diff --git a/config/platform-infra/sub2api-codex-pool.yaml b/config/platform-infra/sub2api-codex-pool.yaml index de59c7d0..022786e8 100644 --- a/config/platform-infra/sub2api-codex-pool.yaml +++ b/config/platform-infra/sub2api-codex-pool.yaml @@ -1,3 +1,13 @@ +version: 1 +kind: platform-infra-sub2api-codex-pool + +metadata: + id: sub2api-codex-pool + owner: unidesk + relatedIssues: + - 339 + - 340 + pool: groupName: unidesk-codex-pool apiKeyName: unidesk-codex-pool-api-key diff --git a/config/platform-infra/sub2api.yaml b/config/platform-infra/sub2api.yaml index 1ac8c328..d42dbe29 100644 --- a/config/platform-infra/sub2api.yaml +++ b/config/platform-infra/sub2api.yaml @@ -1,7 +1,23 @@ +version: 1 +kind: platform-infra-sub2api + +metadata: + id: sub2api + owner: unidesk + relatedIssues: + - 220 + - 339 + - 340 + image: repository: weishaw/sub2api tag: 0.1.136 pullPolicy: IfNotPresent + +dependencyImages: + postgres: postgres:18-alpine + redis: redis:8-alpine + targets: - id: G14 route: G14:k3s diff --git a/scripts/src/platform-infra-sub2api-codex.ts b/scripts/src/platform-infra-sub2api-codex.ts index 93a418ae..7f47bfc5 100644 --- a/scripts/src/platform-infra-sub2api-codex.ts +++ b/scripts/src/platform-infra-sub2api-codex.ts @@ -131,6 +131,13 @@ export interface CodexTempUnschedulablePolicy { } interface CodexPoolConfig { + version: number; + kind: string; + metadata: { + id: string; + owner: string; + relatedIssues: number[]; + }; groupName: string; apiKeyName: string; apiKeySecretName: string; @@ -1209,11 +1216,17 @@ function resolveProfileFiles(codexDir: string, profile: CodexPoolProfileConfig): function readCodexPoolConfig(): CodexPoolConfig { const defaults = defaultCodexPoolConfig(); - if (!existsSync(codexPoolConfigPath)) return defaults; + if (!existsSync(codexPoolConfigPath)) throw new Error(`${codexPoolConfigPath} is required`); const parsed = Bun.YAML.parse(readFileSync(codexPoolConfigPath, "utf8")) as unknown; if (!isRecord(parsed)) throw new Error(`${codexPoolConfigPath} must contain a YAML object`); + const version = integerConfigField(parsed, "version", ""); + if (version !== 1) throw new Error(`${codexPoolConfigPath}.version must be 1`); + const kind = requiredStringConfigField(parsed, "kind", ""); + if (kind !== "platform-infra-sub2api-codex-pool") throw new Error(`${codexPoolConfigPath}.kind must be platform-infra-sub2api-codex-pool`); + const metadata = requiredRecordConfigField(parsed, "metadata", ""); const pool = parsed.pool; if (!isRecord(pool)) throw new Error(`${codexPoolConfigPath}.pool must be a YAML object`); + if (!isRecord(parsed.profiles)) throw new Error(`${codexPoolConfigPath}.profiles must be a YAML object`); rejectSchedulableYamlField(pool, "pool"); const defaultTempUnschedulable = readTempUnschedulablePolicy(pool.defaultTempUnschedulable, "pool.defaultTempUnschedulable", defaults.defaultTempUnschedulable); const defaultAccountPriorityValue = readAccountPriority(pool.defaultAccountPriority, "pool.defaultAccountPriority"); @@ -1233,6 +1246,13 @@ function readCodexPoolConfig(): CodexPoolConfig { ? Math.max(1, declaredAccountCapacity) : readOwnerConcurrency(pool.minOwnerConcurrency, "pool.minOwnerConcurrency"); const config: CodexPoolConfig = { + version, + kind, + metadata: { + id: requiredStringConfigField(metadata, "id", "metadata"), + owner: requiredStringConfigField(metadata, "owner", "metadata"), + relatedIssues: integerArrayConfigField(metadata, "relatedIssues", "metadata"), + }, groupName: stringValue(pool.groupName) ?? defaults.groupName, apiKeyName: stringValue(pool.apiKeyName) ?? defaults.apiKeyName, apiKeySecretName: stringValue(pool.apiKeySecretName) ?? defaults.apiKeySecretName, @@ -1264,6 +1284,13 @@ function readCodexPoolConfig(): CodexPoolConfig { function defaultCodexPoolConfig(): CodexPoolConfig { return { + version: 1, + kind: "platform-infra-sub2api-codex-pool", + metadata: { + id: "sub2api-codex-pool", + owner: "unidesk", + relatedIssues: [], + }, groupName: defaultPoolGroupName, apiKeyName: defaultPoolApiKeyName, apiKeySecretName: defaultPoolApiKeySecretName, @@ -1853,6 +1880,9 @@ function tempUnschedulableSummary(policy: CodexTempUnschedulablePolicy): Record< function codexPoolConfigSummary(pool: CodexPoolConfig): Record { const accountCapacityTotal = desiredAccountCapacityTotal(pool); return { + version: pool.version, + kind: pool.kind, + metadata: pool.metadata, groupName: pool.groupName, apiKeyName: pool.apiKeyName, apiKeySecretName: pool.apiKeySecretName, @@ -3395,6 +3425,36 @@ function stringValue(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } +function requiredStringConfigField(obj: Record, key: string, path: string): string { + const value = stringValue(obj[key]); + const prefix = path.length > 0 ? `${path}.` : ""; + if (value === null) throw new Error(`${codexPoolConfigPath}.${prefix}${key} must be a non-empty string`); + return value; +} + +function requiredRecordConfigField(obj: Record, key: string, path: string): Record { + const value = obj[key]; + const prefix = path.length > 0 ? `${path}.` : ""; + if (!isRecord(value)) throw new Error(`${codexPoolConfigPath}.${prefix}${key} must be a YAML object`); + return value; +} + +function integerConfigField(obj: Record, key: string, path: string): number { + const value = obj[key]; + const prefix = path.length > 0 ? `${path}.` : ""; + if (typeof value !== "number" || !Number.isInteger(value)) throw new Error(`${codexPoolConfigPath}.${prefix}${key} must be an integer`); + return value; +} + +function integerArrayConfigField(obj: Record, key: string, path: string): number[] { + const value = obj[key]; + const prefix = path.length > 0 ? `${path}.` : ""; + if (!Array.isArray(value) || !value.every((item) => typeof item === "number" && Number.isInteger(item))) { + throw new Error(`${codexPoolConfigPath}.${prefix}${key} must be an array of integers`); + } + return value; +} + function numberValue(value: unknown): number | null { if (typeof value === "number" && Number.isFinite(value)) return value; if (typeof value === "string" && value.trim().length > 0) { diff --git a/scripts/src/platform-infra.ts b/scripts/src/platform-infra.ts index dc3dfda4..78de7688 100644 --- a/scripts/src/platform-infra.ts +++ b/scripts/src/platform-infra.ts @@ -24,6 +24,13 @@ type DatabaseMode = "bundled" | "external-pending" | "external-active"; type RedisMode = "bundled-persistent" | "local-ephemeral"; interface Sub2ApiConfig { + version: number; + kind: string; + metadata: { + id: string; + owner: string; + relatedIssues: number[]; + }; image: { repository: string; tag: string; @@ -391,6 +398,12 @@ function readSub2ApiConfig(): Sub2ApiConfig { const parsed = Bun.YAML.parse(readFileSync(configPath, "utf8")) as unknown; if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error(`${configPath} must contain a YAML object`); const root = parsed as Record; + const version = integerField(root, "version", ""); + if (version !== 1) throw new Error(`${configPath}.version must be 1`); + const kind = stringField(root, "kind", ""); + if (kind !== "platform-infra-sub2api") throw new Error(`${configPath}.kind must be platform-infra-sub2api`); + const metadata = objectField(root, "metadata", ""); + const relatedIssues = integerArrayField(metadata, "relatedIssues", "metadata"); const image = (parsed as { image?: unknown }).image; if (typeof image !== "object" || image === null || Array.isArray(image)) throw new Error(`${configPath}.image must be an object`); const record = image as Record; @@ -410,6 +423,13 @@ function readSub2ApiConfig(): Sub2ApiConfig { const targets = parseTargets(root); const runtime = parseRuntime(root); return { + version, + kind, + metadata: { + id: stringField(metadata, "id", "metadata"), + owner: stringField(metadata, "owner", "metadata"), + relatedIssues, + }, image: { repository, tag, pullPolicy }, dependencyImages, security: { @@ -427,7 +447,7 @@ function readSub2ApiConfig(): Sub2ApiConfig { function parseDependencyImages(root: Record): Sub2ApiConfig["dependencyImages"] { const value = root.dependencyImages; - if (value === undefined) return { postgres: "postgres:18-alpine", redis: "redis:8-alpine" }; + if (value === undefined) throw new Error(`${configPath}.dependencyImages must be an object`); if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${configPath}.dependencyImages must be an object`); const record = value as Record; const postgres = stringField(record, "postgres", "dependencyImages"); @@ -439,7 +459,7 @@ function parseDependencyImages(root: Record): Sub2ApiConfig["de function parseTargets(root: Record): Sub2ApiTargetConfig[] { const value = root.targets; - if (value === undefined) return defaultTargets(); + if (value === undefined) throw new Error(`${configPath}.targets must be an array`); if (!Array.isArray(value)) throw new Error(`${configPath}.targets must be an array`); const targets = value.map((item, index) => { if (typeof item !== "object" || item === null || Array.isArray(item)) throw new Error(`${configPath}.targets[${index}] must be an object`); @@ -476,26 +496,6 @@ function parseTargets(root: Record): Sub2ApiTargetConfig[] { return targets; } -function defaultTargets(): Sub2ApiTargetConfig[] { - return [ - { - id: "G14", - route: "G14:k3s", - namespace, - role: "active", - enabled: true, - databaseMode: "bundled", - redisMode: "bundled-persistent", - appReplicas: 1, - redisReplicas: 1, - image: {}, - dependencyImages: {}, - publicExposure: null, - egressProxy: null, - }, - ]; -} - function targetImageOverride(record: Record, path: string): Partial { if (record.image === undefined) return {}; const image = objectField(record, "image", path); @@ -638,34 +638,7 @@ function parseEgressProxyConfig(value: unknown, path: string): Sub2ApiEgressProx function parseRuntime(root: Record): Sub2ApiConfig["runtime"] { const value = root.runtime; - if (value === undefined) { - return { - database: { - mode: "external", - sourceRef: "platform-db/postgres-active.env", - sourceKeys: { - user: "SUB2API_DB_USER", - password: "SUB2API_DB_PASSWORD", - dbName: "SUB2API_DB_NAME", - }, - secretName, - passwordKey: "POSTGRES_PASSWORD", - host: "pika01-postgres.pending.local", - port: 5432, - user: "sub2api", - dbName: "sub2api", - sslMode: "prefer", - pendingAllowed: true, - }, - secrets: { - root: ".state/secrets", - appSourceRef: "platform-infra/sub2api.env", - }, - redis: { serviceName: "sub2api-redis", persistence: false }, - appData: { mode: "persistent-pvc" }, - sentinel: { mode: "singleton", enabledOnTargets: ["G14"] }, - }; - } + if (value === undefined) throw new Error(`${configPath}.runtime must be an object`); if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${configPath}.runtime must be an object`); const runtime = value as Record; const database = objectField(runtime, "database", "runtime"); @@ -760,6 +733,14 @@ function integerField(obj: Record, key: string, path: string): return value; } +function integerArrayField(obj: Record, key: string, path: string): number[] { + const value = obj[key]; + if (!Array.isArray(value) || !value.every((item) => typeof item === "number" && Number.isInteger(item))) { + throw new Error(`${configPath}.${path}.${key} must be an array of integers`); + } + return value; +} + function isKubernetesName(value: string): boolean { return /^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/u.test(value); } @@ -1541,6 +1522,10 @@ function plan(options: TargetOptions): Record { serviceDns: `${serviceName}.${target.namespace}.svc.cluster.local:8080`, }, config: { + path: configPath, + version: sub2api.version, + kind: sub2api.kind, + metadata: sub2api.metadata, image: imageRef(sub2api, target), pullPolicy: targetImage(sub2api, target).pullPolicy, dependencyImages: targetDependencyImages(sub2api, target),