chore: make sub2api d601 primary
This commit is contained in:
@@ -5,7 +5,7 @@ description: UniDesk Sub2API 平台运维技能。用户提到 Sub2API、sub2api
|
||||
|
||||
# UniDesk Sub2API
|
||||
|
||||
UniDesk 在 k3s `platform-infra` namespace 运维 Sub2API。G14 是默认 active runtime;D601 由同一 YAML/CLI 控制,可保持 standby predeploy,也可在外置 DB、镜像、FRP 和 egress proxy 条件就绪后作为 external-active target 运行。日常操作统一使用 UniDesk CLI,不直接写 Kubernetes 资源或手工调用 Sub2API 管理 API。
|
||||
UniDesk 在 k3s `platform-infra` namespace 运维 Sub2API。D601 是当前默认 active runtime,使用外置 PostgreSQL 和目标级 public exposure;G14 由同一 YAML/CLI 控制为 standby predeploy,默认缩容且不运行 sentinel、FRP 或 HTTPS egress proxy。日常操作统一使用 UniDesk CLI,不直接写 Kubernetes 资源或手工调用 Sub2API 管理 API。
|
||||
|
||||
**固定入口**: `cd /root/unidesk && bun scripts/cli.ts platform-infra sub2api ...`
|
||||
|
||||
@@ -33,31 +33,31 @@ bun scripts/cli.ts platform-infra sub2api codex-pool trace --request-id <request
|
||||
- 配置真相是 YAML:`config/platform-infra/sub2api.yaml` 和 `config/platform-infra/sub2api-codex-pool.yaml`。
|
||||
- 业务策略和具体数值只以 YAML 为准。已有字段的数值调整只改 YAML 并跑 `plan` / `sync --confirm` / `validate`;不要自动补代码硬编码、schema 硬范围、合同测试、单元测试或长期参考文档。配置校验只校验格式、类型、必填和可渲染性,不判断数值策略是否“合理”。
|
||||
- 本 skill 目录下若存在 `agents/*.yaml`,只作为 skill/agent 展示与调用元数据,不是 Sub2API 或 Codex pool 运行配置;不要在 skill 目录维护第二份账号、capacity、priority、endpoint 或 Secret 配置。
|
||||
- Runtime target 由 `config/platform-infra/sub2api.yaml` 声明;默认 `G14:k3s` 是 active target,`D601:k3s` 可由 YAML 声明为 standby predeploy 或 external-active target。master server 只是控制端和消费者,不部署 Sub2API/PostgreSQL/Redis。
|
||||
- D601 standby 不部署本地 PostgreSQL,不运行 sentinel 或 FRP 管理入口;外置 DB endpoint/Secret 未准备好时只能预部署 namespace、NetworkPolicy、Service,以及 replicas=0 的 Sub2API/Redis Deployment。Redis 激活后也只允许 ephemeral cache。D601 external-active 仍不部署本地 PostgreSQL,必须直连 YAML 声明的外置 DB,使用本地 ephemeral Redis,并且只有在 YAML 启用时才运行 frpc、egress proxy 和目标级 sentinel。
|
||||
- Runtime target 由 `config/platform-infra/sub2api.yaml` 声明;默认 `D601:k3s` 是 active target,`G14:k3s` 是 standby target。master server 只是控制端和消费者,不部署 Sub2API/PostgreSQL/Redis。
|
||||
- Standby target 不部署本地 PostgreSQL,不运行 sentinel、FRP 管理入口或 HTTPS egress proxy;只能预部署 namespace、NetworkPolicy、Service,以及 replicas=0 的 Sub2API/Redis Deployment。Redis 激活后也只允许 ephemeral cache。External-active target 仍不部署本地 PostgreSQL,必须直连 YAML 声明的外置 DB,使用本地 ephemeral Redis,并且只有在 YAML 启用时才运行 frpc、egress proxy 和目标级 sentinel。
|
||||
- Secret、`~/.codex/config.toml*`、`~/.codex/auth.json*` 是运行时输入或本地状态,不提交。
|
||||
- 默认 `~/.codex/config.toml` 和 `~/.codex/auth.json` 只作为统一 Sub2API consumer 使用;`config.toml` 必须指向 `https://sub2api.74-48-78-17.nip.io/`,`auth.json` 必须使用统一 pool API key。新增上游账号不得覆盖这两个默认文件,只能新增 `config.toml.<profile>` / `auth.json.<profile>` 并在 YAML 里声明。
|
||||
- 默认 `~/.codex/config.toml` 和 `~/.codex/auth.json` 只作为统一 Sub2API consumer 使用;`config.toml` 必须指向 YAML-selected active target 的 consumer URL,当前由 `codex-pool configure-local --confirm` 写入 D601 target-level public URL;`auth.json` 必须使用统一 pool API key。新增上游账号不得覆盖这两个默认文件,只能新增 `config.toml.<profile>` / `auth.json.<profile>` 并在 YAML 里声明。
|
||||
- 输出只能包含 Secret 路径、长度、preview/fingerprint;禁止打印完整 API key、admin password、JWT secret、TOTP key。
|
||||
|
||||
## 部署与状态
|
||||
|
||||
```bash
|
||||
bun scripts/cli.ts platform-infra sub2api plan
|
||||
bun scripts/cli.ts platform-infra sub2api plan --target D601
|
||||
bun scripts/cli.ts platform-infra sub2api plan --target G14
|
||||
bun scripts/cli.ts platform-infra sub2api apply --dry-run
|
||||
bun scripts/cli.ts platform-infra sub2api apply --target D601 --dry-run
|
||||
bun scripts/cli.ts platform-infra sub2api apply --target G14 --dry-run
|
||||
bun scripts/cli.ts platform-infra sub2api apply --confirm
|
||||
bun scripts/cli.ts platform-infra sub2api apply --target D601 --confirm
|
||||
bun scripts/cli.ts platform-infra sub2api apply --target G14 --confirm
|
||||
bun scripts/cli.ts platform-infra sub2api status
|
||||
bun scripts/cli.ts platform-infra sub2api status --target D601
|
||||
bun scripts/cli.ts platform-infra sub2api status --target G14
|
||||
bun scripts/cli.ts platform-infra sub2api validate
|
||||
bun scripts/cli.ts platform-infra sub2api validate --target D601
|
||||
bun scripts/cli.ts platform-infra sub2api validate --target G14
|
||||
```
|
||||
|
||||
- `plan` 读取 `config/platform-infra/sub2api.yaml`,渲染 `src/components/platform-infra/sub2api/sub2api.k8s.yaml`,检查 no Ingress/NodePort/LoadBalancer/hostPort/hostNetwork/resource limits,并要求 `NetworkPolicy/allow-all` 随 manifest 受控创建。
|
||||
- `apply --confirm` 默认创建异步 job;按返回的 `job status` 命令轮询,再跑 `status` 和 `validate`。
|
||||
- `status --full|--raw` 只在需要展开远端 stdout/stderr 或原始 JSON 时使用。
|
||||
- `validate` 是按需验收,不是连续可用性探针。对 D601 standby,`validate --target D601` 验证预部署形态,不要求外置 DB 当前可连接;对 D601 external-active,必须验证外置 DB、ephemeral Redis、Sub2API service、YAML egress proxy 和目标级 public exposure。
|
||||
- `validate` 是按需验收,不是连续可用性探针。对 standby target,`validate --target <id>` 验证预部署形态,不要求外置 DB 当前可连接;对 external-active target,必须验证外置 DB、ephemeral Redis、Sub2API service、YAML egress proxy 和目标级 public exposure。
|
||||
|
||||
## 镜像升级
|
||||
|
||||
|
||||
@@ -119,7 +119,7 @@ profiles:
|
||||
configFile: config.toml.socap
|
||||
authFile: auth.json.socap
|
||||
publicExposure:
|
||||
enabled: true
|
||||
enabled: false
|
||||
proxyName: platform-infra-sub2api
|
||||
configMapName: sub2api-frpc-config
|
||||
deploymentName: sub2api-frpc
|
||||
|
||||
@@ -6,16 +6,16 @@ targets:
|
||||
- id: G14
|
||||
route: G14:k3s
|
||||
namespace: platform-infra
|
||||
role: active
|
||||
role: standby
|
||||
enabled: true
|
||||
databaseMode: bundled
|
||||
redisMode: bundled-persistent
|
||||
appReplicas: 1
|
||||
redisReplicas: 1
|
||||
databaseMode: external-pending
|
||||
redisMode: local-ephemeral
|
||||
appReplicas: 0
|
||||
redisReplicas: 0
|
||||
- id: D601
|
||||
route: D601:k3s
|
||||
namespace: platform-infra
|
||||
role: active-standby
|
||||
role: active
|
||||
enabled: true
|
||||
databaseMode: external-active
|
||||
redisMode: local-ephemeral
|
||||
@@ -118,7 +118,7 @@ runtime:
|
||||
sentinel:
|
||||
mode: singleton
|
||||
enabledOnTargets:
|
||||
- G14
|
||||
- D601
|
||||
security:
|
||||
urlAllowlist:
|
||||
enabled: false
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Platform Infra
|
||||
|
||||
`platform-infra` is the k3s namespace for UniDesk-operated shared platform services. G14 is the default active runtime for this namespace; D601 may host explicitly declared standby or externally backed active targets when the service needs node-local preparation, cutover capacity, or a direct public edge. It is separate from HWLAB runtime lanes, AgentRun lanes, D601 user services, and legacy `devops-infra` control-plane helpers. New shared infra should land here first; old `devops-infra` resources migrate gradually only when a concrete owner and validation path exists.
|
||||
`platform-infra` is the k3s namespace for UniDesk-operated shared platform services. Runtime placement is service-specific and YAML-selected. For Sub2API, D601 is the active externally backed target and G14 is a predeployed standby target scaled to zero; other platform services may still declare G14 as their active runtime in their own YAML. It is separate from HWLAB runtime lanes, AgentRun lanes, D601 user services, and legacy `devops-infra` control-plane helpers. New shared infra should land here first; old `devops-infra` resources migrate gradually only when a concrete owner and validation path exists.
|
||||
|
||||
## Source Of Truth
|
||||
|
||||
@@ -12,15 +12,15 @@
|
||||
## Sub2API Deployment Boundary
|
||||
|
||||
- Sub2API is a platform service operated by UniDesk in namespace `platform-infra`. It is not a HWLAB lane workload, AgentRun workload, D601 user service, or master server daemon.
|
||||
- The canonical deployment entrypoint is `bun scripts/cli.ts platform-infra sub2api plan|apply|status|validate|codex-pool`. Runtime targets are selected with `--target`; `G14` is the default active target and `D601` is controlled by the same YAML as either standby predeploy or externally backed active runtime. Daily operation procedures live in `$unidesk-sub2api` at `.agents/skills/unidesk-sub2api/SKILL.md`. This reference keeps only development boundaries and project-specific source-of-truth rules.
|
||||
- The canonical deployment entrypoint is `bun scripts/cli.ts platform-infra sub2api plan|apply|status|validate|codex-pool`. Runtime targets are selected with `--target`; the Sub2API active target is the target whose YAML role/database mode enables active replicas, currently `D601`, and `G14` is kept as a standby predeploy. Daily operation procedures live in `$unidesk-sub2api` at `.agents/skills/unidesk-sub2api/SKILL.md`. This reference keeps only development boundaries and project-specific source-of-truth rules.
|
||||
- Raw `kubectl` through `trans <target>:k3s` is only for bounded diagnosis and evidence, not a formal mutate path.
|
||||
- The image version is controlled by `config/platform-infra/sub2api.yaml`. Image update procedures are daily operations owned by `$unidesk-sub2api`; the development boundary is that image choices remain YAML-controlled.
|
||||
- Sub2API should stay ClusterIP-only by default. Do not add Ingress, NodePort, LoadBalancer, or broad FRP exposure unless a YAML-controlled public exposure decision exists.
|
||||
- Sub2API currently has no resource limits by design. Do not add CPU or memory limits unless a later explicit decision changes that policy and stores the new policy in YAML.
|
||||
- Master server is a consumer/control host, not the runtime location. Do not deploy Sub2API, PostgreSQL, Redis, or heavy validation loops on master server.
|
||||
- D601 Sub2API is selected by YAML, not by ad hoc runtime patches. In standby mode it must render without a local PostgreSQL StatefulSet, keep the Sub2API app and local Redis cache scaled to zero, and use only ephemeral Redis storage when Redis is later activated. In externally backed active mode it connects directly to the YAML-declared external PostgreSQL endpoint with `sslmode=require`, keeps durable app state outside the D601 k3s node, and uses local Redis only as ephemeral cache. Activation must be applied through the same `platform-infra sub2api --target D601` CLI path.
|
||||
- Sub2API active/standby placement is selected by YAML, not by ad hoc runtime patches. A standby target must render without a local PostgreSQL StatefulSet, keep the Sub2API app and local Redis cache scaled to zero, use only ephemeral Redis storage if Redis is later activated, and omit public FRP, HTTPS egress proxy, and account sentinel resources unless YAML explicitly promotes that target. An externally backed active target connects directly to the YAML-declared external PostgreSQL endpoint with `sslmode=require`, keeps durable app state outside the k3s node, and uses local Redis only as ephemeral cache. Promotion or failback must be applied by editing `config/platform-infra/sub2api.yaml` and running the same `platform-infra sub2api --target <id>` CLI path.
|
||||
- External platform PostgreSQL endpoints for Sub2API are produced by the platform DB YAML and its `platform-db postgres` CLI. Cross-node Sub2API consumers connect directly to that endpoint; the master server is not a PostgreSQL data-plane relay. DNS aliases are optional when the exported `DATABASE_URL` uses a reachable IP with `sslmode=require`; current PK01-specific rules live in `docs/reference/pk01.md`.
|
||||
- Sub2API account sentinel and public exposure are target-scoped YAML decisions. Do not create a second sentinel, FRP client, public management surface, or edge proxy by hand; enable or move those resources only through the target YAML and the `platform-infra sub2api` / `codex-pool --target` CLI paths.
|
||||
- Sub2API account sentinel, public exposure, and HTTPS egress proxy are target-scoped YAML decisions. The active target may run them when YAML enables them; the standby G14 target must stay deployed but inactive until YAML promotion. Do not create a second sentinel, FRP client, public management surface, or edge proxy by hand; enable or move those resources only through the target YAML and the `platform-infra sub2api` / `codex-pool --target` CLI paths.
|
||||
|
||||
## LangBot Deployment Boundary
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
- Codex accounts selected by YAML do not declare `schedulable` as durable configuration. `schedulable=true` is a `codex-pool sync --confirm` process-control baseline for UniDesk-managed accounts that are not under sentinel quarantine, not a YAML field.
|
||||
- `codex-pool sync --confirm` preserves UniDesk-managed accounts that are absent from YAML by default; explicit upstream retirement requires `codex-pool sync --confirm --prune-removed`. This keeps account deletion out of the normal availability-recovery path and prevents temporary YAML edits from becoming destructive runtime changes.
|
||||
- `profiles.entries` selects local Codex profile files from `~/.codex/` and maps them to Sub2API account names.
|
||||
- The unsuffixed master `~/.codex/config.toml` and `~/.codex/auth.json` are reserved for the unified Sub2API consumer. `config.toml` must keep `base_url = "https://sub2api.74-48-78-17.nip.io/"`, and `auth.json` must contain the unified pool API key from `pool.apiKeySecretName` / `pool.apiKeySecretKey`. Do not replace these two files with direct upstream account credentials.
|
||||
- The unsuffixed master `~/.codex/config.toml` and `~/.codex/auth.json` are reserved for the unified Sub2API consumer. `config.toml` must keep the YAML-selected consumer base URL written by `codex-pool configure-local --target <active> --confirm`, and `auth.json` must contain the unified pool API key from `pool.apiKeySecretName` / `pool.apiKeySecretKey` on that active target. Do not replace these two files with direct upstream account credentials.
|
||||
- Additional upstream accounts must use suffixed local profile files such as `config.toml.<profile>` and `auth.json.<profile>`, then be declared through `profiles.entries` in `config/platform-infra/sub2api-codex-pool.yaml`.
|
||||
- `profiles.entries[].capacity` optionally overrides `pool.defaultAccountCapacity` for one account. Capacity is a YAML-controlled routing input; concrete current values belong only in `config/platform-infra/sub2api-codex-pool.yaml` and runtime validation output, not in long-term reference prose. Code constants, Secrets, ad-hoc runtime patches, or stale tests must not override YAML source of truth.
|
||||
- `profiles.entries[].loadFactor` optionally overrides `pool.defaultAccountLoadFactor` for one account and is rendered to Sub2API `load_factor`. Treat it as routing policy: values belong in YAML and `codex-pool validate` output, not code constants, Secrets, or ad-hoc runtime patches.
|
||||
@@ -78,7 +78,7 @@
|
||||
- Codex account-state, quota prompts, model-routing failures, encrypted-content affinity failures, gateway wrappers, and timeout-like upstream errors must be handled by the generic temporary-unschedulable/failover path plus the external marker sentinel. Do not change membership, priority, capacity, load factor, WebSocket mode, `pool_mode`, or a specific provider's status merely to work around those errors. If a matching upstream failure still logs `openai.forward_failed` without `openai.upstream_failover_switching`, the missing fix is in Sub2API's HTTP `/responses` failover classification/error propagation, not in account pinning.
|
||||
- `profiles.entries[].openaiResponsesWebSocketsV2Mode` is the account-level Responses WebSocket v2 switch for OpenAI-compatible upstreams that require WebSocket transport. Allowed values are `off`, `ctx_pool`, and `passthrough`; omit the field unless that upstream needs it.
|
||||
- `profiles.entries[].upstreamUserAgent` is an optional account-level upstream request User-Agent override. Use it only for upstreams that require a Codex CLI compatible User-Agent; keep the value YAML-controlled and newline-free.
|
||||
- `publicExposure` in `config/platform-infra/sub2api-codex-pool.yaml` controls the default Codex-pool public bridge from master server to the G14 ClusterIP service. Target-level `publicExposure` in `config/platform-infra/sub2api.yaml` controls non-master exposure such as a D601-to-PK01 edge.
|
||||
- `publicExposure` in `config/platform-infra/sub2api-codex-pool.yaml` controls the legacy Codex-pool public bridge from master server to the G14 ClusterIP service and should stay disabled unless that bridge is explicitly reintroduced. Target-level `publicExposure` in `config/platform-infra/sub2api.yaml` controls the active public edge such as D601-to-PK01.
|
||||
- `publicExposure.masterCaddy.responseHeaderTimeoutSeconds` controls the master Caddy `response_header_timeout` for the public Sub2API site. It must be long enough for Codex `/responses/compact` requests; otherwise Caddy can return a client-visible 504 before Sub2API finishes the upstream compact request, and that edge timeout is not an account-level upstream failure that Sub2API can use for temporary-unschedulable failover. The numeric value belongs only in `config/platform-infra/sub2api-codex-pool.yaml`; after changing it, use `codex-pool expose --confirm` to reload Caddy and verify the rendered `response_header_timeout`. Requests that were already in flight before the reload may still finish with the previous timeout, so post-change evidence should check only requests that started after the reload.
|
||||
- `publicExposure.masterCaddy.edgeRetry` controls the master Caddy reverse-proxy retry window for the public Sub2API site. This belongs at the edge because FRP remotePort listener loss, `connection refused`, EOF, or connection reset can happen before a request reaches Sub2API, so Sub2API account failover and sentinel logic cannot observe or recover that request. Keep retry scope narrow, especially for non-idempotent POST traffic: connection-attempt failures may be retried by the reverse proxy, while round-trip retry after an upstream connection was established should be limited by YAML `retryMatch` to paths that are safe to repeat, such as compact. Retry durations and intervals belong only in YAML; after changing them, run `codex-pool expose --confirm` and verify the rendered Caddyfile contains the expected `lb_try_duration`, `lb_try_interval`, and `lb_retry_match`.
|
||||
- `localCodex` controls how the master server's current `~/.codex` consumer files are backed up and rewritten. Keep `supportsWebSockets` and `responsesWebSocketsV2` in the same state, and enable them only when at least one YAML-managed account has a current direct Codex WSv2 smoke that passes. If no upstream profile can sustain Responses WSv2, the honest long-term state is `false/false` so Codex uses HTTP Responses directly instead of repeatedly reconnecting before `response.completed`. `localCodex.responsesSmokeModel` is the YAML-declared model used by `codex-pool validate` for the lightweight `POST /v1/responses` smoke.
|
||||
@@ -125,15 +125,15 @@ If the YAML success cadence maximum is lowered or an account changes trust class
|
||||
|
||||
Operational observation for this sentinel should use the read-only `codex-pool sentinel-report` table or its `--raw` form. It is the canonical low-noise view for per-account probe count, trust class, marker result, HTTP/error diagnostics, freeze TTL, success cadence, success cadence maximum, next probe time, and recent CronJob runs; raw ConfigMap dumps and ad hoc log scraping are fallback diagnostics, not the primary state surface.
|
||||
|
||||
The default G14 Codex-pool request path is:
|
||||
The active Codex-pool request path follows the YAML-selected active target:
|
||||
|
||||
1. A client sends an OpenAI-compatible request to the configured consumer base URL, normally `https://sub2api.74-48-78-17.nip.io/v1/...`, with the unified API key.
|
||||
2. master `frps` forwards the TCP connection to `platform-infra/sub2api-frpc` when `publicExposure.enabled` is true.
|
||||
3. `sub2api-frpc` forwards to `sub2api.platform-infra.svc.cluster.local:8080`.
|
||||
1. A client sends an OpenAI-compatible request to the configured consumer base URL with the unified API key.
|
||||
2. The target-level public edge forwards traffic to that target's `sub2api-frpc` when `config/platform-infra/sub2api.yaml` enables `publicExposure`.
|
||||
3. `sub2api-frpc` forwards to `sub2api.platform-infra.svc.cluster.local:8080` inside the active target namespace.
|
||||
4. Sub2API validates the unified key and resolves its `group_id`.
|
||||
5. Accounts listed in `profiles.entries` are bound to the same group via `group_ids`, so Sub2API dispatches through that group using its own account selection semantics.
|
||||
|
||||
The D601 externally backed request path is different when target-level `publicExposure.enabled=true` in `config/platform-infra/sub2api.yaml`: client traffic reaches PK01 Caddy, PK01 forwards to the YAML-declared FRP remote port, D601 `sub2api-frpc` connects directly to PK01 `frps`, and FRP forwards to `sub2api.platform-infra.svc.cluster.local:8080` on D601. This path does not pass through the master server or the pikanode reverse proxy. `api.pikapython.com` must resolve to the YAML-declared PK01 public address before Caddy can obtain or renew the public certificate; when DNS is missing, PK01 local FRP probes and public-IP remote-port probes may prove the edge path, but they are not a substitute for final `https://api.pikapython.com` validation.
|
||||
For the current D601 externally backed active target, client traffic reaches PK01 Caddy, PK01 forwards to the YAML-declared FRP remote port, D601 `sub2api-frpc` connects directly to PK01 `frps`, and FRP forwards to `sub2api.platform-infra.svc.cluster.local:8080` on D601. This path does not pass through the master server or the pikanode reverse proxy. `api.pikapython.com` must resolve to the YAML-declared PK01 public address before Caddy can obtain or renew the public certificate; when DNS is missing, PK01 local FRP probes and public-IP remote-port probes may prove the edge path, but they are not a substitute for final `https://api.pikapython.com` validation.
|
||||
|
||||
When target-level `egressProxy.enabled=true`, the D601 target renders an in-cluster HTTP(S) proxy client from the master VPN subscription source declared in YAML. The CLI injects the resulting proxy URL and `NO_PROXY` into Sub2API and, when requested by YAML, the Codex account sentinel. `platform-infra sub2api validate --target D601 --full` must prove the proxy Deployment/Service is ready and that an app pod can complete the YAML-declared health probe through the proxy. Subscription contents and generated proxy configs are Secret material and must not be printed.
|
||||
|
||||
@@ -208,4 +208,4 @@ spec:
|
||||
|
||||
This policy must be included in the `sub2api plan` / `apply` manifest rendering so that it is created as part of the normal deployment flow, not maintained as a manual one-off.
|
||||
|
||||
`platform-infra sub2api status` must report whether `NetworkPolicy/allow-all` exists and still has `podSelector: {}`, `policyTypes: [Ingress, Egress]`, `ingress: [{}]`, and `egress: [{}]`. For active bundled targets, `platform-infra sub2api validate` must also run temporary in-namespace probe pods that connect to `sub2api-postgres:5432` and `sub2api-redis:6379`; local `pg_isready` inside the PostgreSQL pod alone is insufficient because it does not exercise kube-router cross-pod policy evaluation. For external-DB standby targets, `validate --target` checks the predeployment shape: no local PostgreSQL, app replicas zero, ClusterIP services, allow-all NetworkPolicy, and local Redis declared as ephemeral cache with readiness required only when Redis replicas are above zero. For external-DB active targets, `validate --target` checks that the app uses the external database endpoint, local Redis is ephemeral, no local PostgreSQL StatefulSet exists, and any YAML-declared egress proxy and public exposure resources are present and probed through their configured paths.
|
||||
`platform-infra sub2api status` must report whether `NetworkPolicy/allow-all` exists and still has `podSelector: {}`, `policyTypes: [Ingress, Egress]`, `ingress: [{}]`, and `egress: [{}]`. For active bundled targets, `platform-infra sub2api validate` must also run temporary in-namespace probe pods that connect to `sub2api-postgres:5432` and `sub2api-redis:6379`; local `pg_isready` inside the PostgreSQL pod alone is insufficient because it does not exercise kube-router cross-pod policy evaluation. For external-DB standby targets, `validate --target` checks the predeployment shape: no local PostgreSQL, app replicas zero, ClusterIP services, allow-all NetworkPolicy, local Redis declared as ephemeral cache with readiness required only when Redis replicas are above zero, and no standby-disabled public FRP, egress proxy, or sentinel CronJob remains. For external-DB active targets, `validate --target` checks that the app uses the external database endpoint, local Redis is ephemeral, no local PostgreSQL StatefulSet exists, and any YAML-declared egress proxy and public exposure resources are present and probed through their configured paths.
|
||||
|
||||
@@ -17,7 +17,8 @@ import {
|
||||
import { runSshCommandCapture, type SshCaptureResult } from "./ssh";
|
||||
|
||||
const g14K3sRoute = "G14:k3s";
|
||||
const defaultTargetId = "G14";
|
||||
const defaultTargetId = "D601";
|
||||
const defaultTargetRoute = "D601:k3s";
|
||||
const namespace = "platform-infra";
|
||||
const serviceName = "sub2api";
|
||||
const serviceDns = `${serviceName}.${namespace}.svc.cluster.local:8080`;
|
||||
@@ -79,6 +80,7 @@ interface CodexPoolRuntimeTarget {
|
||||
namespace: string;
|
||||
serviceName: string;
|
||||
serviceDns: string;
|
||||
publicBaseUrl: string | null;
|
||||
appSecretName: string;
|
||||
egressProxy: {
|
||||
enabled: boolean;
|
||||
@@ -549,7 +551,7 @@ function codexPoolRuntimeTarget(targetId: string = defaultTargetId): CodexPoolRu
|
||||
const raw = parsed.targets.find((item) => isRecord(item) && String(item.id ?? "").toLowerCase() === targetId.toLowerCase());
|
||||
if (!isRecord(raw)) throw new Error(`${sub2apiConfigPath}.targets does not contain target ${targetId}`);
|
||||
const id = stringValue(raw.id) ?? targetId;
|
||||
const route = stringValue(raw.route) ?? (id === defaultTargetId ? g14K3sRoute : "");
|
||||
const route = stringValue(raw.route) ?? (id === defaultTargetId ? defaultTargetRoute : "");
|
||||
const targetNamespace = stringValue(raw.namespace) ?? namespace;
|
||||
if (route.length === 0) throw new Error(`${sub2apiConfigPath}.targets[${id}].route is required`);
|
||||
validateKubernetesName(targetNamespace, `${sub2apiConfigPath}.targets[${id}].namespace`, true);
|
||||
@@ -571,12 +573,19 @@ function codexPoolRuntimeTarget(targetId: string = defaultTargetId): CodexPoolRu
|
||||
};
|
||||
}
|
||||
|
||||
let publicBaseUrl: string | null = null;
|
||||
if (isRecord(raw.publicExposure) && raw.publicExposure.enabled === true) {
|
||||
publicBaseUrl = normalizeBaseUrl(stringValue(raw.publicExposure.publicBaseUrl));
|
||||
if (publicBaseUrl === null) throw new Error(`${sub2apiConfigPath}.targets[${id}].publicExposure.publicBaseUrl must be a valid http(s) URL when target public exposure is enabled`);
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
route,
|
||||
namespace: targetNamespace,
|
||||
serviceName,
|
||||
serviceDns: `${serviceName}.${targetNamespace}.svc.cluster.local:8080`,
|
||||
publicBaseUrl,
|
||||
appSecretName,
|
||||
egressProxy,
|
||||
};
|
||||
@@ -591,6 +600,7 @@ function codexPoolPlan(options: DisclosureOptions = { full: false, raw: false, t
|
||||
const runtimeTarget = codexPoolRuntimeTarget(options.targetId);
|
||||
const profiles = collectCodexProfiles();
|
||||
const ok = profiles.length > 0 && profiles.every((profile) => profile.ok);
|
||||
const consumerBaseUrl = codexConsumerBaseUrl(pool, runtimeTarget);
|
||||
return {
|
||||
ok,
|
||||
action: "platform-infra-sub2api-codex-pool-plan",
|
||||
@@ -614,8 +624,10 @@ function codexPoolPlan(options: DisclosureOptions = { full: false, raw: false, t
|
||||
? `Account sentinel is enabled as k8s CronJob ${runtimeTarget.namespace}/${pool.sentinel.cronJobName}; actions.enabled=${pool.sentinel.actions.enabled}.`
|
||||
: "Account sentinel monitoring is disabled by YAML.",
|
||||
publicExposure: pool.publicExposure.enabled
|
||||
? `Default Codex consumers use ${codexConsumerBaseUrl(pool)}; bounded master-local probes may use ${pool.publicExposure.masterBaseUrl}. FRP proxy ${pool.publicExposure.proxyName} maps public ${pool.publicExposure.publicBaseUrl} to ${pool.publicExposure.localIP}:${pool.publicExposure.localPort}.`
|
||||
: "Public FRP exposure is disabled by YAML.",
|
||||
? `Default Codex consumers use ${consumerBaseUrl}; bounded master-local probes may use ${pool.publicExposure.masterBaseUrl}. Legacy Codex-pool FRP proxy ${pool.publicExposure.proxyName} maps public ${pool.publicExposure.publicBaseUrl} to ${pool.publicExposure.localIP}:${pool.publicExposure.localPort}.`
|
||||
: runtimeTarget.publicBaseUrl === null
|
||||
? "Public FRP exposure is disabled by YAML."
|
||||
: `Legacy Codex-pool FRP exposure is disabled by YAML; Codex consumers for target ${runtimeTarget.id} use target-level public exposure ${consumerBaseUrl}.`,
|
||||
idempotency: "sync reuses the group, account names, and k3s Secret when they already exist; credentials are updated from the current local Codex files; managed accounts missing from YAML are preserved unless --prune-removed is explicitly provided.",
|
||||
configPolicy: "UniDesk-owned durable configuration remains YAML-first; local ~/.codex files and runtime Secrets are not committed.",
|
||||
},
|
||||
@@ -994,6 +1006,8 @@ async function codexPoolExpose(config: UniDeskConfig, options: ConfirmOptions):
|
||||
|
||||
async function codexPoolConfigureLocal(config: UniDeskConfig, options: ConfirmOptions): Promise<Record<string, unknown>> {
|
||||
const pool = readCodexPoolConfig();
|
||||
const runtimeTarget = codexPoolRuntimeTarget(options.targetId);
|
||||
const consumerExposureAvailable = pool.publicExposure.enabled || runtimeTarget.publicBaseUrl !== null;
|
||||
const codexDir = join(homedir(), ".codex");
|
||||
const configPath = join(codexDir, "config.toml");
|
||||
const authPath = join(codexDir, "auth.json");
|
||||
@@ -1010,7 +1024,7 @@ async function codexPoolConfigureLocal(config: UniDeskConfig, options: ConfirmOp
|
||||
authPath,
|
||||
backupConfigPath,
|
||||
backupAuthPath,
|
||||
baseUrl: codexConsumerBaseUrl(pool),
|
||||
baseUrl: consumerExposureAvailable ? codexConsumerBaseUrl(pool, runtimeTarget) : null,
|
||||
providerName: pool.localCodex.providerName,
|
||||
wireApi: pool.localCodex.wireApi,
|
||||
modelContextWindow: pool.localCodex.modelContextWindow,
|
||||
@@ -1021,12 +1035,12 @@ async function codexPoolConfigureLocal(config: UniDeskConfig, options: ConfirmOp
|
||||
valuesPrinted: false,
|
||||
},
|
||||
next: {
|
||||
confirm: "bun scripts/cli.ts platform-infra sub2api codex-pool configure-local --confirm",
|
||||
confirm: `bun scripts/cli.ts platform-infra sub2api codex-pool configure-local${targetFlag(runtimeTarget)} --confirm`,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (!pool.publicExposure.enabled) throw new Error(`${codexPoolConfigPath}.publicExposure.enabled must be true before configure-local`);
|
||||
const keyResult = await fetchPoolApiKey(config, pool);
|
||||
if (!consumerExposureAvailable) throw new Error(`${codexPoolConfigPath}.publicExposure.enabled is false and ${sub2apiConfigPath}.targets[${runtimeTarget.id}].publicExposure.enabled is not true; configure-local needs one consumer URL`);
|
||||
const keyResult = await fetchPoolApiKey(config, pool, runtimeTarget);
|
||||
if (keyResult.apiKey === null) {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -1035,8 +1049,8 @@ async function codexPoolConfigureLocal(config: UniDeskConfig, options: ConfirmOp
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
const writeResult = writeLocalCodexConfig(pool, keyResult.apiKey);
|
||||
const validateResult = await validatePublicGatewayWithKey(pool, keyResult.apiKey);
|
||||
const writeResult = writeLocalCodexConfig(pool, keyResult.apiKey, runtimeTarget);
|
||||
const validateResult = await validatePublicGatewayWithKey(pool, keyResult.apiKey, runtimeTarget);
|
||||
return {
|
||||
ok: writeResult.ok && validateResult.ok,
|
||||
action: "platform-infra-sub2api-codex-pool-configure-local",
|
||||
@@ -1044,7 +1058,7 @@ async function codexPoolConfigureLocal(config: UniDeskConfig, options: ConfirmOp
|
||||
local: writeResult,
|
||||
validation: validateResult,
|
||||
apiKey: {
|
||||
secret: `${namespace}/${pool.apiKeySecretName}.${pool.apiKeySecretKey}`,
|
||||
secret: `${runtimeTarget.namespace}/${pool.apiKeySecretName}.${pool.apiKeySecretKey}`,
|
||||
keyPreview: apiKeyPreview(keyResult.apiKey),
|
||||
apiKeyFingerprint: fingerprint(keyResult.apiKey),
|
||||
valuesPrinted: false,
|
||||
@@ -2633,6 +2647,8 @@ function poolTarget(pool = readCodexPoolConfig(), target = codexPoolRuntimeTarge
|
||||
namespace: target.namespace,
|
||||
service: serviceName,
|
||||
serviceDns: target.serviceDns,
|
||||
publicBaseUrl: target.publicBaseUrl,
|
||||
consumerBaseUrl: pool.publicExposure.enabled || target.publicBaseUrl !== null ? codexConsumerBaseUrl(pool, target) : null,
|
||||
configPath: codexPoolConfigPath,
|
||||
groupName: pool.groupName,
|
||||
apiKeyName: pool.apiKeyName,
|
||||
@@ -3070,7 +3086,7 @@ kubectl -n ${target.namespace} get secret ${pool.apiKeySecretName} -o json
|
||||
}
|
||||
}
|
||||
|
||||
function writeLocalCodexConfig(pool: CodexPoolConfig, apiKey: string): Record<string, unknown> {
|
||||
function writeLocalCodexConfig(pool: CodexPoolConfig, apiKey: string, target = codexPoolRuntimeTarget(defaultTargetId)): Record<string, unknown> {
|
||||
const codexDir = join(homedir(), ".codex");
|
||||
mkdirSync(codexDir, { recursive: true, mode: 0o700 });
|
||||
const configPath = join(codexDir, "config.toml");
|
||||
@@ -3082,7 +3098,7 @@ function writeLocalCodexConfig(pool: CodexPoolConfig, apiKey: string): Record<st
|
||||
const configBackupAction = copyIfMissing(configPath, backupConfigPath);
|
||||
const authBackupAction = copyIfMissing(authPath, backupAuthPath);
|
||||
const currentToml = readFileSync(configPath, "utf8");
|
||||
const nextToml = updateCodexConfigToml(currentToml, pool);
|
||||
const nextToml = updateCodexConfigToml(currentToml, pool, target);
|
||||
writeFileSync(configPath, nextToml, { encoding: "utf8", mode: 0o600 });
|
||||
chmodSync(configPath, 0o600);
|
||||
writeFileSync(authPath, `${JSON.stringify({ OPENAI_API_KEY: apiKey }, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
||||
@@ -3110,7 +3126,7 @@ function writeLocalCodexConfig(pool: CodexPoolConfig, apiKey: string): Record<st
|
||||
},
|
||||
provider: {
|
||||
name: pool.localCodex.providerName,
|
||||
baseUrl: codexConsumerBaseUrl(pool),
|
||||
baseUrl: codexConsumerBaseUrl(pool, target),
|
||||
wireApi: pool.localCodex.wireApi,
|
||||
modelContextWindow: pool.localCodex.modelContextWindow,
|
||||
modelAutoCompactTokenLimit: pool.localCodex.modelAutoCompactTokenLimit,
|
||||
@@ -3128,10 +3144,10 @@ function copyIfMissing(source: string, target: string): "created" | "kept-existi
|
||||
return "created";
|
||||
}
|
||||
|
||||
function updateCodexConfigToml(current: string, pool: CodexPoolConfig): string {
|
||||
function updateCodexConfigToml(current: string, pool: CodexPoolConfig, target = codexPoolRuntimeTarget(defaultTargetId)): string {
|
||||
return renderCodexLocalConsumerToml(current, {
|
||||
providerName: pool.localCodex.providerName,
|
||||
baseUrl: codexConsumerBaseUrl(pool),
|
||||
baseUrl: codexConsumerBaseUrl(pool, target),
|
||||
wireApi: pool.localCodex.wireApi,
|
||||
modelContextWindow: pool.localCodex.modelContextWindow,
|
||||
modelAutoCompactTokenLimit: pool.localCodex.modelAutoCompactTokenLimit,
|
||||
@@ -3140,8 +3156,9 @@ function updateCodexConfigToml(current: string, pool: CodexPoolConfig): string {
|
||||
});
|
||||
}
|
||||
|
||||
function codexConsumerBaseUrl(pool: CodexPoolConfig): string {
|
||||
return `${pool.publicExposure.publicBaseUrl.replace(/\/+$/u, "")}/`;
|
||||
function codexConsumerBaseUrl(pool: CodexPoolConfig, target = codexPoolRuntimeTarget(defaultTargetId)): string {
|
||||
const baseUrl = target.publicBaseUrl ?? pool.publicExposure.publicBaseUrl;
|
||||
return `${baseUrl.replace(/\/+$/u, "")}/`;
|
||||
}
|
||||
|
||||
export function renderCodexLocalConsumerToml(current: string, options: CodexLocalConsumerTomlOptions): string {
|
||||
@@ -3237,8 +3254,9 @@ function upsertTomlSectionBoolean(text: string, sectionName: string, key: string
|
||||
return [...lines.slice(0, start + 1), assignment, ...lines.slice(start + 1)].join("\n");
|
||||
}
|
||||
|
||||
async function validatePublicGatewayWithKey(pool: CodexPoolConfig, apiKey: string): Promise<Record<string, unknown>> {
|
||||
const probe = await probePublicModels(pool, "with-api-key", apiKey, "public");
|
||||
async function validatePublicGatewayWithKey(pool: CodexPoolConfig, apiKey: string, target = codexPoolRuntimeTarget(defaultTargetId)): Promise<Record<string, unknown>> {
|
||||
const probeBase = target.publicBaseUrl === null ? "public" : "target-public";
|
||||
const probe = await probePublicModels(pool, "with-api-key", apiKey, probeBase, target);
|
||||
return {
|
||||
...probe,
|
||||
ok: probe.ok === true,
|
||||
@@ -3248,8 +3266,12 @@ async function validatePublicGatewayWithKey(pool: CodexPoolConfig, apiKey: strin
|
||||
};
|
||||
}
|
||||
|
||||
async function probePublicModels(pool: CodexPoolConfig, mode: "with-api-key" | "without-api-key", apiKey?: string, base: "master" | "public" = "master"): Promise<Record<string, unknown>> {
|
||||
const baseUrl = base === "public" ? pool.publicExposure.publicBaseUrl : pool.publicExposure.masterBaseUrl;
|
||||
async function probePublicModels(pool: CodexPoolConfig, mode: "with-api-key" | "without-api-key", apiKey?: string, base: "master" | "public" | "target-public" = "master", target = codexPoolRuntimeTarget(defaultTargetId)): Promise<Record<string, unknown>> {
|
||||
const baseUrl = base === "target-public"
|
||||
? (target.publicBaseUrl ?? pool.publicExposure.publicBaseUrl)
|
||||
: base === "public"
|
||||
? pool.publicExposure.publicBaseUrl
|
||||
: pool.publicExposure.masterBaseUrl;
|
||||
const url = `${baseUrl.replace(/\/+$/u, "")}/v1/models`;
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 8000);
|
||||
|
||||
+257
-19
@@ -7,12 +7,13 @@ import { startJob } from "./jobs";
|
||||
import type { RenderedCliResult } from "./output";
|
||||
import { runSshCommandCapture, type SshCaptureResult } from "./ssh";
|
||||
|
||||
const defaultTargetId = "G14";
|
||||
const defaultTargetId = "D601";
|
||||
const namespace = "platform-infra";
|
||||
const serviceName = "sub2api";
|
||||
const fieldManager = "unidesk-platform-infra";
|
||||
const manifestPath = rootPath("src", "components", "platform-infra", "sub2api", "sub2api.k8s.yaml");
|
||||
const configPath = rootPath("config", "platform-infra", "sub2api.yaml");
|
||||
const codexPoolConfigPath = rootPath("config", "platform-infra", "sub2api-codex-pool.yaml");
|
||||
const repoRoot = rootPath();
|
||||
const secretName = "sub2api-secrets";
|
||||
const requiredSecretKeys = ["POSTGRES_PASSWORD", "ADMIN_PASSWORD", "JWT_SECRET", "TOTP_ENCRYPTION_KEY"] as const;
|
||||
@@ -189,6 +190,33 @@ interface EgressProxySecretMaterial {
|
||||
valuesPrinted: false;
|
||||
}
|
||||
|
||||
interface ManagedResourceCleanupPlan {
|
||||
externalDbState: boolean;
|
||||
redisPersistentState: boolean;
|
||||
publicExposure: {
|
||||
enabled: boolean;
|
||||
deploymentName: string;
|
||||
configMapName: string;
|
||||
secretName: string;
|
||||
};
|
||||
egressProxy: {
|
||||
enabled: boolean;
|
||||
deploymentName: string;
|
||||
serviceName: string;
|
||||
secretName: string;
|
||||
};
|
||||
sentinel: {
|
||||
enabled: boolean;
|
||||
cronJobName: string;
|
||||
configMapName: string;
|
||||
credentialsSecretName: string;
|
||||
stateConfigMapName: string;
|
||||
serviceAccountName: string;
|
||||
roleName: string;
|
||||
roleBindingName: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function platformInfraHelp(): unknown {
|
||||
return {
|
||||
command: "platform-infra sub2api|langbot|n8n|wechat-archive ...",
|
||||
@@ -898,6 +926,68 @@ function configHashFor(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): str
|
||||
.slice(0, 16);
|
||||
}
|
||||
|
||||
function targetHasSentinel(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): boolean {
|
||||
return sub2api.runtime.sentinel.enabledOnTargets.some((id) => id.toLowerCase() === target.id.toLowerCase());
|
||||
}
|
||||
|
||||
function managedResourceCleanupPlan(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): ManagedResourceCleanupPlan {
|
||||
const sentinel = codexPoolSentinelResourceNames();
|
||||
return {
|
||||
externalDbState: isExternalTarget(target),
|
||||
redisPersistentState: target.redisMode === "local-ephemeral",
|
||||
publicExposure: {
|
||||
enabled: target.publicExposure?.enabled === true,
|
||||
deploymentName: target.publicExposure?.frpc.deploymentName ?? "sub2api-frpc",
|
||||
configMapName: "sub2api-frpc-config",
|
||||
secretName: target.publicExposure?.frpc.secretName ?? "sub2api-frpc-secrets",
|
||||
},
|
||||
egressProxy: {
|
||||
enabled: target.egressProxy?.enabled === true,
|
||||
deploymentName: target.egressProxy?.deploymentName ?? "sub2api-egress-proxy",
|
||||
serviceName: target.egressProxy?.serviceName ?? "sub2api-egress-proxy",
|
||||
secretName: target.egressProxy?.secretName ?? "sub2api-egress-proxy-config",
|
||||
},
|
||||
sentinel: {
|
||||
...sentinel,
|
||||
enabled: targetHasSentinel(sub2api, target),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function codexPoolSentinelResourceNames(): ManagedResourceCleanupPlan["sentinel"] {
|
||||
const defaults: ManagedResourceCleanupPlan["sentinel"] = {
|
||||
enabled: false,
|
||||
cronJobName: "sub2api-account-sentinel",
|
||||
configMapName: "sub2api-account-sentinel-config",
|
||||
credentialsSecretName: "sub2api-account-sentinel-profiles",
|
||||
stateConfigMapName: "sub2api-account-sentinel-state",
|
||||
serviceAccountName: "sub2api-account-sentinel",
|
||||
roleName: "sub2api-account-sentinel",
|
||||
roleBindingName: "sub2api-account-sentinel",
|
||||
};
|
||||
if (!existsSync(codexPoolConfigPath)) return defaults;
|
||||
const parsed = Bun.YAML.parse(readFileSync(codexPoolConfigPath, "utf8")) as unknown;
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return defaults;
|
||||
const sentinel = (parsed as Record<string, unknown>).sentinel;
|
||||
if (typeof sentinel !== "object" || sentinel === null || Array.isArray(sentinel)) return defaults;
|
||||
const record = sentinel as Record<string, unknown>;
|
||||
const text = (key: string, fallback: string): string => {
|
||||
const value = record[key];
|
||||
return typeof value === "string" && value.length > 0 ? value : fallback;
|
||||
};
|
||||
const serviceAccountName = text("serviceAccountName", defaults.serviceAccountName);
|
||||
return {
|
||||
enabled: false,
|
||||
cronJobName: text("cronJobName", defaults.cronJobName),
|
||||
configMapName: text("configMapName", defaults.configMapName),
|
||||
credentialsSecretName: text("credentialsSecretName", defaults.credentialsSecretName),
|
||||
stateConfigMapName: text("stateConfigMapName", defaults.stateConfigMapName),
|
||||
serviceAccountName,
|
||||
roleName: serviceAccountName,
|
||||
roleBindingName: serviceAccountName,
|
||||
};
|
||||
}
|
||||
|
||||
function externalPendingManifest(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): string {
|
||||
const urlAllowlist = sub2api.security.urlAllowlist;
|
||||
const database = sub2api.runtime.database;
|
||||
@@ -1473,13 +1563,13 @@ function plan(options: TargetOptions): Record<string, unknown> {
|
||||
decision: {
|
||||
owner: "UniDesk",
|
||||
namespace: target.namespace,
|
||||
reason: target.id === "G14"
|
||||
? "Sub2API remains the active G14 platform-infra deployment."
|
||||
reason: target.databaseMode === "external-pending"
|
||||
? `${target.id} is a standby Sub2API platform-infra target prepared through YAML; it stays scaled to zero until this target is promoted in YAML.`
|
||||
: target.databaseMode === "external-active"
|
||||
? "D601 is activated as a platform-infra Sub2API target against the external PK01 PostgreSQL runtime while keeping app state outside the k3s node."
|
||||
: "D601 is a standby Sub2API platform-infra target prepared through YAML; it does not run until the external Pika01/PK01 database secret is ready.",
|
||||
? `${target.id} is activated as a platform-infra Sub2API target against the external PK01 PostgreSQL runtime while keeping app state outside the k3s node.`
|
||||
: `${target.id} is a bundled active Sub2API platform-infra target.`,
|
||||
exposure: target.publicExposure?.enabled
|
||||
? `Public HTTPS ${target.publicExposure.publicBaseUrl} through PK01 Caddy and D601 frpc; no master server forwarding and no Kubernetes Ingress/NodePort/LoadBalancer.`
|
||||
? `Public HTTPS ${target.publicExposure.publicBaseUrl} through PK01 Caddy and ${target.id} frpc; no master server forwarding and no Kubernetes Ingress/NodePort/LoadBalancer.`
|
||||
: "ClusterIP only; no public ingress or node-level exposure.",
|
||||
resourcePolicy: "No Kubernetes CPU/memory requests or limits, matching issue #220.",
|
||||
imageVersionControl: "Sub2API image repository/tag/pullPolicy are controlled by config/platform-infra/sub2api.yaml in the UniDesk repository.",
|
||||
@@ -1488,7 +1578,7 @@ function plan(options: TargetOptions): Record<string, unknown> {
|
||||
publicExposure: target.publicExposure?.enabled
|
||||
? {
|
||||
mode: "pk01-caddy-frp-direct",
|
||||
dataPath: "client -> PK01 Caddy -> PK01 frps remotePort -> D601 frpc -> Sub2API",
|
||||
dataPath: `client -> PK01 Caddy -> PK01 frps remotePort -> ${target.id} frpc -> Sub2API`,
|
||||
pikanodeRole: "pikapython.com upstream only; api.pikapython.com does not pass through pikanode Express",
|
||||
publicBaseUrl: target.publicExposure.publicBaseUrl,
|
||||
hostname: target.publicExposure.dns.hostname,
|
||||
@@ -1497,7 +1587,7 @@ function plan(options: TargetOptions): Record<string, unknown> {
|
||||
: null,
|
||||
egressProxy: target.egressProxy?.enabled
|
||||
? {
|
||||
mode: "D601 in-cluster HTTP proxy client to the master VPN subscription",
|
||||
mode: `${target.id} in-cluster HTTP proxy client to the master VPN subscription`,
|
||||
service: `${target.egressProxy.serviceName}.${target.namespace}.svc.cluster.local:${target.egressProxy.listenPort}`,
|
||||
sourceRef: target.egressProxy.sourceRef,
|
||||
applyToSub2Api: target.egressProxy.applyToSub2Api,
|
||||
@@ -1508,7 +1598,7 @@ function plan(options: TargetOptions): Record<string, unknown> {
|
||||
dataStores: isExternalTarget(target)
|
||||
? [
|
||||
target.databaseMode === "external-active" ? "External PostgreSQL active from platform-db/PK01" : "External PostgreSQL pending from platform-db/Pika01",
|
||||
target.redisReplicas === 0 ? "D601 local Redis 8 ephemeral cache, scaled to zero until activation" : "D601 local Redis 8 ephemeral cache",
|
||||
target.redisReplicas === 0 ? `${target.id} local Redis 8 ephemeral cache, scaled to zero until activation` : `${target.id} local Redis 8 ephemeral cache`,
|
||||
]
|
||||
: ["PostgreSQL 18", "Redis 8"],
|
||||
appPoolCaps: {
|
||||
@@ -1517,6 +1607,16 @@ function plan(options: TargetOptions): Record<string, unknown> {
|
||||
redisPoolSize: 32,
|
||||
redisMinIdleConns: 2,
|
||||
},
|
||||
standbyActivation: target.databaseMode === "external-pending"
|
||||
? {
|
||||
target: target.id,
|
||||
currentMode: "predeployed-standby",
|
||||
enableByYaml: `change target ${target.id} databaseMode to external-active and set appReplicas/redisReplicas to 1, then apply --target ${target.id} --confirm`,
|
||||
sharedExternalDb: `${sub2api.runtime.database.host}:${sub2api.runtime.database.port}/${sub2api.runtime.database.dbName}`,
|
||||
sentinelEnabled: targetHasSentinel(sub2api, target),
|
||||
}
|
||||
: null,
|
||||
cleanup: managedResourceCleanupPlan(sub2api, target),
|
||||
},
|
||||
policy,
|
||||
next: {
|
||||
@@ -1587,7 +1687,7 @@ async function apply(config: UniDeskConfig, options: ApplyOptions): Promise<Reco
|
||||
const secretMaterial = target.databaseMode === "external-active" ? prepareExternalActiveSecret(sub2api) : null;
|
||||
const publicExposureSecretMaterial = preparePublicExposureSecret(sub2api, target);
|
||||
const egressProxySecretMaterial = prepareEgressProxySecret(sub2api, target);
|
||||
const result = await capture(config, target.route, ["script"], applyScript(yaml, target, secretMaterial, publicExposureSecretMaterial, egressProxySecretMaterial));
|
||||
const result = await capture(config, target.route, ["script"], applyScript(sub2api, yaml, target, secretMaterial, publicExposureSecretMaterial, egressProxySecretMaterial));
|
||||
const parsed = parseJsonOutput(result.stdout);
|
||||
const pk01Exposure = publicExposureSecretMaterial === null ? null : await applyPk01PublicExposure(config, target);
|
||||
return {
|
||||
@@ -1755,7 +1855,7 @@ function policyChecks(yaml: string, target: Sub2ApiTargetConfig): PolicyCheck[]
|
||||
checks.push({
|
||||
name: "local-redis-ephemeral",
|
||||
ok: !/\bsub2api-redis-data\b/u.test(yaml) && /^\s*emptyDir:\s*\{\}\s*$/mu.test(yaml),
|
||||
detail: "D601 standby Redis is a local ephemeral cache and must not allocate persistent Redis storage.",
|
||||
detail: "Local Redis is an ephemeral cache and must not allocate persistent Redis storage.",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1763,7 +1863,7 @@ function policyChecks(yaml: string, target: Sub2ApiTargetConfig): PolicyCheck[]
|
||||
checks.push({
|
||||
name: "egress-proxy-yaml-controlled",
|
||||
ok: hasDeploymentReplicas(yaml, target.egressProxy.deploymentName, 1) && new RegExp(`^\\s*name:\\s*${escapeRegExp(target.egressProxy.serviceName)}\\s*$`, "mu").test(yaml),
|
||||
detail: "D601 egress HTTP proxy client must be rendered as a YAML-controlled platform-infra Deployment and ClusterIP Service.",
|
||||
detail: "Egress HTTP proxy client must be rendered as a YAML-controlled platform-infra Deployment and ClusterIP Service.",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2656,6 +2756,7 @@ PY
|
||||
}
|
||||
|
||||
function applyScript(
|
||||
sub2api: Sub2ApiConfig,
|
||||
yaml: string,
|
||||
target: Sub2ApiTargetConfig,
|
||||
secretMaterial: ExternalActiveSecretMaterial | null,
|
||||
@@ -2663,6 +2764,7 @@ function applyScript(
|
||||
egressProxySecretMaterial: EgressProxySecretMaterial | null,
|
||||
): string {
|
||||
const encoded = Buffer.from(yaml, "utf8").toString("base64");
|
||||
const cleanupPlan = managedResourceCleanupPlan(sub2api, target);
|
||||
if (target.databaseMode === "external-active" && secretMaterial === null) throw new Error("external-active apply requires secret material");
|
||||
const externalSecretFiles = secretMaterial === null
|
||||
? ""
|
||||
@@ -2727,6 +2829,8 @@ egress_secret_out="$tmp/egress-secret.out"
|
||||
egress_secret_err="$tmp/egress-secret.err"
|
||||
apply_out="$tmp/apply.out"
|
||||
apply_err="$tmp/apply.err"
|
||||
cleanup_out="$tmp/cleanup.out"
|
||||
cleanup_err="$tmp/cleanup.err"
|
||||
kubectl create namespace ${target.namespace} --dry-run=client -o yaml | kubectl apply --server-side --force-conflicts --field-manager=${fieldManager} -f - >"$ns_out" 2>"$ns_err"
|
||||
ns_rc=$?
|
||||
secret_action="unknown"
|
||||
@@ -2803,7 +2907,98 @@ else
|
||||
: >"$apply_out"
|
||||
printf '%s\\n' 'skipped because namespace, app secret, public exposure secret, or egress proxy secret step failed' >"$apply_err"
|
||||
fi
|
||||
python3 - "$ns_rc" "$secret_rc" "$exposure_secret_rc" "$egress_secret_rc" "$apply_rc" "$secret_action" "$exposure_secret_action" "$egress_secret_action" "$ns_out" "$ns_err" "$secret_out" "$secret_err" "$exposure_secret_out" "$exposure_secret_err" "$egress_secret_out" "$egress_secret_err" "$apply_out" "$apply_err" <<'PY'
|
||||
cleanup_rc=0
|
||||
if [ "$apply_rc" -eq 0 ]; then
|
||||
python3 - "$cleanup_out" "$cleanup_err" <<'PY'
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
out_path, err_path = sys.argv[1:3]
|
||||
namespace = ${JSON.stringify(target.namespace)}
|
||||
plan = json.loads(${JSON.stringify(JSON.stringify(cleanupPlan))})
|
||||
|
||||
def run(args):
|
||||
proc = subprocess.run(["kubectl", "-n", namespace, *args], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
return {
|
||||
"args": ["kubectl", "-n", namespace, *args],
|
||||
"exitCode": proc.returncode,
|
||||
"stdout": proc.stdout.decode("utf-8", errors="replace")[-2000:],
|
||||
"stderr": proc.stderr.decode("utf-8", errors="replace")[-2000:],
|
||||
}
|
||||
|
||||
def delete(kind, name):
|
||||
return run(["delete", kind, name, "--ignore-not-found=true", "--wait=false"])
|
||||
|
||||
items = []
|
||||
if plan["externalDbState"]:
|
||||
items.append({"name": "legacy-postgres-statefulset", **delete("statefulset", "sub2api-postgres")})
|
||||
items.append({"name": "legacy-postgres-service", **delete("service", "sub2api-postgres")})
|
||||
items.append({"name": "legacy-postgres-pvc", **delete("pvc", "postgres-data-sub2api-postgres-0")})
|
||||
items.append({"name": "legacy-app-data-pvc", **delete("pvc", "sub2api-data")})
|
||||
if plan["redisPersistentState"]:
|
||||
items.append({"name": "legacy-redis-pvc", **delete("pvc", "sub2api-redis-data")})
|
||||
if not plan["publicExposure"]["enabled"]:
|
||||
items.append({"name": "disabled-public-frpc-deployment", **delete("deployment", plan["publicExposure"]["deploymentName"])})
|
||||
items.append({"name": "disabled-public-frpc-configmap", **delete("configmap", plan["publicExposure"]["configMapName"])})
|
||||
items.append({"name": "disabled-public-frpc-secret", **delete("secret", plan["publicExposure"]["secretName"])})
|
||||
if not plan["egressProxy"]["enabled"]:
|
||||
items.append({"name": "disabled-egress-proxy-deployment", **delete("deployment", plan["egressProxy"]["deploymentName"])})
|
||||
items.append({"name": "disabled-egress-proxy-service", **delete("service", plan["egressProxy"]["serviceName"])})
|
||||
items.append({"name": "disabled-egress-proxy-secret", **delete("secret", plan["egressProxy"]["secretName"])})
|
||||
if not plan["sentinel"]["enabled"]:
|
||||
items.append({"name": "disabled-sentinel-cronjob", **delete("cronjob", plan["sentinel"]["cronJobName"])})
|
||||
items.append({"name": "disabled-sentinel-jobs", **run(["delete", "job", "-l", f"app.kubernetes.io/name={plan['sentinel']['cronJobName']}", "--ignore-not-found=true", "--wait=false"])})
|
||||
items.append({"name": "disabled-sentinel-configmap", **delete("configmap", plan["sentinel"]["configMapName"])})
|
||||
items.append({"name": "disabled-sentinel-credentials-secret", **delete("secret", plan["sentinel"]["credentialsSecretName"])})
|
||||
items.append({"name": "disabled-sentinel-serviceaccount", **delete("serviceaccount", plan["sentinel"]["serviceAccountName"])})
|
||||
items.append({"name": "disabled-sentinel-role", **delete("role", plan["sentinel"]["roleName"])})
|
||||
items.append({"name": "disabled-sentinel-rolebinding", **delete("rolebinding", plan["sentinel"]["roleBindingName"])})
|
||||
|
||||
watch = []
|
||||
if plan["externalDbState"]:
|
||||
watch.extend([("statefulset", "sub2api-postgres"), ("service", "sub2api-postgres"), ("pvc", "postgres-data-sub2api-postgres-0"), ("pvc", "sub2api-data")])
|
||||
if plan["redisPersistentState"]:
|
||||
watch.append(("pvc", "sub2api-redis-data"))
|
||||
if not plan["publicExposure"]["enabled"]:
|
||||
watch.append(("deployment", plan["publicExposure"]["deploymentName"]))
|
||||
if not plan["egressProxy"]["enabled"]:
|
||||
watch.append(("deployment", plan["egressProxy"]["deploymentName"]))
|
||||
watch.append(("service", plan["egressProxy"]["serviceName"]))
|
||||
if not plan["sentinel"]["enabled"]:
|
||||
watch.append(("cronjob", plan["sentinel"]["cronJobName"]))
|
||||
|
||||
deadline = time.time() + 90
|
||||
remaining = []
|
||||
while True:
|
||||
remaining = []
|
||||
for kind, name in watch:
|
||||
result = run(["get", kind, name])
|
||||
if result["exitCode"] == 0:
|
||||
remaining.append({"kind": kind, "name": name})
|
||||
if not remaining or time.time() >= deadline:
|
||||
break
|
||||
time.sleep(3)
|
||||
|
||||
payload = {
|
||||
"ok": all(item["exitCode"] == 0 for item in items) and not remaining,
|
||||
"plan": plan,
|
||||
"items": items,
|
||||
"remainingAfterWait": remaining,
|
||||
"valuesPrinted": False,
|
||||
}
|
||||
open(out_path, "w", encoding="utf-8").write(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
open(err_path, "w", encoding="utf-8").write("")
|
||||
sys.exit(0 if payload["ok"] else 1)
|
||||
PY
|
||||
cleanup_rc=$?
|
||||
else
|
||||
: >"$cleanup_out"
|
||||
printf '%s\\n' 'skipped because apply failed' >"$cleanup_err"
|
||||
cleanup_rc=1
|
||||
fi
|
||||
python3 - "$ns_rc" "$secret_rc" "$exposure_secret_rc" "$egress_secret_rc" "$apply_rc" "$cleanup_rc" "$secret_action" "$exposure_secret_action" "$egress_secret_action" "$ns_out" "$ns_err" "$secret_out" "$secret_err" "$exposure_secret_out" "$exposure_secret_err" "$egress_secret_out" "$egress_secret_err" "$apply_out" "$apply_err" "$cleanup_out" "$cleanup_err" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
ns_rc = int(sys.argv[1])
|
||||
@@ -2811,17 +3006,23 @@ secret_rc = int(sys.argv[2])
|
||||
exposure_secret_rc = int(sys.argv[3])
|
||||
egress_secret_rc = int(sys.argv[4])
|
||||
apply_rc = int(sys.argv[5])
|
||||
secret_action = sys.argv[6]
|
||||
exposure_secret_action = sys.argv[7]
|
||||
egress_secret_action = sys.argv[8]
|
||||
paths = sys.argv[9:]
|
||||
cleanup_rc = int(sys.argv[6])
|
||||
secret_action = sys.argv[7]
|
||||
exposure_secret_action = sys.argv[8]
|
||||
egress_secret_action = sys.argv[9]
|
||||
paths = sys.argv[10:]
|
||||
def text(path):
|
||||
try:
|
||||
return open(path, encoding="utf-8").read()
|
||||
except FileNotFoundError:
|
||||
return ""
|
||||
def parsed(path):
|
||||
try:
|
||||
return json.load(open(path, encoding="utf-8"))
|
||||
except Exception:
|
||||
return None
|
||||
payload = {
|
||||
"ok": ns_rc == 0 and secret_rc == 0 and exposure_secret_rc == 0 and egress_secret_rc == 0 and apply_rc == 0,
|
||||
"ok": ns_rc == 0 and secret_rc == 0 and exposure_secret_rc == 0 and egress_secret_rc == 0 and apply_rc == 0 and cleanup_rc == 0,
|
||||
"target": "${target.id}",
|
||||
"namespace": "${target.namespace}",
|
||||
"databaseMode": "${target.databaseMode}",
|
||||
@@ -2843,6 +3044,7 @@ payload = {
|
||||
"publicExposureSecret": {"exitCode": exposure_secret_rc, "action": exposure_secret_action, "stdout": text(paths[4])[-4000:], "stderr": text(paths[5])[-4000:]},
|
||||
"egressProxySecret": {"exitCode": egress_secret_rc, "action": egress_secret_action, "stdout": text(paths[6])[-4000:], "stderr": text(paths[7])[-4000:]},
|
||||
"apply": {"exitCode": apply_rc, "stdout": text(paths[8])[-8000:], "stderr": text(paths[9])[-4000:]},
|
||||
"cleanup": {"exitCode": cleanup_rc, "summary": parsed(paths[10]), "stdout": text(paths[10])[-8000:], "stderr": text(paths[11])[-4000:]},
|
||||
},
|
||||
}
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
@@ -2878,6 +3080,7 @@ capture_json pvc kubectl -n ${target.namespace} get pvc -l app.kubernetes.io/par
|
||||
capture_json secrets kubectl -n ${target.namespace} get secret ${secretName}
|
||||
capture_json configmap kubectl -n ${target.namespace} get configmap sub2api-config
|
||||
capture_json networkpolicies kubectl -n ${target.namespace} get networkpolicy
|
||||
capture_json cronjobs kubectl -n ${target.namespace} get cronjob -l app.kubernetes.io/part-of=platform-infra
|
||||
capture_json ingresses kubectl -n ${target.namespace} get ingress
|
||||
capture_json quotas kubectl -n ${target.namespace} get resourcequota
|
||||
capture_json limitranges kubectl -n ${target.namespace} get limitrange
|
||||
@@ -3074,6 +3277,7 @@ services = items("services")
|
||||
pods = items("pods")
|
||||
pvcs = items("pvc")
|
||||
networkpolicies = items("networkpolicies")
|
||||
cronjobs = items("cronjobs")
|
||||
secret = load("secrets")
|
||||
configmap = load("configmap")
|
||||
configmap_data = (configmap or {}).get("data") or {}
|
||||
@@ -3169,6 +3373,14 @@ statefulset_summaries = [statefulset_summary(item) for item in statefulsets]
|
||||
workload_ready = all(d["ready"] for d in deployment_summaries) and all(s["ready"] for s in statefulset_summaries)
|
||||
local_postgres_present = any(item.get("metadata", {}).get("name") == "sub2api-postgres" for item in statefulsets + services + pvcs)
|
||||
redis_pvc_present = any(item.get("metadata", {}).get("name") == "sub2api-redis-data" for item in pvcs)
|
||||
public_exposure_deployment_present = any(item.get("metadata", {}).get("name") == "sub2api-frpc" for item in deployments)
|
||||
egress_proxy_deployment_present = any(item.get("metadata", {}).get("name") == "sub2api-egress-proxy" for item in deployments)
|
||||
sentinel_cronjob_present = any(item.get("metadata", {}).get("name") == "${codexPoolSentinelResourceNames().cronJobName}" for item in cronjobs)
|
||||
standby_disabled_resources_ok = not external_pending or (
|
||||
not public_exposure_deployment_present
|
||||
and not egress_proxy_deployment_present
|
||||
and not sentinel_cronjob_present
|
||||
)
|
||||
secret_ready = len(missing_secret_keys) == 0
|
||||
secret_ok = secret_ready or external_pending
|
||||
if external_pending:
|
||||
@@ -3179,6 +3391,7 @@ if external_pending:
|
||||
and redis_desired_aligned
|
||||
and expected_app_replicas == 0
|
||||
and expected_redis_replicas == 0
|
||||
and standby_disabled_resources_ok
|
||||
)
|
||||
elif external_active:
|
||||
state_model_ok = (
|
||||
@@ -3223,6 +3436,10 @@ payload = {
|
||||
"redisPvcPresent": redis_pvc_present,
|
||||
"sub2apiDesiredReplicasAligned": sub2api_desired_aligned,
|
||||
"redisDesiredReplicasAligned": redis_desired_aligned,
|
||||
"standbyDisabledResourcesOk": standby_disabled_resources_ok,
|
||||
"publicExposureDeploymentPresent": public_exposure_deployment_present,
|
||||
"egressProxyDeploymentPresent": egress_proxy_deployment_present,
|
||||
"sentinelCronJobPresent": sentinel_cronjob_present,
|
||||
"ok": state_model_ok,
|
||||
},
|
||||
"imageControl": {
|
||||
@@ -3702,7 +3919,9 @@ capture_json() {
|
||||
capture_json ns kubectl get namespace ${target.namespace}
|
||||
capture_json app kubectl -n ${target.namespace} get deployment ${serviceName}
|
||||
capture_json redis kubectl -n ${target.namespace} get deployment ${redisService}
|
||||
capture_json deployments kubectl -n ${target.namespace} get deployments
|
||||
capture_json services kubectl -n ${target.namespace} get service
|
||||
capture_json cronjobs kubectl -n ${target.namespace} get cronjob
|
||||
capture_json statefulsets kubectl -n ${target.namespace} get statefulsets
|
||||
capture_json pvc kubectl -n ${target.namespace} get pvc
|
||||
capture_json configmap kubectl -n ${target.namespace} get configmap sub2api-config
|
||||
@@ -3772,6 +3991,8 @@ def deployment_summary(item):
|
||||
}
|
||||
|
||||
services = items("services")
|
||||
deployments = items("deployments")
|
||||
cronjobs = items("cronjobs")
|
||||
statefulsets = items("statefulsets")
|
||||
pvcs = items("pvc")
|
||||
configmap = load("configmap")
|
||||
@@ -3785,6 +4006,16 @@ network_policy_ok = rc("networkpolicy") == 0 and is_allow_all_network_policy(net
|
||||
service_names = sorted(item.get("metadata", {}).get("name") for item in services)
|
||||
local_postgres_present = any(item.get("metadata", {}).get("name") == "sub2api-postgres" for item in services + statefulsets + pvcs)
|
||||
redis_pvc_present = any(item.get("metadata", {}).get("name") == "sub2api-redis-data" for item in pvcs)
|
||||
public_exposure_deployment_present = any(item.get("metadata", {}).get("name") == "sub2api-frpc" for item in deployments)
|
||||
egress_proxy_deployment_present = any(item.get("metadata", {}).get("name") == "sub2api-egress-proxy" for item in deployments)
|
||||
egress_proxy_service_present = "sub2api-egress-proxy" in service_names
|
||||
sentinel_cronjob_present = any(item.get("metadata", {}).get("name") == "${codexPoolSentinelResourceNames().cronJobName}" for item in cronjobs)
|
||||
standby_disabled_ok = (
|
||||
not public_exposure_deployment_present
|
||||
and not egress_proxy_deployment_present
|
||||
and not egress_proxy_service_present
|
||||
and not sentinel_cronjob_present
|
||||
)
|
||||
redis_ephemeral = any(volume.get("emptyDir") == {} for volume in redis_summary["volumes"])
|
||||
configmap_aligned = (
|
||||
configmap_data.get("DATABASE_HOST") == "${database.host}"
|
||||
@@ -3798,7 +4029,7 @@ app_scaled_to_zero = app_summary["exists"] and app_summary["desired"] == ${targe
|
||||
image_aligned = "${expectedImage}" in app_summary["images"]
|
||||
redis_ready = redis_summary["exists"] and redis_summary["desired"] == ${target.redisReplicas} and redis_ephemeral and (redis_summary["ready"] or ${target.redisReplicas} == 0)
|
||||
payload = {
|
||||
"ok": rc("ns") == 0 and network_policy_ok and app_scaled_to_zero and image_aligned and redis_ready and configmap_aligned and not local_postgres_present and not redis_pvc_present and "${serviceName}" in service_names and "${redisService}" in service_names,
|
||||
"ok": rc("ns") == 0 and network_policy_ok and app_scaled_to_zero and image_aligned and redis_ready and configmap_aligned and not local_postgres_present and not redis_pvc_present and standby_disabled_ok and "${serviceName}" in service_names and "${redisService}" in service_names,
|
||||
"target": "${target.id}",
|
||||
"namespace": "${target.namespace}",
|
||||
"status": "pending-external-db",
|
||||
@@ -3825,6 +4056,13 @@ payload = {
|
||||
"redisReadyEphemeral": {"ok": redis_ready, "summary": redis_summary, "redisPvcPresent": redis_pvc_present, "readinessRequired": ${target.redisReplicas} > 0},
|
||||
"serviceBoundary": {"serviceNames": service_names, "servicePresent": "${serviceName}" in service_names, "redisServicePresent": "${redisService}" in service_names},
|
||||
"externalDbOnly": {"ok": not local_postgres_present, "localPostgresPresent": local_postgres_present},
|
||||
"standbyDisabled": {
|
||||
"ok": standby_disabled_ok,
|
||||
"publicExposureDeploymentPresent": public_exposure_deployment_present,
|
||||
"egressProxyDeploymentPresent": egress_proxy_deployment_present,
|
||||
"egressProxyServicePresent": egress_proxy_service_present,
|
||||
"sentinelCronJobPresent": sentinel_cronjob_present,
|
||||
},
|
||||
"configMapAligned": {"ok": configmap_aligned, "databaseHost": configmap_data.get("DATABASE_HOST"), "redisHost": configmap_data.get("REDIS_HOST")},
|
||||
},
|
||||
"next": {
|
||||
|
||||
Reference in New Issue
Block a user