feat: migrate todo note to nc01 github storage
This commit is contained in:
+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();
|
||||
Reference in New Issue
Block a user