You've already forked tribes-supertest
4daeca9051
Legion now derives managed node bootstrap mode from state. Keep scenario intent for upstream selection and assertions, but remove the retired CLI option and derive captured bootstrap mode from upstream metadata.
416 lines
14 KiB
TypeScript
416 lines
14 KiB
TypeScript
import assert from "node:assert/strict"
|
|
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
|
|
import { tmpdir } from "node:os"
|
|
import { join } from "node:path"
|
|
import test from "node:test"
|
|
|
|
import { resolveRuntimeConfig } from "../src/config.js"
|
|
import type { LegionTrackedServer } from "../src/legion-state.js"
|
|
import {
|
|
HETZNER_CAPACITY_MAX_ATTEMPTS,
|
|
HETZNER_CAPACITY_RETRY_DELAY_MS,
|
|
RunInterruptedError,
|
|
ScenarioExecution,
|
|
buildCompareSystemGenerationsScript,
|
|
buildLocalBootKeyStateScript,
|
|
buildNodeCreateArgs,
|
|
buildSshAccessCommand,
|
|
buildTribesLogRoundTripRpcExpression,
|
|
buildVictoriaMetricsLatestSnapshotScript,
|
|
assertDegradedNbdeShape,
|
|
extractGuixBuildLogPaths,
|
|
extractGuixBuildLogPathsFromText,
|
|
extractReportedNodePubkey,
|
|
isRunInterruptedError
|
|
} from "../src/runner.js"
|
|
|
|
test("scenario execution starts Legion daemon before config init and stops it", async () => {
|
|
const tempDir = await mkdtemp(join(tmpdir(), "tribes-supertest-runner-"))
|
|
|
|
try {
|
|
const invocationLogPath = join(tempDir, "legion-invocations.jsonl")
|
|
const fakeCliPath = join(tempDir, "fake-legion-cli.mjs")
|
|
await writeFile(fakeCliPath, buildFakeLegionCli(invocationLogPath))
|
|
|
|
const config = resolveRuntimeConfig(
|
|
"daemon-lifecycle",
|
|
{
|
|
LEGION_UNLOCK_PASSWORD: "unlock-secret",
|
|
SUPERTEST_ARTIFACT_ROOT: join(tempDir, "artifacts"),
|
|
SUPERTEST_CERT_MODE: "self-signed",
|
|
SUPERTEST_LEGION_CLI_ENTRY: fakeCliPath,
|
|
SUPERTEST_LEGION_REPO: process.cwd()
|
|
},
|
|
process.cwd(),
|
|
new Date("2026-04-09T12:34:56.000Z")
|
|
)
|
|
|
|
await new ScenarioExecution(config, {
|
|
name: "daemon-lifecycle",
|
|
description: "daemon lifecycle test",
|
|
estimatedDuration: "1m",
|
|
phases: [],
|
|
requiredProviders: [],
|
|
execute: async () => {}
|
|
}).run()
|
|
|
|
const invocations = (await readFile(invocationLogPath, "utf8"))
|
|
.trim()
|
|
.split("\n")
|
|
.map((line) => JSON.parse(line) as { args: string[]; daemonToken: string | null })
|
|
|
|
const daemonStartIndex = invocations.findIndex(
|
|
(entry) => entry.args.join(" ") === "daemon start"
|
|
)
|
|
const daemonStatusIndex = invocations.findIndex(
|
|
(entry) => entry.args.join(" ") === "daemon status"
|
|
)
|
|
const configInitIndex = invocations.findIndex((entry) =>
|
|
entry.args.join(" ").startsWith("config init ")
|
|
)
|
|
const daemonStopIndex = invocations.findIndex((entry) => entry.args.join(" ") === "daemon stop")
|
|
|
|
assert.notEqual(daemonStartIndex, -1)
|
|
assert.notEqual(daemonStatusIndex, -1)
|
|
assert.notEqual(configInitIndex, -1)
|
|
assert.notEqual(daemonStopIndex, -1)
|
|
assert.ok(daemonStartIndex < daemonStatusIndex)
|
|
assert.ok(daemonStatusIndex < configInitIndex)
|
|
assert.ok(configInitIndex < daemonStopIndex)
|
|
assert.equal(invocations[daemonStartIndex]!.daemonToken, null)
|
|
assert.equal(invocations[daemonStatusIndex]!.daemonToken, "fake-daemon-token")
|
|
assert.equal(invocations[configInitIndex]!.daemonToken, "fake-daemon-token")
|
|
assert.equal(invocations[daemonStopIndex]!.daemonToken, "fake-daemon-token")
|
|
} finally {
|
|
await rm(tempDir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
test("scenario execution keeps Legion daemon running when keep nodes is enabled", async () => {
|
|
const tempDir = await mkdtemp(join(tmpdir(), "tribes-supertest-runner-"))
|
|
|
|
try {
|
|
const invocationLogPath = join(tempDir, "legion-invocations.jsonl")
|
|
const fakeCliPath = join(tempDir, "fake-legion-cli.mjs")
|
|
await writeFile(fakeCliPath, buildFakeLegionCli(invocationLogPath))
|
|
|
|
const config = resolveRuntimeConfig(
|
|
"daemon-lifecycle-keep-nodes",
|
|
{
|
|
LEGION_UNLOCK_PASSWORD: "unlock-secret",
|
|
SUPERTEST_ARTIFACT_ROOT: join(tempDir, "artifacts"),
|
|
SUPERTEST_CERT_MODE: "self-signed",
|
|
SUPERTEST_KEEP_NODES: "1",
|
|
SUPERTEST_LEGION_CLI_ENTRY: fakeCliPath,
|
|
SUPERTEST_LEGION_REPO: process.cwd()
|
|
},
|
|
process.cwd(),
|
|
new Date("2026-04-09T12:34:56.000Z")
|
|
)
|
|
|
|
await new ScenarioExecution(config, {
|
|
name: "daemon-lifecycle-keep-nodes",
|
|
description: "daemon lifecycle keep nodes test",
|
|
estimatedDuration: "1m",
|
|
phases: [],
|
|
requiredProviders: [],
|
|
execute: async () => {}
|
|
}).run()
|
|
|
|
const invocations = (await readFile(invocationLogPath, "utf8"))
|
|
.trim()
|
|
.split("\n")
|
|
.map((line) => JSON.parse(line) as { args: string[]; daemonToken: string | null })
|
|
|
|
assert.equal(
|
|
invocations.some((entry) => entry.args.join(" ") === "daemon stop"),
|
|
false
|
|
)
|
|
assert.equal(invocations[0]!.args.join(" "), "daemon start")
|
|
assert.equal(invocations[0]!.daemonToken, null)
|
|
assert.ok(
|
|
invocations.some(
|
|
(entry) =>
|
|
entry.args.join(" ") === "daemon status" && entry.daemonToken === "fake-daemon-token"
|
|
)
|
|
)
|
|
} finally {
|
|
await rm(tempDir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
function buildFakeLegionCli(invocationLogPath: string): string {
|
|
return `
|
|
import { appendFileSync, mkdirSync, writeFileSync } from "node:fs"
|
|
import { join } from "node:path"
|
|
|
|
const args = process.argv.slice(2)
|
|
appendFileSync(${JSON.stringify(invocationLogPath)}, JSON.stringify({
|
|
args,
|
|
daemonToken: process.env.LEGION_DAEMON_TOKEN || null
|
|
}) + "\\n")
|
|
|
|
if (args[0] === "daemon" && args[1] === "start") {
|
|
console.log("export LEGION_DAEMON_TOKEN='fake-daemon-token'")
|
|
process.exit(0)
|
|
}
|
|
|
|
if (args[0] === "daemon" && args[1] === "status") {
|
|
if (process.env.LEGION_DAEMON_TOKEN === "fake-daemon-token") {
|
|
console.log("daemon: running")
|
|
process.exit(0)
|
|
}
|
|
console.error("daemon: missing token")
|
|
process.exit(1)
|
|
}
|
|
|
|
if (args[0] === "daemon" && args[1] === "stop") {
|
|
process.exit(0)
|
|
}
|
|
|
|
if (args[0] === "config" && args[1] === "init") {
|
|
if (process.env.LEGION_DAEMON_TOKEN !== "fake-daemon-token") {
|
|
console.error("config init requires daemon token")
|
|
process.exit(1)
|
|
}
|
|
mkdirSync(process.env.LEGION_STATE_DIR, { recursive: true })
|
|
writeFileSync(join(process.env.LEGION_STATE_DIR, "tribes-state.json"), JSON.stringify({ trackedServers: [] }))
|
|
console.log(JSON.stringify({ ok: true }))
|
|
process.exit(0)
|
|
}
|
|
|
|
if (args[0] === "node" && args[1] === "list") {
|
|
console.log("[]")
|
|
process.exit(0)
|
|
}
|
|
|
|
if (args[0] === "provider" && args[1] === "list") {
|
|
console.log("[]")
|
|
process.exit(0)
|
|
}
|
|
|
|
if (args[0] === "logs") {
|
|
if (args.includes("--json")) {
|
|
console.log(JSON.stringify({ ok: true, entries: [] }))
|
|
} else {
|
|
console.log("No local log entries matched.")
|
|
}
|
|
process.exit(0)
|
|
}
|
|
|
|
console.error("unexpected fake Legion args: " + args.join(" "))
|
|
process.exit(1)
|
|
`
|
|
}
|
|
|
|
test("Hetzner capacity retry policy waits three minutes and retries once", () => {
|
|
assert.equal(HETZNER_CAPACITY_MAX_ATTEMPTS, 2)
|
|
assert.equal(HETZNER_CAPACITY_RETRY_DELAY_MS, 180_000)
|
|
})
|
|
|
|
test("extractGuixBuildLogPathsFromText parses log paths from streamed output", () => {
|
|
const text = `building /gnu/store/foo-tribes.drv...
|
|
View build log at '/var/log/guix/drvs/5s/xgpl8x6bi687kr204c1z35gxdxl77j-tribes.drv.gz'.
|
|
cannot build derivation ...`
|
|
|
|
assert.deepEqual(extractGuixBuildLogPathsFromText(text), [
|
|
"/var/log/guix/drvs/5s/xgpl8x6bi687kr204c1z35gxdxl77j-tribes.drv.gz"
|
|
])
|
|
})
|
|
|
|
test("extractGuixBuildLogPaths returns unique guix build log paths from error text", () => {
|
|
const error = new Error(`Legion command failed: nodes add --materialize
|
|
stdout:
|
|
...
|
|
stderr:
|
|
View build log at '/var/log/guix/drvs/8k/xd0qsk4wv8z4zsc4nd2dn1ggi11a75-tribes.drv.gz'.
|
|
cannot build derivation ...
|
|
View build log at '/var/log/guix/drvs/8k/xd0qsk4wv8z4zsc4nd2dn1ggi11a75-tribes.drv.gz'.
|
|
View build log at '/var/log/guix/drvs/ab/cdef1234-profile.drv.gz'.`)
|
|
|
|
assert.deepEqual(extractGuixBuildLogPaths(error), [
|
|
"/var/log/guix/drvs/8k/xd0qsk4wv8z4zsc4nd2dn1ggi11a75-tribes.drv.gz",
|
|
"/var/log/guix/drvs/ab/cdef1234-profile.drv.gz"
|
|
])
|
|
})
|
|
|
|
test("buildCompareSystemGenerationsScript inspects the newest two generations read-only", () => {
|
|
const script = buildCompareSystemGenerationsScript()
|
|
|
|
assert.doesNotMatch(script, /tribes-compare-system-generations/)
|
|
assert.doesNotMatch(script, /command -v guix|\bguix\s/)
|
|
assert.match(script, /\/var\/guix\/profiles\/system-\*-link/)
|
|
assert.match(script, /tail -n 2/)
|
|
assert.match(script, /readlink -f \/run\/current-system/)
|
|
assert.match(script, /configuration\.scm/)
|
|
assert.match(script, /compare-system-generations-readonly-ok/)
|
|
})
|
|
|
|
test("buildVictoriaMetricsLatestSnapshotScript queries latest samples for all metrics", () => {
|
|
const script = buildVictoriaMetricsLatestSnapshotScript()
|
|
|
|
assert.match(script, /127\.0\.0\.1:8428\/api\/v1\/query/)
|
|
assert.match(script, /query=\{__name__=~"\.\+"\}/)
|
|
assert.match(script, /curl -fsS/)
|
|
})
|
|
|
|
test("buildTribesLogRoundTripRpcExpression logs through the running node group leader", () => {
|
|
const expression = buildTribesLogRoundTripRpcExpression("[source=core] hello")
|
|
|
|
assert.match(expression, /old_group_leader = Process\.group_leader\(\)/)
|
|
assert.match(expression, /Process\.group_leader\(self\(\), Process\.whereis\(:user\)\)/)
|
|
assert.match(expression, /Logger\.info\("\[source=core\] hello"\)/)
|
|
assert.match(expression, /Process\.group_leader\(self\(\), old_group_leader\)/)
|
|
})
|
|
|
|
test("buildLocalBootKeyStateScript follows deployed system facts", () => {
|
|
const script = buildLocalBootKeyStateScript()
|
|
|
|
assert.match(script, /\/etc\/tribes\/system-facts\.json/)
|
|
assert.match(script, /localBootKeyFile/)
|
|
assert.match(script, /\/boot\/nbde\/local-boot\.key/)
|
|
assert.doesNotMatch(script, /\/etc\/legion\/nbde\/local-boot\.key/)
|
|
})
|
|
|
|
test("buildSshAccessCommand renders a reusable SSH command for a tracked node", () => {
|
|
const server: LegionTrackedServer = {
|
|
id: "st-20260413-hx-a",
|
|
sshUsername: "root",
|
|
sshPrivateKey: "unused",
|
|
publicIp: "188.245.174.206"
|
|
}
|
|
|
|
assert.equal(
|
|
buildSshAccessCommand(server, "/tmp/kept-node.ed25519"),
|
|
"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /tmp/kept-node.ed25519 root@188.245.174.206"
|
|
)
|
|
})
|
|
|
|
test("assertDegradedNbdeShape accepts Legion two-node degraded NBDE state", () => {
|
|
assert.doesNotThrow(() =>
|
|
assertDegradedNbdeShape(
|
|
{
|
|
id: "st-20260611-hx-c",
|
|
sshUsername: "root",
|
|
sshPrivateKey: "unused",
|
|
publicIp: "203.0.113.10",
|
|
nbde: {
|
|
mode: "degraded",
|
|
localBootKeyPresent: true,
|
|
peerTangNodeIds: []
|
|
}
|
|
},
|
|
"Join node st-20260611-hx-c"
|
|
)
|
|
)
|
|
})
|
|
|
|
test("assertDegradedNbdeShape rejects clustered peer bindings", () => {
|
|
assert.throws(
|
|
() =>
|
|
assertDegradedNbdeShape(
|
|
{
|
|
id: "st-20260611-hx-c",
|
|
sshUsername: "root",
|
|
sshPrivateKey: "unused",
|
|
publicIp: "203.0.113.10",
|
|
nbde: {
|
|
mode: "degraded",
|
|
localBootKeyPresent: true,
|
|
peerTangNodeIds: ["st-20260611-hx-a"]
|
|
}
|
|
},
|
|
"Join node st-20260611-hx-c"
|
|
),
|
|
/should not have peer Tang bindings/
|
|
)
|
|
})
|
|
|
|
test("buildNodeCreateArgs includes dev Guix channel overrides", () => {
|
|
assert.deepEqual(
|
|
buildNodeCreateArgs({
|
|
id: "st-20260508-node-a",
|
|
provider: "hetzner",
|
|
acmeEmail: "hostmaster@tribe-one.org",
|
|
channelUrl: "https://git.teralink.net/tribes/guix-tribes.git",
|
|
channelBranch: "supertest-dev",
|
|
channelCommit: "1d74e0ccccf5bb1277b101b791db4a5e6af7a1ad",
|
|
channelIntroductionCommit: "7c4f9d3b3477945ca75d22baa237c44895f2e454",
|
|
channelIntroductionSigner: "F29B A6DA 96E5 EC29 FDDE D994 8F4F 75B3 B19D 4784"
|
|
}),
|
|
[
|
|
"node",
|
|
"create",
|
|
"--name",
|
|
"st-20260508-node-a",
|
|
"--provider",
|
|
"hetzner",
|
|
"--acme-email",
|
|
"hostmaster@tribe-one.org",
|
|
"--channel-url",
|
|
"https://git.teralink.net/tribes/guix-tribes.git",
|
|
"--channel-branch",
|
|
"supertest-dev",
|
|
"--channel-commit",
|
|
"1d74e0ccccf5bb1277b101b791db4a5e6af7a1ad",
|
|
"--channel-introduction-commit",
|
|
"7c4f9d3b3477945ca75d22baa237c44895f2e454",
|
|
"--channel-introduction-signer",
|
|
"F29B A6DA 96E5 EC29 FDDE D994 8F4F 75B3 B19D 4784"
|
|
]
|
|
)
|
|
})
|
|
|
|
test("buildNodeCreateArgs includes manual connection options", () => {
|
|
assert.deepEqual(
|
|
buildNodeCreateArgs({
|
|
id: "st-20260609-manual-a",
|
|
provider: "manual",
|
|
acmeEmail: "hostmaster@tribe-one.org",
|
|
instance: "manual:x86_64:ubuntu-compatible",
|
|
manualHostIp: "203.0.113.10",
|
|
manualUsername: "ubuntu",
|
|
manualPasswordEnvName: "SUPERTEST_MANUAL_PASSWORD"
|
|
}),
|
|
[
|
|
"node",
|
|
"create",
|
|
"--name",
|
|
"st-20260609-manual-a",
|
|
"--provider",
|
|
"manual",
|
|
"--acme-email",
|
|
"hostmaster@tribe-one.org",
|
|
"--instance",
|
|
"manual:x86_64:ubuntu-compatible",
|
|
"--public-ip",
|
|
"203.0.113.10",
|
|
"--ssh-user",
|
|
"ubuntu",
|
|
"--password-env",
|
|
"SUPERTEST_MANUAL_PASSWORD"
|
|
]
|
|
)
|
|
})
|
|
|
|
test("extractReportedNodePubkey accepts JSON helper output", () => {
|
|
assert.equal(
|
|
extractReportedNodePubkey(
|
|
'{"node":{"pubkey":"e1ad75fa88b59593360e3fa14727a68787dea4d8e981eec3983c0f7779fe04c3"},"bootstrap":{"status":"ready"},"health":"ok"}'
|
|
),
|
|
"e1ad75fa88b59593360e3fa14727a68787dea4d8e981eec3983c0f7779fe04c3"
|
|
)
|
|
})
|
|
|
|
test("extractReportedNodePubkey rejects non-JSON output", () => {
|
|
assert.equal(
|
|
extractReportedNodePubkey("e1ad75fa88b59593360e3fa14727a68787dea4d8e981eec3983c0f7779fe04c3\n"),
|
|
null
|
|
)
|
|
})
|
|
|
|
test("isRunInterruptedError recognizes interrupted run errors", () => {
|
|
assert.equal(isRunInterruptedError(new RunInterruptedError("SIGINT")), true)
|
|
assert.equal(isRunInterruptedError(new Error("plain failure")), false)
|
|
})
|