Public release release-ea887c8f56cd
Source commit: ea887c8f56cd53985a1179b13e5f1b85c485f584 Public tree identity: sha256:b96a97aacdbc8fd4fc2ea20d0e1450280d46db3f24aabe1475e5fbe20e05f255
This commit is contained in:
parent
9223c54939
commit
2a0f7ded04
37 changed files with 3145 additions and 628 deletions
|
|
@ -194,7 +194,7 @@ function downloadNodeCapabilities() {
|
|||
ttl_seconds: 60,
|
||||
});
|
||||
assert.strictEqual(crossTenant.type, "error");
|
||||
assert.match(crossTenant.message, /tenant mismatch/);
|
||||
assert.match(crossTenant.message, /artifact does not exist/);
|
||||
|
||||
const crossProject = await send(addr, {
|
||||
type: "create_artifact_download_link",
|
||||
|
|
@ -206,7 +206,7 @@ function downloadNodeCapabilities() {
|
|||
ttl_seconds: 60,
|
||||
});
|
||||
assert.strictEqual(crossProject.type, "error");
|
||||
assert.match(crossProject.message, /project mismatch/);
|
||||
assert.match(crossProject.message, /artifact does not exist/);
|
||||
|
||||
const crossTenantOpen = await send(addr, {
|
||||
type: "open_artifact_download_stream",
|
||||
|
|
@ -219,7 +219,7 @@ function downloadNodeCapabilities() {
|
|||
chunk_bytes: 1,
|
||||
});
|
||||
assert.strictEqual(crossTenantOpen.type, "error");
|
||||
assert.match(crossTenantOpen.message, /tenant mismatch/);
|
||||
assert.match(crossTenantOpen.message, /artifact does not exist/);
|
||||
|
||||
const crossProjectOpen = await send(addr, {
|
||||
type: "open_artifact_download_stream",
|
||||
|
|
@ -232,7 +232,7 @@ function downloadNodeCapabilities() {
|
|||
chunk_bytes: 1,
|
||||
});
|
||||
assert.strictEqual(crossProjectOpen.type, "error");
|
||||
assert.match(crossProjectOpen.message, /project mismatch/);
|
||||
assert.match(crossProjectOpen.message, /artifact does not exist/);
|
||||
|
||||
const guessed = await send(addr, {
|
||||
type: "open_artifact_download_stream",
|
||||
|
|
|
|||
|
|
@ -231,7 +231,7 @@ async function reportNode(
|
|||
failure_reason: "",
|
||||
});
|
||||
assert.strictEqual(crossTenant.type, "error");
|
||||
assert.match(crossTenant.message, /tenant mismatch/);
|
||||
assert.match(crossTenant.message, /artifact does not exist/);
|
||||
|
||||
await reportNode(addr, sourceNode, sourceIdentity, { artifacts: [artifact] });
|
||||
const failedDirect = await send(addr, {
|
||||
|
|
|
|||
|
|
@ -81,17 +81,12 @@ function requireEnabled() {
|
|||
const hasSecondTenantSession = Boolean(
|
||||
process.env.CLUSTERFLUX_SECOND_TENANT_SESSION_FILE
|
||||
);
|
||||
const hasSingleAccountIsolationTarget = Boolean(
|
||||
process.env.CLUSTERFLUX_ISOLATION_TARGET_TENANT &&
|
||||
process.env.CLUSTERFLUX_ISOLATION_TARGET_PROJECT
|
||||
);
|
||||
if (
|
||||
strictFullRelease &&
|
||||
!hasSecondTenantSession &&
|
||||
!hasSingleAccountIsolationTarget
|
||||
!hasSecondTenantSession
|
||||
) {
|
||||
throw new Error(
|
||||
"CLUSTERFLUX_STRICT_FULL_RELEASE=1 requires either CLUSTERFLUX_SECOND_TENANT_SESSION_FILE or both CLUSTERFLUX_ISOLATION_TARGET_TENANT and CLUSTERFLUX_ISOLATION_TARGET_PROJECT"
|
||||
"CLUSTERFLUX_STRICT_FULL_RELEASE=1 requires CLUSTERFLUX_SECOND_TENANT_SESSION_FILE so the compiled two-tenant Node and artifact collision can run"
|
||||
);
|
||||
}
|
||||
if (strictFullRelease && !process.env.CLUSTERFLUX_EXPIRED_USER_SESSION_FILE) {
|
||||
|
|
@ -500,8 +495,12 @@ async function runMalformedIdentifierSuite({
|
|||
send: async (value) =>
|
||||
sendHostedControl({
|
||||
type: "node_heartbeat",
|
||||
tenant,
|
||||
project,
|
||||
node: value,
|
||||
node_signature: signedNodeHeartbeat(
|
||||
tenant,
|
||||
project,
|
||||
value,
|
||||
securityNode.identity,
|
||||
{
|
||||
|
|
@ -803,8 +802,12 @@ async function runMalformedIdentifierSuite({
|
|||
send: async (value) => {
|
||||
const request = {
|
||||
type: "node_heartbeat",
|
||||
tenant,
|
||||
project,
|
||||
node: securityNode.node,
|
||||
node_signature: signedNodeHeartbeat(
|
||||
tenant,
|
||||
project,
|
||||
securityNode.node,
|
||||
securityNode.identity,
|
||||
{ nonce: value }
|
||||
|
|
@ -1616,8 +1619,10 @@ async function prepareLiveNodeCredentialSecurity({
|
|||
|
||||
const heartbeat = {
|
||||
type: "node_heartbeat",
|
||||
tenant,
|
||||
project,
|
||||
node,
|
||||
node_signature: signedNodeHeartbeat(node, identity, {
|
||||
node_signature: signedNodeHeartbeat(tenant, project, node, identity, {
|
||||
nonce: `strict-valid-${suffix}`,
|
||||
}),
|
||||
};
|
||||
|
|
@ -1632,8 +1637,10 @@ async function prepareLiveNodeCredentialSecurity({
|
|||
const expired = assertDenied(
|
||||
await sendHostedControl({
|
||||
type: "node_heartbeat",
|
||||
tenant,
|
||||
project,
|
||||
node,
|
||||
node_signature: signedNodeHeartbeat(node, identity, {
|
||||
node_signature: signedNodeHeartbeat(tenant, project, node, identity, {
|
||||
nonce: `strict-expired-${suffix}`,
|
||||
issuedAtEpochSeconds: Math.floor(Date.now() / 1000) - 3600,
|
||||
}),
|
||||
|
|
@ -1646,8 +1653,10 @@ async function prepareLiveNodeCredentialSecurity({
|
|||
const forged = assertDenied(
|
||||
await sendHostedControl({
|
||||
type: "node_heartbeat",
|
||||
tenant,
|
||||
project,
|
||||
node,
|
||||
node_signature: signedNodeHeartbeat(node, forgedIdentity, {
|
||||
node_signature: signedNodeHeartbeat(tenant, project, node, forgedIdentity, {
|
||||
nonce: `strict-forged-${suffix}`,
|
||||
}),
|
||||
}),
|
||||
|
|
@ -2556,6 +2565,12 @@ async function runLiveAgentCredentialSecurity({
|
|||
}
|
||||
|
||||
async function runSecondTenantIsolation({
|
||||
clusterflux,
|
||||
clusterfluxNode,
|
||||
firstScope,
|
||||
firstHelloBuild,
|
||||
firstHelloProjectDir,
|
||||
workRoot,
|
||||
firstSessionSecret,
|
||||
firstTenant,
|
||||
firstProject,
|
||||
|
|
@ -2597,14 +2612,17 @@ async function runSecondTenantIsolation({
|
|||
"reused isolation evidence must target the exact public binaries"
|
||||
);
|
||||
return {
|
||||
...isolation,
|
||||
duration_ms: Date.now() - startedAt,
|
||||
reused_from_immutable_evidence: {
|
||||
report_sha256: sha256(evidenceBytes),
|
||||
source_commit: previous.source_commit,
|
||||
hosted_service_sha256: currentDeployment.hosted_service_sha256,
|
||||
public_binary_digests: manifest.binary_digests,
|
||||
evidence: {
|
||||
...isolation,
|
||||
duration_ms: Date.now() - startedAt,
|
||||
reused_from_immutable_evidence: {
|
||||
report_sha256: sha256(evidenceBytes),
|
||||
source_commit: previous.source_commit,
|
||||
hosted_service_sha256: currentDeployment.hosted_service_sha256,
|
||||
public_binary_digests: manifest.binary_digests,
|
||||
},
|
||||
},
|
||||
runtime: null,
|
||||
};
|
||||
}
|
||||
if (!file && targetTenant && targetProject) {
|
||||
|
|
@ -2702,36 +2720,56 @@ async function runSecondTenantIsolation({
|
|||
"single-account foreign process control"
|
||||
);
|
||||
return {
|
||||
executed: true,
|
||||
mode: "single_authenticated_account_foreign_project",
|
||||
second_tenant: targetTenant,
|
||||
target_project: targetProject,
|
||||
project,
|
||||
process_hidden: true,
|
||||
node_hidden: true,
|
||||
tasks_and_logs: tasksAndLogs,
|
||||
debug: {
|
||||
denied: true,
|
||||
reason: debugResponse.authorization.reason,
|
||||
audited: true,
|
||||
charged_debug_read_bytes: debugResponse.charged_debug_read_bytes,
|
||||
evidence: {
|
||||
executed: true,
|
||||
mode: "single_authenticated_account_foreign_project",
|
||||
second_tenant: targetTenant,
|
||||
target_project: targetProject,
|
||||
project,
|
||||
process_hidden: true,
|
||||
node_hidden: true,
|
||||
tasks_and_logs: tasksAndLogs,
|
||||
debug: {
|
||||
denied: true,
|
||||
reason: debugResponse.authorization.reason,
|
||||
audited: true,
|
||||
charged_debug_read_bytes: debugResponse.charged_debug_read_bytes,
|
||||
},
|
||||
artifact_and_download: artifact,
|
||||
process_control: control,
|
||||
duration_ms: Date.now() - startedAt,
|
||||
},
|
||||
artifact_and_download: artifact,
|
||||
process_control: control,
|
||||
duration_ms: Date.now() - startedAt,
|
||||
runtime: null,
|
||||
};
|
||||
}
|
||||
if (!file) return { executed: false, reason: "second tenant session not supplied" };
|
||||
const second = readJson(path.resolve(file));
|
||||
if (!file) {
|
||||
return {
|
||||
evidence: {
|
||||
executed: false,
|
||||
reason: "second tenant session not supplied",
|
||||
duration_ms: Math.max(1, Date.now() - startedAt),
|
||||
},
|
||||
runtime: null,
|
||||
};
|
||||
}
|
||||
const secondSessionPath = path.resolve(file);
|
||||
const second = readJson(secondSessionPath);
|
||||
assert.strictEqual(second.coordinator, serviceEndpoint);
|
||||
assert(second.session_secret, "second tenant session omitted its session secret");
|
||||
const secondSessionSecret =
|
||||
second.cli_session_secret || second.session_secret;
|
||||
assert(secondSessionSecret, "second tenant session omitted its session secret");
|
||||
assert.notStrictEqual(
|
||||
second.tenant,
|
||||
firstTenant,
|
||||
"isolation proof requires a genuinely distinct hosted tenant"
|
||||
);
|
||||
assert.notStrictEqual(
|
||||
second.project,
|
||||
firstProject,
|
||||
"isolation proof requires a genuinely distinct hosted project"
|
||||
);
|
||||
const call = (request) =>
|
||||
sendHostedControl(authenticatedRequest(second.session_secret, request));
|
||||
sendHostedControl(authenticatedRequest(secondSessionSecret, request));
|
||||
|
||||
const project = assertDenied(
|
||||
await call({ type: "select_project", project: firstProject }),
|
||||
|
|
@ -2778,7 +2816,7 @@ async function runSecondTenantIsolation({
|
|||
max_bytes: 1024,
|
||||
ttl_seconds: 60,
|
||||
}),
|
||||
/outside|scope|tenant|project|not found|unavailable/i,
|
||||
/outside|scope|tenant|project|not found|does not exist|unavailable/i,
|
||||
"cross-tenant artifact download"
|
||||
);
|
||||
const control = assertDenied(
|
||||
|
|
@ -2786,17 +2824,437 @@ async function runSecondTenantIsolation({
|
|||
/outside|scope|tenant|project|not active|requires an active|unknown/i,
|
||||
"cross-tenant process control"
|
||||
);
|
||||
|
||||
const secondCheckout = path.join(workRoot, "second-tenant-public-repo");
|
||||
stagePublicCheckout(manifest, secondCheckout);
|
||||
const secondProjectDir = path.join(
|
||||
secondCheckout,
|
||||
"examples",
|
||||
"hello-build"
|
||||
);
|
||||
const secondControlDir = path.join(secondProjectDir, ".clusterflux");
|
||||
ensureDir(secondControlDir);
|
||||
const secondProjectPath = path.join(
|
||||
path.dirname(secondSessionPath),
|
||||
"project.json"
|
||||
);
|
||||
assert(
|
||||
fs.existsSync(secondProjectPath),
|
||||
`second tenant session is missing adjacent project.json: ${secondProjectPath}`
|
||||
);
|
||||
fs.copyFileSync(
|
||||
secondSessionPath,
|
||||
path.join(secondControlDir, "session.json")
|
||||
);
|
||||
fs.copyFileSync(
|
||||
secondProjectPath,
|
||||
path.join(secondControlDir, "project.json")
|
||||
);
|
||||
fs.chmodSync(path.join(secondControlDir, "session.json"), 0o600);
|
||||
fs.chmodSync(path.join(secondControlDir, "project.json"), 0o644);
|
||||
const secondScope = [
|
||||
"--coordinator",
|
||||
serviceEndpoint,
|
||||
"--tenant",
|
||||
second.tenant,
|
||||
"--project-id",
|
||||
second.project,
|
||||
"--user",
|
||||
second.user,
|
||||
"--json",
|
||||
];
|
||||
const secondProjectList = runJson(
|
||||
clusterflux,
|
||||
["project", "list", ...secondScope],
|
||||
{ cwd: secondProjectDir }
|
||||
);
|
||||
assert(secondProjectList.project_count >= 1);
|
||||
const secondProjectSelect = runJson(
|
||||
clusterflux,
|
||||
["project", "select", ...secondScope, second.project],
|
||||
{ cwd: secondProjectDir }
|
||||
);
|
||||
assert.strictEqual(secondProjectSelect.command, "project select");
|
||||
|
||||
const collisionStartedAt = Date.now();
|
||||
const secondGrant = runJson(
|
||||
clusterflux,
|
||||
["node", "enroll", ...secondScope],
|
||||
{ cwd: secondProjectDir }
|
||||
);
|
||||
const secondAttach = runJson(
|
||||
clusterflux,
|
||||
[
|
||||
"node",
|
||||
"attach",
|
||||
"--coordinator",
|
||||
serviceEndpoint,
|
||||
"--tenant",
|
||||
second.tenant,
|
||||
"--project-id",
|
||||
second.project,
|
||||
"--node",
|
||||
firstNode,
|
||||
"--enrollment-grant",
|
||||
secondGrant.enrollment_grant.grant,
|
||||
"--json",
|
||||
],
|
||||
{ cwd: secondProjectDir }
|
||||
);
|
||||
assert.strictEqual(secondAttach.boundary.used_enrollment_exchange, true);
|
||||
const secondStored = readNodeCredential(secondProjectDir, firstNode);
|
||||
const secondCredentialDigest = sha256(
|
||||
fs.readFileSync(secondStored.absolute)
|
||||
);
|
||||
const secondIdentity = nodeIdentityFromPrivateKey(
|
||||
secondStored.credential.private_key
|
||||
);
|
||||
const secondWorkerArgs = [
|
||||
"--coordinator",
|
||||
serviceEndpoint,
|
||||
"--tenant",
|
||||
second.tenant,
|
||||
"--project-id",
|
||||
second.project,
|
||||
"--node",
|
||||
firstNode,
|
||||
"--worker",
|
||||
"--project-root",
|
||||
secondProjectDir,
|
||||
"--assignment-poll-ms",
|
||||
"500",
|
||||
"--emit-ready",
|
||||
];
|
||||
const firstHelloWorker = spawnJsonLines(
|
||||
clusterfluxNode,
|
||||
[
|
||||
"--coordinator",
|
||||
serviceEndpoint,
|
||||
"--tenant",
|
||||
firstTenant,
|
||||
"--project-id",
|
||||
firstProject,
|
||||
"--node",
|
||||
firstNode,
|
||||
"--worker",
|
||||
"--project-root",
|
||||
firstHelloProjectDir,
|
||||
"--assignment-poll-ms",
|
||||
"500",
|
||||
"--emit-ready",
|
||||
],
|
||||
{
|
||||
cwd: firstHelloProjectDir,
|
||||
env: { ...process.env },
|
||||
}
|
||||
);
|
||||
const firstHelloReady = await firstHelloWorker.waitFor(
|
||||
(value) => value.node_status === "ready",
|
||||
"first-tenant hello-build artifact worker ready for collision proof"
|
||||
);
|
||||
assert.strictEqual(firstHelloReady.node, firstNode);
|
||||
const firstCollisionHelloBuild = await runHostedHelloBuild({
|
||||
clusterflux,
|
||||
projectDir: firstHelloProjectDir,
|
||||
scope: firstScope,
|
||||
outputFile: path.join(workRoot, "hello-clusterflux-first-tenant-collision"),
|
||||
});
|
||||
assert.strictEqual(
|
||||
firstCollisionHelloBuild.artifact,
|
||||
firstHelloBuild.artifact,
|
||||
"repeated deterministic first-tenant hello-build changed its ArtifactId"
|
||||
);
|
||||
assert.strictEqual(firstCollisionHelloBuild.digest, firstHelloBuild.digest);
|
||||
assert.strictEqual(
|
||||
firstCollisionHelloBuild.executable_output,
|
||||
firstHelloBuild.executable_output
|
||||
);
|
||||
const secondWorker = spawnJsonLines(clusterfluxNode, secondWorkerArgs, {
|
||||
cwd: secondProjectDir,
|
||||
env: { ...process.env },
|
||||
});
|
||||
let secondHelloBuild;
|
||||
try {
|
||||
const secondReady = await secondWorker.waitFor(
|
||||
(value) => value.node_status === "ready",
|
||||
"same-name second-tenant worker ready"
|
||||
);
|
||||
assert.strictEqual(secondReady.node, firstNode);
|
||||
secondHelloBuild = await runHostedHelloBuild({
|
||||
clusterflux,
|
||||
projectDir: secondProjectDir,
|
||||
scope: secondScope,
|
||||
outputFile: path.join(workRoot, "hello-clusterflux-second-tenant"),
|
||||
});
|
||||
} finally {
|
||||
await stopChild(secondWorker.child);
|
||||
}
|
||||
assert.strictEqual(
|
||||
secondHelloBuild.artifact,
|
||||
firstCollisionHelloBuild.artifact,
|
||||
"deterministic two-tenant hello-build did not collide on ArtifactId"
|
||||
);
|
||||
assert.strictEqual(
|
||||
secondHelloBuild.digest,
|
||||
firstCollisionHelloBuild.digest,
|
||||
"deterministic two-tenant hello-build did not produce identical bytes"
|
||||
);
|
||||
assert.strictEqual(
|
||||
secondHelloBuild.executable_output,
|
||||
firstCollisionHelloBuild.executable_output
|
||||
);
|
||||
|
||||
const firstArtifactsAfterCollision = runJson(
|
||||
clusterflux,
|
||||
[
|
||||
"artifact",
|
||||
"list",
|
||||
...firstScope,
|
||||
"--process",
|
||||
firstCollisionHelloBuild.process,
|
||||
],
|
||||
{ cwd: firstHelloProjectDir }
|
||||
);
|
||||
assert(
|
||||
firstArtifactsAfterCollision.artifacts.some(
|
||||
(candidate) =>
|
||||
candidate.artifact === firstCollisionHelloBuild.artifact &&
|
||||
candidate.digest === firstCollisionHelloBuild.digest
|
||||
),
|
||||
"the second tenant's same-ID artifact masked the first tenant's metadata"
|
||||
);
|
||||
const secondCollisionLink = await call({
|
||||
type: "create_artifact_download_link",
|
||||
artifact: secondHelloBuild.artifact,
|
||||
max_bytes: secondHelloBuild.downloaded_bytes,
|
||||
ttl_seconds: 60,
|
||||
});
|
||||
assert.strictEqual(
|
||||
secondCollisionLink.type,
|
||||
"artifact_download_link",
|
||||
JSON.stringify(secondCollisionLink)
|
||||
);
|
||||
const secondCollisionLinkRevoked = await call({
|
||||
type: "revoke_artifact_download_link",
|
||||
artifact: secondHelloBuild.artifact,
|
||||
token_digest: secondCollisionLink.link.scoped_token_digest,
|
||||
});
|
||||
assert.strictEqual(
|
||||
secondCollisionLinkRevoked.type,
|
||||
"artifact_download_link_revoked",
|
||||
JSON.stringify(secondCollisionLinkRevoked)
|
||||
);
|
||||
const firstDownloadAfterSecondLinkRevocation = runJson(
|
||||
clusterflux,
|
||||
[
|
||||
"artifact",
|
||||
"download",
|
||||
...firstScope,
|
||||
firstCollisionHelloBuild.artifact,
|
||||
"--to",
|
||||
path.join(workRoot, "hello-clusterflux-first-after-second-link-revoke"),
|
||||
],
|
||||
{ cwd: firstHelloProjectDir }
|
||||
);
|
||||
assert.strictEqual(
|
||||
firstDownloadAfterSecondLinkRevocation.local_download.verified_digest,
|
||||
firstCollisionHelloBuild.digest
|
||||
);
|
||||
await stopChild(firstHelloWorker.child);
|
||||
|
||||
return {
|
||||
executed: true,
|
||||
second_tenant: second.tenant,
|
||||
evidence: {
|
||||
executed: true,
|
||||
second_tenant: second.tenant,
|
||||
second_project: second.project,
|
||||
project,
|
||||
process_hidden: true,
|
||||
node_hidden: true,
|
||||
tasks_and_logs: tasksAndLogs,
|
||||
debug,
|
||||
artifact_and_download: artifact,
|
||||
process_control: control,
|
||||
scoped_node_and_artifact_collision: {
|
||||
request: {
|
||||
node: firstNode,
|
||||
first_scope: {
|
||||
tenant: firstTenant,
|
||||
project: firstProject,
|
||||
},
|
||||
second_scope: {
|
||||
tenant: second.tenant,
|
||||
project: second.project,
|
||||
},
|
||||
example: "hello-build",
|
||||
},
|
||||
expected:
|
||||
"same-name Nodes coexist and deterministic hello-build artifacts share an ArtifactId without metadata, link, or download interference",
|
||||
observed: {
|
||||
first_process: firstCollisionHelloBuild.process,
|
||||
second_process: secondHelloBuild.process,
|
||||
same_artifact_id: secondHelloBuild.artifact,
|
||||
first_digest: firstCollisionHelloBuild.digest,
|
||||
second_digest: secondHelloBuild.digest,
|
||||
first_downloaded_bytes: firstCollisionHelloBuild.downloaded_bytes,
|
||||
second_downloaded_bytes: secondHelloBuild.downloaded_bytes,
|
||||
exact_executable_output: secondHelloBuild.executable_output,
|
||||
both_metadata_records_visible_to_owner: true,
|
||||
cross_tenant_unique_artifact_denied: artifact.denied,
|
||||
second_link_revoked: true,
|
||||
first_download_survived_second_link_revocation: true,
|
||||
},
|
||||
duration_ms: Math.max(1, Date.now() - collisionStartedAt),
|
||||
},
|
||||
duration_ms: Math.max(1, Date.now() - startedAt),
|
||||
},
|
||||
runtime: {
|
||||
node: firstNode,
|
||||
tenant: second.tenant,
|
||||
project: second.project,
|
||||
projectDir: secondProjectDir,
|
||||
scope: secondScope,
|
||||
workerArgs: secondWorkerArgs,
|
||||
credentialPath: secondStored.absolute,
|
||||
credentialDigest: secondCredentialDigest,
|
||||
identity: secondIdentity,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function runLiveSignedHostileArtifactPath({
|
||||
clusterflux,
|
||||
projectDir,
|
||||
scope,
|
||||
tenant,
|
||||
project,
|
||||
suffix,
|
||||
securityNode,
|
||||
}) {
|
||||
const startedAt = Date.now();
|
||||
const runReport = runJson(
|
||||
clusterflux,
|
||||
["run", "park-wake", "--project", ".", "--json"],
|
||||
{ cwd: projectDir }
|
||||
);
|
||||
assert.strictEqual(runReport.status, "main_launched");
|
||||
const process = runReport.process;
|
||||
await waitForCli(
|
||||
"hostile-path probe process to become active",
|
||||
() =>
|
||||
runJson(
|
||||
clusterflux,
|
||||
["process", "status", ...scope, "--process", process],
|
||||
{ cwd: projectDir }
|
||||
),
|
||||
(status) => status.state !== "not_active",
|
||||
120_000
|
||||
);
|
||||
|
||||
const invalidStartedAt = Date.now();
|
||||
const invalid = assertDenied(
|
||||
await sendHostedControl(
|
||||
signedNodeRequest(
|
||||
securityNode.node,
|
||||
securityNode.identity,
|
||||
"report_vfs_metadata",
|
||||
{
|
||||
type: "report_vfs_metadata",
|
||||
tenant,
|
||||
project,
|
||||
process,
|
||||
node: securityNode.node,
|
||||
task: `hostile-path-${suffix}`,
|
||||
artifact_path: "/vfs/artifacts/bad artifact!",
|
||||
artifact_digest: sha256(Buffer.from("hostile-path-invalid")),
|
||||
artifact_size_bytes: 20,
|
||||
large_bytes_uploaded: false,
|
||||
},
|
||||
{ nonce: `strict-hostile-path-invalid-${suffix}-${Date.now()}` }
|
||||
)
|
||||
),
|
||||
/invalid VFS artifact path|invalid artifact path|ArtifactId is invalid/i,
|
||||
"correctly signed hostile artifact path"
|
||||
);
|
||||
const invalidDurationMs = Math.max(1, Date.now() - invalidStartedAt);
|
||||
|
||||
const validStartedAt = Date.now();
|
||||
const validArtifact = `strict-hostile-followup-${suffix}`;
|
||||
const valid = await sendHostedControl(
|
||||
signedNodeRequest(
|
||||
securityNode.node,
|
||||
securityNode.identity,
|
||||
"report_vfs_metadata",
|
||||
{
|
||||
type: "report_vfs_metadata",
|
||||
tenant,
|
||||
project,
|
||||
process,
|
||||
node: securityNode.node,
|
||||
task: `hostile-path-${suffix}`,
|
||||
artifact_path: `/vfs/artifacts/${validArtifact}`,
|
||||
artifact_digest: sha256(Buffer.from("hostile-path-valid")),
|
||||
artifact_size_bytes: 18,
|
||||
large_bytes_uploaded: false,
|
||||
},
|
||||
{ nonce: `strict-hostile-path-valid-${suffix}-${Date.now()}` }
|
||||
)
|
||||
);
|
||||
assert.strictEqual(valid.type, "vfs_metadata_recorded", JSON.stringify(valid));
|
||||
const healthyHeartbeat = await sendHostedControl({
|
||||
type: "node_heartbeat",
|
||||
tenant,
|
||||
project,
|
||||
process_hidden: true,
|
||||
node_hidden: true,
|
||||
tasks_and_logs: tasksAndLogs,
|
||||
debug,
|
||||
artifact_and_download: artifact,
|
||||
process_control: control,
|
||||
duration_ms: Date.now() - startedAt,
|
||||
node: securityNode.node,
|
||||
node_signature: signedNodeHeartbeat(
|
||||
tenant,
|
||||
project,
|
||||
securityNode.node,
|
||||
securityNode.identity,
|
||||
{ nonce: `strict-hostile-path-health-${suffix}-${Date.now()}` }
|
||||
),
|
||||
});
|
||||
assert.strictEqual(
|
||||
healthyHeartbeat.type,
|
||||
"node_heartbeat",
|
||||
JSON.stringify(healthyHeartbeat)
|
||||
);
|
||||
const validDurationMs = Math.max(1, Date.now() - validStartedAt);
|
||||
const aborted = runJson(
|
||||
clusterflux,
|
||||
["process", "abort", ...scope, "--process", process, "--yes"],
|
||||
{ cwd: projectDir }
|
||||
);
|
||||
assert.strictEqual(aborted.abort_request.accepted, true);
|
||||
await waitForCli(
|
||||
"hostile-path probe process cleanup",
|
||||
() =>
|
||||
runJson(
|
||||
clusterflux,
|
||||
["process", "status", ...scope, "--process", process],
|
||||
{ cwd: projectDir }
|
||||
),
|
||||
(status) => status.state === "not_active",
|
||||
120_000
|
||||
);
|
||||
return {
|
||||
request: {
|
||||
principal: "enrolled signed Node",
|
||||
variant: "report_vfs_metadata",
|
||||
process,
|
||||
malformed_path: "/vfs/artifacts/bad artifact!",
|
||||
},
|
||||
expected:
|
||||
"structured InvalidArtifactPath rejection followed immediately by valid signed metadata and heartbeat on the same service instance",
|
||||
observed: {
|
||||
rejection: invalid,
|
||||
valid_metadata_response: valid.type,
|
||||
valid_artifact: validArtifact,
|
||||
health_response: healthyHeartbeat.type,
|
||||
process_cleanup: "not_active",
|
||||
},
|
||||
invalid_duration_ms: invalidDurationMs,
|
||||
valid_followup_duration_ms: validDurationMs,
|
||||
duration_ms: Math.max(1, Date.now() - startedAt),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -2960,8 +3418,12 @@ async function revokeSecurityNode({
|
|||
const denied = assertDenied(
|
||||
await sendHostedControl({
|
||||
type: "node_heartbeat",
|
||||
tenant: securityNode.capability_body.tenant,
|
||||
project: securityNode.capability_body.project,
|
||||
node: securityNode.node,
|
||||
node_signature: signedNodeHeartbeat(
|
||||
securityNode.capability_body.tenant,
|
||||
securityNode.capability_body.project,
|
||||
securityNode.node,
|
||||
securityNode.identity,
|
||||
{ nonce: `strict-node-revoked-${Date.now()}` }
|
||||
|
|
@ -3742,6 +4204,7 @@ async function restartHostedServiceAndResume({
|
|||
workerNode,
|
||||
credentialPath,
|
||||
credentialDigest,
|
||||
scopedCollisionRuntime,
|
||||
}) {
|
||||
if (!strictVpsRestart) {
|
||||
return {
|
||||
|
|
@ -3841,6 +4304,79 @@ async function restartHostedServiceAndResume({
|
|||
assert.strictEqual(ready.node, workerNode);
|
||||
assert.strictEqual(sha256(fs.readFileSync(credentialPath)), credentialDigest);
|
||||
|
||||
let scopedNodeCollisionAfterRestart = null;
|
||||
if (scopedCollisionRuntime) {
|
||||
assert.strictEqual(scopedCollisionRuntime.node, workerNode);
|
||||
assert.notStrictEqual(
|
||||
scopedCollisionRuntime.tenant,
|
||||
readJson(path.join(projectDir, ".clusterflux", "session.json")).tenant
|
||||
);
|
||||
assert.strictEqual(
|
||||
sha256(fs.readFileSync(scopedCollisionRuntime.credentialPath)),
|
||||
scopedCollisionRuntime.credentialDigest
|
||||
);
|
||||
const secondWorker = spawnJsonLines(
|
||||
clusterfluxNode,
|
||||
scopedCollisionRuntime.workerArgs,
|
||||
{
|
||||
cwd: scopedCollisionRuntime.projectDir,
|
||||
env: { ...process.env },
|
||||
}
|
||||
);
|
||||
try {
|
||||
const secondReady = await secondWorker.waitFor(
|
||||
(value) => value.node_status === "ready",
|
||||
"same-name second-tenant worker ready after hosted service restart"
|
||||
);
|
||||
assert.strictEqual(secondReady.node, workerNode);
|
||||
const firstStatus = await waitForCli(
|
||||
"first scoped same-name Node online after restart",
|
||||
() =>
|
||||
runJson(
|
||||
clusterflux,
|
||||
["node", "status", ...scope, "--node", workerNode],
|
||||
{ cwd: projectDir }
|
||||
),
|
||||
(status) => nodeDescriptor(status, workerNode)?.online === true,
|
||||
30_000
|
||||
);
|
||||
const secondStatus = await waitForCli(
|
||||
"second scoped same-name Node online after restart",
|
||||
() =>
|
||||
runJson(
|
||||
clusterflux,
|
||||
[
|
||||
"node",
|
||||
"status",
|
||||
...scopedCollisionRuntime.scope,
|
||||
"--node",
|
||||
workerNode,
|
||||
],
|
||||
{ cwd: scopedCollisionRuntime.projectDir }
|
||||
),
|
||||
(status) => nodeDescriptor(status, workerNode)?.online === true,
|
||||
30_000
|
||||
);
|
||||
scopedNodeCollisionAfterRestart = {
|
||||
node: workerNode,
|
||||
first_scope_online:
|
||||
nodeDescriptor(firstStatus, workerNode)?.online === true,
|
||||
second_scope_online:
|
||||
nodeDescriptor(secondStatus, workerNode)?.online === true,
|
||||
both_credentials_reused_without_grants: true,
|
||||
distinct_credential_digests:
|
||||
credentialDigest !== scopedCollisionRuntime.credentialDigest,
|
||||
};
|
||||
assert.strictEqual(
|
||||
scopedNodeCollisionAfterRestart.distinct_credential_digests,
|
||||
true,
|
||||
"same-name scoped Nodes unexpectedly reused one credential"
|
||||
);
|
||||
} finally {
|
||||
await stopChild(secondWorker.child);
|
||||
}
|
||||
}
|
||||
|
||||
const restartedRun = runJson(
|
||||
clusterflux,
|
||||
["run", "build", "--project", ".", "--json"],
|
||||
|
|
@ -3873,6 +4409,7 @@ async function restartHostedServiceAndResume({
|
|||
terminated_ephemeral_process: ephemeralProcess,
|
||||
new_process: restartedProcess,
|
||||
new_process_result: completedEvent.result,
|
||||
scoped_node_collision_after_restart: scopedNodeCollisionAfterRestart,
|
||||
before,
|
||||
after_first_restart: afterFirstRestart,
|
||||
after,
|
||||
|
|
@ -3880,6 +4417,79 @@ async function restartHostedServiceAndResume({
|
|||
};
|
||||
}
|
||||
|
||||
async function finishScopedNodeCollisionAfterRestart({
|
||||
clusterflux,
|
||||
projectDir,
|
||||
scope,
|
||||
workerNode,
|
||||
scopedCollisionRuntime,
|
||||
}) {
|
||||
const startedAt = Date.now();
|
||||
assert(scopedCollisionRuntime, "scoped collision runtime evidence is required");
|
||||
const revoked = runJson(
|
||||
clusterflux,
|
||||
[
|
||||
"node",
|
||||
"revoke",
|
||||
...scopedCollisionRuntime.scope,
|
||||
"--node",
|
||||
scopedCollisionRuntime.node,
|
||||
"--yes",
|
||||
],
|
||||
{ cwd: scopedCollisionRuntime.projectDir }
|
||||
);
|
||||
assert.strictEqual(revoked.command, "node revoke");
|
||||
const revokedHeartbeat = assertDenied(
|
||||
await sendHostedControl({
|
||||
type: "node_heartbeat",
|
||||
tenant: scopedCollisionRuntime.tenant,
|
||||
project: scopedCollisionRuntime.project,
|
||||
node: scopedCollisionRuntime.node,
|
||||
node_signature: signedNodeHeartbeat(
|
||||
scopedCollisionRuntime.tenant,
|
||||
scopedCollisionRuntime.project,
|
||||
scopedCollisionRuntime.node,
|
||||
scopedCollisionRuntime.identity,
|
||||
{
|
||||
nonce: `strict-scoped-node-revoked-${Date.now()}`,
|
||||
}
|
||||
),
|
||||
}),
|
||||
/not enrolled|unknown node|revoked|credential/i,
|
||||
"revoked second scoped Node"
|
||||
);
|
||||
const firstStatus = await waitForCli(
|
||||
"first same-name Node remains live after scoped revocation",
|
||||
() =>
|
||||
runJson(
|
||||
clusterflux,
|
||||
["node", "status", ...scope, "--node", workerNode],
|
||||
{ cwd: projectDir }
|
||||
),
|
||||
(status) => nodeDescriptor(status, workerNode)?.online === true,
|
||||
30_000
|
||||
);
|
||||
return {
|
||||
request: {
|
||||
revoked_scope: {
|
||||
tenant: scopedCollisionRuntime.tenant,
|
||||
project: scopedCollisionRuntime.project,
|
||||
node: scopedCollisionRuntime.node,
|
||||
},
|
||||
retained_node: workerNode,
|
||||
},
|
||||
expected:
|
||||
"revocation affects only the selected scoped Node after both same-name credentials survive restart",
|
||||
observed: {
|
||||
revoked_command: revoked.command,
|
||||
revoked_credential_denied: revokedHeartbeat.denied,
|
||||
first_scope_node_online:
|
||||
nodeDescriptor(firstStatus, workerNode)?.online === true,
|
||||
},
|
||||
duration_ms: Math.max(1, Date.now() - startedAt),
|
||||
};
|
||||
}
|
||||
|
||||
async function finishUserCredentialSecurity({
|
||||
clusterflux,
|
||||
projectDir,
|
||||
|
|
@ -5713,7 +6323,14 @@ async function main() {
|
|||
scope,
|
||||
});
|
||||
|
||||
const tenantIsolation = await runSecondTenantIsolation({
|
||||
await stopChild(worker.child);
|
||||
const tenantIsolationResult = await runSecondTenantIsolation({
|
||||
clusterflux,
|
||||
clusterfluxNode,
|
||||
firstScope: scope,
|
||||
firstHelloBuild: helloBuild,
|
||||
firstHelloProjectDir: helloProjectDir,
|
||||
workRoot,
|
||||
firstSessionSecret: sessionSecret,
|
||||
firstTenant: tenant,
|
||||
firstProject: project,
|
||||
|
|
@ -5722,6 +6339,14 @@ async function main() {
|
|||
firstArtifact: releaseArtifact.artifact,
|
||||
manifest,
|
||||
});
|
||||
const tenantIsolation = tenantIsolationResult.evidence;
|
||||
const scopedCollisionRuntime = tenantIsolationResult.runtime;
|
||||
worker = spawnWorker();
|
||||
const collisionRecoveryReady = await worker.waitFor(
|
||||
(value) => value.node_status === "ready",
|
||||
"primary worker restored after two-tenant collision proof"
|
||||
);
|
||||
assert.strictEqual(collisionRecoveryReady.node, workerNode);
|
||||
|
||||
const securityNode = await prepareLiveNodeCredentialSecurity({
|
||||
clusterflux,
|
||||
|
|
@ -5824,6 +6449,15 @@ async function main() {
|
|||
},
|
||||
releaseArtifact,
|
||||
});
|
||||
const signedHostileArtifactPath = await runLiveSignedHostileArtifactPath({
|
||||
clusterflux,
|
||||
projectDir,
|
||||
scope,
|
||||
tenant,
|
||||
project,
|
||||
suffix,
|
||||
securityNode,
|
||||
});
|
||||
const revokedNodeCredential = await revokeSecurityNode({
|
||||
clusterflux,
|
||||
projectDir,
|
||||
|
|
@ -5844,8 +6478,22 @@ async function main() {
|
|||
workerNode,
|
||||
credentialPath,
|
||||
credentialDigest,
|
||||
scopedCollisionRuntime,
|
||||
});
|
||||
if (serviceRestart.worker) worker = serviceRestart.worker;
|
||||
const scopedNodeRevocationAfterRestart = scopedCollisionRuntime
|
||||
? await finishScopedNodeCollisionAfterRestart({
|
||||
clusterflux,
|
||||
projectDir,
|
||||
scope,
|
||||
workerNode,
|
||||
scopedCollisionRuntime,
|
||||
})
|
||||
: {
|
||||
executed: false,
|
||||
reason: "second tenant runtime session was not supplied",
|
||||
duration_ms: 1,
|
||||
};
|
||||
const relayAfterRestart = relayDurableState();
|
||||
assert(
|
||||
relayAfterRestart.ingress_used >=
|
||||
|
|
@ -6298,6 +6946,49 @@ async function main() {
|
|||
evidence: processCancellationLifecycle,
|
||||
durationMs: processCancellationLifecycle.duration_ms,
|
||||
}),
|
||||
requirement({
|
||||
id: "30_scoped_node_and_artifact_collision",
|
||||
passed:
|
||||
tenantIsolation.scoped_node_and_artifact_collision?.observed
|
||||
?.both_metadata_records_visible_to_owner === true &&
|
||||
tenantIsolation.scoped_node_and_artifact_collision?.observed
|
||||
?.first_download_survived_second_link_revocation === true &&
|
||||
tenantIsolation.scoped_node_and_artifact_collision?.observed
|
||||
?.same_artifact_id === helloBuild.artifact &&
|
||||
serviceRestart.scoped_node_collision_after_restart
|
||||
?.first_scope_online === true &&
|
||||
serviceRestart.scoped_node_collision_after_restart
|
||||
?.second_scope_online === true &&
|
||||
serviceRestart.scoped_node_collision_after_restart
|
||||
?.both_credentials_reused_without_grants === true &&
|
||||
scopedNodeRevocationAfterRestart.observed
|
||||
?.revoked_credential_denied === true &&
|
||||
scopedNodeRevocationAfterRestart.observed
|
||||
?.first_scope_node_online === true,
|
||||
evidence: {
|
||||
live_collision:
|
||||
tenantIsolation.scoped_node_and_artifact_collision,
|
||||
restart:
|
||||
serviceRestart.scoped_node_collision_after_restart,
|
||||
scoped_revocation: scopedNodeRevocationAfterRestart,
|
||||
},
|
||||
durationMs:
|
||||
tenantIsolation.scoped_node_and_artifact_collision?.duration_ms +
|
||||
scopedNodeRevocationAfterRestart.duration_ms,
|
||||
}),
|
||||
requirement({
|
||||
id: "31_signed_hostile_artifact_path_preserves_service",
|
||||
passed:
|
||||
signedHostileArtifactPath.observed.rejection.denied === true &&
|
||||
signedHostileArtifactPath.observed.valid_metadata_response ===
|
||||
"vfs_metadata_recorded" &&
|
||||
signedHostileArtifactPath.observed.health_response ===
|
||||
"node_heartbeat" &&
|
||||
signedHostileArtifactPath.observed.process_cleanup ===
|
||||
"not_active",
|
||||
evidence: signedHostileArtifactPath,
|
||||
durationMs: signedHostileArtifactPath.duration_ms,
|
||||
}),
|
||||
];
|
||||
const fullReleasePassed =
|
||||
strictFullRelease &&
|
||||
|
|
@ -6371,6 +7062,8 @@ async function main() {
|
|||
dap_process_aborted: true,
|
||||
process_cancellation_lifecycle: processCancellationLifecycle,
|
||||
tenant_isolation: tenantIsolation,
|
||||
scoped_node_revocation_after_restart:
|
||||
scopedNodeRevocationAfterRestart,
|
||||
credential_security: {
|
||||
user: userCredentialSecurity,
|
||||
node: {
|
||||
|
|
@ -6409,6 +7102,7 @@ async function main() {
|
|||
hello_build: helloBuild,
|
||||
recovery_build: recoveryBuild,
|
||||
malformed_identifiers: malformedIdentifiers,
|
||||
signed_hostile_artifact_path: signedHostileArtifactPath,
|
||||
named_scenarios: namedScenarios,
|
||||
scenario_skips: [],
|
||||
strict_requirement_ledger: strictRequirementLedger,
|
||||
|
|
|
|||
|
|
@ -42,6 +42,8 @@ const signingInstrumentNode = nodeIdentity(
|
|||
);
|
||||
assert.strictEqual(
|
||||
signedNodeHeartbeat(
|
||||
"tenant",
|
||||
"project",
|
||||
"node-hostile-input-contract",
|
||||
signingInstrumentNode,
|
||||
{ nonce: "" }
|
||||
|
|
@ -127,8 +129,7 @@ for (const [name, source, patterns] of [
|
|||
/const guessed = await send/,
|
||||
/const crossActorOpen = await send/,
|
||||
/token is invalid/,
|
||||
/tenant mismatch/,
|
||||
/project mismatch/,
|
||||
/artifact does not exist/,
|
||||
],
|
||||
],
|
||||
[
|
||||
|
|
|
|||
|
|
@ -94,12 +94,12 @@ function nodeSignatureMessage(
|
|||
);
|
||||
}
|
||||
|
||||
function signedNodeHeartbeat(node, identity) {
|
||||
function signedNodeHeartbeat(tenant, project, node, identity) {
|
||||
const nonce = `node-attach-heartbeat-${process.pid}-${Date.now()}`;
|
||||
const issuedAt = Math.floor(Date.now() / 1000);
|
||||
const payloadDigest = `sha256:${crypto
|
||||
.createHash("sha256")
|
||||
.update(JSON.stringify({ node, type: "node_heartbeat" }))
|
||||
.update(JSON.stringify({ node, project, tenant, type: "node_heartbeat" }))
|
||||
.digest("hex")}`;
|
||||
const signature = crypto.sign(
|
||||
null,
|
||||
|
|
@ -283,8 +283,15 @@ function runAttach(addr, grant) {
|
|||
|
||||
const heartbeat = await send(addr, {
|
||||
type: "node_heartbeat",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
node: "node-attach",
|
||||
node_signature: signedNodeHeartbeat("node-attach", nodeIdentity("node-attach")),
|
||||
node_signature: signedNodeHeartbeat(
|
||||
"tenant",
|
||||
"project",
|
||||
"node-attach",
|
||||
nodeIdentity("node-attach")
|
||||
),
|
||||
});
|
||||
assert.strictEqual(heartbeat.type, "node_heartbeat");
|
||||
assert.strictEqual(heartbeat.node, "node-attach");
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ for (const [name, pattern] of [
|
|||
|
||||
for (const [name, pattern] of [
|
||||
["coordinator rejects stale process ownership", /fn node_reconnect_rejects_stale_process_epoch_after_restart\(\)/],
|
||||
["reconnect preserves enrolled node identity", /reconnect_node\(&NodeId::from\("node"\), None\)/],
|
||||
["reconnect preserves scoped enrolled node identity", /reconnect_node\([\s\S]*&TenantId::from\("tenant"\),[\s\S]*&ProjectId::from\("project"\),[\s\S]*&NodeId::from\("node"\),[\s\S]*None/],
|
||||
["stale process epoch is rejected", /CoordinatorError::StaleProcessEpoch/],
|
||||
]) {
|
||||
expect(coordinatorCore, name, pattern);
|
||||
|
|
|
|||
|
|
@ -151,8 +151,8 @@ function signedNodeProof(node, identity, requestKind, request, options = {}) {
|
|||
};
|
||||
}
|
||||
|
||||
function signedNodeHeartbeat(node, identity, options = {}) {
|
||||
const request = { type: "node_heartbeat", node };
|
||||
function signedNodeHeartbeat(tenant, project, node, identity, options = {}) {
|
||||
const request = { type: "node_heartbeat", tenant, project, node };
|
||||
return signedNodeProof(node, identity, "node_heartbeat", request, options);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,217 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { spawnSync } = require("node:child_process");
|
||||
|
||||
const repo = path.resolve(__dirname, "..");
|
||||
const releaseRoot = path.resolve(
|
||||
process.env.CLUSTERFLUX_PUBLIC_RELEASE_DIR ||
|
||||
path.join(repo, "target/public-release-final")
|
||||
);
|
||||
const manifestPath = path.join(releaseRoot, "public-release-manifest.json");
|
||||
const resultPath = path.resolve(
|
||||
process.env.CLUSTERFLUX_FINAL_RESULT ||
|
||||
path.join(repo, "target/acceptance/cli-happy-path-live.json")
|
||||
);
|
||||
const transcriptPath = path.resolve(
|
||||
process.env.CLUSTERFLUX_VALIDATION_TRANSCRIPT ||
|
||||
path.join(repo, "target/acceptance/clusterflux-manual-launch-transcript.log")
|
||||
);
|
||||
|
||||
function sha256(file) {
|
||||
const hash = crypto.createHash("sha256");
|
||||
hash.update(fs.readFileSync(file));
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
function readJson(file) {
|
||||
return JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
}
|
||||
|
||||
function write(file, contents) {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, contents);
|
||||
}
|
||||
|
||||
function copyVerifiedAsset(asset, destinationRoot) {
|
||||
const source = path.resolve(releaseRoot, asset.file);
|
||||
assert(fs.existsSync(source), `missing finalized asset ${source}`);
|
||||
assert.strictEqual(
|
||||
sha256(source),
|
||||
asset.sha256,
|
||||
`finalized asset digest changed: ${asset.name}`
|
||||
);
|
||||
const destination = path.join(destinationRoot, "assets", asset.name);
|
||||
fs.copyFileSync(source, destination);
|
||||
assert.strictEqual(sha256(destination), asset.sha256);
|
||||
return destination;
|
||||
}
|
||||
|
||||
function extractBinaries(archive, destinationRoot, expectedDigests) {
|
||||
const staging = path.join(destinationRoot, ".binary-extract");
|
||||
fs.mkdirSync(staging, { recursive: true });
|
||||
const extracted = spawnSync("tar", ["-xzf", archive, "-C", staging], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
if (extracted.status !== 0) {
|
||||
throw new Error(`failed to extract ${archive}: ${extracted.stderr}`);
|
||||
}
|
||||
const binRoot = path.join(staging, "bin");
|
||||
assert(fs.existsSync(binRoot), `binary archive omitted bin/: ${archive}`);
|
||||
const copied = [];
|
||||
for (const name of fs.readdirSync(binRoot).sort()) {
|
||||
const source = path.join(binRoot, name);
|
||||
if (!fs.statSync(source).isFile()) continue;
|
||||
const destination = path.join(destinationRoot, "binaries", name);
|
||||
fs.copyFileSync(source, destination);
|
||||
fs.chmodSync(destination, 0o755);
|
||||
assert.strictEqual(
|
||||
`sha256:${sha256(destination)}`,
|
||||
expectedDigests[name],
|
||||
`tested binary digest changed: ${name}`
|
||||
);
|
||||
copied.push(destination);
|
||||
}
|
||||
fs.rmSync(staging, { recursive: true, force: true });
|
||||
assert(copied.length > 0, `binary archive contained no binaries: ${archive}`);
|
||||
return copied;
|
||||
}
|
||||
|
||||
function main() {
|
||||
assert(fs.existsSync(manifestPath), `missing final manifest ${manifestPath}`);
|
||||
assert(fs.existsSync(resultPath), `missing final result ${resultPath}`);
|
||||
assert(fs.existsSync(transcriptPath), `missing validation transcript ${transcriptPath}`);
|
||||
const manifest = readJson(manifestPath);
|
||||
const result = readJson(resultPath);
|
||||
assert.strictEqual(result.acceptance_result, "passed");
|
||||
assert.strictEqual(result.source_commit, manifest.source_commit);
|
||||
assert.strictEqual(result.source_tree_digest, manifest.source_tree_digest);
|
||||
assert.deepStrictEqual(result.binary_digests, manifest.binary_digests);
|
||||
assert.strictEqual(result.scenario_skips.length, 0);
|
||||
assert(result.strict_requirement_ledger.length >= 29);
|
||||
assert(
|
||||
result.strict_requirement_ledger.every(
|
||||
(requirement) =>
|
||||
requirement.passed === true &&
|
||||
Number.isFinite(requirement.duration_ms) &&
|
||||
requirement.duration_ms > 0 &&
|
||||
requirement.evidence
|
||||
),
|
||||
"final validation contains a failed launch requirement"
|
||||
);
|
||||
assert.strictEqual(
|
||||
result.named_scenarios.length,
|
||||
result.strict_requirement_ledger.length
|
||||
);
|
||||
|
||||
const destinationRoot = path.resolve(
|
||||
process.env.CLUSTERFLUX_GITHUB_RELEASE_DIR ||
|
||||
path.join(repo, "target/github-release", manifest.release_name)
|
||||
);
|
||||
fs.rmSync(destinationRoot, { recursive: true, force: true });
|
||||
fs.mkdirSync(path.join(destinationRoot, "assets"), { recursive: true });
|
||||
fs.mkdirSync(path.join(destinationRoot, "binaries"), { recursive: true });
|
||||
|
||||
const copiedAssets = manifest.assets.map((asset) =>
|
||||
copyVerifiedAsset(asset, destinationRoot)
|
||||
);
|
||||
const publicationGuidance = [
|
||||
JSON.stringify(manifest.notes || []),
|
||||
...copiedAssets
|
||||
.filter((file) => file.endsWith(".md"))
|
||||
.map((file) => fs.readFileSync(file, "utf8")),
|
||||
].join("\n");
|
||||
assert.doesNotMatch(
|
||||
publicationGuidance,
|
||||
/(?:download|upload|release downloads?)[^\n]*Forgejo/i,
|
||||
"manual GitHub package contains Forgejo release-publication instructions"
|
||||
);
|
||||
const binaryArchive = copiedAssets.find((file) =>
|
||||
path.basename(file).startsWith("clusterflux-public-binaries-")
|
||||
);
|
||||
assert(binaryArchive, "final manifest omitted the public binary archive");
|
||||
const binaries = extractBinaries(
|
||||
binaryArchive,
|
||||
destinationRoot,
|
||||
manifest.binary_digests
|
||||
);
|
||||
const vsix = copiedAssets.find((file) => file.endsWith(".vsix"));
|
||||
assert(vsix, "final manifest omitted the tested VSIX");
|
||||
assert.strictEqual(
|
||||
sha256(vsix),
|
||||
manifest.release_candidate.extension_sha256,
|
||||
"tested VSIX digest does not match final candidate binding"
|
||||
);
|
||||
|
||||
write(path.join(destinationRoot, "SOURCE_REVISION"), `${manifest.source_commit}\n`);
|
||||
write(
|
||||
path.join(destinationRoot, "SOURCE_TREE_DIGEST"),
|
||||
`${manifest.source_tree_digest}\n`
|
||||
);
|
||||
write(path.join(destinationRoot, "VSIX_SHA256"), `${sha256(vsix)} ${path.basename(vsix)}\n`);
|
||||
fs.copyFileSync(manifestPath, path.join(destinationRoot, "public-release-manifest.json"));
|
||||
fs.copyFileSync(transcriptPath, path.join(destinationRoot, "VALIDATION_TRANSCRIPT.log"));
|
||||
write(
|
||||
path.join(destinationRoot, "RELEASE_NOTES.md"),
|
||||
`# Clusterflux ${manifest.release_name}\n\n` +
|
||||
"First manual GitHub launch release of Clusterflux. The release includes the filtered public source, real Linux CLI/node/coordinator binaries, and the exact VSIX used by the final production-shaped validation batch.\n"
|
||||
);
|
||||
write(
|
||||
path.join(destinationRoot, "KNOWN_LIMITATIONS.md"),
|
||||
"# Known limitations\n\n" +
|
||||
"- The launch batch validates one enrolled Linux node with rootless Podman.\n" +
|
||||
"- Full Windows sandbox validation is deferred.\n" +
|
||||
"- Providers, billing, teams, managed compute, and operator-panel UI are not part of this release.\n"
|
||||
);
|
||||
const testedUploadAssets = [
|
||||
...copiedAssets,
|
||||
...binaries,
|
||||
]
|
||||
.sort()
|
||||
.map((file) => ({
|
||||
file: path.relative(destinationRoot, file),
|
||||
sha256: `sha256:${sha256(file)}`,
|
||||
}));
|
||||
write(
|
||||
path.join(destinationRoot, "final-result.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
...result,
|
||||
manual_release: {
|
||||
source_revision: manifest.source_commit,
|
||||
source_tree_digest: manifest.source_tree_digest,
|
||||
tested_upload_assets: testedUploadAssets,
|
||||
publication: "manual GitHub upload from this directory",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`
|
||||
);
|
||||
|
||||
const checksummed = [
|
||||
...copiedAssets,
|
||||
...binaries,
|
||||
path.join(destinationRoot, "SOURCE_REVISION"),
|
||||
path.join(destinationRoot, "SOURCE_TREE_DIGEST"),
|
||||
path.join(destinationRoot, "VSIX_SHA256"),
|
||||
path.join(destinationRoot, "public-release-manifest.json"),
|
||||
path.join(destinationRoot, "final-result.json"),
|
||||
path.join(destinationRoot, "VALIDATION_TRANSCRIPT.log"),
|
||||
path.join(destinationRoot, "RELEASE_NOTES.md"),
|
||||
path.join(destinationRoot, "KNOWN_LIMITATIONS.md"),
|
||||
].sort();
|
||||
write(
|
||||
path.join(destinationRoot, "SHA256SUMS"),
|
||||
`${checksummed
|
||||
.map((file) => `${sha256(file)} ${path.relative(destinationRoot, file)}`)
|
||||
.join("\n")}\n`
|
||||
);
|
||||
console.log(destinationRoot);
|
||||
}
|
||||
|
||||
main();
|
||||
|
|
@ -169,7 +169,7 @@ function main() {
|
|||
"test",
|
||||
"-p",
|
||||
"clusterflux-coordinator",
|
||||
"completed_main_waits_for_final_child_then_preserves_history_and_releases_the_slot",
|
||||
"completed_main_",
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -243,7 +243,10 @@ async function reportNode(addr, node, identity, locality) {
|
|||
online: true
|
||||
}));
|
||||
assert.strictEqual(crossTenantReport.type, "error");
|
||||
assert.match(crossTenantReport.message, /tenant\/project scope/);
|
||||
assert.match(
|
||||
crossTenantReport.message,
|
||||
/tenant\/project scope|node identity is not enrolled/
|
||||
);
|
||||
|
||||
const placement = await send(addr, {
|
||||
type: "schedule_task",
|
||||
|
|
|
|||
|
|
@ -190,8 +190,8 @@ function nodeSignatureMessage(
|
|||
);
|
||||
}
|
||||
|
||||
function signedNodeHeartbeat(node, identity) {
|
||||
const request = { type: "node_heartbeat", node };
|
||||
function signedNodeHeartbeat(tenant, project, node, identity) {
|
||||
const request = { type: "node_heartbeat", tenant, project, node };
|
||||
const nonce = `self-hosted-heartbeat-${process.pid}-${Date.now()}`;
|
||||
const issuedAt = Math.floor(Date.now() / 1000);
|
||||
const signature = crypto.sign(
|
||||
|
|
@ -313,8 +313,10 @@ async function attachTrustedNode(addr, node, capabilities = linuxNodeCapabilitie
|
|||
|
||||
const heartbeat = await send(addr, {
|
||||
type: "node_heartbeat",
|
||||
tenant: "team",
|
||||
project: "self-hosted",
|
||||
node,
|
||||
node_signature: signedNodeHeartbeat(node, identity)
|
||||
node_signature: signedNodeHeartbeat("team", "self-hosted", node, identity)
|
||||
});
|
||||
assert.strictEqual(heartbeat.type, "node_heartbeat");
|
||||
|
||||
|
|
@ -569,6 +571,8 @@ async function attachTrustedNode(addr, node, capabilities = linuxNodeCapabilitie
|
|||
|
||||
const reconnected = await send(addr, signedNodeRequest("team-linux-a", teamLinuxA, "reconnect_node", {
|
||||
type: "reconnect_node",
|
||||
tenant: "team",
|
||||
project: "self-hosted",
|
||||
node: "team-linux-a",
|
||||
process: "vp-team-build",
|
||||
epoch: started.epoch
|
||||
|
|
|
|||
|
|
@ -170,7 +170,10 @@ function sourceCapableNode(sourceProviders = ["git"]) {
|
|||
source_snapshot: "sha256:source-prepared"
|
||||
}));
|
||||
assert.strictEqual(crossTenantCompletion.type, "error");
|
||||
assert.match(crossTenantCompletion.message, /tenant\/project scope/i);
|
||||
assert.match(
|
||||
crossTenantCompletion.message,
|
||||
/tenant\/project scope|node identity is not enrolled/i
|
||||
);
|
||||
|
||||
const completed = await send(addr, signedNodeRequest("source-ready", nodeIdentities.get("source-ready"), "complete_source_preparation", {
|
||||
type: "complete_source_preparation",
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ const artifactDownloadSmoke = read("scripts/artifact-download-smoke.js");
|
|||
const operatorPanelSmoke = read("scripts/operator-panel-smoke.js");
|
||||
const schedulerSmoke = read("scripts/scheduler-placement-smoke.js");
|
||||
const sourcePreparationSmoke = read("scripts/source-preparation-smoke.js");
|
||||
const liveSmoke = read("scripts/cli-happy-path-live-smoke.js");
|
||||
|
||||
for (const [name, pattern] of [
|
||||
["auth contexts carry tenant and project", /pub struct AuthContext[\s\S]*pub tenant: TenantId[\s\S]*pub project: ProjectId/],
|
||||
|
|
@ -79,10 +80,13 @@ for (const [name, pattern] of [
|
|||
["project creation rejects foreign tenant reuse", /project id is outside the signed-in tenant scope/],
|
||||
["project selection rejects foreign tenant", /project is outside the signed-in tenant scope/],
|
||||
["project listing uses tenant-scoped context", /CoordinatorRequest::ListProjects[\s\S]*list_projects\(&context\)/],
|
||||
["node capability reports check enrollment tenant scope", /node capability report is outside the enrolled tenant\/project scope/],
|
||||
[
|
||||
"node capability reports resolve the full enrolled scope",
|
||||
/NodeScopeKey::from_refs\(&tenant, &project, &node\)[\s\S]*node_identity\(&tenant, &project, &node\)/,
|
||||
],
|
||||
["operator panel stop state is tenant/project/process keyed", /type PanelStopKey = \(TenantId, ProjectId, ProcessId\)/],
|
||||
["download service tests tenant mismatch", /cross_tenant\.to_string\(\)\.contains\("tenant mismatch"\)/],
|
||||
["download service tests project mismatch", /cross_project\.to_string\(\)\.contains\("project mismatch"\)/],
|
||||
["download service hides cross-tenant artifact existence", /cross_tenant\.to_string\(\)\.contains\("does not exist"\)/],
|
||||
["download service hides cross-project artifact existence", /cross_project\.to_string\(\)\.contains\("does not exist"\)/],
|
||||
["node capability test rejects cross-scope report", /fn service_rejects_node_capability_report_outside_enrollment_scope\(\)/],
|
||||
["source preparation test rejects cross-scope completion", /fn service_rejects_source_preparation_completion_outside_node_scope\(\)/],
|
||||
]) {
|
||||
|
|
@ -98,8 +102,7 @@ for (const [name, sourceText, patterns] of [
|
|||
/const crossProject = await send/,
|
||||
/const crossTenantOpen = await send/,
|
||||
/const crossProjectOpen = await send/,
|
||||
/tenant mismatch/,
|
||||
/project mismatch/,
|
||||
/artifact does not exist/,
|
||||
/token is invalid/,
|
||||
],
|
||||
],
|
||||
|
|
@ -129,6 +132,19 @@ for (const [name, sourceText, patterns] of [
|
|||
}
|
||||
}
|
||||
|
||||
for (const [name, pattern] of [
|
||||
[
|
||||
"live same-ID collision refreshes first-tenant metadata before the second build",
|
||||
/const firstCollisionHelloBuild = await runHostedHelloBuild[\s\S]*secondHelloBuild = await runHostedHelloBuild/,
|
||||
],
|
||||
[
|
||||
"live same-ID collision re-reads the fresh first-tenant process",
|
||||
/"--process",[\s\S]*firstCollisionHelloBuild\.process[\s\S]*candidate\.artifact === firstCollisionHelloBuild\.artifact/,
|
||||
],
|
||||
]) {
|
||||
expect(liveSmoke, name, pattern);
|
||||
}
|
||||
|
||||
const hostedLibRoot = maybeRead(["private", "hosted-policy", "src", "lib.rs"]);
|
||||
const hostedServiceSource = maybeRead([
|
||||
"private",
|
||||
|
|
|
|||
|
|
@ -198,6 +198,8 @@ function assertWindowsBackendBoundary() {
|
|||
|
||||
const reconnected = await send(addr, signedNodeRequest(windowsNode, windowsIdentity, "reconnect_node", {
|
||||
type: "reconnect_node",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
node: windowsNode,
|
||||
process: "vp-windows",
|
||||
epoch: started.epoch
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue