feat: standardize user service artifact producer

This commit is contained in:
Codex
2026-05-19 10:05:15 +00:00
parent e866b0526a
commit 292d0cee60
4 changed files with 588 additions and 7 deletions
+341 -5
View File
@@ -1,7 +1,8 @@
import { randomUUID } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import { posix as posixPath } from "node:path";
import { runCommand } from "./command";
import { type UniDeskConfig, repoRoot, rootPath } from "./config";
import { type UniDeskConfig, type UniDeskMicroserviceConfig, repoRoot, rootPath } from "./config";
import { ensureGithubSshIdentityForProvider, gitSshHttpConnectProxySource } from "./deploy-ssh-identity";
import { startJob } from "./jobs";
import { coreInternalFetch } from "./microservices";
@@ -42,6 +43,16 @@ interface CiPublishBackendCoreOptions {
sourceHostPath: string;
}
interface CiPublishUserServiceArtifactOptions {
repoUrl: string;
commit: string;
waitMs: number;
sourceHostPath: string;
serviceId: string;
dockerfile: string;
dryRun: boolean;
}
interface CiDevE2EOptions {
repoUrl: string;
desiredRef: string;
@@ -92,6 +103,19 @@ interface DeployDevManifestSummary {
services: Array<{ id: string; commitId: string; repo: string }>;
}
interface ArtifactSummary {
serviceId: string;
sourceCommit: string;
sourceRepo: string;
dockerfile: string;
registry: string;
repository: string;
tag: string;
imageRef: string;
digest: string | null;
digestRef: string | null;
}
function stringOption(args: string[], name: string): string | null {
const index = args.indexOf(name);
if (index === -1) return null;
@@ -120,6 +144,14 @@ function requireFullCommit(value: string | null, option = "--commit"): string {
return commit;
}
function requireServiceId(value: string | null): string {
const serviceId = value ?? "";
if (!/^[a-z0-9]([-a-z0-9]{0,62}[a-z0-9])?$/u.test(serviceId)) {
throw new Error("ci publish-user-service requires --service <dns-safe-user-service-id>");
}
return serviceId;
}
function requireDesiredRef(value: string | null): string {
const ref = value ?? "master";
if (!/^[A-Za-z0-9._/-]{1,160}$/u.test(ref) || ref.startsWith("-") || ref.includes("..")) {
@@ -149,6 +181,30 @@ function repoSshUrl(repoUrl: string): string {
return repoUrl;
}
function requireRepoRelativePath(path: string, label: string): string {
if (path.length === 0 || path.startsWith("/") || path.includes("\0") || path.split("/").includes("..")) {
throw new Error(`${label} must be a repo-relative path`);
}
const normalized = posixPath.normalize(path);
if (normalized === "." || normalized.startsWith("../")) throw new Error(`${label} must be a repo-relative path`);
return normalized;
}
function requireSupportedUserService(config: UniDeskConfig, serviceId: string): UniDeskMicroserviceConfig {
if (serviceId === "backend-core") {
throw new Error("backend-core uses ci publish-backend-core; publish-user-service is for registered user services");
}
const service = config.microservices.find((item) => item.id === serviceId);
if (service === undefined) throw new Error(`unknown user service: ${serviceId}`);
if (service.providerId !== d601ProviderId || service.development.providerId !== d601ProviderId) {
throw new Error(`ci publish-user-service currently supports only D601 user services; ${serviceId} is assigned to ${service.providerId}`);
}
if (service.deployment.mode !== "k3sctl-managed") {
throw new Error(`ci publish-user-service currently supports k3sctl-managed D601 user services; ${serviceId} uses ${service.deployment.mode}`);
}
return service;
}
function chunks(value: string, size: number): string[] {
const result: string[] = [];
for (let index = 0; index < value.length; index += size) {
@@ -542,10 +598,49 @@ spec:
`;
}
function userServiceArtifactPipelineRunManifest(options: CiPublishUserServiceArtifactOptions): string {
const safeSuffix = new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14).toLowerCase();
return `apiVersion: tekton.dev/v1
kind: PipelineRun
metadata:
generateName: user-service-artifact-${options.serviceId}-${safeSuffix}-
namespace: unidesk-ci
labels:
app.kubernetes.io/name: unidesk-user-service-artifact-publish
app.kubernetes.io/part-of: unidesk
unidesk.ai/service-id: ${JSON.stringify(options.serviceId)}
unidesk.ai/revision: ${JSON.stringify(options.commit)}
spec:
pipelineRef:
name: unidesk-user-service-artifact-publish
taskRunTemplate:
serviceAccountName: unidesk-ci-runner
params:
- name: repo-url
value: ${JSON.stringify(options.repoUrl)}
- name: revision
value: ${JSON.stringify(options.commit)}
- name: service-id
value: ${JSON.stringify(options.serviceId)}
- name: dockerfile
value: ${JSON.stringify(options.dockerfile)}
- name: source-host-path
value: ${JSON.stringify(options.sourceHostPath)}
workspaces:
- name: shared-workspace
persistentVolumeClaim:
claimName: unidesk-ci-cache
`;
}
function backendCoreArtifactSourceHostPath(commit: string): string {
return `/home/ubuntu/.unidesk/ci/backend-core-artifacts/${commit}`;
}
function userServiceArtifactSourceHostPath(serviceId: string, commit: string): string {
return `/home/ubuntu/.unidesk/ci/user-service-artifacts/${serviceId}/${commit}`;
}
async function prepareBackendCoreArtifactSource(config: UniDeskConfig, options: CiPublishBackendCoreOptions): Promise<Record<string, unknown>> {
const sshIdentity = await ensureGithubSshIdentityForProvider(config, d601ProviderId);
if (!sshIdentity.ok) throw new Error(sshIdentity.detail);
@@ -613,6 +708,82 @@ async function prepareBackendCoreArtifactSource(config: UniDeskConfig, options:
};
}
async function prepareUserServiceArtifactSource(config: UniDeskConfig, options: CiPublishUserServiceArtifactOptions): Promise<Record<string, unknown>> {
const sshIdentity = await ensureGithubSshIdentityForProvider(config, d601ProviderId);
if (!sshIdentity.ok) throw new Error(sshIdentity.detail);
const proxyPython = gitSshHttpConnectProxySource();
const sourceRoot = `/home/ubuntu/.unidesk/ci/user-service-artifacts/${options.serviceId}`;
const sourceHostPath = options.sourceHostPath;
const repoCache = "/home/ubuntu/.unidesk/ci/git/unidesk.git";
const repoFetchUrl = repoSshUrl(options.repoUrl);
const dockerfileDir = posixPath.dirname(options.dockerfile);
const script = [
"set -euo pipefail",
`service_id=${shellQuote(options.serviceId)}`,
`commit=${shellQuote(options.commit)}`,
`repo_url=${shellQuote(options.repoUrl)}`,
`repo_fetch_url=${shellQuote(repoFetchUrl)}`,
`dockerfile=${shellQuote(options.dockerfile)}`,
`dockerfile_dir=${shellQuote(dockerfileDir)}`,
`source_root=${shellQuote(sourceRoot)}`,
`source_dir=${shellQuote(sourceHostPath)}`,
`repo_cache=${shellQuote(repoCache)}`,
`proxy_url=${shellQuote(providerGatewayWsEgressProxyUrl)}`,
"mkdir -p \"$(dirname \"$repo_cache\")\" \"$source_root\"",
"export HTTP_PROXY=\"$proxy_url\" HTTPS_PROXY=\"$proxy_url\" ALL_PROXY=\"$proxy_url\"",
"export NO_PROXY=\"localhost,127.0.0.1,::1,host.docker.internal,.svc,.cluster.local,kubernetes.default.svc\"",
"curl -fsSI --max-time 20 -x \"$proxy_url\" https://github.com >/dev/null",
"git_ssh_proxy=/tmp/unidesk-git-ssh-http-connect.py",
"cat > \"$git_ssh_proxy\" <<'UNIDESK_GIT_SSH_PROXY'",
proxyPython,
"UNIDESK_GIT_SSH_PROXY",
"chmod 700 \"$git_ssh_proxy\"",
"export UNIDESK_GIT_SSH_HTTP_PROXY=\"$proxy_url\"",
"export GIT_SSH_COMMAND=\"ssh -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$HOME/.ssh/known_hosts -i $HOME/.ssh/id_ed25519 -o 'ProxyCommand=$git_ssh_proxy %h %p'\"",
"echo user_service_artifact_source_proxy=provider-gateway-ws-egress:$proxy_url",
"echo user_service_artifact_repo_fetch_url=$repo_fetch_url",
"echo user_service_artifact_service_id=$service_id",
"if [ ! -d \"$repo_cache\" ]; then git clone --mirror \"$repo_fetch_url\" \"$repo_cache\"; fi",
"git -C \"$repo_cache\" remote set-url origin \"$repo_fetch_url\"",
"git -C \"$repo_cache\" fetch --no-tags origin \"$commit\" || git -C \"$repo_cache\" fetch --no-tags origin '+refs/heads/*:refs/remotes/origin/*'",
"resolved=$(git -C \"$repo_cache\" rev-parse --verify \"$commit^{commit}\")",
"test \"$resolved\" = \"$commit\" || { echo \"user_service_artifact_resolved_commit_mismatch=$resolved expected=$commit\" >&2; exit 1; }",
"git -C \"$repo_cache\" cat-file -e \"$commit:$dockerfile\"",
"git -C \"$repo_cache\" cat-file -e \"$commit:$dockerfile_dir/src\"",
"tmp_dir=\"$source_root/.tmp-$commit-$$\"",
"rm -rf \"$tmp_dir\"",
"mkdir -p \"$tmp_dir\"",
"git -C \"$repo_cache\" archive \"$commit\" | tar -x -C \"$tmp_dir\"",
"printf '%s\\n' \"$commit\" > \"$tmp_dir/.unidesk-source-commit\"",
"printf '%s\\n' \"$repo_url\" > \"$tmp_dir/.unidesk-source-repo\"",
"printf '%s\\n' \"$service_id\" > \"$tmp_dir/.unidesk-service-id\"",
"printf '%s\\n' \"$dockerfile\" > \"$tmp_dir/.unidesk-dockerfile\"",
"rm -rf \"$source_dir\"",
"mv \"$tmp_dir\" \"$source_dir\"",
"test -f \"$source_dir/$dockerfile\"",
"test -d \"$source_dir/$dockerfile_dir/src\"",
"echo user_service_artifact_source_host_path=$source_dir",
].join("\n");
const result = await runRemoteBackground(`prepare-${options.serviceId}-source`, script, 300_000);
if (!result.ok) throw new Error(`failed to prepare ${options.serviceId} source on D601: ${result.stderr || result.stdout || JSON.stringify(result.raw)}`);
return {
ok: true,
mode: "d601-host-github-ssh-export",
providerId: d601ProviderId,
repoUrl: options.repoUrl,
repoFetchUrl,
commit: options.commit,
serviceId: options.serviceId,
dockerfile: options.dockerfile,
sourceHostPath,
identity: {
fingerprint: sshIdentity.fingerprint,
seededFromLocal: sshIdentity.seededFromLocal,
},
stdoutTail: result.stdout.slice(-4000),
};
}
async function remoteCreatePipelineRun(manifest: string): Promise<string> {
const encoded = Buffer.from(manifest, "utf8").toString("base64");
const token = randomUUID().replace(/-/gu, "").slice(0, 12);
@@ -689,6 +860,46 @@ function pipelineRunWaitSucceeded(wait: DispatchResult | null, condition: Pipeli
return wait.ok || wait.exitCode === 0 || wait.stdout.includes("condition=True\tSucceeded\t");
}
function parseArtifactSummaryFromOutput(output: string, serviceId: string, commit: string, repoUrl: string, dockerfile: string): ArtifactSummary {
const fields = new Map<string, string>();
for (const line of output.split(/\r?\n/u)) {
const match = /^(user_service_artifact_[a-z_]+|backend_core_artifact_[a-z_]+)=(.*)$/u.exec(line.trim());
if (match !== null) fields.set(match[1], match[2]);
}
const digest = fields.get("user_service_artifact_digest") ?? fields.get("backend_core_artifact_digest") ?? null;
const imageRef = fields.get("user_service_artifact_image") ?? fields.get("backend_core_artifact_image") ?? `127.0.0.1:5000/unidesk/${serviceId}:${commit}`;
const repository = fields.get("user_service_artifact_repository") ?? `127.0.0.1:5000/unidesk/${serviceId}`;
const digestRef = fields.get("user_service_artifact_digest_ref") ?? fields.get("backend_core_artifact_digest_ref") ?? (digest === null || digest.length === 0 ? null : `${repository}@${digest}`);
return {
serviceId: fields.get("user_service_artifact_service_id") ?? serviceId,
sourceCommit: fields.get("user_service_artifact_source_commit") ?? fields.get("backend_core_artifact_source_commit") ?? commit,
sourceRepo: fields.get("user_service_artifact_source_repo") ?? repoUrl,
dockerfile: fields.get("user_service_artifact_dockerfile") ?? dockerfile,
registry: fields.get("user_service_artifact_registry") ?? "127.0.0.1:5000",
repository,
tag: fields.get("user_service_artifact_tag") ?? commit,
imageRef,
digest,
digestRef,
};
}
function assertArtifactSummaryComplete(artifact: ArtifactSummary, pipelineRun: string): void {
if (artifact.serviceId.length === 0 || artifact.sourceCommit.length !== 40 || artifact.imageRef.length === 0 || artifact.tag.length === 0 || artifact.digest === null || artifact.digest.length === 0 || artifact.digestRef === null || artifact.digestRef.length === 0) {
throw new Error(`artifact summary for ${pipelineRun} is incomplete`);
}
}
async function readPipelineRunLogText(name: string): Promise<string> {
const result = await runRemoteKubectlRaw([
"set -euo pipefail",
`kubectl get pipelinerun/${shellQuote(name)} -n unidesk-ci -o wide`,
`kubectl get taskrun -n unidesk-ci -l tekton.dev/pipelineRun=${shellQuote(name)} -o wide`,
`for pod in $(kubectl get pods -n unidesk-ci -l tekton.dev/pipelineRun=${shellQuote(name)} -o name); do echo "===== $pod"; kubectl logs -n unidesk-ci "$pod" --all-containers=true --tail=240 || true; done`,
].join("\n"), 60_000, 45_000);
return `${result.stdout}\n${result.stderr}`.trim();
}
async function run(options: CiOptions): Promise<Record<string, unknown>> {
const name = await remoteCreatePipelineRun(pipelineRunManifest(options));
const wait = await waitForPipelineRun(name, options.waitMs);
@@ -717,11 +928,27 @@ async function run(options: CiOptions): Promise<Record<string, unknown>> {
}
async function publishBackendCoreArtifact(config: UniDeskConfig, options: CiPublishBackendCoreOptions): Promise<Record<string, unknown>> {
const plannedArtifact: ArtifactSummary = {
serviceId: "backend-core",
sourceCommit: options.commit,
sourceRepo: options.repoUrl,
dockerfile: "src/components/backend-core/Dockerfile",
registry: "127.0.0.1:5000",
repository: "127.0.0.1:5000/unidesk/backend-core",
tag: options.commit,
imageRef: `127.0.0.1:5000/unidesk/backend-core:${options.commit}`,
digest: null,
digestRef: null,
};
const source = await prepareBackendCoreArtifactSource(config, options);
const name = await remoteCreatePipelineRun(backendCoreArtifactPipelineRunManifest(options));
const wait = await waitForPipelineRun(name, options.waitMs);
const condition = wait === null ? null : await readPipelineRunCondition(name);
const waitSucceeded = pipelineRunWaitSucceeded(wait, condition);
const artifact = waitSucceeded && wait !== null
? parseArtifactSummaryFromOutput(await readPipelineRunLogText(name), "backend-core", options.commit, options.repoUrl, "src/components/backend-core/Dockerfile")
: plannedArtifact;
if (waitSucceeded && wait !== null) assertArtifactSummaryComplete(artifact, name);
return {
ok: waitSucceeded,
pipelineRun: name,
@@ -729,7 +956,8 @@ async function publishBackendCoreArtifact(config: UniDeskConfig, options: CiPubl
repoUrl: options.repoUrl,
commit: options.commit,
source,
artifact: `127.0.0.1:5000/unidesk/backend-core:${options.commit}`,
artifact: artifact.imageRef,
artifactSummary: artifact,
boundary: "CI publishes the image to D601 registry; CD must pull it and must not build backend-core",
wait: wait === null ? null : {
ok: waitSucceeded,
@@ -747,6 +975,84 @@ async function publishBackendCoreArtifact(config: UniDeskConfig, options: CiPubl
};
}
async function publishUserServiceArtifact(config: UniDeskConfig, options: CiPublishUserServiceArtifactOptions): Promise<Record<string, unknown>> {
const plannedArtifact: ArtifactSummary = {
serviceId: options.serviceId,
sourceCommit: options.commit,
sourceRepo: options.repoUrl,
dockerfile: options.dockerfile,
registry: "127.0.0.1:5000",
repository: `127.0.0.1:5000/unidesk/${options.serviceId}`,
tag: options.commit,
imageRef: `127.0.0.1:5000/unidesk/${options.serviceId}:${options.commit}`,
digest: null,
digestRef: null,
} satisfies ArtifactSummary;
if (options.dryRun) {
return {
ok: true,
mode: "dry-run",
pipeline: "unidesk-user-service-artifact-publish",
namespace: "unidesk-ci",
repoUrl: options.repoUrl,
commit: options.commit,
serviceId: options.serviceId,
sourceHostPath: options.sourceHostPath,
source: {
ok: true,
mode: "planned-only",
providerId: d601ProviderId,
repoUrl: options.repoUrl,
repoFetchUrl: repoSshUrl(options.repoUrl),
commit: options.commit,
serviceId: options.serviceId,
dockerfile: options.dockerfile,
sourceHostPath: options.sourceHostPath,
},
artifact: plannedArtifact.imageRef,
artifactSummary: plannedArtifact,
boundary: "dry-run only; no D601 source export, no Tekton submission, no production mutation",
next: [
`bun scripts/cli.ts ci publish-user-service --service ${options.serviceId} --commit ${options.commit} --wait-ms 1200000`,
],
};
}
const source = await prepareUserServiceArtifactSource(config, options);
const name = await remoteCreatePipelineRun(userServiceArtifactPipelineRunManifest(options));
const wait = await waitForPipelineRun(name, options.waitMs);
const condition = wait === null ? null : await readPipelineRunCondition(name);
const waitSucceeded = pipelineRunWaitSucceeded(wait, condition);
const artifact = waitSucceeded && wait !== null
? parseArtifactSummaryFromOutput(await readPipelineRunLogText(name), options.serviceId, options.commit, options.repoUrl, options.dockerfile)
: plannedArtifact;
if (waitSucceeded && wait !== null) assertArtifactSummaryComplete(artifact, name);
return {
ok: waitSucceeded,
pipelineRun: name,
namespace: "unidesk-ci",
repoUrl: options.repoUrl,
commit: options.commit,
serviceId: options.serviceId,
source,
artifact: artifact.imageRef,
artifactSummary: artifact,
boundary: "CI publishes the user-service image to the D601 registry only; it must not deploy production or mutate the production namespace",
wait: wait === null ? null : {
ok: waitSucceeded,
dispatchOk: wait.ok,
dispatchStatus: wait.status,
dispatchExitCode: wait.exitCode,
stdoutTail: wait.stdout.slice(-6000),
stderrTail: wait.stderr.slice(-6000),
},
condition,
next: [
`bun scripts/cli.ts ci logs ${name}`,
"use artifactSummary.imageRef or artifactSummary.digestRef as later dev/prod deployment input",
],
};
}
async function runRemoteDevE2ELauncher(options: CiDevE2EOptions): Promise<DispatchResult> {
const scriptTimeoutMs = Math.max(options.scriptTimeoutMs, options.waitMs, 60_000);
const remoteTimeoutMs = 45_000;
@@ -995,12 +1301,13 @@ async function logs(name: string): Promise<Record<string, unknown>> {
export function ciHelp(): Record<string, unknown> {
return {
command: "ci install|status|run|publish-backend-core|run-dev-e2e|logs",
description: "Manage the D601 k3s Tekton CI gate. CI may publish backend-core image artifacts, but it intentionally does not deploy CD.",
command: "ci install|status|run|publish-backend-core|publish-user-service|run-dev-e2e|logs",
description: "Manage the D601 k3s Tekton CI gate. CI may publish commit-pinned image artifacts, but it intentionally does not deploy CD.",
examples: [
"bun scripts/cli.ts ci install",
"bun scripts/cli.ts ci run --revision <commit>",
"bun scripts/cli.ts ci publish-backend-core --commit <full-sha>",
"bun scripts/cli.ts ci publish-user-service --service decision-center --commit <full-sha>",
"bun scripts/cli.ts ci run-dev-e2e --wait-ms 600000",
"bun scripts/cli.ts ci logs <runId>",
],
@@ -1018,6 +1325,14 @@ export function ciHelp(): Record<string, unknown> {
registry: "127.0.0.1:5000/unidesk/backend-core:<commit>",
cdCommand: "bun scripts/cli.ts artifact-registry deploy-backend-core --commit <full-sha>",
},
userServiceArtifact: {
producer: "D601 CI",
command: "bun scripts/cli.ts ci publish-user-service --service <service-id> --commit <full-sha>",
initiallySupportedServices: ["decision-center"],
registry: "127.0.0.1:5000/unidesk/<service-id>:<commit>",
outputFields: ["imageRef", "tag", "digest", "sourceCommit", "serviceId"],
boundary: "artifact producer only; no prod deploy and no production namespace mutation",
},
runDevE2E: {
defaultTriggerMode: "commit-pinned-ssh-launcher",
desiredState: "origin/master:deploy.json#environments.dev",
@@ -1050,6 +1365,27 @@ export async function runCiCommand(config: UniDeskConfig, args: string[]): Promi
const waitMs = numberOption(args, "--wait-ms", 0);
return publishBackendCoreArtifact(config, { repoUrl, commit, waitMs, sourceHostPath: backendCoreArtifactSourceHostPath(commit) });
}
if (action === "publish-user-service") {
const serviceId = requireServiceId(stringOption(args, "--service") ?? stringOption(args, "--service-id"));
const service = requireSupportedUserService(config, serviceId);
const repoUrl = stringOption(args, "--repo") ?? stringOption(args, "--repo-url") ?? service.repository.url;
const commit = requireFullCommit(stringOption(args, "--commit") ?? stringOption(args, "--revision"));
const waitMs = numberOption(args, "--wait-ms", 0);
const dryRun = boolFlag(args, "--dry-run");
const dockerfile = requireRepoRelativePath(service.repository.dockerfile, `microservices.${serviceId}.repository.dockerfile`);
if (serviceId !== "decision-center") {
throw new Error("ci publish-user-service currently allows only decision-center until each user-service Dockerfile contract is reviewed");
}
return publishUserServiceArtifact(config, {
repoUrl,
commit,
waitMs,
serviceId,
dockerfile,
sourceHostPath: userServiceArtifactSourceHostPath(serviceId, commit),
dryRun,
});
}
if (action === "run-dev-e2e") {
const repoUrl = stringOption(args, "--repo") ?? stringOption(args, "--repo-url") ?? "https://github.com/pikasTech/unidesk";
const desiredRef = requireDesiredRef(stringOption(args, "--desired-ref") ?? stringOption(args, "--deploy-branch"));
@@ -1071,7 +1407,7 @@ export async function runCiCommand(config: UniDeskConfig, args: string[]): Promi
});
}
if (action === "logs") return logs(nameArg ?? "");
throw new Error("ci command must be one of: install, status, run, publish-backend-core, run-dev-e2e, logs");
throw new Error("ci command must be one of: install, status, run, publish-backend-core, publish-user-service, run-dev-e2e, logs");
}
export function startCiInstallJob(): Record<string, unknown> {