fix(cli): don't crash after a successful deploy when an output is missing - #375
fix(cli): don't crash after a successful deploy when an output is missing#375so0k wants to merge 2 commits into
Conversation
…sing A user's `cdktn deploy` applied both stacks successfully and then crashed with `TypeError: Cannot convert undefined or null to object`, reported to Sentry (CDKTN-5, cdktn-cli 0.24.0, Linux, node 24.5.0). `getConstructIdsForOutputs` maps synth metadata (`cdk.tf.json` -> `"//".outputs`) onto the real `terraform output -json` result with an unchecked lookup, so an output declared in metadata but absent from terraform's result was retained with value `undefined`. `isObjectEmpty` only drops a group where *every* output is missing, so a partial miss survived. `renderNested` then reached that node: `isTerraformOutput(undefined)` is false, control fell through, and `Object.entries(undefined)` threw. The reported stack has two nested `renderNested` frames, placing the bad value at `outputsByConstructId[stack][constructId]` - depth 1 - which matches. The defect was invisible in the debug logs because those log via `JSON.stringify`, which silently omits undefined-valued keys: the poison map and a clean one serialize identically, so `OutputsByConstructId` looked complete. The data path is unchanged since before v0.23. What changed in v0.24.0 is that #264 removed `renderInk`, whose `try/catch` used to swallow the throw as a one-line message and `exit(1)`. Without it the throw escapes unhandled, so the same latent bug now produces a stack trace, telemetry and a Sentry report. Fixed producer-first, with defence in depth: * `getConstructIdsForOutputs` omits keys whose terraform output is absent and logs which one was dropped. The check is an explicit `=== undefined`, not a truthiness test, so an output whose legitimate value is `""`, `0` or `false` is still rendered. * `renderNested` and `unpackTerraformOutput` skip non-object nodes instead of recursing into `Object.entries`. These are defensive: with the producer fixed there is no known input that reaches them, but both walk externally-sourced data and neither should be able to take down the CLI after a successful deploy. `unpackTerraformOutput` is the one that runs first on the `--outputs-file` path. * `isTerraformOutput` excludes `null`, which previously threw on `null.sensitive` rather than returning false. * Rendering the outputs table is now non-fatal in `runDeploy`. The deploy has already succeeded by that point, so a presentation failure is logged rather than turned into a failed deploy - restoring the safety net that deleting `renderInk` removed. Note `renderNested` now renders nothing for a group whose children all drop, or for a legitimately empty group, where it previously printed a bare header. Refs #361 for the top-level `.fail()` handler, the common exit path that turned this throw into an unhandled rejection with a stack trace. Fixed separately.
Review: PR #375 —
|
| Layer | File | Change |
|---|---|---|
| Type guard | packages/@cdktn/cli-core/src/lib/models/terraform.ts |
+null guard in isTerraformOutput (typeof null === "object" in JS) |
| Output mapping | packages/@cdktn/cli-core/src/lib/output.ts |
getConstructIdsForOutputs omits absent outputs instead of retaining undefined; unpackTerraformOutput guards null/non-object before Object.entries() |
| CLI rendering | packages/cdktn-cli/src/bin/cmds/helper/format.ts |
renderNested/renderOutputs guard null/non-object values + filter empty-node results |
| Deploy command | packages/cdktn-cli/src/bin/cmds/ui/deploy.ts |
Wraps renderOutputs() in try/catch — render failure is now non-fatal |
| Tests | output.test.ts, format.test.ts, deploy.test.ts |
10 new test cases covering the regression path and edge cases |
Configured Checks
| Check | Result |
|---|---|
pnpm exec nx build cdktn |
✅ Pass |
pnpm exec nx test cdktn --runInBand |
✅ 542 passed, 43 suites, 301 snapshots |
Correctness
isTerraformOutputnull guard: Necessary and sufficient —typeof null === "object"is the only falsy value that passes thetypeofgate.getConstructIdsForOutputsmissing-output handling: Correctly omits absent keys. TheisObjectEmptyinteraction correctly drops groups where all children were omitted.unpackTerraformOutputrecursion: Null/typeof guard precedesObject.entries(), preventing crashes at any nesting depth.renderNestedempty-group behavior: Legitimately empty{}groups now print nothing instead of a bare header — safe becausegetConstructIdsForOutputsalready strips empty groups upstream.runDeploytry/catch:stream.stop()infinallyis guaranteed on all paths (success, render-failure, project-failure). Tests validate: no rejection, callback fires, error logged,stop()called.
One adjacent note (not a blocker)
packages/cdktn-cli/src/bin/cmds/ui/output.ts (the cdktn output command) calls renderOutputs(returnValue) without the same try/catch this PR adds to runDeploy. The same missing-output scenario could crash the output command. Pre-existing and out of scope here — worth a follow-up hardening pass.
Artifact-value
Lean, proportional fix. Every line defends against a real crash. No new abstractions, no speculative code.
Ship it. 🚢
|
Thanks for the review. On the adjacent note about Kept out of this PR deliberately: the reporter of the original crash never passed |
|
Traced the analysis against Should the drop be visible? cdk-terrain/packages/@cdktn/cli-core/src/lib/output.ts Lines 244 to 246 in 6e8226d
Would you consider Two smaller things:
|
|
Both good catches, and I'll take the suggestion on the first. Visibility of the drop — yes, You've put your finger on the uncomfortable part: the failure mode this PR produces is "quietly missing an The The stray blank line — real bug, mine.
Both changes incoming; I'll push and re-request. |
…render line getConstructIdsForOutputs only logger.debug'd a metadata-declared output missing from `terraform output -json`, which is invisible without CDKTF_LOG_LEVEL=debug and reproduces the silence that made the original crash hard to diagnose. Warn for user-declared outputs; keep debug for cross-stack-output-* entries, which are generated plumbing and can be numerous per dependent stack. renderOutputs can return "" even when outputsByConstructId still has keys (every child dropped), so gating the deploy summary on key count printed a stray blank line. Gate on the rendered string instead, and treat that case the same as "no outputs" since there is nothing to show the user.
|
Pushed
One judgement call worth flagging, since it's user-visible: when the keys survive but every output under them Note that #376 then changes this case slightly: because the |
| // | ||
| // Cross-stack outputs are generated plumbing for stack dependencies rather than | ||
| // something the user declared directly, and a dependent stack legitimately produces | ||
| // many of them - warning on each missing one would be noise the user cannot act on. |
There was a problem hiding this comment.
I don't really think something being a cross stack output is all that relevant to whether or not a message is logged.
A missing reference could cause incorrect incorrect plans (though that might just result in a Terraform error), or it could be completely benign. The context of what the user is doing matters.
The same seems true for user defined outputs.
|
|
||
| onOutputsRetrieved(outputs); | ||
|
|
||
| if (outputs && Object.keys(outputs).length > 0) { |
There was a problem hiding this comment.
Seems like omitting this check is going to generate a warning that could easily be avoided.
|
|
||
| if (rendered || renderFailed) { | ||
| if (outputsPath) { | ||
| console.log(`The outputs have been written to ${outputsPath}`); |
There was a problem hiding this comment.
If renderFailed, is this actually true?
Related issue
No GitHub issue — this was reported by a user through Sentry (CDKTN-5) on
cdktn-cli 0.24.0, not filed as an issue. Happy to open one retroactively if you'd prefer the paper trail.Related: #361 — the top-level
.fail()handler is the common exit path that turned this throw into an unhandled rejection with a stack trace. Deliberately not fixed here; it deserves its own PR and a regression test.Description
A user's
cdktn deployapplied both stacks successfully and then crashed:Root cause
getConstructIdsForOutputsmaps synth metadata (cdk.tf.json→"//".outputs) onto the realterraform output -jsonresult with an unchecked lookup:An output declared in metadata but absent from terraform's result was retained with value
undefined.isObjectEmptyonly drops a group where every output is missing, so a partial miss survived.renderNestedthen reached that node —
isTerraformOutput(undefined)isfalse, control fell through, andObject.entries(undefined)threw.The two nested
renderNestedframes put the bad value atoutputsByConstructId[stack][constructId], depth 1,which matches. The reporter's
networkstack mixed user outputs with generatedcross-stack-output-*entriesfeeding a dependent
dbstack — a realistic way to get a partial miss (state drift,--skip-synth, an outputrenamed or removed between applies).
Why the debug log looked healthy
The logged
OutputsByConstructIdappeared complete, which initially looked like it contradicted the theory. Itdoesn't: those lines log via
JSON.stringify, which silently omitsundefined-valued keys. The poison map and aclean map serialize byte-identically — verified — while
Object.keysreveals the extra key. The defect wasstructurally invisible in the logs.
Why it surfaced in 0.24.0
The defective data path is unchanged since before v0.23 (
git diff v0.23.4..v0.24.0onoutput.tsis a singleunrelated zod line). What changed is #264, which removed
renderInk— whosetry/catchused to swallow thisthrow as a one-line message and
exit(1). Without it the throw escapes unhandled, so a long-latent bug nowproduces a stack trace, telemetry and a Sentry report. An exposure regression, not a new defect.
The fix — producer first, with defence in depth
getConstructIdsForOutputsomits keys whose terraform output is absent, and logs which one was dropped.The check is an explicit
=== undefined, not a truthiness test, so an output whose legitimate value is"",0orfalsestill renders.renderNestedandunpackTerraformOutputskip non-object nodes instead of recursing intoObject.entries. These are deliberately defensive: with the producer fixed there is no known input thatreaches them, but both walk externally-sourced data and neither should be able to take down the CLI after a
successful deploy.
unpackTerraformOutputis the one that runs first on the--outputs-filepath.isTerraformOutputexcludesnull, which previously threw onnull.sensitiverather than returningfalse.runDeploy. The deploy has already succeeded by thatpoint, so a presentation failure is logged instead of failing the deploy — restoring the safety net that
deleting
renderInkremoved.Behaviour note
renderNestednow renders nothing for a group whose children all drop, and for a legitimately empty group,where it previously printed a bare header.
Verification
The new tests were checked against the unfixed code, not just the fixed code: reverting the source changes
while keeping the tests reproduces the exact reported
TypeError, with the same nestedrenderNestedframes,end-to-end through the real
runDeploy. Assertions usenot.toHavePropertyrather thantoEqual, sincetoEqualtreats a missing key and anundefinedvalue as equal and would pass on the buggy code.Stacked PR
#376 builds on this branch and fixes a separate crash on the
--outputs-filepath found during review ofthis change. It is deliberately split out — the reporter never passed
--outputs-file, so none of it is on thiscrash's path.
Checklist