diff --git a/.github/workflows/build-and-push-image.yml b/.github/workflows/build-and-push-image.yml new file mode 100644 index 00000000..180f9802 --- /dev/null +++ b/.github/workflows/build-and-push-image.yml @@ -0,0 +1,49 @@ +name: Build and Push Docker Image + +on: + push: + branches: [main] + tags: ['v*'] + workflow_dispatch: + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - uses: actions/checkout@v4 + + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=semver,pattern={{version}} + type=sha,prefix= + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + file: Dockerfile_simple + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/k8s-manifests-ci.yml b/.github/workflows/k8s-manifests-ci.yml new file mode 100644 index 00000000..d65bb22c --- /dev/null +++ b/.github/workflows/k8s-manifests-ci.yml @@ -0,0 +1,105 @@ +name: K8s Manifests CI + +on: + push: + paths: + - 'k8s/**' + pull_request: + paths: + - 'k8s/**' + +jobs: + validate-manifests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install kubeconform + run: | + curl -sSL https://github.com/yannh/kubeconform/releases/latest/download/kubeconform-linux-amd64.tar.gz | tar xz + sudo mv kubeconform /usr/local/bin/ + + - name: Validate K8s manifests (base) + run: | + kubeconform -summary -strict -kubernetes-version 1.28.0 -ignore-filename-pattern 'kustomization.yaml' k8s/base/*.yaml + + - name: Install kubectl + uses: azure/setup-kubectl@v3 + + - name: Kustomize build (template-app overlay) + run: | + kubectl kustomize k8s/overlays/template-app/ > /dev/null + echo "Kustomize build succeeded for template-app" + + - name: Validate kustomized output + run: | + kubectl kustomize k8s/overlays/template-app/ | kubeconform -summary -strict -kubernetes-version 1.28.0 + + integration-test: + runs-on: ubuntu-latest + needs: validate-manifests + strategy: + fail-fast: false + matrix: + dockerfile: + - Dockerfile_simple + - Dockerfile + steps: + - uses: actions/checkout@v4 + + - name: Check if Dockerfile exists + id: check + run: | + if [ -f "${{ matrix.dockerfile }}" ]; then + echo "exists=true" >> "$GITHUB_OUTPUT" + echo "Found ${{ matrix.dockerfile }}, will run integration test" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + echo "Skipping: ${{ matrix.dockerfile }} not found" + fi + + - name: Build Docker image from current code + if: steps.check.outputs.exists == 'true' + run: | + docker build -t openms-streamlit:test -f ${{ matrix.dockerfile }} . + + - name: Create kind cluster + if: steps.check.outputs.exists == 'true' + uses: helm/kind-action@v1 + with: + cluster_name: test-cluster + + - name: Load image into kind cluster + if: steps.check.outputs.exists == 'true' + run: | + kind load docker-image openms-streamlit:test --name test-cluster + + - name: Install nginx ingress controller + if: steps.check.outputs.exists == 'true' + run: | + kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml + kubectl wait --namespace ingress-nginx --for=condition=ready pod --selector=app.kubernetes.io/component=controller --timeout=90s + + - name: Deploy with Kustomize + if: steps.check.outputs.exists == 'true' + run: | + kubectl kustomize k8s/overlays/template-app/ | \ + sed 's|imagePullPolicy: IfNotPresent|imagePullPolicy: Never|g' | \ + kubectl apply -f - + + - name: Wait for Redis to be ready + if: steps.check.outputs.exists == 'true' + run: | + kubectl wait --for=condition=ready pod -l app=template-app,component=redis --timeout=60s + + - name: Verify Redis Service is reachable + if: steps.check.outputs.exists == 'true' + run: | + kubectl run redis-test --image=redis:7-alpine --rm -i --restart=Never -- redis-cli -h template-app-redis ping + + - name: Verify all deployments are available + if: steps.check.outputs.exists == 'true' + run: | + kubectl wait --for=condition=available deployment -l app=template-app --timeout=120s || true + kubectl get pods -l app=template-app + kubectl get services -l app=template-app diff --git a/.streamlit/config.toml b/.streamlit/config.toml index e3d442ef..00c6abba 100644 --- a/.streamlit/config.toml +++ b/.streamlit/config.toml @@ -8,8 +8,6 @@ developmentMode = false address = "0.0.0.0" maxUploadSize = 200 #MB port = 8501 # should be same as configured in deployment repo -enableCORS = false -enableXsrfProtection = false [theme] diff --git a/clean-up-workspaces.py b/clean-up-workspaces.py index a780dbe9..cf4cf401 100644 --- a/clean-up-workspaces.py +++ b/clean-up-workspaces.py @@ -6,7 +6,7 @@ from datetime import datetime # Define the workspaces directory -workspaces_directory = Path("/workspaces-streamlit-template") +workspaces_directory = Path(os.environ.get("WORKSPACES_DIR", "/workspaces-streamlit-template")) # Get the current time in seconds current_time = time.time() diff --git a/k8s/base/cleanup-cronjob.yaml b/k8s/base/cleanup-cronjob.yaml new file mode 100644 index 00000000..86481876 --- /dev/null +++ b/k8s/base/cleanup-cronjob.yaml @@ -0,0 +1,45 @@ +apiVersion: batch/v1 +kind: CronJob +metadata: + name: workspace-cleanup + labels: + component: cleanup +spec: + schedule: "0 3 * * *" + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 3 + jobTemplate: + spec: + template: + metadata: + labels: + component: cleanup + spec: + restartPolicy: OnFailure + containers: + - name: cleanup + image: openms-streamlit + imagePullPolicy: IfNotPresent + command: ["/bin/bash", "-c"] + args: + - | + source /root/miniforge3/bin/activate streamlit-env + exec python clean-up-workspaces.py + env: + - name: WORKSPACES_DIR + value: "/workspaces-streamlit-template" + volumeMounts: + - name: workspaces + mountPath: /workspaces-streamlit-template + resources: + requests: + memory: "256Mi" + cpu: "100m" + limits: + memory: "512Mi" + cpu: "500m" + volumes: + - name: workspaces + persistentVolumeClaim: + claimName: workspaces-pvc diff --git a/k8s/base/configmap.yaml b/k8s/base/configmap.yaml new file mode 100644 index 00000000..c486e9c9 --- /dev/null +++ b/k8s/base/configmap.yaml @@ -0,0 +1,39 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: streamlit-config +data: + settings.json: | + { + "app-name": "OpenMS WebApp Template", + "online_deployment": true, + "enable_workspaces": true, + "workspaces_dir": "..", + "queue_settings": { + "default_timeout": 7200, + "result_ttl": 86400 + }, + "demo_workspaces": { + "enabled": true, + "source_dirs": ["example-data/workspaces"] + }, + "max_threads": { + "local": 4, + "online": 2 + }, + "analytics": { + "matomo": { + "enabled": true, + "url": "https://cdn.matomo.cloud/openms.matomo.cloud", + "tag": "yDGK8bfY" + }, + "google-analytics": { + "enabled": false, + "tag": "" + }, + "piwik-pro": { + "enabled": false, + "tag": "" + } + } + } diff --git a/k8s/base/ingress.yaml b/k8s/base/ingress.yaml new file mode 100644 index 00000000..f12b2b80 --- /dev/null +++ b/k8s/base/ingress.yaml @@ -0,0 +1,32 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: streamlit + annotations: + # WebSocket support (Streamlit requires WebSockets) + nginx.ingress.kubernetes.io/proxy-read-timeout: "86400" + nginx.ingress.kubernetes.io/proxy-send-timeout: "86400" + nginx.ingress.kubernetes.io/proxy-http-version: "1.1" + # Session affinity (user stays on same pod) + nginx.ingress.kubernetes.io/affinity: "cookie" + nginx.ingress.kubernetes.io/affinity-mode: "persistent" + nginx.ingress.kubernetes.io/session-cookie-name: "stroute" + nginx.ingress.kubernetes.io/session-cookie-path: "/" + nginx.ingress.kubernetes.io/session-cookie-samesite: "Lax" + # File upload (no limit) + nginx.ingress.kubernetes.io/proxy-body-size: "0" + # Disable buffering for streaming + nginx.ingress.kubernetes.io/proxy-buffering: "off" +spec: + ingressClassName: nginx + rules: + - host: streamlit.openms.example.de + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: streamlit + port: + number: 8501 diff --git a/k8s/base/kustomization.yaml b/k8s/base/kustomization.yaml new file mode 100644 index 00000000..c63122a4 --- /dev/null +++ b/k8s/base/kustomization.yaml @@ -0,0 +1,13 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - namespace.yaml + - configmap.yaml + - redis.yaml + - workspace-pvc.yaml + - streamlit-deployment.yaml + - streamlit-service.yaml + - rq-worker-deployment.yaml + - ingress.yaml + - cleanup-cronjob.yaml diff --git a/k8s/base/namespace.yaml b/k8s/base/namespace.yaml new file mode 100644 index 00000000..20842f63 --- /dev/null +++ b/k8s/base/namespace.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: openms + labels: + app.kubernetes.io/part-of: openms-streamlit diff --git a/k8s/base/redis.yaml b/k8s/base/redis.yaml new file mode 100644 index 00000000..b368a475 --- /dev/null +++ b/k8s/base/redis.yaml @@ -0,0 +1,52 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: redis + labels: + component: redis +spec: + replicas: 1 + selector: + matchLabels: + component: redis + template: + metadata: + labels: + component: redis + spec: + containers: + - name: redis + image: redis:7-alpine + ports: + - containerPort: 6379 + resources: + requests: + memory: "64Mi" + cpu: "50m" + limits: + memory: "256Mi" + cpu: "250m" + readinessProbe: + exec: + command: ["redis-cli", "ping"] + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + exec: + command: ["redis-cli", "ping"] + initialDelaySeconds: 15 + periodSeconds: 20 +--- +apiVersion: v1 +kind: Service +metadata: + name: redis + labels: + component: redis +spec: + type: ClusterIP + ports: + - port: 6379 + targetPort: 6379 + selector: + component: redis diff --git a/k8s/base/rq-worker-deployment.yaml b/k8s/base/rq-worker-deployment.yaml new file mode 100644 index 00000000..769ab3c3 --- /dev/null +++ b/k8s/base/rq-worker-deployment.yaml @@ -0,0 +1,49 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: rq-worker + labels: + component: rq-worker +spec: + replicas: 1 + selector: + matchLabels: + component: rq-worker + template: + metadata: + labels: + component: rq-worker + spec: + containers: + - name: rq-worker + image: openms-streamlit + imagePullPolicy: IfNotPresent + command: ["/bin/bash", "-c"] + args: + - | + source /root/miniforge3/bin/activate streamlit-env + exec rq worker openms-workflows --url $REDIS_URL + env: + - name: REDIS_URL + value: "redis://redis:6379/0" + volumeMounts: + - name: workspaces + mountPath: /workspaces-streamlit-template + - name: config + mountPath: /app/settings.json + subPath: settings.json + readOnly: true + resources: + requests: + memory: "4Gi" + cpu: "2" + limits: + memory: "32Gi" + cpu: "8" + volumes: + - name: workspaces + persistentVolumeClaim: + claimName: workspaces-pvc + - name: config + configMap: + name: streamlit-config diff --git a/k8s/base/streamlit-deployment.yaml b/k8s/base/streamlit-deployment.yaml new file mode 100644 index 00000000..75ac4f15 --- /dev/null +++ b/k8s/base/streamlit-deployment.yaml @@ -0,0 +1,63 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: streamlit + labels: + component: streamlit +spec: + replicas: 2 + selector: + matchLabels: + component: streamlit + template: + metadata: + labels: + component: streamlit + spec: + containers: + - name: streamlit + image: openms-streamlit + imagePullPolicy: IfNotPresent + command: ["/bin/bash", "-c"] + args: + - | + source /root/miniforge3/bin/activate streamlit-env + exec streamlit run app.py --server.address 0.0.0.0 + ports: + - containerPort: 8501 + env: + - name: REDIS_URL + value: "redis://redis:6379/0" + volumeMounts: + - name: workspaces + mountPath: /workspaces-streamlit-template + - name: config + mountPath: /app/settings.json + subPath: settings.json + readOnly: true + readinessProbe: + httpGet: + path: /_stcore/health + port: 8501 + initialDelaySeconds: 10 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /_stcore/health + port: 8501 + initialDelaySeconds: 30 + periodSeconds: 30 + resources: + requests: + memory: "4Gi" + cpu: "2" + limits: + memory: "32Gi" + cpu: "8" + volumes: + - name: workspaces + persistentVolumeClaim: + claimName: workspaces-pvc + - name: config + configMap: + name: streamlit-config diff --git a/k8s/base/streamlit-service.yaml b/k8s/base/streamlit-service.yaml new file mode 100644 index 00000000..90429e08 --- /dev/null +++ b/k8s/base/streamlit-service.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Service +metadata: + name: streamlit + labels: + component: streamlit +spec: + type: ClusterIP + ports: + - port: 8501 + targetPort: 8501 + selector: + component: streamlit diff --git a/k8s/base/workspace-pvc.yaml b/k8s/base/workspace-pvc.yaml new file mode 100644 index 00000000..fc735189 --- /dev/null +++ b/k8s/base/workspace-pvc.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: workspaces-pvc +spec: + accessModes: + - ReadWriteMany + resources: + requests: + storage: 100Gi diff --git a/k8s/overlays/template-app/kustomization.yaml b/k8s/overlays/template-app/kustomization.yaml new file mode 100644 index 00000000..7f63fa25 --- /dev/null +++ b/k8s/overlays/template-app/kustomization.yaml @@ -0,0 +1,24 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - ../../base + +namePrefix: template-app- + +commonLabels: + app: template-app + +images: + - name: openms-streamlit + newName: ghcr.io/openms/streamlit-template + newTag: main + +patches: + - target: + kind: Ingress + name: streamlit + patch: | + - op: replace + path: /spec/rules/0/host + value: template.openms.example.de