mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-09-04 13:36:35 +02:00
214 lines
10 KiB
Markdown
214 lines
10 KiB
Markdown
# Plan: provisioning failure codes (issue #586)
|
||
|
||
Spec: SPEC.md. Baseline: `ac0e6aed` on `v1.0`.
|
||
Branches: `issue-586` (off `v1.0`), `issue-586-master` (off `master`).
|
||
Two MRs, both referencing #586; the `v1.0` MR carries `Closes #586`.
|
||
|
||
## Shared contract (fixed here so parallel tasks cannot diverge)
|
||
|
||
| Code | Stage | Exit |
|
||
|---|---|---|
|
||
| `API_UNREACHABLE` | bootstrap | 30 |
|
||
| `API_REJECTED` | bootstrap | 31 |
|
||
| `API_DEVICE_INVALID` | bootstrap | 32 |
|
||
| `LISTENER_BIND_FAILED` | listener | 41 |
|
||
| `LISTENER_CONFIGURED_ADDR_UNAVAILABLE` | listener | 42 |
|
||
| `SERVICE_INSTALL_FAILED` | service | 51 |
|
||
| `SERVICE_START_FAILED` | service | 52 |
|
||
| `SERVICE_SELFCHECK_FAILED` | service | 53 |
|
||
|
||
- Result file: `provision_result.json` in the ctrld home dir (same
|
||
resolution as the persisted internal log: `absHomeDir` on v1.0, the
|
||
`userHomeDir`-based equivalent on master). Atomic write (temp +
|
||
rename in the same dir). Cleared when provisioning succeeds.
|
||
- Result schema (version 1): `version`, `timestamp` (RFC3339, UTC),
|
||
`stage`, `code`, `exit_code`, `message`, optional `detail.attempts[]`
|
||
of `{addr, proto, os_error}`; attempts capped at 12 entries, every
|
||
string capped at 256 chars.
|
||
- Identifier line, exact format (greppable, token-free by
|
||
construction): `provisioning failed: stage=<stage> code=<CODE> (exit <N>)`.
|
||
- Redaction: results are built through a constructor that takes the
|
||
secrets in scope (cd UID, provision token) and strips them from every
|
||
field. Messages come from our own summaries plus OS error strings,
|
||
never raw config or API bodies.
|
||
- Exit seam: `provisionExit = os.Exit` package var so tests can stub
|
||
process exit. Emission helper `failProvision(...)` writes the file,
|
||
logs the identifier line, calls the notify func, then exits with the
|
||
stage code. Nonzero exit is preserved everywhere the daemon exits
|
||
nonzero today; the deliberate clean return on permanent API rejection
|
||
stays a clean return (result file only).
|
||
|
||
## Dependency graph
|
||
|
||
```
|
||
A1 (contract module + doc, v1.0) D1 (master port)
|
||
├─► B1 daemon emissions (cli.go) depends on: contract table
|
||
├─► B2 start-side (commands.go+service.go) (from A1) + C1 verified
|
||
└─► B3 postinstall (scripts, tests) implementation as reference
|
||
└─► C1 v1.0 checkpoint ────────────► D1 ─► E1 final checkpoint
|
||
```
|
||
|
||
## Group A — serial, runs inline (1 task)
|
||
|
||
### A1. Contract foundation on `issue-586`
|
||
Create branch `issue-586` from `v1.0`. New files:
|
||
`cmd/cli/provision_result.go`, `cmd/cli/provision_result_test.go`,
|
||
`docs/provisioning-failure-codes.md`.
|
||
|
||
Module contents: stage type + the 8 code constants + exit-code map;
|
||
`ProvisionResult` struct per schema; bounded/redacting constructor;
|
||
atomic `writeProvisionResult` / `readProvisionResult` /
|
||
`clearProvisionResult`; identifier-line formatter; `provisionExit`
|
||
seam; `failProvision` helper. Doc: full table — code, stage, exit,
|
||
failure scenario, next safe troubleshooting action / evidence request.
|
||
|
||
Tests (RED first): every code maps to exactly one stage and one
|
||
in-range exit code (30–39/40–49/50–59); no collision with 0–3
|
||
(`ctrld status`) or 126 (pin); file round-trip; atomic overwrite;
|
||
clear; redaction (a result built from inputs containing a fake token
|
||
and cd UID serializes without them); attempts/string caps enforced;
|
||
identifier line matches the exact format.
|
||
|
||
Acceptance: `go build ./...` and `go test ./cmd/cli/` green; doc rows
|
||
exactly match the constants.
|
||
|
||
## Group B — parallel Workflow fan-out, one subagent per task, worktree isolation, branched from `issue-586` after A1
|
||
|
||
### B1. Daemon emissions in `cli.go`
|
||
- Bootstrap branches in `run()` (`cli.go:339-372`):
|
||
- permanent rejection (`permanentAPIRejection`): write `API_REJECTED`
|
||
result (HTTP status + our own summary, no raw API body), keep the
|
||
existing clean return and its comment.
|
||
- invalid device (`controld.InvalidConfigCode`): write
|
||
`API_DEVICE_INVALID` before `uninstallInvalidCdUID`.
|
||
- fatal fetch: write `API_UNREACHABLE`, replace
|
||
`cdLogger.Fatal()` with error log + identifier line +
|
||
`failProvision` exit 30 (still nonzero for the service manager).
|
||
- Listener (`tryUpdateListenerConfig`, `tryUpdateListenerConfigIntercept`):
|
||
- record every failed bind attempt `{addr, proto, os_error}` —
|
||
capture UDP and TCP errors separately in `tryListen` (keep
|
||
`errors.Join` for control flow), cap per contract.
|
||
- exhaustion fatal (`cli.go:1639`) and converged-random fatal
|
||
(`cli.go:1720`) → `LISTENER_BIND_FAILED` exit 41 with attempts.
|
||
- no-fallback-allowed fatal (`cli.go:1652`) and intercept-mode fatals
|
||
(`cli.go:1452,1467`) → `LISTENER_CONFIGURED_ADDR_UNAVAILABLE`
|
||
exit 42 (fallback-exhausted intercept fatal stays
|
||
`LISTENER_BIND_FAILED`).
|
||
- all fatals keep calling the notify func first; final message
|
||
includes the code string.
|
||
- Clear the result file at the point provisioning is known good
|
||
(after `updateListenerConfig` succeeds in `run()`).
|
||
|
||
Tests (RED first): occupy a UDP+TCP port, drive the listener path to
|
||
exhaustion with the exit seam stubbed, assert the result file has
|
||
`LISTENER_BIND_FAILED`, the attempted address, both protocols'
|
||
`os_error` (`address already in use` class); pure mapping test
|
||
API error → code (permanent 4xx → `API_REJECTED`, 40402 →
|
||
`API_DEVICE_INVALID`, network error → `API_UNREACHABLE`) following
|
||
`cli_preflight_test.go` patterns.
|
||
|
||
Acceptance: only `cmd/cli/cli.go` + new/extended tests touched;
|
||
`go build ./... && go test ./cmd/cli/` green.
|
||
|
||
### B2. `ctrld start` reporting in `commands.go` + `service.go`
|
||
- Add `doTasksE` (returns failed task name + error; `doTasks` keeps
|
||
its signature and delegates).
|
||
- Fresh-install path (`commands.go:618`): failed `Install` task →
|
||
`SERVICE_INSTALL_FAILED` (write result, print identifier line, exit
|
||
51); failed `Start` task → `SERVICE_START_FAILED` (exit 52). This
|
||
fixes the current fall-through that exits 0 on install failure.
|
||
- Existing-service path (`commands.go:528-543`): failure → 52 with the
|
||
same reporting (replaces bare `os.Exit(1)`).
|
||
- Self-check failure branch (`commands.go:627-664`): keep the log
|
||
drain and `uninstall(p, s)`; then read the daemon's result file —
|
||
if present and stamped after this start attempt began, report its
|
||
stage/code/exit (daemon identity wins: e.g. `LISTENER_BIND_FAILED`);
|
||
otherwise write and report `SERVICE_SELFCHECK_FAILED` exit 53.
|
||
Extract this into a testable helper (fabricated result files +
|
||
stubbed exit seam).
|
||
- On successful start (self-check ok), clear any stale result file.
|
||
|
||
Tests (RED first): `doTasksE` failure attribution; helper precedence
|
||
(fresh daemon result wins; stale/missing falls back to 53); exit-code
|
||
selection per failed task.
|
||
|
||
Acceptance: only `cmd/cli/commands.go`, `cmd/cli/service.go` + tests
|
||
touched; build and package tests green.
|
||
|
||
### B3. postinstall MDM surface
|
||
- `scripts/pkg/postinstall`: capture `ctrld start` output to a
|
||
`mktemp` file (chmod 600) instead of `/dev/null`; keep the plist
|
||
check as the success gate; on failure, `grep -m1 '^provisioning
|
||
failed: '` from the capture into the install log together with the
|
||
exit code; delete the capture file always; never echo any other
|
||
output line (token safety preserved by extracting only the
|
||
fixed-format line).
|
||
- Shell test `test-scripts/darwin/test-postinstall-provision-failure.sh`
|
||
(matching existing script conventions): stub `$CTRLD` that prints a
|
||
fake token plus a valid identifier line and exits 41; assert the
|
||
logged output contains stage/code/exit and not the token; assert
|
||
success path unchanged. Runs without root.
|
||
- Update `docs/macos-pkg-mdm.md` where it documents the discard
|
||
behavior/failure triage, and add the failure-code doc link.
|
||
|
||
Acceptance: shell test passes locally (`sh test-scripts/darwin/...`);
|
||
only `scripts/pkg/postinstall`, `test-scripts/darwin/`, `docs/`
|
||
touched.
|
||
|
||
## Checkpoint C1 — serial, after Group B merges
|
||
|
||
Merge order: B1, B2, B3 into `issue-586`. Then: `go build ./...`,
|
||
`go vet ./...`, `go test ./cmd/cli/...` (and full `./...`), run the B3
|
||
shell test, verify doc table == constants, and verify each spec
|
||
acceptance criterion has an implementation + test. Fix-forward any
|
||
merge fallout before Group D starts.
|
||
|
||
## Group D — serial (1 task, own worktree off `master`)
|
||
|
||
### D1. Master port on `issue-586-master`
|
||
Create `git worktree` with branch `issue-586-master` from
|
||
`origin/master`/`master`. Port with the v1.0 implementation as
|
||
reference, adapted to master's structure (zap-shaped logging idiom,
|
||
no `commands.go`):
|
||
|
||
- `cmd/cli/provision_result.go` + tests: identical contract table.
|
||
- Bootstrap: `run()` branches at master `cli.go:340-374` (same
|
||
permanent-rejection clean return, invalid-device, fatal fetch).
|
||
- Listener: `tryUpdateListenerConfig` fatals at master
|
||
`cli.go:1657/1667/1719`; intercept variant at `cli.go:1487/1502`;
|
||
per-attempt capture (bind errors currently logged at Debug,
|
||
`cli.go:1665`).
|
||
- Start side: `commands_service_start.go` — both `doTasks` call sites,
|
||
self-check `default:` arm (`os.Exit(1)` ~line 370), same fall-through
|
||
audit, same precedence logic; `doTasksE` in `service.go`.
|
||
- `docs/provisioning-failure-codes.md`: same table (omit
|
||
pkg/postinstall-specific notes; master has no `scripts/pkg`).
|
||
- No postinstall work on master.
|
||
|
||
Tests mirrored from v1.0 where the structure allows.
|
||
|
||
Acceptance: in the master worktree, `go build ./...`,
|
||
`go test ./cmd/cli/...` green; constants table semantically identical
|
||
to `issue-586`.
|
||
|
||
## Checkpoint E1 — serial, final
|
||
|
||
- Cross-branch contract equality: compare code constants, exit codes,
|
||
identifier-line format, result schema between the two branches.
|
||
- Full test suites on both branches.
|
||
- Both branches committed (per-task Conventional Commits); no pushes,
|
||
no MRs yet — `/draft-review` is the next pipeline step.
|
||
|
||
## Execution notes
|
||
|
||
- Each parallel group runs as one Workflow fan-out, one subagent per
|
||
task, `isolation: 'worktree'` so parallel edits never conflict;
|
||
serial tasks (A1, C1, D1, E1) run inline (D1 manages its own
|
||
master-based worktree).
|
||
- Every subagent follows RED → GREEN → regression → build and commits
|
||
in its worktree; the orchestrator merges in dependency order and
|
||
runs the full suite before the next group.
|
||
- Subagent prompts are self-contained: they carry the contract table
|
||
and file anchors from this plan, not references to SPEC.md (worktree
|
||
copies may not include untracked files).
|