422 lines
18 KiB
TypeScript
422 lines
18 KiB
TypeScript
import { readFileSync } from "node:fs";
|
|
import { rootPath } from "./config";
|
|
|
|
export type DeployJsonEnvironment = "dev" | "prod";
|
|
|
|
export interface DeployJsonArtifactContract {
|
|
kind: "source-build";
|
|
repository: string;
|
|
tag: "commitId";
|
|
}
|
|
|
|
export interface DeployJsonConsumerTargetContract {
|
|
namespace: string;
|
|
deployment: string;
|
|
service: string;
|
|
containerName: string;
|
|
stableImage: string;
|
|
manifestRepoPath: string;
|
|
}
|
|
|
|
export interface DeployJsonConsumerContract {
|
|
kind: "d601-k3s-managed";
|
|
dev?: { enabled: boolean };
|
|
prod?: { enabled: boolean };
|
|
supportLevel: "reviewed";
|
|
targetRef: string;
|
|
noRuntimeSourceBuild: boolean;
|
|
target: DeployJsonConsumerTargetContract;
|
|
}
|
|
|
|
export interface DeployJsonRuntimeContract {
|
|
containerPort: number;
|
|
healthPath: string;
|
|
memory: {
|
|
request: string;
|
|
limit: string;
|
|
};
|
|
health?: {
|
|
deployMetadataRequired: boolean;
|
|
};
|
|
}
|
|
|
|
export interface DeployJsonServiceContract {
|
|
id: string;
|
|
repo: string;
|
|
commitId: string;
|
|
artifact?: DeployJsonArtifactContract;
|
|
consumer?: DeployJsonConsumerContract;
|
|
runtime?: DeployJsonRuntimeContract;
|
|
}
|
|
|
|
export interface DeployJsonDriftItem {
|
|
surface: string;
|
|
path?: string;
|
|
field: string;
|
|
expected: unknown;
|
|
actual: unknown;
|
|
}
|
|
|
|
export interface DeployJsonExecutorMirror {
|
|
surface: string;
|
|
path?: string;
|
|
artifact?: {
|
|
kind?: string;
|
|
repository?: string;
|
|
tag?: string;
|
|
};
|
|
consumer?: {
|
|
kind?: string;
|
|
noRuntimeSourceBuild?: boolean;
|
|
target?: Partial<DeployJsonConsumerTargetContract>;
|
|
};
|
|
runtime?: {
|
|
containerPort?: number;
|
|
servicePort?: number;
|
|
healthPath?: string;
|
|
memory?: {
|
|
request?: string;
|
|
limit?: string;
|
|
};
|
|
health?: {
|
|
deployMetadataRequired?: boolean;
|
|
};
|
|
};
|
|
}
|
|
|
|
function asRecord(value: unknown, path: string): Record<string, unknown> {
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`);
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function optionalRecord(value: unknown, path: string): Record<string, unknown> | undefined {
|
|
if (value === undefined) return undefined;
|
|
return asRecord(value, path);
|
|
}
|
|
|
|
function stringField(record: Record<string, unknown>, key: string, path: string): string {
|
|
const value = record[key];
|
|
if (typeof value !== "string" || value.length === 0) throw new Error(`${path}.${key} must be a non-empty string`);
|
|
return value;
|
|
}
|
|
|
|
function numberField(record: Record<string, unknown>, key: string, path: string): number {
|
|
const value = record[key];
|
|
if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) throw new Error(`${path}.${key} must be a positive integer`);
|
|
return value;
|
|
}
|
|
|
|
function booleanField(record: Record<string, unknown>, key: string, path: string): boolean {
|
|
const value = record[key];
|
|
if (typeof value !== "boolean") throw new Error(`${path}.${key} must be a boolean`);
|
|
return value;
|
|
}
|
|
|
|
function assertKnownKeys(record: Record<string, unknown>, allowed: string[], path: string): void {
|
|
const unknown = Object.keys(record).filter((key) => !allowed.includes(key));
|
|
if (unknown.length > 0) throw new Error(`${path} contains unsupported deploy.json contract key(s): ${unknown.join(", ")}`);
|
|
}
|
|
|
|
function parseArtifactContract(value: unknown, path: string): DeployJsonArtifactContract | undefined {
|
|
const artifact = optionalRecord(value, path);
|
|
if (artifact === undefined) return undefined;
|
|
assertKnownKeys(artifact, ["kind", "repository", "tag"], path);
|
|
const kind = stringField(artifact, "kind", path);
|
|
if (kind !== "source-build") throw new Error(`${path}.kind must be source-build`);
|
|
const tag = stringField(artifact, "tag", path);
|
|
if (tag !== "commitId") throw new Error(`${path}.tag must be commitId`);
|
|
return {
|
|
kind,
|
|
repository: stringField(artifact, "repository", path),
|
|
tag,
|
|
};
|
|
}
|
|
|
|
function parseEnabled(value: unknown, path: string): { enabled: boolean } | undefined {
|
|
const record = optionalRecord(value, path);
|
|
if (record === undefined) return undefined;
|
|
assertKnownKeys(record, ["enabled"], path);
|
|
return { enabled: booleanField(record, "enabled", path) };
|
|
}
|
|
|
|
function parseConsumerTarget(value: unknown, path: string): DeployJsonConsumerTargetContract {
|
|
const target = asRecord(value, path);
|
|
assertKnownKeys(target, ["namespace", "deployment", "service", "containerName", "stableImage", "manifestRepoPath"], path);
|
|
return {
|
|
namespace: stringField(target, "namespace", path),
|
|
deployment: stringField(target, "deployment", path),
|
|
service: stringField(target, "service", path),
|
|
containerName: stringField(target, "containerName", path),
|
|
stableImage: stringField(target, "stableImage", path),
|
|
manifestRepoPath: stringField(target, "manifestRepoPath", path),
|
|
};
|
|
}
|
|
|
|
function parseConsumerContract(value: unknown, path: string): DeployJsonConsumerContract | undefined {
|
|
const consumer = optionalRecord(value, path);
|
|
if (consumer === undefined) return undefined;
|
|
assertKnownKeys(consumer, ["kind", "dev", "prod", "supportLevel", "targetRef", "noRuntimeSourceBuild", "target"], path);
|
|
const kind = stringField(consumer, "kind", path);
|
|
if (kind !== "d601-k3s-managed") throw new Error(`${path}.kind must be d601-k3s-managed`);
|
|
const supportLevel = stringField(consumer, "supportLevel", path);
|
|
if (supportLevel !== "reviewed") throw new Error(`${path}.supportLevel must be reviewed`);
|
|
return {
|
|
kind,
|
|
dev: parseEnabled(consumer.dev, `${path}.dev`),
|
|
prod: parseEnabled(consumer.prod, `${path}.prod`),
|
|
supportLevel,
|
|
targetRef: stringField(consumer, "targetRef", path),
|
|
noRuntimeSourceBuild: booleanField(consumer, "noRuntimeSourceBuild", path),
|
|
target: parseConsumerTarget(consumer.target, `${path}.target`),
|
|
};
|
|
}
|
|
|
|
function parseRuntimeContract(value: unknown, path: string): DeployJsonRuntimeContract | undefined {
|
|
const runtime = optionalRecord(value, path);
|
|
if (runtime === undefined) return undefined;
|
|
assertKnownKeys(runtime, ["containerPort", "healthPath", "memory", "health"], path);
|
|
const memory = asRecord(runtime.memory, `${path}.memory`);
|
|
assertKnownKeys(memory, ["request", "limit"], `${path}.memory`);
|
|
const health = optionalRecord(runtime.health, `${path}.health`);
|
|
if (health !== undefined) assertKnownKeys(health, ["deployMetadataRequired"], `${path}.health`);
|
|
return {
|
|
containerPort: numberField(runtime, "containerPort", path),
|
|
healthPath: stringField(runtime, "healthPath", path),
|
|
memory: {
|
|
request: stringField(memory, "request", `${path}.memory`),
|
|
limit: stringField(memory, "limit", `${path}.memory`),
|
|
},
|
|
health: health === undefined ? undefined : {
|
|
deployMetadataRequired: booleanField(health, "deployMetadataRequired", `${path}.health`),
|
|
},
|
|
};
|
|
}
|
|
|
|
export function parseDeployJsonServiceContract(value: unknown, path: string): DeployJsonServiceContract {
|
|
const service = asRecord(value, path);
|
|
assertKnownKeys(service, ["id", "repo", "commitId", "artifact", "consumer", "runtime"], path);
|
|
const id = stringField(service, "id", path);
|
|
const repo = stringField(service, "repo", path);
|
|
const commitId = stringField(service, "commitId", path).toLowerCase();
|
|
if (!/^[0-9a-f]{7,40}$/iu.test(commitId)) throw new Error(`${path}.commitId must be a 7-40 char git SHA`);
|
|
return {
|
|
id,
|
|
repo,
|
|
commitId,
|
|
artifact: parseArtifactContract(service.artifact, `${path}.artifact`),
|
|
consumer: parseConsumerContract(service.consumer, `${path}.consumer`),
|
|
runtime: parseRuntimeContract(service.runtime, `${path}.runtime`),
|
|
};
|
|
}
|
|
|
|
export function parseDeployJsonServiceContractBase64(raw: string): DeployJsonServiceContract {
|
|
const text = Buffer.from(raw, "base64").toString("utf8");
|
|
return parseDeployJsonServiceContract(JSON.parse(text) as unknown, "deploy service contract");
|
|
}
|
|
|
|
export function encodeDeployJsonServiceContract(service: DeployJsonServiceContract): string {
|
|
return Buffer.from(JSON.stringify(service), "utf8").toString("base64");
|
|
}
|
|
|
|
export function hasDeployJsonExecutorContract(service: DeployJsonServiceContract): boolean {
|
|
return service.artifact !== undefined || service.consumer !== undefined || service.runtime !== undefined;
|
|
}
|
|
|
|
export function deployJsonCommitImage(stableImage: string, commit: string): string {
|
|
const lastSlash = stableImage.lastIndexOf("/");
|
|
const tagIndex = stableImage.indexOf(":", lastSlash + 1);
|
|
if (tagIndex === -1) return `${stableImage}:${commit}`;
|
|
return `${stableImage.slice(0, tagIndex)}:${commit}`;
|
|
}
|
|
|
|
export function deployJsonSourceOfTruth(service: DeployJsonServiceContract, environment: DeployJsonEnvironment): Record<string, unknown> {
|
|
return {
|
|
source: "deploy.json",
|
|
environment,
|
|
serviceId: service.id,
|
|
servicePath: `environments.${environment}.services.${service.id}`,
|
|
ownedFields: [
|
|
...(service.artifact === undefined ? [] : ["artifact.kind", "artifact.repository", "artifact.tag"]),
|
|
...(service.consumer === undefined ? [] : [
|
|
"consumer.kind",
|
|
`consumer.${environment}.enabled`,
|
|
"consumer.supportLevel",
|
|
"consumer.targetRef",
|
|
"consumer.noRuntimeSourceBuild",
|
|
"consumer.target.namespace",
|
|
"consumer.target.deployment",
|
|
"consumer.target.service",
|
|
"consumer.target.containerName",
|
|
"consumer.target.stableImage",
|
|
"consumer.target.manifestRepoPath",
|
|
]),
|
|
...(service.runtime === undefined ? [] : [
|
|
"runtime.containerPort",
|
|
"runtime.healthPath",
|
|
"runtime.memory.request",
|
|
"runtime.memory.limit",
|
|
"runtime.health.deployMetadataRequired",
|
|
]),
|
|
],
|
|
};
|
|
}
|
|
|
|
function pushDrift(items: DeployJsonDriftItem[], mirror: DeployJsonExecutorMirror, field: string, expected: unknown, actual: unknown): void {
|
|
if (actual === undefined || expected === actual) return;
|
|
items.push({ surface: mirror.surface, path: mirror.path, field, expected, actual });
|
|
}
|
|
|
|
export function compareDeployJsonExecutorMirrors(
|
|
service: DeployJsonServiceContract,
|
|
_environment: DeployJsonEnvironment,
|
|
mirrors: DeployJsonExecutorMirror[],
|
|
): DeployJsonDriftItem[] {
|
|
const items: DeployJsonDriftItem[] = [];
|
|
for (const mirror of mirrors) {
|
|
if (service.artifact !== undefined && mirror.artifact !== undefined) {
|
|
pushDrift(items, mirror, "artifact.kind", service.artifact.kind, mirror.artifact.kind);
|
|
pushDrift(items, mirror, "artifact.repository", service.artifact.repository, mirror.artifact.repository);
|
|
pushDrift(items, mirror, "artifact.tag", service.artifact.tag, mirror.artifact.tag);
|
|
}
|
|
if (service.consumer !== undefined && mirror.consumer !== undefined) {
|
|
pushDrift(items, mirror, "consumer.kind", service.consumer.kind, mirror.consumer.kind);
|
|
pushDrift(items, mirror, "consumer.noRuntimeSourceBuild", service.consumer.noRuntimeSourceBuild, mirror.consumer.noRuntimeSourceBuild);
|
|
const expectedTarget = service.consumer.target;
|
|
const actualTarget = mirror.consumer.target;
|
|
if (actualTarget !== undefined) {
|
|
pushDrift(items, mirror, "consumer.target.namespace", expectedTarget.namespace, actualTarget.namespace);
|
|
pushDrift(items, mirror, "consumer.target.deployment", expectedTarget.deployment, actualTarget.deployment);
|
|
pushDrift(items, mirror, "consumer.target.service", expectedTarget.service, actualTarget.service);
|
|
pushDrift(items, mirror, "consumer.target.containerName", expectedTarget.containerName, actualTarget.containerName);
|
|
pushDrift(items, mirror, "consumer.target.stableImage", expectedTarget.stableImage, actualTarget.stableImage);
|
|
pushDrift(items, mirror, "consumer.target.manifestRepoPath", expectedTarget.manifestRepoPath, actualTarget.manifestRepoPath);
|
|
}
|
|
}
|
|
if (service.runtime !== undefined && mirror.runtime !== undefined) {
|
|
pushDrift(items, mirror, "runtime.containerPort", service.runtime.containerPort, mirror.runtime.containerPort);
|
|
pushDrift(items, mirror, "runtime.servicePort", service.runtime.containerPort, mirror.runtime.servicePort);
|
|
pushDrift(items, mirror, "runtime.healthPath", service.runtime.healthPath, mirror.runtime.healthPath);
|
|
pushDrift(items, mirror, "runtime.memory.request", service.runtime.memory.request, mirror.runtime.memory?.request);
|
|
pushDrift(items, mirror, "runtime.memory.limit", service.runtime.memory.limit, mirror.runtime.memory?.limit);
|
|
if (service.runtime.health !== undefined) {
|
|
pushDrift(items, mirror, "runtime.health.deployMetadataRequired", service.runtime.health.deployMetadataRequired, mirror.runtime.health?.deployMetadataRequired);
|
|
}
|
|
}
|
|
}
|
|
return items;
|
|
}
|
|
|
|
function yamlDocuments(text: string): string[] {
|
|
return text.split(/^---\s*$/mu).map((part) => part.trim()).filter((part) => part.length > 0);
|
|
}
|
|
|
|
function yamlKind(documentText: string): string {
|
|
return documentText.match(/^kind:\s*("?)([^"\s#]+)\1\s*$/mu)?.[2] ?? "";
|
|
}
|
|
|
|
function yamlMetadataField(documentText: string, field: "name" | "namespace"): string {
|
|
const metadataIndex = documentText.search(/^metadata:\s*$/mu);
|
|
if (metadataIndex === -1) return "";
|
|
const metadata = documentText.slice(metadataIndex);
|
|
return metadata.match(new RegExp(`^ ${field}:\\s*("?)([^"\\n#]+)\\1\\s*$`, "mu"))?.[2]?.trim() ?? "";
|
|
}
|
|
|
|
function yamlDocumentByKindName(documents: string[], kind: string, name: string): string {
|
|
return documents.find((documentText) => yamlKind(documentText) === kind && yamlMetadataField(documentText, "name") === name) ?? "";
|
|
}
|
|
|
|
function firstRegex(text: string, pattern: RegExp): string | undefined {
|
|
return text.match(pattern)?.[1];
|
|
}
|
|
|
|
function containerSegment(deploymentText: string, containerName: string): string {
|
|
const escaped = containerName.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
const match = new RegExp(`^ - name:\\s*${escaped}\\s*$`, "mu").exec(deploymentText);
|
|
if (match === null) return "";
|
|
const start = match.index;
|
|
const rest = deploymentText.slice(start + match[0].length);
|
|
const next = /^ - name:\s*\S+\s*$/mu.exec(rest);
|
|
return deploymentText.slice(start, next === null ? undefined : start + match[0].length + next.index);
|
|
}
|
|
|
|
export function k3sManifestExecutorMirror(service: DeployJsonServiceContract): DeployJsonExecutorMirror | null {
|
|
const target = service.consumer?.target;
|
|
if (service.consumer?.kind !== "d601-k3s-managed" || target === undefined) return null;
|
|
const path = target.manifestRepoPath;
|
|
const manifest = readFileSync(rootPath(path), "utf8");
|
|
const documents = yamlDocuments(manifest);
|
|
const deployment = yamlDocumentByKindName(documents, "Deployment", target.deployment);
|
|
const serviceDoc = yamlDocumentByKindName(documents, "Service", target.service);
|
|
const container = containerSegment(deployment, target.containerName);
|
|
const containerPortRaw = firstRegex(container, /^\s+containerPort:\s*(\d+)\s*$/mu);
|
|
const servicePortRaw = firstRegex(serviceDoc, /^\s+port:\s*(\d+)\s*$/mu);
|
|
return {
|
|
surface: "k8s-manifest",
|
|
path,
|
|
consumer: {
|
|
kind: "d601-k3s-managed",
|
|
noRuntimeSourceBuild: true,
|
|
target: {
|
|
namespace: yamlMetadataField(deployment, "namespace") || yamlMetadataField(serviceDoc, "namespace"),
|
|
deployment: yamlMetadataField(deployment, "name"),
|
|
service: yamlMetadataField(serviceDoc, "name"),
|
|
containerName: firstRegex(container, /^ - name:\s*([^\s#]+)\s*$/mu),
|
|
manifestRepoPath: path,
|
|
},
|
|
},
|
|
runtime: {
|
|
containerPort: containerPortRaw === undefined ? undefined : Number(containerPortRaw),
|
|
servicePort: servicePortRaw === undefined ? undefined : Number(servicePortRaw),
|
|
healthPath: firstRegex(container, /^\s+path:\s*([^\s#"]+)\s*$/mu),
|
|
memory: {
|
|
request: firstRegex(container, /^\s+requests:\s*$[\s\S]*?^\s+memory:\s*([^\s#"]+)\s*$/mu),
|
|
limit: firstRegex(container, /^\s+limits:\s*$[\s\S]*?^\s+memory:\s*([^\s#"]+)\s*$/mu),
|
|
},
|
|
health: {
|
|
deployMetadataRequired: [
|
|
"UNIDESK_DEPLOY_SERVICE_ID",
|
|
"UNIDESK_DEPLOY_REPO",
|
|
"UNIDESK_DEPLOY_COMMIT",
|
|
"UNIDESK_DEPLOY_REQUESTED_COMMIT",
|
|
].every((name) => container.includes(`name: ${name}`)),
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
export function deployJsonDriftResult(
|
|
service: DeployJsonServiceContract,
|
|
environment: DeployJsonEnvironment,
|
|
drifts: DeployJsonDriftItem[],
|
|
): Record<string, unknown> {
|
|
return {
|
|
ok: false,
|
|
supported: false,
|
|
error: "deploy-json-drift",
|
|
serviceId: service.id,
|
|
environment,
|
|
sourceOfTruth: deployJsonSourceOfTruth(service, environment),
|
|
drift: {
|
|
ok: false,
|
|
count: drifts.length,
|
|
items: drifts,
|
|
},
|
|
policy: "deploy.json is the only source for this executor contract; mirrored manifest, artifact-registry, CI catalog or config values must be regenerated or corrected before dry-run can proceed.",
|
|
};
|
|
}
|
|
|
|
export function readDeployJsonServiceContractFromFile(
|
|
environment: DeployJsonEnvironment,
|
|
serviceId: string,
|
|
filePath = rootPath("deploy.json"),
|
|
): DeployJsonServiceContract | null {
|
|
const root = asRecord(JSON.parse(readFileSync(filePath, "utf8")) as unknown, "deploy.json");
|
|
const environments = asRecord(root.environments, "deploy.json.environments");
|
|
const environmentRecord = asRecord(environments[environment], `deploy.json.environments.${environment}`);
|
|
const services = environmentRecord.services;
|
|
if (!Array.isArray(services)) throw new Error(`deploy.json.environments.${environment}.services must be an array`);
|
|
const index = services.findIndex((item) => asRecord(item, `deploy.json.environments.${environment}.services[]`).id === serviceId);
|
|
if (index === -1) return null;
|
|
return parseDeployJsonServiceContract(services[index], `deploy.json.environments.${environment}.services[${index}]`);
|
|
}
|