puls-dev 0.2.7 ā 0.2.9
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/dist/core/checker.js +71 -0
- package/dist/core/config.d.ts +6 -0
- package/dist/core/config.js +11 -1
- package/dist/core/context.d.ts +14 -0
- package/dist/core/context.js +2 -0
- package/dist/core/decorators.d.ts +4 -0
- package/dist/core/decorators.js +56 -30
- package/dist/core/hooks.d.ts +21 -0
- package/dist/core/hooks.js +116 -0
- package/dist/core/hooks.test.d.ts +1 -0
- package/dist/core/hooks.test.js +194 -0
- package/dist/core/multiregion.test.d.ts +1 -0
- package/dist/core/multiregion.test.js +87 -0
- package/dist/core/output.d.ts +2 -0
- package/dist/core/output.js +9 -2
- package/dist/core/parallel.test.d.ts +1 -0
- package/dist/core/parallel.test.js +215 -0
- package/dist/core/parser.d.ts +10 -0
- package/dist/core/parser.js +140 -0
- package/dist/core/parser.test.d.ts +1 -0
- package/dist/core/parser.test.js +117 -0
- package/dist/core/production.test.d.ts +1 -0
- package/dist/core/production.test.js +189 -0
- package/dist/core/provisioner.d.ts +4 -0
- package/dist/core/provisioner.js +123 -0
- package/dist/core/resource.d.ts +23 -0
- package/dist/core/resource.js +54 -0
- package/dist/core/retry.d.ts +9 -0
- package/dist/core/retry.js +28 -0
- package/dist/core/retry.test.d.ts +1 -0
- package/dist/core/retry.test.js +66 -0
- package/dist/core/secret.d.ts +41 -0
- package/dist/core/secret.js +105 -0
- package/dist/core/secret.test.d.ts +1 -0
- package/dist/core/secret.test.js +166 -0
- package/dist/core/stack.d.ts +4 -3
- package/dist/core/stack.js +322 -48
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/providers/aws/api.js +97 -17
- package/dist/providers/aws/ec2.d.ts +51 -0
- package/dist/providers/aws/ec2.js +331 -0
- package/dist/providers/aws/ec2.test.d.ts +1 -0
- package/dist/providers/aws/ec2.test.js +281 -0
- package/dist/providers/aws/index.d.ts +4 -0
- package/dist/providers/aws/index.js +4 -0
- package/dist/providers/aws/route53.d.ts +1 -0
- package/dist/providers/aws/route53.js +15 -2
- package/dist/providers/aws/route53.test.js +47 -0
- package/dist/providers/aws/template.d.ts +34 -0
- package/dist/providers/aws/template.js +252 -0
- package/dist/providers/aws/template.test.d.ts +1 -0
- package/dist/providers/aws/template.test.js +208 -0
- package/dist/providers/do/api.d.ts +3 -1
- package/dist/providers/do/api.js +126 -27
- package/dist/providers/do/app.d.ts +26 -0
- package/dist/providers/do/app.js +124 -0
- package/dist/providers/do/app.test.d.ts +1 -0
- package/dist/providers/do/app.test.js +268 -0
- package/dist/providers/do/database.d.ts +44 -0
- package/dist/providers/do/database.js +208 -0
- package/dist/providers/do/database.test.d.ts +1 -0
- package/dist/providers/do/database.test.js +293 -0
- package/dist/providers/do/domain.d.ts +2 -0
- package/dist/providers/do/domain.js +30 -0
- package/dist/providers/do/domain.test.js +49 -0
- package/dist/providers/do/droplet.d.ts +9 -0
- package/dist/providers/do/droplet.js +146 -8
- package/dist/providers/do/droplet.test.js +228 -1
- package/dist/providers/do/firewall.d.ts +2 -1
- package/dist/providers/do/firewall.js +23 -9
- package/dist/providers/do/firewall.test.js +54 -0
- package/dist/providers/do/index.d.ts +11 -0
- package/dist/providers/do/index.js +8 -0
- package/dist/providers/do/spaces.d.ts +27 -0
- package/dist/providers/do/spaces.js +142 -0
- package/dist/providers/do/spaces.test.d.ts +1 -0
- package/dist/providers/do/spaces.test.js +180 -0
- package/dist/providers/do/spaces_api.d.ts +2 -0
- package/dist/providers/do/spaces_api.js +20 -0
- package/dist/providers/do/vpc.d.ts +30 -0
- package/dist/providers/do/vpc.js +128 -0
- package/dist/providers/do/vpc.test.d.ts +1 -0
- package/dist/providers/do/vpc.test.js +258 -0
- package/dist/providers/firebase/api.js +92 -29
- package/dist/providers/firebase/list.d.ts +2 -0
- package/dist/providers/firebase/list.js +25 -0
- package/dist/providers/gcp/api.js +88 -14
- package/dist/providers/gcp/clouddns.d.ts +1 -0
- package/dist/providers/gcp/clouddns.js +15 -2
- package/dist/providers/gcp/clouddns.test.js +45 -0
- package/dist/providers/gcp/index.d.ts +5 -1
- package/dist/providers/gcp/index.js +5 -1
- package/dist/providers/gcp/list.d.ts +2 -0
- package/dist/providers/gcp/list.js +55 -0
- package/dist/providers/gcp/secrets.js +1 -1
- package/dist/providers/gcp/template.d.ts +32 -0
- package/dist/providers/gcp/template.js +252 -0
- package/dist/providers/gcp/template.test.d.ts +1 -0
- package/dist/providers/gcp/template.test.js +227 -0
- package/dist/providers/gcp/vm.d.ts +48 -0
- package/dist/providers/gcp/vm.js +375 -0
- package/dist/providers/gcp/vm.test.d.ts +1 -0
- package/dist/providers/gcp/vm.test.js +321 -0
- package/dist/providers/proxmox/api.d.ts +1 -0
- package/dist/providers/proxmox/api.js +72 -16
- package/dist/providers/proxmox/index.d.ts +2 -0
- package/dist/providers/proxmox/index.js +2 -0
- package/dist/providers/proxmox/template.d.ts +44 -0
- package/dist/providers/proxmox/template.js +349 -0
- package/dist/providers/proxmox/template.test.d.ts +1 -0
- package/dist/providers/proxmox/template.test.js +179 -0
- package/dist/providers/proxmox/vm.d.ts +7 -4
- package/dist/providers/proxmox/vm.js +57 -102
- package/dist/providers/proxmox/vm.test.js +77 -0
- package/dist/types/inventory.d.ts +44 -1
- package/package.json +3 -1
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { test, describe, beforeEach, afterEach, mock } from 'node:test';
|
|
2
2
|
import assert from 'node:assert';
|
|
3
3
|
import fs from 'node:fs';
|
|
4
|
-
import { DropletBuilder } from './droplet.js';
|
|
4
|
+
import { DropletBuilder, generateDropletTagForProvision } from './droplet.js';
|
|
5
5
|
import { Config } from '../../core/config.js';
|
|
6
|
+
import { Output } from '../../core/output.js';
|
|
7
|
+
import { getFileHash } from '../proxmox/hash.js';
|
|
6
8
|
describe('DropletBuilder Unit Tests', () => {
|
|
7
9
|
let originalFetch;
|
|
8
10
|
let fetchCalls = [];
|
|
@@ -262,4 +264,229 @@ describe('DropletBuilder Unit Tests', () => {
|
|
|
262
264
|
assert.ok(deleteCall);
|
|
263
265
|
assert.ok(deleteCall.url.endsWith('/droplets/123'));
|
|
264
266
|
});
|
|
267
|
+
test('creates droplet inside a VPC', async () => {
|
|
268
|
+
mockResponses['GET /droplets'] = {
|
|
269
|
+
status: 200,
|
|
270
|
+
body: { droplets: [] }
|
|
271
|
+
};
|
|
272
|
+
mockResponses['POST /droplets'] = {
|
|
273
|
+
status: 202,
|
|
274
|
+
body: {
|
|
275
|
+
droplet: {
|
|
276
|
+
id: 12345,
|
|
277
|
+
name: 'vpc-droplet',
|
|
278
|
+
status: 'active',
|
|
279
|
+
networks: {
|
|
280
|
+
v4: [{ ip_address: '10.10.10.5', type: 'public' }]
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
mockResponses['GET /droplets/12345'] = {
|
|
286
|
+
status: 200,
|
|
287
|
+
body: {
|
|
288
|
+
droplet: {
|
|
289
|
+
id: 12345,
|
|
290
|
+
name: 'vpc-droplet',
|
|
291
|
+
status: 'active',
|
|
292
|
+
networks: {
|
|
293
|
+
v4: [{ ip_address: '10.10.10.5', type: 'public' }]
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
const builder = new DropletBuilder('vpc-droplet');
|
|
299
|
+
builder
|
|
300
|
+
.region('nyc3')
|
|
301
|
+
.size('s-1vcpu-1gb')
|
|
302
|
+
.vpc('my-vpc-uuid-xyz');
|
|
303
|
+
await builder.deploy();
|
|
304
|
+
const dropletCreateCall = fetchCalls.find(c => c.method === 'POST' && c.url.includes('/droplets'));
|
|
305
|
+
assert.ok(dropletCreateCall);
|
|
306
|
+
assert.strictEqual(dropletCreateCall.body.vpc_uuid, 'my-vpc-uuid-xyz');
|
|
307
|
+
});
|
|
308
|
+
test('creates droplet inside a VPC using an Output value', async () => {
|
|
309
|
+
mockResponses['GET /droplets'] = {
|
|
310
|
+
status: 200,
|
|
311
|
+
body: { droplets: [] }
|
|
312
|
+
};
|
|
313
|
+
mockResponses['POST /droplets'] = {
|
|
314
|
+
status: 202,
|
|
315
|
+
body: {
|
|
316
|
+
droplet: {
|
|
317
|
+
id: 12345,
|
|
318
|
+
name: 'vpc-droplet-output',
|
|
319
|
+
status: 'active',
|
|
320
|
+
networks: {
|
|
321
|
+
v4: [{ ip_address: '10.10.10.6', type: 'public' }]
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
mockResponses['GET /droplets/12345'] = {
|
|
327
|
+
status: 200,
|
|
328
|
+
body: {
|
|
329
|
+
droplet: {
|
|
330
|
+
id: 12345,
|
|
331
|
+
name: 'vpc-droplet-output',
|
|
332
|
+
status: 'active',
|
|
333
|
+
networks: {
|
|
334
|
+
v4: [{ ip_address: '10.10.10.6', type: 'public' }]
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
const vpcIdOutput = new Output();
|
|
340
|
+
vpcIdOutput.resolve('my-vpc-uuid-abc');
|
|
341
|
+
const builder = new DropletBuilder('vpc-droplet-output');
|
|
342
|
+
builder
|
|
343
|
+
.region('nyc3')
|
|
344
|
+
.size('s-1vcpu-1gb')
|
|
345
|
+
.vpc(vpcIdOutput);
|
|
346
|
+
await builder.deploy();
|
|
347
|
+
const dropletCreateCall = fetchCalls.find(c => c.method === 'POST' && c.url.includes('/droplets'));
|
|
348
|
+
assert.ok(dropletCreateCall);
|
|
349
|
+
assert.strictEqual(dropletCreateCall.body.vpc_uuid, 'my-vpc-uuid-abc');
|
|
350
|
+
});
|
|
351
|
+
test('deploys new droplet with playbooks and registers initial tags', async () => {
|
|
352
|
+
mockResponses['GET /droplets'] = {
|
|
353
|
+
status: 200,
|
|
354
|
+
body: { droplets: [] }
|
|
355
|
+
};
|
|
356
|
+
mockResponses['POST /droplets'] = {
|
|
357
|
+
status: 202,
|
|
358
|
+
body: {
|
|
359
|
+
droplet: {
|
|
360
|
+
id: 12345,
|
|
361
|
+
name: 'prov-droplet',
|
|
362
|
+
status: 'active',
|
|
363
|
+
networks: {
|
|
364
|
+
v4: [{ ip_address: '1.2.3.4', type: 'public' }]
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
mockResponses['GET /droplets/12345'] = {
|
|
370
|
+
status: 200,
|
|
371
|
+
body: {
|
|
372
|
+
droplet: {
|
|
373
|
+
id: 12345,
|
|
374
|
+
name: 'prov-droplet',
|
|
375
|
+
status: 'active',
|
|
376
|
+
networks: {
|
|
377
|
+
v4: [{ ip_address: '1.2.3.4', type: 'public' }]
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
const builder = new DropletBuilder('prov-droplet')
|
|
383
|
+
.provision('playbooks/nginx.yaml', 'playbooks/db.yaml');
|
|
384
|
+
const provisionCalls = [];
|
|
385
|
+
builder.waitFor = async (label, condition) => {
|
|
386
|
+
return await condition();
|
|
387
|
+
};
|
|
388
|
+
builder.checkPort = async () => true;
|
|
389
|
+
builder.runProvisioner = async (ip, script) => {
|
|
390
|
+
provisionCalls.push(script);
|
|
391
|
+
};
|
|
392
|
+
await builder.deploy();
|
|
393
|
+
// Verify playbooks were executed
|
|
394
|
+
assert.strictEqual(provisionCalls.length, 2);
|
|
395
|
+
assert.strictEqual(provisionCalls[0], 'playbooks/nginx.yaml');
|
|
396
|
+
assert.strictEqual(provisionCalls[1], 'playbooks/db.yaml');
|
|
397
|
+
// Verify initial tags were posted during creation
|
|
398
|
+
const createCall = fetchCalls.find(c => c.method === 'POST' && c.url.includes('/droplets'));
|
|
399
|
+
assert.ok(createCall);
|
|
400
|
+
const expectedTag1 = generateDropletTagForProvision('playbooks/nginx.yaml', getFileHash('playbooks/nginx.yaml'));
|
|
401
|
+
const expectedTag2 = generateDropletTagForProvision('playbooks/db.yaml', getFileHash('playbooks/db.yaml'));
|
|
402
|
+
assert.deepStrictEqual(createCall.body.tags, [expectedTag1, expectedTag2]);
|
|
403
|
+
});
|
|
404
|
+
test('deploys existing droplet and skips playbooks if hashes match', async () => {
|
|
405
|
+
const nginxTag = generateDropletTagForProvision('playbooks/nginx.yaml', getFileHash('playbooks/nginx.yaml'));
|
|
406
|
+
mockResponses['GET /droplets'] = {
|
|
407
|
+
status: 200,
|
|
408
|
+
body: {
|
|
409
|
+
droplets: [
|
|
410
|
+
{
|
|
411
|
+
id: 12345,
|
|
412
|
+
name: 'existing-prov-droplet',
|
|
413
|
+
status: 'active',
|
|
414
|
+
size_slug: 's-1vcpu-1gb',
|
|
415
|
+
region: { slug: 'nyc3' },
|
|
416
|
+
networks: {
|
|
417
|
+
v4: [{ ip_address: '1.2.3.4', type: 'public' }]
|
|
418
|
+
},
|
|
419
|
+
tags: [nginxTag]
|
|
420
|
+
}
|
|
421
|
+
]
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
const builder = new DropletBuilder('existing-prov-droplet')
|
|
425
|
+
.provision('playbooks/nginx.yaml');
|
|
426
|
+
const provisionCalls = [];
|
|
427
|
+
builder.runProvisioner = async (ip, script) => {
|
|
428
|
+
provisionCalls.push(script);
|
|
429
|
+
};
|
|
430
|
+
await builder.deploy();
|
|
431
|
+
// No playbooks should run
|
|
432
|
+
assert.strictEqual(provisionCalls.length, 0);
|
|
433
|
+
// No POST/DELETE tags calls
|
|
434
|
+
const writeCalls = fetchCalls.filter(c => c.method === 'POST' && c.url.includes('/tags'));
|
|
435
|
+
assert.strictEqual(writeCalls.length, 0);
|
|
436
|
+
});
|
|
437
|
+
test('deploys existing droplet and runs playbooks if hash is missing or different, updating tags', async () => {
|
|
438
|
+
const oldNginxTag = generateDropletTagForProvision('playbooks/nginx.yaml', 'abc123123123');
|
|
439
|
+
mockResponses['GET /droplets'] = {
|
|
440
|
+
status: 200,
|
|
441
|
+
body: {
|
|
442
|
+
droplets: [
|
|
443
|
+
{
|
|
444
|
+
id: 12345,
|
|
445
|
+
name: 'existing-diff-droplet',
|
|
446
|
+
status: 'active',
|
|
447
|
+
size_slug: 's-1vcpu-1gb',
|
|
448
|
+
region: { slug: 'nyc3' },
|
|
449
|
+
networks: {
|
|
450
|
+
v4: [{ ip_address: '1.2.3.4', type: 'public' }]
|
|
451
|
+
},
|
|
452
|
+
tags: [oldNginxTag]
|
|
453
|
+
}
|
|
454
|
+
]
|
|
455
|
+
}
|
|
456
|
+
};
|
|
457
|
+
mockResponses['POST /tags'] = { status: 201, body: {} };
|
|
458
|
+
mockResponses['POST /tags/'] = { status: 200, body: {} };
|
|
459
|
+
mockResponses['DELETE /tags/'] = { status: 204, body: {} };
|
|
460
|
+
const builder = new DropletBuilder('existing-diff-droplet')
|
|
461
|
+
.provision('playbooks/nginx.yaml');
|
|
462
|
+
const provisionCalls = [];
|
|
463
|
+
builder.waitFor = async (label, condition) => {
|
|
464
|
+
return await condition();
|
|
465
|
+
};
|
|
466
|
+
builder.checkPort = async () => true;
|
|
467
|
+
builder.runProvisioner = async (ip, script) => {
|
|
468
|
+
provisionCalls.push(script);
|
|
469
|
+
};
|
|
470
|
+
await builder.deploy();
|
|
471
|
+
// Playbook should run
|
|
472
|
+
assert.strictEqual(provisionCalls.length, 1);
|
|
473
|
+
assert.strictEqual(provisionCalls[0], 'playbooks/nginx.yaml');
|
|
474
|
+
// Should have deleted old tag
|
|
475
|
+
const deleteTagCall = fetchCalls.find(c => c.method === 'DELETE' && c.url.includes('/tags/puls-h-nginx-yaml-abc123123123/resources'));
|
|
476
|
+
assert.ok(deleteTagCall);
|
|
477
|
+
assert.deepStrictEqual(deleteTagCall.body, {
|
|
478
|
+
resources: [{ id: '12345', type: 'droplet' }]
|
|
479
|
+
});
|
|
480
|
+
// Should have ensured new tag exists
|
|
481
|
+
const createTagCall = fetchCalls.find(c => c.method === 'POST' && c.url.endsWith('/tags') && c.body?.name?.startsWith('puls-h-nginx-yaml-'));
|
|
482
|
+
assert.ok(createTagCall);
|
|
483
|
+
// Should have tagged the resource
|
|
484
|
+
const newHash = getFileHash('playbooks/nginx.yaml');
|
|
485
|
+
const newTag = generateDropletTagForProvision('playbooks/nginx.yaml', newHash);
|
|
486
|
+
const associateCall = fetchCalls.find(c => c.method === 'POST' && c.url.includes(`/tags/${newTag}/resources`));
|
|
487
|
+
assert.ok(associateCall);
|
|
488
|
+
assert.deepStrictEqual(associateCall.body, {
|
|
489
|
+
resources: [{ id: '12345', type: 'droplet' }]
|
|
490
|
+
});
|
|
491
|
+
});
|
|
265
492
|
});
|
|
@@ -7,12 +7,13 @@ export interface FirewallRule {
|
|
|
7
7
|
destinations?: string[];
|
|
8
8
|
}
|
|
9
9
|
export declare class FirewallBuilder extends BaseBuilder {
|
|
10
|
-
private
|
|
10
|
+
private _rules;
|
|
11
11
|
private dropletNames;
|
|
12
12
|
constructor(name: string);
|
|
13
13
|
private discoverFirewall;
|
|
14
14
|
ingress(protocol: 'tcp' | 'udp' | 'icmp', port: number | string, sources: string[]): this;
|
|
15
15
|
egress(protocol: 'tcp' | 'udp' | 'icmp', port: number | string, destinations: string[]): this;
|
|
16
|
+
rules(filePath: string): this;
|
|
16
17
|
attachTo(dropletName: string): this;
|
|
17
18
|
private resolveDropletIds;
|
|
18
19
|
private buildApiRules;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { BaseBuilder } from '../../core/resource.js';
|
|
2
2
|
import { getDoApi } from './api.js';
|
|
3
|
+
import { loadRecordsFromFile } from '../../core/parser.js';
|
|
3
4
|
export class FirewallBuilder extends BaseBuilder {
|
|
4
|
-
|
|
5
|
+
_rules = [];
|
|
5
6
|
dropletNames = [];
|
|
6
7
|
constructor(name) {
|
|
7
8
|
super(name);
|
|
@@ -13,11 +14,24 @@ export class FirewallBuilder extends BaseBuilder {
|
|
|
13
14
|
return data.firewalls.find(f => f.name === name) ?? null;
|
|
14
15
|
}
|
|
15
16
|
ingress(protocol, port, sources) {
|
|
16
|
-
this.
|
|
17
|
+
this._rules.push({ type: 'ingress', protocol, port, sources });
|
|
17
18
|
return this;
|
|
18
19
|
}
|
|
19
20
|
egress(protocol, port, destinations) {
|
|
20
|
-
this.
|
|
21
|
+
this._rules.push({ type: 'egress', protocol, port, destinations });
|
|
22
|
+
return this;
|
|
23
|
+
}
|
|
24
|
+
rules(filePath) {
|
|
25
|
+
const loaded = loadRecordsFromFile(filePath);
|
|
26
|
+
for (const r of loaded) {
|
|
27
|
+
this._rules.push({
|
|
28
|
+
type: r.type,
|
|
29
|
+
protocol: r.protocol,
|
|
30
|
+
port: r.port,
|
|
31
|
+
sources: r.sources,
|
|
32
|
+
destinations: r.destinations,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
21
35
|
return this;
|
|
22
36
|
}
|
|
23
37
|
attachTo(dropletName) {
|
|
@@ -35,14 +49,14 @@ export class FirewallBuilder extends BaseBuilder {
|
|
|
35
49
|
return ids;
|
|
36
50
|
}
|
|
37
51
|
buildApiRules() {
|
|
38
|
-
const inbound = this.
|
|
52
|
+
const inbound = this._rules
|
|
39
53
|
.filter(r => r.type === 'ingress')
|
|
40
54
|
.map(r => ({
|
|
41
55
|
protocol: r.protocol,
|
|
42
56
|
ports: String(r.port),
|
|
43
57
|
sources: { addresses: r.sources ?? [] },
|
|
44
58
|
}));
|
|
45
|
-
const outbound = this.
|
|
59
|
+
const outbound = this._rules
|
|
46
60
|
.filter(r => r.type === 'egress')
|
|
47
61
|
.map(r => ({
|
|
48
62
|
protocol: r.protocol,
|
|
@@ -57,12 +71,12 @@ export class FirewallBuilder extends BaseBuilder {
|
|
|
57
71
|
const api = getDoApi();
|
|
58
72
|
console.log(`\nš”ļø Finalizing firewall "${this.name}"...`);
|
|
59
73
|
if (dryRun) {
|
|
60
|
-
this.
|
|
74
|
+
this._rules.forEach(r => {
|
|
61
75
|
const dir = r.type === 'ingress' ? 'from' : 'to';
|
|
62
76
|
const targets = r.type === 'ingress' ? r.sources : r.destinations;
|
|
63
77
|
console.log(` š [PLAN] ${r.type.toUpperCase()}: ${r.protocol.toUpperCase()} ${r.port} ${dir} [${targets?.join(', ')}]`);
|
|
64
78
|
});
|
|
65
|
-
return { name: this.name, rules: this.
|
|
79
|
+
return { name: this.name, rules: this._rules };
|
|
66
80
|
}
|
|
67
81
|
const dropletIds = await this.resolveDropletIds(api);
|
|
68
82
|
const { inbound, outbound } = this.buildApiRules();
|
|
@@ -84,11 +98,11 @@ export class FirewallBuilder extends BaseBuilder {
|
|
|
84
98
|
});
|
|
85
99
|
console.log(`š Created firewall ${this.name} (id=${result.firewall.id})`);
|
|
86
100
|
}
|
|
87
|
-
this.
|
|
101
|
+
this._rules.forEach(r => {
|
|
88
102
|
const dir = r.type === 'ingress' ? 'from' : 'to';
|
|
89
103
|
const targets = r.type === 'ingress' ? r.sources : r.destinations;
|
|
90
104
|
console.log(` ā
${r.type.toUpperCase()}: ${r.protocol.toUpperCase()} ${r.port} ${dir} [${targets?.join(', ')}]`);
|
|
91
105
|
});
|
|
92
|
-
return { name: this.name, rules: this.
|
|
106
|
+
return { name: this.name, rules: this._rules };
|
|
93
107
|
}
|
|
94
108
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { test, describe, beforeEach, afterEach } from 'node:test';
|
|
2
2
|
import assert from 'node:assert';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
3
5
|
import { FirewallBuilder } from './firewall.js';
|
|
4
6
|
import { Config } from '../../core/config.js';
|
|
5
7
|
describe('FirewallBuilder Unit Tests', () => {
|
|
@@ -173,4 +175,56 @@ describe('FirewallBuilder Unit Tests', () => {
|
|
|
173
175
|
droplet_ids: [111]
|
|
174
176
|
});
|
|
175
177
|
});
|
|
178
|
+
test('loads rules from a configuration file (YAML) successfully', async () => {
|
|
179
|
+
mockResponses['GET /firewalls'] = {
|
|
180
|
+
status: 200,
|
|
181
|
+
body: { firewalls: [] }
|
|
182
|
+
};
|
|
183
|
+
mockResponses['GET /droplets?name=app-vm-1'] = {
|
|
184
|
+
status: 200,
|
|
185
|
+
body: { droplets: [{ id: 111, name: 'app-vm-1' }] }
|
|
186
|
+
};
|
|
187
|
+
mockResponses['POST /firewalls'] = {
|
|
188
|
+
status: 201,
|
|
189
|
+
body: { firewall: { id: 'fw-789', name: 'my-fw' } }
|
|
190
|
+
};
|
|
191
|
+
// Mock YAML file creation
|
|
192
|
+
const tempYamlPath = path.resolve(process.cwd(), "temp-firewall-rules.yaml");
|
|
193
|
+
const yamlContent = `
|
|
194
|
+
- type: ingress
|
|
195
|
+
protocol: tcp
|
|
196
|
+
port: 80
|
|
197
|
+
sources:
|
|
198
|
+
- 0.0.0.0/0
|
|
199
|
+
- type: egress
|
|
200
|
+
protocol: tcp
|
|
201
|
+
port: all
|
|
202
|
+
destinations:
|
|
203
|
+
- 0.0.0.0/0
|
|
204
|
+
`;
|
|
205
|
+
fs.writeFileSync(tempYamlPath, yamlContent, "utf-8");
|
|
206
|
+
try {
|
|
207
|
+
const builder = new FirewallBuilder('my-fw')
|
|
208
|
+
.rules("temp-firewall-rules.yaml")
|
|
209
|
+
.attachTo('app-vm-1');
|
|
210
|
+
const result = await builder.deploy();
|
|
211
|
+
assert.ok(result);
|
|
212
|
+
const postCall = fetchCalls.find(c => c.method === 'POST');
|
|
213
|
+
assert.ok(postCall);
|
|
214
|
+
assert.deepStrictEqual(postCall.body, {
|
|
215
|
+
name: 'my-fw',
|
|
216
|
+
inbound_rules: [
|
|
217
|
+
{ protocol: 'tcp', ports: '80', sources: { addresses: ['0.0.0.0/0'] } }
|
|
218
|
+
],
|
|
219
|
+
outbound_rules: [
|
|
220
|
+
{ protocol: 'tcp', ports: 'all', destinations: { addresses: ['0.0.0.0/0'] } }
|
|
221
|
+
],
|
|
222
|
+
droplet_ids: [111]
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
finally {
|
|
226
|
+
if (fs.existsSync(tempYamlPath))
|
|
227
|
+
fs.unlinkSync(tempYamlPath);
|
|
228
|
+
}
|
|
229
|
+
});
|
|
176
230
|
});
|
|
@@ -3,14 +3,25 @@ import { DomainBuilder } from "./domain.js";
|
|
|
3
3
|
import { FirewallBuilder } from "./firewall.js";
|
|
4
4
|
import { CertificateBuilder } from "./certificate.js";
|
|
5
5
|
import { LoadBalancerBuilder } from "./load_balancer.js";
|
|
6
|
+
import { SpacesBuilder } from "./spaces.js";
|
|
7
|
+
import { DatabaseBuilder } from "./database.js";
|
|
8
|
+
import { AppPlatformBuilder } from "./app.js";
|
|
9
|
+
import { VPCBuilder } from "./vpc.js";
|
|
6
10
|
export declare const DO: {
|
|
7
11
|
init: (opts: {
|
|
8
12
|
token: string;
|
|
13
|
+
defaultRegion?: string;
|
|
14
|
+
spacesAccessKey?: string;
|
|
15
|
+
spacesSecretKey?: string;
|
|
9
16
|
}) => void;
|
|
10
17
|
Droplet: (name: string) => DropletBuilder;
|
|
11
18
|
Domain: (name: string) => DomainBuilder;
|
|
12
19
|
Firewall: (name: string) => FirewallBuilder;
|
|
13
20
|
Certificate: (name: string) => CertificateBuilder;
|
|
14
21
|
LoadBalancer: (name: string) => LoadBalancerBuilder;
|
|
22
|
+
Spaces: (name: string) => SpacesBuilder;
|
|
23
|
+
Database: (name: string) => DatabaseBuilder;
|
|
24
|
+
App: (name: string) => AppPlatformBuilder;
|
|
25
|
+
VPC: (name: string) => VPCBuilder;
|
|
15
26
|
};
|
|
16
27
|
export * from "../../types/do.js";
|
|
@@ -4,6 +4,10 @@ import { DomainBuilder } from "./domain.js";
|
|
|
4
4
|
import { FirewallBuilder } from "./firewall.js";
|
|
5
5
|
import { CertificateBuilder } from "./certificate.js";
|
|
6
6
|
import { LoadBalancerBuilder } from "./load_balancer.js";
|
|
7
|
+
import { SpacesBuilder } from "./spaces.js";
|
|
8
|
+
import { DatabaseBuilder } from "./database.js";
|
|
9
|
+
import { AppPlatformBuilder } from "./app.js";
|
|
10
|
+
import { VPCBuilder } from "./vpc.js";
|
|
7
11
|
export const DO = {
|
|
8
12
|
init: (opts) => {
|
|
9
13
|
Config.set({
|
|
@@ -18,5 +22,9 @@ export const DO = {
|
|
|
18
22
|
Firewall: (name) => new FirewallBuilder(name),
|
|
19
23
|
Certificate: (name) => new CertificateBuilder(name),
|
|
20
24
|
LoadBalancer: (name) => new LoadBalancerBuilder(name),
|
|
25
|
+
Spaces: (name) => new SpacesBuilder(name),
|
|
26
|
+
Database: (name) => new DatabaseBuilder(name),
|
|
27
|
+
App: (name) => new AppPlatformBuilder(name),
|
|
28
|
+
VPC: (name) => new VPCBuilder(name),
|
|
21
29
|
};
|
|
22
30
|
export * from "../../types/do.js";
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { BaseBuilder } from "../../core/resource.js";
|
|
2
|
+
export type CORSRule = {
|
|
3
|
+
AllowedHeaders?: string[];
|
|
4
|
+
AllowedMethods: string[];
|
|
5
|
+
AllowedOrigins: string[];
|
|
6
|
+
ExposeHeaders?: string[];
|
|
7
|
+
MaxAgeSeconds?: number;
|
|
8
|
+
};
|
|
9
|
+
export declare class SpacesBuilder extends BaseBuilder {
|
|
10
|
+
bucketName: string;
|
|
11
|
+
private _region;
|
|
12
|
+
private _acl;
|
|
13
|
+
private _corsRules?;
|
|
14
|
+
private _uploadPath?;
|
|
15
|
+
constructor(bucketName: string);
|
|
16
|
+
region(r: string): this;
|
|
17
|
+
acl(type: "private" | "public-read"): this;
|
|
18
|
+
cors(rules: CORSRule[]): this;
|
|
19
|
+
upload(filePath: string): this;
|
|
20
|
+
private discoverBucket;
|
|
21
|
+
deploy(): Promise<{
|
|
22
|
+
name: string;
|
|
23
|
+
region: string;
|
|
24
|
+
}>;
|
|
25
|
+
destroy(): Promise<any>;
|
|
26
|
+
private uploadFile;
|
|
27
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { basename, extname } from "node:path";
|
|
3
|
+
import { HeadBucketCommand, CreateBucketCommand, PutBucketAclCommand, PutBucketCorsCommand, PutObjectCommand, DeleteBucketCommand, } from "@aws-sdk/client-s3";
|
|
4
|
+
import { BaseBuilder } from "../../core/resource.js";
|
|
5
|
+
import { getSpacesS3Client } from "./spaces_api.js";
|
|
6
|
+
export class SpacesBuilder extends BaseBuilder {
|
|
7
|
+
bucketName;
|
|
8
|
+
_region = "nyc3";
|
|
9
|
+
_acl = "private";
|
|
10
|
+
_corsRules;
|
|
11
|
+
_uploadPath;
|
|
12
|
+
constructor(bucketName) {
|
|
13
|
+
super(bucketName);
|
|
14
|
+
this.bucketName = bucketName;
|
|
15
|
+
this.discoveryPromise = this.discoverBucket(bucketName);
|
|
16
|
+
}
|
|
17
|
+
region(r) {
|
|
18
|
+
this._region = r;
|
|
19
|
+
this.discoveryPromise = this.discoverBucket(this.bucketName);
|
|
20
|
+
return this;
|
|
21
|
+
}
|
|
22
|
+
acl(type) {
|
|
23
|
+
this._acl = type;
|
|
24
|
+
return this;
|
|
25
|
+
}
|
|
26
|
+
cors(rules) {
|
|
27
|
+
this._corsRules = rules;
|
|
28
|
+
return this;
|
|
29
|
+
}
|
|
30
|
+
upload(filePath) {
|
|
31
|
+
this._uploadPath = filePath;
|
|
32
|
+
return this;
|
|
33
|
+
}
|
|
34
|
+
async discoverBucket(name) {
|
|
35
|
+
try {
|
|
36
|
+
const s3 = getSpacesS3Client(this._region);
|
|
37
|
+
await s3.send(new HeadBucketCommand({ Bucket: name }));
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
catch (e) {
|
|
41
|
+
const status = e.$metadata?.httpStatusCode;
|
|
42
|
+
if (status === 404 || e.name === "NotFound")
|
|
43
|
+
return false;
|
|
44
|
+
if (status === 301 || status === 403)
|
|
45
|
+
return true; // exists or access denied
|
|
46
|
+
if (e.name === "CredentialsProviderError")
|
|
47
|
+
return false;
|
|
48
|
+
throw e;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
async deploy() {
|
|
52
|
+
const dryRun = this.isDryRunActive();
|
|
53
|
+
const exists = await this.discoveryPromise;
|
|
54
|
+
const s3 = getSpacesS3Client(this._region);
|
|
55
|
+
console.log(`\nš Finalizing DigitalOcean Space "${this.bucketName}"...`);
|
|
56
|
+
if (!exists) {
|
|
57
|
+
if (dryRun) {
|
|
58
|
+
console.log(` š [PLAN] Create DigitalOcean Space "${this.bucketName}" (${this._region})`);
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
await s3.send(new CreateBucketCommand({ Bucket: this.bucketName }));
|
|
62
|
+
console.log(`š Created DigitalOcean Space "${this.bucketName}"`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
console.log(` ā
DigitalOcean Space "${this.bucketName}" already exists.`);
|
|
67
|
+
}
|
|
68
|
+
// Apply ACL
|
|
69
|
+
if (this._acl) {
|
|
70
|
+
if (dryRun) {
|
|
71
|
+
console.log(` š [PLAN] Set Space ACL: ${this._acl}`);
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
await s3.send(new PutBucketAclCommand({ Bucket: this.bucketName, ACL: this._acl }));
|
|
75
|
+
console.log(` ā
Set Space ACL: ${this._acl}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// Apply CORS
|
|
79
|
+
if (this._corsRules) {
|
|
80
|
+
if (dryRun) {
|
|
81
|
+
console.log(` š [PLAN] Configure CORS rules: ${JSON.stringify(this._corsRules)}`);
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
await s3.send(new PutBucketCorsCommand({
|
|
85
|
+
Bucket: this.bucketName,
|
|
86
|
+
CORSConfiguration: { CORSRules: this._corsRules },
|
|
87
|
+
}));
|
|
88
|
+
console.log(` ā
Configured CORS rules`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// Handle single file upload
|
|
92
|
+
if (this._uploadPath) {
|
|
93
|
+
if (dryRun) {
|
|
94
|
+
console.log(` š [PLAN] Upload ${basename(this._uploadPath)} ā space://${this.bucketName}/`);
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
await this.uploadFile(s3, this._uploadPath);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
await this.deploySidecars();
|
|
101
|
+
return { name: this.bucketName, region: this._region };
|
|
102
|
+
}
|
|
103
|
+
async destroy() {
|
|
104
|
+
const dryRun = this.isDryRunActive();
|
|
105
|
+
const exists = await this.discoveryPromise;
|
|
106
|
+
console.log(`\nš Destroying DigitalOcean Space "${this.bucketName}"...`);
|
|
107
|
+
if (!exists) {
|
|
108
|
+
console.log(` ā Space "${this.bucketName}" not found`);
|
|
109
|
+
return { destroyed: false };
|
|
110
|
+
}
|
|
111
|
+
if (dryRun) {
|
|
112
|
+
console.log(` š [PLAN] Delete DigitalOcean Space "${this.bucketName}"`);
|
|
113
|
+
return { destroyed: this.bucketName };
|
|
114
|
+
}
|
|
115
|
+
const s3 = getSpacesS3Client(this._region);
|
|
116
|
+
await s3.send(new DeleteBucketCommand({ Bucket: this.bucketName }));
|
|
117
|
+
console.log(` šļø Removed DigitalOcean Space "${this.bucketName}"`);
|
|
118
|
+
return { destroyed: this.bucketName };
|
|
119
|
+
}
|
|
120
|
+
async uploadFile(s3, filePath) {
|
|
121
|
+
const key = basename(filePath);
|
|
122
|
+
const body = fs.readFileSync(filePath);
|
|
123
|
+
const contentTypeMap = {
|
|
124
|
+
".json": "application/json",
|
|
125
|
+
".js": "application/javascript",
|
|
126
|
+
".html": "text/html",
|
|
127
|
+
".css": "text/css",
|
|
128
|
+
".png": "image/png",
|
|
129
|
+
".jpg": "image/jpeg",
|
|
130
|
+
".svg": "image/svg+xml",
|
|
131
|
+
};
|
|
132
|
+
const contentType = contentTypeMap[extname(filePath).toLowerCase()] ?? "application/octet-stream";
|
|
133
|
+
await s3.send(new PutObjectCommand({
|
|
134
|
+
Bucket: this.bucketName,
|
|
135
|
+
Key: key,
|
|
136
|
+
Body: body,
|
|
137
|
+
ContentType: contentType,
|
|
138
|
+
ACL: this._acl,
|
|
139
|
+
}));
|
|
140
|
+
console.log(` ā
Uploaded ${key} ā space://${this.bucketName}/${key}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|