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.
Files changed (69) hide show
  1. package/README.md +8 -8
  2. package/dist/index.d.ts +0 -7
  3. package/dist/index.js +0 -7
  4. package/dist/providers/aws/api.d.ts +4 -0
  5. package/dist/providers/aws/api.js +4 -0
  6. package/dist/providers/aws/cloudwatch.d.ts +44 -0
  7. package/dist/providers/aws/cloudwatch.js +205 -0
  8. package/dist/providers/aws/cloudwatch.test.d.ts +1 -0
  9. package/dist/providers/aws/cloudwatch.test.js +224 -0
  10. package/dist/providers/aws/fargate.d.ts +2 -0
  11. package/dist/providers/aws/fargate.js +6 -0
  12. package/dist/providers/aws/iam.d.ts +52 -0
  13. package/dist/providers/aws/iam.js +307 -0
  14. package/dist/providers/aws/iam.test.d.ts +1 -0
  15. package/dist/providers/aws/iam.test.js +367 -0
  16. package/dist/providers/aws/index.d.ts +8 -0
  17. package/dist/providers/aws/index.js +8 -0
  18. package/dist/providers/aws/lambda.d.ts +3 -1
  19. package/dist/providers/aws/lambda.js +17 -8
  20. package/dist/providers/aws/lambda.test.d.ts +1 -0
  21. package/dist/providers/aws/lambda.test.js +189 -0
  22. package/dist/providers/aws/rds.d.ts +1 -0
  23. package/dist/providers/aws/rds.js +4 -1
  24. package/dist/providers/aws/route53.d.ts +1 -1
  25. package/dist/providers/aws/route53.js +20 -12
  26. package/dist/providers/aws/route53.test.d.ts +1 -0
  27. package/dist/providers/aws/route53.test.js +229 -0
  28. package/dist/providers/aws/s3.d.ts +3 -0
  29. package/dist/providers/aws/s3.js +65 -3
  30. package/dist/providers/aws/s3.test.d.ts +1 -0
  31. package/dist/providers/aws/s3.test.js +172 -0
  32. package/dist/providers/aws/sns.d.ts +22 -0
  33. package/dist/providers/aws/sns.js +146 -0
  34. package/dist/providers/aws/sns.test.d.ts +1 -0
  35. package/dist/providers/aws/sns.test.js +162 -0
  36. package/dist/providers/do/api.js +5 -1
  37. package/dist/providers/do/certificate.test.d.ts +1 -0
  38. package/dist/providers/do/certificate.test.js +133 -0
  39. package/dist/providers/do/domain.d.ts +12 -1
  40. package/dist/providers/do/domain.js +129 -13
  41. package/dist/providers/do/domain.test.d.ts +1 -0
  42. package/dist/providers/do/domain.test.js +200 -0
  43. package/dist/providers/do/droplet.js +2 -2
  44. package/dist/providers/do/droplet.test.d.ts +1 -0
  45. package/dist/providers/do/droplet.test.js +265 -0
  46. package/dist/providers/do/firewall.test.d.ts +1 -0
  47. package/dist/providers/do/firewall.test.js +176 -0
  48. package/dist/providers/do/index.d.ts +1 -0
  49. package/dist/providers/do/index.js +1 -0
  50. package/dist/providers/do/load_balancer.d.ts +39 -5
  51. package/dist/providers/do/load_balancer.js +272 -30
  52. package/dist/providers/do/load_balancer.test.d.ts +1 -0
  53. package/dist/providers/do/load_balancer.test.js +269 -0
  54. package/dist/providers/firebase/api.js +2 -2
  55. package/dist/providers/firebase/functions.d.ts +1 -0
  56. package/dist/providers/firebase/functions.js +24 -10
  57. package/dist/providers/firebase/functions.test.d.ts +1 -0
  58. package/dist/providers/firebase/functions.test.js +297 -0
  59. package/dist/providers/firebase/hosting.js +5 -5
  60. package/dist/providers/firebase/hosting.test.d.ts +1 -0
  61. package/dist/providers/firebase/hosting.test.js +181 -0
  62. package/dist/providers/proxmox/index.d.ts +1 -0
  63. package/dist/providers/proxmox/index.js +1 -0
  64. package/dist/providers/proxmox/vm.d.ts +2 -1
  65. package/dist/providers/proxmox/vm.js +39 -53
  66. package/dist/providers/proxmox/vm.test.d.ts +1 -0
  67. package/dist/providers/proxmox/vm.test.js +155 -0
  68. package/dist/types/aws.d.ts +11 -0
  69. package/package.json +105 -6
@@ -7,6 +7,9 @@ import { FargateBuilder } from "./fargate.js";
7
7
  import { RDSBuilder } from "./rds.js";
8
8
  import { SQSBuilder } from "./sqs.js";
9
9
  import { SecretsBuilder } from "./secrets.js";
10
+ import { IAMRoleBuilder, IAMPolicyBuilder } from "./iam.js";
11
+ import { SNSTopicBuilder } from "./sns.js";
12
+ import { CloudWatchAlarmBuilder } from "./cloudwatch.js";
10
13
  export declare const AWS: {
11
14
  init: (opts: {
12
15
  region: string;
@@ -20,4 +23,9 @@ export declare const AWS: {
20
23
  RDS: (name: string) => RDSBuilder;
21
24
  SQS: (name: string) => SQSBuilder;
22
25
  Secret: (secretId: string) => SecretsBuilder;
26
+ IAMRole: (name: string) => IAMRoleBuilder;
27
+ IAMPolicy: (name: string) => IAMPolicyBuilder;
28
+ SNS: (name: string) => SNSTopicBuilder;
29
+ Alarm: (name: string) => CloudWatchAlarmBuilder;
23
30
  };
31
+ export * from "../../types/aws.js";
@@ -8,6 +8,9 @@ import { FargateBuilder } from "./fargate.js";
8
8
  import { RDSBuilder } from "./rds.js";
9
9
  import { SQSBuilder } from "./sqs.js";
10
10
  import { SecretsBuilder } from "./secrets.js";
11
+ import { IAMRoleBuilder, IAMPolicyBuilder } from "./iam.js";
12
+ import { SNSTopicBuilder } from "./sns.js";
13
+ import { CloudWatchAlarmBuilder } from "./cloudwatch.js";
11
14
  export const AWS = {
12
15
  init: (opts) => {
13
16
  Config.set({
@@ -26,4 +29,9 @@ export const AWS = {
26
29
  RDS: (name) => new RDSBuilder(name),
27
30
  SQS: (name) => new SQSBuilder(name),
28
31
  Secret: (secretId) => new SecretsBuilder(secretId),
32
+ IAMRole: (name) => new IAMRoleBuilder(name),
33
+ IAMPolicy: (name) => new IAMPolicyBuilder(name),
34
+ SNS: (name) => new SNSTopicBuilder(name),
35
+ Alarm: (name) => new CloudWatchAlarmBuilder(name),
29
36
  };
37
+ export * from "../../types/aws.js";
@@ -1,5 +1,6 @@
1
1
  import { BaseBuilder } from "../../core/resource.js";
2
2
  import { SecretsBuilder } from "./secrets.js";
3
+ import { IAMRoleBuilder } from "./iam.js";
3
4
  export declare class LambdaBuilder extends BaseBuilder {
4
5
  private _runtime;
5
6
  private _handler;
@@ -8,6 +9,7 @@ export declare class LambdaBuilder extends BaseBuilder {
8
9
  private _codePath?;
9
10
  private _env;
10
11
  private _roleArn?;
12
+ private _roleBuilder?;
11
13
  resolvedArn: string | null;
12
14
  constructor(name: string);
13
15
  private discoverFunction;
@@ -16,7 +18,7 @@ export declare class LambdaBuilder extends BaseBuilder {
16
18
  handler(h: string): this;
17
19
  memory(mb: number): this;
18
20
  timeout(seconds: number): this;
19
- role(arn: string): this;
21
+ role(arnOrBuilder: string | IAMRoleBuilder): this;
20
22
  env(vars: Record<string, string | SecretsBuilder>): this;
21
23
  private ensureRole;
22
24
  private buildZip;
@@ -1,5 +1,5 @@
1
- import { readFileSync, unlinkSync } from "node:fs";
2
- import { execSync } from "node:child_process";
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";
@@ -25,6 +25,7 @@ export class LambdaBuilder extends BaseBuilder {
25
25
  _codePath;
26
26
  _env = {};
27
27
  _roleArn;
28
+ _roleBuilder;
28
29
  resolvedArn = null;
29
30
  constructor(name) {
30
31
  super(name);
@@ -64,8 +65,13 @@ export class LambdaBuilder extends BaseBuilder {
64
65
  this._timeout = seconds;
65
66
  return this;
66
67
  }
67
- role(arn) {
68
- this._roleArn = arn;
68
+ role(arnOrBuilder) {
69
+ if (typeof arnOrBuilder === "string") {
70
+ this._roleArn = arnOrBuilder;
71
+ }
72
+ else {
73
+ this._roleBuilder = arnOrBuilder;
74
+ }
69
75
  return this;
70
76
  }
71
77
  env(vars) {
@@ -73,6 +79,9 @@ export class LambdaBuilder extends BaseBuilder {
73
79
  return this;
74
80
  }
75
81
  async ensureRole() {
82
+ if (this._roleBuilder) {
83
+ return await this._roleBuilder.out.arn.get();
84
+ }
76
85
  if (this._roleArn)
77
86
  return this._roleArn;
78
87
  const roleName = `puls-lambda-${this.name}-role`;
@@ -103,14 +112,14 @@ export class LambdaBuilder extends BaseBuilder {
103
112
  if (!this._codePath)
104
113
  throw new Error(`[Lambda:${this.name}] .code(path) is required`);
105
114
  if (extname(this._codePath) === ".zip") {
106
- return readFileSync(this._codePath);
115
+ return fs.readFileSync(this._codePath);
107
116
  }
108
117
  const outPath = join(tmpdir(), `puls-lambda-${this.name}-${Date.now()}.zip`);
109
- execSync(`cd "${this._codePath}" && zip -r "${outPath}" .`, {
118
+ cp.execSync(`cd "${this._codePath}" && zip -r "${outPath}" .`, {
110
119
  stdio: "pipe",
111
120
  });
112
- const buf = readFileSync(outPath);
113
- unlinkSync(outPath);
121
+ const buf = fs.readFileSync(outPath);
122
+ fs.unlinkSync(outPath);
114
123
  return buf;
115
124
  }
116
125
  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
+ });
@@ -14,6 +14,7 @@ export declare class RDSBuilder extends BaseBuilder {
14
14
  resolvedPort: number | null;
15
15
  resolvedArn: string | null;
16
16
  constructor(name: string);
17
+ get dbInstanceIdentifier(): string;
17
18
  engine(e: {
18
19
  engine: string;
19
20
  version: string;
@@ -26,6 +26,9 @@ export class RDSBuilder extends BaseBuilder {
26
26
  super(name);
27
27
  this.discoveryPromise = this.discoverInstance(name);
28
28
  }
29
+ get dbInstanceIdentifier() {
30
+ return this.name;
31
+ }
29
32
  engine(e) {
30
33
  this._engine = e.engine;
31
34
  this._engineVersion = e.version;
@@ -74,7 +77,7 @@ export class RDSBuilder extends BaseBuilder {
74
77
  return instance;
75
78
  }
76
79
  catch (e) {
77
- if (e.name === "DBInstanceNotFound")
80
+ if (e.name === "DBInstanceNotFound" || e.name === "DBInstanceNotFoundFault")
78
81
  return null;
79
82
  if (e.name === "CredentialsProviderError")
80
83
  return null;
@@ -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" | "AAAA", value: string): this;
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.map((r) => ({ ...r, ttl: 300 })));
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
- Action: "UPSERT",
215
- ResourceRecordSet: {
216
- Name: r.name,
217
- Type: r.type,
218
- TTL: r.ttl,
219
- ResourceRecords: [{ Value: r.value }],
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
  }