feat: migrate todo note to nc01 github storage
This commit is contained in:
@@ -32,6 +32,7 @@ import { runServerCleanupCommand } from "./src/server-cleanup";
|
||||
import { runGcCommand } from "./src/gc";
|
||||
import { runPlatformDbCommand } from "./src/platform-db";
|
||||
import { runSecretsCommand } from "./src/secrets";
|
||||
import { readHostK8sPublicHost } from "./src/host-k8s-config";
|
||||
|
||||
const remoteOptions = extractRemoteCliOptions(process.argv.slice(2));
|
||||
const args = remoteOptions.args;
|
||||
@@ -470,6 +471,18 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
const config = readConfig();
|
||||
if (top === "microservice" && !args.includes("--check-path")) {
|
||||
const host = readHostK8sPublicHost();
|
||||
if (host !== null) {
|
||||
process.exitCode = await runRemoteCli({
|
||||
...remoteOptions,
|
||||
host,
|
||||
transport: "frontend",
|
||||
args,
|
||||
}, config);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const autoRemoteCiPublishPlan = autoRemoteCiPublishUserServiceDryRunPlan(config, args);
|
||||
if (autoRemoteCiPublishPlan.enabled && autoRemoteCiPublishPlan.host !== null) {
|
||||
process.exitCode = await runRemoteCli({
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
release_dir=${UNIDESK_HOST_RELEASE_DIR:-/workspace/release}
|
||||
source_dir=${UNIDESK_HOST_SOURCE_DIR:-/workspace/source}
|
||||
digest_file=${UNIDESK_HOST_DIGEST_FILE:-/workspace/image-digest-ref}
|
||||
metadata_file=${UNIDESK_HOST_BUILD_METADATA_FILE:-/workspace/build-metadata.json}
|
||||
build_log=${UNIDESK_HOST_BUILD_LOG:-/workspace/image-build.log}
|
||||
source_commit=${SOURCE_COMMIT:?SOURCE_COMMIT is required}
|
||||
|
||||
enabled=$(cat "$release_dir/enabled")
|
||||
if [ "$enabled" != true ]; then
|
||||
: >"$digest_file"
|
||||
: >"$metadata_file"
|
||||
printf '{"ok":true,"phase":"image-build","status":"skipped","reason":"delivery-disabled","sourceCommit":"%s","valuesPrinted":false}\n' "$source_commit"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
image_repository=$(cat "$release_dir/image-repository")
|
||||
dockerfile=$(cat "$release_dir/dockerfile")
|
||||
network_mode=$(cat "$release_dir/network-mode")
|
||||
http_proxy=$(cat "$release_dir/http-proxy")
|
||||
https_proxy=$(cat "$release_dir/https-proxy")
|
||||
all_proxy=$(cat "$release_dir/all-proxy")
|
||||
no_proxy=$(cat "$release_dir/no-proxy")
|
||||
image_ref="$image_repository:$(printf '%s' "$source_commit" | cut -c1-12)"
|
||||
|
||||
rm -f "$metadata_file" "$build_log" "$digest_file"
|
||||
env HTTP_PROXY="$http_proxy" HTTPS_PROXY="$https_proxy" ALL_PROXY="$all_proxy" NO_PROXY="$no_proxy" \
|
||||
http_proxy="$http_proxy" https_proxy="$https_proxy" all_proxy="$all_proxy" no_proxy="$no_proxy" \
|
||||
buildctl-daemonless.sh build \
|
||||
--allow network.host \
|
||||
--frontend dockerfile.v0 \
|
||||
--local context="$source_dir" \
|
||||
--local dockerfile="$source_dir" \
|
||||
--opt "filename=$dockerfile" \
|
||||
--opt "network=$network_mode" \
|
||||
--opt "build-arg:HTTP_PROXY=$http_proxy" \
|
||||
--opt "build-arg:HTTPS_PROXY=$https_proxy" \
|
||||
--opt "build-arg:ALL_PROXY=$all_proxy" \
|
||||
--opt "build-arg:NO_PROXY=$no_proxy" \
|
||||
--metadata-file "$metadata_file" \
|
||||
--output "type=image,name=$image_ref,push=true,registry.insecure=true" >"$build_log" 2>&1
|
||||
cat "$build_log"
|
||||
test -s "$metadata_file"
|
||||
printf '%s\n' "$image_ref" >"$digest_file"
|
||||
printf '{"ok":true,"phase":"image-build","status":"built","sourceCommit":"%s","imageRef":"%s","metadataReady":true,"valuesPrinted":false}\n' "$source_commit" "$image_ref"
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env bun
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
function option(name) {
|
||||
const index = process.argv.indexOf(name);
|
||||
if (index === -1) return null;
|
||||
const value = process.argv[index + 1];
|
||||
if (value === undefined || value.length === 0 || value.startsWith("--")) throw new Error(`${name} requires a value`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function record(value, path) {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function required(value, path) {
|
||||
if (typeof value !== "string" || value.length === 0 || value.includes("\n")) throw new Error(`${path} must be a non-empty single-line string`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function valueAt(root, reference) {
|
||||
return reference.split(".").reduce((value, key) => record(value, reference)[key], root);
|
||||
}
|
||||
|
||||
function writeValue(outputDir, name, value) {
|
||||
writeFileSync(resolve(outputDir, name), `${value}\n`, { encoding: "utf8", mode: 0o644 });
|
||||
}
|
||||
|
||||
function main() {
|
||||
const configPath = option("--config") ?? "config/unidesk-host-k8s.yaml";
|
||||
const outputDir = resolve(required(option("--output-dir"), "--output-dir"));
|
||||
const config = record(Bun.YAML.parse(readFileSync(configPath, "utf8")), configPath);
|
||||
const delivery = record(config.delivery, "delivery");
|
||||
const serviceRef = required(delivery.serviceRef, "delivery.serviceRef");
|
||||
const service = record(valueAt(config, serviceRef), serviceRef);
|
||||
const build = record(delivery.build, "delivery.build");
|
||||
const proxy = record(build.proxy, "delivery.build.proxy");
|
||||
const image = record(delivery.image, "delivery.image");
|
||||
const enabled = delivery.enabled === true;
|
||||
if (delivery.enabled !== true && delivery.enabled !== false) throw new Error("delivery.enabled must be boolean");
|
||||
const noProxy = proxy.noProxy;
|
||||
if (!Array.isArray(noProxy) || noProxy.some((value) => typeof value !== "string" || value.length === 0)) throw new Error("delivery.build.proxy.noProxy must be a non-empty string array");
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
writeValue(outputDir, "enabled", enabled ? "true" : "false");
|
||||
writeValue(outputDir, "dockerfile", required(record(service.repository, `${serviceRef}.repository`).dockerfile, `${serviceRef}.repository.dockerfile`));
|
||||
writeValue(outputDir, "image-repository", required(image.repository, "delivery.image.repository"));
|
||||
writeValue(outputDir, "network-mode", required(build.networkMode, "delivery.build.networkMode"));
|
||||
writeValue(outputDir, "http-proxy", required(proxy.http, "delivery.build.proxy.http"));
|
||||
writeValue(outputDir, "https-proxy", required(proxy.https, "delivery.build.proxy.https"));
|
||||
writeValue(outputDir, "all-proxy", required(proxy.all, "delivery.build.proxy.all"));
|
||||
writeValue(outputDir, "no-proxy", noProxy.join(","));
|
||||
process.stdout.write(`${JSON.stringify({ ok: true, action: "unidesk-host-release-prepare", enabled, serviceRef, valuesPrinted: false })}\n`);
|
||||
}
|
||||
|
||||
if (!process.execArgv.includes("--check")) main();
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env bun
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
function option(name) {
|
||||
const index = process.argv.indexOf(name);
|
||||
if (index === -1) return null;
|
||||
const value = process.argv[index + 1];
|
||||
if (value === undefined || value.length === 0 || value.startsWith("--")) throw new Error(`${name} requires a value`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function record(value, path) {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function required(value, path) {
|
||||
if (typeof value !== "string" || value.length === 0 || value.includes("\n")) throw new Error(`${path} must be a non-empty single-line string`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function run(command, args, cwd, allowFailure = false) {
|
||||
const result = spawnSync(command, args, { cwd, encoding: "utf8", maxBuffer: 4 * 1024 * 1024 });
|
||||
if (result.error !== undefined) throw result.error;
|
||||
if (result.status !== 0 && !allowFailure) {
|
||||
const detail = `${result.stderr || result.stdout || "command failed"}`.trim().slice(-2000);
|
||||
throw new Error(`${command} ${args.join(" ")} failed (${result.status}): ${detail}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function safeManifestPath(value) {
|
||||
const path = required(value, "delivery.gitops.manifestPath");
|
||||
if (path.startsWith("/") || path.split("/").includes("..")) throw new Error("delivery.gitops.manifestPath must be a safe relative path");
|
||||
return path;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const configPath = resolve(option("--config") ?? "config/unidesk-host-k8s.yaml");
|
||||
const sourceRoot = resolve(option("--source-root") ?? process.cwd());
|
||||
const metadataPath = resolve(required(option("--metadata"), "--metadata"));
|
||||
const sourceCommit = required(option("--source-commit"), "--source-commit");
|
||||
const worktree = resolve(option("--worktree") ?? "/workspace/unidesk-host-gitops");
|
||||
if (!/^[0-9a-f]{40}$/u.test(sourceCommit)) throw new Error("--source-commit must be a full Git commit SHA");
|
||||
|
||||
const config = record(Bun.YAML.parse(readFileSync(configPath, "utf8")), configPath);
|
||||
const delivery = record(config.delivery, "delivery");
|
||||
if (delivery.enabled !== true && delivery.enabled !== false) throw new Error("delivery.enabled must be boolean");
|
||||
const enabled = delivery.enabled === true;
|
||||
const serviceRef = required(delivery.serviceRef, "delivery.serviceRef");
|
||||
const imageConfig = record(delivery.image, "delivery.image");
|
||||
const gitops = record(delivery.gitops, "delivery.gitops");
|
||||
const author = record(gitops.author, "delivery.gitops.author");
|
||||
const readUrl = required(gitops.readUrl, "delivery.gitops.readUrl");
|
||||
const writeUrl = required(gitops.writeUrl, "delivery.gitops.writeUrl");
|
||||
const branch = required(gitops.branch, "delivery.gitops.branch");
|
||||
const manifestPath = safeManifestPath(gitops.manifestPath);
|
||||
let digest = null;
|
||||
let digestRef = null;
|
||||
let manifest = null;
|
||||
|
||||
if (enabled) {
|
||||
const metadata = record(JSON.parse(readFileSync(metadataPath, "utf8")), metadataPath);
|
||||
digest = required(metadata["containerimage.digest"], `${metadataPath}.containerimage.digest`);
|
||||
if (!/^sha256:[0-9a-f]{64}$/u.test(digest)) throw new Error("containerimage.digest must be sha256:<64 hex>");
|
||||
digestRef = `${required(imageConfig.repository, "delivery.image.repository")}@${digest}`;
|
||||
const render = run("bun", [
|
||||
"scripts/native/deploy/render-unidesk-host-service.mjs",
|
||||
"--config",
|
||||
configPath,
|
||||
"--service-ref",
|
||||
serviceRef,
|
||||
"--image",
|
||||
digestRef,
|
||||
"--source-commit",
|
||||
sourceCommit,
|
||||
], sourceRoot);
|
||||
manifest = render.stdout;
|
||||
if (!manifest.includes("kind: Deployment") || !manifest.includes("kind: Service")) throw new Error("service renderer did not emit Service and Deployment objects");
|
||||
}
|
||||
|
||||
rmSync(worktree, { recursive: true, force: true });
|
||||
run("git", ["clone", "--no-checkout", readUrl, worktree], sourceRoot);
|
||||
const fetched = run("git", ["fetch", "origin", branch], worktree, true).status === 0;
|
||||
if (fetched) {
|
||||
run("git", ["checkout", "-B", branch, `origin/${branch}`], worktree);
|
||||
} else {
|
||||
run("git", ["checkout", "--orphan", branch], worktree);
|
||||
run("git", ["rm", "-rf", "."], worktree, true);
|
||||
}
|
||||
|
||||
const targetPath = resolve(worktree, manifestPath);
|
||||
if (!targetPath.startsWith(`${worktree}/`)) throw new Error("resolved manifest path escaped the GitOps worktree");
|
||||
if (enabled && manifest !== null) {
|
||||
mkdirSync(dirname(targetPath), { recursive: true });
|
||||
writeFileSync(targetPath, manifest, "utf8");
|
||||
} else {
|
||||
try {
|
||||
unlinkSync(targetPath);
|
||||
} catch (error) {
|
||||
if (error?.code !== "ENOENT") throw error;
|
||||
}
|
||||
}
|
||||
|
||||
run("git", ["config", "user.name", required(author.name, "delivery.gitops.author.name")], worktree);
|
||||
run("git", ["config", "user.email", required(author.email, "delivery.gitops.author.email")], worktree);
|
||||
run("git", ["add", "-A"], worktree);
|
||||
const changed = run("git", ["diff", "--cached", "--quiet"], worktree, true).status !== 0;
|
||||
if (changed) {
|
||||
const action = enabled ? "deploy" : "remove";
|
||||
run("git", ["commit", "-m", `unidesk-host: ${action} ${serviceRef} ${sourceCommit.slice(0, 12)}`], worktree);
|
||||
}
|
||||
|
||||
run("git", ["remote", "set-url", "origin", writeUrl], worktree);
|
||||
let pushAttempts = 0;
|
||||
if (changed) {
|
||||
let pushed = false;
|
||||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||
pushAttempts = attempt;
|
||||
const push = run("git", ["push", "origin", `HEAD:refs/heads/${branch}`], worktree, true);
|
||||
if (push.status === 0) {
|
||||
pushed = true;
|
||||
break;
|
||||
}
|
||||
run("git", ["fetch", "origin", branch], worktree);
|
||||
const rebase = run("git", ["rebase", `origin/${branch}`], worktree, true);
|
||||
if (rebase.status !== 0) {
|
||||
run("git", ["rebase", "--abort"], worktree, true);
|
||||
throw new Error(`GitOps rebase failed after push attempt ${attempt}`);
|
||||
}
|
||||
}
|
||||
if (!pushed) throw new Error("GitOps push failed after 3 attempts");
|
||||
}
|
||||
|
||||
const head = run("git", ["rev-parse", "HEAD"], worktree, !enabled && !changed);
|
||||
const gitopsCommit = head.status === 0 ? head.stdout.trim() : null;
|
||||
process.stdout.write(`${JSON.stringify({
|
||||
ok: true,
|
||||
phase: "gitops-publish",
|
||||
status: enabled ? (changed ? "built" : "reused") : "disabled",
|
||||
imageStatus: enabled ? (changed ? "built" : "reused") : "disabled",
|
||||
sourceCommit,
|
||||
digest,
|
||||
digestRef,
|
||||
gitopsCommit,
|
||||
changed,
|
||||
pushAttempts,
|
||||
valuesPrinted: false,
|
||||
})}\n`);
|
||||
}
|
||||
|
||||
if (!process.execArgv.includes("--check")) main();
|
||||
@@ -122,12 +122,14 @@ const configPath = process.argv[2] ?? "config/unidesk-host-k8s.yaml";
|
||||
const config = Bun.YAML.parse(readFileSync(configPath, "utf8"));
|
||||
const target = record(config.target, "target");
|
||||
const runtime = record(config.runtime, "runtime");
|
||||
const delivery = config.delivery === undefined ? null : record(config.delivery, "delivery");
|
||||
const images = record(config.images, "images");
|
||||
const database = record(config.database, "database");
|
||||
const services = record(config.services, "services");
|
||||
const backend = record(services.backendCore, "services.backendCore");
|
||||
const frontend = record(services.frontend, "services.frontend");
|
||||
const decisionCenter = optionalRecord(services.decisionCenter, "services.decisionCenter");
|
||||
const todoNote = optionalRecord(services.todoNote, "services.todoNote");
|
||||
const runtimeConfig = record(config.runtimeConfig, "runtimeConfig");
|
||||
const runtimeAuth = record(config.runtimeAuth, "runtimeAuth");
|
||||
const runtimeAuthSourceRef = record(runtimeAuth.sourceRef, "runtimeAuth.sourceRef");
|
||||
@@ -152,6 +154,10 @@ for (const key of requiredSecretKeys) {
|
||||
}
|
||||
const secretFingerprint = sha(requiredSecretKeys.map((key) => `${key}=${secrets[key]}`).join("\n"));
|
||||
const runtimeConfigName = "unidesk-runtime-config";
|
||||
const deliveryServiceRef = delivery === null ? null : required(delivery.serviceRef, "delivery.serviceRef");
|
||||
if (delivery !== null && delivery.enabled !== true && delivery.enabled !== false) {
|
||||
throw new Error("delivery.enabled must be boolean");
|
||||
}
|
||||
const databaseMode = database.mode === undefined ? "k8s-postgres" : required(database.mode, "database.mode");
|
||||
if (databaseMode !== "k8s-postgres" && databaseMode !== "host-native") throw new Error("database.mode must be k8s-postgres or host-native");
|
||||
const dbService = required(database.serviceName, "database.serviceName");
|
||||
@@ -162,21 +168,21 @@ if (secrets[secretKeys.databaseUrl] !== `postgres://${secrets[secretKeys.postgre
|
||||
throw new Error(`DATABASE_URL in ${sourcePath} must target ${dbHost}:${dbPort}/${dbName}`);
|
||||
}
|
||||
|
||||
function readDecisionCenterDatabase(decision) {
|
||||
if (decision === null) return null;
|
||||
const database = record(decision.database, "services.decisionCenter.database");
|
||||
const sourceRef = record(database.sourceRef, "services.decisionCenter.database.sourceRef");
|
||||
const sourcePath = resolve(required(sourceRef.path, "services.decisionCenter.database.sourceRef.path"));
|
||||
const sourceKey = required(sourceRef.key, "services.decisionCenter.database.sourceRef.key");
|
||||
function readServiceDatabase(service, servicePath) {
|
||||
if (service === null) return null;
|
||||
const database = record(service.database, `${servicePath}.database`);
|
||||
const sourceRef = record(database.sourceRef, `${servicePath}.database.sourceRef`);
|
||||
const sourcePath = resolve(required(sourceRef.path, `${servicePath}.database.sourceRef.path`));
|
||||
const sourceKey = required(sourceRef.key, `${servicePath}.database.sourceRef.key`);
|
||||
const sourceValues = readEnvFile(sourcePath);
|
||||
const value = sourceValues[sourceKey];
|
||||
if (typeof value !== "string" || value.length === 0) throw new Error(`missing ${sourceKey} in ${sourcePath}`);
|
||||
return {
|
||||
sourcePath,
|
||||
sourceRefPath: required(sourceRef.path, "services.decisionCenter.database.sourceRef.path"),
|
||||
sourceRefPath: required(sourceRef.path, `${servicePath}.database.sourceRef.path`),
|
||||
sourceKey,
|
||||
secretName: required(database.secretName, "services.decisionCenter.database.secretName"),
|
||||
secretKey: required(database.secretKey, "services.decisionCenter.database.secretKey"),
|
||||
secretName: required(database.secretName, `${servicePath}.database.secretName`),
|
||||
secretKey: required(database.secretKey, `${servicePath}.database.secretKey`),
|
||||
value: removeUrlSearchParam(value, "uselibpqcompat"),
|
||||
fingerprint: sha(`${sourceKey}=${removeUrlSearchParam(value, "uselibpqcompat")}`),
|
||||
};
|
||||
@@ -194,37 +200,37 @@ function readFileSource(sourceRef, path) {
|
||||
};
|
||||
}
|
||||
|
||||
function readDecisionCenterStorage(decision) {
|
||||
if (decision === null) return null;
|
||||
const storage = optionalRecord(decision.storage, "services.decisionCenter.storage");
|
||||
function readServiceStorage(service, servicePath) {
|
||||
if (service === null) return null;
|
||||
const storage = optionalRecord(service.storage, `${servicePath}.storage`);
|
||||
if (storage === null) return null;
|
||||
const primary = required(storage.primary, "services.decisionCenter.storage.primary");
|
||||
if (primary !== "postgres" && primary !== "github-repo") throw new Error("services.decisionCenter.storage.primary must be postgres or github-repo");
|
||||
const github = primary === "github-repo" ? record(storage.github, "services.decisionCenter.storage.github") : null;
|
||||
const primary = required(storage.primary, `${servicePath}.storage.primary`);
|
||||
if (primary !== "postgres" && primary !== "github-repo") throw new Error(`${servicePath}.storage.primary must be postgres or github-repo`);
|
||||
const github = primary === "github-repo" ? record(storage.github, `${servicePath}.storage.github`) : null;
|
||||
if (github === null) return { primary };
|
||||
const ssh = record(github.ssh, "services.decisionCenter.storage.github.ssh");
|
||||
const privateKey = record(ssh.privateKey, "services.decisionCenter.storage.github.ssh.privateKey");
|
||||
const knownHosts = record(ssh.knownHosts, "services.decisionCenter.storage.github.ssh.knownHosts");
|
||||
const privateKeySource = readFileSource(record(privateKey.sourceRef, "services.decisionCenter.storage.github.ssh.privateKey.sourceRef"), "services.decisionCenter.storage.github.ssh.privateKey.sourceRef");
|
||||
const knownHostsSource = readFileSource(record(knownHosts.sourceRef, "services.decisionCenter.storage.github.ssh.knownHosts.sourceRef"), "services.decisionCenter.storage.github.ssh.knownHosts.sourceRef");
|
||||
const ssh = record(github.ssh, `${servicePath}.storage.github.ssh`);
|
||||
const privateKey = record(ssh.privateKey, `${servicePath}.storage.github.ssh.privateKey`);
|
||||
const knownHosts = record(ssh.knownHosts, `${servicePath}.storage.github.ssh.knownHosts`);
|
||||
const privateKeySource = readFileSource(record(privateKey.sourceRef, `${servicePath}.storage.github.ssh.privateKey.sourceRef`), `${servicePath}.storage.github.ssh.privateKey.sourceRef`);
|
||||
const knownHostsSource = readFileSource(record(knownHosts.sourceRef, `${servicePath}.storage.github.ssh.knownHosts.sourceRef`), `${servicePath}.storage.github.ssh.knownHosts.sourceRef`);
|
||||
return {
|
||||
primary,
|
||||
github: {
|
||||
repo: required(github.repo, "services.decisionCenter.storage.github.repo"),
|
||||
sshUrl: required(github.sshUrl, "services.decisionCenter.storage.github.sshUrl"),
|
||||
branch: required(github.branch, "services.decisionCenter.storage.github.branch"),
|
||||
basePath: required(github.basePath, "services.decisionCenter.storage.github.basePath"),
|
||||
worktreePath: required(github.worktreePath, "services.decisionCenter.storage.github.worktreePath"),
|
||||
commitMessagePrefix: required(github.commitMessagePrefix, "services.decisionCenter.storage.github.commitMessagePrefix"),
|
||||
repo: required(github.repo, `${servicePath}.storage.github.repo`),
|
||||
sshUrl: required(github.sshUrl, `${servicePath}.storage.github.sshUrl`),
|
||||
branch: required(github.branch, `${servicePath}.storage.github.branch`),
|
||||
basePath: required(github.basePath, `${servicePath}.storage.github.basePath`),
|
||||
worktreePath: required(github.worktreePath, `${servicePath}.storage.github.worktreePath`),
|
||||
commitMessagePrefix: required(github.commitMessagePrefix, `${servicePath}.storage.github.commitMessagePrefix`),
|
||||
author: {
|
||||
name: required(record(github.author, "services.decisionCenter.storage.github.author").name, "services.decisionCenter.storage.github.author.name"),
|
||||
email: required(record(github.author, "services.decisionCenter.storage.github.author").email, "services.decisionCenter.storage.github.author.email"),
|
||||
name: required(record(github.author, `${servicePath}.storage.github.author`).name, `${servicePath}.storage.github.author.name`),
|
||||
email: required(record(github.author, `${servicePath}.storage.github.author`).email, `${servicePath}.storage.github.author.email`),
|
||||
},
|
||||
ssh: {
|
||||
secretName: required(ssh.secretName, "services.decisionCenter.storage.github.ssh.secretName"),
|
||||
mountPath: required(ssh.mountPath, "services.decisionCenter.storage.github.ssh.mountPath"),
|
||||
privateKeyKey: required(privateKey.secretKey, "services.decisionCenter.storage.github.ssh.privateKey.secretKey"),
|
||||
knownHostsKey: required(knownHosts.secretKey, "services.decisionCenter.storage.github.ssh.knownHosts.secretKey"),
|
||||
secretName: required(ssh.secretName, `${servicePath}.storage.github.ssh.secretName`),
|
||||
mountPath: required(ssh.mountPath, `${servicePath}.storage.github.ssh.mountPath`),
|
||||
privateKeyKey: required(privateKey.secretKey, `${servicePath}.storage.github.ssh.privateKey.secretKey`),
|
||||
knownHostsKey: required(knownHosts.secretKey, `${servicePath}.storage.github.ssh.knownHosts.secretKey`),
|
||||
privateKeySource,
|
||||
knownHostsSource,
|
||||
fingerprint: sha(`${privateKeySource.fingerprint}\n${knownHostsSource.fingerprint}`),
|
||||
@@ -233,53 +239,65 @@ function readDecisionCenterStorage(decision) {
|
||||
};
|
||||
}
|
||||
|
||||
const decisionCenterDatabase = readDecisionCenterDatabase(decisionCenter);
|
||||
const decisionCenterStorage = readDecisionCenterStorage(decisionCenter);
|
||||
const effectiveMicroservicesJson = decisionCenter === null
|
||||
? required(runtimeConfig.microservicesJson, "runtimeConfig.microservicesJson")
|
||||
: JSON.stringify([
|
||||
{
|
||||
id: "decision-center",
|
||||
name: "Decision Center",
|
||||
providerId: required(target.id, "target.id"),
|
||||
description: "Decision Center is managed by the NC01 YAML-first k8s runtime for UniDesk decisions, requirements, meetings, and work diaries.",
|
||||
repository: {
|
||||
url: required(record(decisionCenter.repository, "services.decisionCenter.repository").url, "services.decisionCenter.repository.url"),
|
||||
commitId: required(runtime.commit, "runtime.commit"),
|
||||
dockerfile: required(record(decisionCenter.repository, "services.decisionCenter.repository").dockerfile, "services.decisionCenter.repository.dockerfile"),
|
||||
composeFile: required(runtime.deployRef, "runtime.deployRef"),
|
||||
composeService: required(decisionCenter.deploymentName, "services.decisionCenter.deploymentName"),
|
||||
containerName: required(decisionCenter.containerName, "services.decisionCenter.containerName"),
|
||||
},
|
||||
backend: {
|
||||
nodeBaseUrl: `http://${required(decisionCenter.serviceName, "services.decisionCenter.serviceName")}.${namespace}.svc.cluster.local:${required(String(decisionCenter.containerPort), "services.decisionCenter.containerPort")}`,
|
||||
nodeBindHost: `${required(decisionCenter.serviceName, "services.decisionCenter.serviceName")}.${namespace}.svc.cluster.local`,
|
||||
nodePort: positiveInteger(decisionCenter.containerPort, "services.decisionCenter.containerPort"),
|
||||
proxyMode: "cluster-service-http",
|
||||
frontendOnly: true,
|
||||
public: false,
|
||||
allowedMethods: ["GET", "HEAD", "POST", "PUT", "DELETE"],
|
||||
allowedPathPrefixes: ["/health", "/live", "/logs", "/api/"],
|
||||
healthPath: required(decisionCenter.healthPath, "services.decisionCenter.healthPath"),
|
||||
timeoutMs: 30000,
|
||||
},
|
||||
development: {
|
||||
providerId: required(target.id, "target.id"),
|
||||
sshPassthrough: true,
|
||||
worktreePath: required(record(decisionCenter.repository, "services.decisionCenter.repository").worktreePath, "services.decisionCenter.repository.worktreePath"),
|
||||
},
|
||||
frontend: {
|
||||
route: required(record(decisionCenter.frontend, "services.decisionCenter.frontend").route, "services.decisionCenter.frontend.route"),
|
||||
integrated: record(decisionCenter.frontend, "services.decisionCenter.frontend").integrated !== false,
|
||||
},
|
||||
deployment: {
|
||||
mode: "internal-sidecar",
|
||||
namespace,
|
||||
expectedNodeIds: [required(target.id, "target.id")],
|
||||
activeNodeId: required(target.id, "target.id"),
|
||||
},
|
||||
},
|
||||
]);
|
||||
function managedMicroservice(service, servicePath, id, name, description) {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
providerId: required(target.id, "target.id"),
|
||||
description,
|
||||
repository: {
|
||||
url: required(record(service.repository, `${servicePath}.repository`).url, `${servicePath}.repository.url`),
|
||||
commitId: required(runtime.commit, "runtime.commit"),
|
||||
dockerfile: required(record(service.repository, `${servicePath}.repository`).dockerfile, `${servicePath}.repository.dockerfile`),
|
||||
composeFile: required(runtime.deployRef, "runtime.deployRef"),
|
||||
composeService: required(service.deploymentName, `${servicePath}.deploymentName`),
|
||||
containerName: required(service.containerName, `${servicePath}.containerName`),
|
||||
},
|
||||
backend: {
|
||||
nodeBaseUrl: `http://${required(service.serviceName, `${servicePath}.serviceName`)}.${namespace}.svc.cluster.local:${required(String(service.containerPort), `${servicePath}.containerPort`)}`,
|
||||
nodeBindHost: `${required(service.serviceName, `${servicePath}.serviceName`)}.${namespace}.svc.cluster.local`,
|
||||
nodePort: positiveInteger(service.containerPort, `${servicePath}.containerPort`),
|
||||
proxyMode: "cluster-service-http",
|
||||
frontendOnly: true,
|
||||
public: false,
|
||||
allowedMethods: ["GET", "HEAD", "POST", "PUT", "DELETE"],
|
||||
allowedPathPrefixes: ["/health", "/live", "/logs", "/api/"],
|
||||
healthPath: required(service.healthPath, `${servicePath}.healthPath`),
|
||||
timeoutMs: 30000,
|
||||
},
|
||||
development: {
|
||||
providerId: required(target.id, "target.id"),
|
||||
sshPassthrough: true,
|
||||
worktreePath: required(record(service.repository, `${servicePath}.repository`).worktreePath, `${servicePath}.repository.worktreePath`),
|
||||
},
|
||||
frontend: {
|
||||
route: required(record(service.frontend, `${servicePath}.frontend`).route, `${servicePath}.frontend.route`),
|
||||
integrated: record(service.frontend, `${servicePath}.frontend`).integrated !== false,
|
||||
},
|
||||
deployment: {
|
||||
mode: "internal-sidecar",
|
||||
namespace,
|
||||
expectedNodeIds: [required(target.id, "target.id")],
|
||||
activeNodeId: required(target.id, "target.id"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const decisionCenterDatabase = readServiceDatabase(decisionCenter, "services.decisionCenter");
|
||||
const decisionCenterStorage = readServiceStorage(decisionCenter, "services.decisionCenter");
|
||||
const todoNoteDatabase = readServiceDatabase(todoNote, "services.todoNote");
|
||||
const todoNoteStorage = readServiceStorage(todoNote, "services.todoNote");
|
||||
const configuredMicroservices = JSON.parse(required(runtimeConfig.microservicesJson, "runtimeConfig.microservicesJson"));
|
||||
if (!Array.isArray(configuredMicroservices)) throw new Error("runtimeConfig.microservicesJson must encode an array");
|
||||
const managedMicroservices = [
|
||||
...(decisionCenter === null ? [] : [managedMicroservice(decisionCenter, "services.decisionCenter", "decision-center", "Decision Center", "NC01 YAML-first Decision Center for decisions, requirements, meetings, and work diaries.")]),
|
||||
...(todoNote === null ? [] : [managedMicroservice(todoNote, "services.todoNote", "todo-note", "Todo Note", "NC01 YAML-first Todo Note backed by the shared decision-center-data GitHub repository.")]),
|
||||
];
|
||||
const managedIds = new Set(managedMicroservices.map((service) => service.id));
|
||||
const effectiveMicroservicesJson = JSON.stringify([
|
||||
...configuredMicroservices.filter((service) => typeof service !== "object" || service === null || !managedIds.has(service.id)),
|
||||
...managedMicroservices,
|
||||
]);
|
||||
const runtimeConfigFingerprint = sha(JSON.stringify({ ...runtimeConfig, microservicesJson: effectiveMicroservicesJson }));
|
||||
|
||||
const docs = [];
|
||||
@@ -331,39 +349,60 @@ data:
|
||||
MICROSERVICES_JSON: ${yamlScalar(effectiveMicroservicesJson)}
|
||||
NO_PROXY: ${yamlScalar(required(runtime.noProxy, "runtime.noProxy"))}
|
||||
no_proxy: ${yamlScalar(required(runtime.noProxy, "runtime.noProxy"))}`);
|
||||
if (decisionCenter !== null && decisionCenterDatabase !== null) {
|
||||
docs.push(`apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: ${decisionCenterDatabase.secretName}
|
||||
namespace: ${namespace}
|
||||
labels:
|
||||
${indent(labels(config), 4)}
|
||||
app.kubernetes.io/name: decision-center
|
||||
annotations:
|
||||
unidesk.ai/source-ref: ${yamlScalar(decisionCenterDatabase.sourceRefPath)}
|
||||
unidesk.ai/fingerprint: ${yamlScalar(decisionCenterDatabase.fingerprint)}
|
||||
type: Opaque
|
||||
stringData:
|
||||
${decisionCenterDatabase.secretKey}: ${yamlScalar(decisionCenterDatabase.value)}`);
|
||||
const serviceDatabaseSecrets = new Map();
|
||||
for (const databaseSecret of [decisionCenterDatabase, todoNoteDatabase].filter(Boolean)) {
|
||||
const existing = serviceDatabaseSecrets.get(databaseSecret.secretName);
|
||||
if (existing !== undefined && (existing.secretKey !== databaseSecret.secretKey || existing.value !== databaseSecret.value)) {
|
||||
throw new Error(`conflicting managed service database Secret: ${databaseSecret.secretName}`);
|
||||
}
|
||||
serviceDatabaseSecrets.set(databaseSecret.secretName, databaseSecret);
|
||||
}
|
||||
if (decisionCenterStorage?.github !== undefined) {
|
||||
docs.push(`apiVersion: v1
|
||||
for (const databaseSecret of serviceDatabaseSecrets.values()) {
|
||||
docs.push(`apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: ${decisionCenterStorage.github.ssh.secretName}
|
||||
name: ${databaseSecret.secretName}
|
||||
namespace: ${namespace}
|
||||
labels:
|
||||
${indent(labels(config), 4)}
|
||||
app.kubernetes.io/name: decision-center
|
||||
annotations:
|
||||
unidesk.ai/private-key-source-ref: ${yamlScalar(decisionCenterStorage.github.ssh.privateKeySource.sourceRefPath)}
|
||||
unidesk.ai/known-hosts-source-ref: ${yamlScalar(decisionCenterStorage.github.ssh.knownHostsSource.sourceRefPath)}
|
||||
unidesk.ai/fingerprint: ${yamlScalar(decisionCenterStorage.github.ssh.fingerprint)}
|
||||
unidesk.ai/source-ref: ${yamlScalar(databaseSecret.sourceRefPath)}
|
||||
unidesk.ai/fingerprint: ${yamlScalar(databaseSecret.fingerprint)}
|
||||
type: Opaque
|
||||
stringData:
|
||||
${decisionCenterStorage.github.ssh.privateKeyKey}: ${yamlScalar(decisionCenterStorage.github.ssh.privateKeySource.value)}
|
||||
${decisionCenterStorage.github.ssh.knownHostsKey}: ${yamlScalar(decisionCenterStorage.github.ssh.knownHostsSource.value)}`);
|
||||
${databaseSecret.secretKey}: ${yamlScalar(databaseSecret.value)}`);
|
||||
}
|
||||
const serviceGitSecrets = new Map();
|
||||
for (const storage of [decisionCenterStorage, todoNoteStorage]) {
|
||||
if (storage?.github === undefined) continue;
|
||||
const gitSecret = storage.github.ssh;
|
||||
const existing = serviceGitSecrets.get(gitSecret.secretName);
|
||||
if (existing !== undefined && (
|
||||
existing.privateKeyKey !== gitSecret.privateKeyKey
|
||||
|| existing.knownHostsKey !== gitSecret.knownHostsKey
|
||||
|| existing.privateKeySource.value !== gitSecret.privateKeySource.value
|
||||
|| existing.knownHostsSource.value !== gitSecret.knownHostsSource.value
|
||||
)) {
|
||||
throw new Error(`conflicting managed service Git SSH Secret: ${gitSecret.secretName}`);
|
||||
}
|
||||
serviceGitSecrets.set(gitSecret.secretName, gitSecret);
|
||||
}
|
||||
for (const gitSecret of serviceGitSecrets.values()) {
|
||||
docs.push(`apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: ${gitSecret.secretName}
|
||||
namespace: ${namespace}
|
||||
labels:
|
||||
${indent(labels(config), 4)}
|
||||
annotations:
|
||||
unidesk.ai/private-key-source-ref: ${yamlScalar(gitSecret.privateKeySource.sourceRefPath)}
|
||||
unidesk.ai/known-hosts-source-ref: ${yamlScalar(gitSecret.knownHostsSource.sourceRefPath)}
|
||||
unidesk.ai/fingerprint: ${yamlScalar(gitSecret.fingerprint)}
|
||||
type: Opaque
|
||||
stringData:
|
||||
${gitSecret.privateKeyKey}: ${yamlScalar(gitSecret.privateKeySource.value)}
|
||||
${gitSecret.knownHostsKey}: ${yamlScalar(gitSecret.knownHostsSource.value)}`);
|
||||
}
|
||||
if (databaseMode === "k8s-postgres") {
|
||||
docs.push(`apiVersion: v1
|
||||
@@ -585,44 +624,77 @@ ${indent(resourceBlock(backend.resources), 10)}
|
||||
volumes:
|
||||
- name: logs
|
||||
emptyDir: {}`);
|
||||
if (decisionCenter !== null) {
|
||||
function managedStorageEnv(envPrefix, storage) {
|
||||
const lines = [
|
||||
`- name: ${envPrefix}_STORAGE_PRIMARY`,
|
||||
` value: ${yamlScalar(storage?.primary ?? "postgres")}`,
|
||||
];
|
||||
if (storage?.github === undefined) return lines.join("\n");
|
||||
lines.push(
|
||||
`- name: ${envPrefix}_GIT_REPO`,
|
||||
` value: ${yamlScalar(storage.github.repo)}`,
|
||||
`- name: ${envPrefix}_GIT_REPO_SSH_URL`,
|
||||
` value: ${yamlScalar(storage.github.sshUrl)}`,
|
||||
`- name: ${envPrefix}_GIT_BRANCH`,
|
||||
` value: ${yamlScalar(storage.github.branch)}`,
|
||||
`- name: ${envPrefix}_GIT_BASE_PATH`,
|
||||
` value: ${yamlScalar(storage.github.basePath)}`,
|
||||
`- name: ${envPrefix}_GIT_WORKTREE`,
|
||||
` value: ${yamlScalar(storage.github.worktreePath)}`,
|
||||
`- name: ${envPrefix}_GIT_AUTHOR_NAME`,
|
||||
` value: ${yamlScalar(storage.github.author.name)}`,
|
||||
`- name: ${envPrefix}_GIT_AUTHOR_EMAIL`,
|
||||
` value: ${yamlScalar(storage.github.author.email)}`,
|
||||
`- name: ${envPrefix}_GIT_COMMIT_PREFIX`,
|
||||
` value: ${yamlScalar(storage.github.commitMessagePrefix)}`,
|
||||
"- name: GIT_SSH_COMMAND",
|
||||
` value: ${yamlScalar(`ssh -i ${storage.github.ssh.mountPath}/${storage.github.ssh.privateKeyKey} -o UserKnownHostsFile=${storage.github.ssh.mountPath}/${storage.github.ssh.knownHostsKey} -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes`)}`,
|
||||
);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function appendManagedServiceDeployment({ service, servicePath, serviceId, imageKey, envPrefix, databaseConfig, storageConfig }) {
|
||||
if (service === null) return;
|
||||
if (deliveryServiceRef === servicePath) return;
|
||||
if (databaseConfig === null) throw new Error(`${servicePath}.database is required`);
|
||||
const github = storageConfig?.github;
|
||||
docs.push(`apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: ${required(decisionCenter.serviceName, "services.decisionCenter.serviceName")}
|
||||
name: ${required(service.serviceName, `${servicePath}.serviceName`)}
|
||||
namespace: ${namespace}
|
||||
labels:
|
||||
${indent(labels(config), 4)}
|
||||
app.kubernetes.io/name: decision-center
|
||||
app.kubernetes.io/name: ${serviceId}
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app.kubernetes.io/name: decision-center
|
||||
app.kubernetes.io/name: ${serviceId}
|
||||
ports:
|
||||
- name: http
|
||||
port: ${positiveInteger(decisionCenter.containerPort, "services.decisionCenter.containerPort")}
|
||||
port: ${positiveInteger(service.containerPort, `${servicePath}.containerPort`)}
|
||||
targetPort: http`);
|
||||
docs.push(`apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: ${required(decisionCenter.deploymentName, "services.decisionCenter.deploymentName")}
|
||||
name: ${required(service.deploymentName, `${servicePath}.deploymentName`)}
|
||||
namespace: ${namespace}
|
||||
labels:
|
||||
${indent(labels(config), 4)}
|
||||
app.kubernetes.io/name: decision-center
|
||||
app.kubernetes.io/name: ${serviceId}
|
||||
spec:
|
||||
replicas: 1
|
||||
replicas: ${positiveInteger(service.replicas, `${servicePath}.replicas`)}
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: decision-center
|
||||
app.kubernetes.io/name: ${serviceId}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: decision-center
|
||||
app.kubernetes.io/name: ${serviceId}
|
||||
app.kubernetes.io/part-of: unidesk
|
||||
annotations:
|
||||
unidesk.ai/secret-fingerprint: ${yamlScalar(decisionCenterDatabase?.fingerprint ?? "")}
|
||||
unidesk.ai/storage-secret-fingerprint: ${yamlScalar(decisionCenterStorage?.github?.ssh.fingerprint ?? "")}
|
||||
unidesk.ai/secret-fingerprint: ${yamlScalar(databaseConfig.fingerprint)}
|
||||
unidesk.ai/storage-secret-fingerprint: ${yamlScalar(github?.ssh.fingerprint ?? "")}
|
||||
unidesk.ai/runtime-config-fingerprint: ${yamlScalar(runtimeConfigFingerprint)}
|
||||
spec:
|
||||
initContainers:
|
||||
@@ -633,65 +705,48 @@ spec:
|
||||
- name: DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: ${required(record(decisionCenter.database, "services.decisionCenter.database").secretName, "services.decisionCenter.database.secretName")}
|
||||
key: ${required(record(decisionCenter.database, "services.decisionCenter.database").secretKey, "services.decisionCenter.database.secretKey")}
|
||||
name: ${databaseConfig.secretName}
|
||||
key: ${databaseConfig.secretKey}
|
||||
command: ["sh", "-ec", "db_url=\\"$(printf '%s' \\"$DATABASE_URL\\" | sed 's/[?&]uselibpqcompat=true//g')\\"; until psql \\"$db_url\\" -Atqc 'SELECT 1' >/dev/null 2>&1; do sleep 2; done"]
|
||||
containers:
|
||||
- name: ${required(decisionCenter.containerName, "services.decisionCenter.containerName")}
|
||||
image: ${required(images.decisionCenter, "images.decisionCenter")}
|
||||
- name: ${required(service.containerName, `${servicePath}.containerName`)}
|
||||
image: ${required(images[imageKey], `images.${imageKey}`)}
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: ${positiveInteger(decisionCenter.containerPort, "services.decisionCenter.containerPort")}
|
||||
containerPort: ${positiveInteger(service.containerPort, `${servicePath}.containerPort`)}
|
||||
env:
|
||||
- name: HOST
|
||||
value: "0.0.0.0"
|
||||
- name: PORT
|
||||
value: ${yamlScalar(decisionCenter.containerPort)}
|
||||
value: ${yamlScalar(service.containerPort)}
|
||||
- name: DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: ${required(record(decisionCenter.database, "services.decisionCenter.database").secretName, "services.decisionCenter.database.secretName")}
|
||||
key: ${required(record(decisionCenter.database, "services.decisionCenter.database").secretKey, "services.decisionCenter.database.secretKey")}
|
||||
name: ${databaseConfig.secretName}
|
||||
key: ${databaseConfig.secretKey}
|
||||
- name: DATABASE_POOL_MAX
|
||||
value: "2"
|
||||
value: ${yamlScalar(required(runtimeConfig.databasePoolMax, "runtimeConfig.databasePoolMax"))}
|
||||
- name: UNIDESK_DEPLOY_SERVICE_ID
|
||||
value: "decision-center"
|
||||
value: ${yamlScalar(serviceId)}
|
||||
- name: UNIDESK_DEPLOY_REPO
|
||||
value: ${yamlScalar(required(record(decisionCenter.repository, "services.decisionCenter.repository").url, "services.decisionCenter.repository.url"))}
|
||||
value: ${yamlScalar(required(record(service.repository, `${servicePath}.repository`).url, `${servicePath}.repository.url`))}
|
||||
- name: UNIDESK_DEPLOY_COMMIT
|
||||
value: ${yamlScalar(required(runtime.commit, "runtime.commit"))}
|
||||
- name: UNIDESK_DEPLOY_REQUESTED_COMMIT
|
||||
value: ${yamlScalar(required(runtime.commit, "runtime.commit"))}
|
||||
- name: LOG_FILE
|
||||
value: ${yamlScalar(required(decisionCenter.logFile, "services.decisionCenter.logFile"))}
|
||||
- name: DECISION_CENTER_STORAGE_PRIMARY
|
||||
value: ${yamlScalar(decisionCenterStorage?.primary ?? "postgres")}
|
||||
${decisionCenterStorage?.github !== undefined ? indent(`- name: DECISION_CENTER_GIT_REPO_SSH_URL
|
||||
value: ${yamlScalar(decisionCenterStorage.github.sshUrl)}
|
||||
- name: DECISION_CENTER_GIT_BRANCH
|
||||
value: ${yamlScalar(decisionCenterStorage.github.branch)}
|
||||
- name: DECISION_CENTER_GIT_BASE_PATH
|
||||
value: ${yamlScalar(decisionCenterStorage.github.basePath)}
|
||||
- name: DECISION_CENTER_GIT_WORKTREE
|
||||
value: ${yamlScalar(decisionCenterStorage.github.worktreePath)}
|
||||
- name: DECISION_CENTER_GIT_AUTHOR_NAME
|
||||
value: ${yamlScalar(decisionCenterStorage.github.author.name)}
|
||||
- name: DECISION_CENTER_GIT_AUTHOR_EMAIL
|
||||
value: ${yamlScalar(decisionCenterStorage.github.author.email)}
|
||||
- name: DECISION_CENTER_GIT_COMMIT_PREFIX
|
||||
value: ${yamlScalar(decisionCenterStorage.github.commitMessagePrefix)}
|
||||
- name: GIT_SSH_COMMAND
|
||||
value: ${yamlScalar(`ssh -i ${decisionCenterStorage.github.ssh.mountPath}/${decisionCenterStorage.github.ssh.privateKeyKey} -o UserKnownHostsFile=${decisionCenterStorage.github.ssh.mountPath}/${decisionCenterStorage.github.ssh.knownHostsKey} -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes`)}`, 12) : ""}
|
||||
value: ${yamlScalar(required(service.logFile, `${servicePath}.logFile`))}
|
||||
${indent(managedStorageEnv(envPrefix, storageConfig), 12)}
|
||||
volumeMounts:
|
||||
- name: logs
|
||||
mountPath: /var/log/unidesk
|
||||
${decisionCenterStorage?.github !== undefined ? indent(`- name: github-ssh
|
||||
mountPath: ${yamlScalar(decisionCenterStorage.github.ssh.mountPath)}
|
||||
readOnly: true`, 12) : ""}
|
||||
${github === undefined ? "" : indent(`- name: github-ssh
|
||||
mountPath: ${yamlScalar(github.ssh.mountPath)}
|
||||
readOnly: true`, 12)}
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: ${required(decisionCenter.healthPath, "services.decisionCenter.healthPath")}
|
||||
path: ${required(service.healthPath, `${servicePath}.healthPath`)}
|
||||
port: http
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 3
|
||||
@@ -710,18 +765,34 @@ ${decisionCenterStorage?.github !== undefined ? indent(`- name: github-ssh
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 30
|
||||
${indent(resourceBlock(decisionCenter.resources), 10)}
|
||||
${indent(resourceBlock(service.resources), 10)}
|
||||
volumes:
|
||||
- name: logs
|
||||
emptyDir: {}`);
|
||||
if (decisionCenterStorage?.github !== undefined) {
|
||||
docs[docs.length - 1] += `
|
||||
- name: github-ssh
|
||||
secret:
|
||||
secretName: ${decisionCenterStorage.github.ssh.secretName}
|
||||
defaultMode: 0400`;
|
||||
}
|
||||
emptyDir: {}
|
||||
${github === undefined ? "" : indent(`- name: github-ssh
|
||||
secret:
|
||||
secretName: ${github.ssh.secretName}
|
||||
defaultMode: 0400`, 8)}`);
|
||||
}
|
||||
|
||||
appendManagedServiceDeployment({
|
||||
service: decisionCenter,
|
||||
servicePath: "services.decisionCenter",
|
||||
serviceId: "decision-center",
|
||||
imageKey: "decisionCenter",
|
||||
envPrefix: "DECISION_CENTER",
|
||||
databaseConfig: decisionCenterDatabase,
|
||||
storageConfig: decisionCenterStorage,
|
||||
});
|
||||
appendManagedServiceDeployment({
|
||||
service: todoNote,
|
||||
servicePath: "services.todoNote",
|
||||
serviceId: "todo-note",
|
||||
imageKey: "todoNote",
|
||||
envPrefix: "TODO_NOTE",
|
||||
databaseConfig: todoNoteDatabase,
|
||||
storageConfig: todoNoteStorage,
|
||||
});
|
||||
docs.push(`apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
#!/usr/bin/env bun
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
function option(name) {
|
||||
const index = process.argv.indexOf(name);
|
||||
if (index === -1) return null;
|
||||
const value = process.argv[index + 1];
|
||||
if (value === undefined || value.length === 0 || value.startsWith("--")) throw new Error(`${name} requires a value`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function record(value, path) {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function required(value, path) {
|
||||
if (typeof value !== "string" || value.length === 0) throw new Error(`${path} must be a non-empty string`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function positiveInteger(value, path) {
|
||||
if (!Number.isInteger(value) || value < 1) throw new Error(`${path} must be a positive integer`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function booleanValue(value, path) {
|
||||
if (typeof value !== "boolean") throw new Error(`${path} must be a boolean`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function stringValue(value, path) {
|
||||
if (typeof value !== "string") throw new Error(`${path} must be a string`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function valueAt(root, reference) {
|
||||
return reference.split(".").reduce((value, key) => record(value, reference)[key], root);
|
||||
}
|
||||
|
||||
function sha(value) {
|
||||
return createHash("sha256").update(value).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
function secretEnv(name, secretName, secretKey) {
|
||||
return { name, valueFrom: { secretKeyRef: { name: secretName, key: secretKey } } };
|
||||
}
|
||||
|
||||
function main() {
|
||||
const configPath = option("--config") ?? "config/unidesk-host-k8s.yaml";
|
||||
const serviceRef = required(option("--service-ref"), "--service-ref");
|
||||
const image = required(option("--image"), "--image");
|
||||
const sourceCommit = required(option("--source-commit"), "--source-commit");
|
||||
if (!/^services\.[A-Za-z][A-Za-z0-9]*$/u.test(serviceRef)) throw new Error("--service-ref must identify one services.<key> entry");
|
||||
if (!/@sha256:[0-9a-f]{64}$/u.test(image)) throw new Error("--image must be digest pinned");
|
||||
if (!/^[0-9a-f]{40}$/u.test(sourceCommit)) throw new Error("--source-commit must be a full Git commit SHA");
|
||||
|
||||
const config = record(Bun.YAML.parse(readFileSync(configPath, "utf8")), configPath);
|
||||
const target = record(config.target, "target");
|
||||
const runtime = record(config.runtime, "runtime");
|
||||
const images = record(config.images, "images");
|
||||
const service = record(valueAt(config, serviceRef), serviceRef);
|
||||
const database = record(service.database, `${serviceRef}.database`);
|
||||
const storage = record(service.storage, `${serviceRef}.storage`);
|
||||
const repository = record(service.repository, `${serviceRef}.repository`);
|
||||
const reminder = record(service.reminder, `${serviceRef}.reminder`);
|
||||
const reminderTransport = record(reminder.transport, `${serviceRef}.reminder.transport`);
|
||||
const resources = record(service.resources, `${serviceRef}.resources`);
|
||||
const github = storage.primary === "github-repo" ? record(storage.github, `${serviceRef}.storage.github`) : null;
|
||||
const githubSsh = github === null ? null : record(github.ssh, `${serviceRef}.storage.github.ssh`);
|
||||
const privateKey = githubSsh === null ? null : record(githubSsh.privateKey, `${serviceRef}.storage.github.ssh.privateKey`);
|
||||
const knownHosts = githubSsh === null ? null : record(githubSsh.knownHosts, `${serviceRef}.storage.github.ssh.knownHosts`);
|
||||
const namespace = required(target.namespace, "target.namespace");
|
||||
const serviceId = required(service.serviceId, `${serviceRef}.serviceId`);
|
||||
const serviceName = required(service.serviceName, `${serviceRef}.serviceName`);
|
||||
const deploymentName = required(service.deploymentName, `${serviceRef}.deploymentName`);
|
||||
const containerName = required(service.containerName, `${serviceRef}.containerName`);
|
||||
const containerPort = positiveInteger(service.containerPort, `${serviceRef}.containerPort`);
|
||||
const databaseSecretName = required(database.secretName, `${serviceRef}.database.secretName`);
|
||||
const databaseSecretKey = required(database.secretKey, `${serviceRef}.database.secretKey`);
|
||||
const storageEnvPrefix = required(service.storageEnvPrefix, `${serviceRef}.storageEnvPrefix`);
|
||||
const reminderTransportType = required(reminderTransport.type, `${serviceRef}.reminder.transport.type`);
|
||||
if (reminderTransportType !== "claudeqq") throw new Error(`${serviceRef}.reminder.transport.type must be claudeqq`);
|
||||
const reminderTargetType = required(reminderTransport.targetType, `${serviceRef}.reminder.transport.targetType`);
|
||||
if (reminderTargetType !== "private" && reminderTargetType !== "group") throw new Error(`${serviceRef}.reminder.transport.targetType must be private or group`);
|
||||
const reminderUserId = stringValue(reminderTransport.userId, `${serviceRef}.reminder.transport.userId`);
|
||||
const reminderGroupId = stringValue(reminderTransport.groupId, `${serviceRef}.reminder.transport.groupId`);
|
||||
if (booleanValue(reminder.enabled, `${serviceRef}.reminder.enabled`) && reminderTargetType === "private" && reminderUserId.length === 0) throw new Error(`${serviceRef}.reminder.transport.userId is required for private reminders`);
|
||||
if (reminder.enabled && reminderTargetType === "group" && reminderGroupId.length === 0) throw new Error(`${serviceRef}.reminder.transport.groupId is required for group reminders`);
|
||||
const configuredImage = required(valueAt(config, required(service.imageRef, `${serviceRef}.imageRef`)), service.imageRef);
|
||||
const configFingerprint = sha(JSON.stringify({ target, runtime, images, serviceRef, service, configuredImage }));
|
||||
const commonLabels = {
|
||||
"app.kubernetes.io/name": serviceId,
|
||||
"app.kubernetes.io/part-of": "unidesk",
|
||||
"app.kubernetes.io/managed-by": "argocd",
|
||||
"unidesk.ai/deployment": required(record(config.metadata, "metadata").name, "metadata.name"),
|
||||
"unidesk.ai/target": required(target.id, "target.id"),
|
||||
};
|
||||
|
||||
const env = [
|
||||
{ name: "HOST", value: "0.0.0.0" },
|
||||
{ name: "PORT", value: String(containerPort) },
|
||||
secretEnv("DATABASE_URL", databaseSecretName, databaseSecretKey),
|
||||
{ name: "DATABASE_POOL_MAX", value: required(record(config.runtimeConfig, "runtimeConfig").databasePoolMax, "runtimeConfig.databasePoolMax") },
|
||||
{ name: "UNIDESK_DEPLOY_SERVICE_ID", value: serviceId },
|
||||
{ name: "UNIDESK_DEPLOY_REPO", value: required(repository.url, `${serviceRef}.repository.url`) },
|
||||
{ name: "UNIDESK_DEPLOY_COMMIT", value: sourceCommit },
|
||||
{ name: "UNIDESK_DEPLOY_REQUESTED_COMMIT", value: sourceCommit },
|
||||
{ name: "LOG_FILE", value: required(service.logFile, `${serviceRef}.logFile`) },
|
||||
{ name: `${storageEnvPrefix}_STORAGE_PRIMARY`, value: required(storage.primary, `${serviceRef}.storage.primary`) },
|
||||
{ name: "TODO_NOTE_REMINDER_CLAUDEQQ_ENABLED", value: String(reminder.enabled) },
|
||||
{ name: "TODO_NOTE_REMINDER_CLAUDEQQ_BASE_URL", value: required(reminderTransport.baseUrl, `${serviceRef}.reminder.transport.baseUrl`) },
|
||||
{ name: "TODO_NOTE_REMINDER_CLAUDEQQ_TARGET_TYPE", value: reminderTargetType },
|
||||
{ name: "TODO_NOTE_REMINDER_CLAUDEQQ_USER_ID", value: reminderUserId },
|
||||
{ name: "TODO_NOTE_REMINDER_CLAUDEQQ_GROUP_ID", value: reminderGroupId },
|
||||
{ name: "TODO_NOTE_REMINDER_LEAD_MINUTES", value: String(positiveInteger(reminder.leadMinutes, `${serviceRef}.reminder.leadMinutes`)) },
|
||||
{ name: "TODO_NOTE_REMINDER_SCAN_INTERVAL_MS", value: String(positiveInteger(reminder.scanIntervalMs, `${serviceRef}.reminder.scanIntervalMs`)) },
|
||||
{ name: "TODO_NOTE_REMINDER_CLAUDEQQ_TIMEOUT_MS", value: String(positiveInteger(reminderTransport.timeoutMs, `${serviceRef}.reminder.transport.timeoutMs`)) },
|
||||
{ name: "TODO_NOTE_REMINDER_CLAUDEQQ_SEND_ATTEMPTS", value: String(positiveInteger(reminderTransport.sendAttempts, `${serviceRef}.reminder.transport.sendAttempts`)) },
|
||||
];
|
||||
|
||||
const volumeMounts = [{ name: "logs", mountPath: "/var/log/unidesk" }];
|
||||
const volumes = [{ name: "logs", emptyDir: {} }];
|
||||
if (github !== null && githubSsh !== null && privateKey !== null && knownHosts !== null) {
|
||||
const mountPath = required(githubSsh.mountPath, `${serviceRef}.storage.github.ssh.mountPath`);
|
||||
const privateKeyName = required(privateKey.secretKey, `${serviceRef}.storage.github.ssh.privateKey.secretKey`);
|
||||
const knownHostsName = required(knownHosts.secretKey, `${serviceRef}.storage.github.ssh.knownHosts.secretKey`);
|
||||
env.push(
|
||||
{ name: `${storageEnvPrefix}_GIT_REPO`, value: required(github.repo, `${serviceRef}.storage.github.repo`) },
|
||||
{ name: `${storageEnvPrefix}_GIT_REPO_SSH_URL`, value: required(github.sshUrl, `${serviceRef}.storage.github.sshUrl`) },
|
||||
{ name: `${storageEnvPrefix}_GIT_BRANCH`, value: required(github.branch, `${serviceRef}.storage.github.branch`) },
|
||||
{ name: `${storageEnvPrefix}_GIT_BASE_PATH`, value: required(github.basePath, `${serviceRef}.storage.github.basePath`) },
|
||||
{ name: `${storageEnvPrefix}_GIT_WORKTREE`, value: required(github.worktreePath, `${serviceRef}.storage.github.worktreePath`) },
|
||||
{ name: `${storageEnvPrefix}_GIT_AUTHOR_NAME`, value: required(record(github.author, `${serviceRef}.storage.github.author`).name, `${serviceRef}.storage.github.author.name`) },
|
||||
{ name: `${storageEnvPrefix}_GIT_AUTHOR_EMAIL`, value: required(record(github.author, `${serviceRef}.storage.github.author`).email, `${serviceRef}.storage.github.author.email`) },
|
||||
{ name: `${storageEnvPrefix}_GIT_COMMIT_PREFIX`, value: required(github.commitMessagePrefix, `${serviceRef}.storage.github.commitMessagePrefix`) },
|
||||
{ name: "GIT_SSH_COMMAND", value: `ssh -i ${mountPath}/${privateKeyName} -o UserKnownHostsFile=${mountPath}/${knownHostsName} -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes` },
|
||||
);
|
||||
volumeMounts.push({ name: "github-ssh", mountPath, readOnly: true });
|
||||
volumes.push({ name: "github-ssh", secret: { secretName: required(githubSsh.secretName, `${serviceRef}.storage.github.ssh.secretName`), defaultMode: 256 } });
|
||||
}
|
||||
|
||||
const objects = [
|
||||
{
|
||||
apiVersion: "v1",
|
||||
kind: "Service",
|
||||
metadata: { name: serviceName, namespace, labels: commonLabels },
|
||||
spec: {
|
||||
type: "ClusterIP",
|
||||
selector: { "app.kubernetes.io/name": serviceId },
|
||||
ports: [{ name: "http", port: containerPort, targetPort: "http" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
apiVersion: "apps/v1",
|
||||
kind: "Deployment",
|
||||
metadata: { name: deploymentName, namespace, labels: commonLabels },
|
||||
spec: {
|
||||
replicas: positiveInteger(service.replicas, `${serviceRef}.replicas`),
|
||||
selector: { matchLabels: { "app.kubernetes.io/name": serviceId } },
|
||||
template: {
|
||||
metadata: {
|
||||
labels: commonLabels,
|
||||
annotations: {
|
||||
"unidesk.ai/source-commit": sourceCommit,
|
||||
"unidesk.ai/config-fingerprint": `sha256:${configFingerprint}`,
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
initContainers: [{
|
||||
name: "wait-database",
|
||||
image: required(images.postgres, "images.postgres"),
|
||||
imagePullPolicy: "IfNotPresent",
|
||||
env: [secretEnv("DATABASE_URL", databaseSecretName, databaseSecretKey)],
|
||||
command: ["sh", "-ec", "db_url=\"$(printf '%s' \"$DATABASE_URL\" | sed 's/[?&]uselibpqcompat=true//g')\"; until psql \"$db_url\" -Atqc 'SELECT 1' >/dev/null 2>&1; do sleep 2; done"],
|
||||
}],
|
||||
containers: [{
|
||||
name: containerName,
|
||||
image,
|
||||
imagePullPolicy: "IfNotPresent",
|
||||
ports: [{ name: "http", containerPort }],
|
||||
env,
|
||||
volumeMounts,
|
||||
readinessProbe: { httpGet: { path: required(service.healthPath, `${serviceRef}.healthPath`), port: "http" }, periodSeconds: 5, timeoutSeconds: 3, failureThreshold: 18 },
|
||||
livenessProbe: { httpGet: { path: required(service.livePath, `${serviceRef}.livePath`), port: "http" }, periodSeconds: 10, timeoutSeconds: 3, failureThreshold: 6 },
|
||||
startupProbe: { httpGet: { path: required(service.livePath, `${serviceRef}.livePath`), port: "http" }, periodSeconds: 5, timeoutSeconds: 3, failureThreshold: 30 },
|
||||
resources,
|
||||
}],
|
||||
volumes,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
process.stdout.write(`${objects.map((object) => Bun.YAML.stringify(object).trim()).join("\n---\n")}\n`);
|
||||
}
|
||||
|
||||
if (!process.execArgv.includes("--check")) main();
|
||||
@@ -4,6 +4,8 @@ import { rootPath } from "./config";
|
||||
|
||||
export type CiArtifactStatus = "supported" | "blocked";
|
||||
|
||||
export type CiSourceBuildProducer = "ci publish-backend-core" | "ci publish-user-service" | "platform-infra pipelines-as-code";
|
||||
|
||||
export type CiCatalogArtifact = CiSourceBuildCatalogArtifact | CiUpstreamImageCatalogArtifact;
|
||||
|
||||
export interface CiCatalog {
|
||||
@@ -27,7 +29,7 @@ export interface CiSourceBuildCatalogArtifact {
|
||||
serviceId: string;
|
||||
kind: "source-build";
|
||||
status: CiArtifactStatus;
|
||||
producer: "ci publish-backend-core" | "ci publish-user-service";
|
||||
producer: CiSourceBuildProducer;
|
||||
source: {
|
||||
repo: string;
|
||||
dockerfile: string;
|
||||
@@ -132,8 +134,8 @@ function validateSourceBuildArtifact(item: Record<string, unknown>, index: numbe
|
||||
const status = stringField(item, "status", path);
|
||||
if (status !== "supported" && status !== "blocked") throw new Error(`${path}.status must be supported or blocked`);
|
||||
const producer = stringField(item, "producer", path);
|
||||
if (producer !== "ci publish-backend-core" && producer !== "ci publish-user-service") {
|
||||
throw new Error(`${path}.producer must be ci publish-backend-core or ci publish-user-service`);
|
||||
if (producer !== "ci publish-backend-core" && producer !== "ci publish-user-service" && producer !== "platform-infra pipelines-as-code") {
|
||||
throw new Error(`${path}.producer must be ci publish-backend-core, ci publish-user-service, or platform-infra pipelines-as-code`);
|
||||
}
|
||||
const artifact: CiSourceBuildCatalogArtifact = {
|
||||
serviceId: stringField(item, "serviceId", path),
|
||||
@@ -238,9 +240,9 @@ export function findCiCatalogArtifact(serviceId: string): CiCatalogArtifact | nu
|
||||
return loadCiCatalog().artifacts.find((artifact) => artifact.serviceId === serviceId) ?? null;
|
||||
}
|
||||
|
||||
export function supportedSourceBuildArtifactIds(): string[] {
|
||||
export function supportedSourceBuildArtifactIds(producer?: CiSourceBuildProducer): string[] {
|
||||
return loadCiCatalog().artifacts
|
||||
.filter((artifact): artifact is CiSourceBuildCatalogArtifact => artifact.kind === "source-build" && artifact.status === "supported")
|
||||
.filter((artifact): artifact is CiSourceBuildCatalogArtifact => artifact.kind === "source-build" && artifact.status === "supported" && (producer === undefined || artifact.producer === producer))
|
||||
.map((artifact) => artifact.serviceId);
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ import { makeRunId, resolveDeployDevManifest, runDevE2E } from "./dev-e2e";
|
||||
import { ciHelp, requireRunId } from "./help";
|
||||
import { install, installAsync, installStatus } from "./install";
|
||||
import { logs } from "./logs";
|
||||
import { blockedArtifactResult, blockedReason, boolFlag, ciCleanupFailedPodsOptions, ciCleanupRunsOptions, ciLogsOptions, isHelpArg, numberOption, publishTransportOption, requireDesiredRef, requireFullCommit, requireRepoRelativePath, requireRevision, requireServiceId, resolveCatalogArtifact, stringOption, userServicePublishBoundaryBlock } from "./options";
|
||||
import { assertUserServicePublishProducer, blockedArtifactResult, blockedReason, boolFlag, ciCleanupFailedPodsOptions, ciCleanupRunsOptions, ciLogsOptions, isHelpArg, numberOption, publishTransportOption, requireDesiredRef, requireFullCommit, requireRepoRelativePath, requireRevision, requireServiceId, resolveCatalogArtifact, stringOption, userServicePublishBoundaryBlock } from "./options";
|
||||
import { backendCoreArtifactSourceHostPath, userServiceArtifactSourceHostPath } from "./pipelinerun";
|
||||
import { publishBackendCoreArtifact, publishUserServiceArtifact, run } from "./publish";
|
||||
import { ciTarget, ciTargetSourceSummary, providerIdOption } from "./types";
|
||||
@@ -135,6 +135,7 @@ export async function runCiCommand(config: UniDeskConfig, args: string[]): Promi
|
||||
if (artifact.status === "blocked") {
|
||||
return blockedArtifactResult(artifact, commit, blockedReason(artifact));
|
||||
}
|
||||
assertUserServicePublishProducer(artifact);
|
||||
const repoUrl = artifact.source.repo;
|
||||
const dockerfile = requireRepoRelativePath(artifact.source.dockerfile, `CI.json.artifacts.${serviceId}.source.dockerfile`);
|
||||
const boundaryBlock = userServicePublishBoundaryBlock(config, serviceId, commit, artifact);
|
||||
|
||||
@@ -117,7 +117,7 @@ export function ciHelp(): Record<string, unknown> {
|
||||
tekton: "D601 Tekton PipelineRun through backend-core/provider control plane",
|
||||
directDocker: "repo-owned Docker artifact publish without backend-core/database dispatch; no deploy apply, rollout, restart, or active-task mutation",
|
||||
},
|
||||
supportedServices: supportedSourceBuildArtifactIds().filter((serviceId) => serviceId !== "backend-core"),
|
||||
supportedServices: supportedSourceBuildArtifactIds("ci publish-user-service"),
|
||||
blockedServices: blockedCatalogArtifactIds(),
|
||||
registry: "127.0.0.1:5000/unidesk/<service-id>:<commit>",
|
||||
outputFields: ["serviceId", "sourceCommit", "sourceRepo", "dockerfile", "imageRef", "tag", "digest", "digestRef"],
|
||||
|
||||
@@ -280,6 +280,14 @@ export function resolveCatalogArtifact(serviceId: string): CiCatalogArtifact {
|
||||
return artifact;
|
||||
}
|
||||
|
||||
export function assertUserServicePublishProducer(artifact: CiSourceBuildCatalogArtifact): void {
|
||||
if (artifact.producer === "ci publish-user-service") return;
|
||||
if (artifact.producer === "platform-infra pipelines-as-code") {
|
||||
throw new Error(`${artifact.serviceId} is produced by platform-infra pipelines-as-code; use bun scripts/cli.ts platform-infra pipelines-as-code plan --target NC01 --consumer unidesk-host`);
|
||||
}
|
||||
throw new Error(`${artifact.serviceId} is produced by ${artifact.producer}; use the owning producer command`);
|
||||
}
|
||||
|
||||
export function blockedArtifactResult(artifact: CiUpstreamImageCatalogArtifact | CiSourceBuildCatalogArtifact, commit: string, note: string): Record<string, unknown> {
|
||||
const base = {
|
||||
ok: false,
|
||||
|
||||
@@ -25,7 +25,7 @@ import { runSshCommandCapture } from "../ssh";
|
||||
import type { ArtifactSummaryContext, CiOptions, CiPublishBackendCoreOptions, CiPublishUserServiceArtifactOptions, PublishPreflightTransport } from "./types";
|
||||
import { artifactSummaryDefaults } from "./artifact-summary";
|
||||
import { publishUserServiceArtifactDirectDocker } from "./direct-docker";
|
||||
import { blockedArtifactResult, blockedReason, numberOption, publishTransportOption, repoConnectivityProbeUrl, repoNeedsGithubSshIdentity, repoSshUrl, requireFullCommit, requireRepoRelativePath, requireServiceId, resolveCatalogArtifact, stringOption, userServicePublishBoundaryBlock } from "./options";
|
||||
import { assertUserServicePublishProducer, blockedArtifactResult, blockedReason, numberOption, publishTransportOption, repoConnectivityProbeUrl, repoNeedsGithubSshIdentity, repoSshUrl, requireFullCommit, requireRepoRelativePath, requireServiceId, resolveCatalogArtifact, stringOption, userServicePublishBoundaryBlock } from "./options";
|
||||
import { backendCoreArtifactPipelineRunManifest, backendCoreArtifactSourceHostPath, pipelineRunManifest, userServiceArtifactPipelineRunManifest, userServiceArtifactSourceHostPath } from "./pipelinerun";
|
||||
import { pipelineRunWaitSucceeded, readPipelineRunCondition, remoteCreatePipelineRun, waitForPipelineRun } from "./pipelinerun-runtime";
|
||||
import { publishPreflightControlChannelOrder, publishPreflightFailedScopes } from "./preflight";
|
||||
@@ -238,6 +238,7 @@ export async function runCiPublishUserServiceDryRunPreflight(
|
||||
if (artifact.status === "blocked") {
|
||||
return blockedArtifactResult(artifact, commit, blockedReason(artifact));
|
||||
}
|
||||
assertUserServicePublishProducer(artifact);
|
||||
const dockerfile = requireRepoRelativePath(artifact.source.dockerfile, `CI.json.artifacts.${serviceId}.source.dockerfile`);
|
||||
const boundaryBlock = userServicePublishBoundaryBlock(config, serviceId, commit, artifact);
|
||||
if (boundaryBlock !== null) return boundaryBlock;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// Responsibility: shared types, config helpers and text render utilities for web-probe sentinel CI/CD.
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { isAbsolute, join } from "node:path";
|
||||
import { resolveCliChildJsonCommandResult } from "./cli-child-json-recovery";
|
||||
import type { CommandResult } from "./command";
|
||||
import { repoRoot, rootPath } from "./config";
|
||||
@@ -320,7 +320,7 @@ export function monitorWebBuildkitStatePlan(cicd: Record<string, unknown>): Reco
|
||||
}
|
||||
|
||||
export function secretSourcePaths(sourceRef: string): string[] {
|
||||
if (sourceRef.startsWith(".env/")) return ownerFileSourcePaths(sourceRef);
|
||||
if (isAbsolute(sourceRef)) return [sourceRef];
|
||||
const paths = [join(repoRoot, ".state", "secrets", sourceRef)];
|
||||
const marker = "/.worktree/";
|
||||
const index = repoRoot.indexOf(marker);
|
||||
@@ -328,14 +328,6 @@ export function secretSourcePaths(sourceRef: string): string[] {
|
||||
return [...new Set(paths)];
|
||||
}
|
||||
|
||||
export function ownerFileSourcePaths(sourceRef: string): string[] {
|
||||
if (sourceRef.includes("..") || sourceRef.includes("\0")) return [];
|
||||
const marker = "/.worktree/";
|
||||
const index = repoRoot.indexOf(marker);
|
||||
const roots = index >= 0 ? [repoRoot.slice(0, index), repoRoot] : [repoRoot];
|
||||
return [...new Set(roots.map((root) => join(root, sourceRef)))];
|
||||
}
|
||||
|
||||
export function parseEnvFile(textValue: string): Record<string, string> {
|
||||
const values: Record<string, string> = {};
|
||||
for (const rawLine of textValue.split(/\r?\n/u)) {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// Responsibility: YAML-first node/lane operations, including Workbench observability control commands.
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { dirname, isAbsolute, join } from "node:path";
|
||||
import { repoRoot, rootPath, type Config } from "../config";
|
||||
import { runCommand, type CommandResult } from "../command";
|
||||
import { startJob } from "../jobs";
|
||||
@@ -739,7 +739,7 @@ export function readLocalPostgresPasswordMaterial(input: { sourceRef: string; so
|
||||
}
|
||||
|
||||
export function localSecretSourcePaths(sourceRef: string): string[] {
|
||||
if (sourceRef.startsWith(".env/")) return ownerFileSourcePaths(sourceRef);
|
||||
if (isAbsolute(sourceRef)) return [sourceRef];
|
||||
const marker = "/.worktree/";
|
||||
const index = repoRoot.indexOf(marker);
|
||||
const paths = index >= 0
|
||||
@@ -1286,7 +1286,7 @@ export function externalPostgresSecretSourceRoot(spec: HwlabRuntimeLaneSpec): st
|
||||
}
|
||||
|
||||
export function readSecretSourceValue(secretRoot: string, sourceRef: string, key: string): { ok: true; value: string; sourcePath: string } | { ok: false; sourcePath: string } {
|
||||
const sourcePath = join(secretRoot, sourceRef);
|
||||
const sourcePath = isAbsolute(sourceRef) ? sourceRef : join(secretRoot, sourceRef);
|
||||
if (!existsSync(sourcePath)) return { ok: false, sourcePath };
|
||||
const values = parseEnvFile(readFileSync(sourcePath, "utf8"));
|
||||
const value = values[key];
|
||||
@@ -1295,7 +1295,7 @@ export function readSecretSourceValue(secretRoot: string, sourceRef: string, key
|
||||
}
|
||||
|
||||
export function secretSourcePaths(sourceRef: string): string[] {
|
||||
if (sourceRef.startsWith(".env/")) return ownerFileSourcePaths(sourceRef);
|
||||
if (isAbsolute(sourceRef)) return [sourceRef];
|
||||
const paths = [join(repoRoot, ".state", "secrets", sourceRef)];
|
||||
const marker = "/.worktree/";
|
||||
const index = repoRoot.indexOf(marker);
|
||||
@@ -1303,14 +1303,6 @@ export function secretSourcePaths(sourceRef: string): string[] {
|
||||
return [...new Set(paths)];
|
||||
}
|
||||
|
||||
function ownerFileSourcePaths(sourceRef: string): string[] {
|
||||
if (sourceRef.includes("..") || sourceRef.includes("\0")) return [];
|
||||
const marker = "/.worktree/";
|
||||
const index = repoRoot.indexOf(marker);
|
||||
const roots = index >= 0 ? [repoRoot.slice(0, index), repoRoot] : [repoRoot];
|
||||
return [...new Set(roots.map((root) => join(root, sourceRef)))];
|
||||
}
|
||||
|
||||
export function displayRepoPath(path: string): string {
|
||||
const normalizedRoot = repoRoot.replace(/\/+$/u, "");
|
||||
if (path === normalizedRoot) return ".";
|
||||
|
||||
@@ -4,20 +4,17 @@ import { runCommand } from "./command";
|
||||
import { type UniDeskConfig, repoRoot } from "./config";
|
||||
import { jsonByteLength, previewJson } from "./preview";
|
||||
|
||||
// Todo Note misleading-404 rewrite (issue #198) — CLI-side interim workaround
|
||||
// for the upstream todo_note catch-all handler. The upstream repo
|
||||
// (https://gitee.com/Lyon1998/todo_note) wraps every unregistered path in
|
||||
// Todo Note misleading-404 rewrite (issue #198) for legacy deployments.
|
||||
// The historical service wrapped every unregistered path in
|
||||
// `404 {"ok":false,"error":"Todo Note is running in backend-only mode"}`,
|
||||
// which makes agents and humans misdiagnose a path typo as a write lock.
|
||||
// When the upstream fix lands, this rewrite becomes a no-op: the upstream
|
||||
// body will not match `isTodoNoteMisleadingRouteNotFoundBody` anymore and
|
||||
// The vendored service returns normal route errors, so this becomes a no-op:
|
||||
// its body does not match `isTodoNoteMisleadingRouteNotFoundBody` and
|
||||
// the CLI will pass the upstream response through unchanged.
|
||||
//
|
||||
// Source of truth for the writable endpoint list below is
|
||||
// docs/reference/microservices.md "Todo Note On Main Server" and
|
||||
// apps/server/src/server.ts in the upstream todo_note repo. If the
|
||||
// upstream registered routes change, update TODO_NOTE_WRITABLE_ENDPOINTS
|
||||
// and TODO_NOTE_ACTION_TYPES in lock-step.
|
||||
// src/components/microservices/todo-note/src/index.ts. Keep the catalog and
|
||||
// action types aligned with that service.
|
||||
|
||||
interface TodoNoteEndpoint {
|
||||
method: string;
|
||||
@@ -38,6 +35,8 @@ export const TODO_NOTE_ACTION_TYPES = [
|
||||
] as const;
|
||||
|
||||
export const TODO_NOTE_WRITABLE_ENDPOINTS: readonly TodoNoteEndpoint[] = [
|
||||
{ method: "POST", path: "/api/storage/import", hint: "Import a confirmed snapshot or gzip-packed migration request." },
|
||||
{ method: "POST", path: "/api/storage/migrate", hint: "Persist the current runtime index to the configured GitHub store." },
|
||||
{ method: "POST", path: "/api/instances", hint: "Create a new todo list; body {name}." },
|
||||
{ method: "DELETE", path: "/api/instances/:instanceId", hint: "Delete a todo list." },
|
||||
{
|
||||
@@ -673,6 +672,15 @@ function methodOption(args: string[], hasBody = false): string {
|
||||
return method;
|
||||
}
|
||||
|
||||
export function parseMicroserviceProxyRequest(args: string[]): { body: unknown | undefined; method: string; checkPath: boolean } {
|
||||
const body = requestBodyOption(args);
|
||||
return {
|
||||
body,
|
||||
method: methodOption(args, body !== undefined),
|
||||
checkPath: hasFlag(args, "--check-path"),
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeMicroserviceProxyResponse(response: unknown, args: string[]): unknown {
|
||||
const full = args.includes("--full");
|
||||
const raw = args.includes("--raw");
|
||||
@@ -969,16 +977,14 @@ export async function runMicroserviceCommand(_config: UniDeskConfig, args: strin
|
||||
if (action === "proxy") {
|
||||
const id = requireId(idArg, "microservice proxy");
|
||||
const path = requireProxyPath(pathArg);
|
||||
const body = requestBodyOption(args);
|
||||
const request = parseMicroserviceProxyRequest(args);
|
||||
const full = hasFlag(args, "--full");
|
||||
const raw = hasFlag(args, "--raw");
|
||||
const checkPath = hasFlag(args, "--check-path");
|
||||
const method = methodOption(args, body !== undefined);
|
||||
if (checkPath) {
|
||||
if (request.checkPath) {
|
||||
if (id !== "todo-note") {
|
||||
return {
|
||||
ok: false,
|
||||
command: `microservice proxy ${id} ${path} --method ${method} --check-path`,
|
||||
command: `microservice proxy ${id} ${path} --method ${request.method} --check-path`,
|
||||
data: {
|
||||
ok: false,
|
||||
error: `--check-path currently only supports service=todo-note; got ${id}`,
|
||||
@@ -987,12 +993,12 @@ export async function runMicroserviceCommand(_config: UniDeskConfig, args: strin
|
||||
},
|
||||
};
|
||||
}
|
||||
return runTodoNoteCheckPath(method, path);
|
||||
return runTodoNoteCheckPath(request.method, path);
|
||||
}
|
||||
const maxBodyBytes = full ? numberOption(args, "--max-body-bytes", 5_000_000) : cappedNumberOption(args, "--max-body-bytes", raw ? 120_000 : 60_000, 500_000);
|
||||
const maxResponseBytes = full ? Math.min(Math.max(maxBodyBytes, 120_000), 5_000_000) : Math.min(Math.max(maxBodyBytes * 3, 240_000), 1_500_000);
|
||||
const fetched = coreInternalFetch(`/api/microservices/${encodeId(id)}/proxy${path}`, { method, body, maxResponseBytes });
|
||||
const rewritten = rewriteTodoNoteMisleadingRouteNotFound(id, method, path, fetched);
|
||||
const fetched = coreInternalFetch(`/api/microservices/${encodeId(id)}/proxy${path}`, { method: request.method, body: request.body, maxResponseBytes });
|
||||
const rewritten = rewriteTodoNoteMisleadingRouteNotFound(id, request.method, path, fetched);
|
||||
return summarizeMicroserviceProxyResponse(rewritten, args);
|
||||
}
|
||||
throw new Error("microservice command must be one of: list, status, health, diagnostics, tunnel-self-test, proxy");
|
||||
|
||||
@@ -232,10 +232,19 @@ apply_action() {
|
||||
--from-literal="$UNIDESK_PAC_WEBHOOK_SECRET_KEY=$UNIDESK_PAC_WEBHOOK_SECRET" \
|
||||
--dry-run=client -o yaml | kubectl apply -f - >/dev/null
|
||||
repository_manifest | kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_PAC_FIELD_MANAGER" -f - >/dev/null
|
||||
argo_bootstrap=false
|
||||
if [ -n "${UNIDESK_PAC_ARGO_BOOTSTRAP_MANIFEST_B64:-}" ]; then
|
||||
argo_tmp=$(mktemp)
|
||||
printf '%s' "$UNIDESK_PAC_ARGO_BOOTSTRAP_MANIFEST_B64" | base64 -d >"$argo_tmp"
|
||||
kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_PAC_FIELD_MANAGER" -f "$argo_tmp" >/dev/null
|
||||
rm -f "$argo_tmp"
|
||||
kubectl -n "$UNIDESK_PAC_ARGO_NAMESPACE" annotate application "$UNIDESK_PAC_ARGO_APPLICATION" argocd.argoproj.io/refresh=hard --overwrite >/dev/null
|
||||
argo_bootstrap=true
|
||||
fi
|
||||
hook_id=$(ensure_webhook)
|
||||
ready=false
|
||||
if wait_ready; then ready=true; fi
|
||||
printf '{"ok":%s,"mode":"confirmed","mutation":true,"controllerReady":%s,"webhook":{"present":true,"id":"%s","url":"%s"},"repository":"%s","namespace":"%s","valuesPrinted":false}\n' "$ready" "$ready" "$(json_string "$hook_id")" "$(json_string "$UNIDESK_PAC_WEBHOOK_URL")" "$(json_string "$UNIDESK_PAC_REPOSITORY_NAME")" "$(json_string "$UNIDESK_PAC_TARGET_NAMESPACE")"
|
||||
printf '{"ok":%s,"mode":"confirmed","mutation":true,"controllerReady":%s,"argoBootstrap":%s,"webhook":{"present":true,"id":"%s","url":"%s"},"repository":"%s","namespace":"%s","valuesPrinted":false}\n' "$ready" "$ready" "$argo_bootstrap" "$(json_string "$hook_id")" "$(json_string "$UNIDESK_PAC_WEBHOOK_URL")" "$(json_string "$UNIDESK_PAC_REPOSITORY_NAME")" "$(json_string "$UNIDESK_PAC_TARGET_NAMESPACE")"
|
||||
}
|
||||
|
||||
condition_status() {
|
||||
@@ -701,15 +710,24 @@ NODE
|
||||
|
||||
runtime_summary() {
|
||||
app_file=$(mktemp)
|
||||
params_file=$(mktemp)
|
||||
kubectl -n "$UNIDESK_PAC_ARGO_NAMESPACE" get application "$UNIDESK_PAC_ARGO_APPLICATION" -o json >"$app_file" 2>/dev/null || printf '{}' >"$app_file"
|
||||
target=$(node - "$app_file" <<'NODE'
|
||||
printf '%s' "$UNIDESK_PAC_PARAMS_JSON" >"$params_file"
|
||||
target=$(node - "$app_file" "$params_file" <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
const app = JSON.parse(fs.readFileSync(process.argv[2], 'utf8') || '{}');
|
||||
const params = JSON.parse(fs.readFileSync(process.argv[3], 'utf8') || '{}');
|
||||
const prefix = process.env.UNIDESK_PAC_CONSUMER_ID === 'unidesk-host' ? 'unidesk_host_' : '';
|
||||
const param = (name) => params[`${prefix}${name}`] ?? params[name];
|
||||
if (param('runtime_namespace') && param('runtime_deployment')) {
|
||||
process.stdout.write(`${param('runtime_namespace')}\t${param('runtime_deployment')}`);
|
||||
process.exit(0);
|
||||
}
|
||||
const resource = (app.status?.resources || []).find((item) => item.kind === 'Deployment' && item.namespace && item.name);
|
||||
if (resource) process.stdout.write(`${resource.namespace}\t${resource.name}`);
|
||||
NODE
|
||||
)
|
||||
rm -f "$app_file"
|
||||
rm -f "$app_file" "$params_file"
|
||||
if [ -z "$target" ]; then
|
||||
printf '{}'
|
||||
return
|
||||
@@ -759,8 +777,10 @@ const pipelines = JSON.parse(fs.readFileSync(process.argv[3], 'utf8') || '[]');
|
||||
const latest = Array.isArray(pipelines) ? pipelines[0] || {} : {};
|
||||
const sourceCommit = latest.sourceCommit || null;
|
||||
const tag = sourceCommit ? String(sourceCommit).slice(0, 12) : '';
|
||||
const imageRepository = typeof params.image_repository === 'string' ? params.image_repository : '';
|
||||
const probeBase = typeof params.registry_probe_base === 'string' ? params.registry_probe_base.replace(/\/+$/u, '') : '';
|
||||
const prefix = process.env.UNIDESK_PAC_CONSUMER_ID === 'unidesk-host' ? 'unidesk_host_' : '';
|
||||
const param = (name) => params[`${prefix}${name}`] ?? params[name];
|
||||
const imageRepository = typeof param('image_repository') === 'string' ? param('image_repository') : '';
|
||||
const probeBase = typeof param('registry_probe_base') === 'string' ? param('registry_probe_base').replace(/\/+$/u, '') : '';
|
||||
let registryUrl = '';
|
||||
if (imageRepository && tag) {
|
||||
const firstSlash = imageRepository.indexOf('/');
|
||||
@@ -777,9 +797,14 @@ line('registryProbeBase', probeBase);
|
||||
line('sourceCommit', sourceCommit || '');
|
||||
line('tag', tag);
|
||||
line('registryUrl', registryUrl);
|
||||
line('sentinelId', params.sentinel_id || '');
|
||||
line('gitopsBranch', params.gitops_branch || '');
|
||||
line('gitopsManifestPath', params.gitops_manifest_path || '');
|
||||
line('sentinelId', param('sentinel_id') || '');
|
||||
line('gitopsBranch', param('gitops_branch') || '');
|
||||
line('gitopsManifestPath', param('gitops_manifest_path') || '');
|
||||
line('runtimeNamespace', param('runtime_namespace') || '');
|
||||
line('runtimeService', param('runtime_service') || '');
|
||||
line('runtimeServicePort', param('runtime_service_port') || '');
|
||||
line('healthPath', param('health_path') || '');
|
||||
line('healthUrl', param('health_url') || '');
|
||||
NODE
|
||||
)
|
||||
image_repository=$(printf '%s\n' "$probe_env" | sed -n 's/^imageRepository=//p' | base64 -d 2>/dev/null || true)
|
||||
@@ -790,6 +815,11 @@ NODE
|
||||
sentinel_id=$(printf '%s\n' "$probe_env" | sed -n 's/^sentinelId=//p' | base64 -d 2>/dev/null || true)
|
||||
gitops_branch=$(printf '%s\n' "$probe_env" | sed -n 's/^gitopsBranch=//p' | base64 -d 2>/dev/null || true)
|
||||
gitops_manifest_path=$(printf '%s\n' "$probe_env" | sed -n 's/^gitopsManifestPath=//p' | base64 -d 2>/dev/null || true)
|
||||
runtime_namespace=$(printf '%s\n' "$probe_env" | sed -n 's/^runtimeNamespace=//p' | base64 -d 2>/dev/null || true)
|
||||
runtime_service=$(printf '%s\n' "$probe_env" | sed -n 's/^runtimeService=//p' | base64 -d 2>/dev/null || true)
|
||||
runtime_service_port=$(printf '%s\n' "$probe_env" | sed -n 's/^runtimeServicePort=//p' | base64 -d 2>/dev/null || true)
|
||||
health_path=$(printf '%s\n' "$probe_env" | sed -n 's/^healthPath=//p' | base64 -d 2>/dev/null || true)
|
||||
health_url=$(printf '%s\n' "$probe_env" | sed -n 's/^healthUrl=//p' | base64 -d 2>/dev/null || true)
|
||||
registry_present=false
|
||||
registry_digest=""
|
||||
if [ -n "$registry_url" ]; then
|
||||
@@ -804,6 +834,26 @@ NODE
|
||||
fi
|
||||
registry_present_json=false
|
||||
if [ "$registry_present" = true ]; then registry_present_json=true; fi
|
||||
health_status=""
|
||||
if [ -n "$health_url" ]; then
|
||||
if [ -n "$runtime_namespace" ] && [ -n "$runtime_service" ] && [ -n "$runtime_service_port" ] && [ -n "$health_path" ]; then
|
||||
health_file=$(mktemp)
|
||||
if kubectl get --raw "/api/v1/namespaces/$runtime_namespace/services/$runtime_service:$runtime_service_port/proxy$health_path" >"$health_file" 2>/dev/null \
|
||||
&& node - "$health_file" <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
const body = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
|
||||
process.exit(body?.ok === true ? 0 : 1);
|
||||
NODE
|
||||
then
|
||||
health_status=200
|
||||
else
|
||||
health_status=503
|
||||
fi
|
||||
rm -f "$health_file"
|
||||
else
|
||||
health_status=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 8 "$health_url" 2>/dev/null || true)
|
||||
fi
|
||||
fi
|
||||
export UNIDESK_PAC_DIAG_IMAGE_REPOSITORY="$image_repository"
|
||||
export UNIDESK_PAC_DIAG_REGISTRY_PROBE_BASE="$registry_probe_base"
|
||||
export UNIDESK_PAC_DIAG_SOURCE_COMMIT="$source_commit"
|
||||
@@ -814,6 +864,8 @@ NODE
|
||||
export UNIDESK_PAC_DIAG_SENTINEL_ID="$sentinel_id"
|
||||
export UNIDESK_PAC_DIAG_GITOPS_BRANCH="$gitops_branch"
|
||||
export UNIDESK_PAC_DIAG_GITOPS_MANIFEST_PATH="$gitops_manifest_path"
|
||||
export UNIDESK_PAC_DIAG_HEALTH_URL="$health_url"
|
||||
export UNIDESK_PAC_DIAG_HEALTH_STATUS="$health_status"
|
||||
node - "$params_file" "$pipelines_file" "$artifact_file" "$argo_file" "$runtime_file" <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
function parseLoose(path, fallback) {
|
||||
@@ -836,12 +888,15 @@ const registryDigest = process.env.UNIDESK_PAC_DIAG_REGISTRY_DIGEST || null;
|
||||
const expectedDigest = registryDigest || artifact.digest || null;
|
||||
const runtimeImage = runtime.image || null;
|
||||
const runtimeMatches = !expectedDigest || (typeof runtimeImage === 'string' && runtimeImage.includes(expectedDigest));
|
||||
const healthUrl = process.env.UNIDESK_PAC_DIAG_HEALTH_URL || null;
|
||||
const healthStatus = process.env.UNIDESK_PAC_DIAG_HEALTH_STATUS || null;
|
||||
const healthReady = !healthUrl || healthStatus === '200';
|
||||
const gitopsReady = Boolean(artifact.gitopsCommit || argo.revision);
|
||||
let code = 'pac-diagnostics-not-applicable';
|
||||
let phase = 'not-applicable';
|
||||
let ok = true;
|
||||
let hint = 'consumer has no sentinel image_repository diagnostic config';
|
||||
if (params.image_repository) {
|
||||
if (process.env.UNIDESK_PAC_DIAG_IMAGE_REPOSITORY) {
|
||||
if (!sourceCommit) {
|
||||
ok = false; code = 'sentinel-pac-source-unknown'; phase = 'source-unknown'; hint = 'latest PaC PipelineRun did not expose a source commit';
|
||||
} else if (!registryPresent) {
|
||||
@@ -852,6 +907,8 @@ if (params.image_repository) {
|
||||
ok = false; code = 'sentinel-pac-argo-not-ready'; phase = 'gitops-ready-argo-pending'; hint = 'GitOps exists but Argo is not Synced/Healthy';
|
||||
} else if (!runtimeMatches) {
|
||||
ok = false; code = 'sentinel-pac-runtime-not-aligned'; phase = 'argo-ready-runtime-mismatch'; hint = 'runtime image does not match the registry digest observed for the source commit';
|
||||
} else if (!healthReady) {
|
||||
ok = false; code = 'pac-health-not-ready'; phase = 'runtime-ready-health-pending'; hint = 'runtime image is aligned but the configured health endpoint is not ready';
|
||||
} else {
|
||||
code = 'sentinel-pac-ready'; phase = 'ready'; hint = 'source, registry tag, GitOps, Argo and runtime are aligned';
|
||||
}
|
||||
@@ -877,6 +934,7 @@ process.stdout.write(JSON.stringify({
|
||||
},
|
||||
argo: { sync: argo.sync || null, health: argo.health || null, revision: argo.revision || null },
|
||||
runtime: { image: runtimeImage, digest: runtime.digest || null, readyReplicas: runtime.readyReplicas ?? null, replicas: runtime.replicas ?? null },
|
||||
health: { url: healthUrl, status: healthStatus, ready: healthReady },
|
||||
pipelineRun: latest.name || null,
|
||||
valuesPrinted: false,
|
||||
}));
|
||||
|
||||
@@ -120,6 +120,14 @@ interface PacConsumer {
|
||||
pipelineRunPrefix: string;
|
||||
argoNamespace: string;
|
||||
argoApplication: string;
|
||||
argoBootstrap: {
|
||||
project: string;
|
||||
repoUrl: string;
|
||||
targetRevision: string;
|
||||
path: string;
|
||||
destinationNamespace: string;
|
||||
automated: boolean;
|
||||
} | null;
|
||||
repositoryRef: string;
|
||||
closeoutGitOpsMirrorFlush: boolean;
|
||||
}
|
||||
@@ -315,6 +323,7 @@ function parseRepository(repository: Record<string, unknown>, path: string): Pac
|
||||
|
||||
function parseConsumer(consumer: Record<string, unknown>, path: string, defaultRepositoryRef: string): PacConsumer {
|
||||
const id = typeof consumer.id === "string" && consumer.id.length > 0 ? consumer.id : `${y.stringField(consumer, "node", path)}-${y.stringField(consumer, "lane", path)}`;
|
||||
const argoBootstrap = consumer.argoBootstrap === undefined ? null : y.objectField(consumer, "argoBootstrap", path);
|
||||
return {
|
||||
id,
|
||||
node: y.stringField(consumer, "node", path),
|
||||
@@ -324,6 +333,14 @@ function parseConsumer(consumer: Record<string, unknown>, path: string, defaultR
|
||||
pipelineRunPrefix: y.stringField(consumer, "pipelineRunPrefix", path),
|
||||
argoNamespace: y.kubernetesNameField(consumer, "argoNamespace", path),
|
||||
argoApplication: y.kubernetesNameField(consumer, "argoApplication", path),
|
||||
argoBootstrap: argoBootstrap === null ? null : {
|
||||
project: y.kubernetesNameField(argoBootstrap, "project", `${path}.argoBootstrap`),
|
||||
repoUrl: urlField(argoBootstrap, "repoUrl", `${path}.argoBootstrap`),
|
||||
targetRevision: y.stringField(argoBootstrap, "targetRevision", `${path}.argoBootstrap`),
|
||||
path: y.stringField(argoBootstrap, "path", `${path}.argoBootstrap`),
|
||||
destinationNamespace: y.kubernetesNameField(argoBootstrap, "destinationNamespace", `${path}.argoBootstrap`),
|
||||
automated: y.booleanField(argoBootstrap, "automated", `${path}.argoBootstrap`),
|
||||
},
|
||||
repositoryRef: typeof consumer.repositoryRef === "string" && consumer.repositoryRef.length > 0 ? consumer.repositoryRef : defaultRepositoryRef,
|
||||
closeoutGitOpsMirrorFlush: consumer.closeoutGitOpsMirrorFlush === undefined ? false : y.booleanField(consumer, "closeoutGitOpsMirrorFlush", path),
|
||||
};
|
||||
@@ -673,6 +690,7 @@ function remoteScript(action: "apply" | "status" | "history" | "webhook-test", p
|
||||
UNIDESK_PAC_DISPLAY_TIME_ZONE: pac.display.timeZone,
|
||||
UNIDESK_PAC_ARGO_NAMESPACE: consumer.argoNamespace,
|
||||
UNIDESK_PAC_ARGO_APPLICATION: consumer.argoApplication,
|
||||
UNIDESK_PAC_ARGO_BOOTSTRAP_MANIFEST_B64: Buffer.from(argoBootstrapManifest(consumer), "utf8").toString("base64"),
|
||||
UNIDESK_PAC_PART_OF: pacPartOf(consumer.id),
|
||||
UNIDESK_PAC_SPEC: kubernetesLabelValue(pac.metadata.relatedIssues.includes(1555) ? "GH-1552-GH-1555" : pac.metadata.spec),
|
||||
};
|
||||
@@ -698,11 +716,46 @@ function credentialPath(root: string, sourceRef: string): string {
|
||||
}
|
||||
|
||||
function pacPartOf(consumerId: string): string {
|
||||
if (consumerId === "unidesk-host") return "unidesk-host";
|
||||
if (consumerId.startsWith("sentinel")) return "hwlab-web-probe-sentinel";
|
||||
if (consumerId.startsWith("hwlab-")) return "hwlab";
|
||||
return "agentrun";
|
||||
}
|
||||
|
||||
function argoBootstrapManifest(consumer: PacConsumer): string {
|
||||
const bootstrap = consumer.argoBootstrap;
|
||||
if (bootstrap === null) return "";
|
||||
const application = {
|
||||
apiVersion: "argoproj.io/v1alpha1",
|
||||
kind: "Application",
|
||||
metadata: {
|
||||
name: consumer.argoApplication,
|
||||
namespace: consumer.argoNamespace,
|
||||
labels: {
|
||||
"app.kubernetes.io/managed-by": "unidesk",
|
||||
"app.kubernetes.io/part-of": pacPartOf(consumer.id),
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
project: bootstrap.project,
|
||||
source: {
|
||||
repoURL: bootstrap.repoUrl,
|
||||
targetRevision: bootstrap.targetRevision,
|
||||
path: bootstrap.path,
|
||||
},
|
||||
destination: {
|
||||
server: "https://kubernetes.default.svc",
|
||||
namespace: bootstrap.destinationNamespace,
|
||||
},
|
||||
syncPolicy: bootstrap.automated ? {
|
||||
automated: { prune: true, selfHeal: true },
|
||||
syncOptions: ["CreateNamespace=true"],
|
||||
} : undefined,
|
||||
},
|
||||
};
|
||||
return `${Bun.YAML.stringify(application).trim()}\n`;
|
||||
}
|
||||
|
||||
function parseLinePair(path: string, usernameLine: number, passwordLine: number): { username: string; password: string } {
|
||||
const lines = readFileSync(path, "utf8").split(/\r?\n/u);
|
||||
const username = lines[usernameLine - 1]?.trim() ?? "";
|
||||
|
||||
+30
-5
@@ -4,7 +4,14 @@ import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { type UniDeskConfig } from "./config";
|
||||
import { type DebugDispatchCommand, isDebugDispatchCommand } from "./debug";
|
||||
import { summarizeMicroserviceHealthResponse, summarizeMicroserviceObservation, summarizeMicroserviceProxyResponse } from "./microservices";
|
||||
import {
|
||||
parseMicroserviceProxyRequest,
|
||||
rewriteTodoNoteMisleadingRouteNotFound,
|
||||
runTodoNoteCheckPath,
|
||||
summarizeMicroserviceHealthResponse,
|
||||
summarizeMicroserviceObservation,
|
||||
summarizeMicroserviceProxyResponse,
|
||||
} from "./microservices";
|
||||
import { parseNetworkPerfOptions, runNetworkPerf } from "./network-perf";
|
||||
import {
|
||||
buildWindowsPowerShellInvocation,
|
||||
@@ -742,19 +749,35 @@ async function remoteMicroservice(session: FrontendSession, args: string[]): Pro
|
||||
};
|
||||
}
|
||||
if (action === "proxy" && id !== undefined && path !== undefined && path.startsWith("/")) {
|
||||
const request = parseMicroserviceProxyRequest(args);
|
||||
if (request.checkPath) {
|
||||
if (id !== "todo-note") throw new Error(`--check-path currently only supports service=todo-note; got ${id}`);
|
||||
return { transport: "frontend", response: runTodoNoteCheckPath(request.method, path) };
|
||||
}
|
||||
const full = args.includes("--full");
|
||||
const raw = args.includes("--raw");
|
||||
const maxBodyBytes = full ? numberOption(args, "--max-body-bytes", 5_000_000) : cappedNumberOption(args, "--max-body-bytes", raw ? 120_000 : 60_000, 500_000);
|
||||
const maxResponseBytes = full ? Math.min(Math.max(maxBodyBytes, 120_000), 5_000_000) : Math.min(Math.max(maxBodyBytes * 3, 240_000), 1_500_000);
|
||||
const response = await frontendJson(session, `/api/microservices/${encodeURIComponent(id)}/proxy${path}`, undefined, 24_000, maxResponseBytes);
|
||||
const response = await frontendJson(session, `/api/microservices/${encodeURIComponent(id)}/proxy${path}`, {
|
||||
method: request.method,
|
||||
body: request.body === undefined ? undefined : JSON.stringify(request.body),
|
||||
}, 30_000, maxResponseBytes);
|
||||
const rewritten = rewriteTodoNoteMisleadingRouteNotFound(id, request.method, path, response);
|
||||
return {
|
||||
transport: "frontend",
|
||||
response: summarizeMicroserviceProxyResponse(response, args),
|
||||
response: summarizeMicroserviceProxyResponse(rewritten, args),
|
||||
};
|
||||
}
|
||||
throw new Error("remote microservice command must be: microservice list | status <id> | health <id> | diagnostics <id> | tunnel-self-test <id> | proxy <id> <path>");
|
||||
}
|
||||
|
||||
function remoteMicroserviceOk(result: unknown): boolean {
|
||||
if (typeof result !== "object" || result === null || Array.isArray(result)) return true;
|
||||
const response = (result as { response?: unknown }).response;
|
||||
if (typeof response !== "object" || response === null || Array.isArray(response)) return true;
|
||||
return (response as { ok?: unknown }).ok !== false;
|
||||
}
|
||||
|
||||
function commandResultFromFrontendTask(command: string[], task: { status?: string; result?: Record<string, unknown> } | undefined) {
|
||||
const result = task?.result ?? {};
|
||||
const stdout = typeof result.stdout === "string" ? result.stdout : "";
|
||||
@@ -1615,8 +1638,10 @@ async function runRemoteCliOverFrontend(options: RemoteCliOptions, config: UniDe
|
||||
return 0;
|
||||
}
|
||||
if (top === "microservice") {
|
||||
emitRemoteJson(name, await remoteMicroservice(session, args));
|
||||
return 0;
|
||||
const result = await remoteMicroservice(session, args);
|
||||
const ok = remoteMicroserviceOk(result);
|
||||
emitRemoteJson(name, result, ok);
|
||||
return ok ? 0 : 1;
|
||||
}
|
||||
if (top === "artifact-registry") {
|
||||
emitRemoteJson(name, await remoteArtifactRegistry(session, args));
|
||||
|
||||
@@ -10,7 +10,7 @@ export interface TransSshBackendConfig {
|
||||
sourceRef: string;
|
||||
}
|
||||
|
||||
const defaultTransConfigPath = ".env/trans.ymal";
|
||||
const defaultTransConfigPath = "/root/.unidesk/.env/trans.ymal";
|
||||
|
||||
export function readTransSshBackendConfig(env: NodeJS.ProcessEnv = process.env): TransSshBackendConfig | null {
|
||||
const configPath = resolveTransConfigPath(env);
|
||||
|
||||
Reference in New Issue
Block a user