puls-dev 0.2.8 โ†’ 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.
Files changed (63) hide show
  1. package/dist/core/checker.js +71 -0
  2. package/dist/core/config.d.ts +4 -0
  3. package/dist/core/config.js +11 -1
  4. package/dist/core/context.d.ts +14 -0
  5. package/dist/core/context.js +2 -0
  6. package/dist/core/decorators.d.ts +2 -0
  7. package/dist/core/decorators.js +8 -14
  8. package/dist/core/parallel.test.d.ts +1 -0
  9. package/dist/core/parallel.test.js +215 -0
  10. package/dist/core/production.test.d.ts +1 -0
  11. package/dist/core/production.test.js +189 -0
  12. package/dist/core/provisioner.js +29 -11
  13. package/dist/core/resource.d.ts +7 -0
  14. package/dist/core/resource.js +10 -0
  15. package/dist/core/retry.d.ts +9 -0
  16. package/dist/core/retry.js +28 -0
  17. package/dist/core/retry.test.d.ts +1 -0
  18. package/dist/core/retry.test.js +66 -0
  19. package/dist/core/secret.d.ts +2 -1
  20. package/dist/core/secret.js +12 -2
  21. package/dist/core/stack.js +308 -75
  22. package/dist/index.d.ts +1 -0
  23. package/dist/index.js +1 -0
  24. package/dist/providers/aws/api.js +97 -17
  25. package/dist/providers/aws/ec2.d.ts +3 -0
  26. package/dist/providers/aws/ec2.js +37 -3
  27. package/dist/providers/aws/ec2.test.js +5 -3
  28. package/dist/providers/aws/index.d.ts +2 -0
  29. package/dist/providers/aws/index.js +2 -0
  30. package/dist/providers/aws/template.d.ts +34 -0
  31. package/dist/providers/aws/template.js +252 -0
  32. package/dist/providers/aws/template.test.d.ts +1 -0
  33. package/dist/providers/aws/template.test.js +208 -0
  34. package/dist/providers/do/api.d.ts +2 -0
  35. package/dist/providers/do/api.js +124 -26
  36. package/dist/providers/do/droplet.js +14 -0
  37. package/dist/providers/firebase/api.js +92 -29
  38. package/dist/providers/firebase/list.d.ts +2 -0
  39. package/dist/providers/firebase/list.js +25 -0
  40. package/dist/providers/gcp/api.js +88 -14
  41. package/dist/providers/gcp/index.d.ts +3 -1
  42. package/dist/providers/gcp/index.js +3 -1
  43. package/dist/providers/gcp/list.d.ts +2 -0
  44. package/dist/providers/gcp/list.js +55 -0
  45. package/dist/providers/gcp/secrets.js +1 -1
  46. package/dist/providers/gcp/template.d.ts +32 -0
  47. package/dist/providers/gcp/template.js +252 -0
  48. package/dist/providers/gcp/template.test.d.ts +1 -0
  49. package/dist/providers/gcp/template.test.js +227 -0
  50. package/dist/providers/gcp/vm.d.ts +3 -0
  51. package/dist/providers/gcp/vm.js +46 -3
  52. package/dist/providers/proxmox/api.d.ts +1 -0
  53. package/dist/providers/proxmox/api.js +72 -16
  54. package/dist/providers/proxmox/index.d.ts +2 -0
  55. package/dist/providers/proxmox/index.js +2 -0
  56. package/dist/providers/proxmox/template.d.ts +44 -0
  57. package/dist/providers/proxmox/template.js +349 -0
  58. package/dist/providers/proxmox/template.test.d.ts +1 -0
  59. package/dist/providers/proxmox/template.test.js +179 -0
  60. package/dist/providers/proxmox/vm.d.ts +3 -0
  61. package/dist/providers/proxmox/vm.js +40 -9
  62. package/dist/types/inventory.d.ts +44 -1
  63. package/package.json +1 -1
@@ -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 token = await getGCPToken([CLOUD_SCOPE]);
97
- const res = await fetch(`${base}${path}`, {
98
- ...opts,
99
- headers: {
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 text = await res.text();
110
- return text ? JSON.parse(text) : null;
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
- export { GCPSecretBuilder, resolveGCPEnvVars, GCPPubSubTopicBuilder, GCPPubSubSubscriptionBuilder, GCPCloudDNSZoneBuilder, GCPServiceAccountBuilder, GCPIAMBindingBuilder, GCPVMBuilder };
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
- export { GCPSecretBuilder, resolveGCPEnvVars, GCPPubSubTopicBuilder, GCPPubSubSubscriptionBuilder, GCPCloudDNSZoneBuilder, GCPServiceAccountBuilder, GCPIAMBindingBuilder, GCPVMBuilder };
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,2 @@
1
+ import type { GcpInventory } from "../../types/inventory.js";
2
+ export declare function listGcpResources(): Promise<GcpInventory>;
@@ -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
+ }
@@ -23,7 +23,7 @@ export class GCPSecretBuilder extends BaseBuilder {
23
23
  }
24
24
  }
25
25
  catch (err) {
26
- // If version access fails (e.g. no versions created yet), keep resolvedValue as null
26
+ console.warn(` โš ๏ธ Could not fetch latest version of secret "${secretId}": ${err.message}`);
27
27
  }
28
28
  return secret;
29
29
  }
@@ -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 {};