Files
pikasTech-agentrun/scripts/src/gitops-render.ts
T
2026-06-11 15:39:52 +08:00

636 lines
24 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;
}
interface EnvVarValue {
name: string;
value: string;
}
interface SecretEnvVarValue {
name: string;
secretRef: {
name: string;
key: string;
};
}
interface FrpTcpExposure {
enabled: true;
kind: "frp-tcp";
name: string;
serverAddr: string;
serverPort: number;
localIP: string;
localPort: number;
remotePort: number;
publicBaseUrl: string;
masterBaseUrl: string;
}
const defaultBootRepoUrl = "http://git-mirror-http.devops-infra.svc.cluster.local/pikasTech/agentrun.git";
const unideskSshEndpointEnvNames = new Set(["UNIDESK_MAIN_SERVER_IP", "UNIDESK_MAIN_SERVER_HOST", "UNIDESK_FRONTEND_URL"]);
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 unideskSshEndpointEnv = optionalUnideskSshEndpointEnv(deploy);
const managerApiKeyEnv = managerApiKeySecretEnv(deploy);
const publicExposure = optionalFrpTcpExposure(deploy);
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, unideskSshEndpointEnv, managerApiKeyEnv, publicExposure));
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, unideskSshEndpointEnv: unideskSshEndpointEnv ? { name: unideskSshEndpointEnv.name, valuesPrinted: false } : null, managerAuth: { env: managerApiKeyEnv.name, secretRef: { name: managerApiKeyEnv.secretRef.name, key: managerApiKeyEnv.secretRef.key }, valuesPrinted: false }, publicExposure: publicExposure ? { kind: publicExposure.kind, name: publicExposure.name, remotePort: publicExposure.remotePort, masterBaseUrl: publicExposure.masterBaseUrl, publicBaseUrl: publicExposure.publicBaseUrl, valuesPrinted: false } : 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, unideskSshEndpointEnv: EnvVarValue | null, apiKeyEnv: SecretEnvVarValue, publicExposure: FrpTcpExposure | null): string {
const imageRef = repositoryDigestForService(image);
const envIdentity = image.envIdentity ?? image.imageTag ?? "unknown";
const unideskSshEndpointEnvYaml = unideskSshEndpointEnv ? ` - name: ${unideskSshEndpointEnv.name}\n value: ${JSON.stringify(unideskSshEndpointEnv.value)}\n` : "";
const apiKeyEnvYaml = ` - name: ${apiKeyEnv.name}\n valueFrom:\n secretKeyRef:\n name: ${apiKeyEnv.secretRef.name}\n key: ${apiKeyEnv.secretRef.key}\n`;
const frpcYaml = publicExposure ? `---\n${frpTcpExposureYaml(namespace, publicExposure)}` : "";
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"
${apiKeyEnvYaml}
${unideskSshEndpointEnvYaml}
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", "delete", "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
${frpcYaml}
`;
}
function frpTcpExposureYaml(namespace: string, exposure: FrpTcpExposure): string {
const configMapName = `${exposure.name}-config`;
return `apiVersion: v1
kind: ConfigMap
metadata:
name: ${configMapName}
namespace: ${namespace}
labels:
app.kubernetes.io/managed-by: agentrun
app.kubernetes.io/name: ${exposure.name}
app.kubernetes.io/part-of: agentrun
data:
frpc.toml: |
serverAddr = ${JSON.stringify(exposure.serverAddr)}
serverPort = ${exposure.serverPort}
loginFailExit = true
[[proxies]]
name = ${JSON.stringify(exposure.name)}
type = "tcp"
localIP = ${JSON.stringify(exposure.localIP)}
localPort = ${exposure.localPort}
remotePort = ${exposure.remotePort}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: ${exposure.name}
namespace: ${namespace}
labels:
app.kubernetes.io/managed-by: agentrun
app.kubernetes.io/name: ${exposure.name}
app.kubernetes.io/part-of: agentrun
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: ${exposure.name}
template:
metadata:
labels:
app.kubernetes.io/managed-by: agentrun
app.kubernetes.io/name: ${exposure.name}
app.kubernetes.io/part-of: agentrun
spec:
containers:
- name: frpc
image: fatedier/frpc:v0.68.1
imagePullPolicy: IfNotPresent
args:
- -c
- /etc/frp/frpc.toml
volumeMounts:
- name: config
mountPath: /etc/frp
readOnly: true
volumes:
- name: config
configMap:
name: ${configMapName}
`;
}
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;
}
function optionalUnideskSshEndpointEnv(deploy: JsonRecord): EnvVarValue | null {
const value = deploy.unideskSshEndpointEnv;
if (value === undefined) return null;
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new AgentRunError("schema-invalid", "unideskSshEndpointEnv must be an object", { httpStatus: 2 });
const record = value as JsonRecord;
const name = record.name;
const envValue = record.value;
if (typeof name !== "string" || !unideskSshEndpointEnvNames.has(name)) throw new AgentRunError("schema-invalid", "unideskSshEndpointEnv.name must be UNIDESK_MAIN_SERVER_IP, UNIDESK_MAIN_SERVER_HOST, or UNIDESK_FRONTEND_URL", { httpStatus: 2 });
if (typeof envValue !== "string" || envValue.length === 0) throw new AgentRunError("schema-invalid", "unideskSshEndpointEnv.value must be a non-empty string", { httpStatus: 2 });
return { name, value: envValue };
}
function managerApiKeySecretEnv(deploy: JsonRecord): SecretEnvVarValue {
const value = deploy.managerApiKeyEnv;
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new AgentRunError("schema-invalid", "managerApiKeyEnv must be an object", { httpStatus: 2 });
const record = value as JsonRecord;
const name = record.name;
const secretRef = record.secretRef;
if (name !== "AGENTRUN_API_KEY") throw new AgentRunError("schema-invalid", "managerApiKeyEnv.name must be AGENTRUN_API_KEY", { httpStatus: 2 });
if (typeof secretRef !== "object" || secretRef === null || Array.isArray(secretRef)) throw new AgentRunError("schema-invalid", "managerApiKeyEnv.secretRef must be an object", { httpStatus: 2 });
const ref = secretRef as JsonRecord;
const secretName = ref.name;
const key = ref.key;
if (typeof secretName !== "string" || !/^agentrun-v01-[a-z0-9-]+$/u.test(secretName)) throw new AgentRunError("schema-invalid", "managerApiKeyEnv.secretRef.name must be an agentrun-v01 Secret name", { httpStatus: 2 });
if (typeof key !== "string" || key !== "HWLAB_API_KEY") throw new AgentRunError("schema-invalid", "managerApiKeyEnv.secretRef.key must be HWLAB_API_KEY", { httpStatus: 2 });
return { name, secretRef: { name: secretName, key } };
}
function optionalFrpTcpExposure(deploy: JsonRecord): FrpTcpExposure | null {
const value = deploy.managerPublicExposure;
if (value === undefined) return null;
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new AgentRunError("schema-invalid", "managerPublicExposure must be an object", { httpStatus: 2 });
const record = value as JsonRecord;
if (record.enabled !== true) return null;
if (record.kind !== "frp-tcp") throw new AgentRunError("schema-invalid", "managerPublicExposure.kind must be frp-tcp", { httpStatus: 2 });
const exposure: FrpTcpExposure = {
enabled: true,
kind: "frp-tcp",
name: requiredDnsLabel(record, "name"),
serverAddr: requiredNonEmptyString(record, "serverAddr"),
serverPort: requiredPort(record, "serverPort"),
localIP: requiredNonEmptyString(record, "localIP"),
localPort: requiredPort(record, "localPort"),
remotePort: requiredPort(record, "remotePort"),
publicBaseUrl: requiredHttpUrl(record, "publicBaseUrl"),
masterBaseUrl: requiredHttpUrl(record, "masterBaseUrl"),
};
if (!exposure.localIP.endsWith(".svc.cluster.local")) throw new AgentRunError("schema-invalid", "managerPublicExposure.localIP must be a cluster service DNS name", { httpStatus: 2 });
if (!exposure.publicBaseUrl.startsWith("https://")) throw new AgentRunError("schema-invalid", "managerPublicExposure.publicBaseUrl must use https://", { httpStatus: 2 });
if (!exposure.masterBaseUrl.startsWith("http://127.0.0.1:")) throw new AgentRunError("schema-invalid", "managerPublicExposure.masterBaseUrl must point to the master-local FRP port", { httpStatus: 2 });
return exposure;
}
function requiredDnsLabel(record: JsonRecord, key: string): string {
const value = requiredNonEmptyString(record, key);
if (!/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/u.test(value)) throw new AgentRunError("schema-invalid", `managerPublicExposure.${key} must be a DNS label`, { httpStatus: 2 });
return value;
}
function requiredNonEmptyString(record: JsonRecord, key: string): string {
const value = record[key];
if (typeof value !== "string" || value.trim().length === 0) throw new AgentRunError("schema-invalid", `managerPublicExposure.${key} must be a non-empty string`, { httpStatus: 2 });
return value.trim();
}
function requiredPort(record: JsonRecord, key: string): number {
const value = record[key];
if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > 65535) throw new AgentRunError("schema-invalid", `managerPublicExposure.${key} must be a TCP port`, { httpStatus: 2 });
return value;
}
function requiredHttpUrl(record: JsonRecord, key: string): string {
const value = requiredNonEmptyString(record, key);
const url = new URL(value);
if (url.protocol !== "http:" && url.protocol !== "https:") throw new AgentRunError("schema-invalid", `managerPublicExposure.${key} must be http(s)`, { httpStatus: 2 });
return value;
}