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,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 {};
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { test, describe, beforeEach, afterEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import { SQSClient } from '@aws-sdk/client-sqs';
|
|
4
|
+
import { SQSBuilder } from './sqs.js';
|
|
5
|
+
import { Config } from '../../core/config.js';
|
|
6
|
+
describe('SQSBuilder 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 = SQSClient.prototype.send;
|
|
15
|
+
SQSClient.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
|
+
SQSClient.prototype.send = originalSend;
|
|
31
|
+
});
|
|
32
|
+
test('returns null when queue does not exist', async () => {
|
|
33
|
+
const err = new Error('no queue');
|
|
34
|
+
err.name = 'QueueDoesNotExist';
|
|
35
|
+
mockResponses['GetQueueUrlCommand'] = err;
|
|
36
|
+
const builder = new SQSBuilder('my-queue');
|
|
37
|
+
const result = await builder.discoveryPromise;
|
|
38
|
+
assert.strictEqual(result, null);
|
|
39
|
+
assert.strictEqual(calls[0].commandName, 'GetQueueUrlCommand');
|
|
40
|
+
});
|
|
41
|
+
test('discovers existing queue and populates resolvedUrl and resolvedArn', async () => {
|
|
42
|
+
mockResponses['GetQueueUrlCommand'] = {
|
|
43
|
+
QueueUrl: 'https://sqs.us-east-1.amazonaws.com/123/my-queue',
|
|
44
|
+
};
|
|
45
|
+
mockResponses['GetQueueAttributesCommand'] = {
|
|
46
|
+
Attributes: { QueueArn: 'arn:aws:sqs:us-east-1:123:my-queue' },
|
|
47
|
+
};
|
|
48
|
+
const builder = new SQSBuilder('my-queue');
|
|
49
|
+
const result = await builder.discoveryPromise;
|
|
50
|
+
assert.ok(result);
|
|
51
|
+
assert.strictEqual(builder.resolvedUrl, 'https://sqs.us-east-1.amazonaws.com/123/my-queue');
|
|
52
|
+
assert.strictEqual(builder.resolvedArn, 'arn:aws:sqs:us-east-1:123:my-queue');
|
|
53
|
+
});
|
|
54
|
+
test('performs dry-run without creating the queue', async () => {
|
|
55
|
+
Config.set({ dryRun: true, providers: { aws: { region: 'us-east-1' } } });
|
|
56
|
+
const err = new Error('no queue');
|
|
57
|
+
err.name = 'QueueDoesNotExist';
|
|
58
|
+
mockResponses['GetQueueUrlCommand'] = err;
|
|
59
|
+
const builder = new SQSBuilder('my-queue');
|
|
60
|
+
builder.retention(7).timeout(60);
|
|
61
|
+
const result = await builder.deploy();
|
|
62
|
+
assert.ok(result);
|
|
63
|
+
assert.ok(result.url.includes('DRYRUN'));
|
|
64
|
+
assert.ok(result.arn.includes('DRYRUN'));
|
|
65
|
+
const writeCalls = calls.filter((c) => c.commandName === 'CreateQueueCommand');
|
|
66
|
+
assert.strictEqual(writeCalls.length, 0);
|
|
67
|
+
});
|
|
68
|
+
test('creates new standard queue with configured attributes', async () => {
|
|
69
|
+
const err = new Error('no queue');
|
|
70
|
+
err.name = 'QueueDoesNotExist';
|
|
71
|
+
mockResponses['GetQueueUrlCommand'] = err;
|
|
72
|
+
mockResponses['CreateQueueCommand'] = {
|
|
73
|
+
QueueUrl: 'https://sqs.us-east-1.amazonaws.com/123/my-queue',
|
|
74
|
+
};
|
|
75
|
+
mockResponses['GetQueueAttributesCommand'] = {
|
|
76
|
+
Attributes: { QueueArn: 'arn:aws:sqs:us-east-1:123:my-queue' },
|
|
77
|
+
};
|
|
78
|
+
const builder = new SQSBuilder('my-queue');
|
|
79
|
+
builder.retention(7).timeout(45).delay(5);
|
|
80
|
+
const result = await builder.deploy();
|
|
81
|
+
assert.ok(result);
|
|
82
|
+
assert.strictEqual(result.name, 'my-queue');
|
|
83
|
+
assert.strictEqual(result.url, 'https://sqs.us-east-1.amazonaws.com/123/my-queue');
|
|
84
|
+
const createCall = calls.find((c) => c.commandName === 'CreateQueueCommand');
|
|
85
|
+
assert.ok(createCall);
|
|
86
|
+
assert.strictEqual(createCall.input.Attributes.VisibilityTimeout, '45');
|
|
87
|
+
assert.strictEqual(createCall.input.Attributes.MessageRetentionPeriod, String(7 * 86400));
|
|
88
|
+
assert.strictEqual(createCall.input.Attributes.DelaySeconds, '5');
|
|
89
|
+
});
|
|
90
|
+
test('creates FIFO queue with .fifo suffix appended automatically', async () => {
|
|
91
|
+
const err = new Error('no queue');
|
|
92
|
+
err.name = 'QueueDoesNotExist';
|
|
93
|
+
mockResponses['GetQueueUrlCommand'] = err;
|
|
94
|
+
mockResponses['CreateQueueCommand'] = {
|
|
95
|
+
QueueUrl: 'https://sqs.us-east-1.amazonaws.com/123/my-queue.fifo',
|
|
96
|
+
};
|
|
97
|
+
mockResponses['GetQueueAttributesCommand'] = {
|
|
98
|
+
Attributes: { QueueArn: 'arn:aws:sqs:us-east-1:123:my-queue.fifo' },
|
|
99
|
+
};
|
|
100
|
+
const builder = new SQSBuilder('my-queue');
|
|
101
|
+
builder.fifo().deduplication();
|
|
102
|
+
const result = await builder.deploy();
|
|
103
|
+
assert.ok(result);
|
|
104
|
+
assert.strictEqual(result.name, 'my-queue.fifo');
|
|
105
|
+
const createCall = calls.find((c) => c.commandName === 'CreateQueueCommand');
|
|
106
|
+
assert.ok(createCall);
|
|
107
|
+
assert.strictEqual(createCall.input.QueueName, 'my-queue.fifo');
|
|
108
|
+
assert.strictEqual(createCall.input.Attributes.FifoQueue, 'true');
|
|
109
|
+
assert.strictEqual(createCall.input.Attributes.ContentBasedDeduplication, 'true');
|
|
110
|
+
});
|
|
111
|
+
test('creates DLQ before main queue and wires redrive policy', async () => {
|
|
112
|
+
const err = new Error('no queue');
|
|
113
|
+
err.name = 'QueueDoesNotExist';
|
|
114
|
+
let getUrlCallCount = 0;
|
|
115
|
+
mockResponses['GetQueueUrlCommand'] = (input) => {
|
|
116
|
+
getUrlCallCount++;
|
|
117
|
+
if (getUrlCallCount === 1)
|
|
118
|
+
throw err; // discovery: queue not found
|
|
119
|
+
// DLQ lookup in ensureQueue: also not found
|
|
120
|
+
if (input.QueueName === 'my-dlq')
|
|
121
|
+
throw err;
|
|
122
|
+
return { QueueUrl: `https://sqs.us-east-1.amazonaws.com/123/${input.QueueName}` };
|
|
123
|
+
};
|
|
124
|
+
mockResponses['CreateQueueCommand'] = (input) => ({
|
|
125
|
+
QueueUrl: `https://sqs.us-east-1.amazonaws.com/123/${input.QueueName}`,
|
|
126
|
+
});
|
|
127
|
+
mockResponses['GetQueueAttributesCommand'] = (input) => ({
|
|
128
|
+
Attributes: { QueueArn: `arn:aws:sqs:us-east-1:123:${input.QueueUrl?.split('/').pop()}` },
|
|
129
|
+
});
|
|
130
|
+
const builder = new SQSBuilder('my-queue');
|
|
131
|
+
builder.dlq('my-dlq', 5);
|
|
132
|
+
const result = await builder.deploy();
|
|
133
|
+
assert.ok(result);
|
|
134
|
+
const dlqCreate = calls.find((c) => c.commandName === 'CreateQueueCommand' && c.input.QueueName === 'my-dlq');
|
|
135
|
+
assert.ok(dlqCreate);
|
|
136
|
+
const mainCreate = calls.find((c) => c.commandName === 'CreateQueueCommand' && c.input.QueueName === 'my-queue');
|
|
137
|
+
assert.ok(mainCreate);
|
|
138
|
+
const redrivePolicy = JSON.parse(mainCreate.input.Attributes.RedrivePolicy);
|
|
139
|
+
assert.strictEqual(redrivePolicy.maxReceiveCount, 5);
|
|
140
|
+
assert.ok(redrivePolicy.deadLetterTargetArn.includes('my-dlq'));
|
|
141
|
+
});
|
|
142
|
+
test('updates mutable attributes on existing queue', async () => {
|
|
143
|
+
mockResponses['GetQueueUrlCommand'] = {
|
|
144
|
+
QueueUrl: 'https://sqs.us-east-1.amazonaws.com/123/my-queue',
|
|
145
|
+
};
|
|
146
|
+
mockResponses['GetQueueAttributesCommand'] = {
|
|
147
|
+
Attributes: { QueueArn: 'arn:aws:sqs:us-east-1:123:my-queue' },
|
|
148
|
+
};
|
|
149
|
+
const builder = new SQSBuilder('my-queue');
|
|
150
|
+
builder.retention(14).timeout(120);
|
|
151
|
+
await builder.deploy();
|
|
152
|
+
const setAttr = calls.find((c) => c.commandName === 'SetQueueAttributesCommand');
|
|
153
|
+
assert.ok(setAttr);
|
|
154
|
+
assert.strictEqual(setAttr.input.Attributes.VisibilityTimeout, '120');
|
|
155
|
+
assert.strictEqual(setAttr.input.Attributes.MessageRetentionPeriod, String(14 * 86400));
|
|
156
|
+
});
|
|
157
|
+
test('deletes queue on destroy', async () => {
|
|
158
|
+
mockResponses['GetQueueUrlCommand'] = {
|
|
159
|
+
QueueUrl: 'https://sqs.us-east-1.amazonaws.com/123/my-queue',
|
|
160
|
+
};
|
|
161
|
+
mockResponses['GetQueueAttributesCommand'] = {
|
|
162
|
+
Attributes: { QueueArn: 'arn:aws:sqs:us-east-1:123:my-queue' },
|
|
163
|
+
};
|
|
164
|
+
const builder = new SQSBuilder('my-queue');
|
|
165
|
+
await builder.discoveryPromise;
|
|
166
|
+
const result = await builder.destroy();
|
|
167
|
+
assert.deepStrictEqual(result, { destroyed: 'my-queue' });
|
|
168
|
+
const deleteCall = calls.find((c) => c.commandName === 'DeleteQueueCommand');
|
|
169
|
+
assert.ok(deleteCall);
|
|
170
|
+
assert.strictEqual(deleteCall.input.QueueUrl, 'https://sqs.us-east-1.amazonaws.com/123/my-queue');
|
|
171
|
+
});
|
|
172
|
+
test('skips destroy when queue does not exist', async () => {
|
|
173
|
+
const err = new Error('no queue');
|
|
174
|
+
err.name = 'QueueDoesNotExist';
|
|
175
|
+
mockResponses['GetQueueUrlCommand'] = err;
|
|
176
|
+
const builder = new SQSBuilder('my-queue');
|
|
177
|
+
const result = await builder.destroy();
|
|
178
|
+
assert.deepStrictEqual(result, { destroyed: 'my-queue' });
|
|
179
|
+
assert.ok(!calls.some((c) => c.commandName === 'DeleteQueueCommand'));
|
|
180
|
+
});
|
|
181
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export declare class CloudflareApiClient {
|
|
2
|
+
private token;
|
|
3
|
+
private static readonly BASE;
|
|
4
|
+
constructor(token: string);
|
|
5
|
+
private get authHeaders();
|
|
6
|
+
private createCfOfflineMock;
|
|
7
|
+
private request;
|
|
8
|
+
get<T>(path: string): Promise<T>;
|
|
9
|
+
post<T>(path: string, body: unknown): Promise<T>;
|
|
10
|
+
put<T>(path: string, body: unknown, headers?: Record<string, string>): Promise<T>;
|
|
11
|
+
patch<T>(path: string, body: unknown): Promise<T>;
|
|
12
|
+
delete(path: string, body?: unknown): Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
export declare function getCloudflareApi(): CloudflareApiClient;
|
|
15
|
+
export declare function getCloudflareAccountId(): string;
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { Config } from "../../core/config.js";
|
|
2
|
+
import { withRetry } from "../../core/retry.js";
|
|
3
|
+
import { resourceContextStorage } from "../../core/context.js";
|
|
4
|
+
export class CloudflareApiClient {
|
|
5
|
+
token;
|
|
6
|
+
static BASE = "https://api.cloudflare.com/client/v4";
|
|
7
|
+
constructor(token) {
|
|
8
|
+
this.token = token;
|
|
9
|
+
}
|
|
10
|
+
get authHeaders() {
|
|
11
|
+
return {
|
|
12
|
+
Authorization: `Bearer ${this.token}`,
|
|
13
|
+
"Content-Type": "application/json",
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
createCfOfflineMock(method, path, body) {
|
|
17
|
+
if (path.includes("/zones") && !path.includes("/dns_records") && !path.includes("/routes")) {
|
|
18
|
+
if (method === "GET") {
|
|
19
|
+
return {
|
|
20
|
+
result: [
|
|
21
|
+
{ id: "mock-zone-id", name: "mock-domain.com", status: "active" }
|
|
22
|
+
]
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
result: { id: "mock-zone-id", name: "mock-domain.com", status: "active" }
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
if (path.includes("/dns_records")) {
|
|
30
|
+
if (method === "GET") {
|
|
31
|
+
return { result: [] };
|
|
32
|
+
}
|
|
33
|
+
return { result: { id: "mock-dns-record-id" } };
|
|
34
|
+
}
|
|
35
|
+
if (path.includes("/namespaces")) {
|
|
36
|
+
if (method === "GET") {
|
|
37
|
+
return {
|
|
38
|
+
result: [
|
|
39
|
+
{ id: "mock-kv-namespace-id", title: "mock-namespace" }
|
|
40
|
+
]
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
return { result: { id: "mock-kv-namespace-id", title: "mock-namespace" } };
|
|
44
|
+
}
|
|
45
|
+
if (path.includes("/workers/scripts")) {
|
|
46
|
+
return { result: { id: "mock-worker-script-id" } };
|
|
47
|
+
}
|
|
48
|
+
if (path.includes("/routes")) {
|
|
49
|
+
if (method === "GET") {
|
|
50
|
+
return { result: [] };
|
|
51
|
+
}
|
|
52
|
+
return { result: { id: "mock-route-id" } };
|
|
53
|
+
}
|
|
54
|
+
if (path.includes("/r2/buckets")) {
|
|
55
|
+
if (method === "GET") {
|
|
56
|
+
return { result: { buckets: [] } };
|
|
57
|
+
}
|
|
58
|
+
return { result: {} };
|
|
59
|
+
}
|
|
60
|
+
return new Proxy({}, {
|
|
61
|
+
get(target, prop) {
|
|
62
|
+
if (prop === "then")
|
|
63
|
+
return undefined;
|
|
64
|
+
if (prop === "id")
|
|
65
|
+
return "mock-cf-id-123456";
|
|
66
|
+
if (prop === "name")
|
|
67
|
+
return "mock-cf-name";
|
|
68
|
+
if (prop === "status")
|
|
69
|
+
return "active";
|
|
70
|
+
if (prop.endsWith("s"))
|
|
71
|
+
return [];
|
|
72
|
+
return `mock-cf-${prop.toLowerCase()}`;
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
async request(fn) {
|
|
77
|
+
return withRetry(fn, {
|
|
78
|
+
retryable: (err) => {
|
|
79
|
+
const match = err.message.match(/: (\d+)/);
|
|
80
|
+
const status = match ? parseInt(match[1], 10) : null;
|
|
81
|
+
return status === 429 || (status && status >= 500) || err.message.includes("ETIMEDOUT");
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
async get(path) {
|
|
86
|
+
const context = resourceContextStorage.getStore();
|
|
87
|
+
const abortSignal = context?.abortSignal;
|
|
88
|
+
if (Config.isOfflineMode() || Config.isGlobalDryRun()) {
|
|
89
|
+
return Promise.resolve(this.createCfOfflineMock("GET", path));
|
|
90
|
+
}
|
|
91
|
+
return this.request(async () => {
|
|
92
|
+
const res = await fetch(`${CloudflareApiClient.BASE}${path}`, {
|
|
93
|
+
headers: this.authHeaders,
|
|
94
|
+
...(abortSignal && { signal: abortSignal })
|
|
95
|
+
});
|
|
96
|
+
if (!res.ok)
|
|
97
|
+
throw new Error(`Cloudflare API GET ${path}: ${res.status} ${await res.text()}`);
|
|
98
|
+
return res.json();
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
async post(path, body) {
|
|
102
|
+
const context = resourceContextStorage.getStore();
|
|
103
|
+
const abortSignal = context?.abortSignal;
|
|
104
|
+
if (Config.isOfflineMode() || Config.isGlobalDryRun()) {
|
|
105
|
+
return Promise.resolve(this.createCfOfflineMock("POST", path, body));
|
|
106
|
+
}
|
|
107
|
+
return this.request(async () => {
|
|
108
|
+
const res = await fetch(`${CloudflareApiClient.BASE}${path}`, {
|
|
109
|
+
method: "POST",
|
|
110
|
+
headers: this.authHeaders,
|
|
111
|
+
body: JSON.stringify(body),
|
|
112
|
+
...(abortSignal && { signal: abortSignal })
|
|
113
|
+
});
|
|
114
|
+
if (!res.ok)
|
|
115
|
+
throw new Error(`Cloudflare API POST ${path}: ${res.status} ${await res.text()}`);
|
|
116
|
+
return res.json();
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
async put(path, body, headers) {
|
|
120
|
+
const context = resourceContextStorage.getStore();
|
|
121
|
+
const abortSignal = context?.abortSignal;
|
|
122
|
+
if (Config.isOfflineMode() || Config.isGlobalDryRun()) {
|
|
123
|
+
return Promise.resolve(this.createCfOfflineMock("PUT", path, body));
|
|
124
|
+
}
|
|
125
|
+
return this.request(async () => {
|
|
126
|
+
const isMultipartOrRaw = body instanceof FormData || typeof body === "string" || body instanceof Buffer;
|
|
127
|
+
const customHeaders = headers ?? (isMultipartOrRaw ? {} : { "Content-Type": "application/json" });
|
|
128
|
+
const reqHeaders = {
|
|
129
|
+
Authorization: `Bearer ${this.token}`,
|
|
130
|
+
...customHeaders,
|
|
131
|
+
};
|
|
132
|
+
const res = await fetch(`${CloudflareApiClient.BASE}${path}`, {
|
|
133
|
+
method: "PUT",
|
|
134
|
+
headers: reqHeaders,
|
|
135
|
+
body: isMultipartOrRaw ? body : JSON.stringify(body),
|
|
136
|
+
...(abortSignal && { signal: abortSignal })
|
|
137
|
+
});
|
|
138
|
+
if (!res.ok)
|
|
139
|
+
throw new Error(`Cloudflare API PUT ${path}: ${res.status} ${await res.text()}`);
|
|
140
|
+
return res.json();
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
async patch(path, body) {
|
|
144
|
+
const context = resourceContextStorage.getStore();
|
|
145
|
+
const abortSignal = context?.abortSignal;
|
|
146
|
+
if (Config.isOfflineMode() || Config.isGlobalDryRun()) {
|
|
147
|
+
return Promise.resolve(this.createCfOfflineMock("PATCH", path, body));
|
|
148
|
+
}
|
|
149
|
+
return this.request(async () => {
|
|
150
|
+
const res = await fetch(`${CloudflareApiClient.BASE}${path}`, {
|
|
151
|
+
method: "PATCH",
|
|
152
|
+
headers: this.authHeaders,
|
|
153
|
+
body: JSON.stringify(body),
|
|
154
|
+
...(abortSignal && { signal: abortSignal })
|
|
155
|
+
});
|
|
156
|
+
if (!res.ok)
|
|
157
|
+
throw new Error(`Cloudflare API PATCH ${path}: ${res.status} ${await res.text()}`);
|
|
158
|
+
return res.json();
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
async delete(path, body) {
|
|
162
|
+
const context = resourceContextStorage.getStore();
|
|
163
|
+
const abortSignal = context?.abortSignal;
|
|
164
|
+
if (Config.isOfflineMode() || Config.isGlobalDryRun()) {
|
|
165
|
+
return Promise.resolve();
|
|
166
|
+
}
|
|
167
|
+
return this.request(async () => {
|
|
168
|
+
const res = await fetch(`${CloudflareApiClient.BASE}${path}`, {
|
|
169
|
+
method: "DELETE",
|
|
170
|
+
headers: this.authHeaders,
|
|
171
|
+
...(body !== undefined && { body: JSON.stringify(body) }),
|
|
172
|
+
...(abortSignal && { signal: abortSignal })
|
|
173
|
+
});
|
|
174
|
+
if (!res.ok && res.status !== 404) {
|
|
175
|
+
throw new Error(`Cloudflare API DELETE ${path}: ${res.status} ${await res.text()}`);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
export function getCloudflareApi() {
|
|
181
|
+
const token = Config.get().providers.cloudflare?.token;
|
|
182
|
+
if (!token) {
|
|
183
|
+
if (Config.isOfflineMode() || Config.isGlobalDryRun()) {
|
|
184
|
+
return new CloudflareApiClient("mock-cf-token");
|
|
185
|
+
}
|
|
186
|
+
throw new Error('Cloudflare token not configured. Call CF.init({ token: "..." })');
|
|
187
|
+
}
|
|
188
|
+
return new CloudflareApiClient(token);
|
|
189
|
+
}
|
|
190
|
+
export function getCloudflareAccountId() {
|
|
191
|
+
const accId = Config.get().providers.cloudflare?.accountId;
|
|
192
|
+
if (!accId) {
|
|
193
|
+
if (Config.isOfflineMode() || Config.isGlobalDryRun()) {
|
|
194
|
+
return "mock-cf-account-id";
|
|
195
|
+
}
|
|
196
|
+
throw new Error("Cloudflare account ID not configured. Account ID is required for Workers, KV namespaces, and R2 buckets.");
|
|
197
|
+
}
|
|
198
|
+
return accId;
|
|
199
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { ZoneBuilder } from "./zone.js";
|
|
2
|
+
import { WorkerBuilder } from "./worker.js";
|
|
3
|
+
import { KVBuilder } from "./kv.js";
|
|
4
|
+
import { R2Builder } from "./r2.js";
|
|
5
|
+
export declare const CF: {
|
|
6
|
+
init: (opts: {
|
|
7
|
+
token: string;
|
|
8
|
+
accountId?: string;
|
|
9
|
+
}) => void;
|
|
10
|
+
Zone: (domainName: string) => ZoneBuilder;
|
|
11
|
+
Worker: (name: string) => WorkerBuilder;
|
|
12
|
+
KV: (title: string) => KVBuilder;
|
|
13
|
+
R2: (bucketName: string) => R2Builder;
|
|
14
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Config } from "../../core/config.js";
|
|
2
|
+
import { ZoneBuilder } from "./zone.js";
|
|
3
|
+
import { WorkerBuilder } from "./worker.js";
|
|
4
|
+
import { KVBuilder } from "./kv.js";
|
|
5
|
+
import { R2Builder } from "./r2.js";
|
|
6
|
+
export const CF = {
|
|
7
|
+
init: (opts) => {
|
|
8
|
+
Config.set({
|
|
9
|
+
providers: {
|
|
10
|
+
...Config.get().providers,
|
|
11
|
+
cloudflare: opts,
|
|
12
|
+
},
|
|
13
|
+
});
|
|
14
|
+
},
|
|
15
|
+
Zone: (domainName) => new ZoneBuilder(domainName),
|
|
16
|
+
Worker: (name) => new WorkerBuilder(name),
|
|
17
|
+
KV: (title) => new KVBuilder(title),
|
|
18
|
+
R2: (bucketName) => new R2Builder(bucketName),
|
|
19
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { BaseBuilder } from "../../core/resource.js";
|
|
2
|
+
import { Output } from "../../core/output.js";
|
|
3
|
+
export declare class KVBuilder extends BaseBuilder {
|
|
4
|
+
title: string;
|
|
5
|
+
readonly out: {
|
|
6
|
+
id: Output<string>;
|
|
7
|
+
};
|
|
8
|
+
resolvedId: string | null;
|
|
9
|
+
constructor(title: string);
|
|
10
|
+
private discoverNamespace;
|
|
11
|
+
deploy(): Promise<{
|
|
12
|
+
title: string;
|
|
13
|
+
id: string | null;
|
|
14
|
+
}>;
|
|
15
|
+
destroy(): Promise<{
|
|
16
|
+
destroyed: boolean;
|
|
17
|
+
} | {
|
|
18
|
+
destroyed: string;
|
|
19
|
+
}>;
|
|
20
|
+
}
|