f692b7ec7a
Adds a small × on each item that hides it from the page. State is
server-side at /output/hidden.json so the same hidden set follows
the user across devices (home, ipad, laptop, work). A "Hidden (N)"
tray at the bottom shows what's hidden on the current page with a
restore button per row; older hidden ids that aren't on this page
sit silently and continue to filter future editions that include
the same article.
Architecture change: news-digest-web swaps from nginx:alpine to a
FastAPI app on uvicorn, built from the same Dockerfile as the
worker. Same image, different command (`uvicorn web:app` overrides
the worker's cron entrypoint via compose). Drops one image dependency,
adds /api/{hidden,hide,restore}.
Item ids are stable 12-char sha1 prefixes (`reddit:<post_id>` /
`miniflux:<entry_id>`) computed in digest.py at render time and
emitted as `data-id` on each .item. The frontend reads /api/hidden
once on load, applies `is-hidden` to matching items, and POSTs
hide/restore on user interaction (optimistic, with rollback on
network error).
Storage: single JSON array at /output/hidden.json, atomic writes
via tempfile + rename, threading.Lock around the read-modify-write
inside the single uvicorn worker. No auth — the digest itself is
unauthenticated on LAN; same trust boundary applies.
Playbook also drops the DOCKER_BUILDKIT=0 fallback now that
ana-docker is on docker-ce 29, and adds three verify steps
(/api/hidden returns a JSON array, app.js is reachable, full
hide/restore round-trip with a synthetic id).
187 lines
6.9 KiB
YAML
187 lines
6.9 KiB
YAML
# Deploy news-digest (LLM-curated daily briefing) to ana-docker.
|
||
#
|
||
# Usage:
|
||
# scripts/elway ana-docker --playbook playbooks/deploy-news-digest.yaml
|
||
#
|
||
# Idempotent — every step is creates-/when-gated; rerun is safe.
|
||
#
|
||
# After first deploy, the .env still has CHANGE_ME for the Miniflux
|
||
# password. Edit it (see stacks/news-digest/README.md), then
|
||
# `docker compose up -d` again — the container will run the first
|
||
# digest at startup if /output is empty, so no need to wait for cron.
|
||
|
||
vars:
|
||
compose_dir: /opt/docker/compose/news-digest
|
||
output_dir: /opt/docker/data/news-digest
|
||
host_port: "8181"
|
||
|
||
steps:
|
||
# ── host-side dirs ──────────────────────────────────────────────────
|
||
|
||
- name: Ensure compose dir exists
|
||
shell: mkdir -p {{ compose_dir }}
|
||
creates: "{{ compose_dir }}"
|
||
|
||
- name: Ensure output dir exists (bind-mounted into both containers)
|
||
shell: mkdir -p {{ output_dir }}
|
||
creates: "{{ output_dir }}"
|
||
|
||
# ── deploy build context (compose, env, dockerfile, app, templates) ──
|
||
|
||
- name: Upload compose.yaml
|
||
upload:
|
||
src: stacks/news-digest/compose.yaml
|
||
dest: "{{ compose_dir }}/compose.yaml"
|
||
mode: "0644"
|
||
|
||
- name: Upload Dockerfile
|
||
upload:
|
||
src: stacks/news-digest/Dockerfile
|
||
dest: "{{ compose_dir }}/Dockerfile"
|
||
mode: "0644"
|
||
|
||
- name: Upload digest.py
|
||
upload:
|
||
src: stacks/news-digest/digest.py
|
||
dest: "{{ compose_dir }}/digest.py"
|
||
mode: "0644"
|
||
|
||
- name: Upload web.py (FastAPI for the web container)
|
||
upload:
|
||
src: stacks/news-digest/web.py
|
||
dest: "{{ compose_dir }}/web.py"
|
||
mode: "0644"
|
||
|
||
- name: Upload entrypoint.sh
|
||
upload:
|
||
src: stacks/news-digest/entrypoint.sh
|
||
dest: "{{ compose_dir }}/entrypoint.sh"
|
||
mode: "0755"
|
||
|
||
- name: Upload run-digest.sh
|
||
upload:
|
||
src: stacks/news-digest/run-digest.sh
|
||
dest: "{{ compose_dir }}/run-digest.sh"
|
||
mode: "0755"
|
||
|
||
- name: Upload crontab
|
||
upload:
|
||
src: stacks/news-digest/crontab
|
||
dest: "{{ compose_dir }}/crontab"
|
||
mode: "0644"
|
||
|
||
- name: Ensure templates dir exists
|
||
shell: mkdir -p {{ compose_dir }}/templates
|
||
creates: "{{ compose_dir }}/templates"
|
||
|
||
- name: Upload templates/digest.html.j2
|
||
upload:
|
||
src: stacks/news-digest/templates/digest.html.j2
|
||
dest: "{{ compose_dir }}/templates/digest.html.j2"
|
||
mode: "0644"
|
||
|
||
- name: Upload templates/style.css
|
||
upload:
|
||
src: stacks/news-digest/templates/style.css
|
||
dest: "{{ compose_dir }}/templates/style.css"
|
||
mode: "0644"
|
||
|
||
- name: Upload templates/favicon.svg
|
||
upload:
|
||
src: stacks/news-digest/templates/favicon.svg
|
||
dest: "{{ compose_dir }}/templates/favicon.svg"
|
||
mode: "0644"
|
||
|
||
- name: Upload templates/app.js (× button + hidden tray client)
|
||
upload:
|
||
src: stacks/news-digest/templates/app.js
|
||
dest: "{{ compose_dir }}/templates/app.js"
|
||
mode: "0644"
|
||
|
||
- name: Seed .env from template (only if absent)
|
||
upload:
|
||
src: stacks/news-digest/.env.example
|
||
dest: "{{ compose_dir }}/.env"
|
||
mode: "0644"
|
||
when: "[ ! -f {{ compose_dir }}/.env ]"
|
||
|
||
# ── build + bring up ────────────────────────────────────────────────
|
||
# The Dockerfile expects style.css to be inside the image too (it's
|
||
# copied via `COPY templates /app/templates`). nginx serves the
|
||
# generated index.html alongside its own copy of style.css from
|
||
# /output, so the worker writes a copy of style.css into /output too.
|
||
|
||
- name: docker compose build (~2-3 min first time)
|
||
# ana-docker is now on docker-ce 29 (post 2026-04-24 fleet upgrade)
|
||
# so BuildKit works natively — the old DOCKER_BUILDKIT=0 fallback is
|
||
# no longer needed. Strip BuildKit's progress UI lines for cleaner
|
||
# elway output.
|
||
shell: |
|
||
set -o pipefail
|
||
cd {{ compose_dir }} && docker compose build 2>&1 \
|
||
| grep -vE '^#[0-9]+ |^ => |^=> |Collecting|Downloading|Requirement|Using cached|Installing collected|Successfully (installed|built)|━'
|
||
|
||
- name: Pre-stage style.css + favicon.svg + app.js into /output
|
||
# The worker writes HTML that references "style.css", "favicon.svg",
|
||
# and "app.js" relative. None are generated dynamically; the web
|
||
# container serves whichever copy lands in /output. Copy all three
|
||
# from the templates dir at deploy-time.
|
||
shell: |
|
||
cp -f {{ compose_dir }}/templates/style.css {{ output_dir }}/style.css
|
||
cp -f {{ compose_dir }}/templates/favicon.svg {{ output_dir }}/favicon.svg
|
||
cp -f {{ compose_dir }}/templates/app.js {{ output_dir }}/app.js
|
||
|
||
- name: docker compose up -d (rebuild + recreate so the new web image lands)
|
||
shell: cd {{ compose_dir }} && docker compose up -d --build
|
||
|
||
- name: Wait for the web container to serve /
|
||
shell: |
|
||
for i in $(seq 1 30); do
|
||
curl -sf -o /dev/null --max-time 3 http://localhost:{{ host_port }}/ && exit 0
|
||
sleep 2
|
||
done
|
||
exit 1
|
||
changed_when: "false"
|
||
|
||
verify:
|
||
- name: web returns 200 on /
|
||
shell: curl -sf -o /dev/null http://localhost:{{ host_port }}/
|
||
changed_when: "false"
|
||
|
||
- name: Both containers running
|
||
shell: docker inspect news-digest-worker news-digest-web --format '{{.State.Status}}' | grep -c running | grep -q '^2$'
|
||
changed_when: "false"
|
||
|
||
- name: news-digest-web on traefik-net (homepage discovery)
|
||
shell: docker inspect news-digest-web --format '{{json .NetworkSettings.Networks}}' | grep -q traefik-net
|
||
changed_when: "false"
|
||
|
||
- name: /api/hidden returns a JSON array
|
||
shell: |
|
||
curl -sf --max-time 5 http://localhost:{{ host_port }}/api/hidden \
|
||
| python3 -c "import sys, json; d = json.load(sys.stdin); assert isinstance(d, list)"
|
||
changed_when: "false"
|
||
|
||
- name: app.js is reachable
|
||
shell: curl -sf -o /dev/null --max-time 5 http://localhost:{{ host_port }}/app.js
|
||
changed_when: "false"
|
||
|
||
- name: hide → /api/hidden contains it → restore → /api/hidden no longer contains it
|
||
# End-to-end smoke of the hide/restore round-trip without touching
|
||
# any real item id. Uses a synthetic id so we don't pollute state if
|
||
# the deploy runs against a live install.
|
||
shell: |
|
||
set -e
|
||
tid="smoke-$(date +%s)-$$"
|
||
curl -sf -X POST -H 'Content-Type: application/json' \
|
||
-d "{\"id\":\"${tid}\"}" \
|
||
http://localhost:{{ host_port }}/api/hide >/dev/null
|
||
curl -sf http://localhost:{{ host_port }}/api/hidden \
|
||
| python3 -c "import sys, json; assert '${tid}' in json.load(sys.stdin)"
|
||
curl -sf -X POST -H 'Content-Type: application/json' \
|
||
-d "{\"id\":\"${tid}\"}" \
|
||
http://localhost:{{ host_port }}/api/restore >/dev/null
|
||
curl -sf http://localhost:{{ host_port }}/api/hidden \
|
||
| python3 -c "import sys, json; assert '${tid}' not in json.load(sys.stdin)"
|
||
changed_when: "false"
|