459 lines
15 KiB
TypeScript
459 lines
15 KiB
TypeScript
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import type { JsonRecord } from "../../src/common/types.js";
|
|
import { AgentRunError, errorToJson } from "../../src/common/errors.js";
|
|
|
|
interface RenderOptions {
|
|
outDir: string;
|
|
deployFile: string;
|
|
catalogFile?: string;
|
|
sourceCommit: string;
|
|
registryPrefix: string;
|
|
requireCatalog: boolean;
|
|
check: boolean;
|
|
}
|
|
|
|
interface ArtifactCatalog {
|
|
lane: string;
|
|
sourceBranch: string;
|
|
gitopsBranch: string;
|
|
sourceCommitId: string;
|
|
summary?: string;
|
|
services: CatalogService[];
|
|
}
|
|
|
|
interface CatalogService {
|
|
serviceId: string;
|
|
image: string;
|
|
digest: string;
|
|
repositoryDigest: string;
|
|
imageTag: string;
|
|
artifactKind?: string;
|
|
status?: string;
|
|
envIdentity?: string;
|
|
envImage?: string;
|
|
envDigest?: string;
|
|
envRepositoryDigest?: string;
|
|
bootCommit?: string;
|
|
bootScript?: string;
|
|
provenance?: JsonRecord;
|
|
}
|
|
|
|
const defaultBootRepoUrl = "http://git-mirror-http.devops-infra.svc.cluster.local/pikasTech/agentrun.git";
|
|
|
|
export async function runGitopsRenderCli(argv: string[]): Promise<void> {
|
|
try {
|
|
const options = parseArgs(argv);
|
|
const summary = await renderGitops(options);
|
|
console.log(JSON.stringify({ ok: true, data: summary }));
|
|
} catch (error) {
|
|
console.log(JSON.stringify({ ok: false, failureKind: error instanceof AgentRunError ? error.failureKind : "infra-failed", message: error instanceof Error ? error.message : String(error), error: errorToJson(error) }));
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
|
|
export async function renderGitops(options: RenderOptions): Promise<JsonRecord> {
|
|
const deploy = JSON.parse(await readFile(options.deployFile, "utf8")) as JsonRecord;
|
|
const runtimeNamespace = stringField(deploy, "runtimeNamespace", "agentrun-v01");
|
|
const gitopsBranch = stringField(deploy, "gitopsBranch", "v0.1-gitops");
|
|
const runtimePath = stringField(deploy, "runtimePath", "deploy/gitops/g14/runtime-v01");
|
|
const catalog = await loadCatalog(options, gitopsBranch);
|
|
const image = imageForService(catalog, "agentrun-mgr", options);
|
|
|
|
if (options.check) await rm(options.outDir, { recursive: true, force: true });
|
|
await mkdir(path.join(options.outDir, "argocd"), { recursive: true });
|
|
await mkdir(path.join(options.outDir, "runtime-v01"), { recursive: true });
|
|
await writeFile(path.join(options.outDir, "source.json"), `${JSON.stringify({ lane: "v0.1", sourceCommit: options.sourceCommit, generatedBy: "scripts/agentrun-gitops-render.ts" }, null, 2)}\n`);
|
|
await writeFile(path.join(options.outDir, "artifact-catalog.v01.json"), `${JSON.stringify(catalog, null, 2)}\n`);
|
|
await writeFile(path.join(options.outDir, "argocd", "project.yaml"), projectYaml(runtimeNamespace));
|
|
await writeFile(path.join(options.outDir, "argocd", "application-v01.yaml"), applicationYaml(gitopsBranch, runtimePath, runtimeNamespace));
|
|
await writeFile(path.join(options.outDir, "runtime-v01", "kustomization.yaml"), kustomizationYaml());
|
|
await writeFile(path.join(options.outDir, "runtime-v01", "namespace.yaml"), namespaceYaml(runtimeNamespace));
|
|
await writeFile(path.join(options.outDir, "runtime-v01", "postgres.yaml"), postgresYaml(runtimeNamespace));
|
|
await writeFile(path.join(options.outDir, "runtime-v01", "mgr.yaml"), managerYaml(runtimeNamespace, image, options.sourceCommit));
|
|
await writeFile(path.join(options.outDir, "runtime-v01", "runner-rbac.yaml"), runnerRbacYaml(runtimeNamespace));
|
|
return { outDir: options.outDir, runtimeNamespace, gitopsBranch, runtimePath, image: repositoryDigestForService(image), sourceCommit: options.sourceCommit, envIdentity: image.envIdentity ?? null, artifactStatus: image.status ?? null };
|
|
}
|
|
|
|
async function loadCatalog(options: RenderOptions, gitopsBranch: string): Promise<ArtifactCatalog> {
|
|
if (options.catalogFile) return JSON.parse(await readFile(options.catalogFile, "utf8")) as ArtifactCatalog;
|
|
if (options.requireCatalog) throw new AgentRunError("schema-invalid", "artifact catalog is required for promotion render", { httpStatus: 2 });
|
|
const digest = `sha256:${"0".repeat(64)}`;
|
|
const image = `${options.registryPrefix}/agentrun-mgr-env:${options.sourceCommit}`;
|
|
return {
|
|
lane: "v0.1",
|
|
sourceBranch: "v0.1",
|
|
gitopsBranch,
|
|
sourceCommitId: options.sourceCommit,
|
|
summary: "build=1 reuse=0 unsafeReuse=0",
|
|
services: [{ serviceId: "agentrun-mgr", artifactKind: "env-reuse", status: "placeholder", image, digest, repositoryDigest: `${options.registryPrefix}/agentrun-mgr-env@${digest}`, imageTag: options.sourceCommit, envIdentity: options.sourceCommit, envImage: image, envDigest: digest, envRepositoryDigest: `${options.registryPrefix}/agentrun-mgr-env@${digest}`, bootCommit: options.sourceCommit, bootScript: "deploy/runtime/boot/agentrun-boot.sh" }],
|
|
};
|
|
}
|
|
|
|
function imageForService(catalog: ArtifactCatalog, serviceId: string, options: RenderOptions): CatalogService {
|
|
const service = catalog.services.find((item) => item.serviceId === serviceId);
|
|
if (!service) throw new AgentRunError("schema-invalid", `catalog missing service ${serviceId}`, { httpStatus: 2 });
|
|
const digest = service.envDigest ?? service.digest;
|
|
if (!/^sha256:[a-f0-9]{64}$/u.test(digest)) throw new AgentRunError("schema-invalid", `catalog service ${serviceId} has invalid digest`, { httpStatus: 2 });
|
|
if (options.requireCatalog && digest === `sha256:${"0".repeat(64)}`) throw new AgentRunError("schema-invalid", "placeholder digest is not allowed in promotion render", { httpStatus: 2 });
|
|
return service;
|
|
}
|
|
|
|
function repositoryDigestForService(service: CatalogService): string {
|
|
if (service.envRepositoryDigest) return service.envRepositoryDigest;
|
|
if (service.repositoryDigest) return service.repositoryDigest;
|
|
const image = service.envImage ?? service.image;
|
|
const digest = service.envDigest ?? service.digest;
|
|
return `${image.slice(0, image.lastIndexOf(":"))}@${digest}`;
|
|
}
|
|
|
|
function projectYaml(namespace: string): string {
|
|
return `apiVersion: argoproj.io/v1alpha1
|
|
kind: AppProject
|
|
metadata:
|
|
name: agentrun-v01
|
|
namespace: argocd
|
|
spec:
|
|
description: AgentRun v0.1 GitOps lane
|
|
sourceRepos:
|
|
- git@github.com:pikasTech/agentrun.git
|
|
destinations:
|
|
- server: https://kubernetes.default.svc
|
|
namespace: ${namespace}
|
|
clusterResourceWhitelist:
|
|
- group: ""
|
|
kind: Namespace
|
|
namespaceResourceWhitelist:
|
|
- group: "*"
|
|
kind: "*"
|
|
`;
|
|
}
|
|
|
|
function applicationYaml(gitopsBranch: string, runtimePath: string, namespace: string): string {
|
|
return `apiVersion: argoproj.io/v1alpha1
|
|
kind: Application
|
|
metadata:
|
|
name: agentrun-g14-v01
|
|
namespace: argocd
|
|
spec:
|
|
project: agentrun-v01
|
|
source:
|
|
repoURL: git@github.com:pikasTech/agentrun.git
|
|
targetRevision: ${gitopsBranch}
|
|
path: ${runtimePath}
|
|
destination:
|
|
server: https://kubernetes.default.svc
|
|
namespace: ${namespace}
|
|
syncPolicy:
|
|
automated:
|
|
prune: false
|
|
selfHeal: true
|
|
syncOptions:
|
|
- CreateNamespace=true
|
|
- ApplyOutOfSyncOnly=true
|
|
`;
|
|
}
|
|
|
|
function kustomizationYaml(): string {
|
|
return `apiVersion: kustomize.config.k8s.io/v1beta1
|
|
kind: Kustomization
|
|
resources:
|
|
- namespace.yaml
|
|
- postgres.yaml
|
|
- mgr.yaml
|
|
- runner-rbac.yaml
|
|
`;
|
|
}
|
|
|
|
function namespaceYaml(namespace: string): string {
|
|
return `apiVersion: v1
|
|
kind: Namespace
|
|
metadata:
|
|
name: ${namespace}
|
|
labels:
|
|
app.kubernetes.io/part-of: agentrun
|
|
agentrun.pikastech.local/lane: v0.1
|
|
`;
|
|
}
|
|
|
|
function postgresYaml(namespace: string): string {
|
|
return `apiVersion: v1
|
|
kind: Service
|
|
metadata:
|
|
name: agentrun-v01-postgres
|
|
namespace: ${namespace}
|
|
spec:
|
|
selector:
|
|
app.kubernetes.io/name: agentrun-v01-postgres
|
|
ports:
|
|
- name: postgres
|
|
port: 5432
|
|
targetPort: postgres
|
|
---
|
|
apiVersion: apps/v1
|
|
kind: StatefulSet
|
|
metadata:
|
|
name: agentrun-v01-postgres
|
|
namespace: ${namespace}
|
|
spec:
|
|
serviceName: agentrun-v01-postgres
|
|
replicas: 1
|
|
selector:
|
|
matchLabels:
|
|
app.kubernetes.io/name: agentrun-v01-postgres
|
|
template:
|
|
metadata:
|
|
labels:
|
|
app.kubernetes.io/name: agentrun-v01-postgres
|
|
spec:
|
|
containers:
|
|
- name: postgres
|
|
image: postgres:16-alpine
|
|
ports:
|
|
- name: postgres
|
|
containerPort: 5432
|
|
env:
|
|
- name: POSTGRES_DB
|
|
value: agentrun_v01
|
|
- name: POSTGRES_USER
|
|
valueFrom:
|
|
secretKeyRef:
|
|
name: agentrun-v01-postgres
|
|
key: username
|
|
- name: POSTGRES_PASSWORD
|
|
valueFrom:
|
|
secretKeyRef:
|
|
name: agentrun-v01-postgres
|
|
key: password
|
|
volumeMounts:
|
|
- name: data
|
|
mountPath: /var/lib/postgresql/data
|
|
volumeClaimTemplates:
|
|
- metadata:
|
|
name: data
|
|
spec:
|
|
accessModes: ["ReadWriteOnce"]
|
|
resources:
|
|
requests:
|
|
storage: 5Gi
|
|
`;
|
|
}
|
|
|
|
function managerYaml(namespace: string, image: CatalogService, sourceCommit: string): string {
|
|
const imageRef = repositoryDigestForService(image);
|
|
const envIdentity = image.envIdentity ?? image.imageTag ?? "unknown";
|
|
return `apiVersion: v1
|
|
kind: ServiceAccount
|
|
metadata:
|
|
name: agentrun-v01-mgr
|
|
namespace: ${namespace}
|
|
---
|
|
apiVersion: v1
|
|
kind: Service
|
|
metadata:
|
|
name: agentrun-mgr
|
|
namespace: ${namespace}
|
|
spec:
|
|
selector:
|
|
app.kubernetes.io/name: agentrun-mgr
|
|
ports:
|
|
- name: http
|
|
port: 8080
|
|
targetPort: http
|
|
---
|
|
apiVersion: apps/v1
|
|
kind: Deployment
|
|
metadata:
|
|
name: agentrun-mgr
|
|
namespace: ${namespace}
|
|
spec:
|
|
replicas: 1
|
|
selector:
|
|
matchLabels:
|
|
app.kubernetes.io/name: agentrun-mgr
|
|
template:
|
|
metadata:
|
|
labels:
|
|
app.kubernetes.io/name: agentrun-mgr
|
|
annotations:
|
|
agentrun.pikastech.local/lane: v0.1
|
|
agentrun.pikastech.local/source-commit: ${JSON.stringify(sourceCommit)}
|
|
agentrun.pikastech.local/env-identity: ${JSON.stringify(envIdentity)}
|
|
spec:
|
|
serviceAccountName: agentrun-v01-mgr
|
|
containers:
|
|
- name: mgr
|
|
image: ${imageRef}
|
|
imagePullPolicy: IfNotPresent
|
|
ports:
|
|
- name: http
|
|
containerPort: 8080
|
|
env:
|
|
- name: AGENTRUN_LANE
|
|
value: v0.1
|
|
- name: DATABASE_URL
|
|
valueFrom:
|
|
secretKeyRef:
|
|
name: agentrun-v01-mgr-db
|
|
key: DATABASE_URL
|
|
- name: AGENTRUN_SOURCE_COMMIT
|
|
value: ${JSON.stringify(sourceCommit)}
|
|
- name: AGENTRUN_BOOT_COMMIT
|
|
value: ${JSON.stringify(sourceCommit)}
|
|
- name: AGENTRUN_BOOT_MODE
|
|
value: mgr
|
|
- name: AGENTRUN_BOOT_REPO_URL
|
|
value: ${JSON.stringify(defaultBootRepoUrl)}
|
|
- name: AGENTRUN_ENV_IDENTITY
|
|
value: ${JSON.stringify(envIdentity)}
|
|
- name: AGENTRUN_RUNTIME_NAMESPACE
|
|
value: ${JSON.stringify(namespace)}
|
|
- name: AGENTRUN_INTERNAL_MGR_URL
|
|
value: ${JSON.stringify(`http://agentrun-mgr.${namespace}.svc.cluster.local:8080`)}
|
|
- name: AGENTRUN_RUNNER_IMAGE
|
|
value: ${JSON.stringify(imageRef)}
|
|
- name: AGENTRUN_RUNNER_SERVICE_ACCOUNT
|
|
value: "agentrun-v01-runner"
|
|
readinessProbe:
|
|
httpGet:
|
|
path: /health/readiness
|
|
port: http
|
|
livenessProbe:
|
|
httpGet:
|
|
path: /health/live
|
|
port: http
|
|
resources:
|
|
requests:
|
|
cpu: 100m
|
|
memory: 256Mi
|
|
limits:
|
|
cpu: 800m
|
|
memory: 1Gi
|
|
---
|
|
apiVersion: rbac.authorization.k8s.io/v1
|
|
kind: Role
|
|
metadata:
|
|
name: agentrun-v01-mgr-runner-job-controller
|
|
namespace: ${namespace}
|
|
rules:
|
|
- apiGroups: ["batch"]
|
|
resources: ["jobs"]
|
|
verbs: ["create", "get", "list", "watch"]
|
|
- apiGroups: [""]
|
|
resources: ["pods"]
|
|
verbs: ["get", "list", "watch"]
|
|
- apiGroups: [""]
|
|
resources: ["persistentvolumeclaims"]
|
|
verbs: ["create", "get", "list", "watch", "delete"]
|
|
---
|
|
apiVersion: rbac.authorization.k8s.io/v1
|
|
kind: RoleBinding
|
|
metadata:
|
|
name: agentrun-v01-mgr-runner-job-controller
|
|
namespace: ${namespace}
|
|
subjects:
|
|
- kind: ServiceAccount
|
|
name: agentrun-v01-mgr
|
|
roleRef:
|
|
apiGroup: rbac.authorization.k8s.io
|
|
kind: Role
|
|
name: agentrun-v01-mgr-runner-job-controller
|
|
---
|
|
apiVersion: rbac.authorization.k8s.io/v1
|
|
kind: Role
|
|
metadata:
|
|
name: agentrun-v01-mgr-provider-secret-manager
|
|
namespace: ${namespace}
|
|
rules:
|
|
- apiGroups: [""]
|
|
resources: ["secrets"]
|
|
verbs: ["create", "get", "list", "patch", "update"]
|
|
---
|
|
apiVersion: rbac.authorization.k8s.io/v1
|
|
kind: RoleBinding
|
|
metadata:
|
|
name: agentrun-v01-mgr-provider-secret-manager
|
|
namespace: ${namespace}
|
|
subjects:
|
|
- kind: ServiceAccount
|
|
name: agentrun-v01-mgr
|
|
roleRef:
|
|
apiGroup: rbac.authorization.k8s.io
|
|
kind: Role
|
|
name: agentrun-v01-mgr-provider-secret-manager
|
|
`;
|
|
}
|
|
|
|
function runnerRbacYaml(namespace: string): string {
|
|
return `apiVersion: v1
|
|
kind: ServiceAccount
|
|
metadata:
|
|
name: agentrun-v01-runner
|
|
namespace: ${namespace}
|
|
---
|
|
apiVersion: rbac.authorization.k8s.io/v1
|
|
kind: Role
|
|
metadata:
|
|
name: agentrun-v01-runner-secret-reader
|
|
namespace: ${namespace}
|
|
rules:
|
|
- apiGroups: [""]
|
|
resources: ["secrets"]
|
|
verbs: ["get"]
|
|
---
|
|
apiVersion: rbac.authorization.k8s.io/v1
|
|
kind: RoleBinding
|
|
metadata:
|
|
name: agentrun-v01-runner-secret-reader
|
|
namespace: ${namespace}
|
|
subjects:
|
|
- kind: ServiceAccount
|
|
name: agentrun-v01-runner
|
|
roleRef:
|
|
apiGroup: rbac.authorization.k8s.io
|
|
kind: Role
|
|
name: agentrun-v01-runner-secret-reader
|
|
`;
|
|
}
|
|
|
|
function parseArgs(argv: string[]): RenderOptions {
|
|
const flags = new Map<string, string | boolean>();
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const item = argv[index] ?? "";
|
|
if (!item.startsWith("--")) continue;
|
|
const key = item.slice(2);
|
|
const next = argv[index + 1];
|
|
if (next === undefined || next.startsWith("--")) flags.set(key, true);
|
|
else {
|
|
flags.set(key, next);
|
|
index += 1;
|
|
}
|
|
}
|
|
const options: RenderOptions = {
|
|
outDir: stringFlag(flags, "out", "deploy/gitops/g14"),
|
|
deployFile: stringFlag(flags, "deploy-file", "deploy/deploy.json"),
|
|
sourceCommit: stringFlag(flags, "source-commit", "source-check"),
|
|
registryPrefix: stringFlag(flags, "registry-prefix", "127.0.0.1:5000/agentrun"),
|
|
requireCatalog: flags.get("require-catalog") === true,
|
|
check: flags.get("check") === true,
|
|
};
|
|
const catalogFile = optionalStringFlag(flags, "catalog");
|
|
if (catalogFile) options.catalogFile = catalogFile;
|
|
return options;
|
|
}
|
|
|
|
function stringFlag(flags: Map<string, string | boolean>, key: string, fallback: string): string {
|
|
const value = flags.get(key);
|
|
return typeof value === "string" && value.length > 0 ? value : fallback;
|
|
}
|
|
|
|
function optionalStringFlag(flags: Map<string, string | boolean>, key: string): string | undefined {
|
|
const value = flags.get(key);
|
|
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
}
|
|
|
|
function stringField(record: JsonRecord, key: string, fallback: string): string {
|
|
const value = record[key];
|
|
return typeof value === "string" && value.length > 0 ? value : fallback;
|
|
}
|