Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,5 @@ prod-migrations/
.vscode
test.zip
.vs
node_modules/
*.log
82 changes: 82 additions & 0 deletions docs/superpowers/plans/2026-07-20-static-treatments-deploy-fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Static Treatments Deploy Fix Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Ensure admin-edited default treatments are exported into the shared static volume during deploy so the UI continues to read the latest treatments after a rollout.

**Architecture:** Keep the current browser flow intact: the frontend still loads `default-treatments.json` and merges it with the `/treatments` API response. Change only the deploy path so one init container runs `export_default` and then `collectstatic` in the same filesystem context, ensuring the generated JSON is copied into the shared static volume served by nginx. This avoids a backend API rewrite and keeps the fix limited to deployment plumbing.

**Tech Stack:** Django management commands, Helm init containers, Kubernetes persistent volumes, GitHub Actions Docker image build/deploy.

---

### Task 1: Verify the current deploy/data path

**Files:**
- Inspect: `infra/helm/qmra/templates/deployment.yaml`
- Inspect: `infra/helm/qmra/templates/volumes.yaml`
- Inspect: `infra/helm/qmra/templates/configmap.yaml`
- Inspect: `qmra/risk_assessment/admin.py`
- Inspect: `qmra/management/commands/export_default.py`

- [ ] **Step 1: Confirm where `export_default` writes**

Read `qmra/management/commands/export_default.py` and verify that `QMRATreatments.source` points at `qmra/static/data/default-treatments.json`.

- [ ] **Step 2: Confirm how the deploy mounts volumes**

Read `infra/helm/qmra/templates/deployment.yaml` and verify whether the `export-default` init container mounts the static PVC or only the `qmra-default` PVC.

- [ ] **Step 3: Confirm the static serving path**

Read `infra/helm/qmra/templates/volumes.yaml` and confirm that the nginx static deployment serves `/static` from the shared static PVC.

### Task 2: Patch the deploy so the generated JSON survives into the static volume

**Files:**
- Modify: `infra/helm/qmra/templates/deployment.yaml`

- [ ] **Step 1: Combine export and collect into one init container**

Replace the separate `export-default` and `move-static` init containers with a single init container that mounts both shared volumes:

```yaml
volumeMounts:
- name: qmra-default
mountPath: {{ .Values.qmra_default.mount_path }}
- name: static
mountPath: {{ .Values.static.mount_path }}
```

and runs:

```yaml
command: [ sh, -c, "python manage.py export_default && python manage.py collectstatic --noinput" ]
```

This keeps the generated `qmra/static/data/default-treatments.json` visible to `collectstatic` before the init container exits.

### Task 3: Validate the manifest and deployment flow

**Files:**
- Inspect: `infra/helm/qmra/templates/deployment.yaml`
- Inspect: `.github/workflows/deploy.yaml`

- [ ] **Step 1: Render the Helm template**

Run a template render or equivalent manifest check and confirm the `export-default` init container now has both the `qmra-default` and `static` mounts.

- [ ] **Step 2: Confirm workflow behavior**

Read `.github/workflows/deploy.yaml` and verify the image deployment path still uses the repository snapshot plus the runtime `helm upgrade`, so the updated manifest will be applied on the next deploy.

- [ ] **Step 3: Sanity-check the change**

Confirm there are no other changes to the treatment read path. The UI should still:

```javascript
defaultTreatments = await fetch("{% static 'data/default-treatments.json' %}").then(resp => resp.json());
defaultTreatments = {...defaultTreatments, ...await fetch("{% url 'treatments' %}").then(resp => resp.json())}
```

That behavior is intentional and should remain unchanged for this fix.
13 changes: 3 additions & 10 deletions infra/helm/qmra/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -37,24 +37,17 @@ spec:
- name: qmra-default
mountPath: {{ .Values.qmra_default.mount_path }}
command: [ python, manage.py, migrate, --database, qmra ]
- name: export-default
- name: refresh-default-static
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
envFrom:
- configMapRef:
name: {{ .Values.configmap_name }}
volumeMounts:
- name: qmra-default
mountPath: {{ .Values.qmra_default.mount_path }}
command: [ python, manage.py, export_default ]
- name: move-static
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
envFrom:
- configMapRef:
name: {{ .Values.configmap_name }}
volumeMounts:
- name: static
mountPath: {{ .Values.static.mount_path }}
command: [ python, manage.py, collectstatic, --noinput ]
command: [ sh, -c, "python manage.py export_default && python manage.py collectstatic --noinput" ]
Comment thread
yaniv-kwb marked this conversation as resolved.
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
Expand Down Expand Up @@ -133,4 +126,4 @@ spec:
volumes:
- name: static
persistentVolumeClaim:
claimName: {{ include "app.fullname" . }}-static-files-pvc
claimName: {{ include "app.fullname" . }}-static-files-pvc
18 changes: 17 additions & 1 deletion qmra/risk_assessment/qmra_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
import dataclasses as dtc
import enum
import json
import os
from itertools import groupby
from pathlib import Path
from typing import Optional, Any

import numpy as np
Expand Down Expand Up @@ -69,11 +71,25 @@ def model(self) -> dtc.dataclass:
def primary_key(self) -> str:
pass

@classmethod
def _source_path(cls) -> Path:
static_root = os.getenv("STATIC_ROOT")
source_path = Path(cls.source)
if static_root:
try:
runtime_path = Path(static_root) / source_path.relative_to("qmra/static")
except ValueError:
runtime_path = None
else:
if runtime_path.exists():
return runtime_path
return source_path

@classproperty
def raw_data(cls) -> dict[str, dict[str, Any]]:
# because an admin can change this data while the app runs,
# we need _raw_data to be loaded dynamically...
with open(cls.source, "r") as f:
with open(cls._source_path(), "r") as f:
cls._raw_data = json.load(f)
return cls._raw_data

Expand Down
45 changes: 44 additions & 1 deletion qmra/risk_assessment/tests/test_static_entities.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import json
import os
import tempfile
from pathlib import Path
from unittest import TestCase
from assertpy import assert_that
from qmra.risk_assessment.qmra_models import PathogenGroup, QMRASource, QMRASources, QMRAPathogen, \
Expand Down Expand Up @@ -95,6 +99,45 @@ def test_choices(self):
assert_that(choices[0][0]).is_instance_of(str)
assert_that(choices[0][1]).is_instance_of(str)

def test_runtime_static_root_is_preferred_when_present(self):
under_test = QMRATreatments
original_static_root = os.environ.get("STATIC_ROOT")
original_raw_data = under_test._raw_data
with tempfile.TemporaryDirectory() as tmpdir:
runtime_file = Path(tmpdir) / "data" / "default-treatments.json"
runtime_file.parent.mkdir(parents=True, exist_ok=True)
runtime_file.write_text(
json.dumps({
"Runtime treatment": {
"id": 999,
"name": "Runtime treatment",
"group": "Filtration",
"description": "loaded from runtime static root",
"bacteria_min": 1.0,
"bacteria_max": 2.0,
"viruses_min": 1.0,
"viruses_max": 2.0,
"protozoa_min": 1.0,
"protozoa_max": 2.0,
"bacteria_references": [],
"viruses_references": [],
"protozoa_references": [],
}
}),
encoding="utf-8",
)
os.environ["STATIC_ROOT"] = tmpdir
under_test._raw_data = None

assert_that(under_test.raw_data).contains_key("Runtime treatment")
assert_that(under_test.raw_data).does_not_contain_key("UV/H2O2")

if original_static_root is None:
os.environ.pop("STATIC_ROOT", None)
else:
os.environ["STATIC_ROOT"] = original_static_root
under_test._raw_data = original_raw_data


class TestDefaultExposures(TestCase):
expected_length = 8
Expand All @@ -118,4 +161,4 @@ def test_choices(self):
# assert_that(len(choices)).is_equal_to(self.expected_length+2) # other, blank
assert_that(choices[0]).is_instance_of(tuple)
assert_that(choices[0][0]).is_instance_of(str)
assert_that(choices[0][1]).is_instance_of(str)
assert_that(choices[0][1]).is_instance_of(str)
Loading