Compare commits

..

2 commits

Author SHA1 Message Date
4b5a0c376a C1(unpoller-v3): bump kustomization to v3.2.0-1b27242
Built by build-container workflow run #559 from this branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 09:46:00 -07:00
1b27242437 C1(unpoller-v3): upgrade v2.34.0 -> v3.2.0, migrate to container.py
Major version bump from v2.34.0 to v3.2.0.

Breaking changes upstream:
- v3.0.0: UniFi network API shifts (later 10.x); metrics, events and logs
  may have changed names/labels.
- v3.2.0: defaults to a 60s background poll feeding cached Prometheus
  scrapes (was on-demand poll per scrape). Set interval = 0 in up.conf
  to restore on-demand behavior if needed.

Also migrate the container build from a Dockerfile to a native Dagger
pipeline (containers/unpoller/container.py) using the shared helpers in
blumeops.containers, following the navidrome/miniflux pattern. The
build-container workflow already prefers container.py when present.

Refresh last-reviewed and current-version in service-versions.yaml.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 09:34:12 -07:00
214 changed files with 1520 additions and 2703 deletions

View file

@ -12,9 +12,10 @@ blumeops is Erich Blume's GitOps repository for personal infrastructure, orchest
## Rules ## Rules
1. **Start every task by finding and reading the relevant docs** 1. **Always run `mise run ai-docs` at session start**
Search `docs/` for cards related to the change area (grep for titles/tags, follow `[[wiki-links]]`) and read what you find before acting. Wiki-links refer to cards under `docs/` by filename stem. This will refresh your context with important information you will be assumed to know and follow.
For problems with a very large surface area, `mise run ai-sources` concatenates all non-doc source files (~270K tokens) — opt-in only, confirm with the user before loading it wholesale; targeted reading is usually better. **Read the full output** — never truncate, pipe to `head`/`tail`, or skip sections.
For problems with a large surface area, ask the user if `mise run ai-sources` should also be run — it concatenates all non-doc source files (~270K tokens) for deep codebase context.
2. **Always use `--context=minikube-indri` with kubectl** (or `--context=k3s-ringtail` for ringtail services) - work contexts must never be touched 2. **Always use `--context=minikube-indri` with kubectl** (or `--context=k3s-ringtail` for ringtail services) - work contexts must never be touched
**NEVER run `minikube delete`** — it destroys all PVs, etcd, and cluster state. Use `minikube stop`/`minikube start` for restarts. If minikube is stuck, see [[restart-indri]]. Full rebuild from scratch requires the DR procedure in [[rebuild-minikube-cluster]]. **NEVER run `minikube delete`** — it destroys all PVs, etcd, and cluster state. Use `minikube stop`/`minikube start` for restarts. If minikube is stuck, see [[restart-indri]]. Full rebuild from scratch requires the DR procedure in [[rebuild-minikube-cluster]].
3. **Classify the change as C0/C1/C2 before starting** (see below) — this determines branching and PR requirements 3. **Classify the change as C0/C1/C2 before starting** (see below) — this determines branching and PR requirements
@ -64,11 +65,11 @@ See [[agent-change-process]] for the full methodology.
./pulumi/ # Pulumi IaC (tailnet ACLs, dns, cloud) ./pulumi/ # Pulumi IaC (tailnet ACLs, dns, cloud)
~/.config/{nvim,fish} # user's shell config, managed by chezmoi ~/.config/{nvim,fish} # user's shell config, managed by chezmoi
~/code/personal/ # user's projects ~/code/personal/ # user's projects
~/code/personal/zk # user's zettelkasten (Obsidian-sync). Reference-data source; migrating into heph docs (hephaestus). ~/code/personal/zk # user's Obsidian-sync managed zettelkasten. Potential source for reference data.
~/code/3rd/ # mirrored external projects ~/code/3rd/ # mirrored external projects
~/code/work # FORBIDDEN ~/code/work # FORBIDDEN
``` ```
This is just an overview — explore `docs/` for the rest. When you Other code paths will be listed via ai-docs, this is just an overview. When you
encounter wiki-links (`[[like-this]]`) it is referring to docs/ cards. encounter wiki-links (`[[like-this]]`) it is referring to docs/ cards.
## Service Deployment ## Service Deployment
@ -146,45 +147,10 @@ Create a new spork: `mise run spork-create <mirror-name>`
## Task Discovery ## Task Discovery
BlumeOps tasks live in [hephaestus](https://github.com/eblume/hephaestus) (`heph`),
the user's self-hosted context/task system. The CLI is a thin client of the
local `hephd` daemon. (This replaced the retired `blumeops-tasks` mise task,
which read from Todoist.)
### Reading tasks
```fish ```fish
heph list --project Blumeops --json # outstanding Blumeops tasks as JSON mise run blumeops-tasks # fetch from Todoist, sorted by priority
heph next # tactical "what is next?" ranking
heph show <node_id> # one task with its scalars
heph context <node_id> # print the task's canonical-context doc
heph log <node_id> # print the task's latest log entries
``` ```
Most tasks are stored in `./mise-tasks/`. For scripts with any logic or
JSON rows carry `node_id` (use this as `<ID>` in all commands below), `title`,
`state`, `do_date`/`late_on` (epoch ms), `recurrence` (RFC-5545), and
`attention` (red|orange|white|blue — a1a4 urgency tiers; blue = on-deck).
### Manipulating tasks
```fish
heph done <node_id> # mark done (recurring tasks roll forward)
heph drop <node_id> # mark dropped
heph skip <node_id> # skip a recurring task's current occurrence
heph log <node_id> "text" # append a log entry
heph context <node_id> --append "…" # append to the canonical-context doc (--body replaces; `-` reads stdin)
heph edit <node_id> --do-date +3d # reschedule; also --late-on/--recur/--attention/--project (`none` clears)
heph task "Title" --project Blumeops --do-date fri --attention white # create a task
```
Date forms: `today|tomorrow|+3d|fri|YYYY-MM-DD`. Recurrence: presets
(`daily|weekly|monthly|yearly|weekdays`) or natural language (`"every 3 days"`).
Conventions: don't save TODOs to agent memory — file them as heph tasks under
the Blumeops project. When completing a recurring chore (e.g. "BlumeOps doc
review"), `heph log` a short note of what was done, then `heph done` it.
Most operational scripts are stored in `./mise-tasks/`. For scripts with any logic or
complexity, use uv run --script 's with explicit dependencies. Complex complexity, use uv run --script 's with explicit dependencies. Complex
workflows with artifacts should become dagger pipelines. Mise tasks are for workflows with artifacts should become dagger pipelines. Mise tasks are for
development processes and operations - tools for the user or the agent. development processes and operations - tools for the user or the agent.

View file

@ -12,259 +12,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
<!-- towncrier release notes start --> <!-- towncrier release notes start -->
## [v1.17.0] - 2026-06-03
### Features
- Deploy the Adelaide / Heidi / Addie baby shower app — guest splash, raffle
picker, and prize assignment console — on ringtail k3s with `shower.eblu.me`
as the public entry and `shower.ops.eblu.me` as the tailnet admin host. App
source: [`adelaide-baby-shower-app`](https://forge.eblu.me/eblume/adelaide-baby-shower-app).
- Deploy adelaide-baby-shower-app v1.1.0 to ringtail k3s. Replaces the
boolean lock with a four-phase `ShowerState` (`pre_event``party`
`prizes_locked``event_locked`), adds an append-only "guest memories"
panel where guests can leave photos and comments for the baby, and
polishes the admin and QR views. Three Django migrations
(`0009_shower_phase`, `0010_guest_memories`, `0011_book_description`)
run automatically in the entrypoint against the SQLite PV. No config
or env-var changes.
Container build also gains a Forgejo-PyPI workaround: Forgejo's simple
index returns absolute file URLs hardcoded to the public ROOT_URL
(`forge.eblu.me`), which the Fly edge 403s on `/api/packages/*`. The
wheel and sdist are now both pulled via direct `fetchurl` against
`forge.ops.eblu.me` (tailnet-only) and the wheel is handed to pip as
a local path.
- `review-compliance-reports` now also fetches and summarizes the weekly Prowler container-image and IaC scans (previously only the K8s CIS in-cluster scan was processed). For each scan it shows status counts, severity breakdown, week-over-week delta, and — for the high-volume image/IaC scans — top-N tables grouped by check ID and resource instead of per-finding listings.
- runner-logs now authenticates with Forgejo API token and auto-detects the repo from git remote. Job logs are fetched via SSH to indri (reading Forgejo's on-disk zstd log files) instead of the web endpoint, which doesn't support token auth for private repos.
### Bug Fixes
- Fix nightly borgmatic backups failing for 2 days. The shower SQLite
dump hook referenced `kubectl --context=k3s-ringtail`, but indri's
kubeconfig deliberately doesn't carry the ringtail credentials. The
`before_backup` hook's failure aborted the entire run, taking out
*both* the local sifaka repo and the BorgBase offsite. Replaced
the inline-shell dump with a `~/bin/borgmatic-k8s-sqlite-dump`
helper deployed by the ansible role. Each dump entry now declares a
`target` of either `local:<context>` (mealie — kubectl uses indri's
kubeconfig) or `ssh:<user@host>` (shower — ssh into ringtail and
run `k3s kubectl` there, no indri-side kubeconfig needed; k3s.yaml
on ringtail is mode 644 so no sudo required). Bytes stream back via
`kubectl exec ... -- cat` rather than `kubectl cp`, since `kubectl
cp` requires `tar` inside the pod and nix-built images like shower
don't bundle it.
- Shower app container now bakes the wheel + Python deps into the image
at build time via `buildPythonPackage` instead of pip-installing on
first boot. Boots are deterministic and don't depend on forge PyPI
being reachable from the pod. The `wheelHash` in
`containers/shower/default.nix` is the sha256 sourced from the
[forge PyPI simple index](https://forge.eblu.me/api/packages/eblume/pypi/simple/adelaide-baby-shower-app/);
bumping the version means bumping that hash too.
Borgmatic now covers the shower app: SQLite is dumped from the live
pod via `kubectl exec` (mirroring the existing mealie entry, with
`context: k3s-ringtail`), and the prize-photo media share is picked up
through `/Volumes/shower` (sifaka SMB mount on indri, same pattern as
`/Volumes/photos`).
- Disabled adaptive sync (VRR) on ringtail's DP-1 output. The OMEN 27i IPS panel pumps brightness when its refresh rate swings into the low VRR range during low-framerate content (e.g. game cutscenes), producing a flicker that worsened over a session until a reboot. Pinning the panel to a fixed 165Hz eliminates it.
- Fixed forge.eblu.me static assets (CSS, JS, images, fonts) not loading — the proxy's static asset cache block was missing the `Host` header, so Caddy couldn't route the requests.
- Fixed homepage container EACCES on cold start: the nix-built image now chowns
`/app/config` to uid 1000 at build time via `fakeRootCommands`, matching the
behavior of the old Dockerfile. Without this, homepage couldn't seed missing
skeleton configs (proxmox.yaml etc.) or create `/app/config/logs`, crashing on
its first uncached request. Caught during the ringtail cutover.
- Fixed sway keybindings on ringtail — the home-manager `keybindings` block was replacing the module's defaults entirely, leaving only explicit overrides (no workspace switching, focus, move, splits, resize mode, etc). Switched to `lib.mkOptionDefault` with `lib.mkForce` on the conflicting custom binds (`Mod+Return`, `Mod+d`, `Mod+space`, `Mod+l`) so defaults merge back in. Also added `Mod+F1` to show a filterable fuzzel list of current keybindings.
Fixed fuzzel config errors on launch — `border-radius` and `border-width` were under `[main]`, but fuzzel expects them as `radius`/`width` under a `[border]` section.
- Pin the Quartz docs build to v4.5.2. The Dagger `build_docs` pipeline cloned Quartz from the default branch unpinned; Quartz v5.0.0 restructured its config layout (`.quartz/plugins`, `../quartz` imports) and broke the docs build against our existing `quartz.config.ts`/`quartz.layout.ts`.
### Infrastructure
- Wire the ringtail `blumeops-pg` cluster (which holds the wave-1-migrated
paperless + teslamate databases) into backups and Grafana. Adds a Tailscale
LoadBalancer Service (`blumeops-pg-ringtail.tail8d86e.ts.net`) and a Caddy L4
route (`pg.ops.eblu.me:5434`), then repoints borgmatic's `teslamate` +
`paperless` postgres dumps and the `mealie` SQLite dump at ringtail, and the
Grafana TeslaMate datasource at the ringtail DB. Closes the backup gap that
opened at cutover (the migrated live data was still being backed up from the
now-frozen minikube copies) and unblocks the wave-1 decommission.
- Migrated homepage dashboard from minikube (indri/arm64) to k3s (ringtail/amd64).
The container is now built via nix (`containers/homepage/default.nix`), adapted
from nixpkgs `homepage-dashboard` with the upstream Next.js cache patches and
wrapped with `dockerTools.buildLayeredImage`. Autodiscovery shifts: services on
minikube (ArgoCD, Immich, Kiwix, Mealie, Miniflux, Grafana, Prometheus,
Navidrome, Paperless, TeslaMate, Transmission) become explicit static entries
in `services.yaml`; ringtail services (Authentik, Frigate/NVR, Ntfy, Ollama)
auto-populate via Ingress annotations.
- Migrated CV (`cv.eblu.me`) and Docs (`docs.eblu.me`) from minikube Deployments to indri-native ansible roles. Caddy now serves the extracted release tarballs directly via a new `kind: static` service-block in the Caddy template — no daemon, no container — replacing the prior nginx-in-a-pod layer. Removes a network hop on every request and shrinks minikube's footprint. See [[cv-on-indri]] and [[docs-on-indri]]. Part of the broader minikube wind-down.
- Migrated devpi (PyPI mirror at `pypi.ops.eblu.me`) from a minikube StatefulSet to a launchd-managed service on indri. devpi-server now runs in a uv-managed venv with pinned `devpi-server` and `devpi-web` versions, listens on `127.0.0.1:3141`, and is fronted by Caddy. The minikube StatefulSet was crash-looping under memory pressure (and breaking the Python toolchain everywhere); the new layout removes a layer of dependency on cluster health for critical-path tooling. See [[devpi-on-indri]].
- Move the entire Immich stack — server, machine-learning, valkey,
and the PostgreSQL+VectorChord cluster — off `minikube-indri` and
onto `k3s-ringtail`. Postgres data migrated zero-loss via CNPG
`pg_basebackup` (replica catch-up then promote); row counts on
`asset`, `user`, `album`, `smart_search`, `activity`, `asset_face`
verified equal between source and replica before cutover. The ML
pod now uses ringtail's RTX 4080 via the nvidia-device-plugin
(time-slicing bumped 2 → 4 to share with frigate + ollama). Caddy
routing at `photos.ops.eblu.me` is unchanged (still
`photos.tail8d86e.ts.net`, the device just lives on ringtail now).
Borgmatic backups continue against the same `immich-pg` tailnet
hostname. First concrete chain in the broader indri-k8s
decommission effort.
- Add local nix container build for `tailscale` (`containers/tailscale/default.nix`) so ringtail's tailscale-operator ProxyClass proxy pods pull from the forge mirror instead of `docker.io/tailscale/tailscale`. Pinned at v1.94.2 to match `service-versions.yaml`. Indri's tailscale-operator continues to use upstream during the k8s-to-ringtail migration.
- Address the 6 critical Prowler IaC findings against `argocd/manifests/`. Prowler's IaC provider hardcodes `self._mutelist = None` and delegates filtering to Trivy, but doesn't plumb `--ignorefile` through — so the documented "use Trivy filtering" path is actually broken. Added a shim around `trivy` in the Prowler image that injects `--ignorefile $TRIVY_IGNOREFILE` for `trivy fs` invocations when the env var points at a real file. The IaC cronjob now mounts `mutelist/trivyignore.yaml` (Trivy's per-path schema) and sets the env var, muting the `external-secrets` and `kube-state-metrics` Secret-access findings (KSV-0041, KSV-0114). Separately, `grafana-clusterrole` is tightened to remove `secrets` access entirely: the dashboard sidecar already only consumes ConfigMap-labeled dashboards, so its `RESOURCE` env var is now `configmap` instead of `both`.
- Pin ringtail's wired IP to `192.168.1.21` via NixOS scripted networking; NetworkManager no longer manages `enp5s0`. Removes DHCP lease renewal as a failure mode after a silent lease teardown took ringtail offline. Also explicitly enables `net.ipv4.ip_forward` (previously set implicitly by scripted-DHCP) so k3s pod networking and Tailscale routing continue to work with static networking.
- Ripped out the compensating-controls (CC) framework: deleted `compensating-controls.yaml`, the `review-compensating-controls` mise task, and the associated how-to / explanation docs. Prowler and Kingfisher continue to run weekly and produce reports; the Prowler mutelist YAML files remain in place but no longer carry `CC: <id>` prefixes — each entry just keeps a free-form `Description` of why the finding is muted. The CC review cadence proved to be more overhead than this single-operator homelab needed.
- Wire shower app for public exposure: fly nginx `shower.eblu.me` server
block as a guest-only surface — splash page, `/prizes/<token>/`, static
assets, media. Everything authenticated (`/admin/`, `/host/`,
`/accounts/`) returns 403 with a "tailnet only" pointer. Staff hit
`shower.ops.eblu.me` for the operator console + admin; the app's
v1.0.1 `DJANGO_PUBLIC_URL_BASE` setting makes QR codes generated on
the tailnet point back at the WAN host for guests. Plus a Caddy route
on indri, Pulumi Gandi CNAME, and a Grafana APM dashboard tracking
request rate, error rate, latency, bandwidth, and access logs.
- Mirror Valkey 8.1 locally as `registry.ops.eblu.me/blumeops/valkey`. Replaces direct pulls of `docker.io/valkey/valkey:8.1-alpine` for paperless and immich sidecars. Built via native Dagger pipeline on Alpine 3.22. Stateless swap — no data migration. Authentik's nix-built Redis remains separate.
- Add nix-built amd64 valkey for ringtail (`containers/valkey/default.nix`) so immich-ringtail can stop pulling the upstream multi-arch `docker.io/valkey/valkey` image. Existing `container.py` continues to build Alpine arm64 for paperless on indri. Both bump to valkey 8.1.7 (Alpine 3.22 8.1.7-r0 / nixpkgs 8.1.7).
- Upgrade Grafana Alloy v1.14.0 → v1.16.0 across all four service deployments
(alloy-k8s, alloy-ringtail, alloy-tracing-ringtail on k8s; alloy native on
indri). Pulls in stable database observability (v1.15) and the OTel Collector
v0.147.0 bump. Container build also migrated from Dockerfile to native Dagger
`container.py` per the build-container-image migration playbook.
- Upgraded Dagger from v0.20.1 to v0.20.6 (engine, CLI pin, and SDK regen) and migrated `runner-job-image` from a Debian-based Dockerfile to a native Dagger `container.py` on Alpine 3.23, reusing the shared `alpine_runtime` helper.
- Decommission the wave-1 services on minikube-indri now that paperless,
teslamate, and mealie run on ringtail with their data backed up. Removes the
minikube `paperless`/`teslamate`/`mealie` manifest dirs + ArgoCD app
definitions (pruning the parked Deployments, Services, and the redundant
minikube mealie/paperless PVCs), and drops the `paperless`/`teslamate` roles
from the minikube `blumeops-pg` cluster. The `paperless` and `teslamate`
databases are dropped from indri's blumeops-pg as the finalization step.
miniflux + authentik remain on the minikube cluster (later waves).
- Upgraded the k8s Forgejo runner to the v12.8 line, switched it from first-boot registration to declarative `server.connections` credentials from 1Password, and consolidated the supporting runner how-to documentation.
- Move paperless, teslamate, and mealie off `minikube-indri` onto
`k3s-ringtail`, shedding ~1.1 GiB of resident load from the
OOM-thrashing 8 GiB minikube node (the kernel OOM killer had been
killing `kube-apiserver`/`dockerd`/argocd, flapping every
minikube-hosted service at once). paperless + teslamate databases
move into a fresh CNPG `blumeops-pg` cluster on ringtail via a cold
`pg_dump`/`pg_restore` from the quiesced source — row counts verified
equal before any routing flip; source DBs dropped only after the
ringtail side serves traffic. mealie's SQLite PVC is copied as-is.
paperless media stays on sifaka NFS. Downtime-tolerant cold cutover
(no streaming replication); rollback is repoint-and-scale-up with the
source untouched. Second chain in the indri-k8s decommission after
[[migrate-immich-to-ringtail]].
- Recurring maintenance batch:
- Ringtail flake inputs refreshed (`disko`, `home-manager`, `nixpkgs`).
- Tooling deps bumped: prek hooks (trufflehog v3.95.3, kingfisher v1.101.0, ruff v0.15.14, `ansible-core` 2.21.0); fly proxy base images (nginx 1.30.1-alpine, alloy v1.16.1); `typer==0.26.2` in mise tasks.
- Updated `nixos/ringtail/flake.lock` (weekly cadence): `disko`, `home-manager`, and `nixpkgs` inputs refreshed. `nixpkgs-services` skipped per overlay convention.
- Reviewed `mealie` service version freshness; upstream is 5 minor versions ahead (v3.17.0 vs deployed v3.12.0). Marked reviewed; upgrade deferred.
- Deploy shower v1.1.2 — bump container build to new app release.
- Upgrade unpoller v2.34.0 → v3.2.0 and migrate container build from Dockerfile to native Dagger (container.py). v3.0.0 carries breaking UniFi API changes; v3.2.0 introduces a 60s background poll (cached scrapes) by default — set `interval = 0` in `up.conf` to restore on-demand polling.
- Monthly tooling dependency refresh: prek hooks (trufflehog, kingfisher, ruff, shfmt, prettier, actionlint, ansible-lint), fly proxy base images (nginx 1.30.0, tailscale v1.94.2, alloy v1.16.0), normalize pyyaml lower bound in mise-tasks.
- Add GE-Proton (`pkgs.proton-ge-bin`) to `programs.steam.extraCompatPackages`
on ringtail. Subnautica 2 hangs at Mercuna plugin init under Proton
Experimental + DXVK D3D12; GE-Proton is available as a Steam per-game
compatibility option to work around it.
- Add `sn2-prelaunch` Steam launch wrapper on ringtail that removes
Subnautica 2's stale `Saved/running.dat` and `Saved/beforelobby.dat`
lockfiles before each launch. SN2 pops up an invisible (0×0-sized)
Error dialog when it detects an unclean exit, blocking GameThread
forever; this is observable only as a black screen with a spinning
loader. Use via Steam launch option: `sn2-prelaunch %command%`.
- Add local nix container build for `frigate-notify` (`containers/frigate-notify/default.nix`) so the Frigate→ntfy bridge is rebuilt on ringtail from the forge mirror instead of pulled from `ghcr.io/0x2142/frigate-notify`.
- Add resource limits to all ArgoCD pods to prevent unbounded resource consumption during node-wide pressure events.
- Black-hole the `/mirrors/*` repositories at the Fly proxy edge (`return 403``forge.ops.eblu.me`). A surprise $29.60 Fly bill traced to ~1.24 TB/30d of egress on `forge.eblu.me`, 99.95% of all proxy egress — of which ~71% was AI scrapers (Meta `meta-externalagent`, OpenAI `GPTBot`, Amazonbot) crawling the near-infinite git-history URL space of the public mirror repos and timing out Forgejo in the process. Mirrors exist for supply-chain control and are consumed over the tailnet, so their public web UI had no legitimate audience. `robots.txt` already disallowed `/mirrors/`, but the offending agents ignore it. Tier-2 mitigations (user-agent denylist, Anubis proof-of-work gateway) are documented in `docs/explanation/ai-scraper-mitigation.md`.
- Bump paperless and immich kustomizations to the main-SHA-built valkey tag (`v8.1.6-r0-fabca04`). Routine post-merge follow-up to keep production manifests pointing at images built from a commit on main.
- Bump shower container to v1.1.1 (probe FOD hash).
- Bumped shower app to v1.1.3 (wheel/sdist + FOD hashes probed on ringtail).
- Cap systemd-coredump on ringtail (ProcessSizeMax/ExternalSizeMax 1G, MaxUse 2G) so multi-GB Wine/Proton game crash dumps no longer thrash the disk and lock up the desktop.
- Deploy shower v1.1.1 to ringtail (kustomize newTag bump).
- Deployed shower v1.1.3 to ringtail (image built and pushed from ringtail; runner bypassed due to indri overload).
- Fix three follow-ups from the wave-1 decommission: grant the local
break-glass `admin` account ArgoCD admin rights (`g, admin, role:admin`
previously only the Authentik `admins` group had access, so admin was
locked out whenever its token expired), and repoint the alloy blackbox
probe for teslamate from the deleted minikube service to
`https://tesla.ops.eblu.me/` (through Caddy over Tailscale). The orphaned
paperless/teslamate roles + ExternalSecrets left on the minikube
blumeops-pg are also cleaned up.
- Moved the Immich blackbox health probe from indri's alloy to ringtail's alloy. After the immich migration to ringtail, the probe still targeted `immich-server.immich.svc.cluster.local` on indri's cluster where the service no longer exists, causing a persistent `ServiceProbeFailure` alert.
- Pin shower v1.1.1 FOD outputHash (probed locally on ringtail).
- Rebuild Prowler container against main HEAD (v5.23.0-495e45d) after merging the IaC mutelist Dockerfile changes.
- Rebuild and retag alloy v1.16.0 container images from the main-branch SHA
following the squash-merge of #345, per the build-container-image
squash-merge convention. Both images (`registry.ops.eblu.me/blumeops/alloy`)
now reference `9564435` rather than the branch SHA `26a3ab5`, restoring
source traceability after branch cleanup.
- Rebuild shower from the post-merge commit on main so the container's
SHA tag points at a commit that will still exist after the 30-day
branch-cleanup window. Functionally identical to the branch-tag image
already deployed, just preserves source traceability per
[[build-container-image#Squash-merge and container tags]].
- Rebuild unpoller container from squashed main commit so the image SHA tag matches a commit in main's history (was tagged with the pre-squash branch SHA).
- Rebuild valkey container from squashed main commit (both arm64 dagger and amd64 nix variants), and update paperless + immich-ringtail kustomizations to the main-SHA tags `v8.1.7-ecded30` and `v8.1.7-ecded30-nix`.
- Retired the `blumeops-tasks` mise task (Todoist API) in favor of `heph list --project Blumeops --json` from the self-hosted [hephaestus](https://github.com/eblume/hephaestus) system. Updated docs to point task discovery and rotation reminders at heph, and noted that the `~/code/personal/zk` zettelkasten is migrating into heph docs.
- Switch the Fly proxy deploy strategy from `bluegreen` to `immediate` in `fly/fly.toml`. With a single proxy machine, bluegreen offers little benefit — the green machine routinely failed to reach "started" inside Fly's default 5-minute deploy timeout (the cold-start sequence of `tailscaled``tailscale up` → wait-for-MagicDNS → nginx startup eats most of the budget), and the failed deploys would roll back. `immediate` replaces the machine in place with a brief downtime (~510s) but actually completes.
- Switch the ringtail provisioning playbook's blumeops clone URL from `forge.eblu.me` (public, via Fly proxy) to `forge.ops.eblu.me` (tailnet, direct via Caddy on indri). Ringtail is always on the tailnet, so the WAN round-trip is pure overhead — it also made `provision-ringtail` brittle whenever the Fly proxy was slow or down.
- Switched Grafana's deployment strategy from `RollingUpdate` to `Recreate`. With an RWO PVC holding the SQLite database and Bleve search index, `RollingUpdate` reliably crashloops the new pod on the index lock until rollout timeout. `Recreate` terminates the old pod first so the new one acquires the lock cleanly.
- Update `tailscale-operator-ringtail` ProxyClass to reference the `0108b68` main-SHA build of the tailscale container. Routine post-merge cleanup so the deployed image traces to a commit that survives PR branch cleanup.
- Update the ringtail NixOS flake lockfile (`nixos/ringtail/flake.lock`): bump
`nixpkgs` (b77b3de → 25f5383) and `disko` (5ba0c95 → 115e521) to latest.
`nixpkgs-services` was intentionally left pinned (skipped by the
`flake-update` pipeline). Routine recurring maintenance per [[manage-lockfile]].
- Upgrade native macOS Alloy on indri to v1.16.0. Built on gilbert with Go
1.26.2 + CGO (required for the macOS native DNS resolver, which Tailscale
MagicDNS depends on), scp'd to `~/.local/bin/alloy` on indri, codesigned,
and the LaunchAgent reloaded. Completes the v1.16.0 fleet upgrade started
in #345 — all four Alloy services (alloy-k8s, alloy-ringtail,
alloy-tracing-ringtail, alloy ansible) now run v1.16.0.
- Upgraded zot on indri from v2.1.15 to v2.1.16 (security fixes: TLS verification on metrics client, CORS Allow-Credentials suppression on wildcard origins, manifest/API-key body size limits).
### Documentation
- Reviewed `replicating-blumeops` tutorial: fixed "BluemeOps" typos (also in `contributing.md`) and added `last-reviewed` frontmatter.
- Reviewed [[indri]] reference card: added `devpi`, `cv`, and `docs` to the native-services list; widened the k8s note to reflect the growing set of apps now on ringtail and the planned indri-minikube decommission; added CPU/RAM specs.
- New how-to: rotate-fly-deploy-token. Documents the 75-day rotation cadence, why we use `org`-scoped tokens (silences the cosmetic metrics-token warning on `fly status` with marginal blast-radius cost given the single-app personal org), and the procedure for rotation + Forgejo Actions secret sync.
- Add `docs/explanation/ai-scraper-mitigation.md` — the egress-cost / AI-crawler threat model for the public Fly proxy, the tiered mitigation plan (Tier 1: mirror black-hole, shipped; Tier 2: user-agent denylist + Anubis; Tier 3: Cloudflare, rejected on principle), and the data behind it.
- Fix manage-forgejo-mirrors verify step — sync button is on the repo settings page ("Synchronize now"), not the main repo page.
- Fixed the `op item edit` invocation in the [[zot]] API-key rotation procedure: the previous `pbpaste | op item edit ... "field[password]=-"` stdin syntax is rejected by op 2.34 as "invalid JSON" (recent op versions treat piped input as a full JSON template, not a single field value). Procedure now reads the clipboard into a local fish variable and passes it as an inline assignment.
- Fixed the export-filename step in [[run-1password-backup]]: 1Password's desktop app names the export `1PasswordExport-<account-uuid>-<timestamp>.1pux` automatically rather than letting you save to a fixed name, so the procedure now points the task at that glob instead of pretending the default name is `1Password-export.1pux`.
- Refresh the contributing tutorial: add `last-reviewed`, include the `.ai.md` changelog fragment type, and clarify that `prek` is pinned via `mise`.
- Review and refresh the Navidrome reference card: add `last-reviewed`, correct the scanner env var name, document the current image/version, and record routing and runtime details from the manifests.
- Review and refresh the Ollama reference card: add `last-reviewed`, bump the documented image tag to 0.20.4, and add the two `qwen3.5` models now declared in `models.txt`.
- Reviewed [[1password]] reference card: added the `blumeops` vs `Personal` vault split, noted that `onepassword-connect` runs on both indri and ringtail (not just one cluster), and pulled the `op read` vs `op item get --fields` guidance up from agent memory into the card.
- Reviewed `index.md`; added ringtail to the infrastructure overview and stamped `last-reviewed`.
- Reviewed transmission card: corrected storage layout (`/config/` is emptyDir, watch dir disabled) and noted the Prometheus exporter sidecar.
- rotate-fly-deploy-token: combine mint+store into one command with both fish and bash forms; document the `op item edit` "Password item requires ps value" validator gotcha and the placeholder-password workaround.
### AI Assistance
- Adopt `AGENTS.md` as the canonical agent instruction file, keep `CLAUDE.md` as a compatibility shim, and update docs to reference the neutral file and the correct agent-change-process path.
- CLAUDE.md now imports AGENTS.md via `@AGENTS.md` instead of telling agents to go read it. Claude Code only auto-loads CLAUDE.md, so the prose shim was easy to skip; the import inlines AGENTS.md into the session prompt unconditionally.
### Miscellaneous
- Removed the dead minikube manifests, container builds, and tooling shims left behind after the cv + docs migration to indri-native (#342). Deletes `argocd/{apps,manifests}/{cv,docs}/`, `containers/{cv,quartz}/`, and the `quartz``docs` mapping in `mise-tasks/container-version-check`. Bumps `docs.current-version` to `v1.16.0` (the blumeops release tag) now that the legacy nginx-base version pin is gone.
- Rebuild shower v1.1.0 container from main HEAD (`3c7967e`) and bump the
kustomization tag to `v1.1.0-3c7967e-nix`. The PR was squash-merged, so
the branch commit `444ff91` baked into the prior tag isn't reachable
from main's history. The new tag points at a commit that exists on
main; image content is byte-identical because the FOD output is content
addressed and the inputs didn't change.
- Rebuild shower v1.1.2 from main HEAD (a33fa47) and retag — PR #358 was squash-merged so the branch SHA baked into the prior image tag isn't reachable from main. FOD is content-addressed, so image bytes are identical; only provenance changes.
- Remove the duplicate Homepage tiles for Mealie, Paperless, Immich, and
TeslaMate. Homepage runs on ringtail and autodiscovers ringtail Ingresses via
`gethomepage.dev/*` annotations; once these services migrated to ringtail they
were discovered automatically, making their leftover static `services.yaml`
entries (needed only while they lived on minikube) redundant.
- Removed the now-unused `containers/devpi/` Dagger build artifact. Devpi runs natively on indri via uv venv; the container image is no longer referenced anywhere. Doc examples in `docs/reference/tools/dagger.md` updated to use `miniflux` as the example container name.
- `container-build-and-release` now prints the specific `mise run runner-logs <N>` command after dispatching, polling the Forgejo API to resolve the run number for the commit it just triggered.
- `mise run runner-logs <run> -j <n>` now reports a clear error when the log file doesn't exist on indri (e.g. a runner crash that left `action_task.log_in_storage = 0`). Previously it printed only the header and exited 0, because `zstdcat` exits 0 with a "can't stat … -- ignored" stderr message and ssh+fish on indri swallows the remote exit code.
## [v1.16.0] - 2026-04-18 ## [v1.16.0] - 2026-04-18
### Infrastructure ### Infrastructure

View file

@ -260,7 +260,5 @@
tags: cv tags: cv
- role: docs - role: docs
tags: docs tags: docs
- role: heph
tags: heph
- role: caddy - role: caddy
tags: caddy tags: caddy

View file

@ -56,9 +56,8 @@ borgmatic_k8s_sqlite_dumps:
namespace: mealie namespace: mealie
label_selector: app=mealie label_selector: app=mealie
db_path: /app/data/mealie.db db_path: /app/data/mealie.db
# migrated to ringtail (wave-1); ssh to ringtail and run k3s kubectl # local kubectl, --context=minikube (indri's only configured ctx)
# there, same as shower below. target: local:minikube
target: ssh:eblume@ringtail
- name: shower - name: shower
namespace: shower namespace: shower
label_selector: app=shower label_selector: app=shower
@ -103,18 +102,17 @@ borgmatic_postgresql_databases:
hostname: pg.ops.eblu.me hostname: pg.ops.eblu.me
port: 5432 port: 5432
username: borgmatic username: borgmatic
- name: teslamate
hostname: pg.ops.eblu.me
port: 5432
username: borgmatic
- name: authentik - name: authentik
hostname: pg.ops.eblu.me hostname: pg.ops.eblu.me
port: 5432 port: 5432
username: borgmatic username: borgmatic
# migrated to ringtail blumeops-pg (wave-1); port 5434 = Caddy L4 route
- name: teslamate
hostname: pg.ops.eblu.me
port: 5434
username: borgmatic
- name: paperless - name: paperless
hostname: pg.ops.eblu.me hostname: pg.ops.eblu.me
port: 5434 port: 5432
username: borgmatic username: borgmatic
# immich-pg cluster (VectorChord) via Caddy L4 on port 5433 # immich-pg cluster (VectorChord) via Caddy L4 on port 5433
- name: immich - name: immich

View file

@ -19,10 +19,8 @@
ansible.builtin.copy: ansible.builtin.copy:
content: | content: |
# Managed by ansible (borgmatic role) - k8s PostgreSQL backup credentials # Managed by ansible (borgmatic role) - k8s PostgreSQL backup credentials
# 5432 = minikube blumeops-pg, 5433 = immich-pg, 5434 = ringtail blumeops-pg
pg.ops.eblu.me:5432:*:borgmatic:{{ borgmatic_db_password }} pg.ops.eblu.me:5432:*:borgmatic:{{ borgmatic_db_password }}
pg.ops.eblu.me:5433:*:borgmatic:{{ borgmatic_db_password }} pg.ops.eblu.me:5433:*:borgmatic:{{ borgmatic_db_password }}
pg.ops.eblu.me:5434:*:borgmatic:{{ borgmatic_db_password }}
dest: ~/.pgpass dest: ~/.pgpass
mode: '0600' mode: '0600'
no_log: true no_log: true

View file

@ -28,9 +28,7 @@ db_path=${4:?missing db path}
name=${5:?missing name} name=${5:?missing name}
dump_target=${6:?missing dump target} dump_target=${6:?missing dump target}
# Stage the backup next to the source DB (a guaranteed-writable volume); pod_tmp="/tmp/${name}-backup.db"
# minimal nix images (e.g. mealie) have no /tmp.
pod_tmp="$(dirname "$db_path")/.borgmatic-backup-${name}.db"
python_backup='import sqlite3; sqlite3.connect("'"$db_path"'").backup(sqlite3.connect("'"$pod_tmp"'"))' python_backup='import sqlite3; sqlite3.connect("'"$db_path"'").backup(sqlite3.connect("'"$pod_tmp"'"))'

View file

@ -52,9 +52,6 @@ caddy_services:
- name: devpi - name: devpi
host: "pypi.{{ caddy_domain }}" host: "pypi.{{ caddy_domain }}"
backend: "http://localhost:3141" backend: "http://localhost:3141"
- name: heph
host: "heph.{{ caddy_domain }}"
backend: "http://localhost:8787" # hephaestus hub (server mode) + PWA shell
- name: kiwix - name: kiwix
host: "kiwix.{{ caddy_domain }}" host: "kiwix.{{ caddy_domain }}"
backend: "https://kiwix.tail8d86e.ts.net" backend: "https://kiwix.tail8d86e.ts.net"
@ -120,8 +117,6 @@ caddy_tcp_services:
backend: "pg.tail8d86e.ts.net:5432" # PostgreSQL (blumeops-pg) backend: "pg.tail8d86e.ts.net:5432" # PostgreSQL (blumeops-pg)
- port: 5433 - port: 5433
backend: "immich-pg.tail8d86e.ts.net:5432" # PostgreSQL (immich-pg) backend: "immich-pg.tail8d86e.ts.net:5432" # PostgreSQL (immich-pg)
- port: 5434
backend: "blumeops-pg-ringtail.tail8d86e.ts.net:5432" # PostgreSQL (blumeops-pg on ringtail)
- port: "{{ sifaka_node_exporter_port }}" - port: "{{ sifaka_node_exporter_port }}"
backend: "sifaka:{{ sifaka_node_exporter_port }}" # Sifaka node_exporter backend: "sifaka:{{ sifaka_node_exporter_port }}" # Sifaka node_exporter
- port: "{{ sifaka_smartctl_exporter_port }}" - port: "{{ sifaka_smartctl_exporter_port }}"

View file

@ -3,8 +3,9 @@
# Caddy serves docs_content_dir directly via the static-kind service block, # Caddy serves docs_content_dir directly via the static-kind service block,
# with Quartz-style try_files (path → path/ → path.html → 404). # with Quartz-style try_files (path → path/ → path.html → 404).
docs_version: "v1.17.0" docs_version: "v1.16.0"
docs_release_url: "https://forge.eblu.me/eblume/blumeops/releases/download/{{ docs_version }}/docs-{{ docs_version }}.tar.gz" docs_release_url: "https://forge.eblu.me/eblume/blumeops/releases/download/{{ docs_version }}/docs-{{ docs_version }}.tar.gz"
docs_home: /Users/erichblume/blumeops/docs docs_home: /Users/erichblume/blumeops/docs
docs_content_dir: "{{ docs_home }}/content" docs_content_dir: "{{ docs_home }}/content"
docs_version_sentinel: "{{ docs_home }}/.installed-version" docs_version_sentinel: "{{ docs_home }}/.installed-version"

View file

@ -1,49 +0,0 @@
---
# hephaestus hub — the canonical heph replica (server mode) on indri.
# Other devices (e.g. gilbert) are spokes that sync against this hub.
# See [[set-up-sync-hub]] and [[host-heph-pwa]] in the hephaestus repo.
# Pinned release used for the initial `cargo install` and the PWA shell.
# After bootstrap, hephd's own --self-update keeps the binary current; this
# pin only governs the first install and the bundled PWA shell version.
heph_version: v1.2.1
# Anonymous public HTTPS clone — matches hephd's INSTALL_GIT_URL so the initial
# install and unattended self-update build from the same source (no ssh-agent).
heph_repo_url: https://forge.eblu.me/eblume/hephaestus.git
heph_bin_dir: /Users/erichblume/.cargo/bin
heph_binary: "{{ heph_bin_dir }}/hephd"
# rustc/cargo here are rustup shims. The bare (non-mise) environment that the
# launchagent and ansible run in falls back to rustup's *default* toolchain,
# which can lag behind heph's rust-version floor (Cargo.toml: 1.89). Pin the
# channel explicitly so both the bootstrap build and unattended self-update
# always use a current toolchain regardless of the host's rustup default.
heph_rust_toolchain: stable
heph_data_dir: /Users/erichblume/.local/share/heph
heph_db: "{{ heph_data_dir }}/heph.db"
heph_socket: "{{ heph_data_dir }}/hephd.sock"
heph_log_dir: /Users/erichblume/Library/Logs
# Version-pinned source checkout; the PWA static shell is served directly from
# its heph-pwa/ subdir (no copy), keeping shell and hub in lockstep at heph_version.
heph_pwa_src_dir: /Users/erichblume/.cache/heph-pwa-src
heph_web_root: "{{ heph_pwa_src_dir }}/heph-pwa"
# Hub listens on all interfaces so tailnet spokes can reach it directly
# (http://indri.tail8d86e.ts.net:8787) and Caddy can proxy heph.ops.eblu.me.
# Access is gated by Authentik OIDC regardless — tailnet reachability is not
# enough (this is the owner's most sensitive data).
heph_http_addr: 0.0.0.0:8787
heph_port: 8787
heph_external_url: https://heph.ops.eblu.me
# Authentik OIDC — issuer + audience together turn hub auth on. The audience is
# the device-code client id (see argocd/manifests/authentik heph blueprint).
heph_oidc_issuer: https://authentik.ops.eblu.me/application/o/heph/
heph_oidc_audience: heph
# Self-update poll interval (seconds). 10 minutes.
heph_self_update_interval_secs: 600

View file

@ -1,6 +0,0 @@
---
- name: Restart heph
ansible.builtin.shell: |
launchctl unload ~/Library/LaunchAgents/mcquack.eblume.heph.plist 2>/dev/null || true
launchctl load ~/Library/LaunchAgents/mcquack.eblume.heph.plist
changed_when: true

View file

@ -1,82 +0,0 @@
---
# hephaestus hub (server mode) on indri.
#
# DATA SEEDING (one-time, Path A — do this BEFORE the first provision so the hub
# adopts gilbert's existing data instead of being born empty):
#
# 1. On the seed device (gilbert): heph daemon stop
# 2. Copy its store to indri: scp ~/.local/share/heph/heph.db \
# indri:~/.local/share/heph/heph.db
# 3. On indri, give the hub its OWN device origin (keeps gilbert's owner_id +
# data; hephd regenerates a fresh origin on next start when it is missing):
# sqlite3 ~/.local/share/heph/heph.db "DELETE FROM meta WHERE key='origin';"
# 4. Run this role (installs hephd, stages the PWA, loads the launchagent).
#
# hephd auto-creates an empty store on first start if none exists, so seeding is
# optional — skip it only if you intend a fresh, empty hub.
- name: Ensure heph data directory exists
ansible.builtin.file:
path: "{{ heph_data_dir }}"
state: directory
mode: '0700'
- name: Check for installed hephd binary
ansible.builtin.stat:
path: "{{ heph_binary }}"
register: heph_binary_stat
# Bootstrap install only when hephd is absent. Thereafter hephd's own
# --self-update keeps it current; ansible must not fight (or downgrade) it.
# This builds from source and can take several minutes on a cold cargo cache.
- name: Bootstrap-install heph + hephd from the forge ({{ heph_version }})
ansible.builtin.command:
cmd: >-
{{ heph_bin_dir }}/cargo install --locked
--git {{ heph_repo_url }}
--tag {{ heph_version }}
heph hephd
environment:
PATH: "{{ heph_bin_dir }}:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
RUSTUP_TOOLCHAIN: "{{ heph_rust_toolchain }}"
when: not heph_binary_stat.stat.exists
changed_when: true
notify: Restart heph
# Checkout provides the PWA shell at {{ heph_web_root }} (heph-pwa/ subdir),
# served directly by hephd. Static files are read from disk per request, so a
# version bump needs no restart; the service worker (CACHE = "heph-pwa-vN")
# evicts stale assets on next load.
- name: Ensure heph cache parent directory exists
ansible.builtin.file:
path: "{{ heph_pwa_src_dir | dirname }}"
state: directory
mode: '0755'
- name: Stage heph-pwa source at {{ heph_version }}
ansible.builtin.git:
repo: "{{ heph_repo_url }}"
dest: "{{ heph_pwa_src_dir }}"
version: "{{ heph_version }}"
depth: 1
single_branch: true
force: true
- name: Deploy heph LaunchAgent plist
ansible.builtin.template:
src: heph.plist.j2
dest: ~/Library/LaunchAgents/mcquack.eblume.heph.plist
mode: '0644'
notify: Restart heph
- name: Check if heph LaunchAgent is loaded
ansible.builtin.command: launchctl list mcquack.eblume.heph
register: heph_launchctl_check
changed_when: false
failed_when: false
- name: Load heph LaunchAgent if not loaded
ansible.builtin.command: launchctl load ~/Library/LaunchAgents/mcquack.eblume.heph.plist
when: heph_launchctl_check.rc != 0
changed_when: true
failed_when: false

View file

@ -1,50 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- {{ ansible_managed }} -->
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>mcquack.eblume.heph</string>
<key>ProgramArguments</key>
<array>
<string>{{ heph_binary }}</string>
<string>--mode</string>
<string>server</string>
<string>--http-addr</string>
<string>{{ heph_http_addr }}</string>
<string>--db</string>
<string>{{ heph_db }}</string>
<string>--socket</string>
<string>{{ heph_socket }}</string>
<string>--web-root</string>
<string>{{ heph_web_root }}</string>
<string>--oidc-issuer</string>
<string>{{ heph_oidc_issuer }}</string>
<string>--oidc-audience</string>
<string>{{ heph_oidc_audience }}</string>
<string>--self-update</string>
<string>--self-update-interval-secs</string>
<string>{{ heph_self_update_interval_secs }}</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>EnvironmentVariables</key>
<dict>
<!-- cargo + toolchain on PATH so --self-update can run `cargo install`. -->
<key>PATH</key>
<string>{{ heph_bin_dir }}:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
<key>HOME</key>
<string>/Users/erichblume</string>
<!-- Pin the rustup channel: the launchagent runs without mise, so a bare
cargo shim would otherwise use rustup's (stale) default toolchain. -->
<key>RUSTUP_TOOLCHAIN</key>
<string>{{ heph_rust_toolchain }}</string>
</dict>
<key>StandardOutPath</key>
<string>{{ heph_log_dir }}/mcquack.heph.out.log</string>
<key>StandardErrorPath</key>
<string>{{ heph_log_dir }}/mcquack.heph.err.log</string>
</dict>
</plist>

View file

@ -15,7 +15,7 @@ spec:
source: source:
repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git
targetRevision: main targetRevision: main
path: argocd/manifests/external-secrets-ringtail path: argocd/manifests/external-secrets
destination: destination:
server: https://ringtail.tail8d86e.ts.net:6443 server: https://ringtail.tail8d86e.ts.net:6443
namespace: external-secrets namespace: external-secrets

View file

@ -1,26 +0,0 @@
# Mealie on ringtail k3s.
#
# Wave-1 indri-k8s decommission. Staging deployment; the minikube `mealie`
# app stays in parallel until cutover (copy SQLite PVC, drop the minikube
# tailscale ingress, flip Caddy). See [[migrate-wave1-ringtail]].
#
# Prerequisites:
# - external-secrets-ringtail (onepassword-blumeops ClusterSecretStore)
# - mealie-data PVC contents copied from minikube at cutover
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: mealie-ringtail
namespace: argocd
spec:
project: default
source:
repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git
targetRevision: main
path: argocd/manifests/mealie-ringtail
destination:
server: https://ringtail.tail8d86e.ts.net:6443
namespace: mealie
syncPolicy:
syncOptions:
- CreateNamespace=true

17
argocd/apps/mealie.yaml Normal file
View file

@ -0,0 +1,17 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: mealie
namespace: argocd
spec:
project: default
source:
repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git
targetRevision: main
path: argocd/manifests/mealie
destination:
server: https://kubernetes.default.svc
namespace: mealie
syncPolicy:
syncOptions:
- CreateNamespace=true

View file

@ -1,28 +0,0 @@
# Paperless-ngx on ringtail k3s.
#
# Wave-1 indri-k8s decommission. Staging deployment; the minikube
# `paperless` app stays in parallel until cutover (drop the minikube
# tailscale ingress to free the name, then flip Caddy). See
# [[migrate-wave1-ringtail]].
#
# Prerequisites:
# - databases-ringtail blumeops-pg (paperless database + role)
# - external-secrets-ringtail (onepassword-blumeops ClusterSecretStore)
# - sifaka NFS rule granting ringtail access to /volume1/paperless
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: paperless-ringtail
namespace: argocd
spec:
project: default
source:
repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git
targetRevision: main
path: argocd/manifests/paperless-ringtail
destination:
server: https://ringtail.tail8d86e.ts.net:6443
namespace: paperless
syncPolicy:
syncOptions:
- CreateNamespace=true

View file

@ -0,0 +1,17 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: paperless
namespace: argocd
spec:
project: default
source:
repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git
targetRevision: main
path: argocd/manifests/paperless
destination:
server: https://kubernetes.default.svc
namespace: paperless
syncPolicy:
syncOptions:
- CreateNamespace=true

View file

@ -1,28 +0,0 @@
# TeslaMate on ringtail k3s.
#
# Wave-1 indri-k8s decommission. Staging deployment; the minikube
# `teslamate` app stays in parallel until cutover (migrate the teslamate
# database, drop the minikube tailscale ingress, flip Caddy). See
# [[migrate-wave1-ringtail]].
#
# Prerequisites:
# - databases-ringtail blumeops-pg (teslamate database + role; cube +
# earthdistance extensions created by superuser at cutover)
# - external-secrets-ringtail (onepassword-blumeops ClusterSecretStore)
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: teslamate-ringtail
namespace: argocd
spec:
project: default
source:
repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git
targetRevision: main
path: argocd/manifests/teslamate-ringtail
destination:
server: https://ringtail.tail8d86e.ts.net:6443
namespace: teslamate
syncPolicy:
syncOptions:
- CreateNamespace=true

View file

@ -0,0 +1,32 @@
# TeslaMate Tesla Data Logger
# Requires: CloudNativePG PostgreSQL cluster and manual secret setup
#
# Before syncing, create the namespace and secrets:
# kubectl create namespace teslamate
# op inject -i argocd/manifests/databases/secret-teslamate.yaml.tpl | kubectl apply -f -
# op inject -i argocd/manifests/teslamate/secret-encryption-key.yaml.tpl | kubectl apply -f -
# op inject -i argocd/manifests/teslamate/secret-db.yaml.tpl | kubectl apply -f -
#
# Then create the database:
# PGPASSWORD=$(op read "op://blumeops/postgres/password") \
# psql -h pg.ops.eblu.me -U eblume -c "CREATE DATABASE teslamate OWNER teslamate;"
#
# After syncing, access the TeslaMate UI at https://tesla.tail8d86e.ts.net to complete
# Tesla API authentication via OAuth flow.
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: teslamate
namespace: argocd
spec:
project: default
source:
repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git
targetRevision: main
path: argocd/manifests/teslamate
destination:
server: https://kubernetes.default.svc
namespace: teslamate
syncPolicy:
syncOptions:
- CreateNamespace=true

View file

@ -191,9 +191,8 @@ prometheus.exporter.blackbox "services" {
} }
target { target {
// Migrated to ringtail (wave-1); probe through Caddy over Tailscale.
name = "teslamate" name = "teslamate"
address = "https://tesla.ops.eblu.me/" address = "http://teslamate.teslamate.svc.cluster.local:4000/"
module = "http_2xx" module = "http_2xx"
} }

View file

@ -2,9 +2,6 @@
# #
# - workflow-bot: minimal CI/CD permissions (sync, get) # - workflow-bot: minimal CI/CD permissions (sync, get)
# - admins: Authentik admins group mapped to ArgoCD admin role # - admins: Authentik admins group mapped to ArgoCD admin role
# - admin: local break-glass account — keeps ArgoCD admin rights for when
# Authentik SSO is unavailable (without this it has no permissions, since
# policy.default is unset)
# #
apiVersion: v1 apiVersion: v1
kind: ConfigMap kind: ConfigMap
@ -17,4 +14,3 @@ data:
p, role:workflow-bot, applications, get, *, allow p, role:workflow-bot, applications, get, *, allow
g, workflow-bot, role:workflow-bot g, workflow-bot, role:workflow-bot
g, admins, role:admin g, admins, role:admin
g, admin, role:admin

View file

@ -434,93 +434,3 @@ data:
provider: !KeyOf mealie-provider provider: !KeyOf mealie-provider
meta_launch_url: https://meals.ops.eblu.me meta_launch_url: https://meals.ops.eblu.me
policy_engine_mode: all policy_engine_mode: all
heph.yaml: |
version: 1
metadata:
name: BlumeOps Heph SSO
labels:
blueprints.goauthentik.io/description: "Hephaestus hub OIDC (device-code) provider, application, and device-code flow"
entries:
# Device-code flow (RFC 8628). authentik ships no default for this, so we
# create one and bind it to the brand below. An empty stage_configuration
# flow is sufficient: the already-authenticated user just confirms the code.
- model: authentik_flows.flow
id: device-code-flow
identifiers:
slug: default-device-code-flow
attrs:
name: Device code flow
title: Device code flow
slug: default-device-code-flow
designation: stage_configuration
authentication: require_authenticated
# Enable the device-code grant globally by binding the flow to the default
# brand (domain authentik-default). Partial update — only sets this field.
- model: authentik_brands.brand
identifiers:
domain: authentik-default
attrs:
flow_device_code: !KeyOf device-code-flow
# OAuth2 provider for heph — PUBLIC client (device-code + PKCE, no secret).
# client_id doubles as the token audience the hub verifies (--oidc-audience heph),
# and the app slug 'heph' is the issuer path (/application/o/heph/).
- model: authentik_providers_oauth2.oauth2provider
id: heph-provider
identifiers:
name: Heph
attrs:
name: Heph
authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
client_type: public
client_id: heph
# CLI/TUI use the device-code grant (no redirect). The heph-pwa browser
# login uses Authorization Code + PKCE, which DOES redirect back to the
# app's origin — register those here (Authentik also keys token-endpoint
# CORS off these origins). Trailing slash matters: the PWA's redirect_uri
# is its base dir, e.g. https://heph.ops.eblu.me/.
redirect_uris:
- matching_mode: strict
url: https://heph.ops.eblu.me/
- matching_mode: strict
url: http://localhost:8787/ # local dev (hephd --web-root)
signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]]
property_mappings:
- !Find [authentik_providers_oauth2.scopemapping, [scope_name, openid]]
- !Find [authentik_providers_oauth2.scopemapping, [scope_name, email]]
- !Find [authentik_providers_oauth2.scopemapping, [scope_name, profile]]
# offline_access: heph CLI requests "openid offline_access"; without
# this mapping the refresh token is session-bound and hephd's
# refresh_token grant 400s once the session lapses (spoke sync dies).
- !Find [authentik_providers_oauth2.scopemapping, [scope_name, offline_access]]
sub_mode: hashed_user_id
include_claims_in_id_token: true
# Heph application — linked to the OAuth2 provider
- model: authentik_core.application
id: heph-app
identifiers:
slug: heph
attrs:
name: Hephaestus
slug: heph
provider: !KeyOf heph-provider
meta_launch_url: https://heph.ops.eblu.me
policy_engine_mode: any
# Policy binding — restrict heph to admins group (single-owner, sensitive data)
- model: authentik_policies.policybinding
identifiers:
order: 0
target: !KeyOf heph-app
group: !Find [authentik_core.group, [name, admins]]
attrs:
target: !KeyOf heph-app
group: !Find [authentik_core.group, [name, admins]]
order: 0
enabled: true
negate: false
timeout: 30

View file

@ -1,97 +0,0 @@
# PostgreSQL Cluster for blumeops services on ringtail k3s.
#
# Wave-1 indri-k8s decommission target (see [[migrate-wave1-ringtail]]).
# Holds the paperless and teslamate databases migrated off the minikube
# blumeops-pg via cold pg_dump/pg_restore at cutover. miniflux + authentik
# stay where they are for now (later waves), so this cluster only carries
# the wave-1 roles.
#
# Apps reach this in-cluster at blumeops-pg-rw.databases.svc.cluster.local
# — the same name they used on minikube, so teslamate's DATABASE_HOST is
# unchanged.
#
# Database creation is deferred to cutover, mirroring the minikube cluster
# (where only the bootstrap database is declared and the rest were created
# out-of-band):
# - paperless: the bootstrap database below (restored into at cutover).
# - teslamate: created at its cutover by the eblume superuser, because the
# dump's `earthdistance` extension is untrusted and CREATE EXTENSION
# needs superuser. (cube + earthdistance ownership then transferred to
# the teslamate role so it can ALTER EXTENSION UPDATE.)
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: blumeops-pg
namespace: databases
spec:
instances: 1
imageName: ghcr.io/cloudnative-pg/postgresql:18.3
storage:
size: 10Gi
storageClass: local-path
bootstrap:
initdb:
database: paperless
owner: paperless
managed:
roles:
# eblume superuser for admin + privileged restore steps (extensions)
- name: eblume
login: true
superuser: true
createdb: true
createrole: true
connectionLimit: -1
ensure: present
inherit: true
passwordSecret:
name: blumeops-pg-eblume
# borgmatic read-only user for backups
- name: borgmatic
login: true
connectionLimit: -1
ensure: present
inherit: true
inRoles:
- pg_read_all_data
passwordSecret:
name: blumeops-pg-borgmatic
# paperless user (also the bootstrap database owner above; the
# managed role sets its password from the 1Password-backed secret)
- name: paperless
login: true
connectionLimit: -1
ensure: present
inherit: true
passwordSecret:
name: blumeops-pg-paperless
# teslamate user. Extension ownership (cube, earthdistance) is
# transferred to this role at cutover so it can ALTER EXTENSION UPDATE.
- name: teslamate
login: true
connectionLimit: -1
ensure: present
inherit: true
passwordSecret:
name: blumeops-pg-teslamate
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "1Gi"
cpu: "500m"
postgresql:
parameters:
max_connections: "50"
shared_buffers: "128MB"
password_encryption: "scram-sha-256"
pg_hba:
# Password auth from anywhere; network security is via Tailscale.
- host all all 0.0.0.0/0 scram-sha-256
- host all all ::/0 scram-sha-256

View file

@ -1,30 +0,0 @@
# ExternalSecret for borgmatic backup user password
#
# Replaces the manual op inject workflow from secret-borgmatic.yaml.tpl
#
# 1Password item: "borgmatic" in blumeops vault
# Field: "db-password"
#
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: blumeops-pg-borgmatic
namespace: databases
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: onepassword-blumeops
target:
name: blumeops-pg-borgmatic
creationPolicy: Owner
template:
type: kubernetes.io/basic-auth
data:
username: borgmatic
password: "{{ .password }}"
data:
- secretKey: password
remoteRef:
key: borgmatic
property: db-password

View file

@ -1,30 +0,0 @@
# ExternalSecret for eblume superuser password
#
# Replaces the manual op inject workflow from secret-eblume.yaml.tpl
#
# 1Password item: "postgres" in blumeops vault
# Field: "password"
#
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: blumeops-pg-eblume
namespace: databases
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: onepassword-blumeops
target:
name: blumeops-pg-eblume
creationPolicy: Owner
template:
type: kubernetes.io/basic-auth
data:
username: eblume
password: "{{ .password }}"
data:
- secretKey: password
remoteRef:
key: postgres
property: password

View file

@ -7,10 +7,3 @@ resources:
- immich-pg.yaml - immich-pg.yaml
- external-secret-immich-borgmatic.yaml - external-secret-immich-borgmatic.yaml
- service-immich-pg-tailscale.yaml - service-immich-pg-tailscale.yaml
# wave-1 indri-k8s decommission: blumeops-pg (paperless + teslamate)
- blumeops-pg.yaml
- service-blumeops-pg-tailscale.yaml
- external-secret-eblume.yaml
- external-secret-borgmatic.yaml
- external-secret-paperless.yaml
- external-secret-teslamate.yaml

View file

@ -1,24 +0,0 @@
# Tailscale LoadBalancer for the ringtail blumeops-pg cluster.
# Canonical hostname: blumeops-pg-ringtail.tail8d86e.ts.net (distinct from
# the minikube blumeops-pg, which still owns pg.tail8d86e.ts.net until the
# wave-1 decommission). Borgmatic on indri and the Grafana TeslaMate
# datasource reach it via the Caddy L4 route pg.ops.eblu.me:5434.
apiVersion: v1
kind: Service
metadata:
name: blumeops-pg-tailscale
namespace: databases
annotations:
tailscale.com/hostname: "blumeops-pg-ringtail"
tailscale.com/proxy-class: "default"
spec:
type: LoadBalancer
loadBalancerClass: tailscale
selector:
cnpg.io/cluster: blumeops-pg
role: primary
ports:
- name: postgresql
port: 5432
targetPort: 5432
protocol: TCP

View file

@ -44,9 +44,18 @@ spec:
- pg_read_all_data - pg_read_all_data
passwordSecret: passwordSecret:
name: blumeops-pg-borgmatic name: blumeops-pg-borgmatic
# teslamate + paperless roles removed: migrated to ringtail blumeops-pg # teslamate user for TeslaMate Tesla data logger
# (wave-1 decommission). Their databases were dropped from this cluster # Superuser removed. Extension ownership (cube, earthdistance)
# after the cutover was verified and backed up. # transferred manually so teslamate can ALTER EXTENSION UPDATE.
# earthdistance is untrusted — DROP+CREATE needs temporary
# superuser escalation during upgrades.
- name: teslamate
login: true
connectionLimit: -1
ensure: present
inherit: true
passwordSecret:
name: blumeops-pg-teslamate
# authentik user for Authentik identity provider (runs on ringtail) # authentik user for Authentik identity provider (runs on ringtail)
- name: authentik - name: authentik
login: true login: true
@ -56,6 +65,14 @@ spec:
createdb: true createdb: true
passwordSecret: passwordSecret:
name: blumeops-pg-authentik name: blumeops-pg-authentik
# paperless user for Paperless-ngx document management
- name: paperless
login: true
connectionLimit: -1
ensure: present
inherit: true
passwordSecret:
name: blumeops-pg-paperless
# Resource limits for minikube environment # Resource limits for minikube environment
resources: resources:

View file

@ -9,4 +9,6 @@ resources:
- service-metrics-tailscale.yaml - service-metrics-tailscale.yaml
- external-secret-eblume.yaml - external-secret-eblume.yaml
- external-secret-borgmatic.yaml - external-secret-borgmatic.yaml
- external-secret-teslamate.yaml
- external-secret-authentik.yaml - external-secret-authentik.yaml
- external-secret-paperless.yaml

View file

@ -1,16 +0,0 @@
# Ringtail (amd64) overlay for external-secrets.
#
# Reuses the shared indri manifest as a base and only overrides the controller
# image to the nix-built amd64 variant (`-nix` tag). The base sets the arm64
# image (built via containers/external-secrets/container.py on indri's Dagger
# runner); ringtail's k3s is amd64 and needs the image built by
# containers/external-secrets/default.nix on the nix-container-builder.
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../external-secrets
images:
- name: registry.ops.eblu.me/blumeops/external-secrets
newTag: v2.2.0-13895bb-nix

View file

@ -12,5 +12,4 @@ resources:
images: images:
- name: ghcr.io/external-secrets/external-secrets - name: ghcr.io/external-secrets/external-secrets
newName: registry.ops.eblu.me/blumeops/external-secrets newTag: v2.2.0
newTag: v2.2.0-13895bb

View file

@ -63,7 +63,5 @@ datasources:
password: $TESLAMATE_DB_PASSWORD password: $TESLAMATE_DB_PASSWORD
type: postgres type: postgres
uid: TeslaMate uid: TeslaMate
# teslamate DB migrated to ringtail blumeops-pg (wave-1); reached via the url: blumeops-pg-rw.databases.svc.cluster.local:5432
# Caddy L4 route on indri (pg.ops.eblu.me:5434 -> blumeops-pg-ringtail).
url: pg.ops.eblu.me:5434
user: teslamate user: teslamate

View file

@ -71,6 +71,10 @@
enableBlocks: true enableBlocks: true
enableNowPlaying: false enableNowPlaying: false
fields: ["movies", "series", "episodes"] fields: ["movies", "series", "episodes"]
- Mealie:
href: https://meals.ops.eblu.me
icon: mealie.png
description: Recipe manager
- DJ: - DJ:
href: https://dj.ops.eblu.me href: https://dj.ops.eblu.me
icon: navidrome.png icon: navidrome.png
@ -81,7 +85,15 @@
user: "{{HOMEPAGE_VAR_NAVIDROME_USER}}" user: "{{HOMEPAGE_VAR_NAVIDROME_USER}}"
token: "{{HOMEPAGE_VAR_NAVIDROME_TOKEN}}" token: "{{HOMEPAGE_VAR_NAVIDROME_TOKEN}}"
salt: "{{HOMEPAGE_VAR_NAVIDROME_SALT}}" salt: "{{HOMEPAGE_VAR_NAVIDROME_SALT}}"
- Paperless:
href: https://paperless.ops.eblu.me
icon: paperless-ngx.png
description: Document management
- Content: - Content:
- Immich:
href: https://photos.ops.eblu.me
icon: immich.png
description: Photo management
- Kiwix: - Kiwix:
href: https://kiwix.ops.eblu.me href: https://kiwix.ops.eblu.me
icon: kiwix.png icon: kiwix.png
@ -126,6 +138,10 @@
href: https://docs.eblu.me href: https://docs.eblu.me
icon: mdi-book-open-page-variant icon: mdi-book-open-page-variant
description: BlumeOps Documentation description: BlumeOps Documentation
- TeslaMate:
href: https://tesla.ops.eblu.me
icon: teslamate.png
description: Tesla data logger
- Transmission: - Transmission:
href: https://torrent.ops.eblu.me href: https://torrent.ops.eblu.me
icon: transmission.png icon: transmission.png

View file

@ -21,9 +21,8 @@ images:
- name: ghcr.io/immich-app/immich-machine-learning - name: ghcr.io/immich-app/immich-machine-learning
# CUDA variant of the same release — ringtail has an RTX 4080 # CUDA variant of the same release — ringtail has an RTX 4080
newTag: v2.6.3-cuda newTag: v2.6.3-cuda
# amd64 valkey built via nix on the ringtail nix-container-builder # Using upstream multi-arch valkey image directly; the
# (see containers/valkey/default.nix). The Alpine container.py build # registry.ops.eblu.me/blumeops/valkey mirror is arm64-only (built
# is arm64-only and serves paperless on indri. # on indri) and would crashloop on ringtail.
- name: docker.io/valkey/valkey - name: docker.io/valkey/valkey
newName: registry.ops.eblu.me/blumeops/valkey newTag: "8.1.6"
newTag: v8.1.7-ecded30-nix

View file

@ -1,9 +1,3 @@
# Mealie on ringtail k3s — Nix image.
#
# Single gunicorn process (the Nix image's default `mealie-run` entrypoint
# runs init_db then gunicorn), serving the prebuilt frontend. DB is SQLite
# on the mealie-data PVC; its contents are copied from the minikube PVC at
# cutover. See [[migrate-wave1-ringtail]].
apiVersion: apps/v1 apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
@ -11,8 +5,6 @@ metadata:
namespace: mealie namespace: mealie
spec: spec:
replicas: 1 replicas: 1
strategy:
type: Recreate
selector: selector:
matchLabels: matchLabels:
app: mealie app: mealie

View file

@ -12,4 +12,4 @@ resources:
images: images:
- name: registry.ops.eblu.me/blumeops/mealie - name: registry.ops.eblu.me/blumeops/mealie
newTag: v3.16.0-e0057b4-nix newTag: v3.12.0-613f05d

View file

@ -1,5 +1,4 @@
# SQLite data volume for Mealie on ringtail. Contents copied from the ---
# minikube mealie-data PVC at cutover (recipes, meal plans, uploaded media).
apiVersion: v1 apiVersion: v1
kind: PersistentVolumeClaim kind: PersistentVolumeClaim
metadata: metadata:
@ -8,7 +7,7 @@ metadata:
spec: spec:
accessModes: accessModes:
- ReadWriteOnce - ReadWriteOnce
storageClassName: local-path storageClassName: standard
resources: resources:
requests: requests:
storage: 2Gi storage: 2Gi

View file

@ -10,4 +10,4 @@ resources:
images: images:
- name: nvcr.io/nvidia/k8s-device-plugin - name: nvcr.io/nvidia/k8s-device-plugin
newTag: v0.19.2 newTag: v0.19.0

View file

@ -1,22 +0,0 @@
# NFS PersistentVolume for the Paperless document library, mounted from
# ringtail. Same sifaka export (/volume1/paperless) as the minikube PV,
# but a distinct PV name so both clusters can declare it during the
# parallel-run before cutover.
#
# Prerequisite: sifaka must have an NFS rule granting ringtail Read/Write
# (Squash=No mapping) on the paperless share — the same step done for
# immich. See [[sifaka-nfs-from-ringtail]].
apiVersion: v1
kind: PersistentVolume
metadata:
name: paperless-media-nfs-pv-ringtail
spec:
capacity:
storage: 500Gi
accessModes:
- ReadWriteMany
persistentVolumeReclaimPolicy: Retain
storageClassName: ""
nfs:
server: sifaka
path: /volume1/paperless

View file

@ -1,17 +1,3 @@
# Paperless-ngx on ringtail k3s — Nix image, multi-process.
#
# The upstream s6 image ran web + worker + scheduler + consumer (and DB
# migrations) in one container. The Nix image (containers/paperless/
# default.nix) ships the binaries but no supervisor, so we run those as
# four containers in one pod, sharing the local data/consume dirs
# (emptyDir) and the NFS media volume; redis is colocated so
# PAPERLESS_REDIS=localhost works for all. A migrate initContainer runs
# DB migrations once before the app containers start.
#
# DB points in-cluster at the ringtail blumeops-pg (was pg.ops.eblu.me on
# indri). PAPERLESS_{DATA_DIR,MEDIA_ROOT,CONSUMPTION_DIR} are set
# explicitly because the Nix package does not default to the upstream
# /usr/src/paperless paths.
apiVersion: apps/v1 apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
@ -19,8 +5,6 @@ metadata:
namespace: paperless namespace: paperless
spec: spec:
replicas: 1 replicas: 1
strategy:
type: Recreate
selector: selector:
matchLabels: matchLabels:
app: paperless app: paperless
@ -32,38 +16,27 @@ spec:
securityContext: securityContext:
seccompProfile: seccompProfile:
type: RuntimeDefault type: RuntimeDefault
initContainers: containers:
# redis as a native sidecar (restartPolicy: Always): starts before - name: paperless
# the migrate init and stays running for the app containers, so all
# of them reach PAPERLESS_REDIS=localhost:6379.
- name: redis
image: docker.io/library/redis:kustomized
restartPolicy: Always
ports:
- containerPort: 6379
volumeMounts:
- name: redis-data
mountPath: /data
resources:
requests:
memory: "32Mi"
cpu: "10m"
limits:
memory: "128Mi"
- name: migrate
image: registry.ops.eblu.me/blumeops/paperless:kustomized image: registry.ops.eblu.me/blumeops/paperless:kustomized
command: ["paperless-ngx", "migrate", "--no-input"] ports:
env: &paperless-env - containerPort: 8000
name: http
env:
- name: PAPERLESS_URL - name: PAPERLESS_URL
value: "https://paperless.ops.eblu.me" value: "https://paperless.ops.eblu.me"
- name: PAPERLESS_REDIS - name: PAPERLESS_REDIS
value: "redis://localhost:6379" value: "redis://localhost:6379"
- name: PAPERLESS_DBHOST - name: PAPERLESS_DBHOST
value: "blumeops-pg-rw.databases.svc.cluster.local" value: "pg.ops.eblu.me"
- name: PAPERLESS_DBPORT - name: PAPERLESS_DBPORT
value: "5432" value: "5432"
- name: PAPERLESS_DBNAME - name: PAPERLESS_DBNAME
value: "paperless" value: "paperless"
# Explicit port to override k8s-injected PAPERLESS_PORT env var
# (k8s sets PAPERLESS_PORT=tcp://... for a service named 'paperless')
- name: PAPERLESS_PORT
value: "8000"
- name: PAPERLESS_DBUSER - name: PAPERLESS_DBUSER
value: "paperless" value: "paperless"
- name: PAPERLESS_DBPASS - name: PAPERLESS_DBPASS
@ -71,16 +44,6 @@ spec:
secretKeyRef: secretKeyRef:
name: paperless-secrets name: paperless-secrets
key: db-password key: db-password
# Explicit port to override the k8s-injected PAPERLESS_PORT
# (service named 'paperless' would set PAPERLESS_PORT=tcp://...)
- name: PAPERLESS_PORT
value: "8000"
- name: PAPERLESS_DATA_DIR
value: "/usr/src/paperless/data"
- name: PAPERLESS_MEDIA_ROOT
value: "/usr/src/paperless/media"
- name: PAPERLESS_CONSUMPTION_DIR
value: "/usr/src/paperless/consume"
- name: PAPERLESS_SECRET_KEY - name: PAPERLESS_SECRET_KEY
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
@ -92,6 +55,7 @@ spec:
value: "eng" value: "eng"
- name: PAPERLESS_TASK_WORKERS - name: PAPERLESS_TASK_WORKERS
value: "1" value: "1"
# Admin account (created on first startup)
- name: PAPERLESS_ADMIN_USER - name: PAPERLESS_ADMIN_USER
value: "eblume" value: "eblume"
- name: PAPERLESS_ADMIN_PASSWORD - name: PAPERLESS_ADMIN_PASSWORD
@ -101,6 +65,8 @@ spec:
key: admin-password key: admin-password
- name: PAPERLESS_ADMIN_MAIL - name: PAPERLESS_ADMIN_MAIL
value: "blume.erich@gmail.com" value: "blume.erich@gmail.com"
# OIDC via Authentik
# Full JSON blob pulled from 1Password (includes client secret)
- name: PAPERLESS_APPS - name: PAPERLESS_APPS
value: "allauth.socialaccount.providers.openid_connect" value: "allauth.socialaccount.providers.openid_connect"
- name: PAPERLESS_SOCIALACCOUNT_PROVIDERS - name: PAPERLESS_SOCIALACCOUNT_PROVIDERS
@ -116,27 +82,19 @@ spec:
value: "false" value: "false"
- name: PAPERLESS_REDIRECT_LOGIN_TO_SSO - name: PAPERLESS_REDIRECT_LOGIN_TO_SSO
value: "false" value: "false"
volumeMounts: &paperless-mounts volumeMounts:
- name: data - name: data
mountPath: /usr/src/paperless/data mountPath: /usr/src/paperless/data
- name: media - name: media
mountPath: /usr/src/paperless/media mountPath: /usr/src/paperless/media
- name: consume - name: consume
mountPath: /usr/src/paperless/consume mountPath: /usr/src/paperless/consume
containers:
- name: web
image: registry.ops.eblu.me/blumeops/paperless:kustomized
ports:
- containerPort: 8000
name: http
env: *paperless-env
volumeMounts: *paperless-mounts
resources: resources:
requests: requests:
memory: "256Mi" memory: "256Mi"
cpu: "100m" cpu: "100m"
limits: limits:
memory: "1Gi" memory: "2Gi"
cpu: "1000m" cpu: "1000m"
livenessProbe: livenessProbe:
httpGet: httpGet:
@ -151,42 +109,16 @@ spec:
initialDelaySeconds: 30 initialDelaySeconds: 30
periodSeconds: 10 periodSeconds: 10
- name: worker - name: redis
image: registry.ops.eblu.me/blumeops/paperless:kustomized image: docker.io/library/redis:kustomized
command: ["celery", "--app", "paperless", "worker", "--loglevel", "INFO"] ports:
env: *paperless-env - containerPort: 6379
volumeMounts: *paperless-mounts
resources: resources:
requests: requests:
memory: "256Mi" memory: "32Mi"
cpu: "100m" cpu: "10m"
limits: limits:
memory: "1Gi"
cpu: "1000m"
- name: beat
image: registry.ops.eblu.me/blumeops/paperless:kustomized
command: ["celery", "--app", "paperless", "beat", "--loglevel", "INFO"]
env: *paperless-env
volumeMounts: *paperless-mounts
resources:
requests:
memory: "64Mi"
cpu: "20m"
limits:
memory: "256Mi"
- name: consumer
image: registry.ops.eblu.me/blumeops/paperless:kustomized
command: ["paperless-ngx", "document_consumer"]
env: *paperless-env
volumeMounts: *paperless-mounts
resources:
requests:
memory: "128Mi" memory: "128Mi"
cpu: "50m"
limits:
memory: "512Mi"
volumes: volumes:
- name: data - name: data
@ -196,6 +128,3 @@ spec:
claimName: paperless-media claimName: paperless-media
- name: consume - name: consume
emptyDir: {} emptyDir: {}
- name: redis-data
emptyDir:
sizeLimit: 1Gi

View file

@ -13,9 +13,7 @@ resources:
images: images:
- name: registry.ops.eblu.me/blumeops/paperless - name: registry.ops.eblu.me/blumeops/paperless
newTag: v2.20.15-fcac8e5-nix newTag: v2.20.13-07f52e9
# amd64 valkey built via nix (the v8.1.7-ecded30 tag without -nix is the
# arm64 Alpine build for indri and fails on ringtail with exec format error)
- name: docker.io/library/redis - name: docker.io/library/redis
newName: registry.ops.eblu.me/blumeops/valkey newName: registry.ops.eblu.me/blumeops/valkey
newTag: v8.1.7-ecded30-nix newTag: v8.1.6-r0-fabca04

View file

@ -0,0 +1,22 @@
# NFS PersistentVolume for Paperless document library
# Requires: NFS share on sifaka at /volume1/paperless with NFS permissions for indri
#
# To create on Synology:
# 1. Control Panel > Shared Folder > Create
# 2. Name: paperless, Location: Volume 1
# 3. Control Panel > File Services > NFS > NFS Rules
# 4. Add rule for "paperless" share: Hostname=indri, Privilege=Read/Write, Squash=No mapping
apiVersion: v1
kind: PersistentVolume
metadata:
name: paperless-media-nfs-pv
spec:
capacity:
storage: 500Gi
accessModes:
- ReadWriteMany
persistentVolumeReclaimPolicy: Retain
storageClassName: ""
nfs:
server: sifaka
path: /volume1/paperless

View file

@ -1,5 +1,5 @@
# PersistentVolumeClaim for the Paperless document library on ringtail. # PersistentVolumeClaim for Paperless document library
# Binds the NFS PV for sifaka:/volume1/paperless. # Binds to the NFS PV for sifaka:/volume1/paperless
apiVersion: v1 apiVersion: v1
kind: PersistentVolumeClaim kind: PersistentVolumeClaim
metadata: metadata:
@ -9,7 +9,7 @@ spec:
accessModes: accessModes:
- ReadWriteMany - ReadWriteMany
storageClassName: "" storageClassName: ""
volumeName: paperless-media-nfs-pv-ringtail volumeName: paperless-media-nfs-pv
resources: resources:
requests: requests:
storage: 500Gi storage: 500Gi

View file

@ -0,0 +1,54 @@
---
apiVersion: batch/v1
kind: CronJob
metadata:
name: prowler-iac-scan
namespace: prowler
spec:
schedule: "0 2 * * 6" # Saturday 2am
concurrencyPolicy: Forbid
jobTemplate:
spec:
ttlSecondsAfterFinished: 604800 # Auto-delete after 7 days
template:
spec:
securityContext:
seccompProfile:
type: RuntimeDefault
containers:
- name: prowler
image: registry.ops.eblu.me/blumeops/prowler:kustomized
command: ["/bin/sh", "-c"]
# Prowler's --mutelist-file is a no-op for the IaC provider
# (it delegates to Trivy). The Prowler image's trivy shim
# injects --ignorefile $TRIVY_IGNOREFILE when set; see
# containers/prowler/Dockerfile.
env:
- name: TRIVY_IGNOREFILE
value: /mutelist/trivyignore.yaml
args:
- |
DATEDIR=/reports/prowler-iac/$(date +%Y-%m-%d)
mkdir -p "$DATEDIR"
prowler iac \
--scan-repository-url https://forge.ops.eblu.me/eblume/blumeops.git \
-z \
--output-formats html csv json-ocsf \
--output-directory "$DATEDIR"
volumeMounts:
- name: reports
mountPath: /reports
- name: mutelist
mountPath: /mutelist
readOnly: true
restartPolicy: OnFailure
volumes:
- name: reports
persistentVolumeClaim:
claimName: prowler-reports
- name: mutelist
configMap:
name: prowler-mutelist
items:
- key: trivyignore.yaml
path: trivyignore.yaml

View file

@ -0,0 +1,39 @@
---
apiVersion: batch/v1
kind: CronJob
metadata:
name: prowler-image-scan
namespace: prowler
spec:
schedule: "0 3 * * 6" # Saturday 3am
concurrencyPolicy: Forbid
jobTemplate:
spec:
ttlSecondsAfterFinished: 604800 # Auto-delete after 7 days
template:
spec:
securityContext:
seccompProfile:
type: RuntimeDefault
containers:
- name: prowler
image: registry.ops.eblu.me/blumeops/prowler:kustomized
command: ["/bin/sh", "-c"]
args:
- |
DATEDIR=/reports/prowler-images/$(date +%Y-%m-%d)
mkdir -p "$DATEDIR"
prowler image \
--registry https://registry.ops.eblu.me \
--image-filter "^blumeops/" \
-z \
--output-formats html csv json-ocsf \
--output-directory "$DATEDIR"
volumeMounts:
- name: reports
mountPath: /reports
restartPolicy: OnFailure
volumes:
- name: reports
persistentVolumeClaim:
claimName: prowler-reports

View file

@ -10,6 +10,8 @@ resources:
- pv-nfs.yaml - pv-nfs.yaml
- pvc.yaml - pvc.yaml
- cronjob.yaml - cronjob.yaml
- cronjob-image-scan.yaml
- cronjob-iac-scan.yaml
configMapGenerator: configMapGenerator:
- name: prowler-mutelist - name: prowler-mutelist
@ -21,6 +23,7 @@ configMapGenerator:
- mutelist/core-pod-security.yaml - mutelist/core-pod-security.yaml
- mutelist/manual-node-checks.yaml - mutelist/manual-node-checks.yaml
- mutelist/rbac.yaml - mutelist/rbac.yaml
- mutelist/trivyignore.yaml
images: images:
- name: registry.ops.eblu.me/blumeops/prowler - name: registry.ops.eblu.me/blumeops/prowler

View file

@ -0,0 +1,37 @@
# Trivy ignorefile for Prowler IaC scan.
#
# Prowler's `--mutelist-file` flag is a no-op for the IaC provider
# (iac_provider.py sets self._mutelist = None and delegates to Trivy).
# Trivy in turn does not auto-discover this YAML form from cwd, so the
# Prowler image ships a shim wrapper around `trivy` that injects
# --ignorefile $TRIVY_IGNOREFILE when the env var is set. The cronjob
# mounts this file and sets TRIVY_IGNOREFILE accordingly.
#
# Schema: https://trivy.dev/latest/docs/configuration/filtering/
# IDs use the hyphenated form Trivy displays (KSV-0041, not KSV0041).
misconfigurations:
- id: KSV-0041
paths:
- "argocd/manifests/external-secrets/rbac.yaml"
statement: >-
external-secrets-operator's entire function is to read and
synthesize Secret objects; ClusterRole over secrets is its
purpose. Both the controller and cert-controller are
upstream-defined.
- id: KSV-0041
paths:
- "argocd/manifests/kube-state-metrics/rbac.yaml"
- "argocd/manifests/kube-state-metrics-ringtail/rbac.yaml"
statement: >-
KSM exposes only Secret metadata (name, namespace, type, labels),
never the data field. list/watch on secrets is required for
kube_secret_info / kube_secret_labels metrics.
- id: KSV-0114
paths:
- "argocd/manifests/external-secrets/rbac.yaml"
statement: >-
cert-controller manages the external-secrets validating webhook
configurations to inject its own rotating CA bundle. RBAC is
scoped to two named webhooks (secretstore-validate,
externalsecret-validate) via resourceNames; KSV-0114 doesn't see
the resourceNames restriction so reports the full ClusterRole.

View file

@ -6,11 +6,8 @@ namespace: tailscale
# Upstream Tailscale operator manifest from forge mirror. # Upstream Tailscale operator manifest from forge mirror.
# To upgrade: update the ref in the URL AND the newTag below. # To upgrade: update the ref in the URL AND the newTag below.
# Must use the tailnet host forge.ops.eblu.me — the public forge.eblu.me
# black-holes /mirrors/ at the Fly edge (AI-scraper mitigation), which the
# in-cluster ArgoCD repo-server would otherwise hit and fail with a 403.
resources: resources:
- https://forge.ops.eblu.me/mirrors/tailscale/raw/tag/v1.94.2/cmd/k8s-operator/deploy/manifests/operator.yaml - https://forge.eblu.me/mirrors/tailscale/raw/tag/v1.94.2/cmd/k8s-operator/deploy/manifests/operator.yaml
- proxyclass.yaml - proxyclass.yaml
- dnsconfig.yaml - dnsconfig.yaml

View file

@ -9,19 +9,12 @@ resources:
- proxygroup-ingress.yaml - proxygroup-ingress.yaml
- external-secret.yaml - external-secret.yaml
# Rewrite the operator image to the locally nix-built (amd64) mirror. # Rewrite the proxyclass image to our local nix-built mirror.
# The name must match the post-base-render image (base already rewrites # Scoped to ringtail only; indri's tailscale-operator/kustomization.yaml still
# tailscale/k8s-operator -> docker.io/tailscale/k8s-operator). # pulls from upstream docker.io. A strategic merge patch is used instead of
images: # kustomize's `images:` directive because that directive only rewrites images
- name: docker.io/tailscale/k8s-operator # in standard k8s container fields, not custom-resource fields like
newName: registry.ops.eblu.me/blumeops/tailscale-operator # ProxyClass.spec.statefulSet.pod.tailscaleContainer.image.
newTag: v1.94.2-d03ed33-nix
# Rewrite the proxyclass image to our local nix-built mirror (indri's overlay
# carries the equivalent dagger/arm64 patch). A strategic merge patch is used
# instead of kustomize's `images:` directive because that directive only
# rewrites images in standard k8s container fields, not custom-resource fields
# like ProxyClass.spec.statefulSet.pod.tailscaleContainer.image.
patches: patches:
- path: proxyclass-image.yaml - path: proxyclass-image.yaml
target: target:

View file

@ -14,23 +14,3 @@ resources:
# Endpoints). Apply manually: # Endpoints). Apply manually:
# kubectl --context=minikube-indri apply -f endpoints-forge.yaml # kubectl --context=minikube-indri apply -f endpoints-forge.yaml
- ingress-forge.yaml - ingress-forge.yaml
# Rewrite the operator image to the locally dagger-built (arm64) mirror.
# The name must match the post-base-render image (base already rewrites
# tailscale/k8s-operator -> docker.io/tailscale/k8s-operator).
images:
- name: docker.io/tailscale/k8s-operator
newName: registry.ops.eblu.me/blumeops/tailscale-operator
newTag: v1.94.2-d03ed33
# Rewrite the proxyclass image to the local mirror. A strategic merge patch
# is used instead of kustomize's `images:` directive because that directive
# only rewrites standard k8s container fields, not custom-resource fields
# like ProxyClass.spec.statefulSet.pod.tailscaleContainer.image.
patches:
- path: proxyclass-image.yaml
target:
group: tailscale.com
version: v1alpha1
kind: ProxyClass
name: default

View file

@ -1,11 +0,0 @@
apiVersion: tailscale.com/v1alpha1
kind: ProxyClass
metadata:
name: default
spec:
statefulSet:
pod:
tailscaleContainer:
image: registry.ops.eblu.me/blumeops/tailscale:v1.94.2-d03ed33
tailscaleInitContainer:
image: registry.ops.eblu.me/blumeops/tailscale:v1.94.2-d03ed33

View file

@ -0,0 +1,69 @@
# TeslaMate
TeslaMate is a self-hosted Tesla data logger that collects and visualizes vehicle data.
## Prerequisites
### 1. Create 1Password Secrets
Create two items in the blumeops 1Password vault:
1. **TeslaMate DB Password**
- Generate a secure password for the teslamate PostgreSQL user
- Add a field named `password` with the generated value
2. **TeslaMate Encryption Key**
- Generate with: `openssl rand -base64 32`
- Add a field named `key` with the generated value
- This encrypts Tesla API tokens at rest in the database
### 2. Apply Kubernetes Secrets
```bash
# Create namespace
kubectl create namespace teslamate
# Apply database user secret (for CNPG)
op inject -i argocd/manifests/databases/secret-teslamate.yaml.tpl | kubectl apply -f -
# Apply teslamate secrets
op inject -i argocd/manifests/teslamate/secret-encryption-key.yaml.tpl | kubectl apply -f -
op inject -i argocd/manifests/teslamate/secret-db.yaml.tpl | kubectl apply -f -
```
### 3. Create Database
After the teslamate user exists in PostgreSQL (sync blumeops-pg first):
```bash
PGPASSWORD=$(op read "op://blumeops/postgres/password") \
psql -h pg.ops.eblu.me -U eblume -c "CREATE DATABASE teslamate OWNER teslamate;"
```
## Deployment
```bash
# Sync ArgoCD apps
argocd app sync apps
argocd app sync blumeops-pg teslamate grafana grafana-config
```
## Tesla API Setup
1. Access TeslaMate UI at https://tesla.tail8d86e.ts.net
2. Click "Sign in with Tesla"
3. Complete OAuth flow in browser
4. Tokens are encrypted and stored in database
5. Verify vehicle appears and data collection starts
## Grafana Dashboards
TeslaMate dashboards are available in Grafana at https://grafana.tail8d86e.ts.net
They use the "TeslaMate" PostgreSQL datasource (not Prometheus).
## Notes
- MQTT is disabled (can be enabled later for Home Assistant integration)
- Timezone is set to America/Los_Angeles
- Encryption key protects Tesla API tokens at rest

View file

@ -1,10 +1,3 @@
# TeslaMate on ringtail k3s — Nix image.
#
# The Nix image's Entrypoint waits for postgres, runs migrations
# (TeslaMate.Release.migrate), then starts the release — so no command
# override is needed. Stateless; all data lives in the teslamate database
# on the ringtail blumeops-pg (DATABASE_HOST already an in-cluster name,
# unchanged from minikube). See [[migrate-wave1-ringtail]].
apiVersion: apps/v1 apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:

View file

@ -12,4 +12,4 @@ resources:
images: images:
- name: registry.ops.eblu.me/blumeops/teslamate - name: registry.ops.eblu.me/blumeops/teslamate
newTag: v3.0.0-fcac8e5-nix newTag: v3.0.0-08c698e

View file

@ -10,7 +10,7 @@ resources:
images: images:
- name: registry.ops.eblu.me/blumeops/unpoller - name: registry.ops.eblu.me/blumeops/unpoller
newTag: v3.2.0-4d1f4af newTag: v3.2.0-1b27242
configMapGenerator: configMapGenerator:
- name: unpoller-config - name: unpoller-config

View file

@ -1,51 +0,0 @@
"""External Secrets Operator — native Dagger build.
Two-stage build: Go binary (all providers), Alpine runtime.
Source cloned from forge mirror.
A single binary serves as the controller, webhook, and cert-controller; the
Deployments select the role via a subcommand passed in `args:`, so the image
ENTRYPOINT must be the binary itself (matching upstream's distroless image).
"""
import dagger
from blumeops.containers import (
alpine_runtime,
clone_from_forge,
go_build,
oci_labels,
)
VERSION = "v2.2.0"
async def build(src: dagger.Directory) -> dagger.Container:
source = clone_from_forge("external-secrets", VERSION)
# Upstream `make build` compiles every secret provider into a single
# static binary (`-tags all_providers`, CGO disabled). Mirror that so the
# local image is functionally identical to ghcr.io/.../external-secrets.
backend = go_build(
source,
"/external-secrets",
tags="all_providers",
)
runtime = alpine_runtime(
extra_apk=["ca-certificates"],
create_user=False,
)
runtime = oci_labels(
runtime,
title="External Secrets Operator",
description=(
"Kubernetes operator that integrates external secret management systems"
),
version=VERSION,
)
return (
runtime.with_file("/bin/external-secrets", backend.file("/external-secrets"))
.with_user("65534")
.with_entrypoint(["/bin/external-secrets"])
)

View file

@ -1,56 +0,0 @@
# Nix-built External Secrets Operator (amd64, for ringtail k3s).
# Builds v2.2.0 from the forge mirror with all secret providers compiled in,
# faithful to upstream's `make build` (-tags all_providers). The container.py
# sibling builds the arm64 image for indri's minikube; this default.nix builds
# the amd64 image on ringtail's nix-container-builder.
{ pkgs ? import <nixpkgs> { } }:
let
version = "2.2.0";
src = pkgs.fetchgit {
url = "https://forge.ops.eblu.me/mirrors/external-secrets.git";
rev = "v${version}";
hash = "sha256-eAocOAp5s4CFRrpKfQr2lf3Ji+6nQQ1A5/eTw5B7v9U=";
};
# external-secrets v2.2.0 requires Go >= 1.26.1; nixpkgs default go is 1.25.x.
external-secrets = (pkgs.buildGoModule.override { go = pkgs.go_1_26; }) {
inherit src version;
pname = "external-secrets";
vendorHash = "sha256-0xuBK3fjAplPLAElHvKB6d+2lDz+De/s91fV4dPZwjE=";
doCheck = false;
subPackages = [ "." ];
tags = [ "all_providers" ];
ldflags = [ "-s" "-w" ];
meta = with pkgs.lib; {
description = "Kubernetes operator that integrates external secret management systems";
homepage = "https://github.com/external-secrets/external-secrets";
license = licenses.asl20;
mainProgram = "external-secrets";
};
};
in
pkgs.dockerTools.buildLayeredImage {
name = "blumeops/external-secrets";
contents = [
external-secrets
pkgs.cacert
pkgs.tzdata
];
config = {
Entrypoint = [ "${external-secrets}/bin/external-secrets" ];
Env = [
"SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt"
"TZDIR=${pkgs.tzdata}/share/zoneinfo"
];
User = "65534";
};
}

View file

@ -0,0 +1,145 @@
# Mealie — self-hosted recipe manager
# Built from source via forge mirror of mealie-recipes/mealie
# Based on upstream docker/Dockerfile (multi-stage: Node frontend + Python backend)
ARG CONTAINER_APP_VERSION=v3.12.0
###############################################
# Frontend Build
###############################################
FROM node:24-slim AS frontend-builder
ARG CONTAINER_APP_VERSION
RUN apt-get update && apt-get install --no-install-recommends -y git ca-certificates && rm -rf /var/lib/apt/lists/*
RUN git clone --depth 1 --branch ${CONTAINER_APP_VERSION} \
https://forge.ops.eblu.me/mirrors/mealie.git /src
WORKDIR /src/frontend
RUN yarn install \
--prefer-offline \
--frozen-lockfile \
--non-interactive \
--production=false \
--network-timeout 1000000
RUN yarn generate
###############################################
# Python Base
###############################################
FROM python:3.12-slim AS python-base
ENV MEALIE_HOME="/app"
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=off \
PIP_DISABLE_PIP_VERSION_CHECK=on \
PIP_DEFAULT_TIMEOUT=100 \
VENV_PATH="/opt/mealie"
ENV PATH="$VENV_PATH/bin:$PATH"
RUN useradd -u 911 -U -d $MEALIE_HOME -s /bin/bash abc \
&& usermod -G users abc \
&& mkdir $MEALIE_HOME
###############################################
# Backend Package Build
###############################################
FROM python-base AS backend-builder
ARG CONTAINER_APP_VERSION
RUN apt-get update \
&& apt-get install --no-install-recommends -y curl git ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN pip install uv
RUN git clone --depth 1 --branch ${CONTAINER_APP_VERSION} \
https://forge.ops.eblu.me/mirrors/mealie.git /src
WORKDIR /src
COPY --from=frontend-builder /src/frontend/dist ./mealie/frontend
RUN uv build --out-dir dist
RUN uv export --no-editable --no-emit-project --extra pgsql --format requirements-txt --output-file dist/requirements.txt \
&& MEALIE_VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])") \
&& echo "mealie[pgsql]==${MEALIE_VERSION} \\" >> dist/requirements.txt \
&& pip hash dist/mealie-${MEALIE_VERSION}-py3-none-any.whl | tail -n1 | tr -d '\n' >> dist/requirements.txt \
&& echo " \\" >> dist/requirements.txt \
&& pip hash dist/mealie-${MEALIE_VERSION}.tar.gz | tail -n1 >> dist/requirements.txt
###############################################
# Python Venv Build
###############################################
FROM python-base AS venv-builder
RUN apt-get update \
&& apt-get install --no-install-recommends -y \
build-essential \
libpq-dev \
libwebp-dev \
ffmpeg \
libsasl2-dev libldap2-dev libssl-dev \
gnupg gnupg2 gnupg1 \
&& rm -rf /var/lib/apt/lists/*
RUN python3 -m venv --upgrade-deps $VENV_PATH
COPY --from=backend-builder /src/dist /dist
RUN . $VENV_PATH/bin/activate \
&& pip install --require-hashes -r /dist/requirements.txt --find-links /dist
###############################################
# Production Image
###############################################
FROM python-base AS production
ENV PRODUCTION=true
ENV TESTING=false
RUN apt-get update \
&& apt-get install --no-install-recommends -y \
curl \
ffmpeg \
gosu \
iproute2 \
libldap-common \
libldap2 \
&& rm -rf /var/lib/apt/lists/*
RUN mkdir -p /run/secrets
COPY --from=venv-builder $VENV_PATH $VENV_PATH
ENV NLTK_DATA="/nltk_data/"
RUN mkdir -p $NLTK_DATA
RUN python -m nltk.downloader -d $NLTK_DATA averaged_perceptron_tagger_eng
VOLUME ["$MEALIE_HOME/data/"]
ENV APP_PORT=9000
EXPOSE ${APP_PORT}
COPY --from=backend-builder /src/docker/healthcheck.sh $MEALIE_HOME/healthcheck.sh
RUN chmod +x $MEALIE_HOME/healthcheck.sh
HEALTHCHECK CMD $MEALIE_HOME/healthcheck.sh
ENV HOST=0.0.0.0
COPY --from=backend-builder /src/docker/entry.sh $MEALIE_HOME/run.sh
RUN chmod +x $MEALIE_HOME/run.sh
ARG CONTAINER_APP_VERSION
LABEL org.opencontainers.image.title="Mealie"
LABEL org.opencontainers.image.description="Self-hosted recipe manager"
LABEL org.opencontainers.image.version="${CONTAINER_APP_VERSION}"
LABEL org.opencontainers.image.source="https://forge.eblu.me/eblume/blumeops"
LABEL org.opencontainers.image.vendor="blumeops"
ENTRYPOINT ["/app/run.sh"]

View file

@ -1,69 +0,0 @@
# Nix-built Mealie for ringtail (amd64).
#
# Replaces the from-source Dockerfile build (Node frontend + Python venv)
# with nixpkgs' mealie, which ships a single `mealie` gunicorn entrypoint
# serving the prebuilt frontend + backend — so this is a clean single-
# process wrap (unlike paperless, which is multi-process).
#
# Mealie stores its DB as SQLite under DATA_DIR (the mealie-data PVC at
# /app/data); there is no postgres. The run wrapper mirrors the nixpkgs
# mealie NixOS module: run `libexec/init_db` (Alembic migrations) first,
# then exec gunicorn.
#
# Self-pins nixos-unstable: stable nixpkgs lags at 3.9.2, unstable carries
# 3.16.0. This is a forward 4-minor bump from the v3.12.0 Dockerfile build
# (the deferred upgrade) — mealie auto-migrates the SQLite DB forward on
# startup via init_db; the source PVC is retained for rollback. The version
# assertion makes nix-build fail if a pin bump changes the version.
let
nixpkgs = fetchTarball {
url = "https://github.com/NixOS/nixpkgs/archive/331800de5053fcebacf6813adb5db9c9dca22a0c.tar.gz";
sha256 = "1p54fm6dkbq62kpi55cr4wyx7b1nsajpsnjgs64cmp073fwi15f7";
};
pkgs = import nixpkgs { system = "x86_64-linux"; };
version = "3.16.0";
app = pkgs.mealie;
# Mirror the NixOS module's mealie service: init_db (Alembic) then
# gunicorn bound to the app port. DATA_DIR/env come from the image +
# k8s manifest.
mealie-run = pkgs.writeShellScriptBin "mealie-run" ''
set -e
${app}/libexec/init_db
exec ${pkgs.lib.getExe app} -b 0.0.0.0:9000
'';
in
assert app.version == version;
pkgs.dockerTools.buildLayeredImage {
name = "blumeops/mealie";
contents = [
app
mealie-run
pkgs.bashInteractive
pkgs.coreutils
pkgs.cacert
pkgs.tzdata
# python3 (stdlib sqlite3) for the borgmatic k8s-sqlite-dump helper,
# which runs `python3 -c "...sqlite3...backup..."` inside the pod.
# Same nixpkgs python mealie is built against, so ~no added closure.
pkgs.python3
];
config = {
Cmd = [ "${mealie-run}/bin/mealie-run" ];
Env = [
"DATA_DIR=/app/data"
"SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt"
"PYTHONUNBUFFERED=1"
"PRODUCTION=true"
];
ExposedPorts = {
"9000/tcp" = { };
};
};
}

View file

@ -0,0 +1,156 @@
# syntax=docker/dockerfile:1
# Paperless-ngx — self-hosted document management
# Built from source via forge mirror of paperless-ngx/paperless-ngx
# Closely follows upstream Dockerfile structure with git clone instead of COPY
ARG CONTAINER_APP_VERSION=v2.20.13
###############################################
# Stage 1: Clone source (reused by later stages)
###############################################
FROM docker.io/library/alpine:3.22 AS source
ARG CONTAINER_APP_VERSION
RUN apk add --no-cache git
RUN git clone --depth 1 --branch ${CONTAINER_APP_VERSION} \
https://forge.ops.eblu.me/mirrors/paperless-ngx.git /src
###############################################
# Stage 2: Compile frontend
###############################################
FROM --platform=$BUILDPLATFORM docker.io/node:20-trixie-slim AS compile-frontend
COPY --from=source /src/src-ui /src/src-ui
WORKDIR /src/src-ui
RUN set -eux \
&& npm update -g pnpm \
&& npm install -g corepack@latest \
&& corepack enable \
&& pnpm install
RUN set -eux \
&& ./node_modules/.bin/ng build --configuration production
###############################################
# Stage 3: s6-overlay base
###############################################
FROM ghcr.io/astral-sh/uv:0.9.15-python3.12-trixie-slim AS s6-overlay-base
WORKDIR /usr/src/s6
ENV S6_BEHAVIOUR_IF_STAGE2_FAILS=2 \
S6_CMD_WAIT_FOR_SERVICES_MAXTIME=0 \
S6_VERBOSITY=1 \
PATH=/command:$PATH
ARG TARGETARCH
ARG TARGETVARIANT
ARG S6_OVERLAY_VERSION=3.2.1.0
RUN set -eux \
&& apt-get update \
&& apt-get install --yes --quiet --no-install-recommends curl xz-utils \
&& S6_ARCH="" \
&& if [ "${TARGETARCH}${TARGETVARIANT}" = "amd64" ]; then S6_ARCH="x86_64"; \
elif [ "${TARGETARCH}${TARGETVARIANT}" = "arm64" ]; then S6_ARCH="aarch64"; fi \
&& if [ -z "${S6_ARCH}" ]; then echo "Error: Cannot determine arch"; exit 1; fi \
&& curl --fail --silent --show-error --location --remote-name-all --parallel \
"https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-noarch.tar.xz" \
"https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-noarch.tar.xz.sha256" \
"https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-${S6_ARCH}.tar.xz" \
"https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-${S6_ARCH}.tar.xz.sha256" \
&& sha256sum --check ./*.sha256 \
&& tar --directory / -Jxpf s6-overlay-noarch.tar.xz \
&& tar --directory / -Jxpf s6-overlay-${S6_ARCH}.tar.xz \
&& rm ./*.tar.xz ./*.sha256 \
&& apt-get --yes purge curl xz-utils \
&& apt-get --yes autoremove --purge \
&& rm -rf /var/lib/apt/lists/*
# Copy rootfs (s6 service definitions, init scripts)
COPY --from=source /src/docker/rootfs /
###############################################
# Stage 4: Main application
###############################################
FROM s6-overlay-base AS main-app
ARG CONTAINER_APP_VERSION
ARG DEBIAN_FRONTEND=noninteractive
ARG TARGETARCH
ARG JBIG2ENC_VERSION=0.30
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PYTHONWARNINGS="ignore:::django.http.response:517" \
PNGX_CONTAINERIZED=1 \
UV_LINK_MODE=copy \
UV_CACHE_DIR=/cache/uv/
# Runtime packages
RUN set -eux \
&& apt-get update \
&& apt-get install --yes --quiet --no-install-recommends \
curl gosu tzdata fonts-liberation gettext ghostscript gnupg \
icc-profiles-free imagemagick postgresql-client \
tesseract-ocr tesseract-ocr-eng tesseract-ocr-deu tesseract-ocr-fra \
tesseract-ocr-ita tesseract-ocr-spa unpaper pngquant jbig2dec \
libxml2 libxslt1.1 qpdf file libmagic1 media-types zlib1g \
libzbar0 poppler-utils \
&& curl --fail --silent --show-error --location --remote-name-all \
"https://github.com/paperless-ngx/builder/releases/download/jbig2enc-trixie-v${JBIG2ENC_VERSION}/jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb" \
&& dpkg --install ./jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \
&& cp /etc/ImageMagick-6/paperless-policy.xml /etc/ImageMagick-6/policy.xml \
&& rm --force *.deb \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /usr/src/paperless/src/
# Python dependencies
COPY --from=source /src/pyproject.toml /src/uv.lock /usr/src/paperless/src/
RUN --mount=type=cache,target=${UV_CACHE_DIR},id=python-cache \
set -eux \
&& apt-get update \
&& apt-get install --yes --quiet --no-install-recommends \
build-essential default-libmysqlclient-dev pkg-config \
&& uv export --quiet --no-dev --all-extras --format requirements-txt --output-file requirements.txt \
&& uv pip install --system --no-python-downloads --python-preference system --requirements requirements.txt \
&& python3 -W ignore::RuntimeWarning -m nltk.downloader -d "/usr/share/nltk_data" snowball_data \
&& python3 -W ignore::RuntimeWarning -m nltk.downloader -d "/usr/share/nltk_data" stopwords \
&& python3 -W ignore::RuntimeWarning -m nltk.downloader -d "/usr/share/nltk_data" punkt_tab \
&& apt-get --yes purge build-essential default-libmysqlclient-dev pkg-config \
&& apt-get --yes autoremove --purge \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# Copy backend source
COPY --from=source /src/src ./
# Copy compiled frontend
COPY --from=compile-frontend /src/src/documents/static/frontend/ ./documents/static/frontend/
# Create user and finalize
RUN set -eux \
&& addgroup --gid 1000 paperless \
&& useradd --uid 1000 --gid paperless --home-dir /usr/src/paperless paperless \
&& mkdir -p /usr/src/paperless/data /usr/src/paperless/media \
/usr/src/paperless/consume /usr/src/paperless/export \
&& chown -R paperless:paperless /usr/src/paperless \
&& s6-setuidgid paperless python3 manage.py collectstatic --clear --no-input --link \
&& s6-setuidgid paperless python3 manage.py compilemessages
VOLUME ["/usr/src/paperless/data", "/usr/src/paperless/media", \
"/usr/src/paperless/consume", "/usr/src/paperless/export"]
ENTRYPOINT ["/init"]
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=10s --retries=5 \
CMD [ "curl", "-fs", "-S", "-L", "--max-time", "2", "http://localhost:8000" ]
LABEL org.opencontainers.image.title="Paperless-ngx"
LABEL org.opencontainers.image.description="Self-hosted document management system"
LABEL org.opencontainers.image.version="${CONTAINER_APP_VERSION}"
LABEL org.opencontainers.image.source="https://forge.eblu.me/eblume/blumeops"
LABEL org.opencontainers.image.vendor="blumeops"

View file

@ -1,77 +0,0 @@
# Nix-built Paperless-ngx for ringtail (amd64).
#
# Replaces the from-source Dockerfile build (s6-overlay) with nixpkgs'
# paperless-ngx, which already bundles the full OCR/imaging closure
# (tesseract, ghostscript, imagemagick, qpdf, poppler, jbig2enc) and the
# NLTK data via wrappers — so the image stays lean.
#
# Unlike the upstream s6 image, this image does NOT run all processes
# itself. Paperless is multi-process; on ringtail it runs as four
# containers sharing this one image, each with a different command:
# web -> paperless-web (granian, the wrapper below)
# worker -> celery --app paperless worker
# beat -> celery --app paperless beat
# consumer -> paperless-ngx document_consumer
# plus a redis/valkey sidecar. The PYTHONPATH/granian invocation mirrors
# the nixpkgs paperless NixOS module's paperless-web service exactly.
#
# Self-pins nixos-unstable: stable nixpkgs lags at 2.19.6, while unstable
# carries 2.20.15 — a same-minor forward patch bump from the previous
# Dockerfile build (v2.20.13). The version assertion makes nix-build fail
# if a pin bump changes the version, forcing an explicit acknowledgment
# here and in service-versions.yaml (enforced by container-version-check).
let
nixpkgs = fetchTarball {
url = "https://github.com/NixOS/nixpkgs/archive/331800de5053fcebacf6813adb5db9c9dca22a0c.tar.gz";
sha256 = "1p54fm6dkbq62kpi55cr4wyx7b1nsajpsnjgs64cmp073fwi15f7";
};
pkgs = import nixpkgs { system = "x86_64-linux"; };
version = "2.20.15";
app = pkgs.paperless-ngx;
# Mirror the NixOS module's paperless-web service: granian serving the
# ASGI app with the package's propagated deps + src on PYTHONPATH.
pythonPath =
"${app.python.pkgs.makePythonPath app.propagatedBuildInputs}:${app}/lib/paperless-ngx/src";
paperless-web = pkgs.writeShellScriptBin "paperless-web" ''
export PYTHONPATH="${pythonPath}"
export PAPERLESS_NLTK_DIR="${app.nltkDataDir}"
exec ${app.python.pkgs.granian}/bin/granian \
--interface asginl --ws \
--host 0.0.0.0 --port 8000 \
"paperless.asgi:application"
'';
in
assert app.version == version;
pkgs.dockerTools.buildLayeredImage {
name = "blumeops/paperless";
contents = [
app
paperless-web
pkgs.bashInteractive
pkgs.coreutils
pkgs.cacert
pkgs.tzdata
];
config = {
# Default command is the web server; worker/beat/consumer containers
# override `command` in their k8s manifests.
Cmd = [ "${paperless-web}/bin/paperless-web" ];
Env = [
"PAPERLESS_NLTK_DIR=${app.nltkDataDir}"
"SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt"
"PYTHONUNBUFFERED=1"
"PNGX_CONTAINERIZED=1"
];
ExposedPorts = {
"8000/tcp" = { };
};
};
}

View file

@ -1,53 +0,0 @@
"""Tailscale Kubernetes operator — native Dagger build.
Single Go binary (cmd/k8s-operator) from the forge mirror, mirroring
upstream's build_docker.sh mkctr recipe: binary at /usr/local/bin/operator,
go tags ts_kube + ts_package_container, version stamps in ldflags.
Consumed by the tailscale-operator app on indri's minikube (arm64); the
ringtail app uses the -nix tag from default.nix instead.
"""
import dagger
from blumeops.containers import (
alpine_runtime,
clone_from_forge,
go_build,
oci_labels,
)
VERSION = "v1.94.2"
async def build(src: dagger.Directory) -> dagger.Container:
source = clone_from_forge("tailscale", VERSION)
semver = VERSION.removeprefix("v")
builder = go_build(
source,
"/out/operator",
cmd_path="./cmd/k8s-operator",
tags="ts_kube,ts_package_container",
ldflags=(
"-w -s"
f" -X tailscale.com/version.longStamp={semver}"
f" -X tailscale.com/version.shortStamp={semver}"
),
)
# Upstream runs the operator as root on a minimal base; only CA certs
# are needed at runtime (operator talks to the k8s API and Tailscale
# control plane over HTTPS).
runtime = alpine_runtime(extra_apk=["ca-certificates"], create_user=False)
runtime = oci_labels(
runtime,
title="Tailscale Kubernetes Operator",
description="Tailscale operator for Kubernetes Ingress/egress proxies",
version=VERSION,
)
return runtime.with_file(
"/usr/local/bin/operator",
builder.file("/out/operator"),
permissions=0o555,
).with_entrypoint(["/usr/local/bin/operator"])

View file

@ -1,67 +0,0 @@
# Nix-built tailscale k8s-operator for ringtail's tailscale-operator app.
# Builds cmd/k8s-operator v1.94.2 from the forge mirror, mirroring upstream's
# build_docker.sh mkctr recipe (binary at /usr/local/bin/operator, ts_kube +
# ts_package_container go tags). Built on the ringtail nix-container-builder.
{ pkgs ? import <nixpkgs> { } }:
let
version = "1.94.2";
src = pkgs.fetchgit {
url = "https://forge.ops.eblu.me/mirrors/tailscale.git";
rev = "v${version}";
hash = "sha256-qjWVB8xWVgIVUgrf27F6hwiFIE+4ERXWeHv26ugg/x4=";
};
operator = pkgs.buildGoModule {
inherit src version;
pname = "tailscale-operator";
vendorHash = "sha256-WeMTOkERj4hvdg4yPaZ1gRgKnhRIBXX55kUVbX/k/xM=";
subPackages = [ "cmd/k8s-operator" ];
tags = [
"ts_kube"
"ts_package_container"
];
ldflags = [
"-s"
"-w"
"-X tailscale.com/version.longStamp=${version}"
"-X tailscale.com/version.shortStamp=${version}"
];
doCheck = false;
meta = with pkgs.lib; {
description = "Tailscale operator for Kubernetes";
homepage = "https://tailscale.com";
license = licenses.bsd3;
};
};
in
pkgs.dockerTools.buildLayeredImage {
name = "blumeops/tailscale-operator";
tag = "v${version}";
contents = [
operator
pkgs.cacert
];
# buildGoModule names the binary after the package dir (k8s-operator);
# upstream's image expects /usr/local/bin/operator.
extraCommands = ''
mkdir -p usr/local/bin
ln -s /bin/k8s-operator usr/local/bin/operator
'';
config = {
Entrypoint = [ "/usr/local/bin/operator" ];
Env = [
"SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt"
];
};
}

View file

@ -1,104 +0,0 @@
"""Tailscale proxy image (containerboot) — native Dagger build.
Builds cmd/tailscale, cmd/tailscaled, and cmd/containerboot from the forge
mirror, mirroring the upstream Dockerfile: Alpine runtime with iptables
(legacy symlinked over the default, per upstream issue #17854), iproute2,
and the /tailscale/run.sh compat symlink.
Consumed by the tailscale-operator ProxyClass on indri's minikube (arm64);
ringtail's ProxyClass uses the -nix tag from default.nix instead.
"""
import dagger
from blumeops.containers import (
alpine_runtime,
clone_from_forge,
go_build,
oci_labels,
)
VERSION = "v1.94.2"
async def build(src: dagger.Directory) -> dagger.Container:
source = clone_from_forge("tailscale", VERSION)
semver = VERSION.removeprefix("v")
ldflags = (
"-w -s"
f" -X tailscale.com/version.longStamp={semver}"
f" -X tailscale.com/version.shortStamp={semver}"
)
builder = go_build(
source,
"/out/tailscale",
cmd_path="./cmd/tailscale",
ldflags=ldflags,
)
builder = builder.with_exec(
[
"go",
"build",
f"-ldflags={ldflags}",
"-o",
"/out/tailscaled",
"./cmd/tailscaled",
]
).with_exec(
[
"go",
"build",
f"-ldflags={ldflags}",
"-o",
"/out/containerboot",
"./cmd/containerboot",
]
)
runtime = alpine_runtime(
extra_apk=["ca-certificates", "iptables", "iproute2", "ip6tables"],
create_user=False,
)
runtime = oci_labels(
runtime,
title="Tailscale",
description="Tailscale containerboot proxy image for the k8s operator",
version=VERSION,
)
return (
runtime
# Match upstream Dockerfile: nftables-backed iptables misbehaves in
# some environments, force the legacy backend (tailscale/tailscale#17854).
.with_exec(
[
"sh",
"-c",
"rm /usr/sbin/iptables && ln -s /usr/sbin/iptables-legacy /usr/sbin/iptables"
" && rm /usr/sbin/ip6tables && ln -s /usr/sbin/ip6tables-legacy /usr/sbin/ip6tables",
]
)
.with_file(
"/usr/local/bin/tailscale",
builder.file("/out/tailscale"),
permissions=0o555,
)
.with_file(
"/usr/local/bin/tailscaled",
builder.file("/out/tailscaled"),
permissions=0o555,
)
.with_file(
"/usr/local/bin/containerboot",
builder.file("/out/containerboot"),
permissions=0o555,
)
.with_exec(
[
"sh",
"-c",
"mkdir /tailscale && ln -s /usr/local/bin/containerboot /tailscale/run.sh",
]
)
.with_entrypoint(["/usr/local/bin/containerboot"])
)

View file

@ -0,0 +1,104 @@
"""TeslaMate — Tesla data logger.
Two-stage build: Elixir+Node (builder), Debian slim (runtime).
Source cloned from forge mirror.
"""
import dagger
from dagger import dag
from blumeops.containers import clone_from_forge, oci_labels
VERSION = "v3.0.0"
async def build(src: dagger.Directory) -> dagger.Container:
source = clone_from_forge("teslamate", VERSION)
# Stage 1: Build Elixir release with Node.js assets
builder = (
dag.container()
.from_("elixir:1.19.5-otp-26")
.with_exec(
[
"bash",
"-c",
"apt-get update"
" && apt-get install -y ca-certificates curl gnupg git zstd brotli"
" && mkdir -p /etc/apt/keyrings"
" && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key"
" | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg"
' && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg]'
' https://deb.nodesource.com/node_22.x nodistro main"'
" > /etc/apt/sources.list.d/nodesource.list"
" && apt-get update"
" && apt-get install -y nodejs"
" && apt-get clean"
" && rm -rf /var/lib/apt/lists/*",
]
)
.with_exec(["mix", "local.rebar", "--force"])
.with_exec(["mix", "local.hex", "--force"])
.with_directory("/opt/app", source)
.with_workdir("/opt/app")
.with_env_variable("MIX_ENV", "prod")
.with_exec(["mix", "deps.get", "--only", "prod"])
.with_exec(["mix", "deps.compile"])
.with_exec(
[
"npm",
"ci",
"--prefix",
"./assets",
"--progress=false",
"--no-audit",
"--loglevel=error",
]
)
.with_exec(["mix", "assets.deploy"])
.with_exec(["mix", "compile"])
.with_exec(
["bash", "-c", "SKIP_LOCALE_DOWNLOAD=true mix release --path /opt/built"]
)
)
# Stage 2: Debian slim runtime
entrypoint = src.file("containers/teslamate/entrypoint.sh")
runtime = (
dag.container()
.from_("debian:trixie-slim")
.with_exec(
[
"bash",
"-c",
"apt-get update && apt-get install -y --no-install-recommends"
" libodbc2 libsctp1 libssl3t64 libstdc++6"
" netcat-openbsd tini tzdata"
" && apt-get clean"
" && rm -rf /var/lib/apt/lists/*"
" && groupadd --gid 10001 --system nonroot"
" && useradd --uid 10000 --system --gid nonroot"
" --home-dir /home/nonroot --shell /sbin/nologin nonroot",
]
)
)
runtime = oci_labels(
runtime,
title="TeslaMate",
description="Tesla data logger and visualization",
version=VERSION,
)
return (
runtime.with_env_variable("LANG", "C.UTF-8")
.with_env_variable("SRTM_CACHE", "/opt/app/.srtm_cache")
.with_env_variable("HOME", "/opt/app")
.with_workdir("/opt/app")
.with_directory("/opt/app", builder.directory("/opt/built"), owner="nonroot")
.with_exec(["mkdir", "-p", "/opt/app/.srtm_cache"])
.with_file("/entrypoint.sh", entrypoint, permissions=0o555, owner="nonroot")
.with_user("nonroot")
.with_exposed_port(4000)
.with_entrypoint(["tini", "--", "/bin/dash", "/entrypoint.sh"])
.with_default_args(args=["bin/teslamate", "start"])
)

View file

@ -1,122 +0,0 @@
# Nix-built TeslaMate for ringtail (amd64).
#
# Replaces the Dagger container.py (Elixir+Node builder -> Debian slim).
# TeslaMate is NOT in nixpkgs, so this is a from-scratch beamPackages
# mixRelease: an Elixir/Phoenix release with npm-built assets.
#
# Pinned to the same nixos-unstable rev as paperless/mealie for a
# consistent toolchain. The BEAM combo is pinned to erlang_27 + elixir_1_18
# (teslamate requires elixir ~> 1.17; upstream's image uses OTP 26, so we
# stay off the default OTP 28 which elixir 1.18 does not target).
#
# Source comes from the forge mirror (supply-chain control), pinned by the
# v3.0.0 tag's commit so builtins.fetchGit needs no hash.
let
nixpkgs = fetchTarball {
url = "https://github.com/NixOS/nixpkgs/archive/331800de5053fcebacf6813adb5db9c9dca22a0c.tar.gz";
sha256 = "1p54fm6dkbq62kpi55cr4wyx7b1nsajpsnjgs64cmp073fwi15f7";
};
pkgs = import nixpkgs { system = "x86_64-linux"; };
lib = pkgs.lib;
version = "3.0.0";
beamPackages = pkgs.beam.packages.erlang_27;
elixir = beamPackages.elixir_1_18;
src = builtins.fetchGit {
url = "https://forge.ops.eblu.me/mirrors/teslamate.git";
ref = "refs/tags/v${version}";
rev = "3281154d42330786a182c1bbe094ecda0b1c5578";
};
# ex_cldr downloads locale JSON from GitHub at compile time, which the
# build sandbox blocks. teslamate's cldr.ex reads the data dir from the
# LOCALES env var; point it at the pre-fetched elixir-cldr data so no
# download is attempted (with SKIP_LOCALE_DOWNLOAD=true disabling the
# forced refresh). CLDR data version matches the compile-time errors.
cldrData = pkgs.fetchFromGitHub {
owner = "elixir-cldr";
repo = "cldr";
rev = "v2.46.0";
sha256 = "1iwzk9dc754l72vpf8vsisdjncnjx26pz509552b6vnm49xbxyji";
};
teslamate = beamPackages.mixRelease {
pname = "teslamate";
inherit version src elixir;
# Keep the build-generated Erlang cookie in the release. mixRelease
# strips it by default (expecting RELEASE_COOKIE at runtime), but the
# start script reads releases/COOKIE. teslamate is single-node (no
# distributed Erlang exposed), so a baked-in cookie is fine.
removeCookie = false;
mixFodDeps = beamPackages.fetchMixDeps {
pname = "mix-deps-teslamate";
inherit src version elixir;
hash = "sha256-DDrREiM1BIMgD2qFPTK8QyjOYlnfE3XlnaH/jk7G2go=";
};
# Frontend assets. esbuild + sass are devDeps and the esbuild platform
# binary is an optional dep, so npm ci must include both. We run npm ci
# here (not a separate derivation) because assets/package.json has
# file:../deps/phoenix references that only resolve once mixFodDeps has
# populated deps/. npmConfigHook wires up the offline cache from npmDeps;
# then `node scripts/build.js` (custom esbuild) + `mix phx.digest`.
nativeBuildInputs = [ pkgs.nodejs pkgs.npmHooks.npmConfigHook ];
npmDeps = pkgs.fetchNpmDeps {
name = "teslamate-npm-deps";
src = src + "/assets";
hash = "sha256-XyiaUkT/c4rZnNxmxhVLb+vEXnc64A1hjOrnR5fhaEk=";
};
npmRoot = "assets";
preBuild = ''
export SKIP_LOCALE_DOWNLOAD=true
export LOCALES=${cldrData}/priv/cldr
( cd assets && npm ci --include=dev --include=optional && node scripts/build.js )
mix phx.digest --no-deps-check
'';
};
in
pkgs.dockerTools.buildLayeredImage {
name = "blumeops/teslamate";
contents = [
teslamate
pkgs.bashInteractive
pkgs.coreutils
pkgs.dash
pkgs.netcat-openbsd
pkgs.cacert
pkgs.tzdata
];
config = {
# Mirror entrypoint.sh: wait for postgres, run migrations, then start.
Entrypoint = [
"${pkgs.dash}/bin/dash"
"-c"
''
: "''${DATABASE_HOST:=127.0.0.1}"
: "''${DATABASE_PORT:=5432}"
while ! ${pkgs.netcat-openbsd}/bin/nc -z "$DATABASE_HOST" "$DATABASE_PORT" 2>/dev/null; do
echo "waiting for postgres at $DATABASE_HOST:$DATABASE_PORT"; sleep 1
done
${teslamate}/bin/teslamate eval "TeslaMate.Release.migrate"
exec ${teslamate}/bin/teslamate start
''
];
Env = [
"HOME=/opt/app"
"SRTM_CACHE=/opt/app/.srtm_cache"
"LANG=C.UTF-8"
"SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt"
];
ExposedPorts = {
"4000/tcp" = { };
};
};
}

View file

@ -0,0 +1,23 @@
#!/usr/bin/env dash
set -e
: "${DATABASE_HOST:="127.0.0.1"}"
: "${DATABASE_PORT:=5432}"
: "${ULIMIT_MAX_NOFILE:=65536}"
# prevent memory bloat in some misconfigured versions of Docker/containerd
# where the nofiles limit is very large. 0 means don't set it.
if test "${ULIMIT_MAX_NOFILE}" != 0 && test "$(ulimit -n)" -gt "${ULIMIT_MAX_NOFILE}"; then
ulimit -n "${ULIMIT_MAX_NOFILE}"
fi
# wait until Postgres is ready
while ! nc -z "${DATABASE_HOST}" "${DATABASE_PORT}" 2>/dev/null; do
echo waiting for postgres at "${DATABASE_HOST}":"${DATABASE_PORT}"
sleep 1s
done
# apply migrations
bin/teslamate eval "TeslaMate.Release.migrate"
exec "$@"

View file

@ -1,8 +1,8 @@
"""Valkey — native Dagger build (arm64, indri). """Valkey — native Dagger build.
Alpine 3.22 base with the `valkey` apk package (8.1.x Redis-compatible). Alpine 3.22 base with the `valkey` apk package (8.1.x Redis-compatible).
Used by paperless (sidecar) on indri. immich on ringtail uses the Mirrors `docker.io/valkey/valkey:8.1-alpine`, used by paperless and immich
nix-built amd64 variant from `default.nix` in this directory. as a cache/queue sidecar.
""" """
import dagger import dagger
@ -10,10 +10,9 @@ from dagger import dag
from blumeops.containers import oci_labels from blumeops.containers import oci_labels
# Alpine 3.22 currently ships valkey 8.1.7-r0. Alpine 3.23 jumps to 9.0 — # Alpine 3.22 ships valkey 8.1.6-r0. Alpine 3.23 jumps to 9.0 — hold on 3.22
# hold on 3.22 to keep this aligned with the 8.1 line. # to keep this a 1:1 swap for the upstream `valkey:8.1-alpine` image.
VERSION = "8.1.7" VERSION = "8.1.6-r0"
ALPINE_PIN = "8.1.7-r0"
ALPINE_BASE = "alpine:3.22" ALPINE_BASE = "alpine:3.22"
@ -22,7 +21,7 @@ async def build(src: dagger.Directory) -> dagger.Container:
ctr = ( ctr = (
dag.container() dag.container()
.from_(ALPINE_BASE) .from_(ALPINE_BASE)
.with_exec(["apk", "add", "--no-cache", f"valkey={ALPINE_PIN}"]) .with_exec(["apk", "add", "--no-cache", f"valkey={VERSION}"])
.with_exec(["mkdir", "-p", "/data"]) .with_exec(["mkdir", "-p", "/data"])
.with_exec(["chown", "valkey:valkey", "/data"]) .with_exec(["chown", "valkey:valkey", "/data"])
.with_workdir("/data") .with_workdir("/data")

View file

@ -1,30 +0,0 @@
# Nix-built Valkey for ringtail (amd64)
# Companion to container.py (Alpine 3.22, arm64 on indri).
# Used by immich-ringtail which needs an amd64 image; paperless on indri
# continues to use the Alpine container.py build.
#
# The version assertion ensures nix-build fails if a flake.lock update
# changes the Valkey version — forcing an explicit version acknowledgment
# here and in service-versions.yaml (enforced by container-version-check).
{ pkgs ? import <nixpkgs> { } }:
let
version = "8.1.7";
in
assert pkgs.valkey.version == version;
pkgs.dockerTools.buildLayeredImage {
name = "blumeops/valkey";
contents = [
pkgs.valkey
];
config = {
Entrypoint = [ "${pkgs.valkey}/bin/valkey-server" ];
Cmd = [ "--bind" "0.0.0.0" "--protected-mode" "no" "--dir" "/data" ];
ExposedPorts = {
"6379/tcp" = { };
};
};
}

View file

@ -0,0 +1 @@
Fixed the export-filename step in [[run-1password-backup]]: 1Password's desktop app names the export `1PasswordExport-<account-uuid>-<timestamp>.1pux` automatically rather than letting you save to a fixed name, so the procedure now points the task at that glob instead of pretending the default name is `1Password-export.1pux`.

View file

@ -1 +0,0 @@
Corrected the 1Password backup how-to: the desktop app's export menu item is named after the account ("File > Export > Blume/Davis"), not "All Vaults". Verified an account export contains all four vaults (Private, blumeops, Payrix, Shared).

View file

@ -0,0 +1 @@
Adopt `AGENTS.md` as the canonical agent instruction file, keep `CLAUDE.md` as a compatibility shim, and update docs to reference the neutral file and the correct agent-change-process path.

View file

@ -0,0 +1,5 @@
Rebuild and retag alloy v1.16.0 container images from the main-branch SHA
following the squash-merge of #345, per the build-container-image
squash-merge convention. Both images (`registry.ops.eblu.me/blumeops/alloy`)
now reference `9564435` rather than the branch SHA `26a3ab5`, restoring
source traceability after branch cleanup.

View file

@ -0,0 +1,6 @@
Upgrade native macOS Alloy on indri to v1.16.0. Built on gilbert with Go
1.26.2 + CGO (required for the macOS native DNS resolver, which Tailscale
MagicDNS depends on), scp'd to `~/.local/bin/alloy` on indri, codesigned,
and the LaunchAgent reloaded. Completes the v1.16.0 fleet upgrade started
in #345 — all four Alloy services (alloy-k8s, alloy-ringtail,
alloy-tracing-ringtail, alloy ansible) now run v1.16.0.

View file

@ -0,0 +1 @@
Add resource limits to all ArgoCD pods to prevent unbounded resource consumption during node-wide pressure events.

View file

@ -0,0 +1 @@
`blumeops-tasks` now annotates each task with a human-readable due offset (`5d overdue` / `due in 2d` / `due today`) and a `↻ <recurrence>` marker for recurring tasks, and sorts by overdue-ness (most overdue first, no-due-date last) with priority as tiebreaker.

View file

@ -0,0 +1 @@
CLAUDE.md now imports AGENTS.md via `@AGENTS.md` instead of telling agents to go read it. Claude Code only auto-loads CLAUDE.md, so the prose shim was easy to skip; the import inlines AGENTS.md into the session prompt unconditionally.

View file

@ -0,0 +1 @@
`container-build-and-release` now prints the specific `mise run runner-logs <N>` command after dispatching, polling the Forgejo API to resolve the run number for the commit it just triggered.

View file

@ -1 +0,0 @@
Rebuilt the locally-built external-secrets image from the `main` branch so the deployed tag (`v2.2.0-0e70a1b`) traces to a `main` commit rather than the now-merged feature branch, giving a stable provenance reference.

View file

@ -1 +0,0 @@
Rebuilt the external-secrets images off `main` and repointed both clusters to the stable main-sha tags (`v2.2.0-13895bb` arm64 / `v2.2.0-13895bb-nix` amd64), so the deployed images on indri and ringtail trace to the same `main` commit rather than earlier feature-branch builds.

View file

@ -0,0 +1 @@
Fixed forge.eblu.me static assets (CSS, JS, images, fonts) not loading — the proxy's static asset cache block was missing the `Host` header, so Caddy couldn't route the requests.

View file

@ -0,0 +1 @@
Switch the Fly proxy deploy strategy from `bluegreen` to `immediate` in `fly/fly.toml`. With a single proxy machine, bluegreen offers little benefit — the green machine routinely failed to reach "started" inside Fly's default 5-minute deploy timeout (the cold-start sequence of `tailscaled``tailscale up` → wait-for-MagicDNS → nginx startup eats most of the budget), and the failed deploys would roll back. `immediate` replaces the machine in place with a brief downtime (~510s) but actually completes.

View file

@ -0,0 +1 @@
Add local nix container build for `frigate-notify` (`containers/frigate-notify/default.nix`) so the Frigate→ntfy bridge is rebuilt on ringtail from the forge mirror instead of pulled from `ghcr.io/0x2142/frigate-notify`.

View file

@ -0,0 +1 @@
Switched Grafana's deployment strategy from `RollingUpdate` to `Recreate`. With an RWO PVC holding the SQLite database and Bleve search index, `RollingUpdate` reliably crashloops the new pod on the index lock until rollout timeout. `Recreate` terminates the old pod first so the new one acquires the lock cleanly.

View file

@ -1 +0,0 @@
Bumped the indri heph hub to v1.2.1, which adds the hub `GET /config` endpoint and ships the heph-pwa **Login with Authentik** flow (Authorization Code + PKCE). Pairs with the Authentik `heph` provider redirect URIs registered earlier.

View file

@ -0,0 +1,5 @@
Fixed homepage container EACCES on cold start: the nix-built image now chowns
`/app/config` to uid 1000 at build time via `fakeRootCommands`, matching the
behavior of the old Dockerfile. Without this, homepage couldn't seed missing
skeleton configs (proxmox.yaml etc.) or create `/app/config/logs`, crashing on
its first uncached request. Caught during the ringtail cutover.

View file

@ -0,0 +1 @@
Moved the Immich blackbox health probe from indri's alloy to ringtail's alloy. After the immich migration to ringtail, the probe still targeted `immich-server.immich.svc.cluster.local` on indri's cluster where the service no longer exists, causing a persistent `ServiceProbeFailure` alert.

View file

@ -1 +0,0 @@
Upgraded Jellyfin on indri from 10.11.6 to 10.11.11, picking up the security fixes in 10.11.7 (disclosed CVEs/GHSAs, flagged "upgrade immediately") and 10.11.10 (three further GHSAs). Noted the recurring gotcha in the service-versions tracking: after a `brew upgrade --cask jellyfin`, the re-quarantined `.app` makes the launchd-spawned process hang silently until the Gatekeeper first-launch dialog is approved on indri's GUI console — removing the quarantine xattr over SSH is blocked by macOS TCC.

Some files were not shown because too many files have changed in this diff Show more