puls-dev 0.3.6 → 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 +11 -11
- 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,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 {};
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { test, describe, beforeEach, afterEach, mock } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import { RDSClient } from "@aws-sdk/client-rds";
|
|
4
|
+
import { EC2Client } from "@aws-sdk/client-ec2";
|
|
5
|
+
import { RDSBuilder } from "./rds.js";
|
|
6
|
+
import { Config } from "../../core/config.js";
|
|
7
|
+
describe("RDSBuilder Unit Tests", () => {
|
|
8
|
+
let originalRdsSend;
|
|
9
|
+
let originalEc2Send;
|
|
10
|
+
let rdsCalls = [];
|
|
11
|
+
let ec2Calls = [];
|
|
12
|
+
let mockRdsResponses = {};
|
|
13
|
+
let mockEc2Responses = {};
|
|
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
|
+
rdsCalls = [];
|
|
32
|
+
ec2Calls = [];
|
|
33
|
+
mockRdsResponses = {};
|
|
34
|
+
mockEc2Responses = {};
|
|
35
|
+
originalRdsSend = RDSClient.prototype.send;
|
|
36
|
+
originalEc2Send = EC2Client.prototype.send;
|
|
37
|
+
RDSClient.prototype.send = stubSend(rdsCalls, mockRdsResponses);
|
|
38
|
+
EC2Client.prototype.send = stubSend(ec2Calls, mockEc2Responses);
|
|
39
|
+
});
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
RDSClient.prototype.send = originalRdsSend;
|
|
42
|
+
EC2Client.prototype.send = originalEc2Send;
|
|
43
|
+
mock.restoreAll();
|
|
44
|
+
});
|
|
45
|
+
test("returns null when DB instance is not found during discovery", async () => {
|
|
46
|
+
const err = new Error("not found");
|
|
47
|
+
err.name = "DBInstanceNotFound";
|
|
48
|
+
mockRdsResponses["DescribeDBInstancesCommand"] = err;
|
|
49
|
+
const builder = new RDSBuilder("my-db");
|
|
50
|
+
const result = await builder.discoveryPromise;
|
|
51
|
+
assert.strictEqual(result, null);
|
|
52
|
+
assert.strictEqual(rdsCalls[0].commandName, "DescribeDBInstancesCommand");
|
|
53
|
+
});
|
|
54
|
+
test("discovers existing instance and populates resolved fields", async () => {
|
|
55
|
+
mockRdsResponses["DescribeDBInstancesCommand"] = {
|
|
56
|
+
DBInstances: [
|
|
57
|
+
{
|
|
58
|
+
DBInstanceIdentifier: "my-db",
|
|
59
|
+
DBInstanceStatus: "available",
|
|
60
|
+
DBInstanceArn: "arn:aws:rds:us-east-1:123:db:my-db",
|
|
61
|
+
Endpoint: {
|
|
62
|
+
Address: "my-db.xyz.us-east-1.rds.amazonaws.com",
|
|
63
|
+
Port: 5432,
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
};
|
|
68
|
+
const builder = new RDSBuilder("my-db");
|
|
69
|
+
const result = await builder.discoveryPromise;
|
|
70
|
+
assert.ok(result);
|
|
71
|
+
assert.strictEqual(builder.resolvedArn, "arn:aws:rds:us-east-1:123:db:my-db");
|
|
72
|
+
assert.strictEqual(builder.resolvedEndpoint, "my-db.xyz.us-east-1.rds.amazonaws.com");
|
|
73
|
+
assert.strictEqual(builder.resolvedPort, 5432);
|
|
74
|
+
});
|
|
75
|
+
test("performs dry-run without any write API calls", async () => {
|
|
76
|
+
Config.set({ dryRun: true, providers: { aws: { region: "us-east-1" } } });
|
|
77
|
+
const err = new Error("not found");
|
|
78
|
+
err.name = "DBInstanceNotFound";
|
|
79
|
+
mockRdsResponses["DescribeDBInstancesCommand"] = err;
|
|
80
|
+
const builder = new RDSBuilder("my-db");
|
|
81
|
+
builder
|
|
82
|
+
.engine({ engine: "postgres", version: "16" })
|
|
83
|
+
.size("db.t3.micro")
|
|
84
|
+
.storage(20);
|
|
85
|
+
const result = await builder.deploy();
|
|
86
|
+
assert.ok(result);
|
|
87
|
+
assert.ok(result.endpoint.includes("DRYRUN"));
|
|
88
|
+
assert.strictEqual(result.port, 5432);
|
|
89
|
+
const writeCalls = rdsCalls.filter((c) => !c.commandName.startsWith("Describe"));
|
|
90
|
+
assert.strictEqual(writeCalls.length, 0);
|
|
91
|
+
});
|
|
92
|
+
test("creates new DB instance with subnet group and security group", async () => {
|
|
93
|
+
const err = new Error("not found");
|
|
94
|
+
err.name = "DBInstanceNotFound";
|
|
95
|
+
mockRdsResponses["DescribeDBInstancesCommand"] = err;
|
|
96
|
+
// ensureSubnetGroup
|
|
97
|
+
const noGroup = new Error("not found");
|
|
98
|
+
noGroup.name = "DBSubnetGroupNotFoundFault";
|
|
99
|
+
mockRdsResponses["DescribeDBSubnetGroupsCommand"] = noGroup;
|
|
100
|
+
mockRdsResponses["CreateDBSubnetGroupCommand"] = {};
|
|
101
|
+
// ensureSecurityGroup
|
|
102
|
+
mockEc2Responses["DescribeVpcsCommand"] = {
|
|
103
|
+
Vpcs: [{ VpcId: "vpc-1", CidrBlock: "10.0.0.0/16" }],
|
|
104
|
+
};
|
|
105
|
+
mockEc2Responses["DescribeSubnetsCommand"] = {
|
|
106
|
+
Subnets: [{ SubnetId: "subnet-1" }],
|
|
107
|
+
};
|
|
108
|
+
mockEc2Responses["DescribeSecurityGroupsCommand"] = { SecurityGroups: [] };
|
|
109
|
+
mockEc2Responses["CreateSecurityGroupCommand"] = { GroupId: "sg-1" };
|
|
110
|
+
// CreateDBInstance- return available immediately so the waitFor resolves
|
|
111
|
+
mockRdsResponses["CreateDBInstanceCommand"] = {};
|
|
112
|
+
let describeCount = 0;
|
|
113
|
+
mockRdsResponses["DescribeDBInstancesCommand"] = (input) => {
|
|
114
|
+
describeCount++;
|
|
115
|
+
if (describeCount === 1) {
|
|
116
|
+
// First call: discovery in constructor- instance not found
|
|
117
|
+
const notFound = new Error("not found");
|
|
118
|
+
notFound.name = "DBInstanceNotFound";
|
|
119
|
+
throw notFound;
|
|
120
|
+
}
|
|
121
|
+
// Subsequent calls: waitFor polling- instance now available
|
|
122
|
+
return {
|
|
123
|
+
DBInstances: [
|
|
124
|
+
{
|
|
125
|
+
DBInstanceIdentifier: "my-db",
|
|
126
|
+
DBInstanceStatus: "available",
|
|
127
|
+
DBInstanceArn: "arn:aws:rds:us-east-1:123:db:my-db",
|
|
128
|
+
Endpoint: { Address: "my-db.xyz.rds.amazonaws.com", Port: 5432 },
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
};
|
|
132
|
+
};
|
|
133
|
+
// Fast-forward poll timer
|
|
134
|
+
mock.method(global, "setTimeout", (fn) => fn());
|
|
135
|
+
const builder = new RDSBuilder("my-db");
|
|
136
|
+
builder
|
|
137
|
+
.engine({ engine: "postgres", version: "16" })
|
|
138
|
+
.size("db.t3.small")
|
|
139
|
+
.storage(40)
|
|
140
|
+
.credentials("admin", "secret");
|
|
141
|
+
const result = await builder.deploy();
|
|
142
|
+
assert.ok(result);
|
|
143
|
+
const createCall = rdsCalls.find((c) => c.commandName === "CreateDBInstanceCommand");
|
|
144
|
+
assert.ok(createCall);
|
|
145
|
+
assert.strictEqual(createCall.input.DBInstanceIdentifier, "my-db");
|
|
146
|
+
assert.strictEqual(createCall.input.Engine, "postgres");
|
|
147
|
+
assert.strictEqual(createCall.input.EngineVersion, "16");
|
|
148
|
+
assert.strictEqual(createCall.input.DBInstanceClass, "db.t3.small");
|
|
149
|
+
assert.strictEqual(createCall.input.AllocatedStorage, 40);
|
|
150
|
+
});
|
|
151
|
+
test("modifies existing instance on update", async () => {
|
|
152
|
+
mockRdsResponses["DescribeDBInstancesCommand"] = {
|
|
153
|
+
DBInstances: [
|
|
154
|
+
{
|
|
155
|
+
DBInstanceIdentifier: "my-db",
|
|
156
|
+
DBInstanceStatus: "available",
|
|
157
|
+
DBInstanceArn: "arn:aws:rds:us-east-1:123:db:my-db",
|
|
158
|
+
Endpoint: { Address: "my-db.xyz.rds.amazonaws.com", Port: 5432 },
|
|
159
|
+
},
|
|
160
|
+
],
|
|
161
|
+
};
|
|
162
|
+
// ensureSubnetGroup (existing)
|
|
163
|
+
mockRdsResponses["DescribeDBSubnetGroupsCommand"] = {
|
|
164
|
+
DBSubnetGroups: [{ DBSubnetGroupName: "puls-my-db-subnet-group" }],
|
|
165
|
+
};
|
|
166
|
+
const builder = new RDSBuilder("my-db");
|
|
167
|
+
builder
|
|
168
|
+
.engine({ engine: "postgres", version: "16" })
|
|
169
|
+
.size("db.t3.medium")
|
|
170
|
+
.subnets(["subnet-1"])
|
|
171
|
+
.securityGroups(["sg-1"])
|
|
172
|
+
.credentials("admin", "secret");
|
|
173
|
+
await builder.deploy();
|
|
174
|
+
const modifyCall = rdsCalls.find((c) => c.commandName === "ModifyDBInstanceCommand");
|
|
175
|
+
assert.ok(modifyCall);
|
|
176
|
+
assert.strictEqual(modifyCall.input.DBInstanceClass, "db.t3.medium");
|
|
177
|
+
assert.strictEqual(modifyCall.input.ApplyImmediately, true);
|
|
178
|
+
});
|
|
179
|
+
test("deletes instance on destroy", async () => {
|
|
180
|
+
mockRdsResponses["DescribeDBInstancesCommand"] = {
|
|
181
|
+
DBInstances: [
|
|
182
|
+
{
|
|
183
|
+
DBInstanceIdentifier: "my-db",
|
|
184
|
+
DBInstanceStatus: "available",
|
|
185
|
+
DBInstanceArn: "arn:aws:rds:us-east-1:123:db:my-db",
|
|
186
|
+
Endpoint: { Address: "my-db.xyz.rds.amazonaws.com", Port: 5432 },
|
|
187
|
+
},
|
|
188
|
+
],
|
|
189
|
+
};
|
|
190
|
+
const builder = new RDSBuilder("my-db");
|
|
191
|
+
await builder.discoveryPromise;
|
|
192
|
+
const result = await builder.destroy();
|
|
193
|
+
assert.deepStrictEqual(result, { destroyed: "my-db" });
|
|
194
|
+
const deleteCall = rdsCalls.find((c) => c.commandName === "DeleteDBInstanceCommand");
|
|
195
|
+
assert.ok(deleteCall);
|
|
196
|
+
assert.strictEqual(deleteCall.input.DBInstanceIdentifier, "my-db");
|
|
197
|
+
assert.strictEqual(deleteCall.input.SkipFinalSnapshot, true);
|
|
198
|
+
});
|
|
199
|
+
test("getDiff returns field changes against live state", () => {
|
|
200
|
+
const err = new Error("not found");
|
|
201
|
+
err.name = "DBInstanceNotFound";
|
|
202
|
+
mockRdsResponses["DescribeDBInstancesCommand"] = err;
|
|
203
|
+
const builder = new RDSBuilder("my-db");
|
|
204
|
+
builder
|
|
205
|
+
.engine({ engine: "postgres", version: "16" })
|
|
206
|
+
.size("db.t3.small")
|
|
207
|
+
.storage(40);
|
|
208
|
+
const diffs = builder.getDiff({
|
|
209
|
+
Engine: "postgres",
|
|
210
|
+
EngineVersion: "15", // differs
|
|
211
|
+
DBInstanceClass: "db.t3.micro", // differs
|
|
212
|
+
AllocatedStorage: 40,
|
|
213
|
+
PubliclyAccessible: false,
|
|
214
|
+
});
|
|
215
|
+
assert.strictEqual(diffs.length, 2);
|
|
216
|
+
assert.ok(diffs.some((d) => d.field === "engineVersion"));
|
|
217
|
+
assert.ok(diffs.some((d) => d.field === "instanceClass"));
|
|
218
|
+
});
|
|
219
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|