diff --git a/.github/mergify.yml b/.github/mergify.yml index 0fffcc81b..a79369353 100644 --- a/.github/mergify.yml +++ b/.github/mergify.yml @@ -1,51 +1,90 @@ pull_request_rules: -- name: auto-merge - description: > - Automatic merge of PRs to main - conditions: - - "#approved-reviews-by>=2" - - "#changes-requested-reviews-by=0" - - base=main - - label!=do-not-merge - - label!=needs-rebase - - check-success=pre-commit - - check-success=e2e-tests - - check-success=DCO - - check-success=tests + - name: keep PRs up to date with main + conditions: + - -conflict + - -draft + - -closed + - -merged + - label!=do-not-merge + actions: + update: {} + + - name: auto-merge GitHub Actions dependency bumps + description: > + Automatic merge of GitHub Actions dependency bump PRs with 1 approval + conditions: + - "#approved-reviews-by>=1" + - "#changes-requested-reviews-by=0" + - base=main + - label!=do-not-merge + - label!=needs-rebase + - check-success=pre-commit + - check-success=e2e-tests + - check-success=tests + - or: + - title~=(?i)bump.*action + - title~=(?i)update.*github.*action + - label=dependencies + - files~=^\.github/workflows/ + actions: + merge: + method: squash + commit_message_template: | + {{ title }} (#{{ number }}) + + {{ body }} + + {% for user in approved_reviews_by %} + Approved-by: {{ user }} + {% endfor %} + + - name: auto-merge + description: > + Automatic merge of PRs to main + conditions: + - "#approved-reviews-by>=2" + - "#changes-requested-reviews-by=0" + - base=main + - label!=do-not-merge + - label!=needs-rebase + - check-success=pre-commit + - check-success=e2e-tests + - check-success=tests # - check-success=build-latest-image # TODO: uncomment this when the credentials are added to the repo - actions: - merge: - method: squash - commit_message_template: | - {{ title }} (#{{ number }}) + actions: + merge: + method: squash + commit_message_template: | + {{ title }} (#{{ number }}) - {{ body }} + {{ body }} - {% for user in approved_reviews_by %} - Approved-by: {{ user }} - {% endfor %} - delete_head_branch: + {% for user in approved_reviews_by %} + Approved-by: {{ user }} + {% endfor %} -- name: ping author on conflicts and add 'needs-rebase' label - conditions: + - name: ping author on conflicts and add 'needs-rebase' label + conditions: - conflict - -closed - actions: - label: - add: - - needs-rebase - comment: - message: > - This pull request has merge conflicts that must be resolved before it - can be merged. @{{author}} please rebase it. - https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork - -- name: remove 'needs-rebase' label when conflict is resolved - conditions: + actions: + label: + add: + - needs-rebase + comment: + message: > + This pull request has merge conflicts that must be resolved before it + can be merged. @{{author}} please rebase it. + https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork + + - name: remove 'needs-rebase' label when conflict is resolved + conditions: - -conflict - -closed - actions: - label: - remove: - - needs-rebase + actions: + label: + remove: + - needs-rebase +merge_protections_settings: + reporting_method: check-runs diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml index daac84cab..35eaca0fb 100644 --- a/.github/workflows/build-image.yml +++ b/.github/workflows/build-image.yml @@ -9,7 +9,7 @@ permissions: contents: read env: - IMG: quay.io/llamastack/llama-stack-k8s-operator:latest + IMG: quay.io/ogx-ai/ogx-k8s-operator:latest jobs: build-arch: @@ -27,18 +27,18 @@ jobs: runs-on: ${{ matrix.runner }} steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: go.mod - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 - name: Login to Quay.io - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: registry: quay.io username: ${{ secrets.APP_QUAY_USERNAME }} @@ -57,10 +57,10 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 - name: Login to Quay.io - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: registry: quay.io username: ${{ secrets.APP_QUAY_USERNAME }} diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml index 80b76d13e..39a410384 100644 --- a/.github/workflows/code-coverage.yml +++ b/.github/workflows/code-coverage.yml @@ -10,10 +10,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: go.mod diff --git a/.github/workflows/generate-release.yml b/.github/workflows/generate-release.yml index f32559020..be1616af5 100644 --- a/.github/workflows/generate-release.yml +++ b/.github/workflows/generate-release.yml @@ -37,7 +37,7 @@ jobs: e2e_run_url: ${{ needs.test-e2e.outputs.run_url }} steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 with: fetch-depth: 0 # Need full history to check existing tags @@ -98,7 +98,7 @@ jobs: fi - name: Set up Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c #v 6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 #v 6.5.0 with: go-version-file: go.mod @@ -109,7 +109,7 @@ jobs: make release VERSION=${{ steps.validate.outputs.operator_version }} LLAMASTACK_VERSION=${{ steps.validate.outputs.llamastack_version }} - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 with: python-version: '3.12' @@ -118,12 +118,12 @@ jobs: continue-on-error: true id: precommit env: - IMG: quay.io/llamastack/llama-stack-k8s-operator:v${{ steps.validate.outputs.operator_version }} + IMG: quay.io/ogx-ai/ogx-k8s-operator:v${{ steps.validate.outputs.operator_version }} - name: Handle pre-commit changes shell: bash env: - IMG: quay.io/llamastack/llama-stack-k8s-operator:v${{ steps.validate.outputs.operator_version }} + IMG: quay.io/ogx-ai/ogx-k8s-operator:v${{ steps.validate.outputs.operator_version }} run: | # Check if there are any modified files after pre-commit if [ "${{ steps.precommit.outcome }}" = "failure" ]; then @@ -183,7 +183,7 @@ jobs: - name: Validate release image build shell: bash run: | - make image-build IMG=quay.io/llamastack/llama-stack-k8s-operator:v${{ steps.validate.outputs.operator_version }} + make image-build IMG=quay.io/ogx-ai/ogx-k8s-operator:v${{ steps.validate.outputs.operator_version }} - name: Commit and push changes shell: bash @@ -218,20 +218,20 @@ jobs: release_branch: ${{ needs.generate-release.outputs.release_branch }} steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 with: ref: ${{ env.release_branch }} - name: Set up Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: go.mod - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 - name: Log in to Quay.io - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: registry: quay.io username: ${{ secrets.APP_QUAY_USERNAME }} @@ -242,7 +242,7 @@ jobs: make image-build-push-single \ CONTAINER_TOOL=docker \ PLATFORM=${{ matrix.platform }} \ - IMG=quay.io/llamastack/llama-stack-k8s-operator:v${{ env.operator_version }}-${{ matrix.arch }} + IMG=quay.io/ogx-ai/ogx-k8s-operator:v${{ env.operator_version }}-${{ matrix.arch }} finalize-release: needs: [generate-release, build-release-arch] @@ -251,10 +251,10 @@ jobs: operator_version: ${{ needs.generate-release.outputs.operator_version }} llamastack_version: ${{ needs.generate-release.outputs.llamastack_version }} release_branch: ${{ needs.generate-release.outputs.release_branch }} - IMG: quay.io/llamastack/llama-stack-k8s-operator + IMG: quay.io/ogx-ai/ogx-k8s-operator steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 with: ref: ${{ env.release_branch }} fetch-depth: 0 @@ -293,12 +293,12 @@ jobs: Install the operator using kubectl: \`\`\`bash - kubectl apply -f https://raw.githubusercontent.com/llamastack/llama-stack-k8s-operator/v${{ env.operator_version }}/release/operator.yaml + kubectl apply -f https://raw.githubusercontent.com/ogx-ai/ogx-k8s-operator/v${{ env.operator_version }}/release/operator.yaml \`\`\` ## Container Images - - Operator: \`quay.io/llamastack/llama-stack-k8s-operator:v${{ env.operator_version }}\` + - Operator: \`quay.io/ogx-ai/ogx-k8s-operator:v${{ env.operator_version }}\` - LlamaStack distributions: \`docker.io/llamastack/distribution-*:${{ env.llamastack_version }}\`" gh release create "v${{ env.operator_version }}" \ @@ -311,10 +311,10 @@ jobs: GH_TOKEN: ${{ github.token }} - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 - name: Log in to Quay.io - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: registry: quay.io username: ${{ secrets.APP_QUAY_USERNAME }} @@ -335,5 +335,5 @@ jobs: echo "🚀 Release v${{ env.operator_version }} completed successfully!" echo "📦 Container image: ${{ env.IMG }}:v${{ env.operator_version }}" echo "📝 Release notes: https://github.com/${{ github.repository }}/releases/tag/v${{ env.operator_version }}" - echo "🔧 Install with: kubectl apply -f https://raw.githubusercontent.com/llamastack/llama-stack-k8s-operator/v${{ env.operator_version }}/release/operator.yaml" + echo "🔧 Install with: kubectl apply -f https://raw.githubusercontent.com/ogx-ai/ogx-k8s-operator/v${{ env.operator_version }}/release/operator.yaml" echo "🌿 Release branch: ${{ env.release_branch }}" diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 543be3e2c..68c6f9c47 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -15,10 +15,10 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Go env - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: go.mod diff --git a/.github/workflows/release-image.yml b/.github/workflows/release-image.yml index aa3a067e2..7cc924f4e 100644 --- a/.github/workflows/release-image.yml +++ b/.github/workflows/release-image.yml @@ -17,7 +17,7 @@ permissions: contents: read env: - IMG: quay.io/llamastack/llama-stack-k8s-operator:${{ github.event.inputs.version }} + IMG: quay.io/ogx-ai/ogx-k8s-operator:${{ github.event.inputs.version }} jobs: build-arch: @@ -34,20 +34,20 @@ jobs: runs-on: ${{ matrix.runner }} steps: - name: Set up Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: go.mod - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ github.event.inputs.release_branch }} - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 - name: Login to Quay.io - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: registry: quay.io username: ${{ secrets.APP_QUAY_USERNAME }} @@ -65,10 +65,10 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 - name: Login to Quay.io - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: registry: quay.io username: ${{ secrets.APP_QUAY_USERNAME }} diff --git a/.github/workflows/run-e2e-test.yml b/.github/workflows/run-e2e-test.yml index 856a2e9a1..4ad8d62da 100644 --- a/.github/workflows/run-e2e-test.yml +++ b/.github/workflows/run-e2e-test.yml @@ -25,15 +25,15 @@ jobs: run_url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: go.mod - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - name: Create kind config run: | @@ -75,29 +75,36 @@ jobs: - name: Build operator image run: | # Build the image with run-based tag for uniqueness - docker build -t kind-registry:5000/llama-stack-k8s-operator:run-${{ github.run_id }} -f Dockerfile . + docker build -t kind-registry:5000/ogx-k8s-operator:run-${{ github.run_id }} -f Dockerfile . # Tag the image for local registry - docker tag kind-registry:5000/llama-stack-k8s-operator:run-${{ github.run_id }} kind-registry:5000/llama-stack-k8s-operator:latest + docker tag kind-registry:5000/ogx-k8s-operator:run-${{ github.run_id }} kind-registry:5000/ogx-k8s-operator:latest - name: Push operator image to local registry run: | echo '{"insecure-registries": ["kind-registry:5000"]}' | sudo tee /etc/docker/daemon.json sudo systemctl restart docker until docker exec kind-registry true 2>/dev/null; do sleep 1; done - docker push kind-registry:5000/llama-stack-k8s-operator:latest + docker push kind-registry:5000/ogx-k8s-operator:latest + + - name: Install cert-manager + run: | + kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.17.2/cert-manager.yaml + kubectl wait --for=condition=available --timeout=120s deployment/cert-manager -n cert-manager + kubectl wait --for=condition=available --timeout=120s deployment/cert-manager-webhook -n cert-manager + kubectl wait --for=condition=available --timeout=120s deployment/cert-manager-cainjector -n cert-manager - name: Deploy operator run: | # Deploy the operator - make deploy IMG=kind-registry:5000/llama-stack-k8s-operator:latest + make deploy IMG=kind-registry:5000/ogx-k8s-operator:latest # Wait for operator deployment to be ready - if ! kubectl wait --for=condition=available --timeout=300s deployment/llama-stack-k8s-operator-controller-manager -n llama-stack-k8s-operator-system; then + if ! kubectl wait --for=condition=available --timeout=300s deployment/ogx-k8s-operator-controller-manager -n ogx-k8s-operator-system; then echo "Deployment failed to become ready. Debugging information:" - kubectl describe deployment llama-stack-k8s-operator-controller-manager -n llama-stack-k8s-operator-system - kubectl logs -l control-plane=controller-manager -n llama-stack-k8s-operator-system --tail=100 - kubectl get events -n llama-stack-k8s-operator-system --sort-by='.lastTimestamp' + kubectl describe deployment ogx-k8s-operator-controller-manager -n ogx-k8s-operator-system + kubectl logs -l control-plane=controller-manager -n ogx-k8s-operator-system --tail=100 + kubectl get events -n ogx-k8s-operator-system --sort-by='.lastTimestamp' exit 1 fi @@ -120,11 +127,20 @@ jobs: - name: Get logs if: ${{ always() }} run: | - kubectl -n llama-stack-test get all -o yaml > all.log - kubectl -n llama-stack-k8s-operator-system logs deployment.apps/llama-stack-k8s-operator-controller-manager > controller-manager.log - kubectl -n llama-stack-test describe all > all-describe.log - kubectl -n llama-stack-test describe events > events.log - kubectl get llamastackdistributions --all-namespaces -o yaml > llamastackdistributions.log + kubectl -n ogx-test get all -o yaml > all.log + kubectl -n ogx-k8s-operator-system logs deployment.apps/ogx-k8s-operator-controller-manager > controller-manager.log + kubectl -n ogx-k8s-operator-system logs deployment.apps/ogx-k8s-operator-controller-manager --previous > controller-manager-previous.log 2>/dev/null || true + kubectl -n ogx-test describe all > all-describe.log + kubectl -n ogx-test describe events > events.log + kubectl get ogxservers --all-namespaces -o yaml > ogxservers.log + echo "=== Operator Pod Status ===" > operator-debug.log + kubectl -n ogx-k8s-operator-system get pods -o wide >> operator-debug.log 2>&1 + echo "=== Operator Pod Describe ===" >> operator-debug.log + kubectl -n ogx-k8s-operator-system describe pods -l control-plane=controller-manager >> operator-debug.log 2>&1 + echo "=== Node Memory Pressure ===" >> operator-debug.log + kubectl describe nodes | grep -A5 "MemoryPressure\|memory\|OOMKill" >> operator-debug.log 2>&1 + echo "=== Operator Events ===" >> operator-debug.log + kubectl -n ogx-k8s-operator-system get events --sort-by='.lastTimestamp' >> operator-debug.log 2>&1 - name: Upload all logs to artifacts if: ${{ always() }} diff --git a/.gitignore b/.gitignore index 79a1bf61c..306c8e447 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,10 @@ config/manifests/bases/llama-stack-k8s-operator.clusterserviceversion.yaml .DS_Store +# Local env files for hack scripts +hack/*.env + # SDD and Claude Code directories .claude .specify +.cursor/ diff --git a/.golangci.yml b/.golangci.yml index a72b45bef..105fc972d 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -69,6 +69,14 @@ linters: ignore-string-values: "true|false" errcheck: check-type-assertions: true + recvcheck: + # controller-gen generates DeepCopy/DeepCopyInto with pointer receivers, + # but our Provider.DeriveID() methods use value receivers for generic + # constraint satisfaction. Exclude the generated methods to avoid false + # positives on every CRD type. + exclusions: + - "*.DeepCopyInto" + - "*.DeepCopy" exhaustive: check: - switch diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..125468500 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,97 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Kubernetes operator for OGX (Open GenAI Stack) servers, built with operator-sdk (v4 layout) and controller-runtime. Manages a single CRD: `OGXServer` (group `ogx.io`, version `v1beta1`). The operator deploys and manages OGX server workloads including Deployments, Services, PVCs, NetworkPolicies, PDBs, HPAs, and Ingresses. + +The project was renamed from "LlamaStack" to "OGX" — `api/v1alpha1/` contains the legacy `LlamaStackDistribution` CRD (read-only, used for resource adoption). The controller in `controllers/` supports adopting resources from legacy LlamaStackDistribution CRs into OGXServer CRs via annotations (`ogx.io/adopt-storage`, `ogx.io/adopt-networking`). + +## Build & Development Commands + +```bash +make build # Build manager binary (includes generate, fmt, vet) +make test # Run unit/integration tests (all packages except e2e) +make lint # Run golangci-lint (v2, config in .golangci.yml) +make lint-fix # Auto-fix lint issues +make fmt # Format code (go fmt + gci import ordering via golangci-lint) +make manifests # Regenerate CRDs, RBAC, webhook configs via controller-gen +make generate # Regenerate DeepCopy methods +make api-docs # Regenerate docs/api-overview.md from CRD types +``` + +### Running specific tests + +```bash +make test TEST_PKGS=./pkg/deploy # Single package +make test TEST_PKGS=./pkg/deploy TEST_FLAGS="-v -run TestRenderManifest" # Single test +make test TEST_PKGS="./pkg/deploy ./controllers" # Multiple packages +``` + +### E2E tests (requires a running cluster + deployed operator) + +```bash +make test-e2e # Deploys ollama via hack/deploy-quickstart.sh, then runs tests +``` + +### Image and deployment + +```bash +make image IMG=quay.io//ogx-k8s-operator: # Build and push image +make deploy IMG= # Deploy on vanilla K8s (cert-manager overlay) +make deploy-openshift IMG= # Deploy on OpenShift (service-serving-cert-signer) +make undeploy # Remove from vanilla K8s +make release VERSION=0.2.1 LLAMASTACK_VERSION=0.2.12 # Prepare release +``` + +Create `local.mk` to override Makefile variables (e.g., `IMG`, `CONTAINER_TOOL`). + +## Architecture + +### Reconciliation Pipeline + +`main.go` → `OGXServerReconciler.Reconcile()` in `controllers/ogxserver_controller.go`: + +1. **Refresh operator config** — reads `ogx-operator-config` ConfigMap for image overrides via a direct (non-cached) client +2. **Adopt legacy resources** — `legacy_adoption.go` handles PVC/Service/Ingress migration from LlamaStackDistribution +3. **Reconcile ConfigMaps** — validates user override ConfigMap, gathers and validates CA bundles (explicit + auto-detected ODH trusted CA), creates managed CA bundle ConfigMap +4. **Render manifests via kustomize** — `pkg/deploy/kustomizer.go` runs kustomize on `controllers/manifests/base/`, then applies Go-based transformer plugins (`pkg/deploy/plugins/`) +5. **Apply resources** — `pkg/deploy/kustomizer.go:ApplyResources()` creates or patches each resource using server-side apply with field owner `ogx-operator` +6. **Reconcile Ingress** — external access via `network_resources.go` +7. **Update status** — deployment readiness, storage/service conditions, provider info from `/v1/providers`, server version from `/v1/version` + +### Key Packages + +- **`api/v1beta1/`** — OGXServer CRD types, webhook validation (CEL rules + Go webhook), provider type definitions split across `provider_types_*.go` +- **`controllers/`** — reconciler, status management, network resources, legacy adoption, resource helpers +- **`controllers/manifests/base/`** — kustomize base manifests (deployment, service, pvc, networkpolicy, pdb, hpa, serviceaccount, rolebinding) +- **`pkg/deploy/`** — kustomize rendering, resource application (SSA), manifest context building +- **`pkg/deploy/plugins/`** — Go-based kustomize transformer plugins (name prefix, namespace, field mutator, NetworkPolicy transformer) +- **`pkg/cluster/`** — cluster info initialization, upgrade cleanup +- **`pkg/compare/`** — resource comparison utilities +- **`distributions.json`** — embedded at compile time, maps distribution names to container images + +### Distribution Resolution + +Distribution images are resolved via: `distributions.json` (embedded) → operator ConfigMap `image-overrides` (runtime override) → `spec.distribution.image` (direct override per CR). + +### Resource Ownership + +- Namespace-scoped resources get ownerRef → garbage collected on CR deletion +- PVCs are intentionally excluded from ownerRef to prevent data loss +- PVCs use `Recreate` deployment strategy to avoid RWO multi-attach deadlock +- Resources patched via SSA must be owned by the current OGXServer instance (safety check prevents "stealing") + +### ConfigMap Cache Design + +The operator cache filters ConfigMaps by label `ogx.io/watch: "true"`. Operator-managed ConfigMaps (CA bundles) get this label automatically and are watched via `Owns()`. User-referenced ConfigMaps need this label for instant reconciliation. The operator config ConfigMap and user ConfigMaps without the label are read via a direct (non-cached) API client with 5-minute periodic requeue for eventual consistency. + +## Code Conventions + +- **Error messages**: All wrapped errors must start with `"failed to"` — enforced by `hack/check_go_errors.py` pre-commit hook +- **Import ordering**: standard → default → blank → dot (enforced by gci via golangci-lint) +- **Linter config**: golangci-lint v2 with `default: all` and specific disables in `.golangci.yml`; max line length 180, max function length 100 lines/statements +- **Tests**: table-driven with descriptive names explaining behavior; use `require.Eventually` for async K8s operations; integration tests use `envtest` (kubebuilder assets v1.31.0) +- **Pre-commit hooks**: linters, manifest generation, installer rebuild, API docs generation, error message format check, GitHub Actions SHA-pinning check +- **Code generation**: run `make manifests generate` after changing CRD types or RBAC markers; generated files include `zz_generated.deepcopy.go` and CRDs in `config/crd/bases/` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8967ad949..cf4583e4e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ -# Contributing to Llama Stack K8s Operator +# Contributing to OGX K8s Operator -Thank you for your interest in contributing to the Llama Stack K8s Operator! This document provides guidelines and instructions for contributing to this project. +Thank you for your interest in contributing to the OGX K8s Operator! This document provides guidelines and instructions for contributing to this project. ## Development Setup @@ -131,9 +131,9 @@ make test TEST_PKGS=./pkg/deploy TEST_FLAGS="-v -run TestRenderManifest" // Good: Descriptive test names that explain behavior { name: "No storage configuration - should use emptyDir", - buildInstance: func(namespace string) *llamav1alpha1.LlamaStackDistribution { - return NewDistributionBuilder(). - WithStorage(nil). // Clear intent: testing emptyDir behavior + buildInstance: func(namespace string) *ogxiov1beta1.OGXServer { + return NewServerBuilder(). + WithStorage(nil). Build() }, }, diff --git a/Makefile b/Makefile index 733c3230c..13bba8b61 100644 --- a/Makefile +++ b/Makefile @@ -31,8 +31,8 @@ BUNDLE_METADATA_OPTS ?= $(BUNDLE_CHANNELS) $(BUNDLE_DEFAULT_CHANNEL) # This variable is used to construct full image tags for bundle and catalog images. # # For example, running 'make bundle-build bundle-push catalog-build catalog-push' will build and push both -# llamastack.io/llama-stack-k8s-operator-bundle:$VERSION and llamastack.io/llama-stack-k8s-operator-catalog:$VERSION. -IMAGE_TAG_BASE ?= quay.io/llamastack/llama-stack-k8s-operator +# ogx.io/ogx-k8s-operator-bundle:$VERSION and ogx.io/ogx-k8s-operator-catalog:$VERSION. +IMAGE_TAG_BASE ?= quay.io/ogx-ai/ogx-k8s-operator # BUNDLE_IMG defines the image:tag used for the bundle. # You can use it as an arg. (E.g make bundle-build BUNDLE_IMG=/:) @@ -276,7 +276,8 @@ docker-buildx: image-buildx ## Deprecated: use image-buildx instead build-installer: manifests generate kustomize ## Generate a consolidated YAML with CRDs and deployment. mkdir -p release cd config/manager && $(KUSTOMIZE) edit set image controller=${IMG} - $(KUSTOMIZE) build config/default > release/operator.yaml + $(KUSTOMIZE) build config/overlays/cert-manager > release/operator.yaml + $(KUSTOMIZE) build config/overlays/openshift > release/operator-openshift.yaml .PHONY: image image: image-build image-push ## Build and push image with the manager. @@ -296,13 +297,22 @@ uninstall: manifests kustomize ## Uninstall CRDs from the K8s cluster specified $(KUSTOMIZE) build config/crd | kubectl delete --ignore-not-found=$(ignore-not-found) -f - .PHONY: deploy -deploy: manifests kustomize ## Deploy controller to the K8s cluster specified in ~/.kube/config. +deploy: manifests kustomize ## Deploy controller to the K8s cluster (cert-manager overlay, vanilla K8s). cd config/manager && $(KUSTOMIZE) edit set image controller=${IMG} - $(KUSTOMIZE) build config/default | kubectl apply -f - + $(KUSTOMIZE) build config/overlays/cert-manager | kubectl apply -f - + +.PHONY: deploy-openshift +deploy-openshift: manifests kustomize ## Deploy controller to an OpenShift cluster (service-serving-cert-signer). + cd config/manager && $(KUSTOMIZE) edit set image controller=${IMG} + $(KUSTOMIZE) build config/overlays/openshift | oc apply -f - .PHONY: undeploy -undeploy: ## Undeploy controller from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion. - $(KUSTOMIZE) build config/default | kubectl delete --ignore-not-found=$(ignore-not-found) -f - +undeploy: kustomize ## Undeploy controller from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion. + $(KUSTOMIZE) build config/overlays/cert-manager | kubectl delete --ignore-not-found=$(ignore-not-found) -f - + +.PHONY: undeploy-openshift +undeploy-openshift: kustomize ## Undeploy controller from an OpenShift cluster. Call with ignore-not-found=true to ignore resource not found errors during deletion. + $(KUSTOMIZE) build config/overlays/openshift | oc delete --ignore-not-found=$(ignore-not-found) -f - ##@ Dependencies @@ -504,7 +514,7 @@ release: yq kustomize yamlfmt ## Prepare release files with VERSION and LLAMASTA $(call json-fmt,'to_entries | map(.value |= sub(":latest"; ":$(LLAMASTACK_VERSION)")) | from_entries',distributions.json) # Update kustomization files using Kustomize - cd config/manager && $(KUSTOMIZE) edit set image controller=quay.io/llamastack/llama-stack-k8s-operator:v$(VERSION) + cd config/manager && $(KUSTOMIZE) edit set image controller=quay.io/ogx-ai/ogx-k8s-operator:v$(VERSION) # Update environment variables in manager.yaml and format with our preferred YAML style # using YQ because Kustomize doesn't support setting environment variables @@ -512,4 +522,4 @@ release: yq kustomize yamlfmt ## Prepare release files with VERSION and LLAMASTA # Generate manifests and build installer $(MAKE) manifests generate - $(MAKE) -e IMG=quay.io/llamastack/llama-stack-k8s-operator:v$(VERSION) build-installer + $(MAKE) -e IMG=quay.io/ogx-ai/ogx-k8s-operator:v$(VERSION) build-installer diff --git a/PROJECT b/PROJECT index b22acf356..a64e3caa9 100644 --- a/PROJECT +++ b/PROJECT @@ -1,18 +1,18 @@ -domain: llamastack.io +domain: ogx.io layout: - go.kubebuilder.io/v4 plugins: manifests.sdk.operatorframework.io/v2: {} scorecard.sdk.operatorframework.io/v2: {} -projectName: llama-stack-k8s-operator -repo: github.com/llamastack/llama-stack-k8s-operator +projectName: ogx-k8s-operator +repo: github.com/ogx-ai/ogx-k8s-operator resources: - api: crdVersion: v1 namespaced: true controller: true - domain: llamastack.io - kind: LlamaStackDistribution - path: github.com/llamastack/llama-stack-k8s-operator/api/v1alpha1 - version: v1alpha1 + domain: ogx.io + kind: OGXServer + path: github.com/ogx-ai/ogx-k8s-operator/api/v1beta1 + version: v1beta1 version: "3" diff --git a/README.md b/README.md index ec8f1fe86..25bba1a2f 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,12 @@ -# llama-stack-operator -This repo hosts a kubernetes operator that is responsible for creating and managing [llama-stack](https://github.com/meta-llama/llama-stack) server. +# ogx-k8s-operator +This repo hosts a Kubernetes operator that creates and manages OGX (Open GenAI Stack) servers. ## Features -- Automated deployment of Llama Stack servers -- Support for multiple [distributions](https://github.com/meta-llama/llama-stack?tab=readme-ov-file#distributions) (includes Ollama, vLLM, and others) +- Automated deployment of OGX servers +- Support for multiple distributions (includes Ollama, vLLM, and others) +- Declarative runtime config generation from OGXServer CR fields - Customizable server configurations - Volume management for model storage - Kubernetes-native resource management @@ -14,7 +15,8 @@ This repo hosts a kubernetes operator that is responsible for creating and manag - [Quick Start](#quick-start) - [Installation](#installation) - - [Deploying Llama Stack Server](#deploying-the-llama-stack-server) + - [Deploying the OGX Server](#deploying-the-ogx-server) + - [Runtime Config via CR](#runtime-config-via-cr) - [Enabling Network Policies](#enabling-network-policies) - [Developer Guide](#developer-guide) - [Prerequisites](#prerequisites) @@ -32,16 +34,16 @@ You can install the operator directly from a released version or the latest main To install the latest version from the main branch: ```bash -kubectl apply -f https://raw.githubusercontent.com/llamastack/llama-stack-k8s-operator/main/release/operator.yaml +kubectl apply -f https://raw.githubusercontent.com/ogx-ai/ogx-k8s-operator/main/release/operator.yaml ``` To install a specific released version (e.g., v1.0.0), replace `main` with the desired tag: ```bash -kubectl apply -f https://raw.githubusercontent.com/llamastack/llama-stack-k8s-operator/v1.0.0/release/operator.yaml +kubectl apply -f https://raw.githubusercontent.com/ogx-ai/ogx-k8s-operator/v1.0.0/release/operator.yaml ``` -### Deploying the Llama Stack Server +### Deploying the OGX Server 1. Deploy the inference provider server (ollama, vllm) @@ -71,111 +73,163 @@ Deploy vLLM with GPU support: ./hack/deploy-quickstart.sh --provider vllm --runtime-env "VLLM_TARGET_DEVICE=gpu,CUDA_VISIBLE_DEVICES=0" ``` -2. Create LlamaStackDistribution CR to get the server running. Example: +2. Create an OGXServer CR to get the server running. Example: ``` -apiVersion: llamastack.io/v1alpha1 -kind: LlamaStackDistribution +apiVersion: ogx.io/v1beta1 +kind: OGXServer metadata: - name: llamastackdistribution-sample + name: ogxserver-sample spec: - replicas: 1 - server: - distribution: - name: starter - containerSpec: + distribution: + name: starter + workload: + replicas: 1 + storage: + size: "20Gi" + mountPath: "/.ogx" + overrides: env: - name: OLLAMA_INFERENCE_MODEL value: "llama3.2:1b" - name: OLLAMA_URL value: "http://ollama-server-service.ollama-dist.svc.cluster.local:11434" - storage: - size: "20Gi" - mountPath: "/home/lls/.lls" ``` 3. Verify the server pod is running in the user defined namespace. ### Local Vector Storage (inline::milvus) -To enable the `inline::milvus` local vector storage provider, set `ENABLE_INLINE_MILVUS` in `spec.server.containerSpec.env`. This is only supported in single-worker, single-replica deployments. Milvus-Lite uses SQLite internally and does not support concurrent access from multiple processes. +To enable the `inline::milvus` local vector storage provider, set `ENABLE_INLINE_MILVUS` in `spec.workload.overrides.env`. This is only supported in single-worker, single-replica deployments. Milvus-Lite uses SQLite internally and does not support concurrent access from multiple processes. -### Using a ConfigMap for config.yaml configuration +### Runtime Config via CR -A ConfigMap can be used to store config.yaml configuration for each LlamaStackDistribution. -Updates to the ConfigMap will restart the Pod to load the new data. +The operator supports two ways to provide OGX `config.yaml`: -Example to create a config.yaml ConfigMap, and a LlamaStackDistribution that references it: -``` -kubectl apply -f config/samples/example-with-configmap.yaml -``` +1. **Declarative generation from CR fields** (recommended) via: + - `spec.baseConfig` (optional base config input) + - `spec.providers` + - `spec.resources` + - `spec.storage` + - `spec.disabledAPIs` +2. **Direct override** via `spec.overrideConfig` pointing to a user-managed ConfigMap. -## Enabling Network Policies +When declarative fields are present and `spec.overrideConfig` is not set, the operator: + +- Resolves base config from `spec.baseConfig` when set, otherwise from OCI labels `com.ogx.distribution.default-config` + `com.ogx.config.` +- Generates a final `config.yaml` +- Creates immutable ConfigMap `${name}-config-${hash}` +- Mounts that config to `/etc/ogx/config.yaml` +- Injects required secret-based env vars from provider/storage secret refs +- Rolls the Deployment when referenced config/secret inputs change + +The mounted runtime config always comes from either `spec.overrideConfig` or the +generated ConfigMap. `spec.baseConfig` is only used as an input to generation +and is never mounted into the pod directly. -The operator can create an ingress-only `NetworkPolicy` for each `LlamaStackDistribution`. By default, traffic is limited to: -- All pods within the same namespace -- The operator namespace (`llama-stack-k8s-operator-system`) +Example declarative OGXServer: -### Enable the Feature Flag +```yaml +apiVersion: ogx.io/v1beta1 +kind: OGXServer +metadata: + name: runtime-config-sample +spec: + distribution: + name: starter + providers: + inference: + remote: + openai: + - id: openai-primary + apiKey: + name: openai-creds + key: api-key + resources: + models: + - name: gpt-4o-mini + provider: openai-primary + storage: + sql: + type: postgres + connectionString: + name: db-credentials + key: connection-string +``` -Network policies are disabled by default. Enable via ConfigMap: +Ready-to-apply sample: ```bash -kubectl apply -f - </llama-stack-k8s-operator: + make image IMG=quay.io//ogx-k8s-operator: ``` - The default image used is `quay.io/llamastack/llama-stack-k8s-operator:latest` when not supply argument for `make image` + The default image used is `quay.io/ogx-ai/ogx-k8s-operator:latest` when not supply argument for `make image` To create a local file `local.mk` with env variables can overwrite the default values set in the `Makefile`. - Building multi-architecture images (ARM64, AMD64, etc.) @@ -227,17 +281,17 @@ This will cause all LlamaStackDistribution resources using the `starter` distrib The operator supports building for multiple architectures including ARM64. To build and push multi-arch images: ```commandline - make image-buildx IMG=quay.io//llama-stack-k8s-operator: + make image-buildx IMG=quay.io//ogx-k8s-operator: ``` By default, this builds for `linux/amd64,linux/arm64`. You can customize the platforms by setting the `PLATFORMS` variable: ```commandline # Build for specific platforms - make image-buildx IMG=quay.io//llama-stack-k8s-operator: PLATFORMS=linux/amd64,linux/arm64 + make image-buildx IMG=quay.io//ogx-k8s-operator: PLATFORMS=linux/amd64,linux/arm64 # Add more architectures (e.g., for future support) - make image-buildx IMG=quay.io//llama-stack-k8s-operator: PLATFORMS=linux/amd64,linux/arm64,linux/s390x,linux/ppc64le + make image-buildx IMG=quay.io//ogx-k8s-operator: PLATFORMS=linux/amd64,linux/arm64,linux/s390x,linux/ppc64le ``` **Note**: @@ -260,11 +314,11 @@ This will cause all LlamaStackDistribution resources using the `starter` distrib ```commandline # Build and push a single-arch image (used by each matrix job on its native runner) - make image-build-push-single PLATFORM=linux/amd64 IMG=quay.io//llama-stack-k8s-operator:-amd64 + make image-build-push-single PLATFORM=linux/amd64 IMG=quay.io//ogx-k8s-operator:-amd64 # Create a multi-arch manifest from per-arch images (used by the final manifest job) - make image-create-manifest IMG=quay.io//llama-stack-k8s-operator: \ - ARCH_IMGS="quay.io//llama-stack-k8s-operator:-amd64 quay.io//llama-stack-k8s-operator:-arm64" + make image-create-manifest IMG=quay.io//ogx-k8s-operator: \ + ARCH_IMGS="quay.io//ogx-k8s-operator:-amd64 quay.io//ogx-k8s-operator:-arm64" ``` - Building ARM64-only images @@ -272,8 +326,8 @@ This will cause all LlamaStackDistribution resources using the `starter` distrib To build a single ARM64 image (useful for testing or ARM-native systems): ```commandline - make image-build-arm IMG=quay.io//llama-stack-k8s-operator: - make image-push IMG=quay.io//llama-stack-k8s-operator: + make image-build-arm IMG=quay.io//ogx-k8s-operator: + make image-push IMG=quay.io//ogx-k8s-operator: ``` This works with both Docker and Podman. @@ -287,12 +341,12 @@ This will cause all LlamaStackDistribution resources using the `starter` distrib ### Deployment -**Deploying operator locally** +**Deploying on vanilla Kubernetes (cert-manager)** - Deploy the created image in your cluster using following command: ```commandline - make deploy IMG=quay.io//llama-stack-k8s-operator: + make deploy IMG=quay.io//ogx-k8s-operator: ``` - To remove resources created during installation use: @@ -301,6 +355,21 @@ This will cause all LlamaStackDistribution resources using the `starter` distrib make undeploy ``` +**Deploying on OpenShift** + +OpenShift clusters use the built-in service-serving-cert-signer for webhook TLS +(no cert-manager required): + + ```commandline + make deploy-openshift IMG=quay.io//ogx-k8s-operator: + ``` + +- To remove resources: + + ```commandline + make undeploy-openshift + ``` + ## Running E2E Tests The operator includes end-to-end (E2E) tests to verify the complete functionality of the operator. To run the E2E tests: diff --git a/api/v1beta1/groupversion_info.go b/api/v1beta1/groupversion_info.go new file mode 100644 index 000000000..9fc1cade3 --- /dev/null +++ b/api/v1beta1/groupversion_info.go @@ -0,0 +1,36 @@ +/* +Copyright 2025. + +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. +*/ + +// Package v1beta1 contains API Schema definitions for the ogx.io v1beta1 API group. +// +kubebuilder:object:generate=true +// +groupName=ogx.io +package v1beta1 + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/scheme" +) + +var ( + // GroupVersion is group version used to register these objects. + GroupVersion = schema.GroupVersion{Group: "ogx.io", Version: "v1beta1"} + + // SchemeBuilder is used to add go types to the GroupVersionKind scheme. + SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} + + // AddToScheme adds the types in this group-version to the given scheme. + AddToScheme = SchemeBuilder.AddToScheme +) diff --git a/api/v1beta1/ogxserver_cel_test.go b/api/v1beta1/ogxserver_cel_test.go new file mode 100644 index 000000000..8763a1238 --- /dev/null +++ b/api/v1beta1/ogxserver_cel_test.go @@ -0,0 +1,1427 @@ +/* +Copyright 2025. + +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. +*/ + +package v1beta1 + +import ( + "context" + "testing" + + "k8s.io/apimachinery/pkg/api/resource" + "k8s.io/apimachinery/pkg/util/intstr" +) + +func TestCEL_DistributionSpec(t *testing.T) { + ns := createCELTestNamespace(t, "cel-dist") + + tests := []struct { + name string + mutate func(*OGXServer) + wantError string + }{ + { + name: "image only is valid", + mutate: func(_ *OGXServer) {}, + }, + { + name: "name only is valid", + mutate: func(o *OGXServer) { + o.Spec.Distribution = DistributionSpec{Name: "starter"} + }, + }, + { + name: "both name and image is invalid", + mutate: func(o *OGXServer) { + o.Spec.Distribution = DistributionSpec{Name: "starter", Image: "test:latest"} + }, + wantError: "only one of name or image can be specified", + }, + { + name: "neither name nor image is invalid", + mutate: func(o *OGXServer) { + o.Spec.Distribution = DistributionSpec{} + }, + wantError: "one of name or image must be specified", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + obj := validOGXServer(uniqueName(), ns) + tt.mutate(obj) + err := k8sClient.Create(context.Background(), obj) + if tt.wantError == "" { + if err != nil { + t.Fatalf("expected success, got: %v", err) + } + t.Cleanup(func() { _ = k8sClient.Delete(context.Background(), obj) }) + } else { + requireCELError(t, err, tt.wantError) + } + }) + } +} + +func TestCEL_CustomProvider(t *testing.T) { + ns := createCELTestNamespace(t, "cel-prov") + + tests := []struct { + name string + mutate func(*OGXServer) + wantError string + }{ + { + name: "remote prefix is valid", + mutate: func(o *OGXServer) { + o.Spec.Providers = &ProvidersSpec{ + Inference: &InferenceProvidersSpec{ + Remote: &InferenceRemoteProviders{ + Custom: []CustomProvider{{Type: "remote::my-provider"}}, + }, + }, + } + }, + }, + { + name: "inline prefix is valid", + mutate: func(o *OGXServer) { + o.Spec.Providers = &ProvidersSpec{ + Inference: &InferenceProvidersSpec{ + Inline: &InferenceInlineProviders{ + Custom: []CustomProvider{{Type: "inline::builtin"}}, + }, + }, + } + }, + }, + { + name: "no prefix is invalid", + mutate: func(o *OGXServer) { + o.Spec.Providers = &ProvidersSpec{ + Inference: &InferenceProvidersSpec{ + Remote: &InferenceRemoteProviders{ + Custom: []CustomProvider{{Type: "vllm"}}, + }, + }, + } + }, + wantError: "type must have a 'remote::' or 'inline::' prefix", + }, + { + name: "single colon prefix is invalid", + mutate: func(o *OGXServer) { + o.Spec.Providers = &ProvidersSpec{ + Inference: &InferenceProvidersSpec{ + Remote: &InferenceRemoteProviders{ + Custom: []CustomProvider{{Type: "remote:vllm"}}, + }, + }, + } + }, + wantError: "type must have a 'remote::' or 'inline::' prefix", + }, + { + name: "wrong case prefix is invalid", + mutate: func(o *OGXServer) { + o.Spec.Providers = &ProvidersSpec{ + Inference: &InferenceProvidersSpec{ + Remote: &InferenceRemoteProviders{ + Custom: []CustomProvider{{Type: "Remote::vllm"}}, + }, + }, + } + }, + wantError: "type must have a 'remote::' or 'inline::' prefix", + }, + { + name: "bare double colon prefix is invalid", + mutate: func(o *OGXServer) { + o.Spec.Providers = &ProvidersSpec{ + Inference: &InferenceProvidersSpec{ + Remote: &InferenceRemoteProviders{ + Custom: []CustomProvider{{Type: "::vllm"}}, + }, + }, + } + }, + wantError: "type must have a 'remote::' or 'inline::' prefix", + }, + { + name: "explicit non-empty id is valid", + mutate: func(o *OGXServer) { + o.Spec.Providers = &ProvidersSpec{ + Inference: &InferenceProvidersSpec{ + Remote: &InferenceRemoteProviders{ + Custom: []CustomProvider{{ + RoutedProviderBase: RoutedProviderBase{ID: "my-vllm"}, + Type: "remote::vllm", + }}, + }, + }, + } + }, + }, + { + name: "id omitted is valid", + mutate: func(o *OGXServer) { + o.Spec.Providers = &ProvidersSpec{ + Inference: &InferenceProvidersSpec{ + Remote: &InferenceRemoteProviders{ + Custom: []CustomProvider{{Type: "remote::vllm"}}, + }, + }, + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + obj := validOGXServer(uniqueName(), ns) + tt.mutate(obj) + err := k8sClient.Create(context.Background(), obj) + if tt.wantError == "" { + if err != nil { + t.Fatalf("expected success, got: %v", err) + } + t.Cleanup(func() { _ = k8sClient.Delete(context.Background(), obj) }) + } else { + requireCELError(t, err, tt.wantError) + } + }) + } +} + +func TestCEL_ModelConfig(t *testing.T) { + ns := createCELTestNamespace(t, "cel-model") + + tests := []struct { + name string + mutate func(*OGXServer) + wantError string + }{ + { + name: "all optional fields populated is valid", + mutate: func(o *OGXServer) { + o.Spec.Resources = &ResourcesSpec{ + Models: []ModelConfig{{ + Name: "llama3", + Provider: "vllm", + ModelType: "llm", + Quantization: "int8", + }}, + } + }, + }, + { + name: "only required name field is valid", + mutate: func(o *OGXServer) { + o.Spec.Resources = &ResourcesSpec{ + Models: []ModelConfig{{Name: "llama3"}}, + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + obj := validOGXServer(uniqueName(), ns) + tt.mutate(obj) + err := k8sClient.Create(context.Background(), obj) + if tt.wantError == "" { + if err != nil { + t.Fatalf("expected success, got: %v", err) + } + t.Cleanup(func() { _ = k8sClient.Delete(context.Background(), obj) }) + } else { + requireCELError(t, err, tt.wantError) + } + }) + } +} + +func TestCEL_KVStorageSpec(t *testing.T) { + ns := createCELTestNamespace(t, "cel-kv") + + tests := []struct { + name string + mutate func(*OGXServer) + wantError string + }{ + { + name: "sqlite with no endpoint is valid", + mutate: func(o *OGXServer) { + o.Spec.Storage = &StateStorageSpec{ + KV: &KVStorageSpec{Type: "sqlite"}, + } + }, + }, + { + name: "redis with endpoint is valid", + mutate: func(o *OGXServer) { + o.Spec.Storage = &StateStorageSpec{ + KV: &KVStorageSpec{Type: "redis", Endpoint: "redis://localhost:6379"}, + } + }, + }, + { + name: "redis without endpoint is invalid", + mutate: func(o *OGXServer) { + o.Spec.Storage = &StateStorageSpec{ + KV: &KVStorageSpec{Type: "redis"}, + } + }, + wantError: "endpoint is required when type is redis", + }, + { + name: "sqlite with endpoint is invalid", + mutate: func(o *OGXServer) { + o.Spec.Storage = &StateStorageSpec{ + KV: &KVStorageSpec{Type: "sqlite", Endpoint: "redis://localhost:6379"}, + } + }, + wantError: "endpoint is only valid when type is redis", + }, + { + name: "sqlite with password is invalid", + mutate: func(o *OGXServer) { + o.Spec.Storage = &StateStorageSpec{ + KV: &KVStorageSpec{ + Type: "sqlite", + Password: &SecretKeyRef{Name: "secret", Key: "password"}, + }, + } + }, + wantError: "password is only valid when type is redis", + }, + { + name: "redis with password and endpoint is valid", + mutate: func(o *OGXServer) { + o.Spec.Storage = &StateStorageSpec{ + KV: &KVStorageSpec{ + Type: "redis", + Endpoint: "redis://localhost:6379", + Password: &SecretKeyRef{Name: "secret", Key: "password"}, + }, + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + obj := validOGXServer(uniqueName(), ns) + tt.mutate(obj) + err := k8sClient.Create(context.Background(), obj) + if tt.wantError == "" { + if err != nil { + t.Fatalf("expected success, got: %v", err) + } + t.Cleanup(func() { _ = k8sClient.Delete(context.Background(), obj) }) + } else { + requireCELError(t, err, tt.wantError) + } + }) + } +} + +func TestCEL_SQLStorageSpec(t *testing.T) { + ns := createCELTestNamespace(t, "cel-sql") + + tests := []struct { + name string + mutate func(*OGXServer) + wantError string + }{ + { + name: "sqlite with no connectionString is valid", + mutate: func(o *OGXServer) { + o.Spec.Storage = &StateStorageSpec{ + SQL: &SQLStorageSpec{Type: "sqlite"}, + } + }, + }, + { + name: "postgres with connectionString is valid", + mutate: func(o *OGXServer) { + o.Spec.Storage = &StateStorageSpec{ + SQL: &SQLStorageSpec{ + Type: "postgres", + ConnectionString: &SecretKeyRef{Name: "secret", Key: "connstr"}, + }, + } + }, + }, + { + name: "postgres without connectionString is invalid", + mutate: func(o *OGXServer) { + o.Spec.Storage = &StateStorageSpec{ + SQL: &SQLStorageSpec{Type: "postgres"}, + } + }, + wantError: "connectionString is required when type is postgres", + }, + { + name: "sqlite with connectionString is invalid", + mutate: func(o *OGXServer) { + o.Spec.Storage = &StateStorageSpec{ + SQL: &SQLStorageSpec{ + Type: "sqlite", + ConnectionString: &SecretKeyRef{Name: "secret", Key: "connstr"}, + }, + } + }, + wantError: "connectionString is only valid when type is postgres", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + obj := validOGXServer(uniqueName(), ns) + tt.mutate(obj) + err := k8sClient.Create(context.Background(), obj) + if tt.wantError == "" { + if err != nil { + t.Fatalf("expected success, got: %v", err) + } + t.Cleanup(func() { _ = k8sClient.Delete(context.Background(), obj) }) + } else { + requireCELError(t, err, tt.wantError) + } + }) + } +} + +func TestCEL_PVCStorageSpec(t *testing.T) { + ns := createCELTestNamespace(t, "cel-pvc") + + tests := []struct { + name string + mutate func(*OGXServer) + wantError string + }{ + { + name: "custom mountPath is valid", + mutate: func(o *OGXServer) { + o.Spec.Workload = &WorkloadSpec{ + Storage: &PVCStorageSpec{MountPath: "/data"}, + } + }, + }, + { + name: "positive size is valid", + mutate: func(o *OGXServer) { + q := resource.MustParse("10Gi") + o.Spec.Workload = &WorkloadSpec{ + Storage: &PVCStorageSpec{Size: &q}, + } + }, + }, + { + name: "zero size is invalid", + mutate: func(o *OGXServer) { + q := resource.MustParse("0") + o.Spec.Workload = &WorkloadSpec{ + Storage: &PVCStorageSpec{Size: &q}, + } + }, + wantError: "size must be a positive quantity", + }, + { + name: "negative size is invalid", + mutate: func(o *OGXServer) { + q := resource.MustParse("-1Gi") + o.Spec.Workload = &WorkloadSpec{ + Storage: &PVCStorageSpec{Size: &q}, + } + }, + wantError: "size must be a positive quantity", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + obj := validOGXServer(uniqueName(), ns) + tt.mutate(obj) + err := k8sClient.Create(context.Background(), obj) + if tt.wantError == "" { + if err != nil { + t.Fatalf("expected success, got: %v", err) + } + t.Cleanup(func() { _ = k8sClient.Delete(context.Background(), obj) }) + } else { + requireCELError(t, err, tt.wantError) + } + }) + } +} + +func TestCEL_PodDisruptionBudgetSpec(t *testing.T) { + ns := createCELTestNamespace(t, "cel-pdb") + + tests := []struct { + name string + mutate func(*OGXServer) + wantError string + }{ + { + name: "minAvailable only is valid", + mutate: func(o *OGXServer) { + v := intstr.FromInt32(1) + o.Spec.Workload = &WorkloadSpec{ + PodDisruptionBudget: &PodDisruptionBudgetSpec{MinAvailable: &v}, + } + }, + }, + { + name: "maxUnavailable only is valid", + mutate: func(o *OGXServer) { + v := intstr.FromInt32(1) + o.Spec.Workload = &WorkloadSpec{ + PodDisruptionBudget: &PodDisruptionBudgetSpec{MaxUnavailable: &v}, + } + }, + }, + { + name: "both set is invalid", + mutate: func(o *OGXServer) { + minVal := intstr.FromInt32(1) + maxVal := intstr.FromInt32(1) + o.Spec.Workload = &WorkloadSpec{ + PodDisruptionBudget: &PodDisruptionBudgetSpec{ + MinAvailable: &minVal, + MaxUnavailable: &maxVal, + }, + } + }, + wantError: "minAvailable and maxUnavailable are mutually exclusive", + }, + { + name: "neither set is invalid", + mutate: func(o *OGXServer) { + o.Spec.Workload = &WorkloadSpec{ + PodDisruptionBudget: &PodDisruptionBudgetSpec{}, + } + }, + wantError: "at least one of minAvailable or maxUnavailable must be specified", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + obj := validOGXServer(uniqueName(), ns) + tt.mutate(obj) + err := k8sClient.Create(context.Background(), obj) + if tt.wantError == "" { + if err != nil { + t.Fatalf("expected success, got: %v", err) + } + t.Cleanup(func() { _ = k8sClient.Delete(context.Background(), obj) }) + } else { + requireCELError(t, err, tt.wantError) + } + }) + } +} + +func TestCEL_AutoscalingSpec(t *testing.T) { + ns := createCELTestNamespace(t, "cel-hpa") + + tests := []struct { + name string + mutate func(*OGXServer) + wantError string + }{ + { + name: "maxReplicas greater than minReplicas is valid", + mutate: func(o *OGXServer) { + o.Spec.Workload = &WorkloadSpec{ + Autoscaling: &AutoscalingSpec{ + MinReplicas: ptr(int32(2)), + MaxReplicas: 5, + }, + } + }, + }, + { + name: "maxReplicas equal to minReplicas is valid", + mutate: func(o *OGXServer) { + o.Spec.Workload = &WorkloadSpec{ + Autoscaling: &AutoscalingSpec{ + MinReplicas: ptr(int32(3)), + MaxReplicas: 3, + }, + } + }, + }, + { + name: "maxReplicas less than minReplicas is invalid", + mutate: func(o *OGXServer) { + o.Spec.Workload = &WorkloadSpec{ + Autoscaling: &AutoscalingSpec{ + MinReplicas: ptr(int32(5)), + MaxReplicas: 2, + }, + } + }, + wantError: "maxReplicas must be greater than or equal to minReplicas", + }, + { + name: "minReplicas omitted is valid", + mutate: func(o *OGXServer) { + o.Spec.Workload = &WorkloadSpec{ + Autoscaling: &AutoscalingSpec{MaxReplicas: 3}, + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + obj := validOGXServer(uniqueName(), ns) + tt.mutate(obj) + err := k8sClient.Create(context.Background(), obj) + if tt.wantError == "" { + if err != nil { + t.Fatalf("expected success, got: %v", err) + } + t.Cleanup(func() { _ = k8sClient.Delete(context.Background(), obj) }) + } else { + requireCELError(t, err, tt.wantError) + } + }) + } +} + +func TestCEL_OGXServerSpec_OverrideConfigExclusivity(t *testing.T) { + ns := createCELTestNamespace(t, "cel-override") + + tests := []struct { + name string + mutate func(*OGXServer) + wantError string + }{ + { + name: "overrideConfig alone is valid", + mutate: func(o *OGXServer) { + o.Spec.OverrideConfig = &ConfigMapKeyRef{Name: "my-config", Key: "config.yaml"} + }, + }, + { + name: "overrideConfig with providers is invalid", + mutate: func(o *OGXServer) { + o.Spec.OverrideConfig = &ConfigMapKeyRef{Name: "my-config", Key: "config.yaml"} + o.Spec.Providers = &ProvidersSpec{ + Inference: &InferenceProvidersSpec{ + Remote: &InferenceRemoteProviders{ + VLLM: []VLLMProvider{{Endpoint: "https://vllm:8000"}}, + }, + }, + } + }, + wantError: "overrideConfig and providers are mutually exclusive", + }, + { + name: "overrideConfig with resources is invalid", + mutate: func(o *OGXServer) { + o.Spec.OverrideConfig = &ConfigMapKeyRef{Name: "my-config", Key: "config.yaml"} + o.Spec.Resources = &ResourcesSpec{ + Models: []ModelConfig{{Name: "llama3"}}, + } + }, + wantError: "overrideConfig and resources are mutually exclusive", + }, + { + name: "overrideConfig with storage is invalid", + mutate: func(o *OGXServer) { + o.Spec.OverrideConfig = &ConfigMapKeyRef{Name: "my-config", Key: "config.yaml"} + o.Spec.Storage = &StateStorageSpec{ + KV: &KVStorageSpec{Type: "sqlite"}, + } + }, + wantError: "overrideConfig and storage are mutually exclusive", + }, + { + name: "overrideConfig with disabledAPIs is invalid", + mutate: func(o *OGXServer) { + o.Spec.OverrideConfig = &ConfigMapKeyRef{Name: "my-config", Key: "config.yaml"} + o.Spec.DisabledAPIs = []string{"inference"} + }, + wantError: "overrideConfig and disabledAPIs are mutually exclusive", + }, + { + name: "overrideConfig with baseConfig is invalid", + mutate: func(o *OGXServer) { + o.Spec.OverrideConfig = &ConfigMapKeyRef{Name: "my-config", Key: "config.yaml"} + o.Spec.BaseConfig = &ConfigMapKeyRef{Name: "base-config", Key: "config.yaml"} + }, + wantError: "overrideConfig and baseConfig are mutually exclusive", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + obj := validOGXServer(uniqueName(), ns) + tt.mutate(obj) + err := k8sClient.Create(context.Background(), obj) + if tt.wantError == "" { + if err != nil { + t.Fatalf("expected success, got: %v", err) + } + t.Cleanup(func() { _ = k8sClient.Delete(context.Background(), obj) }) + } else { + requireCELError(t, err, tt.wantError) + } + }) + } +} + +func TestCEL_OGXServerSpec_DisabledAPIsProviderConflict(t *testing.T) { + ns := createCELTestNamespace(t, "cel-disabled") + + tests := []struct { + name string + mutate func(*OGXServer) + wantError string + }{ + { + name: "disabledAPIs batches with inference provider has no conflict", + mutate: func(o *OGXServer) { + o.Spec.DisabledAPIs = []string{"batches"} + o.Spec.Providers = &ProvidersSpec{ + Inference: &InferenceProvidersSpec{ + Remote: &InferenceRemoteProviders{ + VLLM: []VLLMProvider{{Endpoint: "https://vllm:8000"}}, + }, + }, + } + }, + }, + { + name: "inference in both disabledAPIs and providers is invalid", + mutate: func(o *OGXServer) { + o.Spec.DisabledAPIs = []string{"inference"} + o.Spec.Providers = &ProvidersSpec{ + Inference: &InferenceProvidersSpec{ + Remote: &InferenceRemoteProviders{ + VLLM: []VLLMProvider{{Endpoint: "https://vllm:8000"}}, + }, + }, + } + }, + wantError: "inference cannot be both in providers and disabledAPIs", + }, + { + name: "vector_io in both disabledAPIs and providers is invalid", + mutate: func(o *OGXServer) { + o.Spec.DisabledAPIs = []string{"vector_io"} + o.Spec.Providers = &ProvidersSpec{ + VectorIo: &VectorIOProvidersSpec{ + Remote: &VectorIORemoteProviders{ + Pgvector: []PgvectorProvider{{Password: SecretKeyRef{Name: "s", Key: "k"}}}, + }, + }, + } + }, + wantError: "vector_io cannot be both in providers and disabledAPIs", + }, + { + name: "tool_runtime in both disabledAPIs and providers is invalid", + mutate: func(o *OGXServer) { + o.Spec.DisabledAPIs = []string{"tool_runtime"} + o.Spec.Providers = &ProvidersSpec{ + ToolRuntime: &ToolRuntimeProvidersSpec{ + Remote: &ToolRuntimeRemoteProviders{ + BraveSearch: []BraveSearchProvider{{APIKey: SecretKeyRef{Name: "s", Key: "k"}}}, + }, + }, + } + }, + wantError: "tool_runtime cannot be both in providers and disabledAPIs", + }, + { + name: "files in both disabledAPIs and providers is invalid", + mutate: func(o *OGXServer) { + o.Spec.DisabledAPIs = []string{"files"} + o.Spec.Providers = &ProvidersSpec{ + Files: &FilesProvidersSpec{ + Inline: &FilesInlineProviders{ + LocalFS: &InlineLocalFSProvider{}, + }, + }, + } + }, + wantError: "files cannot be both in providers and disabledAPIs", + }, + { + name: "file_processors in both disabledAPIs and providers is invalid", + mutate: func(o *OGXServer) { + o.Spec.DisabledAPIs = []string{"file_processors"} + o.Spec.Providers = &ProvidersSpec{ + FileProcessors: &FileProcessorsProvidersSpec{ + Inline: &FileProcessorsInlineProviders{ + PyPDF: &InlinePyPDFFileProcessorProvider{}, + }, + }, + } + }, + wantError: "file_processors cannot be both in providers and disabledAPIs", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + obj := validOGXServer(uniqueName(), ns) + tt.mutate(obj) + err := k8sClient.Create(context.Background(), obj) + if tt.wantError == "" { + if err != nil { + t.Fatalf("expected success, got: %v", err) + } + t.Cleanup(func() { _ = k8sClient.Delete(context.Background(), obj) }) + } else { + requireCELError(t, err, tt.wantError) + } + }) + } +} + +func TestCEL_ExternalAccessConfig(t *testing.T) { + ns := createCELTestNamespace(t, "cel-extaccess") + + tests := []struct { + name string + mutate func(*OGXServer) + wantError string + }{ + { + name: "hostname absent is valid", + mutate: func(o *OGXServer) { + o.Spec.Network = &NetworkSpec{ + ExternalAccess: &ExternalAccessConfig{Enabled: true}, + } + }, + }, + { + name: "hostname set is valid", + mutate: func(o *OGXServer) { + o.Spec.Network = &NetworkSpec{ + ExternalAccess: &ExternalAccessConfig{ + Enabled: true, + Hostname: "example.com", + }, + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + obj := validOGXServer(uniqueName(), ns) + tt.mutate(obj) + err := k8sClient.Create(context.Background(), obj) + if tt.wantError == "" { + if err != nil { + t.Fatalf("expected success, got: %v", err) + } + t.Cleanup(func() { _ = k8sClient.Delete(context.Background(), obj) }) + } else { + requireCELError(t, err, tt.wantError) + } + }) + } +} + +func TestCEL_WorkloadOverrides(t *testing.T) { + ns := createCELTestNamespace(t, "cel-overrides") + + tests := []struct { + name string + mutate func(*OGXServer) + wantError string + }{ + { + name: "serviceAccountName absent is valid", + mutate: func(o *OGXServer) { + o.Spec.Workload = &WorkloadSpec{ + Overrides: &WorkloadOverrides{}, + } + }, + }, + { + name: "serviceAccountName set is valid", + mutate: func(o *OGXServer) { + o.Spec.Workload = &WorkloadSpec{ + Overrides: &WorkloadOverrides{ServiceAccountName: "my-sa"}, + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + obj := validOGXServer(uniqueName(), ns) + tt.mutate(obj) + err := k8sClient.Create(context.Background(), obj) + if tt.wantError == "" { + if err != nil { + t.Fatalf("expected success, got: %v", err) + } + t.Cleanup(func() { _ = k8sClient.Delete(context.Background(), obj) }) + } else { + requireCELError(t, err, tt.wantError) + } + }) + } +} + +func TestCEL_RegistryRefreshIntervalSeconds(t *testing.T) { + ns := createCELTestNamespace(t, "cel-regref") + + tests := []struct { + name string + mutate func(*OGXServer) + wantError string + }{ + { + name: "field omitted is valid", + mutate: func(_ *OGXServer) {}, + }, + { + name: "value of 1 is valid", + mutate: func(o *OGXServer) { + o.Spec.RegistryRefreshIntervalSeconds = ptr(int32(1)) + }, + }, + { + name: "large value is valid", + mutate: func(o *OGXServer) { + o.Spec.RegistryRefreshIntervalSeconds = ptr(int32(86400)) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + obj := validOGXServer(uniqueName(), ns) + tt.mutate(obj) + err := k8sClient.Create(context.Background(), obj) + if tt.wantError == "" { + if err != nil { + t.Fatalf("expected success, got: %v", err) + } + t.Cleanup(func() { _ = k8sClient.Delete(context.Background(), obj) }) + } else { + requireCELError(t, err, tt.wantError) + } + }) + } + + t.Run("value of 0 is rejected by minimum constraint", func(t *testing.T) { + raw := validUnstructuredOGXServer(t, uniqueName(), ns) + setNestedField(raw, int64(0), "spec", "registryRefreshIntervalSeconds") + err := createUnstructured(t, raw) + requireAPIError(t, err, "should be greater than or equal to 1") + }) +} + +func TestCEL_VectorIndexConfig(t *testing.T) { + ns := createCELTestNamespace(t, "cel-vidx") + + tests := []struct { + name string + mutate func(*OGXServer) + wantError string + }{ + { + name: "hnsw only is valid", + mutate: func(o *OGXServer) { + o.Spec.Providers = &ProvidersSpec{ + VectorIo: &VectorIOProvidersSpec{ + Remote: &VectorIORemoteProviders{ + Pgvector: []PgvectorProvider{{ + Password: SecretKeyRef{Name: "s", Key: "k"}, + VectorIndex: &VectorIndexConfig{HNSW: &HNSWConfig{M: ptr(16)}}, + }}, + }, + }, + } + }, + }, + { + name: "ivfFlat only is valid", + mutate: func(o *OGXServer) { + o.Spec.Providers = &ProvidersSpec{ + VectorIo: &VectorIOProvidersSpec{ + Remote: &VectorIORemoteProviders{ + Pgvector: []PgvectorProvider{{ + Password: SecretKeyRef{Name: "s", Key: "k"}, + VectorIndex: &VectorIndexConfig{IVFFlat: &IVFFlatConfig{Nlist: ptr(100)}}, + }}, + }, + }, + } + }, + }, + { + name: "both hnsw and ivfFlat is invalid", + mutate: func(o *OGXServer) { + o.Spec.Providers = &ProvidersSpec{ + VectorIo: &VectorIOProvidersSpec{ + Remote: &VectorIORemoteProviders{ + Pgvector: []PgvectorProvider{{ + Password: SecretKeyRef{Name: "s", Key: "k"}, + VectorIndex: &VectorIndexConfig{ + HNSW: &HNSWConfig{M: ptr(16)}, + IVFFlat: &IVFFlatConfig{Nlist: ptr(100)}, + }, + }}, + }, + }, + } + }, + wantError: "only one of hnsw or ivfFlat can be specified", + }, + { + name: "neither hnsw nor ivfFlat is invalid", + mutate: func(o *OGXServer) { + o.Spec.Providers = &ProvidersSpec{ + VectorIo: &VectorIOProvidersSpec{ + Remote: &VectorIORemoteProviders{ + Pgvector: []PgvectorProvider{{ + Password: SecretKeyRef{Name: "s", Key: "k"}, + VectorIndex: &VectorIndexConfig{}, + }}, + }, + }, + } + }, + wantError: "one of hnsw or ivfFlat must be specified", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + obj := validOGXServer(uniqueName(), ns) + tt.mutate(obj) + err := k8sClient.Create(context.Background(), obj) + if tt.wantError == "" { + if err != nil { + t.Fatalf("expected success, got: %v", err) + } + t.Cleanup(func() { _ = k8sClient.Delete(context.Background(), obj) }) + } else { + requireCELError(t, err, tt.wantError) + } + }) + } +} + +// emptyStringTest defines a test case for CEL rules of the form +// `!has(self.X) || self.X.size() > 0` that cannot be triggered via typed Go +// structs because `omitempty` strips empty strings from the JSON payload. +// These rules guard against raw JSON/YAML submissions (e.g. kubectl apply) +// that explicitly set an optional string field to "". +type emptyStringTest struct { + name string + mutate func(map[string]any) + wantError string +} + +func runEmptyStringTests(t *testing.T, ns string, tests []emptyStringTest) { + t.Helper() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + raw := validUnstructuredOGXServer(t, uniqueName(), ns) + tt.mutate(raw) + err := createUnstructured(t, raw) + requireAPIError(t, err, tt.wantError) + }) + } +} + +func TestCEL_EmptyStringViaUnstructured_SpecFields(t *testing.T) { + ns := createCELTestNamespace(t, "cel-unstr-spec") + + runEmptyStringTests(t, ns, []emptyStringTest{ + { + name: "custom provider with empty id is invalid", + mutate: func(raw map[string]any) { + setNestedField(raw, []any{ + map[string]any{"id": "", "type": "remote::my-provider"}, + }, "spec", "providers", "inference", "remote", "custom") + }, + wantError: "id must not be empty if specified", + }, + { + name: "model config with empty provider is invalid", + mutate: func(raw map[string]any) { + setNestedField(raw, []any{ + map[string]any{"name": "llama3", "provider": ""}, + }, "spec", "resources", "models") + }, + wantError: "provider must not be empty if specified", + }, + { + name: "model config with empty modelType is invalid", + mutate: func(raw map[string]any) { + setNestedField(raw, []any{ + map[string]any{"name": "llama3", "modelType": ""}, + }, "spec", "resources", "models") + }, + wantError: "modelType must not be empty if specified", + }, + { + name: "model config with empty quantization is invalid", + mutate: func(raw map[string]any) { + setNestedField(raw, []any{ + map[string]any{"name": "llama3", "quantization": ""}, + }, "spec", "resources", "models") + }, + wantError: "quantization must not be empty if specified", + }, + { + name: "external access with empty hostname is invalid", + mutate: func(raw map[string]any) { + setNestedField(raw, map[string]any{ + "enabled": true, "hostname": "", + }, "spec", "network", "externalAccess") + }, + wantError: "hostname must not be empty if specified", + }, + { + name: "pvc storage with empty mountPath is invalid", + mutate: func(raw map[string]any) { + setNestedField(raw, map[string]any{ + "mountPath": "", + }, "spec", "workload", "storage") + }, + wantError: "mountPath must not be empty if specified", + }, + { + name: "workload overrides with empty serviceAccountName is invalid", + mutate: func(raw map[string]any) { + setNestedField(raw, map[string]any{ + "serviceAccountName": "", + }, "spec", "workload", "overrides") + }, + wantError: "serviceAccountName must not be empty if specified", + }, + }) +} + +func TestCEL_EmptyStringViaUnstructured_InferenceProviders(t *testing.T) { + ns := createCELTestNamespace(t, "cel-unstr-inf") + + runEmptyStringTests(t, ns, []emptyStringTest{ + { + name: "openai provider with empty endpoint is invalid", + mutate: func(raw map[string]any) { + setNestedField(raw, []any{ + map[string]any{"endpoint": "", "apiKey": map[string]any{"name": "s", "key": "k"}}, + }, "spec", "providers", "inference", "remote", "openai") + }, + wantError: "endpoint must not be empty if specified", + }, + { + name: "azure provider with empty apiVersion is invalid", + mutate: func(raw map[string]any) { + setNestedField(raw, []any{ + map[string]any{"endpoint": "https://az", "apiKey": map[string]any{"name": "s", "key": "k"}, "apiVersion": ""}, + }, "spec", "providers", "inference", "remote", "azure") + }, + wantError: "apiVersion must not be empty if specified", + }, + { + name: "azure provider with empty apiType is invalid", + mutate: func(raw map[string]any) { + setNestedField(raw, []any{ + map[string]any{"endpoint": "https://az", "apiKey": map[string]any{"name": "s", "key": "k"}, "apiType": ""}, + }, "spec", "providers", "inference", "remote", "azure") + }, + wantError: "apiType must not be empty if specified", + }, + { + name: "bedrock provider with empty awsRoleArn is invalid", + mutate: func(raw map[string]any) { + setNestedField(raw, []any{ + map[string]any{"region": "us-east-1", "awsRoleArn": ""}, + }, "spec", "providers", "inference", "remote", "bedrock") + }, + wantError: "awsRoleArn must not be empty if specified", + }, + { + name: "bedrock provider with empty profileName is invalid", + mutate: func(raw map[string]any) { + setNestedField(raw, []any{ + map[string]any{"region": "us-east-1", "profileName": ""}, + }, "spec", "providers", "inference", "remote", "bedrock") + }, + wantError: "profileName must not be empty if specified", + }, + { + name: "bedrock provider with empty retryMode is invalid", + mutate: func(raw map[string]any) { + setNestedField(raw, []any{ + map[string]any{"region": "us-east-1", "retryMode": ""}, + }, "spec", "providers", "inference", "remote", "bedrock") + }, + wantError: "retryMode must not be empty if specified", + }, + { + name: "bedrock provider with empty awsWebIdentityTokenFile is invalid", + mutate: func(raw map[string]any) { + setNestedField(raw, []any{ + map[string]any{"region": "us-east-1", "awsWebIdentityTokenFile": ""}, + }, "spec", "providers", "inference", "remote", "bedrock") + }, + wantError: "awsWebIdentityTokenFile must not be empty if specified", + }, + { + name: "bedrock provider with empty awsRoleSessionName is invalid", + mutate: func(raw map[string]any) { + setNestedField(raw, []any{ + map[string]any{"region": "us-east-1", "awsRoleSessionName": ""}, + }, "spec", "providers", "inference", "remote", "bedrock") + }, + wantError: "awsRoleSessionName must not be empty if specified", + }, + { + name: "vertexai provider with empty location is invalid", + mutate: func(raw map[string]any) { + setNestedField(raw, []any{ + map[string]any{"project": "my-project", "location": ""}, + }, "spec", "providers", "inference", "remote", "vertexai") + }, + wantError: "location must not be empty if specified", + }, + { + name: "watsonx provider with empty endpoint is invalid", + mutate: func(raw map[string]any) { + setNestedField(raw, []any{ + map[string]any{"apiKey": map[string]any{"name": "s", "key": "k"}, "endpoint": ""}, + }, "spec", "providers", "inference", "remote", "watsonx") + }, + wantError: "endpoint must not be empty if specified", + }, + { + name: "watsonx provider with empty projectId is invalid", + mutate: func(raw map[string]any) { + setNestedField(raw, []any{ + map[string]any{"apiKey": map[string]any{"name": "s", "key": "k"}, "projectId": ""}, + }, "spec", "providers", "inference", "remote", "watsonx") + }, + wantError: "projectId must not be empty if specified", + }, + }) +} + +func TestCEL_EmptyStringViaUnstructured_VectorIOAndFiles(t *testing.T) { + ns := createCELTestNamespace(t, "cel-unstr-vio") + + runEmptyStringTests(t, ns, []emptyStringTest{ + { + name: "pgvector provider with empty host is invalid", + mutate: func(raw map[string]any) { + setNestedField(raw, []any{ + map[string]any{"password": map[string]any{"name": "s", "key": "k"}, "host": ""}, + }, "spec", "providers", "vectorIo", "remote", "pgvector") + }, + wantError: "host must not be empty if specified", + }, + { + name: "pgvector provider with empty db is invalid", + mutate: func(raw map[string]any) { + setNestedField(raw, []any{ + map[string]any{"password": map[string]any{"name": "s", "key": "k"}, "db": ""}, + }, "spec", "providers", "vectorIo", "remote", "pgvector") + }, + wantError: "db must not be empty if specified", + }, + { + name: "pgvector provider with empty user is invalid", + mutate: func(raw map[string]any) { + setNestedField(raw, []any{ + map[string]any{"password": map[string]any{"name": "s", "key": "k"}, "user": ""}, + }, "spec", "providers", "vectorIo", "remote", "pgvector") + }, + wantError: "user must not be empty if specified", + }, + { + name: "milvus provider with empty consistencyLevel is invalid", + mutate: func(raw map[string]any) { + setNestedField(raw, []any{ + map[string]any{"uri": "http://milvus:19530", "consistencyLevel": ""}, + }, "spec", "providers", "vectorIo", "remote", "milvus") + }, + wantError: "consistencyLevel must not be empty if specified", + }, + { + name: "qdrant provider with empty url is invalid", + mutate: func(raw map[string]any) { + setNestedField(raw, []any{ + map[string]any{"url": "", "host": "qdrant"}, + }, "spec", "providers", "vectorIo", "remote", "qdrant") + }, + wantError: "url must not be empty if specified", + }, + { + name: "qdrant provider with empty host is invalid", + mutate: func(raw map[string]any) { + setNestedField(raw, []any{ + map[string]any{"url": "http://qdrant:6333", "host": ""}, + }, "spec", "providers", "vectorIo", "remote", "qdrant") + }, + wantError: "host must not be empty if specified", + }, + { + name: "qdrant provider with empty location is invalid", + mutate: func(raw map[string]any) { + setNestedField(raw, []any{ + map[string]any{"url": "http://qdrant:6333", "location": ""}, + }, "spec", "providers", "vectorIo", "remote", "qdrant") + }, + wantError: "location must not be empty if specified", + }, + { + name: "qdrant provider with empty prefix is invalid", + mutate: func(raw map[string]any) { + setNestedField(raw, []any{ + map[string]any{"url": "http://qdrant:6333", "prefix": ""}, + }, "spec", "providers", "vectorIo", "remote", "qdrant") + }, + wantError: "prefix must not be empty if specified", + }, + { + name: "s3 provider with empty region is invalid", + mutate: func(raw map[string]any) { + setNestedField(raw, map[string]any{ + "bucketName": "my-bucket", "region": "", + }, "spec", "providers", "files", "remote", "s3") + }, + wantError: "region must not be empty if specified", + }, + { + name: "s3 provider with empty endpointUrl is invalid", + mutate: func(raw map[string]any) { + setNestedField(raw, map[string]any{ + "bucketName": "my-bucket", "endpointUrl": "", + }, "spec", "providers", "files", "remote", "s3") + }, + wantError: "endpointUrl must not be empty if specified", + }, + }) +} + +func TestCEL_EmptyStringViaUnstructured_ResponsesAndNetwork(t *testing.T) { + ns := createCELTestNamespace(t, "cel-unstr-rsp") + + runEmptyStringTests(t, ns, []emptyStringTest{ + { + name: "vectorStoresConfig with empty defaultProviderId is invalid", + mutate: func(raw map[string]any) { + setNestedField(raw, map[string]any{ + "defaultProviderId": "", + }, "spec", "providers", "responses", "inline", "builtin", "vectorStoresConfig") + }, + wantError: "defaultProviderId must not be empty if specified", + }, + { + name: "compactionConfig with empty summarizationPrompt is invalid", + mutate: func(raw map[string]any) { + setNestedField(raw, map[string]any{ + "summarizationPrompt": "", + }, "spec", "providers", "responses", "inline", "builtin", "compactionConfig") + }, + wantError: "summarizationPrompt must not be empty if specified", + }, + { + name: "compactionConfig with empty summaryPrefix is invalid", + mutate: func(raw map[string]any) { + setNestedField(raw, map[string]any{ + "summaryPrefix": "", + }, "spec", "providers", "responses", "inline", "builtin", "compactionConfig") + }, + wantError: "summaryPrefix must not be empty if specified", + }, + { + name: "compactionConfig with empty summarizationModel is invalid", + mutate: func(raw map[string]any) { + setNestedField(raw, map[string]any{ + "summarizationModel": "", + }, "spec", "providers", "responses", "inline", "builtin", "compactionConfig") + }, + wantError: "summarizationModel must not be empty if specified", + }, + { + name: "compactionConfig with empty tokenizerEncoding is invalid", + mutate: func(raw map[string]any) { + setNestedField(raw, map[string]any{ + "tokenizerEncoding": "", + }, "spec", "providers", "responses", "inline", "builtin", "compactionConfig") + }, + wantError: "tokenizerEncoding must not be empty if specified", + }, + { + name: "proxy config with empty url is invalid", + mutate: func(raw map[string]any) { + setNestedField(raw, []any{ + map[string]any{ + "endpoint": "https://vllm:8000", + "network": map[string]any{"proxy": map[string]any{"url": ""}}, + }, + }, "spec", "providers", "inference", "remote", "vllm") + }, + wantError: "url must not be empty if specified", + }, + { + name: "proxy config with empty http is invalid", + mutate: func(raw map[string]any) { + setNestedField(raw, []any{ + map[string]any{ + "endpoint": "https://vllm:8000", + "network": map[string]any{"proxy": map[string]any{"http": ""}}, + }, + }, "spec", "providers", "inference", "remote", "vllm") + }, + wantError: "http must not be empty if specified", + }, + { + name: "proxy config with empty https is invalid", + mutate: func(raw map[string]any) { + setNestedField(raw, []any{ + map[string]any{ + "endpoint": "https://vllm:8000", + "network": map[string]any{"proxy": map[string]any{"https": ""}}, + }, + }, "spec", "providers", "inference", "remote", "vllm") + }, + wantError: "https must not be empty if specified", + }, + { + name: "proxy config with empty cacert is invalid", + mutate: func(raw map[string]any) { + setNestedField(raw, []any{ + map[string]any{ + "endpoint": "https://vllm:8000", + "network": map[string]any{"proxy": map[string]any{"cacert": ""}}, + }, + }, "spec", "providers", "inference", "remote", "vllm") + }, + wantError: "cacert must not be empty if specified", + }, + }) +} diff --git a/api/v1beta1/ogxserver_types.go b/api/v1beta1/ogxserver_types.go new file mode 100644 index 000000000..58b1389f6 --- /dev/null +++ b/api/v1beta1/ogxserver_types.go @@ -0,0 +1,696 @@ +/* +Copyright 2025. + +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. +*/ + +package v1beta1 + +//nolint:gci +import ( + "errors" + "fmt" + "regexp" + + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" +) + +const ( + // DefaultContainerName is the default name for the container. + DefaultContainerName = "ogx" + // DefaultServerPort is the default port for the server. + DefaultServerPort int32 = 8321 + // DefaultServicePortName is the default name for the service port. + DefaultServicePortName = "http" + // DefaultLabelKey is the default key for labels. + DefaultLabelKey = "app" + // DefaultLabelValue is the default value for labels. + DefaultLabelValue = "ogx" + // DefaultMountPath is the default mount path for storage. + DefaultMountPath = "/.ogx" + // OGXServerKind is the kind name for OGXServer resources. + OGXServerKind = "OGXServer" + // DefaultMetricsPortName is the port name used when a dedicated metrics port is configured. + DefaultMetricsPortName = "metrics" + + // AdoptStorageAnnotation triggers PVC adoption from a legacy LlamaStackDistribution. + AdoptStorageAnnotation = "ogx.io/adopt-storage" + // AdoptNetworkingAnnotation triggers Service/Ingress adoption from a legacy LlamaStackDistribution. + AdoptNetworkingAnnotation = "ogx.io/adopt-networking" + // AdoptedFromLabel is set on adopted resources to record the legacy source. + AdoptedFromLabel = "ogx.io/adopted-from" + // AdoptedAtAnnotation is set on adopted child resources with an RFC 3339 timestamp. + AdoptedAtAnnotation = "ogx.io/adopted-at" +) + +var ( + // DefaultStorageSize is the default size for persistent storage. + DefaultStorageSize = resource.MustParse("10Gi") + // DefaultServerCPURequest ensures the HPA and scheduler have baseline values. + DefaultServerCPURequest = resource.MustParse("500m") + // DefaultServerMemoryRequest ensures the HPA and scheduler have baseline values. + DefaultServerMemoryRequest = resource.MustParse("1Gi") + + // dns1123LabelMaxLen is the maximum length of an RFC 1123 DNS label. + dns1123LabelMaxLen = 63 + // dns1123LabelRegex matches valid RFC 1123 DNS labels. + dns1123LabelRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9\-]{0,61}[a-z0-9])?$`) +) + +// DistributionSpec identifies the OGX distribution image to deploy. +// Exactly one of name or image must be specified. +// +kubebuilder:validation:XValidation:rule="!(has(self.name) && has(self.image))",message="only one of name or image can be specified" +// +kubebuilder:validation:XValidation:rule="has(self.name) || has(self.image)",message="one of name or image must be specified" +type DistributionSpec struct { + // Name is the distribution name that maps to a supported distribution (e.g., "starter", "remote-vllm"). + // Resolved to a container image via distributions.json and image-overrides. + // +optional + Name string `json:"name,omitempty"` + // Image is a direct container image reference to use. + // +optional + Image string `json:"image,omitempty"` +} + +// SecretKeyRef references a specific key in a Kubernetes Secret. +// The Secret must be in the same namespace as the OGXServer and must have +// the label ogx.io/watch: "true" to be detected by the operator's cache. +type SecretKeyRef struct { + // Name is the name of the Kubernetes Secret. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + Name string `json:"name"` + // Key is the key within the Secret. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + // +kubebuilder:validation:Pattern="^[a-zA-Z0-9]([a-zA-Z0-9\\-_.]*[a-zA-Z0-9])?$" + Key string `json:"key"` +} + +// ConfigMapKeyRef references a key within a ConfigMap. +// The ConfigMap must be in the same namespace as the OGXServer and must have +// the label ogx.io/watch: "true" to be detected by the operator's cache. +type ConfigMapKeyRef struct { + // Name is the name of the ConfigMap. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + Name string `json:"name"` + // Key is the key within the ConfigMap. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + // +kubebuilder:validation:Pattern="^[a-zA-Z0-9]([a-zA-Z0-9\\-_.]*[a-zA-Z0-9])?$" + Key string `json:"key"` +} + +// ModelConfig defines a model registration with optional provider assignment and metadata. +// +kubebuilder:validation:XValidation:rule="!has(self.provider) || self.provider.size() > 0",message="provider must not be empty if specified" +// +kubebuilder:validation:XValidation:rule="!has(self.modelType) || self.modelType.size() > 0",message="modelType must not be empty if specified" +// +kubebuilder:validation:XValidation:rule="!has(self.quantization) || self.quantization.size() > 0",message="quantization must not be empty if specified" +type ModelConfig struct { + // Name is the model identifier (e.g., "llama3.2-8b"). + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + Name string `json:"name"` + // Provider is the ID of the provider to register this model with. + // Defaults to the first inference provider when omitted. + // +optional + Provider string `json:"provider,omitempty"` + // ContextLength is the model context window size. + // +optional + ContextLength *int `json:"contextLength,omitempty"` + // ModelType is the model type classification. + // +optional + ModelType string `json:"modelType,omitempty"` + // Quantization is the quantization method. + // +optional + Quantization string `json:"quantization,omitempty"` +} + +// ResourcesSpec defines declarative registration of models. +type ResourcesSpec struct { + // Models to register with inference providers. + // +optional + // +kubebuilder:validation:MinItems=1 + Models []ModelConfig `json:"models,omitempty"` +} + +// KVStorageSpec configures the key-value storage backend. +// +kubebuilder:validation:XValidation:rule="self.type != 'redis' || has(self.endpoint)",message="endpoint is required when type is redis" +// +kubebuilder:validation:XValidation:rule="!has(self.endpoint) || self.type == 'redis'",message="endpoint is only valid when type is redis" +// +kubebuilder:validation:XValidation:rule="!has(self.password) || self.type == 'redis'",message="password is only valid when type is redis" +type KVStorageSpec struct { + // Type is the KV storage backend type. + // +kubebuilder:validation:Enum=sqlite;redis + // +kubebuilder:default:="sqlite" + // +optional + Type string `json:"type,omitempty"` + // Endpoint is the Redis endpoint URL. Required when type is "redis". + // +optional + Endpoint string `json:"endpoint,omitempty"` + // Password references a Secret for Redis authentication. + // The Secret must be in the same namespace as the OGXServer + // and must have the label ogx.io/watch: "true". + // +optional + Password *SecretKeyRef `json:"password,omitempty"` +} + +// SQLStorageSpec configures the relational storage backend. +// +kubebuilder:validation:XValidation:rule="self.type != 'postgres' || has(self.connectionString)",message="connectionString is required when type is postgres" +// +kubebuilder:validation:XValidation:rule="!has(self.connectionString) || self.type == 'postgres'",message="connectionString is only valid when type is postgres" +type SQLStorageSpec struct { + // Type is the SQL storage backend type. + // +kubebuilder:validation:Enum=sqlite;postgres + // +kubebuilder:default:="sqlite" + // +optional + Type string `json:"type,omitempty"` + // ConnectionString references a Secret containing the database connection string. + // Required when type is "postgres". + // The Secret must be in the same namespace as the OGXServer + // and must have the label ogx.io/watch: "true". + // +optional + ConnectionString *SecretKeyRef `json:"connectionString,omitempty"` +} + +// StateStorageSpec groups key-value and SQL storage backends. +type StateStorageSpec struct { + // KV configures key-value storage. + // +optional + KV *KVStorageSpec `json:"kv,omitempty"` + // SQL configures SQL storage. + // +optional + SQL *SQLStorageSpec `json:"sql,omitempty"` +} + +// TLSSpec defines TLS termination configuration for the server. +type TLSSpec struct { + // SecretName references a Kubernetes TLS Secret containing a valid TLS certificate + // for server TLS termination. The Secret must be in the same namespace as the + // OGXServer and must have the label ogx.io/watch: "true" to be detected by the + // operator's cache. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + SecretName string `json:"secretName"` +} + +// TrustConfig configures trust anchors for verifying outbound TLS connections. +type TrustConfig struct { + // CACertificates lists ConfigMap keys containing PEM-encoded CA certificates. + // All certificates are concatenated into a single trust bundle. + // Referenced ConfigMaps must be in the same namespace as the OGXServer + // and must have the label ogx.io/watch: "true". + // +optional + // +kubebuilder:validation:MinItems=1 + CACertificates []ConfigMapKeyRef `json:"caCertificates,omitempty"` +} + +// IdentityConfig configures client certificate identity for mTLS authentication. +type IdentityConfig struct { + // Cert references a ConfigMap key containing the PEM-encoded TLS client certificate. + // The ConfigMap must be in the same namespace as the OGXServer + // and must have the label ogx.io/watch: "true". + // +kubebuilder:validation:Required + Cert ConfigMapKeyRef `json:"cert"` + // Key references a Secret key containing the PEM-encoded TLS client private key. + // The Secret must be in the same namespace as the OGXServer + // and must have the label ogx.io/watch: "true". + // +kubebuilder:validation:Required + Key SecretKeyRef `json:"key"` +} + +// TLSClientConfig groups outbound TLS settings: trust anchors and client identity. +type TLSClientConfig struct { + // Trust configures CA certificates for verifying outbound TLS connections + // to providers and backends. + // +optional + Trust *TrustConfig `json:"trust,omitempty"` + // Identity configures client certificate and key for mTLS authentication + // with providers and backends. + // +optional + Identity *IdentityConfig `json:"identity,omitempty"` +} + +// NetworkPolicySpec configures the operator-managed NetworkPolicy for this server. +// +// Ingress is always enforced unless explicitly omitted from policyTypes. +// The operator always includes default ingress rules (allow from same-namespace +// and operator-namespace on the service port), merging them with any +// user-specified rules. +// +// Egress is unrestricted by default. It is only enforced when egress rules +// are provided or "Egress" is explicitly included in policyTypes. +// When any egress rules are configured, or when "Egress" is explicitly included in +// policyTypes, a kube-dns egress rule is auto-injected to prevent DNS breakage. +type NetworkPolicySpec struct { + // Enabled controls whether the operator manages a NetworkPolicy for this server. + // Defaults to true. Set to false to disable NetworkPolicy creation entirely. + // +optional + // +kubebuilder:default:=true + Enabled *bool `json:"enabled,omitempty"` + // PolicyTypes specifies which policy directions are enforced. + // Follows Kubernetes NetworkPolicy semantics: when omitted or empty, + // Ingress is always included and Egress is included only if egress + // rules are provided. + // +optional + // +kubebuilder:validation:items:Enum=Ingress;Egress + PolicyTypes []networkingv1.PolicyType `json:"policyTypes,omitempty"` + // Ingress defines additional ingress rules, merged with operator defaults + // (allow from same-namespace and operator-namespace on the service port). + // +optional + Ingress []networkingv1.NetworkPolicyIngressRule `json:"ingress,omitempty"` + // Egress rules. When non-empty, a kube-dns egress rule is auto-injected + // to prevent DNS breakage. + // +optional + Egress []networkingv1.NetworkPolicyEgressRule `json:"egress,omitempty"` +} + +// ExternalAccessConfig controls external service exposure. +// +kubebuilder:validation:XValidation:rule="!has(self.hostname) || self.hostname.size() > 0",message="hostname must not be empty if specified" +type ExternalAccessConfig struct { + // Enabled controls whether external access is created. + // +optional + // +kubebuilder:default:=false + Enabled bool `json:"enabled,omitempty"` + // Hostname sets a custom hostname for the external endpoint. + // When omitted, an auto-generated hostname is used. + // +optional + Hostname string `json:"hostname,omitempty"` +} + +// NetworkSpec defines network access controls for the OGXServer. +type NetworkSpec struct { + // Port is the server listen port. + // +optional + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=65535 + // +kubebuilder:default:=8321 + Port int32 `json:"port,omitempty"` + // TLS configures optional TLS termination for the server. + // When omitted, the server listens over plain HTTP. + // +optional + TLS *TLSSpec `json:"tls,omitempty"` + // ExternalAccess controls external service exposure. + // +optional + ExternalAccess *ExternalAccessConfig `json:"externalAccess,omitempty"` + // Policy configures the operator-managed NetworkPolicy. + // When nil, the operator creates a default NetworkPolicy with safe ingress rules. + // +optional + Policy *NetworkPolicySpec `json:"policy,omitempty"` +} + +// MonitoringSpec configures Prometheus monitoring for this OGXServer instance. +type MonitoringSpec struct { + // Enabled controls whether the operator creates monitoring resources + // (ServiceMonitor, PrometheusRule) for this server. + // Defaults to true. Set to false to disable monitoring without removing the config. + // +optional + // +kubebuilder:default:=true + Enabled *bool `json:"enabled,omitempty"` + // MetricsPort is the port serving the /metrics endpoint. + // When omitted, metrics are served on the main API port. + // +optional + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=65535 + MetricsPort *int32 `json:"metricsPort,omitempty"` +} + +// PVCStorageSpec defines PVC storage for persistent data. +// +kubebuilder:validation:XValidation:rule="!has(self.mountPath) || self.mountPath.size() > 0",message="mountPath must not be empty if specified" +// +kubebuilder:validation:XValidation:rule="!has(self.size) || quantity(self.size).isGreaterThan(quantity('0'))",message="size must be a positive quantity" +type PVCStorageSpec struct { + // Size is the size of the PVC. + // +optional + Size *resource.Quantity `json:"size,omitempty"` + // MountPath is the container mount path for the PVC. + // +optional + // +kubebuilder:default:="/.ogx" + MountPath string `json:"mountPath,omitempty"` +} + +// PodDisruptionBudgetSpec defines voluntary disruption controls. +// +kubebuilder:validation:XValidation:rule="has(self.minAvailable) || has(self.maxUnavailable)",message="at least one of minAvailable or maxUnavailable must be specified" +// +kubebuilder:validation:XValidation:rule="!(has(self.minAvailable) && has(self.maxUnavailable))",message="minAvailable and maxUnavailable are mutually exclusive" +type PodDisruptionBudgetSpec struct { + // MinAvailable is the minimum number of pods that must remain available. + // +optional + MinAvailable *intstr.IntOrString `json:"minAvailable,omitempty"` + // MaxUnavailable is the maximum number of pods that can be disrupted simultaneously. + // +optional + MaxUnavailable *intstr.IntOrString `json:"maxUnavailable,omitempty"` +} + +// AutoscalingSpec configures HorizontalPodAutoscaler targets. +// +kubebuilder:validation:XValidation:rule="!has(self.minReplicas) || self.maxReplicas >= self.minReplicas",message="maxReplicas must be greater than or equal to minReplicas" +type AutoscalingSpec struct { + // MinReplicas is the lower bound replica count. + // +optional + // +kubebuilder:validation:Minimum=1 + MinReplicas *int32 `json:"minReplicas,omitempty"` + // MaxReplicas is the upper bound replica count. + // +kubebuilder:validation:Required + // +kubebuilder:validation:Minimum=1 + MaxReplicas int32 `json:"maxReplicas"` + // TargetCPUUtilizationPercentage configures CPU-based scaling. + // +optional + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=100 + TargetCPUUtilizationPercentage *int32 `json:"targetCPUUtilizationPercentage,omitempty"` + // TargetMemoryUtilizationPercentage configures memory-based scaling. + // +optional + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=100 + TargetMemoryUtilizationPercentage *int32 `json:"targetMemoryUtilizationPercentage,omitempty"` +} + +// WorkloadOverrides allows low-level customization of the Pod template. +// +kubebuilder:validation:XValidation:rule="!has(self.serviceAccountName) || self.serviceAccountName.size() > 0",message="serviceAccountName must not be empty if specified" +type WorkloadOverrides struct { + // ServiceAccountName specifies a custom ServiceAccount. + // +optional + ServiceAccountName string `json:"serviceAccountName,omitempty"` + // Env specifies additional environment variables. + // +optional + // +kubebuilder:validation:MinItems=1 + Env []corev1.EnvVar `json:"env,omitempty"` + // Command overrides the container command. + // +optional + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:items:MinLength=1 + Command []string `json:"command,omitempty"` + // Args overrides the container arguments. + // +optional + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:items:MinLength=1 + Args []string `json:"args,omitempty"` + // Volumes adds additional volumes to the Pod. + // +optional + // +kubebuilder:validation:MinItems=1 + Volumes []corev1.Volume `json:"volumes,omitempty"` + // VolumeMounts adds additional volume mounts to the container. + // +optional + // +kubebuilder:validation:MinItems=1 + VolumeMounts []corev1.VolumeMount `json:"volumeMounts,omitempty"` +} + +// WorkloadSpec consolidates Kubernetes deployment settings. +type WorkloadSpec struct { + // Replicas is the desired Pod replica count. + // +optional + // +kubebuilder:validation:Minimum=0 + // +kubebuilder:default:=1 + Replicas *int32 `json:"replicas,omitempty"` + // Workers configures the number of uvicorn worker processes. + // +optional + // +kubebuilder:validation:Minimum=1 + Workers *int32 `json:"workers,omitempty"` + // Resources defines CPU/memory requests and limits. + // +optional + Resources *corev1.ResourceRequirements `json:"resources,omitempty"` + // Autoscaling configures HPA for the server pods. + // +optional + Autoscaling *AutoscalingSpec `json:"autoscaling,omitempty"` + // Storage defines PVC configuration. + // +optional + Storage *PVCStorageSpec `json:"storage,omitempty"` + // PodDisruptionBudget controls voluntary disruption tolerance. + // +optional + PodDisruptionBudget *PodDisruptionBudgetSpec `json:"podDisruptionBudget,omitempty"` + // TopologySpreadConstraints defines Pod spreading rules. + // +optional + // +kubebuilder:validation:MinItems=1 + TopologySpreadConstraints []corev1.TopologySpreadConstraint `json:"topologySpreadConstraints,omitempty"` + // Overrides allows pod-level customization. + // +optional + Overrides *WorkloadOverrides `json:"overrides,omitempty"` +} + +// OGXServerSpec defines the desired state of OGXServer. +// +kubebuilder:validation:XValidation:rule="!has(self.overrideConfig) || !has(self.providers)",message="overrideConfig and providers are mutually exclusive" +// +kubebuilder:validation:XValidation:rule="!has(self.overrideConfig) || !has(self.resources)",message="overrideConfig and resources are mutually exclusive" +// +kubebuilder:validation:XValidation:rule="!has(self.overrideConfig) || !has(self.storage)",message="overrideConfig and storage are mutually exclusive" +// +kubebuilder:validation:XValidation:rule="!has(self.overrideConfig) || !has(self.disabledAPIs)",message="overrideConfig and disabledAPIs are mutually exclusive" +// +kubebuilder:validation:XValidation:rule="!has(self.overrideConfig) || !has(self.baseConfig)",message="overrideConfig and baseConfig are mutually exclusive" +// +kubebuilder:validation:XValidation:rule="!has(self.providers) || !has(self.disabledAPIs) || !self.disabledAPIs.exists(d, d == 'inference') || !has(self.providers.inference)",message="inference cannot be both in providers and disabledAPIs" +// +kubebuilder:validation:XValidation:rule="!has(self.providers) || !has(self.disabledAPIs) || !self.disabledAPIs.exists(d, d == 'vector_io') || !has(self.providers.vectorIo)",message="vector_io cannot be both in providers and disabledAPIs" +// +kubebuilder:validation:XValidation:rule="!has(self.providers) || !has(self.disabledAPIs) || !self.disabledAPIs.exists(d, d == 'tool_runtime') || !has(self.providers.toolRuntime)",message="tool_runtime cannot be both in providers and disabledAPIs" +// +kubebuilder:validation:XValidation:rule="!has(self.providers) || !has(self.disabledAPIs) || !self.disabledAPIs.exists(d, d == 'files') || !has(self.providers.files)",message="files cannot be both in providers and disabledAPIs" +// +kubebuilder:validation:XValidation:rule="!has(self.providers) || !has(self.disabledAPIs) || !self.disabledAPIs.exists(d, d == 'batches') || !has(self.providers.batches)",message="batches cannot be both in providers and disabledAPIs" +// +kubebuilder:validation:XValidation:rule="!has(self.providers) || !has(self.disabledAPIs) || !self.disabledAPIs.exists(d, d == 'responses') || !has(self.providers.responses)",message="responses cannot be both in providers and disabledAPIs" +// +kubebuilder:validation:XValidation:rule="!has(self.providers) || !has(self.disabledAPIs) || !self.disabledAPIs.exists(d, d == 'file_processors') || !has(self.providers.fileProcessors)",message="file_processors cannot be both in providers and disabledAPIs" +// +//nolint:lll // kubebuilder markers cannot be split across lines. +type OGXServerSpec struct { + // Distribution identifies the OGX distribution to deploy. + // +kubebuilder:validation:Required + Distribution DistributionSpec `json:"distribution"` + // Providers configures providers by API type. + // Mutually exclusive with overrideConfig. + // +optional + Providers *ProvidersSpec `json:"providers,omitempty"` + // Resources declares models to register. + // Mutually exclusive with overrideConfig. + // +optional + Resources *ResourcesSpec `json:"resources,omitempty"` + // Storage configures state storage backends (KV and SQL). + // Mutually exclusive with overrideConfig. + // +optional + Storage *StateStorageSpec `json:"storage,omitempty"` + // DisabledAPIs lists API names to remove from the generated config. + // Mutually exclusive with overrideConfig. + // +optional + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=7 + // +kubebuilder:validation:items:Enum=batches;file_processors;inference;responses;tool_runtime;vector_io;files + DisabledAPIs []string `json:"disabledAPIs,omitempty"` + // RegistryRefreshIntervalSeconds configures how often the server refreshes + // its model registry, in seconds. When omitted, the server's built-in + // default is used. + // +optional + // +kubebuilder:validation:Minimum=1 + RegistryRefreshIntervalSeconds *int32 `json:"registryRefreshIntervalSeconds,omitempty"` + // Network defines network access controls. + // +optional + Network *NetworkSpec `json:"network,omitempty"` + // TLS configures outbound TLS trust anchors and client identity for + // connections to providers and backends. + // +optional + TLS *TLSClientConfig `json:"tls,omitempty"` + // Workload consolidates Kubernetes deployment settings. + // +optional + Workload *WorkloadSpec `json:"workload,omitempty"` + // Monitoring configures Prometheus monitoring and observability. + // +optional + Monitoring *MonitoringSpec `json:"monitoring,omitempty"` + // BaseConfig references a ConfigMap key containing the base config.yaml used + // as the starting point for declarative config generation. + // When set, this takes precedence over OCI label resolution. + // Mutually exclusive with overrideConfig. + // The ConfigMap must be in the same namespace as the OGXServer + // and must have the label ogx.io/watch: "true". + // +optional + BaseConfig *ConfigMapKeyRef `json:"baseConfig,omitempty"` + // OverrideConfig references a ConfigMap key containing a full config.yaml override. + // Mutually exclusive with providers, resources, storage, disabledAPIs, and baseConfig. + // The ConfigMap must be in the same namespace as the OGXServer + // and must have the label ogx.io/watch: "true". + // +optional + OverrideConfig *ConfigMapKeyRef `json:"overrideConfig,omitempty"` +} + +// OGXServerPhase represents the current phase of the OGXServer. +// +kubebuilder:validation:Enum=Pending;Initializing;Ready;Failed;Terminating +type OGXServerPhase string + +const ( + OGXServerPhasePending OGXServerPhase = "Pending" + OGXServerPhaseInitializing OGXServerPhase = "Initializing" + OGXServerPhaseReady OGXServerPhase = "Ready" + OGXServerPhaseFailed OGXServerPhase = "Failed" + OGXServerPhaseTerminating OGXServerPhase = "Terminating" +) + +// ProviderHealthStatus represents the health status of a provider. +type ProviderHealthStatus struct { + Status string `json:"status"` + Message string `json:"message"` +} + +// ProviderInfo represents a single provider from the providers endpoint. +type ProviderInfo struct { + API string `json:"api"` + ProviderID string `json:"provider_id"` + ProviderType string `json:"provider_type"` + Config apiextensionsv1.JSON `json:"config"` + Health ProviderHealthStatus `json:"health"` +} + +// DistributionConfig represents the configuration from the providers endpoint. +type DistributionConfig struct { + ActiveDistribution string `json:"activeDistribution,omitempty"` + Providers []ProviderInfo `json:"providers,omitempty"` + AvailableDistributions map[string]string `json:"availableDistributions,omitempty"` +} + +// VersionInfo contains version-related information. +type VersionInfo struct { + OperatorVersion string `json:"operatorVersion,omitempty"` + ServerVersion string `json:"serverVersion,omitempty"` + LastUpdated metav1.Time `json:"lastUpdated,omitempty"` +} + +// ResolvedDistributionStatus tracks the resolved distribution image for change detection. +type ResolvedDistributionStatus struct { + // Image is the resolved container image reference (with digest when available). + Image string `json:"image,omitempty"` + // ConfigSource indicates the base config origin (for example "configmap" or "oci-label"). + ConfigSource string `json:"configSource,omitempty"` + // ConfigHash is the SHA256 hash of the base config used. + ConfigHash string `json:"configHash,omitempty"` +} + +// ConfigGenerationStatus tracks config generation details. +type ConfigGenerationStatus struct { + // ObservedGeneration is the spec generation that was last processed. + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + // ConfigMapName is the name of the generated ConfigMap. + ConfigMapName string `json:"configMapName,omitempty"` + // GeneratedAt is the timestamp of the last generation. + GeneratedAt metav1.Time `json:"generatedAt,omitempty"` + // ProviderCount is the number of configured providers. + ProviderCount int `json:"providerCount,omitempty"` + // ResourceCount is the number of registered resources. + ResourceCount int `json:"resourceCount,omitempty"` + // ConfigVersion is the config.yaml schema version. + ConfigVersion int `json:"configVersion,omitempty"` +} + +// OGXServerStatus defines the observed state of OGXServer. +type OGXServerStatus struct { + // Phase represents the current phase of the server. + Phase OGXServerPhase `json:"phase,omitempty"` + // Version contains version information for both operator and server. + Version VersionInfo `json:"version,omitempty"` + // DistributionConfig contains provider information from the running server. + DistributionConfig DistributionConfig `json:"distributionConfig,omitempty"` + // ResolvedDistribution tracks the resolved image and config source. + // +optional + ResolvedDistribution *ResolvedDistributionStatus `json:"resolvedDistribution,omitempty"` + // ConfigGeneration tracks config generation details. + // +optional + ConfigGeneration *ConfigGenerationStatus `json:"configGeneration,omitempty"` + // Conditions represent the latest available observations of the server's state. + Conditions []metav1.Condition `json:"conditions,omitempty"` + // AvailableReplicas is the number of available replicas. + AvailableReplicas int32 `json:"availableReplicas,omitempty"` + // ServiceURL is the internal Kubernetes service URL. + ServiceURL string `json:"serviceURL,omitempty"` + // ExternalURL is the external URL when external access is configured. + // +optional + ExternalURL *string `json:"externalURL,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="Phase",type="string",JSONPath=".status.phase" +// +kubebuilder:printcolumn:name="Distribution",type="string",JSONPath=".status.resolvedDistribution.image",priority=1 +// +kubebuilder:printcolumn:name="Config",type="string",JSONPath=".status.configGeneration.configMapName",priority=1 +// +kubebuilder:printcolumn:name="Providers",type="integer",JSONPath=".status.configGeneration.providerCount" +// +kubebuilder:printcolumn:name="Operator Version",type="string",JSONPath=".status.version.operatorVersion",priority=1 +// +kubebuilder:printcolumn:name="Server Version",type="string",JSONPath=".status.version.serverVersion",priority=1 +// +kubebuilder:printcolumn:name="Available",type="integer",JSONPath=".status.availableReplicas" +// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" + +// OGXServer is the Schema for the ogxservers API. +type OGXServer struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec OGXServerSpec `json:"spec"` + Status OGXServerStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// OGXServerList contains a list of OGXServer. +type OGXServerList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []OGXServer `json:"items"` +} + +func init() { //nolint:gochecknoinits + SchemeBuilder.Register(&OGXServer{}, &OGXServerList{}) +} + +// HasOverrideConfig returns true if the instance references an override ConfigMap. +func (r *OGXServer) HasOverrideConfig() bool { + return r.Spec.OverrideConfig != nil && + r.Spec.OverrideConfig.Name != "" && + r.Spec.OverrideConfig.Key != "" +} + +// HasDeclarativeConfig returns true if any declarative config fields are set +// (providers, resources, storage, or disabledAPIs). +func (r *OGXServer) HasDeclarativeConfig() bool { + return r.Spec.Providers != nil || + r.Spec.Resources != nil || + r.Spec.Storage != nil || + len(r.Spec.DisabledAPIs) > 0 +} + +// GetAdoptStorageSource returns the legacy LLSD name from the adopt-storage annotation, or empty string. +func (r *OGXServer) GetAdoptStorageSource() string { + if r.Annotations == nil { + return "" + } + return r.Annotations[AdoptStorageAnnotation] +} + +// GetAdoptNetworkingSource returns the legacy LLSD name from the adopt-networking annotation, or empty string. +func (r *OGXServer) GetAdoptNetworkingSource() string { + if r.Annotations == nil { + return "" + } + return r.Annotations[AdoptNetworkingAnnotation] +} + +// GetEffectivePVCName returns the PVC name the reconciler should use. +// When the adopt-storage annotation is present, the adopted PVC name is "{legacyName}-pvc". +// Otherwise the default convention is "{instanceName}-pvc". +func (r *OGXServer) GetEffectivePVCName() string { + if src := r.GetAdoptStorageSource(); src != "" && ValidateAdoptionAnnotation(src) == nil { + return src + "-pvc" + } + return r.Name + "-pvc" +} + +// ValidateAdoptionAnnotation validates that the given annotation value is a valid +// RFC 1123 DNS label: non-empty, lowercase alphanumeric or '-', at most 63 characters, +// starting and ending with an alphanumeric character. +func ValidateAdoptionAnnotation(value string) error { + if value == "" { + return errors.New("failed to validate adoption annotation: value must not be empty") + } + if len(value) > dns1123LabelMaxLen { + return fmt.Errorf("failed to validate adoption annotation: value %q exceeds %d characters", value, dns1123LabelMaxLen) + } + if !dns1123LabelRegex.MatchString(value) { + return fmt.Errorf("failed to validate adoption annotation: value %q is not a valid RFC 1123 DNS label", value) + } + return nil +} diff --git a/api/v1beta1/ogxserver_types_test.go b/api/v1beta1/ogxserver_types_test.go new file mode 100644 index 000000000..80ee1f722 --- /dev/null +++ b/api/v1beta1/ogxserver_types_test.go @@ -0,0 +1,239 @@ +/* +Copyright 2025. + +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. +*/ + +package v1beta1 + +import ( + "strings" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestGetAdoptStorageSource(t *testing.T) { + tests := []struct { + name string + annotations map[string]string + want string + }{ + { + name: "nil annotations", + annotations: nil, + want: "", + }, + { + name: "empty annotations", + annotations: map[string]string{}, + want: "", + }, + { + name: "annotation not present", + annotations: map[string]string{"other": "value"}, + want: "", + }, + { + name: "annotation present", + annotations: map[string]string{AdoptStorageAnnotation: "my-old-llsd"}, + want: "my-old-llsd", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &OGXServer{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: tt.annotations, + }, + } + if got := r.GetAdoptStorageSource(); got != tt.want { + t.Errorf("GetAdoptStorageSource() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestGetAdoptNetworkingSource(t *testing.T) { + tests := []struct { + name string + annotations map[string]string + want string + }{ + { + name: "nil annotations", + annotations: nil, + want: "", + }, + { + name: "annotation present", + annotations: map[string]string{AdoptNetworkingAnnotation: "legacy-server"}, + want: "legacy-server", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &OGXServer{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: tt.annotations, + }, + } + if got := r.GetAdoptNetworkingSource(); got != tt.want { + t.Errorf("GetAdoptNetworkingSource() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestGetEffectivePVCName(t *testing.T) { + tests := []struct { + name string + instanceName string + annotations map[string]string + want string + }{ + { + name: "no adoption annotation uses instance name", + instanceName: "my-server", + annotations: nil, + want: "my-server-pvc", + }, + { + name: "empty annotations uses instance name", + instanceName: "my-server", + annotations: map[string]string{}, + want: "my-server-pvc", + }, + { + name: "adoption annotation present uses legacy name", + instanceName: "my-server", + annotations: map[string]string{AdoptStorageAnnotation: "old-llsd"}, + want: "old-llsd-pvc", + }, + { + name: "adoption annotation same as instance name", + instanceName: "same-name", + annotations: map[string]string{AdoptStorageAnnotation: "same-name"}, + want: "same-name-pvc", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &OGXServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: tt.instanceName, + Annotations: tt.annotations, + }, + } + if got := r.GetEffectivePVCName(); got != tt.want { + t.Errorf("GetEffectivePVCName() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestValidateAdoptionAnnotation(t *testing.T) { + tests := []struct { + name string + value string + wantErr bool + errMsg string + }{ + { + name: "valid simple name", + value: "my-llsd", + wantErr: false, + }, + { + name: "valid single character", + value: "a", + wantErr: false, + }, + { + name: "valid numeric", + value: "123", + wantErr: false, + }, + { + name: "valid max length (63 chars)", + value: strings.Repeat("a", 63), + wantErr: false, + }, + { + name: "empty string", + value: "", + wantErr: true, + errMsg: "must not be empty", + }, + { + name: "exceeds 63 characters", + value: strings.Repeat("a", 64), + wantErr: true, + errMsg: "exceeds 63 characters", + }, + { + name: "uppercase letters", + value: "MyLLSD", + wantErr: true, + errMsg: "not a valid RFC 1123", + }, + { + name: "starts with hyphen", + value: "-invalid", + wantErr: true, + errMsg: "not a valid RFC 1123", + }, + { + name: "ends with hyphen", + value: "invalid-", + wantErr: true, + errMsg: "not a valid RFC 1123", + }, + { + name: "contains underscore", + value: "my_llsd", + wantErr: true, + errMsg: "not a valid RFC 1123", + }, + { + name: "contains dot", + value: "my.llsd", + wantErr: true, + errMsg: "not a valid RFC 1123", + }, + { + name: "contains space", + value: "my llsd", + wantErr: true, + errMsg: "not a valid RFC 1123", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateAdoptionAnnotation(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("ValidateAdoptionAnnotation(%q) error = %v, wantErr %v", tt.value, err, tt.wantErr) + return + } + if tt.wantErr && tt.errMsg != "" { + if !strings.Contains(err.Error(), tt.errMsg) { + t.Errorf("ValidateAdoptionAnnotation(%q) error = %q, want substring %q", tt.value, err.Error(), tt.errMsg) + } + } + }) + } +} diff --git a/api/v1beta1/ogxserver_webhook.go b/api/v1beta1/ogxserver_webhook.go new file mode 100644 index 000000000..8d41163bf --- /dev/null +++ b/api/v1beta1/ogxserver_webhook.go @@ -0,0 +1,212 @@ +/* +Copyright 2025. + +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. +*/ + +package v1beta1 + +import ( + "context" + "fmt" + "sort" + "strings" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/validation/field" + ctrl "sigs.k8s.io/controller-runtime" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +var ogxserverlog = logf.Log.WithName("ogxserver-webhook") + +// OGXServerValidator validates OGXServer resources. +type OGXServerValidator struct { + // KnownDistributionNames is the list of valid distribution names from the + // operator's distribution registry. Injected at setup time to avoid import + // cycles with pkg/cluster. + KnownDistributionNames []string +} + +var _ admission.CustomValidator = &OGXServerValidator{} + +// SetupWebhookWithManager registers the validating webhook. +// knownDistNames should be the keys from the operator's distribution registry. +func SetupWebhookWithManager(mgr ctrl.Manager, knownDistNames []string) error { + return ctrl.NewWebhookManagedBy(mgr). + For(&OGXServer{}). + WithValidator(&OGXServerValidator{ + KnownDistributionNames: knownDistNames, + }). + Complete() +} + +//nolint:lll // kubebuilder marker cannot be split across lines. +//+kubebuilder:webhook:path=/validate-ogx-io-v1beta1-ogxserver,mutating=false,failurePolicy=fail,sideEffects=None,groups=ogx.io,resources=ogxservers,verbs=create;update,versions=v1beta1,name=vogxserver.kb.io,admissionReviewVersions=v1 + +// ValidateCreate implements admission.CustomValidator. +func (v *OGXServerValidator) ValidateCreate(_ context.Context, obj runtime.Object) (admission.Warnings, error) { + r, ok := obj.(*OGXServer) + if !ok { + return nil, fmt.Errorf("failed to validate: expected *OGXServer, got %T", obj) + } + ogxserverlog.Info("validating create", "name", r.Name) + return v.validate(r) +} + +// ValidateUpdate implements admission.CustomValidator. +func (v *OGXServerValidator) ValidateUpdate(_ context.Context, _, newObj runtime.Object) (admission.Warnings, error) { + r, ok := newObj.(*OGXServer) + if !ok { + return nil, fmt.Errorf("failed to validate: expected *OGXServer, got %T", newObj) + } + ogxserverlog.Info("validating update", "name", r.Name) + return v.validate(r) +} + +// ValidateDelete implements admission.CustomValidator. +func (v *OGXServerValidator) ValidateDelete(_ context.Context, _ runtime.Object) (admission.Warnings, error) { + return nil, nil +} + +func (v *OGXServerValidator) validate(r *OGXServer) (admission.Warnings, error) { + allErrs := v.collectValidationErrors(r) + if len(allErrs) > 0 { + return nil, allErrs.ToAggregate() + } + return nil, nil +} + +func (v *OGXServerValidator) collectValidationErrors(r *OGXServer) field.ErrorList { + var allErrs field.ErrorList + + if r.Spec.Distribution.Name != "" { + allErrs = append(allErrs, validateDistributionName(r.Spec.Distribution.Name, v.KnownDistributionNames)...) + } + + if r.Spec.Providers != nil { + allErrs = append(allErrs, validateProviderIDs(r.Spec.Providers)...) + } + + if r.Spec.Resources != nil && r.Spec.Providers != nil { + allErrs = append(allErrs, validateProviderReferences(r.Spec.Resources, r.Spec.Providers)...) + } + + allErrs = append(allErrs, validateAdoptionAnnotations(r)...) + + return allErrs +} + +// validateAdoptionAnnotations rejects adoption annotations whose value equals +// the CR name. Same-name adoption causes Deployment name conflicts and is not +// a supported migration path. +func validateAdoptionAnnotations(r *OGXServer) field.ErrorList { + var errs field.ErrorList + annotationPath := field.NewPath("metadata", "annotations") + + for _, key := range []string{AdoptStorageAnnotation, AdoptNetworkingAnnotation} { + if val, ok := r.Annotations[key]; ok && val == r.Name { + errs = append(errs, field.Invalid( + annotationPath.Key(key), val, + "adoption annotation value must not equal the CR name; same-name adoption causes resource conflicts", + )) + } + } + + return errs +} + +// collectAllProviderIDs returns all provider IDs and any duplicate ID errors. +func collectAllProviderIDs(spec *ProvidersSpec) (map[string]bool, field.ErrorList) { + if spec == nil { + return nil, nil + } + + all := spec.IDs() + seen := make(map[string]bool, len(all)) + var errs field.ErrorList + for _, id := range all { + if seen[id] { + errs = append(errs, field.Invalid( + field.NewPath("spec", "providers"), id, + fmt.Sprintf("duplicate provider ID %q", id), + )) + } + seen[id] = true + } + return seen, errs +} + +// validateProviderIDs validates provider ID uniqueness and that all +// multi-instance provider slices have explicit IDs. +func validateProviderIDs(spec *ProvidersSpec) field.ErrorList { + _, errs := collectAllProviderIDs(spec) + return errs +} + +// validateProviderReferences ensures model provider references point to configured providers. +func validateProviderReferences(resources *ResourcesSpec, providers *ProvidersSpec) field.ErrorList { + var errs field.ErrorList + + providerIDs, _ := collectAllProviderIDs(providers) + + for i, mc := range resources.Models { + if mc.Provider != "" { + if !providerIDs[mc.Provider] { + errs = append(errs, field.Invalid( + field.NewPath("spec", "resources", "models").Index(i).Child("provider"), + mc.Provider, + fmt.Sprintf("references unknown provider ID; available: %v", sortedMapKeys(providerIDs)), + )) + } + } + } + + return errs +} + +// validateDistributionName validates that distribution.name is in the operator +// distribution registry. +func validateDistributionName(name string, knownNames []string) field.ErrorList { + if len(knownNames) == 0 { + return nil + } + + for _, n := range knownNames { + if n == name { + return nil + } + } + + sorted := make([]string, len(knownNames)) + copy(sorted, knownNames) + sort.Strings(sorted) + + var errs field.ErrorList + errs = append(errs, field.Invalid( + field.NewPath("spec", "distribution", "name"), + name, + fmt.Sprintf("unknown distribution %q; available distributions: %s", name, strings.Join(sorted, ", ")), + )) + return errs +} + +func sortedMapKeys(m map[string]bool) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} diff --git a/api/v1beta1/ogxserver_webhook_test.go b/api/v1beta1/ogxserver_webhook_test.go new file mode 100644 index 000000000..cd395f286 --- /dev/null +++ b/api/v1beta1/ogxserver_webhook_test.go @@ -0,0 +1,650 @@ +/* +Copyright 2025. + +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. +*/ + +package v1beta1 + +import ( + "strings" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestDeriveID(t *testing.T) { + tests := []struct { + name string + got string + want string + }{ + {"VLLM default", (VLLMProvider{}).DeriveID(), "remote-vllm"}, + {"VLLM explicit", (VLLMProvider{RoutedProviderBase: RoutedProviderBase{ID: "my-vllm"}}).DeriveID(), "my-vllm"}, + {"OpenAI default", (OpenAIProvider{}).DeriveID(), "remote-openai"}, + {"Azure default", (AzureProvider{}).DeriveID(), "remote-azure"}, + {"Bedrock default", (BedrockProvider{}).DeriveID(), "remote-bedrock"}, + {"VertexAI default", (VertexAIProvider{}).DeriveID(), "remote-vertexai"}, + {"Watsonx default", (WatsonxProvider{}).DeriveID(), "remote-watsonx"}, + {"Pgvector default", (PgvectorProvider{}).DeriveID(), "remote-pgvector"}, + {"Milvus default", (MilvusProvider{}).DeriveID(), "remote-milvus"}, + {"Qdrant default", (QdrantProvider{}).DeriveID(), "remote-qdrant"}, + {"BraveSearch default", (BraveSearchProvider{}).DeriveID(), "remote-brave-search"}, + {"TavilySearch default", (TavilySearchProvider{}).DeriveID(), "remote-tavily-search"}, + {"MCP default", (ModelContextProtocolProvider{}).DeriveID(), "remote-model-context-protocol"}, + {"FileSearch default", (InlineFileSearchProvider{}).DeriveID(), "inline-file-search"}, + {"S3 default", (S3Provider{}).DeriveID(), "remote-s3"}, + {"LocalFS default", (InlineLocalFSProvider{}).DeriveID(), "inline-localfs"}, + {"Auto FileProcessor default", (InlineAutoFileProcessorProvider{}).DeriveID(), "inline-auto"}, + {"PyPDF FileProcessor default", (InlinePyPDFFileProcessorProvider{}).DeriveID(), "inline-pypdf"}, + {"MarkItDown FileProcessor default", (InlineMarkItDownFileProcessorProvider{}).DeriveID(), "inline-markitdown"}, + {"Docling FileProcessor default", (InlineDoclingFileProcessorProvider{}).DeriveID(), "inline-docling"}, + {"DoclingServe default", (DoclingServeProvider{}).DeriveID(), "remote-docling-serve"}, + {"Custom remote", (CustomProvider{Type: "remote::llama-guard"}).DeriveID(), "remote-llama-guard"}, + {"Custom inline", (CustomProvider{Type: "inline::my-thing"}).DeriveID(), "inline-my-thing"}, + {"Custom explicit ID", (CustomProvider{RoutedProviderBase: RoutedProviderBase{ID: "my-guard"}, Type: "remote::llama-guard"}).DeriveID(), "my-guard"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.got != tt.want { + t.Errorf("DeriveID() = %q, want %q", tt.got, tt.want) + } + }) + } +} + +func TestProvidersSpecIDs(t *testing.T) { + t.Run("nil spec", func(t *testing.T) { + var spec *ProvidersSpec + if ids := spec.IDs(); len(ids) != 0 { + t.Errorf("IDs() = %v, want empty", ids) + } + }) + + t.Run("empty spec", func(t *testing.T) { + spec := &ProvidersSpec{} + if ids := spec.IDs(); len(ids) != 0 { + t.Errorf("IDs() = %v, want empty", ids) + } + }) + + t.Run("single VLLM provider", func(t *testing.T) { + spec := &ProvidersSpec{ + Inference: &InferenceProvidersSpec{ + Remote: &InferenceRemoteProviders{ + VLLM: []VLLMProvider{{Endpoint: "https://vllm:8000"}}, + }, + }, + } + assertSliceEqual(t, spec.Inference.Remote.IDs(), []string{"remote-vllm"}) + assertSliceEqual(t, spec.IDs(), []string{"remote-vllm"}) + }) + + t.Run("multiple providers across API types", func(t *testing.T) { + spec := &ProvidersSpec{ + Inference: &InferenceProvidersSpec{ + Remote: &InferenceRemoteProviders{ + VLLM: []VLLMProvider{{ + RoutedProviderBase: RoutedProviderBase{ID: "vllm-gpu"}, + Endpoint: "https://vllm:8000", + }}, + }, + }, + VectorIo: &VectorIOProvidersSpec{ + Remote: &VectorIORemoteProviders{ + Pgvector: []PgvectorProvider{{Password: SecretKeyRef{Name: "s", Key: "k"}}}, + }, + }, + Files: &FilesProvidersSpec{ + Remote: &FilesRemoteProviders{ + S3: &S3Provider{BucketName: "my-bucket"}, + }, + }, + } + assertSliceEqual(t, spec.Inference.Remote.IDs(), []string{"vllm-gpu"}) + assertSliceEqual(t, spec.VectorIo.Remote.IDs(), []string{"remote-pgvector"}) + assertSliceEqual(t, spec.Files.Remote.IDs(), []string{"remote-s3"}) + + ids := spec.IDs() + if len(ids) != 3 { + t.Errorf("IDs() = %v, want 3 elements", ids) + } + }) + + t.Run("duplicate IDs returned", func(t *testing.T) { + spec := &ProvidersSpec{ + Inference: &InferenceProvidersSpec{ + Remote: &InferenceRemoteProviders{ + VLLM: []VLLMProvider{ + {Endpoint: "https://vllm1:8000"}, + {Endpoint: "https://vllm2:8000"}, + }, + }, + }, + } + assertSliceEqual(t, spec.Inference.Remote.IDs(), []string{"remote-vllm", "remote-vllm"}) + }) +} + +func assertSliceEqual(t *testing.T, got, want []string) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("got %v (len %d), want %v (len %d)", got, len(got), want, len(want)) + } + for i := range got { + if got[i] != want[i] { + t.Errorf("[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + +func TestValidateProviderIDs(t *testing.T) { + tests := []struct { + name string + providers *ProvidersSpec + wantErrs int + errSubstr string + }{ + { + name: "empty providers", + providers: &ProvidersSpec{}, + wantErrs: 0, + }, + { + name: "no collision across API types", + providers: &ProvidersSpec{ + Inference: &InferenceProvidersSpec{ + Remote: &InferenceRemoteProviders{ + VLLM: []VLLMProvider{{Endpoint: "https://vllm:8000"}}, + }, + }, + VectorIo: &VectorIOProvidersSpec{ + Remote: &VectorIORemoteProviders{ + Pgvector: []PgvectorProvider{{Password: SecretKeyRef{Name: "s", Key: "k"}}}, + }, + }, + }, + wantErrs: 0, + }, + { + name: "collision via explicit ID across API types", + providers: &ProvidersSpec{ + Inference: &InferenceProvidersSpec{ + Remote: &InferenceRemoteProviders{ + VLLM: []VLLMProvider{{ + RoutedProviderBase: RoutedProviderBase{ID: "shared-id"}, + Endpoint: "https://vllm:8000", + }}, + }, + }, + VectorIo: &VectorIOProvidersSpec{ + Remote: &VectorIORemoteProviders{ + Pgvector: []PgvectorProvider{{ + RoutedProviderBase: RoutedProviderBase{ID: "shared-id"}, + Password: SecretKeyRef{Name: "s", Key: "k"}, + }}, + }, + }, + }, + wantErrs: 1, + errSubstr: "duplicate provider ID", + }, + { + name: "collision via custom provider derived IDs", + providers: &ProvidersSpec{ + Inference: &InferenceProvidersSpec{ + Remote: &InferenceRemoteProviders{ + Custom: []CustomProvider{{Type: "remote::my-model"}}, + }, + }, + ToolRuntime: &ToolRuntimeProvidersSpec{ + Remote: &ToolRuntimeRemoteProviders{ + Custom: []CustomProvider{{Type: "remote::my-model"}}, + }, + }, + }, + wantErrs: 1, + errSubstr: "duplicate provider ID", + }, + { + name: "multiple collisions", + providers: &ProvidersSpec{ + Inference: &InferenceProvidersSpec{ + Remote: &InferenceRemoteProviders{ + VLLM: []VLLMProvider{{ + RoutedProviderBase: RoutedProviderBase{ID: "dup1"}, + Endpoint: "https://vllm:8000", + }}, + OpenAI: []OpenAIProvider{{ + RoutedProviderBase: RoutedProviderBase{ID: "dup2"}, + APIKey: SecretKeyRef{Name: "s", Key: "k"}, + }}, + }, + }, + VectorIo: &VectorIOProvidersSpec{ + Remote: &VectorIORemoteProviders{ + Pgvector: []PgvectorProvider{{ + RoutedProviderBase: RoutedProviderBase{ID: "dup1"}, + Password: SecretKeyRef{Name: "s", Key: "k"}, + }}, + Qdrant: []QdrantProvider{{ + RoutedProviderBase: RoutedProviderBase{ID: "dup2"}, + }}, + }, + }, + }, + wantErrs: 2, + }, + { + name: "multi-instance without explicit IDs errors", + providers: &ProvidersSpec{ + Inference: &InferenceProvidersSpec{ + Remote: &InferenceRemoteProviders{ + VLLM: []VLLMProvider{ + {Endpoint: "https://vllm1:8000"}, + {Endpoint: "https://vllm2:8000"}, + }, + }, + }, + }, + wantErrs: 1, + errSubstr: "duplicate provider ID", + }, + { + name: "multi-instance with explicit IDs succeeds", + providers: &ProvidersSpec{ + Inference: &InferenceProvidersSpec{ + Remote: &InferenceRemoteProviders{ + VLLM: []VLLMProvider{ + {RoutedProviderBase: RoutedProviderBase{ID: "vllm-gpu"}, Endpoint: "https://vllm1:8000"}, + {RoutedProviderBase: RoutedProviderBase{ID: "vllm-cpu"}, Endpoint: "https://vllm2:8000"}, + }, + }, + }, + }, + wantErrs: 0, + }, + { + name: "singleton files providers do not need IDs", + providers: &ProvidersSpec{ + Files: &FilesProvidersSpec{ + Remote: &FilesRemoteProviders{ + S3: &S3Provider{BucketName: "my-bucket"}, + }, + Inline: &FilesInlineProviders{ + LocalFS: &InlineLocalFSProvider{}, + }, + }, + }, + wantErrs: 0, + }, + { + name: "inline and remote providers coexist", + providers: &ProvidersSpec{ + Inference: &InferenceProvidersSpec{ + Remote: &InferenceRemoteProviders{ + VLLM: []VLLMProvider{{Endpoint: "https://vllm:8000"}}, + }, + }, + }, + wantErrs: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + errs := validateProviderIDs(tt.providers) + if len(errs) != tt.wantErrs { + t.Errorf("validateProviderIDs() returned %d errors, want %d: %v", len(errs), tt.wantErrs, errs) + } + if tt.errSubstr != "" && len(errs) > 0 { + found := false + for _, e := range errs { + if strings.Contains(e.Detail, tt.errSubstr) { + found = true + break + } + } + if !found { + t.Errorf("no error contains %q; errors: %v", tt.errSubstr, errs) + } + } + }) + } +} + +func TestValidateProviderReferences(t *testing.T) { + tests := []struct { + name string + resources *ResourcesSpec + providers *ProvidersSpec + wantErrs int + errSubstr string + }{ + { + name: "valid provider reference by derived ID", + resources: &ResourcesSpec{ + Models: []ModelConfig{{Name: "llama3", Provider: "remote-vllm"}}, + }, + providers: &ProvidersSpec{ + Inference: &InferenceProvidersSpec{ + Remote: &InferenceRemoteProviders{ + VLLM: []VLLMProvider{{Endpoint: "https://vllm:8000"}}, + }, + }, + }, + wantErrs: 0, + }, + { + name: "unknown provider reference", + resources: &ResourcesSpec{ + Models: []ModelConfig{{Name: "llama3", Provider: "nonexistent"}}, + }, + providers: &ProvidersSpec{ + Inference: &InferenceProvidersSpec{ + Remote: &InferenceRemoteProviders{ + VLLM: []VLLMProvider{{Endpoint: "https://vllm:8000"}}, + }, + }, + }, + wantErrs: 1, + errSubstr: "references unknown provider ID", + }, + { + name: "empty provider field is allowed", + resources: &ResourcesSpec{ + Models: []ModelConfig{{Name: "llama3"}}, + }, + providers: &ProvidersSpec{ + Inference: &InferenceProvidersSpec{ + Remote: &InferenceRemoteProviders{ + VLLM: []VLLMProvider{{Endpoint: "https://vllm:8000"}}, + }, + }, + }, + wantErrs: 0, + }, + { + name: "reference to provider with explicit ID", + resources: &ResourcesSpec{ + Models: []ModelConfig{{Name: "llama3", Provider: "my-vllm"}}, + }, + providers: &ProvidersSpec{ + Inference: &InferenceProvidersSpec{ + Remote: &InferenceRemoteProviders{ + VLLM: []VLLMProvider{{ + RoutedProviderBase: RoutedProviderBase{ID: "my-vllm"}, + Endpoint: "https://vllm:8000", + }}, + }, + }, + }, + wantErrs: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + errs := validateProviderReferences(tt.resources, tt.providers) + if len(errs) != tt.wantErrs { + t.Errorf("validateProviderReferences() returned %d errors, want %d: %v", len(errs), tt.wantErrs, errs) + } + if tt.errSubstr != "" && len(errs) > 0 { + if !strings.Contains(errs[0].Detail, tt.errSubstr) { + t.Errorf("error detail %q does not contain %q", errs[0].Detail, tt.errSubstr) + } + } + }) + } +} + +func TestValidateDistributionName(t *testing.T) { + knownNames := []string{"starter", "remote-vllm", "meta-reference-gpu", "postgres-demo"} + + tests := []struct { + name string + distName string + knownNames []string + wantErrs int + errSubstr string + }{ + { + name: "valid distribution name", + distName: "starter", + knownNames: knownNames, + wantErrs: 0, + }, + { + name: "unknown distribution name", + distName: "nonexistent", + knownNames: knownNames, + wantErrs: 1, + errSubstr: "unknown distribution", + }, + { + name: "empty known names skips validation", + distName: "anything", + knownNames: nil, + wantErrs: 0, + }, + { + name: "error lists available distributions", + distName: "bad", + knownNames: knownNames, + wantErrs: 1, + errSubstr: "starter", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + errs := validateDistributionName(tt.distName, tt.knownNames) + if len(errs) != tt.wantErrs { + t.Errorf("validateDistributionName(%q) returned %d errors, want %d: %v", tt.distName, len(errs), tt.wantErrs, errs) + } + if tt.errSubstr != "" && len(errs) > 0 { + if !strings.Contains(errs[0].Detail, tt.errSubstr) { + t.Errorf("error detail %q does not contain %q", errs[0].Detail, tt.errSubstr) + } + } + }) + } +} + +func TestValidateAdoptionAnnotations(t *testing.T) { + tests := []struct { + name string + server *OGXServer + wantErrs int + errContains string + }{ + { + name: "no adoption annotations is valid", + server: &OGXServer{ + ObjectMeta: metav1.ObjectMeta{Name: "my-server"}, + }, + wantErrs: 0, + }, + { + name: "adopt-storage with different name is valid", + server: &OGXServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "new-server", + Annotations: map[string]string{AdoptStorageAnnotation: "old-server"}, + }, + }, + wantErrs: 0, + }, + { + name: "adopt-storage equals CR name is rejected", + server: &OGXServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-server", + Annotations: map[string]string{AdoptStorageAnnotation: "my-server"}, + }, + }, + wantErrs: 1, + errContains: "must not equal the CR name", + }, + { + name: "adopt-networking equals CR name is rejected", + server: &OGXServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-server", + Annotations: map[string]string{AdoptNetworkingAnnotation: "my-server"}, + }, + }, + wantErrs: 1, + errContains: "must not equal the CR name", + }, + { + name: "both annotations equal CR name gives two errors", + server: &OGXServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-server", + Annotations: map[string]string{ + AdoptStorageAnnotation: "my-server", + AdoptNetworkingAnnotation: "my-server", + }, + }, + }, + wantErrs: 2, + }, + { + name: "adopt-networking with different name is valid", + server: &OGXServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "new-server", + Annotations: map[string]string{AdoptNetworkingAnnotation: "old-server"}, + }, + }, + wantErrs: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + errs := validateAdoptionAnnotations(tt.server) + if len(errs) != tt.wantErrs { + t.Errorf("validateAdoptionAnnotations() returned %d errors, want %d: %v", len(errs), tt.wantErrs, errs) + } + if tt.errContains != "" && len(errs) > 0 { + if !strings.Contains(errs[0].Detail, tt.errContains) { + t.Errorf("error detail %q does not contain %q", errs[0].Detail, tt.errContains) + } + } + }) + } +} + +func TestCollectValidationErrors(t *testing.T) { + knownNames := []string{"starter", "remote-vllm"} + + tests := []struct { + name string + server *OGXServer + wantErrs int + }{ + { + name: "valid server with all fields", + server: &OGXServer{ + Spec: OGXServerSpec{ + Distribution: DistributionSpec{Name: "starter"}, + Providers: &ProvidersSpec{ + Inference: &InferenceProvidersSpec{ + Remote: &InferenceRemoteProviders{ + VLLM: []VLLMProvider{{Endpoint: "https://vllm:8000"}}, + }, + }, + }, + Resources: &ResourcesSpec{ + Models: []ModelConfig{{Name: "llama3", Provider: "remote-vllm"}}, + }, + }, + }, + wantErrs: 0, + }, + { + name: "image-based distribution skips name validation", + server: &OGXServer{ + Spec: OGXServerSpec{ + Distribution: DistributionSpec{Image: "custom:latest"}, + }, + }, + wantErrs: 0, + }, + { + name: "multiple errors accumulated", + server: &OGXServer{ + Spec: OGXServerSpec{ + Distribution: DistributionSpec{Name: "unknown-dist"}, + Providers: &ProvidersSpec{ + Inference: &InferenceProvidersSpec{ + Remote: &InferenceRemoteProviders{ + VLLM: []VLLMProvider{{ + RoutedProviderBase: RoutedProviderBase{ID: "dup"}, + Endpoint: "https://vllm:8000", + }}, + }, + }, + VectorIo: &VectorIOProvidersSpec{ + Remote: &VectorIORemoteProviders{ + Pgvector: []PgvectorProvider{{ + RoutedProviderBase: RoutedProviderBase{ID: "dup"}, + Password: SecretKeyRef{Name: "s", Key: "k"}, + }}, + }, + }, + }, + Resources: &ResourcesSpec{ + Models: []ModelConfig{{Name: "llama3", Provider: "nonexistent"}}, + }, + }, + }, + wantErrs: 3, + }, + { + name: "no providers or resources is valid", + server: &OGXServer{ + Spec: OGXServerSpec{ + Distribution: DistributionSpec{Name: "starter"}, + }, + }, + wantErrs: 0, + }, + { + name: "self-adoption annotation is rejected", + server: &OGXServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-server", + Annotations: map[string]string{AdoptStorageAnnotation: "my-server"}, + }, + Spec: OGXServerSpec{ + Distribution: DistributionSpec{Name: "starter"}, + }, + }, + wantErrs: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + v := &OGXServerValidator{KnownDistributionNames: knownNames} + errs := v.collectValidationErrors(tt.server) + if len(errs) != tt.wantErrs { + t.Errorf("collectValidationErrors() returned %d errors, want %d: %v", len(errs), tt.wantErrs, errs) + } + }) + } +} diff --git a/api/v1beta1/provider_types.go b/api/v1beta1/provider_types.go new file mode 100644 index 000000000..1ae543f92 --- /dev/null +++ b/api/v1beta1/provider_types.go @@ -0,0 +1,110 @@ +/* +Copyright 2025. + +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. +*/ + +package v1beta1 + +import ( + "slices" + "strings" + + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" +) + +// Provider is implemented by all provider types (typed and custom). +// +kubebuilder:object:generate=false +type Provider interface { + DeriveID() string +} + +// RoutedProviderBase contains fields common to all routed (non-singleton) provider instances. +// +kubebuilder:validation:XValidation:rule="!has(self.id) || self.id.size() > 0",message="id must not be empty if specified" +type RoutedProviderBase struct { + // ID is a unique provider identifier. Derived from the provider + // type when omitted. Must be unique across all providers. + // +optional + ID string `json:"id,omitempty"` +} + +// CustomProvider defines the configuration for a custom provider instance. +// +kubebuilder:validation:XValidation:rule="self.type.startsWith('remote::') || self.type.startsWith('inline::')",message="type must have a 'remote::' or 'inline::' prefix (e.g., 'remote::llama-guard', 'inline::my-provider')" +// +//nolint:lll // CEL validation rule +type CustomProvider struct { + RoutedProviderBase `json:",inline"` + // Type is the provider type, specified with a "remote::" or "inline::" + // prefix (e.g., "remote::llama-guard", "inline::my-provider"). + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + Type string `json:"type"` + // SecretRefs is a map of named secret references for provider-specific + // connection fields (e.g., host, password). Each key becomes the env var + // field suffix and maps to config. with env var substitution. + // Each Secret must be in the same namespace as the OGXServer + // and must have the label ogx.io/watch: "true". + // +optional + // +kubebuilder:validation:MinProperties=1 + SecretRefs map[string]SecretKeyRef `json:"secretRefs,omitempty"` + // Settings contains provider-specific configuration merged into the + // provider's config section in config.yaml. Passed through as-is + // without any secret resolution. Use secretRefs for secret values. + // +optional + Settings *apiextensionsv1.JSON `json:"settings,omitempty"` +} + +func (c CustomProvider) DeriveID() string { + return c.deriveOrDefault(strings.ReplaceAll(c.Type, "::", "-")) +} + +// ProvidersSpec configures providers by API type. +type ProvidersSpec struct { + // +optional + Inference *InferenceProvidersSpec `json:"inference,omitempty"` + // +optional + VectorIo *VectorIOProvidersSpec `json:"vectorIo,omitempty"` + // +optional + ToolRuntime *ToolRuntimeProvidersSpec `json:"toolRuntime,omitempty"` + // +optional + Files *FilesProvidersSpec `json:"files,omitempty"` + // +optional + Batches *BatchesProvidersSpec `json:"batches,omitempty"` + // +optional + Responses *ResponsesProvidersSpec `json:"responses,omitempty"` + // +optional + FileProcessors *FileProcessorsProvidersSpec `json:"fileProcessors,omitempty"` +} + +func (s *ProvidersSpec) IDs() []string { + if s == nil { + return nil + } + return slices.Concat(s.Inference.IDs(), s.VectorIo.IDs(), s.ToolRuntime.IDs(), s.Files.IDs(), s.Batches.IDs(), s.Responses.IDs(), s.FileProcessors.IDs()) +} + +func (b RoutedProviderBase) deriveOrDefault(defaultID string) string { + if b.ID != "" { + return b.ID + } + return defaultID +} + +// deriveSliceIDs maps DeriveID over a provider slice. +func deriveSliceIDs[T Provider](items []T) []string { + ids := make([]string, len(items)) + for i := range items { + ids[i] = items[i].DeriveID() + } + return ids +} diff --git a/api/v1beta1/provider_types_batches.go b/api/v1beta1/provider_types_batches.go new file mode 100644 index 000000000..b47290129 --- /dev/null +++ b/api/v1beta1/provider_types_batches.go @@ -0,0 +1,86 @@ +/* +Copyright 2025. + +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. +*/ + +package v1beta1 + +import "slices" + +// InlineReferenceProvider configures inline::reference for batches. +type InlineReferenceProvider struct { + // MaxConcurrentBatches is the maximum number of concurrent batches + // to process simultaneously. + // +optional + // +kubebuilder:validation:Minimum=1 + MaxConcurrentBatches *int `json:"maxConcurrentBatches,omitempty"` + // MaxConcurrentRequestsPerBatch is the maximum number of concurrent + // requests to process per batch. + // +optional + // +kubebuilder:validation:Minimum=1 + MaxConcurrentRequestsPerBatch *int `json:"maxConcurrentRequestsPerBatch,omitempty"` +} + +func (p InlineReferenceProvider) DeriveID() string { return "inline-reference" } + +// BatchesRemoteProviders groups remote batches providers. +type BatchesRemoteProviders struct { + // +optional + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=100 + Custom []CustomProvider `json:"custom,omitempty"` +} + +func (r *BatchesRemoteProviders) IDs() []string { + if r == nil { + return nil + } + return deriveSliceIDs(r.Custom) +} + +// BatchesInlineProviders groups inline batches providers. +type BatchesInlineProviders struct { + // +optional + Reference *InlineReferenceProvider `json:"reference,omitempty"` + // +optional + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=100 + Custom []CustomProvider `json:"custom,omitempty"` +} + +func (inl *BatchesInlineProviders) IDs() []string { + if inl == nil { + return nil + } + var ids []string + if inl.Reference != nil { + ids = append(ids, inl.Reference.DeriveID()) + } + return append(ids, deriveSliceIDs(inl.Custom)...) +} + +// BatchesProvidersSpec configures batches providers. +type BatchesProvidersSpec struct { + // +optional + Remote *BatchesRemoteProviders `json:"remote,omitempty"` + // +optional + Inline *BatchesInlineProviders `json:"inline,omitempty"` +} + +func (s *BatchesProvidersSpec) IDs() []string { + if s == nil { + return nil + } + return slices.Concat(s.Remote.IDs(), s.Inline.IDs()) +} diff --git a/api/v1beta1/provider_types_fileprocessors.go b/api/v1beta1/provider_types_fileprocessors.go new file mode 100644 index 000000000..50a7223a8 --- /dev/null +++ b/api/v1beta1/provider_types_fileprocessors.go @@ -0,0 +1,176 @@ +/* +Copyright 2025. + +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. +*/ + +package v1beta1 + +import "slices" + +// FileProcessorChunkConfig contains chunking fields shared by all file_processors providers. +type FileProcessorChunkConfig struct { + // DefaultChunkSizeTokens is the default chunk size in tokens when + // chunking_strategy type is 'auto'. + // +optional + // +kubebuilder:validation:Minimum=100 + // +kubebuilder:validation:Maximum=4096 + DefaultChunkSizeTokens *int `json:"defaultChunkSizeTokens,omitempty"` + // DefaultChunkOverlapTokens is the default chunk overlap in tokens when + // chunking_strategy type is 'auto'. + // +optional + // +kubebuilder:validation:Minimum=0 + // +kubebuilder:validation:Maximum=2048 + DefaultChunkOverlapTokens *int `json:"defaultChunkOverlapTokens,omitempty"` +} + +// InlineAutoFileProcessorProvider configures inline::auto for file_processors. +type InlineAutoFileProcessorProvider struct { + FileProcessorChunkConfig `json:",inline"` + // ExtractMetadata controls whether to extract PDF metadata + // (title, author, etc.). + // +optional + ExtractMetadata *bool `json:"extractMetadata,omitempty"` + // CleanText controls whether to clean extracted text + // (remove extra whitespace, normalize line breaks). + // +optional + CleanText *bool `json:"cleanText,omitempty"` +} + +func (p InlineAutoFileProcessorProvider) DeriveID() string { return "inline-auto" } + +// InlinePyPDFFileProcessorProvider configures inline::pypdf for file_processors. +type InlinePyPDFFileProcessorProvider struct { + FileProcessorChunkConfig `json:",inline"` + // ExtractMetadata controls whether to extract PDF metadata + // (title, author, etc.). + // +optional + ExtractMetadata *bool `json:"extractMetadata,omitempty"` + // CleanText controls whether to clean extracted text + // (remove extra whitespace, normalize line breaks). + // +optional + CleanText *bool `json:"cleanText,omitempty"` +} + +func (p InlinePyPDFFileProcessorProvider) DeriveID() string { return "inline-pypdf" } + +// InlineMarkItDownFileProcessorProvider configures inline::markitdown for file_processors. +type InlineMarkItDownFileProcessorProvider struct { + FileProcessorChunkConfig `json:",inline"` +} + +func (p InlineMarkItDownFileProcessorProvider) DeriveID() string { return "inline-markitdown" } + +// InlineDoclingFileProcessorProvider configures inline::docling for file_processors. +type InlineDoclingFileProcessorProvider struct { + FileProcessorChunkConfig `json:",inline"` + // DoOCR controls whether to enable OCR for scanned documents. + // +optional + DoOCR *bool `json:"doOcr,omitempty"` +} + +func (p InlineDoclingFileProcessorProvider) DeriveID() string { return "inline-docling" } + +// DoclingServeProvider configures a remote::docling-serve file_processors provider instance. +// +kubebuilder:validation:XValidation:rule="!has(self.baseUrl) || self.baseUrl.size() > 0",message="baseUrl must not be empty if specified" +type DoclingServeProvider struct { + // BaseURL is the base URL of the Docling Serve instance. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + BaseURL string `json:"baseUrl"` + // APIKey is the API key for authenticating with Docling Serve. + // The Secret must be in the same namespace as the OGXServer + // and must have the label ogx.io/watch: "true". + // +optional + APIKey *SecretKeyRef `json:"apiKey,omitempty"` + // DefaultChunkSizeTokens is the default chunk size in tokens when + // chunking_strategy type is 'auto'. + // +optional + // +kubebuilder:validation:Minimum=100 + // +kubebuilder:validation:Maximum=4096 + DefaultChunkSizeTokens *int `json:"defaultChunkSizeTokens,omitempty"` +} + +func (p DoclingServeProvider) DeriveID() string { return "remote-docling-serve" } + +// FileProcessorsRemoteProviders groups remote file_processors providers. +type FileProcessorsRemoteProviders struct { + // +optional + DoclingServe *DoclingServeProvider `json:"doclingServe,omitempty"` + // +optional + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=100 + Custom []CustomProvider `json:"custom,omitempty"` +} + +func (r *FileProcessorsRemoteProviders) IDs() []string { + if r == nil { + return nil + } + var ids []string + if r.DoclingServe != nil { + ids = append(ids, r.DoclingServe.DeriveID()) + } + return append(ids, deriveSliceIDs(r.Custom)...) +} + +// FileProcessorsInlineProviders groups inline file_processors providers. +type FileProcessorsInlineProviders struct { + // +optional + Auto *InlineAutoFileProcessorProvider `json:"auto,omitempty"` + // +optional + PyPDF *InlinePyPDFFileProcessorProvider `json:"pypdf,omitempty"` + // +optional + MarkItDown *InlineMarkItDownFileProcessorProvider `json:"markitdown,omitempty"` + // +optional + Docling *InlineDoclingFileProcessorProvider `json:"docling,omitempty"` + // +optional + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=100 + Custom []CustomProvider `json:"custom,omitempty"` +} + +func (inl *FileProcessorsInlineProviders) IDs() []string { + if inl == nil { + return nil + } + var ids []string + if inl.Auto != nil { + ids = append(ids, inl.Auto.DeriveID()) + } + if inl.PyPDF != nil { + ids = append(ids, inl.PyPDF.DeriveID()) + } + if inl.MarkItDown != nil { + ids = append(ids, inl.MarkItDown.DeriveID()) + } + if inl.Docling != nil { + ids = append(ids, inl.Docling.DeriveID()) + } + return append(ids, deriveSliceIDs(inl.Custom)...) +} + +// FileProcessorsProvidersSpec configures file_processors providers. +type FileProcessorsProvidersSpec struct { + // +optional + Remote *FileProcessorsRemoteProviders `json:"remote,omitempty"` + // +optional + Inline *FileProcessorsInlineProviders `json:"inline,omitempty"` +} + +func (s *FileProcessorsProvidersSpec) IDs() []string { + if s == nil { + return nil + } + return slices.Concat(s.Remote.IDs(), s.Inline.IDs()) +} diff --git a/api/v1beta1/provider_types_files.go b/api/v1beta1/provider_types_files.go new file mode 100644 index 000000000..e484c1384 --- /dev/null +++ b/api/v1beta1/provider_types_files.go @@ -0,0 +1,118 @@ +/* +Copyright 2025. + +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. +*/ + +package v1beta1 + +import "slices" + +// S3Provider configures a remote::s3 files provider instance. +// +kubebuilder:validation:XValidation:rule="!has(self.region) || self.region.size() > 0",message="region must not be empty if specified" +// +kubebuilder:validation:XValidation:rule="!has(self.endpointUrl) || self.endpointUrl.size() > 0",message="endpointUrl must not be empty if specified" +type S3Provider struct { + // BucketName is the S3 bucket name to store files. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + BucketName string `json:"bucketName"` + // Region is the AWS region where the bucket is located. + // +optional + Region string `json:"region,omitempty"` + // AWSAccessKeyID is the AWS access key ID (optional if using IAM roles). + // The Secret must be in the same namespace as the OGXServer + // and must have the label ogx.io/watch: "true". + // +optional + AWSAccessKeyID *SecretKeyRef `json:"awsAccessKeyId,omitempty"` + // AWSSecretAccessKey is the AWS secret access key (optional if using IAM roles). + // The Secret must be in the same namespace as the OGXServer + // and must have the label ogx.io/watch: "true". + // +optional + AWSSecretAccessKey *SecretKeyRef `json:"awsSecretAccessKey,omitempty"` + // EndpointURL is a custom S3 endpoint URL (for MinIO, LocalStack, etc.). + // +optional + EndpointURL string `json:"endpointUrl,omitempty"` + // AutoCreateBucket controls whether to automatically create the S3 bucket + // if it doesn't exist. + // +optional + AutoCreateBucket *bool `json:"autoCreateBucket,omitempty"` +} + +func (p S3Provider) DeriveID() string { return "remote-s3" } + +// InlineLocalFSProvider configures inline::localfs. +type InlineLocalFSProvider struct { + // TTLSecs is the time-to-live in seconds for uploaded files. + // +optional + // +kubebuilder:validation:Minimum=1 + TTLSecs *int `json:"ttlSecs,omitempty"` +} + +func (p InlineLocalFSProvider) DeriveID() string { return "inline-localfs" } + +// FilesRemoteProviders groups remote files providers. +type FilesRemoteProviders struct { + // +optional + S3 *S3Provider `json:"s3,omitempty"` + // +optional + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=100 + Custom []CustomProvider `json:"custom,omitempty"` +} + +func (r *FilesRemoteProviders) IDs() []string { + if r == nil { + return nil + } + var ids []string + if r.S3 != nil { + ids = append(ids, r.S3.DeriveID()) + } + return append(ids, deriveSliceIDs(r.Custom)...) +} + +// FilesInlineProviders groups inline files providers. +type FilesInlineProviders struct { + // +optional + LocalFS *InlineLocalFSProvider `json:"localfs,omitempty"` + // +optional + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=100 + Custom []CustomProvider `json:"custom,omitempty"` +} + +func (inl *FilesInlineProviders) IDs() []string { + if inl == nil { + return nil + } + var ids []string + if inl.LocalFS != nil { + ids = append(ids, inl.LocalFS.DeriveID()) + } + return append(ids, deriveSliceIDs(inl.Custom)...) +} + +// FilesProvidersSpec configures files providers. +type FilesProvidersSpec struct { + // +optional + Remote *FilesRemoteProviders `json:"remote,omitempty"` + // +optional + Inline *FilesInlineProviders `json:"inline,omitempty"` +} + +func (s *FilesProvidersSpec) IDs() []string { + if s == nil { + return nil + } + return slices.Concat(s.Remote.IDs(), s.Inline.IDs()) +} diff --git a/api/v1beta1/provider_types_inference.go b/api/v1beta1/provider_types_inference.go new file mode 100644 index 000000000..7aa5d91d5 --- /dev/null +++ b/api/v1beta1/provider_types_inference.go @@ -0,0 +1,357 @@ +/* +Copyright 2025. + +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. +*/ + +package v1beta1 + +import "slices" + +// TLSConfig configures TLS settings for remote provider connections. +type TLSConfig struct { + // Verify controls whether TLS certificate verification is enabled. + // Trust anchors and client identity are configured globally via spec.tls. + // +optional + Verify *bool `json:"verify,omitempty"` + // MinVersion sets the minimum TLS version. + // +optional + // +kubebuilder:validation:Enum=TLSv1.2;TLSv1.3 + MinVersion string `json:"minVersion,omitempty"` + // Ciphers is a list of allowed TLS cipher suites. + // +optional + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:items:MinLength=1 + Ciphers []string `json:"ciphers,omitempty"` +} + +// ProxyConfig configures HTTP proxy settings for remote provider connections. +// +kubebuilder:validation:XValidation:rule="!has(self.url) || self.url.size() > 0",message="url must not be empty if specified" +// +kubebuilder:validation:XValidation:rule="!has(self.http) || self.http.size() > 0",message="http must not be empty if specified" +// +kubebuilder:validation:XValidation:rule="!has(self.https) || self.https.size() > 0",message="https must not be empty if specified" +// +kubebuilder:validation:XValidation:rule="!has(self.cacert) || self.cacert.size() > 0",message="cacert must not be empty if specified" +type ProxyConfig struct { + // URL is the proxy URL for all connections. + // +optional + URL *string `json:"url,omitempty"` + // HTTP is the proxy URL for HTTP connections. + // +optional + HTTP *string `json:"http,omitempty"` + // HTTPS is the proxy URL for HTTPS connections. + // +optional + HTTPS *string `json:"https,omitempty"` + // CACert is the path to a CA certificate for verifying the proxy's certificate. + // +optional + CACert *string `json:"cacert,omitempty"` + // NoProxy is a list of hosts that should bypass the proxy. + // +optional + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:items:MinLength=1 + NoProxy []string `json:"noProxy,omitempty"` +} + +// TimeoutConfig configures network timeout settings. +type TimeoutConfig struct { + // Connect is the connection timeout in seconds. + // +optional + // +kubebuilder:validation:Minimum=1 + Connect *int `json:"connect,omitempty"` + // Read is the read timeout in seconds. + // +optional + // +kubebuilder:validation:Minimum=1 + Read *int `json:"read,omitempty"` +} + +// NetworkConfig configures network settings for remote provider connections. +type NetworkConfig struct { + // TLS configures TLS/SSL settings. + // +optional + TLS *TLSConfig `json:"tls,omitempty"` + // Proxy configures HTTP proxy settings. + // +optional + Proxy *ProxyConfig `json:"proxy,omitempty"` + // Timeout configures connection and read timeout settings. + // +optional + Timeout *TimeoutConfig `json:"timeout,omitempty"` + // Headers specifies additional HTTP headers to include in all requests. + // +optional + // +kubebuilder:validation:MinProperties=1 + Headers map[string]string `json:"headers,omitempty"` +} + +// RemoteInferenceCommonConfig contains fields shared by all remote inference providers. +type RemoteInferenceCommonConfig struct { + // AllowedModels restricts which models can be registered with this provider. + // When empty, all models are allowed. + // +optional + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:items:MinLength=1 + AllowedModels []string `json:"allowedModels,omitempty"` + // RefreshModels controls whether the provider periodically refreshes + // its model list from the remote endpoint. + // +optional + RefreshModels *bool `json:"refreshModels,omitempty"` + // Network configures network settings (TLS, proxy, timeouts, headers) + // for the remote connection. + // +optional + Network *NetworkConfig `json:"network,omitempty"` +} + +// VLLMProvider configures a remote::vllm inference provider instance. +type VLLMProvider struct { + RoutedProviderBase `json:",inline"` + RemoteInferenceCommonConfig `json:",inline"` + // Endpoint is the URL for the vLLM model serving endpoint. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + Endpoint string `json:"endpoint"` + // APIToken is the authentication token for the vLLM endpoint. + // The Secret must be in the same namespace as the OGXServer + // and must have the label ogx.io/watch: "true". + // +optional + APIToken *SecretKeyRef `json:"apiToken,omitempty"` + // MaxTokens is the maximum number of tokens to generate. + // +optional + // +kubebuilder:validation:Minimum=1 + MaxTokens *int `json:"maxTokens,omitempty"` +} + +func (p VLLMProvider) DeriveID() string { return p.deriveOrDefault("remote-vllm") } + +// OpenAIProvider configures a remote::openai inference provider instance. +// +kubebuilder:validation:XValidation:rule="!has(self.endpoint) || self.endpoint.size() > 0",message="endpoint must not be empty if specified" +type OpenAIProvider struct { + RoutedProviderBase `json:",inline"` + RemoteInferenceCommonConfig `json:",inline"` + // Endpoint is the base URL for the OpenAI API. + // +optional + Endpoint string `json:"endpoint,omitempty"` + // APIKey is the authentication credential for the OpenAI provider. + // The Secret must be in the same namespace as the OGXServer + // and must have the label ogx.io/watch: "true". + // +kubebuilder:validation:Required + APIKey SecretKeyRef `json:"apiKey"` +} + +func (p OpenAIProvider) DeriveID() string { return p.deriveOrDefault("remote-openai") } + +// AzureProvider configures a remote::azure inference provider instance. +// +kubebuilder:validation:XValidation:rule="!has(self.apiVersion) || self.apiVersion.size() > 0",message="apiVersion must not be empty if specified" +// +kubebuilder:validation:XValidation:rule="!has(self.apiType) || self.apiType.size() > 0",message="apiType must not be empty if specified" +type AzureProvider struct { + RoutedProviderBase `json:",inline"` + RemoteInferenceCommonConfig `json:",inline"` + // Endpoint is the Azure API base URL + // (e.g., https://your-resource-name.openai.azure.com/openai/v1). + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + Endpoint string `json:"endpoint"` + // APIKey is the authentication credential for the Azure provider. + // The Secret must be in the same namespace as the OGXServer + // and must have the label ogx.io/watch: "true". + // +kubebuilder:validation:Required + APIKey SecretKeyRef `json:"apiKey"` + // APIVersion is the Azure API version (e.g., 2024-12-01-preview). + // +optional + APIVersion string `json:"apiVersion,omitempty"` + // APIType is the Azure API type (e.g., azure). + // +optional + APIType string `json:"apiType,omitempty"` +} + +func (p AzureProvider) DeriveID() string { return p.deriveOrDefault("remote-azure") } + +// BedrockProvider configures a remote::bedrock inference provider instance. +// +kubebuilder:validation:XValidation:rule="!has(self.awsRoleArn) || self.awsRoleArn.size() > 0",message="awsRoleArn must not be empty if specified" +// +kubebuilder:validation:XValidation:rule="!has(self.awsWebIdentityTokenFile) || self.awsWebIdentityTokenFile.size() > 0",message="awsWebIdentityTokenFile must not be empty if specified" +// +kubebuilder:validation:XValidation:rule="!has(self.awsRoleSessionName) || self.awsRoleSessionName.size() > 0",message="awsRoleSessionName must not be empty if specified" +// +kubebuilder:validation:XValidation:rule="!has(self.profileName) || self.profileName.size() > 0",message="profileName must not be empty if specified" +// +kubebuilder:validation:XValidation:rule="!has(self.retryMode) || self.retryMode.size() > 0",message="retryMode must not be empty if specified" +// +//nolint:lll // kubebuilder marker cannot be split across lines. +type BedrockProvider struct { + RoutedProviderBase `json:",inline"` + RemoteInferenceCommonConfig `json:",inline"` + // Region is the AWS region for the Bedrock Runtime endpoint. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + Region string `json:"region"` + // APIKey is the authentication credential for the Bedrock provider. + // The Secret must be in the same namespace as the OGXServer + // and must have the label ogx.io/watch: "true". + // +optional + APIKey *SecretKeyRef `json:"apiKey,omitempty"` + // AWSAccessKeyID is the AWS access key to use. + // The Secret must be in the same namespace as the OGXServer + // and must have the label ogx.io/watch: "true". + // +optional + AWSAccessKeyID *SecretKeyRef `json:"awsAccessKeyId,omitempty"` + // AWSSecretAccessKey is the AWS secret access key to use. + // The Secret must be in the same namespace as the OGXServer + // and must have the label ogx.io/watch: "true". + // +optional + AWSSecretAccessKey *SecretKeyRef `json:"awsSecretAccessKey,omitempty"` + // AWSSessionToken is the AWS session token to use. + // The Secret must be in the same namespace as the OGXServer + // and must have the label ogx.io/watch: "true". + // +optional + AWSSessionToken *SecretKeyRef `json:"awsSessionToken,omitempty"` + // AWSRoleArn is the AWS role ARN to assume. + // +optional + AWSRoleArn string `json:"awsRoleArn,omitempty"` + // AWSWebIdentityTokenFile is the path to the web identity token file. + // +optional + AWSWebIdentityTokenFile string `json:"awsWebIdentityTokenFile,omitempty"` + // AWSRoleSessionName is the session name to use when assuming a role. + // +optional + AWSRoleSessionName string `json:"awsRoleSessionName,omitempty"` + // ProfileName is the AWS profile name that contains credentials to use. + // +optional + ProfileName string `json:"profileName,omitempty"` + // TotalMaxAttempts is the maximum number of attempts for a single request, + // including the initial attempt. + // +optional + // +kubebuilder:validation:Minimum=1 + TotalMaxAttempts *int `json:"totalMaxAttempts,omitempty"` + // RetryMode is the type of retries to perform (e.g., standard, adaptive). + // +optional + RetryMode string `json:"retryMode,omitempty"` + // ConnectTimeout is the connection timeout in seconds. + // +optional + // +kubebuilder:validation:Minimum=1 + ConnectTimeout *int `json:"connectTimeout,omitempty"` + // ReadTimeout is the read timeout in seconds. + // +optional + // +kubebuilder:validation:Minimum=1 + ReadTimeout *int `json:"readTimeout,omitempty"` + // SessionTTL is the time in seconds until a session expires. + // +optional + // +kubebuilder:validation:Minimum=1 + SessionTTL *int `json:"sessionTTL,omitempty"` +} + +func (p BedrockProvider) DeriveID() string { return p.deriveOrDefault("remote-bedrock") } + +// VertexAIProvider configures a remote::vertexai inference provider instance. +// +kubebuilder:validation:XValidation:rule="!has(self.location) || self.location.size() > 0",message="location must not be empty if specified" +type VertexAIProvider struct { + RoutedProviderBase `json:",inline"` + RemoteInferenceCommonConfig `json:",inline"` + // Project is the Google Cloud project ID for Vertex AI. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + Project string `json:"project"` + // Location is the Google Cloud location for Vertex AI. + // +optional + Location string `json:"location,omitempty"` +} + +func (p VertexAIProvider) DeriveID() string { return p.deriveOrDefault("remote-vertexai") } + +// WatsonxProvider configures a remote::watsonx inference provider instance. +// +kubebuilder:validation:XValidation:rule="!has(self.endpoint) || self.endpoint.size() > 0",message="endpoint must not be empty if specified" +// +kubebuilder:validation:XValidation:rule="!has(self.projectId) || self.projectId.size() > 0",message="projectId must not be empty if specified" +type WatsonxProvider struct { + RoutedProviderBase `json:",inline"` + RemoteInferenceCommonConfig `json:",inline"` + // Endpoint is the base URL for accessing watsonx.ai. + // +optional + Endpoint string `json:"endpoint,omitempty"` + // APIKey is the authentication credential for the watsonx provider. + // The Secret must be in the same namespace as the OGXServer + // and must have the label ogx.io/watch: "true". + // +kubebuilder:validation:Required + APIKey SecretKeyRef `json:"apiKey"` + // ProjectID is the watsonx.ai project ID. + // +optional + ProjectID string `json:"projectId,omitempty"` + // Timeout is the timeout in seconds for HTTP requests. + // +optional + // +kubebuilder:validation:Minimum=1 + Timeout *int `json:"timeout,omitempty"` +} + +func (p WatsonxProvider) DeriveID() string { return p.deriveOrDefault("remote-watsonx") } + +// InferenceRemoteProviders groups remote inference providers. +type InferenceRemoteProviders struct { + // +optional + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=100 + VLLM []VLLMProvider `json:"vllm,omitempty"` + // +optional + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=100 + OpenAI []OpenAIProvider `json:"openai,omitempty"` + // +optional + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=100 + Azure []AzureProvider `json:"azure,omitempty"` + // +optional + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=100 + Bedrock []BedrockProvider `json:"bedrock,omitempty"` + // +optional + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=100 + VertexAI []VertexAIProvider `json:"vertexai,omitempty"` + // +optional + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=100 + Watsonx []WatsonxProvider `json:"watsonx,omitempty"` + // +optional + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=100 + Custom []CustomProvider `json:"custom,omitempty"` +} + +func (r *InferenceRemoteProviders) IDs() []string { + if r == nil { + return nil + } + return slices.Concat( + deriveSliceIDs(r.VLLM), deriveSliceIDs(r.OpenAI), deriveSliceIDs(r.Azure), + deriveSliceIDs(r.Bedrock), deriveSliceIDs(r.VertexAI), deriveSliceIDs(r.Watsonx), + deriveSliceIDs(r.Custom), + ) +} + +// InferenceInlineProviders groups inline inference providers. +type InferenceInlineProviders struct { + // +optional + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=100 + Custom []CustomProvider `json:"custom,omitempty"` +} + +func (inl *InferenceInlineProviders) IDs() []string { + if inl == nil { + return nil + } + return deriveSliceIDs(inl.Custom) +} + +// InferenceProvidersSpec configures inference providers. +type InferenceProvidersSpec struct { + // +optional + Remote *InferenceRemoteProviders `json:"remote,omitempty"` + // +optional + Inline *InferenceInlineProviders `json:"inline,omitempty"` +} + +func (s *InferenceProvidersSpec) IDs() []string { + if s == nil { + return nil + } + return slices.Concat(s.Remote.IDs(), s.Inline.IDs()) +} diff --git a/api/v1beta1/provider_types_responses.go b/api/v1beta1/provider_types_responses.go new file mode 100644 index 000000000..34a4ddb0c --- /dev/null +++ b/api/v1beta1/provider_types_responses.go @@ -0,0 +1,307 @@ +/* +Copyright 2025. + +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. +*/ + +package v1beta1 + +import "slices" + +// QualifiedModel identifies a model with its provider. +type QualifiedModel struct { + // ProviderID is the provider to use for this model. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + ProviderID string `json:"providerId"` + // ModelID is the model identifier. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + ModelID string `json:"modelId"` + // EmbeddingDimensions is the dimensionality of the embedding vectors. + // +optional + // +kubebuilder:validation:Minimum=1 + EmbeddingDimensions *int `json:"embeddingDimensions,omitempty"` +} + +// RerankerModel identifies a reranker model with its provider. +type RerankerModel struct { + // ProviderID is the provider to use for this model. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + ProviderID string `json:"providerId"` + // ModelID is the model identifier. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + ModelID string `json:"modelId"` +} + +// RewriteQueryParams configures query rewriting/expansion for vector search. +type RewriteQueryParams struct { + // Model is the LLM model used for query rewriting. + // +optional + Model *QualifiedModel `json:"model,omitempty"` + // Prompt is the prompt template for query rewriting. + // Use {query} as a placeholder for the original query. + // +optional + Prompt string `json:"prompt,omitempty"` + // MaxTokens is the maximum number of tokens for query expansion responses. + // +optional + // +kubebuilder:validation:Minimum=1 + MaxTokens *int `json:"maxTokens,omitempty"` + // Temperature controls randomness in query rewriting (0.0 = deterministic, 1.0 = creative). + // Specified as a decimal string (e.g., "0.7"). + // +optional + Temperature *string `json:"temperature,omitempty"` +} + +// FileSearchDisplayParams configures file search output formatting. +type FileSearchDisplayParams struct { + // HeaderTemplate is the template for the header text before search results. + // +optional + HeaderTemplate string `json:"headerTemplate,omitempty"` + // FooterTemplate is the template for the footer text after search results. + // +optional + FooterTemplate string `json:"footerTemplate,omitempty"` +} + +// ContextPromptParams configures LLM prompt content and chunk formatting. +type ContextPromptParams struct { + // ChunkAnnotationTemplate is the template for formatting individual chunks. + // +optional + ChunkAnnotationTemplate string `json:"chunkAnnotationTemplate,omitempty"` + // ContextTemplate is the template for explaining search results to the model. + // +optional + ContextTemplate string `json:"contextTemplate,omitempty"` +} + +// AnnotationPromptParams configures source annotation and attribution. +type AnnotationPromptParams struct { + // EnableAnnotations controls whether source annotations are included. + // +optional + EnableAnnotations *bool `json:"enableAnnotations,omitempty"` + // AnnotationInstructionTemplate provides instructions for citing sources. + // +optional + AnnotationInstructionTemplate string `json:"annotationInstructionTemplate,omitempty"` + // ChunkAnnotationTemplate is the template for chunks with annotation info. + // +optional + ChunkAnnotationTemplate string `json:"chunkAnnotationTemplate,omitempty"` +} + +// FileIngestionParams configures file processing during ingestion. +type FileIngestionParams struct { + // DefaultChunkSizeTokens is the default chunk size in tokens. + // +optional + // +kubebuilder:validation:Minimum=1 + DefaultChunkSizeTokens *int `json:"defaultChunkSizeTokens,omitempty"` + // DefaultChunkOverlapTokens is the default overlap between chunks in tokens. + // +optional + // +kubebuilder:validation:Minimum=0 + DefaultChunkOverlapTokens *int `json:"defaultChunkOverlapTokens,omitempty"` +} + +// ChunkRetrievalParams configures chunk retrieval and ranking during search. +type ChunkRetrievalParams struct { + // ChunkMultiplier multiplies the number of chunks retrieved for over-retrieval. + // +optional + // +kubebuilder:validation:Minimum=1 + ChunkMultiplier *int `json:"chunkMultiplier,omitempty"` + // MaxTokensInContext limits total tokens allowed in RAG context. + // +optional + // +kubebuilder:validation:Minimum=1 + MaxTokensInContext *int `json:"maxTokensInContext,omitempty"` + // DefaultRerankerStrategy is the default reranking strategy. + // +optional + // +kubebuilder:validation:Enum=rrf;weighted;normalized + DefaultRerankerStrategy *string `json:"defaultRerankerStrategy,omitempty"` + // RRFImpactFactor is the impact factor for Reciprocal Rank Fusion reranking. + // Specified as a decimal string (e.g., "60.0"). + // +optional + RRFImpactFactor *string `json:"rrfImpactFactor,omitempty"` + // WeightedSearchAlpha is the alpha weight for weighted search reranking (0.0-1.0). + // Specified as a decimal string (e.g., "0.5"). + // +optional + WeightedSearchAlpha *string `json:"weightedSearchAlpha,omitempty"` + // DefaultSearchMode is the default search mode. + // +optional + // +kubebuilder:validation:Enum=vector;keyword;hybrid + DefaultSearchMode *string `json:"defaultSearchMode,omitempty"` +} + +// FileBatchParams configures file batch processing. +type FileBatchParams struct { + // MaxConcurrentFilesPerBatch limits concurrent files processed per batch. + // +optional + // +kubebuilder:validation:Minimum=1 + MaxConcurrentFilesPerBatch *int `json:"maxConcurrentFilesPerBatch,omitempty"` + // FileBatchChunkSize is the number of files to process in each batch chunk. + // +optional + // +kubebuilder:validation:Minimum=1 + FileBatchChunkSize *int `json:"fileBatchChunkSize,omitempty"` + // CleanupIntervalSeconds is the interval between expired batch cleanup runs. + // +optional + // +kubebuilder:validation:Minimum=1 + CleanupIntervalSeconds *int `json:"cleanupIntervalSeconds,omitempty"` +} + +// ContextualRetrievalParams configures contextual retrieval during file ingestion. +type ContextualRetrievalParams struct { + // Model is the default LLM model for contextual retrieval. + // +optional + Model *QualifiedModel `json:"model,omitempty"` + // DefaultTimeoutSeconds is the timeout per LLM contextualization call. + // +optional + // +kubebuilder:validation:Minimum=1 + DefaultTimeoutSeconds *int `json:"defaultTimeoutSeconds,omitempty"` + // DefaultMaxConcurrency limits concurrent LLM calls for contextualization. + // +optional + // +kubebuilder:validation:Minimum=1 + DefaultMaxConcurrency *int `json:"defaultMaxConcurrency,omitempty"` + // MaxDocumentTokens limits document size in tokens for contextual retrieval. + // +optional + // +kubebuilder:validation:Minimum=1 + MaxDocumentTokens *int `json:"maxDocumentTokens,omitempty"` +} + +// VectorStoresConfig configures vector store behavior for responses and file search. +// +kubebuilder:validation:XValidation:rule="!has(self.defaultProviderId) || self.defaultProviderId.size() > 0",message="defaultProviderId must not be empty if specified" +type VectorStoresConfig struct { + // DefaultProviderID is the vector_io provider to use when multiple + // providers are available and none is specified. + // +optional + DefaultProviderID string `json:"defaultProviderId,omitempty"` + // DefaultEmbeddingModel configures the default embedding model. + // +optional + DefaultEmbeddingModel *QualifiedModel `json:"defaultEmbeddingModel,omitempty"` + // DefaultRerankerModel configures the default reranker model. + // +optional + DefaultRerankerModel *RerankerModel `json:"defaultRerankerModel,omitempty"` + // RewriteQueryParams configures query rewriting/expansion. Nil disables rewriting. + // +optional + RewriteQueryParams *RewriteQueryParams `json:"rewriteQueryParams,omitempty"` + // FileSearchParams configures file search output formatting. + // +optional + FileSearchParams *FileSearchDisplayParams `json:"fileSearchParams,omitempty"` + // ContextPromptParams configures context prompt templates. + // +optional + ContextPromptParams *ContextPromptParams `json:"contextPromptParams,omitempty"` + // AnnotationPromptParams configures source annotation settings. + // +optional + AnnotationPromptParams *AnnotationPromptParams `json:"annotationPromptParams,omitempty"` + // FileIngestionParams configures file ingestion chunk settings. + // +optional + FileIngestionParams *FileIngestionParams `json:"fileIngestionParams,omitempty"` + // ChunkRetrievalParams configures chunk retrieval and ranking. + // +optional + ChunkRetrievalParams *ChunkRetrievalParams `json:"chunkRetrievalParams,omitempty"` + // FileBatchParams configures file batch processing. + // +optional + FileBatchParams *FileBatchParams `json:"fileBatchParams,omitempty"` + // ContextualRetrievalParams configures contextual retrieval during ingestion. + // +optional + ContextualRetrievalParams *ContextualRetrievalParams `json:"contextualRetrievalParams,omitempty"` +} + +// CompactionConfig configures conversation compaction behavior for responses. +// +kubebuilder:validation:XValidation:rule="!has(self.summarizationPrompt) || self.summarizationPrompt.size() > 0",message="summarizationPrompt must not be empty if specified" +// +kubebuilder:validation:XValidation:rule="!has(self.summaryPrefix) || self.summaryPrefix.size() > 0",message="summaryPrefix must not be empty if specified" +// +kubebuilder:validation:XValidation:rule="!has(self.summarizationModel) || self.summarizationModel.size() > 0",message="summarizationModel must not be empty if specified" +// +kubebuilder:validation:XValidation:rule="!has(self.tokenizerEncoding) || self.tokenizerEncoding.size() > 0",message="tokenizerEncoding must not be empty if specified" +type CompactionConfig struct { + // SummarizationPrompt is the prompt used to instruct the model to + // summarize conversation history during compaction. + // +optional + SummarizationPrompt string `json:"summarizationPrompt,omitempty"` + // SummaryPrefix is text prepended to the compaction summary to frame + // it as a handoff for the next LLM context window. + // +optional + SummaryPrefix string `json:"summaryPrefix,omitempty"` + // SummarizationModel is the model to use for generating compaction + // summaries. If unset, uses the same model as the conversation. + // +optional + SummarizationModel string `json:"summarizationModel,omitempty"` + // DefaultCompactThreshold is the token count threshold for auto-compaction. + // Conversations exceeding this count will be automatically compacted. + // +optional + // +kubebuilder:validation:Minimum=1 + DefaultCompactThreshold *int `json:"defaultCompactThreshold,omitempty"` + // TokenizerEncoding is the tiktoken encoding name for token counting + // (e.g., "o200k_base", "cl100k_base"). + // +optional + TokenizerEncoding string `json:"tokenizerEncoding,omitempty"` +} + +// InlineBuiltinResponsesProvider configures inline::builtin for responses. +type InlineBuiltinResponsesProvider struct { + // VectorStoresConfig configures vector store behavior for file search + // and retrieval-augmented generation. + // +optional + VectorStoresConfig *VectorStoresConfig `json:"vectorStoresConfig,omitempty"` + // CompactionConfig configures conversation compaction behavior + // and prompt templates. + // +optional + CompactionConfig *CompactionConfig `json:"compactionConfig,omitempty"` +} + +func (p InlineBuiltinResponsesProvider) DeriveID() string { return "inline-builtin" } + +// ResponsesRemoteProviders groups remote responses providers. +type ResponsesRemoteProviders struct { + // +optional + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=100 + Custom []CustomProvider `json:"custom,omitempty"` +} + +func (r *ResponsesRemoteProviders) IDs() []string { + if r == nil { + return nil + } + return deriveSliceIDs(r.Custom) +} + +// ResponsesInlineProviders groups inline responses providers. +type ResponsesInlineProviders struct { + // +optional + Builtin *InlineBuiltinResponsesProvider `json:"builtin,omitempty"` + // +optional + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=100 + Custom []CustomProvider `json:"custom,omitempty"` +} + +func (inl *ResponsesInlineProviders) IDs() []string { + if inl == nil { + return nil + } + var ids []string + if inl.Builtin != nil { + ids = append(ids, inl.Builtin.DeriveID()) + } + return append(ids, deriveSliceIDs(inl.Custom)...) +} + +// ResponsesProvidersSpec configures responses providers. +type ResponsesProvidersSpec struct { + // +optional + Remote *ResponsesRemoteProviders `json:"remote,omitempty"` + // +optional + Inline *ResponsesInlineProviders `json:"inline,omitempty"` +} + +func (s *ResponsesProvidersSpec) IDs() []string { + if s == nil { + return nil + } + return slices.Concat(s.Remote.IDs(), s.Inline.IDs()) +} diff --git a/api/v1beta1/provider_types_toolruntime.go b/api/v1beta1/provider_types_toolruntime.go new file mode 100644 index 000000000..01938b5df --- /dev/null +++ b/api/v1beta1/provider_types_toolruntime.go @@ -0,0 +1,134 @@ +/* +Copyright 2025. + +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. +*/ + +package v1beta1 + +import "slices" + +// BraveSearchProvider configures a remote::brave-search tool runtime provider. +type BraveSearchProvider struct { + RoutedProviderBase `json:",inline"` + // APIKey is the Brave Search API key. + // The Secret must be in the same namespace as the OGXServer + // and must have the label ogx.io/watch: "true". + // +kubebuilder:validation:Required + APIKey SecretKeyRef `json:"apiKey"` + // MaxResults is the maximum number of search results to return. + // +optional + // +kubebuilder:validation:Minimum=1 + MaxResults *int `json:"maxResults,omitempty"` +} + +func (p BraveSearchProvider) DeriveID() string { return p.deriveOrDefault("remote-brave-search") } + +// TavilySearchProvider configures a remote::tavily-search tool runtime provider. +type TavilySearchProvider struct { + RoutedProviderBase `json:",inline"` + // APIKey is the Tavily Search API key. + // The Secret must be in the same namespace as the OGXServer + // and must have the label ogx.io/watch: "true". + // +kubebuilder:validation:Required + APIKey SecretKeyRef `json:"apiKey"` + // MaxResults is the maximum number of search results to return. + // +optional + // +kubebuilder:validation:Minimum=1 + MaxResults *int `json:"maxResults,omitempty"` +} + +func (p TavilySearchProvider) DeriveID() string { return p.deriveOrDefault("remote-tavily-search") } + +// ModelContextProtocolProvider configures remote::model-context-protocol. +type ModelContextProtocolProvider struct { + RoutedProviderBase `json:",inline"` +} + +func (p ModelContextProtocolProvider) DeriveID() string { + return p.deriveOrDefault("remote-model-context-protocol") +} + +// InlineFileSearchProvider configures inline::file-search. +type InlineFileSearchProvider struct { + RoutedProviderBase `json:",inline"` + // VectorStoresConfig configures vector store behavior for file search. + // +optional + VectorStoresConfig *VectorStoresConfig `json:"vectorStoresConfig,omitempty"` +} + +func (p InlineFileSearchProvider) DeriveID() string { return p.deriveOrDefault("inline-file-search") } + +// ToolRuntimeRemoteProviders groups remote tool runtime providers. +type ToolRuntimeRemoteProviders struct { + // +optional + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=100 + BraveSearch []BraveSearchProvider `json:"braveSearch,omitempty"` + // +optional + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=100 + TavilySearch []TavilySearchProvider `json:"tavilySearch,omitempty"` + // +optional + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=100 + ModelContextProtocol []ModelContextProtocolProvider `json:"modelContextProtocol,omitempty"` + // +optional + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=100 + Custom []CustomProvider `json:"custom,omitempty"` +} + +func (r *ToolRuntimeRemoteProviders) IDs() []string { + if r == nil { + return nil + } + return slices.Concat( + deriveSliceIDs(r.BraveSearch), deriveSliceIDs(r.TavilySearch), + deriveSliceIDs(r.ModelContextProtocol), deriveSliceIDs(r.Custom), + ) +} + +// ToolRuntimeInlineProviders groups inline tool runtime providers. +type ToolRuntimeInlineProviders struct { + // +optional + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=100 + FileSearch []InlineFileSearchProvider `json:"fileSearch,omitempty"` + // +optional + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=100 + Custom []CustomProvider `json:"custom,omitempty"` +} + +func (inl *ToolRuntimeInlineProviders) IDs() []string { + if inl == nil { + return nil + } + return slices.Concat(deriveSliceIDs(inl.FileSearch), deriveSliceIDs(inl.Custom)) +} + +// ToolRuntimeProvidersSpec configures tool runtime providers. +type ToolRuntimeProvidersSpec struct { + // +optional + Remote *ToolRuntimeRemoteProviders `json:"remote,omitempty"` + // +optional + Inline *ToolRuntimeInlineProviders `json:"inline,omitempty"` +} + +func (s *ToolRuntimeProvidersSpec) IDs() []string { + if s == nil { + return nil + } + return slices.Concat(s.Remote.IDs(), s.Inline.IDs()) +} diff --git a/api/v1beta1/provider_types_vectorio.go b/api/v1beta1/provider_types_vectorio.go new file mode 100644 index 000000000..30915b117 --- /dev/null +++ b/api/v1beta1/provider_types_vectorio.go @@ -0,0 +1,226 @@ +/* +Copyright 2025. + +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. +*/ + +package v1beta1 + +import "slices" + +// HNSWConfig configures an HNSW vector index for PGVector. +type HNSWConfig struct { + // M is the maximum number of connections per element in the HNSW graph. + // +optional + // +kubebuilder:validation:Minimum=1 + M *int `json:"m,omitempty"` + // EfConstruction controls the index build-time accuracy/speed tradeoff. + // +optional + // +kubebuilder:validation:Minimum=1 + EfConstruction *int `json:"efConstruction,omitempty"` + // EfSearch controls the query-time accuracy/speed tradeoff. + // +optional + // +kubebuilder:validation:Minimum=1 + EfSearch *int `json:"efSearch,omitempty"` +} + +// IVFFlatConfig configures an IVFFlat vector index for PGVector. +type IVFFlatConfig struct { + // Nlist is the number of inverted lists (clusters). + // +optional + // +kubebuilder:validation:Minimum=1 + Nlist *int `json:"nlist,omitempty"` + // Nprobe is the number of clusters to search at query time. + // +optional + // +kubebuilder:validation:Minimum=1 + Nprobe *int `json:"nprobe,omitempty"` +} + +// VectorIndexConfig configures the vector index strategy for PGVector. +// Exactly one of hnsw or ivfFlat must be specified. +// +kubebuilder:validation:XValidation:rule="has(self.hnsw) || has(self.ivfFlat)",message="one of hnsw or ivfFlat must be specified" +// +kubebuilder:validation:XValidation:rule="!(has(self.hnsw) && has(self.ivfFlat))",message="only one of hnsw or ivfFlat can be specified" +type VectorIndexConfig struct { + // HNSW configures an HNSW index. + // +optional + HNSW *HNSWConfig `json:"hnsw,omitempty"` + // IVFFlat configures an IVFFlat index. + // +optional + IVFFlat *IVFFlatConfig `json:"ivfFlat,omitempty"` +} + +// PgvectorProvider configures a remote::pgvector vector I/O provider instance. +// +kubebuilder:validation:XValidation:rule="!has(self.host) || self.host.size() > 0",message="host must not be empty if specified" +// +kubebuilder:validation:XValidation:rule="!has(self.db) || self.db.size() > 0",message="db must not be empty if specified" +// +kubebuilder:validation:XValidation:rule="!has(self.user) || self.user.size() > 0",message="user must not be empty if specified" +type PgvectorProvider struct { + RoutedProviderBase `json:",inline"` + // Host is the PostgreSQL server hostname. + // +optional + Host string `json:"host,omitempty"` + // Port is the PostgreSQL server port. + // +optional + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=65535 + Port *int `json:"port,omitempty"` + // DB is the PostgreSQL database name. + // +optional + DB string `json:"db,omitempty"` + // User is the PostgreSQL username. + // +optional + User string `json:"user,omitempty"` + // Password is the PostgreSQL password. + // The Secret must be in the same namespace as the OGXServer + // and must have the label ogx.io/watch: "true". + // +kubebuilder:validation:Required + Password SecretKeyRef `json:"password"` + // DistanceMetric is the distance metric used for vector search. + // +optional + // +kubebuilder:validation:Enum=COSINE;L2;L1;INNER_PRODUCT + DistanceMetric string `json:"distanceMetric,omitempty"` + // VectorIndex configures the vector index strategy for + // Approximate Nearest Neighbor (ANN) search. + // +optional + VectorIndex *VectorIndexConfig `json:"vectorIndex,omitempty"` +} + +func (p PgvectorProvider) DeriveID() string { return p.deriveOrDefault("remote-pgvector") } + +// MilvusProvider configures a remote::milvus vector I/O provider instance. +// +kubebuilder:validation:XValidation:rule="!has(self.consistencyLevel) || self.consistencyLevel.size() > 0",message="consistencyLevel must not be empty if specified" +type MilvusProvider struct { + RoutedProviderBase `json:",inline"` + // URI is the URI of the Milvus server. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + URI string `json:"uri"` + // Token is the authentication token for the Milvus server. + // The Secret must be in the same namespace as the OGXServer + // and must have the label ogx.io/watch: "true". + // +optional + Token *SecretKeyRef `json:"token,omitempty"` + // ConsistencyLevel is the consistency level of the Milvus server. + // +optional + ConsistencyLevel string `json:"consistencyLevel,omitempty"` +} + +func (p MilvusProvider) DeriveID() string { return p.deriveOrDefault("remote-milvus") } + +// QdrantProvider configures a remote::qdrant vector I/O provider instance. +// +kubebuilder:validation:XValidation:rule="has(self.url) || has(self.host)",message="at least one of url or host must be specified" +// +kubebuilder:validation:XValidation:rule="!has(self.url) || self.url.size() > 0",message="url must not be empty if specified" +// +kubebuilder:validation:XValidation:rule="!has(self.host) || self.host.size() > 0",message="host must not be empty if specified" +// +kubebuilder:validation:XValidation:rule="!has(self.location) || self.location.size() > 0",message="location must not be empty if specified" +// +kubebuilder:validation:XValidation:rule="!has(self.prefix) || self.prefix.size() > 0",message="prefix must not be empty if specified" +type QdrantProvider struct { + RoutedProviderBase `json:",inline"` + // URL is the URL of the Qdrant server. + // +optional + URL string `json:"url,omitempty"` + // Host is the hostname of the Qdrant server. + // +optional + Host string `json:"host,omitempty"` + // Port is the REST API port of the Qdrant server. + // +optional + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=65535 + Port *int `json:"port,omitempty"` + // APIKey is the authentication key for the Qdrant server. + // The Secret must be in the same namespace as the OGXServer + // and must have the label ogx.io/watch: "true". + // +optional + APIKey *SecretKeyRef `json:"apiKey,omitempty"` + // Location is the Qdrant server location identifier. + // +optional + Location string `json:"location,omitempty"` + // GRPCPort is the gRPC port of the Qdrant server. + // +optional + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=65535 + GRPCPort *int `json:"grpcPort,omitempty"` + // PreferGRPC controls whether to prefer gRPC over REST for communication. + // +optional + PreferGRPC *bool `json:"preferGrpc,omitempty"` + // HTTPS controls whether to use HTTPS for the connection. + // +optional + HTTPS *bool `json:"https,omitempty"` + // Prefix is the URL path prefix for the Qdrant server. + // +optional + Prefix string `json:"prefix,omitempty"` + // Timeout is the connection timeout in seconds. + // +optional + // +kubebuilder:validation:Minimum=1 + Timeout *int `json:"timeout,omitempty"` +} + +func (p QdrantProvider) DeriveID() string { return p.deriveOrDefault("remote-qdrant") } + +// VectorIORemoteProviders groups remote vector I/O providers. +type VectorIORemoteProviders struct { + // +optional + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=100 + Pgvector []PgvectorProvider `json:"pgvector,omitempty"` + // +optional + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=100 + Milvus []MilvusProvider `json:"milvus,omitempty"` + // +optional + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=100 + Qdrant []QdrantProvider `json:"qdrant,omitempty"` + // +optional + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=100 + Custom []CustomProvider `json:"custom,omitempty"` +} + +func (r *VectorIORemoteProviders) IDs() []string { + if r == nil { + return nil + } + return slices.Concat( + deriveSliceIDs(r.Pgvector), deriveSliceIDs(r.Milvus), + deriveSliceIDs(r.Qdrant), deriveSliceIDs(r.Custom), + ) +} + +// VectorIOInlineProviders groups inline vector I/O providers. +type VectorIOInlineProviders struct { + // +optional + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=100 + Custom []CustomProvider `json:"custom,omitempty"` +} + +func (inl *VectorIOInlineProviders) IDs() []string { + if inl == nil { + return nil + } + return deriveSliceIDs(inl.Custom) +} + +// VectorIOProvidersSpec configures vector I/O providers. +type VectorIOProvidersSpec struct { + // +optional + Remote *VectorIORemoteProviders `json:"remote,omitempty"` + // +optional + Inline *VectorIOInlineProviders `json:"inline,omitempty"` +} + +func (s *VectorIOProvidersSpec) IDs() []string { + if s == nil { + return nil + } + return slices.Concat(s.Remote.IDs(), s.Inline.IDs()) +} diff --git a/api/v1beta1/suite_cel_test.go b/api/v1beta1/suite_cel_test.go new file mode 100644 index 000000000..1880434f4 --- /dev/null +++ b/api/v1beta1/suite_cel_test.go @@ -0,0 +1,188 @@ +/* +Copyright 2025. + +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. +*/ + +package v1beta1 + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" +) + +var ( + cfg *rest.Config + k8sClient client.Client + testEnv *envtest.Environment + nameCounter int64 +) + +func TestMain(m *testing.M) { + logf.SetLogger(zap.New(zap.UseDevMode(true))) + + testEnv = &envtest.Environment{ + CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")}, + ErrorIfCRDPathMissing: true, + BinaryAssetsDirectory: os.Getenv("KUBEBUILDER_ASSETS"), + } + + var err error + cfg, err = testEnv.Start() + if err != nil { + logf.Log.Error(err, "failed to start test environment") + os.Exit(1) + } + + err = AddToScheme(scheme.Scheme) + if err != nil { + logf.Log.Error(err, "failed to add v1beta1 scheme") + os.Exit(1) + } + + k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) + if err != nil { + logf.Log.Error(err, "failed to create client") + os.Exit(1) + } + + code := m.Run() + + err = testEnv.Stop() + if err != nil { + logf.Log.Error(err, "failed to stop test environment") + os.Exit(1) + } + + os.Exit(code) +} + +func uniqueName() string { + return fmt.Sprintf("cel-%d", atomic.AddInt64(&nameCounter, 1)) +} + +func createCELTestNamespace(t *testing.T, prefix string) string { + t.Helper() + name := fmt.Sprintf("%s-%d", prefix, atomic.AddInt64(&nameCounter, 1)) + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + } + if err := k8sClient.Create(context.Background(), ns); err != nil { + t.Fatalf("failed to create test namespace %q: %v", name, err) + } + t.Cleanup(func() { + _ = k8sClient.Delete(context.Background(), ns) + }) + return name +} + +func validOGXServer(name, namespace string) *OGXServer { + return &OGXServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: OGXServerSpec{ + Distribution: DistributionSpec{ + Image: "test:latest", + }, + }, + } +} + +func ptr[T any](v T) *T { + return &v +} + +func requireCELError(t *testing.T, err error, expectedMsg string) { + t.Helper() + if err == nil { + t.Fatal("expected CEL validation error, got nil") + } + if !apierrors.IsInvalid(err) { + t.Fatalf("expected StatusReasonInvalid, got: %v", err) + } + statusErr, ok := err.(*apierrors.StatusError) //nolint:errorlint // envtest errors are never wrapped + if !ok { + t.Fatalf("expected *StatusError, got %T", err) + } + causes := statusErr.Status().Details.Causes + for _, c := range causes { + if strings.Contains(c.Message, expectedMsg) { + return + } + } + t.Errorf("no cause contained %q; causes: %v", expectedMsg, causes) +} + +func requireAPIError(t *testing.T, err error, expectedMsg string) { + t.Helper() + if err == nil { + t.Fatal("expected API validation error, got nil") + } + if !strings.Contains(err.Error(), expectedMsg) { + t.Errorf("error %q does not contain %q", err.Error(), expectedMsg) + } +} + +func validUnstructuredOGXServer(t *testing.T, name, namespace string) map[string]any { + t.Helper() + obj := validOGXServer(name, namespace) + data, err := json.Marshal(obj) + if err != nil { + t.Fatalf("failed to marshal OGXServer: %v", err) + } + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("failed to unmarshal to map: %v", err) + } + raw["apiVersion"] = "ogx.io/v1beta1" + raw["kind"] = "OGXServer" + return raw +} + +func createUnstructured(t *testing.T, raw map[string]any) error { + t.Helper() + u := &unstructured.Unstructured{Object: raw} + return k8sClient.Create(context.Background(), u) +} + +func setNestedField(obj map[string]any, value any, fields ...string) { + m := obj + for _, f := range fields[:len(fields)-1] { + next, ok := m[f].(map[string]any) + if !ok { + next = make(map[string]any) + m[f] = next + } + m = next + } + m[fields[len(fields)-1]] = value +} diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go new file mode 100644 index 000000000..4f80dddb7 --- /dev/null +++ b/api/v1beta1/zz_generated.deepcopy.go @@ -0,0 +1,2699 @@ +//go:build !ignore_autogenerated + +/* +Copyright 2025. + +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. +*/ + +// Code generated by controller-gen. DO NOT EDIT. + +package v1beta1 + +import ( + corev1 "k8s.io/api/core/v1" + "k8s.io/api/networking/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/intstr" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AnnotationPromptParams) DeepCopyInto(out *AnnotationPromptParams) { + *out = *in + if in.EnableAnnotations != nil { + in, out := &in.EnableAnnotations, &out.EnableAnnotations + *out = new(bool) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AnnotationPromptParams. +func (in *AnnotationPromptParams) DeepCopy() *AnnotationPromptParams { + if in == nil { + return nil + } + out := new(AnnotationPromptParams) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AutoscalingSpec) DeepCopyInto(out *AutoscalingSpec) { + *out = *in + if in.MinReplicas != nil { + in, out := &in.MinReplicas, &out.MinReplicas + *out = new(int32) + **out = **in + } + if in.TargetCPUUtilizationPercentage != nil { + in, out := &in.TargetCPUUtilizationPercentage, &out.TargetCPUUtilizationPercentage + *out = new(int32) + **out = **in + } + if in.TargetMemoryUtilizationPercentage != nil { + in, out := &in.TargetMemoryUtilizationPercentage, &out.TargetMemoryUtilizationPercentage + *out = new(int32) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AutoscalingSpec. +func (in *AutoscalingSpec) DeepCopy() *AutoscalingSpec { + if in == nil { + return nil + } + out := new(AutoscalingSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AzureProvider) DeepCopyInto(out *AzureProvider) { + *out = *in + out.RoutedProviderBase = in.RoutedProviderBase + in.RemoteInferenceCommonConfig.DeepCopyInto(&out.RemoteInferenceCommonConfig) + out.APIKey = in.APIKey +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AzureProvider. +func (in *AzureProvider) DeepCopy() *AzureProvider { + if in == nil { + return nil + } + out := new(AzureProvider) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BatchesInlineProviders) DeepCopyInto(out *BatchesInlineProviders) { + *out = *in + if in.Reference != nil { + in, out := &in.Reference, &out.Reference + *out = new(InlineReferenceProvider) + (*in).DeepCopyInto(*out) + } + if in.Custom != nil { + in, out := &in.Custom, &out.Custom + *out = make([]CustomProvider, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BatchesInlineProviders. +func (in *BatchesInlineProviders) DeepCopy() *BatchesInlineProviders { + if in == nil { + return nil + } + out := new(BatchesInlineProviders) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BatchesProvidersSpec) DeepCopyInto(out *BatchesProvidersSpec) { + *out = *in + if in.Remote != nil { + in, out := &in.Remote, &out.Remote + *out = new(BatchesRemoteProviders) + (*in).DeepCopyInto(*out) + } + if in.Inline != nil { + in, out := &in.Inline, &out.Inline + *out = new(BatchesInlineProviders) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BatchesProvidersSpec. +func (in *BatchesProvidersSpec) DeepCopy() *BatchesProvidersSpec { + if in == nil { + return nil + } + out := new(BatchesProvidersSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BatchesRemoteProviders) DeepCopyInto(out *BatchesRemoteProviders) { + *out = *in + if in.Custom != nil { + in, out := &in.Custom, &out.Custom + *out = make([]CustomProvider, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BatchesRemoteProviders. +func (in *BatchesRemoteProviders) DeepCopy() *BatchesRemoteProviders { + if in == nil { + return nil + } + out := new(BatchesRemoteProviders) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BedrockProvider) DeepCopyInto(out *BedrockProvider) { + *out = *in + out.RoutedProviderBase = in.RoutedProviderBase + in.RemoteInferenceCommonConfig.DeepCopyInto(&out.RemoteInferenceCommonConfig) + if in.APIKey != nil { + in, out := &in.APIKey, &out.APIKey + *out = new(SecretKeyRef) + **out = **in + } + if in.AWSAccessKeyID != nil { + in, out := &in.AWSAccessKeyID, &out.AWSAccessKeyID + *out = new(SecretKeyRef) + **out = **in + } + if in.AWSSecretAccessKey != nil { + in, out := &in.AWSSecretAccessKey, &out.AWSSecretAccessKey + *out = new(SecretKeyRef) + **out = **in + } + if in.AWSSessionToken != nil { + in, out := &in.AWSSessionToken, &out.AWSSessionToken + *out = new(SecretKeyRef) + **out = **in + } + if in.TotalMaxAttempts != nil { + in, out := &in.TotalMaxAttempts, &out.TotalMaxAttempts + *out = new(int) + **out = **in + } + if in.ConnectTimeout != nil { + in, out := &in.ConnectTimeout, &out.ConnectTimeout + *out = new(int) + **out = **in + } + if in.ReadTimeout != nil { + in, out := &in.ReadTimeout, &out.ReadTimeout + *out = new(int) + **out = **in + } + if in.SessionTTL != nil { + in, out := &in.SessionTTL, &out.SessionTTL + *out = new(int) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BedrockProvider. +func (in *BedrockProvider) DeepCopy() *BedrockProvider { + if in == nil { + return nil + } + out := new(BedrockProvider) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BraveSearchProvider) DeepCopyInto(out *BraveSearchProvider) { + *out = *in + out.RoutedProviderBase = in.RoutedProviderBase + out.APIKey = in.APIKey + if in.MaxResults != nil { + in, out := &in.MaxResults, &out.MaxResults + *out = new(int) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BraveSearchProvider. +func (in *BraveSearchProvider) DeepCopy() *BraveSearchProvider { + if in == nil { + return nil + } + out := new(BraveSearchProvider) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ChunkRetrievalParams) DeepCopyInto(out *ChunkRetrievalParams) { + *out = *in + if in.ChunkMultiplier != nil { + in, out := &in.ChunkMultiplier, &out.ChunkMultiplier + *out = new(int) + **out = **in + } + if in.MaxTokensInContext != nil { + in, out := &in.MaxTokensInContext, &out.MaxTokensInContext + *out = new(int) + **out = **in + } + if in.DefaultRerankerStrategy != nil { + in, out := &in.DefaultRerankerStrategy, &out.DefaultRerankerStrategy + *out = new(string) + **out = **in + } + if in.RRFImpactFactor != nil { + in, out := &in.RRFImpactFactor, &out.RRFImpactFactor + *out = new(string) + **out = **in + } + if in.WeightedSearchAlpha != nil { + in, out := &in.WeightedSearchAlpha, &out.WeightedSearchAlpha + *out = new(string) + **out = **in + } + if in.DefaultSearchMode != nil { + in, out := &in.DefaultSearchMode, &out.DefaultSearchMode + *out = new(string) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ChunkRetrievalParams. +func (in *ChunkRetrievalParams) DeepCopy() *ChunkRetrievalParams { + if in == nil { + return nil + } + out := new(ChunkRetrievalParams) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CompactionConfig) DeepCopyInto(out *CompactionConfig) { + *out = *in + if in.DefaultCompactThreshold != nil { + in, out := &in.DefaultCompactThreshold, &out.DefaultCompactThreshold + *out = new(int) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CompactionConfig. +func (in *CompactionConfig) DeepCopy() *CompactionConfig { + if in == nil { + return nil + } + out := new(CompactionConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ConfigGenerationStatus) DeepCopyInto(out *ConfigGenerationStatus) { + *out = *in + in.GeneratedAt.DeepCopyInto(&out.GeneratedAt) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConfigGenerationStatus. +func (in *ConfigGenerationStatus) DeepCopy() *ConfigGenerationStatus { + if in == nil { + return nil + } + out := new(ConfigGenerationStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ConfigMapKeyRef) DeepCopyInto(out *ConfigMapKeyRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConfigMapKeyRef. +func (in *ConfigMapKeyRef) DeepCopy() *ConfigMapKeyRef { + if in == nil { + return nil + } + out := new(ConfigMapKeyRef) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ContextPromptParams) DeepCopyInto(out *ContextPromptParams) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ContextPromptParams. +func (in *ContextPromptParams) DeepCopy() *ContextPromptParams { + if in == nil { + return nil + } + out := new(ContextPromptParams) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ContextualRetrievalParams) DeepCopyInto(out *ContextualRetrievalParams) { + *out = *in + if in.Model != nil { + in, out := &in.Model, &out.Model + *out = new(QualifiedModel) + (*in).DeepCopyInto(*out) + } + if in.DefaultTimeoutSeconds != nil { + in, out := &in.DefaultTimeoutSeconds, &out.DefaultTimeoutSeconds + *out = new(int) + **out = **in + } + if in.DefaultMaxConcurrency != nil { + in, out := &in.DefaultMaxConcurrency, &out.DefaultMaxConcurrency + *out = new(int) + **out = **in + } + if in.MaxDocumentTokens != nil { + in, out := &in.MaxDocumentTokens, &out.MaxDocumentTokens + *out = new(int) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ContextualRetrievalParams. +func (in *ContextualRetrievalParams) DeepCopy() *ContextualRetrievalParams { + if in == nil { + return nil + } + out := new(ContextualRetrievalParams) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CustomProvider) DeepCopyInto(out *CustomProvider) { + *out = *in + out.RoutedProviderBase = in.RoutedProviderBase + if in.SecretRefs != nil { + in, out := &in.SecretRefs, &out.SecretRefs + *out = make(map[string]SecretKeyRef, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.Settings != nil { + in, out := &in.Settings, &out.Settings + *out = new(apiextensionsv1.JSON) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CustomProvider. +func (in *CustomProvider) DeepCopy() *CustomProvider { + if in == nil { + return nil + } + out := new(CustomProvider) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DistributionConfig) DeepCopyInto(out *DistributionConfig) { + *out = *in + if in.Providers != nil { + in, out := &in.Providers, &out.Providers + *out = make([]ProviderInfo, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.AvailableDistributions != nil { + in, out := &in.AvailableDistributions, &out.AvailableDistributions + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DistributionConfig. +func (in *DistributionConfig) DeepCopy() *DistributionConfig { + if in == nil { + return nil + } + out := new(DistributionConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DistributionSpec) DeepCopyInto(out *DistributionSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DistributionSpec. +func (in *DistributionSpec) DeepCopy() *DistributionSpec { + if in == nil { + return nil + } + out := new(DistributionSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DoclingServeProvider) DeepCopyInto(out *DoclingServeProvider) { + *out = *in + if in.APIKey != nil { + in, out := &in.APIKey, &out.APIKey + *out = new(SecretKeyRef) + **out = **in + } + if in.DefaultChunkSizeTokens != nil { + in, out := &in.DefaultChunkSizeTokens, &out.DefaultChunkSizeTokens + *out = new(int) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DoclingServeProvider. +func (in *DoclingServeProvider) DeepCopy() *DoclingServeProvider { + if in == nil { + return nil + } + out := new(DoclingServeProvider) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ExternalAccessConfig) DeepCopyInto(out *ExternalAccessConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExternalAccessConfig. +func (in *ExternalAccessConfig) DeepCopy() *ExternalAccessConfig { + if in == nil { + return nil + } + out := new(ExternalAccessConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FileBatchParams) DeepCopyInto(out *FileBatchParams) { + *out = *in + if in.MaxConcurrentFilesPerBatch != nil { + in, out := &in.MaxConcurrentFilesPerBatch, &out.MaxConcurrentFilesPerBatch + *out = new(int) + **out = **in + } + if in.FileBatchChunkSize != nil { + in, out := &in.FileBatchChunkSize, &out.FileBatchChunkSize + *out = new(int) + **out = **in + } + if in.CleanupIntervalSeconds != nil { + in, out := &in.CleanupIntervalSeconds, &out.CleanupIntervalSeconds + *out = new(int) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FileBatchParams. +func (in *FileBatchParams) DeepCopy() *FileBatchParams { + if in == nil { + return nil + } + out := new(FileBatchParams) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FileIngestionParams) DeepCopyInto(out *FileIngestionParams) { + *out = *in + if in.DefaultChunkSizeTokens != nil { + in, out := &in.DefaultChunkSizeTokens, &out.DefaultChunkSizeTokens + *out = new(int) + **out = **in + } + if in.DefaultChunkOverlapTokens != nil { + in, out := &in.DefaultChunkOverlapTokens, &out.DefaultChunkOverlapTokens + *out = new(int) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FileIngestionParams. +func (in *FileIngestionParams) DeepCopy() *FileIngestionParams { + if in == nil { + return nil + } + out := new(FileIngestionParams) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FileProcessorChunkConfig) DeepCopyInto(out *FileProcessorChunkConfig) { + *out = *in + if in.DefaultChunkSizeTokens != nil { + in, out := &in.DefaultChunkSizeTokens, &out.DefaultChunkSizeTokens + *out = new(int) + **out = **in + } + if in.DefaultChunkOverlapTokens != nil { + in, out := &in.DefaultChunkOverlapTokens, &out.DefaultChunkOverlapTokens + *out = new(int) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FileProcessorChunkConfig. +func (in *FileProcessorChunkConfig) DeepCopy() *FileProcessorChunkConfig { + if in == nil { + return nil + } + out := new(FileProcessorChunkConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FileProcessorsInlineProviders) DeepCopyInto(out *FileProcessorsInlineProviders) { + *out = *in + if in.Auto != nil { + in, out := &in.Auto, &out.Auto + *out = new(InlineAutoFileProcessorProvider) + (*in).DeepCopyInto(*out) + } + if in.PyPDF != nil { + in, out := &in.PyPDF, &out.PyPDF + *out = new(InlinePyPDFFileProcessorProvider) + (*in).DeepCopyInto(*out) + } + if in.MarkItDown != nil { + in, out := &in.MarkItDown, &out.MarkItDown + *out = new(InlineMarkItDownFileProcessorProvider) + (*in).DeepCopyInto(*out) + } + if in.Docling != nil { + in, out := &in.Docling, &out.Docling + *out = new(InlineDoclingFileProcessorProvider) + (*in).DeepCopyInto(*out) + } + if in.Custom != nil { + in, out := &in.Custom, &out.Custom + *out = make([]CustomProvider, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FileProcessorsInlineProviders. +func (in *FileProcessorsInlineProviders) DeepCopy() *FileProcessorsInlineProviders { + if in == nil { + return nil + } + out := new(FileProcessorsInlineProviders) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FileProcessorsProvidersSpec) DeepCopyInto(out *FileProcessorsProvidersSpec) { + *out = *in + if in.Remote != nil { + in, out := &in.Remote, &out.Remote + *out = new(FileProcessorsRemoteProviders) + (*in).DeepCopyInto(*out) + } + if in.Inline != nil { + in, out := &in.Inline, &out.Inline + *out = new(FileProcessorsInlineProviders) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FileProcessorsProvidersSpec. +func (in *FileProcessorsProvidersSpec) DeepCopy() *FileProcessorsProvidersSpec { + if in == nil { + return nil + } + out := new(FileProcessorsProvidersSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FileProcessorsRemoteProviders) DeepCopyInto(out *FileProcessorsRemoteProviders) { + *out = *in + if in.DoclingServe != nil { + in, out := &in.DoclingServe, &out.DoclingServe + *out = new(DoclingServeProvider) + (*in).DeepCopyInto(*out) + } + if in.Custom != nil { + in, out := &in.Custom, &out.Custom + *out = make([]CustomProvider, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FileProcessorsRemoteProviders. +func (in *FileProcessorsRemoteProviders) DeepCopy() *FileProcessorsRemoteProviders { + if in == nil { + return nil + } + out := new(FileProcessorsRemoteProviders) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FileSearchDisplayParams) DeepCopyInto(out *FileSearchDisplayParams) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FileSearchDisplayParams. +func (in *FileSearchDisplayParams) DeepCopy() *FileSearchDisplayParams { + if in == nil { + return nil + } + out := new(FileSearchDisplayParams) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FilesInlineProviders) DeepCopyInto(out *FilesInlineProviders) { + *out = *in + if in.LocalFS != nil { + in, out := &in.LocalFS, &out.LocalFS + *out = new(InlineLocalFSProvider) + (*in).DeepCopyInto(*out) + } + if in.Custom != nil { + in, out := &in.Custom, &out.Custom + *out = make([]CustomProvider, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FilesInlineProviders. +func (in *FilesInlineProviders) DeepCopy() *FilesInlineProviders { + if in == nil { + return nil + } + out := new(FilesInlineProviders) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FilesProvidersSpec) DeepCopyInto(out *FilesProvidersSpec) { + *out = *in + if in.Remote != nil { + in, out := &in.Remote, &out.Remote + *out = new(FilesRemoteProviders) + (*in).DeepCopyInto(*out) + } + if in.Inline != nil { + in, out := &in.Inline, &out.Inline + *out = new(FilesInlineProviders) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FilesProvidersSpec. +func (in *FilesProvidersSpec) DeepCopy() *FilesProvidersSpec { + if in == nil { + return nil + } + out := new(FilesProvidersSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FilesRemoteProviders) DeepCopyInto(out *FilesRemoteProviders) { + *out = *in + if in.S3 != nil { + in, out := &in.S3, &out.S3 + *out = new(S3Provider) + (*in).DeepCopyInto(*out) + } + if in.Custom != nil { + in, out := &in.Custom, &out.Custom + *out = make([]CustomProvider, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FilesRemoteProviders. +func (in *FilesRemoteProviders) DeepCopy() *FilesRemoteProviders { + if in == nil { + return nil + } + out := new(FilesRemoteProviders) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HNSWConfig) DeepCopyInto(out *HNSWConfig) { + *out = *in + if in.M != nil { + in, out := &in.M, &out.M + *out = new(int) + **out = **in + } + if in.EfConstruction != nil { + in, out := &in.EfConstruction, &out.EfConstruction + *out = new(int) + **out = **in + } + if in.EfSearch != nil { + in, out := &in.EfSearch, &out.EfSearch + *out = new(int) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HNSWConfig. +func (in *HNSWConfig) DeepCopy() *HNSWConfig { + if in == nil { + return nil + } + out := new(HNSWConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *IVFFlatConfig) DeepCopyInto(out *IVFFlatConfig) { + *out = *in + if in.Nlist != nil { + in, out := &in.Nlist, &out.Nlist + *out = new(int) + **out = **in + } + if in.Nprobe != nil { + in, out := &in.Nprobe, &out.Nprobe + *out = new(int) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IVFFlatConfig. +func (in *IVFFlatConfig) DeepCopy() *IVFFlatConfig { + if in == nil { + return nil + } + out := new(IVFFlatConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *IdentityConfig) DeepCopyInto(out *IdentityConfig) { + *out = *in + out.Cert = in.Cert + out.Key = in.Key +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IdentityConfig. +func (in *IdentityConfig) DeepCopy() *IdentityConfig { + if in == nil { + return nil + } + out := new(IdentityConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *InferenceInlineProviders) DeepCopyInto(out *InferenceInlineProviders) { + *out = *in + if in.Custom != nil { + in, out := &in.Custom, &out.Custom + *out = make([]CustomProvider, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InferenceInlineProviders. +func (in *InferenceInlineProviders) DeepCopy() *InferenceInlineProviders { + if in == nil { + return nil + } + out := new(InferenceInlineProviders) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *InferenceProvidersSpec) DeepCopyInto(out *InferenceProvidersSpec) { + *out = *in + if in.Remote != nil { + in, out := &in.Remote, &out.Remote + *out = new(InferenceRemoteProviders) + (*in).DeepCopyInto(*out) + } + if in.Inline != nil { + in, out := &in.Inline, &out.Inline + *out = new(InferenceInlineProviders) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InferenceProvidersSpec. +func (in *InferenceProvidersSpec) DeepCopy() *InferenceProvidersSpec { + if in == nil { + return nil + } + out := new(InferenceProvidersSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *InferenceRemoteProviders) DeepCopyInto(out *InferenceRemoteProviders) { + *out = *in + if in.VLLM != nil { + in, out := &in.VLLM, &out.VLLM + *out = make([]VLLMProvider, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.OpenAI != nil { + in, out := &in.OpenAI, &out.OpenAI + *out = make([]OpenAIProvider, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Azure != nil { + in, out := &in.Azure, &out.Azure + *out = make([]AzureProvider, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Bedrock != nil { + in, out := &in.Bedrock, &out.Bedrock + *out = make([]BedrockProvider, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.VertexAI != nil { + in, out := &in.VertexAI, &out.VertexAI + *out = make([]VertexAIProvider, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Watsonx != nil { + in, out := &in.Watsonx, &out.Watsonx + *out = make([]WatsonxProvider, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Custom != nil { + in, out := &in.Custom, &out.Custom + *out = make([]CustomProvider, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InferenceRemoteProviders. +func (in *InferenceRemoteProviders) DeepCopy() *InferenceRemoteProviders { + if in == nil { + return nil + } + out := new(InferenceRemoteProviders) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *InlineAutoFileProcessorProvider) DeepCopyInto(out *InlineAutoFileProcessorProvider) { + *out = *in + in.FileProcessorChunkConfig.DeepCopyInto(&out.FileProcessorChunkConfig) + if in.ExtractMetadata != nil { + in, out := &in.ExtractMetadata, &out.ExtractMetadata + *out = new(bool) + **out = **in + } + if in.CleanText != nil { + in, out := &in.CleanText, &out.CleanText + *out = new(bool) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InlineAutoFileProcessorProvider. +func (in *InlineAutoFileProcessorProvider) DeepCopy() *InlineAutoFileProcessorProvider { + if in == nil { + return nil + } + out := new(InlineAutoFileProcessorProvider) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *InlineBuiltinResponsesProvider) DeepCopyInto(out *InlineBuiltinResponsesProvider) { + *out = *in + if in.VectorStoresConfig != nil { + in, out := &in.VectorStoresConfig, &out.VectorStoresConfig + *out = new(VectorStoresConfig) + (*in).DeepCopyInto(*out) + } + if in.CompactionConfig != nil { + in, out := &in.CompactionConfig, &out.CompactionConfig + *out = new(CompactionConfig) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InlineBuiltinResponsesProvider. +func (in *InlineBuiltinResponsesProvider) DeepCopy() *InlineBuiltinResponsesProvider { + if in == nil { + return nil + } + out := new(InlineBuiltinResponsesProvider) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *InlineDoclingFileProcessorProvider) DeepCopyInto(out *InlineDoclingFileProcessorProvider) { + *out = *in + in.FileProcessorChunkConfig.DeepCopyInto(&out.FileProcessorChunkConfig) + if in.DoOCR != nil { + in, out := &in.DoOCR, &out.DoOCR + *out = new(bool) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InlineDoclingFileProcessorProvider. +func (in *InlineDoclingFileProcessorProvider) DeepCopy() *InlineDoclingFileProcessorProvider { + if in == nil { + return nil + } + out := new(InlineDoclingFileProcessorProvider) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *InlineFileSearchProvider) DeepCopyInto(out *InlineFileSearchProvider) { + *out = *in + out.RoutedProviderBase = in.RoutedProviderBase + if in.VectorStoresConfig != nil { + in, out := &in.VectorStoresConfig, &out.VectorStoresConfig + *out = new(VectorStoresConfig) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InlineFileSearchProvider. +func (in *InlineFileSearchProvider) DeepCopy() *InlineFileSearchProvider { + if in == nil { + return nil + } + out := new(InlineFileSearchProvider) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *InlineLocalFSProvider) DeepCopyInto(out *InlineLocalFSProvider) { + *out = *in + if in.TTLSecs != nil { + in, out := &in.TTLSecs, &out.TTLSecs + *out = new(int) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InlineLocalFSProvider. +func (in *InlineLocalFSProvider) DeepCopy() *InlineLocalFSProvider { + if in == nil { + return nil + } + out := new(InlineLocalFSProvider) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *InlineMarkItDownFileProcessorProvider) DeepCopyInto(out *InlineMarkItDownFileProcessorProvider) { + *out = *in + in.FileProcessorChunkConfig.DeepCopyInto(&out.FileProcessorChunkConfig) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InlineMarkItDownFileProcessorProvider. +func (in *InlineMarkItDownFileProcessorProvider) DeepCopy() *InlineMarkItDownFileProcessorProvider { + if in == nil { + return nil + } + out := new(InlineMarkItDownFileProcessorProvider) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *InlinePyPDFFileProcessorProvider) DeepCopyInto(out *InlinePyPDFFileProcessorProvider) { + *out = *in + in.FileProcessorChunkConfig.DeepCopyInto(&out.FileProcessorChunkConfig) + if in.ExtractMetadata != nil { + in, out := &in.ExtractMetadata, &out.ExtractMetadata + *out = new(bool) + **out = **in + } + if in.CleanText != nil { + in, out := &in.CleanText, &out.CleanText + *out = new(bool) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InlinePyPDFFileProcessorProvider. +func (in *InlinePyPDFFileProcessorProvider) DeepCopy() *InlinePyPDFFileProcessorProvider { + if in == nil { + return nil + } + out := new(InlinePyPDFFileProcessorProvider) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *InlineReferenceProvider) DeepCopyInto(out *InlineReferenceProvider) { + *out = *in + if in.MaxConcurrentBatches != nil { + in, out := &in.MaxConcurrentBatches, &out.MaxConcurrentBatches + *out = new(int) + **out = **in + } + if in.MaxConcurrentRequestsPerBatch != nil { + in, out := &in.MaxConcurrentRequestsPerBatch, &out.MaxConcurrentRequestsPerBatch + *out = new(int) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InlineReferenceProvider. +func (in *InlineReferenceProvider) DeepCopy() *InlineReferenceProvider { + if in == nil { + return nil + } + out := new(InlineReferenceProvider) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KVStorageSpec) DeepCopyInto(out *KVStorageSpec) { + *out = *in + if in.Password != nil { + in, out := &in.Password, &out.Password + *out = new(SecretKeyRef) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KVStorageSpec. +func (in *KVStorageSpec) DeepCopy() *KVStorageSpec { + if in == nil { + return nil + } + out := new(KVStorageSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MilvusProvider) DeepCopyInto(out *MilvusProvider) { + *out = *in + out.RoutedProviderBase = in.RoutedProviderBase + if in.Token != nil { + in, out := &in.Token, &out.Token + *out = new(SecretKeyRef) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MilvusProvider. +func (in *MilvusProvider) DeepCopy() *MilvusProvider { + if in == nil { + return nil + } + out := new(MilvusProvider) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ModelConfig) DeepCopyInto(out *ModelConfig) { + *out = *in + if in.ContextLength != nil { + in, out := &in.ContextLength, &out.ContextLength + *out = new(int) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ModelConfig. +func (in *ModelConfig) DeepCopy() *ModelConfig { + if in == nil { + return nil + } + out := new(ModelConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ModelContextProtocolProvider) DeepCopyInto(out *ModelContextProtocolProvider) { + *out = *in + out.RoutedProviderBase = in.RoutedProviderBase +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ModelContextProtocolProvider. +func (in *ModelContextProtocolProvider) DeepCopy() *ModelContextProtocolProvider { + if in == nil { + return nil + } + out := new(ModelContextProtocolProvider) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MonitoringSpec) DeepCopyInto(out *MonitoringSpec) { + *out = *in + if in.Enabled != nil { + in, out := &in.Enabled, &out.Enabled + *out = new(bool) + **out = **in + } + if in.MetricsPort != nil { + in, out := &in.MetricsPort, &out.MetricsPort + *out = new(int32) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MonitoringSpec. +func (in *MonitoringSpec) DeepCopy() *MonitoringSpec { + if in == nil { + return nil + } + out := new(MonitoringSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NetworkConfig) DeepCopyInto(out *NetworkConfig) { + *out = *in + if in.TLS != nil { + in, out := &in.TLS, &out.TLS + *out = new(TLSConfig) + (*in).DeepCopyInto(*out) + } + if in.Proxy != nil { + in, out := &in.Proxy, &out.Proxy + *out = new(ProxyConfig) + (*in).DeepCopyInto(*out) + } + if in.Timeout != nil { + in, out := &in.Timeout, &out.Timeout + *out = new(TimeoutConfig) + (*in).DeepCopyInto(*out) + } + if in.Headers != nil { + in, out := &in.Headers, &out.Headers + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NetworkConfig. +func (in *NetworkConfig) DeepCopy() *NetworkConfig { + if in == nil { + return nil + } + out := new(NetworkConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NetworkPolicySpec) DeepCopyInto(out *NetworkPolicySpec) { + *out = *in + if in.Enabled != nil { + in, out := &in.Enabled, &out.Enabled + *out = new(bool) + **out = **in + } + if in.PolicyTypes != nil { + in, out := &in.PolicyTypes, &out.PolicyTypes + *out = make([]v1.PolicyType, len(*in)) + copy(*out, *in) + } + if in.Ingress != nil { + in, out := &in.Ingress, &out.Ingress + *out = make([]v1.NetworkPolicyIngressRule, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Egress != nil { + in, out := &in.Egress, &out.Egress + *out = make([]v1.NetworkPolicyEgressRule, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NetworkPolicySpec. +func (in *NetworkPolicySpec) DeepCopy() *NetworkPolicySpec { + if in == nil { + return nil + } + out := new(NetworkPolicySpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NetworkSpec) DeepCopyInto(out *NetworkSpec) { + *out = *in + if in.TLS != nil { + in, out := &in.TLS, &out.TLS + *out = new(TLSSpec) + **out = **in + } + if in.ExternalAccess != nil { + in, out := &in.ExternalAccess, &out.ExternalAccess + *out = new(ExternalAccessConfig) + **out = **in + } + if in.Policy != nil { + in, out := &in.Policy, &out.Policy + *out = new(NetworkPolicySpec) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NetworkSpec. +func (in *NetworkSpec) DeepCopy() *NetworkSpec { + if in == nil { + return nil + } + out := new(NetworkSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OGXServer) DeepCopyInto(out *OGXServer) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OGXServer. +func (in *OGXServer) DeepCopy() *OGXServer { + if in == nil { + return nil + } + out := new(OGXServer) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *OGXServer) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OGXServerList) DeepCopyInto(out *OGXServerList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]OGXServer, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OGXServerList. +func (in *OGXServerList) DeepCopy() *OGXServerList { + if in == nil { + return nil + } + out := new(OGXServerList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *OGXServerList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OGXServerSpec) DeepCopyInto(out *OGXServerSpec) { + *out = *in + out.Distribution = in.Distribution + if in.Providers != nil { + in, out := &in.Providers, &out.Providers + *out = new(ProvidersSpec) + (*in).DeepCopyInto(*out) + } + if in.Resources != nil { + in, out := &in.Resources, &out.Resources + *out = new(ResourcesSpec) + (*in).DeepCopyInto(*out) + } + if in.Storage != nil { + in, out := &in.Storage, &out.Storage + *out = new(StateStorageSpec) + (*in).DeepCopyInto(*out) + } + if in.DisabledAPIs != nil { + in, out := &in.DisabledAPIs, &out.DisabledAPIs + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.RegistryRefreshIntervalSeconds != nil { + in, out := &in.RegistryRefreshIntervalSeconds, &out.RegistryRefreshIntervalSeconds + *out = new(int32) + **out = **in + } + if in.Network != nil { + in, out := &in.Network, &out.Network + *out = new(NetworkSpec) + (*in).DeepCopyInto(*out) + } + if in.TLS != nil { + in, out := &in.TLS, &out.TLS + *out = new(TLSClientConfig) + (*in).DeepCopyInto(*out) + } + if in.Workload != nil { + in, out := &in.Workload, &out.Workload + *out = new(WorkloadSpec) + (*in).DeepCopyInto(*out) + } + if in.Monitoring != nil { + in, out := &in.Monitoring, &out.Monitoring + *out = new(MonitoringSpec) + (*in).DeepCopyInto(*out) + } + if in.BaseConfig != nil { + in, out := &in.BaseConfig, &out.BaseConfig + *out = new(ConfigMapKeyRef) + **out = **in + } + if in.OverrideConfig != nil { + in, out := &in.OverrideConfig, &out.OverrideConfig + *out = new(ConfigMapKeyRef) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OGXServerSpec. +func (in *OGXServerSpec) DeepCopy() *OGXServerSpec { + if in == nil { + return nil + } + out := new(OGXServerSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OGXServerStatus) DeepCopyInto(out *OGXServerStatus) { + *out = *in + in.Version.DeepCopyInto(&out.Version) + in.DistributionConfig.DeepCopyInto(&out.DistributionConfig) + if in.ResolvedDistribution != nil { + in, out := &in.ResolvedDistribution, &out.ResolvedDistribution + *out = new(ResolvedDistributionStatus) + **out = **in + } + if in.ConfigGeneration != nil { + in, out := &in.ConfigGeneration, &out.ConfigGeneration + *out = new(ConfigGenerationStatus) + (*in).DeepCopyInto(*out) + } + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]metav1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.ExternalURL != nil { + in, out := &in.ExternalURL, &out.ExternalURL + *out = new(string) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OGXServerStatus. +func (in *OGXServerStatus) DeepCopy() *OGXServerStatus { + if in == nil { + return nil + } + out := new(OGXServerStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OGXServerValidator) DeepCopyInto(out *OGXServerValidator) { + *out = *in + if in.KnownDistributionNames != nil { + in, out := &in.KnownDistributionNames, &out.KnownDistributionNames + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OGXServerValidator. +func (in *OGXServerValidator) DeepCopy() *OGXServerValidator { + if in == nil { + return nil + } + out := new(OGXServerValidator) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OpenAIProvider) DeepCopyInto(out *OpenAIProvider) { + *out = *in + out.RoutedProviderBase = in.RoutedProviderBase + in.RemoteInferenceCommonConfig.DeepCopyInto(&out.RemoteInferenceCommonConfig) + out.APIKey = in.APIKey +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OpenAIProvider. +func (in *OpenAIProvider) DeepCopy() *OpenAIProvider { + if in == nil { + return nil + } + out := new(OpenAIProvider) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PVCStorageSpec) DeepCopyInto(out *PVCStorageSpec) { + *out = *in + if in.Size != nil { + in, out := &in.Size, &out.Size + x := (*in).DeepCopy() + *out = &x + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PVCStorageSpec. +func (in *PVCStorageSpec) DeepCopy() *PVCStorageSpec { + if in == nil { + return nil + } + out := new(PVCStorageSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PgvectorProvider) DeepCopyInto(out *PgvectorProvider) { + *out = *in + out.RoutedProviderBase = in.RoutedProviderBase + if in.Port != nil { + in, out := &in.Port, &out.Port + *out = new(int) + **out = **in + } + out.Password = in.Password + if in.VectorIndex != nil { + in, out := &in.VectorIndex, &out.VectorIndex + *out = new(VectorIndexConfig) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PgvectorProvider. +func (in *PgvectorProvider) DeepCopy() *PgvectorProvider { + if in == nil { + return nil + } + out := new(PgvectorProvider) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PodDisruptionBudgetSpec) DeepCopyInto(out *PodDisruptionBudgetSpec) { + *out = *in + if in.MinAvailable != nil { + in, out := &in.MinAvailable, &out.MinAvailable + *out = new(intstr.IntOrString) + **out = **in + } + if in.MaxUnavailable != nil { + in, out := &in.MaxUnavailable, &out.MaxUnavailable + *out = new(intstr.IntOrString) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PodDisruptionBudgetSpec. +func (in *PodDisruptionBudgetSpec) DeepCopy() *PodDisruptionBudgetSpec { + if in == nil { + return nil + } + out := new(PodDisruptionBudgetSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ProviderHealthStatus) DeepCopyInto(out *ProviderHealthStatus) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProviderHealthStatus. +func (in *ProviderHealthStatus) DeepCopy() *ProviderHealthStatus { + if in == nil { + return nil + } + out := new(ProviderHealthStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ProviderInfo) DeepCopyInto(out *ProviderInfo) { + *out = *in + in.Config.DeepCopyInto(&out.Config) + out.Health = in.Health +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProviderInfo. +func (in *ProviderInfo) DeepCopy() *ProviderInfo { + if in == nil { + return nil + } + out := new(ProviderInfo) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ProvidersSpec) DeepCopyInto(out *ProvidersSpec) { + *out = *in + if in.Inference != nil { + in, out := &in.Inference, &out.Inference + *out = new(InferenceProvidersSpec) + (*in).DeepCopyInto(*out) + } + if in.VectorIo != nil { + in, out := &in.VectorIo, &out.VectorIo + *out = new(VectorIOProvidersSpec) + (*in).DeepCopyInto(*out) + } + if in.ToolRuntime != nil { + in, out := &in.ToolRuntime, &out.ToolRuntime + *out = new(ToolRuntimeProvidersSpec) + (*in).DeepCopyInto(*out) + } + if in.Files != nil { + in, out := &in.Files, &out.Files + *out = new(FilesProvidersSpec) + (*in).DeepCopyInto(*out) + } + if in.Batches != nil { + in, out := &in.Batches, &out.Batches + *out = new(BatchesProvidersSpec) + (*in).DeepCopyInto(*out) + } + if in.Responses != nil { + in, out := &in.Responses, &out.Responses + *out = new(ResponsesProvidersSpec) + (*in).DeepCopyInto(*out) + } + if in.FileProcessors != nil { + in, out := &in.FileProcessors, &out.FileProcessors + *out = new(FileProcessorsProvidersSpec) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProvidersSpec. +func (in *ProvidersSpec) DeepCopy() *ProvidersSpec { + if in == nil { + return nil + } + out := new(ProvidersSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ProxyConfig) DeepCopyInto(out *ProxyConfig) { + *out = *in + if in.URL != nil { + in, out := &in.URL, &out.URL + *out = new(string) + **out = **in + } + if in.HTTP != nil { + in, out := &in.HTTP, &out.HTTP + *out = new(string) + **out = **in + } + if in.HTTPS != nil { + in, out := &in.HTTPS, &out.HTTPS + *out = new(string) + **out = **in + } + if in.CACert != nil { + in, out := &in.CACert, &out.CACert + *out = new(string) + **out = **in + } + if in.NoProxy != nil { + in, out := &in.NoProxy, &out.NoProxy + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProxyConfig. +func (in *ProxyConfig) DeepCopy() *ProxyConfig { + if in == nil { + return nil + } + out := new(ProxyConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *QdrantProvider) DeepCopyInto(out *QdrantProvider) { + *out = *in + out.RoutedProviderBase = in.RoutedProviderBase + if in.Port != nil { + in, out := &in.Port, &out.Port + *out = new(int) + **out = **in + } + if in.APIKey != nil { + in, out := &in.APIKey, &out.APIKey + *out = new(SecretKeyRef) + **out = **in + } + if in.GRPCPort != nil { + in, out := &in.GRPCPort, &out.GRPCPort + *out = new(int) + **out = **in + } + if in.PreferGRPC != nil { + in, out := &in.PreferGRPC, &out.PreferGRPC + *out = new(bool) + **out = **in + } + if in.HTTPS != nil { + in, out := &in.HTTPS, &out.HTTPS + *out = new(bool) + **out = **in + } + if in.Timeout != nil { + in, out := &in.Timeout, &out.Timeout + *out = new(int) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new QdrantProvider. +func (in *QdrantProvider) DeepCopy() *QdrantProvider { + if in == nil { + return nil + } + out := new(QdrantProvider) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *QualifiedModel) DeepCopyInto(out *QualifiedModel) { + *out = *in + if in.EmbeddingDimensions != nil { + in, out := &in.EmbeddingDimensions, &out.EmbeddingDimensions + *out = new(int) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new QualifiedModel. +func (in *QualifiedModel) DeepCopy() *QualifiedModel { + if in == nil { + return nil + } + out := new(QualifiedModel) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RemoteInferenceCommonConfig) DeepCopyInto(out *RemoteInferenceCommonConfig) { + *out = *in + if in.AllowedModels != nil { + in, out := &in.AllowedModels, &out.AllowedModels + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.RefreshModels != nil { + in, out := &in.RefreshModels, &out.RefreshModels + *out = new(bool) + **out = **in + } + if in.Network != nil { + in, out := &in.Network, &out.Network + *out = new(NetworkConfig) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RemoteInferenceCommonConfig. +func (in *RemoteInferenceCommonConfig) DeepCopy() *RemoteInferenceCommonConfig { + if in == nil { + return nil + } + out := new(RemoteInferenceCommonConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RerankerModel) DeepCopyInto(out *RerankerModel) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RerankerModel. +func (in *RerankerModel) DeepCopy() *RerankerModel { + if in == nil { + return nil + } + out := new(RerankerModel) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ResolvedDistributionStatus) DeepCopyInto(out *ResolvedDistributionStatus) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResolvedDistributionStatus. +func (in *ResolvedDistributionStatus) DeepCopy() *ResolvedDistributionStatus { + if in == nil { + return nil + } + out := new(ResolvedDistributionStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ResourcesSpec) DeepCopyInto(out *ResourcesSpec) { + *out = *in + if in.Models != nil { + in, out := &in.Models, &out.Models + *out = make([]ModelConfig, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResourcesSpec. +func (in *ResourcesSpec) DeepCopy() *ResourcesSpec { + if in == nil { + return nil + } + out := new(ResourcesSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ResponsesInlineProviders) DeepCopyInto(out *ResponsesInlineProviders) { + *out = *in + if in.Builtin != nil { + in, out := &in.Builtin, &out.Builtin + *out = new(InlineBuiltinResponsesProvider) + (*in).DeepCopyInto(*out) + } + if in.Custom != nil { + in, out := &in.Custom, &out.Custom + *out = make([]CustomProvider, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResponsesInlineProviders. +func (in *ResponsesInlineProviders) DeepCopy() *ResponsesInlineProviders { + if in == nil { + return nil + } + out := new(ResponsesInlineProviders) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ResponsesProvidersSpec) DeepCopyInto(out *ResponsesProvidersSpec) { + *out = *in + if in.Remote != nil { + in, out := &in.Remote, &out.Remote + *out = new(ResponsesRemoteProviders) + (*in).DeepCopyInto(*out) + } + if in.Inline != nil { + in, out := &in.Inline, &out.Inline + *out = new(ResponsesInlineProviders) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResponsesProvidersSpec. +func (in *ResponsesProvidersSpec) DeepCopy() *ResponsesProvidersSpec { + if in == nil { + return nil + } + out := new(ResponsesProvidersSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ResponsesRemoteProviders) DeepCopyInto(out *ResponsesRemoteProviders) { + *out = *in + if in.Custom != nil { + in, out := &in.Custom, &out.Custom + *out = make([]CustomProvider, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResponsesRemoteProviders. +func (in *ResponsesRemoteProviders) DeepCopy() *ResponsesRemoteProviders { + if in == nil { + return nil + } + out := new(ResponsesRemoteProviders) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RewriteQueryParams) DeepCopyInto(out *RewriteQueryParams) { + *out = *in + if in.Model != nil { + in, out := &in.Model, &out.Model + *out = new(QualifiedModel) + (*in).DeepCopyInto(*out) + } + if in.MaxTokens != nil { + in, out := &in.MaxTokens, &out.MaxTokens + *out = new(int) + **out = **in + } + if in.Temperature != nil { + in, out := &in.Temperature, &out.Temperature + *out = new(string) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RewriteQueryParams. +func (in *RewriteQueryParams) DeepCopy() *RewriteQueryParams { + if in == nil { + return nil + } + out := new(RewriteQueryParams) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RoutedProviderBase) DeepCopyInto(out *RoutedProviderBase) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RoutedProviderBase. +func (in *RoutedProviderBase) DeepCopy() *RoutedProviderBase { + if in == nil { + return nil + } + out := new(RoutedProviderBase) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *S3Provider) DeepCopyInto(out *S3Provider) { + *out = *in + if in.AWSAccessKeyID != nil { + in, out := &in.AWSAccessKeyID, &out.AWSAccessKeyID + *out = new(SecretKeyRef) + **out = **in + } + if in.AWSSecretAccessKey != nil { + in, out := &in.AWSSecretAccessKey, &out.AWSSecretAccessKey + *out = new(SecretKeyRef) + **out = **in + } + if in.AutoCreateBucket != nil { + in, out := &in.AutoCreateBucket, &out.AutoCreateBucket + *out = new(bool) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new S3Provider. +func (in *S3Provider) DeepCopy() *S3Provider { + if in == nil { + return nil + } + out := new(S3Provider) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SQLStorageSpec) DeepCopyInto(out *SQLStorageSpec) { + *out = *in + if in.ConnectionString != nil { + in, out := &in.ConnectionString, &out.ConnectionString + *out = new(SecretKeyRef) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SQLStorageSpec. +func (in *SQLStorageSpec) DeepCopy() *SQLStorageSpec { + if in == nil { + return nil + } + out := new(SQLStorageSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SecretKeyRef) DeepCopyInto(out *SecretKeyRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretKeyRef. +func (in *SecretKeyRef) DeepCopy() *SecretKeyRef { + if in == nil { + return nil + } + out := new(SecretKeyRef) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *StateStorageSpec) DeepCopyInto(out *StateStorageSpec) { + *out = *in + if in.KV != nil { + in, out := &in.KV, &out.KV + *out = new(KVStorageSpec) + (*in).DeepCopyInto(*out) + } + if in.SQL != nil { + in, out := &in.SQL, &out.SQL + *out = new(SQLStorageSpec) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StateStorageSpec. +func (in *StateStorageSpec) DeepCopy() *StateStorageSpec { + if in == nil { + return nil + } + out := new(StateStorageSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TLSClientConfig) DeepCopyInto(out *TLSClientConfig) { + *out = *in + if in.Trust != nil { + in, out := &in.Trust, &out.Trust + *out = new(TrustConfig) + (*in).DeepCopyInto(*out) + } + if in.Identity != nil { + in, out := &in.Identity, &out.Identity + *out = new(IdentityConfig) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TLSClientConfig. +func (in *TLSClientConfig) DeepCopy() *TLSClientConfig { + if in == nil { + return nil + } + out := new(TLSClientConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TLSConfig) DeepCopyInto(out *TLSConfig) { + *out = *in + if in.Verify != nil { + in, out := &in.Verify, &out.Verify + *out = new(bool) + **out = **in + } + if in.Ciphers != nil { + in, out := &in.Ciphers, &out.Ciphers + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TLSConfig. +func (in *TLSConfig) DeepCopy() *TLSConfig { + if in == nil { + return nil + } + out := new(TLSConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TLSSpec) DeepCopyInto(out *TLSSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TLSSpec. +func (in *TLSSpec) DeepCopy() *TLSSpec { + if in == nil { + return nil + } + out := new(TLSSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TavilySearchProvider) DeepCopyInto(out *TavilySearchProvider) { + *out = *in + out.RoutedProviderBase = in.RoutedProviderBase + out.APIKey = in.APIKey + if in.MaxResults != nil { + in, out := &in.MaxResults, &out.MaxResults + *out = new(int) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TavilySearchProvider. +func (in *TavilySearchProvider) DeepCopy() *TavilySearchProvider { + if in == nil { + return nil + } + out := new(TavilySearchProvider) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TimeoutConfig) DeepCopyInto(out *TimeoutConfig) { + *out = *in + if in.Connect != nil { + in, out := &in.Connect, &out.Connect + *out = new(int) + **out = **in + } + if in.Read != nil { + in, out := &in.Read, &out.Read + *out = new(int) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TimeoutConfig. +func (in *TimeoutConfig) DeepCopy() *TimeoutConfig { + if in == nil { + return nil + } + out := new(TimeoutConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ToolRuntimeInlineProviders) DeepCopyInto(out *ToolRuntimeInlineProviders) { + *out = *in + if in.FileSearch != nil { + in, out := &in.FileSearch, &out.FileSearch + *out = make([]InlineFileSearchProvider, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Custom != nil { + in, out := &in.Custom, &out.Custom + *out = make([]CustomProvider, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ToolRuntimeInlineProviders. +func (in *ToolRuntimeInlineProviders) DeepCopy() *ToolRuntimeInlineProviders { + if in == nil { + return nil + } + out := new(ToolRuntimeInlineProviders) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ToolRuntimeProvidersSpec) DeepCopyInto(out *ToolRuntimeProvidersSpec) { + *out = *in + if in.Remote != nil { + in, out := &in.Remote, &out.Remote + *out = new(ToolRuntimeRemoteProviders) + (*in).DeepCopyInto(*out) + } + if in.Inline != nil { + in, out := &in.Inline, &out.Inline + *out = new(ToolRuntimeInlineProviders) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ToolRuntimeProvidersSpec. +func (in *ToolRuntimeProvidersSpec) DeepCopy() *ToolRuntimeProvidersSpec { + if in == nil { + return nil + } + out := new(ToolRuntimeProvidersSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ToolRuntimeRemoteProviders) DeepCopyInto(out *ToolRuntimeRemoteProviders) { + *out = *in + if in.BraveSearch != nil { + in, out := &in.BraveSearch, &out.BraveSearch + *out = make([]BraveSearchProvider, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.TavilySearch != nil { + in, out := &in.TavilySearch, &out.TavilySearch + *out = make([]TavilySearchProvider, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.ModelContextProtocol != nil { + in, out := &in.ModelContextProtocol, &out.ModelContextProtocol + *out = make([]ModelContextProtocolProvider, len(*in)) + copy(*out, *in) + } + if in.Custom != nil { + in, out := &in.Custom, &out.Custom + *out = make([]CustomProvider, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ToolRuntimeRemoteProviders. +func (in *ToolRuntimeRemoteProviders) DeepCopy() *ToolRuntimeRemoteProviders { + if in == nil { + return nil + } + out := new(ToolRuntimeRemoteProviders) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TrustConfig) DeepCopyInto(out *TrustConfig) { + *out = *in + if in.CACertificates != nil { + in, out := &in.CACertificates, &out.CACertificates + *out = make([]ConfigMapKeyRef, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TrustConfig. +func (in *TrustConfig) DeepCopy() *TrustConfig { + if in == nil { + return nil + } + out := new(TrustConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VLLMProvider) DeepCopyInto(out *VLLMProvider) { + *out = *in + out.RoutedProviderBase = in.RoutedProviderBase + in.RemoteInferenceCommonConfig.DeepCopyInto(&out.RemoteInferenceCommonConfig) + if in.APIToken != nil { + in, out := &in.APIToken, &out.APIToken + *out = new(SecretKeyRef) + **out = **in + } + if in.MaxTokens != nil { + in, out := &in.MaxTokens, &out.MaxTokens + *out = new(int) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VLLMProvider. +func (in *VLLMProvider) DeepCopy() *VLLMProvider { + if in == nil { + return nil + } + out := new(VLLMProvider) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VectorIOInlineProviders) DeepCopyInto(out *VectorIOInlineProviders) { + *out = *in + if in.Custom != nil { + in, out := &in.Custom, &out.Custom + *out = make([]CustomProvider, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VectorIOInlineProviders. +func (in *VectorIOInlineProviders) DeepCopy() *VectorIOInlineProviders { + if in == nil { + return nil + } + out := new(VectorIOInlineProviders) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VectorIOProvidersSpec) DeepCopyInto(out *VectorIOProvidersSpec) { + *out = *in + if in.Remote != nil { + in, out := &in.Remote, &out.Remote + *out = new(VectorIORemoteProviders) + (*in).DeepCopyInto(*out) + } + if in.Inline != nil { + in, out := &in.Inline, &out.Inline + *out = new(VectorIOInlineProviders) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VectorIOProvidersSpec. +func (in *VectorIOProvidersSpec) DeepCopy() *VectorIOProvidersSpec { + if in == nil { + return nil + } + out := new(VectorIOProvidersSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VectorIORemoteProviders) DeepCopyInto(out *VectorIORemoteProviders) { + *out = *in + if in.Pgvector != nil { + in, out := &in.Pgvector, &out.Pgvector + *out = make([]PgvectorProvider, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Milvus != nil { + in, out := &in.Milvus, &out.Milvus + *out = make([]MilvusProvider, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Qdrant != nil { + in, out := &in.Qdrant, &out.Qdrant + *out = make([]QdrantProvider, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Custom != nil { + in, out := &in.Custom, &out.Custom + *out = make([]CustomProvider, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VectorIORemoteProviders. +func (in *VectorIORemoteProviders) DeepCopy() *VectorIORemoteProviders { + if in == nil { + return nil + } + out := new(VectorIORemoteProviders) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VectorIndexConfig) DeepCopyInto(out *VectorIndexConfig) { + *out = *in + if in.HNSW != nil { + in, out := &in.HNSW, &out.HNSW + *out = new(HNSWConfig) + (*in).DeepCopyInto(*out) + } + if in.IVFFlat != nil { + in, out := &in.IVFFlat, &out.IVFFlat + *out = new(IVFFlatConfig) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VectorIndexConfig. +func (in *VectorIndexConfig) DeepCopy() *VectorIndexConfig { + if in == nil { + return nil + } + out := new(VectorIndexConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VectorStoresConfig) DeepCopyInto(out *VectorStoresConfig) { + *out = *in + if in.DefaultEmbeddingModel != nil { + in, out := &in.DefaultEmbeddingModel, &out.DefaultEmbeddingModel + *out = new(QualifiedModel) + (*in).DeepCopyInto(*out) + } + if in.DefaultRerankerModel != nil { + in, out := &in.DefaultRerankerModel, &out.DefaultRerankerModel + *out = new(RerankerModel) + **out = **in + } + if in.RewriteQueryParams != nil { + in, out := &in.RewriteQueryParams, &out.RewriteQueryParams + *out = new(RewriteQueryParams) + (*in).DeepCopyInto(*out) + } + if in.FileSearchParams != nil { + in, out := &in.FileSearchParams, &out.FileSearchParams + *out = new(FileSearchDisplayParams) + **out = **in + } + if in.ContextPromptParams != nil { + in, out := &in.ContextPromptParams, &out.ContextPromptParams + *out = new(ContextPromptParams) + **out = **in + } + if in.AnnotationPromptParams != nil { + in, out := &in.AnnotationPromptParams, &out.AnnotationPromptParams + *out = new(AnnotationPromptParams) + (*in).DeepCopyInto(*out) + } + if in.FileIngestionParams != nil { + in, out := &in.FileIngestionParams, &out.FileIngestionParams + *out = new(FileIngestionParams) + (*in).DeepCopyInto(*out) + } + if in.ChunkRetrievalParams != nil { + in, out := &in.ChunkRetrievalParams, &out.ChunkRetrievalParams + *out = new(ChunkRetrievalParams) + (*in).DeepCopyInto(*out) + } + if in.FileBatchParams != nil { + in, out := &in.FileBatchParams, &out.FileBatchParams + *out = new(FileBatchParams) + (*in).DeepCopyInto(*out) + } + if in.ContextualRetrievalParams != nil { + in, out := &in.ContextualRetrievalParams, &out.ContextualRetrievalParams + *out = new(ContextualRetrievalParams) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VectorStoresConfig. +func (in *VectorStoresConfig) DeepCopy() *VectorStoresConfig { + if in == nil { + return nil + } + out := new(VectorStoresConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VersionInfo) DeepCopyInto(out *VersionInfo) { + *out = *in + in.LastUpdated.DeepCopyInto(&out.LastUpdated) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VersionInfo. +func (in *VersionInfo) DeepCopy() *VersionInfo { + if in == nil { + return nil + } + out := new(VersionInfo) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VertexAIProvider) DeepCopyInto(out *VertexAIProvider) { + *out = *in + out.RoutedProviderBase = in.RoutedProviderBase + in.RemoteInferenceCommonConfig.DeepCopyInto(&out.RemoteInferenceCommonConfig) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VertexAIProvider. +func (in *VertexAIProvider) DeepCopy() *VertexAIProvider { + if in == nil { + return nil + } + out := new(VertexAIProvider) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WatsonxProvider) DeepCopyInto(out *WatsonxProvider) { + *out = *in + out.RoutedProviderBase = in.RoutedProviderBase + in.RemoteInferenceCommonConfig.DeepCopyInto(&out.RemoteInferenceCommonConfig) + out.APIKey = in.APIKey + if in.Timeout != nil { + in, out := &in.Timeout, &out.Timeout + *out = new(int) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WatsonxProvider. +func (in *WatsonxProvider) DeepCopy() *WatsonxProvider { + if in == nil { + return nil + } + out := new(WatsonxProvider) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WorkloadOverrides) DeepCopyInto(out *WorkloadOverrides) { + *out = *in + if in.Env != nil { + in, out := &in.Env, &out.Env + *out = make([]corev1.EnvVar, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Command != nil { + in, out := &in.Command, &out.Command + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Args != nil { + in, out := &in.Args, &out.Args + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Volumes != nil { + in, out := &in.Volumes, &out.Volumes + *out = make([]corev1.Volume, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.VolumeMounts != nil { + in, out := &in.VolumeMounts, &out.VolumeMounts + *out = make([]corev1.VolumeMount, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkloadOverrides. +func (in *WorkloadOverrides) DeepCopy() *WorkloadOverrides { + if in == nil { + return nil + } + out := new(WorkloadOverrides) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WorkloadSpec) DeepCopyInto(out *WorkloadSpec) { + *out = *in + if in.Replicas != nil { + in, out := &in.Replicas, &out.Replicas + *out = new(int32) + **out = **in + } + if in.Workers != nil { + in, out := &in.Workers, &out.Workers + *out = new(int32) + **out = **in + } + if in.Resources != nil { + in, out := &in.Resources, &out.Resources + *out = new(corev1.ResourceRequirements) + (*in).DeepCopyInto(*out) + } + if in.Autoscaling != nil { + in, out := &in.Autoscaling, &out.Autoscaling + *out = new(AutoscalingSpec) + (*in).DeepCopyInto(*out) + } + if in.Storage != nil { + in, out := &in.Storage, &out.Storage + *out = new(PVCStorageSpec) + (*in).DeepCopyInto(*out) + } + if in.PodDisruptionBudget != nil { + in, out := &in.PodDisruptionBudget, &out.PodDisruptionBudget + *out = new(PodDisruptionBudgetSpec) + (*in).DeepCopyInto(*out) + } + if in.TopologySpreadConstraints != nil { + in, out := &in.TopologySpreadConstraints, &out.TopologySpreadConstraints + *out = make([]corev1.TopologySpreadConstraint, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Overrides != nil { + in, out := &in.Overrides, &out.Overrides + *out = new(WorkloadOverrides) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkloadSpec. +func (in *WorkloadSpec) DeepCopy() *WorkloadSpec { + if in == nil { + return nil + } + out := new(WorkloadSpec) + in.DeepCopyInto(out) + return out +} diff --git a/cmd/configgen/main.go b/cmd/configgen/main.go new file mode 100644 index 000000000..4e09afda5 --- /dev/null +++ b/cmd/configgen/main.go @@ -0,0 +1,301 @@ +/* +Copyright 2025. + +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. +*/ + +// Package main implements a CLI tool for using the operator's config +// generation pipeline outside of Kubernetes. Given an OGXServer CR, it: +// +// 1. Optionally validates the CR (webhook, schema, and CEL validation). +// 2. Resolves a base config from -base, or via OCI labels on distribution.image. +// 3. Expands providers/resources/storage from the spec. +// 4. Outputs the metadata, env var mappings, and config.yaml that the OGX server would receive at runtime. +// +// Usage: +// +// configgen [-base ] [-crd-path ] [-distributions-path ] [-output-config] [-validate] +// +// Notes: +// - spec.baseConfig cannot be dereferenced by the CLI; pass that file with -base. +// - spec.distribution.name-only CRs also need -base, since image resolution happens in the operator. +// - -validate uses distributions.json to validate spec.distribution.name. +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "log" + "os" + "sort" + "strings" + + ogxiov1beta1 "github.com/ogx-ai/ogx-k8s-operator/api/v1beta1" + "github.com/ogx-ai/ogx-k8s-operator/pkg/config" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/serializer" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" +) + +func main() { + opts := parseFlags() + + generated, err := run(opts) + if err != nil { + log.Fatal(err) + } + + if opts.outputConfig { + fmt.Print(generated.ConfigYAML) + } else { + printFullOutput(generated) + } +} + +func run(opts options) (*config.GeneratedConfig, error) { + server, err := loadCR(opts.crPath) + if err != nil { + return nil, fmt.Errorf("failed to load CR: %w", err) + } + + if server.HasOverrideConfig() { + return nil, errors.New("failed to generate config: CR has overrideConfig set; the operator would skip config generation and use the override ConfigMap directly") + } + if !server.HasDeclarativeConfig() { + return nil, errors.New("failed to generate config: CR has no declarative config fields (providers, resources, storage, or disabledAPIs); nothing to generate") + } + + if opts.validate { + if validateErr := validateCR(server, opts.crdPath, opts.distributionsPath); validateErr != nil { + return nil, fmt.Errorf("failed to validate CR:\n%w", validateErr) + } + } + + baseConfigData, err := resolveBaseConfig(opts.basePath, &server.Spec) + if err != nil { + return nil, fmt.Errorf("failed to resolve base config: %w", err) + } + + if err := config.ValidateSecretRefEnvVarNames(&server.Spec); err != nil { + return nil, fmt.Errorf("failed to validate secret ref env var names: %w", err) + } + + return config.GenerateConfig(&server.Spec, baseConfigData) +} + +type options struct { + crPath string + basePath string + crdPath string + distributionsPath string + outputConfig bool + validate bool +} + +func parseFlags() options { + basePath := flag.String("base", "", "path to base config.yaml (omit to resolve via OCI labels on spec.distribution.image)") + crdPath := flag.String("crd-path", "config/crd/bases", "path to CRD bases directory (used with -validate)") + distributionsPath := flag.String("distributions-path", "distributions.json", "path to distributions.json for validating spec.distribution.name") + outputConfig := flag.Bool("output-config", false, "print only the generated config YAML (no metadata or env vars)") + validate := flag.Bool("validate", false, "validate the CR against the CRD schema, CEL rules, and webhook logic (requires KUBEBUILDER_ASSETS)") + flag.Parse() + + crPath := flag.Arg(0) + if crPath == "" { + log.Fatal("usage: configgen [-base ] [-crd-path ] [-distributions-path ] [-output-config] [-validate]") + } + + return options{ + crPath: crPath, + basePath: *basePath, + crdPath: *crdPath, + distributionsPath: *distributionsPath, + outputConfig: *outputConfig, + validate: *validate, + } +} + +func loadCR(path string) (*ogxiov1beta1.OGXServer, error) { + data, err := readFile(path) + if err != nil { + return nil, err + } + + s := runtime.NewScheme() + utilruntime.Must(ogxiov1beta1.AddToScheme(s)) + codecs := serializer.NewCodecFactory(s) + decoder := codecs.UniversalDeserializer() + + obj, _, err := decoder.Decode(data, nil, nil) + if err != nil { + return nil, fmt.Errorf("failed to decode CR: %w", err) + } + + server, ok := obj.(*ogxiov1beta1.OGXServer) + if !ok { + return nil, fmt.Errorf("failed to cast decoded object: expected OGXServer, got %T", obj) + } + + return server, nil +} + +func validateCR(server *ogxiov1beta1.OGXServer, crdPath, distributionsPath string) error { + assets := os.Getenv("KUBEBUILDER_ASSETS") + if assets == "" { + return errors.New("failed to find KUBEBUILDER_ASSETS: not set; run: make envtest && export KUBEBUILDER_ASSETS=$(bin/setup-envtest use 1.31.0 --bin-dir bin -p path)") + } + + if info, err := os.Stat(crdPath); err != nil || !info.IsDir() { + return fmt.Errorf("failed to find CRD path %q: does not exist or is not a directory; use -crd-path to specify", crdPath) + } + + var errs []string + + // CRD schema + CEL validation via envtest + if err := validateWithEnvTest(server, crdPath, assets); err != nil { + errs = append(errs, fmt.Sprintf("CRD/CEL: %s", err)) + } + + // Webhook validation (in-process, no server needed) + knownDistNames, err := loadKnownDistributionNames(server, distributionsPath) + if err != nil { + errs = append(errs, fmt.Sprintf("webhook: %s", err)) + } + validator := &ogxiov1beta1.OGXServerValidator{ + KnownDistributionNames: knownDistNames, + } + if _, err := validator.ValidateCreate(context.Background(), server); err != nil { + errs = append(errs, fmt.Sprintf("webhook: %s", err)) + } + + if len(errs) > 0 { + return fmt.Errorf("failed to validate CR:\n%s", strings.Join(errs, "\n")) + } + return nil +} + +func validateWithEnvTest(server *ogxiov1beta1.OGXServer, crdPath, assets string) error { + testEnv := &envtest.Environment{ + CRDDirectoryPaths: []string{crdPath}, + ErrorIfCRDPathMissing: true, + BinaryAssetsDirectory: assets, + } + + cfg, err := testEnv.Start() + if err != nil { + return fmt.Errorf("failed to start envtest: %w", err) + } + defer func() { _ = testEnv.Stop() }() + + utilruntime.Must(ogxiov1beta1.AddToScheme(scheme.Scheme)) + + k8sClient, err := client.New(cfg, client.Options{Scheme: scheme.Scheme}) + if err != nil { + return fmt.Errorf("failed to create envtest client: %w", err) + } + + ctx := context.Background() + + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: "configgen-validate"}, + } + if err := k8sClient.Create(ctx, ns); err != nil { + return fmt.Errorf("failed to create namespace: %w", err) + } + + cr := server.DeepCopy() + cr.Namespace = ns.Name + if cr.Name == "" { + cr.Name = "configgen-validate" + } + + if err := k8sClient.Create(ctx, cr, client.DryRunAll); err != nil { + return err + } + + return nil +} + +func printFullOutput(g *config.GeneratedConfig) { + fmt.Println("--- config.yaml ---") + fmt.Print(g.ConfigYAML) + + fmt.Printf("\n--- metadata ---\n") + fmt.Printf("content-hash: %s\n", g.ContentHash) + fmt.Printf("config-version: %d\n", g.ConfigVersion) + fmt.Printf("config-version-defaulted: %t\n", g.ConfigVersionDefaulted) + fmt.Printf("providers: %d\n", g.ProviderCount) + fmt.Printf("resources: %d\n", g.ResourceCount) + + if len(g.EnvVars) > 0 { + fmt.Println("\n--- env vars ---") + for _, e := range g.EnvVars { + if e.ValueFrom != nil && e.ValueFrom.SecretKeyRef != nil { + fmt.Printf("%s -> %s/%s\n", e.Name, e.ValueFrom.SecretKeyRef.Name, e.ValueFrom.SecretKeyRef.Key) + } + } + } +} + +func loadKnownDistributionNames(server *ogxiov1beta1.OGXServer, distributionsPath string) ([]string, error) { + if server.Spec.Distribution.Name == "" { + return nil, nil + } + + data, err := readFile(distributionsPath) + if err != nil { + return nil, fmt.Errorf("failed to read distributions file %q: %w", distributionsPath, err) + } + + var distributionImages map[string]string + if err := json.Unmarshal(data, &distributionImages); err != nil { + return nil, fmt.Errorf("failed to parse distributions file %q: %w", distributionsPath, err) + } + + names := make([]string, 0, len(distributionImages)) + for name := range distributionImages { + names = append(names, name) + } + sort.Strings(names) + return names, nil +} + +func resolveBaseConfig(basePath string, spec *ogxiov1beta1.OGXServerSpec) ([]byte, error) { + if basePath != "" { + return readFile(basePath) + } + if spec.BaseConfig != nil { + return nil, errors.New("configgen cannot read spec.baseConfig from Kubernetes; pass -base with the ConfigMap's config.yaml contents") + } + if spec.Distribution.Image == "" { + return nil, errors.New("configgen requires spec.distribution.image or -base because named distributions are resolved by the operator") + } + resolver := config.NewDefaultConfigResolver(config.NewOCILabelFetcher()) + return resolver.Resolve(spec.Distribution.Image, spec.Distribution.Name) +} + +func readFile(path string) ([]byte, error) { + if path == "-" { + return io.ReadAll(os.Stdin) + } + return os.ReadFile(path) //nolint:gosec // CLI tool intentionally reads user-provided file paths +} diff --git a/cmd/configgen/main_test.go b/cmd/configgen/main_test.go new file mode 100644 index 000000000..faea3d466 --- /dev/null +++ b/cmd/configgen/main_test.go @@ -0,0 +1,159 @@ +/* +Copyright 2025. + +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. +*/ + +package main + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + ogxiov1beta1 "github.com/ogx-ai/ogx-k8s-operator/api/v1beta1" + "github.com/ogx-ai/ogx-k8s-operator/pkg/config" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +// TestRun discovers test cases from testdata/ subdirectories. Each subdirectory +// must contain a cr.yaml. Optional files: +// - base.yaml: explicit base config (omit to use standard config resolution) +// - want-err: if present, expect error containing this text +// - want-config.yaml: if present, assert generated config matches structurally +// +// Validation uses testdata/distributions.json by default. A testcase-local +// distributions.json can override it when a fixture needs a custom registry. +// +// Set KUBEBUILDER_ASSETS to enable CRD/CEL and webhook validation for each case. +func TestRun(t *testing.T) { + entries, err := os.ReadDir("testdata") + if err != nil { + t.Fatalf("failed to read testdata: %v", err) + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + name := entry.Name() + t.Run(name, func(t *testing.T) { + tc := loadTestCase(t, filepath.Join("testdata", name)) + generated, runErr := run(tc.opts) + tc.assert(t, generated, runErr) + }) + } +} + +type testCase struct { + opts options + wantErr string + wantConfig string +} + +func loadTestCase(t *testing.T, dir string) testCase { + t.Helper() + + crPath := filepath.Join(dir, "cr.yaml") + if !fileExists(crPath) { + t.Fatalf("missing cr.yaml in %s", dir) + } + + var basePath string + if p := filepath.Join(dir, "base.yaml"); fileExists(p) { + basePath = p + } + distributionsPath := filepath.Join("testdata", "distributions.json") + if p := filepath.Join(dir, "distributions.json"); fileExists(p) { + distributionsPath = p + } + + return testCase{ + opts: options{ + crPath: crPath, + basePath: basePath, + crdPath: "../../config/crd/bases", + distributionsPath: distributionsPath, + validate: os.Getenv("KUBEBUILDER_ASSETS") != "", + }, + wantErr: strings.TrimSpace(readOptionalFile(t, filepath.Join(dir, "want-err"))), + wantConfig: readOptionalFile(t, filepath.Join(dir, "want-config.yaml")), + } +} + +func TestLoadKnownDistributionNames(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "distributions.json") + require.NoError(t, os.WriteFile(path, []byte(`{"starter":"img-a","postgres-demo":"img-b"}`), 0o600)) + + server := &ogxiov1beta1.OGXServer{ + Spec: ogxiov1beta1.OGXServerSpec{ + Distribution: ogxiov1beta1.DistributionSpec{Name: "starter"}, + }, + } + + names, err := loadKnownDistributionNames(server, path) + require.NoError(t, err) + require.Equal(t, []string{"postgres-demo", "starter"}, names) +} + +func (tc testCase) assert(t *testing.T, generated *config.GeneratedConfig, runErr error) { + t.Helper() + + if tc.wantErr != "" { + if runErr == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(runErr.Error(), tc.wantErr) { + t.Fatalf("error %q does not contain %q", runErr, tc.wantErr) + } + return + } + if runErr != nil { + t.Fatalf("unexpected error: %v", runErr) + } + if tc.wantConfig != "" { + assertYAMLEqual(t, tc.wantConfig, generated.ConfigYAML) + } +} + +func assertYAMLEqual(t *testing.T, want, got string) { + t.Helper() + var wantObj, gotObj any + if err := yaml.Unmarshal([]byte(want), &wantObj); err != nil { + t.Fatalf("failed to parse want-config.yaml: %v", err) + } + if err := yaml.Unmarshal([]byte(got), &gotObj); err != nil { + t.Fatalf("failed to parse generated config: %v", err) + } + if !reflect.DeepEqual(wantObj, gotObj) { + t.Errorf("config mismatch:\n--- want\n%s\n--- got\n%s", want, got) + } +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func readOptionalFile(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + return "" + } + return string(data) +} diff --git a/cmd/configgen/testdata/distributions.json b/cmd/configgen/testdata/distributions.json new file mode 100644 index 000000000..924e8f493 --- /dev/null +++ b/cmd/configgen/testdata/distributions.json @@ -0,0 +1,5 @@ +{ + "rh": "docker.io/ogxai/distribution-rh:latest", + "starter": "docker.io/ogxai/distribution-starter:latest", + "test": "docker.io/ogxai/distribution-test:latest" +} diff --git a/cmd/configgen/testdata/err-bad-base-path/base.yaml b/cmd/configgen/testdata/err-bad-base-path/base.yaml new file mode 100644 index 000000000..e5ce7a686 --- /dev/null +++ b/cmd/configgen/testdata/err-bad-base-path/base.yaml @@ -0,0 +1 @@ +image_name: test diff --git a/cmd/configgen/testdata/err-bad-base-path/cr.yaml b/cmd/configgen/testdata/err-bad-base-path/cr.yaml new file mode 100644 index 000000000..42b65a05a --- /dev/null +++ b/cmd/configgen/testdata/err-bad-base-path/cr.yaml @@ -0,0 +1,15 @@ +apiVersion: ogx.io/v1beta1 +kind: OGXServer +metadata: + name: test +spec: + distribution: + name: starter + providers: + inference: + remote: + vllm: + - endpoint: https://my-vllm:8000 + resources: + models: + - name: llama3 diff --git a/cmd/configgen/testdata/err-bad-base-path/want-err b/cmd/configgen/testdata/err-bad-base-path/want-err new file mode 100644 index 000000000..9c2f82479 --- /dev/null +++ b/cmd/configgen/testdata/err-bad-base-path/want-err @@ -0,0 +1 @@ +missing required 'version' field diff --git a/cmd/configgen/testdata/err-no-declarative/cr.yaml b/cmd/configgen/testdata/err-no-declarative/cr.yaml new file mode 100644 index 000000000..3c7ed5b2d --- /dev/null +++ b/cmd/configgen/testdata/err-no-declarative/cr.yaml @@ -0,0 +1,7 @@ +apiVersion: ogx.io/v1beta1 +kind: OGXServer +metadata: + name: test +spec: + distribution: + name: starter diff --git a/cmd/configgen/testdata/err-no-declarative/want-err b/cmd/configgen/testdata/err-no-declarative/want-err new file mode 100644 index 000000000..4aca00544 --- /dev/null +++ b/cmd/configgen/testdata/err-no-declarative/want-err @@ -0,0 +1 @@ +nothing to generate diff --git a/cmd/configgen/testdata/err-override-config/cr.yaml b/cmd/configgen/testdata/err-override-config/cr.yaml new file mode 100644 index 000000000..d29128262 --- /dev/null +++ b/cmd/configgen/testdata/err-override-config/cr.yaml @@ -0,0 +1,10 @@ +apiVersion: ogx.io/v1beta1 +kind: OGXServer +metadata: + name: test +spec: + distribution: + name: starter + overrideConfig: + name: my-config + key: config.yaml diff --git a/cmd/configgen/testdata/err-override-config/want-err b/cmd/configgen/testdata/err-override-config/want-err new file mode 100644 index 000000000..22a908c15 --- /dev/null +++ b/cmd/configgen/testdata/err-override-config/want-err @@ -0,0 +1 @@ +overrideConfig diff --git a/cmd/configgen/testdata/happy-basic/base.yaml b/cmd/configgen/testdata/happy-basic/base.yaml new file mode 100644 index 000000000..163eed6e6 --- /dev/null +++ b/cmd/configgen/testdata/happy-basic/base.yaml @@ -0,0 +1,10 @@ +version: '2' +distro_name: starter +apis: +- inference +providers: + inference: + - provider_id: remote-vllm + provider_type: remote::vllm + config: + url: http://vllm:8000 diff --git a/cmd/configgen/testdata/happy-basic/cr.yaml b/cmd/configgen/testdata/happy-basic/cr.yaml new file mode 100644 index 000000000..42b65a05a --- /dev/null +++ b/cmd/configgen/testdata/happy-basic/cr.yaml @@ -0,0 +1,15 @@ +apiVersion: ogx.io/v1beta1 +kind: OGXServer +metadata: + name: test +spec: + distribution: + name: starter + providers: + inference: + remote: + vllm: + - endpoint: https://my-vllm:8000 + resources: + models: + - name: llama3 diff --git a/cmd/configgen/testdata/happy-basic/want-config.yaml b/cmd/configgen/testdata/happy-basic/want-config.yaml new file mode 100644 index 000000000..44be4adbd --- /dev/null +++ b/cmd/configgen/testdata/happy-basic/want-config.yaml @@ -0,0 +1,17 @@ +version: "2" +distro_name: starter +apis: + - inference +providers: + inference: + - provider_id: remote-vllm + provider_type: remote::vllm + config: + base_url: https://my-vllm:8000 +registered_resources: + models: + - model_id: llama3 + model_type: llm + provider_id: remote-vllm +server: + port: 8321 diff --git a/cmd/configgen/testdata/p0-inference-base-url/base.yaml b/cmd/configgen/testdata/p0-inference-base-url/base.yaml new file mode 100644 index 000000000..36af9e1b2 --- /dev/null +++ b/cmd/configgen/testdata/p0-inference-base-url/base.yaml @@ -0,0 +1,4 @@ +version: '2' +distro_name: test +apis: +- inference diff --git a/cmd/configgen/testdata/p0-inference-base-url/cr.yaml b/cmd/configgen/testdata/p0-inference-base-url/cr.yaml new file mode 100644 index 000000000..0a2030e98 --- /dev/null +++ b/cmd/configgen/testdata/p0-inference-base-url/cr.yaml @@ -0,0 +1,32 @@ +apiVersion: ogx.io/v1beta1 +kind: OGXServer +metadata: + name: test-p0-inference-base-url +spec: + distribution: + name: test + providers: + inference: + remote: + vllm: + - endpoint: https://vllm.example.com:8000 + openai: + - endpoint: https://api.openai.com/v1 + apiKey: + name: openai-secret + key: api-key + azure: + - endpoint: https://my-resource.openai.azure.com/openai/v1 + apiKey: + name: azure-secret + key: api-key + watsonx: + - endpoint: https://us-south.ml.cloud.ibm.com + apiKey: + name: watsonx-secret + key: api-key + bedrock: + - region: us-west-2 + apiKey: + name: bedrock-secret + key: api-key diff --git a/cmd/configgen/testdata/p0-inference-base-url/want-config.yaml b/cmd/configgen/testdata/p0-inference-base-url/want-config.yaml new file mode 100644 index 000000000..aa119b882 --- /dev/null +++ b/cmd/configgen/testdata/p0-inference-base-url/want-config.yaml @@ -0,0 +1,33 @@ +version: "2" +distro_name: test +apis: + - inference +providers: + inference: + - provider_id: remote-vllm + provider_type: remote::vllm + config: + base_url: https://vllm.example.com:8000 + - provider_id: remote-openai + provider_type: remote::openai + config: + api_key: ${env.OGX_REMOTE_OPENAI_API_KEY} + base_url: https://api.openai.com/v1 + - provider_id: remote-azure + provider_type: remote::azure + config: + base_url: https://my-resource.openai.azure.com/openai/v1 + api_key: ${env.OGX_REMOTE_AZURE_API_KEY} + - provider_id: remote-bedrock + provider_type: remote::bedrock + config: + region_name: us-west-2 + api_key: ${env.OGX_REMOTE_BEDROCK_API_KEY} + - provider_id: remote-watsonx + provider_type: remote::watsonx + config: + api_key: ${env.OGX_REMOTE_WATSONX_API_KEY} + base_url: https://us-south.ml.cloud.ibm.com +registered_resources: {} +server: + port: 8321 diff --git a/cmd/configgen/testdata/p0-network-nesting/base.yaml b/cmd/configgen/testdata/p0-network-nesting/base.yaml new file mode 100644 index 000000000..36af9e1b2 --- /dev/null +++ b/cmd/configgen/testdata/p0-network-nesting/base.yaml @@ -0,0 +1,4 @@ +version: '2' +distro_name: test +apis: +- inference diff --git a/cmd/configgen/testdata/p0-network-nesting/cr.yaml b/cmd/configgen/testdata/p0-network-nesting/cr.yaml new file mode 100644 index 000000000..4f26a8ece --- /dev/null +++ b/cmd/configgen/testdata/p0-network-nesting/cr.yaml @@ -0,0 +1,28 @@ +apiVersion: ogx.io/v1beta1 +kind: OGXServer +metadata: + name: test-p0-network-nesting +spec: + distribution: + name: test + providers: + inference: + remote: + vllm: + - endpoint: https://vllm.example.com:8000 + network: + tls: + verify: false + minVersion: TLSv1.3 + ciphers: + - ECDHE+AESGCM + proxy: + url: http://proxy.example.com:3128 + noProxy: + - localhost + - .internal + timeout: + connect: 30 + read: 120 + headers: + X-Custom: my-value diff --git a/cmd/configgen/testdata/p0-network-nesting/want-config.yaml b/cmd/configgen/testdata/p0-network-nesting/want-config.yaml new file mode 100644 index 000000000..c5e68d85e --- /dev/null +++ b/cmd/configgen/testdata/p0-network-nesting/want-config.yaml @@ -0,0 +1,29 @@ +version: "2" +distro_name: test +apis: + - inference +providers: + inference: + - provider_id: remote-vllm + provider_type: remote::vllm + config: + base_url: https://vllm.example.com:8000 + network: + tls: + verify: false + min_version: TLSv1.3 + ciphers: + - ECDHE+AESGCM + proxy: + url: http://proxy.example.com:3128 + no_proxy: + - localhost + - .internal + timeout: + connect: 30 + read: 120 + headers: + X-Custom: my-value +registered_resources: {} +server: + port: 8321 diff --git a/cmd/configgen/testdata/p1-batches-config/base.yaml b/cmd/configgen/testdata/p1-batches-config/base.yaml new file mode 100644 index 000000000..fa63b0b3d --- /dev/null +++ b/cmd/configgen/testdata/p1-batches-config/base.yaml @@ -0,0 +1,4 @@ +version: '2' +distro_name: test +apis: +- batches diff --git a/cmd/configgen/testdata/p1-batches-config/cr.yaml b/cmd/configgen/testdata/p1-batches-config/cr.yaml new file mode 100644 index 000000000..e56b15ceb --- /dev/null +++ b/cmd/configgen/testdata/p1-batches-config/cr.yaml @@ -0,0 +1,13 @@ +apiVersion: ogx.io/v1beta1 +kind: OGXServer +metadata: + name: test-p1-batches-config +spec: + distribution: + name: test + providers: + batches: + inline: + reference: + maxConcurrentBatches: 5 + maxConcurrentRequestsPerBatch: 10 diff --git a/cmd/configgen/testdata/p1-batches-config/want-config.yaml b/cmd/configgen/testdata/p1-batches-config/want-config.yaml new file mode 100644 index 000000000..2f6e20506 --- /dev/null +++ b/cmd/configgen/testdata/p1-batches-config/want-config.yaml @@ -0,0 +1,14 @@ +version: "2" +distro_name: test +apis: + - batches +providers: + batches: + - provider_id: inline-reference + provider_type: inline::batch-reference + config: + max_concurrent_batches: 5 + max_concurrent_requests_per_batch: 10 +registered_resources: {} +server: + port: 8321 diff --git a/cmd/configgen/testdata/p1-fileprocessors-config/base.yaml b/cmd/configgen/testdata/p1-fileprocessors-config/base.yaml new file mode 100644 index 000000000..1e68120d2 --- /dev/null +++ b/cmd/configgen/testdata/p1-fileprocessors-config/base.yaml @@ -0,0 +1,4 @@ +version: '2' +distro_name: test +apis: +- file_processors diff --git a/cmd/configgen/testdata/p1-fileprocessors-config/cr.yaml b/cmd/configgen/testdata/p1-fileprocessors-config/cr.yaml new file mode 100644 index 000000000..faf150b84 --- /dev/null +++ b/cmd/configgen/testdata/p1-fileprocessors-config/cr.yaml @@ -0,0 +1,34 @@ +apiVersion: ogx.io/v1beta1 +kind: OGXServer +metadata: + name: test-p1-fileprocessors-config +spec: + distribution: + name: test + providers: + fileProcessors: + remote: + doclingServe: + baseUrl: "http://docling.example.com:5001/v1" + apiKey: + name: docling-secret + key: api-key + defaultChunkSizeTokens: 2048 + inline: + auto: + defaultChunkSizeTokens: 1000 + defaultChunkOverlapTokens: 500 + extractMetadata: true + cleanText: false + pypdf: + defaultChunkSizeTokens: 600 + defaultChunkOverlapTokens: 200 + extractMetadata: false + cleanText: true + markitdown: + defaultChunkSizeTokens: 900 + defaultChunkOverlapTokens: 300 + docling: + defaultChunkSizeTokens: 1200 + defaultChunkOverlapTokens: 100 + doOcr: false diff --git a/cmd/configgen/testdata/p1-fileprocessors-config/want-config.yaml b/cmd/configgen/testdata/p1-fileprocessors-config/want-config.yaml new file mode 100644 index 000000000..f8724436a --- /dev/null +++ b/cmd/configgen/testdata/p1-fileprocessors-config/want-config.yaml @@ -0,0 +1,40 @@ +version: "2" +distro_name: test +apis: + - file_processors +providers: + file_processors: + - provider_id: remote-docling-serve + provider_type: "remote::docling-serve" + config: + api_key: ${env.OGX_REMOTE_DOCLING_SERVE_API_KEY} + base_url: "http://docling.example.com:5001/v1" + default_chunk_size_tokens: 2048 + - provider_id: inline-auto + provider_type: "inline::auto" + config: + clean_text: false + default_chunk_overlap_tokens: 500 + default_chunk_size_tokens: 1000 + extract_metadata: true + - provider_id: inline-pypdf + provider_type: "inline::pypdf" + config: + clean_text: true + default_chunk_overlap_tokens: 200 + default_chunk_size_tokens: 600 + extract_metadata: false + - provider_id: inline-markitdown + provider_type: "inline::markitdown" + config: + default_chunk_overlap_tokens: 300 + default_chunk_size_tokens: 900 + - provider_id: inline-docling + provider_type: "inline::docling" + config: + default_chunk_overlap_tokens: 100 + default_chunk_size_tokens: 1200 + do_ocr: false +registered_resources: {} +server: + port: 8321 diff --git a/cmd/configgen/testdata/p1-filesearch-config/base.yaml b/cmd/configgen/testdata/p1-filesearch-config/base.yaml new file mode 100644 index 000000000..d1d6d7dd5 --- /dev/null +++ b/cmd/configgen/testdata/p1-filesearch-config/base.yaml @@ -0,0 +1,4 @@ +version: '2' +distro_name: test +apis: +- tool_runtime diff --git a/cmd/configgen/testdata/p1-filesearch-config/cr.yaml b/cmd/configgen/testdata/p1-filesearch-config/cr.yaml new file mode 100644 index 000000000..bd8315aaf --- /dev/null +++ b/cmd/configgen/testdata/p1-filesearch-config/cr.yaml @@ -0,0 +1,17 @@ +apiVersion: ogx.io/v1beta1 +kind: OGXServer +metadata: + name: test-p1-filesearch-config +spec: + distribution: + name: test + providers: + toolRuntime: + inline: + fileSearch: + - vectorStoresConfig: + defaultProviderId: pgvector + defaultEmbeddingModel: + providerId: vllm + modelId: granite-embedding + embeddingDimensions: 384 diff --git a/cmd/configgen/testdata/p1-filesearch-config/want-config.yaml b/cmd/configgen/testdata/p1-filesearch-config/want-config.yaml new file mode 100644 index 000000000..3890aff4c --- /dev/null +++ b/cmd/configgen/testdata/p1-filesearch-config/want-config.yaml @@ -0,0 +1,18 @@ +version: "2" +distro_name: test +apis: + - tool_runtime +providers: + tool_runtime: + - provider_id: inline-file-search + provider_type: inline::rag-runtime + config: + vector_stores_config: + default_provider_id: pgvector + default_embedding_model: + provider_id: vllm + model_id: granite-embedding + embedding_dimensions: 384 +registered_resources: {} +server: + port: 8321 diff --git a/cmd/configgen/testdata/p1-inference-extra-fields/base.yaml b/cmd/configgen/testdata/p1-inference-extra-fields/base.yaml new file mode 100644 index 000000000..36af9e1b2 --- /dev/null +++ b/cmd/configgen/testdata/p1-inference-extra-fields/base.yaml @@ -0,0 +1,4 @@ +version: '2' +distro_name: test +apis: +- inference diff --git a/cmd/configgen/testdata/p1-inference-extra-fields/cr.yaml b/cmd/configgen/testdata/p1-inference-extra-fields/cr.yaml new file mode 100644 index 000000000..b6c633c27 --- /dev/null +++ b/cmd/configgen/testdata/p1-inference-extra-fields/cr.yaml @@ -0,0 +1,16 @@ +apiVersion: ogx.io/v1beta1 +kind: OGXServer +metadata: + name: test-p1-inference-extra-fields +spec: + distribution: + name: test + providers: + inference: + remote: + vllm: + - endpoint: https://vllm.example.com:8000 + refreshModels: true + allowedModels: + - model-a + - model-b diff --git a/cmd/configgen/testdata/p1-inference-extra-fields/want-config.yaml b/cmd/configgen/testdata/p1-inference-extra-fields/want-config.yaml new file mode 100644 index 000000000..512f3b5eb --- /dev/null +++ b/cmd/configgen/testdata/p1-inference-extra-fields/want-config.yaml @@ -0,0 +1,17 @@ +version: "2" +distro_name: test +apis: + - inference +providers: + inference: + - provider_id: remote-vllm + provider_type: remote::vllm + config: + base_url: https://vllm.example.com:8000 + refresh_models: true + allowed_models: + - model-a + - model-b +registered_resources: {} +server: + port: 8321 diff --git a/cmd/configgen/testdata/p1-milvus-config/base.yaml b/cmd/configgen/testdata/p1-milvus-config/base.yaml new file mode 100644 index 000000000..faa92502d --- /dev/null +++ b/cmd/configgen/testdata/p1-milvus-config/base.yaml @@ -0,0 +1,4 @@ +version: '2' +distro_name: test +apis: +- vector_io diff --git a/cmd/configgen/testdata/p1-milvus-config/cr.yaml b/cmd/configgen/testdata/p1-milvus-config/cr.yaml new file mode 100644 index 000000000..4a4593a4d --- /dev/null +++ b/cmd/configgen/testdata/p1-milvus-config/cr.yaml @@ -0,0 +1,16 @@ +apiVersion: ogx.io/v1beta1 +kind: OGXServer +metadata: + name: test-p1-milvus-config +spec: + distribution: + name: test + providers: + vectorIo: + remote: + milvus: + - uri: https://milvus.example.com:19530 + token: + name: milvus-secret + key: token + consistencyLevel: Strong diff --git a/cmd/configgen/testdata/p1-milvus-config/want-config.yaml b/cmd/configgen/testdata/p1-milvus-config/want-config.yaml new file mode 100644 index 000000000..825497315 --- /dev/null +++ b/cmd/configgen/testdata/p1-milvus-config/want-config.yaml @@ -0,0 +1,15 @@ +version: "2" +distro_name: test +apis: + - vector_io +providers: + vector_io: + - provider_id: remote-milvus + provider_type: remote::milvus + config: + uri: https://milvus.example.com:19530 + token: ${env.OGX_REMOTE_MILVUS_TOKEN} + consistency_level: Strong +registered_resources: {} +server: + port: 8321 diff --git a/cmd/configgen/testdata/p1-model-quantization/base.yaml b/cmd/configgen/testdata/p1-model-quantization/base.yaml new file mode 100644 index 000000000..36af9e1b2 --- /dev/null +++ b/cmd/configgen/testdata/p1-model-quantization/base.yaml @@ -0,0 +1,4 @@ +version: '2' +distro_name: test +apis: +- inference diff --git a/cmd/configgen/testdata/p1-model-quantization/cr.yaml b/cmd/configgen/testdata/p1-model-quantization/cr.yaml new file mode 100644 index 000000000..cbcafb21a --- /dev/null +++ b/cmd/configgen/testdata/p1-model-quantization/cr.yaml @@ -0,0 +1,16 @@ +apiVersion: ogx.io/v1beta1 +kind: OGXServer +metadata: + name: test-p1-model-quantization +spec: + distribution: + name: test + providers: + inference: + remote: + vllm: + - endpoint: https://vllm.example.com:8000 + resources: + models: + - name: llama3-quantized + quantization: fp16 diff --git a/cmd/configgen/testdata/p1-model-quantization/want-config.yaml b/cmd/configgen/testdata/p1-model-quantization/want-config.yaml new file mode 100644 index 000000000..1a2bfe7f2 --- /dev/null +++ b/cmd/configgen/testdata/p1-model-quantization/want-config.yaml @@ -0,0 +1,18 @@ +version: "2" +distro_name: test +apis: + - inference +providers: + inference: + - provider_id: remote-vllm + provider_type: remote::vllm + config: + base_url: https://vllm.example.com:8000 +registered_resources: + models: + - model_id: llama3-quantized + model_type: llm + provider_id: remote-vllm + quantization: fp16 +server: + port: 8321 diff --git a/cmd/configgen/testdata/p1-pgvector-config/base.yaml b/cmd/configgen/testdata/p1-pgvector-config/base.yaml new file mode 100644 index 000000000..faa92502d --- /dev/null +++ b/cmd/configgen/testdata/p1-pgvector-config/base.yaml @@ -0,0 +1,4 @@ +version: '2' +distro_name: test +apis: +- vector_io diff --git a/cmd/configgen/testdata/p1-pgvector-config/cr.yaml b/cmd/configgen/testdata/p1-pgvector-config/cr.yaml new file mode 100644 index 000000000..98f3cd67b --- /dev/null +++ b/cmd/configgen/testdata/p1-pgvector-config/cr.yaml @@ -0,0 +1,24 @@ +apiVersion: ogx.io/v1beta1 +kind: OGXServer +metadata: + name: test-p1-pgvector-config +spec: + distribution: + name: test + providers: + vectorIo: + remote: + pgvector: + - host: pgvector.example.com + port: 5432 + db: vectors + user: pguser + password: + name: pgvector-secret + key: password + distanceMetric: COSINE + vectorIndex: + hnsw: + m: 16 + efConstruction: 200 + efSearch: 100 diff --git a/cmd/configgen/testdata/p1-pgvector-config/want-config.yaml b/cmd/configgen/testdata/p1-pgvector-config/want-config.yaml new file mode 100644 index 000000000..077da82f5 --- /dev/null +++ b/cmd/configgen/testdata/p1-pgvector-config/want-config.yaml @@ -0,0 +1,23 @@ +version: "2" +distro_name: test +apis: + - vector_io +providers: + vector_io: + - provider_id: remote-pgvector + provider_type: remote::pgvector + config: + host: pgvector.example.com + port: 5432 + db: vectors + user: pguser + password: ${env.OGX_REMOTE_PGVECTOR_PASSWORD} + distance_metric: COSINE + vector_index: + hnsw: + m: 16 + ef_construction: 200 + ef_search: 100 +registered_resources: {} +server: + port: 8321 diff --git a/cmd/configgen/testdata/p1-qdrant-config/base.yaml b/cmd/configgen/testdata/p1-qdrant-config/base.yaml new file mode 100644 index 000000000..faa92502d --- /dev/null +++ b/cmd/configgen/testdata/p1-qdrant-config/base.yaml @@ -0,0 +1,4 @@ +version: '2' +distro_name: test +apis: +- vector_io diff --git a/cmd/configgen/testdata/p1-qdrant-config/cr.yaml b/cmd/configgen/testdata/p1-qdrant-config/cr.yaml new file mode 100644 index 000000000..e46034fe3 --- /dev/null +++ b/cmd/configgen/testdata/p1-qdrant-config/cr.yaml @@ -0,0 +1,23 @@ +apiVersion: ogx.io/v1beta1 +kind: OGXServer +metadata: + name: test-p1-qdrant-config +spec: + distribution: + name: test + providers: + vectorIo: + remote: + qdrant: + - url: https://qdrant.example.com + host: qdrant-host + port: 6333 + apiKey: + name: qdrant-secret + key: api-key + location: us-east-1 + grpcPort: 6334 + preferGrpc: true + https: true + prefix: my-prefix + timeout: 60 diff --git a/cmd/configgen/testdata/p1-qdrant-config/want-config.yaml b/cmd/configgen/testdata/p1-qdrant-config/want-config.yaml new file mode 100644 index 000000000..885e9abd2 --- /dev/null +++ b/cmd/configgen/testdata/p1-qdrant-config/want-config.yaml @@ -0,0 +1,22 @@ +version: "2" +distro_name: test +apis: + - vector_io +providers: + vector_io: + - provider_id: remote-qdrant + provider_type: remote::qdrant + config: + url: https://qdrant.example.com + host: qdrant-host + port: 6333 + api_key: ${env.OGX_REMOTE_QDRANT_API_KEY} + location: us-east-1 + grpc_port: 6334 + prefer_grpc: true + https: true + prefix: my-prefix + timeout: 60 +registered_resources: {} +server: + port: 8321 diff --git a/cmd/configgen/testdata/p1-responses-config/base.yaml b/cmd/configgen/testdata/p1-responses-config/base.yaml new file mode 100644 index 000000000..e5be3bb82 --- /dev/null +++ b/cmd/configgen/testdata/p1-responses-config/base.yaml @@ -0,0 +1,4 @@ +version: '2' +distro_name: test +apis: +- responses diff --git a/cmd/configgen/testdata/p1-responses-config/cr.yaml b/cmd/configgen/testdata/p1-responses-config/cr.yaml new file mode 100644 index 000000000..e8a53e377 --- /dev/null +++ b/cmd/configgen/testdata/p1-responses-config/cr.yaml @@ -0,0 +1,20 @@ +apiVersion: ogx.io/v1beta1 +kind: OGXServer +metadata: + name: test-p1-responses-config +spec: + distribution: + name: test + providers: + responses: + inline: + builtin: + vectorStoresConfig: + defaultProviderId: milvus + defaultEmbeddingModel: + providerId: vllm + modelId: granite-embedding + embeddingDimensions: 768 + compactionConfig: + summarizationModel: llama3 + defaultCompactThreshold: 10 diff --git a/cmd/configgen/testdata/p1-responses-config/want-config.yaml b/cmd/configgen/testdata/p1-responses-config/want-config.yaml new file mode 100644 index 000000000..a8343028f --- /dev/null +++ b/cmd/configgen/testdata/p1-responses-config/want-config.yaml @@ -0,0 +1,21 @@ +version: "2" +distro_name: test +apis: + - responses +providers: + responses: + - provider_id: inline-builtin + provider_type: inline::responses + config: + vector_stores_config: + default_provider_id: milvus + default_embedding_model: + provider_id: vllm + model_id: granite-embedding + embedding_dimensions: 768 + compaction_config: + summarization_model: llama3 + default_compact_threshold: 10 +registered_resources: {} +server: + port: 8321 diff --git a/cmd/configgen/testdata/rh-add-models/base.yaml b/cmd/configgen/testdata/rh-add-models/base.yaml new file mode 100644 index 000000000..911e4eb77 --- /dev/null +++ b/cmd/configgen/testdata/rh-add-models/base.yaml @@ -0,0 +1,236 @@ +version: 2 +image_name: rh +apis: +- responses +- batches +- inference +- tool_runtime +- vector_io +- files +- file_processors +providers: + inference: + - provider_id: ${env.VLLM_URL:+vllm-inference} + provider_type: remote::vllm + config: + base_url: ${env.VLLM_URL:=} + max_tokens: ${env.VLLM_MAX_TOKENS:=4096} + api_token: ${env.VLLM_API_TOKEN:=fake} + tls_verify: ${env.VLLM_TLS_VERIFY:=true} + refresh_models: ${env.VLLM_REFRESH_MODELS:=false} + - provider_id: ${env.VLLM_EMBEDDING_URL:+vllm-embedding} + provider_type: remote::vllm + config: + base_url: ${env.VLLM_EMBEDDING_URL:=} + max_tokens: ${env.VLLM_EMBEDDING_MAX_TOKENS:=4096} + api_token: ${env.VLLM_EMBEDDING_API_TOKEN:=fake} + tls_verify: ${env.VLLM_EMBEDDING_TLS_VERIFY:=true} + refresh_models: ${env.VLLM_EMBEDDING_REFRESH_MODELS:=false} + - provider_id: ${env.AWS_BEARER_TOKEN_BEDROCK:+bedrock} + provider_type: remote::bedrock + config: + api_key: ${env.AWS_BEARER_TOKEN_BEDROCK:=} + region_name: ${env.AWS_DEFAULT_REGION:=us-east-2} + - provider_id: ${env.WATSONX_API_KEY:+watsonx} + provider_type: remote::watsonx + config: + base_url: ${env.WATSONX_BASE_URL:=https://us-south.ml.cloud.ibm.com} + api_key: ${env.WATSONX_API_KEY:=} + project_id: ${env.WATSONX_PROJECT_ID:=} + - provider_id: ${env.AZURE_API_KEY:+azure} + provider_type: remote::azure + config: + api_key: ${env.AZURE_API_KEY:=} + api_base: ${env.AZURE_API_BASE:=} + api_version: ${env.AZURE_API_VERSION:=} + api_type: ${env.AZURE_API_TYPE:=} + - provider_id: ${env.VERTEX_AI_PROJECT:+vertexai} + provider_type: remote::vertexai + config: + project: ${env.VERTEX_AI_PROJECT:=} + - provider_id: ${env.OPENAI_API_KEY:+openai} + provider_type: remote::openai + config: + api_key: ${env.OPENAI_API_KEY:=} + base_url: ${env.OPENAI_BASE_URL:=https://api.openai.com/v1} + - provider_id: ${env.ENABLE_GEMINI:+gemini} + provider_type: remote::gemini + config: + api_key: ${env.GEMINI_API_KEY:=} + access_token: ${env.GEMINI_ACCESS_TOKEN:=} + project: ${env.GEMINI_AI_PROJECT:=} + vector_io: + - provider_id: ${env.MILVUS_ENDPOINT:+milvus-remote} + provider_type: remote::milvus + config: + uri: ${env.MILVUS_ENDPOINT:=} + token: ${env.MILVUS_TOKEN:=} + secure: ${env.MILVUS_SECURE:=} + consistency_level: ${env.MILVUS_CONSISTENCY_LEVEL:=} + ca_pem_path: ${env.MILVUS_CA_PEM_PATH:=} + client_pem_path: ${env.MILVUS_CLIENT_PEM_PATH:=} + client_key_path: ${env.MILVUS_CLIENT_KEY_PATH:=} + persistence: + backend: kv_default + namespace: vector_io::milvus_remote + - provider_id: ${env.ENABLE_PGVECTOR:+pgvector} + provider_type: remote::pgvector + config: + host: ${env.PGVECTOR_HOST:=localhost} + port: ${env.PGVECTOR_PORT:=5432} + db: ${env.PGVECTOR_DB:=} + user: ${env.PGVECTOR_USER:=} + password: ${env.PGVECTOR_PASSWORD:=} + persistence: + backend: kv_default + namespace: vector_io::pgvector + - provider_id: ${env.ENABLE_QDRANT:+qdrant-remote} + provider_type: remote::qdrant + config: + location: ${env.QDRANT_LOCATION:=} + url: ${env.QDRANT_URL:=} + port: ${env.QDRANT_PORT:=6333} + grpc_port: ${env.QDRANT_GRPC_PORT:=6334} + prefer_grpc: ${env.QDRANT_PREFER_GRPC:=false} + https: ${env.QDRANT_HTTPS:=} + api_key: ${env.QDRANT_API_KEY:=} + prefix: ${env.QDRANT_PREFIX:=} + timeout: ${env.QDRANT_TIMEOUT:=} + host: ${env.QDRANT_HOST:=} + persistence: + backend: kv_default + namespace: vector_io::qdrant_remote + responses: + - provider_id: meta-reference + provider_type: inline::builtin + config: + persistence: + agent_state: + backend: kv_default + namespace: agents::meta_reference + responses: + backend: sql_default + table_name: agents_responses + max_write_queue_size: 10000 + num_writers: 4 + tool_runtime: + - provider_id: brave-search + provider_type: remote::brave-search + config: + api_key: ${env.BRAVE_SEARCH_API_KEY:=} + max_results: 3 + - provider_id: tavily-search + provider_type: remote::tavily-search + config: + api_key: ${env.TAVILY_SEARCH_API_KEY:=} + max_results: 3 + - provider_id: file-search + provider_type: inline::file-search + config: {} + - provider_id: model-context-protocol + provider_type: remote::model-context-protocol + config: {} + files: + - provider_id: meta-reference-files + provider_type: inline::localfs + config: + storage_dir: /opt/app-root/src/.ogx/distributions/rh/files + metadata_store: + backend: sql_default + table_name: files_metadata + - provider_id: ${env.ENABLE_S3:+s3} + provider_type: remote::s3 + config: + bucket_name: ${env.S3_BUCKET_NAME:=} + region: ${env.AWS_DEFAULT_REGION:=us-east-1} + aws_access_key_id: ${env.AWS_ACCESS_KEY_ID:=} + aws_secret_access_key: ${env.AWS_SECRET_ACCESS_KEY:=} + endpoint_url: ${env.S3_ENDPOINT_URL:=} + auto_create_bucket: ${env.S3_AUTO_CREATE_BUCKET:=false} + metadata_store: + backend: sql_default + table_name: files_metadata + file_processors: + - provider_id: pypdf + provider_type: inline::pypdf + config: {} + batches: + - provider_id: reference + provider_type: inline::reference + config: + sqlstore: + table_name: batches + backend: sql_default +storage: + backends: + kv_default: + type: kv_postgres + host: ${env.POSTGRES_HOST:=localhost} + port: ${env.POSTGRES_PORT:=5432} + db: ${env.POSTGRES_DB:=ogx} + user: ${env.POSTGRES_USER:=ogx} + password: ${env.POSTGRES_PASSWORD:=ogx} + table_name: ${env.POSTGRES_TABLE_NAME:=ogx_kvstore} + sql_default: + type: sql_postgres + host: ${env.POSTGRES_HOST:=localhost} + port: ${env.POSTGRES_PORT:=5432} + db: ${env.POSTGRES_DB:=ogx} + user: ${env.POSTGRES_USER:=ogx} + password: ${env.POSTGRES_PASSWORD:=ogx} + stores: + metadata: + backend: kv_default + namespace: registry + inference: + table_name: inference_store + backend: sql_default + max_write_queue_size: 10000 + num_writers: 4 + conversations: + table_name: openai_conversations + backend: sql_default + prompts: + namespace: prompts + backend: kv_default + connectors: + table_name: connectors + backend: sql_default +registered_resources: + models: + - metadata: + embedding_dimension: ${env.EMBEDDING_DIMENSION:=768} + model_id: ${env.EMBEDDING_MODEL:=} + provider_id: ${env.EMBEDDING_PROVIDER:=vllm-embedding} + provider_model_id: ${env.EMBEDDING_PROVIDER_MODEL_ID:=ibm-granite/granite-embedding-125m-english} + model_type: embedding + vector_stores: [] +vector_stores: + annotation_prompt_params: + enable_annotations: true + annotation_instruction_template: "Cite sources immediately at the end of sentences using <|file-id|> format." +telemetry: + enabled: true +server: + port: 8321 + auth: + provider_config: + type: ${env.AUTH_ISSUER:+oauth2_token} + audience: ${env.AUTH_AUDIENCE:=ogx} + issuer: ${env.AUTH_ISSUER:=} + jwks: + uri: ${env.AUTH_JWKS_URI:=} + key_recheck_period: ${env.AUTH_JWKS_RECHECK_PERIOD:=3600} + verify_tls: ${env.AUTH_VERIFY_TLS:=true} + access_policy: + - permit: + actions: [read] + when: resource is unowned + description: "All users can read system resources" + - permit: + actions: [create] + description: "Authenticated users can create resources" + - permit: + actions: [read, update, delete] + when: user is owner + description: "Owners can manage their own resources" diff --git a/cmd/configgen/testdata/rh-add-models/cr.yaml b/cmd/configgen/testdata/rh-add-models/cr.yaml new file mode 100644 index 000000000..a9da393d5 --- /dev/null +++ b/cmd/configgen/testdata/rh-add-models/cr.yaml @@ -0,0 +1,17 @@ +apiVersion: ogx.io/v1beta1 +kind: OGXServer +metadata: + name: test-rh-add-models +spec: + distribution: + name: rh + providers: + inference: + remote: + vllm: + - endpoint: https://my-vllm:8000 + resources: + models: + - name: llama3 + - name: llama3-embed + modelType: embedding diff --git a/cmd/configgen/testdata/rh-add-models/want-config.yaml b/cmd/configgen/testdata/rh-add-models/want-config.yaml new file mode 100644 index 000000000..42470a6c5 --- /dev/null +++ b/cmd/configgen/testdata/rh-add-models/want-config.yaml @@ -0,0 +1,193 @@ +apis: + - responses + - batches + - inference + - tool_runtime + - vector_io + - files + - file_processors +distro_name: rh +providers: + batches: + - config: + sqlstore: + backend: sql_default + table_name: batches + provider_id: reference + provider_type: inline::reference + file_processors: + - config: {} + provider_id: pypdf + provider_type: inline::pypdf + files: + - config: + metadata_store: + backend: sql_default + table_name: files_metadata + storage_dir: /opt/app-root/src/.ogx/distributions/rh/files + provider_id: meta-reference-files + provider_type: inline::localfs + - config: + auto_create_bucket: ${env.S3_AUTO_CREATE_BUCKET:=false} + aws_access_key_id: ${env.AWS_ACCESS_KEY_ID:=} + aws_secret_access_key: ${env.AWS_SECRET_ACCESS_KEY:=} + bucket_name: ${env.S3_BUCKET_NAME:=} + endpoint_url: ${env.S3_ENDPOINT_URL:=} + metadata_store: + backend: sql_default + table_name: files_metadata + region: ${env.AWS_DEFAULT_REGION:=us-east-1} + provider_id: ${env.ENABLE_S3:+s3} + provider_type: remote::s3 + inference: + - config: + base_url: https://my-vllm:8000 + provider_id: remote-vllm + provider_type: remote::vllm + responses: + - config: + persistence: + agent_state: + backend: kv_default + namespace: agents::meta_reference + responses: + backend: sql_default + max_write_queue_size: 10000 + num_writers: 4 + table_name: agents_responses + provider_id: meta-reference + provider_type: inline::builtin + tool_runtime: + - config: + api_key: ${env.BRAVE_SEARCH_API_KEY:=} + max_results: 3 + provider_id: brave-search + provider_type: remote::brave-search + - config: + api_key: ${env.TAVILY_SEARCH_API_KEY:=} + max_results: 3 + provider_id: tavily-search + provider_type: remote::tavily-search + - config: {} + provider_id: file-search + provider_type: inline::file-search + - config: {} + provider_id: model-context-protocol + provider_type: remote::model-context-protocol + vector_io: + - config: + ca_pem_path: ${env.MILVUS_CA_PEM_PATH:=} + client_key_path: ${env.MILVUS_CLIENT_KEY_PATH:=} + client_pem_path: ${env.MILVUS_CLIENT_PEM_PATH:=} + consistency_level: ${env.MILVUS_CONSISTENCY_LEVEL:=} + persistence: + backend: kv_default + namespace: vector_io::milvus_remote + secure: ${env.MILVUS_SECURE:=} + token: ${env.MILVUS_TOKEN:=} + uri: ${env.MILVUS_ENDPOINT:=} + provider_id: ${env.MILVUS_ENDPOINT:+milvus-remote} + provider_type: remote::milvus + - config: + db: ${env.PGVECTOR_DB:=} + host: ${env.PGVECTOR_HOST:=localhost} + password: ${env.PGVECTOR_PASSWORD:=} + persistence: + backend: kv_default + namespace: vector_io::pgvector + port: ${env.PGVECTOR_PORT:=5432} + user: ${env.PGVECTOR_USER:=} + provider_id: ${env.ENABLE_PGVECTOR:+pgvector} + provider_type: remote::pgvector + - config: + api_key: ${env.QDRANT_API_KEY:=} + grpc_port: ${env.QDRANT_GRPC_PORT:=6334} + host: ${env.QDRANT_HOST:=} + https: ${env.QDRANT_HTTPS:=} + location: ${env.QDRANT_LOCATION:=} + persistence: + backend: kv_default + namespace: vector_io::qdrant_remote + port: ${env.QDRANT_PORT:=6333} + prefer_grpc: ${env.QDRANT_PREFER_GRPC:=false} + prefix: ${env.QDRANT_PREFIX:=} + timeout: ${env.QDRANT_TIMEOUT:=} + url: ${env.QDRANT_URL:=} + provider_id: ${env.ENABLE_QDRANT:+qdrant-remote} + provider_type: remote::qdrant +registered_resources: + models: + - model_id: llama3 + model_type: llm + provider_id: remote-vllm + - model_id: llama3-embed + model_type: embedding + provider_id: remote-vllm +server: + auth: + access_policy: + - description: All users can read system resources + permit: + actions: + - read + when: resource is unowned + - description: Authenticated users can create resources + permit: + actions: + - create + - description: Owners can manage their own resources + permit: + actions: + - read + - update + - delete + when: user is owner + provider_config: + audience: ${env.AUTH_AUDIENCE:=ogx} + issuer: ${env.AUTH_ISSUER:=} + jwks: + key_recheck_period: ${env.AUTH_JWKS_RECHECK_PERIOD:=3600} + uri: ${env.AUTH_JWKS_URI:=} + type: ${env.AUTH_ISSUER:+oauth2_token} + verify_tls: ${env.AUTH_VERIFY_TLS:=true} + port: 8321 +storage: + backends: + kv_default: + db: ${env.POSTGRES_DB:=ogx} + host: ${env.POSTGRES_HOST:=localhost} + password: ${env.POSTGRES_PASSWORD:=ogx} + port: ${env.POSTGRES_PORT:=5432} + table_name: ${env.POSTGRES_TABLE_NAME:=ogx_kvstore} + type: kv_postgres + user: ${env.POSTGRES_USER:=ogx} + sql_default: + db: ${env.POSTGRES_DB:=ogx} + host: ${env.POSTGRES_HOST:=localhost} + password: ${env.POSTGRES_PASSWORD:=ogx} + port: ${env.POSTGRES_PORT:=5432} + type: sql_postgres + user: ${env.POSTGRES_USER:=ogx} + stores: + connectors: + backend: sql_default + table_name: connectors + conversations: + backend: sql_default + table_name: openai_conversations + inference: + backend: sql_default + max_write_queue_size: 10000 + num_writers: 4 + table_name: inference_store + metadata: + backend: kv_default + namespace: registry + prompts: + backend: kv_default + namespace: prompts +vector_stores: + annotation_prompt_params: + annotation_instruction_template: Cite sources immediately at the end of sentences using <|file-id|> format. + enable_annotations: true +version: "2" diff --git a/cmd/configgen/testdata/rh-combined/base.yaml b/cmd/configgen/testdata/rh-combined/base.yaml new file mode 100644 index 000000000..911e4eb77 --- /dev/null +++ b/cmd/configgen/testdata/rh-combined/base.yaml @@ -0,0 +1,236 @@ +version: 2 +image_name: rh +apis: +- responses +- batches +- inference +- tool_runtime +- vector_io +- files +- file_processors +providers: + inference: + - provider_id: ${env.VLLM_URL:+vllm-inference} + provider_type: remote::vllm + config: + base_url: ${env.VLLM_URL:=} + max_tokens: ${env.VLLM_MAX_TOKENS:=4096} + api_token: ${env.VLLM_API_TOKEN:=fake} + tls_verify: ${env.VLLM_TLS_VERIFY:=true} + refresh_models: ${env.VLLM_REFRESH_MODELS:=false} + - provider_id: ${env.VLLM_EMBEDDING_URL:+vllm-embedding} + provider_type: remote::vllm + config: + base_url: ${env.VLLM_EMBEDDING_URL:=} + max_tokens: ${env.VLLM_EMBEDDING_MAX_TOKENS:=4096} + api_token: ${env.VLLM_EMBEDDING_API_TOKEN:=fake} + tls_verify: ${env.VLLM_EMBEDDING_TLS_VERIFY:=true} + refresh_models: ${env.VLLM_EMBEDDING_REFRESH_MODELS:=false} + - provider_id: ${env.AWS_BEARER_TOKEN_BEDROCK:+bedrock} + provider_type: remote::bedrock + config: + api_key: ${env.AWS_BEARER_TOKEN_BEDROCK:=} + region_name: ${env.AWS_DEFAULT_REGION:=us-east-2} + - provider_id: ${env.WATSONX_API_KEY:+watsonx} + provider_type: remote::watsonx + config: + base_url: ${env.WATSONX_BASE_URL:=https://us-south.ml.cloud.ibm.com} + api_key: ${env.WATSONX_API_KEY:=} + project_id: ${env.WATSONX_PROJECT_ID:=} + - provider_id: ${env.AZURE_API_KEY:+azure} + provider_type: remote::azure + config: + api_key: ${env.AZURE_API_KEY:=} + api_base: ${env.AZURE_API_BASE:=} + api_version: ${env.AZURE_API_VERSION:=} + api_type: ${env.AZURE_API_TYPE:=} + - provider_id: ${env.VERTEX_AI_PROJECT:+vertexai} + provider_type: remote::vertexai + config: + project: ${env.VERTEX_AI_PROJECT:=} + - provider_id: ${env.OPENAI_API_KEY:+openai} + provider_type: remote::openai + config: + api_key: ${env.OPENAI_API_KEY:=} + base_url: ${env.OPENAI_BASE_URL:=https://api.openai.com/v1} + - provider_id: ${env.ENABLE_GEMINI:+gemini} + provider_type: remote::gemini + config: + api_key: ${env.GEMINI_API_KEY:=} + access_token: ${env.GEMINI_ACCESS_TOKEN:=} + project: ${env.GEMINI_AI_PROJECT:=} + vector_io: + - provider_id: ${env.MILVUS_ENDPOINT:+milvus-remote} + provider_type: remote::milvus + config: + uri: ${env.MILVUS_ENDPOINT:=} + token: ${env.MILVUS_TOKEN:=} + secure: ${env.MILVUS_SECURE:=} + consistency_level: ${env.MILVUS_CONSISTENCY_LEVEL:=} + ca_pem_path: ${env.MILVUS_CA_PEM_PATH:=} + client_pem_path: ${env.MILVUS_CLIENT_PEM_PATH:=} + client_key_path: ${env.MILVUS_CLIENT_KEY_PATH:=} + persistence: + backend: kv_default + namespace: vector_io::milvus_remote + - provider_id: ${env.ENABLE_PGVECTOR:+pgvector} + provider_type: remote::pgvector + config: + host: ${env.PGVECTOR_HOST:=localhost} + port: ${env.PGVECTOR_PORT:=5432} + db: ${env.PGVECTOR_DB:=} + user: ${env.PGVECTOR_USER:=} + password: ${env.PGVECTOR_PASSWORD:=} + persistence: + backend: kv_default + namespace: vector_io::pgvector + - provider_id: ${env.ENABLE_QDRANT:+qdrant-remote} + provider_type: remote::qdrant + config: + location: ${env.QDRANT_LOCATION:=} + url: ${env.QDRANT_URL:=} + port: ${env.QDRANT_PORT:=6333} + grpc_port: ${env.QDRANT_GRPC_PORT:=6334} + prefer_grpc: ${env.QDRANT_PREFER_GRPC:=false} + https: ${env.QDRANT_HTTPS:=} + api_key: ${env.QDRANT_API_KEY:=} + prefix: ${env.QDRANT_PREFIX:=} + timeout: ${env.QDRANT_TIMEOUT:=} + host: ${env.QDRANT_HOST:=} + persistence: + backend: kv_default + namespace: vector_io::qdrant_remote + responses: + - provider_id: meta-reference + provider_type: inline::builtin + config: + persistence: + agent_state: + backend: kv_default + namespace: agents::meta_reference + responses: + backend: sql_default + table_name: agents_responses + max_write_queue_size: 10000 + num_writers: 4 + tool_runtime: + - provider_id: brave-search + provider_type: remote::brave-search + config: + api_key: ${env.BRAVE_SEARCH_API_KEY:=} + max_results: 3 + - provider_id: tavily-search + provider_type: remote::tavily-search + config: + api_key: ${env.TAVILY_SEARCH_API_KEY:=} + max_results: 3 + - provider_id: file-search + provider_type: inline::file-search + config: {} + - provider_id: model-context-protocol + provider_type: remote::model-context-protocol + config: {} + files: + - provider_id: meta-reference-files + provider_type: inline::localfs + config: + storage_dir: /opt/app-root/src/.ogx/distributions/rh/files + metadata_store: + backend: sql_default + table_name: files_metadata + - provider_id: ${env.ENABLE_S3:+s3} + provider_type: remote::s3 + config: + bucket_name: ${env.S3_BUCKET_NAME:=} + region: ${env.AWS_DEFAULT_REGION:=us-east-1} + aws_access_key_id: ${env.AWS_ACCESS_KEY_ID:=} + aws_secret_access_key: ${env.AWS_SECRET_ACCESS_KEY:=} + endpoint_url: ${env.S3_ENDPOINT_URL:=} + auto_create_bucket: ${env.S3_AUTO_CREATE_BUCKET:=false} + metadata_store: + backend: sql_default + table_name: files_metadata + file_processors: + - provider_id: pypdf + provider_type: inline::pypdf + config: {} + batches: + - provider_id: reference + provider_type: inline::reference + config: + sqlstore: + table_name: batches + backend: sql_default +storage: + backends: + kv_default: + type: kv_postgres + host: ${env.POSTGRES_HOST:=localhost} + port: ${env.POSTGRES_PORT:=5432} + db: ${env.POSTGRES_DB:=ogx} + user: ${env.POSTGRES_USER:=ogx} + password: ${env.POSTGRES_PASSWORD:=ogx} + table_name: ${env.POSTGRES_TABLE_NAME:=ogx_kvstore} + sql_default: + type: sql_postgres + host: ${env.POSTGRES_HOST:=localhost} + port: ${env.POSTGRES_PORT:=5432} + db: ${env.POSTGRES_DB:=ogx} + user: ${env.POSTGRES_USER:=ogx} + password: ${env.POSTGRES_PASSWORD:=ogx} + stores: + metadata: + backend: kv_default + namespace: registry + inference: + table_name: inference_store + backend: sql_default + max_write_queue_size: 10000 + num_writers: 4 + conversations: + table_name: openai_conversations + backend: sql_default + prompts: + namespace: prompts + backend: kv_default + connectors: + table_name: connectors + backend: sql_default +registered_resources: + models: + - metadata: + embedding_dimension: ${env.EMBEDDING_DIMENSION:=768} + model_id: ${env.EMBEDDING_MODEL:=} + provider_id: ${env.EMBEDDING_PROVIDER:=vllm-embedding} + provider_model_id: ${env.EMBEDDING_PROVIDER_MODEL_ID:=ibm-granite/granite-embedding-125m-english} + model_type: embedding + vector_stores: [] +vector_stores: + annotation_prompt_params: + enable_annotations: true + annotation_instruction_template: "Cite sources immediately at the end of sentences using <|file-id|> format." +telemetry: + enabled: true +server: + port: 8321 + auth: + provider_config: + type: ${env.AUTH_ISSUER:+oauth2_token} + audience: ${env.AUTH_AUDIENCE:=ogx} + issuer: ${env.AUTH_ISSUER:=} + jwks: + uri: ${env.AUTH_JWKS_URI:=} + key_recheck_period: ${env.AUTH_JWKS_RECHECK_PERIOD:=3600} + verify_tls: ${env.AUTH_VERIFY_TLS:=true} + access_policy: + - permit: + actions: [read] + when: resource is unowned + description: "All users can read system resources" + - permit: + actions: [create] + description: "Authenticated users can create resources" + - permit: + actions: [read, update, delete] + when: user is owner + description: "Owners can manage their own resources" diff --git a/cmd/configgen/testdata/rh-combined/cr.yaml b/cmd/configgen/testdata/rh-combined/cr.yaml new file mode 100644 index 000000000..720d06910 --- /dev/null +++ b/cmd/configgen/testdata/rh-combined/cr.yaml @@ -0,0 +1,19 @@ +apiVersion: ogx.io/v1beta1 +kind: OGXServer +metadata: + name: test-rh-combined +spec: + distribution: + name: rh + providers: + inference: + remote: + vllm: + - endpoint: https://my-vllm:8000 + resources: + models: + - name: llama3 + disabledAPIs: + - batches + network: + port: 9090 diff --git a/cmd/configgen/testdata/rh-combined/want-config.yaml b/cmd/configgen/testdata/rh-combined/want-config.yaml new file mode 100644 index 000000000..d6044a34b --- /dev/null +++ b/cmd/configgen/testdata/rh-combined/want-config.yaml @@ -0,0 +1,189 @@ +apis: + - responses + - inference + - tool_runtime + - vector_io + - files + - file_processors +distro_name: rh +providers: + batches: + - config: + sqlstore: + backend: sql_default + table_name: batches + provider_id: reference + provider_type: inline::reference + file_processors: + - config: {} + provider_id: pypdf + provider_type: inline::pypdf + files: + - config: + metadata_store: + backend: sql_default + table_name: files_metadata + storage_dir: /opt/app-root/src/.ogx/distributions/rh/files + provider_id: meta-reference-files + provider_type: inline::localfs + - config: + auto_create_bucket: ${env.S3_AUTO_CREATE_BUCKET:=false} + aws_access_key_id: ${env.AWS_ACCESS_KEY_ID:=} + aws_secret_access_key: ${env.AWS_SECRET_ACCESS_KEY:=} + bucket_name: ${env.S3_BUCKET_NAME:=} + endpoint_url: ${env.S3_ENDPOINT_URL:=} + metadata_store: + backend: sql_default + table_name: files_metadata + region: ${env.AWS_DEFAULT_REGION:=us-east-1} + provider_id: ${env.ENABLE_S3:+s3} + provider_type: remote::s3 + inference: + - config: + base_url: https://my-vllm:8000 + provider_id: remote-vllm + provider_type: remote::vllm + responses: + - config: + persistence: + agent_state: + backend: kv_default + namespace: agents::meta_reference + responses: + backend: sql_default + max_write_queue_size: 10000 + num_writers: 4 + table_name: agents_responses + provider_id: meta-reference + provider_type: inline::builtin + tool_runtime: + - config: + api_key: ${env.BRAVE_SEARCH_API_KEY:=} + max_results: 3 + provider_id: brave-search + provider_type: remote::brave-search + - config: + api_key: ${env.TAVILY_SEARCH_API_KEY:=} + max_results: 3 + provider_id: tavily-search + provider_type: remote::tavily-search + - config: {} + provider_id: file-search + provider_type: inline::file-search + - config: {} + provider_id: model-context-protocol + provider_type: remote::model-context-protocol + vector_io: + - config: + ca_pem_path: ${env.MILVUS_CA_PEM_PATH:=} + client_key_path: ${env.MILVUS_CLIENT_KEY_PATH:=} + client_pem_path: ${env.MILVUS_CLIENT_PEM_PATH:=} + consistency_level: ${env.MILVUS_CONSISTENCY_LEVEL:=} + persistence: + backend: kv_default + namespace: vector_io::milvus_remote + secure: ${env.MILVUS_SECURE:=} + token: ${env.MILVUS_TOKEN:=} + uri: ${env.MILVUS_ENDPOINT:=} + provider_id: ${env.MILVUS_ENDPOINT:+milvus-remote} + provider_type: remote::milvus + - config: + db: ${env.PGVECTOR_DB:=} + host: ${env.PGVECTOR_HOST:=localhost} + password: ${env.PGVECTOR_PASSWORD:=} + persistence: + backend: kv_default + namespace: vector_io::pgvector + port: ${env.PGVECTOR_PORT:=5432} + user: ${env.PGVECTOR_USER:=} + provider_id: ${env.ENABLE_PGVECTOR:+pgvector} + provider_type: remote::pgvector + - config: + api_key: ${env.QDRANT_API_KEY:=} + grpc_port: ${env.QDRANT_GRPC_PORT:=6334} + host: ${env.QDRANT_HOST:=} + https: ${env.QDRANT_HTTPS:=} + location: ${env.QDRANT_LOCATION:=} + persistence: + backend: kv_default + namespace: vector_io::qdrant_remote + port: ${env.QDRANT_PORT:=6333} + prefer_grpc: ${env.QDRANT_PREFER_GRPC:=false} + prefix: ${env.QDRANT_PREFIX:=} + timeout: ${env.QDRANT_TIMEOUT:=} + url: ${env.QDRANT_URL:=} + provider_id: ${env.ENABLE_QDRANT:+qdrant-remote} + provider_type: remote::qdrant +registered_resources: + models: + - model_id: llama3 + model_type: llm + provider_id: remote-vllm +server: + auth: + access_policy: + - description: All users can read system resources + permit: + actions: + - read + when: resource is unowned + - description: Authenticated users can create resources + permit: + actions: + - create + - description: Owners can manage their own resources + permit: + actions: + - read + - update + - delete + when: user is owner + provider_config: + audience: ${env.AUTH_AUDIENCE:=ogx} + issuer: ${env.AUTH_ISSUER:=} + jwks: + key_recheck_period: ${env.AUTH_JWKS_RECHECK_PERIOD:=3600} + uri: ${env.AUTH_JWKS_URI:=} + type: ${env.AUTH_ISSUER:+oauth2_token} + verify_tls: ${env.AUTH_VERIFY_TLS:=true} + port: 9090 +storage: + backends: + kv_default: + db: ${env.POSTGRES_DB:=ogx} + host: ${env.POSTGRES_HOST:=localhost} + password: ${env.POSTGRES_PASSWORD:=ogx} + port: ${env.POSTGRES_PORT:=5432} + table_name: ${env.POSTGRES_TABLE_NAME:=ogx_kvstore} + type: kv_postgres + user: ${env.POSTGRES_USER:=ogx} + sql_default: + db: ${env.POSTGRES_DB:=ogx} + host: ${env.POSTGRES_HOST:=localhost} + password: ${env.POSTGRES_PASSWORD:=ogx} + port: ${env.POSTGRES_PORT:=5432} + type: sql_postgres + user: ${env.POSTGRES_USER:=ogx} + stores: + connectors: + backend: sql_default + table_name: connectors + conversations: + backend: sql_default + table_name: openai_conversations + inference: + backend: sql_default + max_write_queue_size: 10000 + num_writers: 4 + table_name: inference_store + metadata: + backend: kv_default + namespace: registry + prompts: + backend: kv_default + namespace: prompts +vector_stores: + annotation_prompt_params: + annotation_instruction_template: Cite sources immediately at the end of sentences using <|file-id|> format. + enable_annotations: true +version: "2" diff --git a/cmd/configgen/testdata/rh-custom-port/base.yaml b/cmd/configgen/testdata/rh-custom-port/base.yaml new file mode 100644 index 000000000..911e4eb77 --- /dev/null +++ b/cmd/configgen/testdata/rh-custom-port/base.yaml @@ -0,0 +1,236 @@ +version: 2 +image_name: rh +apis: +- responses +- batches +- inference +- tool_runtime +- vector_io +- files +- file_processors +providers: + inference: + - provider_id: ${env.VLLM_URL:+vllm-inference} + provider_type: remote::vllm + config: + base_url: ${env.VLLM_URL:=} + max_tokens: ${env.VLLM_MAX_TOKENS:=4096} + api_token: ${env.VLLM_API_TOKEN:=fake} + tls_verify: ${env.VLLM_TLS_VERIFY:=true} + refresh_models: ${env.VLLM_REFRESH_MODELS:=false} + - provider_id: ${env.VLLM_EMBEDDING_URL:+vllm-embedding} + provider_type: remote::vllm + config: + base_url: ${env.VLLM_EMBEDDING_URL:=} + max_tokens: ${env.VLLM_EMBEDDING_MAX_TOKENS:=4096} + api_token: ${env.VLLM_EMBEDDING_API_TOKEN:=fake} + tls_verify: ${env.VLLM_EMBEDDING_TLS_VERIFY:=true} + refresh_models: ${env.VLLM_EMBEDDING_REFRESH_MODELS:=false} + - provider_id: ${env.AWS_BEARER_TOKEN_BEDROCK:+bedrock} + provider_type: remote::bedrock + config: + api_key: ${env.AWS_BEARER_TOKEN_BEDROCK:=} + region_name: ${env.AWS_DEFAULT_REGION:=us-east-2} + - provider_id: ${env.WATSONX_API_KEY:+watsonx} + provider_type: remote::watsonx + config: + base_url: ${env.WATSONX_BASE_URL:=https://us-south.ml.cloud.ibm.com} + api_key: ${env.WATSONX_API_KEY:=} + project_id: ${env.WATSONX_PROJECT_ID:=} + - provider_id: ${env.AZURE_API_KEY:+azure} + provider_type: remote::azure + config: + api_key: ${env.AZURE_API_KEY:=} + api_base: ${env.AZURE_API_BASE:=} + api_version: ${env.AZURE_API_VERSION:=} + api_type: ${env.AZURE_API_TYPE:=} + - provider_id: ${env.VERTEX_AI_PROJECT:+vertexai} + provider_type: remote::vertexai + config: + project: ${env.VERTEX_AI_PROJECT:=} + - provider_id: ${env.OPENAI_API_KEY:+openai} + provider_type: remote::openai + config: + api_key: ${env.OPENAI_API_KEY:=} + base_url: ${env.OPENAI_BASE_URL:=https://api.openai.com/v1} + - provider_id: ${env.ENABLE_GEMINI:+gemini} + provider_type: remote::gemini + config: + api_key: ${env.GEMINI_API_KEY:=} + access_token: ${env.GEMINI_ACCESS_TOKEN:=} + project: ${env.GEMINI_AI_PROJECT:=} + vector_io: + - provider_id: ${env.MILVUS_ENDPOINT:+milvus-remote} + provider_type: remote::milvus + config: + uri: ${env.MILVUS_ENDPOINT:=} + token: ${env.MILVUS_TOKEN:=} + secure: ${env.MILVUS_SECURE:=} + consistency_level: ${env.MILVUS_CONSISTENCY_LEVEL:=} + ca_pem_path: ${env.MILVUS_CA_PEM_PATH:=} + client_pem_path: ${env.MILVUS_CLIENT_PEM_PATH:=} + client_key_path: ${env.MILVUS_CLIENT_KEY_PATH:=} + persistence: + backend: kv_default + namespace: vector_io::milvus_remote + - provider_id: ${env.ENABLE_PGVECTOR:+pgvector} + provider_type: remote::pgvector + config: + host: ${env.PGVECTOR_HOST:=localhost} + port: ${env.PGVECTOR_PORT:=5432} + db: ${env.PGVECTOR_DB:=} + user: ${env.PGVECTOR_USER:=} + password: ${env.PGVECTOR_PASSWORD:=} + persistence: + backend: kv_default + namespace: vector_io::pgvector + - provider_id: ${env.ENABLE_QDRANT:+qdrant-remote} + provider_type: remote::qdrant + config: + location: ${env.QDRANT_LOCATION:=} + url: ${env.QDRANT_URL:=} + port: ${env.QDRANT_PORT:=6333} + grpc_port: ${env.QDRANT_GRPC_PORT:=6334} + prefer_grpc: ${env.QDRANT_PREFER_GRPC:=false} + https: ${env.QDRANT_HTTPS:=} + api_key: ${env.QDRANT_API_KEY:=} + prefix: ${env.QDRANT_PREFIX:=} + timeout: ${env.QDRANT_TIMEOUT:=} + host: ${env.QDRANT_HOST:=} + persistence: + backend: kv_default + namespace: vector_io::qdrant_remote + responses: + - provider_id: meta-reference + provider_type: inline::builtin + config: + persistence: + agent_state: + backend: kv_default + namespace: agents::meta_reference + responses: + backend: sql_default + table_name: agents_responses + max_write_queue_size: 10000 + num_writers: 4 + tool_runtime: + - provider_id: brave-search + provider_type: remote::brave-search + config: + api_key: ${env.BRAVE_SEARCH_API_KEY:=} + max_results: 3 + - provider_id: tavily-search + provider_type: remote::tavily-search + config: + api_key: ${env.TAVILY_SEARCH_API_KEY:=} + max_results: 3 + - provider_id: file-search + provider_type: inline::file-search + config: {} + - provider_id: model-context-protocol + provider_type: remote::model-context-protocol + config: {} + files: + - provider_id: meta-reference-files + provider_type: inline::localfs + config: + storage_dir: /opt/app-root/src/.ogx/distributions/rh/files + metadata_store: + backend: sql_default + table_name: files_metadata + - provider_id: ${env.ENABLE_S3:+s3} + provider_type: remote::s3 + config: + bucket_name: ${env.S3_BUCKET_NAME:=} + region: ${env.AWS_DEFAULT_REGION:=us-east-1} + aws_access_key_id: ${env.AWS_ACCESS_KEY_ID:=} + aws_secret_access_key: ${env.AWS_SECRET_ACCESS_KEY:=} + endpoint_url: ${env.S3_ENDPOINT_URL:=} + auto_create_bucket: ${env.S3_AUTO_CREATE_BUCKET:=false} + metadata_store: + backend: sql_default + table_name: files_metadata + file_processors: + - provider_id: pypdf + provider_type: inline::pypdf + config: {} + batches: + - provider_id: reference + provider_type: inline::reference + config: + sqlstore: + table_name: batches + backend: sql_default +storage: + backends: + kv_default: + type: kv_postgres + host: ${env.POSTGRES_HOST:=localhost} + port: ${env.POSTGRES_PORT:=5432} + db: ${env.POSTGRES_DB:=ogx} + user: ${env.POSTGRES_USER:=ogx} + password: ${env.POSTGRES_PASSWORD:=ogx} + table_name: ${env.POSTGRES_TABLE_NAME:=ogx_kvstore} + sql_default: + type: sql_postgres + host: ${env.POSTGRES_HOST:=localhost} + port: ${env.POSTGRES_PORT:=5432} + db: ${env.POSTGRES_DB:=ogx} + user: ${env.POSTGRES_USER:=ogx} + password: ${env.POSTGRES_PASSWORD:=ogx} + stores: + metadata: + backend: kv_default + namespace: registry + inference: + table_name: inference_store + backend: sql_default + max_write_queue_size: 10000 + num_writers: 4 + conversations: + table_name: openai_conversations + backend: sql_default + prompts: + namespace: prompts + backend: kv_default + connectors: + table_name: connectors + backend: sql_default +registered_resources: + models: + - metadata: + embedding_dimension: ${env.EMBEDDING_DIMENSION:=768} + model_id: ${env.EMBEDDING_MODEL:=} + provider_id: ${env.EMBEDDING_PROVIDER:=vllm-embedding} + provider_model_id: ${env.EMBEDDING_PROVIDER_MODEL_ID:=ibm-granite/granite-embedding-125m-english} + model_type: embedding + vector_stores: [] +vector_stores: + annotation_prompt_params: + enable_annotations: true + annotation_instruction_template: "Cite sources immediately at the end of sentences using <|file-id|> format." +telemetry: + enabled: true +server: + port: 8321 + auth: + provider_config: + type: ${env.AUTH_ISSUER:+oauth2_token} + audience: ${env.AUTH_AUDIENCE:=ogx} + issuer: ${env.AUTH_ISSUER:=} + jwks: + uri: ${env.AUTH_JWKS_URI:=} + key_recheck_period: ${env.AUTH_JWKS_RECHECK_PERIOD:=3600} + verify_tls: ${env.AUTH_VERIFY_TLS:=true} + access_policy: + - permit: + actions: [read] + when: resource is unowned + description: "All users can read system resources" + - permit: + actions: [create] + description: "Authenticated users can create resources" + - permit: + actions: [read, update, delete] + when: user is owner + description: "Owners can manage their own resources" diff --git a/cmd/configgen/testdata/rh-custom-port/cr.yaml b/cmd/configgen/testdata/rh-custom-port/cr.yaml new file mode 100644 index 000000000..cd773b998 --- /dev/null +++ b/cmd/configgen/testdata/rh-custom-port/cr.yaml @@ -0,0 +1,14 @@ +apiVersion: ogx.io/v1beta1 +kind: OGXServer +metadata: + name: test-rh-custom-port +spec: + distribution: + name: rh + providers: + inference: + remote: + vllm: + - endpoint: https://my-vllm:8000 + network: + port: 9090 diff --git a/cmd/configgen/testdata/rh-custom-port/want-config.yaml b/cmd/configgen/testdata/rh-custom-port/want-config.yaml new file mode 100644 index 000000000..b685b45b1 --- /dev/null +++ b/cmd/configgen/testdata/rh-custom-port/want-config.yaml @@ -0,0 +1,193 @@ +apis: + - responses + - batches + - inference + - tool_runtime + - vector_io + - files + - file_processors +distro_name: rh +providers: + batches: + - config: + sqlstore: + backend: sql_default + table_name: batches + provider_id: reference + provider_type: inline::reference + file_processors: + - config: {} + provider_id: pypdf + provider_type: inline::pypdf + files: + - config: + metadata_store: + backend: sql_default + table_name: files_metadata + storage_dir: /opt/app-root/src/.ogx/distributions/rh/files + provider_id: meta-reference-files + provider_type: inline::localfs + - config: + auto_create_bucket: ${env.S3_AUTO_CREATE_BUCKET:=false} + aws_access_key_id: ${env.AWS_ACCESS_KEY_ID:=} + aws_secret_access_key: ${env.AWS_SECRET_ACCESS_KEY:=} + bucket_name: ${env.S3_BUCKET_NAME:=} + endpoint_url: ${env.S3_ENDPOINT_URL:=} + metadata_store: + backend: sql_default + table_name: files_metadata + region: ${env.AWS_DEFAULT_REGION:=us-east-1} + provider_id: ${env.ENABLE_S3:+s3} + provider_type: remote::s3 + inference: + - config: + base_url: https://my-vllm:8000 + provider_id: remote-vllm + provider_type: remote::vllm + responses: + - config: + persistence: + agent_state: + backend: kv_default + namespace: agents::meta_reference + responses: + backend: sql_default + max_write_queue_size: 10000 + num_writers: 4 + table_name: agents_responses + provider_id: meta-reference + provider_type: inline::builtin + tool_runtime: + - config: + api_key: ${env.BRAVE_SEARCH_API_KEY:=} + max_results: 3 + provider_id: brave-search + provider_type: remote::brave-search + - config: + api_key: ${env.TAVILY_SEARCH_API_KEY:=} + max_results: 3 + provider_id: tavily-search + provider_type: remote::tavily-search + - config: {} + provider_id: file-search + provider_type: inline::file-search + - config: {} + provider_id: model-context-protocol + provider_type: remote::model-context-protocol + vector_io: + - config: + ca_pem_path: ${env.MILVUS_CA_PEM_PATH:=} + client_key_path: ${env.MILVUS_CLIENT_KEY_PATH:=} + client_pem_path: ${env.MILVUS_CLIENT_PEM_PATH:=} + consistency_level: ${env.MILVUS_CONSISTENCY_LEVEL:=} + persistence: + backend: kv_default + namespace: vector_io::milvus_remote + secure: ${env.MILVUS_SECURE:=} + token: ${env.MILVUS_TOKEN:=} + uri: ${env.MILVUS_ENDPOINT:=} + provider_id: ${env.MILVUS_ENDPOINT:+milvus-remote} + provider_type: remote::milvus + - config: + db: ${env.PGVECTOR_DB:=} + host: ${env.PGVECTOR_HOST:=localhost} + password: ${env.PGVECTOR_PASSWORD:=} + persistence: + backend: kv_default + namespace: vector_io::pgvector + port: ${env.PGVECTOR_PORT:=5432} + user: ${env.PGVECTOR_USER:=} + provider_id: ${env.ENABLE_PGVECTOR:+pgvector} + provider_type: remote::pgvector + - config: + api_key: ${env.QDRANT_API_KEY:=} + grpc_port: ${env.QDRANT_GRPC_PORT:=6334} + host: ${env.QDRANT_HOST:=} + https: ${env.QDRANT_HTTPS:=} + location: ${env.QDRANT_LOCATION:=} + persistence: + backend: kv_default + namespace: vector_io::qdrant_remote + port: ${env.QDRANT_PORT:=6333} + prefer_grpc: ${env.QDRANT_PREFER_GRPC:=false} + prefix: ${env.QDRANT_PREFIX:=} + timeout: ${env.QDRANT_TIMEOUT:=} + url: ${env.QDRANT_URL:=} + provider_id: ${env.ENABLE_QDRANT:+qdrant-remote} + provider_type: remote::qdrant +registered_resources: + models: + - metadata: + embedding_dimension: ${env.EMBEDDING_DIMENSION:=768} + model_id: ${env.EMBEDDING_MODEL:=} + model_type: embedding + provider_id: ${env.EMBEDDING_PROVIDER:=vllm-embedding} + provider_model_id: ${env.EMBEDDING_PROVIDER_MODEL_ID:=ibm-granite/granite-embedding-125m-english} +server: + auth: + access_policy: + - description: All users can read system resources + permit: + actions: + - read + when: resource is unowned + - description: Authenticated users can create resources + permit: + actions: + - create + - description: Owners can manage their own resources + permit: + actions: + - read + - update + - delete + when: user is owner + provider_config: + audience: ${env.AUTH_AUDIENCE:=ogx} + issuer: ${env.AUTH_ISSUER:=} + jwks: + key_recheck_period: ${env.AUTH_JWKS_RECHECK_PERIOD:=3600} + uri: ${env.AUTH_JWKS_URI:=} + type: ${env.AUTH_ISSUER:+oauth2_token} + verify_tls: ${env.AUTH_VERIFY_TLS:=true} + port: 9090 +storage: + backends: + kv_default: + db: ${env.POSTGRES_DB:=ogx} + host: ${env.POSTGRES_HOST:=localhost} + password: ${env.POSTGRES_PASSWORD:=ogx} + port: ${env.POSTGRES_PORT:=5432} + table_name: ${env.POSTGRES_TABLE_NAME:=ogx_kvstore} + type: kv_postgres + user: ${env.POSTGRES_USER:=ogx} + sql_default: + db: ${env.POSTGRES_DB:=ogx} + host: ${env.POSTGRES_HOST:=localhost} + password: ${env.POSTGRES_PASSWORD:=ogx} + port: ${env.POSTGRES_PORT:=5432} + type: sql_postgres + user: ${env.POSTGRES_USER:=ogx} + stores: + connectors: + backend: sql_default + table_name: connectors + conversations: + backend: sql_default + table_name: openai_conversations + inference: + backend: sql_default + max_write_queue_size: 10000 + num_writers: 4 + table_name: inference_store + metadata: + backend: kv_default + namespace: registry + prompts: + backend: kv_default + namespace: prompts +vector_stores: + annotation_prompt_params: + annotation_instruction_template: Cite sources immediately at the end of sentences using <|file-id|> format. + enable_annotations: true +version: "2" diff --git a/cmd/configgen/testdata/rh-disable-apis/base.yaml b/cmd/configgen/testdata/rh-disable-apis/base.yaml new file mode 100644 index 000000000..911e4eb77 --- /dev/null +++ b/cmd/configgen/testdata/rh-disable-apis/base.yaml @@ -0,0 +1,236 @@ +version: 2 +image_name: rh +apis: +- responses +- batches +- inference +- tool_runtime +- vector_io +- files +- file_processors +providers: + inference: + - provider_id: ${env.VLLM_URL:+vllm-inference} + provider_type: remote::vllm + config: + base_url: ${env.VLLM_URL:=} + max_tokens: ${env.VLLM_MAX_TOKENS:=4096} + api_token: ${env.VLLM_API_TOKEN:=fake} + tls_verify: ${env.VLLM_TLS_VERIFY:=true} + refresh_models: ${env.VLLM_REFRESH_MODELS:=false} + - provider_id: ${env.VLLM_EMBEDDING_URL:+vllm-embedding} + provider_type: remote::vllm + config: + base_url: ${env.VLLM_EMBEDDING_URL:=} + max_tokens: ${env.VLLM_EMBEDDING_MAX_TOKENS:=4096} + api_token: ${env.VLLM_EMBEDDING_API_TOKEN:=fake} + tls_verify: ${env.VLLM_EMBEDDING_TLS_VERIFY:=true} + refresh_models: ${env.VLLM_EMBEDDING_REFRESH_MODELS:=false} + - provider_id: ${env.AWS_BEARER_TOKEN_BEDROCK:+bedrock} + provider_type: remote::bedrock + config: + api_key: ${env.AWS_BEARER_TOKEN_BEDROCK:=} + region_name: ${env.AWS_DEFAULT_REGION:=us-east-2} + - provider_id: ${env.WATSONX_API_KEY:+watsonx} + provider_type: remote::watsonx + config: + base_url: ${env.WATSONX_BASE_URL:=https://us-south.ml.cloud.ibm.com} + api_key: ${env.WATSONX_API_KEY:=} + project_id: ${env.WATSONX_PROJECT_ID:=} + - provider_id: ${env.AZURE_API_KEY:+azure} + provider_type: remote::azure + config: + api_key: ${env.AZURE_API_KEY:=} + api_base: ${env.AZURE_API_BASE:=} + api_version: ${env.AZURE_API_VERSION:=} + api_type: ${env.AZURE_API_TYPE:=} + - provider_id: ${env.VERTEX_AI_PROJECT:+vertexai} + provider_type: remote::vertexai + config: + project: ${env.VERTEX_AI_PROJECT:=} + - provider_id: ${env.OPENAI_API_KEY:+openai} + provider_type: remote::openai + config: + api_key: ${env.OPENAI_API_KEY:=} + base_url: ${env.OPENAI_BASE_URL:=https://api.openai.com/v1} + - provider_id: ${env.ENABLE_GEMINI:+gemini} + provider_type: remote::gemini + config: + api_key: ${env.GEMINI_API_KEY:=} + access_token: ${env.GEMINI_ACCESS_TOKEN:=} + project: ${env.GEMINI_AI_PROJECT:=} + vector_io: + - provider_id: ${env.MILVUS_ENDPOINT:+milvus-remote} + provider_type: remote::milvus + config: + uri: ${env.MILVUS_ENDPOINT:=} + token: ${env.MILVUS_TOKEN:=} + secure: ${env.MILVUS_SECURE:=} + consistency_level: ${env.MILVUS_CONSISTENCY_LEVEL:=} + ca_pem_path: ${env.MILVUS_CA_PEM_PATH:=} + client_pem_path: ${env.MILVUS_CLIENT_PEM_PATH:=} + client_key_path: ${env.MILVUS_CLIENT_KEY_PATH:=} + persistence: + backend: kv_default + namespace: vector_io::milvus_remote + - provider_id: ${env.ENABLE_PGVECTOR:+pgvector} + provider_type: remote::pgvector + config: + host: ${env.PGVECTOR_HOST:=localhost} + port: ${env.PGVECTOR_PORT:=5432} + db: ${env.PGVECTOR_DB:=} + user: ${env.PGVECTOR_USER:=} + password: ${env.PGVECTOR_PASSWORD:=} + persistence: + backend: kv_default + namespace: vector_io::pgvector + - provider_id: ${env.ENABLE_QDRANT:+qdrant-remote} + provider_type: remote::qdrant + config: + location: ${env.QDRANT_LOCATION:=} + url: ${env.QDRANT_URL:=} + port: ${env.QDRANT_PORT:=6333} + grpc_port: ${env.QDRANT_GRPC_PORT:=6334} + prefer_grpc: ${env.QDRANT_PREFER_GRPC:=false} + https: ${env.QDRANT_HTTPS:=} + api_key: ${env.QDRANT_API_KEY:=} + prefix: ${env.QDRANT_PREFIX:=} + timeout: ${env.QDRANT_TIMEOUT:=} + host: ${env.QDRANT_HOST:=} + persistence: + backend: kv_default + namespace: vector_io::qdrant_remote + responses: + - provider_id: meta-reference + provider_type: inline::builtin + config: + persistence: + agent_state: + backend: kv_default + namespace: agents::meta_reference + responses: + backend: sql_default + table_name: agents_responses + max_write_queue_size: 10000 + num_writers: 4 + tool_runtime: + - provider_id: brave-search + provider_type: remote::brave-search + config: + api_key: ${env.BRAVE_SEARCH_API_KEY:=} + max_results: 3 + - provider_id: tavily-search + provider_type: remote::tavily-search + config: + api_key: ${env.TAVILY_SEARCH_API_KEY:=} + max_results: 3 + - provider_id: file-search + provider_type: inline::file-search + config: {} + - provider_id: model-context-protocol + provider_type: remote::model-context-protocol + config: {} + files: + - provider_id: meta-reference-files + provider_type: inline::localfs + config: + storage_dir: /opt/app-root/src/.ogx/distributions/rh/files + metadata_store: + backend: sql_default + table_name: files_metadata + - provider_id: ${env.ENABLE_S3:+s3} + provider_type: remote::s3 + config: + bucket_name: ${env.S3_BUCKET_NAME:=} + region: ${env.AWS_DEFAULT_REGION:=us-east-1} + aws_access_key_id: ${env.AWS_ACCESS_KEY_ID:=} + aws_secret_access_key: ${env.AWS_SECRET_ACCESS_KEY:=} + endpoint_url: ${env.S3_ENDPOINT_URL:=} + auto_create_bucket: ${env.S3_AUTO_CREATE_BUCKET:=false} + metadata_store: + backend: sql_default + table_name: files_metadata + file_processors: + - provider_id: pypdf + provider_type: inline::pypdf + config: {} + batches: + - provider_id: reference + provider_type: inline::reference + config: + sqlstore: + table_name: batches + backend: sql_default +storage: + backends: + kv_default: + type: kv_postgres + host: ${env.POSTGRES_HOST:=localhost} + port: ${env.POSTGRES_PORT:=5432} + db: ${env.POSTGRES_DB:=ogx} + user: ${env.POSTGRES_USER:=ogx} + password: ${env.POSTGRES_PASSWORD:=ogx} + table_name: ${env.POSTGRES_TABLE_NAME:=ogx_kvstore} + sql_default: + type: sql_postgres + host: ${env.POSTGRES_HOST:=localhost} + port: ${env.POSTGRES_PORT:=5432} + db: ${env.POSTGRES_DB:=ogx} + user: ${env.POSTGRES_USER:=ogx} + password: ${env.POSTGRES_PASSWORD:=ogx} + stores: + metadata: + backend: kv_default + namespace: registry + inference: + table_name: inference_store + backend: sql_default + max_write_queue_size: 10000 + num_writers: 4 + conversations: + table_name: openai_conversations + backend: sql_default + prompts: + namespace: prompts + backend: kv_default + connectors: + table_name: connectors + backend: sql_default +registered_resources: + models: + - metadata: + embedding_dimension: ${env.EMBEDDING_DIMENSION:=768} + model_id: ${env.EMBEDDING_MODEL:=} + provider_id: ${env.EMBEDDING_PROVIDER:=vllm-embedding} + provider_model_id: ${env.EMBEDDING_PROVIDER_MODEL_ID:=ibm-granite/granite-embedding-125m-english} + model_type: embedding + vector_stores: [] +vector_stores: + annotation_prompt_params: + enable_annotations: true + annotation_instruction_template: "Cite sources immediately at the end of sentences using <|file-id|> format." +telemetry: + enabled: true +server: + port: 8321 + auth: + provider_config: + type: ${env.AUTH_ISSUER:+oauth2_token} + audience: ${env.AUTH_AUDIENCE:=ogx} + issuer: ${env.AUTH_ISSUER:=} + jwks: + uri: ${env.AUTH_JWKS_URI:=} + key_recheck_period: ${env.AUTH_JWKS_RECHECK_PERIOD:=3600} + verify_tls: ${env.AUTH_VERIFY_TLS:=true} + access_policy: + - permit: + actions: [read] + when: resource is unowned + description: "All users can read system resources" + - permit: + actions: [create] + description: "Authenticated users can create resources" + - permit: + actions: [read, update, delete] + when: user is owner + description: "Owners can manage their own resources" diff --git a/cmd/configgen/testdata/rh-disable-apis/cr.yaml b/cmd/configgen/testdata/rh-disable-apis/cr.yaml new file mode 100644 index 000000000..4d3f24a1b --- /dev/null +++ b/cmd/configgen/testdata/rh-disable-apis/cr.yaml @@ -0,0 +1,10 @@ +apiVersion: ogx.io/v1beta1 +kind: OGXServer +metadata: + name: test-rh-disable-apis +spec: + distribution: + name: rh + disabledAPIs: + - batches + - file_processors diff --git a/cmd/configgen/testdata/rh-disable-apis/want-config.yaml b/cmd/configgen/testdata/rh-disable-apis/want-config.yaml new file mode 100644 index 000000000..d897b4ef5 --- /dev/null +++ b/cmd/configgen/testdata/rh-disable-apis/want-config.yaml @@ -0,0 +1,236 @@ +apis: + - responses + - inference + - tool_runtime + - vector_io + - files +distro_name: rh +providers: + batches: + - config: + sqlstore: + backend: sql_default + table_name: batches + provider_id: reference + provider_type: inline::reference + file_processors: + - config: {} + provider_id: pypdf + provider_type: inline::pypdf + files: + - config: + metadata_store: + backend: sql_default + table_name: files_metadata + storage_dir: /opt/app-root/src/.ogx/distributions/rh/files + provider_id: meta-reference-files + provider_type: inline::localfs + - config: + auto_create_bucket: ${env.S3_AUTO_CREATE_BUCKET:=false} + aws_access_key_id: ${env.AWS_ACCESS_KEY_ID:=} + aws_secret_access_key: ${env.AWS_SECRET_ACCESS_KEY:=} + bucket_name: ${env.S3_BUCKET_NAME:=} + endpoint_url: ${env.S3_ENDPOINT_URL:=} + metadata_store: + backend: sql_default + table_name: files_metadata + region: ${env.AWS_DEFAULT_REGION:=us-east-1} + provider_id: ${env.ENABLE_S3:+s3} + provider_type: remote::s3 + inference: + - config: + api_token: ${env.VLLM_API_TOKEN:=fake} + base_url: ${env.VLLM_URL:=} + max_tokens: ${env.VLLM_MAX_TOKENS:=4096} + refresh_models: ${env.VLLM_REFRESH_MODELS:=false} + tls_verify: ${env.VLLM_TLS_VERIFY:=true} + provider_id: ${env.VLLM_URL:+vllm-inference} + provider_type: remote::vllm + - config: + api_token: ${env.VLLM_EMBEDDING_API_TOKEN:=fake} + base_url: ${env.VLLM_EMBEDDING_URL:=} + max_tokens: ${env.VLLM_EMBEDDING_MAX_TOKENS:=4096} + refresh_models: ${env.VLLM_EMBEDDING_REFRESH_MODELS:=false} + tls_verify: ${env.VLLM_EMBEDDING_TLS_VERIFY:=true} + provider_id: ${env.VLLM_EMBEDDING_URL:+vllm-embedding} + provider_type: remote::vllm + - config: + api_key: ${env.AWS_BEARER_TOKEN_BEDROCK:=} + region_name: ${env.AWS_DEFAULT_REGION:=us-east-2} + provider_id: ${env.AWS_BEARER_TOKEN_BEDROCK:+bedrock} + provider_type: remote::bedrock + - config: + api_key: ${env.WATSONX_API_KEY:=} + base_url: ${env.WATSONX_BASE_URL:=https://us-south.ml.cloud.ibm.com} + project_id: ${env.WATSONX_PROJECT_ID:=} + provider_id: ${env.WATSONX_API_KEY:+watsonx} + provider_type: remote::watsonx + - config: + api_base: ${env.AZURE_API_BASE:=} + api_key: ${env.AZURE_API_KEY:=} + api_type: ${env.AZURE_API_TYPE:=} + api_version: ${env.AZURE_API_VERSION:=} + provider_id: ${env.AZURE_API_KEY:+azure} + provider_type: remote::azure + - config: + project: ${env.VERTEX_AI_PROJECT:=} + provider_id: ${env.VERTEX_AI_PROJECT:+vertexai} + provider_type: remote::vertexai + - config: + api_key: ${env.OPENAI_API_KEY:=} + base_url: ${env.OPENAI_BASE_URL:=https://api.openai.com/v1} + provider_id: ${env.OPENAI_API_KEY:+openai} + provider_type: remote::openai + - config: + access_token: ${env.GEMINI_ACCESS_TOKEN:=} + api_key: ${env.GEMINI_API_KEY:=} + project: ${env.GEMINI_AI_PROJECT:=} + provider_id: ${env.ENABLE_GEMINI:+gemini} + provider_type: remote::gemini + responses: + - config: + persistence: + agent_state: + backend: kv_default + namespace: agents::meta_reference + responses: + backend: sql_default + max_write_queue_size: 10000 + num_writers: 4 + table_name: agents_responses + provider_id: meta-reference + provider_type: inline::builtin + tool_runtime: + - config: + api_key: ${env.BRAVE_SEARCH_API_KEY:=} + max_results: 3 + provider_id: brave-search + provider_type: remote::brave-search + - config: + api_key: ${env.TAVILY_SEARCH_API_KEY:=} + max_results: 3 + provider_id: tavily-search + provider_type: remote::tavily-search + - config: {} + provider_id: file-search + provider_type: inline::file-search + - config: {} + provider_id: model-context-protocol + provider_type: remote::model-context-protocol + vector_io: + - config: + ca_pem_path: ${env.MILVUS_CA_PEM_PATH:=} + client_key_path: ${env.MILVUS_CLIENT_KEY_PATH:=} + client_pem_path: ${env.MILVUS_CLIENT_PEM_PATH:=} + consistency_level: ${env.MILVUS_CONSISTENCY_LEVEL:=} + persistence: + backend: kv_default + namespace: vector_io::milvus_remote + secure: ${env.MILVUS_SECURE:=} + token: ${env.MILVUS_TOKEN:=} + uri: ${env.MILVUS_ENDPOINT:=} + provider_id: ${env.MILVUS_ENDPOINT:+milvus-remote} + provider_type: remote::milvus + - config: + db: ${env.PGVECTOR_DB:=} + host: ${env.PGVECTOR_HOST:=localhost} + password: ${env.PGVECTOR_PASSWORD:=} + persistence: + backend: kv_default + namespace: vector_io::pgvector + port: ${env.PGVECTOR_PORT:=5432} + user: ${env.PGVECTOR_USER:=} + provider_id: ${env.ENABLE_PGVECTOR:+pgvector} + provider_type: remote::pgvector + - config: + api_key: ${env.QDRANT_API_KEY:=} + grpc_port: ${env.QDRANT_GRPC_PORT:=6334} + host: ${env.QDRANT_HOST:=} + https: ${env.QDRANT_HTTPS:=} + location: ${env.QDRANT_LOCATION:=} + persistence: + backend: kv_default + namespace: vector_io::qdrant_remote + port: ${env.QDRANT_PORT:=6333} + prefer_grpc: ${env.QDRANT_PREFER_GRPC:=false} + prefix: ${env.QDRANT_PREFIX:=} + timeout: ${env.QDRANT_TIMEOUT:=} + url: ${env.QDRANT_URL:=} + provider_id: ${env.ENABLE_QDRANT:+qdrant-remote} + provider_type: remote::qdrant +registered_resources: + models: + - metadata: + embedding_dimension: ${env.EMBEDDING_DIMENSION:=768} + model_id: ${env.EMBEDDING_MODEL:=} + model_type: embedding + provider_id: ${env.EMBEDDING_PROVIDER:=vllm-embedding} + provider_model_id: ${env.EMBEDDING_PROVIDER_MODEL_ID:=ibm-granite/granite-embedding-125m-english} +server: + auth: + access_policy: + - description: All users can read system resources + permit: + actions: + - read + when: resource is unowned + - description: Authenticated users can create resources + permit: + actions: + - create + - description: Owners can manage their own resources + permit: + actions: + - read + - update + - delete + when: user is owner + provider_config: + audience: ${env.AUTH_AUDIENCE:=ogx} + issuer: ${env.AUTH_ISSUER:=} + jwks: + key_recheck_period: ${env.AUTH_JWKS_RECHECK_PERIOD:=3600} + uri: ${env.AUTH_JWKS_URI:=} + type: ${env.AUTH_ISSUER:+oauth2_token} + verify_tls: ${env.AUTH_VERIFY_TLS:=true} + port: 8321 +storage: + backends: + kv_default: + db: ${env.POSTGRES_DB:=ogx} + host: ${env.POSTGRES_HOST:=localhost} + password: ${env.POSTGRES_PASSWORD:=ogx} + port: ${env.POSTGRES_PORT:=5432} + table_name: ${env.POSTGRES_TABLE_NAME:=ogx_kvstore} + type: kv_postgres + user: ${env.POSTGRES_USER:=ogx} + sql_default: + db: ${env.POSTGRES_DB:=ogx} + host: ${env.POSTGRES_HOST:=localhost} + password: ${env.POSTGRES_PASSWORD:=ogx} + port: ${env.POSTGRES_PORT:=5432} + type: sql_postgres + user: ${env.POSTGRES_USER:=ogx} + stores: + connectors: + backend: sql_default + table_name: connectors + conversations: + backend: sql_default + table_name: openai_conversations + inference: + backend: sql_default + max_write_queue_size: 10000 + num_writers: 4 + table_name: inference_store + metadata: + backend: kv_default + namespace: registry + prompts: + backend: kv_default + namespace: prompts +vector_stores: + annotation_prompt_params: + annotation_instruction_template: Cite sources immediately at the end of sentences using <|file-id|> format. + enable_annotations: true +version: "2" diff --git a/cmd/configgen/testdata/rh-override-inference/base.yaml b/cmd/configgen/testdata/rh-override-inference/base.yaml new file mode 100644 index 000000000..911e4eb77 --- /dev/null +++ b/cmd/configgen/testdata/rh-override-inference/base.yaml @@ -0,0 +1,236 @@ +version: 2 +image_name: rh +apis: +- responses +- batches +- inference +- tool_runtime +- vector_io +- files +- file_processors +providers: + inference: + - provider_id: ${env.VLLM_URL:+vllm-inference} + provider_type: remote::vllm + config: + base_url: ${env.VLLM_URL:=} + max_tokens: ${env.VLLM_MAX_TOKENS:=4096} + api_token: ${env.VLLM_API_TOKEN:=fake} + tls_verify: ${env.VLLM_TLS_VERIFY:=true} + refresh_models: ${env.VLLM_REFRESH_MODELS:=false} + - provider_id: ${env.VLLM_EMBEDDING_URL:+vllm-embedding} + provider_type: remote::vllm + config: + base_url: ${env.VLLM_EMBEDDING_URL:=} + max_tokens: ${env.VLLM_EMBEDDING_MAX_TOKENS:=4096} + api_token: ${env.VLLM_EMBEDDING_API_TOKEN:=fake} + tls_verify: ${env.VLLM_EMBEDDING_TLS_VERIFY:=true} + refresh_models: ${env.VLLM_EMBEDDING_REFRESH_MODELS:=false} + - provider_id: ${env.AWS_BEARER_TOKEN_BEDROCK:+bedrock} + provider_type: remote::bedrock + config: + api_key: ${env.AWS_BEARER_TOKEN_BEDROCK:=} + region_name: ${env.AWS_DEFAULT_REGION:=us-east-2} + - provider_id: ${env.WATSONX_API_KEY:+watsonx} + provider_type: remote::watsonx + config: + base_url: ${env.WATSONX_BASE_URL:=https://us-south.ml.cloud.ibm.com} + api_key: ${env.WATSONX_API_KEY:=} + project_id: ${env.WATSONX_PROJECT_ID:=} + - provider_id: ${env.AZURE_API_KEY:+azure} + provider_type: remote::azure + config: + api_key: ${env.AZURE_API_KEY:=} + api_base: ${env.AZURE_API_BASE:=} + api_version: ${env.AZURE_API_VERSION:=} + api_type: ${env.AZURE_API_TYPE:=} + - provider_id: ${env.VERTEX_AI_PROJECT:+vertexai} + provider_type: remote::vertexai + config: + project: ${env.VERTEX_AI_PROJECT:=} + - provider_id: ${env.OPENAI_API_KEY:+openai} + provider_type: remote::openai + config: + api_key: ${env.OPENAI_API_KEY:=} + base_url: ${env.OPENAI_BASE_URL:=https://api.openai.com/v1} + - provider_id: ${env.ENABLE_GEMINI:+gemini} + provider_type: remote::gemini + config: + api_key: ${env.GEMINI_API_KEY:=} + access_token: ${env.GEMINI_ACCESS_TOKEN:=} + project: ${env.GEMINI_AI_PROJECT:=} + vector_io: + - provider_id: ${env.MILVUS_ENDPOINT:+milvus-remote} + provider_type: remote::milvus + config: + uri: ${env.MILVUS_ENDPOINT:=} + token: ${env.MILVUS_TOKEN:=} + secure: ${env.MILVUS_SECURE:=} + consistency_level: ${env.MILVUS_CONSISTENCY_LEVEL:=} + ca_pem_path: ${env.MILVUS_CA_PEM_PATH:=} + client_pem_path: ${env.MILVUS_CLIENT_PEM_PATH:=} + client_key_path: ${env.MILVUS_CLIENT_KEY_PATH:=} + persistence: + backend: kv_default + namespace: vector_io::milvus_remote + - provider_id: ${env.ENABLE_PGVECTOR:+pgvector} + provider_type: remote::pgvector + config: + host: ${env.PGVECTOR_HOST:=localhost} + port: ${env.PGVECTOR_PORT:=5432} + db: ${env.PGVECTOR_DB:=} + user: ${env.PGVECTOR_USER:=} + password: ${env.PGVECTOR_PASSWORD:=} + persistence: + backend: kv_default + namespace: vector_io::pgvector + - provider_id: ${env.ENABLE_QDRANT:+qdrant-remote} + provider_type: remote::qdrant + config: + location: ${env.QDRANT_LOCATION:=} + url: ${env.QDRANT_URL:=} + port: ${env.QDRANT_PORT:=6333} + grpc_port: ${env.QDRANT_GRPC_PORT:=6334} + prefer_grpc: ${env.QDRANT_PREFER_GRPC:=false} + https: ${env.QDRANT_HTTPS:=} + api_key: ${env.QDRANT_API_KEY:=} + prefix: ${env.QDRANT_PREFIX:=} + timeout: ${env.QDRANT_TIMEOUT:=} + host: ${env.QDRANT_HOST:=} + persistence: + backend: kv_default + namespace: vector_io::qdrant_remote + responses: + - provider_id: meta-reference + provider_type: inline::builtin + config: + persistence: + agent_state: + backend: kv_default + namespace: agents::meta_reference + responses: + backend: sql_default + table_name: agents_responses + max_write_queue_size: 10000 + num_writers: 4 + tool_runtime: + - provider_id: brave-search + provider_type: remote::brave-search + config: + api_key: ${env.BRAVE_SEARCH_API_KEY:=} + max_results: 3 + - provider_id: tavily-search + provider_type: remote::tavily-search + config: + api_key: ${env.TAVILY_SEARCH_API_KEY:=} + max_results: 3 + - provider_id: file-search + provider_type: inline::file-search + config: {} + - provider_id: model-context-protocol + provider_type: remote::model-context-protocol + config: {} + files: + - provider_id: meta-reference-files + provider_type: inline::localfs + config: + storage_dir: /opt/app-root/src/.ogx/distributions/rh/files + metadata_store: + backend: sql_default + table_name: files_metadata + - provider_id: ${env.ENABLE_S3:+s3} + provider_type: remote::s3 + config: + bucket_name: ${env.S3_BUCKET_NAME:=} + region: ${env.AWS_DEFAULT_REGION:=us-east-1} + aws_access_key_id: ${env.AWS_ACCESS_KEY_ID:=} + aws_secret_access_key: ${env.AWS_SECRET_ACCESS_KEY:=} + endpoint_url: ${env.S3_ENDPOINT_URL:=} + auto_create_bucket: ${env.S3_AUTO_CREATE_BUCKET:=false} + metadata_store: + backend: sql_default + table_name: files_metadata + file_processors: + - provider_id: pypdf + provider_type: inline::pypdf + config: {} + batches: + - provider_id: reference + provider_type: inline::reference + config: + sqlstore: + table_name: batches + backend: sql_default +storage: + backends: + kv_default: + type: kv_postgres + host: ${env.POSTGRES_HOST:=localhost} + port: ${env.POSTGRES_PORT:=5432} + db: ${env.POSTGRES_DB:=ogx} + user: ${env.POSTGRES_USER:=ogx} + password: ${env.POSTGRES_PASSWORD:=ogx} + table_name: ${env.POSTGRES_TABLE_NAME:=ogx_kvstore} + sql_default: + type: sql_postgres + host: ${env.POSTGRES_HOST:=localhost} + port: ${env.POSTGRES_PORT:=5432} + db: ${env.POSTGRES_DB:=ogx} + user: ${env.POSTGRES_USER:=ogx} + password: ${env.POSTGRES_PASSWORD:=ogx} + stores: + metadata: + backend: kv_default + namespace: registry + inference: + table_name: inference_store + backend: sql_default + max_write_queue_size: 10000 + num_writers: 4 + conversations: + table_name: openai_conversations + backend: sql_default + prompts: + namespace: prompts + backend: kv_default + connectors: + table_name: connectors + backend: sql_default +registered_resources: + models: + - metadata: + embedding_dimension: ${env.EMBEDDING_DIMENSION:=768} + model_id: ${env.EMBEDDING_MODEL:=} + provider_id: ${env.EMBEDDING_PROVIDER:=vllm-embedding} + provider_model_id: ${env.EMBEDDING_PROVIDER_MODEL_ID:=ibm-granite/granite-embedding-125m-english} + model_type: embedding + vector_stores: [] +vector_stores: + annotation_prompt_params: + enable_annotations: true + annotation_instruction_template: "Cite sources immediately at the end of sentences using <|file-id|> format." +telemetry: + enabled: true +server: + port: 8321 + auth: + provider_config: + type: ${env.AUTH_ISSUER:+oauth2_token} + audience: ${env.AUTH_AUDIENCE:=ogx} + issuer: ${env.AUTH_ISSUER:=} + jwks: + uri: ${env.AUTH_JWKS_URI:=} + key_recheck_period: ${env.AUTH_JWKS_RECHECK_PERIOD:=3600} + verify_tls: ${env.AUTH_VERIFY_TLS:=true} + access_policy: + - permit: + actions: [read] + when: resource is unowned + description: "All users can read system resources" + - permit: + actions: [create] + description: "Authenticated users can create resources" + - permit: + actions: [read, update, delete] + when: user is owner + description: "Owners can manage their own resources" diff --git a/cmd/configgen/testdata/rh-override-inference/cr.yaml b/cmd/configgen/testdata/rh-override-inference/cr.yaml new file mode 100644 index 000000000..a846f5310 --- /dev/null +++ b/cmd/configgen/testdata/rh-override-inference/cr.yaml @@ -0,0 +1,12 @@ +apiVersion: ogx.io/v1beta1 +kind: OGXServer +metadata: + name: test-rh-override-inference +spec: + distribution: + name: rh + providers: + inference: + remote: + vllm: + - endpoint: https://my-vllm:8000 diff --git a/cmd/configgen/testdata/rh-override-inference/want-config.yaml b/cmd/configgen/testdata/rh-override-inference/want-config.yaml new file mode 100644 index 000000000..3f8226b65 --- /dev/null +++ b/cmd/configgen/testdata/rh-override-inference/want-config.yaml @@ -0,0 +1,193 @@ +apis: + - responses + - batches + - inference + - tool_runtime + - vector_io + - files + - file_processors +distro_name: rh +providers: + batches: + - config: + sqlstore: + backend: sql_default + table_name: batches + provider_id: reference + provider_type: inline::reference + file_processors: + - config: {} + provider_id: pypdf + provider_type: inline::pypdf + files: + - config: + metadata_store: + backend: sql_default + table_name: files_metadata + storage_dir: /opt/app-root/src/.ogx/distributions/rh/files + provider_id: meta-reference-files + provider_type: inline::localfs + - config: + auto_create_bucket: ${env.S3_AUTO_CREATE_BUCKET:=false} + aws_access_key_id: ${env.AWS_ACCESS_KEY_ID:=} + aws_secret_access_key: ${env.AWS_SECRET_ACCESS_KEY:=} + bucket_name: ${env.S3_BUCKET_NAME:=} + endpoint_url: ${env.S3_ENDPOINT_URL:=} + metadata_store: + backend: sql_default + table_name: files_metadata + region: ${env.AWS_DEFAULT_REGION:=us-east-1} + provider_id: ${env.ENABLE_S3:+s3} + provider_type: remote::s3 + inference: + - config: + base_url: https://my-vllm:8000 + provider_id: remote-vllm + provider_type: remote::vllm + responses: + - config: + persistence: + agent_state: + backend: kv_default + namespace: agents::meta_reference + responses: + backend: sql_default + max_write_queue_size: 10000 + num_writers: 4 + table_name: agents_responses + provider_id: meta-reference + provider_type: inline::builtin + tool_runtime: + - config: + api_key: ${env.BRAVE_SEARCH_API_KEY:=} + max_results: 3 + provider_id: brave-search + provider_type: remote::brave-search + - config: + api_key: ${env.TAVILY_SEARCH_API_KEY:=} + max_results: 3 + provider_id: tavily-search + provider_type: remote::tavily-search + - config: {} + provider_id: file-search + provider_type: inline::file-search + - config: {} + provider_id: model-context-protocol + provider_type: remote::model-context-protocol + vector_io: + - config: + ca_pem_path: ${env.MILVUS_CA_PEM_PATH:=} + client_key_path: ${env.MILVUS_CLIENT_KEY_PATH:=} + client_pem_path: ${env.MILVUS_CLIENT_PEM_PATH:=} + consistency_level: ${env.MILVUS_CONSISTENCY_LEVEL:=} + persistence: + backend: kv_default + namespace: vector_io::milvus_remote + secure: ${env.MILVUS_SECURE:=} + token: ${env.MILVUS_TOKEN:=} + uri: ${env.MILVUS_ENDPOINT:=} + provider_id: ${env.MILVUS_ENDPOINT:+milvus-remote} + provider_type: remote::milvus + - config: + db: ${env.PGVECTOR_DB:=} + host: ${env.PGVECTOR_HOST:=localhost} + password: ${env.PGVECTOR_PASSWORD:=} + persistence: + backend: kv_default + namespace: vector_io::pgvector + port: ${env.PGVECTOR_PORT:=5432} + user: ${env.PGVECTOR_USER:=} + provider_id: ${env.ENABLE_PGVECTOR:+pgvector} + provider_type: remote::pgvector + - config: + api_key: ${env.QDRANT_API_KEY:=} + grpc_port: ${env.QDRANT_GRPC_PORT:=6334} + host: ${env.QDRANT_HOST:=} + https: ${env.QDRANT_HTTPS:=} + location: ${env.QDRANT_LOCATION:=} + persistence: + backend: kv_default + namespace: vector_io::qdrant_remote + port: ${env.QDRANT_PORT:=6333} + prefer_grpc: ${env.QDRANT_PREFER_GRPC:=false} + prefix: ${env.QDRANT_PREFIX:=} + timeout: ${env.QDRANT_TIMEOUT:=} + url: ${env.QDRANT_URL:=} + provider_id: ${env.ENABLE_QDRANT:+qdrant-remote} + provider_type: remote::qdrant +registered_resources: + models: + - metadata: + embedding_dimension: ${env.EMBEDDING_DIMENSION:=768} + model_id: ${env.EMBEDDING_MODEL:=} + model_type: embedding + provider_id: ${env.EMBEDDING_PROVIDER:=vllm-embedding} + provider_model_id: ${env.EMBEDDING_PROVIDER_MODEL_ID:=ibm-granite/granite-embedding-125m-english} +server: + auth: + access_policy: + - description: All users can read system resources + permit: + actions: + - read + when: resource is unowned + - description: Authenticated users can create resources + permit: + actions: + - create + - description: Owners can manage their own resources + permit: + actions: + - read + - update + - delete + when: user is owner + provider_config: + audience: ${env.AUTH_AUDIENCE:=ogx} + issuer: ${env.AUTH_ISSUER:=} + jwks: + key_recheck_period: ${env.AUTH_JWKS_RECHECK_PERIOD:=3600} + uri: ${env.AUTH_JWKS_URI:=} + type: ${env.AUTH_ISSUER:+oauth2_token} + verify_tls: ${env.AUTH_VERIFY_TLS:=true} + port: 8321 +storage: + backends: + kv_default: + db: ${env.POSTGRES_DB:=ogx} + host: ${env.POSTGRES_HOST:=localhost} + password: ${env.POSTGRES_PASSWORD:=ogx} + port: ${env.POSTGRES_PORT:=5432} + table_name: ${env.POSTGRES_TABLE_NAME:=ogx_kvstore} + type: kv_postgres + user: ${env.POSTGRES_USER:=ogx} + sql_default: + db: ${env.POSTGRES_DB:=ogx} + host: ${env.POSTGRES_HOST:=localhost} + password: ${env.POSTGRES_PASSWORD:=ogx} + port: ${env.POSTGRES_PORT:=5432} + type: sql_postgres + user: ${env.POSTGRES_USER:=ogx} + stores: + connectors: + backend: sql_default + table_name: connectors + conversations: + backend: sql_default + table_name: openai_conversations + inference: + backend: sql_default + max_write_queue_size: 10000 + num_writers: 4 + table_name: inference_store + metadata: + backend: kv_default + namespace: registry + prompts: + backend: kv_default + namespace: prompts +vector_stores: + annotation_prompt_params: + annotation_instruction_template: Cite sources immediately at the end of sentences using <|file-id|> format. + enable_annotations: true +version: "2" diff --git a/cmd/configgen/testdata/rh-override-storage/base.yaml b/cmd/configgen/testdata/rh-override-storage/base.yaml new file mode 100644 index 000000000..911e4eb77 --- /dev/null +++ b/cmd/configgen/testdata/rh-override-storage/base.yaml @@ -0,0 +1,236 @@ +version: 2 +image_name: rh +apis: +- responses +- batches +- inference +- tool_runtime +- vector_io +- files +- file_processors +providers: + inference: + - provider_id: ${env.VLLM_URL:+vllm-inference} + provider_type: remote::vllm + config: + base_url: ${env.VLLM_URL:=} + max_tokens: ${env.VLLM_MAX_TOKENS:=4096} + api_token: ${env.VLLM_API_TOKEN:=fake} + tls_verify: ${env.VLLM_TLS_VERIFY:=true} + refresh_models: ${env.VLLM_REFRESH_MODELS:=false} + - provider_id: ${env.VLLM_EMBEDDING_URL:+vllm-embedding} + provider_type: remote::vllm + config: + base_url: ${env.VLLM_EMBEDDING_URL:=} + max_tokens: ${env.VLLM_EMBEDDING_MAX_TOKENS:=4096} + api_token: ${env.VLLM_EMBEDDING_API_TOKEN:=fake} + tls_verify: ${env.VLLM_EMBEDDING_TLS_VERIFY:=true} + refresh_models: ${env.VLLM_EMBEDDING_REFRESH_MODELS:=false} + - provider_id: ${env.AWS_BEARER_TOKEN_BEDROCK:+bedrock} + provider_type: remote::bedrock + config: + api_key: ${env.AWS_BEARER_TOKEN_BEDROCK:=} + region_name: ${env.AWS_DEFAULT_REGION:=us-east-2} + - provider_id: ${env.WATSONX_API_KEY:+watsonx} + provider_type: remote::watsonx + config: + base_url: ${env.WATSONX_BASE_URL:=https://us-south.ml.cloud.ibm.com} + api_key: ${env.WATSONX_API_KEY:=} + project_id: ${env.WATSONX_PROJECT_ID:=} + - provider_id: ${env.AZURE_API_KEY:+azure} + provider_type: remote::azure + config: + api_key: ${env.AZURE_API_KEY:=} + api_base: ${env.AZURE_API_BASE:=} + api_version: ${env.AZURE_API_VERSION:=} + api_type: ${env.AZURE_API_TYPE:=} + - provider_id: ${env.VERTEX_AI_PROJECT:+vertexai} + provider_type: remote::vertexai + config: + project: ${env.VERTEX_AI_PROJECT:=} + - provider_id: ${env.OPENAI_API_KEY:+openai} + provider_type: remote::openai + config: + api_key: ${env.OPENAI_API_KEY:=} + base_url: ${env.OPENAI_BASE_URL:=https://api.openai.com/v1} + - provider_id: ${env.ENABLE_GEMINI:+gemini} + provider_type: remote::gemini + config: + api_key: ${env.GEMINI_API_KEY:=} + access_token: ${env.GEMINI_ACCESS_TOKEN:=} + project: ${env.GEMINI_AI_PROJECT:=} + vector_io: + - provider_id: ${env.MILVUS_ENDPOINT:+milvus-remote} + provider_type: remote::milvus + config: + uri: ${env.MILVUS_ENDPOINT:=} + token: ${env.MILVUS_TOKEN:=} + secure: ${env.MILVUS_SECURE:=} + consistency_level: ${env.MILVUS_CONSISTENCY_LEVEL:=} + ca_pem_path: ${env.MILVUS_CA_PEM_PATH:=} + client_pem_path: ${env.MILVUS_CLIENT_PEM_PATH:=} + client_key_path: ${env.MILVUS_CLIENT_KEY_PATH:=} + persistence: + backend: kv_default + namespace: vector_io::milvus_remote + - provider_id: ${env.ENABLE_PGVECTOR:+pgvector} + provider_type: remote::pgvector + config: + host: ${env.PGVECTOR_HOST:=localhost} + port: ${env.PGVECTOR_PORT:=5432} + db: ${env.PGVECTOR_DB:=} + user: ${env.PGVECTOR_USER:=} + password: ${env.PGVECTOR_PASSWORD:=} + persistence: + backend: kv_default + namespace: vector_io::pgvector + - provider_id: ${env.ENABLE_QDRANT:+qdrant-remote} + provider_type: remote::qdrant + config: + location: ${env.QDRANT_LOCATION:=} + url: ${env.QDRANT_URL:=} + port: ${env.QDRANT_PORT:=6333} + grpc_port: ${env.QDRANT_GRPC_PORT:=6334} + prefer_grpc: ${env.QDRANT_PREFER_GRPC:=false} + https: ${env.QDRANT_HTTPS:=} + api_key: ${env.QDRANT_API_KEY:=} + prefix: ${env.QDRANT_PREFIX:=} + timeout: ${env.QDRANT_TIMEOUT:=} + host: ${env.QDRANT_HOST:=} + persistence: + backend: kv_default + namespace: vector_io::qdrant_remote + responses: + - provider_id: meta-reference + provider_type: inline::builtin + config: + persistence: + agent_state: + backend: kv_default + namespace: agents::meta_reference + responses: + backend: sql_default + table_name: agents_responses + max_write_queue_size: 10000 + num_writers: 4 + tool_runtime: + - provider_id: brave-search + provider_type: remote::brave-search + config: + api_key: ${env.BRAVE_SEARCH_API_KEY:=} + max_results: 3 + - provider_id: tavily-search + provider_type: remote::tavily-search + config: + api_key: ${env.TAVILY_SEARCH_API_KEY:=} + max_results: 3 + - provider_id: file-search + provider_type: inline::file-search + config: {} + - provider_id: model-context-protocol + provider_type: remote::model-context-protocol + config: {} + files: + - provider_id: meta-reference-files + provider_type: inline::localfs + config: + storage_dir: /opt/app-root/src/.ogx/distributions/rh/files + metadata_store: + backend: sql_default + table_name: files_metadata + - provider_id: ${env.ENABLE_S3:+s3} + provider_type: remote::s3 + config: + bucket_name: ${env.S3_BUCKET_NAME:=} + region: ${env.AWS_DEFAULT_REGION:=us-east-1} + aws_access_key_id: ${env.AWS_ACCESS_KEY_ID:=} + aws_secret_access_key: ${env.AWS_SECRET_ACCESS_KEY:=} + endpoint_url: ${env.S3_ENDPOINT_URL:=} + auto_create_bucket: ${env.S3_AUTO_CREATE_BUCKET:=false} + metadata_store: + backend: sql_default + table_name: files_metadata + file_processors: + - provider_id: pypdf + provider_type: inline::pypdf + config: {} + batches: + - provider_id: reference + provider_type: inline::reference + config: + sqlstore: + table_name: batches + backend: sql_default +storage: + backends: + kv_default: + type: kv_postgres + host: ${env.POSTGRES_HOST:=localhost} + port: ${env.POSTGRES_PORT:=5432} + db: ${env.POSTGRES_DB:=ogx} + user: ${env.POSTGRES_USER:=ogx} + password: ${env.POSTGRES_PASSWORD:=ogx} + table_name: ${env.POSTGRES_TABLE_NAME:=ogx_kvstore} + sql_default: + type: sql_postgres + host: ${env.POSTGRES_HOST:=localhost} + port: ${env.POSTGRES_PORT:=5432} + db: ${env.POSTGRES_DB:=ogx} + user: ${env.POSTGRES_USER:=ogx} + password: ${env.POSTGRES_PASSWORD:=ogx} + stores: + metadata: + backend: kv_default + namespace: registry + inference: + table_name: inference_store + backend: sql_default + max_write_queue_size: 10000 + num_writers: 4 + conversations: + table_name: openai_conversations + backend: sql_default + prompts: + namespace: prompts + backend: kv_default + connectors: + table_name: connectors + backend: sql_default +registered_resources: + models: + - metadata: + embedding_dimension: ${env.EMBEDDING_DIMENSION:=768} + model_id: ${env.EMBEDDING_MODEL:=} + provider_id: ${env.EMBEDDING_PROVIDER:=vllm-embedding} + provider_model_id: ${env.EMBEDDING_PROVIDER_MODEL_ID:=ibm-granite/granite-embedding-125m-english} + model_type: embedding + vector_stores: [] +vector_stores: + annotation_prompt_params: + enable_annotations: true + annotation_instruction_template: "Cite sources immediately at the end of sentences using <|file-id|> format." +telemetry: + enabled: true +server: + port: 8321 + auth: + provider_config: + type: ${env.AUTH_ISSUER:+oauth2_token} + audience: ${env.AUTH_AUDIENCE:=ogx} + issuer: ${env.AUTH_ISSUER:=} + jwks: + uri: ${env.AUTH_JWKS_URI:=} + key_recheck_period: ${env.AUTH_JWKS_RECHECK_PERIOD:=3600} + verify_tls: ${env.AUTH_VERIFY_TLS:=true} + access_policy: + - permit: + actions: [read] + when: resource is unowned + description: "All users can read system resources" + - permit: + actions: [create] + description: "Authenticated users can create resources" + - permit: + actions: [read, update, delete] + when: user is owner + description: "Owners can manage their own resources" diff --git a/cmd/configgen/testdata/rh-override-storage/cr.yaml b/cmd/configgen/testdata/rh-override-storage/cr.yaml new file mode 100644 index 000000000..390af5631 --- /dev/null +++ b/cmd/configgen/testdata/rh-override-storage/cr.yaml @@ -0,0 +1,15 @@ +apiVersion: ogx.io/v1beta1 +kind: OGXServer +metadata: + name: test-rh-override-storage +spec: + distribution: + name: rh + storage: + kv: + type: sqlite + sql: + type: postgres + connectionString: + name: pg-secret + key: conn-string diff --git a/cmd/configgen/testdata/rh-override-storage/want-config.yaml b/cmd/configgen/testdata/rh-override-storage/want-config.yaml new file mode 100644 index 000000000..606b95fb8 --- /dev/null +++ b/cmd/configgen/testdata/rh-override-storage/want-config.yaml @@ -0,0 +1,224 @@ +apis: + - responses + - batches + - inference + - tool_runtime + - vector_io + - files + - file_processors +distro_name: rh +providers: + batches: + - config: + sqlstore: + backend: sql_default + table_name: batches + provider_id: reference + provider_type: inline::reference + file_processors: + - config: {} + provider_id: pypdf + provider_type: inline::pypdf + files: + - config: + metadata_store: + backend: sql_default + table_name: files_metadata + storage_dir: /opt/app-root/src/.ogx/distributions/rh/files + provider_id: meta-reference-files + provider_type: inline::localfs + - config: + auto_create_bucket: ${env.S3_AUTO_CREATE_BUCKET:=false} + aws_access_key_id: ${env.AWS_ACCESS_KEY_ID:=} + aws_secret_access_key: ${env.AWS_SECRET_ACCESS_KEY:=} + bucket_name: ${env.S3_BUCKET_NAME:=} + endpoint_url: ${env.S3_ENDPOINT_URL:=} + metadata_store: + backend: sql_default + table_name: files_metadata + region: ${env.AWS_DEFAULT_REGION:=us-east-1} + provider_id: ${env.ENABLE_S3:+s3} + provider_type: remote::s3 + inference: + - config: + api_token: ${env.VLLM_API_TOKEN:=fake} + base_url: ${env.VLLM_URL:=} + max_tokens: ${env.VLLM_MAX_TOKENS:=4096} + refresh_models: ${env.VLLM_REFRESH_MODELS:=false} + tls_verify: ${env.VLLM_TLS_VERIFY:=true} + provider_id: ${env.VLLM_URL:+vllm-inference} + provider_type: remote::vllm + - config: + api_token: ${env.VLLM_EMBEDDING_API_TOKEN:=fake} + base_url: ${env.VLLM_EMBEDDING_URL:=} + max_tokens: ${env.VLLM_EMBEDDING_MAX_TOKENS:=4096} + refresh_models: ${env.VLLM_EMBEDDING_REFRESH_MODELS:=false} + tls_verify: ${env.VLLM_EMBEDDING_TLS_VERIFY:=true} + provider_id: ${env.VLLM_EMBEDDING_URL:+vllm-embedding} + provider_type: remote::vllm + - config: + api_key: ${env.AWS_BEARER_TOKEN_BEDROCK:=} + region_name: ${env.AWS_DEFAULT_REGION:=us-east-2} + provider_id: ${env.AWS_BEARER_TOKEN_BEDROCK:+bedrock} + provider_type: remote::bedrock + - config: + api_key: ${env.WATSONX_API_KEY:=} + base_url: ${env.WATSONX_BASE_URL:=https://us-south.ml.cloud.ibm.com} + project_id: ${env.WATSONX_PROJECT_ID:=} + provider_id: ${env.WATSONX_API_KEY:+watsonx} + provider_type: remote::watsonx + - config: + api_base: ${env.AZURE_API_BASE:=} + api_key: ${env.AZURE_API_KEY:=} + api_type: ${env.AZURE_API_TYPE:=} + api_version: ${env.AZURE_API_VERSION:=} + provider_id: ${env.AZURE_API_KEY:+azure} + provider_type: remote::azure + - config: + project: ${env.VERTEX_AI_PROJECT:=} + provider_id: ${env.VERTEX_AI_PROJECT:+vertexai} + provider_type: remote::vertexai + - config: + api_key: ${env.OPENAI_API_KEY:=} + base_url: ${env.OPENAI_BASE_URL:=https://api.openai.com/v1} + provider_id: ${env.OPENAI_API_KEY:+openai} + provider_type: remote::openai + - config: + access_token: ${env.GEMINI_ACCESS_TOKEN:=} + api_key: ${env.GEMINI_API_KEY:=} + project: ${env.GEMINI_AI_PROJECT:=} + provider_id: ${env.ENABLE_GEMINI:+gemini} + provider_type: remote::gemini + responses: + - config: + persistence: + agent_state: + backend: kv_default + namespace: agents::meta_reference + responses: + backend: sql_default + max_write_queue_size: 10000 + num_writers: 4 + table_name: agents_responses + provider_id: meta-reference + provider_type: inline::builtin + tool_runtime: + - config: + api_key: ${env.BRAVE_SEARCH_API_KEY:=} + max_results: 3 + provider_id: brave-search + provider_type: remote::brave-search + - config: + api_key: ${env.TAVILY_SEARCH_API_KEY:=} + max_results: 3 + provider_id: tavily-search + provider_type: remote::tavily-search + - config: {} + provider_id: file-search + provider_type: inline::file-search + - config: {} + provider_id: model-context-protocol + provider_type: remote::model-context-protocol + vector_io: + - config: + ca_pem_path: ${env.MILVUS_CA_PEM_PATH:=} + client_key_path: ${env.MILVUS_CLIENT_KEY_PATH:=} + client_pem_path: ${env.MILVUS_CLIENT_PEM_PATH:=} + consistency_level: ${env.MILVUS_CONSISTENCY_LEVEL:=} + persistence: + backend: kv_default + namespace: vector_io::milvus_remote + secure: ${env.MILVUS_SECURE:=} + token: ${env.MILVUS_TOKEN:=} + uri: ${env.MILVUS_ENDPOINT:=} + provider_id: ${env.MILVUS_ENDPOINT:+milvus-remote} + provider_type: remote::milvus + - config: + db: ${env.PGVECTOR_DB:=} + host: ${env.PGVECTOR_HOST:=localhost} + password: ${env.PGVECTOR_PASSWORD:=} + persistence: + backend: kv_default + namespace: vector_io::pgvector + port: ${env.PGVECTOR_PORT:=5432} + user: ${env.PGVECTOR_USER:=} + provider_id: ${env.ENABLE_PGVECTOR:+pgvector} + provider_type: remote::pgvector + - config: + api_key: ${env.QDRANT_API_KEY:=} + grpc_port: ${env.QDRANT_GRPC_PORT:=6334} + host: ${env.QDRANT_HOST:=} + https: ${env.QDRANT_HTTPS:=} + location: ${env.QDRANT_LOCATION:=} + persistence: + backend: kv_default + namespace: vector_io::qdrant_remote + port: ${env.QDRANT_PORT:=6333} + prefer_grpc: ${env.QDRANT_PREFER_GRPC:=false} + prefix: ${env.QDRANT_PREFIX:=} + timeout: ${env.QDRANT_TIMEOUT:=} + url: ${env.QDRANT_URL:=} + provider_id: ${env.ENABLE_QDRANT:+qdrant-remote} + provider_type: remote::qdrant +registered_resources: + models: + - metadata: + embedding_dimension: ${env.EMBEDDING_DIMENSION:=768} + model_id: ${env.EMBEDDING_MODEL:=} + model_type: embedding + provider_id: ${env.EMBEDDING_PROVIDER:=vllm-embedding} + provider_model_id: ${env.EMBEDDING_PROVIDER_MODEL_ID:=ibm-granite/granite-embedding-125m-english} +server: + auth: + access_policy: + - description: All users can read system resources + permit: + actions: + - read + when: resource is unowned + - description: Authenticated users can create resources + permit: + actions: + - create + - description: Owners can manage their own resources + permit: + actions: + - read + - update + - delete + when: user is owner + provider_config: + audience: ${env.AUTH_AUDIENCE:=ogx} + issuer: ${env.AUTH_ISSUER:=} + jwks: + key_recheck_period: ${env.AUTH_JWKS_RECHECK_PERIOD:=3600} + uri: ${env.AUTH_JWKS_URI:=} + type: ${env.AUTH_ISSUER:+oauth2_token} + verify_tls: ${env.AUTH_VERIFY_TLS:=true} + port: 8321 +storage: + backends: + kv_default: + db_path: ${env.SQLITE_STORE_DIR:=/.ogx}/kvstore.db + type: kv_sqlite + sql_default: + connection_string: ${env.OGX_STORAGE_SQL_CONNECTION_STRING} + type: sql_postgres + stores: + conversations: + backend: sql_default + table_name: openai_conversations + inference: + backend: sql_default + table_name: inference_store + metadata: + backend: kv_default + namespace: registry + prompts: + backend: kv_default + namespace: prompts +vector_stores: + annotation_prompt_params: + annotation_instruction_template: Cite sources immediately at the end of sentences using <|file-id|> format. + enable_annotations: true +version: "2" diff --git a/config/certmanager/certificate.yaml b/config/certmanager/certificate.yaml new file mode 100644 index 000000000..8c5033122 --- /dev/null +++ b/config/certmanager/certificate.yaml @@ -0,0 +1,23 @@ +# Self-signed issuer and certificate for webhook TLS. +# Used on vanilla Kubernetes clusters where OpenShift service-ca is unavailable. +apiVersion: cert-manager.io/v1 +kind: Issuer +metadata: + name: selfsigned-issuer + namespace: system +spec: + selfSigned: {} +--- +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: serving-cert + namespace: system +spec: + dnsNames: + - $(SERVICE_NAME).$(SERVICE_NAMESPACE).svc + - $(SERVICE_NAME).$(SERVICE_NAMESPACE).svc.cluster.local + issuerRef: + kind: Issuer + name: selfsigned-issuer + secretName: ogx-k8s-operator-webhook-cert diff --git a/config/certmanager/kustomization.yaml b/config/certmanager/kustomization.yaml new file mode 100644 index 000000000..43215fabf --- /dev/null +++ b/config/certmanager/kustomization.yaml @@ -0,0 +1,11 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: ogx-k8s-operator-system +namePrefix: ogx-k8s-operator- + +resources: +- certificate.yaml + +configurations: +- kustomizeconfig.yaml diff --git a/config/certmanager/kustomizeconfig.yaml b/config/certmanager/kustomizeconfig.yaml new file mode 100644 index 000000000..e3dd6f3c4 --- /dev/null +++ b/config/certmanager/kustomizeconfig.yaml @@ -0,0 +1,13 @@ +# Teaches kustomize how to substitute cert-manager references. +nameReference: +- kind: Issuer + group: cert-manager.io + fieldSpecs: + - kind: Certificate + group: cert-manager.io + path: spec/issuerRef/name + +varReference: +- path: spec/dnsNames + kind: Certificate + group: cert-manager.io diff --git a/config/crd/bases/ogx.io_ogxservers.yaml b/config/crd/bases/ogx.io_ogxservers.yaml new file mode 100644 index 000000000..0cb3dd97f --- /dev/null +++ b/config/crd/bases/ogx.io_ogxservers.yaml @@ -0,0 +1,6800 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.2 + name: ogxservers.ogx.io +spec: + group: ogx.io + names: + kind: OGXServer + listKind: OGXServerList + plural: ogxservers + singular: ogxserver + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .status.phase + name: Phase + type: string + - jsonPath: .status.resolvedDistribution.image + name: Distribution + priority: 1 + type: string + - jsonPath: .status.configGeneration.configMapName + name: Config + priority: 1 + type: string + - jsonPath: .status.configGeneration.providerCount + name: Providers + type: integer + - jsonPath: .status.version.operatorVersion + name: Operator Version + priority: 1 + type: string + - jsonPath: .status.version.serverVersion + name: Server Version + priority: 1 + type: string + - jsonPath: .status.availableReplicas + name: Available + type: integer + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1beta1 + schema: + openAPIV3Schema: + description: OGXServer is the Schema for the ogxservers API. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: OGXServerSpec defines the desired state of OGXServer. + properties: + baseConfig: + description: |- + BaseConfig references a ConfigMap key containing the base config.yaml used + as the starting point for declarative config generation. + When set, this takes precedence over OCI label resolution. + Mutually exclusive with overrideConfig. + The ConfigMap must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the ConfigMap. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the ConfigMap. + minLength: 1 + type: string + required: + - key + - name + type: object + disabledAPIs: + description: |- + DisabledAPIs lists API names to remove from the generated config. + Mutually exclusive with overrideConfig. + items: + enum: + - batches + - file_processors + - inference + - responses + - tool_runtime + - vector_io + - files + type: string + maxItems: 7 + minItems: 1 + type: array + distribution: + description: Distribution identifies the OGX distribution to deploy. + properties: + image: + description: Image is a direct container image reference to use. + type: string + name: + description: |- + Name is the distribution name that maps to a supported distribution (e.g., "starter", "remote-vllm"). + Resolved to a container image via distributions.json and image-overrides. + type: string + type: object + x-kubernetes-validations: + - message: only one of name or image can be specified + rule: '!(has(self.name) && has(self.image))' + - message: one of name or image must be specified + rule: has(self.name) || has(self.image) + monitoring: + description: Monitoring configures Prometheus monitoring and observability. + properties: + enabled: + default: true + description: |- + Enabled controls whether the operator creates monitoring resources + (ServiceMonitor, PrometheusRule) for this server. + Defaults to true. Set to false to disable monitoring without removing the config. + type: boolean + metricsPort: + description: |- + MetricsPort is the port serving the /metrics endpoint. + When omitted, metrics are served on the main API port. + format: int32 + maximum: 65535 + minimum: 1 + type: integer + type: object + network: + description: Network defines network access controls. + properties: + externalAccess: + description: ExternalAccess controls external service exposure. + properties: + enabled: + default: false + description: Enabled controls whether external access is created. + type: boolean + hostname: + description: |- + Hostname sets a custom hostname for the external endpoint. + When omitted, an auto-generated hostname is used. + type: string + type: object + x-kubernetes-validations: + - message: hostname must not be empty if specified + rule: '!has(self.hostname) || self.hostname.size() > 0' + policy: + description: |- + Policy configures the operator-managed NetworkPolicy. + When nil, the operator creates a default NetworkPolicy with safe ingress rules. + properties: + egress: + description: |- + Egress rules. When non-empty, a kube-dns egress rule is auto-injected + to prevent DNS breakage. + items: + description: |- + NetworkPolicyEgressRule describes a particular set of traffic that is allowed out of pods + matched by a NetworkPolicySpec's podSelector. The traffic must match both ports and to. + This type is beta-level in 1.8 + properties: + ports: + description: |- + ports is a list of destination ports for outgoing traffic. + Each item in this list is combined using a logical OR. If this field is + empty or missing, this rule matches all ports (traffic not restricted by port). + If this field is present and contains at least one item, then this rule allows + traffic only if the traffic matches at least one port in the list. + items: + description: NetworkPolicyPort describes a port to + allow traffic on + properties: + endPort: + description: |- + endPort indicates that the range of ports from port to endPort if set, inclusive, + should be allowed by the policy. This field cannot be defined if the port field + is not defined or if the port field is defined as a named (string) port. + The endPort must be equal or greater than port. + format: int32 + type: integer + port: + anyOf: + - type: integer + - type: string + description: |- + port represents the port on the given protocol. This can either be a numerical or named + port on a pod. If this field is not provided, this matches all port names and + numbers. + If present, only traffic on the specified protocol AND port will be matched. + x-kubernetes-int-or-string: true + protocol: + description: |- + protocol represents the protocol (TCP, UDP, or SCTP) which traffic must match. + If not specified, this field defaults to TCP. + type: string + type: object + type: array + x-kubernetes-list-type: atomic + to: + description: |- + to is a list of destinations for outgoing traffic of pods selected for this rule. + Items in this list are combined using a logical OR operation. If this field is + empty or missing, this rule matches all destinations (traffic not restricted by + destination). If this field is present and contains at least one item, this rule + allows traffic only if the traffic matches at least one item in the to list. + items: + description: |- + NetworkPolicyPeer describes a peer to allow traffic to/from. Only certain combinations of + fields are allowed + properties: + ipBlock: + description: |- + ipBlock defines policy on a particular IPBlock. If this field is set then + neither of the other fields can be. + properties: + cidr: + description: |- + cidr is a string representing the IPBlock + Valid examples are "192.168.1.0/24" or "2001:db8::/64" + type: string + except: + description: |- + except is a slice of CIDRs that should not be included within an IPBlock + Valid examples are "192.168.1.0/24" or "2001:db8::/64" + Except values will be rejected if they are outside the cidr range + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - cidr + type: object + namespaceSelector: + description: |- + namespaceSelector selects namespaces using cluster-scoped labels. This field follows + standard label selector semantics; if present but empty, it selects all namespaces. + + If podSelector is also set, then the NetworkPolicyPeer as a whole selects + the pods matching podSelector in the namespaces selected by namespaceSelector. + Otherwise it selects all pods in the namespaces selected by namespaceSelector. + properties: + matchExpressions: + description: matchExpressions is a list of + label selector requirements. The requirements + are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that + the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + podSelector: + description: |- + podSelector is a label selector which selects pods. This field follows standard label + selector semantics; if present but empty, it selects all pods. + + If namespaceSelector is also set, then the NetworkPolicyPeer as a whole selects + the pods matching podSelector in the Namespaces selected by NamespaceSelector. + Otherwise it selects the pods matching podSelector in the policy's own namespace. + properties: + matchExpressions: + description: matchExpressions is a list of + label selector requirements. The requirements + are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that + the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + type: object + type: array + x-kubernetes-list-type: atomic + type: object + type: array + enabled: + default: true + description: |- + Enabled controls whether the operator manages a NetworkPolicy for this server. + Defaults to true. Set to false to disable NetworkPolicy creation entirely. + type: boolean + ingress: + description: |- + Ingress defines additional ingress rules, merged with operator defaults + (allow from same-namespace and operator-namespace on the service port). + items: + description: |- + NetworkPolicyIngressRule describes a particular set of traffic that is allowed to the pods + matched by a NetworkPolicySpec's podSelector. The traffic must match both ports and from. + properties: + from: + description: |- + from is a list of sources which should be able to access the pods selected for this rule. + Items in this list are combined using a logical OR operation. If this field is + empty or missing, this rule matches all sources (traffic not restricted by + source). If this field is present and contains at least one item, this rule + allows traffic only if the traffic matches at least one item in the from list. + items: + description: |- + NetworkPolicyPeer describes a peer to allow traffic to/from. Only certain combinations of + fields are allowed + properties: + ipBlock: + description: |- + ipBlock defines policy on a particular IPBlock. If this field is set then + neither of the other fields can be. + properties: + cidr: + description: |- + cidr is a string representing the IPBlock + Valid examples are "192.168.1.0/24" or "2001:db8::/64" + type: string + except: + description: |- + except is a slice of CIDRs that should not be included within an IPBlock + Valid examples are "192.168.1.0/24" or "2001:db8::/64" + Except values will be rejected if they are outside the cidr range + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - cidr + type: object + namespaceSelector: + description: |- + namespaceSelector selects namespaces using cluster-scoped labels. This field follows + standard label selector semantics; if present but empty, it selects all namespaces. + + If podSelector is also set, then the NetworkPolicyPeer as a whole selects + the pods matching podSelector in the namespaces selected by namespaceSelector. + Otherwise it selects all pods in the namespaces selected by namespaceSelector. + properties: + matchExpressions: + description: matchExpressions is a list of + label selector requirements. The requirements + are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that + the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + podSelector: + description: |- + podSelector is a label selector which selects pods. This field follows standard label + selector semantics; if present but empty, it selects all pods. + + If namespaceSelector is also set, then the NetworkPolicyPeer as a whole selects + the pods matching podSelector in the Namespaces selected by NamespaceSelector. + Otherwise it selects the pods matching podSelector in the policy's own namespace. + properties: + matchExpressions: + description: matchExpressions is a list of + label selector requirements. The requirements + are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that + the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + type: object + type: array + x-kubernetes-list-type: atomic + ports: + description: |- + ports is a list of ports which should be made accessible on the pods selected for + this rule. Each item in this list is combined using a logical OR. If this field is + empty or missing, this rule matches all ports (traffic not restricted by port). + If this field is present and contains at least one item, then this rule allows + traffic only if the traffic matches at least one port in the list. + items: + description: NetworkPolicyPort describes a port to + allow traffic on + properties: + endPort: + description: |- + endPort indicates that the range of ports from port to endPort if set, inclusive, + should be allowed by the policy. This field cannot be defined if the port field + is not defined or if the port field is defined as a named (string) port. + The endPort must be equal or greater than port. + format: int32 + type: integer + port: + anyOf: + - type: integer + - type: string + description: |- + port represents the port on the given protocol. This can either be a numerical or named + port on a pod. If this field is not provided, this matches all port names and + numbers. + If present, only traffic on the specified protocol AND port will be matched. + x-kubernetes-int-or-string: true + protocol: + description: |- + protocol represents the protocol (TCP, UDP, or SCTP) which traffic must match. + If not specified, this field defaults to TCP. + type: string + type: object + type: array + x-kubernetes-list-type: atomic + type: object + type: array + policyTypes: + description: |- + PolicyTypes specifies which policy directions are enforced. + Follows Kubernetes NetworkPolicy semantics: when omitted or empty, + Ingress is always included and Egress is included only if egress + rules are provided. + items: + description: |- + PolicyType string describes the NetworkPolicy type + This type is beta-level in 1.8 + enum: + - Ingress + - Egress + type: string + type: array + type: object + port: + default: 8321 + description: Port is the server listen port. + format: int32 + maximum: 65535 + minimum: 1 + type: integer + tls: + description: |- + TLS configures optional TLS termination for the server. + When omitted, the server listens over plain HTTP. + properties: + secretName: + description: |- + SecretName references a Kubernetes TLS Secret containing a valid TLS certificate + for server TLS termination. The Secret must be in the same namespace as the + OGXServer and must have the label ogx.io/watch: "true" to be detected by the + operator's cache. + minLength: 1 + type: string + required: + - secretName + type: object + type: object + overrideConfig: + description: |- + OverrideConfig references a ConfigMap key containing a full config.yaml override. + Mutually exclusive with providers, resources, storage, disabledAPIs, and baseConfig. + The ConfigMap must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the ConfigMap. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the ConfigMap. + minLength: 1 + type: string + required: + - key + - name + type: object + providers: + description: |- + Providers configures providers by API type. + Mutually exclusive with overrideConfig. + properties: + batches: + description: BatchesProvidersSpec configures batches providers. + properties: + inline: + description: BatchesInlineProviders groups inline batches + providers. + properties: + custom: + items: + description: CustomProvider defines the configuration + for a custom provider instance. + properties: + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + secretRefs: + additionalProperties: + description: |- + SecretKeyRef references a specific key in a Kubernetes Secret. + The Secret must be in the same namespace as the OGXServer and must have + the label ogx.io/watch: "true" to be detected by the operator's cache. + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + description: |- + SecretRefs is a map of named secret references for provider-specific + connection fields (e.g., host, password). Each key becomes the env var + field suffix and maps to config. with env var substitution. + Each Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + minProperties: 1 + type: object + settings: + description: |- + Settings contains provider-specific configuration merged into the + provider's config section in config.yaml. Passed through as-is + without any secret resolution. Use secretRefs for secret values. + x-kubernetes-preserve-unknown-fields: true + type: + description: |- + Type is the provider type, specified with a "remote::" or "inline::" + prefix (e.g., "remote::llama-guard", "inline::my-provider"). + minLength: 1 + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: type must have a 'remote::' or 'inline::' + prefix (e.g., 'remote::llama-guard', 'inline::my-provider') + rule: self.type.startsWith('remote::') || self.type.startsWith('inline::') + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + reference: + description: InlineReferenceProvider configures inline::reference + for batches. + properties: + maxConcurrentBatches: + description: |- + MaxConcurrentBatches is the maximum number of concurrent batches + to process simultaneously. + minimum: 1 + type: integer + maxConcurrentRequestsPerBatch: + description: |- + MaxConcurrentRequestsPerBatch is the maximum number of concurrent + requests to process per batch. + minimum: 1 + type: integer + type: object + type: object + remote: + description: BatchesRemoteProviders groups remote batches + providers. + properties: + custom: + items: + description: CustomProvider defines the configuration + for a custom provider instance. + properties: + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + secretRefs: + additionalProperties: + description: |- + SecretKeyRef references a specific key in a Kubernetes Secret. + The Secret must be in the same namespace as the OGXServer and must have + the label ogx.io/watch: "true" to be detected by the operator's cache. + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + description: |- + SecretRefs is a map of named secret references for provider-specific + connection fields (e.g., host, password). Each key becomes the env var + field suffix and maps to config. with env var substitution. + Each Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + minProperties: 1 + type: object + settings: + description: |- + Settings contains provider-specific configuration merged into the + provider's config section in config.yaml. Passed through as-is + without any secret resolution. Use secretRefs for secret values. + x-kubernetes-preserve-unknown-fields: true + type: + description: |- + Type is the provider type, specified with a "remote::" or "inline::" + prefix (e.g., "remote::llama-guard", "inline::my-provider"). + minLength: 1 + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: type must have a 'remote::' or 'inline::' + prefix (e.g., 'remote::llama-guard', 'inline::my-provider') + rule: self.type.startsWith('remote::') || self.type.startsWith('inline::') + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + type: object + type: object + fileProcessors: + description: FileProcessorsProvidersSpec configures file_processors + providers. + properties: + inline: + description: FileProcessorsInlineProviders groups inline file_processors + providers. + properties: + auto: + description: InlineAutoFileProcessorProvider configures + inline::auto for file_processors. + properties: + cleanText: + description: |- + CleanText controls whether to clean extracted text + (remove extra whitespace, normalize line breaks). + type: boolean + defaultChunkOverlapTokens: + description: |- + DefaultChunkOverlapTokens is the default chunk overlap in tokens when + chunking_strategy type is 'auto'. + maximum: 2048 + minimum: 0 + type: integer + defaultChunkSizeTokens: + description: |- + DefaultChunkSizeTokens is the default chunk size in tokens when + chunking_strategy type is 'auto'. + maximum: 4096 + minimum: 100 + type: integer + extractMetadata: + description: |- + ExtractMetadata controls whether to extract PDF metadata + (title, author, etc.). + type: boolean + type: object + custom: + items: + description: CustomProvider defines the configuration + for a custom provider instance. + properties: + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + secretRefs: + additionalProperties: + description: |- + SecretKeyRef references a specific key in a Kubernetes Secret. + The Secret must be in the same namespace as the OGXServer and must have + the label ogx.io/watch: "true" to be detected by the operator's cache. + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + description: |- + SecretRefs is a map of named secret references for provider-specific + connection fields (e.g., host, password). Each key becomes the env var + field suffix and maps to config. with env var substitution. + Each Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + minProperties: 1 + type: object + settings: + description: |- + Settings contains provider-specific configuration merged into the + provider's config section in config.yaml. Passed through as-is + without any secret resolution. Use secretRefs for secret values. + x-kubernetes-preserve-unknown-fields: true + type: + description: |- + Type is the provider type, specified with a "remote::" or "inline::" + prefix (e.g., "remote::llama-guard", "inline::my-provider"). + minLength: 1 + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: type must have a 'remote::' or 'inline::' + prefix (e.g., 'remote::llama-guard', 'inline::my-provider') + rule: self.type.startsWith('remote::') || self.type.startsWith('inline::') + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + docling: + description: InlineDoclingFileProcessorProvider configures + inline::docling for file_processors. + properties: + defaultChunkOverlapTokens: + description: |- + DefaultChunkOverlapTokens is the default chunk overlap in tokens when + chunking_strategy type is 'auto'. + maximum: 2048 + minimum: 0 + type: integer + defaultChunkSizeTokens: + description: |- + DefaultChunkSizeTokens is the default chunk size in tokens when + chunking_strategy type is 'auto'. + maximum: 4096 + minimum: 100 + type: integer + doOcr: + description: DoOCR controls whether to enable OCR + for scanned documents. + type: boolean + type: object + markitdown: + description: InlineMarkItDownFileProcessorProvider configures + inline::markitdown for file_processors. + properties: + defaultChunkOverlapTokens: + description: |- + DefaultChunkOverlapTokens is the default chunk overlap in tokens when + chunking_strategy type is 'auto'. + maximum: 2048 + minimum: 0 + type: integer + defaultChunkSizeTokens: + description: |- + DefaultChunkSizeTokens is the default chunk size in tokens when + chunking_strategy type is 'auto'. + maximum: 4096 + minimum: 100 + type: integer + type: object + pypdf: + description: InlinePyPDFFileProcessorProvider configures + inline::pypdf for file_processors. + properties: + cleanText: + description: |- + CleanText controls whether to clean extracted text + (remove extra whitespace, normalize line breaks). + type: boolean + defaultChunkOverlapTokens: + description: |- + DefaultChunkOverlapTokens is the default chunk overlap in tokens when + chunking_strategy type is 'auto'. + maximum: 2048 + minimum: 0 + type: integer + defaultChunkSizeTokens: + description: |- + DefaultChunkSizeTokens is the default chunk size in tokens when + chunking_strategy type is 'auto'. + maximum: 4096 + minimum: 100 + type: integer + extractMetadata: + description: |- + ExtractMetadata controls whether to extract PDF metadata + (title, author, etc.). + type: boolean + type: object + type: object + remote: + description: FileProcessorsRemoteProviders groups remote file_processors + providers. + properties: + custom: + items: + description: CustomProvider defines the configuration + for a custom provider instance. + properties: + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + secretRefs: + additionalProperties: + description: |- + SecretKeyRef references a specific key in a Kubernetes Secret. + The Secret must be in the same namespace as the OGXServer and must have + the label ogx.io/watch: "true" to be detected by the operator's cache. + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + description: |- + SecretRefs is a map of named secret references for provider-specific + connection fields (e.g., host, password). Each key becomes the env var + field suffix and maps to config. with env var substitution. + Each Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + minProperties: 1 + type: object + settings: + description: |- + Settings contains provider-specific configuration merged into the + provider's config section in config.yaml. Passed through as-is + without any secret resolution. Use secretRefs for secret values. + x-kubernetes-preserve-unknown-fields: true + type: + description: |- + Type is the provider type, specified with a "remote::" or "inline::" + prefix (e.g., "remote::llama-guard", "inline::my-provider"). + minLength: 1 + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: type must have a 'remote::' or 'inline::' + prefix (e.g., 'remote::llama-guard', 'inline::my-provider') + rule: self.type.startsWith('remote::') || self.type.startsWith('inline::') + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + doclingServe: + description: DoclingServeProvider configures a remote::docling-serve + file_processors provider instance. + properties: + apiKey: + description: |- + APIKey is the API key for authenticating with Docling Serve. + The Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + baseUrl: + description: BaseURL is the base URL of the Docling + Serve instance. + minLength: 1 + type: string + defaultChunkSizeTokens: + description: |- + DefaultChunkSizeTokens is the default chunk size in tokens when + chunking_strategy type is 'auto'. + maximum: 4096 + minimum: 100 + type: integer + required: + - baseUrl + type: object + x-kubernetes-validations: + - message: baseUrl must not be empty if specified + rule: '!has(self.baseUrl) || self.baseUrl.size() > 0' + type: object + type: object + files: + description: FilesProvidersSpec configures files providers. + properties: + inline: + description: FilesInlineProviders groups inline files providers. + properties: + custom: + items: + description: CustomProvider defines the configuration + for a custom provider instance. + properties: + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + secretRefs: + additionalProperties: + description: |- + SecretKeyRef references a specific key in a Kubernetes Secret. + The Secret must be in the same namespace as the OGXServer and must have + the label ogx.io/watch: "true" to be detected by the operator's cache. + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + description: |- + SecretRefs is a map of named secret references for provider-specific + connection fields (e.g., host, password). Each key becomes the env var + field suffix and maps to config. with env var substitution. + Each Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + minProperties: 1 + type: object + settings: + description: |- + Settings contains provider-specific configuration merged into the + provider's config section in config.yaml. Passed through as-is + without any secret resolution. Use secretRefs for secret values. + x-kubernetes-preserve-unknown-fields: true + type: + description: |- + Type is the provider type, specified with a "remote::" or "inline::" + prefix (e.g., "remote::llama-guard", "inline::my-provider"). + minLength: 1 + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: type must have a 'remote::' or 'inline::' + prefix (e.g., 'remote::llama-guard', 'inline::my-provider') + rule: self.type.startsWith('remote::') || self.type.startsWith('inline::') + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + localfs: + description: InlineLocalFSProvider configures inline::localfs. + properties: + ttlSecs: + description: TTLSecs is the time-to-live in seconds + for uploaded files. + minimum: 1 + type: integer + type: object + type: object + remote: + description: FilesRemoteProviders groups remote files providers. + properties: + custom: + items: + description: CustomProvider defines the configuration + for a custom provider instance. + properties: + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + secretRefs: + additionalProperties: + description: |- + SecretKeyRef references a specific key in a Kubernetes Secret. + The Secret must be in the same namespace as the OGXServer and must have + the label ogx.io/watch: "true" to be detected by the operator's cache. + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + description: |- + SecretRefs is a map of named secret references for provider-specific + connection fields (e.g., host, password). Each key becomes the env var + field suffix and maps to config. with env var substitution. + Each Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + minProperties: 1 + type: object + settings: + description: |- + Settings contains provider-specific configuration merged into the + provider's config section in config.yaml. Passed through as-is + without any secret resolution. Use secretRefs for secret values. + x-kubernetes-preserve-unknown-fields: true + type: + description: |- + Type is the provider type, specified with a "remote::" or "inline::" + prefix (e.g., "remote::llama-guard", "inline::my-provider"). + minLength: 1 + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: type must have a 'remote::' or 'inline::' + prefix (e.g., 'remote::llama-guard', 'inline::my-provider') + rule: self.type.startsWith('remote::') || self.type.startsWith('inline::') + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + s3: + description: S3Provider configures a remote::s3 files + provider instance. + properties: + autoCreateBucket: + description: |- + AutoCreateBucket controls whether to automatically create the S3 bucket + if it doesn't exist. + type: boolean + awsAccessKeyId: + description: |- + AWSAccessKeyID is the AWS access key ID (optional if using IAM roles). + The Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + awsSecretAccessKey: + description: |- + AWSSecretAccessKey is the AWS secret access key (optional if using IAM roles). + The Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + bucketName: + description: BucketName is the S3 bucket name to store + files. + minLength: 1 + type: string + endpointUrl: + description: EndpointURL is a custom S3 endpoint URL + (for MinIO, LocalStack, etc.). + type: string + region: + description: Region is the AWS region where the bucket + is located. + type: string + required: + - bucketName + type: object + x-kubernetes-validations: + - message: region must not be empty if specified + rule: '!has(self.region) || self.region.size() > 0' + - message: endpointUrl must not be empty if specified + rule: '!has(self.endpointUrl) || self.endpointUrl.size() + > 0' + type: object + type: object + inference: + description: InferenceProvidersSpec configures inference providers. + properties: + inline: + description: InferenceInlineProviders groups inline inference + providers. + properties: + custom: + items: + description: CustomProvider defines the configuration + for a custom provider instance. + properties: + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + secretRefs: + additionalProperties: + description: |- + SecretKeyRef references a specific key in a Kubernetes Secret. + The Secret must be in the same namespace as the OGXServer and must have + the label ogx.io/watch: "true" to be detected by the operator's cache. + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + description: |- + SecretRefs is a map of named secret references for provider-specific + connection fields (e.g., host, password). Each key becomes the env var + field suffix and maps to config. with env var substitution. + Each Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + minProperties: 1 + type: object + settings: + description: |- + Settings contains provider-specific configuration merged into the + provider's config section in config.yaml. Passed through as-is + without any secret resolution. Use secretRefs for secret values. + x-kubernetes-preserve-unknown-fields: true + type: + description: |- + Type is the provider type, specified with a "remote::" or "inline::" + prefix (e.g., "remote::llama-guard", "inline::my-provider"). + minLength: 1 + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: type must have a 'remote::' or 'inline::' + prefix (e.g., 'remote::llama-guard', 'inline::my-provider') + rule: self.type.startsWith('remote::') || self.type.startsWith('inline::') + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + type: object + remote: + description: InferenceRemoteProviders groups remote inference + providers. + properties: + azure: + items: + description: AzureProvider configures a remote::azure + inference provider instance. + properties: + allowedModels: + description: |- + AllowedModels restricts which models can be registered with this provider. + When empty, all models are allowed. + items: + minLength: 1 + type: string + minItems: 1 + type: array + apiKey: + description: |- + APIKey is the authentication credential for the Azure provider. + The Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + apiType: + description: APIType is the Azure API type (e.g., + azure). + type: string + apiVersion: + description: APIVersion is the Azure API version + (e.g., 2024-12-01-preview). + type: string + endpoint: + description: |- + Endpoint is the Azure API base URL + (e.g., https://your-resource-name.openai.azure.com/openai/v1). + minLength: 1 + type: string + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + network: + description: |- + Network configures network settings (TLS, proxy, timeouts, headers) + for the remote connection. + properties: + headers: + additionalProperties: + type: string + description: Headers specifies additional HTTP + headers to include in all requests. + minProperties: 1 + type: object + proxy: + description: Proxy configures HTTP proxy settings. + properties: + cacert: + description: CACert is the path to a CA + certificate for verifying the proxy's + certificate. + type: string + http: + description: HTTP is the proxy URL for HTTP + connections. + type: string + https: + description: HTTPS is the proxy URL for + HTTPS connections. + type: string + noProxy: + description: NoProxy is a list of hosts + that should bypass the proxy. + items: + minLength: 1 + type: string + minItems: 1 + type: array + url: + description: URL is the proxy URL for all + connections. + type: string + type: object + x-kubernetes-validations: + - message: url must not be empty if specified + rule: '!has(self.url) || self.url.size() > + 0' + - message: http must not be empty if specified + rule: '!has(self.http) || self.http.size() + > 0' + - message: https must not be empty if specified + rule: '!has(self.https) || self.https.size() + > 0' + - message: cacert must not be empty if specified + rule: '!has(self.cacert) || self.cacert.size() + > 0' + timeout: + description: Timeout configures connection and + read timeout settings. + properties: + connect: + description: Connect is the connection timeout + in seconds. + minimum: 1 + type: integer + read: + description: Read is the read timeout in + seconds. + minimum: 1 + type: integer + type: object + tls: + description: TLS configures TLS/SSL settings. + properties: + ciphers: + description: Ciphers is a list of allowed + TLS cipher suites. + items: + minLength: 1 + type: string + minItems: 1 + type: array + minVersion: + description: MinVersion sets the minimum + TLS version. + enum: + - TLSv1.2 + - TLSv1.3 + type: string + verify: + description: |- + Verify controls whether TLS certificate verification is enabled. + Trust anchors and client identity are configured globally via spec.tls. + type: boolean + type: object + type: object + refreshModels: + description: |- + RefreshModels controls whether the provider periodically refreshes + its model list from the remote endpoint. + type: boolean + required: + - apiKey + - endpoint + type: object + x-kubernetes-validations: + - message: apiVersion must not be empty if specified + rule: '!has(self.apiVersion) || self.apiVersion.size() + > 0' + - message: apiType must not be empty if specified + rule: '!has(self.apiType) || self.apiType.size() > + 0' + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + bedrock: + items: + description: BedrockProvider configures a remote::bedrock + inference provider instance. + properties: + allowedModels: + description: |- + AllowedModels restricts which models can be registered with this provider. + When empty, all models are allowed. + items: + minLength: 1 + type: string + minItems: 1 + type: array + apiKey: + description: |- + APIKey is the authentication credential for the Bedrock provider. + The Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + awsAccessKeyId: + description: |- + AWSAccessKeyID is the AWS access key to use. + The Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + awsRoleArn: + description: AWSRoleArn is the AWS role ARN to assume. + type: string + awsRoleSessionName: + description: AWSRoleSessionName is the session name + to use when assuming a role. + type: string + awsSecretAccessKey: + description: |- + AWSSecretAccessKey is the AWS secret access key to use. + The Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + awsSessionToken: + description: |- + AWSSessionToken is the AWS session token to use. + The Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + awsWebIdentityTokenFile: + description: AWSWebIdentityTokenFile is the path + to the web identity token file. + type: string + connectTimeout: + description: ConnectTimeout is the connection timeout + in seconds. + minimum: 1 + type: integer + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + network: + description: |- + Network configures network settings (TLS, proxy, timeouts, headers) + for the remote connection. + properties: + headers: + additionalProperties: + type: string + description: Headers specifies additional HTTP + headers to include in all requests. + minProperties: 1 + type: object + proxy: + description: Proxy configures HTTP proxy settings. + properties: + cacert: + description: CACert is the path to a CA + certificate for verifying the proxy's + certificate. + type: string + http: + description: HTTP is the proxy URL for HTTP + connections. + type: string + https: + description: HTTPS is the proxy URL for + HTTPS connections. + type: string + noProxy: + description: NoProxy is a list of hosts + that should bypass the proxy. + items: + minLength: 1 + type: string + minItems: 1 + type: array + url: + description: URL is the proxy URL for all + connections. + type: string + type: object + x-kubernetes-validations: + - message: url must not be empty if specified + rule: '!has(self.url) || self.url.size() > + 0' + - message: http must not be empty if specified + rule: '!has(self.http) || self.http.size() + > 0' + - message: https must not be empty if specified + rule: '!has(self.https) || self.https.size() + > 0' + - message: cacert must not be empty if specified + rule: '!has(self.cacert) || self.cacert.size() + > 0' + timeout: + description: Timeout configures connection and + read timeout settings. + properties: + connect: + description: Connect is the connection timeout + in seconds. + minimum: 1 + type: integer + read: + description: Read is the read timeout in + seconds. + minimum: 1 + type: integer + type: object + tls: + description: TLS configures TLS/SSL settings. + properties: + ciphers: + description: Ciphers is a list of allowed + TLS cipher suites. + items: + minLength: 1 + type: string + minItems: 1 + type: array + minVersion: + description: MinVersion sets the minimum + TLS version. + enum: + - TLSv1.2 + - TLSv1.3 + type: string + verify: + description: |- + Verify controls whether TLS certificate verification is enabled. + Trust anchors and client identity are configured globally via spec.tls. + type: boolean + type: object + type: object + profileName: + description: ProfileName is the AWS profile name + that contains credentials to use. + type: string + readTimeout: + description: ReadTimeout is the read timeout in + seconds. + minimum: 1 + type: integer + refreshModels: + description: |- + RefreshModels controls whether the provider periodically refreshes + its model list from the remote endpoint. + type: boolean + region: + description: Region is the AWS region for the Bedrock + Runtime endpoint. + minLength: 1 + type: string + retryMode: + description: RetryMode is the type of retries to + perform (e.g., standard, adaptive). + type: string + sessionTTL: + description: SessionTTL is the time in seconds until + a session expires. + minimum: 1 + type: integer + totalMaxAttempts: + description: |- + TotalMaxAttempts is the maximum number of attempts for a single request, + including the initial attempt. + minimum: 1 + type: integer + required: + - region + type: object + x-kubernetes-validations: + - message: awsRoleArn must not be empty if specified + rule: '!has(self.awsRoleArn) || self.awsRoleArn.size() + > 0' + - message: awsWebIdentityTokenFile must not be empty + if specified + rule: '!has(self.awsWebIdentityTokenFile) || self.awsWebIdentityTokenFile.size() + > 0' + - message: awsRoleSessionName must not be empty if specified + rule: '!has(self.awsRoleSessionName) || self.awsRoleSessionName.size() + > 0' + - message: profileName must not be empty if specified + rule: '!has(self.profileName) || self.profileName.size() + > 0' + - message: retryMode must not be empty if specified + rule: '!has(self.retryMode) || self.retryMode.size() + > 0' + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + custom: + items: + description: CustomProvider defines the configuration + for a custom provider instance. + properties: + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + secretRefs: + additionalProperties: + description: |- + SecretKeyRef references a specific key in a Kubernetes Secret. + The Secret must be in the same namespace as the OGXServer and must have + the label ogx.io/watch: "true" to be detected by the operator's cache. + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + description: |- + SecretRefs is a map of named secret references for provider-specific + connection fields (e.g., host, password). Each key becomes the env var + field suffix and maps to config. with env var substitution. + Each Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + minProperties: 1 + type: object + settings: + description: |- + Settings contains provider-specific configuration merged into the + provider's config section in config.yaml. Passed through as-is + without any secret resolution. Use secretRefs for secret values. + x-kubernetes-preserve-unknown-fields: true + type: + description: |- + Type is the provider type, specified with a "remote::" or "inline::" + prefix (e.g., "remote::llama-guard", "inline::my-provider"). + minLength: 1 + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: type must have a 'remote::' or 'inline::' + prefix (e.g., 'remote::llama-guard', 'inline::my-provider') + rule: self.type.startsWith('remote::') || self.type.startsWith('inline::') + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + openai: + items: + description: OpenAIProvider configures a remote::openai + inference provider instance. + properties: + allowedModels: + description: |- + AllowedModels restricts which models can be registered with this provider. + When empty, all models are allowed. + items: + minLength: 1 + type: string + minItems: 1 + type: array + apiKey: + description: |- + APIKey is the authentication credential for the OpenAI provider. + The Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + endpoint: + description: Endpoint is the base URL for the OpenAI + API. + type: string + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + network: + description: |- + Network configures network settings (TLS, proxy, timeouts, headers) + for the remote connection. + properties: + headers: + additionalProperties: + type: string + description: Headers specifies additional HTTP + headers to include in all requests. + minProperties: 1 + type: object + proxy: + description: Proxy configures HTTP proxy settings. + properties: + cacert: + description: CACert is the path to a CA + certificate for verifying the proxy's + certificate. + type: string + http: + description: HTTP is the proxy URL for HTTP + connections. + type: string + https: + description: HTTPS is the proxy URL for + HTTPS connections. + type: string + noProxy: + description: NoProxy is a list of hosts + that should bypass the proxy. + items: + minLength: 1 + type: string + minItems: 1 + type: array + url: + description: URL is the proxy URL for all + connections. + type: string + type: object + x-kubernetes-validations: + - message: url must not be empty if specified + rule: '!has(self.url) || self.url.size() > + 0' + - message: http must not be empty if specified + rule: '!has(self.http) || self.http.size() + > 0' + - message: https must not be empty if specified + rule: '!has(self.https) || self.https.size() + > 0' + - message: cacert must not be empty if specified + rule: '!has(self.cacert) || self.cacert.size() + > 0' + timeout: + description: Timeout configures connection and + read timeout settings. + properties: + connect: + description: Connect is the connection timeout + in seconds. + minimum: 1 + type: integer + read: + description: Read is the read timeout in + seconds. + minimum: 1 + type: integer + type: object + tls: + description: TLS configures TLS/SSL settings. + properties: + ciphers: + description: Ciphers is a list of allowed + TLS cipher suites. + items: + minLength: 1 + type: string + minItems: 1 + type: array + minVersion: + description: MinVersion sets the minimum + TLS version. + enum: + - TLSv1.2 + - TLSv1.3 + type: string + verify: + description: |- + Verify controls whether TLS certificate verification is enabled. + Trust anchors and client identity are configured globally via spec.tls. + type: boolean + type: object + type: object + refreshModels: + description: |- + RefreshModels controls whether the provider periodically refreshes + its model list from the remote endpoint. + type: boolean + required: + - apiKey + type: object + x-kubernetes-validations: + - message: endpoint must not be empty if specified + rule: '!has(self.endpoint) || self.endpoint.size() + > 0' + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + vertexai: + items: + description: VertexAIProvider configures a remote::vertexai + inference provider instance. + properties: + allowedModels: + description: |- + AllowedModels restricts which models can be registered with this provider. + When empty, all models are allowed. + items: + minLength: 1 + type: string + minItems: 1 + type: array + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + location: + description: Location is the Google Cloud location + for Vertex AI. + type: string + network: + description: |- + Network configures network settings (TLS, proxy, timeouts, headers) + for the remote connection. + properties: + headers: + additionalProperties: + type: string + description: Headers specifies additional HTTP + headers to include in all requests. + minProperties: 1 + type: object + proxy: + description: Proxy configures HTTP proxy settings. + properties: + cacert: + description: CACert is the path to a CA + certificate for verifying the proxy's + certificate. + type: string + http: + description: HTTP is the proxy URL for HTTP + connections. + type: string + https: + description: HTTPS is the proxy URL for + HTTPS connections. + type: string + noProxy: + description: NoProxy is a list of hosts + that should bypass the proxy. + items: + minLength: 1 + type: string + minItems: 1 + type: array + url: + description: URL is the proxy URL for all + connections. + type: string + type: object + x-kubernetes-validations: + - message: url must not be empty if specified + rule: '!has(self.url) || self.url.size() > + 0' + - message: http must not be empty if specified + rule: '!has(self.http) || self.http.size() + > 0' + - message: https must not be empty if specified + rule: '!has(self.https) || self.https.size() + > 0' + - message: cacert must not be empty if specified + rule: '!has(self.cacert) || self.cacert.size() + > 0' + timeout: + description: Timeout configures connection and + read timeout settings. + properties: + connect: + description: Connect is the connection timeout + in seconds. + minimum: 1 + type: integer + read: + description: Read is the read timeout in + seconds. + minimum: 1 + type: integer + type: object + tls: + description: TLS configures TLS/SSL settings. + properties: + ciphers: + description: Ciphers is a list of allowed + TLS cipher suites. + items: + minLength: 1 + type: string + minItems: 1 + type: array + minVersion: + description: MinVersion sets the minimum + TLS version. + enum: + - TLSv1.2 + - TLSv1.3 + type: string + verify: + description: |- + Verify controls whether TLS certificate verification is enabled. + Trust anchors and client identity are configured globally via spec.tls. + type: boolean + type: object + type: object + project: + description: Project is the Google Cloud project + ID for Vertex AI. + minLength: 1 + type: string + refreshModels: + description: |- + RefreshModels controls whether the provider periodically refreshes + its model list from the remote endpoint. + type: boolean + required: + - project + type: object + x-kubernetes-validations: + - message: location must not be empty if specified + rule: '!has(self.location) || self.location.size() + > 0' + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + vllm: + items: + description: VLLMProvider configures a remote::vllm + inference provider instance. + properties: + allowedModels: + description: |- + AllowedModels restricts which models can be registered with this provider. + When empty, all models are allowed. + items: + minLength: 1 + type: string + minItems: 1 + type: array + apiToken: + description: |- + APIToken is the authentication token for the vLLM endpoint. + The Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + endpoint: + description: Endpoint is the URL for the vLLM model + serving endpoint. + minLength: 1 + type: string + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + maxTokens: + description: MaxTokens is the maximum number of + tokens to generate. + minimum: 1 + type: integer + network: + description: |- + Network configures network settings (TLS, proxy, timeouts, headers) + for the remote connection. + properties: + headers: + additionalProperties: + type: string + description: Headers specifies additional HTTP + headers to include in all requests. + minProperties: 1 + type: object + proxy: + description: Proxy configures HTTP proxy settings. + properties: + cacert: + description: CACert is the path to a CA + certificate for verifying the proxy's + certificate. + type: string + http: + description: HTTP is the proxy URL for HTTP + connections. + type: string + https: + description: HTTPS is the proxy URL for + HTTPS connections. + type: string + noProxy: + description: NoProxy is a list of hosts + that should bypass the proxy. + items: + minLength: 1 + type: string + minItems: 1 + type: array + url: + description: URL is the proxy URL for all + connections. + type: string + type: object + x-kubernetes-validations: + - message: url must not be empty if specified + rule: '!has(self.url) || self.url.size() > + 0' + - message: http must not be empty if specified + rule: '!has(self.http) || self.http.size() + > 0' + - message: https must not be empty if specified + rule: '!has(self.https) || self.https.size() + > 0' + - message: cacert must not be empty if specified + rule: '!has(self.cacert) || self.cacert.size() + > 0' + timeout: + description: Timeout configures connection and + read timeout settings. + properties: + connect: + description: Connect is the connection timeout + in seconds. + minimum: 1 + type: integer + read: + description: Read is the read timeout in + seconds. + minimum: 1 + type: integer + type: object + tls: + description: TLS configures TLS/SSL settings. + properties: + ciphers: + description: Ciphers is a list of allowed + TLS cipher suites. + items: + minLength: 1 + type: string + minItems: 1 + type: array + minVersion: + description: MinVersion sets the minimum + TLS version. + enum: + - TLSv1.2 + - TLSv1.3 + type: string + verify: + description: |- + Verify controls whether TLS certificate verification is enabled. + Trust anchors and client identity are configured globally via spec.tls. + type: boolean + type: object + type: object + refreshModels: + description: |- + RefreshModels controls whether the provider periodically refreshes + its model list from the remote endpoint. + type: boolean + required: + - endpoint + type: object + x-kubernetes-validations: + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + watsonx: + items: + description: WatsonxProvider configures a remote::watsonx + inference provider instance. + properties: + allowedModels: + description: |- + AllowedModels restricts which models can be registered with this provider. + When empty, all models are allowed. + items: + minLength: 1 + type: string + minItems: 1 + type: array + apiKey: + description: |- + APIKey is the authentication credential for the watsonx provider. + The Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + endpoint: + description: Endpoint is the base URL for accessing + watsonx.ai. + type: string + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + network: + description: |- + Network configures network settings (TLS, proxy, timeouts, headers) + for the remote connection. + properties: + headers: + additionalProperties: + type: string + description: Headers specifies additional HTTP + headers to include in all requests. + minProperties: 1 + type: object + proxy: + description: Proxy configures HTTP proxy settings. + properties: + cacert: + description: CACert is the path to a CA + certificate for verifying the proxy's + certificate. + type: string + http: + description: HTTP is the proxy URL for HTTP + connections. + type: string + https: + description: HTTPS is the proxy URL for + HTTPS connections. + type: string + noProxy: + description: NoProxy is a list of hosts + that should bypass the proxy. + items: + minLength: 1 + type: string + minItems: 1 + type: array + url: + description: URL is the proxy URL for all + connections. + type: string + type: object + x-kubernetes-validations: + - message: url must not be empty if specified + rule: '!has(self.url) || self.url.size() > + 0' + - message: http must not be empty if specified + rule: '!has(self.http) || self.http.size() + > 0' + - message: https must not be empty if specified + rule: '!has(self.https) || self.https.size() + > 0' + - message: cacert must not be empty if specified + rule: '!has(self.cacert) || self.cacert.size() + > 0' + timeout: + description: Timeout configures connection and + read timeout settings. + properties: + connect: + description: Connect is the connection timeout + in seconds. + minimum: 1 + type: integer + read: + description: Read is the read timeout in + seconds. + minimum: 1 + type: integer + type: object + tls: + description: TLS configures TLS/SSL settings. + properties: + ciphers: + description: Ciphers is a list of allowed + TLS cipher suites. + items: + minLength: 1 + type: string + minItems: 1 + type: array + minVersion: + description: MinVersion sets the minimum + TLS version. + enum: + - TLSv1.2 + - TLSv1.3 + type: string + verify: + description: |- + Verify controls whether TLS certificate verification is enabled. + Trust anchors and client identity are configured globally via spec.tls. + type: boolean + type: object + type: object + projectId: + description: ProjectID is the watsonx.ai project + ID. + type: string + refreshModels: + description: |- + RefreshModels controls whether the provider periodically refreshes + its model list from the remote endpoint. + type: boolean + timeout: + description: Timeout is the timeout in seconds for + HTTP requests. + minimum: 1 + type: integer + required: + - apiKey + type: object + x-kubernetes-validations: + - message: endpoint must not be empty if specified + rule: '!has(self.endpoint) || self.endpoint.size() + > 0' + - message: projectId must not be empty if specified + rule: '!has(self.projectId) || self.projectId.size() + > 0' + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + type: object + type: object + responses: + description: ResponsesProvidersSpec configures responses providers. + properties: + inline: + description: ResponsesInlineProviders groups inline responses + providers. + properties: + builtin: + description: InlineBuiltinResponsesProvider configures + inline::builtin for responses. + properties: + compactionConfig: + description: |- + CompactionConfig configures conversation compaction behavior + and prompt templates. + properties: + defaultCompactThreshold: + description: |- + DefaultCompactThreshold is the token count threshold for auto-compaction. + Conversations exceeding this count will be automatically compacted. + minimum: 1 + type: integer + summarizationModel: + description: |- + SummarizationModel is the model to use for generating compaction + summaries. If unset, uses the same model as the conversation. + type: string + summarizationPrompt: + description: |- + SummarizationPrompt is the prompt used to instruct the model to + summarize conversation history during compaction. + type: string + summaryPrefix: + description: |- + SummaryPrefix is text prepended to the compaction summary to frame + it as a handoff for the next LLM context window. + type: string + tokenizerEncoding: + description: |- + TokenizerEncoding is the tiktoken encoding name for token counting + (e.g., "o200k_base", "cl100k_base"). + type: string + type: object + x-kubernetes-validations: + - message: summarizationPrompt must not be empty if + specified + rule: '!has(self.summarizationPrompt) || self.summarizationPrompt.size() + > 0' + - message: summaryPrefix must not be empty if specified + rule: '!has(self.summaryPrefix) || self.summaryPrefix.size() + > 0' + - message: summarizationModel must not be empty if + specified + rule: '!has(self.summarizationModel) || self.summarizationModel.size() + > 0' + - message: tokenizerEncoding must not be empty if + specified + rule: '!has(self.tokenizerEncoding) || self.tokenizerEncoding.size() + > 0' + vectorStoresConfig: + description: |- + VectorStoresConfig configures vector store behavior for file search + and retrieval-augmented generation. + properties: + annotationPromptParams: + description: AnnotationPromptParams configures + source annotation settings. + properties: + annotationInstructionTemplate: + description: AnnotationInstructionTemplate + provides instructions for citing sources. + type: string + chunkAnnotationTemplate: + description: ChunkAnnotationTemplate is the + template for chunks with annotation info. + type: string + enableAnnotations: + description: EnableAnnotations controls whether + source annotations are included. + type: boolean + type: object + chunkRetrievalParams: + description: ChunkRetrievalParams configures chunk + retrieval and ranking. + properties: + chunkMultiplier: + description: ChunkMultiplier multiplies the + number of chunks retrieved for over-retrieval. + minimum: 1 + type: integer + defaultRerankerStrategy: + description: DefaultRerankerStrategy is the + default reranking strategy. + enum: + - rrf + - weighted + - normalized + type: string + defaultSearchMode: + description: DefaultSearchMode is the default + search mode. + enum: + - vector + - keyword + - hybrid + type: string + maxTokensInContext: + description: MaxTokensInContext limits total + tokens allowed in RAG context. + minimum: 1 + type: integer + rrfImpactFactor: + description: |- + RRFImpactFactor is the impact factor for Reciprocal Rank Fusion reranking. + Specified as a decimal string (e.g., "60.0"). + type: string + weightedSearchAlpha: + description: |- + WeightedSearchAlpha is the alpha weight for weighted search reranking (0.0-1.0). + Specified as a decimal string (e.g., "0.5"). + type: string + type: object + contextPromptParams: + description: ContextPromptParams configures context + prompt templates. + properties: + chunkAnnotationTemplate: + description: ChunkAnnotationTemplate is the + template for formatting individual chunks. + type: string + contextTemplate: + description: ContextTemplate is the template + for explaining search results to the model. + type: string + type: object + contextualRetrievalParams: + description: ContextualRetrievalParams configures + contextual retrieval during ingestion. + properties: + defaultMaxConcurrency: + description: DefaultMaxConcurrency limits + concurrent LLM calls for contextualization. + minimum: 1 + type: integer + defaultTimeoutSeconds: + description: DefaultTimeoutSeconds is the + timeout per LLM contextualization call. + minimum: 1 + type: integer + maxDocumentTokens: + description: MaxDocumentTokens limits document + size in tokens for contextual retrieval. + minimum: 1 + type: integer + model: + description: Model is the default LLM model + for contextual retrieval. + properties: + embeddingDimensions: + description: EmbeddingDimensions is the + dimensionality of the embedding vectors. + minimum: 1 + type: integer + modelId: + description: ModelID is the model identifier. + minLength: 1 + type: string + providerId: + description: ProviderID is the provider + to use for this model. + minLength: 1 + type: string + required: + - modelId + - providerId + type: object + type: object + defaultEmbeddingModel: + description: DefaultEmbeddingModel configures + the default embedding model. + properties: + embeddingDimensions: + description: EmbeddingDimensions is the dimensionality + of the embedding vectors. + minimum: 1 + type: integer + modelId: + description: ModelID is the model identifier. + minLength: 1 + type: string + providerId: + description: ProviderID is the provider to + use for this model. + minLength: 1 + type: string + required: + - modelId + - providerId + type: object + defaultProviderId: + description: |- + DefaultProviderID is the vector_io provider to use when multiple + providers are available and none is specified. + type: string + defaultRerankerModel: + description: DefaultRerankerModel configures the + default reranker model. + properties: + modelId: + description: ModelID is the model identifier. + minLength: 1 + type: string + providerId: + description: ProviderID is the provider to + use for this model. + minLength: 1 + type: string + required: + - modelId + - providerId + type: object + fileBatchParams: + description: FileBatchParams configures file batch + processing. + properties: + cleanupIntervalSeconds: + description: CleanupIntervalSeconds is the + interval between expired batch cleanup runs. + minimum: 1 + type: integer + fileBatchChunkSize: + description: FileBatchChunkSize is the number + of files to process in each batch chunk. + minimum: 1 + type: integer + maxConcurrentFilesPerBatch: + description: MaxConcurrentFilesPerBatch limits + concurrent files processed per batch. + minimum: 1 + type: integer + type: object + fileIngestionParams: + description: FileIngestionParams configures file + ingestion chunk settings. + properties: + defaultChunkOverlapTokens: + description: DefaultChunkOverlapTokens is + the default overlap between chunks in tokens. + minimum: 0 + type: integer + defaultChunkSizeTokens: + description: DefaultChunkSizeTokens is the + default chunk size in tokens. + minimum: 1 + type: integer + type: object + fileSearchParams: + description: FileSearchParams configures file + search output formatting. + properties: + footerTemplate: + description: FooterTemplate is the template + for the footer text after search results. + type: string + headerTemplate: + description: HeaderTemplate is the template + for the header text before search results. + type: string + type: object + rewriteQueryParams: + description: RewriteQueryParams configures query + rewriting/expansion. Nil disables rewriting. + properties: + maxTokens: + description: MaxTokens is the maximum number + of tokens for query expansion responses. + minimum: 1 + type: integer + model: + description: Model is the LLM model used for + query rewriting. + properties: + embeddingDimensions: + description: EmbeddingDimensions is the + dimensionality of the embedding vectors. + minimum: 1 + type: integer + modelId: + description: ModelID is the model identifier. + minLength: 1 + type: string + providerId: + description: ProviderID is the provider + to use for this model. + minLength: 1 + type: string + required: + - modelId + - providerId + type: object + prompt: + description: |- + Prompt is the prompt template for query rewriting. + Use {query} as a placeholder for the original query. + type: string + temperature: + description: |- + Temperature controls randomness in query rewriting (0.0 = deterministic, 1.0 = creative). + Specified as a decimal string (e.g., "0.7"). + type: string + type: object + type: object + x-kubernetes-validations: + - message: defaultProviderId must not be empty if + specified + rule: '!has(self.defaultProviderId) || self.defaultProviderId.size() + > 0' + type: object + custom: + items: + description: CustomProvider defines the configuration + for a custom provider instance. + properties: + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + secretRefs: + additionalProperties: + description: |- + SecretKeyRef references a specific key in a Kubernetes Secret. + The Secret must be in the same namespace as the OGXServer and must have + the label ogx.io/watch: "true" to be detected by the operator's cache. + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + description: |- + SecretRefs is a map of named secret references for provider-specific + connection fields (e.g., host, password). Each key becomes the env var + field suffix and maps to config. with env var substitution. + Each Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + minProperties: 1 + type: object + settings: + description: |- + Settings contains provider-specific configuration merged into the + provider's config section in config.yaml. Passed through as-is + without any secret resolution. Use secretRefs for secret values. + x-kubernetes-preserve-unknown-fields: true + type: + description: |- + Type is the provider type, specified with a "remote::" or "inline::" + prefix (e.g., "remote::llama-guard", "inline::my-provider"). + minLength: 1 + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: type must have a 'remote::' or 'inline::' + prefix (e.g., 'remote::llama-guard', 'inline::my-provider') + rule: self.type.startsWith('remote::') || self.type.startsWith('inline::') + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + type: object + remote: + description: ResponsesRemoteProviders groups remote responses + providers. + properties: + custom: + items: + description: CustomProvider defines the configuration + for a custom provider instance. + properties: + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + secretRefs: + additionalProperties: + description: |- + SecretKeyRef references a specific key in a Kubernetes Secret. + The Secret must be in the same namespace as the OGXServer and must have + the label ogx.io/watch: "true" to be detected by the operator's cache. + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + description: |- + SecretRefs is a map of named secret references for provider-specific + connection fields (e.g., host, password). Each key becomes the env var + field suffix and maps to config. with env var substitution. + Each Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + minProperties: 1 + type: object + settings: + description: |- + Settings contains provider-specific configuration merged into the + provider's config section in config.yaml. Passed through as-is + without any secret resolution. Use secretRefs for secret values. + x-kubernetes-preserve-unknown-fields: true + type: + description: |- + Type is the provider type, specified with a "remote::" or "inline::" + prefix (e.g., "remote::llama-guard", "inline::my-provider"). + minLength: 1 + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: type must have a 'remote::' or 'inline::' + prefix (e.g., 'remote::llama-guard', 'inline::my-provider') + rule: self.type.startsWith('remote::') || self.type.startsWith('inline::') + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + type: object + type: object + toolRuntime: + description: ToolRuntimeProvidersSpec configures tool runtime + providers. + properties: + inline: + description: ToolRuntimeInlineProviders groups inline tool + runtime providers. + properties: + custom: + items: + description: CustomProvider defines the configuration + for a custom provider instance. + properties: + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + secretRefs: + additionalProperties: + description: |- + SecretKeyRef references a specific key in a Kubernetes Secret. + The Secret must be in the same namespace as the OGXServer and must have + the label ogx.io/watch: "true" to be detected by the operator's cache. + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + description: |- + SecretRefs is a map of named secret references for provider-specific + connection fields (e.g., host, password). Each key becomes the env var + field suffix and maps to config. with env var substitution. + Each Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + minProperties: 1 + type: object + settings: + description: |- + Settings contains provider-specific configuration merged into the + provider's config section in config.yaml. Passed through as-is + without any secret resolution. Use secretRefs for secret values. + x-kubernetes-preserve-unknown-fields: true + type: + description: |- + Type is the provider type, specified with a "remote::" or "inline::" + prefix (e.g., "remote::llama-guard", "inline::my-provider"). + minLength: 1 + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: type must have a 'remote::' or 'inline::' + prefix (e.g., 'remote::llama-guard', 'inline::my-provider') + rule: self.type.startsWith('remote::') || self.type.startsWith('inline::') + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + fileSearch: + items: + description: InlineFileSearchProvider configures inline::file-search. + properties: + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + vectorStoresConfig: + description: VectorStoresConfig configures vector + store behavior for file search. + properties: + annotationPromptParams: + description: AnnotationPromptParams configures + source annotation settings. + properties: + annotationInstructionTemplate: + description: AnnotationInstructionTemplate + provides instructions for citing sources. + type: string + chunkAnnotationTemplate: + description: ChunkAnnotationTemplate is + the template for chunks with annotation + info. + type: string + enableAnnotations: + description: EnableAnnotations controls + whether source annotations are included. + type: boolean + type: object + chunkRetrievalParams: + description: ChunkRetrievalParams configures + chunk retrieval and ranking. + properties: + chunkMultiplier: + description: ChunkMultiplier multiplies + the number of chunks retrieved for over-retrieval. + minimum: 1 + type: integer + defaultRerankerStrategy: + description: DefaultRerankerStrategy is + the default reranking strategy. + enum: + - rrf + - weighted + - normalized + type: string + defaultSearchMode: + description: DefaultSearchMode is the default + search mode. + enum: + - vector + - keyword + - hybrid + type: string + maxTokensInContext: + description: MaxTokensInContext limits total + tokens allowed in RAG context. + minimum: 1 + type: integer + rrfImpactFactor: + description: |- + RRFImpactFactor is the impact factor for Reciprocal Rank Fusion reranking. + Specified as a decimal string (e.g., "60.0"). + type: string + weightedSearchAlpha: + description: |- + WeightedSearchAlpha is the alpha weight for weighted search reranking (0.0-1.0). + Specified as a decimal string (e.g., "0.5"). + type: string + type: object + contextPromptParams: + description: ContextPromptParams configures + context prompt templates. + properties: + chunkAnnotationTemplate: + description: ChunkAnnotationTemplate is + the template for formatting individual + chunks. + type: string + contextTemplate: + description: ContextTemplate is the template + for explaining search results to the model. + type: string + type: object + contextualRetrievalParams: + description: ContextualRetrievalParams configures + contextual retrieval during ingestion. + properties: + defaultMaxConcurrency: + description: DefaultMaxConcurrency limits + concurrent LLM calls for contextualization. + minimum: 1 + type: integer + defaultTimeoutSeconds: + description: DefaultTimeoutSeconds is the + timeout per LLM contextualization call. + minimum: 1 + type: integer + maxDocumentTokens: + description: MaxDocumentTokens limits document + size in tokens for contextual retrieval. + minimum: 1 + type: integer + model: + description: Model is the default LLM model + for contextual retrieval. + properties: + embeddingDimensions: + description: EmbeddingDimensions is + the dimensionality of the embedding + vectors. + minimum: 1 + type: integer + modelId: + description: ModelID is the model identifier. + minLength: 1 + type: string + providerId: + description: ProviderID is the provider + to use for this model. + minLength: 1 + type: string + required: + - modelId + - providerId + type: object + type: object + defaultEmbeddingModel: + description: DefaultEmbeddingModel configures + the default embedding model. + properties: + embeddingDimensions: + description: EmbeddingDimensions is the + dimensionality of the embedding vectors. + minimum: 1 + type: integer + modelId: + description: ModelID is the model identifier. + minLength: 1 + type: string + providerId: + description: ProviderID is the provider + to use for this model. + minLength: 1 + type: string + required: + - modelId + - providerId + type: object + defaultProviderId: + description: |- + DefaultProviderID is the vector_io provider to use when multiple + providers are available and none is specified. + type: string + defaultRerankerModel: + description: DefaultRerankerModel configures + the default reranker model. + properties: + modelId: + description: ModelID is the model identifier. + minLength: 1 + type: string + providerId: + description: ProviderID is the provider + to use for this model. + minLength: 1 + type: string + required: + - modelId + - providerId + type: object + fileBatchParams: + description: FileBatchParams configures file + batch processing. + properties: + cleanupIntervalSeconds: + description: CleanupIntervalSeconds is the + interval between expired batch cleanup + runs. + minimum: 1 + type: integer + fileBatchChunkSize: + description: FileBatchChunkSize is the number + of files to process in each batch chunk. + minimum: 1 + type: integer + maxConcurrentFilesPerBatch: + description: MaxConcurrentFilesPerBatch + limits concurrent files processed per + batch. + minimum: 1 + type: integer + type: object + fileIngestionParams: + description: FileIngestionParams configures + file ingestion chunk settings. + properties: + defaultChunkOverlapTokens: + description: DefaultChunkOverlapTokens is + the default overlap between chunks in + tokens. + minimum: 0 + type: integer + defaultChunkSizeTokens: + description: DefaultChunkSizeTokens is the + default chunk size in tokens. + minimum: 1 + type: integer + type: object + fileSearchParams: + description: FileSearchParams configures file + search output formatting. + properties: + footerTemplate: + description: FooterTemplate is the template + for the footer text after search results. + type: string + headerTemplate: + description: HeaderTemplate is the template + for the header text before search results. + type: string + type: object + rewriteQueryParams: + description: RewriteQueryParams configures query + rewriting/expansion. Nil disables rewriting. + properties: + maxTokens: + description: MaxTokens is the maximum number + of tokens for query expansion responses. + minimum: 1 + type: integer + model: + description: Model is the LLM model used + for query rewriting. + properties: + embeddingDimensions: + description: EmbeddingDimensions is + the dimensionality of the embedding + vectors. + minimum: 1 + type: integer + modelId: + description: ModelID is the model identifier. + minLength: 1 + type: string + providerId: + description: ProviderID is the provider + to use for this model. + minLength: 1 + type: string + required: + - modelId + - providerId + type: object + prompt: + description: |- + Prompt is the prompt template for query rewriting. + Use {query} as a placeholder for the original query. + type: string + temperature: + description: |- + Temperature controls randomness in query rewriting (0.0 = deterministic, 1.0 = creative). + Specified as a decimal string (e.g., "0.7"). + type: string + type: object + type: object + x-kubernetes-validations: + - message: defaultProviderId must not be empty if + specified + rule: '!has(self.defaultProviderId) || self.defaultProviderId.size() + > 0' + type: object + x-kubernetes-validations: + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + type: object + remote: + description: ToolRuntimeRemoteProviders groups remote tool + runtime providers. + properties: + braveSearch: + items: + description: BraveSearchProvider configures a remote::brave-search + tool runtime provider. + properties: + apiKey: + description: |- + APIKey is the Brave Search API key. + The Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + maxResults: + description: MaxResults is the maximum number of + search results to return. + minimum: 1 + type: integer + required: + - apiKey + type: object + x-kubernetes-validations: + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + custom: + items: + description: CustomProvider defines the configuration + for a custom provider instance. + properties: + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + secretRefs: + additionalProperties: + description: |- + SecretKeyRef references a specific key in a Kubernetes Secret. + The Secret must be in the same namespace as the OGXServer and must have + the label ogx.io/watch: "true" to be detected by the operator's cache. + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + description: |- + SecretRefs is a map of named secret references for provider-specific + connection fields (e.g., host, password). Each key becomes the env var + field suffix and maps to config. with env var substitution. + Each Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + minProperties: 1 + type: object + settings: + description: |- + Settings contains provider-specific configuration merged into the + provider's config section in config.yaml. Passed through as-is + without any secret resolution. Use secretRefs for secret values. + x-kubernetes-preserve-unknown-fields: true + type: + description: |- + Type is the provider type, specified with a "remote::" or "inline::" + prefix (e.g., "remote::llama-guard", "inline::my-provider"). + minLength: 1 + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: type must have a 'remote::' or 'inline::' + prefix (e.g., 'remote::llama-guard', 'inline::my-provider') + rule: self.type.startsWith('remote::') || self.type.startsWith('inline::') + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + modelContextProtocol: + items: + description: ModelContextProtocolProvider configures + remote::model-context-protocol. + properties: + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + type: object + x-kubernetes-validations: + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + tavilySearch: + items: + description: TavilySearchProvider configures a remote::tavily-search + tool runtime provider. + properties: + apiKey: + description: |- + APIKey is the Tavily Search API key. + The Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + maxResults: + description: MaxResults is the maximum number of + search results to return. + minimum: 1 + type: integer + required: + - apiKey + type: object + x-kubernetes-validations: + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + type: object + type: object + vectorIo: + description: VectorIOProvidersSpec configures vector I/O providers. + properties: + inline: + description: VectorIOInlineProviders groups inline vector + I/O providers. + properties: + custom: + items: + description: CustomProvider defines the configuration + for a custom provider instance. + properties: + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + secretRefs: + additionalProperties: + description: |- + SecretKeyRef references a specific key in a Kubernetes Secret. + The Secret must be in the same namespace as the OGXServer and must have + the label ogx.io/watch: "true" to be detected by the operator's cache. + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + description: |- + SecretRefs is a map of named secret references for provider-specific + connection fields (e.g., host, password). Each key becomes the env var + field suffix and maps to config. with env var substitution. + Each Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + minProperties: 1 + type: object + settings: + description: |- + Settings contains provider-specific configuration merged into the + provider's config section in config.yaml. Passed through as-is + without any secret resolution. Use secretRefs for secret values. + x-kubernetes-preserve-unknown-fields: true + type: + description: |- + Type is the provider type, specified with a "remote::" or "inline::" + prefix (e.g., "remote::llama-guard", "inline::my-provider"). + minLength: 1 + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: type must have a 'remote::' or 'inline::' + prefix (e.g., 'remote::llama-guard', 'inline::my-provider') + rule: self.type.startsWith('remote::') || self.type.startsWith('inline::') + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + type: object + remote: + description: VectorIORemoteProviders groups remote vector + I/O providers. + properties: + custom: + items: + description: CustomProvider defines the configuration + for a custom provider instance. + properties: + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + secretRefs: + additionalProperties: + description: |- + SecretKeyRef references a specific key in a Kubernetes Secret. + The Secret must be in the same namespace as the OGXServer and must have + the label ogx.io/watch: "true" to be detected by the operator's cache. + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + description: |- + SecretRefs is a map of named secret references for provider-specific + connection fields (e.g., host, password). Each key becomes the env var + field suffix and maps to config. with env var substitution. + Each Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + minProperties: 1 + type: object + settings: + description: |- + Settings contains provider-specific configuration merged into the + provider's config section in config.yaml. Passed through as-is + without any secret resolution. Use secretRefs for secret values. + x-kubernetes-preserve-unknown-fields: true + type: + description: |- + Type is the provider type, specified with a "remote::" or "inline::" + prefix (e.g., "remote::llama-guard", "inline::my-provider"). + minLength: 1 + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: type must have a 'remote::' or 'inline::' + prefix (e.g., 'remote::llama-guard', 'inline::my-provider') + rule: self.type.startsWith('remote::') || self.type.startsWith('inline::') + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + milvus: + items: + description: MilvusProvider configures a remote::milvus + vector I/O provider instance. + properties: + consistencyLevel: + description: ConsistencyLevel is the consistency + level of the Milvus server. + type: string + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + token: + description: |- + Token is the authentication token for the Milvus server. + The Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + uri: + description: URI is the URI of the Milvus server. + minLength: 1 + type: string + required: + - uri + type: object + x-kubernetes-validations: + - message: consistencyLevel must not be empty if specified + rule: '!has(self.consistencyLevel) || self.consistencyLevel.size() + > 0' + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + pgvector: + items: + description: PgvectorProvider configures a remote::pgvector + vector I/O provider instance. + properties: + db: + description: DB is the PostgreSQL database name. + type: string + distanceMetric: + description: DistanceMetric is the distance metric + used for vector search. + enum: + - COSINE + - L2 + - L1 + - INNER_PRODUCT + type: string + host: + description: Host is the PostgreSQL server hostname. + type: string + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + password: + description: |- + Password is the PostgreSQL password. + The Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + port: + description: Port is the PostgreSQL server port. + maximum: 65535 + minimum: 1 + type: integer + user: + description: User is the PostgreSQL username. + type: string + vectorIndex: + description: |- + VectorIndex configures the vector index strategy for + Approximate Nearest Neighbor (ANN) search. + properties: + hnsw: + description: HNSW configures an HNSW index. + properties: + efConstruction: + description: EfConstruction controls the + index build-time accuracy/speed tradeoff. + minimum: 1 + type: integer + efSearch: + description: EfSearch controls the query-time + accuracy/speed tradeoff. + minimum: 1 + type: integer + m: + description: M is the maximum number of + connections per element in the HNSW graph. + minimum: 1 + type: integer + type: object + ivfFlat: + description: IVFFlat configures an IVFFlat index. + properties: + nlist: + description: Nlist is the number of inverted + lists (clusters). + minimum: 1 + type: integer + nprobe: + description: Nprobe is the number of clusters + to search at query time. + minimum: 1 + type: integer + type: object + type: object + x-kubernetes-validations: + - message: one of hnsw or ivfFlat must be specified + rule: has(self.hnsw) || has(self.ivfFlat) + - message: only one of hnsw or ivfFlat can be specified + rule: '!(has(self.hnsw) && has(self.ivfFlat))' + required: + - password + type: object + x-kubernetes-validations: + - message: host must not be empty if specified + rule: '!has(self.host) || self.host.size() > 0' + - message: db must not be empty if specified + rule: '!has(self.db) || self.db.size() > 0' + - message: user must not be empty if specified + rule: '!has(self.user) || self.user.size() > 0' + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + qdrant: + items: + description: QdrantProvider configures a remote::qdrant + vector I/O provider instance. + properties: + apiKey: + description: |- + APIKey is the authentication key for the Qdrant server. + The Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + grpcPort: + description: GRPCPort is the gRPC port of the Qdrant + server. + maximum: 65535 + minimum: 1 + type: integer + host: + description: Host is the hostname of the Qdrant + server. + type: string + https: + description: HTTPS controls whether to use HTTPS + for the connection. + type: boolean + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + location: + description: Location is the Qdrant server location + identifier. + type: string + port: + description: Port is the REST API port of the Qdrant + server. + maximum: 65535 + minimum: 1 + type: integer + preferGrpc: + description: PreferGRPC controls whether to prefer + gRPC over REST for communication. + type: boolean + prefix: + description: Prefix is the URL path prefix for the + Qdrant server. + type: string + timeout: + description: Timeout is the connection timeout in + seconds. + minimum: 1 + type: integer + url: + description: URL is the URL of the Qdrant server. + type: string + type: object + x-kubernetes-validations: + - message: at least one of url or host must be specified + rule: has(self.url) || has(self.host) + - message: url must not be empty if specified + rule: '!has(self.url) || self.url.size() > 0' + - message: host must not be empty if specified + rule: '!has(self.host) || self.host.size() > 0' + - message: location must not be empty if specified + rule: '!has(self.location) || self.location.size() + > 0' + - message: prefix must not be empty if specified + rule: '!has(self.prefix) || self.prefix.size() > 0' + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + type: object + type: object + type: object + registryRefreshIntervalSeconds: + description: |- + RegistryRefreshIntervalSeconds configures how often the server refreshes + its model registry, in seconds. When omitted, the server's built-in + default is used. + format: int32 + minimum: 1 + type: integer + resources: + description: |- + Resources declares models to register. + Mutually exclusive with overrideConfig. + properties: + models: + description: Models to register with inference providers. + items: + description: ModelConfig defines a model registration with optional + provider assignment and metadata. + properties: + contextLength: + description: ContextLength is the model context window size. + type: integer + modelType: + description: ModelType is the model type classification. + type: string + name: + description: Name is the model identifier (e.g., "llama3.2-8b"). + minLength: 1 + type: string + provider: + description: |- + Provider is the ID of the provider to register this model with. + Defaults to the first inference provider when omitted. + type: string + quantization: + description: Quantization is the quantization method. + type: string + required: + - name + type: object + x-kubernetes-validations: + - message: provider must not be empty if specified + rule: '!has(self.provider) || self.provider.size() > 0' + - message: modelType must not be empty if specified + rule: '!has(self.modelType) || self.modelType.size() > 0' + - message: quantization must not be empty if specified + rule: '!has(self.quantization) || self.quantization.size() + > 0' + minItems: 1 + type: array + type: object + storage: + description: |- + Storage configures state storage backends (KV and SQL). + Mutually exclusive with overrideConfig. + properties: + kv: + description: KV configures key-value storage. + properties: + endpoint: + description: Endpoint is the Redis endpoint URL. Required + when type is "redis". + type: string + password: + description: |- + Password references a Secret for Redis authentication. + The Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + type: + default: sqlite + description: Type is the KV storage backend type. + enum: + - sqlite + - redis + type: string + type: object + x-kubernetes-validations: + - message: endpoint is required when type is redis + rule: self.type != 'redis' || has(self.endpoint) + - message: endpoint is only valid when type is redis + rule: '!has(self.endpoint) || self.type == ''redis''' + - message: password is only valid when type is redis + rule: '!has(self.password) || self.type == ''redis''' + sql: + description: SQL configures SQL storage. + properties: + connectionString: + description: |- + ConnectionString references a Secret containing the database connection string. + Required when type is "postgres". + The Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + type: + default: sqlite + description: Type is the SQL storage backend type. + enum: + - sqlite + - postgres + type: string + type: object + x-kubernetes-validations: + - message: connectionString is required when type is postgres + rule: self.type != 'postgres' || has(self.connectionString) + - message: connectionString is only valid when type is postgres + rule: '!has(self.connectionString) || self.type == ''postgres''' + type: object + tls: + description: |- + TLS configures outbound TLS trust anchors and client identity for + connections to providers and backends. + properties: + identity: + description: |- + Identity configures client certificate and key for mTLS authentication + with providers and backends. + properties: + cert: + description: |- + Cert references a ConfigMap key containing the PEM-encoded TLS client certificate. + The ConfigMap must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the ConfigMap. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the ConfigMap. + minLength: 1 + type: string + required: + - key + - name + type: object + key: + description: |- + Key references a Secret key containing the PEM-encoded TLS client private key. + The Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + required: + - cert + - key + type: object + trust: + description: |- + Trust configures CA certificates for verifying outbound TLS connections + to providers and backends. + properties: + caCertificates: + description: |- + CACertificates lists ConfigMap keys containing PEM-encoded CA certificates. + All certificates are concatenated into a single trust bundle. + Referenced ConfigMaps must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + items: + description: |- + ConfigMapKeyRef references a key within a ConfigMap. + The ConfigMap must be in the same namespace as the OGXServer and must have + the label ogx.io/watch: "true" to be detected by the operator's cache. + properties: + key: + description: Key is the key within the ConfigMap. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the ConfigMap. + minLength: 1 + type: string + required: + - key + - name + type: object + minItems: 1 + type: array + type: object + type: object + workload: + description: Workload consolidates Kubernetes deployment settings. + properties: + autoscaling: + description: Autoscaling configures HPA for the server pods. + properties: + maxReplicas: + description: MaxReplicas is the upper bound replica count. + format: int32 + minimum: 1 + type: integer + minReplicas: + description: MinReplicas is the lower bound replica count. + format: int32 + minimum: 1 + type: integer + targetCPUUtilizationPercentage: + description: TargetCPUUtilizationPercentage configures CPU-based + scaling. + format: int32 + maximum: 100 + minimum: 1 + type: integer + targetMemoryUtilizationPercentage: + description: TargetMemoryUtilizationPercentage configures + memory-based scaling. + format: int32 + maximum: 100 + minimum: 1 + type: integer + required: + - maxReplicas + type: object + x-kubernetes-validations: + - message: maxReplicas must be greater than or equal to minReplicas + rule: '!has(self.minReplicas) || self.maxReplicas >= self.minReplicas' + overrides: + description: Overrides allows pod-level customization. + properties: + args: + description: Args overrides the container arguments. + items: + minLength: 1 + type: string + minItems: 1 + type: array + command: + description: Command overrides the container command. + items: + minLength: 1 + type: string + minItems: 1 + type: array + env: + description: Env specifies additional environment variables. + items: + description: EnvVar represents an environment variable present + in a Container. + properties: + name: + description: |- + Name of the environment variable. + May consist of any printable ASCII characters except '='. + type: string + value: + description: |- + Variable references $(VAR_NAME) are expanded + using the previously defined environment variables in the container and + any service environment variables. If a variable cannot be resolved, + the reference in the input string will be unchanged. Double $$ are reduced + to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. + "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". + Escaped references will never be expanded, regardless of whether the variable + exists or not. + Defaults to "". + type: string + valueFrom: + description: Source for the environment variable's value. + Cannot be used if value is not empty. + properties: + configMapKeyRef: + description: Selects a key of a ConfigMap. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap or + its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + description: |- + Selects a field of the pod: supports metadata.name, metadata.namespace, `metadata.labels['']`, `metadata.annotations['']`, + spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs. + properties: + apiVersion: + description: Version of the schema the FieldPath + is written in terms of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to select in + the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + fileKeyRef: + description: |- + FileKeyRef selects a key of the env file. + Requires the EnvFiles feature gate to be enabled. + properties: + key: + description: |- + The key within the env file. An invalid key will prevent the pod from starting. + The keys defined within a source may consist of any printable ASCII characters except '='. + During Alpha stage of the EnvFiles feature gate, the key size is limited to 128 characters. + type: string + optional: + default: false + description: |- + Specify whether the file or its key must be defined. If the file or key + does not exist, then the env var is not published. + If optional is set to true and the specified key does not exist, + the environment variable will not be set in the Pod's containers. + + If optional is set to false and the specified key does not exist, + an error will be returned during Pod creation. + type: boolean + path: + description: |- + The path within the volume from which to select the file. + Must be relative and may not contain the '..' path or start with '..'. + type: string + volumeName: + description: The name of the volume mount containing + the env file. + type: string + required: + - key + - path + - volumeName + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, limits.ephemeral-storage, requests.cpu, requests.memory and requests.ephemeral-storage) are currently supported. + properties: + containerName: + description: 'Container name: required for volumes, + optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format of + the exposed resources, defaults to "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + description: Selects a key of a secret in the pod's + namespace + properties: + key: + description: The key of the secret to select + from. Must be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or its + key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + minItems: 1 + type: array + serviceAccountName: + description: ServiceAccountName specifies a custom ServiceAccount. + type: string + volumeMounts: + description: VolumeMounts adds additional volume mounts to + the container. + items: + description: VolumeMount describes a mounting of a Volume + within a container. + properties: + mountPath: + description: |- + Path within the container at which the volume should be mounted. Must + not contain ':'. + type: string + mountPropagation: + description: |- + mountPropagation determines how mounts are propagated from the host + to container and the other way around. + When not set, MountPropagationNone is used. + This field is beta in 1.10. + When RecursiveReadOnly is set to IfPossible or to Enabled, MountPropagation must be None or unspecified + (which defaults to None). + type: string + name: + description: This must match the Name of a Volume. + type: string + readOnly: + description: |- + Mounted read-only if true, read-write otherwise (false or unspecified). + Defaults to false. + type: boolean + recursiveReadOnly: + description: |- + RecursiveReadOnly specifies whether read-only mounts should be handled + recursively. + + If ReadOnly is false, this field has no meaning and must be unspecified. + + If ReadOnly is true, and this field is set to Disabled, the mount is not made + recursively read-only. If this field is set to IfPossible, the mount is made + recursively read-only, if it is supported by the container runtime. If this + field is set to Enabled, the mount is made recursively read-only if it is + supported by the container runtime, otherwise the pod will not be started and + an error will be generated to indicate the reason. + + If this field is set to IfPossible or Enabled, MountPropagation must be set to + None (or be unspecified, which defaults to None). + + If this field is not specified, it is treated as an equivalent of Disabled. + type: string + subPath: + description: |- + Path within the volume from which the container's volume should be mounted. + Defaults to "" (volume's root). + type: string + subPathExpr: + description: |- + Expanded path within the volume from which the container's volume should be mounted. + Behaves similarly to SubPath but environment variable references $(VAR_NAME) are expanded using the container's environment. + Defaults to "" (volume's root). + SubPathExpr and SubPath are mutually exclusive. + type: string + required: + - mountPath + - name + type: object + minItems: 1 + type: array + volumes: + description: Volumes adds additional volumes to the Pod. + items: + description: Volume represents a named volume in a pod that + may be accessed by any container in the pod. + properties: + awsElasticBlockStore: + description: |- + awsElasticBlockStore represents an AWS Disk resource that is attached to a + kubelet's host machine and then exposed to the pod. + Deprecated: AWSElasticBlockStore is deprecated. All operations for the in-tree + awsElasticBlockStore type are redirected to the ebs.csi.aws.com CSI driver. + More info: https://kubernetes.io/docs/concepts/storage/volumes#awselasticblockstore + properties: + fsType: + description: |- + fsType is the filesystem type of the volume that you want to mount. + Tip: Ensure that the filesystem type is supported by the host operating system. + Examples: "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" if unspecified. + More info: https://kubernetes.io/docs/concepts/storage/volumes#awselasticblockstore + type: string + partition: + description: |- + partition is the partition in the volume that you want to mount. + If omitted, the default is to mount by volume name. + Examples: For volume /dev/sda1, you specify the partition as "1". + Similarly, the volume partition for /dev/sda is "0" (or you can leave the property empty). + format: int32 + type: integer + readOnly: + description: |- + readOnly value true will force the readOnly setting in VolumeMounts. + More info: https://kubernetes.io/docs/concepts/storage/volumes#awselasticblockstore + type: boolean + volumeID: + description: |- + volumeID is unique ID of the persistent disk resource in AWS (Amazon EBS volume). + More info: https://kubernetes.io/docs/concepts/storage/volumes#awselasticblockstore + type: string + required: + - volumeID + type: object + azureDisk: + description: |- + azureDisk represents an Azure Data Disk mount on the host and bind mount to the pod. + Deprecated: AzureDisk is deprecated. All operations for the in-tree azureDisk type + are redirected to the disk.csi.azure.com CSI driver. + properties: + cachingMode: + description: 'cachingMode is the Host Caching mode: + None, Read Only, Read Write.' + type: string + diskName: + description: diskName is the Name of the data disk + in the blob storage + type: string + diskURI: + description: diskURI is the URI of data disk in + the blob storage + type: string + fsType: + default: ext4 + description: |- + fsType is Filesystem type to mount. + Must be a filesystem type supported by the host operating system. + Ex. "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" if unspecified. + type: string + kind: + description: 'kind expected values are Shared: multiple + blob disks per storage account Dedicated: single + blob disk per storage account Managed: azure + managed data disk (only in managed availability + set). defaults to shared' + type: string + readOnly: + default: false + description: |- + readOnly Defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + required: + - diskName + - diskURI + type: object + azureFile: + description: |- + azureFile represents an Azure File Service mount on the host and bind mount to the pod. + Deprecated: AzureFile is deprecated. All operations for the in-tree azureFile type + are redirected to the file.csi.azure.com CSI driver. + properties: + readOnly: + description: |- + readOnly defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + secretName: + description: secretName is the name of secret that + contains Azure Storage Account Name and Key + type: string + shareName: + description: shareName is the azure share Name + type: string + required: + - secretName + - shareName + type: object + cephfs: + description: |- + cephFS represents a Ceph FS mount on the host that shares a pod's lifetime. + Deprecated: CephFS is deprecated and the in-tree cephfs type is no longer supported. + properties: + monitors: + description: |- + monitors is Required: Monitors is a collection of Ceph monitors + More info: https://examples.k8s.io/volumes/cephfs/README.md#how-to-use-it + items: + type: string + type: array + x-kubernetes-list-type: atomic + path: + description: 'path is Optional: Used as the mounted + root, rather than the full Ceph tree, default + is /' + type: string + readOnly: + description: |- + readOnly is Optional: Defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + More info: https://examples.k8s.io/volumes/cephfs/README.md#how-to-use-it + type: boolean + secretFile: + description: |- + secretFile is Optional: SecretFile is the path to key ring for User, default is /etc/ceph/user.secret + More info: https://examples.k8s.io/volumes/cephfs/README.md#how-to-use-it + type: string + secretRef: + description: |- + secretRef is Optional: SecretRef is reference to the authentication secret for User, default is empty. + More info: https://examples.k8s.io/volumes/cephfs/README.md#how-to-use-it + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + user: + description: |- + user is optional: User is the rados user name, default is admin + More info: https://examples.k8s.io/volumes/cephfs/README.md#how-to-use-it + type: string + required: + - monitors + type: object + cinder: + description: |- + cinder represents a cinder volume attached and mounted on kubelets host machine. + Deprecated: Cinder is deprecated. All operations for the in-tree cinder type + are redirected to the cinder.csi.openstack.org CSI driver. + More info: https://examples.k8s.io/mysql-cinder-pd/README.md + properties: + fsType: + description: |- + fsType is the filesystem type to mount. + Must be a filesystem type supported by the host operating system. + Examples: "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" if unspecified. + More info: https://examples.k8s.io/mysql-cinder-pd/README.md + type: string + readOnly: + description: |- + readOnly defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + More info: https://examples.k8s.io/mysql-cinder-pd/README.md + type: boolean + secretRef: + description: |- + secretRef is optional: points to a secret object containing parameters used to connect + to OpenStack. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + volumeID: + description: |- + volumeID used to identify the volume in cinder. + More info: https://examples.k8s.io/mysql-cinder-pd/README.md + type: string + required: + - volumeID + type: object + configMap: + description: configMap represents a configMap that should + populate this volume + properties: + defaultMode: + description: |- + defaultMode is optional: mode bits used to set permissions on created files by default. + Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + Defaults to 0644. + Directories within the path are not affected by this setting. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + items: + description: |- + items if unspecified, each key-value pair in the Data field of the referenced + ConfigMap will be projected into the volume as a file whose name is the + key and content is the value. If specified, the listed keys will be + projected into the specified paths, and unlisted keys will not be + present. If a key is specified which is not present in the ConfigMap, + the volume setup will error unless it is marked optional. Paths must be + relative and may not contain the '..' path or start with '..'. + items: + description: Maps a string key to a path within + a volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: |- + mode is Optional: mode bits used to set permissions on this file. + Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + May not contain the path element '..'. + May not start with the string '..'. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: optional specify whether the ConfigMap + or its keys must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + csi: + description: csi (Container Storage Interface) represents + ephemeral storage that is handled by certain external + CSI drivers. + properties: + driver: + description: |- + driver is the name of the CSI driver that handles this volume. + Consult with your admin for the correct name as registered in the cluster. + type: string + fsType: + description: |- + fsType to mount. Ex. "ext4", "xfs", "ntfs". + If not provided, the empty value is passed to the associated CSI driver + which will determine the default filesystem to apply. + type: string + nodePublishSecretRef: + description: |- + nodePublishSecretRef is a reference to the secret object containing + sensitive information to pass to the CSI driver to complete the CSI + NodePublishVolume and NodeUnpublishVolume calls. + This field is optional, and may be empty if no secret is required. If the + secret object contains more than one secret, all secret references are passed. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + readOnly: + description: |- + readOnly specifies a read-only configuration for the volume. + Defaults to false (read/write). + type: boolean + volumeAttributes: + additionalProperties: + type: string + description: |- + volumeAttributes stores driver-specific properties that are passed to the CSI + driver. Consult your driver's documentation for supported values. + type: object + required: + - driver + type: object + downwardAPI: + description: downwardAPI represents downward API about + the pod that should populate this volume + properties: + defaultMode: + description: |- + Optional: mode bits to use on created files by default. Must be a + Optional: mode bits used to set permissions on created files by default. + Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + Defaults to 0644. + Directories within the path are not affected by this setting. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + items: + description: Items is a list of downward API volume + file + items: + description: DownwardAPIVolumeFile represents + information to create the file containing the + pod field + properties: + fieldRef: + description: 'Required: Selects a field of + the pod: only annotations, labels, name, + namespace and uid are supported.' + properties: + apiVersion: + description: Version of the schema the + FieldPath is written in terms of, defaults + to "v1". + type: string + fieldPath: + description: Path of the field to select + in the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + mode: + description: |- + Optional: mode bits used to set permissions on this file, must be an octal value + between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: 'Required: Path is the relative + path name of the file to be created. Must + not be absolute or contain the ''..'' path. + Must be utf-8 encoded. The first item of + the relative path must not start with ''..''' + type: string + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, requests.cpu and requests.memory) are currently supported. + properties: + containerName: + description: 'Container name: required + for volumes, optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format + of the exposed resources, defaults to + "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + required: + - path + type: object + type: array + x-kubernetes-list-type: atomic + type: object + emptyDir: + description: |- + emptyDir represents a temporary directory that shares a pod's lifetime. + More info: https://kubernetes.io/docs/concepts/storage/volumes#emptydir + properties: + medium: + description: |- + medium represents what type of storage medium should back this directory. + The default is "" which means to use the node's default medium. + Must be an empty string (default) or Memory. + More info: https://kubernetes.io/docs/concepts/storage/volumes#emptydir + type: string + sizeLimit: + anyOf: + - type: integer + - type: string + description: |- + sizeLimit is the total amount of local storage required for this EmptyDir volume. + The size limit is also applicable for memory medium. + The maximum usage on memory medium EmptyDir would be the minimum value between + the SizeLimit specified here and the sum of memory limits of all containers in a pod. + The default is nil which means that the limit is undefined. + More info: https://kubernetes.io/docs/concepts/storage/volumes#emptydir + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + type: object + ephemeral: + description: |- + ephemeral represents a volume that is handled by a cluster storage driver. + The volume's lifecycle is tied to the pod that defines it - it will be created before the pod starts, + and deleted when the pod is removed. + + Use this if: + a) the volume is only needed while the pod runs, + b) features of normal volumes like restoring from snapshot or capacity + tracking are needed, + c) the storage driver is specified through a storage class, and + d) the storage driver supports dynamic volume provisioning through + a PersistentVolumeClaim (see EphemeralVolumeSource for more + information on the connection between this volume type + and PersistentVolumeClaim). + + Use PersistentVolumeClaim or one of the vendor-specific + APIs for volumes that persist for longer than the lifecycle + of an individual pod. + + Use CSI for light-weight local ephemeral volumes if the CSI driver is meant to + be used that way - see the documentation of the driver for + more information. + + A pod can use both types of ephemeral volumes and + persistent volumes at the same time. + properties: + volumeClaimTemplate: + description: |- + Will be used to create a stand-alone PVC to provision the volume. + The pod in which this EphemeralVolumeSource is embedded will be the + owner of the PVC, i.e. the PVC will be deleted together with the + pod. The name of the PVC will be `-` where + `` is the name from the `PodSpec.Volumes` array + entry. Pod validation will reject the pod if the concatenated name + is not valid for a PVC (for example, too long). + + An existing PVC with that name that is not owned by the pod + will *not* be used for the pod to avoid using an unrelated + volume by mistake. Starting the pod is then blocked until + the unrelated PVC is removed. If such a pre-created PVC is + meant to be used by the pod, the PVC has to updated with an + owner reference to the pod once the pod exists. Normally + this should not be necessary, but it may be useful when + manually reconstructing a broken cluster. + + This field is read-only and no changes will be made by Kubernetes + to the PVC after it has been created. + + Required, must not be nil. + properties: + metadata: + description: |- + May contain labels and annotations that will be copied into the PVC + when creating it. No other fields are allowed and will be rejected during + validation. + type: object + spec: + description: |- + The specification for the PersistentVolumeClaim. The entire content is + copied unchanged into the PVC that gets created from this + template. The same fields as in a PersistentVolumeClaim + are also valid here. + properties: + accessModes: + description: |- + accessModes contains the desired access modes the volume should have. + More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#access-modes-1 + items: + type: string + type: array + x-kubernetes-list-type: atomic + dataSource: + description: |- + dataSource field can be used to specify either: + * An existing VolumeSnapshot object (snapshot.storage.k8s.io/VolumeSnapshot) + * An existing PVC (PersistentVolumeClaim) + If the provisioner or an external controller can support the specified data source, + it will create a new volume based on the contents of the specified data source. + When the AnyVolumeDataSource feature gate is enabled, dataSource contents will be copied to dataSourceRef, + and dataSourceRef contents will be copied to dataSource when dataSourceRef.namespace is not specified. + If the namespace is specified, then dataSourceRef will not be copied to dataSource. + properties: + apiGroup: + description: |- + APIGroup is the group for the resource being referenced. + If APIGroup is not specified, the specified Kind must be in the core API group. + For any other third-party types, APIGroup is required. + type: string + kind: + description: Kind is the type of resource + being referenced + type: string + name: + description: Name is the name of resource + being referenced + type: string + required: + - kind + - name + type: object + x-kubernetes-map-type: atomic + dataSourceRef: + description: |- + dataSourceRef specifies the object from which to populate the volume with data, if a non-empty + volume is desired. This may be any object from a non-empty API group (non + core object) or a PersistentVolumeClaim object. + When this field is specified, volume binding will only succeed if the type of + the specified object matches some installed volume populator or dynamic + provisioner. + This field will replace the functionality of the dataSource field and as such + if both fields are non-empty, they must have the same value. For backwards + compatibility, when namespace isn't specified in dataSourceRef, + both fields (dataSource and dataSourceRef) will be set to the same + value automatically if one of them is empty and the other is non-empty. + When namespace is specified in dataSourceRef, + dataSource isn't set to the same value and must be empty. + There are three important differences between dataSource and dataSourceRef: + * While dataSource only allows two specific types of objects, dataSourceRef + allows any non-core object, as well as PersistentVolumeClaim objects. + * While dataSource ignores disallowed values (dropping them), dataSourceRef + preserves all values, and generates an error if a disallowed value is + specified. + * While dataSource only allows local objects, dataSourceRef allows objects + in any namespaces. + (Beta) Using this field requires the AnyVolumeDataSource feature gate to be enabled. + (Alpha) Using the namespace field of dataSourceRef requires the CrossNamespaceVolumeDataSource feature gate to be enabled. + properties: + apiGroup: + description: |- + APIGroup is the group for the resource being referenced. + If APIGroup is not specified, the specified Kind must be in the core API group. + For any other third-party types, APIGroup is required. + type: string + kind: + description: Kind is the type of resource + being referenced + type: string + name: + description: Name is the name of resource + being referenced + type: string + namespace: + description: |- + Namespace is the namespace of resource being referenced + Note that when a namespace is specified, a gateway.networking.k8s.io/ReferenceGrant object is required in the referent namespace to allow that namespace's owner to accept the reference. See the ReferenceGrant documentation for details. + (Alpha) This field requires the CrossNamespaceVolumeDataSource feature gate to be enabled. + type: string + required: + - kind + - name + type: object + resources: + description: |- + resources represents the minimum resources the volume should have. + If RecoverVolumeExpansionFailure feature is enabled users are allowed to specify resource requirements + that are lower than previous value but must still be higher than capacity recorded in the + status field of the claim. + More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#resources + properties: + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + selector: + description: selector is a label query over + volumes to consider for binding. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The + requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + storageClassName: + description: |- + storageClassName is the name of the StorageClass required by the claim. + More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#class-1 + type: string + volumeAttributesClassName: + description: |- + volumeAttributesClassName may be used to set the VolumeAttributesClass used by this claim. + If specified, the CSI driver will create or update the volume with the attributes defined + in the corresponding VolumeAttributesClass. This has a different purpose than storageClassName, + it can be changed after the claim is created. An empty string or nil value indicates that no + VolumeAttributesClass will be applied to the claim. If the claim enters an Infeasible error state, + this field can be reset to its previous value (including nil) to cancel the modification. + If the resource referred to by volumeAttributesClass does not exist, this PersistentVolumeClaim will be + set to a Pending state, as reflected by the modifyVolumeStatus field, until such as a resource + exists. + More info: https://kubernetes.io/docs/concepts/storage/volume-attributes-classes/ + type: string + volumeMode: + description: |- + volumeMode defines what type of volume is required by the claim. + Value of Filesystem is implied when not included in claim spec. + type: string + volumeName: + description: volumeName is the binding reference + to the PersistentVolume backing this claim. + type: string + type: object + required: + - spec + type: object + type: object + fc: + description: fc represents a Fibre Channel resource + that is attached to a kubelet's host machine and then + exposed to the pod. + properties: + fsType: + description: |- + fsType is the filesystem type to mount. + Must be a filesystem type supported by the host operating system. + Ex. "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" if unspecified. + type: string + lun: + description: 'lun is Optional: FC target lun number' + format: int32 + type: integer + readOnly: + description: |- + readOnly is Optional: Defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + targetWWNs: + description: 'targetWWNs is Optional: FC target + worldwide names (WWNs)' + items: + type: string + type: array + x-kubernetes-list-type: atomic + wwids: + description: |- + wwids Optional: FC volume world wide identifiers (wwids) + Either wwids or combination of targetWWNs and lun must be set, but not both simultaneously. + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + flexVolume: + description: |- + flexVolume represents a generic volume resource that is + provisioned/attached using an exec based plugin. + Deprecated: FlexVolume is deprecated. Consider using a CSIDriver instead. + properties: + driver: + description: driver is the name of the driver to + use for this volume. + type: string + fsType: + description: |- + fsType is the filesystem type to mount. + Must be a filesystem type supported by the host operating system. + Ex. "ext4", "xfs", "ntfs". The default filesystem depends on FlexVolume script. + type: string + options: + additionalProperties: + type: string + description: 'options is Optional: this field holds + extra command options if any.' + type: object + readOnly: + description: |- + readOnly is Optional: defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + secretRef: + description: |- + secretRef is Optional: secretRef is reference to the secret object containing + sensitive information to pass to the plugin scripts. This may be + empty if no secret object is specified. If the secret object + contains more than one secret, all secrets are passed to the plugin + scripts. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + required: + - driver + type: object + flocker: + description: |- + flocker represents a Flocker volume attached to a kubelet's host machine. This depends on the Flocker control service being running. + Deprecated: Flocker is deprecated and the in-tree flocker type is no longer supported. + properties: + datasetName: + description: |- + datasetName is Name of the dataset stored as metadata -> name on the dataset for Flocker + should be considered as deprecated + type: string + datasetUUID: + description: datasetUUID is the UUID of the dataset. + This is unique identifier of a Flocker dataset + type: string + type: object + gcePersistentDisk: + description: |- + gcePersistentDisk represents a GCE Disk resource that is attached to a + kubelet's host machine and then exposed to the pod. + Deprecated: GCEPersistentDisk is deprecated. All operations for the in-tree + gcePersistentDisk type are redirected to the pd.csi.storage.gke.io CSI driver. + More info: https://kubernetes.io/docs/concepts/storage/volumes#gcepersistentdisk + properties: + fsType: + description: |- + fsType is filesystem type of the volume that you want to mount. + Tip: Ensure that the filesystem type is supported by the host operating system. + Examples: "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" if unspecified. + More info: https://kubernetes.io/docs/concepts/storage/volumes#gcepersistentdisk + type: string + partition: + description: |- + partition is the partition in the volume that you want to mount. + If omitted, the default is to mount by volume name. + Examples: For volume /dev/sda1, you specify the partition as "1". + Similarly, the volume partition for /dev/sda is "0" (or you can leave the property empty). + More info: https://kubernetes.io/docs/concepts/storage/volumes#gcepersistentdisk + format: int32 + type: integer + pdName: + description: |- + pdName is unique name of the PD resource in GCE. Used to identify the disk in GCE. + More info: https://kubernetes.io/docs/concepts/storage/volumes#gcepersistentdisk + type: string + readOnly: + description: |- + readOnly here will force the ReadOnly setting in VolumeMounts. + Defaults to false. + More info: https://kubernetes.io/docs/concepts/storage/volumes#gcepersistentdisk + type: boolean + required: + - pdName + type: object + gitRepo: + description: |- + gitRepo represents a git repository at a particular revision. + Deprecated: GitRepo is deprecated. To provision a container with a git repo, mount an + EmptyDir into an InitContainer that clones the repo using git, then mount the EmptyDir + into the Pod's container. + properties: + directory: + description: |- + directory is the target directory name. + Must not contain or start with '..'. If '.' is supplied, the volume directory will be the + git repository. Otherwise, if specified, the volume will contain the git repository in + the subdirectory with the given name. + type: string + repository: + description: repository is the URL + type: string + revision: + description: revision is the commit hash for the + specified revision. + type: string + required: + - repository + type: object + glusterfs: + description: |- + glusterfs represents a Glusterfs mount on the host that shares a pod's lifetime. + Deprecated: Glusterfs is deprecated and the in-tree glusterfs type is no longer supported. + properties: + endpoints: + description: endpoints is the endpoint name that + details Glusterfs topology. + type: string + path: + description: |- + path is the Glusterfs volume path. + More info: https://examples.k8s.io/volumes/glusterfs/README.md#create-a-pod + type: string + readOnly: + description: |- + readOnly here will force the Glusterfs volume to be mounted with read-only permissions. + Defaults to false. + More info: https://examples.k8s.io/volumes/glusterfs/README.md#create-a-pod + type: boolean + required: + - endpoints + - path + type: object + hostPath: + description: |- + hostPath represents a pre-existing file or directory on the host + machine that is directly exposed to the container. This is generally + used for system agents or other privileged things that are allowed + to see the host machine. Most containers will NOT need this. + More info: https://kubernetes.io/docs/concepts/storage/volumes#hostpath + properties: + path: + description: |- + path of the directory on the host. + If the path is a symlink, it will follow the link to the real path. + More info: https://kubernetes.io/docs/concepts/storage/volumes#hostpath + type: string + type: + description: |- + type for HostPath Volume + Defaults to "" + More info: https://kubernetes.io/docs/concepts/storage/volumes#hostpath + type: string + required: + - path + type: object + image: + description: |- + image represents an OCI object (a container image or artifact) pulled and mounted on the kubelet's host machine. + The volume is resolved at pod startup depending on which PullPolicy value is provided: + + - Always: the kubelet always attempts to pull the reference. Container creation will fail If the pull fails. + - Never: the kubelet never pulls the reference and only uses a local image or artifact. Container creation will fail if the reference isn't present. + - IfNotPresent: the kubelet pulls if the reference isn't already present on disk. Container creation will fail if the reference isn't present and the pull fails. + + The volume gets re-resolved if the pod gets deleted and recreated, which means that new remote content will become available on pod recreation. + A failure to resolve or pull the image during pod startup will block containers from starting and may add significant latency. Failures will be retried using normal volume backoff and will be reported on the pod reason and message. + The types of objects that may be mounted by this volume are defined by the container runtime implementation on a host machine and at minimum must include all valid types supported by the container image field. + The OCI object gets mounted in a single directory (spec.containers[*].volumeMounts.mountPath) by merging the manifest layers in the same way as for container images. + The volume will be mounted read-only (ro) and non-executable files (noexec). + Sub path mounts for containers are not supported (spec.containers[*].volumeMounts.subpath) before 1.33. + The field spec.securityContext.fsGroupChangePolicy has no effect on this volume type. + properties: + pullPolicy: + description: |- + Policy for pulling OCI objects. Possible values are: + Always: the kubelet always attempts to pull the reference. Container creation will fail If the pull fails. + Never: the kubelet never pulls the reference and only uses a local image or artifact. Container creation will fail if the reference isn't present. + IfNotPresent: the kubelet pulls if the reference isn't already present on disk. Container creation will fail if the reference isn't present and the pull fails. + Defaults to Always if :latest tag is specified, or IfNotPresent otherwise. + type: string + reference: + description: |- + Required: Image or artifact reference to be used. + Behaves in the same way as pod.spec.containers[*].image. + Pull secrets will be assembled in the same way as for the container image by looking up node credentials, SA image pull secrets, and pod spec image pull secrets. + More info: https://kubernetes.io/docs/concepts/containers/images + This field is optional to allow higher level config management to default or override + container images in workload controllers like Deployments and StatefulSets. + type: string + type: object + iscsi: + description: |- + iscsi represents an ISCSI Disk resource that is attached to a + kubelet's host machine and then exposed to the pod. + More info: https://kubernetes.io/docs/concepts/storage/volumes/#iscsi + properties: + chapAuthDiscovery: + description: chapAuthDiscovery defines whether support + iSCSI Discovery CHAP authentication + type: boolean + chapAuthSession: + description: chapAuthSession defines whether support + iSCSI Session CHAP authentication + type: boolean + fsType: + description: |- + fsType is the filesystem type of the volume that you want to mount. + Tip: Ensure that the filesystem type is supported by the host operating system. + Examples: "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" if unspecified. + More info: https://kubernetes.io/docs/concepts/storage/volumes#iscsi + type: string + initiatorName: + description: |- + initiatorName is the custom iSCSI Initiator Name. + If initiatorName is specified with iscsiInterface simultaneously, new iSCSI interface + : will be created for the connection. + type: string + iqn: + description: iqn is the target iSCSI Qualified Name. + type: string + iscsiInterface: + default: default + description: |- + iscsiInterface is the interface Name that uses an iSCSI transport. + Defaults to 'default' (tcp). + type: string + lun: + description: lun represents iSCSI Target Lun number. + format: int32 + type: integer + portals: + description: |- + portals is the iSCSI Target Portal List. The portal is either an IP or ip_addr:port if the port + is other than default (typically TCP ports 860 and 3260). + items: + type: string + type: array + x-kubernetes-list-type: atomic + readOnly: + description: |- + readOnly here will force the ReadOnly setting in VolumeMounts. + Defaults to false. + type: boolean + secretRef: + description: secretRef is the CHAP Secret for iSCSI + target and initiator authentication + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + targetPortal: + description: |- + targetPortal is iSCSI Target Portal. The Portal is either an IP or ip_addr:port if the port + is other than default (typically TCP ports 860 and 3260). + type: string + required: + - iqn + - lun + - targetPortal + type: object + name: + description: |- + name of the volume. + Must be a DNS_LABEL and unique within the pod. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + nfs: + description: |- + nfs represents an NFS mount on the host that shares a pod's lifetime + More info: https://kubernetes.io/docs/concepts/storage/volumes#nfs + properties: + path: + description: |- + path that is exported by the NFS server. + More info: https://kubernetes.io/docs/concepts/storage/volumes#nfs + type: string + readOnly: + description: |- + readOnly here will force the NFS export to be mounted with read-only permissions. + Defaults to false. + More info: https://kubernetes.io/docs/concepts/storage/volumes#nfs + type: boolean + server: + description: |- + server is the hostname or IP address of the NFS server. + More info: https://kubernetes.io/docs/concepts/storage/volumes#nfs + type: string + required: + - path + - server + type: object + persistentVolumeClaim: + description: |- + persistentVolumeClaimVolumeSource represents a reference to a + PersistentVolumeClaim in the same namespace. + More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#persistentvolumeclaims + properties: + claimName: + description: |- + claimName is the name of a PersistentVolumeClaim in the same namespace as the pod using this volume. + More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#persistentvolumeclaims + type: string + readOnly: + description: |- + readOnly Will force the ReadOnly setting in VolumeMounts. + Default false. + type: boolean + required: + - claimName + type: object + photonPersistentDisk: + description: |- + photonPersistentDisk represents a PhotonController persistent disk attached and mounted on kubelets host machine. + Deprecated: PhotonPersistentDisk is deprecated and the in-tree photonPersistentDisk type is no longer supported. + properties: + fsType: + description: |- + fsType is the filesystem type to mount. + Must be a filesystem type supported by the host operating system. + Ex. "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" if unspecified. + type: string + pdID: + description: pdID is the ID that identifies Photon + Controller persistent disk + type: string + required: + - pdID + type: object + portworxVolume: + description: |- + portworxVolume represents a portworx volume attached and mounted on kubelets host machine. + Deprecated: PortworxVolume is deprecated. All operations for the in-tree portworxVolume type + are redirected to the pxd.portworx.com CSI driver when the CSIMigrationPortworx feature-gate + is on. + properties: + fsType: + description: |- + fSType represents the filesystem type to mount + Must be a filesystem type supported by the host operating system. + Ex. "ext4", "xfs". Implicitly inferred to be "ext4" if unspecified. + type: string + readOnly: + description: |- + readOnly defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + volumeID: + description: volumeID uniquely identifies a Portworx + volume + type: string + required: + - volumeID + type: object + projected: + description: projected items for all in one resources + secrets, configmaps, and downward API + properties: + defaultMode: + description: |- + defaultMode are the mode bits used to set permissions on created files by default. + Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + Directories within the path are not affected by this setting. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + sources: + description: |- + sources is the list of volume projections. Each entry in this list + handles one source. + items: + description: |- + Projection that may be projected along with other supported volume types. + Exactly one of these fields must be set. + properties: + clusterTrustBundle: + description: |- + ClusterTrustBundle allows a pod to access the `.spec.trustBundle` field + of ClusterTrustBundle objects in an auto-updating file. + + Alpha, gated by the ClusterTrustBundleProjection feature gate. + + ClusterTrustBundle objects can either be selected by name, or by the + combination of signer name and a label selector. + + Kubelet performs aggressive normalization of the PEM contents written + into the pod filesystem. Esoteric PEM features such as inter-block + comments and block headers are stripped. Certificates are deduplicated. + The ordering of certificates within the file is arbitrary, and Kubelet + may change the order over time. + properties: + labelSelector: + description: |- + Select all ClusterTrustBundles that match this label selector. Only has + effect if signerName is set. Mutually-exclusive with name. If unset, + interpreted as "match nothing". If set but empty, interpreted as "match + everything". + properties: + matchExpressions: + description: matchExpressions is a + list of label selector requirements. + The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + name: + description: |- + Select a single ClusterTrustBundle by object name. Mutually-exclusive + with signerName and labelSelector. + type: string + optional: + description: |- + If true, don't block pod startup if the referenced ClusterTrustBundle(s) + aren't available. If using name, then the named ClusterTrustBundle is + allowed not to exist. If using signerName, then the combination of + signerName and labelSelector is allowed to match zero + ClusterTrustBundles. + type: boolean + path: + description: Relative path from the volume + root to write the bundle. + type: string + signerName: + description: |- + Select all ClusterTrustBundles that match this signer name. + Mutually-exclusive with name. The contents of all selected + ClusterTrustBundles will be unified and deduplicated. + type: string + required: + - path + type: object + configMap: + description: configMap information about the + configMap data to project + properties: + items: + description: |- + items if unspecified, each key-value pair in the Data field of the referenced + ConfigMap will be projected into the volume as a file whose name is the + key and content is the value. If specified, the listed keys will be + projected into the specified paths, and unlisted keys will not be + present. If a key is specified which is not present in the ConfigMap, + the volume setup will error unless it is marked optional. Paths must be + relative and may not contain the '..' path or start with '..'. + items: + description: Maps a string key to a + path within a volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: |- + mode is Optional: mode bits used to set permissions on this file. + Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + May not contain the path element '..'. + May not start with the string '..'. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: optional specify whether + the ConfigMap or its keys must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + downwardAPI: + description: downwardAPI information about + the downwardAPI data to project + properties: + items: + description: Items is a list of DownwardAPIVolume + file + items: + description: DownwardAPIVolumeFile represents + information to create the file containing + the pod field + properties: + fieldRef: + description: 'Required: Selects + a field of the pod: only annotations, + labels, name, namespace and uid + are supported.' + properties: + apiVersion: + description: Version of the + schema the FieldPath is written + in terms of, defaults to "v1". + type: string + fieldPath: + description: Path of the field + to select in the specified + API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + mode: + description: |- + Optional: mode bits used to set permissions on this file, must be an octal value + between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: 'Required: Path is the + relative path name of the file + to be created. Must not be absolute + or contain the ''..'' path. Must + be utf-8 encoded. The first item + of the relative path must not + start with ''..''' + type: string + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, requests.cpu and requests.memory) are currently supported. + properties: + containerName: + description: 'Container name: + required for volumes, optional + for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output + format of the exposed resources, + defaults to "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource + to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + required: + - path + type: object + type: array + x-kubernetes-list-type: atomic + type: object + podCertificate: + description: |- + Projects an auto-rotating credential bundle (private key and certificate + chain) that the pod can use either as a TLS client or server. + + Kubelet generates a private key and uses it to send a + PodCertificateRequest to the named signer. Once the signer approves the + request and issues a certificate chain, Kubelet writes the key and + certificate chain to the pod filesystem. The pod does not start until + certificates have been issued for each podCertificate projected volume + source in its spec. + + Kubelet will begin trying to rotate the certificate at the time indicated + by the signer using the PodCertificateRequest.Status.BeginRefreshAt + timestamp. + + Kubelet can write a single file, indicated by the credentialBundlePath + field, or separate files, indicated by the keyPath and + certificateChainPath fields. + + The credential bundle is a single file in PEM format. The first PEM + entry is the private key (in PKCS#8 format), and the remaining PEM + entries are the certificate chain issued by the signer (typically, + signers will return their certificate chain in leaf-to-root order). + + Prefer using the credential bundle format, since your application code + can read it atomically. If you use keyPath and certificateChainPath, + your application must make two separate file reads. If these coincide + with a certificate rotation, it is possible that the private key and leaf + certificate you read may not correspond to each other. Your application + will need to check for this condition, and re-read until they are + consistent. + + The named signer controls chooses the format of the certificate it + issues; consult the signer implementation's documentation to learn how to + use the certificates it issues. + properties: + certificateChainPath: + description: |- + Write the certificate chain at this path in the projected volume. + + Most applications should use credentialBundlePath. When using keyPath + and certificateChainPath, your application needs to check that the key + and leaf certificate are consistent, because it is possible to read the + files mid-rotation. + type: string + credentialBundlePath: + description: |- + Write the credential bundle at this path in the projected volume. + + The credential bundle is a single file that contains multiple PEM blocks. + The first PEM block is a PRIVATE KEY block, containing a PKCS#8 private + key. + + The remaining blocks are CERTIFICATE blocks, containing the issued + certificate chain from the signer (leaf and any intermediates). + + Using credentialBundlePath lets your Pod's application code make a single + atomic read that retrieves a consistent key and certificate chain. If you + project them to separate files, your application code will need to + additionally check that the leaf certificate was issued to the key. + type: string + keyPath: + description: |- + Write the key at this path in the projected volume. + + Most applications should use credentialBundlePath. When using keyPath + and certificateChainPath, your application needs to check that the key + and leaf certificate are consistent, because it is possible to read the + files mid-rotation. + type: string + keyType: + description: |- + The type of keypair Kubelet will generate for the pod. + + Valid values are "RSA3072", "RSA4096", "ECDSAP256", "ECDSAP384", + "ECDSAP521", and "ED25519". + type: string + maxExpirationSeconds: + description: |- + maxExpirationSeconds is the maximum lifetime permitted for the + certificate. + + Kubelet copies this value verbatim into the PodCertificateRequests it + generates for this projection. + + If omitted, kube-apiserver will set it to 86400(24 hours). kube-apiserver + will reject values shorter than 3600 (1 hour). The maximum allowable + value is 7862400 (91 days). + + The signer implementation is then free to issue a certificate with any + lifetime *shorter* than MaxExpirationSeconds, but no shorter than 3600 + seconds (1 hour). This constraint is enforced by kube-apiserver. + `kubernetes.io` signers will never issue certificates with a lifetime + longer than 24 hours. + format: int32 + type: integer + signerName: + description: Kubelet's generated CSRs + will be addressed to this signer. + type: string + required: + - keyType + - signerName + type: object + secret: + description: secret information about the + secret data to project + properties: + items: + description: |- + items if unspecified, each key-value pair in the Data field of the referenced + Secret will be projected into the volume as a file whose name is the + key and content is the value. If specified, the listed keys will be + projected into the specified paths, and unlisted keys will not be + present. If a key is specified which is not present in the Secret, + the volume setup will error unless it is marked optional. Paths must be + relative and may not contain the '..' path or start with '..'. + items: + description: Maps a string key to a + path within a volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: |- + mode is Optional: mode bits used to set permissions on this file. + Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + May not contain the path element '..'. + May not start with the string '..'. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: optional field specify whether + the Secret or its key must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + serviceAccountToken: + description: serviceAccountToken is information + about the serviceAccountToken data to project + properties: + audience: + description: |- + audience is the intended audience of the token. A recipient of a token + must identify itself with an identifier specified in the audience of the + token, and otherwise should reject the token. The audience defaults to the + identifier of the apiserver. + type: string + expirationSeconds: + description: |- + expirationSeconds is the requested duration of validity of the service + account token. As the token approaches expiration, the kubelet volume + plugin will proactively rotate the service account token. The kubelet will + start trying to rotate the token if the token is older than 80 percent of + its time to live or if the token is older than 24 hours.Defaults to 1 hour + and must be at least 10 minutes. + format: int64 + type: integer + path: + description: |- + path is the path relative to the mount point of the file to project the + token into. + type: string + required: + - path + type: object + type: object + type: array + x-kubernetes-list-type: atomic + type: object + quobyte: + description: |- + quobyte represents a Quobyte mount on the host that shares a pod's lifetime. + Deprecated: Quobyte is deprecated and the in-tree quobyte type is no longer supported. + properties: + group: + description: |- + group to map volume access to + Default is no group + type: string + readOnly: + description: |- + readOnly here will force the Quobyte volume to be mounted with read-only permissions. + Defaults to false. + type: boolean + registry: + description: |- + registry represents a single or multiple Quobyte Registry services + specified as a string as host:port pair (multiple entries are separated with commas) + which acts as the central registry for volumes + type: string + tenant: + description: |- + tenant owning the given Quobyte volume in the Backend + Used with dynamically provisioned Quobyte volumes, value is set by the plugin + type: string + user: + description: |- + user to map volume access to + Defaults to serivceaccount user + type: string + volume: + description: volume is a string that references + an already created Quobyte volume by name. + type: string + required: + - registry + - volume + type: object + rbd: + description: |- + rbd represents a Rados Block Device mount on the host that shares a pod's lifetime. + Deprecated: RBD is deprecated and the in-tree rbd type is no longer supported. + properties: + fsType: + description: |- + fsType is the filesystem type of the volume that you want to mount. + Tip: Ensure that the filesystem type is supported by the host operating system. + Examples: "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" if unspecified. + More info: https://kubernetes.io/docs/concepts/storage/volumes#rbd + type: string + image: + description: |- + image is the rados image name. + More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it + type: string + keyring: + default: /etc/ceph/keyring + description: |- + keyring is the path to key ring for RBDUser. + Default is /etc/ceph/keyring. + More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it + type: string + monitors: + description: |- + monitors is a collection of Ceph monitors. + More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it + items: + type: string + type: array + x-kubernetes-list-type: atomic + pool: + default: rbd + description: |- + pool is the rados pool name. + Default is rbd. + More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it + type: string + readOnly: + description: |- + readOnly here will force the ReadOnly setting in VolumeMounts. + Defaults to false. + More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it + type: boolean + secretRef: + description: |- + secretRef is name of the authentication secret for RBDUser. If provided + overrides keyring. + Default is nil. + More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + user: + default: admin + description: |- + user is the rados user name. + Default is admin. + More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it + type: string + required: + - image + - monitors + type: object + scaleIO: + description: |- + scaleIO represents a ScaleIO persistent volume attached and mounted on Kubernetes nodes. + Deprecated: ScaleIO is deprecated and the in-tree scaleIO type is no longer supported. + properties: + fsType: + default: xfs + description: |- + fsType is the filesystem type to mount. + Must be a filesystem type supported by the host operating system. + Ex. "ext4", "xfs", "ntfs". + Default is "xfs". + type: string + gateway: + description: gateway is the host address of the + ScaleIO API Gateway. + type: string + protectionDomain: + description: protectionDomain is the name of the + ScaleIO Protection Domain for the configured storage. + type: string + readOnly: + description: |- + readOnly Defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + secretRef: + description: |- + secretRef references to the secret for ScaleIO user and other + sensitive information. If this is not provided, Login operation will fail. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + sslEnabled: + description: sslEnabled Flag enable/disable SSL + communication with Gateway, default false + type: boolean + storageMode: + default: ThinProvisioned + description: |- + storageMode indicates whether the storage for a volume should be ThickProvisioned or ThinProvisioned. + Default is ThinProvisioned. + type: string + storagePool: + description: storagePool is the ScaleIO Storage + Pool associated with the protection domain. + type: string + system: + description: system is the name of the storage system + as configured in ScaleIO. + type: string + volumeName: + description: |- + volumeName is the name of a volume already created in the ScaleIO system + that is associated with this volume source. + type: string + required: + - gateway + - secretRef + - system + type: object + secret: + description: |- + secret represents a secret that should populate this volume. + More info: https://kubernetes.io/docs/concepts/storage/volumes#secret + properties: + defaultMode: + description: |- + defaultMode is Optional: mode bits used to set permissions on created files by default. + Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values + for mode bits. Defaults to 0644. + Directories within the path are not affected by this setting. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + items: + description: |- + items If unspecified, each key-value pair in the Data field of the referenced + Secret will be projected into the volume as a file whose name is the + key and content is the value. If specified, the listed keys will be + projected into the specified paths, and unlisted keys will not be + present. If a key is specified which is not present in the Secret, + the volume setup will error unless it is marked optional. Paths must be + relative and may not contain the '..' path or start with '..'. + items: + description: Maps a string key to a path within + a volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: |- + mode is Optional: mode bits used to set permissions on this file. + Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + May not contain the path element '..'. + May not start with the string '..'. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + optional: + description: optional field specify whether the + Secret or its keys must be defined + type: boolean + secretName: + description: |- + secretName is the name of the secret in the pod's namespace to use. + More info: https://kubernetes.io/docs/concepts/storage/volumes#secret + type: string + type: object + storageos: + description: |- + storageOS represents a StorageOS volume attached and mounted on Kubernetes nodes. + Deprecated: StorageOS is deprecated and the in-tree storageos type is no longer supported. + properties: + fsType: + description: |- + fsType is the filesystem type to mount. + Must be a filesystem type supported by the host operating system. + Ex. "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" if unspecified. + type: string + readOnly: + description: |- + readOnly defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + secretRef: + description: |- + secretRef specifies the secret to use for obtaining the StorageOS API + credentials. If not specified, default values will be attempted. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + volumeName: + description: |- + volumeName is the human-readable name of the StorageOS volume. Volume + names are only unique within a namespace. + type: string + volumeNamespace: + description: |- + volumeNamespace specifies the scope of the volume within StorageOS. If no + namespace is specified then the Pod's namespace will be used. This allows the + Kubernetes name scoping to be mirrored within StorageOS for tighter integration. + Set VolumeName to any name to override the default behaviour. + Set to "default" if you are not using namespaces within StorageOS. + Namespaces that do not pre-exist within StorageOS will be created. + type: string + type: object + vsphereVolume: + description: |- + vsphereVolume represents a vSphere volume attached and mounted on kubelets host machine. + Deprecated: VsphereVolume is deprecated. All operations for the in-tree vsphereVolume type + are redirected to the csi.vsphere.vmware.com CSI driver. + properties: + fsType: + description: |- + fsType is filesystem type to mount. + Must be a filesystem type supported by the host operating system. + Ex. "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" if unspecified. + type: string + storagePolicyID: + description: storagePolicyID is the storage Policy + Based Management (SPBM) profile ID associated + with the StoragePolicyName. + type: string + storagePolicyName: + description: storagePolicyName is the storage Policy + Based Management (SPBM) profile name. + type: string + volumePath: + description: volumePath is the path that identifies + vSphere volume vmdk + type: string + required: + - volumePath + type: object + required: + - name + type: object + minItems: 1 + type: array + type: object + x-kubernetes-validations: + - message: serviceAccountName must not be empty if specified + rule: '!has(self.serviceAccountName) || self.serviceAccountName.size() + > 0' + podDisruptionBudget: + description: PodDisruptionBudget controls voluntary disruption + tolerance. + properties: + maxUnavailable: + anyOf: + - type: integer + - type: string + description: MaxUnavailable is the maximum number of pods + that can be disrupted simultaneously. + x-kubernetes-int-or-string: true + minAvailable: + anyOf: + - type: integer + - type: string + description: MinAvailable is the minimum number of pods that + must remain available. + x-kubernetes-int-or-string: true + type: object + x-kubernetes-validations: + - message: at least one of minAvailable or maxUnavailable must + be specified + rule: has(self.minAvailable) || has(self.maxUnavailable) + - message: minAvailable and maxUnavailable are mutually exclusive + rule: '!(has(self.minAvailable) && has(self.maxUnavailable))' + replicas: + default: 1 + description: Replicas is the desired Pod replica count. + format: int32 + minimum: 0 + type: integer + resources: + description: Resources defines CPU/memory requests and limits. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This field depends on the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + storage: + description: Storage defines PVC configuration. + properties: + mountPath: + default: /.ogx + description: MountPath is the container mount path for the + PVC. + type: string + size: + anyOf: + - type: integer + - type: string + description: Size is the size of the PVC. + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + type: object + x-kubernetes-validations: + - message: mountPath must not be empty if specified + rule: '!has(self.mountPath) || self.mountPath.size() > 0' + - message: size must be a positive quantity + rule: '!has(self.size) || quantity(self.size).isGreaterThan(quantity(''0''))' + topologySpreadConstraints: + description: TopologySpreadConstraints defines Pod spreading rules. + items: + description: TopologySpreadConstraint specifies how to spread + matching pods among the given topology. + properties: + labelSelector: + description: |- + LabelSelector is used to find matching pods. + Pods that match this label selector are counted to determine the number of pods + in their corresponding topology domain. + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select the pods over which + spreading will be calculated. The keys are used to lookup values from the + incoming pod labels, those key-value labels are ANDed with labelSelector + to select the group of existing pods over which spreading will be calculated + for the incoming pod. The same key is forbidden to exist in both MatchLabelKeys and LabelSelector. + MatchLabelKeys cannot be set when LabelSelector isn't set. + Keys that don't exist in the incoming pod labels will + be ignored. A null or empty list means only match against labelSelector. + + This is a beta field and requires the MatchLabelKeysInPodTopologySpread feature gate to be enabled (enabled by default). + items: + type: string + type: array + x-kubernetes-list-type: atomic + maxSkew: + description: |- + MaxSkew describes the degree to which pods may be unevenly distributed. + When `whenUnsatisfiable=DoNotSchedule`, it is the maximum permitted difference + between the number of matching pods in the target topology and the global minimum. + The global minimum is the minimum number of matching pods in an eligible domain + or zero if the number of eligible domains is less than MinDomains. + For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same + labelSelector spread as 2/2/1: + In this case, the global minimum is 1. + | zone1 | zone2 | zone3 | + | P P | P P | P | + - if MaxSkew is 1, incoming pod can only be scheduled to zone3 to become 2/2/2; + scheduling it onto zone1(zone2) would make the ActualSkew(3-1) on zone1(zone2) + violate MaxSkew(1). + - if MaxSkew is 2, incoming pod can be scheduled onto any zone. + When `whenUnsatisfiable=ScheduleAnyway`, it is used to give higher precedence + to topologies that satisfy it. + It's a required field. Default value is 1 and 0 is not allowed. + format: int32 + type: integer + minDomains: + description: |- + MinDomains indicates a minimum number of eligible domains. + When the number of eligible domains with matching topology keys is less than minDomains, + Pod Topology Spread treats "global minimum" as 0, and then the calculation of Skew is performed. + And when the number of eligible domains with matching topology keys equals or greater than minDomains, + this value has no effect on scheduling. + As a result, when the number of eligible domains is less than minDomains, + scheduler won't schedule more than maxSkew Pods to those domains. + If value is nil, the constraint behaves as if MinDomains is equal to 1. + Valid values are integers greater than 0. + When value is not nil, WhenUnsatisfiable must be DoNotSchedule. + + For example, in a 3-zone cluster, MaxSkew is set to 2, MinDomains is set to 5 and pods with the same + labelSelector spread as 2/2/2: + | zone1 | zone2 | zone3 | + | P P | P P | P P | + The number of domains is less than 5(MinDomains), so "global minimum" is treated as 0. + In this situation, new pod with the same labelSelector cannot be scheduled, + because computed skew will be 3(3 - 0) if new Pod is scheduled to any of the three zones, + it will violate MaxSkew. + format: int32 + type: integer + nodeAffinityPolicy: + description: |- + NodeAffinityPolicy indicates how we will treat Pod's nodeAffinity/nodeSelector + when calculating pod topology spread skew. Options are: + - Honor: only nodes matching nodeAffinity/nodeSelector are included in the calculations. + - Ignore: nodeAffinity/nodeSelector are ignored. All nodes are included in the calculations. + + If this value is nil, the behavior is equivalent to the Honor policy. + type: string + nodeTaintsPolicy: + description: |- + NodeTaintsPolicy indicates how we will treat node taints when calculating + pod topology spread skew. Options are: + - Honor: nodes without taints, along with tainted nodes for which the incoming pod + has a toleration, are included. + - Ignore: node taints are ignored. All nodes are included. + + If this value is nil, the behavior is equivalent to the Ignore policy. + type: string + topologyKey: + description: |- + TopologyKey is the key of node labels. Nodes that have a label with this key + and identical values are considered to be in the same topology. + We consider each as a "bucket", and try to put balanced number + of pods into each bucket. + We define a domain as a particular instance of a topology. + Also, we define an eligible domain as a domain whose nodes meet the requirements of + nodeAffinityPolicy and nodeTaintsPolicy. + e.g. If TopologyKey is "kubernetes.io/hostname", each Node is a domain of that topology. + And, if TopologyKey is "topology.kubernetes.io/zone", each zone is a domain of that topology. + It's a required field. + type: string + whenUnsatisfiable: + description: |- + WhenUnsatisfiable indicates how to deal with a pod if it doesn't satisfy + the spread constraint. + - DoNotSchedule (default) tells the scheduler not to schedule it. + - ScheduleAnyway tells the scheduler to schedule the pod in any location, + but giving higher precedence to topologies that would help reduce the + skew. + A constraint is considered "Unsatisfiable" for an incoming pod + if and only if every possible node assignment for that pod would violate + "MaxSkew" on some topology. + For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same + labelSelector spread as 3/1/1: + | zone1 | zone2 | zone3 | + | P P P | P | P | + If WhenUnsatisfiable is set to DoNotSchedule, incoming pod can only be scheduled + to zone2(zone3) to become 3/2/1(3/1/2) as ActualSkew(2-1) on zone2(zone3) satisfies + MaxSkew(1). In other words, the cluster can still be imbalanced, but scheduler + won't make it *more* imbalanced. + It's a required field. + type: string + required: + - maxSkew + - topologyKey + - whenUnsatisfiable + type: object + minItems: 1 + type: array + workers: + description: Workers configures the number of uvicorn worker processes. + format: int32 + minimum: 1 + type: integer + type: object + required: + - distribution + type: object + x-kubernetes-validations: + - message: overrideConfig and providers are mutually exclusive + rule: '!has(self.overrideConfig) || !has(self.providers)' + - message: overrideConfig and resources are mutually exclusive + rule: '!has(self.overrideConfig) || !has(self.resources)' + - message: overrideConfig and storage are mutually exclusive + rule: '!has(self.overrideConfig) || !has(self.storage)' + - message: overrideConfig and disabledAPIs are mutually exclusive + rule: '!has(self.overrideConfig) || !has(self.disabledAPIs)' + - message: overrideConfig and baseConfig are mutually exclusive + rule: '!has(self.overrideConfig) || !has(self.baseConfig)' + - message: inference cannot be both in providers and disabledAPIs + rule: '!has(self.providers) || !has(self.disabledAPIs) || !self.disabledAPIs.exists(d, + d == ''inference'') || !has(self.providers.inference)' + - message: vector_io cannot be both in providers and disabledAPIs + rule: '!has(self.providers) || !has(self.disabledAPIs) || !self.disabledAPIs.exists(d, + d == ''vector_io'') || !has(self.providers.vectorIo)' + - message: tool_runtime cannot be both in providers and disabledAPIs + rule: '!has(self.providers) || !has(self.disabledAPIs) || !self.disabledAPIs.exists(d, + d == ''tool_runtime'') || !has(self.providers.toolRuntime)' + - message: files cannot be both in providers and disabledAPIs + rule: '!has(self.providers) || !has(self.disabledAPIs) || !self.disabledAPIs.exists(d, + d == ''files'') || !has(self.providers.files)' + - message: batches cannot be both in providers and disabledAPIs + rule: '!has(self.providers) || !has(self.disabledAPIs) || !self.disabledAPIs.exists(d, + d == ''batches'') || !has(self.providers.batches)' + - message: responses cannot be both in providers and disabledAPIs + rule: '!has(self.providers) || !has(self.disabledAPIs) || !self.disabledAPIs.exists(d, + d == ''responses'') || !has(self.providers.responses)' + - message: file_processors cannot be both in providers and disabledAPIs + rule: '!has(self.providers) || !has(self.disabledAPIs) || !self.disabledAPIs.exists(d, + d == ''file_processors'') || !has(self.providers.fileProcessors)' + status: + description: OGXServerStatus defines the observed state of OGXServer. + properties: + availableReplicas: + description: AvailableReplicas is the number of available replicas. + format: int32 + type: integer + conditions: + description: Conditions represent the latest available observations + of the server's state. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + configGeneration: + description: ConfigGeneration tracks config generation details. + properties: + configMapName: + description: ConfigMapName is the name of the generated ConfigMap. + type: string + configVersion: + description: ConfigVersion is the config.yaml schema version. + type: integer + generatedAt: + description: GeneratedAt is the timestamp of the last generation. + format: date-time + type: string + observedGeneration: + description: ObservedGeneration is the spec generation that was + last processed. + format: int64 + type: integer + providerCount: + description: ProviderCount is the number of configured providers. + type: integer + resourceCount: + description: ResourceCount is the number of registered resources. + type: integer + type: object + distributionConfig: + description: DistributionConfig contains provider information from + the running server. + properties: + activeDistribution: + type: string + availableDistributions: + additionalProperties: + type: string + type: object + providers: + items: + description: ProviderInfo represents a single provider from + the providers endpoint. + properties: + api: + type: string + config: + x-kubernetes-preserve-unknown-fields: true + health: + description: ProviderHealthStatus represents the health + status of a provider. + properties: + message: + type: string + status: + type: string + required: + - message + - status + type: object + provider_id: + type: string + provider_type: + type: string + required: + - api + - config + - health + - provider_id + - provider_type + type: object + type: array + type: object + externalURL: + description: ExternalURL is the external URL when external access + is configured. + type: string + phase: + description: Phase represents the current phase of the server. + enum: + - Pending + - Initializing + - Ready + - Failed + - Terminating + type: string + resolvedDistribution: + description: ResolvedDistribution tracks the resolved image and config + source. + properties: + configHash: + description: ConfigHash is the SHA256 hash of the base config + used. + type: string + configSource: + description: ConfigSource indicates the base config origin (for + example "configmap" or "oci-label"). + type: string + image: + description: Image is the resolved container image reference (with + digest when available). + type: string + type: object + serviceURL: + description: ServiceURL is the internal Kubernetes service URL. + type: string + version: + description: Version contains version information for both operator + and server. + properties: + lastUpdated: + format: date-time + type: string + operatorVersion: + type: string + serverVersion: + type: string + type: object + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index 1f28475cd..8c2d33388 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -5,29 +5,9 @@ kind: Kustomization # since it depends on service name and namespace that are out of this kustomize package. # It should be run by config/default resources: -- bases/llamastack.io_llamastackdistributions.yaml +- bases/ogx.io_ogxservers.yaml #+kubebuilder:scaffold:crdkustomizeresource -# [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. 'WEBHOOK' components are required. -#- ../certmanager - -# [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. 'WEBHOOK' components are required. -# Uncomment 'CERTMANAGER' sections in crd/kustomization.yaml to enable the CA injection in the admission webhooks. -# 'CERTMANAGER' needs to be enabled to use ca injection -#- ../certmanager - - -# [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER' prefix. -# Uncomment 'CERTMANAGER' sections to enable the CA injection in the admission webhooks. -# 'CERTMANAGER' needs to be enabled to use ca injection -#- patches/cainjection_in_llamastackdistributions.yaml - - -# [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER' prefix. -# Uncomment 'CERTMANAGER' sections to enable the CA injection in the admission webhooks. -# 'CERTMANAGER' needs to be enabled to use ca injection -#- patches/cainjection_in_llamastackdistributions.yaml - # the following config is for teaching kustomize how to do kustomization for CRDs. configurations: - kustomizeconfig.yaml diff --git a/config/crd/patches/cainjection_in_llamastackdistributions.yaml b/config/crd/patches/cainjection_in_ogxservers.yaml similarity index 84% rename from config/crd/patches/cainjection_in_llamastackdistributions.yaml rename to config/crd/patches/cainjection_in_ogxservers.yaml index 1be3208a2..54f49b9ed 100644 --- a/config/crd/patches/cainjection_in_llamastackdistributions.yaml +++ b/config/crd/patches/cainjection_in_ogxservers.yaml @@ -4,4 +4,4 @@ kind: CustomResourceDefinition metadata: annotations: cert-manager.io/inject-ca-from: $(CERTIFICATE_NAMESPACE)/$(CERTIFICATE_NAME) - name: llamastackdistributions.llamastack.io + name: ogxservers.ogx.io diff --git a/config/default/kustomization.yaml b/config/default/kustomization.yaml index b032c465e..fb5e3fa3c 100644 --- a/config/default/kustomization.yaml +++ b/config/default/kustomization.yaml @@ -2,47 +2,34 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization # Adds namespace to all resources. -namespace: llama-stack-k8s-operator-system +namespace: ogx-k8s-operator-system # Value of this field is prepended to the # names of all resources, e.g. a deployment named # "wordpress" becomes "alices-wordpress". # Note that it should also match with the prefix (text before '-') of the namespace # field above. -namePrefix: llama-stack-k8s-operator- +namePrefix: ogx-k8s-operator- resources: - ../crd - ../rbac - ../manager +- ../webhook labels: - includeSelectors: true pairs: - app.kubernetes.io/name: llama-stack-k8s-operator + app.kubernetes.io/name: ogx-k8s-operator # Labels to add to all resources and selectors. -# [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in -# crd/kustomization.yaml -# - ../webhook -# [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. 'WEBHOOK' components are required. -#- ../certmanager # [PROMETHEUS] To enable prometheus monitor, uncomment all sections with 'PROMETHEUS'. #- ../prometheus - -# Use the combined patch instead of separate patches -# - manager_combined_patch.yaml +patches: +- path: manager_webhook_patch.yaml # Mount the controller config file for loading manager configurations # through a ComponentConfig type #- manager_config_patch.yaml - - -# [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. -# Uncomment 'CERTMANAGER' sections in crd/kustomization.yaml to enable the CA injection in the admission webhooks. -# 'CERTMANAGER' needs to be enabled to use ca injection -#- webhookcainjection_patch.yaml - -# the following config is for teaching kustomize how to do var substitution diff --git a/config/default/manager_labels_patch.yaml b/config/default/manager_labels_patch.yaml index 9d42ca04f..1145cc354 100644 --- a/config/default/manager_labels_patch.yaml +++ b/config/default/manager_labels_patch.yaml @@ -8,5 +8,5 @@ spec: metadata: labels: app.kubernetes.io/component: controller-manager - app.kubernetes.io/instance: llama-stack-k8s-operator - app.kubernetes.io/name: llama-stack-k8s-operator + app.kubernetes.io/instance: ogx-k8s-operator + app.kubernetes.io/name: ogx-k8s-operator diff --git a/config/default/manager_webhook_patch.yaml b/config/default/manager_webhook_patch.yaml new file mode 100644 index 000000000..e56e27b15 --- /dev/null +++ b/config/default/manager_webhook_patch.yaml @@ -0,0 +1,23 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: controller-manager + namespace: system +spec: + template: + spec: + containers: + - name: manager + ports: + - containerPort: 9443 + name: webhook-server + protocol: TCP + volumeMounts: + - mountPath: /tmp/k8s-webhook-server/serving-certs + name: cert + readOnly: true + volumes: + - name: cert + secret: + defaultMode: 420 + secretName: ogx-k8s-operator-webhook-cert diff --git a/config/manager/controller_manager_config.yaml b/config/manager/controller_manager_config.yaml index 7fb53ceb6..6f7ecab48 100644 --- a/config/manager/controller_manager_config.yaml +++ b/config/manager/controller_manager_config.yaml @@ -8,7 +8,7 @@ webhook: port: 9443 leaderElection: leaderElect: true - resourceName: 54e06e98.llamastack.io + resourceName: 54e06e98.ogx.io # leaderElectionReleaseOnCancel defines if the leader should step down volume # when the Manager ends. This requires the binary to immediately end when the # Manager is stopped, otherwise, this setting is unsafe. Setting this significantly diff --git a/config/manager/kustomization.yaml b/config/manager/kustomization.yaml index fe16f6de5..7a7d5c727 100644 --- a/config/manager/kustomization.yaml +++ b/config/manager/kustomization.yaml @@ -15,5 +15,5 @@ configMapGenerator: images: - name: controller - newName: quay.io/llamastack/llama-stack-k8s-operator + newName: quay.io/ogx-ai/ogx-k8s-operator newTag: latest diff --git a/config/manager/manager.yaml b/config/manager/manager.yaml index 18228523b..b4d3b5d2b 100644 --- a/config/manager/manager.yaml +++ b/config/manager/manager.yaml @@ -39,13 +39,13 @@ spec: whenUnsatisfiable: ScheduleAnyway labelSelector: matchLabels: - app.kubernetes.io/name: llama-stack-k8s-operator + app.kubernetes.io/name: ogx-k8s-operator - maxSkew: 1 topologyKey: kubernetes.io/hostname whenUnsatisfiable: ScheduleAnyway labelSelector: matchLabels: - app.kubernetes.io/name: llama-stack-k8s-operator + app.kubernetes.io/name: ogx-k8s-operator containers: - command: - /manager @@ -56,6 +56,14 @@ spec: value: "800MiB" - name: OPERATOR_VERSION value: "latest" + - name: RELATED_IMAGE_STARTER + value: "" + - name: RELATED_IMAGE_REMOTE_VLLM + value: "" + - name: RELATED_IMAGE_META_REFERENCE_GPU + value: "" + - name: RELATED_IMAGE_POSTGRES_DEMO + value: "" image: controller:latest imagePullPolicy: Always name: manager diff --git a/config/manager/pdb.yaml b/config/manager/pdb.yaml index 9bd3a51f8..ff286645a 100644 --- a/config/manager/pdb.yaml +++ b/config/manager/pdb.yaml @@ -7,4 +7,4 @@ spec: maxUnavailable: 1 selector: matchLabels: - app.kubernetes.io/name: llama-stack-k8s-operator + app.kubernetes.io/name: ogx-k8s-operator diff --git a/config/overlays/cert-manager/cainjection_in_ogxservers.yaml b/config/overlays/cert-manager/cainjection_in_ogxservers.yaml new file mode 100644 index 000000000..54f49b9ed --- /dev/null +++ b/config/overlays/cert-manager/cainjection_in_ogxservers.yaml @@ -0,0 +1,7 @@ +# The following patch adds a directive for certmanager to inject CA into the CRD +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + cert-manager.io/inject-ca-from: $(CERTIFICATE_NAMESPACE)/$(CERTIFICATE_NAME) + name: ogxservers.ogx.io diff --git a/config/overlays/cert-manager/kustomization.yaml b/config/overlays/cert-manager/kustomization.yaml new file mode 100644 index 000000000..866369619 --- /dev/null +++ b/config/overlays/cert-manager/kustomization.yaml @@ -0,0 +1,98 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +# Overlay for vanilla Kubernetes clusters using cert-manager for webhook TLS. +# Adds cert-manager Issuer/Certificate resources and wires CA injection into +# the ValidatingWebhookConfiguration and CRD annotations. + +resources: +- ../../default +- ../../certmanager + +patches: +# Inject cert-manager CA bundle annotation on the CRD. +- path: cainjection_in_ogxservers.yaml +# Inject cert-manager CA bundle annotation on the webhook configuration. +- target: + kind: ValidatingWebhookConfiguration + patch: |- + - op: add + path: /metadata/annotations + value: + cert-manager.io/inject-ca-from: CERTIFICATE_NAMESPACE/CERTIFICATE_NAME + +# Replacements wire the webhook Service name/namespace into the Certificate +# dnsNames and the Certificate namespace/name into CA injection annotations. +replacements: +- source: + kind: Service + version: v1 + name: webhook-service + fieldPath: metadata.name + targets: + - select: + kind: Certificate + group: cert-manager.io + fieldPaths: + - spec.dnsNames.0 + - spec.dnsNames.1 + options: + delimiter: "." + index: 0 +- source: + kind: Service + version: v1 + name: webhook-service + fieldPath: metadata.namespace + targets: + - select: + kind: Certificate + group: cert-manager.io + fieldPaths: + - spec.dnsNames.0 + - spec.dnsNames.1 + options: + delimiter: "." + index: 1 +- source: + kind: Certificate + group: cert-manager.io + version: v1 + name: serving-cert + fieldPath: metadata.namespace + targets: + - select: + kind: CustomResourceDefinition + fieldPaths: + - metadata.annotations.[cert-manager.io/inject-ca-from] + options: + delimiter: "/" + index: 0 + - select: + kind: ValidatingWebhookConfiguration + fieldPaths: + - metadata.annotations.[cert-manager.io/inject-ca-from] + options: + delimiter: "/" + index: 0 +- source: + kind: Certificate + group: cert-manager.io + version: v1 + name: serving-cert + fieldPath: metadata.name + targets: + - select: + kind: CustomResourceDefinition + fieldPaths: + - metadata.annotations.[cert-manager.io/inject-ca-from] + options: + delimiter: "/" + index: 1 + - select: + kind: ValidatingWebhookConfiguration + fieldPaths: + - metadata.annotations.[cert-manager.io/inject-ca-from] + options: + delimiter: "/" + index: 1 diff --git a/config/overlays/openshift/kustomization.yaml b/config/overlays/openshift/kustomization.yaml new file mode 100644 index 000000000..5143b5127 --- /dev/null +++ b/config/overlays/openshift/kustomization.yaml @@ -0,0 +1,31 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +# Overlay for OpenShift clusters using the built-in service-serving-cert-signer. +# No cert-manager dependency is required. The platform automatically: +# 1. Generates a TLS Secret for the webhook Service via the serving-cert annotation. +# 2. Injects the CA bundle into the ValidatingWebhookConfiguration. + +resources: +- ../../default + +patches: +# Annotate the webhook Service so OpenShift's service-serving-cert-signer +# generates a TLS certificate into the named Secret. +- target: + kind: Service + name: webhook-service + patch: |- + - op: add + path: /metadata/annotations + value: + service.beta.openshift.io/serving-cert-secret-name: ogx-k8s-operator-webhook-cert +# Annotate the ValidatingWebhookConfiguration so OpenShift injects the +# service-ca CA bundle into webhooks[].clientConfig.caBundle. +- target: + kind: ValidatingWebhookConfiguration + patch: |- + - op: add + path: /metadata/annotations + value: + service.beta.openshift.io/inject-cabundle: "true" diff --git a/config/rbac/kustomization.yaml b/config/rbac/kustomization.yaml index ce27843d0..cb99e39f1 100644 --- a/config/rbac/kustomization.yaml +++ b/config/rbac/kustomization.yaml @@ -19,5 +19,5 @@ resources: - auth_proxy_role.yaml - auth_proxy_role_binding.yaml - auth_proxy_client_clusterrole.yaml -- llsd_viewer_role.yaml -- llsd_editor_role.yaml +- ogxserver_viewer_role.yaml +- ogxserver_editor_role.yaml diff --git a/config/rbac/llsd_editor_role.yaml b/config/rbac/ogxserver_editor_role.yaml similarity index 65% rename from config/rbac/llsd_editor_role.yaml rename to config/rbac/ogxserver_editor_role.yaml index da8b6b001..f0d65b08f 100644 --- a/config/rbac/llsd_editor_role.yaml +++ b/config/rbac/ogxserver_editor_role.yaml @@ -1,16 +1,16 @@ -# permissions for end users to edit LlamaStackDistributions. +# permissions for end users to edit OGXServers. apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: - name: llsd-editor-role + name: ogxserver-editor-role labels: rbac.authorization.k8s.io/aggregate-to-edit: "true" rbac.authorization.k8s.io/aggregate-to-admin: "true" rules: - apiGroups: - - llamastack.io + - ogx.io resources: - - llamastackdistributions + - ogxservers verbs: - create - delete @@ -20,8 +20,8 @@ rules: - update - watch - apiGroups: - - llamastack.io + - ogx.io resources: - - llamastackdistributions/status + - ogxservers/status verbs: - get diff --git a/config/rbac/llsd_viewer_role.yaml b/config/rbac/ogxserver_viewer_role.yaml similarity index 57% rename from config/rbac/llsd_viewer_role.yaml rename to config/rbac/ogxserver_viewer_role.yaml index 2d83d435b..193e89b01 100644 --- a/config/rbac/llsd_viewer_role.yaml +++ b/config/rbac/ogxserver_viewer_role.yaml @@ -1,22 +1,22 @@ -# permissions for end users to view LlamaStackDistributions. +# permissions for end users to view OGXServers. apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: - name: llsd-viewer-role + name: ogxserver-viewer-role labels: rbac.authorization.k8s.io/aggregate-to-view: "true" rules: - apiGroups: - - llamastack.io + - ogx.io resources: - - llamastackdistributions + - ogxservers verbs: - get - list - watch - apiGroups: - - llamastack.io + - ogx.io resources: - - llamastackdistributions/status + - ogxservers/status verbs: - get diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 843923ac7..e8fa08f68 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -8,6 +8,7 @@ rules: - "" resources: - configmaps + - persistentvolumeclaims verbs: - create - get @@ -18,9 +19,14 @@ rules: - apiGroups: - "" resources: - - persistentvolumeclaims + - pods + verbs: + - list +- apiGroups: + - "" + resources: + - secrets verbs: - - create - get - list - watch @@ -37,6 +43,14 @@ rules: - patch - update - watch +- apiGroups: + - apiextensions.k8s.io + resources: + - customresourcedefinitions + verbs: + - get + - list + - watch - apiGroups: - apps resources: @@ -49,6 +63,14 @@ rules: - patch - update - watch +- apiGroups: + - apps + resources: + - replicasets + verbs: + - get + - list + - watch - apiGroups: - autoscaling resources: @@ -62,9 +84,10 @@ rules: - update - watch - apiGroups: - - llamastack.io + - monitoring.coreos.com resources: - - llamastackdistributions + - prometheusrules + - servicemonitors verbs: - create - delete @@ -74,24 +97,22 @@ rules: - update - watch - apiGroups: - - llamastack.io - resources: - - llamastackdistributions/finalizers - verbs: - - update -- apiGroups: - - llamastack.io + - networking.k8s.io resources: - - llamastackdistributions/status + - ingresses + - networkpolicies verbs: + - create + - delete - get + - list - patch - update + - watch - apiGroups: - - networking.k8s.io + - ogx.io resources: - - ingresses - - networkpolicies + - ogxservers verbs: - create - delete @@ -100,6 +121,20 @@ rules: - patch - update - watch +- apiGroups: + - ogx.io + resources: + - ogxservers/finalizers + verbs: + - update +- apiGroups: + - ogx.io + resources: + - ogxservers/status + verbs: + - get + - patch + - update - apiGroups: - policy resources: diff --git a/config/samples/_v1alpha1_llamastackdistribution.yaml b/config/samples/_v1beta1_ogxserver.yaml similarity index 50% rename from config/samples/_v1alpha1_llamastackdistribution.yaml rename to config/samples/_v1beta1_ogxserver.yaml index ffdc78083..1ce03afbe 100644 --- a/config/samples/_v1alpha1_llamastackdistribution.yaml +++ b/config/samples/_v1beta1_ogxserver.yaml @@ -1,20 +1,25 @@ -apiVersion: llamastack.io/v1alpha1 -kind: LlamaStackDistribution +apiVersion: ogx.io/v1beta1 +kind: OGXServer metadata: - name: llamastackdistribution-sample + name: ogxserver-sample spec: - replicas: 1 - server: - containerSpec: + distribution: + name: starter + overrideConfig: + name: starter-config + key: config.yaml + workload: + replicas: 1 + workers: 1 + storage: + size: "20Gi" + mountPath: "/.ogx" + overrides: env: - name: OLLAMA_INFERENCE_MODEL value: 'llama3.2:1b' - name: OLLAMA_URL value: 'http://ollama-server-service.ollama-dist.svc.cluster.local:11434' - name: llama-stack - distribution: - name: starter - workers: 2 podDisruptionBudget: maxUnavailable: 1 topologySpreadConstraints: @@ -23,15 +28,12 @@ spec: whenUnsatisfiable: ScheduleAnyway labelSelector: matchLabels: - app.kubernetes.io/instance: llamastackdistribution-sample + app.kubernetes.io/instance: ogxserver-sample autoscaling: minReplicas: 1 maxReplicas: 5 targetCPUUtilizationPercentage: 75 targetMemoryUtilizationPercentage: 70 - # Uncomment the storage section to use persistent storage - # storage: {} # Will use default size of 10Gi and default mount path of /.llama - # Or specify custom values: - storage: - size: "20Gi" - mountPath: "/home/lls/.lls" # Optional, defaults to /.llama. Use with custom distribution images that have a different setup. + # monitoring: + # enabled: true + # metricsPort: 9090 diff --git a/config/samples/example-with-ca-bundle.yaml b/config/samples/example-with-ca-bundle.yaml index 7509147ca..a76f72ff0 100644 --- a/config/samples/example-with-ca-bundle.yaml +++ b/config/samples/example-with-ca-bundle.yaml @@ -1,80 +1,24 @@ -apiVersion: v1 -kind: ConfigMap +apiVersion: ogx.io/v1beta1 +kind: OGXServer metadata: - name: llama-stack-config -data: - config.yaml: | - # Llama Stack Configuration - version: '2' - image_name: starter - apis: - - inference - providers: - inference: - - provider_id: ollama - provider_type: "remote::ollama" - config: - url: "http://ollama-server-service.ollama-dist.svc.cluster.local:11434" - models: - - model_id: "llama3.2:1b" - provider_id: ollama - model_type: llm - server: - port: 8321 - storage: - backends: - kv_default: - type: kv_sqlite - db_path: ${env.SQLITE_STORE_DIR:=~/.llama/distributions/ollama}/kvstore.db - sql_default: - type: sql_sqlite - db_path: ${env.SQLITE_STORE_DIR:=~/.llama/distributions/ollama}/sqlstore.db - stores: - metadata: - backend: kv_default - namespace: registry - inference: - backend: sql_default - table_name: inference_store - max_write_queue_size: 10000 - num_writers: 4 - conversations: - backend: sql_default - table_name: openai_conversations - prompts: - backend: kv_default - namespace: prompts ---- -apiVersion: llamastack.io/v1alpha1 -kind: LlamaStackDistribution -metadata: - name: llamastack-with-config + name: ogxserver-with-ca-bundle spec: - replicas: 1 - server: - distribution: - name: starter - containerSpec: - port: 8321 + distribution: + name: starter + overrideConfig: + name: starter-config + key: config.yaml + tls: + trust: + caCertificates: + - name: custom-ca-bundle + key: ca-bundle.crt + network: + port: 8321 + workload: + overrides: env: - name: INFERENCE_MODEL value: "llama3.2:1b" - name: OLLAMA_URL value: "http://ollama-server-service.ollama-dist.svc.cluster.local:11434" - userConfig: - configMapName: llama-stack-config - # configMapNamespace: "" # Optional - defaults to the same namespace as the CR - tlsConfig: - caBundle: - configMapName: custom-ca-bundle - # configMapNamespace: "" # Optional - defaults to the same namespace as the CR - # configMapKeys not specified - defaults to ["ca-bundle.crt"] - # configMapKeys: # Specify multiple keys to concatenate into a single CA bundle - # - ca-bundle1.crt - # - ca-bundle2.crt - # Note: The operator will automatically: - # 1. Validate and extract valid certificates from all specified ConfigMap keys - # 2. Concatenate them into a single PEM file - # 3. Create a managed ConfigMap named {instance-name}-ca-bundle - # 4. Mount the bundle at /etc/ssl/certs/ca-bundle/ca-bundle.crt - # 5. Set SSL_CERT_FILE environment variable automatically diff --git a/config/samples/example-with-configmap.yaml b/config/samples/example-with-configmap.yaml index db5443353..591d1f41f 100644 --- a/config/samples/example-with-configmap.yaml +++ b/config/samples/example-with-configmap.yaml @@ -1,10 +1,12 @@ apiVersion: v1 kind: ConfigMap metadata: - name: llama-stack-config + name: ogx-server-config + labels: + ogx.io/watch: "true" data: config.yaml: | - # Llama Stack Configuration + # OGX Server Configuration version: '2' image_name: starter apis: @@ -45,20 +47,20 @@ data: backend: kv_default namespace: prompts --- -apiVersion: llamastack.io/v1alpha1 -kind: LlamaStackDistribution +apiVersion: ogx.io/v1beta1 +kind: OGXServer metadata: - name: llamastack-with-userconfig + name: ogxserver-with-userconfig spec: - replicas: 1 - server: - distribution: - name: starter - containerSpec: - port: 8321 + distribution: + name: starter + overrideConfig: + name: ogx-server-config + key: config.yaml + network: + port: 8321 + workload: + overrides: env: - name: OLLAMA_EMBEDDING_MODEL value: all-minilm:l6-v2 - userConfig: - configMapName: llama-stack-config - # configMapNamespace: "" # Optional - defaults to the same namespace as the CR diff --git a/config/samples/example-with-generated-config.yaml b/config/samples/example-with-generated-config.yaml new file mode 100644 index 000000000..e0a76d011 --- /dev/null +++ b/config/samples/example-with-generated-config.yaml @@ -0,0 +1,32 @@ +apiVersion: v1 +kind: Secret +metadata: + name: openai-creds + labels: + ogx.io/watch: "true" +type: Opaque +stringData: + api-key: "" +--- +apiVersion: ogx.io/v1beta1 +kind: OGXServer +metadata: + name: ogxserver-with-generated-config +spec: + distribution: + name: starter + providers: + inference: + remote: + openai: + - id: openai-primary + apiKey: + name: openai-creds + key: api-key + resources: + models: + - name: gpt-4o-mini + provider: openai-primary + modelType: llm + disabledAPIs: + - vector_io diff --git a/config/samples/example-with-network-config.yaml b/config/samples/example-with-network-config.yaml index c0b23cf21..5af26bdd1 100644 --- a/config/samples/example-with-network-config.yaml +++ b/config/samples/example-with-network-config.yaml @@ -1,50 +1,81 @@ -apiVersion: llamastack.io/v1alpha1 -kind: LlamaStackDistribution +# Example with custom NetworkPolicy ingress rules (namespace-based access) +apiVersion: ogx.io/v1beta1 +kind: OGXServer metadata: - name: llamastackdistribution-network-example + name: ogxserver-network-example spec: - replicas: 1 - server: - containerSpec: + distribution: + name: starter + workload: + overrides: env: - name: OLLAMA_INFERENCE_MODEL value: 'llama3.2:1b' - name: OLLAMA_URL value: 'http://ollama-server-service.ollama-dist.svc.cluster.local:11434' - name: llama-stack - distribution: - name: starter network: - exposeRoute: false - allowedFrom: - namespaces: - - nsA - - nsB - labels: - - myproject/lls-allowed - - team/authorized + policy: + ingress: + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: nsA + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: nsB + - namespaceSelector: + matchExpressions: + - key: myproject/ogx-allowed + operator: Exists + - namespaceSelector: + matchExpressions: + - key: team/authorized + operator: Exists + ports: + - protocol: TCP + port: 8321 --- -# Example with exposeRoute enabled and all namespaces allowed -apiVersion: llamastack.io/v1alpha1 -kind: LlamaStackDistribution +# Example with external access enabled and NetworkPolicy disabled +apiVersion: ogx.io/v1beta1 +kind: OGXServer metadata: - name: llamastackdistribution-public + name: ogxserver-public spec: - replicas: 1 - server: - containerSpec: + distribution: + name: starter + workload: + overrides: env: - name: OLLAMA_INFERENCE_MODEL value: 'llama3.2:1b' - name: OLLAMA_URL value: 'http://ollama-server-service.ollama-dist.svc.cluster.local:11434' - name: llama-stack - distribution: - name: starter network: - # Expose the service externally via Ingress - exposeRoute: true - allowedFrom: - # Allow access from all namespaces - namespaces: - - "*" + externalAccess: + enabled: true + policy: + ingress: + - from: + - namespaceSelector: {} + ports: + - protocol: TCP + port: 8321 +--- +# Example with NetworkPolicy explicitly disabled +apiVersion: ogx.io/v1beta1 +kind: OGXServer +metadata: + name: ogxserver-no-networkpolicy +spec: + distribution: + name: starter + workload: + overrides: + env: + - name: OLLAMA_INFERENCE_MODEL + value: 'llama3.2:1b' + - name: OLLAMA_URL + value: 'http://ollama-server-service.ollama-dist.svc.cluster.local:11434' + network: + policy: + enabled: false diff --git a/config/samples/example-withoutconfigmap.yaml b/config/samples/example-withoutconfigmap.yaml index ef723cee8..1143eaefa 100644 --- a/config/samples/example-withoutconfigmap.yaml +++ b/config/samples/example-withoutconfigmap.yaml @@ -1,19 +1,18 @@ --- -apiVersion: llamastack.io/v1alpha1 -kind: LlamaStackDistribution +apiVersion: ogx.io/v1beta1 +kind: OGXServer metadata: - name: llamastack-without-userconfig + name: ogxserver-without-userconfig spec: - replicas: 1 - server: - distribution: - name: starter - containerSpec: + distribution: + name: starter + workload: + storage: + size: "10Gi" + mountPath: "/.ogx" + overrides: env: - name: OLLAMA_INFERENCE_MODEL value: "llama3.2:1b" - name: OLLAMA_URL value: "http://ollama-server-service.ollama-dist.svc.cluster.local:11434" - storage: - size: "10Gi" # Optional - defaults to 10Gi - mountPath: "/home/lls/.lls" # Optional - defaults to /.llama diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml index cb73f9a96..bf2bf7c55 100644 --- a/config/samples/kustomization.yaml +++ b/config/samples/kustomization.yaml @@ -3,7 +3,8 @@ kind: Kustomization ## Append samples you want in your CSV to this file as resources ## resources: -- _v1alpha1_llamastackdistribution.yaml +- _v1beta1_ogxserver.yaml - example-with-configmap.yaml +- example-with-generated-config.yaml - example-with-ca-bundle.yaml - example-with-network-config.yaml diff --git a/config/samples/starter-config-configmap.yaml b/config/samples/starter-config-configmap.yaml new file mode 100644 index 000000000..0f8d757fb --- /dev/null +++ b/config/samples/starter-config-configmap.yaml @@ -0,0 +1,339 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: starter-config + labels: + ogx.io/watch: "true" +data: + config.yaml: | + version: 2 + distro_name: starter + apis: + - batches + - file_processors + - files + - inference + - interactions + - messages + - responses + - skills + - tool_runtime + - vector_io + providers: + inference: + - provider_id: ${env.CEREBRAS_API_KEY:+cerebras} + provider_type: remote::cerebras + config: + base_url: https://api.cerebras.ai/v1 + api_key: ${env.CEREBRAS_API_KEY:=} + - provider_id: ${env.OLLAMA_URL:+ollama} + provider_type: remote::ollama + config: + base_url: ${env.OLLAMA_URL:=http://localhost:11434/v1} + - provider_id: ${env.VLLM_URL:+vllm} + provider_type: remote::vllm + config: + base_url: ${env.VLLM_URL:=} + max_tokens: ${env.VLLM_MAX_TOKENS:=4096} + api_token: ${env.VLLM_API_TOKEN:=fake} + network: + tls: + verify: ${env.VLLM_TLS_VERIFY:=true} + - provider_id: fireworks + provider_type: remote::fireworks + config: + base_url: https://api.fireworks.ai/inference/v1 + api_key: ${env.FIREWORKS_API_KEY:=} + - provider_id: together + provider_type: remote::together + config: + base_url: https://api.together.xyz/v1 + api_key: ${env.TOGETHER_API_KEY:=} + - provider_id: bedrock + provider_type: remote::bedrock + config: + aws_bedrock_bearer_token: ${env.AWS_BEDROCK_BEARER_TOKEN:=} + region_name: ${env.AWS_DEFAULT_REGION:=us-east-2} + aws_role_arn: ${env.AWS_ROLE_ARN:=} + aws_web_identity_token_file: ${env.AWS_WEB_IDENTITY_TOKEN_FILE:=} + - provider_id: ${env.NVIDIA_API_KEY:+nvidia} + provider_type: remote::nvidia + config: + base_url: ${env.NVIDIA_BASE_URL:=https://integrate.api.nvidia.com/v1} + api_key: ${env.NVIDIA_API_KEY:=} + - provider_id: openai + provider_type: remote::openai + config: + api_key: ${env.OPENAI_API_KEY:=} + base_url: ${env.OPENAI_BASE_URL:=https://api.openai.com/v1} + - provider_id: anthropic + provider_type: remote::anthropic + config: + api_key: ${env.ANTHROPIC_API_KEY:=} + - provider_id: gemini + provider_type: remote::gemini + config: + api_key: ${env.GEMINI_API_KEY:=} + access_token: ${env.GEMINI_ACCESS_TOKEN:=} + project: ${env.GEMINI_AI_PROJECT:=} + - provider_id: ${env.VERTEX_AI_PROJECT:+vertexai} + provider_type: remote::vertexai + config: + project: ${env.VERTEX_AI_PROJECT:=} + location: ${env.VERTEX_AI_LOCATION:=global} + - provider_id: groq + provider_type: remote::groq + config: + base_url: https://api.groq.com/openai/v1 + api_key: ${env.GROQ_API_KEY:=} + - provider_id: sambanova + provider_type: remote::sambanova + config: + base_url: https://api.sambanova.ai/v1 + api_key: ${env.SAMBANOVA_API_KEY:=} + - provider_id: ${env.AZURE_API_KEY:+azure} + provider_type: remote::azure + config: + api_key: ${env.AZURE_API_KEY:=} + base_url: ${env.AZURE_API_BASE:=} + api_version: ${env.AZURE_API_VERSION:=} + api_type: ${env.AZURE_API_TYPE:=} + - provider_id: sentence-transformers + provider_type: inline::sentence-transformers + config: + trust_remote_code: false + - provider_id: transformers + provider_type: inline::transformers + vector_io: + - provider_id: faiss + provider_type: inline::faiss + config: + persistence: + namespace: vector_io::faiss + backend: kv_default + - provider_id: sqlite-vec + provider_type: inline::sqlite-vec + config: + db_path: ${env.SQLITE_STORE_DIR:=~/.ogx/distributions/starter}/sqlite_vec.db + persistence: + namespace: vector_io::sqlite_vec + backend: kv_default + - provider_id: ${env.MILVUS_URL:+milvus} + provider_type: inline::milvus + config: + db_path: ${env.MILVUS_DB_PATH:=~/.ogx/distributions/starter}/milvus.db + persistence: + namespace: vector_io::milvus + backend: kv_default + - provider_id: ${env.CHROMADB_URL:+chromadb} + provider_type: remote::chromadb + config: + url: ${env.CHROMADB_URL:=} + persistence: + namespace: vector_io::chroma_remote + backend: kv_default + - provider_id: ${env.PGVECTOR_DB:+pgvector} + provider_type: remote::pgvector + config: + host: ${env.PGVECTOR_HOST:=localhost} + port: ${env.PGVECTOR_PORT:=5432} + db: ${env.PGVECTOR_DB:=} + user: ${env.PGVECTOR_USER:=} + password: ${env.PGVECTOR_PASSWORD:=} + distance_metric: COSINE + vector_index: + type: HNSW + m: 16 + ef_construction: 64 + ef_search: 40 + persistence: + namespace: vector_io::pgvector + backend: kv_default + - provider_id: ${env.QDRANT_URL:+qdrant} + provider_type: remote::qdrant + config: + api_key: ${env.QDRANT_API_KEY:=} + persistence: + namespace: vector_io::qdrant_remote + backend: kv_default + - provider_id: ${env.WEAVIATE_CLUSTER_URL:+weaviate} + provider_type: remote::weaviate + config: + weaviate_api_key: null + weaviate_cluster_url: ${env.WEAVIATE_CLUSTER_URL:=localhost:8080} + persistence: + namespace: vector_io::weaviate + backend: kv_default + - provider_id: ${env.ELASTICSEARCH_URL:+elasticsearch} + provider_type: remote::elasticsearch + config: + elasticsearch_url: ${env.ELASTICSEARCH_URL:=localhost:9200} + elasticsearch_api_key: ${env.ELASTICSEARCH_API_KEY:=} + persistence: + namespace: vector_io::elasticsearch + backend: kv_default + - provider_id: ${env.INFINISPAN_URL:+infinispan} + provider_type: remote::infinispan + config: + url: ${env.INFINISPAN_URL:=http://localhost:11222} + username: ${env.INFINISPAN_USERNAME:=admin} + password: ${env.INFINISPAN_PASSWORD:=} + use_https: false + auth_mechanism: digest + verify_tls: true + persistence: + namespace: vector_io::infinispan + backend: kv_default + files: + - provider_id: builtin-files + provider_type: inline::localfs + config: + storage_dir: ${env.FILES_STORAGE_DIR:=~/.ogx/distributions/starter/files} + metadata_store: + table_name: files_metadata + backend: sql_default + file_processors: + - provider_id: auto + provider_type: inline::auto + interactions: + - provider_id: builtin + provider_type: inline::builtin + config: + store: + table_name: interactions + backend: sql_default + messages: + - provider_id: builtin + provider_type: inline::builtin + config: + kvstore: + namespace: message_batches + backend: kv_default + responses: + - provider_id: builtin + provider_type: inline::builtin + config: + persistence: + responses: + table_name: responses + backend: sql_default + max_write_queue_size: 10000 + num_writers: 4 + skills: + - provider_id: builtin + provider_type: inline::builtin + config: + persistence: + namespace: skills + backend: kv_default + tool_runtime: + - provider_id: brave-search + provider_type: remote::brave-search + config: + api_key: ${env.BRAVE_SEARCH_API_KEY:=} + max_results: 3 + - provider_id: tavily-search + provider_type: remote::tavily-search + config: + api_key: ${env.TAVILY_SEARCH_API_KEY:=} + max_results: 3 + - provider_id: nimble-search + provider_type: remote::nimble-search + config: + api_key: ${env.NIMBLE_API_KEY:=} + max_results: 3 + search_depth: lite + - provider_id: file-search + provider_type: inline::file-search + - provider_id: model-context-protocol + provider_type: remote::model-context-protocol + batches: + - provider_id: reference + provider_type: inline::reference + config: + sqlstore: + table_name: batches + backend: sql_default + storage: + backends: + kv_default: + type: kv_sqlite + db_path: ${env.SQLITE_STORE_DIR:=~/.ogx/distributions/starter}/kvstore.db + sql_default: + type: sql_sqlite + db_path: ${env.SQLITE_STORE_DIR:=~/.ogx/distributions/starter}/sql_store.db + stores: + metadata: + namespace: registry + backend: kv_default + inference: + table_name: inference_store + backend: sql_default + max_write_queue_size: 10000 + num_writers: 4 + conversations: + table_name: openai_conversations + backend: sql_default + prompts: + table_name: prompts + backend: sql_default + connectors: + table_name: connectors + backend: sql_default + registered_resources: + models: + - metadata: {} + model_id: claude-haiku-4-5-20251001 + provider_id: all + provider_model_id: auto + model_type: llm + - metadata: {} + model_id: claude-sonnet-4-5-20250514 + provider_id: all + provider_model_id: auto + model_type: llm + - metadata: {} + model_id: claude-opus-4-6-20260314 + provider_id: all + provider_model_id: auto + model_type: llm + vector_stores: [] + server: + port: 8321 + vector_stores: + default_provider_id: faiss + default_embedding_model: + provider_id: sentence-transformers + model_id: nomic-ai/nomic-embed-text-v1.5 + default_reranker_model: + provider_id: transformers + model_id: Qwen/Qwen3-Reranker-0.6B + file_search_params: + header_template: "file_search tool found {num_chunks} chunks:\nBEGIN of file_search tool results.\n" + footer_template: "END of file_search tool results.\n" + context_prompt_params: + chunk_annotation_template: "Result {index}\nContent: {chunk.content}\nMetadata: {metadata}\n" + context_template: "The above results were retrieved to help answer the user's query: \"{query}\". Use them as supporting information only in answering this query. {annotation_instruction}\n" + annotation_prompt_params: + enable_annotations: true + annotation_instruction_template: Cite sources immediately at the end of sentences before punctuation, using `<|file-id|>` format like 'This is a fact <|file-Cn3MSNn72ENTiiq11Qda4A|>.'. Do not add extra punctuation. Use only the file IDs provided, do not invent new ones. + chunk_annotation_template: "[{index}] {metadata_text} cite as <|{file_id}|>\n{chunk_text}\n" + file_ingestion_params: + default_chunk_size_tokens: 512 + default_chunk_overlap_tokens: 128 + chunk_retrieval_params: + chunk_multiplier: 5 + max_tokens_in_context: 4000 + default_reranker_strategy: rrf + rrf_impact_factor: 60.0 + weighted_search_alpha: 0.5 + default_search_mode: vector + file_batch_params: + max_concurrent_files_per_batch: 3 + file_batch_chunk_size: 10 + cleanup_interval_seconds: 86400 + contextual_retrieval_params: + default_timeout_seconds: 120 + default_max_concurrency: 3 + max_document_tokens: 100000 + connectors: [] diff --git a/config/webhook/kustomization.yaml b/config/webhook/kustomization.yaml new file mode 100644 index 000000000..003c870a4 --- /dev/null +++ b/config/webhook/kustomization.yaml @@ -0,0 +1,9 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: +- manifests.yaml +- service.yaml + +configurations: +- kustomizeconfig.yaml diff --git a/config/webhook/kustomizeconfig.yaml b/config/webhook/kustomizeconfig.yaml new file mode 100644 index 000000000..830f63f2b --- /dev/null +++ b/config/webhook/kustomizeconfig.yaml @@ -0,0 +1,21 @@ +# Teaches kustomize how to substitute service name and namespace in webhook configurations. +nameReference: +- kind: Service + version: v1 + fieldSpecs: + - kind: MutatingWebhookConfiguration + group: admissionregistration.k8s.io + path: webhooks/clientConfig/service/name + - kind: ValidatingWebhookConfiguration + group: admissionregistration.k8s.io + path: webhooks/clientConfig/service/name + +namespace: +- kind: MutatingWebhookConfiguration + group: admissionregistration.k8s.io + path: webhooks/clientConfig/service/namespace + create: true +- kind: ValidatingWebhookConfiguration + group: admissionregistration.k8s.io + path: webhooks/clientConfig/service/namespace + create: true diff --git a/config/webhook/manifests.yaml b/config/webhook/manifests.yaml new file mode 100644 index 000000000..284c43d91 --- /dev/null +++ b/config/webhook/manifests.yaml @@ -0,0 +1,26 @@ +--- +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingWebhookConfiguration +metadata: + name: validating-webhook-configuration +webhooks: +- admissionReviewVersions: + - v1 + clientConfig: + service: + name: webhook-service + namespace: system + path: /validate-ogx-io-v1beta1-ogxserver + failurePolicy: Fail + name: vogxserver.kb.io + rules: + - apiGroups: + - ogx.io + apiVersions: + - v1beta1 + operations: + - CREATE + - UPDATE + resources: + - ogxservers + sideEffects: None diff --git a/config/webhook/service.yaml b/config/webhook/service.yaml new file mode 100644 index 000000000..567677934 --- /dev/null +++ b/config/webhook/service.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Service +metadata: + name: webhook-service + namespace: system +spec: + ports: + - port: 443 + protocol: TCP + targetPort: 9443 + selector: + control-plane: controller-manager diff --git a/controllers/configmap_reconciler.go b/controllers/configmap_reconciler.go new file mode 100644 index 000000000..2c551ca83 --- /dev/null +++ b/controllers/configmap_reconciler.go @@ -0,0 +1,343 @@ +/* +Copyright 2025. + +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. +*/ + +package controllers + +import ( + "context" + "errors" + "fmt" + "sort" + "time" + + ogxiov1beta1 "github.com/ogx-ai/ogx-k8s-operator/api/v1beta1" + "github.com/ogx-ai/ogx-k8s-operator/pkg/config" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +const ( + // generatedConfigLabel identifies operator-generated ConfigMaps. + generatedConfigLabel = "ogx.io/generated-config" + // configMapRetention is the number of generated ConfigMaps to retain. + configMapRetention = 2 + // generatedConfigKeyName is the key used in the generated ConfigMap data. + generatedConfigKeyName = "config.yaml" +) + +var errReferencedConfigMapKeyNotFound = errors.New("failed to find referenced ConfigMap key") + +// generatedConfigMapName returns the name for a generated ConfigMap. +func generatedConfigMapName(crName, contentHash string) string { + return fmt.Sprintf("%s-config-%s", crName, contentHash) +} + +// reconcileGeneratedConfig handles the full config generation lifecycle: +// 1. Generate config.yaml from spec + base config +// 2. Create/verify the ConfigMap with content-hash name +// Returns the generated config result, or nil if no config generation is needed. +// Cleanup of old generated ConfigMaps happens after the Deployment reconcile +// succeeds so in-flight rollouts keep their referenced inputs. +func (r *OGXServerReconciler) reconcileGeneratedConfig(ctx context.Context, instance *ogxiov1beta1.OGXServer) (*config.GeneratedConfig, error) { + logger := log.FromContext(ctx) + + if instance.HasOverrideConfig() || !instance.HasDeclarativeConfig() { + return nil, nil + } + + baseConfigData, err := r.resolveBaseConfig(ctx, instance) + if err != nil { + return nil, err + } + if baseConfigData == nil { + return nil, nil + } + + validateErr := config.ValidateSecretRefEnvVarNames(&instance.Spec) + if validateErr != nil { + return nil, fmt.Errorf("failed to validate secret env var mapping: %w", validateErr) + } + + generated, err := config.GenerateConfig(&instance.Spec, baseConfigData) + if err != nil { + return nil, fmt.Errorf("failed to generate config: %w", err) + } + + if generated.ConfigVersionDefaulted { + logger.Info("base config has non-numeric version, defaulting to version 2", "rawVersion", "non-numeric") + } + + configMapName := generatedConfigMapName(instance.Name, generated.ContentHash) + if err := r.ensureGeneratedConfigMap(ctx, instance, configMapName, generated.ConfigYAML); err != nil { + return nil, err + } + + return generated, nil +} + +// resolveBaseConfig resolves the base config.yaml from a referenced ConfigMap or +// OCI labels on the resolved distribution image. +func (r *OGXServerReconciler) resolveBaseConfig(ctx context.Context, instance *ogxiov1beta1.OGXServer) ([]byte, error) { + logger := log.FromContext(ctx) + + if ref := instance.Spec.BaseConfig; ref != nil { + data, err := r.readReferencedConfigMapKey(ctx, instance.Namespace, *ref) + if err != nil { + return nil, &terminalError{message: fmt.Sprintf("failed to resolve base config from ConfigMap %s/%s[%s]: %v", instance.Namespace, ref.Name, ref.Key, err)} + } + logger.V(1).Info("resolved base config", "source", "configmap", "configMap", ref.Name, "key", ref.Key) + return data, nil + } + + distributionName := instance.Spec.Distribution.Name + resolvedImage, resolveErr := r.resolveImage(instance.Spec.Distribution) + if resolveErr != nil { + return nil, fmt.Errorf("failed to resolve distribution image: %w", resolveErr) + } + + resolver := r.configResolver + if resolver == nil { + resolver = config.NewDefaultConfigResolver(r.OCILabelFetcher) + } + data, err := resolver.Resolve(resolvedImage, distributionName) + if err != nil { + return nil, fmt.Errorf("failed to resolve base config from OCI labels: %w", err) + } + + logger.V(1).Info("resolved base config", "distribution", distributionName, "image", resolvedImage, "source", "oci") + return data, nil +} + +func (r *OGXServerReconciler) readReferencedConfigMapKey( + ctx context.Context, namespace string, ref ogxiov1beta1.ConfigMapKeyRef, +) ([]byte, error) { + configMap := &corev1.ConfigMap{} + if err := r.Get(ctx, client.ObjectKey{Name: ref.Name, Namespace: namespace}, configMap); err != nil { + return nil, err + } + + data, ok := configMap.Data[ref.Key] + if !ok { + return nil, fmt.Errorf("failed to find referenced ConfigMap key %q: %w", ref.Key, errReferencedConfigMapKeyNotFound) + } + return []byte(data), nil +} + +// ensureGeneratedConfigMap creates the generated ConfigMap if it doesn't already exist. +func (r *OGXServerReconciler) ensureGeneratedConfigMap(ctx context.Context, instance *ogxiov1beta1.OGXServer, name, configYAML string) error { + logger := log.FromContext(ctx) + + existingCM := &corev1.ConfigMap{} + err := r.Get(ctx, client.ObjectKey{Name: name, Namespace: instance.Namespace}, existingCM) + if err == nil { + if existingCM.Data[generatedConfigKeyName] != configYAML { + return fmt.Errorf("failed to verify generated ConfigMap content for %q: hash collision detected", name) + } + return nil + } + if !k8serrors.IsNotFound(err) { + return fmt.Errorf("failed to check for existing generated ConfigMap: %w", err) + } + + cm := r.buildGeneratedConfigMap(instance, name, configYAML) + if err := ctrl.SetControllerReference(instance, cm, r.Scheme); err != nil { + return fmt.Errorf("failed to set owner reference on generated ConfigMap: %w", err) + } + if err := r.Create(ctx, cm); err != nil { + if k8serrors.IsAlreadyExists(err) { + logger.V(1).Info("generated ConfigMap already exists (concurrent reconcile)", "name", name) + return nil + } + return fmt.Errorf("failed to create generated ConfigMap: %w", err) + } + logger.Info("created generated ConfigMap", "name", name) + return nil +} + +func (r *OGXServerReconciler) buildGeneratedConfigMap(instance *ogxiov1beta1.OGXServer, name, configYAML string) *corev1.ConfigMap { + return &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: instance.Namespace, + Labels: map[string]string{ + managedByLabelKey: managedByLabelVal, + "app.kubernetes.io/instance": instance.Name, + generatedConfigLabel: "true", + WatchLabelKey: WatchLabelValue, + }, + }, + Immutable: boolPtr(true), + Data: map[string]string{ + generatedConfigKeyName: configYAML, + }, + } +} + +// cleanupOldGeneratedConfigMaps deletes generated ConfigMaps beyond the retention limit. +// +//nolint:cyclop // Orchestrates protection, retention, and deletion rules for generated ConfigMaps. +func (r *OGXServerReconciler) cleanupOldGeneratedConfigMaps(ctx context.Context, instance *ogxiov1beta1.OGXServer, currentName string) error { + logger := log.FromContext(ctx) + + // List all generated ConfigMaps for this instance + cmList := &corev1.ConfigMapList{} + selector := labels.SelectorFromSet(map[string]string{ + generatedConfigLabel: "true", + "app.kubernetes.io/instance": instance.Name, + }) + if err := r.List(ctx, cmList, &client.ListOptions{ + Namespace: instance.Namespace, + LabelSelector: selector, + }); err != nil { + return fmt.Errorf("failed to list generated ConfigMaps: %w", err) + } + + if len(cmList.Items) <= configMapRetention { + return nil + } + + protectedNames, err := r.referencedRuntimeConfigMapNames(ctx, instance, currentName) + if err != nil { + return fmt.Errorf("failed to determine referenced generated ConfigMaps: %w", err) + } + + protectedCount := 0 + for i := range cmList.Items { + if _, ok := protectedNames[cmList.Items[i].Name]; ok { + protectedCount++ + } + } + + // Sort by creation timestamp (oldest first) + sort.Slice(cmList.Items, func(i, j int) bool { + return cmList.Items[i].CreationTimestamp.Before(&cmList.Items[j].CreationTimestamp) + }) + + minimumToKeep := configMapRetention + if protectedCount > minimumToKeep { + minimumToKeep = protectedCount + } + + deleteCount := len(cmList.Items) - minimumToKeep + for i := range cmList.Items { + if deleteCount == 0 { + break + } + cm := &cmList.Items[i] + if _, ok := protectedNames[cm.Name]; ok { + continue + } + if err := r.Delete(ctx, cm); err != nil && !k8serrors.IsNotFound(err) { + logger.Error(err, "failed to delete old generated ConfigMap", "name", cm.Name) + } else { + logger.V(1).Info("deleted old generated ConfigMap", "name", cm.Name) + deleteCount-- + } + } + + return nil +} + +func (r *OGXServerReconciler) getGeneratedConfigMapHashByName(ctx context.Context, namespace, name string) (string, error) { + cm := &corev1.ConfigMap{} + err := r.Get(ctx, client.ObjectKey{ + Name: name, + Namespace: namespace, + }, cm) + if err != nil { + return "", err + } + return fmt.Sprintf("%s-%s", cm.ResourceVersion, cm.Name), nil +} + +func (r *OGXServerReconciler) referencedRuntimeConfigMapNames( + ctx context.Context, + instance *ogxiov1beta1.OGXServer, + currentName string, +) (map[string]struct{}, error) { + protected := map[string]struct{}{currentName: {}} + if instance.Status.ConfigGeneration != nil && instance.Status.ConfigGeneration.ConfigMapName != "" { + protected[instance.Status.ConfigGeneration.ConfigMapName] = struct{}{} + } + + deployment := &appsv1.Deployment{} + if err := r.Get(ctx, client.ObjectKey{Name: instance.Name, Namespace: instance.Namespace}, deployment); err != nil { + if !k8serrors.IsNotFound(err) { + return nil, err + } + } else { + collectRuntimeConfigMapRefs(protected, deployment.Spec.Template.Spec.Volumes) + } + + rsList := &appsv1.ReplicaSetList{} + if err := r.List( + ctx, + rsList, + client.InNamespace(instance.Namespace), + client.MatchingLabels{instanceLabelKey: instance.Name}, + ); err != nil { + return nil, err + } + for i := range rsList.Items { + collectRuntimeConfigMapRefs(protected, rsList.Items[i].Spec.Template.Spec.Volumes) + } + + return protected, nil +} + +func collectRuntimeConfigMapRefs(protected map[string]struct{}, volumes []corev1.Volume) { + for i := range volumes { + if volumes[i].Name != "user-config" || volumes[i].ConfigMap == nil { + continue + } + if name := volumes[i].ConfigMap.Name; name != "" { + protected[name] = struct{}{} + } + } +} + +// updateConfigGenerationStatus updates the status with config generation details. +func (r *OGXServerReconciler) updateConfigGenerationStatus(instance *ogxiov1beta1.OGXServer, generated *config.GeneratedConfig) { + if generated == nil { + return + } + configMapName := generatedConfigMapName(instance.Name, generated.ContentHash) + instance.Status.ConfigGeneration = &ogxiov1beta1.ConfigGenerationStatus{ + ObservedGeneration: instance.Generation, + ConfigMapName: configMapName, + GeneratedAt: metav1.Time{Time: time.Now()}, + ProviderCount: generated.ProviderCount, + ResourceCount: generated.ResourceCount, + ConfigVersion: generated.ConfigVersion, + } +} + +// clearConfigGenerationStatus clears generated config status and sets condition false. +// Always sets the condition to reflect inactive/failed generation state. +func (r *OGXServerReconciler) clearConfigGenerationStatus(instance *ogxiov1beta1.OGXServer, reason, message string) { + instance.Status.ConfigGeneration = nil + r.setConfigGeneratedCondition(instance, false, reason, message) +} + +func boolPtr(b bool) *bool { + return &b +} diff --git a/controllers/configmap_reconciler_test.go b/controllers/configmap_reconciler_test.go new file mode 100644 index 000000000..4c4086f48 --- /dev/null +++ b/controllers/configmap_reconciler_test.go @@ -0,0 +1,400 @@ +package controllers + +import ( + "encoding/base64" + "fmt" + "testing" + "time" + + ogxiov1beta1 "github.com/ogx-ai/ogx-k8s-operator/api/v1beta1" + "github.com/ogx-ai/ogx-k8s-operator/pkg/cluster" + "github.com/ogx-ai/ogx-k8s-operator/pkg/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func buildControllerTestScheme(t *testing.T) *runtime.Scheme { + t.Helper() + scheme := runtime.NewScheme() + require.NoError(t, appsv1.AddToScheme(scheme)) + require.NoError(t, corev1.AddToScheme(scheme)) + require.NoError(t, ogxiov1beta1.AddToScheme(scheme)) + return scheme +} + +func TestReconcileConfigMaps_DoesNotMutateStatusWhenInactive(t *testing.T) { + scheme := buildControllerTestScheme(t) + k8sClient := fake.NewClientBuilder().WithScheme(scheme).Build() + + r := &OGXServerReconciler{ + Client: k8sClient, + + Scheme: scheme, + } + + instance := &ogxiov1beta1.OGXServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "example", + Namespace: "test-ns", + }, + Spec: ogxiov1beta1.OGXServerSpec{ + Distribution: ogxiov1beta1.DistributionSpec{Name: "starter"}, + // No declarative config fields -> generation should be inactive. + }, + Status: ogxiov1beta1.OGXServerStatus{ + ConfigGeneration: &ogxiov1beta1.ConfigGenerationStatus{ + ConfigMapName: "example-config-deadbeef", + ObservedGeneration: 2, + }, + }, + } + + generated, err := r.reconcileConfigMaps(t.Context(), instance) + require.NoError(t, err) + assert.Nil(t, generated) + require.NotNil(t, instance.Status.ConfigGeneration) + assert.Equal(t, "example-config-deadbeef", instance.Status.ConfigGeneration.ConfigMapName) +} + +func TestReconcileGeneratedConfig_HappyPath(t *testing.T) { + scheme := buildControllerTestScheme(t) + k8sClient := fake.NewClientBuilder().WithScheme(scheme).Build() + fetcher := func(string) (map[string]string, error) { + return map[string]string{ + config.OCIDefaultConfigLabel: "config.yaml", + config.OCIConfigLabelPrefix + "config.yaml": base64.StdEncoding.EncodeToString([]byte("version: '2'\ndistro_name: starter\napis:\n- inference\nserver:\n port: 8321\n")), + }, nil + } + + r := &OGXServerReconciler{ + Client: k8sClient, + + Scheme: scheme, + configResolver: config.NewDefaultConfigResolver(fetcher), + ClusterInfo: &cluster.ClusterInfo{ + DistributionImages: map[string]string{ + "starter": "docker.io/ogxai/distribution-starter:latest", + }, + }, + } + + instance := &ogxiov1beta1.OGXServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-server", + Namespace: "test-ns", + UID: types.UID("test-uid-123"), + }, + Spec: ogxiov1beta1.OGXServerSpec{ + Distribution: ogxiov1beta1.DistributionSpec{Name: "starter"}, + Providers: &ogxiov1beta1.ProvidersSpec{ + Inference: &ogxiov1beta1.InferenceProvidersSpec{ + Remote: &ogxiov1beta1.InferenceRemoteProviders{ + VLLM: []ogxiov1beta1.VLLMProvider{ + {Endpoint: "https://vllm:8000"}, + }, + }, + }, + }, + }, + } + + generated, err := r.reconcileGeneratedConfig(t.Context(), instance) + require.NoError(t, err) + require.NotNil(t, generated, "expected generated config for declarative providers") + assert.Positive(t, generated.ProviderCount) + assert.Equal(t, 2, generated.ConfigVersion) + assert.NotEmpty(t, generated.ConfigYAML) + assert.NotEmpty(t, generated.ContentHash) + + cmName := fmt.Sprintf("test-server-config-%s", generated.ContentHash) + cm := &corev1.ConfigMap{} + err = k8sClient.Get(t.Context(), client.ObjectKey{Name: cmName, Namespace: "test-ns"}, cm) + require.NoError(t, err, "expected generated ConfigMap to exist") + assert.Equal(t, generated.ConfigYAML, cm.Data["config.yaml"]) + assert.Equal(t, "true", cm.Labels[generatedConfigLabel]) + assert.Equal(t, managedByLabelVal, cm.Labels[managedByLabelKey]) +} + +func TestReconcileGeneratedConfig_SkippedWhenOverrideSet(t *testing.T) { + scheme := buildControllerTestScheme(t) + k8sClient := fake.NewClientBuilder().WithScheme(scheme).Build() + + r := &OGXServerReconciler{ + Client: k8sClient, + + Scheme: scheme, + configResolver: config.NewDefaultConfigResolver(nil), + ClusterInfo: &cluster.ClusterInfo{ + DistributionImages: map[string]string{ + "starter": "docker.io/ogxai/distribution-starter:latest", + }, + }, + } + + instance := &ogxiov1beta1.OGXServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-server", + Namespace: "test-ns", + }, + Spec: ogxiov1beta1.OGXServerSpec{ + Distribution: ogxiov1beta1.DistributionSpec{Name: "starter"}, + OverrideConfig: &ogxiov1beta1.ConfigMapKeyRef{ + Name: "my-config", + Key: "config.yaml", + }, + Providers: &ogxiov1beta1.ProvidersSpec{ + Inference: &ogxiov1beta1.InferenceProvidersSpec{ + Remote: &ogxiov1beta1.InferenceRemoteProviders{ + VLLM: []ogxiov1beta1.VLLMProvider{ + {Endpoint: "https://vllm:8000"}, + }, + }, + }, + }, + }, + } + + generated, err := r.reconcileGeneratedConfig(t.Context(), instance) + require.NoError(t, err) + assert.Nil(t, generated, "expected nil when override config is set") +} + +func TestReconcileGeneratedConfig_UsesBaseConfigMap(t *testing.T) { + scheme := buildControllerTestScheme(t) + baseConfig := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "base-config", + Namespace: "test-ns", + }, + Data: map[string]string{ + "config.yaml": "version: '2'\ndistro_name: starter\napis:\n- inference\nserver:\n port: 8321\n", + }, + } + k8sClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(baseConfig).Build() + + r := &OGXServerReconciler{ + Client: k8sClient, + + Scheme: scheme, + } + + instance := &ogxiov1beta1.OGXServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-server", + Namespace: "test-ns", + UID: types.UID("test-uid-456"), + }, + Spec: ogxiov1beta1.OGXServerSpec{ + Distribution: ogxiov1beta1.DistributionSpec{Name: "starter"}, + BaseConfig: &ogxiov1beta1.ConfigMapKeyRef{ + Name: "base-config", + Key: "config.yaml", + }, + Providers: &ogxiov1beta1.ProvidersSpec{ + Inference: &ogxiov1beta1.InferenceProvidersSpec{ + Remote: &ogxiov1beta1.InferenceRemoteProviders{ + VLLM: []ogxiov1beta1.VLLMProvider{ + {Endpoint: "https://vllm:8000"}, + }, + }, + }, + }, + }, + } + + generated, err := r.reconcileGeneratedConfig(t.Context(), instance) + require.NoError(t, err) + require.NotNil(t, generated) + assert.Contains(t, generated.ConfigYAML, "distro_name: starter") + assert.Contains(t, generated.ConfigYAML, "provider_id: remote-vllm") +} + +func TestReconcileConfigMaps_LeavesStatusUnchangedWhenInactive(t *testing.T) { + scheme := buildControllerTestScheme(t) + k8sClient := fake.NewClientBuilder().WithScheme(scheme).Build() + + r := &OGXServerReconciler{ + Client: k8sClient, + + Scheme: scheme, + } + + instance := &ogxiov1beta1.OGXServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "example", + Namespace: "test-ns", + }, + Spec: ogxiov1beta1.OGXServerSpec{ + Distribution: ogxiov1beta1.DistributionSpec{Name: "starter"}, + }, + } + + generated, err := r.reconcileConfigMaps(t.Context(), instance) + require.NoError(t, err) + assert.Nil(t, generated) + assert.Nil(t, instance.Status.ConfigGeneration) + assert.Nil(t, GetCondition(&instance.Status, ConditionTypeConfigGenerated)) +} + +func TestCleanupOldGeneratedConfigMaps_KeepsReferencedReplicaSetConfig(t *testing.T) { + scheme := buildControllerTestScheme(t) + current := generatedConfigMapName("test-server", "current") + referenced := generatedConfigMapName("test-server", "referenced") + stale := generatedConfigMapName("test-server", "stale") + + cmCurrent := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: current, + Namespace: "test-ns", + CreationTimestamp: metav1.NewTime(time.Unix(30, 0)), + Labels: map[string]string{ + generatedConfigLabel: "true", + "app.kubernetes.io/instance": "test-server", + }, + }, + } + cmReferenced := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: referenced, + Namespace: "test-ns", + CreationTimestamp: metav1.NewTime(time.Unix(20, 0)), + Labels: map[string]string{ + generatedConfigLabel: "true", + "app.kubernetes.io/instance": "test-server", + }, + }, + } + cmStale := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: stale, + Namespace: "test-ns", + CreationTimestamp: metav1.NewTime(time.Unix(10, 0)), + Labels: map[string]string{ + generatedConfigLabel: "true", + "app.kubernetes.io/instance": "test-server", + }, + }, + } + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-server", + Namespace: "test-ns", + Labels: map[string]string{"app.kubernetes.io/instance": "test-server"}, + }, + } + replicaSet := &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-server-rs", + Namespace: "test-ns", + Labels: map[string]string{"app.kubernetes.io/instance": "test-server"}, + }, + Spec: appsv1.ReplicaSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Volumes: []corev1.Volume{ + { + Name: "user-config", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: referenced}, + }, + }, + }, + }, + }, + }, + }, + } + k8sClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(cmCurrent, cmReferenced, cmStale, deployment, replicaSet). + Build() + + r := &OGXServerReconciler{ + Client: k8sClient, + + Scheme: scheme, + } + instance := &ogxiov1beta1.OGXServer{ + ObjectMeta: metav1.ObjectMeta{Name: "test-server", Namespace: "test-ns"}, + } + + err := r.cleanupOldGeneratedConfigMaps(t.Context(), instance, current) + require.NoError(t, err) + + require.NoError(t, k8sClient.Get(t.Context(), client.ObjectKey{Name: current, Namespace: "test-ns"}, &corev1.ConfigMap{})) + require.NoError(t, k8sClient.Get(t.Context(), client.ObjectKey{Name: referenced, Namespace: "test-ns"}, &corev1.ConfigMap{})) + err = k8sClient.Get(t.Context(), client.ObjectKey{Name: stale, Namespace: "test-ns"}, &corev1.ConfigMap{}) + require.Error(t, err) +} + +func TestReconcileResources_DoesNotAdvanceConfigGenerationStatusOnSecretHashFailure(t *testing.T) { + scheme := buildControllerTestScheme(t) + fetcher := func(string) (map[string]string, error) { + return map[string]string{ + config.OCIDefaultConfigLabel: "config.yaml", + config.OCIConfigLabelPrefix + "config.yaml": base64.StdEncoding.EncodeToString([]byte("version: '2'\ndistro_name: starter\napis:\n- inference\nserver:\n port: 8321\n")), + }, nil + } + k8sClient := fake.NewClientBuilder().WithScheme(scheme).Build() + + r := &OGXServerReconciler{ + Client: k8sClient, + + Scheme: scheme, + configResolver: config.NewDefaultConfigResolver(fetcher), + ClusterInfo: &cluster.ClusterInfo{ + DistributionImages: map[string]string{ + "starter": "docker.io/ogxai/distribution-starter:latest", + }, + }, + } + + instance := &ogxiov1beta1.OGXServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-server", + Namespace: "test-ns", + }, + Spec: ogxiov1beta1.OGXServerSpec{ + Distribution: ogxiov1beta1.DistributionSpec{Name: "starter"}, + Providers: &ogxiov1beta1.ProvidersSpec{ + Inference: &ogxiov1beta1.InferenceProvidersSpec{ + Remote: &ogxiov1beta1.InferenceRemoteProviders{ + VLLM: []ogxiov1beta1.VLLMProvider{ + { + Endpoint: "https://vllm:8000", + APIToken: &ogxiov1beta1.SecretKeyRef{Name: "missing-secret", Key: "api-token"}, + }, + }, + }, + }, + }, + Resources: &ogxiov1beta1.ResourcesSpec{ + Models: []ogxiov1beta1.ModelConfig{{Name: "llama3"}}, + }, + }, + Status: ogxiov1beta1.OGXServerStatus{ + ConfigGeneration: &ogxiov1beta1.ConfigGenerationStatus{ + ConfigMapName: "test-server-config-oldhash", + ObservedGeneration: 1, + }, + }, + } + + err := r.reconcileResources(t.Context(), instance) + require.Error(t, err) + require.NotNil(t, instance.Status.ConfigGeneration) + assert.Equal(t, "test-server-config-oldhash", instance.Status.ConfigGeneration.ConfigMapName) + + condition := GetCondition(&instance.Status, ConditionTypeConfigGenerated) + require.NotNil(t, condition) + assert.Equal(t, metav1.ConditionFalse, condition.Status) + assert.Equal(t, "ConfigGenerationFailed", condition.Reason) +} diff --git a/controllers/kubebuilder_rbac.go b/controllers/kubebuilder_rbac.go index c3e685374..bfa016177 100644 --- a/controllers/kubebuilder_rbac.go +++ b/controllers/kubebuilder_rbac.go @@ -1,16 +1,22 @@ package controllers -// LlamaStackDistribution CRD permissions -//+kubebuilder:rbac:groups=llamastack.io,resources=llamastackdistributions,verbs=get;list;watch;create;update;patch;delete -//+kubebuilder:rbac:groups=llamastack.io,resources=llamastackdistributions/status,verbs=get;update;patch -//+kubebuilder:rbac:groups=llamastack.io,resources=llamastackdistributions/finalizers,verbs=update +// OGXServer CRD permissions +//+kubebuilder:rbac:groups=ogx.io,resources=ogxservers,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=ogx.io,resources=ogxservers/status,verbs=get;update;patch +//+kubebuilder:rbac:groups=ogx.io,resources=ogxservers/finalizers,verbs=update // Deployment permissions - controller creates and manages deployments //+kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete +// ReplicaSet permissions - controller lists rollout history to retain referenced generated configmaps +//+kubebuilder:rbac:groups=apps,resources=replicasets,verbs=get;list;watch // Service permissions - controller creates and manages services //+kubebuilder:rbac:groups="",resources=services,verbs=get;list;watch;create;update;patch;delete +// TRANSITIONAL: Legacy resource access for annotation-driven adoption. +// These permissions will be removed when adoption support is deprecated. +//+kubebuilder:rbac:groups="",resources=pods,verbs=list + // ServiceAccount permissions - controller creates and manages service accounts for PVC permissions //+kubebuilder:rbac:groups="",resources=serviceaccounts,verbs=get;list;watch;create;update;patch;delete @@ -23,7 +29,10 @@ package controllers //+kubebuilder:rbac:groups=security.openshift.io,resources=securitycontextconstraints,verbs=use //+kubebuilder:rbac:groups=security.openshift.io,resources=securitycontextconstraints,resourceNames=anyuid,verbs=use -//+kubebuilder:rbac:groups="",resources=persistentvolumeclaims,verbs=get;list;watch;create +//+kubebuilder:rbac:groups="",resources=persistentvolumeclaims,verbs=get;list;watch;create;update;patch + +// Secret permissions - controller watches user-labeled secrets to trigger reconciliation +//+kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch // ConfigMap permissions - controller reads user configmaps and manages operator config configmaps //+kubebuilder:rbac:groups="",resources=configmaps,verbs=get;list;watch;create;update;patch @@ -39,3 +48,10 @@ package controllers // HorizontalPodAutoscaler permissions - controller creates and manages HPAs for server pods //+kubebuilder:rbac:groups=autoscaling,resources=horizontalpodautoscalers,verbs=get;list;watch;create;update;patch;delete + +// Monitoring permissions - controller creates and manages ServiceMonitors and PrometheusRules +//+kubebuilder:rbac:groups=monitoring.coreos.com,resources=servicemonitors,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=monitoring.coreos.com,resources=prometheusrules,verbs=get;list;watch;create;update;patch;delete + +// CRD discovery - controller checks for monitoring.coreos.com CRD availability +//+kubebuilder:rbac:groups=apiextensions.k8s.io,resources=customresourcedefinitions,verbs=get;list;watch diff --git a/controllers/legacy_adoption.go b/controllers/legacy_adoption.go new file mode 100644 index 000000000..6fe3cb4cf --- /dev/null +++ b/controllers/legacy_adoption.go @@ -0,0 +1,620 @@ +/* +Copyright 2025. + +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. +*/ + +package controllers + +import ( + "context" + "fmt" + "time" + + legacyv1alpha1 "github.com/ogx-ai/ogx-k8s-operator/api/v1alpha1" + ogxiov1beta1 "github.com/ogx-ai/ogx-k8s-operator/api/v1beta1" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +// adoptionResult carries the outcome of a single adoption pass. +type adoptionResult struct { + requeue bool + requeueAfter time.Duration +} + +// adoptLegacyResources processes adoption annotations and transfers ownership +// of legacy LlamaStackDistribution resources to this OGXServer CR. +// Returns (needsRequeue, error). A requeue is requested when the old Deployment +// has been scaled to zero but pods are still terminating. +func (r *OGXServerReconciler) adoptLegacyResources(ctx context.Context, instance *ogxiov1beta1.OGXServer) (adoptionResult, error) { + logger := log.FromContext(ctx).WithValues("adoption", true) + ctx = ctrl.LoggerInto(ctx, logger) + + result := adoptionResult{} + + storageSource := instance.GetAdoptStorageSource() + networkingSource := instance.GetAdoptNetworkingSource() + + // Always clear stale invalid condition when there are no active annotations. + if storageSource == "" && networkingSource == "" { + clearAdoptionConfigInvalidCondition(&instance.Status) + return result, nil + } + + // Validate annotations before any adoption work. + if !validateAdoptionSources(ctx, &instance.Status, instance.Name, storageSource, networkingSource) { + return result, nil + } + + // Clear any previous AdoptionConfigInvalid condition. + clearAdoptionConfigInvalidCondition(&instance.Status) + + storageResult, err := r.adoptStorageSource(ctx, instance, storageSource) + if err != nil { + return result, err + } + if storageResult.requeue { + result = storageResult + } + + if err := r.adoptNetworkingSource(ctx, instance, networkingSource); err != nil { + return result, err + } + + return result, nil +} + +func validateAdoptionSources( + ctx context.Context, + status *ogxiov1beta1.OGXServerStatus, + instanceName, storageSource, networkingSource string, +) bool { + if !validateAdoptionSource(ctx, status, "adopt-storage", storageSource, instanceName) { + return false + } + + return validateAdoptionSource(ctx, status, "adopt-networking", networkingSource, instanceName) +} + +func validateAdoptionSource( + ctx context.Context, + status *ogxiov1beta1.OGXServerStatus, + annotationName, value, instanceName string, +) bool { + if value == "" { + return true + } + + if value == instanceName { + logger := log.FromContext(ctx) + logger.Info("adoption annotation value equals CR name, rejecting", "annotation", annotationName, "value", value) + SetAdoptionConfigInvalidCondition(status, fmt.Sprintf( + "%s: value %q must not equal the CR name; same-name adoption causes resource conflicts", annotationName, value)) + return false + } + + if err := ogxiov1beta1.ValidateAdoptionAnnotation(value); err != nil { + logger := log.FromContext(ctx) + logger.Error(err, "invalid adoption annotation value", "annotation", annotationName, "value", value) + SetAdoptionConfigInvalidCondition(status, fmt.Sprintf("%s: %v", annotationName, err)) + return false + } + + return true +} + +func (r *OGXServerReconciler) adoptStorageSource( + ctx context.Context, + instance *ogxiov1beta1.OGXServer, + storageSource string, +) (adoptionResult, error) { + if storageSource == "" { + return adoptionResult{}, nil + } + + storageResult, err := r.adoptStorage(ctx, instance, storageSource) + if err != nil { + return adoptionResult{}, fmt.Errorf("failed to adopt storage from %q: %w", storageSource, err) + } + + return storageResult, nil +} + +func (r *OGXServerReconciler) adoptNetworkingSource( + ctx context.Context, + instance *ogxiov1beta1.OGXServer, + networkingSource string, +) error { + if networkingSource == "" { + return nil + } + + if err := r.adoptNetworking(ctx, instance, networkingSource); err != nil { + return fmt.Errorf("failed to adopt networking from %q: %w", networkingSource, err) + } + + return nil +} + +// adoptStorage detaches a legacy PVC from its old controller and labels it +// for discovery by this OGXServer. No ownerRef is set on the PVC +// The Deployment references the PVC by name via GetEffectivePVCName(). +func (r *OGXServerReconciler) adoptStorage(ctx context.Context, instance *ogxiov1beta1.OGXServer, legacyName string) (adoptionResult, error) { + logger := log.FromContext(ctx) + result := adoptionResult{} + + pvcName := legacyName + "-pvc" + pvc := &corev1.PersistentVolumeClaim{} + pvcKey := types.NamespacedName{Name: pvcName, Namespace: instance.Namespace} + + if err := r.Get(ctx, pvcKey, pvc); err != nil { + if k8serrors.IsNotFound(err) { + logger.Info("Legacy PVC not found, skipping storage adoption", "pvc", pvcName) + return result, nil + } + return result, fmt.Errorf("failed to get legacy PVC %s: %w", pvcName, err) + } + + // Idempotency: check adoption label instead of ownerRef. + if pvc.Labels != nil && pvc.Labels[ogxiov1beta1.AdoptedFromLabel] == legacyName { + logger.V(1).Info("Legacy PVC already adopted", "pvc", pvcName) + SetStorageAdoptedCondition(&instance.Status, true, fmt.Sprintf("PVC %s adopted", pvcName)) + return result, nil + } + + // Scale legacy Deployment to zero if it still exists, to release the RWO PVC. + requeue, err := r.scaleDownLegacyDeployment(ctx, instance.Namespace, legacyName) + if err != nil { + return result, err + } + if requeue { + result.requeue = true + result.requeueAfter = 5 * time.Second + logger.Info("Waiting for legacy pods to terminate before PVC adoption", "deployment", legacyName) + return result, nil + } + + // Detach from old controller and label for discovery. No new ownerRef is set. + if err := r.detachAndLabelPVC(ctx, instance.Name, pvc, legacyName); err != nil { + return result, err + } + + SetStorageAdoptedCondition(&instance.Status, true, fmt.Sprintf("PVC %s adopted", pvcName)) + logger.Info("Successfully adopted legacy PVC", "pvc", pvcName) + return result, nil +} + +// detachAndLabelPVC removes the legacy controller ownerRef from the PVC and +// sets the adopted-from label for server-side discovery. No new ownerRef is +// added — PVCs must outlive OGXServer deletion. +func (r *OGXServerReconciler) detachAndLabelPVC(ctx context.Context, instanceName string, pvc *corev1.PersistentVolumeClaim, legacySource string) error { + controllerRef := metav1.GetControllerOf(pvc) + if controllerRef != nil && !isExpectedLegacyOwnerRef(controllerRef, legacySource) { + return fmt.Errorf( + "failed to adopt %s: unexpected controller owner %s/%s %q", + pvc.Name, controllerRef.APIVersion, controllerRef.Kind, controllerRef.Name, + ) + } + + // Strip old controller ownerRef. + ownerRefs := pvc.GetOwnerReferences() + filtered := make([]metav1.OwnerReference, 0, len(ownerRefs)) + for i := range ownerRefs { + if ownerRefs[i].Controller != nil && *ownerRefs[i].Controller { + continue + } + filtered = append(filtered, ownerRefs[i]) + } + pvc.SetOwnerReferences(filtered) + + // Set adoption label for server-side discovery, and instance label to + // scope the PVC to the specific OGXServer (avoids cross-instance ambiguity + // when multiple OGXServers share a namespace). + labels := pvc.GetLabels() + if labels == nil { + labels = make(map[string]string) + } + labels[ogxiov1beta1.AdoptedFromLabel] = legacySource + labels[instanceLabelKey] = instanceName + pvc.SetLabels(labels) + + // Set adoption audit annotation. + annotations := pvc.GetAnnotations() + if annotations == nil { + annotations = make(map[string]string) + } + annotations[ogxiov1beta1.AdoptedAtAnnotation] = metav1.Now().UTC().Format(time.RFC3339) + pvc.SetAnnotations(annotations) + + if err := r.Update(ctx, pvc); err != nil { + return fmt.Errorf("failed to update %s after adoption: %w", pvc.Name, err) + } + + return nil +} + +// adoptNetworking transfers ownership of a legacy Service and Ingress to this OGXServer CR. +func (r *OGXServerReconciler) adoptNetworking(ctx context.Context, instance *ogxiov1beta1.OGXServer, legacyName string) error { + serviceAdopted, err := r.adoptLegacyService(ctx, instance, legacyName) + if err != nil { + return err + } + ingressAdopted, err := r.adoptLegacyIngress(ctx, instance, legacyName) + if err != nil { + return err + } + + if serviceAdopted || ingressAdopted { + SetNetworkingAdoptedCondition(&instance.Status, true, fmt.Sprintf("Networking adopted from %s", legacyName)) + } + + return nil +} + +func (r *OGXServerReconciler) adoptLegacyService(ctx context.Context, instance *ogxiov1beta1.OGXServer, legacyName string) (bool, error) { + logger := log.FromContext(ctx) + serviceName := legacyName + "-service" + svc := &corev1.Service{} + svcKey := types.NamespacedName{Name: serviceName, Namespace: instance.Namespace} + + if err := r.Get(ctx, svcKey, svc); err != nil { + if k8serrors.IsNotFound(err) { + logger.Info("Legacy Service not found, skipping", "service", serviceName) + return false, nil + } + return false, fmt.Errorf("failed to get legacy Service %s: %w", serviceName, err) + } + + if metav1.IsControlledBy(svc, instance) { + logger.V(1).Info("Legacy Service already adopted", "service", serviceName) + return true, nil + } + + // Replace selectors to route traffic to new pods. + svc.Spec.Selector = map[string]string{ + ogxiov1beta1.DefaultLabelKey: ogxiov1beta1.DefaultLabelValue, + instanceLabelKey: instance.Name, + } + + if err := r.transferOwnership(ctx, instance, svc, legacyName); err != nil { + return false, err + } + logger.Info("Successfully adopted legacy Service", "service", serviceName) + + return true, nil +} + +func (r *OGXServerReconciler) adoptLegacyIngress(ctx context.Context, instance *ogxiov1beta1.OGXServer, legacyName string) (bool, error) { + logger := log.FromContext(ctx) + ingressName := legacyName + "-ingress" + ingress := &networkingv1.Ingress{} + ingressKey := types.NamespacedName{Name: ingressName, Namespace: instance.Namespace} + + if err := r.Get(ctx, ingressKey, ingress); err != nil { + if k8serrors.IsNotFound(err) { + logger.Info("Legacy Ingress not found, skipping", "ingress", ingressName) + return false, nil + } + return false, fmt.Errorf("failed to get legacy Ingress %s: %w", ingressName, err) + } + + if metav1.IsControlledBy(ingress, instance) { + logger.V(1).Info("Legacy Ingress already adopted", "ingress", ingressName) + return true, nil + } + + if err := r.transferOwnership(ctx, instance, ingress, legacyName); err != nil { + return false, err + } + logger.Info("Successfully adopted legacy Ingress", "ingress", ingressName) + + return true, nil +} + +// scaleDownLegacyDeployment scales the legacy Deployment to zero and returns +// true if a requeue is needed to wait for pod termination. +func (r *OGXServerReconciler) scaleDownLegacyDeployment(ctx context.Context, namespace, legacyName string) (bool, error) { + logger := log.FromContext(ctx) + + deployment := &appsv1.Deployment{} + key := types.NamespacedName{Name: legacyName, Namespace: namespace} + + if err := r.Get(ctx, key, deployment); err != nil { + if k8serrors.IsNotFound(err) { + logger.V(1).Info("Legacy Deployment not found, proceeding with PVC adoption", "deployment", legacyName) + return false, nil + } + return false, fmt.Errorf("failed to get legacy Deployment %s: %w", legacyName, err) + } + + // Scale to zero if not already. + zero := int32(0) + if deployment.Spec.Replicas == nil || *deployment.Spec.Replicas != 0 { + logger.Info("Scaling legacy Deployment to zero", "deployment", legacyName) + patch := client.MergeFrom(deployment.DeepCopy()) + deployment.Spec.Replicas = &zero + if err := r.Patch(ctx, deployment, patch); err != nil { + return false, fmt.Errorf("failed to scale down legacy Deployment %s: %w", legacyName, err) + } + } + + // Check if pods are still running. + podList := &corev1.PodList{} + if err := r.List(ctx, podList, + client.InNamespace(namespace), + client.MatchingLabels{instanceLabelKey: legacyName}, + ); err != nil { + return false, fmt.Errorf("failed to list pods for legacy Deployment %s: %w", legacyName, err) + } + + if len(podList.Items) > 0 { + logger.Info("Legacy pods still terminating", "deployment", legacyName, "podCount", len(podList.Items)) + return true, nil + } + + return false, nil +} + +// transferOwnership removes the existing controller ownerRef (if any) and sets +// this OGXServer as the new controller owner. Also annotates the resource with +// adoption audit metadata. legacySource is the LLSD name the resource was +// adopted from (used in the ogx.io/adopted-from annotation). +func (r *OGXServerReconciler) transferOwnership(ctx context.Context, instance *ogxiov1beta1.OGXServer, obj client.Object, legacySource string) error { + resourceName := obj.GetName() + controllerRef := metav1.GetControllerOf(obj) + + // Safety check: only take over resources that are currently controlled by + // the expected legacy LLSD controller. This avoids accidental takeover of + // unrelated resources that happen to share names. + if controllerRef != nil && !isExpectedLegacyOwnerRef(controllerRef, legacySource) { + return fmt.Errorf( + "failed to adopt %s: unexpected controller owner %s/%s %q", + resourceName, controllerRef.APIVersion, controllerRef.Kind, controllerRef.Name, + ) + } + + // Remove existing controller ownerRef. + ownerRefs := obj.GetOwnerReferences() + filtered := make([]metav1.OwnerReference, 0, len(ownerRefs)) + for i := range ownerRefs { + if ownerRefs[i].Controller != nil && *ownerRefs[i].Controller { + continue + } + filtered = append(filtered, ownerRefs[i]) + } + obj.SetOwnerReferences(filtered) + + // Set adoption audit label and timestamp annotation. + labels := obj.GetLabels() + if labels == nil { + labels = make(map[string]string) + } + labels[ogxiov1beta1.AdoptedFromLabel] = legacySource + obj.SetLabels(labels) + + annotations := obj.GetAnnotations() + if annotations == nil { + annotations = make(map[string]string) + } + annotations[ogxiov1beta1.AdoptedAtAnnotation] = metav1.Now().UTC().Format(time.RFC3339) + obj.SetAnnotations(annotations) + + // Set new controller ownerRef. + if err := ctrl.SetControllerReference(instance, obj, r.Scheme); err != nil { + return fmt.Errorf("failed to set controller reference on %s: %w", resourceName, err) + } + + if err := r.Update(ctx, obj); err != nil { + return fmt.Errorf("failed to update %s after ownership transfer: %w", resourceName, err) + } + + return nil +} + +// cleanupAdoptedNetworking deletes adopted legacy networking resources when the +// adopt-networking annotation is removed. Since same-name adoption is rejected +// at admission time, adopted resources always have different names from the +// kustomize-created ones and must be explicitly deleted. +func (r *OGXServerReconciler) cleanupAdoptedNetworking(ctx context.Context, instance *ogxiov1beta1.OGXServer) error { + // Only clean up if the annotation has been removed. + if instance.GetAdoptNetworkingSource() != "" { + return nil + } + // if this instance never reported networking adoption, there is + // nothing to clean up. + if !IsConditionTrue(&instance.Status, ConditionTypeNetworkingAdopted) { + return nil + } + + if err := r.cleanupAdoptedServices(ctx, instance); err != nil { + return err + } + + if err := r.cleanupAdoptedIngresses(ctx, instance); err != nil { + return err + } + + // Cleanup pass completed successfully and adoption annotation is absent; + // clear condition to avoid repeated full namespace scans on steady-state + // reconciliations. + SetNetworkingAdoptedCondition(&instance.Status, false, "Adoption annotation removed") + + return nil +} + +func (r *OGXServerReconciler) cleanupAdoptedServices(ctx context.Context, instance *ogxiov1beta1.OGXServer) error { + logger := log.FromContext(ctx) + ownedServices := &corev1.ServiceList{} + if err := r.List(ctx, ownedServices, client.InNamespace(instance.Namespace), client.HasLabels{ogxiov1beta1.AdoptedFromLabel}); err != nil { + return fmt.Errorf("failed to list services for adoption cleanup: %w", err) + } + + for i := range ownedServices.Items { + svc := &ownedServices.Items[i] + if !shouldDeleteAdoptedResource(instance, svc) { + continue + } + logger.Info("Deleting adopted legacy Service after annotation removal", "service", svc.Name) + if err := r.Delete(ctx, svc); err != nil && !k8serrors.IsNotFound(err) { + return fmt.Errorf("failed to delete adopted Service %s: %w", svc.Name, err) + } + } + + return nil +} + +func (r *OGXServerReconciler) cleanupAdoptedIngresses(ctx context.Context, instance *ogxiov1beta1.OGXServer) error { + logger := log.FromContext(ctx) + ownedIngresses := &networkingv1.IngressList{} + if err := r.List(ctx, ownedIngresses, client.InNamespace(instance.Namespace), client.HasLabels{ogxiov1beta1.AdoptedFromLabel}); err != nil { + return fmt.Errorf("failed to list ingresses for adoption cleanup: %w", err) + } + + for i := range ownedIngresses.Items { + ing := &ownedIngresses.Items[i] + if !shouldDeleteAdoptedResource(instance, ing) { + continue + } + logger.Info("Deleting adopted legacy Ingress after annotation removal", "ingress", ing.Name) + if err := r.Delete(ctx, ing); err != nil && !k8serrors.IsNotFound(err) { + return fmt.Errorf("failed to delete adopted Ingress %s: %w", ing.Name, err) + } + } + + return nil +} + +func shouldDeleteAdoptedResource( + instance *ogxiov1beta1.OGXServer, + obj metav1.Object, +) bool { + if !metav1.IsControlledBy(obj, instance) { + return false + } + _, hasAdopted := obj.GetLabels()[ogxiov1beta1.AdoptedFromLabel] + return hasAdopted +} + +// --- Condition helpers for adoption --- + +// SetStorageAdoptedCondition sets or updates the StorageAdopted condition. +func SetStorageAdoptedCondition(status *ogxiov1beta1.OGXServerStatus, adopted bool, message string) { + condition := metav1.Condition{ + Type: ConditionTypeStorageAdopted, + Status: metav1.ConditionTrue, + Reason: ReasonStorageAdopted, + Message: message, + LastTransitionTime: metav1.NewTime(metav1.Now().UTC()), + } + if !adopted { + condition.Status = metav1.ConditionFalse + } + SetCondition(status, condition) +} + +// SetNetworkingAdoptedCondition sets or updates the NetworkingAdopted condition. +func SetNetworkingAdoptedCondition(status *ogxiov1beta1.OGXServerStatus, adopted bool, message string) { + condition := metav1.Condition{ + Type: ConditionTypeNetworkingAdopted, + Status: metav1.ConditionTrue, + Reason: ReasonNetworkingAdopted, + Message: message, + LastTransitionTime: metav1.NewTime(metav1.Now().UTC()), + } + if !adopted { + condition.Status = metav1.ConditionFalse + } + SetCondition(status, condition) +} + +// SetAdoptionConfigInvalidCondition sets the AdoptionConfigInvalid condition to True. +func SetAdoptionConfigInvalidCondition(status *ogxiov1beta1.OGXServerStatus, message string) { + SetCondition(status, metav1.Condition{ + Type: ConditionTypeAdoptionConfigInvalid, + Status: metav1.ConditionTrue, + Reason: ReasonAdoptionConfigInvalid, + Message: message, + LastTransitionTime: metav1.NewTime(metav1.Now().UTC()), + }) +} + +// clearAdoptionConfigInvalidCondition sets AdoptionConfigInvalid to False +// when annotations are valid, removing a previously-set warning. +func clearAdoptionConfigInvalidCondition(status *ogxiov1beta1.OGXServerStatus) { + existing := GetCondition(status, ConditionTypeAdoptionConfigInvalid) + if existing == nil || existing.Status == metav1.ConditionFalse { + return + } + SetCondition(status, metav1.Condition{ + Type: ConditionTypeAdoptionConfigInvalid, + Status: metav1.ConditionFalse, + Reason: "AnnotationsValid", + Message: "Adoption annotations are valid", + LastTransitionTime: metav1.NewTime(metav1.Now().UTC()), + }) +} + +// resolveEffectivePVCName determines the PVC name the reconciler should use: +// 1. If adopt-storage annotation is present, the adopted PVC name is "{legacyName}-pvc". +// 2. If the annotation is absent, discover an already-adopted PVC by the AdoptedFromLabel. +// 3. Otherwise, fall back to the default "{instanceName}-pvc". +func (r *OGXServerReconciler) resolveEffectivePVCName(ctx context.Context, instance *ogxiov1beta1.OGXServer) (string, error) { + if src := instance.GetAdoptStorageSource(); src != "" && ogxiov1beta1.ValidateAdoptionAnnotation(src) == nil { + return src + "-pvc", nil + } + + pvcList := &corev1.PersistentVolumeClaimList{} + if err := r.List(ctx, pvcList, + client.InNamespace(instance.Namespace), + client.HasLabels{ogxiov1beta1.AdoptedFromLabel}, + client.MatchingLabels{instanceLabelKey: instance.Name}, + ); err != nil { + return "", fmt.Errorf("failed to list adopted PVCs: %w", err) + } + + if len(pvcList.Items) > 1 { + names := make([]string, len(pvcList.Items)) + for i := range pvcList.Items { + names[i] = pvcList.Items[i].Name + } + msg := fmt.Sprintf("multiple adopted PVCs found for instance %q: %v; remove the %s label from all but one", + instance.Name, names, ogxiov1beta1.AdoptedFromLabel) + logger := log.FromContext(ctx) + logger.Error(nil, msg) + SetAdoptionConfigInvalidCondition(&instance.Status, msg) + return "", &terminalError{message: msg} + } + + if len(pvcList.Items) == 1 { + return pvcList.Items[0].Name, nil + } + + return instance.Name + "-pvc", nil +} + +func isExpectedLegacyOwnerRef(ownerRef *metav1.OwnerReference, legacyName string) bool { + if ownerRef == nil { + return false + } + return ownerRef.APIVersion == legacyv1alpha1.GroupVersion.String() && + ownerRef.Kind == legacyv1alpha1.LlamaStackDistributionKind && + ownerRef.Name == legacyName +} diff --git a/controllers/legacy_adoption_test.go b/controllers/legacy_adoption_test.go new file mode 100644 index 000000000..dbe2d143e --- /dev/null +++ b/controllers/legacy_adoption_test.go @@ -0,0 +1,975 @@ +package controllers_test + +import ( + "testing" + + ogxiov1beta1 "github.com/ogx-ai/ogx-k8s-operator/api/v1beta1" + "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/log/zap" +) + +func TestAdoptStorage(t *testing.T) { + ctrl.SetLogger(zap.New(zap.UseDevMode(true))) + + tests := []struct { + name string + setup func(t *testing.T, ns string) + instance func(ns string) *ogxiov1beta1.OGXServer + assertFn func(t *testing.T, ns string, instance *ogxiov1beta1.OGXServer) + }{ + { + name: "happy path - adopt existing PVC", + setup: func(t *testing.T, ns string) { + t.Helper() + createLegacyPVC(t, ns, "my-old-llsd") + }, + instance: func(ns string) *ogxiov1beta1.OGXServer { + return NewOGXServerBuilder(). + WithName("my-server"). + WithNamespace(ns). + WithStorage(DefaultTestStorage()). + WithAnnotation(ogxiov1beta1.AdoptStorageAnnotation, "my-old-llsd"). + Build() + }, + assertFn: func(t *testing.T, ns string, instance *ogxiov1beta1.OGXServer) { + t.Helper() + pvc := &corev1.PersistentVolumeClaim{} + key := types.NamespacedName{Name: "my-old-llsd-pvc", Namespace: ns} + require.Eventually(t, func() bool { + return k8sClient.Get(t.Context(), key, pvc) == nil && + pvc.Labels[ogxiov1beta1.AdoptedFromLabel] == "my-old-llsd" + }, testTimeout, testInterval, "PVC should have adopted-from label") + + require.Nil(t, metav1.GetControllerOf(pvc), + "Adopted PVC should not have a controller ownerRef") + require.NotEmpty(t, pvc.Annotations[ogxiov1beta1.AdoptedAtAnnotation]) + + assertConditionTrue(t, instance, "StorageAdopted") + }, + }, + { + name: "PVC not found - skip adoption, no error", + setup: func(t *testing.T, ns string) { t.Helper() }, + instance: func(ns string) *ogxiov1beta1.OGXServer { + return NewOGXServerBuilder(). + WithName("my-server"). + WithNamespace(ns). + WithStorage(DefaultTestStorage()). + WithAnnotation(ogxiov1beta1.AdoptStorageAnnotation, "nonexistent"). + Build() + }, + assertFn: func(t *testing.T, ns string, instance *ogxiov1beta1.OGXServer) { + t.Helper() + pvc := &corev1.PersistentVolumeClaim{} + key := types.NamespacedName{Name: "nonexistent-pvc", Namespace: ns} + require.True(t, apierrors.IsNotFound(k8sClient.Get(t.Context(), key, pvc)), + "Legacy PVC should not exist") + }, + }, + { + name: "idempotency - second reconcile makes no changes", + setup: func(t *testing.T, ns string) { + t.Helper() + createLegacyPVC(t, ns, "idempotent-llsd") + }, + instance: func(ns string) *ogxiov1beta1.OGXServer { + return NewOGXServerBuilder(). + WithName("my-server"). + WithNamespace(ns). + WithStorage(DefaultTestStorage()). + WithAnnotation(ogxiov1beta1.AdoptStorageAnnotation, "idempotent-llsd"). + Build() + }, + assertFn: func(t *testing.T, ns string, instance *ogxiov1beta1.OGXServer) { + t.Helper() + + pvc := &corev1.PersistentVolumeClaim{} + key := types.NamespacedName{Name: "idempotent-llsd-pvc", Namespace: ns} + require.Eventually(t, func() bool { + return k8sClient.Get(t.Context(), key, pvc) == nil && + pvc.Labels[ogxiov1beta1.AdoptedFromLabel] == "idempotent-llsd" + }, testTimeout, testInterval, "PVC should have adopted-from label after first reconcile") + + firstVersion := pvc.ResourceVersion + + // Reconcile again. + ReconcileOGXServer(t, instance) + + require.NoError(t, k8sClient.Get(t.Context(), key, pvc)) + require.Equal(t, firstVersion, pvc.ResourceVersion, + "PVC should not change on second reconcile (idempotent)") + }, + }, + { + name: "old Deployment already gone - proceed to adoption", + setup: func(t *testing.T, ns string) { + t.Helper() + createLegacyPVC(t, ns, "gone-deploy") + }, + instance: func(ns string) *ogxiov1beta1.OGXServer { + return NewOGXServerBuilder(). + WithName("my-server"). + WithNamespace(ns). + WithStorage(DefaultTestStorage()). + WithAnnotation(ogxiov1beta1.AdoptStorageAnnotation, "gone-deploy"). + Build() + }, + assertFn: func(t *testing.T, ns string, instance *ogxiov1beta1.OGXServer) { + t.Helper() + pvc := &corev1.PersistentVolumeClaim{} + key := types.NamespacedName{Name: "gone-deploy-pvc", Namespace: ns} + require.Eventually(t, func() bool { + return k8sClient.Get(t.Context(), key, pvc) == nil && + pvc.Labels[ogxiov1beta1.AdoptedFromLabel] == "gone-deploy" + }, testTimeout, testInterval, "PVC should be adopted even though Deployment is gone") + + require.Nil(t, metav1.GetControllerOf(pvc), + "Adopted PVC should not have a controller ownerRef") + }, + }, + { + name: "old Deployment still running - scale to zero", + setup: func(t *testing.T, ns string) { + t.Helper() + createLegacyPVC(t, ns, "running-llsd") + createLegacyDeployment(t, ns, "running-llsd") + }, + instance: func(ns string) *ogxiov1beta1.OGXServer { + return NewOGXServerBuilder(). + WithName("my-server"). + WithNamespace(ns). + WithStorage(DefaultTestStorage()). + WithAnnotation(ogxiov1beta1.AdoptStorageAnnotation, "running-llsd"). + Build() + }, + assertFn: func(t *testing.T, ns string, instance *ogxiov1beta1.OGXServer) { + t.Helper() + // After first reconcile, old Deployment should be scaled to zero. + deployment := &appsv1.Deployment{} + key := types.NamespacedName{Name: "running-llsd", Namespace: ns} + require.Eventually(t, func() bool { + if err := k8sClient.Get(t.Context(), key, deployment); err != nil { + return false + } + return deployment.Spec.Replicas != nil && *deployment.Spec.Replicas == 0 + }, testTimeout, testInterval, "Legacy Deployment should be scaled to zero") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + namespace := createTestNamespace(t, "adopt-storage") + tt.setup(t, namespace.Name) + + instance := tt.instance(namespace.Name) + require.NoError(t, k8sClient.Create(t.Context(), instance)) + t.Cleanup(func() { + if err := k8sClient.Delete(t.Context(), instance); err != nil && !apierrors.IsNotFound(err) { + t.Logf("Cleanup: %v", err) + } + }) + + ReconcileOGXServer(t, instance) + + // Re-read instance for updated status. + require.NoError(t, k8sClient.Get(t.Context(), types.NamespacedName{ + Name: instance.Name, Namespace: instance.Namespace, + }, instance)) + + if tt.assertFn != nil { + tt.assertFn(t, namespace.Name, instance) + } + }) + } +} + +func TestAdoptNetworking(t *testing.T) { + ctrl.SetLogger(zap.New(zap.UseDevMode(true))) + + tests := []struct { + name string + setup func(t *testing.T, ns string) + instance func(ns string) *ogxiov1beta1.OGXServer + assertFn func(t *testing.T, ns string, instance *ogxiov1beta1.OGXServer) + }{ + { + name: "adopt Service - selectors updated", + setup: func(t *testing.T, ns string) { + t.Helper() + createLegacyService(t, ns, "old-llsd") + }, + instance: func(ns string) *ogxiov1beta1.OGXServer { + return NewOGXServerBuilder(). + WithName("new-server"). + WithNamespace(ns). + WithAnnotation(ogxiov1beta1.AdoptNetworkingAnnotation, "old-llsd"). + Build() + }, + assertFn: func(t *testing.T, ns string, instance *ogxiov1beta1.OGXServer) { + t.Helper() + svc := &corev1.Service{} + key := types.NamespacedName{Name: "old-llsd-service", Namespace: ns} + require.Eventually(t, func() bool { + return k8sClient.Get(t.Context(), key, svc) == nil && + metav1.IsControlledBy(svc, instance) + }, testTimeout, testInterval, "Service should be owned by new instance") + + require.Equal(t, ogxiov1beta1.DefaultLabelValue, svc.Spec.Selector[ogxiov1beta1.DefaultLabelKey], + "Service selector should be updated to new label") + require.Equal(t, instance.Name, svc.Spec.Selector["app.kubernetes.io/instance"], + "Service selector should target the new instance") + + require.Equal(t, "old-llsd", svc.Labels[ogxiov1beta1.AdoptedFromLabel]) + require.NotEmpty(t, svc.Annotations[ogxiov1beta1.AdoptedAtAnnotation]) + + assertConditionTrue(t, instance, "NetworkingAdopted") + }, + }, + { + name: "adopt Ingress - ownerRef transferred", + setup: func(t *testing.T, ns string) { + t.Helper() + createLegacyIngress(t, ns, "old-llsd") + }, + instance: func(ns string) *ogxiov1beta1.OGXServer { + return NewOGXServerBuilder(). + WithName("new-server"). + WithNamespace(ns). + WithAnnotation(ogxiov1beta1.AdoptNetworkingAnnotation, "old-llsd"). + Build() + }, + assertFn: func(t *testing.T, ns string, instance *ogxiov1beta1.OGXServer) { + t.Helper() + ingress := &networkingv1.Ingress{} + key := types.NamespacedName{Name: "old-llsd-ingress", Namespace: ns} + require.Eventually(t, func() bool { + return k8sClient.Get(t.Context(), key, ingress) == nil && + metav1.IsControlledBy(ingress, instance) + }, testTimeout, testInterval, "Ingress should be owned by new instance") + + require.Equal(t, "old-llsd", ingress.Labels[ogxiov1beta1.AdoptedFromLabel]) + require.NotEmpty(t, ingress.Annotations[ogxiov1beta1.AdoptedAtAnnotation]) + }, + }, + { + name: "name mismatch - adopted and new resources coexist", + setup: func(t *testing.T, ns string) { + t.Helper() + createLegacyService(t, ns, "legacy-name") + }, + instance: func(ns string) *ogxiov1beta1.OGXServer { + return NewOGXServerBuilder(). + WithName("new-name"). + WithNamespace(ns). + WithAnnotation(ogxiov1beta1.AdoptNetworkingAnnotation, "legacy-name"). + Build() + }, + assertFn: func(t *testing.T, ns string, instance *ogxiov1beta1.OGXServer) { + t.Helper() + // Adopted legacy Service should exist. + legacySvc := &corev1.Service{} + legacyKey := types.NamespacedName{Name: "legacy-name-service", Namespace: ns} + require.Eventually(t, func() bool { + return k8sClient.Get(t.Context(), legacyKey, legacySvc) == nil && + metav1.IsControlledBy(legacySvc, instance) + }, testTimeout, testInterval, "Legacy Service should be adopted") + + // New Service created by kustomize pipeline should also exist. + newSvc := &corev1.Service{} + newKey := types.NamespacedName{Name: "new-name-service", Namespace: ns} + require.Eventually(t, func() bool { + return k8sClient.Get(t.Context(), newKey, newSvc) == nil + }, testTimeout, testInterval, "New Service should also be created by kustomize pipeline") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + namespace := createTestNamespace(t, "adopt-net") + tt.setup(t, namespace.Name) + + instance := tt.instance(namespace.Name) + require.NoError(t, k8sClient.Create(t.Context(), instance)) + t.Cleanup(func() { + if err := k8sClient.Delete(t.Context(), instance); err != nil && !apierrors.IsNotFound(err) { + t.Logf("Cleanup: %v", err) + } + }) + + ReconcileOGXServer(t, instance) + + require.NoError(t, k8sClient.Get(t.Context(), types.NamespacedName{ + Name: instance.Name, Namespace: instance.Namespace, + }, instance)) + + if tt.assertFn != nil { + tt.assertFn(t, namespace.Name, instance) + } + }) + } +} + +func TestAdoptionAnnotationValidation(t *testing.T) { + ctrl.SetLogger(zap.New(zap.UseDevMode(true))) + + tests := []struct { + name string + annotationKey string + annotationVal string + conditionCheck func(t *testing.T, instance *ogxiov1beta1.OGXServer) + }{ + { + name: "empty value", + annotationKey: ogxiov1beta1.AdoptStorageAnnotation, + annotationVal: "", + conditionCheck: func(t *testing.T, instance *ogxiov1beta1.OGXServer) { + t.Helper() + // Empty annotation value means GetAdoptStorageSource() returns "", + // so adoption is not triggered at all. No condition should be set. + }, + }, + { + name: "uppercase characters", + annotationKey: ogxiov1beta1.AdoptStorageAnnotation, + annotationVal: "MyUpperCase", + conditionCheck: func(t *testing.T, instance *ogxiov1beta1.OGXServer) { + t.Helper() + assertConditionTrue(t, instance, "AdoptionConfigInvalid") + }, + }, + { + name: "special characters", + annotationKey: ogxiov1beta1.AdoptStorageAnnotation, + annotationVal: "name_with_underscores", + conditionCheck: func(t *testing.T, instance *ogxiov1beta1.OGXServer) { + t.Helper() + assertConditionTrue(t, instance, "AdoptionConfigInvalid") + }, + }, + { + name: "too long (>63 chars)", + annotationKey: ogxiov1beta1.AdoptStorageAnnotation, + annotationVal: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + conditionCheck: func(t *testing.T, instance *ogxiov1beta1.OGXServer) { + t.Helper() + assertConditionTrue(t, instance, "AdoptionConfigInvalid") + }, + }, + { + name: "invalid networking annotation", + annotationKey: ogxiov1beta1.AdoptNetworkingAnnotation, + annotationVal: "INVALID", + conditionCheck: func(t *testing.T, instance *ogxiov1beta1.OGXServer) { + t.Helper() + assertConditionTrue(t, instance, "AdoptionConfigInvalid") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + namespace := createTestNamespace(t, "adopt-validate") + + builder := NewOGXServerBuilder(). + WithName("validation-test"). + WithNamespace(namespace.Name) + + builder = builder.WithAnnotation(tt.annotationKey, tt.annotationVal) + + instance := builder.Build() + require.NoError(t, k8sClient.Create(t.Context(), instance)) + t.Cleanup(func() { + if err := k8sClient.Delete(t.Context(), instance); err != nil && !apierrors.IsNotFound(err) { + t.Logf("Cleanup: %v", err) + } + }) + + ReconcileOGXServer(t, instance) + + require.NoError(t, k8sClient.Get(t.Context(), types.NamespacedName{ + Name: instance.Name, Namespace: instance.Namespace, + }, instance)) + + tt.conditionCheck(t, instance) + }) + } +} + +func TestNoAdoptionWithoutAnnotations(t *testing.T) { + ctrl.SetLogger(zap.New(zap.UseDevMode(true))) + namespace := createTestNamespace(t, "no-adopt") + + instance := NewOGXServerBuilder(). + WithName("clean-install"). + WithNamespace(namespace.Name). + WithStorage(DefaultTestStorage()). + Build() + + require.NoError(t, k8sClient.Create(t.Context(), instance)) + t.Cleanup(func() { + if err := k8sClient.Delete(t.Context(), instance); err != nil && !apierrors.IsNotFound(err) { + t.Logf("Cleanup: %v", err) + } + }) + + ReconcileOGXServer(t, instance) + + require.NoError(t, k8sClient.Get(t.Context(), types.NamespacedName{ + Name: instance.Name, Namespace: instance.Namespace, + }, instance)) + + // No adoption conditions should be set. + assertConditionAbsent(t, instance, "StorageAdopted") + assertConditionAbsent(t, instance, "NetworkingAdopted") + assertConditionAbsent(t, instance, "AdoptionConfigInvalid") + + // Default PVC should be created with the instance name. + pvc := &corev1.PersistentVolumeClaim{} + key := types.NamespacedName{Name: "clean-install-pvc", Namespace: namespace.Name} + require.Eventually(t, func() bool { + return k8sClient.Get(t.Context(), key, pvc) == nil + }, testTimeout, testInterval, "Default PVC should be created for clean install") +} + +func TestInvalidAdoptionStorageFallsBackToDefaultPVC(t *testing.T) { + ctrl.SetLogger(zap.New(zap.UseDevMode(true))) + namespace := createTestNamespace(t, "invalid-adopt-fallback") + + instance := NewOGXServerBuilder(). + WithName("fallback-server"). + WithNamespace(namespace.Name). + WithStorage(DefaultTestStorage()). + WithAnnotation(ogxiov1beta1.AdoptStorageAnnotation, "INVALID_NAME"). + Build() + + require.NoError(t, k8sClient.Create(t.Context(), instance)) + t.Cleanup(func() { + if err := k8sClient.Delete(t.Context(), instance); err != nil && !apierrors.IsNotFound(err) { + t.Logf("Cleanup: %v", err) + } + }) + + ReconcileOGXServer(t, instance) + + require.NoError(t, k8sClient.Get(t.Context(), types.NamespacedName{ + Name: instance.Name, Namespace: instance.Namespace, + }, instance)) + + assertConditionTrue(t, instance, "AdoptionConfigInvalid") + + // Invalid annotation should not suppress default PVC behavior. + defaultPVC := &corev1.PersistentVolumeClaim{} + defaultKey := types.NamespacedName{Name: "fallback-server-pvc", Namespace: namespace.Name} + require.Eventually(t, func() bool { + return k8sClient.Get(t.Context(), defaultKey, defaultPVC) == nil + }, testTimeout, testInterval, "Default PVC should be created even when adoption annotation is invalid") +} + +func TestAdoptionConfigInvalidClearsWhenAnnotationRemoved(t *testing.T) { + ctrl.SetLogger(zap.New(zap.UseDevMode(true))) + namespace := createTestNamespace(t, "adopt-invalid-clear") + + instance := NewOGXServerBuilder(). + WithName("clear-invalid"). + WithNamespace(namespace.Name). + WithStorage(DefaultTestStorage()). + WithAnnotation(ogxiov1beta1.AdoptStorageAnnotation, "INVALID_NAME"). + Build() + + require.NoError(t, k8sClient.Create(t.Context(), instance)) + t.Cleanup(func() { + if err := k8sClient.Delete(t.Context(), instance); err != nil && !apierrors.IsNotFound(err) { + t.Logf("Cleanup: %v", err) + } + }) + + ReconcileOGXServer(t, instance) + require.NoError(t, k8sClient.Get(t.Context(), types.NamespacedName{ + Name: instance.Name, Namespace: instance.Namespace, + }, instance)) + assertConditionTrue(t, instance, "AdoptionConfigInvalid") + + // Remove annotation and reconcile again; invalid condition should clear. + delete(instance.Annotations, ogxiov1beta1.AdoptStorageAnnotation) + require.NoError(t, k8sClient.Update(t.Context(), instance)) + + ReconcileOGXServer(t, instance) + require.NoError(t, k8sClient.Get(t.Context(), types.NamespacedName{ + Name: instance.Name, Namespace: instance.Namespace, + }, instance)) + assertConditionFalse(t, instance, "AdoptionConfigInvalid") +} + +func TestCleanupAdoptedNetworkingOnAnnotationRemoval(t *testing.T) { + ctrl.SetLogger(zap.New(zap.UseDevMode(true))) + namespace := createTestNamespace(t, "adopt-cleanup-net") + createLegacyService(t, namespace.Name, "legacy-net") + + instance := NewOGXServerBuilder(). + WithName("new-net"). + WithNamespace(namespace.Name). + WithAnnotation(ogxiov1beta1.AdoptNetworkingAnnotation, "legacy-net"). + Build() + + require.NoError(t, k8sClient.Create(t.Context(), instance)) + t.Cleanup(func() { + if err := k8sClient.Delete(t.Context(), instance); err != nil && !apierrors.IsNotFound(err) { + t.Logf("Cleanup: %v", err) + } + }) + + // First reconcile adopts the legacy service. + ReconcileOGXServer(t, instance) + legacyKey := types.NamespacedName{Name: "legacy-net-service", Namespace: namespace.Name} + legacySvc := &corev1.Service{} + require.Eventually(t, func() bool { + return k8sClient.Get(t.Context(), legacyKey, legacySvc) == nil && + metav1.IsControlledBy(legacySvc, instance) + }, testTimeout, testInterval, "Legacy Service should be adopted") + + // Remove annotation and reconcile again; adopted legacy service should be deleted. + require.NoError(t, k8sClient.Get(t.Context(), types.NamespacedName{ + Name: instance.Name, Namespace: instance.Namespace, + }, instance)) + delete(instance.Annotations, ogxiov1beta1.AdoptNetworkingAnnotation) + require.NoError(t, k8sClient.Update(t.Context(), instance)) + + ReconcileOGXServer(t, instance) + require.Eventually(t, func() bool { + return apierrors.IsNotFound(k8sClient.Get(t.Context(), legacyKey, &corev1.Service{})) + }, testTimeout, testInterval, "Adopted legacy Service should be deleted when annotation is removed") + + // New service for current instance should exist. + newKey := types.NamespacedName{Name: "new-net-service", Namespace: namespace.Name} + require.Eventually(t, func() bool { + return k8sClient.Get(t.Context(), newKey, &corev1.Service{}) == nil + }, testTimeout, testInterval, "Current instance Service should exist after cleanup") +} + +func TestSelfAdoptionRejectedByController(t *testing.T) { + ctrl.SetLogger(zap.New(zap.UseDevMode(true))) + namespace := createTestNamespace(t, "self-adopt") + + instance := NewOGXServerBuilder(). + WithName("same-name"). + WithNamespace(namespace.Name). + WithStorage(DefaultTestStorage()). + WithAnnotation(ogxiov1beta1.AdoptStorageAnnotation, "same-name"). + Build() + + require.NoError(t, k8sClient.Create(t.Context(), instance)) + t.Cleanup(func() { + if err := k8sClient.Delete(t.Context(), instance); err != nil && !apierrors.IsNotFound(err) { + t.Logf("Cleanup: %v", err) + } + }) + + ReconcileOGXServer(t, instance) + + require.NoError(t, k8sClient.Get(t.Context(), types.NamespacedName{ + Name: instance.Name, Namespace: instance.Namespace, + }, instance)) + + assertConditionTrue(t, instance, "AdoptionConfigInvalid") +} + +func TestAdoptedPVCLifecycle(t *testing.T) { + ctrl.SetLogger(zap.New(zap.UseDevMode(true))) + namespace := createTestNamespace(t, "adopt-pvc-lifecycle") + createLegacyPVC(t, namespace.Name, "disc-legacy") + + // --- arrange --- + pvcKey := types.NamespacedName{Name: "disc-legacy-pvc", Namespace: namespace.Name} + defaultKey := types.NamespacedName{Name: "disc-server-pvc", Namespace: namespace.Name} + deployKey := types.NamespacedName{Name: "disc-server", Namespace: namespace.Name} + + instance := NewOGXServerBuilder(). + WithName("disc-server"). + WithNamespace(namespace.Name). + WithStorage(DefaultTestStorage()). + WithAnnotation(ogxiov1beta1.AdoptStorageAnnotation, "disc-legacy"). + Build() + + require.NoError(t, k8sClient.Create(t.Context(), instance)) + + // --- act: adopt PVC, then remove annotation --- + ReconcileOGXServer(t, instance) + + pvc := &corev1.PersistentVolumeClaim{} + require.Eventually(t, func() bool { + return k8sClient.Get(t.Context(), pvcKey, pvc) == nil && + pvc.Labels[ogxiov1beta1.AdoptedFromLabel] == "disc-legacy" + }, testTimeout, testInterval, "PVC should be adopted with label") + + deployment := &appsv1.Deployment{} + waitForResource(t, k8sClient, namespace.Name, "disc-server", deployment) + + require.NoError(t, k8sClient.Get(t.Context(), types.NamespacedName{ + Name: instance.Name, Namespace: instance.Namespace, + }, instance)) + delete(instance.Annotations, ogxiov1beta1.AdoptStorageAnnotation) + require.NoError(t, k8sClient.Update(t.Context(), instance)) + + ReconcileOGXServer(t, instance) + + // --- assert: adopted PVC discovered by label, Deployment uses it --- + require.NoError(t, k8sClient.Get(t.Context(), pvcKey, pvc), + "Adopted PVC must survive annotation removal") + require.True(t, apierrors.IsNotFound(k8sClient.Get(t.Context(), defaultKey, &corev1.PersistentVolumeClaim{})), + "Default PVC should NOT be created when adopted PVC is discovered by label") + + require.NoError(t, k8sClient.Get(t.Context(), deployKey, deployment)) + AssertDeploymentUsesPVCStorage(t, deployment, "disc-legacy-pvc") + + // --- act: delete OGXServer --- + require.NoError(t, k8sClient.Delete(t.Context(), instance)) + require.Eventually(t, func() bool { + return apierrors.IsNotFound(k8sClient.Get(t.Context(), types.NamespacedName{ + Name: "disc-server", Namespace: namespace.Name, + }, &ogxiov1beta1.OGXServer{})) + }, testTimeout, testInterval, "OGXServer should be deleted") + + // --- assert: adopted PVC survives CR deletion with labels intact --- + require.NoError(t, k8sClient.Get(t.Context(), pvcKey, pvc), + "Adopted PVC must survive OGXServer deletion") + require.Equal(t, "disc-legacy", pvc.Labels[ogxiov1beta1.AdoptedFromLabel], + "Adopted-from label must be unchanged after CR deletion") + require.Equal(t, "disc-server", pvc.Labels["app.kubernetes.io/instance"], + "Instance label must be unchanged after CR deletion") + + // --- act: recreate OGXServer with same name --- + instance = NewOGXServerBuilder(). + WithName("disc-server"). + WithNamespace(namespace.Name). + WithStorage(DefaultTestStorage()). + Build() + + require.NoError(t, k8sClient.Create(t.Context(), instance)) + t.Cleanup(func() { + if err := k8sClient.Delete(t.Context(), instance); err != nil && !apierrors.IsNotFound(err) { + t.Logf("Cleanup: %v", err) + } + }) + + ReconcileOGXServer(t, instance) + + // --- assert: adopted PVC discovered, no default PVC created --- + require.NoError(t, k8sClient.Get(t.Context(), pvcKey, pvc), + "Adopted PVC must still exist after recreate") + require.True(t, apierrors.IsNotFound(k8sClient.Get(t.Context(), defaultKey, &corev1.PersistentVolumeClaim{})), + "Default PVC should NOT be created when adopted PVC is discovered by label") + + require.NoError(t, k8sClient.Get(t.Context(), deployKey, deployment)) + AssertDeploymentUsesPVCStorage(t, deployment, "disc-legacy-pvc") +} + +func TestAdoptionRejectsUnexpectedControllerOwner(t *testing.T) { + ctrl.SetLogger(zap.New(zap.UseDevMode(true))) + namespace := createTestNamespace(t, "adopt-owner-guard") + createLegacyPVCWithControllerOwner(t, namespace.Name, "legacy-safe", metav1.OwnerReference{ + APIVersion: "apps/v1", + Kind: "Deployment", + Name: "not-legacy-safe", + UID: types.UID("foreign-owner"), + Controller: boolPtrAdoptionTest(true), + }) + + instance := NewOGXServerBuilder(). + WithName("owner-check"). + WithNamespace(namespace.Name). + WithStorage(DefaultTestStorage()). + WithAnnotation(ogxiov1beta1.AdoptStorageAnnotation, "legacy-safe"). + Build() + + require.NoError(t, k8sClient.Create(t.Context(), instance)) + t.Cleanup(func() { + if err := k8sClient.Delete(t.Context(), instance); err != nil && !apierrors.IsNotFound(err) { + t.Logf("Cleanup: %v", err) + } + }) + + reconciler := createTestReconciler() + _, err := reconciler.Reconcile(t.Context(), ctrl.Request{ + NamespacedName: types.NamespacedName{ + Name: instance.Name, + Namespace: instance.Namespace, + }, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "failed to adopt") +} + +func TestMultipleAdoptedPVCsReturnsTerminalError(t *testing.T) { + ctrl.SetLogger(zap.New(zap.UseDevMode(true))) + namespace := createTestNamespace(t, "adopt-multi-pvc") + + // --- arrange: create two PVCs with matching adoption labels --- + for _, pvcName := range []string{"first-pvc", "second-pvc"} { + storageSize := resource.MustParse("1Gi") + pvc := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: pvcName, + Namespace: namespace.Name, + Labels: map[string]string{ + ogxiov1beta1.AdoptedFromLabel: "some-legacy", + "app.kubernetes.io/instance": "multi-test", + }, + }, + Spec: corev1.PersistentVolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: storageSize, + }, + }, + }, + } + require.NoError(t, k8sClient.Create(t.Context(), pvc)) + t.Cleanup(func() { + if err := k8sClient.Delete(t.Context(), pvc); err != nil && !apierrors.IsNotFound(err) { + t.Logf("Cleanup PVC: %v", err) + } + }) + } + + instance := NewOGXServerBuilder(). + WithName("multi-test"). + WithNamespace(namespace.Name). + WithStorage(DefaultTestStorage()). + Build() + + require.NoError(t, k8sClient.Create(t.Context(), instance)) + t.Cleanup(func() { + if err := k8sClient.Delete(t.Context(), instance); err != nil && !apierrors.IsNotFound(err) { + t.Logf("Cleanup: %v", err) + } + }) + + // --- act --- + reconciler := createTestReconciler() + result, err := reconciler.Reconcile(t.Context(), ctrl.Request{ + NamespacedName: types.NamespacedName{ + Name: instance.Name, + Namespace: instance.Namespace, + }, + }) + + // --- assert: no error returned (terminal), no requeue --- + require.NoError(t, err, "terminal error should not propagate as a reconcile error") + require.Zero(t, result.RequeueAfter, "should not requeue on terminal error") + + require.NoError(t, k8sClient.Get(t.Context(), types.NamespacedName{ + Name: instance.Name, Namespace: instance.Namespace, + }, instance)) + assertConditionTrue(t, instance, "AdoptionConfigInvalid") +} + +// --- Helpers --- + +func (b *OGXServerBuilder) WithAnnotation(key, value string) *OGXServerBuilder { + if b.instance.Annotations == nil { + b.instance.Annotations = make(map[string]string) + } + b.instance.Annotations[key] = value + return b +} + +func createLegacyPVC(t *testing.T, ns, legacyName string) { + t.Helper() + storageSize := resource.MustParse("1Gi") + pvc := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: legacyName + "-pvc", + Namespace: ns, + }, + Spec: corev1.PersistentVolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: storageSize, + }, + }, + }, + } + require.NoError(t, k8sClient.Create(t.Context(), pvc)) + t.Cleanup(func() { + if err := k8sClient.Delete(t.Context(), pvc); err != nil && !apierrors.IsNotFound(err) { + t.Logf("Cleanup PVC: %v", err) + } + }) +} + +func createLegacyPVCWithControllerOwner(t *testing.T, ns, legacyName string, ownerRef metav1.OwnerReference) { + t.Helper() + storageSize := resource.MustParse("1Gi") + pvc := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: legacyName + "-pvc", + Namespace: ns, + OwnerReferences: []metav1.OwnerReference{ownerRef}, + }, + Spec: corev1.PersistentVolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: storageSize, + }, + }, + }, + } + require.NoError(t, k8sClient.Create(t.Context(), pvc)) + t.Cleanup(func() { + if err := k8sClient.Delete(t.Context(), pvc); err != nil && !apierrors.IsNotFound(err) { + t.Logf("Cleanup PVC: %v", err) + } + }) +} + +func createLegacyDeployment(t *testing.T, ns, legacyName string) { + t.Helper() + replicas := int32(1) + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: legacyName, + Namespace: ns, + Labels: map[string]string{"app.kubernetes.io/instance": legacyName}, + }, + Spec: appsv1.DeploymentSpec{ + Replicas: &replicas, + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app.kubernetes.io/instance": legacyName}, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"app.kubernetes.io/instance": legacyName}, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{ + Name: "llama-stack", + Image: "registry.example.com/llama-stack:latest", + }}, + }, + }, + }, + } + require.NoError(t, k8sClient.Create(t.Context(), deployment)) + t.Cleanup(func() { + if err := k8sClient.Delete(t.Context(), deployment); err != nil && !apierrors.IsNotFound(err) { + t.Logf("Cleanup Deployment: %v", err) + } + }) +} + +func createLegacyService(t *testing.T, ns, legacyName string) { + t.Helper() + svc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: legacyName + "-service", + Namespace: ns, + Labels: map[string]string{"app": "llama-stack"}, + }, + Spec: corev1.ServiceSpec{ + Selector: map[string]string{ + "app": "llama-stack", + "app.kubernetes.io/instance": legacyName, + }, + Ports: []corev1.ServicePort{{ + Name: "http", + Port: 8321, + Protocol: corev1.ProtocolTCP, + }}, + }, + } + require.NoError(t, k8sClient.Create(t.Context(), svc)) + t.Cleanup(func() { + if err := k8sClient.Delete(t.Context(), svc); err != nil && !apierrors.IsNotFound(err) { + t.Logf("Cleanup Service: %v", err) + } + }) +} + +func createLegacyIngress(t *testing.T, ns, legacyName string) { + t.Helper() + pathType := networkingv1.PathTypePrefix + ingress := &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: legacyName + "-ingress", + Namespace: ns, + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{{ + Host: "legacy.example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{{ + Path: "/", + PathType: &pathType, + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: legacyName + "-service", + Port: networkingv1.ServiceBackendPort{ + Number: 8321, + }, + }, + }, + }}, + }, + }, + }}, + }, + } + require.NoError(t, k8sClient.Create(t.Context(), ingress)) + t.Cleanup(func() { + if err := k8sClient.Delete(t.Context(), ingress); err != nil && !apierrors.IsNotFound(err) { + t.Logf("Cleanup Ingress: %v", err) + } + }) +} + +func assertConditionTrue(t *testing.T, instance *ogxiov1beta1.OGXServer, condType string) { + t.Helper() + for _, c := range instance.Status.Conditions { + if c.Type == condType { + require.Equal(t, metav1.ConditionTrue, c.Status, + "condition %s should be True", condType) + return + } + } + t.Errorf("condition %s not found", condType) +} + +func assertConditionAbsent(t *testing.T, instance *ogxiov1beta1.OGXServer, condType string) { + t.Helper() + for _, c := range instance.Status.Conditions { + if c.Type == condType { + t.Errorf("condition %s should not be present", condType) + return + } + } +} + +func assertConditionFalse(t *testing.T, instance *ogxiov1beta1.OGXServer, condType string) { + t.Helper() + for _, c := range instance.Status.Conditions { + if c.Type == condType { + require.Equal(t, metav1.ConditionFalse, c.Status, + "condition %s should be False", condType) + return + } + } + t.Errorf("condition %s not found", condType) +} + +func boolPtrAdoptionTest(v bool) *bool { + return &v +} diff --git a/controllers/manifests/base/deployment.yaml b/controllers/manifests/base/deployment.yaml index 975a1792c..87af2be50 100644 --- a/controllers/manifests/base/deployment.yaml +++ b/controllers/manifests/base/deployment.yaml @@ -6,12 +6,12 @@ spec: replicas: 1 selector: matchLabels: - app: llama-stack + app: ogx app.kubernetes.io/instance: "" template: metadata: labels: - app: llama-stack + app: ogx app.kubernetes.io/instance: "" annotations: {} spec: diff --git a/controllers/manifests/base/hpa.yaml b/controllers/manifests/base/hpa.yaml index ee2f0bba9..deeaad185 100644 --- a/controllers/manifests/base/hpa.yaml +++ b/controllers/manifests/base/hpa.yaml @@ -3,7 +3,7 @@ kind: HorizontalPodAutoscaler metadata: name: hpa labels: - app: llama-stack + app: ogx app.kubernetes.io/instance: "" spec: minReplicas: 1 diff --git a/controllers/manifests/base/kustomization.yaml b/controllers/manifests/base/kustomization.yaml index 73b346b61..9b5c771a6 100644 --- a/controllers/manifests/base/kustomization.yaml +++ b/controllers/manifests/base/kustomization.yaml @@ -14,6 +14,6 @@ resources: labels: - includeSelectors: false pairs: - app.kubernetes.io/managed-by: llama-stack-operator - app.kubernetes.io/part-of: llama-stack - llamastack.io/watch: "true" + app.kubernetes.io/managed-by: ogx-operator + app.kubernetes.io/part-of: ogx + ogx.io/watch: "true" diff --git a/controllers/manifests/base/networkpolicy.yaml b/controllers/manifests/base/networkpolicy.yaml index 569e6fde5..e5dc1436c 100644 --- a/controllers/manifests/base/networkpolicy.yaml +++ b/controllers/manifests/base/networkpolicy.yaml @@ -5,10 +5,10 @@ metadata: spec: podSelector: matchLabels: - app: llama-stack + app: ogx app.kubernetes.io/instance: "" policyTypes: - Ingress ingress: [] # Ingress rules are populated dynamically by the NetworkPolicy transformer plugin - # based on spec.network.allowedFrom configuration + # based on spec.network.networkPolicy configuration diff --git a/controllers/manifests/base/pdb.yaml b/controllers/manifests/base/pdb.yaml index b7204a520..c6dc6535d 100644 --- a/controllers/manifests/base/pdb.yaml +++ b/controllers/manifests/base/pdb.yaml @@ -3,11 +3,11 @@ kind: PodDisruptionBudget metadata: name: pdb labels: - app: llama-stack + app: ogx app.kubernetes.io/instance: "" spec: maxUnavailable: 1 selector: matchLabels: - app: llama-stack + app: ogx app.kubernetes.io/instance: "" diff --git a/controllers/manifests/base/service.yaml b/controllers/manifests/base/service.yaml index c6ebfdcce..c65cac22a 100644 --- a/controllers/manifests/base/service.yaml +++ b/controllers/manifests/base/service.yaml @@ -5,7 +5,7 @@ metadata: spec: type: ClusterIP selector: - app: llama-stack + app: ogx app.kubernetes.io/instance: "" # Will be set by field transformation ports: - name: http diff --git a/controllers/manifests/base/serviceaccount.yaml b/controllers/manifests/base/serviceaccount.yaml index 4c3d33ac0..9bd79dd1d 100644 --- a/controllers/manifests/base/serviceaccount.yaml +++ b/controllers/manifests/base/serviceaccount.yaml @@ -1,4 +1,4 @@ -# This service account is used to grant privileges to the llama-stack init container +# This service account is used to grant privileges to the ogx init container # to update PVC permissions. apiVersion: v1 kind: ServiceAccount diff --git a/controllers/network_resources.go b/controllers/network_resources.go index 9c36d86cc..7976d94f1 100644 --- a/controllers/network_resources.go +++ b/controllers/network_resources.go @@ -22,8 +22,8 @@ import ( "net/url" "github.com/go-logr/logr" - llamav1alpha1 "github.com/llamastack/llama-stack-k8s-operator/api/v1alpha1" - "github.com/llamastack/llama-stack-k8s-operator/pkg/deploy" + ogxiov1beta1 "github.com/ogx-ai/ogx-k8s-operator/api/v1beta1" + "github.com/ogx-ai/ogx-k8s-operator/pkg/deploy" networkingv1 "k8s.io/api/networking/v1" k8serrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -37,9 +37,9 @@ const ( IngressNameSuffix = "-ingress" ) -// buildIngress creates an Ingress for external access to the LlamaStackDistribution. -func (r *LlamaStackDistributionReconciler) buildIngress( - instance *llamav1alpha1.LlamaStackDistribution, +// buildIngress creates an Ingress for external access to the OGXServer. +func (r *OGXServerReconciler) buildIngress( + instance *ogxiov1beta1.OGXServer, ) (*networkingv1.Ingress, error) { servicePort := deploy.GetServicePort(instance) serviceName := deploy.GetServiceName(instance) @@ -50,7 +50,7 @@ func (r *LlamaStackDistributionReconciler) buildIngress( Name: instance.Name + IngressNameSuffix, Namespace: instance.Namespace, Labels: map[string]string{ - "app.kubernetes.io/managed-by": "llama-stack-operator", + "app.kubernetes.io/managed-by": "ogx-operator", "app.kubernetes.io/instance": instance.Name, }, }, @@ -87,10 +87,10 @@ func (r *LlamaStackDistributionReconciler) buildIngress( return ingress, nil } -// reconcileIngress creates, updates, or deletes the Ingress based on exposeRoute setting. -func (r *LlamaStackDistributionReconciler) reconcileIngress( +// reconcileIngress creates, updates, or deletes the Ingress based on expose setting. +func (r *OGXServerReconciler) reconcileIngress( ctx context.Context, - instance *llamav1alpha1.LlamaStackDistribution, + instance *ogxiov1beta1.OGXServer, ) error { logger := log.FromContext(ctx) ingressName := instance.Name + IngressNameSuffix @@ -99,19 +99,19 @@ func (r *LlamaStackDistributionReconciler) reconcileIngress( err := r.Get(ctx, types.NamespacedName{Name: ingressName, Namespace: instance.Namespace}, existing) existsAlready := err == nil - exposeRoute := instance.Spec.Network != nil && instance.Spec.Network.ExposeRoute + expose := instance.Spec.Network != nil && instance.Spec.Network.ExternalAccess != nil && instance.Spec.Network.ExternalAccess.Enabled - if !exposeRoute { + if !expose { return r.handleDisabledIngress(ctx, instance, existing, existsAlready, ingressName) } return r.handleEnabledIngress(ctx, instance, existing, err, existsAlready, ingressName, logger) } -// handleDisabledIngress handles Ingress deletion when exposeRoute is false. -func (r *LlamaStackDistributionReconciler) handleDisabledIngress( +// handleDisabledIngress handles Ingress deletion when expose is not set. +func (r *OGXServerReconciler) handleDisabledIngress( ctx context.Context, - instance *llamav1alpha1.LlamaStackDistribution, + instance *ogxiov1beta1.OGXServer, existing *networkingv1.Ingress, existsAlready bool, ingressName string, @@ -127,7 +127,7 @@ func (r *LlamaStackDistributionReconciler) handleDisabledIngress( return nil } - logger.Info("Deleting Ingress as exposeRoute is disabled", "name", ingressName) + logger.Info("Deleting Ingress as expose is disabled", "name", ingressName) if err := r.Delete(ctx, existing); err != nil && !k8serrors.IsNotFound(err) { return fmt.Errorf("failed to delete Ingress: %w", err) } @@ -135,10 +135,10 @@ func (r *LlamaStackDistributionReconciler) handleDisabledIngress( return nil } -// handleEnabledIngress handles Ingress creation/update when exposeRoute is true. -func (r *LlamaStackDistributionReconciler) handleEnabledIngress( +// handleEnabledIngress handles Ingress creation/update when expose is set. +func (r *OGXServerReconciler) handleEnabledIngress( ctx context.Context, - instance *llamav1alpha1.LlamaStackDistribution, + instance *ogxiov1beta1.OGXServer, existing *networkingv1.Ingress, getErr error, existsAlready bool, @@ -176,11 +176,11 @@ func (r *LlamaStackDistributionReconciler) handleEnabledIngress( } // getIngressURL returns the external URL from an Ingress if available. -func (r *LlamaStackDistributionReconciler) getIngressURL( +func (r *OGXServerReconciler) getIngressURL( ctx context.Context, - instance *llamav1alpha1.LlamaStackDistribution, + instance *ogxiov1beta1.OGXServer, ) *string { - if instance.Spec.Network == nil || !instance.Spec.Network.ExposeRoute { + if instance.Spec.Network == nil || instance.Spec.Network.ExternalAccess == nil || !instance.Spec.Network.ExternalAccess.Enabled { return nil } @@ -225,8 +225,8 @@ func buildURLString(host string) *string { } // BuildIngressForTest is a test helper that exposes buildIngress for unit testing. -func (r *LlamaStackDistributionReconciler) BuildIngressForTest( - instance *llamav1alpha1.LlamaStackDistribution, +func (r *OGXServerReconciler) BuildIngressForTest( + instance *ogxiov1beta1.OGXServer, ) (*networkingv1.Ingress, error) { return r.buildIngress(instance) } diff --git a/controllers/network_resources_test.go b/controllers/network_resources_test.go index d6730bb26..8aa7fe431 100644 --- a/controllers/network_resources_test.go +++ b/controllers/network_resources_test.go @@ -19,9 +19,9 @@ package controllers_test import ( "testing" - llamav1alpha1 "github.com/llamastack/llama-stack-k8s-operator/api/v1alpha1" - "github.com/llamastack/llama-stack-k8s-operator/controllers" - "github.com/llamastack/llama-stack-k8s-operator/pkg/cluster" + ogxiov1beta1 "github.com/ogx-ai/ogx-k8s-operator/api/v1beta1" + "github.com/ogx-ai/ogx-k8s-operator/controllers" + "github.com/ogx-ai/ogx-k8s-operator/pkg/cluster" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -30,29 +30,24 @@ import ( func TestBuildIngress(t *testing.T) { scheme := runtime.NewScheme() - require.NoError(t, llamav1alpha1.AddToScheme(scheme)) + require.NoError(t, ogxiov1beta1.AddToScheme(scheme)) clusterInfo := &cluster.ClusterInfo{ DistributionImages: map[string]string{"starter": "test-image:latest"}, } - reconciler := controllers.NewTestReconciler(nil, scheme, clusterInfo, nil, true) + reconciler := controllers.NewTestReconciler(nil, scheme, clusterInfo, nil) - instance := &llamav1alpha1.LlamaStackDistribution{ + instance := &ogxiov1beta1.OGXServer{ ObjectMeta: metav1.ObjectMeta{ - Name: "test-llsd", + Name: "test-ogx", Namespace: "test-ns", UID: "test-uid", }, - Spec: llamav1alpha1.LlamaStackDistributionSpec{ - Replicas: 1, - Server: llamav1alpha1.ServerSpec{ - Distribution: llamav1alpha1.DistributionType{ - Name: "starter", - }, - }, - Network: &llamav1alpha1.NetworkSpec{ - ExposeRoute: true, + Spec: ogxiov1beta1.OGXServerSpec{ + Distribution: ogxiov1beta1.DistributionSpec{Name: "starter"}, + Network: &ogxiov1beta1.NetworkSpec{ + ExternalAccess: &ogxiov1beta1.ExternalAccessConfig{Enabled: true}, }, }, } @@ -61,48 +56,38 @@ func TestBuildIngress(t *testing.T) { require.NoError(t, err) require.NotNil(t, ingress) - // Verify Ingress metadata - assert.Equal(t, "test-llsd-ingress", ingress.Name) + assert.Equal(t, "test-ogx-ingress", ingress.Name) assert.Equal(t, "test-ns", ingress.Namespace) - // Verify Ingress spec require.Len(t, ingress.Spec.Rules, 1) require.NotNil(t, ingress.Spec.Rules[0].HTTP) require.Len(t, ingress.Spec.Rules[0].HTTP.Paths, 1) - // Verify backend points to the service - assert.Equal(t, "test-llsd-service", ingress.Spec.Rules[0].HTTP.Paths[0].Backend.Service.Name) + assert.Equal(t, "test-ogx-service", ingress.Spec.Rules[0].HTTP.Paths[0].Backend.Service.Name) assert.Equal(t, int32(8321), ingress.Spec.Rules[0].HTTP.Paths[0].Backend.Service.Port.Number) } func TestBuildIngress_CustomPort(t *testing.T) { scheme := runtime.NewScheme() - require.NoError(t, llamav1alpha1.AddToScheme(scheme)) + require.NoError(t, ogxiov1beta1.AddToScheme(scheme)) clusterInfo := &cluster.ClusterInfo{ DistributionImages: map[string]string{"starter": "test-image:latest"}, } - reconciler := controllers.NewTestReconciler(nil, scheme, clusterInfo, nil, true) + reconciler := controllers.NewTestReconciler(nil, scheme, clusterInfo, nil) - instance := &llamav1alpha1.LlamaStackDistribution{ + instance := &ogxiov1beta1.OGXServer{ ObjectMeta: metav1.ObjectMeta{ - Name: "test-llsd", + Name: "test-ogx", Namespace: "test-ns", UID: "test-uid", }, - Spec: llamav1alpha1.LlamaStackDistributionSpec{ - Replicas: 1, - Server: llamav1alpha1.ServerSpec{ - Distribution: llamav1alpha1.DistributionType{ - Name: "starter", - }, - ContainerSpec: llamav1alpha1.ContainerSpec{ - Port: 9000, - }, - }, - Network: &llamav1alpha1.NetworkSpec{ - ExposeRoute: true, + Spec: ogxiov1beta1.OGXServerSpec{ + Distribution: ogxiov1beta1.DistributionSpec{Name: "starter"}, + Network: &ogxiov1beta1.NetworkSpec{ + Port: 9000, + ExternalAccess: &ogxiov1beta1.ExternalAccessConfig{Enabled: true}, }, }, } @@ -111,6 +96,5 @@ func TestBuildIngress_CustomPort(t *testing.T) { require.NoError(t, err) require.NotNil(t, ingress) - // Verify custom port is used assert.Equal(t, int32(9000), ingress.Spec.Rules[0].HTTP.Paths[0].Backend.Service.Port.Number) } diff --git a/controllers/llamastackdistribution_controller.go b/controllers/ogxserver_controller.go similarity index 56% rename from controllers/llamastackdistribution_controller.go rename to controllers/ogxserver_controller.go index f8cbf92ec..9a3ca67f8 100644 --- a/controllers/llamastackdistribution_controller.go +++ b/controllers/ogxserver_controller.go @@ -19,7 +19,9 @@ package controllers import ( "bytes" "context" + "crypto/sha256" "crypto/x509" + "encoding/hex" "encoding/json" "encoding/pem" "errors" @@ -29,16 +31,17 @@ import ( "net/url" "os" "slices" + "sort" "strings" "time" "github.com/go-logr/logr" "github.com/google/go-cmp/cmp" "github.com/google/go-containerregistry/pkg/name" - llamav1alpha1 "github.com/llamastack/llama-stack-k8s-operator/api/v1alpha1" - "github.com/llamastack/llama-stack-k8s-operator/pkg/cluster" - "github.com/llamastack/llama-stack-k8s-operator/pkg/deploy" - "github.com/llamastack/llama-stack-k8s-operator/pkg/featureflags" + ogxiov1beta1 "github.com/ogx-ai/ogx-k8s-operator/api/v1beta1" + "github.com/ogx-ai/ogx-k8s-operator/pkg/cluster" + "github.com/ogx-ai/ogx-k8s-operator/pkg/config" + "github.com/ogx-ai/ogx-k8s-operator/pkg/deploy" "gopkg.in/yaml.v3" appsv1 "k8s.io/api/apps/v1" autoscalingv2 "k8s.io/api/autoscaling/v2" @@ -49,6 +52,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/util/retry" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" @@ -61,8 +65,10 @@ import ( ) const ( - operatorConfigData = "llama-stack-operator-config" + operatorConfigData = "ogx-operator-config" manifestsBasePath = "manifests/base" + managedByLabelKey = "app.kubernetes.io/managed-by" + managedByLabelVal = "ogx-operator" // CA Bundle related constants. DefaultCABundleKey = "ca-bundle.crt" @@ -82,75 +88,45 @@ const ( // WatchLabelKey is the label key used to include ConfigMaps in the operator's cache. // Operator-managed ConfigMaps get this label automatically. Users can add it to // their ConfigMaps for instant reconciliation on change. - WatchLabelKey = "llamastack.io/watch" + WatchLabelKey = "ogx.io/watch" // WatchLabelValue is the expected value for the watch label. WatchLabelValue = "true" ) -// LlamaStackDistributionReconciler reconciles a LlamaStack object. -// -// ConfigMap handling: -// Operator-managed ConfigMaps (CA bundles) have the managed-by label and are watched -// via Owns(). User-referenced ConfigMaps and the operator config ConfigMap are read -// via a direct (non-cached) API client during reconciliation, with periodic requeue -// (5 minutes) for eventual consistency. -type LlamaStackDistributionReconciler struct { +// OGXServerReconciler reconciles an OGXServer object. +type OGXServerReconciler struct { client.Client Scheme *runtime.Scheme - // DirectClient is a non-cached API client for reading ConfigMaps that - // lack operator labels (user-referenced and operator config ConfigMaps). - DirectClient client.Reader - // Feature flags - EnableNetworkPolicy bool // Image mapping overrides ImageMappingOverrides map[string]string // Cluster info ClusterInfo *cluster.ClusterInfo httpClient *http.Client + // OCILabelFetcher fetches OCI image labels for config resolution. + // When nil, OCI label resolution is disabled. + OCILabelFetcher config.OCILabelFetcher + // configResolver resolves base config from OCI labels. Kept on the reconciler + // so the OCI config cache persists across reconciliations. + configResolver config.ConfigResolver // Cached operator namespace used for config refresh during reconciliation. operatorNamespace string } -// hasUserConfigMap checks if the instance has a valid UserConfig with ConfigMapName. -// Returns true if configured, false otherwise. -func (r *LlamaStackDistributionReconciler) hasUserConfigMap(instance *llamav1alpha1.LlamaStackDistribution) bool { - return instance.Spec.Server.UserConfig != nil && instance.Spec.Server.UserConfig.ConfigMapName != "" -} - -// getUserConfigMapNamespace returns the resolved ConfigMap namespace. -// If ConfigMapNamespace is specified, it returns that; otherwise, it returns the instance's namespace. -func (r *LlamaStackDistributionReconciler) getUserConfigMapNamespace(instance *llamav1alpha1.LlamaStackDistribution) string { - if instance.Spec.Server.UserConfig.ConfigMapNamespace != "" { - return instance.Spec.Server.UserConfig.ConfigMapNamespace - } - return instance.Namespace -} - -// hasCABundleConfigMap checks if the instance has a valid TLSConfig with CABundle ConfigMapName. -// Returns true if configured, false otherwise. -func (r *LlamaStackDistributionReconciler) hasCABundleConfigMap(instance *llamav1alpha1.LlamaStackDistribution) bool { - return instance.Spec.Server.TLSConfig != nil && instance.Spec.Server.TLSConfig.CABundle != nil && instance.Spec.Server.TLSConfig.CABundle.ConfigMapName != "" -} - -// getCABundleConfigMapNamespace returns the resolved CA bundle ConfigMap namespace. -// If ConfigMapNamespace is specified, it returns that; otherwise, it returns the instance's namespace. -func (r *LlamaStackDistributionReconciler) getCABundleConfigMapNamespace(instance *llamav1alpha1.LlamaStackDistribution) string { - if instance.Spec.Server.TLSConfig.CABundle.ConfigMapNamespace != "" { - return instance.Spec.Server.TLSConfig.CABundle.ConfigMapNamespace - } - return instance.Namespace +// hasCACertificates checks if the instance has TLS trust CA certificates configured. +func (r *OGXServerReconciler) hasCACertificates(instance *ogxiov1beta1.OGXServer) bool { + return instance.Spec.TLS != nil && instance.Spec.TLS.Trust != nil && len(instance.Spec.TLS.Trust.CACertificates) > 0 } // Reconcile is part of the main kubernetes reconciliation loop which aims to // move the current state of the cluster closer to the desired state. -// the LlamaStack object against the actual cluster state, and then +// the OGXServer object against the actual cluster state, and then // perform operations to make the cluster state reflect the state specified by // the user. // // For more details, check Reconcile and its Result here: // - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.12.2/pkg/reconcile -func (r *LlamaStackDistributionReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { +func (r *OGXServerReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { // Create a logger with request-specific values and store it in the context. // This ensures consistent logging across the reconciliation process and its sub-functions. // The logger is retrieved from the context in each sub-function that needs it, maintaining @@ -159,25 +135,27 @@ func (r *LlamaStackDistributionReconciler) Reconcile(ctx context.Context, req ct logger := log.FromContext(ctx).WithValues("namespace", req.Namespace, "name", req.Name) ctx = logr.NewContext(ctx, logger) - // Refresh feature flags and image overrides from the operator config ConfigMap. - // This reads via the direct (non-cached) API client so it always gets full data, - // even though the informer cache strips ConfigMap data to save memory. + // Refresh image mapping overrides from the operator config ConfigMap. r.refreshOperatorConfig(ctx) - // Fetch the LlamaStack instance + // Fetch the OGXServer instance instance, err := r.fetchInstance(ctx, req.NamespacedName) if err != nil { return ctrl.Result{}, err } if instance == nil { - logger.V(1).Info("LlamaStackDistribution resource not found, skipping reconciliation") + logger.V(1).Info("OGXServer resource not found, skipping reconciliation") return ctrl.Result{}, nil } // Reconcile all resources, storing the error for later. reconcileErr := r.reconcileResources(ctx, instance) + if result, done := r.handleSentinelErrors(ctx, instance, reconcileErr); done { + return result, nil + } + // Update the status, passing in any reconciliation error. if statusUpdateErr := r.updateStatus(ctx, instance, reconcileErr); statusUpdateErr != nil { // Log the status update error, but prioritize the reconciliation error for return. @@ -194,17 +172,16 @@ func (r *LlamaStackDistributionReconciler) Reconcile(ctx context.Context, req ct } // Check if requeue is needed based on phase - if instance.Status.Phase == llamav1alpha1.LlamaStackDistributionPhaseInitializing { + if instance.Status.Phase == ogxiov1beta1.OGXServerPhaseInitializing { return ctrl.Result{RequeueAfter: 10 * time.Second}, nil } - logger.Info("Successfully reconciled LlamaStackDistribution") + logger.Info("Successfully reconciled OGXServer") return ctrl.Result{RequeueAfter: 5 * time.Minute}, nil } -// refreshOperatorConfig re-reads the operator config ConfigMap via the direct -// API client and updates feature flags and image mapping overrides. -func (r *LlamaStackDistributionReconciler) refreshOperatorConfig(ctx context.Context) { +// refreshOperatorConfig re-reads the operator config ConfigMap and updates image mapping overrides. +func (r *OGXServerReconciler) refreshOperatorConfig(ctx context.Context) { logger := log.FromContext(ctx) operatorNamespace := r.operatorNamespace @@ -219,7 +196,7 @@ func (r *LlamaStackDistributionReconciler) refreshOperatorConfig(ctx context.Con } configMap := &corev1.ConfigMap{} - if err := r.directGet(ctx, types.NamespacedName{ + if err := r.Get(ctx, types.NamespacedName{ Name: operatorConfigData, Namespace: operatorNamespace, }, configMap); err != nil { @@ -227,74 +204,73 @@ func (r *LlamaStackDistributionReconciler) refreshOperatorConfig(ctx context.Con return } - enableNetworkPolicy, err := parseFeatureFlags(configMap.Data) - if err != nil { - logger.Error(err, "failed to parse feature flags") - } else { - r.EnableNetworkPolicy = enableNetworkPolicy - } - r.ImageMappingOverrides = ParseImageMappingOverrides(ctx, configMap.Data) } -// directGet reads an object via the DirectClient (non-cached) if set, otherwise -// falls back to the cached client. This allows tests to work without a separate client. -func (r *LlamaStackDistributionReconciler) directGet(ctx context.Context, key types.NamespacedName, obj client.Object) error { - if r.DirectClient != nil { - return r.DirectClient.Get(ctx, key, obj) - } - return r.Get(ctx, key, obj) -} - -// fetchInstance retrieves the LlamaStackDistribution instance. -func (r *LlamaStackDistributionReconciler) fetchInstance(ctx context.Context, namespacedName types.NamespacedName) (*llamav1alpha1.LlamaStackDistribution, error) { +// fetchInstance retrieves the OGXServer instance. +func (r *OGXServerReconciler) fetchInstance(ctx context.Context, namespacedName types.NamespacedName) (*ogxiov1beta1.OGXServer, error) { logger := log.FromContext(ctx) - instance := &llamav1alpha1.LlamaStackDistribution{} + instance := &ogxiov1beta1.OGXServer{} if err := r.Get(ctx, namespacedName, instance); err != nil { if k8serrors.IsNotFound(err) { - logger.Info("failed to find LlamaStackDistribution resource") + logger.Info("failed to find OGXServer resource") return nil, nil } - return nil, fmt.Errorf("failed to fetch LlamaStackDistribution: %w", err) + return nil, fmt.Errorf("failed to fetch OGXServer: %w", err) } return instance, nil } // determineKindsToExclude returns a list of resource kinds that should be excluded -// based on the instance specification. -func (r *LlamaStackDistributionReconciler) determineKindsToExclude(instance *llamav1alpha1.LlamaStackDistribution) []string { +// based on the instance specification and adoption annotations. +func (r *OGXServerReconciler) determineKindsToExclude(instance *ogxiov1beta1.OGXServer, effectivePVCName string) []string { var kinds []string - // Exclude PersistentVolumeClaim if storage is not configured - if instance.Spec.Server.Storage == nil { + if shouldExcludePVC(instance, effectivePVCName) { kinds = append(kinds, "PersistentVolumeClaim") } - // Exclude NetworkPolicy if the feature is disabled globally - if !r.EnableNetworkPolicy { + // Per-CR NetworkPolicy toggle (default: enabled) + if instance.Spec.Network != nil && instance.Spec.Network.Policy != nil && + instance.Spec.Network.Policy.Enabled != nil && !*instance.Spec.Network.Policy.Enabled { kinds = append(kinds, "NetworkPolicy") } - // Exclude Service if no ports are defined - if !instance.HasPorts() { - kinds = append(kinds, "Service") - } - if !needsPodDisruptionBudget(instance) { kinds = append(kinds, "PodDisruptionBudget") } - if instance.Spec.Server.Autoscaling == nil { + if instance.Spec.Workload == nil || instance.Spec.Workload.Autoscaling == nil { kinds = append(kinds, "HorizontalPodAutoscaler") } return kinds } +func shouldExcludePVC(instance *ogxiov1beta1.OGXServer, effectivePVCName string) bool { + // Suppress PVC creation when the deployment is using an adopted PVC + // (either via annotation or discovered by label after annotation removal). + if effectivePVCName != instance.Name+"-pvc" { + return true + } + + return instance.Spec.Workload == nil || instance.Spec.Workload.Storage == nil +} + // reconcileAllManifestResources applies all manifest-based resources using kustomize. -func (r *LlamaStackDistributionReconciler) reconcileAllManifestResources(ctx context.Context, instance *llamav1alpha1.LlamaStackDistribution) error { +func (r *OGXServerReconciler) reconcileAllManifestResources( + ctx context.Context, + instance *ogxiov1beta1.OGXServer, + runtimeConfig *runtimeConfigRef, +) error { + // Resolve the PVC name once — may use annotation or label-based discovery. + effectivePVCName, err := r.resolveEffectivePVCName(ctx, instance) + if err != nil { + return fmt.Errorf("failed to resolve effective PVC name: %w", err) + } + // Build manifest context for Deployment - manifestCtx, err := r.buildManifestContext(ctx, instance) + manifestCtx, err := r.buildManifestContext(ctx, instance, runtimeConfig, effectivePVCName) if err != nil { return fmt.Errorf("failed to build manifest context: %w", err) } @@ -305,7 +281,7 @@ func (r *LlamaStackDistributionReconciler) reconcileAllManifestResources(ctx con return fmt.Errorf("failed to render manifests: %w", err) } - kindsToExclude := r.determineKindsToExclude(instance) + kindsToExclude := r.determineKindsToExclude(instance, effectivePVCName) filteredResMap, err := deploy.FilterExcludeKinds(resMap, kindsToExclude) if err != nil { return fmt.Errorf("failed to filter manifests: %w", err) @@ -326,7 +302,7 @@ func (r *LlamaStackDistributionReconciler) reconcileAllManifestResources(ctx con // deleteExcludedResources deletes resources that are excluded from the current reconciliation // but might exist from previous reconciliations. -func (r *LlamaStackDistributionReconciler) deleteExcludedResources(ctx context.Context, instance *llamav1alpha1.LlamaStackDistribution, kindsToExclude []string) error { +func (r *OGXServerReconciler) deleteExcludedResources(ctx context.Context, instance *ogxiov1beta1.OGXServer, kindsToExclude []string) error { logger := log.FromContext(ctx) if slices.Contains(kindsToExclude, "NetworkPolicy") { @@ -354,7 +330,7 @@ func (r *LlamaStackDistributionReconciler) deleteExcludedResources(ctx context.C } // deleteNetworkPolicyIfExists deletes the NetworkPolicy if it exists. -func (r *LlamaStackDistributionReconciler) deleteNetworkPolicyIfExists(ctx context.Context, instance *llamav1alpha1.LlamaStackDistribution) error { +func (r *OGXServerReconciler) deleteNetworkPolicyIfExists(ctx context.Context, instance *ogxiov1beta1.OGXServer) error { logger := log.FromContext(ctx) networkPolicy := &networkingv1.NetworkPolicy{} @@ -375,7 +351,7 @@ func (r *LlamaStackDistributionReconciler) deleteNetworkPolicyIfExists(ctx conte return nil } - logger.Info("Deleting NetworkPolicy as feature is disabled", "networkPolicy", networkPolicyName) + logger.Info("Deleting NetworkPolicy as it is disabled for this instance", "networkPolicy", networkPolicyName) if err := r.Delete(ctx, networkPolicy); err != nil { return fmt.Errorf("failed to delete NetworkPolicy: %w", err) } @@ -383,7 +359,7 @@ func (r *LlamaStackDistributionReconciler) deleteNetworkPolicyIfExists(ctx conte return nil } -func (r *LlamaStackDistributionReconciler) deletePodDisruptionBudgetIfExists(ctx context.Context, instance *llamav1alpha1.LlamaStackDistribution) error { +func (r *OGXServerReconciler) deletePodDisruptionBudgetIfExists(ctx context.Context, instance *ogxiov1beta1.OGXServer) error { logger := log.FromContext(ctx) pdb := &policyv1.PodDisruptionBudget{} @@ -410,7 +386,7 @@ func (r *LlamaStackDistributionReconciler) deletePodDisruptionBudgetIfExists(ctx return nil } -func (r *LlamaStackDistributionReconciler) deleteHorizontalPodAutoscalerIfExists(ctx context.Context, instance *llamav1alpha1.LlamaStackDistribution) error { +func (r *OGXServerReconciler) deleteHorizontalPodAutoscalerIfExists(ctx context.Context, instance *ogxiov1beta1.OGXServer) error { logger := log.FromContext(ctx) hpa := &autoscalingv2.HorizontalPodAutoscaler{} @@ -438,36 +414,47 @@ func (r *LlamaStackDistributionReconciler) deleteHorizontalPodAutoscalerIfExists } // buildManifestContext creates the manifest context for Deployment using existing helper functions. -func (r *LlamaStackDistributionReconciler) buildManifestContext(ctx context.Context, instance *llamav1alpha1.LlamaStackDistribution) (*deploy.ManifestContext, error) { - // Validate distribution configuration +// +// buildManifestContext builds the desired pod/deployment inputs for the +// current reconcile pass using the resolved runtime config reference. +func (r *OGXServerReconciler) buildManifestContext( + ctx context.Context, + instance *ogxiov1beta1.OGXServer, + runtimeConfig *runtimeConfigRef, + effectivePVCName string, +) (*deploy.ManifestContext, error) { if err := r.validateDistribution(instance); err != nil { return nil, err } - resolvedImage, err := r.resolveImage(instance.Spec.Server.Distribution) + resolvedImage, err := r.resolveImage(instance.Spec.Distribution) if err != nil { return nil, err } - container := buildContainerSpec(ctx, r, instance, resolvedImage) - podSpec := configurePodStorage(ctx, r, instance, container) + // Compute secret env vars once; reused for both container env injection + // and rollout-triggering secret hash computation. + var secretEnvVars []corev1.EnvVar + if runtimeConfig != nil && runtimeConfig.Generated { + secretEnvVars = config.CollectSecretRefs(&instance.Spec) + } - // Get UserConfigMap hash if needed - var configMapHash string - if r.hasUserConfigMap(instance) { - configMapHash, err = r.getConfigMapHash(ctx, instance) - if err != nil { - return nil, fmt.Errorf("failed to get ConfigMap hash: %w", err) - } + container := buildContainerSpec(ctx, r, instance, resolvedImage, runtimeConfig, secretEnvVars) + podSpec := configurePodStorage(ctx, r, instance, runtimeConfig, container, effectivePVCName) + + configMapHash, err := r.resolveConfigMapHash(ctx, instance, runtimeConfig) + if err != nil { + return nil, err } - // Get CA bundle hash if needed - var caBundleHash string - if r.hasCABundleConfigMap(instance) { - caBundleHash, err = r.getCABundleConfigMapHash(ctx, instance) - if err != nil { - return nil, fmt.Errorf("failed to get CA bundle ConfigMap hash: %w", err) - } + caBundleHash, err := r.resolveCABundleHash(ctx, instance) + if err != nil { + return nil, err + } + + secretHash, err := r.resolveSecretRefsHash(ctx, instance, runtimeConfig, secretEnvVars) + if err != nil { + return nil, err } podSpecMap, err := runtime.DefaultUnstructuredConverter.ToUnstructured(&podSpec) @@ -475,74 +462,213 @@ func (r *LlamaStackDistributionReconciler) buildManifestContext(ctx context.Cont return nil, fmt.Errorf("failed to convert pod spec to map: %w", err) } - pdbSpec := buildPodDisruptionBudgetSpec(instance) - hpaSpec := buildHPASpec(instance) - return &deploy.ManifestContext{ ResolvedImage: resolvedImage, ConfigMapHash: configMapHash, CABundleHash: caBundleHash, + SecretHash: secretHash, PodSpec: podSpecMap, - PodDisruptionBudgetSpec: pdbSpec, - HPASpec: hpaSpec, + PodDisruptionBudgetSpec: buildPodDisruptionBudgetSpec(instance), + HPASpec: buildHPASpec(instance), }, nil } -// reconcileResources reconciles all resources for the LlamaStackDistribution instance. -func (r *LlamaStackDistributionReconciler) reconcileResources(ctx context.Context, instance *llamav1alpha1.LlamaStackDistribution) error { +func (r *OGXServerReconciler) resolveConfigMapHash( + ctx context.Context, + instance *ogxiov1beta1.OGXServer, + runtimeConfig *runtimeConfigRef, +) (string, error) { + if runtimeConfig == nil { + return "", nil + } + if !runtimeConfig.Generated { + return r.getConfigMapHash(ctx, instance) + } + return r.getGeneratedConfigMapHashByName(ctx, instance.Namespace, runtimeConfig.ConfigMapName) +} + +func (r *OGXServerReconciler) resolveCABundleHash(ctx context.Context, instance *ogxiov1beta1.OGXServer) (string, error) { + if r.hasCACertificates(instance) { + return r.getCABundleConfigMapHash(ctx, instance) + } + return "", nil +} + +func (r *OGXServerReconciler) resolveSecretRefsHash( + ctx context.Context, + instance *ogxiov1beta1.OGXServer, + runtimeConfig *runtimeConfigRef, + envVars []corev1.EnvVar, +) (string, error) { + if runtimeConfig == nil || !runtimeConfig.Generated || len(envVars) == 0 { + return "", nil + } + + entries := make([]string, 0, len(envVars)) + for _, env := range envVars { + if env.ValueFrom == nil || env.ValueFrom.SecretKeyRef == nil { + continue + } + selector := env.ValueFrom.SecretKeyRef + secret := &corev1.Secret{} + if err := r.Get(ctx, types.NamespacedName{Name: selector.Name, Namespace: instance.Namespace}, secret); err != nil { + return "", fmt.Errorf("failed to resolve secret %s/%s for env %s: %w", instance.Namespace, selector.Name, env.Name, err) + } + // Hash the actual secret data value so that metadata-only changes + // (label edits, etc.) don't trigger unnecessary pod restarts. + valHash := sha256.Sum256(secret.Data[selector.Key]) + entries = append(entries, fmt.Sprintf("%s=%s/%s@%s", env.Name, selector.Name, selector.Key, hex.EncodeToString(valHash[:8]))) + } + + if len(entries) == 0 { + return "", nil + } + + sort.Strings(entries) + hash := sha256.Sum256([]byte(strings.Join(entries, "|"))) + return hex.EncodeToString(hash[:8]), nil +} + +// reconcileResources reconciles all resources for the OGXServer instance. +// +//nolint:cyclop // Reconcile orchestration intentionally sequences several independent subsystems. +func (r *OGXServerReconciler) reconcileResources(ctx context.Context, instance *ogxiov1beta1.OGXServer) error { + // Run adoption logic before manifest reconciliation so that adopted + // resources are available for the kustomize pipeline to reference. + adoptResult, err := r.adoptLegacyResources(ctx, instance) + if err != nil { + return fmt.Errorf("failed to adopt legacy resources: %w", err) + } + if adoptResult.requeue { + return &requeueError{after: adoptResult.requeueAfter} + } + // Reconcile ConfigMaps first - if err := r.reconcileConfigMaps(ctx, instance); err != nil { + generated, err := r.reconcileConfigMaps(ctx, instance) + if err != nil { + if instance.HasDeclarativeConfig() || instance.Status.ConfigGeneration != nil { + r.setConfigGeneratedCondition(instance, false, "ConfigGenerationFailed", err.Error()) + } return err } + pendingGeneratedConfigMapName := "" + if generated != nil { + pendingGeneratedConfigMapName = generatedConfigMapName(instance.Name, generated.ContentHash) + } + runtimeConfig := resolveRuntimeConfigRef(instance, pendingGeneratedConfigMapName) + // Reconcile all manifest-based resources including Deployment, PVC, ServiceAccount, Service, NetworkPolicy. // NetworkPolicy ingress rules are configured via the kustomize transformer plugin. - if err := r.reconcileAllManifestResources(ctx, instance); err != nil { + if err := r.reconcileAllManifestResources(ctx, instance, runtimeConfig); err != nil { + if generated != nil { + r.setConfigGeneratedCondition(instance, false, "ConfigGenerationFailed", err.Error()) + } return err } + if generated != nil { + r.setConfigGeneratedCondition(instance, true, "ConfigGenerationSucceeded", + fmt.Sprintf("Generated config.yaml with %d providers and %d resources", generated.ProviderCount, generated.ResourceCount)) + r.updateConfigGenerationStatus(instance, generated) + if err := r.cleanupOldGeneratedConfigMaps(ctx, instance, pendingGeneratedConfigMapName); err != nil { + log.FromContext(ctx).Error(err, "failed to clean up old generated ConfigMaps") + } + } else { + r.clearConfigGenerationStatus(instance, "ConfigGenerationInactive", "Declarative config generation is not active") + } + // Reconcile Ingress for external access (not part of kustomize manifests) if err := r.reconcileIngress(ctx, instance); err != nil { return fmt.Errorf("failed to reconcile Ingress: %w", err) } + // Clean up adopted networking resources if the annotation was removed. + // This runs after normal networking reconciliation to avoid delete-before-create + // gaps during the migration-off path. + if err := r.cleanupAdoptedNetworking(ctx, instance); err != nil { + return fmt.Errorf("failed to clean up adopted networking: %w", err) + } + return nil } -func (r *LlamaStackDistributionReconciler) reconcileConfigMaps(ctx context.Context, instance *llamav1alpha1.LlamaStackDistribution) error { - if err := r.validateCABundleKeys(instance); err != nil { - return err +// requeueError signals that the reconciler should requeue after a delay +// without reporting an error to the controller runtime. +type requeueError struct { + after time.Duration +} + +func (e *requeueError) Error() string { + return fmt.Sprintf("requeue after %s", e.after) +} + +// terminalError signals a problem that cannot be resolved by retrying. +// The reconciler sets a status condition and stops without requeueing. +type terminalError struct { + message string +} + +func (e *terminalError) Error() string { + return e.message +} + +func (r *OGXServerReconciler) handleSentinelErrors( + ctx context.Context, instance *ogxiov1beta1.OGXServer, reconcileErr error, +) (ctrl.Result, bool) { + logger := log.FromContext(ctx) + + var requeueErr *requeueError + if errors.As(reconcileErr, &requeueErr) { + if statusUpdateErr := r.updateStatus(ctx, instance, nil); statusUpdateErr != nil { + logger.Error(statusUpdateErr, "failed to update status during adoption requeue") + } + return ctrl.Result{RequeueAfter: requeueErr.after}, true } - if err := r.reconcileUserAndCABundleConfigMaps(ctx, instance); err != nil { - return err + var termErr *terminalError + if errors.As(reconcileErr, &termErr) { + if statusUpdateErr := r.updateStatus(ctx, instance, nil); statusUpdateErr != nil { + logger.Error(statusUpdateErr, "failed to update status for terminal error") + } + return ctrl.Result{}, true } - return r.reconcileManagedCABundle(ctx, instance) + return ctrl.Result{}, false } -func (r *LlamaStackDistributionReconciler) validateCABundleKeys(instance *llamav1alpha1.LlamaStackDistribution) error { - if instance.Spec.Server.TLSConfig == nil || instance.Spec.Server.TLSConfig.CABundle == nil { - return nil +func (r *OGXServerReconciler) reconcileConfigMaps(ctx context.Context, instance *ogxiov1beta1.OGXServer) (*config.GeneratedConfig, error) { + if err := r.reconcileOverrideAndCABundleConfigMaps(ctx, instance); err != nil { + return nil, err } - if len(instance.Spec.Server.TLSConfig.CABundle.ConfigMapKeys) > 0 { - if err := validateConfigMapKeys(instance.Spec.Server.TLSConfig.CABundle.ConfigMapKeys); err != nil { - return fmt.Errorf("failed to validate CA bundle ConfigMap keys: %w", err) - } + // Reconcile operator-generated config (from declarative providers/resources/storage) + generated, err := r.reconcileGeneratedConfig(ctx, instance) + if err != nil { + return nil, fmt.Errorf("failed to reconcile generated config: %w", err) } - return nil + if err := r.reconcileManagedCABundle(ctx, instance); err != nil { + return nil, err + } + + return generated, nil } -func (r *LlamaStackDistributionReconciler) reconcileUserAndCABundleConfigMaps(ctx context.Context, instance *llamav1alpha1.LlamaStackDistribution) error { - if r.hasUserConfigMap(instance) { - if err := r.reconcileUserConfigMap(ctx, instance); err != nil { - return fmt.Errorf("failed to reconcile user ConfigMap: %w", err) +func (r *OGXServerReconciler) reconcileOverrideAndCABundleConfigMaps(ctx context.Context, instance *ogxiov1beta1.OGXServer) error { + if instance.Spec.BaseConfig != nil { + if err := r.reconcileBaseConfigMap(ctx, instance); err != nil { + return fmt.Errorf("failed to reconcile base ConfigMap: %w", err) } } - if r.hasCABundleConfigMap(instance) { + if instance.HasOverrideConfig() { + if err := r.reconcileOverrideConfigMap(ctx, instance); err != nil { + return fmt.Errorf("failed to reconcile override ConfigMap: %w", err) + } + } + + if r.hasCACertificates(instance) { if err := r.reconcileCABundleConfigMap(ctx, instance); err != nil { return fmt.Errorf("failed to reconcile CA bundle ConfigMap: %w", err) } @@ -551,11 +677,11 @@ func (r *LlamaStackDistributionReconciler) reconcileUserAndCABundleConfigMaps(ct return nil } -func (r *LlamaStackDistributionReconciler) reconcileManagedCABundle(ctx context.Context, instance *llamav1alpha1.LlamaStackDistribution) error { +func (r *OGXServerReconciler) reconcileManagedCABundle(ctx context.Context, instance *ogxiov1beta1.OGXServer) error { logger := log.FromContext(ctx) managedConfigMapName := getManagedCABundleConfigMapName(instance) - if !r.hasCABundleConfigMap(instance) && !r.hasODHTrustedCABundle(ctx, instance) { + if !r.hasCACertificates(instance) && !r.hasODHTrustedCABundle(ctx, instance) { // No CA bundles configured, delete managed ConfigMap if it exists existingConfigMap := &corev1.ConfigMap{} err := r.Get(ctx, types.NamespacedName{ @@ -585,10 +711,10 @@ func (r *LlamaStackDistributionReconciler) reconcileManagedCABundle(ctx context. } // SetupWithManager sets up the controller with the Manager. -func (r *LlamaStackDistributionReconciler) SetupWithManager(_ context.Context, mgr ctrl.Manager) error { +func (r *OGXServerReconciler) SetupWithManager(_ context.Context, mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). - For(&llamav1alpha1.LlamaStackDistribution{}, builder.WithPredicates(predicate.Funcs{ - UpdateFunc: r.llamaStackUpdatePredicate(mgr), + For(&ogxiov1beta1.OGXServer{}, builder.WithPredicates(predicate.Funcs{ + UpdateFunc: r.ogxServerUpdatePredicate(mgr), })). Owns(&appsv1.Deployment{}). Owns(&policyv1.PodDisruptionBudget{}). @@ -600,24 +726,29 @@ func (r *LlamaStackDistributionReconciler) SetupWithManager(_ context.Context, m handler.EnqueueRequestsFromMapFunc(r.mapConfigMapToReconcileRequests), builder.WithPredicates(r.userConfigMapPredicate()), ). + Watches( + &corev1.Secret{}, + handler.EnqueueRequestsFromMapFunc(r.mapSecretToReconcileRequests), + builder.WithPredicates(r.userSecretPredicate()), + ). Owns(&networkingv1.NetworkPolicy{}). Owns(&networkingv1.Ingress{}). Owns(&corev1.PersistentVolumeClaim{}). Complete(r) } -// llamaStackUpdatePredicate returns a predicate function for LlamaStackDistribution updates. -func (r *LlamaStackDistributionReconciler) llamaStackUpdatePredicate(mgr ctrl.Manager) func(event.UpdateEvent) bool { +// ogxServerUpdatePredicate returns a predicate function for OGXServer updates. +func (r *OGXServerReconciler) ogxServerUpdatePredicate(mgr ctrl.Manager) func(event.UpdateEvent) bool { return func(e event.UpdateEvent) bool { // Safely type assert old object - oldObj, ok := e.ObjectOld.(*llamav1alpha1.LlamaStackDistribution) + oldObj, ok := e.ObjectOld.(*ogxiov1beta1.OGXServer) if !ok { return false } oldObjCopy := oldObj.DeepCopy() // Safely type assert new object - newObj, ok := e.ObjectNew.(*llamav1alpha1.LlamaStackDistribution) + newObj, ok := e.ObjectNew.(*ogxiov1beta1.OGXServer) if !ok { return false } @@ -626,7 +757,7 @@ func (r *LlamaStackDistributionReconciler) llamaStackUpdatePredicate(mgr ctrl.Ma // Compare only spec, ignoring metadata and status if diff := cmp.Diff(oldObjCopy.Spec, newObjCopy.Spec); diff != "" { logger := mgr.GetLogger().WithValues("namespace", newObjCopy.Namespace, "name", newObjCopy.Name) - logger.Info("LlamaStackDistribution CR spec changed") + logger.Info("OGXServer CR spec changed") // Note that both the logger and fmt.Printf could appear entangled in the output // but there is no simple way to avoid this (forcing the logger to flush its output). // When the logger is used to print the diff the output is hard to read, @@ -639,8 +770,8 @@ func (r *LlamaStackDistributionReconciler) llamaStackUpdatePredicate(mgr ctrl.Ma } // mapConfigMapToReconcileRequests maps a user-opted-in ConfigMap change to the -// LlamaStackDistribution CR(s) that reference it. -func (r *LlamaStackDistributionReconciler) mapConfigMapToReconcileRequests(ctx context.Context, obj client.Object) []reconcile.Request { +// OGXServer CR(s) that reference it. +func (r *OGXServerReconciler) mapConfigMapToReconcileRequests(ctx context.Context, obj client.Object) []reconcile.Request { logger := log.FromContext(ctx) configMap, ok := obj.(*corev1.ConfigMap) @@ -649,14 +780,20 @@ func (r *LlamaStackDistributionReconciler) mapConfigMapToReconcileRequests(ctx c } // Skip operator-managed ConfigMaps — they are handled by Owns(). - if configMap.Labels["app.kubernetes.io/managed-by"] == "llama-stack-operator" { + if configMap.Labels[managedByLabelKey] == managedByLabelVal { return nil } - // List all LlamaStackDistribution CRs to find which ones reference this ConfigMap. - var instances llamav1alpha1.LlamaStackDistributionList - if err := r.List(ctx, &instances); err != nil { - logger.Error(err, "failed to list LlamaStackDistribution instances for ConfigMap mapping") + // List relevant OGXServer CRs to find which ones reference this ConfigMap. + // User ConfigMaps are namespace-scoped per 003 design, so default to same-namespace + // listing. Keep operator config global since it can affect all instances. + var instances ogxiov1beta1.OGXServerList + listOpts := []client.ListOption{client.InNamespace(configMap.Namespace)} + if configMap.Name == operatorConfigData { + listOpts = nil + } + if err := r.List(ctx, &instances, listOpts...); err != nil { + logger.Error(err, "failed to list OGXServer instances for ConfigMap mapping") return nil } @@ -664,7 +801,7 @@ func (r *LlamaStackDistributionReconciler) mapConfigMapToReconcileRequests(ctx c for i := range instances.Items { instance := &instances.Items[i] if r.instanceReferencesConfigMap(instance, configMap.Name, configMap.Namespace) { - logger.Info("ConfigMap change mapped to LlamaStackDistribution", + logger.Info("ConfigMap change mapped to OGXServer", "configMap", configMap.Name, "configMapNamespace", configMap.Namespace, "instance", instance.Name, "instanceNamespace", instance.Namespace) requests = append(requests, reconcile.Request{ @@ -679,22 +816,29 @@ func (r *LlamaStackDistributionReconciler) mapConfigMapToReconcileRequests(ctx c return requests } -// instanceReferencesConfigMap checks if a LlamaStackDistribution instance references +// instanceReferencesConfigMap checks if an OGXServer instance references // a ConfigMap with the given name and namespace. -func (r *LlamaStackDistributionReconciler) instanceReferencesConfigMap( - instance *llamav1alpha1.LlamaStackDistribution, cmName, cmNamespace string, +// +//nolint:cyclop // Aggregates multiple independent ConfigMap reference sources. +func (r *OGXServerReconciler) instanceReferencesConfigMap( + instance *ogxiov1beta1.OGXServer, cmName, cmNamespace string, ) bool { - // User config ConfigMap. - if r.hasUserConfigMap(instance) && - instance.Spec.Server.UserConfig.ConfigMapName == cmName && - r.getUserConfigMapNamespace(instance) == cmNamespace { + // Override config ConfigMap (always in the CR namespace). + if instance.HasOverrideConfig() && + instance.Spec.OverrideConfig.Name == cmName && + instance.Namespace == cmNamespace { return true } - // CA bundle source ConfigMap. - if r.hasCABundleConfigMap(instance) && - instance.Spec.Server.TLSConfig.CABundle.ConfigMapName == cmName && - r.getCABundleConfigMapNamespace(instance) == cmNamespace { + // Declarative base config ConfigMap (always in the CR namespace). + if instance.Spec.BaseConfig != nil && + instance.Spec.BaseConfig.Name == cmName && + instance.Namespace == cmNamespace { + return true + } + + // CA certificate source ConfigMaps. + if r.referencesCACertificateConfigMap(instance, cmName, cmNamespace) { return true } @@ -707,9 +851,73 @@ func (r *LlamaStackDistributionReconciler) instanceReferencesConfigMap( return cmName == operatorConfigData && cmNamespace == r.operatorNamespace } +func (r *OGXServerReconciler) referencesCACertificateConfigMap(instance *ogxiov1beta1.OGXServer, cmName, cmNamespace string) bool { + if !r.hasCACertificates(instance) || cmNamespace != instance.Namespace { + return false + } + for _, ref := range instance.Spec.TLS.Trust.CACertificates { + if ref.Name == cmName { + return true + } + } + return false +} + +// mapSecretToReconcileRequests maps a user-opted-in Secret change to +// OGXServer CR(s) that reference that Secret through declarative providers/storage. +func (r *OGXServerReconciler) mapSecretToReconcileRequests(ctx context.Context, obj client.Object) []reconcile.Request { + logger := log.FromContext(ctx) + + secret, ok := obj.(*corev1.Secret) + if !ok { + return nil + } + + var instances ogxiov1beta1.OGXServerList + if err := r.List(ctx, &instances, client.InNamespace(secret.Namespace)); err != nil { + logger.Error(err, "failed to list OGXServer instances for Secret mapping") + return nil + } + + var requests []reconcile.Request + for i := range instances.Items { + instance := &instances.Items[i] + if r.instanceReferencesSecret(instance, secret.Name, secret.Namespace) { + logger.Info("Secret change mapped to OGXServer", + "secret", secret.Name, "secretNamespace", secret.Namespace, + "instance", instance.Name, "instanceNamespace", instance.Namespace) + requests = append(requests, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: instance.Name, + Namespace: instance.Namespace, + }, + }) + } + } + + return requests +} + +func (r *OGXServerReconciler) instanceReferencesSecret(instance *ogxiov1beta1.OGXServer, secretName, secretNamespace string) bool { + if secretNamespace != instance.Namespace { + return false + } + + for _, env := range config.CollectSecretRefs(&instance.Spec) { + if env.ValueFrom == nil || env.ValueFrom.SecretKeyRef == nil { + continue + } + if env.ValueFrom.SecretKeyRef.Name == secretName { + return true + } + } + + return false +} + // userConfigMapPredicate returns a predicate that accepts only ConfigMaps with // the watch label and rejects operator-managed ConfigMaps (handled by Owns()). -func (r *LlamaStackDistributionReconciler) userConfigMapPredicate() predicate.Funcs { +func (r *OGXServerReconciler) userConfigMapPredicate() predicate.Funcs { return predicate.Funcs{ CreateFunc: func(e event.CreateEvent) bool { return isWatchLabeledUserConfigMap(e.Object) @@ -734,14 +942,46 @@ func isWatchLabeledUserConfigMap(obj client.Object) bool { return false } // Reject operator-managed ConfigMaps — they are handled by Owns(). - if labels["app.kubernetes.io/managed-by"] == "llama-stack-operator" { + if labels[managedByLabelKey] == managedByLabelVal { + return false + } + return labels[WatchLabelKey] == WatchLabelValue +} + +// userSecretPredicate returns a predicate that accepts only user Secrets with +// the watch label and rejects operator-managed objects. +func (r *OGXServerReconciler) userSecretPredicate() predicate.Funcs { + return predicate.Funcs{ + CreateFunc: func(e event.CreateEvent) bool { + return isWatchLabeledUserSecret(e.Object) + }, + UpdateFunc: func(e event.UpdateEvent) bool { + return isWatchLabeledUserSecret(e.ObjectNew) + }, + DeleteFunc: func(e event.DeleteEvent) bool { + return isWatchLabeledUserSecret(e.Object) + }, + GenericFunc: func(e event.GenericEvent) bool { + return isWatchLabeledUserSecret(e.Object) + }, + } +} + +// isWatchLabeledUserSecret returns true if the Secret has the watch label +// and is NOT an operator-managed Secret. +func isWatchLabeledUserSecret(obj client.Object) bool { + labels := obj.GetLabels() + if labels == nil { + return false + } + if labels[managedByLabelKey] == managedByLabelVal { return false } return labels[WatchLabelKey] == WatchLabelValue } -// getServerURL returns the URL for the LlamaStack server. -func (r *LlamaStackDistributionReconciler) getServerURL(instance *llamav1alpha1.LlamaStackDistribution, path string) *url.URL { +// getServerURL returns the URL for the OGX server. +func (r *OGXServerReconciler) getServerURL(instance *ogxiov1beta1.OGXServer, path string) *url.URL { serviceName := deploy.GetServiceName(instance) port := deploy.GetServicePort(instance) @@ -753,7 +993,7 @@ func (r *LlamaStackDistributionReconciler) getServerURL(instance *llamav1alpha1. } // getProviderInfo makes an HTTP request to the providers endpoint. -func (r *LlamaStackDistributionReconciler) getProviderInfo(ctx context.Context, instance *llamav1alpha1.LlamaStackDistribution) ([]llamav1alpha1.ProviderInfo, error) { +func (r *OGXServerReconciler) getProviderInfo(ctx context.Context, instance *ogxiov1beta1.OGXServer) ([]ogxiov1beta1.ProviderInfo, error) { u := r.getServerURL(instance, "/v1/providers") req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) @@ -778,7 +1018,7 @@ func (r *LlamaStackDistributionReconciler) getProviderInfo(ctx context.Context, } var response struct { - Data []llamav1alpha1.ProviderInfo `json:"data"` + Data []ogxiov1beta1.ProviderInfo `json:"data"` } if err := json.Unmarshal(body, &response); err != nil { return nil, fmt.Errorf("failed to unmarshal providers response: %w", err) @@ -788,7 +1028,7 @@ func (r *LlamaStackDistributionReconciler) getProviderInfo(ctx context.Context, } // getVersionInfo makes an HTTP request to the version endpoint. -func (r *LlamaStackDistributionReconciler) getVersionInfo(ctx context.Context, instance *llamav1alpha1.LlamaStackDistribution) (string, error) { +func (r *OGXServerReconciler) getVersionInfo(ctx context.Context, instance *ogxiov1beta1.OGXServer) (string, error) { u := r.getServerURL(instance, "/v1/version") req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) @@ -822,13 +1062,13 @@ func (r *LlamaStackDistributionReconciler) getVersionInfo(ctx context.Context, i return response.Version, nil } -// updateStatus refreshes the LlamaStack status. -func (r *LlamaStackDistributionReconciler) updateStatus(ctx context.Context, instance *llamav1alpha1.LlamaStackDistribution, reconcileErr error) error { +// updateStatus refreshes the OGXServer status. +func (r *OGXServerReconciler) updateStatus(ctx context.Context, instance *ogxiov1beta1.OGXServer, reconcileErr error) error { logger := log.FromContext(ctx) instance.Status.Version.OperatorVersion = os.Getenv("OPERATOR_VERSION") // A reconciliation error is the highest priority. It overrides all other status checks. if reconcileErr != nil { - instance.Status.Phase = llamav1alpha1.LlamaStackDistributionPhaseFailed + instance.Status.Phase = ogxiov1beta1.OGXServerPhaseFailed SetDeploymentReadyCondition(&instance.Status, false, fmt.Sprintf("Resource reconciliation failed: %v", reconcileErr)) } else { // If reconciliation was successful, proceed with detailed status checks. @@ -842,7 +1082,7 @@ func (r *LlamaStackDistributionReconciler) updateStatus(ctx context.Context, ins r.updateDistributionConfig(instance) if deploymentReady { - instance.Status.Phase = llamav1alpha1.LlamaStackDistributionPhaseReady + instance.Status.Phase = ogxiov1beta1.OGXServerPhaseReady providers, err := r.getProviderInfo(ctx, instance) if err != nil { @@ -857,8 +1097,8 @@ func (r *LlamaStackDistributionReconciler) updateStatus(ctx context.Context, ins logger.Error(err, "failed to get version info from API endpoint") // Don't clear the version if we cant fetch it - keep the existing one } else { - instance.Status.Version.LlamaStackServerVersion = version - logger.V(1).Info("Updated LlamaStack version from API endpoint", "version", version) + instance.Status.Version.ServerVersion = version + logger.V(1).Info("Updated server version from API endpoint", "version", version) } SetHealthCheckCondition(&instance.Status, true, MessageHealthCheckPassed) @@ -869,16 +1109,24 @@ func (r *LlamaStackDistributionReconciler) updateStatus(ctx context.Context, ins } } - // Always update the status at the end of the function. instance.Status.Version.LastUpdated = metav1.NewTime(metav1.Now().UTC()) - if err := r.Status().Update(ctx, instance); err != nil { + desiredStatus := instance.Status.DeepCopy() + + err := retry.RetryOnConflict(retry.DefaultRetry, func() error { + latest := &ogxiov1beta1.OGXServer{} + if getErr := r.Get(ctx, client.ObjectKeyFromObject(instance), latest); getErr != nil { + return getErr + } + latest.Status = *desiredStatus + return r.Status().Update(ctx, latest) + }) + if err != nil { return fmt.Errorf("failed to update status: %w", err) } - return nil } -func (r *LlamaStackDistributionReconciler) updateDeploymentStatus(ctx context.Context, instance *llamav1alpha1.LlamaStackDistribution) (bool, error) { +func (r *OGXServerReconciler) updateDeploymentStatus(ctx context.Context, instance *ogxiov1beta1.OGXServer) (bool, error) { deployment := &appsv1.Deployment{} deploymentErr := r.Get(ctx, types.NamespacedName{Name: instance.Name, Namespace: instance.Namespace}, deployment) if deploymentErr != nil && !k8serrors.IsNotFound(deploymentErr) { @@ -889,21 +1137,21 @@ func (r *LlamaStackDistributionReconciler) updateDeploymentStatus(ctx context.Co switch { case deploymentErr != nil: // This case covers when the deployment is not found - instance.Status.Phase = llamav1alpha1.LlamaStackDistributionPhasePending + instance.Status.Phase = ogxiov1beta1.OGXServerPhasePending SetDeploymentReadyCondition(&instance.Status, false, MessageDeploymentPending) case deployment.Status.ReadyReplicas == 0: - instance.Status.Phase = llamav1alpha1.LlamaStackDistributionPhaseInitializing + instance.Status.Phase = ogxiov1beta1.OGXServerPhaseInitializing SetDeploymentReadyCondition(&instance.Status, false, MessageDeploymentPending) - case deployment.Status.ReadyReplicas < instance.Spec.Replicas: - instance.Status.Phase = llamav1alpha1.LlamaStackDistributionPhaseInitializing - deploymentMessage := fmt.Sprintf("Deployment is scaling: %d/%d replicas ready", deployment.Status.ReadyReplicas, instance.Spec.Replicas) + case deployment.Status.ReadyReplicas < deploy.GetEffectiveReplicas(instance): + instance.Status.Phase = ogxiov1beta1.OGXServerPhaseInitializing + deploymentMessage := fmt.Sprintf("Deployment is scaling: %d/%d replicas ready", deployment.Status.ReadyReplicas, deploy.GetEffectiveReplicas(instance)) SetDeploymentReadyCondition(&instance.Status, false, deploymentMessage) - case deployment.Status.ReadyReplicas > instance.Spec.Replicas: - instance.Status.Phase = llamav1alpha1.LlamaStackDistributionPhaseInitializing - deploymentMessage := fmt.Sprintf("Deployment is scaling down: %d/%d replicas ready", deployment.Status.ReadyReplicas, instance.Spec.Replicas) + case deployment.Status.ReadyReplicas > deploy.GetEffectiveReplicas(instance): + instance.Status.Phase = ogxiov1beta1.OGXServerPhaseInitializing + deploymentMessage := fmt.Sprintf("Deployment is scaling down: %d/%d replicas ready", deployment.Status.ReadyReplicas, deploy.GetEffectiveReplicas(instance)) SetDeploymentReadyCondition(&instance.Status, false, deploymentMessage) default: - instance.Status.Phase = llamav1alpha1.LlamaStackDistributionPhaseReady + instance.Status.Phase = ogxiov1beta1.OGXServerPhaseReady deploymentReady = true SetDeploymentReadyCondition(&instance.Status, true, MessageDeploymentReady) } @@ -911,13 +1159,19 @@ func (r *LlamaStackDistributionReconciler) updateDeploymentStatus(ctx context.Co return deploymentReady, nil } -func (r *LlamaStackDistributionReconciler) updateStorageStatus(ctx context.Context, instance *llamav1alpha1.LlamaStackDistribution) { - if instance.Spec.Server.Storage == nil { +func (r *OGXServerReconciler) updateStorageStatus(ctx context.Context, instance *ogxiov1beta1.OGXServer) { + if instance.Spec.Workload == nil || instance.Spec.Workload.Storage == nil { return } - pvc := &corev1.PersistentVolumeClaim{} - err := r.Get(ctx, types.NamespacedName{Name: instance.Name + "-pvc", Namespace: instance.Namespace}, pvc) + + pvcName, err := r.resolveEffectivePVCName(ctx, instance) if err != nil { + SetStorageReadyCondition(&instance.Status, false, fmt.Sprintf("Failed to resolve PVC name: %v", err)) + return + } + + pvc := &corev1.PersistentVolumeClaim{} + if err := r.Get(ctx, types.NamespacedName{Name: pvcName, Namespace: instance.Namespace}, pvc); err != nil { SetStorageReadyCondition(&instance.Status, false, fmt.Sprintf("Failed to get PVC: %v", err)) return } @@ -932,12 +1186,7 @@ func (r *LlamaStackDistributionReconciler) updateStorageStatus(ctx context.Conte SetStorageReadyCondition(&instance.Status, ready, message) } -func (r *LlamaStackDistributionReconciler) updateServiceStatus(ctx context.Context, instance *llamav1alpha1.LlamaStackDistribution) { - logger := log.FromContext(ctx) - if !instance.HasPorts() { - logger.V(1).Info("No ports defined, skipping service status update") - return - } +func (r *OGXServerReconciler) updateServiceStatus(ctx context.Context, instance *ogxiov1beta1.OGXServer) { service := &corev1.Service{} err := r.Get(ctx, types.NamespacedName{Name: instance.Name + "-service", Namespace: instance.Namespace}, service) if err != nil { @@ -949,139 +1198,166 @@ func (r *LlamaStackDistributionReconciler) updateServiceStatus(ctx context.Conte serviceURL := r.getServerURL(instance, "") instance.Status.ServiceURL = serviceURL.String() - // Set the route URL if external access is enabled - instance.Status.RouteURL = r.getIngressURL(ctx, instance) + // Set the external URL if external access is enabled + instance.Status.ExternalURL = r.getIngressURL(ctx, instance) SetServiceReadyCondition(&instance.Status, true, MessageServiceReady) } -func (r *LlamaStackDistributionReconciler) updateDistributionConfig(instance *llamav1alpha1.LlamaStackDistribution) { +func (r *OGXServerReconciler) updateDistributionConfig(instance *ogxiov1beta1.OGXServer) { instance.Status.DistributionConfig.AvailableDistributions = r.ClusterInfo.DistributionImages var activeDistribution string - if instance.Spec.Server.Distribution.Name != "" { - activeDistribution = instance.Spec.Server.Distribution.Name - } else if instance.Spec.Server.Distribution.Image != "" { + if instance.Spec.Distribution.Name != "" { + activeDistribution = instance.Spec.Distribution.Name + } else if instance.Spec.Distribution.Image != "" { activeDistribution = "custom" } instance.Status.DistributionConfig.ActiveDistribution = activeDistribution } -// reconcileUserConfigMap validates that the referenced ConfigMap exists. -func (r *LlamaStackDistributionReconciler) reconcileUserConfigMap(ctx context.Context, instance *llamav1alpha1.LlamaStackDistribution) error { +// reconcileOverrideConfigMap validates that the referenced override ConfigMap exists. +func (r *OGXServerReconciler) reconcileOverrideConfigMap(ctx context.Context, instance *ogxiov1beta1.OGXServer) error { logger := log.FromContext(ctx) - if !r.hasUserConfigMap(instance) { - logger.V(1).Info("No user ConfigMap specified, skipping") + if !instance.HasOverrideConfig() { + logger.V(1).Info("No override ConfigMap specified, skipping") return nil } - // Determine the ConfigMap namespace - default to the same namespace as the LlamaStackDistribution. - configMapNamespace := r.getUserConfigMapNamespace(instance) + configMapNamespace := instance.Namespace - logger.V(1).Info("Validating referenced ConfigMap exists", - "configMapName", instance.Spec.Server.UserConfig.ConfigMapName, + logger.V(1).Info("Validating referenced override ConfigMap exists", + "configMapName", instance.Spec.OverrideConfig.Name, + "configMapKey", instance.Spec.OverrideConfig.Key, "configMapNamespace", configMapNamespace) - // Read via direct client — user ConfigMaps lack operator labels configMap := &corev1.ConfigMap{} - err := r.directGet(ctx, types.NamespacedName{ - Name: instance.Spec.Server.UserConfig.ConfigMapName, + err := r.Get(ctx, types.NamespacedName{ + Name: instance.Spec.OverrideConfig.Name, Namespace: configMapNamespace, }, configMap) if err != nil { if k8serrors.IsNotFound(err) { - logger.Error(err, "Referenced ConfigMap not found", - "configMapName", instance.Spec.Server.UserConfig.ConfigMapName, + logger.Error(err, "Referenced override ConfigMap not found", + "configMapName", instance.Spec.OverrideConfig.Name, "configMapNamespace", configMapNamespace) - return fmt.Errorf("failed to find referenced ConfigMap %s/%s", configMapNamespace, instance.Spec.Server.UserConfig.ConfigMapName) + return fmt.Errorf("failed to find referenced ConfigMap %s/%s", configMapNamespace, instance.Spec.OverrideConfig.Name) } - return fmt.Errorf("failed to fetch ConfigMap %s/%s: %w", configMapNamespace, instance.Spec.Server.UserConfig.ConfigMapName, err) + return fmt.Errorf("failed to fetch ConfigMap %s/%s: %w", configMapNamespace, instance.Spec.OverrideConfig.Name, err) + } + if _, exists := configMap.Data[instance.Spec.OverrideConfig.Key]; !exists { + return fmt.Errorf( + "failed to find override ConfigMap key '%s' in ConfigMap %s/%s", + instance.Spec.OverrideConfig.Key, + configMapNamespace, + instance.Spec.OverrideConfig.Name, + ) } - logger.V(1).Info("User ConfigMap found and validated", + logger.V(1).Info("Override ConfigMap found and validated", "configMap", configMap.Name, "namespace", configMap.Namespace, + "key", instance.Spec.OverrideConfig.Key, "dataKeys", len(configMap.Data)) return nil } -// reconcileCABundleConfigMap validates that the referenced CA bundle ConfigMap exists. -func (r *LlamaStackDistributionReconciler) reconcileCABundleConfigMap(ctx context.Context, instance *llamav1alpha1.LlamaStackDistribution) error { +// reconcileBaseConfigMap validates that the referenced declarative base ConfigMap exists. +func (r *OGXServerReconciler) reconcileBaseConfigMap(ctx context.Context, instance *ogxiov1beta1.OGXServer) error { logger := log.FromContext(ctx) - if !r.hasCABundleConfigMap(instance) { - logger.V(1).Info("No CA bundle ConfigMap specified, skipping") + if instance.Spec.BaseConfig == nil { + logger.V(1).Info("No base ConfigMap specified, skipping") return nil } - // Determine the ConfigMap namespace - default to the same namespace as the LlamaStackDistribution. - configMapNamespace := r.getCABundleConfigMapNamespace(instance) - - logger.V(1).Info("Validating referenced CA bundle ConfigMap exists", - "configMapName", instance.Spec.Server.TLSConfig.CABundle.ConfigMapName, - "configMapNamespace", configMapNamespace) + ref := instance.Spec.BaseConfig + logger.V(1).Info("Validating referenced base ConfigMap exists", + "configMapName", ref.Name, + "configMapKey", ref.Key, + "configMapNamespace", instance.Namespace) - // Read via direct client — user CA bundle ConfigMaps lack operator labels - configMap := &corev1.ConfigMap{} - err := r.directGet(ctx, types.NamespacedName{ - Name: instance.Spec.Server.TLSConfig.CABundle.ConfigMapName, - Namespace: configMapNamespace, - }, configMap) - if err != nil { + if _, err := r.readReferencedConfigMapKey(ctx, instance.Namespace, *ref); err != nil { if k8serrors.IsNotFound(err) { - logger.Error(err, "Referenced CA bundle ConfigMap not found", - "configMapName", instance.Spec.Server.TLSConfig.CABundle.ConfigMapName, - "configMapNamespace", configMapNamespace) - return fmt.Errorf("failed to find referenced CA bundle ConfigMap %s/%s", configMapNamespace, instance.Spec.Server.TLSConfig.CABundle.ConfigMapName) + logger.Error(err, "Referenced base ConfigMap not found", + "configMapName", ref.Name, + "configMapNamespace", instance.Namespace) + return fmt.Errorf("failed to find referenced base ConfigMap %s/%s", instance.Namespace, ref.Name) } - return fmt.Errorf("failed to fetch CA bundle ConfigMap %s/%s: %w", configMapNamespace, instance.Spec.Server.TLSConfig.CABundle.ConfigMapName, err) + if errors.Is(err, errReferencedConfigMapKeyNotFound) { + return fmt.Errorf("failed to find base ConfigMap key '%s' in ConfigMap %s/%s", ref.Key, instance.Namespace, ref.Name) + } + return fmt.Errorf("failed to fetch base ConfigMap %s/%s: %w", instance.Namespace, ref.Name, err) } - // Validate that the specified keys exist in the ConfigMap - var keysToValidate []string - if len(instance.Spec.Server.TLSConfig.CABundle.ConfigMapKeys) > 0 { - keysToValidate = instance.Spec.Server.TLSConfig.CABundle.ConfigMapKeys - } else { - // Default to DefaultCABundleKey when no keys are specified - keysToValidate = []string{DefaultCABundleKey} + logger.V(1).Info("Base ConfigMap found and validated", + "configMap", ref.Name, + "namespace", instance.Namespace, + "key", ref.Key) + return nil +} + +// reconcileCABundleConfigMap validates that referenced CA certificate ConfigMaps exist. +func (r *OGXServerReconciler) reconcileCABundleConfigMap(ctx context.Context, instance *ogxiov1beta1.OGXServer) error { + logger := log.FromContext(ctx) + + if !r.hasCACertificates(instance) { + logger.V(1).Info("No CA certificates specified, skipping") + return nil } - for _, key := range keysToValidate { - if _, exists := configMap.Data[key]; !exists { - logger.Error(err, "CA bundle key not found in ConfigMap", - "configMapName", instance.Spec.Server.TLSConfig.CABundle.ConfigMapName, - "configMapNamespace", configMapNamespace, - "key", key) - return fmt.Errorf("failed to find CA bundle key '%s' in ConfigMap %s/%s", key, configMapNamespace, instance.Spec.Server.TLSConfig.CABundle.ConfigMapName) + for _, ref := range instance.Spec.TLS.Trust.CACertificates { + logger.V(1).Info("Validating referenced CA certificate ConfigMap exists", + "configMapName", ref.Name, + "configMapKey", ref.Key, + "configMapNamespace", instance.Namespace) + + configMap := &corev1.ConfigMap{} + err := r.Get(ctx, types.NamespacedName{ + Name: ref.Name, + Namespace: instance.Namespace, + }, configMap) + if err != nil { + if k8serrors.IsNotFound(err) { + logger.Error(err, "Referenced CA certificate ConfigMap not found", + "configMapName", ref.Name, + "configMapNamespace", instance.Namespace) + return fmt.Errorf("failed to find referenced CA certificate ConfigMap %s/%s", instance.Namespace, ref.Name) + } + return fmt.Errorf("failed to fetch CA certificate ConfigMap %s/%s: %w", instance.Namespace, ref.Name, err) } - // Note: Detailed PEM validation is performed later - // in extractValidCertificates() which validates all PEM blocks. - logger.V(1).Info("CA bundle key found", - "configMapName", instance.Spec.Server.TLSConfig.CABundle.ConfigMapName, - "configMapNamespace", configMapNamespace, - "key", key) + if _, exists := configMap.Data[ref.Key]; !exists { + errMissing := fmt.Errorf("failed to find CA certificate key %q in ConfigMap", ref.Key) + logger.Error(errMissing, "CA certificate key not found in ConfigMap", + "configMapName", ref.Name, + "configMapNamespace", instance.Namespace, + "key", ref.Key) + return fmt.Errorf("failed to find CA certificate key '%s' in ConfigMap %s/%s", ref.Key, instance.Namespace, ref.Name) + } + + logger.V(1).Info("CA certificate ConfigMap key found", + "configMapName", ref.Name, + "configMapNamespace", instance.Namespace, + "key", ref.Key) } - logger.V(1).Info("CA bundle ConfigMap found and validated", - "configMap", configMap.Name, - "namespace", configMap.Namespace, - "keys", keysToValidate, - "dataKeys", len(configMap.Data)) + logger.V(1).Info("All CA certificate ConfigMaps validated", + "count", len(instance.Spec.TLS.Trust.CACertificates)) return nil } // getConfigMapHash calculates a hash of the ConfigMap data to detect changes. -func (r *LlamaStackDistributionReconciler) getConfigMapHash(ctx context.Context, instance *llamav1alpha1.LlamaStackDistribution) (string, error) { - if !r.hasUserConfigMap(instance) { +func (r *OGXServerReconciler) getConfigMapHash(ctx context.Context, instance *ogxiov1beta1.OGXServer) (string, error) { + if !instance.HasOverrideConfig() { return "", nil } - configMapNamespace := r.getUserConfigMapNamespace(instance) + configMapNamespace := instance.Namespace configMap := &corev1.ConfigMap{} - err := r.directGet(ctx, types.NamespacedName{ - Name: instance.Spec.Server.UserConfig.ConfigMapName, + err := r.Get(ctx, types.NamespacedName{ + Name: instance.Spec.OverrideConfig.Name, Namespace: configMapNamespace, }, configMap) if err != nil { @@ -1093,9 +1369,9 @@ func (r *LlamaStackDistributionReconciler) getConfigMapHash(ctx context.Context, } // getCABundleConfigMapHash calculates a hash of the managed CA bundle ConfigMap to detect changes. -func (r *LlamaStackDistributionReconciler) getCABundleConfigMapHash(ctx context.Context, instance *llamav1alpha1.LlamaStackDistribution) (string, error) { +func (r *OGXServerReconciler) getCABundleConfigMapHash(ctx context.Context, instance *ogxiov1beta1.OGXServer) (string, error) { // Check if any CA bundles are configured - if !r.hasCABundleConfigMap(instance) && !r.hasODHTrustedCABundle(ctx, instance) { + if !r.hasCACertificates(instance) && !r.hasODHTrustedCABundle(ctx, instance) { return "", nil } @@ -1119,7 +1395,7 @@ func (r *LlamaStackDistributionReconciler) getCABundleConfigMapHash(ctx context. } // hasODHTrustedCABundle checks if the ODH trusted CA bundle ConfigMap exists and has valid keys. -func (r *LlamaStackDistributionReconciler) hasODHTrustedCABundle(ctx context.Context, instance *llamav1alpha1.LlamaStackDistribution) bool { +func (r *OGXServerReconciler) hasODHTrustedCABundle(ctx context.Context, instance *ogxiov1beta1.OGXServer) bool { _, keys, err := r.detectODHTrustedCABundle(ctx, instance) return err == nil && len(keys) > 0 } @@ -1129,7 +1405,7 @@ func (r *LlamaStackDistributionReconciler) hasODHTrustedCABundle(ctx context.Con // - Validates PEM structure and X.509 certificate format during processing. // - Enforces size limits to prevent resource exhaustion. // - Only extracts valid CERTIFICATE blocks using PEM decoder and X.509 parser. -func (r *LlamaStackDistributionReconciler) gatherCABundleData(ctx context.Context, instance *llamav1alpha1.LlamaStackDistribution) (string, error) { +func (r *OGXServerReconciler) gatherCABundleData(ctx context.Context, instance *ogxiov1beta1.OGXServer) (string, error) { logger := log.FromContext(ctx) collector := &certificateCollector{logger: logger} @@ -1196,31 +1472,31 @@ func (c *certificateCollector) concatenate() (string, error) { return concatenated, nil } -func (r *LlamaStackDistributionReconciler) gatherExplicitCABundle(ctx context.Context, instance *llamav1alpha1.LlamaStackDistribution, collector *certificateCollector) error { - if !r.hasCABundleConfigMap(instance) { +func (r *OGXServerReconciler) gatherExplicitCABundle(ctx context.Context, instance *ogxiov1beta1.OGXServer, collector *certificateCollector) error { + if !r.hasCACertificates(instance) { return nil } - configMapNamespace := r.getCABundleConfigMapNamespace(instance) - configMap := &corev1.ConfigMap{} - err := r.directGet(ctx, types.NamespacedName{ - Name: instance.Spec.Server.TLSConfig.CABundle.ConfigMapName, - Namespace: configMapNamespace, - }, configMap) - if err != nil { - return fmt.Errorf("failed to get CA bundle ConfigMap %s/%s: %w", - configMapNamespace, instance.Spec.Server.TLSConfig.CABundle.ConfigMapName, err) - } + for _, ref := range instance.Spec.TLS.Trust.CACertificates { + configMap := &corev1.ConfigMap{} + err := r.Get(ctx, types.NamespacedName{ + Name: ref.Name, + Namespace: instance.Namespace, + }, configMap) + if err != nil { + return fmt.Errorf("failed to get CA certificate ConfigMap %s/%s: %w", + instance.Namespace, ref.Name, err) + } - keysToProcess := instance.Spec.Server.TLSConfig.CABundle.ConfigMapKeys - if len(keysToProcess) == 0 { - keysToProcess = []string{DefaultCABundleKey} + if err := r.processConfigMapKeys(configMap, []string{ref.Key}, instance.Namespace, ref.Name, collector); err != nil { + return err + } } - return r.processConfigMapKeys(configMap, keysToProcess, configMapNamespace, instance.Spec.Server.TLSConfig.CABundle.ConfigMapName, collector) + return nil } -func (r *LlamaStackDistributionReconciler) gatherODHCABundle(ctx context.Context, instance *llamav1alpha1.LlamaStackDistribution, collector *certificateCollector) error { +func (r *OGXServerReconciler) gatherODHCABundle(ctx context.Context, instance *ogxiov1beta1.OGXServer, collector *certificateCollector) error { configMap, keys, err := r.detectODHTrustedCABundle(ctx, instance) if err != nil { // Log but don't fail - ODH bundle is optional @@ -1234,7 +1510,7 @@ func (r *LlamaStackDistributionReconciler) gatherODHCABundle(ctx context.Context return r.processODHConfigMapKeys(configMap, keys, collector) } -func (r *LlamaStackDistributionReconciler) processConfigMapKeys(configMap *corev1.ConfigMap, keys []string, namespace, name string, collector *certificateCollector) error { +func (r *OGXServerReconciler) processConfigMapKeys(configMap *corev1.ConfigMap, keys []string, namespace, name string, collector *certificateCollector) error { for _, key := range keys { data, exists := configMap.Data[key] if !exists { @@ -1254,7 +1530,7 @@ func (r *LlamaStackDistributionReconciler) processConfigMapKeys(configMap *corev return nil } -func (r *LlamaStackDistributionReconciler) processODHConfigMapKeys(configMap *corev1.ConfigMap, keys []string, collector *certificateCollector) error { +func (r *OGXServerReconciler) processODHConfigMapKeys(configMap *corev1.ConfigMap, keys []string, collector *certificateCollector) error { for _, key := range keys { data, exists := configMap.Data[key] if !exists { @@ -1332,7 +1608,7 @@ func extractValidCertificates(data []byte, keyName string) ([]string, int, int, } // reconcileManagedCABundleConfigMap creates or updates the managed CA bundle ConfigMap. -func (r *LlamaStackDistributionReconciler) reconcileManagedCABundleConfigMap(ctx context.Context, instance *llamav1alpha1.LlamaStackDistribution) error { +func (r *OGXServerReconciler) reconcileManagedCABundleConfigMap(ctx context.Context, instance *ogxiov1beta1.OGXServer) error { logger := log.FromContext(ctx) // Gather all CA certificate data @@ -1360,10 +1636,10 @@ func (r *LlamaStackDistributionReconciler) reconcileManagedCABundleConfigMap(ctx Name: managedConfigMapName, Namespace: instance.Namespace, Labels: map[string]string{ - "app.kubernetes.io/managed-by": "llama-stack-operator", - "app.kubernetes.io/instance": instance.Name, - "app.kubernetes.io/component": "ca-bundle", - WatchLabelKey: WatchLabelValue, + managedByLabelKey: managedByLabelVal, + "app.kubernetes.io/instance": instance.Name, + "app.kubernetes.io/component": "ca-bundle", + WatchLabelKey: WatchLabelValue, }, }, Data: map[string]string{ @@ -1371,7 +1647,7 @@ func (r *LlamaStackDistributionReconciler) reconcileManagedCABundleConfigMap(ctx }, } - // Set owner reference so the ConfigMap is deleted when the LlamaStackDistribution is deleted + // Set owner reference so the ConfigMap is deleted when the OGXServer is deleted if refErr := ctrl.SetControllerReference(instance, desiredConfigMap, r.Scheme); refErr != nil { return fmt.Errorf("failed to set controller reference on managed CA bundle ConfigMap: %w", refErr) } @@ -1408,13 +1684,13 @@ func (r *LlamaStackDistributionReconciler) reconcileManagedCABundleConfigMap(ctx } // detectODHTrustedCABundle checks if the well-known ODH trusted CA bundle ConfigMap -// exists in the same namespace as the LlamaStackDistribution and returns its available keys. +// exists in the same namespace as the OGXServer and returns its available keys. // Returns the ConfigMap and a list of data keys if found, or nil and empty slice if not found. -func (r *LlamaStackDistributionReconciler) detectODHTrustedCABundle(ctx context.Context, instance *llamav1alpha1.LlamaStackDistribution) (*corev1.ConfigMap, []string, error) { +func (r *OGXServerReconciler) detectODHTrustedCABundle(ctx context.Context, instance *ogxiov1beta1.OGXServer) (*corev1.ConfigMap, []string, error) { logger := log.FromContext(ctx) configMap := &corev1.ConfigMap{} - err := r.directGet(ctx, types.NamespacedName{ + err := r.Get(ctx, types.NamespacedName{ Name: odhTrustedCABundleConfigMap, Namespace: instance.Namespace, }, configMap) @@ -1451,88 +1727,26 @@ func (r *LlamaStackDistributionReconciler) detectODHTrustedCABundle(ctx context. return configMap, keys, nil } -// createDefaultConfigMap creates a ConfigMap with default feature flag values. -func createDefaultConfigMap(configMapName types.NamespacedName) (*corev1.ConfigMap, error) { - featureFlags := featureflags.FeatureFlags{ - EnableNetworkPolicy: featureflags.FeatureFlag{ - Enabled: featureflags.NetworkPolicyDefaultValue, - }, - } +// NewOGXServerReconciler creates a new reconciler with default image mappings. +func NewOGXServerReconciler(cachedClient client.Client, scheme *runtime.Scheme, + clusterInfo *cluster.ClusterInfo, imageMappingOverrides map[string]string, + operatorNamespace string) *OGXServerReconciler { + ociLabelFetcher := config.NewOCILabelFetcher() - featureFlagsYAML, err := yaml.Marshal(featureFlags) - if err != nil { - return nil, fmt.Errorf("failed to marshal default feature flags: %w", err) - } - - return &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: configMapName.Name, - Namespace: configMapName.Namespace, - Labels: map[string]string{ - WatchLabelKey: WatchLabelValue, - }, - }, - Data: map[string]string{ - featureflags.FeatureFlagsKey: string(featureFlagsYAML), - }, - }, nil -} - -// parseFeatureFlags extracts and parses feature flags from ConfigMap data. -func parseFeatureFlags(configMapData map[string]string) (bool, error) { - enableNetworkPolicy := featureflags.NetworkPolicyDefaultValue - - featureFlagsYAML, exists := configMapData[featureflags.FeatureFlagsKey] - if !exists { - return enableNetworkPolicy, nil - } - - var flags featureflags.FeatureFlags - if err := yaml.Unmarshal([]byte(featureFlagsYAML), &flags); err != nil { - return false, fmt.Errorf("failed to parse feature flags: %w", err) - } - - return flags.EnableNetworkPolicy.Enabled, nil -} - -// NewLlamaStackDistributionReconciler creates a new reconciler with default image mappings. -func NewLlamaStackDistributionReconciler(ctx context.Context, client client.Client, scheme *runtime.Scheme, - clusterInfo *cluster.ClusterInfo, directClient client.Reader) (*LlamaStackDistributionReconciler, error) { - // get operator namespace - operatorNamespace, err := deploy.GetOperatorNamespace() - if err != nil { - return nil, fmt.Errorf("failed to get operator namespace: %w", err) - } - - // Initialize operator config ConfigMap - configMap, err := initializeOperatorConfigMap(ctx, client, operatorNamespace) - if err != nil { - return nil, err - } - - // Parse feature flags from ConfigMap - enableNetworkPolicy, err := parseFeatureFlags(configMap.Data) - if err != nil { - return nil, fmt.Errorf("failed to parse feature flags: %w", err) - } - - // Parse image mapping overrides from ConfigMap - imageMappingOverrides := ParseImageMappingOverrides(ctx, configMap.Data) - - return &LlamaStackDistributionReconciler{ - Client: client, + return &OGXServerReconciler{ + Client: cachedClient, Scheme: scheme, - DirectClient: directClient, - EnableNetworkPolicy: enableNetworkPolicy, ImageMappingOverrides: imageMappingOverrides, ClusterInfo: clusterInfo, httpClient: &http.Client{Timeout: 5 * time.Second}, + OCILabelFetcher: ociLabelFetcher, + configResolver: config.NewDefaultConfigResolver(ociLabelFetcher), operatorNamespace: operatorNamespace, - }, nil + } } -// initializeOperatorConfigMap gets or creates the operator config ConfigMap. -func initializeOperatorConfigMap(ctx context.Context, c client.Client, operatorNamespace string) (*corev1.ConfigMap, error) { +// InitializeOperatorConfigMap gets or creates the operator config ConfigMap. +func InitializeOperatorConfigMap(ctx context.Context, c client.Client, operatorNamespace string) (*corev1.ConfigMap, error) { configMap := &corev1.ConfigMap{} configMapName := types.NamespacedName{ Name: operatorConfigData, @@ -1541,7 +1755,6 @@ func initializeOperatorConfigMap(ctx context.Context, c client.Client, operatorN err := c.Get(ctx, configMapName, configMap) if err == nil { - // Ensure the watch label exists (upgrade path) if configMap.Labels == nil || configMap.Labels[WatchLabelKey] != WatchLabelValue { if configMap.Labels == nil { configMap.Labels = make(map[string]string) @@ -1558,10 +1771,15 @@ func initializeOperatorConfigMap(ctx context.Context, c client.Client, operatorN return nil, fmt.Errorf("failed to get ConfigMap: %w", err) } - // ConfigMap doesn't exist, create it with defaults - configMap, err = createDefaultConfigMap(configMapName) - if err != nil { - return nil, fmt.Errorf("failed to generate default configMap: %w", err) + configMap = &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: configMapName.Name, + Namespace: configMapName.Namespace, + Labels: map[string]string{ + WatchLabelKey: WatchLabelValue, + }, + }, + Data: map[string]string{}, } if err = c.Create(ctx, configMap); err != nil { @@ -1604,25 +1822,24 @@ func ParseImageMappingOverrides(ctx context.Context, configMapData map[string]st return imageMappingOverrides } -// NewTestReconciler creates a reconciler for testing, allowing injection of a custom http client and feature flags. +// NewTestReconciler creates a reconciler for testing, allowing injection of a custom http client. func NewTestReconciler(client client.Client, scheme *runtime.Scheme, clusterInfo *cluster.ClusterInfo, - httpClient *http.Client, enableNetworkPolicy bool) *LlamaStackDistributionReconciler { - return &LlamaStackDistributionReconciler{ + httpClient *http.Client) *OGXServerReconciler { + return &OGXServerReconciler{ Client: client, Scheme: scheme, ClusterInfo: clusterInfo, httpClient: httpClient, - EnableNetworkPolicy: enableNetworkPolicy, ImageMappingOverrides: make(map[string]string), } } // MapConfigMapToReconcileRequests is an exported wrapper for mapConfigMapToReconcileRequests, for testing. -func (r *LlamaStackDistributionReconciler) MapConfigMapToReconcileRequests(ctx context.Context, obj client.Object) []reconcile.Request { +func (r *OGXServerReconciler) MapConfigMapToReconcileRequests(ctx context.Context, obj client.Object) []reconcile.Request { return r.mapConfigMapToReconcileRequests(ctx, obj) } // UserConfigMapPredicate is an exported wrapper for userConfigMapPredicate, for testing. -func (r *LlamaStackDistributionReconciler) UserConfigMapPredicate() predicate.Funcs { +func (r *OGXServerReconciler) UserConfigMapPredicate() predicate.Funcs { return r.userConfigMapPredicate() } diff --git a/controllers/llamastackdistribution_controller_ca_whitespace_test.go b/controllers/ogxserver_controller_ca_whitespace_test.go similarity index 100% rename from controllers/llamastackdistribution_controller_ca_whitespace_test.go rename to controllers/ogxserver_controller_ca_whitespace_test.go diff --git a/controllers/llamastackdistribution_controller_test.go b/controllers/ogxserver_controller_test.go similarity index 74% rename from controllers/llamastackdistribution_controller_test.go rename to controllers/ogxserver_controller_test.go index 6140d7a58..dcba257b7 100644 --- a/controllers/llamastackdistribution_controller_test.go +++ b/controllers/ogxserver_controller_test.go @@ -9,9 +9,9 @@ import ( "testing" "time" - llamav1alpha1 "github.com/llamastack/llama-stack-k8s-operator/api/v1alpha1" - controllers "github.com/llamastack/llama-stack-k8s-operator/controllers" - "github.com/llamastack/llama-stack-k8s-operator/pkg/cluster" + ogxiov1beta1 "github.com/ogx-ai/ogx-k8s-operator/api/v1beta1" + controllers "github.com/ogx-ai/ogx-k8s-operator/controllers" + "github.com/ogx-ai/ogx-k8s-operator/pkg/cluster" "github.com/stretchr/testify/require" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" @@ -36,41 +36,41 @@ func TestStorageConfiguration(t *testing.T) { tests := []struct { name string - buildInstance func(namespace string) *llamav1alpha1.LlamaStackDistribution + buildInstance func(namespace string) *ogxiov1beta1.OGXServer expectedVolume corev1.Volume expectedMount corev1.VolumeMount }{ { name: "No storage configuration - should use emptyDir", - buildInstance: func(namespace string) *llamav1alpha1.LlamaStackDistribution { - return NewDistributionBuilder(). + buildInstance: func(namespace string) *ogxiov1beta1.OGXServer { + return NewOGXServerBuilder(). WithName("test"). WithNamespace(namespace). WithStorage(nil). Build() }, expectedVolume: corev1.Volume{ - Name: "lls-storage", + Name: testStorageVolumeName, VolumeSource: corev1.VolumeSource{ EmptyDir: &corev1.EmptyDirVolumeSource{}, }, }, expectedMount: corev1.VolumeMount{ - Name: "lls-storage", - MountPath: llamav1alpha1.DefaultMountPath, + Name: testStorageVolumeName, + MountPath: ogxiov1beta1.DefaultMountPath, }, }, { name: "Storage with default values", - buildInstance: func(namespace string) *llamav1alpha1.LlamaStackDistribution { - return NewDistributionBuilder(). + buildInstance: func(namespace string) *ogxiov1beta1.OGXServer { + return NewOGXServerBuilder(). WithName("test"). WithNamespace(namespace). WithStorage(DefaultTestStorage()). Build() }, expectedVolume: corev1.Volume{ - Name: "lls-storage", + Name: testStorageVolumeName, VolumeSource: corev1.VolumeSource{ PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ ClaimName: "test-pvc", @@ -78,21 +78,21 @@ func TestStorageConfiguration(t *testing.T) { }, }, expectedMount: corev1.VolumeMount{ - Name: "lls-storage", - MountPath: llamav1alpha1.DefaultMountPath, + Name: testStorageVolumeName, + MountPath: ogxiov1beta1.DefaultMountPath, }, }, { name: "Storage with custom values", - buildInstance: func(namespace string) *llamav1alpha1.LlamaStackDistribution { - return NewDistributionBuilder(). + buildInstance: func(namespace string) *ogxiov1beta1.OGXServer { + return NewOGXServerBuilder(). WithName("test"). WithNamespace(namespace). WithStorage(CustomTestStorage("20Gi", "/custom/path")). Build() }, expectedVolume: corev1.Volume{ - Name: "lls-storage", + Name: testStorageVolumeName, VolumeSource: corev1.VolumeSource{ PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ ClaimName: "test-pvc", @@ -100,7 +100,7 @@ func TestStorageConfiguration(t *testing.T) { }, }, expectedMount: corev1.VolumeMount{ - Name: "lls-storage", + Name: testStorageVolumeName, MountPath: "/custom/path", }, }, @@ -115,12 +115,12 @@ func TestStorageConfiguration(t *testing.T) { require.NoError(t, k8sClient.Create(t.Context(), instance)) t.Cleanup(func() { if err := k8sClient.Delete(t.Context(), instance); err != nil && !apierrors.IsNotFound(err) { - t.Logf("Failed to delete LlamaStackDistribution instance %s/%s: %v", instance.Namespace, instance.Name, err) + t.Logf("Failed to delete OGXServer instance %s/%s: %v", instance.Namespace, instance.Name, err) } }) // act: reconcile the instance - ReconcileDistribution(t, instance, false) + ReconcileOGXServer(t, instance) // assert deployment := &appsv1.Deployment{} @@ -135,12 +135,12 @@ func TestStorageConfiguration(t *testing.T) { AssertDeploymentHasVolumeMount(t, deployment, tt.expectedMount.MountPath) // verify PVC is created when storage is configured - if instance.Spec.Server.Storage != nil { + if instance.Spec.Workload != nil && instance.Spec.Workload.Storage != nil { expectedPVCName := tt.expectedVolume.PersistentVolumeClaim.ClaimName pvc := AssertPVCExists(t, k8sClient, instance.Namespace, expectedPVCName) - expectedSize := instance.Spec.Server.Storage.Size + expectedSize := instance.Spec.Workload.Storage.Size if expectedSize == nil { - AssertPVCHasSize(t, pvc, llamav1alpha1.DefaultStorageSize.String()) + AssertPVCHasSize(t, pvc, ogxiov1beta1.DefaultStorageSize.String()) } else { AssertPVCHasSize(t, pvc, expectedSize.String()) } @@ -149,12 +149,74 @@ func TestStorageConfiguration(t *testing.T) { } } +func TestDefaultPVCLifecycle(t *testing.T) { + ctrl.SetLogger(zap.New(zap.UseDevMode(true))) + + // --- arrange --- + namespace := createTestNamespace(t, "pvc-lifecycle") + pvcName := "pvc-test-pvc" + pvcKey := types.NamespacedName{Name: pvcName, Namespace: namespace.Name} + crKey := types.NamespacedName{Name: "pvc-test", Namespace: namespace.Name} + + // --- act: create OGXServer with storage --- + instance := NewOGXServerBuilder(). + WithName("pvc-test"). + WithNamespace(namespace.Name). + WithStorage(DefaultTestStorage()). + Build() + require.NoError(t, k8sClient.Create(t.Context(), instance)) + + ReconcileOGXServer(t, instance) + + // --- assert: PVC created without ownerRef, Deployment references it --- + pvc := AssertPVCExists(t, k8sClient, namespace.Name, pvcName) + require.Nil(t, metav1.GetControllerOf(pvc), + "Default PVC should not have a controller ownerRef") + + deployment := &appsv1.Deployment{} + waitForResource(t, k8sClient, namespace.Name, "pvc-test", deployment) + AssertDeploymentUsesPVCStorage(t, deployment, pvcName) + + // --- act: delete OGXServer --- + require.NoError(t, k8sClient.Delete(t.Context(), instance)) + require.Eventually(t, func() bool { + return apierrors.IsNotFound(k8sClient.Get(t.Context(), crKey, &ogxiov1beta1.OGXServer{})) + }, testTimeout, testInterval, "OGXServer should be deleted") + + // --- assert: PVC survives CR deletion --- + require.NoError(t, k8sClient.Get(t.Context(), pvcKey, pvc), + "PVC must survive OGXServer deletion") + + // --- act: recreate OGXServer with same name --- + instance = NewOGXServerBuilder(). + WithName("pvc-test"). + WithNamespace(namespace.Name). + WithStorage(DefaultTestStorage()). + Build() + require.NoError(t, k8sClient.Create(t.Context(), instance)) + t.Cleanup(func() { + if err := k8sClient.Delete(t.Context(), instance); err != nil && !apierrors.IsNotFound(err) { + t.Logf("Cleanup: %v", err) + } + }) + + ReconcileOGXServer(t, instance) + + // --- assert: PVC reused, Deployment references it --- + require.NoError(t, k8sClient.Get(t.Context(), pvcKey, pvc), + "PVC must still exist after recreate") + require.Nil(t, metav1.GetControllerOf(pvc), + "Reused PVC should still not have a controller ownerRef") + + require.NoError(t, k8sClient.Get(t.Context(), crKey, deployment)) + AssertDeploymentUsesPVCStorage(t, deployment, pvcName) +} + func TestConfigMapWatchingFunctionality(t *testing.T) { ctrl.SetLogger(zap.New(zap.UseDevMode(true))) // Create a test namespace namespace := createTestNamespace(t, "test-configmap-watch") - // Create a ConfigMap configMap := &corev1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ @@ -182,16 +244,16 @@ server: } require.NoError(t, k8sClient.Create(t.Context(), configMap)) - // Create a LlamaStackDistribution that references the ConfigMap - instance := NewDistributionBuilder(). + // Create an OGXServer that references the ConfigMap + instance := NewOGXServerBuilder(). WithName("test-configmap-reference"). WithNamespace(namespace.Name). - WithUserConfig(configMap.Name). + WithOverrideConfig(configMap.Name, "config.yaml"). Build() require.NoError(t, k8sClient.Create(t.Context(), instance)) // Reconcile to create initial deployment - ReconcileDistribution(t, instance, false) + ReconcileOGXServer(t, instance) // Get the initial deployment and check for ConfigMap hash annotation deployment := &appsv1.Deployment{} @@ -230,7 +292,7 @@ server: time.Sleep(2 * time.Second) // Trigger reconciliation (in real scenarios this would be triggered by the watch) - ReconcileDistribution(t, instance, false) + ReconcileOGXServer(t, instance) // Verify the deployment was updated with a new hash waitForResourceWithKeyAndCondition( @@ -261,14 +323,14 @@ func TestReconcile(t *testing.T) { ctrl.SetLogger(zap.New(zap.UseDevMode(true))) // --- arrange --- - instanceName := "llamastackdistribution-sample" - instancePort := llamav1alpha1.DefaultServerPort + instanceName := "ogxserver-sample" + instancePort := ogxiov1beta1.DefaultServerPort expectedSelector := map[string]string{ - llamav1alpha1.DefaultLabelKey: llamav1alpha1.DefaultLabelValue, - "app.kubernetes.io/instance": instanceName, + ogxiov1beta1.DefaultLabelKey: ogxiov1beta1.DefaultLabelValue, + "app.kubernetes.io/instance": instanceName, } expectedPort := corev1.ServicePort{ - Name: llamav1alpha1.DefaultServicePortName, + Name: ogxiov1beta1.DefaultServicePortName, Port: instancePort, TargetPort: intstr.FromInt(int(instancePort)), Protocol: corev1.ProtocolTCP, @@ -279,7 +341,7 @@ func TestReconcile(t *testing.T) { t.Setenv("OPERATOR_NAMESPACE", operatorNamespaceName) namespace := createTestNamespace(t, operatorNamespaceName) - instance := NewDistributionBuilder(). + instance := NewOGXServerBuilder(). WithName(instanceName). WithNamespace(namespace.Name). WithDistribution("starter"). @@ -288,7 +350,7 @@ func TestReconcile(t *testing.T) { require.NoError(t, k8sClient.Create(t.Context(), instance)) // --- act --- - ReconcileDistribution(t, instance, true) + ReconcileOGXServer(t, instance) service := &corev1.Service{} waitForResource(t, k8sClient, instance.Namespace, instance.Name+"-service", service) @@ -347,22 +409,21 @@ func newMockAPIResponse(t *testing.T, data any) *http.Response { } } -func TestLlamaStackProviderAndVersionInfo(t *testing.T) { +func TestOGXServerProviderAndVersionInfo(t *testing.T) { // arrange - enableNetworkPolicy := false - expectedLlamaStackVersionInfo := "v-test" + expectedServerVersion := "v-test" expectedProviderID := "mock-ollama" // define the data structure for the mock providers response providerData := struct { - Data []llamav1alpha1.ProviderInfo `json:"data"` + Data []ogxiov1beta1.ProviderInfo `json:"data"` }{ - Data: []llamav1alpha1.ProviderInfo{ + Data: []ogxiov1beta1.ProviderInfo{ { ProviderID: expectedProviderID, ProviderType: "remote::ollama", API: "inference", - Health: llamav1alpha1.ProviderHealthStatus{Status: "OK", Message: ""}, + Health: ogxiov1beta1.ProviderHealthStatus{Status: "OK", Message: ""}, Config: apiextensionsv1.JSON{Raw: []byte(`{"url": "http://mock.server"}`)}, }, }, @@ -372,7 +433,7 @@ func TestLlamaStackProviderAndVersionInfo(t *testing.T) { versionData := struct { Version string `json:"version"` }{ - Version: expectedLlamaStackVersionInfo, + Version: expectedServerVersion, } // create the mock http client that uses our custom roundtripper @@ -396,7 +457,7 @@ func TestLlamaStackProviderAndVersionInfo(t *testing.T) { } namespace := createTestNamespace(t, "test-status") - instance := NewDistributionBuilder(). + instance := NewOGXServerBuilder(). WithName("test-status-instance"). WithNamespace(namespace.Name). Build() @@ -404,7 +465,7 @@ func TestLlamaStackProviderAndVersionInfo(t *testing.T) { testClusterInfo := &cluster.ClusterInfo{ DistributionImages: map[string]string{ - "starter": "docker.io/llamastack/distribution-starter:latest", + "starter": testImage, }, } @@ -413,7 +474,6 @@ func TestLlamaStackProviderAndVersionInfo(t *testing.T) { scheme.Scheme, testClusterInfo, mockClient, - enableNetworkPolicy, ) // act (part 1) @@ -441,7 +501,7 @@ func TestLlamaStackProviderAndVersionInfo(t *testing.T) { require.NoError(t, err) // assert - updatedInstance := &llamav1alpha1.LlamaStackDistribution{} + updatedInstance := &ogxiov1beta1.OGXServer{} waitForResource(t, k8sClient, namespace.Name, instance.Name, updatedInstance) // validate provider info @@ -451,13 +511,13 @@ func TestLlamaStackProviderAndVersionInfo(t *testing.T) { require.Equal(t, "OK", actualProvider.Health.Status, "provider health should match the mock response") require.NotEmpty(t, actualProvider.Config, "provider config should be populated") // validate llama stack version - require.Equal(t, expectedLlamaStackVersionInfo, - updatedInstance.Status.Version.LlamaStackServerVersion, + require.Equal(t, expectedServerVersion, + updatedInstance.Status.Version.ServerVersion, "server version should match the mock response") // validate service URL expectedServiceURL := fmt.Sprintf("http://%s-service.%s.svc.cluster.local:%d", - instance.Name, instance.Namespace, llamav1alpha1.DefaultServerPort) + instance.Name, instance.Namespace, ogxiov1beta1.DefaultServerPort) require.Equal(t, expectedServiceURL, updatedInstance.Status.ServiceURL, "service URL should be set to the internal Kubernetes service URL") } @@ -466,22 +526,31 @@ func TestNetworkPolicyConfiguration(t *testing.T) { ctrl.SetLogger(zap.New(zap.UseDevMode(true))) tests := []struct { - name string - setup func(t *testing.T, instance *llamav1alpha1.LlamaStackDistribution) + name string + disableNPBeforeCreate bool + setup func(t *testing.T, instance *ogxiov1beta1.OGXServer) }{ { - name: "enabled then disabled deletes NetworkPolicy", - setup: func(t *testing.T, instance *llamav1alpha1.LlamaStackDistribution) { + name: "enabled then disabled deletes NetworkPolicy", + disableNPBeforeCreate: false, + setup: func(t *testing.T, instance *ogxiov1beta1.OGXServer) { t.Helper() - // ensure NetworkPolicy exists by reconciling with feature enabled. - ReconcileDistribution(t, instance, true) + ReconcileOGXServer(t, instance) waitForResource(t, k8sClient, instance.Namespace, instance.Name+"-network-policy", &networkingv1.NetworkPolicy{}) + require.NoError(t, k8sClient.Get(t.Context(), types.NamespacedName{Name: instance.Name, Namespace: instance.Namespace}, instance)) + if instance.Spec.Network == nil { + instance.Spec.Network = &ogxiov1beta1.NetworkSpec{} + } + instance.Spec.Network.Policy = &ogxiov1beta1.NetworkPolicySpec{ + Enabled: boolPtr(false), + } + require.NoError(t, k8sClient.Update(t.Context(), instance)) }, }, { - name: "disabled from start leaves NetworkPolicy absent", - setup: func(t *testing.T, instance *llamav1alpha1.LlamaStackDistribution) { - // no setup needed - NetworkPolicy doesn't exist + name: "disabled from start leaves NetworkPolicy absent", + disableNPBeforeCreate: true, + setup: func(t *testing.T, instance *ogxiov1beta1.OGXServer) { t.Helper() }, }, @@ -489,116 +558,36 @@ func TestNetworkPolicyConfiguration(t *testing.T) { for i, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // --- arrange --- operatorNamespaceName := "test-operator-namespace" t.Setenv("OPERATOR_NAMESPACE", operatorNamespaceName) namespace := createTestNamespace(t, "test-networkpolicy") - instance := NewDistributionBuilder(). + instance := NewOGXServerBuilder(). WithName(fmt.Sprintf("np-config-%d", i)). WithNamespace(namespace.Name). WithDistribution("starter"). Build() + if tt.disableNPBeforeCreate { + if instance.Spec.Network == nil { + instance.Spec.Network = &ogxiov1beta1.NetworkSpec{} + } + instance.Spec.Network.Policy = &ogxiov1beta1.NetworkPolicySpec{ + Enabled: boolPtr(false), + } + } require.NoError(t, k8sClient.Create(t.Context(), instance)) t.Cleanup(func() { _ = k8sClient.Delete(t.Context(), instance) }) - // preconditions for this scenario tt.setup(t, instance) - // --- act --- - ReconcileDistribution(t, instance, false) + ReconcileOGXServer(t, instance) - // --- assert --- npKey := types.NamespacedName{Name: instance.Name + "-network-policy", Namespace: instance.Namespace} AssertNetworkPolicyAbsent(t, k8sClient, npKey) }) } } -// TestCABundleConfigMapKeyValidation tests that invalid ConfigMap keys are rejected during reconciliation. -func TestCABundleConfigMapKeyValidation(t *testing.T) { - ctrl.SetLogger(zap.New(zap.UseDevMode(true))) - - tests := []struct { - name string - configMapKeys []string - shouldFail bool - errorContains string - }{ - { - name: "valid ConfigMap keys", - configMapKeys: []string{"ca-bundle.crt", "root-ca.pem", "intermediate.crt"}, - shouldFail: false, - }, - { - name: "ConfigMap key with path traversal (..) should fail", - configMapKeys: []string{"../etc/passwd"}, - shouldFail: true, - errorContains: "invalid path characters", - }, - { - name: "ConfigMap key with forward slash should fail", - configMapKeys: []string{"path/to/cert.crt"}, - shouldFail: true, - errorContains: "invalid path characters", - }, - { - name: "ConfigMap key with double dots in middle should fail", - configMapKeys: []string{"ca..bundle.crt"}, - shouldFail: true, - errorContains: "invalid path characters", - }, - { - name: "empty ConfigMap key should fail", - configMapKeys: []string{""}, - shouldFail: true, - errorContains: "cannot be empty", - }, - { - name: "ConfigMap key with invalid characters should fail", - configMapKeys: []string{"ca bundle with spaces.crt"}, - shouldFail: true, - errorContains: "invalid characters", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // --- arrange --- - namespace := createTestNamespace(t, "test-cabundle-validation") - instance := NewDistributionBuilder(). - WithName("test-cabundle"). - WithNamespace(namespace.Name). - WithCABundle("test-ca-configmap", tt.configMapKeys). - Build() - - require.NoError(t, k8sClient.Create(t.Context(), instance)) - t.Cleanup(func() { _ = k8sClient.Delete(t.Context(), instance) }) - - // --- act --- - reconciler := createTestReconciler() - _, err := reconciler.Reconcile(t.Context(), ctrl.Request{ - NamespacedName: types.NamespacedName{ - Name: instance.Name, - Namespace: instance.Namespace, - }, - }) - - // --- assert --- - if tt.shouldFail { - require.Error(t, err, "reconciliation should fail for invalid ConfigMap keys") - require.Contains(t, err.Error(), tt.errorContains, - "error message should indicate the validation failure") - } else if err != nil { - // For valid keys, reconciliation might fail for other reasons (ConfigMap doesn't exist), - // but it should NOT fail due to key validation - require.NotContains(t, err.Error(), "failed to validate CA bundle ConfigMap keys", - "reconciliation should not fail due to key validation for valid keys") - } - }) - } -} - // TestManagedCABundleConfigMap tests that the operator creates and manages CA bundle ConfigMaps. func TestManagedCABundleConfigMap(t *testing.T) { ctrl.SetLogger(zap.New(zap.UseDevMode(true))) @@ -623,17 +612,20 @@ func TestManagedCABundleConfigMap(t *testing.T) { } require.NoError(t, k8sClient.Create(t.Context(), sourceConfigMap)) - instance := NewDistributionBuilder(). + instance := NewOGXServerBuilder(). WithName("test-managed"). WithNamespace(namespace.Name). - WithCABundle("source-ca-bundle", []string{"root-ca.crt", "intermediate.crt"}). + WithCACertificates( + ogxiov1beta1.ConfigMapKeyRef{Name: "source-ca-bundle", Key: "root-ca.crt"}, + ogxiov1beta1.ConfigMapKeyRef{Name: "source-ca-bundle", Key: "intermediate.crt"}, + ). Build() require.NoError(t, k8sClient.Create(t.Context(), instance)) t.Cleanup(func() { _ = k8sClient.Delete(t.Context(), instance) }) // --- act --- - ReconcileDistribution(t, instance, false) + ReconcileOGXServer(t, instance) // --- assert --- managedConfigMapName := instance.Name + "-ca-bundle" @@ -652,7 +644,7 @@ func TestManagedCABundleConfigMap(t *testing.T) { AssertResourceOwnedByInstance(t, managedConfigMap, instance) // Verify labels - require.Equal(t, "llama-stack-operator", managedConfigMap.Labels["app.kubernetes.io/managed-by"]) + require.Equal(t, "ogx-operator", managedConfigMap.Labels["app.kubernetes.io/managed-by"]) require.Equal(t, instance.Name, managedConfigMap.Labels["app.kubernetes.io/instance"]) require.Equal(t, "ca-bundle", managedConfigMap.Labels["app.kubernetes.io/component"]) require.Equal(t, controllers.WatchLabelValue, managedConfigMap.Labels[controllers.WatchLabelKey], @@ -677,16 +669,16 @@ func TestManagedCABundleConfigMap(t *testing.T) { } require.NoError(t, k8sClient.Create(t.Context(), sourceConfigMap)) - instance := NewDistributionBuilder(). + instance := NewOGXServerBuilder(). WithName("test-update"). WithNamespace(namespace.Name). - WithCABundle("source-ca-bundle", nil). + WithCACertificates(ogxiov1beta1.ConfigMapKeyRef{Name: "source-ca-bundle", Key: "ca-bundle.crt"}). Build() require.NoError(t, k8sClient.Create(t.Context(), instance)) t.Cleanup(func() { _ = k8sClient.Delete(t.Context(), instance) }) - ReconcileDistribution(t, instance, false) + ReconcileOGXServer(t, instance) managedConfigMapName := instance.Name + "-ca-bundle" managedConfigMap := &corev1.ConfigMap{} @@ -699,7 +691,7 @@ func TestManagedCABundleConfigMap(t *testing.T) { sourceConfigMap.Data["ca-bundle.crt"] = testCert + "\n" + testCert require.NoError(t, k8sClient.Update(t.Context(), sourceConfigMap)) - ReconcileDistribution(t, instance, false) + ReconcileOGXServer(t, instance) // --- assert --- require.NoError(t, k8sClient.Get(t.Context(), types.NamespacedName{ @@ -733,10 +725,10 @@ MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1234567890ABCDEF } require.NoError(t, k8sClient.Create(t.Context(), sourceConfigMap)) - instance := NewDistributionBuilder(). + instance := NewOGXServerBuilder(). WithName("test-reject"). WithNamespace(namespace.Name). - WithCABundle("source-with-key", nil). + WithCACertificates(ogxiov1beta1.ConfigMapKeyRef{Name: "source-with-key", Key: "ca-bundle.crt"}). Build() require.NoError(t, k8sClient.Create(t.Context(), instance)) @@ -776,10 +768,10 @@ InvalidCertificateDataThatIsNotValidX509 } require.NoError(t, k8sClient.Create(t.Context(), sourceConfigMap)) - instance := NewDistributionBuilder(). + instance := NewOGXServerBuilder(). WithName("test-reject-invalid"). WithNamespace(namespace.Name). - WithCABundle("source-with-invalid-cert", nil). + WithCACertificates(ogxiov1beta1.ConfigMapKeyRef{Name: "source-with-invalid-cert", Key: "ca-bundle.crt"}). Build() require.NoError(t, k8sClient.Create(t.Context(), instance)) @@ -806,7 +798,7 @@ func TestParseImageMappingOverrides_SingleOverride(t *testing.T) { // Test data with single override configMapData := map[string]string{ - "image-overrides": "starter: quay.io/custom/llama-stack:starter", + "image-overrides": "starter: quay.io/custom/ogx-server:starter", } // Call the function @@ -814,7 +806,7 @@ func TestParseImageMappingOverrides_SingleOverride(t *testing.T) { // Assertions require.Len(t, result, 1, "Should have exactly one override") - require.Equal(t, "quay.io/custom/llama-stack:starter", result["starter"], "Override should match expected value") + require.Equal(t, "quay.io/custom/ogx-server:starter", result["starter"], "Override should match expected value") } func TestParseImageMappingOverrides_InvalidYAML(t *testing.T) { @@ -860,23 +852,21 @@ onemore: registry.redhat.io/org/imagename@sha256:1234567890123456789012345678901 require.NotContains(t, result, "malformed", "Malformed entry should be skipped") } -func TestNewLlamaStackDistributionReconciler_WithImageOverrides(t *testing.T) { +func TestNewOGXServerReconciler_WithImageOverrides(t *testing.T) { ctrl.SetLogger(zap.New(zap.UseDevMode(true))) // Create operator namespace - operatorNamespace := createTestNamespace(t, "llama-stack-k8s-operator-system") + operatorNamespace := createTestNamespace(t, "ogx-k8s-operator-system") t.Setenv("OPERATOR_NAMESPACE", operatorNamespace.Name) // Create test ConfigMap with image overrides configMap := &corev1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ - Name: "llama-stack-operator-config", + Name: "ogx-operator-config", Namespace: operatorNamespace.Name, }, Data: map[string]string{ - "image-overrides": "starter: quay.io/custom/llama-stack:starter", - "featureFlags": `enableNetworkPolicy: - enabled: false`, + "image-overrides": "starter: quay.io/custom/ogx-server:starter", }, } require.NoError(t, k8sClient.Create(t.Context(), configMap)) @@ -888,21 +878,24 @@ func TestNewLlamaStackDistributionReconciler_WithImageOverrides(t *testing.T) { } // Call the function - reconciler, err := controllers.NewLlamaStackDistributionReconciler( - t.Context(), + operatorCM, err := controllers.InitializeOperatorConfigMap(t.Context(), k8sClient, operatorNamespace.Name) + require.NoError(t, err) + imageMappingOverrides := controllers.ParseImageMappingOverrides(t.Context(), operatorCM.Data) + + reconciler := controllers.NewOGXServerReconciler( k8sClient, scheme.Scheme, clusterInfo, - k8sClient, + imageMappingOverrides, + operatorNamespace.Name, ) // Assertions require.NoError(t, err, "Should create reconciler successfully") require.NotNil(t, reconciler, "Reconciler should not be nil") require.Len(t, reconciler.ImageMappingOverrides, 1, "Should have one image override") - require.Equal(t, "quay.io/custom/llama-stack:starter", + require.Equal(t, "quay.io/custom/ogx-server:starter", reconciler.ImageMappingOverrides["starter"], "Override should match expected value") - require.False(t, reconciler.EnableNetworkPolicy, "Network policy should be disabled") } func TestConfigMapUpdateTriggersReconciliation(t *testing.T) { @@ -910,24 +903,21 @@ func TestConfigMapUpdateTriggersReconciliation(t *testing.T) { // Create test namespace namespace := createTestNamespace(t, "test-configmap-update") - operatorNamespace := createTestNamespace(t, "llama-stack-k8s-operator-system") + operatorNamespace := createTestNamespace(t, "ogx-k8s-operator-system") t.Setenv("OPERATOR_NAMESPACE", operatorNamespace.Name) // Create initial ConfigMap configMap := &corev1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ - Name: "llama-stack-operator-config", + Name: "ogx-operator-config", Namespace: operatorNamespace.Name, }, - Data: map[string]string{ - "featureFlags": `enableNetworkPolicy: - enabled: false`, - }, + Data: map[string]string{}, } require.NoError(t, k8sClient.Create(t.Context(), configMap)) - // Create LlamaStackDistribution instance using starter - instance := NewDistributionBuilder(). + // Create test instance using starter + instance := NewOGXServerBuilder(). WithName("test-configmap-update"). WithNamespace(namespace.Name). WithDistribution("starter"). @@ -940,12 +930,16 @@ func TestConfigMapUpdateTriggersReconciliation(t *testing.T) { DistributionImages: map[string]string{"starter": "default-starter-image"}, } - reconciler, err := controllers.NewLlamaStackDistributionReconciler( - t.Context(), + operatorCM, err := controllers.InitializeOperatorConfigMap(t.Context(), k8sClient, operatorNamespace.Name) + require.NoError(t, err) + imageMappingOverrides := controllers.ParseImageMappingOverrides(t.Context(), operatorCM.Data) + + reconciler := controllers.NewOGXServerReconciler( k8sClient, scheme.Scheme, clusterInfo, - k8sClient, + imageMappingOverrides, + operatorNamespace.Name, ) require.NoError(t, err) @@ -970,12 +964,15 @@ func TestConfigMapUpdateTriggersReconciliation(t *testing.T) { }, configMap)) // Update ConfigMap with new overrides - configMap.Data["image-overrides"] = "starter: quay.io/custom/llama-stack:starter" + if configMap.Data == nil { + configMap.Data = make(map[string]string) + } + configMap.Data["image-overrides"] = "starter: quay.io/custom/ogx-server:starter" require.NoError(t, k8sClient.Update(t.Context(), configMap)) // Reconcile with the same reconciler instance. refreshOperatorConfig (called - // at the start of Reconcile) reads the updated ConfigMap via the direct API - // client, so the new image override is picked up without recreating the reconciler. + // at the start of Reconcile) reads the updated ConfigMap from the cache, + // so the new image override is picked up without recreating the reconciler. _, err = reconciler.Reconcile(t.Context(), ctrl.Request{ NamespacedName: types.NamespacedName{Name: instance.Name, Namespace: instance.Namespace}, }) @@ -985,104 +982,27 @@ func TestConfigMapUpdateTriggersReconciliation(t *testing.T) { waitForResourceWithKeyAndCondition( t, k8sClient, types.NamespacedName{Name: instance.Name, Namespace: instance.Namespace}, deployment, func() bool { - return deployment.Spec.Template.Spec.Containers[0].Image == "quay.io/custom/llama-stack:starter" + return deployment.Spec.Template.Spec.Containers[0].Image == "quay.io/custom/ogx-server:starter" }, "Deployment should be updated with new image") } -func TestRefreshOperatorConfigPicksUpChanges(t *testing.T) { - ctrl.SetLogger(zap.New(zap.UseDevMode(true))) - - namespace := createTestNamespace(t, "test-refresh-config") - operatorNamespace := createTestNamespace(t, "llama-stack-k8s-operator-system") - t.Setenv("OPERATOR_NAMESPACE", operatorNamespace.Name) - - configMap := &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "llama-stack-operator-config", - Namespace: operatorNamespace.Name, - }, - Data: map[string]string{ - "featureFlags": `enableNetworkPolicy: - enabled: false`, - }, - } - require.NoError(t, k8sClient.Create(t.Context(), configMap)) - - instance := NewDistributionBuilder(). - WithName("test-refresh-config"). - WithNamespace(namespace.Name). - WithDistribution("starter"). - Build() - require.NoError(t, k8sClient.Create(t.Context(), instance)) - - clusterInfo := &cluster.ClusterInfo{ - OperatorNamespace: operatorNamespace.Name, - DistributionImages: map[string]string{"starter": "default-starter-image"}, - } - - reconciler, err := controllers.NewLlamaStackDistributionReconciler( - t.Context(), - k8sClient, - scheme.Scheme, - clusterInfo, - k8sClient, - ) - require.NoError(t, err) - - // Initial reconcile creates resources. - _, err = reconciler.Reconcile(t.Context(), ctrl.Request{ - NamespacedName: types.NamespacedName{Name: instance.Name, Namespace: instance.Namespace}, - }) - require.NoError(t, err) - - // Verify network policy is NOT created (feature disabled). - networkPolicy := &networkingv1.NetworkPolicy{} - npName := instance.Name + "-network-policy" - err = k8sClient.Get(t.Context(), types.NamespacedName{Name: npName, Namespace: instance.Namespace}, networkPolicy) - require.True(t, apierrors.IsNotFound(err), "NetworkPolicy should not exist when feature is disabled") - - // Re-fetch the ConfigMap to get the latest resource version (initializeOperatorConfigMap - // may have updated it to add the watch label). - require.NoError(t, k8sClient.Get(t.Context(), types.NamespacedName{ - Name: configMap.Name, - Namespace: configMap.Namespace, - }, configMap)) - - // Enable network policy via operator config update. - configMap.Data["featureFlags"] = `enableNetworkPolicy: - enabled: true` - require.NoError(t, k8sClient.Update(t.Context(), configMap)) - - // Reconcile again -- refreshOperatorConfig picks up the new flag. - _, err = reconciler.Reconcile(t.Context(), ctrl.Request{ - NamespacedName: types.NamespacedName{Name: instance.Name, Namespace: instance.Namespace}, - }) - require.NoError(t, err) - - // Verify network policy IS created now. - waitForResource(t, k8sClient, instance.Namespace, npName, networkPolicy) -} - func TestReconcileRequeuesAfterSuccess(t *testing.T) { ctrl.SetLogger(zap.New(zap.UseDevMode(true))) namespace := createTestNamespace(t, "test-requeue") - operatorNamespace := createTestNamespace(t, "llama-stack-k8s-operator-system") + operatorNamespace := createTestNamespace(t, "ogx-k8s-operator-system") t.Setenv("OPERATOR_NAMESPACE", operatorNamespace.Name) configMap := &corev1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ - Name: "llama-stack-operator-config", + Name: "ogx-operator-config", Namespace: operatorNamespace.Name, }, - Data: map[string]string{ - "featureFlags": `enableNetworkPolicy: - enabled: false`, - }, + Data: map[string]string{}, } require.NoError(t, k8sClient.Create(t.Context(), configMap)) - instance := NewDistributionBuilder(). + instance := NewOGXServerBuilder(). WithName("test-requeue"). WithNamespace(namespace.Name). WithDistribution("starter"). @@ -1094,12 +1014,16 @@ func TestReconcileRequeuesAfterSuccess(t *testing.T) { DistributionImages: map[string]string{"starter": "default-starter-image"}, } - reconciler, err := controllers.NewLlamaStackDistributionReconciler( - t.Context(), + operatorCM, err := controllers.InitializeOperatorConfigMap(t.Context(), k8sClient, operatorNamespace.Name) + require.NoError(t, err) + imageMappingOverrides := controllers.ParseImageMappingOverrides(t.Context(), operatorCM.Data) + + reconciler := controllers.NewOGXServerReconciler( k8sClient, scheme.Scheme, clusterInfo, - k8sClient, + imageMappingOverrides, + operatorNamespace.Name, ) require.NoError(t, err) @@ -1140,10 +1064,10 @@ func TestMapConfigMapToReconcileRequests(t *testing.T) { require.NoError(t, k8sClient.Create(t.Context(), userConfigMap)) // Create an instance that references this ConfigMap. - instance := NewDistributionBuilder(). + instance := NewOGXServerBuilder(). WithName("test-cm-mapping"). WithNamespace(namespace.Name). - WithUserConfig(userConfigMap.Name). + WithOverrideConfig(userConfigMap.Name, "config.yaml"). Build() require.NoError(t, k8sClient.Create(t.Context(), instance)) t.Cleanup(func() { _ = k8sClient.Delete(t.Context(), instance) }) @@ -1174,7 +1098,7 @@ func TestMapConfigMapToReconcileRequests_SkipsManagedConfigMaps(t *testing.T) { Name: "managed-cm", Namespace: namespace.Name, Labels: map[string]string{ - "app.kubernetes.io/managed-by": "llama-stack-operator", + "app.kubernetes.io/managed-by": "ogx-operator", controllers.WatchLabelKey: controllers.WatchLabelValue, }, }, @@ -1211,7 +1135,7 @@ func TestUserConfigMapPredicate(t *testing.T) { { name: "operator-managed ConfigMap is rejected even with watch label", labels: map[string]string{ - "app.kubernetes.io/managed-by": "llama-stack-operator", + "app.kubernetes.io/managed-by": "ogx-operator", controllers.WatchLabelKey: controllers.WatchLabelValue, }, expected: false, @@ -1250,3 +1174,5 @@ func TestUserConfigMapPredicate(t *testing.T) { }) } } + +func boolPtr(b bool) *bool { return &b } diff --git a/controllers/resource_helper.go b/controllers/resource_helper.go index d94b69ef7..60970e0e7 100644 --- a/controllers/resource_helper.go +++ b/controllers/resource_helper.go @@ -20,11 +20,10 @@ import ( "context" "errors" "fmt" - "regexp" "strconv" - "strings" - llamav1alpha1 "github.com/llamastack/llama-stack-k8s-operator/api/v1alpha1" + ogxiov1beta1 "github.com/ogx-ai/ogx-k8s-operator/api/v1beta1" + "github.com/ogx-ai/ogx-k8s-operator/pkg/deploy" autoscalingv2 "k8s.io/api/autoscaling/v2" corev1 "k8s.io/api/core/v1" policyv1 "k8s.io/api/policy/v1" @@ -36,11 +35,8 @@ import ( // Constants for validation limits. const ( - // maxConfigMapKeyLength defines the maximum allowed length for ConfigMap keys - // based on Kubernetes DNS subdomain name limits. - maxConfigMapKeyLength = 253 // FSGroup is the filesystem group ID for the pod. - // This is the default group ID for the llama-stack server. + // This is the default group ID for the ogx server. FSGroup = int64(1001) // instanceLabelKey is the label we apply to all resources for per-instance targeting. instanceLabelKey = "app.kubernetes.io/instance" @@ -51,6 +47,32 @@ var ( defaultHPACPUUtilization = int32(80) //nolint:mnd // standard HPA default ) +type runtimeConfigRef struct { + ConfigMapName string + ConfigMapKey string + Generated bool +} + +func resolveRuntimeConfigRef(instance *ogxiov1beta1.OGXServer, pendingGeneratedConfigMapName string) *runtimeConfigRef { + if overrideConfig := instance.Spec.OverrideConfig; overrideConfig != nil && + overrideConfig.Name != "" && overrideConfig.Key != "" { + return &runtimeConfigRef{ + ConfigMapName: overrideConfig.Name, + ConfigMapKey: overrideConfig.Key, + } + } + + if pendingGeneratedConfigMapName != "" { + return &runtimeConfigRef{ + ConfigMapName: pendingGeneratedConfigMapName, + ConfigMapKey: generatedConfigKeyName, + Generated: true, + } + } + + return nil +} + // Probes configuration. const ( startupProbeInitialDelaySeconds = 15 // Time to wait before the first probe @@ -59,12 +81,8 @@ const ( startupProbeSuccessThreshold = 1 // Pod is marked Ready after 1 successful probe ) -// validConfigMapKeyRegex defines allowed characters for ConfigMap keys. -// Kubernetes ConfigMap keys must be valid DNS subdomain names or data keys. -var validConfigMapKeyRegex = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$`) - // getManagedCABundleConfigMapName returns the name of the managed CA bundle ConfigMap. -func getManagedCABundleConfigMapName(instance *llamav1alpha1.LlamaStackDistribution) string { +func getManagedCABundleConfigMapName(instance *ogxiov1beta1.OGXServer) string { return instance.Name + ManagedCABundleConfigMapSuffix } @@ -72,26 +90,26 @@ func getManagedCABundleConfigMapName(instance *llamav1alpha1.LlamaStackDistribut var startupScript = ` set -e -# Determine which CLI to use based on llama-stack version +# Determine which CLI to use based on ogx version VERSION_CODE=$(python -c " import sys from importlib.metadata import version from packaging import version as pkg_version try: - llama_version = version('llama_stack') - print(f'Detected llama-stack version: {llama_version}', file=sys.stderr) + ogx_version = version('ogx') + print(f'Detected ogx version: {ogx_version}', file=sys.stderr) - v = pkg_version.parse(llama_version) + v = pkg_version.parse(ogx_version) # Use base_version to ignore pre-release/post-release/dev suffixes # This ensures that 0.3.0rc2, 0.3.0alpha1, etc. are treated as 0.3.0 base_v = pkg_version.parse(v.base_version) if base_v < pkg_version.parse('0.2.17'): - print('Using legacy module path (llama_stack.distribution.server.server)', file=sys.stderr) + print('Using legacy module path (ogx.distribution.server.server)', file=sys.stderr) print(0) elif base_v < pkg_version.parse('0.3.0'): - print('Using core module path (llama_stack.core.server.server)', file=sys.stderr) + print('Using core module path (ogx.core.server.server)', file=sys.stderr) print(1) else: print('Using uvicorn CLI command', file=sys.stderr) @@ -101,45 +119,22 @@ except Exception as e: print(2) ") -PORT=${LLS_PORT:-8321} -WORKERS=${LLS_WORKERS:-1} +PORT=${OGX_PORT:-8321} +WORKERS=${OGX_WORKERS:-1} # Execute the appropriate CLI based on version case $VERSION_CODE in - 0) python3 -m llama_stack.distribution.server.server --config /etc/llama-stack/config.yaml ;; - 1) python3 -m llama_stack.core.server.server /etc/llama-stack/config.yaml ;; - 2) exec uvicorn llama_stack.core.server.server:create_app --host 0.0.0.0 --port "$PORT" --workers "$WORKERS" --factory ;; + 0) python3 -m ogx.distribution.server.server --config /etc/ogx/config.yaml ;; + 1) python3 -m ogx.core.server.server /etc/ogx/config.yaml ;; + 2) exec uvicorn ogx.core.server.server:create_app --host 0.0.0.0 --port "$PORT" --workers "$WORKERS" --factory ;; *) echo "Invalid version code: $VERSION_CODE, using uvicorn CLI command"; \ - exec uvicorn llama_stack.core.server.server:create_app --host 0.0.0.0 --port "$PORT" --workers "$WORKERS" --factory ;; + exec uvicorn ogx.core.server.server:create_app --host 0.0.0.0 --port "$PORT" --workers "$WORKERS" --factory ;; esac` -const llamaStackConfigPath = "/etc/llama-stack/config.yaml" - -// validateConfigMapKeys validates that all ConfigMap keys contain only safe characters. -// Note: This function validates key names only. PEM content validation is performed -// separately in the controller's reconcileCABundleConfigMap function. -func validateConfigMapKeys(keys []string) error { - for _, key := range keys { - if key == "" { - return errors.New("ConfigMap key cannot be empty") - } - if len(key) > maxConfigMapKeyLength { - return fmt.Errorf("failed to validate ConfigMap key '%s': too long (max %d characters)", key, maxConfigMapKeyLength) - } - // Check for path traversal attempts first (before general regex check) - // to provide specific error messages for security-related issues - if strings.Contains(key, "..") || strings.Contains(key, "/") { - return fmt.Errorf("failed to validate ConfigMap key '%s': contains invalid path characters", key) - } - if !validConfigMapKeyRegex.MatchString(key) { - return fmt.Errorf("failed to validate ConfigMap key '%s': contains invalid characters. Only alphanumeric characters, hyphens, underscores, and dots are allowed", key) - } - } - return nil -} +const ogxConfigPath = "/etc/ogx/config.yaml" // getHealthProbe returns the health probe handler for the container. -func getHealthProbe(instance *llamav1alpha1.LlamaStackDistribution) corev1.ProbeHandler { +func getHealthProbe(instance *ogxiov1beta1.OGXServer) corev1.ProbeHandler { return corev1.ProbeHandler{ HTTPGet: &corev1.HTTPGetAction{ Path: "/v1/health", @@ -149,7 +144,7 @@ func getHealthProbe(instance *llamav1alpha1.LlamaStackDistribution) corev1.Probe } // getStartupProbe returns the startup probe for the container. -func getStartupProbe(instance *llamav1alpha1.LlamaStackDistribution) *corev1.Probe { +func getStartupProbe(instance *ogxiov1beta1.OGXServer) *corev1.Probe { return &corev1.Probe{ ProbeHandler: getHealthProbe(instance), InitialDelaySeconds: startupProbeInitialDelaySeconds, @@ -160,30 +155,35 @@ func getStartupProbe(instance *llamav1alpha1.LlamaStackDistribution) *corev1.Pro } // buildContainerSpec creates the container specification. -func buildContainerSpec(ctx context.Context, r *LlamaStackDistributionReconciler, instance *llamav1alpha1.LlamaStackDistribution, image string) corev1.Container { +func buildContainerSpec( + ctx context.Context, + r *OGXServerReconciler, + instance *ogxiov1beta1.OGXServer, + image string, + runtimeConfig *runtimeConfigRef, + secretEnvVars []corev1.EnvVar, +) corev1.Container { workers, workersSet := getEffectiveWorkers(instance) - container := corev1.Container{ - Name: getContainerName(instance), + Name: ogxiov1beta1.DefaultContainerName, Image: image, - Resources: resolveContainerResources(instance.Spec.Server.ContainerSpec, workers, workersSet), + Resources: resolveContainerResources(instance, workers, workersSet), Ports: []corev1.ContainerPort{{ContainerPort: getContainerPort(instance)}}, StartupProbe: getStartupProbe(instance), } - - // Configure environment variables and mounts - configureContainerEnvironment(ctx, r, instance, &container) - configureContainerMounts(ctx, r, instance, &container) - configureContainerCommands(instance, &container) - + configureContainerEnvironment(ctx, r, instance, &container, secretEnvVars) + configureContainerMounts(ctx, r, instance, runtimeConfig, &container) + configureContainerCommands(instance, runtimeConfig, &container) return container } // resolveContainerResources ensures the container always has CPU and memory // requests defined so that HPAs using utilization metrics can function. -func resolveContainerResources(spec llamav1alpha1.ContainerSpec, workers int32, workersSet bool) corev1.ResourceRequirements { - resources := spec.Resources - +func resolveContainerResources(instance *ogxiov1beta1.OGXServer, workers int32, workersSet bool) corev1.ResourceRequirements { + var resources corev1.ResourceRequirements + if instance.Spec.Workload != nil && instance.Spec.Workload.Resources != nil { + resources = *instance.Spec.Workload.Resources + } ensureRequests(&resources, workers) if workersSet { ensureLimitsMatchRequests(&resources) @@ -197,7 +197,7 @@ func resolveContainerResources(spec llamav1alpha1.ContainerSpec, workers int32, ctrlLog.Log.WithName("resource_helper").WithValues( "workers", workers, "workersEnabled", workersSet, - ).V(1).Info("Defaulted resource values for llama-stack container", + ).V(1).Info("Defaulted resource values for ogx container", "cpuRequest", cpuReq.String(), "memoryRequest", memReq.String(), "cpuLimit", cpuLimit.String(), @@ -218,7 +218,7 @@ func ensureRequests(resources *corev1.ResourceRequirements, workers int32) { } if memQty, ok := resources.Requests[corev1.ResourceMemory]; !ok || memQty.IsZero() { - resources.Requests[corev1.ResourceMemory] = llamav1alpha1.DefaultServerMemoryRequest + resources.Requests[corev1.ResourceMemory] = ogxiov1beta1.DefaultServerMemoryRequest } } @@ -236,32 +236,24 @@ func ensureLimitsMatchRequests(resources *corev1.ResourceRequirements) { } } -// getContainerName returns the container name, using custom name if specified. -func getContainerName(instance *llamav1alpha1.LlamaStackDistribution) string { - if instance.Spec.Server.ContainerSpec.Name != "" { - return instance.Spec.Server.ContainerSpec.Name - } - return llamav1alpha1.DefaultContainerName -} - // getContainerPort returns the container port, using custom port if specified. -func getContainerPort(instance *llamav1alpha1.LlamaStackDistribution) int32 { - if instance.Spec.Server.ContainerSpec.Port != 0 { - return instance.Spec.Server.ContainerSpec.Port +func getContainerPort(instance *ogxiov1beta1.OGXServer) int32 { + if instance.Spec.Network != nil && instance.Spec.Network.Port != 0 { + return instance.Spec.Network.Port } - return llamav1alpha1.DefaultServerPort + return ogxiov1beta1.DefaultServerPort } // getEffectiveWorkers returns a positive worker count, defaulting to 1. -func getEffectiveWorkers(instance *llamav1alpha1.LlamaStackDistribution) (int32, bool) { - if instance.Spec.Server.Workers != nil && *instance.Spec.Server.Workers > 0 { - return *instance.Spec.Server.Workers, true +func getEffectiveWorkers(instance *ogxiov1beta1.OGXServer) (int32, bool) { + if instance.Spec.Workload != nil && instance.Spec.Workload.Workers != nil && *instance.Spec.Workload.Workers > 0 { + return *instance.Spec.Workload.Workers, true } return 1, false } // configureContainerEnvironment sets up environment variables for the container. -func configureContainerEnvironment(ctx context.Context, r *LlamaStackDistributionReconciler, instance *llamav1alpha1.LlamaStackDistribution, container *corev1.Container) { +func configureContainerEnvironment(ctx context.Context, r *OGXServerReconciler, instance *ogxiov1beta1.OGXServer, container *corev1.Container, secretEnvVars []corev1.EnvVar) { mountPath := getMountPath(instance) workers, _ := getEffectiveWorkers(instance) @@ -277,7 +269,6 @@ func configureContainerEnvironment(ctx context.Context, r *LlamaStackDistributio // Add CA bundle environment variable if any CA bundles are configured // (explicit or auto-detected ODH bundles) if hasAnyCABundle(ctx, r, instance) { - // Set SSL_CERT_FILE to point to the managed CA bundle file container.Env = append(container.Env, corev1.EnvVar{ Name: "SSL_CERT_FILE", Value: ManagedCABundleFilePath, @@ -287,39 +278,73 @@ func configureContainerEnvironment(ctx context.Context, r *LlamaStackDistributio // Always provide worker/port/config env for uvicorn; workers default to 1 when unspecified. container.Env = append(container.Env, corev1.EnvVar{ - Name: "LLS_WORKERS", + Name: "OGX_WORKERS", Value: strconv.Itoa(int(workers)), }, corev1.EnvVar{ - Name: "LLS_PORT", + Name: "OGX_PORT", Value: strconv.Itoa(int(getContainerPort(instance))), }, corev1.EnvVar{ - Name: "LLAMA_STACK_CONFIG", - Value: llamaStackConfigPath, + Name: "OGX_CONFIG", + Value: ogxConfigPath, }, ) - // Finally, add the user provided env vars - container.Env = append(container.Env, instance.Spec.Server.ContainerSpec.Env...) + if instance.Spec.RegistryRefreshIntervalSeconds != nil { + container.Env = append(container.Env, corev1.EnvVar{ + Name: "OGX_REGISTRY_REFRESH_INTERVAL_SECONDS", + Value: strconv.Itoa(int(*instance.Spec.RegistryRefreshIntervalSeconds)), + }) + } + // Inject pre-computed secret env vars for provider/storage references. + // These are computed once in buildManifestContext to avoid redundant tree walks. + if len(secretEnvVars) > 0 { + container.Env = append(container.Env, secretEnvVars...) + } + + // Apply user-provided env vars, letting them override operator defaults. + if instance.Spec.Workload != nil && instance.Spec.Workload.Overrides != nil { + overrides := make(map[string]corev1.EnvVar, len(instance.Spec.Workload.Overrides.Env)) + for _, e := range instance.Spec.Workload.Overrides.Env { + overrides[e.Name] = e + } + deduped := make([]corev1.EnvVar, 0, len(container.Env)) + for _, e := range container.Env { + if _, ok := overrides[e.Name]; ok { + continue + } + deduped = append(deduped, e) + } + deduped = append(deduped, instance.Spec.Workload.Overrides.Env...) + container.Env = deduped + } } // configureContainerMounts sets up volume mounts for the container. -func configureContainerMounts(ctx context.Context, r *LlamaStackDistributionReconciler, instance *llamav1alpha1.LlamaStackDistribution, container *corev1.Container) { +func configureContainerMounts( + ctx context.Context, + r *OGXServerReconciler, + instance *ogxiov1beta1.OGXServer, + runtimeConfig *runtimeConfigRef, + container *corev1.Container, +) { // Add volume mount for storage addStorageVolumeMount(instance, container) - // Add ConfigMap volume mount if user config is specified - addUserConfigVolumeMount(instance, container) + // Mount the final runtime config when it comes from overrideConfig or + // operator-generated config. spec.baseConfig is an input to generation only + // and is never mounted into the pod directly. + addRuntimeConfigVolumeMount(runtimeConfig, container) // Add CA bundle volume mount if TLS config is specified or auto-detected addCABundleVolumeMount(ctx, r, instance, container) } // hasAnyCABundle checks if any CA bundle will be mounted (explicit or auto-detected). -func hasAnyCABundle(ctx context.Context, r *LlamaStackDistributionReconciler, instance *llamav1alpha1.LlamaStackDistribution) bool { - // Check for explicit CA bundle configuration - if instance.Spec.Server.TLSConfig != nil && instance.Spec.Server.TLSConfig.CABundle != nil { +func hasAnyCABundle(ctx context.Context, r *OGXServerReconciler, instance *ogxiov1beta1.OGXServer) bool { + // Check for explicit CA certificate configuration + if instance.Spec.TLS != nil && instance.Spec.TLS.Trust != nil && len(instance.Spec.TLS.Trust.CACertificates) > 0 { return true } @@ -334,50 +359,48 @@ func hasAnyCABundle(ctx context.Context, r *LlamaStackDistributionReconciler, in } // configureContainerCommands sets up container commands and args. -func configureContainerCommands(instance *llamav1alpha1.LlamaStackDistribution, container *corev1.Container) { - // Override the container entrypoint to use the custom config file if user config is specified - if instance.Spec.Server.UserConfig != nil && instance.Spec.Server.UserConfig.ConfigMapName != "" { - // Override the container entrypoint to use the custom config file instead of the default - // template. The script will determine the llama-stack version and use the appropriate module - // path to start the server. - +func configureContainerCommands(instance *ogxiov1beta1.OGXServer, runtimeConfig *runtimeConfigRef, container *corev1.Container) { + if runtimeConfig != nil { container.Command = []string{"/bin/sh", "-c", startupScript} container.Args = []string{} } // Apply user-specified command and args (takes precedence) - if len(instance.Spec.Server.ContainerSpec.Command) > 0 { - container.Command = instance.Spec.Server.ContainerSpec.Command - } - - if len(instance.Spec.Server.ContainerSpec.Args) > 0 { - container.Args = instance.Spec.Server.ContainerSpec.Args + if instance.Spec.Workload != nil && instance.Spec.Workload.Overrides != nil { + if len(instance.Spec.Workload.Overrides.Command) > 0 { + container.Command = instance.Spec.Workload.Overrides.Command + } + if len(instance.Spec.Workload.Overrides.Args) > 0 { + container.Args = instance.Spec.Workload.Overrides.Args + } } } // getMountPath returns the mount path, using custom path if specified. -func getMountPath(instance *llamav1alpha1.LlamaStackDistribution) string { - if instance.Spec.Server.Storage != nil && instance.Spec.Server.Storage.MountPath != "" { - return instance.Spec.Server.Storage.MountPath +func getMountPath(instance *ogxiov1beta1.OGXServer) string { + if instance.Spec.Workload != nil && instance.Spec.Workload.Storage != nil && instance.Spec.Workload.Storage.MountPath != "" { + return instance.Spec.Workload.Storage.MountPath } - return llamav1alpha1.DefaultMountPath + return ogxiov1beta1.DefaultMountPath } // addStorageVolumeMount adds the storage volume mount to the container. -func addStorageVolumeMount(instance *llamav1alpha1.LlamaStackDistribution, container *corev1.Container) { +func addStorageVolumeMount(instance *ogxiov1beta1.OGXServer, container *corev1.Container) { mountPath := getMountPath(instance) container.VolumeMounts = append(container.VolumeMounts, corev1.VolumeMount{ - Name: "lls-storage", + Name: "ogx-storage", MountPath: mountPath, }) } -// addUserConfigVolumeMount adds the user config volume mount to the container if specified. -func addUserConfigVolumeMount(instance *llamav1alpha1.LlamaStackDistribution, container *corev1.Container) { - if instance.Spec.Server.UserConfig != nil && instance.Spec.Server.UserConfig.ConfigMapName != "" { +// addRuntimeConfigVolumeMount mounts the final runtime config.yaml into the pod. +// The mounted ConfigMap comes from either spec.overrideConfig or the operator's +// generated ConfigMap. spec.baseConfig is not mounted directly. +func addRuntimeConfigVolumeMount(runtimeConfig *runtimeConfigRef, container *corev1.Container) { + if runtimeConfig != nil { container.VolumeMounts = append(container.VolumeMounts, corev1.VolumeMount{ Name: "user-config", - MountPath: "/etc/llama-stack/", + MountPath: "/etc/ogx/", ReadOnly: true, }) } @@ -385,7 +408,7 @@ func addUserConfigVolumeMount(instance *llamav1alpha1.LlamaStackDistribution, co // addCABundleVolumeMount adds the managed CA bundle volume mount to the container. // Mounts the operator-managed ConfigMap containing all concatenated certificates. -func addCABundleVolumeMount(ctx context.Context, r *LlamaStackDistributionReconciler, instance *llamav1alpha1.LlamaStackDistribution, container *corev1.Container) { +func addCABundleVolumeMount(ctx context.Context, r *OGXServerReconciler, instance *ogxiov1beta1.OGXServer, container *corev1.Container) { // Mount managed CA bundle if any CA bundles are configured if hasAnyCABundle(ctx, r, instance) { container.VolumeMounts = append(container.VolumeMounts, corev1.VolumeMount{ @@ -417,7 +440,14 @@ func createCABundleVolume(managedConfigMapName string) corev1.Volume { } // configurePodStorage configures the pod storage and returns the complete pod spec. -func configurePodStorage(ctx context.Context, r *LlamaStackDistributionReconciler, instance *llamav1alpha1.LlamaStackDistribution, container corev1.Container) corev1.PodSpec { +func configurePodStorage( + ctx context.Context, + r *OGXServerReconciler, + instance *ogxiov1beta1.OGXServer, + runtimeConfig *runtimeConfigRef, + container corev1.Container, + effectivePVCName string, +) corev1.PodSpec { fsGroup := FSGroup podSpec := corev1.PodSpec{ Containers: []corev1.Container{container}, @@ -427,13 +457,13 @@ func configurePodStorage(ctx context.Context, r *LlamaStackDistributionReconcile } // Configure storage volumes - configureStorage(instance, &podSpec) + configureStorage(instance, &podSpec, effectivePVCName) // Configure TLS CA bundle (with auto-detection support) configureTLSCABundle(ctx, r, instance, &podSpec) - // Configure user config - configureUserConfig(instance, &podSpec) + // Configure the final runtime config volume. + configureRuntimeConfigVolume(runtimeConfig, &podSpec) // Apply pod overrides including ServiceAccount, volumes, and volume mounts configurePodOverrides(instance, &podSpec) @@ -444,22 +474,21 @@ func configurePodStorage(ctx context.Context, r *LlamaStackDistributionReconcile } // configureStorage handles storage volume configuration. -func configureStorage(instance *llamav1alpha1.LlamaStackDistribution, podSpec *corev1.PodSpec) { - if instance.Spec.Server.Storage != nil { - configurePersistentStorage(instance, podSpec) +func configureStorage(instance *ogxiov1beta1.OGXServer, podSpec *corev1.PodSpec, effectivePVCName string) { + if instance.Spec.Workload != nil && instance.Spec.Workload.Storage != nil { + configurePersistentStorage(podSpec, effectivePVCName) } else { configureEmptyDirStorage(podSpec) } } // configurePersistentStorage sets up PVC-based storage. -func configurePersistentStorage(instance *llamav1alpha1.LlamaStackDistribution, podSpec *corev1.PodSpec) { - // Use PVC for persistent storage +func configurePersistentStorage(podSpec *corev1.PodSpec, pvcName string) { podSpec.Volumes = append(podSpec.Volumes, corev1.Volume{ - Name: "lls-storage", + Name: "ogx-storage", VolumeSource: corev1.VolumeSource{ PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ - ClaimName: instance.Name + "-pvc", + ClaimName: pvcName, }, }, }) @@ -469,7 +498,7 @@ func configurePersistentStorage(instance *llamav1alpha1.LlamaStackDistribution, func configureEmptyDirStorage(podSpec *corev1.PodSpec) { // Use emptyDir for non-persistent storage podSpec.Volumes = append(podSpec.Volumes, corev1.Volume{ - Name: "lls-storage", + Name: "ogx-storage", VolumeSource: corev1.VolumeSource{ EmptyDir: &corev1.EmptyDirVolumeSource{}, }, @@ -478,7 +507,7 @@ func configureEmptyDirStorage(podSpec *corev1.PodSpec) { // configureTLSCABundle handles TLS CA bundle configuration. // Mounts the operator-managed CA bundle ConfigMap that contains all certificates. -func configureTLSCABundle(ctx context.Context, r *LlamaStackDistributionReconciler, instance *llamav1alpha1.LlamaStackDistribution, podSpec *corev1.PodSpec) { +func configureTLSCABundle(ctx context.Context, r *OGXServerReconciler, instance *ogxiov1beta1.OGXServer, podSpec *corev1.PodSpec) { // Check if any CA bundles are configured (explicit or auto-detected ODH) if !hasAnyCABundle(ctx, r, instance) { return @@ -490,64 +519,61 @@ func configureTLSCABundle(ctx context.Context, r *LlamaStackDistributionReconcil podSpec.Volumes = append(podSpec.Volumes, volume) } -// configureUserConfig handles user configuration setup. -func configureUserConfig(instance *llamav1alpha1.LlamaStackDistribution, podSpec *corev1.PodSpec) { - userConfig := instance.Spec.Server.UserConfig - if userConfig == nil || userConfig.ConfigMapName == "" { +// configureRuntimeConfigVolume adds the ConfigMap volume that provides the +// runtime config.yaml consumed by the container. Override config takes +// precedence over generated config, and spec.baseConfig is never mounted. +func configureRuntimeConfigVolume(runtimeConfig *runtimeConfigRef, podSpec *corev1.PodSpec) { + if runtimeConfig == nil { return } - // Add ConfigMap volume if user config is specified podSpec.Volumes = append(podSpec.Volumes, corev1.Volume{ Name: "user-config", VolumeSource: corev1.VolumeSource{ ConfigMap: &corev1.ConfigMapVolumeSource{ LocalObjectReference: corev1.LocalObjectReference{ - Name: userConfig.ConfigMapName, + Name: runtimeConfig.ConfigMapName, + }, + Items: []corev1.KeyToPath{ + { + Key: runtimeConfig.ConfigMapKey, + Path: "config.yaml", + }, }, }, }, }) } -// configurePodOverrides applies pod-level overrides from the LlamaStackDistribution spec. -func configurePodOverrides(instance *llamav1alpha1.LlamaStackDistribution, podSpec *corev1.PodSpec) { - // Set ServiceAccount name - use override if specified, otherwise use default - if instance.Spec.Server.PodOverrides != nil && instance.Spec.Server.PodOverrides.ServiceAccountName != "" { - podSpec.ServiceAccountName = instance.Spec.Server.PodOverrides.ServiceAccountName +// configurePodOverrides applies pod-level overrides from the OGXServer spec. +func configurePodOverrides(instance *ogxiov1beta1.OGXServer, podSpec *corev1.PodSpec) { + if instance.Spec.Workload != nil && instance.Spec.Workload.Overrides != nil && instance.Spec.Workload.Overrides.ServiceAccountName != "" { + podSpec.ServiceAccountName = instance.Spec.Workload.Overrides.ServiceAccountName } else { podSpec.ServiceAccountName = instance.Name + "-sa" } - // Apply other pod overrides if specified - if instance.Spec.Server.PodOverrides != nil { - // Add volumes if specified - if len(instance.Spec.Server.PodOverrides.Volumes) > 0 { - podSpec.Volumes = append(podSpec.Volumes, instance.Spec.Server.PodOverrides.Volumes...) + if instance.Spec.Workload != nil && instance.Spec.Workload.Overrides != nil { + overrides := instance.Spec.Workload.Overrides + if len(overrides.Volumes) > 0 { + podSpec.Volumes = append(podSpec.Volumes, overrides.Volumes...) } - - // Add volume mounts if specified - if len(instance.Spec.Server.PodOverrides.VolumeMounts) > 0 { + if len(overrides.VolumeMounts) > 0 { if len(podSpec.Containers) > 0 { - podSpec.Containers[0].VolumeMounts = append(podSpec.Containers[0].VolumeMounts, instance.Spec.Server.PodOverrides.VolumeMounts...) + podSpec.Containers[0].VolumeMounts = append(podSpec.Containers[0].VolumeMounts, overrides.VolumeMounts...) } } - - // Apply termination grace period if specified - if instance.Spec.Server.PodOverrides.TerminationGracePeriodSeconds != nil { - podSpec.TerminationGracePeriodSeconds = instance.Spec.Server.PodOverrides.TerminationGracePeriodSeconds - } } } -func configurePodScheduling(instance *llamav1alpha1.LlamaStackDistribution, podSpec *corev1.PodSpec) { - if len(instance.Spec.Server.TopologySpreadConstraints) > 0 { - podSpec.TopologySpreadConstraints = deepCopyTopologySpreadConstraints(instance.Spec.Server.TopologySpreadConstraints) - } else if instance.Spec.Replicas > 1 { +func configurePodScheduling(instance *ogxiov1beta1.OGXServer, podSpec *corev1.PodSpec) { + if instance.Spec.Workload != nil && len(instance.Spec.Workload.TopologySpreadConstraints) > 0 { + podSpec.TopologySpreadConstraints = deepCopyTopologySpreadConstraints(instance.Spec.Workload.TopologySpreadConstraints) + } else if deploy.GetEffectiveReplicas(instance) > 1 { podSpec.TopologySpreadConstraints = defaultTopologySpreadConstraints(instance) } - if instance.Spec.Replicas > 1 { + if deploy.GetEffectiveReplicas(instance) > 1 { ensureDefaultPodAntiAffinity(instance, podSpec) } } @@ -560,7 +586,7 @@ func deepCopyTopologySpreadConstraints(constraints []corev1.TopologySpreadConstr return copied } -func defaultTopologySpreadConstraints(instance *llamav1alpha1.LlamaStackDistribution) []corev1.TopologySpreadConstraint { +func defaultTopologySpreadConstraints(instance *ogxiov1beta1.OGXServer) []corev1.TopologySpreadConstraint { labelSelector := defaultInstanceLabelSelector(instance) return []corev1.TopologySpreadConstraint{ newTopologySpreadConstraint(labelSelector, "topology.kubernetes.io/region"), @@ -578,7 +604,7 @@ func newTopologySpreadConstraint(selector *metav1.LabelSelector, topologyKey str } } -func ensureDefaultPodAntiAffinity(instance *llamav1alpha1.LlamaStackDistribution, podSpec *corev1.PodSpec) { +func ensureDefaultPodAntiAffinity(instance *ogxiov1beta1.OGXServer, podSpec *corev1.PodSpec) { if podSpec.Affinity != nil && podSpec.Affinity.PodAntiAffinity != nil { return } @@ -606,7 +632,7 @@ func ensureDefaultPodAntiAffinity(instance *llamav1alpha1.LlamaStackDistribution podSpec.Affinity.PodAntiAffinity = defaultAntiAffinity.DeepCopy() } -func defaultInstanceLabelSelector(instance *llamav1alpha1.LlamaStackDistribution) *metav1.LabelSelector { +func defaultInstanceLabelSelector(instance *ogxiov1beta1.OGXServer) *metav1.LabelSelector { return &metav1.LabelSelector{ MatchLabels: map[string]string{ instanceLabelKey: instance.Name, @@ -615,14 +641,14 @@ func defaultInstanceLabelSelector(instance *llamav1alpha1.LlamaStackDistribution } // validateDistribution validates the distribution configuration. -func (r *LlamaStackDistributionReconciler) validateDistribution(instance *llamav1alpha1.LlamaStackDistribution) error { +func (r *OGXServerReconciler) validateDistribution(instance *ogxiov1beta1.OGXServer) error { // If using distribution name, validate it exists in clusterInfo - if instance.Spec.Server.Distribution.Name != "" { + if instance.Spec.Distribution.Name != "" { if r.ClusterInfo == nil { return errors.New("failed to initialize cluster info") } - if _, exists := r.ClusterInfo.DistributionImages[instance.Spec.Server.Distribution.Name]; !exists { - return fmt.Errorf("failed to validate distribution: %s. Distribution name not supported", instance.Spec.Server.Distribution.Name) + if _, exists := r.ClusterInfo.DistributionImages[instance.Spec.Distribution.Name]; !exists { + return fmt.Errorf("failed to validate distribution: %s. Distribution name not supported", instance.Spec.Distribution.Name) } } @@ -631,7 +657,7 @@ func (r *LlamaStackDistributionReconciler) validateDistribution(instance *llamav // resolveImage determines the container image to use based on the distribution configuration. // It returns the resolved image and any error encountered. -func (r *LlamaStackDistributionReconciler) resolveImage(distribution llamav1alpha1.DistributionType) (string, error) { +func (r *OGXServerReconciler) resolveImage(distribution ogxiov1beta1.DistributionSpec) (string, error) { distributionMap := r.ClusterInfo.DistributionImages switch { case distribution.Name != "": @@ -652,15 +678,15 @@ func (r *LlamaStackDistributionReconciler) resolveImage(distribution llamav1alph } } -func buildPodDisruptionBudgetSpec(instance *llamav1alpha1.LlamaStackDistribution) *policyv1.PodDisruptionBudgetSpec { +func buildPodDisruptionBudgetSpec(instance *ogxiov1beta1.OGXServer) *policyv1.PodDisruptionBudgetSpec { if !needsPodDisruptionBudget(instance) { return nil } spec := &policyv1.PodDisruptionBudgetSpec{} - if instance.Spec.Server.PodDisruptionBudget != nil { - spec.MinAvailable = copyIntOrString(instance.Spec.Server.PodDisruptionBudget.MinAvailable) - spec.MaxUnavailable = copyIntOrString(instance.Spec.Server.PodDisruptionBudget.MaxUnavailable) + if instance.Spec.Workload != nil && instance.Spec.Workload.PodDisruptionBudget != nil { + spec.MinAvailable = copyIntOrString(instance.Spec.Workload.PodDisruptionBudget.MinAvailable) + spec.MaxUnavailable = copyIntOrString(instance.Spec.Workload.PodDisruptionBudget.MaxUnavailable) } else { // Fix for RHAIENG-3783: Use maxUnavailable instead of minAvailable // to avoid allowedDisruptions=0 with single-replica deployments @@ -671,14 +697,12 @@ func buildPodDisruptionBudgetSpec(instance *llamav1alpha1.LlamaStackDistribution return spec } -func buildHPASpec(instance *llamav1alpha1.LlamaStackDistribution) *autoscalingv2.HorizontalPodAutoscalerSpec { - auto := instance.Spec.Server.Autoscaling - if auto == nil || auto.MaxReplicas == 0 { +func buildHPASpec(instance *ogxiov1beta1.OGXServer) *autoscalingv2.HorizontalPodAutoscalerSpec { + if instance.Spec.Workload == nil || instance.Spec.Workload.Autoscaling == nil || instance.Spec.Workload.Autoscaling.MaxReplicas == 0 { return nil } - - minReplicas := resolveMinReplicas(auto.MinReplicas, instance.Spec.Replicas) - + auto := instance.Spec.Workload.Autoscaling + minReplicas := resolveMinReplicas(auto.MinReplicas, deploy.GetEffectiveReplicas(instance)) spec := &autoscalingv2.HorizontalPodAutoscalerSpec{ ScaleTargetRef: autoscalingv2.CrossVersionObjectReference{ APIVersion: "apps/v1", @@ -689,7 +713,6 @@ func buildHPASpec(instance *llamav1alpha1.LlamaStackDistribution) *autoscalingv2 MaxReplicas: auto.MaxReplicas, Metrics: buildHPAMetrics(auto), } - return spec } @@ -704,7 +727,7 @@ func resolveMinReplicas(value *int32, defaultVal int32) *int32 { return &resolved } -func buildHPAMetrics(auto *llamav1alpha1.AutoscalingSpec) []autoscalingv2.MetricSpec { +func buildHPAMetrics(auto *ogxiov1beta1.AutoscalingSpec) []autoscalingv2.MetricSpec { var metrics []autoscalingv2.MetricSpec if auto.TargetCPUUtilizationPercentage != nil { @@ -749,11 +772,11 @@ func buildHPAMetrics(auto *llamav1alpha1.AutoscalingSpec) []autoscalingv2.Metric return metrics } -func needsPodDisruptionBudget(instance *llamav1alpha1.LlamaStackDistribution) bool { - if instance.Spec.Server.PodDisruptionBudget != nil { +func needsPodDisruptionBudget(instance *ogxiov1beta1.OGXServer) bool { + if instance.Spec.Workload != nil && instance.Spec.Workload.PodDisruptionBudget != nil { return true } - return instance.Spec.Replicas > 1 + return deploy.GetEffectiveReplicas(instance) > 1 } func copyIntOrString(value *intstr.IntOrString) *intstr.IntOrString { diff --git a/controllers/resource_helper_test.go b/controllers/resource_helper_test.go index 8549512bc..ed42a9a4f 100644 --- a/controllers/resource_helper_test.go +++ b/controllers/resource_helper_test.go @@ -1,560 +1,216 @@ -/* -Copyright 2025. - -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. -*/ - package controllers import ( - "strings" "testing" - llamav1alpha1 "github.com/llamastack/llama-stack-k8s-operator/api/v1alpha1" - "github.com/llamastack/llama-stack-k8s-operator/pkg/cluster" + ogxiov1beta1 "github.com/ogx-ai/ogx-k8s-operator/api/v1beta1" + "github.com/ogx-ai/ogx-k8s-operator/pkg/cluster" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - appsv1 "k8s.io/api/apps/v1" - autoscalingv2 "k8s.io/api/autoscaling/v2" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/intstr" ) -func int32Ptr(val int32) *int32 { - return &val -} +func int32Ptr(v int32) *int32 { return &v } -func TestBuildContainerSpec(t *testing.T) { - testCases := []struct { - name string - instance *llamav1alpha1.LlamaStackDistribution - image string - expectedResult corev1.Container - }{ - { - name: "default values", - instance: &llamav1alpha1.LlamaStackDistribution{ - Spec: llamav1alpha1.LlamaStackDistributionSpec{ - Server: llamav1alpha1.ServerSpec{ - ContainerSpec: llamav1alpha1.ContainerSpec{}, - }, - }, - }, - image: "test-image:latest", - expectedResult: corev1.Container{ - Name: llamav1alpha1.DefaultContainerName, - Image: "test-image:latest", - Resources: corev1.ResourceRequirements{ - Requests: corev1.ResourceList{ - corev1.ResourceCPU: resource.MustParse("1"), - corev1.ResourceMemory: llamav1alpha1.DefaultServerMemoryRequest, - }, - }, - Ports: []corev1.ContainerPort{{ContainerPort: llamav1alpha1.DefaultServerPort}}, - StartupProbe: newDefaultStartupProbe(llamav1alpha1.DefaultServerPort), - VolumeMounts: []corev1.VolumeMount{{ - Name: "lls-storage", - MountPath: llamav1alpha1.DefaultMountPath, - }}, - Env: []corev1.EnvVar{ - {Name: "HF_HOME", Value: "/.llama"}, - {Name: "LLS_WORKERS", Value: "1"}, - {Name: "LLS_PORT", Value: "8321"}, - {Name: "LLAMA_STACK_CONFIG", Value: "/etc/llama-stack/config.yaml"}, - }, - }, - }, - { - name: "custom container values", - instance: &llamav1alpha1.LlamaStackDistribution{ - Spec: llamav1alpha1.LlamaStackDistributionSpec{ - Server: llamav1alpha1.ServerSpec{ - ContainerSpec: llamav1alpha1.ContainerSpec{ - Name: "custom-container", - Port: 9000, - Resources: corev1.ResourceRequirements{ - Limits: corev1.ResourceList{ - corev1.ResourceCPU: resource.MustParse("1"), - corev1.ResourceMemory: resource.MustParse("2Gi"), - }, - }, - Env: []corev1.EnvVar{ - {Name: "TEST_ENV", Value: "test-value"}, - }, - }, - Storage: &llamav1alpha1.StorageSpec{ - MountPath: "/custom/path", - }, - }, - }, - }, - image: "test-image:latest", - expectedResult: corev1.Container{ - Name: "custom-container", - Image: "test-image:latest", - Ports: []corev1.ContainerPort{{ContainerPort: 9000}}, - StartupProbe: newDefaultStartupProbe(9000), - Resources: corev1.ResourceRequirements{ - Requests: corev1.ResourceList{ - corev1.ResourceCPU: resource.MustParse("1"), - corev1.ResourceMemory: llamav1alpha1.DefaultServerMemoryRequest, - }, - Limits: corev1.ResourceList{ - corev1.ResourceCPU: resource.MustParse("1"), - corev1.ResourceMemory: resource.MustParse("2Gi"), - }, - }, - Env: []corev1.EnvVar{ - {Name: "HF_HOME", Value: "/custom/path"}, - {Name: "LLS_WORKERS", Value: "1"}, - {Name: "LLS_PORT", Value: "9000"}, - {Name: "LLAMA_STACK_CONFIG", Value: "/etc/llama-stack/config.yaml"}, - {Name: "TEST_ENV", Value: "test-value"}, - }, - VolumeMounts: []corev1.VolumeMount{{ - Name: "lls-storage", - MountPath: "/custom/path", - }}, - Command: nil, - }, - }, - { - name: "command and args overrides", - instance: &llamav1alpha1.LlamaStackDistribution{ - Spec: llamav1alpha1.LlamaStackDistributionSpec{ - Server: llamav1alpha1.ServerSpec{ - ContainerSpec: llamav1alpha1.ContainerSpec{ - Command: []string{"/custom/entrypoint.sh"}, - Args: []string{"--config", "/etc/config.yaml", "--debug"}, - }, - }, - }, - }, - image: "test-image:latest", - expectedResult: corev1.Container{ - Name: llamav1alpha1.DefaultContainerName, - Image: "test-image:latest", - Resources: corev1.ResourceRequirements{ - Requests: corev1.ResourceList{ - corev1.ResourceCPU: resource.MustParse("1"), - corev1.ResourceMemory: llamav1alpha1.DefaultServerMemoryRequest, - }, - }, - Command: []string{"/custom/entrypoint.sh"}, - Args: []string{"--config", "/etc/config.yaml", "--debug"}, - Ports: []corev1.ContainerPort{{ContainerPort: llamav1alpha1.DefaultServerPort}}, - StartupProbe: newDefaultStartupProbe(llamav1alpha1.DefaultServerPort), - VolumeMounts: []corev1.VolumeMount{{ - Name: "lls-storage", - MountPath: llamav1alpha1.DefaultMountPath, - }}, - Env: []corev1.EnvVar{ - {Name: "HF_HOME", Value: "/.llama"}, - {Name: "LLS_WORKERS", Value: "1"}, - {Name: "LLS_PORT", Value: "8321"}, - {Name: "LLAMA_STACK_CONFIG", Value: "/etc/llama-stack/config.yaml"}, - }, - }, - }, - { - name: "uvicorn workers configured", - instance: &llamav1alpha1.LlamaStackDistribution{ - Spec: llamav1alpha1.LlamaStackDistributionSpec{ - Server: llamav1alpha1.ServerSpec{ - Workers: int32Ptr(4), - }, - }, - }, - image: "test-image:latest", - expectedResult: corev1.Container{ - Name: llamav1alpha1.DefaultContainerName, - Image: "test-image:latest", - Resources: corev1.ResourceRequirements{ - Requests: corev1.ResourceList{ - corev1.ResourceCPU: resource.MustParse("4"), - corev1.ResourceMemory: llamav1alpha1.DefaultServerMemoryRequest, - }, - Limits: corev1.ResourceList{ - corev1.ResourceCPU: resource.MustParse("4"), - corev1.ResourceMemory: llamav1alpha1.DefaultServerMemoryRequest, - }, - }, - Ports: []corev1.ContainerPort{{ContainerPort: llamav1alpha1.DefaultServerPort}}, - StartupProbe: newDefaultStartupProbe(llamav1alpha1.DefaultServerPort), - Env: []corev1.EnvVar{ - {Name: "HF_HOME", Value: "/.llama"}, - {Name: "LLS_WORKERS", Value: "4"}, - {Name: "LLS_PORT", Value: "8321"}, - {Name: "LLAMA_STACK_CONFIG", Value: "/etc/llama-stack/config.yaml"}, - }, - VolumeMounts: []corev1.VolumeMount{{ - Name: "lls-storage", - MountPath: llamav1alpha1.DefaultMountPath, - }}, - }, - }, - { - name: "with user config", - instance: &llamav1alpha1.LlamaStackDistribution{ - Spec: llamav1alpha1.LlamaStackDistributionSpec{ - Server: llamav1alpha1.ServerSpec{ - Distribution: llamav1alpha1.DistributionType{ - Name: "ollama", - }, - ContainerSpec: llamav1alpha1.ContainerSpec{}, - UserConfig: &llamav1alpha1.UserConfigSpec{ - ConfigMapName: "test-config", - }, - }, - }, - }, - image: "test-image:latest", - expectedResult: corev1.Container{ - Name: llamav1alpha1.DefaultContainerName, - Image: "test-image:latest", - ImagePullPolicy: corev1.PullAlways, - Resources: corev1.ResourceRequirements{ - Requests: corev1.ResourceList{ - corev1.ResourceCPU: resource.MustParse("1"), - corev1.ResourceMemory: llamav1alpha1.DefaultServerMemoryRequest, - }, - }, - Ports: []corev1.ContainerPort{{ContainerPort: llamav1alpha1.DefaultServerPort}}, - StartupProbe: newDefaultStartupProbe(llamav1alpha1.DefaultServerPort), - Command: []string{"/bin/sh", "-c", startupScript}, - Args: []string{}, - Env: []corev1.EnvVar{ - {Name: "HF_HOME", Value: llamav1alpha1.DefaultMountPath}, - {Name: "LLS_WORKERS", Value: "1"}, - {Name: "LLS_PORT", Value: "8321"}, - {Name: "LLAMA_STACK_CONFIG", Value: "/etc/llama-stack/config.yaml"}, - }, - VolumeMounts: []corev1.VolumeMount{ - { - Name: "lls-storage", - MountPath: llamav1alpha1.DefaultMountPath, - }, - { - Name: "user-config", - MountPath: "/etc/llama-stack/", - ReadOnly: true, - }, - }, - }, +// createTestOGX builds a minimal OGXServer for tests (distribution by name and/or image). +func createTestOGX(name, image string) *ogxiov1beta1.OGXServer { + return &ogxiov1beta1.OGXServer{ + Spec: ogxiov1beta1.OGXServerSpec{ + Distribution: ogxiov1beta1.DistributionSpec{Name: name, Image: image}, }, } +} - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - result := buildContainerSpec(t.Context(), nil, tc.instance, tc.image) - assert.Equal(t, tc.expectedResult.Name, result.Name) - assert.Equal(t, tc.expectedResult.Image, result.Image) - assert.Equal(t, tc.expectedResult.Ports, result.Ports) - assert.Equal(t, tc.expectedResult.Resources, result.Resources) - assert.Equal(t, tc.expectedResult.Env, result.Env) - assert.Equal(t, tc.expectedResult.VolumeMounts, result.VolumeMounts) - assert.Equal(t, tc.expectedResult.Command, result.Command) - assert.Equal(t, tc.expectedResult.Args, result.Args) - assert.Equal(t, tc.expectedResult.StartupProbe, result.StartupProbe) - }) +func setupTestClusterInfo(images map[string]string) *cluster.ClusterInfo { + if images == nil { + images = map[string]string{"ollama": "ollama-image:latest"} + } + return &cluster.ClusterInfo{ + OperatorNamespace: "default", + DistributionImages: images, } } -func TestConfigurePodStorage(t *testing.T) { - testCases := []struct { - name string - instance *llamav1alpha1.LlamaStackDistribution - container corev1.Container - expectedPVCVolume bool - expectedEmptyDir bool - expectedOverrides bool - }{ - { - name: "with PVC storage", - instance: &llamav1alpha1.LlamaStackDistribution{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-instance", - }, - Spec: llamav1alpha1.LlamaStackDistributionSpec{ - Server: llamav1alpha1.ServerSpec{ - Storage: &llamav1alpha1.StorageSpec{}, - }, - }, - }, - container: corev1.Container{Name: "test-container"}, - expectedPVCVolume: true, - expectedEmptyDir: false, - expectedOverrides: false, - }, - { - name: "with EmptyDir storage", - instance: &llamav1alpha1.LlamaStackDistribution{ - Spec: llamav1alpha1.LlamaStackDistributionSpec{ - Server: llamav1alpha1.ServerSpec{ - Storage: nil, - }, - }, - }, - container: corev1.Container{Name: "test-container"}, - expectedPVCVolume: false, - expectedEmptyDir: true, - expectedOverrides: false, - }, - { - name: "with pod overrides", - instance: &llamav1alpha1.LlamaStackDistribution{ - Spec: llamav1alpha1.LlamaStackDistributionSpec{ - Server: llamav1alpha1.ServerSpec{ - Storage: nil, - PodOverrides: &llamav1alpha1.PodOverrides{ - Volumes: []corev1.Volume{ - { - Name: "test-volume", - VolumeSource: corev1.VolumeSource{ - ConfigMap: &corev1.ConfigMapVolumeSource{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "test-config", - }, - }, - }, - }, - }, - VolumeMounts: []corev1.VolumeMount{ - { - Name: "test-volume", - MountPath: "/test/path", - }, - }, - }, - }, - }, +func newDefaultStartupProbe(port int32) *corev1.Probe { + return &corev1.Probe{ + ProbeHandler: corev1.ProbeHandler{ + HTTPGet: &corev1.HTTPGetAction{ + Path: "/v1/health", + Port: intstr.FromInt(int(port)), }, - container: corev1.Container{Name: "test-container"}, - expectedPVCVolume: false, - expectedEmptyDir: true, - expectedOverrides: true, }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - result := configurePodStorage(t.Context(), nil, tc.instance, tc.container) - - // Verify container was added. - assert.Len(t, result.Containers, 1) - - verifyStorageVolumes(t, result, tc.instance, tc.expectedPVCVolume, tc.expectedEmptyDir) - - if tc.expectedOverrides { - verifyPodOverrides(t, result, tc.instance) - } - }) + InitialDelaySeconds: startupProbeInitialDelaySeconds, + TimeoutSeconds: startupProbeTimeoutSeconds, + FailureThreshold: startupProbeFailureThreshold, + SuccessThreshold: startupProbeSuccessThreshold, } } -// verifyStorageVolumes validates that the correct storage volumes are configured. -func verifyStorageVolumes(t *testing.T, podSpec corev1.PodSpec, instance *llamav1alpha1.LlamaStackDistribution, - expectPVC, expectEmptyDir bool) { - t.Helper() - - if expectPVC { - pvcFound := false - for _, vol := range podSpec.Volumes { - if vol.Name == "lls-storage" && vol.PersistentVolumeClaim != nil { - pvcFound = true - assert.Equal(t, instance.Name+"-pvc", vol.PersistentVolumeClaim.ClaimName) - break - } +func TestBuildContainerSpec(t *testing.T) { + t.Run("defaults", func(t *testing.T) { + instance := &ogxiov1beta1.OGXServer{ + Spec: ogxiov1beta1.OGXServerSpec{ + Distribution: ogxiov1beta1.DistributionSpec{Image: "x:latest"}, + }, } - assert.True(t, pvcFound, "Expected PVC volume not found") - } - - if expectEmptyDir { - emptyDirFound := false - for _, vol := range podSpec.Volumes { - if vol.Name == "lls-storage" && vol.EmptyDir != nil { - emptyDirFound = true - break + c := buildContainerSpec(t.Context(), nil, instance, "test-image:latest", nil, nil) + assert.Equal(t, ogxiov1beta1.DefaultContainerName, c.Name) + assert.Equal(t, "test-image:latest", c.Image) + assert.Equal(t, ogxiov1beta1.DefaultServerPort, c.Ports[0].ContainerPort) + assert.Equal(t, newDefaultStartupProbe(ogxiov1beta1.DefaultServerPort), c.StartupProbe) + var foundOgxVol bool + for _, m := range c.VolumeMounts { + if m.Name == "ogx-storage" { + foundOgxVol = true + assert.Equal(t, ogxiov1beta1.DefaultMountPath, m.MountPath) } } - assert.True(t, emptyDirFound, "Expected EmptyDir volume not found") - } -} - -// verifyPodOverrides validates that pod overrides are correctly applied. -func verifyPodOverrides(t *testing.T, podSpec corev1.PodSpec, instance *llamav1alpha1.LlamaStackDistribution) { - t.Helper() - - if instance.Spec.Server.PodOverrides == nil { - return - } - - verifyServiceAccount(t, podSpec, instance) - verifyVolumes(t, podSpec, instance) - verifyVolumeMounts(t, podSpec, instance) -} + assert.True(t, foundOgxVol, "expected ogx-storage volume mount") + }) -func verifyServiceAccount(t *testing.T, podSpec corev1.PodSpec, instance *llamav1alpha1.LlamaStackDistribution) { - t.Helper() - if instance.Spec.Server.PodOverrides.ServiceAccountName != "" { - assert.Equal(t, instance.Spec.Server.PodOverrides.ServiceAccountName, podSpec.ServiceAccountName) - } -} + t.Run("custom port and workload resources", func(t *testing.T) { + instance := &ogxiov1beta1.OGXServer{ + Spec: ogxiov1beta1.OGXServerSpec{ + Distribution: ogxiov1beta1.DistributionSpec{Image: "x:latest"}, + Network: &ogxiov1beta1.NetworkSpec{Port: 9000}, + Workload: &ogxiov1beta1.WorkloadSpec{ + Storage: &ogxiov1beta1.PVCStorageSpec{MountPath: "/custom"}, + Resources: &corev1.ResourceRequirements{ + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("1"), + corev1.ResourceMemory: resource.MustParse("2Gi"), + }, + }, + Overrides: &ogxiov1beta1.WorkloadOverrides{ + Env: []corev1.EnvVar{{Name: "TEST_ENV", Value: "v"}}, + }, + }, + }, + } + c := buildContainerSpec(t.Context(), nil, instance, "test-image:latest", nil, nil) + assert.Equal(t, int32(9000), c.Ports[0].ContainerPort) + assert.Equal(t, newDefaultStartupProbe(9000), c.StartupProbe) + envNames := make([]string, 0, len(c.Env)) + for _, e := range c.Env { + envNames = append(envNames, e.Name) + } + assert.Contains(t, envNames, "TEST_ENV") + }) -func verifyVolumes(t *testing.T, podSpec corev1.PodSpec, instance *llamav1alpha1.LlamaStackDistribution) { - t.Helper() - if len(instance.Spec.Server.PodOverrides.Volumes) > 0 { - for _, volume := range instance.Spec.Server.PodOverrides.Volumes { - found := false - for _, podVolume := range podSpec.Volumes { - if podVolume.Name == volume.Name { - found = true - break - } - } - assert.True(t, found, "Volume %s not found in pod spec", volume.Name) + t.Run("registryRefreshIntervalSeconds not set", func(t *testing.T) { + instance := &ogxiov1beta1.OGXServer{ + Spec: ogxiov1beta1.OGXServerSpec{ + Distribution: ogxiov1beta1.DistributionSpec{Image: "x:latest"}, + }, } - } -} + c := buildContainerSpec(t.Context(), nil, instance, "test-image:latest", nil, nil) + for _, e := range c.Env { + assert.NotEqual(t, "OGX_REGISTRY_REFRESH_INTERVAL_SECONDS", e.Name) + } + }) -func verifyVolumeMounts(t *testing.T, podSpec corev1.PodSpec, instance *llamav1alpha1.LlamaStackDistribution) { - t.Helper() - if len(instance.Spec.Server.PodOverrides.VolumeMounts) > 0 { - for _, container := range podSpec.Containers { - for _, mount := range instance.Spec.Server.PodOverrides.VolumeMounts { - found := false - for _, podMount := range container.VolumeMounts { - if podMount.Name == mount.Name { - found = true - break - } - } - assert.True(t, found, "VolumeMount %s not found in container %s", mount.Name, container.Name) + t.Run("registryRefreshIntervalSeconds set", func(t *testing.T) { + val := int32(30) + instance := &ogxiov1beta1.OGXServer{ + Spec: ogxiov1beta1.OGXServerSpec{ + Distribution: ogxiov1beta1.DistributionSpec{Image: "x:latest"}, + RegistryRefreshIntervalSeconds: &val, + }, + } + c := buildContainerSpec(t.Context(), nil, instance, "test-image:latest", nil, nil) + var found bool + for _, e := range c.Env { + if e.Name == "OGX_REGISTRY_REFRESH_INTERVAL_SECONDS" { + assert.Equal(t, "30", e.Value) + found = true } } - } + assert.True(t, found, "expected OGX_REGISTRY_REFRESH_INTERVAL_SECONDS env var") + }) } -// createLSD creates a LlamaStackDistribution instance with optional name and image. -func createLSD(name, image string) *llamav1alpha1.LlamaStackDistribution { - return &llamav1alpha1.LlamaStackDistribution{ - Spec: llamav1alpha1.LlamaStackDistributionSpec{ - Server: llamav1alpha1.ServerSpec{ - Distribution: llamav1alpha1.DistributionType{ - Name: name, - Image: image, +func TestContainerEnvVarDedup(t *testing.T) { + instance := &ogxiov1beta1.OGXServer{ + Spec: ogxiov1beta1.OGXServerSpec{ + Distribution: ogxiov1beta1.DistributionSpec{Image: "x:latest"}, + Workload: &ogxiov1beta1.WorkloadSpec{ + Overrides: &ogxiov1beta1.WorkloadOverrides{ + Env: []corev1.EnvVar{ + {Name: "OGX_WORKERS", Value: "99"}, + {Name: "MY_CUSTOM_VAR", Value: "custom"}, + }, }, }, }, } -} + c := buildContainerSpec(t.Context(), nil, instance, "test-image:latest", nil, nil) -// setupTestClusterInfo creates a ClusterInfo instance for testing with the specified distribution images. -// If no images are provided, it defaults to having "ollama" with "ollama-image:latest". -func setupTestClusterInfo(images map[string]string) *cluster.ClusterInfo { - if images == nil { - images = map[string]string{ - "ollama": "ollama-image:latest", - } + envMap := make(map[string]int) + for _, e := range c.Env { + envMap[e.Name]++ } - return &cluster.ClusterInfo{ - OperatorNamespace: "default", - DistributionImages: images, + + if envMap["OGX_WORKERS"] != 1 { + t.Errorf("expected OGX_WORKERS to appear exactly once, got %d", envMap["OGX_WORKERS"]) + } + if envMap["MY_CUSTOM_VAR"] != 1 { + t.Errorf("expected MY_CUSTOM_VAR to appear, got %d", envMap["MY_CUSTOM_VAR"]) + } + + // Verify the user override wins + for _, e := range c.Env { + if e.Name == "OGX_WORKERS" { + assert.Equal(t, "99", e.Value, "user override should take precedence over operator default") + break + } } } func TestResolveImage(t *testing.T) { - // Setup test cluster info - clusterInfo := setupTestClusterInfo(map[string]string{ - "ollama": "ollama-image:latest", - }) - - testCases := []struct { - name string - instance *llamav1alpha1.LlamaStackDistribution - expectedImage string - expectError bool + clusterInfo := setupTestClusterInfo(map[string]string{"ollama": "ollama-image:latest"}) + cases := []struct { + name string + instance *ogxiov1beta1.OGXServer + want string + expectErr bool }{ - { - name: "resolve from name", - instance: createLSD("ollama", ""), - expectedImage: clusterInfo.DistributionImages["ollama"], - expectError: false, - }, - { - name: "resolve from image", - instance: createLSD("", "test-image:latest"), - expectedImage: "test-image:latest", - expectError: false, - }, - { - name: "invalid distribution name", - instance: createLSD("invalid-name", ""), - expectedImage: "", - expectError: true, - }, + {"by name", createTestOGX("ollama", ""), "ollama-image:latest", false}, + {"by image", createTestOGX("", "test-image:latest"), "test-image:latest", false}, + {"invalid name", createTestOGX("nope", ""), "", true}, } - - for _, tc := range testCases { + for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - r := &LlamaStackDistributionReconciler{ClusterInfo: clusterInfo} - image, err := r.resolveImage(tc.instance.Spec.Server.Distribution) - if tc.expectError { + r := &OGXServerReconciler{ClusterInfo: clusterInfo} + img, err := r.resolveImage(tc.instance.Spec.Distribution) + if tc.expectErr { require.Error(t, err) - assert.Empty(t, image) - } else { - require.NoError(t, err) - assert.Equal(t, tc.expectedImage, image) + return } + require.NoError(t, err) + assert.Equal(t, tc.want, img) }) } } func TestDistributionValidation(t *testing.T) { - // Setup test cluster info - clusterInfo := setupTestClusterInfo(map[string]string{ - "ollama": "lls/lls-ollama:1.0", - }) - - testCases := []struct { - name string - instance *llamav1alpha1.LlamaStackDistribution - expectError bool + clusterInfo := setupTestClusterInfo(map[string]string{"ollama": "lls/lls-ollama:1.0"}) + cases := []struct { + name string + instance *ogxiov1beta1.OGXServer + wantError bool }{ - { - name: "valid distribution name", - instance: createLSD("ollama", ""), - expectError: false, - }, - { - name: "valid direct image", - instance: createLSD("", "test-image:latest"), - expectError: false, - }, - { - name: "invalid distribution name", - instance: createLSD("invalid-name", ""), - expectError: true, - }, + {"valid name", createTestOGX("ollama", ""), false}, + {"valid image", createTestOGX("", "test:latest"), false}, + {"invalid name", createTestOGX("invalid", ""), true}, } - - for _, tc := range testCases { + for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - r := &LlamaStackDistributionReconciler{ClusterInfo: clusterInfo} + r := &OGXServerReconciler{ClusterInfo: clusterInfo} err := r.validateDistribution(tc.instance) - if tc.expectError { + if tc.wantError { assert.Error(t, err) } else { assert.NoError(t, err) @@ -564,493 +220,103 @@ func TestDistributionValidation(t *testing.T) { } func TestDistributionWithoutClusterInfo(t *testing.T) { - // Clear cluster info - instance := createLSD("ollama", "") - r := &LlamaStackDistributionReconciler{ClusterInfo: nil} - err := r.validateDistribution(instance) + r := &OGXServerReconciler{ClusterInfo: nil} + err := r.validateDistribution(createTestOGX("ollama", "")) require.Error(t, err) - assert.Contains(t, err.Error(), "failed to initialize cluster info") } func TestPodOverridesWithServiceAccount(t *testing.T) { - // Create a test instance with ServiceAccount override - instance := &llamav1alpha1.LlamaStackDistribution{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-instance", - Namespace: "test-namespace", - }, - Spec: llamav1alpha1.LlamaStackDistributionSpec{ - Server: llamav1alpha1.ServerSpec{ - PodOverrides: &llamav1alpha1.PodOverrides{ - ServiceAccountName: "custom-sa", - }, - }, - }, - } - - // Create deployment - deployment := &appsv1.Deployment{ - Spec: appsv1.DeploymentSpec{ - Template: corev1.PodTemplateSpec{ - Spec: corev1.PodSpec{ - Containers: []corev1.Container{ - { - Name: "test-container", - }, - }, - }, - }, - }, - } - - // Apply pod overrides - configurePodOverrides(instance, &deployment.Spec.Template.Spec) - - // Verify ServiceAccount name - if deployment.Spec.Template.Spec.ServiceAccountName != "custom-sa" { - t.Errorf("expected ServiceAccountName to be 'custom-sa', got %s", deployment.Spec.Template.Spec.ServiceAccountName) - } -} - -func TestPodOverridesWithoutServiceAccount(t *testing.T) { - // Create a test instance without ServiceAccount override - instance := &llamav1alpha1.LlamaStackDistribution{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-instance", - Namespace: "test-namespace", - }, - Spec: llamav1alpha1.LlamaStackDistributionSpec{ - Server: llamav1alpha1.ServerSpec{ - PodOverrides: &llamav1alpha1.PodOverrides{}, - }, - }, - } - - // Create deployment - deployment := &appsv1.Deployment{ - Spec: appsv1.DeploymentSpec{ - Template: corev1.PodTemplateSpec{ - Spec: corev1.PodSpec{ - Containers: []corev1.Container{ - { - Name: "test-container", - }, - }, - }, - }, - }, - } - - // Apply pod overrides - configurePodOverrides(instance, &deployment.Spec.Template.Spec) - - // Verify ServiceAccount name is empty (default ServiceAccountName should be set when not explicitly provided) - if deployment.Spec.Template.Spec.ServiceAccountName != instance.Name+"-sa" { - t.Errorf("expected default ServiceAccountName when not explicitly provided, got %s", deployment.Spec.Template.Spec.ServiceAccountName) - } -} - -func TestPodOverridesWithTerminationGracePeriod(t *testing.T) { - gracePeriod := int64(120) - instance := &llamav1alpha1.LlamaStackDistribution{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-instance", - Namespace: "test-namespace", - }, - Spec: llamav1alpha1.LlamaStackDistributionSpec{ - Server: llamav1alpha1.ServerSpec{ - PodOverrides: &llamav1alpha1.PodOverrides{ - TerminationGracePeriodSeconds: &gracePeriod, - }, - }, - }, - } - deployment := &appsv1.Deployment{ - Spec: appsv1.DeploymentSpec{ - Template: corev1.PodTemplateSpec{ - Spec: corev1.PodSpec{ - Containers: []corev1.Container{ - { - Name: "test-container", - }, - }, - }, - }, - }, - } - - configurePodOverrides(instance, &deployment.Spec.Template.Spec) - - require.NotNil(t, deployment.Spec.Template.Spec.TerminationGracePeriodSeconds) - assert.Equal(t, int64(120), *deployment.Spec.Template.Spec.TerminationGracePeriodSeconds) -} - -func TestPodOverridesWithTerminationGracePeriodZero(t *testing.T) { - // Ensures we distinguish "not set" (nil) from "set to 0" (immediate termination) - gracePeriod := int64(0) - instance := &llamav1alpha1.LlamaStackDistribution{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-instance", - Namespace: "test-namespace", - }, - Spec: llamav1alpha1.LlamaStackDistributionSpec{ - Server: llamav1alpha1.ServerSpec{ - PodOverrides: &llamav1alpha1.PodOverrides{ - TerminationGracePeriodSeconds: &gracePeriod, - }, - }, - }, - } - deployment := &appsv1.Deployment{ - Spec: appsv1.DeploymentSpec{ - Template: corev1.PodTemplateSpec{ - Spec: corev1.PodSpec{ - Containers: []corev1.Container{ - { - Name: "test-container", - }, - }, - }, - }, - }, - } - - configurePodOverrides(instance, &deployment.Spec.Template.Spec) - - require.NotNil(t, deployment.Spec.Template.Spec.TerminationGracePeriodSeconds) - assert.Equal(t, int64(0), *deployment.Spec.Template.Spec.TerminationGracePeriodSeconds) -} - -func TestPodOverridesWithoutTerminationGracePeriod(t *testing.T) { - instance := &llamav1alpha1.LlamaStackDistribution{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-instance", - Namespace: "test-namespace", - }, - Spec: llamav1alpha1.LlamaStackDistributionSpec{ - Server: llamav1alpha1.ServerSpec{ - PodOverrides: &llamav1alpha1.PodOverrides{}, + instance := &ogxiov1beta1.OGXServer{ + ObjectMeta: metav1.ObjectMeta{Name: "test-instance", Namespace: "ns"}, + Spec: ogxiov1beta1.OGXServerSpec{ + Distribution: ogxiov1beta1.DistributionSpec{Image: "x:latest"}, + Workload: &ogxiov1beta1.WorkloadSpec{ + Overrides: &ogxiov1beta1.WorkloadOverrides{ServiceAccountName: "custom-sa"}, }, }, } - deployment := &appsv1.Deployment{ - Spec: appsv1.DeploymentSpec{ - Template: corev1.PodTemplateSpec{ - Spec: corev1.PodSpec{ - Containers: []corev1.Container{ - { - Name: "test-container", - }, - }, - }, - }, - }, - } - - configurePodOverrides(instance, &deployment.Spec.Template.Spec) - - assert.Nil(t, deployment.Spec.Template.Spec.TerminationGracePeriodSeconds) -} - -func TestValidateConfigMapKeys(t *testing.T) { - tests := []struct { - name string - keys []string - expectError bool - errorMsg string - }{ - { - name: "valid keys", - keys: []string{DefaultCABundleKey, "intermediate.pem", "root-ca.cert"}, - expectError: false, - }, - { - name: "empty key", - keys: []string{""}, - expectError: true, - errorMsg: "ConfigMap key cannot be empty", - }, - { - name: "command injection attempt", - keys: []string{"valid-key; rm -rf /; echo malicious"}, - expectError: true, - errorMsg: "contains invalid path characters", - }, - { - name: "path traversal attempt", - keys: []string{"../../../etc/passwd"}, - expectError: true, - errorMsg: "contains invalid path characters", - }, - { - name: "shell metacharacters", - keys: []string{"key$(whoami)"}, - expectError: true, - errorMsg: "contains invalid characters", - }, - { - name: "pipe injection", - keys: []string{"key | cat /etc/passwd"}, - expectError: true, - errorMsg: "contains invalid path characters", - }, - { - name: "too long key", - keys: []string{strings.Repeat("a", 254)}, - expectError: true, - errorMsg: "too long", - }, - { - name: "valid alphanumeric with allowed chars", - keys: []string{"ca_bundle-v1.2.crt"}, - expectError: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := validateConfigMapKeys(tt.keys) - if tt.expectError { - if err == nil { - t.Errorf("Expected error but got none") - } else if tt.errorMsg != "" && !strings.Contains(err.Error(), tt.errorMsg) { - t.Errorf("Expected error message to contain '%s', got '%s'", tt.errorMsg, err.Error()) - } - } else { - if err != nil { - t.Errorf("Expected no error but got: %v", err) - } - } - }) - } -} - -func TestDefaultSchedulingAppliedWhenReplicasGreaterThanOne(t *testing.T) { - instance := &llamav1alpha1.LlamaStackDistribution{ - ObjectMeta: metav1.ObjectMeta{ - Name: "sample", - Namespace: "default", - }, - Spec: llamav1alpha1.LlamaStackDistributionSpec{ - Replicas: 2, - Server: llamav1alpha1.ServerSpec{}, - }, - } - - podSpec := configurePodStorage(t.Context(), nil, instance, corev1.Container{}) - - require.NotNil(t, podSpec.Affinity) - require.NotNil(t, podSpec.Affinity.PodAntiAffinity) - require.Len(t, podSpec.Affinity.PodAntiAffinity.PreferredDuringSchedulingIgnoredDuringExecution, 1) - require.Len(t, podSpec.TopologySpreadConstraints, 3) - - keys := []string{ - podSpec.TopologySpreadConstraints[0].TopologyKey, - podSpec.TopologySpreadConstraints[1].TopologyKey, - podSpec.TopologySpreadConstraints[2].TopologyKey, - } - assert.ElementsMatch(t, []string{ - "topology.kubernetes.io/region", - "topology.kubernetes.io/zone", - "kubernetes.io/hostname", - }, keys) -} - -func TestDefaultSchedulingSkippedForSingleReplica(t *testing.T) { - instance := &llamav1alpha1.LlamaStackDistribution{ - ObjectMeta: metav1.ObjectMeta{ - Name: "solo", - Namespace: "default", - }, - Spec: llamav1alpha1.LlamaStackDistributionSpec{ - Replicas: 1, - Server: llamav1alpha1.ServerSpec{}, - }, - } - - podSpec := configurePodStorage(t.Context(), nil, instance, corev1.Container{}) - assert.Nil(t, podSpec.Affinity) - assert.Empty(t, podSpec.TopologySpreadConstraints) -} - -func TestCustomTopologySpreadConstraintsRespected(t *testing.T) { - customConstraint := corev1.TopologySpreadConstraint{ - MaxSkew: 2, - TopologyKey: "custom.topology/key", - WhenUnsatisfiable: corev1.DoNotSchedule, - LabelSelector: &metav1.LabelSelector{ - MatchLabels: map[string]string{ - "custom": "label", - }, - }, - } - - instance := &llamav1alpha1.LlamaStackDistribution{ - ObjectMeta: metav1.ObjectMeta{ - Name: "custom", - Namespace: "default", - }, - Spec: llamav1alpha1.LlamaStackDistributionSpec{ - Replicas: 3, - Server: llamav1alpha1.ServerSpec{ - TopologySpreadConstraints: []corev1.TopologySpreadConstraint{customConstraint}, - }, - }, - } - - podSpec := configurePodStorage(t.Context(), nil, instance, corev1.Container{}) - - require.Len(t, podSpec.TopologySpreadConstraints, 1) - assert.Equal(t, customConstraint.TopologyKey, podSpec.TopologySpreadConstraints[0].TopologyKey) - assert.Equal(t, customConstraint.WhenUnsatisfiable, podSpec.TopologySpreadConstraints[0].WhenUnsatisfiable) + spec := &corev1.PodSpec{Containers: []corev1.Container{{Name: "c"}}} + configurePodOverrides(instance, spec) + assert.Equal(t, "custom-sa", spec.ServiceAccountName) } func TestNeedsPodDisruptionBudget(t *testing.T) { tests := []struct { name string - instance *llamav1alpha1.LlamaStackDistribution - expected bool + instance *ogxiov1beta1.OGXServer + want bool }{ { - name: "single replica without override", - instance: &llamav1alpha1.LlamaStackDistribution{ - Spec: llamav1alpha1.LlamaStackDistributionSpec{Replicas: 1, Server: llamav1alpha1.ServerSpec{}}, - }, - expected: false, + "single replica", + &ogxiov1beta1.OGXServer{Spec: ogxiov1beta1.OGXServerSpec{ + Distribution: ogxiov1beta1.DistributionSpec{Image: "x"}, + Workload: &ogxiov1beta1.WorkloadSpec{Replicas: int32Ptr(1)}, + }}, + false, }, { - name: "multiple replicas without override", - instance: &llamav1alpha1.LlamaStackDistribution{ - Spec: llamav1alpha1.LlamaStackDistributionSpec{Replicas: 2, Server: llamav1alpha1.ServerSpec{}}, - }, - expected: true, + "multiple replicas", + &ogxiov1beta1.OGXServer{Spec: ogxiov1beta1.OGXServerSpec{ + Distribution: ogxiov1beta1.DistributionSpec{Image: "x"}, + Workload: &ogxiov1beta1.WorkloadSpec{Replicas: int32Ptr(2)}, + }}, + true, }, { - name: "explicit override", - instance: &llamav1alpha1.LlamaStackDistribution{ - Spec: llamav1alpha1.LlamaStackDistributionSpec{ - Replicas: 1, - Server: llamav1alpha1.ServerSpec{ - PodDisruptionBudget: &llamav1alpha1.PodDisruptionBudgetSpec{}, - }, + "explicit pdb", + &ogxiov1beta1.OGXServer{Spec: ogxiov1beta1.OGXServerSpec{ + Distribution: ogxiov1beta1.DistributionSpec{Image: "x"}, + Workload: &ogxiov1beta1.WorkloadSpec{ + Replicas: int32Ptr(1), + PodDisruptionBudget: &ogxiov1beta1.PodDisruptionBudgetSpec{}, }, - }, - expected: true, + }}, + true, }, } - for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.expected, needsPodDisruptionBudget(tt.instance)) + assert.Equal(t, tt.want, needsPodDisruptionBudget(tt.instance)) }) } } func TestBuildPodDisruptionBudgetSpec(t *testing.T) { - t.Run("defaults when replicas greater than one", func(t *testing.T) { - instance := &llamav1alpha1.LlamaStackDistribution{ - Spec: llamav1alpha1.LlamaStackDistributionSpec{ - Replicas: 2, - Server: llamav1alpha1.ServerSpec{}, + t.Run("defaults when replicas > 1", func(t *testing.T) { + inst := &ogxiov1beta1.OGXServer{ + Spec: ogxiov1beta1.OGXServerSpec{ + Distribution: ogxiov1beta1.DistributionSpec{Image: "x"}, + Workload: &ogxiov1beta1.WorkloadSpec{Replicas: int32Ptr(2)}, }, } - - spec := buildPodDisruptionBudgetSpec(instance) + spec := buildPodDisruptionBudgetSpec(inst) require.NotNil(t, spec) require.NotNil(t, spec.MaxUnavailable) assert.Equal(t, 1, spec.MaxUnavailable.IntValue()) - assert.Nil(t, spec.MinAvailable) - }) - - t.Run("respects custom overrides", func(t *testing.T) { - minValue := intstr.FromString("50%") - maxValue := intstr.FromInt(1) - instance := &llamav1alpha1.LlamaStackDistribution{ - Spec: llamav1alpha1.LlamaStackDistributionSpec{ - Replicas: 1, - Server: llamav1alpha1.ServerSpec{ - PodDisruptionBudget: &llamav1alpha1.PodDisruptionBudgetSpec{ - MinAvailable: &minValue, - MaxUnavailable: &maxValue, - }, - }, - }, - } - - spec := buildPodDisruptionBudgetSpec(instance) - require.NotNil(t, spec) - require.NotNil(t, spec.MinAvailable) - assert.Equal(t, minValue.String(), spec.MinAvailable.String()) - require.NotNil(t, spec.MaxUnavailable) - assert.Equal(t, maxValue.IntValue(), spec.MaxUnavailable.IntValue()) }) } func TestBuildHPASpec(t *testing.T) { - cpuTarget := int32(70) - memoryTarget := int32(60) - minReplicas := int32(3) - instance := &llamav1alpha1.LlamaStackDistribution{ + cpuT := int32(70) + memT := int32(60) + minR := int32(3) + inst := &ogxiov1beta1.OGXServer{ ObjectMeta: metav1.ObjectMeta{Name: "sample"}, - Spec: llamav1alpha1.LlamaStackDistributionSpec{ - Replicas: 2, - Server: llamav1alpha1.ServerSpec{ - Autoscaling: &llamav1alpha1.AutoscalingSpec{ - MinReplicas: &minReplicas, + Spec: ogxiov1beta1.OGXServerSpec{ + Distribution: ogxiov1beta1.DistributionSpec{Image: "x"}, + Workload: &ogxiov1beta1.WorkloadSpec{ + Replicas: int32Ptr(2), + Autoscaling: &ogxiov1beta1.AutoscalingSpec{ + MinReplicas: &minR, MaxReplicas: 5, - TargetCPUUtilizationPercentage: &cpuTarget, - TargetMemoryUtilizationPercentage: &memoryTarget, + TargetCPUUtilizationPercentage: &cpuT, + TargetMemoryUtilizationPercentage: &memT, }, }, }, } - - spec := buildHPASpec(instance) + spec := buildHPASpec(inst) require.NotNil(t, spec) assert.Equal(t, int32(5), spec.MaxReplicas) - require.NotNil(t, spec.MinReplicas) - assert.Equal(t, minReplicas, *spec.MinReplicas) require.Len(t, spec.Metrics, 2) - assert.Equal(t, autoscalingv2.ResourceMetricSourceType, spec.Metrics[0].Type) - assert.Equal(t, autoscalingv2.UtilizationMetricType, spec.Metrics[0].Resource.Target.Type) - assert.Equal(t, cpuTarget, *spec.Metrics[0].Resource.Target.AverageUtilization) - assert.Equal(t, "Deployment", spec.ScaleTargetRef.Kind) - assert.Equal(t, instance.Name, spec.ScaleTargetRef.Name) - - t.Run("defaults metric when none provided", func(t *testing.T) { - instance := &llamav1alpha1.LlamaStackDistribution{ - ObjectMeta: metav1.ObjectMeta{Name: "sample"}, - Spec: llamav1alpha1.LlamaStackDistributionSpec{ - Replicas: 1, - Server: llamav1alpha1.ServerSpec{ - Autoscaling: &llamav1alpha1.AutoscalingSpec{MaxReplicas: 4}, - }, - }, - } - - spec := buildHPASpec(instance) - require.NotNil(t, spec) - require.Len(t, spec.Metrics, 1) - assert.Equal(t, int32(80), *spec.Metrics[0].Resource.Target.AverageUtilization) - require.NotNil(t, spec.MinReplicas) - assert.Equal(t, int32(1), *spec.MinReplicas) - }) -} - -// newDefaultStartupProbe returns a Kubernetes HTTP readiness probe that checks -// the "/v1/health" endpoint on the given port using default timing and -// threshold settings. -func newDefaultStartupProbe(port int32) *corev1.Probe { - return &corev1.Probe{ - ProbeHandler: corev1.ProbeHandler{ - HTTPGet: &corev1.HTTPGetAction{ - Path: "/v1/health", - Port: intstr.FromInt(int(port)), - }, - }, - InitialDelaySeconds: startupProbeInitialDelaySeconds, - TimeoutSeconds: startupProbeTimeoutSeconds, - FailureThreshold: startupProbeFailureThreshold, - SuccessThreshold: startupProbeSuccessThreshold, - } } diff --git a/controllers/status.go b/controllers/status.go index f4d9c5939..87a6fe5e4 100644 --- a/controllers/status.go +++ b/controllers/status.go @@ -1,7 +1,7 @@ package controllers import ( - llamav1alpha1 "github.com/llamastack/llama-stack-k8s-operator/api/v1alpha1" + ogxiov1beta1 "github.com/ogx-ai/ogx-k8s-operator/api/v1beta1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -15,6 +15,14 @@ const ( ConditionTypeStorageReady = "StorageReady" // ConditionTypeServiceReady indicates whether the service is ready. ConditionTypeServiceReady = "ServiceReady" + // ConditionTypeConfigGenerated indicates whether config generation succeeded. + ConditionTypeConfigGenerated = "ConfigGenerated" + // ConditionTypeStorageAdopted indicates whether legacy storage was adopted. + ConditionTypeStorageAdopted = "StorageAdopted" + // ConditionTypeNetworkingAdopted indicates whether legacy networking was adopted. + ConditionTypeNetworkingAdopted = "NetworkingAdopted" + // ConditionTypeAdoptionConfigInvalid indicates whether adoption annotation values are invalid. + ConditionTypeAdoptionConfigInvalid = "AdoptionConfigInvalid" ) // Condition reasons. @@ -37,6 +45,12 @@ const ( ReasonServiceReady = "ServiceReady" // ReasonServiceFailed indicates the service failed. ReasonServiceFailed = "ServiceFailed" + // ReasonStorageAdopted indicates legacy storage was adopted. + ReasonStorageAdopted = "StorageAdopted" + // ReasonNetworkingAdopted indicates legacy networking was adopted. + ReasonNetworkingAdopted = "NetworkingAdopted" + // ReasonAdoptionConfigInvalid indicates adoption annotation values are invalid. + ReasonAdoptionConfigInvalid = "AdoptionConfigInvalid" ) // Condition messages. @@ -62,7 +76,7 @@ const ( ) // SetDeploymentReadyCondition sets the deployment ready condition. -func SetDeploymentReadyCondition(status *llamav1alpha1.LlamaStackDistributionStatus, ready bool, message string) { +func SetDeploymentReadyCondition(status *ogxiov1beta1.OGXServerStatus, ready bool, message string) { condition := metav1.Condition{ Type: ConditionTypeDeploymentReady, Status: metav1.ConditionTrue, @@ -81,7 +95,7 @@ func SetDeploymentReadyCondition(status *llamav1alpha1.LlamaStackDistributionSta } // SetHealthCheckCondition sets the health check condition. -func SetHealthCheckCondition(status *llamav1alpha1.LlamaStackDistributionStatus, healthy bool, message string) { +func SetHealthCheckCondition(status *ogxiov1beta1.OGXServerStatus, healthy bool, message string) { condition := metav1.Condition{ Type: ConditionTypeHealthCheck, Status: metav1.ConditionTrue, @@ -100,7 +114,7 @@ func SetHealthCheckCondition(status *llamav1alpha1.LlamaStackDistributionStatus, } // SetStorageReadyCondition sets the storage ready condition. -func SetStorageReadyCondition(status *llamav1alpha1.LlamaStackDistributionStatus, ready bool, message string) { +func SetStorageReadyCondition(status *ogxiov1beta1.OGXServerStatus, ready bool, message string) { condition := metav1.Condition{ Type: ConditionTypeStorageReady, Status: metav1.ConditionTrue, @@ -119,7 +133,7 @@ func SetStorageReadyCondition(status *llamav1alpha1.LlamaStackDistributionStatus } // SetServiceReadyCondition sets the service ready condition. -func SetServiceReadyCondition(status *llamav1alpha1.LlamaStackDistributionStatus, ready bool, message string) { +func SetServiceReadyCondition(status *ogxiov1beta1.OGXServerStatus, ready bool, message string) { condition := metav1.Condition{ Type: ConditionTypeServiceReady, Status: metav1.ConditionTrue, @@ -138,7 +152,7 @@ func SetServiceReadyCondition(status *llamav1alpha1.LlamaStackDistributionStatus } // SetCondition sets a condition in the status. -func SetCondition(status *llamav1alpha1.LlamaStackDistributionStatus, condition metav1.Condition) { +func SetCondition(status *ogxiov1beta1.OGXServerStatus, condition metav1.Condition) { // Initialize conditions if needed if status.Conditions == nil { status.Conditions = make([]metav1.Condition, 0) @@ -158,7 +172,7 @@ func SetCondition(status *llamav1alpha1.LlamaStackDistributionStatus, condition } // GetCondition returns a condition by type. -func GetCondition(status *llamav1alpha1.LlamaStackDistributionStatus, conditionType string) *metav1.Condition { +func GetCondition(status *ogxiov1beta1.OGXServerStatus, conditionType string) *metav1.Condition { if status == nil || status.Conditions == nil { return nil } @@ -171,13 +185,28 @@ func GetCondition(status *llamav1alpha1.LlamaStackDistributionStatus, conditionT } // IsConditionTrue returns true if the condition is true. -func IsConditionTrue(status *llamav1alpha1.LlamaStackDistributionStatus, conditionType string) bool { +func IsConditionTrue(status *ogxiov1beta1.OGXServerStatus, conditionType string) bool { condition := GetCondition(status, conditionType) return condition != nil && condition.Status == metav1.ConditionTrue } // IsConditionFalse returns true if the condition is false. -func IsConditionFalse(status *llamav1alpha1.LlamaStackDistributionStatus, conditionType string) bool { +func IsConditionFalse(status *ogxiov1beta1.OGXServerStatus, conditionType string) bool { condition := GetCondition(status, conditionType) return condition != nil && condition.Status == metav1.ConditionFalse } + +// setConfigGeneratedCondition sets the ConfigGenerated condition. +func (r *OGXServerReconciler) setConfigGeneratedCondition(instance *ogxiov1beta1.OGXServer, success bool, reason, message string) { + condition := metav1.Condition{ + Type: ConditionTypeConfigGenerated, + Status: metav1.ConditionTrue, + Reason: reason, + Message: message, + LastTransitionTime: metav1.NewTime(metav1.Now().UTC()), + } + if !success { + condition.Status = metav1.ConditionFalse + } + SetCondition(&instance.Status, condition) +} diff --git a/controllers/suite_test.go b/controllers/suite_test.go index 8f2fd28b6..4f4468382 100644 --- a/controllers/suite_test.go +++ b/controllers/suite_test.go @@ -22,7 +22,7 @@ import ( "path/filepath" "testing" - llamaxk8siov1alpha1 "github.com/llamastack/llama-stack-k8s-operator/api/v1alpha1" + ogxiov1beta1 "github.com/ogx-ai/ogx-k8s-operator/api/v1beta1" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" networkingv1 "k8s.io/api/networking/v1" @@ -58,10 +58,9 @@ func TestMain(m *testing.M) { os.Exit(1) } - // Register all schemes needed by controller tests - err = llamaxk8siov1alpha1.AddToScheme(scheme.Scheme) + err = ogxiov1beta1.AddToScheme(scheme.Scheme) if err != nil { - logf.Log.Error(err, "failed to add llamaxk8siov1alpha1 scheme") + logf.Log.Error(err, "failed to add ogxiov1beta1 scheme") os.Exit(1) } diff --git a/controllers/testing_support_test.go b/controllers/testing_support_test.go index 4d9b9a012..51c2063aa 100644 --- a/controllers/testing_support_test.go +++ b/controllers/testing_support_test.go @@ -8,13 +8,14 @@ import ( "encoding/pem" "fmt" "math/big" + "net/http" "slices" "testing" "time" - llamav1alpha1 "github.com/llamastack/llama-stack-k8s-operator/api/v1alpha1" - controllers "github.com/llamastack/llama-stack-k8s-operator/controllers" - "github.com/llamastack/llama-stack-k8s-operator/pkg/cluster" + ogxiov1beta1 "github.com/ogx-ai/ogx-k8s-operator/api/v1beta1" + controllers "github.com/ogx-ai/ogx-k8s-operator/controllers" + "github.com/ogx-ai/ogx-k8s-operator/pkg/cluster" "github.com/stretchr/testify/require" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" @@ -33,113 +34,126 @@ const ( testTimeout = 5 * time.Second testInterval = 100 * time.Millisecond - // Test-specific identifiers (not production defaults). - testImage = "lls/lls-ollama:1.0" + testImage = "ogx/ogx-ollama:1.0" testOperatorNamespace = "default" - testStorageVolumeName = "lls-storage" + testStorageVolumeName = "ogx-storage" testInstanceName = "test-instance" ) -// DistributionBuilder - Builder pattern for test instances of operator custom resource. -type DistributionBuilder struct { - instance *llamav1alpha1.LlamaStackDistribution +// OGXServerBuilder - Builder pattern for test instances of operator custom resource. +type OGXServerBuilder struct { + instance *ogxiov1beta1.OGXServer } -func NewDistributionBuilder() *DistributionBuilder { - return &DistributionBuilder{ - instance: &llamav1alpha1.LlamaStackDistribution{ +func NewOGXServerBuilder() *OGXServerBuilder { + return &OGXServerBuilder{ + instance: &ogxiov1beta1.OGXServer{ ObjectMeta: metav1.ObjectMeta{ Name: testInstanceName, - Namespace: "default", // Will be overridden in tests + Namespace: "default", }, - Spec: llamav1alpha1.LlamaStackDistributionSpec{ - Replicas: 1, - Server: llamav1alpha1.ServerSpec{ - Distribution: llamav1alpha1.DistributionType{ - Name: "starter", // Real distribution from distributions.json - }, - ContainerSpec: llamav1alpha1.ContainerSpec{ - Name: llamav1alpha1.DefaultContainerName, - Port: llamav1alpha1.DefaultServerPort, - }, + Spec: ogxiov1beta1.OGXServerSpec{ + Distribution: ogxiov1beta1.DistributionSpec{ + Name: "starter", }, }, }, } } -func (b *DistributionBuilder) WithName(name string) *DistributionBuilder { +func (b *OGXServerBuilder) WithName(name string) *OGXServerBuilder { b.instance.Name = name return b } -func (b *DistributionBuilder) WithNamespace(namespace string) *DistributionBuilder { +func (b *OGXServerBuilder) WithNamespace(namespace string) *OGXServerBuilder { b.instance.Namespace = namespace return b } -func (b *DistributionBuilder) WithPort(port int32) *DistributionBuilder { - b.instance.Spec.Server.ContainerSpec.Port = port +func (b *OGXServerBuilder) WithPort(port int32) *OGXServerBuilder { + if b.instance.Spec.Network == nil { + b.instance.Spec.Network = &ogxiov1beta1.NetworkSpec{} + } + b.instance.Spec.Network.Port = port return b } -func (b *DistributionBuilder) WithReplicas(replicas int32) *DistributionBuilder { - b.instance.Spec.Replicas = replicas +func (b *OGXServerBuilder) WithReplicas(replicas int32) *OGXServerBuilder { + if b.instance.Spec.Workload == nil { + b.instance.Spec.Workload = &ogxiov1beta1.WorkloadSpec{} + } + b.instance.Spec.Workload.Replicas = &replicas return b } -func (b *DistributionBuilder) WithStorage(storage *llamav1alpha1.StorageSpec) *DistributionBuilder { - b.instance.Spec.Server.Storage = storage +func (b *OGXServerBuilder) WithStorage(storage *ogxiov1beta1.PVCStorageSpec) *OGXServerBuilder { + if b.instance.Spec.Workload == nil { + b.instance.Spec.Workload = &ogxiov1beta1.WorkloadSpec{} + } + b.instance.Spec.Workload.Storage = storage return b } -func (b *DistributionBuilder) WithDistribution(distributionName string) *DistributionBuilder { - b.instance.Spec.Server.Distribution.Name = distributionName +func (b *OGXServerBuilder) WithDistribution(distributionName string) *OGXServerBuilder { + b.instance.Spec.Distribution.Name = distributionName return b } -func (b *DistributionBuilder) WithResources(resources corev1.ResourceRequirements) *DistributionBuilder { - b.instance.Spec.Server.ContainerSpec.Resources = resources +func (b *OGXServerBuilder) WithResources(resources corev1.ResourceRequirements) *OGXServerBuilder { + if b.instance.Spec.Workload == nil { + b.instance.Spec.Workload = &ogxiov1beta1.WorkloadSpec{} + } + b.instance.Spec.Workload.Resources = &resources return b } -func (b *DistributionBuilder) WithServiceAccountName(serviceAccountName string) *DistributionBuilder { - if b.instance.Spec.Server.PodOverrides == nil { - b.instance.Spec.Server.PodOverrides = &llamav1alpha1.PodOverrides{} +func (b *OGXServerBuilder) WithServiceAccountName(serviceAccountName string) *OGXServerBuilder { + if b.instance.Spec.Workload == nil { + b.instance.Spec.Workload = &ogxiov1beta1.WorkloadSpec{} + } + if b.instance.Spec.Workload.Overrides == nil { + b.instance.Spec.Workload.Overrides = &ogxiov1beta1.WorkloadOverrides{} } - b.instance.Spec.Server.PodOverrides.ServiceAccountName = serviceAccountName + b.instance.Spec.Workload.Overrides.ServiceAccountName = serviceAccountName return b } -func (b *DistributionBuilder) WithUserConfig(configMapName string) *DistributionBuilder { - b.instance.Spec.Server.UserConfig = &llamav1alpha1.UserConfigSpec{ - ConfigMapName: configMapName, +func (b *OGXServerBuilder) WithOverrideConfig(configMapName, key string) *OGXServerBuilder { + b.instance.Spec.OverrideConfig = &ogxiov1beta1.ConfigMapKeyRef{ + Name: configMapName, + Key: key, } return b } -func (b *DistributionBuilder) WithCABundle(configMapName string, configMapKeys []string) *DistributionBuilder { - if b.instance.Spec.Server.TLSConfig == nil { - b.instance.Spec.Server.TLSConfig = &llamav1alpha1.TLSConfig{} +func (b *OGXServerBuilder) WithCACertificates(refs ...ogxiov1beta1.ConfigMapKeyRef) *OGXServerBuilder { + if b.instance.Spec.TLS == nil { + b.instance.Spec.TLS = &ogxiov1beta1.TLSClientConfig{} } - b.instance.Spec.Server.TLSConfig.CABundle = &llamav1alpha1.CABundleConfig{ - ConfigMapName: configMapName, - ConfigMapKeys: configMapKeys, + if b.instance.Spec.TLS.Trust == nil { + b.instance.Spec.TLS.Trust = &ogxiov1beta1.TrustConfig{} } + b.instance.Spec.TLS.Trust.CACertificates = refs return b } -func (b *DistributionBuilder) Build() *llamav1alpha1.LlamaStackDistribution { +func (b *OGXServerBuilder) WithMonitoring(monitoring *ogxiov1beta1.MonitoringSpec) *OGXServerBuilder { + b.instance.Spec.Monitoring = monitoring + return b +} + +func (b *OGXServerBuilder) Build() *ogxiov1beta1.OGXServer { return b.instance.DeepCopy() } -func DefaultTestStorage() *llamav1alpha1.StorageSpec { - return &llamav1alpha1.StorageSpec{} +func DefaultTestStorage() *ogxiov1beta1.PVCStorageSpec { + return &ogxiov1beta1.PVCStorageSpec{} } -func CustomTestStorage(size string, mountPath string) *llamav1alpha1.StorageSpec { +func CustomTestStorage(size string, mountPath string) *ogxiov1beta1.PVCStorageSpec { sizeQuantity := resource.MustParse(size) - return &llamav1alpha1.StorageSpec{ + return &ogxiov1beta1.PVCStorageSpec{ Size: &sizeQuantity, MountPath: mountPath, } @@ -210,11 +224,9 @@ func AssertPVCExists(t *testing.T, client client.Client, namespace, name string) func AssertServiceExposesDeployment(t *testing.T, service *corev1.Service, deployment *appsv1.Deployment) { t.Helper() - // Behavior: Service should target deployment pods via selectors require.Equal(t, service.Spec.Selector, deployment.Spec.Template.Labels, "service selector should match deployment pod labels for traffic routing") - // Behavior: Service port should route to deployment container port require.NotEmpty(t, service.Spec.Ports, "service should expose at least one port") require.NotEmpty(t, deployment.Spec.Template.Spec.Containers, "deployment should have at least one container") @@ -227,11 +239,9 @@ func AssertServiceExposesDeployment(t *testing.T, service *corev1.Service, deplo func AssertNetworkPolicyProtectsDeployment(t *testing.T, networkPolicy *networkingv1.NetworkPolicy, deployment *appsv1.Deployment) { t.Helper() - // Behavior: NetworkPolicy should target the same pods as deployment require.Equal(t, deployment.Spec.Template.Labels, networkPolicy.Spec.PodSelector.MatchLabels, "network policy should protect the same pods as deployment") - // Behavior: NetworkPolicy should allow traffic on deployment container port require.NotEmpty(t, deployment.Spec.Template.Spec.Containers, "deployment should have containers") require.NotEmpty(t, networkPolicy.Spec.Ingress, "network policy should have ingress rules") require.NotEmpty(t, networkPolicy.Spec.Ingress[0].Ports, "network policy should allow specific ports") @@ -242,20 +252,18 @@ func AssertNetworkPolicyProtectsDeployment(t *testing.T, networkPolicy *networki "network policy should allow traffic on deployment container port") } -func AssertResourceOwnedByInstance(t *testing.T, resource metav1.Object, instance *llamav1alpha1.LlamaStackDistribution) { +func AssertResourceOwnedByInstance(t *testing.T, resource metav1.Object, instance *ogxiov1beta1.OGXServer) { t.Helper() - // Behavior: Resource should be owned by the LlamaStackDistribution instance ownerRefs := resource.GetOwnerReferences() require.Len(t, ownerRefs, 1, "resource should have exactly one owner reference") require.Equal(t, instance.GetUID(), ownerRefs[0].UID, - "resource should be owned by the LlamaStackDistribution instance for garbage collection") + "resource should be owned by the OGXServer instance for garbage collection") } func AssertClusterRoleBindingLinksServiceAccount(t *testing.T, crb *rbacv1.ClusterRoleBinding, serviceAccount *corev1.ServiceAccount) { t.Helper() - // Behavior: ClusterRoleBinding should grant permissions to the ServiceAccount require.NotEmpty(t, crb.Subjects, "cluster role binding should have subjects") found := false @@ -275,7 +283,6 @@ func AssertClusterRoleBindingLinksServiceAccount(t *testing.T, crb *rbacv1.Clust func AssertDeploymentUsesServiceAccount(t *testing.T, deployment *appsv1.Deployment, serviceAccount *corev1.ServiceAccount) { t.Helper() - // Behavior: Deployment should use the ServiceAccount for pod identity require.Equal(t, serviceAccount.Name, deployment.Spec.Template.Spec.ServiceAccountName, "deployment pods should use the service account for proper permissions") } @@ -290,14 +297,12 @@ func AssertPVCHasSize(t *testing.T, pvc *corev1.PersistentVolumeClaim, expectedS "PVC should request %s storage, got %s", expectedSize, storageRequest.String()) } -// AssertServicePortMatches verifies that a service has the expected port configuration. func AssertServicePortMatches(t *testing.T, service *corev1.Service, expectedPort corev1.ServicePort) { t.Helper() require.Len(t, service.Spec.Ports, 1, "Service should have exactly one port") require.Equal(t, expectedPort, service.Spec.Ports[0], "Service port should match expected") } -// AssertServiceAndDeploymentPortsAlign verifies that service target port matches deployment container port. func AssertServiceAndDeploymentPortsAlign(t *testing.T, service *corev1.Service, deployment *appsv1.Deployment) { t.Helper() require.Len(t, service.Spec.Ports, 1, "Service should have exactly one port") @@ -309,29 +314,22 @@ func AssertServiceAndDeploymentPortsAlign(t *testing.T, service *corev1.Service, require.Equal(t, serviceTargetPort, containerPort, "Service target port should match deployment container port") } -// AssertServiceSelectorMatches verifies that a service has the expected selector. func AssertServiceSelectorMatches(t *testing.T, service *corev1.Service, expectedSelector map[string]string) { t.Helper() require.Equal(t, expectedSelector, service.Spec.Selector, "Service selector should match expected") } -// AssertServiceAndDeploymentSelectorsAlign verifies that service selector matches deployment pod labels. func AssertServiceAndDeploymentSelectorsAlign(t *testing.T, service *corev1.Service, deployment *appsv1.Deployment) { t.Helper() require.Equal(t, service.Spec.Selector, deployment.Spec.Template.Labels, "Service selector should match deployment pod labels") } -// AssertNetworkPolicyTargetsDeploymentPods verifies that network policy targets the same pods as deployment. func AssertNetworkPolicyTargetsDeploymentPods(t *testing.T, networkPolicy *networkingv1.NetworkPolicy, deployment *appsv1.Deployment) { t.Helper() require.Equal(t, deployment.Spec.Template.Labels, networkPolicy.Spec.PodSelector.MatchLabels, "NetworkPolicy should target same pods as deployment") } -// hasMatchingIngressRule is a generic helper function that checks if a network policy -// contains at least one ingress rule that meets two specific criteria: -// 1. The rule allows traffic on the specified 'port'. -// 2. The rule's source (the 'From' field) matches a custom condition defined by the 'peerPredicate'. func hasMatchingIngressRule( t *testing.T, policy *networkingv1.NetworkPolicy, @@ -340,39 +338,28 @@ func hasMatchingIngressRule( ) bool { t.Helper() for _, rule := range policy.Spec.Ingress { - // First, check if this rule's source (any of its 'From' peers) matches our criteria. - // If not, move on to the next one. if !slices.ContainsFunc(rule.From, peerPredicate) { continue } - // Check if this same rule also allows traffic on the required port. - // Both conditions must be met by a single rule for the policy to be - // considered valid. portMatches := slices.ContainsFunc(rule.Ports, func(p networkingv1.NetworkPolicyPort) bool { return p.Port != nil && p.Port.IntVal == port }) if portMatches { - // This rule meets both the source and port requirements. return true } } - // Returning false signifies that no single rule in the entire policy satisfied - // both the source (predicate) and port conditions for this specific check. return false } -// AssertNetworkPolicyAllowsDeploymentPort verifies that the network policy -// allows traffic from both intra-stack components and the operator. func AssertNetworkPolicyAllowsDeploymentPort(t *testing.T, networkPolicy *networkingv1.NetworkPolicy, deployment *appsv1.Deployment, operatorNamespace string) { t.Helper() require.Len(t, deployment.Spec.Template.Spec.Containers, 1, "Deployment should have exactly one container") require.Len(t, deployment.Spec.Template.Spec.Containers[0].Ports, 1, "Container should have exactly one port") containerPort := deployment.Spec.Template.Spec.Containers[0].Ports[0].ContainerPort - // Behavior 1: Verify a rule exists for same-namespace communication. sameNamespacePredicate := func(peer networkingv1.NetworkPolicyPeer) bool { return peer.PodSelector != nil && len(peer.PodSelector.MatchLabels) == 0 && peer.NamespaceSelector == nil } @@ -380,9 +367,6 @@ func AssertNetworkPolicyAllowsDeploymentPort(t *testing.T, networkPolicy *networ hasMatchingIngressRule(t, networkPolicy, containerPort, sameNamespacePredicate), "NetworkPolicy is missing a rule to allow traffic from all pods in the same namespace on port %d", containerPort) - // Behavior 2: Verify a rule for operator communication exists. - // This allows the operator to communicate with the server pods it manages - // from its separate namespace for tasks like health checks. operatorPredicate := func(peer networkingv1.NetworkPolicyPeer) bool { return peer.NamespaceSelector != nil && peer.NamespaceSelector.MatchLabels["kubernetes.io/metadata.name"] == operatorNamespace } @@ -391,14 +375,12 @@ func AssertNetworkPolicyAllowsDeploymentPort(t *testing.T, networkPolicy *networ "NetworkPolicy is missing a rule to allow traffic from the operator in namespace '%s' on port %d", operatorNamespace, containerPort) } -// AssertNetworkPolicyIsIngressOnly verifies that network policy is configured for ingress-only traffic. func AssertNetworkPolicyIsIngressOnly(t *testing.T, networkPolicy *networkingv1.NetworkPolicy) { t.Helper() expectedPolicyTypes := []networkingv1.PolicyType{networkingv1.PolicyTypeIngress} require.Equal(t, expectedPolicyTypes, networkPolicy.Spec.PolicyTypes, "NetworkPolicy should be ingress-only") } -// AssertNetworkPolicyAbsent verifies that a NetworkPolicy with the given key does not exist. func AssertNetworkPolicyAbsent(t *testing.T, c client.Client, key types.NamespacedName) { t.Helper() require.Eventually(t, func() bool { @@ -414,11 +396,9 @@ func AssertServiceAccountDeploymentAlign(t *testing.T, deployment *appsv1.Deploy "Deployment should use the created ServiceAccount for pod permissions") } -func ReconcileDistribution(t *testing.T, instance *llamav1alpha1.LlamaStackDistribution, enableNetworkPolicy bool) { +func ReconcileOGXServer(t *testing.T, instance *ogxiov1beta1.OGXServer) { t.Helper() - // Create reconciler and run reconciliation reconciler := createTestReconciler() - reconciler.EnableNetworkPolicy = enableNetworkPolicy _, err := reconciler.Reconcile(t.Context(), ctrl.Request{ NamespacedName: types.NamespacedName{ Name: instance.Name, @@ -432,18 +412,14 @@ func ResourceTestName(instanceName, suffix string) string { return instanceName + suffix } -func createTestReconciler() *controllers.LlamaStackDistributionReconciler { +func createTestReconciler() *controllers.OGXServerReconciler { clusterInfo := &cluster.ClusterInfo{ OperatorNamespace: testOperatorNamespace, DistributionImages: map[string]string{ - "starter": testImage, // Use same distribution as builder + "starter": testImage, }, } - return &controllers.LlamaStackDistributionReconciler{ - Client: k8sClient, - Scheme: scheme.Scheme, - ClusterInfo: clusterInfo, - } + return controllers.NewTestReconciler(k8sClient, scheme.Scheme, clusterInfo, &http.Client{}) } func findVolumeByName(t *testing.T, deployment *appsv1.Deployment, volumeName string) *corev1.Volume { @@ -468,42 +444,33 @@ func findVolumeMountByName(t *testing.T, container corev1.Container, volumeName return nil } -// waitForResource waits for a resource to exist (convenience version). func waitForResource(t *testing.T, client client.Client, namespace, name string, resource client.Object) { t.Helper() key := types.NamespacedName{Name: name, Namespace: namespace} waitForResourceWithKey(t, client, key, resource) } -// waitForResourceWithKey waits for a resource using an existing NamespacedName. func waitForResourceWithKey(t *testing.T, client client.Client, key types.NamespacedName, resource client.Object) { t.Helper() waitForResourceWithKeyAndCondition(t, client, key, resource, nil, fmt.Sprintf("timed out waiting for %T %s to be available", resource, key)) } -// waitForResourceWithKeyAndCondition provides the full flexibility for complex conditions. func waitForResourceWithKeyAndCondition(t *testing.T, client client.Client, key types.NamespacedName, resource client.Object, condition func() bool, message string) { t.Helper() - // envtest interacts with a real API server, which is eventually consistent. require.Eventually(t, func() bool { err := client.Get(t.Context(), key, resource) if err != nil { return false } - // If no condition specified, just check existence if condition == nil { return true } - // Otherwise check the custom condition return condition() }, testTimeout, testInterval, message) } -// createTestNamespace creates a unique test namespace and registers cleanup. func createTestNamespace(t *testing.T, namePrefix string) *corev1.Namespace { t.Helper() - // envtest does not fully support namespace deletion and cleanup between test cases. - // To ensure test isolation and avoid interference, a unique namespace is created for each test run. testenvNamespaceCounter++ nsName := fmt.Sprintf("%s-%d", namePrefix, testenvNamespaceCounter) namespace := &corev1.Namespace{ @@ -513,8 +480,6 @@ func createTestNamespace(t *testing.T, namePrefix string) *corev1.Namespace { } require.NoError(t, k8sClient.Create(t.Context(), namespace)) - // Attempt to delete the namespace after the test. While envtest might not fully reclaim it, - // this is good practice and helps keep the test environment cleaner. t.Cleanup(func() { if err := k8sClient.Delete(t.Context(), namespace); err != nil { t.Logf("Failed to delete test namespace %s: %v", namespace.Name, err) @@ -523,17 +488,12 @@ func createTestNamespace(t *testing.T, namePrefix string) *corev1.Namespace { return namespace } -// loadTestCertificate generates a valid self-signed test certificate. -// This function generates certificates programmatically to avoid dependencies on external scripts -// or the openssl command, making tests portable across different environments including CI/CD. func loadTestCertificate(t *testing.T) string { t.Helper() - // Generate a private key privateKey, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err, "Failed to generate private key") - // Create a certificate template serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) require.NoError(t, err, "Failed to generate serial number") @@ -555,11 +515,9 @@ func loadTestCertificate(t *testing.T) string { IsCA: true, } - // Create a self-signed certificate certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey) require.NoError(t, err, "Failed to create certificate") - // Encode certificate to PEM format certPEM := pem.EncodeToMemory(&pem.Block{ Type: "CERTIFICATE", Bytes: certDER, diff --git a/crd-ref-docs.config.yaml b/crd-ref-docs.config.yaml index 970e755ad..0c56c160c 100644 --- a/crd-ref-docs.config.yaml +++ b/crd-ref-docs.config.yaml @@ -2,7 +2,7 @@ render: processor: # RE2 regular expressions describing types that should be excluded from the generated documentation. ignoreTypes: - - "(LlamaStackDistribution)List$" + - "(OGXServer)List$" # Version of Kubernetes to use when generating links to Kubernetes API documentation. kubernetesVersion: 1.31 diff --git a/distributions.json b/distributions.json index 80dd7d7bf..e897e3359 100644 --- a/distributions.json +++ b/distributions.json @@ -1,6 +1,6 @@ { - "starter": "docker.io/llamastack/distribution-starter:latest", - "remote-vllm": "docker.io/llamastack/distribution-remote-vllm:latest", - "meta-reference-gpu": "docker.io/llamastack/distribution-meta-reference-gpu:latest", - "postgres-demo": "docker.io/llamastack/distribution-postgres-demo:latest" + "starter": "docker.io/ogxai/distribution-starter:latest", + "remote-vllm": "docker.io/ogxai/distribution-remote-vllm:latest", + "meta-reference-gpu": "docker.io/ogxai/distribution-meta-reference-gpu:latest", + "postgres-demo": "docker.io/ogxai/distribution-postgres-demo:latest" } diff --git a/docs/additional/ca-bundle-configuration.md b/docs/additional/ca-bundle-configuration.md index cf83cebba..440930376 100644 --- a/docs/additional/ca-bundle-configuration.md +++ b/docs/additional/ca-bundle-configuration.md @@ -1,19 +1,21 @@ -# CA Bundle Configuration for LlamaStackDistribution +# CA Bundle Configuration for OGXServer -This document explains how to configure custom CA bundles for LlamaStackDistribution to enable secure communication with external LLM providers using self-signed certificates. +This document explains how to configure custom CA bundles for OGXServer to enable secure communication with external LLM providers using self-signed certificates. ## Overview The CA bundle configuration allows you to: - Use self-signed certificates for external LLM API connections - Trust custom Certificate Authorities (CAs) for secure communication -- Mount CA certificates from ConfigMaps into the LlamaStack server pods +- Mount CA certificates from ConfigMaps into the OGX server pods + +Source ConfigMaps must live in the **same namespace** as the OGXServer and must include the label **`ogx.io/watch: "true"`** so the operator can watch them for changes. ## How It Works When you configure a CA bundle: -1. **ConfigMap Storage**: CA certificates are stored in a Kubernetes ConfigMap (source ConfigMap) +1. **ConfigMap Storage**: CA certificates are stored in a Kubernetes ConfigMap (source ConfigMap) in the same namespace as the OGXServer, with `ogx.io/watch: "true"` on the ConfigMap metadata 2. **Controller Processing**: The operator controller reads and validates certificates from the source ConfigMap(s) 3. **Concatenation**: Valid certificates are concatenated into a single PEM file using Go's `encoding/pem` package 4. **Managed ConfigMap**: The operator creates a managed ConfigMap named `{instance-name}-ca-bundle` containing the concatenated bundle @@ -26,7 +28,7 @@ When you configure a CA bundle: The operator processes CA bundle certificates in the controller before deployment: **Processing Steps:** -1. The controller reads CA certificate data from the source ConfigMap(s) specified in `tlsConfig.caBundle` +1. The controller reads CA certificate data from the source ConfigMap(s) specified in `spec.tls.trust.caCertificates` 2. Each certificate is validated using Go's `encoding/pem` package to ensure proper PEM format 3. Valid `CERTIFICATE` blocks are extracted and concatenated into a single PEM file 4. The concatenated bundle is stored in a managed ConfigMap named `{instance-name}-ca-bundle` with key `ca-bundle.crt` @@ -46,46 +48,46 @@ The operator processes CA bundle certificates in the controller before deploymen ### Basic CA Bundle Configuration ```yaml -apiVersion: llamastack.io/v1alpha1 -kind: LlamaStackDistribution +apiVersion: ogx.io/v1beta1 +kind: OGXServer metadata: - name: my-llama-stack + name: my-ogx-server spec: - server: - distribution: - name: hf-serverless - tlsConfig: - caBundle: - configMapName: my-ca-bundle - # configMapNamespace: default # Optional - defaults to CR namespace - # configMapKey: ca-bundle.crt # Optional - defaults to "ca-bundle.crt" + distribution: + name: hf-serverless + tls: + trust: + caCertificates: + - name: my-ca-bundle + key: ca-bundle.crt ``` -### Multiple CA Bundle Keys Configuration (RHOAI Pattern) +### Multiple CA Sources (RHOAI Pattern) ```yaml -apiVersion: llamastack.io/v1alpha1 -kind: LlamaStackDistribution +apiVersion: ogx.io/v1beta1 +kind: OGXServer metadata: - name: my-llama-stack + name: my-ogx-server spec: - server: - distribution: - name: hf-serverless - tlsConfig: - caBundle: - configMapName: odh-trusted-ca-bundle - # configMapNamespace: default # Optional - defaults to CR namespace - configMapKeys: # Multiple keys from same ConfigMap - - ca-bundle.crt # CNO-injected cluster CAs - - odh-ca-bundle.crt # User-specified custom CAs + distribution: + name: hf-serverless + tls: + trust: + caCertificates: + - name: odh-trusted-ca-bundle + key: ca-bundle.crt # CNO-injected cluster CAs + - name: odh-trusted-ca-bundle + key: odh-ca-bundle.crt # User-specified custom CAs ``` ### Configuration Fields -- `configMapName` (required): Name of the ConfigMap containing CA certificates -- `configMapNamespace` (optional): Namespace of the ConfigMap. Defaults to the same namespace as the LlamaStackDistribution -- `configMapKeys` (optional): Array of keys within the ConfigMap containing CA bundle data. All certificates from these keys will be concatenated into a single CA bundle file. If not specified, defaults to `["ca-bundle.crt"]` +- `spec.tls.trust.caCertificates` (array of ConfigMapKeyRef): Each entry references a specific key in a ConfigMap containing PEM-encoded CA certificates. All certificates from all entries are concatenated into a single CA bundle file. + - `name` (required): Name of the ConfigMap containing the CA certificate. + - `key` (required): Key within the ConfigMap containing the PEM-encoded certificate data. + +**ConfigMap requirements:** The referenced ConfigMap must include `metadata.labels["ogx.io/watch"]` set to `"true"`. ## Examples @@ -96,69 +98,62 @@ apiVersion: v1 kind: ConfigMap metadata: name: my-ca-bundle + labels: + ogx.io/watch: "true" data: ca-bundle.crt: | -----BEGIN CERTIFICATE----- # ... your CA certificate data here ... -----END CERTIFICATE----- --- -apiVersion: llamastack.io/v1alpha1 -kind: LlamaStackDistribution +apiVersion: ogx.io/v1beta1 +kind: OGXServer metadata: - name: secure-llama-stack + name: secure-ogx-server spec: - server: - distribution: - name: hf-serverless - tlsConfig: - caBundle: - configMapName: my-ca-bundle + distribution: + name: hf-serverless + tls: + trust: + caCertificates: + - name: my-ca-bundle + key: ca-bundle.crt ``` ### Example 2: Custom Key Name +Reference a non-default key name from the ConfigMap: + ```yaml apiVersion: v1 kind: ConfigMap metadata: name: my-ca-bundle + labels: + ogx.io/watch: "true" data: custom-ca.pem: | -----BEGIN CERTIFICATE----- # ... your CA certificate data here ... -----END CERTIFICATE----- --- -apiVersion: llamastack.io/v1alpha1 -kind: LlamaStackDistribution +apiVersion: ogx.io/v1beta1 +kind: OGXServer metadata: - name: secure-llama-stack + name: secure-ogx-server spec: - server: - distribution: - name: hf-serverless - tlsConfig: - caBundle: - configMapName: my-ca-bundle - configMapKey: custom-ca.pem + distribution: + name: hf-serverless + tls: + trust: + caCertificates: + - name: my-ca-bundle + key: custom-ca.pem ``` -### Example 3: Cross-Namespace CA Bundle +### Example 3: Same Namespace Only -```yaml -apiVersion: llamastack.io/v1alpha1 -kind: LlamaStackDistribution -metadata: - name: secure-llama-stack - namespace: my-namespace -spec: - server: - distribution: - name: hf-serverless - tlsConfig: - caBundle: - configMapName: global-ca-bundle - configMapNamespace: kube-system -``` +CA bundle ConfigMaps must be in the **same namespace** as the OGXServer. There is no cross-namespace reference support; create or copy the bundle ConfigMap into the OGXServer namespace before referencing it. ### Example 4: RHOAI Pattern with Multiple CA Sources @@ -168,6 +163,7 @@ kind: ConfigMap metadata: name: odh-trusted-ca-bundle labels: + ogx.io/watch: "true" config.openshift.io/inject-trusted-cabundle: "true" data: ca-bundle.crt: | @@ -184,24 +180,32 @@ data: # ... custom CA certificate 2 ... -----END CERTIFICATE----- --- -apiVersion: llamastack.io/v1alpha1 -kind: LlamaStackDistribution +apiVersion: ogx.io/v1beta1 +kind: OGXServer metadata: - name: rhoai-llama-stack + name: rhoai-ogx-server spec: - server: - distribution: - name: hf-serverless - tlsConfig: - caBundle: - configMapName: odh-trusted-ca-bundle - configMapKeys: - - ca-bundle.crt # Cluster CAs - - odh-ca-bundle.crt # Custom CAs + distribution: + name: hf-serverless + tls: + trust: + caCertificates: + - name: odh-trusted-ca-bundle + key: ca-bundle.crt + - name: odh-trusted-ca-bundle + key: odh-ca-bundle.crt ``` ## Creating CA Bundle ConfigMaps +Every CA bundle ConfigMap must be labeled so the operator watches it: + +```yaml +metadata: + labels: + ogx.io/watch: "true" +``` + ### From Certificate Files ```bash @@ -212,6 +216,9 @@ kubectl create configmap my-ca-bundle --from-file=ca-bundle.crt=/path/to/your/ca kubectl create configmap my-ca-bundle \ --from-file=ca-bundle.crt=/path/to/your/ca1.crt \ --from-file=additional-ca.crt=/path/to/your/ca2.crt + +# Label for OGX (required before the operator will track updates) +kubectl label configmap my-ca-bundle ogx.io/watch=true ``` ### From Certificate Content @@ -219,6 +226,7 @@ kubectl create configmap my-ca-bundle \ ```bash # Create a ConfigMap with certificate content kubectl create configmap my-ca-bundle --from-literal=ca-bundle.crt="$(cat /path/to/your/ca.crt)" +kubectl label configmap my-ca-bundle ogx.io/watch=true ``` ## Use Cases @@ -229,36 +237,39 @@ When using private cloud LLM providers with self-signed certificates: ```yaml spec: - server: - distribution: - name: hf-serverless - containerSpec: + distribution: + name: hf-serverless + workload: + overrides: env: - - name: HF_API_KEY - valueFrom: - secretKeyRef: - name: hf-api-key - key: token - userConfig: - configMapName: llama-stack-config - tlsConfig: - caBundle: - configMapName: private-cloud-ca-bundle + - name: HF_API_KEY + valueFrom: + secretKeyRef: + name: hf-api-key + key: token + overrideConfig: + name: ogx-config + key: config.yaml + tls: + trust: + caCertificates: + - name: private-cloud-ca-bundle + key: ca-bundle.crt ``` ### 2. Internal Enterprise APIs -For enterprise environments with internal CAs: +For enterprise environments with internal CAs, place the CA bundle ConfigMap in the same namespace as the OGXServer and reference it by name: ```yaml spec: - server: - distribution: - name: hf-endpoint - tlsConfig: - caBundle: - configMapName: enterprise-ca-bundle - configMapNamespace: security-system + distribution: + name: hf-endpoint + tls: + trust: + caCertificates: + - name: enterprise-ca-bundle + key: ca-bundle.crt ``` ### 3. Development/Testing @@ -267,13 +278,13 @@ For development environments with self-signed certificates: ```yaml spec: - server: - distribution: - name: ollama - tlsConfig: - caBundle: - configMapName: dev-ca-bundle - configMapKey: development-ca.pem + distribution: + name: ollama + tls: + trust: + caCertificates: + - name: dev-ca-bundle + key: development-ca.pem ``` ## Troubleshooting @@ -284,12 +295,13 @@ spec: 2. **Permission Denied**: Check that the operator has permissions to read the ConfigMap 3. **Invalid Certificate**: Verify the certificate format is correct (PEM format) 4. **Pod Not Restarting**: ConfigMap changes trigger automatic pod restarts via annotations +5. **ConfigMap Not Watched**: Ensure the source ConfigMap has label `ogx.io/watch: "true"` ### Common Error Messages and Solutions #### "CA bundle key not found in ConfigMap" - **Cause**: The specified key doesn't exist in the ConfigMap data -- **Solution**: Check the key name in your LlamaStackDistribution spec, default is "ca-bundle.crt" +- **Solution**: Check the `key` field in your `spec.tls.trust.caCertificates` entry matches an existing key in the ConfigMap - **Example**: Verify `kubectl get configmap my-ca-bundle -o yaml` shows your expected key #### "Invalid CA bundle format" @@ -298,9 +310,9 @@ spec: - **Example**: Valid format starts with `-----BEGIN CERTIFICATE-----` #### "Referenced CA bundle ConfigMap not found" -- **Cause**: The ConfigMap specified in tlsConfig.caBundle.configMapName doesn't exist -- **Solution**: Create the ConfigMap first, then apply the LlamaStackDistribution -- **Example**: `kubectl create configmap my-ca-bundle --from-file=ca-bundle.crt=my-ca.crt` +- **Cause**: The ConfigMap specified in `spec.tls.trust.caCertificates[].name` doesn't exist or is not in the same namespace as the OGXServer +- **Solution**: Create the ConfigMap in the OGX namespace first, label it with `ogx.io/watch=true`, then apply the OGXServer +- **Example**: `kubectl create configmap my-ca-bundle --from-file=ca-bundle.crt=my-ca.crt && kubectl label configmap my-ca-bundle ogx.io/watch=true` #### "No valid certificates found in CA bundle" - **Cause**: The ConfigMap contains data but no parseable certificates @@ -319,13 +331,13 @@ spec: kubectl get configmap my-ca-bundle -o yaml # Check pod environment variables -kubectl describe pod +kubectl describe pod # Check mounted certificates -kubectl exec -- ls -la /etc/ssl/certs/ca-bundle/ +kubectl exec -- ls -la /etc/ssl/certs/ca-bundle/ # Check SSL_CERT_FILE environment variable -kubectl exec -- env | grep SSL_CERT_FILE +kubectl exec -- env | grep SSL_CERT_FILE # Validate certificate format locally openssl x509 -text -noout -in ca-bundle.crt @@ -339,10 +351,11 @@ openssl verify -CAfile ca-bundle.crt server.crt ### Validation Checklist -Before deploying a LlamaStackDistribution with CA bundle: +Before deploying an OGXServer with CA bundle: -- [ ] ConfigMap exists in the correct namespace -- [ ] ConfigMap contains the specified key (default: "ca-bundle.crt") +- [ ] ConfigMap exists in the **same namespace** as the OGXServer +- [ ] ConfigMap has label `ogx.io/watch: "true"` +- [ ] ConfigMap contains the key referenced in `spec.tls.trust.caCertificates[].key` - [ ] Certificate data is in PEM format - [ ] Certificate data contains valid X.509 certificates - [ ] Operator has read permissions on the ConfigMap @@ -353,7 +366,7 @@ Before deploying a LlamaStackDistribution with CA bundle: 1. **ConfigMap Security**: ConfigMaps are stored in plain text in etcd. Consider using appropriate RBAC policies 2. **Certificate Rotation**: Update ConfigMaps when certificates expire or are rotated -3. **Namespace Isolation**: Use appropriate namespaces to isolate CA bundles +3. **Namespace Isolation**: CA bundle ConfigMaps must reside in the same namespace as the OGXServer; use namespaces to isolate workloads and secrets 4. **Audit Trail**: Monitor ConfigMap changes in production environments 5. **Principle of Least Privilege**: Only grant necessary permissions to access CA bundle ConfigMaps 6. **Resource Limits**: The operator enforces limits during certificate concatenation (10MB max bundle size, 1000 max certificates) to prevent resource exhaustion @@ -365,4 +378,4 @@ Before deploying a LlamaStackDistribution with CA bundle: - ConfigMap size limits apply (1MB by default for source ConfigMaps) - Maximum bundle size is 10MB and maximum 1000 certificates (enforced by controller) - Certificate validation is handled by Go's `encoding/pem` and `crypto/x509` packages in the controller -- Cross-namespace ConfigMap access requires appropriate RBAC permissions +- The CA bundle ConfigMap must be in the same namespace as the OGXServer (cross-namespace references are not supported) diff --git a/docs/additional/runtime-config-generation.md b/docs/additional/runtime-config-generation.md new file mode 100644 index 000000000..c81d9574d --- /dev/null +++ b/docs/additional/runtime-config-generation.md @@ -0,0 +1,173 @@ +# Runtime Config Generation from OGXServer CR + +This guide explains how to use OGX runtime config generation directly from the `OGXServer` custom resource, without hand-writing a full `config.yaml`. + +## Overview + +The operator can generate server `config.yaml` from declarative CR fields: + +- `spec.providers` +- `spec.resources` +- `spec.storage` +- `spec.disabledAPIs` + +When this mode is active, the operator resolves a base config from `spec.baseConfig` when provided, otherwise from OCI image labels, merges your CR values, writes an immutable generated ConfigMap, and mounts it into the server pod. + +## Precedence Rules + +The operator supports two config modes: + +1. **Override mode**: `spec.overrideConfig` points to a user-managed ConfigMap key. +2. **Generated mode**: declarative CR fields generate `config.yaml`. + +If `spec.overrideConfig` is set, it takes precedence over generated mode. +The mounted runtime config always comes from `spec.overrideConfig` or the +generated ConfigMap; `spec.baseConfig` is only an input to generation. + +## Required Resource Labels and Namespace Scope + +Any ConfigMap/Secret referenced by the CR must: + +- Be in the **same namespace** as the `OGXServer` +- Have label `ogx.io/watch: "true"` + +Example: + +```yaml +metadata: + labels: + ogx.io/watch: "true" +``` + +This label allows the operator to watch changes and trigger reconciliation. + +## How It Works + +1. If `spec.baseConfig` is set, read that ConfigMap key as the base config. +2. Otherwise, resolve base config from OCI labels `com.ogx.distribution.default-config` and `com.ogx.config.` on the resolved distribution image. +3. Expand providers/resources/storage from CR spec. +4. Merge with base config: + - providers: user values replace base per API type + - models/resources: user values replace base models + - storage: user value replaces base storage + - APIs: base filtered by `disabledAPIs` +5. Create immutable ConfigMap: `${ogxserver-name}-config-${contentHash}`. +6. Mount generated `config.yaml` to `/etc/ogx/config.yaml`. +7. Inject secret-backed environment variables for provider/storage credentials. +8. Roll deployment when referenced ConfigMaps/Secrets change. + +## Minimal Declarative Example + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: openai-creds + labels: + ogx.io/watch: "true" +stringData: + api-key: "" +--- +apiVersion: ogx.io/v1beta1 +kind: OGXServer +metadata: + name: generated-config-sample +spec: + distribution: + name: starter + providers: + inference: + remote: + openai: + - id: openai-primary + apiKey: + name: openai-creds + key: api-key + resources: + models: + - name: gpt-4o-mini + provider: openai-primary +``` + +Apply a ready-to-use sample from this repository: + +```bash +kubectl apply -f config/samples/example-with-generated-config.yaml +``` + +## `configgen` CLI + +The `configgen` CLI runs the same generation pipeline outside Kubernetes. + +```bash +configgen -base [-distributions-path distributions.json] [-output-config] [-validate] +``` + +Notes: + +- If the CR uses `spec.baseConfig`, pass that file with `-base`. +- If the CR only sets `spec.distribution.name`, pass `-base` as well; image + resolution for named distributions happens in the operator. +- `-validate` uses `distributions.json` to validate `spec.distribution.name`. + +## Storage Example (Postgres) + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: pg-conn + labels: + ogx.io/watch: "true" +stringData: + connection-string: "postgresql://user:pass@postgres:5432/ogx" +--- +apiVersion: ogx.io/v1beta1 +kind: OGXServer +metadata: + name: generated-config-with-storage +spec: + distribution: + name: starter + storage: + sql: + type: postgres + connectionString: + name: pg-conn + key: connection-string +``` + +## Status and Conditions + +When generation is active, status includes: + +- `.status.configGeneration.configMapName` +- `.status.configGeneration.generatedAt` +- `.status.configGeneration.providerCount` +- `.status.configGeneration.resourceCount` +- `.status.configGeneration.configVersion` + +Condition `ConfigGenerated` indicates current generation state: + +- `True` with reason `ConfigGenerationSucceeded` when generation succeeds +- `False` with reasons like `ConfigGenerationFailed` or `ConfigGenerationInactive` + +## Rollout Behavior + +Pod template annotations include hashes for generated config and referenced inputs. +Changing any of these triggers rollout: + +- Generated config content +- Managed CA bundle (if configured) +- Referenced Secret resource versions + +## Troubleshooting + +- **Config not generated** + - Check `kubectl get ogxserver -o yaml` for `ConfigGenerated` condition and messages. +- **No restart after editing Secret/ConfigMap** + - Ensure referenced object has `ogx.io/watch: "true"` and same namespace as `OGXServer`. +- **Validation errors for generated env var names** + - Check provider IDs and custom secretRef keys for collisions after normalization. +- **Hash collision/content mismatch error** + - Operator detected existing generated ConfigMap name with different content; update CR and re-reconcile. diff --git a/docs/api-overview.md b/docs/api-overview.md index c10abe5c9..bba5e95eb 100644 --- a/docs/api-overview.md +++ b/docs/api-overview.md @@ -2,6 +2,7 @@ ## Packages - [llamastack.io/v1alpha1](#llamastackiov1alpha1) +- [ogx.io/v1beta1](#ogxiov1beta1) ## llamastack.io/v1alpha1 @@ -203,7 +204,7 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | | `serviceAccountName` _string_ | ServiceAccountName allows users to specify their own ServiceAccount
If not specified, the operator will use the default ServiceAccount | | | -| `terminationGracePeriodSeconds` _integer_ | TerminationGracePeriodSeconds is the time allowed for graceful pod shutdown.
If not specified, Kubernetes defaults to 30 seconds. | | | +| `terminationGracePeriodSeconds` _[int64](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#int64-v1-core)_ | TerminationGracePeriodSeconds is the time allowed for graceful pod shutdown.
If not specified, Kubernetes defaults to 30 seconds. | | | | `volumes` _[Volume](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#volume-v1-core) array_ | | | | | `volumeMounts` _[VolumeMount](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#volumemount-v1-core) array_ | | | | @@ -299,3 +300,1261 @@ _Appears in:_ | `operatorVersion` _string_ | OperatorVersion is the version of the operator managing this distribution | | | | `llamaStackServerVersion` _string_ | LlamaStackServerVersion is the version of the LlamaStack server | | | | `lastUpdated` _[Time](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#time-v1-meta)_ | LastUpdated represents when the version information was last updated | | | + +## ogx.io/v1beta1 + +Package v1beta1 contains API Schema definitions for the ogx.io v1beta1 API group. + +### Resource Types +- [OGXServer](#ogxserver) +- [OGXServerList](#ogxserverlist) + +#### AutoscalingSpec + +AutoscalingSpec configures HorizontalPodAutoscaler targets. + +_Appears in:_ +- [WorkloadSpec](#workloadspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `minReplicas` _integer_ | MinReplicas is the lower bound replica count. | | Minimum: 1
| +| `maxReplicas` _integer_ | MaxReplicas is the upper bound replica count. | | Minimum: 1
Required: \{\}
| +| `targetCPUUtilizationPercentage` _integer_ | TargetCPUUtilizationPercentage configures CPU-based scaling. | | Maximum: 100
Minimum: 1
| +| `targetMemoryUtilizationPercentage` _integer_ | TargetMemoryUtilizationPercentage configures memory-based scaling. | | Maximum: 100
Minimum: 1
| + +#### AzureProvider + +AzureProvider configures a remote::azure inference provider instance. + +_Appears in:_ +- [InferenceRemoteProviders](#inferenceremoteproviders) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `id` _string_ | ID is a unique provider identifier. Derived from the provider
type when omitted. Must be unique across all providers. | | | +| `allowedModels` _string array_ | AllowedModels restricts which models can be registered with this provider.
When empty, all models are allowed. | | MinItems: 1
items:MinLength: 1
| +| `refreshModels` _boolean_ | RefreshModels controls whether the provider periodically refreshes
its model list from the remote endpoint. | | | +| `network` _[NetworkConfig](#networkconfig)_ | Network configures network settings (TLS, proxy, timeouts, headers)
for the remote connection. | | | +| `endpoint` _string_ | Endpoint is the Azure API base URL
(e.g., https://your-resource-name.openai.azure.com/openai/v1). | | MinLength: 1
Required: \{\}
| +| `apiKey` _[SecretKeyRef](#secretkeyref)_ | APIKey is the authentication credential for the Azure provider.
The Secret must be in the same namespace as the OGXServer
and must have the label ogx.io/watch: "true". | | Required: \{\}
| +| `apiVersion` _string_ | APIVersion is the Azure API version (e.g., 2024-12-01-preview). | | | +| `apiType` _string_ | APIType is the Azure API type (e.g., azure). | | | + +#### BatchesInlineProviders + +BatchesInlineProviders groups inline batches providers. + +_Appears in:_ +- [BatchesProvidersSpec](#batchesprovidersspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `reference` _[InlineReferenceProvider](#inlinereferenceprovider)_ | | | | +| `custom` _[CustomProvider](#customprovider) array_ | | | MaxItems: 100
MinItems: 1
| + +#### BatchesProvidersSpec + +BatchesProvidersSpec configures batches providers. + +_Appears in:_ +- [ProvidersSpec](#providersspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `remote` _[BatchesRemoteProviders](#batchesremoteproviders)_ | | | | +| `inline` _[BatchesInlineProviders](#batchesinlineproviders)_ | | | | + +#### BatchesRemoteProviders + +BatchesRemoteProviders groups remote batches providers. + +_Appears in:_ +- [BatchesProvidersSpec](#batchesprovidersspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `custom` _[CustomProvider](#customprovider) array_ | | | MaxItems: 100
MinItems: 1
| + +#### BedrockProvider + +BedrockProvider configures a remote::bedrock inference provider instance. + +_Appears in:_ +- [InferenceRemoteProviders](#inferenceremoteproviders) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `id` _string_ | ID is a unique provider identifier. Derived from the provider
type when omitted. Must be unique across all providers. | | | +| `allowedModels` _string array_ | AllowedModels restricts which models can be registered with this provider.
When empty, all models are allowed. | | MinItems: 1
items:MinLength: 1
| +| `refreshModels` _boolean_ | RefreshModels controls whether the provider periodically refreshes
its model list from the remote endpoint. | | | +| `network` _[NetworkConfig](#networkconfig)_ | Network configures network settings (TLS, proxy, timeouts, headers)
for the remote connection. | | | +| `region` _string_ | Region is the AWS region for the Bedrock Runtime endpoint. | | MinLength: 1
Required: \{\}
| +| `apiKey` _[SecretKeyRef](#secretkeyref)_ | APIKey is the authentication credential for the Bedrock provider.
The Secret must be in the same namespace as the OGXServer
and must have the label ogx.io/watch: "true". | | | +| `awsAccessKeyId` _[SecretKeyRef](#secretkeyref)_ | AWSAccessKeyID is the AWS access key to use.
The Secret must be in the same namespace as the OGXServer
and must have the label ogx.io/watch: "true". | | | +| `awsSecretAccessKey` _[SecretKeyRef](#secretkeyref)_ | AWSSecretAccessKey is the AWS secret access key to use.
The Secret must be in the same namespace as the OGXServer
and must have the label ogx.io/watch: "true". | | | +| `awsSessionToken` _[SecretKeyRef](#secretkeyref)_ | AWSSessionToken is the AWS session token to use.
The Secret must be in the same namespace as the OGXServer
and must have the label ogx.io/watch: "true". | | | +| `awsRoleArn` _string_ | AWSRoleArn is the AWS role ARN to assume. | | | +| `awsWebIdentityTokenFile` _string_ | AWSWebIdentityTokenFile is the path to the web identity token file. | | | +| `awsRoleSessionName` _string_ | AWSRoleSessionName is the session name to use when assuming a role. | | | +| `profileName` _string_ | ProfileName is the AWS profile name that contains credentials to use. | | | +| `totalMaxAttempts` _integer_ | TotalMaxAttempts is the maximum number of attempts for a single request,
including the initial attempt. | | Minimum: 1
| +| `retryMode` _string_ | RetryMode is the type of retries to perform (e.g., standard, adaptive). | | | +| `connectTimeout` _integer_ | ConnectTimeout is the connection timeout in seconds. | | Minimum: 1
| +| `readTimeout` _integer_ | ReadTimeout is the read timeout in seconds. | | Minimum: 1
| +| `sessionTTL` _integer_ | SessionTTL is the time in seconds until a session expires. | | Minimum: 1
| + +#### BraveSearchProvider + +BraveSearchProvider configures a remote::brave-search tool runtime provider. + +_Appears in:_ +- [ToolRuntimeRemoteProviders](#toolruntimeremoteproviders) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `id` _string_ | ID is a unique provider identifier. Derived from the provider
type when omitted. Must be unique across all providers. | | | +| `apiKey` _[SecretKeyRef](#secretkeyref)_ | APIKey is the Brave Search API key.
The Secret must be in the same namespace as the OGXServer
and must have the label ogx.io/watch: "true". | | Required: \{\}
| +| `maxResults` _integer_ | MaxResults is the maximum number of search results to return. | | Minimum: 1
| + +#### CompactionConfig + +_Underlying type:_ _[struct{SummarizationPrompt string "json:\"summarizationPrompt,omitempty\""; SummaryPrefix string "json:\"summaryPrefix,omitempty\""; SummarizationModel string "json:\"summarizationModel,omitempty\""; DefaultCompactThreshold *int "json:\"defaultCompactThreshold,omitempty\""; TokenizerEncoding string "json:\"tokenizerEncoding,omitempty\""}](#struct{summarizationprompt-string-"json:\"summarizationprompt,omitempty\"";-summaryprefix-string-"json:\"summaryprefix,omitempty\"";-summarizationmodel-string-"json:\"summarizationmodel,omitempty\"";-defaultcompactthreshold-*int-"json:\"defaultcompactthreshold,omitempty\"";-tokenizerencoding-string-"json:\"tokenizerencoding,omitempty\""})_ + +CompactionConfig configures conversation compaction behavior for responses. + +_Appears in:_ +- [InlineBuiltinResponsesProvider](#inlinebuiltinresponsesprovider) + +#### ConfigGenerationStatus + +ConfigGenerationStatus tracks config generation details. + +_Appears in:_ +- [OGXServerStatus](#ogxserverstatus) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `observedGeneration` _integer_ | ObservedGeneration is the spec generation that was last processed. | | | +| `configMapName` _string_ | ConfigMapName is the name of the generated ConfigMap. | | | +| `generatedAt` _[Time](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#time-v1-meta)_ | GeneratedAt is the timestamp of the last generation. | | | +| `providerCount` _integer_ | ProviderCount is the number of configured providers. | | | +| `resourceCount` _integer_ | ResourceCount is the number of registered resources. | | | +| `configVersion` _integer_ | ConfigVersion is the config.yaml schema version. | | | + +#### ConfigMapKeyRef + +ConfigMapKeyRef references a key within a ConfigMap. +The ConfigMap must be in the same namespace as the OGXServer and must have +the label ogx.io/watch: "true" to be detected by the operator's cache. + +_Appears in:_ +- [IdentityConfig](#identityconfig) +- [OGXServerSpec](#ogxserverspec) +- [TrustConfig](#trustconfig) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `name` _string_ | Name is the name of the ConfigMap. | | MinLength: 1
Required: \{\}
| +| `key` _string_ | Key is the key within the ConfigMap. | | MaxLength: 253
MinLength: 1
Pattern: `^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$`
Required: \{\}
| + +#### CustomProvider + +CustomProvider defines the configuration for a custom provider instance. + +_Appears in:_ +- [BatchesInlineProviders](#batchesinlineproviders) +- [BatchesRemoteProviders](#batchesremoteproviders) +- [FileProcessorsInlineProviders](#fileprocessorsinlineproviders) +- [FileProcessorsRemoteProviders](#fileprocessorsremoteproviders) +- [FilesInlineProviders](#filesinlineproviders) +- [FilesRemoteProviders](#filesremoteproviders) +- [InferenceInlineProviders](#inferenceinlineproviders) +- [InferenceRemoteProviders](#inferenceremoteproviders) +- [ResponsesInlineProviders](#responsesinlineproviders) +- [ResponsesRemoteProviders](#responsesremoteproviders) +- [ToolRuntimeInlineProviders](#toolruntimeinlineproviders) +- [ToolRuntimeRemoteProviders](#toolruntimeremoteproviders) +- [VectorIOInlineProviders](#vectorioinlineproviders) +- [VectorIORemoteProviders](#vectorioremoteproviders) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `id` _string_ | ID is a unique provider identifier. Derived from the provider
type when omitted. Must be unique across all providers. | | | +| `type` _string_ | Type is the provider type, specified with a "remote::" or "inline::"
prefix (e.g., "remote::llama-guard", "inline::my-provider"). | | MinLength: 1
Required: \{\}
| +| `secretRefs` _object (keys:string, values:[SecretKeyRef](#secretkeyref))_ | SecretRefs is a map of named secret references for provider-specific
connection fields (e.g., host, password). Each key becomes the env var
field suffix and maps to config. with env var substitution.
Each Secret must be in the same namespace as the OGXServer
and must have the label ogx.io/watch: "true". | | MinProperties: 1
| +| `settings` _[JSON](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#json-v1-apiextensions-k8s-io)_ | Settings contains provider-specific configuration merged into the
provider's config section in config.yaml. Passed through as-is
without any secret resolution. Use secretRefs for secret values. | | | + +#### DistributionConfig + +DistributionConfig represents the configuration from the providers endpoint. + +_Appears in:_ +- [OGXServerStatus](#ogxserverstatus) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `activeDistribution` _string_ | | | | +| `providers` _[ProviderInfo](#providerinfo) array_ | | | | +| `availableDistributions` _object (keys:string, values:string)_ | | | | + +#### DistributionSpec + +DistributionSpec identifies the OGX distribution image to deploy. +Exactly one of name or image must be specified. + +_Appears in:_ +- [OGXServerSpec](#ogxserverspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `name` _string_ | Name is the distribution name that maps to a supported distribution (e.g., "starter", "remote-vllm").
Resolved to a container image via distributions.json and image-overrides. | | | +| `image` _string_ | Image is a direct container image reference to use. | | | + +#### DoclingServeProvider + +DoclingServeProvider configures a remote::docling-serve file_processors provider instance. + +_Appears in:_ +- [FileProcessorsRemoteProviders](#fileprocessorsremoteproviders) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `baseUrl` _string_ | BaseURL is the base URL of the Docling Serve instance. | | MinLength: 1
Required: \{\}
| +| `apiKey` _[SecretKeyRef](#secretkeyref)_ | APIKey is the API key for authenticating with Docling Serve.
The Secret must be in the same namespace as the OGXServer
and must have the label ogx.io/watch: "true". | | | +| `defaultChunkSizeTokens` _integer_ | DefaultChunkSizeTokens is the default chunk size in tokens when
chunking_strategy type is 'auto'. | | Maximum: 4096
Minimum: 100
| + +#### ExternalAccessConfig + +ExternalAccessConfig controls external service exposure. + +_Appears in:_ +- [NetworkSpec](#networkspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `enabled` _boolean_ | Enabled controls whether external access is created. | false | | +| `hostname` _string_ | Hostname sets a custom hostname for the external endpoint.
When omitted, an auto-generated hostname is used. | | | + +#### FileProcessorChunkConfig + +FileProcessorChunkConfig contains chunking fields shared by all file_processors providers. + +_Appears in:_ +- [InlineAutoFileProcessorProvider](#inlineautofileprocessorprovider) +- [InlineDoclingFileProcessorProvider](#inlinedoclingfileprocessorprovider) +- [InlineMarkItDownFileProcessorProvider](#inlinemarkitdownfileprocessorprovider) +- [InlinePyPDFFileProcessorProvider](#inlinepypdffileprocessorprovider) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `defaultChunkSizeTokens` _integer_ | DefaultChunkSizeTokens is the default chunk size in tokens when
chunking_strategy type is 'auto'. | | Maximum: 4096
Minimum: 100
| +| `defaultChunkOverlapTokens` _integer_ | DefaultChunkOverlapTokens is the default chunk overlap in tokens when
chunking_strategy type is 'auto'. | | Maximum: 2048
Minimum: 0
| + +#### FileProcessorsInlineProviders + +FileProcessorsInlineProviders groups inline file_processors providers. + +_Appears in:_ +- [FileProcessorsProvidersSpec](#fileprocessorsprovidersspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `auto` _[InlineAutoFileProcessorProvider](#inlineautofileprocessorprovider)_ | | | | +| `pypdf` _[InlinePyPDFFileProcessorProvider](#inlinepypdffileprocessorprovider)_ | | | | +| `markitdown` _[InlineMarkItDownFileProcessorProvider](#inlinemarkitdownfileprocessorprovider)_ | | | | +| `docling` _[InlineDoclingFileProcessorProvider](#inlinedoclingfileprocessorprovider)_ | | | | +| `custom` _[CustomProvider](#customprovider) array_ | | | MaxItems: 100
MinItems: 1
| + +#### FileProcessorsProvidersSpec + +FileProcessorsProvidersSpec configures file_processors providers. + +_Appears in:_ +- [ProvidersSpec](#providersspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `remote` _[FileProcessorsRemoteProviders](#fileprocessorsremoteproviders)_ | | | | +| `inline` _[FileProcessorsInlineProviders](#fileprocessorsinlineproviders)_ | | | | + +#### FileProcessorsRemoteProviders + +FileProcessorsRemoteProviders groups remote file_processors providers. + +_Appears in:_ +- [FileProcessorsProvidersSpec](#fileprocessorsprovidersspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `doclingServe` _[DoclingServeProvider](#doclingserveprovider)_ | | | | +| `custom` _[CustomProvider](#customprovider) array_ | | | MaxItems: 100
MinItems: 1
| + +#### FilesInlineProviders + +FilesInlineProviders groups inline files providers. + +_Appears in:_ +- [FilesProvidersSpec](#filesprovidersspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `localfs` _[InlineLocalFSProvider](#inlinelocalfsprovider)_ | | | | +| `custom` _[CustomProvider](#customprovider) array_ | | | MaxItems: 100
MinItems: 1
| + +#### FilesProvidersSpec + +FilesProvidersSpec configures files providers. + +_Appears in:_ +- [ProvidersSpec](#providersspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `remote` _[FilesRemoteProviders](#filesremoteproviders)_ | | | | +| `inline` _[FilesInlineProviders](#filesinlineproviders)_ | | | | + +#### FilesRemoteProviders + +FilesRemoteProviders groups remote files providers. + +_Appears in:_ +- [FilesProvidersSpec](#filesprovidersspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `s3` _[S3Provider](#s3provider)_ | | | | +| `custom` _[CustomProvider](#customprovider) array_ | | | MaxItems: 100
MinItems: 1
| + +#### IdentityConfig + +IdentityConfig configures client certificate identity for mTLS authentication. + +_Appears in:_ +- [TLSClientConfig](#tlsclientconfig) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `cert` _[ConfigMapKeyRef](#configmapkeyref)_ | Cert references a ConfigMap key containing the PEM-encoded TLS client certificate.
The ConfigMap must be in the same namespace as the OGXServer
and must have the label ogx.io/watch: "true". | | Required: \{\}
| +| `key` _[SecretKeyRef](#secretkeyref)_ | Key references a Secret key containing the PEM-encoded TLS client private key.
The Secret must be in the same namespace as the OGXServer
and must have the label ogx.io/watch: "true". | | Required: \{\}
| + +#### InferenceInlineProviders + +InferenceInlineProviders groups inline inference providers. + +_Appears in:_ +- [InferenceProvidersSpec](#inferenceprovidersspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `custom` _[CustomProvider](#customprovider) array_ | | | MaxItems: 100
MinItems: 1
| + +#### InferenceProvidersSpec + +InferenceProvidersSpec configures inference providers. + +_Appears in:_ +- [ProvidersSpec](#providersspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `remote` _[InferenceRemoteProviders](#inferenceremoteproviders)_ | | | | +| `inline` _[InferenceInlineProviders](#inferenceinlineproviders)_ | | | | + +#### InferenceRemoteProviders + +InferenceRemoteProviders groups remote inference providers. + +_Appears in:_ +- [InferenceProvidersSpec](#inferenceprovidersspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `vllm` _[VLLMProvider](#vllmprovider) array_ | | | MaxItems: 100
MinItems: 1
| +| `openai` _[OpenAIProvider](#openaiprovider) array_ | | | MaxItems: 100
MinItems: 1
| +| `azure` _[AzureProvider](#azureprovider) array_ | | | MaxItems: 100
MinItems: 1
| +| `bedrock` _[BedrockProvider](#bedrockprovider) array_ | | | MaxItems: 100
MinItems: 1
| +| `vertexai` _[VertexAIProvider](#vertexaiprovider) array_ | | | MaxItems: 100
MinItems: 1
| +| `watsonx` _[WatsonxProvider](#watsonxprovider) array_ | | | MaxItems: 100
MinItems: 1
| +| `custom` _[CustomProvider](#customprovider) array_ | | | MaxItems: 100
MinItems: 1
| + +#### InlineAutoFileProcessorProvider + +InlineAutoFileProcessorProvider configures inline::auto for file_processors. + +_Appears in:_ +- [FileProcessorsInlineProviders](#fileprocessorsinlineproviders) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `defaultChunkSizeTokens` _integer_ | DefaultChunkSizeTokens is the default chunk size in tokens when
chunking_strategy type is 'auto'. | | Maximum: 4096
Minimum: 100
| +| `defaultChunkOverlapTokens` _integer_ | DefaultChunkOverlapTokens is the default chunk overlap in tokens when
chunking_strategy type is 'auto'. | | Maximum: 2048
Minimum: 0
| +| `extractMetadata` _boolean_ | ExtractMetadata controls whether to extract PDF metadata
(title, author, etc.). | | | +| `cleanText` _boolean_ | CleanText controls whether to clean extracted text
(remove extra whitespace, normalize line breaks). | | | + +#### InlineBuiltinResponsesProvider + +InlineBuiltinResponsesProvider configures inline::builtin for responses. + +_Appears in:_ +- [ResponsesInlineProviders](#responsesinlineproviders) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `vectorStoresConfig` _[VectorStoresConfig](#vectorstoresconfig)_ | VectorStoresConfig configures vector store behavior for file search
and retrieval-augmented generation. | | | +| `compactionConfig` _[CompactionConfig](#compactionconfig)_ | CompactionConfig configures conversation compaction behavior
and prompt templates. | | | + +#### InlineDoclingFileProcessorProvider + +InlineDoclingFileProcessorProvider configures inline::docling for file_processors. + +_Appears in:_ +- [FileProcessorsInlineProviders](#fileprocessorsinlineproviders) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `defaultChunkSizeTokens` _integer_ | DefaultChunkSizeTokens is the default chunk size in tokens when
chunking_strategy type is 'auto'. | | Maximum: 4096
Minimum: 100
| +| `defaultChunkOverlapTokens` _integer_ | DefaultChunkOverlapTokens is the default chunk overlap in tokens when
chunking_strategy type is 'auto'. | | Maximum: 2048
Minimum: 0
| +| `doOcr` _boolean_ | DoOCR controls whether to enable OCR for scanned documents. | | | + +#### InlineFileSearchProvider + +InlineFileSearchProvider configures inline::file-search. + +_Appears in:_ +- [ToolRuntimeInlineProviders](#toolruntimeinlineproviders) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `id` _string_ | ID is a unique provider identifier. Derived from the provider
type when omitted. Must be unique across all providers. | | | +| `vectorStoresConfig` _[VectorStoresConfig](#vectorstoresconfig)_ | VectorStoresConfig configures vector store behavior for file search. | | | + +#### InlineLocalFSProvider + +InlineLocalFSProvider configures inline::localfs. + +_Appears in:_ +- [FilesInlineProviders](#filesinlineproviders) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `ttlSecs` _integer_ | TTLSecs is the time-to-live in seconds for uploaded files. | | Minimum: 1
| + +#### InlineMarkItDownFileProcessorProvider + +InlineMarkItDownFileProcessorProvider configures inline::markitdown for file_processors. + +_Appears in:_ +- [FileProcessorsInlineProviders](#fileprocessorsinlineproviders) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `defaultChunkSizeTokens` _integer_ | DefaultChunkSizeTokens is the default chunk size in tokens when
chunking_strategy type is 'auto'. | | Maximum: 4096
Minimum: 100
| +| `defaultChunkOverlapTokens` _integer_ | DefaultChunkOverlapTokens is the default chunk overlap in tokens when
chunking_strategy type is 'auto'. | | Maximum: 2048
Minimum: 0
| + +#### InlinePyPDFFileProcessorProvider + +InlinePyPDFFileProcessorProvider configures inline::pypdf for file_processors. + +_Appears in:_ +- [FileProcessorsInlineProviders](#fileprocessorsinlineproviders) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `defaultChunkSizeTokens` _integer_ | DefaultChunkSizeTokens is the default chunk size in tokens when
chunking_strategy type is 'auto'. | | Maximum: 4096
Minimum: 100
| +| `defaultChunkOverlapTokens` _integer_ | DefaultChunkOverlapTokens is the default chunk overlap in tokens when
chunking_strategy type is 'auto'. | | Maximum: 2048
Minimum: 0
| +| `extractMetadata` _boolean_ | ExtractMetadata controls whether to extract PDF metadata
(title, author, etc.). | | | +| `cleanText` _boolean_ | CleanText controls whether to clean extracted text
(remove extra whitespace, normalize line breaks). | | | + +#### InlineReferenceProvider + +InlineReferenceProvider configures inline::reference for batches. + +_Appears in:_ +- [BatchesInlineProviders](#batchesinlineproviders) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `maxConcurrentBatches` _integer_ | MaxConcurrentBatches is the maximum number of concurrent batches
to process simultaneously. | | Minimum: 1
| +| `maxConcurrentRequestsPerBatch` _integer_ | MaxConcurrentRequestsPerBatch is the maximum number of concurrent
requests to process per batch. | | Minimum: 1
| + +#### KVStorageSpec + +KVStorageSpec configures the key-value storage backend. + +_Appears in:_ +- [StateStorageSpec](#statestoragespec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `type` _string_ | Type is the KV storage backend type. | sqlite | Enum: [sqlite redis]
| +| `endpoint` _string_ | Endpoint is the Redis endpoint URL. Required when type is "redis". | | | +| `password` _[SecretKeyRef](#secretkeyref)_ | Password references a Secret for Redis authentication.
The Secret must be in the same namespace as the OGXServer
and must have the label ogx.io/watch: "true". | | | + +#### MilvusProvider + +MilvusProvider configures a remote::milvus vector I/O provider instance. + +_Appears in:_ +- [VectorIORemoteProviders](#vectorioremoteproviders) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `id` _string_ | ID is a unique provider identifier. Derived from the provider
type when omitted. Must be unique across all providers. | | | +| `uri` _string_ | URI is the URI of the Milvus server. | | MinLength: 1
Required: \{\}
| +| `token` _[SecretKeyRef](#secretkeyref)_ | Token is the authentication token for the Milvus server.
The Secret must be in the same namespace as the OGXServer
and must have the label ogx.io/watch: "true". | | | +| `consistencyLevel` _string_ | ConsistencyLevel is the consistency level of the Milvus server. | | | + +#### ModelConfig + +ModelConfig defines a model registration with optional provider assignment and metadata. + +_Appears in:_ +- [ResourcesSpec](#resourcesspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `name` _string_ | Name is the model identifier (e.g., "llama3.2-8b"). | | MinLength: 1
Required: \{\}
| +| `provider` _string_ | Provider is the ID of the provider to register this model with.
Defaults to the first inference provider when omitted. | | | +| `contextLength` _integer_ | ContextLength is the model context window size. | | | +| `modelType` _string_ | ModelType is the model type classification. | | | +| `quantization` _string_ | Quantization is the quantization method. | | | + +#### ModelContextProtocolProvider + +ModelContextProtocolProvider configures remote::model-context-protocol. + +_Appears in:_ +- [ToolRuntimeRemoteProviders](#toolruntimeremoteproviders) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `id` _string_ | ID is a unique provider identifier. Derived from the provider
type when omitted. Must be unique across all providers. | | | + +#### MonitoringSpec + +MonitoringSpec configures Prometheus monitoring for this OGXServer instance. + +_Appears in:_ +- [OGXServerSpec](#ogxserverspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `enabled` _boolean_ | Enabled controls whether the operator creates monitoring resources
(ServiceMonitor, PrometheusRule) for this server.
Defaults to true. Set to false to disable monitoring without removing the config. | true | | +| `metricsPort` _integer_ | MetricsPort is the port serving the /metrics endpoint.
When omitted, metrics are served on the main API port. | | Maximum: 65535
Minimum: 1
| + +#### NetworkPolicySpec + +NetworkPolicySpec configures the operator-managed NetworkPolicy for this server. + +Ingress is always enforced unless explicitly omitted from policyTypes. +The operator always includes default ingress rules (allow from same-namespace +and operator-namespace on the service port), merging them with any +user-specified rules. + +Egress is unrestricted by default. It is only enforced when egress rules +are provided or "Egress" is explicitly included in policyTypes. +When any egress rules are configured, or when "Egress" is explicitly included in +policyTypes, a kube-dns egress rule is auto-injected to prevent DNS breakage. + +_Appears in:_ +- [NetworkSpec](#networkspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `enabled` _boolean_ | Enabled controls whether the operator manages a NetworkPolicy for this server.
Defaults to true. Set to false to disable NetworkPolicy creation entirely. | true | | +| `policyTypes` _[PolicyType](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#policytype-v1-networking) array_ | PolicyTypes specifies which policy directions are enforced.
Follows Kubernetes NetworkPolicy semantics: when omitted or empty,
Ingress is always included and Egress is included only if egress
rules are provided. | | items:Enum: [Ingress Egress]
| +| `ingress` _[NetworkPolicyIngressRule](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#networkpolicyingressrule-v1-networking) array_ | Ingress defines additional ingress rules, merged with operator defaults
(allow from same-namespace and operator-namespace on the service port). | | | +| `egress` _[NetworkPolicyEgressRule](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#networkpolicyegressrule-v1-networking) array_ | Egress rules. When non-empty, a kube-dns egress rule is auto-injected
to prevent DNS breakage. | | | + +#### NetworkSpec + +NetworkSpec defines network access controls for the OGXServer. + +_Appears in:_ +- [OGXServerSpec](#ogxserverspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `port` _integer_ | Port is the server listen port. | 8321 | Maximum: 65535
Minimum: 1
| +| `tls` _[TLSSpec](#tlsspec)_ | TLS configures optional TLS termination for the server.
When omitted, the server listens over plain HTTP. | | | +| `externalAccess` _[ExternalAccessConfig](#externalaccessconfig)_ | ExternalAccess controls external service exposure. | | | +| `policy` _[NetworkPolicySpec](#networkpolicyspec)_ | Policy configures the operator-managed NetworkPolicy.
When nil, the operator creates a default NetworkPolicy with safe ingress rules. | | | + +#### OGXServer + +OGXServer is the Schema for the ogxservers API. + +_Appears in:_ +- [OGXServerList](#ogxserverlist) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `ogx.io/v1beta1` | | | +| `kind` _string_ | `OGXServer` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | | +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | | +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `spec` _[OGXServerSpec](#ogxserverspec)_ | | | | +| `status` _[OGXServerStatus](#ogxserverstatus)_ | | | | + +#### OGXServerList + +OGXServerList contains a list of OGXServer. + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `ogx.io/v1beta1` | | | +| `kind` _string_ | `OGXServerList` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | | +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | | +| `metadata` _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#listmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `items` _[OGXServer](#ogxserver) array_ | | | | + +#### OGXServerPhase + +_Underlying type:_ _string_ + +OGXServerPhase represents the current phase of the OGXServer. + +_Validation:_ +- Enum: [Pending Initializing Ready Failed Terminating] + +_Appears in:_ +- [OGXServerStatus](#ogxserverstatus) + +| Field | Description | +| --- | --- | +| `Pending` | | +| `Initializing` | | +| `Ready` | | +| `Failed` | | +| `Terminating` | | + +#### OGXServerSpec + +OGXServerSpec defines the desired state of OGXServer. + +_Appears in:_ +- [OGXServer](#ogxserver) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `distribution` _[DistributionSpec](#distributionspec)_ | Distribution identifies the OGX distribution to deploy. | | Required: \{\}
| +| `providers` _[ProvidersSpec](#providersspec)_ | Providers configures providers by API type.
Mutually exclusive with overrideConfig. | | | +| `resources` _[ResourcesSpec](#resourcesspec)_ | Resources declares models to register.
Mutually exclusive with overrideConfig. | | | +| `storage` _[StateStorageSpec](#statestoragespec)_ | Storage configures state storage backends (KV and SQL).
Mutually exclusive with overrideConfig. | | | +| `disabledAPIs` _string array_ | DisabledAPIs lists API names to remove from the generated config.
Mutually exclusive with overrideConfig. | | MaxItems: 7
MinItems: 1
items:Enum: [batches file_processors inference responses tool_runtime vector_io files]
| +| `registryRefreshIntervalSeconds` _integer_ | RegistryRefreshIntervalSeconds configures how often the server refreshes
its model registry, in seconds. When omitted, the server's built-in
default is used. | | Minimum: 1
| +| `network` _[NetworkSpec](#networkspec)_ | Network defines network access controls. | | | +| `tls` _[TLSClientConfig](#tlsclientconfig)_ | TLS configures outbound TLS trust anchors and client identity for
connections to providers and backends. | | | +| `workload` _[WorkloadSpec](#workloadspec)_ | Workload consolidates Kubernetes deployment settings. | | | +| `monitoring` _[MonitoringSpec](#monitoringspec)_ | Monitoring configures Prometheus monitoring and observability. | | | +| `baseConfig` _[ConfigMapKeyRef](#configmapkeyref)_ | BaseConfig references a ConfigMap key containing the base config.yaml used
as the starting point for declarative config generation.
When set, this takes precedence over OCI label resolution.
Mutually exclusive with overrideConfig.
The ConfigMap must be in the same namespace as the OGXServer
and must have the label ogx.io/watch: "true". | | | +| `overrideConfig` _[ConfigMapKeyRef](#configmapkeyref)_ | OverrideConfig references a ConfigMap key containing a full config.yaml override.
Mutually exclusive with providers, resources, storage, disabledAPIs, and baseConfig.
The ConfigMap must be in the same namespace as the OGXServer
and must have the label ogx.io/watch: "true". | | | + +#### OGXServerStatus + +OGXServerStatus defines the observed state of OGXServer. + +_Appears in:_ +- [OGXServer](#ogxserver) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `phase` _[OGXServerPhase](#ogxserverphase)_ | Phase represents the current phase of the server. | | Enum: [Pending Initializing Ready Failed Terminating]
| +| `version` _[VersionInfo](#versioninfo)_ | Version contains version information for both operator and server. | | | +| `distributionConfig` _[DistributionConfig](#distributionconfig)_ | DistributionConfig contains provider information from the running server. | | | +| `resolvedDistribution` _[ResolvedDistributionStatus](#resolveddistributionstatus)_ | ResolvedDistribution tracks the resolved image and config source. | | | +| `configGeneration` _[ConfigGenerationStatus](#configgenerationstatus)_ | ConfigGeneration tracks config generation details. | | | +| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#condition-v1-meta) array_ | Conditions represent the latest available observations of the server's state. | | | +| `availableReplicas` _integer_ | AvailableReplicas is the number of available replicas. | | | +| `serviceURL` _string_ | ServiceURL is the internal Kubernetes service URL. | | | +| `externalURL` _string_ | ExternalURL is the external URL when external access is configured. | | | + +#### OpenAIProvider + +OpenAIProvider configures a remote::openai inference provider instance. + +_Appears in:_ +- [InferenceRemoteProviders](#inferenceremoteproviders) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `id` _string_ | ID is a unique provider identifier. Derived from the provider
type when omitted. Must be unique across all providers. | | | +| `allowedModels` _string array_ | AllowedModels restricts which models can be registered with this provider.
When empty, all models are allowed. | | MinItems: 1
items:MinLength: 1
| +| `refreshModels` _boolean_ | RefreshModels controls whether the provider periodically refreshes
its model list from the remote endpoint. | | | +| `network` _[NetworkConfig](#networkconfig)_ | Network configures network settings (TLS, proxy, timeouts, headers)
for the remote connection. | | | +| `endpoint` _string_ | Endpoint is the base URL for the OpenAI API. | | | +| `apiKey` _[SecretKeyRef](#secretkeyref)_ | APIKey is the authentication credential for the OpenAI provider.
The Secret must be in the same namespace as the OGXServer
and must have the label ogx.io/watch: "true". | | Required: \{\}
| + +#### PVCStorageSpec + +PVCStorageSpec defines PVC storage for persistent data. + +_Appears in:_ +- [WorkloadSpec](#workloadspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `size` _[Quantity](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#quantity-resource-api)_ | Size is the size of the PVC. | | | +| `mountPath` _string_ | MountPath is the container mount path for the PVC. | /.ogx | | + +#### PgvectorProvider + +PgvectorProvider configures a remote::pgvector vector I/O provider instance. + +_Appears in:_ +- [VectorIORemoteProviders](#vectorioremoteproviders) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `id` _string_ | ID is a unique provider identifier. Derived from the provider
type when omitted. Must be unique across all providers. | | | +| `host` _string_ | Host is the PostgreSQL server hostname. | | | +| `port` _integer_ | Port is the PostgreSQL server port. | | Maximum: 65535
Minimum: 1
| +| `db` _string_ | DB is the PostgreSQL database name. | | | +| `user` _string_ | User is the PostgreSQL username. | | | +| `password` _[SecretKeyRef](#secretkeyref)_ | Password is the PostgreSQL password.
The Secret must be in the same namespace as the OGXServer
and must have the label ogx.io/watch: "true". | | Required: \{\}
| +| `distanceMetric` _string_ | DistanceMetric is the distance metric used for vector search. | | Enum: [COSINE L2 L1 INNER_PRODUCT]
| +| `vectorIndex` _[VectorIndexConfig](#vectorindexconfig)_ | VectorIndex configures the vector index strategy for
Approximate Nearest Neighbor (ANN) search. | | | + +#### PodDisruptionBudgetSpec + +PodDisruptionBudgetSpec defines voluntary disruption controls. + +_Appears in:_ +- [WorkloadSpec](#workloadspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `minAvailable` _[IntOrString](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#intorstring-intstr-util)_ | MinAvailable is the minimum number of pods that must remain available. | | | +| `maxUnavailable` _[IntOrString](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#intorstring-intstr-util)_ | MaxUnavailable is the maximum number of pods that can be disrupted simultaneously. | | | + +#### ProviderHealthStatus + +ProviderHealthStatus represents the health status of a provider. + +_Appears in:_ +- [ProviderInfo](#providerinfo) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `status` _string_ | | | | +| `message` _string_ | | | | + +#### ProviderInfo + +ProviderInfo represents a single provider from the providers endpoint. + +_Appears in:_ +- [DistributionConfig](#distributionconfig) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `api` _string_ | | | | +| `provider_id` _string_ | | | | +| `provider_type` _string_ | | | | +| `config` _[JSON](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#json-v1-apiextensions-k8s-io)_ | | | | +| `health` _[ProviderHealthStatus](#providerhealthstatus)_ | | | | + +#### ProvidersSpec + +ProvidersSpec configures providers by API type. + +_Appears in:_ +- [OGXServerSpec](#ogxserverspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `inference` _[InferenceProvidersSpec](#inferenceprovidersspec)_ | | | | +| `vectorIo` _[VectorIOProvidersSpec](#vectorioprovidersspec)_ | | | | +| `toolRuntime` _[ToolRuntimeProvidersSpec](#toolruntimeprovidersspec)_ | | | | +| `files` _[FilesProvidersSpec](#filesprovidersspec)_ | | | | +| `batches` _[BatchesProvidersSpec](#batchesprovidersspec)_ | | | | +| `responses` _[ResponsesProvidersSpec](#responsesprovidersspec)_ | | | | +| `fileProcessors` _[FileProcessorsProvidersSpec](#fileprocessorsprovidersspec)_ | | | | + +#### ProxyConfig + +ProxyConfig configures HTTP proxy settings for remote provider connections. + +_Appears in:_ +- [NetworkConfig](#networkconfig) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `url` _string_ | URL is the proxy URL for all connections. | | | +| `http` _string_ | HTTP is the proxy URL for HTTP connections. | | | +| `https` _string_ | HTTPS is the proxy URL for HTTPS connections. | | | +| `cacert` _string_ | CACert is the path to a CA certificate for verifying the proxy's certificate. | | | +| `noProxy` _string array_ | NoProxy is a list of hosts that should bypass the proxy. | | MinItems: 1
items:MinLength: 1
| + +#### QdrantProvider + +QdrantProvider configures a remote::qdrant vector I/O provider instance. + +_Appears in:_ +- [VectorIORemoteProviders](#vectorioremoteproviders) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `id` _string_ | ID is a unique provider identifier. Derived from the provider
type when omitted. Must be unique across all providers. | | | +| `url` _string_ | URL is the URL of the Qdrant server. | | | +| `host` _string_ | Host is the hostname of the Qdrant server. | | | +| `port` _integer_ | Port is the REST API port of the Qdrant server. | | Maximum: 65535
Minimum: 1
| +| `apiKey` _[SecretKeyRef](#secretkeyref)_ | APIKey is the authentication key for the Qdrant server.
The Secret must be in the same namespace as the OGXServer
and must have the label ogx.io/watch: "true". | | | +| `location` _string_ | Location is the Qdrant server location identifier. | | | +| `grpcPort` _integer_ | GRPCPort is the gRPC port of the Qdrant server. | | Maximum: 65535
Minimum: 1
| +| `preferGrpc` _boolean_ | PreferGRPC controls whether to prefer gRPC over REST for communication. | | | +| `https` _boolean_ | HTTPS controls whether to use HTTPS for the connection. | | | +| `prefix` _string_ | Prefix is the URL path prefix for the Qdrant server. | | | +| `timeout` _integer_ | Timeout is the connection timeout in seconds. | | Minimum: 1
| + +#### QualifiedModel + +QualifiedModel identifies a model with its provider. + +_Appears in:_ +- [ContextualRetrievalParams](#contextualretrievalparams) +- [RewriteQueryParams](#rewritequeryparams) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `providerId` _string_ | ProviderID is the provider to use for this model. | | MinLength: 1
Required: \{\}
| +| `modelId` _string_ | ModelID is the model identifier. | | MinLength: 1
Required: \{\}
| +| `embeddingDimensions` _integer_ | EmbeddingDimensions is the dimensionality of the embedding vectors. | | Minimum: 1
| + +#### RemoteInferenceCommonConfig + +RemoteInferenceCommonConfig contains fields shared by all remote inference providers. + +_Appears in:_ +- [AzureProvider](#azureprovider) +- [BedrockProvider](#bedrockprovider) +- [OpenAIProvider](#openaiprovider) +- [VLLMProvider](#vllmprovider) +- [VertexAIProvider](#vertexaiprovider) +- [WatsonxProvider](#watsonxprovider) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `allowedModels` _string array_ | AllowedModels restricts which models can be registered with this provider.
When empty, all models are allowed. | | MinItems: 1
items:MinLength: 1
| +| `refreshModels` _boolean_ | RefreshModels controls whether the provider periodically refreshes
its model list from the remote endpoint. | | | +| `network` _[NetworkConfig](#networkconfig)_ | Network configures network settings (TLS, proxy, timeouts, headers)
for the remote connection. | | | + +#### ResolvedDistributionStatus + +ResolvedDistributionStatus tracks the resolved distribution image for change detection. + +_Appears in:_ +- [OGXServerStatus](#ogxserverstatus) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `image` _string_ | Image is the resolved container image reference (with digest when available). | | | +| `configSource` _string_ | ConfigSource indicates the base config origin (for example "configmap" or "oci-label"). | | | +| `configHash` _string_ | ConfigHash is the SHA256 hash of the base config used. | | | + +#### ResourcesSpec + +ResourcesSpec defines declarative registration of models. + +_Appears in:_ +- [OGXServerSpec](#ogxserverspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `models` _[ModelConfig](#modelconfig) array_ | Models to register with inference providers. | | MinItems: 1
| + +#### ResponsesInlineProviders + +ResponsesInlineProviders groups inline responses providers. + +_Appears in:_ +- [ResponsesProvidersSpec](#responsesprovidersspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `builtin` _[InlineBuiltinResponsesProvider](#inlinebuiltinresponsesprovider)_ | | | | +| `custom` _[CustomProvider](#customprovider) array_ | | | MaxItems: 100
MinItems: 1
| + +#### ResponsesProvidersSpec + +ResponsesProvidersSpec configures responses providers. + +_Appears in:_ +- [ProvidersSpec](#providersspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `remote` _[ResponsesRemoteProviders](#responsesremoteproviders)_ | | | | +| `inline` _[ResponsesInlineProviders](#responsesinlineproviders)_ | | | | + +#### ResponsesRemoteProviders + +ResponsesRemoteProviders groups remote responses providers. + +_Appears in:_ +- [ResponsesProvidersSpec](#responsesprovidersspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `custom` _[CustomProvider](#customprovider) array_ | | | MaxItems: 100
MinItems: 1
| + +#### RoutedProviderBase + +RoutedProviderBase contains fields common to all routed (non-singleton) provider instances. + +_Appears in:_ +- [AzureProvider](#azureprovider) +- [BedrockProvider](#bedrockprovider) +- [BraveSearchProvider](#bravesearchprovider) +- [CustomProvider](#customprovider) +- [InlineFileSearchProvider](#inlinefilesearchprovider) +- [MilvusProvider](#milvusprovider) +- [ModelContextProtocolProvider](#modelcontextprotocolprovider) +- [OpenAIProvider](#openaiprovider) +- [PgvectorProvider](#pgvectorprovider) +- [QdrantProvider](#qdrantprovider) +- [TavilySearchProvider](#tavilysearchprovider) +- [VLLMProvider](#vllmprovider) +- [VertexAIProvider](#vertexaiprovider) +- [WatsonxProvider](#watsonxprovider) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `id` _string_ | ID is a unique provider identifier. Derived from the provider
type when omitted. Must be unique across all providers. | | | + +#### S3Provider + +S3Provider configures a remote::s3 files provider instance. + +_Appears in:_ +- [FilesRemoteProviders](#filesremoteproviders) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `bucketName` _string_ | BucketName is the S3 bucket name to store files. | | MinLength: 1
Required: \{\}
| +| `region` _string_ | Region is the AWS region where the bucket is located. | | | +| `awsAccessKeyId` _[SecretKeyRef](#secretkeyref)_ | AWSAccessKeyID is the AWS access key ID (optional if using IAM roles).
The Secret must be in the same namespace as the OGXServer
and must have the label ogx.io/watch: "true". | | | +| `awsSecretAccessKey` _[SecretKeyRef](#secretkeyref)_ | AWSSecretAccessKey is the AWS secret access key (optional if using IAM roles).
The Secret must be in the same namespace as the OGXServer
and must have the label ogx.io/watch: "true". | | | +| `endpointUrl` _string_ | EndpointURL is a custom S3 endpoint URL (for MinIO, LocalStack, etc.). | | | +| `autoCreateBucket` _boolean_ | AutoCreateBucket controls whether to automatically create the S3 bucket
if it doesn't exist. | | | + +#### SQLStorageSpec + +SQLStorageSpec configures the relational storage backend. + +_Appears in:_ +- [StateStorageSpec](#statestoragespec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `type` _string_ | Type is the SQL storage backend type. | sqlite | Enum: [sqlite postgres]
| +| `connectionString` _[SecretKeyRef](#secretkeyref)_ | ConnectionString references a Secret containing the database connection string.
Required when type is "postgres".
The Secret must be in the same namespace as the OGXServer
and must have the label ogx.io/watch: "true". | | | + +#### SecretKeyRef + +SecretKeyRef references a specific key in a Kubernetes Secret. +The Secret must be in the same namespace as the OGXServer and must have +the label ogx.io/watch: "true" to be detected by the operator's cache. + +_Appears in:_ +- [AzureProvider](#azureprovider) +- [BedrockProvider](#bedrockprovider) +- [BraveSearchProvider](#bravesearchprovider) +- [CustomProvider](#customprovider) +- [DoclingServeProvider](#doclingserveprovider) +- [IdentityConfig](#identityconfig) +- [KVStorageSpec](#kvstoragespec) +- [MilvusProvider](#milvusprovider) +- [OpenAIProvider](#openaiprovider) +- [PgvectorProvider](#pgvectorprovider) +- [QdrantProvider](#qdrantprovider) +- [S3Provider](#s3provider) +- [SQLStorageSpec](#sqlstoragespec) +- [TavilySearchProvider](#tavilysearchprovider) +- [VLLMProvider](#vllmprovider) +- [WatsonxProvider](#watsonxprovider) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `name` _string_ | Name is the name of the Kubernetes Secret. | | MinLength: 1
Required: \{\}
| +| `key` _string_ | Key is the key within the Secret. | | MaxLength: 253
MinLength: 1
Pattern: `^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$`
Required: \{\}
| + +#### StateStorageSpec + +StateStorageSpec groups key-value and SQL storage backends. + +_Appears in:_ +- [OGXServerSpec](#ogxserverspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `kv` _[KVStorageSpec](#kvstoragespec)_ | KV configures key-value storage. | | | +| `sql` _[SQLStorageSpec](#sqlstoragespec)_ | SQL configures SQL storage. | | | + +#### TLSClientConfig + +TLSClientConfig groups outbound TLS settings: trust anchors and client identity. + +_Appears in:_ +- [OGXServerSpec](#ogxserverspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `trust` _[TrustConfig](#trustconfig)_ | Trust configures CA certificates for verifying outbound TLS connections
to providers and backends. | | | +| `identity` _[IdentityConfig](#identityconfig)_ | Identity configures client certificate and key for mTLS authentication
with providers and backends. | | | + +#### TLSConfig + +TLSConfig configures TLS settings for remote provider connections. + +_Appears in:_ +- [NetworkConfig](#networkconfig) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `verify` _boolean_ | Verify controls whether TLS certificate verification is enabled.
Trust anchors and client identity are configured globally via spec.tls. | | | +| `minVersion` _string_ | MinVersion sets the minimum TLS version. | | Enum: [TLSv1.2 TLSv1.3]
| +| `ciphers` _string array_ | Ciphers is a list of allowed TLS cipher suites. | | MinItems: 1
items:MinLength: 1
| + +#### TLSSpec + +TLSSpec defines TLS termination configuration for the server. + +_Appears in:_ +- [NetworkSpec](#networkspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `secretName` _string_ | SecretName references a Kubernetes TLS Secret containing a valid TLS certificate
for server TLS termination. The Secret must be in the same namespace as the
OGXServer and must have the label ogx.io/watch: "true" to be detected by the
operator's cache. | | MinLength: 1
Required: \{\}
| + +#### TavilySearchProvider + +TavilySearchProvider configures a remote::tavily-search tool runtime provider. + +_Appears in:_ +- [ToolRuntimeRemoteProviders](#toolruntimeremoteproviders) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `id` _string_ | ID is a unique provider identifier. Derived from the provider
type when omitted. Must be unique across all providers. | | | +| `apiKey` _[SecretKeyRef](#secretkeyref)_ | APIKey is the Tavily Search API key.
The Secret must be in the same namespace as the OGXServer
and must have the label ogx.io/watch: "true". | | Required: \{\}
| +| `maxResults` _integer_ | MaxResults is the maximum number of search results to return. | | Minimum: 1
| + +#### TimeoutConfig + +TimeoutConfig configures network timeout settings. + +_Appears in:_ +- [NetworkConfig](#networkconfig) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `connect` _integer_ | Connect is the connection timeout in seconds. | | Minimum: 1
| +| `read` _integer_ | Read is the read timeout in seconds. | | Minimum: 1
| + +#### ToolRuntimeInlineProviders + +ToolRuntimeInlineProviders groups inline tool runtime providers. + +_Appears in:_ +- [ToolRuntimeProvidersSpec](#toolruntimeprovidersspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `fileSearch` _[InlineFileSearchProvider](#inlinefilesearchprovider) array_ | | | MaxItems: 100
MinItems: 1
| +| `custom` _[CustomProvider](#customprovider) array_ | | | MaxItems: 100
MinItems: 1
| + +#### ToolRuntimeProvidersSpec + +ToolRuntimeProvidersSpec configures tool runtime providers. + +_Appears in:_ +- [ProvidersSpec](#providersspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `remote` _[ToolRuntimeRemoteProviders](#toolruntimeremoteproviders)_ | | | | +| `inline` _[ToolRuntimeInlineProviders](#toolruntimeinlineproviders)_ | | | | + +#### ToolRuntimeRemoteProviders + +ToolRuntimeRemoteProviders groups remote tool runtime providers. + +_Appears in:_ +- [ToolRuntimeProvidersSpec](#toolruntimeprovidersspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `braveSearch` _[BraveSearchProvider](#bravesearchprovider) array_ | | | MaxItems: 100
MinItems: 1
| +| `tavilySearch` _[TavilySearchProvider](#tavilysearchprovider) array_ | | | MaxItems: 100
MinItems: 1
| +| `modelContextProtocol` _[ModelContextProtocolProvider](#modelcontextprotocolprovider) array_ | | | MaxItems: 100
MinItems: 1
| +| `custom` _[CustomProvider](#customprovider) array_ | | | MaxItems: 100
MinItems: 1
| + +#### TrustConfig + +TrustConfig configures trust anchors for verifying outbound TLS connections. + +_Appears in:_ +- [TLSClientConfig](#tlsclientconfig) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `caCertificates` _[ConfigMapKeyRef](#configmapkeyref) array_ | CACertificates lists ConfigMap keys containing PEM-encoded CA certificates.
All certificates are concatenated into a single trust bundle.
Referenced ConfigMaps must be in the same namespace as the OGXServer
and must have the label ogx.io/watch: "true". | | MinItems: 1
| + +#### VLLMProvider + +VLLMProvider configures a remote::vllm inference provider instance. + +_Appears in:_ +- [InferenceRemoteProviders](#inferenceremoteproviders) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `id` _string_ | ID is a unique provider identifier. Derived from the provider
type when omitted. Must be unique across all providers. | | | +| `allowedModels` _string array_ | AllowedModels restricts which models can be registered with this provider.
When empty, all models are allowed. | | MinItems: 1
items:MinLength: 1
| +| `refreshModels` _boolean_ | RefreshModels controls whether the provider periodically refreshes
its model list from the remote endpoint. | | | +| `network` _[NetworkConfig](#networkconfig)_ | Network configures network settings (TLS, proxy, timeouts, headers)
for the remote connection. | | | +| `endpoint` _string_ | Endpoint is the URL for the vLLM model serving endpoint. | | MinLength: 1
Required: \{\}
| +| `apiToken` _[SecretKeyRef](#secretkeyref)_ | APIToken is the authentication token for the vLLM endpoint.
The Secret must be in the same namespace as the OGXServer
and must have the label ogx.io/watch: "true". | | | +| `maxTokens` _integer_ | MaxTokens is the maximum number of tokens to generate. | | Minimum: 1
| + +#### VectorIOInlineProviders + +VectorIOInlineProviders groups inline vector I/O providers. + +_Appears in:_ +- [VectorIOProvidersSpec](#vectorioprovidersspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `custom` _[CustomProvider](#customprovider) array_ | | | MaxItems: 100
MinItems: 1
| + +#### VectorIOProvidersSpec + +VectorIOProvidersSpec configures vector I/O providers. + +_Appears in:_ +- [ProvidersSpec](#providersspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `remote` _[VectorIORemoteProviders](#vectorioremoteproviders)_ | | | | +| `inline` _[VectorIOInlineProviders](#vectorioinlineproviders)_ | | | | + +#### VectorIORemoteProviders + +VectorIORemoteProviders groups remote vector I/O providers. + +_Appears in:_ +- [VectorIOProvidersSpec](#vectorioprovidersspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `pgvector` _[PgvectorProvider](#pgvectorprovider) array_ | | | MaxItems: 100
MinItems: 1
| +| `milvus` _[MilvusProvider](#milvusprovider) array_ | | | MaxItems: 100
MinItems: 1
| +| `qdrant` _[QdrantProvider](#qdrantprovider) array_ | | | MaxItems: 100
MinItems: 1
| +| `custom` _[CustomProvider](#customprovider) array_ | | | MaxItems: 100
MinItems: 1
| + +#### VectorIndexConfig + +_Underlying type:_ _[struct{HNSW *HNSWConfig "json:\"hnsw,omitempty\""; IVFFlat *IVFFlatConfig "json:\"ivfFlat,omitempty\""}](#struct{hnsw-*hnswconfig-"json:\"hnsw,omitempty\"";-ivfflat-*ivfflatconfig-"json:\"ivfflat,omitempty\""})_ + +VectorIndexConfig configures the vector index strategy for PGVector. +Exactly one of hnsw or ivfFlat must be specified. + +_Appears in:_ +- [PgvectorProvider](#pgvectorprovider) + +#### VectorStoresConfig + +_Underlying type:_ _[struct{DefaultProviderID string "json:\"defaultProviderId,omitempty\""; DefaultEmbeddingModel *QualifiedModel "json:\"defaultEmbeddingModel,omitempty\""; DefaultRerankerModel *RerankerModel "json:\"defaultRerankerModel,omitempty\""; RewriteQueryParams *RewriteQueryParams "json:\"rewriteQueryParams,omitempty\""; FileSearchParams *FileSearchDisplayParams "json:\"fileSearchParams,omitempty\""; ContextPromptParams *ContextPromptParams "json:\"contextPromptParams,omitempty\""; AnnotationPromptParams *AnnotationPromptParams "json:\"annotationPromptParams,omitempty\""; FileIngestionParams *FileIngestionParams "json:\"fileIngestionParams,omitempty\""; ChunkRetrievalParams *ChunkRetrievalParams "json:\"chunkRetrievalParams,omitempty\""; FileBatchParams *FileBatchParams "json:\"fileBatchParams,omitempty\""; ContextualRetrievalParams *ContextualRetrievalParams "json:\"contextualRetrievalParams,omitempty\""}](#struct{defaultproviderid-string-"json:\"defaultproviderid,omitempty\"";-defaultembeddingmodel-*qualifiedmodel-"json:\"defaultembeddingmodel,omitempty\"";-defaultrerankermodel-*rerankermodel-"json:\"defaultrerankermodel,omitempty\"";-rewritequeryparams-*rewritequeryparams-"json:\"rewritequeryparams,omitempty\"";-filesearchparams-*filesearchdisplayparams-"json:\"filesearchparams,omitempty\"";-contextpromptparams-*contextpromptparams-"json:\"contextpromptparams,omitempty\"";-annotationpromptparams-*annotationpromptparams-"json:\"annotationpromptparams,omitempty\"";-fileingestionparams-*fileingestionparams-"json:\"fileingestionparams,omitempty\"";-chunkretrievalparams-*chunkretrievalparams-"json:\"chunkretrievalparams,omitempty\"";-filebatchparams-*filebatchparams-"json:\"filebatchparams,omitempty\"";-contextualretrievalparams-*contextualretrievalparams-"json:\"contextualretrievalparams,omitempty\""})_ + +VectorStoresConfig configures vector store behavior for responses and file search. + +_Appears in:_ +- [InlineBuiltinResponsesProvider](#inlinebuiltinresponsesprovider) +- [InlineFileSearchProvider](#inlinefilesearchprovider) + +#### VersionInfo + +VersionInfo contains version-related information. + +_Appears in:_ +- [OGXServerStatus](#ogxserverstatus) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `operatorVersion` _string_ | | | | +| `serverVersion` _string_ | | | | +| `lastUpdated` _[Time](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#time-v1-meta)_ | | | | + +#### VertexAIProvider + +VertexAIProvider configures a remote::vertexai inference provider instance. + +_Appears in:_ +- [InferenceRemoteProviders](#inferenceremoteproviders) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `id` _string_ | ID is a unique provider identifier. Derived from the provider
type when omitted. Must be unique across all providers. | | | +| `allowedModels` _string array_ | AllowedModels restricts which models can be registered with this provider.
When empty, all models are allowed. | | MinItems: 1
items:MinLength: 1
| +| `refreshModels` _boolean_ | RefreshModels controls whether the provider periodically refreshes
its model list from the remote endpoint. | | | +| `network` _[NetworkConfig](#networkconfig)_ | Network configures network settings (TLS, proxy, timeouts, headers)
for the remote connection. | | | +| `project` _string_ | Project is the Google Cloud project ID for Vertex AI. | | MinLength: 1
Required: \{\}
| +| `location` _string_ | Location is the Google Cloud location for Vertex AI. | | | + +#### WatsonxProvider + +WatsonxProvider configures a remote::watsonx inference provider instance. + +_Appears in:_ +- [InferenceRemoteProviders](#inferenceremoteproviders) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `id` _string_ | ID is a unique provider identifier. Derived from the provider
type when omitted. Must be unique across all providers. | | | +| `allowedModels` _string array_ | AllowedModels restricts which models can be registered with this provider.
When empty, all models are allowed. | | MinItems: 1
items:MinLength: 1
| +| `refreshModels` _boolean_ | RefreshModels controls whether the provider periodically refreshes
its model list from the remote endpoint. | | | +| `network` _[NetworkConfig](#networkconfig)_ | Network configures network settings (TLS, proxy, timeouts, headers)
for the remote connection. | | | +| `endpoint` _string_ | Endpoint is the base URL for accessing watsonx.ai. | | | +| `apiKey` _[SecretKeyRef](#secretkeyref)_ | APIKey is the authentication credential for the watsonx provider.
The Secret must be in the same namespace as the OGXServer
and must have the label ogx.io/watch: "true". | | Required: \{\}
| +| `projectId` _string_ | ProjectID is the watsonx.ai project ID. | | | +| `timeout` _integer_ | Timeout is the timeout in seconds for HTTP requests. | | Minimum: 1
| + +#### WorkloadOverrides + +WorkloadOverrides allows low-level customization of the Pod template. + +_Appears in:_ +- [WorkloadSpec](#workloadspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `serviceAccountName` _string_ | ServiceAccountName specifies a custom ServiceAccount. | | | +| `env` _[EnvVar](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#envvar-v1-core) array_ | Env specifies additional environment variables. | | MinItems: 1
| +| `command` _string array_ | Command overrides the container command. | | MinItems: 1
items:MinLength: 1
| +| `args` _string array_ | Args overrides the container arguments. | | MinItems: 1
items:MinLength: 1
| +| `volumes` _[Volume](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#volume-v1-core) array_ | Volumes adds additional volumes to the Pod. | | MinItems: 1
| +| `volumeMounts` _[VolumeMount](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#volumemount-v1-core) array_ | VolumeMounts adds additional volume mounts to the container. | | MinItems: 1
| + +#### WorkloadSpec + +WorkloadSpec consolidates Kubernetes deployment settings. + +_Appears in:_ +- [OGXServerSpec](#ogxserverspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `replicas` _integer_ | Replicas is the desired Pod replica count. | 1 | Minimum: 0
| +| `workers` _integer_ | Workers configures the number of uvicorn worker processes. | | Minimum: 1
| +| `resources` _[ResourceRequirements](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#resourcerequirements-v1-core)_ | Resources defines CPU/memory requests and limits. | | | +| `autoscaling` _[AutoscalingSpec](#autoscalingspec)_ | Autoscaling configures HPA for the server pods. | | | +| `storage` _[PVCStorageSpec](#pvcstoragespec)_ | Storage defines PVC configuration. | | | +| `podDisruptionBudget` _[PodDisruptionBudgetSpec](#poddisruptionbudgetspec)_ | PodDisruptionBudget controls voluntary disruption tolerance. | | | +| `topologySpreadConstraints` _[TopologySpreadConstraint](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#topologyspreadconstraint-v1-core) array_ | TopologySpreadConstraints defines Pod spreading rules. | | MinItems: 1
| +| `overrides` _[WorkloadOverrides](#workloadoverrides)_ | Overrides allows pod-level customization. | | | diff --git a/docs/config-audit.md b/docs/config-audit.md new file mode 100644 index 000000000..4828ba1aa --- /dev/null +++ b/docs/config-audit.md @@ -0,0 +1,166 @@ +# Config Audit: Operator CRD vs Upstream StackConfig + +**Current status:** all P0 and P1 issues have been fixed. #19 (add `file_processors` API) has been addressed + +**Date:** 2026-06-09 +**Upstream ref:** `ogx-ai/ogx@main` — `src/ogx/core/datatypes.py` (`StackConfig`) +**Product build ref:** `opendatahub-io/ogx-distribution@main` — `distribution/config.yaml` +**Operator branch:** `pr/VaishnaviHire/295` + +--- + +## P0 — Config field name mismatches (generated config will not work) + +### 1. `url` should be `base_url` for vLLM, OpenAI, Azure, WatsonX + +In `pkg/config/provider.go:138`, `:160`, `:173`, `:260` — the operator generates `"url"` but the upstream provider configs define the field as `base_url` (confirmed in `VLLMInferenceAdapterConfig`, `AzureConfig`, and inherited via `RemoteInferenceProviderConfig`). + +| Provider | Operator generates | Upstream expects | +|----------|--------------------|------------------| +| vLLM | `url` | `base_url` | +| OpenAI | `url` | `base_url` (via parent) | +| Azure | `url` | `base_url` | +| WatsonX | `url` | `base_url` (via parent) | + +### 2. Bedrock `region` should be `region_name` + +In `pkg/config/provider.go:193` — operator generates `"region"` but `BedrockConfig` defines the field as `region_name`. + +### 3. Network config nesting is wrong — `tls`/`timeout`/`headers` should be under `network` + +In `pkg/config/provider.go:566-610` — `applyNetworkConfig` puts `tls`, `timeout`, and `headers` at the **top level** of the provider config dict. The upstream `RemoteInferenceProviderConfig` expects these nested under `network`: + +```yaml +# Operator generates (WRONG): # Upstream expects (CORRECT): +config: config: + tls: network: + verify: true tls: + timeout: verify: true + connect: 30 timeout: + headers: {...} connect: 30 + headers: {...} +``` + +--- + +## P1 — CRD fields silently dropped (users set them, nothing happens) + +### 4. `refreshModels` and `allowedModels` not generated for any inference provider + +These are valid fields on `RemoteInferenceProviderConfig` (`refresh_models: bool`, `allowed_models: list[str]`). The CRD exposes them on every inference provider type, but `pkg/config/provider.go` never includes them in the generated config. Users who set these fields get no effect. + +### 5. Qdrant — 8 CRD fields silently dropped + +`pkg/config/provider.go:363-376` — only `url` and `api_key` are generated. These CRD fields are ignored: + +| CRD field | Upstream config key | +|--------------|---------------------| +| `host` | `host` | +| `port` | `port` | +| `grpcPort` | `grpc_port` | +| `preferGrpc` | `prefer_grpc` | +| `https` | `https` | +| `prefix` | `prefix` | +| `timeout` | `timeout` | +| `location` | `location` | + +### 6. Milvus `consistencyLevel` not generated + +`pkg/config/provider.go:348-361` — the CRD has `consistencyLevel` but only `uri` and `token` are generated. + +### 7. PGVector `distanceMetric` and `vectorIndex` not generated + +`pkg/config/provider.go:325-346` — CRD has `distanceMetric` (enum) and full `vectorIndex` config (HNSW/IVFFlat params), none generated. + +### 8. Proxy config never applied + +`applyNetworkConfig` in `pkg/config/provider.go:566-575` calls `applyTLSConfig`, `applyTimeoutConfig`, and handles `headers`, but never calls anything for `network.Proxy`. The CRD has a full `ProxyConfig` struct (`url`, `http`, `https`, `cacert`, `noProxy`) that is completely ignored. + +### 9. Responses provider: `vectorStoresConfig` and `compactionConfig` not generated + +`pkg/config/provider.go:558-564` — `expandBuiltinResponsesProvider` returns an empty config. The CRD has rich `VectorStoresConfig` and `CompactionConfig` types that are silently dropped. + +### 10. File search provider: `vectorStoresConfig` not generated + +`pkg/config/provider.go:443-451` — `expandInlineFileSearchProvider` returns empty config, ignoring the CRD's `VectorStoresConfig`. + +### 11. Batches reference provider: config fields not generated + +`pkg/config/provider.go:532-538` — `expandReferenceProvider` returns empty config. CRD fields `maxConcurrentBatches` and `maxConcurrentRequestsPerBatch` are dropped. + +### 12. Model `quantization` not generated + +`pkg/config/generator.go:149-165` — `serializeModels` includes `model_id`, `provider_id`, `model_type`, `context_length` but not `quantization`. + +--- + +## P2 — Missing StackConfig features (no CRD representation) + +### 13. `server.auth` — No authentication configuration + +The upstream `ServerConfig.auth` supports 5 auth provider types (OAuth2, GitHub, Custom, Kubernetes, UpstreamHeader) plus `route_policy` and `access_policy`. The product build configures OAuth2 auth. The operator CRD has no auth fields at all. Users must rely on `baseConfig`/`overrideConfig` for auth. + +### 14. `server.workers` not mapped from CRD + +`pkg/config/generator.go:179-190` — `buildServerSection` only maps `port`. The CRD has `workload.workers` but it is not injected into the server config's `workers` field. + +### 15. `server.registry_refresh_interval_seconds` not exposed + +No CRD field for this. Defaults to 300s upstream. + +### 16. `server.host` not exposed + +No CRD field for binding to a specific host/interface. + +### 17. `logging` / telemetry not exposed + +The upstream `StackConfig.logging` (`LoggingConfig`) and the product build's `telemetry` section have no CRD representation. + +### 18. `connectors` not exposed + +`StackConfig.connectors: list[ConnectorInput]` has no CRD representation. + +### 19. `file_processors` API/provider not handled + +The product build includes `file_processors` in its API list and a `pypdf` provider. The operator doesn't have a `file_processors` provider expander. + +--- + +## P2b — Storage gaps + +### 20. `kv_postgres` backend not supported + +The product build uses `kv_postgres` for its KV backend (individual host/port/db/user/password fields). The operator's KV storage only supports `sqlite` and `redis` (`pkg/config/storage.go:53-67`). When users rely on the product's base config, the KV postgres config passes through unchanged, but they can't configure it via the CRD. + +### 21. SQL postgres uses `connection_string` but upstream uses individual fields + +The operator generates `connection_string` (`pkg/config/storage.go:72-76`), but `PostgresSqlStoreConfig` uses `host`, `port`, `db`, `user`, `password`, `pool_size`, `max_overflow`, `pool_recycle`. These are incompatible schemas — `connection_string` is not a field on the upstream class. (Needs verification — there may be an adapter that accepts connection strings.) + +### 22. Missing stores: `connectors`, `responses`, `vector_stores` + +`pkg/config/storage.go:95-114` — `defaultStores` generates 4 stores (metadata, inference, conversations, prompts). The upstream `ServerStoresConfig` also defines `connectors` (with a default), `responses`, and `vector_stores`. + +--- + +## P3 — Missing typed providers (available via CustomProvider) + +### 23. Gemini (`remote::gemini`) + +In the product build but no typed CRD provider. Users must use `CustomProvider`. + +### 24. Anthropic (`remote::anthropic`) + +Same as above. + +--- + +## Summary + +| Severity | Count | Impact | +|----------|-------|--------| +| P0 — Wrong field names | 3 issues | Generated config will be rejected or silently ignored by the server | +| P1 — Silently dropped CRD fields | 9 issues | Users set values that have no effect | +| P2 — Missing features | 10 issues | Functionality not configurable via CRD | +| P3 — Missing typed providers | 2 issues | Workaround via CustomProvider exists | + +The P0 field name mismatches (`url` -> `base_url`, `region` -> `region_name`, flat TLS -> nested `network`) are the most urgent — they mean generated configs for these providers won't work correctly when the operator overrides the base config's providers. diff --git a/docs/create-operator.md b/docs/create-operator.md index 2e9961d23..8ac1e76f6 100644 --- a/docs/create-operator.md +++ b/docs/create-operator.md @@ -1,10 +1,10 @@ -# Deploying the `llama-stack-k8s-operator` as an OpenShift Catalog Item +# Deploying the `ogx-k8s-operator` as an OpenShift Catalog Item -This document outlines the steps required to package the `llama-stack-k8s-operator` into an Operator Bundle and make it available as a catalog item in OpenShift's OperatorHub. +This document outlines the steps required to package the `ogx-k8s-operator` into an Operator Bundle and make it available as a catalog item in OpenShift's OperatorHub. ## Overview -To expose the `llama-stack-k8s-operator` through the OpenShift OperatorHub, we need to package it according to the Operator Lifecycle Manager (OLM) specifications. This involves creating an Operator Bundle, building a bundle image, compiling a custom catalog image, and finally, registering this catalog with OpenShift using a `CatalogSource`. +To expose the `ogx-k8s-operator` through the OpenShift OperatorHub, we need to package it according to the Operator Lifecycle Manager (OLM) specifications. This involves creating an Operator Bundle, building a bundle image, compiling a custom catalog image, and finally, registering this catalog with OpenShift using a `CatalogSource`. ## 2 Prerequisites @@ -22,11 +22,11 @@ Follow these steps to prepare and deploy your operator. ### Clone the Operator Repository -Start by cloning the `llama-stack-k8s-operator` repository to your local machine: +Start by cloning the `ogx-k8s-operator` repository to your local machine: ```bash -git clone https://github.com/llamastack/llama-stack-k8s-operator.git -cd llama-stack-k8s-operator +git clone https://github.com/ogx-ai/ogx-k8s-operator.git +cd ogx-k8s-operator ``` ### Set Env Variables @@ -44,8 +44,8 @@ The operator's container image needs to be built and pushed to a container regis **Note:** Replace `` with your actual registry and namespace. ```bash -make image-build IMG=$REGISTRY/$REGISTRY_NAMESPACE/llama-stack-operator:$TAG -podman push $REGISTRY/$REGISTRY_NAMESPACE/llama-stack-operator:$TAG +make image-build IMG=$REGISTRY/$REGISTRY_NAMESPACE/ogx-k8s-operator:$TAG +podman push $REGISTRY/$REGISTRY_NAMESPACE/ogx-k8s-operator:$TAG ``` ### 3.3. Generate the Operator Bundle @@ -58,7 +58,7 @@ An Operator Bundle is a directory containing the necessary manifests that descri make bundle ``` -After running `make bundle`, inspect the contents of the newly created `bundle/manifests/bases` directory. Pay close attention to the `llama-stack-k8s-operator.clusterserviceversion.yaml` file. You may need to edit this file to: +After running `make bundle`, inspect the contents of the newly created `bundle/manifests/bases` directory. Pay close attention to the `ogx-k8s-operator.clusterserviceversion.yaml` file. You may need to edit this file to: * **`spec.installModes`**: Should be se to `AllNamespaces` * **`spec.replaces`**: If you have previous versions of the operator, this field is crucial for OLM to manage upgrades. @@ -71,8 +71,8 @@ After running `make bundle`, inspect the contents of the newly created `bundle/m The generated bundle needs to be packaged into its own container image. ```bash -make bundle-build BUNDLE_IMG=$REGISTRY/$REGISTRY_NAMESPACE/llama-stack-operator-bundle:$TAG -podman push $REGISTRY/$REGISTRY_NAMESPACE/llama-stack-operator-bundle:$TAG +make bundle-build BUNDLE_IMG=$REGISTRY/$REGISTRY_NAMESPACE/ogx-k8s-operator-bundle:$TAG +podman push $REGISTRY/$REGISTRY_NAMESPACE/ogx-k8s-operator-bundle:$TAG ``` ### 3.5. Create a Custom Catalog (Index) Image @@ -86,17 +86,17 @@ We'll use the `opm` (Operator Package Manager) CLI tool, which is part of the Op # This creates a Dockerfile for the catalog and an empty index.yaml. # You can create a new directory for your catalog, e.g., 'catalog_dir'. mkdir -p catalog_dir -opm init llama-stack-catalog --output yaml > catalog_dir/index.yaml +opm init ogx-catalog --output yaml > catalog_dir/index.yaml # 2. Add your operator bundle to the catalog. # Replace with your actual bundle image. opm index add \ - --bundles $REGISTRY/$REGISTRY_NAMESPACE/llama-stack-operator-bundle:$TAG \ - --tag $REGISTRY/$REGISTRY_NAMESPACE/llama-stack-catalog:$TAG \ + --bundles $REGISTRY/$REGISTRY_NAMESPACE/ogx-k8s-operator-bundle:$TAG \ + --tag $REGISTRY/$REGISTRY_NAMESPACE/ogx-catalog:$TAG \ --build-tool podman # 3. Push the catalog image to your registry. -podman push $REGISTRY/$REGISTRY_NAMESPACE/llama-stack-catalog:$TAG +podman push $REGISTRY/$REGISTRY_NAMESPACE/ogx-catalog:$TAG ``` ### 3.6. Create a `CatalogSource` in OpenShift @@ -109,14 +109,14 @@ Create a YAML file (e.g., `.openshift/catalog-item.yaml`): apiVersion: operators.coreos.com/v1alpha1 kind: CatalogSource metadata: - name: llama-stack-catalog + name: ogx-catalog namespace: openshift-marketplace # Standard namespace for CatalogSources labels: environment: dev spec: sourceType: grpc - image: $REGISTRY/$REGISTRY_NAMESPACE/llama-stack-catalog:$TAG # Your catalog image from Step 3.5 - displayName: "Llama Stack Operator Catalog (dev)" + image: $REGISTRY/$REGISTRY_NAMESPACE/ogx-catalog:$TAG # Your catalog image from Step 3.5 + displayName: "OGX Operator Catalog (dev)" publisher: "Red Hat Community" updateStrategy: registryPoll: @@ -135,5 +135,5 @@ After applying the `CatalogSource`, allow a few moments for OpenShift to process 1. Log in to the OpenShift Web Console. 2. Navigate to **Operators \> OperatorHub**. -3. In the "Catalog Sources" filter, you should see "Llama Stack Operator Catalog" listed. -4. Filter by this catalog, and your `LlamaStack` operator should appear, ready for installation. +3. In the "Catalog Sources" filter, you should see "OGX Operator Catalog" listed. +4. Filter by this catalog, and your OGX K8s operator should appear, ready for installation. diff --git a/docs/migration-guide.md b/docs/migration-guide.md new file mode 100644 index 000000000..b38a6dacc --- /dev/null +++ b/docs/migration-guide.md @@ -0,0 +1,392 @@ +# Migration Guide: LlamaStackDistribution to OGXServer + +This guide covers migrating from the `LlamaStackDistribution` operator (`llamastack.io/v1alpha1`) to the `OGXServer` operator (`ogx.io/v1beta1`). + +## Breaking Change + +This is a **breaking change**. There is no coexistence period and no conversion webhooks. Users must manually create new OGXServer CRs and migrate configuration. + +## Name Mapping + +| Component | Old | New | +|-----------|-----|-----| +| API Group | `llamastack.io` | `ogx.io` | +| API Version | `v1alpha1` | `v1beta1` | +| Kind | `LlamaStackDistribution` | `OGXServer` | +| Plural | `llamastackdistributions` | `ogxservers` | +| Short Name | `llsd` | `ogxserver` | +| Container Name | `llama-stack` | `ogx` | +| App Label | `app: llama-stack` | `app: ogx` | +| Managed-by | `llama-stack-operator` | `ogx-operator` | +| Operator Namespace | `llama-stack-k8s-operator-system` | `ogx-k8s-operator-system` | +| Watch Label | `llamastack.io/watch: "true"` | `ogx.io/watch: "true"` | +| Mount Path | `/.llama` | `/.ogx` | +| Leader Election ID | `81d5736e.llamastack.io` | `54e06e98.ogx.io` | + +### Environment Variables + +| Old | New | Notes | +|-----|-----|-------| +| `LLS_PORT` | `OGX_PORT` | Container port for the server | +| `LLS_WORKERS` | `OGX_WORKERS` | Number of uvicorn worker processes | +| `LLAMA_STACK_CONFIG` | `OGX_CONFIG` | Path to the server config file | + +### Status Field Changes + +| Old Path | New Path | +|----------|----------| +| `.status.version.llamaStackServerVersion` | `.status.version.serverVersion` | +| `.status.routeURL` | `.status.externalURL` | + +### CLI Commands + +| Old | New | +|-----|-----| +| `kubectl get llsd` | `kubectl get ogxserver` | +| `kubectl get llamastackdistributions` | `kubectl get ogxservers` | + +## Spec Changes + +### Network Configuration + +Old (`spec.network`): +```yaml +spec: + network: + exposeRoute: true + allowedFrom: + namespaces: ["my-app"] + labels: ["team=frontend"] +``` + +New (`spec.network`): +```yaml +spec: + network: + externalAccess: + enabled: true + policy: + enabled: true + ingress: + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: my-app + - namespaceSelector: + matchLabels: + team: frontend + ports: + - protocol: TCP + port: 8321 +``` + +### TLS Configuration + +Old (nested under `server.tlsConfig`): +```yaml +spec: + server: + tlsConfig: + enabled: true + secretName: my-tls-secret + caBundle: + configMapName: my-ca-bundle +``` + +New (server TLS under `network.tls`, outbound trust under `tls.trust`): +```yaml +spec: + network: + tls: + secretName: my-tls-secret + tls: + trust: + caCertificates: + - name: my-ca-bundle + key: ca-bundle.crt +``` + +### Workload Configuration + +Old (flat on spec): +```yaml +spec: + replicas: 2 + server: + distribution: + name: starter + containerSpec: + env: + - name: MY_VAR + value: "hello" + storage: + size: "20Gi" +``` + +New (grouped under `spec.workload`): +```yaml +spec: + distribution: + name: starter + workload: + replicas: 2 + storage: + size: "20Gi" + overrides: + env: + - name: MY_VAR + value: "hello" +``` + +### Runtime Config in CR (New) + +`OGXServer` adds declarative runtime config generation directly from CR fields: + +- `spec.providers` +- `spec.resources` +- `spec.storage` +- `spec.disabledAPIs` + +If `spec.overrideConfig` is not set, the operator generates `config.yaml` and mounts it automatically. +If `spec.overrideConfig` is set, the user-provided ConfigMap override takes precedence. + +See [Runtime Config Generation Guide](additional/runtime-config-generation.md). + +### NetworkPolicy + +The legacy `AllowedFromSpec` and ConfigMap-based `enableNetworkPolicy` feature flag are replaced by `spec.network.policy` with native Kubernetes NetworkPolicy types: + +```yaml +spec: + network: + policy: + enabled: true # Per-CR toggle (replaces ConfigMap feature flag) + policyTypes: + - Ingress + - Egress + ingress: # Native K8s NetworkPolicyIngressRule + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: my-app + ports: + - protocol: TCP + port: 8321 + egress: # Native K8s NetworkPolicyEgressRule + - to: + - ipBlock: + cidr: 10.0.0.0/8 + ports: + - protocol: TCP + port: 443 +``` + +To translate `enableNetworkPolicy: false` from the old ConfigMap, set `spec.network.policy.enabled: false` on the CR. + +### ConfigMap/Secret Watch Labels and Namespace Scope + +All referenced ConfigMaps and Secrets must have the `ogx.io/watch: "true"` label, and must be in the same namespace as the OGXServer CR: + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: my-config + namespace: + labels: + ogx.io/watch: "true" +data: + config.yaml: | + ... +``` + +## Upgrade Steps + +### Step 1: Remove the Old LLS Operator + +**Via meta-operator:** +```bash +# Set component to Removed +dsc.spec.components.lls = "Removed" +``` + +**Manual:** +```bash +# Delete the operator Deployment, ServiceAccount, and RBAC — but NOT the CRD. +# Deleting the CRD cascade-deletes all CRs and their owned resources (data loss). +kubectl -n llama-stack-k8s-operator-system delete deployment llama-stack-k8s-operator-controller-manager +kubectl -n llama-stack-k8s-operator-system delete serviceaccount llama-stack-k8s-operator-controller-manager +kubectl delete clusterrolebinding llama-stack-k8s-operator-manager-rolebinding +kubectl delete clusterrole llama-stack-k8s-operator-manager-role +``` + +> **Warning:** Do NOT run `kubectl delete -f release/operator.yaml` — that file includes the CRD, +> and deleting a CRD cascade-deletes all its CRs and their owned resources (PVCs, Deployments, etc.). + +The operand Deployments, CRD, and CRs remain after operator removal. The old workload keeps running — no downtime yet. + +### Step 2: Install the New OGX Operator + +**Via meta-operator:** +```bash +dsc.spec.components.ogx = "Managed" +``` + +**Manual:** +```bash +kubectl apply -f release/operator.yaml +``` + +### Step 3: Define the OGXServer CR + +Translate fields from the old LLSD CR into the new OGXServer spec. See [Spec Changes](#spec-changes) above for the field mapping. + +```yaml +apiVersion: ogx.io/v1beta1 +kind: OGXServer +metadata: + name: my-server +spec: + distribution: + name: starter + workload: + replicas: 1 + storage: + size: "20Gi" + overrides: + env: + - name: OLLAMA_INFERENCE_MODEL + value: "llama3.2:1b" + - name: OLLAMA_URL + value: "http://ollama-server-service.ollama-dist.svc.cluster.local:11434" +``` + +> **Note:** The OGXServer name **must differ** from the old LLSD name. Same-name adoption is rejected by the validating webhook to prevent resource naming conflicts. + +#### Adopting Existing PVC (Optional) + +To preserve existing data by adopting the PVC from the old LlamaStackDistribution: + +```yaml +metadata: + annotations: + ogx.io/adopt-storage: "" +``` + +The operator strips the old ownerRef from the PVC and labels it for discovery. The adopted PVC intentionally has **no** ownerReference to the OGXServer — it survives CR deletion and must be cleaned up manually. + +> **Warning:** If multiple PVCs with matching adoption labels are found for the same instance, the controller sets an `AdoptionConfigInvalid` condition and stops reconciling. Remove the conflicting label to resolve. + +#### Adopting Existing Service and Ingress (Optional) + +To preserve ClusterIP / external endpoints: + +```yaml +metadata: + annotations: + ogx.io/adopt-storage: "" + ogx.io/adopt-networking: "" +``` + +The operator adopts the orphaned Service + Ingress, replaces Service selectors with new pod labels (`app: ogx`, `app.kubernetes.io/instance: `), and sets ownerReferences. + +### Step 4: Apply the OGXServer CR + +```bash +kubectl apply -f ogxserver.yaml +``` + +Expect ~30–60s until the new pod is ready. + +### Step 5: Verify + +```bash +# Check the new CRD is registered +kubectl get crd ogxservers.ogx.io + +# List OGXServer resources +kubectl get ogxserver + +# Check conditions for adoption status +kubectl get ogxserver my-server -o jsonpath='{.status.conditions}' + +# Verify the server is ready +kubectl get ogxserver my-server -o jsonpath='{.status.phase}' +``` + +Wait until the OGXServer phase is `Ready` before proceeding to cleanup. + +### Step 6: Clean Up Legacy Resources + +Once the new OGXServer is verified healthy, remove legacy resources. + +Delete the old LlamaStackDistribution CR. Kubernetes will cascade-delete its owned resources (Deployment, NetworkPolicy, ServiceAccount, RoleBinding, HPA, PDB): + +```bash +kubectl delete llamastackdistribution -n +``` + +If you adopted the PVC or Service/Ingress (Step 3), those resources now have new ownerRefs and are **not** affected by this deletion. + +If you chose **not** to adopt the old PVC, delete it manually: + +```bash +kubectl delete pvc -pvc -n +``` + +If you chose **not** to adopt the old Service and Ingress/Route, delete them manually: + +```bash +kubectl delete svc -service -n +kubectl delete ingress -n --ignore-not-found +kubectl delete route -n --ignore-not-found +``` + +Finally, once all LlamaStackDistribution CRs have been removed, delete the legacy CRD: + +```bash +kubectl delete crd llamastackdistributions.llamastack.io +``` + +## Rollback + +If something goes wrong before completing Step 6 (legacy cleanup), rollback is straightforward because the old resources are still in place: + +1. Delete the OGXServer CR: `kubectl delete ogxserver my-server -n ` +2. Reinstall the old LLS operator +3. The old LlamaStackDistribution CR is still present and will be reconciled by the reinstalled operator + +If you already completed Step 6 (legacy resources deleted), you must recreate the old LlamaStackDistribution CR manually after reinstalling the old operator. + +## NetworkPolicy Impact + +The label change from `app: llama-stack` to `app: ogx` affects all NetworkPolicy `podSelector` fields: + +**Old operator NetworkPolicy:** +```yaml +spec: + podSelector: + matchLabels: + app: llama-stack + app.kubernetes.io/instance: my-server +``` + +**New operator NetworkPolicy:** +```yaml +spec: + podSelector: + matchLabels: + app: ogx + app.kubernetes.io/instance: my-server +``` + +Any external NetworkPolicies targeting `app: llama-stack` must be updated to `app: ogx`. + +### Distribution Name Removals + +The following built-in distribution names have been removed from `distributions.json`: + +- `remote-vllm` +- `meta-reference-gpu` + +If your `OGXServer` spec references either of these by name, you must switch to specifying the image directly via `spec.distribution.image` or use one of the remaining distributions (`starter`, `postgres-demo`). diff --git a/go.mod b/go.mod index 5391952ab..e16e669fc 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/llamastack/llama-stack-k8s-operator +module github.com/ogx-ai/ogx-k8s-operator go 1.25.8 diff --git a/hack/validate-config-dryrun.env.example b/hack/validate-config-dryrun.env.example new file mode 100644 index 000000000..aa0229254 --- /dev/null +++ b/hack/validate-config-dryrun.env.example @@ -0,0 +1,8 @@ +# configgen inputs +CR_PATH=cmd/configgen/testdata/happy-basic/cr.yaml +BASE_PATH=cmd/configgen/testdata/happy-basic/base.yaml + +# ogx source — git ref to install via uvx +# Can be a branch, tag, or commit SHA +OGX_GIT_REF=main +OGX_GIT_REPO=https://github.com/ogx-ai/ogx diff --git a/hack/validate-config-dryrun.sh b/hack/validate-config-dryrun.sh new file mode 100755 index 000000000..66de42476 --- /dev/null +++ b/hack/validate-config-dryrun.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +ENV_FILE="$SCRIPT_DIR/validate-config-dryrun.env" + +if [[ ! -f "$ENV_FILE" ]]; then + echo "Error: $ENV_FILE not found" + echo "Copy the example and edit it:" + echo " cp $ENV_FILE.example $ENV_FILE" + exit 2 +fi + +# shellcheck source=/dev/null +source "$ENV_FILE" + +: "${CR_PATH:?CR_PATH must be set in $ENV_FILE}" +: "${OGX_GIT_REF:?OGX_GIT_REF must be set in $ENV_FILE}" +: "${OGX_GIT_REPO:?OGX_GIT_REPO must be set in $ENV_FILE}" + +if ! command -v uvx &>/dev/null; then + echo "Error: uvx not found. Install uv: https://docs.astral.sh/uv/" + exit 2 +fi + +CONFIGGEN="$REPO_ROOT/bin/configgen" +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT + +echo "Building configgen..." +(cd "$REPO_ROOT" && go build -o "$CONFIGGEN" ./cmd/configgen/) + +CONFIGGEN_ARGS=(-output-config) +if [[ -n "${BASE_PATH:-}" ]]; then + CONFIGGEN_ARGS+=(-base "$REPO_ROOT/$BASE_PATH") +fi +CONFIGGEN_ARGS+=("$REPO_ROOT/$CR_PATH") + +CONFIG_FILE="$TMPDIR/config.yaml" + +echo "Generating config from $CR_PATH..." +"$CONFIGGEN" "${CONFIGGEN_ARGS[@]}" > "$CONFIG_FILE" + +echo "--- generated config ---" +cat "$CONFIG_FILE" +echo "------------------------" + +echo "" +echo "Running ogx dry-run (${OGX_GIT_REPO}@${OGX_GIT_REF})..." +uvx --from "ogx @ git+${OGX_GIT_REPO}@${OGX_GIT_REF}" ogx run "$CONFIG_FILE" --dry-run diff --git a/main.go b/main.go index fda28c7ab..e9e52919b 100644 --- a/main.go +++ b/main.go @@ -22,9 +22,10 @@ import ( "fmt" "os" - llamaxk8siov1alpha1 "github.com/llamastack/llama-stack-k8s-operator/api/v1alpha1" - "github.com/llamastack/llama-stack-k8s-operator/controllers" - "github.com/llamastack/llama-stack-k8s-operator/pkg/cluster" + ogxiov1beta1 "github.com/ogx-ai/ogx-k8s-operator/api/v1beta1" + "github.com/ogx-ai/ogx-k8s-operator/controllers" + "github.com/ogx-ai/ogx-k8s-operator/pkg/cluster" + "github.com/ogx-ai/ogx-k8s-operator/pkg/deploy" "go.uber.org/zap/zapcore" appsv1 "k8s.io/api/apps/v1" autoscalingv2 "k8s.io/api/autoscaling/v2" @@ -61,15 +62,32 @@ var ( func init() { //nolint:gochecknoinits utilruntime.Must(clientgoscheme.AddToScheme(scheme)) - utilruntime.Must(llamaxk8siov1alpha1.AddToScheme(scheme)) + utilruntime.Must(ogxiov1beta1.AddToScheme(scheme)) //+kubebuilder:scaffold:scheme } -func setupReconciler(ctx context.Context, cli client.Client, mgr ctrl.Manager, clusterInfo *cluster.ClusterInfo, directClient client.Reader) error { - reconciler, err := controllers.NewLlamaStackDistributionReconciler(ctx, cli, scheme, clusterInfo, directClient) +func setupWebhook(mgr ctrl.Manager, clusterInfo *cluster.ClusterInfo) error { + distNames := make([]string, 0, len(clusterInfo.DistributionImages)) + for name := range clusterInfo.DistributionImages { + distNames = append(distNames, name) + } + return ogxiov1beta1.SetupWebhookWithManager(mgr, distNames) +} + +func setupReconciler(ctx context.Context, setupClient client.Client, mgr ctrl.Manager, clusterInfo *cluster.ClusterInfo) error { + operatorNamespace, err := deploy.GetOperatorNamespace() + if err != nil { + return fmt.Errorf("failed to get operator namespace: %w", err) + } + + configMap, err := controllers.InitializeOperatorConfigMap(ctx, setupClient, operatorNamespace) if err != nil { - return fmt.Errorf("failed to create reconciler: %w", err) + return fmt.Errorf("failed to initialize operator config: %w", err) } + + imageMappingOverrides := controllers.ParseImageMappingOverrides(ctx, configMap.Data) + + reconciler := controllers.NewOGXServerReconciler(mgr.GetClient(), scheme, clusterInfo, imageMappingOverrides, operatorNamespace) if err = reconciler.SetupWithManager(ctx, mgr); err != nil { return fmt.Errorf("failed to create controller: %w", err) } @@ -78,7 +96,7 @@ func setupReconciler(ctx context.Context, cli client.Client, mgr ctrl.Manager, c func newCacheOptions() cache.Options { managedBySelector := labels.SelectorFromSet(labels.Set{ - "app.kubernetes.io/managed-by": "llama-stack-operator", + "app.kubernetes.io/managed-by": "ogx-operator", }) managedByFilter := cache.ByObject{Label: managedBySelector} @@ -90,6 +108,11 @@ func newCacheOptions() cache.Options { controllers.WatchLabelKey: controllers.WatchLabelValue, }), }, + &corev1.Secret{}: { + Label: labels.SelectorFromSet(labels.Set{ + controllers.WatchLabelKey: controllers.WatchLabelValue, + }), + }, &appsv1.Deployment{}: managedByFilter, &policyv1.PodDisruptionBudget{}: managedByFilter, &autoscalingv2.HorizontalPodAutoscaler{}: managedByFilter, @@ -139,7 +162,7 @@ func main() { Cache: newCacheOptions(), HealthProbeBindAddress: probeAddr, LeaderElection: enableLeaderElection, - LeaderElectionID: "54e06e98.llamastack.io", + LeaderElectionID: "54e06e98.ogx.io", LeaderElectionResourceLock: "leases", LeaderElectionNamespace: "", // LeaderElectionReleaseOnCancel defines if the leader should step down voluntarily @@ -185,7 +208,12 @@ func main() { os.Exit(1) } - if err := setupReconciler(ctx, setupClient, mgr, clusterInfo, setupClient); err != nil { + if err := setupWebhook(mgr, clusterInfo); err != nil { + setupLog.Error(err, "failed to set up webhook") + os.Exit(1) + } + + if err := setupReconciler(ctx, setupClient, mgr, clusterInfo); err != nil { setupLog.Error(err, "failed to set up reconciler") os.Exit(1) } diff --git a/pkg/cluster/cluster.go b/pkg/cluster/cluster.go index f645963b5..b8911093f 100644 --- a/pkg/cluster/cluster.go +++ b/pkg/cluster/cluster.go @@ -4,9 +4,11 @@ import ( "context" "encoding/json" "fmt" + "os" + "strings" "github.com/go-logr/logr" - "github.com/llamastack/llama-stack-k8s-operator/pkg/deploy" + "github.com/ogx-ai/ogx-k8s-operator/pkg/deploy" rbacv1 "k8s.io/api/rbac/v1" k8serrors "k8s.io/apimachinery/pkg/api/errors" "sigs.k8s.io/controller-runtime/pkg/client" @@ -30,12 +32,31 @@ func NewClusterInfo(ctx context.Context, client client.Client, embeddedDistribut return nil, fmt.Errorf("failed to parse embedded distributions JSON: %w", err) } + applyRelatedImageOverrides(distributionImages) + return &ClusterInfo{ OperatorNamespace: operatorNamespace, DistributionImages: distributionImages, }, nil } +// applyRelatedImageOverrides overrides distribution images with RELATED_IMAGE_* env vars +// when set. OLM injects these with mirrored image digests for disconnected environments. +func applyRelatedImageOverrides(distributionImages map[string]string) { + for name := range distributionImages { + envKey := DistributionEnvVarKey(name) + if override := os.Getenv(envKey); override != "" { + distributionImages[name] = override + } + } +} + +// DistributionEnvVarKey converts a distribution name to its RELATED_IMAGE_* env var name. +// Example: "remote-vllm" becomes "RELATED_IMAGE_REMOTE_VLLM". +func DistributionEnvVarKey(name string) string { + return "RELATED_IMAGE_" + strings.ToUpper(strings.ReplaceAll(name, "-", "_")) +} + // PerformUpgradeCleanup performs one-time cleanup operations for seamless upgrades. func PerformUpgradeCleanup(ctx context.Context, client client.Client) error { logger := log.FromContext(ctx).WithName("upgrade-cleanup") @@ -89,7 +110,7 @@ func cleanupLegacyClusterRoleBindings(ctx context.Context, client client.Client, // shouldDeleteLegacyClusterRoleBinding determines if a ClusterRoleBinding should be deleted. func shouldDeleteLegacyClusterRoleBinding(crb *rbacv1.ClusterRoleBinding) bool { // Only delete ClusterRoleBindings that were created by our operator - if managedBy, exists := crb.Labels["app.kubernetes.io/managed-by"]; !exists || managedBy != "llama-stack-operator" { + if managedBy, exists := crb.Labels["app.kubernetes.io/managed-by"]; !exists || managedBy != "ogx-operator" { return false } diff --git a/pkg/cluster/cluster_test.go b/pkg/cluster/cluster_test.go index d84d6ad85..cabfe79ac 100644 --- a/pkg/cluster/cluster_test.go +++ b/pkg/cluster/cluster_test.go @@ -4,6 +4,8 @@ import ( "encoding/json" "os" "testing" + + "github.com/stretchr/testify/require" ) // TestDistributionsJSONIsValid ensures that the distributions.json file always @@ -28,3 +30,78 @@ func TestDistributionsJSONIsValid(t *testing.T) { } } } + +func TestDistributionEnvVarKey(t *testing.T) { + tests := []struct { + name string + expected string + }{ + {name: "starter", expected: "RELATED_IMAGE_STARTER"}, + {name: "remote-vllm", expected: "RELATED_IMAGE_REMOTE_VLLM"}, + {name: "meta-reference-gpu", expected: "RELATED_IMAGE_META_REFERENCE_GPU"}, + {name: "postgres-demo", expected: "RELATED_IMAGE_POSTGRES_DEMO"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.expected, DistributionEnvVarKey(tt.name)) + }) + } +} + +func TestNewClusterInfoRelatedImageOverrides(t *testing.T) { + distributions := `{ + "starter": "docker.io/ogxai/distribution-starter:latest", + "remote-vllm": "docker.io/ogxai/distribution-remote-vllm:latest", + "meta-reference-gpu": "docker.io/ogxai/distribution-meta-reference-gpu:latest" + }` + + t.Run("no env vars set uses distributions.json defaults", func(t *testing.T) { + var images map[string]string + require.NoError(t, json.Unmarshal([]byte(distributions), &images)) + + applyRelatedImageOverrides(images) + + require.Equal(t, "docker.io/ogxai/distribution-starter:latest", images["starter"]) + require.Equal(t, "docker.io/ogxai/distribution-remote-vllm:latest", images["remote-vllm"]) + require.Equal(t, "docker.io/ogxai/distribution-meta-reference-gpu:latest", images["meta-reference-gpu"]) + }) + + t.Run("env var overrides single distribution", func(t *testing.T) { + t.Setenv("RELATED_IMAGE_STARTER", "mirror.example.com/ogx-starter@sha256:abc123") + + var images map[string]string + require.NoError(t, json.Unmarshal([]byte(distributions), &images)) + + applyRelatedImageOverrides(images) + + require.Equal(t, "mirror.example.com/ogx-starter@sha256:abc123", images["starter"]) + require.Equal(t, "docker.io/ogxai/distribution-remote-vllm:latest", images["remote-vllm"]) + }) + + t.Run("env var overrides all distributions", func(t *testing.T) { + t.Setenv("RELATED_IMAGE_STARTER", "mirror.example.com/starter@sha256:aaa") + t.Setenv("RELATED_IMAGE_REMOTE_VLLM", "mirror.example.com/vllm@sha256:bbb") + t.Setenv("RELATED_IMAGE_META_REFERENCE_GPU", "mirror.example.com/gpu@sha256:ccc") + + var images map[string]string + require.NoError(t, json.Unmarshal([]byte(distributions), &images)) + + applyRelatedImageOverrides(images) + + require.Equal(t, "mirror.example.com/starter@sha256:aaa", images["starter"]) + require.Equal(t, "mirror.example.com/vllm@sha256:bbb", images["remote-vllm"]) + require.Equal(t, "mirror.example.com/gpu@sha256:ccc", images["meta-reference-gpu"]) + }) + + t.Run("empty env var does not override", func(t *testing.T) { + t.Setenv("RELATED_IMAGE_STARTER", "") + + var images map[string]string + require.NoError(t, json.Unmarshal([]byte(distributions), &images)) + + applyRelatedImageOverrides(images) + + require.Equal(t, "docker.io/ogxai/distribution-starter:latest", images["starter"]) + }) +} diff --git a/pkg/compare/comparison_test.go b/pkg/compare/comparison_test.go index e3f1ae0da..9f3612399 100644 --- a/pkg/compare/comparison_test.go +++ b/pkg/compare/comparison_test.go @@ -3,7 +3,7 @@ package compare_test import ( "testing" - "github.com/llamastack/llama-stack-k8s-operator/pkg/compare" + "github.com/ogx-ai/ogx-k8s-operator/pkg/compare" "github.com/stretchr/testify/assert" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go new file mode 100644 index 000000000..ecec5e646 --- /dev/null +++ b/pkg/config/config_test.go @@ -0,0 +1,1823 @@ +/* +Copyright 2025. + +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. +*/ + +package config + +import ( + "errors" + "strings" + "testing" + + ogxiov1beta1 "github.com/ogx-ai/ogx-k8s-operator/api/v1beta1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" +) + +const llama3ModelID = "llama3" + +func requireProviderAt(t *testing.T, result map[string][]ConfigProvider, api string, idx int) ConfigProvider { + t.Helper() + + providers := result[api] + if len(providers) <= idx { + t.Fatalf("expected %s provider at index %d, got %d providers", api, idx, len(providers)) + } + return providers[idx] +} + +func requireConfigMap(t *testing.T, cfg map[string]interface{}, key string) map[string]interface{} { + t.Helper() + + value, ok := cfg[key].(map[string]interface{}) + if !ok { + t.Fatalf("expected %s map, got %v", key, cfg[key]) + } + return value +} + +func requireStringMap(t *testing.T, cfg map[string]interface{}, key string) map[string]string { + t.Helper() + + value, ok := cfg[key].(map[string]string) + if !ok { + t.Fatalf("expected %s map, got %v", key, cfg[key]) + } + return value +} + +func assertProviderIdentity(t *testing.T, provider ConfigProvider, wantID, wantType string) { + t.Helper() + + if provider.ProviderID != wantID { + t.Errorf("expected provider_id %q, got %q", wantID, provider.ProviderID) + } + if wantType != "" && provider.ProviderType != wantType { + t.Errorf("expected provider_type %q, got %q", wantType, provider.ProviderType) + } +} + +func assertConfigValue(t *testing.T, cfg map[string]interface{}, key string, want interface{}) { + t.Helper() + + got, ok := cfg[key] + if !ok { + t.Errorf("expected %s, but key was missing", key) + return + } + if got != want { + t.Errorf("expected %s %v, got %v", key, want, got) + } +} + +func assertConfigValues(t *testing.T, cfg map[string]interface{}, want map[string]interface{}) { + t.Helper() + + for key, value := range want { + assertConfigValue(t, cfg, key, value) + } +} + +func assertConfigAbsent(t *testing.T, cfg map[string]interface{}, key string) { + t.Helper() + + if _, ok := cfg[key]; ok { + t.Errorf("%s should not appear in config", key) + } +} + +func TestParseBaseConfig(t *testing.T) { //nolint:cyclop,gocognit // table-driven test with inline assertions + tests := []struct { + name string + input string + wantErr bool + check func(t *testing.T, cfg *BaseConfig) + }{ + { + name: "valid base config", + input: `version: '2' +distro_name: starter +image_name: starter +apis: +- inference +- vector_io +providers: + inference: + - provider_id: remote-vllm + provider_type: remote::vllm + config: + url: http://vllm:8000 +registered_resources: + models: + - model_id: llama3 + provider_id: remote-vllm + model_type: llm +`, + check: func(t *testing.T, cfg *BaseConfig) { + t.Helper() + if cfg.Version != "2" { + t.Errorf("expected version '2', got %q", cfg.Version) + } + if cfg.DistroName != "starter" { + t.Errorf("expected distro_name 'starter', got %q", cfg.DistroName) + } + if cfg.ImageName != "starter" { + t.Errorf("expected image_name 'starter', got %q", cfg.ImageName) + } + if len(cfg.APIs) != 2 { + t.Fatalf("expected 2 APIs, got %d", len(cfg.APIs)) + } + if cfg.APIs[0] != "inference" { + t.Errorf("expected first API 'inference', got %q", cfg.APIs[0]) + } + if len(cfg.Providers["inference"]) != 1 { + t.Fatalf("expected 1 inference provider, got %d", len(cfg.Providers["inference"])) + } + if cfg.Providers["inference"][0].ProviderID != "remote-vllm" { + t.Errorf("expected provider_id 'remote-vllm', got %q", cfg.Providers["inference"][0].ProviderID) + } + if cfg.RegisteredResources == nil || len(cfg.RegisteredResources.Models) != 1 { + t.Fatalf("expected 1 registered resource model, got %+v", cfg.RegisteredResources) + } + firstModel, ok := cfg.RegisteredResources.Models[0].(map[string]interface{}) + if !ok || firstModel["model_id"] != llama3ModelID { + t.Errorf("expected registered resource model %q, got %+v", llama3ModelID, cfg.RegisteredResources.Models[0]) + } + }, + }, + { + name: "missing version", + input: `image_name: test`, + wantErr: true, + }, + { + name: "invalid yaml", + input: `{invalid`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg, err := ParseBaseConfig([]byte(tt.input)) + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tt.check != nil { + tt.check(t, cfg) + } + }) + } +} + +func TestMergeProviders(t *testing.T) { + base := map[string][]ConfigProvider{ + "inference": { + {ProviderID: "base-vllm", ProviderType: "remote::vllm"}, + }, + "vector_io": { + {ProviderID: "base-pgvector", ProviderType: "remote::pgvector"}, + }, + } + + user := map[string][]ConfigProvider{ + "inference": { + {ProviderID: "user-openai", ProviderType: "remote::openai"}, + }, + } + + merged := MergeProviders(base, user) + + // User inference should replace base inference + if len(merged["inference"]) != 1 || merged["inference"][0].ProviderID != "user-openai" { + t.Errorf("expected user inference to replace base, got %+v", merged["inference"]) + } + + // Base vector_io should remain + if len(merged["vector_io"]) != 1 || merged["vector_io"][0].ProviderID != "base-pgvector" { + t.Errorf("expected base vector_io to remain, got %+v", merged["vector_io"]) + } +} + +func TestMergeProviders_NilCases(t *testing.T) { + providers := map[string][]ConfigProvider{ + "inference": {{ProviderID: "test"}}, + } + + // nil user returns base + if result := MergeProviders(providers, nil); len(result) != 1 { + t.Error("expected base returned when user is nil") + } + + // nil base returns user + if result := MergeProviders(nil, providers); len(result) != 1 { + t.Error("expected user returned when base is nil") + } +} + +func TestMergeAPIs(t *testing.T) { + base := []string{"inference", "vector_io", "tool_runtime", "agents"} + disabled := []string{"agents", "vector_io"} + + result := MergeAPIs(base, disabled) + + expected := map[string]bool{"inference": true, "tool_runtime": true} + if len(result) != 2 { + t.Fatalf("expected 2 APIs, got %d: %v", len(result), result) + } + for _, api := range result { + if !expected[api] { + t.Errorf("unexpected API %q in result", api) + } + } +} + +func TestMergeAPIs_NoDisabled(t *testing.T) { + base := []string{"inference", "vector_io"} + result := MergeAPIs(base, nil) + if len(result) != 2 { + t.Errorf("expected all base APIs when no disabled, got %v", result) + } +} + +func TestExpandResources(t *testing.T) { + providers := map[string][]ConfigProvider{ + "inference": { + {ProviderID: "vllm-1", ProviderType: "remote::vllm"}, + }, + } + + contextLen := 8192 + resources := &ogxiov1beta1.ResourcesSpec{ + Models: []ogxiov1beta1.ModelConfig{ + { + Name: llama3ModelID, + Provider: "vllm-1", + ModelType: defaultModelType, + ContextLength: &contextLen, + }, + { + Name: "llama3-embed", + }, + }, + } + + models, err := ExpandResources(resources, providers) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(models) != 2 { + t.Fatalf("expected 2 models, got %d", len(models)) + } + + // First model: explicit provider + if models[0].ModelID != llama3ModelID { + t.Errorf("expected model_id %q, got %q", llama3ModelID, models[0].ModelID) + } + if models[0].ProviderID != "vllm-1" { + t.Errorf("expected provider_id 'vllm-1', got %q", models[0].ProviderID) + } + if models[0].ModelType != defaultModelType { + t.Errorf("expected model_type 'llm', got %q", models[0].ModelType) + } + if models[0].ContextLength == nil || *models[0].ContextLength != 8192 { + t.Errorf("expected context_length 8192, got %v", models[0].ContextLength) + } + + // Second model: defaults to first inference provider + if models[1].ProviderID != "vllm-1" { + t.Errorf("expected default provider_id 'vllm-1', got %q", models[1].ProviderID) + } + if models[1].ModelType != defaultModelType { + t.Errorf("expected default model_type 'llm', got %q", models[1].ModelType) + } +} + +func TestExpandResources_NilSpec(t *testing.T) { + models, err := ExpandResources(nil, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if models != nil { + t.Errorf("expected nil, got %v", models) + } +} + +func TestExpandResources_DefaultProviderIsDeterministic(t *testing.T) { + providers := map[string][]ConfigProvider{ + "inference": { + {ProviderID: "openai-1", ProviderType: "remote::openai"}, + {ProviderID: "azure-1", ProviderType: "remote::azure"}, + {ProviderID: "vllm-1", ProviderType: "remote::vllm"}, + }, + } + resources := &ogxiov1beta1.ResourcesSpec{ + Models: []ogxiov1beta1.ModelConfig{{Name: llama3ModelID}}, + } + + models, err := ExpandResources(resources, providers) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if models[0].ProviderID != "azure-1" { + t.Errorf("expected lexicographically first provider 'azure-1', got %q", models[0].ProviderID) + } + + // Reverse the input order — result should be the same + providers["inference"] = []ConfigProvider{ + {ProviderID: "vllm-1", ProviderType: "remote::vllm"}, + {ProviderID: "openai-1", ProviderType: "remote::openai"}, + {ProviderID: "azure-1", ProviderType: "remote::azure"}, + } + models2, err := ExpandResources(resources, providers) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if models2[0].ProviderID != "azure-1" { + t.Errorf("expected same provider regardless of input order, got %q", models2[0].ProviderID) + } +} + +func TestExpandResources_NoProviderAndNoInference(t *testing.T) { + resources := &ogxiov1beta1.ResourcesSpec{ + Models: []ogxiov1beta1.ModelConfig{ + {Name: llama3ModelID}, + }, + } + // No providers at all + _, err := ExpandResources(resources, map[string][]ConfigProvider{}) + if err == nil { + t.Fatal("expected error when no inference providers") + } +} + +func TestApplyStorage_Nil(t *testing.T) { + result := ApplyStorage(nil) + if result != nil { + t.Errorf("expected nil for nil storage spec, got %v", result) + } +} + +func TestApplyStorage_SQLiteDefaults(t *testing.T) { + storage := &ogxiov1beta1.StateStorageSpec{ + KV: &ogxiov1beta1.KVStorageSpec{Type: "sqlite"}, + SQL: &ogxiov1beta1.SQLStorageSpec{Type: "sqlite"}, + } + + result := ApplyStorage(storage) + if result == nil { + t.Fatal("expected non-nil result") + } + + backends, ok := result["backends"].(map[string]interface{}) + if !ok { + t.Fatal("expected backends map") + } + + kvBackend, ok := backends["kv_default"].(map[string]interface{}) + if !ok { + t.Fatal("expected kv_default map") + } + if kvBackend["type"] != "kv_sqlite" { + t.Errorf("expected kv_sqlite, got %v", kvBackend["type"]) + } + + sqlBackend, ok := backends["sql_default"].(map[string]interface{}) + if !ok { + t.Fatal("expected sql_default map") + } + if sqlBackend["type"] != "sql_sqlite" { + t.Errorf("expected sql_sqlite, got %v", sqlBackend["type"]) + } +} + +func TestApplyStorage_Redis(t *testing.T) { + storage := &ogxiov1beta1.StateStorageSpec{ + KV: &ogxiov1beta1.KVStorageSpec{ + Type: "redis", + Endpoint: "redis://my-redis:6379", + Password: &ogxiov1beta1.SecretKeyRef{ + Name: "redis-secret", + Key: "password", + }, + }, + } + + result := ApplyStorage(storage) + backends, ok := result["backends"].(map[string]interface{}) + if !ok { + t.Fatal("expected backends to be a map") + } + kvBackend, ok := backends["kv_default"].(map[string]interface{}) + if !ok { + t.Fatal("expected kv_default to be a map") + } + + if kvBackend["type"] != "kv_redis" { + t.Errorf("expected kv_redis, got %v", kvBackend["type"]) + } + if kvBackend["host"] != "redis://my-redis:6379" { + t.Errorf("expected endpoint, got %v", kvBackend["host"]) + } +} + +func TestExpandProviders_Nil(t *testing.T) { + result, err := ExpandProviders(nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != nil { + t.Errorf("expected nil for nil providers, got %v", result) + } +} + +func TestExpandProviders_InferenceVLLM(t *testing.T) { + providers := &ogxiov1beta1.ProvidersSpec{ + Inference: &ogxiov1beta1.InferenceProvidersSpec{ + Remote: &ogxiov1beta1.InferenceRemoteProviders{ + VLLM: []ogxiov1beta1.VLLMProvider{ + { + Endpoint: "https://vllm.example.com:8000", + }, + }, + }, + }, + } + + result, err := ExpandProviders(providers) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result == nil { + t.Fatal("expected non-nil result") + } + + inferenceProviders := result["inference"] + if len(inferenceProviders) != 1 { + t.Fatalf("expected 1 inference provider, got %d", len(inferenceProviders)) + } + + p := inferenceProviders[0] + if p.ProviderType != "remote::vllm" { + t.Errorf("expected provider_type 'remote::vllm', got %q", p.ProviderType) + } + if p.Config["base_url"] != "https://vllm.example.com:8000" { + t.Errorf("expected base_url, got %v", p.Config["base_url"]) + } +} + +func TestExpandProviders_InferenceOpenAI(t *testing.T) { + providers := &ogxiov1beta1.ProvidersSpec{ + Inference: &ogxiov1beta1.InferenceProvidersSpec{ + Remote: &ogxiov1beta1.InferenceRemoteProviders{ + OpenAI: []ogxiov1beta1.OpenAIProvider{ + { + Endpoint: "https://api.openai.com/v1", + APIKey: ogxiov1beta1.SecretKeyRef{ + Name: "openai-secret", + Key: "api-key", + }, + }, + }, + }, + }, + } + + result, err := ExpandProviders(providers) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + inferenceProviders := result["inference"] + if len(inferenceProviders) != 1 { + t.Fatalf("expected 1 inference provider, got %d", len(inferenceProviders)) + } + + p := inferenceProviders[0] + if p.ProviderType != "remote::openai" { + t.Errorf("expected provider_type 'remote::openai', got %q", p.ProviderType) + } +} + +func TestExpandProviders_FileProcessorsAllProviders(t *testing.T) { + chunkSize := 1000 + chunkOverlap := 500 + extractMeta := true + cleanText := false + doOCR := false + doclingChunk := 2048 + + providers := &ogxiov1beta1.ProvidersSpec{ + FileProcessors: &ogxiov1beta1.FileProcessorsProvidersSpec{ + Remote: &ogxiov1beta1.FileProcessorsRemoteProviders{ + DoclingServe: &ogxiov1beta1.DoclingServeProvider{ + BaseURL: "http://docling.example.com:5001/v1", + APIKey: &ogxiov1beta1.SecretKeyRef{Name: "docling-secret", Key: "api-key"}, + DefaultChunkSizeTokens: &doclingChunk, + }, + }, + Inline: &ogxiov1beta1.FileProcessorsInlineProviders{ + Auto: &ogxiov1beta1.InlineAutoFileProcessorProvider{ + FileProcessorChunkConfig: ogxiov1beta1.FileProcessorChunkConfig{ + DefaultChunkSizeTokens: &chunkSize, + DefaultChunkOverlapTokens: &chunkOverlap, + }, + ExtractMetadata: &extractMeta, + CleanText: &cleanText, + }, + MarkItDown: &ogxiov1beta1.InlineMarkItDownFileProcessorProvider{}, + Docling: &ogxiov1beta1.InlineDoclingFileProcessorProvider{ + DoOCR: &doOCR, + }, + }, + }, + } + + result, err := ExpandProviders(providers) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + fps := result["file_processors"] + if len(fps) != 4 { + t.Fatalf("expected 4 file_processors providers, got %d", len(fps)) + } + + assertProviderIdentity(t, fps[0], "remote-docling-serve", "remote::docling-serve") + assertConfigValues(t, fps[0].Config, map[string]interface{}{ + "base_url": "http://docling.example.com:5001/v1", + "default_chunk_size_tokens": 2048, + }) + + assertProviderIdentity(t, fps[1], "inline-auto", "inline::auto") + assertConfigValues(t, fps[1].Config, map[string]interface{}{ + "default_chunk_size_tokens": 1000, + "extract_metadata": true, + "clean_text": false, + }) + + assertProviderIdentity(t, fps[2], "inline-markitdown", "") + if len(fps[2].Config) != 0 { + t.Errorf("expected empty config for markitdown, got %v", fps[2].Config) + } + + assertProviderIdentity(t, fps[3], "inline-docling", "") + assertConfigValue(t, fps[3].Config, "do_ocr", false) +} + +func TestCollectSecretRefs_FileProcessorsDoclingServe(t *testing.T) { + spec := &ogxiov1beta1.OGXServerSpec{ + Providers: &ogxiov1beta1.ProvidersSpec{ + FileProcessors: &ogxiov1beta1.FileProcessorsProvidersSpec{ + Remote: &ogxiov1beta1.FileProcessorsRemoteProviders{ + DoclingServe: &ogxiov1beta1.DoclingServeProvider{ + BaseURL: "http://docling.example.com:5001/v1", + APIKey: &ogxiov1beta1.SecretKeyRef{Name: "docling-secret", Key: "api-key"}, + }, + }, + }, + }, + } + + envVars := CollectSecretRefs(spec) + if len(envVars) != 1 { + t.Fatalf("expected 1 env var, got %d", len(envVars)) + } + if envVars[0].Name != "OGX_REMOTE_DOCLING_SERVE_API_KEY" { + t.Errorf("expected env var name 'OGX_REMOTE_DOCLING_SERVE_API_KEY', got %q", envVars[0].Name) + } + if envVars[0].ValueFrom.SecretKeyRef.Name != "docling-secret" { + t.Errorf("expected secret name 'docling-secret', got %q", envVars[0].ValueFrom.SecretKeyRef.Name) + } +} + +func TestCollectSecretRefs_Nil(t *testing.T) { + spec := &ogxiov1beta1.OGXServerSpec{} + envVars := CollectSecretRefs(spec) + if len(envVars) != 0 { + t.Errorf("expected no env vars for empty spec, got %d", len(envVars)) + } +} + +func TestCollectSecretRefs_StorageRedis(t *testing.T) { + spec := &ogxiov1beta1.OGXServerSpec{ + Storage: &ogxiov1beta1.StateStorageSpec{ + KV: &ogxiov1beta1.KVStorageSpec{ + Type: "redis", + Endpoint: "redis://redis:6379", + Password: &ogxiov1beta1.SecretKeyRef{ + Name: "redis-secret", + Key: "password", + }, + }, + }, + } + + envVars := CollectSecretRefs(spec) + if len(envVars) == 0 { + t.Fatal("expected at least 1 env var for Redis password") + } + + found := false + for _, ev := range envVars { + if ev.ValueFrom != nil && ev.ValueFrom.SecretKeyRef != nil { + if ev.ValueFrom.SecretKeyRef.Name == "redis-secret" && + ev.ValueFrom.SecretKeyRef.Key == "password" { + found = true + break + } + } + } + if !found { + t.Error("expected env var referencing redis-secret/password") + } +} + +func TestCollectSecretRefs_StoragePostgres(t *testing.T) { + spec := &ogxiov1beta1.OGXServerSpec{ + Storage: &ogxiov1beta1.StateStorageSpec{ + SQL: &ogxiov1beta1.SQLStorageSpec{ + Type: "postgres", + ConnectionString: &ogxiov1beta1.SecretKeyRef{ + Name: "pg-secret", + Key: "conn-string", + }, + }, + }, + } + + envVars := CollectSecretRefs(spec) + if len(envVars) == 0 { + t.Fatal("expected at least 1 env var for Postgres connection string") + } + + found := false + for _, ev := range envVars { + if ev.ValueFrom != nil && ev.ValueFrom.SecretKeyRef != nil { + if ev.ValueFrom.SecretKeyRef.Name == "pg-secret" && + ev.ValueFrom.SecretKeyRef.Key == "conn-string" { + found = true + break + } + } + } + if !found { + t.Error("expected env var referencing pg-secret/conn-string") + } +} + +func TestGenerateConfig_Basic(t *testing.T) { + baseConfig := `version: '2' +distro_name: remote-vllm +image_name: remote-vllm +apis: +- inference +- vector_io +providers: + inference: + - provider_id: default-vllm + provider_type: remote::vllm + config: + url: http://default:8000 +models: +- model_id: default-model + provider_id: default-vllm + model_type: llm +vector_stores: + default_provider_id: faiss +` + + spec := &ogxiov1beta1.OGXServerSpec{ + Distribution: ogxiov1beta1.DistributionSpec{Name: "remote-vllm"}, + Providers: &ogxiov1beta1.ProvidersSpec{ + Inference: &ogxiov1beta1.InferenceProvidersSpec{ + Remote: &ogxiov1beta1.InferenceRemoteProviders{ + VLLM: []ogxiov1beta1.VLLMProvider{ + { + Endpoint: "https://my-vllm:8000", + }, + }, + }, + }, + }, + Resources: &ogxiov1beta1.ResourcesSpec{ + Models: []ogxiov1beta1.ModelConfig{ + {Name: "llama3.2-8b"}, + }, + }, + } + + generated, err := GenerateConfig(spec, []byte(baseConfig)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if generated.ConfigYAML == "" { + t.Error("expected non-empty ConfigYAML") + } + if generated.ContentHash == "" { + t.Error("expected non-empty ContentHash") + } + if generated.ProviderCount < 1 { + t.Errorf("expected at least 1 provider, got %d", generated.ProviderCount) + } + if generated.ResourceCount < 1 { + t.Errorf("expected at least 1 resource, got %d", generated.ResourceCount) + } + if generated.ConfigVersion != 2 { + t.Errorf("expected config version 2, got %d", generated.ConfigVersion) + } + if !strings.Contains(generated.ConfigYAML, "distro_name: remote-vllm") { + t.Errorf("expected generated config to preserve distro_name, got:\n%s", generated.ConfigYAML) + } + if !strings.Contains(generated.ConfigYAML, "registered_resources:") { + t.Errorf("expected generated config to use registered_resources, got:\n%s", generated.ConfigYAML) + } + if !strings.Contains(generated.ConfigYAML, "vector_stores:") { + t.Errorf("expected generated config to preserve unrelated top-level sections, got:\n%s", generated.ConfigYAML) + } +} + +func TestGenerateConfig_DisabledAPIs(t *testing.T) { + baseConfig := `version: '2' +apis: +- inference +- vector_io +- tool_runtime +providers: + inference: + - provider_id: vllm + provider_type: remote::vllm + config: {} +` + + spec := &ogxiov1beta1.OGXServerSpec{ + DisabledAPIs: []string{"vector_io", "tool_runtime"}, + } + + generated, err := GenerateConfig(spec, []byte(baseConfig)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if generated.ConfigYAML == "" { + t.Error("expected non-empty ConfigYAML") + } +} + +func TestGenerateConfig_DisabledAPIs_AllRemoved(t *testing.T) { + baseConfig := `version: '2' +apis: +- inference +` + + spec := &ogxiov1beta1.OGXServerSpec{ + DisabledAPIs: []string{"inference"}, + } + + generated, err := GenerateConfig(spec, []byte(baseConfig)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if strings.Contains(generated.ConfigYAML, "apis:") { + t.Errorf("expected apis section to be removed when all APIs are disabled, got:\n%s", generated.ConfigYAML) + } +} + +func TestGenerateConfig_ContentHashDeterministic(t *testing.T) { + baseConfig := `version: '2' +providers: + inference: + - provider_id: vllm + provider_type: remote::vllm + config: {} +` + spec := &ogxiov1beta1.OGXServerSpec{ + DisabledAPIs: []string{"vector_io"}, + } + + g1, err := GenerateConfig(spec, []byte(baseConfig)) + if err != nil { + t.Fatal(err) + } + g2, err := GenerateConfig(spec, []byte(baseConfig)) + if err != nil { + t.Fatal(err) + } + + if g1.ContentHash != g2.ContentHash { + t.Errorf("expected same hash for same input, got %q and %q", g1.ContentHash, g2.ContentHash) + } + if len(g1.ContentHash) != 16 { + t.Errorf("expected 16-char content hash, got %q", g1.ContentHash) + } +} + +func TestDefaultConfigResolver_EmptyImage(t *testing.T) { + resolver := NewDefaultConfigResolver(nil) + _, err := resolver.Resolve("", "starter") + if err == nil { + t.Error("expected error for empty image reference") + } +} + +func TestDefaultConfigResolver_NoFetcher(t *testing.T) { + resolver := NewDefaultConfigResolver(nil) + _, err := resolver.Resolve("starter:latest", "starter") + if err == nil { + t.Error("expected error when OCI fetcher is not configured") + } +} + +func TestDefaultConfigResolver_NoImageNoDistribution(t *testing.T) { + resolver := NewDefaultConfigResolver(nil) + _, err := resolver.Resolve("", "") + if err == nil { + t.Error("expected error when image is empty") + } +} + +func TestDefaultConfigResolver_OCILabelUsed(t *testing.T) { + expectedConfig := "version: '2'\nimage_name: from-oci\n" + fetcher := func(imageRef string) (map[string]string, error) { + return map[string]string{ + OCIDefaultConfigLabel: "config.yaml", + OCIConfigLabelPrefix + "config.yaml": "dmVyc2lvbjogJzInCmltYWdlX25hbWU6IGZyb20tb2NpCg==", // base64 of expectedConfig + }, nil + } + + resolver := NewDefaultConfigResolver(fetcher) + data, err := resolver.Resolve("my-image:latest", "starter") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(data) != expectedConfig { + t.Errorf("expected OCI config %q, got %q", expectedConfig, string(data)) + } +} + +func TestDefaultConfigResolver_OCIFetchFails(t *testing.T) { + fetcher := func(imageRef string) (map[string]string, error) { + return nil, errors.New("failed to connect: network error") + } + + resolver := NewDefaultConfigResolver(fetcher) + _, err := resolver.Resolve("unreachable-image:v1", "starter") + if err != nil { + if !strings.Contains(err.Error(), "failed to connect") { + t.Fatalf("expected OCI fetch failure, got: %v", err) + } + return + } + t.Fatal("expected error when OCI fetch fails") +} + +func TestDefaultConfigResolver_OCILabelMissing(t *testing.T) { + fetcher := func(imageRef string) (map[string]string, error) { + return map[string]string{"unrelated-label": "value"}, nil + } + + resolver := NewDefaultConfigResolver(fetcher) + _, err := resolver.Resolve("my-image:latest", "starter") + if err == nil { + t.Fatal("expected error when OCI labels are missing") + } + if !strings.Contains(err.Error(), OCIDefaultConfigLabel) { + t.Fatalf("expected missing default-config label error, got %v", err) + } +} + +func TestMergeStorage(t *testing.T) { + base := map[string]interface{}{ + "backends": map[string]interface{}{ + "kv_default": map[string]interface{}{"type": "kv_sqlite"}, + }, + } + + user := map[string]interface{}{ + "backends": map[string]interface{}{ + "kv_default": map[string]interface{}{"type": "kv_redis", "host": "redis:6379"}, + }, + } + + result := MergeStorage(base, user) + backends, ok := result["backends"].(map[string]interface{}) + if !ok { + t.Fatal("expected backends to be a map") + } + kv, ok := backends["kv_default"].(map[string]interface{}) + if !ok { + t.Fatal("expected kv_default to be a map") + } + if kv["type"] != "kv_redis" { + t.Errorf("expected user storage to override, got %v", kv["type"]) + } +} + +func TestValidateSecretRefEnvVarNames_ValidWithNormalization(t *testing.T) { + spec := &ogxiov1beta1.OGXServerSpec{ + Providers: &ogxiov1beta1.ProvidersSpec{ + Inference: &ogxiov1beta1.InferenceProvidersSpec{ + Remote: &ogxiov1beta1.InferenceRemoteProviders{ + Custom: []ogxiov1beta1.CustomProvider{ + { + RoutedProviderBase: ogxiov1beta1.RoutedProviderBase{ID: "team.a/provider-1"}, + Type: "remote::foo", + SecretRefs: map[string]ogxiov1beta1.SecretKeyRef{ + "api.key": {Name: "s1", Key: "k1"}, + }, + }, + }, + }, + }, + }, + } + + if err := ValidateSecretRefEnvVarNames(spec); err != nil { + t.Fatalf("expected valid env names after normalization, got: %v", err) + } + + envVars := CollectSecretRefs(spec) + if len(envVars) != 1 { + t.Fatalf("expected one env var, got %d", len(envVars)) + } + if strings.Contains(envVars[0].Name, ".") || strings.Contains(envVars[0].Name, "/") || strings.Contains(envVars[0].Name, "-") { + t.Fatalf("expected normalized env var name, got %q", envVars[0].Name) + } +} + +func TestValidateSecretRefEnvVarNames_DetectsCollision(t *testing.T) { + spec := &ogxiov1beta1.OGXServerSpec{ + Providers: &ogxiov1beta1.ProvidersSpec{ + Inference: &ogxiov1beta1.InferenceProvidersSpec{ + Remote: &ogxiov1beta1.InferenceRemoteProviders{ + Custom: []ogxiov1beta1.CustomProvider{ + { + RoutedProviderBase: ogxiov1beta1.RoutedProviderBase{ID: "provider-a"}, + Type: "remote::foo", + SecretRefs: map[string]ogxiov1beta1.SecretKeyRef{ + "api-key": {Name: "secret-a", Key: "token"}, + }, + }, + { + RoutedProviderBase: ogxiov1beta1.RoutedProviderBase{ID: "provider_a"}, + Type: "remote::foo", + SecretRefs: map[string]ogxiov1beta1.SecretKeyRef{ + "api.key": {Name: "secret-b", Key: "token"}, + }, + }, + }, + }, + }, + }, + } + + if err := ValidateSecretRefEnvVarNames(spec); err == nil { + t.Fatal("expected collision validation error, got nil") + } +} + +func TestExpandCustomProvider_SecretRefSettingsConflict(t *testing.T) { + p := ogxiov1beta1.CustomProvider{ + RoutedProviderBase: ogxiov1beta1.RoutedProviderBase{ID: "my-provider"}, + Type: "remote::custom", + SecretRefs: map[string]ogxiov1beta1.SecretKeyRef{ + "api_key": {Name: "my-secret", Key: "key"}, + }, + Settings: &apiextensionsv1.JSON{ + Raw: []byte(`{"api_key":"plaintext-value"}`), + }, + } + + _, err := expandCustomProvider(p) + if err == nil { + t.Fatal("expected error for secretRefs/settings key conflict, got nil") + } + if !strings.Contains(err.Error(), "api_key") { + t.Errorf("expected error to mention conflicting key, got: %v", err) + } +} + +func TestExpandCustomProvider_NoConflict(t *testing.T) { + p := ogxiov1beta1.CustomProvider{ + RoutedProviderBase: ogxiov1beta1.RoutedProviderBase{ID: "my-provider"}, + Type: "remote::custom", + SecretRefs: map[string]ogxiov1beta1.SecretKeyRef{ + "api_key": {Name: "my-secret", Key: "key"}, + }, + Settings: &apiextensionsv1.JSON{ + Raw: []byte(`{"endpoint":"https://example.com"}`), + }, + } + + cp, err := expandCustomProvider(p) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cp.ProviderType != "remote::custom" { + t.Errorf("expected provider type 'remote::custom', got %q", cp.ProviderType) + } + if cp.Config["endpoint"] != "https://example.com" { + t.Errorf("expected endpoint in config, got %v", cp.Config["endpoint"]) + } +} + +func TestExpandProviders_VLLMWithCommonInferenceConfig(t *testing.T) { + refreshModels := true + providers := &ogxiov1beta1.ProvidersSpec{ + Inference: &ogxiov1beta1.InferenceProvidersSpec{ + Remote: &ogxiov1beta1.InferenceRemoteProviders{ + VLLM: []ogxiov1beta1.VLLMProvider{ + { + Endpoint: "https://vllm.example.com:8000", + RemoteInferenceCommonConfig: ogxiov1beta1.RemoteInferenceCommonConfig{ + RefreshModels: &refreshModels, + AllowedModels: []string{llama3ModelID, "mistral"}, + }, + }, + }, + }, + }, + } + + result, err := ExpandProviders(providers) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + p := result["inference"][0] + if p.Config["base_url"] != "https://vllm.example.com:8000" { + t.Errorf("expected base_url, got %v", p.Config["base_url"]) + } + if p.Config["refresh_models"] != true { + t.Errorf("expected refresh_models true, got %v", p.Config["refresh_models"]) + } + allowedModels, ok := p.Config["allowed_models"].([]string) + if !ok || len(allowedModels) != 2 || allowedModels[0] != llama3ModelID { + t.Errorf("expected allowed_models [%s mistral], got %v", llama3ModelID, p.Config["allowed_models"]) + } +} + +func TestExpandProviders_AzureBaseURL(t *testing.T) { + providers := &ogxiov1beta1.ProvidersSpec{ + Inference: &ogxiov1beta1.InferenceProvidersSpec{ + Remote: &ogxiov1beta1.InferenceRemoteProviders{ + Azure: []ogxiov1beta1.AzureProvider{ + { + Endpoint: "https://my-resource.openai.azure.com/openai/v1", + APIKey: ogxiov1beta1.SecretKeyRef{Name: "azure-secret", Key: "key"}, + }, + }, + }, + }, + } + + result, err := ExpandProviders(providers) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + p := result["inference"][0] + if p.Config["base_url"] != "https://my-resource.openai.azure.com/openai/v1" { + t.Errorf("expected base_url, got %v", p.Config["base_url"]) + } + if _, hasOldKey := p.Config["url"]; hasOldKey { + t.Error("config should not contain legacy 'url' key") + } +} + +func TestExpandProviders_BedrockRegionName(t *testing.T) { + providers := &ogxiov1beta1.ProvidersSpec{ + Inference: &ogxiov1beta1.InferenceProvidersSpec{ + Remote: &ogxiov1beta1.InferenceRemoteProviders{ + Bedrock: []ogxiov1beta1.BedrockProvider{ + { + Region: "us-east-1", + }, + }, + }, + }, + } + + result, err := ExpandProviders(providers) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + p := result["inference"][0] + if p.Config["region_name"] != "us-east-1" { + t.Errorf("expected region_name 'us-east-1', got %v", p.Config["region_name"]) + } + if _, hasOldKey := p.Config["region"]; hasOldKey { + t.Error("config should not contain legacy 'region' key") + } +} + +func TestExpandProviders_WatsonxBaseURL(t *testing.T) { + providers := &ogxiov1beta1.ProvidersSpec{ + Inference: &ogxiov1beta1.InferenceProvidersSpec{ + Remote: &ogxiov1beta1.InferenceRemoteProviders{ + Watsonx: []ogxiov1beta1.WatsonxProvider{ + { + Endpoint: "https://watsonx.example.com", + APIKey: ogxiov1beta1.SecretKeyRef{Name: "wx-secret", Key: "key"}, + }, + }, + }, + }, + } + + result, err := ExpandProviders(providers) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + p := result["inference"][0] + if p.Config["base_url"] != "https://watsonx.example.com" { + t.Errorf("expected base_url, got %v", p.Config["base_url"]) + } + if _, hasOldKey := p.Config["url"]; hasOldKey { + t.Error("config should not contain legacy 'url' key") + } +} + +func TestExpandProviders_NetworkConfigNested(t *testing.T) { + verify := false + connectTimeout := 30 + providers := &ogxiov1beta1.ProvidersSpec{ + Inference: &ogxiov1beta1.InferenceProvidersSpec{ + Remote: &ogxiov1beta1.InferenceRemoteProviders{ + VLLM: []ogxiov1beta1.VLLMProvider{ + { + Endpoint: "https://vllm.example.com:8000", + RemoteInferenceCommonConfig: ogxiov1beta1.RemoteInferenceCommonConfig{ + Network: &ogxiov1beta1.NetworkConfig{ + TLS: &ogxiov1beta1.TLSConfig{ + Verify: &verify, + }, + Timeout: &ogxiov1beta1.TimeoutConfig{ + Connect: &connectTimeout, + }, + Headers: map[string]string{ + "X-Custom": "value", + }, + }, + }, + }, + }, + }, + }, + } + + result, err := ExpandProviders(providers) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + p := requireProviderAt(t, result, "inference", 0) + network := requireConfigMap(t, p.Config, "network") + assertConfigValue(t, requireConfigMap(t, network, "tls"), "verify", false) + assertConfigValue(t, requireConfigMap(t, network, "timeout"), "connect", 30) + + headers := requireStringMap(t, network, "headers") + if headers["X-Custom"] != "value" { + t.Errorf("expected X-Custom header, got %v", headers["X-Custom"]) + } + + assertConfigAbsent(t, p.Config, "tls") + assertConfigAbsent(t, p.Config, "timeout") + assertConfigAbsent(t, p.Config, "headers") +} + +func TestExpandProviders_NetworkConfigWithProxy(t *testing.T) { + proxyURL := "http://proxy.example.com:8080" + proxyHTTPS := "http://proxy.example.com:8443" + caCert := "/etc/ssl/proxy-ca.crt" + providers := &ogxiov1beta1.ProvidersSpec{ + Inference: &ogxiov1beta1.InferenceProvidersSpec{ + Remote: &ogxiov1beta1.InferenceRemoteProviders{ + VLLM: []ogxiov1beta1.VLLMProvider{ + { + Endpoint: "https://vllm.example.com:8000", + RemoteInferenceCommonConfig: ogxiov1beta1.RemoteInferenceCommonConfig{ + Network: &ogxiov1beta1.NetworkConfig{ + Proxy: &ogxiov1beta1.ProxyConfig{ + URL: &proxyURL, + HTTPS: &proxyHTTPS, + CACert: &caCert, + NoProxy: []string{"localhost", "10.0.0.0/8"}, + }, + }, + }, + }, + }, + }, + }, + } + + result, err := ExpandProviders(providers) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + p := result["inference"][0] + network, ok := p.Config["network"].(map[string]interface{}) + if !ok { + t.Fatalf("expected nested 'network' map, got %v", p.Config["network"]) + } + proxy, ok := network["proxy"].(map[string]interface{}) + if !ok { + t.Fatalf("expected proxy map inside network, got %v", network["proxy"]) + } + if proxy["url"] != proxyURL { + t.Errorf("expected proxy url %q, got %v", proxyURL, proxy["url"]) + } + if proxy["https"] != proxyHTTPS { + t.Errorf("expected proxy https %q, got %v", proxyHTTPS, proxy["https"]) + } + if proxy["ca_cert"] != caCert { + t.Errorf("expected proxy ca_cert %q, got %v", caCert, proxy["ca_cert"]) + } + noProxy, ok := proxy["no_proxy"].([]string) + if !ok || len(noProxy) != 2 { + t.Errorf("expected 2 no_proxy entries, got %v", proxy["no_proxy"]) + } +} + +func TestExpandProviders_NetworkConfigNilIsOmitted(t *testing.T) { + providers := &ogxiov1beta1.ProvidersSpec{ + Inference: &ogxiov1beta1.InferenceProvidersSpec{ + Remote: &ogxiov1beta1.InferenceRemoteProviders{ + VLLM: []ogxiov1beta1.VLLMProvider{ + { + Endpoint: "https://vllm.example.com:8000", + }, + }, + }, + }, + } + + result, err := ExpandProviders(providers) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + p := result["inference"][0] + if _, hasNetwork := p.Config["network"]; hasNetwork { + t.Error("expected no 'network' key when network config is nil") + } +} + +func TestExpandProviders_QdrantAllFields(t *testing.T) { + port := 6333 + grpcPort := 6334 + preferGRPC := true + https := true + timeout := 30 + providers := &ogxiov1beta1.ProvidersSpec{ + VectorIo: &ogxiov1beta1.VectorIOProvidersSpec{ + Remote: &ogxiov1beta1.VectorIORemoteProviders{ + Qdrant: []ogxiov1beta1.QdrantProvider{ + { + URL: "http://qdrant:6333", + Host: "qdrant.example.com", + Port: &port, + APIKey: &ogxiov1beta1.SecretKeyRef{Name: "qdrant-secret", Key: "api-key"}, + Location: "us-east-1", + GRPCPort: &grpcPort, + PreferGRPC: &preferGRPC, + HTTPS: &https, + Prefix: "/v1", + Timeout: &timeout, + }, + }, + }, + }, + } + + result, err := ExpandProviders(providers) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + p := requireProviderAt(t, result, "vector_io", 0) + assertProviderIdentity(t, p, "remote-qdrant", "remote::qdrant") + assertConfigValues(t, p.Config, map[string]interface{}{ + "url": "http://qdrant:6333", + "host": "qdrant.example.com", + "port": 6333, + "location": "us-east-1", + "grpc_port": 6334, + "prefer_grpc": true, + "https": true, + "prefix": "/v1", + "timeout": 30, + }) +} + +func TestExpandProviders_QdrantMinimalWithHost(t *testing.T) { + providers := &ogxiov1beta1.ProvidersSpec{ + VectorIo: &ogxiov1beta1.VectorIOProvidersSpec{ + Remote: &ogxiov1beta1.VectorIORemoteProviders{ + Qdrant: []ogxiov1beta1.QdrantProvider{ + { + Host: "qdrant.example.com", + }, + }, + }, + }, + } + + result, err := ExpandProviders(providers) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + p := result["vector_io"][0] + if p.Config["host"] != "qdrant.example.com" { + t.Errorf("expected host, got %v", p.Config["host"]) + } + if _, hasURL := p.Config["url"]; hasURL { + t.Error("url should not be set when only host is provided") + } +} + +func TestExpandProviders_PgvectorWithVectorIndex(t *testing.T) { + m := 16 + efConstruction := 200 + efSearch := 100 + providers := &ogxiov1beta1.ProvidersSpec{ + VectorIo: &ogxiov1beta1.VectorIOProvidersSpec{ + Remote: &ogxiov1beta1.VectorIORemoteProviders{ + Pgvector: []ogxiov1beta1.PgvectorProvider{ + { + Host: "pg.example.com", + Password: ogxiov1beta1.SecretKeyRef{Name: "pg-secret", Key: "password"}, + DistanceMetric: "COSINE", + VectorIndex: &ogxiov1beta1.VectorIndexConfig{ + HNSW: &ogxiov1beta1.HNSWConfig{ + M: &m, + EfConstruction: &efConstruction, + EfSearch: &efSearch, + }, + }, + }, + }, + }, + }, + } + + result, err := ExpandProviders(providers) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + p := result["vector_io"][0] + if p.ProviderType != "remote::pgvector" { + t.Errorf("expected provider_type 'remote::pgvector', got %q", p.ProviderType) + } + if p.Config["distance_metric"] != "COSINE" { + t.Errorf("expected distance_metric 'COSINE', got %v", p.Config["distance_metric"]) + } + + vi, ok := p.Config["vector_index"].(map[string]interface{}) + if !ok { + t.Fatalf("expected vector_index map, got %v", p.Config["vector_index"]) + } + hnsw, ok := vi["hnsw"].(map[string]interface{}) + if !ok { + t.Fatalf("expected hnsw map, got %v", vi["hnsw"]) + } + if hnsw["m"] != 16 { + t.Errorf("expected hnsw m=16, got %v", hnsw["m"]) + } + if hnsw["ef_construction"] != 200 { + t.Errorf("expected hnsw ef_construction=200, got %v", hnsw["ef_construction"]) + } + if hnsw["ef_search"] != 100 { + t.Errorf("expected hnsw ef_search=100, got %v", hnsw["ef_search"]) + } +} + +func TestExpandProviders_PgvectorWithIVFFlat(t *testing.T) { + nlist := 128 + nprobe := 10 + providers := &ogxiov1beta1.ProvidersSpec{ + VectorIo: &ogxiov1beta1.VectorIOProvidersSpec{ + Remote: &ogxiov1beta1.VectorIORemoteProviders{ + Pgvector: []ogxiov1beta1.PgvectorProvider{ + { + Password: ogxiov1beta1.SecretKeyRef{Name: "pg-secret", Key: "password"}, + VectorIndex: &ogxiov1beta1.VectorIndexConfig{ + IVFFlat: &ogxiov1beta1.IVFFlatConfig{ + Nlist: &nlist, + Nprobe: &nprobe, + }, + }, + }, + }, + }, + }, + } + + result, err := ExpandProviders(providers) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + vi, ok := result["vector_io"][0].Config["vector_index"].(map[string]interface{}) + if !ok { + t.Fatalf("expected vector_index map, got %v", result["vector_io"][0].Config["vector_index"]) + } + ivf, ok := vi["ivf_flat"].(map[string]interface{}) + if !ok { + t.Fatalf("expected ivf_flat map, got %v", vi["ivf_flat"]) + } + if ivf["nlist"] != 128 { + t.Errorf("expected nlist=128, got %v", ivf["nlist"]) + } + if ivf["nprobe"] != 10 { + t.Errorf("expected nprobe=10, got %v", ivf["nprobe"]) + } +} + +func TestExpandProviders_MilvusWithConsistencyLevel(t *testing.T) { + providers := &ogxiov1beta1.ProvidersSpec{ + VectorIo: &ogxiov1beta1.VectorIOProvidersSpec{ + Remote: &ogxiov1beta1.VectorIORemoteProviders{ + Milvus: []ogxiov1beta1.MilvusProvider{ + { + URI: "http://milvus:19530", + ConsistencyLevel: "Strong", + }, + }, + }, + }, + } + + result, err := ExpandProviders(providers) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + p := result["vector_io"][0] + if p.ProviderType != "remote::milvus" { + t.Errorf("expected provider_type 'remote::milvus', got %q", p.ProviderType) + } + if p.Config["consistency_level"] != "Strong" { + t.Errorf("expected consistency_level 'Strong', got %v", p.Config["consistency_level"]) + } +} + +func TestExpandProviders_BatchReference(t *testing.T) { + maxBatches := 5 + maxRequestsPerBatch := 100 + providers := &ogxiov1beta1.ProvidersSpec{ + Batches: &ogxiov1beta1.BatchesProvidersSpec{ + Inline: &ogxiov1beta1.BatchesInlineProviders{ + Reference: &ogxiov1beta1.InlineReferenceProvider{ + MaxConcurrentBatches: &maxBatches, + MaxConcurrentRequestsPerBatch: &maxRequestsPerBatch, + }, + }, + }, + } + + result, err := ExpandProviders(providers) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + p := result["batches"][0] + if p.ProviderType != "inline::batch-reference" { + t.Errorf("expected provider_type 'inline::batch-reference', got %q", p.ProviderType) + } + if p.Config["max_concurrent_batches"] != 5 { + t.Errorf("expected max_concurrent_batches=5, got %v", p.Config["max_concurrent_batches"]) + } + if p.Config["max_concurrent_requests_per_batch"] != 100 { + t.Errorf("expected max_concurrent_requests_per_batch=100, got %v", p.Config["max_concurrent_requests_per_batch"]) + } +} + +func TestExpandProviders_BatchReferenceEmpty(t *testing.T) { + providers := &ogxiov1beta1.ProvidersSpec{ + Batches: &ogxiov1beta1.BatchesProvidersSpec{ + Inline: &ogxiov1beta1.BatchesInlineProviders{ + Reference: &ogxiov1beta1.InlineReferenceProvider{}, + }, + }, + } + + result, err := ExpandProviders(providers) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + p := result["batches"][0] + if len(p.Config) != 0 { + t.Errorf("expected empty config for default reference provider, got %v", p.Config) + } +} + +func TestExpandProviders_InlineFileSearchWithVectorStoresConfig(t *testing.T) { + embeddingDim := 768 + providers := &ogxiov1beta1.ProvidersSpec{ + ToolRuntime: &ogxiov1beta1.ToolRuntimeProvidersSpec{ + Inline: &ogxiov1beta1.ToolRuntimeInlineProviders{ + FileSearch: []ogxiov1beta1.InlineFileSearchProvider{ + { + VectorStoresConfig: &ogxiov1beta1.VectorStoresConfig{ + DefaultProviderID: "pgvector-1", + DefaultEmbeddingModel: &ogxiov1beta1.QualifiedModel{ + ProviderID: "embed-provider", + ModelID: "all-MiniLM-L6-v2", + EmbeddingDimensions: &embeddingDim, + }, + }, + }, + }, + }, + }, + } + + result, err := ExpandProviders(providers) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + p := result["tool_runtime"][0] + if p.ProviderType != "inline::rag-runtime" { + t.Errorf("expected provider_type 'inline::rag-runtime', got %q", p.ProviderType) + } + vs, ok := p.Config["vector_stores_config"].(map[string]interface{}) + if !ok { + t.Fatalf("expected vector_stores_config map, got %v", p.Config["vector_stores_config"]) + } + if vs["default_provider_id"] != "pgvector-1" { + t.Errorf("expected default_provider_id, got %v", vs["default_provider_id"]) + } + emb, ok := vs["default_embedding_model"].(map[string]interface{}) + if !ok { + t.Fatalf("expected default_embedding_model map, got %v", vs["default_embedding_model"]) + } + if emb["provider_id"] != "embed-provider" { + t.Errorf("expected provider_id 'embed-provider', got %v", emb["provider_id"]) + } + if emb["model_id"] != "all-MiniLM-L6-v2" { + t.Errorf("expected model_id, got %v", emb["model_id"]) + } + if emb["embedding_dimensions"] != 768 { + t.Errorf("expected embedding_dimensions 768, got %v", emb["embedding_dimensions"]) + } +} + +func TestExpandProviders_BuiltinResponsesWithVectorStoresAndCompaction(t *testing.T) { + compactThreshold := 4096 + chunkSize := 512 + chunkOverlap := 50 + maxTokens := 256 + temperature := "0.7" + enableAnnotations := true + chunkMultiplier := 3 + maxTokensInContext := 2048 + rerankerStrategy := "rrf" + rrfFactor := "60.0" + searchAlpha := "0.5" + searchMode := "hybrid" + maxFilesPerBatch := 10 + batchChunkSize := 5 + cleanupInterval := 3600 + contextTimeout := 30 + contextConcurrency := 4 + maxDocTokens := 8000 + + providers := &ogxiov1beta1.ProvidersSpec{ + Responses: &ogxiov1beta1.ResponsesProvidersSpec{ + Inline: &ogxiov1beta1.ResponsesInlineProviders{ + Builtin: &ogxiov1beta1.InlineBuiltinResponsesProvider{ + VectorStoresConfig: &ogxiov1beta1.VectorStoresConfig{ + DefaultProviderID: "qdrant-1", + DefaultRerankerModel: &ogxiov1beta1.RerankerModel{ + ProviderID: "reranker-provider", + ModelID: "bge-reranker-v2", + }, + RewriteQueryParams: &ogxiov1beta1.RewriteQueryParams{ + Model: &ogxiov1beta1.QualifiedModel{ + ProviderID: "llm-provider", + ModelID: llama3ModelID, + }, + Prompt: "Rewrite: {query}", + MaxTokens: &maxTokens, + Temperature: &temperature, + }, + FileSearchParams: &ogxiov1beta1.FileSearchDisplayParams{ + HeaderTemplate: "=== Results ===", + FooterTemplate: "=== End ===", + }, + ContextPromptParams: &ogxiov1beta1.ContextPromptParams{ + ChunkAnnotationTemplate: "Chunk: {{content}}", + ContextTemplate: "Context: {{chunks}}", + }, + AnnotationPromptParams: &ogxiov1beta1.AnnotationPromptParams{ + EnableAnnotations: &enableAnnotations, + AnnotationInstructionTemplate: "Cite sources", + ChunkAnnotationTemplate: "Source: {{source}}", + }, + FileIngestionParams: &ogxiov1beta1.FileIngestionParams{ + DefaultChunkSizeTokens: &chunkSize, + DefaultChunkOverlapTokens: &chunkOverlap, + }, + ChunkRetrievalParams: &ogxiov1beta1.ChunkRetrievalParams{ + ChunkMultiplier: &chunkMultiplier, + MaxTokensInContext: &maxTokensInContext, + DefaultRerankerStrategy: &rerankerStrategy, + RRFImpactFactor: &rrfFactor, + WeightedSearchAlpha: &searchAlpha, + DefaultSearchMode: &searchMode, + }, + FileBatchParams: &ogxiov1beta1.FileBatchParams{ + MaxConcurrentFilesPerBatch: &maxFilesPerBatch, + FileBatchChunkSize: &batchChunkSize, + CleanupIntervalSeconds: &cleanupInterval, + }, + ContextualRetrievalParams: &ogxiov1beta1.ContextualRetrievalParams{ + Model: &ogxiov1beta1.QualifiedModel{ + ProviderID: "ctx-provider", + ModelID: "ctx-model", + }, + DefaultTimeoutSeconds: &contextTimeout, + DefaultMaxConcurrency: &contextConcurrency, + MaxDocumentTokens: &maxDocTokens, + }, + }, + CompactionConfig: &ogxiov1beta1.CompactionConfig{ + SummarizationPrompt: "Summarize this conversation", + SummaryPrefix: "Previous context: ", + SummarizationModel: "llama3-8b", + DefaultCompactThreshold: &compactThreshold, + TokenizerEncoding: "cl100k_base", + }, + }, + }, + }, + } + + result, err := ExpandProviders(providers) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + p := requireProviderAt(t, result, "responses", 0) + assertProviderIdentity(t, p, "inline-builtin", "inline::responses") + + vs := requireConfigMap(t, p.Config, "vector_stores_config") + assertConfigValue(t, vs, "default_provider_id", "qdrant-1") + assertConfigValues(t, requireConfigMap(t, vs, "default_reranker_model"), map[string]interface{}{ + "provider_id": "reranker-provider", + "model_id": "bge-reranker-v2", + }) + assertConfigValues(t, requireConfigMap(t, vs, "rewrite_query_params"), map[string]interface{}{ + "prompt": "Rewrite: {query}", + "max_tokens": 256, + "temperature": "0.7", + }) + assertConfigValues(t, requireConfigMap(t, vs, "file_search_params"), map[string]interface{}{ + "header_template": "=== Results ===", + "footer_template": "=== End ===", + }) + assertConfigValues(t, requireConfigMap(t, vs, "context_prompt_params"), map[string]interface{}{ + "chunk_annotation_template": "Chunk: {{content}}", + "context_template": "Context: {{chunks}}", + }) + assertConfigValue(t, requireConfigMap(t, vs, "annotation_prompt_params"), "enable_annotations", true) + assertConfigValues(t, requireConfigMap(t, vs, "file_ingestion_params"), map[string]interface{}{ + "default_chunk_size_tokens": 512, + "default_chunk_overlap_tokens": 50, + }) + assertConfigValues(t, requireConfigMap(t, vs, "chunk_retrieval_params"), map[string]interface{}{ + "chunk_multiplier": 3, + "default_reranker_strategy": "rrf", + "default_search_mode": "hybrid", + }) + assertConfigValue(t, requireConfigMap(t, vs, "file_batch_params"), "max_concurrent_files_per_batch", 10) + assertConfigValues(t, requireConfigMap(t, vs, "contextual_retrieval_params"), map[string]interface{}{ + "default_timeout_seconds": 30, + "max_document_tokens": 8000, + }) + + assertConfigValues(t, requireConfigMap(t, p.Config, "compaction_config"), map[string]interface{}{ + "summarization_prompt": "Summarize this conversation", + "summary_prefix": "Previous context: ", + "summarization_model": "llama3-8b", + "default_compact_threshold": 4096, + "tokenizer_encoding": "cl100k_base", + }) +} + +func TestExpandProviders_BuiltinResponsesEmpty(t *testing.T) { + providers := &ogxiov1beta1.ProvidersSpec{ + Responses: &ogxiov1beta1.ResponsesProvidersSpec{ + Inline: &ogxiov1beta1.ResponsesInlineProviders{ + Builtin: &ogxiov1beta1.InlineBuiltinResponsesProvider{}, + }, + }, + } + + result, err := ExpandProviders(providers) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + p := result["responses"][0] + if len(p.Config) != 0 { + t.Errorf("expected empty config for default builtin responses, got %v", p.Config) + } +} + +func TestExpandResources_WithQuantization(t *testing.T) { + providers := map[string][]ConfigProvider{ + "inference": { + {ProviderID: "vllm-1", ProviderType: "remote::vllm"}, + }, + } + + resources := &ogxiov1beta1.ResourcesSpec{ + Models: []ogxiov1beta1.ModelConfig{ + { + Name: "llama3-8b-int8", + Provider: "vllm-1", + Quantization: "int8", + }, + }, + } + + models, err := ExpandResources(resources, providers) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(models) != 1 { + t.Fatalf("expected 1 model, got %d", len(models)) + } + if models[0].Quantization != "int8" { + t.Errorf("expected quantization 'int8', got %q", models[0].Quantization) + } +} + +func TestGenerateConfig_ModelQuantizationInYAML(t *testing.T) { + baseConfig := `version: '2' +providers: + inference: + - provider_id: vllm + provider_type: remote::vllm + config: {} +` + + spec := &ogxiov1beta1.OGXServerSpec{ + Providers: &ogxiov1beta1.ProvidersSpec{ + Inference: &ogxiov1beta1.InferenceProvidersSpec{ + Remote: &ogxiov1beta1.InferenceRemoteProviders{ + VLLM: []ogxiov1beta1.VLLMProvider{ + {Endpoint: "https://vllm:8000"}, + }, + }, + }, + }, + Resources: &ogxiov1beta1.ResourcesSpec{ + Models: []ogxiov1beta1.ModelConfig{ + { + Name: "llama3-8b-int4", + Quantization: "int4", + }, + }, + }, + } + + generated, err := GenerateConfig(spec, []byte(baseConfig)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.Contains(generated.ConfigYAML, "quantization: int4") { + t.Errorf("expected quantization in generated YAML, got:\n%s", generated.ConfigYAML) + } +} diff --git a/pkg/config/generator.go b/pkg/config/generator.go new file mode 100644 index 000000000..8b9f9f7cf --- /dev/null +++ b/pkg/config/generator.go @@ -0,0 +1,220 @@ +/* +Copyright 2025. + +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. +*/ + +package config + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "strconv" + + ogxiov1beta1 "github.com/ogx-ai/ogx-k8s-operator/api/v1beta1" + "gopkg.in/yaml.v3" +) + +// GenerateConfig orchestrates the config generation pipeline. +// It takes the OGXServer spec and resolved base config, producing a +// complete config.yaml with all provider/resource/storage expansions applied. +func GenerateConfig(spec *ogxiov1beta1.OGXServerSpec, baseConfigData []byte) (*GeneratedConfig, error) { + // Parse base config + baseConfig, err := ParseBaseConfig(baseConfigData) + if err != nil { + return nil, fmt.Errorf("failed to parse base config: %w", err) + } + + // Expand user providers from typed spec + userProviders, err := ExpandProviders(spec.Providers) + if err != nil { + return nil, fmt.Errorf("failed to expand providers: %w", err) + } + + // Merge providers: user replaces base by API type + mergedProviders := MergeProviders(baseConfig.Providers, userProviders) + + // Expand resources (models) + userModels, err := ExpandResources(spec.Resources, mergedProviders) + if err != nil { + return nil, fmt.Errorf("failed to expand resources: %w", err) + } + + // Apply storage configuration + userStorage := ApplyStorage(spec.Storage) + mergedStorage := MergeStorage(baseConfig.Storage, userStorage) + + // Determine APIs (filter disabled) + mergedAPIs := MergeAPIs(baseConfig.APIs, spec.DisabledAPIs) + + // Build the final config.yaml structure + finalConfig := buildFinalConfig(baseConfig, mergedProviders, userModels, mergedStorage, mergedAPIs, spec) + + // Serialize to YAML + configYAML, err := yaml.Marshal(finalConfig) + if err != nil { + return nil, fmt.Errorf("failed to serialize config: %w", err) + } + + // Compute content hash + hash := sha256.Sum256(configYAML) + contentHash := hex.EncodeToString(hash[:8]) + + // Collect secret references for env vars + envVars := CollectSecretRefs(spec) + + // Count providers and resources + providerCount := countProviders(mergedProviders) + resourceCount := len(userModels) + + // Parse config version — defaulted indicates a non-numeric version string + configVersion, configVersionParsed := parseConfigVersion(baseConfig.Version) + + return &GeneratedConfig{ + ConfigYAML: string(configYAML), + ContentHash: contentHash, + EnvVars: envVars, + ProviderCount: providerCount, + ResourceCount: resourceCount, + ConfigVersion: configVersion, + ConfigVersionDefaulted: !configVersionParsed, + }, nil +} + +func buildFinalConfig( + base *BaseConfig, + providers map[string][]ConfigProvider, + models []ConfigModel, + storage map[string]interface{}, + apis []string, + spec *ogxiov1beta1.OGXServerSpec, +) map[string]interface{} { + cfg := make(map[string]interface{}) + + cfg["version"] = base.Version + switch { + case spec.Distribution.Name != "": + cfg["distro_name"] = spec.Distribution.Name + case spec.Distribution.Image != "": + cfg["distro_name"] = spec.Distribution.Image + } + if len(apis) > 0 { + cfg["apis"] = apis + } + if len(providers) > 0 { + cfg["providers"] = serializeProviders(providers) + } + cfg["registered_resources"] = buildRegisteredResources(base, models) + if base.VectorStores != nil { + cfg["vector_stores"] = base.VectorStores + } + cfg["server"] = buildServerSection(base, spec) + buildStorageSection(cfg, storage, base) + + return cfg +} + +func serializeProviders(providers map[string][]ConfigProvider) map[string]interface{} { + section := make(map[string]interface{}, len(providers)) + for apiType, ps := range providers { + list := make([]interface{}, 0, len(ps)) + for _, p := range ps { + entry := map[string]interface{}{ + "provider_id": p.ProviderID, + "provider_type": p.ProviderType, + } + if p.Config != nil { + entry["config"] = p.Config + } else { + entry["config"] = map[string]interface{}{} + } + list = append(list, entry) + } + section[apiType] = list + } + return section +} + +func serializeModels(models []ConfigModel) []interface{} { + list := make([]interface{}, 0, len(models)) + for _, m := range models { + entry := map[string]interface{}{ + "model_id": m.ModelID, + "provider_id": m.ProviderID, + } + if m.ModelType != "" { + entry["model_type"] = m.ModelType + } + if m.ContextLength != nil { + entry["context_length"] = *m.ContextLength + } + if m.Quantization != "" { + entry["quantization"] = m.Quantization + } + list = append(list, entry) + } + return list +} + +func buildRegisteredResources(base *BaseConfig, userModels []ConfigModel) *RegisteredResources { + rr := &RegisteredResources{} + if base.RegisteredResources != nil { + rr.Models = base.RegisteredResources.Models + rr.VectorStores = base.RegisteredResources.VectorStores + } + if len(userModels) > 0 { + rr.Models = serializeModels(userModels) + } + return rr +} + +func buildServerSection(base *BaseConfig, spec *ogxiov1beta1.OGXServerSpec) map[string]interface{} { + server := make(map[string]interface{}) + for k, v := range base.Server { + server[k] = v + } + if spec.Network != nil && spec.Network.Port != 0 { + server["port"] = spec.Network.Port + } else if _, hasPort := server["port"]; !hasPort { + server["port"] = ogxiov1beta1.DefaultServerPort + } + return server +} + +func buildStorageSection(cfg map[string]interface{}, storage map[string]interface{}, base *BaseConfig) { + if storage != nil { + cfg["storage"] = storage + } else if base.Storage != nil { + cfg["storage"] = base.Storage + } +} + +func countProviders(providers map[string][]ConfigProvider) int { + count := 0 + for _, ps := range providers { + count += len(ps) + } + return count +} + +// parseConfigVersion attempts to parse the version string as an integer. +// Returns the parsed version and true on success, or (2, false) when the +// version is non-numeric so callers can log a warning about the default. +func parseConfigVersion(version string) (int, bool) { + v, err := strconv.Atoi(version) + if err != nil { + return 2, false + } + return v, true +} diff --git a/pkg/config/merge.go b/pkg/config/merge.go new file mode 100644 index 000000000..e1450b18b --- /dev/null +++ b/pkg/config/merge.go @@ -0,0 +1,88 @@ +/* +Copyright 2025. + +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. +*/ + +package config + +import ( + "errors" + "fmt" + + "gopkg.in/yaml.v3" +) + +// ParseBaseConfig parses the raw YAML into a BaseConfig struct. +func ParseBaseConfig(data []byte) (*BaseConfig, error) { + var cfg BaseConfig + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("failed to parse base config: %w", err) + } + if cfg.Version == "" { + return nil, errors.New("failed to validate base config: missing required 'version' field") + } + return &cfg, nil +} + +// MergeProviders merges user-configured providers over the base config. +// Strategy: replace entire API type block when user specifies it. +func MergeProviders(base map[string][]ConfigProvider, user map[string][]ConfigProvider) map[string][]ConfigProvider { + if user == nil { + return base + } + if base == nil { + return user + } + + merged := make(map[string][]ConfigProvider, len(base)) + for k, v := range base { + merged[k] = v + } + // User providers replace base providers by API type + for k, v := range user { + merged[k] = v + } + return merged +} + +// MergeStorage merges user storage config over base storage. +// Strategy: full replacement when user storage is specified. +func MergeStorage(base map[string]interface{}, user map[string]interface{}) map[string]interface{} { + if user == nil { + return base + } + if base == nil { + return user + } + // User storage replaces base storage entirely when specified + return user +} + +// MergeAPIs filters the API list based on disabled APIs. +func MergeAPIs(baseAPIs []string, disabledAPIs []string) []string { + if len(disabledAPIs) == 0 { + return baseAPIs + } + disabled := make(map[string]bool, len(disabledAPIs)) + for _, d := range disabledAPIs { + disabled[d] = true + } + var apis []string + for _, api := range baseAPIs { + if !disabled[api] { + apis = append(apis, api) + } + } + return apis +} diff --git a/pkg/config/oci_fetcher.go b/pkg/config/oci_fetcher.go new file mode 100644 index 000000000..3669999b7 --- /dev/null +++ b/pkg/config/oci_fetcher.go @@ -0,0 +1,170 @@ +/* +Copyright 2025. + +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. +*/ + +package config + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "time" + + "github.com/google/go-containerregistry/pkg/name" +) + +const ( + ociHTTPTimeout = 30 * time.Second + configBlobLimit = 1 << 20 // 1 MB +) + +var ociHTTPClient = &http.Client{Timeout: ociHTTPTimeout} + +// NewOCILabelFetcher returns an OCILabelFetcher that retrieves image labels +// from a container registry using the OCI Distribution HTTP API directly, +// avoiding heavy dependencies on docker/containerd client libraries. +// +// Authentication: currently supports anonymous/public registry access. +// Private registries will fall back to embedded configs via the resolver. +func NewOCILabelFetcher() OCILabelFetcher { + return fetchOCILabels +} + +func fetchOCILabels(imageRef string) (map[string]string, error) { + ref, err := name.ParseReference(imageRef) + if err != nil { + return nil, fmt.Errorf("failed to parse image reference %q: %w", imageRef, err) + } + + registry := ref.Context().RegistryStr() + repo := ref.Context().RepositoryStr() + identifier := ref.Identifier() + + base, err := detectRegistryScheme(registry) + if err != nil { + return nil, fmt.Errorf("failed to reach registry %q: %w", registry, err) + } + + configDigest, err := fetchManifestConfigDigest(base, repo, identifier, imageRef) + if err != nil { + return nil, err + } + if configDigest == "" { + return map[string]string{}, nil + } + + return fetchConfigLabels(base, repo, configDigest, imageRef) +} + +func fetchManifestConfigDigest(base, repo, identifier, imageRef string) (string, error) { + manifestURL := fmt.Sprintf("%s/v2/%s/manifests/%s", base, repo, identifier) + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, manifestURL, nil) + if err != nil { + return "", fmt.Errorf("failed to build manifest request: %w", err) + } + req.Header.Set("Accept", "application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json") + + resp, err := ociHTTPClient.Do(req) + if err != nil { + return "", fmt.Errorf("failed to fetch manifest for %q: %w", imageRef, err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("failed to fetch manifest for %q: HTTP %d", imageRef, resp.StatusCode) + } + + var manifest struct { + Config struct { + Digest string `json:"digest"` + } `json:"config"` + } + if decodeErr := json.NewDecoder(resp.Body).Decode(&manifest); decodeErr != nil { + return "", fmt.Errorf("failed to decode manifest for %q: %w", imageRef, decodeErr) + } + + return manifest.Config.Digest, nil +} + +func fetchConfigLabels(base, repo, digest, imageRef string) (map[string]string, error) { + blobURL := fmt.Sprintf("%s/v2/%s/blobs/%s", base, repo, digest) + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, blobURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to build config blob request: %w", err) + } + + resp, err := ociHTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch config blob for %q: %w", imageRef, err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to fetch config blob for %q: HTTP %d", imageRef, resp.StatusCode) + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, configBlobLimit)) + if err != nil { + return nil, fmt.Errorf("failed to read config blob for %q: %w", imageRef, err) + } + + var imgConfig struct { + Config struct { + Labels map[string]string `json:"Labels"` + } `json:"config"` + } + if err := json.Unmarshal(body, &imgConfig); err != nil { + return nil, fmt.Errorf("failed to decode config for %q: %w", imageRef, err) + } + + if imgConfig.Config.Labels == nil { + return map[string]string{}, nil + } + return imgConfig.Config.Labels, nil +} + +// detectRegistryScheme probes the registry's /v2/ endpoint over HTTPS first, +// falling back to HTTP if HTTPS fails. This mirrors Docker's behavior and +// avoids hardcoding hostname patterns for plain-HTTP registries. +func detectRegistryScheme(registry string) (string, error) { + httpsErr := false + for _, scheme := range []string{"https", "http"} { + probeURL := fmt.Sprintf("%s://%s/v2/", scheme, registry) + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, probeURL, nil) + if err != nil { + if scheme == "https" { + httpsErr = true + } + continue + } + resp, err := ociHTTPClient.Do(req) + if err != nil { + if scheme == "https" { + httpsErr = true + } + continue + } + _ = resp.Body.Close() + if scheme == "http" && httpsErr { + slog.Warn("HTTPS probe failed, falling back to HTTP for registry", "registry", registry) + } + return fmt.Sprintf("%s://%s", scheme, registry), nil + } + return "", errors.New("failed to connect on https or http") +} diff --git a/pkg/config/oci_fetcher_test.go b/pkg/config/oci_fetcher_test.go new file mode 100644 index 000000000..98299c8a8 --- /dev/null +++ b/pkg/config/oci_fetcher_test.go @@ -0,0 +1,299 @@ +/* +Copyright 2025. + +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. +*/ + +package config + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// testRegistry is a minimal in-process OCI registry that serves manifests and +// config blobs. It avoids depending on go-containerregistry's registry package. +type testRegistry struct { + // key: "repo:tag" -> manifest JSON bytes + manifests map[string][]byte + // key: digest -> config blob bytes + blobs map[string][]byte +} + +func newTestRegistry() *testRegistry { + return &testRegistry{ + manifests: make(map[string][]byte), + blobs: make(map[string][]byte), + } +} + +type ociImageConfig struct { + Config struct { + Labels map[string]string `json:"Labels"` + } `json:"config"` +} + +type ociManifest struct { + SchemaVersion int `json:"schemaVersion"` + MediaType string `json:"mediaType"` + Config struct { + MediaType string `json:"mediaType"` + Digest string `json:"digest"` + } `json:"config"` +} + +func (r *testRegistry) pushImage(repo, tag string, labels map[string]string) { + cfg := ociImageConfig{} + cfg.Config.Labels = labels + configBytes, _ := json.Marshal(cfg) //nolint:errchkjson // test-only typed struct + digest := fmt.Sprintf("sha256:%x", sha256.Sum256(configBytes)) + + r.blobs[digest] = configBytes + + m := ociManifest{ + SchemaVersion: 2, + MediaType: "application/vnd.oci.image.manifest.v1+json", + } + m.Config.MediaType = "application/vnd.oci.image.config.v1+json" + m.Config.Digest = digest + manifestBytes, _ := json.Marshal(m) //nolint:errchkjson // test-only typed struct + r.manifests[repo+":"+tag] = manifestBytes +} + +func (r *testRegistry) ServeHTTP(w http.ResponseWriter, req *http.Request) { + path := strings.TrimPrefix(req.URL.Path, "/v2/") + + // Handle manifest requests: /manifests/ + if parts := strings.SplitN(path, "/manifests/", 2); len(parts) == 2 { + repo, tag := parts[0], parts[1] + if data, ok := r.manifests[repo+":"+tag]; ok { + w.Header().Set("Content-Type", "application/vnd.oci.image.manifest.v1+json") + w.Write(data) + return + } + http.NotFound(w, req) + return + } + + // Handle blob requests: /blobs/ + if parts := strings.SplitN(path, "/blobs/", 2); len(parts) == 2 { + digest := parts[1] + if data, ok := r.blobs[digest]; ok { + w.Header().Set("Content-Type", "application/octet-stream") + w.Write(data) + return + } + http.NotFound(w, req) + return + } + + http.NotFound(w, req) +} + +func setupTestRegistryWithImages(t *testing.T) (*testRegistry, string) { + t.Helper() + reg := newTestRegistry() + s := httptest.NewServer(reg) + t.Cleanup(s.Close) + host := strings.TrimPrefix(s.URL, "http://") + return reg, host +} + +func TestFetchOCILabels_WithConfigLabel(t *testing.T) { + reg, host := setupTestRegistryWithImages(t) + + configYAML := "version: '2'\nimage_name: test-distro\n" + encoded := base64.StdEncoding.EncodeToString([]byte(configYAML)) + + reg.pushImage("distro/test", "v1", map[string]string{ + OCIDefaultConfigLabel: "config.yaml", + OCIConfigListLabel: "config.yaml", + OCIConfigLabelPrefix + "config.yaml": encoded, + "unrelated": "value", + }) + + labels, err := fetchOCILabels(fmt.Sprintf("%s/distro/test:v1", host)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if labels[OCIConfigLabelPrefix+"config.yaml"] != encoded { + t.Errorf("expected label %q = %q, got %q", OCIConfigLabelPrefix+"config.yaml", encoded, labels[OCIConfigLabelPrefix+"config.yaml"]) + } +} + +func TestFetchOCILabels_NoLabels(t *testing.T) { + reg, host := setupTestRegistryWithImages(t) + + reg.pushImage("distro/empty", "v1", nil) + + labels, err := fetchOCILabels(fmt.Sprintf("%s/distro/empty:v1", host)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(labels) != 0 { + t.Errorf("expected empty labels, got %v", labels) + } +} + +func TestFetchOCILabels_InvalidRef(t *testing.T) { + _, err := fetchOCILabels(":::invalid") + if err == nil { + t.Fatal("expected error for invalid image reference") + } +} + +func TestFetchOCILabels_Unreachable(t *testing.T) { + _, err := fetchOCILabels("localhost:1/nonexistent:v1") + if err == nil { + t.Fatal("expected error for unreachable registry") + } +} + +func TestResolverWithRealOCIFetch(t *testing.T) { + reg, host := setupTestRegistryWithImages(t) + + configYAML := "version: '2'\nimage_name: oci-resolved\napis:\n- inference\nproviders:\n inference:\n - provider_id: test\n provider_type: remote::test\n config: {}\n" + encoded := base64.StdEncoding.EncodeToString([]byte(configYAML)) + + reg.pushImage("distro/starter", "latest", map[string]string{ + OCIDefaultConfigLabel: "config.yaml", + OCIConfigListLabel: "config.yaml", + OCIConfigLabelPrefix + "config.yaml": encoded, + }) + + resolver := NewDefaultConfigResolver(fetchOCILabels) + data, err := resolver.Resolve(fmt.Sprintf("%s/distro/starter:latest", host), "starter") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(data) != configYAML { + t.Errorf("expected config %q, got %q", configYAML, string(data)) + } +} + +func TestResolverOCIReportsMissingLabel(t *testing.T) { + reg, host := setupTestRegistryWithImages(t) + + reg.pushImage("distro/nolabel", "v1", map[string]string{ + "other": "label", + }) + + resolver := NewDefaultConfigResolver(fetchOCILabels) + _, err := resolver.Resolve(fmt.Sprintf("%s/distro/nolabel:v1", host), "starter") + if err == nil { + t.Fatal("expected missing label error") + } +} + +func TestNewOCILabelFetcher_ReturnsWorkingFetcher(t *testing.T) { + fetcher := NewOCILabelFetcher() + if fetcher == nil { + t.Fatal("expected non-nil fetcher") + } + + _, err := fetcher(":::invalid") + if err == nil { + t.Error("expected error for invalid reference") + } +} + +func TestResolverCachesOCIResult(t *testing.T) { + callCount := 0 + fetcher := func(imageRef string) (map[string]string, error) { + callCount++ + return map[string]string{ + OCIDefaultConfigLabel: "config.yaml", + OCIConfigLabelPrefix + "config.yaml": base64.StdEncoding.EncodeToString([]byte("version: '2'\nimage_name: cached\n")), + }, nil + } + + resolver := NewDefaultConfigResolver(fetcher) + imageRef := "test-image@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + + _, err := resolver.Resolve(imageRef, "") + if err != nil { + t.Fatalf("first resolve: %v", err) + } + _, err = resolver.Resolve(imageRef, "") + if err != nil { + t.Fatalf("second resolve: %v", err) + } + + if callCount != 1 { + t.Errorf("expected fetcher called once (cached), called %d times", callCount) + } +} + +func TestResolverDoesNotCacheMutableTagResult(t *testing.T) { + callCount := 0 + fetcher := func(imageRef string) (map[string]string, error) { + callCount++ + return map[string]string{ + OCIDefaultConfigLabel: "config.yaml", + OCIConfigLabelPrefix + "config.yaml": base64.StdEncoding.EncodeToString([]byte("version: '2'\nimage_name: mutable\n")), + }, nil + } + + resolver := NewDefaultConfigResolver(fetcher) + + _, err := resolver.Resolve("test-image:latest", "") + if err != nil { + t.Fatalf("first resolve: %v", err) + } + _, err = resolver.Resolve("test-image:latest", "") + if err != nil { + t.Fatalf("second resolve: %v", err) + } + + if callCount != 2 { + t.Errorf("expected fetcher called twice for mutable tag, called %d times", callCount) + } +} + +func TestOCIConfigCacheLRUEviction(t *testing.T) { + cache := newOCIConfigCache() + + // Fill to capacity + for i := 0; i < maxOCICacheEntries; i++ { + cache.set(fmt.Sprintf("key-%d", i), []byte(fmt.Sprintf("data-%d", i))) + } + + // Access the oldest entry to make it recently used + data, ok := cache.get("key-0") + if !ok { + t.Fatal("expected key-0 to be in cache") + } + if string(data) != "data-0" { + t.Errorf("expected data-0, got %s", string(data)) + } + + // Add one more — should evict key-1 (the actual LRU), not key-0 + cache.set("new-key", []byte("new-data")) + + if _, ok := cache.get("key-0"); !ok { + t.Error("key-0 should still be in cache after promotion") + } + if _, ok := cache.get("key-1"); ok { + t.Error("key-1 should have been evicted as the LRU entry") + } + if data, ok := cache.get("new-key"); !ok || string(data) != "new-data" { + t.Error("new-key should be in cache") + } +} diff --git a/pkg/config/provider.go b/pkg/config/provider.go new file mode 100644 index 000000000..b22ecd8b9 --- /dev/null +++ b/pkg/config/provider.go @@ -0,0 +1,1092 @@ +/* +Copyright 2025. + +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. +*/ + +package config + +import ( + "encoding/json" + "fmt" + + ogxiov1beta1 "github.com/ogx-ai/ogx-k8s-operator/api/v1beta1" +) + +// providerExpander pairs an API type name with a function that expands the spec for that type. +type providerExpander struct { + apiType string + expand func() ([]ConfigProvider, error) +} + +// ExpandProviders converts the typed ProvidersSpec into config.yaml provider sections. +// Returns a map keyed by API type (e.g., "inference", "vector_io") to provider lists. +func ExpandProviders(providers *ogxiov1beta1.ProvidersSpec) (map[string][]ConfigProvider, error) { + if providers == nil { + return nil, nil + } + + expanders := buildProviderExpanders(providers) + result := make(map[string][]ConfigProvider, len(expanders)) + for _, e := range expanders { + ps, err := e.expand() + if err != nil { + return nil, fmt.Errorf("failed to expand %s providers: %w", e.apiType, err) + } + if len(ps) > 0 { + result[e.apiType] = ps + } + } + return result, nil +} + +// buildProviderExpanders returns one expander per configured API type. +// NOTE: Keep in sync with collectProviderSecrets in secret.go — when adding a +// new provider API type, both functions must be updated. +func buildProviderExpanders(p *ogxiov1beta1.ProvidersSpec) []providerExpander { + var out []providerExpander + if p.Inference != nil { + spec := p.Inference + out = append(out, providerExpander{"inference", func() ([]ConfigProvider, error) { return expandInferenceProviders(spec) }}) + } + if p.VectorIo != nil { + spec := p.VectorIo + out = append(out, providerExpander{"vector_io", func() ([]ConfigProvider, error) { return expandVectorIOProviders(spec) }}) + } + if p.ToolRuntime != nil { + spec := p.ToolRuntime + out = append(out, providerExpander{"tool_runtime", func() ([]ConfigProvider, error) { return expandToolRuntimeProviders(spec) }}) + } + if p.Files != nil { + spec := p.Files + out = append(out, providerExpander{"files", func() ([]ConfigProvider, error) { return expandFilesProviders(spec) }}) + } + if p.Batches != nil { + spec := p.Batches + out = append(out, providerExpander{"batches", func() ([]ConfigProvider, error) { return expandBatchesProviders(spec) }}) + } + if p.Responses != nil { + spec := p.Responses + out = append(out, providerExpander{"responses", func() ([]ConfigProvider, error) { return expandResponsesProviders(spec) }}) + } + if p.FileProcessors != nil { + spec := p.FileProcessors + out = append(out, providerExpander{"file_processors", func() ([]ConfigProvider, error) { return expandFileProcessorsProviders(spec) }}) + } + return out +} + +func expandInferenceProviders(spec *ogxiov1beta1.InferenceProvidersSpec) ([]ConfigProvider, error) { + var providers []ConfigProvider + + if spec.Remote != nil { + providers = expandRemoteInferenceProviders(spec.Remote, providers) + if err := appendCustomProviders(&providers, spec.Remote.Custom); err != nil { + return nil, err + } + } + if spec.Inline != nil { + if err := appendCustomProviders(&providers, spec.Inline.Custom); err != nil { + return nil, err + } + } + + return providers, nil +} + +func expandRemoteInferenceProviders(remote *ogxiov1beta1.InferenceRemoteProviders, providers []ConfigProvider) []ConfigProvider { + for _, p := range remote.VLLM { + providers = append(providers, expandVLLMProvider(p)) + } + for _, p := range remote.OpenAI { + providers = append(providers, expandOpenAIProvider(p)) + } + for _, p := range remote.Azure { + providers = append(providers, expandAzureProvider(p)) + } + for _, p := range remote.Bedrock { + providers = append(providers, expandBedrockProvider(p)) + } + for _, p := range remote.VertexAI { + providers = append(providers, expandVertexAIProvider(p)) + } + for _, p := range remote.Watsonx { + providers = append(providers, expandWatsonxProvider(p)) + } + return providers +} + +func appendCustomProviders(providers *[]ConfigProvider, custom []ogxiov1beta1.CustomProvider) error { + for _, p := range custom { + cp, err := expandCustomProvider(p) + if err != nil { + return err + } + *providers = append(*providers, cp) + } + return nil +} + +func expandVLLMProvider(p ogxiov1beta1.VLLMProvider) ConfigProvider { + cfg := map[string]interface{}{ + "base_url": p.Endpoint, + } + if p.MaxTokens != nil { + cfg["max_tokens"] = *p.MaxTokens + } + if p.APIToken != nil { + cfg["api_token"] = envVarRef(p.DeriveID(), "API_TOKEN") + } + applyCommonInferenceConfig(cfg, p.RemoteInferenceCommonConfig) + + return ConfigProvider{ + ProviderID: p.DeriveID(), + ProviderType: "remote::vllm", + Config: cfg, + } +} + +func expandOpenAIProvider(p ogxiov1beta1.OpenAIProvider) ConfigProvider { + cfg := map[string]interface{}{ + "api_key": envVarRef(p.DeriveID(), "API_KEY"), + } + if p.Endpoint != "" { + cfg["base_url"] = p.Endpoint + } + applyCommonInferenceConfig(cfg, p.RemoteInferenceCommonConfig) + + return ConfigProvider{ + ProviderID: p.DeriveID(), + ProviderType: "remote::openai", + Config: cfg, + } +} + +func expandAzureProvider(p ogxiov1beta1.AzureProvider) ConfigProvider { + cfg := map[string]interface{}{ + "base_url": p.Endpoint, + "api_key": envVarRef(p.DeriveID(), "API_KEY"), + } + if p.APIVersion != "" { + cfg["api_version"] = p.APIVersion + } + if p.APIType != "" { + cfg["api_type"] = p.APIType + } + applyCommonInferenceConfig(cfg, p.RemoteInferenceCommonConfig) + + return ConfigProvider{ + ProviderID: p.DeriveID(), + ProviderType: "remote::azure", + Config: cfg, + } +} + +func expandBedrockProvider(p ogxiov1beta1.BedrockProvider) ConfigProvider { + cfg := map[string]interface{}{ + "region_name": p.Region, + } + id := p.DeriveID() + setSecretRef(cfg, "api_key", id, "API_KEY", p.APIKey) + setSecretRef(cfg, "aws_access_key_id", id, "AWS_ACCESS_KEY_ID", p.AWSAccessKeyID) + setSecretRef(cfg, "aws_secret_access_key", id, "AWS_SECRET_ACCESS_KEY", p.AWSSecretAccessKey) + setSecretRef(cfg, "aws_session_token", id, "AWS_SESSION_TOKEN", p.AWSSessionToken) + setString(cfg, "aws_role_arn", p.AWSRoleArn) + setString(cfg, "aws_web_identity_token_file", p.AWSWebIdentityTokenFile) + setString(cfg, "aws_role_session_name", p.AWSRoleSessionName) + setString(cfg, "profile_name", p.ProfileName) + setString(cfg, "retry_mode", p.RetryMode) + setIntPtr(cfg, "total_max_attempts", p.TotalMaxAttempts) + setIntPtr(cfg, "connect_timeout", p.ConnectTimeout) + setIntPtr(cfg, "read_timeout", p.ReadTimeout) + setIntPtr(cfg, "session_ttl", p.SessionTTL) + applyCommonInferenceConfig(cfg, p.RemoteInferenceCommonConfig) + + return ConfigProvider{ + ProviderID: id, + ProviderType: "remote::bedrock", + Config: cfg, + } +} + +func applyCommonInferenceConfig(cfg map[string]interface{}, c ogxiov1beta1.RemoteInferenceCommonConfig) { + if c.RefreshModels != nil { + cfg["refresh_models"] = *c.RefreshModels + } + if len(c.AllowedModels) > 0 { + cfg["allowed_models"] = c.AllowedModels + } + applyNetworkConfig(cfg, c.Network) +} + +func setSecretRef(cfg map[string]interface{}, key, providerID, field string, ref *ogxiov1beta1.SecretKeyRef) { + if ref != nil { + cfg[key] = envVarRef(providerID, field) + } +} + +func setString(cfg map[string]interface{}, key, val string) { + if val != "" { + cfg[key] = val + } +} + +func setIntPtr(cfg map[string]interface{}, key string, val *int) { + if val != nil { + cfg[key] = *val + } +} + +func expandVertexAIProvider(p ogxiov1beta1.VertexAIProvider) ConfigProvider { + cfg := map[string]interface{}{ + "project": p.Project, + } + if p.Location != "" { + cfg["location"] = p.Location + } + applyCommonInferenceConfig(cfg, p.RemoteInferenceCommonConfig) + + return ConfigProvider{ + ProviderID: p.DeriveID(), + ProviderType: "remote::vertexai", + Config: cfg, + } +} + +func expandWatsonxProvider(p ogxiov1beta1.WatsonxProvider) ConfigProvider { + cfg := map[string]interface{}{ + "api_key": envVarRef(p.DeriveID(), "API_KEY"), + } + if p.Endpoint != "" { + cfg["base_url"] = p.Endpoint + } + if p.ProjectID != "" { + cfg["project_id"] = p.ProjectID + } + if p.Timeout != nil { + cfg["timeout"] = *p.Timeout + } + applyCommonInferenceConfig(cfg, p.RemoteInferenceCommonConfig) + + return ConfigProvider{ + ProviderID: p.DeriveID(), + ProviderType: "remote::watsonx", + Config: cfg, + } +} + +func expandCustomProvider(p ogxiov1beta1.CustomProvider) (ConfigProvider, error) { + cfg := make(map[string]interface{}) + + for key := range p.SecretRefs { + cfg[key] = envVarRef(p.DeriveID(), normalizeEnvVarField(key)) + } + + if p.Settings != nil && p.Settings.Raw != nil { + var settings map[string]interface{} + if err := json.Unmarshal(p.Settings.Raw, &settings); err != nil { + return ConfigProvider{}, fmt.Errorf("failed to unmarshal settings JSON for provider %q: %w", p.DeriveID(), err) + } + for k, v := range settings { + if _, conflict := p.SecretRefs[k]; conflict { + return ConfigProvider{}, fmt.Errorf("failed to expand provider %q: key %q appears in both secretRefs and settings", p.DeriveID(), k) + } + cfg[k] = v + } + } + + return ConfigProvider{ + ProviderID: p.DeriveID(), + ProviderType: p.Type, + Config: cfg, + }, nil +} + +func expandVectorIOProviders(spec *ogxiov1beta1.VectorIOProvidersSpec) ([]ConfigProvider, error) { + var providers []ConfigProvider + if spec.Remote != nil { + for _, p := range spec.Remote.Pgvector { + providers = append(providers, expandPgvectorProvider(p)) + } + for _, p := range spec.Remote.Milvus { + providers = append(providers, expandMilvusProvider(p)) + } + for _, p := range spec.Remote.Qdrant { + providers = append(providers, expandQdrantProvider(p)) + } + if err := appendCustomProviders(&providers, spec.Remote.Custom); err != nil { + return nil, err + } + } + if spec.Inline != nil { + if err := appendCustomProviders(&providers, spec.Inline.Custom); err != nil { + return nil, err + } + } + return providers, nil +} + +func expandPgvectorProvider(p ogxiov1beta1.PgvectorProvider) ConfigProvider { + cfg := map[string]interface{}{} + if p.Host != "" { + cfg["host"] = p.Host + } + if p.Port != nil { + cfg["port"] = *p.Port + } + if p.DB != "" { + cfg["db"] = p.DB + } + if p.User != "" { + cfg["user"] = p.User + } + cfg["password"] = envVarRef(p.DeriveID(), "PASSWORD") + if p.DistanceMetric != "" { + cfg["distance_metric"] = p.DistanceMetric + } + if vectorIndex := expandPgvectorVectorIndex(p.VectorIndex); vectorIndex != nil { + cfg["vector_index"] = vectorIndex + } + + return ConfigProvider{ + ProviderID: p.DeriveID(), + ProviderType: "remote::pgvector", + Config: cfg, + } +} + +func expandPgvectorVectorIndex(vectorIndex *ogxiov1beta1.VectorIndexConfig) map[string]interface{} { + if vectorIndex == nil { + return nil + } + + cfg := map[string]interface{}{} + if hnsw := expandPgvectorHNSW(vectorIndex.HNSW); hnsw != nil { + cfg["hnsw"] = hnsw + } + if ivfFlat := expandPgvectorIVFFlat(vectorIndex.IVFFlat); ivfFlat != nil { + cfg["ivf_flat"] = ivfFlat + } + if len(cfg) == 0 { + return nil + } + return cfg +} + +func expandPgvectorHNSW(hnsw *ogxiov1beta1.HNSWConfig) map[string]interface{} { + if hnsw == nil { + return nil + } + + cfg := map[string]interface{}{} + if hnsw.M != nil { + cfg["m"] = *hnsw.M + } + if hnsw.EfConstruction != nil { + cfg["ef_construction"] = *hnsw.EfConstruction + } + if hnsw.EfSearch != nil { + cfg["ef_search"] = *hnsw.EfSearch + } + if len(cfg) == 0 { + return nil + } + return cfg +} + +func expandPgvectorIVFFlat(ivfFlat *ogxiov1beta1.IVFFlatConfig) map[string]interface{} { + if ivfFlat == nil { + return nil + } + + cfg := map[string]interface{}{} + if ivfFlat.Nlist != nil { + cfg["nlist"] = *ivfFlat.Nlist + } + if ivfFlat.Nprobe != nil { + cfg["nprobe"] = *ivfFlat.Nprobe + } + if len(cfg) == 0 { + return nil + } + return cfg +} + +func expandMilvusProvider(p ogxiov1beta1.MilvusProvider) ConfigProvider { + cfg := map[string]interface{}{ + "uri": p.URI, + } + if p.Token != nil { + cfg["token"] = envVarRef(p.DeriveID(), "TOKEN") + } + if p.ConsistencyLevel != "" { + cfg["consistency_level"] = p.ConsistencyLevel + } + + return ConfigProvider{ + ProviderID: p.DeriveID(), + ProviderType: "remote::milvus", + Config: cfg, + } +} + +func expandQdrantProvider(p ogxiov1beta1.QdrantProvider) ConfigProvider { + cfg := map[string]interface{}{} + applyQdrantConnectionConfig(cfg, p) + applyQdrantAuthConfig(cfg, p) + applyQdrantRuntimeConfig(cfg, p) + + return ConfigProvider{ + ProviderID: p.DeriveID(), + ProviderType: "remote::qdrant", + Config: cfg, + } +} + +func applyQdrantConnectionConfig(cfg map[string]interface{}, p ogxiov1beta1.QdrantProvider) { + if p.URL != "" { + cfg["url"] = p.URL + } + if p.Host != "" { + cfg["host"] = p.Host + } + if p.Port != nil { + cfg["port"] = *p.Port + } + if p.Location != "" { + cfg["location"] = p.Location + } +} + +func applyQdrantAuthConfig(cfg map[string]interface{}, p ogxiov1beta1.QdrantProvider) { + if p.APIKey != nil { + cfg["api_key"] = envVarRef(p.DeriveID(), "API_KEY") + } +} + +func applyQdrantRuntimeConfig(cfg map[string]interface{}, p ogxiov1beta1.QdrantProvider) { + if p.GRPCPort != nil { + cfg["grpc_port"] = *p.GRPCPort + } + if p.PreferGRPC != nil { + cfg["prefer_grpc"] = *p.PreferGRPC + } + if p.HTTPS != nil { + cfg["https"] = *p.HTTPS + } + if p.Prefix != "" { + cfg["prefix"] = p.Prefix + } + if p.Timeout != nil { + cfg["timeout"] = *p.Timeout + } +} + +func expandToolRuntimeProviders(spec *ogxiov1beta1.ToolRuntimeProvidersSpec) ([]ConfigProvider, error) { + var providers []ConfigProvider + if spec.Remote != nil { + for _, p := range spec.Remote.BraveSearch { + providers = append(providers, expandBraveSearchProvider(p)) + } + for _, p := range spec.Remote.TavilySearch { + providers = append(providers, expandTavilySearchProvider(p)) + } + for _, p := range spec.Remote.ModelContextProtocol { + providers = append(providers, expandMCPProvider(p)) + } + if err := appendCustomProviders(&providers, spec.Remote.Custom); err != nil { + return nil, err + } + } + if spec.Inline != nil { + for _, p := range spec.Inline.FileSearch { + providers = append(providers, expandInlineFileSearchProvider(p)) + } + if err := appendCustomProviders(&providers, spec.Inline.Custom); err != nil { + return nil, err + } + } + return providers, nil +} + +func expandBraveSearchProvider(p ogxiov1beta1.BraveSearchProvider) ConfigProvider { + cfg := map[string]interface{}{ + "api_key": envVarRef(p.DeriveID(), "API_KEY"), + } + if p.MaxResults != nil { + cfg["max_results"] = *p.MaxResults + } + + return ConfigProvider{ + ProviderID: p.DeriveID(), + ProviderType: "remote::brave-search", + Config: cfg, + } +} + +func expandTavilySearchProvider(p ogxiov1beta1.TavilySearchProvider) ConfigProvider { + cfg := map[string]interface{}{ + "api_key": envVarRef(p.DeriveID(), "API_KEY"), + } + if p.MaxResults != nil { + cfg["max_results"] = *p.MaxResults + } + + return ConfigProvider{ + ProviderID: p.DeriveID(), + ProviderType: "remote::tavily-search", + Config: cfg, + } +} + +func expandMCPProvider(p ogxiov1beta1.ModelContextProtocolProvider) ConfigProvider { + return ConfigProvider{ + ProviderID: p.DeriveID(), + ProviderType: "remote::model-context-protocol", + Config: map[string]interface{}{}, + } +} + +func expandInlineFileSearchProvider(p ogxiov1beta1.InlineFileSearchProvider) ConfigProvider { + cfg := map[string]interface{}{} + if p.VectorStoresConfig != nil { + cfg["vector_stores_config"] = expandVectorStoresConfig(p.VectorStoresConfig) + } + return ConfigProvider{ + ProviderID: p.DeriveID(), + ProviderType: "inline::rag-runtime", + Config: cfg, + } +} + +func expandFilesProviders(spec *ogxiov1beta1.FilesProvidersSpec) ([]ConfigProvider, error) { + var providers []ConfigProvider + if spec.Remote != nil { + if spec.Remote.S3 != nil { + providers = append(providers, expandS3Provider(*spec.Remote.S3)) + } + if err := appendCustomProviders(&providers, spec.Remote.Custom); err != nil { + return nil, err + } + } + if spec.Inline != nil { + if spec.Inline.LocalFS != nil { + providers = append(providers, expandLocalFSProvider(*spec.Inline.LocalFS)) + } + if err := appendCustomProviders(&providers, spec.Inline.Custom); err != nil { + return nil, err + } + } + return providers, nil +} + +func expandS3Provider(p ogxiov1beta1.S3Provider) ConfigProvider { + cfg := map[string]interface{}{ + "bucket_name": p.BucketName, + } + if p.Region != "" { + cfg["region"] = p.Region + } + if p.EndpointURL != "" { + cfg["endpoint_url"] = p.EndpointURL + } + if p.AWSAccessKeyID != nil { + cfg["aws_access_key_id"] = envVarRef(p.DeriveID(), "AWS_ACCESS_KEY_ID") + } + if p.AWSSecretAccessKey != nil { + cfg["aws_secret_access_key"] = envVarRef(p.DeriveID(), "AWS_SECRET_ACCESS_KEY") + } + if p.AutoCreateBucket != nil { + cfg["auto_create_bucket"] = *p.AutoCreateBucket + } + + return ConfigProvider{ + ProviderID: p.DeriveID(), + ProviderType: "remote::s3", + Config: cfg, + } +} + +func expandLocalFSProvider(p ogxiov1beta1.InlineLocalFSProvider) ConfigProvider { + cfg := map[string]interface{}{} + if p.TTLSecs != nil { + cfg["ttl_secs"] = *p.TTLSecs + } + + return ConfigProvider{ + ProviderID: p.DeriveID(), + ProviderType: "inline::localfs", + Config: cfg, + } +} + +func expandBatchesProviders(spec *ogxiov1beta1.BatchesProvidersSpec) ([]ConfigProvider, error) { + var providers []ConfigProvider + if spec.Remote != nil { + if err := appendCustomProviders(&providers, spec.Remote.Custom); err != nil { + return nil, err + } + } + if spec.Inline != nil { + if spec.Inline.Reference != nil { + providers = append(providers, expandReferenceProvider(*spec.Inline.Reference)) + } + if err := appendCustomProviders(&providers, spec.Inline.Custom); err != nil { + return nil, err + } + } + return providers, nil +} + +func expandReferenceProvider(p ogxiov1beta1.InlineReferenceProvider) ConfigProvider { + cfg := map[string]interface{}{} + if p.MaxConcurrentBatches != nil { + cfg["max_concurrent_batches"] = *p.MaxConcurrentBatches + } + if p.MaxConcurrentRequestsPerBatch != nil { + cfg["max_concurrent_requests_per_batch"] = *p.MaxConcurrentRequestsPerBatch + } + return ConfigProvider{ + ProviderID: p.DeriveID(), + ProviderType: "inline::batch-reference", + Config: cfg, + } +} + +func expandResponsesProviders(spec *ogxiov1beta1.ResponsesProvidersSpec) ([]ConfigProvider, error) { + var providers []ConfigProvider + if spec.Remote != nil { + if err := appendCustomProviders(&providers, spec.Remote.Custom); err != nil { + return nil, err + } + } + if spec.Inline != nil { + if spec.Inline.Builtin != nil { + providers = append(providers, expandBuiltinResponsesProvider(*spec.Inline.Builtin)) + } + if err := appendCustomProviders(&providers, spec.Inline.Custom); err != nil { + return nil, err + } + } + return providers, nil +} + +func expandBuiltinResponsesProvider(p ogxiov1beta1.InlineBuiltinResponsesProvider) ConfigProvider { + cfg := map[string]interface{}{} + if p.VectorStoresConfig != nil { + cfg["vector_stores_config"] = expandVectorStoresConfig(p.VectorStoresConfig) + } + if p.CompactionConfig != nil { + cfg["compaction_config"] = expandCompactionConfig(p.CompactionConfig) + } + return ConfigProvider{ + ProviderID: p.DeriveID(), + ProviderType: "inline::responses", + Config: cfg, + } +} + +func expandFileProcessorsProviders(spec *ogxiov1beta1.FileProcessorsProvidersSpec) ([]ConfigProvider, error) { + var providers []ConfigProvider + if spec.Remote != nil { + if spec.Remote.DoclingServe != nil { + providers = append(providers, expandDoclingServeProvider(*spec.Remote.DoclingServe)) + } + if err := appendCustomProviders(&providers, spec.Remote.Custom); err != nil { + return nil, err + } + } + if spec.Inline != nil { + if spec.Inline.Auto != nil { + providers = append(providers, expandAutoFileProcessorProvider(*spec.Inline.Auto)) + } + if spec.Inline.PyPDF != nil { + providers = append(providers, expandPyPDFFileProcessorProvider(*spec.Inline.PyPDF)) + } + if spec.Inline.MarkItDown != nil { + providers = append(providers, expandMarkItDownFileProcessorProvider(*spec.Inline.MarkItDown)) + } + if spec.Inline.Docling != nil { + providers = append(providers, expandDoclingFileProcessorProvider(*spec.Inline.Docling)) + } + if err := appendCustomProviders(&providers, spec.Inline.Custom); err != nil { + return nil, err + } + } + return providers, nil +} + +func expandDoclingServeProvider(p ogxiov1beta1.DoclingServeProvider) ConfigProvider { + cfg := map[string]interface{}{ + "base_url": p.BaseURL, + } + if p.APIKey != nil { + cfg["api_key"] = envVarRef(p.DeriveID(), "API_KEY") + } + setIntPtr(cfg, "default_chunk_size_tokens", p.DefaultChunkSizeTokens) + + return ConfigProvider{ + ProviderID: p.DeriveID(), + ProviderType: "remote::docling-serve", + Config: cfg, + } +} + +func expandAutoFileProcessorProvider(p ogxiov1beta1.InlineAutoFileProcessorProvider) ConfigProvider { + cfg := map[string]interface{}{} + setIntPtr(cfg, "default_chunk_size_tokens", p.DefaultChunkSizeTokens) + setIntPtr(cfg, "default_chunk_overlap_tokens", p.DefaultChunkOverlapTokens) + if p.ExtractMetadata != nil { + cfg["extract_metadata"] = *p.ExtractMetadata + } + if p.CleanText != nil { + cfg["clean_text"] = *p.CleanText + } + + return ConfigProvider{ + ProviderID: p.DeriveID(), + ProviderType: "inline::auto", + Config: cfg, + } +} + +func expandPyPDFFileProcessorProvider(p ogxiov1beta1.InlinePyPDFFileProcessorProvider) ConfigProvider { + cfg := map[string]interface{}{} + setIntPtr(cfg, "default_chunk_size_tokens", p.DefaultChunkSizeTokens) + setIntPtr(cfg, "default_chunk_overlap_tokens", p.DefaultChunkOverlapTokens) + if p.ExtractMetadata != nil { + cfg["extract_metadata"] = *p.ExtractMetadata + } + if p.CleanText != nil { + cfg["clean_text"] = *p.CleanText + } + + return ConfigProvider{ + ProviderID: p.DeriveID(), + ProviderType: "inline::pypdf", + Config: cfg, + } +} + +func expandMarkItDownFileProcessorProvider(p ogxiov1beta1.InlineMarkItDownFileProcessorProvider) ConfigProvider { + cfg := map[string]interface{}{} + setIntPtr(cfg, "default_chunk_size_tokens", p.DefaultChunkSizeTokens) + setIntPtr(cfg, "default_chunk_overlap_tokens", p.DefaultChunkOverlapTokens) + + return ConfigProvider{ + ProviderID: p.DeriveID(), + ProviderType: "inline::markitdown", + Config: cfg, + } +} + +func expandDoclingFileProcessorProvider(p ogxiov1beta1.InlineDoclingFileProcessorProvider) ConfigProvider { + cfg := map[string]interface{}{} + setIntPtr(cfg, "default_chunk_size_tokens", p.DefaultChunkSizeTokens) + setIntPtr(cfg, "default_chunk_overlap_tokens", p.DefaultChunkOverlapTokens) + if p.DoOCR != nil { + cfg["do_ocr"] = *p.DoOCR + } + + return ConfigProvider{ + ProviderID: p.DeriveID(), + ProviderType: "inline::docling", + Config: cfg, + } +} + +func applyNetworkConfig(cfg map[string]interface{}, network *ogxiov1beta1.NetworkConfig) { + if network == nil { + return + } + n := map[string]interface{}{} + applyTLSConfig(n, network.TLS) + applyTimeoutConfig(n, network.Timeout) + if len(network.Headers) > 0 { + n["headers"] = network.Headers + } + applyProxyConfig(n, network.Proxy) + if len(n) > 0 { + cfg["network"] = n + } +} + +func applyProxyConfig(cfg map[string]interface{}, proxy *ogxiov1beta1.ProxyConfig) { + if proxy == nil { + return + } + m := map[string]interface{}{} + if proxy.URL != nil { + m["url"] = *proxy.URL + } + if proxy.HTTP != nil { + m["http"] = *proxy.HTTP + } + if proxy.HTTPS != nil { + m["https"] = *proxy.HTTPS + } + if proxy.CACert != nil { + m["ca_cert"] = *proxy.CACert + } + if len(proxy.NoProxy) > 0 { + m["no_proxy"] = proxy.NoProxy + } + if len(m) > 0 { + cfg["proxy"] = m + } +} + +func expandVectorStoresConfig(vs *ogxiov1beta1.VectorStoresConfig) map[string]interface{} { + m := map[string]interface{}{} + applyVectorStoreDefaults(m, vs) + applyVectorStoreSearchConfig(m, vs) + applyVectorStorePromptConfig(m, vs) + applyVectorStoreIngestionConfig(m, vs) + return m +} + +func applyVectorStoreDefaults(cfg map[string]interface{}, vs *ogxiov1beta1.VectorStoresConfig) { + if vs.DefaultProviderID != "" { + cfg["default_provider_id"] = vs.DefaultProviderID + } + if vs.DefaultEmbeddingModel != nil { + cfg["default_embedding_model"] = expandQualifiedModel(vs.DefaultEmbeddingModel) + } + if vs.DefaultRerankerModel != nil { + cfg["default_reranker_model"] = expandRerankerModel(vs.DefaultRerankerModel) + } +} + +func applyVectorStoreSearchConfig(cfg map[string]interface{}, vs *ogxiov1beta1.VectorStoresConfig) { + if vs.RewriteQueryParams != nil { + cfg["rewrite_query_params"] = expandRewriteQueryParams(vs.RewriteQueryParams) + } + if vs.FileSearchParams != nil { + cfg["file_search_params"] = expandFileSearchParams(vs.FileSearchParams) + } + if vs.ChunkRetrievalParams != nil { + cfg["chunk_retrieval_params"] = expandChunkRetrievalParams(vs.ChunkRetrievalParams) + } +} + +func applyVectorStorePromptConfig(cfg map[string]interface{}, vs *ogxiov1beta1.VectorStoresConfig) { + if vs.ContextPromptParams != nil { + cfg["context_prompt_params"] = expandContextPromptParams(vs.ContextPromptParams) + } + if vs.AnnotationPromptParams != nil { + cfg["annotation_prompt_params"] = expandAnnotationPromptParams(vs.AnnotationPromptParams) + } +} + +func applyVectorStoreIngestionConfig(cfg map[string]interface{}, vs *ogxiov1beta1.VectorStoresConfig) { + if vs.FileIngestionParams != nil { + cfg["file_ingestion_params"] = expandFileIngestionParams(vs.FileIngestionParams) + } + if vs.FileBatchParams != nil { + cfg["file_batch_params"] = expandFileBatchParams(vs.FileBatchParams) + } + if vs.ContextualRetrievalParams != nil { + cfg["contextual_retrieval_params"] = expandContextualRetrievalParams(vs.ContextualRetrievalParams) + } +} + +func expandRerankerModel(model *ogxiov1beta1.RerankerModel) map[string]interface{} { + return map[string]interface{}{ + "provider_id": model.ProviderID, + "model_id": model.ModelID, + } +} + +func expandRewriteQueryParams(params *ogxiov1beta1.RewriteQueryParams) map[string]interface{} { + cfg := map[string]interface{}{} + if params.Model != nil { + cfg["model"] = expandQualifiedModel(params.Model) + } + if params.Prompt != "" { + cfg["prompt"] = params.Prompt + } + if params.MaxTokens != nil { + cfg["max_tokens"] = *params.MaxTokens + } + if params.Temperature != nil { + cfg["temperature"] = *params.Temperature + } + return cfg +} + +func expandFileSearchParams(params *ogxiov1beta1.FileSearchDisplayParams) map[string]interface{} { + cfg := map[string]interface{}{} + if params.HeaderTemplate != "" { + cfg["header_template"] = params.HeaderTemplate + } + if params.FooterTemplate != "" { + cfg["footer_template"] = params.FooterTemplate + } + return cfg +} + +func expandContextPromptParams(params *ogxiov1beta1.ContextPromptParams) map[string]interface{} { + cfg := map[string]interface{}{} + if params.ChunkAnnotationTemplate != "" { + cfg["chunk_annotation_template"] = params.ChunkAnnotationTemplate + } + if params.ContextTemplate != "" { + cfg["context_template"] = params.ContextTemplate + } + return cfg +} + +func expandAnnotationPromptParams(params *ogxiov1beta1.AnnotationPromptParams) map[string]interface{} { + cfg := map[string]interface{}{} + if params.EnableAnnotations != nil { + cfg["enable_annotations"] = *params.EnableAnnotations + } + if params.AnnotationInstructionTemplate != "" { + cfg["annotation_instruction_template"] = params.AnnotationInstructionTemplate + } + if params.ChunkAnnotationTemplate != "" { + cfg["chunk_annotation_template"] = params.ChunkAnnotationTemplate + } + return cfg +} + +func expandFileIngestionParams(params *ogxiov1beta1.FileIngestionParams) map[string]interface{} { + cfg := map[string]interface{}{} + if params.DefaultChunkSizeTokens != nil { + cfg["default_chunk_size_tokens"] = *params.DefaultChunkSizeTokens + } + if params.DefaultChunkOverlapTokens != nil { + cfg["default_chunk_overlap_tokens"] = *params.DefaultChunkOverlapTokens + } + return cfg +} + +func expandChunkRetrievalParams(params *ogxiov1beta1.ChunkRetrievalParams) map[string]interface{} { + cfg := map[string]interface{}{} + if params.ChunkMultiplier != nil { + cfg["chunk_multiplier"] = *params.ChunkMultiplier + } + if params.MaxTokensInContext != nil { + cfg["max_tokens_in_context"] = *params.MaxTokensInContext + } + if params.DefaultRerankerStrategy != nil { + cfg["default_reranker_strategy"] = *params.DefaultRerankerStrategy + } + if params.RRFImpactFactor != nil { + cfg["rrf_impact_factor"] = *params.RRFImpactFactor + } + if params.WeightedSearchAlpha != nil { + cfg["weighted_search_alpha"] = *params.WeightedSearchAlpha + } + if params.DefaultSearchMode != nil { + cfg["default_search_mode"] = *params.DefaultSearchMode + } + return cfg +} + +func expandFileBatchParams(params *ogxiov1beta1.FileBatchParams) map[string]interface{} { + cfg := map[string]interface{}{} + if params.MaxConcurrentFilesPerBatch != nil { + cfg["max_concurrent_files_per_batch"] = *params.MaxConcurrentFilesPerBatch + } + if params.FileBatchChunkSize != nil { + cfg["file_batch_chunk_size"] = *params.FileBatchChunkSize + } + if params.CleanupIntervalSeconds != nil { + cfg["cleanup_interval_seconds"] = *params.CleanupIntervalSeconds + } + return cfg +} + +func expandContextualRetrievalParams(params *ogxiov1beta1.ContextualRetrievalParams) map[string]interface{} { + cfg := map[string]interface{}{} + if params.Model != nil { + cfg["model"] = expandQualifiedModel(params.Model) + } + if params.DefaultTimeoutSeconds != nil { + cfg["default_timeout_seconds"] = *params.DefaultTimeoutSeconds + } + if params.DefaultMaxConcurrency != nil { + cfg["default_max_concurrency"] = *params.DefaultMaxConcurrency + } + if params.MaxDocumentTokens != nil { + cfg["max_document_tokens"] = *params.MaxDocumentTokens + } + return cfg +} + +func expandQualifiedModel(qm *ogxiov1beta1.QualifiedModel) map[string]interface{} { + m := map[string]interface{}{ + "provider_id": qm.ProviderID, + "model_id": qm.ModelID, + } + if qm.EmbeddingDimensions != nil { + m["embedding_dimensions"] = *qm.EmbeddingDimensions + } + return m +} + +func expandCompactionConfig(cc *ogxiov1beta1.CompactionConfig) map[string]interface{} { + m := map[string]interface{}{} + if cc.SummarizationPrompt != "" { + m["summarization_prompt"] = cc.SummarizationPrompt + } + if cc.SummaryPrefix != "" { + m["summary_prefix"] = cc.SummaryPrefix + } + if cc.SummarizationModel != "" { + m["summarization_model"] = cc.SummarizationModel + } + if cc.DefaultCompactThreshold != nil { + m["default_compact_threshold"] = *cc.DefaultCompactThreshold + } + if cc.TokenizerEncoding != "" { + m["tokenizer_encoding"] = cc.TokenizerEncoding + } + return m +} + +func applyTLSConfig(cfg map[string]interface{}, tls *ogxiov1beta1.TLSConfig) { + if tls == nil { + return + } + m := map[string]interface{}{} + if tls.Verify != nil { + m["verify"] = *tls.Verify + } + if tls.MinVersion != "" { + m["min_version"] = tls.MinVersion + } + if len(tls.Ciphers) > 0 { + m["ciphers"] = tls.Ciphers + } + if len(m) > 0 { + cfg["tls"] = m + } +} + +func applyTimeoutConfig(cfg map[string]interface{}, timeout *ogxiov1beta1.TimeoutConfig) { + if timeout == nil { + return + } + m := map[string]interface{}{} + if timeout.Connect != nil { + m["connect"] = *timeout.Connect + } + if timeout.Read != nil { + m["read"] = *timeout.Read + } + if len(m) > 0 { + cfg["timeout"] = m + } +} diff --git a/pkg/config/resolver.go b/pkg/config/resolver.go new file mode 100644 index 000000000..00b63b1a9 --- /dev/null +++ b/pkg/config/resolver.go @@ -0,0 +1,168 @@ +/* +Copyright 2025. + +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. +*/ + +package config + +import ( + "container/list" + "encoding/base64" + "errors" + "fmt" + "strings" + "sync" +) + +const ( + // OCIConfigListLabel is the OCI metadata label listing available config filenames. + OCIConfigListLabel = "com.ogx.distribution.configs" + // OCIDefaultConfigLabel identifies which config filename is the runtime default. + OCIDefaultConfigLabel = "com.ogx.distribution.default-config" + // OCIConfigLabelPrefix prefixes the per-file base64-encoded config labels. + OCIConfigLabelPrefix = "com.ogx.config." +) + +// OCILabelFetcher fetches OCI image labels for a given image reference. +// Returns the label map, or an error if the image is inaccessible. +type OCILabelFetcher func(imageRef string) (map[string]string, error) + +// ConfigResolver resolves a distribution's base config.yaml content from OCI labels. +type ConfigResolver interface { + // Resolve returns the base config for the given distribution. + // imageRef is the resolved container image reference. + // distributionName is accepted for compatibility with existing callers. + Resolve(imageRef string, distributionName string) ([]byte, error) +} + +// DefaultConfigResolver resolves base config content from OCI image labels. +type DefaultConfigResolver struct { + cache *ociConfigCache + labelFetcher OCILabelFetcher +} + +// NewDefaultConfigResolver creates a resolver backed by OCI label lookups. +func NewDefaultConfigResolver(labelFetcher OCILabelFetcher) *DefaultConfigResolver { + return &DefaultConfigResolver{ + cache: newOCIConfigCache(), + labelFetcher: labelFetcher, + } +} + +func (r *DefaultConfigResolver) Resolve(imageRef string, _ string) ([]byte, error) { + if imageRef == "" { + return nil, errors.New("failed to resolve base config from OCI labels: distribution image reference is empty") + } + if r.labelFetcher == nil { + return nil, fmt.Errorf("failed to resolve base config from OCI labels for %q: OCI label fetcher is not configured", imageRef) + } + return r.resolveFromOCI(imageRef) +} + +func (r *DefaultConfigResolver) resolveFromOCI(imageRef string) ([]byte, error) { + // Cache only digest-pinned references. Mutable tags (for example :latest) + // must be refetched so updated OCI labels are observed without a restart. + if shouldCacheOCIConfig(imageRef) { + if data, ok := r.cache.get(imageRef); ok { + return data, nil + } + } + + labels, err := r.labelFetcher(imageRef) + if err != nil { + return nil, fmt.Errorf("failed to fetch OCI labels for %q: %w", imageRef, err) + } + + defaultConfigName, ok := labels[OCIDefaultConfigLabel] + if !ok || defaultConfigName == "" { + return nil, fmt.Errorf("failed to find OCI label %q on image %q", OCIDefaultConfigLabel, imageRef) + } + + configLabel := OCIConfigLabelPrefix + defaultConfigName + labelValue, ok := labels[configLabel] + if !ok || labelValue == "" { + return nil, fmt.Errorf("failed to find OCI label %q on image %q (available configs: %q)", configLabel, imageRef, labels[OCIConfigListLabel]) + } + data, err := base64.StdEncoding.DecodeString(labelValue) + if err != nil { + return nil, fmt.Errorf("failed to decode base64 config from OCI label %q on %q: %w", configLabel, imageRef, err) + } + + if shouldCacheOCIConfig(imageRef) { + r.cache.set(imageRef, data) + } + + return data, nil +} + +func shouldCacheOCIConfig(imageRef string) bool { + return strings.Contains(imageRef, "@sha256:") +} + +const maxOCICacheEntries = 64 + +type ociCacheEntry struct { + key string + data []byte +} + +// ociConfigCache provides thread-safe LRU caching of OCI label configs by image reference. +type ociConfigCache struct { + mu sync.Mutex + items map[string]*list.Element + order list.List +} + +func newOCIConfigCache() *ociConfigCache { + return &ociConfigCache{items: make(map[string]*list.Element)} +} + +func (c *ociConfigCache) get(key string) ([]byte, bool) { + c.mu.Lock() + defer c.mu.Unlock() + elem, ok := c.items[key] + if !ok { + return nil, false + } + c.order.MoveToFront(elem) + entry := cacheEntry(elem) + return entry.data, true +} + +func (c *ociConfigCache) set(key string, data []byte) { + c.mu.Lock() + defer c.mu.Unlock() + if elem, ok := c.items[key]; ok { + c.order.MoveToFront(elem) + entry := cacheEntry(elem) + entry.data = data + return + } + if len(c.items) >= maxOCICacheEntries { + oldest := c.order.Back() + oldestEntry := cacheEntry(oldest) + _ = c.order.Remove(oldest) + delete(c.items, oldestEntry.key) + } + elem := c.order.PushFront(&ociCacheEntry{key: key, data: data}) + c.items[key] = elem +} + +func cacheEntry(elem *list.Element) *ociCacheEntry { + entry, ok := elem.Value.(*ociCacheEntry) + if !ok { + panic("unexpected OCI cache entry type") + } + return entry +} diff --git a/pkg/config/resource.go b/pkg/config/resource.go new file mode 100644 index 000000000..141fdeb08 --- /dev/null +++ b/pkg/config/resource.go @@ -0,0 +1,72 @@ +/* +Copyright 2025. + +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. +*/ + +package config + +import ( + "fmt" + "sort" + + ogxiov1beta1 "github.com/ogx-ai/ogx-k8s-operator/api/v1beta1" +) + +const defaultModelType = "llm" + +// ExpandResources converts the ResourcesSpec into config.yaml model entries. +// It assigns providers based on the provider map (from ExpandProviders or base config). +func ExpandResources(resources *ogxiov1beta1.ResourcesSpec, providers map[string][]ConfigProvider) ([]ConfigModel, error) { + if resources == nil { + return nil, nil + } + + var models []ConfigModel + + // Expand models + for _, m := range resources.Models { + providerID := m.Provider + if providerID == "" { + inferenceProviders := providers["inference"] + if len(inferenceProviders) == 0 { + return nil, fmt.Errorf("failed to assign provider for model %q: no explicit provider and no inference providers are configured", m.Name) + } + sorted := make([]ConfigProvider, len(inferenceProviders)) + copy(sorted, inferenceProviders) + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].ProviderID < sorted[j].ProviderID + }) + providerID = sorted[0].ProviderID + } + + model := ConfigModel{ + ModelID: m.Name, + ProviderID: providerID, + } + if m.ModelType != "" { + model.ModelType = m.ModelType + } else { + model.ModelType = defaultModelType + } + if m.ContextLength != nil { + model.ContextLength = m.ContextLength + } + if m.Quantization != "" { + model.Quantization = m.Quantization + } + models = append(models, model) + } + + return models, nil +} diff --git a/pkg/config/secret.go b/pkg/config/secret.go new file mode 100644 index 000000000..5dfefee26 --- /dev/null +++ b/pkg/config/secret.go @@ -0,0 +1,413 @@ +/* +Copyright 2025. + +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. +*/ + +package config + +import ( + "fmt" + "regexp" + "sort" + "strings" + + ogxiov1beta1 "github.com/ogx-ai/ogx-k8s-operator/api/v1beta1" + corev1 "k8s.io/api/core/v1" +) + +const envVarPrefix = "OGX" + +var envVarNameRegex = regexp.MustCompile(`^[A-Z_][A-Z0-9_]*$`) + +// CollectSecretRefs collects all secret references from the spec and returns +// the corresponding environment variable definitions for the Deployment. +func CollectSecretRefs(spec *ogxiov1beta1.OGXServerSpec) []corev1.EnvVar { + rawEnvVars := collectSecretRefsRaw(spec) + unique := make(map[string]corev1.EnvVar) + addUniqueEnvVars(unique, rawEnvVars) + + envVars := make([]corev1.EnvVar, 0, len(unique)) + for _, env := range unique { + envVars = append(envVars, env) + } + + // Sort for determinism + sort.Slice(envVars, func(i, j int) bool { + return envVars[i].Name < envVars[j].Name + }) + + return envVars +} + +func collectSecretRefsRaw(spec *ogxiov1beta1.OGXServerSpec) []corev1.EnvVar { + var envVars []corev1.EnvVar + if spec.Providers != nil { + envVars = append(envVars, collectProviderSecrets(spec.Providers)...) + } + if spec.Storage != nil { + envVars = append(envVars, collectStorageSecrets(spec.Storage)...) + } + return envVars +} + +func addUniqueEnvVars(dst map[string]corev1.EnvVar, envVars []corev1.EnvVar) { + for _, env := range envVars { + if _, exists := dst[env.Name]; !exists { + dst[env.Name] = env + } + } +} + +// collectProviderSecrets walks all provider types and collects secret references. +// NOTE: Keep in sync with buildProviderExpanders in provider.go — when adding a +// new provider API type, both functions must be updated. +func collectProviderSecrets(providers *ogxiov1beta1.ProvidersSpec) []corev1.EnvVar { + var envVars []corev1.EnvVar + + if providers.Inference != nil { + envVars = append(envVars, collectInferenceSecrets(providers.Inference)...) + } + if providers.VectorIo != nil { + envVars = append(envVars, collectVectorIOSecrets(providers.VectorIo)...) + } + if providers.ToolRuntime != nil { + envVars = append(envVars, collectToolRuntimeSecrets(providers.ToolRuntime)...) + } + if providers.Files != nil { + envVars = append(envVars, collectFilesSecrets(providers.Files)...) + } + if providers.Batches != nil { + envVars = append(envVars, collectBatchesSecrets(providers.Batches)...) + } + if providers.Responses != nil { + envVars = append(envVars, collectResponsesSecrets(providers.Responses)...) + } + if providers.FileProcessors != nil { + envVars = append(envVars, collectFileProcessorsSecrets(providers.FileProcessors)...) + } + + return envVars +} + +func collectInferenceSecrets(spec *ogxiov1beta1.InferenceProvidersSpec) []corev1.EnvVar { + var envVars []corev1.EnvVar + if spec.Remote != nil { + envVars = append(envVars, collectRemoteInferenceSecrets(spec.Remote)...) + } + if spec.Inline != nil { + for _, p := range spec.Inline.Custom { + envVars = append(envVars, collectCustomProviderSecrets(p)...) + } + } + return envVars +} + +func collectRemoteInferenceSecrets(remote *ogxiov1beta1.InferenceRemoteProviders) []corev1.EnvVar { + var envVars []corev1.EnvVar + for _, p := range remote.VLLM { + if p.APIToken != nil { + envVars = append(envVars, secretToEnvVar(p.DeriveID(), "API_TOKEN", *p.APIToken)) + } + } + for _, p := range remote.OpenAI { + envVars = append(envVars, secretToEnvVar(p.DeriveID(), "API_KEY", p.APIKey)) + } + for _, p := range remote.Azure { + envVars = append(envVars, secretToEnvVar(p.DeriveID(), "API_KEY", p.APIKey)) + } + for _, p := range remote.Bedrock { + envVars = append(envVars, collectBedrockSecrets(p)...) + } + for _, p := range remote.Watsonx { + envVars = append(envVars, secretToEnvVar(p.DeriveID(), "API_KEY", p.APIKey)) + } + for _, p := range remote.Custom { + envVars = append(envVars, collectCustomProviderSecrets(p)...) + } + return envVars +} + +func collectBedrockSecrets(p ogxiov1beta1.BedrockProvider) []corev1.EnvVar { + var envVars []corev1.EnvVar + id := p.DeriveID() + if p.APIKey != nil { + envVars = append(envVars, secretToEnvVar(id, "API_KEY", *p.APIKey)) + } + if p.AWSAccessKeyID != nil { + envVars = append(envVars, secretToEnvVar(id, "AWS_ACCESS_KEY_ID", *p.AWSAccessKeyID)) + } + if p.AWSSecretAccessKey != nil { + envVars = append(envVars, secretToEnvVar(id, "AWS_SECRET_ACCESS_KEY", *p.AWSSecretAccessKey)) + } + if p.AWSSessionToken != nil { + envVars = append(envVars, secretToEnvVar(id, "AWS_SESSION_TOKEN", *p.AWSSessionToken)) + } + return envVars +} + +func collectVectorIOSecrets(spec *ogxiov1beta1.VectorIOProvidersSpec) []corev1.EnvVar { + var envVars []corev1.EnvVar + if spec.Remote != nil { + for _, p := range spec.Remote.Pgvector { + // Password is required (not a pointer) + envVars = append(envVars, secretToEnvVar(p.DeriveID(), "PASSWORD", p.Password)) + } + for _, p := range spec.Remote.Milvus { + if p.Token != nil { + envVars = append(envVars, secretToEnvVar(p.DeriveID(), "TOKEN", *p.Token)) + } + } + for _, p := range spec.Remote.Qdrant { + if p.APIKey != nil { + envVars = append(envVars, secretToEnvVar(p.DeriveID(), "API_KEY", *p.APIKey)) + } + } + for _, p := range spec.Remote.Custom { + envVars = append(envVars, collectCustomProviderSecrets(p)...) + } + } + if spec.Inline != nil { + for _, p := range spec.Inline.Custom { + envVars = append(envVars, collectCustomProviderSecrets(p)...) + } + } + return envVars +} + +func collectToolRuntimeSecrets(spec *ogxiov1beta1.ToolRuntimeProvidersSpec) []corev1.EnvVar { + var envVars []corev1.EnvVar + if spec.Remote != nil { + for _, p := range spec.Remote.BraveSearch { + envVars = append(envVars, secretToEnvVar(p.DeriveID(), "API_KEY", p.APIKey)) + } + for _, p := range spec.Remote.TavilySearch { + envVars = append(envVars, secretToEnvVar(p.DeriveID(), "API_KEY", p.APIKey)) + } + for _, p := range spec.Remote.Custom { + envVars = append(envVars, collectCustomProviderSecrets(p)...) + } + } + if spec.Inline != nil { + for _, p := range spec.Inline.Custom { + envVars = append(envVars, collectCustomProviderSecrets(p)...) + } + } + return envVars +} + +func collectFilesSecrets(spec *ogxiov1beta1.FilesProvidersSpec) []corev1.EnvVar { + var envVars []corev1.EnvVar + if spec.Remote != nil { + if spec.Remote.S3 != nil { + p := spec.Remote.S3 + if p.AWSAccessKeyID != nil { + envVars = append(envVars, secretToEnvVar(p.DeriveID(), "AWS_ACCESS_KEY_ID", *p.AWSAccessKeyID)) + } + if p.AWSSecretAccessKey != nil { + envVars = append(envVars, secretToEnvVar(p.DeriveID(), "AWS_SECRET_ACCESS_KEY", *p.AWSSecretAccessKey)) + } + } + for _, p := range spec.Remote.Custom { + envVars = append(envVars, collectCustomProviderSecrets(p)...) + } + } + if spec.Inline != nil { + for _, p := range spec.Inline.Custom { + envVars = append(envVars, collectCustomProviderSecrets(p)...) + } + } + return envVars +} + +func collectFileProcessorsSecrets(spec *ogxiov1beta1.FileProcessorsProvidersSpec) []corev1.EnvVar { + var envVars []corev1.EnvVar + if spec.Remote != nil { + if spec.Remote.DoclingServe != nil && spec.Remote.DoclingServe.APIKey != nil { + envVars = append(envVars, secretToEnvVar(spec.Remote.DoclingServe.DeriveID(), "API_KEY", *spec.Remote.DoclingServe.APIKey)) + } + for _, p := range spec.Remote.Custom { + envVars = append(envVars, collectCustomProviderSecrets(p)...) + } + } + if spec.Inline != nil { + for _, p := range spec.Inline.Custom { + envVars = append(envVars, collectCustomProviderSecrets(p)...) + } + } + return envVars +} + +func collectBatchesSecrets(spec *ogxiov1beta1.BatchesProvidersSpec) []corev1.EnvVar { + var remote, inline []ogxiov1beta1.CustomProvider + if spec.Remote != nil { + remote = spec.Remote.Custom + } + if spec.Inline != nil { + inline = spec.Inline.Custom + } + return collectCustomSecretsFromSlices(remote, inline) +} + +func collectResponsesSecrets(spec *ogxiov1beta1.ResponsesProvidersSpec) []corev1.EnvVar { + var remote, inline []ogxiov1beta1.CustomProvider + if spec.Remote != nil { + remote = spec.Remote.Custom + } + if spec.Inline != nil { + inline = spec.Inline.Custom + } + return collectCustomSecretsFromSlices(remote, inline) +} + +// collectCustomSecretsFromSlices collects secrets from remote and inline custom +// provider slices. Used by API types that only support custom providers. +func collectCustomSecretsFromSlices(remote, inline []ogxiov1beta1.CustomProvider) []corev1.EnvVar { + envVars := make([]corev1.EnvVar, 0, len(remote)+len(inline)) + for _, p := range remote { + envVars = append(envVars, collectCustomProviderSecrets(p)...) + } + for _, p := range inline { + envVars = append(envVars, collectCustomProviderSecrets(p)...) + } + return envVars +} + +func collectCustomProviderSecrets(p ogxiov1beta1.CustomProvider) []corev1.EnvVar { + envVars := make([]corev1.EnvVar, 0, len(p.SecretRefs)) + keys := make([]string, 0, len(p.SecretRefs)) + for k := range p.SecretRefs { + keys = append(keys, k) + } + sort.Strings(keys) + for _, key := range keys { + ref := p.SecretRefs[key] + envVars = append(envVars, secretToEnvVar(p.DeriveID(), normalizeEnvVarField(key), ref)) + } + return envVars +} + +func collectStorageSecrets(storage *ogxiov1beta1.StateStorageSpec) []corev1.EnvVar { + var envVars []corev1.EnvVar + if storage.KV != nil && storage.KV.Password != nil { + envVars = append(envVars, corev1.EnvVar{ + Name: fmt.Sprintf("%s_STORAGE_KV_PASSWORD", envVarPrefix), + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: storage.KV.Password.Name}, + Key: storage.KV.Password.Key, + }, + }, + }) + } + if storage.SQL != nil && storage.SQL.ConnectionString != nil { + envVars = append(envVars, corev1.EnvVar{ + Name: fmt.Sprintf("%s_STORAGE_SQL_CONNECTION_STRING", envVarPrefix), + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: storage.SQL.ConnectionString.Name}, + Key: storage.SQL.ConnectionString.Key, + }, + }, + }) + } + return envVars +} + +func secretToEnvVar(providerID, field string, ref ogxiov1beta1.SecretKeyRef) corev1.EnvVar { + return corev1.EnvVar{ + Name: buildEnvVarName(providerID, field), + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: ref.Name}, + Key: ref.Key, + }, + }, + } +} + +// buildEnvVarName constructs the env var name: OGX__. +func buildEnvVarName(providerID, field string) string { + return fmt.Sprintf("%s_%s_%s", envVarPrefix, normalizeEnvVarField(providerID), field) +} + +// envVarRef returns the config.yaml env substitution reference string. +func envVarRef(providerID, field string) string { + return fmt.Sprintf("${env.%s}", buildEnvVarName(providerID, field)) +} + +// ValidateSecretRefEnvVarNames validates generated env var names and reports collisions. +func ValidateSecretRefEnvVarNames(spec *ogxiov1beta1.OGXServerSpec) error { + envVars := collectSecretRefsRaw(spec) + seen := make(map[string]corev1.SecretKeySelector, len(envVars)) + for _, env := range envVars { + if !envVarNameRegex.MatchString(env.Name) { + return fmt.Errorf("failed to validate generated env var name %q", env.Name) + } + if env.ValueFrom == nil || env.ValueFrom.SecretKeyRef == nil { + continue + } + + ref := *env.ValueFrom.SecretKeyRef + if existing, ok := seen[env.Name]; ok { + if existing.Name != ref.Name || existing.Key != ref.Key { + return fmt.Errorf( + "failed to validate env var name collision for %q: %s/%s conflicts with %s/%s", + env.Name, existing.Name, existing.Key, ref.Name, ref.Key, + ) + } + continue + } + seen[env.Name] = ref + } + return nil +} + +// normalizeEnvVarField uppercases and replaces non-alphanumeric characters with underscores. +func normalizeEnvVarField(s string) string { + upper := strings.ToUpper(s) + out := trimAndCollapseToUnderscore(upper) + return ensureValidEnvVarPrefix(out) +} + +func trimAndCollapseToUnderscore(s string) string { + var b strings.Builder + b.Grow(len(s)) + lastUnderscore := false + for _, r := range s { + if isEnvVarAlphaNum(r) { + b.WriteRune(r) + lastUnderscore = false + continue + } + if !lastUnderscore { + b.WriteByte('_') + lastUnderscore = true + } + } + return strings.Trim(b.String(), "_") +} + +func ensureValidEnvVarPrefix(s string) string { + if s == "" { + return "_" + } + if s[0] >= '0' && s[0] <= '9' { + return "_" + s + } + return s +} + +func isEnvVarAlphaNum(r rune) bool { + return (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') +} diff --git a/pkg/config/storage.go b/pkg/config/storage.go new file mode 100644 index 000000000..4bb2ba565 --- /dev/null +++ b/pkg/config/storage.go @@ -0,0 +1,114 @@ +/* +Copyright 2025. + +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. +*/ + +package config + +import ( + "fmt" + + ogxiov1beta1 "github.com/ogx-ai/ogx-k8s-operator/api/v1beta1" +) + +// ApplyStorage generates the storage section for config.yaml based on the spec. +// Returns nil if no storage is configured (base config storage is preserved). +func ApplyStorage(storage *ogxiov1beta1.StateStorageSpec) map[string]interface{} { + if storage == nil { + return nil + } + + backends := make(map[string]interface{}) + stores := defaultStores() + + if storage.KV != nil { + backends["kv_default"] = expandKVBackend(storage.KV) + } else { + backends["kv_default"] = defaultKVBackend() + } + + if storage.SQL != nil { + backends["sql_default"] = expandSQLBackend(storage.SQL) + } else { + backends["sql_default"] = defaultSQLBackend() + } + + return map[string]interface{}{ + "backends": backends, + "stores": stores, + } +} + +func expandKVBackend(kv *ogxiov1beta1.KVStorageSpec) map[string]interface{} { + switch kv.Type { + case "redis": + cfg := map[string]interface{}{ + "type": "kv_redis", + "host": kv.Endpoint, + } + if kv.Password != nil { + cfg["password"] = fmt.Sprintf("${env.%s_STORAGE_KV_PASSWORD}", envVarPrefix) + } + return cfg + default: // sqlite + return defaultKVBackend() + } +} + +func expandSQLBackend(sql *ogxiov1beta1.SQLStorageSpec) map[string]interface{} { + switch sql.Type { + case "postgres": + return map[string]interface{}{ + "type": "sql_postgres", + "connection_string": fmt.Sprintf("${env.%s_STORAGE_SQL_CONNECTION_STRING}", envVarPrefix), + } + default: // sqlite + return defaultSQLBackend() + } +} + +func defaultKVBackend() map[string]interface{} { + return map[string]interface{}{ + "type": "kv_sqlite", + "db_path": "${env.SQLITE_STORE_DIR:=/.ogx}/kvstore.db", + } +} + +func defaultSQLBackend() map[string]interface{} { + return map[string]interface{}{ + "type": "sql_sqlite", + "db_path": "${env.SQLITE_STORE_DIR:=/.ogx}/sqlstore.db", + } +} + +func defaultStores() map[string]interface{} { + return map[string]interface{}{ + "metadata": map[string]interface{}{ + "backend": "kv_default", + "namespace": "registry", + }, + "inference": map[string]interface{}{ + "backend": "sql_default", + "table_name": "inference_store", + }, + "conversations": map[string]interface{}{ + "backend": "sql_default", + "table_name": "openai_conversations", + }, + "prompts": map[string]interface{}{ + "backend": "kv_default", + "namespace": "prompts", + }, + } +} diff --git a/pkg/config/types.go b/pkg/config/types.go new file mode 100644 index 000000000..1b98c454b --- /dev/null +++ b/pkg/config/types.go @@ -0,0 +1,73 @@ +/* +Copyright 2025. + +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. +*/ + +package config + +import corev1 "k8s.io/api/core/v1" + +// GeneratedConfig is the output of the config generation pipeline. +type GeneratedConfig struct { + // ConfigYAML is the final config.yaml content. + ConfigYAML string + // ContentHash is a short SHA-256 prefix of ConfigYAML (8 bytes / 16 hex characters). + ContentHash string + // EnvVars are environment variable definitions to set on the Deployment container. + EnvVars []corev1.EnvVar + // ProviderCount is the number of configured providers. + ProviderCount int + // ResourceCount is the number of registered model resources. + ResourceCount int + // ConfigVersion is the detected config.yaml schema version. + ConfigVersion int + // ConfigVersionDefaulted is true when the version string was non-numeric + // and ConfigVersion was defaulted to 2. Callers should log a warning. + ConfigVersionDefaulted bool +} + +// ConfigProvider represents a provider in config.yaml format. +type ConfigProvider struct { + ProviderID string `yaml:"provider_id"` + ProviderType string `yaml:"provider_type"` + Config map[string]interface{} `yaml:"config"` +} + +// ConfigModel represents a model in config.yaml format. +type ConfigModel struct { + ModelID string `yaml:"model_id"` + ProviderID string `yaml:"provider_id"` + ModelType string `yaml:"model_type,omitempty"` + ContextLength *int `yaml:"context_length,omitempty"` + Quantization string `yaml:"quantization,omitempty"` +} + +// RegisteredResources represents the registered_resources section of config.yaml. +type RegisteredResources struct { + Models []interface{} `yaml:"models,omitempty"` + VectorStores []interface{} `yaml:"vector_stores,omitempty"` +} + +// BaseConfig represents the parsed base config.yaml structure. +type BaseConfig struct { + Version string `yaml:"version"` + DistroName string `yaml:"distro_name,omitempty"` + ImageName string `yaml:"image_name,omitempty"` + APIs []string `yaml:"apis,omitempty"` + Providers map[string][]ConfigProvider `yaml:"providers,omitempty"` + RegisteredResources *RegisteredResources `yaml:"registered_resources,omitempty"` + VectorStores map[string]interface{} `yaml:"vector_stores,omitempty"` + Server map[string]interface{} `yaml:"server,omitempty"` + Storage map[string]interface{} `yaml:"storage,omitempty"` +} diff --git a/pkg/deploy/deploy.go b/pkg/deploy/deploy.go index 2648336c9..bca97b59d 100644 --- a/pkg/deploy/deploy.go +++ b/pkg/deploy/deploy.go @@ -6,7 +6,7 @@ import ( "reflect" "github.com/go-logr/logr" - llamav1alpha1 "github.com/llamastack/llama-stack-k8s-operator/api/v1alpha1" + ogxiov1beta1 "github.com/ogx-ai/ogx-k8s-operator/api/v1beta1" appsv1 "k8s.io/api/apps/v1" "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime" @@ -16,7 +16,7 @@ import ( // ApplyDeployment creates or updates the Deployment. func ApplyDeployment(ctx context.Context, cli client.Client, scheme *runtime.Scheme, - instance *llamav1alpha1.LlamaStackDistribution, deployment *appsv1.Deployment, logger logr.Logger) error { + instance *ogxiov1beta1.OGXServer, deployment *appsv1.Deployment, logger logr.Logger) error { if err := ctrl.SetControllerReference(instance, deployment, scheme); err != nil { return fmt.Errorf("failed to set controller reference: %w", err) } @@ -30,7 +30,8 @@ func ApplyDeployment(ctx context.Context, cli client.Client, scheme *runtime.Sch return fmt.Errorf("failed to fetch deployment: %w", err) } - if isAutoscalingEnabled(instance) { + if instance != nil && instance.Spec.Workload != nil && instance.Spec.Workload.Autoscaling != nil && + instance.Spec.Workload.Autoscaling.MaxReplicas > 0 { deployment.Spec.Replicas = found.Spec.Replicas } @@ -43,7 +44,7 @@ func ApplyDeployment(ctx context.Context, cli client.Client, scheme *runtime.Sch // Use server-side apply to merge changes properly // Ensure the deployment has proper TypeMeta for server-side apply deployment.SetGroupVersionKind(appsv1.SchemeGroupVersion.WithKind("Deployment")) - return cli.Patch(ctx, deployment, client.Apply, client.ForceOwnership, client.FieldOwner("llama-stack-operator")) + return cli.Patch(ctx, deployment, client.Apply, client.ForceOwnership, client.FieldOwner("ogx-operator")) } return nil } diff --git a/pkg/deploy/deploy_test.go b/pkg/deploy/deploy_test.go index 9a9ce0337..1fb82fb70 100644 --- a/pkg/deploy/deploy_test.go +++ b/pkg/deploy/deploy_test.go @@ -3,7 +3,7 @@ package deploy import ( "testing" - llamav1alpha1 "github.com/llamastack/llama-stack-k8s-operator/api/v1alpha1" + ogxiov1beta1 "github.com/ogx-ai/ogx-k8s-operator/api/v1beta1" "github.com/stretchr/testify/require" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" @@ -16,12 +16,15 @@ func TestApplyDeploymentPreservesSelector(t *testing.T) { ctx := t.Context() logger := logf.Log.WithName("test-apply-deployment") - instance := &llamav1alpha1.LlamaStackDistribution{ + instance := &ogxiov1beta1.OGXServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-instance", Namespace: "default", UID: "test-uid", }, + Spec: ogxiov1beta1.OGXServerSpec{ + Distribution: ogxiov1beta1.DistributionSpec{Name: "test"}, + }, } deploymentName := "test-deployment-selector" @@ -44,8 +47,8 @@ func TestApplyDeploymentPreservesSelector(t *testing.T) { Spec: corev1.PodSpec{ Containers: []corev1.Container{ { - Name: "llamastack", - Image: "quay.io/llamastack/llama-stack-k8s-operator:v0.0.1", + Name: "ogx", + Image: "quay.io/ogx-ai/ogx-k8s-operator:v0.0.1", }, }, }, @@ -81,8 +84,8 @@ func TestApplyDeploymentPreservesSelector(t *testing.T) { Spec: corev1.PodSpec{ Containers: []corev1.Container{ { - Name: "llamastack", - Image: "quay.io/llamastack/llama-stack-k8s-operator:v0.0.2", + Name: "ogx", + Image: "quay.io/ogx-ai/ogx-k8s-operator:v0.0.2", }, }, }, @@ -101,7 +104,7 @@ func TestApplyDeploymentPreservesSelector(t *testing.T) { require.Equal(t, "initial", foundDeployment.Spec.Selector.MatchLabels["app"]) // And the other updates should be applied - require.Equal(t, "quay.io/llamastack/llama-stack-k8s-operator:v0.0.2", foundDeployment.Spec.Template.Spec.Containers[0].Image) + require.Equal(t, "quay.io/ogx-ai/ogx-k8s-operator:v0.0.2", foundDeployment.Spec.Template.Spec.Containers[0].Image) } func TestApplyDeploymentDoesNotOverrideHPAScale(t *testing.T) { @@ -109,16 +112,18 @@ func TestApplyDeploymentDoesNotOverrideHPAScale(t *testing.T) { logger := logf.Log.WithName("test-apply-deployment-hpa") minReplicas := int32(1) - instance := &llamav1alpha1.LlamaStackDistribution{ + replicas := int32(1) + instance := &ogxiov1beta1.OGXServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-instance-hpa", Namespace: "default", UID: "test-uid-hpa", }, - Spec: llamav1alpha1.LlamaStackDistributionSpec{ - Replicas: 1, - Server: llamav1alpha1.ServerSpec{ - Autoscaling: &llamav1alpha1.AutoscalingSpec{ + Spec: ogxiov1beta1.OGXServerSpec{ + Distribution: ogxiov1beta1.DistributionSpec{Name: "test"}, + Workload: &ogxiov1beta1.WorkloadSpec{ + Replicas: &replicas, + Autoscaling: &ogxiov1beta1.AutoscalingSpec{ MinReplicas: &minReplicas, MaxReplicas: 5, }, @@ -147,8 +152,8 @@ func TestApplyDeploymentDoesNotOverrideHPAScale(t *testing.T) { Spec: corev1.PodSpec{ Containers: []corev1.Container{ { - Name: "llamastack", - Image: "quay.io/llamastack/llama-stack-k8s-operator:v0.0.1", + Name: "ogx", + Image: "quay.io/ogx-ai/ogx-k8s-operator:v0.0.1", }, }, }, diff --git a/pkg/deploy/kustomizer.go b/pkg/deploy/kustomizer.go index 638033960..f64e3f3ed 100644 --- a/pkg/deploy/kustomizer.go +++ b/pkg/deploy/kustomizer.go @@ -9,9 +9,9 @@ import ( "slices" "sort" - llamav1alpha1 "github.com/llamastack/llama-stack-k8s-operator/api/v1alpha1" - "github.com/llamastack/llama-stack-k8s-operator/pkg/compare" - "github.com/llamastack/llama-stack-k8s-operator/pkg/deploy/plugins" + ogxiov1beta1 "github.com/ogx-ai/ogx-k8s-operator/api/v1beta1" + "github.com/ogx-ai/ogx-k8s-operator/pkg/compare" + "github.com/ogx-ai/ogx-k8s-operator/pkg/deploy/plugins" appsv1 "k8s.io/api/apps/v1" autoscalingv2 "k8s.io/api/autoscaling/v2" corev1 "k8s.io/api/core/v1" @@ -41,7 +41,7 @@ const deploymentKind = "Deployment" func RenderManifest( fs filesys.FileSystem, manifestPath string, - ownerInstance *llamav1alpha1.LlamaStackDistribution, + ownerInstance *ogxiov1beta1.OGXServer, ) (*resmap.ResMap, error) { // fallback to the 'default' directory' if we cannot initially find // the kustomization file @@ -67,7 +67,7 @@ func ApplyResources( ctx context.Context, cli client.Client, scheme *runtime.Scheme, - ownerInstance *llamav1alpha1.LlamaStackDistribution, + ownerInstance *ogxiov1beta1.OGXServer, resMap *resmap.ResMap, ) error { for _, res := range (*resMap).Resources() { @@ -85,10 +85,10 @@ func manageResource( cli client.Client, scheme *runtime.Scheme, res *resource.Resource, - ownerInstance *llamav1alpha1.LlamaStackDistribution, + ownerInstance *ogxiov1beta1.OGXServer, ) error { // prevent the controller from trying to apply changes to its own CR - if res.GetKind() == llamav1alpha1.LlamaStackDistributionKind && res.GetName() == ownerInstance.Name && res.GetNamespace() == ownerInstance.Namespace { + if res.GetKind() == ogxiov1beta1.OGXServerKind && res.GetName() == ownerInstance.Name && res.GetNamespace() == ownerInstance.Namespace { return nil } @@ -127,21 +127,22 @@ func manageResource( } // createResource creates a new resource, setting an owner reference only if it's namespace-scoped. +// PersistentVolumeClaims are intentionally excluded from ownerRef to prevent +// data loss on CR deletion — PVCs must be cleaned up explicitly by users. func createResource( ctx context.Context, cli client.Client, obj *unstructured.Unstructured, - ownerInstance *llamav1alpha1.LlamaStackDistribution, + ownerInstance *ogxiov1beta1.OGXServer, scheme *runtime.Scheme, gvk schema.GroupVersionKind, ) error { - // Check if the resource is cluster-scoped (like a ClusterRole) to avoid - // incorrectly setting a namespace-bound owner reference on it. isClusterScoped, err := isClusterScoped(cli.RESTMapper(), gvk) if err != nil { return fmt.Errorf("failed to determine resource scope: %w", err) } - if !isClusterScoped { + skipOwnerRef := isClusterScoped || gvk.Kind == "PersistentVolumeClaim" + if !skipOwnerRef { if err := ctrl.SetControllerReference(ownerInstance, obj, scheme); err != nil { return fmt.Errorf("failed to set controller reference for %s: %w", gvk.Kind, err) } @@ -159,7 +160,7 @@ func isClusterScoped(mapper meta.RESTMapper, gvk schema.GroupVersionKind) (bool, } // patchResource patches an existing resource, but only if we own it. -func patchResource(ctx context.Context, cli client.Client, desired, existing *unstructured.Unstructured, ownerInstance *llamav1alpha1.LlamaStackDistribution) error { +func patchResource(ctx context.Context, cli client.Client, desired, existing *unstructured.Unstructured, ownerInstance *ogxiov1beta1.OGXServer) error { logger := log.FromContext(ctx) // Critical safety check to prevent the operator from "stealing" or @@ -213,12 +214,12 @@ func patchResource(ctx context.Context, cli client.Client, desired, existing *un existing, client.RawPatch(k8stypes.ApplyPatchType, data), client.ForceOwnership, - client.FieldOwner(ownerInstance.GetName()), + client.FieldOwner("ogx-operator"), ) } // applyPlugins runs all Go-based transformations on the resource map. -func applyPlugins(resMap *resmap.ResMap, ownerInstance *llamav1alpha1.LlamaStackDistribution) error { +func applyPlugins(resMap *resmap.ResMap, ownerInstance *ogxiov1beta1.OGXServer) error { namePrefixPlugin := plugins.CreateNamePrefixPlugin(plugins.NamePrefixConfig{ Prefix: ownerInstance.GetName(), // Exclude Deployment to maintain backward compatibility with existing deployment names @@ -258,10 +259,10 @@ func applyPlugins(resMap *resmap.ResMap, ownerInstance *llamav1alpha1.LlamaStack } // applyNetworkPolicyTransformer applies the NetworkPolicy transformer plugin. -func applyNetworkPolicyTransformer(resMap *resmap.ResMap, ownerInstance *llamav1alpha1.LlamaStackDistribution) error { +func applyNetworkPolicyTransformer(resMap *resmap.ResMap, ownerInstance *ogxiov1beta1.OGXServer) error { operatorNS, err := GetOperatorNamespace() if err != nil { - operatorNS = "llama-stack-k8s-operator-system" + operatorNS = "ogx-k8s-operator-system" } npTransformer := plugins.CreateNetworkPolicyTransformer(plugins.NetworkPolicyTransformerConfig{ @@ -307,7 +308,7 @@ func removeDeploymentReplicas(resMap resmap.ResMap) error { } // getFieldMappings returns essential field mappings for kustomize transformation. -func getFieldMappings(ownerInstance *llamav1alpha1.LlamaStackDistribution) []plugins.FieldMapping { +func getFieldMappings(ownerInstance *ogxiov1beta1.OGXServer) []plugins.FieldMapping { instanceName := ownerInstance.GetName() instanceNamespace := ownerInstance.GetNamespace() serviceAccountName := instanceName + "-sa" @@ -315,11 +316,11 @@ func getFieldMappings(ownerInstance *llamav1alpha1.LlamaStackDistribution) []plu storageSize := getStorageSize(ownerInstance) instanceLabelPath := "/app.kubernetes.io~1instance" - mappings := buildFieldMappings(instanceName, instanceNamespace, serviceAccountName, servicePort, storageSize, instanceLabelPath, ownerInstance.Spec.Replicas) + mappings := buildFieldMappings(instanceName, instanceNamespace, serviceAccountName, servicePort, storageSize, instanceLabelPath, GetEffectiveReplicas(ownerInstance)) // When persistent storage is configured, use Recreate strategy to avoid // RWO PVC multi-attach deadlock during rolling updates - if ownerInstance.Spec.Server.Storage != nil { + if ownerInstance.Spec.Workload != nil && ownerInstance.Spec.Workload.Storage != nil { mappings = append(mappings, plugins.FieldMapping{ SourceValue: "Recreate", TargetField: "/spec/strategy/type", @@ -338,21 +339,21 @@ func buildFieldMappings(instanceName, instanceNamespace, serviceAccountName stri return []plugins.FieldMapping{ { SourceValue: storageSize, - DefaultValue: llamav1alpha1.DefaultStorageSize.String(), + DefaultValue: ogxiov1beta1.DefaultStorageSize.String(), TargetField: "/spec/resources/requests/storage", TargetKind: "PersistentVolumeClaim", CreateIfNotExists: true, }, { SourceValue: servicePort, - DefaultValue: llamav1alpha1.DefaultServerPort, + DefaultValue: ogxiov1beta1.DefaultServerPort, TargetField: "/spec/ports/0/port", TargetKind: "Service", CreateIfNotExists: true, }, { SourceValue: servicePort, - DefaultValue: llamav1alpha1.DefaultServerPort, + DefaultValue: ogxiov1beta1.DefaultServerPort, TargetField: "/spec/ports/0/targetPort", TargetKind: "Service", CreateIfNotExists: true, @@ -421,29 +422,29 @@ func buildFieldMappings(instanceName, instanceNamespace, serviceAccountName stri } // getStorageSize extracts the storage size from the CR spec. -func getStorageSize(instance *llamav1alpha1.LlamaStackDistribution) string { - if instance.Spec.Server.Storage != nil && instance.Spec.Server.Storage.Size != nil { - return instance.Spec.Server.Storage.Size.String() +func getStorageSize(instance *ogxiov1beta1.OGXServer) string { + if instance.Spec.Workload != nil && instance.Spec.Workload.Storage != nil && instance.Spec.Workload.Storage.Size != nil { + return instance.Spec.Workload.Storage.Size.String() } // Returning an empty string signals the field transformer to use the default value. return "" } // getServicePort returns the service port or nil if not specified. -func getServicePort(instance *llamav1alpha1.LlamaStackDistribution) any { - if instance.Spec.Server.ContainerSpec.Port != 0 { - return instance.Spec.Server.ContainerSpec.Port +func getServicePort(instance *ogxiov1beta1.OGXServer) any { + if instance.Spec.Network != nil && instance.Spec.Network.Port != 0 { + return instance.Spec.Network.Port } // Returning nil signals the field transformer to use the default value. return nil } -func isAutoscalingEnabled(instance *llamav1alpha1.LlamaStackDistribution) bool { - if instance == nil || instance.Spec.Server.Autoscaling == nil { +func isAutoscalingEnabled(instance *ogxiov1beta1.OGXServer) bool { + if instance == nil || instance.Spec.Workload == nil || instance.Spec.Workload.Autoscaling == nil { return false } - return instance.Spec.Server.Autoscaling.MaxReplicas > 0 + return instance.Spec.Workload.Autoscaling.MaxReplicas > 0 } // ManifestContext provides the necessary context for complex resource rendering. @@ -451,6 +452,7 @@ type ManifestContext struct { ResolvedImage string ConfigMapHash string CABundleHash string + SecretHash string ContainerSpec map[string]any PodSpec map[string]any PodDisruptionBudgetSpec *policyv1.PodDisruptionBudgetSpec @@ -461,7 +463,7 @@ type ManifestContext struct { func RenderManifestWithContext( fs filesys.FileSystem, manifestsPath string, - ownerInstance *llamav1alpha1.LlamaStackDistribution, + ownerInstance *ogxiov1beta1.OGXServer, manifestCtx *ManifestContext, ) (*resmap.ResMap, error) { // First, render the base manifests @@ -598,6 +600,9 @@ func addConfigMapAnnotations(data map[string]any, manifestCtx *ManifestContext) if manifestCtx.CABundleHash != "" { annotations["configmap.hash/ca-bundle"] = manifestCtx.CABundleHash } + if manifestCtx.SecretHash != "" { + annotations["secret.hash/referenced"] = manifestCtx.SecretHash + } return nil } @@ -741,7 +746,7 @@ func hasLegacyCABundleVolumes(ctx context.Context, deployment *unstructured.Unst // hasStaleUserConfigVolume returns true when the existing Deployment has a "user-config" // volume that is absent from the desired Deployment spec. This happens when -// spec.server.userConfig is removed from the LLSD resource: the volume persists because +// spec.overrideConfig is removed from the OGXServer resource: the volume persists because // it was applied via cli.Create (no SSA field manager tracking), so a subsequent SSA patch // cannot remove it. Using cli.Update instead performs a full spec replacement. func hasStaleUserConfigVolume(desired, existing *appsv1.Deployment) bool { @@ -804,3 +809,41 @@ func CheckClusterRoleExists(ctx context.Context, cli client.Client, crb *unstruc } return false, nil } + +// CheckCRDExists checks whether a CustomResourceDefinition is registered on the cluster. +// Returns true if the CRD exists, false if not found. +func CheckCRDExists(ctx context.Context, cli client.Client, crdName string) (bool, error) { + crd := &unstructured.Unstructured{} + crd.SetGroupVersionKind(schema.GroupVersionKind{ + Group: "apiextensions.k8s.io", + Version: "v1", + Kind: "CustomResourceDefinition", + }) + + err := cli.Get(ctx, client.ObjectKey{Name: crdName}, crd) + if err != nil { + if k8serr.IsNotFound(err) { + return false, nil + } + return false, err + } + return true, nil +} + +// MonitoringCRDsAvailable checks whether the prometheus-operator CRDs +// (ServiceMonitor and PrometheusRule) are registered on the cluster. +func MonitoringCRDsAvailable(ctx context.Context, cli client.Client) (bool, error) { + for _, crdName := range []string{ + "servicemonitors.monitoring.coreos.com", + "prometheusrules.monitoring.coreos.com", + } { + exists, err := CheckCRDExists(ctx, cli, crdName) + if err != nil { + return false, fmt.Errorf("failed to check CRD %s: %w", crdName, err) + } + if !exists { + return false, nil + } + } + return true, nil +} diff --git a/pkg/deploy/kustomizer_test.go b/pkg/deploy/kustomizer_test.go index 3d2ca9548..1a2a8d974 100644 --- a/pkg/deploy/kustomizer_test.go +++ b/pkg/deploy/kustomizer_test.go @@ -6,8 +6,8 @@ import ( "testing" "time" - llamav1alpha1 "github.com/llamastack/llama-stack-k8s-operator/api/v1alpha1" - "github.com/llamastack/llama-stack-k8s-operator/pkg/deploy/plugins" + ogxiov1beta1 "github.com/ogx-ai/ogx-k8s-operator/api/v1beta1" + "github.com/ogx-ai/ogx-k8s-operator/pkg/deploy/plugins" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" appsv1 "k8s.io/api/apps/v1" @@ -28,7 +28,7 @@ import ( const manifestBasePath = "manifests/base" -func setupApplyResourcesTest(t *testing.T, ownerName string) (context.Context, string, *llamav1alpha1.LlamaStackDistribution) { +func setupApplyResourcesTest(t *testing.T, ownerName string) (context.Context, string, *ogxiov1beta1.OGXServer) { t.Helper() ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second) // to avlid client rate limit due to too many test in parallel @@ -42,22 +42,27 @@ func setupApplyResourcesTest(t *testing.T, ownerName string) (context.Context, s require.NoError(t, k8sClient.Delete(context.Background(), ns)) }) - owner := &llamav1alpha1.LlamaStackDistribution{ + owner := &ogxiov1beta1.OGXServer{ TypeMeta: metav1.TypeMeta{ - APIVersion: "llamastack.io/v1alpha1", - Kind: "LlamaStackDistribution", + APIVersion: "ogx.io/v1beta1", + Kind: "OGXServer", }, ObjectMeta: metav1.ObjectMeta{ Name: ownerName, Namespace: testNs, }, + Spec: ogxiov1beta1.OGXServerSpec{ + Distribution: ogxiov1beta1.DistributionSpec{ + Name: "starter", + }, + }, } ownerGVK := owner.GroupVersionKind() require.NoError(t, k8sClient.Create(ctx, owner)) require.NotEmpty(t, owner.UID) - createdOwner := &llamav1alpha1.LlamaStackDistribution{} + createdOwner := &ogxiov1beta1.OGXServer{} require.NoError(t, k8sClient.Get(ctx, types.NamespacedName{Name: owner.Name, Namespace: owner.Namespace}, createdOwner)) createdOwner.SetGroupVersionKind(ownerGVK) @@ -95,12 +100,14 @@ spec: // given an owner with an empty spec to verify that the default value logic // in the field transformer plugin is correctly triggered - owner := &llamav1alpha1.LlamaStackDistribution{ + owner := &ogxiov1beta1.OGXServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-instance", Namespace: "test-render-ns", }, - Spec: llamav1alpha1.LlamaStackDistributionSpec{}, + Spec: ogxiov1beta1.OGXServerSpec{ + Distribution: ogxiov1beta1.DistributionSpec{Image: "test-image:latest"}, + }, } // when we call RenderManifest @@ -145,7 +152,7 @@ metadata: name: deployment` require.NoError(t, fsys.WriteFile(filepath.Join(defaultPath, "deployment.yaml"), []byte(deploymentContent))) - owner := &llamav1alpha1.LlamaStackDistribution{ + owner := &ogxiov1beta1.OGXServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-instance", Namespace: "test-fallback-ns", @@ -177,7 +184,7 @@ resources: ` require.NoError(t, fsys.WriteFile(filepath.Join(manifestBasePath, "kustomization.yaml"), []byte(kustomizationContent))) - owner := &llamav1alpha1.LlamaStackDistribution{ + owner := &ogxiov1beta1.OGXServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-instance", Namespace: "test-error-ns", @@ -310,21 +317,26 @@ func TestApplyResources(t *testing.T) { // given ctx, testNs, owner := setupApplyResourcesTest(t, "does-not-steal-owner") - ownerOther := &llamav1alpha1.LlamaStackDistribution{ + ownerOther := &ogxiov1beta1.OGXServer{ TypeMeta: metav1.TypeMeta{ - APIVersion: "llamastack.io/v1alpha1", - Kind: "LlamaStackDistribution", + APIVersion: "ogx.io/v1beta1", + Kind: "OGXServer", }, ObjectMeta: metav1.ObjectMeta{ Name: "test-owner-other", Namespace: testNs, }, + Spec: ogxiov1beta1.OGXServerSpec{ + Distribution: ogxiov1beta1.DistributionSpec{ + Name: "starter", + }, + }, } require.NoError(t, k8sClient.Create(ctx, ownerOther)) - createdOwnerOther := &llamav1alpha1.LlamaStackDistribution{} + createdOwnerOther := &ogxiov1beta1.OGXServer{} require.NoError(t, k8sClient.Get(ctx, client.ObjectKeyFromObject(ownerOther), createdOwnerOther)) - createdOwnerOther.SetGroupVersionKind(llamav1alpha1.GroupVersion.WithKind("LlamaStackDistribution")) + createdOwnerOther.SetGroupVersionKind(ogxiov1beta1.GroupVersion.WithKind("OGXServer")) existingSvc := &corev1.Service{ ObjectMeta: metav1.ObjectMeta{ @@ -539,13 +551,9 @@ func TestFilterExcludeKinds(t *testing.T) { func TestSetDefaultPort(t *testing.T) { // arrange // instance with no custom port and service with empty port values - instance := &llamav1alpha1.LlamaStackDistribution{ - Spec: llamav1alpha1.LlamaStackDistributionSpec{ - Server: llamav1alpha1.ServerSpec{ - ContainerSpec: llamav1alpha1.ContainerSpec{ - Port: 0, // no port configured - }, - }, + instance := &ogxiov1beta1.OGXServer{ + Spec: ogxiov1beta1.OGXServerSpec{ + Distribution: ogxiov1beta1.DistributionSpec{Image: "test-image:latest"}, }, } @@ -559,7 +567,7 @@ func TestSetDefaultPort(t *testing.T) { Mappings: []plugins.FieldMapping{ { SourceValue: getServicePort(instance), // tests getServicePort() integration with kustomizer - DefaultValue: llamav1alpha1.DefaultServerPort, + DefaultValue: ogxiov1beta1.DefaultServerPort, TargetField: "/spec/ports/0/port", TargetKind: "Service", CreateIfNotExists: true, @@ -583,20 +591,21 @@ func TestSetDefaultPort(t *testing.T) { require.True(t, ok) actualPort, ok := ports[0].(map[string]any)["port"] require.True(t, ok) - require.Equal(t, int(llamav1alpha1.DefaultServerPort), actualPort) + require.Equal(t, int(ogxiov1beta1.DefaultServerPort), actualPort) } func TestRemoveDeploymentReplicas(t *testing.T) { t.Parallel() - instance := &llamav1alpha1.LlamaStackDistribution{ + instance := &ogxiov1beta1.OGXServer{ ObjectMeta: metav1.ObjectMeta{ Name: "example", Namespace: "llama", }, - Spec: llamav1alpha1.LlamaStackDistributionSpec{ - Server: llamav1alpha1.ServerSpec{ - Autoscaling: &llamav1alpha1.AutoscalingSpec{ + Spec: ogxiov1beta1.OGXServerSpec{ + Distribution: ogxiov1beta1.DistributionSpec{Image: "test-image:latest"}, + Workload: &ogxiov1beta1.WorkloadSpec{ + Autoscaling: &ogxiov1beta1.AutoscalingSpec{ MaxReplicas: 5, }, }, @@ -1056,12 +1065,13 @@ func ptr[T any](v T) *T { func TestGetFieldMappings_RecreateStrategyWithStorage(t *testing.T) { t.Run("includes Recreate strategy when storage is configured", func(t *testing.T) { - owner := &llamav1alpha1.LlamaStackDistribution{ + owner := &ogxiov1beta1.OGXServer{ ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, - Spec: llamav1alpha1.LlamaStackDistributionSpec{ - Replicas: 1, - Server: llamav1alpha1.ServerSpec{ - Storage: &llamav1alpha1.StorageSpec{}, + Spec: ogxiov1beta1.OGXServerSpec{ + Distribution: ogxiov1beta1.DistributionSpec{Image: "test-image:latest"}, + Workload: &ogxiov1beta1.WorkloadSpec{ + Replicas: ptr(int32(1)), + Storage: &ogxiov1beta1.PVCStorageSpec{}, }, }, } @@ -1081,11 +1091,11 @@ func TestGetFieldMappings_RecreateStrategyWithStorage(t *testing.T) { }) t.Run("does not include strategy when storage is nil", func(t *testing.T) { - owner := &llamav1alpha1.LlamaStackDistribution{ + owner := &ogxiov1beta1.OGXServer{ ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, - Spec: llamav1alpha1.LlamaStackDistributionSpec{ - Replicas: 1, - Server: llamav1alpha1.ServerSpec{}, + Spec: ogxiov1beta1.OGXServerSpec{ + Distribution: ogxiov1beta1.DistributionSpec{Image: "test-image:latest"}, + Workload: &ogxiov1beta1.WorkloadSpec{Replicas: ptr(int32(1))}, }, } @@ -1099,6 +1109,32 @@ func TestGetFieldMappings_RecreateStrategyWithStorage(t *testing.T) { }) } +func TestCheckCRDExists(t *testing.T) { + ctx := context.Background() + + t.Run("returns true for registered CRD", func(t *testing.T) { + exists, err := CheckCRDExists(ctx, k8sClient, "ogxservers.ogx.io") + require.NoError(t, err) + assert.True(t, exists, "ogxservers.ogx.io CRD should exist in envtest") + }) + + t.Run("returns false for non-existent CRD", func(t *testing.T) { + exists, err := CheckCRDExists(ctx, k8sClient, "fakes.nonexistent.example.com") + require.NoError(t, err) + assert.False(t, exists, "non-existent CRD should return false") + }) +} + +func TestMonitoringCRDsAvailable(t *testing.T) { + ctx := context.Background() + + t.Run("returns false when monitoring CRDs are not installed", func(t *testing.T) { + available, err := MonitoringCRDsAvailable(ctx, k8sClient) + require.NoError(t, err) + assert.False(t, available, "monitoring CRDs should not be available in envtest") + }) +} + // resourceToUnstructured converts a kustomize resource to an unstructured object. func resourceToUnstructured(t *testing.T, res *kresource.Resource) (*unstructured.Unstructured, error) { t.Helper() diff --git a/pkg/deploy/networkpolicy.go b/pkg/deploy/networkpolicy.go index e0d9e84cd..5723740b2 100644 --- a/pkg/deploy/networkpolicy.go +++ b/pkg/deploy/networkpolicy.go @@ -5,7 +5,7 @@ import ( "fmt" "github.com/go-logr/logr" - llamav1alpha1 "github.com/llamastack/llama-stack-k8s-operator/api/v1alpha1" + ogxiov1beta1 "github.com/ogx-ai/ogx-k8s-operator/api/v1beta1" networkingv1 "k8s.io/api/networking/v1" k8serrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime" @@ -15,7 +15,7 @@ import ( // ApplyNetworkPolicy creates or updates a NetworkPolicy. func ApplyNetworkPolicy(ctx context.Context, c client.Client, scheme *runtime.Scheme, - instance *llamav1alpha1.LlamaStackDistribution, networkPolicy *networkingv1.NetworkPolicy, log logr.Logger) error { + instance *ogxiov1beta1.OGXServer, networkPolicy *networkingv1.NetworkPolicy, log logr.Logger) error { // Set the controller reference if err := ctrl.SetControllerReference(instance, networkPolicy, scheme); err != nil { return fmt.Errorf("failed to set controller reference: %w", err) diff --git a/pkg/deploy/plugins/field_mutator_test.go b/pkg/deploy/plugins/field_mutator_test.go index 123cda832..24d6b162c 100644 --- a/pkg/deploy/plugins/field_mutator_test.go +++ b/pkg/deploy/plugins/field_mutator_test.go @@ -246,7 +246,7 @@ func TestTransform(t *testing.T) { transformer: CreateFieldMutator(FieldMutatorConfig{ Mappings: []FieldMapping{ {TargetKind: "Service", TargetField: "/spec/selector/app.kubernetes.io~1instance", SourceValue: "dynamic-instance-name", CreateIfNotExists: true}, - {TargetKind: "Service", TargetField: "/spec/selector/app.kubernetes.io~1name", SourceValue: "llamastack", CreateIfNotExists: true}, + {TargetKind: "Service", TargetField: "/spec/selector/app.kubernetes.io~1name", SourceValue: "ogx", CreateIfNotExists: true}, }, }), initialResources: []*resource.Resource{ @@ -261,7 +261,7 @@ func TestTransform(t *testing.T) { "my-service": { "selector": map[string]any{ "app.kubernetes.io/instance": "dynamic-instance-name", - "app.kubernetes.io/name": "llamastack", + "app.kubernetes.io/name": "ogx", "app": "other-label", }, }, diff --git a/pkg/deploy/plugins/networkpolicy_transformer.go b/pkg/deploy/plugins/networkpolicy_transformer.go index 011d66473..702bcc283 100644 --- a/pkg/deploy/plugins/networkpolicy_transformer.go +++ b/pkg/deploy/plugins/networkpolicy_transformer.go @@ -17,10 +17,12 @@ limitations under the License. package plugins import ( + "encoding/json" "errors" "fmt" - llamav1alpha1 "github.com/llamastack/llama-stack-k8s-operator/api/v1alpha1" + ogxiov1beta1 "github.com/ogx-ai/ogx-k8s-operator/api/v1beta1" + networkingv1 "k8s.io/api/networking/v1" "sigs.k8s.io/kustomize/api/resmap" "sigs.k8s.io/kustomize/api/resource" "sigs.k8s.io/yaml" @@ -37,14 +39,14 @@ const ( // NetworkPolicyTransformerConfig holds the configuration for the NetworkPolicy transformer. type NetworkPolicyTransformerConfig struct { - // InstanceName is the name of the LlamaStackDistribution instance. + // InstanceName is the name of the OGXServer instance. InstanceName string // ServicePort is the port the service is exposed on. ServicePort int32 // OperatorNamespace is the namespace where the operator is running. OperatorNamespace string // NetworkSpec is the network configuration from the CR spec. - NetworkSpec *llamav1alpha1.NetworkSpec + NetworkSpec *ogxiov1beta1.NetworkSpec } // CreateNetworkPolicyTransformer creates a transformer for NetworkPolicy resources. @@ -91,11 +93,62 @@ func (t *networkPolicyTransformer) transformNetworkPolicy(res *resource.Resource return err } - // Build and set ingress rules + if err := t.applyNetworkPolicySpec(spec); err != nil { + return err + } + + return updateResource(res, data) +} + +// applyNetworkPolicySpec sets ingress/egress from the CR when explicitly provided; otherwise uses defaults. +func (t *networkPolicyTransformer) applyNetworkPolicySpec(spec map[string]any) error { + np := t.config.NetworkSpec + if np != nil && np.Policy != nil && len(np.Policy.Ingress) > 0 { + ingress, err := networkPolicyRulesToAnySlice(np.Policy.Ingress) + if err != nil { + return fmt.Errorf("failed to convert NetworkPolicy ingress rules: %w", err) + } + spec["ingress"] = ingress + policyTypes := []any{"Ingress"} + if len(np.Policy.Egress) > 0 { + egress, err := networkPolicyEgressRulesToAnySlice(np.Policy.Egress) + if err != nil { + return fmt.Errorf("failed to convert NetworkPolicy egress rules: %w", err) + } + spec["egress"] = egress + policyTypes = append(policyTypes, "Egress") + } + spec["policyTypes"] = policyTypes + return nil + } + ingressRules := t.buildIngressRules() spec["ingress"] = ingressRules + return nil +} - return updateResource(res, data) +func networkPolicyRulesToAnySlice(rules []networkingv1.NetworkPolicyIngressRule) ([]any, error) { + b, err := json.Marshal(rules) + if err != nil { + return nil, err + } + var out []any + if err := json.Unmarshal(b, &out); err != nil { + return nil, err + } + return out, nil +} + +func networkPolicyEgressRulesToAnySlice(rules []networkingv1.NetworkPolicyEgressRule) ([]any, error) { + b, err := json.Marshal(rules) + if err != nil { + return nil, err + } + var out []any + if err := json.Unmarshal(b, &out); err != nil { + return nil, err + } + return out, nil } func (t *networkPolicyTransformer) updatePodSelector(spec map[string]any) error { @@ -111,7 +164,7 @@ func (t *networkPolicyTransformer) updatePodSelector(spec map[string]any) error podSelector["matchLabels"] = matchLabels } - matchLabels["app"] = llamav1alpha1.DefaultLabelValue + matchLabels["app"] = ogxiov1beta1.DefaultLabelValue matchLabels["app.kubernetes.io/instance"] = t.config.InstanceName return nil @@ -136,36 +189,11 @@ func (t *networkPolicyTransformer) buildIngressRules() []any { } func (t *networkPolicyTransformer) buildPeers() []any { - // Check if all namespaces are allowed - if t.isAllNamespacesAllowed() { - return []any{ - map[string]any{ - "namespaceSelector": map[string]any{}, // Empty selector matches all - }, - } - } - peers := t.buildDefaultPeers() - peers = append(peers, t.buildNamespacePeers()...) - peers = append(peers, t.buildLabelPeers()...) peers = append(peers, t.buildRouterPeers()...) - return peers } -func (t *networkPolicyTransformer) isAllNamespacesAllowed() bool { - if t.config.NetworkSpec == nil || t.config.NetworkSpec.AllowedFrom == nil { - return false - } - - for _, ns := range t.config.NetworkSpec.AllowedFrom.Namespaces { - if ns == AllNamespacesSelector { - return true - } - } - return false -} - // buildDefaultPeers builds the default NetworkPolicy peers: // 1. All pods within the same namespace (no pod-level restriction). // 2. All pods from the operator namespace. @@ -186,56 +214,6 @@ func (t *networkPolicyTransformer) buildDefaultPeers() []any { } } -// buildNamespacePeers builds NetworkPolicy peers for explicit namespace list. -func (t *networkPolicyTransformer) buildNamespacePeers() []any { - if t.config.NetworkSpec == nil || t.config.NetworkSpec.AllowedFrom == nil { - return nil - } - - namespaces := t.config.NetworkSpec.AllowedFrom.Namespaces - peers := make([]any, 0, len(namespaces)) - for _, ns := range namespaces { - if ns == AllNamespacesSelector { - continue // Already handled separately - } - // No podSelector - allow all pods in the namespace - peers = append(peers, map[string]any{ - "namespaceSelector": map[string]any{ - "matchLabels": map[string]any{ - "kubernetes.io/metadata.name": ns, - }, - }, - }) - } - - return peers -} - -// buildLabelPeers builds NetworkPolicy peers for label-based namespace selection. -func (t *networkPolicyTransformer) buildLabelPeers() []any { - if t.config.NetworkSpec == nil || t.config.NetworkSpec.AllowedFrom == nil { - return nil - } - - labels := t.config.NetworkSpec.AllowedFrom.Labels - peers := make([]any, 0, len(labels)) - for _, labelKey := range labels { - // No podSelector - allow all pods in matching namespaces - peers = append(peers, map[string]any{ - "namespaceSelector": map[string]any{ - "matchExpressions": []any{ - map[string]any{ - "key": labelKey, - "operator": "Exists", - }, - }, - }, - }) - } - - return peers -} - // buildRouterPeers builds NetworkPolicy peers for ingress controller traffic. func (t *networkPolicyTransformer) buildRouterPeers() []any { if t.config.NetworkSpec == nil { diff --git a/pkg/deploy/plugins/networkpolicy_transformer_test.go b/pkg/deploy/plugins/networkpolicy_transformer_test.go index 80eff615d..f51d8a5de 100644 --- a/pkg/deploy/plugins/networkpolicy_transformer_test.go +++ b/pkg/deploy/plugins/networkpolicy_transformer_test.go @@ -19,9 +19,13 @@ package plugins import ( "testing" - llamav1alpha1 "github.com/llamastack/llama-stack-k8s-operator/api/v1alpha1" + ogxiov1beta1 "github.com/ogx-ai/ogx-k8s-operator/api/v1beta1" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" "sigs.k8s.io/kustomize/api/resmap" "sigs.k8s.io/kustomize/api/resource" ) @@ -34,7 +38,7 @@ metadata: spec: podSelector: matchLabels: - app: llama-stack + app: ogx policyTypes: - Ingress ingress: [] @@ -76,7 +80,8 @@ func TestNetworkPolicyTransformer_Default(t *testing.T) { assert.Contains(t, yamlStr, "port: 8321") } -func TestNetworkPolicyTransformer_AllNamespaces(t *testing.T) { +// TestNetworkPolicyTransformer_ExplicitIngressFromCR uses v1beta1 NetworkPolicySpec.Ingress verbatim. +func TestNetworkPolicyTransformer_ExplicitIngressFromCR(t *testing.T) { rf := resource.NewFactory(nil) res, err := rf.FromBytes([]byte(networkPolicyTestYAML)) require.NoError(t, err) @@ -84,84 +89,28 @@ func TestNetworkPolicyTransformer_AllNamespaces(t *testing.T) { rm := resmap.New() require.NoError(t, rm.Append(res)) - transformer := CreateNetworkPolicyTransformer(NetworkPolicyTransformerConfig{ - InstanceName: "test-instance", - ServicePort: 8321, - OperatorNamespace: "operator-ns", - NetworkSpec: &llamav1alpha1.NetworkSpec{ - AllowedFrom: &llamav1alpha1.AllowedFromSpec{ - Namespaces: []string{"*"}, + proto := corev1.ProtocolTCP + ingress := []networkingv1.NetworkPolicyIngressRule{ + { + From: []networkingv1.NetworkPolicyPeer{ + {NamespaceSelector: &metav1.LabelSelector{}}, }, - }, - }) - - err = transformer.Transform(rm) - require.NoError(t, err) - - transformedRes := rm.Resources()[0] - yamlBytes, err := transformedRes.AsYAML() - require.NoError(t, err) - - yamlStr := string(yamlBytes) - - // Should have empty namespace selector (all namespaces) - assert.Contains(t, yamlStr, "namespaceSelector: {}") - - // Should NOT have the operator namespace selector (since all are allowed) - assert.NotContains(t, yamlStr, "kubernetes.io/metadata.name: operator-ns") -} - -func TestNetworkPolicyTransformer_ExplicitNamespaces(t *testing.T) { - rf := resource.NewFactory(nil) - res, err := rf.FromBytes([]byte(networkPolicyTestYAML)) - require.NoError(t, err) - - rm := resmap.New() - require.NoError(t, rm.Append(res)) - - transformer := CreateNetworkPolicyTransformer(NetworkPolicyTransformerConfig{ - InstanceName: "test-instance", - ServicePort: 8321, - OperatorNamespace: "operator-ns", - NetworkSpec: &llamav1alpha1.NetworkSpec{ - AllowedFrom: &llamav1alpha1.AllowedFromSpec{ - Namespaces: []string{"ns-a", "ns-b"}, + Ports: []networkingv1.NetworkPolicyPort{ + { + Protocol: &proto, + Port: &intstr.IntOrString{Type: intstr.Int, IntVal: 8321}, + }, }, }, - }) - - err = transformer.Transform(rm) - require.NoError(t, err) - - transformedRes := rm.Resources()[0] - yamlBytes, err := transformedRes.AsYAML() - require.NoError(t, err) - - yamlStr := string(yamlBytes) - - // Should have explicit namespace selectors - assert.Contains(t, yamlStr, "kubernetes.io/metadata.name: ns-a") - assert.Contains(t, yamlStr, "kubernetes.io/metadata.name: ns-b") - - // Should also have operator namespace - assert.Contains(t, yamlStr, "kubernetes.io/metadata.name: operator-ns") -} - -func TestNetworkPolicyTransformer_LabelSelectors(t *testing.T) { - rf := resource.NewFactory(nil) - res, err := rf.FromBytes([]byte(networkPolicyTestYAML)) - require.NoError(t, err) - - rm := resmap.New() - require.NoError(t, rm.Append(res)) + } transformer := CreateNetworkPolicyTransformer(NetworkPolicyTransformerConfig{ InstanceName: "test-instance", ServicePort: 8321, OperatorNamespace: "operator-ns", - NetworkSpec: &llamav1alpha1.NetworkSpec{ - AllowedFrom: &llamav1alpha1.AllowedFromSpec{ - Labels: []string{"myproject/lls-allowed", "team/authorized"}, + NetworkSpec: &ogxiov1beta1.NetworkSpec{ + Policy: &ogxiov1beta1.NetworkPolicySpec{ + Ingress: ingress, }, }, }) @@ -172,13 +121,9 @@ func TestNetworkPolicyTransformer_LabelSelectors(t *testing.T) { transformedRes := rm.Resources()[0] yamlBytes, err := transformedRes.AsYAML() require.NoError(t, err) - yamlStr := string(yamlBytes) - // Should have label selectors with Exists operator - assert.Contains(t, yamlStr, "key: myproject/lls-allowed") - assert.Contains(t, yamlStr, "key: team/authorized") - assert.Contains(t, yamlStr, "operator: Exists") + assert.Contains(t, yamlStr, "namespaceSelector: {}") } func TestNetworkPolicyTransformer_CustomPort(t *testing.T) { @@ -221,9 +166,7 @@ func TestNetworkPolicyTransformer_RouterPeersWhenNetworkSpecProvided(t *testing. InstanceName: "test-instance", ServicePort: 8321, OperatorNamespace: "operator-ns", - NetworkSpec: &llamav1alpha1.NetworkSpec{ - ExposeRoute: true, - }, + NetworkSpec: &ogxiov1beta1.NetworkSpec{}, }) err = transformer.Transform(rm) diff --git a/pkg/deploy/suite_test.go b/pkg/deploy/suite_test.go index 3c03735d0..961e73938 100644 --- a/pkg/deploy/suite_test.go +++ b/pkg/deploy/suite_test.go @@ -6,7 +6,7 @@ import ( "path/filepath" "testing" - llamav1alpha1 "github.com/llamastack/llama-stack-k8s-operator/api/v1alpha1" + ogxiov1beta1 "github.com/ogx-ai/ogx-k8s-operator/api/v1beta1" "github.com/stretchr/testify/require" "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/rest" @@ -38,7 +38,7 @@ func TestMain(m *testing.M) { os.Exit(1) } - err = llamav1alpha1.AddToScheme(scheme.Scheme) + err = ogxiov1beta1.AddToScheme(scheme.Scheme) if err != nil { logf.Log.Error(err, "failed to add scheme") os.Exit(1) diff --git a/pkg/deploy/utils.go b/pkg/deploy/utils.go index b0083b2a9..bc7ed161c 100644 --- a/pkg/deploy/utils.go +++ b/pkg/deploy/utils.go @@ -4,7 +4,7 @@ import ( "fmt" "os" - llamav1alpha1 "github.com/llamastack/llama-stack-k8s-operator/api/v1alpha1" + ogxiov1beta1 "github.com/ogx-ai/ogx-k8s-operator/api/v1beta1" ) func GetOperatorNamespace() (string, error) { @@ -16,15 +16,21 @@ func GetOperatorNamespace() (string, error) { return string(data), err } -func GetServicePort(instance *llamav1alpha1.LlamaStackDistribution) int32 { - // Use the container's port (defaulted to 8321 if unset) - port := instance.Spec.Server.ContainerSpec.Port - if port == 0 { - port = llamav1alpha1.DefaultServerPort +func GetServicePort(instance *ogxiov1beta1.OGXServer) int32 { + if instance.Spec.Network != nil && instance.Spec.Network.Port != 0 { + return instance.Spec.Network.Port } - return port + return ogxiov1beta1.DefaultServerPort } -func GetServiceName(instance *llamav1alpha1.LlamaStackDistribution) string { +func GetServiceName(instance *ogxiov1beta1.OGXServer) string { return fmt.Sprintf("%s-service", instance.Name) } + +// GetEffectiveReplicas returns the desired replica count, defaulting to 1. +func GetEffectiveReplicas(instance *ogxiov1beta1.OGXServer) int32 { + if instance.Spec.Workload != nil && instance.Spec.Workload.Replicas != nil { + return *instance.Spec.Workload.Replicas + } + return 1 +} diff --git a/pkg/featureflags/featureflags.go b/pkg/featureflags/featureflags.go deleted file mode 100644 index 61ca2c0da..000000000 --- a/pkg/featureflags/featureflags.go +++ /dev/null @@ -1,21 +0,0 @@ -package featureflags - -type FeatureFlag struct { - Enabled bool `yaml:"enabled"` -} - -// FeatureFlags represents the configuration for feature flags in the operator. -// Add more feature flags later. -type FeatureFlags struct { - // EnableNetworkPolicy controls whether NetworkPolicy resources should be created. - EnableNetworkPolicy FeatureFlag `yaml:"enableNetworkPolicy"` -} - -const ( - // FeatureFlagsKey is the key used in the ConfigMap to store feature flags. - FeatureFlagsKey = "featureFlags" - // EnableNetworkPolicyKey is the key for the network policy feature flag. - EnableNetworkPolicyKey = "enableNetworkPolicy" - // NetworkPolicyDefaultValue is the default value for the network policy feature flag. - NetworkPolicyDefaultValue = false -) diff --git a/release/operator-openshift.yaml b/release/operator-openshift.yaml new file mode 100644 index 000000000..2ad1283b8 --- /dev/null +++ b/release/operator-openshift.yaml @@ -0,0 +1,7387 @@ +apiVersion: v1 +kind: Namespace +metadata: + labels: + app.kubernetes.io/name: ogx-k8s-operator + control-plane: controller-manager + name: ogx-k8s-operator-system +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.2 + labels: + app.kubernetes.io/name: ogx-k8s-operator + name: ogxservers.ogx.io +spec: + group: ogx.io + names: + kind: OGXServer + listKind: OGXServerList + plural: ogxservers + singular: ogxserver + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .status.phase + name: Phase + type: string + - jsonPath: .status.resolvedDistribution.image + name: Distribution + priority: 1 + type: string + - jsonPath: .status.configGeneration.configMapName + name: Config + priority: 1 + type: string + - jsonPath: .status.configGeneration.providerCount + name: Providers + type: integer + - jsonPath: .status.version.operatorVersion + name: Operator Version + priority: 1 + type: string + - jsonPath: .status.version.serverVersion + name: Server Version + priority: 1 + type: string + - jsonPath: .status.availableReplicas + name: Available + type: integer + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1beta1 + schema: + openAPIV3Schema: + description: OGXServer is the Schema for the ogxservers API. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: OGXServerSpec defines the desired state of OGXServer. + properties: + baseConfig: + description: |- + BaseConfig references a ConfigMap key containing the base config.yaml used + as the starting point for declarative config generation. + When set, this takes precedence over OCI label resolution. + Mutually exclusive with overrideConfig. + The ConfigMap must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the ConfigMap. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the ConfigMap. + minLength: 1 + type: string + required: + - key + - name + type: object + disabledAPIs: + description: |- + DisabledAPIs lists API names to remove from the generated config. + Mutually exclusive with overrideConfig. + items: + enum: + - batches + - file_processors + - inference + - responses + - tool_runtime + - vector_io + - files + type: string + maxItems: 7 + minItems: 1 + type: array + distribution: + description: Distribution identifies the OGX distribution to deploy. + properties: + image: + description: Image is a direct container image reference to use. + type: string + name: + description: |- + Name is the distribution name that maps to a supported distribution (e.g., "starter", "remote-vllm"). + Resolved to a container image via distributions.json and image-overrides. + type: string + type: object + x-kubernetes-validations: + - message: only one of name or image can be specified + rule: '!(has(self.name) && has(self.image))' + - message: one of name or image must be specified + rule: has(self.name) || has(self.image) + monitoring: + description: Monitoring configures Prometheus monitoring and observability. + properties: + enabled: + default: true + description: |- + Enabled controls whether the operator creates monitoring resources + (ServiceMonitor, PrometheusRule) for this server. + Defaults to true. Set to false to disable monitoring without removing the config. + type: boolean + metricsPort: + description: |- + MetricsPort is the port serving the /metrics endpoint. + When omitted, metrics are served on the main API port. + format: int32 + maximum: 65535 + minimum: 1 + type: integer + type: object + network: + description: Network defines network access controls. + properties: + externalAccess: + description: ExternalAccess controls external service exposure. + properties: + enabled: + default: false + description: Enabled controls whether external access is created. + type: boolean + hostname: + description: |- + Hostname sets a custom hostname for the external endpoint. + When omitted, an auto-generated hostname is used. + type: string + type: object + x-kubernetes-validations: + - message: hostname must not be empty if specified + rule: '!has(self.hostname) || self.hostname.size() > 0' + policy: + description: |- + Policy configures the operator-managed NetworkPolicy. + When nil, the operator creates a default NetworkPolicy with safe ingress rules. + properties: + egress: + description: |- + Egress rules. When non-empty, a kube-dns egress rule is auto-injected + to prevent DNS breakage. + items: + description: |- + NetworkPolicyEgressRule describes a particular set of traffic that is allowed out of pods + matched by a NetworkPolicySpec's podSelector. The traffic must match both ports and to. + This type is beta-level in 1.8 + properties: + ports: + description: |- + ports is a list of destination ports for outgoing traffic. + Each item in this list is combined using a logical OR. If this field is + empty or missing, this rule matches all ports (traffic not restricted by port). + If this field is present and contains at least one item, then this rule allows + traffic only if the traffic matches at least one port in the list. + items: + description: NetworkPolicyPort describes a port to + allow traffic on + properties: + endPort: + description: |- + endPort indicates that the range of ports from port to endPort if set, inclusive, + should be allowed by the policy. This field cannot be defined if the port field + is not defined or if the port field is defined as a named (string) port. + The endPort must be equal or greater than port. + format: int32 + type: integer + port: + anyOf: + - type: integer + - type: string + description: |- + port represents the port on the given protocol. This can either be a numerical or named + port on a pod. If this field is not provided, this matches all port names and + numbers. + If present, only traffic on the specified protocol AND port will be matched. + x-kubernetes-int-or-string: true + protocol: + description: |- + protocol represents the protocol (TCP, UDP, or SCTP) which traffic must match. + If not specified, this field defaults to TCP. + type: string + type: object + type: array + x-kubernetes-list-type: atomic + to: + description: |- + to is a list of destinations for outgoing traffic of pods selected for this rule. + Items in this list are combined using a logical OR operation. If this field is + empty or missing, this rule matches all destinations (traffic not restricted by + destination). If this field is present and contains at least one item, this rule + allows traffic only if the traffic matches at least one item in the to list. + items: + description: |- + NetworkPolicyPeer describes a peer to allow traffic to/from. Only certain combinations of + fields are allowed + properties: + ipBlock: + description: |- + ipBlock defines policy on a particular IPBlock. If this field is set then + neither of the other fields can be. + properties: + cidr: + description: |- + cidr is a string representing the IPBlock + Valid examples are "192.168.1.0/24" or "2001:db8::/64" + type: string + except: + description: |- + except is a slice of CIDRs that should not be included within an IPBlock + Valid examples are "192.168.1.0/24" or "2001:db8::/64" + Except values will be rejected if they are outside the cidr range + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - cidr + type: object + namespaceSelector: + description: |- + namespaceSelector selects namespaces using cluster-scoped labels. This field follows + standard label selector semantics; if present but empty, it selects all namespaces. + + If podSelector is also set, then the NetworkPolicyPeer as a whole selects + the pods matching podSelector in the namespaces selected by namespaceSelector. + Otherwise it selects all pods in the namespaces selected by namespaceSelector. + properties: + matchExpressions: + description: matchExpressions is a list of + label selector requirements. The requirements + are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that + the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + podSelector: + description: |- + podSelector is a label selector which selects pods. This field follows standard label + selector semantics; if present but empty, it selects all pods. + + If namespaceSelector is also set, then the NetworkPolicyPeer as a whole selects + the pods matching podSelector in the Namespaces selected by NamespaceSelector. + Otherwise it selects the pods matching podSelector in the policy's own namespace. + properties: + matchExpressions: + description: matchExpressions is a list of + label selector requirements. The requirements + are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that + the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + type: object + type: array + x-kubernetes-list-type: atomic + type: object + type: array + enabled: + default: true + description: |- + Enabled controls whether the operator manages a NetworkPolicy for this server. + Defaults to true. Set to false to disable NetworkPolicy creation entirely. + type: boolean + ingress: + description: |- + Ingress defines additional ingress rules, merged with operator defaults + (allow from same-namespace and operator-namespace on the service port). + items: + description: |- + NetworkPolicyIngressRule describes a particular set of traffic that is allowed to the pods + matched by a NetworkPolicySpec's podSelector. The traffic must match both ports and from. + properties: + from: + description: |- + from is a list of sources which should be able to access the pods selected for this rule. + Items in this list are combined using a logical OR operation. If this field is + empty or missing, this rule matches all sources (traffic not restricted by + source). If this field is present and contains at least one item, this rule + allows traffic only if the traffic matches at least one item in the from list. + items: + description: |- + NetworkPolicyPeer describes a peer to allow traffic to/from. Only certain combinations of + fields are allowed + properties: + ipBlock: + description: |- + ipBlock defines policy on a particular IPBlock. If this field is set then + neither of the other fields can be. + properties: + cidr: + description: |- + cidr is a string representing the IPBlock + Valid examples are "192.168.1.0/24" or "2001:db8::/64" + type: string + except: + description: |- + except is a slice of CIDRs that should not be included within an IPBlock + Valid examples are "192.168.1.0/24" or "2001:db8::/64" + Except values will be rejected if they are outside the cidr range + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - cidr + type: object + namespaceSelector: + description: |- + namespaceSelector selects namespaces using cluster-scoped labels. This field follows + standard label selector semantics; if present but empty, it selects all namespaces. + + If podSelector is also set, then the NetworkPolicyPeer as a whole selects + the pods matching podSelector in the namespaces selected by namespaceSelector. + Otherwise it selects all pods in the namespaces selected by namespaceSelector. + properties: + matchExpressions: + description: matchExpressions is a list of + label selector requirements. The requirements + are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that + the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + podSelector: + description: |- + podSelector is a label selector which selects pods. This field follows standard label + selector semantics; if present but empty, it selects all pods. + + If namespaceSelector is also set, then the NetworkPolicyPeer as a whole selects + the pods matching podSelector in the Namespaces selected by NamespaceSelector. + Otherwise it selects the pods matching podSelector in the policy's own namespace. + properties: + matchExpressions: + description: matchExpressions is a list of + label selector requirements. The requirements + are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that + the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + type: object + type: array + x-kubernetes-list-type: atomic + ports: + description: |- + ports is a list of ports which should be made accessible on the pods selected for + this rule. Each item in this list is combined using a logical OR. If this field is + empty or missing, this rule matches all ports (traffic not restricted by port). + If this field is present and contains at least one item, then this rule allows + traffic only if the traffic matches at least one port in the list. + items: + description: NetworkPolicyPort describes a port to + allow traffic on + properties: + endPort: + description: |- + endPort indicates that the range of ports from port to endPort if set, inclusive, + should be allowed by the policy. This field cannot be defined if the port field + is not defined or if the port field is defined as a named (string) port. + The endPort must be equal or greater than port. + format: int32 + type: integer + port: + anyOf: + - type: integer + - type: string + description: |- + port represents the port on the given protocol. This can either be a numerical or named + port on a pod. If this field is not provided, this matches all port names and + numbers. + If present, only traffic on the specified protocol AND port will be matched. + x-kubernetes-int-or-string: true + protocol: + description: |- + protocol represents the protocol (TCP, UDP, or SCTP) which traffic must match. + If not specified, this field defaults to TCP. + type: string + type: object + type: array + x-kubernetes-list-type: atomic + type: object + type: array + policyTypes: + description: |- + PolicyTypes specifies which policy directions are enforced. + Follows Kubernetes NetworkPolicy semantics: when omitted or empty, + Ingress is always included and Egress is included only if egress + rules are provided. + items: + description: |- + PolicyType string describes the NetworkPolicy type + This type is beta-level in 1.8 + enum: + - Ingress + - Egress + type: string + type: array + type: object + port: + default: 8321 + description: Port is the server listen port. + format: int32 + maximum: 65535 + minimum: 1 + type: integer + tls: + description: |- + TLS configures optional TLS termination for the server. + When omitted, the server listens over plain HTTP. + properties: + secretName: + description: |- + SecretName references a Kubernetes TLS Secret containing a valid TLS certificate + for server TLS termination. The Secret must be in the same namespace as the + OGXServer and must have the label ogx.io/watch: "true" to be detected by the + operator's cache. + minLength: 1 + type: string + required: + - secretName + type: object + type: object + overrideConfig: + description: |- + OverrideConfig references a ConfigMap key containing a full config.yaml override. + Mutually exclusive with providers, resources, storage, disabledAPIs, and baseConfig. + The ConfigMap must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the ConfigMap. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the ConfigMap. + minLength: 1 + type: string + required: + - key + - name + type: object + providers: + description: |- + Providers configures providers by API type. + Mutually exclusive with overrideConfig. + properties: + batches: + description: BatchesProvidersSpec configures batches providers. + properties: + inline: + description: BatchesInlineProviders groups inline batches + providers. + properties: + custom: + items: + description: CustomProvider defines the configuration + for a custom provider instance. + properties: + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + secretRefs: + additionalProperties: + description: |- + SecretKeyRef references a specific key in a Kubernetes Secret. + The Secret must be in the same namespace as the OGXServer and must have + the label ogx.io/watch: "true" to be detected by the operator's cache. + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + description: |- + SecretRefs is a map of named secret references for provider-specific + connection fields (e.g., host, password). Each key becomes the env var + field suffix and maps to config. with env var substitution. + Each Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + minProperties: 1 + type: object + settings: + description: |- + Settings contains provider-specific configuration merged into the + provider's config section in config.yaml. Passed through as-is + without any secret resolution. Use secretRefs for secret values. + x-kubernetes-preserve-unknown-fields: true + type: + description: |- + Type is the provider type, specified with a "remote::" or "inline::" + prefix (e.g., "remote::llama-guard", "inline::my-provider"). + minLength: 1 + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: type must have a 'remote::' or 'inline::' + prefix (e.g., 'remote::llama-guard', 'inline::my-provider') + rule: self.type.startsWith('remote::') || self.type.startsWith('inline::') + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + reference: + description: InlineReferenceProvider configures inline::reference + for batches. + properties: + maxConcurrentBatches: + description: |- + MaxConcurrentBatches is the maximum number of concurrent batches + to process simultaneously. + minimum: 1 + type: integer + maxConcurrentRequestsPerBatch: + description: |- + MaxConcurrentRequestsPerBatch is the maximum number of concurrent + requests to process per batch. + minimum: 1 + type: integer + type: object + type: object + remote: + description: BatchesRemoteProviders groups remote batches + providers. + properties: + custom: + items: + description: CustomProvider defines the configuration + for a custom provider instance. + properties: + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + secretRefs: + additionalProperties: + description: |- + SecretKeyRef references a specific key in a Kubernetes Secret. + The Secret must be in the same namespace as the OGXServer and must have + the label ogx.io/watch: "true" to be detected by the operator's cache. + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + description: |- + SecretRefs is a map of named secret references for provider-specific + connection fields (e.g., host, password). Each key becomes the env var + field suffix and maps to config. with env var substitution. + Each Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + minProperties: 1 + type: object + settings: + description: |- + Settings contains provider-specific configuration merged into the + provider's config section in config.yaml. Passed through as-is + without any secret resolution. Use secretRefs for secret values. + x-kubernetes-preserve-unknown-fields: true + type: + description: |- + Type is the provider type, specified with a "remote::" or "inline::" + prefix (e.g., "remote::llama-guard", "inline::my-provider"). + minLength: 1 + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: type must have a 'remote::' or 'inline::' + prefix (e.g., 'remote::llama-guard', 'inline::my-provider') + rule: self.type.startsWith('remote::') || self.type.startsWith('inline::') + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + type: object + type: object + fileProcessors: + description: FileProcessorsProvidersSpec configures file_processors + providers. + properties: + inline: + description: FileProcessorsInlineProviders groups inline file_processors + providers. + properties: + auto: + description: InlineAutoFileProcessorProvider configures + inline::auto for file_processors. + properties: + cleanText: + description: |- + CleanText controls whether to clean extracted text + (remove extra whitespace, normalize line breaks). + type: boolean + defaultChunkOverlapTokens: + description: |- + DefaultChunkOverlapTokens is the default chunk overlap in tokens when + chunking_strategy type is 'auto'. + maximum: 2048 + minimum: 0 + type: integer + defaultChunkSizeTokens: + description: |- + DefaultChunkSizeTokens is the default chunk size in tokens when + chunking_strategy type is 'auto'. + maximum: 4096 + minimum: 100 + type: integer + extractMetadata: + description: |- + ExtractMetadata controls whether to extract PDF metadata + (title, author, etc.). + type: boolean + type: object + custom: + items: + description: CustomProvider defines the configuration + for a custom provider instance. + properties: + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + secretRefs: + additionalProperties: + description: |- + SecretKeyRef references a specific key in a Kubernetes Secret. + The Secret must be in the same namespace as the OGXServer and must have + the label ogx.io/watch: "true" to be detected by the operator's cache. + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + description: |- + SecretRefs is a map of named secret references for provider-specific + connection fields (e.g., host, password). Each key becomes the env var + field suffix and maps to config. with env var substitution. + Each Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + minProperties: 1 + type: object + settings: + description: |- + Settings contains provider-specific configuration merged into the + provider's config section in config.yaml. Passed through as-is + without any secret resolution. Use secretRefs for secret values. + x-kubernetes-preserve-unknown-fields: true + type: + description: |- + Type is the provider type, specified with a "remote::" or "inline::" + prefix (e.g., "remote::llama-guard", "inline::my-provider"). + minLength: 1 + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: type must have a 'remote::' or 'inline::' + prefix (e.g., 'remote::llama-guard', 'inline::my-provider') + rule: self.type.startsWith('remote::') || self.type.startsWith('inline::') + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + docling: + description: InlineDoclingFileProcessorProvider configures + inline::docling for file_processors. + properties: + defaultChunkOverlapTokens: + description: |- + DefaultChunkOverlapTokens is the default chunk overlap in tokens when + chunking_strategy type is 'auto'. + maximum: 2048 + minimum: 0 + type: integer + defaultChunkSizeTokens: + description: |- + DefaultChunkSizeTokens is the default chunk size in tokens when + chunking_strategy type is 'auto'. + maximum: 4096 + minimum: 100 + type: integer + doOcr: + description: DoOCR controls whether to enable OCR + for scanned documents. + type: boolean + type: object + markitdown: + description: InlineMarkItDownFileProcessorProvider configures + inline::markitdown for file_processors. + properties: + defaultChunkOverlapTokens: + description: |- + DefaultChunkOverlapTokens is the default chunk overlap in tokens when + chunking_strategy type is 'auto'. + maximum: 2048 + minimum: 0 + type: integer + defaultChunkSizeTokens: + description: |- + DefaultChunkSizeTokens is the default chunk size in tokens when + chunking_strategy type is 'auto'. + maximum: 4096 + minimum: 100 + type: integer + type: object + pypdf: + description: InlinePyPDFFileProcessorProvider configures + inline::pypdf for file_processors. + properties: + cleanText: + description: |- + CleanText controls whether to clean extracted text + (remove extra whitespace, normalize line breaks). + type: boolean + defaultChunkOverlapTokens: + description: |- + DefaultChunkOverlapTokens is the default chunk overlap in tokens when + chunking_strategy type is 'auto'. + maximum: 2048 + minimum: 0 + type: integer + defaultChunkSizeTokens: + description: |- + DefaultChunkSizeTokens is the default chunk size in tokens when + chunking_strategy type is 'auto'. + maximum: 4096 + minimum: 100 + type: integer + extractMetadata: + description: |- + ExtractMetadata controls whether to extract PDF metadata + (title, author, etc.). + type: boolean + type: object + type: object + remote: + description: FileProcessorsRemoteProviders groups remote file_processors + providers. + properties: + custom: + items: + description: CustomProvider defines the configuration + for a custom provider instance. + properties: + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + secretRefs: + additionalProperties: + description: |- + SecretKeyRef references a specific key in a Kubernetes Secret. + The Secret must be in the same namespace as the OGXServer and must have + the label ogx.io/watch: "true" to be detected by the operator's cache. + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + description: |- + SecretRefs is a map of named secret references for provider-specific + connection fields (e.g., host, password). Each key becomes the env var + field suffix and maps to config. with env var substitution. + Each Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + minProperties: 1 + type: object + settings: + description: |- + Settings contains provider-specific configuration merged into the + provider's config section in config.yaml. Passed through as-is + without any secret resolution. Use secretRefs for secret values. + x-kubernetes-preserve-unknown-fields: true + type: + description: |- + Type is the provider type, specified with a "remote::" or "inline::" + prefix (e.g., "remote::llama-guard", "inline::my-provider"). + minLength: 1 + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: type must have a 'remote::' or 'inline::' + prefix (e.g., 'remote::llama-guard', 'inline::my-provider') + rule: self.type.startsWith('remote::') || self.type.startsWith('inline::') + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + doclingServe: + description: DoclingServeProvider configures a remote::docling-serve + file_processors provider instance. + properties: + apiKey: + description: |- + APIKey is the API key for authenticating with Docling Serve. + The Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + baseUrl: + description: BaseURL is the base URL of the Docling + Serve instance. + minLength: 1 + type: string + defaultChunkSizeTokens: + description: |- + DefaultChunkSizeTokens is the default chunk size in tokens when + chunking_strategy type is 'auto'. + maximum: 4096 + minimum: 100 + type: integer + required: + - baseUrl + type: object + x-kubernetes-validations: + - message: baseUrl must not be empty if specified + rule: '!has(self.baseUrl) || self.baseUrl.size() > 0' + type: object + type: object + files: + description: FilesProvidersSpec configures files providers. + properties: + inline: + description: FilesInlineProviders groups inline files providers. + properties: + custom: + items: + description: CustomProvider defines the configuration + for a custom provider instance. + properties: + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + secretRefs: + additionalProperties: + description: |- + SecretKeyRef references a specific key in a Kubernetes Secret. + The Secret must be in the same namespace as the OGXServer and must have + the label ogx.io/watch: "true" to be detected by the operator's cache. + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + description: |- + SecretRefs is a map of named secret references for provider-specific + connection fields (e.g., host, password). Each key becomes the env var + field suffix and maps to config. with env var substitution. + Each Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + minProperties: 1 + type: object + settings: + description: |- + Settings contains provider-specific configuration merged into the + provider's config section in config.yaml. Passed through as-is + without any secret resolution. Use secretRefs for secret values. + x-kubernetes-preserve-unknown-fields: true + type: + description: |- + Type is the provider type, specified with a "remote::" or "inline::" + prefix (e.g., "remote::llama-guard", "inline::my-provider"). + minLength: 1 + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: type must have a 'remote::' or 'inline::' + prefix (e.g., 'remote::llama-guard', 'inline::my-provider') + rule: self.type.startsWith('remote::') || self.type.startsWith('inline::') + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + localfs: + description: InlineLocalFSProvider configures inline::localfs. + properties: + ttlSecs: + description: TTLSecs is the time-to-live in seconds + for uploaded files. + minimum: 1 + type: integer + type: object + type: object + remote: + description: FilesRemoteProviders groups remote files providers. + properties: + custom: + items: + description: CustomProvider defines the configuration + for a custom provider instance. + properties: + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + secretRefs: + additionalProperties: + description: |- + SecretKeyRef references a specific key in a Kubernetes Secret. + The Secret must be in the same namespace as the OGXServer and must have + the label ogx.io/watch: "true" to be detected by the operator's cache. + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + description: |- + SecretRefs is a map of named secret references for provider-specific + connection fields (e.g., host, password). Each key becomes the env var + field suffix and maps to config. with env var substitution. + Each Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + minProperties: 1 + type: object + settings: + description: |- + Settings contains provider-specific configuration merged into the + provider's config section in config.yaml. Passed through as-is + without any secret resolution. Use secretRefs for secret values. + x-kubernetes-preserve-unknown-fields: true + type: + description: |- + Type is the provider type, specified with a "remote::" or "inline::" + prefix (e.g., "remote::llama-guard", "inline::my-provider"). + minLength: 1 + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: type must have a 'remote::' or 'inline::' + prefix (e.g., 'remote::llama-guard', 'inline::my-provider') + rule: self.type.startsWith('remote::') || self.type.startsWith('inline::') + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + s3: + description: S3Provider configures a remote::s3 files + provider instance. + properties: + autoCreateBucket: + description: |- + AutoCreateBucket controls whether to automatically create the S3 bucket + if it doesn't exist. + type: boolean + awsAccessKeyId: + description: |- + AWSAccessKeyID is the AWS access key ID (optional if using IAM roles). + The Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + awsSecretAccessKey: + description: |- + AWSSecretAccessKey is the AWS secret access key (optional if using IAM roles). + The Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + bucketName: + description: BucketName is the S3 bucket name to store + files. + minLength: 1 + type: string + endpointUrl: + description: EndpointURL is a custom S3 endpoint URL + (for MinIO, LocalStack, etc.). + type: string + region: + description: Region is the AWS region where the bucket + is located. + type: string + required: + - bucketName + type: object + x-kubernetes-validations: + - message: region must not be empty if specified + rule: '!has(self.region) || self.region.size() > 0' + - message: endpointUrl must not be empty if specified + rule: '!has(self.endpointUrl) || self.endpointUrl.size() + > 0' + type: object + type: object + inference: + description: InferenceProvidersSpec configures inference providers. + properties: + inline: + description: InferenceInlineProviders groups inline inference + providers. + properties: + custom: + items: + description: CustomProvider defines the configuration + for a custom provider instance. + properties: + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + secretRefs: + additionalProperties: + description: |- + SecretKeyRef references a specific key in a Kubernetes Secret. + The Secret must be in the same namespace as the OGXServer and must have + the label ogx.io/watch: "true" to be detected by the operator's cache. + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + description: |- + SecretRefs is a map of named secret references for provider-specific + connection fields (e.g., host, password). Each key becomes the env var + field suffix and maps to config. with env var substitution. + Each Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + minProperties: 1 + type: object + settings: + description: |- + Settings contains provider-specific configuration merged into the + provider's config section in config.yaml. Passed through as-is + without any secret resolution. Use secretRefs for secret values. + x-kubernetes-preserve-unknown-fields: true + type: + description: |- + Type is the provider type, specified with a "remote::" or "inline::" + prefix (e.g., "remote::llama-guard", "inline::my-provider"). + minLength: 1 + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: type must have a 'remote::' or 'inline::' + prefix (e.g., 'remote::llama-guard', 'inline::my-provider') + rule: self.type.startsWith('remote::') || self.type.startsWith('inline::') + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + type: object + remote: + description: InferenceRemoteProviders groups remote inference + providers. + properties: + azure: + items: + description: AzureProvider configures a remote::azure + inference provider instance. + properties: + allowedModels: + description: |- + AllowedModels restricts which models can be registered with this provider. + When empty, all models are allowed. + items: + minLength: 1 + type: string + minItems: 1 + type: array + apiKey: + description: |- + APIKey is the authentication credential for the Azure provider. + The Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + apiType: + description: APIType is the Azure API type (e.g., + azure). + type: string + apiVersion: + description: APIVersion is the Azure API version + (e.g., 2024-12-01-preview). + type: string + endpoint: + description: |- + Endpoint is the Azure API base URL + (e.g., https://your-resource-name.openai.azure.com/openai/v1). + minLength: 1 + type: string + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + network: + description: |- + Network configures network settings (TLS, proxy, timeouts, headers) + for the remote connection. + properties: + headers: + additionalProperties: + type: string + description: Headers specifies additional HTTP + headers to include in all requests. + minProperties: 1 + type: object + proxy: + description: Proxy configures HTTP proxy settings. + properties: + cacert: + description: CACert is the path to a CA + certificate for verifying the proxy's + certificate. + type: string + http: + description: HTTP is the proxy URL for HTTP + connections. + type: string + https: + description: HTTPS is the proxy URL for + HTTPS connections. + type: string + noProxy: + description: NoProxy is a list of hosts + that should bypass the proxy. + items: + minLength: 1 + type: string + minItems: 1 + type: array + url: + description: URL is the proxy URL for all + connections. + type: string + type: object + x-kubernetes-validations: + - message: url must not be empty if specified + rule: '!has(self.url) || self.url.size() > + 0' + - message: http must not be empty if specified + rule: '!has(self.http) || self.http.size() + > 0' + - message: https must not be empty if specified + rule: '!has(self.https) || self.https.size() + > 0' + - message: cacert must not be empty if specified + rule: '!has(self.cacert) || self.cacert.size() + > 0' + timeout: + description: Timeout configures connection and + read timeout settings. + properties: + connect: + description: Connect is the connection timeout + in seconds. + minimum: 1 + type: integer + read: + description: Read is the read timeout in + seconds. + minimum: 1 + type: integer + type: object + tls: + description: TLS configures TLS/SSL settings. + properties: + ciphers: + description: Ciphers is a list of allowed + TLS cipher suites. + items: + minLength: 1 + type: string + minItems: 1 + type: array + minVersion: + description: MinVersion sets the minimum + TLS version. + enum: + - TLSv1.2 + - TLSv1.3 + type: string + verify: + description: |- + Verify controls whether TLS certificate verification is enabled. + Trust anchors and client identity are configured globally via spec.tls. + type: boolean + type: object + type: object + refreshModels: + description: |- + RefreshModels controls whether the provider periodically refreshes + its model list from the remote endpoint. + type: boolean + required: + - apiKey + - endpoint + type: object + x-kubernetes-validations: + - message: apiVersion must not be empty if specified + rule: '!has(self.apiVersion) || self.apiVersion.size() + > 0' + - message: apiType must not be empty if specified + rule: '!has(self.apiType) || self.apiType.size() > + 0' + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + bedrock: + items: + description: BedrockProvider configures a remote::bedrock + inference provider instance. + properties: + allowedModels: + description: |- + AllowedModels restricts which models can be registered with this provider. + When empty, all models are allowed. + items: + minLength: 1 + type: string + minItems: 1 + type: array + apiKey: + description: |- + APIKey is the authentication credential for the Bedrock provider. + The Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + awsAccessKeyId: + description: |- + AWSAccessKeyID is the AWS access key to use. + The Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + awsRoleArn: + description: AWSRoleArn is the AWS role ARN to assume. + type: string + awsRoleSessionName: + description: AWSRoleSessionName is the session name + to use when assuming a role. + type: string + awsSecretAccessKey: + description: |- + AWSSecretAccessKey is the AWS secret access key to use. + The Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + awsSessionToken: + description: |- + AWSSessionToken is the AWS session token to use. + The Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + awsWebIdentityTokenFile: + description: AWSWebIdentityTokenFile is the path + to the web identity token file. + type: string + connectTimeout: + description: ConnectTimeout is the connection timeout + in seconds. + minimum: 1 + type: integer + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + network: + description: |- + Network configures network settings (TLS, proxy, timeouts, headers) + for the remote connection. + properties: + headers: + additionalProperties: + type: string + description: Headers specifies additional HTTP + headers to include in all requests. + minProperties: 1 + type: object + proxy: + description: Proxy configures HTTP proxy settings. + properties: + cacert: + description: CACert is the path to a CA + certificate for verifying the proxy's + certificate. + type: string + http: + description: HTTP is the proxy URL for HTTP + connections. + type: string + https: + description: HTTPS is the proxy URL for + HTTPS connections. + type: string + noProxy: + description: NoProxy is a list of hosts + that should bypass the proxy. + items: + minLength: 1 + type: string + minItems: 1 + type: array + url: + description: URL is the proxy URL for all + connections. + type: string + type: object + x-kubernetes-validations: + - message: url must not be empty if specified + rule: '!has(self.url) || self.url.size() > + 0' + - message: http must not be empty if specified + rule: '!has(self.http) || self.http.size() + > 0' + - message: https must not be empty if specified + rule: '!has(self.https) || self.https.size() + > 0' + - message: cacert must not be empty if specified + rule: '!has(self.cacert) || self.cacert.size() + > 0' + timeout: + description: Timeout configures connection and + read timeout settings. + properties: + connect: + description: Connect is the connection timeout + in seconds. + minimum: 1 + type: integer + read: + description: Read is the read timeout in + seconds. + minimum: 1 + type: integer + type: object + tls: + description: TLS configures TLS/SSL settings. + properties: + ciphers: + description: Ciphers is a list of allowed + TLS cipher suites. + items: + minLength: 1 + type: string + minItems: 1 + type: array + minVersion: + description: MinVersion sets the minimum + TLS version. + enum: + - TLSv1.2 + - TLSv1.3 + type: string + verify: + description: |- + Verify controls whether TLS certificate verification is enabled. + Trust anchors and client identity are configured globally via spec.tls. + type: boolean + type: object + type: object + profileName: + description: ProfileName is the AWS profile name + that contains credentials to use. + type: string + readTimeout: + description: ReadTimeout is the read timeout in + seconds. + minimum: 1 + type: integer + refreshModels: + description: |- + RefreshModels controls whether the provider periodically refreshes + its model list from the remote endpoint. + type: boolean + region: + description: Region is the AWS region for the Bedrock + Runtime endpoint. + minLength: 1 + type: string + retryMode: + description: RetryMode is the type of retries to + perform (e.g., standard, adaptive). + type: string + sessionTTL: + description: SessionTTL is the time in seconds until + a session expires. + minimum: 1 + type: integer + totalMaxAttempts: + description: |- + TotalMaxAttempts is the maximum number of attempts for a single request, + including the initial attempt. + minimum: 1 + type: integer + required: + - region + type: object + x-kubernetes-validations: + - message: awsRoleArn must not be empty if specified + rule: '!has(self.awsRoleArn) || self.awsRoleArn.size() + > 0' + - message: awsWebIdentityTokenFile must not be empty + if specified + rule: '!has(self.awsWebIdentityTokenFile) || self.awsWebIdentityTokenFile.size() + > 0' + - message: awsRoleSessionName must not be empty if specified + rule: '!has(self.awsRoleSessionName) || self.awsRoleSessionName.size() + > 0' + - message: profileName must not be empty if specified + rule: '!has(self.profileName) || self.profileName.size() + > 0' + - message: retryMode must not be empty if specified + rule: '!has(self.retryMode) || self.retryMode.size() + > 0' + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + custom: + items: + description: CustomProvider defines the configuration + for a custom provider instance. + properties: + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + secretRefs: + additionalProperties: + description: |- + SecretKeyRef references a specific key in a Kubernetes Secret. + The Secret must be in the same namespace as the OGXServer and must have + the label ogx.io/watch: "true" to be detected by the operator's cache. + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + description: |- + SecretRefs is a map of named secret references for provider-specific + connection fields (e.g., host, password). Each key becomes the env var + field suffix and maps to config. with env var substitution. + Each Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + minProperties: 1 + type: object + settings: + description: |- + Settings contains provider-specific configuration merged into the + provider's config section in config.yaml. Passed through as-is + without any secret resolution. Use secretRefs for secret values. + x-kubernetes-preserve-unknown-fields: true + type: + description: |- + Type is the provider type, specified with a "remote::" or "inline::" + prefix (e.g., "remote::llama-guard", "inline::my-provider"). + minLength: 1 + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: type must have a 'remote::' or 'inline::' + prefix (e.g., 'remote::llama-guard', 'inline::my-provider') + rule: self.type.startsWith('remote::') || self.type.startsWith('inline::') + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + openai: + items: + description: OpenAIProvider configures a remote::openai + inference provider instance. + properties: + allowedModels: + description: |- + AllowedModels restricts which models can be registered with this provider. + When empty, all models are allowed. + items: + minLength: 1 + type: string + minItems: 1 + type: array + apiKey: + description: |- + APIKey is the authentication credential for the OpenAI provider. + The Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + endpoint: + description: Endpoint is the base URL for the OpenAI + API. + type: string + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + network: + description: |- + Network configures network settings (TLS, proxy, timeouts, headers) + for the remote connection. + properties: + headers: + additionalProperties: + type: string + description: Headers specifies additional HTTP + headers to include in all requests. + minProperties: 1 + type: object + proxy: + description: Proxy configures HTTP proxy settings. + properties: + cacert: + description: CACert is the path to a CA + certificate for verifying the proxy's + certificate. + type: string + http: + description: HTTP is the proxy URL for HTTP + connections. + type: string + https: + description: HTTPS is the proxy URL for + HTTPS connections. + type: string + noProxy: + description: NoProxy is a list of hosts + that should bypass the proxy. + items: + minLength: 1 + type: string + minItems: 1 + type: array + url: + description: URL is the proxy URL for all + connections. + type: string + type: object + x-kubernetes-validations: + - message: url must not be empty if specified + rule: '!has(self.url) || self.url.size() > + 0' + - message: http must not be empty if specified + rule: '!has(self.http) || self.http.size() + > 0' + - message: https must not be empty if specified + rule: '!has(self.https) || self.https.size() + > 0' + - message: cacert must not be empty if specified + rule: '!has(self.cacert) || self.cacert.size() + > 0' + timeout: + description: Timeout configures connection and + read timeout settings. + properties: + connect: + description: Connect is the connection timeout + in seconds. + minimum: 1 + type: integer + read: + description: Read is the read timeout in + seconds. + minimum: 1 + type: integer + type: object + tls: + description: TLS configures TLS/SSL settings. + properties: + ciphers: + description: Ciphers is a list of allowed + TLS cipher suites. + items: + minLength: 1 + type: string + minItems: 1 + type: array + minVersion: + description: MinVersion sets the minimum + TLS version. + enum: + - TLSv1.2 + - TLSv1.3 + type: string + verify: + description: |- + Verify controls whether TLS certificate verification is enabled. + Trust anchors and client identity are configured globally via spec.tls. + type: boolean + type: object + type: object + refreshModels: + description: |- + RefreshModels controls whether the provider periodically refreshes + its model list from the remote endpoint. + type: boolean + required: + - apiKey + type: object + x-kubernetes-validations: + - message: endpoint must not be empty if specified + rule: '!has(self.endpoint) || self.endpoint.size() + > 0' + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + vertexai: + items: + description: VertexAIProvider configures a remote::vertexai + inference provider instance. + properties: + allowedModels: + description: |- + AllowedModels restricts which models can be registered with this provider. + When empty, all models are allowed. + items: + minLength: 1 + type: string + minItems: 1 + type: array + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + location: + description: Location is the Google Cloud location + for Vertex AI. + type: string + network: + description: |- + Network configures network settings (TLS, proxy, timeouts, headers) + for the remote connection. + properties: + headers: + additionalProperties: + type: string + description: Headers specifies additional HTTP + headers to include in all requests. + minProperties: 1 + type: object + proxy: + description: Proxy configures HTTP proxy settings. + properties: + cacert: + description: CACert is the path to a CA + certificate for verifying the proxy's + certificate. + type: string + http: + description: HTTP is the proxy URL for HTTP + connections. + type: string + https: + description: HTTPS is the proxy URL for + HTTPS connections. + type: string + noProxy: + description: NoProxy is a list of hosts + that should bypass the proxy. + items: + minLength: 1 + type: string + minItems: 1 + type: array + url: + description: URL is the proxy URL for all + connections. + type: string + type: object + x-kubernetes-validations: + - message: url must not be empty if specified + rule: '!has(self.url) || self.url.size() > + 0' + - message: http must not be empty if specified + rule: '!has(self.http) || self.http.size() + > 0' + - message: https must not be empty if specified + rule: '!has(self.https) || self.https.size() + > 0' + - message: cacert must not be empty if specified + rule: '!has(self.cacert) || self.cacert.size() + > 0' + timeout: + description: Timeout configures connection and + read timeout settings. + properties: + connect: + description: Connect is the connection timeout + in seconds. + minimum: 1 + type: integer + read: + description: Read is the read timeout in + seconds. + minimum: 1 + type: integer + type: object + tls: + description: TLS configures TLS/SSL settings. + properties: + ciphers: + description: Ciphers is a list of allowed + TLS cipher suites. + items: + minLength: 1 + type: string + minItems: 1 + type: array + minVersion: + description: MinVersion sets the minimum + TLS version. + enum: + - TLSv1.2 + - TLSv1.3 + type: string + verify: + description: |- + Verify controls whether TLS certificate verification is enabled. + Trust anchors and client identity are configured globally via spec.tls. + type: boolean + type: object + type: object + project: + description: Project is the Google Cloud project + ID for Vertex AI. + minLength: 1 + type: string + refreshModels: + description: |- + RefreshModels controls whether the provider periodically refreshes + its model list from the remote endpoint. + type: boolean + required: + - project + type: object + x-kubernetes-validations: + - message: location must not be empty if specified + rule: '!has(self.location) || self.location.size() + > 0' + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + vllm: + items: + description: VLLMProvider configures a remote::vllm + inference provider instance. + properties: + allowedModels: + description: |- + AllowedModels restricts which models can be registered with this provider. + When empty, all models are allowed. + items: + minLength: 1 + type: string + minItems: 1 + type: array + apiToken: + description: |- + APIToken is the authentication token for the vLLM endpoint. + The Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + endpoint: + description: Endpoint is the URL for the vLLM model + serving endpoint. + minLength: 1 + type: string + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + maxTokens: + description: MaxTokens is the maximum number of + tokens to generate. + minimum: 1 + type: integer + network: + description: |- + Network configures network settings (TLS, proxy, timeouts, headers) + for the remote connection. + properties: + headers: + additionalProperties: + type: string + description: Headers specifies additional HTTP + headers to include in all requests. + minProperties: 1 + type: object + proxy: + description: Proxy configures HTTP proxy settings. + properties: + cacert: + description: CACert is the path to a CA + certificate for verifying the proxy's + certificate. + type: string + http: + description: HTTP is the proxy URL for HTTP + connections. + type: string + https: + description: HTTPS is the proxy URL for + HTTPS connections. + type: string + noProxy: + description: NoProxy is a list of hosts + that should bypass the proxy. + items: + minLength: 1 + type: string + minItems: 1 + type: array + url: + description: URL is the proxy URL for all + connections. + type: string + type: object + x-kubernetes-validations: + - message: url must not be empty if specified + rule: '!has(self.url) || self.url.size() > + 0' + - message: http must not be empty if specified + rule: '!has(self.http) || self.http.size() + > 0' + - message: https must not be empty if specified + rule: '!has(self.https) || self.https.size() + > 0' + - message: cacert must not be empty if specified + rule: '!has(self.cacert) || self.cacert.size() + > 0' + timeout: + description: Timeout configures connection and + read timeout settings. + properties: + connect: + description: Connect is the connection timeout + in seconds. + minimum: 1 + type: integer + read: + description: Read is the read timeout in + seconds. + minimum: 1 + type: integer + type: object + tls: + description: TLS configures TLS/SSL settings. + properties: + ciphers: + description: Ciphers is a list of allowed + TLS cipher suites. + items: + minLength: 1 + type: string + minItems: 1 + type: array + minVersion: + description: MinVersion sets the minimum + TLS version. + enum: + - TLSv1.2 + - TLSv1.3 + type: string + verify: + description: |- + Verify controls whether TLS certificate verification is enabled. + Trust anchors and client identity are configured globally via spec.tls. + type: boolean + type: object + type: object + refreshModels: + description: |- + RefreshModels controls whether the provider periodically refreshes + its model list from the remote endpoint. + type: boolean + required: + - endpoint + type: object + x-kubernetes-validations: + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + watsonx: + items: + description: WatsonxProvider configures a remote::watsonx + inference provider instance. + properties: + allowedModels: + description: |- + AllowedModels restricts which models can be registered with this provider. + When empty, all models are allowed. + items: + minLength: 1 + type: string + minItems: 1 + type: array + apiKey: + description: |- + APIKey is the authentication credential for the watsonx provider. + The Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + endpoint: + description: Endpoint is the base URL for accessing + watsonx.ai. + type: string + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + network: + description: |- + Network configures network settings (TLS, proxy, timeouts, headers) + for the remote connection. + properties: + headers: + additionalProperties: + type: string + description: Headers specifies additional HTTP + headers to include in all requests. + minProperties: 1 + type: object + proxy: + description: Proxy configures HTTP proxy settings. + properties: + cacert: + description: CACert is the path to a CA + certificate for verifying the proxy's + certificate. + type: string + http: + description: HTTP is the proxy URL for HTTP + connections. + type: string + https: + description: HTTPS is the proxy URL for + HTTPS connections. + type: string + noProxy: + description: NoProxy is a list of hosts + that should bypass the proxy. + items: + minLength: 1 + type: string + minItems: 1 + type: array + url: + description: URL is the proxy URL for all + connections. + type: string + type: object + x-kubernetes-validations: + - message: url must not be empty if specified + rule: '!has(self.url) || self.url.size() > + 0' + - message: http must not be empty if specified + rule: '!has(self.http) || self.http.size() + > 0' + - message: https must not be empty if specified + rule: '!has(self.https) || self.https.size() + > 0' + - message: cacert must not be empty if specified + rule: '!has(self.cacert) || self.cacert.size() + > 0' + timeout: + description: Timeout configures connection and + read timeout settings. + properties: + connect: + description: Connect is the connection timeout + in seconds. + minimum: 1 + type: integer + read: + description: Read is the read timeout in + seconds. + minimum: 1 + type: integer + type: object + tls: + description: TLS configures TLS/SSL settings. + properties: + ciphers: + description: Ciphers is a list of allowed + TLS cipher suites. + items: + minLength: 1 + type: string + minItems: 1 + type: array + minVersion: + description: MinVersion sets the minimum + TLS version. + enum: + - TLSv1.2 + - TLSv1.3 + type: string + verify: + description: |- + Verify controls whether TLS certificate verification is enabled. + Trust anchors and client identity are configured globally via spec.tls. + type: boolean + type: object + type: object + projectId: + description: ProjectID is the watsonx.ai project + ID. + type: string + refreshModels: + description: |- + RefreshModels controls whether the provider periodically refreshes + its model list from the remote endpoint. + type: boolean + timeout: + description: Timeout is the timeout in seconds for + HTTP requests. + minimum: 1 + type: integer + required: + - apiKey + type: object + x-kubernetes-validations: + - message: endpoint must not be empty if specified + rule: '!has(self.endpoint) || self.endpoint.size() + > 0' + - message: projectId must not be empty if specified + rule: '!has(self.projectId) || self.projectId.size() + > 0' + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + type: object + type: object + responses: + description: ResponsesProvidersSpec configures responses providers. + properties: + inline: + description: ResponsesInlineProviders groups inline responses + providers. + properties: + builtin: + description: InlineBuiltinResponsesProvider configures + inline::builtin for responses. + properties: + compactionConfig: + description: |- + CompactionConfig configures conversation compaction behavior + and prompt templates. + properties: + defaultCompactThreshold: + description: |- + DefaultCompactThreshold is the token count threshold for auto-compaction. + Conversations exceeding this count will be automatically compacted. + minimum: 1 + type: integer + summarizationModel: + description: |- + SummarizationModel is the model to use for generating compaction + summaries. If unset, uses the same model as the conversation. + type: string + summarizationPrompt: + description: |- + SummarizationPrompt is the prompt used to instruct the model to + summarize conversation history during compaction. + type: string + summaryPrefix: + description: |- + SummaryPrefix is text prepended to the compaction summary to frame + it as a handoff for the next LLM context window. + type: string + tokenizerEncoding: + description: |- + TokenizerEncoding is the tiktoken encoding name for token counting + (e.g., "o200k_base", "cl100k_base"). + type: string + type: object + x-kubernetes-validations: + - message: summarizationPrompt must not be empty if + specified + rule: '!has(self.summarizationPrompt) || self.summarizationPrompt.size() + > 0' + - message: summaryPrefix must not be empty if specified + rule: '!has(self.summaryPrefix) || self.summaryPrefix.size() + > 0' + - message: summarizationModel must not be empty if + specified + rule: '!has(self.summarizationModel) || self.summarizationModel.size() + > 0' + - message: tokenizerEncoding must not be empty if + specified + rule: '!has(self.tokenizerEncoding) || self.tokenizerEncoding.size() + > 0' + vectorStoresConfig: + description: |- + VectorStoresConfig configures vector store behavior for file search + and retrieval-augmented generation. + properties: + annotationPromptParams: + description: AnnotationPromptParams configures + source annotation settings. + properties: + annotationInstructionTemplate: + description: AnnotationInstructionTemplate + provides instructions for citing sources. + type: string + chunkAnnotationTemplate: + description: ChunkAnnotationTemplate is the + template for chunks with annotation info. + type: string + enableAnnotations: + description: EnableAnnotations controls whether + source annotations are included. + type: boolean + type: object + chunkRetrievalParams: + description: ChunkRetrievalParams configures chunk + retrieval and ranking. + properties: + chunkMultiplier: + description: ChunkMultiplier multiplies the + number of chunks retrieved for over-retrieval. + minimum: 1 + type: integer + defaultRerankerStrategy: + description: DefaultRerankerStrategy is the + default reranking strategy. + enum: + - rrf + - weighted + - normalized + type: string + defaultSearchMode: + description: DefaultSearchMode is the default + search mode. + enum: + - vector + - keyword + - hybrid + type: string + maxTokensInContext: + description: MaxTokensInContext limits total + tokens allowed in RAG context. + minimum: 1 + type: integer + rrfImpactFactor: + description: |- + RRFImpactFactor is the impact factor for Reciprocal Rank Fusion reranking. + Specified as a decimal string (e.g., "60.0"). + type: string + weightedSearchAlpha: + description: |- + WeightedSearchAlpha is the alpha weight for weighted search reranking (0.0-1.0). + Specified as a decimal string (e.g., "0.5"). + type: string + type: object + contextPromptParams: + description: ContextPromptParams configures context + prompt templates. + properties: + chunkAnnotationTemplate: + description: ChunkAnnotationTemplate is the + template for formatting individual chunks. + type: string + contextTemplate: + description: ContextTemplate is the template + for explaining search results to the model. + type: string + type: object + contextualRetrievalParams: + description: ContextualRetrievalParams configures + contextual retrieval during ingestion. + properties: + defaultMaxConcurrency: + description: DefaultMaxConcurrency limits + concurrent LLM calls for contextualization. + minimum: 1 + type: integer + defaultTimeoutSeconds: + description: DefaultTimeoutSeconds is the + timeout per LLM contextualization call. + minimum: 1 + type: integer + maxDocumentTokens: + description: MaxDocumentTokens limits document + size in tokens for contextual retrieval. + minimum: 1 + type: integer + model: + description: Model is the default LLM model + for contextual retrieval. + properties: + embeddingDimensions: + description: EmbeddingDimensions is the + dimensionality of the embedding vectors. + minimum: 1 + type: integer + modelId: + description: ModelID is the model identifier. + minLength: 1 + type: string + providerId: + description: ProviderID is the provider + to use for this model. + minLength: 1 + type: string + required: + - modelId + - providerId + type: object + type: object + defaultEmbeddingModel: + description: DefaultEmbeddingModel configures + the default embedding model. + properties: + embeddingDimensions: + description: EmbeddingDimensions is the dimensionality + of the embedding vectors. + minimum: 1 + type: integer + modelId: + description: ModelID is the model identifier. + minLength: 1 + type: string + providerId: + description: ProviderID is the provider to + use for this model. + minLength: 1 + type: string + required: + - modelId + - providerId + type: object + defaultProviderId: + description: |- + DefaultProviderID is the vector_io provider to use when multiple + providers are available and none is specified. + type: string + defaultRerankerModel: + description: DefaultRerankerModel configures the + default reranker model. + properties: + modelId: + description: ModelID is the model identifier. + minLength: 1 + type: string + providerId: + description: ProviderID is the provider to + use for this model. + minLength: 1 + type: string + required: + - modelId + - providerId + type: object + fileBatchParams: + description: FileBatchParams configures file batch + processing. + properties: + cleanupIntervalSeconds: + description: CleanupIntervalSeconds is the + interval between expired batch cleanup runs. + minimum: 1 + type: integer + fileBatchChunkSize: + description: FileBatchChunkSize is the number + of files to process in each batch chunk. + minimum: 1 + type: integer + maxConcurrentFilesPerBatch: + description: MaxConcurrentFilesPerBatch limits + concurrent files processed per batch. + minimum: 1 + type: integer + type: object + fileIngestionParams: + description: FileIngestionParams configures file + ingestion chunk settings. + properties: + defaultChunkOverlapTokens: + description: DefaultChunkOverlapTokens is + the default overlap between chunks in tokens. + minimum: 0 + type: integer + defaultChunkSizeTokens: + description: DefaultChunkSizeTokens is the + default chunk size in tokens. + minimum: 1 + type: integer + type: object + fileSearchParams: + description: FileSearchParams configures file + search output formatting. + properties: + footerTemplate: + description: FooterTemplate is the template + for the footer text after search results. + type: string + headerTemplate: + description: HeaderTemplate is the template + for the header text before search results. + type: string + type: object + rewriteQueryParams: + description: RewriteQueryParams configures query + rewriting/expansion. Nil disables rewriting. + properties: + maxTokens: + description: MaxTokens is the maximum number + of tokens for query expansion responses. + minimum: 1 + type: integer + model: + description: Model is the LLM model used for + query rewriting. + properties: + embeddingDimensions: + description: EmbeddingDimensions is the + dimensionality of the embedding vectors. + minimum: 1 + type: integer + modelId: + description: ModelID is the model identifier. + minLength: 1 + type: string + providerId: + description: ProviderID is the provider + to use for this model. + minLength: 1 + type: string + required: + - modelId + - providerId + type: object + prompt: + description: |- + Prompt is the prompt template for query rewriting. + Use {query} as a placeholder for the original query. + type: string + temperature: + description: |- + Temperature controls randomness in query rewriting (0.0 = deterministic, 1.0 = creative). + Specified as a decimal string (e.g., "0.7"). + type: string + type: object + type: object + x-kubernetes-validations: + - message: defaultProviderId must not be empty if + specified + rule: '!has(self.defaultProviderId) || self.defaultProviderId.size() + > 0' + type: object + custom: + items: + description: CustomProvider defines the configuration + for a custom provider instance. + properties: + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + secretRefs: + additionalProperties: + description: |- + SecretKeyRef references a specific key in a Kubernetes Secret. + The Secret must be in the same namespace as the OGXServer and must have + the label ogx.io/watch: "true" to be detected by the operator's cache. + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + description: |- + SecretRefs is a map of named secret references for provider-specific + connection fields (e.g., host, password). Each key becomes the env var + field suffix and maps to config. with env var substitution. + Each Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + minProperties: 1 + type: object + settings: + description: |- + Settings contains provider-specific configuration merged into the + provider's config section in config.yaml. Passed through as-is + without any secret resolution. Use secretRefs for secret values. + x-kubernetes-preserve-unknown-fields: true + type: + description: |- + Type is the provider type, specified with a "remote::" or "inline::" + prefix (e.g., "remote::llama-guard", "inline::my-provider"). + minLength: 1 + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: type must have a 'remote::' or 'inline::' + prefix (e.g., 'remote::llama-guard', 'inline::my-provider') + rule: self.type.startsWith('remote::') || self.type.startsWith('inline::') + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + type: object + remote: + description: ResponsesRemoteProviders groups remote responses + providers. + properties: + custom: + items: + description: CustomProvider defines the configuration + for a custom provider instance. + properties: + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + secretRefs: + additionalProperties: + description: |- + SecretKeyRef references a specific key in a Kubernetes Secret. + The Secret must be in the same namespace as the OGXServer and must have + the label ogx.io/watch: "true" to be detected by the operator's cache. + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + description: |- + SecretRefs is a map of named secret references for provider-specific + connection fields (e.g., host, password). Each key becomes the env var + field suffix and maps to config. with env var substitution. + Each Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + minProperties: 1 + type: object + settings: + description: |- + Settings contains provider-specific configuration merged into the + provider's config section in config.yaml. Passed through as-is + without any secret resolution. Use secretRefs for secret values. + x-kubernetes-preserve-unknown-fields: true + type: + description: |- + Type is the provider type, specified with a "remote::" or "inline::" + prefix (e.g., "remote::llama-guard", "inline::my-provider"). + minLength: 1 + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: type must have a 'remote::' or 'inline::' + prefix (e.g., 'remote::llama-guard', 'inline::my-provider') + rule: self.type.startsWith('remote::') || self.type.startsWith('inline::') + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + type: object + type: object + toolRuntime: + description: ToolRuntimeProvidersSpec configures tool runtime + providers. + properties: + inline: + description: ToolRuntimeInlineProviders groups inline tool + runtime providers. + properties: + custom: + items: + description: CustomProvider defines the configuration + for a custom provider instance. + properties: + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + secretRefs: + additionalProperties: + description: |- + SecretKeyRef references a specific key in a Kubernetes Secret. + The Secret must be in the same namespace as the OGXServer and must have + the label ogx.io/watch: "true" to be detected by the operator's cache. + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + description: |- + SecretRefs is a map of named secret references for provider-specific + connection fields (e.g., host, password). Each key becomes the env var + field suffix and maps to config. with env var substitution. + Each Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + minProperties: 1 + type: object + settings: + description: |- + Settings contains provider-specific configuration merged into the + provider's config section in config.yaml. Passed through as-is + without any secret resolution. Use secretRefs for secret values. + x-kubernetes-preserve-unknown-fields: true + type: + description: |- + Type is the provider type, specified with a "remote::" or "inline::" + prefix (e.g., "remote::llama-guard", "inline::my-provider"). + minLength: 1 + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: type must have a 'remote::' or 'inline::' + prefix (e.g., 'remote::llama-guard', 'inline::my-provider') + rule: self.type.startsWith('remote::') || self.type.startsWith('inline::') + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + fileSearch: + items: + description: InlineFileSearchProvider configures inline::file-search. + properties: + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + vectorStoresConfig: + description: VectorStoresConfig configures vector + store behavior for file search. + properties: + annotationPromptParams: + description: AnnotationPromptParams configures + source annotation settings. + properties: + annotationInstructionTemplate: + description: AnnotationInstructionTemplate + provides instructions for citing sources. + type: string + chunkAnnotationTemplate: + description: ChunkAnnotationTemplate is + the template for chunks with annotation + info. + type: string + enableAnnotations: + description: EnableAnnotations controls + whether source annotations are included. + type: boolean + type: object + chunkRetrievalParams: + description: ChunkRetrievalParams configures + chunk retrieval and ranking. + properties: + chunkMultiplier: + description: ChunkMultiplier multiplies + the number of chunks retrieved for over-retrieval. + minimum: 1 + type: integer + defaultRerankerStrategy: + description: DefaultRerankerStrategy is + the default reranking strategy. + enum: + - rrf + - weighted + - normalized + type: string + defaultSearchMode: + description: DefaultSearchMode is the default + search mode. + enum: + - vector + - keyword + - hybrid + type: string + maxTokensInContext: + description: MaxTokensInContext limits total + tokens allowed in RAG context. + minimum: 1 + type: integer + rrfImpactFactor: + description: |- + RRFImpactFactor is the impact factor for Reciprocal Rank Fusion reranking. + Specified as a decimal string (e.g., "60.0"). + type: string + weightedSearchAlpha: + description: |- + WeightedSearchAlpha is the alpha weight for weighted search reranking (0.0-1.0). + Specified as a decimal string (e.g., "0.5"). + type: string + type: object + contextPromptParams: + description: ContextPromptParams configures + context prompt templates. + properties: + chunkAnnotationTemplate: + description: ChunkAnnotationTemplate is + the template for formatting individual + chunks. + type: string + contextTemplate: + description: ContextTemplate is the template + for explaining search results to the model. + type: string + type: object + contextualRetrievalParams: + description: ContextualRetrievalParams configures + contextual retrieval during ingestion. + properties: + defaultMaxConcurrency: + description: DefaultMaxConcurrency limits + concurrent LLM calls for contextualization. + minimum: 1 + type: integer + defaultTimeoutSeconds: + description: DefaultTimeoutSeconds is the + timeout per LLM contextualization call. + minimum: 1 + type: integer + maxDocumentTokens: + description: MaxDocumentTokens limits document + size in tokens for contextual retrieval. + minimum: 1 + type: integer + model: + description: Model is the default LLM model + for contextual retrieval. + properties: + embeddingDimensions: + description: EmbeddingDimensions is + the dimensionality of the embedding + vectors. + minimum: 1 + type: integer + modelId: + description: ModelID is the model identifier. + minLength: 1 + type: string + providerId: + description: ProviderID is the provider + to use for this model. + minLength: 1 + type: string + required: + - modelId + - providerId + type: object + type: object + defaultEmbeddingModel: + description: DefaultEmbeddingModel configures + the default embedding model. + properties: + embeddingDimensions: + description: EmbeddingDimensions is the + dimensionality of the embedding vectors. + minimum: 1 + type: integer + modelId: + description: ModelID is the model identifier. + minLength: 1 + type: string + providerId: + description: ProviderID is the provider + to use for this model. + minLength: 1 + type: string + required: + - modelId + - providerId + type: object + defaultProviderId: + description: |- + DefaultProviderID is the vector_io provider to use when multiple + providers are available and none is specified. + type: string + defaultRerankerModel: + description: DefaultRerankerModel configures + the default reranker model. + properties: + modelId: + description: ModelID is the model identifier. + minLength: 1 + type: string + providerId: + description: ProviderID is the provider + to use for this model. + minLength: 1 + type: string + required: + - modelId + - providerId + type: object + fileBatchParams: + description: FileBatchParams configures file + batch processing. + properties: + cleanupIntervalSeconds: + description: CleanupIntervalSeconds is the + interval between expired batch cleanup + runs. + minimum: 1 + type: integer + fileBatchChunkSize: + description: FileBatchChunkSize is the number + of files to process in each batch chunk. + minimum: 1 + type: integer + maxConcurrentFilesPerBatch: + description: MaxConcurrentFilesPerBatch + limits concurrent files processed per + batch. + minimum: 1 + type: integer + type: object + fileIngestionParams: + description: FileIngestionParams configures + file ingestion chunk settings. + properties: + defaultChunkOverlapTokens: + description: DefaultChunkOverlapTokens is + the default overlap between chunks in + tokens. + minimum: 0 + type: integer + defaultChunkSizeTokens: + description: DefaultChunkSizeTokens is the + default chunk size in tokens. + minimum: 1 + type: integer + type: object + fileSearchParams: + description: FileSearchParams configures file + search output formatting. + properties: + footerTemplate: + description: FooterTemplate is the template + for the footer text after search results. + type: string + headerTemplate: + description: HeaderTemplate is the template + for the header text before search results. + type: string + type: object + rewriteQueryParams: + description: RewriteQueryParams configures query + rewriting/expansion. Nil disables rewriting. + properties: + maxTokens: + description: MaxTokens is the maximum number + of tokens for query expansion responses. + minimum: 1 + type: integer + model: + description: Model is the LLM model used + for query rewriting. + properties: + embeddingDimensions: + description: EmbeddingDimensions is + the dimensionality of the embedding + vectors. + minimum: 1 + type: integer + modelId: + description: ModelID is the model identifier. + minLength: 1 + type: string + providerId: + description: ProviderID is the provider + to use for this model. + minLength: 1 + type: string + required: + - modelId + - providerId + type: object + prompt: + description: |- + Prompt is the prompt template for query rewriting. + Use {query} as a placeholder for the original query. + type: string + temperature: + description: |- + Temperature controls randomness in query rewriting (0.0 = deterministic, 1.0 = creative). + Specified as a decimal string (e.g., "0.7"). + type: string + type: object + type: object + x-kubernetes-validations: + - message: defaultProviderId must not be empty if + specified + rule: '!has(self.defaultProviderId) || self.defaultProviderId.size() + > 0' + type: object + x-kubernetes-validations: + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + type: object + remote: + description: ToolRuntimeRemoteProviders groups remote tool + runtime providers. + properties: + braveSearch: + items: + description: BraveSearchProvider configures a remote::brave-search + tool runtime provider. + properties: + apiKey: + description: |- + APIKey is the Brave Search API key. + The Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + maxResults: + description: MaxResults is the maximum number of + search results to return. + minimum: 1 + type: integer + required: + - apiKey + type: object + x-kubernetes-validations: + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + custom: + items: + description: CustomProvider defines the configuration + for a custom provider instance. + properties: + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + secretRefs: + additionalProperties: + description: |- + SecretKeyRef references a specific key in a Kubernetes Secret. + The Secret must be in the same namespace as the OGXServer and must have + the label ogx.io/watch: "true" to be detected by the operator's cache. + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + description: |- + SecretRefs is a map of named secret references for provider-specific + connection fields (e.g., host, password). Each key becomes the env var + field suffix and maps to config. with env var substitution. + Each Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + minProperties: 1 + type: object + settings: + description: |- + Settings contains provider-specific configuration merged into the + provider's config section in config.yaml. Passed through as-is + without any secret resolution. Use secretRefs for secret values. + x-kubernetes-preserve-unknown-fields: true + type: + description: |- + Type is the provider type, specified with a "remote::" or "inline::" + prefix (e.g., "remote::llama-guard", "inline::my-provider"). + minLength: 1 + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: type must have a 'remote::' or 'inline::' + prefix (e.g., 'remote::llama-guard', 'inline::my-provider') + rule: self.type.startsWith('remote::') || self.type.startsWith('inline::') + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + modelContextProtocol: + items: + description: ModelContextProtocolProvider configures + remote::model-context-protocol. + properties: + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + type: object + x-kubernetes-validations: + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + tavilySearch: + items: + description: TavilySearchProvider configures a remote::tavily-search + tool runtime provider. + properties: + apiKey: + description: |- + APIKey is the Tavily Search API key. + The Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + maxResults: + description: MaxResults is the maximum number of + search results to return. + minimum: 1 + type: integer + required: + - apiKey + type: object + x-kubernetes-validations: + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + type: object + type: object + vectorIo: + description: VectorIOProvidersSpec configures vector I/O providers. + properties: + inline: + description: VectorIOInlineProviders groups inline vector + I/O providers. + properties: + custom: + items: + description: CustomProvider defines the configuration + for a custom provider instance. + properties: + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + secretRefs: + additionalProperties: + description: |- + SecretKeyRef references a specific key in a Kubernetes Secret. + The Secret must be in the same namespace as the OGXServer and must have + the label ogx.io/watch: "true" to be detected by the operator's cache. + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + description: |- + SecretRefs is a map of named secret references for provider-specific + connection fields (e.g., host, password). Each key becomes the env var + field suffix and maps to config. with env var substitution. + Each Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + minProperties: 1 + type: object + settings: + description: |- + Settings contains provider-specific configuration merged into the + provider's config section in config.yaml. Passed through as-is + without any secret resolution. Use secretRefs for secret values. + x-kubernetes-preserve-unknown-fields: true + type: + description: |- + Type is the provider type, specified with a "remote::" or "inline::" + prefix (e.g., "remote::llama-guard", "inline::my-provider"). + minLength: 1 + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: type must have a 'remote::' or 'inline::' + prefix (e.g., 'remote::llama-guard', 'inline::my-provider') + rule: self.type.startsWith('remote::') || self.type.startsWith('inline::') + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + type: object + remote: + description: VectorIORemoteProviders groups remote vector + I/O providers. + properties: + custom: + items: + description: CustomProvider defines the configuration + for a custom provider instance. + properties: + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + secretRefs: + additionalProperties: + description: |- + SecretKeyRef references a specific key in a Kubernetes Secret. + The Secret must be in the same namespace as the OGXServer and must have + the label ogx.io/watch: "true" to be detected by the operator's cache. + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + description: |- + SecretRefs is a map of named secret references for provider-specific + connection fields (e.g., host, password). Each key becomes the env var + field suffix and maps to config. with env var substitution. + Each Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + minProperties: 1 + type: object + settings: + description: |- + Settings contains provider-specific configuration merged into the + provider's config section in config.yaml. Passed through as-is + without any secret resolution. Use secretRefs for secret values. + x-kubernetes-preserve-unknown-fields: true + type: + description: |- + Type is the provider type, specified with a "remote::" or "inline::" + prefix (e.g., "remote::llama-guard", "inline::my-provider"). + minLength: 1 + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: type must have a 'remote::' or 'inline::' + prefix (e.g., 'remote::llama-guard', 'inline::my-provider') + rule: self.type.startsWith('remote::') || self.type.startsWith('inline::') + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + milvus: + items: + description: MilvusProvider configures a remote::milvus + vector I/O provider instance. + properties: + consistencyLevel: + description: ConsistencyLevel is the consistency + level of the Milvus server. + type: string + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + token: + description: |- + Token is the authentication token for the Milvus server. + The Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + uri: + description: URI is the URI of the Milvus server. + minLength: 1 + type: string + required: + - uri + type: object + x-kubernetes-validations: + - message: consistencyLevel must not be empty if specified + rule: '!has(self.consistencyLevel) || self.consistencyLevel.size() + > 0' + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + pgvector: + items: + description: PgvectorProvider configures a remote::pgvector + vector I/O provider instance. + properties: + db: + description: DB is the PostgreSQL database name. + type: string + distanceMetric: + description: DistanceMetric is the distance metric + used for vector search. + enum: + - COSINE + - L2 + - L1 + - INNER_PRODUCT + type: string + host: + description: Host is the PostgreSQL server hostname. + type: string + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + password: + description: |- + Password is the PostgreSQL password. + The Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + port: + description: Port is the PostgreSQL server port. + maximum: 65535 + minimum: 1 + type: integer + user: + description: User is the PostgreSQL username. + type: string + vectorIndex: + description: |- + VectorIndex configures the vector index strategy for + Approximate Nearest Neighbor (ANN) search. + properties: + hnsw: + description: HNSW configures an HNSW index. + properties: + efConstruction: + description: EfConstruction controls the + index build-time accuracy/speed tradeoff. + minimum: 1 + type: integer + efSearch: + description: EfSearch controls the query-time + accuracy/speed tradeoff. + minimum: 1 + type: integer + m: + description: M is the maximum number of + connections per element in the HNSW graph. + minimum: 1 + type: integer + type: object + ivfFlat: + description: IVFFlat configures an IVFFlat index. + properties: + nlist: + description: Nlist is the number of inverted + lists (clusters). + minimum: 1 + type: integer + nprobe: + description: Nprobe is the number of clusters + to search at query time. + minimum: 1 + type: integer + type: object + type: object + x-kubernetes-validations: + - message: one of hnsw or ivfFlat must be specified + rule: has(self.hnsw) || has(self.ivfFlat) + - message: only one of hnsw or ivfFlat can be specified + rule: '!(has(self.hnsw) && has(self.ivfFlat))' + required: + - password + type: object + x-kubernetes-validations: + - message: host must not be empty if specified + rule: '!has(self.host) || self.host.size() > 0' + - message: db must not be empty if specified + rule: '!has(self.db) || self.db.size() > 0' + - message: user must not be empty if specified + rule: '!has(self.user) || self.user.size() > 0' + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + qdrant: + items: + description: QdrantProvider configures a remote::qdrant + vector I/O provider instance. + properties: + apiKey: + description: |- + APIKey is the authentication key for the Qdrant server. + The Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + grpcPort: + description: GRPCPort is the gRPC port of the Qdrant + server. + maximum: 65535 + minimum: 1 + type: integer + host: + description: Host is the hostname of the Qdrant + server. + type: string + https: + description: HTTPS controls whether to use HTTPS + for the connection. + type: boolean + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + location: + description: Location is the Qdrant server location + identifier. + type: string + port: + description: Port is the REST API port of the Qdrant + server. + maximum: 65535 + minimum: 1 + type: integer + preferGrpc: + description: PreferGRPC controls whether to prefer + gRPC over REST for communication. + type: boolean + prefix: + description: Prefix is the URL path prefix for the + Qdrant server. + type: string + timeout: + description: Timeout is the connection timeout in + seconds. + minimum: 1 + type: integer + url: + description: URL is the URL of the Qdrant server. + type: string + type: object + x-kubernetes-validations: + - message: at least one of url or host must be specified + rule: has(self.url) || has(self.host) + - message: url must not be empty if specified + rule: '!has(self.url) || self.url.size() > 0' + - message: host must not be empty if specified + rule: '!has(self.host) || self.host.size() > 0' + - message: location must not be empty if specified + rule: '!has(self.location) || self.location.size() + > 0' + - message: prefix must not be empty if specified + rule: '!has(self.prefix) || self.prefix.size() > 0' + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + type: object + type: object + type: object + registryRefreshIntervalSeconds: + description: |- + RegistryRefreshIntervalSeconds configures how often the server refreshes + its model registry, in seconds. When omitted, the server's built-in + default is used. + format: int32 + minimum: 1 + type: integer + resources: + description: |- + Resources declares models to register. + Mutually exclusive with overrideConfig. + properties: + models: + description: Models to register with inference providers. + items: + description: ModelConfig defines a model registration with optional + provider assignment and metadata. + properties: + contextLength: + description: ContextLength is the model context window size. + type: integer + modelType: + description: ModelType is the model type classification. + type: string + name: + description: Name is the model identifier (e.g., "llama3.2-8b"). + minLength: 1 + type: string + provider: + description: |- + Provider is the ID of the provider to register this model with. + Defaults to the first inference provider when omitted. + type: string + quantization: + description: Quantization is the quantization method. + type: string + required: + - name + type: object + x-kubernetes-validations: + - message: provider must not be empty if specified + rule: '!has(self.provider) || self.provider.size() > 0' + - message: modelType must not be empty if specified + rule: '!has(self.modelType) || self.modelType.size() > 0' + - message: quantization must not be empty if specified + rule: '!has(self.quantization) || self.quantization.size() + > 0' + minItems: 1 + type: array + type: object + storage: + description: |- + Storage configures state storage backends (KV and SQL). + Mutually exclusive with overrideConfig. + properties: + kv: + description: KV configures key-value storage. + properties: + endpoint: + description: Endpoint is the Redis endpoint URL. Required + when type is "redis". + type: string + password: + description: |- + Password references a Secret for Redis authentication. + The Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + type: + default: sqlite + description: Type is the KV storage backend type. + enum: + - sqlite + - redis + type: string + type: object + x-kubernetes-validations: + - message: endpoint is required when type is redis + rule: self.type != 'redis' || has(self.endpoint) + - message: endpoint is only valid when type is redis + rule: '!has(self.endpoint) || self.type == ''redis''' + - message: password is only valid when type is redis + rule: '!has(self.password) || self.type == ''redis''' + sql: + description: SQL configures SQL storage. + properties: + connectionString: + description: |- + ConnectionString references a Secret containing the database connection string. + Required when type is "postgres". + The Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + type: + default: sqlite + description: Type is the SQL storage backend type. + enum: + - sqlite + - postgres + type: string + type: object + x-kubernetes-validations: + - message: connectionString is required when type is postgres + rule: self.type != 'postgres' || has(self.connectionString) + - message: connectionString is only valid when type is postgres + rule: '!has(self.connectionString) || self.type == ''postgres''' + type: object + tls: + description: |- + TLS configures outbound TLS trust anchors and client identity for + connections to providers and backends. + properties: + identity: + description: |- + Identity configures client certificate and key for mTLS authentication + with providers and backends. + properties: + cert: + description: |- + Cert references a ConfigMap key containing the PEM-encoded TLS client certificate. + The ConfigMap must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the ConfigMap. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the ConfigMap. + minLength: 1 + type: string + required: + - key + - name + type: object + key: + description: |- + Key references a Secret key containing the PEM-encoded TLS client private key. + The Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + required: + - cert + - key + type: object + trust: + description: |- + Trust configures CA certificates for verifying outbound TLS connections + to providers and backends. + properties: + caCertificates: + description: |- + CACertificates lists ConfigMap keys containing PEM-encoded CA certificates. + All certificates are concatenated into a single trust bundle. + Referenced ConfigMaps must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + items: + description: |- + ConfigMapKeyRef references a key within a ConfigMap. + The ConfigMap must be in the same namespace as the OGXServer and must have + the label ogx.io/watch: "true" to be detected by the operator's cache. + properties: + key: + description: Key is the key within the ConfigMap. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the ConfigMap. + minLength: 1 + type: string + required: + - key + - name + type: object + minItems: 1 + type: array + type: object + type: object + workload: + description: Workload consolidates Kubernetes deployment settings. + properties: + autoscaling: + description: Autoscaling configures HPA for the server pods. + properties: + maxReplicas: + description: MaxReplicas is the upper bound replica count. + format: int32 + minimum: 1 + type: integer + minReplicas: + description: MinReplicas is the lower bound replica count. + format: int32 + minimum: 1 + type: integer + targetCPUUtilizationPercentage: + description: TargetCPUUtilizationPercentage configures CPU-based + scaling. + format: int32 + maximum: 100 + minimum: 1 + type: integer + targetMemoryUtilizationPercentage: + description: TargetMemoryUtilizationPercentage configures + memory-based scaling. + format: int32 + maximum: 100 + minimum: 1 + type: integer + required: + - maxReplicas + type: object + x-kubernetes-validations: + - message: maxReplicas must be greater than or equal to minReplicas + rule: '!has(self.minReplicas) || self.maxReplicas >= self.minReplicas' + overrides: + description: Overrides allows pod-level customization. + properties: + args: + description: Args overrides the container arguments. + items: + minLength: 1 + type: string + minItems: 1 + type: array + command: + description: Command overrides the container command. + items: + minLength: 1 + type: string + minItems: 1 + type: array + env: + description: Env specifies additional environment variables. + items: + description: EnvVar represents an environment variable present + in a Container. + properties: + name: + description: |- + Name of the environment variable. + May consist of any printable ASCII characters except '='. + type: string + value: + description: |- + Variable references $(VAR_NAME) are expanded + using the previously defined environment variables in the container and + any service environment variables. If a variable cannot be resolved, + the reference in the input string will be unchanged. Double $$ are reduced + to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. + "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". + Escaped references will never be expanded, regardless of whether the variable + exists or not. + Defaults to "". + type: string + valueFrom: + description: Source for the environment variable's value. + Cannot be used if value is not empty. + properties: + configMapKeyRef: + description: Selects a key of a ConfigMap. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap or + its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + description: |- + Selects a field of the pod: supports metadata.name, metadata.namespace, `metadata.labels['']`, `metadata.annotations['']`, + spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs. + properties: + apiVersion: + description: Version of the schema the FieldPath + is written in terms of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to select in + the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + fileKeyRef: + description: |- + FileKeyRef selects a key of the env file. + Requires the EnvFiles feature gate to be enabled. + properties: + key: + description: |- + The key within the env file. An invalid key will prevent the pod from starting. + The keys defined within a source may consist of any printable ASCII characters except '='. + During Alpha stage of the EnvFiles feature gate, the key size is limited to 128 characters. + type: string + optional: + default: false + description: |- + Specify whether the file or its key must be defined. If the file or key + does not exist, then the env var is not published. + If optional is set to true and the specified key does not exist, + the environment variable will not be set in the Pod's containers. + + If optional is set to false and the specified key does not exist, + an error will be returned during Pod creation. + type: boolean + path: + description: |- + The path within the volume from which to select the file. + Must be relative and may not contain the '..' path or start with '..'. + type: string + volumeName: + description: The name of the volume mount containing + the env file. + type: string + required: + - key + - path + - volumeName + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, limits.ephemeral-storage, requests.cpu, requests.memory and requests.ephemeral-storage) are currently supported. + properties: + containerName: + description: 'Container name: required for volumes, + optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format of + the exposed resources, defaults to "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + description: Selects a key of a secret in the pod's + namespace + properties: + key: + description: The key of the secret to select + from. Must be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or its + key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + minItems: 1 + type: array + serviceAccountName: + description: ServiceAccountName specifies a custom ServiceAccount. + type: string + volumeMounts: + description: VolumeMounts adds additional volume mounts to + the container. + items: + description: VolumeMount describes a mounting of a Volume + within a container. + properties: + mountPath: + description: |- + Path within the container at which the volume should be mounted. Must + not contain ':'. + type: string + mountPropagation: + description: |- + mountPropagation determines how mounts are propagated from the host + to container and the other way around. + When not set, MountPropagationNone is used. + This field is beta in 1.10. + When RecursiveReadOnly is set to IfPossible or to Enabled, MountPropagation must be None or unspecified + (which defaults to None). + type: string + name: + description: This must match the Name of a Volume. + type: string + readOnly: + description: |- + Mounted read-only if true, read-write otherwise (false or unspecified). + Defaults to false. + type: boolean + recursiveReadOnly: + description: |- + RecursiveReadOnly specifies whether read-only mounts should be handled + recursively. + + If ReadOnly is false, this field has no meaning and must be unspecified. + + If ReadOnly is true, and this field is set to Disabled, the mount is not made + recursively read-only. If this field is set to IfPossible, the mount is made + recursively read-only, if it is supported by the container runtime. If this + field is set to Enabled, the mount is made recursively read-only if it is + supported by the container runtime, otherwise the pod will not be started and + an error will be generated to indicate the reason. + + If this field is set to IfPossible or Enabled, MountPropagation must be set to + None (or be unspecified, which defaults to None). + + If this field is not specified, it is treated as an equivalent of Disabled. + type: string + subPath: + description: |- + Path within the volume from which the container's volume should be mounted. + Defaults to "" (volume's root). + type: string + subPathExpr: + description: |- + Expanded path within the volume from which the container's volume should be mounted. + Behaves similarly to SubPath but environment variable references $(VAR_NAME) are expanded using the container's environment. + Defaults to "" (volume's root). + SubPathExpr and SubPath are mutually exclusive. + type: string + required: + - mountPath + - name + type: object + minItems: 1 + type: array + volumes: + description: Volumes adds additional volumes to the Pod. + items: + description: Volume represents a named volume in a pod that + may be accessed by any container in the pod. + properties: + awsElasticBlockStore: + description: |- + awsElasticBlockStore represents an AWS Disk resource that is attached to a + kubelet's host machine and then exposed to the pod. + Deprecated: AWSElasticBlockStore is deprecated. All operations for the in-tree + awsElasticBlockStore type are redirected to the ebs.csi.aws.com CSI driver. + More info: https://kubernetes.io/docs/concepts/storage/volumes#awselasticblockstore + properties: + fsType: + description: |- + fsType is the filesystem type of the volume that you want to mount. + Tip: Ensure that the filesystem type is supported by the host operating system. + Examples: "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" if unspecified. + More info: https://kubernetes.io/docs/concepts/storage/volumes#awselasticblockstore + type: string + partition: + description: |- + partition is the partition in the volume that you want to mount. + If omitted, the default is to mount by volume name. + Examples: For volume /dev/sda1, you specify the partition as "1". + Similarly, the volume partition for /dev/sda is "0" (or you can leave the property empty). + format: int32 + type: integer + readOnly: + description: |- + readOnly value true will force the readOnly setting in VolumeMounts. + More info: https://kubernetes.io/docs/concepts/storage/volumes#awselasticblockstore + type: boolean + volumeID: + description: |- + volumeID is unique ID of the persistent disk resource in AWS (Amazon EBS volume). + More info: https://kubernetes.io/docs/concepts/storage/volumes#awselasticblockstore + type: string + required: + - volumeID + type: object + azureDisk: + description: |- + azureDisk represents an Azure Data Disk mount on the host and bind mount to the pod. + Deprecated: AzureDisk is deprecated. All operations for the in-tree azureDisk type + are redirected to the disk.csi.azure.com CSI driver. + properties: + cachingMode: + description: 'cachingMode is the Host Caching mode: + None, Read Only, Read Write.' + type: string + diskName: + description: diskName is the Name of the data disk + in the blob storage + type: string + diskURI: + description: diskURI is the URI of data disk in + the blob storage + type: string + fsType: + default: ext4 + description: |- + fsType is Filesystem type to mount. + Must be a filesystem type supported by the host operating system. + Ex. "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" if unspecified. + type: string + kind: + description: 'kind expected values are Shared: multiple + blob disks per storage account Dedicated: single + blob disk per storage account Managed: azure + managed data disk (only in managed availability + set). defaults to shared' + type: string + readOnly: + default: false + description: |- + readOnly Defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + required: + - diskName + - diskURI + type: object + azureFile: + description: |- + azureFile represents an Azure File Service mount on the host and bind mount to the pod. + Deprecated: AzureFile is deprecated. All operations for the in-tree azureFile type + are redirected to the file.csi.azure.com CSI driver. + properties: + readOnly: + description: |- + readOnly defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + secretName: + description: secretName is the name of secret that + contains Azure Storage Account Name and Key + type: string + shareName: + description: shareName is the azure share Name + type: string + required: + - secretName + - shareName + type: object + cephfs: + description: |- + cephFS represents a Ceph FS mount on the host that shares a pod's lifetime. + Deprecated: CephFS is deprecated and the in-tree cephfs type is no longer supported. + properties: + monitors: + description: |- + monitors is Required: Monitors is a collection of Ceph monitors + More info: https://examples.k8s.io/volumes/cephfs/README.md#how-to-use-it + items: + type: string + type: array + x-kubernetes-list-type: atomic + path: + description: 'path is Optional: Used as the mounted + root, rather than the full Ceph tree, default + is /' + type: string + readOnly: + description: |- + readOnly is Optional: Defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + More info: https://examples.k8s.io/volumes/cephfs/README.md#how-to-use-it + type: boolean + secretFile: + description: |- + secretFile is Optional: SecretFile is the path to key ring for User, default is /etc/ceph/user.secret + More info: https://examples.k8s.io/volumes/cephfs/README.md#how-to-use-it + type: string + secretRef: + description: |- + secretRef is Optional: SecretRef is reference to the authentication secret for User, default is empty. + More info: https://examples.k8s.io/volumes/cephfs/README.md#how-to-use-it + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + user: + description: |- + user is optional: User is the rados user name, default is admin + More info: https://examples.k8s.io/volumes/cephfs/README.md#how-to-use-it + type: string + required: + - monitors + type: object + cinder: + description: |- + cinder represents a cinder volume attached and mounted on kubelets host machine. + Deprecated: Cinder is deprecated. All operations for the in-tree cinder type + are redirected to the cinder.csi.openstack.org CSI driver. + More info: https://examples.k8s.io/mysql-cinder-pd/README.md + properties: + fsType: + description: |- + fsType is the filesystem type to mount. + Must be a filesystem type supported by the host operating system. + Examples: "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" if unspecified. + More info: https://examples.k8s.io/mysql-cinder-pd/README.md + type: string + readOnly: + description: |- + readOnly defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + More info: https://examples.k8s.io/mysql-cinder-pd/README.md + type: boolean + secretRef: + description: |- + secretRef is optional: points to a secret object containing parameters used to connect + to OpenStack. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + volumeID: + description: |- + volumeID used to identify the volume in cinder. + More info: https://examples.k8s.io/mysql-cinder-pd/README.md + type: string + required: + - volumeID + type: object + configMap: + description: configMap represents a configMap that should + populate this volume + properties: + defaultMode: + description: |- + defaultMode is optional: mode bits used to set permissions on created files by default. + Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + Defaults to 0644. + Directories within the path are not affected by this setting. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + items: + description: |- + items if unspecified, each key-value pair in the Data field of the referenced + ConfigMap will be projected into the volume as a file whose name is the + key and content is the value. If specified, the listed keys will be + projected into the specified paths, and unlisted keys will not be + present. If a key is specified which is not present in the ConfigMap, + the volume setup will error unless it is marked optional. Paths must be + relative and may not contain the '..' path or start with '..'. + items: + description: Maps a string key to a path within + a volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: |- + mode is Optional: mode bits used to set permissions on this file. + Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + May not contain the path element '..'. + May not start with the string '..'. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: optional specify whether the ConfigMap + or its keys must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + csi: + description: csi (Container Storage Interface) represents + ephemeral storage that is handled by certain external + CSI drivers. + properties: + driver: + description: |- + driver is the name of the CSI driver that handles this volume. + Consult with your admin for the correct name as registered in the cluster. + type: string + fsType: + description: |- + fsType to mount. Ex. "ext4", "xfs", "ntfs". + If not provided, the empty value is passed to the associated CSI driver + which will determine the default filesystem to apply. + type: string + nodePublishSecretRef: + description: |- + nodePublishSecretRef is a reference to the secret object containing + sensitive information to pass to the CSI driver to complete the CSI + NodePublishVolume and NodeUnpublishVolume calls. + This field is optional, and may be empty if no secret is required. If the + secret object contains more than one secret, all secret references are passed. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + readOnly: + description: |- + readOnly specifies a read-only configuration for the volume. + Defaults to false (read/write). + type: boolean + volumeAttributes: + additionalProperties: + type: string + description: |- + volumeAttributes stores driver-specific properties that are passed to the CSI + driver. Consult your driver's documentation for supported values. + type: object + required: + - driver + type: object + downwardAPI: + description: downwardAPI represents downward API about + the pod that should populate this volume + properties: + defaultMode: + description: |- + Optional: mode bits to use on created files by default. Must be a + Optional: mode bits used to set permissions on created files by default. + Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + Defaults to 0644. + Directories within the path are not affected by this setting. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + items: + description: Items is a list of downward API volume + file + items: + description: DownwardAPIVolumeFile represents + information to create the file containing the + pod field + properties: + fieldRef: + description: 'Required: Selects a field of + the pod: only annotations, labels, name, + namespace and uid are supported.' + properties: + apiVersion: + description: Version of the schema the + FieldPath is written in terms of, defaults + to "v1". + type: string + fieldPath: + description: Path of the field to select + in the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + mode: + description: |- + Optional: mode bits used to set permissions on this file, must be an octal value + between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: 'Required: Path is the relative + path name of the file to be created. Must + not be absolute or contain the ''..'' path. + Must be utf-8 encoded. The first item of + the relative path must not start with ''..''' + type: string + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, requests.cpu and requests.memory) are currently supported. + properties: + containerName: + description: 'Container name: required + for volumes, optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format + of the exposed resources, defaults to + "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + required: + - path + type: object + type: array + x-kubernetes-list-type: atomic + type: object + emptyDir: + description: |- + emptyDir represents a temporary directory that shares a pod's lifetime. + More info: https://kubernetes.io/docs/concepts/storage/volumes#emptydir + properties: + medium: + description: |- + medium represents what type of storage medium should back this directory. + The default is "" which means to use the node's default medium. + Must be an empty string (default) or Memory. + More info: https://kubernetes.io/docs/concepts/storage/volumes#emptydir + type: string + sizeLimit: + anyOf: + - type: integer + - type: string + description: |- + sizeLimit is the total amount of local storage required for this EmptyDir volume. + The size limit is also applicable for memory medium. + The maximum usage on memory medium EmptyDir would be the minimum value between + the SizeLimit specified here and the sum of memory limits of all containers in a pod. + The default is nil which means that the limit is undefined. + More info: https://kubernetes.io/docs/concepts/storage/volumes#emptydir + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + type: object + ephemeral: + description: |- + ephemeral represents a volume that is handled by a cluster storage driver. + The volume's lifecycle is tied to the pod that defines it - it will be created before the pod starts, + and deleted when the pod is removed. + + Use this if: + a) the volume is only needed while the pod runs, + b) features of normal volumes like restoring from snapshot or capacity + tracking are needed, + c) the storage driver is specified through a storage class, and + d) the storage driver supports dynamic volume provisioning through + a PersistentVolumeClaim (see EphemeralVolumeSource for more + information on the connection between this volume type + and PersistentVolumeClaim). + + Use PersistentVolumeClaim or one of the vendor-specific + APIs for volumes that persist for longer than the lifecycle + of an individual pod. + + Use CSI for light-weight local ephemeral volumes if the CSI driver is meant to + be used that way - see the documentation of the driver for + more information. + + A pod can use both types of ephemeral volumes and + persistent volumes at the same time. + properties: + volumeClaimTemplate: + description: |- + Will be used to create a stand-alone PVC to provision the volume. + The pod in which this EphemeralVolumeSource is embedded will be the + owner of the PVC, i.e. the PVC will be deleted together with the + pod. The name of the PVC will be `-` where + `` is the name from the `PodSpec.Volumes` array + entry. Pod validation will reject the pod if the concatenated name + is not valid for a PVC (for example, too long). + + An existing PVC with that name that is not owned by the pod + will *not* be used for the pod to avoid using an unrelated + volume by mistake. Starting the pod is then blocked until + the unrelated PVC is removed. If such a pre-created PVC is + meant to be used by the pod, the PVC has to updated with an + owner reference to the pod once the pod exists. Normally + this should not be necessary, but it may be useful when + manually reconstructing a broken cluster. + + This field is read-only and no changes will be made by Kubernetes + to the PVC after it has been created. + + Required, must not be nil. + properties: + metadata: + description: |- + May contain labels and annotations that will be copied into the PVC + when creating it. No other fields are allowed and will be rejected during + validation. + type: object + spec: + description: |- + The specification for the PersistentVolumeClaim. The entire content is + copied unchanged into the PVC that gets created from this + template. The same fields as in a PersistentVolumeClaim + are also valid here. + properties: + accessModes: + description: |- + accessModes contains the desired access modes the volume should have. + More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#access-modes-1 + items: + type: string + type: array + x-kubernetes-list-type: atomic + dataSource: + description: |- + dataSource field can be used to specify either: + * An existing VolumeSnapshot object (snapshot.storage.k8s.io/VolumeSnapshot) + * An existing PVC (PersistentVolumeClaim) + If the provisioner or an external controller can support the specified data source, + it will create a new volume based on the contents of the specified data source. + When the AnyVolumeDataSource feature gate is enabled, dataSource contents will be copied to dataSourceRef, + and dataSourceRef contents will be copied to dataSource when dataSourceRef.namespace is not specified. + If the namespace is specified, then dataSourceRef will not be copied to dataSource. + properties: + apiGroup: + description: |- + APIGroup is the group for the resource being referenced. + If APIGroup is not specified, the specified Kind must be in the core API group. + For any other third-party types, APIGroup is required. + type: string + kind: + description: Kind is the type of resource + being referenced + type: string + name: + description: Name is the name of resource + being referenced + type: string + required: + - kind + - name + type: object + x-kubernetes-map-type: atomic + dataSourceRef: + description: |- + dataSourceRef specifies the object from which to populate the volume with data, if a non-empty + volume is desired. This may be any object from a non-empty API group (non + core object) or a PersistentVolumeClaim object. + When this field is specified, volume binding will only succeed if the type of + the specified object matches some installed volume populator or dynamic + provisioner. + This field will replace the functionality of the dataSource field and as such + if both fields are non-empty, they must have the same value. For backwards + compatibility, when namespace isn't specified in dataSourceRef, + both fields (dataSource and dataSourceRef) will be set to the same + value automatically if one of them is empty and the other is non-empty. + When namespace is specified in dataSourceRef, + dataSource isn't set to the same value and must be empty. + There are three important differences between dataSource and dataSourceRef: + * While dataSource only allows two specific types of objects, dataSourceRef + allows any non-core object, as well as PersistentVolumeClaim objects. + * While dataSource ignores disallowed values (dropping them), dataSourceRef + preserves all values, and generates an error if a disallowed value is + specified. + * While dataSource only allows local objects, dataSourceRef allows objects + in any namespaces. + (Beta) Using this field requires the AnyVolumeDataSource feature gate to be enabled. + (Alpha) Using the namespace field of dataSourceRef requires the CrossNamespaceVolumeDataSource feature gate to be enabled. + properties: + apiGroup: + description: |- + APIGroup is the group for the resource being referenced. + If APIGroup is not specified, the specified Kind must be in the core API group. + For any other third-party types, APIGroup is required. + type: string + kind: + description: Kind is the type of resource + being referenced + type: string + name: + description: Name is the name of resource + being referenced + type: string + namespace: + description: |- + Namespace is the namespace of resource being referenced + Note that when a namespace is specified, a gateway.networking.k8s.io/ReferenceGrant object is required in the referent namespace to allow that namespace's owner to accept the reference. See the ReferenceGrant documentation for details. + (Alpha) This field requires the CrossNamespaceVolumeDataSource feature gate to be enabled. + type: string + required: + - kind + - name + type: object + resources: + description: |- + resources represents the minimum resources the volume should have. + If RecoverVolumeExpansionFailure feature is enabled users are allowed to specify resource requirements + that are lower than previous value but must still be higher than capacity recorded in the + status field of the claim. + More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#resources + properties: + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + selector: + description: selector is a label query over + volumes to consider for binding. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The + requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + storageClassName: + description: |- + storageClassName is the name of the StorageClass required by the claim. + More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#class-1 + type: string + volumeAttributesClassName: + description: |- + volumeAttributesClassName may be used to set the VolumeAttributesClass used by this claim. + If specified, the CSI driver will create or update the volume with the attributes defined + in the corresponding VolumeAttributesClass. This has a different purpose than storageClassName, + it can be changed after the claim is created. An empty string or nil value indicates that no + VolumeAttributesClass will be applied to the claim. If the claim enters an Infeasible error state, + this field can be reset to its previous value (including nil) to cancel the modification. + If the resource referred to by volumeAttributesClass does not exist, this PersistentVolumeClaim will be + set to a Pending state, as reflected by the modifyVolumeStatus field, until such as a resource + exists. + More info: https://kubernetes.io/docs/concepts/storage/volume-attributes-classes/ + type: string + volumeMode: + description: |- + volumeMode defines what type of volume is required by the claim. + Value of Filesystem is implied when not included in claim spec. + type: string + volumeName: + description: volumeName is the binding reference + to the PersistentVolume backing this claim. + type: string + type: object + required: + - spec + type: object + type: object + fc: + description: fc represents a Fibre Channel resource + that is attached to a kubelet's host machine and then + exposed to the pod. + properties: + fsType: + description: |- + fsType is the filesystem type to mount. + Must be a filesystem type supported by the host operating system. + Ex. "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" if unspecified. + type: string + lun: + description: 'lun is Optional: FC target lun number' + format: int32 + type: integer + readOnly: + description: |- + readOnly is Optional: Defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + targetWWNs: + description: 'targetWWNs is Optional: FC target + worldwide names (WWNs)' + items: + type: string + type: array + x-kubernetes-list-type: atomic + wwids: + description: |- + wwids Optional: FC volume world wide identifiers (wwids) + Either wwids or combination of targetWWNs and lun must be set, but not both simultaneously. + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + flexVolume: + description: |- + flexVolume represents a generic volume resource that is + provisioned/attached using an exec based plugin. + Deprecated: FlexVolume is deprecated. Consider using a CSIDriver instead. + properties: + driver: + description: driver is the name of the driver to + use for this volume. + type: string + fsType: + description: |- + fsType is the filesystem type to mount. + Must be a filesystem type supported by the host operating system. + Ex. "ext4", "xfs", "ntfs". The default filesystem depends on FlexVolume script. + type: string + options: + additionalProperties: + type: string + description: 'options is Optional: this field holds + extra command options if any.' + type: object + readOnly: + description: |- + readOnly is Optional: defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + secretRef: + description: |- + secretRef is Optional: secretRef is reference to the secret object containing + sensitive information to pass to the plugin scripts. This may be + empty if no secret object is specified. If the secret object + contains more than one secret, all secrets are passed to the plugin + scripts. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + required: + - driver + type: object + flocker: + description: |- + flocker represents a Flocker volume attached to a kubelet's host machine. This depends on the Flocker control service being running. + Deprecated: Flocker is deprecated and the in-tree flocker type is no longer supported. + properties: + datasetName: + description: |- + datasetName is Name of the dataset stored as metadata -> name on the dataset for Flocker + should be considered as deprecated + type: string + datasetUUID: + description: datasetUUID is the UUID of the dataset. + This is unique identifier of a Flocker dataset + type: string + type: object + gcePersistentDisk: + description: |- + gcePersistentDisk represents a GCE Disk resource that is attached to a + kubelet's host machine and then exposed to the pod. + Deprecated: GCEPersistentDisk is deprecated. All operations for the in-tree + gcePersistentDisk type are redirected to the pd.csi.storage.gke.io CSI driver. + More info: https://kubernetes.io/docs/concepts/storage/volumes#gcepersistentdisk + properties: + fsType: + description: |- + fsType is filesystem type of the volume that you want to mount. + Tip: Ensure that the filesystem type is supported by the host operating system. + Examples: "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" if unspecified. + More info: https://kubernetes.io/docs/concepts/storage/volumes#gcepersistentdisk + type: string + partition: + description: |- + partition is the partition in the volume that you want to mount. + If omitted, the default is to mount by volume name. + Examples: For volume /dev/sda1, you specify the partition as "1". + Similarly, the volume partition for /dev/sda is "0" (or you can leave the property empty). + More info: https://kubernetes.io/docs/concepts/storage/volumes#gcepersistentdisk + format: int32 + type: integer + pdName: + description: |- + pdName is unique name of the PD resource in GCE. Used to identify the disk in GCE. + More info: https://kubernetes.io/docs/concepts/storage/volumes#gcepersistentdisk + type: string + readOnly: + description: |- + readOnly here will force the ReadOnly setting in VolumeMounts. + Defaults to false. + More info: https://kubernetes.io/docs/concepts/storage/volumes#gcepersistentdisk + type: boolean + required: + - pdName + type: object + gitRepo: + description: |- + gitRepo represents a git repository at a particular revision. + Deprecated: GitRepo is deprecated. To provision a container with a git repo, mount an + EmptyDir into an InitContainer that clones the repo using git, then mount the EmptyDir + into the Pod's container. + properties: + directory: + description: |- + directory is the target directory name. + Must not contain or start with '..'. If '.' is supplied, the volume directory will be the + git repository. Otherwise, if specified, the volume will contain the git repository in + the subdirectory with the given name. + type: string + repository: + description: repository is the URL + type: string + revision: + description: revision is the commit hash for the + specified revision. + type: string + required: + - repository + type: object + glusterfs: + description: |- + glusterfs represents a Glusterfs mount on the host that shares a pod's lifetime. + Deprecated: Glusterfs is deprecated and the in-tree glusterfs type is no longer supported. + properties: + endpoints: + description: endpoints is the endpoint name that + details Glusterfs topology. + type: string + path: + description: |- + path is the Glusterfs volume path. + More info: https://examples.k8s.io/volumes/glusterfs/README.md#create-a-pod + type: string + readOnly: + description: |- + readOnly here will force the Glusterfs volume to be mounted with read-only permissions. + Defaults to false. + More info: https://examples.k8s.io/volumes/glusterfs/README.md#create-a-pod + type: boolean + required: + - endpoints + - path + type: object + hostPath: + description: |- + hostPath represents a pre-existing file or directory on the host + machine that is directly exposed to the container. This is generally + used for system agents or other privileged things that are allowed + to see the host machine. Most containers will NOT need this. + More info: https://kubernetes.io/docs/concepts/storage/volumes#hostpath + properties: + path: + description: |- + path of the directory on the host. + If the path is a symlink, it will follow the link to the real path. + More info: https://kubernetes.io/docs/concepts/storage/volumes#hostpath + type: string + type: + description: |- + type for HostPath Volume + Defaults to "" + More info: https://kubernetes.io/docs/concepts/storage/volumes#hostpath + type: string + required: + - path + type: object + image: + description: |- + image represents an OCI object (a container image or artifact) pulled and mounted on the kubelet's host machine. + The volume is resolved at pod startup depending on which PullPolicy value is provided: + + - Always: the kubelet always attempts to pull the reference. Container creation will fail If the pull fails. + - Never: the kubelet never pulls the reference and only uses a local image or artifact. Container creation will fail if the reference isn't present. + - IfNotPresent: the kubelet pulls if the reference isn't already present on disk. Container creation will fail if the reference isn't present and the pull fails. + + The volume gets re-resolved if the pod gets deleted and recreated, which means that new remote content will become available on pod recreation. + A failure to resolve or pull the image during pod startup will block containers from starting and may add significant latency. Failures will be retried using normal volume backoff and will be reported on the pod reason and message. + The types of objects that may be mounted by this volume are defined by the container runtime implementation on a host machine and at minimum must include all valid types supported by the container image field. + The OCI object gets mounted in a single directory (spec.containers[*].volumeMounts.mountPath) by merging the manifest layers in the same way as for container images. + The volume will be mounted read-only (ro) and non-executable files (noexec). + Sub path mounts for containers are not supported (spec.containers[*].volumeMounts.subpath) before 1.33. + The field spec.securityContext.fsGroupChangePolicy has no effect on this volume type. + properties: + pullPolicy: + description: |- + Policy for pulling OCI objects. Possible values are: + Always: the kubelet always attempts to pull the reference. Container creation will fail If the pull fails. + Never: the kubelet never pulls the reference and only uses a local image or artifact. Container creation will fail if the reference isn't present. + IfNotPresent: the kubelet pulls if the reference isn't already present on disk. Container creation will fail if the reference isn't present and the pull fails. + Defaults to Always if :latest tag is specified, or IfNotPresent otherwise. + type: string + reference: + description: |- + Required: Image or artifact reference to be used. + Behaves in the same way as pod.spec.containers[*].image. + Pull secrets will be assembled in the same way as for the container image by looking up node credentials, SA image pull secrets, and pod spec image pull secrets. + More info: https://kubernetes.io/docs/concepts/containers/images + This field is optional to allow higher level config management to default or override + container images in workload controllers like Deployments and StatefulSets. + type: string + type: object + iscsi: + description: |- + iscsi represents an ISCSI Disk resource that is attached to a + kubelet's host machine and then exposed to the pod. + More info: https://kubernetes.io/docs/concepts/storage/volumes/#iscsi + properties: + chapAuthDiscovery: + description: chapAuthDiscovery defines whether support + iSCSI Discovery CHAP authentication + type: boolean + chapAuthSession: + description: chapAuthSession defines whether support + iSCSI Session CHAP authentication + type: boolean + fsType: + description: |- + fsType is the filesystem type of the volume that you want to mount. + Tip: Ensure that the filesystem type is supported by the host operating system. + Examples: "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" if unspecified. + More info: https://kubernetes.io/docs/concepts/storage/volumes#iscsi + type: string + initiatorName: + description: |- + initiatorName is the custom iSCSI Initiator Name. + If initiatorName is specified with iscsiInterface simultaneously, new iSCSI interface + : will be created for the connection. + type: string + iqn: + description: iqn is the target iSCSI Qualified Name. + type: string + iscsiInterface: + default: default + description: |- + iscsiInterface is the interface Name that uses an iSCSI transport. + Defaults to 'default' (tcp). + type: string + lun: + description: lun represents iSCSI Target Lun number. + format: int32 + type: integer + portals: + description: |- + portals is the iSCSI Target Portal List. The portal is either an IP or ip_addr:port if the port + is other than default (typically TCP ports 860 and 3260). + items: + type: string + type: array + x-kubernetes-list-type: atomic + readOnly: + description: |- + readOnly here will force the ReadOnly setting in VolumeMounts. + Defaults to false. + type: boolean + secretRef: + description: secretRef is the CHAP Secret for iSCSI + target and initiator authentication + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + targetPortal: + description: |- + targetPortal is iSCSI Target Portal. The Portal is either an IP or ip_addr:port if the port + is other than default (typically TCP ports 860 and 3260). + type: string + required: + - iqn + - lun + - targetPortal + type: object + name: + description: |- + name of the volume. + Must be a DNS_LABEL and unique within the pod. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + nfs: + description: |- + nfs represents an NFS mount on the host that shares a pod's lifetime + More info: https://kubernetes.io/docs/concepts/storage/volumes#nfs + properties: + path: + description: |- + path that is exported by the NFS server. + More info: https://kubernetes.io/docs/concepts/storage/volumes#nfs + type: string + readOnly: + description: |- + readOnly here will force the NFS export to be mounted with read-only permissions. + Defaults to false. + More info: https://kubernetes.io/docs/concepts/storage/volumes#nfs + type: boolean + server: + description: |- + server is the hostname or IP address of the NFS server. + More info: https://kubernetes.io/docs/concepts/storage/volumes#nfs + type: string + required: + - path + - server + type: object + persistentVolumeClaim: + description: |- + persistentVolumeClaimVolumeSource represents a reference to a + PersistentVolumeClaim in the same namespace. + More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#persistentvolumeclaims + properties: + claimName: + description: |- + claimName is the name of a PersistentVolumeClaim in the same namespace as the pod using this volume. + More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#persistentvolumeclaims + type: string + readOnly: + description: |- + readOnly Will force the ReadOnly setting in VolumeMounts. + Default false. + type: boolean + required: + - claimName + type: object + photonPersistentDisk: + description: |- + photonPersistentDisk represents a PhotonController persistent disk attached and mounted on kubelets host machine. + Deprecated: PhotonPersistentDisk is deprecated and the in-tree photonPersistentDisk type is no longer supported. + properties: + fsType: + description: |- + fsType is the filesystem type to mount. + Must be a filesystem type supported by the host operating system. + Ex. "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" if unspecified. + type: string + pdID: + description: pdID is the ID that identifies Photon + Controller persistent disk + type: string + required: + - pdID + type: object + portworxVolume: + description: |- + portworxVolume represents a portworx volume attached and mounted on kubelets host machine. + Deprecated: PortworxVolume is deprecated. All operations for the in-tree portworxVolume type + are redirected to the pxd.portworx.com CSI driver when the CSIMigrationPortworx feature-gate + is on. + properties: + fsType: + description: |- + fSType represents the filesystem type to mount + Must be a filesystem type supported by the host operating system. + Ex. "ext4", "xfs". Implicitly inferred to be "ext4" if unspecified. + type: string + readOnly: + description: |- + readOnly defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + volumeID: + description: volumeID uniquely identifies a Portworx + volume + type: string + required: + - volumeID + type: object + projected: + description: projected items for all in one resources + secrets, configmaps, and downward API + properties: + defaultMode: + description: |- + defaultMode are the mode bits used to set permissions on created files by default. + Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + Directories within the path are not affected by this setting. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + sources: + description: |- + sources is the list of volume projections. Each entry in this list + handles one source. + items: + description: |- + Projection that may be projected along with other supported volume types. + Exactly one of these fields must be set. + properties: + clusterTrustBundle: + description: |- + ClusterTrustBundle allows a pod to access the `.spec.trustBundle` field + of ClusterTrustBundle objects in an auto-updating file. + + Alpha, gated by the ClusterTrustBundleProjection feature gate. + + ClusterTrustBundle objects can either be selected by name, or by the + combination of signer name and a label selector. + + Kubelet performs aggressive normalization of the PEM contents written + into the pod filesystem. Esoteric PEM features such as inter-block + comments and block headers are stripped. Certificates are deduplicated. + The ordering of certificates within the file is arbitrary, and Kubelet + may change the order over time. + properties: + labelSelector: + description: |- + Select all ClusterTrustBundles that match this label selector. Only has + effect if signerName is set. Mutually-exclusive with name. If unset, + interpreted as "match nothing". If set but empty, interpreted as "match + everything". + properties: + matchExpressions: + description: matchExpressions is a + list of label selector requirements. + The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + name: + description: |- + Select a single ClusterTrustBundle by object name. Mutually-exclusive + with signerName and labelSelector. + type: string + optional: + description: |- + If true, don't block pod startup if the referenced ClusterTrustBundle(s) + aren't available. If using name, then the named ClusterTrustBundle is + allowed not to exist. If using signerName, then the combination of + signerName and labelSelector is allowed to match zero + ClusterTrustBundles. + type: boolean + path: + description: Relative path from the volume + root to write the bundle. + type: string + signerName: + description: |- + Select all ClusterTrustBundles that match this signer name. + Mutually-exclusive with name. The contents of all selected + ClusterTrustBundles will be unified and deduplicated. + type: string + required: + - path + type: object + configMap: + description: configMap information about the + configMap data to project + properties: + items: + description: |- + items if unspecified, each key-value pair in the Data field of the referenced + ConfigMap will be projected into the volume as a file whose name is the + key and content is the value. If specified, the listed keys will be + projected into the specified paths, and unlisted keys will not be + present. If a key is specified which is not present in the ConfigMap, + the volume setup will error unless it is marked optional. Paths must be + relative and may not contain the '..' path or start with '..'. + items: + description: Maps a string key to a + path within a volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: |- + mode is Optional: mode bits used to set permissions on this file. + Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + May not contain the path element '..'. + May not start with the string '..'. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: optional specify whether + the ConfigMap or its keys must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + downwardAPI: + description: downwardAPI information about + the downwardAPI data to project + properties: + items: + description: Items is a list of DownwardAPIVolume + file + items: + description: DownwardAPIVolumeFile represents + information to create the file containing + the pod field + properties: + fieldRef: + description: 'Required: Selects + a field of the pod: only annotations, + labels, name, namespace and uid + are supported.' + properties: + apiVersion: + description: Version of the + schema the FieldPath is written + in terms of, defaults to "v1". + type: string + fieldPath: + description: Path of the field + to select in the specified + API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + mode: + description: |- + Optional: mode bits used to set permissions on this file, must be an octal value + between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: 'Required: Path is the + relative path name of the file + to be created. Must not be absolute + or contain the ''..'' path. Must + be utf-8 encoded. The first item + of the relative path must not + start with ''..''' + type: string + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, requests.cpu and requests.memory) are currently supported. + properties: + containerName: + description: 'Container name: + required for volumes, optional + for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output + format of the exposed resources, + defaults to "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource + to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + required: + - path + type: object + type: array + x-kubernetes-list-type: atomic + type: object + podCertificate: + description: |- + Projects an auto-rotating credential bundle (private key and certificate + chain) that the pod can use either as a TLS client or server. + + Kubelet generates a private key and uses it to send a + PodCertificateRequest to the named signer. Once the signer approves the + request and issues a certificate chain, Kubelet writes the key and + certificate chain to the pod filesystem. The pod does not start until + certificates have been issued for each podCertificate projected volume + source in its spec. + + Kubelet will begin trying to rotate the certificate at the time indicated + by the signer using the PodCertificateRequest.Status.BeginRefreshAt + timestamp. + + Kubelet can write a single file, indicated by the credentialBundlePath + field, or separate files, indicated by the keyPath and + certificateChainPath fields. + + The credential bundle is a single file in PEM format. The first PEM + entry is the private key (in PKCS#8 format), and the remaining PEM + entries are the certificate chain issued by the signer (typically, + signers will return their certificate chain in leaf-to-root order). + + Prefer using the credential bundle format, since your application code + can read it atomically. If you use keyPath and certificateChainPath, + your application must make two separate file reads. If these coincide + with a certificate rotation, it is possible that the private key and leaf + certificate you read may not correspond to each other. Your application + will need to check for this condition, and re-read until they are + consistent. + + The named signer controls chooses the format of the certificate it + issues; consult the signer implementation's documentation to learn how to + use the certificates it issues. + properties: + certificateChainPath: + description: |- + Write the certificate chain at this path in the projected volume. + + Most applications should use credentialBundlePath. When using keyPath + and certificateChainPath, your application needs to check that the key + and leaf certificate are consistent, because it is possible to read the + files mid-rotation. + type: string + credentialBundlePath: + description: |- + Write the credential bundle at this path in the projected volume. + + The credential bundle is a single file that contains multiple PEM blocks. + The first PEM block is a PRIVATE KEY block, containing a PKCS#8 private + key. + + The remaining blocks are CERTIFICATE blocks, containing the issued + certificate chain from the signer (leaf and any intermediates). + + Using credentialBundlePath lets your Pod's application code make a single + atomic read that retrieves a consistent key and certificate chain. If you + project them to separate files, your application code will need to + additionally check that the leaf certificate was issued to the key. + type: string + keyPath: + description: |- + Write the key at this path in the projected volume. + + Most applications should use credentialBundlePath. When using keyPath + and certificateChainPath, your application needs to check that the key + and leaf certificate are consistent, because it is possible to read the + files mid-rotation. + type: string + keyType: + description: |- + The type of keypair Kubelet will generate for the pod. + + Valid values are "RSA3072", "RSA4096", "ECDSAP256", "ECDSAP384", + "ECDSAP521", and "ED25519". + type: string + maxExpirationSeconds: + description: |- + maxExpirationSeconds is the maximum lifetime permitted for the + certificate. + + Kubelet copies this value verbatim into the PodCertificateRequests it + generates for this projection. + + If omitted, kube-apiserver will set it to 86400(24 hours). kube-apiserver + will reject values shorter than 3600 (1 hour). The maximum allowable + value is 7862400 (91 days). + + The signer implementation is then free to issue a certificate with any + lifetime *shorter* than MaxExpirationSeconds, but no shorter than 3600 + seconds (1 hour). This constraint is enforced by kube-apiserver. + `kubernetes.io` signers will never issue certificates with a lifetime + longer than 24 hours. + format: int32 + type: integer + signerName: + description: Kubelet's generated CSRs + will be addressed to this signer. + type: string + required: + - keyType + - signerName + type: object + secret: + description: secret information about the + secret data to project + properties: + items: + description: |- + items if unspecified, each key-value pair in the Data field of the referenced + Secret will be projected into the volume as a file whose name is the + key and content is the value. If specified, the listed keys will be + projected into the specified paths, and unlisted keys will not be + present. If a key is specified which is not present in the Secret, + the volume setup will error unless it is marked optional. Paths must be + relative and may not contain the '..' path or start with '..'. + items: + description: Maps a string key to a + path within a volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: |- + mode is Optional: mode bits used to set permissions on this file. + Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + May not contain the path element '..'. + May not start with the string '..'. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: optional field specify whether + the Secret or its key must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + serviceAccountToken: + description: serviceAccountToken is information + about the serviceAccountToken data to project + properties: + audience: + description: |- + audience is the intended audience of the token. A recipient of a token + must identify itself with an identifier specified in the audience of the + token, and otherwise should reject the token. The audience defaults to the + identifier of the apiserver. + type: string + expirationSeconds: + description: |- + expirationSeconds is the requested duration of validity of the service + account token. As the token approaches expiration, the kubelet volume + plugin will proactively rotate the service account token. The kubelet will + start trying to rotate the token if the token is older than 80 percent of + its time to live or if the token is older than 24 hours.Defaults to 1 hour + and must be at least 10 minutes. + format: int64 + type: integer + path: + description: |- + path is the path relative to the mount point of the file to project the + token into. + type: string + required: + - path + type: object + type: object + type: array + x-kubernetes-list-type: atomic + type: object + quobyte: + description: |- + quobyte represents a Quobyte mount on the host that shares a pod's lifetime. + Deprecated: Quobyte is deprecated and the in-tree quobyte type is no longer supported. + properties: + group: + description: |- + group to map volume access to + Default is no group + type: string + readOnly: + description: |- + readOnly here will force the Quobyte volume to be mounted with read-only permissions. + Defaults to false. + type: boolean + registry: + description: |- + registry represents a single or multiple Quobyte Registry services + specified as a string as host:port pair (multiple entries are separated with commas) + which acts as the central registry for volumes + type: string + tenant: + description: |- + tenant owning the given Quobyte volume in the Backend + Used with dynamically provisioned Quobyte volumes, value is set by the plugin + type: string + user: + description: |- + user to map volume access to + Defaults to serivceaccount user + type: string + volume: + description: volume is a string that references + an already created Quobyte volume by name. + type: string + required: + - registry + - volume + type: object + rbd: + description: |- + rbd represents a Rados Block Device mount on the host that shares a pod's lifetime. + Deprecated: RBD is deprecated and the in-tree rbd type is no longer supported. + properties: + fsType: + description: |- + fsType is the filesystem type of the volume that you want to mount. + Tip: Ensure that the filesystem type is supported by the host operating system. + Examples: "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" if unspecified. + More info: https://kubernetes.io/docs/concepts/storage/volumes#rbd + type: string + image: + description: |- + image is the rados image name. + More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it + type: string + keyring: + default: /etc/ceph/keyring + description: |- + keyring is the path to key ring for RBDUser. + Default is /etc/ceph/keyring. + More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it + type: string + monitors: + description: |- + monitors is a collection of Ceph monitors. + More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it + items: + type: string + type: array + x-kubernetes-list-type: atomic + pool: + default: rbd + description: |- + pool is the rados pool name. + Default is rbd. + More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it + type: string + readOnly: + description: |- + readOnly here will force the ReadOnly setting in VolumeMounts. + Defaults to false. + More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it + type: boolean + secretRef: + description: |- + secretRef is name of the authentication secret for RBDUser. If provided + overrides keyring. + Default is nil. + More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + user: + default: admin + description: |- + user is the rados user name. + Default is admin. + More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it + type: string + required: + - image + - monitors + type: object + scaleIO: + description: |- + scaleIO represents a ScaleIO persistent volume attached and mounted on Kubernetes nodes. + Deprecated: ScaleIO is deprecated and the in-tree scaleIO type is no longer supported. + properties: + fsType: + default: xfs + description: |- + fsType is the filesystem type to mount. + Must be a filesystem type supported by the host operating system. + Ex. "ext4", "xfs", "ntfs". + Default is "xfs". + type: string + gateway: + description: gateway is the host address of the + ScaleIO API Gateway. + type: string + protectionDomain: + description: protectionDomain is the name of the + ScaleIO Protection Domain for the configured storage. + type: string + readOnly: + description: |- + readOnly Defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + secretRef: + description: |- + secretRef references to the secret for ScaleIO user and other + sensitive information. If this is not provided, Login operation will fail. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + sslEnabled: + description: sslEnabled Flag enable/disable SSL + communication with Gateway, default false + type: boolean + storageMode: + default: ThinProvisioned + description: |- + storageMode indicates whether the storage for a volume should be ThickProvisioned or ThinProvisioned. + Default is ThinProvisioned. + type: string + storagePool: + description: storagePool is the ScaleIO Storage + Pool associated with the protection domain. + type: string + system: + description: system is the name of the storage system + as configured in ScaleIO. + type: string + volumeName: + description: |- + volumeName is the name of a volume already created in the ScaleIO system + that is associated with this volume source. + type: string + required: + - gateway + - secretRef + - system + type: object + secret: + description: |- + secret represents a secret that should populate this volume. + More info: https://kubernetes.io/docs/concepts/storage/volumes#secret + properties: + defaultMode: + description: |- + defaultMode is Optional: mode bits used to set permissions on created files by default. + Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values + for mode bits. Defaults to 0644. + Directories within the path are not affected by this setting. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + items: + description: |- + items If unspecified, each key-value pair in the Data field of the referenced + Secret will be projected into the volume as a file whose name is the + key and content is the value. If specified, the listed keys will be + projected into the specified paths, and unlisted keys will not be + present. If a key is specified which is not present in the Secret, + the volume setup will error unless it is marked optional. Paths must be + relative and may not contain the '..' path or start with '..'. + items: + description: Maps a string key to a path within + a volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: |- + mode is Optional: mode bits used to set permissions on this file. + Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + May not contain the path element '..'. + May not start with the string '..'. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + optional: + description: optional field specify whether the + Secret or its keys must be defined + type: boolean + secretName: + description: |- + secretName is the name of the secret in the pod's namespace to use. + More info: https://kubernetes.io/docs/concepts/storage/volumes#secret + type: string + type: object + storageos: + description: |- + storageOS represents a StorageOS volume attached and mounted on Kubernetes nodes. + Deprecated: StorageOS is deprecated and the in-tree storageos type is no longer supported. + properties: + fsType: + description: |- + fsType is the filesystem type to mount. + Must be a filesystem type supported by the host operating system. + Ex. "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" if unspecified. + type: string + readOnly: + description: |- + readOnly defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + secretRef: + description: |- + secretRef specifies the secret to use for obtaining the StorageOS API + credentials. If not specified, default values will be attempted. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + volumeName: + description: |- + volumeName is the human-readable name of the StorageOS volume. Volume + names are only unique within a namespace. + type: string + volumeNamespace: + description: |- + volumeNamespace specifies the scope of the volume within StorageOS. If no + namespace is specified then the Pod's namespace will be used. This allows the + Kubernetes name scoping to be mirrored within StorageOS for tighter integration. + Set VolumeName to any name to override the default behaviour. + Set to "default" if you are not using namespaces within StorageOS. + Namespaces that do not pre-exist within StorageOS will be created. + type: string + type: object + vsphereVolume: + description: |- + vsphereVolume represents a vSphere volume attached and mounted on kubelets host machine. + Deprecated: VsphereVolume is deprecated. All operations for the in-tree vsphereVolume type + are redirected to the csi.vsphere.vmware.com CSI driver. + properties: + fsType: + description: |- + fsType is filesystem type to mount. + Must be a filesystem type supported by the host operating system. + Ex. "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" if unspecified. + type: string + storagePolicyID: + description: storagePolicyID is the storage Policy + Based Management (SPBM) profile ID associated + with the StoragePolicyName. + type: string + storagePolicyName: + description: storagePolicyName is the storage Policy + Based Management (SPBM) profile name. + type: string + volumePath: + description: volumePath is the path that identifies + vSphere volume vmdk + type: string + required: + - volumePath + type: object + required: + - name + type: object + minItems: 1 + type: array + type: object + x-kubernetes-validations: + - message: serviceAccountName must not be empty if specified + rule: '!has(self.serviceAccountName) || self.serviceAccountName.size() + > 0' + podDisruptionBudget: + description: PodDisruptionBudget controls voluntary disruption + tolerance. + properties: + maxUnavailable: + anyOf: + - type: integer + - type: string + description: MaxUnavailable is the maximum number of pods + that can be disrupted simultaneously. + x-kubernetes-int-or-string: true + minAvailable: + anyOf: + - type: integer + - type: string + description: MinAvailable is the minimum number of pods that + must remain available. + x-kubernetes-int-or-string: true + type: object + x-kubernetes-validations: + - message: at least one of minAvailable or maxUnavailable must + be specified + rule: has(self.minAvailable) || has(self.maxUnavailable) + - message: minAvailable and maxUnavailable are mutually exclusive + rule: '!(has(self.minAvailable) && has(self.maxUnavailable))' + replicas: + default: 1 + description: Replicas is the desired Pod replica count. + format: int32 + minimum: 0 + type: integer + resources: + description: Resources defines CPU/memory requests and limits. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This field depends on the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + storage: + description: Storage defines PVC configuration. + properties: + mountPath: + default: /.ogx + description: MountPath is the container mount path for the + PVC. + type: string + size: + anyOf: + - type: integer + - type: string + description: Size is the size of the PVC. + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + type: object + x-kubernetes-validations: + - message: mountPath must not be empty if specified + rule: '!has(self.mountPath) || self.mountPath.size() > 0' + - message: size must be a positive quantity + rule: '!has(self.size) || quantity(self.size).isGreaterThan(quantity(''0''))' + topologySpreadConstraints: + description: TopologySpreadConstraints defines Pod spreading rules. + items: + description: TopologySpreadConstraint specifies how to spread + matching pods among the given topology. + properties: + labelSelector: + description: |- + LabelSelector is used to find matching pods. + Pods that match this label selector are counted to determine the number of pods + in their corresponding topology domain. + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select the pods over which + spreading will be calculated. The keys are used to lookup values from the + incoming pod labels, those key-value labels are ANDed with labelSelector + to select the group of existing pods over which spreading will be calculated + for the incoming pod. The same key is forbidden to exist in both MatchLabelKeys and LabelSelector. + MatchLabelKeys cannot be set when LabelSelector isn't set. + Keys that don't exist in the incoming pod labels will + be ignored. A null or empty list means only match against labelSelector. + + This is a beta field and requires the MatchLabelKeysInPodTopologySpread feature gate to be enabled (enabled by default). + items: + type: string + type: array + x-kubernetes-list-type: atomic + maxSkew: + description: |- + MaxSkew describes the degree to which pods may be unevenly distributed. + When `whenUnsatisfiable=DoNotSchedule`, it is the maximum permitted difference + between the number of matching pods in the target topology and the global minimum. + The global minimum is the minimum number of matching pods in an eligible domain + or zero if the number of eligible domains is less than MinDomains. + For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same + labelSelector spread as 2/2/1: + In this case, the global minimum is 1. + | zone1 | zone2 | zone3 | + | P P | P P | P | + - if MaxSkew is 1, incoming pod can only be scheduled to zone3 to become 2/2/2; + scheduling it onto zone1(zone2) would make the ActualSkew(3-1) on zone1(zone2) + violate MaxSkew(1). + - if MaxSkew is 2, incoming pod can be scheduled onto any zone. + When `whenUnsatisfiable=ScheduleAnyway`, it is used to give higher precedence + to topologies that satisfy it. + It's a required field. Default value is 1 and 0 is not allowed. + format: int32 + type: integer + minDomains: + description: |- + MinDomains indicates a minimum number of eligible domains. + When the number of eligible domains with matching topology keys is less than minDomains, + Pod Topology Spread treats "global minimum" as 0, and then the calculation of Skew is performed. + And when the number of eligible domains with matching topology keys equals or greater than minDomains, + this value has no effect on scheduling. + As a result, when the number of eligible domains is less than minDomains, + scheduler won't schedule more than maxSkew Pods to those domains. + If value is nil, the constraint behaves as if MinDomains is equal to 1. + Valid values are integers greater than 0. + When value is not nil, WhenUnsatisfiable must be DoNotSchedule. + + For example, in a 3-zone cluster, MaxSkew is set to 2, MinDomains is set to 5 and pods with the same + labelSelector spread as 2/2/2: + | zone1 | zone2 | zone3 | + | P P | P P | P P | + The number of domains is less than 5(MinDomains), so "global minimum" is treated as 0. + In this situation, new pod with the same labelSelector cannot be scheduled, + because computed skew will be 3(3 - 0) if new Pod is scheduled to any of the three zones, + it will violate MaxSkew. + format: int32 + type: integer + nodeAffinityPolicy: + description: |- + NodeAffinityPolicy indicates how we will treat Pod's nodeAffinity/nodeSelector + when calculating pod topology spread skew. Options are: + - Honor: only nodes matching nodeAffinity/nodeSelector are included in the calculations. + - Ignore: nodeAffinity/nodeSelector are ignored. All nodes are included in the calculations. + + If this value is nil, the behavior is equivalent to the Honor policy. + type: string + nodeTaintsPolicy: + description: |- + NodeTaintsPolicy indicates how we will treat node taints when calculating + pod topology spread skew. Options are: + - Honor: nodes without taints, along with tainted nodes for which the incoming pod + has a toleration, are included. + - Ignore: node taints are ignored. All nodes are included. + + If this value is nil, the behavior is equivalent to the Ignore policy. + type: string + topologyKey: + description: |- + TopologyKey is the key of node labels. Nodes that have a label with this key + and identical values are considered to be in the same topology. + We consider each as a "bucket", and try to put balanced number + of pods into each bucket. + We define a domain as a particular instance of a topology. + Also, we define an eligible domain as a domain whose nodes meet the requirements of + nodeAffinityPolicy and nodeTaintsPolicy. + e.g. If TopologyKey is "kubernetes.io/hostname", each Node is a domain of that topology. + And, if TopologyKey is "topology.kubernetes.io/zone", each zone is a domain of that topology. + It's a required field. + type: string + whenUnsatisfiable: + description: |- + WhenUnsatisfiable indicates how to deal with a pod if it doesn't satisfy + the spread constraint. + - DoNotSchedule (default) tells the scheduler not to schedule it. + - ScheduleAnyway tells the scheduler to schedule the pod in any location, + but giving higher precedence to topologies that would help reduce the + skew. + A constraint is considered "Unsatisfiable" for an incoming pod + if and only if every possible node assignment for that pod would violate + "MaxSkew" on some topology. + For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same + labelSelector spread as 3/1/1: + | zone1 | zone2 | zone3 | + | P P P | P | P | + If WhenUnsatisfiable is set to DoNotSchedule, incoming pod can only be scheduled + to zone2(zone3) to become 3/2/1(3/1/2) as ActualSkew(2-1) on zone2(zone3) satisfies + MaxSkew(1). In other words, the cluster can still be imbalanced, but scheduler + won't make it *more* imbalanced. + It's a required field. + type: string + required: + - maxSkew + - topologyKey + - whenUnsatisfiable + type: object + minItems: 1 + type: array + workers: + description: Workers configures the number of uvicorn worker processes. + format: int32 + minimum: 1 + type: integer + type: object + required: + - distribution + type: object + x-kubernetes-validations: + - message: overrideConfig and providers are mutually exclusive + rule: '!has(self.overrideConfig) || !has(self.providers)' + - message: overrideConfig and resources are mutually exclusive + rule: '!has(self.overrideConfig) || !has(self.resources)' + - message: overrideConfig and storage are mutually exclusive + rule: '!has(self.overrideConfig) || !has(self.storage)' + - message: overrideConfig and disabledAPIs are mutually exclusive + rule: '!has(self.overrideConfig) || !has(self.disabledAPIs)' + - message: overrideConfig and baseConfig are mutually exclusive + rule: '!has(self.overrideConfig) || !has(self.baseConfig)' + - message: inference cannot be both in providers and disabledAPIs + rule: '!has(self.providers) || !has(self.disabledAPIs) || !self.disabledAPIs.exists(d, + d == ''inference'') || !has(self.providers.inference)' + - message: vector_io cannot be both in providers and disabledAPIs + rule: '!has(self.providers) || !has(self.disabledAPIs) || !self.disabledAPIs.exists(d, + d == ''vector_io'') || !has(self.providers.vectorIo)' + - message: tool_runtime cannot be both in providers and disabledAPIs + rule: '!has(self.providers) || !has(self.disabledAPIs) || !self.disabledAPIs.exists(d, + d == ''tool_runtime'') || !has(self.providers.toolRuntime)' + - message: files cannot be both in providers and disabledAPIs + rule: '!has(self.providers) || !has(self.disabledAPIs) || !self.disabledAPIs.exists(d, + d == ''files'') || !has(self.providers.files)' + - message: batches cannot be both in providers and disabledAPIs + rule: '!has(self.providers) || !has(self.disabledAPIs) || !self.disabledAPIs.exists(d, + d == ''batches'') || !has(self.providers.batches)' + - message: responses cannot be both in providers and disabledAPIs + rule: '!has(self.providers) || !has(self.disabledAPIs) || !self.disabledAPIs.exists(d, + d == ''responses'') || !has(self.providers.responses)' + - message: file_processors cannot be both in providers and disabledAPIs + rule: '!has(self.providers) || !has(self.disabledAPIs) || !self.disabledAPIs.exists(d, + d == ''file_processors'') || !has(self.providers.fileProcessors)' + status: + description: OGXServerStatus defines the observed state of OGXServer. + properties: + availableReplicas: + description: AvailableReplicas is the number of available replicas. + format: int32 + type: integer + conditions: + description: Conditions represent the latest available observations + of the server's state. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + configGeneration: + description: ConfigGeneration tracks config generation details. + properties: + configMapName: + description: ConfigMapName is the name of the generated ConfigMap. + type: string + configVersion: + description: ConfigVersion is the config.yaml schema version. + type: integer + generatedAt: + description: GeneratedAt is the timestamp of the last generation. + format: date-time + type: string + observedGeneration: + description: ObservedGeneration is the spec generation that was + last processed. + format: int64 + type: integer + providerCount: + description: ProviderCount is the number of configured providers. + type: integer + resourceCount: + description: ResourceCount is the number of registered resources. + type: integer + type: object + distributionConfig: + description: DistributionConfig contains provider information from + the running server. + properties: + activeDistribution: + type: string + availableDistributions: + additionalProperties: + type: string + type: object + providers: + items: + description: ProviderInfo represents a single provider from + the providers endpoint. + properties: + api: + type: string + config: + x-kubernetes-preserve-unknown-fields: true + health: + description: ProviderHealthStatus represents the health + status of a provider. + properties: + message: + type: string + status: + type: string + required: + - message + - status + type: object + provider_id: + type: string + provider_type: + type: string + required: + - api + - config + - health + - provider_id + - provider_type + type: object + type: array + type: object + externalURL: + description: ExternalURL is the external URL when external access + is configured. + type: string + phase: + description: Phase represents the current phase of the server. + enum: + - Pending + - Initializing + - Ready + - Failed + - Terminating + type: string + resolvedDistribution: + description: ResolvedDistribution tracks the resolved image and config + source. + properties: + configHash: + description: ConfigHash is the SHA256 hash of the base config + used. + type: string + configSource: + description: ConfigSource indicates the base config origin (for + example "configmap" or "oci-label"). + type: string + image: + description: Image is the resolved container image reference (with + digest when available). + type: string + type: object + serviceURL: + description: ServiceURL is the internal Kubernetes service URL. + type: string + version: + description: Version contains version information for both operator + and server. + properties: + lastUpdated: + format: date-time + type: string + operatorVersion: + type: string + serverVersion: + type: string + type: object + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + labels: + app.kubernetes.io/name: ogx-k8s-operator + name: ogx-k8s-operator-controller-manager + namespace: ogx-k8s-operator-system +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + labels: + app.kubernetes.io/name: ogx-k8s-operator + name: ogx-k8s-operator-leader-election-role + namespace: ogx-k8s-operator-system +rules: +- apiGroups: + - "" + resources: + - configmaps + verbs: + - get + - list + - watch + - create + - update + - patch + - delete +- apiGroups: + - coordination.k8s.io + resources: + - leases + verbs: + - get + - list + - watch + - create + - update + - patch + - delete +- apiGroups: + - "" + resources: + - events + verbs: + - create + - patch +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: ogx-k8s-operator + name: ogx-k8s-operator-manager-role +rules: +- apiGroups: + - "" + resources: + - configmaps + - persistentvolumeclaims + verbs: + - create + - get + - list + - patch + - update + - watch +- apiGroups: + - "" + resources: + - pods + verbs: + - list +- apiGroups: + - "" + resources: + - secrets + verbs: + - get + - list + - watch +- apiGroups: + - "" + resources: + - serviceaccounts + - services + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - apiextensions.k8s.io + resources: + - customresourcedefinitions + verbs: + - get + - list + - watch +- apiGroups: + - apps + resources: + - deployments + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - apps + resources: + - replicasets + verbs: + - get + - list + - watch +- apiGroups: + - autoscaling + resources: + - horizontalpodautoscalers + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - monitoring.coreos.com + resources: + - prometheusrules + - servicemonitors + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - networking.k8s.io + resources: + - ingresses + - networkpolicies + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - ogx.io + resources: + - ogxservers + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - ogx.io + resources: + - ogxservers/finalizers + verbs: + - update +- apiGroups: + - ogx.io + resources: + - ogxservers/status + verbs: + - get + - patch + - update +- apiGroups: + - policy + resources: + - poddisruptionbudgets + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - rbac.authorization.k8s.io + resources: + - clusterrolebindings + verbs: + - delete + - get + - list +- apiGroups: + - rbac.authorization.k8s.io + resources: + - clusterroles + verbs: + - get + - list + - watch +- apiGroups: + - rbac.authorization.k8s.io + resources: + - rolebindings + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - security.openshift.io + resources: + - securitycontextconstraints + verbs: + - use +- apiGroups: + - security.openshift.io + resourceNames: + - anyuid + resources: + - securitycontextconstraints + verbs: + - use +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: ogx-k8s-operator + name: ogx-k8s-operator-metrics-reader +rules: +- nonResourceURLs: + - /metrics + verbs: + - get +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: ogx-k8s-operator + rbac.authorization.k8s.io/aggregate-to-admin: "true" + rbac.authorization.k8s.io/aggregate-to-edit: "true" + name: ogx-k8s-operator-ogxserver-editor-role +rules: +- apiGroups: + - ogx.io + resources: + - ogxservers + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - ogx.io + resources: + - ogxservers/status + verbs: + - get +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: ogx-k8s-operator + rbac.authorization.k8s.io/aggregate-to-view: "true" + name: ogx-k8s-operator-ogxserver-viewer-role +rules: +- apiGroups: + - ogx.io + resources: + - ogxservers + verbs: + - get + - list + - watch +- apiGroups: + - ogx.io + resources: + - ogxservers/status + verbs: + - get +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: ogx-k8s-operator + name: ogx-k8s-operator-proxy-role +rules: +- apiGroups: + - authentication.k8s.io + resources: + - tokenreviews + verbs: + - create +- apiGroups: + - authorization.k8s.io + resources: + - subjectaccessreviews + verbs: + - create +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + labels: + app.kubernetes.io/name: ogx-k8s-operator + name: ogx-k8s-operator-leader-election-rolebinding + namespace: ogx-k8s-operator-system +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: ogx-k8s-operator-leader-election-role +subjects: +- kind: ServiceAccount + name: ogx-k8s-operator-controller-manager + namespace: ogx-k8s-operator-system +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + labels: + app.kubernetes.io/name: ogx-k8s-operator + name: ogx-k8s-operator-manager-rolebinding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: ogx-k8s-operator-manager-role +subjects: +- kind: ServiceAccount + name: ogx-k8s-operator-controller-manager + namespace: ogx-k8s-operator-system +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + labels: + app.kubernetes.io/name: ogx-k8s-operator + name: ogx-k8s-operator-proxy-rolebinding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: ogx-k8s-operator-proxy-role +subjects: +- kind: ServiceAccount + name: ogx-k8s-operator-controller-manager + namespace: ogx-k8s-operator-system +--- +apiVersion: v1 +data: + controller_manager_config.yaml: | + apiVersion: controller-runtime.sigs.k8s.io/v1alpha1 + kind: ControllerManagerConfig + health: + healthProbeBindAddress: :8081 + metrics: + bindAddress: 127.0.0.1:8080 + webhook: + port: 9443 + leaderElection: + leaderElect: true + resourceName: 54e06e98.ogx.io + # leaderElectionReleaseOnCancel defines if the leader should step down volume + # when the Manager ends. This requires the binary to immediately end when the + # Manager is stopped, otherwise, this setting is unsafe. Setting this significantly + # speeds up voluntary leader transitions as the new leader don't have to wait + # LeaseDuration time first. + # In the default scaffold provided, the program ends immediately after + # the manager stops, so would be fine to enable this option. However, + # if you are doing or is intended to do any operation such as perform cleanups + # after the manager stops then its usage might be unsafe. + # leaderElectionReleaseOnCancel: true +kind: ConfigMap +metadata: + labels: + app.kubernetes.io/name: ogx-k8s-operator + name: ogx-k8s-operator-manager-config + namespace: ogx-k8s-operator-system +--- +apiVersion: v1 +kind: Service +metadata: + labels: + app.kubernetes.io/name: ogx-k8s-operator + control-plane: controller-manager + name: ogx-k8s-operator-controller-manager-metrics-service + namespace: ogx-k8s-operator-system +spec: + ports: + - name: https + port: 8443 + protocol: TCP + targetPort: https + selector: + app.kubernetes.io/name: ogx-k8s-operator + control-plane: controller-manager +--- +apiVersion: v1 +kind: Service +metadata: + annotations: + service.beta.openshift.io/serving-cert-secret-name: ogx-k8s-operator-webhook-cert + labels: + app.kubernetes.io/name: ogx-k8s-operator + name: ogx-k8s-operator-webhook-service + namespace: ogx-k8s-operator-system +spec: + ports: + - port: 443 + protocol: TCP + targetPort: 9443 + selector: + app.kubernetes.io/name: ogx-k8s-operator + control-plane: controller-manager +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app.kubernetes.io/name: ogx-k8s-operator + control-plane: controller-manager + name: ogx-k8s-operator-controller-manager + namespace: ogx-k8s-operator-system +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: ogx-k8s-operator + control-plane: controller-manager + template: + metadata: + annotations: + kubectl.kubernetes.io/default-container: manager + labels: + app.kubernetes.io/name: ogx-k8s-operator + control-plane: controller-manager + spec: + containers: + - args: + - --leader-elect + command: + - /manager + env: + - name: GOMEMLIMIT + value: 800MiB + - name: OPERATOR_VERSION + value: latest + - name: RELATED_IMAGE_STARTER + value: "" + - name: RELATED_IMAGE_REMOTE_VLLM + value: "" + - name: RELATED_IMAGE_META_REFERENCE_GPU + value: "" + - name: RELATED_IMAGE_POSTGRES_DEMO + value: "" + image: quay.io/ogx-ai/ogx-k8s-operator:latest + imagePullPolicy: Always + livenessProbe: + httpGet: + path: /healthz + port: 8081 + initialDelaySeconds: 15 + periodSeconds: 20 + name: manager + ports: + - containerPort: 9443 + name: webhook-server + protocol: TCP + readinessProbe: + httpGet: + path: /readyz + port: 8081 + initialDelaySeconds: 5 + periodSeconds: 10 + resources: + limits: + cpu: 500m + memory: 1Gi + requests: + cpu: 10m + memory: 256Mi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + volumeMounts: + - mountPath: /tmp/k8s-webhook-server/serving-certs + name: cert + readOnly: true + securityContext: + runAsNonRoot: true + serviceAccountName: ogx-k8s-operator-controller-manager + terminationGracePeriodSeconds: 10 + topologySpreadConstraints: + - labelSelector: + matchLabels: + app.kubernetes.io/name: ogx-k8s-operator + maxSkew: 1 + topologyKey: topology.kubernetes.io/zone + whenUnsatisfiable: ScheduleAnyway + - labelSelector: + matchLabels: + app.kubernetes.io/name: ogx-k8s-operator + maxSkew: 1 + topologyKey: kubernetes.io/hostname + whenUnsatisfiable: ScheduleAnyway + volumes: + - name: cert + secret: + defaultMode: 420 + secretName: ogx-k8s-operator-webhook-cert +--- +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + labels: + app.kubernetes.io/name: ogx-k8s-operator + name: ogx-k8s-operator-controller-manager-pdb + namespace: ogx-k8s-operator-system +spec: + maxUnavailable: 1 + selector: + matchLabels: + app.kubernetes.io/name: ogx-k8s-operator +--- +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingWebhookConfiguration +metadata: + annotations: + service.beta.openshift.io/inject-cabundle: "true" + labels: + app.kubernetes.io/name: ogx-k8s-operator + name: ogx-k8s-operator-validating-webhook-configuration +webhooks: +- admissionReviewVersions: + - v1 + clientConfig: + service: + name: ogx-k8s-operator-webhook-service + namespace: ogx-k8s-operator-system + path: /validate-ogx-io-v1beta1-ogxserver + failurePolicy: Fail + name: vogxserver.kb.io + rules: + - apiGroups: + - ogx.io + apiVersions: + - v1beta1 + operations: + - CREATE + - UPDATE + resources: + - ogxservers + sideEffects: None diff --git a/release/operator.yaml b/release/operator.yaml index 62664e42b..f47a7a580 100644 --- a/release/operator.yaml +++ b/release/operator.yaml @@ -2,38 +2,50 @@ apiVersion: v1 kind: Namespace metadata: labels: - app.kubernetes.io/name: llama-stack-k8s-operator + app.kubernetes.io/name: ogx-k8s-operator control-plane: controller-manager - name: llama-stack-k8s-operator-system + name: ogx-k8s-operator-system --- apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: + cert-manager.io/inject-ca-from: ogx-k8s-operator-system/ogx-k8s-operator-serving-cert controller-gen.kubebuilder.io/version: v0.17.2 labels: - app.kubernetes.io/name: llama-stack-k8s-operator - name: llamastackdistributions.llamastack.io + app.kubernetes.io/name: ogx-k8s-operator + name: ogxservers.ogx.io spec: - group: llamastack.io + group: ogx.io names: - kind: LlamaStackDistribution - listKind: LlamaStackDistributionList - plural: llamastackdistributions - shortNames: - - llsd - singular: llamastackdistribution + kind: OGXServer + listKind: OGXServerList + plural: ogxservers + singular: ogxserver scope: Namespaced versions: - additionalPrinterColumns: - jsonPath: .status.phase name: Phase type: string + - jsonPath: .status.resolvedDistribution.image + name: Distribution + priority: 1 + type: string + - jsonPath: .status.configGeneration.configMapName + name: Config + priority: 1 + type: string + - jsonPath: .status.configGeneration.providerCount + name: Providers + type: integer - jsonPath: .status.version.operatorVersion name: Operator Version + priority: 1 type: string - - jsonPath: .status.version.llamaStackServerVersion + - jsonPath: .status.version.serverVersion name: Server Version + priority: 1 type: string - jsonPath: .status.availableReplicas name: Available @@ -41,9 +53,10 @@ spec: - jsonPath: .metadata.creationTimestamp name: Age type: date - name: v1alpha1 + name: v1beta1 schema: openAPIV3Schema: + description: OGXServer is the Schema for the ogxservers API. properties: apiVersion: description: |- @@ -63,87 +76,4091 @@ spec: metadata: type: object spec: - description: LlamaStackDistributionSpec defines the desired state of LlamaStackDistribution. + description: OGXServerSpec defines the desired state of OGXServer. properties: + baseConfig: + description: |- + BaseConfig references a ConfigMap key containing the base config.yaml used + as the starting point for declarative config generation. + When set, this takes precedence over OCI label resolution. + Mutually exclusive with overrideConfig. + The ConfigMap must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the ConfigMap. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the ConfigMap. + minLength: 1 + type: string + required: + - key + - name + type: object + disabledAPIs: + description: |- + DisabledAPIs lists API names to remove from the generated config. + Mutually exclusive with overrideConfig. + items: + enum: + - batches + - file_processors + - inference + - responses + - tool_runtime + - vector_io + - files + type: string + maxItems: 7 + minItems: 1 + type: array + distribution: + description: Distribution identifies the OGX distribution to deploy. + properties: + image: + description: Image is a direct container image reference to use. + type: string + name: + description: |- + Name is the distribution name that maps to a supported distribution (e.g., "starter", "remote-vllm"). + Resolved to a container image via distributions.json and image-overrides. + type: string + type: object + x-kubernetes-validations: + - message: only one of name or image can be specified + rule: '!(has(self.name) && has(self.image))' + - message: one of name or image must be specified + rule: has(self.name) || has(self.image) + monitoring: + description: Monitoring configures Prometheus monitoring and observability. + properties: + enabled: + default: true + description: |- + Enabled controls whether the operator creates monitoring resources + (ServiceMonitor, PrometheusRule) for this server. + Defaults to true. Set to false to disable monitoring without removing the config. + type: boolean + metricsPort: + description: |- + MetricsPort is the port serving the /metrics endpoint. + When omitted, metrics are served on the main API port. + format: int32 + maximum: 65535 + minimum: 1 + type: integer + type: object network: - description: Network defines network access controls for the LlamaStack - service + description: Network defines network access controls. properties: - allowedFrom: + externalAccess: + description: ExternalAccess controls external service exposure. + properties: + enabled: + default: false + description: Enabled controls whether external access is created. + type: boolean + hostname: + description: |- + Hostname sets a custom hostname for the external endpoint. + When omitted, an auto-generated hostname is used. + type: string + type: object + x-kubernetes-validations: + - message: hostname must not be empty if specified + rule: '!has(self.hostname) || self.hostname.size() > 0' + policy: description: |- - AllowedFrom defines which namespaces are allowed to access the LlamaStack service. - By default, only the LLSD namespace and the operator namespace are allowed. + Policy configures the operator-managed NetworkPolicy. + When nil, the operator creates a default NetworkPolicy with safe ingress rules. properties: - labels: + egress: description: |- - Labels is a list of namespace label keys that are allowed to access the service. - A namespace matching any of these labels will be granted access (OR semantics). - Example: ["myproject/lls-allowed", "team/authorized"] + Egress rules. When non-empty, a kube-dns egress rule is auto-injected + to prevent DNS breakage. items: - type: string + description: |- + NetworkPolicyEgressRule describes a particular set of traffic that is allowed out of pods + matched by a NetworkPolicySpec's podSelector. The traffic must match both ports and to. + This type is beta-level in 1.8 + properties: + ports: + description: |- + ports is a list of destination ports for outgoing traffic. + Each item in this list is combined using a logical OR. If this field is + empty or missing, this rule matches all ports (traffic not restricted by port). + If this field is present and contains at least one item, then this rule allows + traffic only if the traffic matches at least one port in the list. + items: + description: NetworkPolicyPort describes a port to + allow traffic on + properties: + endPort: + description: |- + endPort indicates that the range of ports from port to endPort if set, inclusive, + should be allowed by the policy. This field cannot be defined if the port field + is not defined or if the port field is defined as a named (string) port. + The endPort must be equal or greater than port. + format: int32 + type: integer + port: + anyOf: + - type: integer + - type: string + description: |- + port represents the port on the given protocol. This can either be a numerical or named + port on a pod. If this field is not provided, this matches all port names and + numbers. + If present, only traffic on the specified protocol AND port will be matched. + x-kubernetes-int-or-string: true + protocol: + description: |- + protocol represents the protocol (TCP, UDP, or SCTP) which traffic must match. + If not specified, this field defaults to TCP. + type: string + type: object + type: array + x-kubernetes-list-type: atomic + to: + description: |- + to is a list of destinations for outgoing traffic of pods selected for this rule. + Items in this list are combined using a logical OR operation. If this field is + empty or missing, this rule matches all destinations (traffic not restricted by + destination). If this field is present and contains at least one item, this rule + allows traffic only if the traffic matches at least one item in the to list. + items: + description: |- + NetworkPolicyPeer describes a peer to allow traffic to/from. Only certain combinations of + fields are allowed + properties: + ipBlock: + description: |- + ipBlock defines policy on a particular IPBlock. If this field is set then + neither of the other fields can be. + properties: + cidr: + description: |- + cidr is a string representing the IPBlock + Valid examples are "192.168.1.0/24" or "2001:db8::/64" + type: string + except: + description: |- + except is a slice of CIDRs that should not be included within an IPBlock + Valid examples are "192.168.1.0/24" or "2001:db8::/64" + Except values will be rejected if they are outside the cidr range + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - cidr + type: object + namespaceSelector: + description: |- + namespaceSelector selects namespaces using cluster-scoped labels. This field follows + standard label selector semantics; if present but empty, it selects all namespaces. + + If podSelector is also set, then the NetworkPolicyPeer as a whole selects + the pods matching podSelector in the namespaces selected by namespaceSelector. + Otherwise it selects all pods in the namespaces selected by namespaceSelector. + properties: + matchExpressions: + description: matchExpressions is a list of + label selector requirements. The requirements + are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that + the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + podSelector: + description: |- + podSelector is a label selector which selects pods. This field follows standard label + selector semantics; if present but empty, it selects all pods. + + If namespaceSelector is also set, then the NetworkPolicyPeer as a whole selects + the pods matching podSelector in the Namespaces selected by NamespaceSelector. + Otherwise it selects the pods matching podSelector in the policy's own namespace. + properties: + matchExpressions: + description: matchExpressions is a list of + label selector requirements. The requirements + are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that + the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + type: object + type: array + x-kubernetes-list-type: atomic + type: object + type: array + enabled: + default: true + description: |- + Enabled controls whether the operator manages a NetworkPolicy for this server. + Defaults to true. Set to false to disable NetworkPolicy creation entirely. + type: boolean + ingress: + description: |- + Ingress defines additional ingress rules, merged with operator defaults + (allow from same-namespace and operator-namespace on the service port). + items: + description: |- + NetworkPolicyIngressRule describes a particular set of traffic that is allowed to the pods + matched by a NetworkPolicySpec's podSelector. The traffic must match both ports and from. + properties: + from: + description: |- + from is a list of sources which should be able to access the pods selected for this rule. + Items in this list are combined using a logical OR operation. If this field is + empty or missing, this rule matches all sources (traffic not restricted by + source). If this field is present and contains at least one item, this rule + allows traffic only if the traffic matches at least one item in the from list. + items: + description: |- + NetworkPolicyPeer describes a peer to allow traffic to/from. Only certain combinations of + fields are allowed + properties: + ipBlock: + description: |- + ipBlock defines policy on a particular IPBlock. If this field is set then + neither of the other fields can be. + properties: + cidr: + description: |- + cidr is a string representing the IPBlock + Valid examples are "192.168.1.0/24" or "2001:db8::/64" + type: string + except: + description: |- + except is a slice of CIDRs that should not be included within an IPBlock + Valid examples are "192.168.1.0/24" or "2001:db8::/64" + Except values will be rejected if they are outside the cidr range + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - cidr + type: object + namespaceSelector: + description: |- + namespaceSelector selects namespaces using cluster-scoped labels. This field follows + standard label selector semantics; if present but empty, it selects all namespaces. + + If podSelector is also set, then the NetworkPolicyPeer as a whole selects + the pods matching podSelector in the namespaces selected by namespaceSelector. + Otherwise it selects all pods in the namespaces selected by namespaceSelector. + properties: + matchExpressions: + description: matchExpressions is a list of + label selector requirements. The requirements + are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that + the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + podSelector: + description: |- + podSelector is a label selector which selects pods. This field follows standard label + selector semantics; if present but empty, it selects all pods. + + If namespaceSelector is also set, then the NetworkPolicyPeer as a whole selects + the pods matching podSelector in the Namespaces selected by NamespaceSelector. + Otherwise it selects the pods matching podSelector in the policy's own namespace. + properties: + matchExpressions: + description: matchExpressions is a list of + label selector requirements. The requirements + are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that + the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + type: object + type: array + x-kubernetes-list-type: atomic + ports: + description: |- + ports is a list of ports which should be made accessible on the pods selected for + this rule. Each item in this list is combined using a logical OR. If this field is + empty or missing, this rule matches all ports (traffic not restricted by port). + If this field is present and contains at least one item, then this rule allows + traffic only if the traffic matches at least one port in the list. + items: + description: NetworkPolicyPort describes a port to + allow traffic on + properties: + endPort: + description: |- + endPort indicates that the range of ports from port to endPort if set, inclusive, + should be allowed by the policy. This field cannot be defined if the port field + is not defined or if the port field is defined as a named (string) port. + The endPort must be equal or greater than port. + format: int32 + type: integer + port: + anyOf: + - type: integer + - type: string + description: |- + port represents the port on the given protocol. This can either be a numerical or named + port on a pod. If this field is not provided, this matches all port names and + numbers. + If present, only traffic on the specified protocol AND port will be matched. + x-kubernetes-int-or-string: true + protocol: + description: |- + protocol represents the protocol (TCP, UDP, or SCTP) which traffic must match. + If not specified, this field defaults to TCP. + type: string + type: object + type: array + x-kubernetes-list-type: atomic + type: object type: array - namespaces: + policyTypes: description: |- - Namespaces is an explicit list of namespace names allowed to access the service. - Use "*" to allow all namespaces. + PolicyTypes specifies which policy directions are enforced. + Follows Kubernetes NetworkPolicy semantics: when omitted or empty, + Ingress is always included and Egress is included only if egress + rules are provided. items: + description: |- + PolicyType string describes the NetworkPolicy type + This type is beta-level in 1.8 + enum: + - Ingress + - Egress type: string type: array type: object - exposeRoute: - default: false - description: |- - ExposeRoute when true, creates an Ingress for external access. - Default is false (internal access only). - type: boolean + port: + default: 8321 + description: Port is the server listen port. + format: int32 + maximum: 65535 + minimum: 1 + type: integer + tls: + description: |- + TLS configures optional TLS termination for the server. + When omitted, the server listens over plain HTTP. + properties: + secretName: + description: |- + SecretName references a Kubernetes TLS Secret containing a valid TLS certificate + for server TLS termination. The Secret must be in the same namespace as the + OGXServer and must have the label ogx.io/watch: "true" to be detected by the + operator's cache. + minLength: 1 + type: string + required: + - secretName + type: object + type: object + overrideConfig: + description: |- + OverrideConfig references a ConfigMap key containing a full config.yaml override. + Mutually exclusive with providers, resources, storage, disabledAPIs, and baseConfig. + The ConfigMap must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the ConfigMap. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the ConfigMap. + minLength: 1 + type: string + required: + - key + - name + type: object + providers: + description: |- + Providers configures providers by API type. + Mutually exclusive with overrideConfig. + properties: + batches: + description: BatchesProvidersSpec configures batches providers. + properties: + inline: + description: BatchesInlineProviders groups inline batches + providers. + properties: + custom: + items: + description: CustomProvider defines the configuration + for a custom provider instance. + properties: + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + secretRefs: + additionalProperties: + description: |- + SecretKeyRef references a specific key in a Kubernetes Secret. + The Secret must be in the same namespace as the OGXServer and must have + the label ogx.io/watch: "true" to be detected by the operator's cache. + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + description: |- + SecretRefs is a map of named secret references for provider-specific + connection fields (e.g., host, password). Each key becomes the env var + field suffix and maps to config. with env var substitution. + Each Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + minProperties: 1 + type: object + settings: + description: |- + Settings contains provider-specific configuration merged into the + provider's config section in config.yaml. Passed through as-is + without any secret resolution. Use secretRefs for secret values. + x-kubernetes-preserve-unknown-fields: true + type: + description: |- + Type is the provider type, specified with a "remote::" or "inline::" + prefix (e.g., "remote::llama-guard", "inline::my-provider"). + minLength: 1 + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: type must have a 'remote::' or 'inline::' + prefix (e.g., 'remote::llama-guard', 'inline::my-provider') + rule: self.type.startsWith('remote::') || self.type.startsWith('inline::') + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + reference: + description: InlineReferenceProvider configures inline::reference + for batches. + properties: + maxConcurrentBatches: + description: |- + MaxConcurrentBatches is the maximum number of concurrent batches + to process simultaneously. + minimum: 1 + type: integer + maxConcurrentRequestsPerBatch: + description: |- + MaxConcurrentRequestsPerBatch is the maximum number of concurrent + requests to process per batch. + minimum: 1 + type: integer + type: object + type: object + remote: + description: BatchesRemoteProviders groups remote batches + providers. + properties: + custom: + items: + description: CustomProvider defines the configuration + for a custom provider instance. + properties: + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + secretRefs: + additionalProperties: + description: |- + SecretKeyRef references a specific key in a Kubernetes Secret. + The Secret must be in the same namespace as the OGXServer and must have + the label ogx.io/watch: "true" to be detected by the operator's cache. + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + description: |- + SecretRefs is a map of named secret references for provider-specific + connection fields (e.g., host, password). Each key becomes the env var + field suffix and maps to config. with env var substitution. + Each Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + minProperties: 1 + type: object + settings: + description: |- + Settings contains provider-specific configuration merged into the + provider's config section in config.yaml. Passed through as-is + without any secret resolution. Use secretRefs for secret values. + x-kubernetes-preserve-unknown-fields: true + type: + description: |- + Type is the provider type, specified with a "remote::" or "inline::" + prefix (e.g., "remote::llama-guard", "inline::my-provider"). + minLength: 1 + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: type must have a 'remote::' or 'inline::' + prefix (e.g., 'remote::llama-guard', 'inline::my-provider') + rule: self.type.startsWith('remote::') || self.type.startsWith('inline::') + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + type: object + type: object + fileProcessors: + description: FileProcessorsProvidersSpec configures file_processors + providers. + properties: + inline: + description: FileProcessorsInlineProviders groups inline file_processors + providers. + properties: + auto: + description: InlineAutoFileProcessorProvider configures + inline::auto for file_processors. + properties: + cleanText: + description: |- + CleanText controls whether to clean extracted text + (remove extra whitespace, normalize line breaks). + type: boolean + defaultChunkOverlapTokens: + description: |- + DefaultChunkOverlapTokens is the default chunk overlap in tokens when + chunking_strategy type is 'auto'. + maximum: 2048 + minimum: 0 + type: integer + defaultChunkSizeTokens: + description: |- + DefaultChunkSizeTokens is the default chunk size in tokens when + chunking_strategy type is 'auto'. + maximum: 4096 + minimum: 100 + type: integer + extractMetadata: + description: |- + ExtractMetadata controls whether to extract PDF metadata + (title, author, etc.). + type: boolean + type: object + custom: + items: + description: CustomProvider defines the configuration + for a custom provider instance. + properties: + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + secretRefs: + additionalProperties: + description: |- + SecretKeyRef references a specific key in a Kubernetes Secret. + The Secret must be in the same namespace as the OGXServer and must have + the label ogx.io/watch: "true" to be detected by the operator's cache. + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + description: |- + SecretRefs is a map of named secret references for provider-specific + connection fields (e.g., host, password). Each key becomes the env var + field suffix and maps to config. with env var substitution. + Each Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + minProperties: 1 + type: object + settings: + description: |- + Settings contains provider-specific configuration merged into the + provider's config section in config.yaml. Passed through as-is + without any secret resolution. Use secretRefs for secret values. + x-kubernetes-preserve-unknown-fields: true + type: + description: |- + Type is the provider type, specified with a "remote::" or "inline::" + prefix (e.g., "remote::llama-guard", "inline::my-provider"). + minLength: 1 + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: type must have a 'remote::' or 'inline::' + prefix (e.g., 'remote::llama-guard', 'inline::my-provider') + rule: self.type.startsWith('remote::') || self.type.startsWith('inline::') + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + docling: + description: InlineDoclingFileProcessorProvider configures + inline::docling for file_processors. + properties: + defaultChunkOverlapTokens: + description: |- + DefaultChunkOverlapTokens is the default chunk overlap in tokens when + chunking_strategy type is 'auto'. + maximum: 2048 + minimum: 0 + type: integer + defaultChunkSizeTokens: + description: |- + DefaultChunkSizeTokens is the default chunk size in tokens when + chunking_strategy type is 'auto'. + maximum: 4096 + minimum: 100 + type: integer + doOcr: + description: DoOCR controls whether to enable OCR + for scanned documents. + type: boolean + type: object + markitdown: + description: InlineMarkItDownFileProcessorProvider configures + inline::markitdown for file_processors. + properties: + defaultChunkOverlapTokens: + description: |- + DefaultChunkOverlapTokens is the default chunk overlap in tokens when + chunking_strategy type is 'auto'. + maximum: 2048 + minimum: 0 + type: integer + defaultChunkSizeTokens: + description: |- + DefaultChunkSizeTokens is the default chunk size in tokens when + chunking_strategy type is 'auto'. + maximum: 4096 + minimum: 100 + type: integer + type: object + pypdf: + description: InlinePyPDFFileProcessorProvider configures + inline::pypdf for file_processors. + properties: + cleanText: + description: |- + CleanText controls whether to clean extracted text + (remove extra whitespace, normalize line breaks). + type: boolean + defaultChunkOverlapTokens: + description: |- + DefaultChunkOverlapTokens is the default chunk overlap in tokens when + chunking_strategy type is 'auto'. + maximum: 2048 + minimum: 0 + type: integer + defaultChunkSizeTokens: + description: |- + DefaultChunkSizeTokens is the default chunk size in tokens when + chunking_strategy type is 'auto'. + maximum: 4096 + minimum: 100 + type: integer + extractMetadata: + description: |- + ExtractMetadata controls whether to extract PDF metadata + (title, author, etc.). + type: boolean + type: object + type: object + remote: + description: FileProcessorsRemoteProviders groups remote file_processors + providers. + properties: + custom: + items: + description: CustomProvider defines the configuration + for a custom provider instance. + properties: + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + secretRefs: + additionalProperties: + description: |- + SecretKeyRef references a specific key in a Kubernetes Secret. + The Secret must be in the same namespace as the OGXServer and must have + the label ogx.io/watch: "true" to be detected by the operator's cache. + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + description: |- + SecretRefs is a map of named secret references for provider-specific + connection fields (e.g., host, password). Each key becomes the env var + field suffix and maps to config. with env var substitution. + Each Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + minProperties: 1 + type: object + settings: + description: |- + Settings contains provider-specific configuration merged into the + provider's config section in config.yaml. Passed through as-is + without any secret resolution. Use secretRefs for secret values. + x-kubernetes-preserve-unknown-fields: true + type: + description: |- + Type is the provider type, specified with a "remote::" or "inline::" + prefix (e.g., "remote::llama-guard", "inline::my-provider"). + minLength: 1 + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: type must have a 'remote::' or 'inline::' + prefix (e.g., 'remote::llama-guard', 'inline::my-provider') + rule: self.type.startsWith('remote::') || self.type.startsWith('inline::') + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + doclingServe: + description: DoclingServeProvider configures a remote::docling-serve + file_processors provider instance. + properties: + apiKey: + description: |- + APIKey is the API key for authenticating with Docling Serve. + The Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + baseUrl: + description: BaseURL is the base URL of the Docling + Serve instance. + minLength: 1 + type: string + defaultChunkSizeTokens: + description: |- + DefaultChunkSizeTokens is the default chunk size in tokens when + chunking_strategy type is 'auto'. + maximum: 4096 + minimum: 100 + type: integer + required: + - baseUrl + type: object + x-kubernetes-validations: + - message: baseUrl must not be empty if specified + rule: '!has(self.baseUrl) || self.baseUrl.size() > 0' + type: object + type: object + files: + description: FilesProvidersSpec configures files providers. + properties: + inline: + description: FilesInlineProviders groups inline files providers. + properties: + custom: + items: + description: CustomProvider defines the configuration + for a custom provider instance. + properties: + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + secretRefs: + additionalProperties: + description: |- + SecretKeyRef references a specific key in a Kubernetes Secret. + The Secret must be in the same namespace as the OGXServer and must have + the label ogx.io/watch: "true" to be detected by the operator's cache. + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + description: |- + SecretRefs is a map of named secret references for provider-specific + connection fields (e.g., host, password). Each key becomes the env var + field suffix and maps to config. with env var substitution. + Each Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + minProperties: 1 + type: object + settings: + description: |- + Settings contains provider-specific configuration merged into the + provider's config section in config.yaml. Passed through as-is + without any secret resolution. Use secretRefs for secret values. + x-kubernetes-preserve-unknown-fields: true + type: + description: |- + Type is the provider type, specified with a "remote::" or "inline::" + prefix (e.g., "remote::llama-guard", "inline::my-provider"). + minLength: 1 + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: type must have a 'remote::' or 'inline::' + prefix (e.g., 'remote::llama-guard', 'inline::my-provider') + rule: self.type.startsWith('remote::') || self.type.startsWith('inline::') + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + localfs: + description: InlineLocalFSProvider configures inline::localfs. + properties: + ttlSecs: + description: TTLSecs is the time-to-live in seconds + for uploaded files. + minimum: 1 + type: integer + type: object + type: object + remote: + description: FilesRemoteProviders groups remote files providers. + properties: + custom: + items: + description: CustomProvider defines the configuration + for a custom provider instance. + properties: + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + secretRefs: + additionalProperties: + description: |- + SecretKeyRef references a specific key in a Kubernetes Secret. + The Secret must be in the same namespace as the OGXServer and must have + the label ogx.io/watch: "true" to be detected by the operator's cache. + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + description: |- + SecretRefs is a map of named secret references for provider-specific + connection fields (e.g., host, password). Each key becomes the env var + field suffix and maps to config. with env var substitution. + Each Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + minProperties: 1 + type: object + settings: + description: |- + Settings contains provider-specific configuration merged into the + provider's config section in config.yaml. Passed through as-is + without any secret resolution. Use secretRefs for secret values. + x-kubernetes-preserve-unknown-fields: true + type: + description: |- + Type is the provider type, specified with a "remote::" or "inline::" + prefix (e.g., "remote::llama-guard", "inline::my-provider"). + minLength: 1 + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: type must have a 'remote::' or 'inline::' + prefix (e.g., 'remote::llama-guard', 'inline::my-provider') + rule: self.type.startsWith('remote::') || self.type.startsWith('inline::') + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + s3: + description: S3Provider configures a remote::s3 files + provider instance. + properties: + autoCreateBucket: + description: |- + AutoCreateBucket controls whether to automatically create the S3 bucket + if it doesn't exist. + type: boolean + awsAccessKeyId: + description: |- + AWSAccessKeyID is the AWS access key ID (optional if using IAM roles). + The Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + awsSecretAccessKey: + description: |- + AWSSecretAccessKey is the AWS secret access key (optional if using IAM roles). + The Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + bucketName: + description: BucketName is the S3 bucket name to store + files. + minLength: 1 + type: string + endpointUrl: + description: EndpointURL is a custom S3 endpoint URL + (for MinIO, LocalStack, etc.). + type: string + region: + description: Region is the AWS region where the bucket + is located. + type: string + required: + - bucketName + type: object + x-kubernetes-validations: + - message: region must not be empty if specified + rule: '!has(self.region) || self.region.size() > 0' + - message: endpointUrl must not be empty if specified + rule: '!has(self.endpointUrl) || self.endpointUrl.size() + > 0' + type: object + type: object + inference: + description: InferenceProvidersSpec configures inference providers. + properties: + inline: + description: InferenceInlineProviders groups inline inference + providers. + properties: + custom: + items: + description: CustomProvider defines the configuration + for a custom provider instance. + properties: + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + secretRefs: + additionalProperties: + description: |- + SecretKeyRef references a specific key in a Kubernetes Secret. + The Secret must be in the same namespace as the OGXServer and must have + the label ogx.io/watch: "true" to be detected by the operator's cache. + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + description: |- + SecretRefs is a map of named secret references for provider-specific + connection fields (e.g., host, password). Each key becomes the env var + field suffix and maps to config. with env var substitution. + Each Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + minProperties: 1 + type: object + settings: + description: |- + Settings contains provider-specific configuration merged into the + provider's config section in config.yaml. Passed through as-is + without any secret resolution. Use secretRefs for secret values. + x-kubernetes-preserve-unknown-fields: true + type: + description: |- + Type is the provider type, specified with a "remote::" or "inline::" + prefix (e.g., "remote::llama-guard", "inline::my-provider"). + minLength: 1 + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: type must have a 'remote::' or 'inline::' + prefix (e.g., 'remote::llama-guard', 'inline::my-provider') + rule: self.type.startsWith('remote::') || self.type.startsWith('inline::') + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + type: object + remote: + description: InferenceRemoteProviders groups remote inference + providers. + properties: + azure: + items: + description: AzureProvider configures a remote::azure + inference provider instance. + properties: + allowedModels: + description: |- + AllowedModels restricts which models can be registered with this provider. + When empty, all models are allowed. + items: + minLength: 1 + type: string + minItems: 1 + type: array + apiKey: + description: |- + APIKey is the authentication credential for the Azure provider. + The Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + apiType: + description: APIType is the Azure API type (e.g., + azure). + type: string + apiVersion: + description: APIVersion is the Azure API version + (e.g., 2024-12-01-preview). + type: string + endpoint: + description: |- + Endpoint is the Azure API base URL + (e.g., https://your-resource-name.openai.azure.com/openai/v1). + minLength: 1 + type: string + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + network: + description: |- + Network configures network settings (TLS, proxy, timeouts, headers) + for the remote connection. + properties: + headers: + additionalProperties: + type: string + description: Headers specifies additional HTTP + headers to include in all requests. + minProperties: 1 + type: object + proxy: + description: Proxy configures HTTP proxy settings. + properties: + cacert: + description: CACert is the path to a CA + certificate for verifying the proxy's + certificate. + type: string + http: + description: HTTP is the proxy URL for HTTP + connections. + type: string + https: + description: HTTPS is the proxy URL for + HTTPS connections. + type: string + noProxy: + description: NoProxy is a list of hosts + that should bypass the proxy. + items: + minLength: 1 + type: string + minItems: 1 + type: array + url: + description: URL is the proxy URL for all + connections. + type: string + type: object + x-kubernetes-validations: + - message: url must not be empty if specified + rule: '!has(self.url) || self.url.size() > + 0' + - message: http must not be empty if specified + rule: '!has(self.http) || self.http.size() + > 0' + - message: https must not be empty if specified + rule: '!has(self.https) || self.https.size() + > 0' + - message: cacert must not be empty if specified + rule: '!has(self.cacert) || self.cacert.size() + > 0' + timeout: + description: Timeout configures connection and + read timeout settings. + properties: + connect: + description: Connect is the connection timeout + in seconds. + minimum: 1 + type: integer + read: + description: Read is the read timeout in + seconds. + minimum: 1 + type: integer + type: object + tls: + description: TLS configures TLS/SSL settings. + properties: + ciphers: + description: Ciphers is a list of allowed + TLS cipher suites. + items: + minLength: 1 + type: string + minItems: 1 + type: array + minVersion: + description: MinVersion sets the minimum + TLS version. + enum: + - TLSv1.2 + - TLSv1.3 + type: string + verify: + description: |- + Verify controls whether TLS certificate verification is enabled. + Trust anchors and client identity are configured globally via spec.tls. + type: boolean + type: object + type: object + refreshModels: + description: |- + RefreshModels controls whether the provider periodically refreshes + its model list from the remote endpoint. + type: boolean + required: + - apiKey + - endpoint + type: object + x-kubernetes-validations: + - message: apiVersion must not be empty if specified + rule: '!has(self.apiVersion) || self.apiVersion.size() + > 0' + - message: apiType must not be empty if specified + rule: '!has(self.apiType) || self.apiType.size() > + 0' + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + bedrock: + items: + description: BedrockProvider configures a remote::bedrock + inference provider instance. + properties: + allowedModels: + description: |- + AllowedModels restricts which models can be registered with this provider. + When empty, all models are allowed. + items: + minLength: 1 + type: string + minItems: 1 + type: array + apiKey: + description: |- + APIKey is the authentication credential for the Bedrock provider. + The Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + awsAccessKeyId: + description: |- + AWSAccessKeyID is the AWS access key to use. + The Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + awsRoleArn: + description: AWSRoleArn is the AWS role ARN to assume. + type: string + awsRoleSessionName: + description: AWSRoleSessionName is the session name + to use when assuming a role. + type: string + awsSecretAccessKey: + description: |- + AWSSecretAccessKey is the AWS secret access key to use. + The Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + awsSessionToken: + description: |- + AWSSessionToken is the AWS session token to use. + The Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + awsWebIdentityTokenFile: + description: AWSWebIdentityTokenFile is the path + to the web identity token file. + type: string + connectTimeout: + description: ConnectTimeout is the connection timeout + in seconds. + minimum: 1 + type: integer + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + network: + description: |- + Network configures network settings (TLS, proxy, timeouts, headers) + for the remote connection. + properties: + headers: + additionalProperties: + type: string + description: Headers specifies additional HTTP + headers to include in all requests. + minProperties: 1 + type: object + proxy: + description: Proxy configures HTTP proxy settings. + properties: + cacert: + description: CACert is the path to a CA + certificate for verifying the proxy's + certificate. + type: string + http: + description: HTTP is the proxy URL for HTTP + connections. + type: string + https: + description: HTTPS is the proxy URL for + HTTPS connections. + type: string + noProxy: + description: NoProxy is a list of hosts + that should bypass the proxy. + items: + minLength: 1 + type: string + minItems: 1 + type: array + url: + description: URL is the proxy URL for all + connections. + type: string + type: object + x-kubernetes-validations: + - message: url must not be empty if specified + rule: '!has(self.url) || self.url.size() > + 0' + - message: http must not be empty if specified + rule: '!has(self.http) || self.http.size() + > 0' + - message: https must not be empty if specified + rule: '!has(self.https) || self.https.size() + > 0' + - message: cacert must not be empty if specified + rule: '!has(self.cacert) || self.cacert.size() + > 0' + timeout: + description: Timeout configures connection and + read timeout settings. + properties: + connect: + description: Connect is the connection timeout + in seconds. + minimum: 1 + type: integer + read: + description: Read is the read timeout in + seconds. + minimum: 1 + type: integer + type: object + tls: + description: TLS configures TLS/SSL settings. + properties: + ciphers: + description: Ciphers is a list of allowed + TLS cipher suites. + items: + minLength: 1 + type: string + minItems: 1 + type: array + minVersion: + description: MinVersion sets the minimum + TLS version. + enum: + - TLSv1.2 + - TLSv1.3 + type: string + verify: + description: |- + Verify controls whether TLS certificate verification is enabled. + Trust anchors and client identity are configured globally via spec.tls. + type: boolean + type: object + type: object + profileName: + description: ProfileName is the AWS profile name + that contains credentials to use. + type: string + readTimeout: + description: ReadTimeout is the read timeout in + seconds. + minimum: 1 + type: integer + refreshModels: + description: |- + RefreshModels controls whether the provider periodically refreshes + its model list from the remote endpoint. + type: boolean + region: + description: Region is the AWS region for the Bedrock + Runtime endpoint. + minLength: 1 + type: string + retryMode: + description: RetryMode is the type of retries to + perform (e.g., standard, adaptive). + type: string + sessionTTL: + description: SessionTTL is the time in seconds until + a session expires. + minimum: 1 + type: integer + totalMaxAttempts: + description: |- + TotalMaxAttempts is the maximum number of attempts for a single request, + including the initial attempt. + minimum: 1 + type: integer + required: + - region + type: object + x-kubernetes-validations: + - message: awsRoleArn must not be empty if specified + rule: '!has(self.awsRoleArn) || self.awsRoleArn.size() + > 0' + - message: awsWebIdentityTokenFile must not be empty + if specified + rule: '!has(self.awsWebIdentityTokenFile) || self.awsWebIdentityTokenFile.size() + > 0' + - message: awsRoleSessionName must not be empty if specified + rule: '!has(self.awsRoleSessionName) || self.awsRoleSessionName.size() + > 0' + - message: profileName must not be empty if specified + rule: '!has(self.profileName) || self.profileName.size() + > 0' + - message: retryMode must not be empty if specified + rule: '!has(self.retryMode) || self.retryMode.size() + > 0' + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + custom: + items: + description: CustomProvider defines the configuration + for a custom provider instance. + properties: + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + secretRefs: + additionalProperties: + description: |- + SecretKeyRef references a specific key in a Kubernetes Secret. + The Secret must be in the same namespace as the OGXServer and must have + the label ogx.io/watch: "true" to be detected by the operator's cache. + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + description: |- + SecretRefs is a map of named secret references for provider-specific + connection fields (e.g., host, password). Each key becomes the env var + field suffix and maps to config. with env var substitution. + Each Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + minProperties: 1 + type: object + settings: + description: |- + Settings contains provider-specific configuration merged into the + provider's config section in config.yaml. Passed through as-is + without any secret resolution. Use secretRefs for secret values. + x-kubernetes-preserve-unknown-fields: true + type: + description: |- + Type is the provider type, specified with a "remote::" or "inline::" + prefix (e.g., "remote::llama-guard", "inline::my-provider"). + minLength: 1 + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: type must have a 'remote::' or 'inline::' + prefix (e.g., 'remote::llama-guard', 'inline::my-provider') + rule: self.type.startsWith('remote::') || self.type.startsWith('inline::') + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + openai: + items: + description: OpenAIProvider configures a remote::openai + inference provider instance. + properties: + allowedModels: + description: |- + AllowedModels restricts which models can be registered with this provider. + When empty, all models are allowed. + items: + minLength: 1 + type: string + minItems: 1 + type: array + apiKey: + description: |- + APIKey is the authentication credential for the OpenAI provider. + The Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + endpoint: + description: Endpoint is the base URL for the OpenAI + API. + type: string + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + network: + description: |- + Network configures network settings (TLS, proxy, timeouts, headers) + for the remote connection. + properties: + headers: + additionalProperties: + type: string + description: Headers specifies additional HTTP + headers to include in all requests. + minProperties: 1 + type: object + proxy: + description: Proxy configures HTTP proxy settings. + properties: + cacert: + description: CACert is the path to a CA + certificate for verifying the proxy's + certificate. + type: string + http: + description: HTTP is the proxy URL for HTTP + connections. + type: string + https: + description: HTTPS is the proxy URL for + HTTPS connections. + type: string + noProxy: + description: NoProxy is a list of hosts + that should bypass the proxy. + items: + minLength: 1 + type: string + minItems: 1 + type: array + url: + description: URL is the proxy URL for all + connections. + type: string + type: object + x-kubernetes-validations: + - message: url must not be empty if specified + rule: '!has(self.url) || self.url.size() > + 0' + - message: http must not be empty if specified + rule: '!has(self.http) || self.http.size() + > 0' + - message: https must not be empty if specified + rule: '!has(self.https) || self.https.size() + > 0' + - message: cacert must not be empty if specified + rule: '!has(self.cacert) || self.cacert.size() + > 0' + timeout: + description: Timeout configures connection and + read timeout settings. + properties: + connect: + description: Connect is the connection timeout + in seconds. + minimum: 1 + type: integer + read: + description: Read is the read timeout in + seconds. + minimum: 1 + type: integer + type: object + tls: + description: TLS configures TLS/SSL settings. + properties: + ciphers: + description: Ciphers is a list of allowed + TLS cipher suites. + items: + minLength: 1 + type: string + minItems: 1 + type: array + minVersion: + description: MinVersion sets the minimum + TLS version. + enum: + - TLSv1.2 + - TLSv1.3 + type: string + verify: + description: |- + Verify controls whether TLS certificate verification is enabled. + Trust anchors and client identity are configured globally via spec.tls. + type: boolean + type: object + type: object + refreshModels: + description: |- + RefreshModels controls whether the provider periodically refreshes + its model list from the remote endpoint. + type: boolean + required: + - apiKey + type: object + x-kubernetes-validations: + - message: endpoint must not be empty if specified + rule: '!has(self.endpoint) || self.endpoint.size() + > 0' + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + vertexai: + items: + description: VertexAIProvider configures a remote::vertexai + inference provider instance. + properties: + allowedModels: + description: |- + AllowedModels restricts which models can be registered with this provider. + When empty, all models are allowed. + items: + minLength: 1 + type: string + minItems: 1 + type: array + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + location: + description: Location is the Google Cloud location + for Vertex AI. + type: string + network: + description: |- + Network configures network settings (TLS, proxy, timeouts, headers) + for the remote connection. + properties: + headers: + additionalProperties: + type: string + description: Headers specifies additional HTTP + headers to include in all requests. + minProperties: 1 + type: object + proxy: + description: Proxy configures HTTP proxy settings. + properties: + cacert: + description: CACert is the path to a CA + certificate for verifying the proxy's + certificate. + type: string + http: + description: HTTP is the proxy URL for HTTP + connections. + type: string + https: + description: HTTPS is the proxy URL for + HTTPS connections. + type: string + noProxy: + description: NoProxy is a list of hosts + that should bypass the proxy. + items: + minLength: 1 + type: string + minItems: 1 + type: array + url: + description: URL is the proxy URL for all + connections. + type: string + type: object + x-kubernetes-validations: + - message: url must not be empty if specified + rule: '!has(self.url) || self.url.size() > + 0' + - message: http must not be empty if specified + rule: '!has(self.http) || self.http.size() + > 0' + - message: https must not be empty if specified + rule: '!has(self.https) || self.https.size() + > 0' + - message: cacert must not be empty if specified + rule: '!has(self.cacert) || self.cacert.size() + > 0' + timeout: + description: Timeout configures connection and + read timeout settings. + properties: + connect: + description: Connect is the connection timeout + in seconds. + minimum: 1 + type: integer + read: + description: Read is the read timeout in + seconds. + minimum: 1 + type: integer + type: object + tls: + description: TLS configures TLS/SSL settings. + properties: + ciphers: + description: Ciphers is a list of allowed + TLS cipher suites. + items: + minLength: 1 + type: string + minItems: 1 + type: array + minVersion: + description: MinVersion sets the minimum + TLS version. + enum: + - TLSv1.2 + - TLSv1.3 + type: string + verify: + description: |- + Verify controls whether TLS certificate verification is enabled. + Trust anchors and client identity are configured globally via spec.tls. + type: boolean + type: object + type: object + project: + description: Project is the Google Cloud project + ID for Vertex AI. + minLength: 1 + type: string + refreshModels: + description: |- + RefreshModels controls whether the provider periodically refreshes + its model list from the remote endpoint. + type: boolean + required: + - project + type: object + x-kubernetes-validations: + - message: location must not be empty if specified + rule: '!has(self.location) || self.location.size() + > 0' + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + vllm: + items: + description: VLLMProvider configures a remote::vllm + inference provider instance. + properties: + allowedModels: + description: |- + AllowedModels restricts which models can be registered with this provider. + When empty, all models are allowed. + items: + minLength: 1 + type: string + minItems: 1 + type: array + apiToken: + description: |- + APIToken is the authentication token for the vLLM endpoint. + The Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + endpoint: + description: Endpoint is the URL for the vLLM model + serving endpoint. + minLength: 1 + type: string + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + maxTokens: + description: MaxTokens is the maximum number of + tokens to generate. + minimum: 1 + type: integer + network: + description: |- + Network configures network settings (TLS, proxy, timeouts, headers) + for the remote connection. + properties: + headers: + additionalProperties: + type: string + description: Headers specifies additional HTTP + headers to include in all requests. + minProperties: 1 + type: object + proxy: + description: Proxy configures HTTP proxy settings. + properties: + cacert: + description: CACert is the path to a CA + certificate for verifying the proxy's + certificate. + type: string + http: + description: HTTP is the proxy URL for HTTP + connections. + type: string + https: + description: HTTPS is the proxy URL for + HTTPS connections. + type: string + noProxy: + description: NoProxy is a list of hosts + that should bypass the proxy. + items: + minLength: 1 + type: string + minItems: 1 + type: array + url: + description: URL is the proxy URL for all + connections. + type: string + type: object + x-kubernetes-validations: + - message: url must not be empty if specified + rule: '!has(self.url) || self.url.size() > + 0' + - message: http must not be empty if specified + rule: '!has(self.http) || self.http.size() + > 0' + - message: https must not be empty if specified + rule: '!has(self.https) || self.https.size() + > 0' + - message: cacert must not be empty if specified + rule: '!has(self.cacert) || self.cacert.size() + > 0' + timeout: + description: Timeout configures connection and + read timeout settings. + properties: + connect: + description: Connect is the connection timeout + in seconds. + minimum: 1 + type: integer + read: + description: Read is the read timeout in + seconds. + minimum: 1 + type: integer + type: object + tls: + description: TLS configures TLS/SSL settings. + properties: + ciphers: + description: Ciphers is a list of allowed + TLS cipher suites. + items: + minLength: 1 + type: string + minItems: 1 + type: array + minVersion: + description: MinVersion sets the minimum + TLS version. + enum: + - TLSv1.2 + - TLSv1.3 + type: string + verify: + description: |- + Verify controls whether TLS certificate verification is enabled. + Trust anchors and client identity are configured globally via spec.tls. + type: boolean + type: object + type: object + refreshModels: + description: |- + RefreshModels controls whether the provider periodically refreshes + its model list from the remote endpoint. + type: boolean + required: + - endpoint + type: object + x-kubernetes-validations: + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + watsonx: + items: + description: WatsonxProvider configures a remote::watsonx + inference provider instance. + properties: + allowedModels: + description: |- + AllowedModels restricts which models can be registered with this provider. + When empty, all models are allowed. + items: + minLength: 1 + type: string + minItems: 1 + type: array + apiKey: + description: |- + APIKey is the authentication credential for the watsonx provider. + The Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + endpoint: + description: Endpoint is the base URL for accessing + watsonx.ai. + type: string + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + network: + description: |- + Network configures network settings (TLS, proxy, timeouts, headers) + for the remote connection. + properties: + headers: + additionalProperties: + type: string + description: Headers specifies additional HTTP + headers to include in all requests. + minProperties: 1 + type: object + proxy: + description: Proxy configures HTTP proxy settings. + properties: + cacert: + description: CACert is the path to a CA + certificate for verifying the proxy's + certificate. + type: string + http: + description: HTTP is the proxy URL for HTTP + connections. + type: string + https: + description: HTTPS is the proxy URL for + HTTPS connections. + type: string + noProxy: + description: NoProxy is a list of hosts + that should bypass the proxy. + items: + minLength: 1 + type: string + minItems: 1 + type: array + url: + description: URL is the proxy URL for all + connections. + type: string + type: object + x-kubernetes-validations: + - message: url must not be empty if specified + rule: '!has(self.url) || self.url.size() > + 0' + - message: http must not be empty if specified + rule: '!has(self.http) || self.http.size() + > 0' + - message: https must not be empty if specified + rule: '!has(self.https) || self.https.size() + > 0' + - message: cacert must not be empty if specified + rule: '!has(self.cacert) || self.cacert.size() + > 0' + timeout: + description: Timeout configures connection and + read timeout settings. + properties: + connect: + description: Connect is the connection timeout + in seconds. + minimum: 1 + type: integer + read: + description: Read is the read timeout in + seconds. + minimum: 1 + type: integer + type: object + tls: + description: TLS configures TLS/SSL settings. + properties: + ciphers: + description: Ciphers is a list of allowed + TLS cipher suites. + items: + minLength: 1 + type: string + minItems: 1 + type: array + minVersion: + description: MinVersion sets the minimum + TLS version. + enum: + - TLSv1.2 + - TLSv1.3 + type: string + verify: + description: |- + Verify controls whether TLS certificate verification is enabled. + Trust anchors and client identity are configured globally via spec.tls. + type: boolean + type: object + type: object + projectId: + description: ProjectID is the watsonx.ai project + ID. + type: string + refreshModels: + description: |- + RefreshModels controls whether the provider periodically refreshes + its model list from the remote endpoint. + type: boolean + timeout: + description: Timeout is the timeout in seconds for + HTTP requests. + minimum: 1 + type: integer + required: + - apiKey + type: object + x-kubernetes-validations: + - message: endpoint must not be empty if specified + rule: '!has(self.endpoint) || self.endpoint.size() + > 0' + - message: projectId must not be empty if specified + rule: '!has(self.projectId) || self.projectId.size() + > 0' + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + type: object + type: object + responses: + description: ResponsesProvidersSpec configures responses providers. + properties: + inline: + description: ResponsesInlineProviders groups inline responses + providers. + properties: + builtin: + description: InlineBuiltinResponsesProvider configures + inline::builtin for responses. + properties: + compactionConfig: + description: |- + CompactionConfig configures conversation compaction behavior + and prompt templates. + properties: + defaultCompactThreshold: + description: |- + DefaultCompactThreshold is the token count threshold for auto-compaction. + Conversations exceeding this count will be automatically compacted. + minimum: 1 + type: integer + summarizationModel: + description: |- + SummarizationModel is the model to use for generating compaction + summaries. If unset, uses the same model as the conversation. + type: string + summarizationPrompt: + description: |- + SummarizationPrompt is the prompt used to instruct the model to + summarize conversation history during compaction. + type: string + summaryPrefix: + description: |- + SummaryPrefix is text prepended to the compaction summary to frame + it as a handoff for the next LLM context window. + type: string + tokenizerEncoding: + description: |- + TokenizerEncoding is the tiktoken encoding name for token counting + (e.g., "o200k_base", "cl100k_base"). + type: string + type: object + x-kubernetes-validations: + - message: summarizationPrompt must not be empty if + specified + rule: '!has(self.summarizationPrompt) || self.summarizationPrompt.size() + > 0' + - message: summaryPrefix must not be empty if specified + rule: '!has(self.summaryPrefix) || self.summaryPrefix.size() + > 0' + - message: summarizationModel must not be empty if + specified + rule: '!has(self.summarizationModel) || self.summarizationModel.size() + > 0' + - message: tokenizerEncoding must not be empty if + specified + rule: '!has(self.tokenizerEncoding) || self.tokenizerEncoding.size() + > 0' + vectorStoresConfig: + description: |- + VectorStoresConfig configures vector store behavior for file search + and retrieval-augmented generation. + properties: + annotationPromptParams: + description: AnnotationPromptParams configures + source annotation settings. + properties: + annotationInstructionTemplate: + description: AnnotationInstructionTemplate + provides instructions for citing sources. + type: string + chunkAnnotationTemplate: + description: ChunkAnnotationTemplate is the + template for chunks with annotation info. + type: string + enableAnnotations: + description: EnableAnnotations controls whether + source annotations are included. + type: boolean + type: object + chunkRetrievalParams: + description: ChunkRetrievalParams configures chunk + retrieval and ranking. + properties: + chunkMultiplier: + description: ChunkMultiplier multiplies the + number of chunks retrieved for over-retrieval. + minimum: 1 + type: integer + defaultRerankerStrategy: + description: DefaultRerankerStrategy is the + default reranking strategy. + enum: + - rrf + - weighted + - normalized + type: string + defaultSearchMode: + description: DefaultSearchMode is the default + search mode. + enum: + - vector + - keyword + - hybrid + type: string + maxTokensInContext: + description: MaxTokensInContext limits total + tokens allowed in RAG context. + minimum: 1 + type: integer + rrfImpactFactor: + description: |- + RRFImpactFactor is the impact factor for Reciprocal Rank Fusion reranking. + Specified as a decimal string (e.g., "60.0"). + type: string + weightedSearchAlpha: + description: |- + WeightedSearchAlpha is the alpha weight for weighted search reranking (0.0-1.0). + Specified as a decimal string (e.g., "0.5"). + type: string + type: object + contextPromptParams: + description: ContextPromptParams configures context + prompt templates. + properties: + chunkAnnotationTemplate: + description: ChunkAnnotationTemplate is the + template for formatting individual chunks. + type: string + contextTemplate: + description: ContextTemplate is the template + for explaining search results to the model. + type: string + type: object + contextualRetrievalParams: + description: ContextualRetrievalParams configures + contextual retrieval during ingestion. + properties: + defaultMaxConcurrency: + description: DefaultMaxConcurrency limits + concurrent LLM calls for contextualization. + minimum: 1 + type: integer + defaultTimeoutSeconds: + description: DefaultTimeoutSeconds is the + timeout per LLM contextualization call. + minimum: 1 + type: integer + maxDocumentTokens: + description: MaxDocumentTokens limits document + size in tokens for contextual retrieval. + minimum: 1 + type: integer + model: + description: Model is the default LLM model + for contextual retrieval. + properties: + embeddingDimensions: + description: EmbeddingDimensions is the + dimensionality of the embedding vectors. + minimum: 1 + type: integer + modelId: + description: ModelID is the model identifier. + minLength: 1 + type: string + providerId: + description: ProviderID is the provider + to use for this model. + minLength: 1 + type: string + required: + - modelId + - providerId + type: object + type: object + defaultEmbeddingModel: + description: DefaultEmbeddingModel configures + the default embedding model. + properties: + embeddingDimensions: + description: EmbeddingDimensions is the dimensionality + of the embedding vectors. + minimum: 1 + type: integer + modelId: + description: ModelID is the model identifier. + minLength: 1 + type: string + providerId: + description: ProviderID is the provider to + use for this model. + minLength: 1 + type: string + required: + - modelId + - providerId + type: object + defaultProviderId: + description: |- + DefaultProviderID is the vector_io provider to use when multiple + providers are available and none is specified. + type: string + defaultRerankerModel: + description: DefaultRerankerModel configures the + default reranker model. + properties: + modelId: + description: ModelID is the model identifier. + minLength: 1 + type: string + providerId: + description: ProviderID is the provider to + use for this model. + minLength: 1 + type: string + required: + - modelId + - providerId + type: object + fileBatchParams: + description: FileBatchParams configures file batch + processing. + properties: + cleanupIntervalSeconds: + description: CleanupIntervalSeconds is the + interval between expired batch cleanup runs. + minimum: 1 + type: integer + fileBatchChunkSize: + description: FileBatchChunkSize is the number + of files to process in each batch chunk. + minimum: 1 + type: integer + maxConcurrentFilesPerBatch: + description: MaxConcurrentFilesPerBatch limits + concurrent files processed per batch. + minimum: 1 + type: integer + type: object + fileIngestionParams: + description: FileIngestionParams configures file + ingestion chunk settings. + properties: + defaultChunkOverlapTokens: + description: DefaultChunkOverlapTokens is + the default overlap between chunks in tokens. + minimum: 0 + type: integer + defaultChunkSizeTokens: + description: DefaultChunkSizeTokens is the + default chunk size in tokens. + minimum: 1 + type: integer + type: object + fileSearchParams: + description: FileSearchParams configures file + search output formatting. + properties: + footerTemplate: + description: FooterTemplate is the template + for the footer text after search results. + type: string + headerTemplate: + description: HeaderTemplate is the template + for the header text before search results. + type: string + type: object + rewriteQueryParams: + description: RewriteQueryParams configures query + rewriting/expansion. Nil disables rewriting. + properties: + maxTokens: + description: MaxTokens is the maximum number + of tokens for query expansion responses. + minimum: 1 + type: integer + model: + description: Model is the LLM model used for + query rewriting. + properties: + embeddingDimensions: + description: EmbeddingDimensions is the + dimensionality of the embedding vectors. + minimum: 1 + type: integer + modelId: + description: ModelID is the model identifier. + minLength: 1 + type: string + providerId: + description: ProviderID is the provider + to use for this model. + minLength: 1 + type: string + required: + - modelId + - providerId + type: object + prompt: + description: |- + Prompt is the prompt template for query rewriting. + Use {query} as a placeholder for the original query. + type: string + temperature: + description: |- + Temperature controls randomness in query rewriting (0.0 = deterministic, 1.0 = creative). + Specified as a decimal string (e.g., "0.7"). + type: string + type: object + type: object + x-kubernetes-validations: + - message: defaultProviderId must not be empty if + specified + rule: '!has(self.defaultProviderId) || self.defaultProviderId.size() + > 0' + type: object + custom: + items: + description: CustomProvider defines the configuration + for a custom provider instance. + properties: + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + secretRefs: + additionalProperties: + description: |- + SecretKeyRef references a specific key in a Kubernetes Secret. + The Secret must be in the same namespace as the OGXServer and must have + the label ogx.io/watch: "true" to be detected by the operator's cache. + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + description: |- + SecretRefs is a map of named secret references for provider-specific + connection fields (e.g., host, password). Each key becomes the env var + field suffix and maps to config. with env var substitution. + Each Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + minProperties: 1 + type: object + settings: + description: |- + Settings contains provider-specific configuration merged into the + provider's config section in config.yaml. Passed through as-is + without any secret resolution. Use secretRefs for secret values. + x-kubernetes-preserve-unknown-fields: true + type: + description: |- + Type is the provider type, specified with a "remote::" or "inline::" + prefix (e.g., "remote::llama-guard", "inline::my-provider"). + minLength: 1 + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: type must have a 'remote::' or 'inline::' + prefix (e.g., 'remote::llama-guard', 'inline::my-provider') + rule: self.type.startsWith('remote::') || self.type.startsWith('inline::') + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + type: object + remote: + description: ResponsesRemoteProviders groups remote responses + providers. + properties: + custom: + items: + description: CustomProvider defines the configuration + for a custom provider instance. + properties: + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + secretRefs: + additionalProperties: + description: |- + SecretKeyRef references a specific key in a Kubernetes Secret. + The Secret must be in the same namespace as the OGXServer and must have + the label ogx.io/watch: "true" to be detected by the operator's cache. + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + description: |- + SecretRefs is a map of named secret references for provider-specific + connection fields (e.g., host, password). Each key becomes the env var + field suffix and maps to config. with env var substitution. + Each Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + minProperties: 1 + type: object + settings: + description: |- + Settings contains provider-specific configuration merged into the + provider's config section in config.yaml. Passed through as-is + without any secret resolution. Use secretRefs for secret values. + x-kubernetes-preserve-unknown-fields: true + type: + description: |- + Type is the provider type, specified with a "remote::" or "inline::" + prefix (e.g., "remote::llama-guard", "inline::my-provider"). + minLength: 1 + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: type must have a 'remote::' or 'inline::' + prefix (e.g., 'remote::llama-guard', 'inline::my-provider') + rule: self.type.startsWith('remote::') || self.type.startsWith('inline::') + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + type: object + type: object + toolRuntime: + description: ToolRuntimeProvidersSpec configures tool runtime + providers. + properties: + inline: + description: ToolRuntimeInlineProviders groups inline tool + runtime providers. + properties: + custom: + items: + description: CustomProvider defines the configuration + for a custom provider instance. + properties: + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + secretRefs: + additionalProperties: + description: |- + SecretKeyRef references a specific key in a Kubernetes Secret. + The Secret must be in the same namespace as the OGXServer and must have + the label ogx.io/watch: "true" to be detected by the operator's cache. + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + description: |- + SecretRefs is a map of named secret references for provider-specific + connection fields (e.g., host, password). Each key becomes the env var + field suffix and maps to config. with env var substitution. + Each Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + minProperties: 1 + type: object + settings: + description: |- + Settings contains provider-specific configuration merged into the + provider's config section in config.yaml. Passed through as-is + without any secret resolution. Use secretRefs for secret values. + x-kubernetes-preserve-unknown-fields: true + type: + description: |- + Type is the provider type, specified with a "remote::" or "inline::" + prefix (e.g., "remote::llama-guard", "inline::my-provider"). + minLength: 1 + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: type must have a 'remote::' or 'inline::' + prefix (e.g., 'remote::llama-guard', 'inline::my-provider') + rule: self.type.startsWith('remote::') || self.type.startsWith('inline::') + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + fileSearch: + items: + description: InlineFileSearchProvider configures inline::file-search. + properties: + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + vectorStoresConfig: + description: VectorStoresConfig configures vector + store behavior for file search. + properties: + annotationPromptParams: + description: AnnotationPromptParams configures + source annotation settings. + properties: + annotationInstructionTemplate: + description: AnnotationInstructionTemplate + provides instructions for citing sources. + type: string + chunkAnnotationTemplate: + description: ChunkAnnotationTemplate is + the template for chunks with annotation + info. + type: string + enableAnnotations: + description: EnableAnnotations controls + whether source annotations are included. + type: boolean + type: object + chunkRetrievalParams: + description: ChunkRetrievalParams configures + chunk retrieval and ranking. + properties: + chunkMultiplier: + description: ChunkMultiplier multiplies + the number of chunks retrieved for over-retrieval. + minimum: 1 + type: integer + defaultRerankerStrategy: + description: DefaultRerankerStrategy is + the default reranking strategy. + enum: + - rrf + - weighted + - normalized + type: string + defaultSearchMode: + description: DefaultSearchMode is the default + search mode. + enum: + - vector + - keyword + - hybrid + type: string + maxTokensInContext: + description: MaxTokensInContext limits total + tokens allowed in RAG context. + minimum: 1 + type: integer + rrfImpactFactor: + description: |- + RRFImpactFactor is the impact factor for Reciprocal Rank Fusion reranking. + Specified as a decimal string (e.g., "60.0"). + type: string + weightedSearchAlpha: + description: |- + WeightedSearchAlpha is the alpha weight for weighted search reranking (0.0-1.0). + Specified as a decimal string (e.g., "0.5"). + type: string + type: object + contextPromptParams: + description: ContextPromptParams configures + context prompt templates. + properties: + chunkAnnotationTemplate: + description: ChunkAnnotationTemplate is + the template for formatting individual + chunks. + type: string + contextTemplate: + description: ContextTemplate is the template + for explaining search results to the model. + type: string + type: object + contextualRetrievalParams: + description: ContextualRetrievalParams configures + contextual retrieval during ingestion. + properties: + defaultMaxConcurrency: + description: DefaultMaxConcurrency limits + concurrent LLM calls for contextualization. + minimum: 1 + type: integer + defaultTimeoutSeconds: + description: DefaultTimeoutSeconds is the + timeout per LLM contextualization call. + minimum: 1 + type: integer + maxDocumentTokens: + description: MaxDocumentTokens limits document + size in tokens for contextual retrieval. + minimum: 1 + type: integer + model: + description: Model is the default LLM model + for contextual retrieval. + properties: + embeddingDimensions: + description: EmbeddingDimensions is + the dimensionality of the embedding + vectors. + minimum: 1 + type: integer + modelId: + description: ModelID is the model identifier. + minLength: 1 + type: string + providerId: + description: ProviderID is the provider + to use for this model. + minLength: 1 + type: string + required: + - modelId + - providerId + type: object + type: object + defaultEmbeddingModel: + description: DefaultEmbeddingModel configures + the default embedding model. + properties: + embeddingDimensions: + description: EmbeddingDimensions is the + dimensionality of the embedding vectors. + minimum: 1 + type: integer + modelId: + description: ModelID is the model identifier. + minLength: 1 + type: string + providerId: + description: ProviderID is the provider + to use for this model. + minLength: 1 + type: string + required: + - modelId + - providerId + type: object + defaultProviderId: + description: |- + DefaultProviderID is the vector_io provider to use when multiple + providers are available and none is specified. + type: string + defaultRerankerModel: + description: DefaultRerankerModel configures + the default reranker model. + properties: + modelId: + description: ModelID is the model identifier. + minLength: 1 + type: string + providerId: + description: ProviderID is the provider + to use for this model. + minLength: 1 + type: string + required: + - modelId + - providerId + type: object + fileBatchParams: + description: FileBatchParams configures file + batch processing. + properties: + cleanupIntervalSeconds: + description: CleanupIntervalSeconds is the + interval between expired batch cleanup + runs. + minimum: 1 + type: integer + fileBatchChunkSize: + description: FileBatchChunkSize is the number + of files to process in each batch chunk. + minimum: 1 + type: integer + maxConcurrentFilesPerBatch: + description: MaxConcurrentFilesPerBatch + limits concurrent files processed per + batch. + minimum: 1 + type: integer + type: object + fileIngestionParams: + description: FileIngestionParams configures + file ingestion chunk settings. + properties: + defaultChunkOverlapTokens: + description: DefaultChunkOverlapTokens is + the default overlap between chunks in + tokens. + minimum: 0 + type: integer + defaultChunkSizeTokens: + description: DefaultChunkSizeTokens is the + default chunk size in tokens. + minimum: 1 + type: integer + type: object + fileSearchParams: + description: FileSearchParams configures file + search output formatting. + properties: + footerTemplate: + description: FooterTemplate is the template + for the footer text after search results. + type: string + headerTemplate: + description: HeaderTemplate is the template + for the header text before search results. + type: string + type: object + rewriteQueryParams: + description: RewriteQueryParams configures query + rewriting/expansion. Nil disables rewriting. + properties: + maxTokens: + description: MaxTokens is the maximum number + of tokens for query expansion responses. + minimum: 1 + type: integer + model: + description: Model is the LLM model used + for query rewriting. + properties: + embeddingDimensions: + description: EmbeddingDimensions is + the dimensionality of the embedding + vectors. + minimum: 1 + type: integer + modelId: + description: ModelID is the model identifier. + minLength: 1 + type: string + providerId: + description: ProviderID is the provider + to use for this model. + minLength: 1 + type: string + required: + - modelId + - providerId + type: object + prompt: + description: |- + Prompt is the prompt template for query rewriting. + Use {query} as a placeholder for the original query. + type: string + temperature: + description: |- + Temperature controls randomness in query rewriting (0.0 = deterministic, 1.0 = creative). + Specified as a decimal string (e.g., "0.7"). + type: string + type: object + type: object + x-kubernetes-validations: + - message: defaultProviderId must not be empty if + specified + rule: '!has(self.defaultProviderId) || self.defaultProviderId.size() + > 0' + type: object + x-kubernetes-validations: + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + type: object + remote: + description: ToolRuntimeRemoteProviders groups remote tool + runtime providers. + properties: + braveSearch: + items: + description: BraveSearchProvider configures a remote::brave-search + tool runtime provider. + properties: + apiKey: + description: |- + APIKey is the Brave Search API key. + The Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + maxResults: + description: MaxResults is the maximum number of + search results to return. + minimum: 1 + type: integer + required: + - apiKey + type: object + x-kubernetes-validations: + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + custom: + items: + description: CustomProvider defines the configuration + for a custom provider instance. + properties: + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + secretRefs: + additionalProperties: + description: |- + SecretKeyRef references a specific key in a Kubernetes Secret. + The Secret must be in the same namespace as the OGXServer and must have + the label ogx.io/watch: "true" to be detected by the operator's cache. + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + description: |- + SecretRefs is a map of named secret references for provider-specific + connection fields (e.g., host, password). Each key becomes the env var + field suffix and maps to config. with env var substitution. + Each Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + minProperties: 1 + type: object + settings: + description: |- + Settings contains provider-specific configuration merged into the + provider's config section in config.yaml. Passed through as-is + without any secret resolution. Use secretRefs for secret values. + x-kubernetes-preserve-unknown-fields: true + type: + description: |- + Type is the provider type, specified with a "remote::" or "inline::" + prefix (e.g., "remote::llama-guard", "inline::my-provider"). + minLength: 1 + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: type must have a 'remote::' or 'inline::' + prefix (e.g., 'remote::llama-guard', 'inline::my-provider') + rule: self.type.startsWith('remote::') || self.type.startsWith('inline::') + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + modelContextProtocol: + items: + description: ModelContextProtocolProvider configures + remote::model-context-protocol. + properties: + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + type: object + x-kubernetes-validations: + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + tavilySearch: + items: + description: TavilySearchProvider configures a remote::tavily-search + tool runtime provider. + properties: + apiKey: + description: |- + APIKey is the Tavily Search API key. + The Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + maxResults: + description: MaxResults is the maximum number of + search results to return. + minimum: 1 + type: integer + required: + - apiKey + type: object + x-kubernetes-validations: + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + type: object + type: object + vectorIo: + description: VectorIOProvidersSpec configures vector I/O providers. + properties: + inline: + description: VectorIOInlineProviders groups inline vector + I/O providers. + properties: + custom: + items: + description: CustomProvider defines the configuration + for a custom provider instance. + properties: + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + secretRefs: + additionalProperties: + description: |- + SecretKeyRef references a specific key in a Kubernetes Secret. + The Secret must be in the same namespace as the OGXServer and must have + the label ogx.io/watch: "true" to be detected by the operator's cache. + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + description: |- + SecretRefs is a map of named secret references for provider-specific + connection fields (e.g., host, password). Each key becomes the env var + field suffix and maps to config. with env var substitution. + Each Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + minProperties: 1 + type: object + settings: + description: |- + Settings contains provider-specific configuration merged into the + provider's config section in config.yaml. Passed through as-is + without any secret resolution. Use secretRefs for secret values. + x-kubernetes-preserve-unknown-fields: true + type: + description: |- + Type is the provider type, specified with a "remote::" or "inline::" + prefix (e.g., "remote::llama-guard", "inline::my-provider"). + minLength: 1 + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: type must have a 'remote::' or 'inline::' + prefix (e.g., 'remote::llama-guard', 'inline::my-provider') + rule: self.type.startsWith('remote::') || self.type.startsWith('inline::') + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + type: object + remote: + description: VectorIORemoteProviders groups remote vector + I/O providers. + properties: + custom: + items: + description: CustomProvider defines the configuration + for a custom provider instance. + properties: + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + secretRefs: + additionalProperties: + description: |- + SecretKeyRef references a specific key in a Kubernetes Secret. + The Secret must be in the same namespace as the OGXServer and must have + the label ogx.io/watch: "true" to be detected by the operator's cache. + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + description: |- + SecretRefs is a map of named secret references for provider-specific + connection fields (e.g., host, password). Each key becomes the env var + field suffix and maps to config. with env var substitution. + Each Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + minProperties: 1 + type: object + settings: + description: |- + Settings contains provider-specific configuration merged into the + provider's config section in config.yaml. Passed through as-is + without any secret resolution. Use secretRefs for secret values. + x-kubernetes-preserve-unknown-fields: true + type: + description: |- + Type is the provider type, specified with a "remote::" or "inline::" + prefix (e.g., "remote::llama-guard", "inline::my-provider"). + minLength: 1 + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: type must have a 'remote::' or 'inline::' + prefix (e.g., 'remote::llama-guard', 'inline::my-provider') + rule: self.type.startsWith('remote::') || self.type.startsWith('inline::') + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + milvus: + items: + description: MilvusProvider configures a remote::milvus + vector I/O provider instance. + properties: + consistencyLevel: + description: ConsistencyLevel is the consistency + level of the Milvus server. + type: string + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + token: + description: |- + Token is the authentication token for the Milvus server. + The Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + uri: + description: URI is the URI of the Milvus server. + minLength: 1 + type: string + required: + - uri + type: object + x-kubernetes-validations: + - message: consistencyLevel must not be empty if specified + rule: '!has(self.consistencyLevel) || self.consistencyLevel.size() + > 0' + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + pgvector: + items: + description: PgvectorProvider configures a remote::pgvector + vector I/O provider instance. + properties: + db: + description: DB is the PostgreSQL database name. + type: string + distanceMetric: + description: DistanceMetric is the distance metric + used for vector search. + enum: + - COSINE + - L2 + - L1 + - INNER_PRODUCT + type: string + host: + description: Host is the PostgreSQL server hostname. + type: string + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + password: + description: |- + Password is the PostgreSQL password. + The Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + port: + description: Port is the PostgreSQL server port. + maximum: 65535 + minimum: 1 + type: integer + user: + description: User is the PostgreSQL username. + type: string + vectorIndex: + description: |- + VectorIndex configures the vector index strategy for + Approximate Nearest Neighbor (ANN) search. + properties: + hnsw: + description: HNSW configures an HNSW index. + properties: + efConstruction: + description: EfConstruction controls the + index build-time accuracy/speed tradeoff. + minimum: 1 + type: integer + efSearch: + description: EfSearch controls the query-time + accuracy/speed tradeoff. + minimum: 1 + type: integer + m: + description: M is the maximum number of + connections per element in the HNSW graph. + minimum: 1 + type: integer + type: object + ivfFlat: + description: IVFFlat configures an IVFFlat index. + properties: + nlist: + description: Nlist is the number of inverted + lists (clusters). + minimum: 1 + type: integer + nprobe: + description: Nprobe is the number of clusters + to search at query time. + minimum: 1 + type: integer + type: object + type: object + x-kubernetes-validations: + - message: one of hnsw or ivfFlat must be specified + rule: has(self.hnsw) || has(self.ivfFlat) + - message: only one of hnsw or ivfFlat can be specified + rule: '!(has(self.hnsw) && has(self.ivfFlat))' + required: + - password + type: object + x-kubernetes-validations: + - message: host must not be empty if specified + rule: '!has(self.host) || self.host.size() > 0' + - message: db must not be empty if specified + rule: '!has(self.db) || self.db.size() > 0' + - message: user must not be empty if specified + rule: '!has(self.user) || self.user.size() > 0' + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + qdrant: + items: + description: QdrantProvider configures a remote::qdrant + vector I/O provider instance. + properties: + apiKey: + description: |- + APIKey is the authentication key for the Qdrant server. + The Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes + Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + grpcPort: + description: GRPCPort is the gRPC port of the Qdrant + server. + maximum: 65535 + minimum: 1 + type: integer + host: + description: Host is the hostname of the Qdrant + server. + type: string + https: + description: HTTPS controls whether to use HTTPS + for the connection. + type: boolean + id: + description: |- + ID is a unique provider identifier. Derived from the provider + type when omitted. Must be unique across all providers. + type: string + location: + description: Location is the Qdrant server location + identifier. + type: string + port: + description: Port is the REST API port of the Qdrant + server. + maximum: 65535 + minimum: 1 + type: integer + preferGrpc: + description: PreferGRPC controls whether to prefer + gRPC over REST for communication. + type: boolean + prefix: + description: Prefix is the URL path prefix for the + Qdrant server. + type: string + timeout: + description: Timeout is the connection timeout in + seconds. + minimum: 1 + type: integer + url: + description: URL is the URL of the Qdrant server. + type: string + type: object + x-kubernetes-validations: + - message: at least one of url or host must be specified + rule: has(self.url) || has(self.host) + - message: url must not be empty if specified + rule: '!has(self.url) || self.url.size() > 0' + - message: host must not be empty if specified + rule: '!has(self.host) || self.host.size() > 0' + - message: location must not be empty if specified + rule: '!has(self.location) || self.location.size() + > 0' + - message: prefix must not be empty if specified + rule: '!has(self.prefix) || self.prefix.size() > 0' + - message: id must not be empty if specified + rule: '!has(self.id) || self.id.size() > 0' + maxItems: 100 + minItems: 1 + type: array + type: object + type: object + type: object + registryRefreshIntervalSeconds: + description: |- + RegistryRefreshIntervalSeconds configures how often the server refreshes + its model registry, in seconds. When omitted, the server's built-in + default is used. + format: int32 + minimum: 1 + type: integer + resources: + description: |- + Resources declares models to register. + Mutually exclusive with overrideConfig. + properties: + models: + description: Models to register with inference providers. + items: + description: ModelConfig defines a model registration with optional + provider assignment and metadata. + properties: + contextLength: + description: ContextLength is the model context window size. + type: integer + modelType: + description: ModelType is the model type classification. + type: string + name: + description: Name is the model identifier (e.g., "llama3.2-8b"). + minLength: 1 + type: string + provider: + description: |- + Provider is the ID of the provider to register this model with. + Defaults to the first inference provider when omitted. + type: string + quantization: + description: Quantization is the quantization method. + type: string + required: + - name + type: object + x-kubernetes-validations: + - message: provider must not be empty if specified + rule: '!has(self.provider) || self.provider.size() > 0' + - message: modelType must not be empty if specified + rule: '!has(self.modelType) || self.modelType.size() > 0' + - message: quantization must not be empty if specified + rule: '!has(self.quantization) || self.quantization.size() + > 0' + minItems: 1 + type: array + type: object + storage: + description: |- + Storage configures state storage backends (KV and SQL). + Mutually exclusive with overrideConfig. + properties: + kv: + description: KV configures key-value storage. + properties: + endpoint: + description: Endpoint is the Redis endpoint URL. Required + when type is "redis". + type: string + password: + description: |- + Password references a Secret for Redis authentication. + The Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + type: + default: sqlite + description: Type is the KV storage backend type. + enum: + - sqlite + - redis + type: string + type: object + x-kubernetes-validations: + - message: endpoint is required when type is redis + rule: self.type != 'redis' || has(self.endpoint) + - message: endpoint is only valid when type is redis + rule: '!has(self.endpoint) || self.type == ''redis''' + - message: password is only valid when type is redis + rule: '!has(self.password) || self.type == ''redis''' + sql: + description: SQL configures SQL storage. + properties: + connectionString: + description: |- + ConnectionString references a Secret containing the database connection string. + Required when type is "postgres". + The Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + type: + default: sqlite + description: Type is the SQL storage backend type. + enum: + - sqlite + - postgres + type: string + type: object + x-kubernetes-validations: + - message: connectionString is required when type is postgres + rule: self.type != 'postgres' || has(self.connectionString) + - message: connectionString is only valid when type is postgres + rule: '!has(self.connectionString) || self.type == ''postgres''' + type: object + tls: + description: |- + TLS configures outbound TLS trust anchors and client identity for + connections to providers and backends. + properties: + identity: + description: |- + Identity configures client certificate and key for mTLS authentication + with providers and backends. + properties: + cert: + description: |- + Cert references a ConfigMap key containing the PEM-encoded TLS client certificate. + The ConfigMap must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the ConfigMap. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the ConfigMap. + minLength: 1 + type: string + required: + - key + - name + type: object + key: + description: |- + Key references a Secret key containing the PEM-encoded TLS client private key. + The Secret must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + properties: + key: + description: Key is the key within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the Kubernetes Secret. + minLength: 1 + type: string + required: + - key + - name + type: object + required: + - cert + - key + type: object + trust: + description: |- + Trust configures CA certificates for verifying outbound TLS connections + to providers and backends. + properties: + caCertificates: + description: |- + CACertificates lists ConfigMap keys containing PEM-encoded CA certificates. + All certificates are concatenated into a single trust bundle. + Referenced ConfigMaps must be in the same namespace as the OGXServer + and must have the label ogx.io/watch: "true". + items: + description: |- + ConfigMapKeyRef references a key within a ConfigMap. + The ConfigMap must be in the same namespace as the OGXServer and must have + the label ogx.io/watch: "true" to be detected by the operator's cache. + properties: + key: + description: Key is the key within the ConfigMap. + maxLength: 253 + minLength: 1 + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the ConfigMap. + minLength: 1 + type: string + required: + - key + - name + type: object + minItems: 1 + type: array + type: object type: object - replicas: - default: 1 - format: int32 - type: integer - server: - description: ServerSpec defines the desired state of llama server. + workload: + description: Workload consolidates Kubernetes deployment settings. properties: autoscaling: - description: Autoscaling configures HorizontalPodAutoscaler for - the server pods + description: Autoscaling configures HPA for the server pods. properties: maxReplicas: - description: MaxReplicas is the upper bound replica count - maintained by the HPA + description: MaxReplicas is the upper bound replica count. format: int32 + minimum: 1 type: integer minReplicas: - description: MinReplicas is the lower bound replica count - maintained by the HPA + description: MinReplicas is the lower bound replica count. format: int32 + minimum: 1 type: integer targetCPUUtilizationPercentage: - description: TargetCPUUtilizationPercentage configures CPU - based scaling + description: TargetCPUUtilizationPercentage configures CPU-based + scaling. format: int32 + maximum: 100 + minimum: 1 type: integer targetMemoryUtilizationPercentage: description: TargetMemoryUtilizationPercentage configures - memory based scaling + memory-based scaling. format: int32 + maximum: 100 + minimum: 1 type: integer required: - maxReplicas type: object - containerSpec: - description: ContainerSpec defines the llama-stack server container - configuration. + x-kubernetes-validations: + - message: maxReplicas must be greater than or equal to minReplicas + rule: '!has(self.minReplicas) || self.maxReplicas >= self.minReplicas' + overrides: + description: Overrides allows pod-level customization. properties: args: + description: Args overrides the container arguments. items: + minLength: 1 type: string + minItems: 1 type: array command: + description: Command overrides the container command. items: + minLength: 1 type: string + minItems: 1 type: array env: + description: Env specifies additional environment variables. items: description: EnvVar represents an environment variable present in a Container. @@ -299,124 +4316,14 @@ spec: required: - name type: object + minItems: 1 type: array - name: - default: llama-stack - type: string - port: - format: int32 - type: integer - resources: - description: ResourceRequirements describes the compute resource - requirements. - properties: - claims: - description: |- - Claims lists the names of resources, defined in spec.resourceClaims, - that are used by this container. - - This field depends on the - DynamicResourceAllocation feature gate. - - This field is immutable. It can only be set for containers. - items: - description: ResourceClaim references one entry in PodSpec.ResourceClaims. - properties: - name: - description: |- - Name must match the name of one entry in pod.spec.resourceClaims of - the Pod where this field is used. It makes that resource available - inside a container. - type: string - request: - description: |- - Request is the name chosen for a request in the referenced claim. - If empty, everything from the claim is made available, otherwise - only the result of this request. - type: string - required: - - name - type: object - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - limits: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Limits describes the maximum amount of compute resources allowed. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - requests: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Requests describes the minimum amount of compute resources required. - If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, - otherwise to an implementation-defined value. Requests cannot exceed Limits. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - type: object - type: object - distribution: - description: DistributionType defines the distribution configuration - for llama-stack. - properties: - image: - description: Image is the direct container image reference - to use - type: string - name: - description: Name is the distribution name that maps to supported - distributions. - type: string - type: object - x-kubernetes-validations: - - message: Only one of name or image can be specified - rule: '!(has(self.name) && has(self.image))' - podDisruptionBudget: - description: PodDisruptionBudget controls voluntary disruption - tolerance for the server pods - properties: - maxUnavailable: - anyOf: - - type: integer - - type: string - description: MaxUnavailable is the maximum number of pods - that can be disrupted simultaneously - x-kubernetes-int-or-string: true - minAvailable: - anyOf: - - type: integer - - type: string - description: MinAvailable is the minimum number of pods that - must remain available - x-kubernetes-int-or-string: true - type: object - podOverrides: - description: PodOverrides allows advanced pod-level customization. - properties: serviceAccountName: - description: |- - ServiceAccountName allows users to specify their own ServiceAccount - If not specified, the operator will use the default ServiceAccount + description: ServiceAccountName specifies a custom ServiceAccount. type: string - terminationGracePeriodSeconds: - description: |- - TerminationGracePeriodSeconds is the time allowed for graceful pod shutdown. - If not specified, Kubernetes defaults to 30 seconds. - format: int64 - type: integer volumeMounts: + description: VolumeMounts adds additional volume mounts to + the container. items: description: VolumeMount describes a mounting of a Volume within a container. @@ -478,8 +4385,10 @@ spec: - mountPath - name type: object + minItems: 1 type: array volumes: + description: Volumes adds additional volumes to the Pod. items: description: Volume represents a named volume in a pod that may be accessed by any container in the pod. @@ -2378,56 +6287,126 @@ spec: required: - name type: object + minItems: 1 + type: array + type: object + x-kubernetes-validations: + - message: serviceAccountName must not be empty if specified + rule: '!has(self.serviceAccountName) || self.serviceAccountName.size() + > 0' + podDisruptionBudget: + description: PodDisruptionBudget controls voluntary disruption + tolerance. + properties: + maxUnavailable: + anyOf: + - type: integer + - type: string + description: MaxUnavailable is the maximum number of pods + that can be disrupted simultaneously. + x-kubernetes-int-or-string: true + minAvailable: + anyOf: + - type: integer + - type: string + description: MinAvailable is the minimum number of pods that + must remain available. + x-kubernetes-int-or-string: true + type: object + x-kubernetes-validations: + - message: at least one of minAvailable or maxUnavailable must + be specified + rule: has(self.minAvailable) || has(self.maxUnavailable) + - message: minAvailable and maxUnavailable are mutually exclusive + rule: '!(has(self.minAvailable) && has(self.maxUnavailable))' + replicas: + default: 1 + description: Replicas is the desired Pod replica count. + format: int32 + minimum: 0 + type: integer + resources: + description: Resources defines CPU/memory requests and limits. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This field depends on the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object type: object storage: - description: Storage defines the persistent storage configuration + description: Storage defines PVC configuration. properties: mountPath: - description: MountPath is the path where the storage will - be mounted in the container + default: /.ogx + description: MountPath is the container mount path for the + PVC. type: string size: anyOf: - type: integer - type: string - description: Size is the size of the persistent volume claim - created for holding persistent data of the llama-stack server + description: Size is the size of the PVC. pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ x-kubernetes-int-or-string: true type: object - tlsConfig: - description: TLSConfig defines the TLS configuration for the llama-stack - server - properties: - caBundle: - description: CABundle defines the CA bundle configuration - for custom certificates - properties: - configMapKeys: - description: |- - ConfigMapKeys specifies multiple keys within the ConfigMap containing CA bundle data - All certificates from these keys will be concatenated into a single CA bundle file - If not specified, defaults to [DefaultCABundleKey] - items: - type: string - maxItems: 50 - type: array - configMapName: - description: ConfigMapName is the name of the ConfigMap - containing CA bundle certificates - type: string - configMapNamespace: - description: ConfigMapNamespace is the namespace of the - ConfigMap (defaults to the same namespace as the CR) - type: string - required: - - configMapName - type: object - type: object + x-kubernetes-validations: + - message: mountPath must not be empty if specified + rule: '!has(self.mountPath) || self.mountPath.size() > 0' + - message: size must be a positive quantity + rule: '!has(self.size) || quantity(self.size).isGreaterThan(quantity(''0''))' topologySpreadConstraints: - description: TopologySpreadConstraints defines fine-grained spreading - rules + description: TopologySpreadConstraints defines Pod spreading rules. items: description: TopologySpreadConstraint specifies how to spread matching pods among the given topology. @@ -2599,48 +6578,59 @@ spec: - topologyKey - whenUnsatisfiable type: object + minItems: 1 type: array - userConfig: - description: UserConfig defines the user configuration for the - llama-stack server - properties: - configMapName: - description: ConfigMapName is the name of the ConfigMap containing - user configuration - type: string - configMapNamespace: - description: ConfigMapNamespace is the namespace of the ConfigMap - (defaults to the same namespace as the CR) - type: string - required: - - configMapName - type: object workers: - description: |- - Workers configures the number of uvicorn worker processes to run. - When set, the operator will launch llama-stack using uvicorn with the specified worker count. - Ref: https://fastapi.tiangolo.com/deployment/server-workers/ - CPU requests are set to the number of workers when set, otherwise 1 full core + description: Workers configures the number of uvicorn worker processes. format: int32 minimum: 1 type: integer - required: - - distribution type: object required: - - server + - distribution type: object + x-kubernetes-validations: + - message: overrideConfig and providers are mutually exclusive + rule: '!has(self.overrideConfig) || !has(self.providers)' + - message: overrideConfig and resources are mutually exclusive + rule: '!has(self.overrideConfig) || !has(self.resources)' + - message: overrideConfig and storage are mutually exclusive + rule: '!has(self.overrideConfig) || !has(self.storage)' + - message: overrideConfig and disabledAPIs are mutually exclusive + rule: '!has(self.overrideConfig) || !has(self.disabledAPIs)' + - message: overrideConfig and baseConfig are mutually exclusive + rule: '!has(self.overrideConfig) || !has(self.baseConfig)' + - message: inference cannot be both in providers and disabledAPIs + rule: '!has(self.providers) || !has(self.disabledAPIs) || !self.disabledAPIs.exists(d, + d == ''inference'') || !has(self.providers.inference)' + - message: vector_io cannot be both in providers and disabledAPIs + rule: '!has(self.providers) || !has(self.disabledAPIs) || !self.disabledAPIs.exists(d, + d == ''vector_io'') || !has(self.providers.vectorIo)' + - message: tool_runtime cannot be both in providers and disabledAPIs + rule: '!has(self.providers) || !has(self.disabledAPIs) || !self.disabledAPIs.exists(d, + d == ''tool_runtime'') || !has(self.providers.toolRuntime)' + - message: files cannot be both in providers and disabledAPIs + rule: '!has(self.providers) || !has(self.disabledAPIs) || !self.disabledAPIs.exists(d, + d == ''files'') || !has(self.providers.files)' + - message: batches cannot be both in providers and disabledAPIs + rule: '!has(self.providers) || !has(self.disabledAPIs) || !self.disabledAPIs.exists(d, + d == ''batches'') || !has(self.providers.batches)' + - message: responses cannot be both in providers and disabledAPIs + rule: '!has(self.providers) || !has(self.disabledAPIs) || !self.disabledAPIs.exists(d, + d == ''responses'') || !has(self.providers.responses)' + - message: file_processors cannot be both in providers and disabledAPIs + rule: '!has(self.providers) || !has(self.disabledAPIs) || !self.disabledAPIs.exists(d, + d == ''file_processors'') || !has(self.providers.fileProcessors)' status: - description: LlamaStackDistributionStatus defines the observed state of - LlamaStackDistribution. + description: OGXServerStatus defines the observed state of OGXServer. properties: availableReplicas: - description: AvailableReplicas is the number of available replicas + description: AvailableReplicas is the number of available replicas. format: int32 type: integer conditions: description: Conditions represent the latest available observations - of the distribution's current state + of the server's state. items: description: Condition contains details for one aspect of the current state of this API Resource. @@ -2696,19 +6686,40 @@ spec: - type type: object type: array + configGeneration: + description: ConfigGeneration tracks config generation details. + properties: + configMapName: + description: ConfigMapName is the name of the generated ConfigMap. + type: string + configVersion: + description: ConfigVersion is the config.yaml schema version. + type: integer + generatedAt: + description: GeneratedAt is the timestamp of the last generation. + format: date-time + type: string + observedGeneration: + description: ObservedGeneration is the spec generation that was + last processed. + format: int64 + type: integer + providerCount: + description: ProviderCount is the number of configured providers. + type: integer + resourceCount: + description: ResourceCount is the number of registered resources. + type: integer + type: object distributionConfig: - description: DistributionConfig contains the configuration information - from the providers endpoint + description: DistributionConfig contains provider information from + the running server. properties: activeDistribution: - description: ActiveDistribution shows which distribution is currently - being used type: string availableDistributions: additionalProperties: type: string - description: AvailableDistributions lists all available distributions - and their images type: object providers: items: @@ -2720,8 +6731,8 @@ spec: config: x-kubernetes-preserve-unknown-fields: true health: - description: HealthStatus represents the health status of - a provider + description: ProviderHealthStatus represents the health + status of a provider. properties: message: type: string @@ -2744,8 +6755,12 @@ spec: type: object type: array type: object + externalURL: + description: ExternalURL is the external URL when external access + is configured. + type: string phase: - description: Phase represents the current phase of the distribution + description: Phase represents the current phase of the server. enum: - Pending - Initializing @@ -2753,42 +6768,42 @@ spec: - Failed - Terminating type: string - routeURL: - description: |- - RouteURL is the external URL where the distribution is exposed (when exposeRoute is true). - nil when external access is not configured, empty string when Ingress exists but URL not ready. - type: string + resolvedDistribution: + description: ResolvedDistribution tracks the resolved image and config + source. + properties: + configHash: + description: ConfigHash is the SHA256 hash of the base config + used. + type: string + configSource: + description: ConfigSource indicates the base config origin (for + example "configmap" or "oci-label"). + type: string + image: + description: Image is the resolved container image reference (with + digest when available). + type: string + type: object serviceURL: - description: ServiceURL is the internal Kubernetes service URL where - the distribution is exposed + description: ServiceURL is the internal Kubernetes service URL. type: string version: description: Version contains version information for both operator - and deployment + and server. properties: lastUpdated: - description: LastUpdated represents when the version information - was last updated format: date-time type: string - llamaStackServerVersion: - description: LlamaStackServerVersion is the version of the LlamaStack - server - type: string operatorVersion: - description: OperatorVersion is the version of the operator managing - this distribution + type: string + serverVersion: type: string type: object type: object required: - spec type: object - selectableFields: - - jsonPath: .spec.server.userConfig.configMapName - - jsonPath: .spec.server.userConfig.configMapNamespace - - jsonPath: .spec.server.tlsConfig.caBundle.configMapName - - jsonPath: .spec.server.tlsConfig.caBundle.configMapNamespace served: true storage: true subresources: @@ -2798,17 +6813,17 @@ apiVersion: v1 kind: ServiceAccount metadata: labels: - app.kubernetes.io/name: llama-stack-k8s-operator - name: llama-stack-k8s-operator-controller-manager - namespace: llama-stack-k8s-operator-system + app.kubernetes.io/name: ogx-k8s-operator + name: ogx-k8s-operator-controller-manager + namespace: ogx-k8s-operator-system --- apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: labels: - app.kubernetes.io/name: llama-stack-k8s-operator - name: llama-stack-k8s-operator-leader-election-role - namespace: llama-stack-k8s-operator-system + app.kubernetes.io/name: ogx-k8s-operator + name: ogx-k8s-operator-leader-election-role + namespace: ogx-k8s-operator-system rules: - apiGroups: - "" @@ -2846,85 +6861,60 @@ apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: labels: - app.kubernetes.io/name: llama-stack-k8s-operator - rbac.authorization.k8s.io/aggregate-to-admin: "true" - rbac.authorization.k8s.io/aggregate-to-edit: "true" - name: llama-stack-k8s-operator-llsd-editor-role + app.kubernetes.io/name: ogx-k8s-operator + name: ogx-k8s-operator-manager-role rules: - apiGroups: - - llamastack.io + - "" resources: - - llamastackdistributions + - configmaps + - persistentvolumeclaims verbs: - create - - delete - get - list - patch - update - watch - apiGroups: - - llamastack.io + - "" resources: - - llamastackdistributions/status + - pods verbs: - - get ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - labels: - app.kubernetes.io/name: llama-stack-k8s-operator - rbac.authorization.k8s.io/aggregate-to-view: "true" - name: llama-stack-k8s-operator-llsd-viewer-role -rules: + - list - apiGroups: - - llamastack.io + - "" resources: - - llamastackdistributions + - secrets verbs: - get - list - watch -- apiGroups: - - llamastack.io - resources: - - llamastackdistributions/status - verbs: - - get ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - labels: - app.kubernetes.io/name: llama-stack-k8s-operator - name: llama-stack-k8s-operator-manager-role -rules: - apiGroups: - "" resources: - - configmaps + - serviceaccounts + - services verbs: - create + - delete - get - list - patch - update - watch - apiGroups: - - "" + - apiextensions.k8s.io resources: - - persistentvolumeclaims + - customresourcedefinitions verbs: - - create - get - list - watch - apiGroups: - - "" + - apps resources: - - serviceaccounts - - services + - deployments verbs: - create - delete @@ -2936,14 +6926,10 @@ rules: - apiGroups: - apps resources: - - deployments + - replicasets verbs: - - create - - delete - get - list - - patch - - update - watch - apiGroups: - autoscaling @@ -2958,9 +6944,10 @@ rules: - update - watch - apiGroups: - - llamastack.io + - monitoring.coreos.com resources: - - llamastackdistributions + - prometheusrules + - servicemonitors verbs: - create - delete @@ -2970,24 +6957,22 @@ rules: - update - watch - apiGroups: - - llamastack.io - resources: - - llamastackdistributions/finalizers - verbs: - - update -- apiGroups: - - llamastack.io + - networking.k8s.io resources: - - llamastackdistributions/status + - ingresses + - networkpolicies verbs: + - create + - delete - get + - list - patch - update + - watch - apiGroups: - - networking.k8s.io + - ogx.io resources: - - ingresses - - networkpolicies + - ogxservers verbs: - create - delete @@ -2996,6 +6981,20 @@ rules: - patch - update - watch +- apiGroups: + - ogx.io + resources: + - ogxservers/finalizers + verbs: + - update +- apiGroups: + - ogx.io + resources: + - ogxservers/status + verbs: + - get + - patch + - update - apiGroups: - policy resources: @@ -3055,8 +7054,8 @@ apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: labels: - app.kubernetes.io/name: llama-stack-k8s-operator - name: llama-stack-k8s-operator-metrics-reader + app.kubernetes.io/name: ogx-k8s-operator + name: ogx-k8s-operator-metrics-reader rules: - nonResourceURLs: - /metrics @@ -3067,8 +7066,59 @@ apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: labels: - app.kubernetes.io/name: llama-stack-k8s-operator - name: llama-stack-k8s-operator-proxy-role + app.kubernetes.io/name: ogx-k8s-operator + rbac.authorization.k8s.io/aggregate-to-admin: "true" + rbac.authorization.k8s.io/aggregate-to-edit: "true" + name: ogx-k8s-operator-ogxserver-editor-role +rules: +- apiGroups: + - ogx.io + resources: + - ogxservers + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - ogx.io + resources: + - ogxservers/status + verbs: + - get +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: ogx-k8s-operator + rbac.authorization.k8s.io/aggregate-to-view: "true" + name: ogx-k8s-operator-ogxserver-viewer-role +rules: +- apiGroups: + - ogx.io + resources: + - ogxservers + verbs: + - get + - list + - watch +- apiGroups: + - ogx.io + resources: + - ogxservers/status + verbs: + - get +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: ogx-k8s-operator + name: ogx-k8s-operator-proxy-role rules: - apiGroups: - authentication.k8s.io @@ -3087,47 +7137,47 @@ apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: labels: - app.kubernetes.io/name: llama-stack-k8s-operator - name: llama-stack-k8s-operator-leader-election-rolebinding - namespace: llama-stack-k8s-operator-system + app.kubernetes.io/name: ogx-k8s-operator + name: ogx-k8s-operator-leader-election-rolebinding + namespace: ogx-k8s-operator-system roleRef: apiGroup: rbac.authorization.k8s.io kind: Role - name: llama-stack-k8s-operator-leader-election-role + name: ogx-k8s-operator-leader-election-role subjects: - kind: ServiceAccount - name: llama-stack-k8s-operator-controller-manager - namespace: llama-stack-k8s-operator-system + name: ogx-k8s-operator-controller-manager + namespace: ogx-k8s-operator-system --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: labels: - app.kubernetes.io/name: llama-stack-k8s-operator - name: llama-stack-k8s-operator-manager-rolebinding + app.kubernetes.io/name: ogx-k8s-operator + name: ogx-k8s-operator-manager-rolebinding roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole - name: llama-stack-k8s-operator-manager-role + name: ogx-k8s-operator-manager-role subjects: - kind: ServiceAccount - name: llama-stack-k8s-operator-controller-manager - namespace: llama-stack-k8s-operator-system + name: ogx-k8s-operator-controller-manager + namespace: ogx-k8s-operator-system --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: labels: - app.kubernetes.io/name: llama-stack-k8s-operator - name: llama-stack-k8s-operator-proxy-rolebinding + app.kubernetes.io/name: ogx-k8s-operator + name: ogx-k8s-operator-proxy-rolebinding roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole - name: llama-stack-k8s-operator-proxy-role + name: ogx-k8s-operator-proxy-role subjects: - kind: ServiceAccount - name: llama-stack-k8s-operator-controller-manager - namespace: llama-stack-k8s-operator-system + name: ogx-k8s-operator-controller-manager + namespace: ogx-k8s-operator-system --- apiVersion: v1 data: @@ -3142,7 +7192,7 @@ data: port: 9443 leaderElection: leaderElect: true - resourceName: 54e06e98.llamastack.io + resourceName: 54e06e98.ogx.io # leaderElectionReleaseOnCancel defines if the leader should step down volume # when the Manager ends. This requires the binary to immediately end when the # Manager is stopped, otherwise, this setting is unsafe. Setting this significantly @@ -3156,18 +7206,18 @@ data: kind: ConfigMap metadata: labels: - app.kubernetes.io/name: llama-stack-k8s-operator - name: llama-stack-k8s-operator-manager-config - namespace: llama-stack-k8s-operator-system + app.kubernetes.io/name: ogx-k8s-operator + name: ogx-k8s-operator-manager-config + namespace: ogx-k8s-operator-system --- apiVersion: v1 kind: Service metadata: labels: - app.kubernetes.io/name: llama-stack-k8s-operator + app.kubernetes.io/name: ogx-k8s-operator control-plane: controller-manager - name: llama-stack-k8s-operator-controller-manager-metrics-service - namespace: llama-stack-k8s-operator-system + name: ogx-k8s-operator-controller-manager-metrics-service + namespace: ogx-k8s-operator-system spec: ports: - name: https @@ -3175,29 +7225,45 @@ spec: protocol: TCP targetPort: https selector: - app.kubernetes.io/name: llama-stack-k8s-operator + app.kubernetes.io/name: ogx-k8s-operator + control-plane: controller-manager +--- +apiVersion: v1 +kind: Service +metadata: + labels: + app.kubernetes.io/name: ogx-k8s-operator + name: ogx-k8s-operator-webhook-service + namespace: ogx-k8s-operator-system +spec: + ports: + - port: 443 + protocol: TCP + targetPort: 9443 + selector: + app.kubernetes.io/name: ogx-k8s-operator control-plane: controller-manager --- apiVersion: apps/v1 kind: Deployment metadata: labels: - app.kubernetes.io/name: llama-stack-k8s-operator + app.kubernetes.io/name: ogx-k8s-operator control-plane: controller-manager - name: llama-stack-k8s-operator-controller-manager - namespace: llama-stack-k8s-operator-system + name: ogx-k8s-operator-controller-manager + namespace: ogx-k8s-operator-system spec: replicas: 1 selector: matchLabels: - app.kubernetes.io/name: llama-stack-k8s-operator + app.kubernetes.io/name: ogx-k8s-operator control-plane: controller-manager template: metadata: annotations: kubectl.kubernetes.io/default-container: manager labels: - app.kubernetes.io/name: llama-stack-k8s-operator + app.kubernetes.io/name: ogx-k8s-operator control-plane: controller-manager spec: containers: @@ -3210,7 +7276,15 @@ spec: value: 800MiB - name: OPERATOR_VERSION value: latest - image: quay.io/llamastack/llama-stack-k8s-operator:latest + - name: RELATED_IMAGE_STARTER + value: "" + - name: RELATED_IMAGE_REMOTE_VLLM + value: "" + - name: RELATED_IMAGE_META_REFERENCE_GPU + value: "" + - name: RELATED_IMAGE_POSTGRES_DEMO + value: "" + image: quay.io/ogx-ai/ogx-k8s-operator:latest imagePullPolicy: Always livenessProbe: httpGet: @@ -3219,6 +7293,10 @@ spec: initialDelaySeconds: 15 periodSeconds: 20 name: manager + ports: + - containerPort: 9443 + name: webhook-server + protocol: TCP readinessProbe: httpGet: path: /readyz @@ -3237,33 +7315,94 @@ spec: capabilities: drop: - ALL + volumeMounts: + - mountPath: /tmp/k8s-webhook-server/serving-certs + name: cert + readOnly: true securityContext: runAsNonRoot: true - serviceAccountName: llama-stack-k8s-operator-controller-manager + serviceAccountName: ogx-k8s-operator-controller-manager terminationGracePeriodSeconds: 10 topologySpreadConstraints: - labelSelector: matchLabels: - app.kubernetes.io/name: llama-stack-k8s-operator + app.kubernetes.io/name: ogx-k8s-operator maxSkew: 1 topologyKey: topology.kubernetes.io/zone whenUnsatisfiable: ScheduleAnyway - labelSelector: matchLabels: - app.kubernetes.io/name: llama-stack-k8s-operator + app.kubernetes.io/name: ogx-k8s-operator maxSkew: 1 topologyKey: kubernetes.io/hostname whenUnsatisfiable: ScheduleAnyway + volumes: + - name: cert + secret: + defaultMode: 420 + secretName: ogx-k8s-operator-webhook-cert --- apiVersion: policy/v1 kind: PodDisruptionBudget metadata: labels: - app.kubernetes.io/name: llama-stack-k8s-operator - name: llama-stack-k8s-operator-controller-manager-pdb - namespace: llama-stack-k8s-operator-system + app.kubernetes.io/name: ogx-k8s-operator + name: ogx-k8s-operator-controller-manager-pdb + namespace: ogx-k8s-operator-system spec: maxUnavailable: 1 selector: matchLabels: - app.kubernetes.io/name: llama-stack-k8s-operator + app.kubernetes.io/name: ogx-k8s-operator +--- +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: ogx-k8s-operator-serving-cert + namespace: ogx-k8s-operator-system +spec: + dnsNames: + - ogx-k8s-operator-webhook-service.ogx-k8s-operator-system.svc + - ogx-k8s-operator-webhook-service.ogx-k8s-operator-system.svc.cluster.local + issuerRef: + kind: Issuer + name: ogx-k8s-operator-selfsigned-issuer + secretName: ogx-k8s-operator-webhook-cert +--- +apiVersion: cert-manager.io/v1 +kind: Issuer +metadata: + name: ogx-k8s-operator-selfsigned-issuer + namespace: ogx-k8s-operator-system +spec: + selfSigned: {} +--- +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingWebhookConfiguration +metadata: + annotations: + cert-manager.io/inject-ca-from: ogx-k8s-operator-system/ogx-k8s-operator-serving-cert + labels: + app.kubernetes.io/name: ogx-k8s-operator + name: ogx-k8s-operator-validating-webhook-configuration +webhooks: +- admissionReviewVersions: + - v1 + clientConfig: + service: + name: ogx-k8s-operator-webhook-service + namespace: ogx-k8s-operator-system + path: /validate-ogx-io-v1beta1-ogxserver + failurePolicy: Fail + name: vogxserver.kb.io + rules: + - apiGroups: + - ogx.io + apiVersions: + - v1beta1 + operations: + - CREATE + - UPDATE + resources: + - ogxservers + sideEffects: None diff --git a/specs/002-operator-generated-config/contracts/crd-schema.yaml b/specs/002-operator-generated-config/contracts/crd-schema.yaml index 282fd25b6..e5ff34d8b 100644 --- a/specs/002-operator-generated-config/contracts/crd-schema.yaml +++ b/specs/002-operator-generated-config/contracts/crd-schema.yaml @@ -73,9 +73,9 @@ spec: configMapName: string configMapNamespace: string # Optional, defaults to CR namespace configMapKeys: [string] # Optional, defaults to ca-bundle.crt - expose: + externalAccess: enabled: bool # Enable external access (default: false) - hostname: string # Custom hostname for Ingress/Route (optional) + hostname: string # Custom hostname for external endpoint (optional) allowedFrom: namespaces: [string] labels: [string] # Namespace label keys for NetworkPolicy diff --git a/specs/002-operator-generated-config/spec.md b/specs/002-operator-generated-config/spec.md index c59a6422f..d85bf751f 100644 --- a/specs/002-operator-generated-config/spec.md +++ b/specs/002-operator-generated-config/spec.md @@ -135,13 +135,13 @@ As a platform operator, I want to update the LLSD CR (e.g., add a provider, chan - What: `secretKeyRef` points to a secret that doesn't exist - Expected: Reconciliation fails with clear error, status shows "Secret not found: {name}" -- **Expose with empty object**: - - What: User specifies `expose: {}` - - Expected: Treated as expose enabled with defaults (auto-generated hostname) +- **~~Expose with empty object~~** *(v1beta1: `expose` renamed to `externalAccess` with explicit `enabled` default false — see D-030. An empty `externalAccess: {}` means disabled.)*: + - What: User specifies `externalAccess: {}` + - Expected: External access is disabled (default `enabled: false`) - **Disabled APIs conflict with providers**: - - What: User configures `providers.inference` but also `disabled: [inference]` - - Expected: Validation fails with error: "spec.providers.inference conflicts with spec.disabled: inference API is disabled but has providers configured. Remove the provider configuration or remove 'inference' from the disabled list." Accepting contradictory config and silently ignoring part of it leads to user confusion. Failing fast is more Kubernetes-idiomatic. + - What: User configures `providers.inference` but also `disabledAPIs: [inference]` *(v1beta1: renamed from `disabled` — see D-031)* + - Expected: Validation fails with error: "inference cannot be both in providers and disabledAPIs". Accepting contradictory config and silently ignoring part of it leads to user confusion. Failing fast is more Kubernetes-idiomatic. - **Model references non-existent provider**: - What: `resources.models[].provider` references an ID not in `providers` @@ -165,7 +165,7 @@ As a platform operator, I want to update the LLSD CR (e.g., add a provider, chan - **Secret references via settings vs secretRefs**: - What: User puts a secretKeyRef inside `settings` instead of using the `secretRefs` field - - Expected: The `settings` map is passed through to config.yaml as-is without any secret resolution. Only the explicit `apiKey` and `secretRefs` fields trigger secret-to-env-var resolution. This is a clear, unambiguous boundary (no heuristic matching of map shapes). + - Expected: The `settings` map is passed through to config.yaml as-is without any secret resolution. Only the explicit `secretRefs` field triggers secret-to-env-var resolution *(v1beta1: `apiKey` removed — see D-035)*. This is a clear, unambiguous boundary (no heuristic matching of map shapes). - **Tools specified without toolRuntime provider**: - What: User specifies `resources.tools: [websearch]` but does not configure `providers.toolRuntime` @@ -183,18 +183,18 @@ As a platform operator, I want to update the LLSD CR (e.g., add a provider, chan - **FR-001**: The CRD MUST define a new API version `v1alpha2` with the redesigned schema - **FR-002**: The `spec.distribution` field MUST support both `name` (mapped) and `image` (direct) forms, mutually exclusive -- **FR-003**: The `spec.providers` section MUST support provider types: `inference`, `safety`, `vectorIo`, `toolRuntime`, `telemetry` +- **FR-003**: The `spec.providers` section MUST support provider types: `inference`, `safety`, `vectorIo`, `toolRuntime` ~~, `telemetry`~~ *(telemetry removed in v1beta1 — see D-033)* - **FR-004**: Each provider field MUST be a list of `ProviderConfig` objects (`[]ProviderConfig`). A single provider is expressed as a one-element list. This ensures kubebuilder validation markers apply to all provider fields and CEL rules can inspect provider IDs for uniqueness. -- **FR-005**: Each `ProviderConfig` MUST support fields: `id` (unique provider identifier), `provider` (type, required), `endpoint`, `apiKey` (secretKeyRef), `secretRefs` (named secret references map), `settings` (escape hatch). Provider-specific connection fields (e.g., `host` for vectorIo) MUST use `secretRefs` entries rather than embedding `secretKeyRef` inside `settings`. The `secretRefs` field is a `map[string]SecretKeyRef` where each key becomes the env var field suffix. The `settings` map is passed through to config.yaml as-is without secret resolution. +- **FR-005**: Each `ProviderConfig` MUST support fields: `id` (unique provider identifier), `provider` (type, required — *v1beta1: must have `remote::` or `inline::` prefix, see D-034*), `endpoint`, ~~`apiKey` (secretKeyRef),~~ `secretRefs` (named secret references map — *v1beta1: replaces `apiKey`, see D-035*), `settings` (escape hatch). Provider-specific connection fields (e.g., `host` for vectorIo) MUST use `secretRefs` entries rather than embedding `secretKeyRef` inside `settings`. The `secretRefs` field is a `map[string]SecretKeyRef` where each key becomes the env var field suffix. The `settings` map is passed through to config.yaml as-is without secret resolution. - **FR-006**: The `spec.resources` section MUST support: `models`, `tools`, `shields` - **FR-007**: `resources.models` MUST be a list of `ModelConfig` objects (`[]ModelConfig`), where only the `name` field is required. Simple model references use `ModelConfig` with just `name` set. `resources.tools` and `resources.shields` MUST be lists of strings. - **FR-008**: The `spec.storage` section MUST have subsections: `kv` (key-value) and `sql` (relational) -- **FR-009**: The `spec.disabled` field MUST be a list of API names to disable +- **FR-009**: The `spec.disabled` field MUST be a list of API names to disable *(v1beta1: renamed to `spec.disabledAPIs`, enum restricted to `agents`, `inference`, `tool_runtime`, `vector_io` — see D-031)* - **FR-010**: The `spec.networking` section MUST consolidate: `port`, `tls`, `expose`, `allowedFrom` - **FR-011**: The `networking.expose` field MUST be an object with optional `enabled` (bool) and `hostname` (string) fields. When `enabled` is true (or when the object is present with defaults), an Ingress/Route is created. When `hostname` is specified, it is used for the Ingress/Route hostname. - **FR-012**: The `spec.workload` section MUST contain K8s deployment settings: `replicas`, `workers`, `resources`, `autoscaling`, `storage`, `podDisruptionBudget`, `topologySpreadConstraints`, `overrides` - **FR-013**: The `spec.overrideConfig` field MUST be mutually exclusive with `providers`, `resources`, `storage`, `disabled`. The referenced ConfigMap MUST reside in the same namespace as the LLSD CR (consistent with namespace-scoped RBAC, constitution section 1.1) -- **FR-014**: The `spec.externalProviders` field MUST remain for integration with spec 001 +- ~~**FR-014**: The `spec.externalProviders` field MUST remain for integration with spec 001~~ *(removed in v1beta1 — design not yet finalized, see D-032)* #### Configuration Generation @@ -238,16 +238,16 @@ The base config extraction follows a phased approach. Phase 1 provides an implem #### Provider Configuration -- **FR-030**: Provider `provider` field MUST map to `provider_type` with `remote::` prefix (e.g., `vllm` becomes `remote::vllm`) +- **FR-030**: Provider `provider` field MUST map to `provider_type` ~~with `remote::` prefix (e.g., `vllm` becomes `remote::vllm`)~~ *(v1beta1: explicit `remote::` or `inline::` prefix required on the field value itself — no implicit normalization, see D-034)* - **FR-031**: Provider `endpoint` field MUST map to `config.url` in config.yaml - **FR-032**: Provider `apiKey.secretKeyRef` and `secretRefs` entries MUST be resolved to environment variables and referenced as `${env.LLSD__}`, where `` is the provider's unique `id` (explicit or auto-generated per FR-035), uppercased with hyphens replaced by underscores. For `apiKey`, the field suffix is `API_KEY`. For `secretRefs`, the map key is uppercased with hyphens replaced by underscores. Example: provider ID `vllm-primary` with `apiKey` produces `LLSD_VLLM_PRIMARY_API_KEY`; `secretRefs.host` produces `LLSD_VLLM_PRIMARY_HOST`. - **FR-033**: Provider `settings` MUST be merged into the provider's `config` section in config.yaml - **FR-034**: When multiple providers are specified for the same API type, each MUST have an explicit `id` field. CEL validation enforces this at admission time. - **FR-035**: A single provider (one-element list) without `id` MUST auto-generate `provider_id` from the `provider` field value -#### Telemetry Provider +#### ~~Telemetry Provider~~ *(removed in v1beta1 — see D-033)* -- **FR-036**: Telemetry providers follow the same schema as other provider types (FR-004, FR-005). The `provider` field maps to the telemetry backend (e.g., `opentelemetry`). The `endpoint` and `settings` fields configure the telemetry destination. No telemetry-specific fields are defined beyond the standard provider schema. +- ~~**FR-036**: Telemetry providers follow the same schema as other provider types (FR-004, FR-005). The `provider` field maps to the telemetry backend (e.g., `opentelemetry`). The `endpoint` and `settings` fields configure the telemetry destination. No telemetry-specific fields are defined beyond the standard provider schema.~~ *(removed in v1beta1 — telemetry provider type doesn't exist upstream)* #### Resource Registration @@ -280,7 +280,7 @@ The base config extraction follows a phased approach. Phase 1 provides an implem #### Validation - **FR-070**: CEL validation MUST enforce mutual exclusivity between `overrideConfig` and each of `providers`, `resources`, `storage`, and `disabled` -- **FR-071**: CEL validation MUST require explicit `id` on each `ProviderConfig` when a provider list has more than one element. Rule applied per provider type field (e.g., `self.providers.inference.size() <= 1 || self.providers.inference.all(p, has(p.id))`) +- ~~**FR-071**: CEL validation MUST require explicit `id` on each `ProviderConfig` when a provider list has more than one element. Rule applied per provider type field (e.g., `self.providers.inference.size() <= 1 || self.providers.inference.all(p, has(p.id))`)~~ *(v1beta1: replaced by validating webhook for global provider ID uniqueness — see D-036)* - **FR-072**: CEL validation MUST enforce unique provider IDs across all provider types. Since providers are typed slices, CEL can inspect IDs directly (e.g., check that the union of all provider IDs has no duplicates) - **FR-073**: Controller validation MUST verify referenced Secrets exist before generating config - **FR-074**: Controller validation MUST verify referenced ConfigMaps exist for `overrideConfig` and `caBundle` @@ -288,7 +288,7 @@ The base config extraction follows a phased approach. Phase 1 provides an implem - **FR-076**: A validating admission webhook MUST validate CR creation and update operations for constraints that cannot be expressed in CEL (e.g., Secret existence checks, ConfigMap existence for `overrideConfig`, cross-field semantic validation such as provider ID references in resources) - **FR-077**: The validating webhook MUST return structured error responses with field paths and actionable messages following Kubernetes API conventions - **FR-078**: The validating webhook MUST be deployed as part of the operator installation and configured via the operator's kustomize manifests with appropriate certificate management -- **FR-079**: CEL validation MUST enforce that `networking.tls.secretName` is required when `networking.tls.enabled` is true: `!self.tls.enabled || has(self.tls.secretName)` +- ~~**FR-079**: CEL validation MUST enforce that `networking.tls.secretName` is required when `networking.tls.enabled` is true: `!self.tls.enabled || has(self.tls.secretName)`~~ *(v1beta1: TLS uses presence semantics — `secretName` is always required when `tls` is present, no `enabled` bool — see D-028)* - **FR-079a**: CEL validation MUST enforce that `storage.kv.endpoint` is required when `storage.kv.type` is `redis`: `self.storage.kv.type != 'redis' || has(self.storage.kv.endpoint)` - **FR-079b**: CEL validation MUST enforce that `storage.sql.connectionString` is required when `storage.sql.type` is `postgres`: `self.storage.sql.type != 'postgres' || has(self.storage.sql.connectionString)` - **FR-079c**: CEL validation SHOULD warn when `storage.kv.endpoint` or `storage.kv.password` are specified with `storage.kv.type: sqlite` (unused fields) @@ -673,6 +673,134 @@ status: - ~~**OQ-003**~~: Resolved. Environment variable naming uses the **provider ID** (not provider type or API type): `LLSD__`. The provider ID is unique across all providers (enforced by FR-072), ensuring no collisions. For single providers without explicit `id`, the auto-generated ID from FR-035 is used. Examples: `LLSD_VLLM_PRIMARY_API_KEY`, `LLSD_PGVECTOR_HOST`. Characters not valid in env var names (hyphens) are replaced with underscores and uppercased. (From PR #242 review) - **OQ-004**: Should the operator create a default LlamaStackDistribution instance when installed? This is uncommon for Kubernetes operators but could improve the getting-started experience. If adopted, it should be opt-in via operator configuration (e.g., a Helm value or OLM parameter). (From team discussion, 2026-02-10) +## v1beta1 Folding Divergences (2026-04-29) + +When the v1alpha2 types were folded into the `OGXServer` CRD (`ogx.io/v1beta1`) in [PR #289](https://github.com/ogx-ai/ogx-k8s-operator/pull/289), the following changes diverged from this spec. These reflect review feedback and improved API design decisions. See `specs/003-ogx-rename/research.md` (D-027 through D-038) for detailed rationale. + +| This spec (v1alpha2) | v1beta1 OGXServer (actual) | Rationale | +|-----------------------|---------------------------|-----------| +| `spec.networking` | `spec.network` | Shorter, avoids conflation (D-026) | +| `spec.networking.tls.caBundle` nested under TLS | `spec.caBundle` top-level on OGXServerSpec | CA trust is a server-wide concern, not TLS-specific (D-027) | +| `spec.networking.tls.enabled` + `secretName` | `spec.network.tls` with presence semantics, `secretName` required | Avoids contradicting `enabled: false` + required `secretName` (D-028) | +| `spec.networking.allowedFrom` | `spec.network.policy` with native K8s types + `policyTypes` | Full NetworkPolicy power, avoids `network.networkPolicy` stutter (D-029) | +| `spec.networking.expose` (polymorphic, object or bool) | `spec.network.externalAccess` with explicit `enabled` (default false) | Clearer UX, mechanism-neutral naming (D-030) | +| `spec.disabled` | `spec.disabledAPIs` | Makes field purpose explicit (D-031) | +| `spec.externalProviders` | Removed | Design not yet finalized (D-032) | +| `providers.telemetry` | Removed | Provider type doesn't exist upstream (D-033) | +| `ProviderConfig.provider` — implicit `remote::` prefix | Must start with `remote::` or `inline::` (CEL-enforced) | Better UX, removes normalization (D-034) | +| `ProviderConfig.apiKey` (`*SecretKeyRef`) | `ProviderConfig.secretRefs` (`map[string]SecretKeyRef`) | Flexible named secret refs (D-035) | +| Per-slice CEL for provider ID uniqueness (FR-071) | Validating webhook for global ID uniqueness | Global uniqueness across all API types (D-036) | +| `CABundleConfig.configMapNamespace` | Removed | Same-namespace requirement for multi-tenancy (D-027) | +| `DefaultMountPath = "/.llama"` | `DefaultMountPath = "/.ogx"` | Consistent with OGX branding (D-038) | +| No label requirement on referenced ConfigMaps/Secrets | `ogx.io/watch: "true"` label required | Filtered informer cache (D-037) | +| FR-030: implicit `remote::` prefix normalization | Explicit prefix required, no normalization | Better UX (D-034) | +| FR-036: Telemetry provider | Removed | Doesn't exist (D-033) | + +### Updated CRD Schema (v1beta1) + +```yaml +apiVersion: ogx.io/v1beta1 +kind: OGXServer +metadata: + name: my-server +spec: + distribution: + name: starter # OR image: "registry/image:tag" + + providers: + inference: + - id: vllm-primary + provider: "remote::vllm" # explicit prefix required + endpoint: "http://vllm:8000" + secretRefs: # replaces apiKey + api-key: + name: vllm-creds + key: token + settings: + max_tokens: 8192 + safety: + - provider: "remote::llama-guard" + vectorIo: + - provider: "remote::pgvector" + secretRefs: + host: + name: pg-creds + key: host + # No telemetry provider (removed) + + resources: + models: # typed []ModelConfig + - name: "llama3.2-8b" + - name: "llama3.2-70b" + provider: vllm-primary + contextLength: 128000 + tools: + - websearch + - rag + shields: + - llama-guard + + storage: + kv: + type: sqlite + sql: + type: postgres + connectionString: + name: pg-creds + key: url + + disabledAPIs: # renamed from disabled; enum: agents, inference, tool_runtime, vector_io + - agents + + caBundle: # top-level, not under tls + configMapName: custom-ca + # no configMapNamespace (same namespace required) + + network: # not networking + port: 8321 + tls: # presence = enabled, no enabled bool + secretName: my-tls-secret + externalAccess: # not expose + enabled: true + hostname: "ogx.example.com" + policy: # not networkPolicy + enabled: true + policyTypes: # follows K8s semantics + - Ingress + - Egress + ingress: + - from: + - namespaceSelector: + matchLabels: + app: frontend + egress: + - to: + - namespaceSelector: {} + + workload: + replicas: 1 + workers: 2 + resources: + requests: {cpu: "500m", memory: "1Gi"} + limits: {cpu: "2", memory: "4Gi"} + autoscaling: + minReplicas: 1 + maxReplicas: 5 + targetCPUUtilizationPercentage: 80 + storage: + size: "10Gi" + mountPath: "/.ogx" # not /.llama + podDisruptionBudget: + maxUnavailable: 1 + overrides: + serviceAccountName: "custom-sa" + + # No externalProviders (removed - design not finalized) + + # overrideConfig: + # configMapName: my-full-config +``` + ## References - Design Document: [LlamaStackDistribution CRD v1alpha2 Schema Design](https://docs.google.com/document/d/10VhoQPb8bLGUo9yka4MXuEGIZClGf1oBr31TpK4NLD0/edit) diff --git a/specs/003-ogx-rename/deferred-items.md b/specs/003-ogx-rename/deferred-items.md new file mode 100644 index 000000000..88ac9fb3e --- /dev/null +++ b/specs/003-ogx-rename/deferred-items.md @@ -0,0 +1,49 @@ +# Deferred Items: OGX Rename + +Items deferred from the initial rename PRs per FR-006. These use legacy naming intentionally and will be addressed in follow-up work. + +## Container Registry URLs + +The following container registry references still use the old naming convention. These depend on external infrastructure (registry org creation, image migration) and will be updated once the `ogx-ai` Quay.io org and image pipeline are established: + +- `quay.io/llamastack/` image references in `distributions.json` +- Hardcoded fallback images in `pkg/cluster/cluster.go` +- CI workflow image push targets (`.github/workflows/build-image.yml`, `release-image.yml`) + +## Git Organization URLs + +The Go module path has been updated to `github.com/ogx-ai/ogx-k8s-operator`, but the following depend on the GitHub org existing with the repo: + +- `go.mod` module path (currently updated but repo not yet migrated) +- Import paths across all Go files (updated to new module path) +- GitHub Actions workflow references to the repo + +## Upstream Runtime Contracts + +Per the spec, the following upstream runtime contract strings are preserved as-is because upstream is still stabilizing them: + +- `LLAMA_STACK_CONFIG` environment variable +- `/etc/llama-stack/config.yaml` config mount path +- `/.llama` legacy upstream data directory (operator uses `/.ogx` for its own mount) +- `llama_stack.core.server.server` Python module entrypoint + +These will be updated once the upstream `llama-stack` Python package completes its own rename. + +## Adoption Annotations / Labels + +The following annotations and labels are transitional and will be removed once migration tooling is no longer needed: + +- `ogx.io/adopt-storage` (annotation on OGXServer CR) +- `ogx.io/adopt-networking` (annotation on OGXServer CR) +- `ogx.io/adopted-from` (label on adopted PVC/Service/Ingress) +- `ogx.io/adopted-at` (annotation on adopted resources) + +Target removal: 2 minor releases after adoption feature ships. + +## RBAC Markers + +Transitional RBAC permissions in `controllers/kubebuilder_rbac.go` for legacy resource access: + +- Pods (`list`) — for checking termination status during adoption scale-down + +These will be removed alongside the adoption annotations. diff --git a/specs/003-ogx-rename/plan.md b/specs/003-ogx-rename/plan.md new file mode 100644 index 000000000..320eef379 --- /dev/null +++ b/specs/003-ogx-rename/plan.md @@ -0,0 +1,220 @@ +# Implementation Plan: OGX (Open GenAI Stack) Operator + +**Branch**: `003-ogx-rename` | **Date**: 2026-04-29 | **Spec**: `specs/003-ogx-rename/spec.md` + +## Summary + +Replace the `LlamaStackDistribution` CRD (`llamastack.io/v1alpha1`) with a new `OGXServer` CRD (`ogx.io/v1beta1`). This is a **breaking change** — no conversion webhooks, no coexistence period. The new CRD incorporates both the rename and the expanded API surface from spec 002 (providers, resources, state storage, **`spec.network`** (port, TLS with presence semantics, externalAccess with explicit enabled field, **`policy`** with native K8s ingress/egress types and policyTypes), **`spec.caBundle`** (top-level, independent of network/TLS), workload, overrideConfig). The OGX controller handles the new CR and will later handle config generation. The legacy `AllowedFromSpec` and ConfigMap-based `enableNetworkPolicy` feature flag are replaced by `spec.network.policy` with per-CR `enabled` toggle, `policyTypes`, and native `NetworkPolicyIngressRule`/`NetworkPolicyEgressRule` types. + +Upstream runtime contracts (`LLAMA_STACK_CONFIG`, `/etc/llama-stack/config.yaml`, etc.) are being updated upstream and are out of scope for the initial PRs. + +### PR Strategy + +| PR | Scope | Description | +|----|-------|-------------| +| **PR 1** | API types + generated artifacts | New `OGXServer` CRD with expanded spec, generated deepcopy + CRD YAML, config scaffolding, samples, CI | +| **PR 2** | Controller + adoption + tests + docs | Reconciler adapted to new spec, annotation-driven adoption, unit/E2E tests, migration guide | +| **PR 3** | Config generation | Provider expansion, resource registration, storage config, secret resolution (spec 002 follow-up) | + +## Technical Context + +**Language/Version**: Go 1.25+ (operator), Python 3.11+ (server images — not modified) +**Primary Dependencies**: controller-runtime v0.22+, client-go v0.34+, kubebuilder v4 +**Target Platform**: Kubernetes 1.32+, OpenShift 4.19+ + +## Constitution Check + +- ✅ §1.1 Namespace-scoped — Adoption operates within the CR's namespace; no cluster-admin required. +- ✅ §1.2 Idempotent reconciliation — Adoption checks ownerRefs before acting. +- ✅ §1.3 Owner references — Adopted PVC/Service/Ingress get ownerRefs pointing to the new CR. +- ✅ §2.1 Kubebuilder validation — New CRD uses CEL validation markers. +- ✅ §3 Status has Phase + Conditions — `StorageAdopted` and `NetworkingAdopted` condition types. +- ✅ §4.1 Error wrapping — All new code wraps errors with `%w` and resource identifiers. +- ✅ §5.1 Logger in context — Adoption uses context logger with migration-specific fields. +- ✅ §6.1 Table-driven tests — All adoption test cases use table-driven pattern. +- ✅ §11 Feature flags — Adoption is opt-in via CR annotations; no global flag needed. +- ✅ §13.1 Conventional commits — All commits use `feat:`, `fix:`, `refactor:`, `docs:` prefixes. + +## Project Structure + +### New and Renamed Files + +```text +api/v1beta1/ +├── groupversion_info.go # NEW: API group ogx.io, version v1beta1 +├── ogxserver_types.go # NEW: expanded OGXServer types (replaces llamastackdistribution_types.go) +├── ogxserver_types_test.go # NEW: adoption helper tests +└── zz_generated.deepcopy.go # REGENERATE + +api/v1alpha1/ # DELETE after migration (legacy llamastack.io types removed) +api/v1alpha2/ # DELETE (folded into ogx.io/v1beta1) + +controllers/ +├── ogxserver_controller.go # RENAME from llamastackdistribution_controller.go +├── ogxserver_controller_test.go # RENAME +├── ogxserver_controller_ca_whitespace_test.go # RENAME +├── legacy_adoption.go # NEW +├── legacy_adoption_test.go # NEW +├── kubebuilder_rbac.go # MODIFY: ogx.io markers +├── status.go # MODIFY: OGXServer types, new conditions +├── resource_helper.go # MODIFY: new spec shape +├── network_resources.go # MODIFY: new `spec.network.policy` shape (native K8s types, policyTypes, per-CR enable, auto kube-dns egress) +└── manifests/base/*.yaml # MODIFY: app: ogx labels + +config/ +├── crd/bases/ogx.io_ogxservers.yaml # REGENERATE +├── crd/patches/cainjection_in_ogxservers.yaml # RENAME +├── rbac/ogxserver_editor_role.yaml # RENAME from llsd_editor_role.yaml +├── rbac/ogxserver_viewer_role.yaml # RENAME from llsd_viewer_role.yaml +├── samples/_v1beta1_ogxserver.yaml # RENAME + restructure to new schema +└── samples/example-*.yaml # MODIFY: new apiVersion, Kind, spec shape +``` + +## Implementation Phases + +Phases map 1:1 to the task list in `tasks.md`. See that file for the complete task breakdown. + +### Phase 1: API Changes (PR 1) + +New `OGXServer` types under `ogx.io/v1beta1` with the expanded spec from 002: +- `OGXServerSpec` with `distribution`, `providers`, `resources`, `storage`, `disabledAPIs`, **`network`**, **`caBundle`** (top-level), `workload`, `overrideConfig` +- **`spec.caBundle`**: Top-level `CABundleConfig` with `configMapName` and optional `configMapKeys`. Configures outbound trust (CA certificates for provider/backend connections), independent of inbound TLS termination. Moved out of `spec.network.tls` because CA trust is a server-wide concern. +- **`spec.network.policy`**: `NetworkPolicySpec` with `enabled` (default true), `policyTypes` (`[]networkingv1.PolicyType`, following K8s NetworkPolicy semantics), `ingress` (`[]networkingv1.NetworkPolicyIngressRule`), `egress` (`[]networkingv1.NetworkPolicyEgressRule`). Uses native Kubernetes NetworkPolicy types for zero-conversion, full-power policy configuration. When nil/default, operator generates safe defaults (ingress on service port from same-namespace + operator-namespace; egress unrestricted). Replaces the legacy `AllowedFromSpec` and the ConfigMap-based `enableNetworkPolicy` feature flag. +- **`spec.network.tls`**: `TLSSpec` with required `secretName`. Uses presence semantics (TLS enabled when the `tls` field is present, disabled when omitted). No explicit `enabled` bool — avoids contradicting states. +- **`spec.network.externalAccess`**: `ExternalAccessConfig` with explicit `enabled` (default false) and optional `hostname`. Replaces the polymorphic `expose` field. Named for mechanism-neutrality (supports both Ingress and Route). +- **`spec.providers`**: Typed `[]ProviderConfig` slices per API type (`inference`, `safety`, `vectorIo`, `toolRuntime`). No `telemetry` provider (it doesn't exist). `ProviderConfig.Provider` requires explicit `remote::` or `inline::` prefix (CEL-enforced). `ProviderConfig.apiKey` replaced with flexible `secretRefs` map. +- **`spec.disabledAPIs`**: Renamed from `disabled` for clarity — explicitly states the field controls which API types are excluded from config generation. +- CEL validation: `providers`/`resources`/`storage`/`disabledAPIs` mutually exclusive with `overrideConfig`; `distribution.name` mutually exclusive with `distribution.image`; disabled+providers cross-field conflict validation; provider prefix requirement +- Validating webhook: distribution name validation against embedded registry, cross-slice provider ID uniqueness (global, not per-slice), model provider reference validation +- Status types: `OGXServerStatus` with `ResolvedDistribution`, `ConfigGeneration`, `ServerVersion`, `ExternalURL` (renamed from `RouteURL`) +- Adoption annotation helpers (`GetAdoptStorageSource`, `GetEffectivePVCName`) +- Generated CRD YAML, deepcopy, config scaffolding, samples, CI updates +- All ConfigMap/Secret references require `ogx.io/watch: "true"` label for operator cache detection +- `ExternalProviders` removed — design not yet finalized +- `DefaultMountPath` updated to `/.ogx` + +### Phase 2: Controller Foundation (PR 2) + +Rename controller files and adapt the reconciler to the new spec structure: +- Map `spec.distribution` → image, `spec.workload` → Deployment, **`spec.network`** → Service/Ingress/NetworkPolicy, **`spec.caBundle`** → CA trust volume mounts +- **NetworkPolicy reconciliation**: read `spec.network.policy.enabled` (default true); when disabled, delete existing NP. When enabled with no custom rules, generate default ingress (same-namespace + operator-namespace on service port, egress unrestricted). When custom `ingress`/`egress` provided, merge with defaults. Respect `policyTypes` following K8s NetworkPolicy semantics. Auto-inject kube-dns egress rule (UDP/TCP 53) when any egress rules are configured or when "Egress" is in policyTypes. Remove the ConfigMap-based `enableNetworkPolicy` feature flag and `pkg/featureflags/` package. +- **TLS handling**: `spec.network.tls` uses presence semantics — TLS enabled when the field is present (with required `secretName`), disabled when omitted +- **External access**: `spec.network.externalAccess.enabled` controls Ingress/Route creation (default false) +- `overrideConfig` path: mount user-provided ConfigMap +- Default path: deploy with distribution's embedded config (no ConfigMap mount) +- Update all packages (`pkg/deploy`, `pkg/cluster`), `main.go`, manifests + +### Phase 3: Adoption Logic (PR 2) + +Annotation-driven adoption of legacy resources: +- Validate annotation values are non-empty, valid RFC 1123 DNS labels (FR-007); set `AdoptionConfigInvalid` condition if invalid, skip adoption, proceed with normal reconciliation +- `ogx.io/adopt-storage` → adopt PVC, scale old Deployment to zero, replace ownerRef (remove old controller ref, then `SetControllerReference` to new CR), annotate adopted resources with `ogx.io/adopted-from` and `ogx.io/adopted-at` (FR-017a) +- `ogx.io/adopt-networking` → adopt Service (update selectors) + Ingress, transfer ownerRefs. When CR name differs from legacy name, adopted resources coexist alongside new resources from the kustomize pipeline (FR-015) +- Idempotency via `metav1.IsControlledBy` +- `StorageAdopted` / `NetworkingAdopted` / `AdoptionConfigInvalid` conditions; audit annotations on child resources (FR-017a) +- RBAC markers for legacy resource access (marked `// TRANSITIONAL`) + +### Phase 4–6: Tests, Docs, Verification (PR 2) + +Tests (unit + E2E), migration guide, doc updates, residual naming audit. + +## User Upgrade Steps + +This is a **breaking change**. Only the OGX operator will be available after upgrade. + +```text +Step 1: Remove the old LLS operator + ├─ Via meta-operator: set dsc.spec.components.lls to "Removed" + └─ Manual: delete operator manifests (operator Deployment, RBAC, ServiceAccount) + +Step 2: Delete orphaned stateless resources + └─ kubectl delete deploy,networkpolicy,sa,rolebinding -l app.kubernetes.io/instance= + └─ kubectl delete hpa,pdb -l app.kubernetes.io/instance= + (KEEP: PVC, and optionally Service + Ingress for adoption. + WHY: Orphaned resources have no ownerRefs. The new operator's patchResource + safety check skips resources it doesn't own. Orphaned NetworkPolicy with + app: llama-stack selector leaves new app: ogx pods unprotected.) + +Step 3: Install the new OGX operator + ├─ Via meta-operator: set dsc.spec.components.ogx to "Managed" + └─ Manual: apply release/operator.yaml + +Step 4: Create OGXServer CR with adopt-storage annotation + └─ Translate fields from old LLSD CR + ConfigMap into new OGXServer spec + └─ Include annotation: ogx.io/adopt-storage: "" + +Step 5 (optional): Adopt networking + └─ Add annotation: ogx.io/adopt-networking: "" + +Step 6: Clean up legacy resources + └─ kubectl delete crd llamastackdistributions.llamastack.io +``` + +## Adoption Design + +### Storage adoption flow + +1. Validate annotation value is non-empty and a valid RFC 1123 DNS label (FR-007); set `AdoptionConfigInvalid` condition if invalid, skip adoption, proceed with normal reconciliation +2. Resolve legacy PVC name: `{legacyName}-pvc` +3. If PVC not found, log warning and skip (no error) +4. If PVC already has ownerRef pointing to this CR, return early (idempotent) +5. If old Deployment still exists and is running, scale to zero and requeue +6. Wait for old pods to terminate (requeue with delay) +7. Replace PVC ownerRef: remove existing controller ownerRef (legacy CR), then call `ctrl.SetControllerReference` to set new CR as controller owner. Annotate PVC with `ogx.io/adopted-from` and `ogx.io/adopted-at`. Emit event. + +### Networking adoption flow + +1. Validate annotation value is non-empty and a valid RFC 1123 DNS label (FR-007); set `AdoptionConfigInvalid` condition if invalid, skip adoption, proceed with normal reconciliation +2. Adopt Service: update `spec.selector` to new pod labels, replace ownerRef (remove old controller ref, `SetControllerReference` to new CR), annotate with `ogx.io/adopted-from` and `ogx.io/adopted-at` +3. Adopt Ingress: replace ownerRef (same pattern), annotate with `ogx.io/adopted-from` and `ogx.io/adopted-at` +4. **Same-name case** (CR name == legacy name): resource names match the kustomize pipeline's naming convention — managed normally after ownership transfer, no duplicate resources +5. **Different-name case** (CR name != legacy name): adopted legacy resources coexist alongside new resources created by the kustomize pipeline. Both sets are owned by the new CR. Removing the `ogx.io/adopt-networking` annotation causes the operator to delete the adopted legacy resources. + +### Rollback + +Scaling old Deployment to zero (not deleting) preserves a rollback path: remove annotation, scale old Deployment back up, recreate old LLSD CR. + +## Alternative Approaches Considered + +| Approach | Why Rejected | +|----------|-------------| +| Automatic adoption controller (startup scan) | Deployment `spec.selector.matchLabels` is immutable — can't patch `app: llama-stack` to `app: ogx`, causing persistent reconciliation failures and cascading label mismatches | +| Conversion webhook | Not needed for this breaking cut (new group/kind; users recreate CRs); webhook would add operational complexity without serving legacy `ogx.io` versions | +| Standalone migration CLI | Annotation-driven adoption in the operator is simpler; no separate tool to install | +| In-place CRD rename | API group can't be changed this way; not supported by Kubernetes | +| Parallel reconcilers | Two reconcilers fighting over same child resources; complex ownership | + +## Complexity Tracking + +| Decision | Why Needed | Simpler Alternative Rejected Because | +|----------|------------|--------------------------------------| +| Annotation-driven adoption | Users need to preserve PVC data during migration | Automatic adoption rejected due to immutable Deployment selector | +| Scale-to-zero (not delete) old Deployment | Preserves rollback path | Deleting is simpler but irreversible | +| `GetEffectivePVCName()` helper | Adopted PVC has different name than default convention | Hardcoding `instance.Name + "-pvc"` breaks adoption | +| Requeue on pending pod termination | RWO PVC can't be mounted by two nodes | Without requeue, new pod stuck in Pending | +| Delete orphaned stateless resources (Step 2) | `patchResource` ownership check blocks updates to orphans; NetworkPolicy with old selector leaves pods unprotected | Modifying `patchResource` to claim unowned resources would weaken safety check | + +## Risks and Mitigations + +| Risk | Impact | Mitigation | +|------|--------|------------| +| User deletes LLSD CR without removing operator first (cascade-deletes PVC) | High — data loss | Migration guide prominently warns; instruct to remove operator first | +| RWO PVC transfer causes brief downtime | Medium — 30-60s interruption | Documented and expected; status conditions report progress | +| User removes `adopt-storage` annotation while adopted PVC in use | Medium — reconciler references wrong PVC name | Status condition warning; document that annotation must persist | +| Module path change breaks downstream importers | Medium — downstream must update imports | Coordinate with ODH/RHOAI before merge | + +## Dependencies and Prerequisites + +- Confirm `ogx-ai` GitHub org exists and `ogx-k8s-operator` repo is created +- Confirm `ogx.io` API group has no conflicts +- Coordinate with downstream consumers (ODH, RHOAI) on migration timeline +- Decide if container registry migration is in this release or deferred (FR-006) + +## Success Metrics + +- PVC data preserved byte-for-byte after adoption (SC-001) +- Clean-install startup time unchanged (SC-002) +- Migration guide enables unassisted migration (SC-003) +- No residual legacy naming in operator-owned artifacts (SC-005) +- `kubectl get ogxserver` returns new resources (SC-006) +- PVC adoption downtime under 90 seconds (SC-009) diff --git a/specs/003-ogx-rename/research.md b/specs/003-ogx-rename/research.md new file mode 100644 index 000000000..1d752d381 --- /dev/null +++ b/specs/003-ogx-rename/research.md @@ -0,0 +1,280 @@ +# Research: Rename to OGX (Open GenAI Stack) Operator + +**Branch**: `003-ogx-rename` +**Date**: 2026-04-16 (updated 2026-04-29) +**Spec**: `specs/003-ogx-rename/spec.md` + +## Naming Decisions + +### D-001: New API Group + +- **Decision**: `ogx.io` +- **Rationale**: Short, memorable, matches the "OGX" branding. Standard Kubernetes convention for API groups. +- **Alternatives considered**: + - `opengenaistack.io` — too long for daily CLI use + - `ogx.ai` — `.ai` TLD less conventional for K8s API groups; reserve for the GitHub org + +### D-002: New CRD Kind + +- **Decision**: `OGXServer` +- **Rationale**: Per FR-003, the Kind should reflect that the resource encompasses the full server deployment, not just distribution selection. `OGXServer` is concise and accurately describes the resource. +- **Alternatives considered**: + - `OGXDistribution` — still uses "distribution" which the spec wants to move away from + - `OGXStack` — ambiguous (stack of what?) + - `OpenGenAIStackServer` — too long + +### D-003: New Short Name + +- **Decision**: `ogxserver` +- **Rationale**: Matches the singular resource name, which is the most intuitive short name for users. Avoids the ambiguity of an abbreviation. +- **Alternatives considered**: + - `ogxs` — shorter but less intuitive than the singular form + - `ogx` — simpler but may collide if the API group later adds more Kinds + +### D-004: New Resource Plural + +- **Decision**: `ogxservers` (auto-generated from Kind by controller-gen) +- **Rationale**: Standard Kubernetes plural convention applied to `OGXServer`. + +### D-005: Status Field Rename (FR-004) + +- **Decision**: Rename `llamaStackServerVersion` to `serverVersion` +- **Rationale**: The field reports the version of the server binary. With the rename, "LlamaStack" branding is removed. The field name becomes generic and future-proof. +- **Impact**: Breaking change for status consumers. Print column JSONPath changes from `.status.version.llamaStackServerVersion` to `.status.version.serverVersion`. + +### D-006: Label and Annotation Values + +| Old Value | New Value | Notes | +|-----------|-----------|-------| +| `app: llama-stack` | `app: ogx` | Default label on workloads. New deployments use `app: ogx`. Old deployments are not re-parented (scaled to zero instead); see D-022. | +| `app.kubernetes.io/managed-by: llama-stack-operator` | `app.kubernetes.io/managed-by: ogx-operator` | Used in cache selector, managed ConfigMap labels, Ingress labels. | +| `app.kubernetes.io/part-of: llama-stack` | `app.kubernetes.io/part-of: ogx` | Kustomize-injected label on manifests. | +| `app.kubernetes.io/name: llama-stack-k8s-operator` | `app.kubernetes.io/name: ogx-k8s-operator` | Kustomize default label. | +| `llamastack.io/watch: "true"` | `ogx.io/watch: "true"` | Watch label key for ConfigMap cache filter. During transition, accept both. | + +> **Resolved in PR #289**: The `ogx.io/watch: "true"` label requirement is now part of the v1beta1 API (see D-037). All ConfigMap and Secret references in the CRD include docstrings stating the label is required. This is no longer deferred — it was implemented directly in the types. + +### D-007: Leader Election ID + +- **Decision**: `54e06e98.ogx.io` +- **Rationale**: Keep the hash prefix (arbitrary, unique), change the domain suffix to match the new API group. + +### D-008: Operator Namespace + +- **Decision**: `ogx-k8s-operator-system` +- **Rationale**: Mirrors the existing pattern `{project}-system`. + +### D-009: Operator Config ConfigMap Name + +- **Decision**: `ogx-operator-config` +- **Rationale**: Matches the new branding. Users manually configure the new ConfigMap; no automatic migration from the old one. + +### D-010: Go Module Path + +- **Decision**: `github.com/ogx-ai/ogx-k8s-operator` +- **Rationale**: Matches the new GitHub organization and repo name. All import paths update accordingly. + +### D-011: Container Image (Deferred per FR-006) + +- **Decision**: Container image registry (`quay.io/llamastack/llama-stack-k8s-operator`) MAY remain temporarily. +- **Rationale**: FR-006 explicitly allows deferral. The plan enumerates the deferred items so they can be tracked. +- **Deferred items**: `quay.io/llamastack/` registry prefix, `docker.io/llamastack/distribution-*` distribution images, GitHub Actions image references. + +### D-012: FieldOwner String + +- **Decision**: `ogx-operator` +- **Rationale**: Used in server-side apply as the field owner identity. + +## Upstream Runtime Contracts (Out of Scope) + +These are contracts with the upstream server container image. They are currently being updated upstream and may change. Preserving or renaming these is **out of scope for the initial PRs** — handle in a follow-up once upstream stabilizes. + +| Contract | Current Value | File(s) | +|----------|---------------|---------| +| Python module (core) | `llama_stack.core.server.server` | `controllers/resource_helper.go` | +| Python module (distribution) | `llama_stack.distribution.server.server` | `controllers/resource_helper.go` | +| Version check | `version('llama_stack')` | `controllers/resource_helper.go` | +| Config env var | `LLAMA_STACK_CONFIG` | `controllers/resource_helper.go` | +| Config mount path | `/etc/llama-stack/config.yaml` | `controllers/resource_helper.go` | +| Default storage mount | `/.ogx` (operator PVC mount), `/.llama` (upstream server default) | `api/v1beta1/ogxserver_types.go` | +| HuggingFace home | `HF_HOME` → `/.llama` | `controllers/resource_helper.go` | + +## Migration Approach + +### D-020: Adoption Strategy + +- **Decision**: Annotation-driven adoption within the reconciler. Users create new OGXServer CRs manually (migrating config from old LLSD CRs and ConfigMaps) and annotate them with `ogx.io/adopt-storage` and/or `ogx.io/adopt-networking` to adopt legacy PVCs and Ingresses. +- **Rationale**: A clean break from the old schema avoids the immutable Deployment selector problem that plagued the automatic adoption approach (see D-022). Users manually migrating config is acceptable for an alpha API, and this approach keeps all resource state reproducible from the CR spec plus annotations. +- **Alternatives considered**: + - Automatic adoption controller at startup (re-parent all children, create OGXServer from legacy CR) — rejected because Deployment `spec.selector.matchLabels` is immutable, causing persistent reconciliation failures and cascading label mismatches across Service, NetworkPolicy, and HPA. See D-022 for details. + - Standalone migration CLI tool — rejected because annotation-driven adoption within the operator is simpler and doesn't require a separate tool + - Conversion webhook — out of scope per spec assumptions (alpha API, one-way rename acceptable) + +### D-021: Legacy Resource Cleanup + +- **Decision**: Users delete old LLSD CRs using `kubectl delete llsd --cascade=orphan` to preserve child resources (Deployment, PVC, Service, etc.), or remove the old operator first so that no controller is running to trigger cascade deletion. +- **Rationale**: Normal CR deletion cascade-deletes all owned children, which would destroy the PVC data. The `--cascade=orphan` flag prevents this by removing the CR without deleting its children. Alternatively, if the old operator is already removed, there is no active controller to enforce cascade behavior. +- **Rollback consideration**: Using `--cascade=orphan` also preserves the old Deployment (scaled to zero by the adoption logic), enabling rollback if needed. + +### D-022: Immutable Deployment Selector Problem (why automatic adoption was rejected) + +- **Problem**: Deployment `spec.selector.matchLabels` is immutable after creation. The old Deployment uses `app: llama-stack` in its selector. If the new reconciler tries to patch this to `app: ogx`, the API server rejects it on every reconcile, causing persistent failures. +- **Cascading effects**: + - Service, NetworkPolicy, and HPA selectors also use `app: llama-stack`. If the reconciler updates those to `app: ogx` while pods still carry `app: llama-stack`, traffic and scaling break. + - The reconciler can no longer derive resource specs purely from the CR. It must inspect the existing Deployment's immutable selector at reconcile time to generate correct selectors for dependent resources. This complicates the current pipeline which renders all manifests from the CR spec in a single kustomize pass. + - Resources recreated during normal operations (toggling `exposeRoute` recreates Ingress, disabling and re-enabling NetworkPolicy recreates it, accidental deletion triggers recreation on next reconcile) would all use `app: ogx` from the spec, breaking the selector link to adopted Deployment pods. +- **Decision**: Do not re-parent the old Deployment at all. Instead, scale it to zero and create a new Deployment with clean `app: ogx` labels. This means a brief downtime window (the adopted PVC must transfer between pods) but avoids all the immutable selector problems. + +### D-023: Orphaned Stateless Resources Must Be Deleted Before Upgrade + +- **Decision**: The upgrade guide instructs users to delete orphaned stateless resources (Deployment, NetworkPolicy, ServiceAccount, RoleBinding, HPA, PDB) after `--cascade=orphan` and before creating the OGXServer CR. Only PVC and optionally Service + Ingress are kept for adoption. +- **Rationale**: After `--cascade=orphan`, Kubernetes removes ownerReferences from child resources but the resources themselves persist with old configuration. The operator's `patchResource` function (`kustomizer.go`) has a safety check that skips resources not owned by the current CR instance (`ref.UID == ownerInstance.GetUID()`). Since orphaned resources have no ownerRefs, the check fails and the operator cannot update or replace them. The orphaned resource blocks creation of a new one with the same name. +- **NetworkPolicy is the critical case**: The orphaned NetworkPolicy's `podSelector.matchLabels.app` still targets `llama-stack`, which does not match the new `app: ogx` pods. This leaves new pods with **no NetworkPolicy protection** and is a security concern. The same label mismatch affects the orphaned Service selector and Deployment selector/template, but those are also covered by the adoption annotations or by simply deleting them. +- **Alternative rejected**: Modifying `patchResource` to automatically claim resources with no ownerReferences was considered but rejected because it would weaken the ownership safety check, potentially allowing the operator to overwrite resources created by users or other controllers. +- **Fallback**: The adopt-storage logic retains the scale-to-zero code path for the old Deployment as a safety net, in case users skip the manual deletion step. + +### D-024: Annotation Persistence + +- **Decision**: The `ogx.io/adopt-storage` annotation must remain on the OGXServer CR as long as the adopted PVC is in use. The `ogx.io/adopt-networking` annotation can be removed once ownership is transferred (the reconciler continues managing the same-name resources normally). +- **Rationale**: The adopted PVC has a different name (`{old-llsd-name}-pvc`) than the default convention (`{new-ogx-name}-pvc`). The reconciler uses `GetEffectivePVCName()` to resolve the correct name. Without the annotation, the reconciler would try to reference/create a PVC with the default name, breaking the deployment. +- **Networking (same-name)**: When the OGXServer CR uses the same name as the old LLSD, the adopted Service and Ingress names match the reconciler's naming convention. After ownership transfer, the kustomize pipeline manages them normally. Removing the annotation is a no-op. +- **Networking (different-name)**: When the names differ, the annotation MUST persist as long as clients depend on the legacy endpoints. Removing it causes the operator to delete the adopted legacy resources. The kustomize-created resources under the new name continue to exist. + +### D-025: Adopt-Networking Includes Service (not just Ingress) + +- **Decision**: The `ogx.io/adopt-networking` annotation adopts both the legacy Service (`{value}-service`) and legacy Ingress (`{value}-ingress`). +- **Rationale**: Cluster-internal clients may reference the old Service by name or ClusterIP. Adopting only the Ingress would break internal traffic. The adopted Service's `spec.selector` is updated to match new pod labels so it routes traffic to the new pods while preserving its name and ClusterIP. +- **Same-name case**: When the OGXServer CR name matches the old LLSD name, the adopted resource names match what the kustomize pipeline would create. No duplicate resources are needed. After ownership transfer the reconciler manages them directly. +- **Different-name case**: When the OGXServer CR name differs from the old LLSD name, the adopted resources (`{old-name}-service`, `{old-name}-ingress`) have different names from what the kustomize pipeline creates (`{new-name}-service`, `{new-name}-ingress`). Both sets of resources coexist: adopted resources preserve existing client endpoints, and the kustomize-created resources provide canonical endpoints for the new CR. Removing the `ogx.io/adopt-networking` annotation causes the operator to delete the adopted legacy resources once they are no longer needed. +- **Origin**: This refinement was requested by @eoinfennessy in PR review feedback. + +### D-026: `spec.network` (not `spec.networking`) + +- **Decision**: The OGXServer CRD uses the JSON field **`network`** (`spec.network`) for port, TLS, externalAccess, and network policy. The Go type is named **`NetworkSpec`** (kubebuilder/json: `network`). +- **Rationale**: Shorter, conventional name; avoids conflating the spec block with the English word “networking” everywhere and with the transitional annotation `ogx.io/adopt-networking` (which remains unchanged). +- **Note**: Spec 002 and v1alpha2 drafts used `spec.networking`; when folding types into `OGXServer`, rename the field and update any CEL rules that referenced `self.networking` to **`self.network`**. + +### D-027: `spec.caBundle` promoted to top-level + +- **Decision**: CA bundle configuration is at `spec.caBundle` (not `spec.network.tls.caBundle`). +- **Rationale**: CABundle configures outbound trust (which CAs the server trusts when connecting to providers/backends), not inbound TLS termination. Nesting it under TLSSpec forced users to enter the `tls` block even when not enabling server TLS. Moving it to the spec root reflects that CA trust is a server-wide concern independent of network configuration. +- **Impact**: `CABundleConfig` has `configMapName` (required) and optional `configMapKeys`. No `configMapNamespace` — ConfigMaps must be in the same namespace as the OGXServer for multi-tenant deployments. + +### D-028: TLS presence semantics (no `enabled` bool) + +- **Decision**: `spec.network.tls` uses presence semantics. TLS is enabled when the `tls` field is present (with required `secretName`), disabled when omitted. No explicit `enabled` bool. +- **Rationale**: `spec.network.tls` only has one field (`secretName`), which is required. A TLS spec with explicit `enabled: false` plus a required secret ref is contradicting and useless state that should be unrepresentable. + +### D-029: `spec.network.policy` (not `spec.network.networkPolicy`) + +- **Decision**: The NetworkPolicy configuration field is `spec.network.policy` (not `spec.network.networkPolicy`). +- **Rationale**: Avoid redundant stutter — `network.networkPolicy` repeats "network". +- **Additional**: `policyTypes` field added following Kubernetes NetworkPolicy spec and semantics. Docstrings clearly describe the ingress-always/egress-opt-in behavior and default rule merging. + +### D-030: `spec.network.externalAccess` (not `spec.network.expose`) + +- **Decision**: External service exposure is configured via `spec.network.externalAccess` with an explicit `enabled` field (default false) and optional `hostname`. +- **Rationale**: The verb "expose" only made sense in the original polymorphic plan where it could be a boolean. The explicit `enabled` bool is clearer than presence semantics of adding an empty object, which was unusual and didn't make clear what the behavior of an empty object was. Named `externalAccess` for mechanism-neutrality (supports both Ingress and Route). + +### D-031: `spec.disabledAPIs` (not `spec.disabled`) + +- **Decision**: The disabled APIs field is `spec.disabledAPIs`. +- **Rationale**: Makes it explicit that the field controls which API types are excluded from config generation, rather than the ambiguous `disabled`. + +### D-032: No `ExternalProviders` + +- **Decision**: Remove `ExternalProviderRef`, `ExternalProvidersSpec`, and the `ExternalProviders` field from `OGXServerSpec`. +- **Rationale**: The design for external providers is not yet finalized, and adding these prematurely forces the API into a corner. + +### D-033: No `telemetry` provider + +- **Decision**: Remove `Telemetry` from `ProvidersSpec`. +- **Rationale**: The telemetry provider type doesn't exist in the upstream llama-stack. + +### D-034: Explicit `remote::`/`inline::` prefix on provider type + +- **Decision**: `ProviderConfig.Provider` must start with `remote::` or `inline::` (e.g., `remote::vllm`, `inline::builtin`). CEL validation enforces this. +- **Rationale**: Previously, `remote::` was added implicitly to all providers that did not specify a prefix (e.g., `vllm` became `remote::vllm`). This could lead to UX issues — "When should I add a prefix?", "Why doesn't the builtin responses provider work?", etc. Explicitly requiring it is a better UX and removes the need for custom normalization logic. + +### D-035: `secretRefs` replaces `apiKey` + +- **Decision**: `ProviderConfig.apiKey` (`*SecretKeyRef`) is replaced by `secretRefs` (`map[string]SecretKeyRef`). +- **Rationale**: A single `apiKey` field was too narrow. The `secretRefs` map supports multiple named secret references per provider (e.g., `host`, `password`, `api-key`). Each key becomes the env var field suffix. + +### D-036: Provider ID uniqueness validated by webhook (not per-slice CEL) + +- **Decision**: Remove the per-slice CEL rule ensuring ID is specified if more than one provider of the same API type is present. Provider ID uniqueness is validated globally by the validating webhook. +- **Rationale**: What needs to be ensured is that provider IDs (derived or explicit) are globally unique — all providers have unique IDs, regardless of API type. The webhook now does this. The per-slice CEL rule was redundant. + +### D-037: `ogx.io/watch: "true"` label required on all referenced ConfigMaps and Secrets + +- **Decision**: All user-provided ConfigMaps and Secrets referenced in the CRD must have the `ogx.io/watch: "true"` label. +- **Rationale**: This allows the operator to use a filtered informer cache that watches only labeled resources, avoiding the need to poll or use an uncached client. Documenting this requirement from the beginning avoids cache issues. + +### D-038: `DefaultMountPath` changed to `/.ogx` + +- **Decision**: `DefaultMountPath = "/.ogx"` (not `"/.llama"`). +- **Rationale**: Consistent with the OGX branding rename. The upstream `/.llama` path may still be used internally by the server image, but the operator's default PVC mount path reflects the new naming. + +## Codebase Rename Inventory + +### Files to Rename + +| Old Path | New Path | +|----------|----------| +| `api/v1alpha1/llamastackdistribution_types.go` | `api/v1beta1/ogxserver_types.go` (new file with expanded API from spec 002); remove `api/v1alpha1/` after cutover | +| `api/v1alpha2/` (entire directory) | DELETE (folded into `ogx.io/v1beta1`) | +| `controllers/llamastackdistribution_controller.go` | `controllers/ogxserver_controller.go` | +| `controllers/llamastackdistribution_controller_test.go` | `controllers/ogxserver_controller_test.go` | +| `controllers/llamastackdistribution_controller_ca_whitespace_test.go` | `controllers/ogxserver_controller_ca_whitespace_test.go` | +| `config/crd/bases/llamastack.io_llamastackdistributions.yaml` | `config/crd/bases/ogx.io_ogxservers.yaml` (generated) | +| `config/crd/patches/cainjection_in_llamastackdistributions.yaml` | `config/crd/patches/cainjection_in_ogxservers.yaml` | +| `config/samples/_v1alpha1_llamastackdistribution.yaml` | `config/samples/_v1beta1_ogxserver.yaml` | + +### Go Identifier Renames (representative, not exhaustive) + +| Old Identifier | New Identifier | +|----------------|----------------| +| `LlamaStackDistribution` | `OGXServer` | +| `LlamaStackDistributionList` | `OGXServerList` | +| `LlamaStackDistributionSpec` | `OGXServerSpec` | +| `LlamaStackDistributionStatus` | `OGXServerStatus` | +| `LlamaStackDistributionReconciler` | `OGXServerReconciler` | +| `NewLlamaStackDistributionReconciler` | `NewOGXServerReconciler` | +| `LlamaStackDistributionPhasePending` | `OGXServerPhasePending` | +| `LlamaStackDistributionPhaseReady` | `OGXServerPhaseReady` | +| `LlamaStackDistributionPhaseFailed` | `OGXServerPhaseFailed` | +| `LlamaStackDistributionPhaseInitializing` | `OGXServerPhaseInitializing` | +| `LlamaStackDistributionPhaseTerminating` | `OGXServerPhaseTerminating` | +| `DefaultLabelValue` ("llama-stack") | `DefaultLabelValue` ("ogx") | +| `DefaultContainerName` ("llama-stack") | `DefaultContainerName` ("ogx") | +| `LlamaStackServerVersion` (field) | `ServerVersion` (field) | +| `llamaStackUpdatePredicate` | `ogxServerUpdatePredicate` | +| `llamaxk8siov1alpha1` (import alias) | `ogxiov1beta1` | + +### Constant Value Renames + +| Constant/String | Old Value | New Value | +|-----------------|-----------|-----------| +| `operatorConfigData` | `llama-stack-operator-config` | `ogx-operator-config` | +| `WatchLabelKey` | `llamastack.io/watch` | `ogx.io/watch` | +| `LeaderElectionID` | `54e06e98.llamastack.io` | `54e06e98.ogx.io` | +| `FieldOwner` | `llama-stack-operator` | `ogx-operator` | +| Default NS fallback | `llama-stack-k8s-operator-system` | `ogx-k8s-operator-system` | +| Managed-by label | `llama-stack-operator` | `ogx-operator` | +| Part-of label | `llama-stack` | `ogx` | +| App label | `llama-stack` | `ogx` | +| Kustomize namePrefix | `llama-stack-k8s-operator-` | `ogx-k8s-operator-` | +| Kustomize namespace | `llama-stack-k8s-operator-system` | `ogx-k8s-operator-system` | + +### Files Requiring Content Changes (by category) + +**Go source (33 files)**: All files under `api/`, `controllers/`, `pkg/`, `tests/e2e/`, `main.go` that import the module path or reference renamed types/constants. + +**YAML manifests (20+ files)**: `config/default/`, `config/crd/`, `config/rbac/`, `config/manager/`, `config/samples/`, `controllers/manifests/base/`, `release/operator.yaml`. + +**Build/CI (5 files)**: `Makefile`, `go.mod`, `.github/workflows/build-image.yml`, `.github/workflows/release-image.yml`, `.github/workflows/run-e2e-test.yml`. + +**Documentation (5+ files)**: `README.md`, `CONTRIBUTING.md`, `docs/create-operator.md`, `docs/additional/ca-bundle-configuration.md`, `docs/api-overview.md`. + +**Specs (carry-forward)**: `specs/constitution.md`, `specs/001-*/`, `specs/002-*/` — update references in existing spec documents. diff --git a/specs/003-ogx-rename/spec.md b/specs/003-ogx-rename/spec.md new file mode 100644 index 000000000..77f105301 --- /dev/null +++ b/specs/003-ogx-rename/spec.md @@ -0,0 +1,163 @@ +# Feature Specification: Rename to OGX (Open GenAI Stack) Operator + +**Feature Branch**: `003-ogx-rename` +**Created**: 2026-04-16 +**Updated**: 2026-04-29 +**Status**: Ready +**Input**: Replace `LlamaStackDistribution` (`llamastack.io/v1alpha1`) with `OGXServer` (`ogx.io/v1beta1`). This is a breaking change — no conversion webhooks, no coexistence period. The new CRD incorporates both the rename and the expanded API surface from spec 002 (providers, resources, state storage, **`spec.network`** (port, TLS, externalAccess, **`policy`** with native K8s ingress/egress types and policyTypes), **`spec.caBundle`** (top-level), workload, overrideConfig). The OGX controller handles the new CR. Provide annotation-driven migration for existing workloads to adopt legacy PVCs and networking. + +**Scope note**: Spec 002 (operator-generated config) API types are folded into this CRD. The `api/v1alpha2/` package is deleted; there is no separate v1alpha2 (those types live under `ogx.io/v1beta1`). Config generation logic itself (provider expansion, resource registration, secret resolution) is deferred to a follow-up PR. + +## User Scenarios & Testing + +### User Story 1 - Upgrade from legacy operator with annotation-driven migration (Priority: P1) + +An administrator is running the legacy operator with one or more existing workloads that have cached model data on persistent volumes. They want to switch to the renamed operator while preserving their PVC data and optionally maintaining their existing network endpoints. The administrator removes the old operator, creates a new OGXServer custom resource (manually migrating configuration from their old LLSD CR and ConfigMaps), and annotates it to adopt the legacy PVC. The new operator binds the new workload to the adopted PVC. There is a brief downtime window while the old pod terminates and the new pod mounts the PVC. + +**Why this priority**: Every existing user with cached model data must have a path to preserve that data during the rename. Losing cached models (which may take hours to re-download) is unacceptable. The annotation-driven approach keeps state reproducible from the CR spec and avoids the complexity of automatic adoption with immutable Deployment selectors. + +**Independent Test**: Install the legacy operator, create a workload with a populated persistent volume, remove the legacy operator (preserving the legacy CRD and CRs, or deleting the old CR with `--cascade=orphan` to preserve child resources), install the new operator, create an OGXServer CR with the `ogx.io/adopt-storage` annotation pointing to the old LLSD name, and verify (a) the persistent volume is preserved with its data intact and mounted by the new pod, and (b) the new workload comes up successfully. + +**Acceptance Scenarios**: + +1. **Given** the legacy operator has been removed and the old deployment is orphaned, **When** the administrator creates an OGXServer CR with `ogx.io/adopt-storage: "my-old-llsd"`, **Then** the new operator scales down or deletes the old deployment, adopts the legacy PVC by transferring its owner reference, and creates a new deployment that mounts the adopted PVC. +2. **Given** an OGXServer CR with the `ogx.io/adopt-storage` annotation, **When** the new pod starts, **Then** it has access to all previously cached model data on the adopted PVC. +3. **Given** an OGXServer CR with the same name as the old LLSD and the `ogx.io/adopt-networking: "my-old-llsd"` annotation, **When** the new operator reconciles, **Then** it adopts the old Service (updating its selectors to new pod labels) and old Ingress, transferring ownership to the new CR. Since the CR name is the same, the Service and Ingress names are unchanged and no client updates are needed. +4. **Given** an OGXServer CR with a **different** name from the old LLSD and the `ogx.io/adopt-networking: "my-old-llsd"` annotation, **When** the new operator reconciles, **Then** it adopts the legacy Service and Ingress (preserving existing endpoints) AND creates new Service and Ingress resources under the new CR name. Both sets coexist and route traffic to the new pods. +5. **Given** an adopted PVC, **When** the operator is restarted, **Then** reconciliation is idempotent and the adopted PVC remains bound to the new deployment without disruption. +6. **Given** the old deployment is still running with the PVC bound, **When** the adoption annotation is set, **Then** the operator scales the old deployment to zero to release the PVC before the new pod can mount it, resulting in a brief downtime window. + +--- + +### User Story 2 - Clean install with no legacy state (Priority: P2) + +A new user adopts the operator for the first time and has no legacy resources anywhere in their cluster. They want a clean install that does not carry migration overhead. + +**Why this priority**: New users should not pay a cost for a migration they do not need. The operator must start up quickly and not create spurious warnings or perform unnecessary cluster-wide list operations. + +**Independent Test**: On a cluster with no legacy custom resource definition and no legacy custom resources, install the new operator and verify startup completes cleanly with no migration activity logged and no errors related to missing legacy resources. + +**Acceptance Scenarios**: + +1. **Given** a cluster with no legacy custom resource definition, **When** the new operator starts, **Then** the startup log confirms no legacy resources were found and no errors are produced. +2. **Given** a fresh install, **When** the administrator creates a new-kind custom resource using the sample manifest, **Then** the workload comes up successfully with no reference to the legacy naming in operator-controlled artifacts (labels, annotations, CRD, RBAC, manifests). Upstream runtime contracts preserved per FR-002 (e.g., `LLAMA_STACK_CONFIG`, `/etc/llama-stack/`) are excluded from this check. + +--- + +### User Story 3 - API consumers and tooling continue to function (Priority: P2) + +Downstream consumers (dashboards, CI pipelines, custom scripts) that read from the custom resource's status field need to continue working after the migration. + +**Why this priority**: External tooling is a real part of the operator's ecosystem. Breaking status-field consumers without warning erodes trust. A documented field rename with a clear deprecation path is acceptable; silent breakage is not. + +**Independent Test**: Point an external tool that reads the server version status field at a new-kind custom resource after migration, and verify the tool can locate the renamed field using the documented migration guide. + +**Acceptance Scenarios**: + +1. **Given** a tool was reading the legacy status field path, **When** the migration documentation is consulted, **Then** the new field path is clearly stated and the tool can be updated. +2. **Given** a user is running read-only queries like listing resources by the legacy short name, **When** they are told to switch to the new short name, **Then** the documentation provides a one-to-one mapping for every naming change. + +--- + +### User Story 4 - Administrator can audit and defer cleanup (Priority: P3) + +A cautious administrator wants visibility into what the migration did and the ability to verify before committing to final cleanup of legacy resources. + +**Why this priority**: Migrations that are opaque or irreversible feel risky. Giving administrators clear audit trails and a deferred cleanup step increases confidence in the process. + +**Independent Test**: After an automated migration, inspect the cluster and verify every adopted resource carries an annotation pointing back to its legacy origin, and verify the legacy resources still exist (not auto-deleted) so the administrator can manually decide when to remove them. + +**Acceptance Scenarios**: + +1. **Given** adoption has completed, **When** the administrator lists new-kind resources, **Then** every migrated resource carries a machine-readable annotation identifying its legacy origin and the migration timestamp. +2. **Given** adoption has completed, **When** the administrator lists legacy resources, **Then** legacy resources still exist and are not auto-deleted. +3. **Given** an adopted workload, **When** the administrator chooses to remove the legacy resource, **Then** removing the legacy resource does not affect the running workload or its persistent volume. + +--- + +### Edge Cases + +- What happens when the legacy PVC referenced by the `ogx.io/adopt-storage` annotation does not exist? The operator logs a warning, skips adoption, and creates a new PVC normally. No error is raised to avoid blocking reconciliation. +- What happens when the old deployment is still running and holding the RWO PVC? The upgrade guide instructs users to delete orphaned stateless resources (including the old Deployment) before creating the OGXServer CR. If the user skips that step, the operator falls back to scaling the old Deployment to zero to release the PVC, then requeues to wait for pod termination before the new pod can mount it. +- What happens if the operator crashes mid-adoption (after deleting the old deployment but before the new pod starts)? Adoption is idempotent: on restart the operator detects the PVC is not yet adopted (ownerReference check), finds the old deployment already gone, and proceeds to transfer ownership and create the new deployment. +- What happens when the `ogx.io/adopt-storage` annotation is removed while the adopted PVC is in use? The PVC remains in use by the current deployment. The annotation must persist as long as the adopted PVC is bound, because the PVC name differs from the default naming convention. A status condition warns if the annotation is missing but the deployment references an adopted PVC name. +- What happens when the `ogx.io/adopt-networking` annotation is removed? When the OGXServer CR name matches the old LLSD name, the adopted Service and Ingress have the same names as what the reconciler would create, so the reconciler continues managing them normally — the annotation removal is a no-op. When the names differ, removing the annotation causes the operator to delete the adopted legacy resources (since they are no longer referenced) while retaining the new resources created under the current CR name. Administrators should ensure clients have migrated to the new endpoints before removing the annotation. +- What happens to orphaned stateless resources (NetworkPolicy, Deployment, ServiceAccount, RoleBinding, HPA, PDB) after `--cascade=orphan`? These resources lose their ownerReferences but retain the old `app: llama-stack` labels and configuration. The new operator's `patchResource` safety check skips resources it does not own, so it cannot update or replace them. Users must delete these orphaned resources before creating the OGXServer CR so the new operator can create fresh versions with correct `app: ogx` labels. This is especially critical for NetworkPolicy: the old policy's `podSelector` targets `app: llama-stack`, which no longer matches the new pods, leaving them unprotected. +- What happens when the old LLSD CR is deleted without `--cascade=orphan`? If the old CRD still exists and the CR is deleted normally, Kubernetes cascade-deletes all child resources (Deployment, PVC, Service, etc.), destroying the PVC data. The migration guide must instruct users to either delete with `--cascade=orphan` or remove the old operator first (which stops the cascade controller). +- What happens when both operators are running simultaneously? The new operator does not attempt to adopt resources owned by another controller. The `patchResource` ownership check (UID match) prevents the new operator from stealing resources still managed by the old operator. +- What happens on a cross-namespace adoption attempt? Not supported. The annotation value must reference an LLSD in the same namespace. Kubernetes ownerReferences are namespace-scoped, and the reconciler only looks up resources in the CR's own namespace. +- What happens when the adopted PVC has a different storage size than what the new CR specifies? The PVC spec is immutable after creation. The operator skips PVC patching (existing behavior) and the adopted PVC retains its original storage size. The new CR's storage spec is informational only for adopted PVCs. +- What happens when the user wants to roll back to the old operator after adoption? The user can remove the `ogx.io/adopt-storage` annotation, scale up the old deployment (if it was scaled to zero rather than deleted), and recreate the old LLSD CR. The PVC ownerReference would need to be manually restored or the old operator would create a new binding on reconcile. + +## Requirements + +### Functional Requirements + +#### Renaming and identity + +- **FR-001**: The operator's identity (name, repository, module path, container image, labels, API group, resource kind, and short name) MUST be migrated from all forms of "llama", "LlamaStack", and "llama-stack" to the corresponding "ogx" (Open GenAI Stack) naming, with the sole exceptions listed in FR-002. +- **FR-002**: Upstream runtime contracts (server startup module paths, `LLAMA_STACK_CONFIG`, `/etc/llama-stack/config.yaml`, `/.llama`, etc.) are currently being updated by the upstream project. Preserving or renaming these is **out of scope** for the initial PRs. Handle in a follow-up once upstream stabilizes. +- **FR-003**: The custom resource kind MUST be renamed to a name that reflects that the resource encompasses the full server deployment (not just a distribution selection). +- **FR-004**: The custom resource status field that reports the running server version MUST be renamed to match the new naming, and the rename MUST be documented as a breaking change for status consumers. +- **FR-005**: The operator's labels, annotations, leader-election identifier, operator-namespace name, and sample custom resources MUST all be updated to the new naming. +- **FR-006**: Container image references, registry locations, and Git/documentation URLs that still reference the legacy organization MAY remain temporarily, but the rename plan MUST enumerate them so they can be migrated on a later schedule. + +#### Annotation-driven migration + +- **FR-007**: The operator MUST validate that the values of the `ogx.io/adopt-storage` and `ogx.io/adopt-networking` annotations, when present, are non-empty and conform to valid Kubernetes resource names (RFC 1123 DNS label: lowercase alphanumeric or `-`, at most 63 characters, starting and ending with an alphanumeric character). If validation fails, the operator MUST set a status condition (e.g., `AdoptionConfigInvalid`) with a descriptive message and MUST skip all adoption steps for the invalid annotation. Normal reconciliation (creating fresh resources using default naming) MUST proceed so that a typo in an adoption annotation does not block the workload from deploying. + +- **FR-010**: The new operator MUST support an `ogx.io/adopt-storage` annotation on the OGXServer custom resource whose value is the name of a legacy LlamaStackDistribution resource in the same namespace. When present, the operator MUST adopt the legacy PVC (`{value}-pvc`) instead of creating a new one. +- **FR-011**: When the `ogx.io/adopt-storage` annotation is present, the operator MUST replace the adopted PVC's controller owner reference: first removing the existing owner reference (which points to the legacy custom resource, and may be stale if the legacy CR was deleted), then setting a new controller owner reference pointing to the new OGXServer custom resource via `ctrl.SetControllerReference`. This ensures the PVC's lifecycle is tied to the new resource and avoids the `SetControllerReference` error that occurs when a resource already has a controller owner reference. +- **FR-012**: When the `ogx.io/adopt-storage` annotation is present and the old deployment (`{value}`) still exists and is running (i.e., the user skipped the orphan cleanup step), the operator MUST scale the old deployment to zero to release the ReadWriteOnce PVC before the new pod attempts to mount it. A brief downtime window while the PVC transfers between pods is acceptable and expected. +- **FR-013**: The operator MUST suppress creation of a new PVC from the kustomize manifest pipeline when the `ogx.io/adopt-storage` annotation is present, to avoid creating a duplicate empty PVC. +- **FR-014**: The adoption logic MUST be idempotent: if the operator restarts or re-reconciles, it MUST detect that the PVC is already adopted (via owner reference check) and not re-run destructive steps such as scaling down the old deployment. +- **FR-015**: The operator MUST support an `ogx.io/adopt-networking` annotation on the OGXServer custom resource whose value is the name of a legacy LlamaStackDistribution resource. When present, the operator MUST adopt the legacy Service (`{value}-service`) and Ingress (`{value}-ingress`), transfer their owner references to the new CR, and update the adopted Service's selectors to match the new pod labels. When the OGXServer CR name matches the old LLSD name, the adopted resource names match the reconciler's expected naming convention and no duplicate resources are created. When the names differ (e.g., OGXServer is named `my-server` but adopts networking from legacy LLSD `my-old-llsd`), the operator MUST adopt the legacy resources (`my-old-llsd-service`, `my-old-llsd-ingress`) AND create the new resources (`my-server-service`, `my-server-ingress`) from the kustomize pipeline. Both sets of resources coexist: the adopted resources preserve existing client endpoints, while the new resources provide the canonical endpoints for the new CR. The adopted resources remain owned by the new CR and are cleaned up when the administrator removes the `ogx.io/adopt-networking` annotation. +- **FR-016**: Adopted PVC, adopted Service, and adopted Ingress MUST all be reproducible from the annotation value plus the OGXServer CR spec. If an adopted resource is accidentally deleted, the operator MUST be able to recreate or re-adopt it using the same annotation. +- **FR-017**: The operator MUST report adoption status via conditions on the OGXServer custom resource (e.g., `StorageAdopted`, `NetworkingAdopted`) and emit Kubernetes events for audit purposes. +- **FR-017a**: After ownership transfer, the operator MUST annotate each adopted child resource (PVC, Service, Ingress) with `ogx.io/adopted-from: ` and `ogx.io/adopted-at: ` so that administrators can audit which resources were migrated and when, without inspecting operator logs. +- **FR-018**: The operator MUST NOT auto-delete legacy custom resources or the legacy custom resource definition. Administrators MUST retain manual control over cleanup of legacy resources. +- **FR-019**: The migration guide MUST instruct administrators to delete old LLSD custom resources using `--cascade=orphan` to preserve child resources, and then to delete orphaned stateless resources (Deployment, NetworkPolicy, ServiceAccount, RoleBinding, HPA, PDB) so the new operator can create fresh versions with correct labels. The guide MUST explain that orphaned NetworkPolicies with old `podSelector` labels leave new pods unprotected. +- **FR-020**: Users MUST manually create OGXServer custom resources and migrate configuration from their old LLSD CRs and ConfigMaps. The operator does not auto-create OGXServer resources from legacy CRs. + +#### Documentation + +- **FR-040**: A migration guide MUST be written that includes a complete old-to-new name mapping and lists every breaking change (including the status field rename) with specific instructions for external tool owners. +- **FR-041**: The migration guide MUST describe how administrators verify adoption succeeded, how they inspect the audit annotations, and how they perform the final cleanup of legacy resources and the legacy custom resource definition. +- **FR-042**: Sample custom resources, getting-started documentation, and the generated API reference MUST all reflect the new naming. + +#### Deprecation + +- **FR-050**: The adoption annotation support (`ogx.io/adopt-storage`, `ogx.io/adopt-networking`) and any associated RBAC permissions for managing legacy resources MUST be clearly marked as transitional and MUST have a documented removal target (a specific future release). + +### Key Entities + +- **Operator**: The controller running in the cluster. After migration, the operator is identified by the new naming throughout (namespace, image labels, leader-election identifier, managed-by label). +- **Custom Resource (new kind)**: The renamed custom resource that represents a running instance of the server, including server configuration, **`spec.network`** (externalAccess, TLS with presence semantics, **`policy`** with per-CR enable/disable, `policyTypes`, and native K8s `NetworkPolicyIngressRule`/`NetworkPolicyEgressRule` types), **`spec.caBundle`** (top-level, independent of TLS), storage, autoscaling, and workload settings. Always created manually by administrators, both for fresh installs and upgrades. The legacy ConfigMap-based `enableNetworkPolicy` feature flag is replaced by `spec.network.policy.enabled` on each CR. +- **Custom Resource (legacy kind)**: The old-kind custom resource from the legacy operator. After the legacy operator is removed, these resources may still exist on the cluster. Administrators clean them up manually (using `--cascade=orphan` to preserve child resources). +- **Adoption annotations**: Annotations on the OGXServer CR (`ogx.io/adopt-storage`, `ogx.io/adopt-networking`) that instruct the operator to adopt legacy child resources (PVC, Ingress) by name. These annotations persist on the CR and make the adopted state reproducible. +- **Persistent volume claim**: The most important piece of stateful data in the system (holds cached models and user files). Preservation of persistent volume claims and their contents is the primary migration requirement. Adopted via the `ogx.io/adopt-storage` annotation. +- **Adopted networking resources**: The legacy Service (preserving ClusterIP for cluster-internal clients) and legacy Ingress (preserving external endpoint), both adopted via the `ogx.io/adopt-networking` annotation. The adopted Service's selectors are updated to route to the new pods. When the OGXServer CR name matches the old LLSD name, these resources have the same names as what the reconciler expects and are managed normally after ownership transfer. When the names differ, the adopted resources coexist alongside newly created resources for the new CR name. + +## Assumptions + +- The administrator removes the legacy operator before installing the new operator. This can be done by deleting the operator manifests directly, or via a meta-operator setting (e.g., setting the component to "Removed"). The legacy CRD and CRs may or may not remain on the cluster depending on the uninstall method. +- If the administrator wants to preserve legacy child resources (PVC, Service, Ingress) while deleting the old LLSD CR, they use `kubectl delete llsd --cascade=orphan`. After that, they delete orphaned stateless resources (Deployment, NetworkPolicy, ServiceAccount, RoleBinding, HPA, PDB) so the new operator can create fresh versions. Only PVC and optionally Service + Ingress are kept for adoption. +- This is a breaking change with no conversion webhook. Users manually create new OGXServer CRs and migrate configuration from their old LLSD CRs and ConfigMaps. The new CRD incorporates the expanded API surface from spec 002. +- A brief downtime window (typically 30-60 seconds) is acceptable during PVC adoption, as the old pod must terminate before the new pod can mount a ReadWriteOnce PVC. +- Container image registry names and Git organization URLs can remain on the legacy names temporarily; their migration will be scheduled separately and does not block this rename. +- Upstream server image contracts (module paths, environment variable names, filesystem paths) are being updated upstream. Handling these is out of scope for initial PRs. +- Administrators are responsible for cleanup of legacy resources (old CRs, old CRD) after migration is complete. + +## Success Criteria + +### Measurable Outcomes + +- **SC-001**: Persistent volume contents (cached model files, user configuration files) survive migration without loss or corruption. The verification bar is a byte-for-byte check of representative files before and after migration on at least one end-to-end test scenario. +- **SC-002**: On a cluster with no legacy state, the new operator completes startup within the same time bounds as the legacy operator did, confirming that no migration-related overhead is introduced for clean installs. +- **SC-003**: The published migration guide allows an administrator unfamiliar with the internal migration mechanics to successfully migrate a test workload using only the documentation, measured by a documentation walkthrough on a test cluster. +- **SC-004**: Status-field consumers (dashboards, external scripts) can update their queries using only the old-to-new field mapping table in the migration guide, with no additional investigation needed. +- **SC-005**: The rename leaves no residual legacy naming in any operator-owned artifact (custom resource definition, role-based access, deployment, labels, annotations, leader-election identifier, sample manifests, documentation) except the explicitly-listed exceptions (upstream runtime contracts) and the deferred items (container registry, Git organization URLs). +- **SC-006**: A user running a read-only query for the new resource kind (by the new plural or short name) gets the expected results, proving the naming migration is complete from an end-user command-line perspective. +- **SC-007**: An OGXServer CR with `ogx.io/adopt-storage` annotation successfully mounts the legacy PVC and the new pod starts serving with cached model data available. +- **SC-008**: The adopted PVC and Ingress state is reproducible from the OGXServer CR spec plus annotations. If either resource is accidentally deleted, the operator can recreate or re-adopt it on the next reconcile. +- **SC-009**: The migration downtime window for PVC adoption (old pod termination to new pod ready) is under 90 seconds for a typical deployment with default graceful termination settings. diff --git a/specs/003-ogx-rename/tasks.md b/specs/003-ogx-rename/tasks.md new file mode 100644 index 000000000..a97cd48d9 --- /dev/null +++ b/specs/003-ogx-rename/tasks.md @@ -0,0 +1,444 @@ +# Tasks: OGX (Open GenAI Stack) Operator + +**Branch**: `003-ogx-rename` +**Date**: 2026-04-29 +**Plan**: `specs/003-ogx-rename/plan.md` +**Spec**: `specs/003-ogx-rename/spec.md` + +## Overview + +This is a **breaking change**. The old `LlamaStackDistribution` CRD (`llamastack.io/v1alpha1`) is replaced by a new `OGXServer` CRD (`ogx.io/v1beta1`) that incorporates both the rename and the expanded API surface from spec 002 (providers with typed slices and explicit `remote::`/`inline::` prefix, resources with typed `ModelConfig`, state storage, **`spec.network`** (port, TLS with presence semantics, externalAccess with explicit enabled field, **`policy`** with native K8s ingress/egress types, policyTypes, replacing `AllowedFromSpec` and ConfigMap feature flag), **`spec.caBundle`** (top-level, independent of TLS), `disabledAPIs`, workload, overrideConfig). No conversion webhooks. The OGX controller handles the new CR and will later handle config generation. **`v1beta1`** is required for downstream consumers that only integrate non-alpha API versions. + +**NOTE**: Upstream runtime contracts (`llama_stack.core.server.server`, `LLAMA_STACK_CONFIG`, `/etc/llama-stack/config.yaml`, `/.llama`, etc.) are currently being updated upstream and may change. Preserving or renaming these is out of scope for the initial PRs — handle in a follow-up once upstream stabilizes. + +### PR Strategy + +| PR | Scope | Phase | +|----|-------|-------| +| **PR 1** | API types + generated artifacts | Phase 1 | +| **PR 2** | Controller with basic reconciliation (distribution, configmap, storage, network) | Phase 2–4 | +| **PR 3** | Config generation logic (provider expansion, resource registration, storage config, secret resolution) | Future — spec 002 | + +--- + +## Phase 1: API Changes (PR 1) + +**Goal**: Introduce the new `OGXServer` CRD under `ogx.io/v1beta1` with the full expanded spec (distribution, providers with typed slices and explicit prefix requirement, resources with typed `ModelConfig`, state storage, **`network`** (port, TLS with presence semantics, externalAccess with explicit enabled, **`policy`** with native K8s types and policyTypes), **`caBundle`** (top-level), `disabledAPIs`, workload, overrideConfig). Generate CRD YAML and deepcopy. Add validating webhook for constraints CEL cannot express. No controller changes yet. + +### Go module and API group + +- [ ] T001 Update `go.mod` module path from `github.com/llamastack/llama-stack-k8s-operator` to `github.com/ogx-ai/ogx-k8s-operator` +- [ ] T002 Create `api/v1beta1/groupversion_info.go` for the new API group: `+groupName=ogx.io`, `Group: "ogx.io"`, `Version: "v1beta1"` + +### OGXServer types + +- [ ] T003 Create `api/v1beta1/ogxserver_types.go` with the new CRD types incorporating the expanded API surface: + - Constants: `DefaultContainerName = "ogx"`, `DefaultLabelValue = "ogx"`, `DefaultServerPort = 8321`, `DefaultMountPath = "/.ogx"`, `OGXServerKind = "OGXServer"` + - `DistributionSpec` — exactly one of `name` or `image` (CEL: mutual exclusivity) + - `SecretKeyRef` — `name`, `key` (both required, MinLength=1). Docstring states Secret must have `ogx.io/watch: "true"` label. + - `ProviderConfig` — `id`, `provider` (required, CEL: must start with `remote::` or `inline::`), `endpoint`, `secretRefs` (`map[string]SecretKeyRef`, replaces `apiKey`), `settings` (`*apiextensionsv1.JSON`) + - `ProvidersSpec` — typed `[]ProviderConfig` slices: `inference`, `safety`, `vectorIo`, `toolRuntime`. No `telemetry` provider (it doesn't exist). + - `ModelConfig` — `name` (required), `provider`, `contextLength` (`*int`), `modelType`, `quantization` + - `ResourcesSpec` — `models` (`[]ModelConfig`), `tools`, `shields` (`[]string`) + - `KVStorageSpec` — `type` (sqlite/redis, default sqlite), `endpoint`, `password` (`*SecretKeyRef`). CEL: endpoint required for redis, endpoint/password only valid for redis. + - `SQLStorageSpec` — `type` (sqlite/postgres, default sqlite), `connectionString` (`*SecretKeyRef`). CEL: connectionString required for postgres, only valid for postgres. + - `StateStorageSpec` — `kv`, `sql` + - `CABundleConfig` — `configMapName` (required), `configMapKeys` (optional). **Top-level on OGXServerSpec** (not nested under TLS). Docstring states ConfigMap must have `ogx.io/watch: "true"` label. No `configMapNamespace` — same namespace required. + - `TLSSpec` — `secretName` (required). **Presence semantics**: TLS enabled when the `tls` field is present, disabled when omitted. No `enabled` bool. No `caBundle` (moved to top-level). Docstring states Secret must have `ogx.io/watch: "true"` label. + - `NetworkPolicySpec` — `enabled` (`*bool`, default true), `policyTypes` (`[]networkingv1.PolicyType`, Enum: Ingress/Egress, follows K8s semantics), `ingress` (`[]networkingv1.NetworkPolicyIngressRule`), `egress` (`[]networkingv1.NetworkPolicyEgressRule`) — native K8s types, replaces legacy `AllowedFromSpec` and ConfigMap feature flag + - `ExternalAccessConfig` — `enabled` (bool, default false), `hostname` (optional). Replaces polymorphic `expose`. CEL: hostname must not be empty if specified. + - `NetworkSpec` (JSON field **`network`**) — `port`, `tls` (`*TLSSpec`), `externalAccess` (`*ExternalAccessConfig`), `policy` (`*NetworkPolicySpec`). Note: field is `policy` not `networkPolicy` to avoid stutter. + - `PVCStorageSpec` — `size` (`*resource.Quantity`), `mountPath` (default `/.ogx`). CEL: size must be positive. + - `PodDisruptionBudgetSpec` — `minAvailable`, `maxUnavailable` (CEL: at least one required, mutually exclusive) + - `AutoscalingSpec` — `minReplicas`, `maxReplicas` (required), `targetCPUUtilizationPercentage`, `targetMemoryUtilizationPercentage`. CEL: maxReplicas >= minReplicas. + - `WorkloadOverrides` — `serviceAccountName`, `env`, `command`, `args`, `volumes`, `volumeMounts`. CEL: serviceAccountName must not be empty if specified. + - `WorkloadSpec` — `replicas` (default 1), `workers`, `resources`, `autoscaling`, `storage`, `podDisruptionBudget`, `topologySpreadConstraints`, `overrides` + - `OverrideConfigSpec` — `configMapName` (required). Docstring states ConfigMap must have `ogx.io/watch: "true"` label. + - `OGXServerSpec` — `distribution`, `providers`, `resources`, `storage`, `disabledAPIs` (renamed from `disabled`, Enum: agents/inference/tool_runtime/vector_io), **`network`**, **`caBundle`** (top-level), `workload`, `overrideConfig` (CEL: `providers`/`resources`/`storage`/`disabledAPIs` mutually exclusive with `overrideConfig`; cross-field disabled+providers conflict validation) + - `OGXServerPhase` — `Pending`, `Initializing`, `Ready`, `Failed`, `Terminating` + - Status types: `ProviderHealthStatus`, `ProviderInfo`, `DistributionConfig`, `VersionInfo` (with `ServerVersion` not `LlamaStackServerVersion`), `ResolvedDistributionStatus` (`Image`, `ConfigSource`, `ConfigHash`), `ConfigGenerationStatus` (`ObservedGeneration`, `ConfigMapName`, `GeneratedAt`, `ProviderCount`, `ResourceCount`, `ConfigVersion`), `OGXServerStatus` (with `ExternalURL` replacing `RouteURL`, pointer `*ResolvedDistributionStatus`, pointer `*ConfigGenerationStatus`) + - Root: `OGXServer`, `OGXServerList` with kubebuilder markers (`shortName=ogxserver`, printer columns including Distribution/Config/Providers at priority=1, subresource:status) + - `init()` registering types with SchemeBuilder +- [ ] T003a Add validating admission webhook for OGXServer that enforces constraints CEL markers cannot express: distribution name validation against embedded registry, cross-slice provider ID uniqueness (global, not per-slice), and model provider reference validation +- [ ] T004 Add adoption annotation constants and helpers to `api/v1beta1/ogxserver_types.go`: + - `AdoptStorageAnnotation = "ogx.io/adopt-storage"` + - `AdoptNetworkingAnnotation = "ogx.io/adopt-networking"` + - `AdoptedFromAnnotation = "ogx.io/adopted-from"` + - `AdoptedAtAnnotation = "ogx.io/adopted-at"` + - `func (r *OGXServer) GetAdoptStorageSource() string` + - `func (r *OGXServer) GetAdoptNetworkingSource() string` + - `func (r *OGXServer) GetEffectivePVCName() string` + - `func ValidateAdoptionAnnotation(value string) error` — validate non-empty and RFC 1123 DNS label (per FR-007) +- [ ] T005 Write unit tests for adoption helpers in `api/v1beta1/ogxserver_types_test.go`: `GetEffectivePVCName` returns adopted PVC name when annotation present, default name otherwise. Test `ValidateAdoptionAnnotation` with valid names, empty string, invalid characters, and names exceeding 63 characters. + +### Generated artifacts + +- [ ] T006 Run `make generate` to produce `zz_generated.deepcopy.go` for new types +- [ ] T007 Run `make manifests` to generate CRD YAML (`config/crd/bases/ogx.io_ogxservers.yaml`) and RBAC (`config/rbac/role.yaml`) +- [ ] T008 Delete old generated CRD: `config/crd/bases/llamastack.io_llamastackdistributions.yaml` +- [ ] T009 Remove legacy `llamastack.io` Go types and the `api/v1alpha1/` package; new OGX types live under `api/v1beta1/` (e.g. `ogxserver_types.go`) +- [ ] T010 Delete `api/v1alpha2/` directory (v1alpha2 types are now folded into `ogx.io/v1beta1`) + +### Config scaffolding + +- [ ] T011 Update `config/crd/kustomization.yaml`: base path to `ogx.io_ogxservers.yaml`, update patch references +- [ ] T012 Rename `config/crd/patches/cainjection_in_llamastackdistributions.yaml` → `config/crd/patches/cainjection_in_ogxservers.yaml` and update content +- [ ] T013 Rename `config/rbac/llsd_editor_role.yaml` → `config/rbac/ogxserver_editor_role.yaml`, update role name and API group +- [ ] T014 Rename `config/rbac/llsd_viewer_role.yaml` → `config/rbac/ogxserver_viewer_role.yaml`, update role name and API group +- [ ] T015 Update `config/default/kustomization.yaml`: namespace to `ogx-k8s-operator-system`, namePrefix to `ogx-k8s-operator-`, labels to `app.kubernetes.io/name: ogx-k8s-operator` +- [ ] T016 Update `config/default/manager_labels_patch.yaml`: label values +- [ ] T017 Update `config/manager/manager.yaml`, `config/manager/pdb.yaml`: label values +- [ ] T018 Update `config/manager/controller_manager_config.yaml`: `resourceName` to `54e06e98.ogx.io` +- [ ] T019 Update `PROJECT` file: domain to `ogx.io`, repo to `github.com/ogx-ai/ogx-k8s-operator`, kind to `OGXServer`, API version `v1beta1` + +### Samples + +- [ ] T020 Delete old sample: `config/samples/_v1alpha1_llamastackdistribution.yaml` +- [ ] T021 Create `config/samples/_v1beta1_ogxserver.yaml` — minimal OGXServer sample with distribution name +- [ ] T022 Update all `config/samples/example-*.yaml`: `apiVersion: ogx.io/v1beta1`, `kind: OGXServer`, restructure spec fields to new schema +- [ ] T023 Update `config/samples/kustomization.yaml`: resource references + +### Build and CI + +- [ ] T024 Update `Makefile`: `IMAGE_TAG_BASE` to `quay.io/ogx-ai/ogx-k8s-operator`, update comments +- [ ] T025 Update `crd-ref-docs.config.yaml`: API group to `ogx.io` +- [ ] T026 Update `.github/workflows/build-image.yml`: image name to `ogx-k8s-operator` +- [ ] T027 Update `.github/workflows/release-image.yml`: image name +- [ ] T028 Update `.github/workflows/run-e2e-test.yml`: namespace to `ogx-k8s-operator-system`, deployment name, image refs, `kubectl get ogxservers` +- [ ] T029 Update `.github/workflows/generate-release.yml`: image name, repo URL references + +### Verification (PR 1) + +- [ ] T030 Run `make generate manifests` — verify CRD and deepcopy regenerate cleanly +- [ ] T031 Run `go build ./api/...` — verify API package compiles +- [ ] T032 Run `go test ./api/...` — verify type tests pass + +--- + +## Phase 2: Controller Foundation (PR 2) + +**Goal**: Rename and restructure the controller to reconcile the new `OGXServer` CR. Support basic reconciliation: distribution image resolution, user-provided ConfigMap, PVC storage, **`spec.caBundle`** (top-level CA trust), **`spec.network`** (Service, Ingress/Route via `externalAccess.enabled`, TLS via presence semantics, NetworkPolicy via `policy` with native K8s types, per-CR enable/disable, policyTypes, and auto-injected kube-dns egress), and workload (Deployment, HPA, PDB). Remove ConfigMap-based `enableNetworkPolicy` feature flag. + +### Controller rename + +- [ ] T033 Rename `controllers/llamastackdistribution_controller.go` → `controllers/ogxserver_controller.go` +- [ ] T034 Rename `controllers/llamastackdistribution_controller_test.go` → `controllers/ogxserver_controller_test.go` +- [ ] T035 Rename `controllers/llamastackdistribution_controller_ca_whitespace_test.go` → `controllers/ogxserver_controller_ca_whitespace_test.go` +- [ ] T036 Rename `LlamaStackDistributionReconciler` → `OGXServerReconciler` and `NewLlamaStackDistributionReconciler` → `NewOGXServerReconciler` across all controller files +- [ ] T037 Update controller constants: `operatorConfigData` → `"ogx-operator-config"`, `WatchLabelKey` → `"ogx.io/watch"`, managed-by label → `"ogx-operator"` + +### Controller adaptation to new spec + +- [ ] T038 Update reconciler to work with new `OGXServerSpec` structure: map `spec.distribution` to container image, `spec.workload.replicas` to deployment replicas, **`spec.network.port`** to container/service port, etc. +- [ ] T039 Update `controllers/resource_helper.go`: adapt to new spec shape (distribution, workload.storage, workload.resources, workload.overrides, **`spec.network.tls`** with presence semantics, **`spec.caBundle`** as top-level CA trust). Preserve all upstream runtime contract strings per FR-002. +- [ ] T040 Update `controllers/status.go`: rename all `LlamaStackDistribution*` type references to `OGXServer*`, add new status fields (`ResolvedDistribution`, `ConfigGeneration`) +- [ ] T041 Update `controllers/network_resources.go`: adapt to **`spec.network`** shape (`externalAccess` with explicit `enabled` field, `policy` with native K8s types and `policyTypes`, `tls` with presence semantics), rename type references, update managed-by label to `"ogx-operator"`. Implement NetworkPolicy reconciliation: when `policy.enabled` is false, delete existing NP; when enabled with no custom rules, generate safe defaults; when custom `ingress`/`egress` provided, merge with defaults; respect `policyTypes` per K8s NetworkPolicy semantics; auto-inject kube-dns egress rule (UDP/TCP 53 to kube-system) when any egress rules are configured or when "Egress" is in policyTypes. Remove ConfigMap-based `enableNetworkPolicy` feature flag. +- [ ] T042 Update `controllers/kubebuilder_rbac.go`: change RBAC markers from `llamastack.io` to `ogx.io`, `llamastackdistributions` to `ogxservers` +- [ ] T043 Update `controllers/suite_test.go`: rename scheme registration +- [ ] T044 Update `controllers/testing_support_test.go`: rename builder and reconciler references, adapt to new spec structure + +### ConfigMap support (overrideConfig path) + +- [ ] T045 Implement `overrideConfig` path in reconciler: when `spec.overrideConfig.configMapName` is set, use the referenced ConfigMap as the server config (mount it into the pod). Skip any config generation logic. +- [ ] T046 When neither `overrideConfig` nor `providers`/`resources`/`storage` are set, deploy with the distribution's embedded default config (no ConfigMap mount needed — server uses its built-in config) + +### Operand manifests + +- [ ] T047 Update `controllers/manifests/base/kustomization.yaml`: labels to `app.kubernetes.io/managed-by: ogx-operator`, `app.kubernetes.io/part-of: ogx`, `ogx.io/watch: "true"` +- [ ] T048 Update `controllers/manifests/base/deployment.yaml`: `app: llama-stack` → `app: ogx` +- [ ] T049 Update `controllers/manifests/base/service.yaml`, `networkpolicy.yaml`, `hpa.yaml`, `pdb.yaml`: `app: llama-stack` → `app: ogx`. Note: `networkpolicy.yaml` base template now serves as the skeleton for operator-managed NP; the controller populates `ingress`/`egress` rules and `policyTypes` from `spec.network.policy` at reconcile time. + +### Package updates + +- [ ] T050 Update `pkg/deploy/kustomizer.go`: rename type refs, `FieldOwner` to `"ogx-operator"`, NS fallback to `"ogx-k8s-operator-system"` +- [ ] T051 Update `pkg/deploy/deploy.go`: rename type refs and `FieldOwner` +- [ ] T052 Update `pkg/deploy/utils.go` and `pkg/deploy/networkpolicy.go`: rename type refs +- [ ] T053 Update `pkg/deploy/plugins/networkpolicy_transformer.go` and `plugins/field_mutator.go`: rename type refs +- [ ] T054 Update `pkg/cluster/cluster.go`: managed-by label check from `"llama-stack-operator"` to `"ogx-operator"` + +### Main entrypoint + +- [ ] T055 Update `main.go`: rename import alias to `ogxiov1beta1`, update `LeaderElectionID` to `"54e06e98.ogx.io"`, update cache selector managed-by label, update reconciler call +- [ ] T056 Update all import statements across the codebase to the new module path `github.com/ogx-ai/ogx-k8s-operator` + +### Verification (PR 2 — build) + +- [ ] T057 Run `make generate manifests` — regenerate after controller changes +- [ ] T058 Run `make build-installer` to regenerate `release/operator.yaml` +- [ ] T059 Delete old generated files if any remain (old CRD YAML, old release artifacts) +- [ ] T060 Run `go build ./...` — verify full codebase compiles +- [ ] T061 Run `make lint` — verify no linter errors + +--- + +## Phase 3: Adoption Logic (PR 2, continued) + +**Goal**: Implement annotation-driven adoption so users can preserve PVCs and networking resources from old LlamaStackDistribution workloads. + +- [ ] T062 Create `controllers/legacy_adoption.go` with `adoptLegacyResources(ctx, instance)` entry point. Validate adoption annotation values using `ValidateAdoptionAnnotation()` (FR-007); if invalid, set `AdoptionConfigInvalid` condition and skip adoption. Normal reconciliation MUST proceed (a typo in an annotation must not block the workload). +- [ ] T063 Implement `adoptStorage(ctx, instance, legacyName)`: find legacy PVC (`{legacyName}-pvc`), scale old Deployment to zero if still running, wait for pod termination (requeue), replace PVC ownerRef (remove existing controller ownerRef, then `ctrl.SetControllerReference` to new CR), annotate PVC with `ogx.io/adopted-from` and `ogx.io/adopted-at` (FR-017a), emit event +- [ ] T064 Implement `adoptNetworking(ctx, instance, legacyName)`: adopt legacy Service (`{legacyName}-service`) by updating selectors to new pod labels + replacing ownerRef (remove old controller ref, `SetControllerReference` to new CR); adopt legacy Ingress (`{legacyName}-ingress`) by replacing ownerRef (same pattern). Annotate both with `ogx.io/adopted-from` and `ogx.io/adopted-at` (FR-017a). When the OGXServer CR name differs from `legacyName`, allow the kustomize pipeline to create new resources under the CR name in addition to the adopted legacy resources (FR-015 name-mismatch case). +- [ ] T065 Implement idempotency: check `metav1.IsControlledBy` before adoption, skip if already adopted +- [ ] T066 Update `configurePersistentStorage()` in `controllers/resource_helper.go` to use `instance.GetEffectivePVCName()` when `adopt-storage` annotation is present +- [ ] T067 Update `determineKindsToExclude()` in reconciler: exclude PVC from kustomize ResMap when `ogx.io/adopt-storage` annotation is present. For `ogx.io/adopt-networking` when the CR name matches the legacy name, exclude Service and Ingress from kustomize ResMap (same-name case); when names differ, allow kustomize to create new resources alongside adopted ones. +- [ ] T068 Update `reconcileResources()`: call `adoptLegacyResources()` before manifest reconciliation, propagate requeue signal +- [ ] T069 Add `StorageAdopted`, `NetworkingAdopted`, and `AdoptionConfigInvalid` condition types to `controllers/status.go`, set from adoption functions +- [ ] T070 Add/verify RBAC markers in `controllers/kubebuilder_rbac.go` for adoption: Deployments (`get`, `list`, `update`), Pods (`list`), PVCs (`get`, `list`, `update`), Services (`get`, `list`, `update`), Ingresses (`get`, `list`, `update`). Mark as `// TRANSITIONAL` +- [ ] T071 Run `make manifests` to regenerate RBAC after marker changes +- [ ] T071a Implement cleanup of adopted networking resources when `ogx.io/adopt-networking` annotation is removed and the CR name differs from the legacy name (different-name case only — same-name case is a no-op per D-024) + +--- + +## Phase 4: Tests (PR 2, continued) + +**Goal**: Unit and integration tests for the controller and adoption logic. + +### Controller tests + +- [ ] T072 Update `controllers/resource_helper_test.go`: adapt to new spec structure +- [ ] T073 Update `controllers/network_resources_test.go`: adapt to new **`spec.network.policy`** schema (test default NP generation, custom ingress/egress merging with defaults, policyTypes handling, kube-dns auto-injection, `enabled: false` deletion, TLS presence semantics, externalAccess.enabled) +- [ ] T074 Update all test files in `pkg/deploy/`: `kustomizer_test.go`, `deploy_test.go`, `suite_test.go`, `plugins/networkpolicy_transformer_test.go`, `plugins/field_mutator_test.go` + +### Adoption tests + +- [ ] T075 Write table-driven test: `adoptStorage` happy path — annotation present → old Deployment scaled to zero → PVC ownerRef transferred → `StorageAdopted` condition set (`controllers/legacy_adoption_test.go`) +- [ ] T076 Write table-driven test: PVC not found — annotation present but PVC missing → no error, new PVC created normally +- [ ] T077 Write table-driven test: idempotency — reconcile twice with annotation → no changes on second run +- [ ] T078 Write table-driven test: old pods still terminating → requeue with delay +- [ ] T079 Write table-driven test: old Deployment already gone → proceed to PVC ownership transfer +- [ ] T080 Write table-driven tests: `adoptNetworking` — old Service selector updated, old Ingress ownerRef transferred, `NetworkingAdopted` condition set +- [ ] T080a Write table-driven test: `adoptNetworking` with name mismatch — adopted legacy resources coexist alongside kustomize-created new resources, both owned by the new CR +- [ ] T080b Write table-driven test: annotation validation — invalid annotation values (empty, uppercase, special chars, >63 chars) → `AdoptionConfigInvalid` condition set, adoption skipped, normal reconciliation proceeds +- [ ] T080c Write table-driven test: adopted child resources carry `ogx.io/adopted-from` and `ogx.io/adopted-at` annotations after ownership transfer (FR-017a) +- [ ] T081 Write test: clean install without adoption annotations → no adoption code path triggered, normal PVC created + +### E2E tests + +- [ ] T082 Update all E2E test files in `tests/e2e/`: `creation_test.go`, `deletion_test.go`, `rollout_test.go`, `tls_test.go`, `validation_test.go`, `e2e_test.go`, `test_utils.go` — new API group, Kind, spec structure +- [ ] T083 Write E2E test: full adoption flow → verify `StorageAdopted` condition set, event emitted, adopted PVC accessible +- [ ] T084 Write E2E test: networking adoption → verify Service selector updated, Ingress ownerRef transferred + +### Verification (PR 2 — tests) + +- [ ] T085 Run `go test ./...` — all tests pass +- [ ] T086 Run grep audit for residual legacy naming: search for `llamastack`, `LlamaStack`, `llama-stack`, `llsd` in all operator-owned files (excluding upstream runtime contracts per FR-002 and deferred items per FR-006) — verify clean + +--- + +## Phase 5: Documentation (PR 2, continued) + +**Goal**: Update all docs, create migration guide, update specs. + +### Migration guide + +- [ ] T087 Create `docs/migration-guide.md` with: + - Complete old-to-new name mapping table (API group, Kind, plural, short name, labels, annotations, status field paths, CLI commands) + - Breaking changes section (this is a breaking change — no coexistence period, no webhooks) + - Step-by-step upgrade instructions: + 1. Remove old LLS operator (via meta-operator or manual) + 2. Delete orphaned stateless resources (`kubectl delete deploy,networkpolicy,sa,rolebinding -l app.kubernetes.io/instance=` + HPA/PDB) + 3. Install new OGX operator + 4. Create OGXServer CR with `ogx.io/adopt-storage` annotation + 5. (Optional) Add `ogx.io/adopt-networking` annotation + 6. Clean up: `kubectl delete crd llamastackdistributions.llamastack.io` + - NetworkPolicy impact explanation (old `app: llama-stack` selector vs new `app: ogx`) + - Verification commands (`kubectl get ogxserver`, check conditions, check events) + - Rollback section + - Deprecation notice for adoption annotations (future removal target) + +### Existing docs + +- [ ] T088 Update `README.md`: rename all operator/CRD references +- [ ] T089 Update `CONTRIBUTING.md`: update repo references +- [ ] T090 Update `docs/create-operator.md`: rename all references +- [ ] T091 Update `docs/api-overview.md`: rename CRD type references, short name, API group +- [ ] T092 Update `docs/additional/ca-bundle-configuration.md`: rename all references +- [ ] T093 Regenerate API reference docs: `make api-docs` + +### Specs + +- [ ] T094 Update `specs/constitution.md`: rename examples/references from LlamaStack to OGX +- [ ] T095 Update `specs/001-deploy-time-providers-l1/*.md`: rename operator references +- [ ] T096 Update `specs/002-operator-generated-config/*.md`: rename operator references, note that v1alpha2 is now folded into OGXServer `ogx.io/v1beta1`, and that the folded network block is **`spec.network`** on OGXServer (not `spec.networking`). Document divergences from original 002 spec: `caBundle` moved to top-level, TLS uses presence semantics (no `enabled` bool), `expose` renamed to `externalAccess` with explicit `enabled`, `networkPolicy` renamed to `policy` with `policyTypes`, `disabled` renamed to `disabledAPIs`, `apiKey` replaced by `secretRefs`, `telemetry` provider removed, `externalProviders` removed, provider prefix required, `DefaultMountPath` changed to `/.ogx`, `configMapNamespace` removed from `CABundleConfig` + +--- + +## Phase 6: Final Verification + +**Goal**: Full build and audit pass. + +- [ ] T097 Run full verification: `make generate manifests build-installer api-docs lint` and `go test ./...` — zero errors +- [ ] T098 Run grep audit for residual legacy naming (final pass) +- [ ] T099 Create tracking list of deferred items per FR-006 (container registry URLs, Git org URLs) in `specs/003-ogx-rename/deferred-items.md` + +--- + +## User Upgrade Steps + +This is a **breaking change**. Only the OGX operator will be available after the upgrade — there is no period where both operators coexist in the same workload. Users must manually create new OGXServer CRs and migrate configuration. + +### Key changes: API surface + +Multiple changes affect the API surface: + +**1. Label change**: `DefaultLabelValue` changes from `llama-stack` to `ogx`, affecting `podSelector` on all resources. + +**2. NetworkPolicy API redesign**: The legacy `AllowedFromSpec` (namespace names and labels) and the ConfigMap-based `enableNetworkPolicy` feature flag are replaced by `spec.network.policy`: + +- **`enabled`** (default `true`): per-CR toggle replacing the global ConfigMap feature flag. Set to `false` to disable NP creation entirely. +- **`policyTypes`** (`[]networkingv1.PolicyType`): follows K8s NetworkPolicy semantics. When omitted, Ingress is always included and Egress is included only if egress rules are provided. +- **`ingress`** (`[]networkingv1.NetworkPolicyIngressRule`): native K8s types. When nil, operator generates safe defaults (same-namespace + operator-namespace on service port). When set, merged with operator defaults. +- **`egress`** (`[]networkingv1.NetworkPolicyEgressRule`): native K8s types. When nil, egress is unrestricted. When set (or when "Egress" is in policyTypes), operator auto-injects a kube-dns egress rule (UDP/TCP 53 to kube-system) to prevent DNS breakage. + +**3. TLS uses presence semantics**: `spec.network.tls` has only a required `secretName`. TLS is enabled when the `tls` field is present, disabled when omitted. No `enabled` bool. + +**4. CA bundle is top-level**: `spec.caBundle` (not `spec.network.tls.caBundle`) configures outbound trust for provider/backend connections, independent of inbound TLS termination. No `configMapNamespace` — ConfigMap must be in the same namespace as the OGXServer. + +**5. External access renamed**: `spec.network.externalAccess` (not `spec.network.expose`) with explicit `enabled` field (default false) and optional `hostname`. Named for mechanism-neutrality. + +**6. Providers require explicit prefix**: `ProviderConfig.Provider` must start with `remote::` or `inline::` (e.g., `remote::vllm`, `inline::builtin`). No implicit normalization. + +**7. DisabledAPIs renamed**: `spec.disabledAPIs` (not `spec.disabled`) for clarity. + +**8. Provider apiKey replaced by secretRefs**: `ProviderConfig.apiKey` is replaced by `secretRefs` (`map[string]SecretKeyRef`) for flexible named secret references. + +**9. Telemetry provider removed**: No `telemetry` field in `ProvidersSpec` — it doesn't exist. + +**10. ExternalProviders removed**: Design not yet finalized. Removed to avoid premature commitment. + +**11. DefaultMountPath changed**: `/.llama` → `/.ogx`. + +**12. ogx.io/watch label required**: All ConfigMaps and Secrets referenced in the CRD must have the `ogx.io/watch: "true"` label to be detected by the operator's cache. + +**13. Validating webhook**: Enforces constraints CEL cannot express — distribution name validation, global provider ID uniqueness, model provider reference validation. + +**Before (old operator)**: +```yaml +spec: + podSelector: + matchLabels: + app: llama-stack + app.kubernetes.io/instance: my-server + ingress: + - from: + - podSelector: {} + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: llama-stack-k8s-operator-system + ports: + - { protocol: TCP, port: 8321 } +``` + +**After (new operator, default behavior — no custom rules)**: +```yaml +spec: + podSelector: + matchLabels: + app: ogx + app.kubernetes.io/instance: my-server + ingress: + - from: + - podSelector: {} + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: ogx-k8s-operator-system + ports: + - { protocol: TCP, port: 8321 } +``` + +**Migration from old `allowedFrom`**: Translate `allowedFrom.namespaces` and `allowedFrom.labels` to equivalent `spec.network.policy.ingress[].from` entries using `namespaceSelector`. Translate ConfigMap `enableNetworkPolicy: false` to `spec.network.policy.enabled: false` on the CR. + +### Upgrade Path (with PVC data preservation) + +```text +Step 1: Remove the old LLS operator + ├─ Via meta-operator: set dsc.spec.components.lls to "Removed" + └─ Manual: delete operator manifests (operator Deployment, RBAC, ServiceAccount) + (The operator Deployment is removed. The operand Deployments, CRD, and CRs remain.) + +Step 2: Delete orphaned stateless resources + └─ kubectl delete deploy,networkpolicy,sa,rolebinding -l app.kubernetes.io/instance= + └─ Also delete HPA/PDB if they exist: kubectl delete hpa,pdb -l app.kubernetes.io/instance= + (KEEP: PVC (adopted in Step 4), and optionally Service + Ingress (adopted in Step 5). + WHY: After operator removal, orphaned resources have no active controller. + The new operator's patchResource safety check skips resources it does not own, + so it cannot update or replace them. Deleting them lets the new operator + create fresh versions with correct "ogx" labels and configuration. + NetworkPolicy is especially critical: the old policy's podSelector targets + "app: llama-stack" which no longer matches the new "app: ogx" pods, + leaving them unprotected until the orphan is removed.) + +Step 3: Install the new OGX operator + ├─ Via meta-operator: set dsc.spec.components.ogx to "Managed" + └─ Manual: apply release/operator.yaml + +Step 4: Create OGXServer CR with adopt-storage annotation + └─ Translate fields from old LLSD CR + ConfigMap into the new OGXServer spec + └─ Include annotation: ogx.io/adopt-storage: "" + (Operator adopts the orphaned PVC, sets ownerReference, creates new Deployment + that mounts the adopted PVC. ~30-60s until new pod is ready.) + +Step 5 (optional): Adopt networking (preserve ClusterIP / external endpoint) + └─ Add annotation: ogx.io/adopt-networking: "" + (Operator adopts orphaned Service + Ingress, updates Service selectors to + new pod labels, sets ownerReferences. If the CR name matches the old LLSD + name, resource names are unchanged -- no client updates needed. If names + differ, adopted resources coexist alongside new ones created by the + kustomize pipeline.) + +Step 6: Clean up legacy resources + └─ Delete old CRD: kubectl delete crd llamastackdistributions.llamastack.io +``` + +--- + +## Dependencies + +```text +T001–T002 → T003 (module path + API group before types) +T003 → T004–T005 (types before helpers/tests) +T003 → T006–T007 (types before generation) +T006–T010 → T011–T019 (generated artifacts before config scaffolding) +T003 → T020–T023 (types before samples) +T030–T032 → T033 (PR 1 verified before controller work) + +T033–T037 → T038–T044 (controller renamed before adaptation) +T038–T044 → T045–T046 (controller adapted before ConfigMap support) +T038–T044 → T047–T049 (controller adapted before manifest updates) +T038–T044 → T050–T054 (controller adapted before package updates) +T050–T056 → T057–T061 (all code updated before build verification) + +T057–T061 → T062–T071 (build clean before adoption logic) +T062–T071 → T072–T086 (adoption code before tests) +T087–T096 (docs) can run in parallel with T062–T086 + +T097–T099 must run last +``` + +## Parallel Execution Opportunities + +**Within Phase 1** (after T003): +- T004, T005 can run in parallel (adoption helpers + tests) +- T011–T019 can all run in parallel (config scaffolding files) +- T020–T023 can all run in parallel (samples) +- T024–T029 can all run in parallel (build/CI) + +**Within Phase 2** (after T038): +- T039, T040, T041, T042 can run in parallel (independent controller files) +- T047, T048, T049 can run in parallel (manifest files) +- T050, T051, T052, T053, T054 can run in parallel (package files) + +**Across phases**: +- Phase 5 (docs) can run in parallel with Phase 3 + Phase 4 +- T094, T095, T096 (spec updates) can run in parallel with everything + +## Summary + +- **Total tasks**: 105 +- **Phase 1 (API — PR 1)**: 33 tasks (T001–T032, T003a) +- **Phase 2 (Controller — PR 2)**: 29 tasks (T033–T061) +- **Phase 3 (Adoption — PR 2)**: 11 tasks (T062–T071a) +- **Phase 4 (Tests — PR 2)**: 18 tasks (T072–T086, including T080a, T080b, T080c) +- **Phase 5 (Docs — PR 2)**: 10 tasks (T087–T096) +- **Phase 6 (Final verification)**: 3 tasks (T097–T099) +- **Config generation (PR 3)**: Deferred to spec 002 follow-up diff --git a/tests/e2e/creation_test.go b/tests/e2e/creation_test.go index 7dc3ccca2..9db8108d0 100644 --- a/tests/e2e/creation_test.go +++ b/tests/e2e/creation_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "github.com/llamastack/llama-stack-k8s-operator/api/v1alpha1" + ogxiov1beta1 "github.com/ogx-ai/ogx-k8s-operator/api/v1beta1" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gopkg.in/yaml.v2" @@ -22,55 +22,69 @@ import ( ) // runCreationTestsForDistribution runs creation tests for a specific distribution type. -func runCreationTestsForDistribution(t *testing.T, distType string) *v1alpha1.LlamaStackDistribution { +func runCreationTestsForDistribution(t *testing.T, distType string) *ogxiov1beta1.OGXServer { t.Helper() if TestOpts.SkipCreation { t.Skip("Skipping creation test suite") } - var llsdistributionCR *v1alpha1.LlamaStackDistribution + var ogxServer *ogxiov1beta1.OGXServer - t.Run("should create LlamaStackDistribution", func(t *testing.T) { - llsdistributionCR = testCreateDistributionForType(t, distType) + t.Run("should create OGXServer", func(t *testing.T) { + ogxServer = testCreateServerForType(t, distType) }) + requireServer := func(t *testing.T) { + t.Helper() + if ogxServer == nil { + t.Skip("Skipping: OGXServer creation failed") + } + } + t.Run("should create PVC if storage is configured", func(t *testing.T) { - testPVCConfiguration(t, llsdistributionCR) + requireServer(t) + testPVCConfiguration(t, ogxServer) }) t.Run("should handle direct deployment updates", func(t *testing.T) { - testDirectDeploymentUpdates(t, llsdistributionCR) + requireServer(t) + testDirectDeploymentUpdates(t, ogxServer) }) t.Run("should check health status", func(t *testing.T) { - testHealthStatus(t, llsdistributionCR) + requireServer(t) + testHealthStatus(t, ogxServer) }) t.Run("should update deployment through CR", func(t *testing.T) { - testCRDeploymentUpdate(t, llsdistributionCR) + requireServer(t) + testCRDeploymentUpdate(t, ogxServer) }) t.Run("should update distribution status", func(t *testing.T) { - testDistributionStatus(t, llsdistributionCR) + requireServer(t) + testDistributionStatus(t, ogxServer) }) - t.Run("should use custom ServiceAccount from PodOverrides", func(t *testing.T) { - testServiceAccountOverride(t, llsdistributionCR) + t.Run("should use custom ServiceAccount from workload overrides", func(t *testing.T) { + requireServer(t) + testServiceAccountOverride(t, ogxServer) }) t.Run("should apply image mapping overrides from ConfigMap", func(t *testing.T) { - testImageMappingOverrides(t, llsdistributionCR) + requireServer(t) + testImageMappingOverrides(t, ogxServer) }) - return llsdistributionCR + return ogxServer } -func testCreateDistributionForType(t *testing.T, distType string) *v1alpha1.LlamaStackDistribution { +func testCreateServerForType(t *testing.T, distType string) *ogxiov1beta1.OGXServer { t.Helper() - // Create test namespace + ns := &corev1.Namespace{ ObjectMeta: metav1.ObjectMeta{ - Name: "llama-stack-test", + Name: "ogx-test", }, } err := TestEnv.Client.Create(TestEnv.Ctx, ns) @@ -78,56 +92,53 @@ func testCreateDistributionForType(t *testing.T, distType string) *v1alpha1.Llam require.NoError(t, err) } - // Get sample CR for the specific distribution type - llsdistributionCR := GetSampleCRForDistribution(t, distType) - llsdistributionCR.Namespace = ns.Name + ogxServer := GetSampleCRForDistribution(t, distType) + ogxServer.Namespace = ns.Name + + EnsureOverrideConfigMap(t, TestEnv.Client, TestEnv.Ctx, ogxServer) - t.Logf("Creating %s distribution with name: %s", distType, llsdistributionCR.Name) + t.Logf("Creating %s distribution with name: %s", distType, ogxServer.Name) - err = TestEnv.Client.Create(TestEnv.Ctx, llsdistributionCR) + err = TestEnv.Client.Create(TestEnv.Ctx, ogxServer) if err != nil && !k8serrors.IsAlreadyExists(err) { require.NoError(t, err) } - // Wait for deployment to be ready err = EnsureResourceReady(t, TestEnv, schema.GroupVersionKind{ Group: "apps", Version: "v1", Kind: "Deployment", - }, llsdistributionCR.Name, ns.Name, ResourceReadyTimeout, isDeploymentReady) + }, ogxServer.Name, ns.Name, ResourceReadyTimeout, isDeploymentReady) require.NoError(t, err) - // Wait for pods to be running and ready - err = WaitForPodsReady(t, TestEnv, ns.Name, llsdistributionCR.Name, ResourceReadyTimeout) + err = WaitForPodsReady(t, TestEnv, ns.Name, ogxServer.Name, ResourceReadyTimeout) require.NoError(t, err, "Pods should be running and ready") - // Verify service is created err = EnsureResourceReady(t, TestEnv, schema.GroupVersionKind{ Group: "", Version: "v1", Kind: "Service", - }, llsdistributionCR.Name+"-service", ns.Name, ResourceReadyTimeout, func(u *unstructured.Unstructured) bool { - // Check if the service has a valid spec and status + }, ogxServer.Name+"-service", ns.Name, ResourceReadyTimeout, func(u *unstructured.Unstructured) bool { spec, specFound, _ := unstructured.NestedMap(u.Object, "spec") status, statusFound, _ := unstructured.NestedMap(u.Object, "status") return specFound && statusFound && spec != nil && status != nil }) - requireNoErrorWithDebugging(t, TestEnv, err, "Service readiness check failed", llsdistributionCR.Namespace, llsdistributionCR.Name) + requireNoErrorWithDebugging(t, TestEnv, err, "Service readiness check failed", ogxServer.Namespace, ogxServer.Name) - return llsdistributionCR + return ogxServer } -func testDirectDeploymentUpdates(t *testing.T, distribution *v1alpha1.LlamaStackDistribution) { +func testDirectDeploymentUpdates(t *testing.T, server *ogxiov1beta1.OGXServer) { t.Helper() - if distribution.Spec.Server.Autoscaling != nil && distribution.Spec.Server.Autoscaling.MaxReplicas > 0 { + if server.Spec.Workload != nil && server.Spec.Workload.Autoscaling != nil && server.Spec.Workload.Autoscaling.MaxReplicas > 0 { t.Skip("Skipping direct deployment update healing test when autoscaling is enabled") } - // Get the deployment + deployment := &appsv1.Deployment{} err := TestEnv.Client.Get(TestEnv.Ctx, client.ObjectKey{ - Namespace: distribution.Namespace, - Name: distribution.Name, + Namespace: server.Namespace, + Name: server.Name, }, deployment) require.NoError(t, err) @@ -136,204 +147,198 @@ func testDirectDeploymentUpdates(t *testing.T, distribution *v1alpha1.LlamaStack err = TestEnv.Client.Update(TestEnv.Ctx, deployment) require.NoError(t, err) - // Wait for operator to reconcile time.Sleep(5 * time.Second) - // Verify deployment is reverted to original state err = TestEnv.Client.Get(TestEnv.Ctx, client.ObjectKey{ - Namespace: distribution.Namespace, - Name: distribution.Name, + Namespace: server.Namespace, + Name: server.Name, }, deployment) require.NoError(t, err) require.Equal(t, originalReplicas, *deployment.Spec.Replicas, "Deployment should be reverted to original state") } -func testCRDeploymentUpdate(t *testing.T, distribution *v1alpha1.LlamaStackDistribution) { +func updateCRReplicas(server *ogxiov1beta1.OGXServer, replicas int32) error { + return wait.PollUntilContextTimeout(TestEnv.Ctx, time.Second, 30*time.Second, true, func(ctx context.Context) (bool, error) { + latest := &ogxiov1beta1.OGXServer{} + if err := TestEnv.Client.Get(ctx, client.ObjectKey{ + Namespace: server.Namespace, + Name: server.Name, + }, latest); err != nil { + return false, err + } + if latest.Spec.Workload == nil { + latest.Spec.Workload = &ogxiov1beta1.WorkloadSpec{} + } + latest.Spec.Workload.Replicas = &replicas + if err := TestEnv.Client.Update(ctx, latest); err != nil { + if k8serrors.IsConflict(err) { + return false, nil + } + return false, err + } + return true, nil + }) +} + +func testCRDeploymentUpdate(t *testing.T, server *ogxiov1beta1.OGXServer) { t.Helper() - // Update CR - err := TestEnv.Client.Get(TestEnv.Ctx, client.ObjectKey{ - Namespace: distribution.Namespace, - Name: distribution.Name, - }, distribution) - require.NoError(t, err) - // Skip scaling test if PVC with ReadWriteOnce is used - // Most cloud providers (AWS EBS, Azure Disk, GCE PD) only support ReadWriteOnce for block storage - // ReadWriteMany requires network file systems (NFS, CephFS, etc.) which may not be available - if distribution.Spec.Server.Storage != nil { - t.Log("Skipping replica scaling test - PVC with ReadWriteOnce can only be attached to one pod at a time") - t.Log("To enable replica scaling with persistent storage, configure a StorageClass that supports ReadWriteMany") - return + if server.Spec.Workload != nil && server.Spec.Workload.Autoscaling != nil && server.Spec.Workload.Autoscaling.MaxReplicas > 0 { + t.Skip("Skipping CR deployment update test when autoscaling is enabled") } - // Update replicas - distribution.Spec.Replicas = 2 - err = TestEnv.Client.Update(TestEnv.Ctx, distribution) - require.NoError(t, err) + // Scale to 0 via CR update + require.NoError(t, updateCRReplicas(server, 0), "Failed to update CR replicas to 0") - // Wait for deployment to be updated and ready with new replicas - err = EnsureResourceReady(t, TestEnv, schema.GroupVersionKind{ + // Verify deployment scales to 0 + err := EnsureResourceReady(t, TestEnv, schema.GroupVersionKind{ Group: "apps", Version: "v1", Kind: "Deployment", - }, distribution.Name, distribution.Namespace, ResourceReadyTimeout, func(u *unstructured.Unstructured) bool { - availableReplicas, found, nestedErr := unstructured.NestedInt64(u.Object, "status", "availableReplicas") + }, server.Name, server.Namespace, ResourceReadyTimeout, func(u *unstructured.Unstructured) bool { + specReplicas, found, nestedErr := unstructured.NestedInt64(u.Object, "spec", "replicas") if !found || nestedErr != nil { return false } - return availableReplicas == 2 + return specReplicas == 0 }) - require.NoError(t, err, "Failed to wait for deployment to update replicas") + require.NoError(t, err, "Deployment should scale to 0") - // Verify deployment is updated - deployment := &appsv1.Deployment{} - err = TestEnv.Client.Get(TestEnv.Ctx, client.ObjectKey{ - Namespace: distribution.Namespace, - Name: distribution.Name, - }, deployment) - require.NoError(t, err) - require.Equal(t, int32(2), deployment.Status.AvailableReplicas, "Deployment should have 2 available replicas") + // Scale back to 1 + require.NoError(t, updateCRReplicas(server, 1), "Failed to scale CR back to 1") + + err = WaitForPodsReady(t, TestEnv, server.Namespace, server.Name, ResourceReadyTimeout) + require.NoError(t, err, "Pod should be ready after scale-up") } -func testHealthStatus(t *testing.T, distribution *v1alpha1.LlamaStackDistribution) { +func testHealthStatus(t *testing.T, server *ogxiov1beta1.OGXServer) { t.Helper() - // Wait for status to be updated with a longer interval to avoid rate limiting + err := wait.PollUntilContextTimeout(TestEnv.Ctx, 1*time.Minute, 5*time.Minute, true, func(ctx context.Context) (bool, error) { - // Get the latest state of the distribution - updatedDistribution := &v1alpha1.LlamaStackDistribution{} + updated := &ogxiov1beta1.OGXServer{} err := TestEnv.Client.Get(ctx, client.ObjectKey{ - Namespace: distribution.Namespace, - Name: distribution.Name, - }, updatedDistribution) + Namespace: server.Namespace, + Name: server.Name, + }, updated) if err != nil { return false, err } - return updatedDistribution.Status.Phase == v1alpha1.LlamaStackDistributionPhaseReady, nil + return updated.Status.Phase == ogxiov1beta1.OGXServerPhaseReady, nil }) - requireNoErrorWithDebugging(t, TestEnv, err, "Failed to wait for distribution status update", distribution.Namespace, distribution.Name) + requireNoErrorWithDebugging(t, TestEnv, err, "Failed to wait for server status update", server.Namespace, server.Name) } -func testDistributionStatus(t *testing.T, llsdistributionCR *v1alpha1.LlamaStackDistribution) { +func testDistributionStatus(t *testing.T, server *ogxiov1beta1.OGXServer) { t.Helper() - // Wait for status to be updated with distribution info + err := wait.PollUntilContextTimeout(TestEnv.Ctx, 1*time.Minute, 5*time.Minute, true, func(ctx context.Context) (bool, error) { - updatedDistribution := &v1alpha1.LlamaStackDistribution{} + updated := &ogxiov1beta1.OGXServer{} err := TestEnv.Client.Get(ctx, client.ObjectKey{ - Namespace: llsdistributionCR.Namespace, - Name: llsdistributionCR.Name, - }, updatedDistribution) + Namespace: server.Namespace, + Name: server.Name, + }, updated) if err != nil { return false, err } - return isDistributionStatusReady(updatedDistribution), nil + return isOGXServerStatusReady(updated), nil }) if err != nil { - // Get the final state to print on error - finalDistribution := &v1alpha1.LlamaStackDistribution{} + finalServer := &ogxiov1beta1.OGXServer{} TestEnv.Client.Get(TestEnv.Ctx, client.ObjectKey{ - Namespace: llsdistributionCR.Namespace, - Name: llsdistributionCR.Name, - }, finalDistribution) - requireNoErrorWithDebugging(t, TestEnv, err, "Failed to wait for distribution status update", llsdistributionCR.Namespace, llsdistributionCR.Name) + Namespace: server.Namespace, + Name: server.Name, + }, finalServer) + requireNoErrorWithDebugging(t, TestEnv, err, "Failed to wait for distribution status update", server.Namespace, server.Name) } - // Get final state and verify - updatedDistribution := &v1alpha1.LlamaStackDistribution{} + updated := &ogxiov1beta1.OGXServer{} err = TestEnv.Client.Get(TestEnv.Ctx, client.ObjectKey{ - Namespace: llsdistributionCR.Namespace, - Name: llsdistributionCR.Name, - }, updatedDistribution) + Namespace: server.Namespace, + Name: server.Name, + }, updated) require.NoError(t, err) - // Verify distribution config (but allow it to be empty for some distributions) - if len(updatedDistribution.Status.DistributionConfig.AvailableDistributions) > 0 { - require.NotEmpty(t, updatedDistribution.Status.DistributionConfig.AvailableDistributions, + if len(updated.Status.DistributionConfig.AvailableDistributions) > 0 { + require.NotEmpty(t, updated.Status.DistributionConfig.AvailableDistributions, "Available distributions should be populated") } - if updatedDistribution.Status.DistributionConfig.ActiveDistribution != "" { - require.Equal(t, updatedDistribution.Spec.Server.Distribution.Name, - updatedDistribution.Status.DistributionConfig.ActiveDistribution, + if updated.Status.DistributionConfig.ActiveDistribution != "" { + require.Equal(t, updated.Spec.Distribution.Name, + updated.Status.DistributionConfig.ActiveDistribution, "Active distribution should match the spec") } - // Verify provider config and health (but allow it to be empty for some distributions) - if len(updatedDistribution.Status.DistributionConfig.Providers) > 0 { - // Verify that each provider has config and health info - validateProviders(t, updatedDistribution) + if len(updated.Status.DistributionConfig.Providers) > 0 { + validateProviders(t, updated) } else { t.Log("No providers found in distribution status - this might be expected for some distributions") } - // Write the final distribution status to a file for CI to collect - yaml, err := yaml.Marshal(updatedDistribution) + yamlData, err := yaml.Marshal(updated) if err != nil { - t.Fatalf("Failed to marshal distribution: %v", err) + t.Fatalf("Failed to marshal server: %v", err) } - // Weak - do this better to write to a temp file and then move it to the right place at the - // repo's root so the CI agent can collect it - err = os.WriteFile("../../distribution.log", yaml, 0644) + err = os.WriteFile("../../distribution.log", yamlData, 0644) require.NoError(t, err) } -func testPVCConfiguration(t *testing.T, distribution *v1alpha1.LlamaStackDistribution) { +func testPVCConfiguration(t *testing.T, server *ogxiov1beta1.OGXServer) { t.Helper() - pvcName := distribution.Name + "-pvc" + pvcName := server.Name + "-pvc" pvc := &corev1.PersistentVolumeClaim{} err := TestEnv.Client.Get(TestEnv.Ctx, client.ObjectKey{ - Namespace: distribution.Namespace, + Namespace: server.Namespace, Name: pvcName, }, pvc) - if distribution.Spec.Server.Storage == nil { + + hasStorage := server.Spec.Workload != nil && server.Spec.Workload.Storage != nil + if !hasStorage { require.Error(t, err, "PVC should not exist when storage is not configured") require.True(t, k8serrors.IsNotFound(err), "Expected not found error for PVC when storage is not configured") } else { require.NoError(t, err, "PVC should be created when storage is configured") - // Check storage size - expectedSize := v1alpha1.DefaultStorageSize - if distribution.Spec.Server.Storage.Size != nil { - expectedSize = *distribution.Spec.Server.Storage.Size + expectedSize := ogxiov1beta1.DefaultStorageSize + if server.Spec.Workload.Storage.Size != nil { + expectedSize = *server.Spec.Workload.Storage.Size } actualSize := pvc.Spec.Resources.Requests[corev1.ResourceStorage] require.Equal(t, expectedSize.String(), actualSize.String(), "PVC storage size should match CR") } } -func testServiceAccountOverride(t *testing.T, distribution *v1alpha1.LlamaStackDistribution) { +func testServiceAccountOverride(t *testing.T, server *ogxiov1beta1.OGXServer) { t.Helper() - // Create a custom ServiceAccount sa := &corev1.ServiceAccount{ ObjectMeta: metav1.ObjectMeta{ Name: "custom-sa", - Namespace: distribution.Namespace, + Namespace: server.Namespace, }, } require.NoError(t, TestEnv.Client.Create(TestEnv.Ctx, sa)) - defer TestEnv.Client.Delete(TestEnv.Ctx, sa) - // Update the CR to use the custom ServiceAccount with retry logic err := wait.PollUntilContextTimeout(TestEnv.Ctx, time.Second, 30*time.Second, true, func(ctx context.Context) (bool, error) { - // Get the latest version of the CR - latestDistribution := &v1alpha1.LlamaStackDistribution{} + latest := &ogxiov1beta1.OGXServer{} if err := TestEnv.Client.Get(ctx, client.ObjectKey{ - Namespace: distribution.Namespace, - Name: distribution.Name, - }, latestDistribution); err != nil { + Namespace: server.Namespace, + Name: server.Name, + }, latest); err != nil { return false, err } - // Update the ServiceAccount - latestDistribution.Spec.Server.PodOverrides = &v1alpha1.PodOverrides{ - ServiceAccountName: "custom-sa", + if latest.Spec.Workload == nil { + latest.Spec.Workload = &ogxiov1beta1.WorkloadSpec{} } + if latest.Spec.Workload.Overrides == nil { + latest.Spec.Workload.Overrides = &ogxiov1beta1.WorkloadOverrides{} + } + latest.Spec.Workload.Overrides.ServiceAccountName = "custom-sa" - // Try to update - if err := TestEnv.Client.Update(ctx, latestDistribution); err != nil { + if err := TestEnv.Client.Update(ctx, latest); err != nil { if k8serrors.IsConflict(err) { - // If there's a conflict, return false to retry return false, nil } return false, err @@ -342,20 +347,41 @@ func testServiceAccountOverride(t *testing.T, distribution *v1alpha1.LlamaStackD }) require.NoError(t, err, "Failed to update CR with ServiceAccount override") - // Wait for the deployment to be updated time.Sleep(5 * time.Second) - // Get the deployment deployment := &appsv1.Deployment{} require.NoError(t, TestEnv.Client.Get(TestEnv.Ctx, client.ObjectKey{ - Name: distribution.Name, - Namespace: distribution.Namespace, + Name: server.Name, + Namespace: server.Namespace, }, deployment)) - // Verify the ServiceAccount is set correctly assert.Equal(t, "custom-sa", deployment.Spec.Template.Spec.ServiceAccountName) + + // Reset the ServiceAccountName before deleting the SA to prevent subsequent + // reconciles from producing a deployment that references a deleted SA. + err = wait.PollUntilContextTimeout(TestEnv.Ctx, time.Second, 30*time.Second, true, func(ctx context.Context) (bool, error) { + latest := &ogxiov1beta1.OGXServer{} + if getErr := TestEnv.Client.Get(ctx, client.ObjectKey{ + Namespace: server.Namespace, + Name: server.Name, + }, latest); getErr != nil { + return false, getErr + } + latest.Spec.Workload.Overrides.ServiceAccountName = "" + if updateErr := TestEnv.Client.Update(ctx, latest); updateErr != nil { + if k8serrors.IsConflict(updateErr) { + return false, nil + } + return false, updateErr + } + return true, nil + }) + require.NoError(t, err, "Failed to reset ServiceAccount override") + + time.Sleep(5 * time.Second) + require.NoError(t, TestEnv.Client.Delete(TestEnv.Ctx, sa)) } func isDeploymentReady(u *unstructured.Unstructured) bool { @@ -367,45 +393,36 @@ func isDeploymentReady(u *unstructured.Unstructured) bool { return found && err == nil && availableReplicas == replicas } -// isDistributionStatusReady checks if the distribution status is ready. -func isDistributionStatusReady(distribution *v1alpha1.LlamaStackDistribution) bool { - // Check that distribution config is populated - but this might not be required for all distributions - if len(distribution.Status.DistributionConfig.AvailableDistributions) == 0 { - // Allow this to be empty if the distribution is in ready phase - if distribution.Status.Phase != v1alpha1.LlamaStackDistributionPhaseReady { +// isOGXServerStatusReady checks if the server status is ready. +func isOGXServerStatusReady(server *ogxiov1beta1.OGXServer) bool { + if len(server.Status.DistributionConfig.AvailableDistributions) == 0 { + if server.Status.Phase != ogxiov1beta1.OGXServerPhaseReady { return false } } - // Verify that the active distribution is set - but this might not be required for all distributions - if distribution.Status.DistributionConfig.ActiveDistribution == "" { - // Allow this to be empty if the distribution is in ready phase - if distribution.Status.Phase != v1alpha1.LlamaStackDistributionPhaseReady { + if server.Status.DistributionConfig.ActiveDistribution == "" { + if server.Status.Phase != ogxiov1beta1.OGXServerPhaseReady { return false } } - // For now, we'll consider it ready if the phase is ready, even if providers are empty - // This is because the providers might be populated asynchronously - return distribution.Status.Phase == v1alpha1.LlamaStackDistributionPhaseReady + return server.Status.Phase == ogxiov1beta1.OGXServerPhaseReady } -// validateProviders validates all providers in the distribution. -func validateProviders(t *testing.T, distribution *v1alpha1.LlamaStackDistribution) { +// validateProviders validates all providers in the server status. +func validateProviders(t *testing.T, server *ogxiov1beta1.OGXServer) { t.Helper() - for _, provider := range distribution.Status.DistributionConfig.Providers { + for _, provider := range server.Status.DistributionConfig.Providers { require.NotEmpty(t, provider.API, "Provider should have API info") require.NotEmpty(t, provider.ProviderID, "Provider should have ProviderID info") require.NotEmpty(t, provider.ProviderType, "Provider should have ProviderType info") require.NotNil(t, provider.Config, "Provider should have config info") - // If starter test it returns OK status if provider.ProviderID == "starter" { require.Equal(t, "OK", provider.Health.Status, "Provider should have OK health status") } - // Check that status is one of the allowed values require.Contains(t, []string{"OK", "Error", "Not Implemented"}, provider.Health.Status, "Provider health status should be one of: OK, Error, Not Implemented") - // There is no message for OK status if provider.Health.Status != "OK" { require.NotEmpty(t, provider.Health.Message, "Provider should have health message") } @@ -413,41 +430,35 @@ func validateProviders(t *testing.T, distribution *v1alpha1.LlamaStackDistributi } } -func testImageMappingOverrides(t *testing.T, distribution *v1alpha1.LlamaStackDistribution) { +func testImageMappingOverrides(t *testing.T, server *ogxiov1beta1.OGXServer) { t.Helper() - // Get the current deployment to save the original image deployment := &appsv1.Deployment{} require.NoError(t, TestEnv.Client.Get(TestEnv.Ctx, client.ObjectKey{ - Namespace: distribution.Namespace, - Name: distribution.Name, + Namespace: server.Namespace, + Name: server.Name, }, deployment)) originalImage := deployment.Spec.Template.Spec.Containers[0].Image - // Get the operator ConfigMap operatorConfigMap := &corev1.ConfigMap{} require.NoError(t, TestEnv.Client.Get(TestEnv.Ctx, client.ObjectKey{ Namespace: TestOpts.OperatorNS, - Name: "llama-stack-operator-config", + Name: "ogx-operator-config", }, operatorConfigMap)) - // Add image override for the distribution type - testOverrideImage := "quay.io/test/llama-stack:override-test" + testOverrideImage := "quay.io/test/ogx-server:override-test" if operatorConfigMap.Data == nil { operatorConfigMap.Data = make(map[string]string) } - operatorConfigMap.Data["image-overrides"] = distribution.Spec.Server.Distribution.Name + ": " + testOverrideImage + operatorConfigMap.Data["image-overrides"] = server.Spec.Distribution.Name + ": " + testOverrideImage - // Update the ConfigMap require.NoError(t, TestEnv.Client.Update(TestEnv.Ctx, operatorConfigMap), "Failed to update operator ConfigMap with image overrides") - // Wait for the operator to reconcile and update the deployment - // The ConfigMap change should trigger reconciliation of all distributions err := wait.PollUntilContextTimeout(TestEnv.Ctx, 10*time.Second, 2*time.Minute, true, func(ctx context.Context) (bool, error) { getErr := TestEnv.Client.Get(ctx, client.ObjectKey{ - Namespace: distribution.Namespace, - Name: distribution.Name, + Namespace: server.Namespace, + Name: server.Name, }, deployment) if getErr != nil { return false, getErr @@ -458,27 +469,24 @@ func testImageMappingOverrides(t *testing.T, distribution *v1alpha1.LlamaStackDi }) requireNoErrorWithDebugging(t, TestEnv, err, "Deployment should be updated with override image from ConfigMap", - distribution.Namespace, distribution.Name) + server.Namespace, server.Name) - // Verify the deployment is using the override image require.NoError(t, TestEnv.Client.Get(TestEnv.Ctx, client.ObjectKey{ - Namespace: distribution.Namespace, - Name: distribution.Name, + Namespace: server.Namespace, + Name: server.Name, }, deployment)) assert.Equal(t, testOverrideImage, deployment.Spec.Template.Spec.Containers[0].Image, "Deployment should use image from ConfigMap override") - // Test updating the override to a different image - updatedOverrideImage := "quay.io/test/llama-stack:override-test-v2" - operatorConfigMap.Data["image-overrides"] = distribution.Spec.Server.Distribution.Name + ": " + updatedOverrideImage + updatedOverrideImage := "quay.io/test/ogx-server:override-test-v2" + operatorConfigMap.Data["image-overrides"] = server.Spec.Distribution.Name + ": " + updatedOverrideImage require.NoError(t, TestEnv.Client.Update(TestEnv.Ctx, operatorConfigMap), "Failed to update operator ConfigMap with new image override") - // Wait for the deployment to update with the new override err = wait.PollUntilContextTimeout(TestEnv.Ctx, 10*time.Second, 2*time.Minute, true, func(ctx context.Context) (bool, error) { getErr := TestEnv.Client.Get(ctx, client.ObjectKey{ - Namespace: distribution.Namespace, - Name: distribution.Name, + Namespace: server.Namespace, + Name: server.Name, }, deployment) if getErr != nil { return false, getErr @@ -489,19 +497,16 @@ func testImageMappingOverrides(t *testing.T, distribution *v1alpha1.LlamaStackDi }) requireNoErrorWithDebugging(t, TestEnv, err, "Deployment should be updated with new override image from ConfigMap", - distribution.Namespace, distribution.Name) + server.Namespace, server.Name) - // Clean up - remove the image-overrides entry from the ConfigMap delete(operatorConfigMap.Data, "image-overrides") - require.NoError(t, TestEnv.Client.Update(TestEnv.Ctx, operatorConfigMap), "Failed to restore operator ConfigMap") - // Wait for the deployment to revert to the original image err = wait.PollUntilContextTimeout(TestEnv.Ctx, 10*time.Second, 2*time.Minute, true, func(ctx context.Context) (bool, error) { getErr := TestEnv.Client.Get(ctx, client.ObjectKey{ - Namespace: distribution.Namespace, - Name: distribution.Name, + Namespace: server.Namespace, + Name: server.Name, }, deployment) if getErr != nil { return false, getErr @@ -512,5 +517,5 @@ func testImageMappingOverrides(t *testing.T, distribution *v1alpha1.LlamaStackDi }) requireNoErrorWithDebugging(t, TestEnv, err, "Deployment should revert to original image after removing override", - distribution.Namespace, distribution.Name) + server.Namespace, server.Name) } diff --git a/tests/e2e/deletion_test.go b/tests/e2e/deletion_test.go index 3bad16f77..cbe034e1c 100644 --- a/tests/e2e/deletion_test.go +++ b/tests/e2e/deletion_test.go @@ -4,23 +4,21 @@ package e2e import ( "testing" - "github.com/llamastack/llama-stack-k8s-operator/api/v1alpha1" + ogxiov1beta1 "github.com/ogx-ai/ogx-k8s-operator/api/v1beta1" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/runtime/schema" "sigs.k8s.io/controller-runtime/pkg/client" ) -// runDeletionTests runs deletion tests for a specific distribution. -func runDeletionTests(t *testing.T, instance *v1alpha1.LlamaStackDistribution) { +// runDeletionTests runs deletion tests for a specific OGXServer. +func runDeletionTests(t *testing.T, instance *ogxiov1beta1.OGXServer) { t.Helper() - t.Run("should delete LlamaStackDistribution CR and cleanup resources", func(t *testing.T) { - // Delete the instance + t.Run("should delete OGXServer CR and cleanup resources", func(t *testing.T) { err := TestEnv.Client.Delete(TestEnv.Ctx, instance) require.NoError(t, err) - // Wait for deployment to be deleted err = EnsureResourceDeleted(t, TestEnv, schema.GroupVersionKind{ Group: "apps", Version: "v1", @@ -28,7 +26,6 @@ func runDeletionTests(t *testing.T, instance *v1alpha1.LlamaStackDistribution) { }, instance.Name, instance.Namespace, ResourceReadyTimeout) require.NoError(t, err, "Deployment should be deleted") - // Wait for service to be deleted err = EnsureResourceDeleted(t, TestEnv, schema.GroupVersionKind{ Group: "", Version: "v1", @@ -36,15 +33,13 @@ func runDeletionTests(t *testing.T, instance *v1alpha1.LlamaStackDistribution) { }, instance.Name+"-service", instance.Namespace, ResourceReadyTimeout) require.NoError(t, err, "Service should be deleted") - // Wait for CR to be deleted err = EnsureResourceDeleted(t, TestEnv, schema.GroupVersionKind{ - Group: "llamastack.io", - Version: "v1alpha1", - Kind: "LlamaStackDistribution", + Group: "ogx.io", + Version: "v1beta1", + Kind: "OGXServer", }, instance.Name, instance.Namespace, ResourceReadyTimeout) require.NoError(t, err, "CR should be deleted") - // Verify no orphaned resources podList := &corev1.PodList{} err = TestEnv.Client.List(TestEnv.Ctx, podList, client.InNamespace(instance.Namespace)) require.NoError(t, err) @@ -52,7 +47,6 @@ func runDeletionTests(t *testing.T, instance *v1alpha1.LlamaStackDistribution) { require.NotEqual(t, instance.Name, pod.Labels["app"], "Found orphaned pod") } - // Verify no orphaned configmaps configMapList := &corev1.ConfigMapList{} err = TestEnv.Client.List(TestEnv.Ctx, configMapList, client.InNamespace(instance.Namespace)) require.NoError(t, err) diff --git a/tests/e2e/e2e_test.go b/tests/e2e/e2e_test.go index b854c422c..43c41ef50 100644 --- a/tests/e2e/e2e_test.go +++ b/tests/e2e/e2e_test.go @@ -4,7 +4,7 @@ package e2e import ( "testing" - "github.com/llamastack/llama-stack-k8s-operator/api/v1alpha1" + ogxiov1beta1 "github.com/ogx-ai/ogx-k8s-operator/api/v1beta1" ) func TestE2E(t *testing.T) { @@ -13,11 +13,9 @@ func TestE2E(t *testing.T) { t.Run("validation", TestValidationSuite) // Run combined creation and deletion tests for multiple distributions - // starter: newer image currently being actively updated distributions := []string{"starter"} for _, dist := range distributions { t.Run("creation-deletion-"+dist, func(t *testing.T) { - // Set distribution type for this test run t.Logf("Testing distribution: %s", dist) runCreationDeletionSuiteForDistribution(t, dist) }) @@ -37,18 +35,16 @@ func runCreationDeletionSuiteForDistribution(t *testing.T, distType string) { } var creationFailed bool - var createdDistribution *v1alpha1.LlamaStackDistribution + var createdServer *ogxiov1beta1.OGXServer - // Run all creation tests t.Run("creation", func(t *testing.T) { - createdDistribution = runCreationTestsForDistribution(t, distType) + createdServer = runCreationTestsForDistribution(t, distType) creationFailed = t.Failed() }) - // Run deletion tests only if creation passed - if !creationFailed && !TestOpts.SkipDeletion && createdDistribution != nil { + if !creationFailed && !TestOpts.SkipDeletion && createdServer != nil { t.Run("deletion", func(t *testing.T) { - runDeletionTests(t, createdDistribution) + runDeletionTests(t, createdServer) }) } else { if TestOpts.SkipDeletion { diff --git a/tests/e2e/rollout_test.go b/tests/e2e/rollout_test.go index 261e731ec..ba8f0330c 100644 --- a/tests/e2e/rollout_test.go +++ b/tests/e2e/rollout_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "github.com/llamastack/llama-stack-k8s-operator/api/v1alpha1" + ogxiov1beta1 "github.com/ogx-ai/ogx-k8s-operator/api/v1beta1" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" appsv1 "k8s.io/api/apps/v1" @@ -19,16 +19,14 @@ import ( ) const ( - rolloutTestNS = "llama-stack-rollout-test" + rolloutTestNS = "ogx-rollout-test" rolloutTestTimeout = 5 * time.Minute rolloutCRName = "rollout-test" ollamaInferenceModelEnv = "OLLAMA_INFERENCE_MODEL" ) -// TestRolloutWithStorage verifies that updating a LlamaStackDistribution +// TestRolloutWithStorage verifies that updating an OGXServer // with persistent storage completes without RWO PVC multi-attach deadlock. -// Requires a multi-node cluster. Cordons the initial pod's node to force -// cross-node scheduling, guaranteeing the RWO conflict if strategy is wrong. func TestRolloutWithStorage(t *testing.T) { if TestOpts.SkipCreation { t.Skip("Skipping rollout test suite") @@ -44,8 +42,8 @@ func TestRolloutWithStorage(t *testing.T) { testCreateRolloutNamespace(t) }) - t.Run("should create distribution with storage", func(t *testing.T) { - testCreateDistributionWithStorage(t) + t.Run("should create OGXServer with storage", func(t *testing.T) { + testCreateServerWithStorage(t) }) t.Run("should use Recreate strategy when storage is configured", func(t *testing.T) { @@ -77,33 +75,33 @@ func testCreateRolloutNamespace(t *testing.T) { } } -func testCreateDistributionWithStorage(t *testing.T) { +func testCreateServerWithStorage(t *testing.T) { t.Helper() - cr := &v1alpha1.LlamaStackDistribution{ + replicas := int32(1) + cr := &ogxiov1beta1.OGXServer{ ObjectMeta: metav1.ObjectMeta{ Name: rolloutCRName, Namespace: rolloutTestNS, }, - Spec: v1alpha1.LlamaStackDistributionSpec{ - Replicas: 1, - Server: v1alpha1.ServerSpec{ - Distribution: v1alpha1.DistributionType{ - Name: starterDistType, - }, - ContainerSpec: v1alpha1.ContainerSpec{ - Name: "llama-stack", + Spec: ogxiov1beta1.OGXServerSpec{ + Distribution: ogxiov1beta1.DistributionSpec{ + Name: starterDistType, + }, + Workload: &ogxiov1beta1.WorkloadSpec{ + Replicas: &replicas, + Storage: &ogxiov1beta1.PVCStorageSpec{}, + Overrides: &ogxiov1beta1.WorkloadOverrides{ Env: []corev1.EnvVar{ {Name: ollamaInferenceModelEnv, Value: "llama3.2:1b"}, {Name: "OLLAMA_URL", Value: "http://ollama-server-service.ollama-dist.svc.cluster.local:11434"}, }, }, - Storage: &v1alpha1.StorageSpec{}, }, }, } - t.Log("Creating LlamaStackDistribution with persistent storage") + t.Log("Creating OGXServer with persistent storage") require.NoError(t, TestEnv.Client.Create(TestEnv.Ctx, cr)) err := EnsureResourceReady(t, TestEnv, schema.GroupVersionKind{ @@ -144,10 +142,8 @@ func testRolloutAfterEnvVarUpdate(t *testing.T) { cordonNodeForTest(t, initialNodeName) t.Log("Updating OLLAMA_INFERENCE_MODEL to trigger rollout") - updateDistributionEnvVar(t, rolloutTestNS, rolloutCRName, ollamaInferenceModelEnv, "llama3.2:3b") + updateServerEnvVar(t, rolloutTestNS, rolloutCRName, ollamaInferenceModelEnv, "llama3.2:3b") - // Poll until the operator reconciles the env var into the Deployment, - // otherwise the old (still-ready) Deployment passes readiness immediately. t.Log("Waiting for operator to update Deployment with new env var") waitForDeploymentEnvVar(t, rolloutTestNS, rolloutCRName, ollamaInferenceModelEnv, "llama3.2:3b") @@ -195,8 +191,6 @@ func testNoFailedAttachVolumeEvents(t *testing.T) { for _, event := range eventList.Items { if event.Reason == "FailedAttachVolume" { - // Transient during cross-node volume migration with Recreate strategy. - // A permanent deadlock (RollingUpdate) would have timed out above. t.Logf("Transient FailedAttachVolume (expected): %s", event.Message) } } @@ -205,13 +199,13 @@ func testNoFailedAttachVolumeEvents(t *testing.T) { func testRolloutCleanup(t *testing.T) { t.Helper() - distribution := &v1alpha1.LlamaStackDistribution{ + server := &ogxiov1beta1.OGXServer{ ObjectMeta: metav1.ObjectMeta{ Name: rolloutCRName, Namespace: rolloutTestNS, }, } - err := TestEnv.Client.Delete(TestEnv.Ctx, distribution) + err := TestEnv.Client.Delete(TestEnv.Ctx, server) if err != nil && !k8serrors.IsNotFound(err) { require.NoError(t, err) } @@ -222,8 +216,7 @@ func testRolloutCleanup(t *testing.T) { require.NoError(t, err, "Deployment should be deleted") } -// cordonNodeForTest marks a node as unschedulable and registers a -// t.Cleanup to uncordon it when the test finishes. +// cordonNodeForTest marks a node as unschedulable and registers cleanup. func cordonNodeForTest(t *testing.T, nodeName string) { t.Helper() @@ -249,22 +242,28 @@ func cordonNodeForTest(t *testing.T, nodeName string) { }) } -// updateDistributionEnvVar updates an env var on a LlamaStackDistribution, -// retrying on conflict. -func updateDistributionEnvVar(t *testing.T, namespace, name, envName, newValue string) { +// updateServerEnvVar updates an env var on an OGXServer, retrying on conflict. +func updateServerEnvVar(t *testing.T, namespace, name, envName, newValue string) { t.Helper() err := wait.PollUntilContextTimeout(TestEnv.Ctx, time.Second, 30*time.Second, true, func(ctx context.Context) (bool, error) { - latest := &v1alpha1.LlamaStackDistribution{} + latest := &ogxiov1beta1.OGXServer{} if getErr := TestEnv.Client.Get(ctx, client.ObjectKey{ Namespace: namespace, Name: name, }, latest); getErr != nil { return false, getErr } - for i, env := range latest.Spec.Server.ContainerSpec.Env { + if latest.Spec.Workload == nil { + latest.Spec.Workload = &ogxiov1beta1.WorkloadSpec{} + } + if latest.Spec.Workload.Overrides == nil { + latest.Spec.Workload.Overrides = &ogxiov1beta1.WorkloadOverrides{} + } + + for i, env := range latest.Spec.Workload.Overrides.Env { if env.Name == envName { - latest.Spec.Server.ContainerSpec.Env[i].Value = newValue + latest.Spec.Workload.Overrides.Env[i].Value = newValue break } } @@ -280,8 +279,7 @@ func updateDistributionEnvVar(t *testing.T, namespace, name, envName, newValue s require.NoError(t, err, "Failed to update CR env var "+envName) } -// waitForDeploymentEnvVar polls until the Deployment's container spec -// reflects the expected env var value. +// waitForDeploymentEnvVar polls until the Deployment reflects the expected env var value. func waitForDeploymentEnvVar(t *testing.T, namespace, name, envName, expectedValue string) { t.Helper() diff --git a/tests/e2e/test_options.go b/tests/e2e/test_options.go index 801727be7..52452ee94 100644 --- a/tests/e2e/test_options.go +++ b/tests/e2e/test_options.go @@ -22,7 +22,7 @@ func NewTestOptions() *TestOptions { SkipValidation: false, SkipCreation: false, SkipDeletion: false, - OperatorNS: "llama-stack-k8s-operator-system", + OperatorNS: "ogx-k8s-operator-system", } } diff --git a/tests/e2e/test_utils.go b/tests/e2e/test_utils.go index 38f572ece..89890d9f6 100644 --- a/tests/e2e/test_utils.go +++ b/tests/e2e/test_utils.go @@ -10,7 +10,7 @@ import ( "testing" "time" - "github.com/llamastack/llama-stack-k8s-operator/api/v1alpha1" + ogxiov1beta1 "github.com/ogx-ai/ogx-k8s-operator/api/v1beta1" "github.com/stretchr/testify/require" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" @@ -49,13 +49,11 @@ type TestEnvironment struct { // SetupTestEnv sets up the test environment. func SetupTestEnv() (*TestEnvironment, error) { - // Get a config to talk to the apiserver cfg, err := config.GetConfig() if err != nil { return nil, err } - // Create a new client cl, err := client.New(cfg, client.Options{Scheme: Scheme}) if err != nil { return nil, err @@ -154,7 +152,6 @@ func WaitForPodsReady(t *testing.T, testenv *TestEnvironment, namespace, deploym defer cancel() return wait.PollUntilContextTimeout(ctx, pollInterval, timeout, true, func(ctx context.Context) (bool, error) { - // Get pods for the deployment podList, err := GetPodsForDeployment(testenv, ctx, namespace, deploymentName) if err != nil { t.Logf("Error listing pods: %v", err) @@ -166,7 +163,6 @@ func WaitForPodsReady(t *testing.T, testenv *TestEnvironment, namespace, deploym return false, nil } - // Check each pod's status for _, pod := range podList.Items { ready, err := checkPodStatus(t, &pod) if err != nil { @@ -187,19 +183,16 @@ func checkPodStatus(t *testing.T, pod *corev1.Pod) (bool, error) { t.Helper() t.Logf("Pod %s status: Phase=%s, Ready=%v", pod.Name, pod.Status.Phase, isPodReady(pod)) - // Check if pod is running if pod.Status.Phase != corev1.PodRunning && pod.Status.Phase != corev1.PodSucceeded { t.Logf("Pod %s not running yet (phase: %s)", pod.Name, pod.Status.Phase) return false, nil } - // Check if pod is ready if !isPodReady(pod) { t.Logf("Pod %s not ready yet", pod.Name) return false, nil } - // Check container statuses for errors return checkContainerStatuses(t, pod) } @@ -213,7 +206,6 @@ func checkContainerStatuses(t *testing.T, pod *corev1.Pod) (bool, error) { containerStatus.State.Waiting.Reason, containerStatus.State.Waiting.Message) - // Fail fast on image pull errors or crash loops if containerStatus.State.Waiting.Reason == "ImagePullBackOff" || containerStatus.State.Waiting.Reason == "ErrImagePull" || containerStatus.State.Waiting.Reason == "CrashLoopBackOff" { @@ -266,7 +258,7 @@ func registerSchemes() { schemes := []func(*runtime.Scheme) error{ clientgoscheme.AddToScheme, apiextv1.AddToScheme, - v1alpha1.AddToScheme, + ogxiov1beta1.AddToScheme, } for _, schemeFn := range schemes { @@ -274,53 +266,82 @@ func registerSchemes() { } } -// GetSampleCRForDistribution returns a LlamaStackDistribution configured for the specified distribution type. -func GetSampleCRForDistribution(t *testing.T, distType string) *v1alpha1.LlamaStackDistribution { +// EnsureOverrideConfigMap creates the ConfigMap referenced by the CR's overrideConfig +// if one is configured. The ConfigMap must exist before the CR is created. +func EnsureOverrideConfigMap(t *testing.T, c client.Client, ctx context.Context, server *ogxiov1beta1.OGXServer) { + t.Helper() + + if server.Spec.OverrideConfig == nil || server.Spec.OverrideConfig.Name == "" { + return + } + + projectRoot, err := filepath.Abs("../..") + require.NoError(t, err) + + configMapPath := filepath.Join(projectRoot, "config", "samples", "starter-config-configmap.yaml") + yamlFile, err := os.ReadFile(configMapPath) + require.NoError(t, err) + + cm := &corev1.ConfigMap{} + err = yaml.Unmarshal(yamlFile, cm) + require.NoError(t, err) + + cm.Namespace = server.Namespace + err = c.Create(ctx, cm) + if err != nil && !errors.IsAlreadyExists(err) { + require.NoError(t, err) + } +} + +// GetSampleCRForDistribution returns an OGXServer configured for the specified distribution type. +func GetSampleCRForDistribution(t *testing.T, distType string) *ogxiov1beta1.OGXServer { t.Helper() - // Get the absolute path of the project root projectRoot, err := filepath.Abs("../..") require.NoError(t, err) - // Construct the path to the sample file - samplePath := filepath.Join(projectRoot, "config", "samples", "_v1alpha1_llamastackdistribution.yaml") + samplePath := filepath.Join(projectRoot, "config", "samples", "_v1beta1_ogxserver.yaml") - // Read the sample file yamlFile, err := os.ReadFile(samplePath) require.NoError(t, err) - // Create and unmarshal the distribution - distribution := &v1alpha1.LlamaStackDistribution{} - err = yaml.Unmarshal(yamlFile, distribution) + server := &ogxiov1beta1.OGXServer{} + err = yaml.Unmarshal(yamlFile, server) require.NoError(t, err) - // Modify the distribution based on the type switch distType { case starterDistType: - distribution.Spec.Server.Distribution.Name = starterDistType - distribution.ObjectMeta.Name = "llamastackdistribution-" + starterDistType + "-sample" + server.Spec.Distribution.Name = starterDistType + server.ObjectMeta.Name = "ogxserver-" + starterDistType + "-sample" default: t.Fatalf("Unknown distribution type: %s", distType) } - return distribution + if server.Spec.Workload != nil { + server.Spec.Workload.Autoscaling = nil + server.Spec.Workload.Storage = nil + server.Spec.Workload.PodDisruptionBudget = nil + server.Spec.Workload.TopologySpreadConstraints = nil + } + + return server } -// checkLlamaStackDistributionStatus helps identify if the custom resource reached the expected state during test execution. -func checkLlamaStackDistributionStatus(t *testing.T, testenv *TestEnvironment, namespace, name string) { +// checkOGXServerStatus helps identify if the custom resource reached the expected state during test execution. +func checkOGXServerStatus(t *testing.T, testenv *TestEnvironment, namespace, name string) { t.Helper() - llsDistro := &v1alpha1.LlamaStackDistribution{} - err := testenv.Client.Get(testenv.Ctx, client.ObjectKey{Namespace: namespace, Name: name}, llsDistro) + ogxServer := &ogxiov1beta1.OGXServer{} + err := testenv.Client.Get(testenv.Ctx, client.ObjectKey{Namespace: namespace, Name: name}, ogxServer) if err != nil { - t.Logf("⚠️ Error getting LlamaStackDistribution: %v", err) + t.Logf("Error getting OGXServer: %v", err) return } - t.Logf("LlamaStackDistribution status:") - t.Logf(" Phase: %s", llsDistro.Status.Phase) - t.Logf(" Generation: %d", llsDistro.Generation) - t.Logf(" ResourceVersion: %s", llsDistro.ResourceVersion) - t.Logf(" Conditions: %+v", llsDistro.Status.Conditions) + t.Logf("OGXServer status:") + t.Logf(" Phase: %s", ogxServer.Status.Phase) + t.Logf(" Generation: %d", ogxServer.Generation) + t.Logf(" ResourceVersion: %s", ogxServer.ResourceVersion) + t.Logf(" Conditions: %+v", ogxServer.Status.Conditions) } // checkNamespaceEvents reveals what Kubernetes operations occurred and why they may have failed. @@ -330,21 +351,21 @@ func checkNamespaceEvents(t *testing.T, testenv *TestEnvironment, namespace stri eventList := &corev1.EventList{} err := testenv.Client.List(testenv.Ctx, eventList, client.InNamespace(namespace)) if err != nil { - t.Logf("⚠️ Error getting events: %v", err) + t.Logf("Error getting events: %v", err) return } if len(eventList.Items) == 0 { - t.Log("📝 No events found in namespace") + t.Log("No events found in namespace") return } maxEvents := 25 if len(eventList.Items) > maxEvents { - t.Logf("📝 Showing first %d events (of %d total):", maxEvents, len(eventList.Items)) + t.Logf("Showing first %d events (of %d total):", maxEvents, len(eventList.Items)) eventList.Items = eventList.Items[:maxEvents] } else { - t.Logf("📝 Found %d events in namespace %s:", len(eventList.Items), namespace) + t.Logf("Found %d events in namespace %s:", len(eventList.Items), namespace) } for _, event := range eventList.Items { @@ -356,28 +377,17 @@ func checkNamespaceEvents(t *testing.T, testenv *TestEnvironment, namespace stri } } -// requireNoErrorWithDebugging provides comprehensive debugging context when tests fail to help identify root causes quickly. +// requireNoErrorWithDebugging provides comprehensive debugging context when tests fail. func requireNoErrorWithDebugging(t *testing.T, testenv *TestEnvironment, err error, msg string, namespace, crName string) { t.Helper() if err != nil { - t.Logf("💥 ERROR OCCURRED: %s - %v", msg, err) - - // Check custom resource status first to see if the operator processed the request correctly - checkLlamaStackDistributionStatus(t, testenv, namespace, crName) + t.Logf("ERROR OCCURRED: %s - %v", msg, err) - // Check events to understand what Kubernetes operations were attempted and why they failed + checkOGXServerStatus(t, testenv, namespace, crName) checkNamespaceEvents(t, testenv, namespace) - - // Check pod details to identify container startup issues or crash loops logPodDetails(t, testenv, namespace) - - // Check service endpoints to see if pods are being discovered by services logServiceEndpoints(t, testenv, namespace, crName+"-service") - - // Check service configuration to identify selector mismatches logServiceSpec(t, testenv, namespace, crName+"-service") - - // Check deployment spec to identify configuration problems preventing pod startup logDeploymentSpec(t, testenv, namespace, crName) require.NoError(t, err, msg) @@ -395,16 +405,14 @@ func logPodDetails(t *testing.T, testenv *TestEnvironment, namespace string) { return } - t.Logf("📦 Found %d pods in namespace %s:", len(podList.Items), namespace) + t.Logf("Found %d pods in namespace %s:", len(podList.Items), namespace) for _, pod := range podList.Items { t.Logf("Pod: %s, Phase: %s", pod.Name, pod.Status.Phase) for _, cs := range pod.Status.ContainerStatuses { - // RestartCount indicates crash loops or configuration issues t.Logf(" Container %s: Ready=%v, RestartCount=%d", cs.Name, cs.Ready, cs.RestartCount) - // Container states reveal why pods aren't starting or are crashing if cs.State.Waiting != nil { t.Logf(" Waiting: %s - %s", cs.State.Waiting.Reason, cs.State.Waiting.Message) @@ -415,7 +423,6 @@ func logPodDetails(t *testing.T, testenv *TestEnvironment, namespace string) { } } - // Pod logs would show startup errors but require different client access t.Logf(" (Pod logs require direct kubectl access)") } } @@ -424,7 +431,6 @@ func logPodDetails(t *testing.T, testenv *TestEnvironment, namespace string) { func logServiceEndpoints(t *testing.T, testenv *TestEnvironment, namespace, serviceName string) { t.Helper() - // List all EndpointSlices for the service endpointSliceList := &discoveryv1.EndpointSliceList{} err := testenv.Client.List(testenv.Ctx, endpointSliceList, client.InNamespace(namespace), @@ -436,11 +442,11 @@ func logServiceEndpoints(t *testing.T, testenv *TestEnvironment, namespace, serv } if len(endpointSliceList.Items) == 0 { - t.Logf("🔗 Service %s has no endpoint slices", serviceName) + t.Logf("Service %s has no endpoint slices", serviceName) return } - t.Logf("🔗 Service %s endpoints:", serviceName) + t.Logf("Service %s endpoints:", serviceName) for i, slice := range endpointSliceList.Items { t.Logf(" EndpointSlice %d (%s):", i, slice.Name) logEndpointSliceDetails(t, &slice) @@ -511,9 +517,8 @@ func logDeploymentSpec(t *testing.T, testenv *TestEnvironment, namespace, name s return } - t.Logf("🚀 Deployment %s spec:", name) + t.Logf("Deployment %s spec:", name) t.Logf(" Replicas: %d", *deployment.Spec.Replicas) - // Selector must match pod labels or pods won't be managed by deployment t.Logf(" Selector: %+v", deployment.Spec.Selector.MatchLabels) t.Logf(" Template labels: %+v", deployment.Spec.Template.Labels) @@ -524,18 +529,82 @@ func logDeploymentSpec(t *testing.T, testenv *TestEnvironment, namespace, name s for _, port := range container.Ports { t.Logf(" - %d", port.ContainerPort) } - // Environment variables can cause startup failures if misconfigured t.Logf(" Env vars:") for _, env := range container.Env { t.Logf(" %s=%s", env.Name, env.Value) } - // Readiness probe configuration affects when pods become service endpoints if container.ReadinessProbe != nil { t.Logf(" Readiness probe: %+v", container.ReadinessProbe) } } } +// logOperatorPodStatus logs operator pod details to diagnose webhook failures +// caused by pod restarts, OOMKills, or CrashLoopBackOff. +func logOperatorPodStatus(t *testing.T, testenv *TestEnvironment, operatorNS string) { + t.Helper() + + podList := &corev1.PodList{} + err := testenv.Client.List(testenv.Ctx, podList, + client.InNamespace(operatorNS), + client.MatchingLabels{"control-plane": "controller-manager"}) + if err != nil { + t.Logf("Failed to list operator pods: %v", err) + return + } + + for _, pod := range podList.Items { + t.Logf("Operator pod %s: Phase=%s", pod.Name, pod.Status.Phase) + for _, cs := range pod.Status.ContainerStatuses { + t.Logf(" Container %s: Ready=%v, RestartCount=%d", cs.Name, cs.Ready, cs.RestartCount) + if cs.LastTerminationState.Terminated != nil { + t.Logf(" Last termination: Reason=%s, ExitCode=%d, At=%s", + cs.LastTerminationState.Terminated.Reason, + cs.LastTerminationState.Terminated.ExitCode, + cs.LastTerminationState.Terminated.FinishedAt.String()) + } + } + } +} + +// WaitForWebhookReady polls until the webhook service endpoint has at least one ready address. +// This prevents "connection refused" errors when creating OGXServer resources before +// the operator's webhook server is accepting connections. +func WaitForWebhookReady(t *testing.T, testenv *TestEnvironment, operatorNS string, timeout time.Duration) error { + t.Helper() + t.Log("Waiting for webhook endpoint to become ready...") + + webhookServiceName := "ogx-k8s-operator-webhook-service" + + return wait.PollUntilContextTimeout(testenv.Ctx, generalRetryInterval, timeout, true, func(ctx context.Context) (bool, error) { + endpointSliceList := &discoveryv1.EndpointSliceList{} + err := testenv.Client.List(ctx, endpointSliceList, + client.InNamespace(operatorNS), + client.MatchingLabels{"kubernetes.io/service-name": webhookServiceName}) + if err != nil { + t.Logf("Failed to list endpoint slices for webhook service: %v", err) + return false, nil + } + + if len(endpointSliceList.Items) == 0 { + t.Log("No endpoint slices found for webhook service yet") + return false, nil + } + + for _, slice := range endpointSliceList.Items { + for _, endpoint := range slice.Endpoints { + if endpoint.Conditions.Ready != nil && *endpoint.Conditions.Ready { + t.Log("Webhook endpoint is ready") + return true, nil + } + } + } + + t.Log("Webhook endpoint exists but no ready addresses yet") + return false, nil + }) +} + // logServiceSpec logs the actual service configuration to debug selector issues. func logServiceSpec(t *testing.T, testenv *TestEnvironment, namespace, serviceName string) { t.Helper() @@ -551,9 +620,8 @@ func logServiceSpec(t *testing.T, testenv *TestEnvironment, namespace, serviceNa return } - t.Logf("🔧 Service %s spec:", serviceName) + t.Logf("Service %s spec:", serviceName) t.Logf(" Type: %s", service.Spec.Type) - // Selector must match pod labels or service won't route traffic to pods t.Logf(" Selector: %+v", service.Spec.Selector) t.Logf(" Ports:") for _, port := range service.Spec.Ports { diff --git a/tests/e2e/tls_test.go b/tests/e2e/tls_test.go index 3265e45f5..687c435fe 100644 --- a/tests/e2e/tls_test.go +++ b/tests/e2e/tls_test.go @@ -12,8 +12,8 @@ import ( "testing" "time" - "github.com/llamastack/llama-stack-k8s-operator/api/v1alpha1" - "github.com/llamastack/llama-stack-k8s-operator/controllers" + ogxiov1beta1 "github.com/ogx-ai/ogx-k8s-operator/api/v1beta1" + "github.com/ogx-ai/ogx-k8s-operator/controllers" "github.com/stretchr/testify/require" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" @@ -28,7 +28,7 @@ import ( const ( tlsTestTimeout = 5 * time.Minute - llsTestNS = "llama-stack-test" + ogxTestNS = "ogx-test" ) func TestTLSSuite(t *testing.T) { @@ -36,7 +36,6 @@ func TestTLSSuite(t *testing.T) { t.Skip("Skipping TLS test suite") } - // Generate certificates before running any tests t.Run("should generate certificates", func(t *testing.T) { generateCertificates(t) }) @@ -45,8 +44,8 @@ func TestTLSSuite(t *testing.T) { testCreateNamespace(t) }) - t.Run("should create LlamaStackDistribution with CA bundle", func(t *testing.T) { - testLlamaStackWithCABundle(t) + t.Run("should create OGXServer with CA bundle", func(t *testing.T) { + testOGXServerWithCABundle(t) }) t.Run("should cleanup TLS resources", func(t *testing.T) { @@ -57,10 +56,9 @@ func TestTLSSuite(t *testing.T) { func testCreateNamespace(t *testing.T) { t.Helper() - // Create test namespace testNs := &corev1.Namespace{ ObjectMeta: metav1.ObjectMeta{ - Name: llsTestNS, + Name: ogxTestNS, }, } err := TestEnv.Client.Create(TestEnv.Ctx, testNs) @@ -68,92 +66,96 @@ func testCreateNamespace(t *testing.T) { require.NoError(t, err) } - // Create CA bundle configmap in test namespace - err = createCABundleConfigMap(t, llsTestNS) + ensureStarterConfigMap(t, ogxTestNS) + + err = createCABundleConfigMap(t, ogxTestNS) require.NoError(t, err) - // Verify the CA bundle ConfigMap was created correctly - err = verifyCABundleConfigMap(t, llsTestNS) + err = verifyCABundleConfigMap(t, ogxTestNS) require.NoError(t, err) } -func testLlamaStackWithCABundle(t *testing.T) { +func ensureStarterConfigMap(t *testing.T, namespace string) { t.Helper() - // Deploy LlamaStackDistribution with CA bundle - err := deployLlamaStackWithCABundle(t) + projectRoot, err := filepath.Abs("../..") require.NoError(t, err) - // The YAML file creates a placeholder ConfigMap, so we need to update it with the actual CA bundle - err = updateCABundleConfigMap(t, llsTestNS) + configMapPath := filepath.Join(projectRoot, "config", "samples", "starter-config-configmap.yaml") + yamlFile, err := os.ReadFile(configMapPath) require.NoError(t, err) - // Verify the CA bundle ConfigMap has the correct content after update - err = verifyCABundleConfigMap(t, llsTestNS) + cm := &corev1.ConfigMap{} + require.NoError(t, yaml.Unmarshal(yamlFile, cm)) + + cm.Namespace = namespace + err = TestEnv.Client.Create(TestEnv.Ctx, cm) + if err != nil && !k8serrors.IsAlreadyExists(err) { + require.NoError(t, err) + } +} + +func testOGXServerWithCABundle(t *testing.T) { + t.Helper() + + err := deployOGXServerWithCABundle(t) require.NoError(t, err) - // Verify the LlamaStack distribution is configured with TLS - err = verifyLlamaStackTLSConfig(t, llsTestNS, "llamastack-with-config") + err = updateCABundleConfigMap(t, ogxTestNS) require.NoError(t, err) - // Wait for the operator to process the LlamaStackDistribution and create the deployment - err = waitForDeploymentCreation(t, llsTestNS, "llamastack-with-config", 3*time.Minute) - require.NoError(t, err, "LlamaStack deployment should be created by operator") + err = verifyCABundleConfigMap(t, ogxTestNS) + require.NoError(t, err) - // Wait for pods to be running and ready - err = WaitForPodsReady(t, TestEnv, llsTestNS, "llamastack-with-config", 5*time.Minute) - require.NoError(t, err, "LlamaStack pods should be running and ready") + err = verifyOGXServerCABundleConfig(t, ogxTestNS, "ogxserver-with-ca-bundle") + require.NoError(t, err) - // Verify certificate volumes are mounted correctly - err = verifyCertificateMounts(t, llsTestNS, "llamastack-with-config") + err = waitForDeploymentCreation(t, ogxTestNS, "ogxserver-with-ca-bundle", 3*time.Minute) + require.NoError(t, err, "OGXServer deployment should be created by operator") + + err = WaitForPodsReady(t, TestEnv, ogxTestNS, "ogxserver-with-ca-bundle", 5*time.Minute) + require.NoError(t, err, "OGXServer pods should be running and ready") + + err = verifyCertificateMounts(t, ogxTestNS, "ogxserver-with-ca-bundle") require.NoError(t, err, "Certificate volumes should be mounted correctly") - // Verify environment variables are set correctly - err = verifyEnvironmentVariables(t, llsTestNS, "llamastack-with-config") + err = verifyEnvironmentVariables(t, ogxTestNS, "ogxserver-with-ca-bundle") require.NoError(t, err, "Environment variables should be set correctly") } func testTLSCleanup(t *testing.T) { t.Helper() - // Delete LlamaStackDistribution - distribution := &v1alpha1.LlamaStackDistribution{ + server := &ogxiov1beta1.OGXServer{ ObjectMeta: metav1.ObjectMeta{ - Name: "llamastack-with-config", - Namespace: llsTestNS, + Name: "ogxserver-with-ca-bundle", + Namespace: ogxTestNS, }, } - err := TestEnv.Client.Delete(TestEnv.Ctx, distribution) + err := TestEnv.Client.Delete(TestEnv.Ctx, server) if err != nil && !k8serrors.IsNotFound(err) { require.NoError(t, err) } - // Wait for LlamaStack resources to be cleaned up err = EnsureResourceDeleted(t, TestEnv, schema.GroupVersionKind{ Group: "apps", Version: "v1", Kind: "Deployment", - }, "llamastack-with-config", llsTestNS, ResourceReadyTimeout) - require.NoError(t, err, "LlamaStack deployment should be deleted") + }, "ogxserver-with-ca-bundle", ogxTestNS, ResourceReadyTimeout) + require.NoError(t, err, "OGXServer deployment should be deleted") } -// Helper functions - func generateCertificates(t *testing.T) { t.Helper() - // Get the project root path projectRoot, err := filepath.Abs("../..") require.NoError(t, err, "Failed to get project root") - // Run the certificate generation script scriptPath := filepath.Join(projectRoot, "config", "samples", "generate_certificates.sh") t.Logf("Running certificate generation script: %s", scriptPath) - // Change to the project root directory to run the script t.Chdir(projectRoot) - // Execute the script cmd := exec.CommandContext(t.Context(), "bash", scriptPath) output, err := cmd.CombinedOutput() if err != nil { @@ -167,13 +169,11 @@ func generateCertificates(t *testing.T) { func createCABundleConfigMap(t *testing.T, targetNS string) error { t.Helper() - // Get the project root path projectRoot, err := filepath.Abs("../..") if err != nil { return fmt.Errorf("failed to get project root: %w", err) } - // Read CA bundle caBundle, err := os.ReadFile(filepath.Join(projectRoot, "config", "samples", "vllm-ca-certs", controllers.DefaultCABundleKey)) if err != nil { return fmt.Errorf("failed to read CA bundle: %w", err) @@ -183,17 +183,18 @@ func createCABundleConfigMap(t *testing.T, targetNS string) error { ObjectMeta: metav1.ObjectMeta{ Name: "custom-ca-bundle", Namespace: targetNS, + Labels: map[string]string{ + controllers.WatchLabelKey: controllers.WatchLabelValue, + }, }, Data: map[string]string{ controllers.DefaultCABundleKey: string(caBundle), }, } - // Try to create, if it exists, update it err = TestEnv.Client.Create(TestEnv.Ctx, caBundleConfigMap) if err != nil { if k8serrors.IsAlreadyExists(err) { - // ConfigMap exists, update it existingConfigMap := &corev1.ConfigMap{} err = TestEnv.Client.Get(TestEnv.Ctx, client.ObjectKey{ Namespace: targetNS, @@ -221,7 +222,6 @@ func createCABundleConfigMap(t *testing.T, targetNS string) error { func verifyCABundleConfigMap(t *testing.T, targetNS string) error { t.Helper() - // Get the ConfigMap configMap := &corev1.ConfigMap{} err := TestEnv.Client.Get(TestEnv.Ctx, client.ObjectKey{ Namespace: targetNS, @@ -231,7 +231,6 @@ func verifyCABundleConfigMap(t *testing.T, targetNS string) error { return fmt.Errorf("failed to get CA bundle ConfigMap: %w", err) } - // Verify the CA bundle content exists caBundle, exists := configMap.Data[controllers.DefaultCABundleKey] if !exists { return fmt.Errorf("failed to find %s CA bundle key in ConfigMap", controllers.DefaultCABundleKey) @@ -241,12 +240,10 @@ func verifyCABundleConfigMap(t *testing.T, targetNS string) error { return fmt.Errorf("failed to find any keys in CA bundle ConfigMap %s", controllers.DefaultCABundleKey) } - // Check if CA bundle appears to be a placeholder if len(caBundle) < 100 || !strings.Contains(caBundle, "BEGIN CERTIFICATE") { t.Logf("WARNING: CA bundle appears to be a placeholder or invalid") t.Logf("CA bundle content: %s", caBundle) - // Try to update the ConfigMap with the actual CA bundle from the file err := updateCABundleConfigMap(t, targetNS) if err != nil { t.Logf("Failed to update CA bundle ConfigMap: %v", err) @@ -256,26 +253,20 @@ func verifyCABundleConfigMap(t *testing.T, targetNS string) error { return nil } -func verifyLlamaStackTLSConfig(t *testing.T, namespace, name string) error { +func verifyOGXServerCABundleConfig(t *testing.T, namespace, name string) error { t.Helper() - // Get the LlamaStack distribution - distribution := &v1alpha1.LlamaStackDistribution{} + server := &ogxiov1beta1.OGXServer{} err := TestEnv.Client.Get(TestEnv.Ctx, client.ObjectKey{ Namespace: namespace, Name: name, - }, distribution) + }, server) if err != nil { - return fmt.Errorf("failed to get LlamaStack distribution: %w", err) + return fmt.Errorf("failed to get OGXServer: %w", err) } - // Verify TLS configuration is present - if distribution.Spec.Server.TLSConfig == nil { - return errors.New("LlamaStack distribution does not have TLS config") - } - - if distribution.Spec.Server.TLSConfig.CABundle == nil { - return errors.New("LlamaStack distribution TLS config does not have CA bundle") + if server.Spec.TLS == nil || server.Spec.TLS.Trust == nil || len(server.Spec.TLS.Trust.CACertificates) == 0 { + return errors.New("OGXServer does not have CA bundle config (spec.tls.trust.caCertificates)") } return nil @@ -284,19 +275,16 @@ func verifyLlamaStackTLSConfig(t *testing.T, namespace, name string) error { func updateCABundleConfigMap(t *testing.T, targetNS string) error { t.Helper() - // Get the project root path projectRoot, err := filepath.Abs("../..") if err != nil { return fmt.Errorf("failed to get project root: %w", err) } - // Read the actual CA bundle from the file actualCABundle, err := os.ReadFile(filepath.Join(projectRoot, "config", "samples", "vllm-ca-certs", controllers.DefaultCABundleKey)) if err != nil { return fmt.Errorf("failed to read CA bundle file: %w", err) } - // Get the existing ConfigMap configMap := &corev1.ConfigMap{} err = TestEnv.Client.Get(TestEnv.Ctx, client.ObjectKey{ Namespace: targetNS, @@ -306,7 +294,6 @@ func updateCABundleConfigMap(t *testing.T, targetNS string) error { return fmt.Errorf("failed to get ConfigMap: %w", err) } - // Update the ConfigMap with the actual CA bundle configMap.Data[controllers.DefaultCABundleKey] = string(actualCABundle) err = TestEnv.Client.Update(TestEnv.Ctx, configMap) @@ -317,46 +304,63 @@ func updateCABundleConfigMap(t *testing.T, targetNS string) error { return nil } -func deployLlamaStackWithCABundle(t *testing.T) error { +func deployOGXServerWithCABundle(t *testing.T) error { t.Helper() - // Read LlamaStack TLS test configuration + logOperatorPodStatus(t, TestEnv, TestOpts.OperatorNS) + + if err := WaitForWebhookReady(t, TestEnv, TestOpts.OperatorNS, 2*time.Minute); err != nil { + logOperatorPodStatus(t, TestEnv, TestOpts.OperatorNS) + return fmt.Errorf("failed to wait for webhook readiness: %w", err) + } + projectRoot, err := filepath.Abs("../..") if err != nil { return fmt.Errorf("failed to get project root: %w", err) } - llamaStackConfigPath := filepath.Join(projectRoot, "config", "samples", "example-with-ca-bundle.yaml") - llamaStackConfigData, err := os.ReadFile(llamaStackConfigPath) + configPath := filepath.Join(projectRoot, "config", "samples", "example-with-ca-bundle.yaml") + configData, err := os.ReadFile(configPath) if err != nil { - return fmt.Errorf("failed to read LlamaStack config: %w", err) + return fmt.Errorf("failed to read OGXServer config: %w", err) } - // Apply LlamaStack configuration - objects, err := parseKubernetesYAML(llamaStackConfigData) + objects, err := parseKubernetesYAML(configData) if err != nil { - return fmt.Errorf("failed to parse LlamaStack config: %w", err) + return fmt.Errorf("failed to parse OGXServer config: %w", err) } for _, obj := range objects { - // Set the namespace for namespaced resources if obj.GetNamespace() == "" { - obj.SetNamespace(llsTestNS) + obj.SetNamespace(ogxTestNS) } - - err = TestEnv.Client.Create(TestEnv.Ctx, obj) - if err != nil && !k8serrors.IsAlreadyExists(err) { - return fmt.Errorf("failed to create LlamaStack resource: %w", err) + if err := createObjectWithWebhookRetry(t, obj); err != nil { + return fmt.Errorf("failed to create OGXServer resource: %w", err) } } return nil } +func createObjectWithWebhookRetry(t *testing.T, obj client.Object) error { + t.Helper() + + return wait.PollUntilContextTimeout(TestEnv.Ctx, 5*time.Second, 2*time.Minute, true, func(ctx context.Context) (bool, error) { + err := TestEnv.Client.Create(ctx, obj) + if err == nil || k8serrors.IsAlreadyExists(err) { + return true, nil + } + if strings.Contains(err.Error(), "connection refused") || strings.Contains(err.Error(), "webhook") { + t.Logf("Webhook not ready yet, retrying: %v", err) + return false, nil + } + return false, err + }) +} + func verifyCertificateMounts(t *testing.T, namespace, name string) error { t.Helper() - // Get the deployment deployment := &appsv1.Deployment{} err := TestEnv.Client.Get(TestEnv.Ctx, client.ObjectKey{ Namespace: namespace, @@ -366,12 +370,10 @@ func verifyCertificateMounts(t *testing.T, namespace, name string) error { return fmt.Errorf("failed to get deployment: %w", err) } - // Check if CA bundle volume is defined if !hasCABundleVolume(deployment.Spec.Template.Spec.Volumes) { return errors.New("CA bundle volume not found in deployment") } - // Check if CA bundle is mounted in any container if !hasCABundleMount(deployment.Spec.Template.Spec.Containers) { return errors.New("CA bundle mount not found in any container") } @@ -381,8 +383,6 @@ func verifyCertificateMounts(t *testing.T, namespace, name string) error { func hasCABundleVolume(volumes []corev1.Volume) bool { for _, volume := range volumes { - // Check for the managed CA bundle ConfigMap (named {instance-name}-ca-bundle) - // or the source CA bundle ConfigMap if volume.ConfigMap != nil && (strings.HasSuffix(volume.ConfigMap.Name, "-ca-bundle") || volume.ConfigMap.Name == "custom-ca-bundle") { @@ -415,7 +415,6 @@ func hasCABundleMountInContainer(mounts []corev1.VolumeMount) bool { func verifyEnvironmentVariables(t *testing.T, namespace, name string) error { t.Helper() - // Get the deployment deployment := &appsv1.Deployment{} err := TestEnv.Client.Get(TestEnv.Ctx, client.ObjectKey{ Namespace: namespace, @@ -425,7 +424,6 @@ func verifyEnvironmentVariables(t *testing.T, namespace, name string) error { return fmt.Errorf("failed to get deployment: %w", err) } - // Check for TLS-related environment variables tlsEnvVarsFound := 0 expectedEnvVars := map[string]string{ "SSL_CERT_FILE": controllers.ManagedCABundleFilePath, @@ -452,10 +450,7 @@ func verifyEnvironmentVariables(t *testing.T, namespace, name string) error { } func parseKubernetesYAML(data []byte) ([]client.Object, error) { - // Split YAML documents docs := yamlSplit(data) - - // Pre-allocate slice with expected capacity objects := make([]client.Object, 0, len(docs)) for _, doc := range docs { @@ -506,20 +501,18 @@ func waitForDeploymentCreation(t *testing.T, namespace, name string, timeout tim t.Helper() return wait.PollUntilContextTimeout(TestEnv.Ctx, 10*time.Second, timeout, true, func(ctx context.Context) (bool, error) { - // First check if the LlamaStackDistribution is being processed - distribution := &v1alpha1.LlamaStackDistribution{} + server := &ogxiov1beta1.OGXServer{} err := TestEnv.Client.Get(ctx, client.ObjectKey{ Namespace: namespace, Name: name, - }, distribution) + }, server) if err != nil { - t.Logf("LlamaStackDistribution not found yet: %v", err) + t.Logf("OGXServer not found yet: %v", err) return false, nil } - t.Logf("LlamaStackDistribution status: Phase=%s", distribution.Status.Phase) + t.Logf("OGXServer status: Phase=%s", server.Status.Phase) - // Then check if the deployment has been created deployment := &appsv1.Deployment{} err = TestEnv.Client.Get(ctx, client.ObjectKey{ Namespace: namespace, diff --git a/tests/e2e/validation_test.go b/tests/e2e/validation_test.go index 7b942f3fa..b50b990ab 100644 --- a/tests/e2e/validation_test.go +++ b/tests/e2e/validation_test.go @@ -15,12 +15,12 @@ func TestValidationSuite(t *testing.T) { } t.Run("should validate CRDs", func(t *testing.T) { - err := validateCRD(TestEnv.Client, TestEnv.Ctx, "llamastackdistributions.llamastack.io") - require.NoErrorf(t, err, "error in validating CRD: llamastackdistributions.llamastack.io") + err := validateCRD(TestEnv.Client, TestEnv.Ctx, "ogxservers.ogx.io") + require.NoErrorf(t, err, "error in validating CRD: ogxservers.ogx.io") }) t.Run("should validate operator deployment", func(t *testing.T) { - deployment, err := GetDeployment(TestEnv.Client, TestEnv.Ctx, "llama-stack-k8s-operator-controller-manager", TestOpts.OperatorNS) + deployment, err := GetDeployment(TestEnv.Client, TestEnv.Ctx, "ogx-k8s-operator-controller-manager", TestOpts.OperatorNS) require.NoError(t, err, "Operator deployment not found") require.Equal(t, int32(1), deployment.Status.ReadyReplicas, "Operator deployment not ready") }) @@ -32,7 +32,7 @@ func TestValidationSuite(t *testing.T) { operatorPodFound := false for _, pod := range podList.Items { - if pod.Labels["app.kubernetes.io/name"] == "llama-stack-k8s-operator" { + if pod.Labels["app.kubernetes.io/name"] == "ogx-k8s-operator" { operatorPodFound = true require.Equal(t, corev1.PodRunning, pod.Status.Phase) break