@beesolve/aws-accounts 1.0.7 → 1.2.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 +80 -11
- package/dist/applyLogic.js +288 -19
- package/dist/awsConfig.js +414 -32
- package/dist/cli.js +95 -25
- package/dist/commands/graveyard.js +27 -0
- package/dist/commands/profile.js +116 -0
- package/dist/commands/remote.js +152 -47
- package/dist/commands/validate.js +125 -0
- package/dist/diff.js +278 -22
- package/dist/lambda/handler.js +8 -4
- package/dist/lambdaClient.js +5 -2
- package/dist/operations.js +91 -2
- package/dist/scanLogic.js +164 -7
- package/dist/state.js +164 -7
- package/dist-lambda/handler.mjs +707 -40
- package/dist-lambda/lambda.zip +0 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -11,8 +11,13 @@ import {
|
|
|
11
11
|
resolveAwsRegion
|
|
12
12
|
} from "./awsClientConfig.js";
|
|
13
13
|
import { consoleLogger } from "./logger.js";
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
runGraveyardCloseCommand,
|
|
16
|
+
runGraveyardCommand
|
|
17
|
+
} from "./commands/graveyard.js";
|
|
18
|
+
import { runProfileCommand } from "./commands/profile.js";
|
|
15
19
|
import { runRegenerateCommand } from "./commands/regenerate.js";
|
|
20
|
+
import { runValidateCommand } from "./commands/validate.js";
|
|
16
21
|
import {
|
|
17
22
|
runRemoteBootstrap,
|
|
18
23
|
runRemoteScan,
|
|
@@ -26,12 +31,15 @@ import {
|
|
|
26
31
|
exitCodeForCliErrorKind,
|
|
27
32
|
toUsageError
|
|
28
33
|
} from "./error.js";
|
|
34
|
+
import { readAwsContextFromFile, readPackageVersion } from "./awsConfig.js";
|
|
29
35
|
const commands = [
|
|
30
36
|
"bootstrap",
|
|
31
37
|
"scan",
|
|
32
38
|
"init",
|
|
33
39
|
"regenerate",
|
|
40
|
+
"validate",
|
|
34
41
|
"graveyard",
|
|
42
|
+
"profile",
|
|
35
43
|
"plan",
|
|
36
44
|
"apply",
|
|
37
45
|
"upgrade"
|
|
@@ -51,6 +59,9 @@ async function main() {
|
|
|
51
59
|
"ignore-unsupported": { type: "boolean", default: false },
|
|
52
60
|
"allow-destructive": { type: "boolean", default: false },
|
|
53
61
|
refresh: { type: "boolean", default: false },
|
|
62
|
+
update: { type: "boolean", default: false },
|
|
63
|
+
"sso-start-url": { type: "string" },
|
|
64
|
+
"sso-session": { type: "string", default: "sso" },
|
|
54
65
|
help: { type: "boolean", default: false }
|
|
55
66
|
},
|
|
56
67
|
allowPositionals: true
|
|
@@ -87,7 +98,29 @@ async function main() {
|
|
|
87
98
|
}
|
|
88
99
|
return;
|
|
89
100
|
}
|
|
101
|
+
if (command === "validate") {
|
|
102
|
+
const valid = await runValidateCommand({ logger });
|
|
103
|
+
if (!valid) {
|
|
104
|
+
process.exitCode = 1;
|
|
105
|
+
}
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
90
108
|
if (command === "graveyard") {
|
|
109
|
+
const subcommand = args.positionals[1];
|
|
110
|
+
if (subcommand === "close") {
|
|
111
|
+
await runGraveyardCloseCommand({
|
|
112
|
+
logger,
|
|
113
|
+
cachePath: ".remote-state-cache.json",
|
|
114
|
+
contextPath
|
|
115
|
+
});
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (subcommand != null) {
|
|
119
|
+
printHelp(logger);
|
|
120
|
+
throw toUsageError(
|
|
121
|
+
`Unknown graveyard subcommand: "${subcommand}". Valid subcommands: close`
|
|
122
|
+
);
|
|
123
|
+
}
|
|
91
124
|
await runGraveyardCommand({
|
|
92
125
|
logger,
|
|
93
126
|
cachePath: ".remote-state-cache.json",
|
|
@@ -95,6 +128,24 @@ async function main() {
|
|
|
95
128
|
});
|
|
96
129
|
return;
|
|
97
130
|
}
|
|
131
|
+
if (command === "profile") {
|
|
132
|
+
const ssoStartUrl = args.values["sso-start-url"] ?? process.env.AWS_SSO_START_URL;
|
|
133
|
+
if (ssoStartUrl == null) {
|
|
134
|
+
printHelp(logger);
|
|
135
|
+
throw toUsageError(
|
|
136
|
+
"--sso-start-url is required for the profile command (or set AWS_SSO_START_URL)."
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
await runProfileCommand({
|
|
140
|
+
logger,
|
|
141
|
+
cachePath: ".remote-state-cache.json",
|
|
142
|
+
contextPath,
|
|
143
|
+
ssoStartUrl,
|
|
144
|
+
ssoSession: args.values["sso-session"] ?? "sso",
|
|
145
|
+
isTty: process.stdin.isTTY
|
|
146
|
+
});
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
98
149
|
const overwriteConfirmation = buildOverwriteConfirmation({
|
|
99
150
|
yes: args.values.yes ?? false,
|
|
100
151
|
isTty: process.stdin.isTTY
|
|
@@ -107,7 +158,8 @@ async function main() {
|
|
|
107
158
|
yes: args.values.yes ?? false,
|
|
108
159
|
refresh: args.values.refresh ?? false,
|
|
109
160
|
allowDestructive: args.values["allow-destructive"] ?? false,
|
|
110
|
-
ignoreUnsupported: args.values["ignore-unsupported"] ?? false
|
|
161
|
+
ignoreUnsupported: args.values["ignore-unsupported"] ?? false,
|
|
162
|
+
update: args.values.update ?? false
|
|
111
163
|
},
|
|
112
164
|
logger,
|
|
113
165
|
overwriteConfirmation,
|
|
@@ -118,25 +170,23 @@ async function main() {
|
|
|
118
170
|
ssoAdminClient: new SSOAdminClient(clientConfig)
|
|
119
171
|
};
|
|
120
172
|
if (command === "bootstrap") {
|
|
121
|
-
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
if (command === "
|
|
127
|
-
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
return runRemoteUpgrade(remoteInput);
|
|
173
|
+
await runRemoteBootstrap(remoteInput);
|
|
174
|
+
} else if (command === "scan") {
|
|
175
|
+
await runRemoteScan(remoteInput);
|
|
176
|
+
} else if (command === "init") {
|
|
177
|
+
await runRemoteInit(remoteInput);
|
|
178
|
+
} else if (command === "plan") {
|
|
179
|
+
await runRemotePlan(remoteInput);
|
|
180
|
+
} else if (command === "apply") {
|
|
181
|
+
await runRemoteApply(remoteInput);
|
|
182
|
+
} else if (command === "upgrade") {
|
|
183
|
+
await runRemoteUpgrade(remoteInput);
|
|
184
|
+
} else {
|
|
185
|
+
printHelp(logger);
|
|
186
|
+
process.exitCode = 1;
|
|
187
|
+
return;
|
|
137
188
|
}
|
|
138
|
-
|
|
139
|
-
process.exitCode = 1;
|
|
189
|
+
await printVersionBannerIfNeeded(logger);
|
|
140
190
|
}
|
|
141
191
|
function printHelp(logger) {
|
|
142
192
|
logger.log("@beesolve/aws-accounts");
|
|
@@ -145,23 +195,27 @@ function printHelp(logger) {
|
|
|
145
195
|
logger.log(
|
|
146
196
|
" npm run cli -- bootstrap [--profile <name>] [--region <region>] [--yes]"
|
|
147
197
|
);
|
|
198
|
+
logger.log(" npm run cli -- scan [--profile <name>] [--region <region>]");
|
|
148
199
|
logger.log(
|
|
149
|
-
" npm run cli --
|
|
200
|
+
" npm run cli -- init [--profile <name>] [--region <region>] [--yes]"
|
|
150
201
|
);
|
|
151
202
|
logger.log(
|
|
152
|
-
" npm run cli -- init [--profile <name>] [--region <region>] [--yes]"
|
|
203
|
+
" npm run cli -- init --update [--profile <name>] [--region <region>] [--yes]"
|
|
153
204
|
);
|
|
154
205
|
logger.log(" npm run cli -- regenerate [--yes]");
|
|
206
|
+
logger.log(" npm run cli -- validate");
|
|
155
207
|
logger.log(" npm run cli -- graveyard");
|
|
208
|
+
logger.log(" npm run cli -- graveyard close");
|
|
156
209
|
logger.log(
|
|
157
|
-
" npm run cli --
|
|
210
|
+
" npm run cli -- profile --sso-start-url <url> [--sso-session <name>] (env: AWS_SSO_START_URL)"
|
|
158
211
|
);
|
|
159
212
|
logger.log(
|
|
160
|
-
" npm run cli --
|
|
213
|
+
" npm run cli -- plan [--profile <name>] [--region <region>] [--refresh]"
|
|
161
214
|
);
|
|
162
215
|
logger.log(
|
|
163
|
-
" npm run cli --
|
|
216
|
+
" npm run cli -- apply [--profile <name>] [--region <region>] [--yes] [--allow-destructive] [--ignore-unsupported]"
|
|
164
217
|
);
|
|
218
|
+
logger.log(" npm run cli -- upgrade [--profile <name>] [--region <region>]");
|
|
165
219
|
logger.log("");
|
|
166
220
|
logger.log("Environment fallback:");
|
|
167
221
|
logger.log(" AWS_PROFILE, AWS_REGION, AWS_DEFAULT_REGION");
|
|
@@ -194,6 +248,22 @@ function buildOverwriteConfirmation(props) {
|
|
|
194
248
|
}
|
|
195
249
|
};
|
|
196
250
|
}
|
|
251
|
+
async function printVersionBannerIfNeeded(logger) {
|
|
252
|
+
try {
|
|
253
|
+
const [context, currentVersion] = await Promise.all([
|
|
254
|
+
readAwsContextFromFile(contextPath),
|
|
255
|
+
readPackageVersion()
|
|
256
|
+
]);
|
|
257
|
+
const remoteVersion = context.deployment?.cliVersion;
|
|
258
|
+
if (remoteVersion != null && remoteVersion !== currentVersion) {
|
|
259
|
+
logger.log("");
|
|
260
|
+
logger.log(
|
|
261
|
+
`New version installed (local: ${currentVersion}, remote: ${remoteVersion}). Run upgrade then init --update to sync.`
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
} catch {
|
|
265
|
+
}
|
|
266
|
+
}
|
|
197
267
|
main().catch((error) => {
|
|
198
268
|
const classified = classifyCliError(error);
|
|
199
269
|
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
|
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
import { readAwsContextFromFile } from "../awsConfig.js";
|
|
3
|
+
import { readStateCache } from "../remoteStateCache.js";
|
|
4
|
+
async function runProfileCommand(input) {
|
|
5
|
+
const cache = await readStateCache(input.cachePath);
|
|
6
|
+
if (cache == null) {
|
|
7
|
+
throw new Error(
|
|
8
|
+
`No remote state cache found at "${input.cachePath}". Run scan or plan first.`
|
|
9
|
+
);
|
|
10
|
+
}
|
|
11
|
+
const context = await readAwsContextFromFile(input.contextPath);
|
|
12
|
+
const region = context.deployment?.region || process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || "us-east-1";
|
|
13
|
+
const entries = buildProfileEntries(cache.state);
|
|
14
|
+
if (entries.length === 0) {
|
|
15
|
+
input.logger.log("No account assignments found in state cache.");
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const selected = await selectEntry({ entries, logger: input.logger, isTty: input.isTty });
|
|
19
|
+
if (selected == null) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const profileName = buildProfileName(selected);
|
|
23
|
+
const block = renderProfileBlock({
|
|
24
|
+
profileName,
|
|
25
|
+
ssoSession: input.ssoSession,
|
|
26
|
+
accountId: selected.accountId,
|
|
27
|
+
roleName: selected.permissionSetName,
|
|
28
|
+
ssoStartUrl: input.ssoStartUrl,
|
|
29
|
+
region,
|
|
30
|
+
ssoRegistrationScopes: "sso:account:access"
|
|
31
|
+
});
|
|
32
|
+
input.logger.log("");
|
|
33
|
+
input.logger.log(block);
|
|
34
|
+
}
|
|
35
|
+
async function selectEntry(props) {
|
|
36
|
+
if (props.isTty !== true) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
"Profile command requires an interactive terminal. Use --account and --permission-set in non-interactive mode (not yet supported)."
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
props.logger.log("Select an account/permission-set combination:");
|
|
42
|
+
props.logger.log("");
|
|
43
|
+
for (const [index, entry] of props.entries.entries()) {
|
|
44
|
+
props.logger.log(
|
|
45
|
+
` ${index + 1}. ${entry.accountName} / ${entry.permissionSetName} (${entry.accountId})`
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
props.logger.log("");
|
|
49
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
50
|
+
try {
|
|
51
|
+
let choice;
|
|
52
|
+
while (choice == null) {
|
|
53
|
+
const answer = await rl.question(`Enter number (1-${props.entries.length}): `);
|
|
54
|
+
const parsed = parseInt(answer.trim(), 10);
|
|
55
|
+
if (!Number.isNaN(parsed) && parsed >= 1 && parsed <= props.entries.length) {
|
|
56
|
+
choice = parsed;
|
|
57
|
+
} else {
|
|
58
|
+
props.logger.log(`Please enter a number between 1 and ${props.entries.length}.`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return props.entries[choice - 1] ?? null;
|
|
62
|
+
} finally {
|
|
63
|
+
rl.close();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function buildProfileEntries(state) {
|
|
67
|
+
const accountById = Object.fromEntries(state.organization.accounts.map((a) => [a.id, a]));
|
|
68
|
+
const permissionSetByArn = Object.fromEntries(
|
|
69
|
+
state.identityCenter.permissionSets.map((ps) => [ps.permissionSetArn, ps])
|
|
70
|
+
);
|
|
71
|
+
const seen = /* @__PURE__ */ new Set();
|
|
72
|
+
const entries = [];
|
|
73
|
+
for (const assignment of state.identityCenter.accountAssignments) {
|
|
74
|
+
const key = `${assignment.accountId}|${assignment.permissionSetArn}`;
|
|
75
|
+
if (seen.has(key)) {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
seen.add(key);
|
|
79
|
+
const account = accountById[assignment.accountId];
|
|
80
|
+
const permissionSet = permissionSetByArn[assignment.permissionSetArn];
|
|
81
|
+
if (account == null || permissionSet == null) {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
entries.push({
|
|
85
|
+
accountId: account.id,
|
|
86
|
+
accountName: account.name,
|
|
87
|
+
permissionSetName: permissionSet.name
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return entries.sort((a, b) => {
|
|
91
|
+
const accountCmp = a.accountName.localeCompare(b.accountName);
|
|
92
|
+
return accountCmp !== 0 ? accountCmp : a.permissionSetName.localeCompare(b.permissionSetName);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
function buildProfileName(entry) {
|
|
96
|
+
return `${toKebabCase(entry.accountName)}-${toKebabCase(entry.permissionSetName)}`;
|
|
97
|
+
}
|
|
98
|
+
function toKebabCase(value) {
|
|
99
|
+
return value.replace(/([a-z])([A-Z])/g, "$1-$2").replace(/[\s_]+/g, "-").toLowerCase().replace(/[^a-z0-9-]/g, "");
|
|
100
|
+
}
|
|
101
|
+
function renderProfileBlock(props) {
|
|
102
|
+
return [
|
|
103
|
+
`[profile ${props.profileName}]`,
|
|
104
|
+
`sso_session = ${props.ssoSession}`,
|
|
105
|
+
`sso_account_id = ${props.accountId}`,
|
|
106
|
+
`sso_role_name = ${props.roleName}`,
|
|
107
|
+
``,
|
|
108
|
+
`[sso-session ${props.ssoSession}]`,
|
|
109
|
+
`sso_start_url = ${props.ssoStartUrl}`,
|
|
110
|
+
`sso_region = ${props.region}`,
|
|
111
|
+
`sso_registration_scopes = ${props.ssoRegistrationScopes}`
|
|
112
|
+
].join("\n");
|
|
113
|
+
}
|
|
114
|
+
export {
|
|
115
|
+
runProfileCommand
|
|
116
|
+
};
|