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
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { test, describe, beforeEach, afterEach, mock } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import { EC2Client } from "@aws-sdk/client-ec2";
|
|
4
|
+
import { EC2TemplateBuilder } from "./template.js";
|
|
5
|
+
import { EC2VMBuilder } from "./ec2.js";
|
|
6
|
+
import { Config } from "../../core/config.js";
|
|
7
|
+
import { getFileHash } from "../proxmox/hash.js";
|
|
8
|
+
import { Stack } from "../../core/stack.js";
|
|
9
|
+
describe("AWS EC2TemplateBuilder Unit Tests", () => {
|
|
10
|
+
let originalSend;
|
|
11
|
+
let clientCalls = [];
|
|
12
|
+
let mockResponses = {};
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
Config.set({
|
|
15
|
+
dryRun: false,
|
|
16
|
+
providers: {
|
|
17
|
+
aws: { region: "us-east-1" },
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
clientCalls = [];
|
|
21
|
+
mockResponses = {};
|
|
22
|
+
originalSend = EC2Client.prototype.send;
|
|
23
|
+
EC2Client.prototype.send = async function (command) {
|
|
24
|
+
const name = command.constructor.name;
|
|
25
|
+
clientCalls.push({ method: name, input: command.input });
|
|
26
|
+
if (mockResponses[name] !== undefined) {
|
|
27
|
+
const handler = mockResponses[name];
|
|
28
|
+
if (typeof handler === "function")
|
|
29
|
+
return handler(command.input);
|
|
30
|
+
return handler;
|
|
31
|
+
}
|
|
32
|
+
if (name === "DescribeImagesCommand") {
|
|
33
|
+
return { Images: [] };
|
|
34
|
+
}
|
|
35
|
+
if (name === "DescribeInstancesCommand") {
|
|
36
|
+
return { Reservations: [] };
|
|
37
|
+
}
|
|
38
|
+
if (name === "RunInstancesCommand") {
|
|
39
|
+
return { Instances: [{ InstanceId: "i-temp123" }] };
|
|
40
|
+
}
|
|
41
|
+
if (name === "CreateImageCommand") {
|
|
42
|
+
return { ImageId: "ami-custom456" };
|
|
43
|
+
}
|
|
44
|
+
return {};
|
|
45
|
+
};
|
|
46
|
+
});
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
EC2Client.prototype.send = originalSend;
|
|
49
|
+
});
|
|
50
|
+
test("gracefully handles discovery when Template does not exist", async () => {
|
|
51
|
+
const template = new EC2TemplateBuilder("my-golden-image");
|
|
52
|
+
const existing = await template.discoveryPromise;
|
|
53
|
+
assert.strictEqual(existing, null);
|
|
54
|
+
});
|
|
55
|
+
test("discovers existing Template and skips deployment if hashes match (Idempotence)", async () => {
|
|
56
|
+
const nginxHash = getFileHash("playbooks/nginx.yaml");
|
|
57
|
+
mockResponses["DescribeImagesCommand"] = {
|
|
58
|
+
Images: [
|
|
59
|
+
{
|
|
60
|
+
ImageId: "ami-golden123",
|
|
61
|
+
State: "available",
|
|
62
|
+
Tags: [
|
|
63
|
+
{ Key: "Name", Value: "my-docker-base" },
|
|
64
|
+
{ Key: "puls-provision", Value: `nginx-yaml=${nginxHash}` },
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
};
|
|
69
|
+
const template = new EC2TemplateBuilder("my-docker-base")
|
|
70
|
+
.provision("playbooks/nginx.yaml");
|
|
71
|
+
const result = await template.deploy();
|
|
72
|
+
assert.strictEqual(result.amiId, "ami-golden123");
|
|
73
|
+
// Ensure no RunInstances or CreateImage calls were made
|
|
74
|
+
const writes = clientCalls.filter(c => c.method === "RunInstancesCommand" || c.method === "CreateImageCommand");
|
|
75
|
+
assert.strictEqual(writes.length, 0);
|
|
76
|
+
});
|
|
77
|
+
test("purges and rebuilds template if playbooks differ", async () => {
|
|
78
|
+
mockResponses["DescribeImagesCommand"] = (input) => {
|
|
79
|
+
// If querying the target name "my-docker-base"
|
|
80
|
+
if (input.Filters?.some((f) => f.Name === "name" && f.Values?.includes("my-docker-base"))) {
|
|
81
|
+
return {
|
|
82
|
+
Images: [
|
|
83
|
+
{
|
|
84
|
+
ImageId: "ami-old555",
|
|
85
|
+
State: "available",
|
|
86
|
+
Tags: [
|
|
87
|
+
{ Key: "Name", Value: "my-docker-base" },
|
|
88
|
+
{ Key: "puls-provision", Value: "nginx-yaml=outdated" },
|
|
89
|
+
],
|
|
90
|
+
BlockDeviceMappings: [
|
|
91
|
+
{ Ebs: { SnapshotId: "snap-old999" } }
|
|
92
|
+
],
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
// If querying the baked template status "ami-custom456"
|
|
98
|
+
if (input.ImageIds?.includes("ami-custom456")) {
|
|
99
|
+
return {
|
|
100
|
+
Images: [{ ImageId: "ami-custom456", State: "available" }]
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
return { Images: [] };
|
|
104
|
+
};
|
|
105
|
+
let describeInstanceCount = 0;
|
|
106
|
+
mockResponses["DescribeInstancesCommand"] = () => {
|
|
107
|
+
describeInstanceCount++;
|
|
108
|
+
return {
|
|
109
|
+
Reservations: [
|
|
110
|
+
{
|
|
111
|
+
Instances: [
|
|
112
|
+
{
|
|
113
|
+
InstanceId: "i-temp123",
|
|
114
|
+
State: { Name: describeInstanceCount > 1 ? "stopped" : "running" },
|
|
115
|
+
PublicIpAddress: "34.20.10.99",
|
|
116
|
+
}
|
|
117
|
+
]
|
|
118
|
+
}
|
|
119
|
+
]
|
|
120
|
+
};
|
|
121
|
+
};
|
|
122
|
+
const template = new EC2TemplateBuilder("my-docker-base")
|
|
123
|
+
.provision("playbooks/nginx.yaml");
|
|
124
|
+
template.waitFor = async (label, condition) => {
|
|
125
|
+
return await condition();
|
|
126
|
+
};
|
|
127
|
+
template.checkPort = async () => true;
|
|
128
|
+
const provisionSpy = mock.method(template, "runProvisioner", async () => { });
|
|
129
|
+
const result = await template.deploy();
|
|
130
|
+
assert.strictEqual(result.amiId, "ami-custom456");
|
|
131
|
+
// Verify Deregister and Snapshot delete called
|
|
132
|
+
const deregisterCall = clientCalls.find(c => c.method === "DeregisterImageCommand");
|
|
133
|
+
assert.ok(deregisterCall);
|
|
134
|
+
assert.strictEqual(deregisterCall.input.ImageId, "ami-old555");
|
|
135
|
+
const deleteSnapCall = clientCalls.find(c => c.method === "DeleteSnapshotCommand");
|
|
136
|
+
assert.ok(deleteSnapCall);
|
|
137
|
+
assert.strictEqual(deleteSnapCall.input.SnapshotId, "snap-old999");
|
|
138
|
+
// Verify temp instance was created, stopped, image created, and terminated
|
|
139
|
+
assert.ok(clientCalls.some(c => c.method === "RunInstancesCommand"));
|
|
140
|
+
assert.ok(clientCalls.some(c => c.method === "StopInstancesCommand"));
|
|
141
|
+
assert.ok(clientCalls.some(c => c.method === "CreateImageCommand"));
|
|
142
|
+
assert.ok(clientCalls.some(c => c.method === "TerminateInstancesCommand"));
|
|
143
|
+
// Verify provision script ran on resolved temporary instance IP
|
|
144
|
+
assert.strictEqual(provisionSpy.mock.callCount(), 1);
|
|
145
|
+
assert.strictEqual(provisionSpy.mock.calls[0].arguments[0], "34.20.10.99");
|
|
146
|
+
assert.strictEqual(provisionSpy.mock.calls[0].arguments[1], "playbooks/nginx.yaml");
|
|
147
|
+
});
|
|
148
|
+
test("EC2 instance clones from custom baked template successfully", async () => {
|
|
149
|
+
const nginxHash = getFileHash("playbooks/nginx.yaml");
|
|
150
|
+
mockResponses["DescribeImagesCommand"] = (input) => {
|
|
151
|
+
if (input.Filters?.some((f) => f.Name === "name" && f.Values?.includes("my-golden-ami"))) {
|
|
152
|
+
return {
|
|
153
|
+
Images: [
|
|
154
|
+
{
|
|
155
|
+
ImageId: "ami-custom777",
|
|
156
|
+
State: "available",
|
|
157
|
+
Tags: [
|
|
158
|
+
{ Key: "Name", Value: "my-golden-ami" },
|
|
159
|
+
{ Key: "puls-provision", Value: `nginx-yaml=${nginxHash}` },
|
|
160
|
+
],
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
return { Images: [] };
|
|
166
|
+
};
|
|
167
|
+
let describeCount = 0;
|
|
168
|
+
mockResponses["DescribeInstancesCommand"] = () => {
|
|
169
|
+
describeCount++;
|
|
170
|
+
if (describeCount === 1)
|
|
171
|
+
return { Reservations: [] }; // VM doesn't exist initially
|
|
172
|
+
return {
|
|
173
|
+
Reservations: [
|
|
174
|
+
{
|
|
175
|
+
Instances: [
|
|
176
|
+
{
|
|
177
|
+
InstanceId: "i-prod123",
|
|
178
|
+
State: { Name: "running" },
|
|
179
|
+
PublicIpAddress: "34.200.5.5",
|
|
180
|
+
}
|
|
181
|
+
]
|
|
182
|
+
}
|
|
183
|
+
]
|
|
184
|
+
};
|
|
185
|
+
};
|
|
186
|
+
mockResponses["RunInstancesCommand"] = {
|
|
187
|
+
Instances: [{ InstanceId: "i-prod123" }],
|
|
188
|
+
};
|
|
189
|
+
class AWSStack extends Stack {
|
|
190
|
+
amiTemplate = new EC2TemplateBuilder("my-golden-ami")
|
|
191
|
+
.provision("playbooks/nginx.yaml");
|
|
192
|
+
server = new EC2VMBuilder("prod-server-01")
|
|
193
|
+
.fromTemplate(this.amiTemplate)
|
|
194
|
+
.instanceType("t3.small");
|
|
195
|
+
}
|
|
196
|
+
const stack = new AWSStack();
|
|
197
|
+
stack.server.waitFor = async () => true;
|
|
198
|
+
stack.server.checkPort = async () => true;
|
|
199
|
+
const result = await stack.deploy();
|
|
200
|
+
// Verify Stack outputs
|
|
201
|
+
assert.strictEqual(result.amiTemplate.amiId, "ami-custom777");
|
|
202
|
+
assert.strictEqual(result.server.id, "i-prod123");
|
|
203
|
+
// Verify VM cloned from dynamic template AMI
|
|
204
|
+
const runInstanceCall = clientCalls.find(c => c.method === "RunInstancesCommand" && c.input?.ImageId === "ami-custom777");
|
|
205
|
+
assert.ok(runInstanceCall);
|
|
206
|
+
assert.strictEqual(runInstanceCall.input.InstanceType, "t3.small");
|
|
207
|
+
});
|
|
208
|
+
});
|
|
@@ -3,6 +3,8 @@ export declare class DoApiClient {
|
|
|
3
3
|
private static readonly BASE;
|
|
4
4
|
constructor(token: string);
|
|
5
5
|
private get authHeaders();
|
|
6
|
+
private createDoOfflineMock;
|
|
7
|
+
private request;
|
|
6
8
|
get<T>(path: string): Promise<T>;
|
|
7
9
|
post<T>(path: string, body: unknown): Promise<T>;
|
|
8
10
|
put<T>(path: string, body: unknown): Promise<T>;
|
package/dist/providers/do/api.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { Config } from '../../core/config.js';
|
|
2
|
+
import { withRetry } from '../../core/retry.js';
|
|
3
|
+
import { resourceContextStorage } from '../../core/context.js';
|
|
2
4
|
export class DoApiClient {
|
|
3
5
|
token;
|
|
4
6
|
static BASE = 'https://api.digitalocean.com/v2';
|
|
@@ -12,46 +14,142 @@ export class DoApiClient {
|
|
|
12
14
|
'Accept-Encoding': 'identity'
|
|
13
15
|
};
|
|
14
16
|
}
|
|
17
|
+
createDoOfflineMock(method, path, body) {
|
|
18
|
+
if (path.includes("/droplets")) {
|
|
19
|
+
return {
|
|
20
|
+
droplet: {
|
|
21
|
+
id: 1234567,
|
|
22
|
+
name: body?.name ?? "mock-droplet",
|
|
23
|
+
networks: {
|
|
24
|
+
v4: [
|
|
25
|
+
{ ip_address: "159.203.12.34", type: "public" },
|
|
26
|
+
{ ip_address: "10.132.0.3", type: "private" }
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
droplets: [
|
|
31
|
+
{
|
|
32
|
+
id: 1234567,
|
|
33
|
+
name: body?.name ?? "mock-droplet",
|
|
34
|
+
networks: {
|
|
35
|
+
v4: [
|
|
36
|
+
{ ip_address: "159.203.12.34", type: "public" },
|
|
37
|
+
{ ip_address: "10.132.0.3", type: "private" }
|
|
38
|
+
]
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
]
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
if (path.includes("/domains")) {
|
|
45
|
+
return { domain: { name: "mock-domain.com", ttl: 1800 } };
|
|
46
|
+
}
|
|
47
|
+
if (path.includes("/ssh_keys")) {
|
|
48
|
+
return { ssh_key: { id: 12345, name: "mock-key", public_key: "ssh-rsa mock" } };
|
|
49
|
+
}
|
|
50
|
+
return new Proxy({}, {
|
|
51
|
+
get(target, prop) {
|
|
52
|
+
if (prop === "then")
|
|
53
|
+
return undefined;
|
|
54
|
+
if (prop === "id")
|
|
55
|
+
return 123456;
|
|
56
|
+
if (prop === "name")
|
|
57
|
+
return "mock-do-name";
|
|
58
|
+
if (prop === "status")
|
|
59
|
+
return "active";
|
|
60
|
+
if (prop.endsWith("s"))
|
|
61
|
+
return [];
|
|
62
|
+
return `mock-do-${prop.toLowerCase()}`;
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
async request(fn) {
|
|
67
|
+
return withRetry(fn, {
|
|
68
|
+
retryable: (err) => {
|
|
69
|
+
const match = err.message.match(/: (\d+)/);
|
|
70
|
+
const status = match ? parseInt(match[1], 10) : null;
|
|
71
|
+
return status === 429 || (status && status >= 500) || err.message.includes("ETIMEDOUT");
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
}
|
|
15
75
|
async get(path) {
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
76
|
+
const context = resourceContextStorage.getStore();
|
|
77
|
+
const abortSignal = context?.abortSignal;
|
|
78
|
+
if (Config.isOfflineMode() || Config.isGlobalDryRun()) {
|
|
79
|
+
return Promise.resolve(this.createDoOfflineMock('GET', path));
|
|
80
|
+
}
|
|
81
|
+
return this.request(async () => {
|
|
82
|
+
const res = await fetch(`${DoApiClient.BASE}${path}`, {
|
|
83
|
+
headers: this.authHeaders,
|
|
84
|
+
...(abortSignal && { signal: abortSignal })
|
|
85
|
+
});
|
|
86
|
+
if (!res.ok)
|
|
87
|
+
throw new Error(`DO API GET ${path}: ${res.status} ${await res.text()}`);
|
|
88
|
+
return res.json();
|
|
89
|
+
});
|
|
20
90
|
}
|
|
21
91
|
async post(path, body) {
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
92
|
+
const context = resourceContextStorage.getStore();
|
|
93
|
+
const abortSignal = context?.abortSignal;
|
|
94
|
+
if (Config.isOfflineMode() || Config.isGlobalDryRun()) {
|
|
95
|
+
return Promise.resolve(this.createDoOfflineMock('POST', path, body));
|
|
96
|
+
}
|
|
97
|
+
return this.request(async () => {
|
|
98
|
+
const res = await fetch(`${DoApiClient.BASE}${path}`, {
|
|
99
|
+
method: 'POST',
|
|
100
|
+
headers: this.authHeaders,
|
|
101
|
+
body: JSON.stringify(body),
|
|
102
|
+
...(abortSignal && { signal: abortSignal })
|
|
103
|
+
});
|
|
104
|
+
if (!res.ok)
|
|
105
|
+
throw new Error(`DO API POST ${path}: ${res.status} ${await res.text()}`);
|
|
106
|
+
return res.json();
|
|
26
107
|
});
|
|
27
|
-
if (!res.ok)
|
|
28
|
-
throw new Error(`DO API POST ${path}: ${res.status} ${await res.text()}`);
|
|
29
|
-
return res.json();
|
|
30
108
|
}
|
|
31
109
|
async put(path, body) {
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
110
|
+
const context = resourceContextStorage.getStore();
|
|
111
|
+
const abortSignal = context?.abortSignal;
|
|
112
|
+
if (Config.isOfflineMode() || Config.isGlobalDryRun()) {
|
|
113
|
+
return Promise.resolve(this.createDoOfflineMock('PUT', path, body));
|
|
114
|
+
}
|
|
115
|
+
return this.request(async () => {
|
|
116
|
+
const res = await fetch(`${DoApiClient.BASE}${path}`, {
|
|
117
|
+
method: 'PUT',
|
|
118
|
+
headers: this.authHeaders,
|
|
119
|
+
body: JSON.stringify(body),
|
|
120
|
+
...(abortSignal && { signal: abortSignal })
|
|
121
|
+
});
|
|
122
|
+
if (!res.ok)
|
|
123
|
+
throw new Error(`DO API PUT ${path}: ${res.status} ${await res.text()}`);
|
|
124
|
+
return res.json();
|
|
36
125
|
});
|
|
37
|
-
if (!res.ok)
|
|
38
|
-
throw new Error(`DO API PUT ${path}: ${res.status} ${await res.text()}`);
|
|
39
|
-
return res.json();
|
|
40
126
|
}
|
|
41
127
|
async delete(path, body) {
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
});
|
|
47
|
-
if (!res.ok && res.status !== 404) {
|
|
48
|
-
throw new Error(`DO API DELETE ${path}: ${res.status} ${await res.text()}`);
|
|
128
|
+
const context = resourceContextStorage.getStore();
|
|
129
|
+
const abortSignal = context?.abortSignal;
|
|
130
|
+
if (Config.isOfflineMode() || Config.isGlobalDryRun()) {
|
|
131
|
+
return Promise.resolve();
|
|
49
132
|
}
|
|
133
|
+
return this.request(async () => {
|
|
134
|
+
const res = await fetch(`${DoApiClient.BASE}${path}`, {
|
|
135
|
+
method: 'DELETE',
|
|
136
|
+
headers: this.authHeaders,
|
|
137
|
+
...(body !== undefined && { body: JSON.stringify(body) }),
|
|
138
|
+
...(abortSignal && { signal: abortSignal })
|
|
139
|
+
});
|
|
140
|
+
if (!res.ok && res.status !== 404) {
|
|
141
|
+
throw new Error(`DO API DELETE ${path}: ${res.status} ${await res.text()}`);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
50
144
|
}
|
|
51
145
|
}
|
|
52
146
|
export function getDoApi() {
|
|
53
147
|
const token = Config.get().providers.do?.token;
|
|
54
|
-
if (!token)
|
|
148
|
+
if (!token) {
|
|
149
|
+
if (Config.isOfflineMode() || Config.isGlobalDryRun()) {
|
|
150
|
+
return new DoApiClient("mock-do-token");
|
|
151
|
+
}
|
|
55
152
|
throw new Error('DO token not configured. Call DO.init({ token: "..." })');
|
|
153
|
+
}
|
|
56
154
|
return new DoApiClient(token);
|
|
57
155
|
}
|
|
@@ -10,6 +10,7 @@ import { LoadBalancerBuilder } from './load_balancer.js';
|
|
|
10
10
|
import { getDoApi } from './api.js';
|
|
11
11
|
import { checkPort, runProvisioner } from '../../core/provisioner.js';
|
|
12
12
|
import { getFileHash } from '../proxmox/hash.js';
|
|
13
|
+
import { resourceContextStorage } from '../../core/context.js';
|
|
13
14
|
export class DropletBuilder extends BaseBuilder {
|
|
14
15
|
out = {
|
|
15
16
|
ip: new Output(),
|
|
@@ -250,6 +251,19 @@ export class DropletBuilder extends BaseBuilder {
|
|
|
250
251
|
console.log(`✅ ${this.name} is up to date.`);
|
|
251
252
|
}
|
|
252
253
|
}
|
|
254
|
+
const context = resourceContextStorage.getStore();
|
|
255
|
+
if (context && context.hosts) {
|
|
256
|
+
const activeIp = this.resolvedIp ?? "0.0.0.0";
|
|
257
|
+
if (!context.hosts.some(h => h.name === this.name)) {
|
|
258
|
+
context.hosts.push({
|
|
259
|
+
name: this.name,
|
|
260
|
+
ip: activeIp,
|
|
261
|
+
user: "root",
|
|
262
|
+
sshKey: this.sshKeyPath,
|
|
263
|
+
provider: "do"
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
}
|
|
253
267
|
for (const sidecar of this.sidecars)
|
|
254
268
|
await sidecar.deploy();
|
|
255
269
|
return this.config;
|
|
@@ -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
|
function resolveFirebaseConfig() {
|
|
7
|
+
const isOffline = Config.isOfflineMode() || Config.isGlobalDryRun();
|
|
5
8
|
const cfg = Config.get().providers.firebase;
|
|
6
9
|
if (cfg?.serviceAccountPath)
|
|
7
10
|
return cfg;
|
|
@@ -11,12 +14,18 @@ function resolveFirebaseConfig() {
|
|
|
11
14
|
const sa = JSON.parse(fs.readFileSync(saPath, 'utf8'));
|
|
12
15
|
return { projectId: sa.project_id, serviceAccountPath: saPath };
|
|
13
16
|
}
|
|
17
|
+
if (isOffline) {
|
|
18
|
+
return { projectId: "mock-firebase-project", serviceAccountPath: "/mock/sa.json" };
|
|
19
|
+
}
|
|
14
20
|
throw new Error('Firebase not configured. Set FIREBASE_SA=/path/to/sa.json or use @Deploy({ firebase: "..." })');
|
|
15
21
|
}
|
|
16
22
|
export function getProjectId() {
|
|
17
23
|
return resolveFirebaseConfig().projectId;
|
|
18
24
|
}
|
|
19
25
|
export async function getFirebaseToken(scopes) {
|
|
26
|
+
if (Config.isOfflineMode() || Config.isGlobalDryRun()) {
|
|
27
|
+
return "mock-firebase-token";
|
|
28
|
+
}
|
|
20
29
|
const { serviceAccountPath } = resolveFirebaseConfig();
|
|
21
30
|
const auth = new GoogleAuth({ keyFile: serviceAccountPath, scopes });
|
|
22
31
|
const client = await auth.getClient();
|
|
@@ -25,38 +34,92 @@ export async function getFirebaseToken(scopes) {
|
|
|
25
34
|
}
|
|
26
35
|
const HOSTING_SCOPE = 'https://www.googleapis.com/auth/firebase.hosting';
|
|
27
36
|
const CLOUD_SCOPE = 'https://www.googleapis.com/auth/cloud-platform';
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
37
|
+
function createFirebaseOfflineMock(path, opts) {
|
|
38
|
+
if (path.includes("/versions")) {
|
|
39
|
+
return { name: `${path}/versions/mock-version-id`, status: "FINALIZED" };
|
|
40
|
+
}
|
|
41
|
+
if (path.includes("/releases")) {
|
|
42
|
+
return { name: `${path}/releases/mock-release-id` };
|
|
43
|
+
}
|
|
44
|
+
if (path.includes("/sites/")) {
|
|
45
|
+
return { name: "mock-site-name", defaultUrl: "https://mock-project.web.app" };
|
|
46
|
+
}
|
|
47
|
+
return new Proxy({}, {
|
|
48
|
+
get(target, prop) {
|
|
49
|
+
if (prop === "then")
|
|
50
|
+
return undefined;
|
|
51
|
+
if (prop === "name")
|
|
52
|
+
return "mock-firebase-name";
|
|
53
|
+
if (prop === "status")
|
|
54
|
+
return "FINALIZED";
|
|
55
|
+
if (prop === "id")
|
|
56
|
+
return "mock-firebase-id";
|
|
57
|
+
if (prop.endsWith("s"))
|
|
58
|
+
return [];
|
|
59
|
+
return `mock-fb-${prop.toLowerCase()}`;
|
|
60
|
+
}
|
|
38
61
|
});
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
62
|
+
}
|
|
63
|
+
export async function hostingFetch(path, opts = {}) {
|
|
64
|
+
const context = resourceContextStorage.getStore();
|
|
65
|
+
const abortSignal = context?.abortSignal;
|
|
66
|
+
if (Config.isOfflineMode() || Config.isGlobalDryRun()) {
|
|
67
|
+
return Promise.resolve(createFirebaseOfflineMock(path, opts));
|
|
42
68
|
}
|
|
43
|
-
const
|
|
44
|
-
return
|
|
69
|
+
const fetchOpts = abortSignal ? { ...opts, signal: abortSignal } : opts;
|
|
70
|
+
return withRetry(async () => {
|
|
71
|
+
const token = await getFirebaseToken([HOSTING_SCOPE]);
|
|
72
|
+
const base = 'https://firebasehosting.googleapis.com/v1beta1';
|
|
73
|
+
const res = await fetch(`${base}${path}`, {
|
|
74
|
+
...fetchOpts,
|
|
75
|
+
headers: {
|
|
76
|
+
'Authorization': `Bearer ${token}`,
|
|
77
|
+
'Content-Type': 'application/json',
|
|
78
|
+
...(fetchOpts.headers ?? {}),
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
if (!res.ok) {
|
|
82
|
+
const body = await res.text();
|
|
83
|
+
throw new Error(`Firebase Hosting API ${fetchOpts.method ?? 'GET'} ${path} → ${res.status}: ${body}`);
|
|
84
|
+
}
|
|
85
|
+
const text = await res.text();
|
|
86
|
+
return text ? JSON.parse(text) : null;
|
|
87
|
+
}, {
|
|
88
|
+
retryable: (err) => {
|
|
89
|
+
const match = err.message.match(/→ (\d+):/);
|
|
90
|
+
const status = match ? parseInt(match[1], 10) : null;
|
|
91
|
+
return status === 429 || (status && status >= 500) || err.message.includes("ETIMEDOUT");
|
|
92
|
+
}
|
|
93
|
+
});
|
|
45
94
|
}
|
|
46
95
|
export async function cloudFetch(base, path, opts = {}) {
|
|
47
|
-
const
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
'Authorization': `Bearer ${token}`,
|
|
52
|
-
'Content-Type': 'application/json',
|
|
53
|
-
...(opts.headers ?? {}),
|
|
54
|
-
},
|
|
55
|
-
});
|
|
56
|
-
if (!res.ok) {
|
|
57
|
-
const body = await res.text();
|
|
58
|
-
throw new Error(`GCP API ${opts.method ?? 'GET'} ${path} → ${res.status}: ${body}`);
|
|
96
|
+
const context = resourceContextStorage.getStore();
|
|
97
|
+
const abortSignal = context?.abortSignal;
|
|
98
|
+
if (Config.isOfflineMode() || Config.isGlobalDryRun()) {
|
|
99
|
+
return Promise.resolve(createFirebaseOfflineMock(path, opts));
|
|
59
100
|
}
|
|
60
|
-
const
|
|
61
|
-
return
|
|
101
|
+
const fetchOpts = abortSignal ? { ...opts, signal: abortSignal } : opts;
|
|
102
|
+
return withRetry(async () => {
|
|
103
|
+
const token = await getFirebaseToken([CLOUD_SCOPE]);
|
|
104
|
+
const res = await fetch(`${base}${path}`, {
|
|
105
|
+
...fetchOpts,
|
|
106
|
+
headers: {
|
|
107
|
+
'Authorization': `Bearer ${token}`,
|
|
108
|
+
'Content-Type': 'application/json',
|
|
109
|
+
...(fetchOpts.headers ?? {}),
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
if (!res.ok) {
|
|
113
|
+
const body = await res.text();
|
|
114
|
+
throw new Error(`GCP API ${fetchOpts.method ?? 'GET'} ${path} → ${res.status}: ${body}`);
|
|
115
|
+
}
|
|
116
|
+
const text = await res.text();
|
|
117
|
+
return text ? JSON.parse(text) : null;
|
|
118
|
+
}, {
|
|
119
|
+
retryable: (err) => {
|
|
120
|
+
const match = err.message.match(/→ (\d+):/);
|
|
121
|
+
const status = match ? parseInt(match[1], 10) : null;
|
|
122
|
+
return status === 429 || (status && status >= 500) || err.message.includes("ETIMEDOUT");
|
|
123
|
+
}
|
|
124
|
+
});
|
|
62
125
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { getProjectId, hostingFetch, cloudFetch } from "./api.js";
|
|
2
|
+
export async function listFirebaseResources() {
|
|
3
|
+
const project = getProjectId();
|
|
4
|
+
const [hostRes, fnRes] = await Promise.all([
|
|
5
|
+
hostingFetch(`/projects/${project}/sites`).catch(() => ({})),
|
|
6
|
+
cloudFetch("https://cloudfunctions.googleapis.com/v2", `/projects/${project}/locations/-/functions`).catch(() => ({})),
|
|
7
|
+
]);
|
|
8
|
+
// 1. Map Hosting Sites
|
|
9
|
+
const hostingSites = (hostRes.sites ?? []).map((s) => ({
|
|
10
|
+
site: s.name.split("/").pop() ?? "unknown",
|
|
11
|
+
}));
|
|
12
|
+
// 2. Map Cloud Functions
|
|
13
|
+
const functions = (fnRes.functions ?? []).map((f) => {
|
|
14
|
+
const parts = f.name.split("/");
|
|
15
|
+
const name = parts.pop() ?? "unknown";
|
|
16
|
+
const region = parts[parts.indexOf("locations") + 1] ?? "unknown";
|
|
17
|
+
return {
|
|
18
|
+
name,
|
|
19
|
+
region,
|
|
20
|
+
entryPoint: f.buildConfig?.entryPoint ?? "unknown",
|
|
21
|
+
runtime: f.buildConfig?.runtime ?? "unknown",
|
|
22
|
+
};
|
|
23
|
+
});
|
|
24
|
+
return { hostingSites, functions };
|
|
25
|
+
}
|