@friggframework/devtools 2.0.0--canary.474.6ec870b.0 → 2.0.0--canary.474.82ba370.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/infrastructure/domains/health/domain/entities/issue.js +250 -0
- package/infrastructure/domains/health/domain/entities/issue.test.js +417 -0
- package/infrastructure/domains/health/domain/entities/resource.js +159 -0
- package/infrastructure/domains/health/domain/entities/resource.test.js +432 -0
- package/package.json +6 -6
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue Entity
|
|
3
|
+
*
|
|
4
|
+
* Represents a problem detected in infrastructure health check
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
class Issue {
|
|
8
|
+
/**
|
|
9
|
+
* Valid issue types
|
|
10
|
+
*/
|
|
11
|
+
static TYPES = {
|
|
12
|
+
ORPHANED_RESOURCE: 'ORPHANED_RESOURCE',
|
|
13
|
+
MISSING_RESOURCE: 'MISSING_RESOURCE',
|
|
14
|
+
PROPERTY_MISMATCH: 'PROPERTY_MISMATCH',
|
|
15
|
+
DRIFTED_RESOURCE: 'DRIFTED_RESOURCE',
|
|
16
|
+
MISSING_TAG: 'MISSING_TAG',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Valid severity levels
|
|
21
|
+
*/
|
|
22
|
+
static SEVERITIES = {
|
|
23
|
+
CRITICAL: 'critical',
|
|
24
|
+
WARNING: 'warning',
|
|
25
|
+
INFO: 'info',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Create a new Issue
|
|
30
|
+
*
|
|
31
|
+
* @param {Object} params
|
|
32
|
+
* @param {string} params.type - Issue type (ORPHANED_RESOURCE, MISSING_RESOURCE, etc.)
|
|
33
|
+
* @param {string} params.severity - Severity level (critical, warning, info)
|
|
34
|
+
* @param {string} params.resourceType - CloudFormation resource type
|
|
35
|
+
* @param {string} params.resourceId - Resource identifier (physical or logical ID)
|
|
36
|
+
* @param {string} params.description - Human-readable description
|
|
37
|
+
* @param {string} [params.resolution] - Suggested resolution
|
|
38
|
+
* @param {boolean} [params.canAutoFix=false] - Whether issue can be automatically fixed
|
|
39
|
+
* @param {PropertyMismatch} [params.propertyMismatch] - Property mismatch details (for PROPERTY_MISMATCH type)
|
|
40
|
+
*/
|
|
41
|
+
constructor({
|
|
42
|
+
type,
|
|
43
|
+
severity,
|
|
44
|
+
resourceType,
|
|
45
|
+
resourceId,
|
|
46
|
+
description,
|
|
47
|
+
resolution = null,
|
|
48
|
+
canAutoFix = false,
|
|
49
|
+
propertyMismatch = null,
|
|
50
|
+
}) {
|
|
51
|
+
// Validate required fields
|
|
52
|
+
if (!type) {
|
|
53
|
+
throw new Error('type is required');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!severity) {
|
|
57
|
+
throw new Error('severity is required');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!resourceType) {
|
|
61
|
+
throw new Error('resourceType is required');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!resourceId) {
|
|
65
|
+
throw new Error('resourceId is required');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!description) {
|
|
69
|
+
throw new Error('description is required');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Validate type
|
|
73
|
+
if (!Object.values(Issue.TYPES).includes(type)) {
|
|
74
|
+
throw new Error(`Invalid issue type: ${type}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Validate severity
|
|
78
|
+
if (!Object.values(Issue.SEVERITIES).includes(severity)) {
|
|
79
|
+
throw new Error(`Invalid severity: ${severity}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
this.type = type;
|
|
83
|
+
this.severity = severity;
|
|
84
|
+
this.resourceType = resourceType;
|
|
85
|
+
this.resourceId = resourceId;
|
|
86
|
+
this.description = description;
|
|
87
|
+
this.resolution = resolution;
|
|
88
|
+
this.canAutoFix = canAutoFix;
|
|
89
|
+
this.propertyMismatch = propertyMismatch;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Check if issue is an orphaned resource
|
|
94
|
+
* @returns {boolean}
|
|
95
|
+
*/
|
|
96
|
+
isOrphanedResource() {
|
|
97
|
+
return this.type === Issue.TYPES.ORPHANED_RESOURCE;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Check if issue is a missing resource
|
|
102
|
+
* @returns {boolean}
|
|
103
|
+
*/
|
|
104
|
+
isMissingResource() {
|
|
105
|
+
return this.type === Issue.TYPES.MISSING_RESOURCE;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Check if issue is a property mismatch
|
|
110
|
+
* @returns {boolean}
|
|
111
|
+
*/
|
|
112
|
+
isPropertyMismatch() {
|
|
113
|
+
return this.type === Issue.TYPES.PROPERTY_MISMATCH;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Check if issue is a drifted resource
|
|
118
|
+
* @returns {boolean}
|
|
119
|
+
*/
|
|
120
|
+
isDrifted() {
|
|
121
|
+
return this.type === Issue.TYPES.DRIFTED_RESOURCE;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Check if issue is critical severity
|
|
126
|
+
* @returns {boolean}
|
|
127
|
+
*/
|
|
128
|
+
isCritical() {
|
|
129
|
+
return this.severity === Issue.SEVERITIES.CRITICAL;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Check if issue is warning severity
|
|
134
|
+
* @returns {boolean}
|
|
135
|
+
*/
|
|
136
|
+
isWarning() {
|
|
137
|
+
return this.severity === Issue.SEVERITIES.WARNING;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Check if issue is info severity
|
|
142
|
+
* @returns {boolean}
|
|
143
|
+
*/
|
|
144
|
+
isInfo() {
|
|
145
|
+
return this.severity === Issue.SEVERITIES.INFO;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Get string representation
|
|
150
|
+
* @returns {string}
|
|
151
|
+
*/
|
|
152
|
+
toString() {
|
|
153
|
+
return `Issue: ${this.type} [${this.severity}] - ${this.resourceType} (${this.resourceId}): ${this.description}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Serialize to JSON
|
|
158
|
+
* @returns {Object}
|
|
159
|
+
*/
|
|
160
|
+
toJSON() {
|
|
161
|
+
return {
|
|
162
|
+
type: this.type,
|
|
163
|
+
severity: this.severity,
|
|
164
|
+
resourceType: this.resourceType,
|
|
165
|
+
resourceId: this.resourceId,
|
|
166
|
+
description: this.description,
|
|
167
|
+
resolution: this.resolution,
|
|
168
|
+
canAutoFix: this.canAutoFix,
|
|
169
|
+
propertyMismatch: this.propertyMismatch ? this.propertyMismatch.toJSON() : null,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Create an orphaned resource issue
|
|
175
|
+
*
|
|
176
|
+
* @param {Object} params
|
|
177
|
+
* @param {string} params.resourceType - CloudFormation resource type
|
|
178
|
+
* @param {string} params.resourceId - Resource physical ID
|
|
179
|
+
* @param {string} params.description - Issue description
|
|
180
|
+
* @returns {Issue}
|
|
181
|
+
*/
|
|
182
|
+
static orphanedResource({ resourceType, resourceId, description }) {
|
|
183
|
+
return new Issue({
|
|
184
|
+
type: Issue.TYPES.ORPHANED_RESOURCE,
|
|
185
|
+
severity: Issue.SEVERITIES.CRITICAL,
|
|
186
|
+
resourceType,
|
|
187
|
+
resourceId,
|
|
188
|
+
description,
|
|
189
|
+
resolution: 'Import resource into CloudFormation stack using frigg repair --import',
|
|
190
|
+
canAutoFix: true,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Create a missing resource issue
|
|
196
|
+
*
|
|
197
|
+
* @param {Object} params
|
|
198
|
+
* @param {string} params.resourceType - CloudFormation resource type
|
|
199
|
+
* @param {string} params.resourceId - Resource logical ID
|
|
200
|
+
* @param {string} params.description - Issue description
|
|
201
|
+
* @returns {Issue}
|
|
202
|
+
*/
|
|
203
|
+
static missingResource({ resourceType, resourceId, description }) {
|
|
204
|
+
return new Issue({
|
|
205
|
+
type: Issue.TYPES.MISSING_RESOURCE,
|
|
206
|
+
severity: Issue.SEVERITIES.CRITICAL,
|
|
207
|
+
resourceType,
|
|
208
|
+
resourceId,
|
|
209
|
+
description,
|
|
210
|
+
resolution: 'Verify resource was not manually deleted. May need to recreate or remove from stack definition.',
|
|
211
|
+
canAutoFix: false,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Create a property mismatch issue
|
|
217
|
+
*
|
|
218
|
+
* @param {Object} params
|
|
219
|
+
* @param {string} params.resourceType - CloudFormation resource type
|
|
220
|
+
* @param {string} params.resourceId - Resource identifier
|
|
221
|
+
* @param {PropertyMismatch} params.mismatch - Property mismatch details
|
|
222
|
+
* @returns {Issue}
|
|
223
|
+
*/
|
|
224
|
+
static propertyMismatch({ resourceType, resourceId, mismatch }) {
|
|
225
|
+
const severity = mismatch.requiresReplacement()
|
|
226
|
+
? Issue.SEVERITIES.CRITICAL
|
|
227
|
+
: Issue.SEVERITIES.WARNING;
|
|
228
|
+
|
|
229
|
+
const canAutoFix = mismatch.canAutoFix();
|
|
230
|
+
|
|
231
|
+
const description = `Property mismatch: ${mismatch.propertyPath} (expected: ${mismatch.expectedValue}, actual: ${mismatch.actualValue})`;
|
|
232
|
+
|
|
233
|
+
const resolution = canAutoFix
|
|
234
|
+
? 'Can be auto-fixed using frigg repair --reconcile'
|
|
235
|
+
: 'Requires resource replacement - manual intervention needed';
|
|
236
|
+
|
|
237
|
+
return new Issue({
|
|
238
|
+
type: Issue.TYPES.PROPERTY_MISMATCH,
|
|
239
|
+
severity,
|
|
240
|
+
resourceType,
|
|
241
|
+
resourceId,
|
|
242
|
+
description,
|
|
243
|
+
resolution,
|
|
244
|
+
canAutoFix,
|
|
245
|
+
propertyMismatch: mismatch,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
module.exports = Issue;
|
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for Issue Entity
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const Issue = require('./issue');
|
|
6
|
+
const ResourceState = require('../value-objects/resource-state');
|
|
7
|
+
const PropertyMismatch = require('./property-mismatch');
|
|
8
|
+
const PropertyMutability = require('../value-objects/property-mutability');
|
|
9
|
+
|
|
10
|
+
describe('Issue', () => {
|
|
11
|
+
describe('constructor', () => {
|
|
12
|
+
it('should create an orphaned resource issue', () => {
|
|
13
|
+
const issue = new Issue({
|
|
14
|
+
type: 'ORPHANED_RESOURCE',
|
|
15
|
+
severity: 'critical',
|
|
16
|
+
resourceType: 'AWS::RDS::DBCluster',
|
|
17
|
+
resourceId: 'my-app-prod-aurora',
|
|
18
|
+
description: 'Aurora cluster exists in AWS but not managed by CloudFormation',
|
|
19
|
+
resolution: 'Import resource into CloudFormation stack',
|
|
20
|
+
canAutoFix: true,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
expect(issue.type).toBe('ORPHANED_RESOURCE');
|
|
24
|
+
expect(issue.severity).toBe('critical');
|
|
25
|
+
expect(issue.resourceType).toBe('AWS::RDS::DBCluster');
|
|
26
|
+
expect(issue.resourceId).toBe('my-app-prod-aurora');
|
|
27
|
+
expect(issue.description).toBe('Aurora cluster exists in AWS but not managed by CloudFormation');
|
|
28
|
+
expect(issue.resolution).toBe('Import resource into CloudFormation stack');
|
|
29
|
+
expect(issue.canAutoFix).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('should create a missing resource issue', () => {
|
|
33
|
+
const issue = new Issue({
|
|
34
|
+
type: 'MISSING_RESOURCE',
|
|
35
|
+
severity: 'critical',
|
|
36
|
+
resourceType: 'AWS::KMS::Key',
|
|
37
|
+
resourceId: 'FriggKMSKey',
|
|
38
|
+
description: 'KMS key defined in stack but does not exist in AWS',
|
|
39
|
+
resolution: 'Verify resource was not manually deleted',
|
|
40
|
+
canAutoFix: false,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
expect(issue.type).toBe('MISSING_RESOURCE');
|
|
44
|
+
expect(issue.severity).toBe('critical');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should create a property mismatch issue', () => {
|
|
48
|
+
const mismatch = new PropertyMismatch({
|
|
49
|
+
propertyPath: 'Properties.Tags',
|
|
50
|
+
expectedValue: [{ Key: 'Environment', Value: 'production' }],
|
|
51
|
+
actualValue: [{ Key: 'Env', Value: 'prod' }],
|
|
52
|
+
mutability: PropertyMutability.MUTABLE,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const issue = new Issue({
|
|
56
|
+
type: 'PROPERTY_MISMATCH',
|
|
57
|
+
severity: 'warning',
|
|
58
|
+
resourceType: 'AWS::EC2::VPC',
|
|
59
|
+
resourceId: 'vpc-0abc123',
|
|
60
|
+
description: 'VPC tags differ from expected configuration',
|
|
61
|
+
resolution: 'Update VPC tags to match desired state',
|
|
62
|
+
canAutoFix: true,
|
|
63
|
+
propertyMismatch: mismatch,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
expect(issue.type).toBe('PROPERTY_MISMATCH');
|
|
67
|
+
expect(issue.propertyMismatch).toBe(mismatch);
|
|
68
|
+
expect(issue.canAutoFix).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('should require type', () => {
|
|
72
|
+
expect(() => {
|
|
73
|
+
new Issue({
|
|
74
|
+
severity: 'critical',
|
|
75
|
+
resourceType: 'AWS::S3::Bucket',
|
|
76
|
+
resourceId: 'my-bucket',
|
|
77
|
+
description: 'Test',
|
|
78
|
+
resolution: 'Fix it',
|
|
79
|
+
});
|
|
80
|
+
}).toThrow('type is required');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('should require severity', () => {
|
|
84
|
+
expect(() => {
|
|
85
|
+
new Issue({
|
|
86
|
+
type: 'ORPHANED_RESOURCE',
|
|
87
|
+
resourceType: 'AWS::S3::Bucket',
|
|
88
|
+
resourceId: 'my-bucket',
|
|
89
|
+
description: 'Test',
|
|
90
|
+
resolution: 'Fix it',
|
|
91
|
+
});
|
|
92
|
+
}).toThrow('severity is required');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should require resourceType', () => {
|
|
96
|
+
expect(() => {
|
|
97
|
+
new Issue({
|
|
98
|
+
type: 'ORPHANED_RESOURCE',
|
|
99
|
+
severity: 'critical',
|
|
100
|
+
resourceId: 'my-bucket',
|
|
101
|
+
description: 'Test',
|
|
102
|
+
resolution: 'Fix it',
|
|
103
|
+
});
|
|
104
|
+
}).toThrow('resourceType is required');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('should require resourceId', () => {
|
|
108
|
+
expect(() => {
|
|
109
|
+
new Issue({
|
|
110
|
+
type: 'ORPHANED_RESOURCE',
|
|
111
|
+
severity: 'critical',
|
|
112
|
+
resourceType: 'AWS::S3::Bucket',
|
|
113
|
+
description: 'Test',
|
|
114
|
+
resolution: 'Fix it',
|
|
115
|
+
});
|
|
116
|
+
}).toThrow('resourceId is required');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('should require description', () => {
|
|
120
|
+
expect(() => {
|
|
121
|
+
new Issue({
|
|
122
|
+
type: 'ORPHANED_RESOURCE',
|
|
123
|
+
severity: 'critical',
|
|
124
|
+
resourceType: 'AWS::S3::Bucket',
|
|
125
|
+
resourceId: 'my-bucket',
|
|
126
|
+
resolution: 'Fix it',
|
|
127
|
+
});
|
|
128
|
+
}).toThrow('description is required');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('should validate issue type', () => {
|
|
132
|
+
expect(() => {
|
|
133
|
+
new Issue({
|
|
134
|
+
type: 'INVALID_TYPE',
|
|
135
|
+
severity: 'critical',
|
|
136
|
+
resourceType: 'AWS::S3::Bucket',
|
|
137
|
+
resourceId: 'my-bucket',
|
|
138
|
+
description: 'Test',
|
|
139
|
+
resolution: 'Fix it',
|
|
140
|
+
});
|
|
141
|
+
}).toThrow('Invalid issue type: INVALID_TYPE');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('should validate severity', () => {
|
|
145
|
+
expect(() => {
|
|
146
|
+
new Issue({
|
|
147
|
+
type: 'ORPHANED_RESOURCE',
|
|
148
|
+
severity: 'invalid',
|
|
149
|
+
resourceType: 'AWS::S3::Bucket',
|
|
150
|
+
resourceId: 'my-bucket',
|
|
151
|
+
description: 'Test',
|
|
152
|
+
resolution: 'Fix it',
|
|
153
|
+
});
|
|
154
|
+
}).toThrow('Invalid severity: invalid');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('should default canAutoFix to false', () => {
|
|
158
|
+
const issue = new Issue({
|
|
159
|
+
type: 'ORPHANED_RESOURCE',
|
|
160
|
+
severity: 'critical',
|
|
161
|
+
resourceType: 'AWS::S3::Bucket',
|
|
162
|
+
resourceId: 'my-bucket',
|
|
163
|
+
description: 'Test',
|
|
164
|
+
resolution: 'Fix it',
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
expect(issue.canAutoFix).toBe(false);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe('type checks', () => {
|
|
172
|
+
it('should check if issue is orphaned resource', () => {
|
|
173
|
+
const issue = new Issue({
|
|
174
|
+
type: 'ORPHANED_RESOURCE',
|
|
175
|
+
severity: 'critical',
|
|
176
|
+
resourceType: 'AWS::RDS::DBCluster',
|
|
177
|
+
resourceId: 'my-cluster',
|
|
178
|
+
description: 'Test',
|
|
179
|
+
resolution: 'Import',
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
expect(issue.isOrphanedResource()).toBe(true);
|
|
183
|
+
expect(issue.isMissingResource()).toBe(false);
|
|
184
|
+
expect(issue.isPropertyMismatch()).toBe(false);
|
|
185
|
+
expect(issue.isDrifted()).toBe(false);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('should check if issue is missing resource', () => {
|
|
189
|
+
const issue = new Issue({
|
|
190
|
+
type: 'MISSING_RESOURCE',
|
|
191
|
+
severity: 'critical',
|
|
192
|
+
resourceType: 'AWS::KMS::Key',
|
|
193
|
+
resourceId: 'FriggKMSKey',
|
|
194
|
+
description: 'Test',
|
|
195
|
+
resolution: 'Verify deletion',
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
expect(issue.isOrphanedResource()).toBe(false);
|
|
199
|
+
expect(issue.isMissingResource()).toBe(true);
|
|
200
|
+
expect(issue.isPropertyMismatch()).toBe(false);
|
|
201
|
+
expect(issue.isDrifted()).toBe(false);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('should check if issue is property mismatch', () => {
|
|
205
|
+
const issue = new Issue({
|
|
206
|
+
type: 'PROPERTY_MISMATCH',
|
|
207
|
+
severity: 'warning',
|
|
208
|
+
resourceType: 'AWS::EC2::VPC',
|
|
209
|
+
resourceId: 'vpc-123',
|
|
210
|
+
description: 'Test',
|
|
211
|
+
resolution: 'Update',
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
expect(issue.isOrphanedResource()).toBe(false);
|
|
215
|
+
expect(issue.isMissingResource()).toBe(false);
|
|
216
|
+
expect(issue.isPropertyMismatch()).toBe(true);
|
|
217
|
+
expect(issue.isDrifted()).toBe(false);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('should check if issue is drifted', () => {
|
|
221
|
+
const issue = new Issue({
|
|
222
|
+
type: 'DRIFTED_RESOURCE',
|
|
223
|
+
severity: 'warning',
|
|
224
|
+
resourceType: 'AWS::S3::Bucket',
|
|
225
|
+
resourceId: 'my-bucket',
|
|
226
|
+
description: 'Test',
|
|
227
|
+
resolution: 'Reconcile',
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
expect(issue.isOrphanedResource()).toBe(false);
|
|
231
|
+
expect(issue.isMissingResource()).toBe(false);
|
|
232
|
+
expect(issue.isPropertyMismatch()).toBe(false);
|
|
233
|
+
expect(issue.isDrifted()).toBe(true);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
describe('severity checks', () => {
|
|
238
|
+
it('should check if issue is critical', () => {
|
|
239
|
+
const issue = new Issue({
|
|
240
|
+
type: 'ORPHANED_RESOURCE',
|
|
241
|
+
severity: 'critical',
|
|
242
|
+
resourceType: 'AWS::RDS::DBCluster',
|
|
243
|
+
resourceId: 'my-cluster',
|
|
244
|
+
description: 'Test',
|
|
245
|
+
resolution: 'Import',
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
expect(issue.isCritical()).toBe(true);
|
|
249
|
+
expect(issue.isWarning()).toBe(false);
|
|
250
|
+
expect(issue.isInfo()).toBe(false);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('should check if issue is warning', () => {
|
|
254
|
+
const issue = new Issue({
|
|
255
|
+
type: 'PROPERTY_MISMATCH',
|
|
256
|
+
severity: 'warning',
|
|
257
|
+
resourceType: 'AWS::EC2::VPC',
|
|
258
|
+
resourceId: 'vpc-123',
|
|
259
|
+
description: 'Test',
|
|
260
|
+
resolution: 'Update',
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
expect(issue.isCritical()).toBe(false);
|
|
264
|
+
expect(issue.isWarning()).toBe(true);
|
|
265
|
+
expect(issue.isInfo()).toBe(false);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('should check if issue is info', () => {
|
|
269
|
+
const issue = new Issue({
|
|
270
|
+
type: 'MISSING_TAG',
|
|
271
|
+
severity: 'info',
|
|
272
|
+
resourceType: 'AWS::Lambda::Function',
|
|
273
|
+
resourceId: 'my-function',
|
|
274
|
+
description: 'Test',
|
|
275
|
+
resolution: 'Add tag',
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
expect(issue.isCritical()).toBe(false);
|
|
279
|
+
expect(issue.isWarning()).toBe(false);
|
|
280
|
+
expect(issue.isInfo()).toBe(true);
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
describe('toString', () => {
|
|
285
|
+
it('should return string representation', () => {
|
|
286
|
+
const issue = new Issue({
|
|
287
|
+
type: 'ORPHANED_RESOURCE',
|
|
288
|
+
severity: 'critical',
|
|
289
|
+
resourceType: 'AWS::RDS::DBCluster',
|
|
290
|
+
resourceId: 'my-app-prod-aurora',
|
|
291
|
+
description: 'Aurora cluster exists in AWS but not managed by CloudFormation',
|
|
292
|
+
resolution: 'Import resource into CloudFormation stack',
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
const str = issue.toString();
|
|
296
|
+
expect(str).toContain('ORPHANED_RESOURCE');
|
|
297
|
+
expect(str).toContain('critical');
|
|
298
|
+
expect(str).toContain('AWS::RDS::DBCluster');
|
|
299
|
+
expect(str).toContain('my-app-prod-aurora');
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
describe('toJSON', () => {
|
|
304
|
+
it('should serialize to JSON', () => {
|
|
305
|
+
const issue = new Issue({
|
|
306
|
+
type: 'ORPHANED_RESOURCE',
|
|
307
|
+
severity: 'critical',
|
|
308
|
+
resourceType: 'AWS::RDS::DBCluster',
|
|
309
|
+
resourceId: 'my-app-prod-aurora',
|
|
310
|
+
description: 'Aurora cluster exists in AWS but not managed by CloudFormation',
|
|
311
|
+
resolution: 'Import resource into CloudFormation stack',
|
|
312
|
+
canAutoFix: true,
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const json = issue.toJSON();
|
|
316
|
+
|
|
317
|
+
expect(json).toEqual({
|
|
318
|
+
type: 'ORPHANED_RESOURCE',
|
|
319
|
+
severity: 'critical',
|
|
320
|
+
resourceType: 'AWS::RDS::DBCluster',
|
|
321
|
+
resourceId: 'my-app-prod-aurora',
|
|
322
|
+
description: 'Aurora cluster exists in AWS but not managed by CloudFormation',
|
|
323
|
+
resolution: 'Import resource into CloudFormation stack',
|
|
324
|
+
canAutoFix: true,
|
|
325
|
+
propertyMismatch: null,
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('should include property mismatch in JSON', () => {
|
|
330
|
+
const mismatch = new PropertyMismatch({
|
|
331
|
+
propertyPath: 'Properties.Tags',
|
|
332
|
+
expectedValue: [{ Key: 'Env', Value: 'prod' }],
|
|
333
|
+
actualValue: [{ Key: 'Env', Value: 'dev' }],
|
|
334
|
+
mutability: PropertyMutability.MUTABLE,
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
const issue = new Issue({
|
|
338
|
+
type: 'PROPERTY_MISMATCH',
|
|
339
|
+
severity: 'warning',
|
|
340
|
+
resourceType: 'AWS::EC2::VPC',
|
|
341
|
+
resourceId: 'vpc-123',
|
|
342
|
+
description: 'VPC tags differ',
|
|
343
|
+
resolution: 'Update tags',
|
|
344
|
+
canAutoFix: true,
|
|
345
|
+
propertyMismatch: mismatch,
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
const json = issue.toJSON();
|
|
349
|
+
|
|
350
|
+
expect(json.propertyMismatch).toBeDefined();
|
|
351
|
+
expect(json.propertyMismatch.propertyPath).toBe('Properties.Tags');
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
describe('static factory methods', () => {
|
|
356
|
+
it('should create orphaned resource issue', () => {
|
|
357
|
+
const issue = Issue.orphanedResource({
|
|
358
|
+
resourceType: 'AWS::RDS::DBCluster',
|
|
359
|
+
resourceId: 'my-cluster',
|
|
360
|
+
description: 'Cluster not in stack',
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
expect(issue.type).toBe('ORPHANED_RESOURCE');
|
|
364
|
+
expect(issue.severity).toBe('critical');
|
|
365
|
+
expect(issue.canAutoFix).toBe(true);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it('should create missing resource issue', () => {
|
|
369
|
+
const issue = Issue.missingResource({
|
|
370
|
+
resourceType: 'AWS::KMS::Key',
|
|
371
|
+
resourceId: 'FriggKMSKey',
|
|
372
|
+
description: 'Key not in AWS',
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
expect(issue.type).toBe('MISSING_RESOURCE');
|
|
376
|
+
expect(issue.severity).toBe('critical');
|
|
377
|
+
expect(issue.canAutoFix).toBe(false);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it('should create property mismatch issue', () => {
|
|
381
|
+
const mismatch = new PropertyMismatch({
|
|
382
|
+
propertyPath: 'Properties.Tags',
|
|
383
|
+
expectedValue: ['tag1'],
|
|
384
|
+
actualValue: ['tag2'],
|
|
385
|
+
mutability: PropertyMutability.MUTABLE,
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
const issue = Issue.propertyMismatch({
|
|
389
|
+
resourceType: 'AWS::EC2::VPC',
|
|
390
|
+
resourceId: 'vpc-123',
|
|
391
|
+
mismatch,
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
expect(issue.type).toBe('PROPERTY_MISMATCH');
|
|
395
|
+
expect(issue.severity).toBe('warning'); // Default for mutable
|
|
396
|
+
expect(issue.propertyMismatch).toBe(mismatch);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it('should create property mismatch with critical severity for immutable', () => {
|
|
400
|
+
const mismatch = new PropertyMismatch({
|
|
401
|
+
propertyPath: 'Properties.BucketName',
|
|
402
|
+
expectedValue: 'bucket-v2',
|
|
403
|
+
actualValue: 'bucket-v1',
|
|
404
|
+
mutability: PropertyMutability.IMMUTABLE,
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
const issue = Issue.propertyMismatch({
|
|
408
|
+
resourceType: 'AWS::S3::Bucket',
|
|
409
|
+
resourceId: 'my-bucket',
|
|
410
|
+
mismatch,
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
expect(issue.severity).toBe('critical'); // Critical for immutable
|
|
414
|
+
expect(issue.canAutoFix).toBe(false); // Can't auto-fix immutable
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
});
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resource Entity
|
|
3
|
+
*
|
|
4
|
+
* Represents a CloudFormation resource with its current state and any detected issues
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const ResourceState = require('../value-objects/resource-state');
|
|
8
|
+
|
|
9
|
+
class Resource {
|
|
10
|
+
/**
|
|
11
|
+
* Create a new Resource
|
|
12
|
+
*
|
|
13
|
+
* @param {Object} params
|
|
14
|
+
* @param {string|null} params.logicalId - CloudFormation logical ID (null for orphaned resources)
|
|
15
|
+
* @param {string} params.physicalId - Physical resource ID in cloud provider
|
|
16
|
+
* @param {string} params.resourceType - CloudFormation resource type (e.g., AWS::EC2::VPC)
|
|
17
|
+
* @param {ResourceState} params.state - Resource state
|
|
18
|
+
* @param {Object} [params.properties={}] - Resource properties
|
|
19
|
+
* @param {Issue[]} [params.issues=[]] - Detected issues with this resource
|
|
20
|
+
*/
|
|
21
|
+
constructor({
|
|
22
|
+
logicalId = null,
|
|
23
|
+
physicalId,
|
|
24
|
+
resourceType,
|
|
25
|
+
state,
|
|
26
|
+
properties = {},
|
|
27
|
+
issues = [],
|
|
28
|
+
}) {
|
|
29
|
+
// Validate required fields
|
|
30
|
+
if (physicalId === undefined || physicalId === null) {
|
|
31
|
+
throw new Error('physicalId is required');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!resourceType) {
|
|
35
|
+
throw new Error('resourceType is required');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!state) {
|
|
39
|
+
throw new Error('state is required');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!(state instanceof ResourceState)) {
|
|
43
|
+
throw new Error('state must be a ResourceState instance');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
this.logicalId = logicalId;
|
|
47
|
+
this.physicalId = physicalId;
|
|
48
|
+
this.resourceType = resourceType;
|
|
49
|
+
this.state = state;
|
|
50
|
+
this.properties = properties;
|
|
51
|
+
this.issues = [...issues]; // Copy array to avoid mutations
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Check if resource is in CloudFormation stack
|
|
56
|
+
* @returns {boolean}
|
|
57
|
+
*/
|
|
58
|
+
isInStack() {
|
|
59
|
+
return this.state.isInStack();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Check if resource is orphaned (exists in cloud but not in stack)
|
|
64
|
+
* @returns {boolean}
|
|
65
|
+
*/
|
|
66
|
+
isOrphaned() {
|
|
67
|
+
return this.state.isOrphaned();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Check if resource is missing (exists in stack but not in cloud)
|
|
72
|
+
* @returns {boolean}
|
|
73
|
+
*/
|
|
74
|
+
isMissing() {
|
|
75
|
+
return this.state.isMissing();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Check if resource has drifted (properties differ)
|
|
80
|
+
* @returns {boolean}
|
|
81
|
+
*/
|
|
82
|
+
isDrifted() {
|
|
83
|
+
return this.state.isDrifted();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Add an issue to this resource
|
|
88
|
+
* @param {Issue} issue
|
|
89
|
+
*/
|
|
90
|
+
addIssue(issue) {
|
|
91
|
+
this.issues.push(issue);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Check if resource has any issues
|
|
96
|
+
* @returns {boolean}
|
|
97
|
+
*/
|
|
98
|
+
hasIssues() {
|
|
99
|
+
return this.issues.length > 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Check if resource has critical issues
|
|
104
|
+
* @returns {boolean}
|
|
105
|
+
*/
|
|
106
|
+
hasCriticalIssues() {
|
|
107
|
+
return this.issues.some(issue => issue.isCritical());
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Get all critical issues
|
|
112
|
+
* @returns {Issue[]}
|
|
113
|
+
*/
|
|
114
|
+
getCriticalIssues() {
|
|
115
|
+
return this.issues.filter(issue => issue.isCritical());
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Check if resource is healthy (no issues)
|
|
120
|
+
* @returns {boolean}
|
|
121
|
+
*/
|
|
122
|
+
isHealthy() {
|
|
123
|
+
return !this.hasIssues();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Get resource identifier (logical ID or physical ID)
|
|
128
|
+
* @returns {string}
|
|
129
|
+
*/
|
|
130
|
+
getIdentifier() {
|
|
131
|
+
return this.logicalId || this.physicalId;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Get string representation
|
|
136
|
+
* @returns {string}
|
|
137
|
+
*/
|
|
138
|
+
toString() {
|
|
139
|
+
return `Resource: ${this.resourceType} [${this.state.toString()}] - LogicalId: ${this.logicalId}, PhysicalId: ${this.physicalId}`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Serialize to JSON
|
|
144
|
+
* @returns {Object}
|
|
145
|
+
*/
|
|
146
|
+
toJSON() {
|
|
147
|
+
return {
|
|
148
|
+
logicalId: this.logicalId,
|
|
149
|
+
physicalId: this.physicalId,
|
|
150
|
+
resourceType: this.resourceType,
|
|
151
|
+
state: this.state.toString(),
|
|
152
|
+
properties: this.properties,
|
|
153
|
+
issues: this.issues.map(issue => issue.toJSON()),
|
|
154
|
+
isHealthy: this.isHealthy(),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
module.exports = Resource;
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for Resource Entity
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const Resource = require('./resource');
|
|
6
|
+
const ResourceState = require('../value-objects/resource-state');
|
|
7
|
+
const Issue = require('./issue');
|
|
8
|
+
const PropertyMismatch = require('./property-mismatch');
|
|
9
|
+
const PropertyMutability = require('../value-objects/property-mutability');
|
|
10
|
+
|
|
11
|
+
describe('Resource', () => {
|
|
12
|
+
describe('constructor', () => {
|
|
13
|
+
it('should create a resource in stack', () => {
|
|
14
|
+
const resource = new Resource({
|
|
15
|
+
logicalId: 'ProductionVPC',
|
|
16
|
+
physicalId: 'vpc-0abc123def456',
|
|
17
|
+
resourceType: 'AWS::EC2::VPC',
|
|
18
|
+
state: ResourceState.IN_STACK,
|
|
19
|
+
properties: {
|
|
20
|
+
CidrBlock: '10.0.0.0/16',
|
|
21
|
+
Tags: [{ Key: 'Environment', Value: 'production' }],
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
expect(resource.logicalId).toBe('ProductionVPC');
|
|
26
|
+
expect(resource.physicalId).toBe('vpc-0abc123def456');
|
|
27
|
+
expect(resource.resourceType).toBe('AWS::EC2::VPC');
|
|
28
|
+
expect(resource.state.value).toBe('IN_STACK');
|
|
29
|
+
expect(resource.properties.CidrBlock).toBe('10.0.0.0/16');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('should create an orphaned resource', () => {
|
|
33
|
+
const resource = new Resource({
|
|
34
|
+
logicalId: null,
|
|
35
|
+
physicalId: 'my-app-prod-aurora',
|
|
36
|
+
resourceType: 'AWS::RDS::DBCluster',
|
|
37
|
+
state: ResourceState.ORPHANED,
|
|
38
|
+
properties: {
|
|
39
|
+
Engine: 'aurora-postgresql',
|
|
40
|
+
EngineVersion: '13.7',
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
expect(resource.logicalId).toBeNull();
|
|
45
|
+
expect(resource.physicalId).toBe('my-app-prod-aurora');
|
|
46
|
+
expect(resource.state.value).toBe('ORPHANED');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should require physicalId', () => {
|
|
50
|
+
expect(() => {
|
|
51
|
+
new Resource({
|
|
52
|
+
logicalId: 'MyResource',
|
|
53
|
+
resourceType: 'AWS::S3::Bucket',
|
|
54
|
+
state: ResourceState.IN_STACK,
|
|
55
|
+
});
|
|
56
|
+
}).toThrow('physicalId is required');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should require resourceType', () => {
|
|
60
|
+
expect(() => {
|
|
61
|
+
new Resource({
|
|
62
|
+
logicalId: 'MyResource',
|
|
63
|
+
physicalId: 'my-bucket',
|
|
64
|
+
state: ResourceState.IN_STACK,
|
|
65
|
+
});
|
|
66
|
+
}).toThrow('resourceType is required');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should require state', () => {
|
|
70
|
+
expect(() => {
|
|
71
|
+
new Resource({
|
|
72
|
+
logicalId: 'MyResource',
|
|
73
|
+
physicalId: 'my-bucket',
|
|
74
|
+
resourceType: 'AWS::S3::Bucket',
|
|
75
|
+
});
|
|
76
|
+
}).toThrow('state is required');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('should validate state is ResourceState instance', () => {
|
|
80
|
+
expect(() => {
|
|
81
|
+
new Resource({
|
|
82
|
+
logicalId: 'MyResource',
|
|
83
|
+
physicalId: 'my-bucket',
|
|
84
|
+
resourceType: 'AWS::S3::Bucket',
|
|
85
|
+
state: 'IN_STACK', // String instead of ResourceState
|
|
86
|
+
});
|
|
87
|
+
}).toThrow('state must be a ResourceState instance');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('should initialize empty issues array', () => {
|
|
91
|
+
const resource = new Resource({
|
|
92
|
+
logicalId: 'MyVPC',
|
|
93
|
+
physicalId: 'vpc-123',
|
|
94
|
+
resourceType: 'AWS::EC2::VPC',
|
|
95
|
+
state: ResourceState.IN_STACK,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
expect(resource.issues).toEqual([]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('should initialize with provided issues', () => {
|
|
102
|
+
const issue = Issue.orphanedResource({
|
|
103
|
+
resourceType: 'AWS::RDS::DBCluster',
|
|
104
|
+
resourceId: 'my-cluster',
|
|
105
|
+
description: 'Test',
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const resource = new Resource({
|
|
109
|
+
logicalId: null,
|
|
110
|
+
physicalId: 'my-cluster',
|
|
111
|
+
resourceType: 'AWS::RDS::DBCluster',
|
|
112
|
+
state: ResourceState.ORPHANED,
|
|
113
|
+
issues: [issue],
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
expect(resource.issues).toHaveLength(1);
|
|
117
|
+
expect(resource.issues[0]).toBe(issue);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('should default properties to empty object', () => {
|
|
121
|
+
const resource = new Resource({
|
|
122
|
+
logicalId: 'MyVPC',
|
|
123
|
+
physicalId: 'vpc-123',
|
|
124
|
+
resourceType: 'AWS::EC2::VPC',
|
|
125
|
+
state: ResourceState.IN_STACK,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
expect(resource.properties).toEqual({});
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('state checks', () => {
|
|
133
|
+
it('should check if resource is in stack', () => {
|
|
134
|
+
const resource = new Resource({
|
|
135
|
+
logicalId: 'MyVPC',
|
|
136
|
+
physicalId: 'vpc-123',
|
|
137
|
+
resourceType: 'AWS::EC2::VPC',
|
|
138
|
+
state: ResourceState.IN_STACK,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
expect(resource.isInStack()).toBe(true);
|
|
142
|
+
expect(resource.isOrphaned()).toBe(false);
|
|
143
|
+
expect(resource.isMissing()).toBe(false);
|
|
144
|
+
expect(resource.isDrifted()).toBe(false);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('should check if resource is orphaned', () => {
|
|
148
|
+
const resource = new Resource({
|
|
149
|
+
logicalId: null,
|
|
150
|
+
physicalId: 'my-cluster',
|
|
151
|
+
resourceType: 'AWS::RDS::DBCluster',
|
|
152
|
+
state: ResourceState.ORPHANED,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
expect(resource.isInStack()).toBe(false);
|
|
156
|
+
expect(resource.isOrphaned()).toBe(true);
|
|
157
|
+
expect(resource.isMissing()).toBe(false);
|
|
158
|
+
expect(resource.isDrifted()).toBe(false);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('should check if resource is missing', () => {
|
|
162
|
+
const resource = new Resource({
|
|
163
|
+
logicalId: 'FriggKMSKey',
|
|
164
|
+
physicalId: 'key-id-that-does-not-exist',
|
|
165
|
+
resourceType: 'AWS::KMS::Key',
|
|
166
|
+
state: ResourceState.MISSING,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
expect(resource.isInStack()).toBe(false);
|
|
170
|
+
expect(resource.isOrphaned()).toBe(false);
|
|
171
|
+
expect(resource.isMissing()).toBe(true);
|
|
172
|
+
expect(resource.isDrifted()).toBe(false);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('should check if resource is drifted', () => {
|
|
176
|
+
const resource = new Resource({
|
|
177
|
+
logicalId: 'MyVPC',
|
|
178
|
+
physicalId: 'vpc-123',
|
|
179
|
+
resourceType: 'AWS::EC2::VPC',
|
|
180
|
+
state: ResourceState.DRIFTED,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
expect(resource.isInStack()).toBe(false);
|
|
184
|
+
expect(resource.isOrphaned()).toBe(false);
|
|
185
|
+
expect(resource.isMissing()).toBe(false);
|
|
186
|
+
expect(resource.isDrifted()).toBe(true);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe('issue management', () => {
|
|
191
|
+
it('should add an issue', () => {
|
|
192
|
+
const resource = new Resource({
|
|
193
|
+
logicalId: 'MyVPC',
|
|
194
|
+
physicalId: 'vpc-123',
|
|
195
|
+
resourceType: 'AWS::EC2::VPC',
|
|
196
|
+
state: ResourceState.IN_STACK,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const issue = Issue.propertyMismatch({
|
|
200
|
+
resourceType: 'AWS::EC2::VPC',
|
|
201
|
+
resourceId: 'vpc-123',
|
|
202
|
+
mismatch: new PropertyMismatch({
|
|
203
|
+
propertyPath: 'Properties.Tags',
|
|
204
|
+
expectedValue: ['tag1'],
|
|
205
|
+
actualValue: ['tag2'],
|
|
206
|
+
mutability: PropertyMutability.MUTABLE,
|
|
207
|
+
}),
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
resource.addIssue(issue);
|
|
211
|
+
|
|
212
|
+
expect(resource.issues).toHaveLength(1);
|
|
213
|
+
expect(resource.issues[0]).toBe(issue);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('should check if resource has issues', () => {
|
|
217
|
+
const resource = new Resource({
|
|
218
|
+
logicalId: 'MyVPC',
|
|
219
|
+
physicalId: 'vpc-123',
|
|
220
|
+
resourceType: 'AWS::EC2::VPC',
|
|
221
|
+
state: ResourceState.IN_STACK,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
expect(resource.hasIssues()).toBe(false);
|
|
225
|
+
|
|
226
|
+
const issue = Issue.orphanedResource({
|
|
227
|
+
resourceType: 'AWS::EC2::VPC',
|
|
228
|
+
resourceId: 'vpc-123',
|
|
229
|
+
description: 'Test',
|
|
230
|
+
});
|
|
231
|
+
resource.addIssue(issue);
|
|
232
|
+
|
|
233
|
+
expect(resource.hasIssues()).toBe(true);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('should check if resource has critical issues', () => {
|
|
237
|
+
const resource = new Resource({
|
|
238
|
+
logicalId: 'MyVPC',
|
|
239
|
+
physicalId: 'vpc-123',
|
|
240
|
+
resourceType: 'AWS::EC2::VPC',
|
|
241
|
+
state: ResourceState.IN_STACK,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
expect(resource.hasCriticalIssues()).toBe(false);
|
|
245
|
+
|
|
246
|
+
// Add warning issue
|
|
247
|
+
const warningIssue = new Issue({
|
|
248
|
+
type: 'PROPERTY_MISMATCH',
|
|
249
|
+
severity: 'warning',
|
|
250
|
+
resourceType: 'AWS::EC2::VPC',
|
|
251
|
+
resourceId: 'vpc-123',
|
|
252
|
+
description: 'Test warning',
|
|
253
|
+
});
|
|
254
|
+
resource.addIssue(warningIssue);
|
|
255
|
+
expect(resource.hasCriticalIssues()).toBe(false);
|
|
256
|
+
|
|
257
|
+
// Add critical issue
|
|
258
|
+
const criticalIssue = Issue.orphanedResource({
|
|
259
|
+
resourceType: 'AWS::EC2::VPC',
|
|
260
|
+
resourceId: 'vpc-123',
|
|
261
|
+
description: 'Test critical',
|
|
262
|
+
});
|
|
263
|
+
resource.addIssue(criticalIssue);
|
|
264
|
+
expect(resource.hasCriticalIssues()).toBe(true);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('should get critical issues', () => {
|
|
268
|
+
const resource = new Resource({
|
|
269
|
+
logicalId: 'MyVPC',
|
|
270
|
+
physicalId: 'vpc-123',
|
|
271
|
+
resourceType: 'AWS::EC2::VPC',
|
|
272
|
+
state: ResourceState.IN_STACK,
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
const warningIssue = new Issue({
|
|
276
|
+
type: 'PROPERTY_MISMATCH',
|
|
277
|
+
severity: 'warning',
|
|
278
|
+
resourceType: 'AWS::EC2::VPC',
|
|
279
|
+
resourceId: 'vpc-123',
|
|
280
|
+
description: 'Warning',
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const criticalIssue1 = Issue.orphanedResource({
|
|
284
|
+
resourceType: 'AWS::EC2::VPC',
|
|
285
|
+
resourceId: 'vpc-123',
|
|
286
|
+
description: 'Critical 1',
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
const criticalIssue2 = Issue.missingResource({
|
|
290
|
+
resourceType: 'AWS::EC2::VPC',
|
|
291
|
+
resourceId: 'vpc-123',
|
|
292
|
+
description: 'Critical 2',
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
resource.addIssue(warningIssue);
|
|
296
|
+
resource.addIssue(criticalIssue1);
|
|
297
|
+
resource.addIssue(criticalIssue2);
|
|
298
|
+
|
|
299
|
+
const criticalIssues = resource.getCriticalIssues();
|
|
300
|
+
expect(criticalIssues).toHaveLength(2);
|
|
301
|
+
expect(criticalIssues).toContain(criticalIssue1);
|
|
302
|
+
expect(criticalIssues).toContain(criticalIssue2);
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
describe('isHealthy', () => {
|
|
307
|
+
it('should be healthy with no issues', () => {
|
|
308
|
+
const resource = new Resource({
|
|
309
|
+
logicalId: 'MyVPC',
|
|
310
|
+
physicalId: 'vpc-123',
|
|
311
|
+
resourceType: 'AWS::EC2::VPC',
|
|
312
|
+
state: ResourceState.IN_STACK,
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
expect(resource.isHealthy()).toBe(true);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('should not be healthy with issues', () => {
|
|
319
|
+
const resource = new Resource({
|
|
320
|
+
logicalId: 'MyVPC',
|
|
321
|
+
physicalId: 'vpc-123',
|
|
322
|
+
resourceType: 'AWS::EC2::VPC',
|
|
323
|
+
state: ResourceState.IN_STACK,
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
const issue = Issue.propertyMismatch({
|
|
327
|
+
resourceType: 'AWS::EC2::VPC',
|
|
328
|
+
resourceId: 'vpc-123',
|
|
329
|
+
mismatch: new PropertyMismatch({
|
|
330
|
+
propertyPath: 'Properties.Tags',
|
|
331
|
+
expectedValue: ['tag1'],
|
|
332
|
+
actualValue: ['tag2'],
|
|
333
|
+
mutability: PropertyMutability.MUTABLE,
|
|
334
|
+
}),
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
resource.addIssue(issue);
|
|
338
|
+
expect(resource.isHealthy()).toBe(false);
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
describe('getIdentifier', () => {
|
|
343
|
+
it('should return logical ID if present', () => {
|
|
344
|
+
const resource = new Resource({
|
|
345
|
+
logicalId: 'ProductionVPC',
|
|
346
|
+
physicalId: 'vpc-123',
|
|
347
|
+
resourceType: 'AWS::EC2::VPC',
|
|
348
|
+
state: ResourceState.IN_STACK,
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
expect(resource.getIdentifier()).toBe('ProductionVPC');
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('should return physical ID if no logical ID', () => {
|
|
355
|
+
const resource = new Resource({
|
|
356
|
+
logicalId: null,
|
|
357
|
+
physicalId: 'vpc-123',
|
|
358
|
+
resourceType: 'AWS::EC2::VPC',
|
|
359
|
+
state: ResourceState.ORPHANED,
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
expect(resource.getIdentifier()).toBe('vpc-123');
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
describe('toString', () => {
|
|
367
|
+
it('should return string representation', () => {
|
|
368
|
+
const resource = new Resource({
|
|
369
|
+
logicalId: 'ProductionVPC',
|
|
370
|
+
physicalId: 'vpc-123',
|
|
371
|
+
resourceType: 'AWS::EC2::VPC',
|
|
372
|
+
state: ResourceState.IN_STACK,
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
const str = resource.toString();
|
|
376
|
+
expect(str).toContain('AWS::EC2::VPC');
|
|
377
|
+
expect(str).toContain('ProductionVPC');
|
|
378
|
+
expect(str).toContain('vpc-123');
|
|
379
|
+
expect(str).toContain('IN_STACK');
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
describe('toJSON', () => {
|
|
384
|
+
it('should serialize to JSON', () => {
|
|
385
|
+
const resource = new Resource({
|
|
386
|
+
logicalId: 'ProductionVPC',
|
|
387
|
+
physicalId: 'vpc-123',
|
|
388
|
+
resourceType: 'AWS::EC2::VPC',
|
|
389
|
+
state: ResourceState.IN_STACK,
|
|
390
|
+
properties: {
|
|
391
|
+
CidrBlock: '10.0.0.0/16',
|
|
392
|
+
},
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
const json = resource.toJSON();
|
|
396
|
+
|
|
397
|
+
expect(json).toEqual({
|
|
398
|
+
logicalId: 'ProductionVPC',
|
|
399
|
+
physicalId: 'vpc-123',
|
|
400
|
+
resourceType: 'AWS::EC2::VPC',
|
|
401
|
+
state: 'IN_STACK',
|
|
402
|
+
properties: {
|
|
403
|
+
CidrBlock: '10.0.0.0/16',
|
|
404
|
+
},
|
|
405
|
+
issues: [],
|
|
406
|
+
isHealthy: true,
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it('should include issues in JSON', () => {
|
|
411
|
+
const resource = new Resource({
|
|
412
|
+
logicalId: 'MyVPC',
|
|
413
|
+
physicalId: 'vpc-123',
|
|
414
|
+
resourceType: 'AWS::EC2::VPC',
|
|
415
|
+
state: ResourceState.DRIFTED,
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
const issue = Issue.orphanedResource({
|
|
419
|
+
resourceType: 'AWS::EC2::VPC',
|
|
420
|
+
resourceId: 'vpc-123',
|
|
421
|
+
description: 'Test',
|
|
422
|
+
});
|
|
423
|
+
resource.addIssue(issue);
|
|
424
|
+
|
|
425
|
+
const json = resource.toJSON();
|
|
426
|
+
|
|
427
|
+
expect(json.issues).toHaveLength(1);
|
|
428
|
+
expect(json.issues[0].type).toBe('ORPHANED_RESOURCE');
|
|
429
|
+
expect(json.isHealthy).toBe(false);
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
});
|
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.82ba370.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.82ba370.0",
|
|
15
|
+
"@friggframework/test": "2.0.0--canary.474.82ba370.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.82ba370.0",
|
|
38
|
+
"@friggframework/prettier-config": "2.0.0--canary.474.82ba370.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": "82ba370ccfb7248873a75e36760fbd30401e1027"
|
|
74
74
|
}
|