@friggframework/devtools 2.0.0--canary.474.082077e.0 → 2.0.0--canary.474.4793186.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.
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AWSResourceImporter - AWS CloudFormation Resource Import Adapter
|
|
3
|
+
*
|
|
4
|
+
* Infrastructure Adapter - Hexagonal Architecture
|
|
5
|
+
*
|
|
6
|
+
* Implements IResourceImporter port for AWS CloudFormation.
|
|
7
|
+
* Handles resource import operations using CloudFormation change sets.
|
|
8
|
+
*
|
|
9
|
+
* Lazy-loads AWS SDK to minimize cold start time and memory usage.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const IResourceImporter = require('../../application/ports/IResourceImporter');
|
|
13
|
+
|
|
14
|
+
// Lazy-loaded AWS SDK CloudFormation client
|
|
15
|
+
let CloudFormationClient,
|
|
16
|
+
CreateChangeSetCommand,
|
|
17
|
+
DescribeChangeSetCommand,
|
|
18
|
+
ExecuteChangeSetCommand,
|
|
19
|
+
GetTemplateCommand;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Lazy load CloudFormation SDK
|
|
23
|
+
*/
|
|
24
|
+
function loadCloudFormation() {
|
|
25
|
+
if (!CloudFormationClient) {
|
|
26
|
+
const cfModule = require('@aws-sdk/client-cloudformation');
|
|
27
|
+
CloudFormationClient = cfModule.CloudFormationClient;
|
|
28
|
+
CreateChangeSetCommand = cfModule.CreateChangeSetCommand;
|
|
29
|
+
DescribeChangeSetCommand = cfModule.DescribeChangeSetCommand;
|
|
30
|
+
ExecuteChangeSetCommand = cfModule.ExecuteChangeSetCommand;
|
|
31
|
+
GetTemplateCommand = cfModule.GetTemplateCommand;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
class AWSResourceImporter extends IResourceImporter {
|
|
36
|
+
/**
|
|
37
|
+
* Resource types that support import
|
|
38
|
+
* Maps CloudFormation resource type to identifier property
|
|
39
|
+
* @private
|
|
40
|
+
*/
|
|
41
|
+
static IMPORTABLE_TYPES = {
|
|
42
|
+
'AWS::EC2::VPC': 'VpcId',
|
|
43
|
+
'AWS::EC2::Subnet': 'SubnetId',
|
|
44
|
+
'AWS::EC2::SecurityGroup': 'GroupId',
|
|
45
|
+
'AWS::EC2::RouteTable': 'RouteTableId',
|
|
46
|
+
'AWS::RDS::DBCluster': 'DBClusterIdentifier',
|
|
47
|
+
'AWS::KMS::Key': 'KeyId',
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Create AWS Resource Importer
|
|
52
|
+
*
|
|
53
|
+
* @param {Object} [config={}]
|
|
54
|
+
* @param {string} [config.region] - AWS region (defaults to AWS_REGION env var)
|
|
55
|
+
*/
|
|
56
|
+
constructor(config = {}) {
|
|
57
|
+
super();
|
|
58
|
+
this.region = config.region || process.env.AWS_REGION || 'us-east-1';
|
|
59
|
+
this.client = null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Get or create CloudFormation client
|
|
64
|
+
* @private
|
|
65
|
+
*/
|
|
66
|
+
_getClient() {
|
|
67
|
+
if (!this.client) {
|
|
68
|
+
loadCloudFormation();
|
|
69
|
+
this.client = new CloudFormationClient({ region: this.region });
|
|
70
|
+
}
|
|
71
|
+
return this.client;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Check if a resource type supports import
|
|
76
|
+
*/
|
|
77
|
+
async supportsImport(resourceType) {
|
|
78
|
+
return resourceType in AWSResourceImporter.IMPORTABLE_TYPES;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Get the identifier property for a resource type
|
|
83
|
+
*/
|
|
84
|
+
async getIdentifierProperty(resourceType) {
|
|
85
|
+
if (!(await this.supportsImport(resourceType))) {
|
|
86
|
+
throw new Error(`Resource type ${resourceType} does not support import`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return AWSResourceImporter.IMPORTABLE_TYPES[resourceType];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Validate that a resource can be imported
|
|
94
|
+
*/
|
|
95
|
+
async validateImport({ resourceType, physicalId, region }) {
|
|
96
|
+
const canImport = await this.supportsImport(resourceType);
|
|
97
|
+
|
|
98
|
+
if (!canImport) {
|
|
99
|
+
return {
|
|
100
|
+
canImport: false,
|
|
101
|
+
reason: `Resource type ${resourceType} is not supported for import`,
|
|
102
|
+
warnings: [],
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Add resource-specific warnings
|
|
107
|
+
const warnings = [];
|
|
108
|
+
if (resourceType === 'AWS::RDS::DBCluster') {
|
|
109
|
+
warnings.push(
|
|
110
|
+
'Ensure DBCluster has required properties (Engine, MasterUsername, etc.)'
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
canImport: true,
|
|
116
|
+
reason: '',
|
|
117
|
+
warnings,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Import a single resource into a stack
|
|
123
|
+
*/
|
|
124
|
+
async importResource({ stackIdentifier, logicalId, resourceType, physicalId, properties }) {
|
|
125
|
+
// Validate resource type
|
|
126
|
+
if (!(await this.supportsImport(resourceType))) {
|
|
127
|
+
throw new Error(`Resource type ${resourceType} does not support import`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const client = this._getClient();
|
|
131
|
+
|
|
132
|
+
// Get identifier property
|
|
133
|
+
const identifierProperty = await this.getIdentifierProperty(resourceType);
|
|
134
|
+
|
|
135
|
+
// Create change set for import
|
|
136
|
+
const changeSetName = `import-${logicalId}-${Date.now()}`;
|
|
137
|
+
|
|
138
|
+
const createChangeSetCommand = new CreateChangeSetCommand({
|
|
139
|
+
StackName: stackIdentifier.stackName,
|
|
140
|
+
ChangeSetName: changeSetName,
|
|
141
|
+
ChangeSetType: 'IMPORT',
|
|
142
|
+
ResourcesToImport: [
|
|
143
|
+
{
|
|
144
|
+
ResourceType: resourceType,
|
|
145
|
+
LogicalResourceId: logicalId,
|
|
146
|
+
ResourceIdentifier: {
|
|
147
|
+
[identifierProperty]: physicalId,
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
],
|
|
151
|
+
TemplateBody: JSON.stringify({
|
|
152
|
+
Resources: {
|
|
153
|
+
[logicalId]: {
|
|
154
|
+
Type: resourceType,
|
|
155
|
+
Properties: properties,
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
}),
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const createResponse = await client.send(createChangeSetCommand);
|
|
162
|
+
|
|
163
|
+
// Execute the change set
|
|
164
|
+
const executeCommand = new ExecuteChangeSetCommand({
|
|
165
|
+
ChangeSetName: changeSetName,
|
|
166
|
+
StackName: stackIdentifier.stackName,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
await client.send(executeCommand);
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
operationId: createResponse.Id,
|
|
173
|
+
status: 'IN_PROGRESS',
|
|
174
|
+
message: `Resource import initiated via change set ${createResponse.Id}`,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Import multiple resources into a stack in a single operation
|
|
180
|
+
*/
|
|
181
|
+
async importMultipleResources({ stackIdentifier, resources }) {
|
|
182
|
+
const client = this._getClient();
|
|
183
|
+
|
|
184
|
+
// Filter to only supported resources
|
|
185
|
+
const supportedResources = [];
|
|
186
|
+
const unsupportedResources = [];
|
|
187
|
+
|
|
188
|
+
for (const resource of resources) {
|
|
189
|
+
if (await this.supportsImport(resource.resourceType)) {
|
|
190
|
+
supportedResources.push(resource);
|
|
191
|
+
} else {
|
|
192
|
+
unsupportedResources.push(resource);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (supportedResources.length === 0) {
|
|
197
|
+
return {
|
|
198
|
+
operationId: null,
|
|
199
|
+
status: 'FAILED',
|
|
200
|
+
importedCount: 0,
|
|
201
|
+
failedCount: resources.length,
|
|
202
|
+
message: 'No supported resources to import',
|
|
203
|
+
details: [],
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Build resources to import
|
|
208
|
+
const resourcesToImport = [];
|
|
209
|
+
const templateResources = {};
|
|
210
|
+
|
|
211
|
+
for (const resource of supportedResources) {
|
|
212
|
+
const identifierProperty = await this.getIdentifierProperty(resource.resourceType);
|
|
213
|
+
|
|
214
|
+
resourcesToImport.push({
|
|
215
|
+
ResourceType: resource.resourceType,
|
|
216
|
+
LogicalResourceId: resource.logicalId,
|
|
217
|
+
ResourceIdentifier: {
|
|
218
|
+
[identifierProperty]: resource.physicalId,
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
templateResources[resource.logicalId] = {
|
|
223
|
+
Type: resource.resourceType,
|
|
224
|
+
Properties: resource.properties,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Create change set for import
|
|
229
|
+
const changeSetName = `import-multi-${Date.now()}`;
|
|
230
|
+
|
|
231
|
+
const createChangeSetCommand = new CreateChangeSetCommand({
|
|
232
|
+
StackName: stackIdentifier.stackName,
|
|
233
|
+
ChangeSetName: changeSetName,
|
|
234
|
+
ChangeSetType: 'IMPORT',
|
|
235
|
+
ResourcesToImport: resourcesToImport,
|
|
236
|
+
TemplateBody: JSON.stringify({
|
|
237
|
+
Resources: templateResources,
|
|
238
|
+
}),
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
const createResponse = await client.send(createChangeSetCommand);
|
|
242
|
+
|
|
243
|
+
// Execute the change set
|
|
244
|
+
const executeCommand = new ExecuteChangeSetCommand({
|
|
245
|
+
ChangeSetName: changeSetName,
|
|
246
|
+
StackName: stackIdentifier.stackName,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
await client.send(executeCommand);
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
operationId: createResponse.Id,
|
|
253
|
+
status: 'IN_PROGRESS',
|
|
254
|
+
importedCount: supportedResources.length,
|
|
255
|
+
failedCount: unsupportedResources.length,
|
|
256
|
+
message: `Import operation initiated for ${supportedResources.length} resources`,
|
|
257
|
+
details: [],
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Get status of an import operation
|
|
263
|
+
*/
|
|
264
|
+
async getImportStatus(operationId) {
|
|
265
|
+
const client = this._getClient();
|
|
266
|
+
|
|
267
|
+
const command = new DescribeChangeSetCommand({
|
|
268
|
+
ChangeSetName: operationId,
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const response = await client.send(command);
|
|
272
|
+
|
|
273
|
+
// Map CloudFormation status to our status
|
|
274
|
+
let status = 'IN_PROGRESS';
|
|
275
|
+
let progress = 0;
|
|
276
|
+
|
|
277
|
+
if (response.ExecutionStatus === 'EXECUTE_COMPLETE') {
|
|
278
|
+
status = 'COMPLETE';
|
|
279
|
+
progress = 100;
|
|
280
|
+
} else if (response.Status === 'FAILED' || response.ExecutionStatus === 'EXECUTE_FAILED') {
|
|
281
|
+
status = 'FAILED';
|
|
282
|
+
progress = 0;
|
|
283
|
+
} else if (response.Status === 'CREATE_COMPLETE') {
|
|
284
|
+
status = 'IN_PROGRESS';
|
|
285
|
+
progress = 50;
|
|
286
|
+
} else if (response.Status === 'CREATE_PENDING') {
|
|
287
|
+
status = 'IN_PROGRESS';
|
|
288
|
+
progress = 25;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
operationId,
|
|
293
|
+
status,
|
|
294
|
+
progress,
|
|
295
|
+
message: response.StatusReason || '',
|
|
296
|
+
completedTime: status === 'COMPLETE' ? response.CreationTime : null,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Generate CloudFormation template snippet for an imported resource
|
|
302
|
+
*/
|
|
303
|
+
async generateTemplateSnippet({ logicalId, resourceType, properties }) {
|
|
304
|
+
// Validate resource type
|
|
305
|
+
if (!(await this.supportsImport(resourceType))) {
|
|
306
|
+
throw new Error(`Resource type ${resourceType} does not support import`);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
[logicalId]: {
|
|
311
|
+
Type: resourceType,
|
|
312
|
+
Properties: properties,
|
|
313
|
+
},
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
module.exports = AWSResourceImporter;
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for AWSResourceImporter Adapter
|
|
3
|
+
*
|
|
4
|
+
* Tests CloudFormation resource import operations using mocked AWS SDK
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const AWSResourceImporter = require('./aws-resource-importer');
|
|
8
|
+
const StackIdentifier = require('../../domain/value-objects/stack-identifier');
|
|
9
|
+
|
|
10
|
+
// Mock AWS SDK
|
|
11
|
+
jest.mock('@aws-sdk/client-cloudformation', () => ({
|
|
12
|
+
CloudFormationClient: jest.fn(),
|
|
13
|
+
CreateChangeSetCommand: jest.fn(),
|
|
14
|
+
DescribeChangeSetCommand: jest.fn(),
|
|
15
|
+
ExecuteChangeSetCommand: jest.fn(),
|
|
16
|
+
GetTemplateCommand: jest.fn(),
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
describe('AWSResourceImporter', () => {
|
|
20
|
+
let importer;
|
|
21
|
+
let mockSend;
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
jest.clearAllMocks();
|
|
25
|
+
|
|
26
|
+
mockSend = jest.fn();
|
|
27
|
+
const { CloudFormationClient } = require('@aws-sdk/client-cloudformation');
|
|
28
|
+
CloudFormationClient.mockImplementation(() => ({ send: mockSend }));
|
|
29
|
+
|
|
30
|
+
importer = new AWSResourceImporter({ region: 'us-east-1' });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('supportsImport', () => {
|
|
34
|
+
it('should return true for VPC', async () => {
|
|
35
|
+
const supports = await importer.supportsImport('AWS::EC2::VPC');
|
|
36
|
+
expect(supports).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should return true for RDS DBCluster', async () => {
|
|
40
|
+
const supports = await importer.supportsImport('AWS::RDS::DBCluster');
|
|
41
|
+
expect(supports).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should return true for KMS Key', async () => {
|
|
45
|
+
const supports = await importer.supportsImport('AWS::KMS::Key');
|
|
46
|
+
expect(supports).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should return false for unsupported type', async () => {
|
|
50
|
+
const supports = await importer.supportsImport('AWS::Lambda::Function');
|
|
51
|
+
expect(supports).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('getIdentifierProperty', () => {
|
|
56
|
+
it('should return VpcId for VPC', async () => {
|
|
57
|
+
const prop = await importer.getIdentifierProperty('AWS::EC2::VPC');
|
|
58
|
+
expect(prop).toBe('VpcId');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should return SubnetId for Subnet', async () => {
|
|
62
|
+
const prop = await importer.getIdentifierProperty('AWS::EC2::Subnet');
|
|
63
|
+
expect(prop).toBe('SubnetId');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should return DBClusterIdentifier for RDS DBCluster', async () => {
|
|
67
|
+
const prop = await importer.getIdentifierProperty('AWS::RDS::DBCluster');
|
|
68
|
+
expect(prop).toBe('DBClusterIdentifier');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('should throw error for unsupported type', async () => {
|
|
72
|
+
await expect(
|
|
73
|
+
importer.getIdentifierProperty('AWS::Lambda::Function')
|
|
74
|
+
).rejects.toThrow('Resource type AWS::Lambda::Function does not support import');
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('validateImport', () => {
|
|
79
|
+
it('should validate VPC import', async () => {
|
|
80
|
+
const result = await importer.validateImport({
|
|
81
|
+
resourceType: 'AWS::EC2::VPC',
|
|
82
|
+
physicalId: 'vpc-123',
|
|
83
|
+
region: 'us-east-1',
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect(result).toEqual({
|
|
87
|
+
canImport: true,
|
|
88
|
+
reason: '',
|
|
89
|
+
warnings: [],
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('should fail validation for unsupported type', async () => {
|
|
94
|
+
const result = await importer.validateImport({
|
|
95
|
+
resourceType: 'AWS::Lambda::Function',
|
|
96
|
+
physicalId: 'my-function',
|
|
97
|
+
region: 'us-east-1',
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
expect(result.canImport).toBe(false);
|
|
101
|
+
expect(result.reason).toContain('not supported');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should warn about missing required properties', async () => {
|
|
105
|
+
const result = await importer.validateImport({
|
|
106
|
+
resourceType: 'AWS::RDS::DBCluster',
|
|
107
|
+
physicalId: 'my-cluster',
|
|
108
|
+
region: 'us-east-1',
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
expect(result.canImport).toBe(true);
|
|
112
|
+
expect(result.warnings).toContain(
|
|
113
|
+
'Ensure DBCluster has required properties (Engine, MasterUsername, etc.)'
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('importResource', () => {
|
|
119
|
+
it('should import a single VPC resource', async () => {
|
|
120
|
+
const stackIdentifier = new StackIdentifier({
|
|
121
|
+
stackName: 'my-app-prod',
|
|
122
|
+
region: 'us-east-1',
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Mock CreateChangeSet
|
|
126
|
+
mockSend.mockResolvedValueOnce({
|
|
127
|
+
Id: 'changeset-123',
|
|
128
|
+
StackId: 'arn:aws:cloudformation:us-east-1:123456789012:stack/my-app-prod/guid',
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// Mock ExecuteChangeSet
|
|
132
|
+
mockSend.mockResolvedValueOnce({});
|
|
133
|
+
|
|
134
|
+
const result = await importer.importResource({
|
|
135
|
+
stackIdentifier,
|
|
136
|
+
logicalId: 'ImportedVPC',
|
|
137
|
+
resourceType: 'AWS::EC2::VPC',
|
|
138
|
+
physicalId: 'vpc-123',
|
|
139
|
+
properties: {
|
|
140
|
+
CidrBlock: '10.0.0.0/16',
|
|
141
|
+
EnableDnsSupport: true,
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
expect(result).toEqual({
|
|
146
|
+
operationId: 'changeset-123',
|
|
147
|
+
status: 'IN_PROGRESS',
|
|
148
|
+
message: 'Resource import initiated via change set changeset-123',
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
expect(mockSend).toHaveBeenCalledTimes(2);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('should throw error for unsupported resource type', async () => {
|
|
155
|
+
const stackIdentifier = new StackIdentifier({
|
|
156
|
+
stackName: 'my-app-prod',
|
|
157
|
+
region: 'us-east-1',
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
await expect(
|
|
161
|
+
importer.importResource({
|
|
162
|
+
stackIdentifier,
|
|
163
|
+
logicalId: 'MyFunction',
|
|
164
|
+
resourceType: 'AWS::Lambda::Function',
|
|
165
|
+
physicalId: 'my-function',
|
|
166
|
+
properties: {},
|
|
167
|
+
})
|
|
168
|
+
).rejects.toThrow('Resource type AWS::Lambda::Function does not support import');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('should handle import failure', async () => {
|
|
172
|
+
const stackIdentifier = new StackIdentifier({
|
|
173
|
+
stackName: 'my-app-prod',
|
|
174
|
+
region: 'us-east-1',
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const error = new Error('Resource already exists in stack');
|
|
178
|
+
error.name = 'AlreadyExistsException';
|
|
179
|
+
mockSend.mockRejectedValue(error);
|
|
180
|
+
|
|
181
|
+
await expect(
|
|
182
|
+
importer.importResource({
|
|
183
|
+
stackIdentifier,
|
|
184
|
+
logicalId: 'ExistingVPC',
|
|
185
|
+
resourceType: 'AWS::EC2::VPC',
|
|
186
|
+
physicalId: 'vpc-123',
|
|
187
|
+
properties: { CidrBlock: '10.0.0.0/16' },
|
|
188
|
+
})
|
|
189
|
+
).rejects.toThrow('Resource already exists in stack');
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe('importMultipleResources', () => {
|
|
194
|
+
it('should import multiple resources in single operation', async () => {
|
|
195
|
+
const stackIdentifier = new StackIdentifier({
|
|
196
|
+
stackName: 'my-app-prod',
|
|
197
|
+
region: 'us-east-1',
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const resources = [
|
|
201
|
+
{
|
|
202
|
+
logicalId: 'ImportedVPC',
|
|
203
|
+
resourceType: 'AWS::EC2::VPC',
|
|
204
|
+
physicalId: 'vpc-123',
|
|
205
|
+
properties: { CidrBlock: '10.0.0.0/16' },
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
logicalId: 'ImportedSubnet',
|
|
209
|
+
resourceType: 'AWS::EC2::Subnet',
|
|
210
|
+
physicalId: 'subnet-456',
|
|
211
|
+
properties: { VpcId: 'vpc-123', CidrBlock: '10.0.1.0/24' },
|
|
212
|
+
},
|
|
213
|
+
];
|
|
214
|
+
|
|
215
|
+
// Mock CreateChangeSet
|
|
216
|
+
mockSend.mockResolvedValueOnce({
|
|
217
|
+
Id: 'changeset-multi-123',
|
|
218
|
+
StackId: 'arn:aws:cloudformation:us-east-1:123456789012:stack/my-app-prod/guid',
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// Mock ExecuteChangeSet
|
|
222
|
+
mockSend.mockResolvedValueOnce({});
|
|
223
|
+
|
|
224
|
+
const result = await importer.importMultipleResources({
|
|
225
|
+
stackIdentifier,
|
|
226
|
+
resources,
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
expect(result).toEqual({
|
|
230
|
+
operationId: 'changeset-multi-123',
|
|
231
|
+
status: 'IN_PROGRESS',
|
|
232
|
+
importedCount: 2,
|
|
233
|
+
failedCount: 0,
|
|
234
|
+
message: 'Import operation initiated for 2 resources',
|
|
235
|
+
details: [],
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('should filter out unsupported resource types', async () => {
|
|
240
|
+
const stackIdentifier = new StackIdentifier({
|
|
241
|
+
stackName: 'my-app-prod',
|
|
242
|
+
region: 'us-east-1',
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
const resources = [
|
|
246
|
+
{
|
|
247
|
+
logicalId: 'ImportedVPC',
|
|
248
|
+
resourceType: 'AWS::EC2::VPC',
|
|
249
|
+
physicalId: 'vpc-123',
|
|
250
|
+
properties: { CidrBlock: '10.0.0.0/16' },
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
logicalId: 'UnsupportedFunction',
|
|
254
|
+
resourceType: 'AWS::Lambda::Function',
|
|
255
|
+
physicalId: 'my-function',
|
|
256
|
+
properties: {},
|
|
257
|
+
},
|
|
258
|
+
];
|
|
259
|
+
|
|
260
|
+
// Mock CreateChangeSet (only for VPC)
|
|
261
|
+
mockSend.mockResolvedValueOnce({
|
|
262
|
+
Id: 'changeset-filtered-123',
|
|
263
|
+
StackId: 'arn:aws:cloudformation:us-east-1:123456789012:stack/my-app-prod/guid',
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// Mock ExecuteChangeSet
|
|
267
|
+
mockSend.mockResolvedValueOnce({});
|
|
268
|
+
|
|
269
|
+
const result = await importer.importMultipleResources({
|
|
270
|
+
stackIdentifier,
|
|
271
|
+
resources,
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
expect(result.importedCount).toBe(1);
|
|
275
|
+
expect(result.failedCount).toBe(1);
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
describe('getImportStatus', () => {
|
|
280
|
+
it('should get status of in-progress import', async () => {
|
|
281
|
+
mockSend.mockResolvedValue({
|
|
282
|
+
Status: 'CREATE_PENDING',
|
|
283
|
+
ExecutionStatus: 'AVAILABLE',
|
|
284
|
+
StatusReason: 'Change set created',
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
const status = await importer.getImportStatus('changeset-123');
|
|
288
|
+
|
|
289
|
+
expect(status).toEqual({
|
|
290
|
+
operationId: 'changeset-123',
|
|
291
|
+
status: 'IN_PROGRESS',
|
|
292
|
+
progress: 25,
|
|
293
|
+
message: 'Change set created',
|
|
294
|
+
completedTime: null,
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('should get status of completed import', async () => {
|
|
299
|
+
const completedTime = new Date('2024-01-15T10:30:00Z');
|
|
300
|
+
|
|
301
|
+
mockSend.mockResolvedValue({
|
|
302
|
+
Status: 'CREATE_COMPLETE',
|
|
303
|
+
ExecutionStatus: 'EXECUTE_COMPLETE',
|
|
304
|
+
StatusReason: 'Import completed successfully',
|
|
305
|
+
CreationTime: completedTime,
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
const status = await importer.getImportStatus('changeset-123');
|
|
309
|
+
|
|
310
|
+
expect(status.status).toBe('COMPLETE');
|
|
311
|
+
expect(status.progress).toBe(100);
|
|
312
|
+
expect(status.completedTime).toEqual(completedTime);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('should get status of failed import', async () => {
|
|
316
|
+
mockSend.mockResolvedValue({
|
|
317
|
+
Status: 'FAILED',
|
|
318
|
+
ExecutionStatus: 'EXECUTE_FAILED',
|
|
319
|
+
StatusReason: 'Resource already exists',
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
const status = await importer.getImportStatus('changeset-123');
|
|
323
|
+
|
|
324
|
+
expect(status.status).toBe('FAILED');
|
|
325
|
+
expect(status.message).toContain('already exists');
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
describe('generateTemplateSnippet', () => {
|
|
330
|
+
it('should generate VPC template snippet', async () => {
|
|
331
|
+
const snippet = await importer.generateTemplateSnippet({
|
|
332
|
+
logicalId: 'ImportedVPC',
|
|
333
|
+
resourceType: 'AWS::EC2::VPC',
|
|
334
|
+
properties: {
|
|
335
|
+
CidrBlock: '10.0.0.0/16',
|
|
336
|
+
EnableDnsSupport: true,
|
|
337
|
+
EnableDnsHostnames: true,
|
|
338
|
+
},
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
expect(snippet).toEqual({
|
|
342
|
+
ImportedVPC: {
|
|
343
|
+
Type: 'AWS::EC2::VPC',
|
|
344
|
+
Properties: {
|
|
345
|
+
CidrBlock: '10.0.0.0/16',
|
|
346
|
+
EnableDnsSupport: true,
|
|
347
|
+
EnableDnsHostnames: true,
|
|
348
|
+
},
|
|
349
|
+
},
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('should generate RDS DBCluster template snippet', async () => {
|
|
354
|
+
const snippet = await importer.generateTemplateSnippet({
|
|
355
|
+
logicalId: 'ImportedCluster',
|
|
356
|
+
resourceType: 'AWS::RDS::DBCluster',
|
|
357
|
+
properties: {
|
|
358
|
+
Engine: 'aurora-postgresql',
|
|
359
|
+
EngineVersion: '13.7',
|
|
360
|
+
MasterUsername: 'admin',
|
|
361
|
+
},
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
expect(snippet).toEqual({
|
|
365
|
+
ImportedCluster: {
|
|
366
|
+
Type: 'AWS::RDS::DBCluster',
|
|
367
|
+
Properties: {
|
|
368
|
+
Engine: 'aurora-postgresql',
|
|
369
|
+
EngineVersion: '13.7',
|
|
370
|
+
MasterUsername: 'admin',
|
|
371
|
+
},
|
|
372
|
+
},
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it('should throw error for unsupported resource type', async () => {
|
|
377
|
+
await expect(
|
|
378
|
+
importer.generateTemplateSnippet({
|
|
379
|
+
logicalId: 'MyFunction',
|
|
380
|
+
resourceType: 'AWS::Lambda::Function',
|
|
381
|
+
properties: {},
|
|
382
|
+
})
|
|
383
|
+
).rejects.toThrow('Resource type AWS::Lambda::Function does not support import');
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
describe('constructor', () => {
|
|
388
|
+
it('should create instance with default region', () => {
|
|
389
|
+
const imp = new AWSResourceImporter();
|
|
390
|
+
expect(imp).toBeInstanceOf(AWSResourceImporter);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it('should create instance with custom region', () => {
|
|
394
|
+
const imp = new AWSResourceImporter({ region: 'eu-west-1' });
|
|
395
|
+
expect(imp).toBeInstanceOf(AWSResourceImporter);
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@friggframework/devtools",
|
|
3
3
|
"prettier": "@friggframework/prettier-config",
|
|
4
|
-
"version": "2.0.0--canary.474.
|
|
4
|
+
"version": "2.0.0--canary.474.4793186.0",
|
|
5
5
|
"dependencies": {
|
|
6
6
|
"@aws-sdk/client-ec2": "^3.835.0",
|
|
7
7
|
"@aws-sdk/client-kms": "^3.835.0",
|
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
"@babel/eslint-parser": "^7.18.9",
|
|
12
12
|
"@babel/parser": "^7.25.3",
|
|
13
13
|
"@babel/traverse": "^7.25.3",
|
|
14
|
-
"@friggframework/schemas": "2.0.0--canary.474.
|
|
15
|
-
"@friggframework/test": "2.0.0--canary.474.
|
|
14
|
+
"@friggframework/schemas": "2.0.0--canary.474.4793186.0",
|
|
15
|
+
"@friggframework/test": "2.0.0--canary.474.4793186.0",
|
|
16
16
|
"@hapi/boom": "^10.0.1",
|
|
17
17
|
"@inquirer/prompts": "^5.3.8",
|
|
18
18
|
"axios": "^1.7.2",
|
|
@@ -34,8 +34,8 @@
|
|
|
34
34
|
"serverless-http": "^2.7.0"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
|
-
"@friggframework/eslint-config": "2.0.0--canary.474.
|
|
38
|
-
"@friggframework/prettier-config": "2.0.0--canary.474.
|
|
37
|
+
"@friggframework/eslint-config": "2.0.0--canary.474.4793186.0",
|
|
38
|
+
"@friggframework/prettier-config": "2.0.0--canary.474.4793186.0",
|
|
39
39
|
"aws-sdk-client-mock": "^4.1.0",
|
|
40
40
|
"aws-sdk-client-mock-jest": "^4.1.0",
|
|
41
41
|
"jest": "^30.1.3",
|
|
@@ -70,5 +70,5 @@
|
|
|
70
70
|
"publishConfig": {
|
|
71
71
|
"access": "public"
|
|
72
72
|
},
|
|
73
|
-
"gitHead": "
|
|
73
|
+
"gitHead": "47931865212a71b5e52aaf06bc9c1e49dbb3171e"
|
|
74
74
|
}
|