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
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { test, describe, beforeEach, mock } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { Secret } from "./secret.js";
|
|
7
|
+
import { Config } from "./config.js";
|
|
8
|
+
import { Output } from "./output.js";
|
|
9
|
+
// Import clients to mock their prototype methods
|
|
10
|
+
import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager";
|
|
11
|
+
import { SSMClient } from "@aws-sdk/client-ssm";
|
|
12
|
+
import { GoogleAuth } from "google-auth-library";
|
|
13
|
+
describe("Secrets at Deploy Time Unit Tests", () => {
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
// Reset global config before each test
|
|
16
|
+
Config.set({
|
|
17
|
+
dryRun: false,
|
|
18
|
+
providers: {},
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
test("Secret.env resolves existing environment variables", async () => {
|
|
22
|
+
process.env.TEST_MY_SECRET = "super-secret-value";
|
|
23
|
+
try {
|
|
24
|
+
const secret = Secret.env("TEST_MY_SECRET");
|
|
25
|
+
const val = await secret.get();
|
|
26
|
+
assert.strictEqual(val, "super-secret-value");
|
|
27
|
+
}
|
|
28
|
+
finally {
|
|
29
|
+
delete process.env.TEST_MY_SECRET;
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
test("Secret.env uses fallback when environment variable is missing", async () => {
|
|
33
|
+
const secret = Secret.env("TEST_MISSING_SECRET", "fallback-value");
|
|
34
|
+
const val = await secret.get();
|
|
35
|
+
assert.strictEqual(val, "fallback-value");
|
|
36
|
+
});
|
|
37
|
+
test("Secret.env throws error when missing and no fallback is set", async () => {
|
|
38
|
+
const secret = Secret.env("TEST_MISSING_SECRET_NO_FALLBACK");
|
|
39
|
+
await assert.rejects(async () => {
|
|
40
|
+
await secret.get();
|
|
41
|
+
}, /Environment secret "TEST_MISSING_SECRET_NO_FALLBACK" is not set and has no fallback./);
|
|
42
|
+
});
|
|
43
|
+
test("Secret.resolve correctly resolves strings, Outputs, and Secrets", async () => {
|
|
44
|
+
// 1. Resolve plain string
|
|
45
|
+
const r1 = await Secret.resolve("plain-string");
|
|
46
|
+
assert.strictEqual(r1, "plain-string");
|
|
47
|
+
// 2. Resolve general Output
|
|
48
|
+
const out = new Output();
|
|
49
|
+
out.resolve("resolved-output");
|
|
50
|
+
const r2 = await Secret.resolve(out);
|
|
51
|
+
assert.strictEqual(r2, "resolved-output");
|
|
52
|
+
// 3. Resolve Secret
|
|
53
|
+
process.env.TEST_RESOLVE = "secret-val";
|
|
54
|
+
try {
|
|
55
|
+
const sec = Secret.env("TEST_RESOLVE");
|
|
56
|
+
const r3 = await Secret.resolve(sec);
|
|
57
|
+
assert.strictEqual(r3, "secret-val");
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
delete process.env.TEST_RESOLVE;
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
test("Secret resolves immediately to placeholder [SECRET:name] in dry-run mode", async () => {
|
|
64
|
+
Config.set({ dryRun: true });
|
|
65
|
+
// No actual env vars, cloud requests, or file systems will be hit
|
|
66
|
+
const s1 = Secret.env("SOME_ENV");
|
|
67
|
+
const s2 = Secret.aws("some/aws/secret");
|
|
68
|
+
const s3 = Secret.ssm("/some/ssm/param");
|
|
69
|
+
const s4 = Secret.gcp("some-gcp-secret");
|
|
70
|
+
assert.strictEqual(await s1.get(), "[SECRET:SOME_ENV]");
|
|
71
|
+
assert.strictEqual(await s2.get(), "[SECRET:some/aws/secret]");
|
|
72
|
+
assert.strictEqual(await s3.get(), "[SECRET:/some/ssm/param]");
|
|
73
|
+
assert.strictEqual(await s4.get(), "[SECRET:some-gcp-secret]");
|
|
74
|
+
});
|
|
75
|
+
test("Secret.aws resolves value using mocked AWS Secrets Manager", async () => {
|
|
76
|
+
const originalSend = SecretsManagerClient.prototype.send;
|
|
77
|
+
mock.method(SecretsManagerClient.prototype, "send", async (command) => {
|
|
78
|
+
return { SecretString: "my-aws-vault-password" };
|
|
79
|
+
});
|
|
80
|
+
try {
|
|
81
|
+
const secret = Secret.aws("prod/db/pass", { region: "eu-west-1" });
|
|
82
|
+
const val = await secret.get();
|
|
83
|
+
assert.strictEqual(val, "my-aws-vault-password");
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
SecretsManagerClient.prototype.send = originalSend;
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
test("Secret.ssm resolves value using mocked AWS SSM Parameter Store", async () => {
|
|
90
|
+
const originalSend = SSMClient.prototype.send;
|
|
91
|
+
mock.method(SSMClient.prototype, "send", async (command) => {
|
|
92
|
+
return {
|
|
93
|
+
Parameter: {
|
|
94
|
+
Value: "my-ssm-token-value",
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
});
|
|
98
|
+
try {
|
|
99
|
+
const secret = Secret.ssm("/prod/api/token");
|
|
100
|
+
const val = await secret.get();
|
|
101
|
+
assert.strictEqual(val, "my-ssm-token-value");
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
SSMClient.prototype.send = originalSend;
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
test("Secret.gcp resolves value using mocked GCP Secret Manager fetcher", async () => {
|
|
108
|
+
// 1. Create a dummy service account file
|
|
109
|
+
const dummySaPath = join(tmpdir(), `puls-gcp-sa-test-${Date.now()}.json`);
|
|
110
|
+
const dummySa = {
|
|
111
|
+
project_id: "test-gcp-project",
|
|
112
|
+
client_email: "test@developer.gserviceaccount.com",
|
|
113
|
+
};
|
|
114
|
+
fs.writeFileSync(dummySaPath, JSON.stringify(dummySa));
|
|
115
|
+
// Configure config to use the dummy file
|
|
116
|
+
Config.set({
|
|
117
|
+
providers: {
|
|
118
|
+
gcp: {
|
|
119
|
+
projectId: "test-gcp-project",
|
|
120
|
+
serviceAccountPath: dummySaPath,
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
// 2. Mock GoogleAuth class prototype
|
|
125
|
+
const originalGetClient = GoogleAuth.prototype.getClient;
|
|
126
|
+
mock.method(GoogleAuth.prototype, "getClient", async () => {
|
|
127
|
+
return {
|
|
128
|
+
getAccessToken: async () => ({ token: "mocked-gcp-access-token" }),
|
|
129
|
+
};
|
|
130
|
+
});
|
|
131
|
+
// 3. Mock globalThis.fetch to intercept Secret Manager API request
|
|
132
|
+
const originalFetch = globalThis.fetch;
|
|
133
|
+
let requestedUrl = "";
|
|
134
|
+
let authHeader = "";
|
|
135
|
+
globalThis.fetch = (async (url, init) => {
|
|
136
|
+
requestedUrl = url;
|
|
137
|
+
authHeader = init?.headers?.["Authorization"] ?? "";
|
|
138
|
+
return {
|
|
139
|
+
ok: true,
|
|
140
|
+
status: 200,
|
|
141
|
+
text: async () => JSON.stringify({
|
|
142
|
+
name: "projects/test-gcp-project/secrets/my-secret/versions/latest",
|
|
143
|
+
payload: {
|
|
144
|
+
data: Buffer.from("my-gcp-payload-value").toString("base64"),
|
|
145
|
+
},
|
|
146
|
+
}),
|
|
147
|
+
};
|
|
148
|
+
});
|
|
149
|
+
try {
|
|
150
|
+
const secret = Secret.gcp("my-secret");
|
|
151
|
+
const val = await secret.get();
|
|
152
|
+
assert.strictEqual(val, "my-gcp-payload-value");
|
|
153
|
+
assert.strictEqual(requestedUrl, "https://secretmanager.googleapis.com/v1/projects/test-gcp-project/secrets/my-secret/versions/latest:access");
|
|
154
|
+
assert.strictEqual(authHeader, "Bearer mocked-gcp-access-token");
|
|
155
|
+
}
|
|
156
|
+
finally {
|
|
157
|
+
// Cleanup
|
|
158
|
+
globalThis.fetch = originalFetch;
|
|
159
|
+
GoogleAuth.prototype.getClient = originalGetClient;
|
|
160
|
+
try {
|
|
161
|
+
fs.unlinkSync(dummySaPath);
|
|
162
|
+
}
|
|
163
|
+
catch { }
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
});
|
package/dist/core/stack.d.ts
CHANGED
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
import "reflect-metadata";
|
|
2
2
|
export declare abstract class Stack {
|
|
3
3
|
/** @internal - called by @Deploy to register the instance for cross-stack references. */
|
|
4
|
-
static _register(cls: Function, instance: Stack): void;
|
|
4
|
+
static _register(cls: Function, instance: Stack, region?: string): void;
|
|
5
5
|
/**
|
|
6
6
|
* Returns the already-constructed instance of another Stack so you can reference
|
|
7
7
|
* its resource Output fields before deployment completes.
|
|
8
8
|
*
|
|
9
9
|
* The target stack must be decorated with @Deploy and imported before this call.
|
|
10
|
+
* An optional region parameter can be supplied for multi-region configurations.
|
|
10
11
|
*
|
|
11
12
|
* @example
|
|
12
13
|
* class DNSStack extends Stack {
|
|
13
|
-
* private infra = Stack.from(InfraStack);
|
|
14
|
+
* private infra = Stack.from(InfraStack, REGION.US_EAST_1);
|
|
14
15
|
* dns = DO.Domain("example.com").pointer("app", this.infra.app.ip);
|
|
15
16
|
* }
|
|
16
17
|
*/
|
|
17
|
-
static from<T extends Stack>(cls: new (...args: any[]) => T): T;
|
|
18
|
+
static from<T extends Stack>(cls: new (...args: any[]) => T, region?: string): T;
|
|
18
19
|
deploy(): Promise<Record<string, any>>;
|
|
19
20
|
destroy(): Promise<Record<string, any>>;
|
|
20
21
|
}
|
package/dist/core/stack.js
CHANGED
|
@@ -60,7 +60,10 @@ function printOutputs(stackName, outputs) {
|
|
|
60
60
|
}
|
|
61
61
|
export class Stack {
|
|
62
62
|
/** @internal - called by @Deploy to register the instance for cross-stack references. */
|
|
63
|
-
static _register(cls, instance) {
|
|
63
|
+
static _register(cls, instance, region) {
|
|
64
|
+
if (region) {
|
|
65
|
+
_registry.set(`${cls.name}:${region}`, instance);
|
|
66
|
+
}
|
|
64
67
|
_registry.set(cls, instance);
|
|
65
68
|
}
|
|
66
69
|
/**
|
|
@@ -68,21 +71,28 @@ export class Stack {
|
|
|
68
71
|
* its resource Output fields before deployment completes.
|
|
69
72
|
*
|
|
70
73
|
* The target stack must be decorated with @Deploy and imported before this call.
|
|
74
|
+
* An optional region parameter can be supplied for multi-region configurations.
|
|
71
75
|
*
|
|
72
76
|
* @example
|
|
73
77
|
* class DNSStack extends Stack {
|
|
74
|
-
* private infra = Stack.from(InfraStack);
|
|
78
|
+
* private infra = Stack.from(InfraStack, REGION.US_EAST_1);
|
|
75
79
|
* dns = DO.Domain("example.com").pointer("app", this.infra.app.ip);
|
|
76
80
|
* }
|
|
77
81
|
*/
|
|
78
|
-
static from(cls) {
|
|
79
|
-
const
|
|
82
|
+
static from(cls, region) {
|
|
83
|
+
const key = region ? `${cls.name}:${region}` : cls;
|
|
84
|
+
const instance = _registry.get(key);
|
|
80
85
|
if (!instance)
|
|
81
|
-
throw new Error(`Stack "${cls.name}" is not registered. Make sure it is decorated with @Deploy and its module is imported before referencing it.`);
|
|
86
|
+
throw new Error(`Stack "${cls.name}" ${region ? `for region "${region}" ` : ""}is not registered. Make sure it is decorated with @Deploy and its module is imported before referencing it.`);
|
|
82
87
|
return instance;
|
|
83
88
|
}
|
|
84
89
|
async deploy() {
|
|
85
90
|
console.log(`\nšļø Deploying Stack: ${this.constructor.name}`);
|
|
91
|
+
// Stack-level beforeDeploy hook
|
|
92
|
+
if (typeof this.beforeDeploy === "function") {
|
|
93
|
+
console.log(` ā” Running Stack-level beforeDeploy hook...`);
|
|
94
|
+
await this.beforeDeploy();
|
|
95
|
+
}
|
|
86
96
|
const props = Object.getOwnPropertyNames(this);
|
|
87
97
|
const outputs = {};
|
|
88
98
|
for (const prop of props) {
|
|
@@ -92,16 +102,39 @@ export class Stack {
|
|
|
92
102
|
const isDestroyed = Reflect.getMetadata("destroy", this, prop);
|
|
93
103
|
if (isProtected)
|
|
94
104
|
resource.protect();
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
105
|
+
const forceConfigCheck = Reflect.getMetadata("forceConfigCheck", this, prop);
|
|
106
|
+
if (forceConfigCheck && typeof resource.forceConfigCheck === "function") {
|
|
107
|
+
resource.forceConfigCheck();
|
|
108
|
+
}
|
|
109
|
+
let res;
|
|
110
|
+
if (isDestroyed) {
|
|
111
|
+
await resource._runBeforeDestroy();
|
|
112
|
+
res = await resource.destroy();
|
|
113
|
+
await resource._runAfterDestroy(res);
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
await resource._runBeforeDeploy();
|
|
117
|
+
res = await resource.deploy();
|
|
118
|
+
await resource._runAfterDeploy(res);
|
|
119
|
+
}
|
|
120
|
+
outputs[prop] = res;
|
|
98
121
|
}
|
|
99
122
|
}
|
|
100
123
|
printOutputs(this.constructor.name, outputs);
|
|
124
|
+
// Stack-level afterDeploy hook
|
|
125
|
+
if (typeof this.afterDeploy === "function") {
|
|
126
|
+
console.log(` ā” Running Stack-level afterDeploy hook...`);
|
|
127
|
+
await this.afterDeploy(outputs);
|
|
128
|
+
}
|
|
101
129
|
return outputs;
|
|
102
130
|
}
|
|
103
131
|
async destroy() {
|
|
104
132
|
console.log(`\nš„ Tearing down Stack: ${this.constructor.name}`);
|
|
133
|
+
// Stack-level beforeDestroy hook
|
|
134
|
+
if (typeof this.beforeDestroy === "function") {
|
|
135
|
+
console.log(` ā” Running Stack-level beforeDestroy hook...`);
|
|
136
|
+
await this.beforeDestroy();
|
|
137
|
+
}
|
|
105
138
|
const props = Object.getOwnPropertyNames(this).reverse();
|
|
106
139
|
const outputs = {};
|
|
107
140
|
for (const prop of props) {
|
|
@@ -111,10 +144,18 @@ export class Stack {
|
|
|
111
144
|
console.log(` š Skipping protected resource "${prop}"`);
|
|
112
145
|
continue;
|
|
113
146
|
}
|
|
114
|
-
|
|
147
|
+
await resource._runBeforeDestroy();
|
|
148
|
+
const res = await resource.destroy();
|
|
149
|
+
await resource._runAfterDestroy(res);
|
|
150
|
+
outputs[prop] = res;
|
|
115
151
|
}
|
|
116
152
|
}
|
|
117
153
|
printOutputs(this.constructor.name, outputs);
|
|
154
|
+
// Stack-level afterDestroy hook
|
|
155
|
+
if (typeof this.afterDestroy === "function") {
|
|
156
|
+
console.log(` ā” Running Stack-level afterDestroy hook...`);
|
|
157
|
+
await this.afterDestroy(outputs);
|
|
158
|
+
}
|
|
118
159
|
return outputs;
|
|
119
160
|
}
|
|
120
161
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -2,4 +2,6 @@ export * from "./core/stack.js";
|
|
|
2
2
|
export * from "./core/decorators.js";
|
|
3
3
|
export * from "./core/checker.js";
|
|
4
4
|
export * from "./core/resource.js";
|
|
5
|
+
export { Secret } from "./core/secret.js";
|
|
5
6
|
export * as INVENTORY_TYPES from "./types/inventory.js";
|
|
7
|
+
export { SLACK, DISCORD } from "./core/hooks.js";
|
package/dist/index.js
CHANGED
|
@@ -2,4 +2,6 @@ export * from "./core/stack.js";
|
|
|
2
2
|
export * from "./core/decorators.js";
|
|
3
3
|
export * from "./core/checker.js";
|
|
4
4
|
export * from "./core/resource.js";
|
|
5
|
+
export { Secret } from "./core/secret.js";
|
|
5
6
|
export * as INVENTORY_TYPES from "./types/inventory.js";
|
|
7
|
+
export { SLACK, DISCORD } from "./core/hooks.js";
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { BaseBuilder } from "../../core/resource.js";
|
|
2
|
+
import { Output } from "../../core/output.js";
|
|
3
|
+
export declare class EC2VMBuilder extends BaseBuilder {
|
|
4
|
+
readonly out: {
|
|
5
|
+
ip: Output<string>;
|
|
6
|
+
id: Output<string>;
|
|
7
|
+
};
|
|
8
|
+
private _instanceType;
|
|
9
|
+
private _ami;
|
|
10
|
+
private _keyName?;
|
|
11
|
+
private _subnetId?;
|
|
12
|
+
private _securityGroupIds?;
|
|
13
|
+
private _userData?;
|
|
14
|
+
private _sshPrivateKeyPath?;
|
|
15
|
+
private _provision;
|
|
16
|
+
private _forceConfigCheck;
|
|
17
|
+
private resolvedInstanceId?;
|
|
18
|
+
private resolvedIp?;
|
|
19
|
+
constructor(name: string);
|
|
20
|
+
instanceType(type: string): this;
|
|
21
|
+
ami(amiId: string): this;
|
|
22
|
+
keyName(name: string): this;
|
|
23
|
+
subnetId(id: string): this;
|
|
24
|
+
securityGroupIds(ids: string[]): this;
|
|
25
|
+
userData(data: string): this;
|
|
26
|
+
sshPrivateKey(path: string): this;
|
|
27
|
+
provision(...playbookPaths: (string | string[])[]): this;
|
|
28
|
+
forceConfigCheck(): this;
|
|
29
|
+
protected checkPort(ip: string, port: number): Promise<boolean>;
|
|
30
|
+
protected runProvisioner(ip: string, script: string): Promise<void>;
|
|
31
|
+
private discoverVM;
|
|
32
|
+
deploy(): Promise<{
|
|
33
|
+
name: string;
|
|
34
|
+
id: string;
|
|
35
|
+
ip?: undefined;
|
|
36
|
+
} | {
|
|
37
|
+
name: string;
|
|
38
|
+
id: string | undefined;
|
|
39
|
+
ip: string | undefined;
|
|
40
|
+
} | null>;
|
|
41
|
+
destroy(): Promise<{
|
|
42
|
+
destroyed: boolean;
|
|
43
|
+
} | {
|
|
44
|
+
destroyed: string;
|
|
45
|
+
}>;
|
|
46
|
+
}
|
|
47
|
+
export declare function parseAwsTagsForProvision(tags?: any[]): Record<string, string>;
|
|
48
|
+
export declare function mergeAwsTagsForProvision(metadata: Record<string, string>): string;
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { DescribeInstancesCommand, RunInstancesCommand, TerminateInstancesCommand, StopInstancesCommand, StartInstancesCommand, ModifyInstanceAttributeCommand, CreateTagsCommand, } from "@aws-sdk/client-ec2";
|
|
2
|
+
import { BaseBuilder } from "../../core/resource.js";
|
|
3
|
+
import { Output } from "../../core/output.js";
|
|
4
|
+
import { getEC2Client } from "./api.js";
|
|
5
|
+
import { checkPort, runProvisioner } from "../../core/provisioner.js";
|
|
6
|
+
import { getFileHash } from "../proxmox/hash.js";
|
|
7
|
+
export class EC2VMBuilder extends BaseBuilder {
|
|
8
|
+
out = {
|
|
9
|
+
ip: new Output(),
|
|
10
|
+
id: new Output(),
|
|
11
|
+
};
|
|
12
|
+
_instanceType = "t3.micro";
|
|
13
|
+
_ami = "ami-0c55b159cbfafe1f0"; // Default standard Ubuntu 22.04 LTS in us-east-1
|
|
14
|
+
_keyName;
|
|
15
|
+
_subnetId;
|
|
16
|
+
_securityGroupIds;
|
|
17
|
+
_userData;
|
|
18
|
+
_sshPrivateKeyPath;
|
|
19
|
+
_provision = [];
|
|
20
|
+
_forceConfigCheck = false;
|
|
21
|
+
resolvedInstanceId;
|
|
22
|
+
resolvedIp;
|
|
23
|
+
constructor(name) {
|
|
24
|
+
super(name);
|
|
25
|
+
this.discoveryPromise = this.discoverVM();
|
|
26
|
+
}
|
|
27
|
+
instanceType(type) {
|
|
28
|
+
this._instanceType = type;
|
|
29
|
+
return this;
|
|
30
|
+
}
|
|
31
|
+
ami(amiId) {
|
|
32
|
+
this._ami = amiId;
|
|
33
|
+
return this;
|
|
34
|
+
}
|
|
35
|
+
keyName(name) {
|
|
36
|
+
this._keyName = name;
|
|
37
|
+
return this;
|
|
38
|
+
}
|
|
39
|
+
subnetId(id) {
|
|
40
|
+
this._subnetId = id;
|
|
41
|
+
return this;
|
|
42
|
+
}
|
|
43
|
+
securityGroupIds(ids) {
|
|
44
|
+
this._securityGroupIds = ids;
|
|
45
|
+
return this;
|
|
46
|
+
}
|
|
47
|
+
userData(data) {
|
|
48
|
+
this._userData = data;
|
|
49
|
+
return this;
|
|
50
|
+
}
|
|
51
|
+
sshPrivateKey(path) {
|
|
52
|
+
this._sshPrivateKeyPath = path;
|
|
53
|
+
return this;
|
|
54
|
+
}
|
|
55
|
+
provision(...playbookPaths) {
|
|
56
|
+
this._provision.push(...playbookPaths.flat());
|
|
57
|
+
return this;
|
|
58
|
+
}
|
|
59
|
+
forceConfigCheck() {
|
|
60
|
+
this._forceConfigCheck = true;
|
|
61
|
+
return this;
|
|
62
|
+
}
|
|
63
|
+
async checkPort(ip, port) {
|
|
64
|
+
return checkPort(ip, port);
|
|
65
|
+
}
|
|
66
|
+
async runProvisioner(ip, script) {
|
|
67
|
+
if (!this._sshPrivateKeyPath) {
|
|
68
|
+
throw new Error(`[EC2VMBuilder:${this.name}] sshPrivateKey(path) is required to run playbook provisioning.`);
|
|
69
|
+
}
|
|
70
|
+
// Default user is 'ubuntu' for standard Ubuntu images on AWS EC2
|
|
71
|
+
return runProvisioner(ip, "ubuntu", this._sshPrivateKeyPath, script);
|
|
72
|
+
}
|
|
73
|
+
async discoverVM() {
|
|
74
|
+
try {
|
|
75
|
+
const client = getEC2Client();
|
|
76
|
+
const result = await client.send(new DescribeInstancesCommand({
|
|
77
|
+
Filters: [
|
|
78
|
+
{ Name: "tag:Name", Values: [this.name] },
|
|
79
|
+
{
|
|
80
|
+
Name: "instance-state-name",
|
|
81
|
+
Values: ["pending", "running", "stopping", "stopped"],
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
}));
|
|
85
|
+
const reservation = result.Reservations?.[0];
|
|
86
|
+
const instance = reservation?.Instances?.[0];
|
|
87
|
+
if (instance) {
|
|
88
|
+
this.resolvedInstanceId = instance.InstanceId;
|
|
89
|
+
this.resolvedIp = instance.PublicIpAddress ?? instance.PrivateIpAddress ?? undefined;
|
|
90
|
+
if (instance.InstanceId)
|
|
91
|
+
this.out.id.resolve(instance.InstanceId);
|
|
92
|
+
if (this.resolvedIp)
|
|
93
|
+
this.out.ip.resolve(this.resolvedIp);
|
|
94
|
+
}
|
|
95
|
+
return instance ?? null;
|
|
96
|
+
}
|
|
97
|
+
catch (e) {
|
|
98
|
+
if (e.name === "CredentialsProviderError" ||
|
|
99
|
+
e.message?.includes("credentials not configured")) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
throw e;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
async deploy() {
|
|
106
|
+
const dryRun = this.isDryRunActive();
|
|
107
|
+
const existing = await this.discoveryPromise;
|
|
108
|
+
const client = getEC2Client();
|
|
109
|
+
const hasChanges = existing ? existing.InstanceType !== this._instanceType : true;
|
|
110
|
+
if (await this.checkProtection(hasChanges))
|
|
111
|
+
return null;
|
|
112
|
+
// Parse applied playbooks metadata from EC2 Tags
|
|
113
|
+
const appliedHashes = parseAwsTagsForProvision(existing?.Tags);
|
|
114
|
+
const declaredPlaybooksWithHashes = this._provision.map((p) => {
|
|
115
|
+
const baseName = p.split("/").pop() ?? p;
|
|
116
|
+
const slug = baseName.toLowerCase().replace(/[^a-z0-9_-]/g, "-");
|
|
117
|
+
return { path: p, slug, hash: getFileHash(p) };
|
|
118
|
+
});
|
|
119
|
+
const playbooksToRun = this._forceConfigCheck
|
|
120
|
+
? declaredPlaybooksWithHashes
|
|
121
|
+
: declaredPlaybooksWithHashes.filter((p) => {
|
|
122
|
+
const appliedHash = appliedHashes[p.slug];
|
|
123
|
+
return !appliedHash || appliedHash !== p.hash;
|
|
124
|
+
});
|
|
125
|
+
const playbookRunRequired = playbooksToRun.length > 0;
|
|
126
|
+
if (dryRun) {
|
|
127
|
+
console.log(`\nš [DRY RUN] AWS EC2 VM "${this.name}"...`);
|
|
128
|
+
if (!existing) {
|
|
129
|
+
console.log(` š Plan: Create EC2 Instance "${this.name}" (${this._instanceType} from AMI ${this._ami})`);
|
|
130
|
+
if (this._provision.length > 0) {
|
|
131
|
+
console.log(` āā Provision: ${this._provision.join(", ")}`);
|
|
132
|
+
}
|
|
133
|
+
this.out.id.resolve("PENDING");
|
|
134
|
+
this.out.ip.resolve("0.0.0.0");
|
|
135
|
+
}
|
|
136
|
+
else if (hasChanges || playbookRunRequired) {
|
|
137
|
+
if (hasChanges) {
|
|
138
|
+
console.log(` š Plan: Stop, Resize, and Start EC2 VM ${this.name} (${existing.InstanceType} ā ${this._instanceType})`);
|
|
139
|
+
}
|
|
140
|
+
if (playbookRunRequired) {
|
|
141
|
+
console.log(` š [PLAN] Run ${playbooksToRun.length} playbook changes on existing EC2 VM:`);
|
|
142
|
+
for (const p of playbooksToRun) {
|
|
143
|
+
console.log(` āā Playbook: ${p.path} (hash: ${p.hash})`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
console.log(` ā
AWS EC2 VM "${this.name}" is up to date.`);
|
|
149
|
+
}
|
|
150
|
+
return { name: this.name, id: this.resolvedInstanceId ?? "PENDING" };
|
|
151
|
+
}
|
|
152
|
+
console.log(`\nā³ Finalizing AWS EC2 VM "${this.name}"...`);
|
|
153
|
+
if (!existing) {
|
|
154
|
+
const initialHashes = {};
|
|
155
|
+
for (const p of declaredPlaybooksWithHashes) {
|
|
156
|
+
initialHashes[p.slug] = p.hash;
|
|
157
|
+
}
|
|
158
|
+
const initialMetadataVal = mergeAwsTagsForProvision(initialHashes);
|
|
159
|
+
console.log(`š Creating AWS EC2 VM Instance "${this.name}"...`);
|
|
160
|
+
const result = await client.send(new RunInstancesCommand({
|
|
161
|
+
ImageId: this._ami,
|
|
162
|
+
InstanceType: this._instanceType,
|
|
163
|
+
MinCount: 1,
|
|
164
|
+
MaxCount: 1,
|
|
165
|
+
KeyName: this._keyName,
|
|
166
|
+
SubnetId: this._subnetId,
|
|
167
|
+
SecurityGroupIds: this._securityGroupIds,
|
|
168
|
+
UserData: this._userData ? Buffer.from(this._userData).toString("base64") : undefined,
|
|
169
|
+
TagSpecifications: [
|
|
170
|
+
{
|
|
171
|
+
ResourceType: "instance",
|
|
172
|
+
Tags: [
|
|
173
|
+
{ Key: "Name", Value: this.name },
|
|
174
|
+
...(initialMetadataVal ? [{ Key: "puls-provision", Value: initialMetadataVal }] : []),
|
|
175
|
+
],
|
|
176
|
+
},
|
|
177
|
+
],
|
|
178
|
+
}));
|
|
179
|
+
const instanceId = result.Instances?.[0]?.InstanceId;
|
|
180
|
+
if (!instanceId)
|
|
181
|
+
throw new Error("Failed to retrieve instance ID from RunInstancesCommand");
|
|
182
|
+
this.resolvedInstanceId = instanceId;
|
|
183
|
+
this.out.id.resolve(instanceId);
|
|
184
|
+
// Poll until instance is running and has an IP address
|
|
185
|
+
await this.waitFor(`AWS EC2 VM "${this.name}" to start running`, async () => {
|
|
186
|
+
const current = await this.discoverVM();
|
|
187
|
+
return current && current.State?.Name === "running" && !!this.resolvedIp;
|
|
188
|
+
}, { intervalMs: 10_000, timeoutMs: 300_000 });
|
|
189
|
+
console.log(`š AWS EC2 VM "${this.name}" is now running.`);
|
|
190
|
+
if (this._provision.length > 0) {
|
|
191
|
+
const activeIp = this.resolvedIp ?? "0.0.0.0";
|
|
192
|
+
if (activeIp === "0.0.0.0") {
|
|
193
|
+
throw new Error(`Failed to resolve IP for new AWS EC2 VM "${this.name}" to run playbooks`);
|
|
194
|
+
}
|
|
195
|
+
await this.waitFor(`SSH on ${activeIp} to be ready`, () => this.checkPort(activeIp, 22), { intervalMs: 10_000, timeoutMs: 300_000 });
|
|
196
|
+
for (const playbook of this._provision) {
|
|
197
|
+
await this.runProvisioner(activeIp, playbook);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
const instanceId = existing.InstanceId;
|
|
203
|
+
this.resolvedInstanceId = instanceId;
|
|
204
|
+
if (hasChanges) {
|
|
205
|
+
console.log(`⨠Resizing AWS EC2 VM ${this.name} (${existing.InstanceType} ā ${this._instanceType})...`);
|
|
206
|
+
console.log(` š Stopping EC2 VM to perform resize...`);
|
|
207
|
+
await client.send(new StopInstancesCommand({ InstanceIds: [instanceId] }));
|
|
208
|
+
await this.waitFor(`VM "${this.name}" to stop`, async () => {
|
|
209
|
+
const current = await this.discoverVM();
|
|
210
|
+
return current && current.State?.Name === "stopped";
|
|
211
|
+
}, { intervalMs: 10_000, timeoutMs: 300_000 });
|
|
212
|
+
// Perform resize
|
|
213
|
+
await client.send(new ModifyInstanceAttributeCommand({
|
|
214
|
+
InstanceId: instanceId,
|
|
215
|
+
InstanceType: { Value: this._instanceType },
|
|
216
|
+
}));
|
|
217
|
+
// Restart VM
|
|
218
|
+
console.log(` š Restarting EC2 VM...`);
|
|
219
|
+
await client.send(new StartInstancesCommand({ InstanceIds: [instanceId] }));
|
|
220
|
+
await this.waitFor(`VM "${this.name}" to restart`, async () => {
|
|
221
|
+
const current = await this.discoverVM();
|
|
222
|
+
return current && current.State?.Name === "running" && !!this.resolvedIp;
|
|
223
|
+
}, { intervalMs: 10_000, timeoutMs: 300_000 });
|
|
224
|
+
console.log(` ā
AWS EC2 VM resized and restarted successfully.`);
|
|
225
|
+
}
|
|
226
|
+
if (playbookRunRequired) {
|
|
227
|
+
console.log(` š Running ${playbooksToRun.length} playbook changes on AWS EC2 VM...`);
|
|
228
|
+
const activeIp = this.resolvedIp ?? "0.0.0.0";
|
|
229
|
+
if (activeIp === "0.0.0.0") {
|
|
230
|
+
throw new Error(`Failed to resolve IP for AWS EC2 VM "${this.name}" to run playbooks`);
|
|
231
|
+
}
|
|
232
|
+
await this.waitFor(`SSH on ${activeIp} to be ready`, () => this.checkPort(activeIp, 22), { intervalMs: 10_000, timeoutMs: 300_000 });
|
|
233
|
+
for (const p of playbooksToRun) {
|
|
234
|
+
await this.runProvisioner(activeIp, p.path);
|
|
235
|
+
appliedHashes[p.slug] = p.hash;
|
|
236
|
+
}
|
|
237
|
+
// Update EC2 Tags with new hashes
|
|
238
|
+
const newValue = mergeAwsTagsForProvision(appliedHashes);
|
|
239
|
+
await client.send(new CreateTagsCommand({
|
|
240
|
+
Resources: [instanceId],
|
|
241
|
+
Tags: [{ Key: "puls-provision", Value: newValue }],
|
|
242
|
+
}));
|
|
243
|
+
console.log(` ā
Playbooks applied successfully and EC2 Tags updated.`);
|
|
244
|
+
}
|
|
245
|
+
if (!hasChanges && !playbookRunRequired) {
|
|
246
|
+
console.log(` ā
AWS EC2 VM "${this.name}" is up to date.`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
await this.deploySidecars();
|
|
250
|
+
return {
|
|
251
|
+
name: this.name,
|
|
252
|
+
id: this.resolvedInstanceId,
|
|
253
|
+
ip: this.resolvedIp,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
async destroy() {
|
|
257
|
+
const dryRun = this.isDryRunActive();
|
|
258
|
+
const existing = await this.discoveryPromise;
|
|
259
|
+
const client = getEC2Client();
|
|
260
|
+
console.log(`\nšļø Destroying AWS EC2 VM "${this.name}"...`);
|
|
261
|
+
if (!existing) {
|
|
262
|
+
console.log(` ā AWS EC2 VM "${this.name}" not found`);
|
|
263
|
+
return { destroyed: false };
|
|
264
|
+
}
|
|
265
|
+
if (dryRun) {
|
|
266
|
+
console.log(` š [PLAN] Delete AWS EC2 VM "${this.name}" (id=${existing.InstanceId})`);
|
|
267
|
+
return { destroyed: this.name };
|
|
268
|
+
}
|
|
269
|
+
console.log(` š Terminating AWS EC2 VM "${this.name}" (id=${existing.InstanceId})...`);
|
|
270
|
+
await client.send(new TerminateInstancesCommand({ InstanceIds: [existing.InstanceId] }));
|
|
271
|
+
console.log(` šļø Removed AWS EC2 VM "${this.name}"`);
|
|
272
|
+
await this.destroySidecars();
|
|
273
|
+
return { destroyed: this.name };
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
export function parseAwsTagsForProvision(tags) {
|
|
277
|
+
const tag = (tags ?? []).find((t) => t.Key === "puls-provision");
|
|
278
|
+
if (!tag?.Value)
|
|
279
|
+
return {};
|
|
280
|
+
const record = {};
|
|
281
|
+
const entries = tag.Value.split(",");
|
|
282
|
+
for (const entry of entries) {
|
|
283
|
+
const parts = entry.trim().split("=");
|
|
284
|
+
if (parts.length === 2) {
|
|
285
|
+
const [name, hash] = parts;
|
|
286
|
+
if (name && hash) {
|
|
287
|
+
record[name.trim()] = hash.trim();
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return record;
|
|
292
|
+
}
|
|
293
|
+
export function mergeAwsTagsForProvision(metadata) {
|
|
294
|
+
return Object.entries(metadata)
|
|
295
|
+
.map(([name, hash]) => `${name}=${hash}`)
|
|
296
|
+
.join(",");
|
|
297
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|