Fix Code Queue runner skills delivery
This commit is contained in:
+214
-4
@@ -1,6 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { posix as posixPath } from "node:path";
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, posix as posixPath } from "node:path";
|
||||
import { blockedCatalogArtifactIds, catalogSummary, findCiCatalogArtifact, loadCiCatalog, supportedSourceBuildArtifactIds, type CiCatalogArtifact, type CiSourceBuildCatalogArtifact, type CiUpstreamImageCatalogArtifact } from "./ci-catalog";
|
||||
import { runCommand } from "./command";
|
||||
import { type UniDeskConfig, repoRoot, rootPath } from "./config";
|
||||
@@ -63,8 +64,11 @@ interface CiPublishUserServiceArtifactOptions {
|
||||
dockerfile: string;
|
||||
imageRepository: string;
|
||||
dryRun: boolean;
|
||||
transport: CiPublishTransport;
|
||||
}
|
||||
|
||||
type CiPublishTransport = "auto" | "tekton" | "direct-docker";
|
||||
|
||||
interface CiDevE2EOptions {
|
||||
repoUrl: string;
|
||||
desiredRef: string;
|
||||
@@ -232,6 +236,12 @@ function boolFlag(args: string[], name: string): boolean {
|
||||
return args.includes(name);
|
||||
}
|
||||
|
||||
function publishTransportOption(value: string | null): CiPublishTransport {
|
||||
if (value === null || value === "auto") return "auto";
|
||||
if (value === "tekton" || value === "direct-docker") return value;
|
||||
throw new Error("ci publish-user-service --transport must be one of: auto, tekton, direct-docker");
|
||||
}
|
||||
|
||||
function isHelpArg(value: string | undefined): boolean {
|
||||
return value === "help" || value === "--help" || value === "-h";
|
||||
}
|
||||
@@ -1478,6 +1488,195 @@ function missingArtifactSummaryFields(artifact: ArtifactSummary): string[] {
|
||||
return missing;
|
||||
}
|
||||
|
||||
function dockerArtifactDigest(repository: string, imageRef: string): string | null {
|
||||
const inspect = runCommand(["docker", "image", "inspect", imageRef, "--format", "{{range .RepoDigests}}{{println .}}{{end}}"], repoRoot, { timeoutMs: 30_000 });
|
||||
if (inspect.exitCode !== 0) return null;
|
||||
for (const line of inspect.stdout.split(/\r?\n/u).map((item) => item.trim()).filter(Boolean)) {
|
||||
const [repo, digest] = line.split("@");
|
||||
if (repo === repository && /^sha256:[0-9a-f]{64}$/u.test(digest ?? "")) return digest ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function registryManifestDigest(repository: string, tag: string): string | null {
|
||||
const registry = "127.0.0.1:5000";
|
||||
if (!repository.startsWith(`${registry}/`)) return null;
|
||||
const repositoryPath = repository.slice(`${registry}/`.length);
|
||||
if (repositoryPath.length === 0 || repositoryPath.includes("..") || tag.length === 0) return null;
|
||||
const result = runCommand([
|
||||
"curl",
|
||||
"-fsSI",
|
||||
"-H",
|
||||
"Accept: application/vnd.docker.distribution.manifest.v2+json",
|
||||
`http://${registry}/v2/${repositoryPath}/manifests/${tag}`,
|
||||
], repoRoot, { timeoutMs: 30_000 });
|
||||
if (result.exitCode !== 0) return null;
|
||||
const match = /^Docker-Content-Digest:\s*(sha256:[0-9a-f]{64})\s*$/imu.exec(result.stdout);
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
function assertCommandOk(result: ReturnType<typeof runCommand>, label: string): void {
|
||||
if (result.exitCode === 0 && !result.timedOut) return;
|
||||
throw new Error(`${label} failed: ${result.stderr.slice(-2000) || result.stdout.slice(-2000) || `exitCode=${result.exitCode}`}`);
|
||||
}
|
||||
|
||||
function buildContextForService(serviceId: string, dockerfile: string): string {
|
||||
return serviceId === "claudeqq" ? posixPath.dirname(dockerfile) : ".";
|
||||
}
|
||||
|
||||
function directDockerSourceGitMode(repoUrl: string): "local-unidesk-worktree" | "git-archive" {
|
||||
return repoUrl === "https://github.com/pikasTech/unidesk" ? "local-unidesk-worktree" : "git-archive";
|
||||
}
|
||||
|
||||
async function prepareDirectDockerUserServiceSource(options: CiPublishUserServiceArtifactOptions): Promise<{ path: string; cleanup: () => void; summary: Record<string, unknown> }> {
|
||||
const tempRoot = mkdtempSync(join(tmpdir(), `unidesk-ci-${options.serviceId}-${options.commit.slice(0, 8)}-`));
|
||||
const sourcePath = join(tempRoot, "source");
|
||||
const gitMode = directDockerSourceGitMode(options.repoUrl);
|
||||
if (gitMode !== "local-unidesk-worktree") {
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
throw new Error("direct-docker publish currently supports only UniDesk repo-owned source-build artifacts; use --transport tekton for external repositories");
|
||||
}
|
||||
const resolved = runCommand(["git", "rev-parse", "--verify", `${options.commit}^{commit}`], repoRoot, { timeoutMs: 30_000 });
|
||||
assertCommandOk(resolved, "resolve source commit");
|
||||
if (resolved.stdout.trim() !== options.commit) {
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
throw new Error(`direct-docker source commit mismatch: resolved ${resolved.stdout.trim()} expected ${options.commit}`);
|
||||
}
|
||||
const dockerfileExists = runCommand(["git", "cat-file", "-e", `${options.commit}:${options.dockerfile}`], repoRoot, { timeoutMs: 30_000 });
|
||||
assertCommandOk(dockerfileExists, "verify source dockerfile");
|
||||
const worktree = runCommand(["git", "worktree", "add", "--detach", sourcePath, options.commit], repoRoot, { timeoutMs: 120_000 });
|
||||
if (worktree.exitCode !== 0) {
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
throw new Error(`prepare source worktree failed: ${worktree.stderr || worktree.stdout}`);
|
||||
}
|
||||
return {
|
||||
path: sourcePath,
|
||||
cleanup: () => {
|
||||
const removed = runCommand(["git", "worktree", "remove", "--force", sourcePath], repoRoot, { timeoutMs: 60_000 });
|
||||
if (removed.exitCode !== 0) rmSync(tempRoot, { recursive: true, force: true });
|
||||
else rmSync(tempRoot, { recursive: true, force: true });
|
||||
},
|
||||
summary: {
|
||||
ok: true,
|
||||
mode: gitMode,
|
||||
providerId: "local",
|
||||
repoUrl: options.repoUrl,
|
||||
commit: options.commit,
|
||||
serviceId: options.serviceId,
|
||||
dockerfile: options.dockerfile,
|
||||
sourceHostPath: sourcePath,
|
||||
valuesPrinted: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function publishUserServiceArtifactDirectDocker(options: CiPublishUserServiceArtifactOptions, context: ArtifactSummaryContext): Promise<Record<string, unknown>> {
|
||||
const source = await prepareDirectDockerUserServiceSource(options);
|
||||
const planned = artifactSummaryDefaults(context);
|
||||
const localImage = `${options.imageRepository}:${options.commit}`;
|
||||
const buildContext = buildContextForService(options.serviceId, options.dockerfile);
|
||||
const baseArgs = options.serviceId === "code-queue" && runCommand(["docker", "image", "inspect", "unidesk-code-queue:d601"], repoRoot, { timeoutMs: 30_000 }).exitCode === 0
|
||||
? ["--build-arg", "CODE_QUEUE_BASE_IMAGE=unidesk-code-queue:d601"]
|
||||
: [];
|
||||
try {
|
||||
if (options.serviceId === "code-queue" && baseArgs.length === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
runnerDisposition: "infra-blocked",
|
||||
failureClassification: "ci-runner-not-ready",
|
||||
failureKind: "code-queue-base-image-missing",
|
||||
serviceId: options.serviceId,
|
||||
commit: options.commit,
|
||||
artifactSummary: planned,
|
||||
source: source.summary,
|
||||
artifact: planned.imageRef,
|
||||
transport: "direct-docker",
|
||||
controlledPublish: {
|
||||
environment: "DEV-local-artifact",
|
||||
command: `bun scripts/cli.ts ci publish-user-service --service ${options.serviceId} --commit ${options.commit} --transport direct-docker`,
|
||||
noRollout: true,
|
||||
noRuntimeMutation: true,
|
||||
},
|
||||
boundary: "direct-docker CI artifact publish builds and pushes a commit-pinned image only; no deploy apply, no rollout, no Code Queue restart, no active task mutation",
|
||||
next: [
|
||||
"Restore or warm local unidesk-code-queue:d601 base image, then rerun the same direct-docker publish command.",
|
||||
],
|
||||
};
|
||||
}
|
||||
const build = runCommand([
|
||||
"docker",
|
||||
"build",
|
||||
"--label",
|
||||
`unidesk.ai/service-id=${options.serviceId}`,
|
||||
"--label",
|
||||
`unidesk.ai/source-repo=${options.repoUrl}`,
|
||||
"--label",
|
||||
`unidesk.ai/source-commit=${options.commit}`,
|
||||
"--label",
|
||||
`unidesk.ai/dockerfile=${options.dockerfile}`,
|
||||
...baseArgs,
|
||||
"-t",
|
||||
localImage,
|
||||
"-t",
|
||||
planned.imageRef,
|
||||
"-f",
|
||||
options.dockerfile,
|
||||
buildContext,
|
||||
], source.path, { timeoutMs: Math.max(options.waitMs, 20 * 60_000) });
|
||||
assertCommandOk(build, "direct docker build");
|
||||
const inspectLabels = runCommand(["docker", "image", "inspect", planned.imageRef, "--format", "{{ index .Config.Labels \"unidesk.ai/service-id\" }} {{ index .Config.Labels \"unidesk.ai/source-commit\" }}"], repoRoot, { timeoutMs: 30_000 });
|
||||
assertCommandOk(inspectLabels, "inspect built image labels");
|
||||
if (!inspectLabels.stdout.includes(`${options.serviceId} ${options.commit}`)) throw new Error(`direct docker image labels did not match ${options.serviceId}/${options.commit}`);
|
||||
const registryCheck = runCommand(["curl", "-fsS", "http://127.0.0.1:5000/v2/"], repoRoot, { timeoutMs: 15_000 });
|
||||
assertCommandOk(registryCheck, "D601 loopback artifact registry health");
|
||||
const push = runCommand(["docker", "push", planned.imageRef], repoRoot, { timeoutMs: Math.max(options.waitMs, 10 * 60_000) });
|
||||
assertCommandOk(push, "direct docker push");
|
||||
const pull = runCommand(["docker", "pull", planned.imageRef], repoRoot, { timeoutMs: 120_000 });
|
||||
assertCommandOk(pull, "direct docker pull verification");
|
||||
const digest = dockerArtifactDigest(planned.repository, planned.imageRef) ?? registryManifestDigest(planned.repository, planned.tag);
|
||||
const artifact: ArtifactSummary = {
|
||||
...planned,
|
||||
digest,
|
||||
digestRef: digest === null ? null : `${planned.repository}@${digest}`,
|
||||
};
|
||||
assertArtifactSummaryComplete(artifact, "direct-docker");
|
||||
return {
|
||||
ok: true,
|
||||
transport: "direct-docker",
|
||||
pipelineRun: null,
|
||||
namespace: null,
|
||||
repoUrl: options.repoUrl,
|
||||
commit: options.commit,
|
||||
serviceId: options.serviceId,
|
||||
source: source.summary,
|
||||
artifact: artifact.imageRef,
|
||||
artifactSummary: artifact,
|
||||
controlledPublish: {
|
||||
environment: "DEV-local-artifact",
|
||||
command: `bun scripts/cli.ts ci publish-user-service --service ${options.serviceId} --commit ${options.commit} --transport direct-docker`,
|
||||
noRollout: true,
|
||||
noRuntimeMutation: true,
|
||||
dependsOnLocalUnideskDatabase: false,
|
||||
},
|
||||
boundary: "direct-docker CI artifact publish builds and pushes a commit-pinned image only; no deploy apply, no rollout, no Code Queue restart, no active task mutation",
|
||||
wait: {
|
||||
ok: true,
|
||||
dispatchOk: null,
|
||||
dispatchStatus: null,
|
||||
dispatchExitCode: null,
|
||||
stdoutTail: push.stdout.slice(-6000),
|
||||
stderrTail: push.stderr.slice(-6000),
|
||||
},
|
||||
condition: null,
|
||||
next: [
|
||||
"use artifactSummary.imageRef or artifactSummary.digestRef as later dev artifact consumer input",
|
||||
],
|
||||
};
|
||||
} finally {
|
||||
source.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
function assertArtifactSummaryComplete(artifact: ArtifactSummary, pipelineRun: string): void {
|
||||
const missing = missingArtifactSummaryFields(artifact);
|
||||
if (missing.length > 0) {
|
||||
@@ -1862,13 +2061,16 @@ async function publishUserServiceArtifact(config: UniDeskConfig, options: CiPubl
|
||||
environment: "D601",
|
||||
namespace: "unidesk-ci",
|
||||
pipeline: "unidesk-user-service-artifact-publish",
|
||||
command: `bun scripts/cli.ts ci publish-user-service --service ${options.serviceId} --commit ${options.commit} --wait-ms 1200000`,
|
||||
command: `bun scripts/cli.ts ci publish-user-service --service ${options.serviceId} --commit ${options.commit} --wait-ms 1200000 --transport ${options.transport}`,
|
||||
requiresReadyControlChannels: publishPreflightControlChannelOrder,
|
||||
},
|
||||
boundary: preflight.boundary,
|
||||
next: preflight.next,
|
||||
};
|
||||
}
|
||||
if (options.transport === "direct-docker" || (options.transport === "auto" && options.serviceId === "code-queue" && options.repoUrl === "https://github.com/pikasTech/unidesk")) {
|
||||
return publishUserServiceArtifactDirectDocker(options, summaryContext);
|
||||
}
|
||||
const source = options.serviceId === "claudeqq"
|
||||
? await prepareClaudeqqArtifactSource(config, options)
|
||||
: await prepareUserServiceArtifactSource(config, options);
|
||||
@@ -1948,6 +2150,7 @@ export async function runCiPublishUserServiceDryRunPreflight(
|
||||
imageRepository: artifact.image.repository,
|
||||
sourceHostPath: userServiceArtifactSourceHostPath(serviceId, commit),
|
||||
dryRun: true,
|
||||
transport: publishTransportOption(stringOption(args, "--transport")),
|
||||
};
|
||||
const preflight = await publishUserServicePreflight(config, options, plannedArtifact, transport);
|
||||
const plannedRepoFetchUrl = repoSshUrl(options.repoUrl);
|
||||
@@ -1992,7 +2195,7 @@ export async function runCiPublishUserServiceDryRunPreflight(
|
||||
environment: "D601",
|
||||
namespace: "unidesk-ci",
|
||||
pipeline: "unidesk-user-service-artifact-publish",
|
||||
command: `bun scripts/cli.ts ci publish-user-service --service ${options.serviceId} --commit ${options.commit} --wait-ms 1200000`,
|
||||
command: `bun scripts/cli.ts ci publish-user-service --service ${options.serviceId} --commit ${options.commit} --wait-ms 1200000 --transport ${options.transport}`,
|
||||
requiresReadyControlChannels: publishPreflightControlChannelOrder,
|
||||
},
|
||||
boundary: preflight.boundary,
|
||||
@@ -2422,6 +2625,11 @@ export function ciHelp(): Record<string, unknown> {
|
||||
userServiceArtifact: {
|
||||
producer: "D601 CI",
|
||||
command: "bun scripts/cli.ts ci publish-user-service --service <service-id> --commit <full-sha>",
|
||||
transports: {
|
||||
auto: "uses direct-docker for repo-owned code-queue artifacts and Tekton for the remaining services",
|
||||
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"),
|
||||
blockedServices: blockedCatalogArtifactIds(),
|
||||
registry: "127.0.0.1:5000/unidesk/<service-id>:<commit>",
|
||||
@@ -2486,6 +2694,7 @@ export async function runCiCommand(config: UniDeskConfig, args: string[]): Promi
|
||||
const commit = requireFullCommit(stringOption(args, "--commit") ?? stringOption(args, "--revision"));
|
||||
const waitMs = numberOption(args, "--wait-ms", 0);
|
||||
const dryRun = boolFlag(args, "--dry-run");
|
||||
const transport = publishTransportOption(stringOption(args, "--transport"));
|
||||
if (stringOption(args, "--repo") !== null || stringOption(args, "--repo-url") !== null) {
|
||||
throw new Error("ci publish-user-service reads source repo from CI.json; edit CI.json instead of using --repo");
|
||||
}
|
||||
@@ -2512,6 +2721,7 @@ export async function runCiCommand(config: UniDeskConfig, args: string[]): Promi
|
||||
imageRepository: artifact.image.repository,
|
||||
sourceHostPath: userServiceArtifactSourceHostPath(serviceId, commit),
|
||||
dryRun,
|
||||
transport,
|
||||
});
|
||||
}
|
||||
if (action === "run-dev-e2e") {
|
||||
|
||||
@@ -6207,6 +6207,7 @@ function compactSkillsStatus(value: unknown): Record<string, unknown> | null {
|
||||
degradedReason: record.degradedReason ?? record.blocker ?? null,
|
||||
readonly: record.readonly ?? false,
|
||||
skillCount: record.skillCount ?? 0,
|
||||
version: record.version ?? null,
|
||||
sourceSkillCount: record.sourceSkillCount ?? null,
|
||||
targetSkillCount: record.targetSkillCount ?? null,
|
||||
sourceMissingSkills: Array.isArray(record.sourceMissingSkills) ? record.sourceMissingSkills.map(String) : [],
|
||||
@@ -6241,6 +6242,7 @@ function compactSkillPathReport(value: unknown): Record<string, unknown> | null
|
||||
symlink: record.symlink ?? false,
|
||||
realPath: record.realPath ?? null,
|
||||
skillCount: record.skillCount ?? 0,
|
||||
version: record.version ?? null,
|
||||
requiredSkills: Array.isArray(record.requiredSkills) ? record.requiredSkills.map(String) : [],
|
||||
missingSkills: Array.isArray(record.missingSkills) ? record.missingSkills.map(String) : [],
|
||||
error: record.error ?? null,
|
||||
@@ -6265,6 +6267,7 @@ function compactSkillsSyncStatus(value: unknown, full = false): Record<string, u
|
||||
target,
|
||||
expected: record.expected ?? null,
|
||||
counts: record.counts ?? null,
|
||||
version: record.version ?? null,
|
||||
missing: record.missing ?? null,
|
||||
permissionFailures: Array.isArray(record.permissionFailures) ? record.permissionFailures.slice(0, full ? undefined : 4) : [],
|
||||
permissionFailureCount: Array.isArray(record.permissionFailures) ? record.permissionFailures.length : 0,
|
||||
|
||||
@@ -642,6 +642,7 @@ function compactSkillAvailability(value: unknown): Record<string, unknown> | nul
|
||||
degradedReason: skills.degradedReason ?? skills.blocker ?? null,
|
||||
readonly: skills.readonly ?? false,
|
||||
skillCount: skills.skillCount ?? 0,
|
||||
version: skills.version ?? null,
|
||||
sourceSkillCount: skills.sourceSkillCount ?? null,
|
||||
targetSkillCount: skills.targetSkillCount ?? null,
|
||||
sourceMissingSkills: Array.isArray(skills.sourceMissingSkills) ? skills.sourceMissingSkills.map(String) : [],
|
||||
@@ -676,6 +677,7 @@ function compactSkillSync(value: unknown): Record<string, unknown> | null {
|
||||
"symlink",
|
||||
"realPath",
|
||||
"skillCount",
|
||||
"version",
|
||||
"requiredSkills",
|
||||
"missingSkills",
|
||||
"error",
|
||||
@@ -692,6 +694,7 @@ function compactSkillSync(value: unknown): Record<string, unknown> | null {
|
||||
"symlink",
|
||||
"realPath",
|
||||
"skillCount",
|
||||
"version",
|
||||
"requiredSkills",
|
||||
"missingSkills",
|
||||
"error",
|
||||
@@ -708,6 +711,7 @@ function compactSkillSync(value: unknown): Record<string, unknown> | null {
|
||||
source,
|
||||
target,
|
||||
counts: sync.counts ?? null,
|
||||
version: sync.version ?? null,
|
||||
missing: sync.missing ?? null,
|
||||
permissionFailureCount: permissionFailures.length,
|
||||
permissionFailures: permissionFailures.slice(0, 4),
|
||||
|
||||
Reference in New Issue
Block a user