@projectdochelp/s3te 3.2.0 → 3.2.2
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 +76 -3
- package/package.json +3 -2
- package/packages/aws-adapter/src/deploy.mjs +129 -7
- package/packages/aws-adapter/src/features.mjs +13 -1
- package/packages/aws-adapter/src/manifest.mjs +5 -1
- package/packages/aws-adapter/src/package.mjs +10 -1
- package/packages/aws-adapter/src/runtime/common.mjs +3 -0
- package/packages/aws-adapter/src/runtime/sitemap-updater.mjs +221 -0
- package/packages/aws-adapter/src/template.mjs +68 -1
- package/packages/cli/bin/s3te.mjs +2 -0
- package/packages/cli/src/project.mjs +199 -3
- package/packages/core/src/config.mjs +64 -4
- package/packages/core/src/index.mjs +1 -0
package/README.md
CHANGED
|
@@ -18,6 +18,7 @@ This README is the user guide for the rewrite generation. The deeper implementat
|
|
|
18
18
|
- [Daily Workflow](#daily-workflow)
|
|
19
19
|
- [CLI Commands](#cli-commands)
|
|
20
20
|
- [Template Commands](#template-commands)
|
|
21
|
+
- [Optional: Sitemap](#optional-sitemap)
|
|
21
22
|
- [Optional: Webiny CMS](#optional-webiny-cms)
|
|
22
23
|
|
|
23
24
|
## Motivation
|
|
@@ -65,6 +66,8 @@ That source sync is not limited to Lambda code. It includes your `.html`, `.part
|
|
|
65
66
|
|
|
66
67
|
The persistent environment stack contains the long-lived AWS resources such as buckets, Lambda functions, DynamoDB tables, CloudFront distributions and the runtime manifest parameter. The temporary deploy stack exists only so CloudFormation can consume the packaged Lambda artifacts cleanly.
|
|
67
68
|
|
|
69
|
+
If optional runtime features such as `sitemap` or Webiny are enabled in `s3te.config.json`, the same environment stack also carries those extra Lambdas and event bindings.
|
|
70
|
+
|
|
68
71
|
</details>
|
|
69
72
|
|
|
70
73
|
## Installation (AWS)
|
|
@@ -80,7 +83,7 @@ This section is only about the AWS things you need before you touch S3TE. The ac
|
|
|
80
83
|
| 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
84
|
| 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
85
|
| 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
|
|
86
|
+
| 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
87
|
| 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
88
|
|
|
86
89
|
</details>
|
|
@@ -226,7 +229,18 @@ The most important fields for a first deployment are:
|
|
|
226
229
|
|
|
227
230
|
`route53HostedZoneId` is optional. Leave it out if you want to manage DNS yourself.
|
|
228
231
|
|
|
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
|
|
232
|
+
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 like this:
|
|
233
|
+
|
|
234
|
+
- apex host: `example.com` -> `test.example.com`
|
|
235
|
+
- first-level subdomain: `app.example.com` -> `test-app.example.com`
|
|
236
|
+
- deeper host: `admin.app.example.com` -> `test-admin.app.example.com`
|
|
237
|
+
|
|
238
|
+
Your ACM certificate must cover the final derived aliases of the environment you deploy. Example:
|
|
239
|
+
|
|
240
|
+
- `*.example.com` covers `test.example.com`
|
|
241
|
+
- `*.example.com` also covers `test-app.example.com`
|
|
242
|
+
- `*.example.com` does not cover `test-admin.app.example.com`
|
|
243
|
+
- for deeper aliases like `test-admin.app.example.com`, add a SAN such as `*.app.example.com`, the exact hostname, or use a different `certificateArn` for that environment
|
|
230
244
|
|
|
231
245
|
</details>
|
|
232
246
|
|
|
@@ -243,6 +257,8 @@ npx s3te deploy --env dev
|
|
|
243
257
|
|
|
244
258
|
`render` writes the local preview into `offline/S3TELocal/preview/dev/...`.
|
|
245
259
|
|
|
260
|
+
`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.
|
|
261
|
+
|
|
246
262
|
`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
263
|
|
|
248
264
|
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.
|
|
@@ -468,7 +484,7 @@ Once Webiny is installed and the stack is deployed with Webiny enabled, CMS cont
|
|
|
468
484
|
| `s3te sync --env <name>` | Uploads current project sources into the configured code buckets. |
|
|
469
485
|
| `s3te doctor --env <name>` | Checks local machine and AWS access before deploy. |
|
|
470
486
|
| `s3te deploy --env <name>` | Deploys or updates the AWS environment and syncs source files. |
|
|
471
|
-
| `s3te migrate` | Updates older project configs and can retrofit Webiny into an existing S3TE project. |
|
|
487
|
+
| `s3te migrate` | Updates older project configs and can retrofit optional features such as `sitemap` or Webiny into an existing S3TE project. |
|
|
472
488
|
|
|
473
489
|
</details>
|
|
474
490
|
|
|
@@ -530,6 +546,63 @@ These are the core S3TE commands you will use even in a plain HTML-only project.
|
|
|
530
546
|
|
|
531
547
|
If you also want content-driven commands such as `dbmulti` or `dbmultifile`, continue with the optional Webiny section below. The same commands can also read from local `offline/content/*.json` files when you are not using Webiny yet.
|
|
532
548
|
|
|
549
|
+
## Optional: Sitemap
|
|
550
|
+
|
|
551
|
+
You do not need `sitemap.xml` automation to use S3TE. If you want it, S3TE can maintain one `sitemap.xml` per published output bucket through a dedicated Lambda, just like the older 2.x generation.
|
|
552
|
+
|
|
553
|
+
<details>
|
|
554
|
+
<summary>Enable sitemap maintenance</summary>
|
|
555
|
+
|
|
556
|
+
Add this block to `s3te.config.json`:
|
|
557
|
+
|
|
558
|
+
```json
|
|
559
|
+
"integrations": {
|
|
560
|
+
"sitemap": {
|
|
561
|
+
"enabled": true,
|
|
562
|
+
"environments": {
|
|
563
|
+
"dev": {
|
|
564
|
+
"enabled": false
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
The top-level `enabled` acts as the default. `integrations.sitemap.environments.<env>.enabled` can override that for a single environment.
|
|
572
|
+
|
|
573
|
+
If you prefer the CLI path, this does the same retrofit:
|
|
574
|
+
|
|
575
|
+
```bash
|
|
576
|
+
npx s3te migrate --enable-sitemap --write
|
|
577
|
+
npx s3te migrate --env test --enable-sitemap --write
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
After enabling or disabling `sitemap`, redeploy the affected environment once:
|
|
581
|
+
|
|
582
|
+
```bash
|
|
583
|
+
npx s3te deploy --env prod
|
|
584
|
+
```
|
|
585
|
+
|
|
586
|
+
</details>
|
|
587
|
+
|
|
588
|
+
<details>
|
|
589
|
+
<summary>What the sitemap feature does</summary>
|
|
590
|
+
|
|
591
|
+
When `sitemap` is enabled for an environment, S3TE adds one `sitemap-updater` Lambda to that environment stack and wires every output bucket to it for HTML create/delete events.
|
|
592
|
+
|
|
593
|
+
The Lambda maintains `sitemap.xml` directly inside the same output bucket:
|
|
594
|
+
|
|
595
|
+
- one sitemap per variant/language output bucket
|
|
596
|
+
- only published HTML files are tracked
|
|
597
|
+
- `404.html` is ignored
|
|
598
|
+
- `index.html` becomes `https://example.com/`
|
|
599
|
+
- nested `news/index.html` becomes `https://example.com/news/`
|
|
600
|
+
- regular pages such as `about.html` stay `https://example.com/about.html`
|
|
601
|
+
|
|
602
|
+
Because the trigger sits on the output bucket, the sitemap also stays correct when HTML is regenerated from Webiny content changes in AWS. Asset-only changes do not affect it.
|
|
603
|
+
|
|
604
|
+
</details>
|
|
605
|
+
|
|
533
606
|
## Optional: Webiny CMS
|
|
534
607
|
|
|
535
608
|
You do not need Webiny to use S3TE. Start with plain HTML first. Add Webiny only when editors should maintain content in a CMS instead of editing local JSON files under `offline/content/`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@projectdochelp/s3te",
|
|
3
|
-
"version": "3.2.
|
|
3
|
+
"version": "3.2.2",
|
|
4
4
|
"description": "CLI, render core, AWS adapter, and testkit for S3TemplateEngine projects",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -51,7 +51,8 @@
|
|
|
51
51
|
"@aws-sdk/client-sfn": "^3.0.0",
|
|
52
52
|
"@aws-sdk/client-ssm": "^3.0.0",
|
|
53
53
|
"@aws-sdk/lib-dynamodb": "^3.0.0",
|
|
54
|
-
"@aws-sdk/util-dynamodb": "^3.0.0"
|
|
54
|
+
"@aws-sdk/util-dynamodb": "^3.0.0",
|
|
55
|
+
"fast-xml-parser": "^4.0.8"
|
|
55
56
|
},
|
|
56
57
|
"publishConfig": {
|
|
57
58
|
"access": "public"
|
|
@@ -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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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;
|
|
@@ -226,6 +344,7 @@ function buildEnvironmentStackParameters({
|
|
|
226
344
|
`InvalidationSchedulerArtifactKey=${uploadedArtifacts.invalidationScheduler}`,
|
|
227
345
|
`InvalidationExecutorArtifactKey=${uploadedArtifacts.invalidationExecutor}`,
|
|
228
346
|
`ContentMirrorArtifactKey=${uploadedArtifacts.contentMirror}`,
|
|
347
|
+
`SitemapUpdaterArtifactKey=${uploadedArtifacts.sitemapUpdater}`,
|
|
229
348
|
`RuntimeManifestValue=${runtimeManifestValue}`,
|
|
230
349
|
`WebinySourceTableStreamArn=${webinyStreamArn}`
|
|
231
350
|
];
|
|
@@ -252,6 +371,9 @@ export async function deployAwsProject({
|
|
|
252
371
|
if (requestedFeatureSet.has("webiny") && !runtimeConfig.integrations.webiny.enabled) {
|
|
253
372
|
throw new S3teError("ADAPTER_ERROR", "Feature webiny was requested but is not enabled in s3te.config.json.");
|
|
254
373
|
}
|
|
374
|
+
if (requestedFeatureSet.has("sitemap") && !runtimeConfig.integrations.sitemap.enabled) {
|
|
375
|
+
throw new S3teError("ADAPTER_ERROR", "Feature sitemap was requested but is not enabled in s3te.config.json.");
|
|
376
|
+
}
|
|
255
377
|
|
|
256
378
|
await ensureAwsCliAvailable({ cwd: projectDir });
|
|
257
379
|
await ensureAwsCredentials({
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
resolveEnvironmentSitemapIntegration,
|
|
3
|
+
resolveEnvironmentWebinyIntegration
|
|
4
|
+
} from "../../core/src/index.mjs";
|
|
2
5
|
|
|
3
6
|
export function getConfiguredFeatures(config, environment) {
|
|
4
7
|
const features = [];
|
|
@@ -7,16 +10,25 @@ export function getConfiguredFeatures(config, environment) {
|
|
|
7
10
|
if (resolveEnvironmentWebinyIntegration(config, environment).enabled) {
|
|
8
11
|
features.push("webiny");
|
|
9
12
|
}
|
|
13
|
+
if (resolveEnvironmentSitemapIntegration(config, environment).enabled) {
|
|
14
|
+
features.push("sitemap");
|
|
15
|
+
}
|
|
10
16
|
return features;
|
|
11
17
|
}
|
|
12
18
|
|
|
13
19
|
const hasAnyEnvironmentWebiny = Object.keys(config.environments ?? {}).some((environmentName) => (
|
|
14
20
|
resolveEnvironmentWebinyIntegration(config, environmentName).enabled
|
|
15
21
|
));
|
|
22
|
+
const hasAnyEnvironmentSitemap = Object.keys(config.environments ?? {}).some((environmentName) => (
|
|
23
|
+
resolveEnvironmentSitemapIntegration(config, environmentName).enabled
|
|
24
|
+
));
|
|
16
25
|
|
|
17
26
|
if (hasAnyEnvironmentWebiny) {
|
|
18
27
|
features.push("webiny");
|
|
19
28
|
}
|
|
29
|
+
if (hasAnyEnvironmentSitemap) {
|
|
30
|
+
features.push("sitemap");
|
|
31
|
+
}
|
|
20
32
|
|
|
21
33
|
return features;
|
|
22
34
|
}
|
|
@@ -30,7 +30,8 @@ function buildFunctionNames(runtimeConfig) {
|
|
|
30
30
|
renderWorker: `${runtimeConfig.stackPrefix}_s3te_render_worker`,
|
|
31
31
|
invalidationScheduler: `${runtimeConfig.stackPrefix}_s3te_invalidation_scheduler`,
|
|
32
32
|
invalidationExecutor: `${runtimeConfig.stackPrefix}_s3te_invalidation_executor`,
|
|
33
|
-
contentMirror: runtimeConfig.integrations.webiny.enabled ? `${runtimeConfig.stackPrefix}_s3te_content_mirror` : ""
|
|
33
|
+
contentMirror: runtimeConfig.integrations.webiny.enabled ? `${runtimeConfig.stackPrefix}_s3te_content_mirror` : "",
|
|
34
|
+
sitemapUpdater: runtimeConfig.integrations.sitemap.enabled ? `${runtimeConfig.stackPrefix}_s3te_sitemap_updater` : ""
|
|
34
35
|
};
|
|
35
36
|
}
|
|
36
37
|
|
|
@@ -79,6 +80,9 @@ export function buildAwsRuntimeManifest({ config, environment, stackOutputs = {}
|
|
|
79
80
|
mirrorTableName: runtimeConfig.integrations.webiny.mirrorTableName,
|
|
80
81
|
relevantModels: [...runtimeConfig.integrations.webiny.relevantModels],
|
|
81
82
|
tenant: runtimeConfig.integrations.webiny.tenant
|
|
83
|
+
},
|
|
84
|
+
sitemap: {
|
|
85
|
+
enabled: runtimeConfig.integrations.sitemap.enabled
|
|
82
86
|
}
|
|
83
87
|
},
|
|
84
88
|
variants: runtimeConfig.variants
|
|
@@ -25,7 +25,8 @@ const RUNTIME_PACKAGE_DEPENDENCIES = [
|
|
|
25
25
|
"@aws-sdk/client-sfn",
|
|
26
26
|
"@aws-sdk/client-ssm",
|
|
27
27
|
"@aws-sdk/lib-dynamodb",
|
|
28
|
-
"@aws-sdk/util-dynamodb"
|
|
28
|
+
"@aws-sdk/util-dynamodb",
|
|
29
|
+
"fast-xml-parser"
|
|
29
30
|
];
|
|
30
31
|
const INTERNAL_RUNTIME_DIRECTORIES = [
|
|
31
32
|
{
|
|
@@ -226,6 +227,9 @@ export async function packageAwsProject({
|
|
|
226
227
|
if (features.includes("webiny") && !runtimeConfig.integrations.webiny.enabled) {
|
|
227
228
|
throw new S3teError("ADAPTER_ERROR", "Feature webiny was requested but is not enabled in s3te.config.json.");
|
|
228
229
|
}
|
|
230
|
+
if (features.includes("sitemap") && !runtimeConfig.integrations.sitemap.enabled) {
|
|
231
|
+
throw new S3teError("ADAPTER_ERROR", "Feature sitemap was requested but is not enabled in s3te.config.json.");
|
|
232
|
+
}
|
|
229
233
|
|
|
230
234
|
const resolvedFeatures = resolveRequestedFeatures(config, features, environment);
|
|
231
235
|
const packageDir = outDir
|
|
@@ -267,6 +271,11 @@ export async function packageAwsProject({
|
|
|
267
271
|
archive: path.join(lambdaDir, "content-mirror.zip"),
|
|
268
272
|
parameter: "ContentMirrorArtifactKey",
|
|
269
273
|
s3Key: `lambda/content-mirror.zip`
|
|
274
|
+
},
|
|
275
|
+
sitemapUpdater: {
|
|
276
|
+
archive: path.join(lambdaDir, "sitemap-updater.zip"),
|
|
277
|
+
parameter: "SitemapUpdaterArtifactKey",
|
|
278
|
+
s3Key: "lambda/sitemap-updater.zip"
|
|
270
279
|
}
|
|
271
280
|
};
|
|
272
281
|
|
|
@@ -258,6 +258,9 @@ export function buildCoreConfigFromEnvironment(manifest, environmentName) {
|
|
|
258
258
|
mirrorTableName: environment.integrations.webiny.mirrorTableName,
|
|
259
259
|
relevantModels: [...environment.integrations.webiny.relevantModels],
|
|
260
260
|
tenant: environment.integrations.webiny.tenant
|
|
261
|
+
},
|
|
262
|
+
sitemap: {
|
|
263
|
+
enabled: environment.integrations.sitemap?.enabled ?? false
|
|
261
264
|
}
|
|
262
265
|
}
|
|
263
266
|
};
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { XMLBuilder, XMLParser } from "fast-xml-parser";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createAwsClients,
|
|
5
|
+
decodeS3Key,
|
|
6
|
+
loadEnvironmentManifest
|
|
7
|
+
} from "./common.mjs";
|
|
8
|
+
|
|
9
|
+
const SITEMAP_XML_NAMESPACE = "http://www.sitemaps.org/schemas/sitemap/0.9";
|
|
10
|
+
const parser = new XMLParser({
|
|
11
|
+
ignoreAttributes: false,
|
|
12
|
+
isArray: (name) => name === "url"
|
|
13
|
+
});
|
|
14
|
+
const builder = new XMLBuilder({
|
|
15
|
+
ignoreAttributes: false,
|
|
16
|
+
format: true
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
function createEmptySitemapDocument() {
|
|
20
|
+
return {
|
|
21
|
+
"?xml": {
|
|
22
|
+
"@_version": "1.0",
|
|
23
|
+
"@_encoding": "UTF-8"
|
|
24
|
+
},
|
|
25
|
+
urlset: {
|
|
26
|
+
"@_xmlns": SITEMAP_XML_NAMESPACE,
|
|
27
|
+
url: []
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function bodyToUtf8(body) {
|
|
33
|
+
if (typeof body?.transformToString === "function") {
|
|
34
|
+
return body.transformToString("utf8");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (Buffer.isBuffer(body)) {
|
|
38
|
+
return body.toString("utf8");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (body instanceof Uint8Array) {
|
|
42
|
+
return Buffer.from(body).toString("utf8");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return String(body ?? "");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function normalizeSitemapDocument(document) {
|
|
49
|
+
const candidate = document?.urlset ? document : createEmptySitemapDocument();
|
|
50
|
+
const urls = candidate?.urlset?.url;
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
"?xml": {
|
|
54
|
+
"@_version": candidate?.["?xml"]?.["@_version"] ?? "1.0",
|
|
55
|
+
"@_encoding": candidate?.["?xml"]?.["@_encoding"] ?? "UTF-8"
|
|
56
|
+
},
|
|
57
|
+
urlset: {
|
|
58
|
+
"@_xmlns": candidate?.urlset?.["@_xmlns"] ?? SITEMAP_XML_NAMESPACE,
|
|
59
|
+
url: Array.isArray(urls)
|
|
60
|
+
? urls.filter((entry) => entry?.loc)
|
|
61
|
+
: (urls?.loc ? [urls] : [])
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function loadCurrentSitemap(s3, bucketName) {
|
|
67
|
+
try {
|
|
68
|
+
const response = await s3.getObject({
|
|
69
|
+
Bucket: bucketName,
|
|
70
|
+
Key: "sitemap.xml"
|
|
71
|
+
}).promise();
|
|
72
|
+
return normalizeSitemapDocument(parser.parse(await bodyToUtf8(response.Body)));
|
|
73
|
+
} catch (error) {
|
|
74
|
+
const errorCode = error?.name ?? error?.Code ?? error?.code;
|
|
75
|
+
if (errorCode === "NoSuchKey" || errorCode === "NoSuchBucket" || errorCode === "NotFound") {
|
|
76
|
+
return createEmptySitemapDocument();
|
|
77
|
+
}
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function findLanguageTargetByBucket(environmentManifest, bucketName) {
|
|
83
|
+
for (const [variantName, variantConfig] of Object.entries(environmentManifest.variants ?? {})) {
|
|
84
|
+
for (const [languageCode, languageConfig] of Object.entries(variantConfig.languages ?? {})) {
|
|
85
|
+
if (languageConfig.targetBucket === bucketName) {
|
|
86
|
+
return {
|
|
87
|
+
variantName,
|
|
88
|
+
variantConfig,
|
|
89
|
+
languageCode,
|
|
90
|
+
languageConfig
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function encodePathSegments(key) {
|
|
100
|
+
return String(key)
|
|
101
|
+
.split("/")
|
|
102
|
+
.filter((segment) => segment.length > 0)
|
|
103
|
+
.map((segment) => encodeURIComponent(segment))
|
|
104
|
+
.join("/");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function buildSitemapUrl({ baseUrl, key, indexDocument, notFoundDocument }) {
|
|
108
|
+
const normalizedKey = String(key ?? "").replace(/^\/+/, "");
|
|
109
|
+
if (!normalizedKey || normalizedKey === "sitemap.xml" || normalizedKey === notFoundDocument) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const normalizedBaseUrl = String(baseUrl ?? "").replace(/^https?:\/\//i, "").replace(/\/+$/, "");
|
|
114
|
+
if (!normalizedBaseUrl) {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (normalizedKey === indexDocument) {
|
|
119
|
+
return `https://${normalizedBaseUrl}/`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (normalizedKey.endsWith(`/${indexDocument}`)) {
|
|
123
|
+
const directoryKey = normalizedKey.slice(0, -(indexDocument.length + 1));
|
|
124
|
+
const encodedDirectory = encodePathSegments(directoryKey);
|
|
125
|
+
return `https://${normalizedBaseUrl}/${encodedDirectory}/`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return `https://${normalizedBaseUrl}/${encodePathSegments(normalizedKey)}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function applySitemapRecords(sitemapDocument, sitemapRecords = []) {
|
|
132
|
+
const normalizedDocument = normalizeSitemapDocument(sitemapDocument);
|
|
133
|
+
const entries = new Map((normalizedDocument.urlset.url ?? []).map((entry) => [entry.loc, {
|
|
134
|
+
loc: entry.loc,
|
|
135
|
+
lastmod: entry.lastmod
|
|
136
|
+
}]));
|
|
137
|
+
|
|
138
|
+
for (const record of sitemapRecords) {
|
|
139
|
+
if (!record?.loc) {
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const lastmod = String(record.lastmod ?? new Date().toISOString()).slice(0, 10);
|
|
144
|
+
if (record.action === "delete") {
|
|
145
|
+
entries.delete(record.loc);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
entries.set(record.loc, {
|
|
150
|
+
loc: record.loc,
|
|
151
|
+
lastmod
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
normalizedDocument.urlset.url = [...entries.values()].sort((left, right) => left.loc.localeCompare(right.loc));
|
|
156
|
+
return normalizedDocument;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function handler(event) {
|
|
160
|
+
const environmentName = process.env.S3TE_ENVIRONMENT;
|
|
161
|
+
const runtimeParameter = process.env.S3TE_RUNTIME_PARAMETER;
|
|
162
|
+
|
|
163
|
+
const clients = createAwsClients();
|
|
164
|
+
const { environment: environmentManifest } = await loadEnvironmentManifest(
|
|
165
|
+
clients.ssm,
|
|
166
|
+
runtimeParameter,
|
|
167
|
+
environmentName
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
const updatesByBucket = new Map();
|
|
171
|
+
|
|
172
|
+
for (const record of event.Records ?? []) {
|
|
173
|
+
const bucketName = record.s3?.bucket?.name;
|
|
174
|
+
const key = decodeS3Key(record.s3?.object?.key ?? "");
|
|
175
|
+
const target = findLanguageTargetByBucket(environmentManifest, bucketName);
|
|
176
|
+
if (!bucketName || !key || !target) {
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const loc = buildSitemapUrl({
|
|
181
|
+
baseUrl: target.languageConfig.baseUrl,
|
|
182
|
+
key,
|
|
183
|
+
indexDocument: target.variantConfig.routing.indexDocument,
|
|
184
|
+
notFoundDocument: target.variantConfig.routing.notFoundDocument
|
|
185
|
+
});
|
|
186
|
+
if (!loc) {
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (!updatesByBucket.has(bucketName)) {
|
|
191
|
+
updatesByBucket.set(bucketName, []);
|
|
192
|
+
}
|
|
193
|
+
updatesByBucket.get(bucketName).push({
|
|
194
|
+
action: String(record.eventName).startsWith("ObjectRemoved:") ? "delete" : "upsert",
|
|
195
|
+
loc,
|
|
196
|
+
lastmod: record.eventTime
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
let updatedBuckets = 0;
|
|
201
|
+
|
|
202
|
+
for (const [bucketName, updates] of updatesByBucket.entries()) {
|
|
203
|
+
const sitemapDocument = applySitemapRecords(
|
|
204
|
+
await loadCurrentSitemap(clients.s3, bucketName),
|
|
205
|
+
updates
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
await clients.s3.putObject({
|
|
209
|
+
Bucket: bucketName,
|
|
210
|
+
Key: "sitemap.xml",
|
|
211
|
+
Body: builder.build(sitemapDocument),
|
|
212
|
+
ContentType: "application/xml; charset=utf-8"
|
|
213
|
+
}).promise();
|
|
214
|
+
|
|
215
|
+
updatedBuckets += 1;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
updatedBuckets
|
|
220
|
+
};
|
|
221
|
+
}
|
|
@@ -35,6 +35,26 @@ function lambdaCode(keyParameter) {
|
|
|
35
35
|
};
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
function buildSitemapNotificationConfigurations(functionLogicalId) {
|
|
39
|
+
const suffixes = [".html", ".htm"];
|
|
40
|
+
const events = ["s3:ObjectCreated:*", "s3:ObjectRemoved:*"];
|
|
41
|
+
|
|
42
|
+
return events.flatMap((eventName) => suffixes.map((suffix) => ({
|
|
43
|
+
Event: eventName,
|
|
44
|
+
Function: { "Fn::GetAtt": [functionLogicalId, "Arn"] },
|
|
45
|
+
Filter: {
|
|
46
|
+
S3Key: {
|
|
47
|
+
Rules: [
|
|
48
|
+
{
|
|
49
|
+
Name: "suffix",
|
|
50
|
+
Value: suffix
|
|
51
|
+
}
|
|
52
|
+
]
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
})));
|
|
56
|
+
}
|
|
57
|
+
|
|
38
58
|
function lambdaRuntimeProperties(runtimeConfig, roleRef, name, keyParameter, handlerName, extra = {}) {
|
|
39
59
|
return {
|
|
40
60
|
Type: "AWS::Lambda::Function",
|
|
@@ -117,7 +137,8 @@ export function buildCloudFormationTemplate({ config, environment, features = []
|
|
|
117
137
|
renderWorker: `${runtimeConfig.stackPrefix}_s3te_render_worker`,
|
|
118
138
|
invalidationScheduler: `${runtimeConfig.stackPrefix}_s3te_invalidation_scheduler`,
|
|
119
139
|
invalidationExecutor: `${runtimeConfig.stackPrefix}_s3te_invalidation_executor`,
|
|
120
|
-
contentMirror: `${runtimeConfig.stackPrefix}_s3te_content_mirror
|
|
140
|
+
contentMirror: `${runtimeConfig.stackPrefix}_s3te_content_mirror`,
|
|
141
|
+
sitemapUpdater: `${runtimeConfig.stackPrefix}_s3te_sitemap_updater`
|
|
121
142
|
};
|
|
122
143
|
|
|
123
144
|
const parameters = {
|
|
@@ -140,6 +161,10 @@ export function buildCloudFormationTemplate({ config, environment, features = []
|
|
|
140
161
|
Type: "String",
|
|
141
162
|
Default: ""
|
|
142
163
|
},
|
|
164
|
+
SitemapUpdaterArtifactKey: {
|
|
165
|
+
Type: "String",
|
|
166
|
+
Default: ""
|
|
167
|
+
},
|
|
143
168
|
RuntimeManifestValue: {
|
|
144
169
|
Type: "String",
|
|
145
170
|
Default: "{}"
|
|
@@ -369,6 +394,26 @@ export function buildCloudFormationTemplate({ config, environment, features = []
|
|
|
369
394
|
};
|
|
370
395
|
}
|
|
371
396
|
|
|
397
|
+
if (featureSet.has("sitemap") && runtimeConfig.integrations.sitemap.enabled) {
|
|
398
|
+
resources.SitemapUpdater = lambdaRuntimeProperties(
|
|
399
|
+
runtimeConfig,
|
|
400
|
+
"ExecutionRole",
|
|
401
|
+
functionNames.sitemapUpdater,
|
|
402
|
+
"SitemapUpdaterArtifactKey",
|
|
403
|
+
"sitemap-updater",
|
|
404
|
+
{
|
|
405
|
+
Timeout: 300,
|
|
406
|
+
MemorySize: 512,
|
|
407
|
+
Environment: {
|
|
408
|
+
Variables: {
|
|
409
|
+
S3TE_ENVIRONMENT: environment,
|
|
410
|
+
S3TE_RUNTIME_PARAMETER: runtimeConfig.runtimeParameterName
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
|
|
372
417
|
outputs.StackName = { Value: runtimeConfig.stackName };
|
|
373
418
|
outputs.RuntimeManifestParameterName = {
|
|
374
419
|
Value: runtimeConfig.runtimeParameterName
|
|
@@ -384,6 +429,9 @@ export function buildCloudFormationTemplate({ config, environment, features = []
|
|
|
384
429
|
if (resources.ContentMirror) {
|
|
385
430
|
outputs.ContentMirrorFunctionName = { Value: functionNames.contentMirror };
|
|
386
431
|
}
|
|
432
|
+
if (resources.SitemapUpdater) {
|
|
433
|
+
outputs.SitemapUpdaterFunctionName = { Value: functionNames.sitemapUpdater };
|
|
434
|
+
}
|
|
387
435
|
|
|
388
436
|
for (const [variantName, variantConfig] of Object.entries(runtimeConfig.variants)) {
|
|
389
437
|
const codeBucketLogicalId = cfName(sanitizeLogicalId(variantName), "CodeBucket");
|
|
@@ -417,12 +465,20 @@ export function buildCloudFormationTemplate({ config, environment, features = []
|
|
|
417
465
|
|
|
418
466
|
resources[outputBucketLogicalId] = {
|
|
419
467
|
Type: "AWS::S3::Bucket",
|
|
468
|
+
...(resources.SitemapUpdater ? { DependsOn: ["SitemapUpdaterPermission"] } : {}),
|
|
420
469
|
Properties: {
|
|
421
470
|
BucketName: languageConfig.targetBucket,
|
|
422
471
|
WebsiteConfiguration: {
|
|
423
472
|
IndexDocument: variantConfig.routing.indexDocument,
|
|
424
473
|
ErrorDocument: variantConfig.routing.notFoundDocument
|
|
425
474
|
},
|
|
475
|
+
...(resources.SitemapUpdater
|
|
476
|
+
? {
|
|
477
|
+
NotificationConfiguration: {
|
|
478
|
+
LambdaConfigurations: buildSitemapNotificationConfigurations("SitemapUpdater")
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
: {}),
|
|
426
482
|
PublicAccessBlockConfiguration: {
|
|
427
483
|
BlockPublicAcls: false,
|
|
428
484
|
BlockPublicPolicy: false,
|
|
@@ -544,6 +600,17 @@ export function buildCloudFormationTemplate({ config, environment, features = []
|
|
|
544
600
|
}
|
|
545
601
|
};
|
|
546
602
|
|
|
603
|
+
if (resources.SitemapUpdater) {
|
|
604
|
+
resources.SitemapUpdaterPermission = {
|
|
605
|
+
Type: "AWS::Lambda::Permission",
|
|
606
|
+
Properties: {
|
|
607
|
+
Action: "lambda:InvokeFunction",
|
|
608
|
+
FunctionName: { Ref: "SitemapUpdater" },
|
|
609
|
+
Principal: "s3.amazonaws.com"
|
|
610
|
+
}
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
|
|
547
614
|
return {
|
|
548
615
|
AWSTemplateFormatVersion: "2010-09-09",
|
|
549
616
|
Description: `S3TE environment stack for ${config.project.name} (${environment})`,
|
|
@@ -385,6 +385,8 @@ async function main() {
|
|
|
385
385
|
environment: asArray(options.env)[0],
|
|
386
386
|
enableWebiny: Boolean(options["enable-webiny"]),
|
|
387
387
|
disableWebiny: Boolean(options["disable-webiny"]),
|
|
388
|
+
enableSitemap: Boolean(options["enable-sitemap"]),
|
|
389
|
+
disableSitemap: Boolean(options["disable-sitemap"]),
|
|
388
390
|
webinySourceTable: options["webiny-source-table"],
|
|
389
391
|
webinyTenant: options["webiny-tenant"],
|
|
390
392
|
webinyModels: asArray(options["webiny-model"])
|
|
@@ -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;
|
|
@@ -242,6 +335,23 @@ function schemaTemplate() {
|
|
|
242
335
|
}
|
|
243
336
|
}
|
|
244
337
|
}
|
|
338
|
+
},
|
|
339
|
+
sitemap: {
|
|
340
|
+
type: "object",
|
|
341
|
+
additionalProperties: false,
|
|
342
|
+
properties: {
|
|
343
|
+
enabled: { type: "boolean" },
|
|
344
|
+
environments: {
|
|
345
|
+
type: "object",
|
|
346
|
+
additionalProperties: {
|
|
347
|
+
type: "object",
|
|
348
|
+
additionalProperties: false,
|
|
349
|
+
properties: {
|
|
350
|
+
enabled: { type: "boolean" }
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
245
355
|
}
|
|
246
356
|
}
|
|
247
357
|
}
|
|
@@ -736,8 +846,12 @@ export async function runProjectTests(projectDir) {
|
|
|
736
846
|
const testsDir = await fileExists(path.join(projectDir, "offline", "tests"))
|
|
737
847
|
? "offline/tests"
|
|
738
848
|
: "tests";
|
|
849
|
+
const testFiles = await listProjectTestFiles(path.join(projectDir, testsDir));
|
|
850
|
+
const testArgs = testFiles.length > 0
|
|
851
|
+
? testFiles.map((relativePath) => normalizePath(path.join(testsDir, relativePath)))
|
|
852
|
+
: [testsDir];
|
|
739
853
|
return new Promise((resolve) => {
|
|
740
|
-
const child = spawn(process.execPath, ["--test",
|
|
854
|
+
const child = spawn(process.execPath, ["--test", ...testArgs], {
|
|
741
855
|
cwd: projectDir,
|
|
742
856
|
stdio: "inherit"
|
|
743
857
|
});
|
|
@@ -787,6 +901,9 @@ export async function syncProject(projectDir, config, options = {}) {
|
|
|
787
901
|
}
|
|
788
902
|
|
|
789
903
|
export async function doctorProject(projectDir, configPath, options = {}) {
|
|
904
|
+
const ensureAwsCliAvailableFn = options.ensureAwsCliAvailableFn ?? ensureAwsCliAvailable;
|
|
905
|
+
const ensureAwsCredentialsFn = options.ensureAwsCredentialsFn ?? ensureAwsCredentials;
|
|
906
|
+
const runAwsCliFn = options.runAwsCliFn ?? runAwsCli;
|
|
790
907
|
const checks = [];
|
|
791
908
|
const majorVersion = Number(process.versions.node.split(".")[0]);
|
|
792
909
|
|
|
@@ -811,7 +928,7 @@ export async function doctorProject(projectDir, configPath, options = {}) {
|
|
|
811
928
|
}
|
|
812
929
|
|
|
813
930
|
try {
|
|
814
|
-
await
|
|
931
|
+
await ensureAwsCliAvailableFn({ cwd: projectDir });
|
|
815
932
|
checks.push({ name: "aws-cli", ok: true, message: "AWS CLI available" });
|
|
816
933
|
} catch (error) {
|
|
817
934
|
checks.push({ name: "aws-cli", ok: false, message: error.message });
|
|
@@ -828,7 +945,7 @@ export async function doctorProject(projectDir, configPath, options = {}) {
|
|
|
828
945
|
}
|
|
829
946
|
|
|
830
947
|
try {
|
|
831
|
-
await
|
|
948
|
+
await ensureAwsCredentialsFn({
|
|
832
949
|
region: options.config.environments[options.environment].awsRegion,
|
|
833
950
|
profile: options.profile,
|
|
834
951
|
cwd: projectDir
|
|
@@ -837,6 +954,38 @@ export async function doctorProject(projectDir, configPath, options = {}) {
|
|
|
837
954
|
} catch (error) {
|
|
838
955
|
checks.push({ name: "aws-auth", ok: false, message: error.message });
|
|
839
956
|
}
|
|
957
|
+
|
|
958
|
+
const environmentConfig = options.config.environments[options.environment];
|
|
959
|
+
const awsAuthCheck = checks.at(-1);
|
|
960
|
+
if (awsAuthCheck?.name === "aws-auth" && awsAuthCheck.ok) {
|
|
961
|
+
try {
|
|
962
|
+
const cloudFrontAliases = collectEnvironmentCloudFrontAliases(options.config, options.environment);
|
|
963
|
+
const certificate = await describeAcmCertificate({
|
|
964
|
+
certificateArn: environmentConfig.certificateArn,
|
|
965
|
+
profile: options.profile,
|
|
966
|
+
cwd: projectDir,
|
|
967
|
+
runAwsCliFn
|
|
968
|
+
});
|
|
969
|
+
const certificateDomains = [
|
|
970
|
+
certificate.DomainName,
|
|
971
|
+
...(certificate.SubjectAlternativeNames ?? [])
|
|
972
|
+
];
|
|
973
|
+
const uncoveredAliases = findUncoveredCertificateHosts(cloudFrontAliases, certificateDomains);
|
|
974
|
+
checks.push({
|
|
975
|
+
name: "acm-certificate",
|
|
976
|
+
ok: uncoveredAliases.length === 0,
|
|
977
|
+
message: uncoveredAliases.length === 0
|
|
978
|
+
? `ACM certificate covers ${cloudFrontAliases.length} CloudFront alias(es) for ${options.environment}`
|
|
979
|
+
: `ACM certificate ${environmentConfig.certificateArn} does not cover these CloudFront aliases for ${options.environment}: ${uncoveredAliases.join(", ")}.`
|
|
980
|
+
});
|
|
981
|
+
} catch (error) {
|
|
982
|
+
checks.push({
|
|
983
|
+
name: "acm-certificate",
|
|
984
|
+
ok: false,
|
|
985
|
+
message: `Could not inspect ACM certificate ${environmentConfig.certificateArn}: ${error.message}`
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
}
|
|
840
989
|
}
|
|
841
990
|
|
|
842
991
|
return checks;
|
|
@@ -854,6 +1003,8 @@ export async function migrateProject(configPath, rawConfig, writeChanges) {
|
|
|
854
1003
|
|
|
855
1004
|
const enableWebiny = Boolean(options.enableWebiny);
|
|
856
1005
|
const disableWebiny = Boolean(options.disableWebiny);
|
|
1006
|
+
const enableSitemap = Boolean(options.enableSitemap);
|
|
1007
|
+
const disableSitemap = Boolean(options.disableSitemap);
|
|
857
1008
|
const targetEnvironment = options.environment ? String(options.environment).trim() : "";
|
|
858
1009
|
const webinySourceTable = options.webinySourceTable ? String(options.webinySourceTable).trim() : "";
|
|
859
1010
|
const webinyTenant = options.webinyTenant ? String(options.webinyTenant).trim() : "";
|
|
@@ -862,6 +1013,9 @@ export async function migrateProject(configPath, rawConfig, writeChanges) {
|
|
|
862
1013
|
if (enableWebiny && disableWebiny) {
|
|
863
1014
|
throw new S3teError("CONFIG_CONFLICT_ERROR", "migrate does not allow --enable-webiny and --disable-webiny at the same time.");
|
|
864
1015
|
}
|
|
1016
|
+
if (enableSitemap && disableSitemap) {
|
|
1017
|
+
throw new S3teError("CONFIG_CONFLICT_ERROR", "migrate does not allow --enable-sitemap and --disable-sitemap at the same time.");
|
|
1018
|
+
}
|
|
865
1019
|
|
|
866
1020
|
const touchesWebiny = enableWebiny || disableWebiny || Boolean(webinySourceTable) || Boolean(webinyTenant) || webinyModels.length > 0;
|
|
867
1021
|
if (touchesWebiny) {
|
|
@@ -944,6 +1098,48 @@ export async function migrateProject(configPath, rawConfig, writeChanges) {
|
|
|
944
1098
|
}
|
|
945
1099
|
}
|
|
946
1100
|
|
|
1101
|
+
const touchesSitemap = enableSitemap || disableSitemap;
|
|
1102
|
+
if (touchesSitemap) {
|
|
1103
|
+
if (targetEnvironment && !nextConfig.environments?.[targetEnvironment]) {
|
|
1104
|
+
throw new S3teError("CONFIG_CONFLICT_ERROR", `Unknown environment for migrate: ${targetEnvironment}.`);
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
const existingIntegrations = nextConfig.integrations ?? {};
|
|
1108
|
+
const existingSitemap = existingIntegrations.sitemap ?? {};
|
|
1109
|
+
const existingEnvironmentOverrides = existingSitemap.environments ?? {};
|
|
1110
|
+
const existingTargetSitemap = targetEnvironment
|
|
1111
|
+
? (existingEnvironmentOverrides[targetEnvironment] ?? {})
|
|
1112
|
+
: existingSitemap;
|
|
1113
|
+
const nextEnabled = disableSitemap
|
|
1114
|
+
? false
|
|
1115
|
+
: (enableSitemap || Boolean(targetEnvironment
|
|
1116
|
+
? (existingTargetSitemap.enabled ?? existingSitemap.enabled)
|
|
1117
|
+
: existingSitemap.enabled));
|
|
1118
|
+
|
|
1119
|
+
nextConfig.integrations = {
|
|
1120
|
+
...existingIntegrations,
|
|
1121
|
+
sitemap: targetEnvironment
|
|
1122
|
+
? {
|
|
1123
|
+
...existingSitemap,
|
|
1124
|
+
environments: {
|
|
1125
|
+
...existingEnvironmentOverrides,
|
|
1126
|
+
[targetEnvironment]: {
|
|
1127
|
+
...existingTargetSitemap,
|
|
1128
|
+
enabled: nextEnabled
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
: {
|
|
1133
|
+
...existingSitemap,
|
|
1134
|
+
enabled: nextEnabled,
|
|
1135
|
+
environments: existingEnvironmentOverrides
|
|
1136
|
+
}
|
|
1137
|
+
};
|
|
1138
|
+
|
|
1139
|
+
const scopeLabel = targetEnvironment ? ` for environment ${targetEnvironment}` : "";
|
|
1140
|
+
changes.push(nextEnabled ? `Enabled sitemap integration${scopeLabel}.` : `Disabled sitemap integration${scopeLabel}.`);
|
|
1141
|
+
}
|
|
1142
|
+
|
|
947
1143
|
if (options.writeChanges) {
|
|
948
1144
|
await writeTextFile(configPath, JSON.stringify(nextConfig, null, 2) + "\n");
|
|
949
1145
|
}
|
|
@@ -75,12 +75,27 @@ function environmentHostPrefix(config, environmentName) {
|
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
function prefixHostForEnvironment(config, host, environmentName) {
|
|
78
|
-
|
|
79
|
-
if (!prefix) {
|
|
78
|
+
if (!hasProductionEnvironment(config) || isProductionEnvironment(environmentName)) {
|
|
80
79
|
return host;
|
|
81
80
|
}
|
|
82
81
|
|
|
83
|
-
|
|
82
|
+
const normalizedHost = String(host).trim();
|
|
83
|
+
if (!normalizedHost) {
|
|
84
|
+
return normalizedHost;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (normalizedHost.startsWith(`${environmentName}.`) || normalizedHost.startsWith(`${environmentName}-`)) {
|
|
88
|
+
return normalizedHost;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const labels = normalizedHost.split(".");
|
|
92
|
+
if (labels.length <= 2) {
|
|
93
|
+
const prefix = environmentHostPrefix(config, environmentName);
|
|
94
|
+
return `${prefix}${normalizedHost}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const [firstLabel, ...remainingLabels] = labels;
|
|
98
|
+
return `${environmentName}-${firstLabel}.${remainingLabels.join(".")}`;
|
|
84
99
|
}
|
|
85
100
|
|
|
86
101
|
function isValidConfiguredHost(value) {
|
|
@@ -143,6 +158,12 @@ function resolveWebinyConfigDefaults(webinyConfig = {}) {
|
|
|
143
158
|
};
|
|
144
159
|
}
|
|
145
160
|
|
|
161
|
+
function resolveSitemapConfigDefaults(sitemapConfig = {}) {
|
|
162
|
+
return {
|
|
163
|
+
enabled: sitemapConfig.enabled ?? false
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
146
167
|
function resolveProjectWebinyConfig(projectConfig) {
|
|
147
168
|
const baseConfig = resolveWebinyConfigDefaults(projectConfig.integrations?.webiny ?? {});
|
|
148
169
|
const environmentConfigs = Object.fromEntries(Object.entries(projectConfig.integrations?.webiny?.environments ?? {}).map(([environmentName, webinyConfig]) => ([
|
|
@@ -162,6 +183,21 @@ function resolveProjectWebinyConfig(projectConfig) {
|
|
|
162
183
|
};
|
|
163
184
|
}
|
|
164
185
|
|
|
186
|
+
function resolveProjectSitemapConfig(projectConfig) {
|
|
187
|
+
const baseConfig = resolveSitemapConfigDefaults(projectConfig.integrations?.sitemap ?? {});
|
|
188
|
+
const environmentConfigs = Object.fromEntries(Object.entries(projectConfig.integrations?.sitemap?.environments ?? {}).map(([environmentName, sitemapConfig]) => ([
|
|
189
|
+
environmentName,
|
|
190
|
+
{
|
|
191
|
+
enabled: sitemapConfig.enabled
|
|
192
|
+
}
|
|
193
|
+
])));
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
...baseConfig,
|
|
197
|
+
environments: environmentConfigs
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
165
201
|
export async function loadProjectConfig(configPath) {
|
|
166
202
|
const raw = await fs.readFile(configPath, "utf8");
|
|
167
203
|
try {
|
|
@@ -247,7 +283,8 @@ export function resolveProjectConfig(projectConfig) {
|
|
|
247
283
|
};
|
|
248
284
|
|
|
249
285
|
const integrations = {
|
|
250
|
-
webiny: resolveProjectWebinyConfig(projectConfig)
|
|
286
|
+
webiny: resolveProjectWebinyConfig(projectConfig),
|
|
287
|
+
sitemap: resolveProjectSitemapConfig(projectConfig)
|
|
251
288
|
};
|
|
252
289
|
|
|
253
290
|
for (const [variantName, variantConfig] of Object.entries(variants)) {
|
|
@@ -317,6 +354,15 @@ export function resolveEnvironmentWebinyIntegration(config, environmentName) {
|
|
|
317
354
|
};
|
|
318
355
|
}
|
|
319
356
|
|
|
357
|
+
export function resolveEnvironmentSitemapIntegration(config, environmentName) {
|
|
358
|
+
const baseConfig = resolveSitemapConfigDefaults(config.integrations?.sitemap ?? {});
|
|
359
|
+
const environmentOverride = config.integrations?.sitemap?.environments?.[environmentName] ?? {};
|
|
360
|
+
|
|
361
|
+
return {
|
|
362
|
+
enabled: environmentOverride.enabled ?? baseConfig.enabled
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
320
366
|
export function resolveTableNames(config, environmentName) {
|
|
321
367
|
const context = createPlaceholderContext(config, environmentName);
|
|
322
368
|
const webinyConfig = resolveEnvironmentWebinyIntegration(config, environmentName);
|
|
@@ -340,6 +386,7 @@ export function resolveStackName(config, environmentName) {
|
|
|
340
386
|
export function buildEnvironmentRuntimeConfig(config, environmentName, stackOutputs = {}) {
|
|
341
387
|
const environmentConfig = config.environments[environmentName];
|
|
342
388
|
const webinyConfig = resolveEnvironmentWebinyIntegration(config, environmentName);
|
|
389
|
+
const sitemapConfig = resolveEnvironmentSitemapIntegration(config, environmentName);
|
|
343
390
|
const tables = resolveTableNames(config, environmentName);
|
|
344
391
|
const runtimeParameterName = resolveRuntimeManifestParameterName(config, environmentName);
|
|
345
392
|
const stackName = resolveStackName(config, environmentName);
|
|
@@ -388,6 +435,9 @@ export function buildEnvironmentRuntimeConfig(config, environmentName, stackOutp
|
|
|
388
435
|
webiny: {
|
|
389
436
|
...webinyConfig,
|
|
390
437
|
mirrorTableName: tables.webinyMirror
|
|
438
|
+
},
|
|
439
|
+
sitemap: {
|
|
440
|
+
...sitemapConfig
|
|
391
441
|
}
|
|
392
442
|
},
|
|
393
443
|
variants
|
|
@@ -502,6 +552,7 @@ export async function validateAndResolveProjectConfig(projectConfig, options = {
|
|
|
502
552
|
}
|
|
503
553
|
|
|
504
554
|
const configuredWebiny = projectConfig.integrations?.webiny;
|
|
555
|
+
const configuredSitemap = projectConfig.integrations?.sitemap;
|
|
505
556
|
for (const [environmentName] of environmentEntries) {
|
|
506
557
|
const environmentWebinyConfig = resolveEnvironmentWebinyIntegration(resolveProjectConfig({
|
|
507
558
|
...projectConfig,
|
|
@@ -524,6 +575,15 @@ export async function validateAndResolveProjectConfig(projectConfig, options = {
|
|
|
524
575
|
}
|
|
525
576
|
}
|
|
526
577
|
|
|
578
|
+
for (const environmentName of Object.keys(configuredSitemap?.environments ?? {})) {
|
|
579
|
+
if (!projectConfig.environments?.[environmentName]) {
|
|
580
|
+
errors.push({
|
|
581
|
+
code: "CONFIG_CONFLICT_ERROR",
|
|
582
|
+
message: `integrations.sitemap.environments.${environmentName} does not match a configured environment.`
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
527
587
|
for (const [variantName, pattern] of Object.entries(projectConfig.aws?.codeBuckets ?? {})) {
|
|
528
588
|
ensureKnownPlaceholders(pattern, `aws.codeBuckets.${variantName}`, errors);
|
|
529
589
|
}
|