puls-dev 0.2.7 → 0.2.8
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/config.d.ts +2 -0
- package/dist/core/decorators.d.ts +2 -0
- package/dist/core/decorators.js +48 -16
- 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/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/provisioner.d.ts +4 -0
- package/dist/core/provisioner.js +105 -0
- package/dist/core/resource.d.ts +16 -0
- package/dist/core/resource.js +44 -0
- package/dist/core/secret.d.ts +40 -0
- package/dist/core/secret.js +95 -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 +50 -9
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/providers/aws/ec2.d.ts +48 -0
- package/dist/providers/aws/ec2.js +297 -0
- package/dist/providers/aws/ec2.test.d.ts +1 -0
- package/dist/providers/aws/ec2.test.js +279 -0
- package/dist/providers/aws/index.d.ts +2 -0
- package/dist/providers/aws/index.js +2 -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/do/api.d.ts +1 -1
- package/dist/providers/do/api.js +2 -1
- 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 +132 -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/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 +3 -1
- package/dist/providers/gcp/index.js +3 -1
- package/dist/providers/gcp/vm.d.ts +45 -0
- package/dist/providers/gcp/vm.js +332 -0
- package/dist/providers/gcp/vm.test.d.ts +1 -0
- package/dist/providers/gcp/vm.test.js +321 -0
- package/dist/providers/proxmox/vm.d.ts +4 -4
- package/dist/providers/proxmox/vm.js +17 -93
- package/dist/providers/proxmox/vm.test.js +77 -0
- package/package.json +3 -1
package/dist/core/config.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import "reflect-metadata";
|
|
|
2
2
|
type ProviderOpts = {
|
|
3
3
|
token?: string;
|
|
4
4
|
region?: string;
|
|
5
|
+
regions?: string[];
|
|
5
6
|
dryRun?: boolean;
|
|
6
7
|
firebase?: string;
|
|
7
8
|
proxmox?: {
|
|
@@ -17,6 +18,7 @@ type ProviderOpts = {
|
|
|
17
18
|
};
|
|
18
19
|
};
|
|
19
20
|
export declare function Protected(target: any, propertyKey: string): void;
|
|
21
|
+
export declare function ForceConfigCheck(target: any, propertyKey: string): void;
|
|
20
22
|
export declare function Destroy(target: any, propertyKey: string): void;
|
|
21
23
|
export declare function Destroy(target: Function): void;
|
|
22
24
|
export declare function Destroy(opts: ProviderOpts): (constructor: any) => void;
|
package/dist/core/decorators.js
CHANGED
|
@@ -33,6 +33,9 @@ function applyConfig(opts) {
|
|
|
33
33
|
export function Protected(target, propertyKey) {
|
|
34
34
|
Reflect.defineMetadata("protected", true, target, propertyKey);
|
|
35
35
|
}
|
|
36
|
+
export function ForceConfigCheck(target, propertyKey) {
|
|
37
|
+
Reflect.defineMetadata("forceConfigCheck", true, target, propertyKey);
|
|
38
|
+
}
|
|
36
39
|
export function Destroy(optsOrTarget, propertyKey) {
|
|
37
40
|
if (propertyKey !== undefined) {
|
|
38
41
|
Reflect.defineMetadata("destroy", true, optsOrTarget, propertyKey);
|
|
@@ -48,27 +51,56 @@ export function Destroy(optsOrTarget, propertyKey) {
|
|
|
48
51
|
return;
|
|
49
52
|
}
|
|
50
53
|
return function (constructor) {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
54
|
+
const regions = optsOrTarget.regions ?? [];
|
|
55
|
+
if (regions.length > 0) {
|
|
56
|
+
Promise.resolve().then(async () => {
|
|
57
|
+
for (const r of regions) {
|
|
58
|
+
console.log(`\n🌍 [MULTI-REGION] Tearing down stack in region: ${r}`);
|
|
59
|
+
applyConfig({ ...optsOrTarget, region: r });
|
|
60
|
+
const instance = new constructor();
|
|
61
|
+
Stack._register(constructor, instance, r);
|
|
62
|
+
if (typeof instance.destroy === "function")
|
|
63
|
+
await instance.destroy();
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
applyConfig(optsOrTarget);
|
|
69
|
+
const instance = new constructor();
|
|
70
|
+
Stack._register(constructor, instance);
|
|
71
|
+
Promise.resolve().then(async () => {
|
|
72
|
+
if (typeof instance.destroy === "function")
|
|
73
|
+
await instance.destroy();
|
|
74
|
+
});
|
|
75
|
+
}
|
|
58
76
|
};
|
|
59
77
|
}
|
|
60
78
|
// THE "MAGIC": Auto-executing Stack Decorator
|
|
61
79
|
export function Deploy(opts = {}) {
|
|
62
80
|
return function (constructor) {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
81
|
+
const regions = opts.regions ?? [];
|
|
82
|
+
if (regions.length > 0) {
|
|
83
|
+
Promise.resolve().then(async () => {
|
|
84
|
+
for (const r of regions) {
|
|
85
|
+
console.log(`\n🌍 [MULTI-REGION] Deploying stack to region: ${r}`);
|
|
86
|
+
applyConfig({ ...opts, region: r });
|
|
87
|
+
const instance = new constructor();
|
|
88
|
+
Stack._register(constructor, instance, r);
|
|
89
|
+
if (typeof instance.deploy === "function") {
|
|
90
|
+
await instance.deploy();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
applyConfig(opts);
|
|
97
|
+
const instance = new constructor();
|
|
98
|
+
Stack._register(constructor, instance);
|
|
99
|
+
Promise.resolve().then(async () => {
|
|
100
|
+
if (typeof instance.deploy === "function")
|
|
101
|
+
await instance.deploy();
|
|
102
|
+
});
|
|
103
|
+
}
|
|
72
104
|
};
|
|
73
105
|
}
|
|
74
106
|
export function Check(opts = {}) {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export declare const SLACK: {
|
|
2
|
+
/**
|
|
3
|
+
* Generates a callback that posts a structured lifecycle notification to a Slack webhook.
|
|
4
|
+
* In dry-run mode, it outputs a descriptive log to the terminal instead of executing HTTP writes.
|
|
5
|
+
*
|
|
6
|
+
* @param webhookUrl The Slack Incoming Webhook URL
|
|
7
|
+
*/
|
|
8
|
+
notify: (webhookUrl: string) => (result: any) => Promise<void>;
|
|
9
|
+
};
|
|
10
|
+
export declare const DISCORD: {
|
|
11
|
+
/**
|
|
12
|
+
* Generates a callback that posts a rich embed lifecycle notification to a Discord webhook.
|
|
13
|
+
* In dry-run mode, it outputs a descriptive log to the terminal instead of executing HTTP writes.
|
|
14
|
+
*
|
|
15
|
+
* @param webhookUrl The Discord Incoming Webhook URL
|
|
16
|
+
* @example
|
|
17
|
+
* GCP.CloudRun("api")
|
|
18
|
+
* .afterDeploy(DISCORD.notify("https://discord.com/api/webhooks/..."))
|
|
19
|
+
*/
|
|
20
|
+
notify: (webhookUrl: string) => (result: any) => Promise<void>;
|
|
21
|
+
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { Config } from "./config.js";
|
|
2
|
+
export const SLACK = {
|
|
3
|
+
/**
|
|
4
|
+
* Generates a callback that posts a structured lifecycle notification to a Slack webhook.
|
|
5
|
+
* In dry-run mode, it outputs a descriptive log to the terminal instead of executing HTTP writes.
|
|
6
|
+
*
|
|
7
|
+
* @param webhookUrl The Slack Incoming Webhook URL
|
|
8
|
+
*/
|
|
9
|
+
notify: (webhookUrl) => {
|
|
10
|
+
return async (result) => {
|
|
11
|
+
const name = result?.name ?? "unknown-resource";
|
|
12
|
+
const isDryRun = Config.isGlobalDryRun();
|
|
13
|
+
// Check if it's a destroy result or deploy result
|
|
14
|
+
const isDestroy = result && ("destroyed" in result || result.destroyed === true);
|
|
15
|
+
const action = isDestroy ? "destroyed" : "deployed/updated";
|
|
16
|
+
const statusEmoji = isDestroy ? "🗑️" : "🚀";
|
|
17
|
+
// Filter and print simple key-value attributes
|
|
18
|
+
const details = Object.entries(result ?? {})
|
|
19
|
+
.filter(([k, v]) => k !== "name" &&
|
|
20
|
+
k !== "destroyed" &&
|
|
21
|
+
(typeof v === "string" || typeof v === "number" || typeof v === "boolean"))
|
|
22
|
+
.map(([k, v]) => `• *${k}*: \`${v}\``)
|
|
23
|
+
.join("\n");
|
|
24
|
+
const text = `${statusEmoji} *Puls Notification*: Resource *${name}* was successfully *${action}*!\n${details ? `*Details*:\n${details}` : ""}`;
|
|
25
|
+
if (isDryRun) {
|
|
26
|
+
console.log(`\n📢 [DRY RUN] Would post Slack notification to webhook: ${webhookUrl}`);
|
|
27
|
+
console.log(` Message: ${text.replace(/\n/g, "\n ")}`);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
const res = await fetch(webhookUrl, {
|
|
32
|
+
method: "POST",
|
|
33
|
+
headers: { "Content-Type": "application/json" },
|
|
34
|
+
body: JSON.stringify({ text }),
|
|
35
|
+
});
|
|
36
|
+
if (!res.ok) {
|
|
37
|
+
console.warn(`[WARN] Slack webhook returned status ${res.status}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
console.error(`[ERROR] Failed to send Slack notification: ${err.message}`);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
export const DISCORD = {
|
|
47
|
+
/**
|
|
48
|
+
* Generates a callback that posts a rich embed lifecycle notification to a Discord webhook.
|
|
49
|
+
* In dry-run mode, it outputs a descriptive log to the terminal instead of executing HTTP writes.
|
|
50
|
+
*
|
|
51
|
+
* @param webhookUrl The Discord Incoming Webhook URL
|
|
52
|
+
* @example
|
|
53
|
+
* GCP.CloudRun("api")
|
|
54
|
+
* .afterDeploy(DISCORD.notify("https://discord.com/api/webhooks/..."))
|
|
55
|
+
*/
|
|
56
|
+
notify: (webhookUrl) => {
|
|
57
|
+
return async (result) => {
|
|
58
|
+
const name = result?.name ?? "unknown-resource";
|
|
59
|
+
const isDryRun = Config.isGlobalDryRun();
|
|
60
|
+
// Check if it's a destroy result or deploy result
|
|
61
|
+
const isDestroy = result && ("destroyed" in result || result.destroyed === true);
|
|
62
|
+
const action = isDestroy ? "destroyed" : "deployed/updated";
|
|
63
|
+
const statusEmoji = isDestroy ? "🗑️" : "🚀";
|
|
64
|
+
// Green (0x2ecc71) for deploy, Red (0xe74c3c) for destroy
|
|
65
|
+
const color = isDestroy ? 15158332 : 3066993;
|
|
66
|
+
const fields = Object.entries(result ?? {})
|
|
67
|
+
.filter(([k, v]) => k !== "name" &&
|
|
68
|
+
k !== "destroyed" &&
|
|
69
|
+
(typeof v === "string" || typeof v === "number" || typeof v === "boolean"))
|
|
70
|
+
.map(([k, v]) => ({
|
|
71
|
+
name: k,
|
|
72
|
+
value: `\`${v}\``,
|
|
73
|
+
inline: true,
|
|
74
|
+
}));
|
|
75
|
+
const payload = {
|
|
76
|
+
embeds: [
|
|
77
|
+
{
|
|
78
|
+
title: `${statusEmoji} Puls Notification`,
|
|
79
|
+
description: `Resource **${name}** was successfully **${action}**!`,
|
|
80
|
+
color,
|
|
81
|
+
fields,
|
|
82
|
+
footer: {
|
|
83
|
+
text: "Puls Dev Suite",
|
|
84
|
+
},
|
|
85
|
+
timestamp: new Date().toISOString(),
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
};
|
|
89
|
+
if (isDryRun) {
|
|
90
|
+
console.log(`\n📢 [DRY RUN] Would post Discord notification to webhook: ${webhookUrl}`);
|
|
91
|
+
console.log(` Embed Title: ${statusEmoji} Puls Notification`);
|
|
92
|
+
console.log(` Embed Description: Resource **${name}** was successfully **${action}**!`);
|
|
93
|
+
if (fields.length > 0) {
|
|
94
|
+
console.log(" Fields:");
|
|
95
|
+
for (const f of fields) {
|
|
96
|
+
console.log(` • ${f.name}: ${f.value}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
const res = await fetch(webhookUrl, {
|
|
103
|
+
method: "POST",
|
|
104
|
+
headers: { "Content-Type": "application/json" },
|
|
105
|
+
body: JSON.stringify(payload),
|
|
106
|
+
});
|
|
107
|
+
if (!res.ok) {
|
|
108
|
+
console.warn(`[WARN] Discord webhook returned status ${res.status}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
console.error(`[ERROR] Failed to send Discord notification: ${err.message}`);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
},
|
|
116
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { test, describe, beforeEach } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import { Stack } from "./stack.js";
|
|
4
|
+
import { BaseBuilder } from "./resource.js";
|
|
5
|
+
import { Config } from "./config.js";
|
|
6
|
+
import { SLACK, DISCORD } from "./hooks.js";
|
|
7
|
+
// A minimal dummy resource builder for testing hooks
|
|
8
|
+
class TestResource extends BaseBuilder {
|
|
9
|
+
resultValue;
|
|
10
|
+
constructor(name, resultValue = { name: "test-res", ip: "1.2.3.4" }) {
|
|
11
|
+
super(name);
|
|
12
|
+
this.resultValue = resultValue;
|
|
13
|
+
}
|
|
14
|
+
async deploy() {
|
|
15
|
+
return this.resultValue;
|
|
16
|
+
}
|
|
17
|
+
async destroy() {
|
|
18
|
+
return { name: this.name, destroyed: true };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
describe("Lifecycle Hooks & Notifier Tests", () => {
|
|
22
|
+
let executionLogs = [];
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
Config.set({
|
|
25
|
+
dryRun: false,
|
|
26
|
+
providers: {},
|
|
27
|
+
});
|
|
28
|
+
executionLogs = [];
|
|
29
|
+
});
|
|
30
|
+
test("runs Stack-level and Resource-level deploy hooks in the correct order", async () => {
|
|
31
|
+
class MyStack extends Stack {
|
|
32
|
+
async beforeDeploy() {
|
|
33
|
+
executionLogs.push("stack-before-deploy");
|
|
34
|
+
}
|
|
35
|
+
async afterDeploy(outputs) {
|
|
36
|
+
executionLogs.push(`stack-after-deploy:${outputs.res.ip}`);
|
|
37
|
+
}
|
|
38
|
+
res = new TestResource("my-res", { name: "my-res", ip: "9.9.9.9" })
|
|
39
|
+
.beforeDeploy(() => {
|
|
40
|
+
executionLogs.push("res-before-deploy");
|
|
41
|
+
})
|
|
42
|
+
.afterDeploy((result) => {
|
|
43
|
+
executionLogs.push(`res-after-deploy:${result.ip}`);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
const stack = new MyStack();
|
|
47
|
+
const outputs = await stack.deploy();
|
|
48
|
+
// Verify ordering
|
|
49
|
+
assert.deepStrictEqual(executionLogs, [
|
|
50
|
+
"stack-before-deploy",
|
|
51
|
+
"res-before-deploy",
|
|
52
|
+
"res-after-deploy:9.9.9.9",
|
|
53
|
+
"stack-after-deploy:9.9.9.9",
|
|
54
|
+
]);
|
|
55
|
+
assert.deepStrictEqual(outputs.res, { name: "my-res", ip: "9.9.9.9" });
|
|
56
|
+
});
|
|
57
|
+
test("runs Stack-level and Resource-level destroy hooks in the correct order", async () => {
|
|
58
|
+
class MyStack extends Stack {
|
|
59
|
+
async beforeDestroy() {
|
|
60
|
+
executionLogs.push("stack-before-destroy");
|
|
61
|
+
}
|
|
62
|
+
async afterDestroy(outputs) {
|
|
63
|
+
executionLogs.push(`stack-after-destroy:${outputs.res.destroyed}`);
|
|
64
|
+
}
|
|
65
|
+
res = new TestResource("my-res")
|
|
66
|
+
.beforeDestroy(() => {
|
|
67
|
+
executionLogs.push("res-before-destroy");
|
|
68
|
+
})
|
|
69
|
+
.afterDestroy((result) => {
|
|
70
|
+
executionLogs.push(`res-after-destroy:${result.destroyed}`);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
const stack = new MyStack();
|
|
74
|
+
const outputs = await stack.destroy();
|
|
75
|
+
// Verify ordering (destroy properties are iterated in reverse order, which doesn't affect single resource)
|
|
76
|
+
assert.deepStrictEqual(executionLogs, [
|
|
77
|
+
"stack-before-destroy",
|
|
78
|
+
"res-before-destroy",
|
|
79
|
+
"res-after-destroy:true",
|
|
80
|
+
"stack-after-destroy:true",
|
|
81
|
+
]);
|
|
82
|
+
assert.deepStrictEqual(outputs.res, { name: "my-res", destroyed: true });
|
|
83
|
+
});
|
|
84
|
+
test("SLACK.notify helper sends fetch call on real deploys", async () => {
|
|
85
|
+
const originalFetch = globalThis.fetch;
|
|
86
|
+
let fetchPayload = null;
|
|
87
|
+
let fetchUrl = "";
|
|
88
|
+
globalThis.fetch = (async (url, init) => {
|
|
89
|
+
fetchUrl = url;
|
|
90
|
+
fetchPayload = JSON.parse(init?.body);
|
|
91
|
+
return {
|
|
92
|
+
ok: true,
|
|
93
|
+
status: 200,
|
|
94
|
+
};
|
|
95
|
+
});
|
|
96
|
+
try {
|
|
97
|
+
const webhook = "https://hooks.slack.com/services/T00/B00/X00";
|
|
98
|
+
const notifyHook = SLACK.notify(webhook);
|
|
99
|
+
const deployResult = { name: "web-app", url: "https://web.run.app", ip: "1.2.3.4" };
|
|
100
|
+
await notifyHook(deployResult);
|
|
101
|
+
assert.strictEqual(fetchUrl, webhook);
|
|
102
|
+
assert.ok(fetchPayload);
|
|
103
|
+
assert.ok(fetchPayload.text.includes("🚀"));
|
|
104
|
+
assert.ok(fetchPayload.text.includes("web-app"));
|
|
105
|
+
assert.ok(fetchPayload.text.includes("https://web.run.app"));
|
|
106
|
+
assert.ok(fetchPayload.text.includes("1.2.3.4"));
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
globalThis.fetch = originalFetch;
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
test("SLACK.notify helper formats text and logs to console on dry-runs", async () => {
|
|
113
|
+
Config.set({
|
|
114
|
+
dryRun: true,
|
|
115
|
+
providers: {},
|
|
116
|
+
});
|
|
117
|
+
const originalLog = console.log;
|
|
118
|
+
let logOutput = "";
|
|
119
|
+
console.log = (...args) => {
|
|
120
|
+
logOutput += args.join(" ") + "\n";
|
|
121
|
+
};
|
|
122
|
+
try {
|
|
123
|
+
const notifyHook = SLACK.notify("https://hooks.slack.com/services/T00");
|
|
124
|
+
const destroyResult = { name: "old-db", destroyed: true };
|
|
125
|
+
await notifyHook(destroyResult);
|
|
126
|
+
assert.ok(logOutput.includes("📢 [DRY RUN]"));
|
|
127
|
+
assert.ok(logOutput.includes("Would post Slack notification"));
|
|
128
|
+
assert.ok(logOutput.includes("🗑️"));
|
|
129
|
+
assert.ok(logOutput.includes("old-db"));
|
|
130
|
+
assert.ok(logOutput.includes("destroyed"));
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
console.log = originalLog;
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
test("DISCORD.notify helper sends fetch call with rich embed on real deploys", async () => {
|
|
137
|
+
const originalFetch = globalThis.fetch;
|
|
138
|
+
let fetchPayload = null;
|
|
139
|
+
let fetchUrl = "";
|
|
140
|
+
globalThis.fetch = (async (url, init) => {
|
|
141
|
+
fetchUrl = url;
|
|
142
|
+
fetchPayload = JSON.parse(init?.body);
|
|
143
|
+
return {
|
|
144
|
+
ok: true,
|
|
145
|
+
status: 200,
|
|
146
|
+
};
|
|
147
|
+
});
|
|
148
|
+
try {
|
|
149
|
+
const webhook = "https://discord.com/api/webhooks/123/abc";
|
|
150
|
+
const notifyHook = DISCORD.notify(webhook);
|
|
151
|
+
const deployResult = { name: "api-srv", url: "https://api.run.app", active: true };
|
|
152
|
+
await notifyHook(deployResult);
|
|
153
|
+
assert.strictEqual(fetchUrl, webhook);
|
|
154
|
+
assert.ok(fetchPayload);
|
|
155
|
+
assert.ok(fetchPayload.embeds);
|
|
156
|
+
assert.strictEqual(fetchPayload.embeds.length, 1);
|
|
157
|
+
const embed = fetchPayload.embeds[0];
|
|
158
|
+
assert.strictEqual(embed.title, "🚀 Puls Notification");
|
|
159
|
+
assert.ok(embed.description.includes("api-srv"));
|
|
160
|
+
assert.ok(embed.description.includes("deployed/updated"));
|
|
161
|
+
assert.strictEqual(embed.color, 3066993); // Green
|
|
162
|
+
assert.strictEqual(embed.fields.length, 2);
|
|
163
|
+
assert.deepStrictEqual(embed.fields[0], { name: "url", value: "`https://api.run.app`", inline: true });
|
|
164
|
+
assert.deepStrictEqual(embed.fields[1], { name: "active", value: "`true`", inline: true });
|
|
165
|
+
}
|
|
166
|
+
finally {
|
|
167
|
+
globalThis.fetch = originalFetch;
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
test("DISCORD.notify helper formats embeds and logs to console on dry-runs", async () => {
|
|
171
|
+
Config.set({
|
|
172
|
+
dryRun: true,
|
|
173
|
+
providers: {},
|
|
174
|
+
});
|
|
175
|
+
const originalLog = console.log;
|
|
176
|
+
let logOutput = "";
|
|
177
|
+
console.log = (...args) => {
|
|
178
|
+
logOutput += args.join(" ") + "\n";
|
|
179
|
+
};
|
|
180
|
+
try {
|
|
181
|
+
const notifyHook = DISCORD.notify("https://discord.com/api/webhooks/123");
|
|
182
|
+
const destroyResult = { name: "old-cache", destroyed: true };
|
|
183
|
+
await notifyHook(destroyResult);
|
|
184
|
+
assert.ok(logOutput.includes("📢 [DRY RUN]"));
|
|
185
|
+
assert.ok(logOutput.includes("Would post Discord notification"));
|
|
186
|
+
assert.ok(logOutput.includes("🗑️"));
|
|
187
|
+
assert.ok(logOutput.includes("old-cache"));
|
|
188
|
+
assert.ok(logOutput.includes("destroyed"));
|
|
189
|
+
}
|
|
190
|
+
finally {
|
|
191
|
+
console.log = originalLog;
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,87 @@
|
|
|
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, Destroy } from "./decorators.js";
|
|
11
|
+
import { BaseBuilder } from "./resource.js";
|
|
12
|
+
import { Config } from "./config.js";
|
|
13
|
+
import { Output } from "./output.js";
|
|
14
|
+
class RegionResource extends BaseBuilder {
|
|
15
|
+
out = {
|
|
16
|
+
region: new Output(),
|
|
17
|
+
};
|
|
18
|
+
async deploy() {
|
|
19
|
+
const activeRegion = Config.get().providers.aws?.region ?? "unknown";
|
|
20
|
+
this.out.region.resolve(activeRegion);
|
|
21
|
+
return { name: this.name, region: activeRegion };
|
|
22
|
+
}
|
|
23
|
+
async destroy() {
|
|
24
|
+
const activeRegion = Config.get().providers.aws?.region ?? "unknown";
|
|
25
|
+
return { name: this.name, destroyed: true, region: activeRegion };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
describe("Multi-Region Deployments Unit Tests", () => {
|
|
29
|
+
let executionLogs = [];
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
Config.set({
|
|
32
|
+
dryRun: false,
|
|
33
|
+
providers: {},
|
|
34
|
+
});
|
|
35
|
+
executionLogs = [];
|
|
36
|
+
});
|
|
37
|
+
test("runs sequential deployments across multiple regions and stores instances in registry", async () => {
|
|
38
|
+
let MultiStack = class MultiStack extends Stack {
|
|
39
|
+
res = new RegionResource("my-region-res").afterDeploy((result) => {
|
|
40
|
+
executionLogs.push(`deploy:${result.region}`);
|
|
41
|
+
});
|
|
42
|
+
};
|
|
43
|
+
MultiStack = __decorate([
|
|
44
|
+
Deploy({
|
|
45
|
+
regions: ["us-east-1", "eu-central-1", "ap-northeast-1"],
|
|
46
|
+
dryRun: false,
|
|
47
|
+
})
|
|
48
|
+
], MultiStack);
|
|
49
|
+
// Wait for the asynchronous macro/microtask queue to resolve Deploy runs
|
|
50
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
51
|
+
// Verify sequential region changes occurred in correct order
|
|
52
|
+
assert.deepStrictEqual(executionLogs, [
|
|
53
|
+
"deploy:us-east-1",
|
|
54
|
+
"deploy:eu-central-1",
|
|
55
|
+
"deploy:ap-northeast-1",
|
|
56
|
+
]);
|
|
57
|
+
// Retrieve specific region stack outputs via Stack.from(cls, region)
|
|
58
|
+
const usStack = Stack.from(MultiStack, "us-east-1");
|
|
59
|
+
const euStack = Stack.from(MultiStack, "eu-central-1");
|
|
60
|
+
const apStack = Stack.from(MultiStack, "ap-northeast-1");
|
|
61
|
+
assert.ok(usStack);
|
|
62
|
+
assert.ok(euStack);
|
|
63
|
+
assert.ok(apStack);
|
|
64
|
+
assert.strictEqual(await usStack.res.out.region.get(), "us-east-1");
|
|
65
|
+
assert.strictEqual(await euStack.res.out.region.get(), "eu-central-1");
|
|
66
|
+
assert.strictEqual(await apStack.res.out.region.get(), "ap-northeast-1");
|
|
67
|
+
});
|
|
68
|
+
test("runs sequential teardowns across multiple regions on @Destroy", async () => {
|
|
69
|
+
let CleanStack = class CleanStack extends Stack {
|
|
70
|
+
res = new RegionResource("my-teardown-res").afterDestroy((result) => {
|
|
71
|
+
executionLogs.push(`destroy:${result.region}`);
|
|
72
|
+
});
|
|
73
|
+
};
|
|
74
|
+
CleanStack = __decorate([
|
|
75
|
+
Destroy({
|
|
76
|
+
regions: ["us-east-1", "eu-central-1"],
|
|
77
|
+
dryRun: false,
|
|
78
|
+
})
|
|
79
|
+
], CleanStack);
|
|
80
|
+
// Wait for microtask queue to run Destroy
|
|
81
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
82
|
+
assert.deepStrictEqual(executionLogs, [
|
|
83
|
+
"destroy:us-east-1",
|
|
84
|
+
"destroy:eu-central-1",
|
|
85
|
+
]);
|
|
86
|
+
});
|
|
87
|
+
});
|
package/dist/core/output.d.ts
CHANGED
package/dist/core/output.js
CHANGED
|
@@ -1,19 +1,26 @@
|
|
|
1
1
|
export class Output {
|
|
2
2
|
_promise;
|
|
3
3
|
_resolve;
|
|
4
|
+
_reject;
|
|
4
5
|
constructor() {
|
|
5
|
-
this._promise = new Promise(resolve =>
|
|
6
|
+
this._promise = new Promise((resolve, reject) => {
|
|
7
|
+
this._resolve = resolve;
|
|
8
|
+
this._reject = reject;
|
|
9
|
+
});
|
|
6
10
|
}
|
|
7
11
|
resolve(value) {
|
|
8
12
|
this._resolve(value);
|
|
9
13
|
}
|
|
14
|
+
reject(reason) {
|
|
15
|
+
this._reject(reason);
|
|
16
|
+
}
|
|
10
17
|
get() {
|
|
11
18
|
return this._promise;
|
|
12
19
|
}
|
|
13
20
|
// Transform this output into a new Output<U> without awaiting it yourself.
|
|
14
21
|
apply(fn) {
|
|
15
22
|
const out = new Output();
|
|
16
|
-
this._promise.then(v => out.resolve(fn(v)));
|
|
23
|
+
this._promise.then(v => out.resolve(fn(v)), err => out.reject(err));
|
|
17
24
|
return out;
|
|
18
25
|
}
|
|
19
26
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Robust, zero-dependency, indentation-aware YAML parser.
|
|
3
|
+
* Parses sequences of key-value maps and nested string arrays.
|
|
4
|
+
*/
|
|
5
|
+
export declare function parseYaml(content: string): any[];
|
|
6
|
+
/**
|
|
7
|
+
* Resolves a file path relative to the current working directory,
|
|
8
|
+
* reads its content, and parses it according to its extension (.json vs .yaml/.yml).
|
|
9
|
+
*/
|
|
10
|
+
export declare function loadRecordsFromFile(filePath: string): any[];
|