feat: add manual node init scenario

Add manual provider configuration and node-create wiring for importing an existing host into supertest. Document the manual-node-init scenario and cover config plus CLI argument generation in tests.
This commit is contained in:
2026-06-11 09:01:30 +02:00
parent 9a7f7ac9ac
commit 4da993d609
8 changed files with 200 additions and 6 deletions
+12
View File
@@ -88,6 +88,15 @@ Run the single-node scenario:
npm run scenario:single-node-init
```
Run the manual single-node scenario against an existing host:
```bash
SUPERTEST_MANUAL_HOST_IP=203.0.113.10 \
SUPERTEST_MANUAL_USERNAME=ubuntu \
SUPERTEST_MANUAL_PASSWORD=secret \
npm run scenario:manual-node-init
```
Run the single-node plugin rollout/rollback scenario:
```bash
@@ -128,6 +137,7 @@ Run one scenario directly with the generic entrypoint:
```bash
npm run scenario -- single-node-init
npm run scenario -- manual-node-init
npm run scenario -- single-node-plugin-rollout-rollback
npm run scenario -- single-node-sender
npm run scenario -- cluster-sender-fanout-reboot
@@ -166,6 +176,8 @@ main checkout contains unrelated local work.
- `single-node-init`
Provisions one Hetzner init node and captures deployed channels, service status, and NBDE state.
- `manual-node-init`
Imports one existing Ubuntu-compatible host from `SUPERTEST_MANUAL_HOST_IP`, `SUPERTEST_MANUAL_USERNAME`, and `SUPERTEST_MANUAL_PASSWORD`, then captures deployed channels, service status, and NBDE state.
- `single-node-plugin-rollout-rollback`
Provisions one Hetzner init node, applies a plugin rollout through the public admin API, and rolls back to the pre-rollout generation.
- `single-node-sender`
+1
View File
@@ -30,6 +30,7 @@
"scenario": "node --import tsx src/index.ts",
"scenario:list": "npm run scenario -- list",
"scenario:single-node-init": "npm run scenario -- single-node-init",
"scenario:manual-node-init": "npm run scenario -- manual-node-init",
"scenario:single-node-plugin-rollout-rollback": "npm run scenario -- single-node-plugin-rollout-rollback",
"scenario:single-node-sender": "npm run scenario -- single-node-sender",
"scenario:cluster-sender-fanout-reboot": "npm run scenario -- cluster-sender-fanout-reboot",
+47 -2
View File
@@ -3,7 +3,7 @@ import { resolve } from "node:path"
import type { ScenarioDefinition } from "./scenarios.js"
export type ProviderKind = "hetzner" | "ovh" | "scaleway"
export type ProviderKind = "hetzner" | "ovh" | "scaleway" | "manual"
export type LegionTestCertificateMode = "acme" | "self-signed"
export interface ProviderRuntimeConfig {
@@ -12,6 +12,12 @@ export interface ProviderRuntimeConfig {
bootMode?: "efi" | "bios"
}
export interface ManualProviderRuntimeConfig extends ProviderRuntimeConfig {
hostIp?: string
username?: string
passwordEnvName: string
}
export interface DevGuixTribesChannelConfig {
url: string
branch: string
@@ -49,6 +55,7 @@ export interface RuntimeConfig {
endpoint: string
}
scaleway: ProviderRuntimeConfig
manual: ManualProviderRuntimeConfig
}
}
@@ -99,6 +106,13 @@ export interface RuntimeConfigSummary {
projectIdEnvName: string
configured: boolean
}
manual: {
accountId: string
hostIp?: string
username?: string
passwordEnvName: string
configured: boolean
}
}
}
@@ -180,7 +194,17 @@ export function resolveRuntimeConfig(
accountId: readEnv(env, "SUPERTEST_SCALEWAY_ACCOUNT_ID") || "supertest-scaleway",
instance: readEnv(env, "SUPERTEST_SCALEWAY_INSTANCE") || "DEV1-L",
bootMode: parseBootMode(readEnv(env, "SUPERTEST_SCALEWAY_BOOT_MODE"))
})
}),
manual: {
...buildProviderConfig({
accountId: readEnv(env, "SUPERTEST_MANUAL_ACCOUNT_ID") || "manual",
instance: readEnv(env, "SUPERTEST_MANUAL_INSTANCE") || "manual:x86_64:ubuntu-compatible",
bootMode: parseBootMode(readEnv(env, "SUPERTEST_MANUAL_BOOT_MODE"))
}),
...withOptional("hostIp", readEnv(env, "SUPERTEST_MANUAL_HOST_IP")),
...withOptional("username", readEnv(env, "SUPERTEST_MANUAL_USERNAME")),
passwordEnvName: "SUPERTEST_MANUAL_PASSWORD"
}
}
}
}
@@ -241,6 +265,16 @@ export function buildConfigSummary(
Boolean(readEnv(env, "SCW_ACCESS_KEY")) &&
Boolean(readEnv(env, "SCW_SECRET_KEY")) &&
Boolean(readEnv(env, "SCW_DEFAULT_PROJECT_ID"))
},
manual: {
accountId: config.providers.manual.accountId,
...withOptional("hostIp", config.providers.manual.hostIp),
...withOptional("username", config.providers.manual.username),
passwordEnvName: config.providers.manual.passwordEnvName,
configured:
Boolean(config.providers.manual.hostIp) &&
Boolean(config.providers.manual.username) &&
Boolean(readEnv(env, config.providers.manual.passwordEnvName))
}
}
}
@@ -272,6 +306,17 @@ export function assertProviderEnvironmentAvailable(kind: ProviderKind): void {
"Scenario requires Scaleway, but SCW_ACCESS_KEY, SCW_SECRET_KEY, or SCW_DEFAULT_PROJECT_ID is missing."
)
}
if (
kind === "manual" &&
(!readEnv(process.env, "SUPERTEST_MANUAL_HOST_IP") ||
!readEnv(process.env, "SUPERTEST_MANUAL_USERNAME") ||
!readEnv(process.env, "SUPERTEST_MANUAL_PASSWORD"))
) {
throw new Error(
"Scenario requires a manual host, but SUPERTEST_MANUAL_HOST_IP, SUPERTEST_MANUAL_USERNAME, or SUPERTEST_MANUAL_PASSWORD is missing."
)
}
}
export function summarizeScenarioDefinitions(definitions: ScenarioDefinition[]): string {
+35 -2
View File
@@ -76,6 +76,7 @@ interface CommandStreamHooks {
export interface LegionNodeCreateInput {
id: string
provider: ProviderKind
accountId: string
bootstrapMode: "init" | "join"
acmeEmail: string
@@ -88,6 +89,9 @@ export interface LegionNodeCreateInput {
channelCommit?: string
channelIntroductionCommit?: string
channelIntroductionSigner?: string
manualHostIp?: string
manualUsername?: string
manualPasswordEnvName?: string
}
export interface PluginApiRequest {
@@ -377,7 +381,12 @@ export class ScenarioExecution {
return
}
await this.legion.addScalewayProvider(this.config.providers.scaleway.accountId)
if (provider === "scaleway") {
await this.legion.addScalewayProvider(this.config.providers.scaleway.accountId)
return
}
await this.legion.addManualProvider(this.config.providers.manual.accountId)
}
async addManagedNode(request: NodeProvisionRequest): Promise<void> {
@@ -432,9 +441,11 @@ export class ScenarioExecution {
private managedNodeInput(request: NodeProvisionRequest): LegionNodeCreateInput {
const providerConfig = this.config.providers[request.provider]
const manualConfig = request.provider === "manual" ? this.config.providers.manual : undefined
return {
id: request.id,
provider: request.provider,
accountId: providerConfig.accountId,
bootstrapMode: request.bootstrapMode,
acmeEmail: this.config.acmeEmail,
@@ -452,7 +463,10 @@ export class ScenarioExecution {
...withOptional(
"channelIntroductionSigner",
this.config.devGuixTribesChannel?.introductionFingerprint
)
),
...withOptional("manualHostIp", manualConfig?.hostIp),
...withOptional("manualUsername", manualConfig?.username),
...withOptional("manualPasswordEnvName", manualConfig?.passwordEnvName)
}
}
@@ -2307,6 +2321,14 @@ class LegionAdapter {
)
}
async addManualProvider(accountId: string): Promise<void> {
await this.expectLegionSuccess(
"providers-add-manual",
["provider", "add", "manual", "--id", accountId],
DEFAULT_LEGION_TIMEOUT_MS
)
}
async addNode(input: LegionNodeCreateInput, timeoutMs: number): Promise<void> {
await this.planNode(input, timeoutMs)
await this.materialize(timeoutMs)
@@ -2821,6 +2843,8 @@ export function buildNodeCreateArgs(input: LegionNodeCreateInput): string[] {
"create",
"--name",
input.id,
"--provider",
input.provider,
"--provider-account",
input.accountId,
"--bootstrap-mode",
@@ -2838,6 +2862,15 @@ export function buildNodeCreateArgs(input: LegionNodeCreateInput): string[] {
if (input.bootMode) {
args.push("--boot-mode", input.bootMode)
}
if (input.manualHostIp) {
args.push("--public-ip", input.manualHostIp)
}
if (input.manualUsername) {
args.push("--ssh-user", input.manualUsername)
}
if (input.manualPasswordEnvName) {
args.push("--password-env", input.manualPasswordEnvName)
}
if (input.upstreamId) {
args.push("--upstream", input.upstreamId)
}
+2
View File
@@ -5,6 +5,7 @@ import { clusterLifecycleScenario } from "./scenarios/cluster-lifecycle.js"
import { clusterPluginIntegratedRolloutScenario } from "./scenarios/cluster-plugin-integrated-rollout.js"
import { clusterPluginRolloutSyncSplitBrainScenario } from "./scenarios/cluster-plugin-rollout-sync-split-brain.js"
import { clusterSenderFanoutRebootScenario } from "./scenarios/cluster-sender-fanout-reboot.js"
import { manualNodeInitScenario } from "./scenarios/manual-node-init.js"
import { singleNodeInitScenario } from "./scenarios/single-node-init.js"
import { singleNodePluginRolloutRollbackScenario } from "./scenarios/single-node-plugin-rollout-rollback.js"
import { singleNodeSenderScenario } from "./scenarios/single-node-sender.js"
@@ -20,6 +21,7 @@ export interface ScenarioDefinition {
export const scenarioDefinitions: ScenarioDefinition[] = [
singleNodeInitScenario,
manualNodeInitScenario,
singleNodePluginRolloutRollbackScenario,
singleNodeSenderScenario,
clusterSenderFanoutRebootScenario,
+46
View File
@@ -0,0 +1,46 @@
import type { ScenarioExecution } from "../runner.js"
import {
assertBootstrapMode,
assertInitialNbdeShape,
assertNodeExecutionSucceeded,
requireTrackedServer
} from "../runner.js"
import type { ScenarioDefinition } from "../scenarios.js"
async function execute(context: ScenarioExecution): Promise<void> {
const primaryId = context.nodeId("manual-a")
await context.addManagedNode({
id: primaryId,
provider: "manual",
bootstrapMode: "init"
})
const snapshot = await context.captureSnapshot("after-primary")
context.assertTrackedNodeIds(snapshot.state, [primaryId])
const primary = requireTrackedServer(snapshot.state, primaryId)
assertNodeExecutionSucceeded(primary)
assertBootstrapMode(primary, "init")
assertInitialNbdeShape(primary)
await context.assertSyncPortExternalAccess(primary)
await context.assertLocalControlReady(primary)
await context.assertPublicHttp3Health(primary)
await context.assertNodeExporterMetricsScraped(primary)
await context.assertImportedSystemLogs(primary)
await context.assertTribesLogRoundTrip(primary)
}
export const manualNodeInitScenario: ScenarioDefinition = {
name: "manual-node-init",
description:
"Import one manually supplied init node and assert deployed channels, service status (including local-control), and NBDE state.",
estimatedDuration: "20m",
phases: [
"Configure and deploy one manual init node from SUPERTEST_MANUAL_HOST_IP, SUPERTEST_MANUAL_USERNAME, and SUPERTEST_MANUAL_PASSWORD.",
"Assert Legion state, init bootstrap mode, NBDE shape, and sync-port reachability.",
"Verify local-control readiness, public HTTP/3 health, node-exporter metrics, imported system logs, and a Tribes log round trip."
],
requiredProviders: ["manual"],
execute
}
+16 -2
View File
@@ -27,6 +27,9 @@ test("resolveRuntimeConfig applies defaults and stable run metadata", () => {
assert.equal(config.tribeDomain, "tribes-supertest-2026-04-09t123456000z.invalid")
assert.equal(config.legion.testCertificateMode, "acme")
assert.equal(config.waitOnFailure, false)
assert.equal(config.providers.manual.accountId, "manual")
assert.equal(config.providers.manual.instance, "manual:x86_64:ubuntu-compatible")
assert.equal(config.providers.manual.passwordEnvName, "SUPERTEST_MANUAL_PASSWORD")
})
test("resolveRuntimeConfig supports explicit kexec image URL", () => {
@@ -86,7 +89,10 @@ test("buildConfigSummary reports provider readiness without leaking secrets", ()
OVH_CONSUMER_KEY: "ovh-consumer",
SCW_ACCESS_KEY: "access",
SCW_SECRET_KEY: "secret",
SCW_DEFAULT_PROJECT_ID: "project"
SCW_DEFAULT_PROJECT_ID: "project",
SUPERTEST_MANUAL_HOST_IP: "203.0.113.10",
SUPERTEST_MANUAL_USERNAME: "ubuntu",
SUPERTEST_MANUAL_PASSWORD: "manual-secret"
},
"/workspace/tribes-supertest",
new Date("2026-04-09T12:34:56.000Z")
@@ -100,7 +106,10 @@ test("buildConfigSummary reports provider readiness without leaking secrets", ()
OVH_CONSUMER_KEY: "ovh-consumer",
SCW_ACCESS_KEY: "access",
SCW_SECRET_KEY: "secret",
SCW_DEFAULT_PROJECT_ID: "project"
SCW_DEFAULT_PROJECT_ID: "project",
SUPERTEST_MANUAL_HOST_IP: "203.0.113.10",
SUPERTEST_MANUAL_USERNAME: "ubuntu",
SUPERTEST_MANUAL_PASSWORD: "manual-secret"
})
assert.equal(summary.legion.unlockPasswordProvided, true)
@@ -109,5 +118,10 @@ test("buildConfigSummary reports provider readiness without leaking secrets", ()
assert.equal(summary.providers.ovh.configured, true)
assert.equal(summary.providers.ovh.endpoint, "ovh-eu")
assert.equal(summary.providers.scaleway.configured, true)
assert.equal(summary.providers.manual.configured, true)
assert.equal(summary.providers.manual.hostIp, "203.0.113.10")
assert.equal(summary.providers.manual.username, "ubuntu")
assert.equal(summary.providers.manual.passwordEnvName, "SUPERTEST_MANUAL_PASSWORD")
assert.equal("password" in (summary.providers.manual as Record<string, unknown>), false)
assert.equal("unlockPassword" in (summary.legion as Record<string, unknown>), false)
})
+41
View File
@@ -289,6 +289,7 @@ test("buildNodeCreateArgs includes dev Guix channel overrides", () => {
assert.deepEqual(
buildNodeCreateArgs({
id: "st-20260508-node-a",
provider: "hetzner",
accountId: "supertest-hetzner",
bootstrapMode: "init",
acmeEmail: "hostmaster@tribe-one.org",
@@ -303,6 +304,8 @@ test("buildNodeCreateArgs includes dev Guix channel overrides", () => {
"create",
"--name",
"st-20260508-node-a",
"--provider",
"hetzner",
"--provider-account",
"supertest-hetzner",
"--bootstrap-mode",
@@ -323,6 +326,44 @@ test("buildNodeCreateArgs includes dev Guix channel overrides", () => {
)
})
test("buildNodeCreateArgs includes manual connection options", () => {
assert.deepEqual(
buildNodeCreateArgs({
id: "st-20260609-manual-a",
provider: "manual",
accountId: "manual",
bootstrapMode: "init",
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",
"--provider-account",
"manual",
"--bootstrap-mode",
"init",
"--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(