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.
- package/dist/core/checker.js +71 -0
- package/dist/core/config.d.ts +4 -0
- package/dist/core/config.js +11 -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/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 +7 -0
- package/dist/core/resource.js +10 -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 +308 -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/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 +1 -1
- 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 +2 -0
- package/dist/providers/proxmox/index.js +2 -0
- package/dist/providers/proxmox/template.d.ts +44 -0
- package/dist/providers/proxmox/template.js +349 -0
- package/dist/providers/proxmox/template.test.d.ts +1 -0
- package/dist/providers/proxmox/template.test.js +179 -0
- package/dist/providers/proxmox/vm.d.ts +3 -0
- package/dist/providers/proxmox/vm.js +40 -9
- package/dist/types/inventory.d.ts +44 -1
- 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
|
-
|
|
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
|
-
|
|
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";
|
|
@@ -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 {};
|