cdkd drift
cdkd drift [<stack>...] detects drift between cdkd's S3 state
and the live AWS-side configuration of each managed resource. cdkd does
not go through CloudFormation, so CFn-style drift detection does not
apply — instead, the command asks each resource's provider for its
readCurrentState snapshot and compares it against the deploy-time
AWS snapshot stored in ResourceState.observedProperties (state
schema version: 3+). Resources written by an older binary or by a
provider without readCurrentState lack observedProperties — for
those, the comparator falls back to the user-templated properties
field (the pre-v3 behavior). The observed-baseline path is what makes
console-side changes to keys the user did not template surface as
drift; the fallback only catches changes to keys the user did template.
One carve-out on the observed-baseline path: a top-level key the
template never declared whose captured value was EMPTY ([] / {} /
null) is skipped entirely. Such keys are typically materialized AFTER
the capture by a sibling resource in the same stack
(AWS::ECS::ClusterCapacityProviderAssociations populating the
cluster's CapacityProviders, a standalone
AWS::AutoScaling::LifecycleHook populating the ASG's hook list,
standalone security-group ingress/egress rules) or by AWS itself, so
comparing them reported permanent phantom drift on a freshly deployed
stack — and --revert then stripped that sibling-managed configuration
from AWS. CloudFormation's drift detection only compares
template-declared properties, so this matches its behavior for that
class; an undeclared key captured with a real value (an AWS-side
default) is still compared.
See docs/state-management.md for the schema
details.
Detection is the default behavior — pass --accept or --revert to
also resolve any drift the comparator finds (see "Resolving drift" below).
# Single stack — auto-selects when state has exactly one stack
cdkd drift
# Single stack by name
cdkd drift MyStack
# Every stack in the bucket
cdkd drift --all
# Disambiguate when the same stack name has state in multiple regions
cdkd drift MyStack --stack-region us-east-1
# Machine-readable output for CI gating
cdkd drift --all --json
# Resolve drift: state ← AWS (catch up cdkd state with manual console changes)
cdkd drift MyStack --accept --yes
# Resolve drift: AWS ← state (push cdkd state values back into AWS)
cdkd drift MyStack --revert --yes
# Preview either resolution without acquiring a lock or hitting AWS
cdkd drift MyStack --accept --dry-run
cdkd drift MyStack --revert --dry-run
Flags:
<stacks...>— zero or more positional stack names (physical CloudFormation names). When omitted and--allis not set, the command auto-selects the single stack in state (mirrorscdkd deploy/cdkd destroy); fails with a listing if state has more than one stack.--all— drift-check every stack in the state bucket.--stack-region <region>— region to inspect when a stackName has state in multiple regions (mirrorscdkd state show).--json— emit a structured per-stack report (see below) on stdout, and nothing else. The resolution paths still print their plain-text plan, prompt and summary, but under--jsonthose go to stderr — see "Streams under--json" below.--accept— write the AWS-current values back into cdkd state (state ← AWS) for every drifted property. By default this updatesobservedProperties(the deploy-time snapshot used as the drift baseline) so the next drift run reports clean, while leavingproperties(the user's last-deployed template intent) untouched. For resources withoutobservedProperties(older state, providers withoutreadCurrentState) the mutation falls back toproperties, matching the pre-v3 behavior. Requires a stack lock. Mutually exclusive with--revert. See "Resolving drift" below.--revert— callprovider.updateto push cdkd state values back into AWS (AWS ← state) for every drifted resource. The values passed toprovider.updateare constructed as the AWS-current snapshot with the drifted top-level subtrees overlaid fromobservedProperties ?? properties— same precedence as the comparator, so--revertundoes exactly the deltacdkd driftreported and leaves non-drifted attributes untouched. One exception: a drifted top-level tag list keeps any AWS-SERVICE-authored entry instead of stripping it. Every ordinary tag still reverts exactly as before — one the baseline lost is re-added, a changed value is reset, and a user- or console-added tag AWS alone carries is still REMOVED — but a service-managed key (AmazonECSManaged, or anyaws:-reserved prefix) survives. ECS attachesAmazonECSManagedto an ASG when a capacity provider binds it and managed scaling breaks without it, so the previous strip-everything revert silently broke live resources. The--revertplan names each preserved key before the confirmation prompt. The carve-out applies to a top-level property NAMEDTags(or ending inTags) — the[{Key, Value}]shape alone is not tag-exclusive, e.g.LoadBalancerAttributes— and it applies even when the recorded baseline list is EMPTY, since a declared-but-emptyTagsstill has an AWS side worth diffing. A tag list NESTED inside another property (an EC2 launch template'sTagSpecifications) still reverts wholesale.The plan also names the AWS-authored values the revert leaves alone. A revert overwrites each drifted top-level subtree from
observedProperties ?? properties, so when a resource has NOobservedProperties(older state, or a deploy-time capture that failed) the desired side is the raw template — and anything AWS wrote into that subtree but the template never declared is indistinguishable from an out-of-band change. cdkd preserves those paths and lists them before the confirmation prompt. Both shapes are covered: nested object keys report dotted (Parameters.table_type), and a KEYED[{Key, Value}]list reports its missing entries in bracket form (LoadBalancerAttributes[deletion_protection.enabled]) — the bracket distinguishes a list entry from a nested path, since attribute keys contain dots of their own. That keyed-list half was a later addition: the walk previously skipped every array, which is exactly the shape with the most at stake, so a revert against an ELBv2 resource could touch ~18 untemplated attributes while the plan named none. A service-authored tag is NOT listed — the carve-out above preserves it either way. A POSITIONAL array is still compared wholesale, because its elements have positions rather than identities, so it is neither reported nor narrowed.--revertnormally leaves cdkd state alone — once the update succeeds, AWS matches state by definition. The ONE exception is a provider that reports a narrowing: some providers answerupdate()with the bag they ACTUALLY sent, because AWS only accepts a narrower form than the template declares (AWS::EC2::Route's single destination, anAWS::EC2::SecurityGroupIngressIpProtocolcoerced to a string). The deploy path already records that narrowed value;--revertnow does the same, writing ONLY the keys the provider changed into the same field the comparator uses as its baseline (observedPropertieswhen the resource has one, elseproperties). Without it the nextcdkd driftreported the identical difference and--revertre-issued the identical call, forever. Only the provider-changed keys move — the AWS-current values that rode along in the bag sent toprovider.updateare NOT imported into state, so--revertnever behaves like--accept. Two further limits keep that guarantee airtight: only a key the baseline ALREADY declares can move (a provider echoing back an out-of-band, AWS-only key cannot insert it), and on a resource with noobservedPropertiesonly a key REMOVAL is recorded, never a value — that baseline is the raw template, the values sent to the provider deliberately carry the untemplated AWS paths described above, and writing one intopropertieswould make the template intent describe AWS-side data. That write is BEST-EFFORT: AWS has already been reverted by the time it runs, so a failed state write warns and the command carries on (under--all, aborting would skip every later stack's revert). The only cost of the warn path is that the narrowing re-surfaces on the nextcdkd drift.Requires a stack lock. Mutually exclusive with
--accept. See "Resolving drift" below.--dry-run— for--accept/--revert: print the planned mutations and exit without acquiring a lock or hitting AWS / S3.--concurrency <number>— maximum concurrentprovider.updatecalls during--revert(default4). No effect on--accept(writes are serialized per stack).-y/--yes— skip the confirmation prompt before writing state (--accept) or pushing changes back to AWS (--revert).--state-bucket,--state-prefix,--profile,--verbose,--role-arn,--region— same as on every other state-driven command.--regionis deprecated (preferAWS_REGION/ your AWS profile) but still honored if passed.
Exit codes:
| Exit | Meaning |
|---|---|
0 (detection) |
Every inspected stack has zero drift and cdkd left no comparison incomplete (nothing refused, and no read or comparison failed). Note a resource can be reported under notCompared and the run still exit 0 — that happens when the only reason it was not compared is a {{resolve:...}} spelling cdkd never resolves (see the 2 (detection) row). |
0 (--accept / --revert) |
The remediation run completed: it resolved every drift cleanly, or there was no drift to resolve. Unlike the detection row, this does NOT mean every comparison completed — the remediation modes keep their documented exit codes, so a run whose reads were refused or failed still exits 0 (the 2 exit is scoped to detection-only mode deliberately; changing it would alter what a remediation run means). It says so in WORDS instead: a remediation run that found no drift but could not compare everything prints Comparison INCOMPLETE — nothing to accept/revert, and that is NOT a clean bill of health, names how many of the stack's resources were not compared and why, splits them into the ones cdkd is genuinely uncertain about (a failed read, a refused or unresolvable dynamic reference) and the ones it never drift-checks by design (Custom::*, and types no provider reads back yet — reported without any uncertainty claim), and points at the detection-only run — which DOES report it as exit 2. Such resources are never touched by --accept / --revert (both act on drifted resources only), so nothing is written on the strength of a comparison that did not happen. --accept likewise exits 0 when it deliberately REFUSED a secret-bearing property whose AWS-current value it could not identify (see "Secret dynamic references" above) — the refusal is warned about by name and the drift is still reported on the next run. If you gate CI on "everything was actually compared", run cdkd drift without --accept / --revert and read its exit code, or read --json's notCompared[].cause. |
1 |
Drift detected on at least one resource on at least one stack (detection-only mode), OR the command crashed (no state found, AWS error, bad arguments). Both go through the default error handler — drift detection emits the rich human report before throwing, so the report is the only output for the drift case. Drift OUTRANKS a partial comparison: a run that both detects drift and leaves something uncompared exits 1, not 2. |
2 (detection) |
Nothing drifted, but at least one resource's comparison did not happen for a reason you can act on. Two causes produce it. (a) cdkd deliberately REFUSED to compare a resource: a dynamic reference its state records could not be attributed to a region, so its secret-bearing properties were not compared. (b) the read or comparison FAILED for a resource — an SDK or Cloud Control readback that rejected (a least-privilege role, a throttle), or a comparison that threw on a provider-authored bag. Previously that second cause did not produce an exit code at all: it aborted the whole run with exit 1, leaving every other resource in the stack unchecked while reporting the same code that means "drift detected", so a CI gate could not tell the two apart. Reporting that run as 0 would be a clean bill of health for a comparison that did not happen — and the refused population likewise used to exit 1, because cdkd resolved the reference in the wrong region and reported phantom drift, so a non-zero exit is what CI consumers already had. This is narrower than notCompared, deliberately. A resource whose only uncompared properties hold a surviving {{resolve:ssm-secure:...}} token is listed under notCompared and in the report's not-fully-compared block, but does not produce this exit code: cdkd has never resolved that spelling, so the condition is permanent and unclearable by any action you can take, and exiting non-zero for it would fail such a stack's CI forever over something unrelated. A type Cloud Control has no READ handler for is excluded on the same grounds and reports drift unknown instead, whether the fallback signals it by returning nothing or by throwing UnsupportedActionException. Fix a refusal by spelling the reference as a full ARN, which names its region; fix a read failure by granting the missing permission or re-running. Use --json and read each notCompared entry's cause to tell them apart per resource. |
2 (--revert) |
--revert finished but one or more resources did not revert (PartialFailureError): a provider.update call failed, threw ResourceUpdateNotSupportedError, or — counted and reported separately, since it never reached provider.update at all — cdkd could not re-resolve the dynamic reference(s) the resource's state records (grant the caller secretsmanager:GetSecretValue / ssm:GetParameter, or fix the reference). That same counter also covers a resource --revert REFUSED because its recorded baseline holds only the redaction mask and AWS reports nothing to preserve there (force that custom resource to update and re-deploy, so its handler supplies the value again; an ordinary re-deploy leaves it unchanged). Successful resources are now in sync; re-run cdkd drift <stack> to see what's left, then either cdkd drift <stack> --revert (for the recoverable failures) or cdkd deploy <stack> --replace (for the update-not-supported ones). |
The command produces four terminal states per resource:
- drifted — at least one property differs between state and AWS.
Reported as
~ <logicalId> (<type>)with one+/-line per property path that diverged. - clean — every state-recorded property was compared against AWS and matched. Counted in the per-stack summary but not listed individually.
- not compared — nothing differed, but cdkd did not compare every
property. Three causes, told apart per resource by
--json'snotCompared[].cause:refused— cdkd deliberately refused to resolve a dynamic reference the resource's state records, so its secret-bearing properties were never looked at. Clearable: spell the reference as a full ARN.unresolvedToken— the state records a{{resolve:...}}spelling cdkd resolves for nobody, in practice{{resolve:ssm-secure:...}}. Permanent; a re-run cannot clear it, which is why it alone does not affect the exit code.readFailed— the read or the comparison THREW, so none of that resource's properties were compared. Every OTHER resource in the stack is still compared and reported; previously one such throw aborted the entire run.
- drift unknown — the provider does not implement the optional
readCurrentStatemethod yet. Reported as? <logicalId> (<type>)in a separate block at the bottom of each stack's report, and — like everything else nothing was read for — excluded from the summary's "checked" count, which reports it separately asN unsupported. A stack whose only resource is unsupported prints⚠ ... no drift detected, but NOTHING was compared — 0 of 1 resource checked (1 unsupported)— the glyph follows "was everything actually compared", so a stack in which nothing was compared does not get the reassuring✓. The exit code is unchanged at0: only the claim printed about coverage changed. The same applies to a stack whose resources are all Custom Resources (skipped).
A drifted resource can be partially compared too — the changes it reports
are real, but they are not the whole comparison — so it carries
referencesUnresolved: true alongside them. Everything not fully compared, that
resource included, is rolled up under notCompared in --json, listed under the
human report's PARTIALLY compared block -- whose heading changes to
NOT fully compared when a readFailed resource is present, since none of that
resource's properties were compared and calling it "partially" understates it -- and excluded from that report's
"fully checked" count — as is anything unsupported, so both the N checked
and the N of M fully checked spellings count only resources a comparison was
attempted for. In the N of M spelling the unsupported total is reported
outside the parenthetical (1 of 3 resources fully checked (2 only partially compared), 1 unsupported), so the bracketed figure accounts for exactly the
gap between the two numbers rather than reading as a third share of M. A clean verdict never means anything but "compared
and matched".
Two different things land in that bucket, and only one of them changes the exit code:
- Refused — cdkd CAN read the reference but declines to, because it cannot
tell which region should answer for it. This is actionable (spell the
reference as a full ARN) and it drives exit
2. - Unresolvable — a
{{resolve:...}}spelling cdkd resolves for nobody, in practice{{resolve:ssm-secure:...}}, which CloudFormation resolves server-side. Nothing you can do makes cdkd compare it, so it is reported but left out of the exit code.
The --json payload does not distinguish them today; if you need to, key on
whether the run exited 2. Giving the two
causes a single, explicit representation is planned.
Secret dynamic references ({{resolve:secretsmanager:...}}, and
{{resolve:ssm:...}} naming a SecureString parameter) are compared
like-for-like. cdkd state stores the unresolved expression, never the
plaintext, so cdkd drift re-resolves the baseline in memory before
comparing it against the AWS-current snapshot — a comparison, and nothing
else: the resolved value is never written to state.
This means cdkd drift needs read access to the referenced secrets:
secretsmanager:GetSecretValue for a secretsmanager reference, and
ssm:GetParameter (with kms:Decrypt on the parameter's key) for an ssm
one. Earlier versions made no such call. If the caller lacks the permission,
or the secret has been deleted, cdkd warns and continues: that one
resource's secret-bearing properties are reported as neither clean nor
drifted — they are simply not compared — and every other resource and stack
in the run is unaffected.
What the report is allowed to show at a secret-bearing property is deliberately narrow:
- When AWS holds the value the reference resolves to, both sides render as
the
{{resolve:...}}expression, so the property is clean and nothing is printed at all. - When AWS holds anything else — the commonest cause is a Secrets Manager
rotation, where the deployed resource still carries the previous version,
but an out-of-band console edit looks identical from here — the property is
reported as drifted with the AWS side shown as
***. cdkd cannot tell a stale secret from a non-secret edit, so it does not print either. - When AWS returns nothing for that exact property, it is reported as
neither clean nor drifted. A write-only credential (
MasterUserPasswordand friends) is not returned by any readback, so an absence there means "cannot be checked", not "was removed" — reporting it would makecdkd driftexit 1 forever on every stack with a templated credential. This applies to the property itself only: a whole block disappearing (the console's "remove all environment variables") IS reported, and--acceptrefuses it, because accepting an absence deletes the key and would take the{{resolve:...}}reference with it. Use--revertfor that shape. --acceptrefuses such a property and says so: it will not write***into state, and it will not write a value it could not identify. The drift keeps being reported. Use--revertto push the referenced value back to AWS, or re-deploy if the reference itself changed. Properties that are not secret-bearing are accepted normally in the same run, and--accept --dry-runprints the same refusal the real run will make.--revertre-resolves before calling the provider, so the live resource receives the concrete secret rather than the literal{{resolve:...}}token.
A REDACTED baseline is a different shape. Where a NoEcho
custom-resource Data value was resolved into a property, cdkd state holds the
literal mask *** rather than an expression — the value was generated by the
handler, so there is nothing to re-resolve. The three arms above therefore
answer differently, and none of them needs a {{resolve:...}} reference to
fire:
- the report shows the position but MASKS the AWS-current side, so the live value is not printed;
--acceptrefuses the property, because accepting would write the live plaintext over a deliberate redaction;--revertleaves that position exactly as AWS has it rather than pushing the mask. When AWS reports nothing there, it refuses the whole resource (counted with the other unresolvable ones — exit2), because sending***would corrupt the live value and dropping the key would delete a property the resource may require. To clear it, force the custom resource to update (change one of its properties — a nonce is the usual way) and re-deploy, so its handler runs again and supplies the value; an ordinary re-deploy leaves the resource unchanged, so the handler does not run and the mask stays.
Such a position drifts on every run, and that is expected. cdkd's side is
the mask and AWS's side is the real value, so the two never converge — the
resource is reported as drifted, the report renders *** on both sides
(cdkd will not print the live one), and detection-only mode exits 1
indefinitely. cdkd does NOT drop the comparison the way it drops an absent
write-only credential: there the read is impossible and the comparison is
meaningless, whereas here the live value is genuinely readable and cdkd simply
cannot say what belongs there — silently hiding it would claim a clean bill of
health it has no basis for. If a stack in this state must gate CI on drift,
either stop marking that response NoEcho (the value then round-trips
normally) or gate on --json and filter the known position out.
A property whose real value happens to BE the string *** is treated the same
way, since nothing in state distinguishes the two — see
state-management.md.
Known limitation. cdkd cannot mask a value for a reference it never
resolved, so for {{resolve:ssm-secure:...}} the protections above are not
complete. The report masks by POSITION and the --revert payload declines to
carry such a value, but if a provider echoes its own readback back to cdkd
(effectiveProperties, which --revert persists as a narrowing) the resolved
value can reach state.json with nothing able to recognise it. This predates
the reconciliation described here — that write previously had no redaction at
all — and closing it needs masking by SPAN rather than by value, which is
tracked separately. If that matters for your stack, prefer a
secretsmanager or plain ssm reference, both of which cdkd resolves and can
therefore mask.
A reference cdkd does not resolve at all — {{resolve:ssm-secure:...}} is the
one such spelling today — is not an error, and is not compared: the
property is reported as neither clean nor drifted, and a warning names the
token once per resource.
What a --revert triggered by another drifted property on the same resource
does to those positions depends on where the token sits, and the difference
matters for a stack adopted with cdkd import --migrate-from-cloudformation,
where CloudFormation resolved ssm-secure server-side so AWS holds the
resolved value while cdkd state holds the literal token:
- If the property's whole value is the token, the live value is left unchanged — cdkd cannot tell what it should be, so it does not touch it.
- If the token is embedded in a longer string (
"jdbc:...password={{resolve:ssm-secure:/pw}}"), that string is written with the token literal, exactly ascdkd deploydoes — so a resolved value AWS holds there is overwritten. Preserving this case needs masking by span, which is tracked separately; until then, prefer asecretsmanageror plainssmreference in a composed string, or keep the reference as the property's whole value.
Both the drift warning and the revert warning state which of the two applies, and the drift one is printed before the confirmation prompt.
Drift detection works automatically for every resource type that goes
through Cloud Control API (the majority of cdkd's surface). SDK
Providers add their own readCurrentState incrementally — providers
without an implementation surface as drift unknown rather than clean,
so you can see exactly which types are still uncovered.
The following SDK Providers ship with first-class readCurrentState
(no CC API round-trip):
AWS::Lambda::Function,AWS::S3::Bucket,AWS::DynamoDB::Table,AWS::IAM::Role,AWS::SQS::Queue,AWS::SNS::Topic,AWS::Logs::LogGroupAWS::CloudFront::CloudFrontOriginAccessIdentity,AWS::Events::EventBus,AWS::Events::Rule,AWS::SSM::Parameter,AWS::SecretsManager::Secret,AWS::ECR::Repository,AWS::StepFunctions::StateMachine,AWS::ECS::Cluster,AWS::ECS::Service,AWS::ECS::TaskDefinition,AWS::RDS::DBInstance,AWS::RDS::DBCluster,AWS::RDS::DBSubnetGroup,AWS::KMS::Key,AWS::KMS::Alias,AWS::ApiGateway::Account,AWS::ApiGateway::Method,AWS::ApiGatewayV2::Api,AWS::Cognito::UserPoolAWS::AppSync::GraphQLApi,AWS::AppSync::DataSource,AWS::AppSync::Resolver,AWS::AppSync::ApiKey,AWS::EFS::FileSystem,AWS::EFS::AccessPoint,AWS::EFS::MountTarget,AWS::ElastiCache::CacheCluster,AWS::ElastiCache::SubnetGroup,AWS::ElasticLoadBalancingV2::LoadBalancer,AWS::ElasticLoadBalancingV2::TargetGroup,AWS::ElasticLoadBalancingV2::Listener,AWS::Route53::HostedZone,AWS::Route53::RecordSet,AWS::WAFv2::WebACL,AWS::KinesisFirehose::DeliveryStream,AWS::Kinesis::Stream,AWS::Glue::Database,AWS::Glue::Table,AWS::CloudTrail::Trail,AWS::CloudWatch::Alarm,AWS::CodeBuild::Project,AWS::ServiceDiscovery::PrivateDnsNamespace,AWS::ServiceDiscovery::Service,AWS::SNS::SubscriptionAWS::IAM::Policy,AWS::Lambda::Permission,AWS::ApiGateway::Authorizer,AWS::ApiGateway::Resource,AWS::ApiGateway::Deployment,AWS::ApiGateway::Stage,AWS::ApiGatewayV2::Stage,AWS::ApiGatewayV2::Integration,AWS::ApiGatewayV2::Route,AWS::ApiGatewayV2::Authorizer(the sub-resource batch; receivespropertiesso the parentRestApiId/ApiId/FunctionName/Roles[]is available to issue the matchingGet*call)AWS::ServiceDiscovery::HttpNamespace,AWS::ServiceDiscovery::PublicDnsNamespaceAWS::CloudFront::OriginAccessControl(SDK provider added to take the type off the Cloud Control polling path; the CFn and SDKOriginAccessControlConfigfield names are identical, so the reverse mapping is a straight per-field copy)
Note
physicalId formats. Several types in the lists above (
AWS::ApiGateway::Method,AWS::Route53::RecordSet,AWS::AppSync::DataSource/Resolver/ApiKey,AWS::Glue::Table,AWS::S3Tables::Namespace/Table, and the EC2 sub-resources) are identified by a composite,|-delimited physical id rather than a single scalar. That composite is whatcdkd state show/cdkd state resourcesprint and whatcdkd import --resource <logicalId>=<physicalId>expects — quote it on a shell command line. The per-type format table is in state-management.md.
Tag drift is supported across the SDK Providers listed above (and the CC
API fallback). cdkd filters out CDK / AWS-internal aws:-prefixed entries
(notably aws:cdk:path and aws:cdk:metadata) from the AWS-current
snapshot before comparing — those are injected by CDK as construct
metadata, not as user-managed Tags properties, so leaving them in would
fire false-positive drift on every CDK-deployed resource. The remaining
user tags are normalized to CFn's [{Key, Value}] shape (sorted by Key
for stable comparison) and the result key is omitted entirely when AWS
reports no user tags. IAM Role / User / Group inline-policy bodies are
covered (paginated List*Policies + parallel Get*Policy round-trips
with state-driven order reconciliation);
see src/types/resource.ts for the per-provider
shape decisions.
Still reporting drift unknown (deferred):
AWS::CloudFront::Distributiondefers to the CC API fallback — itsDistributionConfigschema uses the SDK'sQuantity + Itemsshape vs CFn's flat array shape, and mirroring the conversion would balloon the diff for marginal gain over the CC API path.AWS::AppSync::GraphQLSchemabody drift is deferred — AWS'sGetIntrospectionSchemareturns SDL bytes but normalizes the schema on the way out (canonical field ordering, comment / whitespace stripping), so a direct string comparison against the user-authoredDefinitionin cdkd state would fire constantly on cosmetic diffs. A meaningful comparison needs an SDL parser to canonicalize both sides before diff, which is out of scope.AWS::Kinesis::StreamConsumerfalls through to the CC API fallback; the SDK provider only handlesAWS::Kinesis::Stream. A dedicated SDK impl would require building out create / update / delete first.
Streams under --json
With --json, stdout carries the payload and nothing else, so
cdkd drift ... --json | jq ... is safe on every mode including --accept /
--revert. Every human-facing line the command would otherwise print on
stdout — the --accept / --revert plan, the confirmation prompt, the
--dry-run notice, No drift detected — nothing to accept., the
Comparison INCOMPLETE block, the ✓ State updated and Revert summary
lines, and --verbose debug output — goes to stderr instead. Warnings and
errors were already on stderr and are unaffected.
The lines are moved, not suppressed: run the command in a terminal (or with
2>&1 into a pager) and you see exactly what you saw before. Redirect the two
streams separately to keep both:
cdkd drift MyStack --json --accept --yes > report.json 2> progress.log
Without --json nothing moves on cdkd drift — its human modes keep
printing to stdout as before. Previously these lines shared stdout
with the payload, so a --json --accept run produced a document a parser
rejected while looking correct on screen.
The same contract holds for every --json surface, not just drift:
cdkd events --json (and its --format json alias) and the four
cdkd state {list,resources,show,info} --json subcommands route their
--verbose debug output and the Assumed role ... notice from
--role-arn / CDKD_ROLE_ARN runs to stderr while --json is in effect.
cdkd diff --json predates the mechanism and instead demotes the logger to
warn, which suppresses rather than moves its info-level lines.
cdkd list is no longer in that list, because its reservation is no longer
conditional: it reserves stdout in
EVERY mode, --json or not, along with cdkd synth, cdkd local invoke and
cdkd local invoke-agentcore -- see
Output streams: when stdout is a payload.
--json output shape:
[
{
"stack": "MyStack",
"region": "us-east-1",
"drifted": [
{
"logicalId": "Bucket1",
"type": "AWS::S3::Bucket",
"changes": [
{
"path": "VersioningConfiguration.Status",
"stateValue": "Enabled",
"awsValue": "Suspended"
}
],
"referencesUnresolved": false
}
],
"clean": [
{ "logicalId": "Queue1", "type": "AWS::SQS::Queue", "referencesUnresolved": false }
],
"notSupported": [
{ "logicalId": "Function1", "type": "AWS::Lambda::Function" }
],
"notCompared": []
}
]
A populated notCompared entry, showing the two per-entry keys:
"notCompared": [
{
"logicalId": "Function1",
"type": "AWS::Lambda::Function",
"referencesUnresolved": true,
"cause": "refused"
},
{
"logicalId": "Detector1",
"type": "AWS::CloudWatch::AnomalyDetector",
"referencesUnresolved": false,
"cause": "readFailed"
}
]
The notCompared roll-up answers one question clean alone cannot:
was the comparison complete? When cdkd cannot resolve — or
deliberately REFUSES to resolve — a dynamic reference a resource's state
records, that resource's secret-bearing properties are not compared at
all. The comparator skips those leaves, so nothing is reported as
drifted, which on its own is indistinguishable from "compared and
matched". The array also carries a
resource whose read or comparison THREW, for which nothing at all was
compared.
Such a resource appears under notCompared and not under clean: clean
means compared-and-matched and nothing else, so its entries always carry
referencesUnresolved: false. A drifted entry can carry
referencesUnresolved: true — the changes it reports are real, but they
are not the whole comparison — and it is rolled up under notCompared
as well.
Each notCompared entry carries two keys of its own:
cause—refused,unresolvedTokenorreadFailed(see the outcome list above). This is the key to gate on when you need to tell a clearable cause from a permanent one; the exit code says the run had at least one clearable cause but cannot say which resource.referencesUnresolved—truefor the two reference-related causes andfalseforreadFailed, whose references are beside the point. It was the constanttruebeforereadFailedentries widened the array's population, so a consumer written asnotCompared.filter(n => n.referencesUnresolved)silently dropsreadFailedentries where it previously matched everything in the array. Gate onnotCompared.length(unchanged, and the documented predicate) or oncause; the run still exits2either way.
So a CI job that gates on drift should read notCompared, not just
drifted:
# "everything was actually checked, and nothing drifted"
cdkd drift --all --json | jq -e 'all(.[]; .drifted == [] and .notCompared == [])'
The human-readable report says the same thing in a block headed
N resource(s) only PARTIALLY compared — or, when a readFailed resource is
present, N resource(s) NOT fully compared — K not compared AT ALL (the read or comparison failed), because calling such a resource "partially compared"
understates it. Each entry names its own reason. The per-resource detail also
goes to the log, which a caller piping stdout does not see. The usual
causes are a least-privilege role without secretsmanager:GetSecretValue
/ ssm:GetParameter, a deleted or rotated-away secret, and a
cross-region reference cdkd refuses to resolve in the consumer's region
because that would compare against (and with --revert, write) a
different region's same-named secret.
The comparator only looks at keys present in cdkd state — AWS-managed fields (timestamps, generated identifiers, account-wide defaults) that cdkd never set are ignored, so they never surface as false-positive drift.
False-drift prevention for the CC API fallback
When an SDK Provider doesn't yet implement readCurrentState, drift
falls back to Cloud Control API's generic GetResource. cdkd state's
properties field is in CFn-template shape (what provider.create()
was passed); CC API's response is usually the same shape, but for some
resource types it diverges enough to fire false-positive drift on
every run. Two guards protect the fallback:
- Deny-list (
src/analyzer/drift-cc-api-deny-list.ts) — types with verified structural divergence (e.g.AWS::ApiGateway::RestApi's write-onlyBodyfield, orAWS::EC2::LaunchTemplate's version-bumpedLaunchTemplateData) short-circuit todrift unknownbefore the CC API call ever fires. The fix path for any deny-listed type is a first-class SDK-providerreadCurrentState, not a per-entry tweak — once the provider implements it, the deny-list entry is unreachable. - Strip pass (
src/analyzer/cc-api-strip.ts) — known AWS-managed timestamp / owner / generated-id fields (CreationDate,LastModifiedTime,OwnerId,RevisionId, ...) are removed from CC API responses before the comparator sees them. The strip list is conservative: name-collision-prone fields that some CFn types use as legitimate inputs (Status,State,VersionId,Arn, ...) are NOT stripped, so a realStatuschange onAWS::ECS::CapacityProvider.ManagedScalingstill surfaces as drift.
A breadth-of-coverage shape fixture suite
(tests/unit/analyzer/drift-cc-api-shape-fixtures.test.ts) verifies
~10 representative CC-API-fallback types produce zero drift on a
clean stack. When a new shape regression is reported, add the type
either to the fixture suite (if the strip list catches it) or to the
deny-list (if the divergence is structural).
Resolving drift (--accept / --revert)
Once cdkd drift has detected drift, the same command can also resolve
it. The two flags are mutually exclusive — pick the direction that
matches the intent:
--accept(state ← AWS) — write the AWS-current values back into cdkd's S3 state file. Use this when the AWS-side change is the intentional source of truth (typically a manual console edit you want cdkd to "catch up" to without re-deploying). The cdkd state ETag captured during the read is forwarded toS3StateBackend.saveStateasIfMatchfor optimistic locking, so a concurrentcdkd deploycannot race the write. AWS resources are NOT modified.--revert(AWS ← state) — call each drifted resource'sprovider.updateto push state values back into AWS for the drifted properties.propertiesis built as the AWS-current snapshot (captured during the drift read, no second AWS call) with the drifted top-level subtrees overlaid from cdkd'sobservedProperties, andpreviousPropertiesis the AWS-current snapshot itself. Net effect: every drifted property is pushed back to its state-recorded value; non-drifted properties carry their AWS-current values, so a diff-basedupdate()(e.g. SNS, IAM Role) seesnewVal === oldValfor them and does not touch the AWS resource for those keys. Use this to undo a manual AWS console change. Per-resource failures are collected and surface asPartialFailureError(exit 2) at the end of the run; one resource's failure does not abort the rest. cdkd state is NOT modified by--revert— onceprovider.updatesucceeds, AWS values match state by definition, so a subsequentcdkd driftreportsclean.Resources with no observed-capture baseline. The revert baseline is
observedProperties ?? properties. A resource deployed BEFORE observed-capture shipped has noobservedProperties, so the desired side is the raw template while the previous side would be the AWS-current snapshot — and on that baseline cdkd cannot tell an AWS-authored value from an out-of-band change, because neither appears in the template.So on that baseline the revert leaves every untemplated value alone: cdkd merges those paths into the bag it actually SENDS, instead of overlaying the drifted subtree wholesale. Merging on the desired side (rather than trimming
previousProperties) is what makes this hold for both provider shapes — one that key-diffs a collection would otherwise put them on its removal path, and one that replaces a bag wholesale (PutBucketTaggingis documented full-replace) never consults the previous side at all. The plan prints, per affected resource, a! this resource has no observed-capture baseline ... LEAVES N AWS-authored values untouchedline naming each path, before the confirmation prompt and under--dry-run. A Glue Iceberg table'stable_type/metadata_location, and the ~18 untemplated attributes an ELBv2 load balancer reports, survive the revert instead of being reset. Runcdkd state refresh-observed <stack>(or re-deploy) if you want them reverted too; either populatesobservedProperties, after which the baseline IS a deploy-time AWS snapshot — an out-of-band addition is then genuinely identifiable and IS stripped, and the notice stops firing. Only DRIFTED top-level keys are ever narrowed; non-drifted keys keep their AWS-current values on both sides.Update-not-supported resources. Some resource types are immutable in AWS (e.g.
AWS::Lambda::LayerVersion, sub-resource attachments likeAWS::Lambda::Permission,AWS::ApiGateway::Deployment) or do not yet have an in-placeupdate()implementation in cdkd (AWS::AppSync::*,AWS::EFS::*,AWS::KinesisFirehose::DeliveryStream,AWS::ApiGatewayV2::*,AWS::ApiGateway::Authorizer/Deployment/Method,AWS::Glue::Database,AWS::ServiceDiscovery::*,AWS::ElasticLoadBalancingV2::LoadBalancer). For those,--revertsurfaces a distinct⊘ <stack>/<id> (<type>): could not revert — ...line with aResourceUpdateNotSupportedErrorand an explicit suggestion. The summary then names them separately ("N reverted, M update-not-supported") and the run exits2. The fix is to re-deploy the stack withcdkd deploy --replace, or destroy + redeploy — the same recovery path you would use for a CloudFormation immutable-property error. AWS update failures (a successfulprovider.update()call returning a runtime error) are reported separately with a✗glyph and counted asfailed; the fix there is to inspect the AWS error and retry once the underlying cause is resolved.
Both flags acquire the per-stack lock (the same one cdkd deploy uses)
before mutating anything, and prompt for confirmation unless -y /
--yes is set. --dry-run prints the planned mutations and exits 0
without acquiring a lock or hitting AWS / S3.
--accept is a no-op on a clean stack (no drift, nothing to write).
--revert is likewise a no-op on a clean stack (no drift, nothing to
push). Resources surfaced as unsupported (provider has no
readCurrentState yet) are skipped by both flags — the comparator
never produced a PropertyDrift for them.