puls-dev 0.2.6 → 0.2.8

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 (83) hide show
  1. package/README.md +1 -1
  2. package/dist/core/config.d.ts +2 -0
  3. package/dist/core/decorators.d.ts +2 -0
  4. package/dist/core/decorators.js +48 -16
  5. package/dist/core/hooks.d.ts +21 -0
  6. package/dist/core/hooks.js +116 -0
  7. package/dist/core/hooks.test.d.ts +1 -0
  8. package/dist/core/hooks.test.js +194 -0
  9. package/dist/core/multiregion.test.d.ts +1 -0
  10. package/dist/core/multiregion.test.js +87 -0
  11. package/dist/core/output.d.ts +2 -0
  12. package/dist/core/output.js +9 -2
  13. package/dist/core/parser.d.ts +10 -0
  14. package/dist/core/parser.js +140 -0
  15. package/dist/core/parser.test.d.ts +1 -0
  16. package/dist/core/parser.test.js +117 -0
  17. package/dist/core/provisioner.d.ts +4 -0
  18. package/dist/core/provisioner.js +105 -0
  19. package/dist/core/resource.d.ts +16 -0
  20. package/dist/core/resource.js +44 -0
  21. package/dist/core/secret.d.ts +40 -0
  22. package/dist/core/secret.js +95 -0
  23. package/dist/core/secret.test.d.ts +1 -0
  24. package/dist/core/secret.test.js +166 -0
  25. package/dist/core/stack.d.ts +4 -3
  26. package/dist/core/stack.js +50 -9
  27. package/dist/index.d.ts +2 -0
  28. package/dist/index.js +2 -0
  29. package/dist/providers/aws/ec2.d.ts +48 -0
  30. package/dist/providers/aws/ec2.js +297 -0
  31. package/dist/providers/aws/ec2.test.d.ts +1 -0
  32. package/dist/providers/aws/ec2.test.js +279 -0
  33. package/dist/providers/aws/index.d.ts +2 -0
  34. package/dist/providers/aws/index.js +2 -0
  35. package/dist/providers/aws/route53.d.ts +1 -0
  36. package/dist/providers/aws/route53.js +15 -2
  37. package/dist/providers/aws/route53.test.js +47 -0
  38. package/dist/providers/do/api.d.ts +1 -1
  39. package/dist/providers/do/api.js +2 -1
  40. package/dist/providers/do/app.d.ts +26 -0
  41. package/dist/providers/do/app.js +124 -0
  42. package/dist/providers/do/app.test.d.ts +1 -0
  43. package/dist/providers/do/app.test.js +268 -0
  44. package/dist/providers/do/database.d.ts +44 -0
  45. package/dist/providers/do/database.js +208 -0
  46. package/dist/providers/do/database.test.d.ts +1 -0
  47. package/dist/providers/do/database.test.js +293 -0
  48. package/dist/providers/do/domain.d.ts +2 -0
  49. package/dist/providers/do/domain.js +30 -0
  50. package/dist/providers/do/domain.test.js +49 -0
  51. package/dist/providers/do/droplet.d.ts +9 -0
  52. package/dist/providers/do/droplet.js +132 -8
  53. package/dist/providers/do/droplet.test.js +228 -1
  54. package/dist/providers/do/firewall.d.ts +2 -1
  55. package/dist/providers/do/firewall.js +23 -9
  56. package/dist/providers/do/firewall.test.js +54 -0
  57. package/dist/providers/do/index.d.ts +11 -0
  58. package/dist/providers/do/index.js +8 -0
  59. package/dist/providers/do/spaces.d.ts +27 -0
  60. package/dist/providers/do/spaces.js +142 -0
  61. package/dist/providers/do/spaces.test.d.ts +1 -0
  62. package/dist/providers/do/spaces.test.js +180 -0
  63. package/dist/providers/do/spaces_api.d.ts +2 -0
  64. package/dist/providers/do/spaces_api.js +20 -0
  65. package/dist/providers/do/vpc.d.ts +30 -0
  66. package/dist/providers/do/vpc.js +128 -0
  67. package/dist/providers/do/vpc.test.d.ts +1 -0
  68. package/dist/providers/do/vpc.test.js +258 -0
  69. package/dist/providers/gcp/clouddns.d.ts +1 -0
  70. package/dist/providers/gcp/clouddns.js +15 -2
  71. package/dist/providers/gcp/clouddns.test.js +45 -0
  72. package/dist/providers/gcp/index.d.ts +3 -1
  73. package/dist/providers/gcp/index.js +3 -1
  74. package/dist/providers/gcp/vm.d.ts +45 -0
  75. package/dist/providers/gcp/vm.js +332 -0
  76. package/dist/providers/gcp/vm.test.d.ts +1 -0
  77. package/dist/providers/gcp/vm.test.js +321 -0
  78. package/dist/providers/proxmox/hash.d.ts +3 -0
  79. package/dist/providers/proxmox/hash.js +46 -0
  80. package/dist/providers/proxmox/vm.d.ts +8 -7
  81. package/dist/providers/proxmox/vm.js +126 -106
  82. package/dist/providers/proxmox/vm.test.js +224 -0
  83. 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 rules;
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
- rules = [];
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.rules.push({ type: 'ingress', protocol, port, sources });
17
+ this._rules.push({ type: 'ingress', protocol, port, sources });
17
18
  return this;
18
19
  }
19
20
  egress(protocol, port, destinations) {
20
- this.rules.push({ type: 'egress', protocol, port, destinations });
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.rules
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.rules
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.rules.forEach(r => {
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.rules };
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.rules.forEach(r => {
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.rules };
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 {};