@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 +9 -1
- package/package.json +1 -1
- package/packages/aws-adapter/src/deploy.mjs +125 -7
- package/packages/cli/src/project.mjs +135 -3
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
|
|
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
|
@@ -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;
|
|
@@ -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",
|
|
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
|
|
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
|
|
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;
|