Files
ctrld/tasks/plan.md
T

214 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 (3039/4049/5059); no collision with 03
(`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).