Files
wehub-resource-sync 426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

415 lines
15 KiB
TypeScript

#!/usr/bin/env bun
/**
* Onboard an EXISTING host (e.g. a Hetzner robot box) as an elizaOS Cloud
* Docker node — with zero manual SSH/DB steps.
*
* A robot/auctioned host can't be cloud-init'd (it's already running), so this
* script runs the bootstrap-equivalent steps over SSH and then registers the
* node into `docker_nodes` the same way the autoscaler / bootstrap-callback do.
* It is the operator-side counterpart to `buildContainerNodeUserData`.
*
* Every step is idempotent and safe to re-run:
* 1. verify/install Docker + ensure the daemon is running,
* 2. ensure the shared bridge network exists,
* 3. ensure deterministic ghcr access — THE robot fix: clear any stale
* stored credential (an expired ghcr token in /root/.docker/config.json
* overrides anonymous access and bricks the public-image pull with
* `denied`). Reuses `ensureRegistryAccess`.
* 4. clean zombie/stale agent containers (exited/created orphans matching the
* agent naming scheme — never an active sandbox),
* 5. upsert the node into `docker_nodes` (update if it already exists),
* 6. print a clear summary of what changed vs. was already in place.
*
* No secrets are hard-coded: the registry token (if any) comes from the
* control-plane env via `containersEnv`; the DB target from `DATABASE_URL`.
*
* Usage:
* DATABASE_URL=... bun run packages/scripts/cloud/admin/onboard-docker-node.ts \
* --host 1.2.3.4 --key ~/.ssh/id_ed25519_eliza --node-id robot-fsn1-01
*
* Flags (env fallback in parens):
* --host <ip|hostname> SSH target (ONBOARD_NODE_HOST) [required]
* --node-id <id> Logical node id (ONBOARD_NODE_ID) [required]
* --key <path> SSH private key path (ONBOARD_NODE_SSH_KEY) [default ~/.ssh/id_ed25519]
* --ssh-port <n> SSH port (ONBOARD_NODE_SSH_PORT) [default 22]
* --ssh-user <user> SSH user (ONBOARD_NODE_SSH_USER) [default root]
* --capacity <n> Agent capacity (ONBOARD_NODE_CAPACITY) [default 8]
* --dry-run Print the planned steps, touch nothing.
*/
import * as os from "node:os";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
// The cloud-shared modules are imported lazily inside main() (see loadDeps) so
// importing this file for its pure helpers — e.g. from the unit test — does not
// drag in the Drizzle / plugin-sql DB stack.
async function loadDeps() {
const [
{ dockerNodesRepository, stampDockerNodeEnvironmentMetadata },
{ ensureRegistryAccess },
dockerUtils,
{ DockerSSHClient },
] = await Promise.all([
import("@elizaos/cloud-shared/db/repositories/docker-nodes"),
import(
"@elizaos/cloud-shared/lib/services/containers/hetzner-client/registry"
),
import("@elizaos/cloud-shared/lib/services/docker-sandbox-utils"),
import("@elizaos/cloud-shared/lib/services/docker-ssh"),
]);
return {
dockerNodesRepository,
stampDockerNodeEnvironmentMetadata,
ensureRegistryAccess,
buildEnsureNetworkCmd: dockerUtils.buildEnsureNetworkCmd,
shellQuote: dockerUtils.shellQuote,
DockerSSHClient,
};
}
// ---------------------------------------------------------------------------
// Pure helpers (unit-tested in onboard-docker-node.test.ts)
// ---------------------------------------------------------------------------
/** Container-name prefixes the cloud control plane uses for agent workloads. */
export const AGENT_CONTAINER_PREFIXES = ["agent-", "cloud-container-"] as const;
/** Docker states that mean a container is NOT actively serving — safe to reap. */
const REAPABLE_STATES = ["exited", "created", "dead"] as const;
export interface DockerPsRow {
name: string;
state: string;
}
/**
* Parse the output of `docker ps -a --format '{{.Names}}\t{{.State}}'`.
* Tolerant of blank lines and trailing whitespace.
*/
export function parseDockerPs(output: string): DockerPsRow[] {
return output
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0 && !line.startsWith("[stderr]"))
.map((line) => {
const [name, state] = line.split("\t");
return {
name: (name ?? "").trim(),
state: (state ?? "").trim().toLowerCase(),
};
})
.filter((row) => row.name.length > 0);
}
/**
* Conservative zombie filter: an agent-named container in a non-running state.
* Running / restarting / paused containers are NEVER selected, so an active
* sandbox is never touched even if its DB row drifted.
*/
export function selectZombieAgentContainers(rows: DockerPsRow[]): string[] {
return rows
.filter(
(row) =>
AGENT_CONTAINER_PREFIXES.some((prefix) =>
row.name.startsWith(prefix),
) && (REAPABLE_STATES as readonly string[]).includes(row.state),
)
.map((row) => row.name);
}
export interface OnboardArgs {
host: string;
nodeId: string;
keyPath: string;
sshPort: number;
sshUser: string;
capacity: number;
dryRun: boolean;
}
interface ExistingDockerNodePin {
host_key_fingerprint: string | null;
capacity: number;
}
interface OnboardSshConfig {
hostname: string;
port: number;
username: string;
privateKeyPath: string;
hostKeyFingerprint?: string;
onHostKeyDiscovered: (hostname: string, fingerprint: string) => Promise<void>;
}
export function buildOnboardSshConfig(
args: OnboardArgs,
existing: ExistingDockerNodePin | null,
onHostKeyDiscovered: OnboardSshConfig["onHostKeyDiscovered"],
): OnboardSshConfig {
return {
hostname: args.host,
port: args.sshPort,
username: args.sshUser,
privateKeyPath: args.keyPath,
hostKeyFingerprint: existing?.host_key_fingerprint ?? undefined,
onHostKeyDiscovered,
};
}
export function hostKeyFingerprintForOnboardUpsert(
existing: ExistingDockerNodePin | null,
capturedFingerprint: string | undefined,
): string | null {
return existing?.host_key_fingerprint ?? capturedFingerprint ?? null;
}
/**
* Capacity to write on (re-)onboard. Once a node exists, its slot count is
* operator-owned (tuned via the admin PATCH route or a direct DB update to
* match the box's real RAM), so a re-onboard preserves it and never resets it
* to the `--capacity` default (which is sized for the small cpx32-class node
* this script was born on). The flag value is only used to seed a brand-new
* row.
*/
export function capacityForOnboardUpsert(
existing: ExistingDockerNodePin | null,
flagCapacity: number,
): number {
return existing?.capacity ?? flagCapacity;
}
/** Parse argv + env into a validated config. Throws on missing required fields. */
export function parseArgs(argv: string[], env: NodeJS.ProcessEnv): OnboardArgs {
const flags = new Map<string, string>();
let dryRun = false;
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === undefined) continue;
if (arg === "--dry-run") {
dryRun = true;
continue;
}
if (arg.startsWith("--")) {
const key = arg.slice(2);
const value = argv[i + 1];
if (value === undefined || value.startsWith("--")) {
throw new Error(`Flag --${key} requires a value`);
}
flags.set(key, value);
i++;
}
}
const host = flags.get("host") ?? env.ONBOARD_NODE_HOST;
const nodeId = flags.get("node-id") ?? env.ONBOARD_NODE_ID;
if (!host) throw new Error("Missing --host (or ONBOARD_NODE_HOST)");
if (!nodeId) throw new Error("Missing --node-id (or ONBOARD_NODE_ID)");
const keyPath =
flags.get("key") ??
env.ONBOARD_NODE_SSH_KEY ??
path.join(os.homedir(), ".ssh", "id_ed25519");
const sshPort = Number.parseInt(
flags.get("ssh-port") ?? env.ONBOARD_NODE_SSH_PORT ?? "22",
10,
);
const sshUser = flags.get("ssh-user") ?? env.ONBOARD_NODE_SSH_USER ?? "root";
const capacityRaw = flags.get("capacity") ?? env.ONBOARD_NODE_CAPACITY ?? "8";
const capacity = Number.parseInt(capacityRaw, 10);
if (!Number.isInteger(sshPort) || sshPort < 1 || sshPort > 65535) {
throw new Error(
`Invalid ssh-port: ${flags.get("ssh-port") ?? env.ONBOARD_NODE_SSH_PORT}`,
);
}
if (!Number.isInteger(capacity) || capacity < 1 || capacity > 64) {
throw new Error(`Invalid capacity (must be 1..64): ${capacityRaw}`);
}
return { host, nodeId, keyPath, sshPort, sshUser, capacity, dryRun };
}
// ---------------------------------------------------------------------------
// Onboarding flow
// ---------------------------------------------------------------------------
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2), process.env);
// Resolve network/image from config for the preview. `containersEnv` is a
// light import; the heavier DB/SSH stack is only loaded once we commit to
// touching the host, so --dry-run stays side-effect-free.
const { containersEnv } = await import(
"@elizaos/cloud-shared/lib/config/containers-env"
);
const network = containersEnv.dockerNetwork();
const image = containersEnv.defaultAgentImage();
console.log(
`[onboard] target ${args.sshUser}@${args.host}:${args.sshPort} as node "${args.nodeId}"`,
);
console.log(
`[onboard] network=${network} image=${image} capacity=${args.capacity}`,
);
if (args.dryRun) {
console.log("[onboard] --dry-run: no changes will be made.");
return;
}
const {
dockerNodesRepository,
stampDockerNodeEnvironmentMetadata,
ensureRegistryAccess,
buildEnsureNetworkCmd,
shellQuote,
DockerSSHClient,
} = await loadDeps();
const summary: string[] = [];
const existing = await dockerNodesRepository.findByNodeId(args.nodeId);
// Re-onboard must verify against the stored pin before any root SSH command
// runs. Only a never-pinned node takes the TOFU branch and persists the
// captured key during the upsert below.
let capturedFingerprint: string | undefined;
const ssh = new DockerSSHClient(
buildOnboardSshConfig(args, existing, async (hostname, fingerprint) => {
capturedFingerprint = fingerprint;
console.log(
`[onboard] TOFU captured host key for ${hostname}: SHA256:${fingerprint}`,
);
}),
);
try {
// 1. Docker present + running (install via get.docker.com only if missing).
const hasDocker = await ssh
.exec("command -v docker >/dev/null 2>&1 && echo yes || echo no", 30_000)
.then((out) => out.includes("yes"));
if (!hasDocker) {
console.log("[onboard] docker not found — installing via get.docker.com");
await ssh.exec("curl -fsSL https://get.docker.com | sh", 5 * 60 * 1000);
summary.push("installed Docker");
} else {
summary.push("Docker already present");
}
await ssh.exec(
"systemctl enable --now docker >/dev/null 2>&1 || true",
60_000,
);
await ssh.exec("docker info >/dev/null 2>&1", 30_000);
summary.push("Docker daemon running");
// 2. Shared bridge network (idempotent, race-safe).
await ssh.exec(buildEnsureNetworkCmd(network), 30_000);
summary.push(`network "${network}" ensured`);
// 3. THE robot fix: deterministic registry access (clear stale ghcr cred).
await ensureRegistryAccess(ssh, image);
summary.push(
containersEnv.registryToken() || containersEnv.registryTokenFile()
? "ghcr login refreshed (token configured)"
: "ghcr stale creds cleared (anonymous pull)",
);
// 4. Reap zombie agent containers (orphaned, non-running). Conservative.
const psOutput = await ssh.exec(
"docker ps -a --format '{{.Names}}\t{{.State}}'",
30_000,
);
const zombies = selectZombieAgentContainers(parseDockerPs(psOutput));
if (zombies.length > 0) {
await ssh.exec(
`docker rm -f ${zombies.map(shellQuote).join(" ")}`,
60_000,
);
summary.push(
`removed ${zombies.length} zombie container(s): ${zombies.join(", ")}`,
);
} else {
summary.push("no zombie containers");
}
// 5. Pull the agent image now so the first deploy on this node is warm.
console.log(
`[onboard] pre-pulling ${image} (first run can take a few minutes)`,
);
await ssh
.exec(`docker pull ${shellQuote(image)}`, 10 * 60 * 1000)
.then(() => summary.push("agent image pulled"))
.catch((err) => {
console.warn(
`[onboard] image pre-pull failed (node still registers; will retry on deploy): ${err instanceof Error ? err.message : String(err)}`,
);
summary.push("agent image pre-pull FAILED (non-fatal)");
});
// 6. Register / upsert into docker_nodes — same shape as bootstrap-callback.
if (existing) {
await dockerNodesRepository.update(existing.id, {
hostname: args.host,
ssh_port: args.sshPort,
ssh_user: args.sshUser,
// Preserve an operator-tuned capacity across re-onboards; the
// `--capacity` default only seeds a brand-new row (see create branch).
capacity: capacityForOnboardUpsert(existing, args.capacity),
status: "unknown",
// Never overwrite an established pin during re-onboard; a differing
// presented key must fail in DockerSSHClient before this update.
host_key_fingerprint: hostKeyFingerprintForOnboardUpsert(
existing,
capturedFingerprint,
),
metadata: stampDockerNodeEnvironmentMetadata({
...((existing.metadata as Record<string, unknown>) ?? {}),
provider: "operator-onboarded",
lastOnboardedAt: new Date().toISOString(),
}),
});
summary.push(`docker_nodes row updated (${args.nodeId})`);
} else {
await dockerNodesRepository.create({
node_id: args.nodeId,
hostname: args.host,
ssh_port: args.sshPort,
ssh_user: args.sshUser,
capacity: args.capacity,
enabled: true,
status: "unknown",
allocated_count: 0,
// Persist the TOFU-captured pin so later control-plane SSH is verified.
host_key_fingerprint: hostKeyFingerprintForOnboardUpsert(
null,
capturedFingerprint,
),
metadata: stampDockerNodeEnvironmentMetadata({
provider: "operator-onboarded",
onboardedAt: new Date().toISOString(),
}),
});
summary.push(`docker_nodes row created (${args.nodeId})`);
}
} finally {
// error-policy:J6 best-effort SSH teardown; the onboarding result already stands
await ssh.disconnect().catch(() => {});
}
console.log("\n[onboard] done:");
for (const line of summary) console.log(` - ${line}`);
}
function isMainModule(): boolean {
const entry = process.argv[1];
return entry ? path.resolve(entry) === fileURLToPath(import.meta.url) : false;
}
if (isMainModule()) {
main().catch((error) => {
console.error(
"[onboard] failed:",
error instanceof Error ? error.message : String(error),
);
process.exit(1);
});
}