diff --git a/.gitea/workflows/ci-images.yml b/.gitea/workflows/ci-images.yml new file mode 100644 index 0000000..4898516 --- /dev/null +++ b/.gitea/workflows/ci-images.yml @@ -0,0 +1,242 @@ +--- +# Builds the Gitea Actions job images and publishes them to the Gitea container +# registry, so the runner can re-pull one the nightly podman prune removed +# instead of waiting for a human to re-run `make deploy TAGS=gitea-actions`. +# +# Source of truth is ansible/roles/gitea-actions: files/Containerfile.* for the +# image contents, defaults/main.yml for the version pins and the registry path. +# This workflow reads those vars rather than repeating them. roles/gitea-actions +# then only pulls what lands here (gitea_ci_build_local is the escape hatch for +# seeding an empty namespace, since the job below runs *in* gitea-ci). +# +# `docker build` here talks to the gitea-runner user's rootless podman socket, +# mounted into every job container by roles/gitea-actions (config.yaml.j2), so +# the build happens in the same image store the runner pulls from and the layer +# cache survives between runs. That also means a build writes tags the live +# runner will use -- which is why pull requests build under a throwaway +# :pr- tag and delete it again. +name: CI Images + +on: + push: + branches: [master] + paths: + - ansible/roles/gitea-actions/files/Containerfile.* + - ansible/roles/gitea-actions/defaults/main.yml + - .gitea/workflows/ci-images.yml + pull_request: + branches: [master] + paths: + - ansible/roles/gitea-actions/files/Containerfile.* + - ansible/roles/gitea-actions/defaults/main.yml + - .gitea/workflows/ci-images.yml + workflow_dispatch: + inputs: + image: + description: Which image to rebuild + type: choice + options: [all, ci, espidf, platformio] + default: all + schedule: + # Weekly rebuild so base-image security updates land without a commit. + # Sunday 04:00, after the 02:00 podman prune has finished. + - cron: "0 4 * * 0" + +env: + DEFAULTS: ansible/roles/gitea-actions/defaults/main.yml + CONTEXT: ansible/roles/gitea-actions/files + REGISTRY: git.debyl.io + # Not a secret: the same namespace is in defaults/main.yml. It must be the + # owner of REGISTRY_TOKEN -- Gitea authorises a package push by the token's + # user, not by the path, so pushing to gitbot/ means logging in as gitbot. + REGISTRY_USER: gitbot + KEEP_LABEL: io.debyl.ci-base + +# One publisher at a time. Two runs pushing :latest concurrently would leave the +# registry holding whichever finished last, which need not be the newest commit. +concurrency: + group: ci-images + cancel-in-progress: false + +jobs: + plan: + name: Plan + runs-on: fedora + outputs: + matrix: ${{ steps.plan.outputs.matrix }} + any: ${{ steps.plan.outputs.any }} + steps: + - uses: actions/checkout@v4 + with: + # Full history so the change detection below can diff against the + # pushed-from commit / the PR base. + fetch-depth: 0 + + - name: Decide which images to build + id: plan + env: + EVENT: ${{ github.event_name }} + SELECTED: ${{ github.event.inputs.image }} + BEFORE: ${{ github.event.before }} + PR_BASE: ${{ github.event.pull_request.base.sha }} + run: | + set -euo pipefail + python3 - <<'PY' >> "$GITHUB_OUTPUT" + import json, os, subprocess, sys, yaml + + defaults = yaml.safe_load(open(os.environ["DEFAULTS"])) + ctx = os.environ["CONTEXT"] + reg, ns = os.environ["REGISTRY"], os.environ["REGISTRY_USER"] + + # Mirrors gitea_ci_images in defaults/main.yml. The tags are rebuilt + # from the same version vars the role interpolates, so a pin bump in + # that file moves the image tag here and in ansible together. + images = [ + { + "key": "ci", + "containerfile": "Containerfile.ci", + "tag": f"{reg}/{ns}/gitea-ci:latest", + "build_args": "", + }, + { + "key": "espidf", + "containerfile": "Containerfile.espidf", + "tag": f"{reg}/{ns}/gitea-ci-espidf:{defaults['esp_idf_version']}", + "build_args": f"ESP_IDF_VERSION={defaults['esp_idf_version']}", + }, + { + "key": "platformio", + "containerfile": "Containerfile.platformio", + "tag": f"{reg}/{ns}/gitea-ci-platformio:{defaults['pio_espressif32_version']}", + "build_args": ( + f"PLATFORMIO_CORE_VERSION={defaults['platformio_core_version']} " + f"PIO_ESPRESSIF32_VERSION={defaults['pio_espressif32_version']}" + ), + }, + ] + + event = os.environ["EVENT"] + + def changed_files(base): + """Paths touched since `base`, or None if the diff is not usable.""" + if not base or set(base) == {"0"}: + return None + try: + out = subprocess.run( + ["git", "diff", "--name-only", f"{base}...HEAD"], + capture_output=True, text=True, check=True, + ).stdout + except subprocess.CalledProcessError: + # Force push, shallow clone, first push of a branch: fall back + # to building everything rather than silently skipping a real + # change. + return None + return set(out.split()) + + if event == "workflow_dispatch": + selected = os.environ.get("SELECTED") or "all" + picked = images if selected == "all" else [i for i in images if i["key"] == selected] + elif event == "schedule": + picked = images + else: + base = os.environ["PR_BASE"] if event == "pull_request" else os.environ["BEFORE"] + touched = changed_files(base) + if touched is None: + picked = images + else: + # defaults/main.yml holds every pin, so a change there could + # retag any image; the workflow file itself changes how all of + # them are built. Either one rebuilds the lot. + wide = {os.environ["DEFAULTS"], ".gitea/workflows/ci-images.yml"} + if touched & wide: + picked = images + else: + picked = [i for i in images if f"{ctx}/{i['containerfile']}" in touched] + + print(f"matrix={json.dumps({'include': picked})}") + print(f"any={'true' if picked else 'false'}") + print("building: " + (", ".join(i["tag"] for i in picked) or "nothing"), file=sys.stderr) + PY + + build: + name: Build ${{ matrix.key }} + needs: plan + if: needs.plan.outputs.any == 'true' + runs-on: fedora + strategy: + # One image failing must not cancel the others: they are independent, and + # a half-published set is what this whole workflow exists to avoid. + fail-fast: false + matrix: ${{ fromJSON(needs.plan.outputs.matrix) }} + steps: + - uses: actions/checkout@v4 + + - name: Log in to the Gitea Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ env.REGISTRY_USER }} + password: ${{ secrets.REGISTRY_TOKEN }} + + # The build lands in the live runner's image store, and act_runner will + # not re-pull a tag it already has locally. Tagging a PR build with the + # real tag would therefore hand every later job on this host an unmerged + # image, so PRs get a throwaway tag that the cleanup step removes. + - name: Resolve build tag + id: tag + run: | + set -euo pipefail + if [ "${{ github.event_name }}" = "pull_request" ]; then + echo "image=${{ matrix.tag }}-pr${{ github.event.number }}" >> "$GITHUB_OUTPUT" + else + echo "image=${{ matrix.tag }}" >> "$GITHUB_OUTPUT" + fi + + - name: Build ${{ matrix.key }} + env: + IMAGE: ${{ steps.tag.outputs.image }} + BUILD_ARGS: ${{ matrix.build_args }} + run: | + set -euo pipefail + args=() + for a in $BUILD_ARGS; do args+=(--build-arg "$a"); done + # --pull so a scheduled run actually picks up a refreshed base image; + # without it an unchanged FROM line just hits the local layer cache. + docker build --pull \ + "${args[@]}" \ + -t "$IMAGE" \ + -f "$CONTEXT/${{ matrix.containerfile }}" \ + "$CONTEXT" + + - name: Verify the prune-exemption label survived the build + env: + IMAGE: ${{ steps.tag.outputs.image }} + run: | + set -euo pipefail + # roles/podman's nightly prune keeps an image only if it carries this + # label (podman_prune_ci_keep_label). Publishing one without it would + # quietly restore the nightly-deletion behaviour this replaced, and + # nothing would notice until CI failed on a Monday morning. + got=$(docker inspect -f "{{ index .Config.Labels \"$KEEP_LABEL\" }}" "$IMAGE") + test "$got" = "true" || { + echo "::error::$IMAGE is missing LABEL $KEEP_LABEL=true" + exit 1 + } + + - name: Push ${{ matrix.key }} + if: github.event_name != 'pull_request' + env: + IMAGE: ${{ steps.tag.outputs.image }} + run: | + set -euo pipefail + docker push "$IMAGE" + echo "Pushed: $IMAGE" + + # Always, including on failure: the throwaway tag carries the keep label, + # so the nightly prune will not reclaim it and a few skipped cleanups add + # up to gigabytes in the runner's store. + - name: Drop the pull-request image + if: always() && github.event_name == 'pull_request' + env: + IMAGE: ${{ steps.tag.outputs.image }} + run: docker rmi -f "$IMAGE" || true diff --git a/CLAUDE.md b/CLAUDE.md index 3745e8f..17e0608 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,6 +28,11 @@ The project uses Python virtualenv for dependency management: - Makefile automatically creates `.venv/` and installs dependencies - Vault password is sourced from password manager via `.pass.sh` +### Git Workflow +- Work directly on `master` - no feature branches and no pull requests in this repo. +- Commit to `master` and push to `origin/master` when asked; don't create a branch first. +- Pushing to `master` also triggers `.gitea/workflows/ci-images.yml` (see Gitea Actions CI images below), which only rebuilds images whose inputs changed. + ## Architecture ### Directory Structure @@ -45,6 +50,7 @@ ansible/ │ ├── ssl/ # Legacy SSL management (deprecated - Caddy handles certificates automatically) │ ├── github-actions/# CI/CD runner setup │ ├── labelprint/ # 4x6 label print proxy (Raspberry Pi, CUPS/TSPL) +│ ├── gitea-actions/ # Gitea Actions runners + CI job images (see its README) │ └── pihole/ # DNS filtering └── vars/ └── vault.yml # Encrypted secrets @@ -90,6 +96,7 @@ Tasks are tagged by service/component for selective deployment: - `ddns` - Dynamic DNS tasks - ~~`drone` - CI/CD tasks (decommissioned)~~ - `hass` - Home Assistant tasks +- `gitea-actions` - Gitea Actions runners and their CI job images - Common infrastructure tags like `common`, `ssl` ## Configuration Files @@ -122,6 +129,17 @@ Tasks are tagged by service/component for selective deployment: - Falls back to its own rescue Wi-Fi AP at 192.168.4.1 when the home SSID is unreachable +### Gitea Actions CI images + +The runner's job images (`gitea-ci`, `gitea-ci-espidf`, `gitea-ci-platformio`) +are built by `.gitea/workflows/ci-images.yml` and published to the Gitea +container registry under `git.debyl.io/gitbot/`. The `gitea-actions` role only +pulls them - do NOT add build steps back to it. To change an image, edit +`ansible/roles/gitea-actions/files/Containerfile.*` (or a version pin in that +role's `defaults/main.yml`) and push to master; CI rebuilds only what changed. +See `ansible/roles/gitea-actions/README.md` for the registry rationale, the +prune-exemption label, and the bootstrap path when CI itself cannot build. + ### Remote SSH Commands for Service Users The `podman` user (and other service users) have `/bin/nologin` as their shell. To run commands as these users via SSH: diff --git a/Makefile b/Makefile index 63a478f..4c652c5 100644 --- a/Makefile +++ b/Makefile @@ -54,7 +54,9 @@ ${VAULT_FILE}: ${VAULT_PASS_FILE} touch $@ # Linting -YAML_FILES=$(shell find ansible/ -name '*.yml' -not -name '*vault*') +# .gitea/workflows is linted too: the CI-image workflow is as much part of the +# deployment as the roles it publishes for. +YAML_FILES=$(shell find ansible/ .gitea/ -name '*.yml' -not -name '*vault*') SKIP_FILE=./.lint-vars.sh # Targets diff --git a/ansible/roles/git/defaults/main.yml b/ansible/roles/git/defaults/main.yml index ac7f150..3ad16a8 100644 --- a/ansible/roles/git/defaults/main.yml +++ b/ansible/roles/git/defaults/main.yml @@ -4,9 +4,11 @@ git_home: "/srv/{{ git_user }}" # Gitea configuration gitea_debyl_server_name: git.debyl.io -gitea_image: docker.gitea.com/gitea:1.26.1 +# Pinned per instance so one can be upgraded (and verified) before the other. +gitea_debyl_image: docker.gitea.com/gitea:1.27.3 gitea_db_image: docker.io/library/postgres:14-alpine # Skudak Gitea configuration gitea_skudak_server_name: git.skudak.com gitea_skudak_ssh_port: 2222 +gitea_skudak_image: docker.gitea.com/gitea:1.27.3 diff --git a/ansible/roles/git/tasks/gitea-skudak.yml b/ansible/roles/git/tasks/gitea-skudak.yml index ce4b174..827f6c9 100644 --- a/ansible/roles/git/tasks/gitea-skudak.yml +++ b/ansible/roles/git/tasks/gitea-skudak.yml @@ -43,7 +43,7 @@ become_user: "{{ git_user }}" containers.podman.podman_container: name: gitea-skudak - image: "{{ gitea_image }}" + image: "{{ gitea_skudak_image }}" pod: gitea-skudak-pod restart_policy: on-failure:3 log_driver: journald diff --git a/ansible/roles/git/tasks/gitea.yml b/ansible/roles/git/tasks/gitea.yml index b0cc991..094d80c 100644 --- a/ansible/roles/git/tasks/gitea.yml +++ b/ansible/roles/git/tasks/gitea.yml @@ -10,7 +10,7 @@ state: started ports: - "3100:3000" - tags: gitea + tags: gitea, gitea-debyl # PostgreSQL container in pod - name: create gitea-debyl-postgres container @@ -28,7 +28,7 @@ POSTGRES_PASSWORD: "{{ gitea_debyl_db_pass }}" volumes: - "{{ git_home }}/volumes/gitea/psql:/var/lib/postgresql/data" - tags: gitea + tags: gitea, gitea-debyl # Gitea container in pod - name: create gitea-debyl container @@ -36,7 +36,7 @@ become_user: "{{ git_user }}" containers.podman.podman_container: name: gitea-debyl - image: "{{ gitea_image }}" + image: "{{ gitea_debyl_image }}" pod: gitea-debyl-pod restart_policy: on-failure:3 log_driver: journald @@ -66,7 +66,7 @@ volumes: - "{{ git_home }}/volumes/gitea/data:/data" - /etc/localtime:/etc/localtime:ro - tags: gitea + tags: gitea, gitea-debyl # Generate systemd service for the pod - name: create systemd job for gitea-debyl-pod @@ -80,7 +80,7 @@ args: chdir: "{{ git_home }}" changed_when: false - tags: gitea + tags: gitea, gitea-debyl - name: enable gitea-debyl-pod service become: true @@ -91,4 +91,4 @@ enabled: true state: started scope: user - tags: gitea + tags: gitea, gitea-debyl diff --git a/ansible/roles/gitea-actions/README.md b/ansible/roles/gitea-actions/README.md new file mode 100644 index 0000000..906d1eb --- /dev/null +++ b/ansible/roles/gitea-actions/README.md @@ -0,0 +1,91 @@ +# gitea-actions + +Runs the Gitea Actions runners on `home.debyl.io`. One `act_runner` process per +Gitea instance (`git.debyl.io`, `git.skudak.com`), both as the `gitea-runner` +user, both backed by the same rootless podman image store. + +## CI job images + +Jobs do not run on the host. Each one gets an ephemeral container from one of +three images: + +| `runs-on` / `container:` | Image | Used by | +| --- | --- | --- | +| `fedora`, `ubuntu-latest`, `ubuntu-22.04` | `git.debyl.io/gitbot/gitea-ci:latest` | Go / node / web jobs, `docker build` | +| `container: image:` | `git.debyl.io/gitbot/gitea-ci-espidf:` | esp-mg-tpms, skudak/esp32-stm32-vcu | +| `container: image:` | `git.debyl.io/gitbot/gitea-ci-platformio:` | skudak/esp32-web-interface | + +**This role does not build them.** `.gitea/workflows/ci-images.yml` builds +`files/Containerfile.*` and pushes to the Gitea registry; the role logs +`gitea-runner` in and pulls. Version pins live in `defaults/main.yml` and are +read by both the role and the workflow, so a bump moves the image tag in one +place. + +### Why the registry + +The images used to exist only as `localhost/gitea-ci*` in the runner's store. +The nightly prune (`roles/podman`, `podman_prune_ci_until: 48h`) deletes any +CI-user image older than that which no container holds, so after an idle +weekend every job failed in under a second on `docker pull +localhost/gitea-ci:latest`, and the only fix was re-running this role and +waiting out a full rebuild. + +Two things now keep that from happening: + +- **A registry copy.** `force_pull` stays `false`, which in act_runner means + *pull only when missing* — so a present image is never re-fetched, and a + pruned one is restored by the next job without anyone noticing. +- **A prune exemption.** Each Containerfile declares + `LABEL io.debyl.ci-base="true"`, and the prune skips that label + (`podman_prune_ci_keep_label`). Its `until` counts from build time, not pull + time, so without this a re-pulled image would be deleted again the same night + — a 7.8 GB ESP-IDF download every single day. + +The label is declared in the Containerfile rather than passed as `--label` so +neither builder can omit it; the workflow re-checks it with `docker inspect` +before pushing. + +### Authentication + +Both the role and act_runner read `/home/gitea-runner/.docker/config.json`. +act_runner uses it for the job-image pull it performs when a label's image is +missing; podman falls back to the same file. The role writes it from +`gitea_registry_username` / `gitea_registry_token` (vault), so one login covers +both. The `skudak` runner pulls from `git.debyl.io` too — same host, same user, +same file. + +The workflow pushes with a `REGISTRY_TOKEN` secret on `bastian/deploy_home`, +belonging to the same `gitbot` user: Gitea authorises a package push by the +token's owner, not by the path, so pushing to `gitbot/` means logging in as +`gitbot`. + +### Rebuilding + +Normally nothing to do — edit a `files/Containerfile.*` or a version pin, push +to `master`, and the workflow rebuilds only the affected images. It also +rebuilds everything weekly so base-image updates land without a commit, and +takes a `workflow_dispatch` with an image selector. + +Pull requests build but do not push, under a throwaway `:pr-` tag that is +deleted afterwards. The build runs in the live runner's image store, so a PR +tagged with the real name would hand every later job on this host an unmerged +image. + +### Bootstrap / CI is down + +The workflow that builds `gitea-ci` runs *in* `gitea-ci`, so a registry that has +never held it cannot bootstrap itself. Build on the host instead: + +```sh +make deploy TAGS=gitea-actions EXTRA_VARS="gitea_ci_build_local=true" +``` + +That builds all three from the same Containerfiles and pushes them. One run is +enough even on a cold registry: `tasks/main.yml` imports `images.yml` before +`runner.yml`, so the images are published before the runner labels are flipped +to point at them. + +The alternative first-time path is to merge the workflow and dispatch it while +the deployed labels still say `localhost/` — the job then builds inside the old +local image and seeds the registry — then run a plain +`make deploy TAGS=gitea-actions` to switch the labels over. diff --git a/ansible/roles/gitea-actions/defaults/main.yml b/ansible/roles/gitea-actions/defaults/main.yml index d182741..c958aab 100644 --- a/ansible/roles/gitea-actions/defaults/main.yml +++ b/ansible/roles/gitea-actions/defaults/main.yml @@ -22,20 +22,70 @@ act_runner_bin: /usr/local/bin/act_runner act_runner_config_dir: /etc/act_runner act_runner_work_dir: /var/lib/act_runner -# Job container images (built locally into the gitea-runner rootless image -# store by tasks/images.yml; never pulled — force_pull is false). -gitea_ci_image: localhost/gitea-ci:latest +# Job container images, served from the Gitea container registry. +# +# They used to live only under localhost/, built by this role. The nightly +# podman prune (roles/podman: podman_prune_ci_until) deletes any CI-user image +# older than 48h that no container is using, so every idle weekend CI failed in +# 0-1s on `docker pull localhost/gitea-ci:latest` until someone re-ran the role +# and waited out a full rebuild. +# +# Now .gitea/workflows/ci-images.yml builds them from files/Containerfile.* and +# pushes them here, and this role only pulls. A pruned image is re-pulled by the +# next job on its own (force_pull stays false, which means "pull only when +# missing", so a present image is never re-fetched). +# +# Workflows that pin `container: image:` must use these registry paths too +# (esp-mg-tpms, skudak/esp32-stm32-vcu, skudak/esp32-web-interface). +gitea_ci_registry: git.debyl.io +# Namespace = the owner of gitea_registry_username / gitea_registry_token (vault). +gitea_ci_registry_namespace: gitbot +gitea_ci_image: "{{ gitea_ci_registry }}/{{ gitea_ci_registry_namespace }}/gitea-ci:latest" # ESP-IDF firmware image tag tracks the upstream espressif/idf release we build from. esp_idf_version: v5.4.1 -gitea_ci_espidf_image: "localhost/gitea-ci-espidf:{{ esp_idf_version }}" +gitea_ci_espidf_image: "{{ gitea_ci_registry }}/{{ gitea_ci_registry_namespace }}/gitea-ci-espidf:{{ esp_idf_version }}" # PlatformIO image for Arduino-framework ESP32 builds (esp32-web-interface). # Tag tracks the pre-baked espressif32 platform version; both pins match the # hardware-validated local build. platformio_core_version: "6.1.19" pio_espressif32_version: "7.0.1" -gitea_ci_platformio_image: "localhost/gitea-ci-platformio:{{ pio_espressif32_version }}" +gitea_ci_platformio_image: "{{ gitea_ci_registry }}/{{ gitea_ci_registry_namespace }}/gitea-ci-platformio:{{ pio_espressif32_version }}" -# Default labels for every runner — map runs-on values to the local CI image. +# Registry credentials for the gitea-runner user. The Docker-format path is read +# by both act_runner (to authenticate job image pulls) and podman (as its +# fallback auth file), so one login covers the runner and this role. +gitea_ci_registry_authfile: "{{ gitea_runner_home }}/.docker/config.json" + +# The images this role keeps present on the runner. `build_args` is a literal +# podman-build argument string (podman_image has no structured build-arg +# option) and is only used by the gitea_ci_build_local fallback below -- the +# workflow passes the same --build-arg values, read out of the version vars +# above, so there is one source of truth for the pins. +gitea_ci_images: + - image: "{{ gitea_ci_image }}" + containerfile: Containerfile.ci + build_args: "" + - image: "{{ gitea_ci_espidf_image }}" + containerfile: Containerfile.espidf + build_args: "--build-arg ESP_IDF_VERSION={{ esp_idf_version }}" + - image: "{{ gitea_ci_platformio_image }}" + containerfile: Containerfile.platformio + build_args: >- + --build-arg PLATFORMIO_CORE_VERSION={{ platformio_core_version }} + --build-arg PIO_ESPRESSIF32_VERSION={{ pio_espressif32_version }} + +# Escape hatch: build the images on the host and push them, instead of pulling +# what CI published. Needed to seed a brand-new registry namespace, and when CI +# itself is down -- the workflow that builds gitea-ci runs *in* gitea-ci, so a +# registry that has never held it cannot bootstrap itself. +# +# make deploy TAGS=gitea-actions EXTRA_VARS="gitea_ci_build_local=true" +# +# Off by default: a plain deploy should never sit through a 15-minute ESP-IDF +# rebuild, and two publishers racing on the same tag is worth avoiding. +gitea_ci_build_local: false + +# Default labels for every runner — map runs-on values to the registry CI image. # Firmware jobs opt into the ESP-IDF image per-job via `container:` in their workflow. gitea_runner_labels: - "fedora:docker://{{ gitea_ci_image }}" diff --git a/ansible/roles/gitea-actions/templates/Containerfile.ci b/ansible/roles/gitea-actions/files/Containerfile.ci similarity index 75% rename from ansible/roles/gitea-actions/templates/Containerfile.ci rename to ansible/roles/gitea-actions/files/Containerfile.ci index 12f4440..3e3bd38 100644 --- a/ansible/roles/gitea-actions/templates/Containerfile.ci +++ b/ansible/roles/gitea-actions/files/Containerfile.ci @@ -1,8 +1,18 @@ # Default Gitea Actions job image (managed by ansible: roles/gitea-actions). # Covers Go/web/node jobs plus `docker build` (talks to the mounted rootless # podman socket). Go toolchains are provided per-job by actions/setup-go. +# +# Built and published by .gitea/workflows/ci-images.yml; roles/gitea-actions +# only pulls the result (see gitea_ci_build_local for the local-build fallback). +# A plain Containerfile, not a template, so CI and ansible build the same bytes. FROM node:20-bookworm-slim +# Exempts the image from the nightly CI prune -- see podman_prune_ci_keep_label +# in roles/podman/defaults/main.yml. Declared here rather than passed as a +# --label at build time so neither builder can forget it: without the label the +# prune deletes the image every night and the next job re-pulls a gigabyte. +LABEL io.debyl.ci-base="true" + ARG DOCKER_CLI_VERSION=27.3.1 RUN apt-get update && apt-get install -y --no-install-recommends \ diff --git a/ansible/roles/gitea-actions/templates/Containerfile.espidf.j2 b/ansible/roles/gitea-actions/files/Containerfile.espidf similarity index 69% rename from ansible/roles/gitea-actions/templates/Containerfile.espidf.j2 rename to ansible/roles/gitea-actions/files/Containerfile.espidf index 6a64850..15fc8fd 100644 --- a/ansible/roles/gitea-actions/templates/Containerfile.espidf.j2 +++ b/ansible/roles/gitea-actions/files/Containerfile.espidf @@ -14,7 +14,19 @@ # the release aborts *after* the firmware and version.json are already live — # clients get the new build while the tag, Gitea release and protocol manifest # are never written. Keep it installed. -FROM espressif/idf:{{ esp_idf_version }} +# +# Built and published by .gitea/workflows/ci-images.yml; roles/gitea-actions +# only pulls the result. ESP_IDF_VERSION is a build arg rather than an ansible +# template var so CI and ansible build the same bytes -- its value is read from +# esp_idf_version in roles/gitea-actions/defaults/main.yml by both. +ARG ESP_IDF_VERSION +FROM espressif/idf:${ESP_IDF_VERSION} + +# Exempts the image from the nightly CI prune -- see podman_prune_ci_keep_label +# in roles/podman/defaults/main.yml. Declared here rather than passed as a +# --label at build time so neither builder can forget it: without the label the +# prune deletes the image every night and the next job re-pulls 7.8 GB. +LABEL io.debyl.ci-base="true" RUN apt-get update && apt-get install -y --no-install-recommends \ curl ca-certificates unzip jq python3-yaml python3-jinja2 \ diff --git a/ansible/roles/gitea-actions/templates/Containerfile.platformio.j2 b/ansible/roles/gitea-actions/files/Containerfile.platformio similarity index 51% rename from ansible/roles/gitea-actions/templates/Containerfile.platformio.j2 rename to ansible/roles/gitea-actions/files/Containerfile.platformio index da21e24..fb548a3 100644 --- a/ansible/roles/gitea-actions/templates/Containerfile.platformio.j2 +++ b/ansible/roles/gitea-actions/files/Containerfile.platformio @@ -8,8 +8,23 @@ # was validated on hardware — bump pio_espressif32_version / # platformio_core_version in defaults/main.yml to upgrade (the image tag # tracks the platform version). +# +# Built and published by .gitea/workflows/ci-images.yml; roles/gitea-actions +# only pulls the result. The pins are build args rather than ansible template +# vars so CI and ansible build the same bytes -- their values are read from +# platformio_core_version / pio_espressif32_version in +# roles/gitea-actions/defaults/main.yml by both. FROM python:3.12-slim-bookworm +ARG PLATFORMIO_CORE_VERSION +ARG PIO_ESPRESSIF32_VERSION + +# Exempts the image from the nightly CI prune -- see podman_prune_ci_keep_label +# in roles/podman/defaults/main.yml. Declared here rather than passed as a +# --label at build time so neither builder can forget it: without the label the +# prune deletes the image every night and the next job re-pulls a gigabyte. +LABEL io.debyl.ci-base="true" + ENV PLATFORMIO_CORE_DIR=/opt/platformio RUN apt-get update && apt-get install -y --no-install-recommends \ @@ -18,12 +33,15 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && apt-get install -y --no-install-recommends nodejs \ && rm -rf /var/lib/apt/lists/* -RUN pip install --no-cache-dir platformio=={{ platformio_core_version }} +RUN pip install --no-cache-dir platformio==${PLATFORMIO_CORE_VERSION} # Seed project mirroring the real projects' platformio.ini so `pio pkg install` # pulls the platform + toolchain + framework packages into the core dir. +# %s + a quoted argument, not ${...} inside the single-quoted format string: +# RUN is `sh -c`, and sh does not expand inside single quotes, so an inlined +# ${PIO_ESPRESSIF32_VERSION} would be written to platformio.ini literally. RUN mkdir -p /tmp/seed/src \ - && printf '[env:seed]\nplatform = espressif32@{{ pio_espressif32_version }}\nframework = arduino\nboard = esp32dev\nboard_build.filesystem = spiffs\nplatform_packages = platformio/tool-esptoolpy\n' > /tmp/seed/platformio.ini \ + && printf '[env:seed]\nplatform = espressif32@%s\nframework = arduino\nboard = esp32dev\nboard_build.filesystem = spiffs\nplatform_packages = platformio/tool-esptoolpy\n' "${PIO_ESPRESSIF32_VERSION}" > /tmp/seed/platformio.ini \ && pio pkg install -d /tmp/seed \ && pio pkg install -d /tmp/seed --tool platformio/tool-mkspiffs \ && rm -rf /tmp/seed \ diff --git a/ansible/roles/gitea-actions/tasks/images.yml b/ansible/roles/gitea-actions/tasks/images.yml index e33b5e4..9f1f484 100644 --- a/ansible/roles/gitea-actions/tasks/images.yml +++ b/ansible/roles/gitea-actions/tasks/images.yml @@ -1,4 +1,51 @@ --- +# CI job images. .gitea/workflows/ci-images.yml builds files/Containerfile.* +# and pushes them to the Gitea registry; this role only logs the runner in and +# makes sure the images are present, so a plain deploy never waits on a build. +# +# Set gitea_ci_build_local=true to build and push from here instead -- see the +# comment on that variable in defaults/main.yml. +- name: create gitea-runner registry auth directory + become: true + become_user: "{{ gitea_runner_user }}" + ansible.builtin.file: + path: "{{ gitea_ci_registry_authfile | dirname }}" + state: directory + mode: "0700" + tags: gitea-actions + +# Docker-format path on purpose: act_runner reads ~/.docker/config.json to +# authenticate the job-image pull it does when a label's image is missing, and +# podman falls back to the same file. One login covers both. +- name: log gitea-runner in to the Gitea container registry + become: true + become_user: "{{ gitea_runner_user }}" + containers.podman.podman_login: + registry: "{{ gitea_ci_registry }}" + username: "{{ gitea_registry_username }}" + password: "{{ gitea_registry_token }}" + authfile: "{{ gitea_ci_registry_authfile }}" + environment: + XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}" + no_log: true + tags: gitea-actions + +- name: pull CI images from the registry + become: true + become_user: "{{ gitea_runner_user }}" + containers.podman.podman_image: + name: "{{ item.image }}" + auth_file: "{{ gitea_ci_registry_authfile }}" + environment: + XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}" + loop: "{{ gitea_ci_images }}" + loop_control: + label: "{{ item.image }}" + when: not (gitea_ci_build_local | bool) + tags: gitea-actions + +# --- local build fallback (gitea_ci_build_local=true) ------------------------ +# Only reached when seeding a new namespace or when CI cannot build for us. - name: create CI image build directory become: true become_user: "{{ gitea_runner_user }}" @@ -6,73 +53,41 @@ path: "{{ gitea_runner_home }}/ci-images" state: directory mode: "0755" + when: gitea_ci_build_local | bool tags: gitea-actions -- name: stage default CI Containerfile +# copy, not template: these are plain Containerfiles that CI builds verbatim. +# Versions come in as --build-arg from the same defaults/main.yml the workflow +# reads, so neither builder can drift from the other. +- name: stage CI Containerfiles become: true become_user: "{{ gitea_runner_user }}" - ansible.builtin.template: - src: Containerfile.ci - dest: "{{ gitea_runner_home }}/ci-images/Containerfile.ci" + ansible.builtin.copy: + src: "{{ item.containerfile }}" + dest: "{{ gitea_runner_home }}/ci-images/{{ item.containerfile }}" mode: "0644" - register: ci_containerfile + loop: "{{ gitea_ci_images }}" + loop_control: + label: "{{ item.containerfile }}" + when: gitea_ci_build_local | bool tags: gitea-actions -- name: stage ESP-IDF CI Containerfile - become: true - become_user: "{{ gitea_runner_user }}" - ansible.builtin.template: - src: Containerfile.espidf.j2 - dest: "{{ gitea_runner_home }}/ci-images/Containerfile.espidf" - mode: "0644" - register: espidf_containerfile - tags: gitea-actions - -- name: build default CI image ({{ gitea_ci_image }}) +- name: build and push CI images become: true become_user: "{{ gitea_runner_user }}" containers.podman.podman_image: - name: "{{ gitea_ci_image }}" + name: "{{ item.image }}" path: "{{ gitea_runner_home }}/ci-images" build: - file: "{{ gitea_runner_home }}/ci-images/Containerfile.ci" - force: "{{ ci_containerfile is changed }}" - environment: - XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}" - tags: gitea-actions - -- name: stage PlatformIO CI Containerfile - become: true - become_user: "{{ gitea_runner_user }}" - ansible.builtin.template: - src: Containerfile.platformio.j2 - dest: "{{ gitea_runner_home }}/ci-images/Containerfile.platformio" - mode: "0644" - register: platformio_containerfile - tags: gitea-actions - -- name: build ESP-IDF CI image ({{ gitea_ci_espidf_image }}) - become: true - become_user: "{{ gitea_runner_user }}" - containers.podman.podman_image: - name: "{{ gitea_ci_espidf_image }}" - path: "{{ gitea_runner_home }}/ci-images" - build: - file: "{{ gitea_runner_home }}/ci-images/Containerfile.espidf" - force: "{{ espidf_containerfile is changed }}" - environment: - XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}" - tags: gitea-actions - -- name: build PlatformIO CI image ({{ gitea_ci_platformio_image }}) - become: true - become_user: "{{ gitea_runner_user }}" - containers.podman.podman_image: - name: "{{ gitea_ci_platformio_image }}" - path: "{{ gitea_runner_home }}/ci-images" - build: - file: "{{ gitea_runner_home }}/ci-images/Containerfile.platformio" - force: "{{ platformio_containerfile is changed }}" + file: "{{ gitea_runner_home }}/ci-images/{{ item.containerfile }}" + extra_args: "{{ item.build_args }}" + force: true + push: true + auth_file: "{{ gitea_ci_registry_authfile }}" environment: XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}" + loop: "{{ gitea_ci_images }}" + loop_control: + label: "{{ item.image }}" + when: gitea_ci_build_local | bool tags: gitea-actions diff --git a/ansible/roles/podman/defaults/main.yml b/ansible/roles/podman/defaults/main.yml index 327d6d3..3d2f7cb 100644 --- a/ansible/roles/podman/defaults/main.yml +++ b/ansible/roles/podman/defaults/main.yml @@ -311,6 +311,14 @@ podman_prune_ci_users: - gitea-runner - actions-runner podman_prune_ci_until: 48h +# The CI base images declare LABEL io.debyl.ci-base="true" in +# roles/gitea-actions/files/Containerfile.* (and .gitea/workflows/ci-images.yml +# verifies it before publishing -- keep all three in sync). Images carrying it +# are skipped below: `until` counts from build time and not pull time, so +# without the exemption a re-pulled image would be deleted again the next +# night, a 7.8 GB ESP-IDF re-download after every idle day. Superseded tags +# (e.g. after an esp_idf_version bump) survive too; remove those by hand. +podman_prune_ci_keep_label: io.debyl.ci-base # Daily rather than weekly: CI turns over many images a day, and a week of that # is what let the store reach 113 GB between runs. diff --git a/ansible/roles/podman/files/hass/automations.yaml b/ansible/roles/podman/files/hass/automations.yaml index fabd5c6..aee6a0b 100644 --- a/ansible/roles/podman/files/hass/automations.yaml +++ b/ansible/roles/podman/files/hass/automations.yaml @@ -192,46 +192,53 @@ mode: single - id: '1762116115638' alias: Light - TV On - description: '' + description: TV mode on; once it's dark, dim the living room and turn off the + lights that glare on the TV triggers: - - type: turned_on - device_id: 18a9bb7a2a32be4371da447767ef50a9 - entity_id: c05688f2610e27e2d86380e2945ceae5 - domain: remote - trigger: device + - trigger: state + entity_id: remote.samsung_tv + to: 'on' + not_from: + - unavailable + - unknown conditions: [] actions: - action: input_boolean.turn_on target: entity_id: input_boolean.tv_mode - - action: light.turn_on - metadata: {} - data: - brightness_pct: 5 - target: - area_id: living_room - - type: turn_off - device_id: 03a12d2360d9954aed19c2449070725a - entity_id: 7c1e7db73799cc3f90948b5118596985 - domain: light - - type: turn_off - device_id: 800eddbeeda071225f181a14cb9527e0 - entity_id: 521a92ddd8be76c7eddfc544f81f6020 - domain: light - - type: turn_off - device_id: 3f7f65571d9bb0833433996f1f6725bd - entity_id: 7407afe14783543252c666d5ff7c5d5c - domain: light + - if: + - condition: or + conditions: + - condition: sun + after: sunset + after_offset: "-01:00:00" + - condition: sun + before: sunrise + then: + - action: light.turn_on + data: + brightness_pct: 5 + target: + area_id: living_room + - action: light.turn_off + target: + entity_id: + - light.kitchen_wall_light + - light.dining_hall + - light.bathroom_hallway mode: single - id: new_tv_off alias: Light - TV Off - Restore - description: Restores appropriate lighting level when TV turns off based on time + description: Evening (sunset -1h to 23:30) brings the lights back to the + scheduled level; late night (23:30 to sunrise) only turns off the TV glow; + daytime leaves the lights alone triggers: - - type: turned_off - device_id: 18a9bb7a2a32be4371da447767ef50a9 - entity_id: c05688f2610e27e2d86380e2945ceae5 - domain: remote - trigger: device + - trigger: state + entity_id: remote.samsung_tv + to: 'off' + not_from: + - unavailable + - unknown conditions: [] actions: - action: input_boolean.turn_off @@ -239,409 +246,163 @@ entity_id: input_boolean.tv_mode - choose: - conditions: + - condition: sun + after: sunset + after_offset: "-01:00:00" - condition: time - after: '22:30:00' - before: '23:45:00' + before: '23:30:00' sequence: - # Late dim levels - - type: turn_on - device_id: f31e4f9bf8fa3687a07aeb4430eaef38 - entity_id: b79934d97f3bb9d8a3da47c76d03ded4 - domain: light - brightness_pct: 1 - - type: turn_on - device_id: 03a12d2360d9954aed19c2449070725a - entity_id: 7c1e7db73799cc3f90948b5118596985 - domain: light - brightness_pct: 1 - - type: turn_on - device_id: 800eddbeeda071225f181a14cb9527e0 - entity_id: 521a92ddd8be76c7eddfc544f81f6020 - domain: light - brightness_pct: 25 - - type: turn_on - device_id: 3f7f65571d9bb0833433996f1f6725bd - entity_id: 7407afe14783543252c666d5ff7c5d5c - domain: light - brightness_pct: 10 - - type: turn_on - device_id: 21eb2bd28aba2ee361a22af92e8b2d16 - entity_id: 81c486d682afcc94e98e377475cc92fc - domain: light - brightness_pct: 10 + - action: script.evening_lights_apply + data: + apply: force - conditions: - - condition: time - after: '21:30:00' - before: '22:30:00' + - condition: or + conditions: + - condition: time + after: '23:30:00' + - condition: sun + before: sunrise sequence: - # Mid dim levels - - type: turn_on - device_id: f31e4f9bf8fa3687a07aeb4430eaef38 - entity_id: b79934d97f3bb9d8a3da47c76d03ded4 - domain: light - brightness_pct: 25 - - type: turn_on - device_id: 03a12d2360d9954aed19c2449070725a - entity_id: 7c1e7db73799cc3f90948b5118596985 - domain: light - brightness_pct: 25 - - type: turn_on - device_id: 800eddbeeda071225f181a14cb9527e0 - entity_id: 521a92ddd8be76c7eddfc544f81f6020 - domain: light - brightness_pct: 50 - - type: turn_on - device_id: 3f7f65571d9bb0833433996f1f6725bd - entity_id: 7407afe14783543252c666d5ff7c5d5c - domain: light - brightness_pct: 25 - - type: turn_on - device_id: 21eb2bd28aba2ee361a22af92e8b2d16 - entity_id: 81c486d682afcc94e98e377475cc92fc - domain: light - brightness_pct: 25 - - conditions: - - condition: time - after: '21:00:00' - before: '21:30:00' - sequence: - # Early dim levels - - type: turn_on - device_id: 03a12d2360d9954aed19c2449070725a - entity_id: 7c1e7db73799cc3f90948b5118596985 - domain: light - brightness_pct: 50 - - type: turn_on - device_id: f31e4f9bf8fa3687a07aeb4430eaef38 - entity_id: b79934d97f3bb9d8a3da47c76d03ded4 - domain: light - brightness_pct: 50 - - type: turn_on - device_id: 3f7f65571d9bb0833433996f1f6725bd - entity_id: 7407afe14783543252c666d5ff7c5d5c - domain: light - brightness_pct: 50 - - type: turn_on - device_id: 21eb2bd28aba2ee361a22af92e8b2d16 - entity_id: 81c486d682afcc94e98e377475cc92fc - domain: light - brightness_pct: 50 - default: - # Full brightness (before 21:00 after sunset) - - type: turn_on - device_id: 800eddbeeda071225f181a14cb9527e0 - entity_id: 521a92ddd8be76c7eddfc544f81f6020 - domain: light - brightness_pct: 25 - - type: turn_on - device_id: f31e4f9bf8fa3687a07aeb4430eaef38 - entity_id: b79934d97f3bb9d8a3da47c76d03ded4 - domain: light - brightness_pct: 100 - - type: turn_on - device_id: 03a12d2360d9954aed19c2449070725a - entity_id: 7c1e7db73799cc3f90948b5118596985 - domain: light - brightness_pct: 100 - - type: turn_on - device_id: 3f7f65571d9bb0833433996f1f6725bd - entity_id: 7407afe14783543252c666d5ff7c5d5c - domain: light - brightness_pct: 75 - - type: turn_on - device_id: 21eb2bd28aba2ee361a22af92e8b2d16 - entity_id: 81c486d682afcc94e98e377475cc92fc - domain: light - brightness_pct: 100 + - action: light.turn_off + target: + area_id: living_room mode: single -- id: 'sunset_lights_on' - alias: Lights - Sunset On - description: Turn on lights 1 hour before sunset +- id: driveway_lights_on + alias: Driveway String Lights On + description: On 1 hour before sunset whether or not the TV is on. Also catches + up if Home Assistant restarts before the 23:00 off triggers: - trigger: sun event: sunset offset: "-01:00:00" + id: sunset + - trigger: homeassistant + event: start conditions: - condition: state - entity_id: input_boolean.tv_mode + entity_id: switch.driveway_string_lights state: 'off' + - condition: or + conditions: + - condition: trigger + id: sunset + - condition: and + conditions: + - condition: sun + after: sunset + after_offset: "-01:00:00" + - condition: time + before: '23:00:00' actions: - - type: turn_on - device_id: 1fa1aca8f90daf94a2a7baf8a3abc158 - entity_id: 58d101e63456fd8e088d3a3b63f3a0f9 - domain: switch - - type: turn_on - device_id: 800eddbeeda071225f181a14cb9527e0 - entity_id: 521a92ddd8be76c7eddfc544f81f6020 - domain: light - brightness_pct: 25 - - type: turn_on - device_id: f31e4f9bf8fa3687a07aeb4430eaef38 - entity_id: b79934d97f3bb9d8a3da47c76d03ded4 - domain: light - brightness_pct: 100 - - type: turn_on - device_id: 03a12d2360d9954aed19c2449070725a - entity_id: 7c1e7db73799cc3f90948b5118596985 - domain: light - brightness_pct: 100 - - type: turn_on - device_id: 3f7f65571d9bb0833433996f1f6725bd - entity_id: 7407afe14783543252c666d5ff7c5d5c - domain: light - brightness_pct: 75 - - type: turn_on - device_id: 21eb2bd28aba2ee361a22af92e8b2d16 - entity_id: 81c486d682afcc94e98e377475cc92fc - domain: light - brightness_pct: 100 + - action: switch.turn_on + target: + entity_id: switch.driveway_string_lights mode: single -- id: 'evening_dim_2100' - alias: Lights - Evening Dim (21:00) - description: Dim lights at 21:00 - only affects lights that are ON +- id: 'sunset_lights_on' + alias: Lights - Sunset On + description: Turn on lights 1 hour before sunset. If the TV is on, the living + room gets the TV glow and the lights that glare on the TV stay off triggers: - - trigger: time - at: "21:00:00" - conditions: - - condition: state - entity_id: input_boolean.tv_mode - state: 'off' - actions: - - if: - - condition: device - device_id: 03a12d2360d9954aed19c2449070725a - domain: light - entity_id: 7c1e7db73799cc3f90948b5118596985 - type: is_on - then: - - type: turn_on - device_id: 03a12d2360d9954aed19c2449070725a - entity_id: 7c1e7db73799cc3f90948b5118596985 - domain: light - brightness_pct: 50 - - if: - - condition: device - device_id: f31e4f9bf8fa3687a07aeb4430eaef38 - domain: light - entity_id: b79934d97f3bb9d8a3da47c76d03ded4 - type: is_on - then: - - type: turn_on - device_id: f31e4f9bf8fa3687a07aeb4430eaef38 - entity_id: b79934d97f3bb9d8a3da47c76d03ded4 - domain: light - brightness_pct: 50 - - if: - - condition: device - device_id: 3f7f65571d9bb0833433996f1f6725bd - domain: light - entity_id: 7407afe14783543252c666d5ff7c5d5c - type: is_on - then: - - type: turn_on - device_id: 3f7f65571d9bb0833433996f1f6725bd - entity_id: 7407afe14783543252c666d5ff7c5d5c - domain: light - brightness_pct: 50 - - if: - - condition: device - device_id: 21eb2bd28aba2ee361a22af92e8b2d16 - domain: light - entity_id: 81c486d682afcc94e98e377475cc92fc - type: is_on - then: - - type: turn_on - device_id: 21eb2bd28aba2ee361a22af92e8b2d16 - entity_id: 81c486d682afcc94e98e377475cc92fc - domain: light - brightness_pct: 50 - mode: single -- id: 'mid_dim_2130' - alias: Lights - Mid Dim (21:30) - description: Dim lights at 21:30 - only affects lights that are ON - triggers: - - trigger: time - at: "21:30:00" - conditions: - - condition: state - entity_id: input_boolean.tv_mode - state: 'off' - actions: - - if: - - condition: device - device_id: f31e4f9bf8fa3687a07aeb4430eaef38 - domain: light - entity_id: b79934d97f3bb9d8a3da47c76d03ded4 - type: is_on - then: - - type: turn_on - device_id: f31e4f9bf8fa3687a07aeb4430eaef38 - entity_id: b79934d97f3bb9d8a3da47c76d03ded4 - domain: light - brightness_pct: 25 - - if: - - condition: device - device_id: 03a12d2360d9954aed19c2449070725a - domain: light - entity_id: 7c1e7db73799cc3f90948b5118596985 - type: is_on - then: - - type: turn_on - device_id: 03a12d2360d9954aed19c2449070725a - entity_id: 7c1e7db73799cc3f90948b5118596985 - domain: light - brightness_pct: 25 - - if: - - condition: device - device_id: 800eddbeeda071225f181a14cb9527e0 - domain: light - entity_id: 521a92ddd8be76c7eddfc544f81f6020 - type: is_on - then: - - type: turn_on - device_id: 800eddbeeda071225f181a14cb9527e0 - entity_id: 521a92ddd8be76c7eddfc544f81f6020 - domain: light - brightness_pct: 50 - - if: - - condition: device - device_id: 3f7f65571d9bb0833433996f1f6725bd - domain: light - entity_id: 7407afe14783543252c666d5ff7c5d5c - type: is_on - then: - - type: turn_on - device_id: 3f7f65571d9bb0833433996f1f6725bd - entity_id: 7407afe14783543252c666d5ff7c5d5c - domain: light - brightness_pct: 25 - - if: - - condition: device - device_id: 21eb2bd28aba2ee361a22af92e8b2d16 - domain: light - entity_id: 81c486d682afcc94e98e377475cc92fc - type: is_on - then: - - type: turn_on - device_id: 21eb2bd28aba2ee361a22af92e8b2d16 - entity_id: 81c486d682afcc94e98e377475cc92fc - domain: light - brightness_pct: 25 - mode: single -- id: 'late_dim_2230' - alias: Lights - Late Dim (22:30) - description: Dim lights at 22:30 - only affects lights that are ON - triggers: - - trigger: time - at: "22:30:00" - conditions: - - condition: state - entity_id: input_boolean.tv_mode - state: 'off' - actions: - - if: - - condition: device - device_id: f31e4f9bf8fa3687a07aeb4430eaef38 - domain: light - entity_id: b79934d97f3bb9d8a3da47c76d03ded4 - type: is_on - then: - - type: turn_on - device_id: f31e4f9bf8fa3687a07aeb4430eaef38 - entity_id: b79934d97f3bb9d8a3da47c76d03ded4 - domain: light - brightness_pct: 1 - - if: - - condition: device - device_id: 03a12d2360d9954aed19c2449070725a - domain: light - entity_id: 7c1e7db73799cc3f90948b5118596985 - type: is_on - then: - - type: turn_on - device_id: 03a12d2360d9954aed19c2449070725a - entity_id: 7c1e7db73799cc3f90948b5118596985 - domain: light - brightness_pct: 1 - - if: - - condition: device - device_id: 800eddbeeda071225f181a14cb9527e0 - domain: light - entity_id: 521a92ddd8be76c7eddfc544f81f6020 - type: is_on - then: - - type: turn_on - device_id: 800eddbeeda071225f181a14cb9527e0 - entity_id: 521a92ddd8be76c7eddfc544f81f6020 - domain: light - brightness_pct: 25 - - if: - - condition: device - device_id: 3f7f65571d9bb0833433996f1f6725bd - domain: light - entity_id: 7407afe14783543252c666d5ff7c5d5c - type: is_on - then: - - type: turn_on - device_id: 3f7f65571d9bb0833433996f1f6725bd - entity_id: 7407afe14783543252c666d5ff7c5d5c - domain: light - brightness_pct: 10 - - if: - - condition: device - device_id: 21eb2bd28aba2ee361a22af92e8b2d16 - domain: light - entity_id: 81c486d682afcc94e98e377475cc92fc - type: is_on - then: - - type: turn_on - device_id: 21eb2bd28aba2ee361a22af92e8b2d16 - entity_id: 81c486d682afcc94e98e377475cc92fc - domain: light - brightness_pct: 10 - mode: single -- id: 'lights_out_2345' - alias: Lights - Out (23:45) - description: Turn off all lights at 23:45 - triggers: - - trigger: time - at: "23:45:00" + - trigger: sun + event: sunset + offset: "-01:00:00" conditions: [] actions: - - type: turn_off - device_id: 3f7f65571d9bb0833433996f1f6725bd - entity_id: 7407afe14783543252c666d5ff7c5d5c - domain: light - - type: turn_off - device_id: f31e4f9bf8fa3687a07aeb4430eaef38 - entity_id: b79934d97f3bb9d8a3da47c76d03ded4 - domain: light - - type: turn_off - device_id: 03a12d2360d9954aed19c2449070725a - entity_id: 7c1e7db73799cc3f90948b5118596985 - domain: light - - type: turn_off - device_id: 800eddbeeda071225f181a14cb9527e0 - entity_id: 521a92ddd8be76c7eddfc544f81f6020 - domain: light - - type: turn_off - device_id: 03eb359bf2344a58bebfe1e9c5bcfadd - entity_id: a30b2da3cd80a5b4c927e1608b91eb65 - domain: light - - type: turn_off - device_id: 21eb2bd28aba2ee361a22af92e8b2d16 - entity_id: 81c486d682afcc94e98e377475cc92fc - domain: light + - action: script.evening_lights_apply + data: + apply: force + - if: + - condition: state + entity_id: input_boolean.tv_mode + state: 'on' + then: + - action: light.turn_on + data: + brightness_pct: 5 + target: + area_id: living_room + mode: single +- id: evening_dim_ramp + alias: Lights - Evening Dim Ramp + description: Every 5 minutes from 20:30 to 23:30, ease the lights that are on + toward the schedule in script.evening_lights_apply + triggers: + - trigger: time_pattern + minutes: /5 + conditions: + - condition: time + after: '20:30:00' + before: '23:30:00' + actions: + - action: script.evening_lights_apply + data: + apply: ramp + mode: single +- id: 'lights_out_2345' + alias: Lights - Out (23:30) + description: Turn off all lights at 23:30. While the TV is on the living room + is left as it is + triggers: + - trigger: time + at: "23:30:00" + conditions: [] + actions: + - action: light.turn_off + target: + entity_id: + - light.kitchen_lights + - light.kitchen_wall_light + - light.dining_hall + - light.dining_room + - light.bathroom_hallway + - if: + - condition: state + entity_id: input_boolean.tv_mode + state: 'off' + then: + - action: light.turn_off + target: + entity_id: light.living_room + mode: single +- id: lights_sweep_0100 + alias: Lights - Sweep (01:00) + description: Catch anything turned back on after lights-out. While the TV is + on the living room is left as it is + triggers: + - trigger: time + at: "01:00:00" + conditions: [] + actions: + - action: light.turn_off + target: + entity_id: + - light.kitchen_lights + - light.kitchen_wall_light + - light.dining_hall + - light.dining_room + - light.bathroom_hallway + - action: switch.turn_off + target: + entity_id: switch.driveway_string_lights + - if: + - condition: state + entity_id: input_boolean.tv_mode + state: 'off' + then: + - action: light.turn_off + target: + entity_id: light.living_room mode: single - id: '1768862300896' alias: Bedroom On description: '' triggers: - - type: turned_on - device_id: afb9734fe9b187ab6881a64d24e1c2f5 - entity_id: 27efa149b9ebb388e7c21ba89e671b42 - domain: switch - trigger: device + - trigger: state + entity_id: switch.bedroom_light + to: 'on' + not_from: + - unavailable + - unknown conditions: [] actions: - action: light.turn_on @@ -655,11 +416,12 @@ alias: Bedroom Off description: '' triggers: - - type: turned_off - device_id: afb9734fe9b187ab6881a64d24e1c2f5 - entity_id: 27efa149b9ebb388e7c21ba89e671b42 - domain: switch - trigger: device + - trigger: state + entity_id: switch.bedroom_light + to: 'off' + not_from: + - unavailable + - unknown conditions: [] actions: - action: light.turn_off diff --git a/ansible/roles/podman/files/hass/configuration.yaml b/ansible/roles/podman/files/hass/configuration.yaml index 04c73ae..a25d51e 100644 --- a/ansible/roles/podman/files/hass/configuration.yaml +++ b/ansible/roles/podman/files/hass/configuration.yaml @@ -23,6 +23,7 @@ homeassistant: media: /share automation: !include automations.yaml +script: !include scripts.yaml input_boolean: tv_mode: diff --git a/ansible/roles/podman/files/hass/scripts.yaml b/ansible/roles/podman/files/hass/scripts.yaml new file mode 100644 index 0000000..83cbd32 --- /dev/null +++ b/ansible/roles/podman/files/hass/scripts.yaml @@ -0,0 +1,76 @@ +evening_lights_apply: + alias: Evening Lights - Apply Schedule + description: >- + Sets each evening light to its scheduled brightness for the current time, + blending linearly between the points in `schedule`. apply=force turns the + lights on (sunset, TV off); apply=ramp only eases lights that are already + on, and leaves alone any light someone has changed by hand. While TV mode + is on the living room and the lights that glare on the TV are left alone. + mode: queued + fields: + apply: + description: "force: turn lights on at the target. ramp: only adjust lights that are already on." + example: ramp + selector: + select: + options: + - force + - ramp + variables: + # [minute of day, brightness %] - 1230 = 20:30, 1260 = 21:00, + # 1290 = 21:30, 1350 = 22:30. Before the first point a light sits at the + # first value; after the last it holds the last value until lights-out. + schedule: + light.kitchen_lights: [[1230, 100], [1260, 50], [1290, 25], [1350, 1]] + light.kitchen_wall_light: [[1230, 100], [1260, 50], [1290, 25], [1350, 1]] + light.bathroom_hallway: [[1230, 75], [1260, 50], [1290, 25], [1350, 10]] + light.living_room: [[1230, 100], [1260, 50], [1290, 25], [1350, 10]] + light.dining_hall: [[1290, 25], [1350, 15]] + tv_mode_lights: + - light.living_room + - light.kitchen_wall_light + - light.dining_hall + - light.bathroom_hallway + apply_mode: "{{ apply | default('ramp') }}" + sequence: + - repeat: + for_each: "{{ schedule.keys() | list }}" + sequence: + - variables: + light: "{{ repeat.item }}" + # [target now, target 5 minutes ago - what the last ramp tick set] + levels: >- + {%- macro at(pts, t) -%} + {%- if t <= pts[0][0] -%}{{ pts[0][1] }} + {%- elif t >= pts[-1][0] -%}{{ pts[-1][1] }} + {%- else -%} + {%- for i in range(pts | length - 1) if pts[i][0] <= t < pts[i + 1][0] -%} + {{ (pts[i][1] + (pts[i + 1][1] - pts[i][1]) * (t - pts[i][0]) / (pts[i + 1][0] - pts[i][0])) | round(0) | int }} + {%- endfor -%} + {%- endif -%} + {%- endmacro -%} + {%- set t = now().hour * 60 + now().minute -%} + {{ [at(schedule[repeat.item], t) | int, at(schedule[repeat.item], t - 5) | int] }} + current: "{{ ((state_attr(repeat.item, 'brightness') or 0) / 2.55) | round(0) | int }}" + skip: "{{ is_state('input_boolean.tv_mode', 'on') and repeat.item in tv_mode_lights }}" + - choose: + - conditions: "{{ not skip and apply_mode == 'force' }}" + sequence: + - action: light.turn_on + target: + entity_id: "{{ light }}" + data: + brightness_pct: "{{ levels[0] }}" + transition: 2 + # A light more than 10 points off the last tick was set by hand; the + # biggest scheduled change in 5 minutes is ~8 + - conditions: >- + {{ not skip and apply_mode == 'ramp' and is_state(light, 'on') + and (current - levels[1]) | abs <= 10 and current != levels[0] }} + sequence: + - action: light.turn_on + target: + entity_id: "{{ light }}" + data: + brightness_pct: "{{ levels[0] }}" + transition: 60 diff --git a/ansible/roles/podman/tasks/containers/home/hass.yml b/ansible/roles/podman/tasks/containers/home/hass.yml index d47cfbd..786c4e8 100644 --- a/ansible/roles/podman/tasks/containers/home/hass.yml +++ b/ansible/roles/podman/tasks/containers/home/hass.yml @@ -25,6 +25,7 @@ loop: - configuration.yaml - automations.yaml + - scripts.yaml - name: flush handlers ansible.builtin.meta: flush_handlers diff --git a/ansible/roles/podman/tasks/main.yml b/ansible/roles/podman/tasks/main.yml index 6269ecd..c74cd54 100644 --- a/ansible/roles/podman/tasks/main.yml +++ b/ansible/roles/podman/tasks/main.yml @@ -39,7 +39,7 @@ - import_tasks: containers/home/hass.yml vars: - image: ghcr.io/home-assistant/home-assistant:2026.8.3 + image: ghcr.io/home-assistant/home-assistant:2026.9.3 tags: hass - import_tasks: containers/home/partsy.yml @@ -123,14 +123,14 @@ - import_tasks: containers/home/gregtime.yml vars: - image: localhost/greg-time-bot:3.18.1 + image: localhost/greg-time-bot:3.19.0 tags: gregtime # Built and loaded by `make deploy-remote` in ~/src/rsvp-debylio; bump this to # the VERSION it loaded. The Caddy vhost ships with the caddy-config tag. - import_tasks: containers/home/rsvp.yml vars: - image: localhost/rsvpd:1.0.5 + image: localhost/rsvpd:1.0.7 tags: rsvp # Gated on zomboid_enabled (roles/podman/defaults/main.yml) so it can be taken diff --git a/ansible/roles/podman/templates/podman-prune.sh.j2 b/ansible/roles/podman/templates/podman-prune.sh.j2 index 380b532..f59431f 100644 --- a/ansible/roles/podman/templates/podman-prune.sh.j2 +++ b/ansible/roles/podman/templates/podman-prune.sh.j2 @@ -43,9 +43,10 @@ run() { exec podman "$@"' _ "$@" } -# prune_user +# prune_user [image prune filter...] prune_user() { local u=$1 keep=$2 do_containers=$3 + shift 3 local before after img vol con if ! id "$u" >/dev/null 2>&1; then @@ -66,7 +67,7 @@ prune_user() { con=$(run "$u" container prune -f --filter "until=$keep" 2>&1 | tail -1) fi - img=$(run "$u" image prune -af --filter "until=$keep" 2>&1 | tail -1) + img=$(run "$u" image prune -af --filter "until=$keep" "$@" 2>&1 | tail -1) vol=$(run "$u" volume prune -f 2>&1 | tail -1) after=$(run "$u" system df --format '{{ '{{' }}.Size{{ '}}' }}' 2>/dev/null | head -1) @@ -80,7 +81,10 @@ for u in {{ podman_prune_users | join(' ') }}; do done for u in {{ podman_prune_ci_users | join(' ') }}; do - prune_user "$u" "{{ podman_prune_ci_until }}" yes + # CI base images (gitea-ci, -espidf, -platformio) carry the keep label: they + # are rebuilt or re-pulled from the registry only when missing, so pruning + # them just forces a multi-GB re-download on the next job. + prune_user "$u" "{{ podman_prune_ci_until }}" yes --filter "label!={{ podman_prune_ci_keep_label }}" done log "status=ok" diff --git a/ansible/vars/vault.yml b/ansible/vars/vault.yml index 340d8be..e2a3b5c 100644 Binary files a/ansible/vars/vault.yml and b/ansible/vars/vault.yml differ