201 lines
12 KiB
JavaScript
Executable File
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();
|