231 lines
9.8 KiB
JavaScript
231 lines
9.8 KiB
JavaScript
import { createHash } from "node:crypto";
|
|
import { readFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
|
|
export function artifactCatalogAuthorityPath(catalogPath) {
|
|
return `${catalogPath}.authority.json`;
|
|
}
|
|
|
|
export function artifactCatalogSha256(catalog) {
|
|
return createHash("sha256").update(JSON.stringify(catalog)).digest("hex");
|
|
}
|
|
|
|
export function artifactCatalogSourceCommitId(catalog) {
|
|
const direct = text(catalog?.publish?.sourceCommitId) || text(catalog?.sourceCommitId) || text(catalog?.commitId);
|
|
if (direct) return direct;
|
|
const serviceCommits = unique((catalog?.services ?? []).map((service) => text(service?.sourceCommitId)).filter(Boolean));
|
|
return serviceCommits.length === 1 ? serviceCommits[0] : null;
|
|
}
|
|
|
|
export function artifactCatalogServiceCoverage(catalog, selectedServices) {
|
|
if (!Array.isArray(catalog?.services)) throw new Error("artifact catalog services must be an array");
|
|
const selectedServiceIds = normalizedServiceIds(selectedServices, "selected services");
|
|
const catalogServiceIds = normalizedServiceIds(catalog.services.map((service) => service?.serviceId), "artifact catalog services");
|
|
const missingSelectedServices = selectedServiceIds.filter((serviceId) => !catalogServiceIds.includes(serviceId));
|
|
const extraCatalogServices = catalogServiceIds.filter((serviceId) => !selectedServiceIds.includes(serviceId));
|
|
return {
|
|
status: missingSelectedServices.length > 0
|
|
? (extraCatalogServices.length > 0 ? "divergent" : "partial")
|
|
: (extraCatalogServices.length > 0 ? "superset" : "exact"),
|
|
selectedServiceCount: selectedServiceIds.length,
|
|
selectedServicePresentCount: selectedServiceIds.length - missingSelectedServices.length,
|
|
catalogServiceCount: catalogServiceIds.length,
|
|
selectedServiceIds,
|
|
catalogServiceIds,
|
|
missingSelectedServices,
|
|
extraCatalogServices
|
|
};
|
|
}
|
|
|
|
export function createHydratedArtifactCatalogAuthority({ catalog, catalogPath, gitopsBranch, gitopsCommitId, selectedServices }) {
|
|
return {
|
|
schemaVersion: "v1",
|
|
status: "hydrated",
|
|
authority: "gitops-branch",
|
|
catalogPath: normalizeCatalogPath(catalogPath),
|
|
gitopsBranch: text(gitopsBranch),
|
|
gitopsCommitId: text(gitopsCommitId),
|
|
catalogSourceCommitId: artifactCatalogSourceCommitId(catalog),
|
|
serviceCount: Array.isArray(catalog?.services) ? catalog.services.length : 0,
|
|
catalogSha256: artifactCatalogSha256(catalog),
|
|
coverage: artifactCatalogServiceCoverage(catalog, selectedServices)
|
|
};
|
|
}
|
|
|
|
export function createBootstrapArtifactCatalogAuthority({ catalog, catalogPath, sourceCommitId, reason, selectedServices }) {
|
|
return {
|
|
schemaVersion: "v1",
|
|
status: "bootstrap",
|
|
authority: "source-bootstrap",
|
|
catalogPath: normalizeCatalogPath(catalogPath),
|
|
sourceCommitId: text(sourceCommitId),
|
|
bootstrapReason: text(reason),
|
|
catalogSourceCommitId: artifactCatalogSourceCommitId(catalog),
|
|
serviceCount: Array.isArray(catalog?.services) ? catalog.services.length : 0,
|
|
catalogSha256: artifactCatalogSha256(catalog),
|
|
coverage: artifactCatalogServiceCoverage(catalog, selectedServices)
|
|
};
|
|
}
|
|
|
|
export async function readArtifactCatalogSummary({ repoRoot, catalogPath, authorityPath = artifactCatalogAuthorityPath(catalogPath), catalog = undefined }) {
|
|
const loadedCatalog = catalog === undefined ? await readJsonIfPresent(repoRoot, catalogPath) : catalog;
|
|
const authority = await readJsonIfPresent(repoRoot, authorityPath);
|
|
const normalizedCatalogPath = normalizeCatalogPath(catalogPath);
|
|
if (!loadedCatalog) {
|
|
if (authority) throw new Error(`artifact catalog authority exists without catalog ${normalizedCatalogPath}`);
|
|
return {
|
|
status: "missing",
|
|
authority: "none",
|
|
catalogPath: normalizedCatalogPath,
|
|
authorityPath: null,
|
|
gitopsBranch: null,
|
|
gitopsCommitId: null,
|
|
catalogSourceCommitId: null,
|
|
serviceCount: 0,
|
|
catalogSha256: null,
|
|
coverage: null,
|
|
bootstrapReason: null
|
|
};
|
|
}
|
|
|
|
const serviceCount = Array.isArray(loadedCatalog.services) ? loadedCatalog.services.length : 0;
|
|
const catalogSha256 = artifactCatalogSha256(loadedCatalog);
|
|
const catalogSourceCommitId = artifactCatalogSourceCommitId(loadedCatalog);
|
|
if (!authority) {
|
|
return {
|
|
status: "loaded",
|
|
authority: "workspace-file",
|
|
catalogPath: normalizedCatalogPath,
|
|
authorityPath: null,
|
|
gitopsBranch: null,
|
|
gitopsCommitId: null,
|
|
catalogSourceCommitId,
|
|
serviceCount,
|
|
catalogSha256,
|
|
coverage: null,
|
|
bootstrapReason: null
|
|
};
|
|
}
|
|
|
|
const coverage = validateCatalogAuthority({ authority, catalog: loadedCatalog, catalogPath: normalizedCatalogPath, serviceCount, catalogSha256 });
|
|
if (authority.authority === "gitops-branch") {
|
|
return {
|
|
status: "hydrated",
|
|
authority: "gitops-branch",
|
|
catalogPath: normalizedCatalogPath,
|
|
authorityPath: normalizeCatalogPath(authorityPath),
|
|
gitopsBranch: text(authority.gitopsBranch),
|
|
gitopsCommitId: text(authority.gitopsCommitId),
|
|
catalogSourceCommitId: text(authority.catalogSourceCommitId) || catalogSourceCommitId,
|
|
serviceCount,
|
|
catalogSha256,
|
|
coverage,
|
|
bootstrapReason: null
|
|
};
|
|
}
|
|
return {
|
|
status: "bootstrap",
|
|
authority: "source-bootstrap",
|
|
catalogPath: normalizedCatalogPath,
|
|
authorityPath: normalizeCatalogPath(authorityPath),
|
|
gitopsBranch: null,
|
|
gitopsCommitId: null,
|
|
sourceCommitId: text(authority.sourceCommitId),
|
|
catalogSourceCommitId: text(authority.catalogSourceCommitId) || catalogSourceCommitId,
|
|
serviceCount,
|
|
catalogSha256,
|
|
coverage,
|
|
bootstrapReason: text(authority.bootstrapReason)
|
|
};
|
|
}
|
|
|
|
function validateCatalogAuthority({ authority, catalog, catalogPath, serviceCount, catalogSha256 }) {
|
|
const gitopsAuthority = authority.schemaVersion === "v1" && authority.status === "hydrated" && authority.authority === "gitops-branch";
|
|
const bootstrapAuthority = authority.schemaVersion === "v1" && authority.status === "bootstrap" && authority.authority === "source-bootstrap";
|
|
if (!gitopsAuthority && !bootstrapAuthority) {
|
|
throw new Error(`artifact catalog authority for ${catalogPath} is not a supported authority record`);
|
|
}
|
|
if (normalizeCatalogPath(authority.catalogPath) !== catalogPath) {
|
|
throw new Error(`artifact catalog authority path mismatch: expected ${catalogPath}, got ${authority.catalogPath ?? "missing"}`);
|
|
}
|
|
if (authority.serviceCount !== serviceCount) {
|
|
throw new Error(`artifact catalog authority service count mismatch for ${catalogPath}: expected ${serviceCount}, got ${authority.serviceCount ?? "missing"}`);
|
|
}
|
|
if (authority.catalogSha256 !== catalogSha256) {
|
|
throw new Error(`artifact catalog authority fingerprint mismatch for ${catalogPath}`);
|
|
}
|
|
const coverage = artifactCatalogServiceCoverage(catalog, authority.coverage?.selectedServiceIds);
|
|
validateCoverageRecord(authority.coverage, coverage, catalogPath);
|
|
if (gitopsAuthority && (!text(authority.gitopsBranch) || !/^[a-f0-9]{40}$/u.test(text(authority.gitopsCommitId) ?? ""))) {
|
|
throw new Error(`artifact catalog authority gitops provenance is incomplete for ${catalogPath}`);
|
|
}
|
|
if (bootstrapAuthority) {
|
|
const sourceCommitId = text(authority.sourceCommitId);
|
|
if (!/^[a-f0-9]{40}$/u.test(sourceCommitId ?? "") || !text(authority.bootstrapReason)) {
|
|
throw new Error(`artifact catalog bootstrap authority is incomplete for ${catalogPath}`);
|
|
}
|
|
if (sourceCommitId !== artifactCatalogSourceCommitId(catalog)) {
|
|
throw new Error(`artifact catalog bootstrap source commit mismatch for ${catalogPath}`);
|
|
}
|
|
}
|
|
return coverage;
|
|
}
|
|
|
|
async function readJsonIfPresent(repoRoot, filePath) {
|
|
const resolved = path.isAbsolute(filePath) ? filePath : path.join(repoRoot, filePath);
|
|
try {
|
|
return JSON.parse(await readFile(resolved, "utf8"));
|
|
} catch (error) {
|
|
if (error?.code === "ENOENT") return null;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function normalizeCatalogPath(value) {
|
|
return String(value ?? "").replaceAll("\\", "/").replace(/^\.\//u, "").trim();
|
|
}
|
|
|
|
function text(value) {
|
|
const result = typeof value === "string" ? value.trim() : "";
|
|
return result || null;
|
|
}
|
|
|
|
function unique(values) {
|
|
return [...new Set(values)];
|
|
}
|
|
|
|
function normalizedServiceIds(values, label) {
|
|
if (!Array.isArray(values) || values.length === 0) throw new Error(`${label} must be a non-empty array`);
|
|
const normalized = values.map(text);
|
|
if (normalized.some((value) => value === null)) throw new Error(`${label} contains an empty service id`);
|
|
const deduplicated = unique(normalized);
|
|
if (deduplicated.length !== normalized.length) throw new Error(`${label} contains duplicate service ids`);
|
|
return deduplicated.sort();
|
|
}
|
|
|
|
function validateCoverageRecord(actual, expected, catalogPath) {
|
|
const scalarFields = ["status", "selectedServiceCount", "selectedServicePresentCount", "catalogServiceCount"];
|
|
const arrayFields = ["selectedServiceIds", "catalogServiceIds", "missingSelectedServices", "extraCatalogServices"];
|
|
const expectedFields = [...scalarFields, ...arrayFields].sort();
|
|
const actualFields = actual && typeof actual === "object" && !Array.isArray(actual)
|
|
? Object.keys(actual).sort()
|
|
: [];
|
|
if (!sameArray(actualFields, expectedFields)) {
|
|
throw new Error(`artifact catalog authority coverage fields mismatch for ${catalogPath}`);
|
|
}
|
|
for (const field of scalarFields) {
|
|
if (actual[field] !== expected[field]) {
|
|
throw new Error(`artifact catalog authority coverage ${field} mismatch for ${catalogPath}`);
|
|
}
|
|
}
|
|
for (const field of arrayFields) {
|
|
if (!sameArray(actual[field], expected[field])) {
|
|
throw new Error(`artifact catalog authority coverage ${field} mismatch for ${catalogPath}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function sameArray(left, right) {
|
|
return Array.isArray(left) && Array.isArray(right) && left.length === right.length && left.every((value, index) => value === right[index]);
|
|
}
|