@@ -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