feat: add decision center service and cli

This commit is contained in:
Codex
2026-05-17 06:49:42 +00:00
parent 2b2a327301
commit 4d9eed6513
32 changed files with 1758 additions and 48 deletions
+154 -15
View File
@@ -1,6 +1,7 @@
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { runCommand } from "./command";
import { type UniDeskConfig, type UniDeskMicroserviceConfig, repoRoot, rootPath } from "./config";
import { ensureGithubSshIdentityForProvider } from "./deploy-ssh-identity";
@@ -116,7 +117,7 @@ function deployHelp(action: string | undefined = undefined): Record<string, unkn
apply: "Start an async target-side reconcile job unless --run-now is explicitly present.",
},
options: [
{ name: "--file <path>", default: defaultDeployFile, description: "Desired-state manifest path relative to the repo root." },
{ name: "--file <path>", default: defaultDeployFile, description: "Desired-state manifest path relative to the repo root. JSON and ESM JS manifests are supported, for example deploy.json or develop.js." },
{ name: "--service <id>", description: "Limit reconcile to one service from the manifest." },
{ name: "--dry-run", description: "Prepare and validate without mutating the target service." },
{ name: "--force", description: "Redeploy even when the live commit appears up to date." },
@@ -360,10 +361,7 @@ function positionalArgs(args: string[]): string[] {
return result;
}
function readDeployManifest(file: string): DeployManifest {
const path = resolve(repoRoot, file);
if (!existsSync(path)) throw new Error(`deploy manifest not found: ${path}`);
const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown;
function parseDeployManifest(parsed: unknown): DeployManifest {
const record = asRecord(parsed);
if (record === null) throw new Error("deploy manifest must be an object");
if (record.schemaVersion !== 1) throw new Error("deploy manifest schemaVersion must be 1");
@@ -385,6 +383,67 @@ function readDeployManifest(file: string): DeployManifest {
return { schemaVersion: 1, services };
}
async function readDeployManifest(file: string): Promise<DeployManifest> {
const path = resolve(repoRoot, file);
if (!existsSync(path)) throw new Error(`deploy manifest not found: ${path}`);
if (/\.(?:mjs|js|cjs)$/iu.test(path)) {
const moduleValue = await import(`${pathToFileURL(path).href}?unideskDeployManifest=${Date.now()}`);
const moduleRecord = asRecord(moduleValue);
const parsed = moduleRecord?.default ?? moduleRecord?.manifest;
if (parsed === undefined) throw new Error(`deploy JS manifest must export default or named manifest: ${path}`);
return parseDeployManifest(parsed);
}
return parseDeployManifest(JSON.parse(readFileSync(path, "utf8")) as unknown);
}
function frontendCoreDeployService(config: UniDeskConfig): UniDeskMicroserviceConfig {
return {
id: "frontend",
name: "UniDesk Frontend",
providerId: "main-server",
description: "UniDesk core frontend entrypoint deployed by the main-server direct Compose executor.",
repository: {
url: unideskRepoUrl,
commitId: "local",
dockerfile: "src/components/frontend/Dockerfile",
composeFile: config.docker.composeFile,
composeService: "frontend",
containerName: "unidesk-frontend",
},
backend: {
nodeBaseUrl: `http://127.0.0.1:${config.network.frontend.port}`,
nodeBindHost: "127.0.0.1",
nodePort: config.network.frontend.port,
proxyMode: "core-direct",
frontendOnly: false,
public: true,
allowedMethods: ["GET", "HEAD"],
allowedPathPrefixes: ["/"],
healthPath: "/health",
timeoutMs: 8000,
},
deployment: { mode: "unidesk-direct" },
development: {
providerId: "main-server",
sshPassthrough: false,
worktreePath: repoRoot,
},
frontend: {
route: "/",
integrated: true,
},
};
}
function coreDeployService(config: UniDeskConfig, id: string): UniDeskMicroserviceConfig | undefined {
if (id === "frontend") return frontendCoreDeployService(config);
return undefined;
}
function isCoreDeployService(service: UniDeskMicroserviceConfig): boolean {
return service.id === "frontend" && service.providerId === "main-server" && service.backend.proxyMode === "core-direct";
}
function selectServices(config: UniDeskConfig, manifest: DeployManifest, serviceId: string | null): Array<{
desired: DeployManifestService;
config: UniDeskMicroserviceConfig;
@@ -393,8 +452,8 @@ function selectServices(config: UniDeskConfig, manifest: DeployManifest, service
const selected = serviceId === null ? manifest.services : manifest.services.filter((service) => service.id === serviceId);
if (serviceId !== null && selected.length === 0) throw new Error(`deploy manifest does not contain service: ${serviceId}`);
return selected.map((desired) => {
const service = configById.get(desired.id);
if (service === undefined) throw new Error(`deploy manifest service ${desired.id} is not present in config.json microservices`);
const service = configById.get(desired.id) ?? coreDeployService(config, desired.id);
if (service === undefined) throw new Error(`deploy manifest service ${desired.id} is not present in config.json microservices or supported core deploy services`);
return { desired, config: service };
});
}
@@ -424,7 +483,7 @@ function targetExportDir(service: UniDeskMicroserviceConfig, runId: string): str
function targetWorkDir(service: UniDeskMicroserviceConfig): string {
if (service.deployment.mode === "k3sctl-managed") return k3sDeployDir;
if (targetIsMain(service) && service.repository.url === unideskRepoUrl) {
if (targetIsMain(service) && isUnideskRepo(service.repository.url)) {
return rootPath(".state", "deploy", "work", safeId(service.id));
}
return service.development.worktreePath;
@@ -475,12 +534,12 @@ function directComposeEnvFile(service: UniDeskMicroserviceConfig): string {
}
function directBuildContextOverride(service: UniDeskMicroserviceConfig): string {
if (targetIsMain(service) && service.repository.url === unideskRepoUrl) return targetWorkDir(service);
if (targetIsMain(service) && isUnideskRepo(service.repository.url)) return targetWorkDir(service);
return "";
}
function directDockerfileOverride(service: UniDeskMicroserviceConfig): string {
if (targetIsMain(service) && service.repository.url === unideskRepoUrl) return service.repository.dockerfile;
if (targetIsMain(service) && isUnideskRepo(service.repository.url)) return service.repository.dockerfile;
return "";
}
@@ -587,7 +646,7 @@ function buildCachePrelude(dockerfileVariable: string): string[] {
}
function prepareSourceScript(service: UniDeskMicroserviceConfig, desired: DeployManifestService, exportDir: string): string {
if (targetIsMain(service) && desired.repo === unideskRepoUrl) {
if (targetIsMain(service) && isUnideskRepo(desired.repo)) {
return [
"set -euo pipefail",
`repo=${shellQuote(repoRoot)}`,
@@ -848,10 +907,63 @@ function imageLabelVerifyScript(service: UniDeskMicroserviceConfig, expectedComm
].join("\n");
}
function composeDeployScript(service: UniDeskMicroserviceConfig): string {
function directComposeDeployEnv(service: UniDeskMicroserviceConfig, desired: DeployManifestService, resolvedCommit: string): Record<string, string> {
if (service.id !== "frontend") return {};
return {
UNIDESK_FRONTEND_DEPLOY_SERVICE_ID: service.id,
UNIDESK_FRONTEND_DEPLOY_REPO: desired.repo,
UNIDESK_FRONTEND_DEPLOY_COMMIT: resolvedCommit,
UNIDESK_FRONTEND_DEPLOY_REQUESTED_COMMIT: desired.commitId,
};
}
function composeEnvStampLines(service: UniDeskMicroserviceConfig, desired: DeployManifestService, resolvedCommit: string): string[] {
const deployEnv = directComposeDeployEnv(service, desired, resolvedCommit);
const entries = Object.entries(deployEnv);
if (entries.length === 0) return [];
const encoded = Buffer.from(JSON.stringify(deployEnv), "utf8").toString("base64");
return [
`deploy_env_b64=${shellQuote(encoded)}`,
"if [ -n \"$compose_env_file\" ]; then",
" python3 - \"$compose_env_file\" \"$deploy_env_b64\" <<'PY'",
"import base64, json, re, sys",
"path = sys.argv[1]",
"updates = json.loads(base64.b64decode(sys.argv[2]).decode('utf-8'))",
"def env_value(value):",
" text = str(value)",
" return text if re.match(r'^[A-Za-z0-9_./:@-]+$', text) else json.dumps(text)",
"try:",
" lines = open(path, encoding='utf-8').read().splitlines()",
"except FileNotFoundError:",
" lines = []",
"seen = set()",
"out = []",
"for line in lines:",
" if '=' not in line or line.startswith('#'):",
" out.append(line)",
" continue",
" key = line.split('=', 1)[0]",
" if key in updates:",
" out.append(f'{key}={env_value(updates[key])}')",
" seen.add(key)",
" else:",
" out.append(line)",
"for key, value in updates.items():",
" if key not in seen:",
" out.append(f'{key}={env_value(value)}')",
"with open(path, 'w', encoding='utf-8') as handle:",
" handle.write('\\n'.join(out) + '\\n')",
"PY",
" echo compose_env_deploy_stamp=updated",
"fi",
];
}
function composeDeployScript(service: UniDeskMicroserviceConfig, desired: DeployManifestService, resolvedCommit: string): string {
return [
"set -euo pipefail",
directComposeResolveScript(service),
...composeEnvStampLines(service, desired, resolvedCommit),
"docker compose \"${compose_env_args[@]}\" -f \"$compose_file\" -p \"$project\" up -d --no-build --no-deps --force-recreate \"$compose_service\"",
"ready=0",
"for attempt in $(seq 1 90); do",
@@ -1146,6 +1258,33 @@ function healthSummary(response: unknown): Record<string, unknown> {
};
}
async function directHttpJson(url: string, timeoutMs: number): Promise<unknown> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, { signal: controller.signal });
const text = await response.text();
let body: unknown = null;
try {
body = text.length > 0 ? JSON.parse(text) : null;
} catch {
body = { text };
}
return { ok: response.ok, status: response.status, body };
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) };
} finally {
clearTimeout(timer);
}
}
async function serviceHealth(config: UniDeskConfig, service: UniDeskMicroserviceConfig): Promise<unknown> {
if (isCoreDeployService(service)) {
return await directHttpJson(`${service.backend.nodeBaseUrl}${service.backend.healthPath}`, service.backend.timeoutMs);
}
return coreInternalFetch(`/api/microservices/${encodeURIComponent(service.id)}/health`);
}
function commitMatches(actual: string | null, desired: string): boolean {
if (actual === null || actual.length === 0) return false;
const normalized = actual.toLowerCase();
@@ -1397,7 +1536,7 @@ async function readK8sCommit(config: UniDeskConfig, service: UniDeskMicroservice
async function readRuntimeState(config: UniDeskConfig, service: UniDeskMicroserviceConfig, desired: DeployManifestService): Promise<ServiceRuntimeState> {
const reason = unsupportedReason(service);
const health = coreInternalFetch(`/api/microservices/${encodeURIComponent(service.id)}/health`);
const health = await serviceHealth(config, service);
const healthBody = coreBody(health);
const healthCommit = healthDeployCommit(healthBody);
const healthRecord = asRecord(health);
@@ -1553,7 +1692,7 @@ async function applyOneService(config: UniDeskConfig, service: UniDeskMicroservi
if (!pushStep(steps, cleanup)) return { ok: false, serviceId: service.id, startedAt, finishedAt: nowIso(), resolvedCommit, before, steps };
}
} else {
const deploy = await step(config, service, "compose-up", composeDeployScript(service), targetIsMain(service) ? repoRoot : targetWorkDir(service), 180_000, !targetIsMain(service));
const deploy = await step(config, service, "compose-up", composeDeployScript(service, desired, resolvedCommit), targetIsMain(service) ? repoRoot : targetWorkDir(service), 180_000, !targetIsMain(service));
if (!pushStep(steps, deploy)) return { ok: false, serviceId: service.id, startedAt, finishedAt: nowIso(), resolvedCommit, before, steps };
const imageVerify = await step(config, service, "image-label-verify", imageLabelVerifyScript(service, resolvedCommit), targetIsMain(service) ? repoRoot : targetWorkDir(service), 60_000, false);
if (!pushStep(steps, imageVerify)) return { ok: false, serviceId: service.id, startedAt, finishedAt: nowIso(), resolvedCommit, before, steps };
@@ -1638,7 +1777,7 @@ export async function runDeployCommand(config: UniDeskConfig, args: string[]): P
if (!["check", "plan", "apply"].includes(actionRaw)) throw new Error("deploy command must be one of: check, plan, apply");
const action = actionRaw as DeployAction;
const options = parseOptions(args.slice(1));
const manifest = resolveManifestCommits(readDeployManifest(options.file), options.serviceId);
const manifest = resolveManifestCommits(await readDeployManifest(options.file), options.serviceId);
if (action === "check" || action === "plan") return await checkOrPlan(config, manifest, options, action);
if (!options.runNow) return applyJob(config, args, options);
return await runApplyNow(config, manifest, options);