@empire-builder-kit/nx 1.0.0-rc.45 → 1.0.0-rc.46
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/package.json +3 -3
- package/src/developer-agent/generator-planning.js +10 -3
- package/src/developer-agent/operation-registry.js +1 -1
- package/src/executors/github-bootstrap/github-bootstrap.d.ts +3 -0
- package/src/executors/github-bootstrap/github-bootstrap.js +28 -0
- package/src/executors/github-bootstrap/schema.json +14 -0
- package/src/generators/preset/workspace-files-thin-deploy.js +5 -1
- package/src/generators/preset/workspace-files.js +31 -28
- package/src/generators/slice/built-in-plan-data-files.js +10 -3
- package/src/generators/slice/built-in-plan-metadata-files.js +4 -1
- package/src/generators/slice/built-in-plan-release-files.js +0 -1
- package/src/generators/slice/built-in-plan-verification-files.js +0 -1
- package/src/generators/slice/built-in-plan-web-files.js +4 -5
- package/src/generators/slice/built-in-plan.js +1 -2
- package/src/generators/upgrade/upgrade.js +89 -3
- package/src/generators/workload/reliability-registration.d.ts +1 -1
- package/src/generators/workload/reliability-registration.js +9 -9
- package/src/generators/workload/workload.d.ts +1 -1
- package/src/generators/workload/workload.js +27 -9
- package/src/git-policy/github-bootstrap.d.ts +18 -0
- package/src/git-policy/github-bootstrap.js +99 -6
- package/src/local-dev/fleet-session.js +41 -4
- package/src/local-dev/fleet-supervisor-daemon.js +1 -5
- package/src/local-dev/fleet-supervisor.d.ts +1 -0
- package/src/local-dev/fleet-supervisor.js +34 -11
- package/src/supply-chain/blueprint-verification-authority.js +24 -3
- package/src/supply-chain/sbom.js +74 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@empire-builder-kit/nx",
|
|
3
|
-
"version": "1.0.0-rc.
|
|
3
|
+
"version": "1.0.0-rc.46",
|
|
4
4
|
"description": "Nx generators, executors, and Blueprint contracts for Empire Builder Kit",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"aws",
|
|
@@ -145,7 +145,7 @@
|
|
|
145
145
|
"provenance": false
|
|
146
146
|
},
|
|
147
147
|
"dependencies": {
|
|
148
|
-
"@empire-builder-kit/runtime": "1.0.0-rc.
|
|
148
|
+
"@empire-builder-kit/runtime": "1.0.0-rc.46",
|
|
149
149
|
"@aws-sdk/client-cloudfront": "^3.700.0",
|
|
150
150
|
"@aws-sdk/client-cloudwatch": "^3.700.0",
|
|
151
151
|
"@aws-sdk/client-cloudwatch-logs": "^3.700.0",
|
|
@@ -178,5 +178,5 @@
|
|
|
178
178
|
"engines": {
|
|
179
179
|
"node": ">=22.13.0"
|
|
180
180
|
},
|
|
181
|
-
"gitHead": "
|
|
181
|
+
"gitHead": "2165c67769556e404a2d0e9d2f5bcdd23413fce8"
|
|
182
182
|
}
|
|
@@ -11,7 +11,8 @@ exports.rebindDeveloperAgentGeneratorPlan = rebindDeveloperAgentGeneratorPlan;
|
|
|
11
11
|
const node_util_1 = require("node:util");
|
|
12
12
|
const node_fs_1 = require("node:fs");
|
|
13
13
|
const node_path_1 = require("node:path");
|
|
14
|
-
const index_js_1 = require("../
|
|
14
|
+
const index_js_1 = require("../repository-policy/index.js");
|
|
15
|
+
const index_js_2 = require("../blueprints/index.js");
|
|
15
16
|
const function_js_1 = require("../generators/function/function.js");
|
|
16
17
|
const logging_registry_js_1 = require("../generators/logging-registry.js");
|
|
17
18
|
const built_in_plan_js_1 = require("../generators/slice/built-in-plan.js");
|
|
@@ -313,7 +314,7 @@ function slicePlan(context, input, mode, rebindCompleted = false) {
|
|
|
313
314
|
workspacePathExists(context, node_path_1.posix.join('packages', input.name))) {
|
|
314
315
|
return unavailableGeneratorDestination(`Product Slice ${input.name} collides with an existing packages/${input.name} path.`);
|
|
315
316
|
}
|
|
316
|
-
const blueprint = (0,
|
|
317
|
+
const blueprint = (0, index_js_2.resolveBlueprint)({
|
|
317
318
|
engineVersion: context.workspace.frameworkVersion,
|
|
318
319
|
reference: 'standard',
|
|
319
320
|
workspaceRoot: context.workspace.repositoryRoot,
|
|
@@ -420,7 +421,13 @@ function workloadPlan(context, input, mode, rebindCompleted = false) {
|
|
|
420
421
|
const ownsProtectedLoadAdapter = kind === 'service'
|
|
421
422
|
? serviceShouldOwnProtectedLoadAdapter(context, input.slice)
|
|
422
423
|
: false;
|
|
423
|
-
const plan = (0, workload_js_1.createWorkloadFilePlan)(context.workspace.identity, input.slice, input.name, kind, context.workspace.frameworkVersion, input.feature, ownsProtectedLoadAdapter
|
|
424
|
+
const plan = (0, workload_js_1.createWorkloadFilePlan)(context.workspace.identity, input.slice, input.name, kind, context.workspace.frameworkVersion, input.feature, ownsProtectedLoadAdapter, ownsProtectedLoadAdapter
|
|
425
|
+
? [
|
|
426
|
+
(0, index_js_1.productionRehearsalStage)((0, index_js_1.readRepositoryOperatingPolicy)(context.workspace.repositoryRoot)
|
|
427
|
+
.deploymentProfile),
|
|
428
|
+
'production',
|
|
429
|
+
]
|
|
430
|
+
: []);
|
|
424
431
|
if (!rebindCompleted) {
|
|
425
432
|
requireAvailableProspectiveProjectIds(context, [plan.project], `${kind} ${input.name}`);
|
|
426
433
|
}
|
|
@@ -409,7 +409,7 @@ const TARGET_SIGNATURES = [
|
|
|
409
409
|
: undefined;
|
|
410
410
|
}
|
|
411
411
|
const kind = plural === 'services' ? 'service' : 'job';
|
|
412
|
-
return (
|
|
412
|
+
return ('docker build -f Dockerfile ' +
|
|
413
413
|
`-t ${slice}-${kind}-${workload}:local ../../../..`);
|
|
414
414
|
}, { nonPlannableRationale: exports.DOCKER_BUILD_OWNER_RATIONALE }),
|
|
415
415
|
runScriptSignature('dev', (input) => input.project.root.includes('/services/')
|
|
@@ -3,6 +3,9 @@ interface GitHubBootstrapExecutorOptions {
|
|
|
3
3
|
apply?: boolean;
|
|
4
4
|
immutableOidcTrustAcknowledgement?: string;
|
|
5
5
|
npmPrereleaseReviewerIds?: string;
|
|
6
|
+
npmStable?: boolean;
|
|
7
|
+
npmStableReviewerIds?: string;
|
|
8
|
+
npmStablePreventSelfReview?: boolean;
|
|
6
9
|
productionReviewerIds?: string;
|
|
7
10
|
repository?: string;
|
|
8
11
|
}
|
|
@@ -10,6 +10,34 @@ function parseReviewerIds(value) {
|
|
|
10
10
|
}
|
|
11
11
|
const executor = async (options, context) => {
|
|
12
12
|
try {
|
|
13
|
+
if (options.npmStable === true) {
|
|
14
|
+
if (options.npmStableReviewerIds === undefined ||
|
|
15
|
+
!/^[1-9][0-9]*(,[1-9][0-9]*)*$/.test(options.npmStableReviewerIds) ||
|
|
16
|
+
typeof options.npmStablePreventSelfReview !== 'boolean') {
|
|
17
|
+
throw new Error('npm-stable requires explicit reviewer IDs and prevent-self-review choice.');
|
|
18
|
+
}
|
|
19
|
+
if (options.npmPrereleaseReviewerIds !== undefined ||
|
|
20
|
+
options.productionReviewerIds !== undefined ||
|
|
21
|
+
options.immutableOidcTrustAcknowledgement !== undefined) {
|
|
22
|
+
throw new Error('npm-stable does not accept adopter bootstrap options.');
|
|
23
|
+
}
|
|
24
|
+
const report = await (0, index_js_1.runGitHubBootstrap)({
|
|
25
|
+
npmStable: true,
|
|
26
|
+
apply: options.apply === true,
|
|
27
|
+
repository: options.repository?.trim() ?? '',
|
|
28
|
+
npmStableReviewerIds: parseReviewerIds(options.npmStableReviewerIds),
|
|
29
|
+
npmStablePreventSelfReview: options.npmStablePreventSelfReview,
|
|
30
|
+
token: process.env.GH_TOKEN?.trim() ||
|
|
31
|
+
process.env.GITHUB_TOKEN?.trim() ||
|
|
32
|
+
'',
|
|
33
|
+
});
|
|
34
|
+
devkit_1.logger.info(JSON.stringify(report, null, 2));
|
|
35
|
+
return { success: options.apply ? report.ready : true };
|
|
36
|
+
}
|
|
37
|
+
if (options.npmStableReviewerIds !== undefined ||
|
|
38
|
+
options.npmStablePreventSelfReview !== undefined) {
|
|
39
|
+
throw new Error('npm-stable settings require the explicit npmStable selector.');
|
|
40
|
+
}
|
|
13
41
|
const policy = (0, index_js_2.readRepositoryOperatingPolicy)(context.root);
|
|
14
42
|
const report = await (0, index_js_1.runGitHubBootstrap)({
|
|
15
43
|
apply: options.apply === true,
|
|
@@ -31,6 +31,20 @@
|
|
|
31
31
|
"type": "string",
|
|
32
32
|
"pattern": "^[1-9][0-9]*(,[1-9][0-9]*)*$",
|
|
33
33
|
"description": "Optional comma-separated GitHub user IDs when committed Repository Operating Policy selects environment-reviewer mode. Existing reviewers are preserved when omitted."
|
|
34
|
+
},
|
|
35
|
+
"npmStable": {
|
|
36
|
+
"type": "boolean",
|
|
37
|
+
"default": false,
|
|
38
|
+
"description": "Configure only the framework npm-stable environment; exact framework repository required."
|
|
39
|
+
},
|
|
40
|
+
"npmStableReviewerIds": {
|
|
41
|
+
"type": "string",
|
|
42
|
+
"pattern": "^[1-9][0-9]*(,[1-9][0-9]*)*$",
|
|
43
|
+
"description": "Explicit GitHub user IDs for the framework npm-stable required reviewers."
|
|
44
|
+
},
|
|
45
|
+
"npmStablePreventSelfReview": {
|
|
46
|
+
"type": "boolean",
|
|
47
|
+
"description": "Explicit prevent-self-review choice for the framework npm-stable environment."
|
|
34
48
|
}
|
|
35
49
|
}
|
|
36
50
|
}
|
|
@@ -759,7 +759,11 @@ ${indentedScript(`set -euo pipefail\n${canonicalStageAuthorityDerivationScript()
|
|
|
759
759
|
# production authority is derived here beside the staging authority.
|
|
760
760
|
- id: production-account
|
|
761
761
|
name: Derive canonical committed production authority for trunk promotion
|
|
762
|
-
if:
|
|
762
|
+
if: >-
|
|
763
|
+
steps.route.outputs.action == 'deploy' &&
|
|
764
|
+
steps.route.outputs.stage == 'staging' &&
|
|
765
|
+
steps.policy.outputs.deployment_profile != 'starter' &&
|
|
766
|
+
steps.policy.outputs.has_slices == 'true'
|
|
763
767
|
env:
|
|
764
768
|
EBK_DEVELOPMENT_ACCOUNT: \${{ steps.policy.outputs.development_account_id }}
|
|
765
769
|
EBK_PRODUCTION_ACCOUNT: \${{ steps.policy.outputs.production_account_id }}
|
|
@@ -7659,6 +7659,7 @@ console.log('Installed the repository-local EBK pre-push hook at ' + hookPath +
|
|
|
7659
7659
|
.open-next
|
|
7660
7660
|
dist
|
|
7661
7661
|
*.tsbuildinfo
|
|
7662
|
+
**/next-env.d.ts
|
|
7662
7663
|
node_modules
|
|
7663
7664
|
.sst
|
|
7664
7665
|
**/playwright/.auth
|
|
@@ -8365,36 +8366,38 @@ ${foundationPlan.resources.network ? " const natMode = productionConfiguration?
|
|
|
8365
8366
|
if (repositoryPolicy.deploymentProfile !== normalizedRouterWafPolicy.deploymentProfile) {
|
|
8366
8367
|
throw new Error('Router WAF policy profile differs from committed repository policy.');
|
|
8367
8368
|
}
|
|
8368
|
-
|
|
8369
|
-
|
|
8370
|
-
normalizedRouterWafPolicy.deploymentProfile
|
|
8371
|
-
|
|
8372
|
-
|
|
8373
|
-
|
|
8374
|
-
|
|
8375
|
-
|
|
8376
|
-
|
|
8377
|
-
|
|
8378
|
-
|
|
8379
|
-
|
|
8380
|
-
|
|
8369
|
+
let rehearsal: ReturnType<typeof reconcileRouterWafBlockingRehearsal> = null;
|
|
8370
|
+
if (isProduction) {
|
|
8371
|
+
const rehearsalStage = productionRehearsalStage(normalizedRouterWafPolicy.deploymentProfile);
|
|
8372
|
+
const rehearsalAccount = configuredDeploymentAccountForStage(
|
|
8373
|
+
normalizedRouterWafPolicy.deploymentProfile,
|
|
8374
|
+
repositoryPolicy.deploymentAccounts,
|
|
8375
|
+
rehearsalStage,
|
|
8376
|
+
);
|
|
8377
|
+
const rehearsalRegion = configuredDeploymentRegion(repositoryPolicy.deploymentRegion);
|
|
8378
|
+
const rehearsalRoleArn = createStageAuthorityIdentity({
|
|
8379
|
+
accountId: rehearsalAccount,
|
|
8380
|
+
partition: rehearsalRegion.startsWith('us-gov-') ? 'aws-us-gov' : 'aws',
|
|
8381
|
+
region: rehearsalRegion,
|
|
8382
|
+
stage: rehearsalStage,
|
|
8383
|
+
}).deploymentRoleArn;
|
|
8384
|
+
rehearsal = reconcileRouterWafBlockingRehearsal({
|
|
8385
|
+
evidence: routerWafLiveEvidence,
|
|
8386
|
+
expected: {
|
|
8387
|
+
accountId: rehearsalAccount,
|
|
8388
|
+
deploymentRoleArn: rehearsalRoleArn,
|
|
8389
|
+
region: rehearsalRegion,
|
|
8390
|
+
stage: rehearsalStage,
|
|
8391
|
+
wafContractDigest: routerWafContractDigest(normalizedRouterWafPolicy),
|
|
8392
|
+
workspace: '${name}',
|
|
8393
|
+
},
|
|
8394
|
+
policy: normalizedRouterWafPolicy,
|
|
8395
|
+
receipt: routerWafBlockingRehearsal,
|
|
8396
|
+
});
|
|
8397
|
+
}
|
|
8381
8398
|
const wafPlan = createRouterWafStagePlan({
|
|
8382
8399
|
policy: normalizedRouterWafPolicy,
|
|
8383
|
-
rehearsal
|
|
8384
|
-
? reconcileRouterWafBlockingRehearsal({
|
|
8385
|
-
evidence: routerWafLiveEvidence,
|
|
8386
|
-
expected: {
|
|
8387
|
-
accountId: rehearsalAccount,
|
|
8388
|
-
deploymentRoleArn: rehearsalRoleArn,
|
|
8389
|
-
region: rehearsalRegion,
|
|
8390
|
-
stage: rehearsalStage,
|
|
8391
|
-
wafContractDigest: routerWafContractDigest(normalizedRouterWafPolicy),
|
|
8392
|
-
workspace: '${name}',
|
|
8393
|
-
},
|
|
8394
|
-
policy: normalizedRouterWafPolicy,
|
|
8395
|
-
receipt: routerWafBlockingRehearsal,
|
|
8396
|
-
})
|
|
8397
|
-
: null,
|
|
8400
|
+
rehearsal,
|
|
8398
8401
|
stage: $app.stage,
|
|
8399
8402
|
});
|
|
8400
8403
|
const deploymentAccount = process.env.AWS_EXPECTED_ACCOUNT_ID?.trim() ?? '';
|
|
@@ -350,9 +350,16 @@ value to GitHub secrets, workflow input, argv, or agent-visible material.
|
|
|
350
350
|
|
|
351
351
|
The protected deploy role needs metadata authority for the Slice prefix:
|
|
352
352
|
\`ListSecrets\`, \`DescribeSecret\`, \`CreateSecret\`, and \`TagResource\`.
|
|
353
|
-
It
|
|
354
|
-
|
|
355
|
-
|
|
353
|
+
It is denied \`BatchGetSecretValue\`, \`UpdateSecretVersionStage\`, and
|
|
354
|
+
application deployment outside its separately reviewed deploy policy, and it is
|
|
355
|
+
denied \`GetSecretValue\` and \`PutSecretValue\` for every operator and
|
|
356
|
+
generated secret; the exact tagged Foundation database credential is the only
|
|
357
|
+
value-action exception. \`UpdateSecret\` is permitted only inside the stage's
|
|
358
|
+
\`ebk/<stage>/*\` namespace and the Foundation provider secret, so native
|
|
359
|
+
recovery can reconcile EBK-owned containers. \`CreateSecret\` and
|
|
360
|
+
\`UpdateSecret\` can both carry a value and IAM cannot prove a call is
|
|
361
|
+
metadata-only, so reviewed generated source, temporary deployment credentials,
|
|
362
|
+
and CloudTrail are the controls.
|
|
356
363
|
\`DeleteSecret\` is used only by the governed generated-secret retirement path
|
|
357
364
|
after native Slice removal succeeds; the executor binds that call to the exact
|
|
358
365
|
generated HMAC name and ownership tags described above.
|
|
@@ -337,7 +337,10 @@ keep the source checkout, continue an interrupted report with its run
|
|
|
337
337
|
reference, and retry the same approved run. Never delete its checkpoint,
|
|
338
338
|
pending deletion work or retained evidence to silence the alarm.
|
|
339
339
|
`,
|
|
340
|
-
'ebk-globals.d.ts':
|
|
340
|
+
'ebk-globals.d.ts': `// Next.js ambient types, independent of the gitignored web/next-env.d.ts.
|
|
341
|
+
/// <reference types="next" />
|
|
342
|
+
/// <reference types="next/image-types/global" />
|
|
343
|
+
import 'sst/resource';
|
|
341
344
|
|
|
342
345
|
declare global {
|
|
343
346
|
type EbkSstOutput<T> = {
|
|
@@ -6360,7 +6360,6 @@ if (['stopped', 'recovering'].includes(result.rollout.phase)) {
|
|
|
6360
6360
|
sourceInputPaths: [
|
|
6361
6361
|
`packages/${name}/configuration`,
|
|
6362
6362
|
`packages/${name}/infra`,
|
|
6363
|
-
`packages/${name}/web/next-env.d.ts`,
|
|
6364
6363
|
`packages/${name}/web/next.config.ts`,
|
|
6365
6364
|
`packages/${name}/web/open-next.config.ts`,
|
|
6366
6365
|
`packages/${name}/web/src`,
|
|
@@ -416,7 +416,6 @@ test('renders the Slice through its exact task HTTPS origin', async ({ page }) =
|
|
|
416
416
|
expect(response?.status()).toBe(200);
|
|
417
417
|
expect(response?.headers()['x-ebk-upstream']).toBe('local');
|
|
418
418
|
expect(new URL(page.url()).origin).toBe(target.origin);
|
|
419
|
-
await expect(page.getByRole('heading', { name: '${pascalName}', exact: true })).toBeVisible();
|
|
420
419
|
});
|
|
421
420
|
`,
|
|
422
421
|
'playwright.config.ts': `import { defineConfig } from '@playwright/test';
|
|
@@ -2,11 +2,9 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.renderProductWebFiles = renderProductWebFiles;
|
|
4
4
|
const built_in_plan_shared_js_1 = require("./built-in-plan-shared.js");
|
|
5
|
-
const sst_environment_source_format_js_1 = require("../../sst-environment-source-format.js");
|
|
6
5
|
/** @internal */
|
|
7
6
|
function renderProductWebFiles(name, pascalName, workspace, sql = false) {
|
|
8
7
|
return {
|
|
9
|
-
'web/next-env.d.ts': (0, sst_environment_source_format_js_1.generatedNextEnvironmentSource)(),
|
|
10
8
|
'web/tsconfig.json': (0, built_in_plan_shared_js_1.json)({
|
|
11
9
|
extends: '../tsconfig.json',
|
|
12
10
|
compilerOptions: {
|
|
@@ -18,9 +16,10 @@ function renderProductWebFiles(name, pascalName, workspace, sql = false) {
|
|
|
18
16
|
incremental: true,
|
|
19
17
|
plugins: [{ name: 'next' }],
|
|
20
18
|
},
|
|
21
|
-
// Next
|
|
22
|
-
//
|
|
23
|
-
//
|
|
19
|
+
// Next rewrites tsconfig.json when these entries are missing, so the
|
|
20
|
+
// generated include matches its output. Next writes the gitignored
|
|
21
|
+
// next-env.d.ts itself on dev, build, and typegen; EBK does not
|
|
22
|
+
// generate or track it.
|
|
24
23
|
include: [
|
|
25
24
|
'next-env.d.ts',
|
|
26
25
|
'.next/types/**/*.ts',
|
|
@@ -48,7 +48,6 @@ const FORMAT_SENSITIVE_BUILT_IN_PATHS = new Set([
|
|
|
48
48
|
'api/generated/baseline.json',
|
|
49
49
|
'api/generated/contract.json',
|
|
50
50
|
'api/generated/openapi.json',
|
|
51
|
-
'web/next-env.d.ts',
|
|
52
51
|
]);
|
|
53
52
|
function isBuiltInSliceFormatSensitivePath(path) {
|
|
54
53
|
return (path.startsWith('database/migrations/') ||
|
|
@@ -257,7 +256,7 @@ function classifyPaths(files, sql) {
|
|
|
257
256
|
]);
|
|
258
257
|
for (const path of built_in_plan_performance_files_js_1.PRODUCT_PERFORMANCE_SEEDED_PATHS)
|
|
259
258
|
seededPaths.add(path);
|
|
260
|
-
const toolOwnedPaths = new Set(['sst-env.d.ts'
|
|
259
|
+
const toolOwnedPaths = new Set(['sst-env.d.ts']);
|
|
261
260
|
return Object.entries(files)
|
|
262
261
|
.filter(([path]) => path !== '.ebk/ownership.json' && !toolOwnedPaths.has(path))
|
|
263
262
|
.map(([path, contents]) => {
|
|
@@ -470,6 +470,89 @@ function appendSeededReconciliationMarker(tree, root, report) {
|
|
|
470
470
|
return;
|
|
471
471
|
tree.write(anchor, `${current.replace(/\s*$/, '')}\n\n${SEEDED_RECONCILIATION_MARKER}\n`);
|
|
472
472
|
}
|
|
473
|
+
function isJsonObject(value) {
|
|
474
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
475
|
+
}
|
|
476
|
+
function appendServiceTaskPresenceReconciliation(currentFiles, currentOwnership, targetOwnership, report) {
|
|
477
|
+
const services = currentOwnership.compositions
|
|
478
|
+
.filter(({ kind }) => kind === 'service')
|
|
479
|
+
.map(({ name }) => name)
|
|
480
|
+
.sort(deterministic_order_js_1.compareUtf16);
|
|
481
|
+
if (services.length === 0)
|
|
482
|
+
return;
|
|
483
|
+
const registryPath = 'operations/reliability.json';
|
|
484
|
+
const source = currentFiles.get(registryPath);
|
|
485
|
+
if (source === null || source === undefined)
|
|
486
|
+
return;
|
|
487
|
+
let operations;
|
|
488
|
+
try {
|
|
489
|
+
operations = JSON.parse(source);
|
|
490
|
+
}
|
|
491
|
+
catch {
|
|
492
|
+
throw new Error('Retained Service reliability registry must be valid JSON before upgrade.');
|
|
493
|
+
}
|
|
494
|
+
if (!isJsonObject(operations) ||
|
|
495
|
+
!Array.isArray(operations.deployedAlarmResources)) {
|
|
496
|
+
throw new Error('Retained Service reliability registry must expose deployedAlarmResources before upgrade.');
|
|
497
|
+
}
|
|
498
|
+
const resources = operations.deployedAlarmResources;
|
|
499
|
+
const append = (path, guidance) => {
|
|
500
|
+
const current = currentFiles.get(path);
|
|
501
|
+
const targetEntry = targetOwnership.paths.find((entry) => entry.path === path);
|
|
502
|
+
const currentEntry = currentOwnership.paths.find((entry) => entry.path === path);
|
|
503
|
+
if (current === null ||
|
|
504
|
+
current === undefined ||
|
|
505
|
+
targetEntry?.mode !== 'seeded')
|
|
506
|
+
return;
|
|
507
|
+
const existing = report.seededReconciliation.find((entry) => entry.path === path);
|
|
508
|
+
if (existing) {
|
|
509
|
+
existing.guidance = `${existing.guidance ? `${existing.guidance}\n\n` : ''}${guidance}`;
|
|
510
|
+
existing.implications = [
|
|
511
|
+
...new Set([...existing.implications, 'service-live-task-presence']),
|
|
512
|
+
].sort(deterministic_order_js_1.compareUtf16);
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
report.seededReconciliation.push({
|
|
516
|
+
adopterContentSha256: (0, index_js_5.hashOwnedContent)(current, currentEntry ?? targetEntry),
|
|
517
|
+
category: 'reliability-operations',
|
|
518
|
+
currentFrameworkSha256: currentEntry?.sha256 ?? null,
|
|
519
|
+
guidance,
|
|
520
|
+
implications: ['alarm-registration', 'service-live-task-presence'],
|
|
521
|
+
markerReference: SEEDED_RECONCILIATION_MARKER_REFERENCE,
|
|
522
|
+
path,
|
|
523
|
+
requiredAction: 'review-and-reconcile-preserving-adopter-content',
|
|
524
|
+
targetFrameworkSha256: targetEntry.sha256,
|
|
525
|
+
});
|
|
526
|
+
};
|
|
527
|
+
for (const name of services) {
|
|
528
|
+
const id = `service-${name}-health`;
|
|
529
|
+
const legacy = resources.some((entry) => {
|
|
530
|
+
if (!isJsonObject(entry))
|
|
531
|
+
return false;
|
|
532
|
+
const expected = {
|
|
533
|
+
comparisonOperator: 'LessThanThreshold',
|
|
534
|
+
evaluationPeriods: 1,
|
|
535
|
+
id,
|
|
536
|
+
metricName: 'RunningTaskCount',
|
|
537
|
+
name: `${currentOwnership.slice}-{stage}-service-${name}-running`,
|
|
538
|
+
namespace: 'ECS/ContainerInsights',
|
|
539
|
+
period: 60,
|
|
540
|
+
signal: 'metric',
|
|
541
|
+
statistic: 'Sum',
|
|
542
|
+
threshold: 1,
|
|
543
|
+
treatMissingData: 'breaching',
|
|
544
|
+
};
|
|
545
|
+
return (Object.entries(expected).every(([key, value]) => entry[key] === value) &&
|
|
546
|
+
JSON.stringify(entry.dimensionNames) ===
|
|
547
|
+
JSON.stringify(['ClusterName', 'ServiceName']));
|
|
548
|
+
});
|
|
549
|
+
if (!legacy)
|
|
550
|
+
continue;
|
|
551
|
+
append(registryPath, `For recorded Service ${name}, reconcile only deployedAlarmResources[id=${id}]: namespace ECS/ContainerInsights -> AWS/ECS, metricName RunningTaskCount -> LiveTaskCount, statistic Sum -> Minimum. Keep its ID, physical name, actions, dimensions, threshold 1, LessThanThreshold, 60-second period, one evaluation period and breaching missing-data policy. In alarmRegistrations[id=${id}], set missingData to breaching and review businessSymptom, condition and both expected risk fields for task presence or missing telemetry, startup/emission-gap false positives, and activating/draining/replacement crash-loop false negatives. Preserve unrelated registrations and every adopter-owned field. Review services/${name}/RUNBOOK.md, run the declared reliability verification after authorized deployment, retain evidence, then resolve the existing seeded reconciliation marker.`);
|
|
552
|
+
append(`services/${name}/RUNBOOK.md`, `Review the retained Service ${name} runbook for AWS/ECS LiveTaskCount Minimum: ACTIVATING, RUNNING and DEACTIVATING task presence, not readiness. Missing telemetry breaches but does not prove zero tasks. Document startup/emission-gap false positives and activating/draining/replacement crash-loop false negatives, preserve custom recovery instructions, verify the critical journey and alarm recovery after an authorized roll-forward, retain evidence, then resolve the existing seeded reconciliation marker.`);
|
|
553
|
+
}
|
|
554
|
+
report.seededReconciliation.sort((left, right) => (0, deterministic_order_js_1.compareUtf16)(left.path, right.path));
|
|
555
|
+
}
|
|
473
556
|
function currentContractConsumers(tree, root) {
|
|
474
557
|
const source = tree.read(node_path_1.posix.join(root, 'contracts/dependencies.json'), 'utf8');
|
|
475
558
|
if (source === null)
|
|
@@ -1110,7 +1193,7 @@ async function formatMergedManagedJsonFiles(tree, root, merge, ownership) {
|
|
|
1110
1193
|
}));
|
|
1111
1194
|
return { ...merge, files };
|
|
1112
1195
|
}
|
|
1113
|
-
function renderCompositionUpgradePlan(ownership, workspace, runtimeVersion, serviceAdapterOwner) {
|
|
1196
|
+
function renderCompositionUpgradePlan(ownership, workspace, runtimeVersion, serviceAdapterOwner, deploymentProfile) {
|
|
1114
1197
|
const compositionPaths = ownership.paths.filter(({ origin }) => origin === 'composition');
|
|
1115
1198
|
const files = {};
|
|
1116
1199
|
const paths = new Map();
|
|
@@ -1139,7 +1222,7 @@ function renderCompositionUpgradePlan(ownership, workspace, runtimeVersion, serv
|
|
|
1139
1222
|
}
|
|
1140
1223
|
else {
|
|
1141
1224
|
const plan = (0, workload_js_1.createWorkloadFilePlan)(workspace, ownership.slice, composition.name, composition.kind, runtimeVersion, composition.feature, composition.kind === 'service' &&
|
|
1142
|
-
composition.name === serviceAdapterOwner);
|
|
1225
|
+
composition.name === serviceAdapterOwner, [(0, index_js_4.productionRehearsalStage)(deploymentProfile), 'production']);
|
|
1143
1226
|
addPlan(plan.files, plan.paths);
|
|
1144
1227
|
}
|
|
1145
1228
|
}
|
|
@@ -1672,7 +1755,7 @@ async function executeUpgradeGenerator(tree, options, output = devkit_1.logger,
|
|
|
1672
1755
|
targetFiles = preserveBuiltInDerivedApiArtifacts(tree, root, currentOwnership, targetFiles);
|
|
1673
1756
|
}
|
|
1674
1757
|
const targetPathSet = new Set(targetOwnership.paths.map(({ path }) => path));
|
|
1675
|
-
const compositionPlan = renderCompositionUpgradePlan(currentOwnership, packageJson.name ?? '', packageJson.dependencies?.['@empire-builder-kit/runtime'] ?? '', serviceAdapterOwner);
|
|
1758
|
+
const compositionPlan = renderCompositionUpgradePlan(currentOwnership, packageJson.name ?? '', packageJson.dependencies?.['@empire-builder-kit/runtime'] ?? '', serviceAdapterOwner, (0, tree_policy_js_1.readRepositoryOperatingPolicyFromTree)(tree).deploymentProfile);
|
|
1676
1759
|
const compositionPaths = compositionPlan.paths.filter(({ path }) => !targetPathSet.has(path));
|
|
1677
1760
|
if (externalBlueprintDestinationPaths) {
|
|
1678
1761
|
(0, index_js_1.assertBlueprintDestinationPaths)(tree.root, root, [
|
|
@@ -1745,6 +1828,9 @@ async function executeUpgradeGenerator(tree, options, output = devkit_1.logger,
|
|
|
1745
1828
|
mergedTarget.files.set(logging_registry_js_1.SLICE_LOGGING_REGISTRY_PATH, await (0, formatting_js_1.formatTreeContentsToFixedPoint)(loggingAppend, formatting, workspacePath));
|
|
1746
1829
|
}
|
|
1747
1830
|
const report = createUpgradeReport(currentFiles, currentOwnership, mergedTarget.files, targetOwnership, mergedTarget);
|
|
1831
|
+
if (blueprint.source === 'built-in') {
|
|
1832
|
+
appendServiceTaskPresenceReconciliation(currentFiles, currentOwnership, targetOwnership, report);
|
|
1833
|
+
}
|
|
1748
1834
|
if (loggingAppend !== null) {
|
|
1749
1835
|
report.preservedSeeded = report.preservedSeeded.filter((path) => path !== logging_registry_js_1.SLICE_LOGGING_REGISTRY_PATH);
|
|
1750
1836
|
report.appendedSeeded.push(logging_registry_js_1.SLICE_LOGGING_REGISTRY_PATH);
|
|
@@ -3,7 +3,7 @@ export declare const WORKLOAD_RELIABILITY_REGISTRY_PATH: "operations/reliability
|
|
|
3
3
|
export interface WorkloadReliabilityPlan {
|
|
4
4
|
alarmId: string;
|
|
5
5
|
contents: string;
|
|
6
|
-
metricName: '
|
|
6
|
+
metricName: 'LiveTaskCount' | 'MatchedEvents';
|
|
7
7
|
path: string;
|
|
8
8
|
runbookRef: string;
|
|
9
9
|
}
|
|
@@ -61,21 +61,21 @@ function planWorkloadReliabilityRegistration(tree, input) {
|
|
|
61
61
|
...operations.alarmRegistrations,
|
|
62
62
|
(0, reliability_1.validateAlarmRegistrationMetadata)({
|
|
63
63
|
businessSymptom: input.kind === 'service'
|
|
64
|
-
? `${input.name} Service
|
|
64
|
+
? `${input.name} Service task presence is below one or telemetry is missing.`
|
|
65
65
|
: `${input.name} Job executions are failing.`,
|
|
66
66
|
condition: input.kind === 'service'
|
|
67
|
-
? 'The AWS
|
|
67
|
+
? 'The AWS/ECS LiveTaskCount minimum is below one or missing for the exact Service; it counts ACTIVATING, RUNNING, and DEACTIVATING tasks.'
|
|
68
68
|
: 'An AWS-native ECS task-state event reports an essential container exit for the exact Job task family.',
|
|
69
69
|
dependencyAlarmIds: [],
|
|
70
70
|
escalationRouteKey: `${input.slice}-on-call`,
|
|
71
71
|
expectedFalseNegativeRisk: input.kind === 'service'
|
|
72
|
-
? '
|
|
72
|
+
? 'Activating, draining, or repeatedly replaced tasks can keep LiveTaskCount positive while readiness or useful work fails.'
|
|
73
73
|
: 'ECS task-state events are durable but can be delayed before EventBridge reports a match.',
|
|
74
74
|
expectedFalsePositiveRisk: input.kind === 'service'
|
|
75
|
-
? '
|
|
75
|
+
? 'Metric emission gaps, startup, or lifecycle transitions can breach the missing-data policy while tasks remain present.'
|
|
76
76
|
: 'A retried Job attempt can fail before a later attempt succeeds.',
|
|
77
77
|
id: alarmId,
|
|
78
|
-
missingData: 'missing',
|
|
78
|
+
missingData: input.kind === 'service' ? 'breaching' : 'missing',
|
|
79
79
|
owner,
|
|
80
80
|
runbookRef: `runbook:${runbookRef}`,
|
|
81
81
|
severity: 'high',
|
|
@@ -90,15 +90,15 @@ function planWorkloadReliabilityRegistration(tree, input) {
|
|
|
90
90
|
dimensionNames: input.kind === 'service' ? ['ClusterName', 'ServiceName'] : ['RuleName'],
|
|
91
91
|
evaluationPeriods: 1,
|
|
92
92
|
id: alarmId,
|
|
93
|
-
metricName: input.kind === 'service' ? '
|
|
93
|
+
metricName: input.kind === 'service' ? 'LiveTaskCount' : 'MatchedEvents',
|
|
94
94
|
name: `${input.slice}-{stage}-${input.kind}-${input.name}-${input.kind === 'service' ? 'running' : 'hard-crash'}`,
|
|
95
|
-
namespace: input.kind === 'service' ? 'ECS
|
|
95
|
+
namespace: input.kind === 'service' ? 'AWS/ECS' : 'AWS/Events',
|
|
96
96
|
owner,
|
|
97
97
|
period: 60,
|
|
98
98
|
route: `${input.slice}-on-call`,
|
|
99
99
|
runbook: runbookRef,
|
|
100
100
|
signal: 'metric',
|
|
101
|
-
statistic: 'Sum',
|
|
101
|
+
statistic: input.kind === 'service' ? 'Minimum' : 'Sum',
|
|
102
102
|
threshold: 1,
|
|
103
103
|
treatMissingData: input.kind === 'service' ? 'breaching' : 'notBreaching',
|
|
104
104
|
};
|
|
@@ -142,7 +142,7 @@ function planWorkloadReliabilityRegistration(tree, input) {
|
|
|
142
142
|
return {
|
|
143
143
|
alarmId,
|
|
144
144
|
contents: `${JSON.stringify(operations, null, 2)}\n`,
|
|
145
|
-
metricName: input.kind === 'service' ? '
|
|
145
|
+
metricName: input.kind === 'service' ? 'LiveTaskCount' : 'MatchedEvents',
|
|
146
146
|
path,
|
|
147
147
|
runbookRef,
|
|
148
148
|
};
|
|
@@ -13,5 +13,5 @@ export interface WorkloadFilePlan {
|
|
|
13
13
|
}>;
|
|
14
14
|
project: string;
|
|
15
15
|
}
|
|
16
|
-
export declare function createWorkloadFilePlan(workspace: string, slice: string, workload: string, kind: WorkloadKind, runtimeVersion: string, feature: string, ownsProtectedLoadAdapter: boolean, protectedRuntimeStages
|
|
16
|
+
export declare function createWorkloadFilePlan(workspace: string, slice: string, workload: string, kind: WorkloadKind, runtimeVersion: string, feature: string, ownsProtectedLoadAdapter: boolean, protectedRuntimeStages: readonly ('development' | 'production' | 'staging')[]): WorkloadFilePlan;
|
|
17
17
|
export declare function workloadGenerator(tree: Tree, options: WorkloadGeneratorSchema, kind: WorkloadKind): Promise<GeneratorCallback | void>;
|
|
@@ -139,7 +139,7 @@ exports.SERVICE_WORKLOAD_PACKAGE_MANAGED_JSON_POINTERS = [
|
|
|
139
139
|
'/dependencies/@aws-sdk~1client-dynamodb',
|
|
140
140
|
'/dependencies/sst',
|
|
141
141
|
];
|
|
142
|
-
function createWorkloadFilePlan(workspace, slice, workload, kind, runtimeVersion, feature, ownsProtectedLoadAdapter, protectedRuntimeStages
|
|
142
|
+
function createWorkloadFilePlan(workspace, slice, workload, kind, runtimeVersion, feature, ownsProtectedLoadAdapter, protectedRuntimeStages) {
|
|
143
143
|
const plural = kind === 'service' ? 'services' : 'jobs';
|
|
144
144
|
const relativeRoot = node_path_1.posix.join(plural, workload);
|
|
145
145
|
const className = `${pascal(slice)}${pascal(workload)}${pascal(kind)}`;
|
|
@@ -161,7 +161,7 @@ function createWorkloadFilePlan(workspace, slice, workload, kind, runtimeVersion
|
|
|
161
161
|
version: '0.0.0',
|
|
162
162
|
scripts: {
|
|
163
163
|
build: 'tsc -p tsconfig.json',
|
|
164
|
-
'docker-build':
|
|
164
|
+
'docker-build': 'docker build -f Dockerfile ' +
|
|
165
165
|
`-t ${slice}-${kind}-${workload}:local ../../../..`,
|
|
166
166
|
dev: `node --experimental-strip-types ${kind === 'service' ? 'src/main.ts' : 'src/run.ts'}`,
|
|
167
167
|
lint: 'eslint .',
|
|
@@ -212,7 +212,7 @@ function createWorkloadFilePlan(workspace, slice, workload, kind, runtimeVersion
|
|
|
212
212
|
[`${relativeRoot}/src/workload.spec.ts`]: kind === 'service'
|
|
213
213
|
? `import { describe, expect, it } from 'vitest';\nimport { healthResponse } from './main.js';\n\ndescribe('${workload} service', () => {\n it('reports healthy only on the health endpoint', () => {\n expect(healthResponse('/health')).toEqual({ body: { service: '${workload}', status: 'ok' }, status: 200 });\n expect(healthResponse('/missing').status).toBe(404);\n });\n});\n`
|
|
214
214
|
: `import { createLogger, type LogEntry } from '@empire-builder-kit/runtime/logging';\nimport { describe, expect, it } from 'vitest';\nimport { run } from './run.js';\n\ndescribe('${workload} job', () => {\n it('emits canonical start and completion records', async () => {\n const entries: LogEntry[] = [];\n const logger = createLogger({\n component: 'job-${workload}', feature: '${feature}', operation: 'run',\n slice: '${slice}', stage: 'test', workspace: '${workspace}',\n sink: (entry) => entries.push(entry as LogEntry),\n });\n await expect(run(logger)).resolves.toEqual({\n ok: true,\n providerDeliveryAssigned: false,\n });\n expect(entries.map(({ message, outcome }) => ({ message, outcome }))).toEqual([\n { message: '${workload} job started', outcome: undefined },\n { message: '${workload} job completed', outcome: 'success' },\n ]);\n });\n});\n`,
|
|
215
|
-
[`${relativeRoot}/infra.ts`]: `import { createManagedLogGroupContract } from '@empire-builder-kit/nx/foundation';\nimport { standardSliceExtensionTaskFamily } from '../../infra/workload-roles/effective-intent.js';\n\nexport function ${factoryName}(\n cluster: any,\n alertTopicArn: any,\n roles?: { executionRole: string; taskRole: string },\n links: any[] = [],\n configuration?: {\n applicationId: any;\n deployment: any;\n environmentId: any;\n profileId: any;\n },\n) {\n const contract = createManagedLogGroupContract({\n component: '${kind}-${workload}',\n slice: '${slice}',\n stage: $app.stage,\n workspace: '${workspace}',\n });\n const workload = new sst.aws.${resource}('${className}', {\n ...(roles ?? {}),\n cluster,\n environment: {\n EBK_STAGE: $app.stage,\n ...(configuration\n ? {\n EBK_APPCONFIG_APPLICATION_ID: configuration.applicationId,\n EBK_APPCONFIG_ENVIRONMENT_ID: configuration.environmentId,\n EBK_APPCONFIG_PROFILE_ID: configuration.profileId,\n }\n : {}),\n },\n link: links,\n ${kind === 'service' ? "image: { context: './services/" + workload + "', dockerfile: 'Dockerfile' },\n health: { command: ['CMD-SHELL', 'wget -qO- http://localhost:3000/health || exit 1'] },\n logging: { name: contract.name, retention: '1 month' },\n serviceRegistry: { port: 3000 }," : "image: { context: './jobs/" + workload + "', dockerfile: 'Dockerfile' },\n logging: { name: contract.name, retention: '1 month' },"}\n transform: {\n logGroup: {\n logGroupClass: contract.logGroupClass,\n tags: contract.tags,\n },\n taskDefinition: {\n family: standardSliceExtensionTaskFamily(\n $app.stage,\n '${workload}',\n '${kind}',\n ),\n },\n },\n }, configuration?.deployment\n ? { dependsOn: [configuration.deployment] }\n : {});\n const dataProtection = new aws.cloudwatch.LogDataProtectionPolicy(\n '${className}LogDataProtection',\n {\n logGroupName: contract.name,\n policyDocument: JSON.stringify(contract.dataProtectionPolicy),\n },\n { dependsOn: [workload.nodes.taskDefinition] },\n );\n const dataProtectionAlarm = new aws.cloudwatch.MetricAlarm(\n '${className}LogDataProtectionFindings',\n {\n alarmActions: [alertTopicArn],\n comparisonOperator: 'GreaterThanOrEqualToThreshold',\n dimensions: { LogGroupName: contract.name },\n evaluationPeriods: 1,\n metricName: 'LogEventsWithFindings',\n namespace: 'AWS/Logs',\n period: 300,\n statistic: 'Sum',\n threshold: 1,\n treatMissingData: 'notBreaching',\n },\n { dependsOn: [dataProtection] },\n );\n void dataProtectionAlarm;\n return workload;\n}\n`,
|
|
215
|
+
[`${relativeRoot}/infra.ts`]: `import { createManagedLogGroupContract } from '@empire-builder-kit/nx/foundation';\nimport { standardSliceExtensionTaskFamily } from '../../infra/workload-roles/effective-intent.js';\n\nexport function ${factoryName}(\n cluster: any,\n alertTopicArn: any,\n roles?: { executionRole: string; taskRole: string },\n links: any[] = [],\n configuration?: {\n applicationId: any;\n deployment: any;\n environmentId: any;\n profileId: any;\n },\n) {\n const contract = createManagedLogGroupContract({\n component: '${kind}-${workload}',\n slice: '${slice}',\n stage: $app.stage,\n workspace: '${workspace}',\n });\n const workload = new sst.aws.${resource}('${className}', {\n ...(roles ?? {}),\n cluster,\n environment: {\n EBK_STAGE: $app.stage,\n ...(configuration\n ? {\n EBK_APPCONFIG_APPLICATION_ID: configuration.applicationId,\n EBK_APPCONFIG_ENVIRONMENT_ID: configuration.environmentId,\n EBK_APPCONFIG_PROFILE_ID: configuration.profileId,\n }\n : {}),\n },\n link: links,\n ${kind === 'service' ? "image: { context: './services/" + workload + "', dockerfile: 'Dockerfile' },\n health: { command: ['CMD-SHELL', 'wget -qO- http://localhost:3000/health || exit 1'] },\n logging: { name: contract.name, retention: '1 month' },\n serviceRegistry: { port: 3000 }," : "image: { context: './jobs/" + workload + "', dockerfile: 'Dockerfile' },\n logging: { name: contract.name, retention: '1 month' },"}\n transform: {\n logGroup: {\n logGroupClass: contract.logGroupClass,\n tags: contract.tags,\n },\n taskDefinition: {\n family: standardSliceExtensionTaskFamily(\n $app.stage,\n '${workload}',\n '${kind}',\n ),\n },\n },\n }, configuration?.deployment\n ? { dependsOn: [configuration.deployment] }\n : {});\n${kind === 'service' ? ' if ($dev) return workload;\n' : ''} const dataProtection = new aws.cloudwatch.LogDataProtectionPolicy(\n '${className}LogDataProtection',\n {\n logGroupName: contract.name,\n policyDocument: JSON.stringify(contract.dataProtectionPolicy),\n },\n { dependsOn: [workload.nodes.taskDefinition] },\n );\n const dataProtectionAlarm = new aws.cloudwatch.MetricAlarm(\n '${className}LogDataProtectionFindings',\n {\n alarmActions: [alertTopicArn],\n comparisonOperator: 'GreaterThanOrEqualToThreshold',\n dimensions: { LogGroupName: contract.name },\n evaluationPeriods: 1,\n metricName: 'LogEventsWithFindings',\n namespace: 'AWS/Logs',\n period: 300,\n statistic: 'Sum',\n threshold: 1,\n treatMissingData: 'notBreaching',\n },\n { dependsOn: [dataProtection] },\n );\n void dataProtectionAlarm;\n return workload;\n}\n`,
|
|
216
216
|
};
|
|
217
217
|
if (kind === 'service') {
|
|
218
218
|
const serviceMainPath = `${relativeRoot}/src/main.ts`;
|
|
@@ -413,7 +413,7 @@ ${kind !== 'service' || ownsProtectedLoadAdapter
|
|
|
413
413
|
alarmDescription: JSON.stringify({
|
|
414
414
|
businessSymptom:
|
|
415
415
|
'${kind === 'service'
|
|
416
|
-
? `${workload} Service
|
|
416
|
+
? `${workload} Service task presence is below one or telemetry is missing.`
|
|
417
417
|
: `${workload} Job essential container exited.`}',
|
|
418
418
|
id: '${kind === 'service'
|
|
419
419
|
? `service-${workload}-health`
|
|
@@ -428,14 +428,14 @@ ${kind !== 'service' || ownsProtectedLoadAdapter
|
|
|
428
428
|
? 'LessThanThreshold'
|
|
429
429
|
: 'GreaterThanOrEqualToThreshold'}',
|
|
430
430
|
dimensions: ${kind === 'service'
|
|
431
|
-
? `{ ClusterName: cluster.name, ServiceName: workload.name }`
|
|
431
|
+
? `{ ClusterName: cluster.nodes.cluster.name, ServiceName: workload.nodes.service.name }`
|
|
432
432
|
: `{ RuleName: reliabilityTaskStopped.name }`},
|
|
433
433
|
evaluationPeriods: 1,
|
|
434
|
-
metricName: '${kind === 'service' ? '
|
|
434
|
+
metricName: '${kind === 'service' ? 'LiveTaskCount' : 'MatchedEvents'}',
|
|
435
435
|
name: '${slice}-' + $app.stage + '-${kind}-${workload}-${kind === 'service' ? 'running' : 'hard-crash'}',
|
|
436
|
-
namespace: '${kind === 'service' ? 'ECS
|
|
436
|
+
namespace: '${kind === 'service' ? 'AWS/ECS' : 'AWS/Events'}',
|
|
437
437
|
period: 60,
|
|
438
|
-
statistic: 'Sum',
|
|
438
|
+
statistic: '${kind === 'service' ? 'Minimum' : 'Sum'}',
|
|
439
439
|
tags: standardTags,
|
|
440
440
|
threshold: 1,
|
|
441
441
|
treatMissingData: '${kind === 'service' ? 'breaching' : 'notBreaching'}',
|
|
@@ -551,7 +551,25 @@ through the read-only diagnostic path. Do not restart, scale, retry, or replace
|
|
|
551
551
|
protected resources without the named approval.
|
|
552
552
|
|
|
553
553
|
${kind === 'service'
|
|
554
|
-
?
|
|
554
|
+
? `The native AWS/ECS LiveTaskCount alarm uses Minimum over 60 seconds and
|
|
555
|
+
counts tasks in ACTIVATING, RUNNING, and DEACTIVATING states. It breaches below
|
|
556
|
+
one or when telemetry is missing. ECS can emit no metrics when there are no
|
|
557
|
+
RUNNING tasks; missing telemetry is actionable but does not prove zero tasks.
|
|
558
|
+
|
|
559
|
+
Startup, emission gaps, and lifecycle transitions can cause false positives.
|
|
560
|
+
Activating, draining, or repeatedly replaced tasks can keep the count positive
|
|
561
|
+
while readiness or useful work fails. ALARM is not a diagnosis. OK does not
|
|
562
|
+
prove readiness, freedom from crash loops, or successful business traffic.
|
|
563
|
+
|
|
564
|
+
Inspect ECS events, task states and exit reasons, the existing container health
|
|
565
|
+
check, and the Slice API health and burn alarms. Do not infer that every Service
|
|
566
|
+
has a load-balancer readiness alarm. Prefer a verified roll-forward; verify
|
|
567
|
+
stable tasks, readiness, dependency protection, and the critical Service journey
|
|
568
|
+
after recovery, then confirm telemetry and alarm recovery.
|
|
569
|
+
|
|
570
|
+
LiveTaskCount adds no metric collection charge and needs no Container Insights
|
|
571
|
+
opt-in. Existing alarm, compute, log, and request charges still apply; this is
|
|
572
|
+
not a measured account-cost claim.`
|
|
555
573
|
: 'Preserve idempotency and checkpoint state; verify the business deadline and postconditions before retrying or declaring recovery.'}
|
|
556
574
|
`;
|
|
557
575
|
files[`${relativeRoot}/src/reliability.ts`] = `import {
|
|
@@ -72,5 +72,23 @@ export interface GitHubRepositoryIdentity {
|
|
|
72
72
|
repositoryId: string;
|
|
73
73
|
subjectPrefix: string;
|
|
74
74
|
}
|
|
75
|
+
export interface FrameworkNpmStableBootstrapOptions {
|
|
76
|
+
npmStable: true;
|
|
77
|
+
apiBaseUrl?: string;
|
|
78
|
+
apply?: boolean;
|
|
79
|
+
repository: string;
|
|
80
|
+
token: string;
|
|
81
|
+
npmStableReviewerIds: number[];
|
|
82
|
+
npmStablePreventSelfReview: boolean;
|
|
83
|
+
}
|
|
84
|
+
export interface FrameworkNpmStableBootstrapReport {
|
|
85
|
+
kind: 'framework-npm-stable';
|
|
86
|
+
repository: string;
|
|
87
|
+
ready: boolean;
|
|
88
|
+
blockers: string[];
|
|
89
|
+
changes: string[];
|
|
90
|
+
warnings: string[];
|
|
91
|
+
}
|
|
92
|
+
export declare function runGitHubBootstrap(options: FrameworkNpmStableBootstrapOptions): Promise<FrameworkNpmStableBootstrapReport>;
|
|
75
93
|
export declare function runGitHubBootstrap(options: GitHubBootstrapOptions): Promise<GitHubBootstrapReport>;
|
|
76
94
|
export {};
|
|
@@ -203,11 +203,7 @@ function requiresImmutableOidcSubjectCutover(state) {
|
|
|
203
203
|
function immutableOidcTrustAcknowledgement(identity) {
|
|
204
204
|
return `${GITHUB_IMMUTABLE_OIDC_TRUST_ACKNOWLEDGEMENT_PREFIX}${identity.subjectPrefix}`;
|
|
205
205
|
}
|
|
206
|
-
const PRIVATE_ENVIRONMENT_REVIEWER_PLANS = new Set([
|
|
207
|
-
'business_plus',
|
|
208
|
-
'enterprise',
|
|
209
|
-
'enterprise_cloud',
|
|
210
|
-
]);
|
|
206
|
+
const PRIVATE_ENVIRONMENT_REVIEWER_PLANS = new Set(['enterprise']);
|
|
211
207
|
class GitHubApiError extends Error {
|
|
212
208
|
status;
|
|
213
209
|
constructor(status, message) {
|
|
@@ -575,6 +571,14 @@ async function inspectEnvironment(api, repositoryPath, name) {
|
|
|
575
571
|
branchPolicies: branchPolicyValues,
|
|
576
572
|
customBranchPolicies,
|
|
577
573
|
exists: true,
|
|
574
|
+
canAdminsBypass: environment.can_admins_bypass === false ? false : true,
|
|
575
|
+
protectedBranches: typeof environment.deployment_branch_policy === 'object' &&
|
|
576
|
+
environment.deployment_branch_policy !== null &&
|
|
577
|
+
'protected_branches' in environment.deployment_branch_policy &&
|
|
578
|
+
typeof environment.deployment_branch_policy.protected_branches ===
|
|
579
|
+
'boolean'
|
|
580
|
+
? environment.deployment_branch_policy.protected_branches
|
|
581
|
+
: undefined,
|
|
578
582
|
preventSelfReview: reviewers.preventSelfReview,
|
|
579
583
|
reviewers: reviewers.reviewers,
|
|
580
584
|
secretNames: namedValues(secrets),
|
|
@@ -786,7 +790,7 @@ function evaluate(repository, state, inspectionBlockers, productionApprovalMode,
|
|
|
786
790
|
privateRepository &&
|
|
787
791
|
state.ownerPlanName !== undefined &&
|
|
788
792
|
!PRIVATE_ENVIRONMENT_REVIEWER_PLANS.has(state.ownerPlanName)) {
|
|
789
|
-
blockers.push(`Committed environment-reviewer mode
|
|
793
|
+
blockers.push(`Committed environment-reviewer mode does not support inspected owner plan ${state.ownerPlanName} under EBK's verified private environment-reviewer contract; supported REST plan.name: enterprise.`);
|
|
790
794
|
}
|
|
791
795
|
if (state.repo.allow_merge_commit !== true) {
|
|
792
796
|
changes.push('Enable repository merge commits.');
|
|
@@ -1099,7 +1103,96 @@ async function applyChanges(api, repositoryPath, state, productionApprovalMode,
|
|
|
1099
1103
|
}
|
|
1100
1104
|
}
|
|
1101
1105
|
}
|
|
1106
|
+
async function runFrameworkNpmStableBootstrap(options) {
|
|
1107
|
+
if (options.repository !== 'empire-builder-kit/empire-builder-kit')
|
|
1108
|
+
throw new Error('npm-stable is restricted to the exact framework repository.');
|
|
1109
|
+
if (!options.token.trim())
|
|
1110
|
+
throw new Error('GH_TOKEN or GITHUB_TOKEN is required for GitHub bootstrap.');
|
|
1111
|
+
if (!Array.isArray(options.npmStableReviewerIds) ||
|
|
1112
|
+
options.npmStableReviewerIds.length < 1 ||
|
|
1113
|
+
options.npmStableReviewerIds.length > 6 ||
|
|
1114
|
+
options.npmStableReviewerIds.some((id) => !Number.isSafeInteger(id) || id <= 0) ||
|
|
1115
|
+
new Set(options.npmStableReviewerIds).size !==
|
|
1116
|
+
options.npmStableReviewerIds.length ||
|
|
1117
|
+
typeof options.npmStablePreventSelfReview !== 'boolean') {
|
|
1118
|
+
throw new Error('npm-stable requires exact reviewer IDs and explicit prevent-self-review choice.');
|
|
1119
|
+
}
|
|
1120
|
+
const reviewers = existingReviewerPayload([...options.npmStableReviewerIds].sort((left, right) => left - right));
|
|
1121
|
+
const api = new GitHubApi(options.apiBaseUrl?.trim() || 'https://api.github.com', options.token);
|
|
1122
|
+
const repositoryPath = '/repos/empire-builder-kit/empire-builder-kit';
|
|
1123
|
+
const repo = await api.request('GET', repositoryPath);
|
|
1124
|
+
if (repo.full_name !== options.repository)
|
|
1125
|
+
throw new Error('npm-stable repository readback differs from its exact target.');
|
|
1126
|
+
const environmentPath = `${repositoryPath}/environments/npm-stable`;
|
|
1127
|
+
const inspectStable = () => inspectEnvironment(api, repositoryPath, 'npm-stable');
|
|
1128
|
+
const evaluateStable = (state) => {
|
|
1129
|
+
const changes = [];
|
|
1130
|
+
const blockers = [];
|
|
1131
|
+
if (!state.exists ||
|
|
1132
|
+
!state.customBranchPolicies ||
|
|
1133
|
+
state.protectedBranches !== false ||
|
|
1134
|
+
state.canAdminsBypass !== false ||
|
|
1135
|
+
state.preventSelfReview !== options.npmStablePreventSelfReview ||
|
|
1136
|
+
state.waitTimer !== 0 ||
|
|
1137
|
+
JSON.stringify(state.reviewers) !== JSON.stringify(reviewers)) {
|
|
1138
|
+
changes.push('Set exact npm-stable reviewers, explicit self-review choice, administrator bypass false, and custom main-only policy.');
|
|
1139
|
+
}
|
|
1140
|
+
if (state.branchPolicies.length !== 1 ||
|
|
1141
|
+
state.branchPolicies[0]?.name !== 'main' ||
|
|
1142
|
+
state.branchPolicies[0]?.type !== 'branch')
|
|
1143
|
+
changes.push('Restrict npm-stable to the main branch only.');
|
|
1144
|
+
if ([...state.secretNames, ...state.variableNames].some((name) => FORBIDDEN_NPM_TOKEN_NAMES.some((tokenName) => name.toUpperCase() === tokenName)))
|
|
1145
|
+
blockers.push('npm-stable may not contain npm token credentials.');
|
|
1146
|
+
return {
|
|
1147
|
+
kind: 'framework-npm-stable',
|
|
1148
|
+
repository: options.repository,
|
|
1149
|
+
ready: changes.length === 0 && blockers.length === 0,
|
|
1150
|
+
blockers,
|
|
1151
|
+
changes,
|
|
1152
|
+
warnings: [],
|
|
1153
|
+
};
|
|
1154
|
+
};
|
|
1155
|
+
const initial = await inspectStable();
|
|
1156
|
+
const report = evaluateStable(initial);
|
|
1157
|
+
if (!options.apply)
|
|
1158
|
+
return report;
|
|
1159
|
+
if (report.blockers.length)
|
|
1160
|
+
throw new Error(`npm-stable apply is blocked: ${report.blockers.join(' ')}`);
|
|
1161
|
+
if (report.ready)
|
|
1162
|
+
return report;
|
|
1163
|
+
await api.request('PUT', environmentPath, {
|
|
1164
|
+
can_admins_bypass: false,
|
|
1165
|
+
deployment_branch_policy: {
|
|
1166
|
+
protected_branches: false,
|
|
1167
|
+
custom_branch_policies: true,
|
|
1168
|
+
},
|
|
1169
|
+
prevent_self_review: options.npmStablePreventSelfReview,
|
|
1170
|
+
reviewers,
|
|
1171
|
+
wait_timer: 0,
|
|
1172
|
+
});
|
|
1173
|
+
const policies = await readPagedObjects(api, `${environmentPath}/deployment-branch-policies`, 'branch_policies');
|
|
1174
|
+
let mainRetained = false;
|
|
1175
|
+
for (const policy of policies) {
|
|
1176
|
+
if (policy.name === 'main' && policy.type === 'branch' && !mainRetained) {
|
|
1177
|
+
mainRetained = true;
|
|
1178
|
+
continue;
|
|
1179
|
+
}
|
|
1180
|
+
if (!Number.isSafeInteger(policy.id) ||
|
|
1181
|
+
typeof policy.id !== 'number' ||
|
|
1182
|
+
policy.id <= 0)
|
|
1183
|
+
throw new Error('npm-stable branch policy has no exact deletion identity.');
|
|
1184
|
+
await api.request('DELETE', `${environmentPath}/deployment-branch-policies/${policy.id}`);
|
|
1185
|
+
}
|
|
1186
|
+
if (!mainRetained)
|
|
1187
|
+
await api.request('POST', `${environmentPath}/deployment-branch-policies`, {
|
|
1188
|
+
name: 'main',
|
|
1189
|
+
type: 'branch',
|
|
1190
|
+
});
|
|
1191
|
+
return evaluateStable(await inspectStable());
|
|
1192
|
+
}
|
|
1102
1193
|
async function runGitHubBootstrap(options) {
|
|
1194
|
+
if ('npmStable' in options)
|
|
1195
|
+
return runFrameworkNpmStableBootstrap(options);
|
|
1103
1196
|
const repository = options.repository.trim();
|
|
1104
1197
|
const deploymentProfile = options.deploymentProfile ?? 'fully-isolated';
|
|
1105
1198
|
const { owner, repo } = parseRepository(repository);
|
|
@@ -22,6 +22,7 @@ class FleetSession {
|
|
|
22
22
|
#cleanupStates = new Map();
|
|
23
23
|
#fleetId;
|
|
24
24
|
#operation = Promise.resolve();
|
|
25
|
+
#pendingStartup;
|
|
25
26
|
#processFactory;
|
|
26
27
|
#processes = new Map();
|
|
27
28
|
#proxy;
|
|
@@ -144,7 +145,19 @@ class FleetSession {
|
|
|
144
145
|
upstream: route.upstream,
|
|
145
146
|
});
|
|
146
147
|
this.#processes.set(name, process);
|
|
147
|
-
|
|
148
|
+
const pending = {
|
|
149
|
+
name,
|
|
150
|
+
process,
|
|
151
|
+
interruptionRequested: false,
|
|
152
|
+
};
|
|
153
|
+
this.#pendingStartup = pending;
|
|
154
|
+
try {
|
|
155
|
+
await process.start();
|
|
156
|
+
}
|
|
157
|
+
finally {
|
|
158
|
+
if (this.#pendingStartup === pending)
|
|
159
|
+
this.#pendingStartup = undefined;
|
|
160
|
+
}
|
|
148
161
|
}
|
|
149
162
|
}
|
|
150
163
|
catch (error) {
|
|
@@ -228,7 +241,28 @@ class FleetSession {
|
|
|
228
241
|
return sessionError('not-running', `Product Slice ${name} is not selected.`);
|
|
229
242
|
}
|
|
230
243
|
const previousState = route.state;
|
|
231
|
-
|
|
244
|
+
const pending = {
|
|
245
|
+
name,
|
|
246
|
+
process,
|
|
247
|
+
interruptionRequested: false,
|
|
248
|
+
};
|
|
249
|
+
this.#pendingStartup = pending;
|
|
250
|
+
try {
|
|
251
|
+
await process.restart();
|
|
252
|
+
}
|
|
253
|
+
catch (error) {
|
|
254
|
+
if (pending.interruptionRequested) {
|
|
255
|
+
const failures = await this.#quiesce([name]);
|
|
256
|
+
if (failures.length > 0) {
|
|
257
|
+
return sessionError('cleanup-incomplete', `Fleet restart failed (${startupFailure(error)}) and owned writer quiescence was incomplete: ${failures.join('; ')}`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
throw error;
|
|
261
|
+
}
|
|
262
|
+
finally {
|
|
263
|
+
if (this.#pendingStartup === pending)
|
|
264
|
+
this.#pendingStartup = undefined;
|
|
265
|
+
}
|
|
232
266
|
results.push({
|
|
233
267
|
changed: true,
|
|
234
268
|
name,
|
|
@@ -314,8 +348,11 @@ class FleetSession {
|
|
|
314
348
|
// running up operation owns rollback; down waits for that rollback rather
|
|
315
349
|
// than racing a second native cleanup against a live writer.
|
|
316
350
|
const ordered = (0, contracts_js_1.resolveFleetSliceOperation)(this.#adapters, 'down', names, this.selectedSlices());
|
|
317
|
-
|
|
318
|
-
|
|
351
|
+
const pending = this.#pendingStartup;
|
|
352
|
+
if (pending && ordered.includes(pending.name)) {
|
|
353
|
+
pending.interruptionRequested = true;
|
|
354
|
+
pending.process.interruptStartup?.();
|
|
355
|
+
}
|
|
319
356
|
}
|
|
320
357
|
allSlices() {
|
|
321
358
|
return this.routes.map(({ name }) => name).sort();
|
|
@@ -10,11 +10,7 @@ async function runFleetSupervisorDaemon(startupPath) {
|
|
|
10
10
|
return;
|
|
11
11
|
handlingSignal = true;
|
|
12
12
|
try {
|
|
13
|
-
|
|
14
|
-
if (selected.length > 0)
|
|
15
|
-
await runtime.session.down(selected);
|
|
16
|
-
await runtime.session.closeProxyWhenEmpty();
|
|
17
|
-
await runtime.supervisor.stopGracefully();
|
|
13
|
+
await runtime.supervisor.stopForSignal();
|
|
18
14
|
}
|
|
19
15
|
catch (error) {
|
|
20
16
|
handlingSignal = false;
|
|
@@ -96,6 +96,7 @@ class FleetSupervisor {
|
|
|
96
96
|
#shutdownGraceMs;
|
|
97
97
|
#state;
|
|
98
98
|
#stopping = false;
|
|
99
|
+
#stopOperation;
|
|
99
100
|
constructor(options, effects = {}) {
|
|
100
101
|
this.#adapters = options.adapters;
|
|
101
102
|
this.#host = options.host;
|
|
@@ -422,22 +423,44 @@ class FleetSupervisor {
|
|
|
422
423
|
return;
|
|
423
424
|
await new Promise((resolvePromise) => this.#closedWaiters.push(resolvePromise));
|
|
424
425
|
}
|
|
425
|
-
|
|
426
|
+
stopGracefully() {
|
|
427
|
+
return this.#stop('empty');
|
|
428
|
+
}
|
|
429
|
+
stopForSignal() {
|
|
430
|
+
return this.#stop('down-selected');
|
|
431
|
+
}
|
|
432
|
+
#stop(mode) {
|
|
426
433
|
if (this.#closed)
|
|
427
|
-
return;
|
|
434
|
+
return Promise.resolve();
|
|
435
|
+
if (this.#stopOperation)
|
|
436
|
+
return this.#stopOperation;
|
|
428
437
|
this.#stopping = true;
|
|
429
|
-
|
|
430
|
-
try {
|
|
431
|
-
this.#assertShutdownOwnership();
|
|
432
|
-
await this.#host.closeProxyWhenEmpty();
|
|
433
|
-
(0, fleet_runner_evidence_js_1.retireFleetRunnerEvidence)(this.#state, this.#paths, {
|
|
434
|
-
security: this.#security,
|
|
435
|
-
});
|
|
436
|
-
}
|
|
437
|
-
catch (error) {
|
|
438
|
+
this.#stopOperation = this.#finishStop(mode).catch((error) => {
|
|
438
439
|
this.#stopping = false;
|
|
440
|
+
this.#stopOperation = undefined;
|
|
439
441
|
throw error;
|
|
442
|
+
});
|
|
443
|
+
return this.#stopOperation;
|
|
444
|
+
}
|
|
445
|
+
async #finishStop(mode) {
|
|
446
|
+
await this.waitForIdle();
|
|
447
|
+
if (mode === 'down-selected') {
|
|
448
|
+
const selected = this.#host.selectedSlices();
|
|
449
|
+
if (selected.length > 0) {
|
|
450
|
+
try {
|
|
451
|
+
this.#syncSliceStates(await this.#host.down(selected));
|
|
452
|
+
}
|
|
453
|
+
finally {
|
|
454
|
+
this.#syncLiveSliceStates();
|
|
455
|
+
this.#persistState();
|
|
456
|
+
}
|
|
457
|
+
}
|
|
440
458
|
}
|
|
459
|
+
this.#assertShutdownOwnership();
|
|
460
|
+
await this.#host.closeProxyWhenEmpty();
|
|
461
|
+
(0, fleet_runner_evidence_js_1.retireFleetRunnerEvidence)(this.#state, this.#paths, {
|
|
462
|
+
security: this.#security,
|
|
463
|
+
});
|
|
441
464
|
if (this.#heartbeat)
|
|
442
465
|
clearInterval(this.#heartbeat);
|
|
443
466
|
this.#heartbeat = undefined;
|
|
@@ -439,6 +439,25 @@ function isBlueprintVerificationExecutionAuthorityPath(path) {
|
|
|
439
439
|
/^(?:vite|vitest)\.config\.[a-z0-9]+$/u.test(name) ||
|
|
440
440
|
/^packages\/[^/]+\/\.ebk\/ownership\.json$/u.test(normalized));
|
|
441
441
|
}
|
|
442
|
+
function isNativeArtifactDependencyBoundary(workspaceRoot, path, candidate) {
|
|
443
|
+
const match = /^packages\/([a-z][a-z0-9]*(?:-[a-z0-9]+)*)\/\.sst\/artifacts\/[^/]+\/node_modules$/u.exec(path);
|
|
444
|
+
const slice = match?.[1];
|
|
445
|
+
if (!slice)
|
|
446
|
+
return false;
|
|
447
|
+
const dependencyRoot = (0, node_path_1.join)(workspaceRoot, 'packages', slice, 'node_modules');
|
|
448
|
+
try {
|
|
449
|
+
const metadata = (0, node_fs_1.lstatSync)(dependencyRoot);
|
|
450
|
+
return (metadata.isDirectory() &&
|
|
451
|
+
!metadata.isSymbolicLink() &&
|
|
452
|
+
(0, node_fs_1.realpathSync)(dependencyRoot) === dependencyRoot &&
|
|
453
|
+
(0, node_path_1.toNamespacedPath)((0, node_path_1.resolve)((0, node_path_1.dirname)(candidate), (0, node_fs_1.readlinkSync)(candidate))) ===
|
|
454
|
+
(0, node_path_1.toNamespacedPath)(dependencyRoot) &&
|
|
455
|
+
(0, node_fs_1.realpathSync)(candidate) === dependencyRoot);
|
|
456
|
+
}
|
|
457
|
+
catch {
|
|
458
|
+
return false;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
442
461
|
/**
|
|
443
462
|
* Inventories every execution-loader configuration outside bounded generated,
|
|
444
463
|
* dependency, cache, and evidence directories. Forbidden ambient config is
|
|
@@ -467,15 +486,17 @@ function assertNoMutableBlueprintVerificationConfiguration(workspace) {
|
|
|
467
486
|
const path = directoryPath
|
|
468
487
|
? `${directoryPath}/${entry.name}`
|
|
469
488
|
: entry.name;
|
|
470
|
-
const
|
|
471
|
-
|
|
489
|
+
const candidate = (0, node_path_1.join)(directory, entry.name);
|
|
490
|
+
const skippedDirectory = (entry.isDirectory() &&
|
|
491
|
+
SKIPPED_CONFIGURATION_DIRECTORIES.has(entry.name.toLowerCase())) ||
|
|
492
|
+
(entry.isSymbolicLink() &&
|
|
493
|
+
isNativeArtifactDependencyBoundary(workspaceRoot, path, candidate));
|
|
472
494
|
if (entry.isSymbolicLink() && !skippedDirectory) {
|
|
473
495
|
throw new Error(`Blueprint verification configuration inventory refuses symbolic-link authority at ${path}.`);
|
|
474
496
|
}
|
|
475
497
|
if (forbiddenConfigurationName(entry.name)) {
|
|
476
498
|
throw new Error(`Blueprint verification refuses mutable environment or package-manager configuration at ${path}.`);
|
|
477
499
|
}
|
|
478
|
-
const candidate = (0, node_path_1.join)(directory, entry.name);
|
|
479
500
|
if (protectedConfigurationName(entry.name)) {
|
|
480
501
|
const metadata = (0, node_fs_1.lstatSync)(candidate);
|
|
481
502
|
const child = (0, node_path_1.relative)(workspaceRoot, candidate);
|
package/src/supply-chain/sbom.js
CHANGED
|
@@ -160,7 +160,8 @@ function createCycloneDxComponentsFromInventory(input) {
|
|
|
160
160
|
}));
|
|
161
161
|
}
|
|
162
162
|
function canonicalTimestamp(value, path) {
|
|
163
|
-
if (
|
|
163
|
+
if (typeof value !== 'string' ||
|
|
164
|
+
!Number.isFinite(Date.parse(value)) ||
|
|
164
165
|
new Date(value).toISOString() !== value) {
|
|
165
166
|
throw new errors_js_1.SupplyChainError('supply-chain-input-invalid', path, 'must be a canonical ISO-8601 timestamp');
|
|
166
167
|
}
|
|
@@ -775,8 +776,11 @@ function bindingInput(value) {
|
|
|
775
776
|
}
|
|
776
777
|
function createSupplyChainReleaseBinding(input) {
|
|
777
778
|
canonicalTimestamp(input.createdAt, 'createdAt');
|
|
778
|
-
if (
|
|
779
|
-
|
|
779
|
+
if (typeof input.releaseId !== 'string' ||
|
|
780
|
+
common_js_1.SUPPLY_CHAIN_REFERENCE.exec(input.releaseId)?.[0] !== input.releaseId ||
|
|
781
|
+
typeof input.releaseManifestDigest !== 'string' ||
|
|
782
|
+
common_js_1.SUPPLY_CHAIN_SHA256.exec(input.releaseManifestDigest)?.[0] !==
|
|
783
|
+
input.releaseManifestDigest ||
|
|
780
784
|
input.artifacts.length === 0 ||
|
|
781
785
|
(0, common_js_1.hasSensitiveSupplyChainShape)(input)) {
|
|
782
786
|
throw new errors_js_1.SupplyChainError('supply-chain-input-invalid', 'releaseBinding', 'must bind at least one artifact to an exact release manifest');
|
|
@@ -785,11 +789,21 @@ function createSupplyChainReleaseBinding(input) {
|
|
|
785
789
|
const artifacts = [...input.artifacts]
|
|
786
790
|
.sort((left, right) => (0, deterministic_order_js_1.compareUtf16)(left.bindingId, right.bindingId))
|
|
787
791
|
.map((artifact, index) => {
|
|
788
|
-
if (
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
792
|
+
if (typeof artifact.bindingId !== 'string' ||
|
|
793
|
+
common_js_1.SUPPLY_CHAIN_REFERENCE.exec(artifact.bindingId)?.[0] !==
|
|
794
|
+
artifact.bindingId ||
|
|
795
|
+
typeof artifact.candidateDigest !== 'string' ||
|
|
796
|
+
common_js_1.SUPPLY_CHAIN_SHA256.exec(artifact.candidateDigest)?.[0] !==
|
|
797
|
+
artifact.candidateDigest ||
|
|
798
|
+
typeof artifact.contentDigest !== 'string' ||
|
|
799
|
+
common_js_1.SUPPLY_CHAIN_SHA256.exec(artifact.contentDigest)?.[0] !==
|
|
800
|
+
artifact.contentDigest ||
|
|
801
|
+
typeof artifact.sbomDigest !== 'string' ||
|
|
802
|
+
common_js_1.SUPPLY_CHAIN_SHA256.exec(artifact.sbomDigest)?.[0] !==
|
|
803
|
+
artifact.sbomDigest ||
|
|
804
|
+
typeof artifact.sbomSerialNumber !== 'string' ||
|
|
805
|
+
/^urn:uuid:[a-f0-9-]{36}$/.exec(artifact.sbomSerialNumber)?.[0] !==
|
|
806
|
+
artifact.sbomSerialNumber ||
|
|
793
807
|
seen.has(artifact.bindingId)) {
|
|
794
808
|
throw new errors_js_1.SupplyChainError('supply-chain-input-invalid', `releaseBinding.artifacts[${index}]`, 'must uniquely bind artifact and SBOM digests');
|
|
795
809
|
}
|
|
@@ -810,14 +824,62 @@ function createSupplyChainReleaseBinding(input) {
|
|
|
810
824
|
};
|
|
811
825
|
}
|
|
812
826
|
function validateSupplyChainReleaseBinding(value, expected) {
|
|
827
|
+
const exactKeys = (record, keys) => [Object.prototype, null].includes(Object.getPrototypeOf(record)) &&
|
|
828
|
+
Object.keys(record).sort(deterministic_order_js_1.compareUtf16).join('\0') ===
|
|
829
|
+
[...keys].sort(deterministic_order_js_1.compareUtf16).join('\0');
|
|
813
830
|
if (!(0, validation_js_1.isRecord)(value) ||
|
|
831
|
+
!exactKeys(value, [
|
|
832
|
+
'artifacts',
|
|
833
|
+
'createdAt',
|
|
834
|
+
'kind',
|
|
835
|
+
'materialDigest',
|
|
836
|
+
'releaseId',
|
|
837
|
+
'releaseManifestDigest',
|
|
838
|
+
'schemaVersion',
|
|
839
|
+
]) ||
|
|
814
840
|
value.kind !== exports.SUPPLY_CHAIN_RELEASE_BINDING_KIND ||
|
|
815
841
|
value.schemaVersion !== exports.SUPPLY_CHAIN_SBOM_SCHEMA_VERSION ||
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
842
|
+
typeof value.materialDigest !== 'string' ||
|
|
843
|
+
common_js_1.SUPPLY_CHAIN_SHA256.exec(value.materialDigest)?.[0] !==
|
|
844
|
+
value.materialDigest ||
|
|
845
|
+
typeof value.createdAt !== 'string' ||
|
|
846
|
+
typeof value.releaseId !== 'string' ||
|
|
847
|
+
typeof value.releaseManifestDigest !== 'string' ||
|
|
848
|
+
!Array.isArray(value.artifacts) ||
|
|
849
|
+
!value.artifacts.length) {
|
|
850
|
+
throw new errors_js_1.SupplyChainError('supply-chain-input-invalid', 'releaseBinding', 'must be an exact EBK supply-chain release binding v1');
|
|
851
|
+
}
|
|
852
|
+
const artifacts = [];
|
|
853
|
+
for (const artifact of value.artifacts) {
|
|
854
|
+
if (!(0, validation_js_1.isRecord)(artifact) ||
|
|
855
|
+
!exactKeys(artifact, [
|
|
856
|
+
'bindingId',
|
|
857
|
+
'candidateDigest',
|
|
858
|
+
'contentDigest',
|
|
859
|
+
'sbomDigest',
|
|
860
|
+
'sbomSerialNumber',
|
|
861
|
+
]) ||
|
|
862
|
+
typeof artifact.bindingId !== 'string' ||
|
|
863
|
+
typeof artifact.candidateDigest !== 'string' ||
|
|
864
|
+
typeof artifact.contentDigest !== 'string' ||
|
|
865
|
+
typeof artifact.sbomDigest !== 'string' ||
|
|
866
|
+
typeof artifact.sbomSerialNumber !== 'string') {
|
|
867
|
+
throw new errors_js_1.SupplyChainError('supply-chain-input-invalid', 'releaseBinding.artifacts', 'must contain exact string artifact fields');
|
|
868
|
+
}
|
|
869
|
+
artifacts.push({
|
|
870
|
+
bindingId: artifact.bindingId,
|
|
871
|
+
candidateDigest: artifact.candidateDigest,
|
|
872
|
+
contentDigest: artifact.contentDigest,
|
|
873
|
+
sbomDigest: artifact.sbomDigest,
|
|
874
|
+
sbomSerialNumber: artifact.sbomSerialNumber,
|
|
875
|
+
});
|
|
819
876
|
}
|
|
820
|
-
const recreated = createSupplyChainReleaseBinding(
|
|
877
|
+
const recreated = createSupplyChainReleaseBinding({
|
|
878
|
+
artifacts,
|
|
879
|
+
createdAt: value.createdAt,
|
|
880
|
+
releaseId: value.releaseId,
|
|
881
|
+
releaseManifestDigest: value.releaseManifestDigest,
|
|
882
|
+
});
|
|
821
883
|
if (recreated.materialDigest !== value.materialDigest) {
|
|
822
884
|
throw new errors_js_1.SupplyChainError('supply-chain-artifact-mismatch', 'releaseBinding.materialDigest', 'does not match the canonical binding bytes');
|
|
823
885
|
}
|