diff --git a/.github/workflows/validate.yaml b/.github/workflows/validate.yaml index 87aaa17..92a2d31 100644 --- a/.github/workflows/validate.yaml +++ b/.github/workflows/validate.yaml @@ -12,14 +12,28 @@ jobs: validate: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 + + - uses: actions/checkout@v7 + with: + repository: justgithubaccount/app-poly-gitops-k8s + ref: main + path: downstream-k8s - uses: azure/setup-helm@v4 with: version: v3.19.0 + - name: Install validation tools + run: | + python3 -m pip install --disable-pip-version-check PyYAML==6.0.3 + curl --fail --location --silent --show-error \ + https://github.com/yannh/kubeconform/releases/download/v0.7.0/kubeconform-linux-amd64.tar.gz \ + | tar -xz kubeconform + sudo mv kubeconform /usr/local/bin/kubeconform + - name: Chart contract run: bash tests/test_chart.sh - - name: Render standalone chart - run: helm template chat-api . --namespace smoke >/dev/null + - name: Current GitOps consumer layering + run: bash tests/test_downstream.sh downstream-k8s diff --git a/Chart.yaml b/Chart.yaml index 54aab3d..9a12a90 100644 --- a/Chart.yaml +++ b/Chart.yaml @@ -1,6 +1,6 @@ apiVersion: v2 name: chat-api -description: ai-app (mind) +description: Helm chart for the FastAPI Chat API service type: application version: 0.4.1 appVersion: "1.1.2" diff --git a/README.md b/README.md index 2b3d593..eeec456 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,17 @@ Real model requests require a secret selected with `postgresSecretRefName` or explicit `extraEnv` entries. Use an external database when data must survive pod replacement. +`serviceAccount.create=true` creates and selects one ServiceAccount. Set +`serviceAccount.name` to override its name. With `serviceAccount.create=false`, +`serviceAccount.name` is required and must identify an existing account. + +`createInternalSecret=true` creates a chart-managed Secret for the non-sensitive +provider URL, model and optional attribution headers, and attaches it to the +Deployment. It does not store a provider key. `postgres.externalService.enabled` +only creates the stable `postgres-external` DNS name for an external host; it +cannot construct credentials or a database name, so `DATABASE_URL` must still be +provided through `postgresSecretRefName` or `extraEnv`. + ## Getting started Requirements: Helm 3 and access to a Kubernetes cluster for installation. @@ -65,6 +76,10 @@ helm upgrade --install chat-api . \ | `openrouter.defaultModel` | `anthropic/claude-opus-4` | Model identifier | | `openrouterSecretRefName` | empty | Existing provider Secret | | `postgresSecretRefName` | empty | Existing database Secret | +| `serviceAccount.create` | `true` | Create a ServiceAccount | +| `serviceAccount.name` | empty | Created-account override, or required existing account name | +| `createInternalSecret` | `false` | Create and consume non-sensitive provider configuration | +| `postgres.externalService.enabled` | `false` | Create an ExternalName Service; does not set `DATABASE_URL` | | `ingress.enabled` | `false` | Render an Ingress | | `extraEnv` | `[]` | Additional container environment entries | @@ -77,11 +92,13 @@ Ingress options, and the complete value set. - `values.yaml` — default values - `templates/` — Kubernetes templates - `tests/test_chart.sh` — lint and rendered-contract checks +- `tests/test_downstream.sh` — current GitOps values and chart-pin compatibility Run the repository check with: ~~~bash bash tests/test_chart.sh +bash tests/test_downstream.sh ../app-poly-gitops-k8s ~~~ ## Related repositories diff --git a/templates/_helpers.tpl b/templates/_helpers.tpl index 4111f21..d2925ed 100644 --- a/templates/_helpers.tpl +++ b/templates/_helpers.tpl @@ -19,6 +19,14 @@ {{- default .Release.Namespace .Values.namespace -}} {{- end }} +{{- define "chat-api.serviceAccountName" -}} +{{- if .Values.serviceAccount.create -}} +{{- default (include "chat-api.fullname" .) .Values.serviceAccount.name -}} +{{- else -}} +{{- required "serviceAccount.name is required when serviceAccount.create=false" .Values.serviceAccount.name -}} +{{- end -}} +{{- end }} + {{- define "chat-api.chart" -}} {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} {{- end }} diff --git a/templates/deployment.yaml b/templates/deployment.yaml index a024ae6..93ea5b1 100644 --- a/templates/deployment.yaml +++ b/templates/deployment.yaml @@ -17,7 +17,7 @@ spec: annotations: openrouter.model: {{ .Values.openrouter.defaultModel | quote }} spec: - serviceAccountName: {{ include "chat-api.fullname" . }} + serviceAccountName: {{ include "chat-api.serviceAccountName" . }} containers: - name: chat-api image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" @@ -69,8 +69,12 @@ spec: {{- toYaml . | nindent 12 }} {{- end }} - {{- if or .Values.openrouterSecretRefName .Values.postgresSecretRefName }} + {{- if or .Values.createInternalSecret .Values.openrouterSecretRefName .Values.postgresSecretRefName }} envFrom: + {{- if .Values.createInternalSecret }} + - secretRef: + name: {{ include "chat-api.fullname" . }}-config + {{- end }} {{- if .Values.openrouterSecretRefName }} - secretRef: name: {{ .Values.openrouterSecretRefName }} diff --git a/templates/ingress.yaml b/templates/ingress.yaml index ac3853b..8d1cdc1 100644 --- a/templates/ingress.yaml +++ b/templates/ingress.yaml @@ -13,7 +13,7 @@ metadata: spec: ingressClassName: {{ .Values.ingress.className | default "nginx" }} rules: - - host: {{ .Values.ingress.host | quote }} + - host: {{ required "ingress.host is required when ingress.enabled=true" .Values.ingress.host | quote }} http: paths: - path: {{ .Values.ingress.path | default "/" | quote }} diff --git a/templates/pg-service.yaml b/templates/pg-service.yaml index 921bf39..3308b09 100644 --- a/templates/pg-service.yaml +++ b/templates/pg-service.yaml @@ -9,7 +9,7 @@ metadata: component: database spec: type: ExternalName - externalName: {{ .Values.postgres.externalService.host }} + externalName: {{ required "postgres.externalService.host is required when postgres.externalService.enabled=true" .Values.postgres.externalService.host }} ports: - port: {{ .Values.postgres.externalService.port }} targetPort: {{ .Values.postgres.externalService.port }} diff --git a/templates/serviceaccount.yaml b/templates/serviceaccount.yaml index fe68d27..5eab9ad 100644 --- a/templates/serviceaccount.yaml +++ b/templates/serviceaccount.yaml @@ -2,7 +2,7 @@ apiVersion: v1 kind: ServiceAccount metadata: - name: {{ include "chat-api.fullname" . }} + name: {{ include "chat-api.serviceAccountName" . }} namespace: {{ include "chat-api.namespace" . }} labels: {{- include "chat-api.labels" . | nindent 4 }} diff --git a/tests/test_chart.sh b/tests/test_chart.sh index bc3b629..8c04861 100755 --- a/tests/test_chart.sh +++ b/tests/test_chart.sh @@ -2,14 +2,104 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +tmp_dir="$(mktemp -d)" +trap 'rm -rf "${tmp_dir}"' EXIT + +render() { + helm template chat-api "${repo_root}" --namespace smoke "$@" +} + +assert_yaml_resources() { + python3 - "$1" <<'PY' +import pathlib +import sys + +import yaml + +documents = list(yaml.safe_load_all(pathlib.Path(sys.argv[1]).read_text())) +resources = [document for document in documents if document is not None] +assert resources, "render did not contain Kubernetes resources" +for resource in resources: + assert isinstance(resource, dict), resource + assert resource.get("apiVersion"), resource + assert resource.get("kind"), resource + assert isinstance(resource.get("metadata"), dict), resource + assert resource["metadata"].get("name"), resource +PY +} helm lint "${repo_root}" -rendered="$(helm template chat-api "${repo_root}" --namespace smoke)" -grep -q 'namespace: smoke' <<<"${rendered}" -grep -q 'image: "ghcr.io/justgithubaccount/app-poly-gitops-fastapi:1.1.2"' <<<"${rendered}" -! grep -q 'secretRef:' <<<"${rendered}" -! grep -q 'command:' <<<"${rendered}" +default_render="${tmp_dir}/default.yaml" +render > "${default_render}" +assert_yaml_resources "${default_render}" +test "$(sha256sum "${default_render}" | cut -d' ' -f1)" = \ + "eccee8f126eec4287d1ccf664414f73632b70a6dfa46357b8c31f7d54e96637f" +grep -q 'namespace: smoke' "${default_render}" +grep -q 'image: "ghcr.io/justgithubaccount/app-poly-gitops-fastapi:1.1.2"' "${default_render}" +! grep -q 'secretRef:' "${default_render}" +! grep -q 'command:' "${default_render}" +! grep -q '^kind: Ingress$' "${default_render}" + +created_sa="${tmp_dir}/created-sa.yaml" +render --set serviceAccount.name=existing-compatible-name > "${created_sa}" +test "$(grep -c '^kind: ServiceAccount$' "${created_sa}")" -eq 1 +grep -q 'name: existing-compatible-name' "${created_sa}" +grep -q 'serviceAccountName: existing-compatible-name' "${created_sa}" + +existing_sa="${tmp_dir}/existing-sa.yaml" +render --set serviceAccount.create=false --set serviceAccount.name=precreated-chat-api > "${existing_sa}" +! grep -q '^kind: ServiceAccount$' "${existing_sa}" +grep -q 'serviceAccountName: precreated-chat-api' "${existing_sa}" + +if render --set serviceAccount.create=false --set-string serviceAccount.name= \ + > "${tmp_dir}/invalid-sa.yaml" 2> "${tmp_dir}/invalid-sa.err"; then + echo "serviceAccount.create=false without serviceAccount.name unexpectedly rendered" >&2 + exit 1 +fi +grep -q 'serviceAccount.name is required' "${tmp_dir}/invalid-sa.err" + +internal_secret="${tmp_dir}/internal-secret.yaml" +render --set createInternalSecret=true > "${internal_secret}" +grep -q '^kind: Secret$' "${internal_secret}" +grep -A3 'envFrom:' "${internal_secret}" | grep -q 'name: chat-api-config' + +external_postgres="${tmp_dir}/external-postgres.yaml" +render \ + --set postgres.externalService.enabled=true \ + --set postgres.externalService.host=db.example.internal \ + > "${external_postgres}" +grep -q '^kind: Service$' "${external_postgres}" +grep -q 'externalName: db.example.internal' "${external_postgres}" +! grep -q 'name: DATABASE_URL' "${external_postgres}" +if render --set postgres.externalService.enabled=true \ + > "${tmp_dir}/invalid-postgres.yaml" 2> "${tmp_dir}/invalid-postgres.err"; then + echo "external Postgres service without a host unexpectedly rendered" >&2 + exit 1 +fi +grep -q 'postgres.externalService.host is required' "${tmp_dir}/invalid-postgres.err" + +ingress="${tmp_dir}/ingress.yaml" +render \ + --set namespace=application \ + --set ingress.enabled=true \ + --set ingress.host=chat.example.test \ + > "${ingress}" +grep -q '^kind: Ingress$' "${ingress}" +grep -A5 '^kind: Ingress$' "${ingress}" | grep -q 'namespace: application' +grep -q 'host: "chat.example.test"' "${ingress}" +if render --set ingress.enabled=true \ + > "${tmp_dir}/invalid-ingress.yaml" 2> "${tmp_dir}/invalid-ingress.err"; then + echo "enabled ingress without a host unexpectedly rendered" >&2 + exit 1 +fi +grep -q 'ingress.host is required' "${tmp_dir}/invalid-ingress.err" + +for manifest in "${default_render}" "${created_sa}" "${existing_sa}" \ + "${internal_secret}" "${external_postgres}" "${ingress}"; do + assert_yaml_resources "${manifest}" + kubeconform -strict -summary -ignore-missing-schemas "${manifest}" +done workflow="${repo_root}/.github/workflows/validate.yaml" test -f "${workflow}" diff --git a/tests/test_downstream.sh b/tests/test_downstream.sh new file mode 100644 index 0000000..d483f74 --- /dev/null +++ b/tests/test_downstream.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +k8s_repo="${1:?usage: tests/test_downstream.sh /path/to/app-poly-gitops-k8s}" +application="${k8s_repo}/tenants/product-team/apps/chat/base/application.yaml" +values="${k8s_repo}/tenants/product-team/apps/chat/overlays/dev/values.yaml" + +test -f "${application}" +test -f "${values}" +grep -q 'repoURL: https://github.com/justgithubaccount/app-poly-gitops-helm' "${application}" +grep -q 'targetRevision: v0.4.1' "${application}" + +rendered="$(helm template chat-api "${repo_root}" --namespace product-team -f "${values}")" +grep -q 'namespace: product-team' <<<"${rendered}" +grep -q 'image: "ghcr.io/justgithubaccount/app-poly-gitops-fastapi:1.1.2"' <<<"${rendered}" +grep -q 'serviceAccountName: chat-api' <<<"${rendered}" diff --git a/values.yaml b/values.yaml index f3fab3b..1bb0690 100644 --- a/values.yaml +++ b/values.yaml @@ -29,6 +29,7 @@ livenessProbe: serviceAccount: create: true annotations: {} + # Required when create=false. When create=true, empty uses the chart fullname. name: "" env: @@ -54,6 +55,8 @@ postgresSecretRefName: "" postgres: externalService: + # Creates only an ExternalName Service. Supply DATABASE_URL separately with + # postgresSecretRefName or extraEnv; credentials are intentionally not built here. enabled: false host: "" port: 5432 @@ -69,4 +72,6 @@ ingress: # Additional container environment entries, including Secret key mappings. extraEnv: [] +# Creates and consumes a chart-managed Secret for non-sensitive provider config. +# Provider credentials still belong in openrouterSecretRefName or extraEnv. createInternalSecret: false