Make container (and every engine's) step output discoverable from the module - #159
Conversation
…om the module
GET /api/project-modules/{id}/logs read DeploymentLog rows, which only the
retry path ever writes. Every engine -- opentofu, container, ansible, ssh,
cli, kubernetes, tmos -- streams its step output into Task.logs. So a
module that had just applied with ten lines of output got
200 {"module_id":215,"total_logs":0,"logs":[]}
which is indistinguishable from "this step produced no output", and sent
an operator to `docker logs` on the host. The task id -- the real handle,
GET /api/tasks/{id} -- was not reachable from /status or /deployments
either; /deployments returned a deployment `id` that LOOKED like the log
handle but was not. Anything driving Forge headlessly diagnoses a failure
through these endpoints, so the empty success actively misleads.
This is the issue's first option, applied to every engine rather than
container only, because the DeploymentLog gap is engine-agnostic:
- /logs: DeploymentLog rows still win when present. Otherwise serve the
module's newest task's logs, with `source: "task"` and `task_id` so the
caller knows where they came from and can follow to /api/tasks/{id}.
`limit` is honoured as a tail; `level` becomes a best-effort filter on
the engines' own markers. A module with nothing at all returns
`source: "none"` plus a hint naming /api/tasks?module_id=, instead of a
silent empty list. Existing fields are unchanged, so the shape is
backward compatible.
- /status: `latest_task_id` -- the live lock holder when a task is running
now, else the newest task row.
- /deployments: `task_id` per row. Deployment has no task_id column, so
create_deployment_record (the one writer all eight engines go through)
records it in meta_data; no migration. Pre-existing rows report null.
None of the three routes declare a response_model, so the OpenAPI spec is
unchanged.
Fixes #154
Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
…g branch Self-review of #159: the DeploymentLog branch has always returned entries newest-first (timestamp.desc()); my task-log fallback returned them in natural (oldest-first) order. A caller treating logs[0] as "most recent" would get a different answer depending on which source happened to serve it. Tail first, then reverse, so both sources honour one contract. The docstring now states the ordering and the three sources explicitly. Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
Self-reviewOne defect found and fixed, plus a ruff catch before push. 🟠 Fixed — the two log sources returned opposite orderingsThe Tail first ( Checked and found correct
Ruff, caught pre-push this time
Scope note, restated for the reviewerThe issue is filed against the container engine; the fix is engine-agnostic because the 24/24 in the route file, 744/0 on the broader sweep, |
…string CI's OpenAPI Spec Freshness check failed on #159. The self-review commit rewrote the /logs route docstring to state the newest-first ordering and the three sources -- and FastAPI emits route docstrings as the operation `description`, so that IS spec content. The PR body's claim that the spec was unchanged because no response_model changed was wrong: docstrings count too. Diff is the one description field in openapi.json and the matching JSDoc in api-generated.ts. No schema or type shape changes. make openapi-check -> OK: backend/openapi.json is up to date Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
CI fix — OpenAPI Spec Freshness
Diffed the regenerated spec against the committed one to be sure that's all it was: Regenerated Lesson I'm carrying forward: "no |
mwiget
left a comment
There was a problem hiding this comment.
The diagnosis is right and the fix is the correct shape: DeploymentLog is written only by the retry path, every engine streams into Task.logs, so the empty-200 was structural rather than "no output". Falling back to the newest task and naming the source is better than silently merging the two — a caller can tell which store it got.
Verified against the code, not just the description:
Deployment.meta_data(models/project.py:358) has no other writer or reader anywhere inbackend/, so stamping{"task_id", "celery_task_id"}there clobbers nothing.Task.module_idexists and is indexed;ProjectModule.holding_task_idis aTask.id(set byEntityLockServicewith the DB task id fromfetch_task_or_raise), solatest_task_idis coherent — preferring the lock holder over the newest row is the right precedence./statushas noresponse_model, solatest_task_idis genuinely additive.- No frontend consumer calls
getModuleLogstoday (only the API wrapper exists), so"timestamp": nullon the task path breaks no UI. - Newest-first on both branches is right and worth having stated in the docstring — the two sources would otherwise disagree on
logs[0].
Three things below, none of them blocking. Not approving yet only because P3 · Integration Tests · Backend is still running — I'll approve once it's green if nothing changes.
| # `id` is the deployment row, NOT the task -- an easy thing to | ||
| # mistake for the log handle (#154). Older rows predate the | ||
| # meta_data backfill and report null. | ||
| "task_id": (dep.meta_data or {}).get("task_id"), |
There was a problem hiding this comment.
The same id-looks-like-the-log-handle confusion this fixes exists one route down: get_project_deployment_history (GET /project/{project_id}/deployments, the "id": dep.id block near line 303 on staging) builds the same dict shape and doesn't get task_id.
An operator on the project-wide view — arguably the more likely entry point when they don't yet know which module failed — still gets a number that isn't the log handle. Same one-line addition, same (dep.meta_data or {}).get("task_id").
| "error": ("ERROR", "✗", "error:", "--- ERROR ---"), | ||
| "warning": ("WARN", "WARNING", "⚠"), | ||
| "success": ("✓", "SUCCESS", "Complete"), | ||
| "info": (), |
There was a problem hiding this comment.
"info": () means wanted is empty, so if wanted: is false and no filter is applied — ?level=info returns every line, errors and warnings included. That's the opposite of what a caller asking for info-only expects, and it's the one level where the parameter silently means nothing rather than meaning something approximate.
Given free-text logs there's no good positive marker for info, so the honest options are: exclude the error/warning markers instead of matching a positive set, or drop info from the dict and let the docstring say that info is unfiltered on this path. Either beats a silent no-op.
| from models import Task as TaskModel | ||
|
|
||
| task = ( | ||
| db.query(TaskModel) |
There was a problem hiding this comment.
Nit, for later rather than now: Task.logs is a deferred() column, so task.logs here triggers a second query that pulls the entire log text into memory purely to splitlines() and tail the last limit lines. For a long apply that's a large TEXT round-trip on every poll of this endpoint.
Not worth restructuring in this PR — correctness first — but if /logs ends up polled by the UI, a server-side tail (or at least load_only-ing the columns you need on the task lookup, since you only use id and logs) would be the next step.
…ywords Review finding (mwiget): merging #158 as it stood would have closed #94 and #128 -- not because of the regex change, but because the PR's own description DOCUMENTS closing keywords in backticks, and the parser reads body text raw. Reproduced against this branch's real parse step with this PR's body as PR_BODY: Parsed closing-keyword issues: ['94', '128', '7'] #94 and #128 are open with their real fixes unmerged in #157/#156; they would have closed with "Auto-closed by PR #158", wrong issue and wrong PR. The old parser had the same blind spot (it read ['94'] from this body); widening the skip tripled the blast radius on a body that talks about the very forms it now accepts. A parser PR is the right place to close the class, not the instance. Fenced blocks are stripped first (they may contain backticks), then inline spans. Re-ran the full matrix through the real step: every real closing line in plain text still closes; every example in backticks or a fence no longer does; a body with both a real "Fixes #94" and a documented "`Fixes #999`" closes only 94. This PR's own body now yields no issues. Then ran EVERY open PR's actual body through the patched step -- the check I should have done the first time: #156 -> 128 #157 -> 94 #159 -> 154 #160 -> 99 #158 -> (none) #161 -> (none, deliberate Refs #79) #135 -> (none) Each PR closes exactly its own issue and nothing else. Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
mwiget
left a comment
There was a problem hiding this comment.
Approving — the full pipeline is green on 59670e0 (25 checks, CI Gate SUCCESS), including the P3 integration run I was waiting on, and the head is unchanged from what I reviewed above.
The three points in my earlier review stand as follow-ups rather than objections:
task_idis still missing from the project-level deployment history (GET /project/{project_id}/deployments), which has the same id-looks-like-the-log-handle confusion this PR fixes on the module-level route.?level=infois a silent no-op on the task path — it returns every line rather than info-only.Task.logsisdeferred(), so the whole log text loads just to taillimitlines.
None of them are worth holding the fix for; the empty-200 was the real problem and it is properly diagnosed and closed. Happy for the first one to land as a one-liner here or in a follow-up, whichever you prefer.
Description
GET /api/project-modules/{id}/logsreadDeploymentLogrows — which only the retry path ever writes. Every engine (opentofu, container, ansible, ssh, cli, kubernetes, tmos) streams its step output intoTask.logs. So a module that had just applied with ten lines of output got:indistinguishable from "this step produced no output", and it sent the reporter to
docker logson the host. The task id — the real handle,GET /api/tasks/{id}— was not reachable from/statusor/deploymentseither;/deploymentsreturned a deploymentidthat looked like the log handle but was not. Anything driving Forge headlessly diagnoses a failure through these endpoints, so the empty success actively misleads.Fixes #154
The issue's first option, but for every engine
The issue frames this as container-specific and offers "return the task logs for container-engine modules." Tracing the writers showed the gap is engine-agnostic —
DeploymentLogis vestigial everywhere, and all eight engines go through the sametask.logspath — so the fix applies uniformly rather than special-casing one engine./logsDeploymentLogrows still win when present. Otherwise serve the newest task's logs withsource: "task"andtask_id, so the caller knows the provenance and can follow to/api/tasks/{id}.limithonoured as a tail;levelbecomes a best-effort filter on the engines' own markers. A module with nothing at all returnssource: "none"plus ahintnaming/api/tasks?module_id=— still200, but no longer silent./statuslatest_task_id— the live lock holder when a task is running now, else the newest task row./deploymentstask_idper row.Deploymenthas notask_idcolumn, socreate_deployment_record(the one writer all eight engines go through) records it inmeta_data. No migration. Pre-existing rows reportnull.All existing fields are unchanged — the shapes are backward compatible, and the one pre-existing test asserting
total_logs == 0, logs == []on an empty module still passes.Why not 404 (the issue's second option)
A 404 from
/logswould break any existing client that treats it as "no logs yet" (the pre-existing test does exactly that). Serving the real logs with provenance is strictly more useful and strictly less breaking.Architectural Decision Record (ADR)
Type of Change
Additive fields only. None of the three routes declare a
response_model, so no schema changes — but the/logsroute docstring is emitted as the OpenAPI operationdescription, soopenapi.jsonandapi-generated.tsare regenerated (JSDoc only; no type shapes change). (Corrected after CI caught the stale spec — my original claim that the spec was untouched was wrong.)Verification & Testing
test_routes_project_deployments.py(+5): task-log fallback withsource/task_id— the reporter's exact scenario;DeploymentLogstill preferred when present; empty module carries the hint;limittails correctly;/deploymentsexposestask_idand tolerates pre-Container step logs are reachable only via /api/tasks — the module log endpoint returns an empty 200 and no task id is exposed #154 rows with nometa_data.test_project_module_service.py(+1):latest_task_idis null → newest task → the lock holder when one is running.test_deployment_record_task_link.py: the shared writer recordstask_id/celery_task_id.deployment/module_status/project_module/logs/task: 744 passed, 0 failed.ruffclean (it caught 3E702one-liners in my tests before push — fixed).Environment validation needed
Light — the behaviour is fully covered, but worth one manual check on a live instance since the reporter was on 3.1.6: apply any container module, then
GET /api/project-modules/{id}/logsshould now return the step output with"source": "task", and/statusshould carry alatest_task_idthat resolves at/api/tasks/{id}.Checklist
/logsdocstring and an inline comment explain theDeploymentLogvsTask.logssplit, which is the non-obvious part.make openapi-types) — the/logsdocstring change lands in the spec as a description.