mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-09-04 13:36:35 +02:00
248 lines
7.9 KiB
Go
248 lines
7.9 KiB
Go
package cli
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
// A terminal provisioning failure reports the same stable code on three
|
|
// surfaces: a persisted result file, one fixed-format output line, and a
|
|
// stage-scoped process exit code. docs/provisioning-failure-codes.md maps
|
|
// each code to its scenario and must stay in sync with the constants below.
|
|
// Codes are append-only once released; renaming or reusing one breaks the
|
|
// support contract.
|
|
|
|
type provisionStage string
|
|
|
|
const (
|
|
provisionStageBootstrap provisionStage = "bootstrap"
|
|
provisionStageListener provisionStage = "listener"
|
|
provisionStageService provisionStage = "service"
|
|
)
|
|
|
|
type provisionFailureCode string
|
|
|
|
const (
|
|
provisionCodeAPIUnreachable provisionFailureCode = "API_UNREACHABLE"
|
|
provisionCodeAPIRejected provisionFailureCode = "API_REJECTED"
|
|
provisionCodeAPIDeviceInvalid provisionFailureCode = "API_DEVICE_INVALID"
|
|
provisionCodeListenerBindFailed provisionFailureCode = "LISTENER_BIND_FAILED"
|
|
provisionCodeListenerAddrUnavail provisionFailureCode = "LISTENER_CONFIGURED_ADDR_UNAVAILABLE"
|
|
provisionCodeServiceInstall provisionFailureCode = "SERVICE_INSTALL_FAILED"
|
|
provisionCodeServiceStartFailed provisionFailureCode = "SERVICE_START_FAILED"
|
|
provisionCodeServiceSelfCheck provisionFailureCode = "SERVICE_SELFCHECK_FAILED"
|
|
)
|
|
|
|
var allProvisionFailureCodes = []provisionFailureCode{
|
|
provisionCodeAPIUnreachable,
|
|
provisionCodeAPIRejected,
|
|
provisionCodeAPIDeviceInvalid,
|
|
provisionCodeListenerBindFailed,
|
|
provisionCodeListenerAddrUnavail,
|
|
provisionCodeServiceInstall,
|
|
provisionCodeServiceStartFailed,
|
|
provisionCodeServiceSelfCheck,
|
|
}
|
|
|
|
var provisionStageForCode = map[provisionFailureCode]provisionStage{
|
|
provisionCodeAPIUnreachable: provisionStageBootstrap,
|
|
provisionCodeAPIRejected: provisionStageBootstrap,
|
|
provisionCodeAPIDeviceInvalid: provisionStageBootstrap,
|
|
provisionCodeListenerBindFailed: provisionStageListener,
|
|
provisionCodeListenerAddrUnavail: provisionStageListener,
|
|
provisionCodeServiceInstall: provisionStageService,
|
|
provisionCodeServiceStartFailed: provisionStageService,
|
|
provisionCodeServiceSelfCheck: provisionStageService,
|
|
}
|
|
|
|
// Exit codes are grouped by stage (bootstrap 30-39, listener 40-49, service
|
|
// 50-59) so the exit code alone names the failed stage. 0-3 belong to
|
|
// "ctrld status" and 126 to the deactivation pin check; never reuse those.
|
|
var provisionExitCodeForCode = map[provisionFailureCode]int{
|
|
provisionCodeAPIUnreachable: 30,
|
|
provisionCodeAPIRejected: 31,
|
|
provisionCodeAPIDeviceInvalid: 32,
|
|
provisionCodeListenerBindFailed: 41,
|
|
provisionCodeListenerAddrUnavail: 42,
|
|
provisionCodeServiceInstall: 51,
|
|
provisionCodeServiceStartFailed: 52,
|
|
provisionCodeServiceSelfCheck: 53,
|
|
}
|
|
|
|
const (
|
|
provisionResultFileName = "provision_result.json"
|
|
// Detail identifies a failure, it is not a log. Caps keep the artifact
|
|
// small and predictable.
|
|
maxProvisionBindAttempts = 12
|
|
maxProvisionStringLen = 256
|
|
)
|
|
|
|
type provisionBindAttempt struct {
|
|
Addr string `json:"addr"`
|
|
Proto string `json:"proto"`
|
|
OSError string `json:"os_error"`
|
|
}
|
|
|
|
type provisionDetail struct {
|
|
Attempts []provisionBindAttempt `json:"attempts,omitempty"`
|
|
}
|
|
|
|
type provisionResult struct {
|
|
Version int `json:"version"`
|
|
Timestamp string `json:"timestamp"`
|
|
Stage string `json:"stage"`
|
|
Code string `json:"code"`
|
|
ExitCode int `json:"exit_code"`
|
|
Message string `json:"message"`
|
|
Detail *provisionDetail `json:"detail,omitempty"`
|
|
}
|
|
|
|
// provisionResultPath is a var so tests can point it at a temp dir.
|
|
var provisionResultPath = func() string {
|
|
return absHomeDir(provisionResultFileName)
|
|
}
|
|
|
|
// provisionExit is a var so tests can observe the exit code instead of dying.
|
|
var provisionExit = os.Exit
|
|
|
|
// newProvisionResult builds a result with every field bounded and the given
|
|
// secrets stripped. The artifact reaches installer logs and support tickets,
|
|
// so callers pass every secret in scope (provision token, cd UID).
|
|
func newProvisionResult(code provisionFailureCode, message string, attempts []provisionBindAttempt, secrets ...string) *provisionResult {
|
|
sanitize := func(s string) string {
|
|
s = redactSecrets(s, secrets...)
|
|
if len(s) > maxProvisionStringLen {
|
|
// Cut on a rune boundary so a localized OS error does not end in
|
|
// a broken multi-byte sequence.
|
|
cut := maxProvisionStringLen
|
|
for cut > 0 && !utf8.RuneStart(s[cut]) {
|
|
cut--
|
|
}
|
|
s = s[:cut]
|
|
}
|
|
return s
|
|
}
|
|
r := &provisionResult{
|
|
Version: 1,
|
|
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
|
Stage: string(provisionStageForCode[code]),
|
|
Code: string(code),
|
|
ExitCode: provisionExitCodeForCode[code],
|
|
Message: sanitize(message),
|
|
}
|
|
if len(attempts) > 0 {
|
|
if len(attempts) > maxProvisionBindAttempts {
|
|
attempts = attempts[:maxProvisionBindAttempts]
|
|
}
|
|
detail := &provisionDetail{Attempts: make([]provisionBindAttempt, 0, len(attempts))}
|
|
for _, a := range attempts {
|
|
detail.Attempts = append(detail.Attempts, provisionBindAttempt{
|
|
Addr: sanitize(a.Addr),
|
|
Proto: sanitize(a.Proto),
|
|
OSError: sanitize(a.OSError),
|
|
})
|
|
}
|
|
r.Detail = detail
|
|
}
|
|
return r
|
|
}
|
|
|
|
// redactSecrets removes every non-empty secret from s.
|
|
func redactSecrets(s string, secrets ...string) string {
|
|
for _, secret := range secrets {
|
|
if secret == "" {
|
|
continue
|
|
}
|
|
s = strings.ReplaceAll(s, secret, "[redacted]")
|
|
}
|
|
return s
|
|
}
|
|
|
|
// provisionResultTrusted rejects a result whose code, stage, or exit code is
|
|
// not part of the known contract, so a corrupt or planted file cannot drive
|
|
// what "ctrld start" logs and exits with.
|
|
func provisionResultTrusted(r *provisionResult) bool {
|
|
code := provisionFailureCode(r.Code)
|
|
stage, ok := provisionStageForCode[code]
|
|
if !ok {
|
|
return false
|
|
}
|
|
return r.Stage == string(stage) && r.ExitCode == provisionExitCodeForCode[code]
|
|
}
|
|
|
|
func (r *provisionResult) failureLine() string {
|
|
return fmt.Sprintf("provisioning failed: stage=%s code=%s (exit %d)", r.Stage, r.Code, r.ExitCode)
|
|
}
|
|
|
|
// writeProvisionResult persists the result atomically (temp file + rename in
|
|
// the same directory) so a reader never sees a partial file.
|
|
func writeProvisionResult(r *provisionResult) error {
|
|
path := provisionResultPath()
|
|
buf, err := json.MarshalIndent(r, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmp, err := os.CreateTemp(filepath.Dir(path), provisionResultFileName+".tmp*")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmpName := tmp.Name()
|
|
if _, err := tmp.Write(buf); err != nil {
|
|
_ = tmp.Close()
|
|
_ = os.Remove(tmpName)
|
|
return err
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
_ = os.Remove(tmpName)
|
|
return err
|
|
}
|
|
if err := os.Chmod(tmpName, 0o600); err != nil {
|
|
_ = os.Remove(tmpName)
|
|
return err
|
|
}
|
|
if err := os.Rename(tmpName, path); err != nil {
|
|
_ = os.Remove(tmpName)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func readProvisionResult() (*provisionResult, error) {
|
|
buf, err := os.ReadFile(provisionResultPath())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
r := &provisionResult{}
|
|
if err := json.Unmarshal(buf, r); err != nil {
|
|
return nil, err
|
|
}
|
|
return r, nil
|
|
}
|
|
|
|
// clearProvisionResult removes a stale result once provisioning succeeds, so
|
|
// support never diagnoses a healthy install from an old failure.
|
|
func clearProvisionResult() {
|
|
if err := os.Remove(provisionResultPath()); err != nil && !os.IsNotExist(err) {
|
|
mainLog.Load().Debug().Err(err).Msg("could not remove provision result file")
|
|
}
|
|
}
|
|
|
|
// failProvision persists the result, prints the identifier line, unblocks a
|
|
// waiting "ctrld start" via notify, then exits with the stage code. The write
|
|
// comes first so the file survives even if logging or notify misbehaves.
|
|
func failProvision(r *provisionResult, notify func()) {
|
|
if err := writeProvisionResult(r); err != nil {
|
|
mainLog.Load().Warn().Err(err).Msg("could not persist provision result")
|
|
}
|
|
mainLog.Load().Error().Msg(r.failureLine())
|
|
if notify != nil {
|
|
notify()
|
|
}
|
|
provisionExit(r.ExitCode)
|
|
}
|