edfddd2445
* docs: specify cicd yaml target governance * fix: resolve cicd targets from yaml --------- Co-authored-by: Codex <codex@noreply.local>
430 lines
20 KiB
TypeScript
430 lines
20 KiB
TypeScript
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. publish module for scripts/src/ci.ts.
|
|
// SPEC: PJ2026-01060308 cicd-yaml-targets draft-2026-06-25-cicd-yaml-targets.
|
|
|
|
// Moved mechanically from scripts/src/ci.ts:2568-2959 for #903.
|
|
|
|
import { randomUUID } from "node:crypto";
|
|
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join, posix as posixPath } from "node:path";
|
|
import { blockedCatalogArtifactIds, catalogSummary, findCiCatalogArtifact, loadCiCatalog, supportedSourceBuildArtifactIds, type CiCatalogArtifact, type CiSourceBuildCatalogArtifact, type CiUpstreamImageCatalogArtifact } from "../ci-catalog";
|
|
import { runCommand } from "../command";
|
|
import { type UniDeskConfig, repoRoot, rootPath } from "../config";
|
|
import { ensureGithubSshIdentityForProvider, gitSshHttpConnectProxySource } from "../deploy-ssh-identity";
|
|
import { jobWithTail, listJobs, readJob, startJob } from "../jobs";
|
|
import { coreInternalFetch } from "../microservices";
|
|
import {
|
|
artifactRegistryReadonlyResultFromCommand,
|
|
buildArtifactRegistryReadonlyProbe,
|
|
parseArtifactRegistryOptions,
|
|
type ArtifactRegistryReadonlyProbe,
|
|
} from "../artifact-registry";
|
|
import { d601K3sGuardShellLines, d601NativeKubeconfig } from "../d601-k3s-guard";
|
|
import { runSshCommandCapture } from "../ssh";
|
|
|
|
import type { ArtifactSummaryContext, CiOptions, CiPublishBackendCoreOptions, CiPublishUserServiceArtifactOptions, PublishPreflightTransport } from "./types";
|
|
import { artifactSummaryDefaults } from "./artifact-summary";
|
|
import { publishUserServiceArtifactDirectDocker } from "./direct-docker";
|
|
import { blockedArtifactResult, blockedReason, numberOption, publishTransportOption, repoConnectivityProbeUrl, repoNeedsGithubSshIdentity, repoSshUrl, requireFullCommit, requireRepoRelativePath, requireServiceId, resolveCatalogArtifact, stringOption, userServicePublishBoundaryBlock } from "./options";
|
|
import { backendCoreArtifactPipelineRunManifest, backendCoreArtifactSourceHostPath, pipelineRunManifest, userServiceArtifactPipelineRunManifest, userServiceArtifactSourceHostPath } from "./pipelinerun";
|
|
import { pipelineRunWaitSucceeded, readPipelineRunCondition, remoteCreatePipelineRun, waitForPipelineRun } from "./pipelinerun-runtime";
|
|
import { publishPreflightControlChannelOrder, publishPreflightFailedScopes } from "./preflight";
|
|
import { assertArtifactSummaryComplete, publishBackendCorePreflight, publishUserServicePreflight, readArtifactSummaryFromPipelineRun } from "./publish-preflight";
|
|
import { localPublishPreflightTransport } from "./remote";
|
|
import { backendCoreArtifactRequirements, backendCoreDevApplyPath } from "./runner-preflight";
|
|
import { prepareBackendCoreArtifactSource, prepareClaudeqqArtifactSource, prepareUserServiceArtifactSource } from "./source-prepare";
|
|
import { d601ProviderId, providerGatewayWsEgressProxyUrl } from "./types";
|
|
|
|
export async function run(options: CiOptions): Promise<Record<string, unknown>> {
|
|
const name = await remoteCreatePipelineRun(pipelineRunManifest(options), options.target);
|
|
const wait = await waitForPipelineRun(name, options.waitMs, options.target);
|
|
const condition = wait === null ? null : await readPipelineRunCondition(name, options.target);
|
|
const waitSucceeded = pipelineRunWaitSucceeded(wait, condition);
|
|
return {
|
|
ok: waitSucceeded,
|
|
providerId: options.target.providerId,
|
|
pipelineRun: name,
|
|
namespace: "unidesk-ci",
|
|
repoUrl: options.repoUrl,
|
|
revision: options.revision,
|
|
wait: wait === null ? null : {
|
|
ok: waitSucceeded,
|
|
dispatchOk: wait.ok,
|
|
dispatchStatus: wait.status,
|
|
dispatchExitCode: wait.exitCode,
|
|
stdoutTail: wait.stdout.slice(-6000),
|
|
stderrTail: wait.stderr.slice(-6000),
|
|
},
|
|
condition,
|
|
next: [
|
|
`bun scripts/cli.ts ci logs ${name} --target ${options.target.targetId}`,
|
|
`bun scripts/cli.ts ci status --target ${options.target.targetId}`,
|
|
],
|
|
};
|
|
}
|
|
|
|
export async function publishBackendCoreArtifact(config: UniDeskConfig, options: CiPublishBackendCoreOptions): Promise<Record<string, unknown>> {
|
|
const summaryContext: ArtifactSummaryContext = {
|
|
serviceId: "backend-core",
|
|
commit: options.commit,
|
|
repoUrl: options.repoUrl,
|
|
dockerfile: options.dockerfile,
|
|
imageRepository: options.imageRepository,
|
|
};
|
|
const plannedArtifact = artifactSummaryDefaults(summaryContext);
|
|
if (options.dryRun) {
|
|
return runCiPublishBackendCoreDryRunPreflight(config, [
|
|
"publish-backend-core",
|
|
"--commit",
|
|
options.commit,
|
|
"--dry-run",
|
|
...(options.waitMs > 0 ? ["--wait-ms", String(options.waitMs)] : []),
|
|
], localPublishPreflightTransport, options);
|
|
}
|
|
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
|
|
? await readArtifactSummaryFromPipelineRun(name, summaryContext)
|
|
: plannedArtifact;
|
|
if (waitSucceeded && wait !== null) assertArtifactSummaryComplete(artifact, name);
|
|
return {
|
|
ok: waitSucceeded,
|
|
pipelineRun: name,
|
|
namespace: "unidesk-ci",
|
|
repoUrl: options.repoUrl,
|
|
commit: options.commit,
|
|
source,
|
|
artifact: artifact.imageRef,
|
|
artifactSummary: artifact,
|
|
boundary: "CI publishes the image to D601 registry; CD must pull it and must not build backend-core",
|
|
wait: wait === null ? null : {
|
|
ok: waitSucceeded,
|
|
dispatchOk: wait.ok,
|
|
dispatchStatus: wait.status,
|
|
dispatchExitCode: wait.exitCode,
|
|
stdoutTail: wait.stdout.slice(-6000),
|
|
stderrTail: wait.stderr.slice(-6000),
|
|
},
|
|
condition,
|
|
next: [
|
|
`bun scripts/cli.ts ci logs ${name}`,
|
|
`bun scripts/cli.ts deploy apply --env prod --service backend-core --commit ${options.commit}`,
|
|
],
|
|
};
|
|
}
|
|
|
|
export async function publishUserServiceArtifact(config: UniDeskConfig, options: CiPublishUserServiceArtifactOptions): Promise<Record<string, unknown>> {
|
|
const summaryContext: ArtifactSummaryContext = {
|
|
serviceId: options.serviceId,
|
|
dockerfile: options.dockerfile,
|
|
commit: options.commit,
|
|
repoUrl: options.repoUrl,
|
|
imageRepository: options.imageRepository,
|
|
};
|
|
const plannedArtifact = artifactSummaryDefaults(summaryContext);
|
|
const plannedRepoFetchUrl = repoSshUrl(options.repoUrl);
|
|
if (options.dryRun) {
|
|
const preflight = await publishUserServicePreflight(config, options, plannedArtifact, localPublishPreflightTransport);
|
|
return {
|
|
ok: preflight.ok,
|
|
mode: "dry-run-preflight",
|
|
runnerDisposition: preflight.runnerDisposition,
|
|
pipeline: "unidesk-user-service-artifact-publish",
|
|
namespace: "unidesk-ci",
|
|
repoUrl: options.repoUrl,
|
|
commit: options.commit,
|
|
serviceId: options.serviceId,
|
|
supportedArtifactPublish: preflight.supportedArtifactPublish,
|
|
missingChannels: preflight.missingChannels,
|
|
missingControlChannels: preflight.missingControlChannels,
|
|
controlChannels: preflight.controlChannels,
|
|
channels: preflight.channels,
|
|
failureClassification: preflight.failureClassification,
|
|
failedScopes: publishPreflightFailedScopes(preflight),
|
|
recommendedAction: preflight.recommendedAction,
|
|
remoteCommandShape: preflight.remoteCommandShape,
|
|
controlPlane: preflight.controlPlane,
|
|
registry: preflight.registry,
|
|
sourceHostPath: options.sourceHostPath,
|
|
source: {
|
|
ok: preflight.ok,
|
|
mode: "planned-only",
|
|
providerId: d601ProviderId,
|
|
repoUrl: options.repoUrl,
|
|
repoFetchUrl: plannedRepoFetchUrl,
|
|
repoProbeUrl: repoConnectivityProbeUrl(plannedRepoFetchUrl),
|
|
commit: options.commit,
|
|
serviceId: options.serviceId,
|
|
dockerfile: options.dockerfile,
|
|
imageRepository: options.imageRepository,
|
|
sourceHostPath: options.sourceHostPath,
|
|
...(options.serviceId === "claudeqq" ? { overlay: "UniDesk claudeqq Dockerfile and unidesk-adapter.cjs are injected before Tekton build" } : {}),
|
|
},
|
|
artifact: plannedArtifact.imageRef,
|
|
artifactSummary: plannedArtifact,
|
|
controlledPublish: {
|
|
environment: "D601",
|
|
namespace: "unidesk-ci",
|
|
pipeline: "unidesk-user-service-artifact-publish",
|
|
command: `bun scripts/cli.ts ci publish-user-service --service ${options.serviceId} --commit ${options.commit} --wait-ms 1200000 --transport ${options.transport}`,
|
|
requiresReadyControlChannels: publishPreflightControlChannelOrder,
|
|
},
|
|
boundary: preflight.boundary,
|
|
next: preflight.next,
|
|
};
|
|
}
|
|
if (options.transport === "direct-docker" || (options.transport === "auto" && options.serviceId === "code-queue" && options.repoUrl === "https://github.com/pikasTech/unidesk")) {
|
|
return publishUserServiceArtifactDirectDocker(options, summaryContext);
|
|
}
|
|
const source = options.serviceId === "claudeqq"
|
|
? await prepareClaudeqqArtifactSource(config, options)
|
|
: await prepareUserServiceArtifactSource(config, options);
|
|
const name = await remoteCreatePipelineRun(userServiceArtifactPipelineRunManifest(options));
|
|
const wait = await waitForPipelineRun(name, options.waitMs);
|
|
const condition = wait === null ? null : await readPipelineRunCondition(name);
|
|
const waitSucceeded = pipelineRunWaitSucceeded(wait, condition);
|
|
const artifact = waitSucceeded && wait !== null
|
|
? await readArtifactSummaryFromPipelineRun(name, summaryContext)
|
|
: plannedArtifact;
|
|
if (waitSucceeded && wait !== null) assertArtifactSummaryComplete(artifact, name);
|
|
return {
|
|
ok: waitSucceeded,
|
|
pipelineRun: name,
|
|
namespace: "unidesk-ci",
|
|
repoUrl: options.repoUrl,
|
|
commit: options.commit,
|
|
serviceId: options.serviceId,
|
|
source,
|
|
artifact: artifact.imageRef,
|
|
artifactSummary: artifact,
|
|
boundary: "CI publishes the user-service image to the D601 registry only; it must not deploy production or mutate the production namespace",
|
|
wait: wait === null ? null : {
|
|
ok: waitSucceeded,
|
|
dispatchOk: wait.ok,
|
|
dispatchStatus: wait.status,
|
|
dispatchExitCode: wait.exitCode,
|
|
stdoutTail: wait.stdout.slice(-6000),
|
|
stderrTail: wait.stderr.slice(-6000),
|
|
},
|
|
condition,
|
|
next: [
|
|
`bun scripts/cli.ts ci logs ${name}`,
|
|
"use artifactSummary.imageRef or artifactSummary.digestRef as later dev/prod deployment input",
|
|
],
|
|
};
|
|
}
|
|
|
|
export async function runCiPublishUserServiceDryRunPreflight(
|
|
config: UniDeskConfig,
|
|
args: string[],
|
|
transport: PublishPreflightTransport,
|
|
): Promise<Record<string, unknown>> {
|
|
const serviceId = requireServiceId(stringOption(args, "--service") ?? stringOption(args, "--service-id"));
|
|
const commit = requireFullCommit(stringOption(args, "--commit") ?? stringOption(args, "--revision"));
|
|
if (!args.includes("--dry-run")) throw new Error("publish-user-service preflight requires --dry-run");
|
|
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 dockerfile = requireRepoRelativePath(artifact.source.dockerfile, `CI.json.artifacts.${serviceId}.source.dockerfile`);
|
|
const boundaryBlock = userServicePublishBoundaryBlock(config, serviceId, commit, artifact);
|
|
if (boundaryBlock !== null) return boundaryBlock;
|
|
const summaryContext: ArtifactSummaryContext = {
|
|
serviceId,
|
|
dockerfile,
|
|
commit,
|
|
repoUrl: artifact.source.repo,
|
|
imageRepository: artifact.image.repository,
|
|
};
|
|
const plannedArtifact = artifactSummaryDefaults(summaryContext);
|
|
const options: CiPublishUserServiceArtifactOptions = {
|
|
repoUrl: artifact.source.repo,
|
|
commit,
|
|
waitMs: numberOption(args, "--wait-ms", 0),
|
|
serviceId,
|
|
dockerfile,
|
|
imageRepository: artifact.image.repository,
|
|
sourceHostPath: userServiceArtifactSourceHostPath(serviceId, commit),
|
|
dryRun: true,
|
|
transport: publishTransportOption(stringOption(args, "--transport")),
|
|
};
|
|
const preflight = await publishUserServicePreflight(config, options, plannedArtifact, transport);
|
|
const plannedRepoFetchUrl = repoSshUrl(options.repoUrl);
|
|
return {
|
|
ok: preflight.ok,
|
|
mode: "dry-run-preflight",
|
|
runnerDisposition: preflight.runnerDisposition,
|
|
pipeline: "unidesk-user-service-artifact-publish",
|
|
namespace: "unidesk-ci",
|
|
repoUrl: options.repoUrl,
|
|
commit: options.commit,
|
|
serviceId: options.serviceId,
|
|
supportedArtifactPublish: preflight.supportedArtifactPublish,
|
|
missingChannels: preflight.missingChannels,
|
|
missingControlChannels: preflight.missingControlChannels,
|
|
controlChannels: preflight.controlChannels,
|
|
channels: preflight.channels,
|
|
failureClassification: preflight.failureClassification,
|
|
failedScopes: publishPreflightFailedScopes(preflight),
|
|
recommendedAction: preflight.recommendedAction,
|
|
remoteCommandShape: preflight.remoteCommandShape,
|
|
controlPlane: preflight.controlPlane,
|
|
registry: preflight.registry,
|
|
sourceHostPath: options.sourceHostPath,
|
|
source: {
|
|
ok: preflight.ok,
|
|
mode: "planned-only",
|
|
providerId: d601ProviderId,
|
|
repoUrl: options.repoUrl,
|
|
repoFetchUrl: plannedRepoFetchUrl,
|
|
repoProbeUrl: repoConnectivityProbeUrl(plannedRepoFetchUrl),
|
|
commit: options.commit,
|
|
serviceId: options.serviceId,
|
|
dockerfile: options.dockerfile,
|
|
imageRepository: options.imageRepository,
|
|
sourceHostPath: options.sourceHostPath,
|
|
...(options.serviceId === "claudeqq" ? { overlay: "UniDesk claudeqq Dockerfile and unidesk-adapter.cjs are injected before Tekton build" } : {}),
|
|
},
|
|
artifact: plannedArtifact.imageRef,
|
|
artifactSummary: plannedArtifact,
|
|
controlledPublish: {
|
|
environment: "D601",
|
|
namespace: "unidesk-ci",
|
|
pipeline: "unidesk-user-service-artifact-publish",
|
|
command: `bun scripts/cli.ts ci publish-user-service --service ${options.serviceId} --commit ${options.commit} --wait-ms 1200000 --transport ${options.transport}`,
|
|
requiresReadyControlChannels: publishPreflightControlChannelOrder,
|
|
},
|
|
boundary: preflight.boundary,
|
|
next: preflight.next,
|
|
};
|
|
}
|
|
|
|
export async function runCiPublishBackendCoreDryRunPreflight(
|
|
config: UniDeskConfig,
|
|
args: string[],
|
|
transport: PublishPreflightTransport,
|
|
resolvedOptions?: CiPublishBackendCoreOptions,
|
|
): Promise<Record<string, unknown>> {
|
|
const commit = requireFullCommit(stringOption(args, "--commit") ?? stringOption(args, "--revision"));
|
|
if (!args.includes("--dry-run")) throw new Error("publish-backend-core preflight requires --dry-run");
|
|
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 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));
|
|
const dockerfile = requireRepoRelativePath(artifact.source.dockerfile, "CI.json.artifacts.backend-core.source.dockerfile");
|
|
const options: CiPublishBackendCoreOptions = resolvedOptions ?? {
|
|
repoUrl: artifact.source.repo,
|
|
commit,
|
|
waitMs: numberOption(args, "--wait-ms", 0),
|
|
sourceHostPath: backendCoreArtifactSourceHostPath(commit),
|
|
dockerfile,
|
|
imageRepository: artifact.image.repository,
|
|
dryRun: true,
|
|
};
|
|
const summaryContext: ArtifactSummaryContext = {
|
|
serviceId: "backend-core",
|
|
commit,
|
|
repoUrl: options.repoUrl,
|
|
dockerfile: options.dockerfile,
|
|
imageRepository: options.imageRepository,
|
|
};
|
|
const plannedArtifact = artifactSummaryDefaults(summaryContext);
|
|
const preflight = await publishBackendCorePreflight(config, options, plannedArtifact, transport);
|
|
const plannedRepoFetchUrl = repoSshUrl(options.repoUrl);
|
|
const blockedScopes = publishPreflightFailedScopes(preflight);
|
|
return {
|
|
ok: preflight.ok,
|
|
mode: "dry-run-preflight",
|
|
runnerDisposition: preflight.runnerDisposition,
|
|
pipeline: "unidesk-backend-core-artifact-publish",
|
|
namespace: "unidesk-ci",
|
|
repoUrl: options.repoUrl,
|
|
commit: options.commit,
|
|
targetCommit: options.commit,
|
|
sourceRepo: options.repoUrl,
|
|
serviceId: "backend-core",
|
|
providerId: d601ProviderId,
|
|
ciRunner: {
|
|
providerId: d601ProviderId,
|
|
environment: "D601",
|
|
namespace: "unidesk-ci",
|
|
pipeline: "unidesk-backend-core-artifact-publish",
|
|
wouldBuildOnD601: true,
|
|
realBuildRequiresAuthorization: true,
|
|
},
|
|
registryTarget: plannedArtifact.repository,
|
|
wouldBuildOnD601: true,
|
|
dryRunBuildStarted: false,
|
|
supportedArtifactPublish: preflight.supportedArtifactPublish,
|
|
missingChannels: preflight.missingChannels,
|
|
missingControlChannels: preflight.missingControlChannels,
|
|
controlChannels: preflight.controlChannels,
|
|
channels: preflight.channels,
|
|
failureClassification: preflight.failureClassification,
|
|
failedScopes: blockedScopes,
|
|
blockedScopes,
|
|
recommendedAction: preflight.recommendedAction,
|
|
remoteCommandShape: preflight.remoteCommandShape,
|
|
remoteControlPlaneCandidate: preflight.controlPlane,
|
|
controlPlane: preflight.controlPlane,
|
|
registry: preflight.registry,
|
|
sourceHostPath: options.sourceHostPath,
|
|
source: {
|
|
ok: preflight.ok,
|
|
mode: "planned-only",
|
|
providerId: d601ProviderId,
|
|
repoUrl: options.repoUrl,
|
|
repoFetchUrl: plannedRepoFetchUrl,
|
|
repoProbeUrl: repoConnectivityProbeUrl(plannedRepoFetchUrl),
|
|
commit: options.commit,
|
|
serviceId: "backend-core",
|
|
dockerfile: options.dockerfile,
|
|
imageRepository: options.imageRepository,
|
|
sourceHostPath: options.sourceHostPath,
|
|
},
|
|
sourceAuth: {
|
|
repoFetchUrl: plannedRepoFetchUrl,
|
|
egressProxy: providerGatewayWsEgressProxyUrl,
|
|
gitSshProxy: repoNeedsGithubSshIdentity(plannedRepoFetchUrl),
|
|
identityRequired: repoNeedsGithubSshIdentity(plannedRepoFetchUrl),
|
|
},
|
|
d601Ci: {
|
|
providerId: d601ProviderId,
|
|
namespace: "unidesk-ci",
|
|
pipeline: "unidesk-backend-core-artifact-publish",
|
|
wouldBuildOnD601: true,
|
|
dryRunWillStartTekton: false,
|
|
dryRunWillCompileRust: false,
|
|
dryRunWillPushRegistry: false,
|
|
},
|
|
artifact: plannedArtifact.imageRef,
|
|
artifactSummary: plannedArtifact,
|
|
artifactRequirements: backendCoreArtifactRequirements(options, plannedArtifact),
|
|
controlledPublish: {
|
|
environment: "D601",
|
|
namespace: "unidesk-ci",
|
|
pipeline: "unidesk-backend-core-artifact-publish",
|
|
command: `bun scripts/cli.ts ci publish-backend-core --commit ${options.commit} --wait-ms 1200000`,
|
|
requiresReadyControlChannels: publishPreflightControlChannelOrder,
|
|
requiresHumanAuthorization: true,
|
|
},
|
|
devApplyPath: backendCoreDevApplyPath(options, plannedArtifact),
|
|
boundary: preflight.boundary,
|
|
next: preflight.next,
|
|
};
|
|
}
|