feat: initialize unidesk platform
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
||||
import { basename, join, resolve } from "node:path";
|
||||
import { commandOk, runCommand, tailFile } from "./command";
|
||||
import { type UniDeskConfig, repoRoot, rootPath } from "./config";
|
||||
import { startJob } from "./jobs";
|
||||
|
||||
export interface ComposeRuntimeEnv {
|
||||
envFile: string;
|
||||
logDir: string;
|
||||
logPrefix: string;
|
||||
}
|
||||
|
||||
export interface ContainerStatus {
|
||||
id: string;
|
||||
name: string;
|
||||
image: string;
|
||||
status: string;
|
||||
ports: string;
|
||||
}
|
||||
|
||||
export function resolveComposeCommand(config: UniDeskConfig, envFile: string): string[] {
|
||||
const composeFile = rootPath(config.docker.composeFile);
|
||||
if (commandOk(["docker", "compose", "version"], repoRoot)) {
|
||||
return ["docker", "compose", "--env-file", envFile, "-f", composeFile, "-p", config.docker.projectName];
|
||||
}
|
||||
if (commandOk(["docker-compose", "--version"], repoRoot)) {
|
||||
return ["docker-compose", "--env-file", envFile, "-f", composeFile, "-p", config.docker.projectName];
|
||||
}
|
||||
throw new Error("Neither docker compose plugin nor docker-compose binary is available");
|
||||
}
|
||||
|
||||
function pad(value: number): string {
|
||||
return String(value).padStart(2, "0");
|
||||
}
|
||||
|
||||
function localDateParts(date: Date): { day: string; stamp: string } {
|
||||
const day = `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}`;
|
||||
const stamp = `${day}_${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
|
||||
return { day, stamp };
|
||||
}
|
||||
|
||||
function envValue(value: string): string {
|
||||
if (/^[A-Za-z0-9_./:@-]+$/.test(value)) return value;
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
export function writeComposeEnv(config: UniDeskConfig, freshLogPrefix: boolean): ComposeRuntimeEnv {
|
||||
const stateDir = rootPath(config.paths.stateDir);
|
||||
mkdirSync(stateDir, { recursive: true });
|
||||
const envFile = join(stateDir, "docker-compose.env");
|
||||
if (!freshLogPrefix && existsSync(envFile)) {
|
||||
const raw = readFileSync(envFile, "utf8");
|
||||
const logDir = raw.match(/^UNIDESK_LOG_DIR=(.*)$/m)?.[1]?.replace(/^"|"$/g, "") ?? rootPath(config.paths.logsDir);
|
||||
const logPrefix = raw.match(/^UNIDESK_LOG_PREFIX=(.*)$/m)?.[1]?.replace(/^"|"$/g, "") ?? localDateParts(new Date()).stamp;
|
||||
return { envFile, logDir, logPrefix };
|
||||
}
|
||||
const parts = localDateParts(new Date());
|
||||
const logDir = resolve(rootPath(config.paths.logsDir, parts.day));
|
||||
mkdirSync(logDir, { recursive: true });
|
||||
chmodSync(logDir, 0o777);
|
||||
const labels = JSON.stringify(config.providerGateway.labels);
|
||||
const lines = {
|
||||
UNIDESK_PUBLIC_HOST: config.network.publicHost,
|
||||
UNIDESK_CORE_PORT: String(config.network.core.port),
|
||||
UNIDESK_FRONTEND_PORT: String(config.network.frontend.port),
|
||||
UNIDESK_DATABASE_PORT: String(config.network.database.port),
|
||||
UNIDESK_DATABASE_USER: config.database.user,
|
||||
UNIDESK_DATABASE_PASSWORD: config.database.password,
|
||||
UNIDESK_DATABASE_NAME: config.database.name,
|
||||
UNIDESK_PROVIDER_TOKEN: config.providerGateway.token,
|
||||
UNIDESK_PROVIDER_ID: config.providerGateway.id,
|
||||
UNIDESK_PROVIDER_NAME: config.providerGateway.name,
|
||||
UNIDESK_PROVIDER_LABELS_JSON: labels,
|
||||
UNIDESK_HEARTBEAT_INTERVAL_MS: String(config.providerGateway.heartbeatIntervalMs),
|
||||
UNIDESK_HEARTBEAT_TIMEOUT_MS: "90000",
|
||||
UNIDESK_RECONNECT_BASE_MS: String(config.providerGateway.reconnectBaseMs),
|
||||
UNIDESK_RECONNECT_MAX_MS: String(config.providerGateway.reconnectMaxMs),
|
||||
UNIDESK_LOG_DIR: logDir,
|
||||
UNIDESK_LOG_PREFIX: parts.stamp,
|
||||
UNIDESK_HOST_SSH_HOST: config.sshForwarding.host,
|
||||
UNIDESK_HOST_SSH_PORT: String(config.sshForwarding.port),
|
||||
UNIDESK_HOST_SSH_USER: config.sshForwarding.user,
|
||||
};
|
||||
writeFileSync(envFile, Object.entries(lines).map(([key, value]) => `${key}=${envValue(value)}`).join("\n") + "\n", "utf8");
|
||||
return { envFile, logDir, logPrefix: parts.stamp };
|
||||
}
|
||||
|
||||
export function composeConfig(config: UniDeskConfig): { runtimeEnv: ComposeRuntimeEnv; command: string[]; result: ReturnType<typeof runCommand> } {
|
||||
const runtimeEnv = writeComposeEnv(config, false);
|
||||
const compose = resolveComposeCommand(config, runtimeEnv.envFile);
|
||||
const command = [...compose, "config", "-q"];
|
||||
return { runtimeEnv, command, result: runCommand(command, repoRoot) };
|
||||
}
|
||||
|
||||
export function startStack(config: UniDeskConfig): unknown {
|
||||
const runtimeEnv = writeComposeEnv(config, true);
|
||||
const compose = resolveComposeCommand(config, runtimeEnv.envFile);
|
||||
const containers = dockerContainers(config);
|
||||
const occupiedPorts = fixedPorts(config).filter((item) => item.listening);
|
||||
if (occupiedPorts.length > 0 && containers.length === 0) {
|
||||
throw new Error(`Fixed UniDesk port is occupied before start: ${occupiedPorts.map((p) => `${p.name}:${p.port}`).join(", ")}`);
|
||||
}
|
||||
const downCommand = [...compose, "down", "--remove-orphans"];
|
||||
const upCommand = [...compose, "up", "-d", "--build"];
|
||||
const command = ["bash", "-lc", `set -euo pipefail; ${shellJoin(downCommand)}; ${shellJoin(upCommand)}`];
|
||||
const job = startJob("server_start", command, "Build and start UniDesk database, core, frontend, and provider gateway containers");
|
||||
return { job, runtimeEnv, command, ports: fixedPorts(config) };
|
||||
}
|
||||
|
||||
export function stopStack(config: UniDeskConfig): unknown {
|
||||
const runtimeEnv = writeComposeEnv(config, false);
|
||||
const compose = resolveComposeCommand(config, runtimeEnv.envFile);
|
||||
const command = [...compose, "down", "--remove-orphans"];
|
||||
const job = startJob("server_stop", command, "Stop all UniDesk Docker services managed by the fixed compose project");
|
||||
return { job, runtimeEnv, command, portsBeforeStop: fixedPorts(config) };
|
||||
}
|
||||
|
||||
function fixedPorts(config: UniDeskConfig): Array<{ name: string; port: number; listening: boolean }> {
|
||||
return [
|
||||
{ name: "backend-core", port: config.network.core.port, listening: isPortListening(config.network.core.port) },
|
||||
{ name: "frontend", port: config.network.frontend.port, listening: isPortListening(config.network.frontend.port) },
|
||||
{ name: "database", port: config.network.database.port, listening: isPortListening(config.network.database.port) },
|
||||
];
|
||||
}
|
||||
|
||||
function shellJoin(args: string[]): string {
|
||||
return args.map((arg) => `'${arg.replace(/'/g, `'\\''`)}'`).join(" ");
|
||||
}
|
||||
|
||||
function isPortListening(port: number): boolean {
|
||||
const result = runCommand(["ss", "-ltn"], repoRoot);
|
||||
if (result.exitCode !== 0) return false;
|
||||
return result.stdout.split("\n").some((line) => line.includes(`:${port} `) || line.includes(`:${port}\t`));
|
||||
}
|
||||
|
||||
export function dockerContainers(config: UniDeskConfig): ContainerStatus[] {
|
||||
const result = runCommand([
|
||||
"docker",
|
||||
"ps",
|
||||
"-a",
|
||||
"--filter",
|
||||
`label=com.docker.compose.project=${config.docker.projectName}`,
|
||||
"--format",
|
||||
"{{json .}}",
|
||||
], repoRoot);
|
||||
if (result.exitCode !== 0 || result.stdout.trim().length === 0) return [];
|
||||
return result.stdout
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line) as Record<string, string>)
|
||||
.map((row) => ({ id: row.ID ?? "", name: row.Names ?? "", image: row.Image ?? "", status: row.Status ?? "", ports: row.Ports ?? "" }));
|
||||
}
|
||||
|
||||
async function probe(url: string): Promise<unknown> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 2500);
|
||||
try {
|
||||
const res = await fetch(url, { signal: controller.signal });
|
||||
const body = await res.text();
|
||||
return { ok: res.ok, status: res.status, body: body.slice(0, 1200) };
|
||||
} catch (error) {
|
||||
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
export async function stackStatus(config: UniDeskConfig): Promise<unknown> {
|
||||
const runtimeEnv = writeComposeEnv(config, false);
|
||||
return {
|
||||
runtimeEnv,
|
||||
ports: fixedPorts(config),
|
||||
containers: dockerContainers(config),
|
||||
health: {
|
||||
core: await probe(`http://127.0.0.1:${config.network.core.port}/health`),
|
||||
frontend: await probe(`http://127.0.0.1:${config.network.frontend.port}/health`),
|
||||
overview: await probe(`http://127.0.0.1:${config.network.core.port}/api/overview`),
|
||||
},
|
||||
urls: {
|
||||
core: `http://${config.network.publicHost}:${config.network.core.port}`,
|
||||
frontend: `http://${config.network.publicHost}:${config.network.frontend.port}`,
|
||||
database: `postgres://${config.database.user}:***@${config.network.publicHost}:${config.network.database.port}/${config.database.name}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function listLogFiles(root: string): string[] {
|
||||
if (!existsSync(root)) return [];
|
||||
const entries = readdirSync(root, { withFileTypes: true });
|
||||
const files: string[] = [];
|
||||
for (const entry of entries) {
|
||||
const path = join(root, entry.name);
|
||||
if (entry.isDirectory()) files.push(...listLogFiles(path));
|
||||
if (entry.isFile()) files.push(path);
|
||||
}
|
||||
return files.sort();
|
||||
}
|
||||
|
||||
export function stackLogs(config: UniDeskConfig, tailBytes: number): unknown {
|
||||
const logRoot = rootPath(config.paths.logsDir);
|
||||
const runtimeEnv = writeComposeEnv(config, false);
|
||||
const allFiles = listLogFiles(logRoot);
|
||||
const currentFiles = allFiles.filter((path) => basename(path).startsWith(runtimeEnv.logPrefix));
|
||||
const selectedFiles = (currentFiles.length > 0 ? currentFiles : allFiles.slice(-12)).slice(-12);
|
||||
const files = selectedFiles.map((path) => ({ path, name: basename(path), tail: tailFile(path, tailBytes) }));
|
||||
const containerNames = ["unidesk-database", "unidesk-backend-core", "unidesk-frontend", "unidesk-provider-gateway-main"];
|
||||
const docker = containerNames.map((name) => {
|
||||
const result = runCommand(["docker", "logs", "--tail", "40", name], repoRoot);
|
||||
return { name, exitCode: result.exitCode, stdoutTail: result.stdout.slice(-tailBytes), stderrTail: result.stderr.slice(-tailBytes) };
|
||||
});
|
||||
return { logRoot, runtimeEnv, files, docker };
|
||||
}
|
||||
Reference in New Issue
Block a user