@devramps/cli 0.1.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/LICENSE +21 -0
- package/README.md +204 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2563 -0
- package/package.json +57 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2563 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { program } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/commands/bootstrap.ts
|
|
7
|
+
import ora from "ora";
|
|
8
|
+
|
|
9
|
+
// src/aws/credentials.ts
|
|
10
|
+
import { STSClient, GetCallerIdentityCommand } from "@aws-sdk/client-sts";
|
|
11
|
+
|
|
12
|
+
// src/utils/errors.ts
|
|
13
|
+
var DevRampsError = class extends Error {
|
|
14
|
+
constructor(message) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = "DevRampsError";
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
var NoDevrampsFolderError = class extends DevRampsError {
|
|
20
|
+
constructor() {
|
|
21
|
+
super(
|
|
22
|
+
"Could not find .devramps folder in current directory. Please run this command from the root of your project."
|
|
23
|
+
);
|
|
24
|
+
this.name = "NoDevrampsFolderError";
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
var NoCredentialsError = class extends DevRampsError {
|
|
28
|
+
constructor() {
|
|
29
|
+
super(
|
|
30
|
+
"No AWS credentials found. Please configure AWS credentials using `aws configure` or set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment variables."
|
|
31
|
+
);
|
|
32
|
+
this.name = "NoCredentialsError";
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
var RoleAssumptionError = class extends DevRampsError {
|
|
36
|
+
targetAccountId;
|
|
37
|
+
sourceAccountId;
|
|
38
|
+
roleName;
|
|
39
|
+
constructor(targetAccountId, roleName, sourceAccountId) {
|
|
40
|
+
super(
|
|
41
|
+
`Cannot bootstrap account ${targetAccountId}. Your current credentials (account ${sourceAccountId}) cannot assume role '${roleName}' in the target account. Please ensure the target account has a trust policy allowing your account to assume this role, or use --target-account-role-name to specify a different role.`
|
|
42
|
+
);
|
|
43
|
+
this.name = "RoleAssumptionError";
|
|
44
|
+
this.targetAccountId = targetAccountId;
|
|
45
|
+
this.sourceAccountId = sourceAccountId;
|
|
46
|
+
this.roleName = roleName;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
var PipelineParseError = class extends DevRampsError {
|
|
50
|
+
pipelineSlug;
|
|
51
|
+
constructor(pipelineSlug, cause) {
|
|
52
|
+
super(`Failed to parse pipeline.yaml in .devramps/${pipelineSlug}/: ${cause}`);
|
|
53
|
+
this.name = "PipelineParseError";
|
|
54
|
+
this.pipelineSlug = pipelineSlug;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
var AuthenticationError = class extends DevRampsError {
|
|
58
|
+
constructor(message) {
|
|
59
|
+
super(`Authentication failed: ${message}`);
|
|
60
|
+
this.name = "AuthenticationError";
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
var CloudFormationError = class extends DevRampsError {
|
|
64
|
+
stackName;
|
|
65
|
+
accountId;
|
|
66
|
+
constructor(stackName, accountId, cause) {
|
|
67
|
+
super(`Failed to deploy stack '${stackName}' in account ${accountId}: ${cause}`);
|
|
68
|
+
this.name = "CloudFormationError";
|
|
69
|
+
this.stackName = stackName;
|
|
70
|
+
this.accountId = accountId;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// src/utils/logger.ts
|
|
75
|
+
import chalk from "chalk";
|
|
76
|
+
var verboseMode = false;
|
|
77
|
+
function setVerbose(enabled) {
|
|
78
|
+
verboseMode = enabled;
|
|
79
|
+
}
|
|
80
|
+
function isVerbose() {
|
|
81
|
+
return verboseMode;
|
|
82
|
+
}
|
|
83
|
+
function info(message) {
|
|
84
|
+
console.log(chalk.blue("\u2139"), message);
|
|
85
|
+
}
|
|
86
|
+
function success(message) {
|
|
87
|
+
console.log(chalk.green("\u2714"), message);
|
|
88
|
+
}
|
|
89
|
+
function warn(message) {
|
|
90
|
+
console.log(chalk.yellow("\u26A0"), message);
|
|
91
|
+
}
|
|
92
|
+
function error(message) {
|
|
93
|
+
console.error(chalk.red("\u2716"), message);
|
|
94
|
+
}
|
|
95
|
+
function verbose(message) {
|
|
96
|
+
if (verboseMode) {
|
|
97
|
+
console.log(chalk.gray(" \u2192"), chalk.gray(message));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function header(message) {
|
|
101
|
+
console.log();
|
|
102
|
+
console.log(chalk.bold.underline(message));
|
|
103
|
+
console.log();
|
|
104
|
+
}
|
|
105
|
+
function table(rows) {
|
|
106
|
+
if (rows.length === 0) return;
|
|
107
|
+
const colWidths = rows[0].map(
|
|
108
|
+
(_, colIndex) => Math.max(...rows.map((row) => (row[colIndex] || "").length))
|
|
109
|
+
);
|
|
110
|
+
const separator = "\u2500";
|
|
111
|
+
const corner = "\u253C";
|
|
112
|
+
const vertical = "\u2502";
|
|
113
|
+
const formatRow = (row, isHeader = false) => {
|
|
114
|
+
const cells = row.map((cell, i) => ` ${cell.padEnd(colWidths[i])} `);
|
|
115
|
+
const line = vertical + cells.join(vertical) + vertical;
|
|
116
|
+
return isHeader ? chalk.bold(line) : line;
|
|
117
|
+
};
|
|
118
|
+
const horizontalLine = (char) => {
|
|
119
|
+
const segments = colWidths.map((w) => char.repeat(w + 2));
|
|
120
|
+
return char === "\u2500" ? "\u251C" + segments.join(corner) + "\u2524" : "\u250C" + segments.join("\u252C") + "\u2510";
|
|
121
|
+
};
|
|
122
|
+
const bottomLine = () => {
|
|
123
|
+
const segments = colWidths.map((w) => separator.repeat(w + 2));
|
|
124
|
+
return "\u2514" + segments.join("\u2534") + "\u2518";
|
|
125
|
+
};
|
|
126
|
+
console.log(horizontalLine("\u2500").replace("\u251C", "\u250C").replace("\u2524", "\u2510").replace(/┼/g, "\u252C"));
|
|
127
|
+
console.log(formatRow(rows[0], true));
|
|
128
|
+
console.log(horizontalLine("\u2500"));
|
|
129
|
+
for (let i = 1; i < rows.length; i++) {
|
|
130
|
+
console.log(formatRow(rows[i]));
|
|
131
|
+
}
|
|
132
|
+
console.log(bottomLine());
|
|
133
|
+
}
|
|
134
|
+
function newline() {
|
|
135
|
+
console.log();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// src/aws/credentials.ts
|
|
139
|
+
var DEFAULT_REGION = "us-east-1";
|
|
140
|
+
async function getCurrentIdentity() {
|
|
141
|
+
const client = new STSClient({ region: DEFAULT_REGION });
|
|
142
|
+
try {
|
|
143
|
+
verbose("Checking AWS credentials...");
|
|
144
|
+
const response = await client.send(new GetCallerIdentityCommand({}));
|
|
145
|
+
if (!response.Account || !response.Arn || !response.UserId) {
|
|
146
|
+
throw new NoCredentialsError();
|
|
147
|
+
}
|
|
148
|
+
verbose(`Authenticated as: ${response.Arn}`);
|
|
149
|
+
verbose(`Account ID: ${response.Account}`);
|
|
150
|
+
return {
|
|
151
|
+
accountId: response.Account,
|
|
152
|
+
arn: response.Arn,
|
|
153
|
+
userId: response.UserId
|
|
154
|
+
};
|
|
155
|
+
} catch (error2) {
|
|
156
|
+
if (error2 instanceof NoCredentialsError) {
|
|
157
|
+
throw error2;
|
|
158
|
+
}
|
|
159
|
+
const errorMessage = error2 instanceof Error ? error2.message : String(error2);
|
|
160
|
+
if (errorMessage.includes("Could not load credentials") || errorMessage.includes("Missing credentials") || errorMessage.includes("ExpiredToken") || errorMessage.includes("InvalidClientTokenId")) {
|
|
161
|
+
throw new NoCredentialsError();
|
|
162
|
+
}
|
|
163
|
+
throw error2;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// src/aws/assume-role.ts
|
|
168
|
+
import { STSClient as STSClient2, AssumeRoleCommand } from "@aws-sdk/client-sts";
|
|
169
|
+
|
|
170
|
+
// src/types/config.ts
|
|
171
|
+
var DEFAULT_TARGET_ROLE = "OrganizationAccountAccessRole";
|
|
172
|
+
var FALLBACK_TARGET_ROLE = "AWSControlTowerExecution";
|
|
173
|
+
var OIDC_PROVIDER_URL = "devramps.com";
|
|
174
|
+
|
|
175
|
+
// src/aws/assume-role.ts
|
|
176
|
+
async function assumeRoleForAccount(options) {
|
|
177
|
+
const { targetAccountId, currentAccountId, targetRoleName } = options;
|
|
178
|
+
if (targetAccountId === currentAccountId) {
|
|
179
|
+
verbose(`Target account ${targetAccountId} is the current account, using current credentials`);
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
const rolesToTry = targetRoleName ? [targetRoleName] : [DEFAULT_TARGET_ROLE, FALLBACK_TARGET_ROLE];
|
|
183
|
+
let lastError;
|
|
184
|
+
for (const roleName of rolesToTry) {
|
|
185
|
+
const roleArn = `arn:aws:iam::${targetAccountId}:role/${roleName}`;
|
|
186
|
+
try {
|
|
187
|
+
verbose(`Attempting to assume role: ${roleArn}`);
|
|
188
|
+
const credentials = await assumeRole(roleArn);
|
|
189
|
+
verbose(`Successfully assumed role: ${roleName}`);
|
|
190
|
+
return {
|
|
191
|
+
credentials,
|
|
192
|
+
accountId: targetAccountId,
|
|
193
|
+
roleArn
|
|
194
|
+
};
|
|
195
|
+
} catch (error2) {
|
|
196
|
+
verbose(`Failed to assume role ${roleName}: ${error2 instanceof Error ? error2.message : String(error2)}`);
|
|
197
|
+
lastError = error2 instanceof Error ? error2 : new Error(String(error2));
|
|
198
|
+
if (targetRoleName) {
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const attemptedRole = targetRoleName || `${DEFAULT_TARGET_ROLE} or ${FALLBACK_TARGET_ROLE}`;
|
|
204
|
+
throw new RoleAssumptionError(targetAccountId, attemptedRole, currentAccountId);
|
|
205
|
+
}
|
|
206
|
+
var DEFAULT_REGION2 = "us-east-1";
|
|
207
|
+
async function assumeRole(roleArn) {
|
|
208
|
+
const client = new STSClient2({ region: DEFAULT_REGION2 });
|
|
209
|
+
const response = await client.send(
|
|
210
|
+
new AssumeRoleCommand({
|
|
211
|
+
RoleArn: roleArn,
|
|
212
|
+
RoleSessionName: "DevRampsBootstrap",
|
|
213
|
+
DurationSeconds: 3600
|
|
214
|
+
// 1 hour
|
|
215
|
+
})
|
|
216
|
+
);
|
|
217
|
+
if (!response.Credentials) {
|
|
218
|
+
throw new Error("AssumeRole returned no credentials");
|
|
219
|
+
}
|
|
220
|
+
const { AccessKeyId, SecretAccessKey, SessionToken, Expiration } = response.Credentials;
|
|
221
|
+
if (!AccessKeyId || !SecretAccessKey) {
|
|
222
|
+
throw new Error("AssumeRole returned incomplete credentials");
|
|
223
|
+
}
|
|
224
|
+
return {
|
|
225
|
+
accessKeyId: AccessKeyId,
|
|
226
|
+
secretAccessKey: SecretAccessKey,
|
|
227
|
+
sessionToken: SessionToken,
|
|
228
|
+
expiration: Expiration
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// src/aws/cloudformation.ts
|
|
233
|
+
import {
|
|
234
|
+
CloudFormationClient,
|
|
235
|
+
DescribeStacksCommand,
|
|
236
|
+
DescribeStackResourcesCommand,
|
|
237
|
+
CreateStackCommand,
|
|
238
|
+
UpdateStackCommand,
|
|
239
|
+
CreateChangeSetCommand,
|
|
240
|
+
DescribeChangeSetCommand,
|
|
241
|
+
DeleteChangeSetCommand,
|
|
242
|
+
waitUntilStackCreateComplete,
|
|
243
|
+
waitUntilStackUpdateComplete,
|
|
244
|
+
waitUntilChangeSetCreateComplete,
|
|
245
|
+
ChangeSetType
|
|
246
|
+
} from "@aws-sdk/client-cloudformation";
|
|
247
|
+
async function getStackStatus(stackName, credentials, region) {
|
|
248
|
+
const client = new CloudFormationClient({
|
|
249
|
+
credentials,
|
|
250
|
+
region
|
|
251
|
+
});
|
|
252
|
+
try {
|
|
253
|
+
const response = await client.send(
|
|
254
|
+
new DescribeStacksCommand({ StackName: stackName })
|
|
255
|
+
);
|
|
256
|
+
const stack = response.Stacks?.[0];
|
|
257
|
+
if (!stack) {
|
|
258
|
+
return { exists: false };
|
|
259
|
+
}
|
|
260
|
+
return {
|
|
261
|
+
exists: true,
|
|
262
|
+
status: stack.StackStatus,
|
|
263
|
+
stackId: stack.StackId
|
|
264
|
+
};
|
|
265
|
+
} catch (error2) {
|
|
266
|
+
const errorMessage = error2 instanceof Error ? error2.message : String(error2);
|
|
267
|
+
if (errorMessage.includes("does not exist")) {
|
|
268
|
+
return { exists: false };
|
|
269
|
+
}
|
|
270
|
+
throw error2;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
async function previewStackChanges(options) {
|
|
274
|
+
const { stackName, template, region, credentials } = options;
|
|
275
|
+
const client = new CloudFormationClient({
|
|
276
|
+
credentials,
|
|
277
|
+
region
|
|
278
|
+
});
|
|
279
|
+
const templateBody = JSON.stringify(template);
|
|
280
|
+
const stackStatus = await getStackStatus(stackName, credentials, region);
|
|
281
|
+
const changeSetName = `devramps-preview-${Date.now()}`;
|
|
282
|
+
try {
|
|
283
|
+
await client.send(
|
|
284
|
+
new CreateChangeSetCommand({
|
|
285
|
+
StackName: stackName,
|
|
286
|
+
ChangeSetName: changeSetName,
|
|
287
|
+
TemplateBody: templateBody,
|
|
288
|
+
Capabilities: ["CAPABILITY_NAMED_IAM"],
|
|
289
|
+
ChangeSetType: stackStatus.exists ? ChangeSetType.UPDATE : ChangeSetType.CREATE
|
|
290
|
+
})
|
|
291
|
+
);
|
|
292
|
+
await waitUntilChangeSetCreateComplete(
|
|
293
|
+
{ client, maxWaitTime: 120 },
|
|
294
|
+
{ StackName: stackName, ChangeSetName: changeSetName }
|
|
295
|
+
);
|
|
296
|
+
const changeSetResponse = await client.send(
|
|
297
|
+
new DescribeChangeSetCommand({
|
|
298
|
+
StackName: stackName,
|
|
299
|
+
ChangeSetName: changeSetName
|
|
300
|
+
})
|
|
301
|
+
);
|
|
302
|
+
logStackChanges(stackName, changeSetResponse.Changes || [], stackStatus.exists);
|
|
303
|
+
await client.send(
|
|
304
|
+
new DeleteChangeSetCommand({
|
|
305
|
+
StackName: stackName,
|
|
306
|
+
ChangeSetName: changeSetName
|
|
307
|
+
})
|
|
308
|
+
);
|
|
309
|
+
} catch (error2) {
|
|
310
|
+
const errorMessage = error2 instanceof Error ? error2.message : String(error2);
|
|
311
|
+
if (errorMessage.includes("No updates are to be performed") || errorMessage.includes("didn't contain changes")) {
|
|
312
|
+
verbose(` Stack ${stackName}: No changes`);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
try {
|
|
316
|
+
await client.send(
|
|
317
|
+
new DeleteChangeSetCommand({
|
|
318
|
+
StackName: stackName,
|
|
319
|
+
ChangeSetName: changeSetName
|
|
320
|
+
})
|
|
321
|
+
);
|
|
322
|
+
} catch {
|
|
323
|
+
}
|
|
324
|
+
verbose(` Could not preview changes for ${stackName}: ${errorMessage}`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
function logStackChanges(stackName, changes, isUpdate) {
|
|
328
|
+
if (changes.length === 0) {
|
|
329
|
+
verbose(` Stack ${stackName}: No changes`);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
const action = isUpdate ? "update" : "create";
|
|
333
|
+
info(` Stack ${stackName} will ${action} ${changes.length} resource(s):`);
|
|
334
|
+
for (const change of changes) {
|
|
335
|
+
const resourceChange = change.ResourceChange;
|
|
336
|
+
if (!resourceChange) continue;
|
|
337
|
+
const actionSymbol = getActionSymbol(resourceChange.Action);
|
|
338
|
+
const resourceType = resourceChange.ResourceType || "Unknown";
|
|
339
|
+
const logicalId = resourceChange.LogicalResourceId || "Unknown";
|
|
340
|
+
const replacement = resourceChange.Replacement === "True" ? " (REPLACEMENT)" : "";
|
|
341
|
+
info(` ${actionSymbol} ${resourceType} ${logicalId}${replacement}`);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
function getActionSymbol(action) {
|
|
345
|
+
switch (action) {
|
|
346
|
+
case "Add":
|
|
347
|
+
return "+";
|
|
348
|
+
case "Modify":
|
|
349
|
+
return "~";
|
|
350
|
+
case "Remove":
|
|
351
|
+
return "-";
|
|
352
|
+
case "Import":
|
|
353
|
+
return ">";
|
|
354
|
+
case "Dynamic":
|
|
355
|
+
return "?";
|
|
356
|
+
default:
|
|
357
|
+
return " ";
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
async function deployStack(options) {
|
|
361
|
+
const { stackName, template, accountId, region, credentials } = options;
|
|
362
|
+
const client = new CloudFormationClient({
|
|
363
|
+
credentials,
|
|
364
|
+
region
|
|
365
|
+
});
|
|
366
|
+
const templateBody = JSON.stringify(template);
|
|
367
|
+
try {
|
|
368
|
+
const stackStatus = await getStackStatus(stackName, credentials, region);
|
|
369
|
+
if (stackStatus.exists) {
|
|
370
|
+
verbose(`Stack ${stackName} exists, updating...`);
|
|
371
|
+
await updateStack(client, stackName, templateBody, accountId);
|
|
372
|
+
} else {
|
|
373
|
+
verbose(`Stack ${stackName} does not exist, creating...`);
|
|
374
|
+
await createStack(client, stackName, templateBody, accountId);
|
|
375
|
+
}
|
|
376
|
+
} catch (error2) {
|
|
377
|
+
const errorMessage = error2 instanceof Error ? error2.message : String(error2);
|
|
378
|
+
if (errorMessage.includes("No updates are to be performed")) {
|
|
379
|
+
verbose(`Stack ${stackName} is already up to date`);
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
throw new CloudFormationError(stackName, accountId, errorMessage);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
async function createStack(client, stackName, templateBody, accountId) {
|
|
386
|
+
await client.send(
|
|
387
|
+
new CreateStackCommand({
|
|
388
|
+
StackName: stackName,
|
|
389
|
+
TemplateBody: templateBody,
|
|
390
|
+
Capabilities: ["CAPABILITY_NAMED_IAM"],
|
|
391
|
+
Tags: [
|
|
392
|
+
{ Key: "CreatedBy", Value: "DevRamps" },
|
|
393
|
+
{ Key: "ManagedBy", Value: "DevRamps-CLI" }
|
|
394
|
+
]
|
|
395
|
+
})
|
|
396
|
+
);
|
|
397
|
+
verbose(`Waiting for stack ${stackName} to be created...`);
|
|
398
|
+
await waitUntilStackCreateComplete(
|
|
399
|
+
{ client, maxWaitTime: 600 },
|
|
400
|
+
{ StackName: stackName }
|
|
401
|
+
);
|
|
402
|
+
success(`Stack ${stackName} created successfully in account ${accountId}`);
|
|
403
|
+
}
|
|
404
|
+
async function updateStack(client, stackName, templateBody, accountId) {
|
|
405
|
+
await client.send(
|
|
406
|
+
new UpdateStackCommand({
|
|
407
|
+
StackName: stackName,
|
|
408
|
+
TemplateBody: templateBody,
|
|
409
|
+
Capabilities: ["CAPABILITY_NAMED_IAM"]
|
|
410
|
+
})
|
|
411
|
+
);
|
|
412
|
+
verbose(`Waiting for stack ${stackName} to be updated...`);
|
|
413
|
+
await waitUntilStackUpdateComplete(
|
|
414
|
+
{ client, maxWaitTime: 600 },
|
|
415
|
+
{ StackName: stackName }
|
|
416
|
+
);
|
|
417
|
+
success(`Stack ${stackName} updated successfully in account ${accountId}`);
|
|
418
|
+
}
|
|
419
|
+
async function readExistingStack(stackName, accountId, region, credentials) {
|
|
420
|
+
const client = new CloudFormationClient({
|
|
421
|
+
credentials,
|
|
422
|
+
region
|
|
423
|
+
});
|
|
424
|
+
try {
|
|
425
|
+
const stacksResponse = await client.send(
|
|
426
|
+
new DescribeStacksCommand({ StackName: stackName })
|
|
427
|
+
);
|
|
428
|
+
const stack = stacksResponse.Stacks?.[0];
|
|
429
|
+
if (!stack) {
|
|
430
|
+
return null;
|
|
431
|
+
}
|
|
432
|
+
const resourcesResponse = await client.send(
|
|
433
|
+
new DescribeStackResourcesCommand({ StackName: stackName })
|
|
434
|
+
);
|
|
435
|
+
const outputs = {};
|
|
436
|
+
if (stack.Outputs) {
|
|
437
|
+
for (const output of stack.Outputs) {
|
|
438
|
+
if (output.OutputKey && output.OutputValue) {
|
|
439
|
+
outputs[output.OutputKey] = output.OutputValue;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
const resources = {};
|
|
444
|
+
if (resourcesResponse.StackResources) {
|
|
445
|
+
for (const resource of resourcesResponse.StackResources) {
|
|
446
|
+
if (resource.LogicalResourceId) {
|
|
447
|
+
resources[resource.LogicalResourceId] = {
|
|
448
|
+
type: resource.ResourceType,
|
|
449
|
+
physicalId: resource.PhysicalResourceId,
|
|
450
|
+
status: resource.ResourceStatus
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return {
|
|
456
|
+
stackName,
|
|
457
|
+
accountId,
|
|
458
|
+
region,
|
|
459
|
+
resources,
|
|
460
|
+
outputs
|
|
461
|
+
};
|
|
462
|
+
} catch (error2) {
|
|
463
|
+
const errorMessage = error2 instanceof Error ? error2.message : String(error2);
|
|
464
|
+
if (errorMessage.includes("does not exist")) {
|
|
465
|
+
return null;
|
|
466
|
+
}
|
|
467
|
+
verbose(`Could not read stack ${stackName}: ${errorMessage}`);
|
|
468
|
+
return null;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// src/aws/oidc-provider.ts
|
|
473
|
+
import {
|
|
474
|
+
IAMClient,
|
|
475
|
+
GetOpenIDConnectProviderCommand,
|
|
476
|
+
ListOpenIDConnectProvidersCommand
|
|
477
|
+
} from "@aws-sdk/client-iam";
|
|
478
|
+
async function checkOidcProviderExists(credentials, region) {
|
|
479
|
+
const client = new IAMClient({
|
|
480
|
+
credentials,
|
|
481
|
+
region
|
|
482
|
+
});
|
|
483
|
+
try {
|
|
484
|
+
const response = await client.send(new ListOpenIDConnectProvidersCommand({}));
|
|
485
|
+
const providers = response.OpenIDConnectProviderList || [];
|
|
486
|
+
for (const provider of providers) {
|
|
487
|
+
if (!provider.Arn) continue;
|
|
488
|
+
try {
|
|
489
|
+
const providerDetails = await client.send(
|
|
490
|
+
new GetOpenIDConnectProviderCommand({
|
|
491
|
+
OpenIDConnectProviderArn: provider.Arn
|
|
492
|
+
})
|
|
493
|
+
);
|
|
494
|
+
if (providerDetails.Url?.includes(OIDC_PROVIDER_URL)) {
|
|
495
|
+
verbose(`Found existing OIDC provider: ${provider.Arn}`);
|
|
496
|
+
return {
|
|
497
|
+
exists: true,
|
|
498
|
+
arn: provider.Arn
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
} catch {
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
verbose(`No existing OIDC provider found for ${OIDC_PROVIDER_URL}`);
|
|
505
|
+
return { exists: false };
|
|
506
|
+
} catch (error2) {
|
|
507
|
+
verbose(`Error checking OIDC providers: ${error2 instanceof Error ? error2.message : String(error2)}`);
|
|
508
|
+
return { exists: false };
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
function getOidcThumbprint() {
|
|
512
|
+
return "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// src/auth/browser-auth.ts
|
|
516
|
+
import express from "express";
|
|
517
|
+
import open from "open";
|
|
518
|
+
import { createServer } from "http";
|
|
519
|
+
|
|
520
|
+
// src/utils/validation.ts
|
|
521
|
+
var AWS_ACCOUNT_ID_REGEX = /^\d{12}$/;
|
|
522
|
+
var AWS_REGION_REGEX = /^[a-z]{2}-[a-z]+-\d$/;
|
|
523
|
+
var VALID_AWS_REGIONS = /* @__PURE__ */ new Set([
|
|
524
|
+
// US regions
|
|
525
|
+
"us-east-1",
|
|
526
|
+
"us-east-2",
|
|
527
|
+
"us-west-1",
|
|
528
|
+
"us-west-2",
|
|
529
|
+
// EU regions
|
|
530
|
+
"eu-west-1",
|
|
531
|
+
"eu-west-2",
|
|
532
|
+
"eu-west-3",
|
|
533
|
+
"eu-central-1",
|
|
534
|
+
"eu-central-2",
|
|
535
|
+
"eu-north-1",
|
|
536
|
+
"eu-south-1",
|
|
537
|
+
"eu-south-2",
|
|
538
|
+
// Asia Pacific regions
|
|
539
|
+
"ap-east-1",
|
|
540
|
+
"ap-south-1",
|
|
541
|
+
"ap-south-2",
|
|
542
|
+
"ap-northeast-1",
|
|
543
|
+
"ap-northeast-2",
|
|
544
|
+
"ap-northeast-3",
|
|
545
|
+
"ap-southeast-1",
|
|
546
|
+
"ap-southeast-2",
|
|
547
|
+
"ap-southeast-3",
|
|
548
|
+
"ap-southeast-4",
|
|
549
|
+
// South America
|
|
550
|
+
"sa-east-1",
|
|
551
|
+
// Middle East
|
|
552
|
+
"me-south-1",
|
|
553
|
+
"me-central-1",
|
|
554
|
+
// Africa
|
|
555
|
+
"af-south-1",
|
|
556
|
+
// Canada
|
|
557
|
+
"ca-central-1",
|
|
558
|
+
"ca-west-1",
|
|
559
|
+
// China (special)
|
|
560
|
+
"cn-north-1",
|
|
561
|
+
"cn-northwest-1",
|
|
562
|
+
// GovCloud
|
|
563
|
+
"us-gov-east-1",
|
|
564
|
+
"us-gov-west-1",
|
|
565
|
+
// Israel
|
|
566
|
+
"il-central-1"
|
|
567
|
+
]);
|
|
568
|
+
function isValidAwsAccountId(accountId) {
|
|
569
|
+
return AWS_ACCOUNT_ID_REGEX.test(accountId);
|
|
570
|
+
}
|
|
571
|
+
function isValidAwsRegion(region) {
|
|
572
|
+
return AWS_REGION_REGEX.test(region) || VALID_AWS_REGIONS.has(region);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// src/auth/pkce.ts
|
|
576
|
+
import { randomBytes, createHash } from "crypto";
|
|
577
|
+
var UNRESERVED_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
|
|
578
|
+
function generateCodeVerifier() {
|
|
579
|
+
const length = 128;
|
|
580
|
+
const bytes = randomBytes(length);
|
|
581
|
+
let verifier = "";
|
|
582
|
+
for (let i = 0; i < length; i++) {
|
|
583
|
+
verifier += UNRESERVED_CHARS[bytes[i] % UNRESERVED_CHARS.length];
|
|
584
|
+
}
|
|
585
|
+
return verifier;
|
|
586
|
+
}
|
|
587
|
+
function generateCodeChallenge(verifier) {
|
|
588
|
+
const hash = createHash("sha256").update(verifier).digest();
|
|
589
|
+
return hash.toString("base64url");
|
|
590
|
+
}
|
|
591
|
+
function generateState() {
|
|
592
|
+
return randomBytes(24).toString("base64url");
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// src/auth/browser-auth.ts
|
|
596
|
+
var DEFAULT_AUTH_BASE_URL = "https://devramps.com";
|
|
597
|
+
var AUTHORIZE_PATH = "/oauth/authorize";
|
|
598
|
+
var TOKEN_PATH = "/oauth/token";
|
|
599
|
+
var CLI_CLIENT_ID = "devramps-cli";
|
|
600
|
+
var AUTH_TIMEOUT_MS = 3e5;
|
|
601
|
+
async function authenticateViaBrowser(options = {}) {
|
|
602
|
+
const baseUrl = options.endpointOverride || DEFAULT_AUTH_BASE_URL;
|
|
603
|
+
info("Opening browser for authentication...");
|
|
604
|
+
if (options.endpointOverride) {
|
|
605
|
+
warn(`Using endpoint override: ${options.endpointOverride}`);
|
|
606
|
+
}
|
|
607
|
+
verbose("Starting local callback server...");
|
|
608
|
+
const codeVerifier = generateCodeVerifier();
|
|
609
|
+
const codeChallenge = generateCodeChallenge(codeVerifier);
|
|
610
|
+
const state = generateState();
|
|
611
|
+
verbose("Generated PKCE code_challenge and state");
|
|
612
|
+
const { server, port, callbackPromise } = await startCallbackServer(state);
|
|
613
|
+
try {
|
|
614
|
+
const redirectUri = `http://localhost:${port}`;
|
|
615
|
+
const authParams = new URLSearchParams({
|
|
616
|
+
response_type: "code",
|
|
617
|
+
client_id: CLI_CLIENT_ID,
|
|
618
|
+
redirect_uri: redirectUri,
|
|
619
|
+
code_challenge: codeChallenge,
|
|
620
|
+
code_challenge_method: "S256",
|
|
621
|
+
state
|
|
622
|
+
});
|
|
623
|
+
const authUrl = `${baseUrl}${AUTHORIZE_PATH}?${authParams.toString()}`;
|
|
624
|
+
verbose(`Auth URL: ${authUrl}`);
|
|
625
|
+
verbose(`Redirect URI: ${redirectUri}`);
|
|
626
|
+
await open(authUrl);
|
|
627
|
+
info("Waiting for authentication...");
|
|
628
|
+
verbose("Complete the authentication in your browser.");
|
|
629
|
+
const callbackResult = await Promise.race([
|
|
630
|
+
callbackPromise,
|
|
631
|
+
timeout(AUTH_TIMEOUT_MS)
|
|
632
|
+
]);
|
|
633
|
+
if (!callbackResult) {
|
|
634
|
+
throw new AuthenticationError("Authentication timed out. Please try again.");
|
|
635
|
+
}
|
|
636
|
+
if (callbackResult.error) {
|
|
637
|
+
const errorMsg = callbackResult.errorDescription || callbackResult.error;
|
|
638
|
+
throw new AuthenticationError(errorMsg);
|
|
639
|
+
}
|
|
640
|
+
if (!callbackResult.code) {
|
|
641
|
+
throw new AuthenticationError("No authorization code received. Please try again.");
|
|
642
|
+
}
|
|
643
|
+
if (callbackResult.state !== state) {
|
|
644
|
+
throw new AuthenticationError("State mismatch - possible CSRF attack. Please try again.");
|
|
645
|
+
}
|
|
646
|
+
verbose("Received authorization code, exchanging for access token...");
|
|
647
|
+
const tokenResponse = await exchangeCodeForToken({
|
|
648
|
+
baseUrl,
|
|
649
|
+
code: callbackResult.code,
|
|
650
|
+
redirectUri,
|
|
651
|
+
codeVerifier
|
|
652
|
+
});
|
|
653
|
+
if (!tokenResponse.organization_id) {
|
|
654
|
+
throw new AuthenticationError("No organization ID in token response. Please try again.");
|
|
655
|
+
}
|
|
656
|
+
verbose("Fetching organization details...");
|
|
657
|
+
const orgResponse = await fetchOrganization({
|
|
658
|
+
baseUrl,
|
|
659
|
+
accessToken: tokenResponse.access_token,
|
|
660
|
+
organizationId: tokenResponse.organization_id
|
|
661
|
+
});
|
|
662
|
+
const awsConfig = await fetchAwsConfiguration({
|
|
663
|
+
baseUrl,
|
|
664
|
+
accessToken: tokenResponse.access_token,
|
|
665
|
+
organizationId: tokenResponse.organization_id
|
|
666
|
+
});
|
|
667
|
+
const cicdAccountId = awsConfig.cicdAccountId || awsConfig.cicdAccount?.accountId;
|
|
668
|
+
if (!cicdAccountId) {
|
|
669
|
+
throw new AuthenticationError("No CI/CD account configured for this organization. Please configure one in the DevRamps dashboard.");
|
|
670
|
+
}
|
|
671
|
+
if (!isValidAwsAccountId(cicdAccountId)) {
|
|
672
|
+
throw new AuthenticationError("Invalid CI/CD account ID format.");
|
|
673
|
+
}
|
|
674
|
+
if (!awsConfig.defaultRegion || !isValidAwsRegion(awsConfig.defaultRegion)) {
|
|
675
|
+
throw new AuthenticationError("Invalid or missing default AWS region.");
|
|
676
|
+
}
|
|
677
|
+
success(`Authenticated with organization: ${orgResponse.slug}`);
|
|
678
|
+
verbose(`CI/CD Account: ${cicdAccountId}, Region: ${awsConfig.defaultRegion}`);
|
|
679
|
+
return {
|
|
680
|
+
orgSlug: orgResponse.slug,
|
|
681
|
+
cicdAccountId,
|
|
682
|
+
cicdRegion: awsConfig.defaultRegion
|
|
683
|
+
};
|
|
684
|
+
} finally {
|
|
685
|
+
await closeServer(server);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
async function exchangeCodeForToken(params) {
|
|
689
|
+
const tokenUrl = `${params.baseUrl}${TOKEN_PATH}`;
|
|
690
|
+
const body = new URLSearchParams({
|
|
691
|
+
grant_type: "authorization_code",
|
|
692
|
+
client_id: CLI_CLIENT_ID,
|
|
693
|
+
code: params.code,
|
|
694
|
+
redirect_uri: params.redirectUri,
|
|
695
|
+
code_verifier: params.codeVerifier
|
|
696
|
+
});
|
|
697
|
+
verbose(`Token exchange URL: ${tokenUrl}`);
|
|
698
|
+
const response = await fetch(tokenUrl, {
|
|
699
|
+
method: "POST",
|
|
700
|
+
headers: {
|
|
701
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
702
|
+
Accept: "application/json"
|
|
703
|
+
},
|
|
704
|
+
body: body.toString()
|
|
705
|
+
});
|
|
706
|
+
if (!response.ok) {
|
|
707
|
+
let errorMessage = `Token exchange failed with status ${response.status}`;
|
|
708
|
+
try {
|
|
709
|
+
const errorBody = await response.json();
|
|
710
|
+
if (errorBody.error_description) {
|
|
711
|
+
errorMessage = errorBody.error_description;
|
|
712
|
+
} else if (errorBody.error) {
|
|
713
|
+
errorMessage = `Token exchange failed: ${errorBody.error}`;
|
|
714
|
+
}
|
|
715
|
+
} catch {
|
|
716
|
+
}
|
|
717
|
+
throw new AuthenticationError(errorMessage);
|
|
718
|
+
}
|
|
719
|
+
const tokenResponse = await response.json();
|
|
720
|
+
if (!tokenResponse.access_token) {
|
|
721
|
+
throw new AuthenticationError("No access token in response");
|
|
722
|
+
}
|
|
723
|
+
verbose(`Token response: organization_id=${tokenResponse.organization_id}, scope=${tokenResponse.scope}, expires_in=${tokenResponse.expires_in}`);
|
|
724
|
+
return tokenResponse;
|
|
725
|
+
}
|
|
726
|
+
async function fetchOrganization(params) {
|
|
727
|
+
const url = `${params.baseUrl}/api/v1/organizations/${params.organizationId}`;
|
|
728
|
+
verbose(`Fetching organization: GET ${url}`);
|
|
729
|
+
const response = await fetch(url, {
|
|
730
|
+
method: "GET",
|
|
731
|
+
headers: {
|
|
732
|
+
Authorization: `Bearer ${params.accessToken}`,
|
|
733
|
+
Accept: "application/json"
|
|
734
|
+
}
|
|
735
|
+
});
|
|
736
|
+
verbose(`Organization response status: ${response.status}`);
|
|
737
|
+
if (!response.ok) {
|
|
738
|
+
const errorText = await response.text();
|
|
739
|
+
verbose(`Organization error response: ${errorText}`);
|
|
740
|
+
throw new AuthenticationError(`Failed to fetch organization: ${response.status}`);
|
|
741
|
+
}
|
|
742
|
+
const data = await response.json();
|
|
743
|
+
verbose(`Organization data: id=${data.id}, name=${data.name}, slug=${data.slug}`);
|
|
744
|
+
return data;
|
|
745
|
+
}
|
|
746
|
+
async function fetchAwsConfiguration(params) {
|
|
747
|
+
const url = `${params.baseUrl}/api/v1/organizations/${params.organizationId}/aws/configuration`;
|
|
748
|
+
verbose(`Fetching AWS configuration: GET ${url}`);
|
|
749
|
+
const response = await fetch(url, {
|
|
750
|
+
method: "GET",
|
|
751
|
+
headers: {
|
|
752
|
+
Authorization: `Bearer ${params.accessToken}`,
|
|
753
|
+
Accept: "application/json"
|
|
754
|
+
}
|
|
755
|
+
});
|
|
756
|
+
verbose(`AWS configuration response status: ${response.status}`);
|
|
757
|
+
if (!response.ok) {
|
|
758
|
+
const errorText = await response.text();
|
|
759
|
+
verbose(`AWS configuration error response: ${errorText}`);
|
|
760
|
+
throw new AuthenticationError(`Failed to fetch AWS configuration: ${response.status}`);
|
|
761
|
+
}
|
|
762
|
+
const data = await response.json();
|
|
763
|
+
verbose(`AWS configuration data: defaultRegion=${data.defaultRegion}, cicdAccountId=${data.cicdAccountId}, cicdAccount=${JSON.stringify(data.cicdAccount)}`);
|
|
764
|
+
return data;
|
|
765
|
+
}
|
|
766
|
+
async function startCallbackServer(expectedState) {
|
|
767
|
+
const app = express();
|
|
768
|
+
let resolveCallback;
|
|
769
|
+
const callbackPromise = new Promise((resolve) => {
|
|
770
|
+
resolveCallback = resolve;
|
|
771
|
+
});
|
|
772
|
+
app.get("/", (req, res) => {
|
|
773
|
+
const { code, state, error: error2, error_description } = req.query;
|
|
774
|
+
if (error2) {
|
|
775
|
+
res.send(errorPage(String(error_description || error2)));
|
|
776
|
+
resolveCallback({
|
|
777
|
+
error: String(error2),
|
|
778
|
+
errorDescription: error_description ? String(error_description) : void 0
|
|
779
|
+
});
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
if (!state || state !== expectedState) {
|
|
783
|
+
res.send(errorPage("Invalid state parameter - possible CSRF attack"));
|
|
784
|
+
resolveCallback({ error: "state_mismatch" });
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
if (!code || typeof code !== "string") {
|
|
788
|
+
res.send(errorPage("No authorization code received"));
|
|
789
|
+
resolveCallback({ error: "missing_code" });
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
res.send(successPage());
|
|
793
|
+
resolveCallback({
|
|
794
|
+
code,
|
|
795
|
+
state: String(state)
|
|
796
|
+
});
|
|
797
|
+
});
|
|
798
|
+
return new Promise((resolve, reject) => {
|
|
799
|
+
const server = createServer(app);
|
|
800
|
+
server.listen(0, "127.0.0.1", () => {
|
|
801
|
+
const address = server.address();
|
|
802
|
+
if (!address || typeof address === "string") {
|
|
803
|
+
reject(new Error("Failed to get server address"));
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
const port = address.port;
|
|
807
|
+
verbose(`Callback server listening on port ${port}`);
|
|
808
|
+
resolve({ server, port, callbackPromise });
|
|
809
|
+
});
|
|
810
|
+
server.on("error", reject);
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
async function closeServer(server) {
|
|
814
|
+
return new Promise((resolve) => {
|
|
815
|
+
server.close(() => {
|
|
816
|
+
verbose("Callback server closed");
|
|
817
|
+
resolve();
|
|
818
|
+
});
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
function timeout(ms) {
|
|
822
|
+
return new Promise((_, reject) => {
|
|
823
|
+
setTimeout(() => {
|
|
824
|
+
reject(new AuthenticationError("Authentication timed out"));
|
|
825
|
+
}, ms);
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
function successPage() {
|
|
829
|
+
return `
|
|
830
|
+
<!DOCTYPE html>
|
|
831
|
+
<html>
|
|
832
|
+
<head>
|
|
833
|
+
<title>DevRamps - Authentication Successful</title>
|
|
834
|
+
<style>
|
|
835
|
+
body {
|
|
836
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
837
|
+
display: flex;
|
|
838
|
+
justify-content: center;
|
|
839
|
+
align-items: center;
|
|
840
|
+
min-height: 100vh;
|
|
841
|
+
margin: 0;
|
|
842
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
843
|
+
color: white;
|
|
844
|
+
}
|
|
845
|
+
.container {
|
|
846
|
+
text-align: center;
|
|
847
|
+
padding: 2rem;
|
|
848
|
+
}
|
|
849
|
+
.checkmark {
|
|
850
|
+
font-size: 4rem;
|
|
851
|
+
margin-bottom: 1rem;
|
|
852
|
+
}
|
|
853
|
+
h1 {
|
|
854
|
+
margin: 0 0 0.5rem 0;
|
|
855
|
+
}
|
|
856
|
+
p {
|
|
857
|
+
opacity: 0.9;
|
|
858
|
+
}
|
|
859
|
+
</style>
|
|
860
|
+
</head>
|
|
861
|
+
<body>
|
|
862
|
+
<div class="container">
|
|
863
|
+
<div class="checkmark">✓</div>
|
|
864
|
+
<h1>Authentication Successful</h1>
|
|
865
|
+
<p>You can close this window and return to your terminal.</p>
|
|
866
|
+
</div>
|
|
867
|
+
</body>
|
|
868
|
+
</html>
|
|
869
|
+
`;
|
|
870
|
+
}
|
|
871
|
+
function errorPage(error2) {
|
|
872
|
+
return `
|
|
873
|
+
<!DOCTYPE html>
|
|
874
|
+
<html>
|
|
875
|
+
<head>
|
|
876
|
+
<title>DevRamps - Authentication Failed</title>
|
|
877
|
+
<style>
|
|
878
|
+
body {
|
|
879
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
880
|
+
display: flex;
|
|
881
|
+
justify-content: center;
|
|
882
|
+
align-items: center;
|
|
883
|
+
min-height: 100vh;
|
|
884
|
+
margin: 0;
|
|
885
|
+
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a5a 100%);
|
|
886
|
+
color: white;
|
|
887
|
+
}
|
|
888
|
+
.container {
|
|
889
|
+
text-align: center;
|
|
890
|
+
padding: 2rem;
|
|
891
|
+
}
|
|
892
|
+
.icon {
|
|
893
|
+
font-size: 4rem;
|
|
894
|
+
margin-bottom: 1rem;
|
|
895
|
+
}
|
|
896
|
+
h1 {
|
|
897
|
+
margin: 0 0 0.5rem 0;
|
|
898
|
+
}
|
|
899
|
+
p {
|
|
900
|
+
opacity: 0.9;
|
|
901
|
+
}
|
|
902
|
+
.error {
|
|
903
|
+
background: rgba(255,255,255,0.2);
|
|
904
|
+
padding: 0.5rem 1rem;
|
|
905
|
+
border-radius: 4px;
|
|
906
|
+
display: inline-block;
|
|
907
|
+
margin-top: 1rem;
|
|
908
|
+
}
|
|
909
|
+
</style>
|
|
910
|
+
</head>
|
|
911
|
+
<body>
|
|
912
|
+
<div class="container">
|
|
913
|
+
<div class="icon">✗</div>
|
|
914
|
+
<h1>Authentication Failed</h1>
|
|
915
|
+
<p>Please close this window and try again in your terminal.</p>
|
|
916
|
+
<div class="error">${error2}</div>
|
|
917
|
+
</div>
|
|
918
|
+
</body>
|
|
919
|
+
</html>
|
|
920
|
+
`;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// src/parsers/pipeline.ts
|
|
924
|
+
import { readFile as readFile2, readdir, access as access2, constants as constants2 } from "fs/promises";
|
|
925
|
+
import { join as join2 } from "path";
|
|
926
|
+
import { parse as parseYaml2 } from "yaml";
|
|
927
|
+
|
|
928
|
+
// src/parsers/additional-policies.ts
|
|
929
|
+
import { readFile, access, constants } from "fs/promises";
|
|
930
|
+
import { join } from "path";
|
|
931
|
+
import { parse as parseYaml } from "yaml";
|
|
932
|
+
var POLICIES_JSON = "aws_additional_iam_policies.json";
|
|
933
|
+
var POLICIES_YAML = "aws_additional_iam_policies.yaml";
|
|
934
|
+
async function parseAdditionalPolicies(pipelineDir) {
|
|
935
|
+
const jsonPath = join(pipelineDir, POLICIES_JSON);
|
|
936
|
+
const yamlPath = join(pipelineDir, POLICIES_YAML);
|
|
937
|
+
let content;
|
|
938
|
+
let format;
|
|
939
|
+
try {
|
|
940
|
+
await access(jsonPath, constants.R_OK);
|
|
941
|
+
content = await readFile(jsonPath, "utf-8");
|
|
942
|
+
format = "json";
|
|
943
|
+
verbose(`Found additional policies: ${POLICIES_JSON}`);
|
|
944
|
+
} catch {
|
|
945
|
+
try {
|
|
946
|
+
await access(yamlPath, constants.R_OK);
|
|
947
|
+
content = await readFile(yamlPath, "utf-8");
|
|
948
|
+
format = "yaml";
|
|
949
|
+
verbose(`Found additional policies: ${POLICIES_YAML}`);
|
|
950
|
+
} catch {
|
|
951
|
+
return [];
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
if (!content || !format) {
|
|
955
|
+
return [];
|
|
956
|
+
}
|
|
957
|
+
let policies;
|
|
958
|
+
try {
|
|
959
|
+
if (format === "json") {
|
|
960
|
+
policies = JSON.parse(content);
|
|
961
|
+
} else {
|
|
962
|
+
policies = parseYaml(content);
|
|
963
|
+
}
|
|
964
|
+
} catch (error2) {
|
|
965
|
+
throw new Error(`Failed to parse ${format === "json" ? POLICIES_JSON : POLICIES_YAML}: ${error2 instanceof Error ? error2.message : String(error2)}`);
|
|
966
|
+
}
|
|
967
|
+
if (!Array.isArray(policies)) {
|
|
968
|
+
throw new Error(`Additional policies file must contain an array of IAM policies`);
|
|
969
|
+
}
|
|
970
|
+
const validatedPolicies = [];
|
|
971
|
+
for (let i = 0; i < policies.length; i++) {
|
|
972
|
+
const policy = policies[i];
|
|
973
|
+
if (!policy || typeof policy !== "object") {
|
|
974
|
+
throw new Error(`Policy at index ${i} is not an object`);
|
|
975
|
+
}
|
|
976
|
+
if (!("Statement" in policy) || !Array.isArray(policy.Statement)) {
|
|
977
|
+
throw new Error(`Policy at index ${i} is missing Statement array`);
|
|
978
|
+
}
|
|
979
|
+
validatedPolicies.push(policy);
|
|
980
|
+
}
|
|
981
|
+
verbose(`Loaded ${validatedPolicies.length} additional policies`);
|
|
982
|
+
return validatedPolicies;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// src/parsers/pipeline.ts
|
|
986
|
+
var DEVRAMPS_FOLDER = ".devramps";
|
|
987
|
+
var PIPELINE_FILE = "pipeline.yaml";
|
|
988
|
+
async function findDevrampsPipelines(basePath, filterSlugs) {
|
|
989
|
+
const devrampsPath = join2(basePath, DEVRAMPS_FOLDER);
|
|
990
|
+
try {
|
|
991
|
+
await access2(devrampsPath, constants2.R_OK);
|
|
992
|
+
} catch {
|
|
993
|
+
throw new NoDevrampsFolderError();
|
|
994
|
+
}
|
|
995
|
+
const entries = await readdir(devrampsPath, { withFileTypes: true });
|
|
996
|
+
const pipelineSlugs = [];
|
|
997
|
+
for (const entry of entries) {
|
|
998
|
+
if (!entry.isDirectory()) continue;
|
|
999
|
+
const pipelinePath = join2(devrampsPath, entry.name, PIPELINE_FILE);
|
|
1000
|
+
try {
|
|
1001
|
+
await access2(pipelinePath, constants2.R_OK);
|
|
1002
|
+
pipelineSlugs.push(entry.name);
|
|
1003
|
+
} catch {
|
|
1004
|
+
verbose(`Skipping ${entry.name}: no pipeline.yaml found`);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
if (filterSlugs && filterSlugs.length > 0) {
|
|
1008
|
+
const filtered = pipelineSlugs.filter((slug) => filterSlugs.includes(slug));
|
|
1009
|
+
for (const slug of filterSlugs) {
|
|
1010
|
+
if (!pipelineSlugs.includes(slug)) {
|
|
1011
|
+
warn(`Pipeline '${slug}' not found in ${DEVRAMPS_FOLDER}/`);
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
return filtered;
|
|
1015
|
+
}
|
|
1016
|
+
return pipelineSlugs;
|
|
1017
|
+
}
|
|
1018
|
+
async function parsePipeline(basePath, slug) {
|
|
1019
|
+
const pipelinePath = join2(basePath, DEVRAMPS_FOLDER, slug, PIPELINE_FILE);
|
|
1020
|
+
verbose(`Parsing pipeline: ${pipelinePath}`);
|
|
1021
|
+
let content;
|
|
1022
|
+
try {
|
|
1023
|
+
content = await readFile2(pipelinePath, "utf-8");
|
|
1024
|
+
} catch (error2) {
|
|
1025
|
+
throw new PipelineParseError(slug, `Could not read file: ${error2 instanceof Error ? error2.message : String(error2)}`);
|
|
1026
|
+
}
|
|
1027
|
+
let definition;
|
|
1028
|
+
try {
|
|
1029
|
+
definition = parseYaml2(content);
|
|
1030
|
+
} catch (error2) {
|
|
1031
|
+
throw new PipelineParseError(slug, `Invalid YAML: ${error2 instanceof Error ? error2.message : String(error2)}`);
|
|
1032
|
+
}
|
|
1033
|
+
if (!definition.pipeline) {
|
|
1034
|
+
throw new PipelineParseError(slug, 'Missing "pipeline" key in definition');
|
|
1035
|
+
}
|
|
1036
|
+
if (!definition.pipeline.stages || definition.pipeline.stages.length === 0) {
|
|
1037
|
+
throw new PipelineParseError(slug, "Pipeline must have at least one stage");
|
|
1038
|
+
}
|
|
1039
|
+
for (const stage of definition.pipeline.stages) {
|
|
1040
|
+
if (!stage.account_id) {
|
|
1041
|
+
throw new PipelineParseError(slug, `Stage "${stage.name}" is missing account_id`);
|
|
1042
|
+
}
|
|
1043
|
+
if (!stage.region) {
|
|
1044
|
+
throw new PipelineParseError(slug, `Stage "${stage.name}" is missing region`);
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
const targetAccountIds = extractTargetAccountIds(definition);
|
|
1048
|
+
const steps = extractSteps(definition);
|
|
1049
|
+
const additionalPolicies = await parseAdditionalPoliciesForPipeline(basePath, slug);
|
|
1050
|
+
verbose(`Pipeline ${slug}: ${targetAccountIds.length} accounts, ${steps.length} steps`);
|
|
1051
|
+
return {
|
|
1052
|
+
slug,
|
|
1053
|
+
definition,
|
|
1054
|
+
targetAccountIds,
|
|
1055
|
+
stages: definition.pipeline.stages,
|
|
1056
|
+
steps,
|
|
1057
|
+
additionalPolicies
|
|
1058
|
+
};
|
|
1059
|
+
}
|
|
1060
|
+
function extractTargetAccountIds(definition) {
|
|
1061
|
+
const accountIds = /* @__PURE__ */ new Set();
|
|
1062
|
+
for (const stage of definition.pipeline.stages) {
|
|
1063
|
+
if (stage.account_id) {
|
|
1064
|
+
accountIds.add(stage.account_id);
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
return Array.from(accountIds);
|
|
1068
|
+
}
|
|
1069
|
+
function extractSteps(definition) {
|
|
1070
|
+
return definition.pipeline.steps || [];
|
|
1071
|
+
}
|
|
1072
|
+
async function parseAdditionalPoliciesForPipeline(basePath, slug) {
|
|
1073
|
+
const pipelineDir = join2(basePath, DEVRAMPS_FOLDER, slug);
|
|
1074
|
+
try {
|
|
1075
|
+
return await parseAdditionalPolicies(pipelineDir);
|
|
1076
|
+
} catch (error2) {
|
|
1077
|
+
verbose(`No additional policies for ${slug}: ${error2 instanceof Error ? error2.message : String(error2)}`);
|
|
1078
|
+
return [];
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
// src/parsers/artifacts.ts
|
|
1083
|
+
var DOCKER_TYPES = ["DEVRAMPS:DOCKER:BUILD", "DEVRAMPS:DOCKER:IMPORT"];
|
|
1084
|
+
var BUNDLE_TYPES = ["DEVRAMPS:BUNDLE:BUILD", "DEVRAMPS:BUNDLE:IMPORT"];
|
|
1085
|
+
var VALID_TYPES = [...DOCKER_TYPES, ...BUNDLE_TYPES];
|
|
1086
|
+
function parseArtifacts(definition) {
|
|
1087
|
+
const docker = [];
|
|
1088
|
+
const bundle = [];
|
|
1089
|
+
const rawArtifacts = definition.pipeline.artifacts;
|
|
1090
|
+
if (!rawArtifacts) {
|
|
1091
|
+
return { docker, bundle };
|
|
1092
|
+
}
|
|
1093
|
+
for (const [name, raw] of Object.entries(rawArtifacts)) {
|
|
1094
|
+
const artifact = parseArtifact(name, raw);
|
|
1095
|
+
if (!artifact) {
|
|
1096
|
+
continue;
|
|
1097
|
+
}
|
|
1098
|
+
if (DOCKER_TYPES.includes(artifact.type)) {
|
|
1099
|
+
docker.push(artifact);
|
|
1100
|
+
} else if (BUNDLE_TYPES.includes(artifact.type)) {
|
|
1101
|
+
bundle.push(artifact);
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
verbose(`Parsed artifacts: ${docker.length} docker, ${bundle.length} bundle`);
|
|
1105
|
+
return { docker, bundle };
|
|
1106
|
+
}
|
|
1107
|
+
function parseArtifact(name, raw) {
|
|
1108
|
+
if (!raw.type) {
|
|
1109
|
+
warn(`Artifact "${name}" is missing type, skipping`);
|
|
1110
|
+
return null;
|
|
1111
|
+
}
|
|
1112
|
+
if (!VALID_TYPES.includes(raw.type)) {
|
|
1113
|
+
warn(`Artifact "${name}" has unknown type "${raw.type}", skipping`);
|
|
1114
|
+
return null;
|
|
1115
|
+
}
|
|
1116
|
+
const base = {
|
|
1117
|
+
name,
|
|
1118
|
+
id: raw.id,
|
|
1119
|
+
type: raw.type,
|
|
1120
|
+
architecture: raw.architecture,
|
|
1121
|
+
host_size: raw.host_size,
|
|
1122
|
+
per_stage: raw.per_stage,
|
|
1123
|
+
rebuild_when_changed: raw.rebuild_when_changed,
|
|
1124
|
+
dependencies: raw.dependencies,
|
|
1125
|
+
params: raw.params
|
|
1126
|
+
};
|
|
1127
|
+
switch (raw.type) {
|
|
1128
|
+
case "DEVRAMPS:DOCKER:BUILD":
|
|
1129
|
+
return base;
|
|
1130
|
+
case "DEVRAMPS:DOCKER:IMPORT":
|
|
1131
|
+
return base;
|
|
1132
|
+
case "DEVRAMPS:BUNDLE:BUILD":
|
|
1133
|
+
return base;
|
|
1134
|
+
case "DEVRAMPS:BUNDLE:IMPORT":
|
|
1135
|
+
return base;
|
|
1136
|
+
default:
|
|
1137
|
+
return null;
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
function filterArtifactsForPipelineStack(artifacts) {
|
|
1141
|
+
return {
|
|
1142
|
+
docker: artifacts.docker.filter((a) => !a.per_stage),
|
|
1143
|
+
bundle: artifacts.bundle.filter((a) => !a.per_stage)
|
|
1144
|
+
};
|
|
1145
|
+
}
|
|
1146
|
+
function getArtifactId(artifact) {
|
|
1147
|
+
if (artifact.id) {
|
|
1148
|
+
return artifact.id;
|
|
1149
|
+
}
|
|
1150
|
+
return artifact.name.toLowerCase().replace(/[^a-z0-9]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
// src/templates/common.ts
|
|
1154
|
+
var STANDARD_TAGS = [
|
|
1155
|
+
{ Key: "CreatedBy", Value: "DevRamps" },
|
|
1156
|
+
{ Key: "ManagedBy", Value: "DevRamps-CLI" }
|
|
1157
|
+
];
|
|
1158
|
+
function createBaseTemplate(description) {
|
|
1159
|
+
return {
|
|
1160
|
+
AWSTemplateFormatVersion: "2010-09-09",
|
|
1161
|
+
Description: description,
|
|
1162
|
+
Parameters: {},
|
|
1163
|
+
Conditions: {},
|
|
1164
|
+
Resources: {},
|
|
1165
|
+
Outputs: {}
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
function sanitizeResourceId(name) {
|
|
1169
|
+
return name.replace(/[^a-zA-Z0-9]/g, "").substring(0, 64);
|
|
1170
|
+
}
|
|
1171
|
+
function addOidcProviderResource(template, conditional = true) {
|
|
1172
|
+
if (conditional) {
|
|
1173
|
+
template.Parameters.OIDCProviderExists = {
|
|
1174
|
+
Type: "String",
|
|
1175
|
+
Default: "false",
|
|
1176
|
+
AllowedValues: ["true", "false"],
|
|
1177
|
+
Description: "Whether the OIDC provider already exists in this account"
|
|
1178
|
+
};
|
|
1179
|
+
template.Conditions.CreateOIDCProvider = {
|
|
1180
|
+
"Fn::Equals": [{ Ref: "OIDCProviderExists" }, "false"]
|
|
1181
|
+
};
|
|
1182
|
+
}
|
|
1183
|
+
template.Resources.DevRampsOIDCProvider = {
|
|
1184
|
+
Type: "AWS::IAM::OIDCProvider",
|
|
1185
|
+
...conditional ? { Condition: "CreateOIDCProvider" } : {},
|
|
1186
|
+
Properties: {
|
|
1187
|
+
Url: `https://${OIDC_PROVIDER_URL}`,
|
|
1188
|
+
ClientIdList: [OIDC_PROVIDER_URL],
|
|
1189
|
+
ThumbprintList: [getOidcThumbprint()],
|
|
1190
|
+
Tags: STANDARD_TAGS
|
|
1191
|
+
}
|
|
1192
|
+
};
|
|
1193
|
+
}
|
|
1194
|
+
function getOidcProviderArn(accountId, conditional = true) {
|
|
1195
|
+
if (conditional) {
|
|
1196
|
+
return {
|
|
1197
|
+
"Fn::If": [
|
|
1198
|
+
"CreateOIDCProvider",
|
|
1199
|
+
{ "Fn::GetAtt": ["DevRampsOIDCProvider", "Arn"] },
|
|
1200
|
+
`arn:aws:iam::${accountId}:oidc-provider/${OIDC_PROVIDER_URL}`
|
|
1201
|
+
]
|
|
1202
|
+
};
|
|
1203
|
+
}
|
|
1204
|
+
return { "Fn::GetAtt": ["DevRampsOIDCProvider", "Arn"] };
|
|
1205
|
+
}
|
|
1206
|
+
function buildOidcTrustPolicy(accountId, subject) {
|
|
1207
|
+
return {
|
|
1208
|
+
Version: "2012-10-17",
|
|
1209
|
+
Statement: [
|
|
1210
|
+
{
|
|
1211
|
+
Effect: "Allow",
|
|
1212
|
+
Principal: {
|
|
1213
|
+
Federated: `arn:aws:iam::${accountId}:oidc-provider/${OIDC_PROVIDER_URL}`
|
|
1214
|
+
},
|
|
1215
|
+
Action: "sts:AssumeRoleWithWebIdentity",
|
|
1216
|
+
Condition: {
|
|
1217
|
+
StringEquals: {
|
|
1218
|
+
[`${OIDC_PROVIDER_URL}:sub`]: subject,
|
|
1219
|
+
[`${OIDC_PROVIDER_URL}:aud`]: OIDC_PROVIDER_URL
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
]
|
|
1224
|
+
};
|
|
1225
|
+
}
|
|
1226
|
+
function createIamRoleResource(roleName, trustPolicy, policies, additionalTags = []) {
|
|
1227
|
+
return {
|
|
1228
|
+
Type: "AWS::IAM::Role",
|
|
1229
|
+
Properties: {
|
|
1230
|
+
RoleName: roleName,
|
|
1231
|
+
AssumeRolePolicyDocument: trustPolicy,
|
|
1232
|
+
...policies && policies.length > 0 ? { Policies: policies } : {},
|
|
1233
|
+
Tags: [...STANDARD_TAGS, ...additionalTags]
|
|
1234
|
+
}
|
|
1235
|
+
};
|
|
1236
|
+
}
|
|
1237
|
+
function createS3BucketResource(bucketName, additionalTags = [], encryption) {
|
|
1238
|
+
const encryptionConfig = encryption?.kmsKeyArn ? {
|
|
1239
|
+
ServerSideEncryptionConfiguration: [
|
|
1240
|
+
{
|
|
1241
|
+
ServerSideEncryptionByDefault: {
|
|
1242
|
+
SSEAlgorithm: "aws:kms",
|
|
1243
|
+
KMSMasterKeyID: encryption.kmsKeyArn
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
]
|
|
1247
|
+
} : {
|
|
1248
|
+
ServerSideEncryptionConfiguration: [
|
|
1249
|
+
{
|
|
1250
|
+
ServerSideEncryptionByDefault: {
|
|
1251
|
+
SSEAlgorithm: "AES256"
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
]
|
|
1255
|
+
};
|
|
1256
|
+
return {
|
|
1257
|
+
Type: "AWS::S3::Bucket",
|
|
1258
|
+
Properties: {
|
|
1259
|
+
BucketName: bucketName,
|
|
1260
|
+
VersioningConfiguration: { Status: "Enabled" },
|
|
1261
|
+
BucketEncryption: encryptionConfig,
|
|
1262
|
+
PublicAccessBlockConfiguration: {
|
|
1263
|
+
BlockPublicAcls: true,
|
|
1264
|
+
BlockPublicPolicy: true,
|
|
1265
|
+
IgnorePublicAcls: true,
|
|
1266
|
+
RestrictPublicBuckets: true
|
|
1267
|
+
},
|
|
1268
|
+
Tags: [...STANDARD_TAGS, ...additionalTags]
|
|
1269
|
+
}
|
|
1270
|
+
};
|
|
1271
|
+
}
|
|
1272
|
+
function createEcrRepositoryResource(repositoryName, additionalTags = []) {
|
|
1273
|
+
return {
|
|
1274
|
+
Type: "AWS::ECR::Repository",
|
|
1275
|
+
Properties: {
|
|
1276
|
+
RepositoryName: repositoryName,
|
|
1277
|
+
ImageScanningConfiguration: { ScanOnPush: true },
|
|
1278
|
+
EncryptionConfiguration: { EncryptionType: "AES256" },
|
|
1279
|
+
Tags: [...STANDARD_TAGS, ...additionalTags]
|
|
1280
|
+
}
|
|
1281
|
+
};
|
|
1282
|
+
}
|
|
1283
|
+
function createKmsKeyResource(description, keyPolicy, additionalTags = []) {
|
|
1284
|
+
return {
|
|
1285
|
+
Type: "AWS::KMS::Key",
|
|
1286
|
+
Properties: {
|
|
1287
|
+
Description: description,
|
|
1288
|
+
EnableKeyRotation: true,
|
|
1289
|
+
KeyPolicy: keyPolicy,
|
|
1290
|
+
Tags: [...STANDARD_TAGS, ...additionalTags]
|
|
1291
|
+
}
|
|
1292
|
+
};
|
|
1293
|
+
}
|
|
1294
|
+
function createKmsKeyAliasResource(aliasName, targetKeyRef) {
|
|
1295
|
+
return {
|
|
1296
|
+
Type: "AWS::KMS::Alias",
|
|
1297
|
+
Properties: {
|
|
1298
|
+
AliasName: aliasName,
|
|
1299
|
+
TargetKeyId: { Ref: targetKeyRef }
|
|
1300
|
+
}
|
|
1301
|
+
};
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
// src/naming/index.ts
|
|
1305
|
+
var S3_BUCKET_MAX_LENGTH = 63;
|
|
1306
|
+
var ECR_REPO_MAX_LENGTH = 256;
|
|
1307
|
+
var IAM_ROLE_MAX_LENGTH = 64;
|
|
1308
|
+
var CF_STACK_MAX_LENGTH = 128;
|
|
1309
|
+
function normalizeName(name) {
|
|
1310
|
+
return name.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
1311
|
+
}
|
|
1312
|
+
function generateShortHash(input, length = 6) {
|
|
1313
|
+
let hash = 0;
|
|
1314
|
+
for (let i = 0; i < input.length; i++) {
|
|
1315
|
+
const char = input.charCodeAt(i);
|
|
1316
|
+
hash = (hash << 5) - hash + char;
|
|
1317
|
+
hash = hash & hash;
|
|
1318
|
+
}
|
|
1319
|
+
return Math.abs(hash).toString(36).substring(0, length).padStart(length, "0");
|
|
1320
|
+
}
|
|
1321
|
+
function truncateName(name, maxLength, hashLength = 6) {
|
|
1322
|
+
if (name.length <= maxLength) {
|
|
1323
|
+
return name;
|
|
1324
|
+
}
|
|
1325
|
+
const availableLength = maxLength - hashLength - 1;
|
|
1326
|
+
const hash = generateShortHash(name, hashLength);
|
|
1327
|
+
return `${name.substring(0, availableLength)}-${hash}`;
|
|
1328
|
+
}
|
|
1329
|
+
function generateTerraformStateBucketName(orgSlug) {
|
|
1330
|
+
const normalized = normalizeName(`devramps-${orgSlug}-terraform-state`);
|
|
1331
|
+
return truncateName(normalized, S3_BUCKET_MAX_LENGTH);
|
|
1332
|
+
}
|
|
1333
|
+
function generatePipelineBucketName(cicdAccountId, pipelineSlug, artifactId) {
|
|
1334
|
+
const normalized = normalizeName(`${cicdAccountId}-${pipelineSlug}-${artifactId}`);
|
|
1335
|
+
return truncateName(normalized, S3_BUCKET_MAX_LENGTH);
|
|
1336
|
+
}
|
|
1337
|
+
function generateStageBucketName(accountId, pipelineSlug, stageName, artifactId) {
|
|
1338
|
+
const normalized = normalizeName(`${accountId}-${pipelineSlug}-${stageName}-${artifactId}`);
|
|
1339
|
+
return truncateName(normalized, S3_BUCKET_MAX_LENGTH);
|
|
1340
|
+
}
|
|
1341
|
+
function generatePipelineEcrRepoName(pipelineSlug, artifactId) {
|
|
1342
|
+
const normalized = normalizeName(`${pipelineSlug}-${artifactId}`);
|
|
1343
|
+
return truncateName(normalized, ECR_REPO_MAX_LENGTH);
|
|
1344
|
+
}
|
|
1345
|
+
function generateStageEcrRepoName(pipelineSlug, stageName, artifactId) {
|
|
1346
|
+
const normalized = normalizeName(`${pipelineSlug}-${stageName}-${artifactId}`);
|
|
1347
|
+
return truncateName(normalized, ECR_REPO_MAX_LENGTH);
|
|
1348
|
+
}
|
|
1349
|
+
function getOrgRoleName() {
|
|
1350
|
+
return "DevRamps-CICD-DeploymentRole";
|
|
1351
|
+
}
|
|
1352
|
+
function generateStageRoleName(pipelineSlug, stageName) {
|
|
1353
|
+
const baseName = `DevRamps-${pipelineSlug}-${stageName}-DeploymentRole`;
|
|
1354
|
+
return truncateName(baseName, IAM_ROLE_MAX_LENGTH);
|
|
1355
|
+
}
|
|
1356
|
+
function getOrgStackName(orgSlug) {
|
|
1357
|
+
return truncateName(`DevRamps-${orgSlug}-Org`, CF_STACK_MAX_LENGTH);
|
|
1358
|
+
}
|
|
1359
|
+
function getPipelineStackName(pipelineSlug) {
|
|
1360
|
+
return truncateName(`DevRamps-${pipelineSlug}-Pipeline`, CF_STACK_MAX_LENGTH);
|
|
1361
|
+
}
|
|
1362
|
+
function getStageStackName(pipelineSlug, stageName) {
|
|
1363
|
+
return truncateName(`DevRamps-${pipelineSlug}-${stageName}-Stage`, CF_STACK_MAX_LENGTH);
|
|
1364
|
+
}
|
|
1365
|
+
function getKmsKeyAlias(orgSlug) {
|
|
1366
|
+
return `alias/devramps-${normalizeName(orgSlug)}`;
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
// src/merge/bucket-policy.ts
|
|
1370
|
+
import { S3Client, GetBucketPolicyCommand } from "@aws-sdk/client-s3";
|
|
1371
|
+
|
|
1372
|
+
// src/merge/strategy.ts
|
|
1373
|
+
var BaseMergeStrategy = class {
|
|
1374
|
+
/**
|
|
1375
|
+
* Default validation - always valid. Override for specific validation.
|
|
1376
|
+
*/
|
|
1377
|
+
validate(result) {
|
|
1378
|
+
return { valid: true };
|
|
1379
|
+
}
|
|
1380
|
+
};
|
|
1381
|
+
|
|
1382
|
+
// src/merge/bucket-policy.ts
|
|
1383
|
+
var BucketPolicyMergeStrategy = class extends BaseMergeStrategy {
|
|
1384
|
+
strategyId = "terraform-state-bucket-policy";
|
|
1385
|
+
displayName = "Terraform State Bucket Policy";
|
|
1386
|
+
bucketName = null;
|
|
1387
|
+
credentials;
|
|
1388
|
+
region = "us-east-1";
|
|
1389
|
+
/**
|
|
1390
|
+
* Configure the strategy with bucket details
|
|
1391
|
+
*/
|
|
1392
|
+
configure(bucketName, region, credentials) {
|
|
1393
|
+
this.bucketName = bucketName;
|
|
1394
|
+
this.region = region;
|
|
1395
|
+
this.credentials = credentials;
|
|
1396
|
+
}
|
|
1397
|
+
/**
|
|
1398
|
+
* Extract existing account IDs from the current bucket policy
|
|
1399
|
+
*/
|
|
1400
|
+
async extractExisting(stackResources) {
|
|
1401
|
+
if (!this.bucketName) {
|
|
1402
|
+
verbose("No bucket name configured, cannot extract existing policy");
|
|
1403
|
+
return null;
|
|
1404
|
+
}
|
|
1405
|
+
try {
|
|
1406
|
+
const client = new S3Client({
|
|
1407
|
+
region: this.region,
|
|
1408
|
+
credentials: this.credentials
|
|
1409
|
+
});
|
|
1410
|
+
const response = await client.send(
|
|
1411
|
+
new GetBucketPolicyCommand({ Bucket: this.bucketName })
|
|
1412
|
+
);
|
|
1413
|
+
if (!response.Policy) {
|
|
1414
|
+
verbose("Bucket has no policy");
|
|
1415
|
+
return null;
|
|
1416
|
+
}
|
|
1417
|
+
const policy = JSON.parse(response.Policy);
|
|
1418
|
+
const accountIds = this.extractAccountIdsFromPolicy(policy);
|
|
1419
|
+
verbose(`Found ${accountIds.length} existing account(s) in bucket policy`);
|
|
1420
|
+
return { allowedAccountIds: accountIds };
|
|
1421
|
+
} catch (error2) {
|
|
1422
|
+
if (error2 instanceof Error && error2.name === "NoSuchBucketPolicy") {
|
|
1423
|
+
verbose("Bucket has no policy (NoSuchBucketPolicy)");
|
|
1424
|
+
return null;
|
|
1425
|
+
}
|
|
1426
|
+
if (error2 instanceof Error && error2.name === "NoSuchBucket") {
|
|
1427
|
+
verbose("Bucket does not exist yet");
|
|
1428
|
+
return null;
|
|
1429
|
+
}
|
|
1430
|
+
verbose(`Could not read bucket policy: ${error2 instanceof Error ? error2.message : String(error2)}`);
|
|
1431
|
+
return null;
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
/**
|
|
1435
|
+
* Collect all target account IDs from all pipelines
|
|
1436
|
+
*/
|
|
1437
|
+
async collectNew(context) {
|
|
1438
|
+
const accountIds = /* @__PURE__ */ new Set();
|
|
1439
|
+
if (!isValidAwsAccountId(context.cicdAccountId)) {
|
|
1440
|
+
throw new Error(
|
|
1441
|
+
`Invalid CI/CD account ID: "${context.cicdAccountId}". AWS account IDs must be exactly 12 digits.`
|
|
1442
|
+
);
|
|
1443
|
+
}
|
|
1444
|
+
accountIds.add(context.cicdAccountId);
|
|
1445
|
+
for (const pipeline of context.pipelines) {
|
|
1446
|
+
for (const accountId of pipeline.targetAccountIds) {
|
|
1447
|
+
if (!isValidAwsAccountId(accountId)) {
|
|
1448
|
+
throw new Error(
|
|
1449
|
+
`Invalid target account ID in pipeline "${pipeline.slug}": "${accountId}". AWS account IDs must be exactly 12 digits.`
|
|
1450
|
+
);
|
|
1451
|
+
}
|
|
1452
|
+
accountIds.add(accountId);
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
verbose(`Collected ${accountIds.size} account(s) from pipelines`);
|
|
1456
|
+
return { allowedAccountIds: Array.from(accountIds) };
|
|
1457
|
+
}
|
|
1458
|
+
/**
|
|
1459
|
+
* Merge existing and new account IDs, deduplicating
|
|
1460
|
+
*/
|
|
1461
|
+
merge(existing, newData) {
|
|
1462
|
+
const mergedAccountIds = /* @__PURE__ */ new Set();
|
|
1463
|
+
if (existing) {
|
|
1464
|
+
for (const accountId of existing.allowedAccountIds) {
|
|
1465
|
+
mergedAccountIds.add(accountId);
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
for (const accountId of newData.allowedAccountIds) {
|
|
1469
|
+
mergedAccountIds.add(accountId);
|
|
1470
|
+
}
|
|
1471
|
+
const sorted = Array.from(mergedAccountIds).sort();
|
|
1472
|
+
verbose(`Merged to ${sorted.length} unique account(s)`);
|
|
1473
|
+
return { allowedAccountIds: sorted };
|
|
1474
|
+
}
|
|
1475
|
+
/**
|
|
1476
|
+
* Validate the merged result
|
|
1477
|
+
*/
|
|
1478
|
+
validate(result) {
|
|
1479
|
+
const errors = [];
|
|
1480
|
+
const warnings = [];
|
|
1481
|
+
for (const accountId of result.allowedAccountIds) {
|
|
1482
|
+
if (!/^\d{12}$/.test(accountId)) {
|
|
1483
|
+
errors.push(`Invalid AWS account ID format: ${accountId}`);
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
if (result.allowedAccountIds.length > 50) {
|
|
1487
|
+
warnings.push(
|
|
1488
|
+
`Large number of accounts (${result.allowedAccountIds.length}) in bucket policy. Consider using AWS Organizations conditions instead.`
|
|
1489
|
+
);
|
|
1490
|
+
}
|
|
1491
|
+
return {
|
|
1492
|
+
valid: errors.length === 0,
|
|
1493
|
+
errors: errors.length > 0 ? errors : void 0,
|
|
1494
|
+
warnings: warnings.length > 0 ? warnings : void 0
|
|
1495
|
+
};
|
|
1496
|
+
}
|
|
1497
|
+
/**
|
|
1498
|
+
* Extract account IDs from a bucket policy document
|
|
1499
|
+
*/
|
|
1500
|
+
extractAccountIdsFromPolicy(policy) {
|
|
1501
|
+
const accountIds = [];
|
|
1502
|
+
if (!policy || typeof policy !== "object") {
|
|
1503
|
+
return accountIds;
|
|
1504
|
+
}
|
|
1505
|
+
const policyDoc = policy;
|
|
1506
|
+
if (!Array.isArray(policyDoc.Statement)) {
|
|
1507
|
+
return accountIds;
|
|
1508
|
+
}
|
|
1509
|
+
for (const statement of policyDoc.Statement) {
|
|
1510
|
+
if (!statement || typeof statement !== "object") continue;
|
|
1511
|
+
const stmt = statement;
|
|
1512
|
+
const principal = stmt.Principal?.AWS;
|
|
1513
|
+
if (!principal) continue;
|
|
1514
|
+
const principals = Array.isArray(principal) ? principal : [principal];
|
|
1515
|
+
for (const p of principals) {
|
|
1516
|
+
let extractedId = null;
|
|
1517
|
+
const arnMatch = p.match(/arn:aws:iam::(\d{12}):/);
|
|
1518
|
+
if (arnMatch) {
|
|
1519
|
+
extractedId = arnMatch[1];
|
|
1520
|
+
} else if (/^\d{12}$/.test(p)) {
|
|
1521
|
+
extractedId = p;
|
|
1522
|
+
}
|
|
1523
|
+
if (extractedId) {
|
|
1524
|
+
if (isValidAwsAccountId(extractedId)) {
|
|
1525
|
+
accountIds.push(extractedId);
|
|
1526
|
+
} else {
|
|
1527
|
+
verbose(`Skipping invalid account ID from existing policy: "${extractedId}"`);
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
return [...new Set(accountIds)];
|
|
1533
|
+
}
|
|
1534
|
+
};
|
|
1535
|
+
function createTerraformStateBucketPolicy(bucketName, cicdAccountId, allowedAccountIds) {
|
|
1536
|
+
const accountStatements = allowedAccountIds.filter((id) => id !== cicdAccountId).map((accountId) => ({
|
|
1537
|
+
Sid: `AllowAccount${accountId}`,
|
|
1538
|
+
Effect: "Allow",
|
|
1539
|
+
Principal: {
|
|
1540
|
+
AWS: `arn:aws:iam::${accountId}:root`
|
|
1541
|
+
},
|
|
1542
|
+
Action: [
|
|
1543
|
+
"s3:GetObject",
|
|
1544
|
+
"s3:PutObject",
|
|
1545
|
+
"s3:DeleteObject"
|
|
1546
|
+
],
|
|
1547
|
+
Resource: `arn:aws:s3:::${bucketName}/*`
|
|
1548
|
+
}));
|
|
1549
|
+
const listStatement = {
|
|
1550
|
+
Sid: "AllowListBucket",
|
|
1551
|
+
Effect: "Allow",
|
|
1552
|
+
Principal: {
|
|
1553
|
+
AWS: allowedAccountIds.map((id) => `arn:aws:iam::${id}:root`)
|
|
1554
|
+
},
|
|
1555
|
+
Action: "s3:ListBucket",
|
|
1556
|
+
Resource: `arn:aws:s3:::${bucketName}`
|
|
1557
|
+
};
|
|
1558
|
+
const cicdStatement = {
|
|
1559
|
+
Sid: "AllowCICDAccount",
|
|
1560
|
+
Effect: "Allow",
|
|
1561
|
+
Principal: {
|
|
1562
|
+
AWS: `arn:aws:iam::${cicdAccountId}:root`
|
|
1563
|
+
},
|
|
1564
|
+
Action: "s3:*",
|
|
1565
|
+
Resource: [
|
|
1566
|
+
`arn:aws:s3:::${bucketName}`,
|
|
1567
|
+
`arn:aws:s3:::${bucketName}/*`
|
|
1568
|
+
]
|
|
1569
|
+
};
|
|
1570
|
+
return {
|
|
1571
|
+
Version: "2012-10-17",
|
|
1572
|
+
Statement: [cicdStatement, listStatement, ...accountStatements]
|
|
1573
|
+
};
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
// src/templates/org-stack.ts
|
|
1577
|
+
function generateOrgStackTemplate(options) {
|
|
1578
|
+
const { orgSlug, cicdAccountId, targetAccountIds } = options;
|
|
1579
|
+
const template = createBaseTemplate(`DevRamps Org Stack for ${orgSlug}`);
|
|
1580
|
+
addOidcProviderResource(template, true);
|
|
1581
|
+
const kmsKeyPolicy = buildKmsKeyPolicy(cicdAccountId, targetAccountIds);
|
|
1582
|
+
template.Resources.DevRampsKMSKey = createKmsKeyResource(
|
|
1583
|
+
`DevRamps encryption key for org: ${orgSlug}`,
|
|
1584
|
+
kmsKeyPolicy,
|
|
1585
|
+
[{ Key: "Organization", Value: orgSlug }]
|
|
1586
|
+
);
|
|
1587
|
+
template.Resources.DevRampsKMSKeyAlias = createKmsKeyAliasResource(
|
|
1588
|
+
getKmsKeyAlias(orgSlug),
|
|
1589
|
+
"DevRampsKMSKey"
|
|
1590
|
+
);
|
|
1591
|
+
const bucketName = generateTerraformStateBucketName(orgSlug);
|
|
1592
|
+
template.Resources.TerraformStateBucket = createS3BucketResource(
|
|
1593
|
+
bucketName,
|
|
1594
|
+
[{ Key: "Organization", Value: orgSlug }],
|
|
1595
|
+
{ kmsKeyArn: { "Fn::GetAtt": ["DevRampsKMSKey", "Arn"] } }
|
|
1596
|
+
);
|
|
1597
|
+
const bucketPolicy = createTerraformStateBucketPolicy(
|
|
1598
|
+
bucketName,
|
|
1599
|
+
cicdAccountId,
|
|
1600
|
+
targetAccountIds
|
|
1601
|
+
);
|
|
1602
|
+
template.Resources.TerraformStateBucketPolicy = {
|
|
1603
|
+
Type: "AWS::S3::BucketPolicy",
|
|
1604
|
+
Properties: {
|
|
1605
|
+
Bucket: { Ref: "TerraformStateBucket" },
|
|
1606
|
+
PolicyDocument: bucketPolicy
|
|
1607
|
+
}
|
|
1608
|
+
};
|
|
1609
|
+
const trustPolicy = buildOidcTrustPolicy(cicdAccountId, `org:${orgSlug}`);
|
|
1610
|
+
const orgRolePolicies = buildOrgRolePolicies(orgSlug);
|
|
1611
|
+
template.Resources.DevRampsCICDDeploymentRole = createIamRoleResource(
|
|
1612
|
+
getOrgRoleName(),
|
|
1613
|
+
trustPolicy,
|
|
1614
|
+
orgRolePolicies,
|
|
1615
|
+
[{ Key: "Organization", Value: orgSlug }]
|
|
1616
|
+
);
|
|
1617
|
+
template.Outputs = {
|
|
1618
|
+
OrgRoleArn: {
|
|
1619
|
+
Description: "ARN of the org-level CICD deployment role",
|
|
1620
|
+
Value: { "Fn::GetAtt": ["DevRampsCICDDeploymentRole", "Arn"] },
|
|
1621
|
+
Export: { Name: `DevRamps-${orgSlug}-OrgRoleArn` }
|
|
1622
|
+
},
|
|
1623
|
+
OrgRoleName: {
|
|
1624
|
+
Description: "Name of the org-level CICD deployment role",
|
|
1625
|
+
Value: { Ref: "DevRampsCICDDeploymentRole" }
|
|
1626
|
+
},
|
|
1627
|
+
KMSKeyArn: {
|
|
1628
|
+
Description: "ARN of the KMS encryption key",
|
|
1629
|
+
Value: { "Fn::GetAtt": ["DevRampsKMSKey", "Arn"] },
|
|
1630
|
+
Export: { Name: `DevRamps-${orgSlug}-KMSKeyArn` }
|
|
1631
|
+
},
|
|
1632
|
+
KMSKeyId: {
|
|
1633
|
+
Description: "ID of the KMS encryption key",
|
|
1634
|
+
Value: { Ref: "DevRampsKMSKey" }
|
|
1635
|
+
},
|
|
1636
|
+
TerraformStateBucketName: {
|
|
1637
|
+
Description: "Name of the Terraform state bucket",
|
|
1638
|
+
Value: { Ref: "TerraformStateBucket" },
|
|
1639
|
+
Export: { Name: `DevRamps-${orgSlug}-TerraformStateBucket` }
|
|
1640
|
+
},
|
|
1641
|
+
TerraformStateBucketArn: {
|
|
1642
|
+
Description: "ARN of the Terraform state bucket",
|
|
1643
|
+
Value: { "Fn::GetAtt": ["TerraformStateBucket", "Arn"] }
|
|
1644
|
+
},
|
|
1645
|
+
OIDCProviderArn: {
|
|
1646
|
+
Description: "ARN of the OIDC provider",
|
|
1647
|
+
Value: getOidcProviderArn(cicdAccountId, true)
|
|
1648
|
+
}
|
|
1649
|
+
};
|
|
1650
|
+
return template;
|
|
1651
|
+
}
|
|
1652
|
+
function buildKmsKeyPolicy(cicdAccountId, targetAccountIds) {
|
|
1653
|
+
const allAccountIds = [.../* @__PURE__ */ new Set([cicdAccountId, ...targetAccountIds])];
|
|
1654
|
+
return {
|
|
1655
|
+
Version: "2012-10-17",
|
|
1656
|
+
Statement: [
|
|
1657
|
+
{
|
|
1658
|
+
Sid: "EnableRootAccountPermissions",
|
|
1659
|
+
Effect: "Allow",
|
|
1660
|
+
Principal: {
|
|
1661
|
+
AWS: `arn:aws:iam::${cicdAccountId}:root`
|
|
1662
|
+
},
|
|
1663
|
+
Action: "kms:*",
|
|
1664
|
+
Resource: "*"
|
|
1665
|
+
},
|
|
1666
|
+
{
|
|
1667
|
+
Sid: "AllowTargetAccountsEncryptDecrypt",
|
|
1668
|
+
Effect: "Allow",
|
|
1669
|
+
Principal: {
|
|
1670
|
+
AWS: allAccountIds.map((id) => `arn:aws:iam::${id}:root`)
|
|
1671
|
+
},
|
|
1672
|
+
Action: [
|
|
1673
|
+
"kms:Encrypt",
|
|
1674
|
+
"kms:Decrypt",
|
|
1675
|
+
"kms:ReEncrypt*",
|
|
1676
|
+
"kms:GenerateDataKey*",
|
|
1677
|
+
"kms:DescribeKey"
|
|
1678
|
+
],
|
|
1679
|
+
Resource: "*"
|
|
1680
|
+
}
|
|
1681
|
+
]
|
|
1682
|
+
};
|
|
1683
|
+
}
|
|
1684
|
+
function buildOrgRolePolicies(orgSlug) {
|
|
1685
|
+
return [
|
|
1686
|
+
{
|
|
1687
|
+
PolicyName: "DevRampsOrgPolicy",
|
|
1688
|
+
PolicyDocument: {
|
|
1689
|
+
Version: "2012-10-17",
|
|
1690
|
+
Statement: [
|
|
1691
|
+
{
|
|
1692
|
+
Sid: "AllowAssumeStageRoles",
|
|
1693
|
+
Effect: "Allow",
|
|
1694
|
+
Action: "sts:AssumeRole",
|
|
1695
|
+
Resource: `arn:aws:iam::*:role/DevRamps-*-DeploymentRole`
|
|
1696
|
+
},
|
|
1697
|
+
{
|
|
1698
|
+
Sid: "AllowKMSUsage",
|
|
1699
|
+
Effect: "Allow",
|
|
1700
|
+
Action: [
|
|
1701
|
+
"kms:Encrypt",
|
|
1702
|
+
"kms:Decrypt",
|
|
1703
|
+
"kms:GenerateDataKey*"
|
|
1704
|
+
],
|
|
1705
|
+
Resource: "*",
|
|
1706
|
+
Condition: {
|
|
1707
|
+
StringEquals: {
|
|
1708
|
+
"kms:CallerAccount": { Ref: "AWS::AccountId" }
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
},
|
|
1712
|
+
{
|
|
1713
|
+
Sid: "AllowS3TerraformState",
|
|
1714
|
+
Effect: "Allow",
|
|
1715
|
+
Action: [
|
|
1716
|
+
"s3:GetObject",
|
|
1717
|
+
"s3:PutObject",
|
|
1718
|
+
"s3:DeleteObject",
|
|
1719
|
+
"s3:ListBucket"
|
|
1720
|
+
],
|
|
1721
|
+
Resource: [
|
|
1722
|
+
{ "Fn::GetAtt": ["TerraformStateBucket", "Arn"] },
|
|
1723
|
+
{ "Fn::Sub": "${TerraformStateBucket.Arn}/*" }
|
|
1724
|
+
]
|
|
1725
|
+
},
|
|
1726
|
+
{
|
|
1727
|
+
Sid: "AllowECROperations",
|
|
1728
|
+
Effect: "Allow",
|
|
1729
|
+
Action: [
|
|
1730
|
+
"ecr:GetAuthorizationToken",
|
|
1731
|
+
"ecr:BatchCheckLayerAvailability",
|
|
1732
|
+
"ecr:GetDownloadUrlForLayer",
|
|
1733
|
+
"ecr:BatchGetImage",
|
|
1734
|
+
"ecr:PutImage",
|
|
1735
|
+
"ecr:InitiateLayerUpload",
|
|
1736
|
+
"ecr:UploadLayerPart",
|
|
1737
|
+
"ecr:CompleteLayerUpload"
|
|
1738
|
+
],
|
|
1739
|
+
Resource: "*"
|
|
1740
|
+
}
|
|
1741
|
+
]
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
];
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
// src/templates/pipeline-stack.ts
|
|
1748
|
+
function generatePipelineStackTemplate(options) {
|
|
1749
|
+
const { pipelineSlug, cicdAccountId, dockerArtifacts, bundleArtifacts } = options;
|
|
1750
|
+
const template = createBaseTemplate(`DevRamps Pipeline Stack for ${pipelineSlug}`);
|
|
1751
|
+
const ecrOutputs = {};
|
|
1752
|
+
const s3Outputs = {};
|
|
1753
|
+
for (const artifact of dockerArtifacts) {
|
|
1754
|
+
const artifactId = getArtifactId(artifact);
|
|
1755
|
+
const repoName = generatePipelineEcrRepoName(pipelineSlug, artifactId);
|
|
1756
|
+
const resourceId = sanitizeResourceId(`ECR${artifactId}`);
|
|
1757
|
+
template.Resources[resourceId] = createEcrRepositoryResource(
|
|
1758
|
+
repoName,
|
|
1759
|
+
[
|
|
1760
|
+
{ Key: "Pipeline", Value: pipelineSlug },
|
|
1761
|
+
{ Key: "Artifact", Value: artifact.name },
|
|
1762
|
+
{ Key: "ArtifactType", Value: artifact.type }
|
|
1763
|
+
]
|
|
1764
|
+
);
|
|
1765
|
+
ecrOutputs[artifact.name] = { repoName, resourceId };
|
|
1766
|
+
}
|
|
1767
|
+
for (const artifact of bundleArtifacts) {
|
|
1768
|
+
const artifactId = getArtifactId(artifact);
|
|
1769
|
+
const bucketName = generatePipelineBucketName(cicdAccountId, pipelineSlug, artifactId);
|
|
1770
|
+
const resourceId = sanitizeResourceId(`Bucket${artifactId}`);
|
|
1771
|
+
template.Resources[resourceId] = createS3BucketResource(
|
|
1772
|
+
bucketName,
|
|
1773
|
+
[
|
|
1774
|
+
{ Key: "Pipeline", Value: pipelineSlug },
|
|
1775
|
+
{ Key: "Artifact", Value: artifact.name },
|
|
1776
|
+
{ Key: "ArtifactType", Value: artifact.type }
|
|
1777
|
+
]
|
|
1778
|
+
);
|
|
1779
|
+
s3Outputs[artifact.name] = { bucketName, resourceId };
|
|
1780
|
+
}
|
|
1781
|
+
for (const [artifactName, { resourceId }] of Object.entries(ecrOutputs)) {
|
|
1782
|
+
const safeName = sanitizeResourceId(artifactName);
|
|
1783
|
+
template.Outputs[`${safeName}RepoUri`] = {
|
|
1784
|
+
Description: `ECR Repository URI for ${artifactName}`,
|
|
1785
|
+
Value: { "Fn::GetAtt": [resourceId, "RepositoryUri"] },
|
|
1786
|
+
Export: { Name: `DevRamps-${pipelineSlug}-${safeName}-RepoUri` }
|
|
1787
|
+
};
|
|
1788
|
+
template.Outputs[`${safeName}RepoArn`] = {
|
|
1789
|
+
Description: `ECR Repository ARN for ${artifactName}`,
|
|
1790
|
+
Value: { "Fn::GetAtt": [resourceId, "Arn"] }
|
|
1791
|
+
};
|
|
1792
|
+
}
|
|
1793
|
+
for (const [artifactName, { resourceId }] of Object.entries(s3Outputs)) {
|
|
1794
|
+
const safeName = sanitizeResourceId(artifactName);
|
|
1795
|
+
template.Outputs[`${safeName}BucketName`] = {
|
|
1796
|
+
Description: `S3 Bucket name for ${artifactName}`,
|
|
1797
|
+
Value: { Ref: resourceId },
|
|
1798
|
+
Export: { Name: `DevRamps-${pipelineSlug}-${safeName}-BucketName` }
|
|
1799
|
+
};
|
|
1800
|
+
template.Outputs[`${safeName}BucketArn`] = {
|
|
1801
|
+
Description: `S3 Bucket ARN for ${artifactName}`,
|
|
1802
|
+
Value: { "Fn::GetAtt": [resourceId, "Arn"] }
|
|
1803
|
+
};
|
|
1804
|
+
}
|
|
1805
|
+
template.Outputs.PipelineSlug = {
|
|
1806
|
+
Description: "Pipeline slug",
|
|
1807
|
+
Value: pipelineSlug
|
|
1808
|
+
};
|
|
1809
|
+
template.Outputs.ECRRepoCount = {
|
|
1810
|
+
Description: "Number of ECR repositories created",
|
|
1811
|
+
Value: String(Object.keys(ecrOutputs).length)
|
|
1812
|
+
};
|
|
1813
|
+
template.Outputs.S3BucketCount = {
|
|
1814
|
+
Description: "Number of S3 buckets created",
|
|
1815
|
+
Value: String(Object.keys(s3Outputs).length)
|
|
1816
|
+
};
|
|
1817
|
+
return template;
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
// src/permissions/eks-deploy.ts
|
|
1821
|
+
var EKS_DEPLOY_PERMISSIONS = {
|
|
1822
|
+
actions: [
|
|
1823
|
+
// EKS cluster access
|
|
1824
|
+
"eks:DescribeCluster",
|
|
1825
|
+
"eks:AccessKubernetesApi",
|
|
1826
|
+
// EKS access entry management (for setting up kubectl access)
|
|
1827
|
+
"eks:CreateAccessEntry",
|
|
1828
|
+
"eks:DescribeAccessEntry",
|
|
1829
|
+
"eks:AssociateAccessPolicy"
|
|
1830
|
+
],
|
|
1831
|
+
// Resources are scoped to the specific cluster at deployment time
|
|
1832
|
+
// '*' is used here as the specific cluster ARN is determined by the pipeline config
|
|
1833
|
+
resources: ["*"]
|
|
1834
|
+
};
|
|
1835
|
+
|
|
1836
|
+
// src/permissions/eks-helm.ts
|
|
1837
|
+
var EKS_HELM_PERMISSIONS = {
|
|
1838
|
+
actions: [
|
|
1839
|
+
// EKS cluster access
|
|
1840
|
+
"eks:DescribeCluster",
|
|
1841
|
+
"eks:AccessKubernetesApi",
|
|
1842
|
+
// EKS access entry management (for setting up kubectl/helm access)
|
|
1843
|
+
"eks:CreateAccessEntry",
|
|
1844
|
+
"eks:DescribeAccessEntry",
|
|
1845
|
+
"eks:AssociateAccessPolicy"
|
|
1846
|
+
],
|
|
1847
|
+
// Resources are scoped to the specific cluster at deployment time
|
|
1848
|
+
resources: ["*"]
|
|
1849
|
+
};
|
|
1850
|
+
|
|
1851
|
+
// src/permissions/ecs-deploy.ts
|
|
1852
|
+
var ECS_DEPLOY_PERMISSIONS = {
|
|
1853
|
+
actions: [
|
|
1854
|
+
// ECS service operations
|
|
1855
|
+
"ecs:UpdateService",
|
|
1856
|
+
"ecs:DescribeServices",
|
|
1857
|
+
// Task definition operations
|
|
1858
|
+
"ecs:DescribeTaskDefinition",
|
|
1859
|
+
"ecs:RegisterTaskDefinition",
|
|
1860
|
+
// Required for ECS to use task/execution roles
|
|
1861
|
+
"iam:PassRole"
|
|
1862
|
+
],
|
|
1863
|
+
// Resources are scoped to the specific cluster/service at deployment time
|
|
1864
|
+
resources: ["*"]
|
|
1865
|
+
};
|
|
1866
|
+
|
|
1867
|
+
// src/permissions/approval-bake.ts
|
|
1868
|
+
var APPROVAL_BAKE_PERMISSIONS = {
|
|
1869
|
+
actions: [],
|
|
1870
|
+
resources: []
|
|
1871
|
+
};
|
|
1872
|
+
|
|
1873
|
+
// src/permissions/approval-test.ts
|
|
1874
|
+
var APPROVAL_TEST_PERMISSIONS = {
|
|
1875
|
+
actions: [],
|
|
1876
|
+
resources: []
|
|
1877
|
+
};
|
|
1878
|
+
|
|
1879
|
+
// src/permissions/mirror-ecr.ts
|
|
1880
|
+
var MIRROR_ECR_PERMISSIONS = {
|
|
1881
|
+
actions: [
|
|
1882
|
+
// ECR authentication
|
|
1883
|
+
"ecr:GetAuthorizationToken",
|
|
1884
|
+
// Pull operations (source ECR)
|
|
1885
|
+
"ecr:BatchGetImage",
|
|
1886
|
+
"ecr:GetDownloadUrlForLayer",
|
|
1887
|
+
// Push operations (target ECR)
|
|
1888
|
+
"ecr:PutImage",
|
|
1889
|
+
"ecr:InitiateLayerUpload",
|
|
1890
|
+
"ecr:UploadLayerPart",
|
|
1891
|
+
"ecr:CompleteLayerUpload",
|
|
1892
|
+
"ecr:BatchCheckLayerAvailability"
|
|
1893
|
+
],
|
|
1894
|
+
resources: ["*"]
|
|
1895
|
+
};
|
|
1896
|
+
|
|
1897
|
+
// src/permissions/mirror-s3.ts
|
|
1898
|
+
var MIRROR_S3_PERMISSIONS = {
|
|
1899
|
+
actions: [
|
|
1900
|
+
// Read operations (source bucket)
|
|
1901
|
+
"s3:GetObject",
|
|
1902
|
+
"s3:HeadObject",
|
|
1903
|
+
// Write operations (target bucket)
|
|
1904
|
+
"s3:PutObject",
|
|
1905
|
+
"s3:PutObjectAcl"
|
|
1906
|
+
],
|
|
1907
|
+
resources: ["*"]
|
|
1908
|
+
};
|
|
1909
|
+
|
|
1910
|
+
// src/permissions/bundle-import.ts
|
|
1911
|
+
var BUNDLE_IMPORT_PERMISSIONS = {
|
|
1912
|
+
actions: [
|
|
1913
|
+
// Read operations (source bucket in CI/CD account)
|
|
1914
|
+
"s3:GetObject",
|
|
1915
|
+
"s3:HeadObject",
|
|
1916
|
+
// Write operations (target bucket in deployment account)
|
|
1917
|
+
"s3:PutObject",
|
|
1918
|
+
"s3:PutObjectAcl",
|
|
1919
|
+
// Cross-account role assumption
|
|
1920
|
+
"sts:AssumeRole"
|
|
1921
|
+
],
|
|
1922
|
+
resources: ["*"]
|
|
1923
|
+
};
|
|
1924
|
+
|
|
1925
|
+
// src/permissions/docker-import.ts
|
|
1926
|
+
var DOCKER_IMPORT_PERMISSIONS = {
|
|
1927
|
+
actions: [
|
|
1928
|
+
// ECR authentication
|
|
1929
|
+
"ecr:GetAuthorizationToken",
|
|
1930
|
+
// Check image availability (source ECR)
|
|
1931
|
+
"ecr:DescribeImages",
|
|
1932
|
+
// Pull operations (source ECR)
|
|
1933
|
+
"ecr:BatchGetImage",
|
|
1934
|
+
"ecr:GetDownloadUrlForLayer",
|
|
1935
|
+
// Push operations (target ECR)
|
|
1936
|
+
"ecr:PutImage",
|
|
1937
|
+
"ecr:InitiateLayerUpload",
|
|
1938
|
+
"ecr:UploadLayerPart",
|
|
1939
|
+
"ecr:CompleteLayerUpload",
|
|
1940
|
+
"ecr:BatchCheckLayerAvailability",
|
|
1941
|
+
// Cross-account role assumption
|
|
1942
|
+
"sts:AssumeRole"
|
|
1943
|
+
],
|
|
1944
|
+
resources: ["*"]
|
|
1945
|
+
};
|
|
1946
|
+
|
|
1947
|
+
// src/permissions/custom.ts
|
|
1948
|
+
function getCustomPermissions(stepType) {
|
|
1949
|
+
verbose(
|
|
1950
|
+
`Step type '${stepType}' is a custom step. Ensure permissions are defined in aws_additional_iam_policies.yaml/json`
|
|
1951
|
+
);
|
|
1952
|
+
return {
|
|
1953
|
+
actions: [],
|
|
1954
|
+
resources: []
|
|
1955
|
+
};
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
// src/permissions/index.ts
|
|
1959
|
+
var PERMISSIONS_REGISTRY = {
|
|
1960
|
+
// Deployment steps
|
|
1961
|
+
"DEVRAMPS:EKS:DEPLOY": EKS_DEPLOY_PERMISSIONS,
|
|
1962
|
+
"DEVRAMPS:EKS:HELM": EKS_HELM_PERMISSIONS,
|
|
1963
|
+
"DEVRAMPS:ECS:DEPLOY": ECS_DEPLOY_PERMISSIONS,
|
|
1964
|
+
// Artifact mirroring steps (CI/CD account -> deployment account)
|
|
1965
|
+
"DEVRAMPS:MIRROR:ECR": MIRROR_ECR_PERMISSIONS,
|
|
1966
|
+
"DEVRAMPS:MIRROR:S3": MIRROR_S3_PERMISSIONS,
|
|
1967
|
+
// Artifact import steps (cross-account)
|
|
1968
|
+
"DEVRAMPS:BUNDLE:IMPORT": BUNDLE_IMPORT_PERMISSIONS,
|
|
1969
|
+
"DEVRAMPS:DOCKER:IMPORT": DOCKER_IMPORT_PERMISSIONS,
|
|
1970
|
+
// Approval/wait steps (no AWS permissions needed)
|
|
1971
|
+
"DEVRAMPS:APPROVAL:BAKE": APPROVAL_BAKE_PERMISSIONS,
|
|
1972
|
+
"DEVRAMPS:APPROVAL:TEST": APPROVAL_TEST_PERMISSIONS
|
|
1973
|
+
};
|
|
1974
|
+
function getStepPermissions(stepType) {
|
|
1975
|
+
const permissions = PERMISSIONS_REGISTRY[stepType];
|
|
1976
|
+
if (permissions) {
|
|
1977
|
+
return permissions;
|
|
1978
|
+
}
|
|
1979
|
+
if (stepType.startsWith("CUSTOM:")) {
|
|
1980
|
+
return getCustomPermissions(stepType);
|
|
1981
|
+
}
|
|
1982
|
+
return {
|
|
1983
|
+
actions: [],
|
|
1984
|
+
resources: []
|
|
1985
|
+
};
|
|
1986
|
+
}
|
|
1987
|
+
function hasPermissions(stepType) {
|
|
1988
|
+
const permissions = getStepPermissions(stepType);
|
|
1989
|
+
return permissions.actions.length > 0;
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
// src/templates/stage-stack.ts
|
|
1993
|
+
function generateStageStackTemplate(options) {
|
|
1994
|
+
const {
|
|
1995
|
+
pipelineSlug,
|
|
1996
|
+
stageName,
|
|
1997
|
+
orgSlug,
|
|
1998
|
+
accountId,
|
|
1999
|
+
steps,
|
|
2000
|
+
additionalPolicies,
|
|
2001
|
+
dockerArtifacts,
|
|
2002
|
+
bundleArtifacts
|
|
2003
|
+
} = options;
|
|
2004
|
+
const template = createBaseTemplate(
|
|
2005
|
+
`DevRamps Stage Stack for ${pipelineSlug}/${stageName}`
|
|
2006
|
+
);
|
|
2007
|
+
addOidcProviderResource(template, true);
|
|
2008
|
+
const roleName = generateStageRoleName(pipelineSlug, stageName);
|
|
2009
|
+
const trustPolicy = buildStageTrustPolicy(accountId, orgSlug, pipelineSlug, stageName);
|
|
2010
|
+
const policies = buildStagePolicies(steps, additionalPolicies);
|
|
2011
|
+
template.Resources.StageDeploymentRole = createIamRoleResource(
|
|
2012
|
+
roleName,
|
|
2013
|
+
trustPolicy,
|
|
2014
|
+
policies.length > 0 ? policies : void 0,
|
|
2015
|
+
[
|
|
2016
|
+
{ Key: "Pipeline", Value: pipelineSlug },
|
|
2017
|
+
{ Key: "Stage", Value: stageName },
|
|
2018
|
+
{ Key: "Organization", Value: orgSlug }
|
|
2019
|
+
]
|
|
2020
|
+
);
|
|
2021
|
+
const ecrOutputs = {};
|
|
2022
|
+
const s3Outputs = {};
|
|
2023
|
+
for (const artifact of dockerArtifacts) {
|
|
2024
|
+
const artifactId = getArtifactId(artifact);
|
|
2025
|
+
const repoName = generateStageEcrRepoName(pipelineSlug, stageName, artifactId);
|
|
2026
|
+
const resourceId = sanitizeResourceId(`ECR${artifactId}`);
|
|
2027
|
+
template.Resources[resourceId] = createEcrRepositoryResource(
|
|
2028
|
+
repoName,
|
|
2029
|
+
[
|
|
2030
|
+
{ Key: "Pipeline", Value: pipelineSlug },
|
|
2031
|
+
{ Key: "Stage", Value: stageName },
|
|
2032
|
+
{ Key: "Artifact", Value: artifact.name },
|
|
2033
|
+
{ Key: "ArtifactType", Value: artifact.type }
|
|
2034
|
+
]
|
|
2035
|
+
);
|
|
2036
|
+
ecrOutputs[artifact.name] = { resourceId };
|
|
2037
|
+
}
|
|
2038
|
+
for (const artifact of bundleArtifacts) {
|
|
2039
|
+
const artifactId = getArtifactId(artifact);
|
|
2040
|
+
const bucketName = generateStageBucketName(accountId, pipelineSlug, stageName, artifactId);
|
|
2041
|
+
const resourceId = sanitizeResourceId(`Bucket${artifactId}`);
|
|
2042
|
+
template.Resources[resourceId] = createS3BucketResource(
|
|
2043
|
+
bucketName,
|
|
2044
|
+
[
|
|
2045
|
+
{ Key: "Pipeline", Value: pipelineSlug },
|
|
2046
|
+
{ Key: "Stage", Value: stageName },
|
|
2047
|
+
{ Key: "Artifact", Value: artifact.name },
|
|
2048
|
+
{ Key: "ArtifactType", Value: artifact.type }
|
|
2049
|
+
]
|
|
2050
|
+
);
|
|
2051
|
+
s3Outputs[artifact.name] = { resourceId };
|
|
2052
|
+
}
|
|
2053
|
+
template.Outputs = {
|
|
2054
|
+
StageRoleArn: {
|
|
2055
|
+
Description: "ARN of the stage deployment role",
|
|
2056
|
+
Value: { "Fn::GetAtt": ["StageDeploymentRole", "Arn"] },
|
|
2057
|
+
Export: { Name: `DevRamps-${pipelineSlug}-${stageName}-RoleArn` }
|
|
2058
|
+
},
|
|
2059
|
+
StageRoleName: {
|
|
2060
|
+
Description: "Name of the stage deployment role",
|
|
2061
|
+
Value: { Ref: "StageDeploymentRole" }
|
|
2062
|
+
},
|
|
2063
|
+
OIDCProviderArn: {
|
|
2064
|
+
Description: "ARN of the OIDC provider",
|
|
2065
|
+
Value: getOidcProviderArn(accountId, true)
|
|
2066
|
+
},
|
|
2067
|
+
PipelineSlug: {
|
|
2068
|
+
Description: "Pipeline slug",
|
|
2069
|
+
Value: pipelineSlug
|
|
2070
|
+
},
|
|
2071
|
+
StageName: {
|
|
2072
|
+
Description: "Stage name",
|
|
2073
|
+
Value: stageName
|
|
2074
|
+
}
|
|
2075
|
+
};
|
|
2076
|
+
for (const [artifactName, { resourceId }] of Object.entries(ecrOutputs)) {
|
|
2077
|
+
const safeName = sanitizeResourceId(artifactName);
|
|
2078
|
+
template.Outputs[`${safeName}RepoUri`] = {
|
|
2079
|
+
Description: `ECR Repository URI for ${artifactName}`,
|
|
2080
|
+
Value: { "Fn::GetAtt": [resourceId, "RepositoryUri"] }
|
|
2081
|
+
};
|
|
2082
|
+
}
|
|
2083
|
+
for (const [artifactName, { resourceId }] of Object.entries(s3Outputs)) {
|
|
2084
|
+
const safeName = sanitizeResourceId(artifactName);
|
|
2085
|
+
template.Outputs[`${safeName}BucketName`] = {
|
|
2086
|
+
Description: `S3 Bucket name for ${artifactName}`,
|
|
2087
|
+
Value: { Ref: resourceId }
|
|
2088
|
+
};
|
|
2089
|
+
}
|
|
2090
|
+
return template;
|
|
2091
|
+
}
|
|
2092
|
+
function buildStageTrustPolicy(accountId, orgSlug, pipelineSlug, stageName) {
|
|
2093
|
+
const subject = `org:${orgSlug}/pipeline:${pipelineSlug}/stage:${stageName}`;
|
|
2094
|
+
return buildOidcTrustPolicy(accountId, subject);
|
|
2095
|
+
}
|
|
2096
|
+
function buildStagePolicies(steps, additionalPolicies) {
|
|
2097
|
+
const policies = [];
|
|
2098
|
+
for (const step of steps) {
|
|
2099
|
+
if (!hasPermissions(step.type)) {
|
|
2100
|
+
continue;
|
|
2101
|
+
}
|
|
2102
|
+
const permissions = getStepPermissions(step.type);
|
|
2103
|
+
if (!permissions.actions || permissions.actions.length === 0) {
|
|
2104
|
+
continue;
|
|
2105
|
+
}
|
|
2106
|
+
const policyName = `${sanitizeResourceId(step.name)}DeploymentPolicy`;
|
|
2107
|
+
policies.push({
|
|
2108
|
+
PolicyName: policyName,
|
|
2109
|
+
PolicyDocument: {
|
|
2110
|
+
Version: "2012-10-17",
|
|
2111
|
+
Statement: [
|
|
2112
|
+
{
|
|
2113
|
+
Sid: sanitizeResourceId(step.name),
|
|
2114
|
+
Effect: "Allow",
|
|
2115
|
+
Action: permissions.actions,
|
|
2116
|
+
Resource: permissions.resources || ["*"]
|
|
2117
|
+
}
|
|
2118
|
+
]
|
|
2119
|
+
}
|
|
2120
|
+
});
|
|
2121
|
+
}
|
|
2122
|
+
for (let i = 0; i < additionalPolicies.length; i++) {
|
|
2123
|
+
const policy = additionalPolicies[i];
|
|
2124
|
+
const policyName = `AdditionalPolicy${i + 1}`;
|
|
2125
|
+
policies.push({
|
|
2126
|
+
PolicyName: policyName,
|
|
2127
|
+
PolicyDocument: {
|
|
2128
|
+
Version: policy.Version || "2012-10-17",
|
|
2129
|
+
Statement: policy.Statement.map((stmt) => ({
|
|
2130
|
+
...stmt.Sid ? { Sid: stmt.Sid } : {},
|
|
2131
|
+
Effect: stmt.Effect,
|
|
2132
|
+
Action: Array.isArray(stmt.Action) ? stmt.Action : [stmt.Action],
|
|
2133
|
+
Resource: stmt.Resource,
|
|
2134
|
+
...stmt.Condition ? { Condition: stmt.Condition } : {}
|
|
2135
|
+
}))
|
|
2136
|
+
}
|
|
2137
|
+
});
|
|
2138
|
+
}
|
|
2139
|
+
return policies;
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2142
|
+
// src/merge/index.ts
|
|
2143
|
+
var MERGE_STRATEGIES = /* @__PURE__ */ new Map();
|
|
2144
|
+
var bucketPolicyStrategy = new BucketPolicyMergeStrategy();
|
|
2145
|
+
MERGE_STRATEGIES.set(bucketPolicyStrategy.strategyId, bucketPolicyStrategy);
|
|
2146
|
+
function getBucketPolicyStrategy() {
|
|
2147
|
+
return bucketPolicyStrategy;
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
// src/utils/prompts.ts
|
|
2151
|
+
import inquirer from "inquirer";
|
|
2152
|
+
async function confirmDeployment(plan) {
|
|
2153
|
+
header("DevRamps Bootstrap Summary");
|
|
2154
|
+
console.log(`Organization: ${plan.orgSlug}`);
|
|
2155
|
+
newline();
|
|
2156
|
+
const pipelineGroups = /* @__PURE__ */ new Map();
|
|
2157
|
+
for (const stack of plan.stacks) {
|
|
2158
|
+
const existing = pipelineGroups.get(stack.pipelineSlug) || [];
|
|
2159
|
+
existing.push(stack);
|
|
2160
|
+
pipelineGroups.set(stack.pipelineSlug, existing);
|
|
2161
|
+
}
|
|
2162
|
+
console.log("Pipelines to bootstrap:");
|
|
2163
|
+
for (const [slug, stacks] of pipelineGroups) {
|
|
2164
|
+
const accounts = new Set(stacks.map((s) => s.accountId));
|
|
2165
|
+
console.log(` - ${slug} (${accounts.size} target account${accounts.size !== 1 ? "s" : ""})`);
|
|
2166
|
+
}
|
|
2167
|
+
newline();
|
|
2168
|
+
console.log("Stacks to deploy:");
|
|
2169
|
+
const tableRows = [
|
|
2170
|
+
["Account ID", "Pipeline", "Stack Name", "Action"]
|
|
2171
|
+
];
|
|
2172
|
+
for (const stack of plan.stacks) {
|
|
2173
|
+
tableRows.push([
|
|
2174
|
+
stack.accountId,
|
|
2175
|
+
stack.pipelineSlug,
|
|
2176
|
+
stack.stackName,
|
|
2177
|
+
stack.action
|
|
2178
|
+
]);
|
|
2179
|
+
}
|
|
2180
|
+
table(tableRows);
|
|
2181
|
+
newline();
|
|
2182
|
+
console.log("Each stack creates:");
|
|
2183
|
+
console.log(" - OIDC Identity Provider for devramps.com (if not exists)");
|
|
2184
|
+
console.log(" - IAM Role: DevRamps-CICD-DeploymentRole");
|
|
2185
|
+
console.log(` - Trust: org:${plan.orgSlug}/pipeline:<pipeline-slug>`);
|
|
2186
|
+
console.log(" - Policies for each deployment step");
|
|
2187
|
+
newline();
|
|
2188
|
+
const { proceed } = await inquirer.prompt([
|
|
2189
|
+
{
|
|
2190
|
+
type: "confirm",
|
|
2191
|
+
name: "proceed",
|
|
2192
|
+
message: "Do you want to proceed?",
|
|
2193
|
+
default: false
|
|
2194
|
+
}
|
|
2195
|
+
]);
|
|
2196
|
+
return proceed;
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
// src/commands/bootstrap.ts
|
|
2200
|
+
async function bootstrapCommand(options) {
|
|
2201
|
+
try {
|
|
2202
|
+
if (options.verbose) {
|
|
2203
|
+
setVerbose(true);
|
|
2204
|
+
}
|
|
2205
|
+
header("DevRamps Bootstrap");
|
|
2206
|
+
const spinner = ora("Checking AWS credentials...").start();
|
|
2207
|
+
const identity = await getCurrentIdentity();
|
|
2208
|
+
spinner.succeed(`Authenticated as ${identity.arn}`);
|
|
2209
|
+
const authData = await authenticateViaBrowser({
|
|
2210
|
+
endpointOverride: options.endpointOverride
|
|
2211
|
+
});
|
|
2212
|
+
spinner.start("Finding pipelines...");
|
|
2213
|
+
const basePath = process.cwd();
|
|
2214
|
+
const filterSlugs = options.pipelineSlugs ? options.pipelineSlugs.split(",").map((s) => s.trim()) : void 0;
|
|
2215
|
+
const pipelineSlugs = await findDevrampsPipelines(basePath, filterSlugs);
|
|
2216
|
+
if (pipelineSlugs.length === 0) {
|
|
2217
|
+
spinner.fail("No pipelines found");
|
|
2218
|
+
error("No pipeline.yaml files found in .devramps/ folder.");
|
|
2219
|
+
process.exit(1);
|
|
2220
|
+
}
|
|
2221
|
+
spinner.text = `Parsing ${pipelineSlugs.length} pipeline(s)...`;
|
|
2222
|
+
const pipelines = [];
|
|
2223
|
+
const pipelineArtifacts = /* @__PURE__ */ new Map();
|
|
2224
|
+
for (const slug of pipelineSlugs) {
|
|
2225
|
+
const pipeline = await parsePipeline(basePath, slug);
|
|
2226
|
+
pipelines.push(pipeline);
|
|
2227
|
+
const artifacts = parseArtifacts(pipeline.definition);
|
|
2228
|
+
pipelineArtifacts.set(slug, artifacts);
|
|
2229
|
+
}
|
|
2230
|
+
spinner.succeed(`Found ${pipelines.length} pipeline(s)`);
|
|
2231
|
+
spinner.start("Building deployment plan...");
|
|
2232
|
+
const plan = await buildDeploymentPlan(
|
|
2233
|
+
pipelines,
|
|
2234
|
+
pipelineArtifacts,
|
|
2235
|
+
authData,
|
|
2236
|
+
identity.accountId,
|
|
2237
|
+
options.targetAccountRoleName
|
|
2238
|
+
);
|
|
2239
|
+
spinner.succeed("Deployment plan ready");
|
|
2240
|
+
if (options.dryRun) {
|
|
2241
|
+
await showDryRunPlan(plan);
|
|
2242
|
+
return;
|
|
2243
|
+
}
|
|
2244
|
+
const confirmed = await confirmDeploymentPlan(plan);
|
|
2245
|
+
if (!confirmed) {
|
|
2246
|
+
info("Deployment cancelled by user.");
|
|
2247
|
+
return;
|
|
2248
|
+
}
|
|
2249
|
+
await executeDeployment(plan, pipelines, pipelineArtifacts, authData, identity.accountId, options);
|
|
2250
|
+
} catch (error2) {
|
|
2251
|
+
if (error2 instanceof DevRampsError) {
|
|
2252
|
+
error(error2.message);
|
|
2253
|
+
process.exit(1);
|
|
2254
|
+
}
|
|
2255
|
+
error(`Unexpected error: ${error2 instanceof Error ? error2.message : String(error2)}`);
|
|
2256
|
+
if (isVerbose() && error2 instanceof Error && error2.stack) {
|
|
2257
|
+
console.error(error2.stack);
|
|
2258
|
+
}
|
|
2259
|
+
process.exit(1);
|
|
2260
|
+
}
|
|
2261
|
+
}
|
|
2262
|
+
async function buildDeploymentPlan(pipelines, pipelineArtifacts, authData, currentAccountId, targetRoleName) {
|
|
2263
|
+
const { orgSlug, cicdAccountId, cicdRegion } = authData;
|
|
2264
|
+
const allTargetAccountIds = /* @__PURE__ */ new Set();
|
|
2265
|
+
for (const pipeline of pipelines) {
|
|
2266
|
+
for (const accountId of pipeline.targetAccountIds) {
|
|
2267
|
+
allTargetAccountIds.add(accountId);
|
|
2268
|
+
}
|
|
2269
|
+
}
|
|
2270
|
+
let cicdCredentials;
|
|
2271
|
+
try {
|
|
2272
|
+
if (cicdAccountId !== currentAccountId) {
|
|
2273
|
+
const assumed = await assumeRoleForAccount({
|
|
2274
|
+
targetAccountId: cicdAccountId,
|
|
2275
|
+
currentAccountId,
|
|
2276
|
+
targetRoleName
|
|
2277
|
+
});
|
|
2278
|
+
cicdCredentials = assumed?.credentials;
|
|
2279
|
+
}
|
|
2280
|
+
} catch {
|
|
2281
|
+
verbose("Could not assume role in CI/CD account for status check");
|
|
2282
|
+
}
|
|
2283
|
+
const orgStackName = getOrgStackName(orgSlug);
|
|
2284
|
+
const orgStack = {
|
|
2285
|
+
stackType: "Org",
|
|
2286
|
+
stackName: orgStackName,
|
|
2287
|
+
accountId: cicdAccountId,
|
|
2288
|
+
region: cicdRegion,
|
|
2289
|
+
action: await determineStackAction(orgStackName, cicdCredentials, cicdRegion),
|
|
2290
|
+
orgSlug,
|
|
2291
|
+
targetAccountIds: Array.from(allTargetAccountIds)
|
|
2292
|
+
};
|
|
2293
|
+
const pipelineStacks = [];
|
|
2294
|
+
for (const pipeline of pipelines) {
|
|
2295
|
+
const artifacts = pipelineArtifacts.get(pipeline.slug);
|
|
2296
|
+
const filteredArtifacts = filterArtifactsForPipelineStack(artifacts);
|
|
2297
|
+
const stackName = getPipelineStackName(pipeline.slug);
|
|
2298
|
+
pipelineStacks.push({
|
|
2299
|
+
stackType: "Pipeline",
|
|
2300
|
+
stackName,
|
|
2301
|
+
accountId: cicdAccountId,
|
|
2302
|
+
region: cicdRegion,
|
|
2303
|
+
action: await determineStackAction(stackName, cicdCredentials, cicdRegion),
|
|
2304
|
+
pipelineSlug: pipeline.slug,
|
|
2305
|
+
dockerArtifacts: filteredArtifacts.docker,
|
|
2306
|
+
bundleArtifacts: filteredArtifacts.bundle
|
|
2307
|
+
});
|
|
2308
|
+
}
|
|
2309
|
+
const stageStacks = [];
|
|
2310
|
+
for (const pipeline of pipelines) {
|
|
2311
|
+
const artifacts = pipelineArtifacts.get(pipeline.slug);
|
|
2312
|
+
for (const stage of pipeline.stages) {
|
|
2313
|
+
const stackName = getStageStackName(pipeline.slug, stage.name);
|
|
2314
|
+
let stageCredentials;
|
|
2315
|
+
try {
|
|
2316
|
+
if (stage.account_id !== currentAccountId) {
|
|
2317
|
+
const assumed = await assumeRoleForAccount({
|
|
2318
|
+
targetAccountId: stage.account_id,
|
|
2319
|
+
currentAccountId,
|
|
2320
|
+
targetRoleName
|
|
2321
|
+
});
|
|
2322
|
+
stageCredentials = assumed?.credentials;
|
|
2323
|
+
}
|
|
2324
|
+
} catch {
|
|
2325
|
+
verbose(`Could not assume role in ${stage.account_id} for status check`);
|
|
2326
|
+
}
|
|
2327
|
+
stageStacks.push({
|
|
2328
|
+
stackType: "Stage",
|
|
2329
|
+
stackName,
|
|
2330
|
+
accountId: stage.account_id,
|
|
2331
|
+
region: stage.region,
|
|
2332
|
+
action: await determineStackAction(stackName, stageCredentials, stage.region),
|
|
2333
|
+
pipelineSlug: pipeline.slug,
|
|
2334
|
+
stageName: stage.name,
|
|
2335
|
+
orgSlug,
|
|
2336
|
+
steps: pipeline.steps,
|
|
2337
|
+
additionalPolicies: pipeline.additionalPolicies,
|
|
2338
|
+
dockerArtifacts: artifacts.docker,
|
|
2339
|
+
bundleArtifacts: artifacts.bundle
|
|
2340
|
+
});
|
|
2341
|
+
}
|
|
2342
|
+
}
|
|
2343
|
+
return {
|
|
2344
|
+
orgSlug,
|
|
2345
|
+
cicdAccountId,
|
|
2346
|
+
cicdRegion,
|
|
2347
|
+
orgStack,
|
|
2348
|
+
pipelineStacks,
|
|
2349
|
+
stageStacks
|
|
2350
|
+
};
|
|
2351
|
+
}
|
|
2352
|
+
async function determineStackAction(stackName, credentials, region) {
|
|
2353
|
+
try {
|
|
2354
|
+
const status = await getStackStatus(stackName, credentials, region);
|
|
2355
|
+
return status.exists ? "UPDATE" : "CREATE";
|
|
2356
|
+
} catch {
|
|
2357
|
+
return "CREATE";
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
async function showDryRunPlan(plan) {
|
|
2361
|
+
newline();
|
|
2362
|
+
header("Deployment Plan (Dry Run)");
|
|
2363
|
+
info(`Organization: ${plan.orgSlug}`);
|
|
2364
|
+
info(`CI/CD Account: ${plan.cicdAccountId}`);
|
|
2365
|
+
info(`CI/CD Region: ${plan.cicdRegion}`);
|
|
2366
|
+
newline();
|
|
2367
|
+
info("Phase 1: Org Stack");
|
|
2368
|
+
info(` ${plan.orgStack.action}: ${plan.orgStack.stackName}`);
|
|
2369
|
+
info(` Account: ${plan.orgStack.accountId}`);
|
|
2370
|
+
info(` Target accounts with bucket access: ${plan.orgStack.targetAccountIds.length}`);
|
|
2371
|
+
newline();
|
|
2372
|
+
info("Phase 2: Pipeline Stacks");
|
|
2373
|
+
for (const stack of plan.pipelineStacks) {
|
|
2374
|
+
info(` ${stack.action}: ${stack.stackName}`);
|
|
2375
|
+
info(` ECR repos: ${stack.dockerArtifacts.length}, S3 buckets: ${stack.bundleArtifacts.length}`);
|
|
2376
|
+
}
|
|
2377
|
+
newline();
|
|
2378
|
+
info("Phase 3: Stage Stacks");
|
|
2379
|
+
for (const stack of plan.stageStacks) {
|
|
2380
|
+
info(` ${stack.action}: ${stack.stackName}`);
|
|
2381
|
+
info(` Account: ${stack.accountId}, Region: ${stack.region}`);
|
|
2382
|
+
info(` ECR repos: ${stack.dockerArtifacts.length}, S3 buckets: ${stack.bundleArtifacts.length}`);
|
|
2383
|
+
}
|
|
2384
|
+
const totalStacks = 1 + plan.pipelineStacks.length + plan.stageStacks.length;
|
|
2385
|
+
newline();
|
|
2386
|
+
info(`Total stacks to deploy: ${totalStacks}`);
|
|
2387
|
+
}
|
|
2388
|
+
async function confirmDeploymentPlan(plan) {
|
|
2389
|
+
const totalStacks = 1 + plan.pipelineStacks.length + plan.stageStacks.length;
|
|
2390
|
+
newline();
|
|
2391
|
+
info(`About to deploy ${totalStacks} stack(s):`);
|
|
2392
|
+
info(` - 1 Org stack (${plan.orgStack.action})`);
|
|
2393
|
+
info(` - ${plan.pipelineStacks.length} Pipeline stack(s)`);
|
|
2394
|
+
info(` - ${plan.stageStacks.length} Stage stack(s)`);
|
|
2395
|
+
return confirmDeployment({
|
|
2396
|
+
orgSlug: plan.orgSlug,
|
|
2397
|
+
stacks: [
|
|
2398
|
+
{ ...plan.orgStack, pipelineSlug: "org", steps: [], additionalPoliciesCount: 0 },
|
|
2399
|
+
...plan.pipelineStacks.map((s) => ({ ...s, steps: [], additionalPoliciesCount: 0 })),
|
|
2400
|
+
...plan.stageStacks.map((s) => ({ ...s, steps: s.steps.map((st) => st.name), additionalPoliciesCount: s.additionalPolicies.length }))
|
|
2401
|
+
]
|
|
2402
|
+
});
|
|
2403
|
+
}
|
|
2404
|
+
async function executeDeployment(plan, pipelines, pipelineArtifacts, authData, currentAccountId, options) {
|
|
2405
|
+
const results = { success: 0, failed: 0 };
|
|
2406
|
+
newline();
|
|
2407
|
+
header("Phase 1: Org Stack");
|
|
2408
|
+
try {
|
|
2409
|
+
await deployOrgStack(plan, pipelines, authData, currentAccountId, options);
|
|
2410
|
+
results.success++;
|
|
2411
|
+
success("Org stack deployed successfully");
|
|
2412
|
+
} catch (error2) {
|
|
2413
|
+
results.failed++;
|
|
2414
|
+
error(`Org stack failed: ${error2 instanceof Error ? error2.message : String(error2)}`);
|
|
2415
|
+
throw error2;
|
|
2416
|
+
}
|
|
2417
|
+
newline();
|
|
2418
|
+
header("Phase 2: Pipeline Stacks");
|
|
2419
|
+
for (const stack of plan.pipelineStacks) {
|
|
2420
|
+
const spinner = ora(`Deploying ${stack.stackName}...`).start();
|
|
2421
|
+
try {
|
|
2422
|
+
await deployPipelineStack(stack, authData, currentAccountId, options);
|
|
2423
|
+
spinner.succeed(`${stack.stackName} deployed`);
|
|
2424
|
+
results.success++;
|
|
2425
|
+
} catch (error2) {
|
|
2426
|
+
spinner.fail(`${stack.stackName} failed: ${error2 instanceof Error ? error2.message : String(error2)}`);
|
|
2427
|
+
results.failed++;
|
|
2428
|
+
}
|
|
2429
|
+
}
|
|
2430
|
+
newline();
|
|
2431
|
+
header("Phase 3: Stage Stacks");
|
|
2432
|
+
for (const stack of plan.stageStacks) {
|
|
2433
|
+
const spinner = ora(`Deploying ${stack.stackName} to ${stack.accountId}/${stack.region}...`).start();
|
|
2434
|
+
try {
|
|
2435
|
+
await deployStageStack(stack, authData, currentAccountId, options);
|
|
2436
|
+
spinner.succeed(`${stack.stackName} deployed to ${stack.accountId}`);
|
|
2437
|
+
results.success++;
|
|
2438
|
+
} catch (error2) {
|
|
2439
|
+
spinner.fail(`${stack.stackName} failed: ${error2 instanceof Error ? error2.message : String(error2)}`);
|
|
2440
|
+
results.failed++;
|
|
2441
|
+
}
|
|
2442
|
+
}
|
|
2443
|
+
newline();
|
|
2444
|
+
header("Deployment Summary");
|
|
2445
|
+
if (results.failed === 0) {
|
|
2446
|
+
success(`All ${results.success} stack(s) deployed successfully!`);
|
|
2447
|
+
} else {
|
|
2448
|
+
warn(`${results.success} stack(s) succeeded, ${results.failed} stack(s) failed.`);
|
|
2449
|
+
}
|
|
2450
|
+
}
|
|
2451
|
+
async function deployOrgStack(plan, pipelines, authData, currentAccountId, options) {
|
|
2452
|
+
const { orgSlug, cicdAccountId, cicdRegion } = authData;
|
|
2453
|
+
const credentials = cicdAccountId !== currentAccountId ? (await assumeRoleForAccount({
|
|
2454
|
+
targetAccountId: cicdAccountId,
|
|
2455
|
+
currentAccountId,
|
|
2456
|
+
targetRoleName: options.targetAccountRoleName
|
|
2457
|
+
}))?.credentials : void 0;
|
|
2458
|
+
let targetAccountIds = plan.orgStack.targetAccountIds;
|
|
2459
|
+
if (plan.orgStack.action === "UPDATE") {
|
|
2460
|
+
verbose("Merging bucket policy with existing accounts...");
|
|
2461
|
+
const bucketName = generateTerraformStateBucketName(orgSlug);
|
|
2462
|
+
const strategy = getBucketPolicyStrategy();
|
|
2463
|
+
strategy.configure(bucketName, cicdRegion, credentials);
|
|
2464
|
+
const existingStack = await readExistingStack(
|
|
2465
|
+
plan.orgStack.stackName,
|
|
2466
|
+
cicdAccountId,
|
|
2467
|
+
cicdRegion,
|
|
2468
|
+
credentials
|
|
2469
|
+
);
|
|
2470
|
+
if (existingStack) {
|
|
2471
|
+
const existing = await strategy.extractExisting(existingStack);
|
|
2472
|
+
const newData = { allowedAccountIds: targetAccountIds };
|
|
2473
|
+
const merged = strategy.merge(existing, newData);
|
|
2474
|
+
targetAccountIds = merged.allowedAccountIds;
|
|
2475
|
+
verbose(`Merged ${targetAccountIds.length} account(s) into bucket policy`);
|
|
2476
|
+
}
|
|
2477
|
+
}
|
|
2478
|
+
const template = generateOrgStackTemplate({
|
|
2479
|
+
orgSlug,
|
|
2480
|
+
cicdAccountId,
|
|
2481
|
+
targetAccountIds
|
|
2482
|
+
});
|
|
2483
|
+
const deployOptions = {
|
|
2484
|
+
stackName: plan.orgStack.stackName,
|
|
2485
|
+
template,
|
|
2486
|
+
accountId: cicdAccountId,
|
|
2487
|
+
region: cicdRegion,
|
|
2488
|
+
credentials
|
|
2489
|
+
};
|
|
2490
|
+
await previewStackChanges(deployOptions);
|
|
2491
|
+
await deployStack(deployOptions);
|
|
2492
|
+
}
|
|
2493
|
+
async function deployPipelineStack(stack, authData, currentAccountId, options) {
|
|
2494
|
+
const { cicdAccountId, cicdRegion } = authData;
|
|
2495
|
+
const credentials = cicdAccountId !== currentAccountId ? (await assumeRoleForAccount({
|
|
2496
|
+
targetAccountId: cicdAccountId,
|
|
2497
|
+
currentAccountId,
|
|
2498
|
+
targetRoleName: options.targetAccountRoleName
|
|
2499
|
+
}))?.credentials : void 0;
|
|
2500
|
+
const template = generatePipelineStackTemplate({
|
|
2501
|
+
pipelineSlug: stack.pipelineSlug,
|
|
2502
|
+
cicdAccountId,
|
|
2503
|
+
dockerArtifacts: stack.dockerArtifacts,
|
|
2504
|
+
bundleArtifacts: stack.bundleArtifacts
|
|
2505
|
+
});
|
|
2506
|
+
const deployOptions = {
|
|
2507
|
+
stackName: stack.stackName,
|
|
2508
|
+
template,
|
|
2509
|
+
accountId: cicdAccountId,
|
|
2510
|
+
region: cicdRegion,
|
|
2511
|
+
credentials
|
|
2512
|
+
};
|
|
2513
|
+
await previewStackChanges(deployOptions);
|
|
2514
|
+
await deployStack(deployOptions);
|
|
2515
|
+
}
|
|
2516
|
+
async function deployStageStack(stack, authData, currentAccountId, options) {
|
|
2517
|
+
const credentials = stack.accountId !== currentAccountId ? (await assumeRoleForAccount({
|
|
2518
|
+
targetAccountId: stack.accountId,
|
|
2519
|
+
currentAccountId,
|
|
2520
|
+
targetRoleName: options.targetAccountRoleName
|
|
2521
|
+
}))?.credentials : void 0;
|
|
2522
|
+
const oidcInfo = await checkOidcProviderExists(credentials, stack.region);
|
|
2523
|
+
verbose(`OIDC provider in ${stack.accountId}: ${oidcInfo.exists ? "exists" : "will be created"}`);
|
|
2524
|
+
const template = generateStageStackTemplate({
|
|
2525
|
+
pipelineSlug: stack.pipelineSlug,
|
|
2526
|
+
stageName: stack.stageName,
|
|
2527
|
+
orgSlug: stack.orgSlug,
|
|
2528
|
+
accountId: stack.accountId,
|
|
2529
|
+
steps: stack.steps,
|
|
2530
|
+
additionalPolicies: stack.additionalPolicies,
|
|
2531
|
+
dockerArtifacts: stack.dockerArtifacts,
|
|
2532
|
+
bundleArtifacts: stack.bundleArtifacts
|
|
2533
|
+
});
|
|
2534
|
+
const deployOptions = {
|
|
2535
|
+
stackName: stack.stackName,
|
|
2536
|
+
template,
|
|
2537
|
+
accountId: stack.accountId,
|
|
2538
|
+
region: stack.region,
|
|
2539
|
+
credentials
|
|
2540
|
+
};
|
|
2541
|
+
await previewStackChanges(deployOptions);
|
|
2542
|
+
await deployStack(deployOptions);
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2545
|
+
// src/index.ts
|
|
2546
|
+
program.name("devramps").description("DevRamps CLI - Bootstrap AWS infrastructure for CI/CD pipelines").version("0.1.0");
|
|
2547
|
+
program.command("bootstrap").description("Bootstrap IAM roles in target AWS accounts based on pipeline definitions").option(
|
|
2548
|
+
"--target-account-role-name <name>",
|
|
2549
|
+
"Role to assume in target accounts (default: OrganizationAccountAccessRole, fallback: AWSControlTowerExecution)"
|
|
2550
|
+
).option(
|
|
2551
|
+
"--pipeline-slugs <slugs>",
|
|
2552
|
+
"Comma-separated list of pipeline slugs to bootstrap (default: all pipelines)"
|
|
2553
|
+
).option(
|
|
2554
|
+
"--dry-run",
|
|
2555
|
+
"Show what would be deployed without actually deploying"
|
|
2556
|
+
).option(
|
|
2557
|
+
"--verbose",
|
|
2558
|
+
"Enable verbose logging for debugging"
|
|
2559
|
+
).option(
|
|
2560
|
+
"--endpoint-override <url>",
|
|
2561
|
+
"Override the DevRamps API endpoint (for testing, e.g., http://localhost:3000)"
|
|
2562
|
+
).action(bootstrapCommand);
|
|
2563
|
+
program.parse();
|