@kungfu-tech/buildchain 2.11.13-alpha.2 → 2.11.13
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/dist/site/buildchain-contract.json +3 -3
- package/dist/site/buildchain-site.json +17 -12
- package/dist/site/kfd-claims.json +2 -2
- package/dist/site/kfd-upstream-aggregate.json +1 -1
- package/dist/site/manual-registry.json +2 -2
- package/dist/site/page-registry.json +11 -6
- package/dist/site/public-surface-audit.json +1 -1
- package/dist/site/publication-registry.json +4 -4
- package/dist/site/site-manifest.json +6 -6
- package/docs/lifecycle-protocol.md +8 -0
- package/docs/publication-artifacts.md +23 -0
- package/docs/web-surface-deployments.md +35 -1
- package/package.json +1 -1
- package/scripts/web-surface-core.mjs +350 -27
- package/scripts/web-surface-immutable-object.mjs +123 -0
- package/scripts/web-surface.mjs +4 -0
|
@@ -273,14 +273,150 @@ function surfaceArtifactRootFor({ artifactRoot, binding }) {
|
|
|
273
273
|
return artifactRoot;
|
|
274
274
|
}
|
|
275
275
|
|
|
276
|
-
function syncStaticArtifactArgs({ artifactRoot, bucket, objectPrefix }) {
|
|
276
|
+
function syncStaticArtifactArgs({ artifactRoot, bucket, objectPrefix, deleteExcludes = [] }) {
|
|
277
277
|
const args = ["s3", "sync", artifactRoot, s3Uri(bucket, objectPrefix), "--delete"];
|
|
278
278
|
if (!objectPrefix) {
|
|
279
279
|
args.push("--exclude", ".buildchain/*");
|
|
280
280
|
}
|
|
281
|
+
for (const pattern of deleteExcludes) {
|
|
282
|
+
args.push("--exclude", pattern);
|
|
283
|
+
}
|
|
281
284
|
return args;
|
|
282
285
|
}
|
|
283
286
|
|
|
287
|
+
const PUBLICATION_ARCHIVE_POLICY_CONTRACT = "kungfu-buildchain-publication-archive-policy";
|
|
288
|
+
|
|
289
|
+
function immutablePrefix(value, label) {
|
|
290
|
+
const raw = String(value || "").trim().replaceAll("\\", "/");
|
|
291
|
+
if (!raw || raw.split("/").includes("..")) {
|
|
292
|
+
throw new Error(`invalid ${label}: ${value}`);
|
|
293
|
+
}
|
|
294
|
+
const normalized = normalizeS3Key(raw);
|
|
295
|
+
if (!normalized || normalized.split("/").length < 2) {
|
|
296
|
+
throw new Error(`${label} must identify a versioned path below an immutable root: ${value}`);
|
|
297
|
+
}
|
|
298
|
+
return normalized;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function publicationImmutablePolicy({ artifactRoot, binding }) {
|
|
302
|
+
const surfaceRoot = surfaceArtifactRootFor({ artifactRoot, binding });
|
|
303
|
+
const manifestFile = path.join(surfaceRoot, "manifest.json");
|
|
304
|
+
if (!fs.existsSync(manifestFile)) return null;
|
|
305
|
+
let manifest;
|
|
306
|
+
try {
|
|
307
|
+
manifest = JSON.parse(fs.readFileSync(manifestFile, "utf8"));
|
|
308
|
+
} catch (error) {
|
|
309
|
+
throw new Error(`invalid publication archive manifest ${toPosix(path.relative(artifactRoot, manifestFile))}: ${error.message}`);
|
|
310
|
+
}
|
|
311
|
+
if (manifest?.archivePolicy?.contract !== PUBLICATION_ARCHIVE_POLICY_CONTRACT) return null;
|
|
312
|
+
const declaredPrefixes = [...new Set((manifest.publications || []).flatMap((publication) =>
|
|
313
|
+
(publication.versions || []).map((version) => immutablePrefix(
|
|
314
|
+
version.immutablePath,
|
|
315
|
+
`publication ${publication.id || "unknown"} immutablePath`,
|
|
316
|
+
)),
|
|
317
|
+
))].sort();
|
|
318
|
+
if (declaredPrefixes.length === 0) {
|
|
319
|
+
throw new Error("publication archive policy must declare at least one immutable version prefix");
|
|
320
|
+
}
|
|
321
|
+
for (const prefix of declaredPrefixes) {
|
|
322
|
+
if (!fs.existsSync(path.join(surfaceRoot, prefix))) {
|
|
323
|
+
throw new Error(`declared immutable publication prefix does not exist in artifact: ${prefix}`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
const preservedRoots = [...new Set(declaredPrefixes.map((prefix) => prefix.split("/")[0]))].sort();
|
|
327
|
+
const files = preservedRoots
|
|
328
|
+
.flatMap((root) => listFiles(surfaceRoot, root))
|
|
329
|
+
.map((filePath) => ({
|
|
330
|
+
path: toPosix(path.relative(surfaceRoot, filePath)),
|
|
331
|
+
size: fs.statSync(filePath).size,
|
|
332
|
+
sha256: sha256File(filePath),
|
|
333
|
+
}))
|
|
334
|
+
.sort((left, right) => left.path.localeCompare(right.path));
|
|
335
|
+
return {
|
|
336
|
+
contract: "kungfu-buildchain-web-surface-immutable-publication",
|
|
337
|
+
sourceContract: manifest.contract || "",
|
|
338
|
+
archivePolicyContract: PUBLICATION_ARCHIVE_POLICY_CONTRACT,
|
|
339
|
+
manifestPath: toPosix(path.relative(artifactRoot, manifestFile)),
|
|
340
|
+
preservedRoots,
|
|
341
|
+
declaredPrefixes,
|
|
342
|
+
files,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function withImmutablePublicationPolicies(bindings, { cwd, artifactPath }) {
|
|
347
|
+
const artifactRoot = path.resolve(cwd, artifactPath);
|
|
348
|
+
const withOwnPolicies = bindings.map((binding) => {
|
|
349
|
+
const immutablePublication = publicationImmutablePolicy({ artifactRoot, binding });
|
|
350
|
+
if (!immutablePublication) return binding;
|
|
351
|
+
return { ...binding, immutablePublication };
|
|
352
|
+
});
|
|
353
|
+
const protectedRoots = withOwnPolicies.flatMap((binding) =>
|
|
354
|
+
(binding.immutablePublication?.preservedRoots || []).map((root) => ({
|
|
355
|
+
owner: binding.surface,
|
|
356
|
+
path: joinS3Key(binding.artifactPathPrefix, root),
|
|
357
|
+
})),
|
|
358
|
+
);
|
|
359
|
+
const withDeleteExcludes = withOwnPolicies.map((binding) => {
|
|
360
|
+
const bindingPrefix = normalizeS3Key(binding.artifactPathPrefix);
|
|
361
|
+
const mutableDeleteExcludes = protectedRoots
|
|
362
|
+
.map((protectedRoot) => {
|
|
363
|
+
if (!bindingPrefix) return `${protectedRoot.path}/*`;
|
|
364
|
+
if (protectedRoot.path === bindingPrefix) return "*";
|
|
365
|
+
if (!protectedRoot.path.startsWith(`${bindingPrefix}/`)) return "";
|
|
366
|
+
return `${protectedRoot.path.slice(bindingPrefix.length + 1)}/*`;
|
|
367
|
+
})
|
|
368
|
+
.filter(Boolean)
|
|
369
|
+
.sort();
|
|
370
|
+
return mutableDeleteExcludes.length > 0
|
|
371
|
+
? { ...binding, mutableDeleteExcludes }
|
|
372
|
+
: binding;
|
|
373
|
+
});
|
|
374
|
+
return withDeleteExcludes.map((binding) => {
|
|
375
|
+
if (!binding.immutablePublication) return binding;
|
|
376
|
+
const ownedPaths = protectedRoots
|
|
377
|
+
.filter((protectedRoot) => protectedRoot.owner === binding.surface)
|
|
378
|
+
.map((protectedRoot) => protectedRoot.path);
|
|
379
|
+
const coveringBindings = withDeleteExcludes
|
|
380
|
+
.map((candidate) => {
|
|
381
|
+
const candidatePrefix = normalizeS3Key(candidate.artifactPathPrefix);
|
|
382
|
+
const excludes = ownedPaths
|
|
383
|
+
.map((ownedPath) => {
|
|
384
|
+
if (!candidatePrefix) return `${ownedPath}/*`;
|
|
385
|
+
if (ownedPath === candidatePrefix) return "*";
|
|
386
|
+
if (!ownedPath.startsWith(`${candidatePrefix}/`)) return "";
|
|
387
|
+
return `${ownedPath.slice(candidatePrefix.length + 1)}/*`;
|
|
388
|
+
})
|
|
389
|
+
.filter(Boolean)
|
|
390
|
+
.sort();
|
|
391
|
+
return excludes.length > 0
|
|
392
|
+
? { surface: candidate.surface, mutableDeleteExcludes: excludes }
|
|
393
|
+
: null;
|
|
394
|
+
})
|
|
395
|
+
.filter(Boolean)
|
|
396
|
+
.sort((left, right) => left.surface.localeCompare(right.surface));
|
|
397
|
+
return {
|
|
398
|
+
...binding,
|
|
399
|
+
immutablePublication: {
|
|
400
|
+
...binding.immutablePublication,
|
|
401
|
+
coveringBindings,
|
|
402
|
+
},
|
|
403
|
+
};
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function pathUnderPreservedRoot(relativePath, preservedRoots = []) {
|
|
408
|
+
const normalized = normalizeS3Key(relativePath);
|
|
409
|
+
return preservedRoots.some((root) => normalized === root || normalized.startsWith(`${root}/`));
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function pathExcludedFromMutableDelete(relativePath, patterns = []) {
|
|
413
|
+
if (patterns.includes("*")) return true;
|
|
414
|
+
return pathUnderPreservedRoot(
|
|
415
|
+
relativePath,
|
|
416
|
+
patterns.map((pattern) => pattern.replace(/\/\*$/, "")),
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
|
|
284
420
|
function directoryIndexAliasKeys({ objectPrefix, relativeIndexPath }) {
|
|
285
421
|
const normalizedPrefix = normalizeS3Key(objectPrefix);
|
|
286
422
|
if (!normalizedPrefix) {
|
|
@@ -301,6 +437,10 @@ function directoryIndexAliasOperations({ surfaceArtifactRoot, bucket, binding })
|
|
|
301
437
|
}
|
|
302
438
|
return listFiles(surfaceArtifactRoot, ".")
|
|
303
439
|
.filter((filePath) => path.basename(filePath) === (binding.directoryIndex || "index.html"))
|
|
440
|
+
.filter((filePath) => !pathExcludedFromMutableDelete(
|
|
441
|
+
toPosix(path.relative(surfaceArtifactRoot, filePath)),
|
|
442
|
+
binding.mutableDeleteExcludes || [],
|
|
443
|
+
))
|
|
304
444
|
.flatMap((filePath) => {
|
|
305
445
|
const relativeIndexPath = toPosix(path.relative(surfaceArtifactRoot, filePath));
|
|
306
446
|
return directoryIndexAliasKeys({ objectPrefix: binding.objectPrefix, relativeIndexPath }).map((key) => ({
|
|
@@ -931,9 +1071,12 @@ export function planWebSurfaceDeploy({
|
|
|
931
1071
|
rollbackLimitations,
|
|
932
1072
|
deployedAt,
|
|
933
1073
|
});
|
|
934
|
-
const surfaceBindings = withSurfaceRoutingEvidence(manifest.surfaceBindings, {
|
|
1074
|
+
const surfaceBindings = withImmutablePublicationPolicies(withSurfaceRoutingEvidence(manifest.surfaceBindings, {
|
|
935
1075
|
artifactPath: artifactPath || deployConfig.artifactPath || ".",
|
|
936
1076
|
files: resolvedArtifact.files,
|
|
1077
|
+
}), {
|
|
1078
|
+
cwd,
|
|
1079
|
+
artifactPath: artifactPath || deployConfig.artifactPath || ".",
|
|
937
1080
|
});
|
|
938
1081
|
manifest.surfaceBindings = surfaceBindings;
|
|
939
1082
|
return {
|
|
@@ -993,9 +1136,12 @@ export function applyWebSurfaceDeploy({
|
|
|
993
1136
|
}
|
|
994
1137
|
const bucket = deployConfig.bucket || deployConfig.target || "";
|
|
995
1138
|
const artifactRoot = path.resolve(cwd, resolvedPlan.artifact.path);
|
|
996
|
-
const bindings = withSurfaceRoutingEvidence(resolvedPlan.manifest.surfaceBindings || [], {
|
|
1139
|
+
const bindings = withImmutablePublicationPolicies(withSurfaceRoutingEvidence(resolvedPlan.manifest.surfaceBindings || [], {
|
|
997
1140
|
artifactPath: resolvedPlan.artifact.path,
|
|
998
1141
|
files: resolvedPlan.artifact.files || [],
|
|
1142
|
+
}), {
|
|
1143
|
+
cwd,
|
|
1144
|
+
artifactPath: resolvedPlan.artifact.path,
|
|
999
1145
|
});
|
|
1000
1146
|
resolvedPlan.manifest.surfaceBindings = bindings;
|
|
1001
1147
|
if (!dryRun) {
|
|
@@ -1007,20 +1153,56 @@ export function applyWebSurfaceDeploy({
|
|
|
1007
1153
|
});
|
|
1008
1154
|
}
|
|
1009
1155
|
}
|
|
1010
|
-
const
|
|
1011
|
-
...cloudFrontDirectoryIndexRewriteOperations(bindings),
|
|
1012
|
-
...bindings.flatMap((binding) => deployBindingOperations({
|
|
1156
|
+
const bindingOperations = bindings.flatMap((binding) => deployBindingOperations({
|
|
1013
1157
|
artifactRoot,
|
|
1014
1158
|
deployConfig,
|
|
1015
1159
|
manifest: resolvedPlan.manifest,
|
|
1016
1160
|
binding,
|
|
1017
|
-
}))
|
|
1161
|
+
}));
|
|
1162
|
+
const operations = [
|
|
1163
|
+
...bindingOperations.filter((operation) => operation.action === "verify-immutable-artifact-before-upload"),
|
|
1164
|
+
...bindingOperations.filter((operation) => operation.action === "sync-immutable-artifact"),
|
|
1165
|
+
...bindingOperations.filter((operation) => operation.action === "verify-immutable-artifact-after-upload"),
|
|
1166
|
+
...cloudFrontDirectoryIndexRewriteOperations(bindings),
|
|
1167
|
+
...bindingOperations.filter((operation) => ![
|
|
1168
|
+
"verify-immutable-artifact-before-upload",
|
|
1169
|
+
"sync-immutable-artifact",
|
|
1170
|
+
"verify-immutable-artifact-after-upload",
|
|
1171
|
+
].includes(operation.action)),
|
|
1018
1172
|
];
|
|
1019
1173
|
const primaryBinding = bindings[0] || {};
|
|
1020
1174
|
const objectPrefix = primaryBinding.objectPrefix || objectPrefixFor(deployConfig, resolvedPlan.manifest.alias || resolvedPlan.manifest.channel);
|
|
1021
1175
|
const manifestKey = primaryBinding.manifestKey || deployManifestKey(deployConfig, resolvedPlan.manifest);
|
|
1022
1176
|
const invalidationPaths = bindings.flatMap((binding) => [viewerWildcardPath(binding), cdnPath(binding.manifestKey)]);
|
|
1023
1177
|
const operationResults = runAdapterOperations({ operations, dryRun, commandRunner });
|
|
1178
|
+
const immutablePreservation = bindings
|
|
1179
|
+
.filter((binding) => binding.immutablePublication)
|
|
1180
|
+
.map((binding) => {
|
|
1181
|
+
const relevant = operationResults.filter((operation) =>
|
|
1182
|
+
operation.surface === binding.surface &&
|
|
1183
|
+
[
|
|
1184
|
+
"verify-immutable-artifact-before-upload",
|
|
1185
|
+
"sync-immutable-artifact",
|
|
1186
|
+
"verify-immutable-artifact-after-upload",
|
|
1187
|
+
"sync-static-artifact",
|
|
1188
|
+
].includes(operation.action),
|
|
1189
|
+
);
|
|
1190
|
+
const expectedOperationCount = (binding.immutablePublication.files.length * 2) +
|
|
1191
|
+
binding.immutablePublication.preservedRoots.length + 1;
|
|
1192
|
+
const complete = relevant.length === expectedOperationCount;
|
|
1193
|
+
return {
|
|
1194
|
+
surface: binding.surface,
|
|
1195
|
+
manifestPath: binding.immutablePublication.manifestPath,
|
|
1196
|
+
preservedRoots: binding.immutablePublication.preservedRoots,
|
|
1197
|
+
declaredPrefixes: binding.immutablePublication.declaredPrefixes,
|
|
1198
|
+
fileCount: binding.immutablePublication.files.length,
|
|
1199
|
+
mutableDeleteExcludes: binding.mutableDeleteExcludes || [],
|
|
1200
|
+
coveringBindings: binding.immutablePublication.coveringBindings,
|
|
1201
|
+
status: !complete || relevant.some((operation) => operation.status === "failed")
|
|
1202
|
+
? "failed"
|
|
1203
|
+
: dryRun ? "planned" : "applied",
|
|
1204
|
+
};
|
|
1205
|
+
});
|
|
1024
1206
|
return {
|
|
1025
1207
|
schemaVersion: 1,
|
|
1026
1208
|
contract: "kungfu-buildchain-web-surface-deploy-apply",
|
|
@@ -1043,6 +1225,7 @@ export function applyWebSurfaceDeploy({
|
|
|
1043
1225
|
invalidationPaths,
|
|
1044
1226
|
manifest: resolvedPlan.manifest,
|
|
1045
1227
|
surfaceBindings: bindings,
|
|
1228
|
+
immutablePreservation,
|
|
1046
1229
|
operations: operationResults,
|
|
1047
1230
|
};
|
|
1048
1231
|
}
|
|
@@ -1622,6 +1805,64 @@ export async function checkWebSurfaceHealth({
|
|
|
1622
1805
|
}
|
|
1623
1806
|
}
|
|
1624
1807
|
|
|
1808
|
+
const immutableBindings = bindings.filter((binding) => binding.immutablePublication);
|
|
1809
|
+
if (immutableBindings.length > 0) {
|
|
1810
|
+
const operationSource = Array.isArray(result?.operations) && result.operations.length > 0
|
|
1811
|
+
? result.operations
|
|
1812
|
+
: (Array.isArray(plan?.steps) ? plan.steps : []);
|
|
1813
|
+
const requiredActions = [
|
|
1814
|
+
"verify-immutable-artifact-before-upload",
|
|
1815
|
+
"sync-immutable-artifact",
|
|
1816
|
+
"verify-immutable-artifact-after-upload",
|
|
1817
|
+
"sync-static-artifact",
|
|
1818
|
+
];
|
|
1819
|
+
const preservationBindings = immutableBindings.map((binding) => {
|
|
1820
|
+
const actions = new Set(operationSource
|
|
1821
|
+
.filter((operation) => operation.surface === binding.surface)
|
|
1822
|
+
.filter((operation) => operationEvidenceStatus(operation, { plannedEvidence: !result }))
|
|
1823
|
+
.map((operation) => operation.action));
|
|
1824
|
+
const missingActions = requiredActions.filter((action) => !actions.has(action));
|
|
1825
|
+
const coveringBindings = (binding.immutablePublication.coveringBindings || []).map((covering) => {
|
|
1826
|
+
const sync = operationSource.find((operation) =>
|
|
1827
|
+
operation.surface === covering.surface &&
|
|
1828
|
+
operation.action === "sync-static-artifact" &&
|
|
1829
|
+
operationEvidenceStatus(operation, { plannedEvidence: !result }),
|
|
1830
|
+
);
|
|
1831
|
+
const actualExcludes = sync?.preservation?.mutableDeleteExcludes || sync?.deleteExcludes || [];
|
|
1832
|
+
return {
|
|
1833
|
+
...covering,
|
|
1834
|
+
status: sync && covering.mutableDeleteExcludes.every((pattern) => actualExcludes.includes(pattern))
|
|
1835
|
+
? "pass"
|
|
1836
|
+
: "fail",
|
|
1837
|
+
};
|
|
1838
|
+
});
|
|
1839
|
+
return {
|
|
1840
|
+
surface: binding.surface,
|
|
1841
|
+
manifestPath: binding.immutablePublication.manifestPath,
|
|
1842
|
+
preservedRoots: binding.immutablePublication.preservedRoots,
|
|
1843
|
+
declaredPrefixes: binding.immutablePublication.declaredPrefixes,
|
|
1844
|
+
fileCount: binding.immutablePublication.files.length,
|
|
1845
|
+
mutableDeleteExcludes: binding.mutableDeleteExcludes || [],
|
|
1846
|
+
coveringBindings,
|
|
1847
|
+
actions: [...actions].sort(),
|
|
1848
|
+
requiredActions,
|
|
1849
|
+
missingActions,
|
|
1850
|
+
status: missingActions.length === 0 && coveringBindings.every((covering) => covering.status === "pass")
|
|
1851
|
+
? "pass"
|
|
1852
|
+
: "fail",
|
|
1853
|
+
};
|
|
1854
|
+
});
|
|
1855
|
+
checks.push({
|
|
1856
|
+
surface: "__immutable__",
|
|
1857
|
+
url: "",
|
|
1858
|
+
status: preservationBindings.every((binding) => binding.status === "pass") ? "pass" : "fail",
|
|
1859
|
+
bindings: preservationBindings,
|
|
1860
|
+
message: preservationBindings.every((binding) => binding.status === "pass")
|
|
1861
|
+
? "immutable publication roots were excluded from mutable deletion and verified around no-overwrite upload"
|
|
1862
|
+
: "immutable publication preservation evidence is incomplete",
|
|
1863
|
+
});
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1625
1866
|
const manifestChecks = bindings.map((binding) => ({
|
|
1626
1867
|
surface: binding.surface,
|
|
1627
1868
|
manifestKey: binding.manifestKey,
|
|
@@ -1655,13 +1896,66 @@ function deployBindingOperations({ artifactRoot, deployConfig, manifest, binding
|
|
|
1655
1896
|
const bucket = binding.bucket || effectiveDeploy.bucket || effectiveDeploy.target || "";
|
|
1656
1897
|
const distribution = binding.distributionId || effectiveDeploy.cloudfront_distribution || effectiveDeploy.distribution || "";
|
|
1657
1898
|
const surfaceArtifactRoot = surfaceArtifactRootFor({ artifactRoot, binding });
|
|
1899
|
+
const immutable = binding.immutablePublication;
|
|
1900
|
+
const immutableVerifier = path.join(moduleDir, "web-surface-immutable-object.mjs");
|
|
1901
|
+
const verifyImmutable = (phase) => (immutable?.files || []).map((file) => ({
|
|
1902
|
+
action: `verify-immutable-artifact-${phase}`,
|
|
1903
|
+
surface: binding.surface,
|
|
1904
|
+
command: "node",
|
|
1905
|
+
args: [
|
|
1906
|
+
immutableVerifier,
|
|
1907
|
+
"--bucket",
|
|
1908
|
+
bucket,
|
|
1909
|
+
"--key",
|
|
1910
|
+
joinS3Key(binding.objectPrefix, file.path),
|
|
1911
|
+
"--sha256",
|
|
1912
|
+
file.sha256,
|
|
1913
|
+
],
|
|
1914
|
+
immutable: {
|
|
1915
|
+
phase,
|
|
1916
|
+
path: file.path,
|
|
1917
|
+
sha256: file.sha256,
|
|
1918
|
+
},
|
|
1919
|
+
}));
|
|
1920
|
+
const syncImmutable = (immutable?.preservedRoots || []).map((root) => ({
|
|
1921
|
+
action: "sync-immutable-artifact",
|
|
1922
|
+
surface: binding.surface,
|
|
1923
|
+
command: "aws",
|
|
1924
|
+
args: [
|
|
1925
|
+
"s3",
|
|
1926
|
+
"sync",
|
|
1927
|
+
path.join(surfaceArtifactRoot, root),
|
|
1928
|
+
s3Uri(bucket, joinS3Key(binding.objectPrefix, root)),
|
|
1929
|
+
"--no-overwrite",
|
|
1930
|
+
"--checksum-algorithm",
|
|
1931
|
+
"SHA256",
|
|
1932
|
+
],
|
|
1933
|
+
immutable: {
|
|
1934
|
+
preservedRoot: root,
|
|
1935
|
+
overwrite: false,
|
|
1936
|
+
},
|
|
1937
|
+
}));
|
|
1658
1938
|
const operations = [
|
|
1939
|
+
...verifyImmutable("before-upload"),
|
|
1940
|
+
...syncImmutable,
|
|
1941
|
+
...verifyImmutable("after-upload"),
|
|
1659
1942
|
{
|
|
1660
1943
|
action: "sync-static-artifact",
|
|
1661
1944
|
surface: binding.surface,
|
|
1662
1945
|
command: "aws",
|
|
1663
|
-
args: syncStaticArtifactArgs({
|
|
1946
|
+
args: syncStaticArtifactArgs({
|
|
1947
|
+
artifactRoot: surfaceArtifactRoot,
|
|
1948
|
+
bucket,
|
|
1949
|
+
objectPrefix: binding.objectPrefix,
|
|
1950
|
+
deleteExcludes: binding.mutableDeleteExcludes || [],
|
|
1951
|
+
}),
|
|
1664
1952
|
routing: binding.routing,
|
|
1953
|
+
preservation: binding.mutableDeleteExcludes?.length > 0
|
|
1954
|
+
? {
|
|
1955
|
+
contract: "kungfu-buildchain-web-surface-immutable-delete-exclusion",
|
|
1956
|
+
mutableDeleteExcludes: binding.mutableDeleteExcludes,
|
|
1957
|
+
}
|
|
1958
|
+
: undefined,
|
|
1665
1959
|
},
|
|
1666
1960
|
...directoryIndexAliasOperations({ surfaceArtifactRoot, bucket, binding }),
|
|
1667
1961
|
{
|
|
@@ -1698,32 +1992,61 @@ function deployBindingOperations({ artifactRoot, deployConfig, manifest, binding
|
|
|
1698
1992
|
|
|
1699
1993
|
function planAdapterSteps(adapter, deployConfig, manifest) {
|
|
1700
1994
|
if (adapter === "aws-s3-cloudfront") {
|
|
1995
|
+
const bindingSteps = manifest.surfaceBindings.flatMap((binding) => [
|
|
1996
|
+
...(binding.immutablePublication
|
|
1997
|
+
? [
|
|
1998
|
+
{
|
|
1999
|
+
action: "verify-immutable-artifact-before-upload",
|
|
2000
|
+
surface: binding.surface,
|
|
2001
|
+
fileCount: binding.immutablePublication.files.length,
|
|
2002
|
+
},
|
|
2003
|
+
{
|
|
2004
|
+
action: "sync-immutable-artifact",
|
|
2005
|
+
surface: binding.surface,
|
|
2006
|
+
roots: binding.immutablePublication.preservedRoots,
|
|
2007
|
+
overwrite: false,
|
|
2008
|
+
},
|
|
2009
|
+
{
|
|
2010
|
+
action: "verify-immutable-artifact-after-upload",
|
|
2011
|
+
surface: binding.surface,
|
|
2012
|
+
fileCount: binding.immutablePublication.files.length,
|
|
2013
|
+
},
|
|
2014
|
+
]
|
|
2015
|
+
: []),
|
|
2016
|
+
{
|
|
2017
|
+
action: "sync-static-artifact",
|
|
2018
|
+
surface: binding.surface,
|
|
2019
|
+
target: binding.bucket,
|
|
2020
|
+
prefix: binding.objectPrefix,
|
|
2021
|
+
deleteExcludes: binding.mutableDeleteExcludes || [],
|
|
2022
|
+
},
|
|
2023
|
+
{
|
|
2024
|
+
action: "write-deployment-manifest",
|
|
2025
|
+
surface: binding.surface,
|
|
2026
|
+
target: manifestPrefixFor(deployConfig),
|
|
2027
|
+
key: binding.manifestKey,
|
|
2028
|
+
},
|
|
2029
|
+
{
|
|
2030
|
+
action: "invalidate-cdn",
|
|
2031
|
+
surface: binding.surface,
|
|
2032
|
+
distribution: binding.distributionId,
|
|
2033
|
+
},
|
|
2034
|
+
]);
|
|
1701
2035
|
return [
|
|
2036
|
+
...bindingSteps.filter((step) => step.action === "verify-immutable-artifact-before-upload"),
|
|
2037
|
+
...bindingSteps.filter((step) => step.action === "sync-immutable-artifact"),
|
|
2038
|
+
...bindingSteps.filter((step) => step.action === "verify-immutable-artifact-after-upload"),
|
|
1702
2039
|
...cloudFrontDirectoryIndexRewriteOperations(manifest.surfaceBindings || []).map((operation) => ({
|
|
1703
2040
|
action: operation.action,
|
|
1704
2041
|
distribution: operation.routing.distributionId,
|
|
1705
2042
|
functionName: operation.routing.functionName,
|
|
1706
2043
|
strategy: operation.routing.strategy,
|
|
1707
2044
|
})),
|
|
1708
|
-
...
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
prefix: binding.objectPrefix,
|
|
1714
|
-
},
|
|
1715
|
-
{
|
|
1716
|
-
action: "write-deployment-manifest",
|
|
1717
|
-
surface: binding.surface,
|
|
1718
|
-
target: manifestPrefixFor(deployConfig),
|
|
1719
|
-
key: binding.manifestKey,
|
|
1720
|
-
},
|
|
1721
|
-
{
|
|
1722
|
-
action: "invalidate-cdn",
|
|
1723
|
-
surface: binding.surface,
|
|
1724
|
-
distribution: binding.distributionId,
|
|
1725
|
-
},
|
|
1726
|
-
]),
|
|
2045
|
+
...bindingSteps.filter((step) => ![
|
|
2046
|
+
"verify-immutable-artifact-before-upload",
|
|
2047
|
+
"sync-immutable-artifact",
|
|
2048
|
+
"verify-immutable-artifact-after-upload",
|
|
2049
|
+
].includes(step.action)),
|
|
1727
2050
|
];
|
|
1728
2051
|
}
|
|
1729
2052
|
return [
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
function defaultCommandRunner(command, args) {
|
|
6
|
+
return spawnSync(command, args, {
|
|
7
|
+
encoding: null,
|
|
8
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function resultStatus(result) {
|
|
13
|
+
return result?.status ?? result?.exitCode ?? 0;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function resultText(value) {
|
|
17
|
+
return Buffer.isBuffer(value) ? value.toString("utf8") : String(value || "");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function assertSha256(value) {
|
|
21
|
+
const digest = String(value || "").trim().toLowerCase();
|
|
22
|
+
if (!/^[a-f0-9]{64}$/.test(digest)) {
|
|
23
|
+
throw new Error("expected SHA256 must be a 64-character hexadecimal digest");
|
|
24
|
+
}
|
|
25
|
+
return digest;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isMissingHeadObject(result) {
|
|
29
|
+
if (resultStatus(result) === 0) return false;
|
|
30
|
+
return /(?:404|not found|nosuchkey)/i.test(resultText(result?.stderr));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function assertCommandSucceeded(result, label) {
|
|
34
|
+
if (result?.error) throw result.error;
|
|
35
|
+
if (resultStatus(result) !== 0) {
|
|
36
|
+
const stderr = resultText(result?.stderr).trim();
|
|
37
|
+
throw new Error(`${label} failed with exit code ${resultStatus(result)}${stderr ? `: ${stderr}` : ""}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function checksumHexFromHead(head) {
|
|
42
|
+
const encoded = String(head?.ChecksumSHA256 || "").trim();
|
|
43
|
+
if (!encoded) return "";
|
|
44
|
+
const decoded = Buffer.from(encoded, "base64");
|
|
45
|
+
return decoded.length === 32 ? decoded.toString("hex") : "";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function verifyImmutableS3Object({
|
|
49
|
+
bucket,
|
|
50
|
+
key,
|
|
51
|
+
expectedSha256,
|
|
52
|
+
commandRunner = defaultCommandRunner,
|
|
53
|
+
} = {}) {
|
|
54
|
+
const expected = assertSha256(expectedSha256);
|
|
55
|
+
if (!String(bucket || "").trim() || !String(key || "").trim()) {
|
|
56
|
+
throw new Error("immutable S3 verification requires bucket and key");
|
|
57
|
+
}
|
|
58
|
+
const head = commandRunner("aws", [
|
|
59
|
+
"s3api",
|
|
60
|
+
"head-object",
|
|
61
|
+
"--bucket",
|
|
62
|
+
bucket,
|
|
63
|
+
"--key",
|
|
64
|
+
key,
|
|
65
|
+
"--checksum-mode",
|
|
66
|
+
"ENABLED",
|
|
67
|
+
]);
|
|
68
|
+
if (isMissingHeadObject(head)) {
|
|
69
|
+
return { status: "missing", bucket, key, expectedSha256: expected };
|
|
70
|
+
}
|
|
71
|
+
assertCommandSucceeded(head, `head immutable object s3://${bucket}/${key}`);
|
|
72
|
+
|
|
73
|
+
let stored = "";
|
|
74
|
+
let source = "";
|
|
75
|
+
try {
|
|
76
|
+
stored = checksumHexFromHead(JSON.parse(resultText(head.stdout) || "{}"));
|
|
77
|
+
} catch {
|
|
78
|
+
stored = "";
|
|
79
|
+
}
|
|
80
|
+
if (stored) {
|
|
81
|
+
source = "s3-checksum-sha256";
|
|
82
|
+
} else {
|
|
83
|
+
const download = commandRunner("aws", ["s3", "cp", `s3://${bucket}/${key}`, "-", "--only-show-errors"]);
|
|
84
|
+
assertCommandSucceeded(download, `read immutable object s3://${bucket}/${key}`);
|
|
85
|
+
const body = Buffer.isBuffer(download.stdout) ? download.stdout : Buffer.from(download.stdout || "");
|
|
86
|
+
stored = crypto.createHash("sha256").update(body).digest("hex");
|
|
87
|
+
source = "downloaded-object";
|
|
88
|
+
}
|
|
89
|
+
if (stored !== expected) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
`immutable object digest mismatch for s3://${bucket}/${key}: expected ${expected}, got ${stored}`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
return { status: "verified", bucket, key, sha256: stored, source };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function cliArgs(argv) {
|
|
98
|
+
const values = {};
|
|
99
|
+
for (let index = 0; index < argv.length; index += 2) {
|
|
100
|
+
const flag = argv[index];
|
|
101
|
+
const value = argv[index + 1];
|
|
102
|
+
if (!flag?.startsWith("--") || value === undefined) {
|
|
103
|
+
throw new Error("usage: web-surface-immutable-object --bucket NAME --key KEY --sha256 DIGEST");
|
|
104
|
+
}
|
|
105
|
+
values[flag.slice(2)] = value;
|
|
106
|
+
}
|
|
107
|
+
return values;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
111
|
+
try {
|
|
112
|
+
const args = cliArgs(process.argv.slice(2));
|
|
113
|
+
const result = verifyImmutableS3Object({
|
|
114
|
+
bucket: args.bucket,
|
|
115
|
+
key: args.key,
|
|
116
|
+
expectedSha256: args.sha256,
|
|
117
|
+
});
|
|
118
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
119
|
+
} catch (error) {
|
|
120
|
+
process.stderr.write(`${String(error.message || error)}\n`);
|
|
121
|
+
process.exitCode = 1;
|
|
122
|
+
}
|
|
123
|
+
}
|
package/scripts/web-surface.mjs
CHANGED
|
@@ -207,6 +207,9 @@ export function compactWebSurfaceApplyResult(result = {}) {
|
|
|
207
207
|
objectPrefix: result.objectPrefix || "",
|
|
208
208
|
manifestKey: result.manifestKey || "",
|
|
209
209
|
invalidationPaths: Array.isArray(result.invalidationPaths) ? result.invalidationPaths : [],
|
|
210
|
+
immutablePreservation: Array.isArray(result.immutablePreservation)
|
|
211
|
+
? result.immutablePreservation
|
|
212
|
+
: [],
|
|
210
213
|
surfaceBindings: Array.isArray(result.surfaceBindings)
|
|
211
214
|
? result.surfaceBindings.map((binding) => ({
|
|
212
215
|
surface: binding.surface || "",
|
|
@@ -216,6 +219,7 @@ export function compactWebSurfaceApplyResult(result = {}) {
|
|
|
216
219
|
manifestKey: binding.manifestKey || "",
|
|
217
220
|
accessControl: binding.accessControl || "",
|
|
218
221
|
healthStrategy: binding.healthStrategy || "",
|
|
222
|
+
mutableDeleteExcludes: binding.mutableDeleteExcludes || [],
|
|
219
223
|
}))
|
|
220
224
|
: [],
|
|
221
225
|
};
|