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.
Files changed (80) hide show
  1. package/dist/core/config.d.ts +2 -0
  2. package/dist/core/decorators.d.ts +2 -0
  3. package/dist/core/decorators.js +48 -16
  4. package/dist/core/hooks.d.ts +21 -0
  5. package/dist/core/hooks.js +116 -0
  6. package/dist/core/hooks.test.d.ts +1 -0
  7. package/dist/core/hooks.test.js +194 -0
  8. package/dist/core/multiregion.test.d.ts +1 -0
  9. package/dist/core/multiregion.test.js +87 -0
  10. package/dist/core/output.d.ts +2 -0
  11. package/dist/core/output.js +9 -2
  12. package/dist/core/parser.d.ts +10 -0
  13. package/dist/core/parser.js +140 -0
  14. package/dist/core/parser.test.d.ts +1 -0
  15. package/dist/core/parser.test.js +117 -0
  16. package/dist/core/provisioner.d.ts +4 -0
  17. package/dist/core/provisioner.js +105 -0
  18. package/dist/core/resource.d.ts +16 -0
  19. package/dist/core/resource.js +44 -0
  20. package/dist/core/secret.d.ts +40 -0
  21. package/dist/core/secret.js +95 -0
  22. package/dist/core/secret.test.d.ts +1 -0
  23. package/dist/core/secret.test.js +166 -0
  24. package/dist/core/stack.d.ts +4 -3
  25. package/dist/core/stack.js +50 -9
  26. package/dist/index.d.ts +2 -0
  27. package/dist/index.js +2 -0
  28. package/dist/providers/aws/ec2.d.ts +48 -0
  29. package/dist/providers/aws/ec2.js +297 -0
  30. package/dist/providers/aws/ec2.test.d.ts +1 -0
  31. package/dist/providers/aws/ec2.test.js +279 -0
  32. package/dist/providers/aws/index.d.ts +2 -0
  33. package/dist/providers/aws/index.js +2 -0
  34. package/dist/providers/aws/route53.d.ts +1 -0
  35. package/dist/providers/aws/route53.js +15 -2
  36. package/dist/providers/aws/route53.test.js +47 -0
  37. package/dist/providers/do/api.d.ts +1 -1
  38. package/dist/providers/do/api.js +2 -1
  39. package/dist/providers/do/app.d.ts +26 -0
  40. package/dist/providers/do/app.js +124 -0
  41. package/dist/providers/do/app.test.d.ts +1 -0
  42. package/dist/providers/do/app.test.js +268 -0
  43. package/dist/providers/do/database.d.ts +44 -0
  44. package/dist/providers/do/database.js +208 -0
  45. package/dist/providers/do/database.test.d.ts +1 -0
  46. package/dist/providers/do/database.test.js +293 -0
  47. package/dist/providers/do/domain.d.ts +2 -0
  48. package/dist/providers/do/domain.js +30 -0
  49. package/dist/providers/do/domain.test.js +49 -0
  50. package/dist/providers/do/droplet.d.ts +9 -0
  51. package/dist/providers/do/droplet.js +132 -8
  52. package/dist/providers/do/droplet.test.js +228 -1
  53. package/dist/providers/do/firewall.d.ts +2 -1
  54. package/dist/providers/do/firewall.js +23 -9
  55. package/dist/providers/do/firewall.test.js +54 -0
  56. package/dist/providers/do/index.d.ts +11 -0
  57. package/dist/providers/do/index.js +8 -0
  58. package/dist/providers/do/spaces.d.ts +27 -0
  59. package/dist/providers/do/spaces.js +142 -0
  60. package/dist/providers/do/spaces.test.d.ts +1 -0
  61. package/dist/providers/do/spaces.test.js +180 -0
  62. package/dist/providers/do/spaces_api.d.ts +2 -0
  63. package/dist/providers/do/spaces_api.js +20 -0
  64. package/dist/providers/do/vpc.d.ts +30 -0
  65. package/dist/providers/do/vpc.js +128 -0
  66. package/dist/providers/do/vpc.test.d.ts +1 -0
  67. package/dist/providers/do/vpc.test.js +258 -0
  68. package/dist/providers/gcp/clouddns.d.ts +1 -0
  69. package/dist/providers/gcp/clouddns.js +15 -2
  70. package/dist/providers/gcp/clouddns.test.js +45 -0
  71. package/dist/providers/gcp/index.d.ts +3 -1
  72. package/dist/providers/gcp/index.js +3 -1
  73. package/dist/providers/gcp/vm.d.ts +45 -0
  74. package/dist/providers/gcp/vm.js +332 -0
  75. package/dist/providers/gcp/vm.test.d.ts +1 -0
  76. package/dist/providers/gcp/vm.test.js +321 -0
  77. package/dist/providers/proxmox/vm.d.ts +4 -4
  78. package/dist/providers/proxmox/vm.js +17 -93
  79. package/dist/providers/proxmox/vm.test.js +77 -0
  80. 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
+ });
@@ -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
  }
@@ -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 instance = _registry.get(cls);
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
- outputs[prop] = isDestroyed
96
- ? await resource.destroy()
97
- : await resource.deploy();
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
- outputs[prop] = await resource.destroy();
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 {};