puls-dev 0.1.9 → 0.2.1
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 +8 -8
- package/dist/index.d.ts +0 -7
- package/dist/index.js +0 -7
- package/dist/providers/aws/api.d.ts +4 -0
- package/dist/providers/aws/api.js +4 -0
- package/dist/providers/aws/cloudwatch.d.ts +44 -0
- package/dist/providers/aws/cloudwatch.js +205 -0
- package/dist/providers/aws/cloudwatch.test.d.ts +1 -0
- package/dist/providers/aws/cloudwatch.test.js +224 -0
- package/dist/providers/aws/fargate.d.ts +2 -0
- package/dist/providers/aws/fargate.js +6 -0
- package/dist/providers/aws/iam.d.ts +52 -0
- package/dist/providers/aws/iam.js +307 -0
- package/dist/providers/aws/iam.test.d.ts +1 -0
- package/dist/providers/aws/iam.test.js +367 -0
- package/dist/providers/aws/index.d.ts +8 -0
- package/dist/providers/aws/index.js +8 -0
- package/dist/providers/aws/lambda.d.ts +3 -1
- package/dist/providers/aws/lambda.js +17 -8
- package/dist/providers/aws/lambda.test.d.ts +1 -0
- package/dist/providers/aws/lambda.test.js +189 -0
- package/dist/providers/aws/rds.d.ts +1 -0
- package/dist/providers/aws/rds.js +4 -1
- package/dist/providers/aws/route53.d.ts +1 -1
- package/dist/providers/aws/route53.js +20 -12
- package/dist/providers/aws/route53.test.d.ts +1 -0
- package/dist/providers/aws/route53.test.js +229 -0
- package/dist/providers/aws/s3.d.ts +3 -0
- package/dist/providers/aws/s3.js +65 -3
- package/dist/providers/aws/s3.test.d.ts +1 -0
- package/dist/providers/aws/s3.test.js +172 -0
- package/dist/providers/aws/sns.d.ts +22 -0
- package/dist/providers/aws/sns.js +146 -0
- package/dist/providers/aws/sns.test.d.ts +1 -0
- package/dist/providers/aws/sns.test.js +162 -0
- package/dist/providers/do/api.js +5 -1
- package/dist/providers/do/certificate.test.d.ts +1 -0
- package/dist/providers/do/certificate.test.js +133 -0
- package/dist/providers/do/domain.d.ts +12 -1
- package/dist/providers/do/domain.js +129 -13
- package/dist/providers/do/domain.test.d.ts +1 -0
- package/dist/providers/do/domain.test.js +200 -0
- package/dist/providers/do/droplet.js +2 -2
- package/dist/providers/do/droplet.test.d.ts +1 -0
- package/dist/providers/do/droplet.test.js +265 -0
- package/dist/providers/do/firewall.test.d.ts +1 -0
- package/dist/providers/do/firewall.test.js +176 -0
- package/dist/providers/do/index.d.ts +1 -0
- package/dist/providers/do/index.js +1 -0
- package/dist/providers/do/load_balancer.d.ts +39 -5
- package/dist/providers/do/load_balancer.js +272 -30
- package/dist/providers/do/load_balancer.test.d.ts +1 -0
- package/dist/providers/do/load_balancer.test.js +269 -0
- package/dist/providers/firebase/api.js +2 -2
- package/dist/providers/firebase/functions.d.ts +1 -0
- package/dist/providers/firebase/functions.js +24 -10
- package/dist/providers/firebase/functions.test.d.ts +1 -0
- package/dist/providers/firebase/functions.test.js +297 -0
- package/dist/providers/firebase/hosting.js +5 -5
- package/dist/providers/firebase/hosting.test.d.ts +1 -0
- package/dist/providers/firebase/hosting.test.js +181 -0
- package/dist/providers/proxmox/index.d.ts +1 -0
- package/dist/providers/proxmox/index.js +1 -0
- package/dist/providers/proxmox/vm.d.ts +2 -1
- package/dist/providers/proxmox/vm.js +39 -53
- package/dist/providers/proxmox/vm.test.d.ts +1 -0
- package/dist/providers/proxmox/vm.test.js +155 -0
- package/dist/types/aws.d.ts +11 -0
- package/package.json +105 -6
package/dist/providers/aws/s3.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import
|
|
1
|
+
import fs from "node:fs";
|
|
2
2
|
import { basename, extname } from "node:path";
|
|
3
|
-
import { HeadBucketCommand, CreateBucketCommand, GetBucketPolicyCommand, PutBucketPolicyCommand, PutObjectCommand, } from "@aws-sdk/client-s3";
|
|
3
|
+
import { HeadBucketCommand, CreateBucketCommand, GetBucketPolicyCommand, PutBucketPolicyCommand, PutObjectCommand, PutBucketWebsiteCommand, PutPublicAccessBlockCommand, } from "@aws-sdk/client-s3";
|
|
4
4
|
import { BaseBuilder } from "../../core/resource.js";
|
|
5
5
|
import { getS3Client } from "./api.js";
|
|
6
6
|
import { Config } from "../../core/config.js";
|
|
@@ -10,6 +10,7 @@ export class S3BucketBuilder extends BaseBuilder {
|
|
|
10
10
|
_allowedDistributions = [];
|
|
11
11
|
_region;
|
|
12
12
|
_uploadPath;
|
|
13
|
+
_websiteConfig;
|
|
13
14
|
constructor(bucketName) {
|
|
14
15
|
super(bucketName);
|
|
15
16
|
this.bucketName = bucketName;
|
|
@@ -48,6 +49,10 @@ export class S3BucketBuilder extends BaseBuilder {
|
|
|
48
49
|
this._uploadPath = filePath;
|
|
49
50
|
return this;
|
|
50
51
|
}
|
|
52
|
+
staticSite(indexDocument = "index.html", errorDocument = "error.html") {
|
|
53
|
+
this._websiteConfig = { index: indexDocument, error: errorDocument };
|
|
54
|
+
return this;
|
|
55
|
+
}
|
|
51
56
|
async deploy() {
|
|
52
57
|
const dryRun = this.isDryRunActive();
|
|
53
58
|
const exists = await this.discoveryPromise;
|
|
@@ -87,6 +92,34 @@ export class S3BucketBuilder extends BaseBuilder {
|
|
|
87
92
|
await this.updateBucketPolicy(s3, newArns);
|
|
88
93
|
}
|
|
89
94
|
}
|
|
95
|
+
if (this._websiteConfig) {
|
|
96
|
+
if (dryRun) {
|
|
97
|
+
console.log(` 📝 [PLAN] Enable static site hosting: index=${this._websiteConfig.index}, error=${this._websiteConfig.error}`);
|
|
98
|
+
console.log(` 📝 [PLAN] Remove public access block from bucket`);
|
|
99
|
+
console.log(` 📝 [PLAN] Configure public read bucket policy`);
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
await s3.send(new PutPublicAccessBlockCommand({
|
|
103
|
+
Bucket: this.bucketName,
|
|
104
|
+
PublicAccessBlockConfiguration: {
|
|
105
|
+
BlockPublicAcls: false,
|
|
106
|
+
IgnorePublicAcls: false,
|
|
107
|
+
BlockPublicPolicy: false,
|
|
108
|
+
RestrictPublicBuckets: false,
|
|
109
|
+
},
|
|
110
|
+
}));
|
|
111
|
+
console.log(` ✅ Public access block removed`);
|
|
112
|
+
await s3.send(new PutBucketWebsiteCommand({
|
|
113
|
+
Bucket: this.bucketName,
|
|
114
|
+
WebsiteConfiguration: {
|
|
115
|
+
IndexDocument: { Suffix: this._websiteConfig.index },
|
|
116
|
+
ErrorDocument: { Key: this._websiteConfig.error },
|
|
117
|
+
},
|
|
118
|
+
}));
|
|
119
|
+
console.log(` ✅ Configured static website hosting`);
|
|
120
|
+
await this.applyPublicReadPolicy(s3);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
90
123
|
if (this._uploadPath) {
|
|
91
124
|
if (dryRun) {
|
|
92
125
|
console.log(` 📝 [PLAN] Upload ${basename(this._uploadPath)} → s3://${this.bucketName}/`);
|
|
@@ -100,7 +133,7 @@ export class S3BucketBuilder extends BaseBuilder {
|
|
|
100
133
|
}
|
|
101
134
|
async uploadFile(s3, filePath) {
|
|
102
135
|
const key = basename(filePath);
|
|
103
|
-
const body = readFileSync(filePath);
|
|
136
|
+
const body = fs.readFileSync(filePath);
|
|
104
137
|
const contentTypeMap = {
|
|
105
138
|
".json": "application/json",
|
|
106
139
|
".js": "application/javascript",
|
|
@@ -169,4 +202,33 @@ export class S3BucketBuilder extends BaseBuilder {
|
|
|
169
202
|
for (const arn of newArns)
|
|
170
203
|
console.log(` └─ ${arn}`);
|
|
171
204
|
}
|
|
205
|
+
async applyPublicReadPolicy(s3) {
|
|
206
|
+
let policy = { Version: "2012-10-17", Statement: [] };
|
|
207
|
+
try {
|
|
208
|
+
const existing = await s3.send(new GetBucketPolicyCommand({ Bucket: this.bucketName }));
|
|
209
|
+
if (existing.Policy)
|
|
210
|
+
policy = JSON.parse(existing.Policy);
|
|
211
|
+
}
|
|
212
|
+
catch (e) {
|
|
213
|
+
if (e.name !== "NoSuchBucketPolicy")
|
|
214
|
+
throw e;
|
|
215
|
+
}
|
|
216
|
+
let stmt = policy.Statement.find((s) => s.Sid === "PublicReadGetObject" ||
|
|
217
|
+
(s.Effect === "Allow" && s.Principal === "*" && s.Action === "s3:GetObject"));
|
|
218
|
+
if (!stmt) {
|
|
219
|
+
stmt = {
|
|
220
|
+
Sid: "PublicReadGetObject",
|
|
221
|
+
Effect: "Allow",
|
|
222
|
+
Principal: "*",
|
|
223
|
+
Action: "s3:GetObject",
|
|
224
|
+
Resource: `arn:aws:s3:::${this.bucketName}/*`,
|
|
225
|
+
};
|
|
226
|
+
policy.Statement.push(stmt);
|
|
227
|
+
}
|
|
228
|
+
await s3.send(new PutBucketPolicyCommand({
|
|
229
|
+
Bucket: this.bucketName,
|
|
230
|
+
Policy: JSON.stringify(policy),
|
|
231
|
+
}));
|
|
232
|
+
console.log(` ✅ Public read policy statement applied`);
|
|
233
|
+
}
|
|
172
234
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { test, describe, beforeEach, afterEach, mock } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import { S3Client } from '@aws-sdk/client-s3';
|
|
5
|
+
import { S3BucketBuilder } from './s3.js';
|
|
6
|
+
import { Config } from '../../core/config.js';
|
|
7
|
+
describe('S3BucketBuilder Unit Tests', () => {
|
|
8
|
+
let originalSend;
|
|
9
|
+
let s3Calls = [];
|
|
10
|
+
let mockS3Responses = {};
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
Config.set({
|
|
13
|
+
dryRun: false,
|
|
14
|
+
providers: {
|
|
15
|
+
aws: { region: 'us-east-1' }
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
s3Calls = [];
|
|
19
|
+
mockS3Responses = {};
|
|
20
|
+
originalSend = S3Client.prototype.send;
|
|
21
|
+
// Zero-dependency override on prototype to intercept S3 commands
|
|
22
|
+
S3Client.prototype.send = async function (command) {
|
|
23
|
+
const commandName = command.constructor.name;
|
|
24
|
+
const input = command.input;
|
|
25
|
+
s3Calls.push({ commandName, input });
|
|
26
|
+
if (mockS3Responses[commandName]) {
|
|
27
|
+
const handler = mockS3Responses[commandName];
|
|
28
|
+
if (typeof handler === 'function') {
|
|
29
|
+
return handler(input);
|
|
30
|
+
}
|
|
31
|
+
if (handler instanceof Error) {
|
|
32
|
+
throw handler;
|
|
33
|
+
}
|
|
34
|
+
return handler;
|
|
35
|
+
}
|
|
36
|
+
return {};
|
|
37
|
+
};
|
|
38
|
+
mock.method(fs, 'readFileSync', () => {
|
|
39
|
+
return Buffer.from('hello from s3 site');
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
S3Client.prototype.send = originalSend;
|
|
44
|
+
mock.restoreAll();
|
|
45
|
+
});
|
|
46
|
+
test('gracefully handles discovery when bucket does not exist', async () => {
|
|
47
|
+
const notFoundError = new Error('NoSuchBucket');
|
|
48
|
+
notFoundError.name = 'NotFound';
|
|
49
|
+
notFoundError.$metadata = { httpStatusCode: 404 };
|
|
50
|
+
mockS3Responses['HeadBucketCommand'] = notFoundError;
|
|
51
|
+
const builder = new S3BucketBuilder('my-bucket');
|
|
52
|
+
const discoveryResult = await builder.discoveryPromise;
|
|
53
|
+
assert.strictEqual(discoveryResult, false);
|
|
54
|
+
assert.strictEqual(s3Calls.length, 1);
|
|
55
|
+
assert.strictEqual(s3Calls[0].commandName, 'HeadBucketCommand');
|
|
56
|
+
assert.strictEqual(s3Calls[0].input.Bucket, 'my-bucket');
|
|
57
|
+
});
|
|
58
|
+
test('discovers bucket successfully when it exists', async () => {
|
|
59
|
+
mockS3Responses['HeadBucketCommand'] = {};
|
|
60
|
+
const builder = new S3BucketBuilder('my-bucket');
|
|
61
|
+
const discoveryResult = await builder.discoveryPromise;
|
|
62
|
+
assert.strictEqual(discoveryResult, true);
|
|
63
|
+
assert.strictEqual(s3Calls.length, 1);
|
|
64
|
+
assert.strictEqual(s3Calls[0].commandName, 'HeadBucketCommand');
|
|
65
|
+
});
|
|
66
|
+
test('performs clean dry-run planning without making write requests', async () => {
|
|
67
|
+
Config.set({
|
|
68
|
+
dryRun: true,
|
|
69
|
+
providers: { aws: { region: 'us-east-1' } }
|
|
70
|
+
});
|
|
71
|
+
const notFoundError = new Error('NoSuchBucket');
|
|
72
|
+
notFoundError.name = 'NotFound';
|
|
73
|
+
notFoundError.$metadata = { httpStatusCode: 404 };
|
|
74
|
+
mockS3Responses['HeadBucketCommand'] = notFoundError;
|
|
75
|
+
const builder = new S3BucketBuilder('my-bucket');
|
|
76
|
+
builder.versioning().upload('/path/to/index.html');
|
|
77
|
+
const result = await builder.deploy();
|
|
78
|
+
assert.ok(result);
|
|
79
|
+
assert.strictEqual(result.name, 'my-bucket');
|
|
80
|
+
// Asserts HeadBucket was called for discovery, but no CreateBucket or PutObject
|
|
81
|
+
assert.strictEqual(s3Calls.filter(c => c.commandName !== 'HeadBucketCommand').length, 0);
|
|
82
|
+
});
|
|
83
|
+
test('deploys new bucket when missing', async () => {
|
|
84
|
+
const notFoundError = new Error('NoSuchBucket');
|
|
85
|
+
notFoundError.name = 'NotFound';
|
|
86
|
+
notFoundError.$metadata = { httpStatusCode: 404 };
|
|
87
|
+
mockS3Responses['HeadBucketCommand'] = notFoundError;
|
|
88
|
+
mockS3Responses['CreateBucketCommand'] = {};
|
|
89
|
+
const builder = new S3BucketBuilder('my-bucket');
|
|
90
|
+
const result = await builder.deploy();
|
|
91
|
+
assert.ok(result);
|
|
92
|
+
assert.strictEqual(result.name, 'my-bucket');
|
|
93
|
+
const createCall = s3Calls.find(c => c.commandName === 'CreateBucketCommand');
|
|
94
|
+
assert.ok(createCall);
|
|
95
|
+
assert.strictEqual(createCall.input.Bucket, 'my-bucket');
|
|
96
|
+
});
|
|
97
|
+
test('deploys static site with public access unblocking, website configuration, and public read policy', async () => {
|
|
98
|
+
const notFoundError = new Error('NoSuchBucket');
|
|
99
|
+
notFoundError.name = 'NotFound';
|
|
100
|
+
notFoundError.$metadata = { httpStatusCode: 404 };
|
|
101
|
+
mockS3Responses['HeadBucketCommand'] = notFoundError;
|
|
102
|
+
mockS3Responses['CreateBucketCommand'] = {};
|
|
103
|
+
mockS3Responses['PutPublicAccessBlockCommand'] = {};
|
|
104
|
+
mockS3Responses['PutBucketWebsiteCommand'] = {};
|
|
105
|
+
mockS3Responses['GetBucketPolicyCommand'] = new Error('NoSuchBucketPolicy');
|
|
106
|
+
mockS3Responses['GetBucketPolicyCommand'].name = 'NoSuchBucketPolicy';
|
|
107
|
+
mockS3Responses['PutBucketPolicyCommand'] = {};
|
|
108
|
+
const builder = new S3BucketBuilder('my-web-bucket');
|
|
109
|
+
builder.staticSite('index.html', 'error.html');
|
|
110
|
+
const result = await builder.deploy();
|
|
111
|
+
assert.ok(result);
|
|
112
|
+
// Verify Public Access Block removal
|
|
113
|
+
const blockCall = s3Calls.find(c => c.commandName === 'PutPublicAccessBlockCommand');
|
|
114
|
+
assert.ok(blockCall);
|
|
115
|
+
assert.deepStrictEqual(blockCall.input.PublicAccessBlockConfiguration, {
|
|
116
|
+
BlockPublicAcls: false,
|
|
117
|
+
IgnorePublicAcls: false,
|
|
118
|
+
BlockPublicPolicy: false,
|
|
119
|
+
RestrictPublicBuckets: false,
|
|
120
|
+
});
|
|
121
|
+
// Verify website configurations
|
|
122
|
+
const websiteCall = s3Calls.find(c => c.commandName === 'PutBucketWebsiteCommand');
|
|
123
|
+
assert.ok(websiteCall);
|
|
124
|
+
assert.deepStrictEqual(websiteCall.input.WebsiteConfiguration, {
|
|
125
|
+
IndexDocument: { Suffix: 'index.html' },
|
|
126
|
+
ErrorDocument: { Key: 'error.html' },
|
|
127
|
+
});
|
|
128
|
+
// Verify public read bucket policy upload
|
|
129
|
+
const policyCall = s3Calls.find(c => c.commandName === 'PutBucketPolicyCommand');
|
|
130
|
+
assert.ok(policyCall);
|
|
131
|
+
const parsedPolicy = JSON.parse(policyCall.input.Policy);
|
|
132
|
+
const stmt = parsedPolicy.Statement.find((s) => s.Sid === 'PublicReadGetObject');
|
|
133
|
+
assert.ok(stmt);
|
|
134
|
+
assert.strictEqual(stmt.Effect, 'Allow');
|
|
135
|
+
assert.strictEqual(stmt.Principal, '*');
|
|
136
|
+
assert.strictEqual(stmt.Resource, 'arn:aws:s3:::my-web-bucket/*');
|
|
137
|
+
});
|
|
138
|
+
test('attaches OAC CloudFront policy integration correctly', async () => {
|
|
139
|
+
mockS3Responses['HeadBucketCommand'] = {};
|
|
140
|
+
mockS3Responses['GetBucketPolicyCommand'] = new Error('NoSuchBucketPolicy');
|
|
141
|
+
mockS3Responses['GetBucketPolicyCommand'].name = 'NoSuchBucketPolicy';
|
|
142
|
+
mockS3Responses['PutBucketPolicyCommand'] = {};
|
|
143
|
+
const mockCdn = {
|
|
144
|
+
name: 'my-cdn',
|
|
145
|
+
resolvedArn: 'arn:aws:cloudfront::123456789012:distribution/ED12345'
|
|
146
|
+
};
|
|
147
|
+
const builder = new S3BucketBuilder('my-bucket');
|
|
148
|
+
builder.allowFrom(mockCdn);
|
|
149
|
+
await builder.deploy();
|
|
150
|
+
const policyCall = s3Calls.find(c => c.commandName === 'PutBucketPolicyCommand');
|
|
151
|
+
assert.ok(policyCall);
|
|
152
|
+
const parsedPolicy = JSON.parse(policyCall.input.Policy);
|
|
153
|
+
const stmt = parsedPolicy.Statement.find((s) => s.Principal.Service === 'cloudfront.amazonaws.com');
|
|
154
|
+
assert.ok(stmt);
|
|
155
|
+
assert.strictEqual(stmt.Effect, 'Allow');
|
|
156
|
+
const sourceArns = stmt.Condition.StringEquals['AWS:SourceArn'];
|
|
157
|
+
assert.deepStrictEqual(sourceArns, ['arn:aws:cloudfront::123456789012:distribution/ED12345']);
|
|
158
|
+
});
|
|
159
|
+
test('uploads local file with mapped content type', async () => {
|
|
160
|
+
mockS3Responses['HeadBucketCommand'] = {};
|
|
161
|
+
mockS3Responses['PutObjectCommand'] = {};
|
|
162
|
+
const builder = new S3BucketBuilder('my-bucket');
|
|
163
|
+
builder.upload('/path/to/my-page.html');
|
|
164
|
+
await builder.deploy();
|
|
165
|
+
const uploadCall = s3Calls.find(c => c.commandName === 'PutObjectCommand');
|
|
166
|
+
assert.ok(uploadCall);
|
|
167
|
+
assert.strictEqual(uploadCall.input.Bucket, 'my-bucket');
|
|
168
|
+
assert.strictEqual(uploadCall.input.Key, 'my-page.html');
|
|
169
|
+
assert.strictEqual(uploadCall.input.ContentType, 'text/html');
|
|
170
|
+
assert.deepStrictEqual(uploadCall.input.Body, Buffer.from('hello from s3 site'));
|
|
171
|
+
});
|
|
172
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { BaseBuilder } from "../../core/resource.js";
|
|
2
|
+
import { Output } from "../../core/output.js";
|
|
3
|
+
export declare class SNSTopicBuilder extends BaseBuilder {
|
|
4
|
+
readonly out: {
|
|
5
|
+
arn: Output<string>;
|
|
6
|
+
};
|
|
7
|
+
private _displayName?;
|
|
8
|
+
private _subscriptions;
|
|
9
|
+
resolvedArn: string | null;
|
|
10
|
+
resolvedDisplayName: string | null;
|
|
11
|
+
constructor(name: string);
|
|
12
|
+
displayName(name: string): this;
|
|
13
|
+
subscribe(protocol: "email" | "sms" | "lambda" | "sqs" | "https", endpoint: string): this;
|
|
14
|
+
private discoverTopic;
|
|
15
|
+
deploy(): Promise<{
|
|
16
|
+
name: string;
|
|
17
|
+
arn: string | null;
|
|
18
|
+
}>;
|
|
19
|
+
destroy(): Promise<{
|
|
20
|
+
destroyed: string;
|
|
21
|
+
}>;
|
|
22
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { CreateTopicCommand, DeleteTopicCommand, GetTopicAttributesCommand, ListTopicsCommand, SetTopicAttributesCommand, SubscribeCommand, UnsubscribeCommand, ListSubscriptionsByTopicCommand, } from "@aws-sdk/client-sns";
|
|
2
|
+
import { BaseBuilder } from "../../core/resource.js";
|
|
3
|
+
import { Output } from "../../core/output.js";
|
|
4
|
+
import { getSNSClient } from "./api.js";
|
|
5
|
+
export class SNSTopicBuilder extends BaseBuilder {
|
|
6
|
+
out = {
|
|
7
|
+
arn: new Output(),
|
|
8
|
+
};
|
|
9
|
+
_displayName;
|
|
10
|
+
_subscriptions = [];
|
|
11
|
+
resolvedArn = null;
|
|
12
|
+
resolvedDisplayName = null;
|
|
13
|
+
constructor(name) {
|
|
14
|
+
super(name);
|
|
15
|
+
this.discoveryPromise = this.discoverTopic(name);
|
|
16
|
+
}
|
|
17
|
+
displayName(name) {
|
|
18
|
+
this._displayName = name;
|
|
19
|
+
return this;
|
|
20
|
+
}
|
|
21
|
+
subscribe(protocol, endpoint) {
|
|
22
|
+
this._subscriptions.push({ protocol, endpoint });
|
|
23
|
+
return this;
|
|
24
|
+
}
|
|
25
|
+
async discoverTopic(name) {
|
|
26
|
+
const sns = getSNSClient();
|
|
27
|
+
try {
|
|
28
|
+
let nextToken;
|
|
29
|
+
do {
|
|
30
|
+
const result = await sns.send(new ListTopicsCommand({ NextToken: nextToken }));
|
|
31
|
+
const match = (result.Topics ?? []).find((t) => t.TopicArn?.split(":").pop() === name);
|
|
32
|
+
if (match) {
|
|
33
|
+
this.resolvedArn = match.TopicArn ?? null;
|
|
34
|
+
if (this.resolvedArn) {
|
|
35
|
+
this.out.arn.resolve(this.resolvedArn);
|
|
36
|
+
try {
|
|
37
|
+
const attrsResult = await sns.send(new GetTopicAttributesCommand({ TopicArn: this.resolvedArn }));
|
|
38
|
+
this.resolvedDisplayName = attrsResult.Attributes?.DisplayName ?? null;
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
// Ignore attribute fetch errors (e.g. permission or not found)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return match;
|
|
45
|
+
}
|
|
46
|
+
nextToken = result.NextToken;
|
|
47
|
+
} while (nextToken);
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
catch (e) {
|
|
51
|
+
if (e.name === "CredentialsProviderError")
|
|
52
|
+
return null;
|
|
53
|
+
throw e;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
async deploy() {
|
|
57
|
+
const dryRun = this.isDryRunActive();
|
|
58
|
+
const existing = await this.discoveryPromise;
|
|
59
|
+
const sns = getSNSClient();
|
|
60
|
+
console.log(`\n📢 Finalizing SNS Topic "${this.name}"...`);
|
|
61
|
+
if (dryRun) {
|
|
62
|
+
console.log(` 📝 [PLAN] ${existing ? "Update" : "Create"} SNS topic "${this.name}"`);
|
|
63
|
+
if (this._displayName) {
|
|
64
|
+
console.log(` └─ Display Name: ${this._displayName}`);
|
|
65
|
+
}
|
|
66
|
+
for (const sub of this._subscriptions) {
|
|
67
|
+
console.log(` └─ Subscribe: ${sub.protocol} to ${sub.endpoint}`);
|
|
68
|
+
}
|
|
69
|
+
this.resolvedArn = existing?.TopicArn ?? `arn:aws:sns:us-east-1:000000000000:DRYRUN-${this.name}`;
|
|
70
|
+
this.out.arn.resolve(this.resolvedArn);
|
|
71
|
+
return { name: this.name, arn: this.resolvedArn };
|
|
72
|
+
}
|
|
73
|
+
const topicAttrs = {};
|
|
74
|
+
if (this._displayName) {
|
|
75
|
+
topicAttrs.DisplayName = this._displayName;
|
|
76
|
+
}
|
|
77
|
+
if (!existing) {
|
|
78
|
+
const result = await sns.send(new CreateTopicCommand({
|
|
79
|
+
Name: this.name,
|
|
80
|
+
Attributes: topicAttrs,
|
|
81
|
+
}));
|
|
82
|
+
this.resolvedArn = result.TopicArn;
|
|
83
|
+
this.out.arn.resolve(this.resolvedArn);
|
|
84
|
+
console.log(`🚀 Created SNS Topic "${this.name}" (arn=${this.resolvedArn})`);
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
this.resolvedArn = existing.TopicArn;
|
|
88
|
+
this.out.arn.resolve(this.resolvedArn);
|
|
89
|
+
if (this._displayName && this._displayName !== this.resolvedDisplayName) {
|
|
90
|
+
await sns.send(new SetTopicAttributesCommand({
|
|
91
|
+
TopicArn: this.resolvedArn,
|
|
92
|
+
AttributeName: "DisplayName",
|
|
93
|
+
AttributeValue: this._displayName,
|
|
94
|
+
}));
|
|
95
|
+
console.log(` ✅ Updated SNS topic display name to "${this._displayName}"`);
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
console.log(` ✅ SNS topic "${this.name}" already exists`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// Sync subscriptions
|
|
102
|
+
const activeSubsResult = await sns.send(new ListSubscriptionsByTopicCommand({ TopicArn: this.resolvedArn }));
|
|
103
|
+
const activeSubs = activeSubsResult.Subscriptions ?? [];
|
|
104
|
+
// 1. Unsubscribe stale subscriptions
|
|
105
|
+
for (const sub of activeSubs) {
|
|
106
|
+
if (!sub.SubscriptionArn || sub.SubscriptionArn === "PendingConfirmation")
|
|
107
|
+
continue;
|
|
108
|
+
const isStillWanted = this._subscriptions.some((s) => s.protocol === sub.Protocol && s.endpoint === sub.Endpoint);
|
|
109
|
+
if (!isStillWanted) {
|
|
110
|
+
await sns.send(new UnsubscribeCommand({ SubscriptionArn: sub.SubscriptionArn }));
|
|
111
|
+
console.log(` 🧹 Unsubscribed stale subscription: ${sub.Protocol} to ${sub.Endpoint}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// 2. Subscribe new subscriptions
|
|
115
|
+
for (const target of this._subscriptions) {
|
|
116
|
+
const alreadyExists = activeSubs.some((sub) => sub.Protocol === target.protocol && sub.Endpoint === target.endpoint);
|
|
117
|
+
if (!alreadyExists) {
|
|
118
|
+
await sns.send(new SubscribeCommand({
|
|
119
|
+
TopicArn: this.resolvedArn,
|
|
120
|
+
Protocol: target.protocol,
|
|
121
|
+
Endpoint: target.endpoint,
|
|
122
|
+
}));
|
|
123
|
+
console.log(` ➕ Subscribed: ${target.protocol} to ${target.endpoint}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
await this.deploySidecars();
|
|
127
|
+
return { name: this.name, arn: this.resolvedArn };
|
|
128
|
+
}
|
|
129
|
+
async destroy() {
|
|
130
|
+
const dryRun = this.isDryRunActive();
|
|
131
|
+
const existing = await this.discoveryPromise;
|
|
132
|
+
console.log(`\n🗑️ Destroying SNS Topic "${this.name}"...`);
|
|
133
|
+
if (!existing) {
|
|
134
|
+
console.log(` ✅ Topic "${this.name}" does not exist - nothing to do`);
|
|
135
|
+
return { destroyed: this.name };
|
|
136
|
+
}
|
|
137
|
+
if (dryRun) {
|
|
138
|
+
console.log(` 📝 [PLAN] Delete SNS Topic "${this.name}"`);
|
|
139
|
+
return { destroyed: this.name };
|
|
140
|
+
}
|
|
141
|
+
const sns = getSNSClient();
|
|
142
|
+
await sns.send(new DeleteTopicCommand({ TopicArn: this.resolvedArn }));
|
|
143
|
+
console.log(` ✅ Deleted SNS Topic "${this.name}"`);
|
|
144
|
+
return { destroyed: this.name };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { test, describe, beforeEach, afterEach } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import { SNSClient } from "@aws-sdk/client-sns";
|
|
4
|
+
import { SNSTopicBuilder } from "./sns.js";
|
|
5
|
+
import { Config } from "../../core/config.js";
|
|
6
|
+
describe("SNSTopicBuilder Unit Tests", () => {
|
|
7
|
+
let originalSnsSend;
|
|
8
|
+
let snsCalls = [];
|
|
9
|
+
let mockSnsResponses = {};
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
Config.set({
|
|
12
|
+
dryRun: false,
|
|
13
|
+
providers: {
|
|
14
|
+
aws: { region: "us-east-1" },
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
snsCalls = [];
|
|
18
|
+
mockSnsResponses = {};
|
|
19
|
+
originalSnsSend = SNSClient.prototype.send;
|
|
20
|
+
SNSClient.prototype.send = async function (command) {
|
|
21
|
+
const commandName = command.constructor.name;
|
|
22
|
+
const input = command.input;
|
|
23
|
+
snsCalls.push({ commandName, input });
|
|
24
|
+
if (mockSnsResponses[commandName]) {
|
|
25
|
+
const handler = mockSnsResponses[commandName];
|
|
26
|
+
if (typeof handler === "function")
|
|
27
|
+
return handler(input);
|
|
28
|
+
if (handler instanceof Error)
|
|
29
|
+
throw handler;
|
|
30
|
+
return handler;
|
|
31
|
+
}
|
|
32
|
+
return {};
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
SNSClient.prototype.send = originalSnsSend;
|
|
37
|
+
});
|
|
38
|
+
test("gracefully handles discovery when topic does not exist", async () => {
|
|
39
|
+
mockSnsResponses["ListTopicsCommand"] = { Topics: [] };
|
|
40
|
+
const builder = new SNSTopicBuilder("my-topic");
|
|
41
|
+
const discoveryResult = await builder.discoveryPromise;
|
|
42
|
+
assert.strictEqual(discoveryResult, null);
|
|
43
|
+
assert.ok(snsCalls.some((c) => c.commandName === "ListTopicsCommand"));
|
|
44
|
+
});
|
|
45
|
+
test("discovers existing topic by matching name", async () => {
|
|
46
|
+
mockSnsResponses["ListTopicsCommand"] = {
|
|
47
|
+
Topics: [{ TopicArn: "arn:aws:sns:us-east-1:123456789012:my-topic" }],
|
|
48
|
+
};
|
|
49
|
+
mockSnsResponses["GetTopicAttributesCommand"] = {
|
|
50
|
+
Attributes: { DisplayName: "My Friendly Topic" },
|
|
51
|
+
};
|
|
52
|
+
const builder = new SNSTopicBuilder("my-topic");
|
|
53
|
+
const discoveryResult = await builder.discoveryPromise;
|
|
54
|
+
assert.ok(discoveryResult);
|
|
55
|
+
assert.strictEqual(builder.resolvedArn, "arn:aws:sns:us-east-1:123456789012:my-topic");
|
|
56
|
+
assert.strictEqual(builder.resolvedDisplayName, "My Friendly Topic");
|
|
57
|
+
const resolvedArn = await builder.out.arn.get();
|
|
58
|
+
assert.strictEqual(resolvedArn, "arn:aws:sns:us-east-1:123456789012:my-topic");
|
|
59
|
+
});
|
|
60
|
+
test("creates a new topic with display name and subscriptions", async () => {
|
|
61
|
+
mockSnsResponses["ListTopicsCommand"] = { Topics: [] };
|
|
62
|
+
mockSnsResponses["CreateTopicCommand"] = {
|
|
63
|
+
TopicArn: "arn:aws:sns:us-east-1:123456789012:my-topic",
|
|
64
|
+
};
|
|
65
|
+
mockSnsResponses["ListSubscriptionsByTopicCommand"] = { Subscriptions: [] };
|
|
66
|
+
const builder = new SNSTopicBuilder("my-topic")
|
|
67
|
+
.displayName("Cool Alert")
|
|
68
|
+
.subscribe("email", "ops@company.com")
|
|
69
|
+
.subscribe("sms", "+15555555555");
|
|
70
|
+
const deployResult = await builder.deploy();
|
|
71
|
+
assert.strictEqual(deployResult.arn, "arn:aws:sns:us-east-1:123456789012:my-topic");
|
|
72
|
+
assert.strictEqual(builder.resolvedArn, "arn:aws:sns:us-east-1:123456789012:my-topic");
|
|
73
|
+
const createCall = snsCalls.find((c) => c.commandName === "CreateTopicCommand");
|
|
74
|
+
assert.ok(createCall);
|
|
75
|
+
assert.deepStrictEqual(createCall.input, {
|
|
76
|
+
Name: "my-topic",
|
|
77
|
+
Attributes: { DisplayName: "Cool Alert" },
|
|
78
|
+
});
|
|
79
|
+
const subscribeCalls = snsCalls.filter((c) => c.commandName === "SubscribeCommand");
|
|
80
|
+
assert.strictEqual(subscribeCalls.length, 2);
|
|
81
|
+
assert.deepStrictEqual(subscribeCalls[0].input, {
|
|
82
|
+
TopicArn: "arn:aws:sns:us-east-1:123456789012:my-topic",
|
|
83
|
+
Protocol: "email",
|
|
84
|
+
Endpoint: "ops@company.com",
|
|
85
|
+
});
|
|
86
|
+
assert.deepStrictEqual(subscribeCalls[1].input, {
|
|
87
|
+
TopicArn: "arn:aws:sns:us-east-1:123456789012:my-topic",
|
|
88
|
+
Protocol: "sms",
|
|
89
|
+
Endpoint: "+15555555555",
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
test("syncs subscriptions correctly - unsubscribes stale and skips active", async () => {
|
|
93
|
+
mockSnsResponses["ListTopicsCommand"] = {
|
|
94
|
+
Topics: [{ TopicArn: "arn:aws:sns:us-east-1:123456789012:my-topic" }],
|
|
95
|
+
};
|
|
96
|
+
mockSnsResponses["GetTopicAttributesCommand"] = {
|
|
97
|
+
Attributes: { DisplayName: "Cool Alert" },
|
|
98
|
+
};
|
|
99
|
+
mockSnsResponses["ListSubscriptionsByTopicCommand"] = {
|
|
100
|
+
Subscriptions: [
|
|
101
|
+
{
|
|
102
|
+
SubscriptionArn: "arn:aws:sns:us-east-1:123456789012:my-topic:sub1",
|
|
103
|
+
Protocol: "email",
|
|
104
|
+
Endpoint: "keep-me@company.com",
|
|
105
|
+
TopicArn: "arn:aws:sns:us-east-1:123456789012:my-topic",
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
SubscriptionArn: "arn:aws:sns:us-east-1:123456789012:my-topic:sub2",
|
|
109
|
+
Protocol: "email",
|
|
110
|
+
Endpoint: "delete-me@company.com",
|
|
111
|
+
TopicArn: "arn:aws:sns:us-east-1:123456789012:my-topic",
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
};
|
|
115
|
+
const builder = new SNSTopicBuilder("my-topic")
|
|
116
|
+
.displayName("Cool Alert")
|
|
117
|
+
.subscribe("email", "keep-me@company.com")
|
|
118
|
+
.subscribe("sms", "+19999999999");
|
|
119
|
+
await builder.deploy();
|
|
120
|
+
// Verify unsubscribe was called for stale one
|
|
121
|
+
const unsubscribeCall = snsCalls.find((c) => c.commandName === "UnsubscribeCommand");
|
|
122
|
+
assert.ok(unsubscribeCall);
|
|
123
|
+
assert.strictEqual(unsubscribeCall.input.SubscriptionArn, "arn:aws:sns:us-east-1:123456789012:my-topic:sub2");
|
|
124
|
+
// Verify subscribe was called for the new sms one, but NOT for keep-me@company.com
|
|
125
|
+
const subscribeCalls = snsCalls.filter((c) => c.commandName === "SubscribeCommand");
|
|
126
|
+
assert.strictEqual(subscribeCalls.length, 1);
|
|
127
|
+
assert.deepStrictEqual(subscribeCalls[0].input, {
|
|
128
|
+
TopicArn: "arn:aws:sns:us-east-1:123456789012:my-topic",
|
|
129
|
+
Protocol: "sms",
|
|
130
|
+
Endpoint: "+19999999999",
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
test("destroys an existing topic successfully", async () => {
|
|
134
|
+
mockSnsResponses["ListTopicsCommand"] = {
|
|
135
|
+
Topics: [{ TopicArn: "arn:aws:sns:us-east-1:123456789012:my-topic" }],
|
|
136
|
+
};
|
|
137
|
+
const builder = new SNSTopicBuilder("my-topic");
|
|
138
|
+
await builder.discoveryPromise;
|
|
139
|
+
const destroyResult = await builder.destroy();
|
|
140
|
+
assert.deepStrictEqual(destroyResult, { destroyed: "my-topic" });
|
|
141
|
+
const deleteCall = snsCalls.find((c) => c.commandName === "DeleteTopicCommand");
|
|
142
|
+
assert.ok(deleteCall);
|
|
143
|
+
assert.strictEqual(deleteCall.input.TopicArn, "arn:aws:sns:us-east-1:123456789012:my-topic");
|
|
144
|
+
});
|
|
145
|
+
test("runs in dry run mode safely", async () => {
|
|
146
|
+
Config.set({
|
|
147
|
+
dryRun: true,
|
|
148
|
+
providers: {
|
|
149
|
+
aws: { region: "us-east-1" },
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
mockSnsResponses["ListTopicsCommand"] = { Topics: [] };
|
|
153
|
+
const builder = new SNSTopicBuilder("my-topic")
|
|
154
|
+
.displayName("Cool Alert")
|
|
155
|
+
.subscribe("email", "ops@company.com");
|
|
156
|
+
const deployResult = await builder.deploy();
|
|
157
|
+
assert.ok(deployResult.arn.includes("DRYRUN"));
|
|
158
|
+
// No create topic or subscribe commands should be called in real mode
|
|
159
|
+
assert.ok(!snsCalls.some((c) => c.commandName === "CreateTopicCommand"));
|
|
160
|
+
assert.ok(!snsCalls.some((c) => c.commandName === "SubscribeCommand"));
|
|
161
|
+
});
|
|
162
|
+
});
|
package/dist/providers/do/api.js
CHANGED
|
@@ -6,7 +6,11 @@ export class DoApiClient {
|
|
|
6
6
|
this.token = token;
|
|
7
7
|
}
|
|
8
8
|
get authHeaders() {
|
|
9
|
-
return {
|
|
9
|
+
return {
|
|
10
|
+
Authorization: `Bearer ${this.token}`,
|
|
11
|
+
'Content-Type': 'application/json',
|
|
12
|
+
'Accept-Encoding': 'identity'
|
|
13
|
+
};
|
|
10
14
|
}
|
|
11
15
|
async get(path) {
|
|
12
16
|
const res = await fetch(`${DoApiClient.BASE}${path}`, { headers: this.authHeaders });
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|