puls-dev 0.2.6 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +1 -1
  2. package/dist/core/config.d.ts +2 -0
  3. package/dist/core/decorators.d.ts +2 -0
  4. package/dist/core/decorators.js +48 -16
  5. package/dist/core/hooks.d.ts +21 -0
  6. package/dist/core/hooks.js +116 -0
  7. package/dist/core/hooks.test.d.ts +1 -0
  8. package/dist/core/hooks.test.js +194 -0
  9. package/dist/core/multiregion.test.d.ts +1 -0
  10. package/dist/core/multiregion.test.js +87 -0
  11. package/dist/core/output.d.ts +2 -0
  12. package/dist/core/output.js +9 -2
  13. package/dist/core/parser.d.ts +10 -0
  14. package/dist/core/parser.js +140 -0
  15. package/dist/core/parser.test.d.ts +1 -0
  16. package/dist/core/parser.test.js +117 -0
  17. package/dist/core/provisioner.d.ts +4 -0
  18. package/dist/core/provisioner.js +105 -0
  19. package/dist/core/resource.d.ts +16 -0
  20. package/dist/core/resource.js +44 -0
  21. package/dist/core/secret.d.ts +40 -0
  22. package/dist/core/secret.js +95 -0
  23. package/dist/core/secret.test.d.ts +1 -0
  24. package/dist/core/secret.test.js +166 -0
  25. package/dist/core/stack.d.ts +4 -3
  26. package/dist/core/stack.js +50 -9
  27. package/dist/index.d.ts +2 -0
  28. package/dist/index.js +2 -0
  29. package/dist/providers/aws/ec2.d.ts +48 -0
  30. package/dist/providers/aws/ec2.js +297 -0
  31. package/dist/providers/aws/ec2.test.d.ts +1 -0
  32. package/dist/providers/aws/ec2.test.js +279 -0
  33. package/dist/providers/aws/index.d.ts +2 -0
  34. package/dist/providers/aws/index.js +2 -0
  35. package/dist/providers/aws/route53.d.ts +1 -0
  36. package/dist/providers/aws/route53.js +15 -2
  37. package/dist/providers/aws/route53.test.js +47 -0
  38. package/dist/providers/do/api.d.ts +1 -1
  39. package/dist/providers/do/api.js +2 -1
  40. package/dist/providers/do/app.d.ts +26 -0
  41. package/dist/providers/do/app.js +124 -0
  42. package/dist/providers/do/app.test.d.ts +1 -0
  43. package/dist/providers/do/app.test.js +268 -0
  44. package/dist/providers/do/database.d.ts +44 -0
  45. package/dist/providers/do/database.js +208 -0
  46. package/dist/providers/do/database.test.d.ts +1 -0
  47. package/dist/providers/do/database.test.js +293 -0
  48. package/dist/providers/do/domain.d.ts +2 -0
  49. package/dist/providers/do/domain.js +30 -0
  50. package/dist/providers/do/domain.test.js +49 -0
  51. package/dist/providers/do/droplet.d.ts +9 -0
  52. package/dist/providers/do/droplet.js +132 -8
  53. package/dist/providers/do/droplet.test.js +228 -1
  54. package/dist/providers/do/firewall.d.ts +2 -1
  55. package/dist/providers/do/firewall.js +23 -9
  56. package/dist/providers/do/firewall.test.js +54 -0
  57. package/dist/providers/do/index.d.ts +11 -0
  58. package/dist/providers/do/index.js +8 -0
  59. package/dist/providers/do/spaces.d.ts +27 -0
  60. package/dist/providers/do/spaces.js +142 -0
  61. package/dist/providers/do/spaces.test.d.ts +1 -0
  62. package/dist/providers/do/spaces.test.js +180 -0
  63. package/dist/providers/do/spaces_api.d.ts +2 -0
  64. package/dist/providers/do/spaces_api.js +20 -0
  65. package/dist/providers/do/vpc.d.ts +30 -0
  66. package/dist/providers/do/vpc.js +128 -0
  67. package/dist/providers/do/vpc.test.d.ts +1 -0
  68. package/dist/providers/do/vpc.test.js +258 -0
  69. package/dist/providers/gcp/clouddns.d.ts +1 -0
  70. package/dist/providers/gcp/clouddns.js +15 -2
  71. package/dist/providers/gcp/clouddns.test.js +45 -0
  72. package/dist/providers/gcp/index.d.ts +3 -1
  73. package/dist/providers/gcp/index.js +3 -1
  74. package/dist/providers/gcp/vm.d.ts +45 -0
  75. package/dist/providers/gcp/vm.js +332 -0
  76. package/dist/providers/gcp/vm.test.d.ts +1 -0
  77. package/dist/providers/gcp/vm.test.js +321 -0
  78. package/dist/providers/proxmox/hash.d.ts +3 -0
  79. package/dist/providers/proxmox/hash.js +46 -0
  80. package/dist/providers/proxmox/vm.d.ts +8 -7
  81. package/dist/providers/proxmox/vm.js +126 -106
  82. package/dist/providers/proxmox/vm.test.js +224 -0
  83. package/package.json +3 -1
@@ -1,5 +1,7 @@
1
1
  import { test, describe, beforeEach, afterEach, mock } from "node:test";
2
2
  import assert from "node:assert";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
3
5
  import { GoogleAuth } from "google-auth-library";
4
6
  import { GCPCloudDNSZoneBuilder } from "./clouddns.js";
5
7
  import { Config } from "../../core/config.js";
@@ -256,4 +258,47 @@ describe("GCPCloudDNSZoneBuilder Unit Tests", () => {
256
258
  const deleteCall = fetchCalls.find((c) => c.method === "DELETE" && c.url.endsWith("/managedZones/to-delete-com"));
257
259
  assert.ok(deleteCall);
258
260
  });
261
+ test("loads records from a configuration file (YAML) successfully", async () => {
262
+ // 1. Zone exists
263
+ mockResponses["GET /managedZones/file-zone-com"] = {
264
+ status: 200,
265
+ body: { name: "file-zone-com" },
266
+ };
267
+ mockResponses["GET /managedZones/file-zone-com/rrsets"] = { status: 200, body: { rrsets: [] } };
268
+ mockResponses["POST /managedZones/file-zone-com/changes"] = { status: 200, body: {} };
269
+ // 2. Mock YAML file creation
270
+ const tempYamlPath = path.resolve(process.cwd(), "temp-dns-records.yaml");
271
+ const yamlContent = `
272
+ - name: www
273
+ type: CNAME
274
+ value: lb.google.com
275
+ - name: mail
276
+ type: A
277
+ value: 1.2.3.4
278
+ ttl: 600
279
+ `;
280
+ fs.writeFileSync(tempYamlPath, yamlContent, "utf-8");
281
+ try {
282
+ const builder = new GCPCloudDNSZoneBuilder("file-zone.com")
283
+ .record("temp-dns-records.yaml")
284
+ .record("api", "A", "10.0.0.9", 120); // Hybrid programmatic record!
285
+ const result = await builder.deploy();
286
+ assert.strictEqual(result.records.length, 3);
287
+ const wwwRec = result.records.find((r) => r.name === "www.file-zone.com.");
288
+ assert.ok(wwwRec);
289
+ assert.strictEqual(wwwRec.type, "CNAME");
290
+ assert.deepStrictEqual(wwwRec.rrdatas, ["lb.google.com."]);
291
+ const mailRec = result.records.find((r) => r.name === "mail.file-zone.com.");
292
+ assert.ok(mailRec);
293
+ assert.strictEqual(mailRec.type, "A");
294
+ assert.strictEqual(mailRec.ttl, 600);
295
+ const apiRec = result.records.find((r) => r.name === "api.file-zone.com.");
296
+ assert.ok(apiRec);
297
+ assert.strictEqual(apiRec.ttl, 120);
298
+ }
299
+ finally {
300
+ if (fs.existsSync(tempYamlPath))
301
+ fs.unlinkSync(tempYamlPath);
302
+ }
303
+ });
259
304
  });
@@ -4,7 +4,8 @@ import { GCPSecretBuilder, resolveGCPEnvVars } from './secrets.js';
4
4
  import { GCPPubSubTopicBuilder, GCPPubSubSubscriptionBuilder } from './pubsub.js';
5
5
  import { GCPCloudDNSZoneBuilder } from './clouddns.js';
6
6
  import { GCPServiceAccountBuilder, GCPIAMBindingBuilder } from './iam.js';
7
- export { GCPSecretBuilder, resolveGCPEnvVars, GCPPubSubTopicBuilder, GCPPubSubSubscriptionBuilder, GCPCloudDNSZoneBuilder, GCPServiceAccountBuilder, GCPIAMBindingBuilder };
7
+ import { GCPVMBuilder } from './vm.js';
8
+ export { GCPSecretBuilder, resolveGCPEnvVars, GCPPubSubTopicBuilder, GCPPubSubSubscriptionBuilder, GCPCloudDNSZoneBuilder, GCPServiceAccountBuilder, GCPIAMBindingBuilder, GCPVMBuilder };
8
9
  export declare const GCP: {
9
10
  CloudRun: (serviceId: string) => GCPCloudRunBuilder;
10
11
  CloudSQL: (instanceId: string) => GCPCloudSQLBuilder;
@@ -16,4 +17,5 @@ export declare const GCP: {
16
17
  Topic: (topicId: string) => GCPPubSubTopicBuilder;
17
18
  Subscription: (subscriptionId: string) => GCPPubSubSubscriptionBuilder;
18
19
  };
20
+ VM: (instanceId: string) => GCPVMBuilder;
19
21
  };
@@ -4,7 +4,8 @@ import { GCPSecretBuilder, resolveGCPEnvVars } from './secrets.js';
4
4
  import { GCPPubSubTopicBuilder, GCPPubSubSubscriptionBuilder } from './pubsub.js';
5
5
  import { GCPCloudDNSZoneBuilder } from './clouddns.js';
6
6
  import { GCPServiceAccountBuilder, GCPIAMBindingBuilder } from './iam.js';
7
- export { GCPSecretBuilder, resolveGCPEnvVars, GCPPubSubTopicBuilder, GCPPubSubSubscriptionBuilder, GCPCloudDNSZoneBuilder, GCPServiceAccountBuilder, GCPIAMBindingBuilder };
7
+ import { GCPVMBuilder } from './vm.js';
8
+ export { GCPSecretBuilder, resolveGCPEnvVars, GCPPubSubTopicBuilder, GCPPubSubSubscriptionBuilder, GCPCloudDNSZoneBuilder, GCPServiceAccountBuilder, GCPIAMBindingBuilder, GCPVMBuilder };
8
9
  export const GCP = {
9
10
  CloudRun: (serviceId) => new GCPCloudRunBuilder(serviceId),
10
11
  CloudSQL: (instanceId) => new GCPCloudSQLBuilder(instanceId),
@@ -16,4 +17,5 @@ export const GCP = {
16
17
  Topic: (topicId) => new GCPPubSubTopicBuilder(topicId),
17
18
  Subscription: (subscriptionId) => new GCPPubSubSubscriptionBuilder(subscriptionId),
18
19
  },
20
+ VM: (instanceId) => new GCPVMBuilder(instanceId),
19
21
  };
@@ -0,0 +1,45 @@
1
+ import { BaseBuilder } from "../../core/resource.js";
2
+ import { Output } from "../../core/output.js";
3
+ export declare class GCPVMBuilder extends BaseBuilder {
4
+ readonly out: {
5
+ ip: Output<string>;
6
+ id: Output<string>;
7
+ };
8
+ private _machineType;
9
+ private _image;
10
+ private _zone;
11
+ private _network;
12
+ private _sshKeys;
13
+ private _provision;
14
+ private _forceConfigCheck;
15
+ private resolvedInstanceId?;
16
+ private resolvedIp?;
17
+ constructor(name: string);
18
+ machineType(type: string): this;
19
+ image(img: string): this;
20
+ zone(z: string): this;
21
+ network(netPath: string): this;
22
+ sshKey(keys: string | string[]): this;
23
+ provision(...playbookPaths: (string | string[])[]): this;
24
+ forceConfigCheck(): this;
25
+ protected checkPort(ip: string, port: number): Promise<boolean>;
26
+ protected runProvisioner(ip: string, script: string): Promise<void>;
27
+ private discoverVM;
28
+ deploy(): Promise<{
29
+ name: string;
30
+ id: string;
31
+ ip?: undefined;
32
+ } | {
33
+ name: string;
34
+ id: string | undefined;
35
+ ip: string | undefined;
36
+ } | null>;
37
+ destroy(): Promise<{
38
+ destroyed: boolean;
39
+ } | {
40
+ destroyed: string;
41
+ }>;
42
+ private updateGcpMetadata;
43
+ }
44
+ export declare function parseGcpMetadataForProvision(value?: string): Record<string, string>;
45
+ export declare function mergeGcpMetadataForProvision(metadata: Record<string, string>): string;
@@ -0,0 +1,332 @@
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
+ export class GCPVMBuilder extends BaseBuilder {
9
+ out = {
10
+ ip: new Output(),
11
+ id: new Output(),
12
+ };
13
+ _machineType = "e2-micro";
14
+ _image = "projects/ubuntu-os-cloud/global/images/family/ubuntu-2204-lts";
15
+ _zone = "us-central1-a";
16
+ _network = "global/networks/default";
17
+ _sshKeys = [];
18
+ _provision = [];
19
+ _forceConfigCheck = false;
20
+ resolvedInstanceId;
21
+ resolvedIp;
22
+ constructor(name) {
23
+ super(name);
24
+ this.discoveryPromise = this.discoverVM();
25
+ }
26
+ machineType(type) {
27
+ this._machineType = type;
28
+ return this;
29
+ }
30
+ image(img) {
31
+ this._image = img;
32
+ return this;
33
+ }
34
+ zone(z) {
35
+ this._zone = z;
36
+ this.discoveryPromise = this.discoverVM();
37
+ return this;
38
+ }
39
+ network(netPath) {
40
+ this._network = netPath;
41
+ return this;
42
+ }
43
+ sshKey(keys) {
44
+ this._sshKeys = keys;
45
+ return this;
46
+ }
47
+ provision(...playbookPaths) {
48
+ this._provision.push(...playbookPaths.flat());
49
+ return this;
50
+ }
51
+ forceConfigCheck() {
52
+ this._forceConfigCheck = true;
53
+ return this;
54
+ }
55
+ async checkPort(ip, port) {
56
+ return checkPort(ip, port);
57
+ }
58
+ async runProvisioner(ip, script) {
59
+ const keysArray = Array.isArray(this._sshKeys) ? this._sshKeys : [this._sshKeys];
60
+ const keyPath = keysArray.find(k => !k.startsWith('ssh-') && !k.startsWith('ecdsa-') && !k.startsWith('sk-'));
61
+ return runProvisioner(ip, "root", keyPath, script);
62
+ }
63
+ async discoverVM() {
64
+ try {
65
+ const project = getProjectId();
66
+ const zone = this._zone;
67
+ const res = await gcpFetch("https://compute.googleapis.com", `/compute/v1/projects/${project}/zones/${zone}/instances/${this.name}`);
68
+ if (res) {
69
+ this.resolvedInstanceId = res.id;
70
+ const netInterface = (res.networkInterfaces ?? [])[0];
71
+ const extIp = (netInterface?.accessConfigs ?? [])[0]?.natIP;
72
+ this.resolvedIp = extIp;
73
+ if (res.id)
74
+ this.out.id.resolve(res.id);
75
+ if (extIp)
76
+ this.out.ip.resolve(extIp);
77
+ }
78
+ return res ?? null;
79
+ }
80
+ catch (e) {
81
+ if (e.message?.includes("404") ||
82
+ e.message?.includes("403") ||
83
+ e.message?.includes("credentials not configured")) {
84
+ return null;
85
+ }
86
+ throw e;
87
+ }
88
+ }
89
+ async deploy() {
90
+ const dryRun = this.isDryRunActive();
91
+ const existing = await this.discoveryPromise;
92
+ const project = getProjectId();
93
+ const zone = this._zone;
94
+ // Check if machine resizing is needed
95
+ const hasChanges = existing
96
+ ? existing.machineType?.split("/").pop() !== this._machineType
97
+ : true;
98
+ if (await this.checkProtection(hasChanges))
99
+ return null;
100
+ // Parse applied playbooks metadata from GCP metadata items
101
+ const metadataItem = (existing?.metadata?.items ?? []).find((i) => i.key === "puls-provision");
102
+ const appliedHashes = parseGcpMetadataForProvision(metadataItem?.value);
103
+ const declaredPlaybooksWithHashes = this._provision.map((p) => {
104
+ const baseName = p.split("/").pop() ?? p;
105
+ const slug = baseName.toLowerCase().replace(/[^a-z0-9_-]/g, "-");
106
+ return { path: p, slug, hash: getFileHash(p) };
107
+ });
108
+ const playbooksToRun = this._forceConfigCheck
109
+ ? declaredPlaybooksWithHashes
110
+ : declaredPlaybooksWithHashes.filter((p) => {
111
+ const appliedHash = appliedHashes[p.slug];
112
+ return !appliedHash || appliedHash !== p.hash;
113
+ });
114
+ const playbookRunRequired = playbooksToRun.length > 0;
115
+ if (dryRun) {
116
+ console.log(`\nšŸ” [DRY RUN] GCP VM "${this.name}"...`);
117
+ if (!existing) {
118
+ console.log(` šŸ“ Plan: Create GCP VM Instance "${this.name}" (${this._machineType} in zone ${this._zone})`);
119
+ if (this._provision.length > 0) {
120
+ console.log(` └─ Provision: ${this._provision.join(", ")}`);
121
+ }
122
+ this.out.id.resolve("PENDING");
123
+ this.out.ip.resolve("0.0.0.0");
124
+ }
125
+ else if (hasChanges || playbookRunRequired) {
126
+ if (hasChanges) {
127
+ console.log(` šŸ“ Plan: Stop and Resize VM ${this.name} → ${this._machineType}`);
128
+ }
129
+ if (playbookRunRequired) {
130
+ console.log(` šŸ“ [PLAN] Run ${playbooksToRun.length} playbook changes on existing GCP VM:`);
131
+ for (const p of playbooksToRun) {
132
+ console.log(` └─ Playbook: ${p.path} (hash: ${p.hash})`);
133
+ }
134
+ }
135
+ }
136
+ else {
137
+ console.log(` āœ… GCP VM "${this.name}" is up to date.`);
138
+ }
139
+ return { name: this.name, id: "PENDING" };
140
+ }
141
+ console.log(`\nā³ Finalizing GCP VM "${this.name}"...`);
142
+ if (!existing) {
143
+ const keysArray = Array.isArray(this._sshKeys) ? this._sshKeys : [this._sshKeys];
144
+ const sshKeysValue = keysArray
145
+ .map((k) => {
146
+ if (k.startsWith("ssh-") || k.startsWith("ecdsa-") || k.startsWith("sk-")) {
147
+ return `root:${k.trim()}`;
148
+ }
149
+ try {
150
+ const path = k.replace(/^~/, homedir());
151
+ const pubPath = path.replace(/\.pub$/, "") + ".pub";
152
+ const keyData = fs.readFileSync(pubPath, "utf-8").trim();
153
+ return `root:${keyData}`;
154
+ }
155
+ catch {
156
+ return `root:${k.trim()}`;
157
+ }
158
+ })
159
+ .join("\n");
160
+ // Compute initial playbooks metadata tag
161
+ const initialHashes = {};
162
+ for (const p of declaredPlaybooksWithHashes) {
163
+ initialHashes[p.slug] = p.hash;
164
+ }
165
+ const initialMetadataVal = mergeGcpMetadataForProvision(initialHashes);
166
+ const body = {
167
+ name: this.name,
168
+ machineType: `zones/${zone}/machineTypes/${this._machineType}`,
169
+ disks: [
170
+ {
171
+ boot: true,
172
+ autoDelete: true,
173
+ initializeParams: {
174
+ sourceImage: this._image,
175
+ },
176
+ },
177
+ ],
178
+ networkInterfaces: [
179
+ {
180
+ network: this._network,
181
+ accessConfigs: [
182
+ {
183
+ name: "External NAT",
184
+ type: "ONE_TO_ONE_NAT",
185
+ },
186
+ ],
187
+ },
188
+ ],
189
+ metadata: {
190
+ items: [
191
+ ...(sshKeysValue ? [{ key: "ssh-keys", value: sshKeysValue }] : []),
192
+ ...(initialMetadataVal ? [{ key: "puls-provision", value: initialMetadataVal }] : []),
193
+ ],
194
+ },
195
+ };
196
+ console.log(`šŸš€ Creating GCP Compute VM Instance "${this.name}"...`);
197
+ await gcpFetch("https://compute.googleapis.com", `/compute/v1/projects/${project}/zones/${zone}/instances`, {
198
+ method: "POST",
199
+ body: JSON.stringify(body),
200
+ });
201
+ // Poll until instance is RUNNING
202
+ await this.waitFor(`GCP VM "${this.name}" to start running`, async () => {
203
+ const current = await this.discoverVM();
204
+ return current && current.status === "RUNNING";
205
+ }, { intervalMs: 10_000, timeoutMs: 300_000 });
206
+ console.log(`šŸš€ GCP VM "${this.name}" is now running.`);
207
+ if (this._provision.length > 0) {
208
+ const activeIp = this.resolvedIp ?? "0.0.0.0";
209
+ if (activeIp === "0.0.0.0") {
210
+ throw new Error(`Failed to resolve IP for new GCP VM "${this.name}" to run playbooks`);
211
+ }
212
+ await this.waitFor(`SSH on ${activeIp} to be ready`, () => this.checkPort(activeIp, 22), { intervalMs: 10_000, timeoutMs: 300_000 });
213
+ for (const playbook of this._provision) {
214
+ await this.runProvisioner(activeIp, playbook);
215
+ }
216
+ }
217
+ }
218
+ else {
219
+ if (hasChanges) {
220
+ console.log(`✨ Resizing GCP VM ${this.name} → ${this._machineType}...`);
221
+ // GCP requires instance to be stopped to resize machineType
222
+ console.log(` šŸ”„ Stopping VM to perform resize...`);
223
+ await gcpFetch("https://compute.googleapis.com", `/compute/v1/projects/${project}/zones/${zone}/instances/${this.name}/stop`, { method: "POST" });
224
+ await this.waitFor(`VM "${this.name}" to stop`, async () => {
225
+ const current = await this.discoverVM();
226
+ return current && current.status === "TERMINATED";
227
+ }, { intervalMs: 10_000, timeoutMs: 300_000 });
228
+ // Perform resize
229
+ await gcpFetch("https://compute.googleapis.com", `/compute/v1/projects/${project}/zones/${zone}/instances/${this.name}/setSize`, {
230
+ method: "POST",
231
+ body: JSON.stringify({
232
+ machineType: `zones/${zone}/machineTypes/${this._machineType}`,
233
+ }),
234
+ });
235
+ // Restart VM
236
+ console.log(` šŸ”„ Restarting VM...`);
237
+ await gcpFetch("https://compute.googleapis.com", `/compute/v1/projects/${project}/zones/${zone}/instances/${this.name}/start`, { method: "POST" });
238
+ await this.waitFor(`VM "${this.name}" to restart`, async () => {
239
+ const current = await this.discoverVM();
240
+ return current && current.status === "RUNNING";
241
+ }, { intervalMs: 10_000, timeoutMs: 300_000 });
242
+ console.log(` āœ… GCP VM resized and restarted successfully.`);
243
+ }
244
+ if (playbookRunRequired) {
245
+ console.log(` šŸ”„ Running ${playbooksToRun.length} playbook changes on GCP VM...`);
246
+ const activeIp = this.resolvedIp ?? "0.0.0.0";
247
+ if (activeIp === "0.0.0.0") {
248
+ throw new Error(`Failed to resolve IP for GCP VM "${this.name}" to run playbooks`);
249
+ }
250
+ await this.waitFor(`SSH on ${activeIp} to be ready`, () => this.checkPort(activeIp, 22), { intervalMs: 10_000, timeoutMs: 300_000 });
251
+ for (const p of playbooksToRun) {
252
+ await this.runProvisioner(activeIp, p.path);
253
+ appliedHashes[p.slug] = p.hash;
254
+ }
255
+ // Re-discover to get fresh metadata fingerprint
256
+ const fresh = await this.discoverVM();
257
+ await this.updateGcpMetadata(fresh, appliedHashes);
258
+ console.log(` āœ… Playbooks applied successfully and metadata updated.`);
259
+ }
260
+ if (!hasChanges && !playbookRunRequired) {
261
+ console.log(`āœ… GCP VM "${this.name}" is up to date.`);
262
+ }
263
+ }
264
+ return {
265
+ name: this.name,
266
+ id: this.resolvedInstanceId,
267
+ ip: this.resolvedIp,
268
+ };
269
+ }
270
+ async destroy() {
271
+ const dryRun = this.isDryRunActive();
272
+ const existing = await this.discoveryPromise;
273
+ const project = getProjectId();
274
+ const zone = this._zone;
275
+ console.log(`\nšŸ—‘ļø Destroying GCP Compute VM "${this.name}"...`);
276
+ if (!existing) {
277
+ console.log(` ─ GCP VM "${this.name}" not found`);
278
+ return { destroyed: false };
279
+ }
280
+ if (dryRun) {
281
+ console.log(` šŸ“ [PLAN] Delete GCP VM "${this.name}"`);
282
+ return { destroyed: this.name };
283
+ }
284
+ console.log(` šŸ”„ Deleting GCP VM "${this.name}"...`);
285
+ await gcpFetch("https://compute.googleapis.com", `/compute/v1/projects/${project}/zones/${zone}/instances/${this.name}`, {
286
+ method: "DELETE",
287
+ });
288
+ console.log(` šŸ—‘ļø Removed GCP VM "${this.name}"`);
289
+ return { destroyed: this.name };
290
+ }
291
+ async updateGcpMetadata(existing, newHashes) {
292
+ const project = getProjectId();
293
+ const zone = this._zone;
294
+ const currentItems = [...(existing.metadata?.items ?? [])];
295
+ const newValue = mergeGcpMetadataForProvision(newHashes);
296
+ const provIdx = currentItems.findIndex((i) => i.key === "puls-provision");
297
+ if (provIdx >= 0) {
298
+ currentItems[provIdx].value = newValue;
299
+ }
300
+ else {
301
+ currentItems.push({ key: "puls-provision", value: newValue });
302
+ }
303
+ await gcpFetch("https://compute.googleapis.com", `/compute/v1/projects/${project}/zones/${zone}/instances/${this.name}/setMetadata`, {
304
+ method: "POST",
305
+ body: JSON.stringify({
306
+ fingerprint: existing.metadata?.fingerprint,
307
+ items: currentItems,
308
+ }),
309
+ });
310
+ }
311
+ }
312
+ export function parseGcpMetadataForProvision(value) {
313
+ if (!value)
314
+ return {};
315
+ const record = {};
316
+ const entries = value.split(",");
317
+ for (const entry of entries) {
318
+ const parts = entry.trim().split("=");
319
+ if (parts.length === 2) {
320
+ const [name, hash] = parts;
321
+ if (name && hash) {
322
+ record[name.trim()] = hash.trim();
323
+ }
324
+ }
325
+ }
326
+ return record;
327
+ }
328
+ export function mergeGcpMetadataForProvision(metadata) {
329
+ return Object.entries(metadata)
330
+ .map(([name, hash]) => `${name}=${hash}`)
331
+ .join(",");
332
+ }
@@ -0,0 +1 @@
1
+ export {};