ci: standardize artifact summary catalog
This commit is contained in:
+146
-42
@@ -41,6 +41,7 @@ interface CiPublishBackendCoreOptions {
|
||||
commit: string;
|
||||
waitMs: number;
|
||||
sourceHostPath: string;
|
||||
dryRun: boolean;
|
||||
}
|
||||
|
||||
interface CiPublishUserServiceArtifactOptions {
|
||||
@@ -116,6 +117,13 @@ interface ArtifactSummary {
|
||||
digestRef: string | null;
|
||||
}
|
||||
|
||||
interface ArtifactSummaryContext {
|
||||
serviceId: string;
|
||||
commit: string;
|
||||
repoUrl: string;
|
||||
dockerfile: string;
|
||||
}
|
||||
|
||||
function stringOption(args: string[], name: string): string | null {
|
||||
const index = args.indexOf(name);
|
||||
if (index === -1) return null;
|
||||
@@ -863,34 +871,112 @@ function pipelineRunWaitSucceeded(wait: DispatchResult | null, condition: Pipeli
|
||||
return wait.ok || wait.exitCode === 0 || wait.stdout.includes("condition=True\tSucceeded\t");
|
||||
}
|
||||
|
||||
function parseArtifactSummaryFromOutput(output: string, serviceId: string, commit: string, repoUrl: string, dockerfile: string): ArtifactSummary {
|
||||
const fields = new Map<string, string>();
|
||||
for (const line of output.split(/\r?\n/u)) {
|
||||
const match = /^(user_service_artifact_[a-z_]+|backend_core_artifact_[a-z_]+)=(.*)$/u.exec(line.trim());
|
||||
if (match !== null) fields.set(match[1], match[2]);
|
||||
}
|
||||
const digest = fields.get("user_service_artifact_digest") ?? fields.get("backend_core_artifact_digest") ?? null;
|
||||
const imageRef = fields.get("user_service_artifact_image") ?? fields.get("backend_core_artifact_image") ?? `127.0.0.1:5000/unidesk/${serviceId}:${commit}`;
|
||||
const repository = fields.get("user_service_artifact_repository") ?? `127.0.0.1:5000/unidesk/${serviceId}`;
|
||||
const digestRef = fields.get("user_service_artifact_digest_ref") ?? fields.get("backend_core_artifact_digest_ref") ?? (digest === null || digest.length === 0 ? null : `${repository}@${digest}`);
|
||||
function artifactSummaryDefaults(context: ArtifactSummaryContext): ArtifactSummary {
|
||||
const registry = "127.0.0.1:5000";
|
||||
const repository = `${registry}/unidesk/${context.serviceId}`;
|
||||
return {
|
||||
serviceId: fields.get("user_service_artifact_service_id") ?? serviceId,
|
||||
sourceCommit: fields.get("user_service_artifact_source_commit") ?? fields.get("backend_core_artifact_source_commit") ?? commit,
|
||||
sourceRepo: fields.get("user_service_artifact_source_repo") ?? repoUrl,
|
||||
dockerfile: fields.get("user_service_artifact_dockerfile") ?? dockerfile,
|
||||
registry: fields.get("user_service_artifact_registry") ?? "127.0.0.1:5000",
|
||||
serviceId: context.serviceId,
|
||||
sourceCommit: context.commit,
|
||||
sourceRepo: context.repoUrl,
|
||||
dockerfile: context.dockerfile,
|
||||
registry,
|
||||
repository,
|
||||
tag: fields.get("user_service_artifact_tag") ?? commit,
|
||||
tag: context.commit,
|
||||
imageRef: `${repository}:${context.commit}`,
|
||||
digest: null,
|
||||
digestRef: null,
|
||||
};
|
||||
}
|
||||
|
||||
function artifactSummaryField(fields: Map<string, string>, suffix: string): string | null {
|
||||
const value = fields.get(`user_service_artifact_${suffix}`) ?? fields.get(`backend_core_artifact_${suffix}`) ?? null;
|
||||
return value === null || value.length === 0 ? null : value;
|
||||
}
|
||||
|
||||
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 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");
|
||||
const digestRef = artifactSummaryField(fields, "digest_ref") ?? (digest === null || digest.length === 0 || repository.length === 0 ? null : `${repository}@${digest}`);
|
||||
return {
|
||||
serviceId: artifactSummaryField(fields, "service_id") ?? planned.serviceId,
|
||||
sourceCommit: artifactSummaryField(fields, "source_commit") ?? planned.sourceCommit,
|
||||
sourceRepo: artifactSummaryField(fields, "source_repo") ?? planned.sourceRepo,
|
||||
dockerfile: artifactSummaryField(fields, "dockerfile") ?? planned.dockerfile,
|
||||
registry,
|
||||
repository,
|
||||
tag,
|
||||
imageRef,
|
||||
digest,
|
||||
digestRef,
|
||||
};
|
||||
}
|
||||
|
||||
function assertArtifactSummaryComplete(artifact: ArtifactSummary, pipelineRun: string): void {
|
||||
if (artifact.serviceId.length === 0 || artifact.sourceCommit.length !== 40 || artifact.imageRef.length === 0 || artifact.tag.length === 0 || artifact.digest === null || artifact.digest.length === 0 || artifact.digestRef === null || artifact.digestRef.length === 0) {
|
||||
throw new Error(`artifact summary for ${pipelineRun} is incomplete`);
|
||||
function parseArtifactSummaryFromOutput(output: string, context: ArtifactSummaryContext): ArtifactSummary {
|
||||
const fields = new Map<string, string>();
|
||||
for (const line of output.split(/\r?\n/u)) {
|
||||
const match = /^(user_service_artifact_[a-z_]+|backend_core_artifact_[a-z_]+)=(.*)$/u.exec(line.trim());
|
||||
if (match !== null) fields.set(match[1], match[2]);
|
||||
}
|
||||
return parseArtifactSummaryFromFields(fields, context);
|
||||
}
|
||||
|
||||
function missingArtifactSummaryFields(artifact: ArtifactSummary): string[] {
|
||||
const missing: string[] = [];
|
||||
if (artifact.serviceId.length === 0) missing.push("serviceId");
|
||||
if (!/^[0-9a-f]{40}$/u.test(artifact.sourceCommit)) missing.push("sourceCommit");
|
||||
if (artifact.sourceRepo.length === 0) missing.push("sourceRepo");
|
||||
if (artifact.dockerfile.length === 0) missing.push("dockerfile");
|
||||
if (artifact.imageRef.length === 0) missing.push("imageRef");
|
||||
if (artifact.tag.length === 0) missing.push("tag");
|
||||
if (artifact.digest === null || artifact.digest.length === 0) missing.push("digest");
|
||||
if (artifact.digestRef === null || artifact.digestRef.length === 0) missing.push("digestRef");
|
||||
return missing;
|
||||
}
|
||||
|
||||
function assertArtifactSummaryComplete(artifact: ArtifactSummary, pipelineRun: string): void {
|
||||
const missing = missingArtifactSummaryFields(artifact);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`artifact summary for ${pipelineRun} is missing required field(s): ${missing.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function readArtifactSummaryFromPipelineRun(name: string, context: ArtifactSummaryContext): Promise<ArtifactSummary> {
|
||||
const result = await runRemoteKubectlRaw([
|
||||
"set -euo pipefail",
|
||||
`kubectl get taskrun -n unidesk-ci -l tekton.dev/pipelineRun=${shellQuote(name)} -o json`,
|
||||
].join("\n"), 60_000, 45_000);
|
||||
if (result.ok && result.stdout.trim().length > 0) {
|
||||
try {
|
||||
const parsed = JSON.parse(result.stdout) as unknown;
|
||||
const fields = new Map<string, string>();
|
||||
const list = asRecord(parsed);
|
||||
const items = Array.isArray(list?.items) ? list.items : [];
|
||||
for (const item of items) {
|
||||
const taskRun = asRecord(item);
|
||||
const status = asRecord(taskRun?.status);
|
||||
const results = Array.isArray(status?.results) ? status.results : [];
|
||||
for (const rawResult of results) {
|
||||
const taskResult = asRecord(rawResult);
|
||||
const nameValue = asString(taskResult?.name);
|
||||
const value = asString(taskResult?.value);
|
||||
if (/^(user_service_artifact_[a-z_]+|backend_core_artifact_[a-z_]+)$/u.test(nameValue) && value.length > 0) {
|
||||
fields.set(nameValue, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (fields.size > 0) {
|
||||
const fromResults = parseArtifactSummaryFromFields(fields, context);
|
||||
if (missingArtifactSummaryFields(fromResults).length === 0) return fromResults;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to pod logs below; a malformed diagnostic line must not mask a succeeded PipelineRun.
|
||||
}
|
||||
}
|
||||
return parseArtifactSummaryFromOutput(await readPipelineRunLogText(name), context);
|
||||
}
|
||||
|
||||
async function readPipelineRunLogText(name: string): Promise<string> {
|
||||
@@ -931,25 +1017,47 @@ async function run(options: CiOptions): Promise<Record<string, unknown>> {
|
||||
}
|
||||
|
||||
async function publishBackendCoreArtifact(config: UniDeskConfig, options: CiPublishBackendCoreOptions): Promise<Record<string, unknown>> {
|
||||
const plannedArtifact: ArtifactSummary = {
|
||||
const summaryContext: ArtifactSummaryContext = {
|
||||
serviceId: "backend-core",
|
||||
sourceCommit: options.commit,
|
||||
sourceRepo: options.repoUrl,
|
||||
dockerfile: "src/components/backend-core/Dockerfile",
|
||||
registry: "127.0.0.1:5000",
|
||||
repository: "127.0.0.1:5000/unidesk/backend-core",
|
||||
tag: options.commit,
|
||||
imageRef: `127.0.0.1:5000/unidesk/backend-core:${options.commit}`,
|
||||
digest: null,
|
||||
digestRef: null,
|
||||
commit: options.commit,
|
||||
repoUrl: options.repoUrl,
|
||||
};
|
||||
const plannedArtifact = artifactSummaryDefaults(summaryContext);
|
||||
if (options.dryRun) {
|
||||
return {
|
||||
ok: true,
|
||||
mode: "dry-run",
|
||||
pipeline: "unidesk-backend-core-artifact-publish",
|
||||
namespace: "unidesk-ci",
|
||||
repoUrl: options.repoUrl,
|
||||
commit: options.commit,
|
||||
sourceHostPath: options.sourceHostPath,
|
||||
source: {
|
||||
ok: true,
|
||||
mode: "planned-only",
|
||||
providerId: d601ProviderId,
|
||||
repoUrl: options.repoUrl,
|
||||
repoFetchUrl: repoSshUrl(options.repoUrl),
|
||||
commit: options.commit,
|
||||
dockerfile: "src/components/backend-core/Dockerfile",
|
||||
sourceHostPath: options.sourceHostPath,
|
||||
},
|
||||
artifact: plannedArtifact.imageRef,
|
||||
artifactSummary: plannedArtifact,
|
||||
boundary: "dry-run only; no D601 source export, no Tekton submission, no production mutation",
|
||||
next: [
|
||||
`bun scripts/cli.ts ci publish-backend-core --commit ${options.commit} --wait-ms 1200000`,
|
||||
],
|
||||
};
|
||||
}
|
||||
const source = await prepareBackendCoreArtifactSource(config, options);
|
||||
const name = await remoteCreatePipelineRun(backendCoreArtifactPipelineRunManifest(options));
|
||||
const wait = await waitForPipelineRun(name, options.waitMs);
|
||||
const condition = wait === null ? null : await readPipelineRunCondition(name);
|
||||
const waitSucceeded = pipelineRunWaitSucceeded(wait, condition);
|
||||
const artifact = waitSucceeded && wait !== null
|
||||
? parseArtifactSummaryFromOutput(await readPipelineRunLogText(name), "backend-core", options.commit, options.repoUrl, "src/components/backend-core/Dockerfile")
|
||||
? await readArtifactSummaryFromPipelineRun(name, summaryContext)
|
||||
: plannedArtifact;
|
||||
if (waitSucceeded && wait !== null) assertArtifactSummaryComplete(artifact, name);
|
||||
return {
|
||||
@@ -979,18 +1087,13 @@ async function publishBackendCoreArtifact(config: UniDeskConfig, options: CiPubl
|
||||
}
|
||||
|
||||
async function publishUserServiceArtifact(config: UniDeskConfig, options: CiPublishUserServiceArtifactOptions): Promise<Record<string, unknown>> {
|
||||
const plannedArtifact: ArtifactSummary = {
|
||||
const summaryContext: ArtifactSummaryContext = {
|
||||
serviceId: options.serviceId,
|
||||
sourceCommit: options.commit,
|
||||
sourceRepo: options.repoUrl,
|
||||
dockerfile: options.dockerfile,
|
||||
registry: "127.0.0.1:5000",
|
||||
repository: `127.0.0.1:5000/unidesk/${options.serviceId}`,
|
||||
tag: options.commit,
|
||||
imageRef: `127.0.0.1:5000/unidesk/${options.serviceId}:${options.commit}`,
|
||||
digest: null,
|
||||
digestRef: null,
|
||||
} satisfies ArtifactSummary;
|
||||
commit: options.commit,
|
||||
repoUrl: options.repoUrl,
|
||||
};
|
||||
const plannedArtifact = artifactSummaryDefaults(summaryContext);
|
||||
if (options.dryRun) {
|
||||
return {
|
||||
ok: true,
|
||||
@@ -1026,7 +1129,7 @@ async function publishUserServiceArtifact(config: UniDeskConfig, options: CiPubl
|
||||
const condition = wait === null ? null : await readPipelineRunCondition(name);
|
||||
const waitSucceeded = pipelineRunWaitSucceeded(wait, condition);
|
||||
const artifact = waitSucceeded && wait !== null
|
||||
? parseArtifactSummaryFromOutput(await readPipelineRunLogText(name), options.serviceId, options.commit, options.repoUrl, options.dockerfile)
|
||||
? await readArtifactSummaryFromPipelineRun(name, summaryContext)
|
||||
: plannedArtifact;
|
||||
if (waitSucceeded && wait !== null) assertArtifactSummaryComplete(artifact, name);
|
||||
return {
|
||||
@@ -1334,7 +1437,7 @@ export function ciHelp(): Record<string, unknown> {
|
||||
command: "bun scripts/cli.ts ci publish-user-service --service <service-id> --commit <full-sha>",
|
||||
initiallySupportedServices: ["baidu-netdisk", "decision-center"],
|
||||
registry: "127.0.0.1:5000/unidesk/<service-id>:<commit>",
|
||||
outputFields: ["imageRef", "tag", "digest", "sourceCommit", "serviceId"],
|
||||
outputFields: ["serviceId", "sourceCommit", "sourceRepo", "dockerfile", "imageRef", "tag", "digest", "digestRef"],
|
||||
boundary: "artifact producer only; no prod deploy and no production namespace mutation",
|
||||
},
|
||||
runDevE2E: {
|
||||
@@ -1367,7 +1470,8 @@ export async function runCiCommand(config: UniDeskConfig, args: string[]): Promi
|
||||
const repoUrl = stringOption(args, "--repo") ?? stringOption(args, "--repo-url") ?? "https://github.com/pikasTech/unidesk";
|
||||
const commit = requireFullCommit(stringOption(args, "--commit") ?? stringOption(args, "--revision"));
|
||||
const waitMs = numberOption(args, "--wait-ms", 0);
|
||||
return publishBackendCoreArtifact(config, { repoUrl, commit, waitMs, sourceHostPath: backendCoreArtifactSourceHostPath(commit) });
|
||||
const dryRun = boolFlag(args, "--dry-run");
|
||||
return publishBackendCoreArtifact(config, { repoUrl, commit, waitMs, sourceHostPath: backendCoreArtifactSourceHostPath(commit), dryRun });
|
||||
}
|
||||
if (action === "publish-user-service") {
|
||||
const serviceId = requireServiceId(stringOption(args, "--service") ?? stringOption(args, "--service-id"));
|
||||
|
||||
Reference in New Issue
Block a user