puls-dev 0.2.8 → 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.
Files changed (63) hide show
  1. package/dist/core/checker.js +71 -0
  2. package/dist/core/config.d.ts +4 -0
  3. package/dist/core/config.js +11 -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/parallel.test.d.ts +1 -0
  9. package/dist/core/parallel.test.js +215 -0
  10. package/dist/core/production.test.d.ts +1 -0
  11. package/dist/core/production.test.js +189 -0
  12. package/dist/core/provisioner.js +29 -11
  13. package/dist/core/resource.d.ts +7 -0
  14. package/dist/core/resource.js +10 -0
  15. package/dist/core/retry.d.ts +9 -0
  16. package/dist/core/retry.js +28 -0
  17. package/dist/core/retry.test.d.ts +1 -0
  18. package/dist/core/retry.test.js +66 -0
  19. package/dist/core/secret.d.ts +2 -1
  20. package/dist/core/secret.js +12 -2
  21. package/dist/core/stack.js +308 -75
  22. package/dist/index.d.ts +1 -0
  23. package/dist/index.js +1 -0
  24. package/dist/providers/aws/api.js +97 -17
  25. package/dist/providers/aws/ec2.d.ts +3 -0
  26. package/dist/providers/aws/ec2.js +37 -3
  27. package/dist/providers/aws/ec2.test.js +5 -3
  28. package/dist/providers/aws/index.d.ts +2 -0
  29. package/dist/providers/aws/index.js +2 -0
  30. package/dist/providers/aws/template.d.ts +34 -0
  31. package/dist/providers/aws/template.js +252 -0
  32. package/dist/providers/aws/template.test.d.ts +1 -0
  33. package/dist/providers/aws/template.test.js +208 -0
  34. package/dist/providers/do/api.d.ts +2 -0
  35. package/dist/providers/do/api.js +124 -26
  36. package/dist/providers/do/droplet.js +14 -0
  37. package/dist/providers/firebase/api.js +92 -29
  38. package/dist/providers/firebase/list.d.ts +2 -0
  39. package/dist/providers/firebase/list.js +25 -0
  40. package/dist/providers/gcp/api.js +88 -14
  41. package/dist/providers/gcp/index.d.ts +3 -1
  42. package/dist/providers/gcp/index.js +3 -1
  43. package/dist/providers/gcp/list.d.ts +2 -0
  44. package/dist/providers/gcp/list.js +55 -0
  45. package/dist/providers/gcp/secrets.js +1 -1
  46. package/dist/providers/gcp/template.d.ts +32 -0
  47. package/dist/providers/gcp/template.js +252 -0
  48. package/dist/providers/gcp/template.test.d.ts +1 -0
  49. package/dist/providers/gcp/template.test.js +227 -0
  50. package/dist/providers/gcp/vm.d.ts +3 -0
  51. package/dist/providers/gcp/vm.js +46 -3
  52. package/dist/providers/proxmox/api.d.ts +1 -0
  53. package/dist/providers/proxmox/api.js +72 -16
  54. package/dist/providers/proxmox/index.d.ts +2 -0
  55. package/dist/providers/proxmox/index.js +2 -0
  56. package/dist/providers/proxmox/template.d.ts +44 -0
  57. package/dist/providers/proxmox/template.js +349 -0
  58. package/dist/providers/proxmox/template.test.d.ts +1 -0
  59. package/dist/providers/proxmox/template.test.js +179 -0
  60. package/dist/providers/proxmox/vm.d.ts +3 -0
  61. package/dist/providers/proxmox/vm.js +40 -9
  62. package/dist/types/inventory.d.ts +44 -1
  63. package/package.json +1 -1
@@ -15,26 +15,106 @@ import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager";
15
15
  import { CloudWatchClient } from "@aws-sdk/client-cloudwatch";
16
16
  import { SNSClient } from "@aws-sdk/client-sns";
17
17
  import { Config } from "../../core/config.js";
18
+ import { withRetry } from "../../core/retry.js";
19
+ import { resourceContextStorage } from "../../core/context.js";
18
20
  function getRegion() {
19
21
  const region = Config.get().providers.aws?.region;
20
- if (!region)
22
+ if (!region) {
23
+ if (Config.isOfflineMode() || Config.isGlobalDryRun()) {
24
+ return "us-east-1";
25
+ }
21
26
  throw new Error('AWS region not configured. Call AWS.init({ region: "..." })');
27
+ }
22
28
  return region;
23
29
  }
24
- export const getS3Client = (region) => new S3Client({ region: region ?? getRegion() });
30
+ function createAwsOfflineMock(command) {
31
+ const name = command.constructor.name;
32
+ if (name.includes("RunInstances")) {
33
+ return {
34
+ Instances: [
35
+ {
36
+ InstanceId: "i-mock1234567890abcdef0",
37
+ State: { Name: "running" },
38
+ PublicIpAddress: "54.210.12.34",
39
+ PrivateIpAddress: "10.0.1.10",
40
+ }
41
+ ]
42
+ };
43
+ }
44
+ if (name.includes("CreateVpc")) {
45
+ return { Vpc: { VpcId: "vpc-mock123456" } };
46
+ }
47
+ if (name.includes("CreateSubnet")) {
48
+ return { Subnet: { SubnetId: "subnet-mock123456" } };
49
+ }
50
+ if (name.includes("CreateSecurityGroup")) {
51
+ return { GroupId: "sg-mock123456" };
52
+ }
53
+ if (name.includes("CreateBucket")) {
54
+ return { Location: "/mock-bucket" };
55
+ }
56
+ if (name.includes("CreateKeyPair")) {
57
+ return { KeyMaterial: "mock-private-key", KeyName: "mock-key" };
58
+ }
59
+ const mockProxy = new Proxy({}, {
60
+ get(target, prop) {
61
+ if (prop === "then")
62
+ return undefined;
63
+ if (prop === "CertificateArn")
64
+ return "arn:aws:acm:us-east-1:123456789012:certificate/mock-cert-uuid";
65
+ if (prop === "HostedZoneId")
66
+ return "Z2FDTNDATAQYW2";
67
+ if (prop === "Id" || prop === "id")
68
+ return "mock-id-12345";
69
+ if (prop === "Arn" || prop === "arn")
70
+ return `arn:aws:mock:::resource/mock-id`;
71
+ if (prop === "Status" || prop === "status")
72
+ return "Active";
73
+ if (prop === "DNSName")
74
+ return "mock.cloudfront.net";
75
+ if (prop.endsWith("s"))
76
+ return [];
77
+ return `mock-${prop.toLowerCase()}`;
78
+ }
79
+ });
80
+ return mockProxy;
81
+ }
82
+ function wrapClient(client) {
83
+ const originalSend = client.send;
84
+ client.send = function (command, options) {
85
+ const context = resourceContextStorage.getStore();
86
+ const abortSignal = context?.abortSignal;
87
+ if (Config.isOfflineMode() || Config.isGlobalDryRun()) {
88
+ return Promise.resolve(createAwsOfflineMock(command));
89
+ }
90
+ const opts = abortSignal ? { abortSignal, ...options } : options;
91
+ return withRetry(() => originalSend.call(client, command, opts), {
92
+ retryable: (err) => {
93
+ const code = err.name || err.code;
94
+ const status = err.$metadata?.httpStatusCode;
95
+ return (code === "ThrottlingException" ||
96
+ code === "ProvisionedThroughputExceededException" ||
97
+ code === "RequestLimitExceeded" ||
98
+ (status && status >= 500));
99
+ }
100
+ });
101
+ };
102
+ return client;
103
+ }
104
+ export const getS3Client = (region) => wrapClient(new S3Client({ region: region ?? getRegion() }));
25
105
  // CloudFront, Route53, ACM, Route53 Domains, and IAM are all global - must use us-east-1
26
- export const getCFClient = () => new CloudFrontClient({ region: "us-east-1" });
27
- export const getR53Client = () => new Route53Client({ region: "us-east-1" });
28
- export const getR53DomainsClient = () => new Route53DomainsClient({ region: "us-east-1" });
29
- export const getACMClient = () => new ACMClient({ region: "us-east-1" });
30
- export const getIAMClient = () => new IAMClient({ region: "us-east-1" });
31
- export const getLambdaClient = (region) => new LambdaClient({ region: region ?? getRegion() });
32
- export const getAPIGWClient = (region) => new ApiGatewayV2Client({ region: region ?? getRegion() });
33
- export const getECSClient = (region) => new ECSClient({ region: region ?? getRegion() });
34
- export const getEC2Client = (region) => new EC2Client({ region: region ?? getRegion() });
35
- export const getCWLogsClient = (region) => new CloudWatchLogsClient({ region: region ?? getRegion() });
36
- export const getRDSClient = (region) => new RDSClient({ region: region ?? getRegion() });
37
- export const getSQSClient = (region) => new SQSClient({ region: region ?? getRegion() });
38
- export const getSecretsClient = (region) => new SecretsManagerClient({ region: region ?? getRegion() });
39
- export const getCWClient = (region) => new CloudWatchClient({ region: region ?? getRegion() });
40
- export const getSNSClient = (region) => new SNSClient({ region: region ?? getRegion() });
106
+ export const getCFClient = () => wrapClient(new CloudFrontClient({ region: "us-east-1" }));
107
+ export const getR53Client = () => wrapClient(new Route53Client({ region: "us-east-1" }));
108
+ export const getR53DomainsClient = () => wrapClient(new Route53DomainsClient({ region: "us-east-1" }));
109
+ export const getACMClient = () => wrapClient(new ACMClient({ region: "us-east-1" }));
110
+ export const getIAMClient = () => wrapClient(new IAMClient({ region: "us-east-1" }));
111
+ export const getLambdaClient = (region) => wrapClient(new LambdaClient({ region: region ?? getRegion() }));
112
+ export const getAPIGWClient = (region) => wrapClient(new ApiGatewayV2Client({ region: region ?? getRegion() }));
113
+ export const getECSClient = (region) => wrapClient(new ECSClient({ region: region ?? getRegion() }));
114
+ export const getEC2Client = (region) => wrapClient(new EC2Client({ region: region ?? getRegion() }));
115
+ export const getCWLogsClient = (region) => wrapClient(new CloudWatchLogsClient({ region: region ?? getRegion() }));
116
+ export const getRDSClient = (region) => wrapClient(new RDSClient({ region: region ?? getRegion() }));
117
+ export const getSQSClient = (region) => wrapClient(new SQSClient({ region: region ?? getRegion() }));
118
+ export const getSecretsClient = (region) => wrapClient(new SecretsManagerClient({ region: region ?? getRegion() }));
119
+ export const getCWClient = (region) => wrapClient(new CloudWatchClient({ region: region ?? getRegion() }));
120
+ export const getSNSClient = (region) => wrapClient(new SNSClient({ region: region ?? getRegion() }));
@@ -1,5 +1,6 @@
1
1
  import { BaseBuilder } from "../../core/resource.js";
2
2
  import { Output } from "../../core/output.js";
3
+ import { EC2TemplateBuilder } from "./template.js";
3
4
  export declare class EC2VMBuilder extends BaseBuilder {
4
5
  readonly out: {
5
6
  ip: Output<string>;
@@ -7,6 +8,7 @@ export declare class EC2VMBuilder extends BaseBuilder {
7
8
  };
8
9
  private _instanceType;
9
10
  private _ami;
11
+ private _templateSource?;
10
12
  private _keyName?;
11
13
  private _subnetId?;
12
14
  private _securityGroupIds?;
@@ -19,6 +21,7 @@ export declare class EC2VMBuilder extends BaseBuilder {
19
21
  constructor(name: string);
20
22
  instanceType(type: string): this;
21
23
  ami(amiId: string): this;
24
+ fromTemplate(template: EC2TemplateBuilder): this;
22
25
  keyName(name: string): this;
23
26
  subnetId(id: string): this;
24
27
  securityGroupIds(ids: string[]): this;
@@ -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";
@@ -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 {};