forked from aws/agentcore-cli
-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathphase2-import.ts
More file actions
308 lines (265 loc) · 10.1 KB
/
Copy pathphase2-import.ts
File metadata and controls
308 lines (265 loc) · 10.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
import { getCredentialProvider } from '../../aws/account';
import type { CfnTemplate } from './template-utils';
import { buildImportTemplate } from './template-utils';
import type { ResourceToImport } from './types';
import {
type ResourceToImport as CfnResourceToImport,
CloudFormationClient,
CreateChangeSetCommand,
DescribeChangeSetCommand,
DescribeStacksCommand,
ExecuteChangeSetCommand,
} from '@aws-sdk/client-cloudformation';
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { AssumeRoleCommand, STSClient } from '@aws-sdk/client-sts';
import { execSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
export interface Phase2Options {
region: string;
stackName: string;
deployedTemplate: CfnTemplate;
synthTemplate: CfnTemplate;
resourcesToImport: ResourceToImport[];
assemblyDirectory: string;
onProgress?: (message: string) => void;
}
export interface Phase2Result {
success: boolean;
error?: string;
}
/**
* Phase 2: IMPORT
*
* Uses CloudFormation's IMPORT change set mechanism to bring pre-existing
* resources under stack management.
*
* Three strict restrictions:
* 1. Cannot create new resources outside ResourcesToImport
* 2. Cannot update existing resources in the stack
* 3. Cannot add or modify Outputs
*/
export async function executePhase2(options: Phase2Options): Promise<Phase2Result> {
const { region, stackName, deployedTemplate, synthTemplate, resourcesToImport, assemblyDirectory, onProgress } =
options;
if (resourcesToImport.length === 0) {
onProgress?.('No resources to import');
return { success: true };
}
const credentials = getCredentialProvider();
const cfn = new CloudFormationClient({ region, credentials });
// Publish CDK assets to S3 before creating the import change set
onProgress?.('Publishing CDK assets to S3...');
await publishCdkAssets(assemblyDirectory, region, onProgress);
// Build import template: deployed template + primary resources with DeletionPolicy: Retain
const logicalIds = resourcesToImport.map(r => r.logicalResourceId);
const importTemplate = buildImportTemplate(deployedTemplate, synthTemplate, logicalIds);
const templateBody = JSON.stringify(importTemplate);
// Map to CloudFormation's ResourcesToImport format
const cfnResourcesToImport: CfnResourceToImport[] = resourcesToImport.map(r => ({
ResourceType: r.resourceType,
LogicalResourceId: r.logicalResourceId,
ResourceIdentifier: r.resourceIdentifier,
}));
const changeSetName = `import-${Date.now()}`;
onProgress?.(`Creating IMPORT change set: ${changeSetName}`);
try {
// Create the import change set
await cfn.send(
new CreateChangeSetCommand({
StackName: stackName,
ChangeSetName: changeSetName,
ChangeSetType: 'IMPORT',
TemplateBody: templateBody,
ResourcesToImport: cfnResourcesToImport,
Capabilities: ['CAPABILITY_IAM', 'CAPABILITY_NAMED_IAM'],
})
);
// Wait for the change set to be created
onProgress?.('Waiting for change set to be created...');
await waitForChangeSetReady(cfn, stackName, changeSetName);
// Describe the change set to see what it will do
const changeSetDescription = await cfn.send(
new DescribeChangeSetCommand({
StackName: stackName,
ChangeSetName: changeSetName,
})
);
onProgress?.(`Change set has ${changeSetDescription.Changes?.length ?? 0} changes. Executing...`);
// Execute the change set
await cfn.send(
new ExecuteChangeSetCommand({
StackName: stackName,
ChangeSetName: changeSetName,
})
);
// Wait for import to complete
onProgress?.('Waiting for IMPORT to complete...');
await waitForStackImportComplete(cfn, stackName);
onProgress?.('Phase 2 IMPORT complete');
return { success: true };
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
// Detect "already exists in stack" errors and provide a friendlier message
const alreadyInStackMatch = /(.+) already exists in stack (.+)/.exec(message);
if (alreadyInStackMatch) {
const resourceId = alreadyInStackMatch[1];
const existingStack = alreadyInStackMatch[2];
return {
success: false,
error: `Resource "${resourceId}" is already managed by CloudFormation stack "${existingStack}". It must be removed from that stack before importing into this project.`,
};
}
return { success: false, error: `Import change set failed: ${message}` };
}
}
/**
* Wait for a change set to be in CREATE_COMPLETE status.
*/
async function waitForChangeSetReady(
cfn: CloudFormationClient,
stackName: string,
changeSetName: string
): Promise<void> {
const maxAttempts = 60;
const delay = 5000; // 5 seconds
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const response = await cfn.send(
new DescribeChangeSetCommand({
StackName: stackName,
ChangeSetName: changeSetName,
})
);
const status = response.Status;
if (status === 'CREATE_COMPLETE') {
return;
}
if (status === 'FAILED') {
throw new Error(`Change set creation failed: ${response.StatusReason ?? 'Unknown reason'}`);
}
// CREATE_PENDING, CREATE_IN_PROGRESS — keep waiting
await new Promise(resolve => setTimeout(resolve, delay));
}
throw new Error('Timed out waiting for change set creation');
}
/**
* Wait for stack to reach IMPORT_COMPLETE status.
*/
async function waitForStackImportComplete(cfn: CloudFormationClient, stackName: string): Promise<void> {
const maxAttempts = 120;
const delay = 5000; // 5 seconds
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const response = await cfn.send(new DescribeStacksCommand({ StackName: stackName }));
const stack = response.Stacks?.[0];
if (!stack) {
throw new Error(`Stack ${stackName} not found during import wait`);
}
const status = stack.StackStatus ?? '';
if (status === 'IMPORT_COMPLETE') {
return;
}
if (status.includes('FAILED') || status.includes('ROLLBACK')) {
throw new Error(`Import failed with status: ${status}. Reason: ${stack.StackStatusReason ?? 'Unknown'}`);
}
// IMPORT_IN_PROGRESS — keep waiting
await new Promise(resolve => setTimeout(resolve, delay));
}
throw new Error('Timed out waiting for import to complete');
}
/**
* Publish CDK file assets (code zips, templates) to the bootstrap S3 bucket.
* Reads the assets manifest from the CDK assembly directory.
*/
export async function publishCdkAssets(
assemblyDirectory: string,
region: string,
onProgress?: (message: string) => void
): Promise<void> {
// Find the assets manifest
const manifestFiles = fs.readdirSync(assemblyDirectory).filter(f => f.endsWith('.assets.json'));
if (manifestFiles.length === 0) {
onProgress?.('No assets manifest found, skipping asset publishing');
return;
}
for (const manifestFile of manifestFiles) {
const manifest = JSON.parse(fs.readFileSync(path.join(assemblyDirectory, manifestFile), 'utf-8')) as {
files?: Record<
string,
{
source: { path: string; packaging: string };
destinations: Record<
string,
{
bucketName: string;
objectKey: string;
region: string;
assumeRoleArn?: string;
}
>;
}
>;
};
if (!manifest.files) continue;
for (const [_assetHash, asset] of Object.entries(manifest.files)) {
const sourcePath = path.join(assemblyDirectory, asset.source.path);
if (!fs.existsSync(sourcePath)) {
onProgress?.(`Asset file not found: ${asset.source.path}, skipping`);
continue;
}
// Determine the file body to upload
let body: Buffer;
const stat = fs.statSync(sourcePath);
if (stat.isDirectory()) {
if (asset.source.packaging === 'zip') {
// Zip the directory contents
const zipPath = `${sourcePath}.zip`;
execSync(`cd "${sourcePath}" && zip -rq "${zipPath}" .`);
body = fs.readFileSync(zipPath);
fs.unlinkSync(zipPath);
} else {
// Skip directory assets that aren't zip packaging (e.g. Docker image contexts)
onProgress?.(`Skipping directory asset: ${asset.source.path} (packaging: ${asset.source.packaging})`);
continue;
}
} else {
body = fs.readFileSync(sourcePath);
}
for (const dest of Object.values(asset.destinations)) {
const destRegion = dest.region || region;
// Get credentials — try assuming the publishing role if specified
let s3Credentials = getCredentialProvider();
if (dest.assumeRoleArn && !dest.assumeRoleArn.includes('${')) {
try {
const sts = new STSClient({ region: destRegion, credentials: getCredentialProvider() });
const assumed = await sts.send(
new AssumeRoleCommand({
RoleArn: dest.assumeRoleArn,
RoleSessionName: 'agentcore-import-publish',
})
);
if (assumed.Credentials) {
/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any */
s3Credentials = {
accessKeyId: assumed.Credentials.AccessKeyId!,
secretAccessKey: assumed.Credentials.SecretAccessKey!,
sessionToken: assumed.Credentials.SessionToken,
} as any;
/* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any */
}
} catch {
// Fall back to default credentials if role assumption fails
}
}
const s3 = new S3Client({ region: destRegion, credentials: s3Credentials });
onProgress?.(`Uploading ${asset.source.path} → s3://${dest.bucketName}/${dest.objectKey}`);
await s3.send(
new PutObjectCommand({
Bucket: dest.bucketName,
Key: dest.objectKey,
Body: body,
})
);
}
}
}
}