create-aws-project 1.5.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -25
- package/dist/__tests__/aws/cdk-bootstrap.spec.d.ts +1 -0
- package/dist/__tests__/aws/cdk-bootstrap.spec.js +266 -0
- package/dist/__tests__/aws/root-credentials.spec.d.ts +1 -0
- package/dist/__tests__/aws/root-credentials.spec.js +230 -0
- package/dist/aws/cdk-bootstrap.d.ts +77 -0
- package/dist/aws/cdk-bootstrap.js +139 -0
- package/dist/aws/iam.js +17 -0
- package/dist/aws/organizations.d.ts +7 -1
- package/dist/aws/organizations.js +19 -1
- package/dist/aws/root-credentials.d.ts +68 -0
- package/dist/aws/root-credentials.js +157 -0
- package/dist/cli.js +30 -31
- package/dist/commands/initialize-github.js +162 -83
- package/dist/commands/setup-aws-envs.js +282 -50
- package/dist/git/setup.d.ts +28 -0
- package/dist/git/setup.js +169 -0
- package/dist/utils/project-context.d.ts +14 -0
- package/dist/wizard.d.ts +4 -1
- package/dist/wizard.js +6 -2
- package/package.json +1 -1
- package/templates/apps/web/src/App.tsx +4 -2
- package/templates/apps/web/src/__tests__/App.spec.tsx +27 -9
- package/templates/apps/web/vite.config.ts +3 -0
- package/templates/root/README.md +1 -1
package/README.md
CHANGED
|
@@ -69,6 +69,7 @@ The interactive wizard will ask you about:
|
|
|
69
69
|
- VS Code workspace configuration
|
|
70
70
|
6. **AWS region** - Where to deploy your infrastructure
|
|
71
71
|
7. **Brand color** - Theme color for your UI (blue, purple, teal, green, orange)
|
|
72
|
+
8. **GitHub repository** *(optional)* - Provide a repo URL to git init, commit, and push automatically. Press Enter to skip.
|
|
72
73
|
|
|
73
74
|
## Requirements
|
|
74
75
|
|
|
@@ -84,32 +85,9 @@ After creating your project, you'll set up AWS environments and GitHub deploymen
|
|
|
84
85
|
|
|
85
86
|
Before you begin:
|
|
86
87
|
- AWS CLI configured with credentials from your AWS management account
|
|
87
|
-
- GitHub repository created for your project
|
|
88
88
|
- GitHub Personal Access Token with "repo" scope ([create one here](https://github.com/settings/tokens/new))
|
|
89
89
|
|
|
90
|
-
### Step 1:
|
|
91
|
-
|
|
92
|
-
* Initialize the repository
|
|
93
|
-
```
|
|
94
|
-
git init
|
|
95
|
-
```
|
|
96
|
-
|
|
97
|
-
* Add the remote repository using the git remote add <name> <url> command. A common practice is to name it origin.
|
|
98
|
-
```bash
|
|
99
|
-
git remote add origin <REMOTE_URL>
|
|
100
|
-
```
|
|
101
|
-
|
|
102
|
-
* Verify the connection by listing your remotes. The -v flag shows the URLs.
|
|
103
|
-
```bash
|
|
104
|
-
git remote -v
|
|
105
|
-
```
|
|
106
|
-
|
|
107
|
-
* Push your local commits to the remote repository for the first time.
|
|
108
|
-
```bash
|
|
109
|
-
git push -u origin main
|
|
110
|
-
```
|
|
111
|
-
|
|
112
|
-
### Step 2: Set Up AWS Environments
|
|
90
|
+
### Step 1: Set Up AWS Environments
|
|
113
91
|
|
|
114
92
|
From your project directory, run:
|
|
115
93
|
|
|
@@ -136,7 +114,7 @@ AWS environment setup complete!
|
|
|
136
114
|
|
|
137
115
|
Account IDs are saved to `.aws-starter-config.json` for the next step.
|
|
138
116
|
|
|
139
|
-
### Step
|
|
117
|
+
### Step 2: Configure GitHub Environments
|
|
140
118
|
|
|
141
119
|
For each environment, run:
|
|
142
120
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
|
2
|
+
// Mock execa
|
|
3
|
+
const mockExeca = jest.fn();
|
|
4
|
+
jest.unstable_mockModule('execa', () => ({
|
|
5
|
+
execa: mockExeca,
|
|
6
|
+
}));
|
|
7
|
+
// Mock AWS SDK STS client
|
|
8
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
9
|
+
const mockSTSSend = jest.fn();
|
|
10
|
+
const mockAssumeRoleCommand = jest.fn();
|
|
11
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
12
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
13
|
+
jest.unstable_mockModule('@aws-sdk/client-sts', () => {
|
|
14
|
+
class MockSTSClient {
|
|
15
|
+
send = mockSTSSend;
|
|
16
|
+
}
|
|
17
|
+
return {
|
|
18
|
+
STSClient: MockSTSClient,
|
|
19
|
+
AssumeRoleCommand: mockAssumeRoleCommand,
|
|
20
|
+
};
|
|
21
|
+
});
|
|
22
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
23
|
+
// Mock ora (for spinner type)
|
|
24
|
+
const mockOra = {
|
|
25
|
+
text: '',
|
|
26
|
+
succeed: jest.fn(),
|
|
27
|
+
fail: jest.fn(),
|
|
28
|
+
};
|
|
29
|
+
const { bootstrapCDKEnvironment, bootstrapAllEnvironments } = await import('../../aws/cdk-bootstrap.js');
|
|
30
|
+
describe('cdk-bootstrap', () => {
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
jest.clearAllMocks();
|
|
33
|
+
mockOra.text = '';
|
|
34
|
+
// Mock AssumeRoleCommand to return the input
|
|
35
|
+
mockAssumeRoleCommand.mockImplementation((input) => input);
|
|
36
|
+
});
|
|
37
|
+
describe('bootstrapCDKEnvironment', () => {
|
|
38
|
+
it('calls execa with correct npx cdk bootstrap command', async () => {
|
|
39
|
+
mockExeca.mockResolvedValue({ all: 'Bootstrap successful' });
|
|
40
|
+
await bootstrapCDKEnvironment({
|
|
41
|
+
accountId: '123456789012',
|
|
42
|
+
region: 'us-west-2',
|
|
43
|
+
credentials: {
|
|
44
|
+
accessKeyId: 'AKIATEST',
|
|
45
|
+
secretAccessKey: 'secret123',
|
|
46
|
+
sessionToken: 'token123',
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
expect(mockExeca).toHaveBeenCalledWith('npx', [
|
|
50
|
+
'cdk',
|
|
51
|
+
'bootstrap',
|
|
52
|
+
'aws://123456789012/us-west-2',
|
|
53
|
+
'--trust',
|
|
54
|
+
'123456789012',
|
|
55
|
+
'--cloudformation-execution-policies',
|
|
56
|
+
'arn:aws:iam::aws:policy/AdministratorAccess',
|
|
57
|
+
'--require-approval',
|
|
58
|
+
'never',
|
|
59
|
+
], expect.objectContaining({
|
|
60
|
+
all: true,
|
|
61
|
+
}));
|
|
62
|
+
});
|
|
63
|
+
it('passes credentials as environment variables', async () => {
|
|
64
|
+
mockExeca.mockResolvedValue({ all: 'Bootstrap successful' });
|
|
65
|
+
await bootstrapCDKEnvironment({
|
|
66
|
+
accountId: '123456789012',
|
|
67
|
+
region: 'us-west-2',
|
|
68
|
+
credentials: {
|
|
69
|
+
accessKeyId: 'AKIATEST',
|
|
70
|
+
secretAccessKey: 'secret123',
|
|
71
|
+
sessionToken: 'token123',
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
const callArgs = mockExeca.mock.calls[0];
|
|
75
|
+
const options = callArgs[2];
|
|
76
|
+
expect(options.env).toBeDefined();
|
|
77
|
+
expect(options.env?.AWS_ACCESS_KEY_ID).toBe('AKIATEST');
|
|
78
|
+
expect(options.env?.AWS_SECRET_ACCESS_KEY).toBe('secret123');
|
|
79
|
+
expect(options.env?.AWS_SESSION_TOKEN).toBe('token123');
|
|
80
|
+
expect(options.env?.AWS_REGION).toBe('us-west-2');
|
|
81
|
+
});
|
|
82
|
+
it('omits session token from env if not provided', async () => {
|
|
83
|
+
mockExeca.mockResolvedValue({ all: 'Bootstrap successful' });
|
|
84
|
+
await bootstrapCDKEnvironment({
|
|
85
|
+
accountId: '123456789012',
|
|
86
|
+
region: 'us-west-2',
|
|
87
|
+
credentials: {
|
|
88
|
+
accessKeyId: 'AKIATEST',
|
|
89
|
+
secretAccessKey: 'secret123',
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
const callArgs = mockExeca.mock.calls[0];
|
|
93
|
+
const options = callArgs[2];
|
|
94
|
+
expect(options.env).toBeDefined();
|
|
95
|
+
expect(options.env?.AWS_ACCESS_KEY_ID).toBe('AKIATEST');
|
|
96
|
+
expect(options.env?.AWS_SECRET_ACCESS_KEY).toBe('secret123');
|
|
97
|
+
expect(options.env?.AWS_SESSION_TOKEN).toBeUndefined();
|
|
98
|
+
expect(options.env?.AWS_REGION).toBe('us-west-2');
|
|
99
|
+
});
|
|
100
|
+
it('returns success result on successful execution', async () => {
|
|
101
|
+
mockExeca.mockResolvedValue({ all: 'Bootstrap successful' });
|
|
102
|
+
const result = await bootstrapCDKEnvironment({
|
|
103
|
+
accountId: '123456789012',
|
|
104
|
+
region: 'us-west-2',
|
|
105
|
+
credentials: {
|
|
106
|
+
accessKeyId: 'AKIATEST',
|
|
107
|
+
secretAccessKey: 'secret123',
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
expect(result.success).toBe(true);
|
|
111
|
+
expect(result.output).toBe('Bootstrap successful');
|
|
112
|
+
});
|
|
113
|
+
it('returns failure result with output on error', async () => {
|
|
114
|
+
const error = new Error('Bootstrap failed');
|
|
115
|
+
error.all = 'Error: Stack already exists';
|
|
116
|
+
mockExeca.mockRejectedValue(error);
|
|
117
|
+
const result = await bootstrapCDKEnvironment({
|
|
118
|
+
accountId: '123456789012',
|
|
119
|
+
region: 'us-west-2',
|
|
120
|
+
credentials: {
|
|
121
|
+
accessKeyId: 'AKIATEST',
|
|
122
|
+
secretAccessKey: 'secret123',
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
expect(result.success).toBe(false);
|
|
126
|
+
expect(result.output).toBe('Error: Stack already exists');
|
|
127
|
+
});
|
|
128
|
+
it('uses error message if all property not available', async () => {
|
|
129
|
+
const error = new Error('Bootstrap failed');
|
|
130
|
+
mockExeca.mockRejectedValue(error);
|
|
131
|
+
const result = await bootstrapCDKEnvironment({
|
|
132
|
+
accountId: '123456789012',
|
|
133
|
+
region: 'us-west-2',
|
|
134
|
+
credentials: {
|
|
135
|
+
accessKeyId: 'AKIATEST',
|
|
136
|
+
secretAccessKey: 'secret123',
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
expect(result.success).toBe(false);
|
|
140
|
+
expect(result.output).toBe('Bootstrap failed');
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
describe('bootstrapAllEnvironments', () => {
|
|
144
|
+
beforeEach(() => {
|
|
145
|
+
// Mock successful STS AssumeRole responses
|
|
146
|
+
mockSTSSend.mockResolvedValue({
|
|
147
|
+
Credentials: {
|
|
148
|
+
AccessKeyId: 'ASIATEMP123',
|
|
149
|
+
SecretAccessKey: 'tempsecret123',
|
|
150
|
+
SessionToken: 'temptoken123',
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
// Mock successful bootstrap
|
|
154
|
+
mockExeca.mockResolvedValue({ all: 'Bootstrap successful' });
|
|
155
|
+
});
|
|
156
|
+
it('calls bootstrapCDKEnvironment for each environment', async () => {
|
|
157
|
+
await bootstrapAllEnvironments({
|
|
158
|
+
accounts: {
|
|
159
|
+
dev: '111111111111',
|
|
160
|
+
stage: '222222222222',
|
|
161
|
+
prod: '333333333333',
|
|
162
|
+
},
|
|
163
|
+
region: 'us-east-1',
|
|
164
|
+
adminCredentials: {
|
|
165
|
+
accessKeyId: 'AKIAADMIN',
|
|
166
|
+
secretAccessKey: 'adminsecret',
|
|
167
|
+
},
|
|
168
|
+
spinner: mockOra, // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
169
|
+
});
|
|
170
|
+
// Should call execa 3 times (once per environment)
|
|
171
|
+
expect(mockExeca).toHaveBeenCalledTimes(3);
|
|
172
|
+
// Verify each environment was bootstrapped
|
|
173
|
+
expect(mockExeca).toHaveBeenCalledWith('npx', expect.arrayContaining(['aws://111111111111/us-east-1']), expect.any(Object));
|
|
174
|
+
expect(mockExeca).toHaveBeenCalledWith('npx', expect.arrayContaining(['aws://222222222222/us-east-1']), expect.any(Object));
|
|
175
|
+
expect(mockExeca).toHaveBeenCalledWith('npx', expect.arrayContaining(['aws://333333333333/us-east-1']), expect.any(Object));
|
|
176
|
+
});
|
|
177
|
+
it('assumes OrganizationAccountAccessRole for each account', async () => {
|
|
178
|
+
await bootstrapAllEnvironments({
|
|
179
|
+
accounts: {
|
|
180
|
+
dev: '111111111111',
|
|
181
|
+
stage: '222222222222',
|
|
182
|
+
prod: '333333333333',
|
|
183
|
+
},
|
|
184
|
+
region: 'us-east-1',
|
|
185
|
+
adminCredentials: {
|
|
186
|
+
accessKeyId: 'AKIAADMIN',
|
|
187
|
+
secretAccessKey: 'adminsecret',
|
|
188
|
+
},
|
|
189
|
+
spinner: mockOra, // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
190
|
+
});
|
|
191
|
+
// Should call AssumeRoleCommand 3 times (once per environment)
|
|
192
|
+
expect(mockAssumeRoleCommand).toHaveBeenCalledTimes(3);
|
|
193
|
+
// Verify role ARNs for each environment
|
|
194
|
+
const calls = mockAssumeRoleCommand.mock.calls;
|
|
195
|
+
expect(calls[0][0].RoleArn).toBe('arn:aws:iam::111111111111:role/OrganizationAccountAccessRole');
|
|
196
|
+
expect(calls[1][0].RoleArn).toBe('arn:aws:iam::222222222222:role/OrganizationAccountAccessRole');
|
|
197
|
+
expect(calls[2][0].RoleArn).toBe('arn:aws:iam::333333333333:role/OrganizationAccountAccessRole');
|
|
198
|
+
});
|
|
199
|
+
it('updates spinner text for each environment', async () => {
|
|
200
|
+
await bootstrapAllEnvironments({
|
|
201
|
+
accounts: {
|
|
202
|
+
dev: '111111111111',
|
|
203
|
+
stage: '222222222222',
|
|
204
|
+
prod: '333333333333',
|
|
205
|
+
},
|
|
206
|
+
region: 'us-east-1',
|
|
207
|
+
adminCredentials: {
|
|
208
|
+
accessKeyId: 'AKIAADMIN',
|
|
209
|
+
secretAccessKey: 'adminsecret',
|
|
210
|
+
},
|
|
211
|
+
spinner: mockOra, // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
212
|
+
});
|
|
213
|
+
expect(mockOra.succeed).toHaveBeenCalledTimes(3);
|
|
214
|
+
expect(mockOra.succeed).toHaveBeenCalledWith('CDK bootstrapped in dev account (111111111111)');
|
|
215
|
+
expect(mockOra.succeed).toHaveBeenCalledWith('CDK bootstrapped in stage account (222222222222)');
|
|
216
|
+
expect(mockOra.succeed).toHaveBeenCalledWith('CDK bootstrapped in prod account (333333333333)');
|
|
217
|
+
});
|
|
218
|
+
it('throws error and fails spinner if bootstrap fails', async () => {
|
|
219
|
+
mockExeca.mockResolvedValueOnce({ all: 'Success' }); // dev succeeds
|
|
220
|
+
mockExeca.mockRejectedValueOnce({
|
|
221
|
+
all: 'Error: CDK bootstrap failed',
|
|
222
|
+
message: 'Error: CDK bootstrap failed',
|
|
223
|
+
}); // stage fails
|
|
224
|
+
await expect(bootstrapAllEnvironments({
|
|
225
|
+
accounts: {
|
|
226
|
+
dev: '111111111111',
|
|
227
|
+
stage: '222222222222',
|
|
228
|
+
prod: '333333333333',
|
|
229
|
+
},
|
|
230
|
+
region: 'us-east-1',
|
|
231
|
+
adminCredentials: null,
|
|
232
|
+
spinner: mockOra, // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
233
|
+
})).rejects.toThrow('CDK bootstrap failed in stage account');
|
|
234
|
+
expect(mockOra.fail).toHaveBeenCalledWith('CDK bootstrap failed in stage account');
|
|
235
|
+
});
|
|
236
|
+
it('works with null adminCredentials (uses default credentials)', async () => {
|
|
237
|
+
await bootstrapAllEnvironments({
|
|
238
|
+
accounts: {
|
|
239
|
+
dev: '111111111111',
|
|
240
|
+
},
|
|
241
|
+
region: 'us-east-1',
|
|
242
|
+
adminCredentials: null,
|
|
243
|
+
spinner: mockOra, // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
244
|
+
});
|
|
245
|
+
expect(mockSTSSend).toHaveBeenCalled();
|
|
246
|
+
expect(mockExeca).toHaveBeenCalled();
|
|
247
|
+
});
|
|
248
|
+
it('skips environments with missing account IDs', async () => {
|
|
249
|
+
await bootstrapAllEnvironments({
|
|
250
|
+
accounts: {
|
|
251
|
+
dev: '111111111111',
|
|
252
|
+
// stage missing
|
|
253
|
+
prod: '333333333333',
|
|
254
|
+
},
|
|
255
|
+
region: 'us-east-1',
|
|
256
|
+
adminCredentials: {
|
|
257
|
+
accessKeyId: 'AKIAADMIN',
|
|
258
|
+
secretAccessKey: 'adminsecret',
|
|
259
|
+
},
|
|
260
|
+
spinner: mockOra, // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
261
|
+
});
|
|
262
|
+
// Should only call execa twice (dev and prod, skip stage)
|
|
263
|
+
expect(mockExeca).toHaveBeenCalledTimes(2);
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals';
|
|
2
|
+
// Mock getAccessKeyCount from iam.ts
|
|
3
|
+
const mockGetAccessKeyCount = jest.fn();
|
|
4
|
+
jest.unstable_mockModule('../../aws/iam.js', () => ({
|
|
5
|
+
getAccessKeyCount: mockGetAccessKeyCount,
|
|
6
|
+
}));
|
|
7
|
+
// Mock AWS SDK clients
|
|
8
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
9
|
+
const mockSTSSend = jest.fn();
|
|
10
|
+
const mockIAMSend = jest.fn();
|
|
11
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
12
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
13
|
+
jest.unstable_mockModule('@aws-sdk/client-sts', () => {
|
|
14
|
+
class MockSTSClient {
|
|
15
|
+
send = mockSTSSend;
|
|
16
|
+
}
|
|
17
|
+
return {
|
|
18
|
+
STSClient: MockSTSClient,
|
|
19
|
+
GetCallerIdentityCommand: jest.fn((input) => input),
|
|
20
|
+
};
|
|
21
|
+
});
|
|
22
|
+
jest.unstable_mockModule('@aws-sdk/client-iam', () => {
|
|
23
|
+
class MockIAMClient {
|
|
24
|
+
send = mockIAMSend;
|
|
25
|
+
}
|
|
26
|
+
class NoSuchEntityException extends Error {
|
|
27
|
+
constructor(message) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = 'NoSuchEntityException';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
IAMClient: MockIAMClient,
|
|
34
|
+
CreateUserCommand: jest.fn((input) => input),
|
|
35
|
+
GetUserCommand: jest.fn((input) => input),
|
|
36
|
+
AttachUserPolicyCommand: jest.fn((input) => input),
|
|
37
|
+
CreateAccessKeyCommand: jest.fn((input) => input),
|
|
38
|
+
ListUserTagsCommand: jest.fn((input) => input),
|
|
39
|
+
NoSuchEntityException,
|
|
40
|
+
};
|
|
41
|
+
});
|
|
42
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
43
|
+
const { isRootUser, detectRootCredentials, retryWithBackoff, createOrAdoptAdminUser, } = await import('../../aws/root-credentials.js');
|
|
44
|
+
describe('root-credentials', () => {
|
|
45
|
+
describe('isRootUser', () => {
|
|
46
|
+
it('returns true for root ARN', () => {
|
|
47
|
+
expect(isRootUser('arn:aws:iam::123456789012:root')).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
it('returns false for IAM user ARN', () => {
|
|
50
|
+
expect(isRootUser('arn:aws:iam::123456789012:user/admin')).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
it('returns false for different IAM user', () => {
|
|
53
|
+
expect(isRootUser('arn:aws:iam::123456789012:user/deploy')).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
it('returns false for assumed role ARN', () => {
|
|
56
|
+
expect(isRootUser('arn:aws:sts::123456789012:assumed-role/role-name/session')).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
it('returns false for empty string', () => {
|
|
59
|
+
expect(isRootUser('')).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
describe('detectRootCredentials', () => {
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
jest.clearAllMocks();
|
|
65
|
+
});
|
|
66
|
+
it('detects root credentials correctly', async () => {
|
|
67
|
+
mockSTSSend.mockResolvedValueOnce({
|
|
68
|
+
Arn: 'arn:aws:iam::123456789012:root',
|
|
69
|
+
Account: '123456789012',
|
|
70
|
+
UserId: '123456789012',
|
|
71
|
+
});
|
|
72
|
+
const result = await detectRootCredentials('us-east-1');
|
|
73
|
+
expect(result.arn).toBe('arn:aws:iam::123456789012:root');
|
|
74
|
+
expect(result.accountId).toBe('123456789012');
|
|
75
|
+
expect(result.userId).toBe('123456789012');
|
|
76
|
+
expect(result.isRoot).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
it('detects IAM user credentials correctly', async () => {
|
|
79
|
+
mockSTSSend.mockResolvedValueOnce({
|
|
80
|
+
Arn: 'arn:aws:iam::123456789012:user/admin',
|
|
81
|
+
Account: '123456789012',
|
|
82
|
+
UserId: 'AIDACKCEVSQ6C2EXAMPLE',
|
|
83
|
+
});
|
|
84
|
+
const result = await detectRootCredentials('us-east-1');
|
|
85
|
+
expect(result.isRoot).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
it('propagates errors from STS', async () => {
|
|
88
|
+
mockSTSSend.mockRejectedValueOnce(new Error('Access denied'));
|
|
89
|
+
await expect(detectRootCredentials('us-east-1')).rejects.toThrow('Access denied');
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
describe('retryWithBackoff', () => {
|
|
93
|
+
beforeEach(() => {
|
|
94
|
+
jest.useFakeTimers();
|
|
95
|
+
});
|
|
96
|
+
afterEach(() => {
|
|
97
|
+
jest.useRealTimers();
|
|
98
|
+
});
|
|
99
|
+
it('returns result on first success', async () => {
|
|
100
|
+
const fn = jest.fn().mockResolvedValue('success');
|
|
101
|
+
const promise = retryWithBackoff(fn, { baseDelayMs: 10 });
|
|
102
|
+
await jest.runAllTimersAsync();
|
|
103
|
+
const result = await promise;
|
|
104
|
+
expect(result).toBe('success');
|
|
105
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
106
|
+
});
|
|
107
|
+
it('retries on failure and eventually succeeds', async () => {
|
|
108
|
+
const fn = jest.fn()
|
|
109
|
+
.mockRejectedValueOnce(new Error('Fail 1'))
|
|
110
|
+
.mockRejectedValueOnce(new Error('Fail 2'))
|
|
111
|
+
.mockResolvedValue('success');
|
|
112
|
+
const promise = retryWithBackoff(fn, { baseDelayMs: 10, maxRetries: 5 });
|
|
113
|
+
await jest.runAllTimersAsync();
|
|
114
|
+
const result = await promise;
|
|
115
|
+
expect(result).toBe('success');
|
|
116
|
+
expect(fn).toHaveBeenCalledTimes(3);
|
|
117
|
+
});
|
|
118
|
+
it('throws last error after all retries fail', async () => {
|
|
119
|
+
const fn = jest.fn();
|
|
120
|
+
fn.mockRejectedValue(new Error('Permanent failure'));
|
|
121
|
+
const promise = retryWithBackoff(fn, { baseDelayMs: 10, maxRetries: 2 });
|
|
122
|
+
jest.runAllTimersAsync();
|
|
123
|
+
try {
|
|
124
|
+
await promise;
|
|
125
|
+
throw new Error('Should have thrown');
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
expect(error).toBeInstanceOf(Error);
|
|
129
|
+
expect(error.message).toBe('Permanent failure');
|
|
130
|
+
}
|
|
131
|
+
expect(fn).toHaveBeenCalledTimes(3); // Initial + 2 retries
|
|
132
|
+
});
|
|
133
|
+
it('respects maxRetries option', async () => {
|
|
134
|
+
const fn = jest.fn();
|
|
135
|
+
fn.mockRejectedValue(new Error('Always fails'));
|
|
136
|
+
const promise = retryWithBackoff(fn, { baseDelayMs: 10, maxRetries: 3 });
|
|
137
|
+
jest.runAllTimersAsync();
|
|
138
|
+
try {
|
|
139
|
+
await promise;
|
|
140
|
+
throw new Error('Should have thrown');
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
expect(error).toBeInstanceOf(Error);
|
|
144
|
+
expect(error.message).toBe('Always fails');
|
|
145
|
+
}
|
|
146
|
+
expect(fn).toHaveBeenCalledTimes(4); // Initial + 3 retries
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
describe('createOrAdoptAdminUser', () => {
|
|
150
|
+
beforeEach(() => {
|
|
151
|
+
jest.clearAllMocks();
|
|
152
|
+
mockGetAccessKeyCount.mockReset();
|
|
153
|
+
});
|
|
154
|
+
it('creates new admin user when user does not exist', async () => {
|
|
155
|
+
// Import the mocked IAMClient and exception class
|
|
156
|
+
const { IAMClient, NoSuchEntityException } = await import('@aws-sdk/client-iam');
|
|
157
|
+
const mockIAMClient = new IAMClient({ region: 'us-east-1' });
|
|
158
|
+
// Use the mocked NoSuchEntityException class
|
|
159
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
160
|
+
const noSuchEntityError = new NoSuchEntityException('User does not exist');
|
|
161
|
+
mockIAMSend
|
|
162
|
+
.mockRejectedValueOnce(noSuchEntityError) // GetUserCommand throws
|
|
163
|
+
.mockResolvedValueOnce({}) // CreateUserCommand succeeds
|
|
164
|
+
.mockResolvedValueOnce({}) // AttachUserPolicyCommand succeeds
|
|
165
|
+
.mockResolvedValueOnce({
|
|
166
|
+
// CreateAccessKeyCommand succeeds
|
|
167
|
+
AccessKey: {
|
|
168
|
+
AccessKeyId: 'AKIAIOSFODNN7EXAMPLE',
|
|
169
|
+
SecretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
mockGetAccessKeyCount.mockResolvedValue(0);
|
|
173
|
+
const result = await createOrAdoptAdminUser(mockIAMClient, 'test-project');
|
|
174
|
+
expect(result.userName).toBe('test-project-admin');
|
|
175
|
+
expect(result.accessKeyId).toBe('AKIAIOSFODNN7EXAMPLE');
|
|
176
|
+
expect(result.secretAccessKey).toBe('wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY');
|
|
177
|
+
expect(result.adopted).toBe(false);
|
|
178
|
+
// Verify commands were called in order
|
|
179
|
+
expect(mockIAMSend).toHaveBeenCalledTimes(4);
|
|
180
|
+
});
|
|
181
|
+
it('adopts existing managed user with no keys', async () => {
|
|
182
|
+
const { IAMClient } = await import('@aws-sdk/client-iam');
|
|
183
|
+
const mockIAMClient = new IAMClient({ region: 'us-east-1' });
|
|
184
|
+
mockIAMSend
|
|
185
|
+
.mockResolvedValueOnce({}) // GetUserCommand succeeds
|
|
186
|
+
.mockResolvedValueOnce({
|
|
187
|
+
// ListUserTagsCommand returns ManagedBy tag
|
|
188
|
+
Tags: [
|
|
189
|
+
{ Key: 'ManagedBy', Value: 'create-aws-starter-kit' },
|
|
190
|
+
{ Key: 'Purpose', Value: 'CLI Admin' },
|
|
191
|
+
],
|
|
192
|
+
})
|
|
193
|
+
.mockResolvedValueOnce({
|
|
194
|
+
// CreateAccessKeyCommand succeeds
|
|
195
|
+
AccessKey: {
|
|
196
|
+
AccessKeyId: 'AKIAIOSFODNN7EXAMPLE',
|
|
197
|
+
SecretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
mockGetAccessKeyCount.mockResolvedValue(0);
|
|
201
|
+
const result = await createOrAdoptAdminUser(mockIAMClient, 'test-project');
|
|
202
|
+
expect(result.userName).toBe('test-project-admin');
|
|
203
|
+
expect(result.adopted).toBe(true);
|
|
204
|
+
expect(mockIAMSend).toHaveBeenCalledTimes(3);
|
|
205
|
+
});
|
|
206
|
+
it('throws error when existing user is not managed by us', async () => {
|
|
207
|
+
const { IAMClient } = await import('@aws-sdk/client-iam');
|
|
208
|
+
const mockIAMClient = new IAMClient({ region: 'us-east-1' });
|
|
209
|
+
mockIAMSend
|
|
210
|
+
.mockResolvedValueOnce({}) // GetUserCommand succeeds
|
|
211
|
+
.mockResolvedValueOnce({
|
|
212
|
+
// ListUserTagsCommand returns different tag
|
|
213
|
+
Tags: [{ Key: 'Owner', Value: 'SomeoneElse' }],
|
|
214
|
+
});
|
|
215
|
+
await expect(createOrAdoptAdminUser(mockIAMClient, 'test-project')).rejects.toThrow('IAM user "test-project-admin" exists but was not created by this tool');
|
|
216
|
+
});
|
|
217
|
+
it('throws error when existing managed user has keys', async () => {
|
|
218
|
+
const { IAMClient } = await import('@aws-sdk/client-iam');
|
|
219
|
+
const mockIAMClient = new IAMClient({ region: 'us-east-1' });
|
|
220
|
+
mockIAMSend
|
|
221
|
+
.mockResolvedValueOnce({}) // GetUserCommand succeeds
|
|
222
|
+
.mockResolvedValueOnce({
|
|
223
|
+
// ListUserTagsCommand returns ManagedBy tag
|
|
224
|
+
Tags: [{ Key: 'ManagedBy', Value: 'create-aws-starter-kit' }],
|
|
225
|
+
});
|
|
226
|
+
mockGetAccessKeyCount.mockResolvedValue(1);
|
|
227
|
+
await expect(createOrAdoptAdminUser(mockIAMClient, 'test-project')).rejects.toThrow('IAM user "test-project-admin" already exists with 1 access key');
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { Ora } from 'ora';
|
|
2
|
+
/**
|
|
3
|
+
* Credentials for AWS API operations
|
|
4
|
+
*/
|
|
5
|
+
export interface AWSCredentials {
|
|
6
|
+
accessKeyId: string;
|
|
7
|
+
secretAccessKey: string;
|
|
8
|
+
sessionToken?: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Options for bootstrapping a single CDK environment
|
|
12
|
+
*/
|
|
13
|
+
export interface BootstrapCDKEnvironmentOptions {
|
|
14
|
+
accountId: string;
|
|
15
|
+
region: string;
|
|
16
|
+
credentials: AWSCredentials;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Result of a CDK bootstrap operation
|
|
20
|
+
*/
|
|
21
|
+
export interface BootstrapResult {
|
|
22
|
+
success: boolean;
|
|
23
|
+
output: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Options for bootstrapping all environments
|
|
27
|
+
*/
|
|
28
|
+
export interface BootstrapAllEnvironmentsOptions {
|
|
29
|
+
accounts: Record<string, string>;
|
|
30
|
+
region: string;
|
|
31
|
+
adminCredentials: AWSCredentials | null;
|
|
32
|
+
spinner: Ora;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Bootstraps AWS CDK in a single environment account
|
|
36
|
+
*
|
|
37
|
+
* Runs `cdk bootstrap` via npx to prepare the account for CDK deployments.
|
|
38
|
+
* Bootstrap creates the necessary CloudFormation stack with S3 bucket and ECR
|
|
39
|
+
* repository for CDK deployment assets.
|
|
40
|
+
*
|
|
41
|
+
* @param options - Bootstrap options including account ID, region, and credentials
|
|
42
|
+
* @returns Promise resolving to bootstrap result with success status and output
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* ```typescript
|
|
46
|
+
* const result = await bootstrapCDKEnvironment({
|
|
47
|
+
* accountId: '123456789012',
|
|
48
|
+
* region: 'us-west-2',
|
|
49
|
+
* credentials: { accessKeyId: 'AKIA...', secretAccessKey: 'secret...', sessionToken: 'token...' }
|
|
50
|
+
* });
|
|
51
|
+
* if (!result.success) {
|
|
52
|
+
* console.error('Bootstrap failed:', result.output);
|
|
53
|
+
* }
|
|
54
|
+
* ```
|
|
55
|
+
*/
|
|
56
|
+
export declare function bootstrapCDKEnvironment(options: BootstrapCDKEnvironmentOptions): Promise<BootstrapResult>;
|
|
57
|
+
/**
|
|
58
|
+
* Bootstraps AWS CDK in all environment accounts (dev, stage, prod)
|
|
59
|
+
*
|
|
60
|
+
* Iterates through all environments, assumes the OrganizationAccountAccessRole
|
|
61
|
+
* in each account, and runs CDK bootstrap. This prepares all environments for
|
|
62
|
+
* CDK deployments with proper trust relationships and execution policies.
|
|
63
|
+
*
|
|
64
|
+
* @param options - Bootstrap options including account map, region, admin credentials, and spinner
|
|
65
|
+
* @throws Error if bootstrap fails in any environment
|
|
66
|
+
*
|
|
67
|
+
* @example
|
|
68
|
+
* ```typescript
|
|
69
|
+
* await bootstrapAllEnvironments({
|
|
70
|
+
* accounts: { dev: '111111111111', stage: '222222222222', prod: '333333333333' },
|
|
71
|
+
* region: 'us-west-2',
|
|
72
|
+
* adminCredentials: { accessKeyId: 'AKIA...', secretAccessKey: 'secret...' },
|
|
73
|
+
* spinner: ora()
|
|
74
|
+
* });
|
|
75
|
+
* ```
|
|
76
|
+
*/
|
|
77
|
+
export declare function bootstrapAllEnvironments(options: BootstrapAllEnvironmentsOptions): Promise<void>;
|