From f19ca704475a8950594c0251df52957df9468b3c Mon Sep 17 00:00:00 2001 From: TsukiKage Date: Tue, 6 Oct 2026 00:13:07 +0800 Subject: [PATCH 1/3] feat: add Helm chart for Kubernetes deployment --- .github/workflows/master.yml | 33 +++ .pre-commit-config.yaml | 1 + README.md | 2 + deploy/helm/powercontext/Chart.yaml | 19 ++ deploy/helm/powercontext/README.md | 200 ++++++++++++++++++ .../helm/powercontext/templates/_helpers.tpl | 40 ++++ .../powercontext/templates/deployments.yaml | 154 ++++++++++++++ .../helm/powercontext/templates/ingress.yaml | 45 ++++ .../helm/powercontext/templates/service.yaml | 30 +++ .../helm/powercontext/values-acceptance.yaml | 23 ++ deploy/helm/powercontext/values.schema.json | 132 ++++++++++++ deploy/helm/powercontext/values.yaml | 85 ++++++++ docs/en/docs/operate/kubernetes.md | 25 +++ docs/en/docs/operate/meta.json | 1 + docs/zh/docs/operate/kubernetes.md | 22 ++ docs/zh/docs/operate/meta.json | 1 + tests/e2e/helm_acceptance.py | 139 ++++++++++++ tests/e2e/helm_probe.py | 129 +++++++++++ tests/test_helm_acceptance.py | 122 +++++++++++ tests/test_helm_chart.py | 149 +++++++++++++ 20 files changed, 1352 insertions(+) create mode 100644 deploy/helm/powercontext/Chart.yaml create mode 100644 deploy/helm/powercontext/README.md create mode 100644 deploy/helm/powercontext/templates/_helpers.tpl create mode 100644 deploy/helm/powercontext/templates/deployments.yaml create mode 100644 deploy/helm/powercontext/templates/ingress.yaml create mode 100644 deploy/helm/powercontext/templates/service.yaml create mode 100644 deploy/helm/powercontext/values-acceptance.yaml create mode 100644 deploy/helm/powercontext/values.schema.json create mode 100644 deploy/helm/powercontext/values.yaml create mode 100644 docs/en/docs/operate/kubernetes.md create mode 100644 docs/zh/docs/operate/kubernetes.md create mode 100644 tests/e2e/helm_acceptance.py create mode 100644 tests/e2e/helm_probe.py create mode 100644 tests/test_helm_acceptance.py create mode 100644 tests/test_helm_chart.py diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index 96abbf56aa..1d8f454667 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -294,3 +294,36 @@ jobs: - name: Check website run: make docs-test + + helm: + name: Helm deployment contracts + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - uses: ./.github/actions/setup-python-env + - name: Install Helm + run: | + curl --fail --silent --show-error --location https://get.helm.sh/helm-v3.19.0-linux-amd64.tar.gz -o /tmp/helm.tar.gz + curl --fail --silent --show-error --location https://get.helm.sh/helm-v3.19.0-linux-amd64.tar.gz.sha256sum -o /tmp/helm.sha256 + EXPECTED=$(cut -d ' ' -f 1 /tmp/helm.sha256) + echo "$EXPECTED /tmp/helm.tar.gz" | sha256sum --check + tar -xzf /tmp/helm.tar.gz -C /tmp + sudo install /tmp/linux-amd64/helm /usr/local/bin/helm + - name: Lint chart + run: >- + helm lint deploy/helm/powercontext --strict + --set image.repository=example/powercontext + --set image.tag=ci + --set existingSecret=credentials + - name: Lint acceptance profile + run: >- + helm lint deploy/helm/powercontext --strict + --values deploy/helm/powercontext/values-acceptance.yaml + --set image.repository=example/powercontext + --set image.tag=ci + --set existingSecret=credentials + - name: Test deployment configuration and acceptance safeguards + run: uv run --locked pytest tests/test_helm_chart.py tests/test_helm_acceptance.py -q diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b89f03a610..90bdf2f6cf 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,6 +6,7 @@ repos: - id: check-merge-conflict - id: check-toml - id: check-yaml + exclude: ^deploy/helm/powercontext/templates/ - id: check-json exclude: ^.devcontainer/devcontainer.json - id: pretty-format-json diff --git a/README.md b/README.md index 5ba54fa7a5..12fa5304d4 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,8 @@ PowerContext keeps context with the work across conversations. When you return, PowerContext 1.2.0 includes the guided setup. The commands below install this version and connect the matching Agent integration. +For Kubernetes deployments with external OceanBase, see the [Helm chart and deployment guide](deploy/helm/powercontext/README.md). + ## Pick up where the work left off You see the context the work needs now: confirmed decisions, constraints, progress, evidence, and next steps. You can continue from there or hand the work to another person or agent without rereading the full history. diff --git a/deploy/helm/powercontext/Chart.yaml b/deploy/helm/powercontext/Chart.yaml new file mode 100644 index 0000000000..c54e69478c --- /dev/null +++ b/deploy/helm/powercontext/Chart.yaml @@ -0,0 +1,19 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +apiVersion: v2 +name: powercontext +description: PowerContext API and background roles with external OceanBase +type: application +version: 0.1.0 diff --git a/deploy/helm/powercontext/README.md b/deploy/helm/powercontext/README.md new file mode 100644 index 0000000000..a7d3a2c0d7 --- /dev/null +++ b/deploy/helm/powercontext/README.md @@ -0,0 +1,200 @@ +# PowerContext Helm chart + +Deploy the current `api` and `background` roles against an **external OceanBase** database. +This chart does not provision a database. SQLite and embedded seekdb are unsupported; +use the single-process Docker example for evaluation. + +## Application compatibility + +Chart 0.1.0 targets the current master role/configuration contract, inspected at +`86537e4f`. No public release image is assumed. +Build the repository Dockerfile from the application revision being tested and publish it +to your registry. Supply its immutable tag or SHA-256 digest explicitly; `latest` is rejected. +The chart is not compatible with the proposed API/Scheduler/Worker deployment until that +runtime is integrated and tested. Keep every Pod on the same application image. + +```sh +# From the repository root; choose your registry and a unique immutable image tag. +POWERCONTEXT_VERSION=$(uvx --from hatchling --with hatch-vcs hatchling version) +docker build --file docker/Dockerfile \ + --build-arg "POWERCONTEXT_VERSION=${POWERCONTEXT_VERSION}" \ + --tag registry.example.com/powercontext:YOUR_REVISION . +docker push registry.example.com/powercontext:YOUR_REVISION +``` + +## Secrets and configuration + +Provision a Secret named `powercontext` in your namespace through your secret manager: + +| Key | Value | +| --- | --- | +| `database-url` | `mysql+aoceanbase://USER:PASSWORD@HOST:2881/DATABASE?charset=utf8mb4` | +| `bearer-token` | Nonempty static bearer credential | +| `cursor-signing-secret` | The same secret of at least 32 bytes for every replica | + +URL-encode credentials in the database URL. The database must meet PowerContext's +OceanBase requirements. For an existing database, complete the processing migration +before startup. Secret values are never stored in Helm values or a rendered Secret. + +Optional `providerSecret` names a second existing Secret whose keys are provider environment +variables such as `OPENAI_API_KEY`. Reserve it for provider credentials: do not put +PowerContext role, storage, authentication, or runtime overrides there. +Non-secret model settings belong in `inference`: + +```yaml +image: + repository: registry.example.com/powercontext + tag: YOUR_REVISION +existingSecret: powercontext +providerSecret: powercontext-provider +inference: + POWERCONTEXT_SERVER_INFERENCE_GENERATION_MODEL: openai:gpt-4.1-mini +``` + +Configure a complete embedding profile as described in the application configuration guide +when enabling vector search. The chart does not enable models or incur provider requests by default. +Use the same model configuration on both roles. Secrets referenced as environment variables +are read at Pod startup; coordinate a restart after rotation. Rotating the cursor key invalidates +outstanding cursors. + +```sh +helm lint deploy/helm/powercontext -f values.local.yaml +helm upgrade --install pc deploy/helm/powercontext \ + --namespace powercontext --create-namespace -f values.local.yaml --wait --timeout 10m +kubectl -n powercontext port-forward service/pc-powercontext 8000:8000 +``` + +The Service only selects API Pods. Ingress is optional; configure its host, TLS Secret and +controller-specific annotations when enabled. Bearer authentication and enforced Access are +always enabled. Use HTTPS or a trusted private network for clients. The Pod service account +token is not mounted; no Kubernetes API permissions are required. + +## Replicas and health + +`api.replicas` and `background.replicas` default to one. More background replicas are standby +candidates for the **global** database lease, not extra active supervisors. Each active supervisor +starts local workers, bounded by `background.maxWorkers`. Size memory for those child processes. +The chart deliberately fixes global mode; switching supervisor modes requires a separate migration. + +API probes use `/health/live` and `/health/ready`. Startup has a five-minute budget by default; +adjust probes for database startup times. Readiness can return HTTP 200 with degraded inference: +inspect its body and application errors rather than treating readiness as proof that model calls work. +`/metrics` is available through the API Service with the application's authentication rules. +It does not report the remote background supervisor's processing metrics. + +The background runner has **no HTTP listener**. There is no HTTP probe or background Service. +Kubernetes restarts an exited process, but Pod Ready only means its container started; it does +not prove database access, lease ownership, or progress. Monitor restart counts, background logs, +lease renewal and durable processing progress separately. Standby candidates are healthy without +owning the lease. Do not add a probe that kills every non-leader, or use `kill -0` as a claim of +processing health. A dedicated background health endpoint remains a separate runtime improvement. + +All API Pods share a cursor key and use `FASTMCP_STATELESS_HTTP=true` so MCP requests can cross +replicas. This transport does not offer persistent MCP sessions; clients must support stateless +Streamable HTTP. Local `/data` and `/tmp` are ephemeral writable directories; durable application +state lives in OceanBase. Local skill publication is disabled. + +## Upgrade and migration + +`Recreate` prevents rolling replacements within each Deployment. **It does not coordinate the two +Deployments**. Do not assume an ordinary `helm upgrade` is a safe mixed-version upgrade. + +1. Pause client writes and explicit processing triggers. Back up OceanBase and record the deployed + image, configuration and Secret versions. +2. If uninterrupted completion is required, wait for accepted processing to finish and verify durable + progress before stopping candidates. SIGTERM revokes leadership and stops child processes; it does + not promise every model call will drain. A longer grace period alone does not guarantee draining. +3. Scale both Deployments to zero and wait until all their Pods have terminated. Disable any external + reconciler that would restore replicas during maintenance. +4. With the target application's configuration and credentials, run the documented processing + migration `plan`, then `apply --maintenance-confirmed` when needed, then `verify`. Follow + `docs/en/docs/operate/artifact-processing-migration.md`; there is no automatic Helm migration hook. +5. Only after migration verification succeeds, upgrade the chart/image using your saved values and + restore the desired replica counts. Check readiness, authentication and background progress before + resuming traffic. + +A Helm rollback does not roll back the database. Assess schema compatibility and restore the database +backup when required before returning to an earlier application version. Uninstall removes the +workloads and ephemeral files; externally managed Secrets and OceanBase data remain. + +## Verification + +Render/configuration tests (requires Helm, pytest and the repository server dependencies): + +```sh +uv run --locked --extra server pytest tests/test_helm_chart.py tests/test_helm_acceptance.py -q +``` + +The Main workflow installs Helm 3.19.0, runs strict lint on the default and acceptance +profiles, and runs these rendering/configuration and acceptance CLI tests. The CLI tests +simulate kubectl responses to check preflight rejection and per-Pod verification. + +Cluster acceptance is intentionally opt-in. Confirm the target kubeconfig context before running +these commands. Use an isolated namespace, a disposable external OceanBase database, and an image +built from the application revision under test. Provision an existing Secret in that namespace +with the three keys described above; do not use a production database or production credentials. + +Create `values.acceptance.local.yaml` containing the image and Secret name, without Secret values: + +```yaml +image: + repository: registry.example.com/powercontext + tag: YOUR_REVISION +existingSecret: powercontext-acceptance +``` + +The supplied `values-acceptance.yaml` selects two API replicas, one initial background replica, +and the local `test` generation model so the background capability acquires a lease without an +external model provider. This profile is for deployment tests only; leave embedding providers +unconfigured. It does not validate real model quality or interrupted inference recovery. + +```sh +helm upgrade --install pc-acceptance deploy/helm/powercontext \ + --namespace powercontext-acceptance --create-namespace \ + -f values.acceptance.local.yaml \ + -f deploy/helm/powercontext/values-acceptance.yaml --wait --timeout 10m +uv run python tests/e2e/helm_acceptance.py \ + --namespace powercontext-acceptance --release pc-acceptance +``` + +This test writes one test Scope, replaces API Pods, scales background candidates from one to two, +and removes the original leader Pod. It checks HTTP/MCP authentication through the Service, the +Scope through each API replica after replacement, and database lease holder/generation change. It rejects +deployments with fewer than two configured API replicas before mutating the cluster, and requires at least +two Ready, non-terminating API Pods before creating its Scope and after API Pod replacement. Exactly one +initial background replica and a configured processing capability that acquires a lease are also required. +It returns background replicas to one. It does not delete its Scope or database. +Run only against a test deployment. A lease transition is not proof that all accepted inference work +completed: validate application processing and provider-specific recovery separately with representative +workloads. Multi-active-worker recovery and mixed-version rolling upgrades are outside this chart's scope. + +### Verified environment + +On 2026-10-05, the isolated cluster check passed on three Linux amd64 RKE2 nodes +(Kubernetes `v1.35.7+rke2r1`, Helm 3.19.0) with OceanBase CE 4.4.2.1. A fresh database +had zero tables before concurrent startup of two API Pods and one background Pod. +All initial and replacement application containers had zero restarts. + +The check verified authenticated HTTP/MCP and unauthenticated 401 responses through +the Service, persisted Scope readback from each replacement API Pod, and a new +background lease holder with generation increasing from 1 to 3. Background replicas +returned to one; the disposable namespaces and database were removed. + +The application image included separate local concurrent-startup fixes. These make +relational and FULLTEXT DDL idempotent, probe all four FULLTEXT projections with `MATCH`, +and retry narrowly identified authorization bootstrap conflicts after rollback. +The fixes must be integrated separately before this cold-start result applies to a +released image. + +| Component | Tested revision | +| --- | --- | +| Chart | `0.1.0`; application image supplied explicitly | +| Application base | `86537e4fbe2f3bec2d0a93c73caea4ec8ebaec24`, package `1.2.1.dev0` | +| Runtime patch SHA-256 | `3b38b98cd3df3cbe806d14bb8bb927ac61a0d07943f820a25ed14e8b1fcadda1` | +| Local image tag | `powercontext-fts-fix:86537e4f-20261005-v3` | +| Local image ID | `sha256:c95925cebba951d9ded320cc887eef9cc8ed0d35a768b7859d1f2b211336d6dd` | + +The image ID is a local container identity, not a published registry manifest digest. +Validation used the local `test` generation model and a native UTC tenant. It covers +this deployment configuration; model/provider and schema-migration acceptance belong +to their respective runtime changes. diff --git a/deploy/helm/powercontext/templates/_helpers.tpl b/deploy/helm/powercontext/templates/_helpers.tpl new file mode 100644 index 0000000000..35bde2d10b --- /dev/null +++ b/deploy/helm/powercontext/templates/_helpers.tpl @@ -0,0 +1,40 @@ +{{/* +Copyright (c) 2026 OceanBase. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +*/}} +{{- define "powercontext.name" -}} +{{- $name := printf "%s-powercontext" .Release.Name -}} +{{- if gt (len $name) 52 -}} +{{- printf "%s-%s" ($name | trunc 43 | trimSuffix "-") (.Release.Name | sha256sum | trunc 8) -}} +{{- else -}} +{{- $name -}} +{{- end -}} +{{- end -}} +{{- define "powercontext.labels" -}} +app.kubernetes.io/name: powercontext +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | quote }} +{{- end -}} +{{- define "powercontext.image" -}} +{{- $repo := required "image.repository is required" .Values.image.repository -}} +{{- if .Values.image.digest -}} +{{- printf "%s@%s" $repo .Values.image.digest -}} +{{- else -}} +{{- $tag := required "image.tag or image.digest is required" .Values.image.tag -}} +{{- if eq $tag "latest" -}}{{ fail "image.tag must be pinned; latest is unsupported" }}{{- end -}} +{{- printf "%s:%s" $repo $tag -}} +{{- end -}} +{{- end -}} diff --git a/deploy/helm/powercontext/templates/deployments.yaml b/deploy/helm/powercontext/templates/deployments.yaml new file mode 100644 index 0000000000..614f3872a4 --- /dev/null +++ b/deploy/helm/powercontext/templates/deployments.yaml @@ -0,0 +1,154 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +{{- $root := . -}} +{{- $secret := required "existingSecret is required" .Values.existingSecret -}} +{{- range $role := list "api" "background" }} +{{- $config := index $root.Values $role }} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "powercontext.name" $root }}-{{ $role }} + labels: + {{- include "powercontext.labels" $root | nindent 4 }} + app.kubernetes.io/component: {{ $role }} +spec: + replicas: {{ $config.replicas }} + # Upgrades are coordinated offline; this does not promise mixed-version support. + strategy: + type: Recreate + selector: + matchLabels: + app.kubernetes.io/instance: {{ $root.Release.Name }} + app.kubernetes.io/name: powercontext + app.kubernetes.io/component: {{ $role }} + template: + metadata: + labels: + app.kubernetes.io/instance: {{ $root.Release.Name }} + app.kubernetes.io/name: powercontext + app.kubernetes.io/component: {{ $role }} + spec: + automountServiceAccountToken: false + terminationGracePeriodSeconds: {{ $root.Values.terminationGracePeriodSeconds }} + securityContext: + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + fsGroup: 10001 + seccompProfile: + type: RuntimeDefault + {{- with $root.Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: powercontext + image: {{ include "powercontext.image" $root | quote }} + imagePullPolicy: {{ $root.Values.image.pullPolicy }} + args: ["server", "run"] + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + {{- with $root.Values.providerSecret }} + envFrom: + - secretRef: + name: {{ . | quote }} + {{- end }} + env: + - name: POWERCONTEXT_HOME + value: /data + - name: POWERCONTEXT_SERVER_DATABASE_KIND + value: oceanbase + - name: POWERCONTEXT_SERVER_DATABASE_URL + valueFrom: + secretKeyRef: + name: {{ $secret | quote }} + key: {{ $root.Values.secretKeys.databaseUrl | quote }} + - name: POWERCONTEXT_SERVER_AUTH_TOKEN + valueFrom: + secretKeyRef: + name: {{ $secret | quote }} + key: {{ $root.Values.secretKeys.bearerToken | quote }} + - name: POWERCONTEXT_SERVER_CURSOR_SIGNING_SECRET + valueFrom: + secretKeyRef: + name: {{ $secret | quote }} + key: {{ $root.Values.secretKeys.cursorSigningSecret | quote }} + - name: POWERCONTEXT_SERVER_ACCESS_MODE + value: enforced + - name: POWERCONTEXT_SERVER_AUTH_ENABLED + value: "true" + - name: POWERCONTEXT_SERVER_ALLOW_UNAUTHENTICATED_NON_LOOPBACK + value: "false" + - name: POWERCONTEXT_SERVER_HTTP_HOST + value: "0.0.0.0" + - name: POWERCONTEXT_SERVER_HTTP_PORT + value: "8000" + - name: POWERCONTEXT_SERVER_RUNTIME_ARTIFACT_PROCESSING_ROLE + value: {{ $role }} + - name: POWERCONTEXT_SERVER_RUNTIME_ARTIFACT_PROCESSING_SUPERVISOR_MODE + value: global + - name: POWERCONTEXT_SERVER_RUNTIME_ARTIFACT_PROCESSING_MAX_WORKERS + value: {{ $root.Values.background.maxWorkers | quote }} + - name: POWERCONTEXT_SERVER_EXTERNAL_SKILLS + value: '{"targets": []}' + - name: POWERCONTEXT_SERVER_LOGGING_FORMAT + value: json + # Avoid process-local MCP sessions when traffic crosses API replicas. + - name: FASTMCP_STATELESS_HTTP + value: "true" + {{- range $key, $value := $root.Values.inference }} + - name: {{ $key }} + value: {{ $value | quote }} + {{- end }} + {{- if eq $role "api" }} + ports: + - name: http + containerPort: 8000 + startupProbe: + {{- toYaml $config.startupProbe | nindent 12 }} + livenessProbe: + {{- toYaml $config.livenessProbe | nindent 12 }} + readinessProbe: + {{- toYaml $config.readinessProbe | nindent 12 }} + {{- end }} + resources: + {{- toYaml $config.resources | nindent 12 }} + volumeMounts: + - name: data + mountPath: /data + - name: tmp + mountPath: /tmp + volumes: + - name: data + emptyDir: {} + - name: tmp + emptyDir: {} + {{- with $root.Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with $root.Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with $root.Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} +{{- end }} diff --git a/deploy/helm/powercontext/templates/ingress.yaml b/deploy/helm/powercontext/templates/ingress.yaml new file mode 100644 index 0000000000..e0de9a2077 --- /dev/null +++ b/deploy/helm/powercontext/templates/ingress.yaml @@ -0,0 +1,45 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +{{- if .Values.ingress.enabled }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "powercontext.name" . }} + labels: + {{- include "powercontext.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- with .Values.ingress.className }} + ingressClassName: {{ . | quote }} + {{- end }} + {{- with .Values.ingress.tls }} + tls: + {{- toYaml . | nindent 4 }} + {{- end }} + rules: + - host: {{ required "ingress.host is required when ingress is enabled" .Values.ingress.host | quote }} + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: {{ include "powercontext.name" . }} + port: + name: http +{{- end }} diff --git a/deploy/helm/powercontext/templates/service.yaml b/deploy/helm/powercontext/templates/service.yaml new file mode 100644 index 0000000000..ed7b757f1e --- /dev/null +++ b/deploy/helm/powercontext/templates/service.yaml @@ -0,0 +1,30 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +apiVersion: v1 +kind: Service +metadata: + name: {{ include "powercontext.name" . }} + labels: + {{- include "powercontext.labels" . | nindent 4 }} +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: powercontext + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/component: api + ports: + - name: http + port: {{ .Values.service.port }} + targetPort: http diff --git a/deploy/helm/powercontext/values-acceptance.yaml b/deploy/helm/powercontext/values-acceptance.yaml new file mode 100644 index 0000000000..2d0a3e47c7 --- /dev/null +++ b/deploy/helm/powercontext/values-acceptance.yaml @@ -0,0 +1,23 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Only for an isolated namespace and disposable external OceanBase database. +# Supply the application image and existingSecret in your local values file. +api: + replicas: 2 +background: + replicas: 1 +inference: + # Enables background processing without calling an external model provider. + POWERCONTEXT_SERVER_INFERENCE_GENERATION_MODEL: test diff --git a/deploy/helm/powercontext/values.schema.json b/deploy/helm/powercontext/values.schema.json new file mode 100644 index 0000000000..5a5127198d --- /dev/null +++ b/deploy/helm/powercontext/values.schema.json @@ -0,0 +1,132 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "properties": { + "image": { + "type": "object", + "properties": { + "digest": { + "type": "string", + "pattern": "^$|^sha256:[a-f0-9]{64}$" + }, + "pullPolicy": { + "enum": [ + "Always", + "IfNotPresent", + "Never" + ] + }, + "repository": { + "type": "string" + }, + "tag": { + "type": "string" + } + }, + "additionalProperties": false + }, + "secretKeys": { + "type": "object", + "properties": { + "databaseUrl": { + "type": "string", + "minLength": 1 + }, + "bearerToken": { + "type": "string", + "minLength": 1 + }, + "cursorSigningSecret": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + }, + "inference": { + "type": "object", + "patternProperties": { + "^POWERCONTEXT_SERVER_INFERENCE_[A-Z_]+$": { + "type": "string" + } + }, + "additionalProperties": false + }, + "api": { + "type": "object", + "properties": { + "replicas": { + "type": "integer", + "minimum": 0 + }, + "resources": { + "type": "object" + }, + "startupProbe": { + "type": "object" + }, + "livenessProbe": { + "type": "object" + }, + "readinessProbe": { + "type": "object" + } + }, + "additionalProperties": false + }, + "background": { + "type": "object", + "properties": { + "replicas": { + "type": "integer", + "minimum": 0 + }, + "maxWorkers": { + "type": "integer", + "minimum": 1 + }, + "resources": { + "type": "object" + } + }, + "additionalProperties": false + }, + "service": { + "type": "object", + "properties": { + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + } + }, + "additionalProperties": false + }, + "ingress": { + "type": "object" + }, + "nodeSelector": { + "type": "object" + }, + "affinity": { + "type": "object" + }, + "imagePullSecrets": { + "type": "array" + }, + "tolerations": { + "type": "array" + }, + "existingSecret": { + "type": "string" + }, + "providerSecret": { + "type": "string" + }, + "terminationGracePeriodSeconds": { + "type": "integer", + "minimum": 1 + } + } +} diff --git a/deploy/helm/powercontext/values.yaml b/deploy/helm/powercontext/values.yaml new file mode 100644 index 0000000000..8e604868bf --- /dev/null +++ b/deploy/helm/powercontext/values.yaml @@ -0,0 +1,85 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Build an image from the application revision you intend to deploy. +# No public image publication is assumed. Supply an immutable tag or digest. +image: + repository: "" + tag: "" + digest: "" + pullPolicy: IfNotPresent +imagePullSecrets: [] + +# These keys must exist in a Secret in the release namespace. +existingSecret: "" +secretKeys: + databaseUrl: database-url + bearerToken: bearer-token + cursorSigningSecret: cursor-signing-secret + +# Additional Secret containing provider environment variables (e.g. OPENAI_API_KEY). +providerSecret: "" +# Non-secret model settings, using POWERCONTEXT_SERVER_INFERENCE_* variables. +inference: {} + +api: + replicas: 1 + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + memory: 1Gi + startupProbe: + httpGet: + path: /health/live + port: http + periodSeconds: 5 + failureThreshold: 60 + livenessProbe: + httpGet: + path: /health/live + port: http + periodSeconds: 10 + timeoutSeconds: 5 + readinessProbe: + httpGet: + path: /health/ready + port: http + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + +background: + replicas: 1 + maxWorkers: 2 + resources: + requests: + cpu: 100m + memory: 512Mi + limits: + memory: 2Gi + +terminationGracePeriodSeconds: 60 +service: + port: 8000 +ingress: + enabled: false + className: "" + annotations: {} + host: "" + tls: [] +nodeSelector: {} +tolerations: [] +affinity: {} diff --git a/docs/en/docs/operate/kubernetes.md b/docs/en/docs/operate/kubernetes.md new file mode 100644 index 0000000000..b3e7c041dc --- /dev/null +++ b/docs/en/docs/operate/kubernetes.md @@ -0,0 +1,25 @@ +--- +title: Deploy on Kubernetes +description: Deploy API and background roles with Helm and external OceanBase. +--- + +# Deploy on Kubernetes + +The [Helm chart](https://github.com/oceanbase/powercontext/tree/master/deploy/helm/powercontext) packages the current +`api` / `background` roles with an external OceanBase database. It includes Secret references, +API health probes, a ClusterIP Service, optional Ingress and configurable resources. + +The chart requires an explicitly pinned application image and existing Secrets. Background +replicas use a single active supervisor with database lease election. The background runner has +no HTTP health endpoint; Pod readiness alone does not establish processing health. + +Follow the chart's installation, coordinated upgrade and acceptance-test instructions. SQLite +and embedded seekdb remain single-process evaluation options outside this chart. See +[processing migration](artifact-processing-migration.md) before reusing an existing database. + +For cluster acceptance, use the chart's `values-acceptance.yaml` together with your local image and +Secret references. It configures two API replicas, one initial background replica, and the local +`test` generation model. The acceptance script rejects fewer than two configured API replicas and +requires two Ready API Pods before creating test data and after replacing API Pods. Run it only in +an isolated test namespace with a disposable external OceanBase database; it creates data, deletes +Pods, and changes background replica counts. A passing configuration test is not cluster acceptance. diff --git a/docs/en/docs/operate/meta.json b/docs/en/docs/operate/meta.json index 5e04efad00..5f6d7ed627 100644 --- a/docs/en/docs/operate/meta.json +++ b/docs/en/docs/operate/meta.json @@ -3,6 +3,7 @@ "pages": [ "index", "deploy-server", + "kubernetes", "connect-remote-server", "artifact-processing-migration", "observability", diff --git a/docs/zh/docs/operate/kubernetes.md b/docs/zh/docs/operate/kubernetes.md new file mode 100644 index 0000000000..77dcfc329a --- /dev/null +++ b/docs/zh/docs/operate/kubernetes.md @@ -0,0 +1,22 @@ +--- +title: 在 Kubernetes 上部署 +description: 使用 Helm 和外部 OceanBase 部署 API 与后台进程。 +--- + +# 在 Kubernetes 上部署 + +[Helm Chart](https://github.com/oceanbase/powercontext/tree/master/deploy/helm/powercontext) 封装当前的 `api` / `background` +角色,连接外部 OceanBase,提供已有 Secret 引用、API 健康探针、ClusterIP Service、可选 Ingress 和资源配置。 + +部署前需要指定固定版本的应用镜像并准备 Secret。多个 background 副本通过数据库租约选出一个活跃 +supervisor,其余作为候选实例;增加副本不会增加活跃 supervisor 的数量。当前 background runner 没有 +HTTP 健康端点,Pod Ready 不代表后台处理正常,需结合租约续期、日志和持久化处理进度判断。 + +安装、协调停机升级和集群验收步骤见 Chart 文档。Chart 不支持 SQLite 或内嵌 seekdb;这些后端应使用 +单进程评估部署。复用已有数据库前请先完成[处理状态迁移](artifact-processing-migration.md)。 + +集群验收时,将 Chart 中的 `values-acceptance.yaml` 与本地镜像和 Secret 引用配置一起使用。 +该配置启用两个 API 副本、一个初始 background 副本及本地 `test` 生成模型。验收脚本会拒绝少于 +两个 API 副本的配置,并在创建测试数据前及替换 API Pod 后确认至少两个 API Pod 处于 Ready 状态。 +仅在隔离测试 namespace 和可丢弃的外部 OceanBase 数据库上运行;脚本会写入数据、删除 Pod 并调整 +background 副本数。配置测试通过不代表真实集群验收通过。 diff --git a/docs/zh/docs/operate/meta.json b/docs/zh/docs/operate/meta.json index f8a7b06d97..497ad7262f 100644 --- a/docs/zh/docs/operate/meta.json +++ b/docs/zh/docs/operate/meta.json @@ -3,6 +3,7 @@ "pages": [ "index", "deploy-server", + "kubernetes", "connect-remote-server", "artifact-processing-migration", "observability", diff --git a/tests/e2e/helm_acceptance.py b/tests/e2e/helm_acceptance.py new file mode 100644 index 0000000000..2ab0a37bc1 --- /dev/null +++ b/tests/e2e/helm_acceptance.py @@ -0,0 +1,139 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Opt-in destructive Pod acceptance checks for an isolated Helm test deployment.""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import time +from pathlib import Path +from uuid import uuid4 + + +def check_failover(kubectl, pods, execute, rollout, background_name): + original_pod = pods("background")[0] + original_lease = None + for _ in range(30): + original_lease = execute({"action": "lease"}) + if original_lease: + break + time.sleep(2) + if not original_lease: + raise RuntimeError("No active global lease; configure a background capability before running this test") # noqa: TRY003 + try: + kubectl("scale", "deployment/" + background_name, "--replicas=2") + rollout("background") + if execute({"action": "lease"}) != original_lease: + raise RuntimeError("Leader changed before fault injection; rerun against a stable deployment") # noqa: TRY003 + kubectl("delete", "pod", original_pod, "--wait=true") + for _ in range(60): + lease = execute({"action": "lease"}) + if lease and lease[0] != original_lease[0] and lease[1] > original_lease[1]: + print("PASS: global lease acquired by a different holder with a newer generation") + break + time.sleep(2) + else: + raise RuntimeError("Background leader did not recover within 120 seconds") # noqa: TRY003 + finally: + kubectl("scale", "deployment/" + background_name, "--replicas=1") + rollout("background") + + +def validate_topology(deployments) -> None: + if set(deployments) != {"api", "background"}: + raise RuntimeError("Expected API and background Deployments for this release") # noqa: TRY003 + if deployments["api"]["spec"].get("replicas", 1) < 2: + raise RuntimeError("Acceptance requires api.replicas >= 2; use values-acceptance.yaml") # noqa: TRY003 + if deployments["background"]["spec"]["replicas"] != 1: + raise RuntimeError("Start with exactly one background replica in an isolated test deployment") # noqa: TRY003 + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--namespace", required=True) + parser.add_argument("--release", required=True) + args = parser.parse_args() + selector = f"app.kubernetes.io/name=powercontext,app.kubernetes.io/instance={args.release}" + + def kubectl(*command: str, source: str | None = None) -> str: + result = subprocess.run( + ["kubectl", "--namespace", args.namespace, *command], # noqa: S607 + input=source, + text=True, + capture_output=True, + check=False, + timeout=360, + ) + if result.returncode: + # Do not echo application errors that may contain connection details. + raise RuntimeError("kubectl operation failed; inspect the test deployment locally") # noqa: TRY003 + return result.stdout + + def pods(role: str, *, ready_only: bool = False) -> list[str]: + data = json.loads(kubectl("get", "pods", "-l", selector + f",app.kubernetes.io/component={role}", "-o", "json")) + return [ + item["metadata"]["name"] + for item in data["items"] + if not item["metadata"].get("deletionTimestamp") + and ( + not ready_only + or any( + condition["type"] == "Ready" and condition["status"] == "True" + for condition in item.get("status", {}).get("conditions", []) + ) + ) + ] + + def ready_api_pods() -> list[str]: + names = pods("api", ready_only=True) + if len(names) < 2: + raise RuntimeError("Acceptance requires at least two Ready, non-terminating API Pods") # noqa: TRY003 + return names + + def execute(payload: dict[str, str], pod: str | None = None): + pod = pod or ready_api_pods()[0] + source = Path(__file__).with_name("helm_probe.py").read_text() + return json.loads(kubectl("exec", "-i", pod, "--", "python", "-", json.dumps(payload), source=source)) + + def rollout(role: str) -> None: + kubectl("rollout", "status", f"deployment/{deployments[role]['metadata']['name']}", "--timeout=300s") + + items = json.loads(kubectl("get", "deployments", "-l", selector, "-o", "json"))["items"] + deployments = {item["metadata"]["labels"]["app.kubernetes.io/component"]: item for item in items} + validate_topology(deployments) + background = deployments["background"] + rollout("api") + rollout("background") + services = json.loads(kubectl("get", "services", "-l", selector, "-o", "json"))["items"] + if len(services) != 1: + raise RuntimeError("Expected one API Service for this release") # noqa: TRY003 + service = services[0] + base = f"http://{service['metadata']['name']}:{service['spec']['ports'][0]['port']}" + scope = execute({"base": base, "action": "create", "key": "helm-" + str(uuid4())}) + kubectl("delete", "pods", "-l", selector + ",app.kubernetes.io/component=api", "--wait=true") + rollout("api") + execute({"base": base, "action": "read", "scope": scope["scope_id"]}) + for pod in ready_api_pods(): + execute({"base": "http://127.0.0.1:8000", "action": "read", "scope": scope["scope_id"]}, pod) + print("PASS: authenticated HTTP/MCP through Service and persisted Scope readable from every replacement API Pod") + + check_failover(kubectl, pods, execute, rollout, background["metadata"]["name"]) + print("Test Scope retained:", scope["scope_id"]) + + +if __name__ == "__main__": + main() diff --git a/tests/e2e/helm_probe.py b/tests/e2e/helm_probe.py new file mode 100644 index 0000000000..df118a6f34 --- /dev/null +++ b/tests/e2e/helm_probe.py @@ -0,0 +1,129 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Run HTTP/MCP and read-only lease checks inside a test API Pod.""" + +from __future__ import annotations + +import asyncio +import json +import os +import sys +import urllib.error +import urllib.request +from typing import Any + +from sqlalchemy import text +from sqlalchemy.dialects import registry +from sqlalchemy.ext.asyncio import create_async_engine + + +def request(base: str, path: str, body: dict[str, Any] | None = None, *, authenticated: bool = True): + headers = {"Accept": "application/json, text/event-stream"} + if authenticated: + headers["Authorization"] = "Bearer " + os.environ["POWERCONTEXT_SERVER_AUTH_TOKEN"] + if body is not None: + headers["Content-Type"] = "application/json" + req = urllib.request.Request( # noqa: S310 -- URL comes from the test Service. + base + path, data=None if body is None else json.dumps(body).encode(), headers=headers + ) + try: + with urllib.request.urlopen(req, timeout=30) as response: # noqa: S310 + raw = response.read().decode() + if raw.startswith(("event:", "data:")): + raw = next(line[6:] for line in raw.splitlines() if line.startswith("data: ")) + return response.status, json.loads(raw), dict(response.headers) + except urllib.error.HTTPError as error: + return error.code, {}, {} + + +def create_scope(payload: dict[str, str]): + base = payload["base"] + assert request(base, "/v1/scopes", authenticated=False)[0] == 401, "HTTP must require authentication" + rpc = { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-03-26", + "capabilities": {}, + "clientInfo": {"name": "helm-acceptance", "version": "1"}, + }, + } + assert request(base, "/mcp/", rpc, authenticated=False)[0] == 401, "MCP must require authentication" + status, result, headers = request(base, "/mcp/", rpc) + assert status == 200 and "result" in result, "MCP initialization failed" + assert not any(key.lower() == "mcp-session-id" for key in headers), "MCP must be stateless" + assert request(base, "/mcp/", {"jsonrpc": "2.0", "id": 2, "method": "ping"})[0] == 200 + status, scope, _ = request( + base, + "/v1/scopes", + { + "title": "Helm acceptance", + "summary": "Disposable deployment persistence check", + "idempotency_key": payload["key"], + }, + ) + assert status == 201, "Scope creation failed" + return scope + + +def read_scope(payload: dict[str, str]): + status, scope, _ = request(payload["base"], "/v1/scopes/" + payload["scope"]) + assert status == 200 and scope["scope_id"] == payload["scope"], "Scope did not survive API replacement" + status, result, _ = request( + payload["base"], + "/mcp/", + { + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": {"name": "get_scope", "arguments": {"scope_id": payload["scope"]}}, + }, + ) + assert status == 200 and "result" in result and not result["result"].get("isError", False), "MCP get_scope failed" + assert payload["scope"] in json.dumps(result["result"]), "MCP returned the wrong Scope" + return scope + + +async def read_lease(): + registry.register("mysql.aoceanbase", "pyobvector", "AsyncOceanBaseDialect") + engine = create_async_engine(os.environ["POWERCONTEXT_SERVER_DATABASE_URL"]) + try: + async with engine.connect() as connection: + result = await connection.execute( + text( + "SELECT holder_id, supervisor_generation FROM pc_artifact_processing_leases " + "WHERE supervisor_group='global' AND lease_expires_at > UTC_TIMESTAMP()" + ) + ) + row = result.first() + return None if row is None else list(row) + finally: + await engine.dispose() + + +def main() -> None: + payload = json.loads(sys.argv[1]) + if payload["action"] == "lease": + result = asyncio.run(read_lease()) + elif payload["action"] == "create": + result = create_scope(payload) + else: + result = read_scope(payload) + print(json.dumps(result)) + + +if __name__ == "__main__": + main() diff --git a/tests/test_helm_acceptance.py b/tests/test_helm_acceptance.py new file mode 100644 index 0000000000..5905288376 --- /dev/null +++ b/tests/test_helm_acceptance.py @@ -0,0 +1,122 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Acceptance CLI safeguards with simulated kubectl responses, without a cluster.""" + +import json +import subprocess +import sys + +import pytest + +from tests.e2e import helm_acceptance + + +def api_pod(name, *, ready=True, terminating=False): + metadata = {"name": name} + if terminating: + metadata["deletionTimestamp"] = "2026-10-05T00:00:00Z" + return { + "metadata": metadata, + "status": {"conditions": [{"type": "Ready", "status": "True" if ready else "False"}]}, + } + + +def simulate_probe(state, args): + payload = json.loads(args[-1]) + if payload["action"] == "create": + state["mutations"].append(["create_scope"]) + if payload.get("base") == "http://127.0.0.1:8000": + state["direct_reads"].append(args[2]) + return state["lease"] if payload["action"] == "lease" else {"scope_id": "test-scope"} + + +@pytest.fixture +def cluster(monkeypatch): + mutations: list[list[str]] = [] + state = { + "api_replicas": 2, + "api_pods": [api_pod("api-old-0"), api_pod("api-old-1")], + "replacement_pods": [api_pod("api-new-0"), api_pod("api-new-1")], + "lease": ["original", 1], + "mutations": mutations, + "direct_reads": [], + } + + def run(command, **kwargs): + args = command[3:] # kubectl --namespace helm-test + if args[:2] == ["get", "deployments"]: + response = { + "items": [ + { + "metadata": {"name": role, "labels": {"app.kubernetes.io/component": role}}, + "spec": {"replicas": state["api_replicas"] if role == "api" else 1}, + } + for role in ("api", "background") + ] + } + elif args[:2] == ["get", "pods"]: + response = { + "items": state["api_pods"] + if args[3].endswith("component=api") + else [{"metadata": {"name": "background-original"}}] + } + elif args[:2] == ["get", "services"]: + response = {"items": [{"metadata": {"name": "pc"}, "spec": {"ports": [{"port": 8000}]}}]} + elif args[0] == "rollout": + response = {} + elif args[0] in ("delete", "scale"): + mutations.append(args) + if args[:2] == ["delete", "pods"]: + state["api_pods"] = state["replacement_pods"] + elif args[:2] == ["delete", "pod"]: + state["lease"] = ["replacement", 2] + response = {} + elif args[0] == "exec": + response = simulate_probe(state, args) + else: + pytest.fail(f"Unexpected kubectl command: {args}") + return subprocess.CompletedProcess(command, 0, json.dumps(response), "") + + monkeypatch.setattr(helm_acceptance.subprocess, "run", run) + monkeypatch.setattr(sys, "argv", ["helm_acceptance.py", "--namespace", "helm-test", "--release", "pc"]) + return state + + +def test_rejects_insufficient_api_replicas_before_mutation(cluster): + cluster["api_replicas"] = 1 + with pytest.raises(RuntimeError, match=r"api\.replicas >= 2"): + helm_acceptance.main() + assert cluster["mutations"] == [] + + +@pytest.mark.parametrize("second", [None, api_pod("unready", ready=False), api_pod("terminating", terminating=True)]) +def test_requires_two_ready_live_api_pods_before_mutation(cluster, second): + cluster["api_pods"] = [api_pod("api-0")] + ([] if second is None else [second]) + with pytest.raises(RuntimeError, match="two Ready, non-terminating API Pods"): + helm_acceptance.main() + assert cluster["mutations"] == [] + + +def test_rechecks_api_replicas_after_replacement(cluster, capsys): + cluster["replacement_pods"] = [api_pod("api-new-0")] + with pytest.raises(RuntimeError, match="two Ready, non-terminating API Pods"): + helm_acceptance.main() + assert "PASS:" not in capsys.readouterr().out + + +def test_checks_each_replacement_api_pod(cluster, capsys): + helm_acceptance.main() + assert set(cluster["direct_reads"]) == {"api-new-0", "api-new-1"} + assert "authenticated HTTP/MCP through Service" in capsys.readouterr().out diff --git a/tests/test_helm_chart.py b/tests/test_helm_chart.py new file mode 100644 index 0000000000..8b5cd73949 --- /dev/null +++ b/tests/test_helm_chart.py @@ -0,0 +1,149 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Deployment configuration contracts rendered by Helm.""" + +import os +import shutil +import subprocess +from pathlib import Path + +import pytest +import yaml + +from powercontext.server.settings import ServerSettings + +CHART = Path(__file__).resolve().parents[1] / "deploy/helm/powercontext" +pytestmark = pytest.mark.skipif(shutil.which("helm") is None, reason="Helm is required") + + +def render(*overrides, release="test", values=None): + command = [ + "helm", + "template", + release, + str(CHART), + "--set", + "image.repository=example/powercontext", + "--set", + "image.tag=revision", + "--set", + "existingSecret=credentials", + ] + if values is not None: + command.extend(["--values", str(values)]) + for value in overrides: + command.extend(["--set", value]) + return subprocess.run(command, text=True, capture_output=True, check=False) + + +def test_role_configuration_and_service_isolation(monkeypatch): + result = render("api.replicas=2", "background.replicas=2") + assert result.returncode == 0, result.stderr + documents = [item for item in yaml.safe_load_all(result.stdout) if item] + deployments = [item for item in documents if item["kind"] == "Deployment"] + assert {item["metadata"]["labels"]["app.kubernetes.io/component"] for item in deployments} == {"api", "background"} + assert len(deployments) == 2 + service = next(item for item in documents if item["kind"] == "Service") + assert service["spec"]["selector"]["app.kubernetes.io/component"] == "api" + for key in os.environ: + if key.startswith("POWERCONTEXT_SERVER_"): + monkeypatch.delenv(key) + secrets = { + "database-url": "mysql+aoceanbase://test:test@localhost:2881/test?charset=utf8mb4", + "bearer-token": "helm-test-token", + "cursor-signing-secret": "x" * 32, + } + for deployment in deployments: + role = deployment["metadata"]["labels"]["app.kubernetes.io/component"] + spec = deployment["spec"]["template"]["spec"] + container = spec["containers"][0] + for entry in container["env"]: + value = entry.get("value") + if value is None: + ref = entry["valueFrom"]["secretKeyRef"] + assert ref["name"] == "credentials" + value = secrets[ref["key"]] + monkeypatch.setenv(entry["name"], value) + settings = ServerSettings() + assert settings.database.kind == "oceanbase" + assert settings.runtime.artifact_processing_role == role + assert settings.access.mode == "enforced" + assert settings.auth.enabled + assert settings.cursor_signing_secret is not None + assert settings.cursor_signing_secret.get_secret_value() == secrets["cursor-signing-secret"] + assert settings.external_skills.targets == () + assert os.environ["FASTMCP_STATELESS_HTTP"] == "true" + assert spec["automountServiceAccountToken"] is False + assert spec["securityContext"]["seccompProfile"]["type"] == "RuntimeDefault" + assert deployment["spec"]["strategy"]["type"] == "Recreate" + if role == "api": + assert container["readinessProbe"]["httpGet"]["path"] == "/health/ready" + else: + assert "ports" not in container + assert "readinessProbe" not in container + + +@pytest.mark.parametrize( + "override", + [ + "image.tag=latest", + "image.repository=", + "existingSecret=", + "database.kind=sqlite", + "api.replicas=-1", + "background.maxWorkers=0", + "ingress.enabled=true", + "inference.POWERCONTEXT_SERVER_DATABASE_KIND=sqlite", + ], +) +def test_unsupported_configuration_is_rejected(override): + assert render(override).returncode != 0 + + +def test_digest_and_ingress(): + digest = "sha256:" + "a" * 64 + result = render("image.digest=" + digest, "ingress.enabled=true", "ingress.host=context.example.com") + assert result.returncode == 0, result.stderr + documents = [item for item in yaml.safe_load_all(result.stdout) if item] + ingress = next(item for item in documents if item["kind"] == "Ingress") + assert ingress["spec"]["rules"][0]["host"] == "context.example.com" + for deployment in (item for item in documents if item["kind"] == "Deployment"): + assert deployment["spec"]["template"]["spec"]["containers"][0]["image"].endswith("@" + digest) + + +def test_long_release_names_remain_distinct(): + names = [] + for suffix in ("b", "c"): + result = render(release="a" * 52 + suffix) + assert result.returncode == 0, result.stderr + documents = [item for item in yaml.safe_load_all(result.stdout) if item] + resources = {item["metadata"]["name"] for item in documents} + assert all(len(name) <= 63 for name in resources) + names.append(resources) + assert names[0].isdisjoint(names[1]) + + +def test_acceptance_profile_renders_two_apis_and_one_background(): + result = render(values=CHART / "values-acceptance.yaml") + assert result.returncode == 0, result.stderr + documents = [item for item in yaml.safe_load_all(result.stdout) if item] + replicas = {} + for deployment in (item for item in documents if item["kind"] == "Deployment"): + role = deployment["metadata"]["labels"]["app.kubernetes.io/component"] + replicas[role] = deployment["spec"]["replicas"] + env = deployment["spec"]["template"]["spec"]["containers"][0]["env"] + model = next(item for item in env if item["name"] == "POWERCONTEXT_SERVER_INFERENCE_GENERATION_MODEL") + assert model["value"] == "test" + assert replicas == {"api": 2, "background": 1} From 98d2d78628882dc5f7ec422c103d6c1744d08ef5 Mon Sep 17 00:00:00 2001 From: TsukiKage Date: Tue, 6 Oct 2026 15:28:03 +0800 Subject: [PATCH 2/3] fix(helm): quote release names in labels and selectors --- .../helm/powercontext/templates/_helpers.tpl | 2 +- .../powercontext/templates/deployments.yaml | 4 +-- .../helm/powercontext/templates/service.yaml | 2 +- tests/test_helm_chart.py | 29 +++++++++++++++++++ 4 files changed, 33 insertions(+), 4 deletions(-) diff --git a/deploy/helm/powercontext/templates/_helpers.tpl b/deploy/helm/powercontext/templates/_helpers.tpl index 35bde2d10b..e5add32c12 100644 --- a/deploy/helm/powercontext/templates/_helpers.tpl +++ b/deploy/helm/powercontext/templates/_helpers.tpl @@ -24,7 +24,7 @@ limitations under the License. {{- end -}} {{- define "powercontext.labels" -}} app.kubernetes.io/name: powercontext -app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/instance: {{ .Release.Name | quote }} app.kubernetes.io/managed-by: {{ .Release.Service }} helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | quote }} {{- end -}} diff --git a/deploy/helm/powercontext/templates/deployments.yaml b/deploy/helm/powercontext/templates/deployments.yaml index 614f3872a4..5ae270da7a 100644 --- a/deploy/helm/powercontext/templates/deployments.yaml +++ b/deploy/helm/powercontext/templates/deployments.yaml @@ -31,13 +31,13 @@ spec: type: Recreate selector: matchLabels: - app.kubernetes.io/instance: {{ $root.Release.Name }} + app.kubernetes.io/instance: {{ $root.Release.Name | quote }} app.kubernetes.io/name: powercontext app.kubernetes.io/component: {{ $role }} template: metadata: labels: - app.kubernetes.io/instance: {{ $root.Release.Name }} + app.kubernetes.io/instance: {{ $root.Release.Name | quote }} app.kubernetes.io/name: powercontext app.kubernetes.io/component: {{ $role }} spec: diff --git a/deploy/helm/powercontext/templates/service.yaml b/deploy/helm/powercontext/templates/service.yaml index ed7b757f1e..a693a0968e 100644 --- a/deploy/helm/powercontext/templates/service.yaml +++ b/deploy/helm/powercontext/templates/service.yaml @@ -22,7 +22,7 @@ spec: type: ClusterIP selector: app.kubernetes.io/name: powercontext - app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/instance: {{ .Release.Name | quote }} app.kubernetes.io/component: api ports: - name: http diff --git a/tests/test_helm_chart.py b/tests/test_helm_chart.py index 8b5cd73949..4914973c71 100644 --- a/tests/test_helm_chart.py +++ b/tests/test_helm_chart.py @@ -135,6 +135,35 @@ def test_long_release_names_remain_distinct(): assert names[0].isdisjoint(names[1]) +@pytest.mark.parametrize("release", ["true", "false", "on", "off"]) +def test_boolean_style_release_names_keep_string_labels_and_matching_selectors(release): + result = render("ingress.enabled=true", "ingress.host=context.example.com", release=release) + assert result.returncode == 0, result.stderr + documents = [item for item in yaml.safe_load_all(result.stdout) if item] + for resource in documents: + labels = resource["metadata"]["labels"] + assert all(isinstance(value, str) for value in labels.values()) + assert labels["app.kubernetes.io/instance"] == release + deployments = [item for item in documents if item["kind"] == "Deployment"] + for deployment in deployments: + selector = deployment["spec"]["selector"]["matchLabels"] + pod_labels = deployment["spec"]["template"]["metadata"]["labels"] + assert all(isinstance(value, str) for value in selector.values()) + assert all(isinstance(value, str) for value in pod_labels.values()) + assert selector["app.kubernetes.io/instance"] == release + assert selector.items() <= pod_labels.items() + service = next(item for item in documents if item["kind"] == "Service") + selector = service["spec"]["selector"] + assert all(isinstance(value, str) for value in selector.values()) + assert selector["app.kubernetes.io/instance"] == release + selected_roles = { + deployment["metadata"]["labels"]["app.kubernetes.io/component"] + for deployment in deployments + if selector.items() <= deployment["spec"]["template"]["metadata"]["labels"].items() + } + assert selected_roles == {"api"} + + def test_acceptance_profile_renders_two_apis_and_one_background(): result = render(values=CHART / "values-acceptance.yaml") assert result.returncode == 0, result.stderr From 0518869ff3c5f97e8da8f3fa983a20ebadb12acc Mon Sep 17 00:00:00 2001 From: TsukiKage Date: Fri, 30 Oct 2026 15:58:47 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(helm):=20=E5=AE=8C=E5=96=84=E8=B7=A8?= =?UTF-8?q?=E5=89=AF=E6=9C=AC=E9=AA=8C=E6=94=B6=E4=B8=8E=E9=9B=86=E7=BE=A4?= =?UTF-8?q?=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除无效 MCP 环境变量及对应断言,补充跨 Pod 游标续页验收和显式测试确认。增加标签、注解、安全上下文与 Ingress TLS 校验,修正 worker 配额及历史集群证据说明。 --- deploy/helm/powercontext/README.md | 63 +++++- .../helm/powercontext/templates/_helpers.tpl | 3 + .../powercontext/templates/deployments.yaml | 22 +- deploy/helm/powercontext/values.schema.json | 202 +++++++++++++++++- deploy/helm/powercontext/values.yaml | 18 ++ docs/en/docs/operate/kubernetes.md | 16 +- docs/zh/docs/operate/kubernetes.md | 14 +- tests/e2e/helm_acceptance.py | 32 ++- tests/e2e/helm_probe.py | 14 ++ tests/test_helm_acceptance.py | 105 ++++++++- tests/test_helm_chart.py | 54 ++++- 11 files changed, 510 insertions(+), 33 deletions(-) diff --git a/deploy/helm/powercontext/README.md b/deploy/helm/powercontext/README.md index 4f01477372..a56df6010d 100644 --- a/deploy/helm/powercontext/README.md +++ b/deploy/helm/powercontext/README.md @@ -131,11 +131,49 @@ controller-specific annotations when enabled. Bearer authentication and enforced always enabled. Use HTTPS or a trusted private network for clients. The Pod service account token is not mounted; no Kubernetes API permissions are required. +## Cluster policy overrides + +`commonLabels` adds string-valued labels to resources and Pods without changing selectors. +The chart's identity labels (`app.kubernetes.io/name`, `instance`, `component`, `managed-by` +and `helm.sh/chart`) are reserved. `podAnnotations` adds string-valued Pod annotations. +`podSecurityContext` and container `securityContext` expose the fields listed in +`values.schema.json`; their defaults retain non-root execution, RuntimeDefault seccomp, +dropped capabilities and a read-only root filesystem. + +For clusters that assign a UID/GID through admission, remove the fixed identity fields +with Helm's `null` overrides. For example, an OpenShift SCC may assign the identity: + +```yaml +commonLabels: + cost-center: platform +podAnnotations: + example.com/owner: platform +podSecurityContext: + runAsUser: null + runAsGroup: null + fsGroup: null +``` + +Validate the rendered Pod against your cluster's admission policy and volume permissions. +The chart does not create PDBs or topology-spread constraints; manage any required PDB +separately and use the existing `affinity`, `nodeSelector` and `tolerations` for placement. +Ingress annotations must be strings; `ingress.tls` is an array of entries with a nonempty +`secretName` and optional string-array `hosts`. + ## Replicas and health `api.replicas` and `background.replicas` default to one. More background replicas are standby candidates for the **global** database lease, not extra active supervisors. Each active supervisor -starts local workers, bounded by `background.maxWorkers`. Size memory for those child processes. +starts local workers with per-family quotas. `background.maxWorkers` sets the legacy +`artifact_processing_max_workers` alias and the `topic_memory_max_workers` quota; it is +not a global process limit or an additional worker pool. Other quotas retain their defaults: +Memory, Experience, Skill, Handoff and Prompt each allow one worker, and Profile allows four. +With `maxWorkers=2` and all seven built-in families enabled, their quotas sum to 11 workers. +The acceptance profile's `test` generation model enables four families with quotas totaling eight; +without a generation model, no families are enabled by default. +This is a quota estimate, not measured concurrency: enabled capabilities, available work and +scheduling determine actual occupancy. Size memory for the supervisor and its child processes; +the default 2 GiB limit is a starting configuration, not a capacity guarantee. The chart deliberately fixes global mode; switching supervisor modes requires a separate migration. API probes use `/health/live` and `/health/ready`. Startup has a five-minute budget by default; @@ -151,10 +189,11 @@ lease renewal and durable processing progress separately. Standby candidates are owning the lease. Do not add a probe that kills every non-leader, or use `kill -0` as a claim of processing health. A dedicated background health endpoint remains a separate runtime improvement. -All API Pods share a cursor key and use `FASTMCP_STATELESS_HTTP=true` so MCP requests can cross -replicas. This transport does not offer persistent MCP sessions; clients must support stateless -Streamable HTTP. Local `/data` and `/tmp` are ephemeral writable directories; durable application -state lives in OceanBase. Local skill publication is disabled. +All API Pods share a cursor key so paginated requests can cross replicas. The application +runtime mounts MCP as stateless Streamable HTTP with `stateless_http=True`; this is a runtime +property, not a Chart environment setting. This transport does not offer persistent MCP sessions; +clients must support stateless Streamable HTTP. Local `/data` and `/tmp` are ephemeral writable +directories; durable application state lives in OceanBase. Local skill publication is disabled. ## Upgrade and migration @@ -230,16 +269,19 @@ helm upgrade pc-acceptance deploy/helm/powercontext \ --namespace powercontext-acceptance -f values.acceptance.local.yaml \ -f deploy/helm/powercontext/values-acceptance.yaml --wait --timeout 10m uv run python tests/e2e/helm_acceptance.py \ - --namespace powercontext-acceptance --release pc-acceptance + --namespace powercontext-acceptance --release pc-acceptance --confirm-disposable ``` -This test writes one test Scope, replaces API Pods, scales background candidates from one to two, +The explicit `--confirm-disposable` flag is required before any cluster access. +This test writes a parent Scope and two child Scopes, replaces API Pods, scales background candidates from one to two, and removes the original leader Pod. It checks HTTP/MCP authentication through the Service, the -Scope through each API replica after replacement, and database lease holder/generation change. It rejects +parent Scope through each API replica after replacement, cross-Pod cursor continuation, +and database lease holder/generation change. It lists the children with `limit=1` on one API Pod, +then resumes that cursor on every other API Pod and checks for skipped or repeated Scopes. It rejects deployments with fewer than two configured API replicas before mutating the cluster, and requires at least two Ready, non-terminating API Pods before creating its Scope and after API Pod replacement. Exactly one initial background replica and a configured processing capability that acquires a lease are also required. -It returns background replicas to one. It does not delete its Scope or database. +It returns background replicas to one. It does not delete its test Scopes or database. Run only against a test deployment. A lease transition is not proof that all accepted inference work completed: validate application processing and provider-specific recovery separately with representative workloads. Multi-active-worker recovery and mixed-version rolling upgrades are outside this chart's scope. @@ -275,5 +317,6 @@ the historical empty-database startup result does not certify it. The image ID is a local container identity, not a published registry manifest digest. Validation used the local `test` generation model and a native UTC tenant. It covers -this deployment configuration; model/provider and schema-migration acceptance belong +the historical deployment configuration only, including neither the current startup sequence +nor cross-Pod cursor continuation; model/provider and schema-migration acceptance belong to their respective runtime changes. diff --git a/deploy/helm/powercontext/templates/_helpers.tpl b/deploy/helm/powercontext/templates/_helpers.tpl index e5add32c12..375256e76f 100644 --- a/deploy/helm/powercontext/templates/_helpers.tpl +++ b/deploy/helm/powercontext/templates/_helpers.tpl @@ -23,6 +23,9 @@ limitations under the License. {{- end -}} {{- end -}} {{- define "powercontext.labels" -}} +{{- with .Values.commonLabels }} +{{ toYaml . }} +{{ end -}} app.kubernetes.io/name: powercontext app.kubernetes.io/instance: {{ .Release.Name | quote }} app.kubernetes.io/managed-by: {{ .Release.Service }} diff --git a/deploy/helm/powercontext/templates/deployments.yaml b/deploy/helm/powercontext/templates/deployments.yaml index 5ae270da7a..376d68727e 100644 --- a/deploy/helm/powercontext/templates/deployments.yaml +++ b/deploy/helm/powercontext/templates/deployments.yaml @@ -37,19 +37,17 @@ spec: template: metadata: labels: - app.kubernetes.io/instance: {{ $root.Release.Name | quote }} - app.kubernetes.io/name: powercontext + {{- include "powercontext.labels" $root | nindent 8 }} app.kubernetes.io/component: {{ $role }} + {{- with $root.Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} spec: automountServiceAccountToken: false terminationGracePeriodSeconds: {{ $root.Values.terminationGracePeriodSeconds }} securityContext: - runAsNonRoot: true - runAsUser: 10001 - runAsGroup: 10001 - fsGroup: 10001 - seccompProfile: - type: RuntimeDefault + {{- toYaml $root.Values.podSecurityContext | nindent 8 }} {{- with $root.Values.imagePullSecrets }} imagePullSecrets: {{- toYaml . | nindent 8 }} @@ -60,10 +58,7 @@ spec: imagePullPolicy: {{ $root.Values.image.pullPolicy }} args: ["server", "run"] securityContext: - allowPrivilegeEscalation: false - readOnlyRootFilesystem: true - capabilities: - drop: ["ALL"] + {{- toYaml $root.Values.securityContext | nindent 12 }} {{- with $root.Values.providerSecret }} envFrom: - secretRef: @@ -109,9 +104,6 @@ spec: value: '{"targets": []}' - name: POWERCONTEXT_SERVER_LOGGING_FORMAT value: json - # Avoid process-local MCP sessions when traffic crosses API replicas. - - name: FASTMCP_STATELESS_HTTP - value: "true" {{- range $key, $value := $root.Values.inference }} - name: {{ $key }} value: {{ $value | quote }} diff --git a/deploy/helm/powercontext/values.schema.json b/deploy/helm/powercontext/values.schema.json index 5a5127198d..33f8a82da8 100644 --- a/deploy/helm/powercontext/values.schema.json +++ b/deploy/helm/powercontext/values.schema.json @@ -104,7 +104,48 @@ "additionalProperties": false }, "ingress": { - "type": "object" + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "className": { + "type": "string" + }, + "host": { + "type": "string" + }, + "annotations": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "tls": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "secretName" + ], + "properties": { + "secretName": { + "type": "string", + "minLength": 1 + }, + "hosts": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + } + } + } + } }, "nodeSelector": { "type": "object" @@ -127,6 +168,165 @@ "terminationGracePeriodSeconds": { "type": "integer", "minimum": 1 + }, + "commonLabels": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "not": { + "enum": [ + "app.kubernetes.io/name", + "app.kubernetes.io/instance", + "app.kubernetes.io/component", + "app.kubernetes.io/managed-by", + "helm.sh/chart" + ] + } + } + }, + "podAnnotations": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "podSecurityContext": { + "type": "object", + "additionalProperties": false, + "properties": { + "runAsUser": { + "type": "integer", + "minimum": 0 + }, + "runAsGroup": { + "type": "integer", + "minimum": 0 + }, + "runAsNonRoot": { + "type": "boolean" + }, + "fsGroup": { + "type": "integer", + "minimum": 0 + }, + "fsGroupChangePolicy": { + "enum": [ + "Always", + "OnRootMismatch" + ] + }, + "supplementalGroups": { + "type": "array", + "items": { + "type": "integer", + "minimum": 0 + } + }, + "seccompProfile": { + "type": "object", + "additionalProperties": false, + "required": [ + "type" + ], + "properties": { + "type": { + "enum": [ + "RuntimeDefault", + "Localhost", + "Unconfined" + ] + }, + "localhostProfile": { + "type": "string", + "minLength": 1 + } + } + }, + "seLinuxOptions": { + "type": "object", + "additionalProperties": false, + "properties": { + "user": { + "type": "string" + }, + "role": { + "type": "string" + }, + "type": { + "type": "string" + }, + "level": { + "type": "string" + } + } + } + } + }, + "securityContext": { + "type": "object", + "additionalProperties": false, + "properties": { + "runAsUser": { + "type": "integer", + "minimum": 0 + }, + "runAsGroup": { + "type": "integer", + "minimum": 0 + }, + "runAsNonRoot": { + "type": "boolean" + }, + "privileged": { + "type": "boolean" + }, + "allowPrivilegeEscalation": { + "type": "boolean" + }, + "readOnlyRootFilesystem": { + "type": "boolean" + }, + "seccompProfile": { + "type": "object", + "additionalProperties": false, + "required": [ + "type" + ], + "properties": { + "type": { + "enum": [ + "RuntimeDefault", + "Localhost", + "Unconfined" + ] + }, + "localhostProfile": { + "type": "string", + "minLength": 1 + } + } + }, + "capabilities": { + "type": "object", + "additionalProperties": false, + "properties": { + "add": { + "type": "array", + "items": { + "type": "string" + } + }, + "drop": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } } } } diff --git a/deploy/helm/powercontext/values.yaml b/deploy/helm/powercontext/values.yaml index 8e604868bf..f2fe49eebc 100644 --- a/deploy/helm/powercontext/values.yaml +++ b/deploy/helm/powercontext/values.yaml @@ -21,6 +21,23 @@ image: pullPolicy: IfNotPresent imagePullSecrets: [] +# Extra resource and Pod labels; chart identity/selector labels are reserved. +commonLabels: {} +podAnnotations: {} +# Set UID/GID fields to null to let cluster admission assign them (e.g. OpenShift). +podSecurityContext: + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + fsGroup: 10001 + seccompProfile: + type: RuntimeDefault +securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + # These keys must exist in a Secret in the release namespace. existingSecret: "" secretKeys: @@ -63,6 +80,7 @@ api: background: replicas: 1 + # Legacy alias for topic-memory's worker quota, not a global process limit. maxWorkers: 2 resources: requests: diff --git a/docs/en/docs/operate/kubernetes.md b/docs/en/docs/operate/kubernetes.md index da2d842776..a0eeab655d 100644 --- a/docs/en/docs/operate/kubernetes.md +++ b/docs/en/docs/operate/kubernetes.md @@ -12,6 +12,17 @@ API health probes, a ClusterIP Service, optional Ingress and configurable resour The chart requires an explicitly pinned application image and existing Secrets. Background replicas use a single active supervisor with database lease election. The background runner has no HTTP health endpoint; Pod readiness alone does not establish processing health. +`background.maxWorkers` controls the Topic Memory quota through a legacy alias, not the +total process count. With `maxWorkers=2` and all seven built-in families enabled, their quotas +sum to 11; the acceptance profile's `test` model enables four families with quotas totaling eight. +Without a generation model, no families are enabled by default. Actual concurrency depends on +enabled capabilities and scheduling. Size resources accordingly. + +Use `commonLabels`, `podAnnotations`, `podSecurityContext` and `securityContext` for cluster +policy overrides. In clusters that assign UID/GID values, set `runAsUser`, `runAsGroup` and +`fsGroup` to `null` under `podSecurityContext`; validate admission and volume permissions. +PDBs and topology-spread constraints are managed outside this chart. MCP is mounted as +stateless Streamable HTTP by the runtime; the chart does not configure that property. Follow the chart's installation, coordinated upgrade and acceptance-test instructions. SQLite and embedded seekdb remain single-process evaluation options outside this chart. @@ -32,4 +43,7 @@ Secret references. It configures two API replicas, one initial background replic `test` generation model. The acceptance script rejects fewer than two configured API replicas and requires two Ready API Pods before creating test data and after replacing API Pods. Run it only in an isolated test namespace with a disposable external OceanBase database; it creates data, deletes -Pods, and changes background replica counts. A passing configuration test is not cluster acceptance. +Pods, and changes background replica counts. Pass `--confirm-disposable` explicitly. The script +also creates two child Scopes, obtains a cursor with `limit=1` on one API Pod and resumes it +on each other API Pod to verify shared signing keys without skipped or repeated results. +A passing configuration test is not cluster acceptance. diff --git a/docs/zh/docs/operate/kubernetes.md b/docs/zh/docs/operate/kubernetes.md index eec54a7048..16d0f85378 100644 --- a/docs/zh/docs/operate/kubernetes.md +++ b/docs/zh/docs/operate/kubernetes.md @@ -11,6 +11,16 @@ description: 使用 Helm 和外部 OceanBase 部署 API 与后台进程。 部署前需要指定固定版本的应用镜像并准备 Secret。多个 background 副本通过数据库租约选出一个活跃 supervisor,其余作为候选实例;增加副本不会增加活跃 supervisor 的数量。当前 background runner 没有 HTTP 健康端点,Pod Ready 不代表后台处理正常,需结合租约续期、日志和持久化处理进度判断。 +`background.maxWorkers` 通过兼容别名设置 Topic Memory 配额,不是进程总数上限; +`maxWorkers=2` 且七个内置 family 全部启用时,配额合计为 11;验收配置的 `test` 模型 +启用四个 family,配额合计为 8。未配置生成模型时默认不启用任何 family;实际并发取决于启用能力和调度。 +应为 supervisor 及其子进程共同规划资源。 + +可通过 `commonLabels`、`podAnnotations`、`podSecurityContext` 和 `securityContext` +适配集群策略。由准入控制分配 UID/GID 的集群,可将 `podSecurityContext` 中的 +`runAsUser`、`runAsGroup` 和 `fsGroup` 设为 `null`,并验证准入规则和卷权限。 +PDB 和 topology-spread constraints 需在 Chart 外管理。MCP 的 stateless Streamable HTTP +由运行时挂载行为保证,不是 Chart 的环境变量配置。 安装、协调停机升级和集群验收步骤见 Chart 文档。Chart 不支持 SQLite 或内嵌 seekdb;这些后端应使用 单进程评估部署。首次安装前,即使 OceanBase 数据库为空,也必须使用目标镜像执行完整 Server @@ -28,4 +38,6 @@ API Ready 后,再从保存的配置恢复目标副本数。 该配置启用两个 API 副本、一个初始 background 副本及本地 `test` 生成模型。验收脚本会拒绝少于 两个 API 副本的配置,并在创建测试数据前及替换 API Pod 后确认至少两个 API Pod 处于 Ready 状态。 仅在隔离测试 namespace 和可丢弃的外部 OceanBase 数据库上运行;脚本会写入数据、删除 Pod 并调整 -background 副本数。配置测试通过不代表真实集群验收通过。 +background 副本数,必须显式传入 `--confirm-disposable`。脚本还创建两个子 Scope, +在一个 API Pod 上以 `limit=1` 取得游标,再让其他各 API Pod 续读,检查签名键共享及结果无遗漏、无重复。 +配置测试通过不代表真实集群验收通过。 diff --git a/tests/e2e/helm_acceptance.py b/tests/e2e/helm_acceptance.py index 2ab0a37bc1..54cad0895a 100644 --- a/tests/e2e/helm_acceptance.py +++ b/tests/e2e/helm_acceptance.py @@ -62,10 +62,31 @@ def validate_topology(deployments) -> None: raise RuntimeError("Start with exactly one background replica in an isolated test deployment") # noqa: TRY003 +def check_pagination(execute, api_pods, parent_scope, children) -> None: + page_request = {"base": "http://127.0.0.1:8000", "action": "page", "parent": parent_scope} + first_page = execute(page_request, api_pods[0]) + first_ids = {item["scope_id"] for item in first_page["items"]} + if len(first_ids) != 1 or not first_ids < children or not first_page.get("next_cursor"): + raise RuntimeError("Expected a signed cursor and the first of two test child Scopes") # noqa: TRY003 + for pod in api_pods[1:]: + second_page = execute({**page_request, "cursor": first_page["next_cursor"]}, pod) + if {item["scope_id"] for item in second_page["items"]} != children - first_ids or second_page.get( + "next_cursor" + ): + raise RuntimeError("Cross-Pod pagination skipped or repeated test Scopes") # noqa: TRY003 + print("PASS: cursor issued by one API Pod resumes on every other API Pod without skipped or repeated Scopes") + + def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--namespace", required=True) parser.add_argument("--release", required=True) + parser.add_argument( + "--confirm-disposable", + action="store_true", + required=True, + help="Confirm this release and database are disposable; deletes Pods and writes test Scopes", + ) args = parser.parse_args() selector = f"app.kubernetes.io/name=powercontext,app.kubernetes.io/instance={args.release}" @@ -124,13 +145,22 @@ def rollout(role: str) -> None: service = services[0] base = f"http://{service['metadata']['name']}:{service['spec']['ports'][0]['port']}" scope = execute({"base": base, "action": "create", "key": "helm-" + str(uuid4())}) + children = { + execute({"base": base, "action": "create", "key": "helm-" + str(uuid4()), "parent": scope["scope_id"]})[ + "scope_id" + ] + for _ in range(2) + } kubectl("delete", "pods", "-l", selector + ",app.kubernetes.io/component=api", "--wait=true") rollout("api") execute({"base": base, "action": "read", "scope": scope["scope_id"]}) - for pod in ready_api_pods(): + api_pods = ready_api_pods() + for pod in api_pods: execute({"base": "http://127.0.0.1:8000", "action": "read", "scope": scope["scope_id"]}, pod) print("PASS: authenticated HTTP/MCP through Service and persisted Scope readable from every replacement API Pod") + check_pagination(execute, api_pods, scope["scope_id"], children) + check_failover(kubectl, pods, execute, rollout, background["metadata"]["name"]) print("Test Scope retained:", scope["scope_id"]) diff --git a/tests/e2e/helm_probe.py b/tests/e2e/helm_probe.py index df118a6f34..cc4847b784 100644 --- a/tests/e2e/helm_probe.py +++ b/tests/e2e/helm_probe.py @@ -21,6 +21,7 @@ import os import sys import urllib.error +import urllib.parse import urllib.request from typing import Any @@ -73,12 +74,23 @@ def create_scope(payload: dict[str, str]): "title": "Helm acceptance", "summary": "Disposable deployment persistence check", "idempotency_key": payload["key"], + **({"parent_scope_id": payload["parent"]} if "parent" in payload else {}), }, ) assert status == 201, "Scope creation failed" return scope +def page_scopes(payload: dict[str, str]): + query = {"limit": "1", "parent_scope_id": payload["parent"]} + if "cursor" in payload: + query["cursor"] = payload["cursor"] + status, page, _ = request(payload["base"], "/v1/scopes?" + urllib.parse.urlencode(query)) + assert status == 200, "Scope pagination failed; check shared cursor signing keys" + assert len(page["items"]) == 1, "Expected one Scope per page" + return page + + def read_scope(payload: dict[str, str]): status, scope, _ = request(payload["base"], "/v1/scopes/" + payload["scope"]) assert status == 200 and scope["scope_id"] == payload["scope"], "Scope did not survive API replacement" @@ -120,6 +132,8 @@ def main() -> None: result = asyncio.run(read_lease()) elif payload["action"] == "create": result = create_scope(payload) + elif payload["action"] == "page": + result = page_scopes(payload) else: result = read_scope(payload) print(json.dumps(result)) diff --git a/tests/test_helm_acceptance.py b/tests/test_helm_acceptance.py index 5905288376..b09c809f6f 100644 --- a/tests/test_helm_acceptance.py +++ b/tests/test_helm_acceptance.py @@ -19,8 +19,13 @@ import sys import pytest +from fastapi.testclient import TestClient +from pydantic import SecretStr -from tests.e2e import helm_acceptance +from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.server.factory import create_server_app +from powercontext.server.settings import AccessControlConfig, BearerAuthConfig, McpConfig, ServerSettings +from tests.e2e import helm_acceptance, helm_probe def api_pod(name, *, ready=True, terminating=False): @@ -37,7 +42,15 @@ def simulate_probe(state, args): payload = json.loads(args[-1]) if payload["action"] == "create": state["mutations"].append(["create_scope"]) - if payload.get("base") == "http://127.0.0.1:8000": + return {"scope_id": f"test-scope-{len(state['mutations'])}"} + if payload["action"] == "page": + state["pages"].append((args[2], payload)) + if "cursor" not in payload: + return {"items": [{"scope_id": "test-scope-2"}], "next_cursor": "signed-cursor"} + if state["bad_cursor_key"]: + raise RuntimeError("Scope pagination failed; check shared cursor signing keys") # noqa: TRY003 + return {"items": [{"scope_id": "test-scope-3"}], "next_cursor": None} + if payload["action"] == "read" and payload.get("base") == "http://127.0.0.1:8000": state["direct_reads"].append(args[2]) return state["lease"] if payload["action"] == "lease" else {"scope_id": "test-scope"} @@ -52,6 +65,8 @@ def cluster(monkeypatch): "lease": ["original", 1], "mutations": mutations, "direct_reads": [], + "pages": [], + "bad_cursor_key": False, } def run(command, **kwargs): @@ -90,7 +105,9 @@ def run(command, **kwargs): return subprocess.CompletedProcess(command, 0, json.dumps(response), "") monkeypatch.setattr(helm_acceptance.subprocess, "run", run) - monkeypatch.setattr(sys, "argv", ["helm_acceptance.py", "--namespace", "helm-test", "--release", "pc"]) + monkeypatch.setattr( + sys, "argv", ["helm_acceptance.py", "--namespace", "helm-test", "--release", "pc", "--confirm-disposable"] + ) return state @@ -120,3 +137,85 @@ def test_checks_each_replacement_api_pod(cluster, capsys): helm_acceptance.main() assert set(cluster["direct_reads"]) == {"api-new-0", "api-new-1"} assert "authenticated HTTP/MCP through Service" in capsys.readouterr().out + + +def test_requires_explicit_confirmation_before_any_cluster_operation(cluster, monkeypatch): + monkeypatch.setattr(sys, "argv", sys.argv[:-1]) + monkeypatch.setattr(helm_acceptance.subprocess, "run", lambda *args, **kwargs: pytest.fail("Cluster accessed")) + with pytest.raises(SystemExit) as error: + helm_acceptance.main() + assert error.value.code == 2 + + +def test_cursor_is_consumed_by_a_different_api_pod(cluster, capsys): + cluster["replacement_pods"].append(api_pod("api-new-2")) + helm_acceptance.main() + first, *resumed = cluster["pages"] + assert first[0] == "api-new-0" + assert "cursor" not in first[1] + assert {pod for pod, _ in resumed} == {"api-new-1", "api-new-2"} + assert all( + payload["cursor"] == "signed-cursor" and payload["parent"] == first[1]["parent"] for _, payload in resumed + ) + assert "cursor issued by one API Pod resumes" in capsys.readouterr().out + + +def test_cursor_rejection_cannot_report_acceptance_success(cluster, capsys): + cluster["bad_cursor_key"] = True + with pytest.raises(RuntimeError, match="shared cursor signing keys"): + helm_acceptance.main() + assert "cursor issued by one API Pod resumes" not in capsys.readouterr().out + + +@pytest.mark.parametrize("shared_key", [True, False]) +def test_page_probe_checks_real_server_cursor_signatures(tmp_path, monkeypatch, shared_key): + database = SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'pagination.db'}") + + def app(key): + return create_server_app( + settings=ServerSettings( + database=database, + cursor_signing_secret=SecretStr(key * 32), + auth=BearerAuthConfig(enabled=True, token=SecretStr("helm-test-token")), + access=AccessControlConfig(mode="enforced"), + mcp=McpConfig(enabled=False), + ) + ) + + headers = {"Authorization": "Bearer helm-test-token"} + with TestClient(app("a"), headers=headers) as first: + parent = first.post( + "/v1/scopes", json={"title": "Parent", "summary": "Test", "idempotency_key": "parent"} + ).json() + children = set() + for key in ("first", "second"): + response = first.post( + "/v1/scopes", + json={"title": key, "summary": "Test", "idempotency_key": key, "parent_scope_id": parent["scope_id"]}, + ) + assert response.status_code == 201 + children.add(response.json()["scope_id"]) + + def request(_base, path): + response = first.get(path) + return response.status_code, response.json(), response.headers + + monkeypatch.setattr(helm_probe, "request", request) + payload = {"base": "http://127.0.0.1:8000", "parent": parent["scope_id"]} + page = helm_probe.page_scopes(payload) + assert page["next_cursor"] + + with TestClient(app("a" if shared_key else "b"), headers=headers) as second: + + def request(_base, path): + response = second.get(path) + return response.status_code, response.json(), response.headers + + monkeypatch.setattr(helm_probe, "request", request) + if shared_key: + resumed = helm_probe.page_scopes({**payload, "cursor": page["next_cursor"]}) + assert {item["scope_id"] for item in [*page["items"], *resumed["items"]]} == children + assert not resumed["next_cursor"] + else: + with pytest.raises(AssertionError, match="shared cursor signing keys"): + helm_probe.page_scopes({**payload, "cursor": page["next_cursor"]}) diff --git a/tests/test_helm_chart.py b/tests/test_helm_chart.py index 4914973c71..2b3d356875 100644 --- a/tests/test_helm_chart.py +++ b/tests/test_helm_chart.py @@ -84,7 +84,6 @@ def test_role_configuration_and_service_isolation(monkeypatch): assert settings.cursor_signing_secret is not None assert settings.cursor_signing_secret.get_secret_value() == secrets["cursor-signing-secret"] assert settings.external_skills.targets == () - assert os.environ["FASTMCP_STATELESS_HTTP"] == "true" assert spec["automountServiceAccountToken"] is False assert spec["securityContext"]["seccompProfile"]["type"] == "RuntimeDefault" assert deployment["spec"]["strategy"]["type"] == "Recreate" @@ -176,3 +175,56 @@ def test_acceptance_profile_renders_two_apis_and_one_background(): model = next(item for item in env if item["name"] == "POWERCONTEXT_SERVER_INFERENCE_GENERATION_MODEL") assert model["value"] == "test" assert replicas == {"api": 2, "background": 1} + + +def test_metadata_security_overrides_and_ingress_tls(tmp_path): + values = tmp_path / "values.yaml" + values.write_text( + yaml.safe_dump({ + "commonLabels": {"cost-center": "platform"}, + "podAnnotations": {"prometheus.io/scrape": "true"}, + "podSecurityContext": {"runAsUser": None, "runAsGroup": None, "fsGroup": None}, + "securityContext": {"runAsUser": 20000}, + "ingress": { + "enabled": True, + "host": "context.example.com", + "tls": [{"secretName": "context-tls", "hosts": ["context.example.com"]}], + }, + }) + ) + result = render(values=values) + assert result.returncode == 0, result.stderr + documents = [item for item in yaml.safe_load_all(result.stdout) if item] + for resource in documents: + assert resource["metadata"]["labels"]["cost-center"] == "platform" + if resource["kind"] == "Deployment": + template = resource["spec"]["template"] + assert template["metadata"]["labels"]["cost-center"] == "platform" + assert template["metadata"]["annotations"]["prometheus.io/scrape"] == "true" + context = template["spec"]["securityContext"] + assert not {"runAsUser", "runAsGroup", "fsGroup"} & context.keys() + assert context["runAsNonRoot"] is True + security = template["spec"]["containers"][0]["securityContext"] + assert security["runAsUser"] == 20000 + assert security["allowPrivilegeEscalation"] is False + assert resource["spec"]["selector"]["matchLabels"].items() <= template["metadata"]["labels"].items() + if resource["kind"] == "Ingress": + assert resource["spec"]["tls"] == [{"secretName": "context-tls", "hosts": ["context.example.com"]}] + + +@pytest.mark.parametrize( + "values", + [ + {"ingress": {"tls": "context-tls"}}, + {"ingress": {"tls": [{"hosts": ["context.example.com"]}]}}, + {"ingress": {"tls": [{"secretName": "context-tls", "hosts": "context.example.com"}]}}, + {"podAnnotations": {"prometheus.io/scrape": True}}, + {"commonLabels": {"app.kubernetes.io/component": "custom"}}, + {"podSecurityContext": {"runAsUser": "random"}}, + {"securityContext": {"allowPrivilegeEscalation": "false"}}, + ], +) +def test_invalid_operational_overrides_are_rejected(tmp_path, values): + path = tmp_path / "values.yaml" + path.write_text(yaml.safe_dump(values)) + assert render(values=path).returncode != 0