puls-dev 0.2.8 โ†’ 0.3.0

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 (66) hide show
  1. package/dist/core/checker.js +71 -0
  2. package/dist/core/config.d.ts +5 -0
  3. package/dist/core/config.js +12 -1
  4. package/dist/core/context.d.ts +14 -0
  5. package/dist/core/context.js +2 -0
  6. package/dist/core/decorators.d.ts +2 -0
  7. package/dist/core/decorators.js +8 -14
  8. package/dist/core/group.test.d.ts +1 -0
  9. package/dist/core/group.test.js +94 -0
  10. package/dist/core/parallel.test.d.ts +1 -0
  11. package/dist/core/parallel.test.js +215 -0
  12. package/dist/core/production.test.d.ts +1 -0
  13. package/dist/core/production.test.js +189 -0
  14. package/dist/core/provisioner.js +29 -11
  15. package/dist/core/resource.d.ts +8 -0
  16. package/dist/core/resource.js +45 -0
  17. package/dist/core/retry.d.ts +9 -0
  18. package/dist/core/retry.js +28 -0
  19. package/dist/core/retry.test.d.ts +1 -0
  20. package/dist/core/retry.test.js +66 -0
  21. package/dist/core/secret.d.ts +2 -1
  22. package/dist/core/secret.js +12 -2
  23. package/dist/core/stack.js +381 -75
  24. package/dist/index.d.ts +1 -0
  25. package/dist/index.js +1 -0
  26. package/dist/providers/aws/api.js +97 -17
  27. package/dist/providers/aws/ec2.d.ts +3 -0
  28. package/dist/providers/aws/ec2.js +37 -3
  29. package/dist/providers/aws/ec2.test.js +5 -3
  30. package/dist/providers/aws/index.d.ts +2 -0
  31. package/dist/providers/aws/index.js +2 -0
  32. package/dist/providers/aws/secrets.js +20 -3
  33. package/dist/providers/aws/template.d.ts +34 -0
  34. package/dist/providers/aws/template.js +252 -0
  35. package/dist/providers/aws/template.test.d.ts +1 -0
  36. package/dist/providers/aws/template.test.js +208 -0
  37. package/dist/providers/do/api.d.ts +2 -0
  38. package/dist/providers/do/api.js +124 -26
  39. package/dist/providers/do/droplet.js +14 -0
  40. package/dist/providers/firebase/api.js +92 -29
  41. package/dist/providers/firebase/list.d.ts +2 -0
  42. package/dist/providers/firebase/list.js +25 -0
  43. package/dist/providers/gcp/api.js +88 -14
  44. package/dist/providers/gcp/index.d.ts +3 -1
  45. package/dist/providers/gcp/index.js +3 -1
  46. package/dist/providers/gcp/list.d.ts +2 -0
  47. package/dist/providers/gcp/list.js +55 -0
  48. package/dist/providers/gcp/secrets.js +21 -4
  49. package/dist/providers/gcp/template.d.ts +32 -0
  50. package/dist/providers/gcp/template.js +252 -0
  51. package/dist/providers/gcp/template.test.d.ts +1 -0
  52. package/dist/providers/gcp/template.test.js +227 -0
  53. package/dist/providers/gcp/vm.d.ts +3 -0
  54. package/dist/providers/gcp/vm.js +46 -3
  55. package/dist/providers/proxmox/api.d.ts +1 -0
  56. package/dist/providers/proxmox/api.js +72 -16
  57. package/dist/providers/proxmox/index.d.ts +3 -1
  58. package/dist/providers/proxmox/index.js +14 -1
  59. package/dist/providers/proxmox/template.d.ts +44 -0
  60. package/dist/providers/proxmox/template.js +350 -0
  61. package/dist/providers/proxmox/template.test.d.ts +1 -0
  62. package/dist/providers/proxmox/template.test.js +215 -0
  63. package/dist/providers/proxmox/vm.d.ts +3 -0
  64. package/dist/providers/proxmox/vm.js +43 -11
  65. package/dist/types/inventory.d.ts +44 -1
  66. package/package.json +2 -2
@@ -4,6 +4,7 @@ import { Output } from "../../core/output.js";
4
4
  import { getEC2Client } from "./api.js";
5
5
  import { checkPort, runProvisioner } from "../../core/provisioner.js";
6
6
  import { getFileHash } from "../proxmox/hash.js";
7
+ import { resourceContextStorage } from "../../core/context.js";
7
8
  export class EC2VMBuilder extends BaseBuilder {
8
9
  out = {
9
10
  ip: new Output(),
@@ -11,6 +12,7 @@ export class EC2VMBuilder extends BaseBuilder {
11
12
  };
12
13
  _instanceType = "t3.micro";
13
14
  _ami = "ami-0c55b159cbfafe1f0"; // Default standard Ubuntu 22.04 LTS in us-east-1
15
+ _templateSource;
14
16
  _keyName;
15
17
  _subnetId;
16
18
  _securityGroupIds;
@@ -32,6 +34,11 @@ export class EC2VMBuilder extends BaseBuilder {
32
34
  this._ami = amiId;
33
35
  return this;
34
36
  }
37
+ fromTemplate(template) {
38
+ this._templateSource = template;
39
+ this.dependsOn(template);
40
+ return this;
41
+ }
35
42
  keyName(name) {
36
43
  this._keyName = name;
37
44
  return this;
@@ -126,9 +133,19 @@ export class EC2VMBuilder extends BaseBuilder {
126
133
  if (dryRun) {
127
134
  console.log(`\n๐Ÿ” [DRY RUN] AWS EC2 VM "${this.name}"...`);
128
135
  if (!existing) {
129
- console.log(` ๐Ÿ“ Plan: Create EC2 Instance "${this.name}" (${this._instanceType} from AMI ${this._ami})`);
136
+ const sourceLabel = this._templateSource ? `Template: ${this._templateSource.name}` : `AMI ${this._ami}`;
137
+ console.log(` ๐Ÿ“ Plan: Create AWS EC2 Instance`);
138
+ const details = [
139
+ `Name: ${this.name}`,
140
+ `Instance Type: ${this._instanceType}`,
141
+ `Source: ${sourceLabel}`,
142
+ ];
130
143
  if (this._provision.length > 0) {
131
- console.log(` โ””โ”€ Provision: ${this._provision.join(", ")}`);
144
+ details.push(`Provision: ${this._provision.join(", ")}`);
145
+ }
146
+ for (let i = 0; i < details.length; i++) {
147
+ const prefix = i === details.length - 1 ? " โ””โ”€ " : " โ”œโ”€ ";
148
+ console.log(`${prefix}${details[i]}`);
132
149
  }
133
150
  this.out.id.resolve("PENDING");
134
151
  this.out.ip.resolve("0.0.0.0");
@@ -156,9 +173,13 @@ export class EC2VMBuilder extends BaseBuilder {
156
173
  initialHashes[p.slug] = p.hash;
157
174
  }
158
175
  const initialMetadataVal = mergeAwsTagsForProvision(initialHashes);
176
+ let activeAmi = this._ami;
177
+ if (this._templateSource) {
178
+ activeAmi = await this._templateSource.out.amiId.get();
179
+ }
159
180
  console.log(`๐Ÿš€ Creating AWS EC2 VM Instance "${this.name}"...`);
160
181
  const result = await client.send(new RunInstancesCommand({
161
- ImageId: this._ami,
182
+ ImageId: activeAmi,
162
183
  InstanceType: this._instanceType,
163
184
  MinCount: 1,
164
185
  MaxCount: 1,
@@ -246,6 +267,19 @@ export class EC2VMBuilder extends BaseBuilder {
246
267
  console.log(` โœ… AWS EC2 VM "${this.name}" is up to date.`);
247
268
  }
248
269
  }
270
+ const context = resourceContextStorage.getStore();
271
+ if (context && context.hosts) {
272
+ const activeIp = this.resolvedIp ?? "0.0.0.0";
273
+ if (!context.hosts.some(h => h.name === this.name)) {
274
+ context.hosts.push({
275
+ name: this.name,
276
+ ip: activeIp,
277
+ user: "ubuntu",
278
+ sshKey: this._sshPrivateKeyPath,
279
+ provider: "aws"
280
+ });
281
+ }
282
+ }
249
283
  await this.deploySidecars();
250
284
  return {
251
285
  name: this.name,
@@ -92,9 +92,11 @@ describe("EC2VMBuilder Unit Tests", () => {
92
92
  assert.strictEqual(await vm.out.id.get(), "PENDING");
93
93
  assert.strictEqual(await vm.out.ip.get(), "0.0.0.0");
94
94
  assert.ok(logOutput.includes("๐Ÿ” [DRY RUN]"));
95
- assert.ok(logOutput.includes("Plan: Create EC2 Instance \"dryrun-vm\""));
96
- assert.ok(logOutput.includes("t3.medium from AMI ami-test123"));
97
- assert.ok(logOutput.includes("playbooks/nginx.yaml"));
95
+ assert.ok(logOutput.includes("Plan: Create AWS EC2 Instance"));
96
+ assert.ok(logOutput.includes("Name: dryrun-vm"));
97
+ assert.ok(logOutput.includes("Instance Type: t3.medium"));
98
+ assert.ok(logOutput.includes("Source: AMI ami-test123"));
99
+ assert.ok(logOutput.includes("Provision: playbooks/nginx.yaml"));
98
100
  }
99
101
  finally {
100
102
  console.log = originalLog;
@@ -11,6 +11,7 @@ import { IAMRoleBuilder, IAMPolicyBuilder } from "./iam.js";
11
11
  import { SNSTopicBuilder } from "./sns.js";
12
12
  import { CloudWatchAlarmBuilder } from "./cloudwatch.js";
13
13
  import { EC2VMBuilder } from "./ec2.js";
14
+ import { EC2TemplateBuilder } from "./template.js";
14
15
  export declare const AWS: {
15
16
  init: (opts: {
16
17
  region: string;
@@ -29,5 +30,6 @@ export declare const AWS: {
29
30
  SNS: (name: string) => SNSTopicBuilder;
30
31
  Alarm: (name: string) => CloudWatchAlarmBuilder;
31
32
  EC2: (name: string) => EC2VMBuilder;
33
+ Template: (name: string) => EC2TemplateBuilder;
32
34
  };
33
35
  export * from "../../types/aws.js";
@@ -12,6 +12,7 @@ import { IAMRoleBuilder, IAMPolicyBuilder } from "./iam.js";
12
12
  import { SNSTopicBuilder } from "./sns.js";
13
13
  import { CloudWatchAlarmBuilder } from "./cloudwatch.js";
14
14
  import { EC2VMBuilder } from "./ec2.js";
15
+ import { EC2TemplateBuilder } from "./template.js";
15
16
  export const AWS = {
16
17
  init: (opts) => {
17
18
  Config.set({
@@ -35,5 +36,6 @@ export const AWS = {
35
36
  SNS: (name) => new SNSTopicBuilder(name),
36
37
  Alarm: (name) => new CloudWatchAlarmBuilder(name),
37
38
  EC2: (name) => new EC2VMBuilder(name),
39
+ Template: (name) => new EC2TemplateBuilder(name),
38
40
  };
39
41
  export * from "../../types/aws.js";
@@ -1,6 +1,7 @@
1
1
  import { GetSecretValueCommand, CreateSecretCommand, PutSecretValueCommand, DeleteSecretCommand, } from "@aws-sdk/client-secrets-manager";
2
2
  import { BaseBuilder } from "../../core/resource.js";
3
3
  import { getSecretsClient } from "./api.js";
4
+ import { resolvedSecrets } from "../../core/secret.js";
4
5
  export class SecretsBuilder extends BaseBuilder {
5
6
  _value;
6
7
  _description;
@@ -16,6 +17,9 @@ export class SecretsBuilder extends BaseBuilder {
16
17
  try {
17
18
  const result = await getSecretsClient().send(new GetSecretValueCommand({ SecretId: secretId }));
18
19
  this.resolvedValue = result.SecretString ?? null;
20
+ if (this.resolvedValue && this.resolvedValue.length >= 3) {
21
+ resolvedSecrets.add(this.resolvedValue);
22
+ }
19
23
  this.resolvedArn = result.ARN ?? null;
20
24
  return result;
21
25
  }
@@ -38,10 +42,17 @@ export class SecretsBuilder extends BaseBuilder {
38
42
  }
39
43
  plainText(v) {
40
44
  this._value = v;
45
+ if (v && v.length >= 3) {
46
+ resolvedSecrets.add(v);
47
+ }
41
48
  return this;
42
49
  }
43
50
  keyValue(obj) {
44
- this._value = JSON.stringify(obj);
51
+ const v = JSON.stringify(obj);
52
+ this._value = v;
53
+ if (v && v.length >= 3) {
54
+ resolvedSecrets.add(v);
55
+ }
45
56
  return this;
46
57
  }
47
58
  description(d) {
@@ -61,7 +72,7 @@ export class SecretsBuilder extends BaseBuilder {
61
72
  if (existing) {
62
73
  console.log(` โœ… Secret "${this.name}" exists`);
63
74
  if (this.resolvedValue !== null)
64
- console.log(` ๐Ÿ’ฌ Value: ${this.resolvedValue}`);
75
+ console.log(` ๐Ÿ’ฌ Value: ********`);
65
76
  if (this._value)
66
77
  console.log(` ๐Ÿ“ [PLAN] Update secret value`);
67
78
  }
@@ -88,18 +99,24 @@ export class SecretsBuilder extends BaseBuilder {
88
99
  }));
89
100
  this.resolvedArn = result.ARN ?? null;
90
101
  this.resolvedValue = this._value;
102
+ if (this._value && this._value.length >= 3) {
103
+ resolvedSecrets.add(this._value);
104
+ }
91
105
  console.log(`๐Ÿš€ Created secret "${this.name}"`);
92
106
  }
93
107
  else {
94
108
  console.log(` โœ… Secret "${this.name}" exists`);
95
109
  if (this.resolvedValue !== null)
96
- console.log(` ๐Ÿ’ฌ Value: ${this.resolvedValue}`);
110
+ console.log(` ๐Ÿ’ฌ Value: ********`);
97
111
  if (this._value && this._value !== this.resolvedValue) {
98
112
  await client.send(new PutSecretValueCommand({
99
113
  SecretId: this.name,
100
114
  SecretString: this._value,
101
115
  }));
102
116
  this.resolvedValue = this._value;
117
+ if (this._value && this._value.length >= 3) {
118
+ resolvedSecrets.add(this._value);
119
+ }
103
120
  console.log(` โœ… Updated secret value`);
104
121
  }
105
122
  }
@@ -0,0 +1,34 @@
1
+ import { BaseBuilder } from "../../core/resource.js";
2
+ import { Output } from "../../core/output.js";
3
+ export declare class EC2TemplateBuilder extends BaseBuilder {
4
+ readonly out: {
5
+ amiId: Output<string>;
6
+ };
7
+ private _baseImage;
8
+ private _instanceType;
9
+ private _subnetId?;
10
+ private _securityGroupIds?;
11
+ private _sshPrivateKeyPath?;
12
+ private _keyName?;
13
+ private _provision;
14
+ constructor(name: string);
15
+ baseImage(amiId: string): this;
16
+ instanceType(type: string): this;
17
+ subnetId(id: string): this;
18
+ securityGroupIds(ids: string[]): this;
19
+ sshPrivateKey(path: string): this;
20
+ keyName(name: string): this;
21
+ provision(...playbookPaths: (string | string[])[]): this;
22
+ private discoverAMI;
23
+ protected checkPort(ip: string, port: number): Promise<boolean>;
24
+ protected runProvisioner(ip: string, script: string): Promise<void>;
25
+ deploy(): Promise<{
26
+ name: string;
27
+ amiId: any;
28
+ }>;
29
+ destroy(): Promise<{
30
+ destroyed: boolean;
31
+ } | {
32
+ destroyed: string;
33
+ }>;
34
+ }
@@ -0,0 +1,252 @@
1
+ import { DescribeImagesCommand, DeregisterImageCommand, DeleteSnapshotCommand, RunInstancesCommand, DescribeInstancesCommand, StopInstancesCommand, CreateImageCommand, CreateTagsCommand, TerminateInstancesCommand, } 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
+ import { parseAwsTagsForProvision, mergeAwsTagsForProvision } from "./ec2.js";
8
+ export class EC2TemplateBuilder extends BaseBuilder {
9
+ out = {
10
+ amiId: new Output(),
11
+ };
12
+ _baseImage = "ami-0c55b159cbfafe1f0"; // Default standard Ubuntu 22.04 LTS
13
+ _instanceType = "t3.micro";
14
+ _subnetId;
15
+ _securityGroupIds;
16
+ _sshPrivateKeyPath;
17
+ _keyName;
18
+ _provision = [];
19
+ constructor(name) {
20
+ super(name);
21
+ this.discoveryPromise = this.discoverAMI();
22
+ }
23
+ baseImage(amiId) {
24
+ this._baseImage = amiId;
25
+ return this;
26
+ }
27
+ instanceType(type) {
28
+ this._instanceType = type;
29
+ return this;
30
+ }
31
+ subnetId(id) {
32
+ this._subnetId = id;
33
+ return this;
34
+ }
35
+ securityGroupIds(ids) {
36
+ this._securityGroupIds = ids;
37
+ return this;
38
+ }
39
+ sshPrivateKey(path) {
40
+ this._sshPrivateKeyPath = path;
41
+ return this;
42
+ }
43
+ keyName(name) {
44
+ this._keyName = name;
45
+ return this;
46
+ }
47
+ provision(...playbookPaths) {
48
+ this._provision.push(...playbookPaths.flat());
49
+ return this;
50
+ }
51
+ async discoverAMI() {
52
+ try {
53
+ const client = getEC2Client();
54
+ const res = await client.send(new DescribeImagesCommand({
55
+ Filters: [
56
+ { Name: "name", Values: [this.name] },
57
+ { Name: "state", Values: ["available", "pending"] },
58
+ ],
59
+ Owners: ["self"],
60
+ }));
61
+ return res.Images?.[0] ?? null;
62
+ }
63
+ catch (e) {
64
+ if (e.name === "CredentialsProviderError" ||
65
+ e.message?.includes("credentials not configured")) {
66
+ return null;
67
+ }
68
+ throw e;
69
+ }
70
+ }
71
+ async checkPort(ip, port) {
72
+ return checkPort(ip, port);
73
+ }
74
+ async runProvisioner(ip, script) {
75
+ if (!this._sshPrivateKeyPath) {
76
+ throw new Error(`[EC2TemplateBuilder:${this.name}] sshPrivateKey(path) is required to run playbook provisioning.`);
77
+ }
78
+ return runProvisioner(ip, "ubuntu", this._sshPrivateKeyPath, script);
79
+ }
80
+ async deploy() {
81
+ const dryRun = this.isDryRunActive();
82
+ const existing = await this.discoveryPromise;
83
+ const client = getEC2Client();
84
+ const declaredPlaybooksWithHashes = this._provision.map((p) => {
85
+ const baseName = p.split("/").pop() ?? p;
86
+ const slug = baseName.toLowerCase().replace(/[^a-z0-9_-]/g, "-");
87
+ return { path: p, slug, hash: getFileHash(p) };
88
+ });
89
+ if (existing) {
90
+ this.out.amiId.resolve(existing.ImageId);
91
+ // Check if playbooks differ
92
+ const appliedHashes = parseAwsTagsForProvision(existing.Tags);
93
+ const hasChanges = declaredPlaybooksWithHashes.some((p) => {
94
+ const appliedHash = appliedHashes[p.slug];
95
+ return !appliedHash || appliedHash !== p.hash;
96
+ });
97
+ if (!hasChanges) {
98
+ console.log(`\n๐Ÿ” AWS AMI Template "${this.name}"...`);
99
+ console.log(` โœ… Custom AMI "${this.name}" already exists and matches defined state.`);
100
+ return { name: this.name, amiId: existing.ImageId };
101
+ }
102
+ console.log(`\nโณ Finalizing AWS AMI Template "${this.name}"...`);
103
+ console.log(` ๐Ÿ”„ Template playbook hashes changed. Deregistering old custom AMI...`);
104
+ if (dryRun) {
105
+ console.log(` ๐Ÿ“ [PLAN] Would deregister AMI "${this.name}" (${existing.ImageId}) and rebuild.`);
106
+ return { name: this.name, amiId: "PENDING" };
107
+ }
108
+ else {
109
+ await client.send(new DeregisterImageCommand({ ImageId: existing.ImageId }));
110
+ const mappings = existing.BlockDeviceMappings ?? [];
111
+ for (const mapping of mappings) {
112
+ const snapshotId = mapping.Ebs?.SnapshotId;
113
+ if (snapshotId) {
114
+ try {
115
+ await client.send(new DeleteSnapshotCommand({ SnapshotId: snapshotId }));
116
+ }
117
+ catch (err) {
118
+ console.warn(` โš ๏ธ Could not delete snapshot ${snapshotId}: ${err.message}`);
119
+ }
120
+ }
121
+ }
122
+ }
123
+ }
124
+ console.log(`\nโณ Finalizing AWS AMI Template "${this.name}"...`);
125
+ const initialHashes = {};
126
+ for (const p of declaredPlaybooksWithHashes) {
127
+ initialHashes[p.slug] = p.hash;
128
+ }
129
+ const initialMetadataVal = mergeAwsTagsForProvision(initialHashes);
130
+ if (dryRun) {
131
+ console.log(` ๐Ÿ“ [PLAN] Bake AWS AMI Template "${this.name}"`);
132
+ console.log(` โ””โ”€ Base Image: ${this._baseImage} Instance Type: ${this._instanceType}`);
133
+ if (this._provision.length > 0) {
134
+ console.log(` โ””โ”€ Provision: ${this._provision.join(", ")}`);
135
+ }
136
+ this.out.amiId.resolve("PENDING");
137
+ return { name: this.name, amiId: "PENDING" };
138
+ }
139
+ // Spawn temporary instance to bake
140
+ console.log(`๐Ÿš€ Spawning temporary VM "puls-bake-temp-${this.name}" to bake custom AMI...`);
141
+ const runRes = await client.send(new RunInstancesCommand({
142
+ ImageId: this._baseImage,
143
+ InstanceType: this._instanceType,
144
+ MinCount: 1,
145
+ MaxCount: 1,
146
+ KeyName: this._keyName,
147
+ SubnetId: this._subnetId,
148
+ SecurityGroupIds: this._securityGroupIds,
149
+ TagSpecifications: [
150
+ {
151
+ ResourceType: "instance",
152
+ Tags: [
153
+ { Key: "Name", Value: `puls-bake-temp-${this.name}` },
154
+ ],
155
+ },
156
+ ],
157
+ }));
158
+ const instanceId = runRes.Instances?.[0]?.InstanceId;
159
+ if (!instanceId)
160
+ throw new Error("Failed to retrieve instance ID from RunInstancesCommand");
161
+ // Wait until running and IP is resolved
162
+ let resolvedIp;
163
+ await this.waitFor(`temporary instance "${instanceId}" to start running`, async () => {
164
+ const result = await client.send(new DescribeInstancesCommand({ InstanceIds: [instanceId] }));
165
+ const inst = result.Reservations?.[0]?.Instances?.[0];
166
+ if (inst && inst.State?.Name === "running") {
167
+ resolvedIp = inst.PublicIpAddress ?? inst.PrivateIpAddress ?? undefined;
168
+ return !!resolvedIp;
169
+ }
170
+ return false;
171
+ }, { intervalMs: 10_000, timeoutMs: 300_000 });
172
+ if (!resolvedIp) {
173
+ throw new Error(`Failed to resolve IP for temporary instance "${instanceId}"`);
174
+ }
175
+ // Provision the instance
176
+ if (this._provision.length > 0) {
177
+ await this.waitFor(`SSH on ${resolvedIp} to be ready`, () => this.checkPort(resolvedIp, 22), { intervalMs: 10_000, timeoutMs: 300_000 });
178
+ for (const playbook of this._provision) {
179
+ await this.runProvisioner(resolvedIp, playbook);
180
+ }
181
+ }
182
+ // Stop temporary instance
183
+ console.log(` ๐Ÿ›‘ Stopping temporary instance "${instanceId}"...`);
184
+ await client.send(new StopInstancesCommand({ InstanceIds: [instanceId] }));
185
+ await this.waitFor(`temporary instance to stop`, async () => {
186
+ const result = await client.send(new DescribeInstancesCommand({ InstanceIds: [instanceId] }));
187
+ const inst = result.Reservations?.[0]?.Instances?.[0];
188
+ return inst?.State?.Name === "stopped";
189
+ }, { intervalMs: 10_000, timeoutMs: 300_000 });
190
+ // Bake AMI
191
+ console.log(` ๐Ÿ’พ Baking custom AMI "${this.name}" from instance "${instanceId}"...`);
192
+ const amiRes = await client.send(new CreateImageCommand({
193
+ InstanceId: instanceId,
194
+ Name: this.name,
195
+ Description: `Puls Golden Image Template - ${this.name}`,
196
+ NoReboot: true,
197
+ }));
198
+ const newAmiId = amiRes.ImageId;
199
+ if (!newAmiId)
200
+ throw new Error("Failed to bake custom AMI from instance");
201
+ // Wait until AMI is available
202
+ await this.waitFor(`custom AMI "${newAmiId}" to become available`, async () => {
203
+ const result = await client.send(new DescribeImagesCommand({ ImageIds: [newAmiId] }));
204
+ const img = result.Images?.[0];
205
+ return img?.State === "available";
206
+ }, { intervalMs: 15_000, timeoutMs: 600_000 });
207
+ // Tag the AMI
208
+ await client.send(new CreateTagsCommand({
209
+ Resources: [newAmiId],
210
+ Tags: [
211
+ { Key: "Name", Value: this.name },
212
+ ...(initialMetadataVal ? [{ Key: "puls-provision", Value: initialMetadataVal }] : []),
213
+ ],
214
+ }));
215
+ console.log(` โœ… Custom AMI "${this.name}" (${newAmiId}) baked successfully.`);
216
+ // Clean up temporary instance
217
+ console.log(` ๐Ÿงน Terminating temporary provisioning instance "${instanceId}"...`);
218
+ await client.send(new TerminateInstancesCommand({ InstanceIds: [instanceId] }));
219
+ this.out.amiId.resolve(newAmiId);
220
+ return { name: this.name, amiId: newAmiId };
221
+ }
222
+ async destroy() {
223
+ const dryRun = this.isDryRunActive();
224
+ const existing = await this.discoveryPromise;
225
+ const client = getEC2Client();
226
+ console.log(`\n๐Ÿ—‘๏ธ Destroying AWS AMI Template "${this.name}"...`);
227
+ if (!existing) {
228
+ console.log(` โ”€ AWS AMI Template "${this.name}" not found`);
229
+ return { destroyed: false };
230
+ }
231
+ if (dryRun) {
232
+ console.log(` ๐Ÿ“ [PLAN] Deregister AWS AMI "${this.name}" (id=${existing.ImageId})`);
233
+ return { destroyed: this.name };
234
+ }
235
+ console.log(` ๐Ÿ”„ Deregistering AWS AMI "${this.name}" (id=${existing.ImageId})...`);
236
+ await client.send(new DeregisterImageCommand({ ImageId: existing.ImageId }));
237
+ const mappings = existing.BlockDeviceMappings ?? [];
238
+ for (const mapping of mappings) {
239
+ const snapshotId = mapping.Ebs?.SnapshotId;
240
+ if (snapshotId) {
241
+ try {
242
+ await client.send(new DeleteSnapshotCommand({ SnapshotId: snapshotId }));
243
+ }
244
+ catch {
245
+ // Ignore safely
246
+ }
247
+ }
248
+ }
249
+ console.log(` ๐Ÿ—‘๏ธ Removed AWS AMI Template "${this.name}"`);
250
+ return { destroyed: this.name };
251
+ }
252
+ }
@@ -0,0 +1 @@
1
+ export {};