@go-to-k/cdkd 0.48.0 → 0.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -171,6 +171,57 @@ the full per-type table.
171
171
  | CC API null value stripping | ✅ | Removes null values before API calls |
172
172
  | Retry with HTTP status codes | ✅ | 429/503 + cause chain inspection |
173
173
 
174
+ ### Drift detection
175
+
176
+ `cdkd drift <stack>` (state-driven; no synth) compares each resource
177
+ between the AWS-current snapshot returned by `provider.readCurrentState`
178
+ and the **deploy-time AWS snapshot** stored in
179
+ `ResourceState.observedProperties`. The observedProperties baseline is
180
+ populated automatically on every successful `cdkd deploy` /
181
+ `cdkd import`, so console-side changes to keys you did NOT template
182
+ (IAM policies attached out-of-band, S3 public-access-block toggled,
183
+ etc.) surface as drift instead of being silently ignored.
184
+
185
+ State schema `version: 3` (the layout that carries observedProperties)
186
+ is auto-migrated on the next write — no user action needed for new
187
+ deploys. **For stacks already deployed with an older binary**, the
188
+ upgrade story is:
189
+
190
+ 1. `cdkd 0.46.x` (or earlier) deployed your stack — state.json is
191
+ `version: 2`, no observedProperties on any resource.
192
+ 2. Upgrade cdkd to `0.47.0+`. The new binary reads v2 state cleanly,
193
+ and `cdkd drift` falls back to comparing against the user-templated
194
+ `properties` field (= the pre-v3 behavior) for any resource that
195
+ hasn't been refreshed yet.
196
+ 3. **Populate observedProperties** for the existing stack — pick one:
197
+
198
+ ```bash
199
+ # Option A (recommended): explicit refresh, no redeploy.
200
+ cdkd state refresh-observed MyStack
201
+
202
+ # Option B: trigger an UPDATE on each affected resource via the
203
+ # next `cdkd deploy`. NO_CHANGE-skipped resources are NOT refreshed
204
+ # by deploy alone — only the ones whose template changed get a
205
+ # readCurrentState call. Use this only if you're already changing
206
+ # the template; for an upgrade-and-refresh-only flow prefer A.
207
+ cdkd deploy MyStack
208
+ ```
209
+ 4. Re-run `cdkd drift MyStack` — now observed-baseline drift detection
210
+ is fully enabled.
211
+
212
+ `cdkd state refresh-observed --all` does the same for every stack in
213
+ the state bucket; `--dry-run` prints the per-stack refresh count
214
+ without touching state. Resolve any drift the comparator finds with
215
+ `cdkd drift <stack> --accept` (state ← AWS) or `--revert` (AWS ← state).
216
+
217
+ `cdkd deploy --no-capture-observed-state` (or
218
+ `cdk.json context.cdkd.captureObservedState: false`) opts out of the
219
+ capture entirely if you care more about deploy speed than rich drift
220
+ detection — drift then falls back to comparing against `properties`,
221
+ the pre-v3 behavior. Bench measurements show roughly +0–4% deploy
222
+ time with the capture on (lambda integ within noise; bench-cdk-sample
223
+ +3.4% median).
224
+
174
225
  ## Prerequisites
175
226
 
176
227
  - **Node.js** >= 20.0.0
@@ -300,6 +351,14 @@ cdkd drift MyStack --accept --yes
300
351
  # Resolve drift: AWS ← state (push state values back into AWS via provider.update)
301
352
  cdkd drift MyStack --revert --yes
302
353
 
354
+ # Refresh the deploy-time AWS snapshot used as drift baseline.
355
+ # Run this once after upgrading from a pre-v3 cdkd binary (= state schema
356
+ # `version: 2`) so console-side changes to keys you didn't template can
357
+ # be detected for resources that won't change in any near-future deploy.
358
+ # Same idempotent behavior on the same v3 state — see "Drift detection"
359
+ # below for the full upgrade story.
360
+ cdkd state refresh-observed MyStack
361
+
303
362
  # Dry run (plan only, no changes)
304
363
  cdkd deploy --dry-run
305
364
 
package/dist/cli.js CHANGED
@@ -39028,6 +39028,14 @@ async function runDriftForStack(stackName, region, stateBackend, providerRegistr
39028
39028
  resource.properties ?? {}
39029
39029
  );
39030
39030
  } else {
39031
+ if (resource.resourceType.startsWith("Custom::")) {
39032
+ outcomes.push({
39033
+ kind: "unsupported",
39034
+ logicalId,
39035
+ resourceType: resource.resourceType
39036
+ });
39037
+ continue;
39038
+ }
39031
39039
  if (CC_API_FALLBACK_DENY_LIST[resource.resourceType]) {
39032
39040
  outcomes.push({
39033
39041
  kind: "unsupported",
@@ -41846,6 +41854,215 @@ function createStateInfoCommand() {
41846
41854
  [...commonOptions, ...stateOptions].forEach((opt) => cmd.addOption(opt));
41847
41855
  return cmd;
41848
41856
  }
41857
+ async function stateRefreshObservedCommand(stackArgs, options) {
41858
+ const logger = getLogger();
41859
+ if (options.verbose)
41860
+ logger.setLevel("debug");
41861
+ if (!options.all && stackArgs.length === 0) {
41862
+ throw new Error(
41863
+ "Stack name is required. Usage: cdkd state refresh-observed <stack> [<stack>...] | --all"
41864
+ );
41865
+ }
41866
+ const setup = await setupStateBackend(options);
41867
+ const providerRegistry = new ProviderRegistry();
41868
+ registerAllProviders(providerRegistry);
41869
+ providerRegistry.setCustomResourceResponseBucket(setup.bucket);
41870
+ try {
41871
+ const stateRefs = await setup.stateBackend.listStacks();
41872
+ let targets;
41873
+ if (options.all) {
41874
+ targets = options.stackRegion ? stateRefs.filter((r) => r.region === options.stackRegion) : stateRefs;
41875
+ if (targets.length === 0) {
41876
+ logger.info("No stacks found in state");
41877
+ return;
41878
+ }
41879
+ } else {
41880
+ targets = [];
41881
+ for (const stackName of stackArgs) {
41882
+ const matches = stateRefs.filter((r) => r.stackName === stackName);
41883
+ if (matches.length === 0) {
41884
+ throw new Error(
41885
+ `No state found for stack '${stackName}'. Run 'cdkd state list' to see available stacks.`
41886
+ );
41887
+ }
41888
+ if (options.stackRegion) {
41889
+ const ref = matches.find((r) => r.region === options.stackRegion);
41890
+ if (!ref) {
41891
+ const seen = matches.map((r) => r.region ?? "(legacy)").join(", ");
41892
+ throw new Error(
41893
+ `No state found for stack '${stackName}' in region '${options.stackRegion}'. Available regions: ${seen}.`
41894
+ );
41895
+ }
41896
+ targets.push(ref);
41897
+ } else if (matches.length === 1) {
41898
+ targets.push(matches[0]);
41899
+ } else {
41900
+ const regions = matches.map((r) => r.region ?? "(legacy)").join(", ");
41901
+ throw new Error(
41902
+ `Stack '${stackName}' has state in multiple regions: ${regions}. Re-run with --stack-region <region> to disambiguate.`
41903
+ );
41904
+ }
41905
+ }
41906
+ }
41907
+ if (!options.yes && !options.dryRun) {
41908
+ const targetList = targets.map(formatStackRef).join(", ");
41909
+ const ok = await confirmRefresh(
41910
+ `Refresh observedProperties for ${targets.length} stack(s) (${targetList})?`
41911
+ );
41912
+ if (!ok) {
41913
+ logger.info("Aborted.");
41914
+ return;
41915
+ }
41916
+ }
41917
+ let totalRefreshed = 0;
41918
+ let totalUnsupported = 0;
41919
+ let totalFailed = 0;
41920
+ for (const target of targets) {
41921
+ if (!target.region) {
41922
+ throw new Error(
41923
+ `Stack '${target.stackName}' has only a legacy state record without a region. Run 'cdkd deploy ${target.stackName}' (or any cdkd write) first to migrate it to the region-scoped layout, then re-run refresh-observed.`
41924
+ );
41925
+ }
41926
+ const counts = await refreshObservedForStack(
41927
+ target.stackName,
41928
+ target.region,
41929
+ setup.stateBackend,
41930
+ setup.lockManager,
41931
+ providerRegistry,
41932
+ { dryRun: options.dryRun ?? false, logger }
41933
+ );
41934
+ totalRefreshed += counts.refreshed;
41935
+ totalUnsupported += counts.unsupported;
41936
+ totalFailed += counts.failed;
41937
+ }
41938
+ const summary = options.dryRun ? `Plan: ${totalRefreshed} resource(s) would be refreshed, ${totalUnsupported} unsupported, ${totalFailed} would fail (--dry-run, no state was written)` : `Done: ${totalRefreshed} resource(s) refreshed, ${totalUnsupported} unsupported, ${totalFailed} failed`;
41939
+ logger.info(`
41940
+ ${summary}`);
41941
+ if (totalFailed > 0) {
41942
+ throw new PartialFailureError(
41943
+ `Refresh completed with ${totalFailed} per-resource readCurrentState failure(s). Affected resources keep their previous observedProperties (or no observedProperties at all). Re-run 'cdkd state refresh-observed' to retry.`
41944
+ );
41945
+ }
41946
+ } finally {
41947
+ setup.dispose();
41948
+ }
41949
+ }
41950
+ async function refreshObservedForStack(stackName, region, stateBackend, lockManager, providerRegistry, opts) {
41951
+ const { logger } = opts;
41952
+ const result = await stateBackend.getState(stackName, region);
41953
+ if (!result) {
41954
+ throw new Error(
41955
+ `No state found for stack '${stackName}' (${region}). Run 'cdkd state list' to see available stacks.`
41956
+ );
41957
+ }
41958
+ const { state, etag, migrationPending } = result;
41959
+ const entries = Object.entries(state.resources ?? {});
41960
+ if (entries.length === 0) {
41961
+ logger.info(`\u2713 ${stackName} (${region}): no resources in state, skipping`);
41962
+ return { refreshed: 0, unsupported: 0, failed: 0 };
41963
+ }
41964
+ if (opts.dryRun) {
41965
+ let wouldRefresh = 0;
41966
+ let wouldUnsupported = 0;
41967
+ for (const [, resource] of entries) {
41968
+ let provider;
41969
+ try {
41970
+ provider = providerRegistry.getProvider(resource.resourceType);
41971
+ } catch {
41972
+ wouldUnsupported++;
41973
+ continue;
41974
+ }
41975
+ if (provider.readCurrentState)
41976
+ wouldRefresh++;
41977
+ else
41978
+ wouldUnsupported++;
41979
+ }
41980
+ logger.info(
41981
+ `Plan ${stackName} (${region}): ${wouldRefresh} resource(s) would be refreshed, ${wouldUnsupported} unsupported`
41982
+ );
41983
+ return { refreshed: wouldRefresh, unsupported: wouldUnsupported, failed: 0 };
41984
+ }
41985
+ const owner = `${process.env["USER"] || "unknown"}@${process.env["HOSTNAME"] || "host"}:${process.pid}`;
41986
+ await lockManager.acquireLock(stackName, region, owner, "state-refresh-observed");
41987
+ try {
41988
+ let refreshed = 0;
41989
+ let unsupported = 0;
41990
+ let failed = 0;
41991
+ await withStackName(stackName, async () => {
41992
+ const tasks = entries.map(async ([logicalId, resource]) => {
41993
+ if (providerRegistry.shouldSkipResource(resource.resourceType)) {
41994
+ unsupported++;
41995
+ return;
41996
+ }
41997
+ let provider;
41998
+ try {
41999
+ provider = providerRegistry.getProvider(resource.resourceType);
42000
+ } catch {
42001
+ unsupported++;
42002
+ return;
42003
+ }
42004
+ if (!provider.readCurrentState) {
42005
+ unsupported++;
42006
+ return;
42007
+ }
42008
+ try {
42009
+ const observed = await provider.readCurrentState(
42010
+ resource.physicalId,
42011
+ logicalId,
42012
+ resource.resourceType,
42013
+ resource.properties ?? {}
42014
+ );
42015
+ if (observed === void 0) {
42016
+ unsupported++;
42017
+ return;
42018
+ }
42019
+ resource.observedProperties = observed;
42020
+ refreshed++;
42021
+ } catch (err) {
42022
+ failed++;
42023
+ logger.warn(
42024
+ ` \u2717 ${stackName}/${logicalId} (${resource.resourceType}): readCurrentState failed \u2014 ${err instanceof Error ? err.message : String(err)}`
42025
+ );
42026
+ }
42027
+ });
42028
+ await Promise.all(tasks);
42029
+ });
42030
+ state.lastModified = Date.now();
42031
+ const saveOptions = {
42032
+ expectedEtag: etag
42033
+ };
42034
+ if (migrationPending)
42035
+ saveOptions.migrateLegacy = true;
42036
+ await stateBackend.saveState(stackName, region, state, saveOptions);
42037
+ logger.info(
42038
+ `\u2713 ${stackName} (${region}): ${refreshed} refreshed, ${unsupported} unsupported, ${failed} failed`
42039
+ );
42040
+ return { refreshed, unsupported, failed };
42041
+ } finally {
42042
+ await lockManager.releaseLock(stackName, region).catch((err) => {
42043
+ logger.warn(
42044
+ `Failed to release lock for ${stackName} (${region}): ` + (err instanceof Error ? err.message : String(err))
42045
+ );
42046
+ });
42047
+ }
42048
+ }
42049
+ async function confirmRefresh(prompt) {
42050
+ const rl = readline5.createInterface({ input: process.stdin, output: process.stdout });
42051
+ try {
42052
+ const ans = await rl.question(`${prompt} [y/N] `);
42053
+ return /^y(es)?$/i.test(ans.trim());
42054
+ } finally {
42055
+ rl.close();
42056
+ }
42057
+ }
42058
+ function createStateRefreshObservedCommand() {
42059
+ const cmd = new Command12("refresh-observed").description(
42060
+ "Refresh observedProperties for every resource in a stack by calling provider.readCurrentState \u2014 populates the drift baseline for stacks deployed before state schema v3, without redeploying."
42061
+ ).argument("[stacks...]", "Stack name(s) to refresh (physical CloudFormation names)").option("--all", "Refresh every stack in the state bucket", false).option("--dry-run", "Print the per-stack refresh count without writing state", false).addOption(stackRegionOption2()).action(withErrorHandling(stateRefreshObservedCommand));
42062
+ [...commonOptions, ...stateOptions].forEach((opt) => cmd.addOption(opt));
42063
+ cmd.addOption(deprecatedRegionOption);
42064
+ return cmd;
42065
+ }
41849
42066
  function createStateCommand() {
41850
42067
  const cmd = new Command12("state").description("Manage cdkd state stored in S3");
41851
42068
  cmd.addCommand(createStateInfoCommand());
@@ -41855,6 +42072,7 @@ function createStateCommand() {
41855
42072
  cmd.addCommand(createStateOrphanCommand());
41856
42073
  cmd.addCommand(createStateDestroyCommand());
41857
42074
  cmd.addCommand(createStateMigrateCommand());
42075
+ cmd.addCommand(createStateRefreshObservedCommand());
41858
42076
  return cmd;
41859
42077
  }
41860
42078
 
@@ -42637,7 +42855,7 @@ function reorderArgs(argv) {
42637
42855
  }
42638
42856
  async function main() {
42639
42857
  const program = new Command14();
42640
- program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.48.0");
42858
+ program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.49.0");
42641
42859
  program.addCommand(createBootstrapCommand());
42642
42860
  program.addCommand(createSynthCommand());
42643
42861
  program.addCommand(createListCommand());