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.
Files changed (117) hide show
  1. package/dist/core/checker.js +71 -0
  2. package/dist/core/config.d.ts +6 -0
  3. package/dist/core/config.js +11 -1
  4. package/dist/core/context.d.ts +14 -0
  5. package/dist/core/context.js +2 -0
  6. package/dist/core/decorators.d.ts +4 -0
  7. package/dist/core/decorators.js +56 -30
  8. package/dist/core/hooks.d.ts +21 -0
  9. package/dist/core/hooks.js +116 -0
  10. package/dist/core/hooks.test.d.ts +1 -0
  11. package/dist/core/hooks.test.js +194 -0
  12. package/dist/core/multiregion.test.d.ts +1 -0
  13. package/dist/core/multiregion.test.js +87 -0
  14. package/dist/core/output.d.ts +2 -0
  15. package/dist/core/output.js +9 -2
  16. package/dist/core/parallel.test.d.ts +1 -0
  17. package/dist/core/parallel.test.js +215 -0
  18. package/dist/core/parser.d.ts +10 -0
  19. package/dist/core/parser.js +140 -0
  20. package/dist/core/parser.test.d.ts +1 -0
  21. package/dist/core/parser.test.js +117 -0
  22. package/dist/core/production.test.d.ts +1 -0
  23. package/dist/core/production.test.js +189 -0
  24. package/dist/core/provisioner.d.ts +4 -0
  25. package/dist/core/provisioner.js +123 -0
  26. package/dist/core/resource.d.ts +23 -0
  27. package/dist/core/resource.js +54 -0
  28. package/dist/core/retry.d.ts +9 -0
  29. package/dist/core/retry.js +28 -0
  30. package/dist/core/retry.test.d.ts +1 -0
  31. package/dist/core/retry.test.js +66 -0
  32. package/dist/core/secret.d.ts +41 -0
  33. package/dist/core/secret.js +105 -0
  34. package/dist/core/secret.test.d.ts +1 -0
  35. package/dist/core/secret.test.js +166 -0
  36. package/dist/core/stack.d.ts +4 -3
  37. package/dist/core/stack.js +322 -48
  38. package/dist/index.d.ts +3 -0
  39. package/dist/index.js +3 -0
  40. package/dist/providers/aws/api.js +97 -17
  41. package/dist/providers/aws/ec2.d.ts +51 -0
  42. package/dist/providers/aws/ec2.js +331 -0
  43. package/dist/providers/aws/ec2.test.d.ts +1 -0
  44. package/dist/providers/aws/ec2.test.js +281 -0
  45. package/dist/providers/aws/index.d.ts +4 -0
  46. package/dist/providers/aws/index.js +4 -0
  47. package/dist/providers/aws/route53.d.ts +1 -0
  48. package/dist/providers/aws/route53.js +15 -2
  49. package/dist/providers/aws/route53.test.js +47 -0
  50. package/dist/providers/aws/template.d.ts +34 -0
  51. package/dist/providers/aws/template.js +252 -0
  52. package/dist/providers/aws/template.test.d.ts +1 -0
  53. package/dist/providers/aws/template.test.js +208 -0
  54. package/dist/providers/do/api.d.ts +3 -1
  55. package/dist/providers/do/api.js +126 -27
  56. package/dist/providers/do/app.d.ts +26 -0
  57. package/dist/providers/do/app.js +124 -0
  58. package/dist/providers/do/app.test.d.ts +1 -0
  59. package/dist/providers/do/app.test.js +268 -0
  60. package/dist/providers/do/database.d.ts +44 -0
  61. package/dist/providers/do/database.js +208 -0
  62. package/dist/providers/do/database.test.d.ts +1 -0
  63. package/dist/providers/do/database.test.js +293 -0
  64. package/dist/providers/do/domain.d.ts +2 -0
  65. package/dist/providers/do/domain.js +30 -0
  66. package/dist/providers/do/domain.test.js +49 -0
  67. package/dist/providers/do/droplet.d.ts +9 -0
  68. package/dist/providers/do/droplet.js +146 -8
  69. package/dist/providers/do/droplet.test.js +228 -1
  70. package/dist/providers/do/firewall.d.ts +2 -1
  71. package/dist/providers/do/firewall.js +23 -9
  72. package/dist/providers/do/firewall.test.js +54 -0
  73. package/dist/providers/do/index.d.ts +11 -0
  74. package/dist/providers/do/index.js +8 -0
  75. package/dist/providers/do/spaces.d.ts +27 -0
  76. package/dist/providers/do/spaces.js +142 -0
  77. package/dist/providers/do/spaces.test.d.ts +1 -0
  78. package/dist/providers/do/spaces.test.js +180 -0
  79. package/dist/providers/do/spaces_api.d.ts +2 -0
  80. package/dist/providers/do/spaces_api.js +20 -0
  81. package/dist/providers/do/vpc.d.ts +30 -0
  82. package/dist/providers/do/vpc.js +128 -0
  83. package/dist/providers/do/vpc.test.d.ts +1 -0
  84. package/dist/providers/do/vpc.test.js +258 -0
  85. package/dist/providers/firebase/api.js +92 -29
  86. package/dist/providers/firebase/list.d.ts +2 -0
  87. package/dist/providers/firebase/list.js +25 -0
  88. package/dist/providers/gcp/api.js +88 -14
  89. package/dist/providers/gcp/clouddns.d.ts +1 -0
  90. package/dist/providers/gcp/clouddns.js +15 -2
  91. package/dist/providers/gcp/clouddns.test.js +45 -0
  92. package/dist/providers/gcp/index.d.ts +5 -1
  93. package/dist/providers/gcp/index.js +5 -1
  94. package/dist/providers/gcp/list.d.ts +2 -0
  95. package/dist/providers/gcp/list.js +55 -0
  96. package/dist/providers/gcp/secrets.js +1 -1
  97. package/dist/providers/gcp/template.d.ts +32 -0
  98. package/dist/providers/gcp/template.js +252 -0
  99. package/dist/providers/gcp/template.test.d.ts +1 -0
  100. package/dist/providers/gcp/template.test.js +227 -0
  101. package/dist/providers/gcp/vm.d.ts +48 -0
  102. package/dist/providers/gcp/vm.js +375 -0
  103. package/dist/providers/gcp/vm.test.d.ts +1 -0
  104. package/dist/providers/gcp/vm.test.js +321 -0
  105. package/dist/providers/proxmox/api.d.ts +1 -0
  106. package/dist/providers/proxmox/api.js +72 -16
  107. package/dist/providers/proxmox/index.d.ts +2 -0
  108. package/dist/providers/proxmox/index.js +2 -0
  109. package/dist/providers/proxmox/template.d.ts +44 -0
  110. package/dist/providers/proxmox/template.js +349 -0
  111. package/dist/providers/proxmox/template.test.d.ts +1 -0
  112. package/dist/providers/proxmox/template.test.js +179 -0
  113. package/dist/providers/proxmox/vm.d.ts +7 -4
  114. package/dist/providers/proxmox/vm.js +57 -102
  115. package/dist/providers/proxmox/vm.test.js +77 -0
  116. package/dist/types/inventory.d.ts +44 -1
  117. package/package.json +3 -1
@@ -0,0 +1,252 @@
1
+ import { DescribeImagesCommand, DeregisterImageCommand, DeleteSnapshotCommand, RunInstancesCommand, DescribeInstancesCommand, StopInstancesCommand, CreateImageCommand, CreateTagsCommand, TerminateInstancesCommand, } from "@aws-sdk/client-ec2";
2
+ import { BaseBuilder } from "../../core/resource.js";
3
+ import { Output } from "../../core/output.js";
4
+ import { getEC2Client } from "./api.js";
5
+ import { checkPort, runProvisioner } from "../../core/provisioner.js";
6
+ import { getFileHash } from "../proxmox/hash.js";
7
+ import { parseAwsTagsForProvision, mergeAwsTagsForProvision } from "./ec2.js";
8
+ export class EC2TemplateBuilder extends BaseBuilder {
9
+ out = {
10
+ amiId: new Output(),
11
+ };
12
+ _baseImage = "ami-0c55b159cbfafe1f0"; // Default standard Ubuntu 22.04 LTS
13
+ _instanceType = "t3.micro";
14
+ _subnetId;
15
+ _securityGroupIds;
16
+ _sshPrivateKeyPath;
17
+ _keyName;
18
+ _provision = [];
19
+ constructor(name) {
20
+ super(name);
21
+ this.discoveryPromise = this.discoverAMI();
22
+ }
23
+ baseImage(amiId) {
24
+ this._baseImage = amiId;
25
+ return this;
26
+ }
27
+ instanceType(type) {
28
+ this._instanceType = type;
29
+ return this;
30
+ }
31
+ subnetId(id) {
32
+ this._subnetId = id;
33
+ return this;
34
+ }
35
+ securityGroupIds(ids) {
36
+ this._securityGroupIds = ids;
37
+ return this;
38
+ }
39
+ sshPrivateKey(path) {
40
+ this._sshPrivateKeyPath = path;
41
+ return this;
42
+ }
43
+ keyName(name) {
44
+ this._keyName = name;
45
+ return this;
46
+ }
47
+ provision(...playbookPaths) {
48
+ this._provision.push(...playbookPaths.flat());
49
+ return this;
50
+ }
51
+ async discoverAMI() {
52
+ try {
53
+ const client = getEC2Client();
54
+ const res = await client.send(new DescribeImagesCommand({
55
+ Filters: [
56
+ { Name: "name", Values: [this.name] },
57
+ { Name: "state", Values: ["available", "pending"] },
58
+ ],
59
+ Owners: ["self"],
60
+ }));
61
+ return res.Images?.[0] ?? null;
62
+ }
63
+ catch (e) {
64
+ if (e.name === "CredentialsProviderError" ||
65
+ e.message?.includes("credentials not configured")) {
66
+ return null;
67
+ }
68
+ throw e;
69
+ }
70
+ }
71
+ async checkPort(ip, port) {
72
+ return checkPort(ip, port);
73
+ }
74
+ async runProvisioner(ip, script) {
75
+ if (!this._sshPrivateKeyPath) {
76
+ throw new Error(`[EC2TemplateBuilder:${this.name}] sshPrivateKey(path) is required to run playbook provisioning.`);
77
+ }
78
+ return runProvisioner(ip, "ubuntu", this._sshPrivateKeyPath, script);
79
+ }
80
+ async deploy() {
81
+ const dryRun = this.isDryRunActive();
82
+ const existing = await this.discoveryPromise;
83
+ const client = getEC2Client();
84
+ const declaredPlaybooksWithHashes = this._provision.map((p) => {
85
+ const baseName = p.split("/").pop() ?? p;
86
+ const slug = baseName.toLowerCase().replace(/[^a-z0-9_-]/g, "-");
87
+ return { path: p, slug, hash: getFileHash(p) };
88
+ });
89
+ if (existing) {
90
+ this.out.amiId.resolve(existing.ImageId);
91
+ // Check if playbooks differ
92
+ const appliedHashes = parseAwsTagsForProvision(existing.Tags);
93
+ const hasChanges = declaredPlaybooksWithHashes.some((p) => {
94
+ const appliedHash = appliedHashes[p.slug];
95
+ return !appliedHash || appliedHash !== p.hash;
96
+ });
97
+ if (!hasChanges) {
98
+ console.log(`\n🔍 AWS AMI Template "${this.name}"...`);
99
+ console.log(` ✅ Custom AMI "${this.name}" already exists and matches defined state.`);
100
+ return { name: this.name, amiId: existing.ImageId };
101
+ }
102
+ console.log(`\n⏳ Finalizing AWS AMI Template "${this.name}"...`);
103
+ console.log(` 🔄 Template playbook hashes changed. Deregistering old custom AMI...`);
104
+ if (dryRun) {
105
+ console.log(` 📝 [PLAN] Would deregister AMI "${this.name}" (${existing.ImageId}) and rebuild.`);
106
+ return { name: this.name, amiId: "PENDING" };
107
+ }
108
+ else {
109
+ await client.send(new DeregisterImageCommand({ ImageId: existing.ImageId }));
110
+ const mappings = existing.BlockDeviceMappings ?? [];
111
+ for (const mapping of mappings) {
112
+ const snapshotId = mapping.Ebs?.SnapshotId;
113
+ if (snapshotId) {
114
+ try {
115
+ await client.send(new DeleteSnapshotCommand({ SnapshotId: snapshotId }));
116
+ }
117
+ catch (err) {
118
+ console.warn(` ⚠️ Could not delete snapshot ${snapshotId}: ${err.message}`);
119
+ }
120
+ }
121
+ }
122
+ }
123
+ }
124
+ console.log(`\n⏳ Finalizing AWS AMI Template "${this.name}"...`);
125
+ const initialHashes = {};
126
+ for (const p of declaredPlaybooksWithHashes) {
127
+ initialHashes[p.slug] = p.hash;
128
+ }
129
+ const initialMetadataVal = mergeAwsTagsForProvision(initialHashes);
130
+ if (dryRun) {
131
+ console.log(` 📝 [PLAN] Bake AWS AMI Template "${this.name}"`);
132
+ console.log(` └─ Base Image: ${this._baseImage} Instance Type: ${this._instanceType}`);
133
+ if (this._provision.length > 0) {
134
+ console.log(` └─ Provision: ${this._provision.join(", ")}`);
135
+ }
136
+ this.out.amiId.resolve("PENDING");
137
+ return { name: this.name, amiId: "PENDING" };
138
+ }
139
+ // Spawn temporary instance to bake
140
+ console.log(`🚀 Spawning temporary VM "puls-bake-temp-${this.name}" to bake custom AMI...`);
141
+ const runRes = await client.send(new RunInstancesCommand({
142
+ ImageId: this._baseImage,
143
+ InstanceType: this._instanceType,
144
+ MinCount: 1,
145
+ MaxCount: 1,
146
+ KeyName: this._keyName,
147
+ SubnetId: this._subnetId,
148
+ SecurityGroupIds: this._securityGroupIds,
149
+ TagSpecifications: [
150
+ {
151
+ ResourceType: "instance",
152
+ Tags: [
153
+ { Key: "Name", Value: `puls-bake-temp-${this.name}` },
154
+ ],
155
+ },
156
+ ],
157
+ }));
158
+ const instanceId = runRes.Instances?.[0]?.InstanceId;
159
+ if (!instanceId)
160
+ throw new Error("Failed to retrieve instance ID from RunInstancesCommand");
161
+ // Wait until running and IP is resolved
162
+ let resolvedIp;
163
+ await this.waitFor(`temporary instance "${instanceId}" to start running`, async () => {
164
+ const result = await client.send(new DescribeInstancesCommand({ InstanceIds: [instanceId] }));
165
+ const inst = result.Reservations?.[0]?.Instances?.[0];
166
+ if (inst && inst.State?.Name === "running") {
167
+ resolvedIp = inst.PublicIpAddress ?? inst.PrivateIpAddress ?? undefined;
168
+ return !!resolvedIp;
169
+ }
170
+ return false;
171
+ }, { intervalMs: 10_000, timeoutMs: 300_000 });
172
+ if (!resolvedIp) {
173
+ throw new Error(`Failed to resolve IP for temporary instance "${instanceId}"`);
174
+ }
175
+ // Provision the instance
176
+ if (this._provision.length > 0) {
177
+ await this.waitFor(`SSH on ${resolvedIp} to be ready`, () => this.checkPort(resolvedIp, 22), { intervalMs: 10_000, timeoutMs: 300_000 });
178
+ for (const playbook of this._provision) {
179
+ await this.runProvisioner(resolvedIp, playbook);
180
+ }
181
+ }
182
+ // Stop temporary instance
183
+ console.log(` 🛑 Stopping temporary instance "${instanceId}"...`);
184
+ await client.send(new StopInstancesCommand({ InstanceIds: [instanceId] }));
185
+ await this.waitFor(`temporary instance to stop`, async () => {
186
+ const result = await client.send(new DescribeInstancesCommand({ InstanceIds: [instanceId] }));
187
+ const inst = result.Reservations?.[0]?.Instances?.[0];
188
+ return inst?.State?.Name === "stopped";
189
+ }, { intervalMs: 10_000, timeoutMs: 300_000 });
190
+ // Bake AMI
191
+ console.log(` 💾 Baking custom AMI "${this.name}" from instance "${instanceId}"...`);
192
+ const amiRes = await client.send(new CreateImageCommand({
193
+ InstanceId: instanceId,
194
+ Name: this.name,
195
+ Description: `Puls Golden Image Template - ${this.name}`,
196
+ NoReboot: true,
197
+ }));
198
+ const newAmiId = amiRes.ImageId;
199
+ if (!newAmiId)
200
+ throw new Error("Failed to bake custom AMI from instance");
201
+ // Wait until AMI is available
202
+ await this.waitFor(`custom AMI "${newAmiId}" to become available`, async () => {
203
+ const result = await client.send(new DescribeImagesCommand({ ImageIds: [newAmiId] }));
204
+ const img = result.Images?.[0];
205
+ return img?.State === "available";
206
+ }, { intervalMs: 15_000, timeoutMs: 600_000 });
207
+ // Tag the AMI
208
+ await client.send(new CreateTagsCommand({
209
+ Resources: [newAmiId],
210
+ Tags: [
211
+ { Key: "Name", Value: this.name },
212
+ ...(initialMetadataVal ? [{ Key: "puls-provision", Value: initialMetadataVal }] : []),
213
+ ],
214
+ }));
215
+ console.log(` ✅ Custom AMI "${this.name}" (${newAmiId}) baked successfully.`);
216
+ // Clean up temporary instance
217
+ console.log(` 🧹 Terminating temporary provisioning instance "${instanceId}"...`);
218
+ await client.send(new TerminateInstancesCommand({ InstanceIds: [instanceId] }));
219
+ this.out.amiId.resolve(newAmiId);
220
+ return { name: this.name, amiId: newAmiId };
221
+ }
222
+ async destroy() {
223
+ const dryRun = this.isDryRunActive();
224
+ const existing = await this.discoveryPromise;
225
+ const client = getEC2Client();
226
+ console.log(`\n🗑️ Destroying AWS AMI Template "${this.name}"...`);
227
+ if (!existing) {
228
+ console.log(` ─ AWS AMI Template "${this.name}" not found`);
229
+ return { destroyed: false };
230
+ }
231
+ if (dryRun) {
232
+ console.log(` 📝 [PLAN] Deregister AWS AMI "${this.name}" (id=${existing.ImageId})`);
233
+ return { destroyed: this.name };
234
+ }
235
+ console.log(` 🔄 Deregistering AWS AMI "${this.name}" (id=${existing.ImageId})...`);
236
+ await client.send(new DeregisterImageCommand({ ImageId: existing.ImageId }));
237
+ const mappings = existing.BlockDeviceMappings ?? [];
238
+ for (const mapping of mappings) {
239
+ const snapshotId = mapping.Ebs?.SnapshotId;
240
+ if (snapshotId) {
241
+ try {
242
+ await client.send(new DeleteSnapshotCommand({ SnapshotId: snapshotId }));
243
+ }
244
+ catch {
245
+ // Ignore safely
246
+ }
247
+ }
248
+ }
249
+ console.log(` 🗑️ Removed AWS AMI Template "${this.name}"`);
250
+ return { destroyed: this.name };
251
+ }
252
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,208 @@
1
+ import { test, describe, beforeEach, afterEach, mock } from "node:test";
2
+ import assert from "node:assert";
3
+ import { EC2Client } from "@aws-sdk/client-ec2";
4
+ import { EC2TemplateBuilder } from "./template.js";
5
+ import { EC2VMBuilder } from "./ec2.js";
6
+ import { Config } from "../../core/config.js";
7
+ import { getFileHash } from "../proxmox/hash.js";
8
+ import { Stack } from "../../core/stack.js";
9
+ describe("AWS EC2TemplateBuilder Unit Tests", () => {
10
+ let originalSend;
11
+ let clientCalls = [];
12
+ let mockResponses = {};
13
+ beforeEach(() => {
14
+ Config.set({
15
+ dryRun: false,
16
+ providers: {
17
+ aws: { region: "us-east-1" },
18
+ },
19
+ });
20
+ clientCalls = [];
21
+ mockResponses = {};
22
+ originalSend = EC2Client.prototype.send;
23
+ EC2Client.prototype.send = async function (command) {
24
+ const name = command.constructor.name;
25
+ clientCalls.push({ method: name, input: command.input });
26
+ if (mockResponses[name] !== undefined) {
27
+ const handler = mockResponses[name];
28
+ if (typeof handler === "function")
29
+ return handler(command.input);
30
+ return handler;
31
+ }
32
+ if (name === "DescribeImagesCommand") {
33
+ return { Images: [] };
34
+ }
35
+ if (name === "DescribeInstancesCommand") {
36
+ return { Reservations: [] };
37
+ }
38
+ if (name === "RunInstancesCommand") {
39
+ return { Instances: [{ InstanceId: "i-temp123" }] };
40
+ }
41
+ if (name === "CreateImageCommand") {
42
+ return { ImageId: "ami-custom456" };
43
+ }
44
+ return {};
45
+ };
46
+ });
47
+ afterEach(() => {
48
+ EC2Client.prototype.send = originalSend;
49
+ });
50
+ test("gracefully handles discovery when Template does not exist", async () => {
51
+ const template = new EC2TemplateBuilder("my-golden-image");
52
+ const existing = await template.discoveryPromise;
53
+ assert.strictEqual(existing, null);
54
+ });
55
+ test("discovers existing Template and skips deployment if hashes match (Idempotence)", async () => {
56
+ const nginxHash = getFileHash("playbooks/nginx.yaml");
57
+ mockResponses["DescribeImagesCommand"] = {
58
+ Images: [
59
+ {
60
+ ImageId: "ami-golden123",
61
+ State: "available",
62
+ Tags: [
63
+ { Key: "Name", Value: "my-docker-base" },
64
+ { Key: "puls-provision", Value: `nginx-yaml=${nginxHash}` },
65
+ ],
66
+ },
67
+ ],
68
+ };
69
+ const template = new EC2TemplateBuilder("my-docker-base")
70
+ .provision("playbooks/nginx.yaml");
71
+ const result = await template.deploy();
72
+ assert.strictEqual(result.amiId, "ami-golden123");
73
+ // Ensure no RunInstances or CreateImage calls were made
74
+ const writes = clientCalls.filter(c => c.method === "RunInstancesCommand" || c.method === "CreateImageCommand");
75
+ assert.strictEqual(writes.length, 0);
76
+ });
77
+ test("purges and rebuilds template if playbooks differ", async () => {
78
+ mockResponses["DescribeImagesCommand"] = (input) => {
79
+ // If querying the target name "my-docker-base"
80
+ if (input.Filters?.some((f) => f.Name === "name" && f.Values?.includes("my-docker-base"))) {
81
+ return {
82
+ Images: [
83
+ {
84
+ ImageId: "ami-old555",
85
+ State: "available",
86
+ Tags: [
87
+ { Key: "Name", Value: "my-docker-base" },
88
+ { Key: "puls-provision", Value: "nginx-yaml=outdated" },
89
+ ],
90
+ BlockDeviceMappings: [
91
+ { Ebs: { SnapshotId: "snap-old999" } }
92
+ ],
93
+ },
94
+ ],
95
+ };
96
+ }
97
+ // If querying the baked template status "ami-custom456"
98
+ if (input.ImageIds?.includes("ami-custom456")) {
99
+ return {
100
+ Images: [{ ImageId: "ami-custom456", State: "available" }]
101
+ };
102
+ }
103
+ return { Images: [] };
104
+ };
105
+ let describeInstanceCount = 0;
106
+ mockResponses["DescribeInstancesCommand"] = () => {
107
+ describeInstanceCount++;
108
+ return {
109
+ Reservations: [
110
+ {
111
+ Instances: [
112
+ {
113
+ InstanceId: "i-temp123",
114
+ State: { Name: describeInstanceCount > 1 ? "stopped" : "running" },
115
+ PublicIpAddress: "34.20.10.99",
116
+ }
117
+ ]
118
+ }
119
+ ]
120
+ };
121
+ };
122
+ const template = new EC2TemplateBuilder("my-docker-base")
123
+ .provision("playbooks/nginx.yaml");
124
+ template.waitFor = async (label, condition) => {
125
+ return await condition();
126
+ };
127
+ template.checkPort = async () => true;
128
+ const provisionSpy = mock.method(template, "runProvisioner", async () => { });
129
+ const result = await template.deploy();
130
+ assert.strictEqual(result.amiId, "ami-custom456");
131
+ // Verify Deregister and Snapshot delete called
132
+ const deregisterCall = clientCalls.find(c => c.method === "DeregisterImageCommand");
133
+ assert.ok(deregisterCall);
134
+ assert.strictEqual(deregisterCall.input.ImageId, "ami-old555");
135
+ const deleteSnapCall = clientCalls.find(c => c.method === "DeleteSnapshotCommand");
136
+ assert.ok(deleteSnapCall);
137
+ assert.strictEqual(deleteSnapCall.input.SnapshotId, "snap-old999");
138
+ // Verify temp instance was created, stopped, image created, and terminated
139
+ assert.ok(clientCalls.some(c => c.method === "RunInstancesCommand"));
140
+ assert.ok(clientCalls.some(c => c.method === "StopInstancesCommand"));
141
+ assert.ok(clientCalls.some(c => c.method === "CreateImageCommand"));
142
+ assert.ok(clientCalls.some(c => c.method === "TerminateInstancesCommand"));
143
+ // Verify provision script ran on resolved temporary instance IP
144
+ assert.strictEqual(provisionSpy.mock.callCount(), 1);
145
+ assert.strictEqual(provisionSpy.mock.calls[0].arguments[0], "34.20.10.99");
146
+ assert.strictEqual(provisionSpy.mock.calls[0].arguments[1], "playbooks/nginx.yaml");
147
+ });
148
+ test("EC2 instance clones from custom baked template successfully", async () => {
149
+ const nginxHash = getFileHash("playbooks/nginx.yaml");
150
+ mockResponses["DescribeImagesCommand"] = (input) => {
151
+ if (input.Filters?.some((f) => f.Name === "name" && f.Values?.includes("my-golden-ami"))) {
152
+ return {
153
+ Images: [
154
+ {
155
+ ImageId: "ami-custom777",
156
+ State: "available",
157
+ Tags: [
158
+ { Key: "Name", Value: "my-golden-ami" },
159
+ { Key: "puls-provision", Value: `nginx-yaml=${nginxHash}` },
160
+ ],
161
+ },
162
+ ],
163
+ };
164
+ }
165
+ return { Images: [] };
166
+ };
167
+ let describeCount = 0;
168
+ mockResponses["DescribeInstancesCommand"] = () => {
169
+ describeCount++;
170
+ if (describeCount === 1)
171
+ return { Reservations: [] }; // VM doesn't exist initially
172
+ return {
173
+ Reservations: [
174
+ {
175
+ Instances: [
176
+ {
177
+ InstanceId: "i-prod123",
178
+ State: { Name: "running" },
179
+ PublicIpAddress: "34.200.5.5",
180
+ }
181
+ ]
182
+ }
183
+ ]
184
+ };
185
+ };
186
+ mockResponses["RunInstancesCommand"] = {
187
+ Instances: [{ InstanceId: "i-prod123" }],
188
+ };
189
+ class AWSStack extends Stack {
190
+ amiTemplate = new EC2TemplateBuilder("my-golden-ami")
191
+ .provision("playbooks/nginx.yaml");
192
+ server = new EC2VMBuilder("prod-server-01")
193
+ .fromTemplate(this.amiTemplate)
194
+ .instanceType("t3.small");
195
+ }
196
+ const stack = new AWSStack();
197
+ stack.server.waitFor = async () => true;
198
+ stack.server.checkPort = async () => true;
199
+ const result = await stack.deploy();
200
+ // Verify Stack outputs
201
+ assert.strictEqual(result.amiTemplate.amiId, "ami-custom777");
202
+ assert.strictEqual(result.server.id, "i-prod123");
203
+ // Verify VM cloned from dynamic template AMI
204
+ const runInstanceCall = clientCalls.find(c => c.method === "RunInstancesCommand" && c.input?.ImageId === "ami-custom777");
205
+ assert.ok(runInstanceCall);
206
+ assert.strictEqual(runInstanceCall.input.InstanceType, "t3.small");
207
+ });
208
+ });
@@ -3,9 +3,11 @@ export declare class DoApiClient {
3
3
  private static readonly BASE;
4
4
  constructor(token: string);
5
5
  private get authHeaders();
6
+ private createDoOfflineMock;
7
+ private request;
6
8
  get<T>(path: string): Promise<T>;
7
9
  post<T>(path: string, body: unknown): Promise<T>;
8
10
  put<T>(path: string, body: unknown): Promise<T>;
9
- delete(path: string): Promise<void>;
11
+ delete(path: string, body?: unknown): Promise<void>;
10
12
  }
11
13
  export declare function getDoApi(): DoApiClient;
@@ -1,4 +1,6 @@
1
1
  import { Config } from '../../core/config.js';
2
+ import { withRetry } from '../../core/retry.js';
3
+ import { resourceContextStorage } from '../../core/context.js';
2
4
  export class DoApiClient {
3
5
  token;
4
6
  static BASE = 'https://api.digitalocean.com/v2';
@@ -12,45 +14,142 @@ export class DoApiClient {
12
14
  'Accept-Encoding': 'identity'
13
15
  };
14
16
  }
17
+ createDoOfflineMock(method, path, body) {
18
+ if (path.includes("/droplets")) {
19
+ return {
20
+ droplet: {
21
+ id: 1234567,
22
+ name: body?.name ?? "mock-droplet",
23
+ networks: {
24
+ v4: [
25
+ { ip_address: "159.203.12.34", type: "public" },
26
+ { ip_address: "10.132.0.3", type: "private" }
27
+ ]
28
+ }
29
+ },
30
+ droplets: [
31
+ {
32
+ id: 1234567,
33
+ name: body?.name ?? "mock-droplet",
34
+ networks: {
35
+ v4: [
36
+ { ip_address: "159.203.12.34", type: "public" },
37
+ { ip_address: "10.132.0.3", type: "private" }
38
+ ]
39
+ }
40
+ }
41
+ ]
42
+ };
43
+ }
44
+ if (path.includes("/domains")) {
45
+ return { domain: { name: "mock-domain.com", ttl: 1800 } };
46
+ }
47
+ if (path.includes("/ssh_keys")) {
48
+ return { ssh_key: { id: 12345, name: "mock-key", public_key: "ssh-rsa mock" } };
49
+ }
50
+ return new Proxy({}, {
51
+ get(target, prop) {
52
+ if (prop === "then")
53
+ return undefined;
54
+ if (prop === "id")
55
+ return 123456;
56
+ if (prop === "name")
57
+ return "mock-do-name";
58
+ if (prop === "status")
59
+ return "active";
60
+ if (prop.endsWith("s"))
61
+ return [];
62
+ return `mock-do-${prop.toLowerCase()}`;
63
+ }
64
+ });
65
+ }
66
+ async request(fn) {
67
+ return withRetry(fn, {
68
+ retryable: (err) => {
69
+ const match = err.message.match(/: (\d+)/);
70
+ const status = match ? parseInt(match[1], 10) : null;
71
+ return status === 429 || (status && status >= 500) || err.message.includes("ETIMEDOUT");
72
+ }
73
+ });
74
+ }
15
75
  async get(path) {
16
- const res = await fetch(`${DoApiClient.BASE}${path}`, { headers: this.authHeaders });
17
- if (!res.ok)
18
- throw new Error(`DO API GET ${path}: ${res.status} ${await res.text()}`);
19
- return res.json();
76
+ const context = resourceContextStorage.getStore();
77
+ const abortSignal = context?.abortSignal;
78
+ if (Config.isOfflineMode() || Config.isGlobalDryRun()) {
79
+ return Promise.resolve(this.createDoOfflineMock('GET', path));
80
+ }
81
+ return this.request(async () => {
82
+ const res = await fetch(`${DoApiClient.BASE}${path}`, {
83
+ headers: this.authHeaders,
84
+ ...(abortSignal && { signal: abortSignal })
85
+ });
86
+ if (!res.ok)
87
+ throw new Error(`DO API GET ${path}: ${res.status} ${await res.text()}`);
88
+ return res.json();
89
+ });
20
90
  }
21
91
  async post(path, body) {
22
- const res = await fetch(`${DoApiClient.BASE}${path}`, {
23
- method: 'POST',
24
- headers: this.authHeaders,
25
- body: JSON.stringify(body),
92
+ const context = resourceContextStorage.getStore();
93
+ const abortSignal = context?.abortSignal;
94
+ if (Config.isOfflineMode() || Config.isGlobalDryRun()) {
95
+ return Promise.resolve(this.createDoOfflineMock('POST', path, body));
96
+ }
97
+ return this.request(async () => {
98
+ const res = await fetch(`${DoApiClient.BASE}${path}`, {
99
+ method: 'POST',
100
+ headers: this.authHeaders,
101
+ body: JSON.stringify(body),
102
+ ...(abortSignal && { signal: abortSignal })
103
+ });
104
+ if (!res.ok)
105
+ throw new Error(`DO API POST ${path}: ${res.status} ${await res.text()}`);
106
+ return res.json();
26
107
  });
27
- if (!res.ok)
28
- throw new Error(`DO API POST ${path}: ${res.status} ${await res.text()}`);
29
- return res.json();
30
108
  }
31
109
  async put(path, body) {
32
- const res = await fetch(`${DoApiClient.BASE}${path}`, {
33
- method: 'PUT',
34
- headers: this.authHeaders,
35
- body: JSON.stringify(body),
36
- });
37
- if (!res.ok)
38
- throw new Error(`DO API PUT ${path}: ${res.status} ${await res.text()}`);
39
- return res.json();
40
- }
41
- async delete(path) {
42
- const res = await fetch(`${DoApiClient.BASE}${path}`, {
43
- method: 'DELETE',
44
- headers: this.authHeaders,
110
+ const context = resourceContextStorage.getStore();
111
+ const abortSignal = context?.abortSignal;
112
+ if (Config.isOfflineMode() || Config.isGlobalDryRun()) {
113
+ return Promise.resolve(this.createDoOfflineMock('PUT', path, body));
114
+ }
115
+ return this.request(async () => {
116
+ const res = await fetch(`${DoApiClient.BASE}${path}`, {
117
+ method: 'PUT',
118
+ headers: this.authHeaders,
119
+ body: JSON.stringify(body),
120
+ ...(abortSignal && { signal: abortSignal })
121
+ });
122
+ if (!res.ok)
123
+ throw new Error(`DO API PUT ${path}: ${res.status} ${await res.text()}`);
124
+ return res.json();
45
125
  });
46
- if (!res.ok && res.status !== 404) {
47
- throw new Error(`DO API DELETE ${path}: ${res.status} ${await res.text()}`);
126
+ }
127
+ async delete(path, body) {
128
+ const context = resourceContextStorage.getStore();
129
+ const abortSignal = context?.abortSignal;
130
+ if (Config.isOfflineMode() || Config.isGlobalDryRun()) {
131
+ return Promise.resolve();
48
132
  }
133
+ return this.request(async () => {
134
+ const res = await fetch(`${DoApiClient.BASE}${path}`, {
135
+ method: 'DELETE',
136
+ headers: this.authHeaders,
137
+ ...(body !== undefined && { body: JSON.stringify(body) }),
138
+ ...(abortSignal && { signal: abortSignal })
139
+ });
140
+ if (!res.ok && res.status !== 404) {
141
+ throw new Error(`DO API DELETE ${path}: ${res.status} ${await res.text()}`);
142
+ }
143
+ });
49
144
  }
50
145
  }
51
146
  export function getDoApi() {
52
147
  const token = Config.get().providers.do?.token;
53
- if (!token)
148
+ if (!token) {
149
+ if (Config.isOfflineMode() || Config.isGlobalDryRun()) {
150
+ return new DoApiClient("mock-do-token");
151
+ }
54
152
  throw new Error('DO token not configured. Call DO.init({ token: "..." })');
153
+ }
55
154
  return new DoApiClient(token);
56
155
  }
@@ -0,0 +1,26 @@
1
+ import { BaseBuilder } from "../../core/resource.js";
2
+ import { Output } from "../../core/output.js";
3
+ export declare class AppPlatformBuilder extends BaseBuilder {
4
+ readonly out: {
5
+ id: Output<string>;
6
+ liveUrl: Output<string>;
7
+ };
8
+ private _spec;
9
+ constructor(appName: string);
10
+ spec(jsonSpec: any): this;
11
+ private discoverApp;
12
+ deploy(): Promise<{
13
+ name: string;
14
+ id: any;
15
+ liveUrl: any;
16
+ } | {
17
+ name: string;
18
+ id: string;
19
+ liveUrl?: undefined;
20
+ }>;
21
+ destroy(): Promise<{
22
+ destroyed: boolean;
23
+ } | {
24
+ destroyed: string;
25
+ }>;
26
+ }