diff --git a/.github/ci-paths.toml b/.github/ci-paths.toml index 4831abc90d..64753f7b4a 100644 --- a/.github/ci-paths.toml +++ b/.github/ci-paths.toml @@ -16,6 +16,7 @@ ignore = [ ] [suites] +helm = ["deploy/helm/**", "tests/test_helm*.py", "tests/e2e/helm_*.py"] tests = [ "tests/**", "integrations/**", "examples/**", "scripts/generate_js_operations.py", "scripts/sync_dsh_plugin_tree.py", "scripts/generate_integration_manifest_docs.py", diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index 59cf12305b..a661ba42aa 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -1051,3 +1051,38 @@ jobs: env: POWERCONTEXT_TEST_BLOG_SITE: "1" run: uv run pytest tests/e2e/test_blog_site.py -q + + helm: + name: Helm deployment contracts + needs: changes + if: ${{ !cancelled() && (needs.changes.result != 'success' || fromJSON(needs.changes.outputs.suites || '{}').helm) }} + 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 27847ad359..68ce6ff61d 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 a34042070a..e61fefd76e 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,8 @@ Scope └── Topic Memory # extracted in the background from eligible Sources ``` +For Kubernetes deployments with external OceanBase, see the [Helm chart and deployment guide](deploy/helm/powercontext/README.md). + *The Dashboard screenshots below use synthetic demonstration records.* ### Memory — decisions and constraints that outlive a session 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..a56df6010d --- /dev/null +++ b/deploy/helm/powercontext/README.md @@ -0,0 +1,322 @@ +# 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 and managed-database contracts, +inspected at `1f0afd6b`. 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. Both empty and existing databases require explicit Server +maintenance before any API or background Pod starts; follow the initialization procedure +below. 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. + +## Initialize the database and install + +OceanBase Server startup validates managed storage; it does not initialize an empty +database or repair an old schema. Run the target application's `server db-migrate` +commands before the first Helm install. Stop every writer before maintaining an existing +database. There is no automatic Helm migration hook or per-Pod initialization container. + +Use one fixed maintenance host with Docker and a persistent evidence directory dedicated +to this database. All inspections, migration attempts and retries must use that same host, +container hostname and mounted directory. A shared volume or a repeated hostname on a +different node does not provide cross-host coordination. If you instead run a Kubernetes +maintenance Job, pin it to that one node, retain its evidence volume and hostname across +replacement Jobs, and follow the same commands and stopped-writer requirements. + +On that host, prepare a protected `deployment.env` through your secret manager. It must +select `POWERCONTEXT_SERVER_DATABASE_KIND=oceanbase`, the same database URL and credentials, +authentication and cursor secrets, global supervisor mode, and the same inference and +Processing configuration as the target Pods. In particular, the acceptance profile's +`POWERCONTEXT_SERVER_INFERENCE_GENERATION_MODEL=test` must also be present when preparing +its database. The migration plan binds the target Processing declaration and embedding +profile; changing those settings requires a new plan. Do not commit this file. + +The database must already exist in a MySQL-mode user tenant. The maintenance credential +needs the target-identity permissions documented in +[database migrations](../../../docs/en/docs/operate/database-migrations.md#target-configuration-and-external-evidence). +Create a durable directory writable by the image's UID/GID `10001`, then run: + +```sh +export POWERCONTEXT_IMAGE=registry.example.com/powercontext:YOUR_REVISION +sudo install -d -m 0700 -o 10001 -g 10001 /srv/powercontext/YOUR_DATABASE/migration-evidence + +pc_migrate() { + docker run --rm --hostname powercontext-maintenance \ + --env-file "$PWD/deployment.env" \ + --mount type=bind,src=/srv/powercontext/YOUR_DATABASE/migration-evidence,dst=/migration-evidence \ + "$POWERCONTEXT_IMAGE" server db-migrate "$@" --bundle server \ + --evidence-dir /migration-evidence --lock-coordination single-host +} + +pc_migrate status +pc_migrate plan --backup manual --shared-database +# Inspect the plan, stop every writer and make a manual database backup first. +# Replace PLAN_ID with the returned plan_id; confirmation flags declare those actions complete. +pc_migrate apply --backup manual --shared-database --plan-id PLAN_ID \ + --backup-confirmed --maintenance-confirmed --yes +pc_migrate verify +``` + +Require `state=ready`, `readiness_scope=server` and `server_ready=true` before starting +the Deployments. Keep the evidence directory and backup after the container exits. +Unsupported historical layouts require a registered migration path; do not bypass +admission or reuse the historical patched image below to initialize current storage. +Then install the chart using that same image and configuration. Start one API Pod +first so its static bearer Access bindings are committed before other processes +bootstrap the same preset. Schema maintenance does not seed those credential-specific +bindings. After that API becomes Ready, restore the desired replica counts from your +saved values file; do not retain the temporary overrides with `--reuse-values`: + +```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 \ + --set api.replicas=1 --set background.replicas=0 --wait --timeout 10m +helm upgrade pc deploy/helm/powercontext \ + --namespace powercontext -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. + +## 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 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; +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 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 + +`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. Use the target image and configuration to run the full Server maintenance procedure + above on the fixed maintenance host with its retained evidence directory. Review the + plan and backup policy, apply the exact `plan_id` with the required confirmations, and + require full Server readiness from `verify`. Complete any required Processing ownership + transition separately through `--bundle processing` as described in + [database migrations](../../../docs/en/docs/operate/database-migrations.md). + [Atomic Memory migration](../../../docs/en/docs/operate/atomic-memory-migration.md) and + [processing migration](../../../docs/en/docs/operate/artifact-processing-migration.md) + describe compatibility commands and their limits; partial domain readiness does not + replace full Server readiness. There is no automatic Helm migration hook. +5. Only after migration verification succeeds, start one API Pod with zero background replicas + using the target image and your saved values as shown above. After it is Ready, restore the + desired replica counts from those values. 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 +``` + +Initialize and verify the disposable database with the target image and acceptance +configuration using the maintenance procedure above before installing any Pods. +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 \ + --set api.replicas=1 --set background.replicas=0 --wait --timeout 10m +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 --confirm-disposable +``` + +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 +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 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. + +### Historical cluster evidence + +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. +This evidence predates the Atomic Memory and managed Server schemas. The old Memory-entry +FULLTEXT patch and this image do not establish acceptance for the current runtime. Current +deployments initialize through explicit maintenance, seed Access with one API Pod, then +start the desired replicas against verified storage. Validate that sequence and Pod replacement with the target image; +the historical empty-database startup result does not certify it. + +| 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 +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 new file mode 100644 index 0000000000..375256e76f --- /dev/null +++ b/deploy/helm/powercontext/templates/_helpers.tpl @@ -0,0 +1,43 @@ +{{/* +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" -}} +{{- with .Values.commonLabels }} +{{ toYaml . }} +{{ end -}} +app.kubernetes.io/name: powercontext +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 -}} +{{- 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..376d68727e --- /dev/null +++ b/deploy/helm/powercontext/templates/deployments.yaml @@ -0,0 +1,146 @@ +# 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 | quote }} + app.kubernetes.io/name: powercontext + app.kubernetes.io/component: {{ $role }} + template: + metadata: + labels: + {{- 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: + {{- toYaml $root.Values.podSecurityContext | nindent 8 }} + {{- 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: + {{- toYaml $root.Values.securityContext | nindent 12 }} + {{- 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 + {{- 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..a693a0968e --- /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 | quote }} + 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..33f8a82da8 --- /dev/null +++ b/deploy/helm/powercontext/values.schema.json @@ -0,0 +1,332 @@ +{ + "$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", + "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" + }, + "affinity": { + "type": "object" + }, + "imagePullSecrets": { + "type": "array" + }, + "tolerations": { + "type": "array" + }, + "existingSecret": { + "type": "string" + }, + "providerSecret": { + "type": "string" + }, + "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 new file mode 100644 index 0000000000..f2fe49eebc --- /dev/null +++ b/deploy/helm/powercontext/values.yaml @@ -0,0 +1,103 @@ +# 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: [] + +# 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: + 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 + # Legacy alias for topic-memory's worker quota, not a global process limit. + 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..a0eeab655d --- /dev/null +++ b/docs/en/docs/operate/kubernetes.md @@ -0,0 +1,49 @@ +--- +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. +`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. +Before the first install, initialize even an empty OceanBase database with the target image's +full Server [database migration](database-migrations.md) and verify `server_ready=true`. +OceanBase maintenance requires a persistent evidence directory and `--lock-coordination single-host`; +all commands and retries use one fixed maintenance host, stable hostname and the same directory. +The chart README provides the container commands and stages startup with one API Pod and zero background +replicas to seed the static bearer Access bindings before restoring the desired replica counts. +Existing databases also need a reviewed migration plan, +backup and stopped writers; [Atomic Memory migration](atomic-memory-migration.md) and +[processing migration](artifact-processing-migration.md) describe domain-specific compatibility paths. +The chart does not run migrations automatically. Historical cluster evidence uses an older image and schema; +validate initialization, replica startup and Pod replacement with the target application image separately. + +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. 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/en/docs/operate/meta.json b/docs/en/docs/operate/meta.json index da7ca1e553..51f33bba92 100644 --- a/docs/en/docs/operate/meta.json +++ b/docs/en/docs/operate/meta.json @@ -3,6 +3,7 @@ "pages": [ "index", "deploy-server", + "kubernetes", "local-server-ops", "managed-local-server", "connect-remote-server", diff --git a/docs/zh/docs/operate/kubernetes.md b/docs/zh/docs/operate/kubernetes.md new file mode 100644 index 0000000000..16d0f85378 --- /dev/null +++ b/docs/zh/docs/operate/kubernetes.md @@ -0,0 +1,43 @@ +--- +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 不代表后台处理正常,需结合租约续期、日志和持久化处理进度判断。 +`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 +[数据库迁移](database-migrations.md),并确认 `server_ready=true` 后再启动 Pod。 +OceanBase 维护必须使用持久化证据目录和 `--lock-coordination single-host`;所有命令及重试 +都在同一台固定维护主机上运行,保持 hostname 和目录一致。容器命令见 Chart README; +先启动一个 API Pod、保持 background 副本为零,待静态 Bearer 的 Access 绑定初始化完成、 +API Ready 后,再从保存的配置恢复目标副本数。 +已有数据库还需审阅迁移计划、完成备份并停止所有写入; +[Atomic Memory 迁移](atomic-memory-migration.md)和[处理状态迁移](artifact-processing-migration.md) +说明各自的兼容入口及范围。Chart 不会自动执行迁移。历史集群证据对应旧镜像和 schema; +应使用目标应用镜像单独验收初始化、副本启动及 Pod 替换。 + +集群验收时,将 Chart 中的 `values-acceptance.yaml` 与本地镜像和 Secret 引用配置一起使用。 +该配置启用两个 API 副本、一个初始 background 副本及本地 `test` 生成模型。验收脚本会拒绝少于 +两个 API 副本的配置,并在创建测试数据前及替换 API Pod 后确认至少两个 API Pod 处于 Ready 状态。 +仅在隔离测试 namespace 和可丢弃的外部 OceanBase 数据库上运行;脚本会写入数据、删除 Pod 并调整 +background 副本数,必须显式传入 `--confirm-disposable`。脚本还创建两个子 Scope, +在一个 API Pod 上以 `limit=1` 取得游标,再让其他各 API Pod 续读,检查签名键共享及结果无遗漏、无重复。 +配置测试通过不代表真实集群验收通过。 diff --git a/docs/zh/docs/operate/meta.json b/docs/zh/docs/operate/meta.json index 0c0d6718a5..fb7970ebce 100644 --- a/docs/zh/docs/operate/meta.json +++ b/docs/zh/docs/operate/meta.json @@ -3,6 +3,7 @@ "pages": [ "index", "deploy-server", + "kubernetes", "local-server-ops", "managed-local-server", "connect-remote-server", diff --git a/tests/e2e/helm_acceptance.py b/tests/e2e/helm_acceptance.py new file mode 100644 index 0000000000..54cad0895a --- /dev/null +++ b/tests/e2e/helm_acceptance.py @@ -0,0 +1,169 @@ +# 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 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}" + + 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())}) + 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"]}) + 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"]) + + +if __name__ == "__main__": + main() diff --git a/tests/e2e/helm_probe.py b/tests/e2e/helm_probe.py new file mode 100644 index 0000000000..cc4847b784 --- /dev/null +++ b/tests/e2e/helm_probe.py @@ -0,0 +1,143 @@ +# 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.parse +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"], + **({"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" + 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) + elif payload["action"] == "page": + result = page_scopes(payload) + else: + result = read_scope(payload) + print(json.dumps(result)) + + +if __name__ == "__main__": + main() diff --git a/tests/test_ci_changes.py b/tests/test_ci_changes.py index cfb6052617..02c8b6b0e0 100644 --- a/tests/test_ci_changes.py +++ b/tests/test_ci_changes.py @@ -37,6 +37,10 @@ (["website/src/app/page.tsx"], {"website"}), (["tests/e2e/test_blog_site.py"], {"website", "tests"}), (["AGENTS.md", "CONTRIBUTING.md"], set()), + (["deploy/helm/powercontext/templates/deployments.yaml"], {"helm"}), + (["deploy/helm/powercontext/README.md"], {"helm"}), + (["tests/test_helm_chart.py"], {"tests", "helm"}), + (["tests/e2e/helm_probe.py"], {"tests", "helm"}), (["integrations/pi/plugins/powercontext/index.ts"], {"tests", "pi", "zcode"}), (["integrations/kimi-code/plugins/powercontext/skills/context/SKILL.md"], {"tests", "kimi"}), (["evaluation/coding/work_continuity/run.py"], {"evaluation"}), diff --git a/tests/test_helm_acceptance.py b/tests/test_helm_acceptance.py new file mode 100644 index 0000000000..b09c809f6f --- /dev/null +++ b/tests/test_helm_acceptance.py @@ -0,0 +1,221 @@ +# 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 fastapi.testclient import TestClient +from pydantic import SecretStr + +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): + 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"]) + 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"} + + +@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": [], + "pages": [], + "bad_cursor_key": False, + } + + 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", "--confirm-disposable"] + ) + 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 + + +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 new file mode 100644 index 0000000000..2b3d356875 --- /dev/null +++ b/tests/test_helm_chart.py @@ -0,0 +1,230 @@ +# 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 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]) + + +@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 + 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} + + +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