@itssergio91/sandman 0.2.0 → 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/README.md +54 -272
- package/dist/cli/commands/cli-contract.test.d.ts +2 -0
- package/dist/cli/commands/cli-contract.test.d.ts.map +1 -0
- package/dist/cli/commands/cli-contract.test.js +217 -0
- package/dist/cli/commands/cli-contract.test.js.map +1 -0
- package/dist/cli/commands/connect.d.ts +1 -0
- package/dist/cli/commands/connect.d.ts.map +1 -1
- package/dist/cli/commands/connect.js +67 -42
- package/dist/cli/commands/connect.js.map +1 -1
- package/dist/cli/commands/create.d.ts +7 -2
- package/dist/cli/commands/create.d.ts.map +1 -1
- package/dist/cli/commands/create.js +158 -46
- package/dist/cli/commands/create.js.map +1 -1
- package/dist/cli/commands/destroy.d.ts.map +1 -1
- package/dist/cli/commands/destroy.js +27 -23
- package/dist/cli/commands/destroy.js.map +1 -1
- package/dist/cli/commands/doctor.d.ts +8 -0
- package/dist/cli/commands/doctor.d.ts.map +1 -0
- package/dist/cli/commands/doctor.js +124 -0
- package/dist/cli/commands/doctor.js.map +1 -0
- package/dist/cli/commands/enable.d.ts +1 -2
- package/dist/cli/commands/enable.d.ts.map +1 -1
- package/dist/cli/commands/enable.js +75 -46
- package/dist/cli/commands/enable.js.map +1 -1
- package/dist/cli/commands/init.d.ts +2 -2
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +49 -17
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/list.d.ts.map +1 -1
- package/dist/cli/commands/list.js +25 -18
- package/dist/cli/commands/list.js.map +1 -1
- package/dist/cli/commands/providers.d.ts +6 -0
- package/dist/cli/commands/providers.d.ts.map +1 -0
- package/dist/cli/commands/providers.js +20 -0
- package/dist/cli/commands/providers.js.map +1 -0
- package/dist/cli/commands/status.d.ts.map +1 -1
- package/dist/cli/commands/status.js +88 -50
- package/dist/cli/commands/status.js.map +1 -1
- package/dist/cli/env-name.d.ts +7 -0
- package/dist/cli/env-name.d.ts.map +1 -0
- package/dist/cli/env-name.js +9 -0
- package/dist/cli/env-name.js.map +1 -0
- package/dist/cli/env-name.test.d.ts +2 -0
- package/dist/cli/env-name.test.d.ts.map +1 -0
- package/dist/cli/env-name.test.js +38 -0
- package/dist/cli/env-name.test.js.map +1 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +43 -11
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/output.d.ts +28 -0
- package/dist/cli/output.d.ts.map +1 -0
- package/dist/cli/output.js +60 -0
- package/dist/cli/output.js.map +1 -0
- package/dist/cli/output.test.d.ts +2 -0
- package/dist/cli/output.test.d.ts.map +1 -0
- package/dist/cli/output.test.js +78 -0
- package/dist/cli/output.test.js.map +1 -0
- package/dist/cli/reap.d.ts +11 -0
- package/dist/cli/reap.d.ts.map +1 -0
- package/dist/cli/reap.js +32 -0
- package/dist/cli/reap.js.map +1 -0
- package/dist/cli/secrets.d.ts +8 -0
- package/dist/cli/secrets.d.ts.map +1 -0
- package/dist/cli/secrets.js +30 -0
- package/dist/cli/secrets.js.map +1 -0
- package/dist/core/state-store.d.ts +15 -1
- package/dist/core/state-store.d.ts.map +1 -1
- package/dist/core/state-store.js +168 -33
- package/dist/core/state-store.js.map +1 -1
- package/dist/core/state-store.test.js +40 -0
- package/dist/core/state-store.test.js.map +1 -1
- package/dist/index.js +0 -0
- package/dist/providers/aws/adapter.d.ts +13 -2
- package/dist/providers/aws/adapter.d.ts.map +1 -1
- package/dist/providers/aws/adapter.js +402 -63
- package/dist/providers/aws/adapter.js.map +1 -1
- package/dist/providers/aws/adapter.test.js +300 -191
- package/dist/providers/aws/adapter.test.js.map +1 -1
- package/dist/providers/base.d.ts +1 -9
- package/dist/providers/base.d.ts.map +1 -1
- package/dist/providers/base.js.map +1 -1
- package/dist/providers/base.test.js +12 -2
- package/dist/providers/base.test.js.map +1 -1
- package/dist/providers/catalog.d.ts +15 -0
- package/dist/providers/catalog.d.ts.map +1 -0
- package/dist/providers/catalog.js +53 -0
- package/dist/providers/catalog.js.map +1 -0
- package/dist/providers/cloudflare/adapter.d.ts +5 -2
- package/dist/providers/cloudflare/adapter.d.ts.map +1 -1
- package/dist/providers/cloudflare/adapter.js +17 -5
- package/dist/providers/cloudflare/adapter.js.map +1 -1
- package/dist/providers/cloudflare/adapter.test.js +4 -4
- package/dist/providers/cloudflare/adapter.test.js.map +1 -1
- package/dist/providers/enable-result.d.ts +9 -0
- package/dist/providers/enable-result.d.ts.map +1 -0
- package/dist/providers/enable-result.js +26 -0
- package/dist/providers/enable-result.js.map +1 -0
- package/dist/providers/gcp/adapter.d.ts +9 -3
- package/dist/providers/gcp/adapter.d.ts.map +1 -1
- package/dist/providers/gcp/adapter.js +115 -20
- package/dist/providers/gcp/adapter.js.map +1 -1
- package/dist/providers/gcp/adapter.test.js +52 -0
- package/dist/providers/gcp/adapter.test.js.map +1 -1
- package/dist/providers/index.d.ts +5 -2
- package/dist/providers/index.d.ts.map +1 -1
- package/dist/providers/index.js +13 -0
- package/dist/providers/index.js.map +1 -1
- package/dist/providers/vercel/adapter.d.ts +5 -2
- package/dist/providers/vercel/adapter.d.ts.map +1 -1
- package/dist/providers/vercel/adapter.js +17 -5
- package/dist/providers/vercel/adapter.js.map +1 -1
- package/dist/providers/vercel/adapter.test.js +4 -4
- package/dist/providers/vercel/adapter.test.js.map +1 -1
- package/dist/types/index.d.ts +40 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +4 -0
- package/dist/types/index.js.map +1 -1
- package/dist/utils/cost-estimator.d.ts +2 -0
- package/dist/utils/cost-estimator.d.ts.map +1 -1
- package/dist/utils/cost-estimator.js +23 -0
- package/dist/utils/cost-estimator.js.map +1 -1
- package/dist/utils/cost-estimator.test.js +16 -1
- package/dist/utils/cost-estimator.test.js.map +1 -1
- package/dist/utils/logger.d.ts +8 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +12 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/utils/ttl.d.ts +13 -0
- package/dist/utils/ttl.d.ts.map +1 -0
- package/dist/utils/ttl.js +35 -0
- package/dist/utils/ttl.js.map +1 -0
- package/dist/utils/ttl.test.d.ts +2 -0
- package/dist/utils/ttl.test.d.ts.map +1 -0
- package/dist/utils/ttl.test.js +27 -0
- package/dist/utils/ttl.test.js.map +1 -0
- package/package.json +12 -5
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
import { enableResult } from "../enable-result.js";
|
|
2
|
+
import { logger } from "../../utils/logger.js";
|
|
1
3
|
export class AwsAdapter {
|
|
2
4
|
accountId = null;
|
|
5
|
+
callerArn = null;
|
|
3
6
|
region = "us-east-1";
|
|
7
|
+
regionExplicit = false;
|
|
4
8
|
async init() {
|
|
5
9
|
try {
|
|
6
10
|
const { STSClient, GetCallerIdentityCommand } = await import("@aws-sdk/client-sts");
|
|
@@ -8,7 +12,10 @@ export class AwsAdapter {
|
|
|
8
12
|
const command = new GetCallerIdentityCommand({});
|
|
9
13
|
const response = await client.send(command);
|
|
10
14
|
this.accountId = response.Account || null;
|
|
11
|
-
this.
|
|
15
|
+
this.callerArn = response.Arn || null;
|
|
16
|
+
if (!this.regionExplicit) {
|
|
17
|
+
this.region = process.env.AWS_DEFAULT_REGION || "us-east-1";
|
|
18
|
+
}
|
|
12
19
|
}
|
|
13
20
|
catch (error) {
|
|
14
21
|
if (error.name === "CredentialsProviderError") {
|
|
@@ -17,60 +24,124 @@ export class AwsAdapter {
|
|
|
17
24
|
throw error;
|
|
18
25
|
}
|
|
19
26
|
}
|
|
27
|
+
setRegion(region) {
|
|
28
|
+
this.region = region;
|
|
29
|
+
this.regionExplicit = true;
|
|
30
|
+
}
|
|
20
31
|
async createEnvironment(name) {
|
|
21
32
|
const bucketName = `sandman-${name}-${Date.now()}`;
|
|
22
33
|
const resources = {};
|
|
34
|
+
const warnings = [];
|
|
23
35
|
try {
|
|
24
|
-
// Create S3 bucket
|
|
25
36
|
const { S3Client, CreateBucketCommand } = await import("@aws-sdk/client-s3");
|
|
26
37
|
const s3Client = new S3Client({ region: this.region });
|
|
27
38
|
await s3Client.send(new CreateBucketCommand({
|
|
28
39
|
Bucket: bucketName,
|
|
29
40
|
}));
|
|
41
|
+
const { PutPublicAccessBlockCommand, PutBucketEncryptionCommand, PutBucketTaggingCommand, } = await import("@aws-sdk/client-s3");
|
|
42
|
+
await s3Client.send(new PutPublicAccessBlockCommand({
|
|
43
|
+
Bucket: bucketName,
|
|
44
|
+
PublicAccessBlockConfiguration: {
|
|
45
|
+
BlockPublicAcls: true,
|
|
46
|
+
IgnorePublicAcls: true,
|
|
47
|
+
BlockPublicPolicy: true,
|
|
48
|
+
RestrictPublicBuckets: true,
|
|
49
|
+
},
|
|
50
|
+
}));
|
|
51
|
+
await s3Client.send(new PutBucketEncryptionCommand({
|
|
52
|
+
Bucket: bucketName,
|
|
53
|
+
ServerSideEncryptionConfiguration: {
|
|
54
|
+
Rules: [
|
|
55
|
+
{
|
|
56
|
+
ApplyServerSideEncryptionByDefault: {
|
|
57
|
+
SSEAlgorithm: "AES256",
|
|
58
|
+
},
|
|
59
|
+
BucketKeyEnabled: true,
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
}));
|
|
64
|
+
try {
|
|
65
|
+
await s3Client.send(new PutBucketTaggingCommand({
|
|
66
|
+
Bucket: bucketName,
|
|
67
|
+
Tagging: {
|
|
68
|
+
TagSet: [
|
|
69
|
+
{ Key: "CreatedBy", Value: "sandman" },
|
|
70
|
+
{ Key: "Name", Value: `sandman-${name}` },
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
}));
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
logger.warn(`Could not tag bucket: ${error.message}`);
|
|
77
|
+
}
|
|
30
78
|
resources.bucketName = bucketName;
|
|
31
79
|
}
|
|
32
80
|
catch (error) {
|
|
33
|
-
if (error.name
|
|
34
|
-
|
|
81
|
+
if (error.name === "BucketAlreadyOwnedByYou") {
|
|
82
|
+
resources.bucketName = bucketName;
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
warnings.push(`bucket: ${error.message}`);
|
|
86
|
+
logger.warn(`Could not create bucket: ${error.message}`);
|
|
35
87
|
}
|
|
36
88
|
}
|
|
37
|
-
// Create VPC and networking
|
|
38
89
|
try {
|
|
39
90
|
const vpcResources = await this.createVpcResources(name);
|
|
40
|
-
Object.
|
|
91
|
+
for (const [key, value] of Object.entries(vpcResources)) {
|
|
92
|
+
if (value) {
|
|
93
|
+
resources[key] = value;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
41
96
|
}
|
|
42
97
|
catch (error) {
|
|
43
|
-
|
|
98
|
+
warnings.push(`vpc: ${error.message}`);
|
|
99
|
+
logger.warn(`Could not create VPC: ${error.message}`);
|
|
44
100
|
}
|
|
45
|
-
// Create IAM role
|
|
46
101
|
try {
|
|
47
102
|
const iamResources = await this.createIamResources(name);
|
|
48
|
-
Object.
|
|
103
|
+
for (const [key, value] of Object.entries(iamResources)) {
|
|
104
|
+
if (value) {
|
|
105
|
+
resources[key] = value;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
49
108
|
}
|
|
50
109
|
catch (error) {
|
|
51
|
-
|
|
110
|
+
warnings.push(`iam: ${error.message}`);
|
|
111
|
+
logger.warn(`Could not create IAM role: ${error.message}`);
|
|
52
112
|
}
|
|
53
|
-
// Create EC2 instance
|
|
54
113
|
try {
|
|
55
114
|
if (resources.vpcId && resources.subnetId && resources.securityGroupId) {
|
|
56
115
|
const instanceId = await this.createEc2Instance(name, resources.subnetId, resources.securityGroupId, resources.iamInstanceProfile);
|
|
57
|
-
|
|
116
|
+
if (instanceId) {
|
|
117
|
+
resources.instanceId = instanceId;
|
|
118
|
+
}
|
|
58
119
|
}
|
|
59
120
|
}
|
|
60
121
|
catch (error) {
|
|
61
|
-
|
|
122
|
+
warnings.push(`ec2: ${error.message}`);
|
|
123
|
+
logger.warn(`Could not create EC2 instance: ${error.message}`);
|
|
62
124
|
}
|
|
63
125
|
const now = new Date().toISOString();
|
|
126
|
+
const hasResources = Object.keys(resources).length > 0;
|
|
127
|
+
if (!hasResources) {
|
|
128
|
+
throw new Error(`Failed to create any AWS resources for "${name}"${warnings.length ? `: ${warnings.join("; ")}` : "."}`);
|
|
129
|
+
}
|
|
130
|
+
const complete = Boolean(resources.bucketName &&
|
|
131
|
+
resources.vpcId &&
|
|
132
|
+
resources.subnetId &&
|
|
133
|
+
resources.securityGroupId);
|
|
64
134
|
return {
|
|
65
135
|
name,
|
|
66
136
|
provider: "aws",
|
|
67
137
|
accountId: this.accountId || undefined,
|
|
68
138
|
region: this.region,
|
|
69
|
-
status: "active",
|
|
139
|
+
status: complete ? "active" : "failed",
|
|
70
140
|
services: [],
|
|
71
141
|
resources,
|
|
72
142
|
createdAt: now,
|
|
73
143
|
updatedAt: now,
|
|
144
|
+
error: complete ? undefined : warnings.join("; ") || "Partial AWS create",
|
|
74
145
|
};
|
|
75
146
|
}
|
|
76
147
|
async createVpcResources(name) {
|
|
@@ -171,16 +242,11 @@ export class AwsAdapter {
|
|
|
171
242
|
],
|
|
172
243
|
}));
|
|
173
244
|
resources.securityGroupId = sgResponse.GroupId || "";
|
|
174
|
-
//
|
|
245
|
+
// HTTP/HTTPS for demos. SSH is not opened to the internet; use SSM
|
|
246
|
+
// (AmazonSSMManagedInstanceCore is attached to the instance role).
|
|
175
247
|
await ec2Client.send(new AuthorizeSecurityGroupIngressCommand({
|
|
176
248
|
GroupId: resources.securityGroupId,
|
|
177
249
|
IpPermissions: [
|
|
178
|
-
{
|
|
179
|
-
IpProtocol: "tcp",
|
|
180
|
-
FromPort: 22,
|
|
181
|
-
ToPort: 22,
|
|
182
|
-
IpRanges: [{ CidrIp: "0.0.0.0/0", Description: "SSH" }],
|
|
183
|
-
},
|
|
184
250
|
{
|
|
185
251
|
IpProtocol: "tcp",
|
|
186
252
|
FromPort: 80,
|
|
@@ -236,7 +302,8 @@ export class AwsAdapter {
|
|
|
236
302
|
InstanceProfileName: instanceProfileName,
|
|
237
303
|
}));
|
|
238
304
|
resources.iamInstanceProfile =
|
|
239
|
-
profileResponse.InstanceProfile?.InstanceProfileName ||
|
|
305
|
+
profileResponse.InstanceProfile?.InstanceProfileName ||
|
|
306
|
+
instanceProfileName;
|
|
240
307
|
// Add role to instance profile
|
|
241
308
|
await iamClient.send(new AddRoleToInstanceProfileCommand({
|
|
242
309
|
InstanceProfileName: instanceProfileName,
|
|
@@ -247,8 +314,9 @@ export class AwsAdapter {
|
|
|
247
314
|
async createEc2Instance(name, subnetId, securityGroupId, iamInstanceProfile) {
|
|
248
315
|
const { EC2Client, RunInstancesCommand } = await import("@aws-sdk/client-ec2");
|
|
249
316
|
const ec2Client = new EC2Client({ region: this.region });
|
|
317
|
+
const imageId = await this.resolveAmi();
|
|
250
318
|
const instanceResponse = await ec2Client.send(new RunInstancesCommand({
|
|
251
|
-
ImageId:
|
|
319
|
+
ImageId: imageId,
|
|
252
320
|
InstanceType: "t2.micro",
|
|
253
321
|
MinCount: 1,
|
|
254
322
|
MaxCount: 1,
|
|
@@ -269,8 +337,72 @@ export class AwsAdapter {
|
|
|
269
337
|
}));
|
|
270
338
|
return instanceResponse.Instances?.[0]?.InstanceId || "";
|
|
271
339
|
}
|
|
340
|
+
static FALLBACK_AMI = "ami-0c7217cdde317cfec";
|
|
341
|
+
async resolveAmi() {
|
|
342
|
+
try {
|
|
343
|
+
const { SSMClient, GetParameterCommand } = await import("@aws-sdk/client-ssm");
|
|
344
|
+
const ssm = new SSMClient({ region: this.region });
|
|
345
|
+
const response = await ssm.send(new GetParameterCommand({
|
|
346
|
+
Name: "/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64",
|
|
347
|
+
}));
|
|
348
|
+
const ami = response.Parameter?.Value;
|
|
349
|
+
if (ami) {
|
|
350
|
+
return ami;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
catch (error) {
|
|
354
|
+
logger.warn(`Could not resolve latest AL2023 AMI via SSM (${error.message}); falling back to ${AwsAdapter.FALLBACK_AMI}`);
|
|
355
|
+
}
|
|
356
|
+
return AwsAdapter.FALLBACK_AMI;
|
|
357
|
+
}
|
|
272
358
|
async enableServices(env, services) {
|
|
273
|
-
|
|
359
|
+
const provisioned = [];
|
|
360
|
+
const localOnly = [];
|
|
361
|
+
const warnings = [];
|
|
362
|
+
const alreadyInCloud = {
|
|
363
|
+
s3: Boolean(env.resources.bucketName),
|
|
364
|
+
ec2: Boolean(env.resources.instanceId),
|
|
365
|
+
iam: Boolean(env.resources.iamRoleArn || env.resources.iamInstanceProfile),
|
|
366
|
+
};
|
|
367
|
+
for (const service of services) {
|
|
368
|
+
if (alreadyInCloud[service]) {
|
|
369
|
+
provisioned.push(service);
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
localOnly.push(service);
|
|
373
|
+
if (service === "lambda") {
|
|
374
|
+
warnings.push("lambda is not provisioned by sandman enable; recorded locally only.");
|
|
375
|
+
}
|
|
376
|
+
else if (service === "s3" || service === "ec2" || service === "iam") {
|
|
377
|
+
warnings.push(`${service} was not created by sandman create; recorded locally only.`);
|
|
378
|
+
}
|
|
379
|
+
else {
|
|
380
|
+
warnings.push(`${service} is recorded locally only and was not provisioned in AWS.`);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
logger.info(`AWS enable recorded ${services.join(", ")} (cloud: ${provisioned.join(", ") || "none"}; local-only: ${localOnly.join(", ") || "none"})`);
|
|
384
|
+
return enableResult({
|
|
385
|
+
recorded: services,
|
|
386
|
+
provisioned,
|
|
387
|
+
localOnly,
|
|
388
|
+
warnings,
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
async whoami() {
|
|
392
|
+
if (!this.accountId) {
|
|
393
|
+
try {
|
|
394
|
+
await this.init();
|
|
395
|
+
}
|
|
396
|
+
catch {
|
|
397
|
+
// Leave identity empty; doctor will surface AUTH_REQUIRED.
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return {
|
|
401
|
+
provider: "aws",
|
|
402
|
+
accountId: this.accountId,
|
|
403
|
+
region: this.region,
|
|
404
|
+
callerArn: this.callerArn,
|
|
405
|
+
};
|
|
274
406
|
}
|
|
275
407
|
async connect(env) {
|
|
276
408
|
const result = {
|
|
@@ -297,47 +429,35 @@ export class AwsAdapter {
|
|
|
297
429
|
return result;
|
|
298
430
|
}
|
|
299
431
|
async destroyEnvironment(env) {
|
|
432
|
+
await this.assertCallerAccount(env);
|
|
433
|
+
await this.assertSandmanOwnership(env);
|
|
300
434
|
const { EC2Client } = await import("@aws-sdk/client-ec2");
|
|
301
|
-
const ec2Client = new EC2Client({ region: env.region });
|
|
435
|
+
const ec2Client = new EC2Client({ region: env.region || this.region });
|
|
436
|
+
const failures = [];
|
|
302
437
|
// Terminate EC2 instance
|
|
303
438
|
if (env.resources.instanceId) {
|
|
304
439
|
try {
|
|
305
|
-
const { TerminateInstancesCommand } = await import("@aws-sdk/client-ec2");
|
|
440
|
+
const { TerminateInstancesCommand, waitUntilInstanceTerminated } = await import("@aws-sdk/client-ec2");
|
|
306
441
|
await ec2Client.send(new TerminateInstancesCommand({
|
|
307
442
|
InstanceIds: [env.resources.instanceId],
|
|
308
443
|
}));
|
|
309
|
-
|
|
444
|
+
if (typeof waitUntilInstanceTerminated === "function") {
|
|
445
|
+
await waitUntilInstanceTerminated({ client: ec2Client, maxWaitTime: 60 }, { InstanceIds: [env.resources.instanceId] });
|
|
446
|
+
}
|
|
447
|
+
logger.info(`Terminated EC2 instance: ${env.resources.instanceId}`);
|
|
310
448
|
}
|
|
311
449
|
catch (error) {
|
|
312
|
-
|
|
450
|
+
failures.push(`instance: ${error.message}`);
|
|
313
451
|
}
|
|
314
452
|
}
|
|
315
|
-
// Delete S3 bucket
|
|
316
453
|
if (env.resources.bucketName) {
|
|
317
454
|
try {
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
const listResponse = await s3Client.send(new ListObjectsV2Command({
|
|
321
|
-
Bucket: env.resources.bucketName,
|
|
322
|
-
}));
|
|
323
|
-
if (listResponse.Contents?.length) {
|
|
324
|
-
await s3Client.send(new DeleteObjectsCommand({
|
|
325
|
-
Bucket: env.resources.bucketName,
|
|
326
|
-
Delete: {
|
|
327
|
-
Objects: listResponse.Contents.map((obj) => ({
|
|
328
|
-
Key: obj.Key,
|
|
329
|
-
})),
|
|
330
|
-
},
|
|
331
|
-
}));
|
|
332
|
-
}
|
|
333
|
-
await s3Client.send(new DeleteBucketCommand({
|
|
334
|
-
Bucket: env.resources.bucketName,
|
|
335
|
-
}));
|
|
336
|
-
console.log(`Deleted S3 bucket: ${env.resources.bucketName}`);
|
|
455
|
+
await this.emptyAndDeleteBucket(env.resources.bucketName, env.region);
|
|
456
|
+
logger.info(`Deleted S3 bucket: ${env.resources.bucketName}`);
|
|
337
457
|
}
|
|
338
458
|
catch (error) {
|
|
339
459
|
if (error.name !== "NoSuchBucket") {
|
|
340
|
-
|
|
460
|
+
failures.push(`bucket: ${error.message}`);
|
|
341
461
|
}
|
|
342
462
|
}
|
|
343
463
|
}
|
|
@@ -348,53 +468,45 @@ export class AwsAdapter {
|
|
|
348
468
|
const iamClient = new IAMClient({ region: env.region });
|
|
349
469
|
const roleName = `sandman-${env.name}-role`;
|
|
350
470
|
const instanceProfileName = env.resources.iamInstanceProfile;
|
|
351
|
-
// Remove role from instance profile
|
|
352
471
|
await iamClient.send(new RemoveRoleFromInstanceProfileCommand({
|
|
353
472
|
InstanceProfileName: instanceProfileName,
|
|
354
473
|
RoleName: roleName,
|
|
355
474
|
}));
|
|
356
|
-
// Delete instance profile
|
|
357
475
|
await iamClient.send(new DeleteInstanceProfileCommand({
|
|
358
476
|
InstanceProfileName: instanceProfileName,
|
|
359
477
|
}));
|
|
360
|
-
// Detach policy from role
|
|
361
478
|
await iamClient.send(new DetachRolePolicyCommand({
|
|
362
479
|
RoleName: roleName,
|
|
363
480
|
PolicyArn: "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore",
|
|
364
481
|
}));
|
|
365
|
-
// Delete role
|
|
366
482
|
await iamClient.send(new DeleteRoleCommand({
|
|
367
483
|
RoleName: roleName,
|
|
368
484
|
}));
|
|
369
|
-
|
|
485
|
+
logger.info(`Deleted IAM role and instance profile for: ${env.name}`);
|
|
370
486
|
}
|
|
371
487
|
catch (error) {
|
|
372
|
-
|
|
488
|
+
failures.push(`iam: ${error.message}`);
|
|
373
489
|
}
|
|
374
490
|
}
|
|
375
491
|
// Delete VPC resources
|
|
376
492
|
if (env.resources.vpcId) {
|
|
377
493
|
try {
|
|
378
494
|
const { DeleteSecurityGroupCommand, DeleteRouteTableCommand, DeleteSubnetCommand, DetachInternetGatewayCommand, DeleteInternetGatewayCommand, DeleteVpcCommand, } = await import("@aws-sdk/client-ec2");
|
|
379
|
-
// Delete security group
|
|
380
495
|
if (env.resources.securityGroupId) {
|
|
381
496
|
await ec2Client.send(new DeleteSecurityGroupCommand({
|
|
382
497
|
GroupId: env.resources.securityGroupId,
|
|
383
498
|
}));
|
|
384
499
|
}
|
|
385
|
-
// Delete route table
|
|
386
500
|
if (env.resources.routeTableId) {
|
|
387
501
|
await ec2Client.send(new DeleteRouteTableCommand({
|
|
388
502
|
RouteTableId: env.resources.routeTableId,
|
|
389
503
|
}));
|
|
390
504
|
}
|
|
391
|
-
// Delete subnet
|
|
392
505
|
if (env.resources.subnetId) {
|
|
393
506
|
await ec2Client.send(new DeleteSubnetCommand({
|
|
394
507
|
SubnetId: env.resources.subnetId,
|
|
395
508
|
}));
|
|
396
509
|
}
|
|
397
|
-
// Detach and delete internet gateway
|
|
398
510
|
if (env.resources.internetGatewayId) {
|
|
399
511
|
await ec2Client.send(new DetachInternetGatewayCommand({
|
|
400
512
|
InternetGatewayId: env.resources.internetGatewayId,
|
|
@@ -404,19 +516,246 @@ export class AwsAdapter {
|
|
|
404
516
|
InternetGatewayId: env.resources.internetGatewayId,
|
|
405
517
|
}));
|
|
406
518
|
}
|
|
407
|
-
// Delete VPC
|
|
408
519
|
await ec2Client.send(new DeleteVpcCommand({
|
|
409
520
|
VpcId: env.resources.vpcId,
|
|
410
521
|
}));
|
|
411
|
-
|
|
522
|
+
logger.info(`Deleted VPC and associated resources for: ${env.name}`);
|
|
523
|
+
}
|
|
524
|
+
catch (error) {
|
|
525
|
+
failures.push(`vpc: ${error.message}`);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
if (failures.length > 0) {
|
|
529
|
+
throw new Error(`Failed to fully destroy environment "${env.name}": ${failures.join("; ")}. Local state was not updated — retry destroy after resolving these errors.`);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
async assertCallerAccount(env) {
|
|
533
|
+
if (!env.accountId) {
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
const { STSClient, GetCallerIdentityCommand } = await import("@aws-sdk/client-sts");
|
|
537
|
+
const sts = new STSClient({ region: env.region || this.region });
|
|
538
|
+
const identity = await sts.send(new GetCallerIdentityCommand({}));
|
|
539
|
+
if (identity.Account && identity.Account !== env.accountId) {
|
|
540
|
+
throw new Error(`Refusing to destroy "${env.name}": state account ${env.accountId} does not match caller ${identity.Account}.`);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
isSandmanOwned(tags, fallbackName) {
|
|
544
|
+
if (tags?.some((t) => t.Key === "CreatedBy" && t.Value === "sandman")) {
|
|
545
|
+
return true;
|
|
546
|
+
}
|
|
547
|
+
return Boolean(fallbackName?.startsWith("sandman-"));
|
|
548
|
+
}
|
|
549
|
+
isMissingAwsResource(error) {
|
|
550
|
+
const name = error?.name || "";
|
|
551
|
+
const message = error?.message || "";
|
|
552
|
+
return (name === "NoSuchBucket" ||
|
|
553
|
+
name === "NotFound" ||
|
|
554
|
+
name === "NoSuchTagSet" ||
|
|
555
|
+
name === "NoSuchEntity" ||
|
|
556
|
+
name === "InvalidInstanceID.NotFound" ||
|
|
557
|
+
name === "InvalidVpcID.NotFound" ||
|
|
558
|
+
name === "InvalidGroup.NotFound" ||
|
|
559
|
+
name === "InvalidSubnetID.NotFound" ||
|
|
560
|
+
name === "InvalidInternetGatewayID.NotFound" ||
|
|
561
|
+
error?.$metadata?.httpStatusCode === 404 ||
|
|
562
|
+
/not found|does not exist/i.test(message));
|
|
563
|
+
}
|
|
564
|
+
async assertSandmanOwnership(env) {
|
|
565
|
+
const region = env.region || this.region;
|
|
566
|
+
const { EC2Client } = await import("@aws-sdk/client-ec2");
|
|
567
|
+
const ec2Client = new EC2Client({ region });
|
|
568
|
+
const refuse = (resource, id) => {
|
|
569
|
+
throw new Error(`Refusing to destroy ${resource} ${id}: it is not tagged CreatedBy=sandman and does not use a sandman- name prefix.`);
|
|
570
|
+
};
|
|
571
|
+
if (env.resources.instanceId) {
|
|
572
|
+
try {
|
|
573
|
+
const { DescribeInstancesCommand } = await import("@aws-sdk/client-ec2");
|
|
574
|
+
const described = await ec2Client.send(new DescribeInstancesCommand({
|
|
575
|
+
InstanceIds: [env.resources.instanceId],
|
|
576
|
+
}));
|
|
577
|
+
const instance = described.Reservations?.[0]?.Instances?.[0];
|
|
578
|
+
const nameTag = instance?.Tags?.find((t) => t.Key === "Name")?.Value;
|
|
579
|
+
if (instance && !this.isSandmanOwned(instance.Tags, nameTag)) {
|
|
580
|
+
refuse("instance", env.resources.instanceId);
|
|
581
|
+
}
|
|
412
582
|
}
|
|
413
583
|
catch (error) {
|
|
414
|
-
|
|
584
|
+
if (!this.isMissingAwsResource(error)) {
|
|
585
|
+
throw error;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
if (env.resources.vpcId) {
|
|
590
|
+
try {
|
|
591
|
+
const { DescribeVpcsCommand } = await import("@aws-sdk/client-ec2");
|
|
592
|
+
const described = await ec2Client.send(new DescribeVpcsCommand({ VpcIds: [env.resources.vpcId] }));
|
|
593
|
+
const vpc = described.Vpcs?.[0];
|
|
594
|
+
const nameTag = vpc?.Tags?.find((t) => t.Key === "Name")?.Value;
|
|
595
|
+
if (vpc && !this.isSandmanOwned(vpc.Tags, nameTag)) {
|
|
596
|
+
refuse("vpc", env.resources.vpcId);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
catch (error) {
|
|
600
|
+
if (!this.isMissingAwsResource(error)) {
|
|
601
|
+
throw error;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
if (env.resources.bucketName) {
|
|
606
|
+
const bucket = env.resources.bucketName;
|
|
607
|
+
try {
|
|
608
|
+
const { S3Client, GetBucketTaggingCommand } = await import("@aws-sdk/client-s3");
|
|
609
|
+
const s3 = new S3Client({ region });
|
|
610
|
+
const tagging = await s3.send(new GetBucketTaggingCommand({ Bucket: bucket }));
|
|
611
|
+
if (!this.isSandmanOwned(tagging.TagSet, bucket)) {
|
|
612
|
+
refuse("bucket", bucket);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
catch (error) {
|
|
616
|
+
if (error.name !== "NoSuchBucket" && !bucket.startsWith("sandman-")) {
|
|
617
|
+
refuse("bucket", bucket);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
if (env.resources.iamInstanceProfile) {
|
|
622
|
+
const profile = String(env.resources.iamInstanceProfile);
|
|
623
|
+
if (!profile.startsWith("sandman-")) {
|
|
624
|
+
refuse("iam instance profile", profile);
|
|
415
625
|
}
|
|
416
626
|
}
|
|
417
627
|
}
|
|
628
|
+
async emptyAndDeleteBucket(bucket, region) {
|
|
629
|
+
const { S3Client, DeleteBucketCommand, ListObjectsV2Command, ListObjectVersionsCommand, DeleteObjectsCommand, } = await import("@aws-sdk/client-s3");
|
|
630
|
+
const s3Client = new S3Client({ region });
|
|
631
|
+
let token;
|
|
632
|
+
do {
|
|
633
|
+
const listResponse = await s3Client.send(new ListObjectsV2Command({
|
|
634
|
+
Bucket: bucket,
|
|
635
|
+
ContinuationToken: token,
|
|
636
|
+
}));
|
|
637
|
+
if (listResponse.Contents?.length) {
|
|
638
|
+
await s3Client.send(new DeleteObjectsCommand({
|
|
639
|
+
Bucket: bucket,
|
|
640
|
+
Delete: {
|
|
641
|
+
Objects: listResponse.Contents.map((obj) => ({
|
|
642
|
+
Key: obj.Key,
|
|
643
|
+
})),
|
|
644
|
+
},
|
|
645
|
+
}));
|
|
646
|
+
}
|
|
647
|
+
token = listResponse.IsTruncated
|
|
648
|
+
? listResponse.NextContinuationToken
|
|
649
|
+
: undefined;
|
|
650
|
+
} while (token);
|
|
651
|
+
let keyMarker;
|
|
652
|
+
let versionIdMarker;
|
|
653
|
+
do {
|
|
654
|
+
const versions = await s3Client.send(new ListObjectVersionsCommand({
|
|
655
|
+
Bucket: bucket,
|
|
656
|
+
KeyMarker: keyMarker,
|
|
657
|
+
VersionIdMarker: versionIdMarker,
|
|
658
|
+
}));
|
|
659
|
+
const objects = [
|
|
660
|
+
...(versions.Versions ?? []).map((v) => ({
|
|
661
|
+
Key: v.Key,
|
|
662
|
+
VersionId: v.VersionId,
|
|
663
|
+
})),
|
|
664
|
+
...(versions.DeleteMarkers ?? []).map((m) => ({
|
|
665
|
+
Key: m.Key,
|
|
666
|
+
VersionId: m.VersionId,
|
|
667
|
+
})),
|
|
668
|
+
];
|
|
669
|
+
if (objects.length) {
|
|
670
|
+
await s3Client.send(new DeleteObjectsCommand({
|
|
671
|
+
Bucket: bucket,
|
|
672
|
+
Delete: { Objects: objects },
|
|
673
|
+
}));
|
|
674
|
+
}
|
|
675
|
+
keyMarker = versions.IsTruncated ? versions.NextKeyMarker : undefined;
|
|
676
|
+
versionIdMarker = versions.IsTruncated
|
|
677
|
+
? versions.NextVersionIdMarker
|
|
678
|
+
: undefined;
|
|
679
|
+
} while (keyMarker);
|
|
680
|
+
await s3Client.send(new DeleteBucketCommand({
|
|
681
|
+
Bucket: bucket,
|
|
682
|
+
}));
|
|
683
|
+
}
|
|
418
684
|
async getStatus(env) {
|
|
419
|
-
|
|
685
|
+
const region = env.region || this.region;
|
|
686
|
+
const missing = [];
|
|
687
|
+
const unhealthy = [];
|
|
688
|
+
if (env.resources.bucketName) {
|
|
689
|
+
const bucket = env.resources.bucketName;
|
|
690
|
+
try {
|
|
691
|
+
const { S3Client, HeadBucketCommand } = await import("@aws-sdk/client-s3");
|
|
692
|
+
const s3 = new S3Client({ region });
|
|
693
|
+
await s3.send(new HeadBucketCommand({ Bucket: bucket }));
|
|
694
|
+
}
|
|
695
|
+
catch (error) {
|
|
696
|
+
if (this.isMissingAwsResource(error)) {
|
|
697
|
+
missing.push(`bucket ${bucket}`);
|
|
698
|
+
}
|
|
699
|
+
else {
|
|
700
|
+
unhealthy.push(`bucket ${bucket}: ${error.message}`);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
if (env.resources.instanceId) {
|
|
705
|
+
const instanceId = env.resources.instanceId;
|
|
706
|
+
try {
|
|
707
|
+
const { EC2Client, DescribeInstancesCommand } = await import("@aws-sdk/client-ec2");
|
|
708
|
+
const ec2 = new EC2Client({ region });
|
|
709
|
+
const described = await ec2.send(new DescribeInstancesCommand({ InstanceIds: [instanceId] }));
|
|
710
|
+
const instance = described.Reservations?.[0]?.Instances?.[0];
|
|
711
|
+
const state = instance?.State?.Name;
|
|
712
|
+
if (!instance || state === "terminated" || state === "shutting-down") {
|
|
713
|
+
missing.push(`instance ${instanceId}`);
|
|
714
|
+
}
|
|
715
|
+
else if (state && state !== "running" && state !== "pending") {
|
|
716
|
+
unhealthy.push(`instance ${instanceId} is ${state}`);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
catch (error) {
|
|
720
|
+
if (this.isMissingAwsResource(error)) {
|
|
721
|
+
missing.push(`instance ${instanceId}`);
|
|
722
|
+
}
|
|
723
|
+
else {
|
|
724
|
+
unhealthy.push(`instance ${instanceId}: ${error.message}`);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
const tracked = Boolean(env.resources.bucketName) || Boolean(env.resources.instanceId);
|
|
729
|
+
if (!tracked) {
|
|
730
|
+
return env;
|
|
731
|
+
}
|
|
732
|
+
const now = new Date().toISOString();
|
|
733
|
+
if (missing.length > 0 && unhealthy.length === 0) {
|
|
734
|
+
const allGone = (!env.resources.bucketName ||
|
|
735
|
+
missing.some((m) => m.startsWith("bucket "))) &&
|
|
736
|
+
(!env.resources.instanceId ||
|
|
737
|
+
missing.some((m) => m.startsWith("instance ")));
|
|
738
|
+
return {
|
|
739
|
+
...env,
|
|
740
|
+
status: allGone ? "destroyed" : "failed",
|
|
741
|
+
error: `Cloud resources missing: ${missing.join("; ")}`,
|
|
742
|
+
updatedAt: now,
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
if (missing.length > 0 || unhealthy.length > 0) {
|
|
746
|
+
return {
|
|
747
|
+
...env,
|
|
748
|
+
status: "failed",
|
|
749
|
+
error: [...missing.map((m) => `missing ${m}`), ...unhealthy].join("; "),
|
|
750
|
+
updatedAt: now,
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
return {
|
|
754
|
+
...env,
|
|
755
|
+
status: "active",
|
|
756
|
+
error: undefined,
|
|
757
|
+
updatedAt: now,
|
|
758
|
+
};
|
|
420
759
|
}
|
|
421
760
|
getAccountInfo() {
|
|
422
761
|
return {
|