puls-dev 0.3.5 → 0.3.7
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 +165 -54
- package/dist/bin/install-shell.js +5 -6
- package/dist/bin/puls.js +10 -3
- package/dist/core/config.d.ts +4 -0
- package/dist/core/decorators.d.ts +4 -0
- package/dist/core/decorators.js +2 -0
- package/dist/core/parallel.test.js +4 -3
- package/dist/core/resource.d.ts +2 -1
- package/dist/core/resource.js +4 -2
- package/dist/core/stack.d.ts +4 -0
- package/dist/core/stack.js +8 -8
- package/dist/providers/aws/acm.test.d.ts +1 -0
- package/dist/providers/aws/acm.test.js +167 -0
- package/dist/providers/aws/cloudfront.test.d.ts +1 -0
- package/dist/providers/aws/cloudfront.test.js +170 -0
- package/dist/providers/aws/fargate.test.d.ts +1 -0
- package/dist/providers/aws/fargate.test.js +244 -0
- package/dist/providers/aws/rds.test.d.ts +1 -0
- package/dist/providers/aws/rds.test.js +219 -0
- package/dist/providers/aws/sqs.test.d.ts +1 -0
- package/dist/providers/aws/sqs.test.js +181 -0
- package/dist/providers/cloudflare/api.d.ts +15 -0
- package/dist/providers/cloudflare/api.js +199 -0
- package/dist/providers/cloudflare/index.d.ts +14 -0
- package/dist/providers/cloudflare/index.js +19 -0
- package/dist/providers/cloudflare/kv.d.ts +20 -0
- package/dist/providers/cloudflare/kv.js +69 -0
- package/dist/providers/cloudflare/kv.test.d.ts +1 -0
- package/dist/providers/cloudflare/kv.test.js +134 -0
- package/dist/providers/cloudflare/r2.d.ts +14 -0
- package/dist/providers/cloudflare/r2.js +57 -0
- package/dist/providers/cloudflare/r2.test.d.ts +1 -0
- package/dist/providers/cloudflare/r2.test.js +132 -0
- package/dist/providers/cloudflare/worker.d.ts +28 -0
- package/dist/providers/cloudflare/worker.js +172 -0
- package/dist/providers/cloudflare/worker.test.d.ts +1 -0
- package/dist/providers/cloudflare/worker.test.js +220 -0
- package/dist/providers/cloudflare/zone.d.ts +42 -0
- package/dist/providers/cloudflare/zone.js +280 -0
- package/dist/providers/cloudflare/zone.test.d.ts +1 -0
- package/dist/providers/cloudflare/zone.test.js +284 -0
- package/dist/providers/firebase/auth.test.d.ts +1 -0
- package/dist/providers/firebase/auth.test.js +145 -0
- package/dist/providers/firebase/hosting.test.js +7 -6
- package/dist/providers/firebase/storage.test.d.ts +1 -0
- package/dist/providers/firebase/storage.test.js +148 -0
- package/package.json +6 -2
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { test, describe, beforeEach, afterEach, mock } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import { ACMClient } from '@aws-sdk/client-acm';
|
|
4
|
+
import { Route53Client } from '@aws-sdk/client-route-53';
|
|
5
|
+
import { ACMCertificateBuilder } from './acm.js';
|
|
6
|
+
import { Config } from '../../core/config.js';
|
|
7
|
+
describe('ACMCertificateBuilder Unit Tests', () => {
|
|
8
|
+
let originalAcmSend;
|
|
9
|
+
let originalR53Send;
|
|
10
|
+
let acmCalls = [];
|
|
11
|
+
let r53Calls = [];
|
|
12
|
+
let mockAcmResponses = {};
|
|
13
|
+
let mockR53Responses = {};
|
|
14
|
+
function stubSend(calls, responses) {
|
|
15
|
+
return async function (command) {
|
|
16
|
+
const commandName = command.constructor.name;
|
|
17
|
+
calls.push({ commandName, input: command.input });
|
|
18
|
+
const handler = responses[commandName];
|
|
19
|
+
if (handler) {
|
|
20
|
+
if (typeof handler === 'function')
|
|
21
|
+
return handler(command.input);
|
|
22
|
+
if (handler instanceof Error)
|
|
23
|
+
throw handler;
|
|
24
|
+
return handler;
|
|
25
|
+
}
|
|
26
|
+
return {};
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
Config.set({ dryRun: false, providers: { aws: { region: 'us-east-1' } } });
|
|
31
|
+
acmCalls = [];
|
|
32
|
+
r53Calls = [];
|
|
33
|
+
mockAcmResponses = {};
|
|
34
|
+
mockR53Responses = {};
|
|
35
|
+
originalAcmSend = ACMClient.prototype.send;
|
|
36
|
+
originalR53Send = Route53Client.prototype.send;
|
|
37
|
+
ACMClient.prototype.send = stubSend(acmCalls, mockAcmResponses);
|
|
38
|
+
Route53Client.prototype.send = stubSend(r53Calls, mockR53Responses);
|
|
39
|
+
});
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
ACMClient.prototype.send = originalAcmSend;
|
|
42
|
+
Route53Client.prototype.send = originalR53Send;
|
|
43
|
+
mock.restoreAll();
|
|
44
|
+
});
|
|
45
|
+
test('returns null when no matching certificate is found', async () => {
|
|
46
|
+
mockAcmResponses['ListCertificatesCommand'] = { CertificateSummaryList: [] };
|
|
47
|
+
const builder = new ACMCertificateBuilder('example.com');
|
|
48
|
+
const result = await builder.discoveryPromise;
|
|
49
|
+
assert.strictEqual(result, null);
|
|
50
|
+
assert.strictEqual(acmCalls[0].commandName, 'ListCertificatesCommand');
|
|
51
|
+
});
|
|
52
|
+
test('discovers existing wildcard certificate by domain name', async () => {
|
|
53
|
+
mockAcmResponses['ListCertificatesCommand'] = {
|
|
54
|
+
CertificateSummaryList: [
|
|
55
|
+
{
|
|
56
|
+
DomainName: '*.example.com',
|
|
57
|
+
CertificateArn: 'arn:aws:acm:us-east-1:123:certificate/abc',
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
};
|
|
61
|
+
const builder = new ACMCertificateBuilder('example.com', true);
|
|
62
|
+
const result = await builder.discoveryPromise;
|
|
63
|
+
assert.ok(result);
|
|
64
|
+
assert.strictEqual(builder.resolvedArn, 'arn:aws:acm:us-east-1:123:certificate/abc');
|
|
65
|
+
});
|
|
66
|
+
test('returns existing cert without requesting a new one', async () => {
|
|
67
|
+
mockAcmResponses['ListCertificatesCommand'] = {
|
|
68
|
+
CertificateSummaryList: [
|
|
69
|
+
{
|
|
70
|
+
DomainName: '*.example.com',
|
|
71
|
+
CertificateArn: 'arn:aws:acm:us-east-1:123:certificate/abc',
|
|
72
|
+
Status: 'ISSUED',
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
};
|
|
76
|
+
const builder = new ACMCertificateBuilder('example.com');
|
|
77
|
+
const result = await builder.deploy();
|
|
78
|
+
assert.strictEqual(result.arn, 'arn:aws:acm:us-east-1:123:certificate/abc');
|
|
79
|
+
assert.ok(!acmCalls.some((c) => c.commandName === 'RequestCertificateCommand'));
|
|
80
|
+
});
|
|
81
|
+
test('performs dry-run without requesting a certificate', async () => {
|
|
82
|
+
Config.set({ dryRun: true, providers: { aws: { region: 'us-east-1' } } });
|
|
83
|
+
mockAcmResponses['ListCertificatesCommand'] = { CertificateSummaryList: [] };
|
|
84
|
+
const builder = new ACMCertificateBuilder('example.com');
|
|
85
|
+
const result = await builder.deploy();
|
|
86
|
+
assert.ok(result.arn.includes('DRYRUN'));
|
|
87
|
+
assert.ok(!acmCalls.some((c) => c.commandName === 'RequestCertificateCommand'));
|
|
88
|
+
});
|
|
89
|
+
test('requests wildcard cert and writes DNS validation records to Route53', async () => {
|
|
90
|
+
mockAcmResponses['ListCertificatesCommand'] = { CertificateSummaryList: [] };
|
|
91
|
+
mockAcmResponses['RequestCertificateCommand'] = {
|
|
92
|
+
CertificateArn: 'arn:aws:acm:us-east-1:123:certificate/new-cert',
|
|
93
|
+
};
|
|
94
|
+
let describeCallCount = 0;
|
|
95
|
+
mockAcmResponses['DescribeCertificateCommand'] = () => {
|
|
96
|
+
describeCallCount++;
|
|
97
|
+
if (describeCallCount === 1) {
|
|
98
|
+
// First call: validation records ready
|
|
99
|
+
return {
|
|
100
|
+
Certificate: {
|
|
101
|
+
Status: 'PENDING_VALIDATION',
|
|
102
|
+
DomainValidationOptions: [
|
|
103
|
+
{
|
|
104
|
+
ResourceRecord: {
|
|
105
|
+
Name: '_abc.example.com',
|
|
106
|
+
Value: '_xyz.acm-validations.aws.',
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
// Second call: ISSUED
|
|
114
|
+
return { Certificate: { Status: 'ISSUED' } };
|
|
115
|
+
};
|
|
116
|
+
mockR53Responses['ChangeResourceRecordSetsCommand'] = {};
|
|
117
|
+
// Fast-forward poll timers
|
|
118
|
+
mock.method(global, 'setTimeout', (fn) => fn());
|
|
119
|
+
const builder = new ACMCertificateBuilder('example.com', true);
|
|
120
|
+
builder.forZone({ zoneId: 'Z123456', zoneName: 'example.com' });
|
|
121
|
+
const result = await builder.deploy();
|
|
122
|
+
assert.strictEqual(result.arn, 'arn:aws:acm:us-east-1:123:certificate/new-cert');
|
|
123
|
+
const requestCall = acmCalls.find((c) => c.commandName === 'RequestCertificateCommand');
|
|
124
|
+
assert.ok(requestCall);
|
|
125
|
+
assert.strictEqual(requestCall.input.DomainName, '*.example.com');
|
|
126
|
+
assert.strictEqual(requestCall.input.ValidationMethod, 'DNS');
|
|
127
|
+
const r53Call = r53Calls.find((c) => c.commandName === 'ChangeResourceRecordSetsCommand');
|
|
128
|
+
assert.ok(r53Call);
|
|
129
|
+
assert.strictEqual(r53Call.input.HostedZoneId, 'Z123456');
|
|
130
|
+
const changes = r53Call.input.ChangeBatch.Changes;
|
|
131
|
+
assert.strictEqual(changes.length, 1);
|
|
132
|
+
assert.strictEqual(changes[0].Action, 'UPSERT');
|
|
133
|
+
assert.ok(changes[0].ResourceRecordSet.Name.includes('_abc'));
|
|
134
|
+
});
|
|
135
|
+
test('deduplicates validation CNAME records before writing to Route53', async () => {
|
|
136
|
+
mockAcmResponses['ListCertificatesCommand'] = { CertificateSummaryList: [] };
|
|
137
|
+
mockAcmResponses['RequestCertificateCommand'] = {
|
|
138
|
+
CertificateArn: 'arn:aws:acm:us-east-1:123:certificate/new-cert',
|
|
139
|
+
};
|
|
140
|
+
let describeCount = 0;
|
|
141
|
+
mockAcmResponses['DescribeCertificateCommand'] = () => {
|
|
142
|
+
describeCount++;
|
|
143
|
+
if (describeCount === 1) {
|
|
144
|
+
return {
|
|
145
|
+
Certificate: {
|
|
146
|
+
Status: 'PENDING_VALIDATION',
|
|
147
|
+
// wildcard + apex SANs produce the same CNAME - duplicated
|
|
148
|
+
DomainValidationOptions: [
|
|
149
|
+
{ ResourceRecord: { Name: '_dup.example.com', Value: '_val.aws.' } },
|
|
150
|
+
{ ResourceRecord: { Name: '_dup.example.com', Value: '_val.aws.' } },
|
|
151
|
+
],
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
return { Certificate: { Status: 'ISSUED' } };
|
|
156
|
+
};
|
|
157
|
+
mockR53Responses['ChangeResourceRecordSetsCommand'] = {};
|
|
158
|
+
mock.method(global, 'setTimeout', (fn) => fn());
|
|
159
|
+
const builder = new ACMCertificateBuilder('example.com', true);
|
|
160
|
+
builder.forZone({ zoneId: 'Z123456', zoneName: 'example.com' });
|
|
161
|
+
await builder.deploy();
|
|
162
|
+
const r53Call = r53Calls.find((c) => c.commandName === 'ChangeResourceRecordSetsCommand');
|
|
163
|
+
assert.ok(r53Call);
|
|
164
|
+
// Should be deduplicated to 1 record
|
|
165
|
+
assert.strictEqual(r53Call.input.ChangeBatch.Changes.length, 1);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { test, describe, beforeEach, afterEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import { CloudFrontClient } from '@aws-sdk/client-cloudfront';
|
|
4
|
+
import { CloudFrontBuilder } from './cloudfront.js';
|
|
5
|
+
import { Config } from '../../core/config.js';
|
|
6
|
+
describe('CloudFrontBuilder Unit Tests', () => {
|
|
7
|
+
let originalSend;
|
|
8
|
+
let calls = [];
|
|
9
|
+
let mockResponses = {};
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
Config.set({ dryRun: false, providers: { aws: { region: 'us-east-1' } } });
|
|
12
|
+
calls = [];
|
|
13
|
+
mockResponses = {};
|
|
14
|
+
originalSend = CloudFrontClient.prototype.send;
|
|
15
|
+
CloudFrontClient.prototype.send = async function (command) {
|
|
16
|
+
const commandName = command.constructor.name;
|
|
17
|
+
calls.push({ commandName, input: command.input });
|
|
18
|
+
const handler = mockResponses[commandName];
|
|
19
|
+
if (handler) {
|
|
20
|
+
if (typeof handler === 'function')
|
|
21
|
+
return handler(command.input);
|
|
22
|
+
if (handler instanceof Error)
|
|
23
|
+
throw handler;
|
|
24
|
+
return handler;
|
|
25
|
+
}
|
|
26
|
+
return {};
|
|
27
|
+
};
|
|
28
|
+
});
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
CloudFrontClient.prototype.send = originalSend;
|
|
31
|
+
});
|
|
32
|
+
test('returns null when distribution is not found during discovery', async () => {
|
|
33
|
+
mockResponses['ListDistributionsCommand'] = { DistributionList: { Items: [] } };
|
|
34
|
+
const builder = new CloudFrontBuilder('my-cdn');
|
|
35
|
+
const result = await builder.discoveryPromise;
|
|
36
|
+
assert.strictEqual(result, null);
|
|
37
|
+
assert.strictEqual(calls[0].commandName, 'ListDistributionsCommand');
|
|
38
|
+
});
|
|
39
|
+
test('discovers existing distribution by comment name', async () => {
|
|
40
|
+
mockResponses['ListDistributionsCommand'] = {
|
|
41
|
+
DistributionList: {
|
|
42
|
+
Items: [
|
|
43
|
+
{ Comment: 'my-cdn', Id: 'd-abc123', ARN: 'arn:aws:cloudfront::123:distribution/d-abc123' },
|
|
44
|
+
],
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
const builder = new CloudFrontBuilder('my-cdn');
|
|
48
|
+
const result = await builder.discoveryPromise;
|
|
49
|
+
assert.ok(result);
|
|
50
|
+
assert.strictEqual(result.Comment, 'my-cdn');
|
|
51
|
+
assert.strictEqual(builder.resolvedId, 'd-abc123');
|
|
52
|
+
assert.strictEqual(builder.resolvedArn, 'arn:aws:cloudfront::123:distribution/d-abc123');
|
|
53
|
+
});
|
|
54
|
+
test('performs dry-run without creating the distribution', async () => {
|
|
55
|
+
Config.set({ dryRun: true, providers: { aws: { region: 'us-east-1' } } });
|
|
56
|
+
mockResponses['ListDistributionsCommand'] = { DistributionList: { Items: [] } };
|
|
57
|
+
const builder = new CloudFrontBuilder('my-cdn');
|
|
58
|
+
builder.origin('origin.example.com').dns('cdn.example.com');
|
|
59
|
+
const result = await builder.deploy();
|
|
60
|
+
assert.ok(result);
|
|
61
|
+
assert.strictEqual(result.name, 'my-cdn');
|
|
62
|
+
assert.strictEqual(result.id, 'PENDING');
|
|
63
|
+
const writeCalls = calls.filter((c) => c.commandName !== 'ListDistributionsCommand');
|
|
64
|
+
assert.strictEqual(writeCalls.length, 0);
|
|
65
|
+
});
|
|
66
|
+
test('creates new distribution from scratch when not found', async () => {
|
|
67
|
+
mockResponses['ListDistributionsCommand'] = { DistributionList: { Items: [] } };
|
|
68
|
+
mockResponses['CreateDistributionCommand'] = {
|
|
69
|
+
Distribution: {
|
|
70
|
+
Id: 'd-new123',
|
|
71
|
+
ARN: 'arn:aws:cloudfront::123:distribution/d-new123',
|
|
72
|
+
DomainName: 'abcdef.cloudfront.net',
|
|
73
|
+
Status: 'Deployed',
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
mockResponses['GetDistributionCommand'] = {
|
|
77
|
+
Distribution: { Status: 'Deployed' },
|
|
78
|
+
};
|
|
79
|
+
const builder = new CloudFrontBuilder('my-cdn');
|
|
80
|
+
builder.origin('origin.example.com');
|
|
81
|
+
const result = await builder.deploy();
|
|
82
|
+
assert.ok(result);
|
|
83
|
+
assert.strictEqual(result.id, 'd-new123');
|
|
84
|
+
const createCall = calls.find((c) => c.commandName === 'CreateDistributionCommand');
|
|
85
|
+
assert.ok(createCall);
|
|
86
|
+
assert.strictEqual(createCall.input.DistributionConfig.Comment, 'my-cdn');
|
|
87
|
+
});
|
|
88
|
+
test('returns existing distribution without re-creating it', async () => {
|
|
89
|
+
mockResponses['ListDistributionsCommand'] = {
|
|
90
|
+
DistributionList: {
|
|
91
|
+
Items: [{ Comment: 'my-cdn', Id: 'd-abc', ARN: 'arn:aws:cloudfront::123:distribution/d-abc' }],
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
const builder = new CloudFrontBuilder('my-cdn');
|
|
95
|
+
const result = await builder.deploy();
|
|
96
|
+
assert.ok(result);
|
|
97
|
+
assert.strictEqual(result.id, 'd-abc');
|
|
98
|
+
assert.ok(!calls.some((c) => c.commandName === 'CreateDistributionCommand'));
|
|
99
|
+
});
|
|
100
|
+
test('runs cache invalidation on an existing distribution', async () => {
|
|
101
|
+
mockResponses['ListDistributionsCommand'] = {
|
|
102
|
+
DistributionList: {
|
|
103
|
+
Items: [{ Comment: 'my-cdn', Id: 'd-abc', ARN: 'arn:aws:cloudfront::123:distribution/d-abc' }],
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
mockResponses['CreateInvalidationCommand'] = { Invalidation: { Id: 'inv-1' } };
|
|
107
|
+
const builder = new CloudFrontBuilder('my-cdn');
|
|
108
|
+
builder.invalidate(['/*', '/index.html']);
|
|
109
|
+
await builder.deploy();
|
|
110
|
+
const invCall = calls.find((c) => c.commandName === 'CreateInvalidationCommand');
|
|
111
|
+
assert.ok(invCall);
|
|
112
|
+
assert.deepStrictEqual(invCall.input.InvalidationBatch.Paths.Items, ['/*', '/index.html']);
|
|
113
|
+
assert.strictEqual(invCall.input.InvalidationBatch.Paths.Quantity, 2);
|
|
114
|
+
});
|
|
115
|
+
test('dry-run prints invalidation plan without executing it', async () => {
|
|
116
|
+
Config.set({ dryRun: true, providers: { aws: { region: 'us-east-1' } } });
|
|
117
|
+
mockResponses['ListDistributionsCommand'] = {
|
|
118
|
+
DistributionList: {
|
|
119
|
+
Items: [{ Comment: 'my-cdn', Id: 'd-abc', ARN: 'arn:aws:cloudfront::123:distribution/d-abc' }],
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
const builder = new CloudFrontBuilder('my-cdn');
|
|
123
|
+
builder.invalidate(['/assets/*']);
|
|
124
|
+
await builder.deploy();
|
|
125
|
+
assert.ok(!calls.some((c) => c.commandName === 'CreateInvalidationCommand'));
|
|
126
|
+
});
|
|
127
|
+
test('clones config from a reference distribution', async () => {
|
|
128
|
+
mockResponses['ListDistributionsCommand'] = { DistributionList: { Items: [] } };
|
|
129
|
+
mockResponses['GetDistributionConfigCommand'] = {
|
|
130
|
+
DistributionConfig: {
|
|
131
|
+
Comment: 'source-cdn',
|
|
132
|
+
Enabled: true,
|
|
133
|
+
Origins: { Quantity: 1, Items: [{ Id: 'orig-1', DomainName: 'source.example.com' }] },
|
|
134
|
+
DefaultCacheBehavior: {
|
|
135
|
+
TargetOriginId: 'orig-1',
|
|
136
|
+
ViewerProtocolPolicy: 'redirect-to-https',
|
|
137
|
+
CachePolicyId: 'cache-policy-id',
|
|
138
|
+
ForwardedValues: { QueryString: false, Cookies: { Forward: 'none' } },
|
|
139
|
+
MinTTL: 0,
|
|
140
|
+
},
|
|
141
|
+
CallerReference: 'old-ref',
|
|
142
|
+
PriceClass: 'PriceClass_All',
|
|
143
|
+
HttpVersion: 'http2',
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
mockResponses['CreateDistributionCommand'] = {
|
|
147
|
+
Distribution: {
|
|
148
|
+
Id: 'd-cloned',
|
|
149
|
+
ARN: 'arn:aws:cloudfront::123:distribution/d-cloned',
|
|
150
|
+
DomainName: 'cloned.cloudfront.net',
|
|
151
|
+
Status: 'Deployed',
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
mockResponses['GetDistributionCommand'] = { Distribution: { Status: 'Deployed' } };
|
|
155
|
+
const builder = new CloudFrontBuilder('my-cdn');
|
|
156
|
+
builder.copyFrom('d-source');
|
|
157
|
+
const result = await builder.deploy();
|
|
158
|
+
assert.ok(result);
|
|
159
|
+
assert.strictEqual(result.id, 'd-cloned');
|
|
160
|
+
const getConfigCall = calls.find((c) => c.commandName === 'GetDistributionConfigCommand');
|
|
161
|
+
assert.ok(getConfigCall);
|
|
162
|
+
assert.strictEqual(getConfigCall.input.Id, 'd-source');
|
|
163
|
+
const createCall = calls.find((c) => c.commandName === 'CreateDistributionCommand');
|
|
164
|
+
assert.ok(createCall);
|
|
165
|
+
// CallerReference from the original clone is deleted; puls inserts a fresh one
|
|
166
|
+
const newRef = createCall.input.DistributionConfig.CallerReference;
|
|
167
|
+
assert.ok(newRef.startsWith('puls-my-cdn-'));
|
|
168
|
+
assert.notStrictEqual(newRef, 'old-ref');
|
|
169
|
+
});
|
|
170
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { test, describe, beforeEach, afterEach, mock } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import { ECSClient } from "@aws-sdk/client-ecs";
|
|
4
|
+
import { EC2Client } from "@aws-sdk/client-ec2";
|
|
5
|
+
import { IAMClient } from "@aws-sdk/client-iam";
|
|
6
|
+
import { CloudWatchLogsClient } from "@aws-sdk/client-cloudwatch-logs";
|
|
7
|
+
import { FargateBuilder } from "./fargate.js";
|
|
8
|
+
import { Config } from "../../core/config.js";
|
|
9
|
+
describe("FargateBuilder Unit Tests", () => {
|
|
10
|
+
let originalEcsSend;
|
|
11
|
+
let originalEc2Send;
|
|
12
|
+
let originalIamSend;
|
|
13
|
+
let originalCwSend;
|
|
14
|
+
let ecsCalls = [];
|
|
15
|
+
let ec2Calls = [];
|
|
16
|
+
let iamCalls = [];
|
|
17
|
+
let cwCalls = [];
|
|
18
|
+
let mockEcsResponses = {};
|
|
19
|
+
let mockEc2Responses = {};
|
|
20
|
+
let mockIamResponses = {};
|
|
21
|
+
let mockCwResponses = {};
|
|
22
|
+
function stubSend(calls, responses) {
|
|
23
|
+
return async function (command) {
|
|
24
|
+
const commandName = command.constructor.name;
|
|
25
|
+
calls.push({ commandName, input: command.input });
|
|
26
|
+
const handler = responses[commandName];
|
|
27
|
+
if (handler) {
|
|
28
|
+
if (typeof handler === "function")
|
|
29
|
+
return handler(command.input);
|
|
30
|
+
if (handler instanceof Error)
|
|
31
|
+
throw handler;
|
|
32
|
+
return handler;
|
|
33
|
+
}
|
|
34
|
+
return {};
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
Config.set({ dryRun: false, providers: { aws: { region: "us-east-1" } } });
|
|
39
|
+
ecsCalls = [];
|
|
40
|
+
ec2Calls = [];
|
|
41
|
+
iamCalls = [];
|
|
42
|
+
cwCalls = [];
|
|
43
|
+
mockEcsResponses = {};
|
|
44
|
+
mockEc2Responses = {};
|
|
45
|
+
mockIamResponses = {};
|
|
46
|
+
mockCwResponses = {};
|
|
47
|
+
originalEcsSend = ECSClient.prototype.send;
|
|
48
|
+
originalEc2Send = EC2Client.prototype.send;
|
|
49
|
+
originalIamSend = IAMClient.prototype.send;
|
|
50
|
+
originalCwSend = CloudWatchLogsClient.prototype.send;
|
|
51
|
+
ECSClient.prototype.send = stubSend(ecsCalls, mockEcsResponses);
|
|
52
|
+
EC2Client.prototype.send = stubSend(ec2Calls, mockEc2Responses);
|
|
53
|
+
IAMClient.prototype.send = stubSend(iamCalls, mockIamResponses);
|
|
54
|
+
CloudWatchLogsClient.prototype.send = stubSend(cwCalls, mockCwResponses);
|
|
55
|
+
});
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
ECSClient.prototype.send = originalEcsSend;
|
|
58
|
+
EC2Client.prototype.send = originalEc2Send;
|
|
59
|
+
IAMClient.prototype.send = originalIamSend;
|
|
60
|
+
CloudWatchLogsClient.prototype.send = originalCwSend;
|
|
61
|
+
mock.restoreAll();
|
|
62
|
+
});
|
|
63
|
+
test("returns null when cluster does not exist during discovery", async () => {
|
|
64
|
+
mockEcsResponses["DescribeClustersCommand"] = { clusters: [] };
|
|
65
|
+
const builder = new FargateBuilder("my-svc");
|
|
66
|
+
const result = await builder.discoveryPromise;
|
|
67
|
+
assert.strictEqual(result, null);
|
|
68
|
+
assert.strictEqual(ecsCalls[0].commandName, "DescribeClustersCommand");
|
|
69
|
+
});
|
|
70
|
+
test("discovers existing service when cluster and service are active", async () => {
|
|
71
|
+
mockEcsResponses["DescribeClustersCommand"] = {
|
|
72
|
+
clusters: [
|
|
73
|
+
{
|
|
74
|
+
status: "ACTIVE",
|
|
75
|
+
clusterArn: "arn:aws:ecs:us-east-1:123:cluster/puls",
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
};
|
|
79
|
+
mockEcsResponses["DescribeServicesCommand"] = {
|
|
80
|
+
services: [
|
|
81
|
+
{
|
|
82
|
+
status: "ACTIVE",
|
|
83
|
+
serviceArn: "arn:aws:ecs:us-east-1:123:service/puls/my-svc",
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
};
|
|
87
|
+
const builder = new FargateBuilder("my-svc");
|
|
88
|
+
const result = await builder.discoveryPromise;
|
|
89
|
+
assert.ok(result);
|
|
90
|
+
assert.strictEqual(result.status, "ACTIVE");
|
|
91
|
+
assert.strictEqual(builder.resolvedArn, "arn:aws:ecs:us-east-1:123:service/puls/my-svc");
|
|
92
|
+
});
|
|
93
|
+
test("performs dry-run without any write API calls", async () => {
|
|
94
|
+
Config.set({ dryRun: true, providers: { aws: { region: "us-east-1" } } });
|
|
95
|
+
mockEcsResponses["DescribeClustersCommand"] = { clusters: [] };
|
|
96
|
+
const builder = new FargateBuilder("my-svc");
|
|
97
|
+
builder
|
|
98
|
+
.image("my-org/api:latest")
|
|
99
|
+
.cpu(512)
|
|
100
|
+
.memory(1024)
|
|
101
|
+
.port(3000)
|
|
102
|
+
.replicas(2);
|
|
103
|
+
const result = await builder.deploy();
|
|
104
|
+
assert.ok(result);
|
|
105
|
+
assert.strictEqual(result.name, "my-svc");
|
|
106
|
+
assert.ok(result.arn.includes("DRYRUN"));
|
|
107
|
+
const writeCalls = [
|
|
108
|
+
...ecsCalls,
|
|
109
|
+
...ec2Calls,
|
|
110
|
+
...iamCalls,
|
|
111
|
+
...cwCalls,
|
|
112
|
+
].filter((c) => !c.commandName.startsWith("Describe"));
|
|
113
|
+
assert.strictEqual(writeCalls.length, 0);
|
|
114
|
+
});
|
|
115
|
+
test("creates new service- registers task def, creates cluster, role, log group, and service", async () => {
|
|
116
|
+
// Discovery: no cluster
|
|
117
|
+
mockEcsResponses["DescribeClustersCommand"] = { clusters: [] };
|
|
118
|
+
// ensureCluster
|
|
119
|
+
mockEcsResponses["CreateClusterCommand"] = {
|
|
120
|
+
cluster: { clusterArn: "arn:aws:ecs:us-east-1:123:cluster/puls" },
|
|
121
|
+
};
|
|
122
|
+
// ensureExecutionRole
|
|
123
|
+
const noRole = new Error("no such entity");
|
|
124
|
+
noRole.name = "NoSuchEntityException";
|
|
125
|
+
mockIamResponses["GetRoleCommand"] = noRole;
|
|
126
|
+
mockIamResponses["CreateRoleCommand"] = {
|
|
127
|
+
Role: { Arn: "arn:aws:iam::123:role/puls-fargate-my-svc-exec-role" },
|
|
128
|
+
};
|
|
129
|
+
// ensureLogGroup
|
|
130
|
+
mockCwResponses["DescribeLogGroupsCommand"] = { logGroups: [] };
|
|
131
|
+
// discoverDefaultVpc
|
|
132
|
+
mockEc2Responses["DescribeVpcsCommand"] = { Vpcs: [{ VpcId: "vpc-abc" }] };
|
|
133
|
+
mockEc2Responses["DescribeSubnetsCommand"] = {
|
|
134
|
+
Subnets: [{ SubnetId: "subnet-1" }, { SubnetId: "subnet-2" }],
|
|
135
|
+
};
|
|
136
|
+
// ensureSecurityGroup
|
|
137
|
+
mockEc2Responses["DescribeSecurityGroupsCommand"] = { SecurityGroups: [] };
|
|
138
|
+
mockEc2Responses["CreateSecurityGroupCommand"] = { GroupId: "sg-xyz" };
|
|
139
|
+
// RegisterTaskDefinition + CreateService
|
|
140
|
+
mockEcsResponses["RegisterTaskDefinitionCommand"] = {
|
|
141
|
+
taskDefinition: {
|
|
142
|
+
taskDefinitionArn: "arn:aws:ecs:us-east-1:123:task-def/my-svc:1",
|
|
143
|
+
revision: 1,
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
mockEcsResponses["CreateServiceCommand"] = {
|
|
147
|
+
service: { serviceArn: "arn:aws:ecs:us-east-1:123:service/puls/my-svc" },
|
|
148
|
+
};
|
|
149
|
+
const builder = new FargateBuilder("my-svc");
|
|
150
|
+
builder
|
|
151
|
+
.image("my-org/api:latest")
|
|
152
|
+
.cpu(512)
|
|
153
|
+
.memory(1024)
|
|
154
|
+
.port(3000)
|
|
155
|
+
.replicas(2);
|
|
156
|
+
const result = await builder.deploy();
|
|
157
|
+
assert.ok(result);
|
|
158
|
+
assert.strictEqual(result.name, "my-svc");
|
|
159
|
+
assert.strictEqual(result.arn, "arn:aws:ecs:us-east-1:123:service/puls/my-svc");
|
|
160
|
+
const createSvc = ecsCalls.find((c) => c.commandName === "CreateServiceCommand");
|
|
161
|
+
assert.ok(createSvc);
|
|
162
|
+
assert.strictEqual(createSvc.input.desiredCount, 2);
|
|
163
|
+
const registerDef = ecsCalls.find((c) => c.commandName === "RegisterTaskDefinitionCommand");
|
|
164
|
+
assert.ok(registerDef);
|
|
165
|
+
assert.strictEqual(registerDef.input.cpu, "512");
|
|
166
|
+
assert.strictEqual(registerDef.input.memory, "1024");
|
|
167
|
+
});
|
|
168
|
+
test("updates existing service with new task definition", async () => {
|
|
169
|
+
mockEcsResponses["DescribeClustersCommand"] = {
|
|
170
|
+
clusters: [
|
|
171
|
+
{
|
|
172
|
+
status: "ACTIVE",
|
|
173
|
+
clusterArn: "arn:aws:ecs:us-east-1:123:cluster/puls",
|
|
174
|
+
},
|
|
175
|
+
],
|
|
176
|
+
};
|
|
177
|
+
mockEcsResponses["DescribeServicesCommand"] = {
|
|
178
|
+
services: [
|
|
179
|
+
{
|
|
180
|
+
status: "ACTIVE",
|
|
181
|
+
serviceArn: "arn:aws:ecs:us-east-1:123:service/puls/my-svc",
|
|
182
|
+
},
|
|
183
|
+
],
|
|
184
|
+
};
|
|
185
|
+
// ensureCluster (existing)
|
|
186
|
+
mockEcsResponses["CreateClusterCommand"] = {};
|
|
187
|
+
// ensureExecutionRole (existing)
|
|
188
|
+
mockIamResponses["GetRoleCommand"] = {
|
|
189
|
+
Role: { Arn: "arn:aws:iam::123:role/puls-fargate-my-svc-exec-role" },
|
|
190
|
+
};
|
|
191
|
+
// ensureLogGroup (existing)
|
|
192
|
+
mockCwResponses["DescribeLogGroupsCommand"] = {
|
|
193
|
+
logGroups: [{ logGroupName: "/puls/my-svc" }],
|
|
194
|
+
};
|
|
195
|
+
// networking (existing SGs provided)
|
|
196
|
+
const builder = new FargateBuilder("my-svc");
|
|
197
|
+
builder
|
|
198
|
+
.image("my-org/api:v2")
|
|
199
|
+
.subnets(["subnet-1"])
|
|
200
|
+
.securityGroups(["sg-1"])
|
|
201
|
+
.replicas(3);
|
|
202
|
+
mockEcsResponses["RegisterTaskDefinitionCommand"] = {
|
|
203
|
+
taskDefinition: {
|
|
204
|
+
taskDefinitionArn: "arn:aws:ecs:us-east-1:123:task-def/my-svc:2",
|
|
205
|
+
revision: 2,
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
mockEcsResponses["UpdateServiceCommand"] = {};
|
|
209
|
+
await builder.deploy();
|
|
210
|
+
const updateSvc = ecsCalls.find((c) => c.commandName === "UpdateServiceCommand");
|
|
211
|
+
assert.ok(updateSvc);
|
|
212
|
+
assert.strictEqual(updateSvc.input.desiredCount, 3);
|
|
213
|
+
assert.strictEqual(updateSvc.input.forceNewDeployment, true);
|
|
214
|
+
});
|
|
215
|
+
test("drains and deletes service on destroy", async () => {
|
|
216
|
+
mockEcsResponses["DescribeClustersCommand"] = {
|
|
217
|
+
clusters: [{ status: "ACTIVE" }],
|
|
218
|
+
};
|
|
219
|
+
mockEcsResponses["DescribeServicesCommand"] = {
|
|
220
|
+
services: [
|
|
221
|
+
{
|
|
222
|
+
status: "ACTIVE",
|
|
223
|
+
serviceArn: "arn:aws:ecs:us-east-1:123:service/puls/my-svc",
|
|
224
|
+
},
|
|
225
|
+
],
|
|
226
|
+
};
|
|
227
|
+
const builder = new FargateBuilder("my-svc");
|
|
228
|
+
await builder.discoveryPromise;
|
|
229
|
+
const result = await builder.destroy();
|
|
230
|
+
assert.deepStrictEqual(result, { destroyed: "my-svc" });
|
|
231
|
+
const drain = ecsCalls.find((c) => c.commandName === "UpdateServiceCommand" && c.input.desiredCount === 0);
|
|
232
|
+
assert.ok(drain);
|
|
233
|
+
const del = ecsCalls.find((c) => c.commandName === "DeleteServiceCommand");
|
|
234
|
+
assert.ok(del);
|
|
235
|
+
assert.strictEqual(del.input.force, true);
|
|
236
|
+
});
|
|
237
|
+
test("skips destroy when service does not exist", async () => {
|
|
238
|
+
mockEcsResponses["DescribeClustersCommand"] = { clusters: [] };
|
|
239
|
+
const builder = new FargateBuilder("my-svc");
|
|
240
|
+
const result = await builder.destroy();
|
|
241
|
+
assert.deepStrictEqual(result, { destroyed: "my-svc" });
|
|
242
|
+
assert.ok(!ecsCalls.some((c) => c.commandName === "DeleteServiceCommand"));
|
|
243
|
+
});
|
|
244
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|