Files
pikasTech-unidesk/scripts/native/deploy/render-unidesk-host-service.mjs
T

201 lines
12 KiB
JavaScript
Executable File

#!/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();