puls-dev 0.1.9 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -8
- package/dist/index.d.ts +0 -7
- package/dist/index.js +0 -7
- package/dist/providers/aws/api.d.ts +4 -0
- package/dist/providers/aws/api.js +4 -0
- package/dist/providers/aws/cloudwatch.d.ts +44 -0
- package/dist/providers/aws/cloudwatch.js +205 -0
- package/dist/providers/aws/cloudwatch.test.d.ts +1 -0
- package/dist/providers/aws/cloudwatch.test.js +224 -0
- package/dist/providers/aws/fargate.d.ts +2 -0
- package/dist/providers/aws/fargate.js +6 -0
- package/dist/providers/aws/iam.d.ts +52 -0
- package/dist/providers/aws/iam.js +307 -0
- package/dist/providers/aws/iam.test.d.ts +1 -0
- package/dist/providers/aws/iam.test.js +367 -0
- package/dist/providers/aws/index.d.ts +8 -0
- package/dist/providers/aws/index.js +8 -0
- package/dist/providers/aws/lambda.d.ts +3 -1
- package/dist/providers/aws/lambda.js +17 -8
- package/dist/providers/aws/lambda.test.d.ts +1 -0
- package/dist/providers/aws/lambda.test.js +189 -0
- package/dist/providers/aws/rds.d.ts +1 -0
- package/dist/providers/aws/rds.js +4 -1
- package/dist/providers/aws/route53.d.ts +1 -1
- package/dist/providers/aws/route53.js +20 -12
- package/dist/providers/aws/route53.test.d.ts +1 -0
- package/dist/providers/aws/route53.test.js +229 -0
- package/dist/providers/aws/s3.d.ts +3 -0
- package/dist/providers/aws/s3.js +65 -3
- package/dist/providers/aws/s3.test.d.ts +1 -0
- package/dist/providers/aws/s3.test.js +172 -0
- package/dist/providers/aws/sns.d.ts +22 -0
- package/dist/providers/aws/sns.js +146 -0
- package/dist/providers/aws/sns.test.d.ts +1 -0
- package/dist/providers/aws/sns.test.js +162 -0
- package/dist/providers/do/api.js +5 -1
- package/dist/providers/do/certificate.test.d.ts +1 -0
- package/dist/providers/do/certificate.test.js +133 -0
- package/dist/providers/do/domain.d.ts +12 -1
- package/dist/providers/do/domain.js +129 -13
- package/dist/providers/do/domain.test.d.ts +1 -0
- package/dist/providers/do/domain.test.js +200 -0
- package/dist/providers/do/droplet.js +2 -2
- package/dist/providers/do/droplet.test.d.ts +1 -0
- package/dist/providers/do/droplet.test.js +265 -0
- package/dist/providers/do/firewall.test.d.ts +1 -0
- package/dist/providers/do/firewall.test.js +176 -0
- package/dist/providers/do/index.d.ts +1 -0
- package/dist/providers/do/index.js +1 -0
- package/dist/providers/do/load_balancer.d.ts +39 -5
- package/dist/providers/do/load_balancer.js +272 -30
- package/dist/providers/do/load_balancer.test.d.ts +1 -0
- package/dist/providers/do/load_balancer.test.js +269 -0
- package/dist/providers/firebase/api.js +2 -2
- package/dist/providers/firebase/functions.d.ts +1 -0
- package/dist/providers/firebase/functions.js +24 -10
- package/dist/providers/firebase/functions.test.d.ts +1 -0
- package/dist/providers/firebase/functions.test.js +297 -0
- package/dist/providers/firebase/hosting.js +5 -5
- package/dist/providers/firebase/hosting.test.d.ts +1 -0
- package/dist/providers/firebase/hosting.test.js +181 -0
- package/dist/providers/proxmox/index.d.ts +1 -0
- package/dist/providers/proxmox/index.js +1 -0
- package/dist/providers/proxmox/vm.d.ts +2 -1
- package/dist/providers/proxmox/vm.js +39 -53
- package/dist/providers/proxmox/vm.test.d.ts +1 -0
- package/dist/providers/proxmox/vm.test.js +155 -0
- package/dist/types/aws.d.ts +11 -0
- package/package.json +105 -6
|
@@ -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
|
-
|
|
66
|
-
|
|
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
|
-
//
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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 {};
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { test, describe, beforeEach, afterEach } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import { DomainBuilder } from "./domain.js";
|
|
4
|
+
import { Config } from "../../core/config.js";
|
|
5
|
+
describe("DomainBuilder 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.endsWith(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 domain does not exist", async () => {
|
|
50
|
+
mockResponses["GET /domains/new-domain.com"] = {
|
|
51
|
+
status: 404,
|
|
52
|
+
body: { message: "Domain not found" }
|
|
53
|
+
};
|
|
54
|
+
const builder = new DomainBuilder("new-domain.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("/domains/new-domain.com"));
|
|
60
|
+
});
|
|
61
|
+
test("discovers domain successfully when it exists", async () => {
|
|
62
|
+
mockResponses["GET /domains/exists.com"] = {
|
|
63
|
+
status: 200,
|
|
64
|
+
body: {
|
|
65
|
+
domain: {
|
|
66
|
+
name: "exists.com",
|
|
67
|
+
ttl: 1800,
|
|
68
|
+
zone_file: "..."
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
const builder = new DomainBuilder("exists.com");
|
|
73
|
+
const discoveryResult = await builder.discoveryPromise;
|
|
74
|
+
assert.ok(discoveryResult);
|
|
75
|
+
assert.strictEqual(discoveryResult.name, "exists.com");
|
|
76
|
+
assert.strictEqual(fetchCalls.length, 1);
|
|
77
|
+
});
|
|
78
|
+
test("performs clean dry-run planning without making write requests", async () => {
|
|
79
|
+
Config.set({
|
|
80
|
+
dryRun: true,
|
|
81
|
+
providers: { do: { token: "fake-token" } }
|
|
82
|
+
});
|
|
83
|
+
mockResponses["GET /domains/dryrun.com"] = {
|
|
84
|
+
status: 404,
|
|
85
|
+
body: { message: "Domain not found" }
|
|
86
|
+
};
|
|
87
|
+
const builder = new DomainBuilder("dryrun.com");
|
|
88
|
+
builder.pointer("www", "1.2.3.4");
|
|
89
|
+
builder.cname("blog", "blog.dryrun.com");
|
|
90
|
+
const result = await builder.deploy();
|
|
91
|
+
assert.deepStrictEqual(result, {
|
|
92
|
+
domain: "dryrun.com",
|
|
93
|
+
records: [
|
|
94
|
+
{ type: "A", name: "www", value: "1.2.3.4" },
|
|
95
|
+
{ type: "CNAME", name: "blog", value: "blog.dryrun.com" }
|
|
96
|
+
]
|
|
97
|
+
});
|
|
98
|
+
const writeCalls = fetchCalls.filter(c => c.method !== "GET");
|
|
99
|
+
assert.strictEqual(writeCalls.length, 0);
|
|
100
|
+
});
|
|
101
|
+
test("deploys new domain and creates records when domain is missing", async () => {
|
|
102
|
+
mockResponses["GET /domains/new.com"] = {
|
|
103
|
+
status: 404,
|
|
104
|
+
body: { message: "Domain not found" }
|
|
105
|
+
};
|
|
106
|
+
mockResponses["POST /domains"] = {
|
|
107
|
+
status: 201,
|
|
108
|
+
body: { domain: { name: "new.com" } }
|
|
109
|
+
};
|
|
110
|
+
mockResponses["POST /domains/new.com/records"] = {
|
|
111
|
+
status: 201,
|
|
112
|
+
body: { domain_record: { id: 101 } }
|
|
113
|
+
};
|
|
114
|
+
const builder = new DomainBuilder("new.com");
|
|
115
|
+
builder.pointer("www", "5.6.7.8");
|
|
116
|
+
await builder.deploy();
|
|
117
|
+
assert.strictEqual(fetchCalls.length, 3);
|
|
118
|
+
assert.strictEqual(fetchCalls[0].method, "GET");
|
|
119
|
+
assert.strictEqual(fetchCalls[1].method, "POST");
|
|
120
|
+
assert.ok(fetchCalls[1].url.endsWith("/domains"));
|
|
121
|
+
assert.deepStrictEqual(fetchCalls[1].body, { name: "new.com" });
|
|
122
|
+
assert.strictEqual(fetchCalls[2].method, "POST");
|
|
123
|
+
assert.ok(fetchCalls[2].url.endsWith("/domains/new.com/records"));
|
|
124
|
+
assert.deepStrictEqual(fetchCalls[2].body, {
|
|
125
|
+
type: "A",
|
|
126
|
+
name: "www",
|
|
127
|
+
data: "5.6.7.8",
|
|
128
|
+
ttl: 3600,
|
|
129
|
+
priority: null,
|
|
130
|
+
port: null,
|
|
131
|
+
weight: null,
|
|
132
|
+
flags: null,
|
|
133
|
+
tag: null
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
test("syncs records: skips matching, updates out-of-date, deletes stale/duplicate", async () => {
|
|
137
|
+
mockResponses["GET /domains/sync.com"] = {
|
|
138
|
+
status: 200,
|
|
139
|
+
body: { domain: { name: "sync.com" } }
|
|
140
|
+
};
|
|
141
|
+
mockResponses["GET /domains/sync.com/records?per_page=200"] = {
|
|
142
|
+
status: 200,
|
|
143
|
+
body: {
|
|
144
|
+
domain_records: [
|
|
145
|
+
{ id: 10, type: "A", name: "www", data: "1.1.1.1" },
|
|
146
|
+
{ id: 20, type: "A", name: "api", data: "2.2.2.2" },
|
|
147
|
+
{ id: 30, type: "A", name: "api", data: "4.4.4.4" }
|
|
148
|
+
]
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
mockResponses["PUT /domains/sync.com/records/20"] = {
|
|
152
|
+
status: 200,
|
|
153
|
+
body: { domain_record: { id: 20 } }
|
|
154
|
+
};
|
|
155
|
+
mockResponses["DELETE /domains/sync.com/records/30"] = {
|
|
156
|
+
status: 204,
|
|
157
|
+
body: {}
|
|
158
|
+
};
|
|
159
|
+
const builder = new DomainBuilder("sync.com");
|
|
160
|
+
builder.pointer("www", "1.1.1.1");
|
|
161
|
+
builder.pointer("api", "3.3.3.3");
|
|
162
|
+
await builder.deploy();
|
|
163
|
+
const putCall = fetchCalls.find(c => c.method === "PUT");
|
|
164
|
+
assert.ok(putCall);
|
|
165
|
+
assert.ok(putCall.url.endsWith("/domains/sync.com/records/20"));
|
|
166
|
+
assert.deepStrictEqual(putCall.body, {
|
|
167
|
+
type: "A",
|
|
168
|
+
name: "api",
|
|
169
|
+
data: "3.3.3.3",
|
|
170
|
+
ttl: 3600,
|
|
171
|
+
priority: null,
|
|
172
|
+
port: null,
|
|
173
|
+
weight: null,
|
|
174
|
+
flags: null,
|
|
175
|
+
tag: null
|
|
176
|
+
});
|
|
177
|
+
const deleteCall = fetchCalls.find(c => c.method === "DELETE");
|
|
178
|
+
assert.ok(deleteCall);
|
|
179
|
+
assert.ok(deleteCall.url.endsWith("/domains/sync.com/records/30"));
|
|
180
|
+
const postCall = fetchCalls.find(c => c.method === "POST");
|
|
181
|
+
assert.strictEqual(postCall, undefined);
|
|
182
|
+
});
|
|
183
|
+
test("destroys domain successfully", async () => {
|
|
184
|
+
mockResponses["GET /domains/destroy.com"] = {
|
|
185
|
+
status: 200,
|
|
186
|
+
body: { domain: { name: "destroy.com" } }
|
|
187
|
+
};
|
|
188
|
+
mockResponses["DELETE /domains/destroy.com"] = {
|
|
189
|
+
status: 204,
|
|
190
|
+
body: {}
|
|
191
|
+
};
|
|
192
|
+
const builder = new DomainBuilder("destroy.com");
|
|
193
|
+
await builder.discoveryPromise;
|
|
194
|
+
const result = await builder.destroy();
|
|
195
|
+
assert.deepStrictEqual(result, { destroyed: "destroy.com" });
|
|
196
|
+
const deleteCall = fetchCalls.find(c => c.method === "DELETE");
|
|
197
|
+
assert.ok(deleteCall);
|
|
198
|
+
assert.ok(deleteCall.url.endsWith("/domains/destroy.com"));
|
|
199
|
+
});
|
|
200
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import fs from 'fs';
|
|
2
2
|
import { homedir } from 'os';
|
|
3
3
|
import { OS, REGION, SIZE, NETWORK } from '../../types/do.js';
|
|
4
4
|
import { Config } from '../../core/config.js';
|
|
@@ -71,7 +71,7 @@ export class DropletBuilder extends BaseBuilder {
|
|
|
71
71
|
}
|
|
72
72
|
async resolveOrRegisterSshKey(api) {
|
|
73
73
|
const pubPath = this.sshKeyPath.replace(/\.pub$/, '') + '.pub';
|
|
74
|
-
const pubKey = readFileSync(pubPath, 'utf8').trim();
|
|
74
|
+
const pubKey = fs.readFileSync(pubPath, 'utf8').trim();
|
|
75
75
|
const { ssh_keys } = await api.get('/account/keys?per_page=200');
|
|
76
76
|
const existing = ssh_keys.find(k => k.public_key.trim() === pubKey);
|
|
77
77
|
if (existing)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|