@projectdochelp/s3te 3.2.0 → 3.2.1

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 CHANGED
@@ -80,7 +80,7 @@ This section is only about the AWS things you need before you touch S3TE. The ac
80
80
  | Daily-work AWS access | `s3te deploy` needs credentials that can create CloudFormation stacks and related resources. | [Create an IAM user](https://docs.aws.amazon.com/console/iam/add-users), [Manage access keys](https://docs.aws.amazon.com/IAM/latest/UserGuide/access-keys-admin-managed.html) |
81
81
  | AWS CLI v2 | The S3TE CLI shells out to the official `aws` CLI. | [Install AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html), [Get started with AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html) |
82
82
  | Domain name you control | CloudFront and TLS only make sense for domains you can point to AWS. | Use your registrar of choice |
83
- | ACM certificate in `us-east-1` | CloudFront requires its public certificate in `us-east-1`. | [Public certificates in ACM](https://docs.aws.amazon.com/acm/latest/userguide/acm-public-certificates.html) |
83
+ | ACM certificate in `us-east-1` | CloudFront requires its public certificate in `us-east-1`, and the certificate must cover every alias S3TE will derive for that environment. | [Public certificates in ACM](https://docs.aws.amazon.com/acm/latest/userguide/acm-public-certificates.html) |
84
84
  | Optional Route53 hosted zone | Needed only if S3TE should create DNS alias records automatically. | [Create a public hosted zone](https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/CreatingHostedZone.html) |
85
85
 
86
86
  </details>
@@ -228,6 +228,12 @@ The most important fields for a first deployment are:
228
228
 
229
229
  Use plain hostnames in `baseUrl` and `cloudFrontAliases`, not full URLs. If your config contains a `prod` environment plus additional environments such as `test` or `stage`, S3TE keeps the `prod` hostname unchanged and derives non-production hostnames automatically by prepending `<env>.`.
230
230
 
231
+ Your ACM certificate must cover the final derived aliases of the environment you deploy. Example:
232
+
233
+ - `*.example.com` covers `test.example.com`
234
+ - `*.example.com` does not cover `test.app.example.com`
235
+ - for nested aliases like `test.app.example.com`, add a SAN such as `*.app.example.com`, the exact hostname, or use a different `certificateArn` for that environment
236
+
231
237
  </details>
232
238
 
233
239
  <details>
@@ -243,6 +249,8 @@ npx s3te deploy --env dev
243
249
 
244
250
  `render` writes the local preview into `offline/S3TELocal/preview/dev/...`.
245
251
 
252
+ `doctor --env <name>` now also checks whether the configured ACM certificate covers the CloudFront aliases that S3TE derives for that environment. For that check, the AWS identity running `doctor` needs permission to call `acm:DescribeCertificate` for the configured certificate ARN.
253
+
246
254
  `deploy` creates or updates the persistent environment stack, uses a temporary deploy stack for packaged Lambda artifacts, synchronizes the source project into the code bucket, and removes the temporary stack again when the deploy finishes.
247
255
 
248
256
  After the first successful deploy, use `s3te sync --env dev` for regular template, partial, asset and source updates when the infrastructure itself did not change.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@projectdochelp/s3te",
3
- "version": "3.2.0",
3
+ "version": "3.2.1",
4
4
  "description": "CLI, render core, AWS adapter, and testkit for S3TemplateEngine projects",
5
5
  "repository": {
6
6
  "type": "git",
@@ -53,6 +53,52 @@ async function describeStack({ stackName, region, profile, cwd }) {
53
53
  return JSON.parse(describedStack.stdout).Stacks?.[0];
54
54
  }
55
55
 
56
+ async function describeStackEvents({ stackName, region, profile, cwd }) {
57
+ const describedEvents = await runAwsCli(["cloudformation", "describe-stack-events", "--stack-name", stackName, "--output", "json"], {
58
+ region,
59
+ profile,
60
+ cwd,
61
+ errorCode: "ADAPTER_ERROR"
62
+ });
63
+ return JSON.parse(describedEvents.stdout).StackEvents ?? [];
64
+ }
65
+
66
+ export function summarizeStackFailureEvents(stackEvents = [], limit = 8) {
67
+ return stackEvents
68
+ .filter((event) => (
69
+ String(event.ResourceStatus ?? "").includes("FAILED")
70
+ || String(event.ResourceStatus ?? "").includes("ROLLBACK")
71
+ ))
72
+ .map((event) => ({
73
+ timestamp: event.Timestamp,
74
+ logicalResourceId: event.LogicalResourceId,
75
+ resourceType: event.ResourceType,
76
+ resourceStatus: event.ResourceStatus,
77
+ resourceStatusReason: event.ResourceStatusReason
78
+ }))
79
+ .slice(0, limit);
80
+ }
81
+
82
+ async function attachStackFailureDetails(error, { stackName, region, profile, cwd }) {
83
+ try {
84
+ const stackEvents = await describeStackEvents({ stackName, region, profile, cwd });
85
+ const summarizedEvents = summarizeStackFailureEvents(stackEvents);
86
+ if (summarizedEvents.length > 0) {
87
+ error.details = {
88
+ ...(error.details ?? {}),
89
+ stackFailureEvents: summarizedEvents
90
+ };
91
+ }
92
+ } catch (stackEventsError) {
93
+ error.details = {
94
+ ...(error.details ?? {}),
95
+ stackFailureEventsError: stackEventsError.message
96
+ };
97
+ }
98
+
99
+ return error;
100
+ }
101
+
56
102
  async function deployCloudFormationStack({
57
103
  stackName,
58
104
  templatePath,
@@ -85,13 +131,22 @@ async function deployCloudFormationStack({
85
131
  args.push("--no-execute-changeset");
86
132
  }
87
133
 
88
- await runAwsCli(args, {
89
- region,
90
- profile,
91
- cwd,
92
- stdio,
93
- errorCode: "ADAPTER_ERROR"
94
- });
134
+ try {
135
+ await runAwsCli(args, {
136
+ region,
137
+ profile,
138
+ cwd,
139
+ stdio,
140
+ errorCode: "ADAPTER_ERROR"
141
+ });
142
+ } catch (error) {
143
+ throw await attachStackFailureDetails(error, {
144
+ stackName,
145
+ region,
146
+ profile,
147
+ cwd
148
+ });
149
+ }
95
150
  }
96
151
 
97
152
  async function resolveWebinyStreamArn({ runtimeConfig, region, profile, cwd }) {
@@ -176,6 +231,63 @@ async function deployTemporaryArtifactsStack({
176
231
  };
177
232
  }
178
233
 
234
+ export function collectBucketObjectVersions(payload = {}) {
235
+ return [
236
+ ...(payload.Versions ?? []),
237
+ ...(payload.DeleteMarkers ?? [])
238
+ ].map((entry) => ({
239
+ Key: entry.Key,
240
+ VersionId: entry.VersionId
241
+ })).filter((entry) => entry.Key && entry.VersionId);
242
+ }
243
+
244
+ function chunkItems(items, chunkSize) {
245
+ const chunks = [];
246
+ for (let index = 0; index < items.length; index += chunkSize) {
247
+ chunks.push(items.slice(index, index + chunkSize));
248
+ }
249
+ return chunks;
250
+ }
251
+
252
+ async function deleteBucketObjectVersions({
253
+ bucketName,
254
+ region,
255
+ profile,
256
+ cwd
257
+ }) {
258
+ while (true) {
259
+ const listedVersions = await runAwsCli(["s3api", "list-object-versions", "--bucket", bucketName, "--output", "json"], {
260
+ region,
261
+ profile,
262
+ cwd,
263
+ errorCode: "ADAPTER_ERROR"
264
+ });
265
+ const objects = collectBucketObjectVersions(JSON.parse(listedVersions.stdout || "{}"));
266
+ if (objects.length === 0) {
267
+ return;
268
+ }
269
+
270
+ for (const batch of chunkItems(objects, 250)) {
271
+ await runAwsCli([
272
+ "s3api",
273
+ "delete-objects",
274
+ "--bucket",
275
+ bucketName,
276
+ "--delete",
277
+ JSON.stringify({
278
+ Objects: batch,
279
+ Quiet: true
280
+ })
281
+ ], {
282
+ region,
283
+ profile,
284
+ cwd,
285
+ errorCode: "ADAPTER_ERROR"
286
+ });
287
+ }
288
+ }
289
+ }
290
+
179
291
  async function cleanupTemporaryArtifactsStack({
180
292
  stackName,
181
293
  artifactBucket,
@@ -191,6 +303,12 @@ async function cleanupTemporaryArtifactsStack({
191
303
  cwd,
192
304
  errorCode: "ADAPTER_ERROR"
193
305
  });
306
+ await deleteBucketObjectVersions({
307
+ bucketName: artifactBucket,
308
+ region,
309
+ profile,
310
+ cwd
311
+ });
194
312
  } catch (error) {
195
313
  if (!String(error.message).includes("NoSuchBucket")) {
196
314
  throw error;
@@ -4,6 +4,7 @@ import { spawn } from "node:child_process";
4
4
 
5
5
  import {
6
6
  S3teError,
7
+ buildEnvironmentRuntimeConfig,
7
8
  createManualRenderTargets,
8
9
  isRenderableKey,
9
10
  loadProjectConfig,
@@ -16,6 +17,7 @@ import {
16
17
  ensureAwsCliAvailable,
17
18
  ensureAwsCredentials,
18
19
  packageAwsProject,
20
+ runAwsCli,
19
21
  syncAwsProject
20
22
  } from "../../aws-adapter/src/index.mjs";
21
23
 
@@ -32,11 +34,102 @@ function normalizePath(value) {
32
34
  return String(value).replace(/\\/g, "/");
33
35
  }
34
36
 
37
+ function isProjectTestFile(filename) {
38
+ return /(?:^test-.*|.*\.(?:test|spec))\.(?:cjs|mjs|js)$/i.test(filename);
39
+ }
40
+
41
+ async function listProjectTestFiles(rootDir, currentDir = rootDir) {
42
+ const entries = await fs.readdir(currentDir, { withFileTypes: true });
43
+ const files = [];
44
+
45
+ for (const entry of entries) {
46
+ const fullPath = path.join(currentDir, entry.name);
47
+ if (entry.isDirectory()) {
48
+ files.push(...await listProjectTestFiles(rootDir, fullPath));
49
+ continue;
50
+ }
51
+
52
+ if (entry.isFile() && isProjectTestFile(entry.name)) {
53
+ files.push(normalizePath(path.relative(rootDir, fullPath)));
54
+ }
55
+ }
56
+
57
+ return files.sort();
58
+ }
59
+
35
60
  function unknownEnvironmentMessage(config, environmentName) {
36
61
  const knownEnvironments = Object.keys(config?.environments ?? {});
37
62
  return `Unknown environment ${environmentName}. Known environments: ${knownEnvironments.length > 0 ? knownEnvironments.join(", ") : "(none)"}.`;
38
63
  }
39
64
 
65
+ function normalizeHostname(value) {
66
+ return String(value ?? "").trim().toLowerCase().replace(/\.+$/, "");
67
+ }
68
+
69
+ function certificatePatternMatchesHost(pattern, hostname) {
70
+ const normalizedPattern = normalizeHostname(pattern);
71
+ const normalizedHostname = normalizeHostname(hostname);
72
+
73
+ if (!normalizedPattern || !normalizedHostname) {
74
+ return false;
75
+ }
76
+
77
+ if (!normalizedPattern.includes("*")) {
78
+ return normalizedPattern === normalizedHostname;
79
+ }
80
+
81
+ const patternLabels = normalizedPattern.split(".");
82
+ const hostnameLabels = normalizedHostname.split(".");
83
+
84
+ if (patternLabels[0] !== "*" || patternLabels.slice(1).some((label) => label.includes("*"))) {
85
+ return false;
86
+ }
87
+
88
+ if (patternLabels.length !== hostnameLabels.length) {
89
+ return false;
90
+ }
91
+
92
+ return patternLabels.slice(1).join(".") === hostnameLabels.slice(1).join(".");
93
+ }
94
+
95
+ function findUncoveredCertificateHosts(hostnames, certificateDomains) {
96
+ const normalizedCertificateDomains = [...new Set(
97
+ certificateDomains
98
+ .map((value) => normalizeHostname(value))
99
+ .filter(Boolean)
100
+ )];
101
+
102
+ return [...new Set(
103
+ hostnames
104
+ .map((value) => normalizeHostname(value))
105
+ .filter(Boolean)
106
+ .filter((hostname) => !normalizedCertificateDomains.some((pattern) => certificatePatternMatchesHost(pattern, hostname)))
107
+ )].sort();
108
+ }
109
+
110
+ function collectEnvironmentCloudFrontAliases(config, environmentName) {
111
+ const runtimeConfig = buildEnvironmentRuntimeConfig(config, environmentName);
112
+ const aliases = [];
113
+
114
+ for (const variantConfig of Object.values(runtimeConfig.variants)) {
115
+ for (const languageConfig of Object.values(variantConfig.languages)) {
116
+ aliases.push(...(languageConfig.cloudFrontAliases ?? []));
117
+ }
118
+ }
119
+
120
+ return [...new Set(aliases.map((value) => normalizeHostname(value)).filter(Boolean))].sort();
121
+ }
122
+
123
+ async function describeAcmCertificate({ certificateArn, profile, cwd, runAwsCliFn }) {
124
+ const response = await runAwsCliFn(["acm", "describe-certificate", "--certificate-arn", certificateArn, "--output", "json"], {
125
+ region: "us-east-1",
126
+ profile,
127
+ cwd,
128
+ errorCode: "AWS_AUTH_ERROR"
129
+ });
130
+ return JSON.parse(response.stdout || "{}").Certificate ?? {};
131
+ }
132
+
40
133
  function assertKnownEnvironment(config, environmentName) {
41
134
  if (!environmentName) {
42
135
  return;
@@ -736,8 +829,12 @@ export async function runProjectTests(projectDir) {
736
829
  const testsDir = await fileExists(path.join(projectDir, "offline", "tests"))
737
830
  ? "offline/tests"
738
831
  : "tests";
832
+ const testFiles = await listProjectTestFiles(path.join(projectDir, testsDir));
833
+ const testArgs = testFiles.length > 0
834
+ ? testFiles.map((relativePath) => normalizePath(path.join(testsDir, relativePath)))
835
+ : [testsDir];
739
836
  return new Promise((resolve) => {
740
- const child = spawn(process.execPath, ["--test", testsDir], {
837
+ const child = spawn(process.execPath, ["--test", ...testArgs], {
741
838
  cwd: projectDir,
742
839
  stdio: "inherit"
743
840
  });
@@ -787,6 +884,9 @@ export async function syncProject(projectDir, config, options = {}) {
787
884
  }
788
885
 
789
886
  export async function doctorProject(projectDir, configPath, options = {}) {
887
+ const ensureAwsCliAvailableFn = options.ensureAwsCliAvailableFn ?? ensureAwsCliAvailable;
888
+ const ensureAwsCredentialsFn = options.ensureAwsCredentialsFn ?? ensureAwsCredentials;
889
+ const runAwsCliFn = options.runAwsCliFn ?? runAwsCli;
790
890
  const checks = [];
791
891
  const majorVersion = Number(process.versions.node.split(".")[0]);
792
892
 
@@ -811,7 +911,7 @@ export async function doctorProject(projectDir, configPath, options = {}) {
811
911
  }
812
912
 
813
913
  try {
814
- await ensureAwsCliAvailable({ cwd: projectDir });
914
+ await ensureAwsCliAvailableFn({ cwd: projectDir });
815
915
  checks.push({ name: "aws-cli", ok: true, message: "AWS CLI available" });
816
916
  } catch (error) {
817
917
  checks.push({ name: "aws-cli", ok: false, message: error.message });
@@ -828,7 +928,7 @@ export async function doctorProject(projectDir, configPath, options = {}) {
828
928
  }
829
929
 
830
930
  try {
831
- await ensureAwsCredentials({
931
+ await ensureAwsCredentialsFn({
832
932
  region: options.config.environments[options.environment].awsRegion,
833
933
  profile: options.profile,
834
934
  cwd: projectDir
@@ -837,6 +937,38 @@ export async function doctorProject(projectDir, configPath, options = {}) {
837
937
  } catch (error) {
838
938
  checks.push({ name: "aws-auth", ok: false, message: error.message });
839
939
  }
940
+
941
+ const environmentConfig = options.config.environments[options.environment];
942
+ const awsAuthCheck = checks.at(-1);
943
+ if (awsAuthCheck?.name === "aws-auth" && awsAuthCheck.ok) {
944
+ try {
945
+ const cloudFrontAliases = collectEnvironmentCloudFrontAliases(options.config, options.environment);
946
+ const certificate = await describeAcmCertificate({
947
+ certificateArn: environmentConfig.certificateArn,
948
+ profile: options.profile,
949
+ cwd: projectDir,
950
+ runAwsCliFn
951
+ });
952
+ const certificateDomains = [
953
+ certificate.DomainName,
954
+ ...(certificate.SubjectAlternativeNames ?? [])
955
+ ];
956
+ const uncoveredAliases = findUncoveredCertificateHosts(cloudFrontAliases, certificateDomains);
957
+ checks.push({
958
+ name: "acm-certificate",
959
+ ok: uncoveredAliases.length === 0,
960
+ message: uncoveredAliases.length === 0
961
+ ? `ACM certificate covers ${cloudFrontAliases.length} CloudFront alias(es) for ${options.environment}`
962
+ : `ACM certificate ${environmentConfig.certificateArn} does not cover these CloudFront aliases for ${options.environment}: ${uncoveredAliases.join(", ")}.`
963
+ });
964
+ } catch (error) {
965
+ checks.push({
966
+ name: "acm-certificate",
967
+ ok: false,
968
+ message: `Could not inspect ACM certificate ${environmentConfig.certificateArn}: ${error.message}`
969
+ });
970
+ }
971
+ }
840
972
  }
841
973
 
842
974
  return checks;