Skip to content

Commit 197fb79

Browse files
committed
update plan
1 parent 3052716 commit 197fb79

1 file changed

Lines changed: 314 additions & 8 deletions

File tree

docs/plans/2026-04-17-uv-modernization.md

Lines changed: 314 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -219,14 +219,320 @@ uv run python manage.py runserver # start dev server
219219

220220
---
221221

222-
## Unknowns (need answers before starting)
223-
224-
- **`deploy/` folder and `scripts/`**: These use `pip install`, `virtualenv`,
225-
and fabric. Need to understand whether they're actively used in production
226-
and whether they need to be updated as part of this plan or can stay as-is
227-
temporarily. If production deploys depend on `requirements/common.pip`
228-
existing, we can't delete it in task 8 until the deploy path is updated too.
229-
**Waiting for feedback.**
222+
## Unknowns (resolved)
223+
224+
- **`deploy/` folder and `scripts/`**: Both retired. The `deploy/` fabric
225+
folder was deleted (commit `db7681ca`). The dead-since-2014/2018 helper
226+
scripts (`scripts/install_ubuntu`, `scripts/install_app`, `scripts/deploy`,
227+
`scripts/deploy_replication`, `scripts/create_database`,
228+
`scripts/db_backup_or_restore`) were also deleted as part of the migration
229+
commit (`51840982`).
230+
- **Production deploy path**: Identified — see Phase 2 below.
231+
232+
## Phase 1 status
233+
234+
Tasks 1–9 landed in commit `51840982 migrate to uv` on branch
235+
`uv-migration`. Three notable divergences from the original plan:
236+
237+
- **Task 3 (ruff)** kept `line-length = 88` and `select = ["E4","E7","E9","F"]`
238+
— the ruleset that was actually enforced by the old `ruff.toml`. The
239+
plan's aspirational `line-length = 79` + broad `["E","F","W"]` would have
240+
introduced 78 violations across 40 files; tightening the ruleset is left
241+
as a separate effort.
242+
- **Task 1 deps**: `xhtml2pdf` bumped `0.2.11``0.2.17` (compat-required —
243+
old pin pulls `reportlab==3.6.13` which can't build wheels on py3.12).
244+
- **`deploy` dependency group skipped**: fabric was retired with the
245+
`deploy/` folder, so no `[dependency-groups] deploy = ["fabric"]` was added.
246+
247+
Follow-up (post-merge): three Dockerfile/.dockerignore tightenings landed in
248+
a separate commit (COPY ordering bug, missing `.dockerignore`,
249+
`.python-version` made trackable).
250+
251+
---
252+
253+
## Phase 2 — Production deploy: add `uv` mode to `onaio.django` ansible role
254+
255+
> **For Claude:** Implement task-by-task. Each task lands as its own commit
256+
> in its own repo. Repos: `onaio/ansible-django` (R1),
257+
> `onaio/ansible-tally-ho` (R2), `onaio/infrastructure` (R3).
258+
259+
**Goal:** Production deploys (orchestrated from `infrastructure/ansible/tally_ho.yml`)
260+
must install dependencies via `uv sync --frozen --no-dev` against
261+
`pyproject.toml` + `uv.lock`, instead of `pip install -r requirements/dev.pip`
262+
(which no longer exists).
263+
264+
**Why:** Phase 1 deleted `requirements/*.pip`. The downstream
265+
`onaio.ansible-django` role (consumed by `onaio.ansible-tally-ho`, consumed
266+
by `onaio/infrastructure`) still expects `pip install -r {{ requirements }}`.
267+
Next deploy will fail at the install step.
268+
269+
**Approach:** Add a new opt-in install mode `django_use_uv` to
270+
`ansible-django` (joining the existing `django_use_regular_old_pip`,
271+
`django_use_pipenv`, `django_use_poetry` modes). Off by default — no
272+
behavior change for existing consumers. `tally-ho` opts in via
273+
`ansible-tally-ho`'s `meta/main.yml`. `infrastructure` bumps the submodule.
274+
275+
**Scope notes:**
276+
277+
- `ansible-django` is a shared role — many consumers. The new mode is
278+
off-by-default; existing pip/pipenv/poetry paths are untouched.
279+
- The role already installs `django_pip_packages` (uwsgi, celery,
280+
django-debug-toolbar, …) via a separate pip task (line 228 of
281+
`tasks/install.yml`) that runs **regardless** of which install mode is
282+
selected and targets `django_venv_path`. The new uv task only needs to
283+
handle the lockfile install — the existing extras task continues to work
284+
as-is, installing into the uv-managed venv.
285+
- `onaio/ansible-django` will get an external review.
286+
287+
### Repo 1: `ansible-django` — add `django_use_uv` mode
288+
289+
#### Task 10: Add `django_use_uv` defaults
290+
291+
**Files:**
292+
293+
- Modify: `defaults/main.yml`
294+
295+
**What:** Add the new flag and its parameters alongside the existing mode flags:
296+
297+
```yaml
298+
## uv (Astral)
299+
django_use_uv: false
300+
# Version of uv to install on the host. "latest" or a pinned version like "0.6.10".
301+
django_uv_version: "latest"
302+
# Path inside django_checkout_path where pyproject.toml + uv.lock live.
303+
# Defaults to the checkout root.
304+
django_uv_project_dir: "{{ django_checkout_path }}"
305+
# Sync flags. Default mirrors what we want for prod: frozen lock, no dev deps.
306+
django_uv_sync_args: "--frozen --no-dev"
307+
```
308+
309+
**Verify:** `ansible-lint` clean.
310+
311+
---
312+
313+
#### Task 11: Install uv on the host
314+
315+
**Files:**
316+
317+
- Modify: `tasks/python.yml`
318+
319+
**What:** When `django_use_uv` is true, install uv via the official
320+
installer script (idempotent — re-running upgrades). Install per-host
321+
(not per-user) so it's usable across rebuilds.
322+
323+
```yaml
324+
- name: Install uv (Astral)
325+
ansible.builtin.shell: |
326+
set -e
327+
curl -LsSf https://astral.sh/uv/{{ django_uv_version }}/install.sh \
328+
| env UV_INSTALL_DIR=/usr/local/bin sh
329+
args:
330+
creates: /usr/local/bin/uv
331+
become: true
332+
become_user: root
333+
when: django_use_uv | bool
334+
```
335+
336+
Note: the `creates:` argument keeps the task idempotent without using
337+
`changed_when: false` (which would mask real upgrades). Pinning a version
338+
makes the `creates:` check meaningful; `latest` will only install once
339+
unless `/usr/local/bin/uv` is removed.
340+
341+
**Verify:** `which uv && uv --version` on a converged host.
342+
343+
---
344+
345+
#### Task 12: Install Python packages via uv
346+
347+
**Files:**
348+
349+
- Modify: `tasks/install.yml`
350+
351+
**What:** Add a new task block, parallel to the existing `pipenv` /
352+
`poetry` / `regular_old_pip` blocks (around line 121–226). Order: place
353+
**after** the existing pip-based blocks but **before** the
354+
`django_pip_packages` install at line 228 — so uwsgi/celery/etc. install
355+
into the uv-created venv.
356+
357+
```yaml
358+
- name: Install Python packages using uv sync
359+
ansible.builtin.command:
360+
cmd: >-
361+
uv sync
362+
{{ django_uv_sync_args }}
363+
--python {{ django_python_version }}
364+
chdir: "{{ django_uv_project_dir }}"
365+
environment:
366+
UV_PROJECT_ENVIRONMENT: "{{ django_venv_path }}"
367+
UV_LINK_MODE: copy
368+
UV_COMPILE_BYTECODE: "1"
369+
become: true
370+
become_user: "{{ django_system_user }}"
371+
changed_when: true
372+
when:
373+
- django_use_uv | bool
374+
```
375+
376+
Why these env vars:
377+
378+
- `UV_PROJECT_ENVIRONMENT` directs uv to use the same venv path the rest of
379+
the role expects (`django_venv_path`), so uwsgi systemd units, the wsgi
380+
config, etc. don't change.
381+
- `UV_LINK_MODE=copy` avoids hardlink failures across mount points.
382+
- `UV_COMPILE_BYTECODE=1` matches the prod Dockerfile pattern.
383+
384+
`changed_when: true` is conservative — uv doesn't expose a clean
385+
"no-op" exit signal we can match on. Acceptable cost.
386+
387+
**Caveat to verify during molecule run:** the existing
388+
`Delete virtualenv` (line 25–31) runs only when
389+
`django_recreate_virtual_env` is true; the existing
390+
`Ensure required directories` (line 33–48) creates `django_venv_path` as
391+
an empty dir owned by `django_system_user`. uv is fine with creating its
392+
venv inside an empty existing dir, but molecule should confirm.
393+
394+
---
395+
396+
#### Task 13: Add `uv` molecule scenario
397+
398+
**Files:**
399+
400+
- Create: `molecule/uv/molecule.yml`
401+
- Create: `molecule/uv/converge.yml`
402+
- Create: `molecule/uv/prepare.yml` (if needed to seed a tiny pyproject)
403+
404+
**What:** Mirror the `default` scenario structure but converge against a
405+
minimal Django app whose dependencies are managed by uv. Use a tiny
406+
fixture project (committed under `tests/fixtures/sample-uv-project/`) with
407+
a `pyproject.toml` declaring `django>=5,<6` plus a trivial `manage.py`,
408+
so the molecule run actually exercises `uv sync` against a real lockfile.
409+
410+
The scenario sets:
411+
412+
```yaml
413+
django_use_uv: true
414+
django_use_regular_old_pip: false
415+
django_git_url: ... # clone the fixture repo (or set up locally)
416+
django_pip_packages: [] # keep extras list empty for the test
417+
```
418+
419+
Verify, via testinfra in the existing `tests/` directory, that:
420+
421+
- `/usr/local/bin/uv` exists and is executable.
422+
- `{{ django_venv_path }}/bin/python` is a working Python.
423+
- `{{ django_venv_path }}/bin/django-admin` exists (proving the lockfile
424+
install ran).
425+
426+
**Verify:** `molecule test -s uv` passes locally (in Docker).
427+
428+
---
429+
430+
#### Task 14: Document the new mode
431+
432+
**Files:**
433+
434+
- Modify: `README.md`
435+
436+
**What:** Add a short section "## uv (Astral) install mode" describing
437+
the flag, the env vars uv reads, and the constraint that the consuming
438+
project must have `pyproject.toml` + `uv.lock` checked in.
439+
440+
---
441+
442+
### Repo 2: `ansible-tally-ho` — opt in to `django_use_uv`
443+
444+
#### Task 15: Switch tally-ho's wiring to uv mode
445+
446+
**Files:**
447+
448+
- Modify: `meta/main.yml`
449+
- Modify: `defaults/main.yml`
450+
- Modify: `requirements.yml`
451+
452+
**Changes in `meta/main.yml` (under the `onaio.django` role vars):**
453+
454+
```diff
455+
- django_use_regular_old_pip: true
456+
- django_use_pipenv: false
457+
+ django_use_uv: true
458+
+ django_use_regular_old_pip: false
459+
+ django_use_pipenv: false
460+
...
461+
- django_pip_paths: "{{ tally_ho_requirements_paths }}"
462+
+ # django_pip_paths intentionally unset — uv mode reads pyproject.toml + uv.lock
463+
```
464+
465+
**Changes in `defaults/main.yml`:**
466+
467+
```diff
468+
-tally_ho_requirements_paths:
469+
- - "{{ tally_ho_django_checkout_path }}/requirements/dev.pip"
470+
-tally_ho_django_setuptools_version: "57.5.0"
471+
+# Removed — uv mode does not need these.
472+
```
473+
474+
Leave `tally_ho_django_pip_packages: [uwsgi, django-debug-toolbar]`
475+
intact — the django role still installs these into the venv via its
476+
mode-agnostic pip step. (Optionally drop `django-debug-toolbar` since it's
477+
already in the app's `[dependency-groups] dev`, but stage runs with
478+
`tally_ho_django_debug: True` and may want it; safer to leave for now.)
479+
480+
**Changes in `requirements.yml`:**
481+
482+
Pin the `ansible-django` role to a sha (or tag, once R1 cuts one) so
483+
deploys are reproducible:
484+
485+
```diff
486+
- src: git+https://github.com/onaio/ansible-django
487+
name: django
488+
- version: master
489+
+ version: <sha-of-the-merged-uv-mode-commit>
490+
```
491+
492+
**Verify:** Bump the sha after R1 merges. Run a stage deploy end-to-end.
493+
494+
---
495+
496+
### Repo 3: `infrastructure` — bump the submodule
497+
498+
#### Task 16: Bump submodule + redeploy stage
499+
500+
**Files:**
501+
502+
- `ansible/roles/tally-ho/` (submodule pointer)
503+
504+
**What:**
505+
506+
```bash
507+
cd ansible/roles/tally-ho
508+
git fetch origin
509+
git checkout <sha-of-Task-15-commit>
510+
cd -
511+
git add ansible/roles/tally-ho
512+
git commit -m "Bump ansible-tally-ho: uv install mode"
513+
```
514+
515+
No inventory changes needed — confirmed earlier sweep that no
516+
`group_vars`/`host_vars` under `inventories/tally-ho/{stage,production}/`
517+
override `tally_ho_requirements_paths`, `django_use_*`, or
518+
`tally_ho_django_setuptools_version`.
519+
520+
**Verify:** Run `ansible-playbook -i inventories/tally-ho/stage tally_ho.yml`.
521+
Watch for: uv install on host, `uv sync` in checkout, venv populated,
522+
uwsgi/celery binaries present in venv, systemd services come up.
523+
524+
---
525+
526+
### Phase 2 pre-flight callouts
527+
528+
1. **Stage's `tally_ho_django_git_version` is `"django-upgrade"`** (a branch);
529+
prod's is `"v3.1.5"` (a tag). Whichever ref the stage deploy hits must
530+
already contain `pyproject.toml` + `uv.lock` (from the `uv-migration`
531+
merge). Either rebase `django-upgrade` onto current master, or push a
532+
fresh tag and point stage at it before running Task 16's verify step.
533+
2. **`uwsgi`** still comes from `tally_ho_django_pip_packages`, not from
534+
project deps. The new `django_use_uv` mode in R1 must not break the
535+
existing extras-pip step. Tasks 12 + 13 explicitly preserve this.
230536

231537
## Out of scope
232538

0 commit comments

Comments
 (0)