Files
pikasTech-unidesk/scripts/src/provider-apps-linux.ts
T
2026-07-20 04:11:55 +02:00

333 lines
17 KiB
TypeScript

import { createHash, randomUUID } from "node:crypto";
import { existsSync, mkdtempSync, readFileSync, rmSync, truncateSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import type { LinuxInstallTarget, ProviderAppsConfig } from "../../src/components/provider-apps/config";
import { runCommand, type CommandResult } from "./command";
const repositoryRoot = resolve(import.meta.dir, "../..");
const transPath = "/root/.local/bin/trans";
function option(args: string[], name: string): string | null {
const index = args.indexOf(name);
if (index < 0) return null;
const value = args[index + 1];
if (value === undefined || value.startsWith("--")) throw new Error(`${name} requires a value`);
return value;
}
function selectedTarget(config: ProviderAppsConfig, args: string[]): [string, LinuxInstallTarget] {
const id = option(args, "--target");
if (id === null) throw new Error("provider-apps linux requires --target <id>");
const target = config.install.linux.targets[id];
if (target === undefined) throw new Error(`unknown Linux Provider target: ${id}`);
return [id, target];
}
function parseEnv(path: string): Record<string, string> {
if (!existsSync(path)) throw new Error(`credential sourceRef is missing: ${path}`);
const result: Record<string, string> = {};
for (const line of readFileSync(path, "utf8").split(/\r?\n/u)) {
const trimmed = line.trim();
if (trimmed.length === 0 || trimmed.startsWith("#")) continue;
const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u.exec(trimmed);
if (match === null) continue;
let value = match[2] ?? "";
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) value = value.slice(1, -1);
result[match[1] ?? ""] = value;
}
return result;
}
function fingerprint(value: string): string {
return `sha256:${createHash("sha256").update(value).digest("hex").slice(0, 12)}`;
}
function fileSha256(path: string): string {
const result = checked(runCommand(["sha256sum", path], repositoryRoot, { timeoutMs: 120_000 }), "hash benchmark file");
const digest = result.stdout.trim().split(/\s+/u)[0] ?? "";
if (!/^[0-9a-f]{64}$/u.test(digest)) throw new Error(`invalid SHA-256 output for ${path}`);
return digest;
}
function shQuote(value: string): string {
return `'${value.replaceAll("'", `'"'"'`)}'`;
}
function psQuote(value: string): string {
return `'${value.replaceAll("'", "''")}'`;
}
function checked(result: CommandResult, operation: string): CommandResult {
if (result.exitCode !== 0) {
const detail = result.stderr.trim() || result.stdout.trim() || `exitCode=${result.exitCode}`;
throw new Error(`${operation} failed: ${detail}`);
}
return result;
}
function trans(route: string, operation: string[], timeoutMs = 60_000): CommandResult {
return runCommand([transPath, route, ...operation], repositoryRoot, { timeoutMs });
}
function bootstrapPowerShell(target: LinuxInstallTarget, remoteCommand: string, timeoutMs = 60_000): CommandResult {
const source = [
"$ErrorActionPreference='Stop'",
`$key=${psQuote(target.bootstrap.identityFile)}`,
`$guest=${psQuote(target.bootstrap.guest)}`,
`& ssh.exe -i $key -o BatchMode=yes -o ConnectTimeout=8 $guest ${psQuote(remoteCommand)}`,
"exit $LASTEXITCODE",
].join("; ");
return trans(target.bootstrap.route, ["ps", source], timeoutMs);
}
function plan(config: ProviderAppsConfig, id: string, target: LinuxInstallTarget): Record<string, unknown> {
const values = parseEnv(target.credential.sourceRef.path);
const token = values[target.credential.sourceRef.key] ?? "";
return {
ok: token.length > 0,
operation: "provider-apps.linux.plan",
target: id,
bootstrap: { mode: target.bootstrap.mode, route: target.bootstrap.route, guest: target.bootstrap.guest },
providerId: target.providerId,
user: target.user,
home: target.home,
configPath: `${target.home}/.unidesk/provider.yaml`,
credential: {
sourceRef: target.credential.sourceRef.path,
key: target.credential.sourceRef.key,
present: token.length > 0,
fingerprint: token.length > 0 ? fingerprint(token) : null,
valuesPrinted: false,
},
installer: `${config.publicExposure.publicBaseUrl}${config.install.routes.linuxInstaller}`,
oneLine: `curl -fsSL ${config.publicExposure.publicBaseUrl}${config.install.routes.linuxInstaller} | bash`,
service: target.systemdUnit,
benchmark: target.benchmark,
};
}
function apply(config: ProviderAppsConfig, id: string, target: LinuxInstallTarget, args: string[]): Record<string, unknown> {
if (!args.includes("--confirm")) throw new Error("provider-apps linux apply requires --confirm");
const values = parseEnv(target.credential.sourceRef.path);
const token = values[target.credential.sourceRef.key] ?? "";
if (token.length === 0) throw new Error(`${target.credential.sourceRef.path}.${target.credential.sourceRef.key} is empty`);
const local = mkdtempSync(join(tmpdir(), "unideskcprovider-linux-"));
const remote = `/tmp/unideskcprovider-linux-${process.pid}-${Date.now()}`;
const windowsStage = `${target.bootstrap.stagingDirectory}\\${process.pid}-${Date.now()}`;
const localConfig = join(local, "provider.yaml");
const localToken = join(local, target.credential.targetFile);
const configYaml = [
`id: ${target.providerId}`,
`server: ${config.install.defaults.server}`,
`credentialFile: ${target.credential.targetFile}`,
`pythonRuntime: ${config.install.defaults.pythonRuntime}`,
`autoStart: ${String(config.install.defaults.autoStart)}`,
"",
].join("\n");
try {
writeFileSync(localConfig, configYaml, { encoding: "utf8", mode: 0o600 });
writeFileSync(localToken, `${token}\n`, { encoding: "utf8", mode: 0o600 });
checked(trans(target.bootstrap.route, ["ps", `New-Item -ItemType Directory -Force -Path ${psQuote(windowsStage)} | Out-Null`]), "prepare Windows SSH staging");
checked(trans(target.bootstrap.route, ["upload", localConfig, `${windowsStage}\\provider.yaml`, "--allow-text-transfer"], 120_000), "upload provider.yaml to SSH bridge");
checked(trans(target.bootstrap.route, ["upload", localToken, `${windowsStage}\\${target.credential.targetFile}`, "--allow-text-transfer"], 120_000), "upload Provider credential to SSH bridge");
const scpSource = [
"$ErrorActionPreference='Stop'",
`$key=${psQuote(target.bootstrap.identityFile)}`,
`$guest=${psQuote(target.bootstrap.guest)}`,
`$stage=${psQuote(windowsStage)}`,
`$remote=${psQuote(remote)}`,
"& ssh.exe -i $key -o BatchMode=yes -o ConnectTimeout=8 $guest \"install -d -m 700 $remote\"",
"if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }",
"& scp.exe -i $key -o BatchMode=yes (Join-Path $stage 'provider.yaml') \"${guest}:$remote/provider.yaml\"",
"if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }",
`& scp.exe -i $key -o BatchMode=yes (Join-Path $stage ${psQuote(target.credential.targetFile)}) `
+ `\"\${guest}:$remote/${target.credential.targetFile}\"`,
"exit $LASTEXITCODE",
].join("; ");
checked(trans(target.bootstrap.route, ["ps", scpSource], 180_000), "copy configuration through Windows SSH bridge");
const script = [
"set -eu",
`sudo install -d -m 700 -o ${shQuote(target.user)} -g ${shQuote(target.user)} ${shQuote(`${target.home}/.unidesk`)}`,
`sudo install -m 600 -o ${shQuote(target.user)} -g ${shQuote(target.user)} ${shQuote(`${remote}/provider.yaml`)} ${shQuote(`${target.home}/.unidesk/provider.yaml`)}`,
`sudo install -m 600 -o ${shQuote(target.user)} -g ${shQuote(target.user)} ${shQuote(`${remote}/${target.credential.targetFile}`)} ${shQuote(`${target.home}/.unidesk/${target.credential.targetFile}`)}`,
`rm -rf ${shQuote(remote)}`,
`sudo -u ${shQuote(target.user)} -H bash -lc ${shQuote(`curl -fsSL ${config.publicExposure.publicBaseUrl}${config.install.routes.linuxInstaller} | bash`)}`,
].join("; ");
const installed = checked(bootstrapPowerShell(target, script, 300_000), "install Linux Provider");
return {
...plan(config, id, target),
ok: true,
operation: "provider-apps.linux.apply",
mutation: true,
installerOutput: installed.stdout.trim(),
};
} finally {
rmSync(local, { recursive: true, force: true });
bootstrapPowerShell(target, `rm -rf ${shQuote(remote)}`);
trans(target.bootstrap.route, ["ps", `Remove-Item -Recurse -Force -ErrorAction SilentlyContinue ${psQuote(windowsStage)}`]);
}
}
function status(id: string, target: LinuxInstallTarget): Record<string, unknown> {
const script = [
"set -eu",
`printf 'serviceActive=%s\\n' "$(systemctl is-active ${shQuote(target.systemdUnit)} 2>/dev/null || true)"`,
`printf 'serviceEnabled=%s\\n' "$(systemctl is-enabled ${shQuote(target.systemdUnit)} 2>/dev/null || true)"`,
`test -f ${shQuote(`${target.home}/.unidesk/provider.yaml`)} && echo configPresent=true || echo configPresent=false`,
`test -s ${shQuote(`${target.home}/.unidesk/${target.credential.targetFile}`)} && echo credentialPresent=true || echo credentialPresent=false`,
`systemctl show ${shQuote(target.systemdUnit)} -p MainPID -p NRestarts --value 2>/dev/null | sed -n '1,2p'`,
].join("; ");
let result = trans(id, ["argv", "bash", "-lc", script]);
if (result.exitCode !== 0) result = checked(bootstrapPowerShell(target, script), "read Linux Provider status");
return { ok: result.stdout.includes("serviceActive=active"), operation: "provider-apps.linux.status", target: id, output: result.stdout.trim() };
}
function logs(id: string, target: LinuxInstallTarget): Record<string, unknown> {
const script = `journalctl -u ${shQuote(target.systemdUnit)} --no-pager -n ${target.logsTailLines}`;
let result = trans(id, ["argv", "bash", "-lc", script]);
if (result.exitCode !== 0) result = checked(bootstrapPowerShell(target, script), "read Linux Provider logs");
return { ok: true, operation: "provider-apps.linux.logs", target: id, tail: result.stdout.trim() };
}
async function transAsync(
route: string,
operation: string[],
timeoutMs: number,
streamProgress = false,
): Promise<{ exitCode: number; stdout: string; stderr: string; elapsedMs: number }> {
const started = performance.now();
const child = Bun.spawn([transPath, route, ...operation], {
cwd: repositoryRoot,
env: { ...process.env, UNIDESK_TRAN_RUNTIME_TIMEOUT_MS: String(timeoutMs) },
stdout: "pipe",
stderr: "pipe",
});
const timer = setTimeout(() => child.kill(), timeoutMs);
const stderrPromise = streamProgress
? (async () => {
const reader = child.stderr.getReader();
const decoder = new TextDecoder();
let result = "";
while (true) {
const next = await reader.read();
if (next.done) break;
const text = decoder.decode(next.value, { stream: true });
result += text;
process.stderr.write(text);
}
result += decoder.decode();
return result;
})()
: new Response(child.stderr).text();
const [exitCode, stdout, stderr] = await Promise.all([
child.exited,
new Response(child.stdout).text(),
stderrPromise,
]);
clearTimeout(timer);
return { exitCode, stdout, stderr, elapsedMs: Math.round(performance.now() - started) };
}
async function benchmark(id: string, target: LinuxInstallTarget, args: string[]): Promise<Record<string, unknown>> {
if (!args.includes("--confirm")) throw new Error("provider-apps linux benchmark requires --confirm");
const spec = target.benchmark;
const runId = randomUUID();
const commandSamples: Array<{ round: number; lane: number; elapsedMs: number }> = [];
for (let round = 0; round < spec.commandRounds; round += 1) {
const results = await Promise.all(Array.from({ length: spec.concurrency }, async (_, lane) => {
const marker = `${runId}-${round}-${lane}`;
const result = await transAsync(id, ["argv", "bash", "-lc", `printf '%s\\n' ${shQuote(marker)}`], 60_000);
if (result.exitCode !== 0 || result.stdout.trim() !== marker) throw new Error(`command lane failed round=${round} lane=${lane}: ${result.stderr || result.stdout}`);
return { round, lane, elapsedMs: result.elapsedMs };
}));
commandSamples.push(...results);
}
const local = mkdtempSync(join(tmpdir(), "unideskcprovider-benchmark-"));
const payload = join(local, "payload.bin");
writeFileSync(payload, "", { mode: 0o600 });
truncateSync(payload, spec.transferBytes);
const payloadSha = fileSha256(payload);
const transfers: Array<Record<string, unknown>> = [];
const remoteFiles = new Set<string>();
try {
for (let round = 0; round < spec.transferRounds; round += 1) {
const remote = `/tmp/unideskcprovider-benchmark-${runId}-${round}.bin`;
remoteFiles.add(remote);
process.stderr.write(`${JSON.stringify({ event: "provider-apps.linux.benchmark.stage", stage: "upload", round, bytes: spec.transferBytes })}\n`);
const upload = await transAsync(id, ["upload", payload, remote, "--runtime-timeout-ms", "600000"], 600_000, true);
if (upload.exitCode !== 0) throw new Error(`upload failed round=${round}: ${upload.stderr}`);
const localCopy = join(local, `download-${round}.bin`);
process.stderr.write(`${JSON.stringify({ event: "provider-apps.linux.benchmark.stage", stage: "download", round, bytes: spec.transferBytes })}\n`);
const download = await transAsync(id, ["download", remote, localCopy, "--runtime-timeout-ms", "600000"], 600_000, true);
if (download.exitCode !== 0) throw new Error(`download failed round=${round}: ${download.stderr}`);
const sha = fileSha256(localCopy);
if (sha !== payloadSha) throw new Error(`download SHA mismatch round=${round}`);
rmSync(localCopy, { force: true });
await transAsync(id, ["argv", "rm", "-f", remote], 60_000);
remoteFiles.delete(remote);
transfers.push({
round,
uploadElapsedMs: upload.elapsedMs,
uploadBytesPerSecond: Math.round((spec.transferBytes * 1000) / upload.elapsedMs),
downloadElapsedMs: download.elapsedMs,
downloadBytesPerSecond: Math.round((spec.transferBytes * 1000) / download.elapsedMs),
});
}
} finally {
for (const remote of remoteFiles) await transAsync(id, ["argv", "rm", "-f", remote], 60_000);
rmSync(local, { recursive: true, force: true });
}
checked(bootstrapPowerShell(target, `sudo systemctl restart ${shQuote(target.systemdUnit)}`), "restart Linux Provider");
const recoveryStarted = Date.now();
let recovered = false;
while (Date.now() - recoveryStarted < spec.readyTimeoutSeconds * 1000) {
const probe = trans(id, ["argv", "hostname"], 15_000);
if (probe.exitCode === 0) { recovered = true; break; }
await Bun.sleep(1_000);
}
if (!recovered) throw new Error(`Provider did not recover within ${spec.readyTimeoutSeconds}s`);
const poolProbes = await Promise.all(Array.from({ length: spec.poolProbeConcurrency }, async (_, lane) => {
const marker = `${runId}-pool-${lane}`;
const result = await transAsync(id, ["argv", "bash", "-lc", `printf '%s\\n' ${shQuote(marker)}`], 60_000);
if (result.exitCode !== 0 || result.stdout.trim() !== marker) throw new Error(`pool probe failed lane=${lane}: ${result.stderr || result.stdout}`);
return { lane, elapsedMs: result.elapsedMs };
}));
const elapsedMs = Date.now() - recoveryStarted;
return {
ok: true,
operation: "provider-apps.linux.benchmark",
target: id,
runId,
command: { concurrency: spec.concurrency, rounds: spec.commandRounds, samples: commandSamples },
transfer: { bytesPerFile: spec.transferBytes, concurrency: spec.concurrency, rounds: spec.transferRounds, sha256: payloadSha, samples: transfers },
recovery: { elapsedMs, timeoutSeconds: spec.readyTimeoutSeconds },
pool: { concurrency: spec.poolProbeConcurrency, readyClaims: poolProbes.length, samples: poolProbes },
};
}
export async function runProviderAppsLinuxCommand(config: ProviderAppsConfig, args: string[]): Promise<Record<string, unknown>> {
if (args.length === 0 || args[0] === "help" || args[0] === "--help") {
return {
ok: true,
operation: "provider-apps.linux.help",
usage: [
"bun scripts/cli.ts provider-apps linux plan --target <id>",
"bun scripts/cli.ts provider-apps linux apply --target <id> --confirm",
"bun scripts/cli.ts provider-apps linux status --target <id>",
"bun scripts/cli.ts provider-apps linux logs --target <id>",
"bun scripts/cli.ts provider-apps linux benchmark --target <id> --confirm",
],
};
}
const [id, target] = selectedTarget(config, args);
if (args[0] === "plan") return plan(config, id, target);
if (args[0] === "apply") return apply(config, id, target, args);
if (args[0] === "status") return status(id, target);
if (args[0] === "logs") return logs(id, target);
if (args[0] === "benchmark") return await benchmark(id, target, args);
throw new Error(`unsupported provider-apps linux action: ${args[0]}`);
}