puls-dev 0.2.7 ā 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.
- package/dist/core/config.d.ts +2 -0
- package/dist/core/decorators.d.ts +2 -0
- package/dist/core/decorators.js +48 -16
- 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/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/provisioner.d.ts +4 -0
- package/dist/core/provisioner.js +105 -0
- package/dist/core/resource.d.ts +16 -0
- package/dist/core/resource.js +44 -0
- package/dist/core/secret.d.ts +40 -0
- package/dist/core/secret.js +95 -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 +50 -9
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/providers/aws/ec2.d.ts +48 -0
- package/dist/providers/aws/ec2.js +297 -0
- package/dist/providers/aws/ec2.test.d.ts +1 -0
- package/dist/providers/aws/ec2.test.js +279 -0
- package/dist/providers/aws/index.d.ts +2 -0
- package/dist/providers/aws/index.js +2 -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/do/api.d.ts +1 -1
- package/dist/providers/do/api.js +2 -1
- 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 +132 -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/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 +3 -1
- package/dist/providers/gcp/index.js +3 -1
- package/dist/providers/gcp/vm.d.ts +45 -0
- package/dist/providers/gcp/vm.js +332 -0
- package/dist/providers/gcp/vm.test.d.ts +1 -0
- package/dist/providers/gcp/vm.test.js +321 -0
- package/dist/providers/proxmox/vm.d.ts +4 -4
- package/dist/providers/proxmox/vm.js +17 -93
- package/dist/providers/proxmox/vm.test.js +77 -0
- 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
|
-
|
|
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
|
-
|
|
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 {};
|