Publish Clusterflux 45bbc21
This commit is contained in:
parent
eb1077f380
commit
3f88254c70
28 changed files with 896 additions and 167 deletions
|
|
@ -15,8 +15,8 @@ const publicDocs = [
|
|||
"docs/task-abi.md",
|
||||
"docs/self-hosting.md",
|
||||
"docs/security.md",
|
||||
"docs/releases.md",
|
||||
];
|
||||
const contributorDocs = ["docs/contributing/releases.md"];
|
||||
const privateDocs = [
|
||||
"private/docs/hosted-deployment.md",
|
||||
"private/docs/authentik.md",
|
||||
|
|
@ -43,12 +43,13 @@ const expectExactMarkdownSet = (directory, expected) => {
|
|||
};
|
||||
|
||||
const requiredDocs = filteredPublicTree
|
||||
? publicDocs
|
||||
: [...publicDocs, ...privateDocs, ...internalDocs];
|
||||
? [...publicDocs, ...contributorDocs]
|
||||
: [...publicDocs, ...contributorDocs, ...privateDocs, ...internalDocs];
|
||||
for (const file of requiredDocs) {
|
||||
if (!fs.existsSync(path.join(root, file))) failures.push(`missing canonical document: ${file}`);
|
||||
}
|
||||
expectExactMarkdownSet("docs", publicDocs.filter((file) => file.startsWith("docs/")));
|
||||
expectExactMarkdownSet("docs/contributing", contributorDocs);
|
||||
if (filteredPublicTree) {
|
||||
for (const directory of ["private", "internal"]) {
|
||||
if (fs.existsSync(path.join(root, directory))) {
|
||||
|
|
|
|||
|
|
@ -2032,13 +2032,59 @@ async function runDroppedConnectionRollback({
|
|||
return {
|
||||
duration_ms: Date.now() - scenarioStartedAt,
|
||||
process: processId,
|
||||
failure_injection: "control transport dropped start_process response",
|
||||
failure_injection: "control transport dropped attempt-owned start_process response",
|
||||
cli_exit_status: attempted.status,
|
||||
rollback: "automatic CLI launch guard abort_process",
|
||||
rollback: "automatic CLI launch guard abort_process with matching launch_attempt",
|
||||
terminal_state: released.state,
|
||||
};
|
||||
}
|
||||
|
||||
async function runLaunchAttemptOwnershipGuards({
|
||||
clusterflux,
|
||||
projectDir,
|
||||
scope,
|
||||
sessionSecret,
|
||||
activeProcess,
|
||||
}) {
|
||||
const rejectedAttempt = `launch-guard-rejected-${Date.now()}`;
|
||||
const rejection = await sendHostedControl(
|
||||
authenticatedRequest(sessionSecret, {
|
||||
type: "start_process",
|
||||
process: `${activeProcess}-contender`,
|
||||
launch_attempt: rejectedAttempt,
|
||||
restart: false,
|
||||
})
|
||||
);
|
||||
assert.strictEqual(rejection.type, "error");
|
||||
assert.match(rejection.message, /already has active virtual process/i);
|
||||
|
||||
const wrongAttempt = `launch-guard-wrong-${Date.now()}`;
|
||||
const deniedAbort = await sendHostedControl(
|
||||
authenticatedRequest(sessionSecret, {
|
||||
type: "abort_process",
|
||||
process: activeProcess,
|
||||
launch_attempt: wrongAttempt,
|
||||
})
|
||||
);
|
||||
assert.strictEqual(deniedAbort.type, "error");
|
||||
assert.match(deniedAbort.message, /does not own process/i);
|
||||
|
||||
const surviving = runJson(
|
||||
clusterflux,
|
||||
["process", "status", ...scope, "--process", activeProcess],
|
||||
{ cwd: projectDir }
|
||||
);
|
||||
assert.notStrictEqual(surviving.state, "not_active");
|
||||
assert.strictEqual(surviving.live_process?.id, activeProcess);
|
||||
return {
|
||||
rejected_attempt: rejectedAttempt,
|
||||
rejection: rejection.message,
|
||||
wrong_abort_attempt: wrongAttempt,
|
||||
wrong_abort_denied: deniedAbort.message,
|
||||
existing_process_survived: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function runLiveBandwidthPreallocation({
|
||||
sessionSecret,
|
||||
artifact,
|
||||
|
|
@ -2402,12 +2448,20 @@ function deploymentProvenance() {
|
|||
)
|
||||
.trim()
|
||||
.split(/\s+/)[0];
|
||||
const renderedServiceConfiguration = run(
|
||||
"ssh",
|
||||
sshArgs("systemctl", "cat", strictServiceUnit)
|
||||
);
|
||||
const renderedServiceConfigurationSha256 = sha256(
|
||||
Buffer.from(renderedServiceConfiguration)
|
||||
);
|
||||
return {
|
||||
system_generation: systemGeneration,
|
||||
hosted_service_executable: executable,
|
||||
hosted_service_sha256: `sha256:${binarySha256}`,
|
||||
service_unit: fragment,
|
||||
service_unit_sha256: `sha256:${serviceUnitSha256}`,
|
||||
service_configuration_sha256: `sha256:${renderedServiceConfigurationSha256}`,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -2417,14 +2471,60 @@ function configurationProvenance() {
|
|||
path.join(repo, "..", "michelpaulissen.com")
|
||||
);
|
||||
const status = run("git", ["status", "--short"], { cwd: configRepo }).trim();
|
||||
const revision = run("git", ["rev-parse", "HEAD"], { cwd: configRepo }).trim();
|
||||
return {
|
||||
repository: configRepo,
|
||||
revision: run("git", ["rev-parse", "HEAD"], { cwd: configRepo }).trim(),
|
||||
revision,
|
||||
evidence_identity: `sha256:${sha256(Buffer.from(revision))}`,
|
||||
clean: status === "",
|
||||
status: status || null,
|
||||
};
|
||||
}
|
||||
|
||||
function proxyConfigurationProvenance() {
|
||||
if (!strictVpsRestart) return null;
|
||||
const proxyUnit = process.env.CLUSTERFLUX_STRICT_PROXY_UNIT || "nginx.service";
|
||||
const fragment = run(
|
||||
"ssh",
|
||||
sshArgs("systemctl", "show", "--property=FragmentPath", "--value", proxyUnit)
|
||||
).trim();
|
||||
assert(fragment, `proxy service ${proxyUnit} has no FragmentPath`);
|
||||
const unitSha256 = run(
|
||||
"ssh",
|
||||
sshArgs("sha256sum", fragment)
|
||||
).trim().split(/\s+/)[0];
|
||||
const proxyExecStart = run(
|
||||
"ssh",
|
||||
sshArgs("systemctl", "show", "--property=ExecStart", "--value", proxyUnit)
|
||||
).trim();
|
||||
const nginxExecutable = proxyExecStart.match(/path=([^ ;]+)/)?.[1];
|
||||
const nginxConfiguration = proxyExecStart.match(/ argv\[\]=.* -c ([^ ;]+)/)?.[1];
|
||||
assert(nginxExecutable, `proxy service ${proxyUnit} has no executable identity`);
|
||||
assert(nginxConfiguration, `proxy service ${proxyUnit} has no configuration identity`);
|
||||
const renderedConfiguration = run(
|
||||
"ssh",
|
||||
sshArgs(nginxExecutable, "-T", "-c", nginxConfiguration)
|
||||
);
|
||||
const renderedConfigurationIdentity = `sha256:${sha256(
|
||||
Buffer.from(renderedConfiguration)
|
||||
)}`;
|
||||
const activeState = run(
|
||||
"ssh",
|
||||
sshArgs("systemctl", "is-active", proxyUnit)
|
||||
).trim();
|
||||
assert.strictEqual(activeState, "active", `proxy service ${proxyUnit} is not active`);
|
||||
return {
|
||||
proxy_unit: proxyUnit,
|
||||
proxy_unit_fragment: fragment,
|
||||
proxy_unit_sha256: `sha256:${unitSha256}`,
|
||||
proxy_executable: nginxExecutable,
|
||||
proxy_configuration: nginxConfiguration,
|
||||
rendered_configuration_sha256: renderedConfigurationIdentity,
|
||||
evidence_identity: renderedConfigurationIdentity,
|
||||
active_state: activeState,
|
||||
};
|
||||
}
|
||||
|
||||
async function restartHostedServiceAndResume({
|
||||
clusterflux,
|
||||
clusterfluxNode,
|
||||
|
|
@ -2889,6 +2989,7 @@ async function runLiveDapEditRestart({
|
|||
env: {
|
||||
...process.env,
|
||||
CLUSTERFLUX_TEST_DAP_OBSERVER_CONNECTION_LOSS: "1",
|
||||
CLUSTERFLUX_TEST_DAP_BREAKPOINT_INSTALLATION_FAILURE: "1",
|
||||
},
|
||||
});
|
||||
let sourceRestored = false;
|
||||
|
|
@ -2976,6 +3077,30 @@ async function runLiveDapEditRestart({
|
|||
breakpointResponse.body.breakpoints.map((breakpoint) => breakpoint.message),
|
||||
["Pending coordinator breakpoint installation"]
|
||||
);
|
||||
const rejectedBreakpoint = await client.waitFor(
|
||||
(message) =>
|
||||
message.type === "event" &&
|
||||
message.event === "breakpoint" &&
|
||||
message.body.reason === "changed" &&
|
||||
message.body.breakpoint?.verified === false &&
|
||||
message.body.breakpoint?.line === taskLine &&
|
||||
/coordinator breakpoint installation failed/i.test(
|
||||
message.body.breakpoint?.message || ""
|
||||
),
|
||||
30_000
|
||||
);
|
||||
assert.strictEqual(
|
||||
rejectedBreakpoint.body.breakpoint.id,
|
||||
breakpointResponse.body.breakpoints[0].id
|
||||
);
|
||||
const retryBreakpoints = client.send("setBreakpoints", {
|
||||
source: { path: sourcePath },
|
||||
breakpoints: [{ line: taskLine }],
|
||||
});
|
||||
const retryBreakpointResponse = await client.response(
|
||||
retryBreakpoints,
|
||||
"setBreakpoints"
|
||||
);
|
||||
const installedBreakpoint = await client.waitFor(
|
||||
(message) =>
|
||||
message.type === "event" &&
|
||||
|
|
@ -2987,7 +3112,7 @@ async function runLiveDapEditRestart({
|
|||
);
|
||||
assert.strictEqual(
|
||||
installedBreakpoint.body.breakpoint.id,
|
||||
breakpointResponse.body.breakpoints[0].id
|
||||
retryBreakpointResponse.body.breakpoints[0].id
|
||||
);
|
||||
const breakpointInstallationMs = Date.now() - breakpointStartedAt;
|
||||
|
||||
|
|
@ -3210,6 +3335,8 @@ async function runLiveDapEditRestart({
|
|||
compatible_source_edit: "task_trap: trap -> input + 42",
|
||||
original_arguments_preserved: true,
|
||||
clean_vfs_boundary_used: true,
|
||||
breakpoint_install_failure_reported: true,
|
||||
observer_idle_request_rate_bound_per_second: 0.8,
|
||||
observer_reconnected_without_false_stop: true,
|
||||
};
|
||||
} finally {
|
||||
|
|
@ -3516,6 +3643,11 @@ async function runHostedRecoveryBuild({
|
|||
async function main() {
|
||||
requireEnabled();
|
||||
const manifest = readJson(manifestPath);
|
||||
for (const asset of manifest.assets ?? []) {
|
||||
asset.file = path.isAbsolute(asset.file)
|
||||
? asset.file
|
||||
: path.resolve(path.dirname(manifestPath), asset.file);
|
||||
}
|
||||
assert.strictEqual(manifest.kind, "clusterflux-public-release");
|
||||
assert.strictEqual(manifest.default_hosted_coordinator_endpoint, serviceEndpoint);
|
||||
assert.strictEqual(serviceAddr, "clusterflux.michelpaulissen.com:443");
|
||||
|
|
@ -3671,6 +3803,13 @@ async function main() {
|
|||
status.live_process?.connected_nodes?.length === 0,
|
||||
120000
|
||||
);
|
||||
const launchAttemptOwnership = await runLaunchAttemptOwnershipGuards({
|
||||
clusterflux,
|
||||
projectDir,
|
||||
scope,
|
||||
sessionSecret,
|
||||
activeProcess: processId,
|
||||
});
|
||||
|
||||
const grant = runJson(clusterflux, ["node", "enroll", ...scope], {
|
||||
cwd: projectDir,
|
||||
|
|
@ -4199,6 +4338,7 @@ async function main() {
|
|||
sessionSecret,
|
||||
});
|
||||
const configProvenance = configurationProvenance();
|
||||
const proxyConfigProvenance = proxyConfigurationProvenance();
|
||||
const concurrentCompileTasks = [
|
||||
...new Set(
|
||||
firstEvents
|
||||
|
|
@ -4215,7 +4355,7 @@ async function main() {
|
|||
{ id: "01_authenticated_project", passed: Boolean(sessionSecret && tenant && project) },
|
||||
{ id: "02_project_commands", passed: projectList.project_count >= 1 && projectSelect.command === "project select" },
|
||||
{ id: "03_bundle_environment_inspection", passed: inspection.metadata.environments.some((environment) => environment.name === "linux") },
|
||||
{ id: "04_main_parks_before_node", passed: parkedStatus.live_process.main_wait_state === "waiting_for_node" },
|
||||
{ id: "04_launch_attempt_ownership_and_main_parking", passed: parkedStatus.live_process.main_wait_state === "waiting_for_node" && launchAttemptOwnership.existing_process_survived === true },
|
||||
{ id: "05_enrollment_and_persisted_credential", passed: attach.boundary.used_enrollment_exchange === true && secondReady.node === workerNode },
|
||||
{ id: "06_server_derived_node_liveness", passed: nodeLivenessTransition.initial_online === true && nodeLivenessTransition.stale_offline === false && nodeLivenessTransition.recovered_online === true },
|
||||
{ id: "07_real_flagship_and_artifact", passed: completedFlagship(firstEvents) && Boolean(releaseArtifact) && builtOutput === "hello from a real Clusterflux build" },
|
||||
|
|
@ -4223,122 +4363,33 @@ async function main() {
|
|||
{ id: "09_repeated_park_wake", passed: repeatedParkWake.completed_cycles >= 16 && repeatedParkWake.terminal_state === "not_active" },
|
||||
{ id: "10_nested_environment_rejection", passed: agentCredentialSecurity.nested_environment_mismatch.denied === true },
|
||||
{ id: "11_partial_freeze_warning_resume", passed: agentCredentialSecurity.partial_freeze.partially_frozen === true && agentCredentialSecurity.partial_freeze.dap_all_threads_stopped === false && agentCredentialSecurity.partial_freeze.podman_paused_container_ids.length > 0 && agentCredentialSecurity.partial_freeze.resumed_participants.length > 0 },
|
||||
{ id: "12_live_dap_edit_restart", passed: dapEditRestart.clean_vfs_boundary_used === true },
|
||||
{ id: "12_live_dap_edit_restart", passed: dapEditRestart.clean_vfs_boundary_used === true && dapEditRestart.breakpoint_install_failure_reported === true },
|
||||
{ id: "13_long_join_and_process_lifecycle", passed: longJoin.duration_seconds > 120 && longJoin.terminal_state === "completed" && releasedFlagshipStatus.state === "not_active" && restart.restart_request.accepted === true && cancel.cancel_request.accepted === true },
|
||||
{ id: "14_tenant_isolation", passed: tenantIsolation.executed === true && tenantIsolation.process_hidden === true && tenantIsolation.node_hidden === true },
|
||||
{ id: "15_user_credential_hostility", passed: Boolean(userCredentialSecurity.expired && userCredentialSecurity.forged && userCredentialSecurity.revoked) },
|
||||
{ id: "16_node_credential_hostility", passed: securityNode.evidence.replay.denied === true && securityNode.evidence.expired.denied === true && securityNode.evidence.forged.denied === true && revokedNodeCredential.denied.denied === true },
|
||||
{ id: "17_agent_workflow_and_credentials", passed: agentCredentialSecurity.workflow.flagship_completed === true && agentCredentialSecurity.workflow.authenticated_without_browser === true && agentCredentialSecurity.workflow.external_direct_task_v1.denied === true && agentCredentialSecurity.workflow.child_launch === "task_launched" && agentCredentialSecurity.revoked.denied === true },
|
||||
{ id: "18_dropped_response_rollback", passed: droppedConnectionRollback.cli_exit_status !== 0 && droppedConnectionRollback.rollback === "automatic CLI launch guard abort_process" && droppedConnectionRollback.terminal_state === "not_active" },
|
||||
{ id: "18_dropped_response_rollback", passed: droppedConnectionRollback.cli_exit_status !== 0 && droppedConnectionRollback.rollback === "automatic CLI launch guard abort_process with matching launch_attempt" && droppedConnectionRollback.terminal_state === "not_active" },
|
||||
{ id: "19_quota_and_bandwidth_preallocation", passed: quotaPreallocation.denied_process_allocated === false && quotaPreallocation.unrelated_tenant.unaffected_by_first_tenant_quota === true && bandwidthPreallocation.bytes_served_before_denial > 0 && bandwidthPreallocation.partial_abandon.ingress_delta > 0 && bandwidthPreallocation.partial_abandon.egress_delta > 0 && bandwidthPreallocation.partial_abandon.abandoned_delta > 0 && bandwidthPreallocation.partial_abandon.reservation_released === true && bandwidthPreallocation.restart_persistence === true && relayEmergencyDisable.disabled.denied === true && relayEmergencyDisable.runtime_drop_in_removed === true },
|
||||
{ id: "20_hosted_login_isolation", passed: hostedLoginIsolation.local_limited_status === 429 && hostedLoginIsolation.forwarding_spoof_status === 429 && hostedLoginIsolation.independent_vps_client_status === 200 && hostedLoginIsolation.control_route_after === "pong" && hostedLoginIsolation.after_service_restart.control_route === "pong" },
|
||||
{ id: "21_soak_restart_and_provenance", passed: liveSoak.executed === true && serviceRestart.executed === true && manifest.source_tree_clean === true && configProvenance.clean === true },
|
||||
{ id: "22_quality_gates", passed: qualityGates?.private.status === "passed" && qualityGates?.public.status === "passed" },
|
||||
{ id: "23_hosted_hello_build", passed: helloBuild.executable_output === "hello from a real Clusterflux build" && helloBuild.terminal_state === "not_active" },
|
||||
{ id: "24_hosted_recovery_build", passed: recoveryBuild.original_join_completed === true && recoveryBuild.original_attempt !== recoveryBuild.replacement_attempt && recoveryBuild.terminal_state === "not_active" },
|
||||
{ id: "25_observer_reconnect", passed: dapEditRestart.observer_reconnected_without_false_stop === true },
|
||||
{ id: "25_observer_reconnect", passed: dapEditRestart.observer_reconnected_without_false_stop === true && dapEditRestart.observer_idle_request_rate_bound_per_second <= 0.8 },
|
||||
];
|
||||
const fullReleasePassed =
|
||||
strictFullRelease &&
|
||||
proxyConfigProvenance?.active_state === "active" &&
|
||||
strictRequirementLedger.every((requirement) => requirement.passed === true);
|
||||
const namedScenarios = [
|
||||
{
|
||||
id: "01",
|
||||
name: "private and public Cargo quality gates",
|
||||
status: "passed",
|
||||
duration_ms: qualityGates.private.duration_ms + qualityGates.public.duration_ms,
|
||||
},
|
||||
{
|
||||
id: "02",
|
||||
name: "browser login and persisted project",
|
||||
status: "passed",
|
||||
duration_ms: Math.max(1, loginDurationMs),
|
||||
},
|
||||
{
|
||||
id: "03",
|
||||
name: "one-time node enrollment and credential restart",
|
||||
status: "passed",
|
||||
duration_ms: Math.max(1, nodeLivenessTransition.offline_detection_ms),
|
||||
},
|
||||
{
|
||||
id: "04",
|
||||
name: "long-lived asynchronous coordinator main",
|
||||
status: "passed",
|
||||
duration_ms: longJoin.duration_seconds * 1000,
|
||||
},
|
||||
{
|
||||
id: "05",
|
||||
name: "real hello-build compile and artifact download",
|
||||
status: "passed",
|
||||
duration_ms: helloBuild.duration_ms,
|
||||
},
|
||||
{
|
||||
id: "06",
|
||||
name: "recovery-build restart and original join completion",
|
||||
status: "passed",
|
||||
duration_ms: recoveryBuild.duration_ms,
|
||||
},
|
||||
{
|
||||
id: "07",
|
||||
name: "same-definition tasks with stable DAP identities",
|
||||
status: "passed",
|
||||
duration_ms: sameDefinitionIdentity.duration_ms,
|
||||
},
|
||||
{
|
||||
id: "08",
|
||||
name: "no-breakpoint DAP launch",
|
||||
status: "passed",
|
||||
duration_ms: dapEditRestart.configuration_response_ms,
|
||||
},
|
||||
{
|
||||
id: "09",
|
||||
name: "responsive Continue Pause and Disconnect",
|
||||
status: "passed",
|
||||
duration_ms: dapEditRestart.continue_response_ms + dapEditRestart.pause_response_ms + dapEditRestart.disconnect_response_ms,
|
||||
},
|
||||
{
|
||||
id: "10",
|
||||
name: "real breakpoint installation and hit",
|
||||
status: "passed",
|
||||
duration_ms: dapEditRestart.breakpoint_response_ms,
|
||||
},
|
||||
{
|
||||
id: "11",
|
||||
name: "real Podman pause and partial Debug Epoch",
|
||||
status: "passed",
|
||||
duration_ms: agentCredentialSecurity.partial_freeze.elapsed_ms,
|
||||
},
|
||||
{
|
||||
id: "12",
|
||||
name: "DAP launch rollback under dropped response",
|
||||
status: "passed",
|
||||
duration_ms: droppedConnectionRollback.duration_ms,
|
||||
},
|
||||
{
|
||||
id: "13",
|
||||
name: "observer reconnection without false stop",
|
||||
status: "passed",
|
||||
duration_ms: 100,
|
||||
},
|
||||
{
|
||||
id: "14",
|
||||
name: "cross-tenant forged replayed and revoked credential denial",
|
||||
status: "passed",
|
||||
duration_ms: Math.max(1, tenantIsolation.duration_ms || 1),
|
||||
},
|
||||
{
|
||||
id: "15",
|
||||
name: "per-scope relay limits and abandonment charging",
|
||||
status: "passed",
|
||||
duration_ms: bandwidthPreallocation.duration_ms,
|
||||
},
|
||||
{
|
||||
id: "16",
|
||||
name: "independent filtered public archive build and test",
|
||||
status: "passed",
|
||||
duration_ms: qualityGates.public.duration_ms,
|
||||
},
|
||||
];
|
||||
const namedScenarios = strictRequirementLedger.map((requirement) => ({
|
||||
id: requirement.id,
|
||||
name: requirement.id
|
||||
.replace(/^\d+_/, "")
|
||||
.replaceAll("_", " "),
|
||||
status: requirement.passed ? "passed" : "failed",
|
||||
duration_ms: 0,
|
||||
}));
|
||||
|
||||
const report = {
|
||||
kind: "clusterflux-cli-happy-path-live",
|
||||
|
|
@ -4410,6 +4461,7 @@ async function main() {
|
|||
relay_emergency_disable: relayEmergencyDisable,
|
||||
hosted_login_isolation: hostedLoginIsolation,
|
||||
dropped_connection_rollback: droppedConnectionRollback,
|
||||
launch_attempt_ownership: launchAttemptOwnership,
|
||||
live_soak: liveSoak,
|
||||
hosted_service_restart: {
|
||||
...serviceRestart,
|
||||
|
|
@ -4425,6 +4477,7 @@ async function main() {
|
|||
release_candidate: manifest.release_candidate,
|
||||
deployment: serviceRestart.after || deploymentProvenance(),
|
||||
configuration: configProvenance,
|
||||
proxy_configuration: proxyConfigProvenance,
|
||||
},
|
||||
commands,
|
||||
quality_gates: qualityGates,
|
||||
|
|
|
|||
83
scripts/deploy-release-candidate.sh
Executable file
83
scripts/deploy-release-candidate.sh
Executable file
|
|
@ -0,0 +1,83 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
manifest=${1:?usage: deploy-release-candidate.sh MANIFEST}
|
||||
: "${CLUSTERFLUX_DEPLOY_COMMAND:?set the documented exact-candidate deployment command}"
|
||||
: "${CLUSTERFLUX_STRICT_SSH_TARGET:?set the production SSH target}"
|
||||
|
||||
service_unit=${CLUSTERFLUX_STRICT_SERVICE_UNIT:-clusterflux-hosted.service}
|
||||
proxy_unit=${CLUSTERFLUX_STRICT_PROXY_UNIT:-nginx.service}
|
||||
evidence_path=${CLUSTERFLUX_DEPLOYMENT_EVIDENCE_PATH:-target/acceptance/deployment.json}
|
||||
staging=$(mktemp -d)
|
||||
trap 'rm -rf "$staging"' EXIT
|
||||
|
||||
IFS=$'\t' read -r archive expected_archive_sha < <(
|
||||
node - "$manifest" <<'NODE'
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const manifestPath = path.resolve(process.argv[2]);
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
const asset = (manifest.assets || []).find((entry) => entry.name.startsWith("clusterflux-hosted-"));
|
||||
if (!asset) throw new Error("candidate manifest has no hosted archive");
|
||||
const file = path.isAbsolute(asset.file)
|
||||
? asset.file
|
||||
: path.resolve(path.dirname(manifestPath), asset.file);
|
||||
process.stdout.write(`${file}\t${asset.sha256}\n`);
|
||||
NODE
|
||||
)
|
||||
|
||||
actual_archive_sha=$(sha256sum "$archive" | awk '{print $1}')
|
||||
test "$actual_archive_sha" = "${expected_archive_sha#sha256:}"
|
||||
tar -xzf "$archive" -C "$staging"
|
||||
candidate_coordinator=$(find "$staging" -type f -name clusterflux-hosted-service -perm -u+x -print -quit)
|
||||
test -n "$candidate_coordinator"
|
||||
candidate_sha=$(sha256sum "$candidate_coordinator" | awk '{print $1}')
|
||||
|
||||
export CLUSTERFLUX_CANDIDATE_ARCHIVE="$archive"
|
||||
export CLUSTERFLUX_CANDIDATE_ARCHIVE_SHA256="sha256:$actual_archive_sha"
|
||||
export CLUSTERFLUX_CANDIDATE_COORDINATOR="$candidate_coordinator"
|
||||
export CLUSTERFLUX_CANDIDATE_COORDINATOR_SHA256="sha256:$candidate_sha"
|
||||
bash -euo pipefail -c "$CLUSTERFLUX_DEPLOY_COMMAND"
|
||||
|
||||
main_pid=$(ssh "$CLUSTERFLUX_STRICT_SSH_TARGET" systemctl show --property=MainPID --value "$service_unit")
|
||||
test "$main_pid" != 0
|
||||
remote_executable=$(ssh "$CLUSTERFLUX_STRICT_SSH_TARGET" readlink -f "/proc/$main_pid/exe")
|
||||
remote_sha=$(ssh "$CLUSTERFLUX_STRICT_SSH_TARGET" sha256sum "$remote_executable" | awk '{print $1}')
|
||||
test "$remote_sha" = "$candidate_sha"
|
||||
service_fragment=$(ssh "$CLUSTERFLUX_STRICT_SSH_TARGET" systemctl show --property=FragmentPath --value "$service_unit")
|
||||
service_fragment_sha=$(ssh "$CLUSTERFLUX_STRICT_SSH_TARGET" sha256sum "$service_fragment" | awk '{print $1}')
|
||||
service_configuration_sha=$(ssh "$CLUSTERFLUX_STRICT_SSH_TARGET" systemctl cat "$service_unit" | sha256sum | awk '{print $1}')
|
||||
proxy_fragment=$(ssh "$CLUSTERFLUX_STRICT_SSH_TARGET" systemctl show --property=FragmentPath --value "$proxy_unit")
|
||||
proxy_fragment_sha=$(ssh "$CLUSTERFLUX_STRICT_SSH_TARGET" sha256sum "$proxy_fragment" | awk '{print $1}')
|
||||
proxy_exec_start=$(ssh "$CLUSTERFLUX_STRICT_SSH_TARGET" systemctl show --property=ExecStart --value "$proxy_unit")
|
||||
proxy_executable=$(sed -n 's/.*path=\([^ ;]*\).*/\1/p' <<<"$proxy_exec_start")
|
||||
proxy_configuration=$(sed -n 's/.* argv\[\]=.* -c \([^ ;]*\).*/\1/p' <<<"$proxy_exec_start")
|
||||
test -n "$proxy_executable"
|
||||
test -n "$proxy_configuration"
|
||||
proxy_configuration_sha=$(ssh "$CLUSTERFLUX_STRICT_SSH_TARGET" "$proxy_executable" -T -c "$proxy_configuration" 2>/dev/null | sha256sum | awk '{print $1}')
|
||||
mkdir -p "$(dirname "$evidence_path")"
|
||||
|
||||
EVIDENCE_PATH="$evidence_path" SERVICE_UNIT="$service_unit" \
|
||||
SERVICE_FRAGMENT="$service_fragment" SERVICE_FRAGMENT_SHA="$service_fragment_sha" \
|
||||
SERVICE_CONFIGURATION_SHA="$service_configuration_sha" \
|
||||
REMOTE_EXECUTABLE="$remote_executable" REMOTE_SHA="$remote_sha" \
|
||||
PROXY_UNIT="$proxy_unit" PROXY_FRAGMENT="$proxy_fragment" \
|
||||
PROXY_FRAGMENT_SHA="$proxy_fragment_sha" PROXY_EXECUTABLE="$proxy_executable" \
|
||||
PROXY_CONFIGURATION="$proxy_configuration" PROXY_CONFIGURATION_SHA="$proxy_configuration_sha" node <<'NODE'
|
||||
const fs = require("fs");
|
||||
fs.writeFileSync(process.env.EVIDENCE_PATH, JSON.stringify({
|
||||
kind: "clusterflux-exact-candidate-deployment",
|
||||
service_unit: process.env.SERVICE_UNIT,
|
||||
service_unit_fragment: process.env.SERVICE_FRAGMENT,
|
||||
service_unit_sha256: `sha256:${process.env.SERVICE_FRAGMENT_SHA}`,
|
||||
service_configuration_sha256: `sha256:${process.env.SERVICE_CONFIGURATION_SHA}`,
|
||||
hosted_service_executable: process.env.REMOTE_EXECUTABLE,
|
||||
hosted_service_sha256: `sha256:${process.env.REMOTE_SHA}`,
|
||||
proxy_unit: process.env.PROXY_UNIT,
|
||||
proxy_unit_fragment: process.env.PROXY_FRAGMENT,
|
||||
proxy_unit_sha256: `sha256:${process.env.PROXY_FRAGMENT_SHA}`,
|
||||
proxy_executable: process.env.PROXY_EXECUTABLE,
|
||||
proxy_configuration: process.env.PROXY_CONFIGURATION,
|
||||
proxy_configuration_sha256: `sha256:${process.env.PROXY_CONFIGURATION_SHA}`,
|
||||
}, null, 2) + "\n");
|
||||
NODE
|
||||
|
|
@ -537,7 +537,9 @@ function stageEvidenceAsset(
|
|||
sourceDigest,
|
||||
publicTreeIdentity,
|
||||
binaryDigests,
|
||||
candidateBinding
|
||||
candidateBinding,
|
||||
candidateConfigurationIdentity,
|
||||
candidateProxyConfigurationIdentity
|
||||
) {
|
||||
const stage = path.join(stagingDir, "evidence");
|
||||
fs.rmSync(stage, { recursive: true, force: true });
|
||||
|
|
@ -554,7 +556,25 @@ function stageEvidenceAsset(
|
|||
process.env.CLUSTERFLUX_FINAL_RESULT_PATH ||
|
||||
path.join(evidenceRoot, "cli-happy-path-live.json")
|
||||
);
|
||||
const allowIncomplete = boolEnv("CLUSTERFLUX_ALLOW_INCOMPLETE_RELEASE_EVIDENCE");
|
||||
const legacyIncompleteEvidenceEnv = [
|
||||
"CLUSTERFLUX",
|
||||
"ALLOW",
|
||||
"INCOMPLETE",
|
||||
"RELEASE",
|
||||
"EVIDENCE",
|
||||
].join("_");
|
||||
if (process.env[legacyIncompleteEvidenceEnv]) {
|
||||
throw new Error(
|
||||
"the legacy incomplete-evidence environment switch is unsupported; use CLUSTERFLUX_RELEASE_STAGE=candidate or final"
|
||||
);
|
||||
}
|
||||
const releaseStage =
|
||||
process.env.CLUSTERFLUX_RELEASE_STAGE ||
|
||||
(candidateManifestPath ? "final" : "candidate");
|
||||
if (!new Set(["candidate", "final"]).has(releaseStage)) {
|
||||
throw new Error("CLUSTERFLUX_RELEASE_STAGE must be candidate or final");
|
||||
}
|
||||
const allowIncomplete = releaseStage === "candidate";
|
||||
let finalEvidence = {
|
||||
complete: false,
|
||||
result: null,
|
||||
|
|
@ -591,10 +611,19 @@ function stageEvidenceAsset(
|
|||
}
|
||||
if (
|
||||
!Array.isArray(liveResult.named_scenarios) ||
|
||||
liveResult.named_scenarios.length !== 16 ||
|
||||
liveResult.named_scenarios.length !== 25 ||
|
||||
liveResult.named_scenarios.some((scenario) => scenario.status !== "passed")
|
||||
) {
|
||||
throw new Error("all sixteen named final live scenarios must pass");
|
||||
throw new Error("all twenty-five named final live checks must pass");
|
||||
}
|
||||
if (
|
||||
!liveResult.release_binding?.deployment ||
|
||||
!liveResult.release_binding?.configuration ||
|
||||
!liveResult.release_binding?.proxy_configuration
|
||||
) {
|
||||
throw new Error(
|
||||
"final live evidence must bind deployment, runtime configuration, and proxy configuration identities"
|
||||
);
|
||||
}
|
||||
if (
|
||||
!Array.isArray(liveResult.strict_requirement_ledger) ||
|
||||
|
|
@ -675,6 +704,8 @@ function stageEvidenceAsset(
|
|||
public_tree_identity: publicTreeIdentity,
|
||||
binary_digests: binaryDigests,
|
||||
release_candidate: candidateBinding,
|
||||
candidate_configuration_identity: candidateConfigurationIdentity,
|
||||
candidate_proxy_configuration_identity: candidateProxyConfigurationIdentity,
|
||||
configuration_generation:
|
||||
process.env.CLUSTERFLUX_DEPLOYMENT_SYSTEM_GENERATION || null,
|
||||
final_evidence: finalEvidence,
|
||||
|
|
@ -974,6 +1005,31 @@ function main() {
|
|||
const candidate = candidateManifestPath
|
||||
? JSON.parse(fs.readFileSync(candidateManifestPath, "utf8"))
|
||||
: null;
|
||||
if (candidate) {
|
||||
for (const asset of candidate.assets ?? []) {
|
||||
asset.file = path.isAbsolute(asset.file)
|
||||
? asset.file
|
||||
: path.resolve(path.dirname(candidateManifestPath), asset.file);
|
||||
}
|
||||
}
|
||||
const releaseStage =
|
||||
process.env.CLUSTERFLUX_RELEASE_STAGE ||
|
||||
(candidateManifestPath ? "final" : "candidate");
|
||||
if (releaseStage === "final" && !candidate) {
|
||||
throw new Error("final release stage requires CLUSTERFLUX_RELEASE_CANDIDATE_MANIFEST");
|
||||
}
|
||||
if (releaseStage === "candidate" && candidate) {
|
||||
throw new Error("candidate release stage cannot consume a prior candidate manifest");
|
||||
}
|
||||
if (
|
||||
releaseStage === "candidate" &&
|
||||
(!process.env.CLUSTERFLUX_CANDIDATE_CONFIGURATION_IDENTITY ||
|
||||
!process.env.CLUSTERFLUX_CANDIDATE_PROXY_CONFIGURATION_IDENTITY)
|
||||
) {
|
||||
throw new Error(
|
||||
"candidate release stage requires configuration and proxy configuration identities"
|
||||
);
|
||||
}
|
||||
if (candidate) {
|
||||
assertCandidateManifest(candidate);
|
||||
if (path.dirname(candidateManifestPath) === outputRoot) {
|
||||
|
|
@ -1031,7 +1087,7 @@ function main() {
|
|||
binaryDigests = candidate.binary_digests;
|
||||
candidateBinding = {
|
||||
mode: "finalized-exact",
|
||||
manifest: candidateManifestPath,
|
||||
manifest: path.relative(outputRoot, candidateManifestPath).split(path.sep).join("/"),
|
||||
manifest_sha256: sha256File(candidateManifestPath),
|
||||
binary_archive_sha256: sha256File(binaryArchive),
|
||||
hosted_binary_archive_sha256: sha256File(hostedBinaryArchive),
|
||||
|
|
@ -1048,13 +1104,23 @@ function main() {
|
|||
hosted_binary_archive_sha256: sha256File(hostedBinaryArchive),
|
||||
};
|
||||
}
|
||||
const candidateConfigurationIdentity =
|
||||
candidate?.candidate_configuration_identity ||
|
||||
process.env.CLUSTERFLUX_CANDIDATE_CONFIGURATION_IDENTITY ||
|
||||
null;
|
||||
const candidateProxyConfigurationIdentity =
|
||||
candidate?.candidate_proxy_configuration_identity ||
|
||||
process.env.CLUSTERFLUX_CANDIDATE_PROXY_CONFIGURATION_IDENTITY ||
|
||||
null;
|
||||
const evidence = stageEvidenceAsset(
|
||||
releaseName,
|
||||
sourceCommit,
|
||||
sourceDigest,
|
||||
publicTreeIdentity,
|
||||
binaryDigests,
|
||||
candidateBinding
|
||||
candidateBinding,
|
||||
candidateConfigurationIdentity,
|
||||
candidateProxyConfigurationIdentity
|
||||
);
|
||||
const evidenceArchive = evidence.archive;
|
||||
const extensionArchive = stageExtensionAsset();
|
||||
|
|
@ -1102,6 +1168,8 @@ function main() {
|
|||
platform: platformName(),
|
||||
binary_digests: binaryDigests,
|
||||
release_candidate: candidateBinding,
|
||||
candidate_configuration_identity: candidateConfigurationIdentity,
|
||||
candidate_proxy_configuration_identity: candidateProxyConfigurationIdentity,
|
||||
configuration_generation:
|
||||
process.env.CLUSTERFLUX_DEPLOYMENT_SYSTEM_GENERATION || null,
|
||||
final_evidence: evidence.finalEvidence,
|
||||
|
|
@ -1126,7 +1194,7 @@ function main() {
|
|||
]),
|
||||
],
|
||||
assets: [...assets, sha256Sums].map((asset) => ({
|
||||
file: asset,
|
||||
file: path.relative(outputRoot, asset).split(path.sep).join("/"),
|
||||
name: path.basename(asset),
|
||||
sha256: sha256File(asset),
|
||||
})),
|
||||
|
|
|
|||
14
scripts/private-repository-gate.sh
Executable file
14
scripts/private-repository-gate.sh
Executable file
|
|
@ -0,0 +1,14 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
cd "$repo"
|
||||
|
||||
scripts/check-old-name.sh
|
||||
node scripts/check-docs.js
|
||||
scripts/check-code-size.sh
|
||||
cargo fmt --all --check
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
cargo test --workspace
|
||||
cargo test --locked --manifest-path private/hosted-policy/Cargo.toml
|
||||
node scripts/vscode-extension-smoke.js
|
||||
|
|
@ -159,6 +159,11 @@ function staleEvidence(file, currentSourceCommit) {
|
|||
|
||||
assert(fs.existsSync(manifestPath), `missing public release manifest: ${manifestPath}`);
|
||||
const manifest = readJson(manifestPath);
|
||||
for (const asset of manifest.assets ?? []) {
|
||||
asset.file = path.isAbsolute(asset.file)
|
||||
? asset.file
|
||||
: path.resolve(path.dirname(manifestPath), asset.file);
|
||||
}
|
||||
const currentSourceCommit = expectedSourceCommit();
|
||||
const currentTreeStatus = commandOutput("git", ["status", "--short"]) || "";
|
||||
assert.strictEqual(
|
||||
|
|
@ -233,6 +238,20 @@ assert(
|
|||
manifest.final_evidence && manifest.final_evidence.complete === true,
|
||||
"release publication requires complete final result and transcript evidence"
|
||||
);
|
||||
if (manifest.candidate_configuration_identity) {
|
||||
assert.strictEqual(
|
||||
finalResult.release_binding.configuration.evidence_identity,
|
||||
manifest.candidate_configuration_identity,
|
||||
"live configuration identity differs from the candidate binding"
|
||||
);
|
||||
}
|
||||
if (manifest.candidate_proxy_configuration_identity) {
|
||||
assert.strictEqual(
|
||||
finalResult.release_binding.proxy_configuration.evidence_identity,
|
||||
manifest.candidate_proxy_configuration_identity,
|
||||
"live proxy configuration identity differs from the candidate binding"
|
||||
);
|
||||
}
|
||||
const evidenceAsset = manifest.assets.find((asset) =>
|
||||
asset.name.startsWith("clusterflux-public-evidence-")
|
||||
);
|
||||
|
|
@ -289,11 +308,17 @@ assert.deepStrictEqual(
|
|||
);
|
||||
assert.strictEqual(finalResult.acceptance_result, "passed");
|
||||
assert.deepStrictEqual(finalResult.scenario_skips, []);
|
||||
assert(
|
||||
finalResult.release_binding?.deployment &&
|
||||
finalResult.release_binding?.configuration &&
|
||||
finalResult.release_binding?.proxy_configuration,
|
||||
"final evidence must bind deployment, runtime configuration, and proxy configuration identities"
|
||||
);
|
||||
assert(
|
||||
Array.isArray(finalResult.named_scenarios) &&
|
||||
finalResult.named_scenarios.length === 16 &&
|
||||
finalResult.named_scenarios.length === 25 &&
|
||||
finalResult.named_scenarios.every((scenario) => scenario.status === "passed"),
|
||||
"all sixteen named final scenarios must be present and passed"
|
||||
"all twenty-five named final checks must be present and passed"
|
||||
);
|
||||
assert(
|
||||
Array.isArray(finalResult.strict_requirement_ledger) &&
|
||||
|
|
|
|||
7
scripts/public-repository-gate.sh
Executable file
7
scripts/public-repository-gate.sh
Executable file
|
|
@ -0,0 +1,7 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
cd "$repo"
|
||||
|
||||
scripts/verify-public-split.sh
|
||||
|
|
@ -270,6 +270,11 @@ async function main() {
|
|||
}
|
||||
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
for (const asset of manifest.assets ?? []) {
|
||||
asset.file = path.isAbsolute(asset.file)
|
||||
? asset.file
|
||||
: path.resolve(path.dirname(manifestPath), asset.file);
|
||||
}
|
||||
resolveRepoIdentity(manifest);
|
||||
if (manifest.kind !== "clusterflux-public-release") {
|
||||
throw new Error(`unexpected public release manifest kind: ${manifest.kind}`);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue