feat: expand ci artifact catalog
This commit is contained in:
@@ -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
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user