feat(cli): add stable provisioning failure codes for manual and MDM installs

This commit is contained in:
Anthony Wong
2026-08-21 14:52:02 +07:00
committed by Cuong Manh Le
parent 753d245029
commit 1f001a559a
14 changed files with 1988 additions and 107 deletions
+206
View File
@@ -0,0 +1,206 @@
# SPEC: Stable customer-visible provisioning failure codes
Issue: [#586](https://gitlab.int.windscribe.com/controld/clients/ctrld/-/issues/586)
Requested by: Catt Garrod (@catt). Scope expanded by: Anthony Wong (@anthony).
## 1. Objective
Terminal provisioning failures in ctrld — bootstrap/API setup, listener
binding, and service installation/startup — must produce a stable,
support-facing failure identifier that survives process exit and reaches
both manual CLI users and MDM-driven installs. A customer or admin reports
one code; Support maps it to a scenario and a next action without asking
for reruns or verbose logs.
Motivating incident (v1.5.5, macOS): provisioning reached the Control D
API, then died with only `FTL listener.0 could not find available listen
ip and port`. The per-address UDP/TCP bind errors existed only at Info
level in an in-memory logger and vanished on exit. The macOS pkg
`postinstall` discards ctrld's stdout/stderr entirely and judges success
by plist existence, so nothing useful reached the MDM log.
**Users:** end customers and IT admins reporting failures; Support agents
triaging them; MDM/RMM operators reading installer logs.
### Failure contract (agreed design)
Three surfaces, all carrying the same identifier:
1. **Result file** — on terminal provisioning failure, ctrld writes a
small redacted JSON file (atomic write: temp + rename) in the ctrld
home directory (same base dir as the internal `ctrld.log`,
via `absHomeDir`). Removed/overwritten on later successful
provisioning so stale failures don't mislead. Schema:
```json
{
"version": 1,
"timestamp": "2026-08-18T12:00:00Z",
"stage": "listener",
"code": "LISTENER_BIND_FAILED",
"exit_code": 41,
"message": "could not find available listen ip and port",
"detail": {
"attempts": [
{"addr": "127.0.0.1:53", "proto": "udp", "os_error": "address already in use"}
]
}
}
```
`detail` is bounded (cap recorded bind attempts; cap string lengths)
and redacted by construction: no provisioning tokens, resolver IDs,
config contents, or unrelated host data.
2. **Exit code + final stderr line** — the installer-facing command
(`ctrld start`, and `ctrld run` when run manually in the foreground)
exits with a stage-scoped code and prints one final line containing
the string code and stage, e.g.
`provisioning failed: stage=listener code=LISTENER_BIND_FAILED (exit 41)`.
3. **Installer log (MDM path)** — `scripts/pkg/postinstall` stops
discarding the signal: it captures `ctrld start`'s output to a
private temp file, extracts only the fixed-charset identifier line
(`stage=[a-z]* code=[A-Z_]* (exit [0-9]*)` — structurally unable to
carry the token), and echoes it with the exit code into the
installer log. The result file's `message`/`detail` fields are
deliberately never surfaced there. The plist-existence check remains
the final success gate.
### Identifier format
- **Primary identifier: stable string codes.** Initial set —
bootstrap: `API_UNREACHABLE`, `API_REJECTED`, `API_DEVICE_INVALID`;
listener: `LISTENER_BIND_FAILED`, `LISTENER_CONFIGURED_ADDR_UNAVAILABLE`;
service: `SERVICE_INSTALL_FAILED`, `SERVICE_START_FAILED`,
`SERVICE_SELFCHECK_FAILED`. Codes are append-only; renames are new
codes plus a deprecation note in the mapping doc.
- **Secondary: stage-scoped process exit codes** as a coarse machine
signal: bootstrap 3039, listener 4049, service install/start 5059.
Each string code owns one exit code. Existing contracts are untouched:
`ctrld status` 03, deactivation-pin 126, success 0.
- One underlying failure maps to one code on every path (manual CLI and
MDM), on both branches.
### Propagation (daemon → installer)
The listener/bootstrap fatals fire inside the daemon process
(`ctrld run` under launchd/systemd/SCM), not in `ctrld start`. The
daemon writes the result file before exiting; the existing log-socket
exit notification (`notifyExitToLogServer`) already unblocks `ctrld
start`'s self-check. `ctrld start` then reads the result file, prints
the identifier, and exits with the mapped stage exit code. The daemon's
own exit-status semantics toward service managers are preserved —
in particular the deliberate exit-0 on permanent API rejection that
protects the restart-policy budget; the result file carries the failure
identity in that case.
### Support mapping
`docs/provisioning-failure-codes.md` in this repo: one row per code —
code, stage, exit code, failure scenario, next safe troubleshooting
action or evidence request. Updated in the same MR whenever a code is
added or changed.
### Branch scope
Full implementation on **both** `v1.0` (release line for v1.5.5) and
`master`. The branches diverge heavily (`v1.0`: zerolog fork,
`commands.go`, `service_status.go`, macOS pkg scripts; `master`: zap,
inline commands, no pkg scripts), so this is one shared contract
(codes, exit-code ranges, file schema, doc) implemented twice, as two
MRs referencing #586.
## 2. Commands
- Build: `go build ./...`
- Test: `go test ./cmd/cli/...` (full: `go test ./...`)
- Vet: `go vet ./...`
- Branch workflow: feature branch off `v1.0` for the v1.0 MR; separate
feature branch off `master` for the port MR. Rebase, never merge the
base branch in.
## 3. Project structure
New and touched files on `v1.0` (master port mirrors the same contract
at its equivalent emission points in its `cli.go`):
- `cmd/cli/provision_result.go` (new) — stage + code enums, exit-code
mapping, result-file schema, atomic write/read/clear helpers,
bounded/redacted detail builders. Pattern follows `service_status.go`
(small file: named constants + classifier + dedicated tests).
- `cmd/cli/provision_result_test.go` (new).
- `cmd/cli/cli.go` — emission points: `run()` bootstrap failure branches
(permanent rejection, invalid-device, fatal fetch), and
`tryUpdateListenerConfig` / `tryUpdateListenerConfigIntercept` fatals,
which now record per-attempt `{addr, proto, os_error}` bind detail.
- `cmd/cli/commands.go` — `initStartCmd`: doTasks install/start failures
and the self-check failure branch read the result file, print the
identifier, and exit with the stage code (replacing bare `os.Exit(1)`
on those paths).
- `scripts/pkg/postinstall` — propagate exit code + result-file contents
into the installer log (v1.0 only; master has no pkg scripts).
- `docs/provisioning-failure-codes.md` (new) — support mapping.
## 4. Code style
- Per repo conventions and global rules: guard clauses, small functions,
descriptive names, explicit error handling — never weaken existing
handling (e.g. keep the permanent-rejection exit-0 rationale intact).
- Comments only for non-obvious constraints (e.g. why the daemon must
still exit 0 on permanent rejection), simple-english, self-contained —
no issue/MR references in code.
- Match each branch's logging idiom: zerolog fork on `v1.0`, zap on
`master`. No new dependencies.
- Conventional Commits; MR titles in simple-english; both MRs reference
#586 (release-line MR carries `Closes #586`).
## 5. Testing strategy
Test-first where the harness allows. Coverage required by the issue:
- **Code/mapping unit tests** — every string code maps to exactly one
stage and one in-range exit code; ranges don't collide with existing
contracts (03 status, 126 pin).
- **Result file round-trip** — write/read/clear; atomic write; stale
file removed on success.
- **Redaction** — serialize a result built from inputs containing a
provision token, resolver ID, and config content; assert none appear.
- **Listener bind failure (regression test for the incident)** — occupy
a port, drive the listener-config path to exhaustion, assert the
result records `LISTENER_BIND_FAILED` with attempted address, UDP/TCP
operation, and OS error (`address already in use`-class).
- **Bootstrap failures** — mock API: permanent 4xx → `API_REJECTED`;
invalid-device 40402 → `API_DEVICE_INVALID`; unreachable →
`API_UNREACHABLE`.
- **Service install/start/self-check failures** — injected task
failures assert code selection and `ctrld start` exit code.
- **MDM surface** — shell-level check of `postinstall` failure branch
(result file present → correct log line and exit), aligned with the
existing `test-scripts/` approach; manual pkg verification steps
documented in the MR.
- Both branches: the shared contract tests exist on both; branch-specific
emission tests match each branch's structure.
## 6. Boundaries
**Always:**
- Redact tokens, resolver IDs, config contents, host data from every
customer-visible surface (result file, stderr line, installer log).
- Preserve existing exit-code contracts (`ctrld status` 03, pin 126)
and the daemon's service-manager-facing exit semantics.
- Bound all recorded detail (attempt counts, string lengths).
- Keep codes append-only once merged.
**Ask first:**
- Changing the daemon's (`ctrld run` under a service manager) exit codes
or restart-relevant behavior beyond writing the result file.
- Adding any persisted file outside the ctrld home directory.
- Expanding scope to runtime (post-provisioning) failures — this ticket
owns terminal provisioning failures only.
**Never:**
- Print or persist the provisioning token (the reason postinstall
discards output today — the replacement surface must stay token-free).
- Auto-detect or kill conflicting processes (explicitly out of scope).
- Break `ctrld status`'s documented exit-code contract.