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 fs from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { BaseBuilder } from "../../core/resource.js";
|
|
4
|
+
import { Output } from "../../core/output.js";
|
|
5
|
+
import { gcpFetch, getProjectId } from "./api.js";
|
|
6
|
+
import { checkPort, runProvisioner } from "../../core/provisioner.js";
|
|
7
|
+
import { getFileHash } from "../proxmox/hash.js";
|
|
8
|
+
import { parseGcpMetadataForProvision, mergeGcpMetadataForProvision } from "./vm.js";
|
|
9
|
+
export class GCPTemplateBuilder extends BaseBuilder {
|
|
10
|
+
out = {
|
|
11
|
+
imageId: new Output(),
|
|
12
|
+
};
|
|
13
|
+
_baseImage = "projects/ubuntu-os-cloud/global/images/family/ubuntu-2204-lts";
|
|
14
|
+
_machineType = "e2-micro";
|
|
15
|
+
_zone = "us-central1-a";
|
|
16
|
+
_network = "global/networks/default";
|
|
17
|
+
_sshKeys = [];
|
|
18
|
+
_provision = [];
|
|
19
|
+
constructor(name) {
|
|
20
|
+
super(name);
|
|
21
|
+
this.discoveryPromise = this.discoverImage();
|
|
22
|
+
}
|
|
23
|
+
baseImage(img) {
|
|
24
|
+
this._baseImage = img;
|
|
25
|
+
return this;
|
|
26
|
+
}
|
|
27
|
+
machineType(type) {
|
|
28
|
+
this._machineType = type;
|
|
29
|
+
return this;
|
|
30
|
+
}
|
|
31
|
+
zone(z) {
|
|
32
|
+
this._zone = z;
|
|
33
|
+
this.discoveryPromise = this.discoverImage();
|
|
34
|
+
return this;
|
|
35
|
+
}
|
|
36
|
+
network(netPath) {
|
|
37
|
+
this._network = netPath;
|
|
38
|
+
return this;
|
|
39
|
+
}
|
|
40
|
+
sshKey(keys) {
|
|
41
|
+
this._sshKeys = keys;
|
|
42
|
+
return this;
|
|
43
|
+
}
|
|
44
|
+
provision(...playbookPaths) {
|
|
45
|
+
this._provision.push(...playbookPaths.flat());
|
|
46
|
+
return this;
|
|
47
|
+
}
|
|
48
|
+
async discoverImage() {
|
|
49
|
+
try {
|
|
50
|
+
const project = getProjectId();
|
|
51
|
+
const res = await gcpFetch("https://compute.googleapis.com", `/compute/v1/projects/${project}/global/images/${this.name}`);
|
|
52
|
+
return res ?? null;
|
|
53
|
+
}
|
|
54
|
+
catch (e) {
|
|
55
|
+
if (e.message?.includes("404") ||
|
|
56
|
+
e.message?.includes("403") ||
|
|
57
|
+
e.message?.includes("credentials not configured")) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
throw e;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
async checkPort(ip, port) {
|
|
64
|
+
return checkPort(ip, port);
|
|
65
|
+
}
|
|
66
|
+
async runProvisioner(ip, script) {
|
|
67
|
+
const keysArray = Array.isArray(this._sshKeys) ? this._sshKeys : [this._sshKeys];
|
|
68
|
+
const keyPath = keysArray.find(k => !k.startsWith('ssh-') && !k.startsWith('ecdsa-') && !k.startsWith('sk-'));
|
|
69
|
+
return runProvisioner(ip, "root", keyPath, script);
|
|
70
|
+
}
|
|
71
|
+
async deploy() {
|
|
72
|
+
const dryRun = this.isDryRunActive();
|
|
73
|
+
const existing = await this.discoveryPromise;
|
|
74
|
+
const project = getProjectId();
|
|
75
|
+
const declaredPlaybooksWithHashes = this._provision.map((p) => {
|
|
76
|
+
const baseName = p.split("/").pop() ?? p;
|
|
77
|
+
const slug = baseName.toLowerCase().replace(/[^a-z0-9_-]/g, "-");
|
|
78
|
+
return { path: p, slug, hash: getFileHash(p) };
|
|
79
|
+
});
|
|
80
|
+
if (existing) {
|
|
81
|
+
const finalImageId = `projects/${project}/global/images/${this.name}`;
|
|
82
|
+
this.out.imageId.resolve(finalImageId);
|
|
83
|
+
// Check if playbook hashes differ
|
|
84
|
+
const appliedHashes = parseGcpMetadataForProvision(existing.description);
|
|
85
|
+
const hasChanges = declaredPlaybooksWithHashes.some((p) => {
|
|
86
|
+
const appliedHash = appliedHashes[p.slug];
|
|
87
|
+
return !appliedHash || appliedHash !== p.hash;
|
|
88
|
+
});
|
|
89
|
+
if (!hasChanges) {
|
|
90
|
+
console.log(`\n🔍 GCP Image Template "${this.name}"...`);
|
|
91
|
+
console.log(` ✅ Custom GCP Image "${this.name}" already exists and matches defined state.`);
|
|
92
|
+
return { name: this.name, imageId: finalImageId };
|
|
93
|
+
}
|
|
94
|
+
console.log(`\n⏳ Finalizing GCP Image Template "${this.name}"...`);
|
|
95
|
+
console.log(` 🔄 Template playbook hashes changed. Deleting old custom Image...`);
|
|
96
|
+
if (dryRun) {
|
|
97
|
+
console.log(` 📝 [PLAN] Would delete GCP Image "${this.name}" and rebuild.`);
|
|
98
|
+
return { name: this.name, imageId: "PENDING" };
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
await gcpFetch("https://compute.googleapis.com", `/compute/v1/projects/${project}/global/images/${this.name}`, { method: "DELETE" });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
console.log(`\n⏳ Finalizing GCP Image Template "${this.name}"...`);
|
|
105
|
+
const hashes = {};
|
|
106
|
+
for (const p of declaredPlaybooksWithHashes) {
|
|
107
|
+
hashes[p.slug] = p.hash;
|
|
108
|
+
}
|
|
109
|
+
const metadataVal = mergeGcpMetadataForProvision(hashes);
|
|
110
|
+
if (dryRun) {
|
|
111
|
+
console.log(` 📝 [PLAN] Bake GCP Image Template "${this.name}"`);
|
|
112
|
+
console.log(` └─ Base Image: ${this._baseImage} Machine Type: ${this._machineType}`);
|
|
113
|
+
if (this._provision.length > 0) {
|
|
114
|
+
console.log(` └─ Provision: ${this._provision.join(", ")}`);
|
|
115
|
+
}
|
|
116
|
+
this.out.imageId.resolve("PENDING");
|
|
117
|
+
return { name: this.name, imageId: "PENDING" };
|
|
118
|
+
}
|
|
119
|
+
// Spawn temporary instance
|
|
120
|
+
const keysArray = Array.isArray(this._sshKeys) ? this._sshKeys : [this._sshKeys];
|
|
121
|
+
const sshKeysValue = keysArray
|
|
122
|
+
.map((k) => {
|
|
123
|
+
if (k.startsWith("ssh-") || k.startsWith("ecdsa-") || k.startsWith("sk-")) {
|
|
124
|
+
return `root:${k.trim()}`;
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
const path = k.replace(/^~/, homedir());
|
|
128
|
+
const pubPath = path.replace(/\.pub$/, "") + ".pub";
|
|
129
|
+
const keyData = fs.readFileSync(pubPath, "utf-8").trim();
|
|
130
|
+
return `root:${keyData}`;
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return `root:${k.trim()}`;
|
|
134
|
+
}
|
|
135
|
+
})
|
|
136
|
+
.join("\n");
|
|
137
|
+
const tempInstanceName = `puls-bake-temp-${this.name}`;
|
|
138
|
+
const body = {
|
|
139
|
+
name: tempInstanceName,
|
|
140
|
+
machineType: `zones/${this._zone}/machineTypes/${this._machineType}`,
|
|
141
|
+
disks: [
|
|
142
|
+
{
|
|
143
|
+
boot: true,
|
|
144
|
+
autoDelete: true,
|
|
145
|
+
initializeParams: {
|
|
146
|
+
sourceImage: this._baseImage,
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
],
|
|
150
|
+
networkInterfaces: [
|
|
151
|
+
{
|
|
152
|
+
network: this._network,
|
|
153
|
+
accessConfigs: [
|
|
154
|
+
{
|
|
155
|
+
name: "External NAT",
|
|
156
|
+
type: "ONE_TO_ONE_NAT",
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
},
|
|
160
|
+
],
|
|
161
|
+
metadata: {
|
|
162
|
+
items: [
|
|
163
|
+
...(sshKeysValue ? [{ key: "ssh-keys", value: sshKeysValue }] : []),
|
|
164
|
+
],
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
console.log(`🚀 Spawning temporary VM "${tempInstanceName}" to bake custom image...`);
|
|
168
|
+
await gcpFetch("https://compute.googleapis.com", `/compute/v1/projects/${project}/zones/${this._zone}/instances`, {
|
|
169
|
+
method: "POST",
|
|
170
|
+
body: JSON.stringify(body),
|
|
171
|
+
});
|
|
172
|
+
// Wait until running and IP resolved
|
|
173
|
+
let resolvedIp;
|
|
174
|
+
await this.waitFor(`temporary instance "${tempInstanceName}" to start running`, async () => {
|
|
175
|
+
try {
|
|
176
|
+
const res = await gcpFetch("https://compute.googleapis.com", `/compute/v1/projects/${project}/zones/${this._zone}/instances/${tempInstanceName}`);
|
|
177
|
+
if (res && res.status === "RUNNING") {
|
|
178
|
+
const netInterface = (res.networkInterfaces ?? [])[0];
|
|
179
|
+
resolvedIp = (netInterface?.accessConfigs ?? [])[0]?.natIP;
|
|
180
|
+
return !!resolvedIp;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
// Ignore
|
|
185
|
+
}
|
|
186
|
+
return false;
|
|
187
|
+
}, { intervalMs: 10_000, timeoutMs: 300_000 });
|
|
188
|
+
if (!resolvedIp) {
|
|
189
|
+
throw new Error(`Failed to resolve IP for temporary instance "${tempInstanceName}"`);
|
|
190
|
+
}
|
|
191
|
+
// Provision the instance
|
|
192
|
+
if (this._provision.length > 0) {
|
|
193
|
+
await this.waitFor(`SSH on ${resolvedIp} to be ready`, () => this.checkPort(resolvedIp, 22), { intervalMs: 10_000, timeoutMs: 300_000 });
|
|
194
|
+
for (const playbook of this._provision) {
|
|
195
|
+
await this.runProvisioner(resolvedIp, playbook);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// Stop temporary instance
|
|
199
|
+
console.log(` 🛑 Stopping temporary instance "${tempInstanceName}"...`);
|
|
200
|
+
await gcpFetch("https://compute.googleapis.com", `/compute/v1/projects/${project}/zones/${this._zone}/instances/${tempInstanceName}/stop`, { method: "POST" });
|
|
201
|
+
await this.waitFor(`temporary instance to stop`, async () => {
|
|
202
|
+
try {
|
|
203
|
+
const res = await gcpFetch("https://compute.googleapis.com", `/compute/v1/projects/${project}/zones/${this._zone}/instances/${tempInstanceName}`);
|
|
204
|
+
return res && res.status === "TERMINATED";
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
}, { intervalMs: 10_000, timeoutMs: 300_000 });
|
|
210
|
+
// Bake Image
|
|
211
|
+
console.log(` 💾 Baking custom GCP Image "${this.name}" from instance disk...`);
|
|
212
|
+
const imageBody = {
|
|
213
|
+
name: this.name,
|
|
214
|
+
sourceDisk: `zones/${this._zone}/disks/${tempInstanceName}`,
|
|
215
|
+
description: metadataVal,
|
|
216
|
+
};
|
|
217
|
+
await gcpFetch("https://compute.googleapis.com", `/compute/v1/projects/${project}/global/images`, {
|
|
218
|
+
method: "POST",
|
|
219
|
+
body: JSON.stringify(imageBody),
|
|
220
|
+
});
|
|
221
|
+
// Wait until image is ready
|
|
222
|
+
await this.waitFor(`custom image "${this.name}" to become ready`, async () => {
|
|
223
|
+
const img = await this.discoverImage();
|
|
224
|
+
return img && img.status === "READY";
|
|
225
|
+
}, { intervalMs: 10_000, timeoutMs: 300_000 });
|
|
226
|
+
console.log(` ✅ Custom GCP Image "${this.name}" baked successfully.`);
|
|
227
|
+
// Clean up temporary instance
|
|
228
|
+
console.log(` 🧹 Terminating temporary provisioning instance...`);
|
|
229
|
+
await gcpFetch("https://compute.googleapis.com", `/compute/v1/projects/${project}/zones/${this._zone}/instances/${tempInstanceName}`, { method: "DELETE" });
|
|
230
|
+
const finalImageId = `projects/${project}/global/images/${this.name}`;
|
|
231
|
+
this.out.imageId.resolve(finalImageId);
|
|
232
|
+
return { name: this.name, imageId: finalImageId };
|
|
233
|
+
}
|
|
234
|
+
async destroy() {
|
|
235
|
+
const dryRun = this.isDryRunActive();
|
|
236
|
+
const existing = await this.discoveryPromise;
|
|
237
|
+
const project = getProjectId();
|
|
238
|
+
console.log(`\n🗑️ Destroying GCP Image Template "${this.name}"...`);
|
|
239
|
+
if (!existing) {
|
|
240
|
+
console.log(` ─ GCP Image Template "${this.name}" not found`);
|
|
241
|
+
return { destroyed: false };
|
|
242
|
+
}
|
|
243
|
+
if (dryRun) {
|
|
244
|
+
console.log(` 📝 [PLAN] Delete GCP Image "${this.name}"`);
|
|
245
|
+
return { destroyed: this.name };
|
|
246
|
+
}
|
|
247
|
+
console.log(` 🔄 Deleting GCP Image "${this.name}"...`);
|
|
248
|
+
await gcpFetch("https://compute.googleapis.com", `/compute/v1/projects/${project}/global/images/${this.name}`, { method: "DELETE" });
|
|
249
|
+
console.log(` 🗑️ Removed GCP Image Template "${this.name}"`);
|
|
250
|
+
return { destroyed: this.name };
|
|
251
|
+
}
|
|
252
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { test, describe, beforeEach, afterEach, mock } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import { GoogleAuth } from "google-auth-library";
|
|
5
|
+
import { GCPTemplateBuilder } from "./template.js";
|
|
6
|
+
import { GCPVMBuilder } from "./vm.js";
|
|
7
|
+
import { Config } from "../../core/config.js";
|
|
8
|
+
import { getFileHash } from "../proxmox/hash.js";
|
|
9
|
+
import { Stack } from "../../core/stack.js";
|
|
10
|
+
describe("GCPTemplateBuilder Unit Tests", () => {
|
|
11
|
+
let originalFetch;
|
|
12
|
+
let fetchCalls = [];
|
|
13
|
+
let mockResponses = {};
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
Config.set({
|
|
16
|
+
dryRun: false,
|
|
17
|
+
providers: {
|
|
18
|
+
gcp: {
|
|
19
|
+
projectId: "my-gcp-project",
|
|
20
|
+
serviceAccountPath: "/fake/sa.json",
|
|
21
|
+
region: "us-central1",
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
originalFetch = globalThis.fetch;
|
|
26
|
+
fetchCalls = [];
|
|
27
|
+
mockResponses = {};
|
|
28
|
+
globalThis.fetch = async (input, init) => {
|
|
29
|
+
const url = String(input);
|
|
30
|
+
const method = init?.method ?? "GET";
|
|
31
|
+
let body;
|
|
32
|
+
if (init?.body) {
|
|
33
|
+
if (typeof init.body === "string") {
|
|
34
|
+
try {
|
|
35
|
+
body = JSON.parse(init.body);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
body = init.body;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
body = "[Binary/Buffer Body]";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const headers = init?.headers;
|
|
46
|
+
fetchCalls.push({ url, method, body, headers });
|
|
47
|
+
const matchKey = Object.keys(mockResponses)
|
|
48
|
+
.filter((key) => {
|
|
49
|
+
const [mMethod, mPath] = key.split(" ");
|
|
50
|
+
return method === mMethod && url.includes(mPath);
|
|
51
|
+
})
|
|
52
|
+
.sort((a, b) => b.split(" ")[1].length - a.split(" ")[1].length)[0];
|
|
53
|
+
if (matchKey) {
|
|
54
|
+
const resp = mockResponses[matchKey];
|
|
55
|
+
return {
|
|
56
|
+
ok: resp.status >= 200 && resp.status < 300,
|
|
57
|
+
status: resp.status,
|
|
58
|
+
json: async () => resp.body,
|
|
59
|
+
text: async () => JSON.stringify(resp.body),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
ok: false,
|
|
64
|
+
status: 404,
|
|
65
|
+
json: async () => ({ error: { message: `Endpoint not mocked: ${method} ${url}` } }),
|
|
66
|
+
text: async () => `Endpoint not mocked: ${method} ${url}`,
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
mock.method(GoogleAuth.prototype, "getClient", async () => {
|
|
70
|
+
return {
|
|
71
|
+
getAccessToken: async () => ({ token: "fake-gcp-token" }),
|
|
72
|
+
};
|
|
73
|
+
});
|
|
74
|
+
mock.method(fs, "readFileSync", () => {
|
|
75
|
+
return "ssh-rsa AAAA_FAKE_GCP_PUBLIC_KEY test@gcp.com";
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
afterEach(() => {
|
|
79
|
+
globalThis.fetch = originalFetch;
|
|
80
|
+
mock.restoreAll();
|
|
81
|
+
});
|
|
82
|
+
test("gracefully handles discovery when Template does not exist", async () => {
|
|
83
|
+
mockResponses["GET /global/images/my-golden-image"] = {
|
|
84
|
+
status: 404,
|
|
85
|
+
body: { error: { message: "Not Found" } },
|
|
86
|
+
};
|
|
87
|
+
const builder = new GCPTemplateBuilder("my-golden-image");
|
|
88
|
+
const existing = await builder.discoveryPromise;
|
|
89
|
+
assert.strictEqual(existing, null);
|
|
90
|
+
});
|
|
91
|
+
test("discovers existing Template and skips deployment if hashes match (Idempotence)", async () => {
|
|
92
|
+
const nginxHash = getFileHash("playbooks/nginx.yaml");
|
|
93
|
+
mockResponses["GET /global/images/my-docker-base"] = {
|
|
94
|
+
status: 200,
|
|
95
|
+
body: {
|
|
96
|
+
name: "my-docker-base",
|
|
97
|
+
status: "READY",
|
|
98
|
+
description: `nginx-yaml=${nginxHash}`,
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
const builder = new GCPTemplateBuilder("my-docker-base")
|
|
102
|
+
.provision("playbooks/nginx.yaml");
|
|
103
|
+
const result = await builder.deploy();
|
|
104
|
+
assert.strictEqual(result.imageId, "projects/my-gcp-project/global/images/my-docker-base");
|
|
105
|
+
// No POST/DELETE calls since it already matches
|
|
106
|
+
const writes = fetchCalls.filter(c => c.method === "POST" || c.method === "DELETE");
|
|
107
|
+
assert.strictEqual(writes.length, 0);
|
|
108
|
+
});
|
|
109
|
+
test("purges and rebuilds template if playbooks differ", async () => {
|
|
110
|
+
// 1. Initial image discovery finds outdated hash
|
|
111
|
+
mockResponses["GET /global/images/my-docker-base"] = {
|
|
112
|
+
status: 200,
|
|
113
|
+
body: {
|
|
114
|
+
name: "my-docker-base",
|
|
115
|
+
status: "READY",
|
|
116
|
+
description: "nginx-yaml=outdated-hash",
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
// 2. Temp instance discovery transition: first 404, then RUNNING, then stopped (TERMINATED), then 404 after delete
|
|
120
|
+
let tempQueryCount = 0;
|
|
121
|
+
mockResponses["GET /instances/puls-bake-temp-my-docker-base"] = {
|
|
122
|
+
status: 200,
|
|
123
|
+
get body() {
|
|
124
|
+
tempQueryCount++;
|
|
125
|
+
if (tempQueryCount === 1) {
|
|
126
|
+
return {
|
|
127
|
+
name: "puls-bake-temp-my-docker-base",
|
|
128
|
+
status: "RUNNING",
|
|
129
|
+
networkInterfaces: [
|
|
130
|
+
{
|
|
131
|
+
accessConfigs: [{ natIP: "35.200.12.34" }],
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
name: "puls-bake-temp-my-docker-base",
|
|
138
|
+
status: "TERMINATED",
|
|
139
|
+
};
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
// 3. Mock image ready after bake
|
|
143
|
+
mockResponses["GET /global/images/my-docker-base"] = {
|
|
144
|
+
status: 200,
|
|
145
|
+
body: {
|
|
146
|
+
name: "my-docker-base",
|
|
147
|
+
status: "READY",
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
// 4. Operations and deletes
|
|
151
|
+
mockResponses["DELETE /global/images/my-docker-base"] = { status: 200, body: {} };
|
|
152
|
+
mockResponses["POST /instances"] = { status: 200, body: {} };
|
|
153
|
+
mockResponses["POST /instances/puls-bake-temp-my-docker-base/stop"] = { status: 200, body: {} };
|
|
154
|
+
mockResponses["POST /global/images"] = { status: 200, body: {} };
|
|
155
|
+
mockResponses["DELETE /instances/puls-bake-temp-my-docker-base"] = { status: 200, body: {} };
|
|
156
|
+
const builder = new GCPTemplateBuilder("my-docker-base")
|
|
157
|
+
.provision("playbooks/nginx.yaml");
|
|
158
|
+
builder.waitFor = async (label, condition) => {
|
|
159
|
+
return await condition();
|
|
160
|
+
};
|
|
161
|
+
builder.checkPort = async () => true;
|
|
162
|
+
const provisionSpy = mock.method(builder, "runProvisioner", async () => { });
|
|
163
|
+
const result = await builder.deploy();
|
|
164
|
+
assert.strictEqual(result.imageId, "projects/my-gcp-project/global/images/my-docker-base");
|
|
165
|
+
// Verify delete old image called
|
|
166
|
+
assert.ok(fetchCalls.some(c => c.method === "DELETE" && c.url.includes("/global/images/my-docker-base")));
|
|
167
|
+
// Verify temp instance POST, stop POST, image bake POST, and temp instance DELETE
|
|
168
|
+
assert.ok(fetchCalls.some(c => c.method === "POST" && c.url.includes("/instances")));
|
|
169
|
+
assert.ok(fetchCalls.some(c => c.method === "POST" && c.url.includes("/stop")));
|
|
170
|
+
assert.ok(fetchCalls.some(c => c.method === "POST" && c.url.includes("/global/images")));
|
|
171
|
+
assert.ok(fetchCalls.some(c => c.method === "DELETE" && c.url.includes("/instances/puls-bake-temp-my-docker-base")));
|
|
172
|
+
// Verify provision playbook ran
|
|
173
|
+
assert.strictEqual(provisionSpy.mock.callCount(), 1);
|
|
174
|
+
assert.strictEqual(provisionSpy.mock.calls[0].arguments[0], "35.200.12.34");
|
|
175
|
+
assert.strictEqual(provisionSpy.mock.calls[0].arguments[1], "playbooks/nginx.yaml");
|
|
176
|
+
});
|
|
177
|
+
test("GCPVMBuilder clones from GCP custom image template successfully", async () => {
|
|
178
|
+
const nginxHash = getFileHash("playbooks/nginx.yaml");
|
|
179
|
+
mockResponses["GET /global/images/my-golden-gcp-image"] = {
|
|
180
|
+
status: 200,
|
|
181
|
+
body: {
|
|
182
|
+
name: "my-golden-gcp-image",
|
|
183
|
+
status: "READY",
|
|
184
|
+
description: `nginx-yaml=${nginxHash}`,
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
let vmQueryCount = 0;
|
|
188
|
+
mockResponses["GET /instances/prod-server-01"] = {
|
|
189
|
+
status: 200,
|
|
190
|
+
get body() {
|
|
191
|
+
vmQueryCount++;
|
|
192
|
+
if (vmQueryCount === 1)
|
|
193
|
+
return null; // VM absent initially
|
|
194
|
+
return {
|
|
195
|
+
name: "prod-server-01",
|
|
196
|
+
status: "RUNNING",
|
|
197
|
+
networkInterfaces: [
|
|
198
|
+
{
|
|
199
|
+
accessConfigs: [{ natIP: "35.240.10.88" }],
|
|
200
|
+
},
|
|
201
|
+
],
|
|
202
|
+
};
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
mockResponses["POST /instances"] = { status: 200, body: {} };
|
|
206
|
+
class GCPStack extends Stack {
|
|
207
|
+
gcpTemplate = new GCPTemplateBuilder("my-golden-gcp-image")
|
|
208
|
+
.provision("playbooks/nginx.yaml");
|
|
209
|
+
server = new GCPVMBuilder("prod-server-01")
|
|
210
|
+
.fromTemplate(this.gcpTemplate)
|
|
211
|
+
.machineType("e2-standard-4");
|
|
212
|
+
}
|
|
213
|
+
const stack = new GCPStack();
|
|
214
|
+
stack.server.waitFor = async (label, condition) => {
|
|
215
|
+
return await condition();
|
|
216
|
+
};
|
|
217
|
+
stack.server.checkPort = async () => true;
|
|
218
|
+
const result = await stack.deploy();
|
|
219
|
+
assert.strictEqual(result.gcpTemplate.imageId, "projects/my-gcp-project/global/images/my-golden-gcp-image");
|
|
220
|
+
assert.strictEqual(result.server.ip, "35.240.10.88");
|
|
221
|
+
// Verify instance creation POST has the dynamically resolved custom image path
|
|
222
|
+
const createCall = fetchCalls.find(c => c.method === "POST" && c.url.includes("/instances") && c.body?.disks);
|
|
223
|
+
assert.ok(createCall);
|
|
224
|
+
assert.strictEqual(createCall.body.disks[0].initializeParams.sourceImage, "projects/my-gcp-project/global/images/my-golden-gcp-image");
|
|
225
|
+
assert.strictEqual(createCall.body.machineType, "zones/us-central1-a/machineTypes/e2-standard-4");
|
|
226
|
+
});
|
|
227
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { BaseBuilder } from "../../core/resource.js";
|
|
2
|
+
import { Output } from "../../core/output.js";
|
|
3
|
+
import { GCPTemplateBuilder } from "./template.js";
|
|
4
|
+
export declare class GCPVMBuilder extends BaseBuilder {
|
|
5
|
+
readonly out: {
|
|
6
|
+
ip: Output<string>;
|
|
7
|
+
id: Output<string>;
|
|
8
|
+
};
|
|
9
|
+
private _machineType;
|
|
10
|
+
private _image;
|
|
11
|
+
private _templateSource?;
|
|
12
|
+
private _zone;
|
|
13
|
+
private _network;
|
|
14
|
+
private _sshKeys;
|
|
15
|
+
private _provision;
|
|
16
|
+
private _forceConfigCheck;
|
|
17
|
+
private resolvedInstanceId?;
|
|
18
|
+
private resolvedIp?;
|
|
19
|
+
constructor(name: string);
|
|
20
|
+
machineType(type: string): this;
|
|
21
|
+
image(img: string): this;
|
|
22
|
+
fromTemplate(template: GCPTemplateBuilder): this;
|
|
23
|
+
zone(z: string): this;
|
|
24
|
+
network(netPath: string): this;
|
|
25
|
+
sshKey(keys: string | string[]): this;
|
|
26
|
+
provision(...playbookPaths: (string | string[])[]): this;
|
|
27
|
+
forceConfigCheck(): this;
|
|
28
|
+
protected checkPort(ip: string, port: number): Promise<boolean>;
|
|
29
|
+
protected runProvisioner(ip: string, script: string): Promise<void>;
|
|
30
|
+
private discoverVM;
|
|
31
|
+
deploy(): Promise<{
|
|
32
|
+
name: string;
|
|
33
|
+
id: string;
|
|
34
|
+
ip?: undefined;
|
|
35
|
+
} | {
|
|
36
|
+
name: string;
|
|
37
|
+
id: string | undefined;
|
|
38
|
+
ip: string | undefined;
|
|
39
|
+
} | null>;
|
|
40
|
+
destroy(): Promise<{
|
|
41
|
+
destroyed: boolean;
|
|
42
|
+
} | {
|
|
43
|
+
destroyed: string;
|
|
44
|
+
}>;
|
|
45
|
+
private updateGcpMetadata;
|
|
46
|
+
}
|
|
47
|
+
export declare function parseGcpMetadataForProvision(value?: string): Record<string, string>;
|
|
48
|
+
export declare function mergeGcpMetadataForProvision(metadata: Record<string, string>): string;
|