feat: add selfmedia production release lane
This commit is contained in:
@@ -26,19 +26,30 @@ test("PaC bootstrap uses one exact YAML-owned Gitea repository", () => {
|
||||
owner: "mirrors",
|
||||
repo: "acme-widgets",
|
||||
cloneUrl: `${mirror.gitea.readUrl}/`,
|
||||
sourceBranch: "main",
|
||||
});
|
||||
expect(selected.key).toBe("widgets-nc01");
|
||||
const releaseMirror = { ...mirror, key: "widgets-release-nc01", upstream: { ...mirror.upstream, branch: "release" } };
|
||||
expect(resolvePacBootstrapMirrorRepository(fixtureConfig([mirror, releaseMirror]), "NC01", {
|
||||
id: "widgets-release-nc01",
|
||||
owner: "mirrors",
|
||||
repo: "acme-widgets",
|
||||
cloneUrl: mirror.gitea.readUrl,
|
||||
sourceBranch: "release",
|
||||
}).key).toBe("widgets-release-nc01");
|
||||
expect(() => resolvePacBootstrapMirrorRepository(fixtureConfig([]), "NC01", {
|
||||
id: "widgets-nc01",
|
||||
owner: "mirrors",
|
||||
repo: "acme-widgets",
|
||||
cloneUrl: mirror.gitea.readUrl,
|
||||
sourceBranch: "main",
|
||||
})).toThrow("no exact match");
|
||||
expect(() => resolvePacBootstrapMirrorRepository(fixtureConfig([mirror, { ...mirror }]), "NC01", {
|
||||
id: "widgets-nc01",
|
||||
owner: "mirrors",
|
||||
repo: "acme-widgets",
|
||||
cloneUrl: mirror.gitea.readUrl,
|
||||
sourceBranch: "main",
|
||||
})).toThrow("2 exact matches");
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface PacBootstrapRepositoryIdentity {
|
||||
readonly owner: string;
|
||||
readonly repo: string;
|
||||
readonly cloneUrl: string;
|
||||
readonly sourceBranch: string;
|
||||
}
|
||||
|
||||
export function resolvePacBootstrapMirrorRepository(
|
||||
@@ -18,12 +19,13 @@ export function resolvePacBootstrapMirrorRepository(
|
||||
&& item.gitea.owner === repository.owner
|
||||
&& item.gitea.name === repository.repo
|
||||
&& repositoryUrlIdentity(item.gitea.readUrl) === repositoryUrlIdentity(repository.cloneUrl)
|
||||
&& item.upstream.branch === repository.sourceBranch
|
||||
));
|
||||
if (candidates.length !== 1) {
|
||||
const disposition = candidates.length === 0 ? "no exact match" : `${candidates.length} exact matches`;
|
||||
throw new Error(
|
||||
`cannot resolve the YAML-owned Gitea bootstrap repository for PaC repository ${repository.id} on ${targetId}: ${disposition}; `
|
||||
+ "match targetId, gitea owner/name, and read URL in config/platform-infra/gitea.yaml",
|
||||
+ "match targetId, gitea owner/name, read URL, and source branch in config/platform-infra/gitea.yaml",
|
||||
);
|
||||
}
|
||||
return candidates[0];
|
||||
|
||||
@@ -989,7 +989,10 @@ async function bootstrap(config: UniDeskConfig, options: ApplyOptions): Promise<
|
||||
const repository = resolveRepository(pac, consumer.repositoryRef);
|
||||
let mirror: ReturnType<typeof resolvePacBootstrapMirrorRepository>;
|
||||
try {
|
||||
mirror = resolvePacBootstrapMirrorRepository(readGiteaConfig(), target.id, repository);
|
||||
mirror = resolvePacBootstrapMirrorRepository(readGiteaConfig(), target.id, {
|
||||
...repository,
|
||||
sourceBranch: repository.params.source_branch,
|
||||
});
|
||||
} catch (error) {
|
||||
return pacBootstrapYamlMatchFailure(target.id, consumer.id, error);
|
||||
}
|
||||
|
||||
@@ -94,7 +94,8 @@ export function readSelfMediaConfig(): SelfMediaConfig {
|
||||
const cutover = record(root.cutover, "cutover");
|
||||
const deliveryRecords = record(delivery.targets, "delivery.targets");
|
||||
const cutoverRecords = record(cutover.targets, "cutover.targets");
|
||||
const deliveryTargets = Object.fromEntries(Object.entries(deliveryRecords).map(([id, value]) => [id, parseDeliveryTarget(id, record(value, `delivery.targets.${id}`))]));
|
||||
const resolvedDeliveryRecords = resolveDeliveryRecords(deliveryRecords);
|
||||
const deliveryTargets = Object.fromEntries(Object.entries(resolvedDeliveryRecords).map(([id, value]) => [id, parseDeliveryTarget(id, value)]));
|
||||
const cutoverTargets = Object.fromEntries(Object.entries(cutoverRecords).map(([id, value]) => [id, parseCutoverTarget(id, record(value, `cutover.targets.${id}`))]));
|
||||
const defaultTargetId = text(defaults.targetId, "defaults.targetId");
|
||||
if (deliveryTargets[defaultTargetId] === undefined || cutoverTargets[defaultTargetId] === undefined) {
|
||||
@@ -111,6 +112,12 @@ export function resolveSelfMediaDeliveryTarget(targetId?: string | null): SelfMe
|
||||
return target;
|
||||
}
|
||||
|
||||
export function resolveSelfMediaDeliveryTargetByConfigRef(configRef: string): SelfMediaDeliveryTarget {
|
||||
const prefix = `${selfMediaConfigRef}#delivery.targets.`;
|
||||
if (!configRef.startsWith(prefix)) throw new Error(`selfmedia delivery configRef must start with ${prefix}`);
|
||||
return resolveSelfMediaDeliveryTarget(configRef.slice(prefix.length));
|
||||
}
|
||||
|
||||
export function resolveSelfMediaCutoverTarget(targetId?: string | null): SelfMediaCutoverTarget {
|
||||
const config = readSelfMediaConfig();
|
||||
const id = targetId ?? config.defaultTargetId;
|
||||
@@ -151,19 +158,19 @@ function parseDeliveryTarget(id: string, value: Record<string, unknown>): SelfMe
|
||||
const publicExposure = record(deployment.publicExposure, `${path}.deployment.publicExposure`);
|
||||
const noProxy = stringArray(proxy.noProxy, `${path}.build.proxy.noProxy`);
|
||||
if (!noProxy.includes("hyueapi.com") || !noProxy.includes(".hyueapi.com")) throw new Error(`${path}.build.proxy.noProxy must preserve hyueapi.com and .hyueapi.com`);
|
||||
if (text(value.node, `${path}.node`) !== id) throw new Error(`${path}.node must match target id ${id}`);
|
||||
const node = text(value.node, `${path}.node`);
|
||||
if (text(target.id, `${path}.deployment.target.id`) !== id) throw new Error(`${path}.deployment.target.id must match ${id}`);
|
||||
if (text(source.repository, `${path}.source.repository`) !== "pikainc/selfmedia") throw new Error(`${path}.source.repository must be pikainc/selfmedia`);
|
||||
if (!text(source.snapshotPrefix, `${path}.source.snapshotPrefix`).startsWith("refs/unidesk/snapshots/")) throw new Error(`${path}.source.snapshotPrefix must be immutable`);
|
||||
if (boolean(ci.serviceAccountAutomount, `${path}.ci.serviceAccountAutomount`) !== false) throw new Error(`${path}.ci.serviceAccountAutomount must be false`);
|
||||
if (boolean(publicExposure.enabled, `${path}.deployment.publicExposure.enabled`) !== true) throw new Error(`${path}.deployment.publicExposure.enabled must be true`);
|
||||
if (integer(publicExposure.hostPort, `${path}.deployment.publicExposure.hostPort`) !== 4317) throw new Error(`${path}.deployment.publicExposure.hostPort must be 4317`);
|
||||
if (integer(publicExposure.hostPort, `${path}.deployment.publicExposure.hostPort`) <= 0) throw new Error(`${path}.deployment.publicExposure.hostPort must be positive`);
|
||||
validateAccessSecret(deployment, `${path}.deployment`);
|
||||
const gitopsWriteUrl = text(gitops.writeUrl, `${path}.gitops.writeUrl`);
|
||||
if (!gitopsWriteUrl.startsWith("http://gitea-http.")) throw new Error(`${path}.gitops.writeUrl must use internal Gitea authority`);
|
||||
return {
|
||||
id,
|
||||
node: id,
|
||||
node,
|
||||
lane: text(value.lane, `${path}.lane`),
|
||||
route: text(value.route, `${path}.route`),
|
||||
ci: {
|
||||
@@ -207,6 +214,38 @@ function parseDeliveryTarget(id: string, value: Record<string, unknown>): SelfMe
|
||||
};
|
||||
}
|
||||
|
||||
function resolveDeliveryRecords(records: Record<string, unknown>): Record<string, Record<string, unknown>> {
|
||||
const resolved: Record<string, Record<string, unknown>> = {};
|
||||
const resolving = new Set<string>();
|
||||
const resolveOne = (id: string): Record<string, unknown> => {
|
||||
if (resolved[id] !== undefined) return resolved[id];
|
||||
if (resolving.has(id)) throw new Error(`${selfMediaConfigRef}.delivery.targets.${id}.extends contains a cycle`);
|
||||
const raw = record(records[id], `delivery.targets.${id}`);
|
||||
resolving.add(id);
|
||||
const parentId = raw.extends === undefined ? null : text(raw.extends, `delivery.targets.${id}.extends`);
|
||||
const own = Object.fromEntries(Object.entries(raw).filter(([key]) => key !== "extends"));
|
||||
const effective = parentId === null ? structuredClone(own) : deepMerge(resolveOne(parentId), own);
|
||||
resolving.delete(id);
|
||||
resolved[id] = effective;
|
||||
return effective;
|
||||
};
|
||||
for (const id of Object.keys(records)) resolveOne(id);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function deepMerge(base: Record<string, unknown>, override: Record<string, unknown>): Record<string, unknown> {
|
||||
const result = structuredClone(base);
|
||||
for (const [key, value] of Object.entries(override)) {
|
||||
const current = result[key];
|
||||
result[key] = isPlainRecord(current) && isPlainRecord(value) ? deepMerge(current, value) : structuredClone(value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function validateAccessSecret(deployment: Record<string, unknown>, path: string): void {
|
||||
const secrets = record(deployment.secrets, `${path}.secrets`);
|
||||
const access = record(secrets.access, `${path}.secrets.access`);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { spawnSync } from "node:child_process";
|
||||
|
||||
import {
|
||||
resolveSelfMediaDeliveryTarget,
|
||||
resolveSelfMediaDeliveryTargetByConfigRef,
|
||||
selfMediaDeliveryConfigRef,
|
||||
selfMediaEffectiveDeployment,
|
||||
type SelfMediaDeliveryTarget,
|
||||
@@ -39,7 +40,7 @@ export interface SelfMediaRenderedPipeline {
|
||||
}
|
||||
|
||||
export function renderSelfMediaDesiredPipeline(binding: SelfMediaRendererBinding, sourceWorktree: string): SelfMediaRenderedPipeline {
|
||||
const target = resolveSelfMediaDeliveryTarget(binding.consumer.node);
|
||||
const target = resolveSelfMediaDeliveryTargetByConfigRef(binding.consumer.sourceArtifact.configRef);
|
||||
const configRef = selfMediaDeliveryConfigRef(target.id);
|
||||
if (binding.consumer.sourceArtifact.renderer !== "selfmedia-runtime" || binding.consumer.sourceArtifact.mode !== "embedded-pipeline-spec") {
|
||||
throw new Error("selfmedia-runtime requires embedded-pipeline-spec");
|
||||
@@ -320,7 +321,8 @@ function validateRuntimeRender(sourceWorktree: string, effectiveDeployment: Reco
|
||||
throw new Error(`selfmedia effective deployment failed service renderer validation: ${evidence}`);
|
||||
}
|
||||
const output = result.stdout;
|
||||
const required = ["kind: Deployment", "kind: Service", "kind: PersistentVolumeClaim", "kind: NetworkPolicy", "hostPort: 4317", "automountServiceAccountToken: false"];
|
||||
const publicExposure = requiredRecord(effectiveDeployment.publicExposure, "deployment.publicExposure");
|
||||
const required = ["kind: Deployment", "kind: Service", "kind: PersistentVolumeClaim", "kind: NetworkPolicy", `hostPort: ${requiredInteger(publicExposure.hostPort, "deployment.publicExposure.hostPort")}`, "automountServiceAccountToken: false"];
|
||||
for (const marker of required) {
|
||||
if (!output.includes(marker)) throw new Error(`selfmedia service renderer output is missing ${marker}`);
|
||||
}
|
||||
@@ -354,3 +356,13 @@ function requiredStringArray(value: unknown, path: string): string[] {
|
||||
if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.length === 0)) throw new Error(`${path} must be an array of non-empty strings`);
|
||||
return value as string[];
|
||||
}
|
||||
|
||||
function requiredRecord(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 requiredInteger(value: unknown, path: string): number {
|
||||
if (!Number.isInteger(value)) throw new Error(`${path} must be an integer`);
|
||||
return value as number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user