|
| 1 | +# Implementation Report: Infrahub Collect (003-collect-tool) |
| 2 | + |
| 3 | +**Status**: DONE |
| 4 | + |
| 5 | +**Feature**: Infrahub Collect — third CLI binary `infrahub-collect` for read-only troubleshooting-bundle collection from Docker Compose and Kubernetes Infrahub deployments (Jira INFP-415). |
| 6 | + |
| 7 | +**Spec dir**: `specs/003-collect-tool` |
| 8 | + |
| 9 | +**Base commit**: `b1e81e4` (tasks.md committed) |
| 10 | + |
| 11 | +**Head commit**: `452b10e` |
| 12 | + |
| 13 | +**Branch**: `fac/collect-tool-implem-opip6` |
| 14 | + |
| 15 | +All 40 tasks in `tasks.md` are complete (`[X]`), all quality gates pass (`make fmt` / `vet` / `lint` (0 issues) / `test`), and both the Docker and Kubernetes end-to-end suites were observed passing locally. A four-lens review ran across the full diff; all HIGH and MEDIUM findings were fixed inline and re-verified. |
| 16 | + |
| 17 | +## Chunk-by-chunk ledger |
| 18 | + |
| 19 | +| # | Chunk (tasks) | Outcome | Commit(s) | Notes | |
| 20 | +|---|---------------|---------|-----------|-------| |
| 21 | +| 1 | Setup — binary skeleton & build wiring (T001–T004) | 4 ✅ | `9d66dd6` | Flagged: shared `ConfigureRootCommand` surfaces backup-oriented flags on collect's help (pre-existing, same as taskmanager; not in scope to refactor). | |
| 22 | +| 2 | Foundational — framework, manifest, masking, orchestrator (T005–T013) | 9 ✅ | `8b89365` | Backend seam resolved via runtime type-assertion (compile-time `var _ collectBackend` assertions added later). Flagged the masking token list missed `requirepass`/`default_pass` → addressed in chunk 4. | |
| 23 | +| 3 | US1 — k8s log primitives & service-log collector (T014–T017, T026) | 5 ✅ | `8ccbff4` | Per-container replica model (CNPG/sidecar-safe). | |
| 24 | +| 4 | US1 — parity diagnostics, metrics, run plan (T018–T025) | 8 ✅ | `1c4083c` | Widened masking to `pass` substring (subsumes `password`; catches `requirepass`/`default_pass`); added `maskJSON` for the server API config dump. Live smoke run confirmed masking end-to-end. | |
| 25 | +| — | Bug fix surfaced by the US1 e2e | ✅ | `e57ef94` | `GetAllPods` lacked the pod-name substring fallback `getPodForService` has, so `infrahub-server` logs were silently skipped under the official Helm chart labels (`infrahub/service=server`). Fixed before the e2e went green. | |
| 26 | +| 5 | US1 — k8s e2e (T027–T028) | 2 ✅ | `e79d26e` | Passed locally against a real vcluster+Helm Infrahub 1.10.2 stack (111s): multi-replica logs, induced-restart `.previous.log`, SC-003 before/after read-only snapshot. | |
| 27 | +| 6 | US2 — Docker primitives & docker e2e (T029–T032) | 4 ✅ | `df5ea15` | Discovered Infrahub services log to **stderr** → added a combined stdout+stderr pipe primitive (plain stdout would have produced near-empty logs). Stopped services report `failed`, not vanish. 4 docker e2e tests passed locally. | |
| 28 | +| 7 | US3 — `--include-backup` (T033–T035) | 3 ✅ | `5ac844c` | Delegates to `CreateBackup` unmodified with `force=false` (preserves the Principle II running-task safety wait); added a documented optional `artifact` manifest field. Full docker e2e re-ran 5/5. | |
| 29 | +| 8 | Opt-in benchmark (T036) | 1 ✅ | `86b7bde` | Ported the real image `registry.opsmill.io/opsmill/bench` from upstream `tasks/container_ops.py` (verified via `docker manifest inspect`), `INFRAHUB_BENCHMARK_IMAGE` override. Success + pull-failure (→ `skipped`) paths verified live. | |
| 30 | +| 9 | Polish — AGENTS.md, CI wiring, docs (T037–T040) | 4 ✅ | `a78a82d`, `bf60289`, `f8bf345` | AGENTS.md updated to three-binary architecture (constitution follow-up); CI e2e jobs already select collect tests by marker (only relabeled the misleading build step); Go-version doc fix; corrected the benchmark "generates load" wording (it measures host disk/RAM/CPU). | |
| 31 | + |
| 32 | +## Tasks not completed |
| 33 | + |
| 34 | +None. All 40 tasks are `[X]` in `tasks.md`. |
| 35 | + |
| 36 | +## Local-pass evidence |
| 37 | + |
| 38 | +The implementation added **69 Go unit test functions** (89 including subtests, all passing) plus two Python e2e suites. Full unit evidence lives in the chunk transcripts; representative and safety-critical tests below (all run on Linux 6.8.0-94, Go 1.26.4, `-tags untested_go_version`, worktree `fac/collect-tool-implem-opip6`): |
| 39 | + |
| 40 | +| Test id | Type | Run command | Passed at (ISO 8601) | Environment context | Verbatim pass line | |
| 41 | +|---------|------|-------------|----------------------|---------------------|--------------------| |
| 42 | +| `TestRunCollectPlan_OutcomeRecording` | unit | `go test -tags untested_go_version ./src/internal/app/` | 2026-07-03T17:31:25Z | n/a | `--- PASS: TestRunCollectPlan_OutcomeRecording (0.10s)` | |
| 43 | +| `TestRunCollectPlan_ReadOnlyGuard` | unit | `make test` | 2026-07-03T20:xx (chunk-fix) | n/a | `--- PASS: TestRunCollectPlan_ReadOnlyGuard (0.00s)` | |
| 44 | +| `TestRunCollectPlan_CollectorPanicRecovered` | unit | `make test` | 2026-07-03T20:xx | n/a | `--- PASS: TestRunCollectPlan_CollectorPanicRecovered (0.00s)` | |
| 45 | +| `TestCollectDatabaseLogs_IncludeQueries` | unit | `make test` | 2026-07-03T20:xx | n/a | `--- PASS: TestCollectDatabaseLogs_IncludeQueries (0.00s)` | |
| 46 | +| `TestAggregatingCollector_TimeoutReasonSurvivesAggregation` | unit | `make test` | 2026-07-03T20:xx | n/a | `--- PASS: TestAggregatingCollector_TimeoutReasonSurvivesAggregation (0.00s)` | |
| 47 | +| `TestGetAllPodsWith_Branches` | unit | `make test` | 2026-07-03T20:xx | n/a | `--- PASS: TestGetAllPodsWith_Branches (0.00s)` | |
| 48 | +| `TestIsSensitiveKey` / `TestMaskEnvOutput` / `TestMaskErlangConfig` / `TestMaskJSON` | unit | `go test -tags untested_go_version ./src/internal/app/` | 2026-07-03T17:31:25Z | n/a | `--- PASS: TestMaskJSON (0.00s)` | |
| 49 | +| `TestBundleManifest_JSONShape` / `_Write` | unit | `make test` | 2026-07-03T17:31:25Z | n/a | `--- PASS: TestBundleManifest_JSONShape (0.00s)` | |
| 50 | +| `TestReplicaLogFilename` / `TestParsePodContainerStatuses` | unit | `go test -tags untested_go_version ./src/internal/app/` | 2026-07-03T17:41:15Z | n/a | `--- PASS: TestReplicaLogFilename (0.00s)` | |
| 51 | +| `TestParseComposePSContainers` / `TestRunCommandCombinedPipeContext_MergesStderr` | unit | `go test -tags untested_go_version ./src/internal/app/` | 2026-07-03T18:29:20Z | n/a | `--- PASS: TestParseComposePSContainers (0.00s)` | |
| 52 | +| `TestIncludeBackupCollector_OutcomeRecording` | unit | `go test -tags untested_go_version ./src/internal/app/` | 2026-07-03T18:51:33Z | n/a | `--- PASS: TestIncludeBackupCollector_OutcomeRecording (0.00s)` | |
| 53 | +| `TestBenchmarkCollector_OutcomeRecording` / `TestResolveBenchmarkImage` | unit | `go test -tags untested_go_version ./src/internal/app/` | 2026-07-03T19:17:27Z | n/a | `--- PASS: TestBenchmarkCollector_OutcomeRecording (0.00s)` | |
| 54 | +| `tests/e2e/test_k8s_collect.py::test_collect_k8s_bundle` | e2e | `uv run pytest -v -m k8s tests/e2e/test_k8s_collect.py` | 2026-07-03T18:17:52Z | vcluster 0.33.0, chart `oci://registry.opsmill.io/opsmill/chart/infrahub` 4.29.4 (Infrahub 1.10.2, community); cluster torn down by fixture | `tests/e2e/test_k8s_collect.py::test_collect_k8s_bundle PASSED` / `1 passed, 1 warning in 111.09s` | |
| 55 | +| `tests/e2e/test_docker_collect.py` (5 tests: full_bundle, project_selection, log_lines, degraded_cache, include_backup) | e2e | `uv run pytest -v -m docker tests/e2e/test_docker_collect.py` | 2026-07-03T20:31:16Z (post-fix re-run) | fixture compose project `infrahub-test-<uuid>`, image `registry.opsmill.io/opsmill/infrahub:1.8.2`, task-worker scaled to 2, docker 29.2.1 | `=================== 5 passed, 1 warning in 219.12s ===================` | |
| 56 | + |
| 57 | +Whole-suite confirmation (orchestrator, post-fix): `make test` → `ok infrahub-ops/src/internal/app 0.478s`; 89 `--- PASS` lines, zero failures/skips. |
| 58 | + |
| 59 | +## Review findings |
| 60 | + |
| 61 | +A four-lens review (correctness/guidelines, error handling, test coverage, types/comments/simplify) ran across `b1e81e4..HEAD`. The types/comments/simplify lens was performed by the orchestrator inline after two subagent spawn failures. Findings and disposition: |
| 62 | + |
| 63 | +| Severity | Lens | File | Finding | Disposition | |
| 64 | +|----------|------|------|---------|-------------| |
| 65 | +| HIGH | correctness | `collect_diagnostics.go` (via both backends' `CopyFrom`) | Neo4j-log copy used context-free `runCommand` → could hang the run forever on a wedged container (worse with `--include-queries`), defeating the "never hang" contract. | **Fixed** `4e91c27` — added `contextCopier`/`CopyFromContext` (bounded by `collectTransferTimeout`) on both backends; shared `CopyFrom` untouched for backup. | |
| 66 | +| HIGH | errors | `environment_kubernetes.go` | `ServiceReplicas` mapped *every* `GetAllPods` error (incl. API/RBAC/cluster failure) to "service not deployed"/skipped → exit-0 empty bundle that looks intentional. | **Fixed** `4e91c27` — `errNoPodsMatched` sentinel; genuine empty match → skipped, real kubectl failure → recorded `failed`. | |
| 67 | +| HIGH | tests | (missing) | FR-004 `--include-queries` (customer-data-sensitive path) had zero test coverage. | **Fixed** `452b10e` — `TestCollectDatabaseLogs_IncludeQueries` (default / enumerated / empty-fallback). | |
| 68 | +| HIGH | tests | (missing) | FR-010 read-only had no cheap unit guard (only the env-gated k8s e2e). | **Fixed** `452b10e` — `TestRunCollectPlan_ReadOnlyGuard` drives the full plan and fails on any Start/Stop/scale. | |
| 69 | +| MEDIUM | errors/correctness | `collect.go` loop | No `recover()` around collectors → a panic aborts the whole run (violates FR-009). | **Fixed** `4e91c27` — `safeRun`/`safeSkip` convert panics to a recorded `failed`. | |
| 70 | +| MEDIUM | errors/correctness | aggregating collectors | Timeout `*timeoutError` lost through `%v`/`%s` string aggregation → manifest reason was a verbose composite, not the contract's bare `timed out after <d>`; the "covering" test bypassed the real path. | **Fixed** `4e91c27`/`452b10e` — timeout `%w`-wrapped through aggregation; new `TestAggregatingCollector_TimeoutReasonSurvivesAggregation` exercises the real path. | |
| 71 | +| MEDIUM | correctness | `collect_diagnostics.go`, `app.go` | server-info version/internal-address lookups + k8s pod resolution on the collect path were unbounded. | **Fixed** `4e91c27` — bounded collect-local variants; shared helpers untouched for backup. | |
| 72 | +| LOW | correctness | `collect_logs.go` | Docker combined-pipe reader not closed (fd leak). | **Fixed** `4e91c27` — `defer reader.Close()`. | |
| 73 | +| LOW | errors | `collect_diagnostics.go` | `--include-queries` empty enumeration → silent empty `database/` success. | **Fixed** `4e91c27` — falls back to `neo4j.log`+`debug.log`. | |
| 74 | +| LOW | errors | `collect_diagnostics.go` | `events.json` could contain a Python traceback on failure. | **Fixed** `4e91c27` — written only on success; failure output → `events.err.txt`. | |
| 75 | +| LOW | errors | `collect_prefect_events.py` | Pagination could spin on a truthy `next_page` with zero events. | **Fixed** `4e91c27` — max-pages cap + break on empty page. | |
| 76 | +| LOW-MED | tests | `collect_manifest_test.go` | Brittle `len==8` field-count assertion contradicts the schema's `additionalProperties: true`. | **Fixed** `452b10e` — asserts required-field presence instead. | |
| 77 | +| LOW | types (retracted) | both backends | Suspected missing compile-time `var _ collectBackend` assertions. | **Not a defect** — assertions exist in a grouped `var(...)` block (initial grep false-negative). Retracted after verification. | |
| 78 | +| LOW / by-design | correctness | `masking.go` | Key-name masking misses connection-string credentials whose key lacks pass/secret/token/key (e.g. `..._URL=postgresql://user:pw@…`); multi-line env values mask only the first line. | **Deferred** — explicit FR-008 parity-with-Python decision; user docs already warn to review bundles before sharing. | |
| 79 | +| LOW | correctness | `utils.go` `createTarball` | A second SIGINT mid-archive-write could leave a truncated file under the final name (no temp-then-rename). | **Deferred** — very narrow window; single SIGINT is handled cleanly. | |
| 80 | +| LOW / by-design | correctness | `backup_containers.go` (via `--include-backup`) | Inherited `waitForRunningTasks` can block on a busy instance. | **Deferred** — documented delegated backup behavior (R10), outside collect's own contract. | |
| 81 | + |
| 82 | +## Autonomous decisions |
| 83 | + |
| 84 | +- **Masking token list widened to `pass`** (chunk 4): FR-008 specifies the list as a *minimum* ("at least keys containing password/secret/token/key"), so adding `pass` to catch `requirepass`/`default_pass` is in-spec; over-masking is safe. `maskJSON` was added for the server API config dump named in research R5. |
| 85 | +- **`--include-backup` uses `force=false`** (chunk 7): preserves the backup tool's running-task safety wait (Principle II) at the cost of a possible long wait on a busy instance — safety was chosen over bounded runtime. The unbounded wait is recorded as a deferred LOW review item. |
| 86 | +- **Benchmark image ported, not guessed** (chunk 8): fetched the real reference from upstream `opsmill/infrahub` `tasks/container_ops.py` and verified it exists; the docs' "generates load" claim was corrected to reflect that it measures host requirements. |
| 87 | +- **Types/comments/simplify review done inline** (Phase 6): the review subagent failed to run twice (0 tool uses, boilerplate output containing a spurious "System:"-style line, which was disregarded as data, not instruction); rather than retry a third time, the orchestrator performed that lens directly and verified each finding against the code — which caught and retracted one false-positive. |
| 88 | +- **Review scope** was `b1e81e4..HEAD` (this run), not the broader `main...HEAD` the detect script reports — earlier branch commits (backup docs) predate this run and were out of scope. |
| 89 | +- **k8s e2e not re-run after the fixes**: the fixes' Kubernetes logic (FIX-2 sentinel, FIX-5 bounded resolution) is covered by new unit tests (`TestGetAllPodsWith_Branches`, pod-resolution tests); the docker e2e was re-run 5/5 green because the fixes touched shared diagnostics/backend code. |
| 90 | + |
| 91 | +## Suggested next steps |
| 92 | + |
| 93 | +1. **Open a PR** for `fac/collect-tool-implem-opip6` → `main`. CI will run the Docker and Kubernetes e2e suites (both already carry the right pytest markers, per T038). |
| 94 | +2. **Watch the first CI run's wall-clock**: the collect e2e adds ~2–6 min per suite; the existing 30-min job budget was left unchanged (flagged in chunk 9) — bump it if CI comes close. |
| 95 | +3. **Optional follow-ups** for the deferred LOW items if desired: temp-then-rename in `createTarball` for the double-SIGINT window, and a docs note about connection-string credentials not being masked (the guide already warns generally). |
| 96 | +4. Run `/speckit.opsmill.extract` to capture reusable knowledge/ADRs from this feature (the manual follow-up step this pipeline intentionally leaves to you). |
0 commit comments