feat: add decision center service and cli
This commit is contained in:
+154
-15
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user