233 lines
8.3 KiB
TypeScript
233 lines
8.3 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";
|
|
import { runProviderTriage } from "./provider-triage";
|
|
|
|
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}`,
|
|
" ports:",
|
|
' - "127.0.0.1:${PROVIDER_EGRESS_PROXY_HOST_PORT:-18789}:${PROVIDER_EGRESS_PROXY_PORT:-18789}"',
|
|
" 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") {
|
|
if (sub === "triage") {
|
|
const providerId = args[1];
|
|
if (providerId === undefined || providerId.length === 0) {
|
|
throw new Error("provider triage requires provider id, for example: bun scripts/cli.ts provider triage D601");
|
|
}
|
|
return runProviderTriage(config, providerId, args.slice(2));
|
|
}
|
|
throw new Error("provider requires subcommand: attach|triage");
|
|
}
|
|
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.",
|
|
],
|
|
};
|
|
}
|