@go-to-k/cdkd 0.48.0 → 0.50.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
@@ -38666,10 +38666,11 @@ init_aws_clients();
38666
38666
  function calculateResourceDrift(stateProperties, awsProperties, options) {
38667
38667
  const drifts = [];
38668
38668
  const ignore = options?.ignorePaths ?? [];
38669
+ const union = options?.unionWalkObjects ?? false;
38669
38670
  for (const key of Object.keys(stateProperties)) {
38670
38671
  if (isIgnoredPath(key, ignore))
38671
38672
  continue;
38672
- diffAt(key, stateProperties[key], awsProperties[key], drifts, ignore);
38673
+ diffAt(key, stateProperties[key], awsProperties[key], drifts, ignore, union);
38673
38674
  }
38674
38675
  return drifts;
38675
38676
  }
@@ -38682,15 +38683,16 @@ function isIgnoredPath(path, ignorePaths) {
38682
38683
  }
38683
38684
  return false;
38684
38685
  }
38685
- function diffAt(path, stateValue, awsValue, out, ignorePaths) {
38686
+ function diffAt(path, stateValue, awsValue, out, ignorePaths, unionWalkObjects) {
38686
38687
  if (deepEqual(stateValue, awsValue))
38687
38688
  return;
38688
38689
  if (isPlainObject(stateValue) && isPlainObject(awsValue) && !Array.isArray(stateValue) && !Array.isArray(awsValue)) {
38689
- for (const key of Object.keys(stateValue)) {
38690
+ const keys = unionWalkObjects ? /* @__PURE__ */ new Set([...Object.keys(stateValue), ...Object.keys(awsValue)]) : Object.keys(stateValue);
38691
+ for (const key of keys) {
38690
38692
  const childPath = `${path}.${key}`;
38691
38693
  if (isIgnoredPath(childPath, ignorePaths))
38692
38694
  continue;
38693
- diffAt(childPath, stateValue[key], awsValue[key], out, ignorePaths);
38695
+ diffAt(childPath, stateValue[key], awsValue[key], out, ignorePaths, unionWalkObjects);
38694
38696
  }
38695
38697
  return;
38696
38698
  }
@@ -39028,6 +39030,14 @@ async function runDriftForStack(stackName, region, stateBackend, providerRegistr
39028
39030
  resource.properties ?? {}
39029
39031
  );
39030
39032
  } else {
39033
+ if (resource.resourceType.startsWith("Custom::")) {
39034
+ outcomes.push({
39035
+ kind: "unsupported",
39036
+ logicalId,
39037
+ resourceType: resource.resourceType
39038
+ });
39039
+ continue;
39040
+ }
39031
39041
  if (CC_API_FALLBACK_DENY_LIST[resource.resourceType]) {
39032
39042
  outcomes.push({
39033
39043
  kind: "unsupported",
@@ -39061,8 +39071,12 @@ async function runDriftForStack(stackName, region, stateBackend, providerRegistr
39061
39071
  continue;
39062
39072
  }
39063
39073
  const ignorePaths = provider.getDriftUnknownPaths ? provider.getDriftUnknownPaths(resource.resourceType) : [];
39064
- const baseline = resource.observedProperties ?? resource.properties ?? {};
39065
- const changes = calculateResourceDrift(baseline, aws, { ignorePaths });
39074
+ const useObserved = resource.observedProperties !== void 0;
39075
+ const baseline = useObserved ? resource.observedProperties : resource.properties ?? {};
39076
+ const changes = calculateResourceDrift(baseline, aws, {
39077
+ ignorePaths,
39078
+ unionWalkObjects: useObserved
39079
+ });
39066
39080
  if (changes.length === 0) {
39067
39081
  outcomes.push({ kind: "clean", logicalId, resourceType: resource.resourceType });
39068
39082
  } else {
@@ -41846,6 +41860,215 @@ function createStateInfoCommand() {
41846
41860
  [...commonOptions, ...stateOptions].forEach((opt) => cmd.addOption(opt));
41847
41861
  return cmd;
41848
41862
  }
41863
+ async function stateRefreshObservedCommand(stackArgs, options) {
41864
+ const logger = getLogger();
41865
+ if (options.verbose)
41866
+ logger.setLevel("debug");
41867
+ if (!options.all && stackArgs.length === 0) {
41868
+ throw new Error(
41869
+ "Stack name is required. Usage: cdkd state refresh-observed <stack> [<stack>...] | --all"
41870
+ );
41871
+ }
41872
+ const setup = await setupStateBackend(options);
41873
+ const providerRegistry = new ProviderRegistry();
41874
+ registerAllProviders(providerRegistry);
41875
+ providerRegistry.setCustomResourceResponseBucket(setup.bucket);
41876
+ try {
41877
+ const stateRefs = await setup.stateBackend.listStacks();
41878
+ let targets;
41879
+ if (options.all) {
41880
+ targets = options.stackRegion ? stateRefs.filter((r) => r.region === options.stackRegion) : stateRefs;
41881
+ if (targets.length === 0) {
41882
+ logger.info("No stacks found in state");
41883
+ return;
41884
+ }
41885
+ } else {
41886
+ targets = [];
41887
+ for (const stackName of stackArgs) {
41888
+ const matches = stateRefs.filter((r) => r.stackName === stackName);
41889
+ if (matches.length === 0) {
41890
+ throw new Error(
41891
+ `No state found for stack '${stackName}'. Run 'cdkd state list' to see available stacks.`
41892
+ );
41893
+ }
41894
+ if (options.stackRegion) {
41895
+ const ref = matches.find((r) => r.region === options.stackRegion);
41896
+ if (!ref) {
41897
+ const seen = matches.map((r) => r.region ?? "(legacy)").join(", ");
41898
+ throw new Error(
41899
+ `No state found for stack '${stackName}' in region '${options.stackRegion}'. Available regions: ${seen}.`
41900
+ );
41901
+ }
41902
+ targets.push(ref);
41903
+ } else if (matches.length === 1) {
41904
+ targets.push(matches[0]);
41905
+ } else {
41906
+ const regions = matches.map((r) => r.region ?? "(legacy)").join(", ");
41907
+ throw new Error(
41908
+ `Stack '${stackName}' has state in multiple regions: ${regions}. Re-run with --stack-region <region> to disambiguate.`
41909
+ );
41910
+ }
41911
+ }
41912
+ }
41913
+ if (!options.yes && !options.dryRun) {
41914
+ const targetList = targets.map(formatStackRef).join(", ");
41915
+ const ok = await confirmRefresh(
41916
+ `Refresh observedProperties for ${targets.length} stack(s) (${targetList})?`
41917
+ );
41918
+ if (!ok) {
41919
+ logger.info("Aborted.");
41920
+ return;
41921
+ }
41922
+ }
41923
+ let totalRefreshed = 0;
41924
+ let totalUnsupported = 0;
41925
+ let totalFailed = 0;
41926
+ for (const target of targets) {
41927
+ if (!target.region) {
41928
+ throw new Error(
41929
+ `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.`
41930
+ );
41931
+ }
41932
+ const counts = await refreshObservedForStack(
41933
+ target.stackName,
41934
+ target.region,
41935
+ setup.stateBackend,
41936
+ setup.lockManager,
41937
+ providerRegistry,
41938
+ { dryRun: options.dryRun ?? false, logger }
41939
+ );
41940
+ totalRefreshed += counts.refreshed;
41941
+ totalUnsupported += counts.unsupported;
41942
+ totalFailed += counts.failed;
41943
+ }
41944
+ 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`;
41945
+ logger.info(`
41946
+ ${summary}`);
41947
+ if (totalFailed > 0) {
41948
+ throw new PartialFailureError(
41949
+ `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.`
41950
+ );
41951
+ }
41952
+ } finally {
41953
+ setup.dispose();
41954
+ }
41955
+ }
41956
+ async function refreshObservedForStack(stackName, region, stateBackend, lockManager, providerRegistry, opts) {
41957
+ const { logger } = opts;
41958
+ const result = await stateBackend.getState(stackName, region);
41959
+ if (!result) {
41960
+ throw new Error(
41961
+ `No state found for stack '${stackName}' (${region}). Run 'cdkd state list' to see available stacks.`
41962
+ );
41963
+ }
41964
+ const { state, etag, migrationPending } = result;
41965
+ const entries = Object.entries(state.resources ?? {});
41966
+ if (entries.length === 0) {
41967
+ logger.info(`\u2713 ${stackName} (${region}): no resources in state, skipping`);
41968
+ return { refreshed: 0, unsupported: 0, failed: 0 };
41969
+ }
41970
+ if (opts.dryRun) {
41971
+ let wouldRefresh = 0;
41972
+ let wouldUnsupported = 0;
41973
+ for (const [, resource] of entries) {
41974
+ let provider;
41975
+ try {
41976
+ provider = providerRegistry.getProvider(resource.resourceType);
41977
+ } catch {
41978
+ wouldUnsupported++;
41979
+ continue;
41980
+ }
41981
+ if (provider.readCurrentState)
41982
+ wouldRefresh++;
41983
+ else
41984
+ wouldUnsupported++;
41985
+ }
41986
+ logger.info(
41987
+ `Plan ${stackName} (${region}): ${wouldRefresh} resource(s) would be refreshed, ${wouldUnsupported} unsupported`
41988
+ );
41989
+ return { refreshed: wouldRefresh, unsupported: wouldUnsupported, failed: 0 };
41990
+ }
41991
+ const owner = `${process.env["USER"] || "unknown"}@${process.env["HOSTNAME"] || "host"}:${process.pid}`;
41992
+ await lockManager.acquireLock(stackName, region, owner, "state-refresh-observed");
41993
+ try {
41994
+ let refreshed = 0;
41995
+ let unsupported = 0;
41996
+ let failed = 0;
41997
+ await withStackName(stackName, async () => {
41998
+ const tasks = entries.map(async ([logicalId, resource]) => {
41999
+ if (providerRegistry.shouldSkipResource(resource.resourceType)) {
42000
+ unsupported++;
42001
+ return;
42002
+ }
42003
+ let provider;
42004
+ try {
42005
+ provider = providerRegistry.getProvider(resource.resourceType);
42006
+ } catch {
42007
+ unsupported++;
42008
+ return;
42009
+ }
42010
+ if (!provider.readCurrentState) {
42011
+ unsupported++;
42012
+ return;
42013
+ }
42014
+ try {
42015
+ const observed = await provider.readCurrentState(
42016
+ resource.physicalId,
42017
+ logicalId,
42018
+ resource.resourceType,
42019
+ resource.properties ?? {}
42020
+ );
42021
+ if (observed === void 0) {
42022
+ unsupported++;
42023
+ return;
42024
+ }
42025
+ resource.observedProperties = observed;
42026
+ refreshed++;
42027
+ } catch (err) {
42028
+ failed++;
42029
+ logger.warn(
42030
+ ` \u2717 ${stackName}/${logicalId} (${resource.resourceType}): readCurrentState failed \u2014 ${err instanceof Error ? err.message : String(err)}`
42031
+ );
42032
+ }
42033
+ });
42034
+ await Promise.all(tasks);
42035
+ });
42036
+ state.lastModified = Date.now();
42037
+ const saveOptions = {
42038
+ expectedEtag: etag
42039
+ };
42040
+ if (migrationPending)
42041
+ saveOptions.migrateLegacy = true;
42042
+ await stateBackend.saveState(stackName, region, state, saveOptions);
42043
+ logger.info(
42044
+ `\u2713 ${stackName} (${region}): ${refreshed} refreshed, ${unsupported} unsupported, ${failed} failed`
42045
+ );
42046
+ return { refreshed, unsupported, failed };
42047
+ } finally {
42048
+ await lockManager.releaseLock(stackName, region).catch((err) => {
42049
+ logger.warn(
42050
+ `Failed to release lock for ${stackName} (${region}): ` + (err instanceof Error ? err.message : String(err))
42051
+ );
42052
+ });
42053
+ }
42054
+ }
42055
+ async function confirmRefresh(prompt) {
42056
+ const rl = readline5.createInterface({ input: process.stdin, output: process.stdout });
42057
+ try {
42058
+ const ans = await rl.question(`${prompt} [y/N] `);
42059
+ return /^y(es)?$/i.test(ans.trim());
42060
+ } finally {
42061
+ rl.close();
42062
+ }
42063
+ }
42064
+ function createStateRefreshObservedCommand() {
42065
+ const cmd = new Command12("refresh-observed").description(
42066
+ "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."
42067
+ ).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));
42068
+ [...commonOptions, ...stateOptions].forEach((opt) => cmd.addOption(opt));
42069
+ cmd.addOption(deprecatedRegionOption);
42070
+ return cmd;
42071
+ }
41849
42072
  function createStateCommand() {
41850
42073
  const cmd = new Command12("state").description("Manage cdkd state stored in S3");
41851
42074
  cmd.addCommand(createStateInfoCommand());
@@ -41855,6 +42078,7 @@ function createStateCommand() {
41855
42078
  cmd.addCommand(createStateOrphanCommand());
41856
42079
  cmd.addCommand(createStateDestroyCommand());
41857
42080
  cmd.addCommand(createStateMigrateCommand());
42081
+ cmd.addCommand(createStateRefreshObservedCommand());
41858
42082
  return cmd;
41859
42083
  }
41860
42084
 
@@ -42637,7 +42861,7 @@ function reorderArgs(argv) {
42637
42861
  }
42638
42862
  async function main() {
42639
42863
  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");
42864
+ program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.50.0");
42641
42865
  program.addCommand(createBootstrapCommand());
42642
42866
  program.addCommand(createSynthCommand());
42643
42867
  program.addCommand(createListCommand());