feat: expand ci artifact catalog

This commit is contained in:
Codex
2026-05-20 01:20:03 +00:00
parent e0d38d7172
commit 5bb44c9a30
11 changed files with 810 additions and 216 deletions
+266
View File
@@ -0,0 +1,266 @@
import { existsSync, readFileSync } from "node:fs";
import { rootPath } from "./config";
export type CiArtifactStatus = "supported" | "blocked";
export type CiCatalogArtifact = CiSourceBuildCatalogArtifact | CiUpstreamImageCatalogArtifact;
export interface CiCatalog {
schemaVersion: number;
kind: "ci-artifact-catalog";
purpose: string;
summaryContract: {
requiredOnSuccess: string[];
fieldSemantics: Record<string, string>;
};
defaults: {
registry: string;
tagTemplate: string;
mutableTagsAllowed: boolean;
runtimeFieldsForbidden: string[];
};
artifacts: CiCatalogArtifact[];
}
export interface CiSourceBuildCatalogArtifact {
serviceId: string;
kind: "source-build";
status: CiArtifactStatus;
producer: "ci publish-backend-core" | "ci publish-user-service";
source: {
repo: string;
dockerfile: string;
root?: string;
};
image: {
repository: string;
};
notes?: string;
blockedReason?: string;
}
export interface CiUpstreamImageCatalogArtifact {
serviceId: string;
kind: "upstream-image";
status: "blocked";
producer: "ci publish-user-service";
upstream: {
imageRef: string;
digestRef: string;
sourceRepo?: string;
sourceRevision?: string;
mirrorRepository: string;
mirrorTag: string;
mirrorDigestRef: string;
};
notes?: string;
blockedReason: string;
}
let cachedCatalog: CiCatalog | null = null;
function asRecord(value: unknown, name: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error(`${name} must be an object`);
}
return value as Record<string, unknown>;
}
function stringField(obj: Record<string, unknown>, key: string, path: string): string {
const value = obj[key];
if (typeof value !== "string" || value.length === 0) throw new Error(`${path}.${key} must be a non-empty string`);
return value;
}
function stringArrayField(obj: Record<string, unknown>, key: string, path: string): string[] {
const value = obj[key];
if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.length === 0)) {
throw new Error(`${path}.${key} must be an array of non-empty strings`);
}
return value as string[];
}
function optionalStringField(obj: Record<string, unknown>, key: string, path: string): string | undefined {
const value = obj[key];
if (value === undefined) return undefined;
if (typeof value !== "string" || value.length === 0) throw new Error(`${path}.${key} must be a non-empty string`);
return value;
}
function optionalStringArrayField(obj: Record<string, unknown>, key: string, path: string): string[] | undefined {
const value = obj[key];
if (value === undefined) return undefined;
return stringArrayField(obj, key, path);
}
function optionalBooleanField(obj: Record<string, unknown>, key: string, path: string): boolean | undefined {
const value = obj[key];
if (value === undefined) return undefined;
if (typeof value !== "boolean") throw new Error(`${path}.${key} must be a boolean`);
return value;
}
function requiredCatalogPath(value: string, label: string): string {
if (value.length === 0 || value.startsWith("/") || value.includes("\0") || value.split("/").includes("..")) {
throw new Error(`${label} must be a repo-relative path`);
}
return value;
}
function requiredImageRepository(value: string, label: string): string {
if (value.length === 0 || value.startsWith("/") || value.includes("..") || value.includes(":") || value.includes("@") || value.includes("latest") || !/^[a-z0-9._/-]+$/u.test(value)) {
throw new Error(`${label} must be a registry repository path without registry host, tag, digest, latest, or uppercase characters`);
}
return value;
}
function stringRecordField(obj: Record<string, unknown>, key: string, path: string): Record<string, string> {
const value = asRecord(obj[key], `${path}.${key}`);
for (const [entryKey, entryValue] of Object.entries(value)) {
if (typeof entryValue !== "string" || entryValue.length === 0) {
throw new Error(`${path}.${key}.${entryKey} must be a non-empty string`);
}
}
return value as Record<string, string>;
}
function validateSourceBuildArtifact(item: Record<string, unknown>, index: number): CiSourceBuildCatalogArtifact {
const path = `artifacts[${index}]`;
const source = asRecord(item.source, `${path}.source`);
const image = asRecord(item.image, `${path}.image`);
const status = stringField(item, "status", path);
if (status !== "supported" && status !== "blocked") throw new Error(`${path}.status must be supported or blocked`);
const producer = stringField(item, "producer", path);
if (producer !== "ci publish-backend-core" && producer !== "ci publish-user-service") {
throw new Error(`${path}.producer must be ci publish-backend-core or ci publish-user-service`);
}
const artifact: CiSourceBuildCatalogArtifact = {
serviceId: stringField(item, "serviceId", path),
kind: "source-build",
status,
producer,
source: {
repo: stringField(source, "repo", `${path}.source`),
dockerfile: requiredCatalogPath(stringField(source, "dockerfile", `${path}.source`), `${path}.source.dockerfile`),
...(source.root === undefined ? {} : { root: requiredCatalogPath(stringField(source, "root", `${path}.source`), `${path}.source.root`) }),
},
image: {
repository: requiredImageRepository(stringField(image, "repository", `${path}.image`), `${path}.image.repository`),
},
...(optionalStringField(item, "notes", path) === undefined ? {} : { notes: optionalStringField(item, "notes", path) }),
...(optionalStringField(item, "blockedReason", path) === undefined ? {} : { blockedReason: optionalStringField(item, "blockedReason", path) }),
};
if (artifact.status === "blocked" && artifact.blockedReason === undefined) {
throw new Error(`${path}.blockedReason is required when status=blocked`);
}
if (artifact.status === "supported" && artifact.blockedReason !== undefined) {
throw new Error(`${path}.blockedReason is only allowed when status=blocked`);
}
return artifact;
}
function validateUpstreamImageArtifact(item: Record<string, unknown>, index: number): CiUpstreamImageCatalogArtifact {
const path = `artifacts[${index}]`;
const upstream = asRecord(item.upstream, `${path}.upstream`);
const producer = stringField(item, "producer", path);
if (producer !== "ci publish-user-service") throw new Error(`${path}.producer must be ci publish-user-service`);
const artifact: CiUpstreamImageCatalogArtifact = {
serviceId: stringField(item, "serviceId", path),
kind: "upstream-image",
status: "blocked",
producer,
upstream: {
imageRef: stringField(upstream, "imageRef", `${path}.upstream`),
digestRef: stringField(upstream, "digestRef", `${path}.upstream`),
...(optionalStringField(upstream, "sourceRepo", `${path}.upstream`) === undefined ? {} : { sourceRepo: optionalStringField(upstream, "sourceRepo", `${path}.upstream`) }),
...(optionalStringField(upstream, "sourceRevision", `${path}.upstream`) === undefined ? {} : { sourceRevision: optionalStringField(upstream, "sourceRevision", `${path}.upstream`) }),
mirrorRepository: requiredImageRepository(stringField(upstream, "mirrorRepository", `${path}.upstream`), `${path}.upstream.mirrorRepository`),
mirrorTag: stringField(upstream, "mirrorTag", `${path}.upstream`),
mirrorDigestRef: stringField(upstream, "mirrorDigestRef", `${path}.upstream`),
},
blockedReason: stringField(item, "blockedReason", path),
...(optionalStringField(item, "notes", path) === undefined ? {} : { notes: optionalStringField(item, "notes", path) }),
};
return artifact;
}
function validateCatalogArtifact(item: unknown, index: number): CiCatalogArtifact {
const record = asRecord(item, `artifacts[${index}]`);
const kind = stringField(record, "kind", `artifacts[${index}]`);
if (kind === "source-build") return validateSourceBuildArtifact(record, index);
if (kind === "upstream-image") return validateUpstreamImageArtifact(record, index);
throw new Error(`artifacts[${index}].kind must be source-build or upstream-image`);
}
function assertUniqueServiceIds(artifacts: CiCatalogArtifact[]): void {
const seen = new Set<string>();
for (const artifact of artifacts) {
if (seen.has(artifact.serviceId)) throw new Error(`CI.json.artifacts contains duplicate serviceId: ${artifact.serviceId}`);
seen.add(artifact.serviceId);
}
}
export function loadCiCatalog(): CiCatalog {
if (cachedCatalog !== null) return cachedCatalog;
const path = rootPath("CI.json");
if (!existsSync(path)) throw new Error(`CI.json not found at ${path}`);
const parsed = asRecord(JSON.parse(readFileSync(path, "utf8")) as unknown, "CI.json");
const schemaVersion = parsed.schemaVersion;
if (schemaVersion !== 2) throw new Error("CI.json schemaVersion must be 2");
const kind = stringField(parsed, "kind", "CI.json");
if (kind !== "ci-artifact-catalog") throw new Error("CI.json kind must be ci-artifact-catalog");
const summaryContract = asRecord(parsed.summaryContract, "CI.json.summaryContract");
const defaults = asRecord(parsed.defaults, "CI.json.defaults");
const artifacts = Array.isArray(parsed.artifacts) ? parsed.artifacts.map((item, index) => validateCatalogArtifact(item, index)) : [];
if (artifacts.length === 0) throw new Error("CI.json.artifacts must not be empty");
assertUniqueServiceIds(artifacts);
cachedCatalog = {
schemaVersion,
kind,
purpose: stringField(parsed, "purpose", "CI.json"),
summaryContract: {
requiredOnSuccess: stringArrayField(summaryContract, "requiredOnSuccess", "CI.json.summaryContract"),
fieldSemantics: stringRecordField(summaryContract, "fieldSemantics", "CI.json.summaryContract"),
},
defaults: {
registry: stringField(defaults, "registry", "CI.json.defaults"),
tagTemplate: stringField(defaults, "tagTemplate", "CI.json.defaults"),
mutableTagsAllowed: optionalBooleanField(defaults, "mutableTagsAllowed", "CI.json.defaults") ?? false,
runtimeFieldsForbidden: optionalStringArrayField(defaults, "runtimeFieldsForbidden", "CI.json.defaults") ?? [],
},
artifacts,
};
return cachedCatalog;
}
export function findCiCatalogArtifact(serviceId: string): CiCatalogArtifact | null {
return loadCiCatalog().artifacts.find((artifact) => artifact.serviceId === serviceId) ?? null;
}
export function supportedSourceBuildArtifactIds(): string[] {
return loadCiCatalog().artifacts
.filter((artifact): artifact is CiSourceBuildCatalogArtifact => artifact.kind === "source-build" && artifact.status === "supported")
.map((artifact) => artifact.serviceId);
}
export function blockedCatalogArtifactIds(): string[] {
return loadCiCatalog().artifacts.filter((artifact) => artifact.status === "blocked").map((artifact) => artifact.serviceId);
}
export function catalogSummary(): {
totalArtifacts: number;
sourceBuildArtifacts: number;
supportedSourceBuildArtifacts: number;
upstreamImageArtifacts: number;
blockedArtifacts: number;
} {
const catalog = loadCiCatalog();
return {
totalArtifacts: catalog.artifacts.length,
sourceBuildArtifacts: catalog.artifacts.filter((artifact) => artifact.kind === "source-build").length,
supportedSourceBuildArtifacts: catalog.artifacts.filter((artifact) => artifact.kind === "source-build" && artifact.status === "supported").length,
upstreamImageArtifacts: catalog.artifacts.filter((artifact) => artifact.kind === "upstream-image").length,
blockedArtifacts: catalog.artifacts.filter((artifact) => artifact.status === "blocked").length,
};
}
+170 -72
View File
@@ -1,8 +1,9 @@
import { randomUUID } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import { 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, type UniDeskMicroserviceConfig, repoRoot, rootPath } from "./config";
import { type UniDeskConfig, repoRoot, rootPath } from "./config";
import { ensureGithubSshIdentityForProvider, gitSshHttpConnectProxySource } from "./deploy-ssh-identity";
import { startJob } from "./jobs";
import { coreInternalFetch } from "./microservices";
@@ -29,14 +30,6 @@ const ciRuntimeImages = [
"alpine/git:2.45.2",
ciCodeQueueImage,
];
const publishUserServiceArtifactAllowedServiceIds = new Set([
"baidu-netdisk",
"code-queue-mgr",
"decision-center",
"frontend",
"oa-event-flow",
"project-manager",
]);
interface CiOptions {
repoUrl: string;
@@ -49,6 +42,8 @@ interface CiPublishBackendCoreOptions {
commit: string;
waitMs: number;
sourceHostPath: string;
dockerfile: string;
imageRepository: string;
dryRun: boolean;
}
@@ -59,6 +54,7 @@ interface CiPublishUserServiceArtifactOptions {
sourceHostPath: string;
serviceId: string;
dockerfile: string;
imageRepository: string;
dryRun: boolean;
}
@@ -130,6 +126,7 @@ interface ArtifactSummaryContext {
commit: string;
repoUrl: string;
dockerfile: string;
imageRepository: string;
}
function stringOption(args: string[], name: string): string | null {
@@ -188,6 +185,10 @@ function shellQuote(value: string): string {
return `'${value.replace(/'/gu, "'\\''")}'`;
}
function safePathToken(value: string): string {
return value.replace(/[^a-z0-9._-]/giu, "-").toLowerCase().replace(/^-+|-+$/gu, "").slice(0, 80) || "artifact";
}
function repoSshUrl(repoUrl: string): string {
if (repoUrl.startsWith("git@")) return repoUrl;
if (repoUrl.startsWith("https://github.com/")) {
@@ -197,6 +198,10 @@ function repoSshUrl(repoUrl: string): string {
return repoUrl;
}
function repoNeedsGithubSshIdentity(repoFetchUrl: string): boolean {
return repoFetchUrl.startsWith("git@github.com:");
}
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`);
@@ -206,35 +211,45 @@ function requireRepoRelativePath(path: string, label: string): string {
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");
function resolveCatalogArtifact(serviceId: string): CiCatalogArtifact {
const artifact = findCiCatalogArtifact(serviceId);
if (artifact === null) {
const known = loadCiCatalog().artifacts.map((item) => item.serviceId).sort().join(", ");
throw new Error(`unknown CI artifact service: ${serviceId}. Known services: ${known}`);
}
const service = config.microservices.find((item) => item.id === serviceId);
if (service === undefined) throw new Error(`unknown user service: ${serviceId}`);
if (service.repository.artifactSource?.kind === "upstream-image") {
throw new Error(`ci publish-user-service does not build ${serviceId}: it is an upstream image consumer (${service.repository.artifactSource.imageRef}). Use the upstream-image digest/mirror governance path; do not add it to Dockerfile CI artifacts.`);
}
const isD601K3sService = service.providerId === d601ProviderId
&& service.development.providerId === d601ProviderId
&& service.deployment.mode === "k3sctl-managed";
const isMainServerDirectService = service.providerId === "main-server"
&& service.development.providerId === "main-server"
&& service.deployment.mode === "unidesk-direct";
const isMainServerInternalSidecar = service.providerId === "main-server"
&& service.development.providerId === "main-server"
&& service.deployment.mode === "internal-sidecar";
if (!isD601K3sService && !isMainServerDirectService && !isMainServerInternalSidecar) {
throw new Error(`ci publish-user-service supports only reviewed k3sctl-managed D601 services or main-server unidesk-direct services; ${serviceId} is ${service.providerId}/${service.deployment.mode}`);
}
return service;
return artifact;
}
function frontendArtifactTarget(repoOverride: string | null): { repoUrl: string; dockerfile: string } {
return {
repoUrl: repoOverride ?? "https://github.com/pikasTech/unidesk",
dockerfile: "src/components/frontend/Dockerfile",
function blockedArtifactResult(artifact: CiUpstreamImageCatalogArtifact | CiSourceBuildCatalogArtifact, commit: string, note: string): Record<string, unknown> {
const base = {
ok: false,
status: "blocked",
error: "blocked",
serviceId: artifact.serviceId,
commit,
reason: note,
catalogArtifact: artifact,
boundary: "CI catalog marks this service as blocked; it must not be treated as a source-build artifact producer",
};
return artifact.kind === "upstream-image"
? {
...base,
upstream: artifact.upstream,
next: [
`document the upstream image contract in CI.json for ${artifact.serviceId}`,
],
}
: {
...base,
next: [
`unblock ${artifact.serviceId} in CI.json before attempting source-build publication`,
],
};
}
function blockedReason(artifact: CiSourceBuildCatalogArtifact): string {
if (artifact.blockedReason === undefined) throw new Error(`${artifact.serviceId} is blocked in CI.json but has no blockedReason`);
return artifact.blockedReason;
}
function chunks(value: string, size: number): string[] {
@@ -621,6 +636,10 @@ spec:
value: ${JSON.stringify(options.repoUrl)}
- name: revision
value: ${JSON.stringify(options.commit)}
- name: dockerfile
value: ${JSON.stringify(options.dockerfile)}
- name: image-repository
value: ${JSON.stringify(options.imageRepository)}
- name: source-host-path
value: ${JSON.stringify(options.sourceHostPath)}
workspaces:
@@ -656,6 +675,8 @@ spec:
value: ${JSON.stringify(options.serviceId)}
- name: dockerfile
value: ${JSON.stringify(options.dockerfile)}
- name: image-repository
value: ${JSON.stringify(options.imageRepository)}
- name: source-host-path
value: ${JSON.stringify(options.sourceHostPath)}
workspaces:
@@ -674,18 +695,20 @@ function userServiceArtifactSourceHostPath(serviceId: string, commit: string): s
}
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);
const proxyPython = gitSshHttpConnectProxySource();
const sourceRoot = "/home/ubuntu/.unidesk/ci/backend-core-artifacts";
const sourceHostPath = options.sourceHostPath;
const repoCache = "/home/ubuntu/.unidesk/ci/git/unidesk.git";
const repoFetchUrl = repoSshUrl(options.repoUrl);
const sshIdentity = repoNeedsGithubSshIdentity(repoFetchUrl) ? await ensureGithubSshIdentityForProvider(config, d601ProviderId) : null;
if (sshIdentity !== null && !sshIdentity.ok) throw new Error(sshIdentity.detail);
const proxyPython = gitSshHttpConnectProxySource();
const dockerfile = requireRepoRelativePath(options.dockerfile, "CI.json.artifacts.backend-core.source.dockerfile");
const script = [
"set -euo pipefail",
`commit=${shellQuote(options.commit)}`,
`repo_url=${shellQuote(options.repoUrl)}`,
`repo_fetch_url=${shellQuote(repoFetchUrl)}`,
`dockerfile=${shellQuote(dockerfile)}`,
`source_root=${shellQuote(sourceRoot)}`,
`source_dir=${shellQuote(sourceHostPath)}`,
`repo_cache=${shellQuote(repoCache)}`,
@@ -708,7 +731,7 @@ async function prepareBackendCoreArtifactSource(config: UniDeskConfig, options:
"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 \"backend_core_artifact_resolved_commit_mismatch=$resolved expected=$commit\" >&2; exit 1; }",
"git -C \"$repo_cache\" cat-file -e \"$commit:src/components/backend-core/Dockerfile\"",
"git -C \"$repo_cache\" cat-file -e \"$commit:$dockerfile\"",
"git -C \"$repo_cache\" cat-file -e \"$commit:src/components/backend-core/src\"",
"tmp_dir=\"$source_root/.tmp-$commit-$$\"",
"rm -rf \"$tmp_dir\"",
@@ -718,7 +741,7 @@ async function prepareBackendCoreArtifactSource(config: UniDeskConfig, options:
"printf '%s\\n' \"$repo_url\" > \"$tmp_dir/.unidesk-source-repo\"",
"rm -rf \"$source_dir\"",
"mv \"$tmp_dir\" \"$source_dir\"",
"test -f \"$source_dir/src/components/backend-core/Dockerfile\"",
"test -f \"$source_dir/$dockerfile\"",
"test -d \"$source_dir/src/components/backend-core/src\"",
"echo backend_core_artifact_source_host_path=$source_dir",
].join("\n");
@@ -726,13 +749,14 @@ async function prepareBackendCoreArtifactSource(config: UniDeskConfig, options:
if (!result.ok) throw new Error(`failed to prepare backend-core source on D601: ${result.stderr || result.stdout || JSON.stringify(result.raw)}`);
return {
ok: true,
mode: "d601-host-github-ssh-export",
mode: repoNeedsGithubSshIdentity(repoFetchUrl) ? "d601-host-git-ssh-export" : "d601-host-git-https-export",
providerId: d601ProviderId,
repoUrl: options.repoUrl,
repoFetchUrl,
commit: options.commit,
sourceHostPath,
identity: {
dockerfile,
identity: sshIdentity === null ? null : {
fingerprint: sshIdentity.fingerprint,
seededFromLocal: sshIdentity.seededFromLocal,
},
@@ -741,14 +765,13 @@ 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 repoCache = `/home/ubuntu/.unidesk/ci/git/${safePathToken(options.serviceId)}.git`;
const repoFetchUrl = repoSshUrl(options.repoUrl);
const dockerfileDir = posixPath.dirname(options.dockerfile);
const sshIdentity = repoNeedsGithubSshIdentity(repoFetchUrl) ? await ensureGithubSshIdentityForProvider(config, d601ProviderId) : null;
if (sshIdentity !== null && !sshIdentity.ok) throw new Error(sshIdentity.detail);
const proxyPython = gitSshHttpConnectProxySource();
const script = [
"set -euo pipefail",
`service_id=${shellQuote(options.serviceId)}`,
@@ -756,7 +779,6 @@ async function prepareUserServiceArtifactSource(config: UniDeskConfig, options:
`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)}`,
@@ -781,7 +803,6 @@ async function prepareUserServiceArtifactSource(config: UniDeskConfig, options:
"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\"",
@@ -793,14 +814,13 @@ async function prepareUserServiceArtifactSource(config: UniDeskConfig, options:
"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",
mode: repoNeedsGithubSshIdentity(repoFetchUrl) ? "d601-host-git-ssh-export" : "d601-host-git-https-export",
providerId: d601ProviderId,
repoUrl: options.repoUrl,
repoFetchUrl,
@@ -808,7 +828,7 @@ async function prepareUserServiceArtifactSource(config: UniDeskConfig, options:
serviceId: options.serviceId,
dockerfile: options.dockerfile,
sourceHostPath,
identity: {
identity: sshIdentity === null ? null : {
fingerprint: sshIdentity.fingerprint,
seededFromLocal: sshIdentity.seededFromLocal,
},
@@ -894,7 +914,7 @@ function pipelineRunWaitSucceeded(wait: DispatchResult | null, condition: Pipeli
function artifactSummaryDefaults(context: ArtifactSummaryContext): ArtifactSummary {
const registry = "127.0.0.1:5000";
const repository = `${registry}/unidesk/${context.serviceId}`;
const repository = `${registry}/${context.imageRepository}`;
return {
serviceId: context.serviceId,
sourceCommit: context.commit,
@@ -917,7 +937,7 @@ function artifactSummaryField(fields: Map<string, string>, suffix: string): stri
function parseArtifactSummaryFromFields(fields: Map<string, string>, context: ArtifactSummaryContext): ArtifactSummary {
const planned = artifactSummaryDefaults(context);
const registry = artifactSummaryField(fields, "registry") ?? planned.registry;
const repository = artifactSummaryField(fields, "repository") ?? `${registry}/unidesk/${context.serviceId}`;
const repository = artifactSummaryField(fields, "repository") ?? planned.repository;
const tag = artifactSummaryField(fields, "tag") ?? planned.tag;
const imageRef = artifactSummaryField(fields, "image") ?? (repository.length > 0 && tag.length > 0 ? `${repository}:${tag}` : planned.imageRef);
const digest = artifactSummaryField(fields, "digest");
@@ -1075,9 +1095,10 @@ async function run(options: CiOptions): Promise<Record<string, unknown>> {
async function publishBackendCoreArtifact(config: UniDeskConfig, options: CiPublishBackendCoreOptions): Promise<Record<string, unknown>> {
const summaryContext: ArtifactSummaryContext = {
serviceId: "backend-core",
dockerfile: "src/components/backend-core/Dockerfile",
commit: options.commit,
repoUrl: options.repoUrl,
dockerfile: options.dockerfile,
imageRepository: options.imageRepository,
};
const plannedArtifact = artifactSummaryDefaults(summaryContext);
if (options.dryRun) {
@@ -1096,7 +1117,8 @@ async function publishBackendCoreArtifact(config: UniDeskConfig, options: CiPubl
repoUrl: options.repoUrl,
repoFetchUrl: repoSshUrl(options.repoUrl),
commit: options.commit,
dockerfile: "src/components/backend-core/Dockerfile",
dockerfile: options.dockerfile,
imageRepository: options.imageRepository,
sourceHostPath: options.sourceHostPath,
},
artifact: plannedArtifact.imageRef,
@@ -1148,6 +1170,7 @@ async function publishUserServiceArtifact(config: UniDeskConfig, options: CiPubl
dockerfile: options.dockerfile,
commit: options.commit,
repoUrl: options.repoUrl,
imageRepository: options.imageRepository,
};
const plannedArtifact = artifactSummaryDefaults(summaryContext);
if (options.dryRun) {
@@ -1169,6 +1192,7 @@ async function publishUserServiceArtifact(config: UniDeskConfig, options: CiPubl
commit: options.commit,
serviceId: options.serviceId,
dockerfile: options.dockerfile,
imageRepository: options.imageRepository,
sourceHostPath: options.sourceHostPath,
},
artifact: plannedArtifact.imageRef,
@@ -1461,7 +1485,32 @@ async function logs(name: string): Promise<Record<string, unknown>> {
};
}
function catalogArtifactDescriptor(artifact: CiCatalogArtifact): Record<string, unknown> {
if (artifact.kind === "source-build") {
return {
serviceId: artifact.serviceId,
kind: artifact.kind,
status: artifact.status,
producer: artifact.producer,
source: artifact.source,
image: artifact.image,
...(artifact.notes === undefined ? {} : { notes: artifact.notes }),
...(artifact.blockedReason === undefined ? {} : { blockedReason: artifact.blockedReason }),
};
}
return {
serviceId: artifact.serviceId,
kind: artifact.kind,
status: artifact.status,
producer: artifact.producer,
upstream: artifact.upstream,
blockedReason: artifact.blockedReason,
...(artifact.notes === undefined ? {} : { notes: artifact.notes }),
};
}
export function ciHelp(): Record<string, unknown> {
const catalog = loadCiCatalog();
return {
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.",
@@ -1492,9 +1541,13 @@ 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>",
initiallySupportedServices: ["baidu-netdisk", "decision-center", "frontend"],
supportedServices: supportedSourceBuildArtifactIds().filter((serviceId) => serviceId !== "backend-core"),
blockedServices: blockedCatalogArtifactIds(),
registry: "127.0.0.1:5000/unidesk/<service-id>:<commit>",
outputFields: ["serviceId", "sourceCommit", "sourceRepo", "dockerfile", "imageRef", "tag", "digest", "digestRef"],
summaryContract: catalog.summaryContract,
catalogSummary: catalogSummary(),
catalog: catalog.artifacts.map(catalogArtifactDescriptor),
boundary: "artifact producer only; no prod deploy and no production namespace mutation",
frontendNext: [
"bun scripts/cli.ts deploy apply --env dev --service frontend",
@@ -1528,37 +1581,82 @@ export async function runCiCommand(config: UniDeskConfig, args: string[]): Promi
return run({ repoUrl, revision, waitMs });
}
if (action === "publish-backend-core") {
const repoUrl = stringOption(args, "--repo") ?? stringOption(args, "--repo-url") ?? "https://github.com/pikasTech/unidesk";
if (stringOption(args, "--repo") !== null || stringOption(args, "--repo-url") !== null) {
throw new Error("ci publish-backend-core reads source repo from CI.json; edit CI.json instead of using --repo");
}
const commit = requireFullCommit(stringOption(args, "--commit") ?? stringOption(args, "--revision"));
const waitMs = numberOption(args, "--wait-ms", 0);
const dryRun = boolFlag(args, "--dry-run");
return publishBackendCoreArtifact(config, { repoUrl, commit, waitMs, sourceHostPath: backendCoreArtifactSourceHostPath(commit), dryRun });
const artifact = resolveCatalogArtifact("backend-core");
if (artifact.kind !== "source-build") throw new Error("backend-core must be modeled as a source-build artifact in CI.json");
if (artifact.status === "blocked") return blockedArtifactResult(artifact, commit, blockedReason(artifact));
return publishBackendCoreArtifact(config, {
repoUrl: artifact.source.repo,
commit,
waitMs,
sourceHostPath: backendCoreArtifactSourceHostPath(commit),
dockerfile: artifact.source.dockerfile,
imageRepository: artifact.image.repository,
dryRun,
});
}
if (action === "publish-user-service") {
const serviceId = requireServiceId(stringOption(args, "--service") ?? stringOption(args, "--service-id"));
const repoOverride = stringOption(args, "--repo") ?? stringOption(args, "--repo-url");
const target = serviceId === "frontend"
? frontendArtifactTarget(repoOverride)
: (() => {
const service = requireSupportedUserService(config, serviceId);
return {
repoUrl: repoOverride ?? service.repository.url,
dockerfile: service.repository.dockerfile,
};
})();
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(target.dockerfile, serviceId === "frontend" ? "frontend.dockerfile" : `microservices.${serviceId}.repository.dockerfile`);
if (!publishUserServiceArtifactAllowedServiceIds.has(serviceId)) {
throw new Error(`ci publish-user-service currently allows only ${Array.from(publishUserServiceArtifactAllowedServiceIds).join(", ")} until each user-service Dockerfile contract is reviewed`);
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");
}
const artifact = resolveCatalogArtifact(serviceId);
if (artifact.kind === "source-build" && artifact.serviceId === "backend-core") {
throw new Error("backend-core uses ci publish-backend-core; publish-user-service is for registered user services");
}
if (artifact.kind === "upstream-image") {
return blockedArtifactResult(artifact, commit, artifact.blockedReason);
}
if (artifact.status === "blocked") {
return blockedArtifactResult(artifact, commit, blockedReason(artifact));
}
const repoUrl = artifact.source.repo;
const dockerfile = requireRepoRelativePath(artifact.source.dockerfile, `CI.json.artifacts.${serviceId}.source.dockerfile`);
const configService = config.microservices.find((item) => item.id === serviceId);
if (configService !== undefined) {
const isD601K3sService = configService.providerId === d601ProviderId
&& configService.development.providerId === d601ProviderId
&& configService.deployment.mode === "k3sctl-managed";
const isD601DirectService = configService.providerId === d601ProviderId
&& configService.development.providerId === d601ProviderId
&& configService.deployment.mode === "unidesk-direct";
const isMainServerDirectService = configService.providerId === "main-server"
&& configService.development.providerId === "main-server"
&& configService.deployment.mode === "unidesk-direct";
const isMainServerInternalSidecar = configService.providerId === "main-server"
&& configService.development.providerId === "main-server"
&& configService.deployment.mode === "internal-sidecar";
if (!isD601K3sService && !isD601DirectService && !isMainServerDirectService && !isMainServerInternalSidecar) {
return {
ok: false,
status: "blocked",
error: "blocked",
serviceId,
commit,
reason: `config.json marks ${serviceId} as ${configService.providerId}/${configService.deployment.mode}, which is outside the reviewed CI artifact producer boundary`,
catalogArtifact: artifact,
configService: {
providerId: configService.providerId,
deploymentMode: configService.deployment.mode,
},
};
}
}
return publishUserServiceArtifact(config, {
repoUrl: target.repoUrl,
repoUrl,
commit,
waitMs,
serviceId,
dockerfile,
imageRepository: artifact.image.repository,
sourceHostPath: userServiceArtifactSourceHostPath(serviceId, commit),
dryRun,
});
+17
View File
@@ -139,6 +139,7 @@ const devApplySupportedServiceIds = new Set<string>(["backend-core"]);
const devArtifactConsumerServiceIds = new Set<string>(["baidu-netdisk", "code-queue-mgr", "decision-center", "frontend", "oa-event-flow", "project-manager", "todo-note"]);
const devArtifactConsumerProdDesiredFallbackServiceIds = new Set<string>(["code-queue-mgr", "oa-event-flow", "project-manager", "todo-note"]);
const prodArtifactConsumerServiceIds = new Set<string>(["backend-core", "baidu-netdisk", "code-queue-mgr", "decision-center", "frontend", "oa-event-flow", "project-manager", "todo-note"]);
const prodForbiddenTargetSideBuildServiceIds = new Set<string>(["backend-core", "baidu-netdisk", "decision-center", "frontend"]);
const prodArtifactLiveApplyBlockedServiceIds = new Map<string, string>([
["code-queue-mgr", "code-queue-mgr is the main-server Code Queue control-plane sidecar; live production apply requires explicit supervisor confirmation."],
["todo-note", "todo-note source is external to this repository and the current checked-in contract cannot prove /api/health deploy.commit/deploy.requestedCommit support."],
@@ -2700,6 +2701,22 @@ function prodArtifactUnsupportedResult(services: DeployManifestService[]): Recor
};
}
function prodArtifactConsumerLocalManifestResult(services: DeployManifestService[]): Record<string, unknown> {
return {
ok: false,
supported: false,
error: "prod-artifact-consumer-local-manifest-blocked",
services: services.map((service) => ({
id: service.id,
repo: service.repo,
commitId: service.commitId,
replacement: `bun scripts/cli.ts deploy apply --env prod --service ${service.id} --commit ${service.commitId}`,
reason: "production artifact consumers must use the Git-backed environment manifest and D601 registry artifact consumer, not local-manifest target-side source build",
})),
policy: "prod deploy must not silently fall back to a dirty worktree, local manifest, target-side source build, or maintenance-channel deployment",
};
}
function prodArtifactLiveApplyBlockedResult(services: DeployManifestService[]): Record<string, unknown> {
return {
ok: false,