puls-dev 0.1.9 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -7
- package/dist/index.d.ts +0 -7
- package/dist/index.js +0 -7
- package/dist/providers/aws/index.d.ts +1 -0
- package/dist/providers/aws/index.js +1 -0
- package/dist/providers/aws/lambda.js +6 -6
- package/dist/providers/aws/lambda.test.d.ts +1 -0
- package/dist/providers/aws/lambda.test.js +189 -0
- 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/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 +0 -1
- package/dist/providers/proxmox/vm.js +4 -50
- package/package.json +78 -5
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Puls-dev
|
|
2
2
|
|
|
3
|
-
**Intent-driven infrastructure-as-code. Describe what you want
|
|
3
|
+
**Intent-driven infrastructure-as-code. Describe what you want - Puls figures out create, update, or skip.**
|
|
4
4
|
|
|
5
5
|
[Live Documentation](https://pulsdev.io/) | Discord: **pulsdev.io** ([Join](https://discord.gg/CjgRayuH))
|
|
6
6
|
|
|
@@ -49,8 +49,8 @@ Running the same stack twice is always safe - existing resources are detected an
|
|
|
49
49
|
### DigitalOcean
|
|
50
50
|
|
|
51
51
|
```typescript
|
|
52
|
-
import {
|
|
53
|
-
|
|
52
|
+
import { Stack, Deploy } from "puls-dev";
|
|
53
|
+
import { DO, SIZE, REGION } from "puls-dev/do";
|
|
54
54
|
|
|
55
55
|
@Deploy({ token: process.env.DO_TOKEN! })
|
|
56
56
|
class Production extends Stack {
|
|
@@ -62,8 +62,8 @@ class Production extends Stack {
|
|
|
62
62
|
### AWS
|
|
63
63
|
|
|
64
64
|
```typescript
|
|
65
|
-
import {
|
|
66
|
-
|
|
65
|
+
import { Stack, Deploy } from "puls-dev";
|
|
66
|
+
import { AWS, DISTRO, BUCKET, DOMAIN_REGISTER, REGION } from "puls-dev/aws";
|
|
67
67
|
|
|
68
68
|
@Deploy({ region: REGION.US_EAST_1 })
|
|
69
69
|
class CDNStack extends Stack {
|
|
@@ -82,8 +82,8 @@ class CDNStack extends Stack {
|
|
|
82
82
|
### Proxmox
|
|
83
83
|
|
|
84
84
|
```typescript
|
|
85
|
-
import {
|
|
86
|
-
|
|
85
|
+
import { Stack, Deploy, Protected } from "puls-dev";
|
|
86
|
+
import { Proxmox, CONFIG, OS, KEYS } from "puls-dev/proxmox";
|
|
87
87
|
|
|
88
88
|
@Deploy({ proxmox: CONFIG.STAGING })
|
|
89
89
|
class StagingInfra extends Stack {
|
package/dist/index.d.ts
CHANGED
|
@@ -2,11 +2,4 @@ export * from "./core/stack.js";
|
|
|
2
2
|
export * from "./core/decorators.js";
|
|
3
3
|
export * from "./core/checker.js";
|
|
4
4
|
export * from "./core/resource.js";
|
|
5
|
-
export { AWS } from "./providers/aws/index.js";
|
|
6
|
-
export { DO } from "./providers/do/index.js";
|
|
7
|
-
export { Proxmox } from "./providers/proxmox/index.js";
|
|
8
|
-
export { Firebase } from "./providers/firebase/index.js";
|
|
9
|
-
export * as AWS_TYPES from "./types/aws.js";
|
|
10
|
-
export * as DO_TYPES from "./types/do.js";
|
|
11
|
-
export * as PROXMOX_TYPES from "./types/proxmox.js";
|
|
12
5
|
export * as INVENTORY_TYPES from "./types/inventory.js";
|
package/dist/index.js
CHANGED
|
@@ -2,11 +2,4 @@ export * from "./core/stack.js";
|
|
|
2
2
|
export * from "./core/decorators.js";
|
|
3
3
|
export * from "./core/checker.js";
|
|
4
4
|
export * from "./core/resource.js";
|
|
5
|
-
export { AWS } from "./providers/aws/index.js";
|
|
6
|
-
export { DO } from "./providers/do/index.js";
|
|
7
|
-
export { Proxmox } from "./providers/proxmox/index.js";
|
|
8
|
-
export { Firebase } from "./providers/firebase/index.js";
|
|
9
|
-
export * as AWS_TYPES from "./types/aws.js";
|
|
10
|
-
export * as DO_TYPES from "./types/do.js";
|
|
11
|
-
export * as PROXMOX_TYPES from "./types/proxmox.js";
|
|
12
5
|
export * as INVENTORY_TYPES from "./types/inventory.js";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import cp from "node:child_process";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join, extname } from "node:path";
|
|
5
5
|
import { GetFunctionCommand, CreateFunctionCommand, UpdateFunctionCodeCommand, UpdateFunctionConfigurationCommand, DeleteFunctionCommand, } from "@aws-sdk/client-lambda";
|
|
@@ -103,14 +103,14 @@ export class LambdaBuilder extends BaseBuilder {
|
|
|
103
103
|
if (!this._codePath)
|
|
104
104
|
throw new Error(`[Lambda:${this.name}] .code(path) is required`);
|
|
105
105
|
if (extname(this._codePath) === ".zip") {
|
|
106
|
-
return readFileSync(this._codePath);
|
|
106
|
+
return fs.readFileSync(this._codePath);
|
|
107
107
|
}
|
|
108
108
|
const outPath = join(tmpdir(), `puls-lambda-${this.name}-${Date.now()}.zip`);
|
|
109
|
-
execSync(`cd "${this._codePath}" && zip -r "${outPath}" .`, {
|
|
109
|
+
cp.execSync(`cd "${this._codePath}" && zip -r "${outPath}" .`, {
|
|
110
110
|
stdio: "pipe",
|
|
111
111
|
});
|
|
112
|
-
const buf = readFileSync(outPath);
|
|
113
|
-
unlinkSync(outPath);
|
|
112
|
+
const buf = fs.readFileSync(outPath);
|
|
113
|
+
fs.unlinkSync(outPath);
|
|
114
114
|
return buf;
|
|
115
115
|
}
|
|
116
116
|
async deploy() {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { test, describe, beforeEach, afterEach, mock } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import { LambdaClient } from '@aws-sdk/client-lambda';
|
|
5
|
+
import { IAMClient } from '@aws-sdk/client-iam';
|
|
6
|
+
import { LambdaBuilder } from './lambda.js';
|
|
7
|
+
import { Config } from '../../core/config.js';
|
|
8
|
+
describe('LambdaBuilder Unit Tests', () => {
|
|
9
|
+
let originalLambdaSend;
|
|
10
|
+
let originalIamSend;
|
|
11
|
+
let lambdaCalls = [];
|
|
12
|
+
let iamCalls = [];
|
|
13
|
+
let mockLambdaResponses = {};
|
|
14
|
+
let mockIamResponses = {};
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
Config.set({
|
|
17
|
+
dryRun: false,
|
|
18
|
+
providers: {
|
|
19
|
+
aws: { region: 'us-east-1' }
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
lambdaCalls = [];
|
|
23
|
+
iamCalls = [];
|
|
24
|
+
mockLambdaResponses = {};
|
|
25
|
+
mockIamResponses = {};
|
|
26
|
+
originalLambdaSend = LambdaClient.prototype.send;
|
|
27
|
+
originalIamSend = IAMClient.prototype.send;
|
|
28
|
+
// Command intercept stubs for Lambda and IAM AWS SDK clients
|
|
29
|
+
LambdaClient.prototype.send = async function (command) {
|
|
30
|
+
const commandName = command.constructor.name;
|
|
31
|
+
const input = command.input;
|
|
32
|
+
lambdaCalls.push({ commandName, input });
|
|
33
|
+
if (mockLambdaResponses[commandName]) {
|
|
34
|
+
const handler = mockLambdaResponses[commandName];
|
|
35
|
+
if (typeof handler === 'function')
|
|
36
|
+
return handler(input);
|
|
37
|
+
if (handler instanceof Error)
|
|
38
|
+
throw handler;
|
|
39
|
+
return handler;
|
|
40
|
+
}
|
|
41
|
+
return {};
|
|
42
|
+
};
|
|
43
|
+
IAMClient.prototype.send = async function (command) {
|
|
44
|
+
const commandName = command.constructor.name;
|
|
45
|
+
const input = command.input;
|
|
46
|
+
iamCalls.push({ commandName, input });
|
|
47
|
+
if (mockIamResponses[commandName]) {
|
|
48
|
+
const handler = mockIamResponses[commandName];
|
|
49
|
+
if (typeof handler === 'function')
|
|
50
|
+
return handler(input);
|
|
51
|
+
if (handler instanceof Error)
|
|
52
|
+
throw handler;
|
|
53
|
+
return handler;
|
|
54
|
+
}
|
|
55
|
+
return {};
|
|
56
|
+
};
|
|
57
|
+
// FS Mocking to return a fake zip buffer when reading the code package
|
|
58
|
+
mock.method(fs, 'readFileSync', () => {
|
|
59
|
+
return Buffer.from('mock-zip-binary-payload');
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
afterEach(() => {
|
|
63
|
+
LambdaClient.prototype.send = originalLambdaSend;
|
|
64
|
+
IAMClient.prototype.send = originalIamSend;
|
|
65
|
+
mock.restoreAll();
|
|
66
|
+
});
|
|
67
|
+
test('gracefully handles discovery when function does not exist', async () => {
|
|
68
|
+
const notFoundError = new Error('Function not found');
|
|
69
|
+
notFoundError.name = 'ResourceNotFoundException';
|
|
70
|
+
mockLambdaResponses['GetFunctionCommand'] = notFoundError;
|
|
71
|
+
const builder = new LambdaBuilder('my-fn');
|
|
72
|
+
const discoveryResult = await builder.discoveryPromise;
|
|
73
|
+
assert.strictEqual(discoveryResult, null);
|
|
74
|
+
assert.strictEqual(lambdaCalls.length, 1);
|
|
75
|
+
assert.strictEqual(lambdaCalls[0].commandName, 'GetFunctionCommand');
|
|
76
|
+
assert.strictEqual(lambdaCalls[0].input.FunctionName, 'my-fn');
|
|
77
|
+
});
|
|
78
|
+
test('discovers function successfully when it exists', async () => {
|
|
79
|
+
mockLambdaResponses['GetFunctionCommand'] = {
|
|
80
|
+
Configuration: { FunctionName: 'my-fn', FunctionArn: 'arn:aws:lambda:us-east-1:12345:function:my-fn' }
|
|
81
|
+
};
|
|
82
|
+
const builder = new LambdaBuilder('my-fn');
|
|
83
|
+
const discoveryResult = await builder.discoveryPromise;
|
|
84
|
+
assert.ok(discoveryResult);
|
|
85
|
+
assert.strictEqual(discoveryResult.FunctionName, 'my-fn');
|
|
86
|
+
assert.strictEqual(builder.resolvedArn, 'arn:aws:lambda:us-east-1:12345:function:my-fn');
|
|
87
|
+
});
|
|
88
|
+
test('performs clean dry-run planning without making write requests', async () => {
|
|
89
|
+
Config.set({
|
|
90
|
+
dryRun: true,
|
|
91
|
+
providers: { aws: { region: 'us-east-1' } }
|
|
92
|
+
});
|
|
93
|
+
const notFoundError = new Error('Function not found');
|
|
94
|
+
notFoundError.name = 'ResourceNotFoundException';
|
|
95
|
+
mockLambdaResponses['GetFunctionCommand'] = notFoundError;
|
|
96
|
+
const builder = new LambdaBuilder('my-fn');
|
|
97
|
+
builder
|
|
98
|
+
.code('my-code.zip')
|
|
99
|
+
.runtime('nodejs20.x')
|
|
100
|
+
.memory(256)
|
|
101
|
+
.env({ DB_HOST: 'localhost' });
|
|
102
|
+
const result = await builder.deploy();
|
|
103
|
+
assert.ok(result);
|
|
104
|
+
assert.strictEqual(result.name, 'my-fn');
|
|
105
|
+
assert.strictEqual(result.arn, 'arn:aws:lambda:DRYRUN:000000000000:function:my-fn');
|
|
106
|
+
// Verify only the GET discovery occurred, no IAM or Lambda write commands
|
|
107
|
+
assert.strictEqual(lambdaCalls.filter(c => c.commandName !== 'GetFunctionCommand').length, 0);
|
|
108
|
+
assert.strictEqual(iamCalls.length, 0);
|
|
109
|
+
});
|
|
110
|
+
test('deploys new function, generates IAM execution role, and uploads code', async () => {
|
|
111
|
+
const notFoundError = new Error('Function not found');
|
|
112
|
+
notFoundError.name = 'ResourceNotFoundException';
|
|
113
|
+
mockLambdaResponses['GetFunctionCommand'] = notFoundError;
|
|
114
|
+
// Mock role lookup to report it doesn't exist yet, forcing role generation
|
|
115
|
+
const noRoleError = new Error('Role not found');
|
|
116
|
+
noRoleError.name = 'NoSuchEntityException';
|
|
117
|
+
mockIamResponses['GetRoleCommand'] = noRoleError;
|
|
118
|
+
mockIamResponses['CreateRoleCommand'] = { Role: { Arn: 'arn:aws:iam::12345:role/puls-lambda-my-fn-role' } };
|
|
119
|
+
mockIamResponses['AttachRolePolicyCommand'] = {};
|
|
120
|
+
mockLambdaResponses['CreateFunctionCommand'] = {
|
|
121
|
+
FunctionArn: 'arn:aws:lambda:us-east-1:12345:function:my-fn'
|
|
122
|
+
};
|
|
123
|
+
const builder = new LambdaBuilder('my-fn');
|
|
124
|
+
builder
|
|
125
|
+
.code('my-code.zip')
|
|
126
|
+
.runtime('nodejs20.x')
|
|
127
|
+
.memory(256)
|
|
128
|
+
.timeout(60);
|
|
129
|
+
// Bypass long-running IAM propagation timer during testing
|
|
130
|
+
const originalPromise = global.Promise;
|
|
131
|
+
const mockTimeout = mock.method(global, 'setTimeout', (fn) => fn());
|
|
132
|
+
const result = await builder.deploy();
|
|
133
|
+
assert.ok(result);
|
|
134
|
+
assert.strictEqual(result.arn, 'arn:aws:lambda:us-east-1:12345:function:my-fn');
|
|
135
|
+
// Assert IAM execution role was created
|
|
136
|
+
const createRoleCall = iamCalls.find(c => c.commandName === 'CreateRoleCommand');
|
|
137
|
+
assert.ok(createRoleCall);
|
|
138
|
+
assert.strictEqual(createRoleCall.input.RoleName, 'puls-lambda-my-fn-role');
|
|
139
|
+
// Assert policy was attached to role
|
|
140
|
+
const attachCall = iamCalls.find(c => c.commandName === 'AttachRolePolicyCommand');
|
|
141
|
+
assert.ok(attachCall);
|
|
142
|
+
assert.strictEqual(attachCall.input.PolicyArn, 'arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole');
|
|
143
|
+
// Assert Lambda creation was deployed with zip buffer
|
|
144
|
+
const createFnCall = lambdaCalls.find(c => c.commandName === 'CreateFunctionCommand');
|
|
145
|
+
assert.ok(createFnCall);
|
|
146
|
+
assert.strictEqual(createFnCall.input.FunctionName, 'my-fn');
|
|
147
|
+
assert.strictEqual(createFnCall.input.Runtime, 'nodejs20.x');
|
|
148
|
+
assert.strictEqual(createFnCall.input.MemorySize, 256);
|
|
149
|
+
assert.strictEqual(createFnCall.input.Timeout, 60);
|
|
150
|
+
assert.strictEqual(createFnCall.input.Role, 'arn:aws:iam::12345:role/puls-lambda-my-fn-role');
|
|
151
|
+
assert.deepStrictEqual(createFnCall.input.Code.ZipFile, Buffer.from('mock-zip-binary-payload'));
|
|
152
|
+
});
|
|
153
|
+
test('updates existing function code and configurations correctly', async () => {
|
|
154
|
+
mockLambdaResponses['GetFunctionCommand'] = {
|
|
155
|
+
Configuration: { FunctionName: 'my-fn', FunctionArn: 'arn:aws:lambda:us-east-1:12345:function:my-fn' }
|
|
156
|
+
};
|
|
157
|
+
mockIamResponses['GetRoleCommand'] = { Role: { Arn: 'arn:aws:iam::12345:role/existing-role' } };
|
|
158
|
+
mockLambdaResponses['UpdateFunctionConfigurationCommand'] = {};
|
|
159
|
+
mockLambdaResponses['UpdateFunctionCodeCommand'] = {};
|
|
160
|
+
const builder = new LambdaBuilder('my-fn');
|
|
161
|
+
builder
|
|
162
|
+
.code('new-code.zip')
|
|
163
|
+
.runtime('nodejs22.x')
|
|
164
|
+
.memory(512);
|
|
165
|
+
await builder.deploy();
|
|
166
|
+
// Verify config update was sent
|
|
167
|
+
const configCall = lambdaCalls.find(c => c.commandName === 'UpdateFunctionConfigurationCommand');
|
|
168
|
+
assert.ok(configCall);
|
|
169
|
+
assert.strictEqual(configCall.input.MemorySize, 512);
|
|
170
|
+
assert.strictEqual(configCall.input.Runtime, 'nodejs22.x');
|
|
171
|
+
// Verify code update was sent with new zip buffer
|
|
172
|
+
const codeCall = lambdaCalls.find(c => c.commandName === 'UpdateFunctionCodeCommand');
|
|
173
|
+
assert.ok(codeCall);
|
|
174
|
+
assert.deepStrictEqual(codeCall.input.ZipFile, Buffer.from('mock-zip-binary-payload'));
|
|
175
|
+
});
|
|
176
|
+
test('destroys Lambda successfully', async () => {
|
|
177
|
+
mockLambdaResponses['GetFunctionCommand'] = {
|
|
178
|
+
Configuration: { FunctionName: 'my-fn', FunctionArn: 'arn:aws:lambda:us-east-1:12345:function:my-fn' }
|
|
179
|
+
};
|
|
180
|
+
mockLambdaResponses['DeleteFunctionCommand'] = {};
|
|
181
|
+
const builder = new LambdaBuilder('my-fn');
|
|
182
|
+
await builder.discoveryPromise;
|
|
183
|
+
const result = await builder.destroy();
|
|
184
|
+
assert.deepStrictEqual(result, { destroyed: 'my-fn' });
|
|
185
|
+
const deleteCall = lambdaCalls.find(c => c.commandName === 'DeleteFunctionCommand');
|
|
186
|
+
assert.ok(deleteCall);
|
|
187
|
+
assert.strictEqual(deleteCall.input.FunctionName, 'my-fn');
|
|
188
|
+
});
|
|
189
|
+
});
|
|
@@ -21,7 +21,7 @@ export declare class Route53Builder extends BaseBuilder {
|
|
|
21
21
|
cert(): ACMCertificateBuilder | undefined;
|
|
22
22
|
withWildcardSSL(): this;
|
|
23
23
|
register(contact?: RegistrantContact): this;
|
|
24
|
-
record(name: string, type: "A" | "CNAME" | "
|
|
24
|
+
record(name: string, type: "A" | "AAAA" | "CNAME" | "MX" | "TXT" | "NS" | "PTR" | "SRV" | "CAA" | "NAPTR" | "SPF", value: string, ttl?: number): this;
|
|
25
25
|
pointer(name: string, target: BaseBuilder): this;
|
|
26
26
|
deploy(): Promise<{
|
|
27
27
|
zone: string;
|
|
@@ -28,6 +28,7 @@ export class Route53Builder extends BaseBuilder {
|
|
|
28
28
|
const match = (result.HostedZones ?? []).find((z) => z.Name === `${name}.`);
|
|
29
29
|
if (match) {
|
|
30
30
|
this.zoneId = match.Id.replace("/hostedzone/", "");
|
|
31
|
+
this.out.zone.resolve({ name: this.zoneName, id: this.zoneId });
|
|
31
32
|
return match;
|
|
32
33
|
}
|
|
33
34
|
return null;
|
|
@@ -58,8 +59,8 @@ export class Route53Builder extends BaseBuilder {
|
|
|
58
59
|
this._registrantContact = contact;
|
|
59
60
|
return this;
|
|
60
61
|
}
|
|
61
|
-
record(name, type, value) {
|
|
62
|
-
this.records.push({ name, type, value });
|
|
62
|
+
record(name, type, value, ttl = 300) {
|
|
63
|
+
this.records.push({ name, type, value, ttl });
|
|
63
64
|
return this;
|
|
64
65
|
}
|
|
65
66
|
pointer(name, target) {
|
|
@@ -115,8 +116,9 @@ export class Route53Builder extends BaseBuilder {
|
|
|
115
116
|
type: r.type,
|
|
116
117
|
name: r.name,
|
|
117
118
|
value: r.value instanceof BaseBuilder ? `[alias: ${r.value.name}]` : r.value,
|
|
119
|
+
ttl: r.ttl ?? 300,
|
|
118
120
|
}));
|
|
119
|
-
await this.upsertRecords(r53, resolved
|
|
121
|
+
await this.upsertRecords(r53, resolved);
|
|
120
122
|
}
|
|
121
123
|
for (const rec of this.records) {
|
|
122
124
|
const val = rec.value instanceof BaseBuilder
|
|
@@ -210,15 +212,21 @@ export class Route53Builder extends BaseBuilder {
|
|
|
210
212
|
await r53.send(new ChangeResourceRecordSetsCommand({
|
|
211
213
|
HostedZoneId: this.zoneId,
|
|
212
214
|
ChangeBatch: {
|
|
213
|
-
Changes: records.map((r) =>
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
215
|
+
Changes: records.map((r) => {
|
|
216
|
+
const value = (r.type === "TXT" || r.type === "SPF") &&
|
|
217
|
+
!r.value.startsWith('"')
|
|
218
|
+
? `"${r.value}"`
|
|
219
|
+
: r.value;
|
|
220
|
+
return {
|
|
221
|
+
Action: "UPSERT",
|
|
222
|
+
ResourceRecordSet: {
|
|
223
|
+
Name: r.name,
|
|
224
|
+
Type: r.type,
|
|
225
|
+
TTL: r.ttl,
|
|
226
|
+
ResourceRecords: [{ Value: value }],
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
}),
|
|
222
230
|
},
|
|
223
231
|
}));
|
|
224
232
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { test, describe, beforeEach, afterEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import { Route53Client } from '@aws-sdk/client-route-53';
|
|
4
|
+
import { Route53DomainsClient } from '@aws-sdk/client-route-53-domains';
|
|
5
|
+
import { Route53Builder } from './route53.js';
|
|
6
|
+
import { Config } from '../../core/config.js';
|
|
7
|
+
describe('Route53Builder Unit Tests', () => {
|
|
8
|
+
let originalR53Send;
|
|
9
|
+
let originalDomainsSend;
|
|
10
|
+
let r53Calls = [];
|
|
11
|
+
let domainsCalls = [];
|
|
12
|
+
let mockR53Responses = {};
|
|
13
|
+
let mockDomainsResponses = {};
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
Config.set({
|
|
16
|
+
dryRun: false,
|
|
17
|
+
providers: {
|
|
18
|
+
aws: { region: 'us-east-1' }
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
r53Calls = [];
|
|
22
|
+
domainsCalls = [];
|
|
23
|
+
mockR53Responses = {};
|
|
24
|
+
mockDomainsResponses = {};
|
|
25
|
+
originalR53Send = Route53Client.prototype.send;
|
|
26
|
+
originalDomainsSend = Route53DomainsClient.prototype.send;
|
|
27
|
+
// Prototype mocks to intercept Route53 and Route53 Domains API commands
|
|
28
|
+
Route53Client.prototype.send = async function (command) {
|
|
29
|
+
const commandName = command.constructor.name;
|
|
30
|
+
const input = command.input;
|
|
31
|
+
r53Calls.push({ commandName, input });
|
|
32
|
+
if (mockR53Responses[commandName]) {
|
|
33
|
+
const handler = mockR53Responses[commandName];
|
|
34
|
+
if (typeof handler === 'function')
|
|
35
|
+
return handler(input);
|
|
36
|
+
if (handler instanceof Error)
|
|
37
|
+
throw handler;
|
|
38
|
+
return handler;
|
|
39
|
+
}
|
|
40
|
+
return {};
|
|
41
|
+
};
|
|
42
|
+
Route53DomainsClient.prototype.send = async function (command) {
|
|
43
|
+
const commandName = command.constructor.name;
|
|
44
|
+
const input = command.input;
|
|
45
|
+
domainsCalls.push({ commandName, input });
|
|
46
|
+
if (mockDomainsResponses[commandName]) {
|
|
47
|
+
const handler = mockDomainsResponses[commandName];
|
|
48
|
+
if (typeof handler === 'function')
|
|
49
|
+
return handler(input);
|
|
50
|
+
if (handler instanceof Error)
|
|
51
|
+
throw handler;
|
|
52
|
+
return handler;
|
|
53
|
+
}
|
|
54
|
+
return {};
|
|
55
|
+
};
|
|
56
|
+
});
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
Route53Client.prototype.send = originalR53Send;
|
|
59
|
+
Route53DomainsClient.prototype.send = originalDomainsSend;
|
|
60
|
+
});
|
|
61
|
+
test('gracefully handles discovery when hosted zone does not exist', async () => {
|
|
62
|
+
mockR53Responses['ListHostedZonesByNameCommand'] = { HostedZones: [] };
|
|
63
|
+
const builder = new Route53Builder('example.com');
|
|
64
|
+
const discoveryResult = await builder.discoveryPromise;
|
|
65
|
+
assert.strictEqual(discoveryResult, null);
|
|
66
|
+
assert.strictEqual(r53Calls.length, 1);
|
|
67
|
+
assert.strictEqual(r53Calls[0].commandName, 'ListHostedZonesByNameCommand');
|
|
68
|
+
assert.strictEqual(r53Calls[0].input.DNSName, 'example.com');
|
|
69
|
+
});
|
|
70
|
+
test('discovers hosted zone successfully when it exists', async () => {
|
|
71
|
+
mockR53Responses['ListHostedZonesByNameCommand'] = {
|
|
72
|
+
HostedZones: [{ Id: '/hostedzone/Z111222', Name: 'example.com.' }]
|
|
73
|
+
};
|
|
74
|
+
const builder = new Route53Builder('example.com');
|
|
75
|
+
const discoveryResult = await builder.discoveryPromise;
|
|
76
|
+
assert.ok(discoveryResult);
|
|
77
|
+
assert.strictEqual(discoveryResult.Id, '/hostedzone/Z111222');
|
|
78
|
+
assert.strictEqual(builder.zoneId, 'Z111222');
|
|
79
|
+
const resolved = await builder.out.zone.get();
|
|
80
|
+
assert.deepStrictEqual(resolved, { name: 'example.com', id: 'Z111222' });
|
|
81
|
+
});
|
|
82
|
+
test('performs clean dry-run planning without making write requests', async () => {
|
|
83
|
+
Config.set({
|
|
84
|
+
dryRun: true,
|
|
85
|
+
providers: { aws: { region: 'us-east-1' } }
|
|
86
|
+
});
|
|
87
|
+
mockR53Responses['ListHostedZonesByNameCommand'] = { HostedZones: [] };
|
|
88
|
+
const builder = new Route53Builder('example.com');
|
|
89
|
+
builder
|
|
90
|
+
.record('@', 'A', '1.2.3.4')
|
|
91
|
+
.withWildcardSSL()
|
|
92
|
+
.register({
|
|
93
|
+
FIRSTNAME: 'Jane', LASTNAME: 'Doe', EMAIL: 'jane@example.com',
|
|
94
|
+
MOBILE: '+1.5555550100', CONTACT_TYPE: 'PERSON', ORGANIZATION: 'N/A',
|
|
95
|
+
ADDRESSLINE: '123 Main St', CITY: 'Seattle', ZIPCODE: '98101', COUNTRY: 'US'
|
|
96
|
+
});
|
|
97
|
+
const result = await builder.deploy();
|
|
98
|
+
assert.ok(result);
|
|
99
|
+
assert.strictEqual(result.zone, 'example.com');
|
|
100
|
+
// Hosted zone, certificate validations, domain registrations are planned, but no writes occur
|
|
101
|
+
const writeCalls = r53Calls.filter(c => c.commandName !== 'ListHostedZonesByNameCommand');
|
|
102
|
+
assert.strictEqual(writeCalls.length, 0);
|
|
103
|
+
assert.strictEqual(domainsCalls.length, 0);
|
|
104
|
+
const resolved = await builder.out.zone.get();
|
|
105
|
+
assert.deepStrictEqual(resolved, { name: 'example.com', id: 'PENDING' });
|
|
106
|
+
});
|
|
107
|
+
test('deploys new hosted zone when missing', async () => {
|
|
108
|
+
mockR53Responses['ListHostedZonesByNameCommand'] = { HostedZones: [] };
|
|
109
|
+
mockR53Responses['CreateHostedZoneCommand'] = {
|
|
110
|
+
HostedZone: { Id: '/hostedzone/Z999888', Name: 'example.com.' }
|
|
111
|
+
};
|
|
112
|
+
const builder = new Route53Builder('example.com');
|
|
113
|
+
const result = await builder.deploy();
|
|
114
|
+
assert.ok(result);
|
|
115
|
+
assert.strictEqual(result.id, 'Z999888');
|
|
116
|
+
const createCall = r53Calls.find(c => c.commandName === 'CreateHostedZoneCommand');
|
|
117
|
+
assert.ok(createCall);
|
|
118
|
+
assert.strictEqual(createCall.input.Name, 'example.com');
|
|
119
|
+
});
|
|
120
|
+
test('deploys records with automatic double quoting for TXT/SPF and custom TTLs', async () => {
|
|
121
|
+
mockR53Responses['ListHostedZonesByNameCommand'] = {
|
|
122
|
+
HostedZones: [{ Id: '/hostedzone/Z123', Name: 'example.com.' }]
|
|
123
|
+
};
|
|
124
|
+
mockR53Responses['ChangeResourceRecordSetsCommand'] = {};
|
|
125
|
+
const builder = new Route53Builder('example.com');
|
|
126
|
+
builder
|
|
127
|
+
.record('www', 'CNAME', 'example.com', 120)
|
|
128
|
+
.record('@', 'TXT', 'v=spf1 include:_spf.google.com ~all')
|
|
129
|
+
.record('spf-record', 'SPF', '"v=spf1 -all"', 600); // already quoted
|
|
130
|
+
await builder.deploy();
|
|
131
|
+
const changeCall = r53Calls.find(c => c.commandName === 'ChangeResourceRecordSetsCommand');
|
|
132
|
+
assert.ok(changeCall);
|
|
133
|
+
assert.strictEqual(changeCall.input.HostedZoneId, 'Z123');
|
|
134
|
+
const changes = changeCall.input.ChangeBatch.Changes;
|
|
135
|
+
assert.strictEqual(changes.length, 3);
|
|
136
|
+
// Assert CNAME configuration
|
|
137
|
+
assert.deepStrictEqual(changes[0], {
|
|
138
|
+
Action: 'UPSERT',
|
|
139
|
+
ResourceRecordSet: {
|
|
140
|
+
Name: 'www',
|
|
141
|
+
Type: 'CNAME',
|
|
142
|
+
TTL: 120,
|
|
143
|
+
ResourceRecords: [{ Value: 'example.com' }]
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
// Assert TXT quoting
|
|
147
|
+
assert.deepStrictEqual(changes[1].ResourceRecordSet, {
|
|
148
|
+
Name: '@',
|
|
149
|
+
Type: 'TXT',
|
|
150
|
+
TTL: 300, // default
|
|
151
|
+
ResourceRecords: [{ Value: '"v=spf1 include:_spf.google.com ~all"' }] // wrapped in quotes
|
|
152
|
+
});
|
|
153
|
+
// Assert already quoted SPF remains same with custom TTL
|
|
154
|
+
assert.deepStrictEqual(changes[2].ResourceRecordSet, {
|
|
155
|
+
Name: 'spf-record',
|
|
156
|
+
Type: 'SPF',
|
|
157
|
+
TTL: 600,
|
|
158
|
+
ResourceRecords: [{ Value: '"v=spf1 -all"' }] // no extra quotes
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
test('adds DNS alias pointers to other builders correctly', async () => {
|
|
162
|
+
mockR53Responses['ListHostedZonesByNameCommand'] = {
|
|
163
|
+
HostedZones: [{ Id: '/hostedzone/Z123', Name: 'example.com.' }]
|
|
164
|
+
};
|
|
165
|
+
const mockTarget = {
|
|
166
|
+
name: 'api-service'
|
|
167
|
+
};
|
|
168
|
+
const builder = new Route53Builder('example.com');
|
|
169
|
+
builder.pointer('api', mockTarget);
|
|
170
|
+
const result = await builder.deploy();
|
|
171
|
+
assert.ok(result);
|
|
172
|
+
// Pointers are logged correctly (in real mode pointers don't write via upsertRecords because ChangeResourceRecordSets requires an alias target config, which puls handles, or mocks out here)
|
|
173
|
+
const recordsField = builder.records;
|
|
174
|
+
const pointerRecord = recordsField.find((r) => r.name === 'api');
|
|
175
|
+
assert.ok(pointerRecord);
|
|
176
|
+
assert.strictEqual(pointerRecord.type, 'A');
|
|
177
|
+
assert.strictEqual(pointerRecord.isAlias, true);
|
|
178
|
+
assert.strictEqual(pointerRecord.value, mockTarget);
|
|
179
|
+
});
|
|
180
|
+
test('registers domain, normalizes phone, and awaits status: successful', async () => {
|
|
181
|
+
let listCallCount = 0;
|
|
182
|
+
mockR53Responses['ListHostedZonesByNameCommand'] = () => {
|
|
183
|
+
listCallCount++;
|
|
184
|
+
if (listCallCount === 1) {
|
|
185
|
+
return { HostedZones: [] };
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
HostedZones: [{ Id: '/hostedzone/Z123', Name: 'random-domain.com.' }]
|
|
189
|
+
};
|
|
190
|
+
};
|
|
191
|
+
mockDomainsResponses['CheckDomainAvailabilityCommand'] = { Availability: 'AVAILABLE' };
|
|
192
|
+
mockDomainsResponses['RegisterDomainCommand'] = { OperationId: 'op-registration-abc' };
|
|
193
|
+
let pollCount = 0;
|
|
194
|
+
mockDomainsResponses['GetOperationDetailCommand'] = () => {
|
|
195
|
+
pollCount++;
|
|
196
|
+
return {
|
|
197
|
+
Status: pollCount === 1 ? 'PENDING' : 'SUCCESSFUL'
|
|
198
|
+
};
|
|
199
|
+
};
|
|
200
|
+
const builder = new Route53Builder('random-domain.com');
|
|
201
|
+
builder.register({
|
|
202
|
+
FIRSTNAME: 'Jane', LASTNAME: 'Doe', EMAIL: 'jane@example.com',
|
|
203
|
+
MOBILE: '+46708339809', CONTACT_TYPE: 'PERSON', ORGANIZATION: 'N/A',
|
|
204
|
+
ADDRESSLINE: '123 Main St', CITY: 'Seattle', ZIPCODE: '98101', COUNTRY: 'US'
|
|
205
|
+
});
|
|
206
|
+
// Override the protected waitFor method to execute polling instantly
|
|
207
|
+
builder.waitFor = async (label, condition) => {
|
|
208
|
+
let done = false;
|
|
209
|
+
while (!done) {
|
|
210
|
+
done = await condition();
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
await builder.deploy();
|
|
214
|
+
// Verify Check Availability was executed
|
|
215
|
+
const checkCall = domainsCalls.find(c => c.commandName === 'CheckDomainAvailabilityCommand');
|
|
216
|
+
assert.ok(checkCall);
|
|
217
|
+
assert.strictEqual(checkCall.input.DomainName, 'random-domain.com');
|
|
218
|
+
// Verify Phone Normalization (+CC.subscriberSwedish example)
|
|
219
|
+
const registerCall = domainsCalls.find(c => c.commandName === 'RegisterDomainCommand');
|
|
220
|
+
assert.ok(registerCall);
|
|
221
|
+
assert.strictEqual(registerCall.input.DomainName, 'random-domain.com');
|
|
222
|
+
// Normalization should transform "+46708339809" into "+46.708339809"
|
|
223
|
+
assert.strictEqual(registerCall.input.RegistrantContact.PhoneNumber, '+46.708339809');
|
|
224
|
+
// Verify polling was performed
|
|
225
|
+
const pollCall = domainsCalls.filter(c => c.commandName === 'GetOperationDetailCommand');
|
|
226
|
+
assert.strictEqual(pollCall.length, 2);
|
|
227
|
+
assert.strictEqual(pollCall[0].input.OperationId, 'op-registration-abc');
|
|
228
|
+
});
|
|
229
|
+
});
|
|
@@ -6,15 +6,18 @@ export declare class S3BucketBuilder extends BaseBuilder {
|
|
|
6
6
|
private _allowedDistributions;
|
|
7
7
|
private _region?;
|
|
8
8
|
private _uploadPath?;
|
|
9
|
+
private _websiteConfig?;
|
|
9
10
|
constructor(bucketName: string);
|
|
10
11
|
region(r: string): this;
|
|
11
12
|
private discoverBucket;
|
|
12
13
|
versioning(enabled?: boolean): this;
|
|
13
14
|
allowFrom(...distributions: CloudFrontBuilder[]): this;
|
|
14
15
|
upload(filePath: string): this;
|
|
16
|
+
staticSite(indexDocument?: string, errorDocument?: string): this;
|
|
15
17
|
deploy(): Promise<{
|
|
16
18
|
name: string;
|
|
17
19
|
}>;
|
|
18
20
|
private uploadFile;
|
|
19
21
|
private updateBucketPolicy;
|
|
22
|
+
private applyPublicReadPolicy;
|
|
20
23
|
}
|