diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 6c33cba..e8f90ee 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,54 +1,53 @@ version: 2 +# Security-only configuration. +# +# `open-pull-requests-limit: 0` turns off routine version-update PRs for an ecosystem while +# Dependabot *security* updates (PRs for dependencies with known advisories/CVEs) keep working. +# The `schedule` block is still required by the schema, but it only affects version updates. +# Prerequisite: "Dependabot security updates" must be enabled in the repository's +# Settings > Code security. +# +# The `security-updates` groups below batch advisory fixes for an ecosystem into a single PR. + updates: - package-ecosystem: uv directory: / target-branch: bugfix/dependabot + open-pull-requests-limit: 0 schedule: interval: weekly - day: monday - time: "09:00" - timezone: America/New_York groups: - python-minor-and-patch: + python-security: + applies-to: security-updates patterns: - "*" - update-types: - - minor - - patch - package-ecosystem: github-actions directory: / target-branch: bugfix/dependabot + open-pull-requests-limit: 0 schedule: interval: weekly - day: monday - time: "09:15" - timezone: America/New_York groups: - actions-minor-and-patch: + actions-security: + applies-to: security-updates patterns: - "*" - update-types: - - minor - - patch - package-ecosystem: docker directories: - / - /opensampl/server/backend + - /opensampl/server/db - /opensampl/server/grafana - /opensampl/server/migrations target-branch: bugfix/dependabot + open-pull-requests-limit: 0 schedule: interval: weekly - day: monday - time: "09:30" - timezone: America/New_York groups: - container-minor-and-patch: + container-security: + applies-to: security-updates patterns: - "*" - update-types: - - minor - - patch diff --git a/.github/workflows/build-images.yml b/.github/workflows/build-images.yml new file mode 100644 index 0000000..0ec9ed6 --- /dev/null +++ b/.github/workflows/build-images.yml @@ -0,0 +1,117 @@ +name: Build images + +# Reusable workflow: builds the opensampl wheel once, then builds every container image from it. +# push=false -> images are built but NOT published (exported as artifacts for the chart test). +# push=true -> images are pushed to GHCR (caller must grant packages: write). +on: + workflow_call: + inputs: + push: + description: Push images to GHCR instead of exporting them as artifacts + type: boolean + default: false + +jobs: + build-wheel: + name: Build wheel + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + version: "0.12.21" + + - name: Build wheel + run: uv build --wheel --out-dir dist + + - name: Get package version + id: version + run: echo "version=$(grep -m1 '^version' pyproject.toml | cut -d'"' -f2)" >> "$GITHUB_OUTPUT" + + - name: Upload wheel + uses: actions/upload-artifact@v4 + with: + name: opensampl-wheel + path: dist/*.whl + if-no-files-found: error + retention-days: 1 + + build-images: + name: Build ${{ matrix.service.name }} + runs-on: ubuntu-latest + needs: build-wheel + strategy: + fail-fast: false + matrix: + service: + - name: db + context: ./opensampl/server/db + - name: backend + context: ./opensampl/server/backend + target: prod + wheel: true + - name: grafana + context: ./opensampl/server/grafana + - name: migrations + context: ./opensampl/server/migrations + wheel: true + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Download wheel + if: matrix.service.wheel + uses: actions/download-artifact@v4 + with: + name: opensampl-wheel + path: ${{ runner.temp }}/dist + + - name: Log in to GitHub Container Registry + if: inputs.push + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push ${{ matrix.service.name }} image + if: inputs.push + uses: docker/build-push-action@v5 + with: + context: ${{ matrix.service.context }} + target: ${{ matrix.service.target }} + build-contexts: ${{ matrix.service.wheel && format('wheel={0}/dist', runner.temp) || '' }} + build-args: OPENSAMPL_VERSION=${{ needs.build-wheel.outputs.version }} + push: true + tags: | + ghcr.io/ornl/opensampl-${{ matrix.service.name }}:latest + ghcr.io/ornl/opensampl-${{ matrix.service.name }}:${{ needs.build-wheel.outputs.version }} + cache-from: type=gha,scope=${{ matrix.service.name }} + cache-to: type=gha,mode=max,scope=${{ matrix.service.name }} + + - name: Build ${{ matrix.service.name }} image (not pushed) + if: ${{ !inputs.push }} + uses: docker/build-push-action@v5 + with: + context: ${{ matrix.service.context }} + target: ${{ matrix.service.target }} + build-contexts: ${{ matrix.service.wheel && format('wheel={0}/dist', runner.temp) || '' }} + build-args: OPENSAMPL_VERSION=${{ needs.build-wheel.outputs.version }} + push: false + tags: ghcr.io/ornl/opensampl-${{ matrix.service.name }}:pr-local + outputs: type=docker,dest=${{ runner.temp }}/${{ matrix.service.name }}.tar + cache-from: type=gha,scope=${{ matrix.service.name }} + + - name: Upload image for chart test + if: ${{ !inputs.push }} + uses: actions/upload-artifact@v4 + with: + name: image-${{ matrix.service.name }} + path: ${{ runner.temp }}/${{ matrix.service.name }}.tar + retention-days: 1 diff --git a/.github/workflows/dependency-security.yml b/.github/workflows/dependency-security.yml index 8b5fd6a..2cfd900 100644 --- a/.github/workflows/dependency-security.yml +++ b/.github/workflows/dependency-security.yml @@ -3,8 +3,15 @@ name: Dependency security on: push: branches: [main] + paths: + - "pyproject.toml" + - "uv.lock" + - ".github/workflows/dependency-security.yml" pull_request: - branches: [main] + paths: + - "pyproject.toml" + - "uv.lock" + - ".github/workflows/dependency-security.yml" schedule: - cron: "17 9 * * 1" workflow_dispatch: @@ -15,6 +22,8 @@ permissions: jobs: ensure-branch: + # writes a branch, so never run it for pull requests (fork PRs only get a read-only token) + if: github.event_name != 'pull_request' runs-on: ubuntu-latest permissions: contents: write @@ -33,6 +42,8 @@ jobs: audit: needs: ensure-branch + # run even when ensure-branch is skipped (pull requests) + if: ${{ !cancelled() && (needs.ensure-branch.result == 'success' || needs.ensure-branch.result == 'skipped') }} name: Audit locked dependencies runs-on: ubuntu-latest steps: diff --git a/.github/workflows/helm-checks.yml b/.github/workflows/helm-checks.yml new file mode 100644 index 0000000..9eff1ff --- /dev/null +++ b/.github/workflows/helm-checks.yml @@ -0,0 +1,127 @@ +name: Helm chart checks + +# Runs on every pull request. Builds images without pushing, lints the chart and test-installs it. +# Nothing is published from this workflow; see publish-artifacts.yml for that. +on: + pull_request: + paths: + - "helm/**" + - "opensampl/**" + - "pyproject.toml" + - "uv.lock" + - ".github/workflows/helm-checks.yml" + - ".github/workflows/build-images.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: helm-checks-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-images: + uses: ./.github/workflows/build-images.yml + with: + push: false + + helm-validate: + name: Lint Helm chart + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Helm + uses: azure/setup-helm@v4 + with: + version: v3.14.0 + + - name: Helm lint + run: | + helm lint ./helm + helm lint ./helm -f ./helm/values-cluster-overrides.yaml + + - name: Render Helm chart + run: | + helm template opensampl ./helm \ + -f ./helm/values-cluster-overrides.yaml \ + --namespace opensampl-test \ + > "$RUNNER_TEMP/opensampl-rendered.yaml" + + - name: Require chart version bump when chart changes + if: github.event_name == 'pull_request' + run: | + git fetch origin main --depth=1 + if ! git diff --quiet origin/main -- helm; then + base="$(git show origin/main:helm/Chart.yaml | grep -m1 '^version:' | awk '{print $2}')" + head="$(grep -m1 '^version:' helm/Chart.yaml | awk '{print $2}')" + if [ "$base" = "$head" ]; then + echo "::error file=helm/Chart.yaml::helm/ changed but chart version is still $head; bump it so the push to GHCR does not collide." + exit 1 + fi + fi + + test-helm-chart: + name: Test Helm chart deployment + runs-on: ubuntu-latest + needs: build-images + steps: + - uses: actions/checkout@v4 + + - name: Start minikube + uses: medyagh/setup-minikube@latest + + - name: Download built images + uses: actions/download-artifact@v4 + with: + pattern: image-* + path: ${{ runner.temp }}/images + merge-multiple: true + + - name: Load images into minikube + run: | + for tar in "$RUNNER_TEMP"/images/*.tar; do + minikube image load "$tar" + done + minikube image ls + + - name: Set up Helm + uses: azure/setup-helm@v4 + + - name: Install Helm chart + working-directory: helm/ + run: | + helm install test-opensampl . \ + --set global.imagePullPolicy=Never \ + --set db.image.tag=pr-local \ + --set backend.image.tag=pr-local \ + --set grafana.image.tag=pr-local \ + --set migrations.image.tag=pr-local \ + --set migrations.enabled=true \ + --set ingress.enabled=false \ + --wait \ + --wait-for-jobs \ + --timeout 15m + + - name: Verify deployment + run: | + kubectl get pods + kubectl get services + kubectl get pvc + kubectl get jobs + + - name: Show pod logs on failure + if: failure() + run: | + echo "=== Pod Status ===" + kubectl get pods + echo "=== Pod Descriptions ===" + kubectl describe pods + echo "=== Pod Logs ===" + for pod in $(kubectl get pods -o name); do + echo "Logs for $pod:" + kubectl logs "$pod" --all-containers=true || true + done diff --git a/.github/workflows/helm-readme.md b/.github/workflows/helm-readme.md new file mode 100644 index 0000000..b1be320 --- /dev/null +++ b/.github/workflows/helm-readme.md @@ -0,0 +1,28 @@ +# CI/CD overview + +Checks run on **every pull request**. Images and charts are published **only after a merge to `main`**. + +| Workflow | Trigger | Publishes? | +|---|---|---| +| `lint.yml`, `tests.yml`, `dependency-security.yml` | every PR, push to `main` | no | +| `helm-checks.yml` | every PR | no | +| `publish-artifacts.yml` | push to `main` | images + Helm chart to GHCR | +| `publish.yml` (PyPI), `static.yml` (Pages) | push to `main` | yes | +| `build-images.yml` | reusable (called by the two above) | only when `push: true` | + +## Images are built from a wheel +`build-images.yml` runs `uv build --wheel` once and passes the wheel to the `backend` and `migrations` +Docker builds as the named context `wheel`. Their Dockerfiles install that wheel when present and fall back +to `opensampl==$OPENSAMPL_VERSION` from PyPI for local builds, so images always contain the code being built. + +## Pull requests (`helm-checks.yml`) +``` +build wheel -> build 4 images (not pushed) -> load into minikube -> helm install (migrations enabled) +helm lint + template, and a check that helm/Chart.yaml `version` was bumped if helm/ changed +``` +Images are tagged `pr-local` and installed with `imagePullPolicy=Never`; nothing touches GHCR. + +## Push to `main` (`publish-artifacts.yml`) +``` +build wheel -> build + push db/backend/grafana/migrations (latest and the pyproject version) -> package + push chart +``` diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 1ba914a..d02eb02 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -4,9 +4,23 @@ on: push: branches: - main + paths: + - "opensampl/**" + - "docs/**" + - "scripts/**" + - "mkdocs.yaml" + - "pyproject.toml" + - "uv.lock" + - ".github/workflows/lint.yml" pull_request: - branches: - - main + paths: + - "opensampl/**" + - "docs/**" + - "scripts/**" + - "mkdocs.yaml" + - "pyproject.toml" + - "uv.lock" + - ".github/workflows/lint.yml" workflow_dispatch: permissions: diff --git a/.github/workflows/publish-artifacts.yml b/.github/workflows/publish-artifacts.yml new file mode 100644 index 0000000..d706547 --- /dev/null +++ b/.github/workflows/publish-artifacts.yml @@ -0,0 +1,53 @@ +name: Publish images and Helm chart + +# Publishes container images and the Helm chart to GHCR. Only runs for main. +# Pull request validation lives in helm-checks.yml. +on: + push: + branches: [ main ] + workflow_dispatch: + +concurrency: + group: publish-artifacts + cancel-in-progress: false + +jobs: + build-images: + if: github.ref == 'refs/heads/main' + permissions: + contents: read + packages: write + uses: ./.github/workflows/build-images.yml + with: + push: true + + helm-package: + name: Package and push Helm chart + runs-on: ubuntu-latest + needs: build-images + if: github.ref == 'refs/heads/main' + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + - name: Setup Helm + uses: azure/setup-helm@v4 + with: + version: v3.14.0 + + - name: Helm lint + run: | + helm lint ./helm + helm lint ./helm -f ./helm/values-cluster-overrides.yaml + + - name: Package chart + run: helm package ./helm + + - name: Push to GHCR + env: + CR_PAT: ${{ secrets.GITHUB_TOKEN }} + run: | + helm registry login ghcr.io -u "$GITHUB_ACTOR" -p "$CR_PAT" + helm push opensampl-*.tgz oci://ghcr.io/ornl/charts diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 052c719..e9fbb44 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -4,7 +4,7 @@ on: push: branches: - main - workflow_dispatch: + workflow_dispatch: permissions: contents: read pages: write diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml index fff4697..09d5afa 100644 --- a/.github/workflows/static.yml +++ b/.github/workflows/static.yml @@ -4,7 +4,7 @@ on: push: branches: - main - workflow_dispatch: + workflow_dispatch: permissions: contents: read pages: write diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d05ce02..0da1ad9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -3,15 +3,25 @@ on: push: branches: - main + paths: + - "opensampl/**" + - "tests/**" + - "pyproject.toml" + - "uv.lock" + - ".github/workflows/tests.yml" pull_request: - branches: - - main + paths: + - "opensampl/**" + - "tests/**" + - "pyproject.toml" + - "uv.lock" + - ".github/workflows/tests.yml" workflow_dispatch: jobs: uv-pytest: name: Python ${{ matrix.python-version }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 strategy: fail-fast: false matrix: @@ -32,13 +42,14 @@ jobs: run: | sudo install -d /usr/share/postgresql-common/pgdg - sudo curl -fsSL \ + sudo curl -fsSL --retry 5 --retry-all-errors --retry-delay 3 --connect-timeout 20 \ https://www.postgresql.org/media/keys/ACCC4CF8.asc \ -o /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc - echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] https://apt.postgresql.org/pub/repos/apt resolute-pgdg main" \ + codename="$(. /etc/os-release && echo "$VERSION_CODENAME")" + echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] https://apt.postgresql.org/pub/repos/apt ${codename}-pgdg main" \ | sudo tee /etc/apt/sources.list.d/pgdg.list - sudo apt-get update - sudo apt-get install -y postgresql-16 postgresql-client-16 postgresql-16-postgis-3 postgresql-16-postgis-3-scripts + sudo apt-get -o Acquire::Retries=5 update + sudo apt-get -o Acquire::Retries=5 install -y postgresql-16 postgresql-client-16 postgresql-16-postgis-3 postgresql-16-postgis-3-scripts echo "/usr/lib/postgresql/16/bin" >> "$GITHUB_PATH" - name: Install the project diff --git a/docs/guides/index.md b/docs/guides/index.md index 7265702..aaddecd 100644 --- a/docs/guides/index.md +++ b/docs/guides/index.md @@ -3,6 +3,7 @@ * [Configuration](configuration.md) * [Using the `opensampl` CLI](opensampl-cli.md) * [Using the `opensampl-server` CLI](opensampl-server.md) +* [Deploying on Kubernetes with Helm](kubernetes-helm.md) * [Value Backfill](value-backfill.md) * [Collection Guide](collection.md) * [Random Data Guide](random-data-generation.md) diff --git a/docs/guides/opensampl-server.md b/docs/guides/opensampl-server.md index 1906028..ab64c54 100644 --- a/docs/guides/opensampl-server.md +++ b/docs/guides/opensampl-server.md @@ -4,6 +4,8 @@ The `opensampl-server` CLI is a thin wrapper around the packaged Docker Compose It is useful when you want a local database, backend API, migrations container, and Grafana instance without managing the compose arguments yourself. +For cluster deployments, see [Deploying on Kubernetes with Helm](kubernetes-helm.md). + ## Prerequisites - Docker with either `docker compose` or `docker-compose` diff --git a/helm/.helmignore b/helm/.helmignore new file mode 100644 index 0000000..5ea7d00 --- /dev/null +++ b/helm/.helmignore @@ -0,0 +1,44 @@ +# Common VCS directories +.git/ +.gitignore +.github/ + +# Mac / Windows system files +.DS_Store +Thumbs.db + +# Editor files +*.swp +*.bak +*.tmp +*.orig +*.log + +# Python / build artifacts +__pycache__/ +*.pyc +*.pyo +*.egg-info/ +dist/ +build/ + +# Tests and CI/CD +tests/ +*.test +*.coverage +.coverage +.env +.vscode/ +.idea/ + +# Docs and misc +docs/ +*.md +LICENSE +CHANGELOG.md +README-PACKAGE.md + +# Docker and Compose files +docker-compose.yaml +Dockerfile +*.Dockerfile diff --git a/helm/Chart.yaml b/helm/Chart.yaml new file mode 100644 index 0000000..08f3ee3 --- /dev/null +++ b/helm/Chart.yaml @@ -0,0 +1,7 @@ +apiVersion: v2 +name: opensampl +description: Helm chart for OpenSAMPL (Postgres + Backend + Grafana + Migrations) +type: application +version: 0.2.3 +appVersion: "1.0.0" +icon: https://raw.githubusercontent.com/ORNL/OpenSAMPL/main/docs/logo.png diff --git a/helm/README.md b/helm/README.md new file mode 100644 index 0000000..e5059d4 --- /dev/null +++ b/helm/README.md @@ -0,0 +1,38 @@ +# openSAMPL Helm chart + +Deploys openSAMPL (TimescaleDB, backend API, Grafana, and a migrations hook job) on Kubernetes. + +Full instructions: [Deploying on Kubernetes with Helm](../docs/guides/kubernetes-helm.md). + +## Quick start + +```bash +# Find the latest version in Chart.yaml or on the GHCR packages page +helm install opensampl \ + oci://ghcr.io/ornl/charts/opensampl \ + --version \ + --namespace opensampl \ + --create-namespace +``` + +Preview first with `--dry-run --debug`; override settings with `-f my-values.yaml` +(see `values.yaml` and the cluster example in `values-cluster-overrides.yaml`). + +## Layout + +``` +helm/ +├── Chart.yaml +├── values.yaml # defaults +├── values-cluster-overrides.yaml # example overrides for the ORNL cluster +└── templates/ + ├── _helpers.tpl + ├── NOTES.txt + ├── ingress.yaml # optional + ├── backend/ # deployment, service + ├── db/ # statefulset, service, pvc, secret, configmaps + ├── grafana/ # deployment, service + └── migrations/ # post-install/post-upgrade hook job +``` + +Charts and images are published to GHCR from `main`; see `docs/maintainers/ci-cd.md`. diff --git a/helm/templates/NOTES.txt b/helm/templates/NOTES.txt new file mode 100644 index 0000000..82d6494 --- /dev/null +++ b/helm/templates/NOTES.txt @@ -0,0 +1,9 @@ +Thank you for installing OpenSAMPL! + +To access your services: +- Backend: ClusterIP service "{{ include "opensampl.name" . }}-backend" on port {{ .Values.backend.service.port }} +- Grafana: ClusterIP service "{{ include "opensampl.name" . }}-grafana" on port {{ .Values.grafana.port }} +- Database: StatefulSet "{{ include "opensampl.name" . }}-db" with {{ .Values.db.storage }} storage + +If ingress is enabled, access OpenSAMPL at: +http://{{ .Values.ingress.host }} diff --git a/helm/templates/_helpers.tpl b/helm/templates/_helpers.tpl new file mode 100644 index 0000000..6c3d8de --- /dev/null +++ b/helm/templates/_helpers.tpl @@ -0,0 +1,28 @@ +{{/* Base name */}} +{{- define "opensampl.name" -}} +opensampl +{{- end -}} + +{{/* Full release-qualified name */}} +{{- define "opensampl.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" (include "opensampl.name" .) .Release.Name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end -}} + +{{/* Component-specific name */}} +{{- define "opensampl.componentname" -}} +{{- printf "%s-%s" (include "opensampl.name" .) .component | trunc 63 | trimSuffix "-" }} +{{- end -}} + +{{/* Common labels */}} +{{- define "opensampl.labels" -}} +app.kubernetes.io/name: {{ include "opensampl.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- if .component }} +app.kubernetes.io/component: {{ .component }} +{{- end }} +{{- end -}} diff --git a/helm/templates/backend/deployment.yaml b/helm/templates/backend/deployment.yaml new file mode 100644 index 0000000..3976f1d --- /dev/null +++ b/helm/templates/backend/deployment.yaml @@ -0,0 +1,55 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "opensampl.fullname" . }}-backend + labels: + {{- include "opensampl.labels" . | nindent 4 }} + role: backend +spec: + replicas: {{ .Values.backend.replicas }} + selector: + matchLabels: + app.kubernetes.io/name: {{ include "opensampl.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + role: backend + template: + metadata: + labels: + app.kubernetes.io/name: {{ include "opensampl.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + role: backend + spec: + initContainers: + - name: wait-for-db + image: "{{ .Values.busybox.image.repository }}:{{ .Values.busybox.image.tag }}" + command: + - sh + - -c + - > + until nc -z {{ include "opensampl.fullname" . }}-db {{ .Values.db.port }}; + do echo "Waiting for DB..."; + sleep 3; + done; + containers: + - name: backend + image: "{{ .Values.backend.image.repository }}:{{ .Values.backend.image.tag }}" + imagePullPolicy: {{ .Values.global.imagePullPolicy }} + ports: + - containerPort: 8000 + env: + - name: DATABASE_URL + value: postgresql://$(POSTGRES_USER):$(POSTGRES_PASSWORD)@{{ include "opensampl.fullname" . }}-db:{{ .Values.db.port }}/{{ .Values.db.database }} + - name: POSTGRES_USER + value: {{ .Values.db.username }} + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "opensampl.fullname" . }}-db-secret + key: POSTGRES_PASSWORD + - name: BACKEND_LOG_LEVEL + value: {{ .Values.backend.logLevel | default "INFO" | quote }} + - name: USE_API_KEY + value: {{ .Values.backend.useApiKey | default "false" | quote }} + {{- with .Values.backend.env }} + {{- toYaml . | nindent 12 }} + {{- end }} \ No newline at end of file diff --git a/helm/templates/backend/service.yaml b/helm/templates/backend/service.yaml new file mode 100644 index 0000000..d2be513 --- /dev/null +++ b/helm/templates/backend/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "opensampl.fullname" . }}-backend + labels: + {{- include "opensampl.labels" . | nindent 4 }} +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: {{ include "opensampl.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + role: backend + ports: + - name: http + port: {{ .Values.backend.service.port }} + targetPort: 8000 \ No newline at end of file diff --git a/helm/templates/db/configmap.yaml b/helm/templates/db/configmap.yaml new file mode 100644 index 0000000..17deed1 --- /dev/null +++ b/helm/templates/db/configmap.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "opensampl.fullname" . }}-db-config + labels: + {{- include "opensampl.labels" . | nindent 4 }} +data: + POSTGRES_DB: {{ .Values.db.database | quote }} + POSTGRES_PORT: "{{ .Values.db.port }}" diff --git a/helm/templates/db/db-init-configmap.yaml b/helm/templates/db/db-init-configmap.yaml new file mode 100644 index 0000000..8af5303 --- /dev/null +++ b/helm/templates/db/db-init-configmap.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "opensampl.fullname" . }}-db-init + labels: + {{- include "opensampl.labels" . | nindent 4 }} +data: + 020_create_grafana_user.sh: | + #!/bin/bash + set -e + + # Create the read-only role Grafana connects as. The migrations grant privileges to this role, + # so it must exist before they run. Mirrors opensampl/server/db/create-grafana.sh. + psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL + CREATE ROLE grafana WITH LOGIN PASSWORD '${GF_SECURITY_ADMIN_PASSWORD}'; + EOSQL diff --git a/helm/templates/db/pvc.yaml b/helm/templates/db/pvc.yaml new file mode 100644 index 0000000..b049791 --- /dev/null +++ b/helm/templates/db/pvc.yaml @@ -0,0 +1,15 @@ +{{- if .Values.db.persistence.enabled }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "opensampl.name" . }}-pvc +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: {{ .Values.db.persistence.size }} + {{- if .Values.db.persistence.storageClass }} + storageClassName: {{ .Values.db.persistence.storageClass }} + {{- end }} +{{- end }} diff --git a/helm/templates/db/secret.yaml b/helm/templates/db/secret.yaml new file mode 100644 index 0000000..79a49c5 --- /dev/null +++ b/helm/templates/db/secret.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "opensampl.fullname" . }}-db-secret + labels: + {{- include "opensampl.labels" . | nindent 4 }} +type: Opaque +stringData: + POSTGRES_USER: {{ .Values.db.username | quote }} + POSTGRES_PASSWORD: {{ .Values.db.password | quote }} + GF_SECURITY_ADMIN_PASSWORD: {{ .Values.db.grafanaPassword | quote }} diff --git a/helm/templates/db/service.yaml b/helm/templates/db/service.yaml new file mode 100644 index 0000000..6d3d185 --- /dev/null +++ b/helm/templates/db/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "opensampl.fullname" . }}-db + labels: + {{- include "opensampl.labels" . | nindent 4 }} +spec: + type: ClusterIP + ports: + - name: postgres + port: {{ .Values.db.port }} + targetPort: {{ .Values.db.port }} + selector: + app.kubernetes.io/name: {{ include "opensampl.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + role: db diff --git a/helm/templates/db/statefulset.yaml b/helm/templates/db/statefulset.yaml new file mode 100644 index 0000000..715355e --- /dev/null +++ b/helm/templates/db/statefulset.yaml @@ -0,0 +1,66 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ include "opensampl.name" . }}-db + labels: + {{- include "opensampl.labels" . | nindent 4 }} +spec: + serviceName: {{ include "opensampl.fullname" . }}-db + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: {{ include "opensampl.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + role: db + template: + metadata: + labels: + app.kubernetes.io/name: {{ include "opensampl.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + role: db + spec: + securityContext: + fsGroup: 999 + fsGroupChangePolicy: "OnRootMismatch" + containers: + - name: db + image: "{{ .Values.db.image.repository }}:{{ .Values.db.image.tag }}" + imagePullPolicy: {{ .Values.global.imagePullPolicy }} + args: + - postgres + - -c + - shared_preload_libraries=timescaledb,pg_cron + - -c + - cron.database_name=$(POSTGRES_DB) + ports: + - containerPort: {{ .Values.db.port }} + name: postgres + env: + - name: PGDATA + value: /var/lib/postgresql/data/pgdata + envFrom: + - secretRef: + name: {{ include "opensampl.fullname" . }}-db-secret + - configMapRef: + name: {{ include "opensampl.fullname" . }}-db-config + volumeMounts: + - name: db-storage + mountPath: /var/lib/postgresql/data + - name: init-script + mountPath: /docker-entrypoint-initdb.d/020_create_grafana_user.sh + subPath: 020_create_grafana_user.sh + volumes: + - name: init-script + configMap: + name: {{ include "opensampl.fullname" . }}-db-init + volumeClaimTemplates: + - metadata: + name: db-storage + spec: + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: {{ .Values.db.persistence.size }} + {{- if .Values.db.persistence.storageClass }} + storageClassName: {{ .Values.db.persistence.storageClass }} + {{- end }} diff --git a/helm/templates/grafana/deployment.yaml b/helm/templates/grafana/deployment.yaml new file mode 100644 index 0000000..cf27599 --- /dev/null +++ b/helm/templates/grafana/deployment.yaml @@ -0,0 +1,57 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "opensampl.fullname" . }}-grafana + labels: + {{- include "opensampl.labels" . | nindent 4 }} +spec: + replicas: 1 + selector: + matchLabels: + app: {{ include "opensampl.name" . }}-grafana + template: + metadata: + labels: + app: {{ include "opensampl.name" . }}-grafana + spec: + containers: + - name: grafana + image: "{{ .Values.grafana.image.repository }}:{{ .Values.grafana.image.tag }}" + imagePullPolicy: {{ .Values.global.imagePullPolicy }} + ports: + - containerPort: {{ .Values.grafana.port }} + env: + - name: GF_SECURITY_ADMIN_USER + value: {{ .Values.grafana.adminUser }} + - name: GF_SECURITY_ADMIN_PASSWORD + value: {{ .Values.grafana.adminPassword }} + - name: GF_DATABASE_TYPE + value: postgres + - name: GF_DATABASE_HOST + value: {{ include "opensampl.fullname" . }}-db:{{ .Values.db.port }} + - name: GF_DATABASE_NAME + value: {{ .Values.db.database }} + - name: GF_DATABASE_USER + value: {{ .Values.db.username }} + - name: GF_DATABASE_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "opensampl.fullname" . }}-db-secret + key: POSTGRES_PASSWORD + # For testing in Mariner + - name: GF_SERVER_ROOT_URL + value: "https://opensampl-test.ornl.gov/grafana" + - name: GF_SERVER_SERVE_FROM_SUB_PATH + value: {{ .Values.grafana.serveFromSubPath | default "false" | quote }} + readinessProbe: + httpGet: + path: /login + port: {{ .Values.grafana.port }} + initialDelaySeconds: 10 + periodSeconds: 5 + livenessProbe: + httpGet: + path: /api/health + port: {{ .Values.grafana.port }} + initialDelaySeconds: 30 + periodSeconds: 10 diff --git a/helm/templates/grafana/service.yaml b/helm/templates/grafana/service.yaml new file mode 100644 index 0000000..6070f7f --- /dev/null +++ b/helm/templates/grafana/service.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "opensampl.fullname" . }}-grafana + labels: + {{- include "opensampl.labels" . | nindent 4 }} +spec: + type: ClusterIP + selector: + app: {{ include "opensampl.name" . }}-grafana + ports: + - name: http + port: {{ .Values.grafana.port }} + targetPort: {{ .Values.grafana.port }} diff --git a/helm/templates/ingress.yaml b/helm/templates/ingress.yaml new file mode 100644 index 0000000..daf2d68 --- /dev/null +++ b/helm/templates/ingress.yaml @@ -0,0 +1,41 @@ +{{- if .Values.ingress.enabled }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "opensampl.fullname" . }}-ingress + labels: + {{- include "opensampl.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if .Values.ingress.className }} + ingressClassName: {{ .Values.ingress.className }} + {{- end }} + {{- if .Values.ingress.tls }} + tls: + {{- range .Values.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + pathType: {{ .pathType }} + backend: + service: + name: {{ include "opensampl.fullname" $ }}-{{ .service }} + port: + number: {{ if eq .service "backend" }}{{ $.Values.backend.service.port }}{{ else }}{{ $.Values.grafana.port }}{{ end }} + {{- end }} + {{- end }} +{{- end }} \ No newline at end of file diff --git a/helm/templates/migrations/migrations-job.yaml b/helm/templates/migrations/migrations-job.yaml new file mode 100644 index 0000000..ddd1aec --- /dev/null +++ b/helm/templates/migrations/migrations-job.yaml @@ -0,0 +1,50 @@ +{{- if .Values.migrations.enabled }} +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ include "opensampl.fullname" . }}-migrations + labels: + {{- include "opensampl.labels" . | nindent 4 }} + role: migrations + annotations: + # Run as a Helm hook so migrations execute after the db exists on install and are re-run on every + # upgrade (a plain Job is immutable and would block or skip `helm upgrade`). A failed Job is kept + # for log inspection; a successful one is removed. + "helm.sh/hook": post-install,post-upgrade + "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded +spec: + # fail reasonably fast (the default of 6 retries with backoff takes ~8 minutes) so a broken + # migration is reported promptly + backoffLimit: 2 + template: + spec: + initContainers: + - name: wait-for-db + image: "{{ .Values.busybox.image.repository }}:{{ .Values.busybox.image.tag }}" + command: + - sh + - -c + - > + until nc -z {{ include "opensampl.fullname" . }}-db {{ .Values.db.port }}; + do echo "Waiting for DB..."; + sleep 3; + done; + containers: + - name: migrations + image: "{{ .Values.migrations.image.repository }}:{{ .Values.migrations.image.tag }}" + imagePullPolicy: {{ .Values.global.imagePullPolicy }} + env: + # the migrations image (alembic env.py) reads DB_URI + - name: DB_URI + value: postgresql+psycopg2://$(POSTGRES_USER):$(POSTGRES_PASSWORD)@{{ include "opensampl.fullname" . }}-db:{{ .Values.db.port }}/{{ .Values.db.database }} + {{- range $key, $value := .Values.migrations.env }} + - name: {{ $key }} + value: {{ $value | quote }} + {{- end }} + envFrom: + - secretRef: + name: {{ include "opensampl.fullname" . }}-db-secret + - configMapRef: + name: {{ include "opensampl.fullname" . }}-db-config + restartPolicy: OnFailure +{{- end }} diff --git a/helm/values-cluster-overrides.yaml b/helm/values-cluster-overrides.yaml new file mode 100644 index 0000000..73d1aad --- /dev/null +++ b/helm/values-cluster-overrides.yaml @@ -0,0 +1,68 @@ +# Laboratory and Kubernetes cluster specific overrides. + +global: + imagePullPolicy: Always + +# Database configuration +db: + image: + repository: ghcr.io/ornl/opensampl-db + tag: latest + database: opensampl + username: opensampl + password: "admin" + port: 5432 + persistence: + size: 50Gi + storageClass: "longhorn" + +# Backend configuration +backend: + image: + repository: ghcr.io/ornl/opensampl-backend + tag: latest + replicas: 1 + service: + type: ClusterIP + port: 8000 + logLevel: "INFO" + useApiKey: false + env: [] + +# Grafana configuration +grafana: + image: + repository: ghcr.io/ornl/opensampl-grafana + tag: latest + adminUser: admin + adminPassword: "admin" + port: 3000 + service: + type: ClusterIP + rootUrl: "https://opensampl-test.eig3-orc1.ornl.gov/grafana" + serveFromSubPath: true + +# Migrations (run as a post-install/post-upgrade Helm hook) +migrations: + enabled: true + +# Ingress configuration +ingress: + enabled: true + className: "nginx" + annotations: + cert-manager.io/cluster-issuer: "letsencrypt-prod" # If using cert-manager + # nginx.ingress.kubernetes.io/ssl-redirect: "true" + hosts: + - host: opensampl-test.eig3-orc1.ornl.gov + paths: + - path: /grafana + pathType: Prefix + service: grafana + - path: / + pathType: Prefix + service: backend + tls: + - secretName: opensampl-tls + hosts: + - opensampl-test.eig3-orc1.ornl.gov \ No newline at end of file diff --git a/helm/values.yaml b/helm/values.yaml new file mode 100644 index 0000000..16a665e --- /dev/null +++ b/helm/values.yaml @@ -0,0 +1,68 @@ +# Global settings +global: + imagePullPolicy: IfNotPresent + +busybox: + image: + repository: busybox + tag: "1.36.1" + +# PostgreSQL Database +db: + image: + repository: ghcr.io/ornl/opensampl-db + tag: latest + username: opensampl + password: opensampl + grafanaPassword: grafana123 + database: opensampl + port: 5432 + storage: 10Gi + persistence: + enabled: true + size: 10Gi + storageClass: "" + +# Backend service +backend: + image: + repository: ghcr.io/ornl/opensampl-backend + tag: latest + replicas: 1 + service: + type: ClusterIP + port: 8000 + logLevel: "INFO" + useApiKey: false + env: [] + +# Grafana service +grafana: + image: + repository: ghcr.io/ornl/opensampl-grafana + tag: latest + replicas: 1 + port: 3000 + adminUser: admin + adminPassword: admin + database: + enabled: true + type: postgres + host: opensampl-db + port: 5432 + name: opensampl + user: opensampl + +# Migrations (optional) +migrations: + enabled: true + image: + repository: ghcr.io/ornl/opensampl-migrations + tag: latest + env: {} + +# Ingress (optional) +ingress: + enabled: false + className: nginx + host: opensampl.local diff --git a/mkdocs.yaml b/mkdocs.yaml index 4af6484..e60450c 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -13,11 +13,15 @@ nav: - Expected Table Format: guides/expected_table_format.md - Create: guides/create_probe_type.md - Server: guides/opensampl-server.md + - Kubernetes (Helm): guides/kubernetes-helm.md - Value Backfill: guides/value-backfill.md - Collect: guides/collection.md - Automatic Ingest: guides/automatic_ingest.md - NTP Extension: guides/ntp-extension.md - Random Data: guides/random-data-generation.md +- Maintainers: + - CI/CD and Releases: maintainers/ci-cd.md + - Security Remediation: maintainers/security-remediation.md - API: - index: api/index.md - cli: api/cli.md diff --git a/opensampl/server/backend/Dockerfile b/opensampl/server/backend/Dockerfile index b0bedc6..10e877f 100644 --- a/opensampl/server/backend/Dockerfile +++ b/opensampl/server/backend/Dockerfile @@ -1,13 +1,24 @@ +# Optional build context holding a locally built opensampl wheel. +# CI overrides this stage with `--build-context wheel=`; +# without it the stage is empty and the released version is installed from PyPI. +FROM scratch AS wheel + FROM python:3.14 AS base -ARG OPENSAMPL_VERSION=1.2.6 +ARG OPENSAMPL_VERSION=1.2.7 WORKDIR /tmp ENV ROUTE_TO_BACKEND=false FROM base AS prod -RUN pip install --no-cache-dir \ - "opensampl[backend]==${OPENSAMPL_VERSION}" \ +RUN --mount=type=bind,from=wheel,target=/wheels \ + if ls /wheels/opensampl-*.whl >/dev/null 2>&1; then \ + spec="$(ls /wheels/opensampl-*.whl)[backend]"; \ + else \ + spec="opensampl[backend]==${OPENSAMPL_VERSION}"; \ + fi && \ + pip install --no-cache-dir \ + "${spec}" \ "anyio>=4.14.2,<5" \ "urllib3>=2.8.0,<3" diff --git a/opensampl/server/db/Dockerfile b/opensampl/server/db/Dockerfile new file mode 100644 index 0000000..a16469b --- /dev/null +++ b/opensampl/server/db/Dockerfile @@ -0,0 +1,7 @@ +FROM timescale/timescaledb-ha:pg16.2-ts2.14.2-all + +ENV DEBIAN_FRONTEND=noninteractive + +COPY create-grafana.sh /docker-entrypoint-initdb.d/020_create_grafana_user.sh + +CMD ["postgres"] \ No newline at end of file diff --git a/opensampl/server/db/create-grafana.sh b/opensampl/server/db/create-grafana.sh new file mode 100644 index 0000000..daa9694 --- /dev/null +++ b/opensampl/server/db/create-grafana.sh @@ -0,0 +1,4 @@ +#!/bin/bash +psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" <`; +# without it the stage is empty and the released version is installed from PyPI. +FROM scratch AS wheel + FROM python:3.14 -ARG OPENSAMPL_VERSION=1.2.6 +ARG OPENSAMPL_VERSION=1.2.7 WORKDIR / -RUN pip install --no-cache-dir \ - "opensampl[migrations]==${OPENSAMPL_VERSION}" \ +RUN --mount=type=bind,from=wheel,target=/wheels \ + if ls /wheels/opensampl-*.whl >/dev/null 2>&1; then \ + spec="$(ls /wheels/opensampl-*.whl)[migrations]"; \ + else \ + spec="opensampl[migrations]==${OPENSAMPL_VERSION}"; \ + fi && \ + pip install --no-cache-dir \ + "${spec}" \ "mako>=1.4.2,<2" \ "urllib3>=2.8.0,<3" diff --git a/pyproject.toml b/pyproject.toml index 1975f0a..076d926 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -126,6 +126,7 @@ include = [ "opensampl/server/migrations/*", "opensampl/server/grafana/*", "opensampl/server/backend/*", + "opensampl/server/db/*", "opensampl/create/templates/*", "opensampl/**/*.py", ] @@ -139,6 +140,7 @@ include = [ "opensampl/server/migrations/*", "opensampl/server/grafana/*", "opensampl/server/backend/*", + "opensampl/server/db/*", "opensampl/create/templates/*", "opensampl/**/*.py", ]