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
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,9 +1,13 @@
|
|
|
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;
|
|
6
8
|
defaultRegion?: string;
|
|
9
|
+
spacesAccessKey?: string;
|
|
10
|
+
spacesSecretKey?: string;
|
|
7
11
|
};
|
|
8
12
|
aws?: {
|
|
9
13
|
region: string;
|
|
@@ -35,6 +39,8 @@ declare class ConfigManager {
|
|
|
35
39
|
set(newConfig: Partial<GlobalConfig>): void;
|
|
36
40
|
get(): GlobalConfig;
|
|
37
41
|
isGlobalDryRun(): boolean;
|
|
42
|
+
isParallelActive(): boolean;
|
|
43
|
+
isOfflineMode(): boolean;
|
|
38
44
|
}
|
|
39
45
|
export declare const Config: ConfigManager;
|
|
40
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>;
|
|
@@ -2,7 +2,10 @@ import "reflect-metadata";
|
|
|
2
2
|
type ProviderOpts = {
|
|
3
3
|
token?: string;
|
|
4
4
|
region?: string;
|
|
5
|
+
regions?: string[];
|
|
5
6
|
dryRun?: boolean;
|
|
7
|
+
parallel?: boolean;
|
|
8
|
+
offline?: boolean;
|
|
6
9
|
firebase?: string;
|
|
7
10
|
proxmox?: {
|
|
8
11
|
url: string;
|
|
@@ -17,6 +20,7 @@ type ProviderOpts = {
|
|
|
17
20
|
};
|
|
18
21
|
};
|
|
19
22
|
export declare function Protected(target: any, propertyKey: string): void;
|
|
23
|
+
export declare function ForceConfigCheck(target: any, propertyKey: string): void;
|
|
20
24
|
export declare function Destroy(target: any, propertyKey: string): void;
|
|
21
25
|
export declare function Destroy(target: Function): void;
|
|
22
26
|
export declare function Destroy(opts: ProviderOpts): (constructor: any) => void;
|
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
|
}
|
|
@@ -33,6 +27,9 @@ function applyConfig(opts) {
|
|
|
33
27
|
export function Protected(target, propertyKey) {
|
|
34
28
|
Reflect.defineMetadata("protected", true, target, propertyKey);
|
|
35
29
|
}
|
|
30
|
+
export function ForceConfigCheck(target, propertyKey) {
|
|
31
|
+
Reflect.defineMetadata("forceConfigCheck", true, target, propertyKey);
|
|
32
|
+
}
|
|
36
33
|
export function Destroy(optsOrTarget, propertyKey) {
|
|
37
34
|
if (propertyKey !== undefined) {
|
|
38
35
|
Reflect.defineMetadata("destroy", true, optsOrTarget, propertyKey);
|
|
@@ -48,27 +45,56 @@ export function Destroy(optsOrTarget, propertyKey) {
|
|
|
48
45
|
return;
|
|
49
46
|
}
|
|
50
47
|
return function (constructor) {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
48
|
+
const regions = optsOrTarget.regions ?? [];
|
|
49
|
+
if (regions.length > 0) {
|
|
50
|
+
Promise.resolve().then(async () => {
|
|
51
|
+
for (const r of regions) {
|
|
52
|
+
console.log(`\n🌍 [MULTI-REGION] Tearing down stack in region: ${r}`);
|
|
53
|
+
applyConfig({ ...optsOrTarget, region: r });
|
|
54
|
+
const instance = new constructor();
|
|
55
|
+
Stack._register(constructor, instance, r);
|
|
56
|
+
if (typeof instance.destroy === "function")
|
|
57
|
+
await instance.destroy();
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
applyConfig(optsOrTarget);
|
|
63
|
+
const instance = new constructor();
|
|
64
|
+
Stack._register(constructor, instance);
|
|
65
|
+
Promise.resolve().then(async () => {
|
|
66
|
+
if (typeof instance.destroy === "function")
|
|
67
|
+
await instance.destroy();
|
|
68
|
+
});
|
|
69
|
+
}
|
|
58
70
|
};
|
|
59
71
|
}
|
|
60
72
|
// THE "MAGIC": Auto-executing Stack Decorator
|
|
61
73
|
export function Deploy(opts = {}) {
|
|
62
74
|
return function (constructor) {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
75
|
+
const regions = opts.regions ?? [];
|
|
76
|
+
if (regions.length > 0) {
|
|
77
|
+
Promise.resolve().then(async () => {
|
|
78
|
+
for (const r of regions) {
|
|
79
|
+
console.log(`\n🌍 [MULTI-REGION] Deploying stack to region: ${r}`);
|
|
80
|
+
applyConfig({ ...opts, region: r });
|
|
81
|
+
const instance = new constructor();
|
|
82
|
+
Stack._register(constructor, instance, r);
|
|
83
|
+
if (typeof instance.deploy === "function") {
|
|
84
|
+
await instance.deploy();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
applyConfig(opts);
|
|
91
|
+
const instance = new constructor();
|
|
92
|
+
Stack._register(constructor, instance);
|
|
93
|
+
Promise.resolve().then(async () => {
|
|
94
|
+
if (typeof instance.deploy === "function")
|
|
95
|
+
await instance.deploy();
|
|
96
|
+
});
|
|
97
|
+
}
|
|
72
98
|
};
|
|
73
99
|
}
|
|
74
100
|
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 {};
|