You've already forked tribes-supertest
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:
@@ -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`
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user