puls-dev 0.2.8 โ 0.3.0
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 +5 -0
- package/dist/core/config.js +12 -1
- package/dist/core/context.d.ts +14 -0
- package/dist/core/context.js +2 -0
- package/dist/core/decorators.d.ts +2 -0
- package/dist/core/decorators.js +8 -14
- package/dist/core/group.test.d.ts +1 -0
- package/dist/core/group.test.js +94 -0
- package/dist/core/parallel.test.d.ts +1 -0
- package/dist/core/parallel.test.js +215 -0
- package/dist/core/production.test.d.ts +1 -0
- package/dist/core/production.test.js +189 -0
- package/dist/core/provisioner.js +29 -11
- package/dist/core/resource.d.ts +8 -0
- package/dist/core/resource.js +45 -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 +2 -1
- package/dist/core/secret.js +12 -2
- package/dist/core/stack.js +381 -75
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/providers/aws/api.js +97 -17
- package/dist/providers/aws/ec2.d.ts +3 -0
- package/dist/providers/aws/ec2.js +37 -3
- package/dist/providers/aws/ec2.test.js +5 -3
- package/dist/providers/aws/index.d.ts +2 -0
- package/dist/providers/aws/index.js +2 -0
- package/dist/providers/aws/secrets.js +20 -3
- 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 +2 -0
- package/dist/providers/do/api.js +124 -26
- package/dist/providers/do/droplet.js +14 -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/index.d.ts +3 -1
- package/dist/providers/gcp/index.js +3 -1
- package/dist/providers/gcp/list.d.ts +2 -0
- package/dist/providers/gcp/list.js +55 -0
- package/dist/providers/gcp/secrets.js +21 -4
- 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 +3 -0
- package/dist/providers/gcp/vm.js +46 -3
- package/dist/providers/proxmox/api.d.ts +1 -0
- package/dist/providers/proxmox/api.js +72 -16
- package/dist/providers/proxmox/index.d.ts +3 -1
- package/dist/providers/proxmox/index.js +14 -1
- package/dist/providers/proxmox/template.d.ts +44 -0
- package/dist/providers/proxmox/template.js +350 -0
- package/dist/providers/proxmox/template.test.d.ts +1 -0
- package/dist/providers/proxmox/template.test.js +215 -0
- package/dist/providers/proxmox/vm.d.ts +3 -0
- package/dist/providers/proxmox/vm.js +43 -11
- package/dist/types/inventory.d.ts +44 -1
- package/package.json +2 -2
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import { GoogleAuth } from 'google-auth-library';
|
|
3
3
|
import { Config } from '../../core/config.js';
|
|
4
|
+
import { withRetry } from '../../core/retry.js';
|
|
5
|
+
import { resourceContextStorage } from '../../core/context.js';
|
|
4
6
|
export function resolveGCPConfig() {
|
|
7
|
+
const isOffline = Config.isOfflineMode() || Config.isGlobalDryRun();
|
|
5
8
|
// 1. Check Config.providers.gcp
|
|
6
9
|
const gcpCfg = Config.get().providers.gcp;
|
|
7
10
|
if (gcpCfg?.serviceAccountPath) {
|
|
@@ -72,6 +75,13 @@ export function resolveGCPConfig() {
|
|
|
72
75
|
// Continue to next fallback
|
|
73
76
|
}
|
|
74
77
|
}
|
|
78
|
+
if (isOffline) {
|
|
79
|
+
return {
|
|
80
|
+
projectId: "mock-gcp-project",
|
|
81
|
+
serviceAccountPath: "/mock/sa.json",
|
|
82
|
+
region: gcpCfg?.region ?? "us-central1"
|
|
83
|
+
};
|
|
84
|
+
}
|
|
75
85
|
throw new Error('GCP credentials not configured. Please set GCP_SA or FIREBASE_SA env var, or configure providers.gcp or providers.firebase in Config.');
|
|
76
86
|
}
|
|
77
87
|
export function getProjectId() {
|
|
@@ -82,6 +92,9 @@ export function getRegion() {
|
|
|
82
92
|
return gcpCfg?.region ?? 'us-central1';
|
|
83
93
|
}
|
|
84
94
|
export async function getGCPToken(scopes) {
|
|
95
|
+
if (Config.isOfflineMode() || Config.isGlobalDryRun()) {
|
|
96
|
+
return "mock-gcp-token";
|
|
97
|
+
}
|
|
85
98
|
const { serviceAccountPath } = resolveGCPConfig();
|
|
86
99
|
const auth = new GoogleAuth({ keyFile: serviceAccountPath, scopes });
|
|
87
100
|
const client = await auth.getClient();
|
|
@@ -91,21 +104,82 @@ export async function getGCPToken(scopes) {
|
|
|
91
104
|
}
|
|
92
105
|
return token.token;
|
|
93
106
|
}
|
|
107
|
+
function createGcpOfflineMock(base, path, opts) {
|
|
108
|
+
if (path.includes("/secrets/")) {
|
|
109
|
+
return {
|
|
110
|
+
payload: {
|
|
111
|
+
data: Buffer.from("mock-gcp-secret-value").toString("base64")
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
if (path.includes("/instances")) {
|
|
116
|
+
return {
|
|
117
|
+
status: "RUNNING",
|
|
118
|
+
id: "mock-gcp-instance-id",
|
|
119
|
+
networkInterfaces: [
|
|
120
|
+
{
|
|
121
|
+
networkIP: "10.128.0.2",
|
|
122
|
+
accessConfigs: [
|
|
123
|
+
{
|
|
124
|
+
natIP: "34.56.78.90"
|
|
125
|
+
}
|
|
126
|
+
]
|
|
127
|
+
}
|
|
128
|
+
]
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
if (path.includes("/global/networks")) {
|
|
132
|
+
return { status: "READY", name: "mock-network" };
|
|
133
|
+
}
|
|
134
|
+
if (path.includes("/subnetworks")) {
|
|
135
|
+
return { status: "READY", name: "mock-subnetwork" };
|
|
136
|
+
}
|
|
137
|
+
// Generic fallback proxy
|
|
138
|
+
return new Proxy({}, {
|
|
139
|
+
get(target, prop) {
|
|
140
|
+
if (prop === "then")
|
|
141
|
+
return undefined;
|
|
142
|
+
if (prop === "id")
|
|
143
|
+
return "mock-gcp-id-12345";
|
|
144
|
+
if (prop === "name")
|
|
145
|
+
return "mock-gcp-name";
|
|
146
|
+
if (prop === "status" || prop === "status")
|
|
147
|
+
return "RUNNING";
|
|
148
|
+
if (prop.endsWith("s"))
|
|
149
|
+
return [];
|
|
150
|
+
return `mock-gcp-${prop.toLowerCase()}`;
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
}
|
|
94
154
|
const CLOUD_SCOPE = 'https://www.googleapis.com/auth/cloud-platform';
|
|
95
155
|
export async function gcpFetch(base, path, opts = {}) {
|
|
96
|
-
const
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
'Authorization': `Bearer ${token}`,
|
|
101
|
-
'Content-Type': 'application/json',
|
|
102
|
-
...(opts.headers ?? {}),
|
|
103
|
-
},
|
|
104
|
-
});
|
|
105
|
-
if (!res.ok) {
|
|
106
|
-
const body = await res.text();
|
|
107
|
-
throw new Error(`GCP API ${opts.method ?? 'GET'} ${path} โ ${res.status}: ${body}`);
|
|
156
|
+
const context = resourceContextStorage.getStore();
|
|
157
|
+
const abortSignal = context?.abortSignal;
|
|
158
|
+
if (Config.isOfflineMode() || Config.isGlobalDryRun()) {
|
|
159
|
+
return Promise.resolve(createGcpOfflineMock(base, path, opts));
|
|
108
160
|
}
|
|
109
|
-
const
|
|
110
|
-
return
|
|
161
|
+
const fetchOpts = abortSignal ? { ...opts, signal: abortSignal } : opts;
|
|
162
|
+
return withRetry(async () => {
|
|
163
|
+
const token = await getGCPToken([CLOUD_SCOPE]);
|
|
164
|
+
const res = await fetch(`${base}${path}`, {
|
|
165
|
+
...fetchOpts,
|
|
166
|
+
headers: {
|
|
167
|
+
'Authorization': `Bearer ${token}`,
|
|
168
|
+
'Content-Type': 'application/json',
|
|
169
|
+
...(fetchOpts.headers ?? {}),
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
if (!res.ok) {
|
|
173
|
+
const body = await res.text();
|
|
174
|
+
throw new Error(`GCP API ${fetchOpts.method ?? 'GET'} ${path} โ ${res.status}: ${body}`);
|
|
175
|
+
}
|
|
176
|
+
const text = await res.text();
|
|
177
|
+
return text ? JSON.parse(text) : null;
|
|
178
|
+
}, {
|
|
179
|
+
retryable: (err) => {
|
|
180
|
+
const match = err.message.match(/โ (\d+):/);
|
|
181
|
+
const status = match ? parseInt(match[1], 10) : null;
|
|
182
|
+
return status === 429 || (status && status >= 500) || err.message.includes("ETIMEDOUT");
|
|
183
|
+
}
|
|
184
|
+
});
|
|
111
185
|
}
|
|
@@ -5,7 +5,8 @@ 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
|
-
|
|
8
|
+
import { GCPTemplateBuilder } from './template.js';
|
|
9
|
+
export { GCPSecretBuilder, resolveGCPEnvVars, GCPPubSubTopicBuilder, GCPPubSubSubscriptionBuilder, GCPCloudDNSZoneBuilder, GCPServiceAccountBuilder, GCPIAMBindingBuilder, GCPVMBuilder, GCPTemplateBuilder };
|
|
9
10
|
export declare const GCP: {
|
|
10
11
|
CloudRun: (serviceId: string) => GCPCloudRunBuilder;
|
|
11
12
|
CloudSQL: (instanceId: string) => GCPCloudSQLBuilder;
|
|
@@ -18,4 +19,5 @@ export declare const GCP: {
|
|
|
18
19
|
Subscription: (subscriptionId: string) => GCPPubSubSubscriptionBuilder;
|
|
19
20
|
};
|
|
20
21
|
VM: (instanceId: string) => GCPVMBuilder;
|
|
22
|
+
Template: (instanceId: string) => GCPTemplateBuilder;
|
|
21
23
|
};
|
|
@@ -5,7 +5,8 @@ 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
|
-
|
|
8
|
+
import { GCPTemplateBuilder } from './template.js';
|
|
9
|
+
export { GCPSecretBuilder, resolveGCPEnvVars, GCPPubSubTopicBuilder, GCPPubSubSubscriptionBuilder, GCPCloudDNSZoneBuilder, GCPServiceAccountBuilder, GCPIAMBindingBuilder, GCPVMBuilder, GCPTemplateBuilder };
|
|
9
10
|
export const GCP = {
|
|
10
11
|
CloudRun: (serviceId) => new GCPCloudRunBuilder(serviceId),
|
|
11
12
|
CloudSQL: (instanceId) => new GCPCloudSQLBuilder(instanceId),
|
|
@@ -18,4 +19,5 @@ export const GCP = {
|
|
|
18
19
|
Subscription: (subscriptionId) => new GCPPubSubSubscriptionBuilder(subscriptionId),
|
|
19
20
|
},
|
|
20
21
|
VM: (instanceId) => new GCPVMBuilder(instanceId),
|
|
22
|
+
Template: (instanceId) => new GCPTemplateBuilder(instanceId),
|
|
21
23
|
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { gcpFetch, getProjectId } from "./api.js";
|
|
2
|
+
export async function listGcpResources() {
|
|
3
|
+
const project = getProjectId();
|
|
4
|
+
const [vmRes, sqlRes, runRes, dnsRes] = await Promise.all([
|
|
5
|
+
gcpFetch("https://compute.googleapis.com", `/compute/v1/projects/${project}/aggregated/instances`).catch(() => ({})),
|
|
6
|
+
gcpFetch("https://sqladmin.googleapis.com", `/v1/projects/${project}/instances`).catch(() => ({})),
|
|
7
|
+
gcpFetch("https://run.googleapis.com", `/v2/projects/${project}/locations/-/services`).catch(() => ({})),
|
|
8
|
+
gcpFetch("https://dns.googleapis.com", `/dns/v1/projects/${project}/managedZones`).catch(() => ({})),
|
|
9
|
+
]);
|
|
10
|
+
// 1. Map VM Instances
|
|
11
|
+
const vms = [];
|
|
12
|
+
if (vmRes.items) {
|
|
13
|
+
for (const [zoneKey, zoneData] of Object.entries(vmRes.items)) {
|
|
14
|
+
const data = zoneData;
|
|
15
|
+
if (data.instances) {
|
|
16
|
+
const zone = zoneKey.split("/").pop() ?? zoneKey;
|
|
17
|
+
for (const inst of data.instances) {
|
|
18
|
+
const machineType = inst.machineType?.split("/").pop() ?? "unknown";
|
|
19
|
+
const ip = inst.networkInterfaces?.[0]?.accessConfigs?.[0]?.natIP ?? "no-ip";
|
|
20
|
+
vms.push({
|
|
21
|
+
name: inst.name,
|
|
22
|
+
zone,
|
|
23
|
+
machineType,
|
|
24
|
+
status: inst.status ?? "unknown",
|
|
25
|
+
ip,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// 2. Map Cloud SQL Instances
|
|
32
|
+
const rdsInstances = (sqlRes.items ?? []).map((i) => ({
|
|
33
|
+
name: i.name,
|
|
34
|
+
engine: i.databaseVersion ?? "unknown",
|
|
35
|
+
tier: i.settings?.tier ?? "unknown",
|
|
36
|
+
status: i.state ?? "unknown",
|
|
37
|
+
}));
|
|
38
|
+
// 3. Map Cloud Run Services
|
|
39
|
+
const distributions = (runRes.services ?? []).map((s) => {
|
|
40
|
+
const parts = s.name.split("/");
|
|
41
|
+
const name = parts.pop() ?? "unknown";
|
|
42
|
+
const region = parts[parts.indexOf("locations") + 1] ?? "unknown";
|
|
43
|
+
return {
|
|
44
|
+
name,
|
|
45
|
+
region,
|
|
46
|
+
url: s.uri ?? "no-url",
|
|
47
|
+
};
|
|
48
|
+
});
|
|
49
|
+
// 4. Map Cloud DNS Zones
|
|
50
|
+
const hostedZones = (dnsRes.managedZones ?? []).map((z) => ({
|
|
51
|
+
name: z.name,
|
|
52
|
+
dnsName: z.dnsName ?? "",
|
|
53
|
+
}));
|
|
54
|
+
return { vms, rdsInstances, distributions, hostedZones };
|
|
55
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { BaseBuilder } from "../../core/resource.js";
|
|
2
2
|
import { gcpFetch, getProjectId } from "./api.js";
|
|
3
|
+
import { resolvedSecrets } from "../../core/secret.js";
|
|
3
4
|
const SECRET_BASE = "https://secretmanager.googleapis.com";
|
|
4
5
|
export class GCPSecretBuilder extends BaseBuilder {
|
|
5
6
|
_value;
|
|
@@ -20,10 +21,13 @@ export class GCPSecretBuilder extends BaseBuilder {
|
|
|
20
21
|
const payload = await gcpFetch(SECRET_BASE, `/v1/projects/${project}/secrets/${secretId}/versions/latest:access`);
|
|
21
22
|
if (payload.payload?.data) {
|
|
22
23
|
this.resolvedValue = Buffer.from(payload.payload.data, "base64").toString("utf8");
|
|
24
|
+
if (this.resolvedValue && this.resolvedValue.length >= 3) {
|
|
25
|
+
resolvedSecrets.add(this.resolvedValue);
|
|
26
|
+
}
|
|
23
27
|
}
|
|
24
28
|
}
|
|
25
29
|
catch (err) {
|
|
26
|
-
|
|
30
|
+
console.warn(` โ ๏ธ Could not fetch latest version of secret "${secretId}": ${err.message}`);
|
|
27
31
|
}
|
|
28
32
|
return secret;
|
|
29
33
|
}
|
|
@@ -42,10 +46,17 @@ export class GCPSecretBuilder extends BaseBuilder {
|
|
|
42
46
|
}
|
|
43
47
|
plainText(v) {
|
|
44
48
|
this._value = v;
|
|
49
|
+
if (v && v.length >= 3) {
|
|
50
|
+
resolvedSecrets.add(v);
|
|
51
|
+
}
|
|
45
52
|
return this;
|
|
46
53
|
}
|
|
47
54
|
keyValue(obj) {
|
|
48
|
-
|
|
55
|
+
const v = JSON.stringify(obj);
|
|
56
|
+
this._value = v;
|
|
57
|
+
if (v && v.length >= 3) {
|
|
58
|
+
resolvedSecrets.add(v);
|
|
59
|
+
}
|
|
49
60
|
return this;
|
|
50
61
|
}
|
|
51
62
|
async deploy() {
|
|
@@ -58,7 +69,7 @@ export class GCPSecretBuilder extends BaseBuilder {
|
|
|
58
69
|
if (existing) {
|
|
59
70
|
console.log(` โ
Secret "${secretId}" exists`);
|
|
60
71
|
if (this.resolvedValue !== null) {
|
|
61
|
-
console.log(` ๐ฌ Value:
|
|
72
|
+
console.log(` ๐ฌ Value: ********`);
|
|
62
73
|
}
|
|
63
74
|
if (this._value) {
|
|
64
75
|
console.log(` ๐ [PLAN] Update secret value`);
|
|
@@ -101,12 +112,15 @@ export class GCPSecretBuilder extends BaseBuilder {
|
|
|
101
112
|
}),
|
|
102
113
|
});
|
|
103
114
|
this.resolvedValue = this._value;
|
|
115
|
+
if (this._value && this._value.length >= 3) {
|
|
116
|
+
resolvedSecrets.add(this._value);
|
|
117
|
+
}
|
|
104
118
|
console.log(`๐ Created secret "${secretId}"`);
|
|
105
119
|
}
|
|
106
120
|
else {
|
|
107
121
|
console.log(` โ
Secret "${secretId}" exists`);
|
|
108
122
|
if (this.resolvedValue !== null) {
|
|
109
|
-
console.log(` ๐ฌ Value:
|
|
123
|
+
console.log(` ๐ฌ Value: ********`);
|
|
110
124
|
}
|
|
111
125
|
if (this._value && this._value !== this.resolvedValue) {
|
|
112
126
|
const base64Data = Buffer.from(this._value, "utf8").toString("base64");
|
|
@@ -119,6 +133,9 @@ export class GCPSecretBuilder extends BaseBuilder {
|
|
|
119
133
|
}),
|
|
120
134
|
});
|
|
121
135
|
this.resolvedValue = this._value;
|
|
136
|
+
if (this._value && this._value.length >= 3) {
|
|
137
|
+
resolvedSecrets.add(this._value);
|
|
138
|
+
}
|
|
122
139
|
console.log(` โ
Updated secret value`);
|
|
123
140
|
}
|
|
124
141
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { BaseBuilder } from "../../core/resource.js";
|
|
2
|
+
import { Output } from "../../core/output.js";
|
|
3
|
+
export declare class GCPTemplateBuilder extends BaseBuilder {
|
|
4
|
+
readonly out: {
|
|
5
|
+
imageId: Output<string>;
|
|
6
|
+
};
|
|
7
|
+
private _baseImage;
|
|
8
|
+
private _machineType;
|
|
9
|
+
private _zone;
|
|
10
|
+
private _network;
|
|
11
|
+
private _sshKeys;
|
|
12
|
+
private _provision;
|
|
13
|
+
constructor(name: string);
|
|
14
|
+
baseImage(img: string): this;
|
|
15
|
+
machineType(type: string): this;
|
|
16
|
+
zone(z: string): this;
|
|
17
|
+
network(netPath: string): this;
|
|
18
|
+
sshKey(keys: string | string[]): this;
|
|
19
|
+
provision(...playbookPaths: (string | string[])[]): this;
|
|
20
|
+
private discoverImage;
|
|
21
|
+
protected checkPort(ip: string, port: number): Promise<boolean>;
|
|
22
|
+
protected runProvisioner(ip: string, script: string): Promise<void>;
|
|
23
|
+
deploy(): Promise<{
|
|
24
|
+
name: string;
|
|
25
|
+
imageId: string;
|
|
26
|
+
}>;
|
|
27
|
+
destroy(): Promise<{
|
|
28
|
+
destroyed: boolean;
|
|
29
|
+
} | {
|
|
30
|
+
destroyed: string;
|
|
31
|
+
}>;
|
|
32
|
+
}
|
|
@@ -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 {};
|