diff --git a/valkey/playbook.yml b/valkey/playbook.yml index e1c68e3..455f32a 100755 --- a/valkey/playbook.yml +++ b/valkey/playbook.yml @@ -1,21 +1,32 @@ --- -- hosts: all - become: true +# Deploys Valkey as a rootless Podman Quadlet unit. +# +# Connects as the container user and runs everything as that user: the Quadlet +# units belong to it, so the connection's PAM session is what makes +# `systemctl --user` work without any environment juggling. +# +# gather_facts is required: podman_user and the home directory come from the +# user_id / user_dir facts. +# +# The rootless-Podman prerequisites (installing podman, enabling lingering, the +# low-port sysctl, creating the Quadlet directory) are deliberately NOT applied +# here. They are the shared task file at caddy/roles/podman/tasks/main.yaml, and +# a host that has already run caddy/playbook.yml has them: +# +# - name: Rootless Podman prerequisites +# ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/podman/tasks/main.yaml" +# +# The variables those tasks need are duplicated at the top of vars/default.yml, +# so this play stands on its own. +# +# Run with: ansible-playbook -u tanshu valkey/playbook.yml +- name: Deploy Valkey as a rootless Podman Quadlet + hosts: emily + gather_facts: true vars_files: - vars/default.yml - tasks: - - name: Pull default valkey image - docker_image: - name: "{{ container_image }}" - source: pull - force_source: yes - - - name: Create valkey container - docker_container: - name: "{{ container_name }}" - image: "{{ container_image }}" - state: started - restart_policy: "unless-stopped" - published_ports: - - 127.0.0.1:6379:6379 + roles: + # The base Valkey unit. It does NOT know which app networks it serves. + # Each service attaches itself via valkey/roles/attach. + - valkey diff --git a/valkey/readme.md b/valkey/readme.md index f6d7ecc..150c5a7 100644 --- a/valkey/readme.md +++ b/valkey/readme.md @@ -1,46 +1,264 @@ -# Docker on Ubuntu 18.04 +# Valkey (rootless Podman + Quadlet) -This playbook will install Docker an Ubuntu 18.04 machine, as explained in the guide on -[How to Use Ansible to Install and Set Up Docker on Ubuntu 18.04](https://www.digitalocean.com/community/tutorials/how-to-use-ansible-to-install-and-set-up-docker-on-ubuntu-18-04). -A number of containers will be created with the options specified in the `vars/default.yml` variable file. +Runs [Valkey](https://valkey.io/) (the Redis fork this repo already used) as a +**rootless Podman** container managed by a **systemd user unit** (Quadlet). +Replaces the `docker_container` playbook, which published `127.0.0.1:6379` and +kept its `/data` in an **anonymous Docker volume** — `docker volume ls` state +that every backup script in `~/bin/` missed entirely +([docs/podman-compat-audit.md §3.1](../docs/podman-compat-audit.md)). + +**Valkey is the base unit and knows nothing about its clients.** It does not list +any app network and does not need editing when a service is added or removed. +Each service attaches *itself*. That is the same design as +[Postgres](../postgres/readme.md), and the mirror image of +[Caddy](../caddy/readme.md). + +The name is split on purpose: the directory, container, unit and Podman network +are `valkey`, exactly as the Docker-era playbook had them, and only the *alias* +clients connect on is `redis`. Renaming the container would touch every app's +`REDIS_HOST` for no gain. + +## Layout + +``` +valkey/ +├── playbook.yml +├── vars/default.yml +└── roles/ + ├── valkey/ # the base valkey.container unit, data dir, handlers + └── attach/ # includable task file: a service attaches itself to Valkey +``` + +Everything is installed under `{{ quadlet_dir }}` +(`~/.config/containers/systemd/`). State lives under `{{ host_directory }}` +(`~/podman-data/valkey/`). + +## How services attach themselves + +Valkey joins the app network of every service that needs a cache. A service joins +by writing Quadlet *drop-ins* next to `valkey.container`: + +``` +~/.config/containers/systemd/ +├── valkey.container # base unit, no app networks +├── valkey.container.d/ # ← drop-ins, one per client service +│ ├── 10-network-aliases.conf # [Container] NetworkAlias=redis (shared) +│ ├── 50-nextcloud.conf # [Container] Network=nextcloud.network +│ └── 50-affine.conf +├── nextcloud.network +└── affine.network +``` + +A service playbook does both halves — its own network, then the attach: + +```yaml +- name: Register this service with Caddy + ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/register/tasks/main.yaml" + vars: + network_name: "{{ container_name }}" + +- name: Attach this service to Valkey + ansible.builtin.include_tasks: "{{ playbook_dir }}/../valkey/roles/attach/tasks/main.yaml" + vars: + network_name: "{{ container_name }}" +``` + +The play must also run the `podman` role (for `podman_user` and `quadlet_dir`) +and define a `Restart Valkey` handler — see +[roles/valkey/handlers/main.yaml](roles/valkey/handlers/main.yaml) for the exact +snippet. [nextcloud](../nextcloud/readme.md) is the first service to do this and +is the working example. + +Order matters in one direction only: the `attach` include needs +`.network` to already exist, which is why Caddy's `register` comes first. +It fails with a readable message if it does not, because a drop-in naming a +missing `.network` file makes Quadlet reject the *whole* valkey unit with an +error that never mentions the drop-in. + +### Names: `valkey` and `redis` + +Both resolve on every attached network. `valkey` comes from +`ContainerName=valkey`; `redis` is added by `10-network-aliases.conf`, because +the clients are not consistent: Nextcloud's `config.php` (written by the image +from `REDIS_HOST=redis`), outline and overleaf connect to `redis`, while +paperless-ngx and yamtrack connect to `valkey` — the container name — because +that is the host their `REDIS_*` URL already had under Docker. + +`NetworkAlias=` applies to *every* network the container joins, which is exactly +what a hub wants, and it lives in one shared drop-in with a fixed value for the +same reasons as Postgres's `db` alias — see +[postgres/readme.md](../postgres/readme.md), "Names: `postgres` and `db`". + +### One way to attach, and one rule + +[roles/attach](roles/attach/tasks/main.yaml) is the shared include, and is what +every service uses. A service may instead write the same two files from its own +role, but then it **must write identical bytes** on purpose: the drop-ins live in +one directory, so anything less means whichever play runs last rewrites the +other's file on every run. If you add a second form, make it match. + +Whichever form you use, **flush handlers before the app starts on a first +attach.** The restart that joins Valkey to the new network is a handler, so by +default it runs at the end of the play — after the app has already come up and +failed to resolve `redis`. `nextcloud/roles/valkey` is the working example. + +### The trade-off + +A new drop-in changes the generated unit, so Valkey is **recreated** to join the +network. That is cheaper here than Postgres's restart: the recreate is a graceful +stop, valkey-server writes a final `dump.rdb` on SIGTERM, and the new container +loads it back from the bind mount. Attached apps keep their keys across the +restart — what they lose is the seconds the container is down. Only a hard kill +or a power loss costs the changes since the last save point. + +Persistence is on **deliberately**, and `host_directory` stays even though +everything in it is disposable. Two clients do not use Valkey as a pure cache: +Nextcloud keeps its PHP sessions and `memcache.locking` there, so an eviction +logs every active user out and drops their file locks, and Yamtrack uses it as +its Celery broker, so an eviction discards queued tasks. The stateless +alternative — `Tmpfs=/data` with `save ""` — is the right shape for a cache +and would take `UserNS=keep-id` and the whole uid/gid mapping with it, but it +would make every redeploy exactly that logout and dropped queue. Not copying the +old anonymous volume (below) is a separate question: a cold start is survivable, +it is just not free. + +## Who runs as whom + +`UserNS=keep-id:uid=999,gid=1000`, which are the two ids the *server* writes as: +the image's entrypoint starts as root, `chown`s the owner of anything in its +WORKDIR that is not already `valkey`'s, and drops to the account with `setpriv`. +The uid is `999` — the same uid the old anonymous volume was owned by +([docs/podman-compat-audit.md §3.2](../docs/podman-compat-audit.md)), so the +mapping matches what was already on disk — but the gid is `1000`, not `999`: +Alpine already has a gid 999, which is why the image's Dockerfile creates the +`valkey` group with the next id. Mapping the uid alone would leave every file +`valkey-server` writes group-owned by a subgid. + +`keep-id` maps both ids onto `podman_user`, which means the data root — and +everything in it — is owned by `tanshu:tanshu` rather than by a subuid or a +subgid. `roles/valkey` creates `~/podman-data/valkey` up front for the same +reason Postgres's role creates its version directory: a directory created by +container-root under `keep-id` is stranded as e.g. `165536`, readable but not +removable by the host user. + +Like Caddy, `podman_user` is derived from the connection +(`ansible_facts['user_id']`) and the play carries no `become`. This playbook does +not run the podman role, so it has no guard against connecting as `root`; the +prerequisites are applied separately (see Running). ## Settings -- `create_containers`: number of containers to create. -- `default_container_name`: default name for new containers. -- `default_container_image`: default image for new containers. -- `default_container_command`: default command to run on new containers. +| Variable | Purpose | +|---|---| +| `podman_user`, `podman_data_dir`, `quadlet_dir` | Rootless-Podman contract, duplicated from [caddy/vars/default.yml](../caddy/vars/default.yml) | +| `container_name` | Container / unit name, and the prefix of the drop-in directory | +| `image` | `docker.io/valkey/valkey:alpine`, fully qualified on purpose | +| `app_uid` / `app_gid` | The ids the server writes as — `999` / `1000` in the Alpine image | +| `host_directory` | Where state lives (`~/podman-data/valkey`) | +| `data_mount` | The image's WORKDIR and VOLUME — `/data` | +| `container_port` | `6379`, the port inside the container clients connect to | +| `valkey_dropin_dir` | Where services attach their networks | +`-alpine` floats within the image's tag for security patches; there is no pinned +digest, the same as the other service playbooks. -## Running this Playbook +### No published port -Quick Steps: +There is deliberately no `PublishPort`. The Docker-era playbook published +`127.0.0.1:6379:6379`, which meant anything that could reach the loopback +interface on the host could read and flush every app's cache. Clients reach it +over the app networks now, so there is no host port at all. Verify with +`ss -tlnp | grep 6379` — it should print nothing. -### 1. Obtain the playbook -```shell -git clone https://github.com/do-community/ansible-playbooks.git -cd ansible-playbooks/docker_ubuntu1804 -``` +## Running -### 2. Customize Options - -```shell -nano vars/default.yml -``` - -```yml -#vars/default.yml ---- -create_containers: 4 -default_container_name: docker -default_container_image: ubuntu -default_container_command: sleep 1d -``` - -### 3. Run the Playbook +The play targets `emily` directly (`hosts: emily`): ```command -ansible-playbook -l [target] -i [inventory file] -u [remote user] playbook.yml +ansible-playbook -u tanshu valkey/playbook.yml ``` -For more information on how to run this Ansible setup, please check this guide: [How to Use Ansible to Install and Set Up Docker on Ubuntu 18.04](https://www.digitalocean.com/community/tutorials/how-to-use-ansible-to-install-and-set-up-docker-on-ubuntu-18-04). \ No newline at end of file +The inventory that defines `emily` lives in `prepare-server/`, whose `ansible.cfg` +names it — run from there, or pass an inventory explicitly. + +This play applies **only** the Valkey unit and its data directory. The +rootless-Podman prerequisites — installing podman, `loginctl enable-linger`, the +low-port sysctl and the Quadlet directory — are the shared task file at +[caddy/roles/podman/tasks/main.yaml](../caddy/roles/podman/tasks/main.yaml), and +are not run here. A host that has already run `caddy/playbook.yml` has them. + +`systemctl --user enable valkey` is neither needed nor possible: the unit is +*generated*, and Quadlet applies `[Install] WantedBy=default.target` itself. + +**Deploy this before any service that attaches to it.** The attach include only +writes drop-ins next to `valkey.container`; if the unit has never been deployed, +the first `Restart Valkey` handler fails against a unit that does not exist. The +prerequisite order is `caddy` → `postgres` → `valkey` → the service. + +## Migrating from the Docker version + +There is **nothing to migrate.** The Docker-era Valkey kept `/data` in an +anonymous volume — the one the audit flagged because no backup script could see +it — and everything in it is disposable: it holds the RDB snapshot and, if it was +ever enabled, the AOF. Starting empty costs one cold cache, with the same +user-visible edges as any eviction ([The trade-off](#the-trade-off)): Nextcloud +sessions gone and queued Celery tasks dropped. Nothing that has to be carried +across. + +That is the opposite of `hiwikijs`, the other anonymous volume, whose content is +irreplaceable ([docs/podman-compat-audit.md §3.1](../docs/podman-compat-audit.md)). + +```bash +# Optional: look at what the old container had, before Docker goes away. +ssh monoco 'docker inspect valkey --format "{{range .Mounts}}{{.Name}} {{.Destination}}{{end}}"' +ssh monoco 'docker volume ls' + +# Nothing is copied to emily. The volume is abandoned deliberately, and can be +# removed once the old host is being decommissioned: +# ssh monoco 'docker volume rm ' +``` + +Do **not** start the old Valkey container again once apps are on `emily`: it would +serve a second, divergent cache. + +## Operational notes + +``` +systemctl --user status valkey +systemctl --user restart valkey +journalctl --user -u valkey -f +podman logs valkey +podman exec -it valkey valkey-cli +podman exec valkey valkey-cli ping +podman exec valkey valkey-cli info keyspace +podman inspect valkey --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' +``` + +The play waits on `valkey-cli ping` inside the container rather than trusting +systemd. `Restart=always` means the unit reports `active` even while the +container crash-loops, so a wrong `keep-id` (an entrypoint permission error, over +and over) would otherwise look like success. + +### Debugging a unit that will not generate + +``` +/usr/lib/systemd/user-generators/podman-user-generator --user --dryrun +systemd-analyze --user --generators=true verify valkey.service +``` + +The most common cause is a `Network=` naming a `.network` file that does not +exist — which is why `attach` checks first. + +## Verification + +- [ ] `systemctl --user status valkey` is active, and stays active after a reboot +- [ ] `podman exec valkey valkey-cli ping` prints `PONG` +- [ ] `ss -tlnp | grep 6379` on the host prints nothing +- [ ] `ls -ln ~/podman-data/valkey` shows `tanshu` as owner **and** group, not a + `165536+` subuid/subgid +- [ ] From each client app's container: `getent hosts valkey` and `getent hosts redis` +- [ ] Each attaching app can read **and** write through the cache — in Nextcloud + that means an upload, which exercises `memcache.locking` and the PHP session + handler the image configures from `REDIS_HOST` +- [ ] A `SAVE` produces `dump.rdb` under `~/podman-data/valkey`, owned by + `tanshu:tanshu` — the check that catches a wrong `app_gid` diff --git a/valkey/roles/attach/tasks/main.yaml b/valkey/roles/attach/tasks/main.yaml new file mode 100644 index 0000000..d8c37e9 --- /dev/null +++ b/valkey/roles/attach/tasks/main.yaml @@ -0,0 +1,94 @@ +--- +# Attach a service's network to Valkey. +# +# Valkey is the *base* unit and knows nothing about its clients, the same way +# Caddy knows nothing about its upstreams. A service that needs a cache adds +# itself by dropping files next to valkey.container, and Quadlet merges them in. +# +# Include this from a service playbook: +# +# - name: Attach this service to Valkey +# ansible.builtin.include_tasks: "{{ playbook_dir }}/../valkey/roles/attach/tasks/main.yaml" +# vars: +# network_name: "{{ network_name }}" +# +# The including play must: +# +# 1. have created {{ network_name }}.network already — caddy/roles/register +# does that, and every service here that needs a cache is also behind +# Caddy, so its register include comes first; +# 2. run the `podman` role (or include its task file and define the same four +# vars), for podman_user and quadlet_dir; +# 3. define a "Restart Valkey" handler — see +# roles/valkey/handlers/main.yaml for the exact snippet; and +# 4. flush handlers before starting the app, on a first attach. This include +# notifies that restart, so by default it runs at the end of the play — +# after the app has already started and failed to reach `redis`. A +# `meta: flush_handlers` immediately after this include is enough; see +# nextcloud/roles/valkey for the working example. +# +# It runs as the connection user, which must be the rootless podman_user. +# +# A service may instead write these two files from its own role; the bytes have +# to match this file exactly, or the two will overwrite each other on every run. + +- name: "Check that the {{ network_name }} network unit exists" + ansible.builtin.stat: + path: "{{ quadlet_dir }}/{{ network_name }}.network" + register: valkey_attach_network + +# A drop-in naming a .network file that does not exist makes Quadlet fail to +# generate the whole valkey unit, with a message that does not mention the +# drop-in at all. Fail here instead, where the cause is obvious. +- name: "Fail if {{ network_name }} has no network unit" + ansible.builtin.assert: + that: valkey_attach_network.stat.exists + fail_msg: >- + {{ quadlet_dir }}/{{ network_name }}.network does not exist, so attaching + it to Valkey would break the valkey unit. Create the network first + (caddy/roles/register does it), then include this file. + +- name: Ensure the Valkey Quadlet drop-in directory exists + ansible.builtin.file: + path: "{{ quadlet_dir }}/{{ valkey_container | default('valkey') }}.container.d" + state: directory + owner: "{{ podman_user }}" + group: "{{ podman_user }}" + mode: '0755' + +- name: "Attach {{ network_name }} to Valkey" + ansible.builtin.copy: + dest: >- + {{ quadlet_dir }}/{{ valkey_container | default('valkey') }}.container.d/50-{{ network_name }}.conf + owner: "{{ podman_user }}" + group: "{{ podman_user }}" + mode: '0644' + content: | + # Added by the {{ network_name }} playbook. Do not edit by hand. + # + # Deleting this file removes the network from {{ valkey_container | default('valkey') }} on + # the next daemon-reload; nothing else changes. + [Container] + Network={{ network_name }}.network + notify: Restart Valkey + +# NetworkAlias= applies to every network the container joins, which is exactly +# right for a hub: this repo's cache clients reach Valkey as `redis` (nextcloud, +# which writes REDIS_HOST=redis into its config, plus outline and overleaf), +# while the rest use the container name. It is a fixed value in one shared file, +# not a per-service option, so the same bytes are written no matter how many +# services attach — two services writing their own aliases would make Quadlet +# pass --network-alias twice. +- name: "Give {{ valkey_container | default('valkey') }} the shared 'redis' alias" + ansible.builtin.copy: + dest: >- + {{ quadlet_dir }}/{{ valkey_container | default('valkey') }}.container.d/10-network-aliases.conf + owner: "{{ podman_user }}" + group: "{{ podman_user }}" + mode: '0644' + content: | + # Shared container aliases for {{ valkey_container | default('valkey') }}. + # Managed by Ansible (the service playbooks that use it). Do not edit. + [Container] + NetworkAlias=redis + notify: Restart Valkey diff --git a/valkey/roles/valkey/handlers/main.yaml b/valkey/roles/valkey/handlers/main.yaml new file mode 100644 index 0000000..3324657 --- /dev/null +++ b/valkey/roles/valkey/handlers/main.yaml @@ -0,0 +1,35 @@ +--- +# All handlers run as the connection user ({{ podman_user }}), so systemctl +# --user works without setting XDG_RUNTIME_DIR. + +- name: Reload systemd user units + ansible.builtin.command: systemctl --user daemon-reload + changed_when: true + listen: Reload systemd user units + +- name: Restart Valkey + ansible.builtin.systemd_service: + name: "{{ unit_name }}" + scope: user + state: restarted + daemon_reload: true + listen: Restart Valkey + +# Reference implementation for service playbooks. A service that attaches its +# network with valkey/roles/attach must notify "Restart Valkey", and defines its +# own copy of this handler, e.g.: +# +# - name: Restart Valkey +# ansible.builtin.systemd_service: +# name: valkey.service +# scope: user +# state: restarted +# daemon_reload: true +# listen: Restart Valkey +# +# A new drop-in changes the generated unit, so Valkey has to be recreated to join +# the network — Quadlet has no live equivalent of `podman network connect`. +# +# The cost is different from Postgres's: Valkey is a cache, so every attached app +# sees a cold cache rather than a period without its database. Correctness is +# unaffected; the first requests after the restart are simply slower. diff --git a/valkey/roles/valkey/tasks/main.yaml b/valkey/roles/valkey/tasks/main.yaml new file mode 100644 index 0000000..f3aaf38 --- /dev/null +++ b/valkey/roles/valkey/tasks/main.yaml @@ -0,0 +1,128 @@ +--- +# Start Valkey +# +# Everything here runs as {{ podman_user }} — the connection user. No become, no +# XDG_RUNTIME_DIR: SSH gave us a real PAM session, so systemd's user manager is +# reachable via the standard environment. + +# Created here rather than left to the container's entrypoint. That entrypoint +# runs as container-root, which under keep-id is a subuid on the host, so a +# directory it created would be owned by e.g. 165536 and the host user could not +# `rm -rf` it without `podman unshare`. Creating it up front keeps everything in +# the data root owned by {{ podman_user }}. +# +# Mode 0755 rather than Postgres's 0700: Valkey's entrypoint runs `find` in this +# directory as root before dropping to uid {{ app_uid }}, and nothing sensitive +# lives here — it is a cache. +- name: Ensure the Valkey data directory exists + ansible.builtin.file: + path: "{{ host_directory }}" + state: directory + owner: "{{ podman_user }}" + group: "{{ podman_user }}" + mode: '0755' + +- name: Ensure the Quadlet drop-in directory exists + ansible.builtin.file: + path: "{{ valkey_dropin_dir }}" + state: directory + owner: "{{ podman_user }}" + group: "{{ podman_user }}" + mode: '0755' + +- name: Install the base Valkey Quadlet unit + ansible.builtin.template: + src: valkey.container.j2 + dest: "{{ quadlet_dir }}/{{ container_name }}.container" + owner: "{{ podman_user }}" + group: "{{ podman_user }}" + mode: '0644' + notify: + - Reload systemd user units + - Restart Valkey + +# The wait is part of the block on purpose. `Restart=always` means systemd +# reports the unit active even while the container crash-loops — a wrong keep-id +# shows up as an entrypoint permission error over and over — so is-active alone +# would call a broken cache healthy, and every app that uses it would fail later +# with a DNS or connection error that points nowhere near the cause. +- name: Start Valkey and wait for it to answer + block: + - name: Start Valkey + ansible.builtin.systemd_service: + name: "{{ unit_name }}" + scope: user + state: started + daemon_reload: true + + # valkey-cli is the server's own client and is guaranteed present, the same + # way pg_isready is in the Postgres image. + - name: Wait for Valkey to answer PING + ansible.builtin.command: "podman exec {{ container_name }} valkey-cli ping" + register: valkey_ping + until: valkey_ping.stdout == 'PONG' + retries: 12 + delay: 5 + changed_when: false + rescue: + # systemd reports "control process exited with error code" and nothing else, + # which is useless for the usual failures (a bad uid mapping, an unwritable + # data directory, a missing image). Pull the real reason out. + - name: Collect Valkey diagnostics + ansible.builtin.command: "{{ item }}" + loop: + - "systemctl --user status {{ unit_name }} --no-pager" + - "journalctl --user -u {{ unit_name }} --no-pager -n 40" + register: valkey_diag + changed_when: false + failed_when: false + + - name: Show Valkey diagnostics + ansible.builtin.debug: + msg: "{{ valkey_diag.results | map(attribute='stdout') | list }}" + + - name: Fail + ansible.builtin.fail: + msg: "Valkey did not come up — see the diagnostics above." + +# Quadlet honours [Install] WantedBy= when it generates the unit, so +# `systemctl --user enable` is neither needed nor possible on a generated unit. +- name: Verify the Valkey unit is active + ansible.builtin.command: "systemctl --user is-active {{ unit_name }}" + register: valkey_active + changed_when: false + failed_when: valkey_active.stdout != 'active' + +- name: List the services attached to Valkey + ansible.builtin.find: + paths: "{{ valkey_dropin_dir }}" + patterns: "*.conf" + register: valkey_dropins + +- name: Report the networks Valkey is attached to + ansible.builtin.command: >- + podman inspect {{ container_name }} + --format '{% raw %}{{ range $k, $v := .NetworkSettings.Networks }}{{ $k }} {{ end }}{% endraw %}' + register: valkey_networks_now + changed_when: false + failed_when: false + +- name: Show attached services + ansible.builtin.debug: + msg: >- + Valkey networks: + {{ valkey_networks_now.stdout | default('(container not running)') }}. + Attached by: + {{ valkey_dropins.files | map(attribute='path') | map('basename') | join(', ') + | default('(nothing yet)', true) }}. + +# Say this out loud rather than let it be discovered by a failing app: with no +# drop-in, Valkey has no Network= at all, so it sits on the default Podman +# network and no app network can resolve it. +- name: Note that no service has attached yet + ansible.builtin.debug: + msg: >- + No service has attached to Valkey. It is on the default Podman network + only, and no app can resolve it until a service playbook includes + valkey/roles/attach (see readme.md). + when: valkey_dropins.matched == 0 diff --git a/valkey/roles/valkey/templates/valkey.container.j2 b/valkey/roles/valkey/templates/valkey.container.j2 new file mode 100644 index 0000000..d8bada0 --- /dev/null +++ b/valkey/roles/valkey/templates/valkey.container.j2 @@ -0,0 +1,43 @@ +# Valkey — rootless Podman Quadlet unit. +# Managed by Ansible (valkey/roles/valkey). Do not edit by hand. +# +# Valkey deliberately does NOT list the app networks it serves. Each service +# attaches itself by dropping a file into: +# +# {{ valkey_dropin_dir }}/ +# +# which Quadlet merges into this unit. Removing a service therefore never means +# editing this file. See readme.md, "How services attach themselves". + +[Unit] +Description=Valkey in-memory data store +Documentation=https://valkey.io/docs/ +After=network-online.target +Wants=network-online.target + +[Container] +ContainerName={{ container_name }} +Image={{ image }} + +# Map the container's valkey account (uid {{ app_uid }}, gid {{ app_gid }}) onto +# {{ podman_user }} on the host, so {{ host_directory }} stays owned by +# {{ podman_user }} — owner *and* group — and never by a subuid or a subgid. +# The two ids differ because Alpine already has a gid 999; see vars/default.yml. +# The image's entrypoint runs as container-root, chowns the owner of anything in +# its WORKDIR that is not already valkey's, then drops to +# {{ app_uid }}:{{ app_gid }} — so everything it creates lands on +# {{ podman_user }} too. +UserNS=keep-id:uid={{ app_uid }},gid={{ app_gid }} + +# No PublishPort, on purpose. The Docker-era playbook published +# 127.0.0.1:6379; here Valkey is reachable only from the app networks that +# attach themselves to it, so there is no host port to firewall or to collide +# with a second cache. +Volume={{ host_directory }}:{{ data_mount }} + +[Service] +Restart=always +TimeoutStartSec=120 + +[Install] +WantedBy=default.target diff --git a/valkey/vars/default.yml b/valkey/vars/default.yml index 1fbb4e5..3893632 100644 --- a/valkey/vars/default.yml +++ b/valkey/vars/default.yml @@ -1,4 +1,58 @@ --- -container_name: valkey -container_image: valkey/valkey:alpine +# --------------------------------------------------------------------------- +# Rootless Podman / Quadlet +# --------------------------------------------------------------------------- +# Duplicated on purpose from caddy/vars/default.yml: a play cannot include that +# file wholesale, because it also sets `host_directory` — to the Caddy data +# directory — and every playbook defines that for itself. Keep these three in +# sync with caddy/vars/default.yml. (Its fourth, podman_unprivileged_port_start, +# belongs to the podman tasks, which this play does not run: Valkey publishes +# nothing host-side.) +# +# podman_user is derived from the connection: this play carries no become, so +# the Quadlet units belong to whoever Ansible connects as. Requires gather_facts +# (set in playbook.yml). +podman_user: "{{ ansible_facts['user_id'] }}" +podman_data_dir: "{{ ansible_facts['user_dir'] }}/podman-data" +quadlet_dir: "{{ ansible_facts['user_dir'] }}/.config/containers/systemd" +# --------------------------------------------------------------------------- +# Valkey +# --------------------------------------------------------------------------- +container_name: valkey +unit_name: "{{ container_name }}.service" + +# Fully qualified on purpose: rootless Podman resolves short names against +# unqualified-search-registries, and there is no TTY to answer a prompt when the +# systemd unit starts. The Docker-era playbook said `valkey/valkey:alpine`. +image: docker.io/valkey/valkey:alpine + +host_directory: "{{ podman_data_dir }}/{{ container_name }}" + +# The image's WORKDIR and VOLUME: the RDB snapshot and, if it is ever enabled, +# the AOF live here. The contents are disposable — nothing needs to be copied +# off monoco — but the mount is not: RDB persistence is on by default, and the +# graceful stop of a rebuild writes a final snapshot here that the next +# container loads. Keep it unless you also set `save ""`. See readme.md, "The +# trade-off". +data_mount: /data + +# The uid and gid of the image's `valkey` account. The uid is 999 — the one the +# anonymous Docker volume was owned by (docs/podman-compat-audit.md §3.2) — but +# the gid is *1000*, not 999: Alpine already has a gid 999, and the image's +# Dockerfile says so outright ("alpine already has a gid 999, so we'll use the +# next id") before dropping to the account with `setpriv --regid=valkey`. +# Mapping both ids onto podman_user is what keeps {{ host_directory }} — owner +# and group alike — with the connection user, instead of stranding the group on +# a subgid the moment valkey-server writes a snapshot. +app_uid: 999 +app_gid: 1000 + +# No PublishPort, on purpose: the Docker-era playbook published 127.0.0.1:6379, +# and nothing on the host needs it. Clients reach Valkey over the app networks +# that attach themselves; {{ container_port }} is the port inside the container. +container_port: 6379 + +# Quadlet drop-in directory for the Valkey unit. Services attach themselves +# here; see the "How services attach themselves" section of readme.md. +valkey_dropin_dir: "{{ quadlet_dir }}/{{ container_name }}.container.d"