diff --git a/README.md b/README.md index 0ca86c3..c068281 100644 --- a/README.md +++ b/README.md @@ -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` diff --git a/package.json b/package.json index c211d00..f948c49 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/config.ts b/src/config.ts index cbb345c..f6ca28d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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 { diff --git a/src/runner.ts b/src/runner.ts index a9f2007..8708d0d 100644 --- a/src/runner.ts +++ b/src/runner.ts @@ -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 { @@ -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 { + await this.expectLegionSuccess( + "providers-add-manual", + ["provider", "add", "manual", "--id", accountId], + DEFAULT_LEGION_TIMEOUT_MS + ) + } + async addNode(input: LegionNodeCreateInput, timeoutMs: number): Promise { 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) } diff --git a/src/scenarios.ts b/src/scenarios.ts index d4880c4..899d747 100644 --- a/src/scenarios.ts +++ b/src/scenarios.ts @@ -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, diff --git a/src/scenarios/manual-node-init.ts b/src/scenarios/manual-node-init.ts new file mode 100644 index 0000000..29e7c72 --- /dev/null +++ b/src/scenarios/manual-node-init.ts @@ -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 { + 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 +} diff --git a/tests/config.test.ts b/tests/config.test.ts index 4ea9e4d..67ea551 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -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), false) assert.equal("unlockPassword" in (summary.legion as Record), false) }) diff --git a/tests/runner.test.ts b/tests/runner.test.ts index ae23b42..463372e 100644 --- a/tests/runner.test.ts +++ b/tests/runner.test.ts @@ -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(