puls-dev 0.2.7 → 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 +6 -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 +4 -0
- package/dist/core/decorators.js +56 -30
- package/dist/core/hooks.d.ts +21 -0
- package/dist/core/hooks.js +116 -0
- package/dist/core/hooks.test.d.ts +1 -0
- package/dist/core/hooks.test.js +194 -0
- package/dist/core/multiregion.test.d.ts +1 -0
- package/dist/core/multiregion.test.js +87 -0
- package/dist/core/output.d.ts +2 -0
- package/dist/core/output.js +9 -2
- package/dist/core/parallel.test.d.ts +1 -0
- package/dist/core/parallel.test.js +215 -0
- package/dist/core/parser.d.ts +10 -0
- package/dist/core/parser.js +140 -0
- package/dist/core/parser.test.d.ts +1 -0
- package/dist/core/parser.test.js +117 -0
- package/dist/core/production.test.d.ts +1 -0
- package/dist/core/production.test.js +189 -0
- package/dist/core/provisioner.d.ts +4 -0
- package/dist/core/provisioner.js +123 -0
- package/dist/core/resource.d.ts +23 -0
- package/dist/core/resource.js +54 -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 +41 -0
- package/dist/core/secret.js +105 -0
- package/dist/core/secret.test.d.ts +1 -0
- package/dist/core/secret.test.js +166 -0
- package/dist/core/stack.d.ts +4 -3
- package/dist/core/stack.js +322 -48
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/providers/aws/api.js +97 -17
- package/dist/providers/aws/ec2.d.ts +51 -0
- package/dist/providers/aws/ec2.js +331 -0
- package/dist/providers/aws/ec2.test.d.ts +1 -0
- package/dist/providers/aws/ec2.test.js +281 -0
- package/dist/providers/aws/index.d.ts +4 -0
- package/dist/providers/aws/index.js +4 -0
- package/dist/providers/aws/route53.d.ts +1 -0
- package/dist/providers/aws/route53.js +15 -2
- package/dist/providers/aws/route53.test.js +47 -0
- package/dist/providers/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 +3 -1
- package/dist/providers/do/api.js +126 -27
- package/dist/providers/do/app.d.ts +26 -0
- package/dist/providers/do/app.js +124 -0
- package/dist/providers/do/app.test.d.ts +1 -0
- package/dist/providers/do/app.test.js +268 -0
- package/dist/providers/do/database.d.ts +44 -0
- package/dist/providers/do/database.js +208 -0
- package/dist/providers/do/database.test.d.ts +1 -0
- package/dist/providers/do/database.test.js +293 -0
- package/dist/providers/do/domain.d.ts +2 -0
- package/dist/providers/do/domain.js +30 -0
- package/dist/providers/do/domain.test.js +49 -0
- package/dist/providers/do/droplet.d.ts +9 -0
- package/dist/providers/do/droplet.js +146 -8
- package/dist/providers/do/droplet.test.js +228 -1
- package/dist/providers/do/firewall.d.ts +2 -1
- package/dist/providers/do/firewall.js +23 -9
- package/dist/providers/do/firewall.test.js +54 -0
- package/dist/providers/do/index.d.ts +11 -0
- package/dist/providers/do/index.js +8 -0
- package/dist/providers/do/spaces.d.ts +27 -0
- package/dist/providers/do/spaces.js +142 -0
- package/dist/providers/do/spaces.test.d.ts +1 -0
- package/dist/providers/do/spaces.test.js +180 -0
- package/dist/providers/do/spaces_api.d.ts +2 -0
- package/dist/providers/do/spaces_api.js +20 -0
- package/dist/providers/do/vpc.d.ts +30 -0
- package/dist/providers/do/vpc.js +128 -0
- package/dist/providers/do/vpc.test.d.ts +1 -0
- package/dist/providers/do/vpc.test.js +258 -0
- package/dist/providers/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/clouddns.d.ts +1 -0
- package/dist/providers/gcp/clouddns.js +15 -2
- package/dist/providers/gcp/clouddns.test.js +45 -0
- package/dist/providers/gcp/index.d.ts +5 -1
- package/dist/providers/gcp/index.js +5 -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 +48 -0
- package/dist/providers/gcp/vm.js +375 -0
- package/dist/providers/gcp/vm.test.d.ts +1 -0
- package/dist/providers/gcp/vm.test.js +321 -0
- 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 +7 -4
- package/dist/providers/proxmox/vm.js +57 -102
- package/dist/providers/proxmox/vm.test.js +77 -0
- package/dist/types/inventory.d.ts +44 -1
- package/package.json +3 -1
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare function checkPort(ip: string, port: number): Promise<boolean>;
|
|
2
|
+
export declare function runAnsible(ip: string, user: string, sshKeys: string | string[] | undefined, playbook: string): Promise<void>;
|
|
3
|
+
export declare function runPuppet(ip: string, user: string, sshKeys: string | string[] | undefined, manifest: string): Promise<void>;
|
|
4
|
+
export declare function runProvisioner(ip: string, user: string, sshKeys: string | string[] | undefined, scriptPath: string): Promise<void>;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import net from "node:net";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { writeFileSync } from "node:fs";
|
|
5
|
+
import { resourceContextStorage } from "./context.js";
|
|
6
|
+
export function checkPort(ip, port) {
|
|
7
|
+
return new Promise((resolve) => {
|
|
8
|
+
const socket = new net.Socket();
|
|
9
|
+
socket.setTimeout(2000);
|
|
10
|
+
socket.on("connect", () => {
|
|
11
|
+
socket.destroy();
|
|
12
|
+
resolve(true);
|
|
13
|
+
});
|
|
14
|
+
socket.on("error", () => {
|
|
15
|
+
resolve(false);
|
|
16
|
+
});
|
|
17
|
+
socket.on("timeout", () => {
|
|
18
|
+
socket.destroy();
|
|
19
|
+
resolve(false);
|
|
20
|
+
});
|
|
21
|
+
socket.connect(port, ip);
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
function resolveSshKeyPath(sshKeys) {
|
|
25
|
+
const keyInput = Array.isArray(sshKeys) ? null : sshKeys;
|
|
26
|
+
return (keyInput &&
|
|
27
|
+
!keyInput.startsWith("ssh-") &&
|
|
28
|
+
!keyInput.startsWith("ecdsa-") &&
|
|
29
|
+
!keyInput.startsWith("sk-")
|
|
30
|
+
? keyInput.replace(/\.pub$/, "")
|
|
31
|
+
: `${homedir()}/.ssh/id_ed25519`).replace(/^~/, homedir());
|
|
32
|
+
}
|
|
33
|
+
export function runAnsible(ip, user, sshKeys, playbook) {
|
|
34
|
+
console.log(` 🔧 Running Ansible: ${playbook} → ${ip}`);
|
|
35
|
+
const keyPath = resolveSshKeyPath(sshKeys);
|
|
36
|
+
const context = resourceContextStorage.getStore();
|
|
37
|
+
const stackName = context?.stackName ?? "default";
|
|
38
|
+
const hosts = context?.hosts ?? [];
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
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" });
|
|
64
|
+
proc.on("close", (code) => {
|
|
65
|
+
if (code === 0) {
|
|
66
|
+
console.log(` ✅ Provisioning complete`);
|
|
67
|
+
resolve();
|
|
68
|
+
}
|
|
69
|
+
else
|
|
70
|
+
reject(new Error(`ansible-playbook exited with code ${code}`));
|
|
71
|
+
});
|
|
72
|
+
proc.on("error", (err) => reject(new Error(`Failed to run ansible-playbook: ${err.message}`)));
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
export function runPuppet(ip, user, sshKeys, manifest) {
|
|
76
|
+
console.log(` 🔧 Applying Puppet manifest: ${manifest} → ${ip}`);
|
|
77
|
+
const keyPath = resolveSshKeyPath(sshKeys);
|
|
78
|
+
// Copy manifest then apply it
|
|
79
|
+
return new Promise((resolve, reject) => {
|
|
80
|
+
const scp = spawn("scp", [
|
|
81
|
+
"-i",
|
|
82
|
+
keyPath,
|
|
83
|
+
"-o",
|
|
84
|
+
"StrictHostKeyChecking=no",
|
|
85
|
+
manifest,
|
|
86
|
+
`${user}@${ip}:/tmp/manifest.pp`,
|
|
87
|
+
], { stdio: "inherit" });
|
|
88
|
+
scp.on("close", (code) => {
|
|
89
|
+
if (code !== 0) {
|
|
90
|
+
reject(new Error(`scp exited with code ${code}`));
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const puppet = spawn("ssh", [
|
|
94
|
+
"-i",
|
|
95
|
+
keyPath,
|
|
96
|
+
"-o",
|
|
97
|
+
"StrictHostKeyChecking=no",
|
|
98
|
+
`${user}@${ip}`,
|
|
99
|
+
"puppet apply /tmp/manifest.pp",
|
|
100
|
+
], { stdio: "inherit" });
|
|
101
|
+
puppet.on("close", (c) => {
|
|
102
|
+
if (c === 0) {
|
|
103
|
+
console.log(` ✅ Provisioning complete`);
|
|
104
|
+
resolve();
|
|
105
|
+
}
|
|
106
|
+
else
|
|
107
|
+
reject(new Error(`puppet apply exited with code ${c}`));
|
|
108
|
+
});
|
|
109
|
+
puppet.on("error", (err) => reject(new Error(`Failed to run puppet: ${err.message}`)));
|
|
110
|
+
});
|
|
111
|
+
scp.on("error", (err) => reject(new Error(`Failed to run scp: ${err.message}`)));
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
export function runProvisioner(ip, user, sshKeys, scriptPath) {
|
|
115
|
+
const ext = scriptPath.split(".").pop()?.toLowerCase();
|
|
116
|
+
if (ext === "sh") {
|
|
117
|
+
throw new Error(`Shell script provisioning (.sh) is no longer supported. ` +
|
|
118
|
+
`Please migrate "${scriptPath}" to an Ansible playbook (.yaml/.yml).`);
|
|
119
|
+
}
|
|
120
|
+
if (ext === "pp")
|
|
121
|
+
return runPuppet(ip, user, sshKeys, scriptPath);
|
|
122
|
+
return runAnsible(ip, user, sshKeys, scriptPath); // .yml / .yaml
|
|
123
|
+
}
|
package/dist/core/resource.d.ts
CHANGED
|
@@ -4,9 +4,32 @@ export declare abstract class BaseBuilder {
|
|
|
4
4
|
protected localDryRun: boolean | null;
|
|
5
5
|
protected discoveryPromise: Promise<any>;
|
|
6
6
|
protected sidecars: BaseBuilder[];
|
|
7
|
+
/** @internal */
|
|
8
|
+
_deployPromise: Promise<any>;
|
|
9
|
+
/** @internal */
|
|
10
|
+
_destroyPromise?: Promise<any>;
|
|
11
|
+
/** @internal */
|
|
12
|
+
_dependencies: BaseBuilder[];
|
|
13
|
+
private _beforeDeployHooks;
|
|
14
|
+
private _afterDeployHooks;
|
|
15
|
+
private _beforeDestroyHooks;
|
|
16
|
+
private _afterDestroyHooks;
|
|
7
17
|
constructor(name: string);
|
|
18
|
+
dependsOn(resource: BaseBuilder): this;
|
|
8
19
|
protect(): this;
|
|
9
20
|
dryRun(enabled?: boolean): this;
|
|
21
|
+
beforeDeploy(callback: () => Promise<void> | void): this;
|
|
22
|
+
afterDeploy(callback: (result: any) => Promise<void> | void): this;
|
|
23
|
+
beforeDestroy(callback: () => Promise<void> | void): this;
|
|
24
|
+
afterDestroy(callback: (result: any) => Promise<void> | void): this;
|
|
25
|
+
/** @internal */
|
|
26
|
+
_runBeforeDeploy(): Promise<void>;
|
|
27
|
+
/** @internal */
|
|
28
|
+
_runAfterDeploy(result: any): Promise<void>;
|
|
29
|
+
/** @internal */
|
|
30
|
+
_runBeforeDestroy(): Promise<void>;
|
|
31
|
+
/** @internal */
|
|
32
|
+
_runAfterDestroy(result: any): Promise<void>;
|
|
10
33
|
protected isDryRunActive(): boolean;
|
|
11
34
|
protected checkProtection(hasChanges: boolean): Promise<boolean>;
|
|
12
35
|
protected waitFor(label: string, condition: () => Promise<boolean>, opts?: {
|
package/dist/core/resource.js
CHANGED
|
@@ -5,9 +5,23 @@ export class BaseBuilder {
|
|
|
5
5
|
localDryRun = null;
|
|
6
6
|
discoveryPromise;
|
|
7
7
|
sidecars = [];
|
|
8
|
+
/** @internal */
|
|
9
|
+
_deployPromise;
|
|
10
|
+
/** @internal */
|
|
11
|
+
_destroyPromise;
|
|
12
|
+
/** @internal */
|
|
13
|
+
_dependencies = [];
|
|
14
|
+
_beforeDeployHooks = [];
|
|
15
|
+
_afterDeployHooks = [];
|
|
16
|
+
_beforeDestroyHooks = [];
|
|
17
|
+
_afterDestroyHooks = [];
|
|
8
18
|
constructor(name) {
|
|
9
19
|
this.name = name;
|
|
10
20
|
}
|
|
21
|
+
dependsOn(resource) {
|
|
22
|
+
this._dependencies.push(resource);
|
|
23
|
+
return this;
|
|
24
|
+
}
|
|
11
25
|
protect() {
|
|
12
26
|
this.isProtected = true;
|
|
13
27
|
return this;
|
|
@@ -16,6 +30,46 @@ export class BaseBuilder {
|
|
|
16
30
|
this.localDryRun = enabled;
|
|
17
31
|
return this;
|
|
18
32
|
}
|
|
33
|
+
beforeDeploy(callback) {
|
|
34
|
+
this._beforeDeployHooks.push(callback);
|
|
35
|
+
return this;
|
|
36
|
+
}
|
|
37
|
+
afterDeploy(callback) {
|
|
38
|
+
this._afterDeployHooks.push(callback);
|
|
39
|
+
return this;
|
|
40
|
+
}
|
|
41
|
+
beforeDestroy(callback) {
|
|
42
|
+
this._beforeDestroyHooks.push(callback);
|
|
43
|
+
return this;
|
|
44
|
+
}
|
|
45
|
+
afterDestroy(callback) {
|
|
46
|
+
this._afterDestroyHooks.push(callback);
|
|
47
|
+
return this;
|
|
48
|
+
}
|
|
49
|
+
/** @internal */
|
|
50
|
+
async _runBeforeDeploy() {
|
|
51
|
+
for (const cb of this._beforeDeployHooks) {
|
|
52
|
+
await cb();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** @internal */
|
|
56
|
+
async _runAfterDeploy(result) {
|
|
57
|
+
for (const cb of this._afterDeployHooks) {
|
|
58
|
+
await cb(result);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** @internal */
|
|
62
|
+
async _runBeforeDestroy() {
|
|
63
|
+
for (const cb of this._beforeDestroyHooks) {
|
|
64
|
+
await cb();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** @internal */
|
|
68
|
+
async _runAfterDestroy(result) {
|
|
69
|
+
for (const cb of this._afterDestroyHooks) {
|
|
70
|
+
await cb(result);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
19
73
|
isDryRunActive() {
|
|
20
74
|
return this.localDryRun !== null
|
|
21
75
|
? this.localDryRun
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface RetryOptions {
|
|
2
|
+
maxAttempts?: number;
|
|
3
|
+
initialDelayMs?: number;
|
|
4
|
+
backoffFactor?: number;
|
|
5
|
+
jitter?: boolean;
|
|
6
|
+
retryable?: (error: any) => boolean;
|
|
7
|
+
delayFn?: (ms: number) => Promise<void>;
|
|
8
|
+
}
|
|
9
|
+
export declare function withRetry<T>(fn: () => Promise<T>, options?: RetryOptions): Promise<T>;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export async function withRetry(fn, options = {}) {
|
|
2
|
+
const maxAttempts = options.maxAttempts ?? 5;
|
|
3
|
+
const initialDelayMs = options.initialDelayMs ?? 1000;
|
|
4
|
+
const backoffFactor = options.backoffFactor ?? 2;
|
|
5
|
+
const useJitter = options.jitter ?? true;
|
|
6
|
+
const retryable = options.retryable ?? (() => true);
|
|
7
|
+
const delayFn = options.delayFn ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
8
|
+
let attempt = 0;
|
|
9
|
+
while (true) {
|
|
10
|
+
try {
|
|
11
|
+
return await fn();
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
attempt++;
|
|
15
|
+
if (attempt >= maxAttempts || !retryable(error)) {
|
|
16
|
+
throw error;
|
|
17
|
+
}
|
|
18
|
+
let delay = initialDelayMs * Math.pow(backoffFactor, attempt - 1);
|
|
19
|
+
if (useJitter) {
|
|
20
|
+
// Add random jitter offset of +/- 10%
|
|
21
|
+
const jitterAmount = (Math.random() * 0.2 - 0.1) * delay;
|
|
22
|
+
delay += jitterAmount;
|
|
23
|
+
}
|
|
24
|
+
console.warn(` ⚠️ Transient error encountered: ${error.message || error}. Retrying in ${Math.round(delay)}ms (attempt ${attempt}/${maxAttempts})...`);
|
|
25
|
+
await delayFn(delay);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { test, describe } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import { withRetry } from "./retry.js";
|
|
4
|
+
describe("Retry & Backoff Engine Unit Tests", () => {
|
|
5
|
+
test("resolves immediately if target function succeeds on the first try", async () => {
|
|
6
|
+
let calls = 0;
|
|
7
|
+
const res = await withRetry(async () => {
|
|
8
|
+
calls++;
|
|
9
|
+
return "success";
|
|
10
|
+
});
|
|
11
|
+
assert.strictEqual(res, "success");
|
|
12
|
+
assert.strictEqual(calls, 1);
|
|
13
|
+
});
|
|
14
|
+
test("retries up to max attempts and then throws the final error", async () => {
|
|
15
|
+
let calls = 0;
|
|
16
|
+
const delayTimes = [];
|
|
17
|
+
await assert.rejects(withRetry(async () => {
|
|
18
|
+
calls++;
|
|
19
|
+
throw new Error(`fail-${calls}`);
|
|
20
|
+
}, {
|
|
21
|
+
maxAttempts: 3,
|
|
22
|
+
jitter: false,
|
|
23
|
+
delayFn: async (ms) => {
|
|
24
|
+
delayTimes.push(ms);
|
|
25
|
+
},
|
|
26
|
+
}), /fail-3/);
|
|
27
|
+
assert.strictEqual(calls, 3);
|
|
28
|
+
// Exponential backoff verification:
|
|
29
|
+
// Delay 1: 1000ms * 2^0 = 1000ms
|
|
30
|
+
// Delay 2: 1000ms * 2^1 = 2000ms
|
|
31
|
+
assert.deepStrictEqual(delayTimes, [1000, 2000]);
|
|
32
|
+
});
|
|
33
|
+
test("immediately stops retrying and throws on non-retryable errors", async () => {
|
|
34
|
+
let calls = 0;
|
|
35
|
+
await assert.rejects(withRetry(async () => {
|
|
36
|
+
calls++;
|
|
37
|
+
if (calls === 1)
|
|
38
|
+
throw new Error("transient");
|
|
39
|
+
throw new Error("fatal");
|
|
40
|
+
}, {
|
|
41
|
+
maxAttempts: 5,
|
|
42
|
+
retryable: (err) => err.message === "transient",
|
|
43
|
+
delayFn: async () => { },
|
|
44
|
+
}), /fatal/);
|
|
45
|
+
assert.strictEqual(calls, 2);
|
|
46
|
+
});
|
|
47
|
+
test("includes randomized jitter when options.jitter is enabled", async () => {
|
|
48
|
+
const delayTimes = [];
|
|
49
|
+
await assert.rejects(withRetry(async () => {
|
|
50
|
+
throw new Error("fail");
|
|
51
|
+
}, {
|
|
52
|
+
maxAttempts: 3,
|
|
53
|
+
initialDelayMs: 1000,
|
|
54
|
+
backoffFactor: 2,
|
|
55
|
+
jitter: true,
|
|
56
|
+
delayFn: async (ms) => {
|
|
57
|
+
delayTimes.push(ms);
|
|
58
|
+
},
|
|
59
|
+
}), /fail/);
|
|
60
|
+
assert.strictEqual(delayTimes.length, 2);
|
|
61
|
+
// With 10% jitter, first delay (1000) should be between 900 and 1100
|
|
62
|
+
assert.ok(delayTimes[0] >= 900 && delayTimes[0] <= 1100);
|
|
63
|
+
// Second delay (2000) should be between 1800 and 2200
|
|
64
|
+
assert.ok(delayTimes[1] >= 1800 && delayTimes[1] <= 2200);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Output } from "./output.js";
|
|
2
|
+
export declare const resolvedSecrets: Set<string>;
|
|
3
|
+
/**
|
|
4
|
+
* Secret represents a lazy, secure credential that is fetched asynchronously
|
|
5
|
+
* at deployment time instead of during the eager construction phase.
|
|
6
|
+
*/
|
|
7
|
+
export declare class Secret extends Output<string> {
|
|
8
|
+
readonly secretName: string;
|
|
9
|
+
constructor(secretName: string, fetcher: () => Promise<string>);
|
|
10
|
+
private startFetching;
|
|
11
|
+
/**
|
|
12
|
+
* Helper method to seamlessly unpack either a static string, a standard Output, or a Secret.
|
|
13
|
+
* Call this within resource builder deploy() methods.
|
|
14
|
+
*/
|
|
15
|
+
static resolve(val: string | Output<string> | Secret): Promise<string>;
|
|
16
|
+
/**
|
|
17
|
+
* Fetches a secret from a local environment variable.
|
|
18
|
+
*/
|
|
19
|
+
static env(envName: string, fallback?: string): Secret;
|
|
20
|
+
/**
|
|
21
|
+
* Fetches a secret from AWS Secrets Manager.
|
|
22
|
+
* Uses dynamic imports so AWS SDK is only loaded if this helper is actually called.
|
|
23
|
+
*/
|
|
24
|
+
static aws(secretId: string, options?: {
|
|
25
|
+
region?: string;
|
|
26
|
+
}): Secret;
|
|
27
|
+
/**
|
|
28
|
+
* Fetches a parameter from AWS SSM Parameter Store.
|
|
29
|
+
* Uses dynamic imports so AWS SDK is only loaded if this helper is actually called.
|
|
30
|
+
*/
|
|
31
|
+
static ssm(parameterName: string, options?: {
|
|
32
|
+
region?: string;
|
|
33
|
+
}): Secret;
|
|
34
|
+
/**
|
|
35
|
+
* Fetches a secret from GCP Secret Manager.
|
|
36
|
+
* Uses dynamic imports so GCP config/fetch tools are only loaded if this helper is actually called.
|
|
37
|
+
*/
|
|
38
|
+
static gcp(secretId: string, options?: {
|
|
39
|
+
projectId?: string;
|
|
40
|
+
}): Secret;
|
|
41
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { Output } from "./output.js";
|
|
2
|
+
import { Config } from "./config.js";
|
|
3
|
+
export const resolvedSecrets = new Set();
|
|
4
|
+
/**
|
|
5
|
+
* Secret represents a lazy, secure credential that is fetched asynchronously
|
|
6
|
+
* at deployment time instead of during the eager construction phase.
|
|
7
|
+
*/
|
|
8
|
+
export class Secret extends Output {
|
|
9
|
+
secretName;
|
|
10
|
+
constructor(secretName, fetcher) {
|
|
11
|
+
super();
|
|
12
|
+
this.secretName = secretName;
|
|
13
|
+
this.startFetching(fetcher);
|
|
14
|
+
}
|
|
15
|
+
async startFetching(fetcher) {
|
|
16
|
+
try {
|
|
17
|
+
if (Config.isGlobalDryRun()) {
|
|
18
|
+
// Resolve immediately to a secure placeholder during dry-run testing
|
|
19
|
+
const placeholder = `[SECRET:${this.secretName}]`;
|
|
20
|
+
this.resolve(placeholder);
|
|
21
|
+
resolvedSecrets.add(placeholder);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const val = await fetcher();
|
|
25
|
+
this.resolve(val);
|
|
26
|
+
if (val && val.length >= 3) {
|
|
27
|
+
resolvedSecrets.add(val);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
this.reject(err);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Helper method to seamlessly unpack either a static string, a standard Output, or a Secret.
|
|
36
|
+
* Call this within resource builder deploy() methods.
|
|
37
|
+
*/
|
|
38
|
+
static async resolve(val) {
|
|
39
|
+
if (val instanceof Output) {
|
|
40
|
+
const resolved = await val.get();
|
|
41
|
+
if (val instanceof Secret && resolved && resolved.length >= 3) {
|
|
42
|
+
resolvedSecrets.add(resolved);
|
|
43
|
+
}
|
|
44
|
+
return resolved;
|
|
45
|
+
}
|
|
46
|
+
return val;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Fetches a secret from a local environment variable.
|
|
50
|
+
*/
|
|
51
|
+
static env(envName, fallback) {
|
|
52
|
+
return new Secret(envName, async () => {
|
|
53
|
+
const val = process.env[envName] ?? fallback;
|
|
54
|
+
if (val === undefined) {
|
|
55
|
+
throw new Error(`Environment secret "${envName}" is not set and has no fallback.`);
|
|
56
|
+
}
|
|
57
|
+
return val;
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Fetches a secret from AWS Secrets Manager.
|
|
62
|
+
* Uses dynamic imports so AWS SDK is only loaded if this helper is actually called.
|
|
63
|
+
*/
|
|
64
|
+
static aws(secretId, options) {
|
|
65
|
+
return new Secret(secretId, async () => {
|
|
66
|
+
const { SecretsManagerClient, GetSecretValueCommand } = await import("@aws-sdk/client-secrets-manager");
|
|
67
|
+
const client = new SecretsManagerClient({ region: options?.region ?? process.env.AWS_REGION ?? "us-east-1" });
|
|
68
|
+
const result = await client.send(new GetSecretValueCommand({ SecretId: secretId }));
|
|
69
|
+
if (!result.SecretString) {
|
|
70
|
+
throw new Error(`AWS Secret "${secretId}" is empty or not a string.`);
|
|
71
|
+
}
|
|
72
|
+
return result.SecretString;
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Fetches a parameter from AWS SSM Parameter Store.
|
|
77
|
+
* Uses dynamic imports so AWS SDK is only loaded if this helper is actually called.
|
|
78
|
+
*/
|
|
79
|
+
static ssm(parameterName, options) {
|
|
80
|
+
return new Secret(parameterName, async () => {
|
|
81
|
+
const { SSMClient, GetParameterCommand } = await import("@aws-sdk/client-ssm");
|
|
82
|
+
const client = new SSMClient({ region: options?.region ?? process.env.AWS_REGION ?? "us-east-1" });
|
|
83
|
+
const result = await client.send(new GetParameterCommand({ Name: parameterName, WithDecryption: true }));
|
|
84
|
+
if (!result.Parameter?.Value) {
|
|
85
|
+
throw new Error(`AWS SSM Parameter "${parameterName}" is empty.`);
|
|
86
|
+
}
|
|
87
|
+
return result.Parameter.Value;
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Fetches a secret from GCP Secret Manager.
|
|
92
|
+
* Uses dynamic imports so GCP config/fetch tools are only loaded if this helper is actually called.
|
|
93
|
+
*/
|
|
94
|
+
static gcp(secretId, options) {
|
|
95
|
+
return new Secret(secretId, async () => {
|
|
96
|
+
const { gcpFetch, getProjectId } = await import("../providers/gcp/api.js");
|
|
97
|
+
const project = options?.projectId ?? getProjectId();
|
|
98
|
+
const payload = await gcpFetch("https://secretmanager.googleapis.com", `/v1/projects/${project}/secrets/${secretId}/versions/latest:access`);
|
|
99
|
+
if (!payload?.payload?.data) {
|
|
100
|
+
throw new Error(`GCP Secret "${secretId}" payload is empty.`);
|
|
101
|
+
}
|
|
102
|
+
return Buffer.from(payload.payload.data, "base64").toString("utf8");
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|