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.
Files changed (47) hide show
  1. package/README.md +7 -7
  2. package/dist/index.d.ts +0 -7
  3. package/dist/index.js +0 -7
  4. package/dist/providers/aws/index.d.ts +1 -0
  5. package/dist/providers/aws/index.js +1 -0
  6. package/dist/providers/aws/lambda.js +6 -6
  7. package/dist/providers/aws/lambda.test.d.ts +1 -0
  8. package/dist/providers/aws/lambda.test.js +189 -0
  9. package/dist/providers/aws/route53.d.ts +1 -1
  10. package/dist/providers/aws/route53.js +20 -12
  11. package/dist/providers/aws/route53.test.d.ts +1 -0
  12. package/dist/providers/aws/route53.test.js +229 -0
  13. package/dist/providers/aws/s3.d.ts +3 -0
  14. package/dist/providers/aws/s3.js +65 -3
  15. package/dist/providers/aws/s3.test.d.ts +1 -0
  16. package/dist/providers/aws/s3.test.js +172 -0
  17. package/dist/providers/do/api.js +5 -1
  18. package/dist/providers/do/certificate.test.d.ts +1 -0
  19. package/dist/providers/do/certificate.test.js +133 -0
  20. package/dist/providers/do/domain.d.ts +12 -1
  21. package/dist/providers/do/domain.js +129 -13
  22. package/dist/providers/do/domain.test.d.ts +1 -0
  23. package/dist/providers/do/domain.test.js +200 -0
  24. package/dist/providers/do/droplet.js +2 -2
  25. package/dist/providers/do/droplet.test.d.ts +1 -0
  26. package/dist/providers/do/droplet.test.js +265 -0
  27. package/dist/providers/do/firewall.test.d.ts +1 -0
  28. package/dist/providers/do/firewall.test.js +176 -0
  29. package/dist/providers/do/index.d.ts +1 -0
  30. package/dist/providers/do/index.js +1 -0
  31. package/dist/providers/do/load_balancer.d.ts +39 -5
  32. package/dist/providers/do/load_balancer.js +272 -30
  33. package/dist/providers/do/load_balancer.test.d.ts +1 -0
  34. package/dist/providers/do/load_balancer.test.js +269 -0
  35. package/dist/providers/firebase/api.js +2 -2
  36. package/dist/providers/firebase/functions.d.ts +1 -0
  37. package/dist/providers/firebase/functions.js +24 -10
  38. package/dist/providers/firebase/functions.test.d.ts +1 -0
  39. package/dist/providers/firebase/functions.test.js +297 -0
  40. package/dist/providers/firebase/hosting.js +5 -5
  41. package/dist/providers/firebase/hosting.test.d.ts +1 -0
  42. package/dist/providers/firebase/hosting.test.js +181 -0
  43. package/dist/providers/proxmox/index.d.ts +1 -0
  44. package/dist/providers/proxmox/index.js +1 -0
  45. package/dist/providers/proxmox/vm.d.ts +0 -1
  46. package/dist/providers/proxmox/vm.js +4 -50
  47. package/package.json +78 -5
@@ -1,6 +1,6 @@
1
- import { readFileSync } from "node:fs";
1
+ import fs from "node:fs";
2
2
  import { basename, extname } from "node:path";
3
- import { HeadBucketCommand, CreateBucketCommand, GetBucketPolicyCommand, PutBucketPolicyCommand, PutObjectCommand, } from "@aws-sdk/client-s3";
3
+ import { HeadBucketCommand, CreateBucketCommand, GetBucketPolicyCommand, PutBucketPolicyCommand, PutObjectCommand, PutBucketWebsiteCommand, PutPublicAccessBlockCommand, } from "@aws-sdk/client-s3";
4
4
  import { BaseBuilder } from "../../core/resource.js";
5
5
  import { getS3Client } from "./api.js";
6
6
  import { Config } from "../../core/config.js";
@@ -10,6 +10,7 @@ export class S3BucketBuilder extends BaseBuilder {
10
10
  _allowedDistributions = [];
11
11
  _region;
12
12
  _uploadPath;
13
+ _websiteConfig;
13
14
  constructor(bucketName) {
14
15
  super(bucketName);
15
16
  this.bucketName = bucketName;
@@ -48,6 +49,10 @@ export class S3BucketBuilder extends BaseBuilder {
48
49
  this._uploadPath = filePath;
49
50
  return this;
50
51
  }
52
+ staticSite(indexDocument = "index.html", errorDocument = "error.html") {
53
+ this._websiteConfig = { index: indexDocument, error: errorDocument };
54
+ return this;
55
+ }
51
56
  async deploy() {
52
57
  const dryRun = this.isDryRunActive();
53
58
  const exists = await this.discoveryPromise;
@@ -87,6 +92,34 @@ export class S3BucketBuilder extends BaseBuilder {
87
92
  await this.updateBucketPolicy(s3, newArns);
88
93
  }
89
94
  }
95
+ if (this._websiteConfig) {
96
+ if (dryRun) {
97
+ console.log(` šŸ“ [PLAN] Enable static site hosting: index=${this._websiteConfig.index}, error=${this._websiteConfig.error}`);
98
+ console.log(` šŸ“ [PLAN] Remove public access block from bucket`);
99
+ console.log(` šŸ“ [PLAN] Configure public read bucket policy`);
100
+ }
101
+ else {
102
+ await s3.send(new PutPublicAccessBlockCommand({
103
+ Bucket: this.bucketName,
104
+ PublicAccessBlockConfiguration: {
105
+ BlockPublicAcls: false,
106
+ IgnorePublicAcls: false,
107
+ BlockPublicPolicy: false,
108
+ RestrictPublicBuckets: false,
109
+ },
110
+ }));
111
+ console.log(` āœ… Public access block removed`);
112
+ await s3.send(new PutBucketWebsiteCommand({
113
+ Bucket: this.bucketName,
114
+ WebsiteConfiguration: {
115
+ IndexDocument: { Suffix: this._websiteConfig.index },
116
+ ErrorDocument: { Key: this._websiteConfig.error },
117
+ },
118
+ }));
119
+ console.log(` āœ… Configured static website hosting`);
120
+ await this.applyPublicReadPolicy(s3);
121
+ }
122
+ }
90
123
  if (this._uploadPath) {
91
124
  if (dryRun) {
92
125
  console.log(` šŸ“ [PLAN] Upload ${basename(this._uploadPath)} → s3://${this.bucketName}/`);
@@ -100,7 +133,7 @@ export class S3BucketBuilder extends BaseBuilder {
100
133
  }
101
134
  async uploadFile(s3, filePath) {
102
135
  const key = basename(filePath);
103
- const body = readFileSync(filePath);
136
+ const body = fs.readFileSync(filePath);
104
137
  const contentTypeMap = {
105
138
  ".json": "application/json",
106
139
  ".js": "application/javascript",
@@ -169,4 +202,33 @@ export class S3BucketBuilder extends BaseBuilder {
169
202
  for (const arn of newArns)
170
203
  console.log(` └─ ${arn}`);
171
204
  }
205
+ async applyPublicReadPolicy(s3) {
206
+ let policy = { Version: "2012-10-17", Statement: [] };
207
+ try {
208
+ const existing = await s3.send(new GetBucketPolicyCommand({ Bucket: this.bucketName }));
209
+ if (existing.Policy)
210
+ policy = JSON.parse(existing.Policy);
211
+ }
212
+ catch (e) {
213
+ if (e.name !== "NoSuchBucketPolicy")
214
+ throw e;
215
+ }
216
+ let stmt = policy.Statement.find((s) => s.Sid === "PublicReadGetObject" ||
217
+ (s.Effect === "Allow" && s.Principal === "*" && s.Action === "s3:GetObject"));
218
+ if (!stmt) {
219
+ stmt = {
220
+ Sid: "PublicReadGetObject",
221
+ Effect: "Allow",
222
+ Principal: "*",
223
+ Action: "s3:GetObject",
224
+ Resource: `arn:aws:s3:::${this.bucketName}/*`,
225
+ };
226
+ policy.Statement.push(stmt);
227
+ }
228
+ await s3.send(new PutBucketPolicyCommand({
229
+ Bucket: this.bucketName,
230
+ Policy: JSON.stringify(policy),
231
+ }));
232
+ console.log(` āœ… Public read policy statement applied`);
233
+ }
172
234
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,172 @@
1
+ import { test, describe, beforeEach, afterEach, mock } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import fs from 'node:fs';
4
+ import { S3Client } from '@aws-sdk/client-s3';
5
+ import { S3BucketBuilder } from './s3.js';
6
+ import { Config } from '../../core/config.js';
7
+ describe('S3BucketBuilder Unit Tests', () => {
8
+ let originalSend;
9
+ let s3Calls = [];
10
+ let mockS3Responses = {};
11
+ beforeEach(() => {
12
+ Config.set({
13
+ dryRun: false,
14
+ providers: {
15
+ aws: { region: 'us-east-1' }
16
+ }
17
+ });
18
+ s3Calls = [];
19
+ mockS3Responses = {};
20
+ originalSend = S3Client.prototype.send;
21
+ // Zero-dependency override on prototype to intercept S3 commands
22
+ S3Client.prototype.send = async function (command) {
23
+ const commandName = command.constructor.name;
24
+ const input = command.input;
25
+ s3Calls.push({ commandName, input });
26
+ if (mockS3Responses[commandName]) {
27
+ const handler = mockS3Responses[commandName];
28
+ if (typeof handler === 'function') {
29
+ return handler(input);
30
+ }
31
+ if (handler instanceof Error) {
32
+ throw handler;
33
+ }
34
+ return handler;
35
+ }
36
+ return {};
37
+ };
38
+ mock.method(fs, 'readFileSync', () => {
39
+ return Buffer.from('hello from s3 site');
40
+ });
41
+ });
42
+ afterEach(() => {
43
+ S3Client.prototype.send = originalSend;
44
+ mock.restoreAll();
45
+ });
46
+ test('gracefully handles discovery when bucket does not exist', async () => {
47
+ const notFoundError = new Error('NoSuchBucket');
48
+ notFoundError.name = 'NotFound';
49
+ notFoundError.$metadata = { httpStatusCode: 404 };
50
+ mockS3Responses['HeadBucketCommand'] = notFoundError;
51
+ const builder = new S3BucketBuilder('my-bucket');
52
+ const discoveryResult = await builder.discoveryPromise;
53
+ assert.strictEqual(discoveryResult, false);
54
+ assert.strictEqual(s3Calls.length, 1);
55
+ assert.strictEqual(s3Calls[0].commandName, 'HeadBucketCommand');
56
+ assert.strictEqual(s3Calls[0].input.Bucket, 'my-bucket');
57
+ });
58
+ test('discovers bucket successfully when it exists', async () => {
59
+ mockS3Responses['HeadBucketCommand'] = {};
60
+ const builder = new S3BucketBuilder('my-bucket');
61
+ const discoveryResult = await builder.discoveryPromise;
62
+ assert.strictEqual(discoveryResult, true);
63
+ assert.strictEqual(s3Calls.length, 1);
64
+ assert.strictEqual(s3Calls[0].commandName, 'HeadBucketCommand');
65
+ });
66
+ test('performs clean dry-run planning without making write requests', async () => {
67
+ Config.set({
68
+ dryRun: true,
69
+ providers: { aws: { region: 'us-east-1' } }
70
+ });
71
+ const notFoundError = new Error('NoSuchBucket');
72
+ notFoundError.name = 'NotFound';
73
+ notFoundError.$metadata = { httpStatusCode: 404 };
74
+ mockS3Responses['HeadBucketCommand'] = notFoundError;
75
+ const builder = new S3BucketBuilder('my-bucket');
76
+ builder.versioning().upload('/path/to/index.html');
77
+ const result = await builder.deploy();
78
+ assert.ok(result);
79
+ assert.strictEqual(result.name, 'my-bucket');
80
+ // Asserts HeadBucket was called for discovery, but no CreateBucket or PutObject
81
+ assert.strictEqual(s3Calls.filter(c => c.commandName !== 'HeadBucketCommand').length, 0);
82
+ });
83
+ test('deploys new bucket when missing', async () => {
84
+ const notFoundError = new Error('NoSuchBucket');
85
+ notFoundError.name = 'NotFound';
86
+ notFoundError.$metadata = { httpStatusCode: 404 };
87
+ mockS3Responses['HeadBucketCommand'] = notFoundError;
88
+ mockS3Responses['CreateBucketCommand'] = {};
89
+ const builder = new S3BucketBuilder('my-bucket');
90
+ const result = await builder.deploy();
91
+ assert.ok(result);
92
+ assert.strictEqual(result.name, 'my-bucket');
93
+ const createCall = s3Calls.find(c => c.commandName === 'CreateBucketCommand');
94
+ assert.ok(createCall);
95
+ assert.strictEqual(createCall.input.Bucket, 'my-bucket');
96
+ });
97
+ test('deploys static site with public access unblocking, website configuration, and public read policy', async () => {
98
+ const notFoundError = new Error('NoSuchBucket');
99
+ notFoundError.name = 'NotFound';
100
+ notFoundError.$metadata = { httpStatusCode: 404 };
101
+ mockS3Responses['HeadBucketCommand'] = notFoundError;
102
+ mockS3Responses['CreateBucketCommand'] = {};
103
+ mockS3Responses['PutPublicAccessBlockCommand'] = {};
104
+ mockS3Responses['PutBucketWebsiteCommand'] = {};
105
+ mockS3Responses['GetBucketPolicyCommand'] = new Error('NoSuchBucketPolicy');
106
+ mockS3Responses['GetBucketPolicyCommand'].name = 'NoSuchBucketPolicy';
107
+ mockS3Responses['PutBucketPolicyCommand'] = {};
108
+ const builder = new S3BucketBuilder('my-web-bucket');
109
+ builder.staticSite('index.html', 'error.html');
110
+ const result = await builder.deploy();
111
+ assert.ok(result);
112
+ // Verify Public Access Block removal
113
+ const blockCall = s3Calls.find(c => c.commandName === 'PutPublicAccessBlockCommand');
114
+ assert.ok(blockCall);
115
+ assert.deepStrictEqual(blockCall.input.PublicAccessBlockConfiguration, {
116
+ BlockPublicAcls: false,
117
+ IgnorePublicAcls: false,
118
+ BlockPublicPolicy: false,
119
+ RestrictPublicBuckets: false,
120
+ });
121
+ // Verify website configurations
122
+ const websiteCall = s3Calls.find(c => c.commandName === 'PutBucketWebsiteCommand');
123
+ assert.ok(websiteCall);
124
+ assert.deepStrictEqual(websiteCall.input.WebsiteConfiguration, {
125
+ IndexDocument: { Suffix: 'index.html' },
126
+ ErrorDocument: { Key: 'error.html' },
127
+ });
128
+ // Verify public read bucket policy upload
129
+ const policyCall = s3Calls.find(c => c.commandName === 'PutBucketPolicyCommand');
130
+ assert.ok(policyCall);
131
+ const parsedPolicy = JSON.parse(policyCall.input.Policy);
132
+ const stmt = parsedPolicy.Statement.find((s) => s.Sid === 'PublicReadGetObject');
133
+ assert.ok(stmt);
134
+ assert.strictEqual(stmt.Effect, 'Allow');
135
+ assert.strictEqual(stmt.Principal, '*');
136
+ assert.strictEqual(stmt.Resource, 'arn:aws:s3:::my-web-bucket/*');
137
+ });
138
+ test('attaches OAC CloudFront policy integration correctly', async () => {
139
+ mockS3Responses['HeadBucketCommand'] = {};
140
+ mockS3Responses['GetBucketPolicyCommand'] = new Error('NoSuchBucketPolicy');
141
+ mockS3Responses['GetBucketPolicyCommand'].name = 'NoSuchBucketPolicy';
142
+ mockS3Responses['PutBucketPolicyCommand'] = {};
143
+ const mockCdn = {
144
+ name: 'my-cdn',
145
+ resolvedArn: 'arn:aws:cloudfront::123456789012:distribution/ED12345'
146
+ };
147
+ const builder = new S3BucketBuilder('my-bucket');
148
+ builder.allowFrom(mockCdn);
149
+ await builder.deploy();
150
+ const policyCall = s3Calls.find(c => c.commandName === 'PutBucketPolicyCommand');
151
+ assert.ok(policyCall);
152
+ const parsedPolicy = JSON.parse(policyCall.input.Policy);
153
+ const stmt = parsedPolicy.Statement.find((s) => s.Principal.Service === 'cloudfront.amazonaws.com');
154
+ assert.ok(stmt);
155
+ assert.strictEqual(stmt.Effect, 'Allow');
156
+ const sourceArns = stmt.Condition.StringEquals['AWS:SourceArn'];
157
+ assert.deepStrictEqual(sourceArns, ['arn:aws:cloudfront::123456789012:distribution/ED12345']);
158
+ });
159
+ test('uploads local file with mapped content type', async () => {
160
+ mockS3Responses['HeadBucketCommand'] = {};
161
+ mockS3Responses['PutObjectCommand'] = {};
162
+ const builder = new S3BucketBuilder('my-bucket');
163
+ builder.upload('/path/to/my-page.html');
164
+ await builder.deploy();
165
+ const uploadCall = s3Calls.find(c => c.commandName === 'PutObjectCommand');
166
+ assert.ok(uploadCall);
167
+ assert.strictEqual(uploadCall.input.Bucket, 'my-bucket');
168
+ assert.strictEqual(uploadCall.input.Key, 'my-page.html');
169
+ assert.strictEqual(uploadCall.input.ContentType, 'text/html');
170
+ assert.deepStrictEqual(uploadCall.input.Body, Buffer.from('hello from s3 site'));
171
+ });
172
+ });
@@ -6,7 +6,11 @@ export class DoApiClient {
6
6
  this.token = token;
7
7
  }
8
8
  get authHeaders() {
9
- return { Authorization: `Bearer ${this.token}`, 'Content-Type': 'application/json' };
9
+ return {
10
+ Authorization: `Bearer ${this.token}`,
11
+ 'Content-Type': 'application/json',
12
+ 'Accept-Encoding': 'identity'
13
+ };
10
14
  }
11
15
  async get(path) {
12
16
  const res = await fetch(`${DoApiClient.BASE}${path}`, { headers: this.authHeaders });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,133 @@
1
+ import { test, describe, beforeEach, afterEach } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import { CertificateBuilder } from './certificate.js';
4
+ import { Config } from '../../core/config.js';
5
+ describe('CertificateBuilder Unit Tests', () => {
6
+ let originalFetch;
7
+ let fetchCalls = [];
8
+ let mockResponses = {};
9
+ beforeEach(() => {
10
+ Config.set({
11
+ dryRun: false,
12
+ providers: {
13
+ do: { token: 'fake-do-token' }
14
+ }
15
+ });
16
+ originalFetch = globalThis.fetch;
17
+ fetchCalls = [];
18
+ mockResponses = {};
19
+ globalThis.fetch = async (input, init) => {
20
+ const url = String(input);
21
+ const method = init?.method ?? 'GET';
22
+ const body = init?.body ? JSON.parse(init.body) : undefined;
23
+ const headers = init?.headers;
24
+ fetchCalls.push({ url, method, body, headers });
25
+ const matchKey = Object.keys(mockResponses).find(key => {
26
+ const [mMethod, mPath] = key.split(' ');
27
+ return method === mMethod && url.includes(mPath);
28
+ });
29
+ if (matchKey) {
30
+ const resp = mockResponses[matchKey];
31
+ return {
32
+ ok: resp.status >= 200 && resp.status < 300,
33
+ status: resp.status,
34
+ json: async () => resp.body,
35
+ text: async () => JSON.stringify(resp.body),
36
+ };
37
+ }
38
+ return {
39
+ ok: false,
40
+ status: 404,
41
+ json: async () => ({ message: 'Not found' }),
42
+ text: async () => 'Not found',
43
+ };
44
+ };
45
+ });
46
+ afterEach(() => {
47
+ globalThis.fetch = originalFetch;
48
+ });
49
+ test('gracefully handles discovery when certificate does not exist', async () => {
50
+ mockResponses['GET /certificates'] = {
51
+ status: 200,
52
+ body: { certificates: [] }
53
+ };
54
+ const builder = new CertificateBuilder('example.com');
55
+ const discoveryResult = await builder.discoveryPromise;
56
+ assert.strictEqual(discoveryResult, null);
57
+ assert.strictEqual(fetchCalls.length, 1);
58
+ assert.strictEqual(fetchCalls[0].method, 'GET');
59
+ assert.ok(fetchCalls[0].url.endsWith('/certificates?per_page=200'));
60
+ });
61
+ test('discovers certificate successfully when it exists', async () => {
62
+ mockResponses['GET /certificates'] = {
63
+ status: 200,
64
+ body: {
65
+ certificates: [
66
+ { id: 'cert-123', name: 'ssl-example.com', state: 'active' }
67
+ ]
68
+ }
69
+ };
70
+ const builder = new CertificateBuilder('example.com');
71
+ const discoveryResult = await builder.discoveryPromise;
72
+ assert.ok(discoveryResult);
73
+ assert.strictEqual(discoveryResult.id, 'cert-123');
74
+ assert.strictEqual(discoveryResult.name, 'ssl-example.com');
75
+ });
76
+ test('performs clean dry-run planning without making write requests', async () => {
77
+ Config.set({
78
+ dryRun: true,
79
+ providers: { do: { token: 'fake-token' } }
80
+ });
81
+ mockResponses['GET /certificates'] = {
82
+ status: 200,
83
+ body: { certificates: [] }
84
+ };
85
+ const builder = new CertificateBuilder('example.com');
86
+ const result = await builder.deploy();
87
+ assert.ok(result);
88
+ assert.strictEqual(result.name, 'ssl-example.com');
89
+ const writeCalls = fetchCalls.filter(c => c.method !== 'GET');
90
+ assert.strictEqual(writeCalls.length, 0);
91
+ });
92
+ test('deploys new certificate when missing', async () => {
93
+ mockResponses['GET /certificates'] = {
94
+ status: 200,
95
+ body: { certificates: [] }
96
+ };
97
+ mockResponses['POST /certificates'] = {
98
+ status: 201,
99
+ body: {
100
+ certificate: { id: 'cert-789', name: 'ssl-example.com', type: 'lets_encrypt' }
101
+ }
102
+ };
103
+ const builder = new CertificateBuilder('example.com');
104
+ const result = await builder.deploy();
105
+ assert.ok(result);
106
+ assert.strictEqual(result.id, 'cert-789');
107
+ const postCall = fetchCalls.find(c => c.method === 'POST');
108
+ assert.ok(postCall);
109
+ assert.ok(postCall.url.endsWith('/certificates'));
110
+ assert.deepStrictEqual(postCall.body, {
111
+ name: 'ssl-example.com',
112
+ type: 'lets_encrypt',
113
+ dns_names: ['*.example.com', 'example.com']
114
+ });
115
+ });
116
+ test('skips certificate deployment if certificate already exists', async () => {
117
+ mockResponses['GET /certificates'] = {
118
+ status: 200,
119
+ body: {
120
+ certificates: [
121
+ { id: 'cert-123', name: 'ssl-example.com', state: 'active' }
122
+ ]
123
+ }
124
+ };
125
+ const builder = new CertificateBuilder('example.com');
126
+ const result = await builder.deploy();
127
+ assert.ok(result);
128
+ assert.strictEqual(result.id, 'cert-123');
129
+ // Only GET discovery should have run, no writes
130
+ const writeCalls = fetchCalls.filter(c => c.method !== 'GET');
131
+ assert.strictEqual(writeCalls.length, 0);
132
+ });
133
+ });
@@ -2,9 +2,14 @@ import { BaseBuilder } from "../../core/resource.js";
2
2
  import { Output } from "../../core/output.js";
3
3
  import { DropletBuilder } from "./droplet.js";
4
4
  export interface DNSRecord {
5
- type: "A" | "CNAME" | "TXT" | "MX";
5
+ type: "A" | "CNAME" | "TXT" | "MX" | "AAAA" | "SRV" | "CAA";
6
6
  name: string;
7
7
  value: string | DropletBuilder | Output<string>;
8
+ priority?: number;
9
+ port?: number;
10
+ weight?: number;
11
+ flags?: number;
12
+ tag?: string;
8
13
  }
9
14
  export declare class DomainBuilder extends BaseBuilder {
10
15
  domainName: string;
@@ -14,8 +19,14 @@ export declare class DomainBuilder extends BaseBuilder {
14
19
  withSSL(): this;
15
20
  pointer(name: string, target: DropletBuilder | Output<string> | string): this;
16
21
  cname(name: string, target: string): this;
22
+ aaaa(name: string, target: string | Output<string>): this;
23
+ txt(name: string, target: string): this;
24
+ mx(name: string, target: string, priority?: number): this;
25
+ srv(name: string, target: string, port: number, priority?: number, weight?: number): this;
26
+ caa(name: string, tag: string, target: string, flags?: number): this;
17
27
  deploy(): Promise<{
18
28
  domain: string;
19
29
  records: DNSRecord[];
20
30
  }>;
31
+ destroy(): Promise<any>;
21
32
  }
@@ -35,6 +35,26 @@ export class DomainBuilder extends BaseBuilder {
35
35
  this.records.push({ type: "CNAME", name, value: target });
36
36
  return this;
37
37
  }
38
+ aaaa(name, target) {
39
+ this.records.push({ type: "AAAA", name, value: target });
40
+ return this;
41
+ }
42
+ txt(name, target) {
43
+ this.records.push({ type: "TXT", name, value: target });
44
+ return this;
45
+ }
46
+ mx(name, target, priority = 10) {
47
+ this.records.push({ type: "MX", name, value: target, priority });
48
+ return this;
49
+ }
50
+ srv(name, target, port, priority = 10, weight = 10) {
51
+ this.records.push({ type: "SRV", name, value: target, port, priority, weight });
52
+ return this;
53
+ }
54
+ caa(name, tag, target, flags = 0) {
55
+ this.records.push({ type: "CAA", name, value: target, tag, flags });
56
+ return this;
57
+ }
38
58
  async deploy() {
39
59
  const dryRun = this.isDryRunActive();
40
60
  const existing = await this.discoveryPromise;
@@ -49,6 +69,19 @@ export class DomainBuilder extends BaseBuilder {
49
69
  console.log(`šŸš€ Created domain ${this.domainName}`);
50
70
  }
51
71
  }
72
+ // Fetch existing records in a single batch
73
+ let existingRecords = [];
74
+ if (existing) {
75
+ try {
76
+ const res = await api.get(`/domains/${this.domainName}/records?per_page=200`);
77
+ existingRecords = res.domain_records;
78
+ }
79
+ catch {
80
+ existingRecords = [];
81
+ }
82
+ }
83
+ // Track matched existing records
84
+ const consumedRecordIds = new Set();
52
85
  for (const record of this.records) {
53
86
  let data;
54
87
  if (record.value instanceof Output) {
@@ -62,24 +95,107 @@ export class DomainBuilder extends BaseBuilder {
62
95
  else {
63
96
  data = record.value;
64
97
  }
65
- if (dryRun) {
66
- console.log(` šŸ“ [PLAN] ${record.type} ${record.name}.${this.domainName} → ${data}`);
98
+ const targetPriority = record.priority ?? null;
99
+ const targetPort = record.port ?? null;
100
+ const targetWeight = record.weight ?? null;
101
+ const targetFlags = record.flags ?? null;
102
+ const targetTag = record.tag ?? null;
103
+ // 1. Check for a perfect match
104
+ const perfectMatch = existingRecords.find((r) => {
105
+ if (consumedRecordIds.has(r.id))
106
+ return false;
107
+ return (r.type === record.type &&
108
+ r.name === record.name &&
109
+ String(r.data) === String(data) &&
110
+ (r.priority ?? null) === targetPriority &&
111
+ (r.port ?? null) === targetPort &&
112
+ (r.weight ?? null) === targetWeight &&
113
+ (r.flags ?? null) === targetFlags &&
114
+ (r.tag ?? null) === targetTag);
115
+ });
116
+ if (perfectMatch) {
117
+ consumedRecordIds.add(perfectMatch.id);
118
+ console.log(` āœ… ${record.type} ${record.name}.${this.domainName} is up to date (→ ${data})`);
67
119
  continue;
68
120
  }
69
- // Delete existing record with same type+name before creating
70
- const existing_records = await api.get(`/domains/${this.domainName}/records?per_page=200`);
71
- const dupe = existing_records.domain_records.find((r) => r.type === record.type && r.name === record.name);
72
- if (dupe)
73
- await api.delete(`/domains/${this.domainName}/records/${dupe.id}`);
74
- await api.post(`/domains/${this.domainName}/records`, {
75
- type: record.type,
76
- name: record.name,
77
- data,
78
- ttl: 3600,
121
+ // 2. Look for updateable match (same type and name, different data)
122
+ const updateableMatch = existingRecords.find((r) => {
123
+ if (consumedRecordIds.has(r.id))
124
+ return false;
125
+ return r.type === record.type && r.name === record.name;
79
126
  });
80
- console.log(` āœ… ${record.type} ${record.name}.${this.domainName} → ${data}`);
127
+ if (updateableMatch) {
128
+ consumedRecordIds.add(updateableMatch.id);
129
+ if (dryRun) {
130
+ console.log(` šŸ“ [PLAN] Update ${record.type} ${record.name}.${this.domainName} → ${data} (was ${updateableMatch.data})`);
131
+ }
132
+ else {
133
+ await api.put(`/domains/${this.domainName}/records/${updateableMatch.id}`, {
134
+ type: record.type,
135
+ name: record.name,
136
+ data,
137
+ ttl: 3600,
138
+ priority: targetPriority,
139
+ port: targetPort,
140
+ weight: targetWeight,
141
+ flags: targetFlags,
142
+ tag: targetTag,
143
+ });
144
+ console.log(` šŸ”„ Updated ${record.type} ${record.name}.${this.domainName} → ${data}`);
145
+ }
146
+ }
147
+ else {
148
+ // 3. No match found, create a new record
149
+ if (dryRun) {
150
+ console.log(` šŸ“ [PLAN] Create ${record.type} ${record.name}.${this.domainName} → ${data}`);
151
+ }
152
+ else {
153
+ await api.post(`/domains/${this.domainName}/records`, {
154
+ type: record.type,
155
+ name: record.name,
156
+ data,
157
+ ttl: 3600,
158
+ priority: targetPriority,
159
+ port: targetPort,
160
+ weight: targetWeight,
161
+ flags: targetFlags,
162
+ tag: targetTag,
163
+ });
164
+ console.log(` šŸš€ Created ${record.type} ${record.name}.${this.domainName} → ${data}`);
165
+ }
166
+ }
167
+ }
168
+ // 4. Delete duplicate/stale records of the types we declared
169
+ const declaredTypesAndNames = new Set(this.records.map((r) => `${r.type}:${r.name}`));
170
+ for (const r of existingRecords) {
171
+ if (consumedRecordIds.has(r.id))
172
+ continue;
173
+ if (declaredTypesAndNames.has(`${r.type}:${r.name}`)) {
174
+ if (dryRun) {
175
+ console.log(` šŸ“ [PLAN] Delete stale ${r.type} ${r.name}.${this.domainName} (→ ${r.data})`);
176
+ }
177
+ else {
178
+ await api.delete(`/domains/${this.domainName}/records/${r.id}`);
179
+ console.log(` šŸ—‘ļø Deleted stale ${r.type} ${r.name}.${this.domainName}`);
180
+ }
181
+ }
81
182
  }
82
183
  await this.deploySidecars();
83
184
  return { domain: this.domainName, records: this.records };
84
185
  }
186
+ async destroy() {
187
+ const dryRun = this.isDryRunActive();
188
+ await this.discoveryPromise;
189
+ console.log(`\nšŸ—‘ļø Destroying DNS domain "${this.domainName}"...`);
190
+ if (dryRun) {
191
+ console.log(` šŸ“ [PLAN] Would delete domain ${this.domainName}`);
192
+ }
193
+ else {
194
+ const api = getDoApi();
195
+ await api.delete(`/domains/${this.domainName}`);
196
+ console.log(` āœ… Deleted.`);
197
+ }
198
+ await this.destroySidecars();
199
+ return { destroyed: this.domainName };
200
+ }
85
201
  }
@@ -0,0 +1 @@
1
+ export {};