389 lines
16 KiB
TypeScript
389 lines
16 KiB
TypeScript
import { rootPath } from "./config";
|
||
import { readYamlRecord } from "./platform-infra-ops-library";
|
||
import { materializeYamlComposition } from "./yaml-composition";
|
||
|
||
const pacConfigPath = "config/platform-infra/pipelines-as-code.yaml";
|
||
const giteaConfigPath = "config/platform-infra/gitea.yaml";
|
||
|
||
export const PAC_AUTOMATIC_DELIVERY_REFERENCE = "docs/reference/platform-infra.md#gitea-与-pipelines-as-code-边界";
|
||
|
||
export interface PacDeliveryConsumerAuthority {
|
||
readonly consumerId: string;
|
||
readonly node: string;
|
||
readonly lane: string;
|
||
readonly namespace: string;
|
||
readonly pipelineRunPrefix: string;
|
||
readonly repositoryRef: string;
|
||
readonly sourceRepository: string;
|
||
readonly sourceBranch: string;
|
||
readonly sourceSnapshotPrefix: string;
|
||
readonly giteaOwner: string;
|
||
readonly giteaRepository: string;
|
||
readonly giteaRepositoryUrl: string;
|
||
readonly pacControllerVersion: string;
|
||
readonly configRefs: readonly [string, string];
|
||
}
|
||
|
||
export interface CicdDeliveryAuthorityCatalog {
|
||
readonly consumers: readonly PacDeliveryConsumerAuthority[];
|
||
readonly configRefs: readonly [string, string];
|
||
}
|
||
|
||
export interface CicdDeliveryAuthorityQuery {
|
||
readonly consumerId?: string | null;
|
||
readonly node?: string | null;
|
||
readonly lane?: string | null;
|
||
readonly sourceRepository?: string | null;
|
||
readonly sourceBranch?: string | null;
|
||
readonly declaredSourceAuthorityMode?: string | null;
|
||
readonly declaredConfigRef?: string | null;
|
||
}
|
||
|
||
export interface CicdDeliveryMutationContext {
|
||
readonly command: string;
|
||
readonly statusCommand: string;
|
||
readonly action: string;
|
||
readonly node: string;
|
||
readonly lane: string;
|
||
}
|
||
|
||
export type CicdDeliveryMutationDecision =
|
||
| {
|
||
readonly allowed: true;
|
||
readonly authority: Extract<CicdDeliveryAuthority, { kind: "legacy-manual" }>;
|
||
}
|
||
| {
|
||
readonly allowed: false;
|
||
readonly authority: Exclude<CicdDeliveryAuthority, { kind: "legacy-manual" }>;
|
||
readonly result: Record<string, unknown>;
|
||
};
|
||
|
||
export type CicdDeliveryAuthority =
|
||
| {
|
||
readonly kind: "pac-pr-merge";
|
||
readonly migrated: true;
|
||
readonly mutationHintsAllowed: false;
|
||
readonly consumer: PacDeliveryConsumerAuthority;
|
||
readonly reason: "exact-pac-consumer";
|
||
readonly valuesPrinted: false;
|
||
}
|
||
| {
|
||
readonly kind: "legacy-manual";
|
||
readonly migrated: false;
|
||
readonly mutationHintsAllowed: true;
|
||
readonly consumer: null;
|
||
readonly reason: "explicit-legacy-source-authority";
|
||
readonly declaredSourceAuthorityMode: string;
|
||
readonly declaredConfigRef: string;
|
||
readonly valuesPrinted: false;
|
||
}
|
||
| {
|
||
readonly kind: "unknown";
|
||
readonly migrated: false;
|
||
readonly mutationHintsAllowed: false;
|
||
readonly consumer: null;
|
||
readonly reason: "authority-config-invalid" | "authority-identity-mismatch" | "authority-not-declared" | "authority-ambiguous";
|
||
readonly detail: string;
|
||
readonly configRefs: readonly string[];
|
||
readonly valuesPrinted: false;
|
||
};
|
||
|
||
interface PacRepositoryRow {
|
||
readonly id: string;
|
||
readonly owner: string;
|
||
readonly repo: string;
|
||
readonly sourceBranch: string;
|
||
readonly sourceSnapshotPrefix: string;
|
||
readonly url: string;
|
||
}
|
||
|
||
interface GiteaRepositoryRow {
|
||
readonly targetId: string;
|
||
readonly owner: string;
|
||
readonly repo: string;
|
||
readonly sourceRepository: string;
|
||
readonly sourceBranch: string;
|
||
}
|
||
|
||
const legacyAuthorityModes = new Set(["gitMirrorSnapshot", "git-mirror-snapshot", "k8s-git-mirror-snapshot"]);
|
||
|
||
export function readCicdDeliveryAuthorityCatalog(): CicdDeliveryAuthorityCatalog {
|
||
const pacRoot = composedYaml(pacConfigPath, "platform-infra-pipelines-as-code");
|
||
const giteaRoot = composedYaml(giteaConfigPath, "platform-infra-gitea");
|
||
const release = record(pacRoot.release, `${pacConfigPath}#release`);
|
||
const pacControllerVersion = requiredString(release.version, `${pacConfigPath}#release.version`);
|
||
const pacRepositories = recordArray(pacRoot.repositories, `${pacConfigPath}#repositories`).map((item, index) => {
|
||
const path = `${pacConfigPath}#repositories[${index}]`;
|
||
const params = record(item.params, `${path}.params`);
|
||
return {
|
||
id: requiredString(item.id ?? item.name, `${path}.id`),
|
||
owner: requiredString(item.owner, `${path}.owner`),
|
||
repo: requiredString(item.repo, `${path}.repo`),
|
||
sourceBranch: requiredString(params.source_branch, `${path}.params.source_branch`),
|
||
sourceSnapshotPrefix: requiredString(params.source_snapshot_prefix, `${path}.params.source_snapshot_prefix`),
|
||
url: requiredString(item.url, `${path}.url`),
|
||
} satisfies PacRepositoryRow;
|
||
});
|
||
const sourceAuthority = record(giteaRoot.sourceAuthority, `${giteaConfigPath}#sourceAuthority`);
|
||
const giteaRepositories = recordArray(sourceAuthority.repositories, `${giteaConfigPath}#sourceAuthority.repositories`).map((item, index) => {
|
||
const path = `${giteaConfigPath}#sourceAuthority.repositories[${index}]`;
|
||
const upstream = record(item.upstream, `${path}.upstream`);
|
||
const gitea = record(item.gitea, `${path}.gitea`);
|
||
return {
|
||
targetId: requiredString(item.targetId, `${path}.targetId`),
|
||
owner: requiredString(gitea.owner, `${path}.gitea.owner`),
|
||
repo: requiredString(gitea.name, `${path}.gitea.name`),
|
||
sourceRepository: requiredString(upstream.repository, `${path}.upstream.repository`),
|
||
sourceBranch: requiredString(upstream.branch, `${path}.upstream.branch`),
|
||
} satisfies GiteaRepositoryRow;
|
||
});
|
||
const consumers = recordArray(pacRoot.consumers, `${pacConfigPath}#consumers`).map((item, index) => {
|
||
const path = `${pacConfigPath}#consumers[${index}]`;
|
||
const consumerId = requiredString(item.id, `${path}.id`);
|
||
const node = requiredString(item.node, `${path}.node`);
|
||
const lane = requiredString(item.lane, `${path}.lane`);
|
||
const namespace = requiredString(item.namespace, `${path}.namespace`);
|
||
const pipelineRunPrefix = requiredString(item.pipelineRunPrefix, `${path}.pipelineRunPrefix`);
|
||
const repositoryRef = requiredString(item.repositoryRef, `${path}.repositoryRef`);
|
||
const pacRepository = exactlyOne(
|
||
pacRepositories.filter((candidate) => same(candidate.id, repositoryRef)),
|
||
`${path}.repositoryRef=${repositoryRef}`,
|
||
);
|
||
const authorityRepository = exactlyOne(
|
||
giteaRepositories.filter((candidate) => same(candidate.targetId, node)
|
||
&& same(candidate.owner, pacRepository.owner)
|
||
&& same(candidate.repo, pacRepository.repo)
|
||
&& same(candidate.sourceBranch, pacRepository.sourceBranch)),
|
||
`${path} source authority ${node}/${pacRepository.owner}/${pacRepository.repo}@${pacRepository.sourceBranch}`,
|
||
);
|
||
return {
|
||
consumerId,
|
||
node,
|
||
lane,
|
||
namespace,
|
||
pipelineRunPrefix,
|
||
repositoryRef,
|
||
sourceRepository: authorityRepository.sourceRepository,
|
||
sourceBranch: authorityRepository.sourceBranch,
|
||
sourceSnapshotPrefix: pacRepository.sourceSnapshotPrefix,
|
||
giteaOwner: pacRepository.owner,
|
||
giteaRepository: pacRepository.repo,
|
||
giteaRepositoryUrl: pacRepository.url,
|
||
pacControllerVersion,
|
||
configRefs: [pacConfigPath, giteaConfigPath],
|
||
} satisfies PacDeliveryConsumerAuthority;
|
||
});
|
||
const duplicateIds = consumers.filter((item, index) => consumers.findIndex((candidate) => same(candidate.consumerId, item.consumerId)) !== index);
|
||
if (duplicateIds.length > 0) throw new Error(`${pacConfigPath} contains duplicate consumer ids: ${duplicateIds.map((item) => item.consumerId).join(", ")}`);
|
||
return { consumers, configRefs: [pacConfigPath, giteaConfigPath] };
|
||
}
|
||
|
||
export function resolveCicdDeliveryAuthority(query: CicdDeliveryAuthorityQuery): CicdDeliveryAuthority {
|
||
try {
|
||
return resolveCicdDeliveryAuthorityFromCatalog(readCicdDeliveryAuthorityCatalog(), query);
|
||
} catch (error) {
|
||
return {
|
||
kind: "unknown",
|
||
migrated: false,
|
||
mutationHintsAllowed: false,
|
||
consumer: null,
|
||
reason: "authority-config-invalid",
|
||
detail: error instanceof Error ? error.message : String(error),
|
||
configRefs: [pacConfigPath, giteaConfigPath],
|
||
valuesPrinted: false,
|
||
};
|
||
}
|
||
}
|
||
|
||
export function resolveCicdDeliveryAuthorityFromCatalog(catalog: CicdDeliveryAuthorityCatalog, query: CicdDeliveryAuthorityQuery): CicdDeliveryAuthority {
|
||
const suppliedIdentity = [query.consumerId, query.node, query.lane, query.sourceRepository, query.sourceBranch]
|
||
.some((value) => normalized(value) !== null);
|
||
const candidates = suppliedIdentity ? catalog.consumers.filter((consumer) => matchesQuery(consumer, query)) : [];
|
||
const stableCandidates = stableIdentityCandidates(catalog, query);
|
||
if (candidates.length === 1) {
|
||
return {
|
||
kind: "pac-pr-merge",
|
||
migrated: true,
|
||
mutationHintsAllowed: false,
|
||
consumer: candidates[0],
|
||
reason: "exact-pac-consumer",
|
||
valuesPrinted: false,
|
||
};
|
||
}
|
||
if (candidates.length > 1) {
|
||
return unknownAuthority("authority-ambiguous", `identity matches multiple PaC consumers: ${candidates.map((item) => item.consumerId).join(", ")}`, catalog.configRefs);
|
||
}
|
||
if (suppliedIdentity && query.consumerId !== undefined && query.consumerId !== null) {
|
||
return unknownAuthority("authority-identity-mismatch", `consumer identity does not match ${query.consumerId}`, catalog.configRefs);
|
||
}
|
||
if (stableCandidates.length > 0) {
|
||
return unknownAuthority(
|
||
stableCandidates.length > 1 ? "authority-ambiguous" : "authority-identity-mismatch",
|
||
`stable PaC identity conflicts with source details: ${stableCandidates.map((item) => item.consumerId).join(", ")}`,
|
||
catalog.configRefs,
|
||
);
|
||
}
|
||
const declaredMode = normalized(query.declaredSourceAuthorityMode);
|
||
const declaredConfigRef = normalized(query.declaredConfigRef);
|
||
if (declaredMode !== null && legacyAuthorityModes.has(declaredMode) && declaredConfigRef !== null) {
|
||
return {
|
||
kind: "legacy-manual",
|
||
migrated: false,
|
||
mutationHintsAllowed: true,
|
||
consumer: null,
|
||
reason: "explicit-legacy-source-authority",
|
||
declaredSourceAuthorityMode: declaredMode,
|
||
declaredConfigRef,
|
||
valuesPrinted: false,
|
||
};
|
||
}
|
||
return unknownAuthority(
|
||
suppliedIdentity ? "authority-identity-mismatch" : "authority-not-declared",
|
||
suppliedIdentity ? "no exact PaC consumer or explicit legacy authority matched" : "delivery authority identity is not declared",
|
||
catalog.configRefs,
|
||
);
|
||
}
|
||
|
||
function stableIdentityCandidates(catalog: CicdDeliveryAuthorityCatalog, query: CicdDeliveryAuthorityQuery): readonly PacDeliveryConsumerAuthority[] {
|
||
const consumerId = normalized(query.consumerId);
|
||
if (consumerId !== null) return catalog.consumers.filter((consumer) => same(consumer.consumerId, consumerId));
|
||
const node = normalized(query.node);
|
||
const lane = normalized(query.lane);
|
||
if (node === null || lane === null) return [];
|
||
return catalog.consumers.filter((consumer) => same(consumer.node, node) && same(consumer.lane, lane));
|
||
}
|
||
|
||
export function decideCicdDeliveryMutation(authority: CicdDeliveryAuthority, context: CicdDeliveryMutationContext): CicdDeliveryMutationDecision {
|
||
if (authority.kind === "legacy-manual") return { allowed: true, authority };
|
||
const next = authority.kind === "pac-pr-merge"
|
||
? pacReadOnlyNext(authority.consumer.node, authority.consumer.consumerId)
|
||
: {
|
||
status: context.statusCommand,
|
||
fixAutomaticDelivery: pacAutomaticDeliveryFix(authority),
|
||
valuesPrinted: false,
|
||
};
|
||
return {
|
||
allowed: false,
|
||
authority,
|
||
result: {
|
||
ok: false,
|
||
command: context.command,
|
||
mode: authority.kind === "pac-pr-merge" ? "pac-pr-merge-mutation-blocked" : "delivery-authority-unknown",
|
||
mutation: false,
|
||
requestedAction: context.action,
|
||
node: context.node,
|
||
lane: context.lane,
|
||
reason: authority.kind === "pac-pr-merge"
|
||
? "该 lane 已迁移到 PaC;GitHub PR merge 是唯一交付触发,CLI 不执行人工推进。"
|
||
: "无法从 owning YAML 唯一确认 delivery authority;CLI 已 fail-closed。",
|
||
deliveryAuthority: authority,
|
||
next,
|
||
valuesPrinted: false,
|
||
},
|
||
};
|
||
}
|
||
|
||
export function gitRepositoryIdentity(remoteUrl: string): string | null {
|
||
const value = remoteUrl.trim();
|
||
if (value.length === 0) return null;
|
||
const scp = value.match(/^[^@\s]+@[^:\s]+:([^\s?#]+?)(?:\.git)?$/u);
|
||
if (scp !== null) return normalizedRepositoryPath(scp[1]);
|
||
try {
|
||
const url = new URL(value);
|
||
return normalizedRepositoryPath(url.pathname);
|
||
} catch {
|
||
return normalizedRepositoryPath(value);
|
||
}
|
||
}
|
||
|
||
export function pacAutomaticDeliveryFix(authority: CicdDeliveryAuthority): Record<string, unknown> {
|
||
const configRefs = authority.kind === "pac-pr-merge" ? authority.consumer.configRefs : authority.kind === "unknown" ? authority.configRefs : [authority.declaredConfigRef];
|
||
return automaticDeliveryFix(configRefs, authority.kind === "unknown" ? "fix-delivery-authority" : "fix-automatic-delivery");
|
||
}
|
||
|
||
export function pacReadOnlyNext(targetId: string, consumerId: string): Record<string, unknown> {
|
||
return {
|
||
status: `bun scripts/cli.ts platform-infra pipelines-as-code status --target ${targetId} --consumer ${consumerId}`,
|
||
history: `bun scripts/cli.ts platform-infra pipelines-as-code history --target ${targetId} --consumer ${consumerId}`,
|
||
fixAutomaticDelivery: automaticDeliveryFix([pacConfigPath, giteaConfigPath], "fix-automatic-delivery"),
|
||
valuesPrinted: false,
|
||
};
|
||
}
|
||
|
||
export function pacNodeReadOnlyNext(targetId: string): Record<string, unknown> {
|
||
return {
|
||
status: `bun scripts/cli.ts cicd status --node ${targetId}`,
|
||
history: `bun scripts/cli.ts platform-infra pipelines-as-code history --target ${targetId} --limit 10`,
|
||
fixAutomaticDelivery: automaticDeliveryFix([pacConfigPath, giteaConfigPath], "fix-automatic-delivery"),
|
||
valuesPrinted: false,
|
||
};
|
||
}
|
||
|
||
function automaticDeliveryFix(configRefs: readonly string[], code: "fix-automatic-delivery" | "fix-delivery-authority"): Record<string, unknown> {
|
||
return {
|
||
code,
|
||
reference: PAC_AUTOMATIC_DELIVERY_REFERENCE,
|
||
configRefs,
|
||
instruction: "修复 owning YAML、controller 或源码中的自动链缺陷,并以新的正常 GitHub PR merge 验收;不得人工补齐当前交付。",
|
||
mutation: false,
|
||
valuesPrinted: false,
|
||
};
|
||
}
|
||
|
||
function normalizedRepositoryPath(value: string): string | null {
|
||
const result = value.replace(/^\/+|\/+$/gu, "").replace(/\.git$/iu, "").trim();
|
||
if (!/^[^/\s]+\/[^/\s]+$/u.test(result)) return null;
|
||
return result;
|
||
}
|
||
|
||
function matchesQuery(consumer: PacDeliveryConsumerAuthority, query: CicdDeliveryAuthorityQuery): boolean {
|
||
return matches(consumer.consumerId, query.consumerId)
|
||
&& matches(consumer.node, query.node)
|
||
&& matches(consumer.lane, query.lane)
|
||
&& matches(consumer.sourceRepository, query.sourceRepository)
|
||
&& matches(consumer.sourceBranch, query.sourceBranch);
|
||
}
|
||
|
||
function matches(actual: string, expected: string | null | undefined): boolean {
|
||
const value = normalized(expected);
|
||
return value === null || same(actual, value);
|
||
}
|
||
|
||
function same(left: string, right: string): boolean {
|
||
return left.toLowerCase() === right.toLowerCase();
|
||
}
|
||
|
||
function normalized(value: string | null | undefined): string | null {
|
||
if (typeof value !== "string") return null;
|
||
const result = value.trim();
|
||
return result.length === 0 ? null : result;
|
||
}
|
||
|
||
function unknownAuthority(reason: Extract<CicdDeliveryAuthority, { kind: "unknown" }>["reason"], detail: string, configRefs: readonly string[]): CicdDeliveryAuthority {
|
||
return { kind: "unknown", migrated: false, mutationHintsAllowed: false, consumer: null, reason, detail, configRefs, valuesPrinted: false };
|
||
}
|
||
|
||
function composedYaml(path: string, kind: string): Record<string, unknown> {
|
||
return materializeYamlComposition(readYamlRecord<Record<string, unknown>>(rootPath(...path.split("/")), kind), { label: path }).value;
|
||
}
|
||
|
||
function record(value: unknown, path: string): Record<string, unknown> {
|
||
if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error(`${path} must be an object`);
|
||
return value as Record<string, unknown>;
|
||
}
|
||
|
||
function recordArray(value: unknown, path: string): Record<string, unknown>[] {
|
||
if (!Array.isArray(value)) throw new Error(`${path} must be an array`);
|
||
return value.map((item, index) => record(item, `${path}[${index}]`));
|
||
}
|
||
|
||
function requiredString(value: unknown, path: string): string {
|
||
if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${path} must be a non-empty string`);
|
||
return value.trim();
|
||
}
|
||
|
||
function exactlyOne<T>(items: T[], path: string): T {
|
||
if (items.length !== 1) throw new Error(`${path} must resolve exactly one row; got ${items.length}`);
|
||
return items[0];
|
||
}
|