diff --git a/.dockerignore b/.dockerignore index c55c2d9d1..078924a5d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -40,9 +40,11 @@ Makefile # Docker & Kubernetes # =================== Dockerfile +Dockerfile.dev .dockerignore docker-compose.yaml -helm/ +deploy/ +skaffold.yaml prometheus.yml # =================== diff --git a/.github/workflows/helm-ci.yml b/.github/workflows/helm-ci.yml new file mode 100644 index 000000000..3a6363c93 --- /dev/null +++ b/.github/workflows/helm-ci.yml @@ -0,0 +1,91 @@ +name: Helm Chart CI + +on: + push: + branches: [main] + paths: + - "deploy/helm/**" + - "deploy/local/**" + - ".github/workflows/helm-ci.yml" + pull_request: + branches: [main] + paths: + - "deploy/helm/**" + - "deploy/local/**" + - ".github/workflows/helm-ci.yml" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + lint-test: + name: Lint and template + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Set up Helm + uses: azure/setup-helm@v4 + with: + version: v3.16.3 + + - name: Helm lint (strict) + run: helm lint --strict deploy/helm/gomodel + + # Render every example value set to catch templating regressions that lint misses. + - name: Helm template (all value sets) + run: | + set -euo pipefail + helm template ci deploy/helm/gomodel > /dev/null + for f in deploy/helm/gomodel/ci/*-values.yaml; do + echo "Rendering with $f" + helm template ci deploy/helm/gomodel -f "$f" > /dev/null + done + + - name: Helm template (local dev values) + run: helm template ci deploy/helm/gomodel -f deploy/local/values.yaml > /dev/null + + - name: Set up chart-testing + uses: helm/chart-testing-action@v2 + + - name: Run chart-testing (lint) + run: ct lint --charts deploy/helm/gomodel --validate-maintainers=false --target-branch ${{ github.event.repository.default_branch }} + + kubeconform: + name: Validate against Kubernetes schemas + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Set up Helm + uses: azure/setup-helm@v4 + with: + version: v3.16.3 + + - name: Install kubeconform + run: | + curl -sSL -o /tmp/kubeconform.tar.gz \ + https://github.com/yannh/kubeconform/releases/download/v0.6.7/kubeconform-linux-amd64.tar.gz + tar -xzf /tmp/kubeconform.tar.gz -C /tmp + sudo mv /tmp/kubeconform /usr/local/bin/ + + # ServiceMonitor is a CRD, so skip resources kubeconform can't resolve. + - name: Render and validate manifests + run: | + set -euo pipefail + for f in deploy/helm/gomodel/ci/*-values.yaml deploy/local/values.yaml; do + echo "Validating manifests rendered with $f" + helm template ci deploy/helm/gomodel -f "$f" \ + | kubeconform -strict -ignore-missing-schemas -summary + done + + - name: Validate local dev dependency manifests + run: | + kubeconform -strict -ignore-missing-schemas -summary \ + deploy/local/deps.yaml deploy/local/mockllm.yaml diff --git a/.github/workflows/helm-release.yml b/.github/workflows/helm-release.yml new file mode 100644 index 000000000..f5786c529 --- /dev/null +++ b/.github/workflows/helm-release.yml @@ -0,0 +1,64 @@ +name: Helm Chart Release + +# Publishes the GoModel Helm chart to the Docker Hub OCI registry. +# Trigger with a chart tag (decoupled from application releases), e.g. helm-v0.1.0, +# or run manually. The pushed chart version is read from Chart.yaml. +on: + push: + tags: + - "helm-v*" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: helm-release-${{ github.ref_name }} + cancel-in-progress: false + +jobs: + release: + name: Package and push chart + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Set up Helm + uses: azure/setup-helm@v4 + with: + version: v3.16.3 + + - name: Lint chart + run: helm lint --strict deploy/helm/gomodel + + - name: Resolve chart metadata + id: meta + shell: bash + env: + DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} + run: | + version="$(helm show chart deploy/helm/gomodel | awk '/^version:/ {print $2}')" + { + echo "version=${version}" + echo "registry=registry-1.docker.io/${DOCKER_USERNAME}" + } >> "$GITHUB_OUTPUT" + + - name: Login to Docker Hub (OCI) + run: | + echo "${{ secrets.DOCKERHUB_TOKEN }}" \ + | helm registry login registry-1.docker.io \ + --username "${{ secrets.DOCKER_USERNAME }}" --password-stdin + + - name: Package chart + run: helm package deploy/helm/gomodel --destination "${RUNNER_TEMP}" + + - name: Push chart to OCI registry + run: | + helm push \ + "${RUNNER_TEMP}/gomodel-${{ steps.meta.outputs.version }}.tgz" \ + "oci://${{ steps.meta.outputs.registry }}" + + - name: Logout + if: always() + run: helm registry logout registry-1.docker.io || true diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 40c6686a3..ae8068647 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,7 +6,7 @@ repos: - id: trailing-whitespace - id: end-of-file-fixer - id: check-yaml - exclude: ^helm/templates/ + exclude: ^deploy/helm/gomodel/templates/ - id: check-json - id: check-added-large-files diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 000000000..2fe34343b --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,34 @@ +# Development Dockerfile for the local Kubernetes (kind + Skaffold) workflow. +# Optimized for fast, repeated native-arch rebuilds — it skips cross-compilation +# and symbol stripping used by the production Dockerfile. Do NOT use for releases. +FROM golang:1.26.4-alpine3.23 AS builder + +WORKDIR /app + +RUN apk add --no-cache ca-certificates + +# Cache dependencies separately from source for fast incremental builds. +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +# Build cache mounts keep incremental rebuilds quick under Skaffold. +RUN --mount=type=cache,target=/root/.cache/go-build \ + --mount=type=cache,target=/go/pkg/mod \ + CGO_ENABLED=0 go build -o /gomodel ./cmd/gomodel + +RUN mkdir -p /app/.cache /app/data && touch /app/.cache/.keep /app/data/.keep + +# Same distroless runtime as production for parity. +FROM gcr.io/distroless/static-debian12:nonroot + +COPY --from=builder /gomodel /gomodel +COPY --from=builder /app/config/*.yaml /app/config/ +COPY --from=builder --chown=65532:65532 /app/.cache /app/.cache +COPY --from=builder --chown=65532:65532 /app/data /app/data + +WORKDIR /app + +EXPOSE 8080 + +ENTRYPOINT ["/gomodel"] diff --git a/Makefile b/Makefile index 97209a8a6..0ea3adf02 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all build run demo clean tidy test test-race test-dashboard test-e2e test-integration test-contract test-all lint lint-fix fix fix-check record-api swagger docs-openapi install-tools perf-check perf-bench infra image seed-demo-data +.PHONY: all build run demo clean tidy test test-race test-dashboard test-e2e test-integration test-contract test-all lint lint-fix fix fix-check record-api swagger docs-openapi install-tools perf-check perf-bench infra image seed-demo-data kind-up kind-down dev-k8s deploy-k8s undeploy-k8s all: build @@ -55,6 +55,41 @@ image: seed-demo-data: bash tools/seed-demo-data.sh +# --------------------------------------------------------------------------- +# Local Kubernetes development (kind + Skaffold) +# See docs/dev/local-kubernetes.md +# --------------------------------------------------------------------------- +KIND_CLUSTER ?= gomodel-dev + +# Create the local kind cluster and deploy in-cluster dependencies (idempotent). +kind-up: + @kind get clusters | grep -qx "$(KIND_CLUSTER)" \ + || kind create cluster --config deploy/local/kind-cluster.yaml + @kubectl cluster-info --context kind-$(KIND_CLUSTER) + kubectl --context kind-$(KIND_CLUSTER) apply -f deploy/local/deps.yaml + kubectl --context kind-$(KIND_CLUSTER) apply -f deploy/local/mockllm.yaml + kubectl --context kind-$(KIND_CLUSTER) rollout status deploy/redis --timeout=120s + kubectl --context kind-$(KIND_CLUSTER) rollout status deploy/postgres --timeout=120s + kubectl --context kind-$(KIND_CLUSTER) rollout status deploy/mongodb --timeout=180s + kubectl --context kind-$(KIND_CLUSTER) rollout status deploy/mockllm --timeout=120s + +# Delete the local kind cluster. +kind-down: + kind delete cluster --name $(KIND_CLUSTER) + +# Inner dev loop: build image, load into kind, deploy via Helm, watch for changes. +# The gateway is reachable at http://localhost:8080 via the kind NodePort mapping. +dev-k8s: + skaffold dev --kube-context kind-$(KIND_CLUSTER) + +# One-shot build + deploy (no watch). +deploy-k8s: + skaffold run --kube-context kind-$(KIND_CLUSTER) + +# Tear down the deployed release and dependencies. +undeploy-k8s: + skaffold delete --kube-context kind-$(KIND_CLUSTER) + # Run unit tests only test: go test ./cmd/... ./internal/... ./config/... -v diff --git a/deploy/helm/gomodel/.helmignore b/deploy/helm/gomodel/.helmignore new file mode 100644 index 000000000..91539f61e --- /dev/null +++ b/deploy/helm/gomodel/.helmignore @@ -0,0 +1,12 @@ +# Patterns to ignore when building packages. +.DS_Store +.git/ +.gitignore +*.tmpl +*.tgz +.helmignore +ci/ +README.md.gotmpl +# CI/development artifacts +.github/ +tests/ diff --git a/deploy/helm/gomodel/Chart.yaml b/deploy/helm/gomodel/Chart.yaml new file mode 100644 index 000000000..f85cdebb6 --- /dev/null +++ b/deploy/helm/gomodel/Chart.yaml @@ -0,0 +1,23 @@ +apiVersion: v2 +name: gomodel +description: A high-performance, lightweight AI gateway that routes requests to multiple AI model providers through an OpenAI-compatible API. +type: application +# Chart version is independent of the application version and follows SemVer. +version: 0.1.0 +# appVersion tracks the GoModel application release this chart was tested against. +appVersion: "latest" +home: https://github.com/ENTERPILOT/GoModel +sources: + - https://github.com/ENTERPILOT/GoModel +keywords: + - ai + - gateway + - llm + - openai + - proxy +maintainers: + - name: GoModel Maintainers + url: https://github.com/ENTERPILOT/GoModel +icon: https://raw.githubusercontent.com/ENTERPILOT/GoModel/main/docs/logo.png +annotations: + category: AIMachineLearning diff --git a/deploy/helm/gomodel/README.md b/deploy/helm/gomodel/README.md new file mode 100644 index 000000000..a372ebb14 --- /dev/null +++ b/deploy/helm/gomodel/README.md @@ -0,0 +1,109 @@ +# GoModel Helm Chart + +A Helm chart for [GoModel](https://github.com/ENTERPILOT/GoModel) — a high-performance, +lightweight AI gateway that routes requests to multiple AI model providers through an +OpenAI-compatible API. + +## TL;DR + +```bash +helm install gomodel oci://registry-1.docker.io/enterpilot/gomodel \ + --version 0.1.0 \ + --namespace gomodel --create-namespace \ + --set secrets.masterKey=$(openssl rand -hex 32) \ + --set secrets.data.OPENAI_API_KEY=sk-... +``` + +> Replace `enterpilot` with the Docker Hub namespace the chart was published under +> (`DOCKER_USERNAME`). The chart is pushed by the `helm-release.yml` workflow when a +> `helm-v*` tag is created. +> +> You can also install directly from a local checkout: +> `helm install gomodel ./deploy/helm/gomodel -n gomodel --create-namespace`. + +## Introduction + +The chart deploys GoModel using the official distroless image +(`enterpilot/gomodel`, non-root UID/GID `65532`) with Kubernetes best practices: +read-only root filesystem, dropped capabilities, HTTP health/readiness probes, +optional autoscaling, PodDisruptionBudget, NetworkPolicy and Prometheus integration. + +## Deployment modes + +GoModel loads configuration in three layers: **built-in defaults → `config.yaml` +(optional) → environment variables (always win)**. + +| Mode | When | Workload | Scaling | +| --- | --- | --- | --- | +| **Stateless** (default) | External Postgres/MongoDB + Redis | `Deployment` | Multi-replica, HPA | +| **SQLite** | `persistence.enabled=true` | `StatefulSet` + PVC at `/app/data` | Single replica only | + +The default (no external DB configured) runs a single replica writing SQLite to an +**ephemeral** `emptyDir` — suitable for evaluation only. For production, either enable +`persistence` (durable single node) or point the app at external datastores (scalable). + +## Configuration + +Two complementary mechanisms: + +- `config` — rendered verbatim into a ConfigMap and mounted read-only at + `/app/config/config.yaml`. Supports `${ENV_VAR}` expansion, so reference secret + values by name here and provide them through `secrets`. +- `env` / `extraEnv` — environment variables, which always override `config.yaml`. + +### Secrets + +Sensitive values (`GOMODEL_MASTER_KEY`, `_API_KEY`, `POSTGRES_URL`, +`MONGODB_URL`, `REDIS_URL`, ...) go under `secrets`: + +```yaml +secrets: + masterKey: "change-me" # GOMODEL_MASTER_KEY; without it the gateway is UNSAFE + data: + OPENAI_API_KEY: sk-... + POSTGRES_URL: postgres://user:pass@host:5432/gomodel + REDIS_URL: redis://redis:6379 +``` + +They are rendered into a chart-managed `Secret` and injected via `envFrom`. For +production / external secret managers, set `secrets.existingSecret` to reference a +pre-created Secret instead. + +## Health & metrics + +- **Liveness**: `GET {basePath}/health` +- **Readiness**: `GET {basePath}/health/ready` (returns `503` when primary storage is + down, pulling the pod out of the Service; a degraded cache stays in rotation) +- **Metrics**: `GET {basePath}/metrics` — enable with `metrics.enabled=true` (or + `metrics.serviceMonitor.enabled=true`, which also creates a Prometheus Operator + ServiceMonitor) + +Probe paths automatically honor `BASE_PATH` / `config.server.base_path`. + +## Values + +See [`values.yaml`](./values.yaml) for the full, documented list. Key values: + +| Key | Default | Description | +| --- | --- | --- | +| `image.repository` | `enterpilot/gomodel` | Image repository | +| `image.tag` | `""` (chart `appVersion`) | Image tag | +| `replicaCount` | `1` | Replicas (forced to 1 in SQLite mode) | +| `persistence.enabled` | `false` | Use SQLite StatefulSet + PVC | +| `secrets.masterKey` | `""` | Gateway master key | +| `secrets.existingSecret` | `""` | Reference an existing Secret instead | +| `config` | `{}` | Rendered into `config.yaml` | +| `ingress.enabled` | `false` | Create an Ingress | +| `autoscaling.enabled` | `false` | Create an HPA (stateless only) | +| `podDisruptionBudget.enabled` | `false` | Create a PDB | +| `networkPolicy.enabled` | `false` | Restrict ingress/egress | +| `metrics.enabled` | `false` | Enable Prometheus `/metrics` | +| `metrics.serviceMonitor.enabled` | `false` | Create a ServiceMonitor | + +## Example value sets + +Ready-to-use examples live in [`ci/`](./ci): + +- `stateless-values.yaml` — multi-replica with external Postgres + Redis +- `sqlite-persistent-values.yaml` — single node with a persistent SQLite volume +- `ingress-tls-values.yaml` — Ingress + TLS + NetworkPolicy + ServiceMonitor diff --git a/deploy/helm/gomodel/ci/ingress-tls-values.yaml b/deploy/helm/gomodel/ci/ingress-tls-values.yaml new file mode 100644 index 000000000..ddea624d7 --- /dev/null +++ b/deploy/helm/gomodel/ci/ingress-tls-values.yaml @@ -0,0 +1,28 @@ +# Ingress with TLS, NetworkPolicy, and Prometheus ServiceMonitor enabled. +ingress: + enabled: true + className: nginx + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + hosts: + - host: gomodel.example.com + paths: + - path: / + pathType: Prefix + tls: + - secretName: gomodel-tls + hosts: + - gomodel.example.com + +networkPolicy: + enabled: true + +metrics: + enabled: true + serviceMonitor: + enabled: true + labels: + release: prometheus + +secrets: + masterKey: "test-master-key" diff --git a/deploy/helm/gomodel/ci/sqlite-persistent-values.yaml b/deploy/helm/gomodel/ci/sqlite-persistent-values.yaml new file mode 100644 index 000000000..9030c2b92 --- /dev/null +++ b/deploy/helm/gomodel/ci/sqlite-persistent-values.yaml @@ -0,0 +1,9 @@ +# SQLite-backed single-replica deployment with a persistent volume (StatefulSet mode). +persistence: + enabled: true + size: 2Gi + +secrets: + masterKey: "test-master-key" + data: + ANTHROPIC_API_KEY: "sk-ant-test" diff --git a/deploy/helm/gomodel/ci/stateless-values.yaml b/deploy/helm/gomodel/ci/stateless-values.yaml new file mode 100644 index 000000000..0d1f3546f --- /dev/null +++ b/deploy/helm/gomodel/ci/stateless-values.yaml @@ -0,0 +1,28 @@ +# Stateless deployment backed by external Postgres + Redis (multi-replica). +replicaCount: 2 + +secrets: + masterKey: "test-master-key" + data: + OPENAI_API_KEY: "sk-test" + POSTGRES_URL: "postgres://gomodel:gomodel@postgres:5432/gomodel" + REDIS_URL: "redis://redis:6379" + +config: + server: + port: "8080" + storage: + type: postgresql + postgresql: + url: ${POSTGRES_URL} + cache: + model: + redis: + url: ${REDIS_URL} + +podDisruptionBudget: + enabled: true + minAvailable: 1 + +metrics: + enabled: true diff --git a/deploy/helm/gomodel/templates/NOTES.txt b/deploy/helm/gomodel/templates/NOTES.txt new file mode 100644 index 000000000..f02035a34 --- /dev/null +++ b/deploy/helm/gomodel/templates/NOTES.txt @@ -0,0 +1,36 @@ +{{- include "gomodel.validate" . -}} +GoModel has been deployed as release "{{ .Release.Name }}" in namespace "{{ .Release.Namespace }}". + +{{ if .Values.persistence.enabled -}} +Mode: SQLite (StatefulSet, single replica, persistent volume at /app/data). +{{- else -}} +Mode: stateless Deployment ({{ include "gomodel.replicaCount" . }} replica(s)). +Storage defaults to SQLite in an emptyDir (ephemeral). For a durable, scalable +deployment, point `secrets.data.POSTGRES_URL` (or MONGODB_URL) and REDIS_URL at +external services via values. +{{- end }} + +1. Access the gateway: +{{- if .Values.ingress.enabled }} +{{- range $host := .Values.ingress.hosts }} + {{- range .paths }} + http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }} + {{- end }} +{{- end }} +{{- else }} + kubectl --namespace {{ .Release.Namespace }} port-forward svc/{{ include "gomodel.fullname" . }} 8080:{{ .Values.service.port }} + # then: curl http://127.0.0.1:8080{{ include "gomodel.basePath" . }}/health +{{- end }} + +2. Health endpoints: + Liveness : {{ include "gomodel.basePath" . }}/health + Readiness: {{ include "gomodel.basePath" . }}/health/ready +{{- if .Values.metrics.enabled }} + Metrics : {{ include "gomodel.basePath" . }}/metrics +{{- end }} + +{{ if and (not .Values.secrets.masterKey) (not .Values.secrets.existingSecret) -}} +WARNING: No master key configured (secrets.masterKey / secrets.existingSecret). +GoModel is running in UNSAFE MODE with no authentication. Set a master key before +exposing this gateway to untrusted networks. +{{- end }} diff --git a/deploy/helm/gomodel/templates/_helpers.tpl b/deploy/helm/gomodel/templates/_helpers.tpl new file mode 100644 index 000000000..7fe7c2463 --- /dev/null +++ b/deploy/helm/gomodel/templates/_helpers.tpl @@ -0,0 +1,139 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "gomodel.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +Truncated to 63 chars because some Kubernetes name fields are limited to this. +*/}} +{{- define "gomodel.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "gomodel.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "gomodel.labels" -}} +helm.sh/chart: {{ include "gomodel.chart" . }} +{{ include "gomodel.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +app.kubernetes.io/part-of: gomodel +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "gomodel.selectorLabels" -}} +app.kubernetes.io/name: {{ include "gomodel.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +The image reference, defaulting the tag to the chart appVersion. +*/}} +{{- define "gomodel.image" -}} +{{- $tag := .Values.image.tag | default .Chart.AppVersion -}} +{{- printf "%s:%s" .Values.image.repository $tag -}} +{{- end }} + +{{/* +Create the name of the service account to use. +*/}} +{{- define "gomodel.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "gomodel.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} + +{{/* +Name of the Secret to reference via envFrom. Prefers an existing secret. +*/}} +{{- define "gomodel.secretName" -}} +{{- if .Values.secrets.existingSecret }} +{{- .Values.secrets.existingSecret }} +{{- else }} +{{- include "gomodel.fullname" . }} +{{- end }} +{{- end }} + +{{/* +Whether the chart manages its own Secret (i.e. no existing secret and some data set). +*/}} +{{- define "gomodel.createSecret" -}} +{{- if and (not .Values.secrets.existingSecret) (or .Values.secrets.masterKey .Values.secrets.data) -}} +true +{{- end -}} +{{- end }} + +{{/* +Whether persistent (SQLite/StatefulSet) mode is active. +*/}} +{{- define "gomodel.persistent" -}} +{{- if .Values.persistence.enabled -}} +true +{{- end -}} +{{- end }} + +{{/* +Effective replica count. Forced to 1 in persistent (SQLite) mode. +*/}} +{{- define "gomodel.replicaCount" -}} +{{- if .Values.persistence.enabled -}} +1 +{{- else -}} +{{- .Values.replicaCount -}} +{{- end -}} +{{- end }} + +{{/* +The URL path prefix (BASE_PATH) used to build probe paths. Honors an env override, +then config.server.base_path, defaulting to "/". +*/}} +{{- define "gomodel.basePath" -}} +{{- $bp := "/" -}} +{{- if and .Values.config .Values.config.server .Values.config.server.base_path -}} +{{- $bp = .Values.config.server.base_path -}} +{{- end -}} +{{- if and .Values.env .Values.env.BASE_PATH -}} +{{- $bp = .Values.env.BASE_PATH -}} +{{- end -}} +{{- $bp = printf "/%s" (trimPrefix "/" (trimSuffix "/" $bp)) -}} +{{- if eq $bp "/" -}}{{- $bp = "" -}}{{- end -}} +{{- $bp -}} +{{- end }} + +{{/* +Validation guardrails. +*/}} +{{- define "gomodel.validate" -}} +{{- if and .Values.persistence.enabled (gt (int .Values.replicaCount) 1) -}} +{{- fail "persistence.enabled=true uses SQLite storage which cannot be shared across pods. Set replicaCount to 1, or disable persistence and use an external database." -}} +{{- end -}} +{{- if and .Values.persistence.enabled .Values.autoscaling.enabled -}} +{{- fail "autoscaling is incompatible with persistence.enabled=true (SQLite single-writer). Disable one of them." -}} +{{- end -}} +{{- end }} diff --git a/deploy/helm/gomodel/templates/_pod.tpl b/deploy/helm/gomodel/templates/_pod.tpl new file mode 100644 index 000000000..a2e2eb709 --- /dev/null +++ b/deploy/helm/gomodel/templates/_pod.tpl @@ -0,0 +1,170 @@ +{{/* +Environment variable list shared by all workloads. +*/}} +{{- define "gomodel.env" -}} +{{- if or .Values.metrics.enabled .Values.metrics.serviceMonitor.enabled }} +- name: METRICS_ENABLED + value: "true" +{{- end }} +{{- range $key, $value := .Values.env }} +- name: {{ $key }} + value: {{ $value | quote }} +{{- end }} +{{- with .Values.extraEnv }} +{{- toYaml . | nindent 0 }} +{{- end }} +{{- end }} + +{{/* +Volumes shared by all workloads. In persistent mode the "data" volume is provided by +the StatefulSet volumeClaimTemplate (or existingClaim) and is not declared here. +*/}} +{{- define "gomodel.volumes" -}} +- name: cache + emptyDir: {} +{{- if not (include "gomodel.persistent" .) }} +- name: data + emptyDir: {} +{{- else if .Values.persistence.existingClaim }} +- name: data + persistentVolumeClaim: + claimName: {{ .Values.persistence.existingClaim }} +{{- end }} +{{- if .Values.config }} +- name: config + configMap: + name: {{ include "gomodel.fullname" . }} +{{- end }} +{{- with .Values.extraVolumes }} +{{- toYaml . | nindent 0 }} +{{- end }} +{{- end }} + +{{/* +The GoModel container spec. +*/}} +{{- define "gomodel.container" -}} +- name: {{ .Chart.Name }} + image: {{ include "gomodel.image" . }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + {{- with .Values.securityContext }} + securityContext: + {{- toYaml . | nindent 4 }} + {{- end }} + ports: + - name: http + containerPort: 8080 + protocol: TCP + {{- $env := include "gomodel.env" . | trim }} + {{- if $env }} + env: + {{- $env | nindent 4 }} + {{- end }} + {{- if or (eq (include "gomodel.createSecret" .) "true") .Values.secrets.existingSecret }} + envFrom: + - secretRef: + name: {{ include "gomodel.secretName" . }} + {{- end }} + {{- if .Values.livenessProbe.enabled }} + livenessProbe: + httpGet: + path: {{ include "gomodel.basePath" . }}/health + port: http + initialDelaySeconds: {{ .Values.livenessProbe.initialDelaySeconds }} + periodSeconds: {{ .Values.livenessProbe.periodSeconds }} + timeoutSeconds: {{ .Values.livenessProbe.timeoutSeconds }} + failureThreshold: {{ .Values.livenessProbe.failureThreshold }} + successThreshold: {{ .Values.livenessProbe.successThreshold }} + {{- end }} + {{- if .Values.readinessProbe.enabled }} + readinessProbe: + httpGet: + path: {{ include "gomodel.basePath" . }}/health/ready + port: http + initialDelaySeconds: {{ .Values.readinessProbe.initialDelaySeconds }} + periodSeconds: {{ .Values.readinessProbe.periodSeconds }} + timeoutSeconds: {{ .Values.readinessProbe.timeoutSeconds }} + failureThreshold: {{ .Values.readinessProbe.failureThreshold }} + successThreshold: {{ .Values.readinessProbe.successThreshold }} + {{- end }} + {{- if .Values.startupProbe.enabled }} + startupProbe: + httpGet: + path: {{ include "gomodel.basePath" . }}/health + port: http + periodSeconds: {{ .Values.startupProbe.periodSeconds }} + timeoutSeconds: {{ .Values.startupProbe.timeoutSeconds }} + failureThreshold: {{ .Values.startupProbe.failureThreshold }} + successThreshold: {{ .Values.startupProbe.successThreshold }} + {{- end }} + {{- with .Values.resources }} + resources: + {{- toYaml . | nindent 4 }} + {{- end }} + volumeMounts: + - name: cache + mountPath: /app/.cache + - name: data + mountPath: /app/data + {{- if .Values.config }} + - name: config + mountPath: /app/config/config.yaml + subPath: config.yaml + readOnly: true + {{- end }} + {{- with .Values.extraVolumeMounts }} + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} + +{{/* +Pod template metadata annotations, including config/secret checksums for auto-reload. +*/}} +{{- define "gomodel.podAnnotations" -}} +{{- if .Values.config }} +checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} +{{- end }} +{{- if eq (include "gomodel.createSecret" .) "true" }} +checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }} +{{- end }} +{{- with .Values.podAnnotations }} +{{- toYaml . | nindent 0 }} +{{- end }} +{{- end }} + +{{/* +Shared pod spec body (everything under spec.template.spec). +*/}} +{{- define "gomodel.podSpec" -}} +{{- with .Values.imagePullSecrets }} +imagePullSecrets: + {{- toYaml . | nindent 2 }} +{{- end }} +serviceAccountName: {{ include "gomodel.serviceAccountName" . }} +automountServiceAccountToken: {{ .Values.serviceAccount.automount }} +{{- with .Values.podSecurityContext }} +securityContext: + {{- toYaml . | nindent 2 }} +{{- end }} +terminationGracePeriodSeconds: {{ .Values.terminationGracePeriodSeconds }} +containers: + {{- include "gomodel.container" . | nindent 2 }} +volumes: + {{- include "gomodel.volumes" . | nindent 2 }} +{{- with .Values.nodeSelector }} +nodeSelector: + {{- toYaml . | nindent 2 }} +{{- end }} +{{- with .Values.affinity }} +affinity: + {{- toYaml . | nindent 2 }} +{{- end }} +{{- with .Values.tolerations }} +tolerations: + {{- toYaml . | nindent 2 }} +{{- end }} +{{- with .Values.topologySpreadConstraints }} +topologySpreadConstraints: + {{- toYaml . | nindent 2 }} +{{- end }} +{{- end }} diff --git a/deploy/helm/gomodel/templates/configmap.yaml b/deploy/helm/gomodel/templates/configmap.yaml new file mode 100644 index 000000000..9ebca8382 --- /dev/null +++ b/deploy/helm/gomodel/templates/configmap.yaml @@ -0,0 +1,12 @@ +{{- if .Values.config -}} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "gomodel.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "gomodel.labels" . | nindent 4 }} +data: + config.yaml: | + {{- toYaml .Values.config | nindent 4 }} +{{- end }} diff --git a/deploy/helm/gomodel/templates/deployment.yaml b/deploy/helm/gomodel/templates/deployment.yaml new file mode 100644 index 000000000..99ff8851d --- /dev/null +++ b/deploy/helm/gomodel/templates/deployment.yaml @@ -0,0 +1,32 @@ +{{- if not (include "gomodel.persistent" .) -}} +{{- include "gomodel.validate" . -}} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "gomodel.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "gomodel.labels" . | nindent 4 }} +spec: + {{- if not .Values.autoscaling.enabled }} + replicas: {{ include "gomodel.replicaCount" . }} + {{- end }} + {{- with .Values.updateStrategy }} + strategy: + {{- toYaml . | nindent 4 }} + {{- end }} + selector: + matchLabels: + {{- include "gomodel.selectorLabels" . | nindent 6 }} + template: + metadata: + annotations: + {{- include "gomodel.podAnnotations" . | nindent 8 }} + labels: + {{- include "gomodel.selectorLabels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- include "gomodel.podSpec" . | nindent 6 }} +{{- end }} diff --git a/helm/templates/hpa.yaml b/deploy/helm/gomodel/templates/hpa.yaml similarity index 74% rename from helm/templates/hpa.yaml rename to deploy/helm/gomodel/templates/hpa.yaml index 526247f1f..ba891fd60 100644 --- a/helm/templates/hpa.yaml +++ b/deploy/helm/gomodel/templates/hpa.yaml @@ -1,11 +1,9 @@ -{{- if .Values.autoscaling.enabled }} -{{- if not (or .Values.autoscaling.targetCPUUtilizationPercentage .Values.autoscaling.targetMemoryUtilizationPercentage) }} -{{- fail "autoscaling.targetCPUUtilizationPercentage or autoscaling.targetMemoryUtilizationPercentage must be set when autoscaling is enabled" }} -{{- end }} +{{- if and .Values.autoscaling.enabled (not (include "gomodel.persistent" .)) -}} apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: name: {{ include "gomodel.fullname" . }} + namespace: {{ .Release.Namespace }} labels: {{- include "gomodel.labels" . | nindent 4 }} spec: diff --git a/helm/templates/ingress.yaml b/deploy/helm/gomodel/templates/ingress.yaml similarity index 54% rename from helm/templates/ingress.yaml rename to deploy/helm/gomodel/templates/ingress.yaml index 92dd127e7..fac9772b9 100644 --- a/helm/templates/ingress.yaml +++ b/deploy/helm/gomodel/templates/ingress.yaml @@ -1,8 +1,11 @@ {{- if .Values.ingress.enabled -}} +{{- $fullName := include "gomodel.fullname" . -}} +{{- $svcPort := .Values.service.port -}} apiVersion: networking.k8s.io/v1 kind: Ingress metadata: - name: {{ include "gomodel.fullname" . }} + name: {{ $fullName }} + namespace: {{ .Release.Namespace }} labels: {{- include "gomodel.labels" . | nindent 4 }} {{- with .Values.ingress.annotations }} @@ -10,18 +13,12 @@ metadata: {{- toYaml . | nindent 4 }} {{- end }} spec: - {{- if .Values.ingress.className }} - ingressClassName: {{ .Values.ingress.className }} + {{- with .Values.ingress.className }} + ingressClassName: {{ . }} {{- end }} - {{- if .Values.ingress.tls }} + {{- with .Values.ingress.tls }} tls: - {{- range .Values.ingress.tls }} - - hosts: - {{- range .hosts }} - - {{ . | quote }} - {{- end }} - secretName: {{ .secretName }} - {{- end }} + {{- toYaml . | nindent 4 }} {{- end }} rules: {{- range .Values.ingress.hosts }} @@ -30,12 +27,12 @@ spec: paths: {{- range .paths }} - path: {{ .path }} - pathType: {{ .pathType }} + pathType: {{ .pathType | default "Prefix" }} backend: service: - name: {{ include "gomodel.fullname" $ }} + name: {{ $fullName }} port: - number: {{ $.Values.service.port }} + number: {{ $svcPort }} {{- end }} {{- end }} {{- end }} diff --git a/deploy/helm/gomodel/templates/networkpolicy.yaml b/deploy/helm/gomodel/templates/networkpolicy.yaml new file mode 100644 index 000000000..2fb161501 --- /dev/null +++ b/deploy/helm/gomodel/templates/networkpolicy.yaml @@ -0,0 +1,40 @@ +{{- if .Values.networkPolicy.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "gomodel.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "gomodel.labels" . | nindent 4 }} +spec: + podSelector: + matchLabels: + {{- include "gomodel.selectorLabels" . | nindent 6 }} + policyTypes: + - Ingress + - Egress + ingress: + # Allow inbound traffic to the HTTP port from anywhere (front by an Ingress/LB). + - ports: + - port: http + protocol: TCP + {{- with .Values.networkPolicy.extraIngress }} + {{- toYaml . | nindent 4 }} + {{- end }} + egress: + # DNS resolution. + - to: [] + ports: + - port: 53 + protocol: UDP + - port: 53 + protocol: TCP + # Outbound HTTPS to provider APIs and external datastores. + - to: [] + ports: + - port: 443 + protocol: TCP + {{- with .Values.networkPolicy.extraEgress }} + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/helm/templates/pdb.yaml b/deploy/helm/gomodel/templates/pdb.yaml similarity index 58% rename from helm/templates/pdb.yaml rename to deploy/helm/gomodel/templates/pdb.yaml index 4849a3d96..450f80cb7 100644 --- a/helm/templates/pdb.yaml +++ b/deploy/helm/gomodel/templates/pdb.yaml @@ -1,19 +1,16 @@ -{{- if .Values.podDisruptionBudget.enabled }} +{{- if .Values.podDisruptionBudget.enabled -}} apiVersion: policy/v1 kind: PodDisruptionBudget metadata: name: {{ include "gomodel.fullname" . }} + namespace: {{ .Release.Namespace }} labels: {{- include "gomodel.labels" . | nindent 4 }} spec: - {{- if and (not .Values.podDisruptionBudget.minAvailable) (not .Values.podDisruptionBudget.maxUnavailable) }} - {{- fail "PodDisruptionBudget requires either minAvailable or maxUnavailable to be set" }} - {{- end }} - {{- if .Values.podDisruptionBudget.minAvailable }} - minAvailable: {{ .Values.podDisruptionBudget.minAvailable }} - {{- end }} {{- if .Values.podDisruptionBudget.maxUnavailable }} maxUnavailable: {{ .Values.podDisruptionBudget.maxUnavailable }} + {{- else if .Values.podDisruptionBudget.minAvailable }} + minAvailable: {{ .Values.podDisruptionBudget.minAvailable }} {{- end }} selector: matchLabels: diff --git a/deploy/helm/gomodel/templates/secret.yaml b/deploy/helm/gomodel/templates/secret.yaml new file mode 100644 index 000000000..f8336dbc6 --- /dev/null +++ b/deploy/helm/gomodel/templates/secret.yaml @@ -0,0 +1,17 @@ +{{- if eq (include "gomodel.createSecret" .) "true" -}} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "gomodel.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "gomodel.labels" . | nindent 4 }} +type: Opaque +stringData: + {{- if .Values.secrets.masterKey }} + GOMODEL_MASTER_KEY: {{ .Values.secrets.masterKey | quote }} + {{- end }} + {{- range $key, $value := .Values.secrets.data }} + {{ $key }}: {{ $value | quote }} + {{- end }} +{{- end }} diff --git a/helm/templates/service.yaml b/deploy/helm/gomodel/templates/service.yaml similarity index 62% rename from helm/templates/service.yaml rename to deploy/helm/gomodel/templates/service.yaml index 170a5a549..a01f1791a 100644 --- a/helm/templates/service.yaml +++ b/deploy/helm/gomodel/templates/service.yaml @@ -2,6 +2,7 @@ apiVersion: v1 kind: Service metadata: name: {{ include "gomodel.fullname" . }} + namespace: {{ .Release.Namespace }} labels: {{- include "gomodel.labels" . | nindent 4 }} {{- with .Values.service.annotations }} @@ -11,9 +12,12 @@ metadata: spec: type: {{ .Values.service.type }} ports: - - port: {{ .Values.service.port }} + - name: http + port: {{ .Values.service.port }} targetPort: http protocol: TCP - name: http + {{- if and (eq .Values.service.type "NodePort") .Values.service.nodePort }} + nodePort: {{ .Values.service.nodePort }} + {{- end }} selector: {{- include "gomodel.selectorLabels" . | nindent 4 }} diff --git a/deploy/helm/gomodel/templates/serviceaccount.yaml b/deploy/helm/gomodel/templates/serviceaccount.yaml new file mode 100644 index 000000000..d0dbadc14 --- /dev/null +++ b/deploy/helm/gomodel/templates/serviceaccount.yaml @@ -0,0 +1,14 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "gomodel.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "gomodel.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: {{ .Values.serviceAccount.automount }} +{{- end }} diff --git a/deploy/helm/gomodel/templates/servicemonitor.yaml b/deploy/helm/gomodel/templates/servicemonitor.yaml new file mode 100644 index 000000000..6afb5b09d --- /dev/null +++ b/deploy/helm/gomodel/templates/servicemonitor.yaml @@ -0,0 +1,32 @@ +{{- if .Values.metrics.serviceMonitor.enabled -}} +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: {{ include "gomodel.fullname" . }} + namespace: {{ .Values.metrics.serviceMonitor.namespace | default .Release.Namespace }} + labels: + {{- include "gomodel.labels" . | nindent 4 }} + {{- with .Values.metrics.serviceMonitor.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + namespaceSelector: + matchNames: + - {{ .Release.Namespace }} + selector: + matchLabels: + {{- include "gomodel.selectorLabels" . | nindent 6 }} + endpoints: + - port: http + path: {{ include "gomodel.basePath" . }}/metrics + interval: {{ .Values.metrics.serviceMonitor.interval }} + scrapeTimeout: {{ .Values.metrics.serviceMonitor.scrapeTimeout }} + {{- with .Values.metrics.serviceMonitor.relabelings }} + relabelings: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.metrics.serviceMonitor.metricRelabelings }} + metricRelabelings: + {{- toYaml . | nindent 8 }} + {{- end }} +{{- end }} diff --git a/deploy/helm/gomodel/templates/statefulset.yaml b/deploy/helm/gomodel/templates/statefulset.yaml new file mode 100644 index 000000000..169167bcc --- /dev/null +++ b/deploy/helm/gomodel/templates/statefulset.yaml @@ -0,0 +1,49 @@ +{{- if include "gomodel.persistent" . -}} +{{- include "gomodel.validate" . -}} +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ include "gomodel.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "gomodel.labels" . | nindent 4 }} +spec: + # SQLite is a single-writer store: exactly one replica. + replicas: 1 + serviceName: {{ include "gomodel.fullname" . }} + podManagementPolicy: OrderedReady + updateStrategy: + type: RollingUpdate + selector: + matchLabels: + {{- include "gomodel.selectorLabels" . | nindent 6 }} + template: + metadata: + annotations: + {{- include "gomodel.podAnnotations" . | nindent 8 }} + labels: + {{- include "gomodel.selectorLabels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- include "gomodel.podSpec" . | nindent 6 }} + {{- if not .Values.persistence.existingClaim }} + volumeClaimTemplates: + - metadata: + name: data + {{- with .Values.persistence.annotations }} + annotations: + {{- toYaml . | nindent 10 }} + {{- end }} + spec: + accessModes: + {{- toYaml .Values.persistence.accessModes | nindent 10 }} + {{- if .Values.persistence.storageClass }} + storageClassName: {{ .Values.persistence.storageClass | quote }} + {{- end }} + resources: + requests: + storage: {{ .Values.persistence.size | quote }} + {{- end }} +{{- end }} diff --git a/deploy/helm/gomodel/values.schema.json b/deploy/helm/gomodel/values.schema.json new file mode 100644 index 000000000..db83e78e8 --- /dev/null +++ b/deploy/helm/gomodel/values.schema.json @@ -0,0 +1,130 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema#", + "title": "GoModel Helm chart values", + "type": "object", + "properties": { + "replicaCount": { "type": "integer", "minimum": 0 }, + "image": { + "type": "object", + "properties": { + "repository": { "type": "string", "minLength": 1 }, + "pullPolicy": { "type": "string", "enum": ["Always", "IfNotPresent", "Never"] }, + "tag": { "type": "string" } + }, + "required": ["repository"], + "additionalProperties": false + }, + "imagePullSecrets": { "type": "array" }, + "nameOverride": { "type": "string" }, + "fullnameOverride": { "type": "string" }, + "serviceAccount": { + "type": "object", + "properties": { + "create": { "type": "boolean" }, + "automount": { "type": "boolean" }, + "annotations": { "type": "object" }, + "name": { "type": "string" } + }, + "additionalProperties": false + }, + "podAnnotations": { "type": "object" }, + "podLabels": { "type": "object" }, + "podSecurityContext": { "type": "object" }, + "securityContext": { "type": "object" }, + "service": { + "type": "object", + "properties": { + "type": { "type": "string", "enum": ["ClusterIP", "NodePort", "LoadBalancer"] }, + "port": { "type": "integer", "minimum": 1, "maximum": 65535 }, + "nodePort": { "type": ["integer", "null"], "minimum": 30000, "maximum": 32767 }, + "annotations": { "type": "object" } + }, + "additionalProperties": false + }, + "ingress": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, + "className": { "type": "string" }, + "annotations": { "type": "object" }, + "hosts": { "type": "array" }, + "tls": { "type": "array" } + }, + "additionalProperties": false + }, + "resources": { "type": "object" }, + "livenessProbe": { "type": "object" }, + "readinessProbe": { "type": "object" }, + "startupProbe": { "type": "object" }, + "autoscaling": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, + "minReplicas": { "type": "integer", "minimum": 1 }, + "maxReplicas": { "type": "integer", "minimum": 1 }, + "targetCPUUtilizationPercentage": { "type": ["integer", "null"] }, + "targetMemoryUtilizationPercentage": { "type": ["integer", "null"] } + }, + "additionalProperties": false + }, + "podDisruptionBudget": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, + "minAvailable": { "type": ["integer", "string", "null"] }, + "maxUnavailable": { "type": ["integer", "string", "null"] } + }, + "additionalProperties": false + }, + "persistence": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, + "existingClaim": { "type": "string" }, + "storageClass": { "type": "string" }, + "accessModes": { "type": "array" }, + "size": { "type": "string" }, + "annotations": { "type": "object" } + }, + "additionalProperties": false + }, + "metrics": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, + "serviceMonitor": { "type": "object" } + }, + "additionalProperties": false + }, + "networkPolicy": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, + "extraIngress": { "type": "array" }, + "extraEgress": { "type": "array" } + }, + "additionalProperties": false + }, + "config": { "type": "object" }, + "secrets": { + "type": "object", + "properties": { + "existingSecret": { "type": "string" }, + "masterKey": { "type": "string" }, + "data": { "type": "object" } + }, + "additionalProperties": false + }, + "env": { "type": ["object", "null"] }, + "extraEnv": { "type": "array" }, + "extraVolumes": { "type": "array" }, + "extraVolumeMounts": { "type": "array" }, + "updateStrategy": { "type": "object" }, + "nodeSelector": { "type": "object" }, + "tolerations": { "type": "array" }, + "affinity": { "type": "object" }, + "topologySpreadConstraints": { "type": "array" }, + "terminationGracePeriodSeconds": { "type": "integer", "minimum": 0 } + }, + "additionalProperties": true +} diff --git a/deploy/helm/gomodel/values.yaml b/deploy/helm/gomodel/values.yaml new file mode 100644 index 000000000..1bb13f70d --- /dev/null +++ b/deploy/helm/gomodel/values.yaml @@ -0,0 +1,253 @@ +# Default values for the GoModel Helm chart. +# This is a YAML-formatted file. Values are documented for helm-docs. + +# -- Number of GoModel replicas. Must be 1 when `persistence.enabled` is true +# (SQLite storage cannot be shared across pods). +replicaCount: 1 + +image: + # -- Container image repository. Official image is published to Docker Hub. + repository: enterpilot/gomodel + # -- Image pull policy. + pullPolicy: IfNotPresent + # -- Image tag. Defaults to the chart's appVersion when empty. + tag: "" + +# -- Image pull secrets for private registries. +imagePullSecrets: [] +# -- Override the chart name. +nameOverride: "" +# -- Override the fully qualified app name. +fullnameOverride: "" + +serviceAccount: + # -- Create a dedicated ServiceAccount. + create: true + # -- Automount the ServiceAccount token. Disabled by default (least privilege). + automount: false + # -- Annotations to add to the ServiceAccount. + annotations: {} + # -- ServiceAccount name. Generated when empty and create is true. + name: "" + +# -- Additional annotations for the pod. +podAnnotations: {} +# -- Additional labels for the pod. +podLabels: {} + +# Pod-level security context. Matches the distroless nonroot image (UID/GID 65532). +podSecurityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + fsGroup: 65532 + seccompProfile: + type: RuntimeDefault + +# Container-level security context. The image supports a read-only root filesystem; +# writable paths (/app/.cache, /app/data) are provided via mounted volumes. +securityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + readOnlyRootFilesystem: true + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + +service: + # -- Kubernetes Service type. + type: ClusterIP + # -- Service port. + port: 8080 + # -- Fixed node port (30000-32767). Only used when `type` is `NodePort`; leave + # null to let Kubernetes allocate one. Handy for local kind port mappings. + nodePort: null + # -- Extra annotations for the Service. + annotations: {} + +ingress: + # -- Enable an Ingress resource. + enabled: false + # -- IngressClass name. + className: "" + # -- Ingress annotations. + annotations: {} + hosts: + - host: gomodel.local + paths: + - path: / + pathType: Prefix + # -- TLS configuration. + tls: [] + # - secretName: gomodel-tls + # hosts: + # - gomodel.local + +# -- Resource requests and limits. +resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: "1" + memory: 512Mi + +# Liveness probe hits GET /health -> 200 {"status":"ok"}. +livenessProbe: + enabled: true + initialDelaySeconds: 5 + periodSeconds: 15 + timeoutSeconds: 3 + failureThreshold: 3 + successThreshold: 1 + +# Readiness probe hits GET /health/ready. Returns 503 (not_ready) when primary +# storage is unreachable, which removes the pod from Service endpoints. +readinessProbe: + enabled: true + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + successThreshold: 1 + +# Startup probe protects slow first boots without loosening the liveness probe. +startupProbe: + enabled: true + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 30 + successThreshold: 1 + +autoscaling: + # -- Enable a HorizontalPodAutoscaler. Ignored when `persistence.enabled` is true. + enabled: false + minReplicas: 2 + maxReplicas: 10 + targetCPUUtilizationPercentage: 80 + # -- Target memory utilization percentage. Set to null to disable. + targetMemoryUtilizationPercentage: null + +podDisruptionBudget: + # -- Enable a PodDisruptionBudget. + enabled: false + # -- Minimum available pods. Mutually exclusive with maxUnavailable. + minAvailable: 1 + # -- Maximum unavailable pods. + maxUnavailable: null + +# Persistence enables SQLite storage mode. When true, the workload is deployed as a +# StatefulSet with a PersistentVolumeClaim mounted at /app/data, and replicaCount is +# forced to 1 (SQLite cannot be shared). Leave disabled and use external Postgres/ +# MongoDB + Redis for a horizontally scalable, stateless deployment. +persistence: + # -- Enable persistent SQLite storage (StatefulSet mode). + enabled: false + # -- Existing PVC name to use instead of a volumeClaimTemplate. + existingClaim: "" + # -- StorageClass for the volume. Empty uses the cluster default. + storageClass: "" + accessModes: + - ReadWriteOnce + # -- Volume size. + size: 1Gi + # -- Annotations for the PVC. + annotations: {} + +metrics: + # -- Enable Prometheus metrics (sets METRICS_ENABLED=true and exposes /metrics). + enabled: false + serviceMonitor: + # -- Create a Prometheus Operator ServiceMonitor. Implies metrics.enabled. + enabled: false + # -- Namespace for the ServiceMonitor. Defaults to the release namespace. + namespace: "" + # -- Scrape interval. + interval: 30s + # -- Scrape timeout. + scrapeTimeout: 10s + # -- Extra labels for the ServiceMonitor (e.g. to match a Prometheus selector). + labels: {} + # -- Metric relabelings. + metricRelabelings: [] + # -- Relabelings. + relabelings: [] + +networkPolicy: + # -- Create a NetworkPolicy restricting ingress/egress. + enabled: false + # -- Extra ingress rules appended to the default (allow to the HTTP port). + extraIngress: [] + # -- Extra egress rules appended to the defaults (DNS + HTTPS to providers). + extraEgress: [] + +# -- The GoModel config.yaml rendered into a ConfigMap and mounted read-only at +# /app/config/config.yaml. Supports ${ENV_VAR} expansion, so secret values should be +# referenced by name here and supplied via `secrets` / `envFrom`. Leave empty to rely +# purely on environment variables and built-in defaults. +config: {} +# server: +# port: "8080" +# storage: +# type: postgresql +# postgresql: +# url: ${POSTGRES_URL} +# cache: +# model: +# redis: +# url: ${REDIS_URL} + +secrets: + # -- Use an existing Secret (referenced via envFrom) instead of a chart-managed one. + # Recommended for production / external secret managers. + existingSecret: "" + # -- GoModel master key (GOMODEL_MASTER_KEY). Strongly recommended; without it the + # gateway runs in UNSAFE mode. + masterKey: "" + # -- Provider API keys and connection URLs, rendered into the chart Secret and + # injected via envFrom. Keys are used verbatim as env var names. + # Example: + # OPENAI_API_KEY: sk-... + # ANTHROPIC_API_KEY: sk-ant-... + # POSTGRES_URL: postgres://user:pass@host:5432/gomodel + # REDIS_URL: redis://redis:6379 + data: {} + +# -- Non-secret environment variables (env vars always win over config.yaml). +env: + # PORT: "8080" + # LOG_LEVEL: info + # LOG_FORMAT: json + +# -- Extra environment variables using full valueFrom syntax. +extraEnv: [] +# - name: SOME_KEY +# valueFrom: +# secretKeyRef: +# name: my-secret +# key: some-key + +# -- Extra volumes. +extraVolumes: [] +# -- Extra volume mounts. +extraVolumeMounts: [] + +# -- Deployment update strategy (stateless mode). Zero-downtime by default. +updateStrategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 0 + maxSurge: 1 + +# -- Node selector. +nodeSelector: {} +# -- Tolerations. +tolerations: [] +# -- Affinity rules. +affinity: {} +# -- Topology spread constraints. +topologySpreadConstraints: [] +# -- Grace period for pod termination. +terminationGracePeriodSeconds: 30 diff --git a/deploy/local/deps.yaml b/deploy/local/deps.yaml new file mode 100644 index 000000000..e860ec79f --- /dev/null +++ b/deploy/local/deps.yaml @@ -0,0 +1,152 @@ +# In-cluster development dependencies for GoModel: Redis, PostgreSQL and MongoDB. +# These mirror the images and settings from docker-compose.yaml and are deployed +# by Skaffold alongside the app. Data is ephemeral (emptyDir) — this is a dev +# convenience, not a durable store. +# +# Service DNS names (used by deploy/local/values.yaml): +# redis://redis:6379 +# postgres://gomodel:gomodel@postgres:5432/gomodel +# mongodb://mongodb:27017/gomodel +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: redis + labels: + app: redis + app.kubernetes.io/part-of: gomodel-dev +spec: + replicas: 1 + selector: + matchLabels: + app: redis + template: + metadata: + labels: + app: redis + spec: + containers: + - name: redis + image: redis:7-alpine + args: ["redis-server", "--appendonly", "yes"] + ports: + - containerPort: 6379 + readinessProbe: + exec: + command: ["redis-cli", "ping"] + periodSeconds: 5 + timeoutSeconds: 3 +--- +apiVersion: v1 +kind: Service +metadata: + name: redis + labels: + app: redis + app.kubernetes.io/part-of: gomodel-dev +spec: + selector: + app: redis + ports: + - port: 6379 + targetPort: 6379 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: postgres + labels: + app: postgres + app.kubernetes.io/part-of: gomodel-dev +spec: + replicas: 1 + selector: + matchLabels: + app: postgres + template: + metadata: + labels: + app: postgres + spec: + containers: + - name: postgres + image: postgres:18-alpine + env: + - name: POSTGRES_USER + value: gomodel + - name: POSTGRES_PASSWORD + value: gomodel + - name: POSTGRES_DB + value: gomodel + ports: + - containerPort: 5432 + readinessProbe: + exec: + command: ["pg_isready", "-U", "gomodel", "-d", "gomodel"] + periodSeconds: 5 + timeoutSeconds: 3 +--- +apiVersion: v1 +kind: Service +metadata: + name: postgres + labels: + app: postgres + app.kubernetes.io/part-of: gomodel-dev +spec: + selector: + app: postgres + ports: + - port: 5432 + targetPort: 5432 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: mongodb + labels: + app: mongodb + app.kubernetes.io/part-of: gomodel-dev +spec: + replicas: 1 + selector: + matchLabels: + app: mongodb + template: + metadata: + labels: + app: mongodb + spec: + containers: + - name: mongodb + image: mongo:8 + args: ["--replSet", "rs0", "--bind_ip_all"] + ports: + - containerPort: 27017 + readinessProbe: + exec: + command: + - mongosh + - --quiet + - --eval + - >- + try { rs.status() } catch(e) { + rs.initiate({_id:'rs0',members:[{_id:0,host:'localhost:27017'}]}) }; + if (!db.hello().isWritablePrimary) { quit(1) } + periodSeconds: 5 + timeoutSeconds: 10 + failureThreshold: 10 +--- +apiVersion: v1 +kind: Service +metadata: + name: mongodb + labels: + app: mongodb + app.kubernetes.io/part-of: gomodel-dev +spec: + selector: + app: mongodb + ports: + - port: 27017 + targetPort: 27017 diff --git a/deploy/local/kind-cluster.yaml b/deploy/local/kind-cluster.yaml new file mode 100644 index 000000000..47325ba8f --- /dev/null +++ b/deploy/local/kind-cluster.yaml @@ -0,0 +1,22 @@ +# kind cluster for local GoModel development. +# Create with: make kind-up (or: kind create cluster --config deploy/local/kind-cluster.yaml) +# +# Access to the gateway is provided by Skaffold's port-forward (localhost:8080), +# so no NodePort/Ingress is required for the default workflow. The extra port +# mappings below are kept ready for an optional Ingress controller on 80/443. +kind: Cluster +apiVersion: kind.x-k8s.io/v1alpha4 +name: gomodel-dev +nodes: + - role: control-plane + # Enable these mappings if you later add an ingress controller. + # kubeadmConfigPatches: + # - | + # kind: InitConfiguration + # nodeRegistration: + # kubeletExtraArgs: + # node-labels: "ingress-ready=true" + extraPortMappings: + - containerPort: 30080 + hostPort: 8080 + protocol: TCP diff --git a/deploy/local/mockllm.yaml b/deploy/local/mockllm.yaml new file mode 100644 index 000000000..fde4bce4a --- /dev/null +++ b/deploy/local/mockllm.yaml @@ -0,0 +1,258 @@ +# Mock upstream LLM provider for local development. +# +# A tiny stdlib-only Python server that speaks the OpenAI API surface GoModel +# needs: GET /v1/models, POST /v1/chat/completions (streaming + non-streaming) +# and POST /v1/responses. It accepts any API key, so GoModel can route real +# requests end-to-end without real provider credentials. +# +# Point the gateway at it via deploy/local/values.yaml: +# OPENAI_BASE_URL: http://mockllm:8080/v1 +# +# Deployed by `make kind-up` alongside the other dependencies. +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: mockllm + labels: + app: mockllm + app.kubernetes.io/part-of: gomodel-dev +data: + mockllm.py: | + #!/usr/bin/env python3 + """Minimal OpenAI-compatible mock LLM server (stdlib only).""" + import json + import time + from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + MODELS = ["gpt-4o", "gpt-4o-mini", "gpt-3.5-turbo"] + + + def now(): + return int(time.time()) + + + def last_user_text(req): + for msg in reversed(req.get("messages") or []): + if msg.get("role") == "user": + content = msg.get("content") + if isinstance(content, str): + return content + if isinstance(content, list): + for item in content: + if isinstance(item, dict) and item.get("type") == "text": + return item.get("text", "") + return "Hello" + + + def input_text(req): + value = req.get("input") + if isinstance(value, str): + return value + if isinstance(value, list): + for item in reversed(value): + if isinstance(item, dict) and item.get("role") == "user": + content = item.get("content") + if isinstance(content, str): + return content + return "Hello" + + + class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, *args): + pass + + def _send_json(self, code, obj): + body = json.dumps(obj).encode() + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _read_body(self): + length = int(self.headers.get("Content-Length", "0") or 0) + raw = self.rfile.read(length) if length else b"{}" + try: + return json.loads(raw or b"{}") + except json.JSONDecodeError: + return None + + def do_GET(self): + if self.path.rstrip("/").endswith("/models"): + self._send_json(200, { + "object": "list", + "data": [ + {"id": m, "object": "model", "owned_by": "mock", "created": now()} + for m in MODELS + ], + }) + return + self._send_json(404, {"error": {"message": "not found", "type": "invalid_request_error"}}) + + def do_POST(self): + req = self._read_body() + if req is None: + self._send_json(400, {"error": {"message": "invalid json", "type": "invalid_request_error"}}) + return + path = self.path.rstrip("/") + if path.endswith("/chat/completions"): + self._chat(req) + elif path.endswith("/responses"): + self._responses(req) + else: + self._send_json(404, {"error": {"message": "not found", "type": "invalid_request_error"}}) + + def _chat(self, req): + model = req.get("model", "gpt-4o") + content = "Mock response to: " + last_user_text(req) + if req.get("stream"): + self._chat_stream(model, content) + return + self._send_json(200, { + "id": "chatcmpl-mock", + "object": "chat.completion", + "created": now(), + "model": model, + "choices": [{ + "index": 0, + "message": {"role": "assistant", "content": content}, + "finish_reason": "stop", + }], + "usage": {"prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30}, + }) + + def _start_stream(self): + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.send_header("Connection", "keep-alive") + self.send_header("Transfer-Encoding", "chunked") + self.end_headers() + + def _sse(self, obj): + chunk = ("data: " + json.dumps(obj) + "\n\n").encode() + self.wfile.write(("%X\r\n" % len(chunk)).encode() + chunk + b"\r\n") + self.wfile.flush() + + def _sse_done(self): + done = b"data: [DONE]\n\n" + self.wfile.write(("%X\r\n" % len(done)).encode() + done + b"\r\n") + self.wfile.write(b"0\r\n\r\n") + self.wfile.flush() + + def _chat_stream(self, model, content): + self._start_stream() + words = content.split(" ") + for i, word in enumerate(words): + self._sse({ + "id": "chatcmpl-mock", + "object": "chat.completion.chunk", + "created": now(), + "model": model, + "choices": [{ + "index": 0, + "delta": {"content": word if i == 0 else " " + word}, + "finish_reason": None, + }], + }) + time.sleep(0.02) + self._sse({ + "id": "chatcmpl-mock", + "object": "chat.completion.chunk", + "created": now(), + "model": model, + "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 10, "completion_tokens": len(words), "total_tokens": 10 + len(words)}, + }) + self._sse_done() + + def _responses(self, req): + model = req.get("model", "gpt-4o") + content = "Mock response to: " + input_text(req) + if req.get("stream"): + self._responses_stream(model, content) + return + self._send_json(200, { + "id": "resp_mock", + "object": "response", + "created_at": now(), + "model": model, + "status": "completed", + "output": [{ + "id": "msg_mock", + "type": "message", + "role": "assistant", + "status": "completed", + "content": [{"type": "output_text", "text": content, "annotations": []}], + }], + "usage": {"input_tokens": 10, "output_tokens": 20, "total_tokens": 30}, + "error": None, + }) + + def _responses_stream(self, model, content): + self._start_stream() + self._sse({"type": "response.created", "response": {"id": "resp_mock", "object": "response", "status": "in_progress", "model": model, "created_at": now()}}) + for word in content.split(" "): + self._sse({"type": "response.output_text.delta", "delta": word + " "}) + time.sleep(0.02) + self._sse({"type": "response.completed", "response": {"id": "resp_mock", "object": "response", "status": "completed", "model": model, "created_at": now(), "usage": {"input_tokens": 10, "output_tokens": 20, "total_tokens": 30}}}) + self._sse_done() + + + if __name__ == "__main__": + ThreadingHTTPServer(("0.0.0.0", 8080), Handler).serve_forever() +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: mockllm + labels: + app: mockllm + app.kubernetes.io/part-of: gomodel-dev +spec: + replicas: 1 + selector: + matchLabels: + app: mockllm + template: + metadata: + labels: + app: mockllm + spec: + containers: + - name: mockllm + image: python:3.13-alpine + command: ["python3", "/config/mockllm.py"] + ports: + - containerPort: 8080 + volumeMounts: + - name: script + mountPath: /config + readOnly: true + readinessProbe: + httpGet: + path: /v1/models + port: 8080 + periodSeconds: 5 + timeoutSeconds: 3 + volumes: + - name: script + configMap: + name: mockllm +--- +apiVersion: v1 +kind: Service +metadata: + name: mockllm + labels: + app: mockllm + app.kubernetes.io/part-of: gomodel-dev +spec: + selector: + app: mockllm + ports: + - port: 8080 + targetPort: 8080 diff --git a/deploy/local/values.yaml b/deploy/local/values.yaml new file mode 100644 index 000000000..c9420be02 --- /dev/null +++ b/deploy/local/values.yaml @@ -0,0 +1,68 @@ +# Helm values for local Kubernetes development. +# Used by skaffold.yaml (deploy.helm) and layered on top of deploy/helm/gomodel/values.yaml. +# +# This deploys GoModel in the production-like STATELESS mode backed by the +# in-cluster Redis + PostgreSQL from deploy/local/deps.yaml, so the real +# storage/cache code paths are exercised locally. + +# Skaffold builds and tags the image; imageStrategy.helm rewrites these. +image: + repository: gomodel + tag: dev + # Never pull: the image is built locally and loaded into kind. + pullPolicy: IfNotPresent + +replicaCount: 1 + +# Expose the Service on a fixed NodePort so kind's extraPortMapping +# (30080 -> host 8080) works even without Skaffold port-forwarding. +service: + type: NodePort + port: 8080 + nodePort: 30080 + +# Relaxed limits for laptops; probes stay on to validate the real behavior. +resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: "1" + memory: 512Mi + +metrics: + enabled: true + +secrets: + # Dev-only master key. NEVER use this outside local development. + masterKey: "dev-master-key" + data: + POSTGRES_URL: "postgres://gomodel:gomodel@postgres:5432/gomodel" + REDIS_URL: "redis://redis:6379" + MONGODB_URL: "mongodb://mongodb:27017/gomodel" + # By default the OpenAI provider points at the in-cluster mock LLM + # (deploy/local/mockllm.yaml), so chat/completions work end-to-end without + # real credentials. The mock accepts any key. To call the real OpenAI API, + # set a real OPENAI_API_KEY and remove OPENAI_BASE_URL from `env` below. + OPENAI_API_KEY: "sk-mock" + +config: + server: + port: "8080" + storage: + type: postgresql + postgresql: + url: ${POSTGRES_URL} + cache: + model: + redis: + url: ${REDIS_URL} + +# Verbose logs for development. +env: + LOG_LEVEL: debug + LOG_FORMAT: text + LOGGING_ENABLED: "true" + # Route the OpenAI provider to the in-cluster mock LLM. Remove to use the + # real OpenAI API (with a real OPENAI_API_KEY). + OPENAI_BASE_URL: "http://mockllm:8080/v1" diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 39d4346f4..01459b6cb 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -66,7 +66,7 @@ Releases are generated automatically from merged PRs, categorized by labels and You can compress the whole repository for LLMs with the following command: ``` -$ repomix -i "./*.md,./**/*_test.go,./tests/,./**/*.md,./.claude/,./data/,./docs/,./helm/,./.cache/,./.github/,./cmd/gomodel/docs/" --style=markdown --remove-comments +$ repomix -i "./*.md,./**/*_test.go,./tests/,./**/*.md,./.claude/,./data/,./docs/,./deploy/,./.cache/,./.github/,./cmd/gomodel/docs/" --style=markdown --remove-comments ``` ## Log output @@ -102,3 +102,32 @@ LOG_FORMAT=text make run # force text output LOG_FORMAT=json make run # force JSON output LOG_LEVEL=debug make run # include debug logs ``` + +## Running locally + +Several workflows are available depending on how close to production you want to be: + +| Workflow | Command | Notes | +| --- | --- | --- | +| Bare process | `make run` | Fastest; SQLite + local file cache by default | +| Docker Compose (infra) | `make infra` | Redis, Postgres, MongoDB, Adminer | +| Docker Compose (full) | `make image` | App + Prometheus on top of infra | +| Local Kubernetes | `make kind-up && make dev-k8s` | kind + Skaffold + Helm chart | + +### Local Kubernetes (kind + Skaffold) + +Develop against a local kind cluster with a build → deploy → watch loop that uses +the production Helm chart: + +```bash +make kind-up # create the kind cluster + dependencies + mock LLM (one time) +make dev-k8s # build, load into kind, deploy, watch (access on :8080) +make undeploy-k8s +make kind-down +``` + +`make kind-up` also deploys an in-cluster OpenAI-compatible mock upstream, so +`/v1/chat/completions` works end-to-end without real provider credentials. + +See [dev/local-kubernetes.md](dev/local-kubernetes.md) for prerequisites, +configuration, and troubleshooting. diff --git a/docs/dev/local-kubernetes.md b/docs/dev/local-kubernetes.md new file mode 100644 index 000000000..3491b25be --- /dev/null +++ b/docs/dev/local-kubernetes.md @@ -0,0 +1,164 @@ +# Local Kubernetes Development + +Develop GoModel against a local [kind](https://kind.sigs.k8s.io/) cluster using +[Skaffold](https://skaffold.dev/) for a build → deploy → watch loop. The app is +deployed through the same production [Helm chart](../../deploy/helm/gomodel) with dev overrides, +so you exercise the real Kubernetes, storage and cache code paths locally. + +## Prerequisites + +Install these once: + +| Tool | Purpose | Install | +| --- | --- | --- | +| [Docker](https://docs.docker.com/get-docker/) | Container runtime for kind + image builds | — | +| [kind](https://kind.sigs.k8s.io/docs/user/quick-start/#installation) | Local Kubernetes cluster | `go install sigs.k8s.io/kind@latest` | +| [kubectl](https://kubernetes.io/docs/tasks/tools/) | Cluster CLI | — | +| [Helm](https://helm.sh/docs/intro/install/) | Chart rendering | — | +| [Skaffold](https://skaffold.dev/docs/install/) | Build + deploy inner loop | — | + +## Layout + +``` +deploy/local/ + kind-cluster.yaml # kind cluster (maps NodePort 30080 -> host 8080) + deps.yaml # in-cluster Redis + PostgreSQL + MongoDB (ephemeral) + mockllm.yaml # in-cluster OpenAI-compatible mock upstream provider + values.yaml # Helm dev overrides (stateless: Postgres + Redis) +skaffold.yaml # build (Dockerfile.dev) + deploy (Helm) +Dockerfile.dev # fast native-arch build image +``` + +The dependencies (Redis, PostgreSQL, MongoDB) and the mock upstream provider are +deployed once by `make kind-up` and persist across app redeploys — Skaffold +manages only the GoModel app. This guarantees the datastores are ready before the +app's first boot. + +## Mock upstream provider + +`deploy/local/mockllm.yaml` runs a tiny stdlib-only Python server +([`ThreadingHTTPServer`](https://docs.python.org/3/library/http.server.html)) +mounted from a ConfigMap into a `python:3.13-alpine` pod — no second image build, +fully offline. It speaks the slice of the OpenAI API that GoModel needs: + +- `GET /v1/models` — advertises `gpt-4o`, `gpt-4o-mini`, `gpt-3.5-turbo`. +- `POST /v1/chat/completions` — streaming (SSE) and non-streaming. +- `POST /v1/responses` — streaming and non-streaming. + +It accepts **any** API key, so GoModel routes real requests end-to-end without +provider credentials. The dev values point the OpenAI provider at it via +`OPENAI_BASE_URL: http://mockllm:8080/v1`, which is how the gateway loads three +models on boot as `openai/gpt-4o`, `openai/gpt-4o-mini`, `openai/gpt-3.5-turbo`. + +To point at a real provider instead, drop `OPENAI_BASE_URL` from +`deploy/local/values.yaml` and set a real `OPENAI_API_KEY` (see +[Adding a provider API key](#adding-a-provider-api-key)). + +## Quick start + +```bash +make kind-up # create the kind cluster + deploy deps + mock LLM (one time) +make dev-k8s # build, load into kind, deploy, watch for changes +``` + +Then, in another terminal: + +```bash +curl localhost:8080/health # {"status":"ok"} +curl localhost:8080/health/ready # {"status":"ready", ...} once Postgres is reachable +open http://localhost:8080/admin/dashboard +``` + +List the models loaded from the mock upstream, then send a chat completion +straight through the gateway (auth uses the dev master key): + +```bash +curl localhost:8080/v1/models -H "Authorization: Bearer dev-master-key" +# lists openai/gpt-4o, openai/gpt-4o-mini, openai/gpt-3.5-turbo + +curl localhost:8080/v1/chat/completions \ + -H "Authorization: Bearer dev-master-key" \ + -H "Content-Type: application/json" \ + -d '{"model":"openai/gpt-3.5-turbo","messages":[{"role":"user","content":"ping"}]}' +# {"...","choices":[{"message":{"role":"assistant","content":"Mock response to: ping"}... +``` + +Add `"stream":true,"stream_options":{"include_usage":true}` for a streamed SSE +response. + +Edit any Go file and Skaffold automatically rebuilds the image, reloads it into +kind, and rolls the pod. Press `Ctrl+C` to stop `skaffold dev` (it uninstalls the +app release; the dependencies stay up). + +## Common commands + +```bash +make deploy-k8s # one-shot build + deploy (no watch) +make undeploy-k8s # remove the app release (dependencies stay up) +make kind-down # delete the whole cluster (removes dependencies too) +``` + +## Configuration + +Dev overrides live in [`deploy/local/values.yaml`](../../deploy/local/values.yaml): + +- **Stateless mode** backed by the in-cluster `postgres` and `redis` Services. +- `image.pullPolicy: IfNotPresent` — the image is built locally and loaded into + kind (never pulled from a registry). Skaffold rewrites `image.repository`/`tag`. +- `secrets.masterKey: dev-master-key` — **dev only**, never reuse elsewhere. +- `secrets.data.OPENAI_API_KEY: sk-mock` and `env.OPENAI_BASE_URL: + http://mockllm:8080/v1` — point the OpenAI provider at the in-cluster + [mock upstream](#mock-upstream-provider) so the gateway boots with real models. +- Verbose logging (`LOG_LEVEL=debug`, text format) and audit logging enabled. + +### Adding a provider API key + +Replace the placeholder or add credentials under `secrets.data` in +`deploy/local/values.yaml`, and drop `env.OPENAI_BASE_URL` to hit the real +upstream instead of the mock: + +```yaml +secrets: + data: + OPENAI_API_KEY: "sk-..." +``` + +Skaffold redeploys on save. Avoid committing real keys — keep them in your local +working copy only. + +### Switching to SQLite + +For the lightest footprint, deploy in SQLite mode instead of Postgres/Redis: +set `persistence.enabled: true` and remove the `storage`/`cache` blocks from +`deploy/local/values.yaml`. The chart then runs a single-replica StatefulSet +with a PVC at `/app/data`. + +## Access + +The gateway is reachable at `http://localhost:8080` via the **kind NodePort +mapping**: the Service is a `NodePort` on `30080`, which +`deploy/local/kind-cluster.yaml` maps to host port `8080`. This works whether or +not `skaffold dev` is running (e.g. after `make deploy-k8s`), so no separate +port-forward is needed. + +## Troubleshooting + +- **`ErrImageNeverPull` / image not found** — ensure you launched via Skaffold + (`make dev-k8s`/`deploy-k8s`) so the image is loaded into kind. `pullPolicy` + must be `IfNotPresent` (already set in dev values). +- **App CrashLoopBackOff: `no providers were successfully registered`** — at + least one `_API_KEY` must be set. The dev values ship + `OPENAI_API_KEY: sk-mock` pointed at the in-cluster mock; keep it or add your own. +- **Gateway `/v1/models` empty / registry `failed_providers`** — the mock upstream + isn't reachable. Ensure `make kind-up` deployed it (`kubectl get pods` shows + `mockllm`) and that `env.OPENAI_BASE_URL` is `http://mockllm:8080/v1`. +- **App CrashLoopBackOff: `failed to connect to redis` / DNS `server + misbehaving`** — the dependencies aren't up yet. Run `make kind-up` (it applies + `deploy/local/deps.yaml` and waits for rollout) before deploying the app. +- **Readiness stuck at 503** — the app returns `503` from `/health/ready` until + primary storage (Postgres) is reachable. Check `kubectl get pods` and + `kubectl logs deploy/postgres`. +- **MongoDB not ready** — the replica set self-initializes via the readiness + probe on first boot; give it up to ~30s. +- **Port 8080 already in use** — stop the conflicting process, or change the + `hostPort` in `deploy/local/kind-cluster.yaml` (then recreate the cluster). diff --git a/helm/Chart.lock b/helm/Chart.lock deleted file mode 100644 index c76da93d0..000000000 --- a/helm/Chart.lock +++ /dev/null @@ -1,6 +0,0 @@ -dependencies: -- name: redis - repository: oci://registry-1.docker.io/bitnamicharts - version: 24.1.0 -digest: sha256:46e96c6627f431805f1ded87a1d15a25c3aaf648a1242343f52b8c6a51e2cb2f -generated: "2026-01-08T06:38:01.742616425+01:00" diff --git a/helm/Chart.yaml b/helm/Chart.yaml deleted file mode 100644 index 2680a40ee..000000000 --- a/helm/Chart.yaml +++ /dev/null @@ -1,30 +0,0 @@ -apiVersion: v2 -name: gomodel -description: High-performance AI gateway for multiple LLM providers (OpenAI, Anthropic, Gemini, DeepSeek, Groq, Kilo AI, Z.ai, xAI) -type: application -version: 0.1.0 -appVersion: "1.0.0" - -keywords: - - llm - - ai - - gateway - - openai - - anthropic - - gemini - - groq - - xai - - proxy - -home: https://github.com/ENTERPILOT/GoModel -sources: - - https://github.com/ENTERPILOT/GoModel - -maintainers: - - name: GoModel Team - -dependencies: - - name: redis - version: "^24.0.0" - repository: oci://registry-1.docker.io/bitnamicharts - condition: redis.enabled diff --git a/helm/README.md b/helm/README.md deleted file mode 100644 index 548ff86a7..000000000 --- a/helm/README.md +++ /dev/null @@ -1,181 +0,0 @@ -# GoModel Helm Chart - -High-performance AI gateway for multiple LLM providers (OpenAI, Anthropic, Gemini, DeepSeek, Groq, Kilo AI, Z.ai, xAI, Oracle). - -## Prerequisites - -- Kubernetes 1.29+ (for Gateway API v1 support) -- Helm 3.x -- (Optional) Prometheus Operator for ServiceMonitor support - -## Installation - -### Add the Helm repository (if published) - -```bash -helm repo add gomodel https://your-org.github.io/gomodel -helm repo update -``` - -### Install from local chart - -```bash -# Basic install with OpenAI (provider auto-enables when apiKey is set) -helm install gomodel ./helm \ - -n gomodel --create-namespace \ - --set providers.openai.apiKey="sk-..." - -# Multi-provider setup with Redis cache -helm install gomodel ./helm \ - -n gomodel --create-namespace \ - --set providers.openai.apiKey="sk-..." \ - --set providers.anthropic.apiKey="sk-ant-..." \ - --set redis.enabled=true - -# Using existing secrets (GitOps-friendly) -helm install gomodel ./helm \ - -n gomodel --create-namespace \ - --set providers.existingSecret="llm-api-keys" \ - --set providers.openai.enabled=true \ - --set providers.anthropic.enabled=true -``` - -## Configuration - -### Key Values - -| Parameter | Description | Default | -| -------------------------------- | ---------------------------------------------------------------------------------------------- | ---------------------- | -| `replicaCount` | Number of replicas | `2` | -| `image.repository` | Image repository | `enterpilot/gomodel` | -| `image.tag` | Image tag | `""` (uses appVersion) | -| `server.port` | Server port | `8080` | -| `server.basePath` | URL path prefix where GoModel is mounted | `"/"` | -| `server.userPathHeader` | Header used to read/write request user_path values | `"X-GoModel-User-Path"` | -| `server.bodySizeLimit` | Max request body size | `"10M"` | -| `auth.masterKey` | Master key for auth | `""` | -| `auth.existingSecret` | Existing secret for auth | `""` | -| `providers.existingSecret` | Existing secret for API keys | `""` | -| `providers.openai.enabled` | Enable OpenAI | `false` | -| `providers.anthropic.enabled` | Enable Anthropic | `false` | -| `providers.gemini.enabled` | Enable Gemini | `false` | -| `providers.gemini.useNativeApi` | Use Gemini native generateContent for chat/responses; set false for Gemini OpenAI compatibility | `true` | -| `providers.groq.enabled` | Enable Groq | `false` | -| `providers.xai.enabled` | Enable xAI | `false` | -| `providers.zai.enabled` | Enable Z.ai | `false` | -| `providers.zai.baseUrl` | Optional Z.ai base URL mapped to `ZAI_BASE_URL`; use Coding Plan endpoint when needed | `""` | -| `providers.kilo.enabled` | Enable Kilo AI | `false` | -| `providers.kilo.baseUrl` | Optional Kilo AI Gateway base URL mapped to `KILO_BASE_URL` | `""` | -| `providers.oracle.enabled` | Enable Oracle | `false` | -| `providers.oracle.baseUrl` | Oracle OpenAI-compatible base URL mapped to `ORACLE_BASE_URL`; required when Oracle is enabled | `""` | -| `providers.vllm.enabled` | Enable vLLM | `false` | -| `providers.vllm.baseUrl` | vLLM OpenAI-compatible base URL mapped to `VLLM_BASE_URL`; required when vLLM is enabled | `""` | -| `cache.type` | Cache type (local/redis) | `"redis"` | -| `redis.enabled` | Deploy Redis subchart | `true` | -| `metrics.enabled` | Enable Prometheus metrics | `true` | -| `metrics.serviceMonitor.enabled` | Create ServiceMonitor | `false` | -| `logging.format` | Log format; empty auto-detects, or set `json`/`text` | `""` | -| `ingress.enabled` | Enable Ingress | `false` | -| `gateway.enabled` | Enable Gateway API HTTPRoute | `false` | -| `autoscaling.enabled` | Enable HPA | `false` | - -### Using Existing Secrets - -Create a secret with your API keys: - -```yaml -apiVersion: v1 -kind: Secret -metadata: - name: llm-api-keys -type: Opaque -stringData: - OPENAI_API_KEY: "sk-..." - ANTHROPIC_API_KEY: "sk-ant-..." - GEMINI_API_KEY: "..." - ZAI_API_KEY: "..." - KILO_API_KEY: "..." - ORACLE_API_KEY: "..." - VLLM_API_KEY: "..." -``` - -Oracle also requires a base URL in values. The chart maps `providers.oracle.baseUrl` -to the container env var `ORACLE_BASE_URL`. - -vLLM does not require an API key unless the upstream server was started with -`--api-key`. The chart maps `providers.vllm.baseUrl` to the container env var -`VLLM_BASE_URL`. - -Then reference it (use `enabled=true` when using existingSecret since apiKey isn't set directly): - -```bash -helm install gomodel ./helm \ - --set providers.existingSecret="llm-api-keys" \ - --set providers.openai.enabled=true -``` - -Example Oracle setup with an existing secret: - -```bash -helm install gomodel ./helm \ - --set providers.existingSecret="llm-api-keys" \ - --set providers.oracle.enabled=true \ - --set providers.oracle.baseUrl="https://inference.generativeai.us-chicago-1.oci.oraclecloud.com/20231130/actions/v1" -``` - -Example keyless vLLM setup: - -```bash -helm install gomodel ./helm \ - --set providers.vllm.enabled=true \ - --set providers.vllm.baseUrl="http://vllm.default.svc.cluster.local:8000/v1" -``` - -### Ingress Example - -```yaml -ingress: - enabled: true - className: nginx - annotations: - cert-manager.io/cluster-issuer: letsencrypt-prod - hosts: - - host: gomodel.example.com - paths: - - path: / - pathType: Prefix - tls: - - secretName: gomodel-tls - hosts: - - gomodel.example.com -``` - -### Gateway API Example - -```yaml -gateway: - enabled: true - parentRef: - name: my-gateway - namespace: gateway-system - hostnames: - - gomodel.example.com -``` - -## Upgrading - -```bash -helm upgrade gomodel ./helm -n gomodel -f values.yaml -``` - -## Uninstalling - -```bash -helm uninstall gomodel -n gomodel -``` - -# Todo - -- Add a values-demo.yaml file with a demo setup ready to run -- Consider adding prometheus + grafana stack as an optional subchart -- Add an example for production-ready redis configuration with persistence and authentication enabled diff --git a/helm/templates/NOTES.txt b/helm/templates/NOTES.txt deleted file mode 100644 index 2c6127768..000000000 --- a/helm/templates/NOTES.txt +++ /dev/null @@ -1,59 +0,0 @@ -Thank you for installing {{ .Chart.Name }}! - -Your release is named: {{ .Release.Name }} - -To get the application URL: -{{- if .Values.ingress.enabled }} -{{- range $host := .Values.ingress.hosts }} - http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ (first $host.paths).path }} -{{- end }} -{{- else if .Values.gateway.enabled }} - Configure your Gateway to route traffic to the service. - Service: {{ include "gomodel.fullname" . }}:{{ .Values.service.port }} -{{- else if contains "NodePort" .Values.service.type }} - export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "gomodel.fullname" . }}) - export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") - echo http://$NODE_IP:$NODE_PORT -{{- else if contains "LoadBalancer" .Values.service.type }} - NOTE: It may take a few minutes for the LoadBalancer IP to be available. - You can watch the status with: kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "gomodel.fullname" . }} - export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "gomodel.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") - echo http://$SERVICE_IP:{{ .Values.service.port }} -{{- else if contains "ClusterIP" .Values.service.type }} - kubectl --namespace {{ .Release.Namespace }} port-forward svc/{{ include "gomodel.fullname" . }} 8080:{{ .Values.service.port }} - echo "Visit http://127.0.0.1:8080 to use the API" -{{- end }} - -{{- if not .Values.auth.masterKey }} -{{- if not .Values.auth.existingSecret }} - -⚠️ WARNING: No authentication configured! - The gateway is running in UNSAFE MODE without authentication. - Set auth.masterKey or auth.existingSecret for production use. -{{- end }} -{{- end }} - -{{- $enabledProviders := list }} -{{- if or .Values.providers.openai.enabled .Values.providers.openai.apiKey }}{{ $enabledProviders = append $enabledProviders "openai" }}{{ end }} -{{- if or .Values.providers.anthropic.enabled .Values.providers.anthropic.apiKey }}{{ $enabledProviders = append $enabledProviders "anthropic" }}{{ end }} -{{- if or .Values.providers.gemini.enabled .Values.providers.gemini.apiKey }}{{ $enabledProviders = append $enabledProviders "gemini" }}{{ end }} -{{- if or .Values.providers.groq.enabled .Values.providers.groq.apiKey }}{{ $enabledProviders = append $enabledProviders "groq" }}{{ end }} -{{- if or .Values.providers.xai.enabled .Values.providers.xai.apiKey }}{{ $enabledProviders = append $enabledProviders "xai" }}{{ end }} -{{- if or .Values.providers.zai.enabled .Values.providers.zai.apiKey }}{{ $enabledProviders = append $enabledProviders "zai" }}{{ end }} -{{- if and .Values.providers.oracle.baseUrl (or .Values.providers.oracle.enabled .Values.providers.oracle.apiKey) }}{{ $enabledProviders = append $enabledProviders "oracle" }}{{ end }} - -{{- if eq (len $enabledProviders) 0 }} - -⚠️ WARNING: No providers enabled! - Provide an API key for at least one provider (e.g., providers.openai.apiKey) -{{- else }} - -Enabled providers: {{ join ", " $enabledProviders }} -{{- end }} - -To check the logs: - kubectl logs --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "gomodel.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -f - -To test the health endpoint: - kubectl --namespace {{ .Release.Namespace }} port-forward svc/{{ include "gomodel.fullname" . }} 8080:{{ .Values.service.port }} & - curl http://127.0.0.1:8080/health diff --git a/helm/templates/_helpers.tpl b/helm/templates/_helpers.tpl deleted file mode 100644 index 1ac6472f5..000000000 --- a/helm/templates/_helpers.tpl +++ /dev/null @@ -1,182 +0,0 @@ -{{/* -Expand the name of the chart. -*/}} -{{- define "gomodel.name" -}} -{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} -{{- end }} - -{{/* -Create a default fully qualified app name. -We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). -*/}} -{{- define "gomodel.fullname" -}} -{{- if .Values.fullnameOverride }} -{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} -{{- else }} -{{- $name := default .Chart.Name .Values.nameOverride }} -{{- if contains $name .Release.Name }} -{{- .Release.Name | trunc 63 | trimSuffix "-" }} -{{- else }} -{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} -{{- end }} -{{- end }} -{{- end }} - -{{/* -Create chart name and version as used by the chart label. -*/}} -{{- define "gomodel.chart" -}} -{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} -{{- end }} - -{{/* -Common labels -*/}} -{{- define "gomodel.labels" -}} -helm.sh/chart: {{ include "gomodel.chart" . }} -{{ include "gomodel.selectorLabels" . }} -{{- if .Chart.AppVersion }} -app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} -{{- end }} -app.kubernetes.io/managed-by: {{ .Release.Service }} -{{- end }} - -{{/* -Selector labels -*/}} -{{- define "gomodel.selectorLabels" -}} -app.kubernetes.io/name: {{ include "gomodel.name" . }} -app.kubernetes.io/instance: {{ .Release.Name }} -{{- end }} - -{{/* -Create the name of the secret containing provider API keys -*/}} -{{- define "gomodel.providerSecretName" -}} -{{- if .Values.providers.existingSecret }} -{{- .Values.providers.existingSecret }} -{{- else }} -{{- include "gomodel.fullname" . }}-providers -{{- end }} -{{- end }} - -{{/* -Create the name of the secret containing auth credentials -*/}} -{{- define "gomodel.authSecretName" -}} -{{- if .Values.auth.existingSecret }} -{{- .Values.auth.existingSecret }} -{{- else }} -{{- include "gomodel.fullname" . }}-auth -{{- end }} -{{- end }} - -{{/* -Determine the Redis URL - either from values or auto-generated for subchart -*/}} -{{- define "gomodel.redisUrl" -}} -{{- if .Values.cache.redis.url }} -{{- .Values.cache.redis.url }} -{{- else if .Values.redis.enabled }} -{{- printf "redis://%s-redis-master:6379" .Release.Name }} -{{- else }} -{{- "" }} -{{- end }} -{{- end }} - -{{/* -Create the image reference -*/}} -{{- define "gomodel.image" -}} -{{- $tag := .Values.image.tag | default .Chart.AppVersion }} -{{- printf "%s:%s" .Values.image.repository $tag }} -{{- end }} - -{{/* -Normalize the public base path used by the application. -*/}} -{{- define "gomodel.basePath" -}} -{{- $basePath := trim (default "/" .Values.server.basePath) -}} -{{- if or (eq $basePath "") (eq $basePath "/") -}} -/ -{{- else -}} -{{- if not (hasPrefix "/" $basePath) -}} -{{- $basePath = printf "/%s" $basePath -}} -{{- end -}} -{{- $basePath = clean $basePath -}} -{{- if or (eq $basePath ".") (eq $basePath "/") -}} -/ -{{- else -}} -{{- $basePath -}} -{{- end -}} -{{- end -}} -{{- end }} - -{{/* -Prefix an application path with server.basePath unless it is already prefixed. -*/}} -{{- define "gomodel.pathWithBasePath" -}} -{{- $root := .root -}} -{{- $path := trim (default "/" .path) -}} -{{- if or (eq $path "") (eq $path "/") -}} -{{- $path = "/" -}} -{{- else if not (hasPrefix "/" $path) -}} -{{- $path = printf "/%s" $path -}} -{{- end -}} -{{- $basePath := include "gomodel.basePath" $root -}} -{{- if eq $path "/" -}} -{{- if eq $basePath "/" -}} -{{- $path -}} -{{- else -}} -{{- $basePath -}} -{{- end -}} -{{- else if eq $basePath "/" -}} -{{- $path -}} -{{- else if or (eq $path $basePath) (hasPrefix (printf "%s/" $basePath) $path) -}} -{{- $path -}} -{{- else -}} -{{- printf "%s%s" $basePath $path -}} -{{- end -}} -{{- end }} - -{{/* -Generate provider API key entries for the Secret stringData. -*/}} -{{- define "gomodel.providerSecretData" -}} -{{- range $name, $config := .Values.providers }} - {{- if and (kindIs "map" $config) (hasKey $config "apiKey") $config.apiKey }} -{{ upper $name }}_API_KEY: {{ $config.apiKey | quote }} - {{- end }} -{{- end }} -{{- end }} - -{{/* -Generate provider environment variables for the Deployment. -*/}} -{{- define "gomodel.providerEnvVars" -}} -{{- $secretName := include "gomodel.providerSecretName" . -}} -{{- range $name, $config := .Values.providers }} -{{- if kindIs "map" $config }} -{{- $hasAPIKey := and (hasKey $config "apiKey") $config.apiKey }} -{{- $enabledWithExistingSecret := and $.Values.providers.existingSecret (hasKey $config "enabled") $config.enabled }} -{{- $enabledWithBaseURL := and (hasKey $config "enabled") $config.enabled $config.baseUrl }} -{{- if or $hasAPIKey $enabledWithExistingSecret }} -- name: {{ upper $name }}_API_KEY - valueFrom: - secretKeyRef: - name: {{ $secretName }} - key: {{ upper $name }}_API_KEY -{{- end }} -{{- if or (or $hasAPIKey $enabledWithExistingSecret) $enabledWithBaseURL }} -{{- if $config.baseUrl }} -- name: {{ upper $name }}_BASE_URL - value: {{ $config.baseUrl | quote }} -{{- end }} -{{- end }} -{{- if and (eq $name "gemini") (hasKey $config "useNativeApi") }} -- name: USE_GOOGLE_GEMINI_NATIVE_API - value: {{ $config.useNativeApi | quote }} -{{- end }} -{{- end }} -{{- end }} -{{- end }} diff --git a/helm/templates/configmap.yaml b/helm/templates/configmap.yaml deleted file mode 100644 index cd8debbe7..000000000 --- a/helm/templates/configmap.yaml +++ /dev/null @@ -1,21 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: {{ include "gomodel.fullname" . }} - labels: - {{- include "gomodel.labels" . | nindent 4 }} -data: - PORT: {{ .Values.server.port | quote }} - BASE_PATH: {{ .Values.server.basePath | default "/" | quote }} - USER_PATH_HEADER: {{ .Values.server.userPathHeader | default "X-GoModel-User-Path" | quote }} - BODY_SIZE_LIMIT: {{ .Values.server.bodySizeLimit | quote }} - {{- if or .Values.redis.enabled .Values.cache.redis.url }} - REDIS_KEY_MODELS: {{ .Values.cache.redis.keyModels | default "gomodel:models" | quote }} - REDIS_KEY_RESPONSES: {{ .Values.cache.redis.keyResponses | default "gomodel:response:" | quote }} - REDIS_TTL_MODELS: {{ .Values.cache.redis.ttlModels | default 86400 | quote }} - REDIS_TTL_RESPONSES: {{ .Values.cache.redis.ttlResponses | default 3600 | quote }} - RESPONSE_CACHE_SIMPLE_ENABLED: "true" - {{- end }} - METRICS_ENABLED: {{ .Values.metrics.enabled | quote }} - METRICS_ENDPOINT: {{ .Values.metrics.endpoint | quote }} - LOG_FORMAT: {{ .Values.logging.format | quote }} diff --git a/helm/templates/deployment.yaml b/helm/templates/deployment.yaml deleted file mode 100644 index e12dd561f..000000000 --- a/helm/templates/deployment.yaml +++ /dev/null @@ -1,153 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: {{ include "gomodel.fullname" . }} - labels: - {{- include "gomodel.labels" . | nindent 4 }} -spec: - {{- if not .Values.autoscaling.enabled }} - replicas: {{ .Values.replicaCount }} - {{- end }} - selector: - matchLabels: - {{- include "gomodel.selectorLabels" . | nindent 6 }} - template: - metadata: - annotations: - checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} - checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }} - {{- with .Values.podAnnotations }} - {{- toYaml . | nindent 8 }} - {{- end }} - labels: - {{- include "gomodel.selectorLabels" . | nindent 8 }} - {{- with .Values.podLabels }} - {{- toYaml . | nindent 8 }} - {{- end }} - spec: - {{- with .Values.imagePullSecrets }} - imagePullSecrets: - {{- toYaml . | nindent 8 }} - {{- end }} - securityContext: - {{- toYaml .Values.podSecurityContext | nindent 8 }} - containers: - - name: {{ .Chart.Name }} - securityContext: - {{- toYaml .Values.securityContext | nindent 12 }} - image: {{ include "gomodel.image" . }} - imagePullPolicy: {{ .Values.image.pullPolicy }} - ports: - - name: http - containerPort: {{ .Values.server.port }} - protocol: TCP - env: - # Server configuration - - name: PORT - valueFrom: - configMapKeyRef: - name: {{ include "gomodel.fullname" . }} - key: PORT - - name: BASE_PATH - valueFrom: - configMapKeyRef: - name: {{ include "gomodel.fullname" . }} - key: BASE_PATH - - name: USER_PATH_HEADER - valueFrom: - configMapKeyRef: - name: {{ include "gomodel.fullname" . }} - key: USER_PATH_HEADER - - name: BODY_SIZE_LIMIT - valueFrom: - configMapKeyRef: - name: {{ include "gomodel.fullname" . }} - key: BODY_SIZE_LIMIT - # Cache configuration - {{- if or .Values.redis.enabled .Values.cache.redis.url }} - - name: REDIS_URL - valueFrom: - secretKeyRef: - name: {{ include "gomodel.providerSecretName" . }} - key: REDIS_URL - optional: true - - name: REDIS_KEY_MODELS - valueFrom: - configMapKeyRef: - name: {{ include "gomodel.fullname" . }} - key: REDIS_KEY_MODELS - - name: REDIS_KEY_RESPONSES - valueFrom: - configMapKeyRef: - name: {{ include "gomodel.fullname" . }} - key: REDIS_KEY_RESPONSES - - name: REDIS_TTL_MODELS - valueFrom: - configMapKeyRef: - name: {{ include "gomodel.fullname" . }} - key: REDIS_TTL_MODELS - - name: REDIS_TTL_RESPONSES - valueFrom: - configMapKeyRef: - name: {{ include "gomodel.fullname" . }} - key: REDIS_TTL_RESPONSES - {{- end }} - # Logging configuration - - name: LOG_FORMAT - valueFrom: - configMapKeyRef: - name: {{ include "gomodel.fullname" . }} - key: LOG_FORMAT - # Metrics configuration - - name: METRICS_ENABLED - valueFrom: - configMapKeyRef: - name: {{ include "gomodel.fullname" . }} - key: METRICS_ENABLED - - name: METRICS_ENDPOINT - valueFrom: - configMapKeyRef: - name: {{ include "gomodel.fullname" . }} - key: METRICS_ENDPOINT - # Authentication - {{- if or .Values.auth.masterKey .Values.auth.existingSecret }} - - name: GOMODEL_MASTER_KEY - valueFrom: - secretKeyRef: - name: {{ include "gomodel.authSecretName" . }} - key: {{ .Values.auth.existingSecretKey | default "master-key" }} - {{- end }} - # Provider API keys (conditional based on enabled providers) -{{- include "gomodel.providerEnvVars" . | nindent 12 }} - volumeMounts: - - name: cache - mountPath: /cache - {{- $livenessProbe := deepCopy .Values.livenessProbe }} - {{- if and $livenessProbe.httpGet $livenessProbe.httpGet.path }} - {{- $_ := set $livenessProbe.httpGet "path" (include "gomodel.pathWithBasePath" (dict "root" . "path" $livenessProbe.httpGet.path)) }} - {{- end }} - livenessProbe: - {{- toYaml $livenessProbe | nindent 12 }} - {{- $readinessProbe := deepCopy .Values.readinessProbe }} - {{- if and $readinessProbe.httpGet $readinessProbe.httpGet.path }} - {{- $_ := set $readinessProbe.httpGet "path" (include "gomodel.pathWithBasePath" (dict "root" . "path" $readinessProbe.httpGet.path)) }} - {{- end }} - readinessProbe: - {{- toYaml $readinessProbe | nindent 12 }} - resources: - {{- toYaml .Values.resources | nindent 12 }} - volumes: - - name: cache - emptyDir: {} - {{- with .Values.nodeSelector }} - nodeSelector: - {{- toYaml . | nindent 8 }} - {{- end }} - {{- with .Values.affinity }} - affinity: - {{- toYaml . | nindent 8 }} - {{- end }} - {{- with .Values.tolerations }} - tolerations: - {{- toYaml . | nindent 8 }} - {{- end }} diff --git a/helm/templates/gateway.yaml b/helm/templates/gateway.yaml deleted file mode 100644 index 7afbbc2f4..000000000 --- a/helm/templates/gateway.yaml +++ /dev/null @@ -1,28 +0,0 @@ -{{- if .Values.gateway.enabled -}} -apiVersion: gateway.networking.k8s.io/v1 -kind: HTTPRoute -metadata: - name: {{ include "gomodel.fullname" . }} - labels: - {{- include "gomodel.labels" . | nindent 4 }} -spec: - parentRefs: - - name: {{ .Values.gateway.parentRef.name }} - {{- if .Values.gateway.parentRef.namespace }} - namespace: {{ .Values.gateway.parentRef.namespace }} - {{- end }} - {{- if .Values.gateway.hostnames }} - hostnames: - {{- range .Values.gateway.hostnames }} - - {{ . | quote }} - {{- end }} - {{- end }} - rules: - - matches: - - path: - type: PathPrefix - value: / - backendRefs: - - name: {{ include "gomodel.fullname" . }} - port: {{ .Values.service.port }} -{{- end }} diff --git a/helm/templates/secret.yaml b/helm/templates/secret.yaml deleted file mode 100644 index 57f97babd..000000000 --- a/helm/templates/secret.yaml +++ /dev/null @@ -1,26 +0,0 @@ -{{- if not .Values.providers.existingSecret }} -apiVersion: v1 -kind: Secret -metadata: - name: {{ include "gomodel.fullname" . }}-providers - labels: - {{- include "gomodel.labels" . | nindent 4 }} -type: Opaque -stringData: -{{- include "gomodel.providerSecretData" . | nindent 2 }} - {{- if or .Values.redis.enabled .Values.cache.redis.url }} - REDIS_URL: {{ include "gomodel.redisUrl" . | quote }} - {{- end }} -{{- end }} ---- -{{- if and .Values.auth.masterKey (not .Values.auth.existingSecret) }} -apiVersion: v1 -kind: Secret -metadata: - name: {{ include "gomodel.fullname" . }}-auth - labels: - {{- include "gomodel.labels" . | nindent 4 }} -type: Opaque -stringData: - master-key: {{ .Values.auth.masterKey | quote }} -{{- end }} diff --git a/helm/templates/servicemonitor.yaml b/helm/templates/servicemonitor.yaml deleted file mode 100644 index 2d5092cc6..000000000 --- a/helm/templates/servicemonitor.yaml +++ /dev/null @@ -1,19 +0,0 @@ -{{- if and .Values.metrics.enabled .Values.metrics.serviceMonitor.enabled }} -apiVersion: monitoring.coreos.com/v1 -kind: ServiceMonitor -metadata: - name: {{ include "gomodel.fullname" . }} - labels: - {{- include "gomodel.labels" . | nindent 4 }} - {{- with .Values.metrics.serviceMonitor.labels }} - {{- toYaml . | nindent 4 }} - {{- end }} -spec: - selector: - matchLabels: - {{- include "gomodel.selectorLabels" . | nindent 6 }} - endpoints: - - port: http - path: {{ include "gomodel.pathWithBasePath" (dict "root" . "path" .Values.metrics.endpoint) | quote }} - interval: {{ .Values.metrics.serviceMonitor.interval }} -{{- end }} diff --git a/helm/values.schema.json b/helm/values.schema.json deleted file mode 100644 index 5f0e386b6..000000000 --- a/helm/values.schema.json +++ /dev/null @@ -1,525 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft-07/schema#", - "title": "GoModel Helm Chart Values", - "type": "object", - "allOf": [ - { - "if": { - "properties": { - "gateway": { - "properties": { "enabled": { "const": true } } - } - } - }, - "then": { - "properties": { - "gateway": { - "properties": { - "parentRef": { - "properties": { - "name": { "minLength": 1 } - }, - "required": ["name"] - } - }, - "required": ["parentRef"] - } - } - } - }, - { - "if": { - "properties": { - "providers": { - "properties": { - "oracle": { - "properties": { "enabled": { "const": true } } - } - } - } - } - }, - "then": { - "properties": { - "providers": { - "properties": { - "oracle": { - "properties": { - "baseUrl": { "minLength": 1 } - }, - "required": ["baseUrl"] - } - } - } - } - } - }, - { - "if": { - "properties": { - "providers": { - "properties": { - "oracle": { - "properties": { - "apiKey": { "minLength": 1 } - } - } - } - } - } - }, - "then": { - "properties": { - "providers": { - "properties": { - "oracle": { - "properties": { - "baseUrl": { "minLength": 1 } - }, - "required": ["baseUrl"] - } - } - } - } - } - }, - { - "if": { - "properties": { - "providers": { - "properties": { - "vllm": { - "properties": { "enabled": { "const": true } } - } - } - } - } - }, - "then": { - "properties": { - "providers": { - "properties": { - "vllm": { - "properties": { - "baseUrl": { "minLength": 1 } - }, - "required": ["baseUrl"] - } - } - } - } - } - } - ], - "anyOf": [ - { - "properties": { - "providers": { - "properties": { "existingSecret": { "minLength": 1 } } - } - } - }, - { - "properties": { - "providers": { - "properties": { - "openai": { - "properties": { "apiKey": { "minLength": 1 } } - } - } - } - } - }, - { - "properties": { - "providers": { - "properties": { - "anthropic": { - "properties": { "apiKey": { "minLength": 1 } } - } - } - } - } - }, - { - "properties": { - "providers": { - "properties": { - "gemini": { - "properties": { "apiKey": { "minLength": 1 } } - } - } - } - } - }, - { - "properties": { - "providers": { - "properties": { - "groq": { - "properties": { "apiKey": { "minLength": 1 } } - } - } - } - } - }, - { - "properties": { - "providers": { - "properties": { - "xai": { - "properties": { "apiKey": { "minLength": 1 } } - } - } - } - } - }, - { - "properties": { - "providers": { - "properties": { - "zai": { - "properties": { "apiKey": { "minLength": 1 } } - } - } - } - } - }, - { - "properties": { - "providers": { - "properties": { - "kilo": { - "properties": { "apiKey": { "minLength": 1 } } - } - } - } - } - }, - { - "properties": { - "providers": { - "properties": { - "oracle": { - "properties": { - "apiKey": { "minLength": 1 }, - "baseUrl": { "minLength": 1 } - } - } - } - } - } - }, - { - "properties": { - "providers": { - "properties": { - "vllm": { - "properties": { - "enabled": { "const": true }, - "baseUrl": { "minLength": 1 } - } - } - } - } - } - } - ], - "properties": { - "replicaCount": { - "type": "integer", - "minimum": 1 - }, - "image": { - "type": "object", - "properties": { - "repository": { "type": "string" }, - "pullPolicy": { - "type": "string", - "enum": ["Always", "IfNotPresent", "Never"] - }, - "tag": { "type": "string" } - } - }, - "providers": { - "type": "object", - "properties": { - "existingSecret": { "type": "string" }, - "openai": { - "type": "object", - "properties": { - "enabled": { "type": "boolean" }, - "apiKey": { "type": "string" }, - "baseUrl": { "type": "string" } - } - }, - "anthropic": { - "type": "object", - "properties": { - "enabled": { "type": "boolean" }, - "apiKey": { "type": "string" }, - "baseUrl": { "type": "string" } - } - }, - "gemini": { - "type": "object", - "properties": { - "enabled": { "type": "boolean" }, - "apiKey": { "type": "string" }, - "useNativeApi": { "type": "boolean" }, - "baseUrl": { "type": "string" } - } - }, - "groq": { - "type": "object", - "properties": { - "enabled": { "type": "boolean" }, - "apiKey": { "type": "string" }, - "baseUrl": { "type": "string" } - } - }, - "xai": { - "type": "object", - "properties": { - "enabled": { "type": "boolean" }, - "apiKey": { "type": "string" }, - "baseUrl": { "type": "string" } - } - }, - "zai": { - "type": "object", - "properties": { - "enabled": { "type": "boolean" }, - "apiKey": { "type": "string" }, - "baseUrl": { "type": "string" } - } - }, - "kilo": { - "type": "object", - "properties": { - "enabled": { "type": "boolean" }, - "apiKey": { "type": "string" }, - "baseUrl": { "type": "string" } - } - }, - "oracle": { - "type": "object", - "properties": { - "enabled": { "type": "boolean" }, - "apiKey": { "type": "string" }, - "baseUrl": { "type": "string" } - } - }, - "vllm": { - "type": "object", - "properties": { - "enabled": { "type": "boolean" }, - "apiKey": { "type": "string" }, - "baseUrl": { "type": "string" } - } - } - } - }, - "cache": { - "type": "object", - "properties": { - "redis": { - "type": "object", - "properties": { - "url": { "type": "string", "description": "Redis connection URL" }, - "keyModels": { "type": "string", "description": "Redis key prefix for the model registry cache (default: gomodel:models)" }, - "ttlModels": { "type": "integer", "description": "TTL for model cache entries in seconds (default: 86400)" }, - "keyResponses": { "type": "string", "description": "Redis key prefix for the response cache (default: gomodel:response:)" }, - "ttlResponses": { "type": "integer", "description": "TTL for response cache entries in seconds (default: 3600)" } - } - } - } - }, - "redis": { - "type": "object", - "additionalProperties": true - }, - "server": { - "type": "object", - "properties": { - "port": { "type": "integer" }, - "basePath": { "type": "string" }, - "userPathHeader": { "type": "string" }, - "bodySizeLimit": { "type": "string" } - } - }, - "auth": { - "type": "object", - "properties": { - "masterKey": { "type": "string" }, - "existingSecret": { "type": "string" }, - "existingSecretKey": { "type": "string" } - } - }, - "metrics": { - "type": "object", - "properties": { - "enabled": { "type": "boolean" }, - "endpoint": { "type": "string" }, - "serviceMonitor": { - "type": "object", - "properties": { - "enabled": { "type": "boolean" }, - "interval": { "type": "string" }, - "labels": { "type": "object" } - } - } - } - }, - "logging": { - "type": "object", - "properties": { - "format": { - "type": "string", - "enum": ["", "json", "text"] - } - } - }, - "service": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["ClusterIP", "LoadBalancer", "NodePort"] - }, - "port": { "type": "integer" }, - "annotations": { "type": "object" } - } - }, - "ingress": { - "type": "object", - "properties": { - "enabled": { "type": "boolean" }, - "className": { "type": "string" }, - "annotations": { "type": "object" }, - "hosts": { - "type": "array", - "items": { - "type": "object", - "properties": { - "host": { "type": "string" }, - "paths": { - "type": "array", - "items": { - "type": "object", - "properties": { - "path": { "type": "string" }, - "pathType": { - "type": "string", - "enum": ["Prefix", "Exact", "ImplementationSpecific"] - } - } - } - } - } - } - }, - "tls": { - "type": "array", - "items": { - "type": "object", - "properties": { - "secretName": { "type": "string" }, - "hosts": { - "type": "array", - "items": { "type": "string" } - } - } - } - } - } - }, - "gateway": { - "type": "object", - "additionalProperties": true - }, - "resources": { - "type": "object", - "additionalProperties": true - }, - "autoscaling": { - "type": "object", - "properties": { - "enabled": { "type": "boolean" }, - "minReplicas": { - "type": "integer", - "minimum": 1 - }, - "maxReplicas": { - "type": "integer", - "minimum": 1 - }, - "targetCPUUtilizationPercentage": { - "type": "integer", - "minimum": 1, - "maximum": 100 - }, - "targetMemoryUtilizationPercentage": { - "type": "integer", - "minimum": 1, - "maximum": 100 - } - } - }, - "podDisruptionBudget": { - "type": "object", - "additionalProperties": true - }, - "livenessProbe": { - "type": "object", - "additionalProperties": true - }, - "readinessProbe": { - "type": "object", - "additionalProperties": true - }, - "podSecurityContext": { - "type": "object", - "additionalProperties": true - }, - "securityContext": { - "type": "object", - "additionalProperties": true - }, - "imagePullSecrets": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { "type": "string", "minLength": 1 } - }, - "required": ["name"] - } - }, - "nameOverride": { "type": "string" }, - "fullnameOverride": { "type": "string" }, - "nodeSelector": { "type": "object" }, - "tolerations": { - "type": "array", - "items": { - "type": "object", - "properties": { - "key": { "type": "string" }, - "operator": { - "type": "string", - "enum": ["Exists", "Equal"] - }, - "value": { "type": "string" }, - "effect": { - "type": "string", - "enum": ["NoSchedule", "PreferNoSchedule", "NoExecute", ""] - }, - "tolerationSeconds": { "type": "integer" } - } - } - }, - "affinity": { "type": "object" }, - "podAnnotations": { "type": "object" }, - "podLabels": { "type": "object" } - } -} diff --git a/helm/values.yaml b/helm/values.yaml deleted file mode 100644 index ba2c1a9b6..000000000 --- a/helm/values.yaml +++ /dev/null @@ -1,298 +0,0 @@ -# Default values for gomodel -# This is a YAML-formatted file. - -# -- Number of replicas (ignored if autoscaling.enabled is true) -replicaCount: 2 - -image: - # -- Image repository - repository: enterpilot/gomodel - # -- Image pull policy - pullPolicy: IfNotPresent - # -- Overrides the image tag (default is the chart appVersion) - tag: "" - -# -- Image pull secrets -imagePullSecrets: [] -# -- Override the name of the chart -nameOverride: "" -# -- Override the full name of the chart -fullnameOverride: "" - -# Server configuration -server: - # -- Server port - port: 8080 - # -- URL path prefix where GoModel is mounted (for example, "/g") - basePath: "/" - # -- Header used to read/write request user_path values - userPathHeader: "X-GoModel-User-Path" - # -- Maximum request body size (e.g., "10M", "1G", "500K") - bodySizeLimit: "10M" - -# Authentication configuration -auth: - # -- Master key for API authentication (leave empty to disable auth - NOT recommended for production) - masterKey: "" - # -- Use an existing secret for the master key - existingSecret: "" - # -- Key in the existing secret containing the master key - existingSecretKey: "master-key" - -# LLM Provider configuration -providers: - # -- Use an existing secret for all provider API keys - # Secret should contain keys: OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY, GROQ_API_KEY, XAI_API_KEY, ZAI_API_KEY, KILO_API_KEY, ORACLE_API_KEY, VLLM_API_KEY - existingSecret: "" - - openai: - # -- Enable OpenAI provider (auto-enabled if apiKey is set) - enabled: false - # -- OpenAI API key (ignored if providers.existingSecret is set) - apiKey: "" - # -- Optional: Override OpenAI base URL - baseUrl: "" - - anthropic: - # -- Enable Anthropic provider (auto-enabled if apiKey is set) - enabled: false - # -- Anthropic API key (ignored if providers.existingSecret is set) - apiKey: "" - # -- Optional: Override Anthropic base URL - baseUrl: "" - - gemini: - # -- Enable Google Gemini provider (auto-enabled if apiKey is set) - enabled: false - # -- Gemini API key (ignored if providers.existingSecret is set) - apiKey: "" - # -- Use Gemini native generateContent API for chat/responses. Set false to use Gemini's OpenAI-compatible API. - useNativeApi: true - # -- Optional: Override Gemini base URL - baseUrl: "" - - groq: - # -- Enable Groq provider (auto-enabled if apiKey is set) - enabled: false - # -- Groq API key (ignored if providers.existingSecret is set) - apiKey: "" - # -- Optional: Override Groq base URL - baseUrl: "" - - xai: - # -- Enable xAI (Grok) provider (auto-enabled if apiKey is set) - enabled: false - # -- xAI API key (ignored if providers.existingSecret is set) - apiKey: "" - # -- Optional: Override xAI base URL - baseUrl: "" - - zai: - # -- Enable Z.ai provider (auto-enabled if apiKey is set) - enabled: false - # -- Z.ai API key (ignored if providers.existingSecret is set) - apiKey: "" - # -- Optional: Override Z.ai base URL; use https://api.z.ai/api/coding/paas/v4 for GLM Coding Plan - baseUrl: "" - - kilo: - # -- Enable Kilo AI provider (auto-enabled if apiKey is set) - enabled: false - # -- Kilo AI API key (ignored if providers.existingSecret is set) - apiKey: "" - # -- Optional: Override Kilo AI Gateway base URL - baseUrl: "" - - oracle: - # -- Enable Oracle provider (auto-enabled if apiKey is set) - enabled: false - # -- Oracle API key (ignored if providers.existingSecret is set) - apiKey: "" - # -- Required: Oracle OpenAI-compatible base URL - baseUrl: "" - - vllm: - # -- Enable vLLM provider (API key optional; baseUrl required when enabled) - enabled: false - # -- Optional vLLM API key, matching vllm serve --api-key when configured - apiKey: "" - # -- vLLM OpenAI-compatible base URL - baseUrl: "" - -# Cache configuration -cache: - redis: - # -- Redis connection URL (e.g., "redis://redis:6379") - # If redis.enabled is true, this is auto-configured to use the subchart. - # Set this explicitly to use an external Redis instance instead. - url: "" - # -- Redis key prefix for storing the model cache - keyModels: "gomodel:models" - # -- TTL for model cache data in seconds (default: 24 hours) - ttlModels: 86400 - # -- Redis key prefix for storing the response cache - keyResponses: "gomodel:response:" - # -- TTL for response cache data in seconds (default: 1 hour) - ttlResponses: 3600 - -# Redis subchart configuration (Bitnami Redis) -redis: - # -- Deploy Redis subchart - enabled: true - # -- Redis architecture: standalone or replication - architecture: standalone - auth: - # -- Disable Redis authentication for simplicity - enabled: false - master: - persistence: - # -- Disable persistence for standalone mode - enabled: false - -# Prometheus metrics configuration -metrics: - # -- Enable Prometheus metrics - enabled: true - # -- Metrics endpoint path - endpoint: "/metrics" - - serviceMonitor: - # -- Create a ServiceMonitor for Prometheus Operator - enabled: false - # ServiceMonitor path is automatically prefixed with server.basePath. - # -- Scrape interval - interval: "15s" - # -- Additional labels for the ServiceMonitor - labels: {} - -# Logging configuration -logging: - # -- Log format; leave empty to auto-detect, or set to "json" or "text" - format: "" - -# Kubernetes Service configuration -service: - # -- Service type: ClusterIP, LoadBalancer, NodePort - type: ClusterIP - # -- Service port - port: 8080 - # -- Service annotations - annotations: {} - -# Ingress configuration -ingress: - # -- Enable Ingress - enabled: false - # -- Ingress class name - className: "" - # -- Ingress annotations - annotations: {} - # -- Ingress hosts configuration - hosts: - - host: gomodel.local - paths: - - path: / - pathType: Prefix - # -- Ingress TLS configuration - tls: [] - # - secretName: gomodel-tls - # hosts: - # - gomodel.local - -# Gateway API configuration -gateway: - # -- Enable Gateway API HTTPRoute - enabled: false - # -- Parent Gateway reference - parentRef: - # -- Gateway name - name: "" - # -- Gateway namespace (defaults to release namespace) - namespace: "" - # -- Hostnames for the HTTPRoute - hostnames: [] - # - gomodel.example.com - -# Resource requests and limits -resources: - requests: - cpu: 100m - memory: 128Mi - limits: - cpu: 1000m - memory: 512Mi - -# Horizontal Pod Autoscaler configuration -autoscaling: - # -- Enable HPA - enabled: false - # -- Minimum replicas - minReplicas: 2 - # -- Maximum replicas - maxReplicas: 10 - # -- Target CPU utilization percentage - targetCPUUtilizationPercentage: 70 - # -- Target memory utilization percentage (optional) - # targetMemoryUtilizationPercentage: 80 - -# Pod Disruption Budget configuration -podDisruptionBudget: - # -- Enable PDB - enabled: true - # -- Minimum available pods - minAvailable: 1 - # -- Maximum unavailable pods (alternative to minAvailable) - # maxUnavailable: 1 - -# Liveness probe configuration -# httpGet.path is automatically prefixed with server.basePath. -livenessProbe: - httpGet: - path: /health - port: http - initialDelaySeconds: 10 - periodSeconds: 10 - timeoutSeconds: 5 - failureThreshold: 3 - -# Readiness probe configuration -# httpGet.path is automatically prefixed with server.basePath. -readinessProbe: - httpGet: - path: /health - port: http - initialDelaySeconds: 5 - periodSeconds: 5 - timeoutSeconds: 3 - failureThreshold: 3 - -# Pod security context -podSecurityContext: - runAsNonRoot: true - runAsUser: 1000 - fsGroup: 1000 - -# Container security context -securityContext: - allowPrivilegeEscalation: false - readOnlyRootFilesystem: true - runAsNonRoot: true - runAsUser: 1000 - capabilities: - drop: - - ALL - -# -- Node selector -nodeSelector: {} - -# -- Tolerations -tolerations: [] - -# -- Affinity rules -affinity: {} - -# -- Additional pod annotations -podAnnotations: {} - -# -- Additional pod labels -podLabels: {} diff --git a/skaffold.yaml b/skaffold.yaml new file mode 100644 index 000000000..8e9cd8e2d --- /dev/null +++ b/skaffold.yaml @@ -0,0 +1,46 @@ +# Skaffold pipeline for local Kubernetes development on kind. +# +# make kind-up # one-time: create the kind cluster + deploy dependencies +# make dev-k8s # build + deploy + watch + port-forward (skaffold dev) +# make deploy-k8s # one-shot build + deploy (skaffold run) +# make undeploy-k8s # remove the release (skaffold delete) +# +# The in-cluster dependencies (Redis, PostgreSQL, MongoDB) are deployed by +# `make kind-up` (deploy/local/deps.yaml), so they persist across app redeploys. +# The gateway is reachable at http://localhost:8080 (Skaffold port-forward and, +# as a fallback, the kind NodePort mapping 30080 -> 8080). +apiVersion: skaffold/v4beta11 +kind: Config +metadata: + name: gomodel + +build: + # Build with the local Docker daemon and load the image into kind. + local: + push: false + useBuildkit: true + artifacts: + - image: gomodel + docker: + dockerfile: Dockerfile.dev + +manifests: + helm: + releases: + - name: gomodel + chartPath: deploy/helm/gomodel + valuesFiles: + - deploy/local/values.yaml + # Rewrite image.repository/tag to the Skaffold-built, kind-loaded image. + setValueTemplates: + image.repository: "{{.IMAGE_REPO_gomodel}}" + image.tag: "{{.IMAGE_TAG_gomodel}}" + +deploy: + helm: {} + # Allow time for the app's first boot against the in-cluster dependencies. + statusCheckDeadlineSeconds: 300 + +# No portForward block: the gateway is exposed on http://localhost:8080 by the +# kind NodePort mapping (30080 -> host 8080) from deploy/local/kind-cluster.yaml, +# which is available whether or not `skaffold dev` is running.