diff --git a/.github/workflows/helm-chart.old b/.github/workflows/helm-chart.old new file mode 100644 index 0000000..6e234c0 --- /dev/null +++ b/.github/workflows/helm-chart.old @@ -0,0 +1,76 @@ +name: Helm Chart CI/CD + +on: + push: + branches: [ helm-deploy ] + pull_request: + branches: [ helm-deploy ] + workflow_dispatch: + +jobs: + build-docker-images: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + strategy: + matrix: + service: + - name: db + context: ./opensampl/db + - name: backend + context: ./opensampl/backend + - name: grafana + context: ./opensampl/server/grafana + #- name: migrations + # context: ./opensampl/migrations + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push ${{ matrix.service.name }} image + uses: docker/build-push-action@v5 + with: + context: ${{ matrix.service.context }} + push: true + tags: ghcr.io/ornl/opensampl-${{ matrix.service.name }}:latest + cache-from: type=gha,scope=${{ matrix.service.name }} + cache-to: type=gha,mode=max,scope=${{ matrix.service.name }} + + helm: + runs-on: ubuntu-latest + needs: build-docker-images + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Helm + uses: azure/setup-helm@v3 + with: + version: v3.14.0 + + - name: Helm Lint + run: helm lint ./helm + + - 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/helm-chart.yml b/.github/workflows/helm-chart.yml new file mode 100644 index 0000000..191faaf --- /dev/null +++ b/.github/workflows/helm-chart.yml @@ -0,0 +1,134 @@ +name: Helm Chart CI/CD + +on: + push: + branches: [ helm-deploy ] + pull_request: + branches: + - helm-deploy + - main + workflow_dispatch: + +jobs: + build-docker-images: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + strategy: + matrix: + service: + - name: db + context: ./opensampl/db + - name: backend + context: ./opensampl/backend + - name: grafana + context: ./opensampl/server/grafana + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set image tags + id: tags + run: | + if [ "${{ github.event_name }}" == "pull_request" ]; then + PR_NUMBER=$(echo ${{ github.ref }} | awk -F'/' '{print $3}') + echo "tag=pr-${PR_NUMBER}" >> $GITHUB_OUTPUT + else + echo "tag=latest" >> $GITHUB_OUTPUT + fi + + - name: Build and push ${{ matrix.service.name }} image + uses: docker/build-push-action@v5 + with: + context: ${{ matrix.service.context }} + push: true + tags: ghcr.io/ornl/opensampl-${{ matrix.service.name }}:${{ steps.tags.outputs.tag }} + cache-from: type=gha,scope=${{ matrix.service.name }} + cache-to: type=gha,mode=max,scope=${{ matrix.service.name }} + + test-helm-chart: + name: Test Helm Chart Deployment + runs-on: ubuntu-latest + needs: build-docker-images + if: github.event_name == 'pull_request' + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Start minikube + uses: medyagh/setup-minikube@latest + + - name: Set up Helm + uses: azure/setup-helm@v4 + + - name: Install Helm Chart + working-directory: helm/ + run: | + PR_NUMBER=$(echo ${GITHUB_REF} | awk -F'/' '{print $3}') + + helm install test-opensampl . \ + --set db.image.tag=pr-${PR_NUMBER} \ + --set backend.image.tag=pr-${PR_NUMBER} \ + --set grafana.image.tag=pr-${PR_NUMBER} \ + --set migrations.enabled=false \ + --wait \ + --timeout 5m + + - name: Verify Deployment + run: | + kubectl get pods + kubectl get services + kubectl get pvc + + - 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 + + helm-package: + name: Package and Push Helm Chart + runs-on: ubuntu-latest + needs: build-docker-images + if: github.event_name != 'pull_request' + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Helm + uses: azure/setup-helm@v4 + with: + version: v3.14.0 + + - name: Helm Lint + run: helm lint ./helm + + - 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 \ No newline at end of file diff --git a/.github/workflows/helm-readme.md b/.github/workflows/helm-readme.md new file mode 100644 index 0000000..4580128 --- /dev/null +++ b/.github/workflows/helm-readme.md @@ -0,0 +1,27 @@ +### 1. **Smart Image Tagging** +Images now get tagged with: +- `pr-123` for pull requests +- `helm-deploy-abc123` for branch commits +- `latest` for main branch + +### 2. **New Test Job** (`test-helm-chart`) +- **Only runs on PRs** (not regular pushes) +- Spins up local Kubernetes (minikube) +- Installs your Helm chart with PR-tagged images +- Verifies pods start successfully +- Shows detailed logs if anything fails + +### 3. **Separated Helm Packaging** +- `helm-package` job only runs on **direct pushes** (not PRs) +- Lints, packages, and pushes the chart + +## Workflow Flow + +**On Pull Request:** +``` +Build Images (with pr-123 tags) → Test in Kubernetes → ✓ Pass/Fail +``` + +**On Push to helm-deploy:** +``` +Build Images (with latest tag) → Package & Push Helm Chart \ No newline at end of file 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/Accept\357\200\272" "b/helm/Accept\357\200\272" new file mode 100644 index 0000000..e69de29 diff --git a/helm/Chart.yaml b/helm/Chart.yaml new file mode 100644 index 0000000..2d20e33 --- /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.1.7 +appVersion: "1.0.0" +icon: https://raw.githubusercontent.com/ORNL/OpenSAMPL/main/docs/logo.png \ No newline at end of file diff --git a/helm/GET b/helm/GET new file mode 100644 index 0000000..e69de29 diff --git "a/helm/Host\357\200\272" "b/helm/Host\357\200\272" new file mode 100644 index 0000000..e69de29 diff --git a/helm/README.md b/helm/README.md new file mode 100644 index 0000000..4f56c30 --- /dev/null +++ b/helm/README.md @@ -0,0 +1,17 @@ +charts/ +└── opensampl/ + ├── Chart.yaml + ├── values.yaml + ├── templates/ + │ ├── _helpers.tpl + │ ├── db-statefulset.yaml + │ ├── db-service.yaml + │ ├── grafana-deployment.yaml + │ ├── grafana-service.yaml + │ ├── backend-deployment.yaml + │ ├── backend-service.yaml + │ ├── migrations-job.yaml + │ ├── pvc.yaml + │ ├── ingress.yaml (optional) + │ └── NOTES.txt + └── .helmignore \ No newline at end of file diff --git "a/helm/User-Agent\357\200\272" "b/helm/User-Agent\357\200\272" new file mode 100644 index 0000000..e69de29 diff --git a/helm/templates/NOTES.txt b/helm/templates/NOTES.txt new file mode 100644 index 0000000..44279c2 --- /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.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..16d48c1 --- /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: busybox + 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..9dd7f5e --- /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 + + # This script creates additional database setup for Grafana + psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL + -- Grant necessary privileges + GRANT ALL PRIVILEGES ON DATABASE ${POSTGRES_DB} TO ${POSTGRES_USER}; + EOSQL \ No newline at end of file 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..1724be1 --- /dev/null +++ b/helm/templates/db/statefulset.yaml @@ -0,0 +1,60 @@ +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 }} + 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..8edd519 --- /dev/null +++ b/helm/templates/migrations/migrations-job.yaml @@ -0,0 +1,21 @@ +{{- if .Values.migrations.enabled }} +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ include "opensampl.fullname" . }}-migrations +spec: + template: + spec: + containers: + - name: migrations + image: "{{ .Values.migrations.image.repository }}:{{ .Values.migrations.image.tag }}" + env: + - name: DATABASE_URL + value: postgresql://$(POSTGRES_USER):$(POSTGRES_PASSWORD)@{{ include "opensampl.fullname" . }}-db:{{ .Values.db.port }}/{{ .Values.db.database }} + envFrom: + - secretRef: + name: {{ include "opensampl.fullname" . }}-db-secret + - configMapRef: + name: {{ include "opensampl.fullname" . }}-db-config + restartPolicy: OnFailure +{{- end }} \ No newline at end of file diff --git a/helm/values-mariner.yaml b/helm/values-mariner.yaml new file mode 100644 index 0000000..915bf7e --- /dev/null +++ b/helm/values-mariner.yaml @@ -0,0 +1,68 @@ +# values-production.yaml + +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.ornl.gov/grafana" + serveFromSubPath: true + +# Migrations (disabled until ready) +migrations: + enabled: false + +# 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.ornl.gov + paths: + - path: /grafana + pathType: Prefix + service: grafana + - path: / + pathType: Prefix + service: backend + tls: + - secretName: opensampl-tls + hosts: + - opensampl-test.ornl.gov \ No newline at end of file diff --git a/helm/values.yaml b/helm/values.yaml new file mode 100644 index 0000000..753c5cb --- /dev/null +++ b/helm/values.yaml @@ -0,0 +1,63 @@ +# Global settings +global: + imagePullPolicy: IfNotPresent + +# 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/opensampl/backend/Dockerfile b/opensampl/backend/Dockerfile new file mode 100644 index 0000000..d1ab545 --- /dev/null +++ b/opensampl/backend/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.11 +WORKDIR /tmp +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt && \ + rm requirements.txt +ENV ROUTE_TO_BACKEND=false +COPY main.py . +CMD ["uvicorn", "main:app", "--proxy-headers", "--host", "0.0.0.0", "--port", "8000"] +# CMD ["tail", "-f", "/dev/null"] \ No newline at end of file diff --git a/opensampl/backend/README.md b/opensampl/backend/README.md new file mode 100644 index 0000000..f46eed8 --- /dev/null +++ b/opensampl/backend/README.md @@ -0,0 +1,2 @@ +CAST Backend +http://localhost:8000/docs - swagger endpoint \ No newline at end of file diff --git a/opensampl/backend/main.py b/opensampl/backend/main.py new file mode 100644 index 0000000..26fbb7c --- /dev/null +++ b/opensampl/backend/main.py @@ -0,0 +1,353 @@ +import io +import json +import os +import sys +import time +from typing import Any, Dict, Callable, Optional +from datetime import datetime, timedelta +import pandas as pd +from fastapi import FastAPI, HTTPException, File, UploadFile, Form, Request, Response, Security, Depends +from fastapi.security.api_key import APIKeyHeader +from fastapi.responses import JSONResponse, RedirectResponse +from loguru import logger +from pydantic import BaseModel +from sqlalchemy import create_engine, text, select, or_, and_ +from sqlalchemy.exc import SQLAlchemyError, IntegrityError +from sqlalchemy.orm import sessionmaker, Session +from prometheus_client import Counter, Histogram, generate_latest, CONTENT_TYPE_LATEST +from opensampl.db.access_orm import APIAccessKey +from opensampl import load_data +from opensampl.db.orm import ProbeMetadata +from opensampl.vendors.constants import VendorType, ProbeKey +from opensampl.metrics import METRICS, MetricType +from opensampl.references import REF_TYPES, CompoundReferenceType, ReferenceType +import psycopg2 + +class TimeDataPoint(BaseModel): + time: str + value: float + + +class WriteTablePayload(BaseModel): + table: str + data: Dict[str, Any] + if_exists: load_data.conflict_actions = 'update' + + +class ProbeMetadataPayload(BaseModel): + vendor: VendorType + probe_key: ProbeKey + data: Dict[str, Any] + + +DATABASE_URI = os.getenv("DATABASE_URL") +engine = create_engine(DATABASE_URI) + +loglevel = os.getenv("BACKEND_LOG_LEVEL", "INFO") +app = FastAPI() + + +REQUEST_COUNT = Counter( + "http_requests_total", + "Total number of HTTP requests", + ["method", "endpoint", "http_status"] +) + +REQUEST_LATENCY = Histogram( + "http_request_duration_seconds", + "Duration of HTTP requests in seconds", + ["method", "endpoint"] +) + +EXCLUDED_PATHS = {"/metrics", "/healthcheck", "/healthcheck_database", "/healthcheck_metadata"} + +logger.configure(handlers=[{"sink": sys.stderr, "level": loglevel}]) + +USE_API_KEY = os.getenv("USE_API_KEY", "false").lower() == "true" +API_KEY_NAME = "access-key" + +api_key_header = APIKeyHeader(name=API_KEY_NAME, auto_error=False) + +def get_keys(): + env_keys = os.getenv('API_KEYS', '').strip() + keys = [k.strip() for k in env_keys.split(',') if k.strip()] + if keys: + logger.debug("api access keys loaded from env") + return keys + try: + Session = sessionmaker(bind=engine) + with Session() as session: + now = datetime.utcnow() + stmt = select(APIAccessKey.key).where( + or_( + APIAccessKey.expires_at == None, + APIAccessKey.expires_at > now + ) + ) + result = session.execute(stmt) + keys = [row[0] for row in result.all()] + logger.debug("api access keys loaded from db") + return keys + except Exception as e: + logger.debug(f"exception attempting to load api access keys from db: {e}") + return [] + +def validate_api_key(api_key: str = Security(api_key_header)): + if not USE_API_KEY: + return # Security is disabled + if api_key not in get_keys(): + raise HTTPException(status_code=403, detail="Invalid or missing API key") + return api_key + +def get_db(): + Session = sessionmaker(bind=engine) + try: + session = Session() + yield session + finally: + session.close() + +@app.middleware("http") +async def metrics_middleware(request: Request, call_next: Callable) -> Response: + """Middleware to track request metrics.""" + if request.url.path in EXCLUDED_PATHS: + return await call_next(request) + start_time = time.time() + response: Response = await call_next(request) + duration = time.time() - start_time + + REQUEST_COUNT.labels( + method=request.method, + endpoint=request.url.path, + http_status=response.status_code + ).inc() + + REQUEST_LATENCY.labels( + method=request.method, + endpoint=request.url.path + ).observe(duration) + + return response + + +# add route to docs from / to /docs +@app.get("/", include_in_schema=False) +async def docs_redirect(): + return RedirectResponse(url='/docs') + + +@app.get("/setloglevel") +def set_log_level(newloglevel: str, api_key: str = Depends(validate_api_key)): + """ + change visible log level in backend container + """ + newloglevel = newloglevel.upper() + logger.configure(handlers=[{"sink": sys.stderr, "level": newloglevel}]) + return {"loglevel": newloglevel} + + +@app.get("/checkloglevel") +def check_log_level(api_key: str = Depends(validate_api_key)): + """ + This is to check which log levels are visible in backend container + """ + logger.debug('Debug test') + logger.info('Info test') + logger.warning('Warning test') + logger.error('Error test') + current_level = list(logger._core.handlers.values())[0]["level"].name + return {"loglevel": current_level} + + +@app.post("/write_to_table") +def write_to_table(payload: WriteTablePayload, api_key: str = Depends(validate_api_key), session: Session = Depends(get_db)): + try: + load_data.write_to_table(table=payload.table, data=payload.data, if_exists=payload.if_exists, session=session) + logger.debug(f'Successfully wrote to {payload.table} using: {payload.data}') + return JSONResponse(content={"message": f"Succeeded loading data into {payload.table}"}, status_code=200) + except IntegrityError as e: + if isinstance(e.orig, psycopg2.errors.UniqueViolation): + return JSONResponse(content={"message": f"Unique violation error: {e}"}, status_code=409) + return JSONResponse(content={"message": f"Integrity error: {e}"}, status_code=500) + except SQLAlchemyError as e: + logger.error(f'SQLAlchemy error: {e}') + return JSONResponse(content={"message": f"Database error: {e}"}, status_code=500) + except json.JSONDecodeError as e: + logger.error(f'JSON decode error: {e}') + return JSONResponse(content={"message": f"Invalid JSON data: {e}"}, status_code=400) + except Exception as e: + logger.error(f'Unexpected error: {e}') + return JSONResponse(content={"message": f"Failed to load JSON into database: {e}"}, status_code=500) + + +@app.post("/load_time_data") +async def load_time_data(probe_key_str: str = Form(...), + metric_type_str: Optional[str] = Form(None), + reference_type_str: Optional[str] = Form(None), + compound_key_str: Optional[str] = Form(None), + file: UploadFile = File(...), + api_key: str = Depends(validate_api_key), + session: Session = Depends(get_db)): + try: + + probe_key = ProbeKey(**json.loads(probe_key_str)) + + if metric_type_str is not None: + metric_type_dict = json.loads(metric_type_str) + metric_type = MetricType(**metric_type_dict) + else: + metric_type = METRICS.UNKNOWN + + if reference_type_str is not None: + reference_type_dict = json.loads(reference_type_str) + if 'reference_table' in reference_type_dict: + reference_type = CompoundReferenceType(**reference_type_dict) + else: + reference_type = ReferenceType(**reference_type_dict) + else: + reference_type = REF_TYPES.UNKNOWN + + compound_key = None if compound_key_str is None else json.loads(compound_key_str) + + content = await file.read() + df = pd.read_csv(io.BytesIO(content)) + logger.info(df.head()) + # Convert time strings back to datetime + df['time'] = pd.to_datetime(df['time']) + + # Convert value strings back to float64 + # df['value'] = df['value'].astype('float64') + + # Use the same load_time_data function as before + load_data.load_time_data( + probe_key=probe_key, + metric_type=metric_type, + reference_type=reference_type, + compound_key=compound_key, + data=df, + session=session + ) + + return JSONResponse( + content={"message": f"Successfully loaded {len(df)} data points"}, + status_code=200 + ) + except IntegrityError as e: + if session: + session.rollback() + session.close() + if isinstance(e.orig, psycopg2.errors.UniqueViolation): + return JSONResponse(content={"message": f"Unique violation error: {e}"}, status_code=409) + return JSONResponse(content={"message": f"Integrity error: {e}"}, status_code=500) + except SQLAlchemyError as e: + logger.error(f'Database error: {e}') + if session: + session.rollback() + session.close() + raise HTTPException(status_code=500, detail=f"Database error: {str(e)}") + except Exception as e: + logger.error(f'Unexpected error: {e}') + if session: + session.rollback() + session.close() + raise HTTPException(status_code=500, detail=f"Error processing time series data: {str(e)}") + + +@app.post("/load_probe_metadata") +def load_probe_metadata(payload: ProbeMetadataPayload, api_key: str = Depends(validate_api_key), session: Session = Depends(get_db)): + logger.debug(f"Received payload: {payload.model_dump()}") + + try: + load_data.load_probe_metadata(vendor=payload.vendor, probe_key=payload.probe_key, data=payload.data, + session=session) + logger.debug( + f'Successfully wrote to {ProbeMetadata.__tablename__} and {payload.vendor.metadata_table}: {payload.data}') + return JSONResponse(content={"message": f"Succeeded loaded metadata for {payload.probe_key}"}, status_code=200) + except IntegrityError as e: + session.rollback() + if isinstance(e.orig, psycopg2.errors.UniqueViolation): + return JSONResponse(content={"message": f"Unique violation error: {e}"}, status_code=409) + return JSONResponse(content={"message": f"Integrity error: {e}"}, status_code=500) + except SQLAlchemyError as e: + logger.error(f'SQLAlchemy error: {e}') + return JSONResponse(content={"message": f"Database error: {e}"}, status_code=500) + except json.JSONDecodeError as e: + logger.error(f'JSON decode error: {e}') + return JSONResponse(content={"message": f"Invalid JSON data: {e}"}, status_code=400) + except Exception as e: + logger.exception(f'Unexpected error: {e}') + return JSONResponse(content={"message": f"Failed to load JSON into database: {e}"}, status_code=500) + + +@app.get("/create_new_tables") +def create_new_tables(create_schema: bool = True, api_key: str = Depends(validate_api_key), session: Session = Depends(get_db)): + try: + load_data.create_new_tables(create_schema=create_schema, session=session) + return JSONResponse(content={"message": f"Succeeded in creating any new tables"}, status_code=200) + except SQLAlchemyError as e: + logger.error(f'SQLAlchemy error: {e}') + return JSONResponse(content={"message": f"Database error: {e}"}, status_code=500) + except json.JSONDecodeError as e: + logger.error(f'JSON decode error: {e}') + return JSONResponse(content={"message": f"Invalid JSON data: {e}"}, status_code=400) + except Exception as e: + logger.error(f'Unexpected error: {e}') + return JSONResponse(content={"message": f"Failed to load JSON into database: {e}"}, status_code=500) + + +@app.get("/gen_api_key") +def generate_api_key(expire_after: Optional[int] = None, session: Session = Depends(get_db)): + try: + + new_key = APIAccessKey() + new_key.generate_key() + if expire_after: + new_key.expires_at = datetime.utcnow() + timedelta(days=expire_after) + + session.add(new_key) + + session.commit() + return JSONResponse(content={"message": f"Succeeded in creating new access key"}, status_code=200) + except SQLAlchemyError as e: + logger.error(f'SQLAlchemy error: {e}') + return JSONResponse(content={"message": f"Database error: {e}"}, status_code=500) + except Exception as e: + logger.error(f'Unexpected error: {e}') + return JSONResponse(content={"message": f"Failed to create new access key: {e}"}, status_code=500) + + +@app.get("/healthcheck") +def healthcheck(): + return {"status": "OK"} + +@app.get("/healthcheck_database") +def healthcheck_db(): + try: + with engine.connect() as connection: + connection.execute(text("SELECT 1")) + return {"status": "OK"} + except SQLAlchemyError: + raise HTTPException(status_code=503, detail="Database connection error") + + +@app.get("/healthcheck_metadata") +def healthcheck_metadata(): + try: + with engine.connect() as connection: + result = connection.execute( + text("SELECT schema_name FROM information_schema.schemata WHERE schema_name = 'castdb';")) + schema_exists = result.fetchone() is not None + if schema_exists: + return {"status": "OK"} + else: + raise HTTPException(status_code=500, detail="Schema 'castdb' does not exist") + except SQLAlchemyError as e: + raise HTTPException(status_code=503, detail=f"Database connection error: {str(e)}") + + +@app.get("/metrics", include_in_schema=False) +def metrics(): + """ + Expose Prometheus metrics. + """ + return Response(content=generate_latest(), media_type=CONTENT_TYPE_LATEST) diff --git a/opensampl/backend/requirements.txt b/opensampl/backend/requirements.txt new file mode 100644 index 0000000..ca91da0 --- /dev/null +++ b/opensampl/backend/requirements.txt @@ -0,0 +1,8 @@ +psycopg2-binary +fastapi +uvicorn +sqlalchemy +loguru +python-multipart +prometheus-client +opensampl==1.1.5 diff --git a/opensampl/db/Dockerfile b/opensampl/db/Dockerfile new file mode 100644 index 0000000..a16469b --- /dev/null +++ b/opensampl/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/db/create-grafana.sh b/opensampl/db/create-grafana.sh new file mode 100644 index 0000000..daa9694 --- /dev/null +++ b/opensampl/db/create-grafana.sh @@ -0,0 +1,4 @@ +#!/bin/bash +psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" <