mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-09-04 13:36:35 +02:00
207 lines
9.4 KiB
Markdown
207 lines
9.4 KiB
Markdown
# 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 30–39, listener 40–49, service install/start 50–59.
|
||
Each string code owns one exit code. Existing contracts are untouched:
|
||
`ctrld status` 0–3, 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 (0–3 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` 0–3, 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.
|