@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.
Files changed (136) hide show
  1. package/README.md +54 -272
  2. package/dist/cli/commands/cli-contract.test.d.ts +2 -0
  3. package/dist/cli/commands/cli-contract.test.d.ts.map +1 -0
  4. package/dist/cli/commands/cli-contract.test.js +217 -0
  5. package/dist/cli/commands/cli-contract.test.js.map +1 -0
  6. package/dist/cli/commands/connect.d.ts +1 -0
  7. package/dist/cli/commands/connect.d.ts.map +1 -1
  8. package/dist/cli/commands/connect.js +67 -42
  9. package/dist/cli/commands/connect.js.map +1 -1
  10. package/dist/cli/commands/create.d.ts +7 -2
  11. package/dist/cli/commands/create.d.ts.map +1 -1
  12. package/dist/cli/commands/create.js +158 -46
  13. package/dist/cli/commands/create.js.map +1 -1
  14. package/dist/cli/commands/destroy.d.ts.map +1 -1
  15. package/dist/cli/commands/destroy.js +27 -23
  16. package/dist/cli/commands/destroy.js.map +1 -1
  17. package/dist/cli/commands/doctor.d.ts +8 -0
  18. package/dist/cli/commands/doctor.d.ts.map +1 -0
  19. package/dist/cli/commands/doctor.js +124 -0
  20. package/dist/cli/commands/doctor.js.map +1 -0
  21. package/dist/cli/commands/enable.d.ts +1 -2
  22. package/dist/cli/commands/enable.d.ts.map +1 -1
  23. package/dist/cli/commands/enable.js +75 -46
  24. package/dist/cli/commands/enable.js.map +1 -1
  25. package/dist/cli/commands/init.d.ts +2 -2
  26. package/dist/cli/commands/init.d.ts.map +1 -1
  27. package/dist/cli/commands/init.js +49 -17
  28. package/dist/cli/commands/init.js.map +1 -1
  29. package/dist/cli/commands/list.d.ts.map +1 -1
  30. package/dist/cli/commands/list.js +25 -18
  31. package/dist/cli/commands/list.js.map +1 -1
  32. package/dist/cli/commands/providers.d.ts +6 -0
  33. package/dist/cli/commands/providers.d.ts.map +1 -0
  34. package/dist/cli/commands/providers.js +20 -0
  35. package/dist/cli/commands/providers.js.map +1 -0
  36. package/dist/cli/commands/status.d.ts.map +1 -1
  37. package/dist/cli/commands/status.js +88 -50
  38. package/dist/cli/commands/status.js.map +1 -1
  39. package/dist/cli/env-name.d.ts +7 -0
  40. package/dist/cli/env-name.d.ts.map +1 -0
  41. package/dist/cli/env-name.js +9 -0
  42. package/dist/cli/env-name.js.map +1 -0
  43. package/dist/cli/env-name.test.d.ts +2 -0
  44. package/dist/cli/env-name.test.d.ts.map +1 -0
  45. package/dist/cli/env-name.test.js +38 -0
  46. package/dist/cli/env-name.test.js.map +1 -0
  47. package/dist/cli/index.d.ts.map +1 -1
  48. package/dist/cli/index.js +43 -11
  49. package/dist/cli/index.js.map +1 -1
  50. package/dist/cli/output.d.ts +28 -0
  51. package/dist/cli/output.d.ts.map +1 -0
  52. package/dist/cli/output.js +60 -0
  53. package/dist/cli/output.js.map +1 -0
  54. package/dist/cli/output.test.d.ts +2 -0
  55. package/dist/cli/output.test.d.ts.map +1 -0
  56. package/dist/cli/output.test.js +78 -0
  57. package/dist/cli/output.test.js.map +1 -0
  58. package/dist/cli/reap.d.ts +11 -0
  59. package/dist/cli/reap.d.ts.map +1 -0
  60. package/dist/cli/reap.js +32 -0
  61. package/dist/cli/reap.js.map +1 -0
  62. package/dist/cli/secrets.d.ts +8 -0
  63. package/dist/cli/secrets.d.ts.map +1 -0
  64. package/dist/cli/secrets.js +30 -0
  65. package/dist/cli/secrets.js.map +1 -0
  66. package/dist/core/state-store.d.ts +15 -1
  67. package/dist/core/state-store.d.ts.map +1 -1
  68. package/dist/core/state-store.js +168 -33
  69. package/dist/core/state-store.js.map +1 -1
  70. package/dist/core/state-store.test.js +40 -0
  71. package/dist/core/state-store.test.js.map +1 -1
  72. package/dist/index.js +0 -0
  73. package/dist/providers/aws/adapter.d.ts +13 -2
  74. package/dist/providers/aws/adapter.d.ts.map +1 -1
  75. package/dist/providers/aws/adapter.js +402 -63
  76. package/dist/providers/aws/adapter.js.map +1 -1
  77. package/dist/providers/aws/adapter.test.js +300 -191
  78. package/dist/providers/aws/adapter.test.js.map +1 -1
  79. package/dist/providers/base.d.ts +1 -9
  80. package/dist/providers/base.d.ts.map +1 -1
  81. package/dist/providers/base.js.map +1 -1
  82. package/dist/providers/base.test.js +12 -2
  83. package/dist/providers/base.test.js.map +1 -1
  84. package/dist/providers/catalog.d.ts +15 -0
  85. package/dist/providers/catalog.d.ts.map +1 -0
  86. package/dist/providers/catalog.js +53 -0
  87. package/dist/providers/catalog.js.map +1 -0
  88. package/dist/providers/cloudflare/adapter.d.ts +5 -2
  89. package/dist/providers/cloudflare/adapter.d.ts.map +1 -1
  90. package/dist/providers/cloudflare/adapter.js +17 -5
  91. package/dist/providers/cloudflare/adapter.js.map +1 -1
  92. package/dist/providers/cloudflare/adapter.test.js +4 -4
  93. package/dist/providers/cloudflare/adapter.test.js.map +1 -1
  94. package/dist/providers/enable-result.d.ts +9 -0
  95. package/dist/providers/enable-result.d.ts.map +1 -0
  96. package/dist/providers/enable-result.js +26 -0
  97. package/dist/providers/enable-result.js.map +1 -0
  98. package/dist/providers/gcp/adapter.d.ts +9 -3
  99. package/dist/providers/gcp/adapter.d.ts.map +1 -1
  100. package/dist/providers/gcp/adapter.js +115 -20
  101. package/dist/providers/gcp/adapter.js.map +1 -1
  102. package/dist/providers/gcp/adapter.test.js +52 -0
  103. package/dist/providers/gcp/adapter.test.js.map +1 -1
  104. package/dist/providers/index.d.ts +5 -2
  105. package/dist/providers/index.d.ts.map +1 -1
  106. package/dist/providers/index.js +13 -0
  107. package/dist/providers/index.js.map +1 -1
  108. package/dist/providers/vercel/adapter.d.ts +5 -2
  109. package/dist/providers/vercel/adapter.d.ts.map +1 -1
  110. package/dist/providers/vercel/adapter.js +17 -5
  111. package/dist/providers/vercel/adapter.js.map +1 -1
  112. package/dist/providers/vercel/adapter.test.js +4 -4
  113. package/dist/providers/vercel/adapter.test.js.map +1 -1
  114. package/dist/types/index.d.ts +40 -1
  115. package/dist/types/index.d.ts.map +1 -1
  116. package/dist/types/index.js +4 -0
  117. package/dist/types/index.js.map +1 -1
  118. package/dist/utils/cost-estimator.d.ts +2 -0
  119. package/dist/utils/cost-estimator.d.ts.map +1 -1
  120. package/dist/utils/cost-estimator.js +23 -0
  121. package/dist/utils/cost-estimator.js.map +1 -1
  122. package/dist/utils/cost-estimator.test.js +16 -1
  123. package/dist/utils/cost-estimator.test.js.map +1 -1
  124. package/dist/utils/logger.d.ts +8 -0
  125. package/dist/utils/logger.d.ts.map +1 -0
  126. package/dist/utils/logger.js +12 -0
  127. package/dist/utils/logger.js.map +1 -0
  128. package/dist/utils/ttl.d.ts +13 -0
  129. package/dist/utils/ttl.d.ts.map +1 -0
  130. package/dist/utils/ttl.js +35 -0
  131. package/dist/utils/ttl.js.map +1 -0
  132. package/dist/utils/ttl.test.d.ts +2 -0
  133. package/dist/utils/ttl.test.d.ts.map +1 -0
  134. package/dist/utils/ttl.test.js +27 -0
  135. package/dist/utils/ttl.test.js.map +1 -0
  136. 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.region = process.env.AWS_DEFAULT_REGION || "us-east-1";
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 !== "BucketAlreadyOwnedByYou") {
34
- console.warn("Could not create bucket:", error.message);
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.assign(resources, vpcResources);
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
- console.warn("Could not create VPC:", error.message);
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.assign(resources, iamResources);
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
- console.warn("Could not create IAM role:", error.message);
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
- resources.instanceId = instanceId;
116
+ if (instanceId) {
117
+ resources.instanceId = instanceId;
118
+ }
58
119
  }
59
120
  }
60
121
  catch (error) {
61
- console.warn("Could not create EC2 instance:", error.message);
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
- // Allow SSH and HTTP/HTTPS
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: "ami-0c7217cdde317cfec", // Amazon Linux 2023
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
- console.log(`Enabling AWS services: ${services.join(", ")}`);
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
- console.log(`Terminated EC2 instance: ${env.resources.instanceId}`);
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
- console.warn("Could not terminate instance:", error.message);
450
+ failures.push(`instance: ${error.message}`);
313
451
  }
314
452
  }
315
- // Delete S3 bucket
316
453
  if (env.resources.bucketName) {
317
454
  try {
318
- const { S3Client, DeleteBucketCommand, ListObjectsV2Command, DeleteObjectsCommand, } = await import("@aws-sdk/client-s3");
319
- const s3Client = new S3Client({ region: env.region });
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
- console.warn("Could not delete bucket:", error.message);
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
- console.log(`Deleted IAM role and instance profile for: ${env.name}`);
485
+ logger.info(`Deleted IAM role and instance profile for: ${env.name}`);
370
486
  }
371
487
  catch (error) {
372
- console.warn("Could not delete IAM resources:", error.message);
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
- console.log(`Deleted VPC and associated resources for: ${env.name}`);
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
- console.warn("Could not delete VPC resources:", error.message);
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
- return env;
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 {