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.
- package/dist/core/checker.js +71 -0
- package/dist/core/config.d.ts +5 -0
- package/dist/core/config.js +12 -1
- package/dist/core/context.d.ts +14 -0
- package/dist/core/context.js +2 -0
- package/dist/core/decorators.d.ts +2 -0
- package/dist/core/decorators.js +8 -14
- package/dist/core/group.test.d.ts +1 -0
- package/dist/core/group.test.js +94 -0
- package/dist/core/parallel.test.d.ts +1 -0
- package/dist/core/parallel.test.js +215 -0
- package/dist/core/production.test.d.ts +1 -0
- package/dist/core/production.test.js +189 -0
- package/dist/core/provisioner.js +29 -11
- package/dist/core/resource.d.ts +8 -0
- package/dist/core/resource.js +45 -0
- package/dist/core/retry.d.ts +9 -0
- package/dist/core/retry.js +28 -0
- package/dist/core/retry.test.d.ts +1 -0
- package/dist/core/retry.test.js +66 -0
- package/dist/core/secret.d.ts +2 -1
- package/dist/core/secret.js +12 -2
- package/dist/core/stack.js +381 -75
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/providers/aws/api.js +97 -17
- package/dist/providers/aws/ec2.d.ts +3 -0
- package/dist/providers/aws/ec2.js +37 -3
- package/dist/providers/aws/ec2.test.js +5 -3
- package/dist/providers/aws/index.d.ts +2 -0
- package/dist/providers/aws/index.js +2 -0
- package/dist/providers/aws/secrets.js +20 -3
- package/dist/providers/aws/template.d.ts +34 -0
- package/dist/providers/aws/template.js +252 -0
- package/dist/providers/aws/template.test.d.ts +1 -0
- package/dist/providers/aws/template.test.js +208 -0
- package/dist/providers/do/api.d.ts +2 -0
- package/dist/providers/do/api.js +124 -26
- package/dist/providers/do/droplet.js +14 -0
- package/dist/providers/firebase/api.js +92 -29
- package/dist/providers/firebase/list.d.ts +2 -0
- package/dist/providers/firebase/list.js +25 -0
- package/dist/providers/gcp/api.js +88 -14
- package/dist/providers/gcp/index.d.ts +3 -1
- package/dist/providers/gcp/index.js +3 -1
- package/dist/providers/gcp/list.d.ts +2 -0
- package/dist/providers/gcp/list.js +55 -0
- package/dist/providers/gcp/secrets.js +21 -4
- package/dist/providers/gcp/template.d.ts +32 -0
- package/dist/providers/gcp/template.js +252 -0
- package/dist/providers/gcp/template.test.d.ts +1 -0
- package/dist/providers/gcp/template.test.js +227 -0
- package/dist/providers/gcp/vm.d.ts +3 -0
- package/dist/providers/gcp/vm.js +46 -3
- package/dist/providers/proxmox/api.d.ts +1 -0
- package/dist/providers/proxmox/api.js +72 -16
- package/dist/providers/proxmox/index.d.ts +3 -1
- package/dist/providers/proxmox/index.js +14 -1
- package/dist/providers/proxmox/template.d.ts +44 -0
- package/dist/providers/proxmox/template.js +350 -0
- package/dist/providers/proxmox/template.test.d.ts +1 -0
- package/dist/providers/proxmox/template.test.js +215 -0
- package/dist/providers/proxmox/vm.d.ts +3 -0
- package/dist/providers/proxmox/vm.js +43 -11
- package/dist/types/inventory.d.ts +44 -1
- 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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
96
|
-
assert.ok(logOutput.includes("
|
|
97
|
-
assert.ok(logOutput.includes("
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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 {};
|