@beesolve/aws-accounts 1.1.0 → 1.2.1

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/dist/cli.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { parseArgs } from "node:util";
2
2
  import { createInterface } from "node:readline/promises";
3
+ import { basename } from "node:path";
3
4
  import { S3Client } from "@aws-sdk/client-s3";
4
5
  import { IAMClient } from "@aws-sdk/client-iam";
5
6
  import { LambdaClient } from "@aws-sdk/client-lambda";
@@ -11,7 +12,10 @@ import {
11
12
  resolveAwsRegion
12
13
  } from "./awsClientConfig.js";
13
14
  import { consoleLogger } from "./logger.js";
14
- import { runGraveyardCommand } from "./commands/graveyard.js";
15
+ import {
16
+ runGraveyardCloseCommand,
17
+ runGraveyardCommand
18
+ } from "./commands/graveyard.js";
15
19
  import { runProfileCommand } from "./commands/profile.js";
16
20
  import { runRegenerateCommand } from "./commands/regenerate.js";
17
21
  import { runValidateCommand } from "./commands/validate.js";
@@ -21,13 +25,16 @@ import {
21
25
  runRemoteInit,
22
26
  runRemotePlan,
23
27
  runRemoteApply,
24
- runRemoteUpgrade
28
+ runRemoteUpgrade,
29
+ runRemoteDrift
25
30
  } from "./commands/remote.js";
26
31
  import {
27
32
  classifyCliError,
28
33
  exitCodeForCliErrorKind,
29
34
  toUsageError
30
35
  } from "./error.js";
36
+ import { assertUnreachable } from "./helpers.js";
37
+ import { readAwsContextFromFile, readPackageVersion } from "./awsConfig.js";
31
38
  const commands = [
32
39
  "bootstrap",
33
40
  "scan",
@@ -38,7 +45,8 @@ const commands = [
38
45
  "profile",
39
46
  "plan",
40
47
  "apply",
41
- "upgrade"
48
+ "upgrade",
49
+ "drift"
42
50
  ];
43
51
  function isCommandName(value) {
44
52
  return commands.includes(value);
@@ -55,6 +63,7 @@ async function main() {
55
63
  "ignore-unsupported": { type: "boolean", default: false },
56
64
  "allow-destructive": { type: "boolean", default: false },
57
65
  refresh: { type: "boolean", default: false },
66
+ update: { type: "boolean", default: false },
58
67
  "sso-start-url": { type: "string" },
59
68
  "sso-session": { type: "string", default: "sso" },
60
69
  help: { type: "boolean", default: false }
@@ -101,6 +110,21 @@ async function main() {
101
110
  return;
102
111
  }
103
112
  if (command === "graveyard") {
113
+ const subcommand = args.positionals[1];
114
+ if (subcommand === "close") {
115
+ await runGraveyardCloseCommand({
116
+ logger,
117
+ cachePath: ".remote-state-cache.json",
118
+ contextPath
119
+ });
120
+ return;
121
+ }
122
+ if (subcommand != null) {
123
+ printHelp(logger);
124
+ throw toUsageError(
125
+ `Unknown graveyard subcommand: "${subcommand}". Valid subcommands: close`
126
+ );
127
+ }
104
128
  await runGraveyardCommand({
105
129
  logger,
106
130
  cachePath: ".remote-state-cache.json",
@@ -112,7 +136,9 @@ async function main() {
112
136
  const ssoStartUrl = args.values["sso-start-url"] ?? process.env.AWS_SSO_START_URL;
113
137
  if (ssoStartUrl == null) {
114
138
  printHelp(logger);
115
- throw toUsageError("--sso-start-url is required for the profile command (or set AWS_SSO_START_URL).");
139
+ throw toUsageError(
140
+ "--sso-start-url is required for the profile command (or set AWS_SSO_START_URL)."
141
+ );
116
142
  }
117
143
  await runProfileCommand({
118
144
  logger,
@@ -136,7 +162,8 @@ async function main() {
136
162
  yes: args.values.yes ?? false,
137
163
  refresh: args.values.refresh ?? false,
138
164
  allowDestructive: args.values["allow-destructive"] ?? false,
139
- ignoreUnsupported: args.values["ignore-unsupported"] ?? false
165
+ ignoreUnsupported: args.values["ignore-unsupported"] ?? false,
166
+ update: args.values.update ?? false
140
167
  },
141
168
  logger,
142
169
  overwriteConfirmation,
@@ -147,53 +174,73 @@ async function main() {
147
174
  ssoAdminClient: new SSOAdminClient(clientConfig)
148
175
  };
149
176
  if (command === "bootstrap") {
150
- return runRemoteBootstrap(remoteInput);
177
+ await runRemoteBootstrap(remoteInput);
178
+ await printVersionBannerIfNeeded(logger);
179
+ return;
151
180
  }
152
181
  if (command === "scan") {
153
- return runRemoteScan(remoteInput);
182
+ await runRemoteScan(remoteInput);
183
+ await printVersionBannerIfNeeded(logger);
184
+ return;
154
185
  }
155
186
  if (command === "init") {
156
- return runRemoteInit(remoteInput);
187
+ await runRemoteInit(remoteInput);
188
+ await printVersionBannerIfNeeded(logger);
189
+ return;
157
190
  }
158
191
  if (command === "plan") {
159
- return runRemotePlan(remoteInput);
192
+ await runRemotePlan(remoteInput);
193
+ await printVersionBannerIfNeeded(logger);
194
+ return;
160
195
  }
161
196
  if (command === "apply") {
162
- return runRemoteApply(remoteInput);
197
+ await runRemoteApply(remoteInput);
198
+ await printVersionBannerIfNeeded(logger);
199
+ return;
163
200
  }
164
201
  if (command === "upgrade") {
165
- return runRemoteUpgrade(remoteInput);
202
+ await runRemoteUpgrade(remoteInput);
203
+ await printVersionBannerIfNeeded(logger);
204
+ return;
205
+ }
206
+ if (command === "drift") {
207
+ await runRemoteDrift(remoteInput);
208
+ await printVersionBannerIfNeeded(logger);
209
+ return;
166
210
  }
167
- printHelp(logger);
168
- process.exitCode = 1;
211
+ assertUnreachable(command, `Unhandled remote command: "${command}"`);
169
212
  }
170
213
  function printHelp(logger) {
214
+ const cmd = basename(process.argv[1], ".js");
171
215
  logger.log("@beesolve/aws-accounts");
172
216
  logger.log("");
173
217
  logger.log("Usage:");
174
218
  logger.log(
175
- " npm run cli -- bootstrap [--profile <name>] [--region <region>] [--yes]"
219
+ ` ${cmd} bootstrap [--profile <name>] [--region <region>] [--yes]`
176
220
  );
221
+ logger.log(` ${cmd} scan [--profile <name>] [--region <region>]`);
177
222
  logger.log(
178
- " npm run cli -- scan [--profile <name>] [--region <region>]"
223
+ ` ${cmd} init [--profile <name>] [--region <region>] [--yes]`
179
224
  );
180
225
  logger.log(
181
- " npm run cli -- init [--profile <name>] [--region <region>] [--yes]"
226
+ ` ${cmd} init --update [--profile <name>] [--region <region>] [--yes]`
182
227
  );
183
- logger.log(" npm run cli -- regenerate [--yes]");
184
- logger.log(" npm run cli -- validate");
185
- logger.log(" npm run cli -- graveyard");
228
+ logger.log(` ${cmd} regenerate [--yes]`);
229
+ logger.log(` ${cmd} validate`);
230
+ logger.log(` ${cmd} graveyard`);
231
+ logger.log(` ${cmd} graveyard close`);
186
232
  logger.log(
187
- " npm run cli -- profile --sso-start-url <url> [--sso-session <name>] (env: AWS_SSO_START_URL)"
233
+ ` ${cmd} profile --sso-start-url <url> [--sso-session <name>] (env: AWS_SSO_START_URL)`
188
234
  );
189
235
  logger.log(
190
- " npm run cli -- plan [--profile <name>] [--region <region>] [--refresh]"
236
+ ` ${cmd} plan [--profile <name>] [--region <region>] [--refresh]`
191
237
  );
192
238
  logger.log(
193
- " npm run cli -- apply [--profile <name>] [--region <region>] [--yes] [--allow-destructive] [--ignore-unsupported]"
239
+ ` ${cmd} apply [--profile <name>] [--region <region>] [--yes] [--allow-destructive] [--ignore-unsupported]`
194
240
  );
241
+ logger.log(` ${cmd} upgrade [--profile <name>] [--region <region>]`);
195
242
  logger.log(
196
- " npm run cli -- upgrade [--profile <name>] [--region <region>]"
243
+ ` ${cmd} drift [--profile <name>] [--region <region>] [--refresh]`
197
244
  );
198
245
  logger.log("");
199
246
  logger.log("Environment fallback:");
@@ -227,6 +274,22 @@ function buildOverwriteConfirmation(props) {
227
274
  }
228
275
  };
229
276
  }
277
+ async function printVersionBannerIfNeeded(logger) {
278
+ try {
279
+ const [context, currentVersion] = await Promise.all([
280
+ readAwsContextFromFile(contextPath),
281
+ readPackageVersion()
282
+ ]);
283
+ const remoteVersion = context.deployment?.cliVersion;
284
+ if (remoteVersion != null && remoteVersion !== currentVersion) {
285
+ logger.log("");
286
+ logger.log(
287
+ `New version installed (local: ${currentVersion}, remote: ${remoteVersion}). Run upgrade then init --update to sync.`
288
+ );
289
+ }
290
+ } catch {
291
+ }
292
+ }
230
293
  main().catch((error) => {
231
294
  const classified = classifyCliError(error);
232
295
  consoleLogger.error(`CLI ${classified.kind} error: ${classified.message}`);
@@ -1,5 +1,31 @@
1
1
  import { readAwsContextFromFile } from "../awsConfig.js";
2
2
  import { readStateCache } from "../remoteStateCache.js";
3
+ async function runGraveyardCloseCommand(props) {
4
+ const [cache, context] = await Promise.all([
5
+ readStateCache(props.cachePath),
6
+ readAwsContextFromFile(props.contextPath)
7
+ ]);
8
+ if (cache == null) {
9
+ throw new Error(
10
+ `No remote state cache found at "${props.cachePath}". Run a scan or apply command first to populate the cache.`
11
+ );
12
+ }
13
+ const graveyardOuId = context.organization.graveyardOuId;
14
+ const eligible = cache.state.organization.accounts.filter((a) => a.parentId === graveyardOuId && a.status === "ACTIVE").sort((a, b) => a.name.localeCompare(b.name));
15
+ if (eligible.length === 0) {
16
+ props.logger.log("No accounts eligible for closure in Graveyard.");
17
+ return;
18
+ }
19
+ props.logger.log(`${eligible.length} account(s) eligible for closure:
20
+ `);
21
+ for (const account of eligible) {
22
+ props.logger.log(`# ${account.name} (${account.id})`);
23
+ props.logger.log(
24
+ `aws organizations close-account --account-id ${account.id}`
25
+ );
26
+ props.logger.log("");
27
+ }
28
+ }
3
29
  async function runGraveyardCommand(props) {
4
30
  const [cache, context] = await Promise.all([
5
31
  readStateCache(props.cachePath),
@@ -42,5 +68,6 @@ async function runGraveyardCommand(props) {
42
68
  };
43
69
  }
44
70
  export {
71
+ runGraveyardCloseCommand,
45
72
  runGraveyardCommand
46
73
  };
@@ -36,6 +36,7 @@ import {
36
36
  loadAwsConfigModelFromTsFile,
37
37
  mapAwsConfigToState,
38
38
  readAwsContextFromFile,
39
+ readPackageVersion,
39
40
  regenerateTypesFromState,
40
41
  writeAwsConfigFromState
41
42
  } from "../awsConfig.js";
@@ -51,16 +52,18 @@ import {
51
52
  import { applyReservedOuDeletionGuard } from "../reservedOuDeletion.js";
52
53
  import { validateState } from "../state.js";
53
54
  import { assertUnreachable, delay } from "../helpers.js";
55
+ import { toPreconditionError } from "../error.js";
54
56
  import { sts, organizations, sso, identitystore, s3, logs, account, iam, lambda } from "@beesolve/iam-policy-ts";
55
57
  const remoteCommandSchema = v.object({
56
- subcommand: v.picklist(["bootstrap", "scan", "init", "plan", "apply", "upgrade"]),
58
+ subcommand: v.picklist(["bootstrap", "scan", "init", "plan", "apply", "upgrade", "drift"]),
57
59
  profile: v.optional(v.string()),
58
60
  region: v.optional(v.string()),
59
61
  flags: v.object({
60
62
  yes: v.boolean(),
61
63
  refresh: v.boolean(),
62
64
  allowDestructive: v.boolean(),
63
- ignoreUnsupported: v.boolean()
65
+ ignoreUnsupported: v.boolean(),
66
+ update: v.boolean()
64
67
  })
65
68
  });
66
69
  const contextFilePath = "aws.context.json";
@@ -121,12 +124,14 @@ async function runRemoteBootstrap(input) {
121
124
  context = await readAwsContextFromFile(contextFilePath);
122
125
  } catch {
123
126
  }
127
+ const cliVersionForBootstrap = await readPackageVersion();
124
128
  const deployment = {
125
129
  profile: input.profile ?? "",
126
130
  region: resolvedRegion,
127
131
  lambdaArn,
128
132
  stateBucketName: bucketName,
129
- stateCacheTtlSeconds: 300
133
+ stateCacheTtlSeconds: 300,
134
+ cliVersion: cliVersionForBootstrap
130
135
  };
131
136
  const updatedContext = context != null ? { ...context, deployment } : {
132
137
  version: "1",
@@ -172,22 +177,7 @@ async function runRemoteBootstrap(input) {
172
177
  input.logger.log(` Lambda ARN: ${lambdaArn}`);
173
178
  input.logger.log(` State bucket: ${bucketName}`);
174
179
  }
175
- async function ensureIamRole(props) {
176
- const trustPolicy = JSON.stringify({
177
- Version: "2012-10-17",
178
- Statement: [
179
- {
180
- Effect: "Allow",
181
- Principal: { Service: "lambda.amazonaws.com" },
182
- Action: sts("AssumeRole")
183
- }
184
- ]
185
- });
186
- const { roleArn } = await getOrCreateIamRole({
187
- iamClient: props.iamClient,
188
- trustPolicy,
189
- logger: props.logger
190
- });
180
+ async function applyLambdaRolePolicy(props) {
191
181
  const inlinePolicy = JSON.stringify({
192
182
  Version: "2012-10-17",
193
183
  Statement: [
@@ -220,7 +210,12 @@ async function ensureIamRole(props) {
220
210
  },
221
211
  {
222
212
  Effect: "Allow",
223
- Action: [account("PutAccountName")],
213
+ Action: [
214
+ account("PutAccountName"),
215
+ account("GetAlternateContact"),
216
+ account("PutAlternateContact"),
217
+ account("DeleteAlternateContact")
218
+ ],
224
219
  Resource: "*"
225
220
  }
226
221
  ]
@@ -232,6 +227,27 @@ async function ensureIamRole(props) {
232
227
  PolicyDocument: inlinePolicy
233
228
  })
234
229
  );
230
+ }
231
+ async function ensureIamRole(props) {
232
+ const trustPolicy = JSON.stringify({
233
+ Version: "2012-10-17",
234
+ Statement: [
235
+ {
236
+ Effect: "Allow",
237
+ Principal: { Service: "lambda.amazonaws.com" },
238
+ Action: sts("AssumeRole")
239
+ }
240
+ ]
241
+ });
242
+ const { roleArn } = await getOrCreateIamRole({
243
+ iamClient: props.iamClient,
244
+ trustPolicy,
245
+ logger: props.logger
246
+ });
247
+ await applyLambdaRolePolicy({
248
+ iamClient: props.iamClient,
249
+ bucketName: props.bucketName
250
+ });
235
251
  return { roleArn };
236
252
  }
237
253
  async function getOrCreateIamRole(props) {
@@ -395,11 +411,27 @@ async function runRemoteScan(input) {
395
411
  input.logger.log(` Groups: ${response.summary.groups}`);
396
412
  input.logger.log(` Permission Sets: ${response.summary.permissionSets}`);
397
413
  input.logger.log(` Account Assignments: ${response.summary.accountAssignments}`);
414
+ input.logger.log(` Policies: ${response.summary.policies}`);
415
+ input.logger.log(` Policy Attachments: ${response.summary.policyAttachments}`);
398
416
  await writeStateCache(cachePath, response.state);
399
417
  input.logger.log("State cache updated.");
400
418
  }
401
419
  async function runRemoteInit(input) {
402
- const deployment = await readDeploymentFromContext();
420
+ const isUpdate = input.flags.update;
421
+ let existingConfig;
422
+ if (isUpdate) {
423
+ try {
424
+ existingConfig = await loadAwsConfigModelFromTsFile({
425
+ configPath: configFilePath,
426
+ typesPath: typesFilePath
427
+ });
428
+ } catch {
429
+ }
430
+ }
431
+ const [deployment, cliVersion] = await Promise.all([
432
+ readDeploymentFromContext(),
433
+ readPackageVersion()
434
+ ]);
403
435
  input.logger.log("Invoking remote scan...");
404
436
  const result = await invokeLambda({
405
437
  lambdaClient: input.lambdaClient,
@@ -420,14 +452,17 @@ async function runRemoteInit(input) {
420
452
  input.logger.log(` Groups: ${response.summary.groups}`);
421
453
  input.logger.log(` Permission Sets: ${response.summary.permissionSets}`);
422
454
  input.logger.log(` Account Assignments: ${response.summary.accountAssignments}`);
455
+ input.logger.log(` Policies: ${response.summary.policies}`);
456
+ input.logger.log(` Policy Attachments: ${response.summary.policyAttachments}`);
423
457
  await writeStateCache(cachePath, response.state);
424
458
  input.logger.log("State cache updated.");
425
459
  const context = await readAwsContextFromFile(contextFilePath);
426
460
  const graveyardOu = response.state.organization.organizationalUnits.find(
427
461
  (ou) => ou.name === "Graveyard"
428
462
  );
429
- const updatedContext = {
430
- ...context,
463
+ const ordered = {
464
+ version: context.version,
465
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
431
466
  organization: {
432
467
  managementAccountId: context.organization.managementAccountId,
433
468
  rootId: response.state.organization.rootId,
@@ -436,14 +471,8 @@ async function runRemoteInit(input) {
436
471
  identityCenter: {
437
472
  instanceArn: response.state.identityCenter.instanceArn,
438
473
  identityStoreId: response.state.identityCenter.identityStoreId
439
- }
440
- };
441
- const ordered = {
442
- version: updatedContext.version,
443
- generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
444
- organization: updatedContext.organization,
445
- identityCenter: updatedContext.identityCenter,
446
- deployment: updatedContext.deployment
474
+ },
475
+ deployment: { ...deployment, cliVersion }
447
476
  };
448
477
  await writeFile(contextFilePath, `${JSON.stringify(ordered, null, 2)}
449
478
  `, "utf8");
@@ -453,12 +482,13 @@ async function runRemoteInit(input) {
453
482
  configPath: configFilePath,
454
483
  typesPath: typesFilePath,
455
484
  logger: input.logger,
456
- overwriteConfirmation: input.overwriteConfirmation
485
+ overwriteConfirmation: input.overwriteConfirmation,
486
+ existingConfig
457
487
  });
458
488
  const writtenFiles = configWriteResult.files.filter((f) => f.status === "written");
459
489
  if (writtenFiles.length > 0) {
460
490
  input.logger.log("");
461
- input.logger.log("Init complete.");
491
+ input.logger.log(isUpdate ? "Init --update complete." : "Init complete.");
462
492
  for (const file of writtenFiles) {
463
493
  input.logger.log(` ${file.path}: ${file.status}`);
464
494
  }
@@ -477,6 +507,7 @@ async function runRemotePlan(input) {
477
507
  typesPath: typesFilePath
478
508
  })
479
509
  ]);
510
+ warnIfRemotePoliciesNotInConfig({ currentState, config, logger: input.logger });
480
511
  const desiredState = mapAwsConfigToState({
481
512
  config,
482
513
  currentState,
@@ -504,6 +535,7 @@ async function runRemoteApply(input) {
504
535
  typesPath: typesFilePath
505
536
  })
506
537
  ]);
538
+ warnIfRemotePoliciesNotInConfig({ currentState, config, logger: input.logger });
507
539
  const desiredState = mapAwsConfigToState({
508
540
  config,
509
541
  currentState,
@@ -591,8 +623,11 @@ async function runRemoteApply(input) {
591
623
  });
592
624
  }
593
625
  async function runRemoteUpgrade(input) {
594
- const deployment = await readDeploymentFromContext();
595
- const lambdaZip = await readLambdaZip();
626
+ const [deployment, cliVersion, lambdaZip] = await Promise.all([
627
+ readDeploymentFromContext(),
628
+ readPackageVersion(),
629
+ readLambdaZip()
630
+ ]);
596
631
  input.logger.log(`Updating Lambda function code: ${deployment.lambdaArn}`);
597
632
  await waitForLambdaReady(input.lambdaClient, deployment.lambdaArn);
598
633
  const updateResult = await input.lambdaClient.send(
@@ -602,12 +637,102 @@ async function runRemoteUpgrade(input) {
602
637
  })
603
638
  );
604
639
  const lastModified = updateResult.LastModified ?? "unknown";
605
- input.logger.log(`Upgrade complete. Last modified: ${lastModified}`);
640
+ input.logger.log(`Lambda updated. Last modified: ${lastModified}`);
641
+ input.logger.log("Updating IAM role policy...");
642
+ await applyLambdaRolePolicy({
643
+ iamClient: input.iamClient,
644
+ bucketName: deployment.stateBucketName
645
+ });
646
+ input.logger.log("IAM role policy updated.");
647
+ const context = await readAwsContextFromFile(contextFilePath);
648
+ const ordered = {
649
+ version: context.version,
650
+ generatedAt: context.generatedAt,
651
+ organization: context.organization,
652
+ identityCenter: context.identityCenter,
653
+ deployment: { ...deployment, cliVersion }
654
+ };
655
+ await writeFile(contextFilePath, `${JSON.stringify(ordered, null, 2)}
656
+ `, "utf8");
657
+ input.logger.log("");
658
+ input.logger.log("Run init --update to sync your config with new remote features before using plan/apply.");
659
+ }
660
+ async function runRemoteDrift(input) {
661
+ const deployment = await readDeploymentFromContext();
662
+ const baseline = await fetchCurrentState({
663
+ input,
664
+ deployment
665
+ });
666
+ const clientConfig = buildAwsClientConfig({
667
+ profile: input.profile ?? (deployment.profile || void 0),
668
+ region: input.region ?? (deployment.region || void 0)
669
+ });
670
+ const lambdaClient = new LambdaClient(clientConfig);
671
+ input.logger.log("Scanning live AWS state...");
672
+ const result = await invokeLambda({
673
+ lambdaClient,
674
+ lambdaArn: deployment.lambdaArn,
675
+ payload: { action: "scan" }
676
+ });
677
+ if (!result.ok) {
678
+ throw new Error(formatLambdaError(result.error));
679
+ }
680
+ const response = result.response;
681
+ if (!("action" in response) || response.action !== "scan") {
682
+ throw new Error("Unexpected response from Lambda scan action.");
683
+ }
684
+ const liveState = response.state;
685
+ await writeStateCache(cachePath, liveState);
686
+ const plan = diffStates({
687
+ current: baseline,
688
+ next: liveState
689
+ });
690
+ displayDrift({ plan, logger: input.logger });
691
+ }
692
+ function displayDrift(props) {
693
+ const driftOperations = props.plan.operations.filter(
694
+ (operation) => operation.kind !== "provisionIdcPermissionSet"
695
+ );
696
+ if (driftOperations.length === 0 && props.plan.unsupported.length === 0) {
697
+ props.logger.log("No drift.");
698
+ return;
699
+ }
700
+ props.logger.log(`Drift: ${driftOperations.length} change(s) detected since last scan`);
701
+ for (const operation of driftOperations) {
702
+ props.logger.log(formatOperationLine(operation));
703
+ }
704
+ if (props.plan.unsupported.length > 0) {
705
+ props.logger.log("Unsupported diffs:");
706
+ for (const diff of props.plan.unsupported) {
707
+ props.logger.log(` - ${diff.description} [${diff.category}]`);
708
+ }
709
+ }
710
+ }
711
+ function warnIfRemotePoliciesNotInConfig(props) {
712
+ const remotePolicies = props.currentState.organization.policies ?? [];
713
+ const hasRemotePolicies = remotePolicies.length > 0;
714
+ const hasLocalPolicies = props.config.policies.serviceControlPolicies.length > 0 || props.config.policies.resourceControlPolicies.length > 0;
715
+ if (hasRemotePolicies && !hasLocalPolicies) {
716
+ props.logger.log("");
717
+ props.logger.log("Warning: remote state contains SCPs/RCPs not present in your config. Proceeding could delete them.");
718
+ props.logger.log("Run init --update to sync first.");
719
+ props.logger.log("");
720
+ }
606
721
  }
607
722
  async function readDeploymentFromContext() {
608
- const context = await readAwsContextFromFile(contextFilePath);
723
+ let context;
724
+ try {
725
+ context = await readAwsContextFromFile(contextFilePath);
726
+ } catch (err) {
727
+ if (err.code === "ENOENT") {
728
+ throw toPreconditionError(
729
+ "aws.context.json not found. Run `aws-accounts bootstrap` first."
730
+ );
731
+ }
732
+ throw err;
733
+ }
609
734
  if (context.deployment == null) {
610
- throw new Error(
735
+ throw toPreconditionError(
611
736
  "No deployment found in aws.context.json. Run `aws-accounts bootstrap` first."
612
737
  );
613
738
  }
@@ -674,7 +799,7 @@ function displayPlan(props) {
674
799
  }
675
800
  }
676
801
  function isDestructiveOperation(operation) {
677
- return operation.kind === "deleteOu" || operation.kind === "removeAccount" || operation.kind === "deleteIdcUser" || operation.kind === "deleteIdcGroup" || operation.kind === "deleteIdcPermissionSet";
802
+ return operation.kind === "deleteOu" || operation.kind === "removeAccount" || operation.kind === "deleteIdcUser" || operation.kind === "deleteIdcGroup" || operation.kind === "deleteIdcPermissionSet" || operation.kind === "deleteIdcPermissionSetPermissionsBoundary" || operation.kind === "detachOrgPolicy" || operation.kind === "deleteOrgPolicy" || operation.kind === "deregisterDelegatedAdministrator";
678
803
  }
679
804
  function formatOperationLine(operation) {
680
805
  if (operation.kind === "moveAccount") {
@@ -752,6 +877,14 @@ function formatOperationLine(operation) {
752
877
  if (operation.kind === "provisionIdcPermissionSet") {
753
878
  return ` provision IdC permission set "${operation.permissionSetName}" to all provisioned accounts`;
754
879
  }
880
+ if (operation.kind === "putIdcPermissionSetPermissionsBoundary") {
881
+ const b = operation.permissionsBoundary;
882
+ const label = "managedPolicyArn" in b ? b.managedPolicyArn : `${b.customerManagedPolicyPath}${b.customerManagedPolicyName}`;
883
+ return ` put permissions boundary "${label}" on IdC permission set "${operation.permissionSetName}"`;
884
+ }
885
+ if (operation.kind === "deleteIdcPermissionSetPermissionsBoundary") {
886
+ return ` [destructive] delete permissions boundary from IdC permission set "${operation.permissionSetName}"`;
887
+ }
755
888
  if (operation.kind === "removeIdcGroupMembership") {
756
889
  return ` remove user "${operation.userName}" from IdC group "${operation.groupDisplayName}"`;
757
890
  }
@@ -765,6 +898,39 @@ function formatOperationLine(operation) {
765
898
  const duration = operation.sessionDuration ?? "default";
766
899
  return ` update IdC permission set session duration "${operation.permissionSetName}" -> ${duration}`;
767
900
  }
901
+ if (operation.kind === "createOrgPolicy") {
902
+ return ` create org policy "${operation.policyName}" (${operation.policyType})`;
903
+ }
904
+ if (operation.kind === "updateOrgPolicyContent") {
905
+ return ` update org policy content "${operation.policyName}"`;
906
+ }
907
+ if (operation.kind === "updateOrgPolicyDescription") {
908
+ return ` update org policy description "${operation.policyName}"`;
909
+ }
910
+ if (operation.kind === "attachOrgPolicy") {
911
+ return ` attach org policy "${operation.policyName}" to "${operation.targetName}"`;
912
+ }
913
+ if (operation.kind === "detachOrgPolicy") {
914
+ return ` [destructive] detach org policy "${operation.policyName}" from "${operation.targetName}"`;
915
+ }
916
+ if (operation.kind === "deleteOrgPolicy") {
917
+ return ` [destructive] delete org policy "${operation.policyName}"`;
918
+ }
919
+ if (operation.kind === "putAlternateContact") {
920
+ return ` set ${operation.contactType} alternate contact for "${operation.accountName}" (${operation.accountId})`;
921
+ }
922
+ if (operation.kind === "deleteAlternateContact") {
923
+ return ` [destructive] delete ${operation.contactType} alternate contact for "${operation.accountName}" (${operation.accountId})`;
924
+ }
925
+ if (operation.kind === "setIdcAccessControlAttributes") {
926
+ return ` set IdC access control attributes (${operation.attributes.length} attribute(s))`;
927
+ }
928
+ if (operation.kind === "registerDelegatedAdministrator") {
929
+ return ` register delegated administrator "${operation.accountName}" (${operation.accountId}) for ${operation.servicePrincipal}`;
930
+ }
931
+ if (operation.kind === "deregisterDelegatedAdministrator") {
932
+ return ` [destructive] deregister delegated administrator "${operation.accountName}" (${operation.accountId}) for ${operation.servicePrincipal}`;
933
+ }
768
934
  assertUnreachable(operation, "Unsupported operation kind in formatOperationLine.");
769
935
  }
770
936
  function formatPrincipalLabel(principalType, principalName) {
@@ -976,6 +1142,7 @@ async function waitForLambdaReady(lambdaClient, functionName) {
976
1142
  export {
977
1143
  runRemoteApply,
978
1144
  runRemoteBootstrap,
1145
+ runRemoteDrift,
979
1146
  runRemoteInit,
980
1147
  runRemotePlan,
981
1148
  runRemoteScan,