Files
pikasTech-unidesk/scripts/src/provider-attach.ts
T

223 lines
7.8 KiB
TypeScript

import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { type UniDeskConfig, repoRoot, rootPath } from "./config";
import { runCommand } from "./command";
interface ProviderAttachOptions {
providerId: string;
masterServer: string;
envFile: string;
composeFile: string;
logDir: string;
hostSshKeyDir: string;
token: string | null;
force: boolean;
up: boolean;
}
interface ProviderAttachContainerValidation {
ok: boolean;
containerName: string;
restartPolicy: string;
restartPolicyOk: boolean;
pidMode: string;
pidModeOk: boolean;
state: string;
command: string[];
exitCode: number | null;
stderr: string;
}
function optionValue(args: string[], name: string): string | null {
const index = args.indexOf(name);
if (index === -1) return null;
const value = args[index + 1];
if (value === undefined || value.length === 0) throw new Error(`${name} requires a non-empty value`);
return value;
}
function hasFlag(args: string[], name: string): boolean {
return args.includes(name);
}
function safeProviderSlug(providerId: string): string {
return providerId.replace(/[^a-zA-Z0-9_.-]/g, "-").replace(/^-+|-+$/g, "").toLowerCase() || "provider";
}
function defaultMasterServer(config: UniDeskConfig): string {
return `http://${config.network.publicHost}/`;
}
function defaultHostSshKeyDir(): string {
const home = process.env.HOME || "/root";
return join(home, ".unidesk", "host-ssh");
}
function parseAttachOptions(config: UniDeskConfig, args: string[]): ProviderAttachOptions {
const valueOptions = new Set(["--env-file", "--compose-file", "--log-dir", "--master-server", "--master", "--host-ssh-key-dir", "--provider-token", "--token"]);
let providerId: string | undefined;
for (let index = 0; index < args.length; index += 1) {
const arg = args[index] ?? "";
if (valueOptions.has(arg)) {
index += 1;
continue;
}
if (!arg.startsWith("--")) {
providerId = arg;
break;
}
}
if (providerId === undefined) {
throw new Error("provider attach requires provider id, for example: bun scripts/cli.ts provider attach D518");
}
const envFile = optionValue(args, "--env-file") ?? rootPath(".state", `provider-${providerId}.env`);
const composeFile = optionValue(args, "--compose-file") ?? rootPath(`provider-${providerId}.yml`);
const logDir = optionValue(args, "--log-dir") ?? rootPath("logs", `provider-${providerId}`);
return {
providerId,
masterServer: optionValue(args, "--master-server") ?? optionValue(args, "--master") ?? defaultMasterServer(config),
envFile,
composeFile,
logDir,
hostSshKeyDir: optionValue(args, "--host-ssh-key-dir") ?? defaultHostSshKeyDir(),
token: optionValue(args, "--provider-token") ?? optionValue(args, "--token"),
force: hasFlag(args, "--force"),
up: hasFlag(args, "--up"),
};
}
function envContent(options: ProviderAttachOptions): string {
const lines = [
"# Generated by: bun scripts/cli.ts provider attach",
"# Required attach inputs are intentionally limited to master server and provider id.",
`UNIDESK_MASTER_SERVER=${options.masterServer}`,
`PROVIDER_ID=${options.providerId}`,
];
if (options.token !== null) lines.push(`PROVIDER_TOKEN=${options.token}`);
return `${lines.join("\n")}\n`;
}
function composeContent(options: ProviderAttachOptions): string {
const slug = safeProviderSlug(options.providerId);
const relativeEnv = pathForCompose(options.envFile);
const relativeLogDir = pathForCompose(options.logDir);
return [
"services:",
" provider-gateway:",
` image: unidesk_provider-gateway:${slug}`,
" build:",
" context: .",
" dockerfile: src/components/provider-gateway/Dockerfile",
` container_name: unidesk-provider-gateway-${slug}`,
" restart: always",
' pid: "host"',
" env_file:",
` - ${relativeEnv}`,
" volumes:",
" - /var/run/docker.sock:/var/run/docker.sock",
" - .:/workspace:ro",
` - ${relativeLogDir}:/var/log/unidesk`,
` - ${options.hostSshKeyDir}:/run/host-ssh:ro`,
" extra_hosts:",
' - "host.docker.internal:host-gateway"',
"",
].join("\n");
}
function pathForCompose(path: string): string {
if (path.startsWith(repoRoot)) {
const relative = path.slice(repoRoot.length).replace(/^\/+/, "");
return relative.length === 0 ? "." : `./${relative}`;
}
return path;
}
function writeGeneratedFile(path: string, content: string, force: boolean): "created" | "updated" | "unchanged" | "skipped" {
if (existsSync(path)) {
const existing = readFileSync(path, "utf8");
if (existing === content) return "unchanged";
if (!force) return "skipped";
writeFileSync(path, content, "utf8");
return "updated";
}
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, content, "utf8");
return "created";
}
function inspectAttachedContainer(options: ProviderAttachOptions): ProviderAttachContainerValidation {
const containerName = `unidesk-provider-gateway-${safeProviderSlug(options.providerId)}`;
const command = [
"docker",
"inspect",
"--format",
"{{.HostConfig.RestartPolicy.Name}}\t{{.HostConfig.PidMode}}\t{{.State.Status}}",
containerName,
];
const result = runCommand(command, repoRoot);
const [restartPolicy = "", pidMode = "", state = ""] = result.stdout.trim().split("\t");
const restartPolicyOk = restartPolicy === "always";
const pidModeOk = pidMode === "host";
return {
ok: result.exitCode === 0 && restartPolicyOk && pidModeOk && state === "running",
containerName,
restartPolicy,
restartPolicyOk,
pidMode,
pidModeOk,
state,
command,
exitCode: result.exitCode,
stderr: result.stderr.trim(),
};
}
export async function runProviderCommand(config: UniDeskConfig, args: string[]): Promise<unknown> {
const [sub] = args;
if (sub !== "attach") {
throw new Error("provider requires subcommand: attach");
}
const options = parseAttachOptions(config, args.slice(1));
mkdirSync(options.logDir, { recursive: true });
mkdirSync(options.hostSshKeyDir, { recursive: true, mode: 0o700 });
const envStatus = writeGeneratedFile(options.envFile, envContent(options), options.force);
const composeStatus = writeGeneratedFile(options.composeFile, composeContent(options), options.force);
const composeCommand = [
"docker",
"compose",
"--env-file",
options.envFile,
"-f",
options.composeFile,
"-p",
`unidesk-${safeProviderSlug(options.providerId)}`,
"up",
"-d",
"--build",
"--remove-orphans",
];
const result = options.up ? runCommand(composeCommand, repoRoot) : null;
const containerValidation = options.up ? inspectAttachedContainer(options) : null;
return {
providerId: options.providerId,
masterServer: options.masterServer,
envFile: options.envFile,
envFileStatus: envStatus,
composeFile: options.composeFile,
composeFileStatus: composeStatus,
logDir: options.logDir,
hostSshKeyDir: options.hostSshKeyDir,
hostSshKeyPath: join(options.hostSshKeyDir, "id_ed25519"),
startCommand: composeCommand,
started: options.up,
result,
containerValidation,
notes: [
"The generated env file keeps only UNIDESK_MASTER_SERVER and PROVIDER_ID unless --provider-token is supplied.",
"Place a maintenance SSH private key at hostSshKeyPath if this provider should expose host.ssh.",
"When --up is used, containerValidation must show restartPolicy=always and pidMode=host before the node is accepted as durable across Docker daemon restarts.",
envStatus === "skipped" || composeStatus === "skipped" ? "Existing generated files differed and were not overwritten; rerun with --force to replace them." : "Generated files are ready.",
],
};
}