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
|
@@ -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;
|
package/dist/providers/do/api.js
CHANGED
|
@@ -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
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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
|
+
}
|