From 0dc4909f545c52e78ffc7603d8e58c1c5e92c9be Mon Sep 17 00:00:00 2001 From: David Canas Date: Thu, 4 Dec 2025 15:08:02 -0800 Subject: [PATCH 01/13] First pass, based off of suggestions from AI. --- docker/Dockerfile.demo | 43 ----- k8s/Chart.lock | 9 - k8s/Chart.yaml | 13 -- k8s/Makefile | 7 - k8s/charts/cloudsql-proxy-2.0.0.tgz | Bin 3650 -> 0 bytes k8s/charts/redis-12.1.1.tgz | Bin 65202 -> 0 bytes k8s/create-cloudsql-database.sh | 25 --- k8s/create-cloudsql-proxy.sh | 21 --- k8s/create-multiplexing-reverse-proxy-lb.sh | 38 ----- k8s/create-postgres-user-and-db.exp | 62 ------- k8s/encrypt-env-var.sh | 17 -- k8s/helm-deploy.sh | 38 ----- k8s/templates/_helpers.tpl | 134 --------------- k8s/templates/garbage-collect-cronjob.yaml | 78 --------- k8s/templates/ingress.yaml | 21 --- k8s/templates/job-template.yaml | 73 -------- .../mark-incomplete-mgmt-command-cronjob.yaml | 78 --------- k8s/templates/production-ingress.yaml | 23 --- ...set-storage-used-mgmt-command-cronjob.yaml | 78 --------- k8s/templates/studio-deployment.yaml | 157 ------------------ k8s/templates/studio-secrets.yaml | 17 -- k8s/templates/studio-service.yaml | 13 -- k8s/values.yaml | 70 -------- 23 files changed, 1015 deletions(-) delete mode 100644 docker/Dockerfile.demo delete mode 100644 k8s/Chart.lock delete mode 100644 k8s/Chart.yaml delete mode 100644 k8s/Makefile delete mode 100644 k8s/charts/cloudsql-proxy-2.0.0.tgz delete mode 100644 k8s/charts/redis-12.1.1.tgz delete mode 100755 k8s/create-cloudsql-database.sh delete mode 100755 k8s/create-cloudsql-proxy.sh delete mode 100755 k8s/create-multiplexing-reverse-proxy-lb.sh delete mode 100755 k8s/create-postgres-user-and-db.exp delete mode 100755 k8s/encrypt-env-var.sh delete mode 100755 k8s/helm-deploy.sh delete mode 100644 k8s/templates/_helpers.tpl delete mode 100644 k8s/templates/garbage-collect-cronjob.yaml delete mode 100644 k8s/templates/ingress.yaml delete mode 100644 k8s/templates/job-template.yaml delete mode 100644 k8s/templates/mark-incomplete-mgmt-command-cronjob.yaml delete mode 100644 k8s/templates/production-ingress.yaml delete mode 100644 k8s/templates/set-storage-used-mgmt-command-cronjob.yaml delete mode 100644 k8s/templates/studio-deployment.yaml delete mode 100644 k8s/templates/studio-secrets.yaml delete mode 100644 k8s/templates/studio-service.yaml delete mode 100644 k8s/values.yaml diff --git a/docker/Dockerfile.demo b/docker/Dockerfile.demo deleted file mode 100644 index 2ae03758b6..0000000000 --- a/docker/Dockerfile.demo +++ /dev/null @@ -1,43 +0,0 @@ -FROM python:3.10-slim-bookworm - -# Set the timezone -RUN ln -fs /usr/share/zoneinfo/America/Los_Angeles /etc/localtime - -ENV DEBIAN_FRONTEND noninteractive -# Default Python file.open file encoding to UTF-8 instead of ASCII, workaround for le-utils setup.py issue -ENV LANG C.UTF-8 -RUN apt-get update && apt-get -y install python3-pip python3-dev gcc libpq-dev make git curl libjpeg-dev libssl-dev libffi-dev ffmpeg - -# Pin, Download and install node 18.x -RUN apt-get update \ - && apt-get install -y ca-certificates curl gnupg \ - && mkdir -p /etc/apt/keyrings \ - && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ - && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list \ - && echo "Package: nodejs" >> /etc/apt/preferences.d/preferences \ - && echo "Pin: origin deb.nodesource.com" >> /etc/apt/preferences.d/preferences \ - && echo "Pin-Priority: 1001" >> /etc/apt/preferences.d/preferences\ - && apt-get update \ - && apt-get install -y nodejs - -RUN corepack enable pnpm -COPY ./package.json . -COPY ./pnpm-lock.yaml . -RUN pnpm install - -COPY requirements.txt . - -RUN pip install --upgrade pip -RUN pip install --ignore-installed -r requirements.txt - -COPY . /contentcuration/ -WORKDIR /contentcuration - -# generate the node bundles -RUN mkdir -p contentcuration/static/js/bundles -RUN ln -s /node_modules /contentcuration/node_modules -RUN pnpm run build - -EXPOSE 8000 - -ENTRYPOINT ["make", "altprodserver"] diff --git a/k8s/Chart.lock b/k8s/Chart.lock deleted file mode 100644 index 3710092dd1..0000000000 --- a/k8s/Chart.lock +++ /dev/null @@ -1,9 +0,0 @@ -dependencies: -- name: cloudsql-proxy - repository: https://storage.googleapis.com/t3n-helm-charts - version: 2.0.0 -- name: redis - repository: https://charts.bitnami.com/bitnami - version: 12.1.1 -digest: sha256:8e1cf67168047aa098bae2eca9ddae32bb68ba68ca80668492760037fe8ce4ef -generated: "2020-11-25T04:47:31.298097908-08:00" diff --git a/k8s/Chart.yaml b/k8s/Chart.yaml deleted file mode 100644 index 0395068cfc..0000000000 --- a/k8s/Chart.yaml +++ /dev/null @@ -1,13 +0,0 @@ -apiVersion: v2 -description: Kolibri Studio, the Content Curation tool for Kolibri! -name: studio -version: 0.3.0 -dependencies: - - name: cloudsql-proxy - version: 2.0.0 - repository: https://storage.googleapis.com/t3n-helm-charts - enabled: true - - name: redis - version: 12.1.1 - repository: https://charts.bitnami.com/bitnami - enabled: true diff --git a/k8s/Makefile b/k8s/Makefile deleted file mode 100644 index c81ba867d9..0000000000 --- a/k8s/Makefile +++ /dev/null @@ -1,7 +0,0 @@ -DEPLOYMENT := `kubectl get deploy -l app=master-studio -o custom-columns=NAME:.metadata.name --no-headers` -POD := `kubectl get pods -o=custom-columns=NAME:.metadata.name --field-selector=status.phase=Running --no-headers -l app=master-studio | head -n 1` - -master-shell: - kubectl rollout status deployment/$(DEPLOYMENT) - echo Running bash inside $(POD) - kubectl exec -it $(POD) bash diff --git a/k8s/charts/cloudsql-proxy-2.0.0.tgz b/k8s/charts/cloudsql-proxy-2.0.0.tgz deleted file mode 100644 index 29a9046b19339b5238920ee0a5d10e186be26c5e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3650 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 Ud8G1x0RRC1{~6e*I{;Jw08HXQ!~g&Q diff --git a/k8s/charts/redis-12.1.1.tgz b/k8s/charts/redis-12.1.1.tgz deleted file mode 100644 index b6735330f3901011fa135acd59ef0b310b2807e1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 65202 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 " -send -- "CREATE DATABASE $db; CREATE USER $user WITH ENCRYPTED PASSWORD '$user'; GRANT ALL PRIVILEGES ON DATABASE $db TO $user;" -send -- "\n" -expect -exact "postgres=>" -send -- "" -expect eof diff --git a/k8s/encrypt-env-var.sh b/k8s/encrypt-env-var.sh deleted file mode 100755 index c20e0774b0..0000000000 --- a/k8s/encrypt-env-var.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/bin/sh - -# How to use: -# pipe a secret string into this script. -# This will output instructions on what you -# then need to add into your cloudbuild.yaml file. - -KEYRING=$1 -KEY=$2 - -gcloud kms encrypt \ - --plaintext-file=- \ - --ciphertext-file=- \ - --location=global \ - --keyring=$KEYRING \ - --key=$KEY \ - | base64 diff --git a/k8s/helm-deploy.sh b/k8s/helm-deploy.sh deleted file mode 100755 index 4f512559fd..0000000000 --- a/k8s/helm-deploy.sh +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -RELEASE_NAME=$1 -STUDIO_APP_IMAGE_NAME=$2 -STUDIO_NGINX_IMAGE_NAME=$3 -STUDIO_BUCKET_NAME=$4 -COMMIT_SHA=$5 -PROJECT_ID=$6 -DATABASE_INSTANCE_NAME=$7 -DATABASE_REGION=$8 - -K8S_DIR=$(dirname $0) - -function get_secret { - gcloud secrets versions access --secret=$1 latest -} - -helm upgrade --install \ - --namespace $RELEASE_NAME --create-namespace \ - --set studioApp.postmarkApiKey=$(get_secret postmark-api-key) \ - --set studioApp.releaseCommit=$COMMIT_SHA \ - --set studioApp.imageName=$STUDIO_APP_IMAGE_NAME \ - --set studioNginx.imageName=$STUDIO_NGINX_IMAGE_NAME \ - --set studioApp.gcs.bucketName=$STUDIO_BUCKET_NAME \ - --set studioApp.gcs.writerServiceAccountKeyBase64Encoded=$(get_secret studio-gcs-service-account-key | base64 -w 0) \ - --set settings=contentcuration.production_settings \ - --set sentry.dsnKey=$(get_secret sentry-dsn-key) \ - --set redis.password=$(get_secret redis-password) \ - --set cloudsql-proxy.credentials.username=$(get_secret postgres-username) \ - --set cloudsql-proxy.credentials.password=$(get_secret postgres-password) \ - --set cloudsql-proxy.credentials.dbname=$(get_secret postgres-dbname) \ - --set cloudsql-proxy.cloudsql.instances[0].instance=$DATABASE_INSTANCE_NAME \ - --set cloudsql-proxy.cloudsql.instances[0].project=$PROJECT_ID \ - --set cloudsql-proxy.cloudsql.instances[0].region=$DATABASE_REGION \ - --set cloudsql-proxy.cloudsql.instances[0].port=5432 \ - $RELEASE_NAME $K8S_DIR diff --git a/k8s/templates/_helpers.tpl b/k8s/templates/_helpers.tpl deleted file mode 100644 index 1098c07dc7..0000000000 --- a/k8s/templates/_helpers.tpl +++ /dev/null @@ -1,134 +0,0 @@ -{{/* vim: set filetype=mustache: */}} -{{/* -Expand the name of the chart. -*/}} -{{- define "studio.name" -}} -{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} -{{- end -}} - -{{/* -Create a default fully qualified app name. -We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). -If release name contains chart name it will be used as a full name. -*/}} -{{- define "studio.fullname" -}} -{{- if .Values.fullnameOverride -}} -{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} -{{- else -}} -{{- $name := default .Chart.Name .Values.nameOverride -}} -{{- if contains $name .Release.Name -}} -{{- .Release.Name | trunc 63 | trimSuffix "-" -}} -{{- else -}} -{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} -{{- end -}} -{{- end -}} -{{- end -}} - -{{- define "cloudsql-proxy.fullname" -}} -{{- $name := .Release.Name -}} -{{- printf "%s-%s" $name "cloudsql-proxy" | trunc 63 | trimSuffix "-" -}} -{{- end -}} -{{- define "redis.fullname" -}} -{{- $name := .Release.Name -}} -{{- printf "%s-%s" $name "redis" | trunc 63 | trimSuffix "-" -}} -{{- end -}} - -{{- define "minio.url" -}} -{{- printf "http://%s-%s:%v" .Release.Name "minio" .Values.minio.service.port -}} -{{- end -}} - - -{{/* -Return the appropriate apiVersion for networkpolicy. -*/}} -{{- define "studio.networkPolicy.apiVersion" -}} -{{- if semverCompare ">=1.4-0, <1.7-0" .Capabilities.KubeVersion.GitVersion -}} -"extensions/v1" -{{- else if semverCompare "^1.7-0" .Capabilities.KubeVersion.GitVersion -}} -"networking.k8s.io/v1" -{{- end -}} -{{- end -}} - -{{/* -Create chart name and version as used by the chart label. -*/}} -{{- define "studio.chart" -}} -{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} -{{- end -}} - -{{/* -Generate chart secret name -*/}} -{{- define "studio.secretName" -}} -{{ default (include "studio.fullname" .) .Values.existingSecret }} -{{- end -}} - -{{/* -Generate the shared environment variables between studio app and workers -*/}} -{{- define "studio.sharedEnvs" -}} -- name: DJANGO_SETTINGS_MODULE - value: {{ .Values.settings }} -- name: DJANGO_LOG_FILE - value: /var/log/django.log -- name: MPLBACKEND - value: PS -- name: STUDIO_BETA_MODE - value: "yes" -- name: RUN_MODE - value: k8s -- name: DATA_DB_NAME - valueFrom: - secretKeyRef: - key: postgres-database - name: {{ template "studio.fullname" . }} -- name: DATA_DB_PORT - value: "5432" -- name: DATA_DB_USER - valueFrom: - secretKeyRef: - key: postgres-user - name: {{ template "studio.fullname" . }} -- name: DATA_DB_PASS - valueFrom: - secretKeyRef: - key: postgres-password - name: {{ template "studio.fullname" . }} -- name: CELERY_TIMEZONE - value: America/Los_Angeles -- name: CELERY_REDIS_DB - value: "0" -- name: CELERY_BROKER_ENDPOINT - value: {{ template "redis.fullname" . }}-master -- name: CELERY_RESULT_BACKEND_ENDPOINT - value: {{ template "redis.fullname" . }}-master -- name: CELERY_REDIS_PASSWORD - valueFrom: - secretKeyRef: - key: redis-password - name: {{ template "studio.fullname" . }} -- name: AWS_S3_ENDPOINT_URL - value: https://storage.googleapis.com -- name: RELEASE_COMMIT_SHA - value: {{ .Values.studioApp.releaseCommit | default "" }} -- name: BRANCH_ENVIRONMENT - value: {{ .Release.Name }} -- name: SENTRY_DSN_KEY - valueFrom: - secretKeyRef: - key: sentry-dsn-key - name: {{ template "studio.fullname" . }} - optional: true -- name: AWS_BUCKET_NAME - value: {{ .Values.studioApp.gcs.bucketName }} -- name: EMAIL_CREDENTIALS_POSTMARK_API_KEY - {{ if .Values.studioApp.postmarkApiKey }} - valueFrom: - secretKeyRef: - key: postmark-api-key - name: {{ template "studio.fullname" . }} - {{ else }} - value: "" - {{ end }} - -{{- end -}} diff --git a/k8s/templates/garbage-collect-cronjob.yaml b/k8s/templates/garbage-collect-cronjob.yaml deleted file mode 100644 index 4395732541..0000000000 --- a/k8s/templates/garbage-collect-cronjob.yaml +++ /dev/null @@ -1,78 +0,0 @@ ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: {{ template "studio.fullname" . }}-garbage-collect-job-config - labels: - tier: job - app: {{ template "studio.fullname" . }} - chart: {{ .Chart.Name }} - release: {{ .Release.Name }} -data: - DJANGO_LOG_FILE: /var/log/django.log - DATA_DB_HOST: {{ template "cloudsql-proxy.fullname" . }} - DATA_DB_PORT: "5432" - MPLBACKEND: PS - RUN_MODE: k8s - RELEASE_COMMIT_SHA: {{ .Values.studioApp.releaseCommit | default "" }} - BRANCH_ENVIRONMENT: {{ .Release.Name }} - AWS_BUCKET_NAME: {{ .Values.studioApp.gcs.bucketName }} ---- -apiVersion: v1 -kind: Secret -metadata: - name: {{ template "studio.fullname" . }}-garbage-collect-job-secret - labels: - app: {{ template "studio.fullname" . }} - chart: {{ .Chart.Name }} - release: {{ .Release.Name }} -type: Opaque -data: - DATA_DB_USER: {{ index .Values "cloudsql-proxy" "credentials" "username" | b64enc }} - DATA_DB_PASS: {{ index .Values "cloudsql-proxy" "credentials" "password" | b64enc }} - DATA_DB_NAME: {{ index .Values "cloudsql-proxy" "credentials" "dbname" | b64enc }} - SENTRY_DSN_KEY: {{ .Values.sentry.dsnKey | b64enc }} ---- -apiVersion: batch/v1beta1 -kind: CronJob -metadata: - name: {{ template "studio.fullname" . }}-garbage-collect-cronjob - labels: - tier: job - chart: {{ .Chart.Name }} - release: {{ .Release.Name }} -spec: - schedule: "@midnight" - jobTemplate: - spec: - template: - spec: - restartPolicy: OnFailure - containers: - - name: app - image: {{ .Values.studioApp.imageName }} - command: - - python - - contentcuration/manage.py - - garbage_collect - env: - - name: DJANGO_SETTINGS_MODULE - value: contentcuration.production_settings - envFrom: - - configMapRef: - name: {{ template "studio.fullname" . }}-garbage-collect-job-config - - secretRef: - name: {{ template "studio.fullname" . }}-garbage-collect-job-secret - resources: - requests: - cpu: 0.5 - memory: 1Gi - affinity: - nodeAffinity: - requiredDuringSchedulingIgnoredDuringExecution: - nodeSelectorTerms: - - matchExpressions: - - key: full-gcp-access-scope - operator: In - values: - - "true" diff --git a/k8s/templates/ingress.yaml b/k8s/templates/ingress.yaml deleted file mode 100644 index c2e199dc7a..0000000000 --- a/k8s/templates/ingress.yaml +++ /dev/null @@ -1,21 +0,0 @@ ---- -apiVersion: networking.k8s.io/v1beta1 -kind: Ingress -metadata: - name: {{ template "studio.fullname" . }} - labels: - app: {{ template "studio.fullname" . }} - tier: ingress - annotations: - ingress.kubernetes.io/rewrite-target: / - kubernetes.io/ingress.class: "nginx" - ingressClassName: "nginx" - -spec: - rules: - - host: {{.Release.Name}}.studio.cd.learningequality.org - http: - paths: - - backend: - serviceName: {{ template "studio.fullname" . }}-app - servicePort: 80 diff --git a/k8s/templates/job-template.yaml b/k8s/templates/job-template.yaml deleted file mode 100644 index 856f27371c..0000000000 --- a/k8s/templates/job-template.yaml +++ /dev/null @@ -1,73 +0,0 @@ ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: {{ template "studio.fullname" . }}-db-migrate-config - labels: - app: {{ template "studio.fullname" . }} - annotations: - "helm.sh/hook": pre-install,pre-upgrade - "helm.sh/hook-delete-policy": before-hook-creation -data: - DJANGO_SETTINGS_MODULE: {{ .Values.settings }} - DJANGO_LOG_FILE: /var/log/django.log - DATA_DB_HOST: {{ template "cloudsql-proxy.fullname" . }} - DATA_DB_PORT: "5432" - MPLBACKEND: PS - STUDIO_BETA_MODE: "yes" - RUN_MODE: k8s - RELEASE_COMMIT_SHA: {{ .Values.studioApp.releaseCommit | default "" }} - BRANCH_ENVIRONMENT: {{ .Release.Name }} ---- -apiVersion: v1 -kind: Secret -metadata: - name: {{ template "studio.fullname" . }}-db-migrate-secrets - labels: - app: studio - chart: {{ .Chart.Name }} - release: {{ .Release.Name }} - annotations: - "helm.sh/hook": pre-install,pre-upgrade - "helm.sh/hook-delete-policy": before-hook-creation -type: Opaque -data: - DATA_DB_USER: {{ index .Values "cloudsql-proxy" "credentials" "username" | b64enc }} - DATA_DB_PASS: {{ index .Values "cloudsql-proxy" "credentials" "password" | b64enc }} - DATA_DB_NAME: {{ index .Values "cloudsql-proxy" "credentials" "dbname" | b64enc }} - SENTRY_DSN_KEY: {{ .Values.sentry.dsnKey | b64enc }} ---- -apiVersion: batch/v1 -kind: Job -metadata: - name: {{ template "studio.fullname" . }}-migrate-job - labels: - app: {{ template "studio.fullname" . }} - annotations: - "helm.sh/hook": post-install,pre-upgrade - "helm.sh/hook-delete-policy": before-hook-creation -spec: - template: - spec: - restartPolicy: OnFailure - containers: - - name: dbmigrate - image: {{ .Values.studioApp.imageName }} - command: - - make - - migrate - envFrom: - - configMapRef: - name: {{ template "studio.fullname" . }}-db-migrate-config - - secretRef: - name: {{ template "studio.fullname" . }}-db-migrate-secrets - env: - - name: DJANGO_SETTINGS_MODULE - value: contentcuration.migration_production_settings - resources: - requests: - cpu: 1 - memory: 2Gi - limits: - cpu: 1 - memory: 2Gi diff --git a/k8s/templates/mark-incomplete-mgmt-command-cronjob.yaml b/k8s/templates/mark-incomplete-mgmt-command-cronjob.yaml deleted file mode 100644 index ad36b8b0e4..0000000000 --- a/k8s/templates/mark-incomplete-mgmt-command-cronjob.yaml +++ /dev/null @@ -1,78 +0,0 @@ ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: {{ template "studio.fullname" . }}-mark-incomplete-job-config - labels: - tier: job - app: {{ template "studio.fullname" . }} - chart: {{ .Chart.Name }} - release: {{ .Release.Name }} -data: - DJANGO_LOG_FILE: /var/log/django.log - DATA_DB_HOST: {{ template "cloudsql-proxy.fullname" . }} - DATA_DB_PORT: "5432" - MPLBACKEND: PS - RUN_MODE: k8s - RELEASE_COMMIT_SHA: {{ .Values.studioApp.releaseCommit | default "" }} - BRANCH_ENVIRONMENT: {{ .Release.Name }} - AWS_BUCKET_NAME: {{ .Values.studioApp.gcs.bucketName }} ---- -apiVersion: v1 -kind: Secret -metadata: - name: {{ template "studio.fullname" . }}-mark-incomplete-job-secrets - labels: - app: {{ template "studio.fullname" . }} - chart: {{ .Chart.Name }} - release: {{ .Release.Name }} -type: Opaque -data: - DATA_DB_USER: {{ index .Values "cloudsql-proxy" "credentials" "username" | b64enc }} - DATA_DB_PASS: {{ index .Values "cloudsql-proxy" "credentials" "password" | b64enc }} - DATA_DB_NAME: {{ index .Values "cloudsql-proxy" "credentials" "dbname" | b64enc }} - SENTRY_DSN_KEY: {{ .Values.sentry.dsnKey | b64enc }} ---- -apiVersion: batch/v1beta1 -kind: CronJob -metadata: - name: mark-incomplete-cronjob - labels: - tier: job - chart: {{ .Chart.Name }} - release: {{ .Release.Name }} -spec: - schedule: "00 12 10 */36 *" - jobTemplate: - spec: - template: - spec: - restartPolicy: OnFailure - containers: - - name: app - image: {{ .Values.studioApp.imageName }} - command: - - python - - contentcuration/manage.py - - mark_incomplete - env: - - name: DJANGO_SETTINGS_MODULE - value: contentcuration.production_settings - envFrom: - - configMapRef: - name: {{ template "studio.fullname" . }}-mark-incomplete-job-config - - secretRef: - name: {{ template "studio.fullname" . }}-mark-incomplete-job-secrets - resources: - requests: - cpu: 0.5 - memory: 1Gi - affinity: - nodeAffinity: - requiredDuringSchedulingIgnoredDuringExecution: - nodeSelectorTerms: - - matchExpressions: - - key: full-gcp-access-scope - operator: In - values: - - "true" diff --git a/k8s/templates/production-ingress.yaml b/k8s/templates/production-ingress.yaml deleted file mode 100644 index 68f10481ba..0000000000 --- a/k8s/templates/production-ingress.yaml +++ /dev/null @@ -1,23 +0,0 @@ -{{- if .Values.productionIngress -}} ---- -apiVersion: networking.k8s.io/v1beta1 -kind: Ingress -metadata: - name: {{ template "studio.fullname" . }}-production - labels: - app: {{ template "studio.fullname" . }} - tier: ingress - type: production - annotations: - ingress.kubernetes.io/rewrite-target: / - kubernetes.io/ingress.class: "nginx" - ingressClassName: "nginx" -spec: - rules: - - host: {{.Release.Name}}.studio.learningequality.org - http: - paths: - - backend: - serviceName: {{ template "studio.fullname" . }}-app - servicePort: 80 -{{- end }} diff --git a/k8s/templates/set-storage-used-mgmt-command-cronjob.yaml b/k8s/templates/set-storage-used-mgmt-command-cronjob.yaml deleted file mode 100644 index cd30ba6f2f..0000000000 --- a/k8s/templates/set-storage-used-mgmt-command-cronjob.yaml +++ /dev/null @@ -1,78 +0,0 @@ ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: {{ template "studio.fullname" . }}-set-storage-used-job-config - labels: - tier: job - app: {{ template "studio.fullname" . }} - chart: {{ .Chart.Name }} - release: {{ .Release.Name }} -data: - DJANGO_LOG_FILE: /var/log/django.log - DATA_DB_HOST: {{ template "cloudsql-proxy.fullname" . }} - DATA_DB_PORT: "5432" - MPLBACKEND: PS - RUN_MODE: k8s - RELEASE_COMMIT_SHA: {{ .Values.studioApp.releaseCommit | default "" }} - BRANCH_ENVIRONMENT: {{ .Release.Name }} - AWS_BUCKET_NAME: {{ .Values.studioApp.gcs.bucketName }} ---- -apiVersion: v1 -kind: Secret -metadata: - name: {{ template "studio.fullname" . }}-set-storage-used-job-secrets - labels: - app: {{ template "studio.fullname" . }} - chart: {{ .Chart.Name }} - release: {{ .Release.Name }} -type: Opaque -data: - DATA_DB_USER: {{ index .Values "cloudsql-proxy" "credentials" "username" | b64enc }} - DATA_DB_PASS: {{ index .Values "cloudsql-proxy" "credentials" "password" | b64enc }} - DATA_DB_NAME: {{ index .Values "cloudsql-proxy" "credentials" "dbname" | b64enc }} - SENTRY_DSN_KEY: {{ .Values.sentry.dsnKey | b64enc }} ---- -apiVersion: batch/v1beta1 -kind: CronJob -metadata: - name: set-storage-used-cronjob - labels: - tier: job - chart: {{ .Chart.Name }} - release: {{ .Release.Name }} -spec: - schedule: "@midnight" - jobTemplate: - spec: - template: - spec: - restartPolicy: OnFailure - containers: - - name: app - image: {{ .Values.studioApp.imageName }} - command: - - python - - contentcuration/manage.py - - set_storage_used - env: - - name: DJANGO_SETTINGS_MODULE - value: contentcuration.production_settings - envFrom: - - configMapRef: - name: {{ template "studio.fullname" . }}-set-storage-used-job-config - - secretRef: - name: {{ template "studio.fullname" . }}-set-storage-used-job-secrets - resources: - requests: - cpu: 0.5 - memory: 1Gi - affinity: - nodeAffinity: - requiredDuringSchedulingIgnoredDuringExecution: - nodeSelectorTerms: - - matchExpressions: - - key: full-gcp-access-scope - operator: In - values: - - "true" diff --git a/k8s/templates/studio-deployment.yaml b/k8s/templates/studio-deployment.yaml deleted file mode 100644 index f6a74f36fa..0000000000 --- a/k8s/templates/studio-deployment.yaml +++ /dev/null @@ -1,157 +0,0 @@ ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: {{ template "studio.fullname" . }} - labels: - tier: app - app: {{ template "studio.fullname" . }} -spec: - replicas: {{ .Values.studioApp.replicas }} - selector: - matchLabels: - app: {{ template "studio.fullname" . }} - tier: frontend - template: - metadata: - annotations: - checksum: {{ include (print $.Template.BasePath "/job-template.yaml") . | sha256sum }} - labels: - app: {{ template "studio.fullname" . }} - tier: frontend - spec: - initContainers: - - name: collectstatic - image: {{ .Values.studioApp.imageName }} - workingDir: /contentcuration/ - command: - - make - args: - - collectstatic - env: - - name: DJANGO_SETTINGS_MODULE - value: contentcuration.collectstatic_settings - - name: STATICFILES_DIR - value: /app/contentworkshop_static/ - volumeMounts: - - mountPath: /app/contentworkshop_static/ - name: staticfiles - containers: - - name: app - image: {{ .Values.studioApp.imageName }} - workingDir: /contentcuration/contentcuration/ - command: - - gunicorn - args: - - contentcuration.wsgi:application - - --timeout=4000 - - --workers=2 - - --bind=0.0.0.0:{{ .Values.studioApp.appPort }} - - --pid=/tmp/contentcuration.pid - env: {{ include "studio.sharedEnvs" . | nindent 8 }} - - name: SEND_USER_ACTIVATION_NOTIFICATION_EMAIL - value: "true" - - name: DATA_DB_HOST - value: {{ template "cloudsql-proxy.fullname" . }} - - name: GOOGLE_CLOUD_STORAGE_SERVICE_ACCOUNT_CREDENTIALS - value: /var/secrets/gcs-writer-service-account-key.json - ports: - - containerPort: {{ .Values.studioApp.appPort }} - readinessProbe: - httpGet: - path: /healthz - port: {{ .Values.studioApp.appPort }} - initialDelaySeconds: 5 - periodSeconds: 2 - failureThreshold: 3 - resources: - requests: - cpu: 0.5 - memory: 2Gi - limits: - memory: 2Gi - volumeMounts: - - mountPath: /var/secrets - name: gcs-writer-service-account-key - readOnly: true - - name: nginx-proxy - image: {{ .Values.studioNginx.imageName }} - env: - - name: AWS_S3_ENDPOINT_URL - value: https://storage.googleapis.com - - name: AWS_BUCKET_NAME - value: {{ .Values.studioApp.gcs.bucketName }} - ports: - - containerPort: {{ .Values.studioNginx.port }} - volumeMounts: - - mountPath: /app/contentworkshop_static/ - name: staticfiles - resources: - requests: - cpu: 0.2 - memory: 256Mi - limits: - memory: 512Mi - volumes: - - emptyDir: {} - name: staticfiles - - name: gcs-writer-service-account-key - secret: - secretName: {{ template "studio.fullname" . }} - items: - - key: gcs-writer-service-account-key - path: gcs-writer-service-account-key.json - affinity: - nodeAffinity: - requiredDuringSchedulingIgnoredDuringExecution: - nodeSelectorTerms: - - matchExpressions: - - key: full-gcp-access-scope - operator: In - values: - - "true" ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: {{template "studio.fullname" . }}-workers -spec: - replicas: {{ .Values.studioWorkers.replicas }} - selector: - matchLabels: - app: {{ template "studio.fullname" . }}-workers - tier: workers - template: - metadata: - labels: - app: {{ template "studio.fullname" . }}-workers - tier: workers - spec: - containers: - - name: worker - image: {{ .Values.studioApp.imageName }} - command: - - make - {{- if not .Values.productionIngress }} - - setup - {{- end }} - - prodceleryworkers - env: {{ include "studio.sharedEnvs" . | nindent 8 }} - - name: DATA_DB_HOST - value: {{ template "cloudsql-proxy.fullname" . }} - resources: - requests: - cpu: 0.5 - memory: 2Gi - limits: - cpu: 2 - memory: 8Gi - affinity: - nodeAffinity: - requiredDuringSchedulingIgnoredDuringExecution: - nodeSelectorTerms: - - matchExpressions: - - key: full-gcp-access-scope - operator: In - values: - - "true" diff --git a/k8s/templates/studio-secrets.yaml b/k8s/templates/studio-secrets.yaml deleted file mode 100644 index 51f2589a3e..0000000000 --- a/k8s/templates/studio-secrets.yaml +++ /dev/null @@ -1,17 +0,0 @@ -apiVersion: v1 -kind: Secret -metadata: - name: {{ template "studio.fullname" . }} - labels: - app: studio - chart: {{ .Chart.Name }} - release: {{ .Release.Name }} -type: Opaque -data: - postmark-api-key: {{ .Values.studioApp.postmarkApiKey | default "" | b64enc }} - redis-password: {{ .Values.redis.password | default "" | b64enc }} - postgres-user: {{ index .Values "cloudsql-proxy" "credentials" "username" | b64enc }} - postgres-password: {{ index .Values "cloudsql-proxy" "credentials" "password" | b64enc }} - postgres-database: {{ index .Values "cloudsql-proxy" "credentials" "dbname" | b64enc }} - sentry-dsn-key: {{ .Values.sentry.dsnKey | b64enc }} - gcs-writer-service-account-key: {{ .Values.studioApp.gcs.writerServiceAccountKeyBase64Encoded }} diff --git a/k8s/templates/studio-service.yaml b/k8s/templates/studio-service.yaml deleted file mode 100644 index 8f70e6f54d..0000000000 --- a/k8s/templates/studio-service.yaml +++ /dev/null @@ -1,13 +0,0 @@ ---- -apiVersion: v1 -kind: Service -metadata: - name: {{ template "studio.fullname" . }}-app -spec: - ports: - - port: 80 - targetPort: {{ .Values.studioNginx.port }} - selector: - app: {{ template "studio.fullname" . }} - tier: frontend - type: NodePort diff --git a/k8s/values.yaml b/k8s/values.yaml deleted file mode 100644 index 11db6c1559..0000000000 --- a/k8s/values.yaml +++ /dev/null @@ -1,70 +0,0 @@ ---- -# A set of values that are meant to be used for a production setup. -# This includes: -# - an external Postgres, GCS Storage, and external Redis -# - real email sending -# - studio production settings -# -# Note that the secrets will have to be filled up by the caller -# through helm upgrade --set. See REPLACEME placeholders -# for values that need to be set. - -settings: contentcuration.sandbox_settings - -productionIngress: true - -studioApp: - imageName: "REPLACEME" - postmarkApiKey: "REPLACEME" - releaseCommit: "" - replicas: 5 - appPort: 8081 - gcs: - bucketName: develop-studio-content - writerServiceAccountKeyBase64Encoded: "REPLACEME" - pgbouncer: - replicas: 3 - pool_size: 10 - reserve_pool_size: 10 - -studioNginx: - imageName: "REPLACEME" - port: 8080 - -sentry: - dsnKey: "" - -cloudsql-proxy: - enabled: true - cloudsql: - instances: - - instance: "REPLACEME" - project: "REPLACEME" - region: "REPLACEME" - port: 5432 - credentials: - username: "" - password: "" - dbname: "" - affinity: - nodeAffinity: - requiredDuringSchedulingIgnoredDuringExecution: - nodeSelectorTerms: - - matchExpressions: - - key: full-gcp-access-scope - operator: In - values: - - "true" - -redis: - enabled: true - -studioWorkers: - replicas: 5 - - -studioProber: - imageName: "REPLACEME" - loginProberUsername: "REPLACEME" - loginProberPassword: "REPLACEME" - port: 9313 From ff6209a5d52355500254f0c7cc6a54411531b083 Mon Sep 17 00:00:00 2001 From: David Canas Date: Thu, 4 Dec 2025 15:09:13 -0800 Subject: [PATCH 02/13] Removing vestigial git module definition --- .gitmodules | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 .gitmodules diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index fc49a89d8a..0000000000 --- a/.gitmodules +++ /dev/null @@ -1,6 +0,0 @@ -[submodule "kolibri"] - path = kolibri - url = https://github.com/learningequality/kolibri.git -[submodule "contentcuration/kolibri"] - path = contentcuration/kolibri - url = https://github.com/learningequality/kolibri.git From c14ec2bf1d25936b84b89adef1b94300c94db211 Mon Sep 17 00:00:00 2001 From: David Canas Date: Thu, 4 Dec 2025 15:11:56 -0800 Subject: [PATCH 03/13] Removing symlink to prod dockerfile. Does not appear to be used anywhere. --- docker/Dockerfile.prod | 1 - 1 file changed, 1 deletion(-) delete mode 120000 docker/Dockerfile.prod diff --git a/docker/Dockerfile.prod b/docker/Dockerfile.prod deleted file mode 120000 index 11036b6d36..0000000000 --- a/docker/Dockerfile.prod +++ /dev/null @@ -1 +0,0 @@ -../k8s/images/app/Dockerfile \ No newline at end of file From 840dcad28810719c3e3c31ea122e5085ae2b54ba Mon Sep 17 00:00:00 2001 From: David Canas Date: Thu, 4 Dec 2025 15:22:45 -0800 Subject: [PATCH 04/13] Clearing out prober code. Doing as a single commit in case of ressurection. --- deploy/cloudprober.cfg | 187 ------------------ deploy/prober-entrypoint.sh | 8 - deploy/probers/base.py | 112 ----------- deploy/probers/channel_creation_probe.py | 35 ---- deploy/probers/channel_edit_page_probe.py | 23 --- deploy/probers/channel_update_probe.py | 30 --- deploy/probers/login_page_probe.py | 14 -- deploy/probers/postgres_probe.py | 36 ---- .../postgres_read_contentnode_probe.py | 38 ---- .../postgres_write_contentnode_probe.py | 64 ------ deploy/probers/postmark_api_probe.py | 36 ---- deploy/probers/publishing_status_probe.py | 53 ----- deploy/probers/task_queue_probe.py | 25 --- deploy/probers/topic_creation_probe.py | 41 ---- deploy/probers/unapplied_changes_probe.py | 26 --- deploy/probers/worker_probe.py | 24 --- k8s/images/prober/Dockerfile | 9 - 17 files changed, 761 deletions(-) delete mode 100644 deploy/cloudprober.cfg delete mode 100755 deploy/prober-entrypoint.sh delete mode 100644 deploy/probers/base.py delete mode 100755 deploy/probers/channel_creation_probe.py delete mode 100755 deploy/probers/channel_edit_page_probe.py delete mode 100755 deploy/probers/channel_update_probe.py delete mode 100755 deploy/probers/login_page_probe.py delete mode 100755 deploy/probers/postgres_probe.py delete mode 100755 deploy/probers/postgres_read_contentnode_probe.py delete mode 100755 deploy/probers/postgres_write_contentnode_probe.py delete mode 100755 deploy/probers/postmark_api_probe.py delete mode 100755 deploy/probers/publishing_status_probe.py delete mode 100755 deploy/probers/task_queue_probe.py delete mode 100755 deploy/probers/topic_creation_probe.py delete mode 100755 deploy/probers/unapplied_changes_probe.py delete mode 100755 deploy/probers/worker_probe.py delete mode 100644 k8s/images/prober/Dockerfile diff --git a/deploy/cloudprober.cfg b/deploy/cloudprober.cfg deleted file mode 100644 index c5a129455e..0000000000 --- a/deploy/cloudprober.cfg +++ /dev/null @@ -1,187 +0,0 @@ -probe { - name: "google_homepage" - type: HTTP - targets { - host_names: "www.google.com" - } - interval_msec: 60000 # 60s - timeout_msec: 1000 # 1s -} - -probe { - name: "facebook_homepage" - type: HTTP - targets { - host_names: "www.facebook.com" - } - interval_msec: 60000 # 60s - timeout_msec: 1000 # 1s -} - -probe { - name: "studio_homepage" - type: HTTP - targets { - host_names: "studio.learningequality.org" - } - interval_msec: 60000 # 60s - timeout_msec: 1000 # 1s -} - -probe { - name: "login" - type: EXTERNAL - targets { dummy_targets {} } - external_probe { - mode: ONCE - command: "./probers/login_page_probe.py" - } - interval_msec: 60000 # 60s - timeout_msec: 1000 # 1s -} - -probe { - name: "postgres" - type: EXTERNAL - targets { dummy_targets {} } - external_probe { - mode: ONCE - command: "./probers/postgres_probe.py" - } - interval_msec: 60000 # 60s - timeout_msec: 1000 # 1s -} - -probe { - name: "workers" - type: EXTERNAL - targets { dummy_targets {} } - external_probe { - mode: ONCE - command: "./probers/worker_probe.py" - } - interval_msec: 60000 # 60s - timeout_msec: 5000 # 5s -} - -probe { - name: "channel_creation" - type: EXTERNAL - targets { dummy_targets {} } - external_probe { - mode: ONCE - command: "./probers/channel_creation_probe.py" - } - interval_msec: 300000 # 5mins - timeout_msec: 10000 # 10s -} - -probe { - name: "channel_update" - type: EXTERNAL - targets { dummy_targets {} } - external_probe { - mode: ONCE - command: "./probers/channel_update_probe.py" - } - interval_msec: 60000 # 1min - timeout_msec: 10000 # 10s -} - -probe { - name: "channel_edit_page" - type: EXTERNAL - targets { dummy_targets {} } - external_probe { - mode: ONCE - command: "./probers/channel_edit_page_probe.py" - } - interval_msec: 10000 # 10s - timeout_msec: 10000 # 10s -} - -probe { - name: "postgres_read_contentnode" - type: EXTERNAL - targets { dummy_targets {} } - external_probe { - mode: ONCE - command: "./probers/postgres_read_contentnode_probe.py" - } - interval_msec: 60000 # 60s - timeout_msec: 1000 # 1s -} - -probe { - name: "postgres_write_contentnode" - type: EXTERNAL - targets { dummy_targets {} } - external_probe { - mode: ONCE - command: "./probers/postgres_write_contentnode_probe.py" - } - interval_msec: 60000 # 60s - timeout_msec: 1000 # 1s -} - -probe { - name: "topic_creation" - type: EXTERNAL - targets { dummy_targets {} } - external_probe { - mode: ONCE - command: "./probers/topic_creation_probe.py" - } - interval_msec: 300000 # 5mins - timeout_msec: 20000 # 20s -} - -probe { - name: "postmark_api" - type: EXTERNAL - targets { dummy_targets {} } - external_probe { - mode: ONCE - command: "./probers/postmark_api_probe.py" - } - interval_msec: 300000 # 5 minutes - timeout_msec: 5000 # 5s -} - -probe { - name: "publishing_status" - type: EXTERNAL - targets { dummy_targets {} } - external_probe { - mode: ONCE - command: "./probers/publishing_status_probe.py" - } - interval_msec: 3600000 # 1 hour - timeout_msec: 10000 # 10s -} - -probe { - name: "unapplied_changes_status" - type: EXTERNAL - targets { dummy_targets {} } - external_probe { - mode: ONCE - command: "./probers/unapplied_changes_probe.py" - } - interval_msec: 1800000 # 30 minutes - timeout_msec: 20000 # 20s -} - -probe { - name: "task_queue_status" - type: EXTERNAL - targets { dummy_targets {} } - external_probe { - mode: ONCE - command: "./probers/task_queue_probe.py" - } - interval_msec: 600000 # 10 minutes - timeout_msec: 10000 # 10s -} - -# Note: When deploying on GKE, the error logs can be found under GCE VM instance. diff --git a/deploy/prober-entrypoint.sh b/deploy/prober-entrypoint.sh deleted file mode 100755 index 323e03cab0..0000000000 --- a/deploy/prober-entrypoint.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/bash - -curl -L -o cloudprober.zip https://github.com/google/cloudprober/releases/download/v0.10.2/cloudprober-v0.10.2-linux-x86_64.zip -unzip -p cloudprober.zip > /bin/cloudprober -chmod +x /bin/cloudprober - -cd deploy/ -cloudprober -logtostderr -config_file cloudprober.cfg diff --git a/deploy/probers/base.py b/deploy/probers/base.py deleted file mode 100644 index 7f85a18c16..0000000000 --- a/deploy/probers/base.py +++ /dev/null @@ -1,112 +0,0 @@ -import datetime -import os - -import requests - -USERNAME = os.getenv("PROBER_STUDIO_USERNAME") or "a@a.com" -PASSWORD = os.getenv("PROBER_STUDIO_PASSWORD") or "a" -PRODUCTION_MODE_ON = os.getenv("PROBER_STUDIO_PRODUCTION_MODE_ON") or False -STUDIO_BASE_URL = os.getenv("PROBER_STUDIO_BASE_URL") or "http://127.0.0.1:8080" - - -class BaseProbe(object): - - metric = "STUB_METRIC" - develop_only = False - prober_name = "PROBER" - - def __init__(self): - self.session = requests.Session() - self.session.headers.update( - {"User-Agent": "Studio-Internal-Prober={}".format(self.prober_name)} - ) - - def do_probe(self): - pass - - def _login(self): - # get our initial csrf - url = self._construct_studio_url("/en/accounts/") - r = self.session.get(url) - r.raise_for_status() - csrf = self.session.cookies.get("csrftoken") - formdata = { - "username": USERNAME, - "password": PASSWORD, - } - headers = { - "referer": url, - "X-Studio-Internal-Prober": "LOGIN-PROBER", - "X-CSRFToken": csrf, - } - - r = self.session.post( - self._construct_studio_url("/en/accounts/login/"), - json=formdata, - headers=headers, - allow_redirects=False, - ) - r.raise_for_status() - - # Since logging into Studio with correct username and password should redirect, fail otherwise - if r.status_code != 302: - raise ProberException("Cannot log into Studio.") - - return r - - def _construct_studio_url(self, path): - path_stripped = path.lstrip("/") - url = "{base_url}/{path}".format(base_url=STUDIO_BASE_URL, path=path_stripped) - return url - - def request( - self, - path, - action="GET", - data=None, - headers=None, - contenttype="application/json", - ): - data = data or {} - headers = headers or {} - - # Make sure session is logged in - if not self.session.cookies.get("csrftoken"): - self._login() - - url = self._construct_studio_url(path) - - headers.update( - { - "X-CSRFToken": self.session.cookies.get("csrftoken"), - } - ) - - headers.update({"Content-Type": contenttype}) - headers.update({"X-Studio-Internal-Prober": self.prober_name}) - response = self.session.request(action, url, data=data, headers=headers) - response.raise_for_status() - - return response - - def run(self): - - if self.develop_only and PRODUCTION_MODE_ON: - return - - start_time = datetime.datetime.now() - - self.do_probe() - - end_time = datetime.datetime.now() - elapsed = (end_time - start_time).total_seconds() * 1000 - - print( # noqa: T201 - "{metric_name} {latency_ms}".format( - metric_name=self.metric, latency_ms=elapsed - ) - ) - - -class ProberException(Exception): - pass diff --git a/deploy/probers/channel_creation_probe.py b/deploy/probers/channel_creation_probe.py deleted file mode 100755 index b7ab8d4254..0000000000 --- a/deploy/probers/channel_creation_probe.py +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env python -import json - -from base import BaseProbe - - -class ChannelCreationProbe(BaseProbe): - - metric = "channel_creation_latency_msec" - develop_only = True - prober_name = "CHANNEL-CREATION-PROBER" - - def _get_user_id(self): - response = self.request("api/internal/authenticate_user_internal") - return json.loads(response.content)["user_id"] - - def do_probe(self): - payload = { - "description": "description", - "language": "en-PT", - "name": "test", - "thumbnail": "b3897c3d96bde7f1cff77ce368924098.png", - "content_defaults": "{}", - "editors": [self._get_user_id()], - } - self.request( - "api/channel", - action="POST", - data=payload, - contenttype="application/x-www-form-urlencoded", - ) - - -if __name__ == "__main__": - ChannelCreationProbe().run() diff --git a/deploy/probers/channel_edit_page_probe.py b/deploy/probers/channel_edit_page_probe.py deleted file mode 100755 index 2b3b80d2a3..0000000000 --- a/deploy/probers/channel_edit_page_probe.py +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env python -import json - -from base import BaseProbe - - -class ChannelEditPageProbe(BaseProbe): - - metric = "channel_edit_page_latency_msec" - prober_name = "CHANNEL-EDIT-PAGE-PROBER" - - def _get_channel(self): - response = self.request("api/probers/get_prober_channel") - return json.loads(response.content) - - def do_probe(self): - channel = self._get_channel() - path = "channels/{}/edit".format(channel["id"]) - self.request(path) - - -if __name__ == "__main__": - ChannelEditPageProbe().run() diff --git a/deploy/probers/channel_update_probe.py b/deploy/probers/channel_update_probe.py deleted file mode 100755 index 1951df9348..0000000000 --- a/deploy/probers/channel_update_probe.py +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env python -import json - -from base import BaseProbe - - -class ChannelUpdateProbe(BaseProbe): - - metric = "channel_update_latency_msec" - prober_name = "CHANNEL-UPDATE-PROBER" - develop_only = True - - def _get_channel(self): - response = self.request("api/probers/get_prober_channel") - return json.loads(response.content) - - def do_probe(self): - channel = self._get_channel() - payload = {"name": "New Test Name", "id": channel["id"]} - path = "api/channel/{}".format(channel["id"]) - self.request( - path, - action="PATCH", - data=payload, - contenttype="application/x-www-form-urlencoded", - ) - - -if __name__ == "__main__": - ChannelUpdateProbe().run() diff --git a/deploy/probers/login_page_probe.py b/deploy/probers/login_page_probe.py deleted file mode 100755 index 42ed9a43e3..0000000000 --- a/deploy/probers/login_page_probe.py +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env python -from base import BaseProbe - - -class LoginProbe(BaseProbe): - - metric = "login_latency_msec" - - def do_probe(self): - self._login() - - -if __name__ == "__main__": - LoginProbe().run() diff --git a/deploy/probers/postgres_probe.py b/deploy/probers/postgres_probe.py deleted file mode 100755 index 3aa29acc0c..0000000000 --- a/deploy/probers/postgres_probe.py +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env python -import os - -import psycopg2 -from base import BaseProbe - - -# Use dev options if no env set -DB_HOST = os.getenv("DATA_DB_HOST") or "localhost" -DB_PORT = 5432 -DB_NAME = os.getenv("DATA_DB_NAME") or "kolibri-studio" -DB_USER = os.getenv("DATA_DB_USER") or "learningequality" -DB_PASSWORD = os.getenv("DATA_DB_PASS") or "kolibri" -TIMEOUT_SECONDS = 2 - - -class PostgresProbe(BaseProbe): - metric = "postgres_latency_msec" - - def do_probe(self): - conn = psycopg2.connect( - host=DB_HOST, - port=DB_PORT, - dbname=DB_NAME, - user=DB_USER, - password=DB_PASSWORD, - connect_timeout=TIMEOUT_SECONDS, - ) - cur = conn.cursor() - cur.execute("SELECT datname FROM pg_database;") - cur.fetchone() # raises exception if cur.execute() produced no results - conn.close() - - -if __name__ == "__main__": - PostgresProbe().run() diff --git a/deploy/probers/postgres_read_contentnode_probe.py b/deploy/probers/postgres_read_contentnode_probe.py deleted file mode 100755 index fa4767f404..0000000000 --- a/deploy/probers/postgres_read_contentnode_probe.py +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env python -import os - -import psycopg2 -from base import BaseProbe - - -# Use dev options if no env set -DB_HOST = os.getenv("DATA_DB_HOST") or "localhost" -DB_PORT = 5432 -DB_NAME = os.getenv("DATA_DB_NAME") or "kolibri-studio" -DB_USER = os.getenv("DATA_DB_USER") or "learningequality" -DB_PASSWORD = os.getenv("DATA_DB_PASS") or "kolibri" -TIMEOUT_SECONDS = 2 - - -class PostgresReadContentnodeProbe(BaseProbe): - metric = "postgres_read_contentnode_latency_msec" - - def do_probe(self): - conn = psycopg2.connect( - host=DB_HOST, - port=DB_PORT, - dbname=DB_NAME, - user=DB_USER, - password=DB_PASSWORD, - connect_timeout=TIMEOUT_SECONDS, - ) - cur = conn.cursor() - cur.execute("SELECT * FROM contentcuration_contentnode LIMIT 1;") - num = cur.fetchone() - conn.close() - if not num: - raise Exception("Reading a ContentNode in PostgreSQL database failed.") - - -if __name__ == "__main__": - PostgresReadContentnodeProbe().run() diff --git a/deploy/probers/postgres_write_contentnode_probe.py b/deploy/probers/postgres_write_contentnode_probe.py deleted file mode 100755 index 7785116fe4..0000000000 --- a/deploy/probers/postgres_write_contentnode_probe.py +++ /dev/null @@ -1,64 +0,0 @@ -#!/usr/bin/env python -import os -from datetime import datetime - -import psycopg2 -from base import BaseProbe - -# Use dev options if no env set -DB_HOST = os.getenv("DATA_DB_HOST") or "localhost" -DB_PORT = 5432 -DB_NAME = os.getenv("DATA_DB_NAME") or "kolibri-studio" -DB_USER = os.getenv("DATA_DB_USER") or "learningequality" -DB_PASSWORD = os.getenv("DATA_DB_PASS") or "kolibri" -TIMEOUT_SECONDS = 2 - - -class PostgresWriteContentnodeProbe(BaseProbe): - metric = "postgres_write_contentnode_latency_msec" - - develop_only = True - - def do_probe(self): - conn = psycopg2.connect( - host=DB_HOST, - port=DB_PORT, - dbname=DB_NAME, - user=DB_USER, - password=DB_PASSWORD, - connect_timeout=TIMEOUT_SECONDS, - ) - cur = conn.cursor() - now = datetime.now() - cur.execute( - """ - INSERT INTO contentcuration_contentnode(id, content_id, kind_id, title, description,sort_order, created, - modified, changed, lft, rght, tree_id, level, published, node_id, freeze_authoring_data, publishing, role_visibility) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s); - """, - ( - "testpostgreswriteprobe", - "testprobecontentid", - "topic", - "test postgres write contentnode probe", - "test postgres write contentnode probe", - 1, - now, - now, - True, - 1, - 1, - 1, - 1, - False, - "testprobenodeid", - False, - False, - "test", - ), - ) - conn.close() - - -if __name__ == "__main__": - PostgresWriteContentnodeProbe().run() diff --git a/deploy/probers/postmark_api_probe.py b/deploy/probers/postmark_api_probe.py deleted file mode 100755 index 30cbb1741c..0000000000 --- a/deploy/probers/postmark_api_probe.py +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env python -import requests -from base import BaseProbe - -POSTMARK_SERVICE_STATUS_URL = "https://status.postmarkapp.com/api/1.0/services" - -# (See here for API details: https://status.postmarkapp.com/api) -ALL_POSSIBLE_STATUSES = ["UP", "MAINTENANCE", "DELAY", "DEGRADED", "DOWN"] - -PASSING_POSTMARK_STATUSES = { - "/services/smtp": ["UP", "MAINTENANCE"], - "/services/api": ALL_POSSIBLE_STATUSES, - "/services/inbound": ALL_POSSIBLE_STATUSES, - "/services/web": ALL_POSSIBLE_STATUSES, -} - - -class PostmarkProbe(BaseProbe): - metric = "postmark_api_latency_msec" - - def do_probe(self): - r = requests.get(url=POSTMARK_SERVICE_STATUS_URL) - for service in r.json(): - allowed_statuses = PASSING_POSTMARK_STATUSES.get(service["url"]) - passing = service["status"] in allowed_statuses - - if passing: - continue - raise Exception( - "Postmark's `%s` service has status %s, but we require one of the following: %s" - % (service["name"], service["status"], allowed_statuses) - ) - - -if __name__ == "__main__": - PostmarkProbe().run() diff --git a/deploy/probers/publishing_status_probe.py b/deploy/probers/publishing_status_probe.py deleted file mode 100755 index fffe67eb92..0000000000 --- a/deploy/probers/publishing_status_probe.py +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env python -import datetime -import os - -from base import BaseProbe -from base import ProberException -from base import PRODUCTION_MODE_ON - - -ALERT_THRESHOLD = int( - os.getenv("PROBER_PUBLISHING_ALERT_THRESHOLD") or 2 * 3600 -) # default = 2 hours -DATE_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ" - - -class PublishingStatusProbe(BaseProbe): - - metric = "max_publishing_duration_sec" - prober_name = "PUBLISHING_STATUS_PROBER" - - def run(self): - if self.develop_only and PRODUCTION_MODE_ON: - return - - r = self.request("api/probers/publishing_status/") - results = r.json() - now = datetime.datetime.now() - max_duration = 0 - channel_ids = [] - - for result in results: - duration = ( - now - datetime.datetime.strptime(result["performed"], DATE_FORMAT) - ).seconds - max_duration = max(max_duration, duration) - if duration >= ALERT_THRESHOLD or not result["task_id"]: - channel_ids.append(result["channel_id"]) - - if max_duration > 0: - print( # noqa: T201 - "{metric_name} {duration_sec}".format( - metric_name=self.metric, duration_sec=max_duration - ) - ) - - if channel_ids: - raise ProberException( - "Publishing alert for channels: {}".format(", ".join(channel_ids)) - ) - - -if __name__ == "__main__": - PublishingStatusProbe().run() diff --git a/deploy/probers/task_queue_probe.py b/deploy/probers/task_queue_probe.py deleted file mode 100755 index 6148176856..0000000000 --- a/deploy/probers/task_queue_probe.py +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env python -from base import BaseProbe - - -class TaskQueueProbe(BaseProbe): - - metric = "task_queue_ping_latency_msec" - threshold = 50 - - def do_probe(self): - r = self.request("api/probers/task_queue_status/") - r.raise_for_status() - results = r.json() - - task_count = results.get("queued_task_count", 0) - if task_count >= self.threshold: - raise Exception( - "Task queue length is over threshold! {} > {}".format( - task_count, self.threshold - ) - ) - - -if __name__ == "__main__": - TaskQueueProbe().run() diff --git a/deploy/probers/topic_creation_probe.py b/deploy/probers/topic_creation_probe.py deleted file mode 100755 index 6c7090c598..0000000000 --- a/deploy/probers/topic_creation_probe.py +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env python -import json - -from base import BaseProbe -from le_utils.constants import content_kinds - - -class TopicCreationProbe(BaseProbe): - - metric = "topic_creation_latency_msec" - develop_only = True - prober_name = "TOPIC-CREATION-PROBER" - - def _get_channel(self): - response = self.request("api/probers/get_prober_channel") - return json.loads(response.content) - - def do_probe(self): - channel = self._get_channel() - payload = { - "title": "Statistics and Probeability", - "kind": content_kinds.TOPIC, - } - response = self.request( - "api/contentnode", action="POST", data=json.dumps(payload) - ) - - # Test saving to channel works - new_topic = json.loads(response.content) - new_topic.update({"parent": channel["main_tree"]}) - path = "api/contentnode/{}".format(new_topic["id"]) - self.request( - path, - action="PUT", - data=payload, - contenttype="application/x-www-form-urlencoded", - ) - - -if __name__ == "__main__": - TopicCreationProbe().run() diff --git a/deploy/probers/unapplied_changes_probe.py b/deploy/probers/unapplied_changes_probe.py deleted file mode 100755 index 6065f3df28..0000000000 --- a/deploy/probers/unapplied_changes_probe.py +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env python -from base import BaseProbe - - -class UnappliedChangesProbe(BaseProbe): - - metric = "unapplied__changes_ping_latency_msec" - - def do_probe(self): - r = self.request("api/probers/unapplied_changes_status/") - r.raise_for_status() - results = r.json() - - active_task_count = results.get("active_task_count", 0) - unapplied_changes_count = results.get("unapplied_changes_count", 0) - - if active_task_count == 0 and unapplied_changes_count > 0: - raise Exception( - "There are unapplied changes and no active tasks! {} unapplied changes".format( - unapplied_changes_count - ) - ) - - -if __name__ == "__main__": - UnappliedChangesProbe().run() diff --git a/deploy/probers/worker_probe.py b/deploy/probers/worker_probe.py deleted file mode 100755 index 211dc2e6a1..0000000000 --- a/deploy/probers/worker_probe.py +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env python -from base import BaseProbe - - -class WorkerProbe(BaseProbe): - - metric = "worker_ping_latency_msec" - - def do_probe(self): - r = self.request("api/probers/celery_worker_status/") - r.raise_for_status() - results = r.json() - - active_workers = [] - for worker_hostname, worker_status in results.items(): - if "ok" in worker_status.keys(): - active_workers.append(worker_hostname) - - if not active_workers: - raise Exception("No workers are running!") - - -if __name__ == "__main__": - WorkerProbe().run() diff --git a/k8s/images/prober/Dockerfile b/k8s/images/prober/Dockerfile deleted file mode 100644 index d3d18ee8a6..0000000000 --- a/k8s/images/prober/Dockerfile +++ /dev/null @@ -1,9 +0,0 @@ -FROM ubuntu:bionic - -RUN apt-get update && apt-get install -y curl python-pip unzip - -RUN pip install requests>=2.20.0 && pip install psycopg2-binary==2.7.4 && pip install le-utils>=0.1.19 - -COPY ./deploy/cloudprober.cfg /deploy/ -COPY ./deploy/prober-entrypoint.sh /deploy/ -COPY ./deploy/probers /deploy/probers/ From aae5494aff8c7773635526dab7302b4f000ed8ff Mon Sep 17 00:00:00 2001 From: David Canas Date: Thu, 4 Dec 2025 15:35:07 -0800 Subject: [PATCH 05/13] Moving nginx files out of `deploy` and housing them with other image-specific files. --- k8s/images/nginx/Dockerfile | 13 ++++++++++--- {deploy => k8s/images/nginx}/includes/README.md | 0 .../images/nginx}/includes/content/_proxy.conf | 0 .../images/nginx}/includes/content/default.conf | 0 .../includes/content/develop-studio-content.conf | 0 .../nginx}/includes/content/studio-content.conf | 0 {deploy => k8s/images/nginx}/mime.types | 0 {deploy => k8s/images/nginx}/nginx.conf | 0 8 files changed, 10 insertions(+), 3 deletions(-) rename {deploy => k8s/images/nginx}/includes/README.md (100%) rename {deploy => k8s/images/nginx}/includes/content/_proxy.conf (100%) rename {deploy => k8s/images/nginx}/includes/content/default.conf (100%) rename {deploy => k8s/images/nginx}/includes/content/develop-studio-content.conf (100%) rename {deploy => k8s/images/nginx}/includes/content/studio-content.conf (100%) rename {deploy => k8s/images/nginx}/mime.types (100%) rename {deploy => k8s/images/nginx}/nginx.conf (100%) diff --git a/k8s/images/nginx/Dockerfile b/k8s/images/nginx/Dockerfile index ab38a1118a..3cf58f9a17 100644 --- a/k8s/images/nginx/Dockerfile +++ b/k8s/images/nginx/Dockerfile @@ -1,8 +1,15 @@ FROM nginx:1.25 +# Build from inside the directory by overriding this. +ARG SRC_DIR=k8s/images/nginx + RUN rm /etc/nginx/conf.d/* # if there's stuff here, nginx won't read sites-enabled -COPY deploy/nginx.conf /etc/nginx/nginx.conf -COPY deploy/includes /etc/nginx/includes -COPY k8s/images/nginx/entrypoint.sh /usr/bin +COPY ${SRC_DIR}/nginx.conf /etc/nginx/nginx.conf +COPY ${SRC_DIR}/includes /etc/nginx/includes +COPY ${SRC_DIR}/entrypoint.sh /usr/bin + +# Really seems like it _should_ be here, as it's referenced by `nginx.conf`. +# But it's hasn't been for years. +# COPY ${SRC_DIR}/mime.types /etc/nginx/mime.types CMD ["entrypoint.sh"] diff --git a/deploy/includes/README.md b/k8s/images/nginx/includes/README.md similarity index 100% rename from deploy/includes/README.md rename to k8s/images/nginx/includes/README.md diff --git a/deploy/includes/content/_proxy.conf b/k8s/images/nginx/includes/content/_proxy.conf similarity index 100% rename from deploy/includes/content/_proxy.conf rename to k8s/images/nginx/includes/content/_proxy.conf diff --git a/deploy/includes/content/default.conf b/k8s/images/nginx/includes/content/default.conf similarity index 100% rename from deploy/includes/content/default.conf rename to k8s/images/nginx/includes/content/default.conf diff --git a/deploy/includes/content/develop-studio-content.conf b/k8s/images/nginx/includes/content/develop-studio-content.conf similarity index 100% rename from deploy/includes/content/develop-studio-content.conf rename to k8s/images/nginx/includes/content/develop-studio-content.conf diff --git a/deploy/includes/content/studio-content.conf b/k8s/images/nginx/includes/content/studio-content.conf similarity index 100% rename from deploy/includes/content/studio-content.conf rename to k8s/images/nginx/includes/content/studio-content.conf diff --git a/deploy/mime.types b/k8s/images/nginx/mime.types similarity index 100% rename from deploy/mime.types rename to k8s/images/nginx/mime.types diff --git a/deploy/nginx.conf b/k8s/images/nginx/nginx.conf similarity index 100% rename from deploy/nginx.conf rename to k8s/images/nginx/nginx.conf From 200f537abdfbda9dec7b0affdfafbe6ad6ccb523 Mon Sep 17 00:00:00 2001 From: David Canas Date: Fri, 5 Dec 2025 11:57:59 -0800 Subject: [PATCH 06/13] WIP: Moving prod dockerfiles out of defunct k8s dir. --- .github/workflows/containerbuild.yml | 4 ++-- cloudbuild-pr.yaml | 2 +- cloudbuild-production.yaml | 6 +++--- docker-compose.yml | 2 +- {k8s/images => docker/prod}/app/Dockerfile | 0 {k8s/images => docker/prod}/app/Makefile | 0 {k8s/images => docker/prod}/nginx/Dockerfile | 0 {k8s/images => docker/prod}/nginx/Makefile | 0 {k8s/images => docker/prod}/nginx/entrypoint.sh | 0 {k8s/images => docker/prod}/nginx/includes/README.md | 0 .../prod}/nginx/includes/content/_proxy.conf | 0 .../prod}/nginx/includes/content/default.conf | 2 +- .../nginx/includes/content/develop-studio-content.conf | 0 .../prod}/nginx/includes/content/studio-content.conf | 0 {k8s/images => docker/prod}/nginx/mime.types | 0 {k8s/images => docker/prod}/nginx/nginx.conf | 0 16 files changed, 8 insertions(+), 8 deletions(-) rename {k8s/images => docker/prod}/app/Dockerfile (100%) rename {k8s/images => docker/prod}/app/Makefile (100%) rename {k8s/images => docker/prod}/nginx/Dockerfile (100%) rename {k8s/images => docker/prod}/nginx/Makefile (100%) rename {k8s/images => docker/prod}/nginx/entrypoint.sh (100%) rename {k8s/images => docker/prod}/nginx/includes/README.md (100%) rename {k8s/images => docker/prod}/nginx/includes/content/_proxy.conf (100%) rename {k8s/images => docker/prod}/nginx/includes/content/default.conf (95%) rename {k8s/images => docker/prod}/nginx/includes/content/develop-studio-content.conf (100%) rename {k8s/images => docker/prod}/nginx/includes/content/studio-content.conf (100%) rename {k8s/images => docker/prod}/nginx/mime.types (100%) rename {k8s/images => docker/prod}/nginx/nginx.conf (100%) diff --git a/.github/workflows/containerbuild.yml b/.github/workflows/containerbuild.yml index 7b367f0eb0..7feb27f226 100644 --- a/.github/workflows/containerbuild.yml +++ b/.github/workflows/containerbuild.yml @@ -79,7 +79,7 @@ jobs: with: skip_after_successful_duplicate: false github_token: ${{ github.token }} - paths: '["k8s/images/nginx/*", ".github/workflows/containerbuild.yml"]' + paths: '["docker/prod/nginx/*", ".github/workflows/containerbuild.yml"]' build_nginx: name: nginx - test build of nginx Docker image @@ -100,6 +100,6 @@ jobs: uses: docker/build-push-action@v6 with: context: ./ - file: ./k8s/images/nginx/Dockerfile + file: ./docker/prod/nginx/Dockerfile platforms: linux/amd64 push: false diff --git a/cloudbuild-pr.yaml b/cloudbuild-pr.yaml index 2fb21ce2c5..1cce544614 100644 --- a/cloudbuild-pr.yaml +++ b/cloudbuild-pr.yaml @@ -20,7 +20,7 @@ steps: waitFor: ['-'] # don't wait for previous steps args: [ 'build', - '-f', 'k8s/images/nginx/Dockerfile', + '-f', 'docker/prod/nginx/Dockerfile', '--cache-from', 'gcr.io/$PROJECT_ID/learningequality-studio-nginx:latest', '-t', 'gcr.io/$PROJECT_ID/learningequality-studio-nginx:$COMMIT_SHA', '-t', 'gcr.io/$PROJECT_ID/learningequality-studio-nginx:latest', diff --git a/cloudbuild-production.yaml b/cloudbuild-production.yaml index 3ff333a67f..3e5188fca2 100644 --- a/cloudbuild-production.yaml +++ b/cloudbuild-production.yaml @@ -12,7 +12,7 @@ steps: - > docker build --build_arg COMMIT_SHA=$COMMIT_SHA - -f k8s/images/app/Dockerfile + -f docker/prod/app/Dockerfile --cache-from gcr.io/$PROJECT_ID/learningequality-studio-app:latest -t gcr.io/$PROJECT_ID/learningequality-studio-app:$COMMIT_SHA -t gcr.io/$PROJECT_ID/learningequality-studio-app:latest @@ -23,7 +23,7 @@ steps: waitFor: ['-'] # don't wait for previous steps args: [ 'build', - '-f', 'k8s/images/nginx/Dockerfile', + '-f', 'docker/prod/nginx/Dockerfile', '--cache-from', 'gcr.io/$PROJECT_ID/learningequality-studio-nginx:latest', '-t', 'gcr.io/$PROJECT_ID/learningequality-studio-nginx:$COMMIT_SHA', '-t', 'gcr.io/$PROJECT_ID/learningequality-studio-nginx:latest', @@ -40,7 +40,7 @@ steps: waitFor: ['pull-prober-image-cache'] # don't wait for previous steps args: [ 'build', - '-f', 'k8s/images/prober/Dockerfile', + '-f', 'docker/prod/prober/Dockerfile', '--cache-from', 'gcr.io/$PROJECT_ID/learningequality-studio-prober:latest', '-t', 'gcr.io/$PROJECT_ID/learningequality-studio-prober:$COMMIT_SHA', '-t', 'gcr.io/$PROJECT_ID/learningequality-studio-prober:latest', diff --git a/docker-compose.yml b/docker-compose.yml index 3a07894c8d..0fd57a2743 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,7 +35,7 @@ services: platform: linux/amd64 build: context: . - dockerfile: k8s/images/nginx/Dockerfile + dockerfile: docker/prod/nginx/Dockerfile ports: - "8081:8080" depends_on: diff --git a/k8s/images/app/Dockerfile b/docker/prod/app/Dockerfile similarity index 100% rename from k8s/images/app/Dockerfile rename to docker/prod/app/Dockerfile diff --git a/k8s/images/app/Makefile b/docker/prod/app/Makefile similarity index 100% rename from k8s/images/app/Makefile rename to docker/prod/app/Makefile diff --git a/k8s/images/nginx/Dockerfile b/docker/prod/nginx/Dockerfile similarity index 100% rename from k8s/images/nginx/Dockerfile rename to docker/prod/nginx/Dockerfile diff --git a/k8s/images/nginx/Makefile b/docker/prod/nginx/Makefile similarity index 100% rename from k8s/images/nginx/Makefile rename to docker/prod/nginx/Makefile diff --git a/k8s/images/nginx/entrypoint.sh b/docker/prod/nginx/entrypoint.sh similarity index 100% rename from k8s/images/nginx/entrypoint.sh rename to docker/prod/nginx/entrypoint.sh diff --git a/k8s/images/nginx/includes/README.md b/docker/prod/nginx/includes/README.md similarity index 100% rename from k8s/images/nginx/includes/README.md rename to docker/prod/nginx/includes/README.md diff --git a/k8s/images/nginx/includes/content/_proxy.conf b/docker/prod/nginx/includes/content/_proxy.conf similarity index 100% rename from k8s/images/nginx/includes/content/_proxy.conf rename to docker/prod/nginx/includes/content/_proxy.conf diff --git a/k8s/images/nginx/includes/content/default.conf b/docker/prod/nginx/includes/content/default.conf similarity index 95% rename from k8s/images/nginx/includes/content/default.conf rename to docker/prod/nginx/includes/content/default.conf index 404bd64075..44c005f21b 100644 --- a/k8s/images/nginx/includes/content/default.conf +++ b/docker/prod/nginx/includes/content/default.conf @@ -1,4 +1,4 @@ -# DO NOT RENAME: referenced by k8s/images/nginx/entrypoint.sh +# DO NOT RENAME: referenced by docker/prod/nginx/entrypoint.sh # assume development location @emulator { diff --git a/k8s/images/nginx/includes/content/develop-studio-content.conf b/docker/prod/nginx/includes/content/develop-studio-content.conf similarity index 100% rename from k8s/images/nginx/includes/content/develop-studio-content.conf rename to docker/prod/nginx/includes/content/develop-studio-content.conf diff --git a/k8s/images/nginx/includes/content/studio-content.conf b/docker/prod/nginx/includes/content/studio-content.conf similarity index 100% rename from k8s/images/nginx/includes/content/studio-content.conf rename to docker/prod/nginx/includes/content/studio-content.conf diff --git a/k8s/images/nginx/mime.types b/docker/prod/nginx/mime.types similarity index 100% rename from k8s/images/nginx/mime.types rename to docker/prod/nginx/mime.types diff --git a/k8s/images/nginx/nginx.conf b/docker/prod/nginx/nginx.conf similarity index 100% rename from k8s/images/nginx/nginx.conf rename to docker/prod/nginx/nginx.conf From 1dbd0db6626f3e5ad418f178eb050d85d50c2d84 Mon Sep 17 00:00:00 2001 From: David Canas Date: Fri, 5 Dec 2025 12:15:49 -0800 Subject: [PATCH 07/13] WIP: Flattening out new image structure, following current naming standard. And deleting defunct cloudbuild prod yaml --- .github/workflows/containerbuild.yml | 4 +- cloudbuild-production.yaml | 99 ------------------- docker-compose.yml | 2 +- .../Dockerfile => Dockerfile.nginx.prod} | 2 +- .../{prod/app/Dockerfile => Dockerfile.prod} | 0 docker/prod/app/Makefile | 4 - docker/prod/nginx/Makefile | 9 -- 7 files changed, 4 insertions(+), 116 deletions(-) delete mode 100644 cloudbuild-production.yaml rename docker/{prod/nginx/Dockerfile => Dockerfile.nginx.prod} (94%) rename docker/{prod/app/Dockerfile => Dockerfile.prod} (100%) delete mode 100644 docker/prod/app/Makefile delete mode 100644 docker/prod/nginx/Makefile diff --git a/.github/workflows/containerbuild.yml b/.github/workflows/containerbuild.yml index 7feb27f226..68f1be456c 100644 --- a/.github/workflows/containerbuild.yml +++ b/.github/workflows/containerbuild.yml @@ -79,7 +79,7 @@ jobs: with: skip_after_successful_duplicate: false github_token: ${{ github.token }} - paths: '["docker/prod/nginx/*", ".github/workflows/containerbuild.yml"]' + paths: '["docker/Dockerfile.nginx.prod", "nginx/*", ".github/workflows/containerbuild.yml"]' build_nginx: name: nginx - test build of nginx Docker image @@ -100,6 +100,6 @@ jobs: uses: docker/build-push-action@v6 with: context: ./ - file: ./docker/prod/nginx/Dockerfile + file: ./docker/Dockerfile.nginx.prod platforms: linux/amd64 push: false diff --git a/cloudbuild-production.yaml b/cloudbuild-production.yaml deleted file mode 100644 index 3e5188fca2..0000000000 --- a/cloudbuild-production.yaml +++ /dev/null @@ -1,99 +0,0 @@ -steps: -- name: 'gcr.io/cloud-builders/docker' - id: pull-app-image-cache - args: ['pull', 'gcr.io/$PROJECT_ID/learningequality-studio-app:latest'] - -- name: 'gcr.io/cloud-builders/docker' - id: build-app-image - entrypoint: bash - waitFor: ['pull-app-image-cache'] # wait for app image cache pull to finish - args: - - -c - - > - docker build - --build_arg COMMIT_SHA=$COMMIT_SHA - -f docker/prod/app/Dockerfile - --cache-from gcr.io/$PROJECT_ID/learningequality-studio-app:latest - -t gcr.io/$PROJECT_ID/learningequality-studio-app:$COMMIT_SHA - -t gcr.io/$PROJECT_ID/learningequality-studio-app:latest - . - -- name: 'gcr.io/cloud-builders/docker' - id: build-nginx-image - waitFor: ['-'] # don't wait for previous steps - args: [ - 'build', - '-f', 'docker/prod/nginx/Dockerfile', - '--cache-from', 'gcr.io/$PROJECT_ID/learningequality-studio-nginx:latest', - '-t', 'gcr.io/$PROJECT_ID/learningequality-studio-nginx:$COMMIT_SHA', - '-t', 'gcr.io/$PROJECT_ID/learningequality-studio-nginx:latest', - '.' - ] - -- name: 'gcr.io/cloud-builders/docker' - id: pull-prober-image-cache - waitFor: ['-'] - args: ['pull', 'gcr.io/$PROJECT_ID/learningequality-studio-prober:latest'] - -- name: 'gcr.io/cloud-builders/docker' - id: build-prober-image - waitFor: ['pull-prober-image-cache'] # don't wait for previous steps - args: [ - 'build', - '-f', 'docker/prod/prober/Dockerfile', - '--cache-from', 'gcr.io/$PROJECT_ID/learningequality-studio-prober:latest', - '-t', 'gcr.io/$PROJECT_ID/learningequality-studio-prober:$COMMIT_SHA', - '-t', 'gcr.io/$PROJECT_ID/learningequality-studio-prober:latest', - '.' - ] - -- name: 'gcr.io/cloud-builders/docker' - id: push-app-image - waitFor: ['build-app-image'] - args: ['push', 'gcr.io/$PROJECT_ID/learningequality-studio-app:$COMMIT_SHA'] - -- name: 'gcr.io/cloud-builders/docker' - id: push-nginx-image - waitFor: ['build-nginx-image'] - args: ['push', 'gcr.io/$PROJECT_ID/learningequality-studio-nginx:$COMMIT_SHA'] - -- name: 'gcr.io/cloud-builders/docker' - id: push-prober-image - waitFor: ['build-prober-image'] - args: ['push', 'gcr.io/$PROJECT_ID/learningequality-studio-prober:$COMMIT_SHA'] - -- name: 'gcr.io/$PROJECT_ID/helm' - id: helm-deploy-studio-instance - waitFor: ['push-app-image', 'push-nginx-image'] - dir: "k8s" - env: - - 'CLOUDSDK_COMPUTE_ZONE=us-central1-f' - - 'CLOUDSDK_CONTAINER_CLUSTER=contentworkshop-central' - entrypoint: 'bash' - args: - - -c - - > - /builder/helm.bash && - ./helm-deploy.sh - $BRANCH_NAME - gcr.io/$PROJECT_ID/learningequality-studio-app:$COMMIT_SHA - gcr.io/$PROJECT_ID/learningequality-studio-nginx:$COMMIT_SHA - $_STORAGE_BUCKET - $COMMIT_SHA - $PROJECT_ID - $_DATABASE_INSTANCE_NAME - us-central1 - - -substitutions: - _DATABASE_INSTANCE_NAME: develop # by default, connect to the develop DB - _STORAGE_BUCKET: develop-studio-content - -timeout: 3600s -images: - - gcr.io/$PROJECT_ID/learningequality-studio-nginx:latest - - gcr.io/$PROJECT_ID/learningequality-studio-nginx:$COMMIT_SHA - - gcr.io/$PROJECT_ID/learningequality-studio-app:latest - - gcr.io/$PROJECT_ID/learningequality-studio-app:$COMMIT_SHA - - 'gcr.io/$PROJECT_ID/learningequality-studio-prober:$COMMIT_SHA' - - 'gcr.io/$PROJECT_ID/learningequality-studio-prober:latest' diff --git a/docker-compose.yml b/docker-compose.yml index 0fd57a2743..2a0eed7799 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,7 +35,7 @@ services: platform: linux/amd64 build: context: . - dockerfile: docker/prod/nginx/Dockerfile + dockerfile: docker/Dockerfile.nginx.prod ports: - "8081:8080" depends_on: diff --git a/docker/prod/nginx/Dockerfile b/docker/Dockerfile.nginx.prod similarity index 94% rename from docker/prod/nginx/Dockerfile rename to docker/Dockerfile.nginx.prod index 3cf58f9a17..a01753b143 100644 --- a/docker/prod/nginx/Dockerfile +++ b/docker/Dockerfile.nginx.prod @@ -1,7 +1,7 @@ FROM nginx:1.25 # Build from inside the directory by overriding this. -ARG SRC_DIR=k8s/images/nginx +ARG SRC_DIR=docker/prod/nginx RUN rm /etc/nginx/conf.d/* # if there's stuff here, nginx won't read sites-enabled COPY ${SRC_DIR}/nginx.conf /etc/nginx/nginx.conf diff --git a/docker/prod/app/Dockerfile b/docker/Dockerfile.prod similarity index 100% rename from docker/prod/app/Dockerfile rename to docker/Dockerfile.prod diff --git a/docker/prod/app/Makefile b/docker/prod/app/Makefile deleted file mode 100644 index d958cc266a..0000000000 --- a/docker/prod/app/Makefile +++ /dev/null @@ -1,4 +0,0 @@ -COMMIT := nlatest - -imagebuild: - docker build ../../../ -f $$PWD/Dockerfile -t gcr.io/github-learningequality-studio/app:$(COMMIT) diff --git a/docker/prod/nginx/Makefile b/docker/prod/nginx/Makefile deleted file mode 100644 index 9c2d01dc60..0000000000 --- a/docker/prod/nginx/Makefile +++ /dev/null @@ -1,9 +0,0 @@ -CONTAINER_NAME := "contentworkshop-app-nginx-proxy" -CONTAINER_VERSION := v4 -GCLOUD_PROJECT := contentworkshop-159920 -GIT_PROJECT_ROOT := `git rev-parse --show-toplevel` - -all: appcodeupdate imagebuild imagepush - -imagebuild: - docker build -t learningequality/$(CONTAINER_NAME):$(CONTAINER_VERSION) -f ./Dockerfile $(GIT_PROJECT_ROOT) From c4155bff363ebebbb8a9e9a48ffffafe5ad59bda Mon Sep 17 00:00:00 2001 From: David Canas Date: Fri, 5 Dec 2025 12:17:33 -0800 Subject: [PATCH 08/13] Removing defunct cloudbuild-pr.yaml Making a separate commit for bookmarking purposes. Seems we used to deploy to a separate/distinct dev cluster, and create new postgres instances for every single pr. --- cloudbuild-pr.yaml | 102 --------------------------------------------- 1 file changed, 102 deletions(-) delete mode 100644 cloudbuild-pr.yaml diff --git a/cloudbuild-pr.yaml b/cloudbuild-pr.yaml deleted file mode 100644 index 1cce544614..0000000000 --- a/cloudbuild-pr.yaml +++ /dev/null @@ -1,102 +0,0 @@ -steps: -- name: 'gcr.io/cloud-builders/docker' - id: pull-app-image-cache - args: ['pull', 'gcr.io/$PROJECT_ID/learningequality-studio-app:latest'] - -- name: 'gcr.io/cloud-builders/docker' - id: build-app-image - waitFor: ['pull-app-image-cache'] # don't wait for previous steps - args: [ - 'build', - '-f', 'docker/Dockerfile.demo', - '--cache-from', 'gcr.io/$PROJECT_ID/learningequality-studio-app:latest', - '-t', 'gcr.io/$PROJECT_ID/learningequality-studio-app:$COMMIT_SHA', - '-t', 'gcr.io/$PROJECT_ID/learningequality-studio-app:latest', - '.' - ] - -- name: 'gcr.io/cloud-builders/docker' - id: build-nginx-image - waitFor: ['-'] # don't wait for previous steps - args: [ - 'build', - '-f', 'docker/prod/nginx/Dockerfile', - '--cache-from', 'gcr.io/$PROJECT_ID/learningequality-studio-nginx:latest', - '-t', 'gcr.io/$PROJECT_ID/learningequality-studio-nginx:$COMMIT_SHA', - '-t', 'gcr.io/$PROJECT_ID/learningequality-studio-nginx:latest', - '.' - ] - -- name: 'gcr.io/cloud-builders/docker' - id: push-app-image - waitFor: ['build-app-image'] - args: ['push', 'gcr.io/$PROJECT_ID/learningequality-studio-app:$COMMIT_SHA'] - -- name: 'gcr.io/cloud-builders/docker' - id: push-nginx-image - waitFor: ['build-nginx-image'] - args: ['push', 'gcr.io/$PROJECT_ID/learningequality-studio-nginx:$COMMIT_SHA'] - -- name: 'gcr.io/cloud-builders/gcloud' - id: decrypt-gcs-service-account - waitFor: ['-'] - args: [ - 'kms', 'decrypt', - '--location=global', '--keyring=builder-secrets', '--key=secret-encrypter', - '--ciphertext-file=k8s/build-secrets/$PROJECT_ID-gcs-service-account.json.enc', - '--plaintext-file=gcs-service-account.json' - ] - -- name: 'gcr.io/cloud-builders/gcloud' - id: create-new-database - waitFor: ['-'] - dir: "k8s" - entrypoint: 'bash' - args: [ - '-c', - './create-cloudsql-database.sh $_RELEASE_NAME $_DATABASE_INSTANCE_NAME' - ] - -- name: 'gcr.io/$PROJECT_ID/helm' - id: helm-deploy-studio-instance - waitFor: ['decrypt-gcs-service-account', 'push-app-image', 'push-nginx-image'] - dir: "k8s" - env: - - 'CLOUDSDK_COMPUTE_ZONE=us-central1-f' - - 'CLOUDSDK_CONTAINER_CLUSTER=dev-qa-cluster' - secretEnv: ['POSTMARK_API_KEY'] - entrypoint: 'bash' - args: - - -c - - > - /builder/helm.bash && - ./helm-deploy.sh - $_RELEASE_NAME - $_STORAGE_BUCKET - $COMMIT_SHA - $$POSTMARK_API_KEY - "" - "" - $_POSTGRES_USERNAME - $_RELEASE_NAME - $_POSTGRES_PASSWORD - $PROJECT_ID-$_DATABASE_INSTANCE_NAME-sql-proxy-gcloud-sqlproxy.sqlproxy - ../gcs-service-account.json - $PROJECT_ID - -- name: 'gcr.io/cloud-builders/gsutil' - id: remove-tarball-in-gcs - waitFor: ['helm-deploy-studio-instance'] - args: ['rm', $_TARBALL_LOCATION] - -timeout: 3600s -secrets: -- kmsKeyName: projects/ops-central/locations/global/keyRings/builder-secrets/cryptoKeys/secret-encrypter - secretEnv: - POSTMARK_API_KEY: CiQA7z1GH3QhvCEWNn6KS64t/c8BEQng5I4CdMC6VGNxJkWmZrwSTgB+R8mv/PSrzlDmCYSOZc4bugWA+K+lJ8nIll1BBsZZEV5M9GuOCYVn6sVWg9pCIVujwyb4EvEy1QaKmZCzAnTw9aHEXDH0sruAUHBaTA== - -images: - - 'gcr.io/$PROJECT_ID/learningequality-studio-nginx:$COMMIT_SHA' - - 'gcr.io/$PROJECT_ID/learningequality-studio-nginx:latest' - - 'gcr.io/$PROJECT_ID/learningequality-studio-app:$COMMIT_SHA' - - 'gcr.io/$PROJECT_ID/learningequality-studio-app:latest' From cb8cdfed994bbec6a6b100bc94b989035461beee Mon Sep 17 00:00:00 2001 From: David Canas Date: Fri, 5 Dec 2025 12:23:39 -0800 Subject: [PATCH 09/13] Flattening file structure further. Standardizing on image-specific docker locations. --- docker-compose.yml | 2 +- docker/Dockerfile.nginx.prod | 2 +- docker/{prod => }/nginx/entrypoint.sh | 0 docker/{prod => }/nginx/includes/README.md | 0 docker/{prod => }/nginx/includes/content/_proxy.conf | 0 docker/{prod => }/nginx/includes/content/default.conf | 2 +- .../nginx/includes/content/develop-studio-content.conf | 0 docker/{prod => }/nginx/includes/content/studio-content.conf | 0 docker/{prod => }/nginx/mime.types | 0 docker/{prod => }/nginx/nginx.conf | 0 docker/{ => studio-dev}/entrypoint.py | 0 11 files changed, 3 insertions(+), 3 deletions(-) rename docker/{prod => }/nginx/entrypoint.sh (100%) rename docker/{prod => }/nginx/includes/README.md (100%) rename docker/{prod => }/nginx/includes/content/_proxy.conf (100%) rename docker/{prod => }/nginx/includes/content/default.conf (95%) rename docker/{prod => }/nginx/includes/content/develop-studio-content.conf (100%) rename docker/{prod => }/nginx/includes/content/studio-content.conf (100%) rename docker/{prod => }/nginx/mime.types (100%) rename docker/{prod => }/nginx/nginx.conf (100%) rename docker/{ => studio-dev}/entrypoint.py (100%) diff --git a/docker-compose.yml b/docker-compose.yml index 2a0eed7799..9ec8d1c34f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -44,7 +44,7 @@ services: studio-app: <<: *studio-worker - entrypoint: python docker/entrypoint.py + entrypoint: python docker/studio-dev/entrypoint.py command: pnpm run devserver ports: - "8080:8080" diff --git a/docker/Dockerfile.nginx.prod b/docker/Dockerfile.nginx.prod index a01753b143..ba33210a28 100644 --- a/docker/Dockerfile.nginx.prod +++ b/docker/Dockerfile.nginx.prod @@ -1,7 +1,7 @@ FROM nginx:1.25 # Build from inside the directory by overriding this. -ARG SRC_DIR=docker/prod/nginx +ARG SRC_DIR=docker/nginx RUN rm /etc/nginx/conf.d/* # if there's stuff here, nginx won't read sites-enabled COPY ${SRC_DIR}/nginx.conf /etc/nginx/nginx.conf diff --git a/docker/prod/nginx/entrypoint.sh b/docker/nginx/entrypoint.sh similarity index 100% rename from docker/prod/nginx/entrypoint.sh rename to docker/nginx/entrypoint.sh diff --git a/docker/prod/nginx/includes/README.md b/docker/nginx/includes/README.md similarity index 100% rename from docker/prod/nginx/includes/README.md rename to docker/nginx/includes/README.md diff --git a/docker/prod/nginx/includes/content/_proxy.conf b/docker/nginx/includes/content/_proxy.conf similarity index 100% rename from docker/prod/nginx/includes/content/_proxy.conf rename to docker/nginx/includes/content/_proxy.conf diff --git a/docker/prod/nginx/includes/content/default.conf b/docker/nginx/includes/content/default.conf similarity index 95% rename from docker/prod/nginx/includes/content/default.conf rename to docker/nginx/includes/content/default.conf index 44c005f21b..c2c95df613 100644 --- a/docker/prod/nginx/includes/content/default.conf +++ b/docker/nginx/includes/content/default.conf @@ -1,4 +1,4 @@ -# DO NOT RENAME: referenced by docker/prod/nginx/entrypoint.sh +# DO NOT RENAME: referenced by docker/nginx/entrypoint.sh # assume development location @emulator { diff --git a/docker/prod/nginx/includes/content/develop-studio-content.conf b/docker/nginx/includes/content/develop-studio-content.conf similarity index 100% rename from docker/prod/nginx/includes/content/develop-studio-content.conf rename to docker/nginx/includes/content/develop-studio-content.conf diff --git a/docker/prod/nginx/includes/content/studio-content.conf b/docker/nginx/includes/content/studio-content.conf similarity index 100% rename from docker/prod/nginx/includes/content/studio-content.conf rename to docker/nginx/includes/content/studio-content.conf diff --git a/docker/prod/nginx/mime.types b/docker/nginx/mime.types similarity index 100% rename from docker/prod/nginx/mime.types rename to docker/nginx/mime.types diff --git a/docker/prod/nginx/nginx.conf b/docker/nginx/nginx.conf similarity index 100% rename from docker/prod/nginx/nginx.conf rename to docker/nginx/nginx.conf diff --git a/docker/entrypoint.py b/docker/studio-dev/entrypoint.py similarity index 100% rename from docker/entrypoint.py rename to docker/studio-dev/entrypoint.py From 0c39da8bc51064f40e3fe0f40fb07f74a6c9ac0e Mon Sep 17 00:00:00 2001 From: David Canas Date: Fri, 5 Dec 2025 13:59:38 -0800 Subject: [PATCH 10/13] Bringing in temporary symlinks for infra-side CD. Will delete after both have been updated. --- k8s/images/app/Dockerfile | 1 + k8s/images/nginx/Dockerfile | 1 + 2 files changed, 2 insertions(+) create mode 120000 k8s/images/app/Dockerfile create mode 120000 k8s/images/nginx/Dockerfile diff --git a/k8s/images/app/Dockerfile b/k8s/images/app/Dockerfile new file mode 120000 index 0000000000..4750df1fc3 --- /dev/null +++ b/k8s/images/app/Dockerfile @@ -0,0 +1 @@ +docker/Dockerfile.prod \ No newline at end of file diff --git a/k8s/images/nginx/Dockerfile b/k8s/images/nginx/Dockerfile new file mode 120000 index 0000000000..a4867c19b0 --- /dev/null +++ b/k8s/images/nginx/Dockerfile @@ -0,0 +1 @@ +docker/Dockerfile.nginx.prod \ No newline at end of file From 1394abac6f1a79620a83a015629de6cf4860f634 Mon Sep 17 00:00:00 2001 From: David Canas Date: Fri, 5 Dec 2025 14:33:24 -0800 Subject: [PATCH 11/13] Cleaning up more prober code. --- Makefile | 6 +----- docker-compose.yml | 11 ----------- 2 files changed, 1 insertion(+), 16 deletions(-) diff --git a/Makefile b/Makefile index 002d337323..dc1e70b51e 100644 --- a/Makefile +++ b/Makefile @@ -171,11 +171,7 @@ dcbuild: $(DOCKER_COMPOSE) build dcup: .docker/minio .docker/postgres - # run all services except for cloudprober - $(DOCKER_COMPOSE) up studio-app celery-worker - -dcup-cloudprober: .docker/minio .docker/postgres - # run all services including cloudprober + # run all services $(DOCKER_COMPOSE) up dcdown: diff --git a/docker-compose.yml b/docker-compose.yml index 9ec8d1c34f..68bb5e2500 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -83,17 +83,6 @@ services: redis: image: redis:6.0.9 - cloudprober: - <<: *studio-worker - working_dir: /src/deploy - entrypoint: "" - # sleep 30 seconds allowing some time for the studio app to start up - command: '/bin/bash -c "sleep 30 && /bin/cloudprober --config_file ./cloudprober.cfg"' - # wait until the main app and celery worker have started - depends_on: - - studio-app - - celery-worker - volumes: minio: From b50bdb94aee25c2b49726c31d2bc9be962873b78 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Oct 2025 00:04:02 +0000 Subject: [PATCH 12/13] Configure Celery for graceful shutdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements soft shutdown feature from Celery 5.5.3 to prevent task interruption during pod termination. Resolves #5000. Changes: - Add worker_soft_shutdown_timeout (28s) to Celery config in settings.py - Add REMAP_SIGTERM=SIGQUIT to K8s shared env vars to trigger soft shutdown When K8s sends SIGTERM during pod termination, workers will now: 1. Stop accepting new tasks 2. Continue processing current task for up to 28 seconds 3. Exit cleanly if task completes, or timeout after 28s 4. Allow K8s 2s buffer before 30s grace period expires 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- contentcuration/contentcuration/settings.py | 5 +++++ docker-compose.yml | 1 + 2 files changed, 6 insertions(+) diff --git a/contentcuration/contentcuration/settings.py b/contentcuration/contentcuration/settings.py index 0f18ed0131..285e7bef76 100644 --- a/contentcuration/contentcuration/settings.py +++ b/contentcuration/contentcuration/settings.py @@ -348,6 +348,11 @@ def gettext(s): "result_serializer": "json", "result_extended": True, "worker_send_task_events": True, + # Graceful shutdown: allow 28 seconds for tasks to complete before forced termination + # This is 2 seconds less than Kubernetes terminationGracePeriodSeconds (30s) + "worker_soft_shutdown_timeout": int( + os.getenv("CELERY_WORKER_SOFT_SHUTDOWN_TIMEOUT", "28") + ), } # When cleaning up orphan nodes, only clean up any that have been last modified diff --git a/docker-compose.yml b/docker-compose.yml index 68bb5e2500..719fc797ed 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,7 @@ x-studio-environment: CELERY_BROKER_ENDPOINT: redis CELERY_RESULT_BACKEND_ENDPOINT: redis CELERY_REDIS_PASSWORD: "" + REMAP_SIGTERM: "SIGQUIT" PROBER_STUDIO_BASE_URL: http://studio-app:8080/{path} x-studio-worker: From 8ca219b3d2d481bd5a061d05eeb88226bfd31802 Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Wed, 17 Dec 2025 15:13:21 -0800 Subject: [PATCH 13/13] Update paths for nginx Dockerfile in workflow --- .github/workflows/containerbuild.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/containerbuild.yml b/.github/workflows/containerbuild.yml index 68f1be456c..0056d99cb4 100644 --- a/.github/workflows/containerbuild.yml +++ b/.github/workflows/containerbuild.yml @@ -79,7 +79,7 @@ jobs: with: skip_after_successful_duplicate: false github_token: ${{ github.token }} - paths: '["docker/Dockerfile.nginx.prod", "nginx/*", ".github/workflows/containerbuild.yml"]' + paths: '["docker/Dockerfile.nginx.prod", "docker/nginx/*", ".github/workflows/containerbuild.yml"]' build_nginx: name: nginx - test build of nginx Docker image