Compare commits
4 Commits
docs/archi
...
feat/regis
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49d747db98 | ||
| 8635da08a6 | |||
|
|
6a5f23c0f5 | ||
| 63cc033e3e |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -23,3 +23,7 @@ node_modules/
|
|||||||
|
|
||||||
# OS specific
|
# OS specific
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# Task files
|
||||||
|
# tasks.json
|
||||||
|
# tasks/
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ _labctl() {
|
|||||||
"app k3s list")
|
"app k3s list")
|
||||||
COMPREPLY=($(compgen -W "--user -h --help" -- "$cur"))
|
COMPREPLY=($(compgen -W "--user -h --help" -- "$cur"))
|
||||||
return ;;
|
return ;;
|
||||||
|
"app k3s kubeconfig")
|
||||||
|
COMPREPLY=($(compgen -W "--user --context --print -h --help" -- "$cur"))
|
||||||
|
return ;;
|
||||||
"init bastion")
|
"init bastion")
|
||||||
COMPREPLY=($(compgen -W "standalone -h --help" -- "$cur"))
|
COMPREPLY=($(compgen -W "standalone -h --help" -- "$cur"))
|
||||||
return ;;
|
return ;;
|
||||||
@@ -67,6 +70,9 @@ _labctl() {
|
|||||||
"provision forget")
|
"provision forget")
|
||||||
COMPREPLY=($(compgen -W "-h --help" -- "$cur"))
|
COMPREPLY=($(compgen -W "-h --help" -- "$cur"))
|
||||||
return ;;
|
return ;;
|
||||||
|
"provision register")
|
||||||
|
COMPREPLY=($(compgen -W "--role --ip -h --help" -- "$cur"))
|
||||||
|
return ;;
|
||||||
"provision logs")
|
"provision logs")
|
||||||
COMPREPLY=($(compgen -W "-f --follow -h --help" -- "$cur"))
|
COMPREPLY=($(compgen -W "-f --follow -h --help" -- "$cur"))
|
||||||
return ;;
|
return ;;
|
||||||
@@ -89,7 +95,7 @@ _labctl() {
|
|||||||
COMPREPLY=($(compgen -W "deploy status -h --help" -- "$cur"))
|
COMPREPLY=($(compgen -W "deploy status -h --help" -- "$cur"))
|
||||||
return ;;
|
return ;;
|
||||||
"app k3s")
|
"app k3s")
|
||||||
COMPREPLY=($(compgen -W "install health list -h --help" -- "$cur"))
|
COMPREPLY=($(compgen -W "install health list kubeconfig -h --help" -- "$cur"))
|
||||||
return ;;
|
return ;;
|
||||||
"version")
|
"version")
|
||||||
COMPREPLY=($(compgen -W "-h --help" -- "$cur"))
|
COMPREPLY=($(compgen -W "-h --help" -- "$cur"))
|
||||||
@@ -98,7 +104,7 @@ _labctl() {
|
|||||||
COMPREPLY=($(compgen -W "bastion -h --help" -- "$cur"))
|
COMPREPLY=($(compgen -W "bastion -h --help" -- "$cur"))
|
||||||
return ;;
|
return ;;
|
||||||
"provision")
|
"provision")
|
||||||
COMPREPLY=($(compgen -W "list install reprovision debug forget logs makeiso -h --help" -- "$cur"))
|
COMPREPLY=($(compgen -W "list install reprovision debug forget register logs makeiso -h --help" -- "$cur"))
|
||||||
return ;;
|
return ;;
|
||||||
"config")
|
"config")
|
||||||
COMPREPLY=($(compgen -W "list get set path -h --help" -- "$cur"))
|
COMPREPLY=($(compgen -W "list get set path -h --help" -- "$cur"))
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ complete -c labctl -n "__labctl_using_cmd provision" -a install -d 'Queue a disc
|
|||||||
complete -c labctl -n "__labctl_using_cmd provision" -a reprovision -d 'Queue install + SSH reboot into PXE (target: hostname, MAC, or IP)'
|
complete -c labctl -n "__labctl_using_cmd provision" -a reprovision -d 'Queue install + SSH reboot into PXE (target: hostname, MAC, or IP)'
|
||||||
complete -c labctl -n "__labctl_using_cmd provision" -a debug -d 'PXE boot into Fedora rescue mode for debugging (target: hostname, MAC, or IP)'
|
complete -c labctl -n "__labctl_using_cmd provision" -a debug -d 'PXE boot into Fedora rescue mode for debugging (target: hostname, MAC, or IP)'
|
||||||
complete -c labctl -n "__labctl_using_cmd provision" -a forget -d 'Remove a machine from bastion state'
|
complete -c labctl -n "__labctl_using_cmd provision" -a forget -d 'Remove a machine from bastion state'
|
||||||
|
complete -c labctl -n "__labctl_using_cmd provision" -a register -d 'Register an already-installed machine (e.g. after state loss)'
|
||||||
complete -c labctl -n "__labctl_using_cmd provision" -a logs -d 'Show provisioning logs for a machine (hostname, MAC, or IP)'
|
complete -c labctl -n "__labctl_using_cmd provision" -a logs -d 'Show provisioning logs for a machine (hostname, MAC, or IP)'
|
||||||
complete -c labctl -n "__labctl_using_cmd provision" -a makeiso -d 'Generate a UEFI-bootable iPXE ISO for network provisioning'
|
complete -c labctl -n "__labctl_using_cmd provision" -a makeiso -d 'Generate a UEFI-bootable iPXE ISO for network provisioning'
|
||||||
|
|
||||||
@@ -140,6 +141,10 @@ complete -c labctl -n "__labctl_in_cmd provision reprovision" -l disk -d 'Target
|
|||||||
# provision debug options
|
# provision debug options
|
||||||
complete -c labctl -n "__labctl_in_cmd provision debug" -l pxe-boot -d 'Boot installed system via PXE (kernel+initrd from network, root from NVMe)'
|
complete -c labctl -n "__labctl_in_cmd provision debug" -l pxe-boot -d 'Boot installed system via PXE (kernel+initrd from network, root from NVMe)'
|
||||||
|
|
||||||
|
# provision register options
|
||||||
|
complete -c labctl -n "__labctl_in_cmd provision register" -l role -d 'Machine role' -xa 'vanilla worker infra labcontroller'
|
||||||
|
complete -c labctl -n "__labctl_in_cmd provision register" -l ip -d 'Machine IP address' -x
|
||||||
|
|
||||||
# provision logs options
|
# provision logs options
|
||||||
complete -c labctl -n "__labctl_in_cmd provision logs" -s f -l follow -d 'Follow log output in real-time'
|
complete -c labctl -n "__labctl_in_cmd provision logs" -s f -l follow -d 'Follow log output in real-time'
|
||||||
|
|
||||||
@@ -179,6 +184,7 @@ complete -c labctl -n "__labctl_in_cmd app labcontroller status" -l user -d 'SSH
|
|||||||
complete -c labctl -n "__labctl_using_cmd app k3s" -a install -d 'Install k3s on a target machine (hostname, IP, or MAC)'
|
complete -c labctl -n "__labctl_using_cmd app k3s" -a install -d 'Install k3s on a target machine (hostname, IP, or MAC)'
|
||||||
complete -c labctl -n "__labctl_using_cmd app k3s" -a health -d 'Check k3s health (all hosts if no target given)'
|
complete -c labctl -n "__labctl_using_cmd app k3s" -a health -d 'Check k3s health (all hosts if no target given)'
|
||||||
complete -c labctl -n "__labctl_using_cmd app k3s" -a list -d 'List installed machines and their k3s status'
|
complete -c labctl -n "__labctl_using_cmd app k3s" -a list -d 'List installed machines and their k3s status'
|
||||||
|
complete -c labctl -n "__labctl_using_cmd app k3s" -a kubeconfig -d 'Fetch kubeconfig from a target and merge into ~/.kube/config'
|
||||||
|
|
||||||
# app k3s install options
|
# app k3s install options
|
||||||
complete -c labctl -n "__labctl_in_cmd app k3s install" -l role -d 'k3s role: infra (server) or worker (agent)' -x
|
complete -c labctl -n "__labctl_in_cmd app k3s install" -l role -d 'k3s role: infra (server) or worker (agent)' -x
|
||||||
@@ -192,3 +198,8 @@ complete -c labctl -n "__labctl_in_cmd app k3s health" -l user -d 'SSH user' -x
|
|||||||
# app k3s list options
|
# app k3s list options
|
||||||
complete -c labctl -n "__labctl_in_cmd app k3s list" -l user -d 'SSH user' -x
|
complete -c labctl -n "__labctl_in_cmd app k3s list" -l user -d 'SSH user' -x
|
||||||
|
|
||||||
|
# app k3s kubeconfig options
|
||||||
|
complete -c labctl -n "__labctl_in_cmd app k3s kubeconfig" -l user -d 'SSH user' -x
|
||||||
|
complete -c labctl -n "__labctl_in_cmd app k3s kubeconfig" -l context -d 'Context name (defaults to hostname)' -x
|
||||||
|
complete -c labctl -n "__labctl_in_cmd app k3s kubeconfig" -l print -d 'Print kubeconfig to stdout instead of merging'
|
||||||
|
|
||||||
|
|||||||
@@ -294,6 +294,21 @@ export async function startBastion(overrides: Partial<BastionConfig> = {}): Prom
|
|||||||
return { status: "ok", data: { mac } };
|
return { status: "ok", data: { mac } };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
labdConn.onCommand("command-register", async (msg) => {
|
||||||
|
if (msg.type !== "command-register") throw new Error("unexpected");
|
||||||
|
const mac = msg.mac.toLowerCase();
|
||||||
|
state.update((s) => {
|
||||||
|
s.installed[mac] = {
|
||||||
|
hostname: msg.hostname,
|
||||||
|
role: msg.role,
|
||||||
|
ip: msg.ip,
|
||||||
|
installed_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
logger.info(`MACHINE REGISTERED: ${mac} -> ${msg.hostname} (${msg.role}) ip=${msg.ip}`);
|
||||||
|
return { status: "ok", data: { mac, hostname: msg.hostname } };
|
||||||
|
});
|
||||||
|
|
||||||
labdConn.onCommand("command-role-update", async (msg) => {
|
labdConn.onCommand("command-role-update", async (msg) => {
|
||||||
if (msg.type !== "command-role-update") throw new Error("unexpected");
|
if (msg.type !== "command-role-update") throw new Error("unexpected");
|
||||||
const mac = msg.mac.toLowerCase();
|
const mac = msg.mac.toLowerCase();
|
||||||
|
|||||||
@@ -315,6 +315,50 @@ export function registerApiRoutes(
|
|||||||
return reply.send({ status: "ok", mac, new: isNew });
|
return reply.send({ status: "ok", mac, new: isNew });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Register an already-installed machine (e.g. re-add after state loss)
|
||||||
|
app.post<{
|
||||||
|
Body: {
|
||||||
|
mac?: string;
|
||||||
|
hostname?: string;
|
||||||
|
role?: string;
|
||||||
|
ip?: string;
|
||||||
|
};
|
||||||
|
}>("/api/register", async (request, reply) => {
|
||||||
|
const { mac: rawMac, hostname, role, ip } = request.body ?? {};
|
||||||
|
const mac = (rawMac ?? "").toLowerCase().replace(/-/g, ":");
|
||||||
|
|
||||||
|
if (mac === "") {
|
||||||
|
return reply.status(400).send({ error: "mac is required" });
|
||||||
|
}
|
||||||
|
if (!hostname) {
|
||||||
|
return reply.status(400).send({ error: "hostname is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const validRole = role ?? "worker";
|
||||||
|
if (!(SUPPORTED_ROLES as readonly string[]).includes(validRole)) {
|
||||||
|
return reply.status(400).send({ error: `invalid role: '${validRole}'. Supported: ${SUPPORTED_ROLES.join(", ")}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
state.update((s) => {
|
||||||
|
s.installed[mac] = {
|
||||||
|
hostname,
|
||||||
|
role: validRole,
|
||||||
|
ip: ip ?? "",
|
||||||
|
installed_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`MACHINE REGISTERED: ${mac} -> hostname=${hostname} role=${validRole} ip=${ip ?? ""}`);
|
||||||
|
|
||||||
|
return reply.send({
|
||||||
|
status: "registered",
|
||||||
|
mac,
|
||||||
|
hostname,
|
||||||
|
role: validRole,
|
||||||
|
ip: ip ?? "",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Update a machine's role (e.g. promote infra -> labcontroller)
|
// Update a machine's role (e.g. promote infra -> labcontroller)
|
||||||
app.post<{
|
app.post<{
|
||||||
Body: {
|
Body: {
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ export class BastionConnection {
|
|||||||
case "command-forget":
|
case "command-forget":
|
||||||
case "command-role-update":
|
case "command-role-update":
|
||||||
case "command-debug":
|
case "command-debug":
|
||||||
|
case "command-register":
|
||||||
void this.handleCommand(msg);
|
void this.handleCommand(msg);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,6 +94,12 @@ export class LabdClient {
|
|||||||
return this.request("POST", "/api/machines/install", { body: opts });
|
return this.request("POST", "/api/machines/install", { body: opts });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async registerMachine(opts: {
|
||||||
|
mac: string; hostname: string; role?: string; ip?: string;
|
||||||
|
}): Promise<{ status: string; data?: unknown; error?: string }> {
|
||||||
|
return this.request("POST", "/api/machines/register", { body: opts });
|
||||||
|
}
|
||||||
|
|
||||||
async debugMachine(mac: string, opts?: { pxeBoot?: boolean }): Promise<{ status: string; data?: { mac: string; hostname: string }; error?: string }> {
|
async debugMachine(mac: string, opts?: { pxeBoot?: boolean }): Promise<{ status: string; data?: { mac: string; hostname: string }; error?: string }> {
|
||||||
return this.request("POST", "/api/machines/debug", { body: { mac, pxeBoot: opts?.pxeBoot } });
|
return this.request("POST", "/api/machines/debug", { body: { mac, pxeBoot: opts?.pxeBoot } });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
// CLI command: labctl app k3s install/health <target>
|
// CLI command: labctl app k3s install/health <target>
|
||||||
// Install or check k3s on a target machine via SSH.
|
// Install or check k3s on a target machine via SSH.
|
||||||
|
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync, writeFileSync, mkdirSync } from "node:fs";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import { execSync } from "node:child_process";
|
||||||
import type { Command } from "commander";
|
import type { Command } from "commander";
|
||||||
import type { BastionState } from "@lab/shared";
|
import type { BastionState } from "@lab/shared";
|
||||||
import { K3sModule, sshExec } from "@lab/modules";
|
import { K3sModule, sshExec } from "@lab/modules";
|
||||||
@@ -400,4 +401,88 @@ export function registerAppCommand(program: Command): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
k3sCmd
|
||||||
|
.command("kubeconfig <target>")
|
||||||
|
.description("Fetch kubeconfig from a target and merge into ~/.kube/config")
|
||||||
|
.option("--user <user>", "SSH user", "root")
|
||||||
|
.option("--context <name>", "Context name (defaults to hostname)")
|
||||||
|
.option("--print", "Print kubeconfig to stdout instead of merging")
|
||||||
|
.action(async (target: string, opts: {
|
||||||
|
user: string;
|
||||||
|
context?: string;
|
||||||
|
print?: boolean;
|
||||||
|
}) => {
|
||||||
|
const state = await fetchState();
|
||||||
|
const resolved = resolveTarget(target, state);
|
||||||
|
|
||||||
|
if (!resolved) {
|
||||||
|
console.error(`Cannot resolve target: ${target}`);
|
||||||
|
console.error("Provide an IP address, hostname, or MAC of an installed machine.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sshKey = findSshKey();
|
||||||
|
|
||||||
|
// Fetch kubeconfig via SSH
|
||||||
|
let raw: string;
|
||||||
|
try {
|
||||||
|
const result = await sshExec(resolved.ip, opts.user, "cat /etc/rancher/k3s/k3s.yaml", {
|
||||||
|
...(sshKey ? { keyPath: sshKey } : {}),
|
||||||
|
timeoutMs: 10_000,
|
||||||
|
});
|
||||||
|
raw = result.stdout;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to fetch kubeconfig: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contextName = opts.context ?? resolved.hostname;
|
||||||
|
|
||||||
|
// Rewrite: replace 127.0.0.1 with actual IP, rename cluster/user/context
|
||||||
|
const rewritten = raw
|
||||||
|
.replace(/server:\s*https:\/\/127\.0\.0\.1:/, `server: https://${resolved.ip}:`)
|
||||||
|
.replace(/name:\s*default/g, `name: ${contextName}`)
|
||||||
|
.replace(/cluster:\s*default/g, `cluster: ${contextName}`)
|
||||||
|
.replace(/user:\s*default/g, `user: ${contextName}`)
|
||||||
|
.replace(/current-context:\s*default/, `current-context: ${contextName}`);
|
||||||
|
|
||||||
|
if (opts.print) {
|
||||||
|
process.stdout.write(rewritten);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge into ~/.kube/config using kubectl
|
||||||
|
const kubeDir = join(homedir(), ".kube");
|
||||||
|
mkdirSync(kubeDir, { recursive: true });
|
||||||
|
const mainConfig = join(kubeDir, "config");
|
||||||
|
const tmpFile = join(kubeDir, `.labctl-${contextName}.tmp`);
|
||||||
|
|
||||||
|
writeFileSync(tmpFile, rewritten, { mode: 0o600 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (existsSync(mainConfig)) {
|
||||||
|
const merged = execSync(
|
||||||
|
`KUBECONFIG="${mainConfig}:${tmpFile}" kubectl config view --flatten`,
|
||||||
|
{ encoding: "utf-8" },
|
||||||
|
);
|
||||||
|
writeFileSync(mainConfig, merged, { mode: 0o600 });
|
||||||
|
} else {
|
||||||
|
writeFileSync(mainConfig, rewritten, { mode: 0o600 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set current context
|
||||||
|
execSync(`kubectl config use-context ${contextName}`, { stdio: "pipe" });
|
||||||
|
|
||||||
|
console.log(`Merged kubeconfig for ${contextName} (${resolved.ip})`);
|
||||||
|
console.log(`Context set to: ${contextName}`);
|
||||||
|
console.log(`\nSwitch contexts: kubectl config use-context <name>`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to merge kubeconfig: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
console.error(`Standalone config saved at: ${tmpFile}`);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
try { const { unlinkSync } = await import("node:fs"); unlinkSync(tmpFile); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ export function registerDebugCommand(parent: Command): void {
|
|||||||
|
|
||||||
const sshArgs = [
|
const sshArgs = [
|
||||||
"-o", "StrictHostKeyChecking=no",
|
"-o", "StrictHostKeyChecking=no",
|
||||||
|
"-o", "UserKnownHostsFile=/dev/null",
|
||||||
"-o", "ConnectTimeout=10",
|
"-o", "ConnectTimeout=10",
|
||||||
...(sshKey !== undefined ? ["-i", sshKey] : []),
|
...(sshKey !== undefined ? ["-i", sshKey] : []),
|
||||||
`${effectiveUser}@${ip}`,
|
`${effectiveUser}@${ip}`,
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ async function followLogs(
|
|||||||
|
|
||||||
let lastStageCount = 0;
|
let lastStageCount = 0;
|
||||||
let lastStatus = "";
|
let lastStatus = "";
|
||||||
|
let sawInstalling = false;
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
try {
|
try {
|
||||||
@@ -118,6 +119,10 @@ async function followLogs(
|
|||||||
lastStatus = status;
|
lastStatus = status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (status === "installing" || status === "queued") {
|
||||||
|
sawInstalling = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Print new stages
|
// Print new stages
|
||||||
if (log && log.length > lastStageCount) {
|
if (log && log.length > lastStageCount) {
|
||||||
for (let i = lastStageCount; i < log.length; i++) {
|
for (let i = lastStageCount; i < log.length; i++) {
|
||||||
@@ -130,8 +135,9 @@ async function followLogs(
|
|||||||
lastStageCount = log.length;
|
lastStageCount = log.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Done
|
// Only exit on "installed" if we actually saw the install happen
|
||||||
if (status === "installed") {
|
// (avoids exiting immediately when following a reprovision that hasn't started yet)
|
||||||
|
if (status === "installed" && sawInstalling) {
|
||||||
const ip = data["ip"] ?? "";
|
const ip = data["ip"] ?? "";
|
||||||
console.log("");
|
console.log("");
|
||||||
console.log(` ${GREEN}${BOLD}Install complete!${RESET}${ip ? ` ${DIM}ssh lab@${ip}${RESET}` : ""}`);
|
console.log(` ${GREEN}${BOLD}Install complete!${RESET}${ip ? ` ${DIM}ssh lab@${ip}${RESET}` : ""}`);
|
||||||
|
|||||||
37
bastion/src/cli/src/commands/register.ts
Normal file
37
bastion/src/cli/src/commands/register.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
// CLI command: provision register
|
||||||
|
// Register an already-installed machine that is missing from bastion state.
|
||||||
|
|
||||||
|
import { Command, Option } from "commander";
|
||||||
|
import { SUPPORTED_ROLES } from "@lab/shared";
|
||||||
|
import { getLabdClient } from "../api/config.js";
|
||||||
|
|
||||||
|
export function registerRegisterCommand(parent: Command): void {
|
||||||
|
parent
|
||||||
|
.command("register <mac> <hostname>")
|
||||||
|
.description("Register an already-installed machine (e.g. after state loss)")
|
||||||
|
.addOption(new Option("--role <role>", "Machine role").choices([...SUPPORTED_ROLES]).default("worker"))
|
||||||
|
.option("--ip <address>", "Machine IP address")
|
||||||
|
.action(async (mac: string, hostname: string, opts: {
|
||||||
|
role: string;
|
||||||
|
ip?: string;
|
||||||
|
}) => {
|
||||||
|
try {
|
||||||
|
const result = await getLabdClient().registerMachine({
|
||||||
|
mac,
|
||||||
|
hostname,
|
||||||
|
role: opts.role,
|
||||||
|
...(opts.ip ? { ip: opts.ip } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
console.error(`Failed: ${result.error}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Registered ${mac} as ${hostname} (role=${opts.role}${opts.ip ? `, ip=${opts.ip}` : ""})`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -144,6 +144,7 @@ export function registerReprovisionCommand(parent: Command): void {
|
|||||||
|
|
||||||
const sshArgs = [
|
const sshArgs = [
|
||||||
"-o", "StrictHostKeyChecking=no",
|
"-o", "StrictHostKeyChecking=no",
|
||||||
|
"-o", "UserKnownHostsFile=/dev/null",
|
||||||
"-o", "ConnectTimeout=10",
|
"-o", "ConnectTimeout=10",
|
||||||
...(sshKey !== undefined ? ["-i", sshKey] : []),
|
...(sshKey !== undefined ? ["-i", sshKey] : []),
|
||||||
`${effectiveUser}@${ip}`,
|
`${effectiveUser}@${ip}`,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// CLI entry point for lab-bastion.
|
// CLI entry point for lab-bastion.
|
||||||
// Commands:
|
// Commands:
|
||||||
// init bastion standalone start/stop/status
|
// init bastion standalone start/stop/status
|
||||||
// provision list/install/reprovision/forget
|
// provision list/install/reprovision/forget/register
|
||||||
|
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { Command, Option } from "commander";
|
import { Command, Option } from "commander";
|
||||||
@@ -16,6 +16,7 @@ import { registerListCommand } from "./commands/list.js";
|
|||||||
import { registerReprovisionCommand } from "./commands/reprovision.js";
|
import { registerReprovisionCommand } from "./commands/reprovision.js";
|
||||||
import { registerDebugCommand } from "./commands/debug.js";
|
import { registerDebugCommand } from "./commands/debug.js";
|
||||||
import { registerForgetCommand } from "./commands/forget.js";
|
import { registerForgetCommand } from "./commands/forget.js";
|
||||||
|
import { registerRegisterCommand } from "./commands/register.js";
|
||||||
import { registerLogsCommand } from "./commands/logs.js";
|
import { registerLogsCommand } from "./commands/logs.js";
|
||||||
import { registerMakeIsoCommand } from "./commands/makeiso.js";
|
import { registerMakeIsoCommand } from "./commands/makeiso.js";
|
||||||
import { registerConfigCommand } from "./commands/config.js";
|
import { registerConfigCommand } from "./commands/config.js";
|
||||||
@@ -98,6 +99,7 @@ export function createProgram(): Command {
|
|||||||
registerReprovisionCommand(provisionCmd);
|
registerReprovisionCommand(provisionCmd);
|
||||||
registerDebugCommand(provisionCmd);
|
registerDebugCommand(provisionCmd);
|
||||||
registerForgetCommand(provisionCmd);
|
registerForgetCommand(provisionCmd);
|
||||||
|
registerRegisterCommand(provisionCmd);
|
||||||
registerLogsCommand(provisionCmd);
|
registerLogsCommand(provisionCmd);
|
||||||
registerMakeIsoCommand(provisionCmd);
|
registerMakeIsoCommand(provisionCmd);
|
||||||
|
|
||||||
|
|||||||
@@ -172,6 +172,43 @@ export function registerBastionRoutes(app: FastifyInstance, db: DbClient): void
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Register an already-installed machine — route to correct bastion (or single bastion)
|
||||||
|
app.post<{
|
||||||
|
Body: { mac?: string; hostname?: string; role?: string; ip?: string };
|
||||||
|
}>("/api/machines/register", async (request, reply) => {
|
||||||
|
const { mac, hostname, role, ip } = request.body ?? {};
|
||||||
|
if (!mac || !hostname) {
|
||||||
|
return reply.code(400).send({ error: "mac and hostname are required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = mac.toLowerCase().replace(/-/g, ":");
|
||||||
|
|
||||||
|
// Find bastion that knows this MAC, or use single connected bastion
|
||||||
|
const bastion = bastionRegistry.findBastionByMac(normalized);
|
||||||
|
const target = bastion ?? (bastionRegistry.getAll().length === 1 ? bastionRegistry.getAll()[0] : null);
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
const all = bastionRegistry.getAll();
|
||||||
|
if (all.length === 0) {
|
||||||
|
return reply.code(503).send({ error: "No bastions connected" });
|
||||||
|
}
|
||||||
|
return reply.code(404).send({ error: `MAC ${normalized} not found on any bastion and multiple bastions connected` });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await sendCommand(target.bastionId, {
|
||||||
|
type: "command-register",
|
||||||
|
mac: normalized,
|
||||||
|
hostname,
|
||||||
|
role: role ?? "worker",
|
||||||
|
ip: ip ?? "",
|
||||||
|
});
|
||||||
|
return reply.code(result.status === "ok" ? 200 : 500).send(result);
|
||||||
|
} catch (err) {
|
||||||
|
return reply.code(500).send({ error: err instanceof Error ? err.message : String(err) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Queue debug/rescue mode — route to correct bastion by MAC
|
// Queue debug/rescue mode — route to correct bastion by MAC
|
||||||
app.post<{
|
app.post<{
|
||||||
Body: { mac?: string; pxeBoot?: boolean };
|
Body: { mac?: string; pxeBoot?: boolean };
|
||||||
@@ -257,17 +294,7 @@ export function registerBastionRoutes(app: FastifyInstance, db: DbClient): void
|
|||||||
const queued = bastion.state.install_queue[mac];
|
const queued = bastion.state.install_queue[mac];
|
||||||
const installed = bastion.state.installed[mac];
|
const installed = bastion.state.installed[mac];
|
||||||
|
|
||||||
if (installed) {
|
// Active install takes priority over old installed state (reprovision case)
|
||||||
return {
|
|
||||||
mac,
|
|
||||||
hostname: installed.hostname,
|
|
||||||
status: "installed",
|
|
||||||
role: installed.role,
|
|
||||||
ip: installed.ip,
|
|
||||||
installed_at: installed.installed_at,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (queued) {
|
if (queued) {
|
||||||
return {
|
return {
|
||||||
mac,
|
mac,
|
||||||
@@ -282,6 +309,17 @@ export function registerBastionRoutes(app: FastifyInstance, db: DbClient): void
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (installed) {
|
||||||
|
return {
|
||||||
|
mac,
|
||||||
|
hostname: installed.hostname,
|
||||||
|
status: "installed",
|
||||||
|
role: installed.role,
|
||||||
|
ip: installed.ip,
|
||||||
|
installed_at: installed.installed_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return reply.code(404).send({ error: `MAC ${mac} not found in install queue or installed` });
|
return reply.code(404).send({ error: `MAC ${mac} not found in install queue or installed` });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ export type LabdBastionMessage =
|
|||||||
| { type: "command-forget"; requestId: string; mac: string }
|
| { type: "command-forget"; requestId: string; mac: string }
|
||||||
| { type: "command-role-update"; requestId: string; mac: string; role: string }
|
| { type: "command-role-update"; requestId: string; mac: string; role: string }
|
||||||
| { type: "command-debug"; requestId: string; mac: string; pxeBoot?: boolean }
|
| { type: "command-debug"; requestId: string; mac: string; pxeBoot?: boolean }
|
||||||
|
| { type: "command-register"; requestId: string; mac: string; hostname: string; role: string; ip: string }
|
||||||
| { type: "server-shutdown"; reconnectAfter: number };
|
| { type: "server-shutdown"; reconnectAfter: number };
|
||||||
|
|
||||||
export type BastionMessageType = BastionMessage["type"];
|
export type BastionMessageType = BastionMessage["type"];
|
||||||
@@ -126,7 +127,7 @@ const BASTION_MESSAGE_TYPES = new Set<string>([
|
|||||||
|
|
||||||
const LABD_BASTION_MESSAGE_TYPES = new Set<string>([
|
const LABD_BASTION_MESSAGE_TYPES = new Set<string>([
|
||||||
"bastion-enrolled", "bastion-heartbeat-ack", "command-install",
|
"bastion-enrolled", "bastion-heartbeat-ack", "command-install",
|
||||||
"command-forget", "command-role-update", "command-debug", "server-shutdown",
|
"command-forget", "command-role-update", "command-debug", "command-register", "server-shutdown",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export function isBastionMessage(msg: unknown): msg is BastionMessage {
|
export function isBastionMessage(msg: unknown): msg is BastionMessage {
|
||||||
|
|||||||
Reference in New Issue
Block a user