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.
- package/dist/core/checker.js +71 -0
- package/dist/core/config.d.ts +4 -0
- package/dist/core/config.js +11 -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/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 +7 -0
- package/dist/core/resource.js +10 -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 +308 -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/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 +1 -1
- 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 +2 -0
- package/dist/providers/proxmox/index.js +2 -0
- package/dist/providers/proxmox/template.d.ts +44 -0
- package/dist/providers/proxmox/template.js +349 -0
- package/dist/providers/proxmox/template.test.d.ts +1 -0
- package/dist/providers/proxmox/template.test.js +179 -0
- package/dist/providers/proxmox/vm.d.ts +3 -0
- package/dist/providers/proxmox/vm.js +40 -9
- package/dist/types/inventory.d.ts +44 -1
- package/package.json +1 -1
package/dist/core/checker.js
CHANGED
|
@@ -123,6 +123,53 @@ function renderAws(inv) {
|
|
|
123
123
|
]);
|
|
124
124
|
}
|
|
125
125
|
}
|
|
126
|
+
function renderGcp(inv) {
|
|
127
|
+
if (inv.vms.length > 0) {
|
|
128
|
+
printSection(`GCP Compute VMs · ${inv.vms.length}`, inv.vms, [
|
|
129
|
+
{ header: "Name", width: 24, render: (v) => v.name },
|
|
130
|
+
{ header: "Zone", width: 15, render: (v) => v.zone },
|
|
131
|
+
{ header: "Machine Type", width: 14, render: (v) => v.machineType },
|
|
132
|
+
{ header: "Status", width: 8, render: (v) => v.status },
|
|
133
|
+
{ header: "IP", width: 15, render: (v) => v.ip },
|
|
134
|
+
]);
|
|
135
|
+
}
|
|
136
|
+
if (inv.rdsInstances.length > 0) {
|
|
137
|
+
printSection(`GCP Cloud SQL · ${inv.rdsInstances.length}`, inv.rdsInstances, [
|
|
138
|
+
{ header: "Name", width: 24, render: (i) => i.name },
|
|
139
|
+
{ header: "Engine", width: 18, render: (i) => i.engine },
|
|
140
|
+
{ header: "Tier", width: 12, render: (i) => i.tier },
|
|
141
|
+
{ header: "Status", width: 10, render: (i) => i.status },
|
|
142
|
+
]);
|
|
143
|
+
}
|
|
144
|
+
if (inv.distributions.length > 0) {
|
|
145
|
+
printSection(`GCP Cloud Run · ${inv.distributions.length}`, inv.distributions, [
|
|
146
|
+
{ header: "Service", width: 24, render: (s) => s.name },
|
|
147
|
+
{ header: "Region", width: 12, render: (s) => s.region },
|
|
148
|
+
{ header: "URL", width: 42, render: (s) => s.url },
|
|
149
|
+
]);
|
|
150
|
+
}
|
|
151
|
+
if (inv.hostedZones.length > 0) {
|
|
152
|
+
printSection(`GCP Cloud DNS · ${inv.hostedZones.length}`, inv.hostedZones, [
|
|
153
|
+
{ header: "Zone", width: 24, render: (z) => z.name },
|
|
154
|
+
{ header: "DNS Name", width: 32, render: (z) => z.dnsName },
|
|
155
|
+
]);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
function renderFirebase(inv) {
|
|
159
|
+
if (inv.hostingSites.length > 0) {
|
|
160
|
+
printSection(`Firebase Hosting · ${inv.hostingSites.length}`, inv.hostingSites, [
|
|
161
|
+
{ header: "Site ID", width: 32, render: (s) => s.site },
|
|
162
|
+
]);
|
|
163
|
+
}
|
|
164
|
+
if (inv.functions.length > 0) {
|
|
165
|
+
printSection(`Firebase Functions · ${inv.functions.length}`, inv.functions, [
|
|
166
|
+
{ header: "Function", width: 24, render: (f) => f.name },
|
|
167
|
+
{ header: "Region", width: 12, render: (f) => f.region },
|
|
168
|
+
{ header: "Entry Point", width: 18, render: (f) => f.entryPoint },
|
|
169
|
+
{ header: "Runtime", width: 10, render: (f) => f.runtime },
|
|
170
|
+
]);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
126
173
|
// ─── Checker ──────────────────────────────────────────────────────────────────
|
|
127
174
|
export class Checker {
|
|
128
175
|
async check() {
|
|
@@ -161,6 +208,26 @@ export class Checker {
|
|
|
161
208
|
errors.push({ provider: "aws", message: err.message });
|
|
162
209
|
}));
|
|
163
210
|
}
|
|
211
|
+
if (cfg.providers.gcp?.serviceAccountPath || process.env.GCP_SA) {
|
|
212
|
+
tasks.push(import("../providers/gcp/list.js")
|
|
213
|
+
.then((m) => m.listGcpResources())
|
|
214
|
+
.then((inv) => {
|
|
215
|
+
result.gcp = inv;
|
|
216
|
+
})
|
|
217
|
+
.catch((err) => {
|
|
218
|
+
errors.push({ provider: "gcp", message: err.message });
|
|
219
|
+
}));
|
|
220
|
+
}
|
|
221
|
+
if (cfg.providers.firebase?.serviceAccountPath || process.env.FIREBASE_SA) {
|
|
222
|
+
tasks.push(import("../providers/firebase/list.js")
|
|
223
|
+
.then((m) => m.listFirebaseResources())
|
|
224
|
+
.then((inv) => {
|
|
225
|
+
result.firebase = inv;
|
|
226
|
+
})
|
|
227
|
+
.catch((err) => {
|
|
228
|
+
errors.push({ provider: "firebase", message: err.message });
|
|
229
|
+
}));
|
|
230
|
+
}
|
|
164
231
|
await Promise.all(tasks);
|
|
165
232
|
if (result.proxmox)
|
|
166
233
|
renderProxmox(result.proxmox);
|
|
@@ -168,6 +235,10 @@ export class Checker {
|
|
|
168
235
|
renderDo(result.do);
|
|
169
236
|
if (result.aws)
|
|
170
237
|
renderAws(result.aws);
|
|
238
|
+
if (result.gcp)
|
|
239
|
+
renderGcp(result.gcp);
|
|
240
|
+
if (result.firebase)
|
|
241
|
+
renderFirebase(result.firebase);
|
|
171
242
|
for (const e of errors) {
|
|
172
243
|
console.warn(`\n [!] ${e.provider}: ${e.message}`);
|
|
173
244
|
}
|
package/dist/core/config.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export interface GlobalConfig {
|
|
2
2
|
dryRun?: boolean;
|
|
3
|
+
parallel?: boolean;
|
|
4
|
+
offline?: boolean;
|
|
3
5
|
providers: {
|
|
4
6
|
do?: {
|
|
5
7
|
token: string;
|
|
@@ -37,6 +39,8 @@ declare class ConfigManager {
|
|
|
37
39
|
set(newConfig: Partial<GlobalConfig>): void;
|
|
38
40
|
get(): GlobalConfig;
|
|
39
41
|
isGlobalDryRun(): boolean;
|
|
42
|
+
isParallelActive(): boolean;
|
|
43
|
+
isOfflineMode(): boolean;
|
|
40
44
|
}
|
|
41
45
|
export declare const Config: ConfigManager;
|
|
42
46
|
export {};
|
package/dist/core/config.js
CHANGED
|
@@ -3,7 +3,11 @@ class ConfigManager {
|
|
|
3
3
|
providers: {},
|
|
4
4
|
};
|
|
5
5
|
set(newConfig) {
|
|
6
|
-
this.config = {
|
|
6
|
+
this.config = {
|
|
7
|
+
...this.config,
|
|
8
|
+
...newConfig,
|
|
9
|
+
providers: { ...this.config.providers, ...newConfig.providers },
|
|
10
|
+
};
|
|
7
11
|
}
|
|
8
12
|
get() {
|
|
9
13
|
return this.config;
|
|
@@ -11,5 +15,11 @@ class ConfigManager {
|
|
|
11
15
|
isGlobalDryRun() {
|
|
12
16
|
return this.config.dryRun ?? false;
|
|
13
17
|
}
|
|
18
|
+
isParallelActive() {
|
|
19
|
+
return this.config.parallel ?? false;
|
|
20
|
+
}
|
|
21
|
+
isOfflineMode() {
|
|
22
|
+
return this.config.offline ?? false;
|
|
23
|
+
}
|
|
14
24
|
}
|
|
15
25
|
export const Config = new ConfigManager();
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
export interface HostEntry {
|
|
3
|
+
name: string;
|
|
4
|
+
ip: string;
|
|
5
|
+
user: string;
|
|
6
|
+
sshKey?: string;
|
|
7
|
+
provider: string;
|
|
8
|
+
}
|
|
9
|
+
export interface ResourceContext {
|
|
10
|
+
abortSignal?: AbortSignal;
|
|
11
|
+
hosts?: HostEntry[];
|
|
12
|
+
stackName?: string;
|
|
13
|
+
}
|
|
14
|
+
export declare const resourceContextStorage: AsyncLocalStorage<ResourceContext>;
|
package/dist/core/decorators.js
CHANGED
|
@@ -5,27 +5,21 @@ import { Stack } from "./stack.js";
|
|
|
5
5
|
function applyConfig(opts) {
|
|
6
6
|
if (opts.dryRun !== undefined)
|
|
7
7
|
Config.set({ dryRun: opts.dryRun });
|
|
8
|
+
if (opts.parallel !== undefined)
|
|
9
|
+
Config.set({ parallel: opts.parallel });
|
|
10
|
+
if (opts.offline !== undefined)
|
|
11
|
+
Config.set({ offline: opts.offline });
|
|
8
12
|
if (opts.token)
|
|
9
|
-
Config.set({
|
|
10
|
-
providers: { ...Config.get().providers, do: { token: opts.token } },
|
|
11
|
-
});
|
|
13
|
+
Config.set({ providers: { do: { token: opts.token } } });
|
|
12
14
|
if (opts.region)
|
|
13
|
-
Config.set({
|
|
14
|
-
providers: { ...Config.get().providers, aws: { region: opts.region } },
|
|
15
|
-
});
|
|
15
|
+
Config.set({ providers: { aws: { region: opts.region } } });
|
|
16
16
|
if (opts.proxmox)
|
|
17
|
-
Config.set({
|
|
18
|
-
providers: { ...Config.get().providers, proxmox: opts.proxmox },
|
|
19
|
-
});
|
|
17
|
+
Config.set({ providers: { proxmox: opts.proxmox } });
|
|
20
18
|
if (opts.firebase) {
|
|
21
19
|
const sa = JSON.parse(readFileSync(opts.firebase, "utf8"));
|
|
22
20
|
Config.set({
|
|
23
21
|
providers: {
|
|
24
|
-
|
|
25
|
-
firebase: {
|
|
26
|
-
projectId: sa.project_id,
|
|
27
|
-
serviceAccountPath: opts.firebase,
|
|
28
|
-
},
|
|
22
|
+
firebase: { projectId: sa.project_id, serviceAccountPath: opts.firebase },
|
|
29
23
|
},
|
|
30
24
|
});
|
|
31
25
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
2
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
4
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
+
};
|
|
7
|
+
import { test, describe, beforeEach } from "node:test";
|
|
8
|
+
import assert from "node:assert";
|
|
9
|
+
import { Stack } from "./stack.js";
|
|
10
|
+
import { Deploy } from "./decorators.js";
|
|
11
|
+
import { BaseBuilder } from "./resource.js";
|
|
12
|
+
import { Config } from "./config.js";
|
|
13
|
+
import { Output } from "./output.js";
|
|
14
|
+
// Helper delay
|
|
15
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
16
|
+
class DelayResource extends BaseBuilder {
|
|
17
|
+
delayMs;
|
|
18
|
+
executionLogs;
|
|
19
|
+
out = {
|
|
20
|
+
val: new Output(),
|
|
21
|
+
};
|
|
22
|
+
constructor(name, delayMs, executionLogs) {
|
|
23
|
+
super(name);
|
|
24
|
+
this.delayMs = delayMs;
|
|
25
|
+
this.executionLogs = executionLogs;
|
|
26
|
+
}
|
|
27
|
+
async deploy() {
|
|
28
|
+
this.executionLogs.push(`start:${this.name}`);
|
|
29
|
+
await delay(this.delayMs);
|
|
30
|
+
this.executionLogs.push(`end:${this.name}`);
|
|
31
|
+
this.out.val.resolve(this.name);
|
|
32
|
+
return { name: this.name };
|
|
33
|
+
}
|
|
34
|
+
async destroy() {
|
|
35
|
+
this.executionLogs.push(`destroy:${this.name}`);
|
|
36
|
+
await delay(this.delayMs);
|
|
37
|
+
return { name: this.name, destroyed: true };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
class FailResource extends BaseBuilder {
|
|
41
|
+
async deploy() {
|
|
42
|
+
throw new Error("Failed to deploy!");
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
describe("Parallel Resource Deployment Unit Tests", () => {
|
|
46
|
+
let logs = [];
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
Config.set({
|
|
49
|
+
dryRun: false,
|
|
50
|
+
parallel: false,
|
|
51
|
+
providers: {},
|
|
52
|
+
});
|
|
53
|
+
logs = [];
|
|
54
|
+
});
|
|
55
|
+
test("runs sequential deployments by default (verifies base case)", async () => {
|
|
56
|
+
class SeqStack extends Stack {
|
|
57
|
+
r1 = new DelayResource("r1", 30, logs);
|
|
58
|
+
r2 = new DelayResource("r2", 30, logs);
|
|
59
|
+
}
|
|
60
|
+
const start = Date.now();
|
|
61
|
+
const stack = new SeqStack();
|
|
62
|
+
await stack.deploy();
|
|
63
|
+
const duration = Date.now() - start;
|
|
64
|
+
// In sequential, execution is r1 start -> r1 end -> r2 start -> r2 end
|
|
65
|
+
assert.deepStrictEqual(logs, [
|
|
66
|
+
"start:r1",
|
|
67
|
+
"end:r1",
|
|
68
|
+
"start:r2",
|
|
69
|
+
"end:r2"
|
|
70
|
+
]);
|
|
71
|
+
assert.ok(duration >= 60, `Sequential should take at least 60ms, took ${duration}ms`);
|
|
72
|
+
});
|
|
73
|
+
test("runs parallel deployments concurrently when opted in", async () => {
|
|
74
|
+
// Enable parallel globally for the stack run
|
|
75
|
+
Config.set({
|
|
76
|
+
dryRun: false,
|
|
77
|
+
parallel: true,
|
|
78
|
+
providers: {},
|
|
79
|
+
});
|
|
80
|
+
class ParStack extends Stack {
|
|
81
|
+
r1 = new DelayResource("r1", 40, logs);
|
|
82
|
+
r2 = new DelayResource("r2", 40, logs);
|
|
83
|
+
}
|
|
84
|
+
const start = Date.now();
|
|
85
|
+
const stack = new ParStack();
|
|
86
|
+
await stack.deploy();
|
|
87
|
+
const duration = Date.now() - start;
|
|
88
|
+
// In parallel, both start before either finishes:
|
|
89
|
+
// start:r1 and start:r2 will both be printed first.
|
|
90
|
+
assert.strictEqual(logs[0].startsWith("start:"), true);
|
|
91
|
+
assert.strictEqual(logs[1].startsWith("start:"), true);
|
|
92
|
+
assert.ok(logs.includes("end:r1"));
|
|
93
|
+
assert.ok(logs.includes("end:r2"));
|
|
94
|
+
// Total duration should be closer to 40ms than 80ms
|
|
95
|
+
assert.ok(duration < 75, `Parallel should take less than 75ms (sum), took ${duration}ms`);
|
|
96
|
+
});
|
|
97
|
+
test("respects explicit dependsOn() ordering in parallel mode", async () => {
|
|
98
|
+
Config.set({
|
|
99
|
+
dryRun: false,
|
|
100
|
+
parallel: true,
|
|
101
|
+
providers: {},
|
|
102
|
+
});
|
|
103
|
+
class DependencyStack extends Stack {
|
|
104
|
+
// r1 depends on r2
|
|
105
|
+
r1 = new DelayResource("r1", 20, logs);
|
|
106
|
+
r2 = new DelayResource("r2", 20, logs);
|
|
107
|
+
constructor() {
|
|
108
|
+
super();
|
|
109
|
+
this.r1.dependsOn(this.r2);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const stack = new DependencyStack();
|
|
113
|
+
await stack.deploy();
|
|
114
|
+
// Since r1 depends on r2, r2 must fully complete before r1 starts
|
|
115
|
+
const r2StartIndex = logs.indexOf("start:r2");
|
|
116
|
+
const r2EndIndex = logs.indexOf("end:r2");
|
|
117
|
+
const r1StartIndex = logs.indexOf("start:r1");
|
|
118
|
+
assert.ok(r2StartIndex < r2EndIndex);
|
|
119
|
+
assert.ok(r2EndIndex < r1StartIndex);
|
|
120
|
+
});
|
|
121
|
+
test("respects implicit Output waiting in parallel mode", async () => {
|
|
122
|
+
Config.set({
|
|
123
|
+
dryRun: false,
|
|
124
|
+
parallel: true,
|
|
125
|
+
providers: {},
|
|
126
|
+
});
|
|
127
|
+
class OutputAwaitingResource extends BaseBuilder {
|
|
128
|
+
dependentVal;
|
|
129
|
+
executionLogs;
|
|
130
|
+
constructor(name, dependentVal, executionLogs) {
|
|
131
|
+
super(name);
|
|
132
|
+
this.dependentVal = dependentVal;
|
|
133
|
+
this.executionLogs = executionLogs;
|
|
134
|
+
}
|
|
135
|
+
async deploy() {
|
|
136
|
+
this.executionLogs.push(`start:${this.name}`);
|
|
137
|
+
// Blocks on the Output of the other resource!
|
|
138
|
+
const val = await this.dependentVal.get();
|
|
139
|
+
this.executionLogs.push(`end:${this.name}:${val}`);
|
|
140
|
+
return { name: this.name };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
class OutputStack extends Stack {
|
|
144
|
+
r1 = new DelayResource("r1", 30, logs);
|
|
145
|
+
// r2 depends implicitly on r1's output
|
|
146
|
+
r2 = new OutputAwaitingResource("r2", this.r1.out.val, logs);
|
|
147
|
+
}
|
|
148
|
+
const stack = new OutputStack();
|
|
149
|
+
await stack.deploy();
|
|
150
|
+
// r2 starts in parallel, but it cannot end until r1 finishes and resolves the output
|
|
151
|
+
const r2StartIndex = logs.indexOf("start:r2");
|
|
152
|
+
const r1EndIndex = logs.indexOf("end:r1");
|
|
153
|
+
const r2EndIndex = logs.findIndex(line => line.startsWith("end:r2"));
|
|
154
|
+
assert.ok(r2StartIndex >= 0);
|
|
155
|
+
assert.ok(r1EndIndex >= 0);
|
|
156
|
+
assert.ok(r1EndIndex < r2EndIndex, `r1 end (${r1EndIndex}) must be before r2 end (${r2EndIndex})`);
|
|
157
|
+
assert.ok(logs.includes("end:r2:r1"));
|
|
158
|
+
});
|
|
159
|
+
test("runs parallel teardowns in reverse topological order on @Destroy", async () => {
|
|
160
|
+
Config.set({
|
|
161
|
+
dryRun: false,
|
|
162
|
+
parallel: true,
|
|
163
|
+
providers: {},
|
|
164
|
+
});
|
|
165
|
+
class TeardownStack extends Stack {
|
|
166
|
+
r1 = new DelayResource("r1", 20, logs);
|
|
167
|
+
r2 = new DelayResource("r2", 20, logs);
|
|
168
|
+
constructor() {
|
|
169
|
+
super();
|
|
170
|
+
// r2 depends on r1 during deploy (so r1 deploys first).
|
|
171
|
+
// Teardown should destroy r2 first, then r1!
|
|
172
|
+
this.r2.dependsOn(this.r1);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const stack = new TeardownStack();
|
|
176
|
+
await stack.destroy();
|
|
177
|
+
const r2DestroyIndex = logs.indexOf("destroy:r2");
|
|
178
|
+
const r1DestroyIndex = logs.indexOf("destroy:r1");
|
|
179
|
+
// r2 depends on r1, so r2 must be fully destroyed BEFORE r1 begins destruction
|
|
180
|
+
assert.ok(r2DestroyIndex < r1DestroyIndex, `r2 destroy (${r2DestroyIndex}) must be before r1 destroy (${r1DestroyIndex})`);
|
|
181
|
+
});
|
|
182
|
+
test("halts execution of dependent resources if a dependency fails", async () => {
|
|
183
|
+
Config.set({
|
|
184
|
+
dryRun: false,
|
|
185
|
+
parallel: true,
|
|
186
|
+
providers: {},
|
|
187
|
+
});
|
|
188
|
+
class FailStack extends Stack {
|
|
189
|
+
r1 = new FailResource("r1");
|
|
190
|
+
r2 = new DelayResource("r2", 30, logs);
|
|
191
|
+
constructor() {
|
|
192
|
+
super();
|
|
193
|
+
this.r2.dependsOn(this.r1);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const stack = new FailStack();
|
|
197
|
+
await assert.rejects(async () => {
|
|
198
|
+
await stack.deploy();
|
|
199
|
+
}, /Failed to deploy!/);
|
|
200
|
+
// r2 should never have started because its dependency r1 failed!
|
|
201
|
+
assert.strictEqual(logs.includes("start:r2"), false);
|
|
202
|
+
});
|
|
203
|
+
test("decorator option propagation sets configuration values", async () => {
|
|
204
|
+
// Clear parallel flag
|
|
205
|
+
Config.set({ parallel: false });
|
|
206
|
+
// We define a decorated simple stack
|
|
207
|
+
let SimpleDecoStack = class SimpleDecoStack extends Stack {
|
|
208
|
+
};
|
|
209
|
+
SimpleDecoStack = __decorate([
|
|
210
|
+
Deploy({ parallel: true })
|
|
211
|
+
], SimpleDecoStack);
|
|
212
|
+
// Verify decorator correctly updated global configuration to true
|
|
213
|
+
assert.strictEqual(Config.isParallelActive(), true);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { test, describe, beforeEach } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import { Stack } from "./stack.js";
|
|
4
|
+
import { Config } from "./config.js";
|
|
5
|
+
import { Output } from "./output.js";
|
|
6
|
+
import { BaseBuilder } from "./resource.js";
|
|
7
|
+
import { Secret } from "./secret.js";
|
|
8
|
+
import { resourceContextStorage } from "./context.js";
|
|
9
|
+
import { runAnsible } from "./provisioner.js";
|
|
10
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
11
|
+
class DelayResource extends BaseBuilder {
|
|
12
|
+
delayMs;
|
|
13
|
+
executionLogs;
|
|
14
|
+
out = { val: new Output() };
|
|
15
|
+
constructor(name, delayMs, executionLogs) {
|
|
16
|
+
super(name);
|
|
17
|
+
this.delayMs = delayMs;
|
|
18
|
+
this.executionLogs = executionLogs;
|
|
19
|
+
}
|
|
20
|
+
async deploy() {
|
|
21
|
+
this.executionLogs.push(`start:${this.name}`);
|
|
22
|
+
await delay(this.delayMs);
|
|
23
|
+
this.executionLogs.push(`end:${this.name}`);
|
|
24
|
+
return { name: this.name };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
class FailResource extends BaseBuilder {
|
|
28
|
+
async deploy() {
|
|
29
|
+
throw new Error("Failure in deploy!");
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
class AbortResource extends BaseBuilder {
|
|
33
|
+
signalLogs;
|
|
34
|
+
constructor(name, signalLogs) {
|
|
35
|
+
super(name);
|
|
36
|
+
this.signalLogs = signalLogs;
|
|
37
|
+
}
|
|
38
|
+
async deploy() {
|
|
39
|
+
const signal = resourceContextStorage.getStore()?.abortSignal;
|
|
40
|
+
if (signal) {
|
|
41
|
+
if (signal.aborted) {
|
|
42
|
+
this.signalLogs.push(`aborted-before:${this.name}`);
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
signal.addEventListener("abort", () => {
|
|
46
|
+
this.signalLogs.push(`aborted-during:${this.name}`);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
await delay(50);
|
|
51
|
+
return { name: this.name };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
describe("Production Features Unit Tests", () => {
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
Config.set({
|
|
57
|
+
dryRun: false,
|
|
58
|
+
parallel: false,
|
|
59
|
+
offline: false,
|
|
60
|
+
providers: {}
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
test("Secret Redaction in console.log during Stack execution", async () => {
|
|
64
|
+
// Register secret
|
|
65
|
+
const mySecret = new Secret("my-api-key", async () => "super-secret-12345");
|
|
66
|
+
await mySecret.get(); // resolves and registers it
|
|
67
|
+
const logs = [];
|
|
68
|
+
const originalLog = console.log;
|
|
69
|
+
// Create a stack that logs the secret
|
|
70
|
+
class SecretStack extends Stack {
|
|
71
|
+
r1 = new DelayResource("r1", 10, []);
|
|
72
|
+
async beforeDeploy() {
|
|
73
|
+
console.log("Starting deployment with my-api-key super-secret-12345");
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const stack = new SecretStack();
|
|
77
|
+
// Intercept console.log temporarily just to capture it
|
|
78
|
+
console.log = (...args) => {
|
|
79
|
+
logs.push(args.join(" "));
|
|
80
|
+
originalLog(...args);
|
|
81
|
+
};
|
|
82
|
+
try {
|
|
83
|
+
await stack.deploy();
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
console.log = originalLog;
|
|
87
|
+
}
|
|
88
|
+
const deploymentLog = logs.join("\n");
|
|
89
|
+
assert.ok(!deploymentLog.includes("super-secret-12345"), "Should NOT contain raw secret");
|
|
90
|
+
assert.ok(deploymentLog.includes("my-api-key ********"), "Should contain redacted secret replacement");
|
|
91
|
+
});
|
|
92
|
+
test("Secret Redaction in formatEntry table matching patterns", async () => {
|
|
93
|
+
class CredentialsStack extends Stack {
|
|
94
|
+
creds_resource = new class extends BaseBuilder {
|
|
95
|
+
async deploy() {
|
|
96
|
+
return {
|
|
97
|
+
password: "my-cleartext-password",
|
|
98
|
+
api_token: "my-cleartext-token",
|
|
99
|
+
safe_field: "public-info"
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
}("creds");
|
|
103
|
+
db_password = new class extends BaseBuilder {
|
|
104
|
+
async deploy() {
|
|
105
|
+
return "my-cleartext-db-pass";
|
|
106
|
+
}
|
|
107
|
+
}("db-pass");
|
|
108
|
+
}
|
|
109
|
+
const logs = [];
|
|
110
|
+
const originalLog = console.log;
|
|
111
|
+
console.log = (...args) => {
|
|
112
|
+
logs.push(args.join(" "));
|
|
113
|
+
originalLog(...args);
|
|
114
|
+
};
|
|
115
|
+
try {
|
|
116
|
+
await new CredentialsStack().deploy();
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
console.log = originalLog;
|
|
120
|
+
}
|
|
121
|
+
const tableOutput = logs.join("\n");
|
|
122
|
+
assert.ok(!tableOutput.includes("my-cleartext-password"), "Should redact password key values in table");
|
|
123
|
+
assert.ok(!tableOutput.includes("my-cleartext-token"), "Should redact token key values in table");
|
|
124
|
+
assert.ok(!tableOutput.includes("my-cleartext-db-pass"), "Should redact sensitive parent key values in table");
|
|
125
|
+
assert.ok(tableOutput.includes("********"), "Should replace with asterisks");
|
|
126
|
+
assert.ok(tableOutput.includes("public-info"), "Should preserve non-sensitive fields");
|
|
127
|
+
});
|
|
128
|
+
test("Parallel Scheduler Cancellation (Fail-Fast)", async () => {
|
|
129
|
+
Config.set({ parallel: true });
|
|
130
|
+
const signalLogs = [];
|
|
131
|
+
class CancelStack extends Stack {
|
|
132
|
+
r1 = new AbortResource("r1", signalLogs);
|
|
133
|
+
r2 = new FailResource("r2");
|
|
134
|
+
}
|
|
135
|
+
await assert.rejects(async () => {
|
|
136
|
+
await new CancelStack().deploy();
|
|
137
|
+
}, /Failure in deploy!/);
|
|
138
|
+
// Wait a bit for abort listener
|
|
139
|
+
await delay(30);
|
|
140
|
+
assert.ok(signalLogs.includes("aborted-during:r1") || signalLogs.includes("aborted-before:r1"), "Aborted resource should have abort signal triggered");
|
|
141
|
+
});
|
|
142
|
+
test("Credential-Free Offline Dry-Run Mode", async () => {
|
|
143
|
+
Config.set({ offline: true, dryRun: true });
|
|
144
|
+
class OfflineStack extends Stack {
|
|
145
|
+
aws_vm = new class extends BaseBuilder {
|
|
146
|
+
async deploy() {
|
|
147
|
+
const { getEC2Client } = await import("../providers/aws/api.js");
|
|
148
|
+
const client = getEC2Client();
|
|
149
|
+
const res = await client.send({ constructor: { name: "RunInstancesCommand" } });
|
|
150
|
+
return res;
|
|
151
|
+
}
|
|
152
|
+
}("aws");
|
|
153
|
+
gcp_vm = new class extends BaseBuilder {
|
|
154
|
+
async deploy() {
|
|
155
|
+
const { gcpFetch } = await import("../providers/gcp/api.js");
|
|
156
|
+
const res = await gcpFetch("compute", "/instances");
|
|
157
|
+
return res;
|
|
158
|
+
}
|
|
159
|
+
}("gcp");
|
|
160
|
+
}
|
|
161
|
+
const outputs = await new OfflineStack().deploy();
|
|
162
|
+
assert.ok(outputs.aws_vm.Instances[0].InstanceId.startsWith("i-mock"), "AWS VM should resolve to mock");
|
|
163
|
+
assert.ok(outputs.gcp_vm.id.startsWith("mock-gcp-instance"), "GCP VM should resolve to mock");
|
|
164
|
+
});
|
|
165
|
+
test("Ansible Provisioner Stack-Wide Dynamic Inventory Generation", async () => {
|
|
166
|
+
const context = {
|
|
167
|
+
stackName: "my-test-stack",
|
|
168
|
+
hosts: [
|
|
169
|
+
{ name: "web1", ip: "1.2.3.4", user: "root", sshKey: "/path/to/key", provider: "do" },
|
|
170
|
+
{ name: "db1", ip: "5.6.7.8", user: "ubuntu", sshKey: "/path/to/other-key", provider: "aws" }
|
|
171
|
+
]
|
|
172
|
+
};
|
|
173
|
+
await resourceContextStorage.run(context, async () => {
|
|
174
|
+
try {
|
|
175
|
+
await runAnsible("1.2.3.4", "root", "/path/to/key", "playbook.yml");
|
|
176
|
+
}
|
|
177
|
+
catch (err) {
|
|
178
|
+
// We expect it might fail if ansible-playbook binary is missing, but it should still attempt write!
|
|
179
|
+
assert.ok(err.message.includes("ansible-playbook") || err.message.includes("ENOENT"), "Should attempt run");
|
|
180
|
+
}
|
|
181
|
+
const fs = await import("node:fs");
|
|
182
|
+
const exists = fs.existsSync("/tmp/puls-inventory-my-test-stack.ini");
|
|
183
|
+
assert.ok(exists, "Dynamic inventory file should be generated in /tmp");
|
|
184
|
+
const contents = fs.readFileSync("/tmp/puls-inventory-my-test-stack.ini", "utf8");
|
|
185
|
+
assert.ok(contents.includes("web1 ansible_host=1.2.3.4"), "Should contain web1 host entry");
|
|
186
|
+
assert.ok(contents.includes("db1 ansible_host=5.6.7.8"), "Should contain db1 host entry");
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
});
|
package/dist/core/provisioner.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import net from "node:net";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
|
+
import { writeFileSync } from "node:fs";
|
|
5
|
+
import { resourceContextStorage } from "./context.js";
|
|
4
6
|
export function checkPort(ip, port) {
|
|
5
7
|
return new Promise((resolve) => {
|
|
6
8
|
const socket = new net.Socket();
|
|
@@ -31,18 +33,34 @@ function resolveSshKeyPath(sshKeys) {
|
|
|
31
33
|
export function runAnsible(ip, user, sshKeys, playbook) {
|
|
32
34
|
console.log(` 🔧 Running Ansible: ${playbook} → ${ip}`);
|
|
33
35
|
const keyPath = resolveSshKeyPath(sshKeys);
|
|
36
|
+
const context = resourceContextStorage.getStore();
|
|
37
|
+
const stackName = context?.stackName ?? "default";
|
|
38
|
+
const hosts = context?.hosts ?? [];
|
|
34
39
|
return new Promise((resolve, reject) => {
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
40
|
+
const args = [playbook];
|
|
41
|
+
if (hosts.length > 0) {
|
|
42
|
+
const inventoryPath = `/tmp/puls-inventory-${stackName}.ini`;
|
|
43
|
+
let inventoryContent = "[all]\n";
|
|
44
|
+
for (const h of hosts) {
|
|
45
|
+
const hKeyPath = resolveSshKeyPath(h.sshKey);
|
|
46
|
+
inventoryContent += `${h.name} ansible_host=${h.ip} ansible_user=${h.user} ansible_ssh_private_key_file=${hKeyPath}\n`;
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
writeFileSync(inventoryPath, inventoryContent, "utf8");
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
reject(new Error(`Failed to write Ansible inventory: ${err.message}`));
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const currentHost = hosts.find(h => h.ip === ip);
|
|
56
|
+
const hostLimit = currentHost ? currentHost.name : ip;
|
|
57
|
+
args.push("-i", inventoryPath, "--limit", hostLimit);
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
args.push("-i", `${ip},`, "-u", user, "--private-key", keyPath);
|
|
61
|
+
}
|
|
62
|
+
args.push("--ssh-extra-args", "-o StrictHostKeyChecking=no -o ConnectTimeout=30");
|
|
63
|
+
const proc = spawn("ansible-playbook", args, { stdio: "inherit" });
|
|
46
64
|
proc.on("close", (code) => {
|
|
47
65
|
if (code === 0) {
|
|
48
66
|
console.log(` ✅ Provisioning complete`);
|