diff --git a/scripts/gitops-render.mjs b/scripts/gitops-render.mjs index 567d467a..a23c73de 100644 --- a/scripts/gitops-render.mjs +++ b/scripts/gitops-render.mjs @@ -1010,7 +1010,7 @@ function runtimeStoreEnvForProfile(deploy, profile, serviceId) { function workbenchRuntimeRedisEnvForProfile(deploy, profile, serviceId) { if (!isRuntimeLane(profile) || serviceId !== "hwlab-workbench-runtime") return {}; - const redis = runtimeLaneConfig(deploy, profile)?.workbenchRuntime?.cache?.redis; + const redis = workbenchRuntimeRedisConfigForProfile(deploy, profile); if (!redis || typeof redis !== "object" || Array.isArray(redis)) return {}; const env = { HWLAB_WORKBENCH_CACHE_REDIS_ENABLED: booleanEnv(Boolean(redis.enabled)) @@ -1049,6 +1049,28 @@ function workbenchRuntimeRedisEnvForProfile(deploy, profile, serviceId) { return env; } +function workbenchRuntimeRedisConfigForProfile(deploy, profile) { + if (!isRuntimeLane(profile)) return null; + const label = `deploy.lanes.${profile}.workbenchRuntime.cache.redis`; + const redis = runtimeLaneConfig(deploy, profile)?.workbenchRuntime?.cache?.redis; + if (!redis || redis.enabled !== true) return null; + configObject(redis, label); + const namespace = requiredConfigString(redis, "namespace", label); + assert.equal(namespace, namespaceNameForProfile(profile), `${label}.namespace must match ${namespaceNameForProfile(profile)}`); + const serviceName = requiredConfigString(redis, "serviceName", label); + const image = requiredConfigString(redis, "image", label); + const port = requiredConfigPositiveInteger(redis, "port", label); + assert.ok(port <= 65535, `${label}.port must be a valid TCP port`); + const resources = configObject(redis.resources, `${label}.resources`); + configObject(resources.requests, `${label}.resources.requests`); + configObject(resources.limits, `${label}.resources.limits`); + const memoryPolicy = configObject(redis.memoryPolicy, `${label}.memoryPolicy`); + assert.equal(memoryPolicy.persistence, "disabled", `${label}.memoryPolicy.persistence must be disabled`); + requiredConfigString(memoryPolicy, "maxMemory", `${label}.memoryPolicy`); + requiredConfigString(memoryPolicy, "eviction", `${label}.memoryPolicy`); + return redis; +} + function booleanEnv(value) { return value ? "1" : "0"; } @@ -5149,12 +5171,101 @@ function v02OpenFgaManifest({ profile = "v02", source }) { }; } -function runtimeKustomization({ profile = "dev", includeDeviceAgent = profile === "dev", externalPostgres = false } = {}) { +function workbenchRuntimeRedisConf(config) { + return [ + "save \"\"", + "appendonly no", + `maxmemory ${config.memoryPolicy.maxMemory}`, + `maxmemory-policy ${config.memoryPolicy.eviction}`, + "" + ].join("\n"); +} + +function workbenchRuntimeRedisManifest({ profile = "v03", config, source }) { + const namespace = config.namespace; + const name = config.serviceName; + const port = config.port; + const selector = { "app.kubernetes.io/name": name }; + const labels = { + ...selector, + "app.kubernetes.io/part-of": "hwlab", + "hwlab.pikastech.local/cache-role": "workbench-derived-read", + "hwlab.pikastech.local/component": "workbench-runtime-cache", + "hwlab.pikastech.local/environment": profile, + "hwlab.pikastech.local/gitops-target": profile, + "hwlab.pikastech.local/profile": profile, + "hwlab.pikastech.local/service-id": name, + "hwlab.pikastech.local/source-commit": source.full + }; + const annotations = { "hwlab.pikastech.local/rendered-by": "scripts/gitops-render.mjs" }; + const runtimeAnnotations = { ...annotations, "argocd.argoproj.io/sync-wave": "2" }; + const templateLabels = { ...labels, ...selector }; + return { + apiVersion: "v1", + kind: "List", + items: [ + { + apiVersion: "v1", + kind: "ConfigMap", + metadata: { name: `${name}-config`, namespace, labels, annotations: runtimeAnnotations }, + data: { "redis.conf": workbenchRuntimeRedisConf(config) } + }, + { + apiVersion: "apps/v1", + kind: "Deployment", + metadata: { name, namespace, labels, annotations: runtimeAnnotations }, + spec: { + replicas: 1, + selector: { matchLabels: selector }, + template: { + metadata: { labels: templateLabels, annotations: runtimeAnnotations }, + spec: { + containers: [{ + name: "redis", + image: config.image, + imagePullPolicy: "IfNotPresent", + args: ["redis-server", "/usr/local/etc/redis/redis.conf"], + ports: [{ name: "redis", containerPort: port }], + readinessProbe: { exec: { command: ["redis-cli", "-p", String(port), "ping"] }, initialDelaySeconds: 3, periodSeconds: 10, timeoutSeconds: 2, failureThreshold: 3 }, + livenessProbe: { exec: { command: ["redis-cli", "-p", String(port), "ping"] }, initialDelaySeconds: 15, periodSeconds: 20, timeoutSeconds: 3, failureThreshold: 3 }, + resources: cloneJson(config.resources), + volumeMounts: [{ name: "config", mountPath: "/usr/local/etc/redis", readOnly: true }] + }], + volumes: [{ name: "config", configMap: { name: `${name}-config` } }] + } + } + } + }, + { + apiVersion: "v1", + kind: "Service", + metadata: { name, namespace, labels, annotations: runtimeAnnotations }, + spec: { type: "ClusterIP", selector, ports: [{ name: "redis", port, targetPort: "redis", protocol: "TCP" }] } + }, + { + apiVersion: "networking.k8s.io/v1", + kind: "NetworkPolicy", + metadata: { name: `${name}-ingress`, namespace, labels, annotations: runtimeAnnotations }, + spec: { + podSelector: { matchLabels: selector }, + policyTypes: ["Ingress"], + ingress: [{ + from: [{ podSelector: { matchLabels: { "app.kubernetes.io/name": "hwlab-workbench-runtime" } } }], + ports: [{ protocol: "TCP", port }] + }] + } + } + ] + }; +} + +function runtimeKustomization({ profile = "dev", includeDeviceAgent = profile === "dev", externalPostgres = false, workbenchRedis = false } = {}) { const resources = ["namespace.yaml", "services.yaml", "health-contract.yaml", "workloads.yaml", "deepseek-proxy.yaml"]; if (!isRuntimeLane(profile)) resources.splice(1, 0, "code-agent-codex-config.yaml"); if (isRuntimeLane(profile)) { if (externalPostgres) resources.push("external-postgres.yaml"); else resources.push("postgres.yaml"); + if (workbenchRedis) resources.push("workbench-redis.yaml"); resources.push("openfga.yaml", "observability.yaml"); } if (includeDeviceAgent) resources.push("device-agent-71-freq.yaml"); @@ -5289,6 +5400,7 @@ async function plannedFiles(args) { const namespace = namespaceNameForProfile(profile); const includeDeviceAgent = profile === "dev"; const externalPostgres = externalPostgresConfigForLane(deploy, profile); + const workbenchRedis = workbenchRuntimeRedisConfigForProfile(deploy, profile); const profileLabels = { ...baseLabels, "hwlab.pikastech.local/environment": runtimeLabelForProfile(profile), "hwlab.pikastech.local/profile": runtimeLabelForProfile(profile) }; const runtimeNamespace = cloneJson(namespaceTemplate); runtimeNamespace.metadata.name = namespace; @@ -5296,7 +5408,7 @@ async function plannedFiles(args) { annotate(runtimeNamespace.metadata, annotations); const runtimePath = runtimePathForProfile(profile); const endpoints = profileEndpoint(args, profile); - putJson(`${runtimePath}/kustomization.yaml`, runtimeKustomization({ profile, includeDeviceAgent, externalPostgres: Boolean(externalPostgres) })); + putJson(`${runtimePath}/kustomization.yaml`, runtimeKustomization({ profile, includeDeviceAgent, externalPostgres: Boolean(externalPostgres), workbenchRedis: Boolean(workbenchRedis) })); putJson(`${runtimePath}/namespace.yaml`, runtimeNamespace); if (!isRuntimeLane(profile)) putJson(`${runtimePath}/code-agent-codex-config.yaml`, transformListNamespace(config, namespace, profileLabels, annotations)); putJson(`${runtimePath}/services.yaml`, transformServices({ services, namespace, labels: profileLabels, annotations, profile, deploy })); @@ -5305,6 +5417,7 @@ async function plannedFiles(args) { putJson(`${runtimePath}/deepseek-proxy.yaml`, deepSeekProxyManifest({ profile, source, sourceBranch: args.sourceBranch, sourceRepo: args.sourceRepo, gitReadUrl: args.gitReadUrl, deploy, registryPrefix: args.registryPrefix, catalog: artifactCatalog, useDeployImages: args.useDeployImages, metricsSidecarSha256 })); if (isRuntimeLane(profile) && externalPostgres) putJson(`${runtimePath}/external-postgres.yaml`, externalPostgresManifest({ profile, config: externalPostgres, source })); if (isRuntimeLane(profile) && !externalPostgres) putJson(`${runtimePath}/postgres.yaml`, v02PostgresManifest({ profile, migrationSql, source })); + if (workbenchRedis) putJson(`${runtimePath}/workbench-redis.yaml`, workbenchRuntimeRedisManifest({ profile, config: workbenchRedis, source })); if (isRuntimeLane(profile)) putJson(`${runtimePath}/openfga.yaml`, v02OpenFgaManifest({ profile, source })); if (isRuntimeLane(profile)) putJson(`${runtimePath}/observability.yaml`, observabilityManifest({ deploy, profile, namespace, labels: profileLabels, annotations, metricsSidecarScript })); if (includeDeviceAgent) putJson(`${runtimePath}/device-agent-71-freq.yaml`, deviceAgent71FreqManifest({ profile, source, registryPrefix: args.registryPrefix, catalog: artifactCatalog, useDeployImages: args.useDeployImages }));