From 8b0ff3d7a517e391e84101c5467e5edc72fa5e20 Mon Sep 17 00:00:00 2001 From: Forgejo Actions Date: Fri, 6 Mar 2026 10:00:42 -0800 Subject: [PATCH 001/430] Update docs release to v1.13.1 - Built changelog from towncrier fragments [skip ci] --- CHANGELOG.md | 8 ++++++++ argocd/manifests/docs/deployment.yaml | 2 +- docs/changelog.d/+kustomized-image-tags.infra.md | 1 - docs/changelog.d/+spider-trap-guard.infra.md | 1 - 4 files changed, 9 insertions(+), 3 deletions(-) delete mode 100644 docs/changelog.d/+kustomized-image-tags.infra.md delete mode 100644 docs/changelog.d/+spider-trap-guard.infra.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a778c9..92c825e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [v1.13.1] - 2026-03-06 + +### Infrastructure + +- Add `:kustomized` sentinel tag to all manifest image references overridden by kustomize, making it clear the real tag lives in kustomization.yaml. +- Add nginx spider-trap guards to docs.eblu.me Quartz container — blocks recursive crawler paths at /tags/ depth >1 and global depth ≥5. + + ## [v1.13.0] - 2026-03-05 ### Features diff --git a/argocd/manifests/docs/deployment.yaml b/argocd/manifests/docs/deployment.yaml index 96e1a67..bc06205 100644 --- a/argocd/manifests/docs/deployment.yaml +++ b/argocd/manifests/docs/deployment.yaml @@ -27,7 +27,7 @@ spec: name: http env: - name: DOCS_RELEASE_URL - value: "https://forge.eblu.me/eblume/blumeops/releases/download/v1.13.0/docs-v1.13.0.tar.gz" + value: "https://forge.eblu.me/eblume/blumeops/releases/download/v1.13.1/docs-v1.13.1.tar.gz" resources: requests: memory: "64Mi" diff --git a/docs/changelog.d/+kustomized-image-tags.infra.md b/docs/changelog.d/+kustomized-image-tags.infra.md deleted file mode 100644 index 28e00e0..0000000 --- a/docs/changelog.d/+kustomized-image-tags.infra.md +++ /dev/null @@ -1 +0,0 @@ -Add `:kustomized` sentinel tag to all manifest image references overridden by kustomize, making it clear the real tag lives in kustomization.yaml. diff --git a/docs/changelog.d/+spider-trap-guard.infra.md b/docs/changelog.d/+spider-trap-guard.infra.md deleted file mode 100644 index d152e75..0000000 --- a/docs/changelog.d/+spider-trap-guard.infra.md +++ /dev/null @@ -1 +0,0 @@ -Add nginx spider-trap guards to docs.eblu.me Quartz container — blocks recursive crawler paths at /tags/ depth >1 and global depth ≥5. From b64010b3c7917c6782cd8047aa0231997b1166ae Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Fri, 6 Mar 2026 18:34:37 -0800 Subject: [PATCH 002/430] Replace spider-trap nginx 404s with robots.txt disallowing /explorer/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /explorer/ SPA endpoints were the source of all spider-trap traffic. A robots.txt Disallow is a better fix than serving 404s — it prevents crawlers from entering the infinite URL tree in the first place, avoids serving large numbers of 404s that hurt SEO, and doesn't break legitimate deep links. Co-Authored-By: Claude Opus 4.6 --- containers/quartz/default.conf | 26 +++++-------------- .../changelog.d/+robots-txt-explorer.infra.md | 1 + 2 files changed, 7 insertions(+), 20 deletions(-) create mode 100644 docs/changelog.d/+robots-txt-explorer.infra.md diff --git a/containers/quartz/default.conf b/containers/quartz/default.conf index 2705f1e..70b8fcc 100644 --- a/containers/quartz/default.conf +++ b/containers/quartz/default.conf @@ -14,26 +14,12 @@ server { add_header Cache-Control "public, immutable"; } - # --- Spider-trap guards ------------------------------------------------ - # Quartz emits relative links (../path). When a crawler resolves these - # from a phantom URL that was already served by the SPA fallback, the - # relative prefix compounds (e.g. /tags/ref/infra → /tags/ref/infra/ref/infra) - # creating an infinite tree of unique URIs — all served as 200 via the - # fallback to index.html. Two rules cut this off: - # - # 1. /tags/ is always flat (/tags/), so block anything deeper. - # 2. Real content never exceeds depth 4 (/how-to//). - # A depth-5 cutoff gives headroom while stopping recursive paths. - # - # Together these caught ~95% of trap requests in the March 2026 incident. - # The proper fix is root-absolute links in Quartz (planned for fork). - - location ~ "^/tags/[^/]+/" { - return 404; - } - - location ~ "^(/[^/]+){5,}" { - return 404; + # Serve robots.txt inline to prevent crawlers from entering /explorer/, + # which is an SPA feature that generates infinite relative-link trees + # when crawled (the March 2026 spider-trap incident). + location = /robots.txt { + default_type text/plain; + return 200 "User-agent: *\nDisallow: /explorer/\n"; } # SPA fallback - serve index.html for client-side routing diff --git a/docs/changelog.d/+robots-txt-explorer.infra.md b/docs/changelog.d/+robots-txt-explorer.infra.md new file mode 100644 index 0000000..25ece70 --- /dev/null +++ b/docs/changelog.d/+robots-txt-explorer.infra.md @@ -0,0 +1 @@ +Replace nginx spider-trap 404 guards with robots.txt disallowing /explorer/ to prevent crawler-induced infinite URL trees. From a7c21bd8a62cf45e57ded56197375b64e01ba012 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Fri, 6 Mar 2026 18:58:40 -0800 Subject: [PATCH 003/430] Update docs quartz container to v1.28.2-b64010b Co-Authored-By: Claude Opus 4.6 --- argocd/manifests/docs/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/argocd/manifests/docs/kustomization.yaml b/argocd/manifests/docs/kustomization.yaml index 8bd0f02..ec97659 100644 --- a/argocd/manifests/docs/kustomization.yaml +++ b/argocd/manifests/docs/kustomization.yaml @@ -9,4 +9,4 @@ resources: - pdb.yaml images: - name: registry.ops.eblu.me/blumeops/quartz - newTag: v1.28.2-6636576 + newTag: v1.28.2-b64010b From e95fb9a555c18cf45d9fefb48f23b45e3a00d89d Mon Sep 17 00:00:00 2001 From: Forgejo Actions Date: Fri, 6 Mar 2026 19:03:24 -0800 Subject: [PATCH 004/430] Update docs release to v1.13.2 - Built changelog from towncrier fragments [skip ci] --- CHANGELOG.md | 7 +++++++ argocd/manifests/docs/deployment.yaml | 2 +- docs/changelog.d/+robots-txt-explorer.infra.md | 1 - 3 files changed, 8 insertions(+), 2 deletions(-) delete mode 100644 docs/changelog.d/+robots-txt-explorer.infra.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 92c825e..b7c301e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [v1.13.2] - 2026-03-06 + +### Infrastructure + +- Replace nginx spider-trap 404 guards with robots.txt disallowing /explorer/ to prevent crawler-induced infinite URL trees. + + ## [v1.13.1] - 2026-03-06 ### Infrastructure diff --git a/argocd/manifests/docs/deployment.yaml b/argocd/manifests/docs/deployment.yaml index bc06205..e9f1c2d 100644 --- a/argocd/manifests/docs/deployment.yaml +++ b/argocd/manifests/docs/deployment.yaml @@ -27,7 +27,7 @@ spec: name: http env: - name: DOCS_RELEASE_URL - value: "https://forge.eblu.me/eblume/blumeops/releases/download/v1.13.1/docs-v1.13.1.tar.gz" + value: "https://forge.eblu.me/eblume/blumeops/releases/download/v1.13.2/docs-v1.13.2.tar.gz" resources: requests: memory: "64Mi" diff --git a/docs/changelog.d/+robots-txt-explorer.infra.md b/docs/changelog.d/+robots-txt-explorer.infra.md deleted file mode 100644 index 25ece70..0000000 --- a/docs/changelog.d/+robots-txt-explorer.infra.md +++ /dev/null @@ -1 +0,0 @@ -Replace nginx spider-trap 404 guards with robots.txt disallowing /explorer/ to prevent crawler-induced infinite URL trees. From ba7236ade03c680c5c2d7b08bd1afcc13f1ca4ef Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Fri, 6 Mar 2026 20:31:30 -0800 Subject: [PATCH 005/430] Add how-to guide for upgrading Dagger Documents the correct two-phase upgrade procedure to avoid the chicken-and-egg problem where CI can't build its own replacement. Also fixes outdated version references in the Dagger reference card. Co-Authored-By: Claude Opus 4.6 --- docs/changelog.d/+upgrade-dagger-howto.doc.md | 1 + docs/how-to/dagger/upgrade-dagger.md | 112 ++++++++++++++++++ docs/how-to/how-to.md | 4 + docs/reference/tools/dagger.md | 4 +- 4 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 docs/changelog.d/+upgrade-dagger-howto.doc.md create mode 100644 docs/how-to/dagger/upgrade-dagger.md diff --git a/docs/changelog.d/+upgrade-dagger-howto.doc.md b/docs/changelog.d/+upgrade-dagger-howto.doc.md new file mode 100644 index 0000000..dcf4f07 --- /dev/null +++ b/docs/changelog.d/+upgrade-dagger-howto.doc.md @@ -0,0 +1 @@ +Add how-to guide for upgrading Dagger, documenting the correct phase ordering to avoid chicken-and-egg CI failures. diff --git a/docs/how-to/dagger/upgrade-dagger.md b/docs/how-to/dagger/upgrade-dagger.md new file mode 100644 index 0000000..d41ea09 --- /dev/null +++ b/docs/how-to/dagger/upgrade-dagger.md @@ -0,0 +1,112 @@ +--- +title: Upgrade Dagger +modified: 2026-03-06 +last-reviewed: 2026-03-06 +tags: + - how-to + - dagger + - ci-cd +--- + +# Upgrade Dagger + +How to upgrade the Dagger engine and CLI across all components in BlumeOps. The ordering matters — upgrading in the wrong sequence creates a chicken-and-egg problem where CI can't build its own replacement. + +## Overview + +Dagger versions are pinned in multiple places. The runner job image (which executes CI workflows) contains the Dagger CLI, and the module's `dagger.json` declares the engine version. These must match — if the CLI is older than the engine version, Dagger refuses to run. + +**Key insight:** Upgrade the runner container *first* (with the new CLI but the old engine version), deploy it, and *then* bump the engine version. This avoids needing a local build to break the cycle. + +## Files to update + +| File | What | Phase | +|------|------|-------| +| `containers/runner-job-image/Dockerfile` | `CONTAINER_APP_VERSION` (CLI version) | 1 | +| `service-versions.yaml` | `runner-job-image` version and `last-reviewed` | 1 | +| `mise.toml` | `dagger` tool version | 2 | +| `dagger.json` | `engineVersion` | 2 | +| `.dagger/uv.lock` | SDK dependency lock (regenerated automatically) | 2 | +| `docs/reference/tools/dagger.md` | Version references in documentation | 2 | +| `argocd/manifests/forgejo-runner/deployment.yaml` | `RUNNER_LABELS` image tag | 2 | + +## Procedure + +### Phase 1: Upgrade the runner job image + +The runner job image contains the Dagger CLI binary. Upgrading it first means the current CI (still on the old engine version) can build and publish the new image normally. + +1. Update `containers/runner-job-image/Dockerfile`: + ```dockerfile + ARG CONTAINER_APP_VERSION= + ``` + +2. Update `service-versions.yaml` — bump `current-version` and `last-reviewed` for `runner-job-image`. + +3. Commit and push to main. The `Build Container` workflow triggers automatically (it watches `containers/**`), building and publishing the new runner-job-image with the updated Dagger CLI. + +4. Verify the build succeeds — check the workflow run on Forgejo. Note the image tag from the build output (format: `v-`). + +### Phase 2: Upgrade the module and deploy the new runner + +Once the Phase 1 build completes, upgrade the module engine version and deploy the new runner in a single commit. None of these paths trigger CI workflows automatically, so there is no race condition. + +1. Update `mise.toml`: + ```toml + dagger = "" + ``` + +2. Run `mise install` to get the new CLI locally. + +3. Update `dagger.json`: + ```json + "engineVersion": "v" + ``` + +4. Regenerate the SDK lock file — run any `dagger call` command (e.g., `dagger call --help` or `dagger functions`). This updates `.dagger/uv.lock` if SDK dependencies changed. + +5. Update `docs/reference/tools/dagger.md` — bump the version in the Quick Reference table and any version references in the body text. + +6. Update `argocd/manifests/forgejo-runner/deployment.yaml` — set the `RUNNER_LABELS` value to use the new image tag from Phase 1: + ```yaml + value: "k8s:docker://registry.ops.eblu.me/blumeops/runner-job-image:" + ``` + +7. Commit and push to main. + +8. Sync the forgejo-runner app: + ```fish + argocd app sync forgejo-runner + ``` + +9. Verify the runner is healthy: + ```fish + argocd app get forgejo-runner + ``` + +10. Test CI by triggering a workflow (e.g., manual dispatch of `Build BlumeOps`). + +## Why the order matters + +The Dagger CLI refuses to run a module whose `engineVersion` is newer than the CLI version. If you upgrade `dagger.json` first: + +1. CI tries to run `dagger call` with the old CLI +2. The module declares a newer engine version +3. Dagger exits with a version mismatch error +4. The `Build Container` workflow can't run — so you can't build the new runner image via CI +5. You're stuck: the runner can't build its own replacement + +By upgrading the CLI in the runner image first (Phase 1), the current engine version (old) still works fine with the newer CLI. Phase 2 combines the engine version bump with the runner deployment in a single commit — this is safe because none of the changed paths (`dagger.json`, `mise.toml`, `argocd/manifests/forgejo-runner/`) trigger CI workflows automatically. Just sync the forgejo-runner app before triggering any workflows. + +## Changelog + +Add a changelog fragment: `docs/changelog.d/+upgrade-dagger-..md` + +Use type `infra` for routine upgrades. Include both the old and new versions in the description. + +## Related + +- [[dagger]] — Dagger reference card +- [[build-container-image]] — How container builds work +- [[update-tooling-dependencies]] — General tooling update procedure +- [[forgejo]] — CI/CD platform diff --git a/docs/how-to/how-to.md b/docs/how-to/how-to.md index f5d6154..a9e096a 100644 --- a/docs/how-to/how-to.md +++ b/docs/how-to/how-to.md @@ -84,6 +84,10 @@ tags: - [[build-grafana-container]] - [[build-grafana-sidecar]] +## Dagger + +- [[upgrade-dagger]] + ## Forgejo Runner - [[upgrade-k8s-runner]] diff --git a/docs/reference/tools/dagger.md b/docs/reference/tools/dagger.md index 2e5979a..677e7f5 100644 --- a/docs/reference/tools/dagger.md +++ b/docs/reference/tools/dagger.md @@ -1,6 +1,6 @@ --- title: Dagger -modified: 2026-02-22 +modified: 2026-03-06 tags: - reference - ci-cd @@ -80,7 +80,7 @@ In [[forgejo]] Actions, secrets are injected as env vars. Locally, mise tasks ca ## Caveats -- **Pre-1.0 API** — Current version is v0.19.x. Pin the CLI version and test upgrades on a branch before adopting. +- **Pre-1.0 API** — Current version is v0.20.x. Pin the CLI version and test upgrades on a branch before adopting. See [[upgrade-dagger]] for the upgrade procedure. - **Privileged container** — The Dagger engine requires privileged container access. The Forgejo runner's DinD sidecar provides this. ## Related From 24f7512d59273cf823d3728a13fe7ced31bedd38 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Fri, 6 Mar 2026 20:32:05 -0800 Subject: [PATCH 006/430] Bump runner-job-image Dagger CLI from 0.20.0 to 0.20.1 Phase 1 of Dagger upgrade: update the CLI in the runner container first so CI can build the new image with the old engine version. See [[upgrade-dagger]] for the full procedure. Co-Authored-By: Claude Opus 4.6 --- containers/runner-job-image/Dockerfile | 2 +- service-versions.yaml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/containers/runner-job-image/Dockerfile b/containers/runner-job-image/Dockerfile index 88e041f..b814339 100644 --- a/containers/runner-job-image/Dockerfile +++ b/containers/runner-job-image/Dockerfile @@ -9,7 +9,7 @@ # Usage: Configure runner with label like: # docker:docker://registry.ops.eblu.me/blumeops/runner-job-image:latest -ARG CONTAINER_APP_VERSION=0.20.0 +ARG CONTAINER_APP_VERSION=0.20.1 FROM debian:bookworm-slim diff --git a/service-versions.yaml b/service-versions.yaml index cc4d491..256a47a 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -230,8 +230,8 @@ services: - name: runner-job-image type: argocd - last-reviewed: 2026-02-23 - current-version: "0.20.0" + last-reviewed: 2026-03-06 + current-version: "0.20.1" upstream-source: https://github.com/dagger/dagger/releases notes: >- Forgejo Actions job execution image. CONTAINER_APP_VERSION tracks the From b793299d6d92d1e2d6426fd2a5946976c2904e75 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Fri, 6 Mar 2026 20:41:02 -0800 Subject: [PATCH 007/430] Upgrade Dagger engine from v0.20.0 to v0.20.1 Phase 2 of Dagger upgrade: bump engine version, update runner deployment to v0.20.1-24f7512, and fix docs reference card version. Co-Authored-By: Claude Opus 4.6 --- argocd/manifests/forgejo-runner/deployment.yaml | 2 +- dagger.json | 2 +- docs/reference/tools/dagger.md | 2 +- mise.toml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/argocd/manifests/forgejo-runner/deployment.yaml b/argocd/manifests/forgejo-runner/deployment.yaml index 3923f66..f61fb77 100644 --- a/argocd/manifests/forgejo-runner/deployment.yaml +++ b/argocd/manifests/forgejo-runner/deployment.yaml @@ -29,7 +29,7 @@ spec: - name: RUNNER_NAME value: "k8s-runner" - name: RUNNER_LABELS - value: "k8s:docker://registry.ops.eblu.me/blumeops/runner-job-image:v0.20.0-448689b" + value: "k8s:docker://registry.ops.eblu.me/blumeops/runner-job-image:v0.20.1-24f7512" command: - /bin/sh - -c diff --git a/dagger.json b/dagger.json index d48c10c..684aa80 100644 --- a/dagger.json +++ b/dagger.json @@ -1,6 +1,6 @@ { "name": "blumeops-ci", - "engineVersion": "v0.20.0", + "engineVersion": "v0.20.1", "sdk": { "source": "python" }, diff --git a/docs/reference/tools/dagger.md b/docs/reference/tools/dagger.md index 677e7f5..5d8a46d 100644 --- a/docs/reference/tools/dagger.md +++ b/docs/reference/tools/dagger.md @@ -16,7 +16,7 @@ Build engine for BlumeOps CI/CD pipelines. Replaces shell-based build scripts wi | Property | Value | |----------|-------| | **Module** | `blumeops-ci` | -| **Engine Version** | v0.19.11 | +| **Engine Version** | v0.20.1 | | **SDK** | Python | | **Source** | `.dagger/src/blumeops_ci/main.py` | | **Config** | `dagger.json` | diff --git a/mise.toml b/mise.toml index becbe3a..5bb2829 100644 --- a/mise.toml +++ b/mise.toml @@ -2,4 +2,4 @@ "pipx:ansible-core" = { version = "latest", uvx = "true", uvx_args = "--with botocore --with boto3" } prek = "latest" pulumi = "latest" -dagger = "0.20.0" +dagger = "0.20.1" From 55013db12427b87b4be78ae4955c7f0bc1ff3433 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Fri, 6 Mar 2026 20:42:00 -0800 Subject: [PATCH 008/430] Add changelog fragment for Dagger v0.20.1 upgrade Co-Authored-By: Claude Opus 4.6 --- docs/changelog.d/+upgrade-dagger-v0.20.1.infra.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 docs/changelog.d/+upgrade-dagger-v0.20.1.infra.md diff --git a/docs/changelog.d/+upgrade-dagger-v0.20.1.infra.md b/docs/changelog.d/+upgrade-dagger-v0.20.1.infra.md new file mode 100644 index 0000000..1e11335 --- /dev/null +++ b/docs/changelog.d/+upgrade-dagger-v0.20.1.infra.md @@ -0,0 +1 @@ +Upgrade Dagger engine and CLI from v0.20.0 to v0.20.1. From 2809ba6f50301b88e313c4f068ad19f1cbdd21d2 Mon Sep 17 00:00:00 2001 From: Forgejo Actions Date: Fri, 6 Mar 2026 20:49:01 -0800 Subject: [PATCH 009/430] Update docs release to v1.13.3 - Built changelog from towncrier fragments [skip ci] --- CHANGELOG.md | 11 +++++++++++ argocd/manifests/docs/deployment.yaml | 2 +- docs/changelog.d/+upgrade-dagger-howto.doc.md | 1 - docs/changelog.d/+upgrade-dagger-v0.20.1.infra.md | 1 - 4 files changed, 12 insertions(+), 3 deletions(-) delete mode 100644 docs/changelog.d/+upgrade-dagger-howto.doc.md delete mode 100644 docs/changelog.d/+upgrade-dagger-v0.20.1.infra.md diff --git a/CHANGELOG.md b/CHANGELOG.md index b7c301e..2701491 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [v1.13.3] - 2026-03-06 + +### Infrastructure + +- Upgrade Dagger engine and CLI from v0.20.0 to v0.20.1. + +### Documentation + +- Add how-to guide for upgrading Dagger, documenting the correct phase ordering to avoid chicken-and-egg CI failures. + + ## [v1.13.2] - 2026-03-06 ### Infrastructure diff --git a/argocd/manifests/docs/deployment.yaml b/argocd/manifests/docs/deployment.yaml index e9f1c2d..aef1e20 100644 --- a/argocd/manifests/docs/deployment.yaml +++ b/argocd/manifests/docs/deployment.yaml @@ -27,7 +27,7 @@ spec: name: http env: - name: DOCS_RELEASE_URL - value: "https://forge.eblu.me/eblume/blumeops/releases/download/v1.13.2/docs-v1.13.2.tar.gz" + value: "https://forge.eblu.me/eblume/blumeops/releases/download/v1.13.3/docs-v1.13.3.tar.gz" resources: requests: memory: "64Mi" diff --git a/docs/changelog.d/+upgrade-dagger-howto.doc.md b/docs/changelog.d/+upgrade-dagger-howto.doc.md deleted file mode 100644 index dcf4f07..0000000 --- a/docs/changelog.d/+upgrade-dagger-howto.doc.md +++ /dev/null @@ -1 +0,0 @@ -Add how-to guide for upgrading Dagger, documenting the correct phase ordering to avoid chicken-and-egg CI failures. diff --git a/docs/changelog.d/+upgrade-dagger-v0.20.1.infra.md b/docs/changelog.d/+upgrade-dagger-v0.20.1.infra.md deleted file mode 100644 index 1e11335..0000000 --- a/docs/changelog.d/+upgrade-dagger-v0.20.1.infra.md +++ /dev/null @@ -1 +0,0 @@ -Upgrade Dagger engine and CLI from v0.20.0 to v0.20.1. From 590cb1d25d3102fae73dfcc6250dd6da81e412a1 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sat, 7 Mar 2026 08:46:11 -0800 Subject: [PATCH 010/430] Document required preview directory for Frigate NFS volume Frigate 0.17 does not auto-create clips/previews//, causing review page previews to silently fail with 500 errors. Co-Authored-By: Claude Opus 4.6 --- argocd/manifests/frigate/pv-nfs.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/argocd/manifests/frigate/pv-nfs.yaml b/argocd/manifests/frigate/pv-nfs.yaml index c7197ab..86d3956 100644 --- a/argocd/manifests/frigate/pv-nfs.yaml +++ b/argocd/manifests/frigate/pv-nfs.yaml @@ -6,6 +6,9 @@ # 2. Name: frigate, Location: Volume 1 # 3. Control Panel > File Services > NFS > NFS Rules # 4. Add rule for "frigate" share: Hostname=ringtail, Privilege=Read/Write, Squash=No mapping +# +# Required directories (Frigate 0.17 does not auto-create these): +# clips/previews// — review page preview clips apiVersion: v1 kind: PersistentVolume metadata: From e47a3b2ebb91f835094fd79ca5daaf08ec6d43d3 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sat, 7 Mar 2026 08:59:51 -0800 Subject: [PATCH 011/430] Review and update review-documentation.md - Add last-reviewed date - Replace raw pulumi commands with mise task equivalents - Reference C0/C1/C2 change classification for making changes - Note that prek handles link validation automatically Co-Authored-By: Claude Opus 4.6 --- .../knowledgebase/review-documentation.md | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/docs/how-to/knowledgebase/review-documentation.md b/docs/how-to/knowledgebase/review-documentation.md index 9d031d3..d6dc064 100644 --- a/docs/how-to/knowledgebase/review-documentation.md +++ b/docs/how-to/knowledgebase/review-documentation.md @@ -1,6 +1,7 @@ --- title: Review Documentation modified: 2026-02-09 +last-reviewed: 2026-03-07 tags: - how-to - documentation @@ -85,23 +86,20 @@ If changes would be made, either the docs are stale or the host has drifted. Check for drift: ```bash -# Tailscale ACLs -cd pulumi/tailscale && pulumi preview - -# DNS (Gandi) -cd pulumi/gandi && pulumi preview +mise run tailnet-preview # Tailscale ACLs +mise run dns-preview # DNS (Gandi) ``` If changes are pending, investigate whether docs or infrastructure is stale. ## Making Changes -If a card needs updates: +If a card needs updates, classify the change (see [[agent-change-process]]): -1. Create a feature branch -2. Make the edits -3. Run `mise run docs-check-links` to verify links -4. Create a PR for review +- **C0 (small fix):** Edit, commit directly to main +- **C1/C2 (larger changes):** Create a feature branch and PR + +Link validation runs automatically via prek on commit. See [[update-documentation]] for publishing changes. From 6a033d55be8b6391825c067e6c9dfcf8beb583ac Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sat, 7 Mar 2026 09:03:08 -0800 Subject: [PATCH 012/430] Review and update review-services.md - Add last-reviewed date - Align service type sections with actual types (argocd/ansible/nixos) - Remove nonexistent "Helm Chart" and "Hybrid" sections - Fold custom container guidance into ArgoCD section - Reference kustomization.yaml for image tags instead of Helm charts Co-Authored-By: Claude Opus 4.6 --- docs/how-to/knowledgebase/review-services.md | 29 +++++++++----------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/docs/how-to/knowledgebase/review-services.md b/docs/how-to/knowledgebase/review-services.md index 516bcef..713a021 100644 --- a/docs/how-to/knowledgebase/review-services.md +++ b/docs/how-to/knowledgebase/review-services.md @@ -1,6 +1,7 @@ --- title: Review Services modified: 2026-02-19 +last-reviewed: 2026-03-07 tags: - how-to - maintenance @@ -37,34 +38,30 @@ mise run service-review --type hybrid ## Review Process by Service Type -### ArgoCD Services +### ArgoCD Services (`type: argocd`) 1. Check the upstream releases page for new versions -2. Compare to the image tag or Helm chart version in `argocd/manifests//` +2. Compare to the image tag in `argocd/manifests//kustomization.yaml` (`images[].newTag`) 3. Review the upstream changelog for breaking changes -4. If upgrading, update the manifest and follow [[deploy-k8s-service]] +4. If the service uses a custom-built container, also check the base image for security updates and follow [[build-container-image]] to rebuild +5. If upgrading, update the manifest and follow [[deploy-k8s-service]] -### Helm Chart Services - -Same as ArgoCD, but also check for new chart versions in the mirrored chart repos under `argocd/manifests//charts/`. - -### Hybrid Services (Custom Container + ArgoCD) - -1. Check the upstream project for new releases -2. Check the base image for security updates -3. If rebuilding, follow [[build-container-image]] to tag and release -4. Update the ArgoCD manifest with the new image tag - -### Ansible Services +### Ansible Services (`type: ansible`) 1. Check the upstream releases page for new versions 2. Review the role's vars/defaults for version pins in `ansible/roles//` 3. If upgrading, update the version and dry-run: `mise run provision-indri -- --tags --check --diff` 4. Follow [[add-ansible-role]] patterns for role changes +### NixOS Services (`type: nixos`) + +1. Check the upstream project for new releases +2. Review the Nix derivation or flake input for version pins +3. If upgrading, update and deploy via `mise run provision-ringtail` + ## Version Tracking Convention -The `current-version` field in `service-versions.yaml` tracks the **upstream application version**, not the container image tag. For hybrid services, the container image tag (e.g., `v1.0.0`) is decoupled from the contained app version (e.g., `v1.10.1`). This allows container rebuilds (base image updates, build fixes) without implying an upstream version change. +The `current-version` field in `service-versions.yaml` tracks the **upstream application version**, not the container image tag. For services with custom-built containers, the container image tag (e.g., `v1.0.0`) is decoupled from the contained app version (e.g., `v1.10.1`). This allows container rebuilds (base image updates, build fixes) without implying an upstream version change. ## Marking a Service as Reviewed From 0e09521ce39e8f94b4acb97c66136ed542cf5069 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sat, 7 Mar 2026 09:03:46 -0800 Subject: [PATCH 013/430] =?UTF-8?q?Review=20manage-flyio-proxy.md=20?= =?UTF-8?q?=E2=80=94=20no=20issues=20found?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add last-reviewed date. Content is accurate and complete. Co-Authored-By: Claude Opus 4.6 --- docs/how-to/operations/manage-flyio-proxy.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/how-to/operations/manage-flyio-proxy.md b/docs/how-to/operations/manage-flyio-proxy.md index d8c5ba4..519481f 100644 --- a/docs/how-to/operations/manage-flyio-proxy.md +++ b/docs/how-to/operations/manage-flyio-proxy.md @@ -1,6 +1,7 @@ --- title: Manage Fly.io Proxy modified: 2026-02-08 +last-reviewed: 2026-03-07 tags: - how-to - fly-io From d3f9699c418cf5908f9ccbd4f0769ffab2c1e65c Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sat, 7 Mar 2026 09:10:16 -0800 Subject: [PATCH 014/430] =?UTF-8?q?Review=20cv=20and=20docs=20services=20?= =?UTF-8?q?=E2=80=94=20both=20healthy,=20no=20upgrades=20needed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- service-versions.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/service-versions.yaml b/service-versions.yaml index 256a47a..3c75f49 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -207,14 +207,14 @@ services: - name: cv type: argocd - last-reviewed: null + last-reviewed: 2026-03-07 current-version: "1.0.3" upstream-source: null notes: Personal static site, no upstream - name: docs type: argocd - last-reviewed: null + last-reviewed: 2026-03-07 current-version: "1.28.2" upstream-source: https://github.com/jackyzha0/quartz/releases notes: Quartz static site generator; container version tracks nginx base From 14e931591bd20da54810e9d75aa33bd640294e6a Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sat, 7 Mar 2026 13:57:04 -0800 Subject: [PATCH 015/430] Fix 1Password Connect numeric log levels misclassified in Grafana (#287) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - 1Password Connect uses non-standard numeric log levels (`1`=error, `2`=warn, `3`=info, `4`=debug, `5`=trace) per [1Password/connect#44](https://github.com/1Password/connect/issues/44) - Alloy extracts the `level` JSON field as-is, so info-level health checks get `level="3"` in Loki - Grafana expects string level labels — numeric values are unrecognized, causing misclassified log severity/coloring - Adds a `stage.match` + `stage.template` in the Alloy pipeline scoped to `{namespace="1password"}` to normalize numeric levels to standard strings - Other services are completely unaffected (scoped by namespace, not global) ## Deployment and Testing - [ ] Sync alloy-k8s from branch: `argocd app set alloy-k8s --revision fix/onepassword-numeric-log-levels && argocd app sync alloy-k8s` - [ ] Wait ~2 minutes for new logs to flow - [ ] Verify level labels: `curl -sG "http://localhost:3100/loki/api/v1/label/level/values" --data-urlencode 'query={namespace="1password"}'` should show `"info"` and `"warn"` instead of `"3"` and `"2"` - [ ] Check Grafana log panel for 1password namespace — logs should no longer appear as errors - [ ] After merge: `argocd app set alloy-k8s --revision main && argocd app sync alloy-k8s` Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/287 --- argocd/manifests/alloy-k8s/config.alloy | 12 ++++++++++++ argocd/manifests/alloy-ringtail/config.alloy | 12 ++++++++++++ .../fix-onepassword-numeric-log-levels.bugfix.md | 1 + 3 files changed, 25 insertions(+) create mode 100644 docs/changelog.d/fix-onepassword-numeric-log-levels.bugfix.md diff --git a/argocd/manifests/alloy-k8s/config.alloy b/argocd/manifests/alloy-k8s/config.alloy index 86c0747..c169c93 100644 --- a/argocd/manifests/alloy-k8s/config.alloy +++ b/argocd/manifests/alloy-k8s/config.alloy @@ -100,6 +100,18 @@ loki.process "pods" { values = ["__error__", "__error_details__"] } + // Normalize 1password-connect numeric log levels to strings (1=error..5=trace) + // Scoped to the 1password namespace so other services are unaffected. + // See: https://github.com/1Password/connect/issues/44 + stage.match { + selector = "{namespace=\"1password\"}" + + stage.template { + source = "level" + template = "{{ if eq .Value \"1\" }}error{{ else if eq .Value \"2\" }}warn{{ else if eq .Value \"3\" }}info{{ else if eq .Value \"4\" }}debug{{ else if eq .Value \"5\" }}trace{{ else }}{{ .Value }}{{ end }}" + } + } + // Extract labels from parsed JSON data stage.labels { values = { diff --git a/argocd/manifests/alloy-ringtail/config.alloy b/argocd/manifests/alloy-ringtail/config.alloy index 9ae8981..c63b478 100644 --- a/argocd/manifests/alloy-ringtail/config.alloy +++ b/argocd/manifests/alloy-ringtail/config.alloy @@ -139,6 +139,18 @@ loki.process "pods" { values = ["__error__", "__error_details__"] } + // Normalize 1password-connect numeric log levels to strings (1=error..5=trace) + // Scoped to the 1password namespace so other services are unaffected. + // See: https://github.com/1Password/connect/issues/44 + stage.match { + selector = "{namespace=\"1password\"}" + + stage.template { + source = "level" + template = "{{ if eq .Value \"1\" }}error{{ else if eq .Value \"2\" }}warn{{ else if eq .Value \"3\" }}info{{ else if eq .Value \"4\" }}debug{{ else if eq .Value \"5\" }}trace{{ else }}{{ .Value }}{{ end }}" + } + } + // Extract labels from parsed JSON data stage.labels { values = { diff --git a/docs/changelog.d/fix-onepassword-numeric-log-levels.bugfix.md b/docs/changelog.d/fix-onepassword-numeric-log-levels.bugfix.md new file mode 100644 index 0000000..74f2a99 --- /dev/null +++ b/docs/changelog.d/fix-onepassword-numeric-log-levels.bugfix.md @@ -0,0 +1 @@ +Fix 1Password Connect logs showing as errors in Grafana by normalizing numeric log levels (1-5) to standard strings (error/warn/info/debug/trace) in the Alloy log processing pipeline. From 1c3bf35dad883ec01cbc70e902557e20cd02b00d Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sat, 7 Mar 2026 20:41:03 -0800 Subject: [PATCH 016/430] Fix mikado invariant check rejecting close without impl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A close commit with zero preceding impl commits is valid — some leaf nodes involve operational steps (e.g., creating a mirror) with no code changes. Removed the false-positive check. Co-Authored-By: Claude Opus 4.6 --- .../+fix-mikado-close-without-impl.bugfix.md | 1 + mise-tasks/mikado-branch-invariant-check | 10 ++-------- 2 files changed, 3 insertions(+), 8 deletions(-) create mode 100644 docs/changelog.d/+fix-mikado-close-without-impl.bugfix.md diff --git a/docs/changelog.d/+fix-mikado-close-without-impl.bugfix.md b/docs/changelog.d/+fix-mikado-close-without-impl.bugfix.md new file mode 100644 index 0000000..f6501ae --- /dev/null +++ b/docs/changelog.d/+fix-mikado-close-without-impl.bugfix.md @@ -0,0 +1 @@ +Fix mikado-branch-invariant-check false positive: close commits without preceding impl commits are valid (e.g., operational tasks with no code changes). diff --git a/mise-tasks/mikado-branch-invariant-check b/mise-tasks/mikado-branch-invariant-check index d8443ba..ffe3d1a 100755 --- a/mise-tasks/mikado-branch-invariant-check +++ b/mise-tasks/mikado-branch-invariant-check @@ -14,9 +14,8 @@ Checks: 1. All commits follow the C2(): convention 2. The invariant ordering is maintained: plan commits come before impl/close 3. No plan commits appear after any impl or close commits -4. Close commits don't appear before impl commits in the same cycle -5. The chain stem in commit messages matches the branch name -6. impl commits don't modify Mikado card files (docs with mikado frontmatter) +4. The chain stem in commit messages matches the branch name +5. impl commits don't modify Mikado card files (docs with mikado frontmatter) Can also be run standalone (no arguments) to validate existing branch history. @@ -233,11 +232,6 @@ def check_invariant(commits: list[dict], chain_stem: str) -> list[str]: seen_impl = True elif verb == "close": seen_close = True - if not seen_impl: - errors.append( - f"{sha_short}: close commit without preceding impl — " - f"leaf nodes should be closed after implementation work" - ) elif verb == "finalize": # finalize is the permitted exception — must be last if i != len(commits) - 1: From 3a811fb188d508f3b6b24c08fa22ffc24065b254 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sun, 8 Mar 2026 11:02:05 -0700 Subject: [PATCH 017/430] =?UTF-8?q?Deploy=20JobSync=20=E2=80=94=20job=20se?= =?UTF-8?q?arch=20tracker=20on=20ringtail=20k3s=20(#288)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary C2 Mikado chain to deploy [JobSync](https://github.com/Gsync/jobsync) — a self-hosted job application tracker — to ringtail's k3s cluster. ### Mikado Graph ``` deploy-jobsync (goal) ├── build-jobsync-container │ └── mirror-jobsync └── integrate-jobsync-ollama ``` ### What is JobSync? Next.js app with SQLite for tracking job applications. Features resume management, application pipeline tracking, and AI-powered resume review/job matching. ### Key Decisions - **Ringtail k3s** (not minikube-indri) — colocates with Ollama for zero-latency AI - **Nix container** via `buildLayeredImage` — no Dockerfile, mirrors upstream source on forge - **Ollama for AI** — uses existing deployment, no API keys needed for AI features - **No upstream fork** — vanilla JobSync, Anthropic AI deferred to future work if needed ### Current Status Planning phase — cards committed, ready for review before implementation begins. Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/288 --- ansible/roles/caddy/defaults/main.yml | 3 + argocd/apps/jobsync.yaml | 18 +++ argocd/manifests/jobsync/deployment.yaml | 73 ++++++++++ argocd/manifests/jobsync/external-secret.yaml | 23 ++++ .../manifests/jobsync/ingress-tailscale.yaml | 26 ++++ argocd/manifests/jobsync/kustomization.yaml | 15 +++ argocd/manifests/jobsync/pvc.yaml | 13 ++ argocd/manifests/jobsync/service.yaml | 13 ++ containers/jobsync/default.nix | 126 ++++++++++++++++++ containers/jobsync/entrypoint.sh | 15 +++ docs/changelog.d/mikado-jobsync.feature.md | 1 + docs/how-to/how-to.md | 5 + .../how-to/jobsync/build-jobsync-container.md | 61 +++++++++ docs/how-to/jobsync/deploy-jobsync.md | 60 +++++++++ service-versions.yaml | 7 + 15 files changed, 459 insertions(+) create mode 100644 argocd/apps/jobsync.yaml create mode 100644 argocd/manifests/jobsync/deployment.yaml create mode 100644 argocd/manifests/jobsync/external-secret.yaml create mode 100644 argocd/manifests/jobsync/ingress-tailscale.yaml create mode 100644 argocd/manifests/jobsync/kustomization.yaml create mode 100644 argocd/manifests/jobsync/pvc.yaml create mode 100644 argocd/manifests/jobsync/service.yaml create mode 100644 containers/jobsync/default.nix create mode 100644 containers/jobsync/entrypoint.sh create mode 100644 docs/changelog.d/mikado-jobsync.feature.md create mode 100644 docs/how-to/jobsync/build-jobsync-container.md create mode 100644 docs/how-to/jobsync/deploy-jobsync.md diff --git a/ansible/roles/caddy/defaults/main.yml b/ansible/roles/caddy/defaults/main.yml index 464d331..931e2a0 100644 --- a/ansible/roles/caddy/defaults/main.yml +++ b/ansible/roles/caddy/defaults/main.yml @@ -85,6 +85,9 @@ caddy_services: - name: ntfy host: "ntfy.{{ caddy_domain }}" backend: "https://ntfy.tail8d86e.ts.net" + - name: jobsync + host: "jobsync.{{ caddy_domain }}" + backend: "https://jobsync.tail8d86e.ts.net" - name: ollama host: "ollama.{{ caddy_domain }}" backend: "https://ollama.tail8d86e.ts.net" diff --git a/argocd/apps/jobsync.yaml b/argocd/apps/jobsync.yaml new file mode 100644 index 0000000..11d8beb --- /dev/null +++ b/argocd/apps/jobsync.yaml @@ -0,0 +1,18 @@ +--- +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: jobsync + namespace: argocd +spec: + project: default + source: + repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git + targetRevision: main + path: argocd/manifests/jobsync + destination: + server: https://ringtail.tail8d86e.ts.net:6443 + namespace: jobsync + syncPolicy: + syncOptions: + - CreateNamespace=true diff --git a/argocd/manifests/jobsync/deployment.yaml b/argocd/manifests/jobsync/deployment.yaml new file mode 100644 index 0000000..833a9b8 --- /dev/null +++ b/argocd/manifests/jobsync/deployment.yaml @@ -0,0 +1,73 @@ +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: jobsync + namespace: jobsync +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app: jobsync + template: + metadata: + labels: + app: jobsync + spec: + containers: + - name: jobsync + image: blumeops/jobsync:kustomized + ports: + - containerPort: 3000 + name: http + env: + - name: DATABASE_URL + value: "file:/data/dev.db" + - name: NEXTAUTH_URL + value: "https://jobsync.ops.eblu.me" + - name: AUTH_TRUST_HOST + value: "true" + - name: NEXT_TELEMETRY_DISABLED + value: "1" + - name: TZ + value: "America/Los_Angeles" + - name: OLLAMA_BASE_URL + value: "http://ollama.ollama.svc.cluster.local:11434" + - name: AUTH_SECRET + valueFrom: + secretKeyRef: + name: jobsync-secrets + key: auth_secret + - name: ENCRYPTION_KEY + valueFrom: + secretKeyRef: + name: jobsync-secrets + key: encryption_key + volumeMounts: + - name: data + mountPath: /data + resources: + requests: + memory: "256Mi" + cpu: "100m" + limits: + memory: "512Mi" + cpu: "500m" + livenessProbe: + httpGet: + path: / + port: 3000 + initialDelaySeconds: 30 + periodSeconds: 30 + readinessProbe: + httpGet: + path: / + port: 3000 + initialDelaySeconds: 10 + periodSeconds: 10 + volumes: + - name: data + persistentVolumeClaim: + claimName: jobsync-data diff --git a/argocd/manifests/jobsync/external-secret.yaml b/argocd/manifests/jobsync/external-secret.yaml new file mode 100644 index 0000000..e4ef3a2 --- /dev/null +++ b/argocd/manifests/jobsync/external-secret.yaml @@ -0,0 +1,23 @@ +--- +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: jobsync-secrets + namespace: jobsync +spec: + refreshInterval: 1h + secretStoreRef: + kind: ClusterSecretStore + name: onepassword-blumeops + target: + name: jobsync-secrets + creationPolicy: Owner + data: + - secretKey: auth_secret + remoteRef: + key: JobSync + property: auth_secret + - secretKey: encryption_key + remoteRef: + key: JobSync + property: encryption_key diff --git a/argocd/manifests/jobsync/ingress-tailscale.yaml b/argocd/manifests/jobsync/ingress-tailscale.yaml new file mode 100644 index 0000000..a8e24c8 --- /dev/null +++ b/argocd/manifests/jobsync/ingress-tailscale.yaml @@ -0,0 +1,26 @@ +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: jobsync-tailscale + namespace: jobsync + annotations: + tailscale.com/proxy-class: "default" + tailscale.com/proxy-group: "ingress" + gethomepage.dev/enabled: "true" + gethomepage.dev/name: "JobSync" + gethomepage.dev/group: "Apps" + gethomepage.dev/icon: "mdi-briefcase-search" + gethomepage.dev/description: "Job application tracker" + gethomepage.dev/href: "https://jobsync.ops.eblu.me" + gethomepage.dev/pod-selector: "app=jobsync" +spec: + ingressClassName: tailscale + defaultBackend: + service: + name: jobsync + port: + number: 3000 + tls: + - hosts: + - jobsync diff --git a/argocd/manifests/jobsync/kustomization.yaml b/argocd/manifests/jobsync/kustomization.yaml new file mode 100644 index 0000000..d0d0c84c --- /dev/null +++ b/argocd/manifests/jobsync/kustomization.yaml @@ -0,0 +1,15 @@ +--- +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: jobsync +resources: + - pvc.yaml + - external-secret.yaml + - deployment.yaml + - service.yaml + - ingress-tailscale.yaml + +images: + - name: blumeops/jobsync + newName: registry.ops.eblu.me/blumeops/jobsync + newTag: "v1.1.4-e51ec83-nix" diff --git a/argocd/manifests/jobsync/pvc.yaml b/argocd/manifests/jobsync/pvc.yaml new file mode 100644 index 0000000..01ab796 --- /dev/null +++ b/argocd/manifests/jobsync/pvc.yaml @@ -0,0 +1,13 @@ +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: jobsync-data + namespace: jobsync +spec: + accessModes: + - ReadWriteOnce + storageClassName: local-path + resources: + requests: + storage: 5Gi diff --git a/argocd/manifests/jobsync/service.yaml b/argocd/manifests/jobsync/service.yaml new file mode 100644 index 0000000..dc2d73a --- /dev/null +++ b/argocd/manifests/jobsync/service.yaml @@ -0,0 +1,13 @@ +--- +apiVersion: v1 +kind: Service +metadata: + name: jobsync + namespace: jobsync +spec: + selector: + app: jobsync + ports: + - name: http + port: 3000 + targetPort: 3000 diff --git a/containers/jobsync/default.nix b/containers/jobsync/default.nix new file mode 100644 index 0000000..198dd70 --- /dev/null +++ b/containers/jobsync/default.nix @@ -0,0 +1,126 @@ +# Nix-built JobSync container +# Next.js job application tracker with Prisma/SQLite +# Built with dockerTools.buildLayeredImage for efficient layer caching +{ pkgs ? import { } }: + +let + version = "1.1.4"; + + prismaEngines = pkgs.prisma-engines; + + src = pkgs.fetchgit { + url = "https://forge.ops.eblu.me/mirrors/jobsync.git"; + rev = "v${version}"; + hash = "sha256-59W5OF36yD67jEK5xa9jSL4EVN9RG+Ez/w9Mq2VykSA="; + }; + + jobsync = pkgs.buildNpmPackage { + inherit src version; + pname = "jobsync"; + npmDepsHash = "sha256-yRNOxtz66qSlmfjR3QDPUQe0C8sdg06tBbuK1Ws1gEA="; + + nodejs = pkgs.nodejs_20; + + # Patch out Google Fonts import (nix sandbox blocks network access at + # build time). Replace with a simple object; app uses system sans-serif. + postPatch = '' + substituteInPlace src/app/layout.tsx \ + --replace-fail 'import { Inter } from "next/font/google";' "" \ + --replace-fail 'const inter = Inter({ + subsets: ["latin"], + variable: "--font-inter", +});' 'const inter = { variable: "" };' + ''; + + # Point Prisma at nixpkgs-built engines (no network download in sandbox) + env = { + PRISMA_QUERY_ENGINE_LIBRARY = "${prismaEngines}/lib/libquery_engine.node"; + PRISMA_QUERY_ENGINE_BINARY = "${prismaEngines}/bin/query-engine"; + PRISMA_SCHEMA_ENGINE_BINARY = "${prismaEngines}/bin/schema-engine"; + PRISMA_FMT_BINARY = "${prismaEngines}/bin/prisma-fmt"; + PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING = "1"; + DATABASE_URL = "file:/tmp/build.db"; + NEXT_TELEMETRY_DISABLED = "1"; + }; + + buildPhase = '' + runHook preBuild + + # Generate Prisma client using nixpkgs engines + npx prisma generate + + # Build Next.js + npm run build + + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + + mkdir -p $out/app + + # Copy Next.js standalone output + cp -r .next/standalone/. $out/app/ + cp -r .next/static $out/app/.next/static + cp -r public $out/app/public + + # Copy Prisma schema and migrations for runtime migrate deploy + cp -r prisma $out/app/prisma + + # Copy entrypoint + cp ${./entrypoint.sh} $out/app/entrypoint.sh + + runHook postInstall + ''; + + dontNpmBuild = true; + }; + + entrypoint = pkgs.writeShellScript "jobsync-entrypoint" '' + cd ${jobsync}/app + exec ${pkgs.bash}/bin/bash entrypoint.sh "$@" + ''; +in + +pkgs.dockerTools.buildLayeredImage { + name = "blumeops/jobsync"; + tag = "latest"; + + contents = [ + jobsync + prismaEngines + pkgs.nodejs_20 + pkgs.cacert + pkgs.tzdata + pkgs.bash + pkgs.coreutils + ]; + + # Create writable directories and FHS symlinks for nix container + extraCommands = '' + mkdir -p tmp data usr/bin + ln -s ${pkgs.coreutils}/bin/env usr/bin/env + ''; + + config = { + Entrypoint = [ "${entrypoint}" ]; + WorkingDir = "${jobsync}/app"; + Env = [ + "SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" + "TZDIR=${pkgs.tzdata}/share/zoneinfo" + "NODE_ENV=production" + "PORT=3000" + "DATABASE_URL=file:/data/dev.db" + "PRISMA_QUERY_ENGINE_LIBRARY=${prismaEngines}/lib/libquery_engine.node" + "PRISMA_SCHEMA_ENGINE_BINARY=${prismaEngines}/bin/schema-engine" + "PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING=1" + ]; + ExposedPorts = { + "3000/tcp" = { }; + }; + Volumes = { + "/data" = { }; + }; + }; +} diff --git a/containers/jobsync/entrypoint.sh b/containers/jobsync/entrypoint.sh new file mode 100644 index 0000000..4dc611f --- /dev/null +++ b/containers/jobsync/entrypoint.sh @@ -0,0 +1,15 @@ +#!/bin/sh +set -e + +# Auto-generate AUTH_SECRET if not provided +if [ -z "$AUTH_SECRET" ]; then + AUTH_SECRET="$(node -e "console.log(require('crypto').randomBytes(32).toString('base64'))")" + export AUTH_SECRET + echo "AUTH_SECRET was not set — generated a temporary secret for this container." +fi + +# Run Prisma migrations (npx -y downloads prisma if not in local node_modules) +npx -y prisma@6.19.0 migrate deploy + +# Start the Next.js server +exec node server.js diff --git a/docs/changelog.d/mikado-jobsync.feature.md b/docs/changelog.d/mikado-jobsync.feature.md new file mode 100644 index 0000000..bdd3eb7 --- /dev/null +++ b/docs/changelog.d/mikado-jobsync.feature.md @@ -0,0 +1 @@ +Deploy JobSync to ringtail k3s — nix-built container, Tailscale Ingress, Caddy route at `jobsync.ops.eblu.me`, Ollama integration for AI features. diff --git a/docs/how-to/how-to.md b/docs/how-to/how-to.md index a9e096a..0ca60a6 100644 --- a/docs/how-to/how-to.md +++ b/docs/how-to/how-to.md @@ -88,6 +88,11 @@ tags: - [[upgrade-dagger]] +## JobSync + +- [[deploy-jobsync]] +- [[build-jobsync-container]] + ## Forgejo Runner - [[upgrade-k8s-runner]] diff --git a/docs/how-to/jobsync/build-jobsync-container.md b/docs/how-to/jobsync/build-jobsync-container.md new file mode 100644 index 0000000..de75915 --- /dev/null +++ b/docs/how-to/jobsync/build-jobsync-container.md @@ -0,0 +1,61 @@ +--- +title: Build JobSync Container +modified: 2026-03-08 +tags: + - how-to + - jobsync + - nix +--- + +# Build JobSync Container + +Build and release the JobSync nix container image. + +```fish +mise run container-release jobsync 1.1.4 +``` + +The derivation is at `containers/jobsync/default.nix`. It uses `buildNpmPackage` for the Next.js app and `dockerTools.buildLayeredImage` for the container. The entrypoint (`containers/jobsync/entrypoint.sh`) runs `prisma migrate deploy` then starts `node server.js`. + +## Upgrading JobSync + +1. Update the forge mirror: `mise run mirror-sync jobsync` +2. Update `version` in `default.nix` to match the new upstream tag +3. Clear `hash` in `fetchgit` (set to `""`), build, grab the correct hash from the error +4. Clear `npmDepsHash` (set to `""`), build again, grab the correct hash +5. Check if `postPatch` still applies — the Google Fonts import may change between versions +6. `mise run container-release jobsync ` +7. Update `newTag` in `argocd/manifests/jobsync/kustomization.yaml` + +## Nix + Prisma + Next.js Pitfalls + +### Prisma engine downloads blocked by sandbox + +Prisma tries to download platform-specific engine binaries during `prisma generate`. The nix sandbox blocks network access at build time. + +**Fix:** Use `pkgs.prisma-engines` from nixpkgs and set env vars pointing at the nix store paths: `PRISMA_QUERY_ENGINE_LIBRARY`, `PRISMA_QUERY_ENGINE_BINARY`, `PRISMA_SCHEMA_ENGINE_BINARY`, `PRISMA_FMT_BINARY`. Set `PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING=1` to tolerate minor version mismatch. + +### Google Fonts blocked by sandbox + +`next/font/google` fetches from `fonts.googleapis.com` during `next build`. + +**Fix:** Patch `src/app/layout.tsx` in `postPatch` to replace the Google font import with a no-op object. The app falls back to system sans-serif. + +### Missing FHS paths in nix containers + +Nix containers lack `/usr/bin/env`, `/tmp`, etc. `npx`-downloaded packages use `#!/usr/bin/env node` shebangs. + +**Fix:** In `extraCommands`: `mkdir -p tmp data usr/bin` and `ln -s ${pkgs.coreutils}/bin/env usr/bin/env`. + +### Runtime migrations via npx + +The nix sandbox blocks network at build time, but runtime has full network access. Use `npx -y prisma@ migrate deploy` in the entrypoint — npx downloads the prisma CLI on first run. + +### Build on ringtail, not via Dagger + +The Dagger `build-nix` pipeline runs in host architecture. On macOS (arm64), this produces arm64 images. Build on ringtail (x86_64) using the CI workflow or `mise run container-release`. + +## Related + +- [[deploy-jobsync]] +- [[build-container-image]] diff --git a/docs/how-to/jobsync/deploy-jobsync.md b/docs/how-to/jobsync/deploy-jobsync.md new file mode 100644 index 0000000..6b72ad7 --- /dev/null +++ b/docs/how-to/jobsync/deploy-jobsync.md @@ -0,0 +1,60 @@ +--- +title: Deploy JobSync +modified: 2026-03-08 +tags: + - how-to + - jobsync +--- + +# Deploy JobSync + +[JobSync](https://github.com/Gsync/jobsync) is a self-hosted job application tracker (Next.js + Prisma/SQLite) running on ringtail's k3s cluster via ArgoCD. + +- **URL:** `https://jobsync.ops.eblu.me` +- **Auth:** Local accounts (email/password), no SSO +- **Storage:** 5Gi PVC at `/data` (SQLite DB + resume uploads) +- **AI:** Ollama at `ollama.ollama.svc.cluster.local:11434` + +## Manifests + +All in `argocd/manifests/jobsync/`: + +| File | Purpose | +|------|---------| +| `deployment.yaml` | Single-replica deployment | +| `service.yaml` | ClusterIP on port 3000 | +| `ingress-tailscale.yaml` | Tailscale Ingress (ProxyGroup) | +| `pvc.yaml` | 5Gi local-path for `/data` | +| `external-secret.yaml` | `auth_secret` + `encryption_key` from 1Password | +| `kustomization.yaml` | Image tag override | + +## Environment Variables + +| Variable | Source | Purpose | +|----------|--------|---------| +| `DATABASE_URL` | Hardcoded | `file:/data/dev.db` | +| `AUTH_SECRET` | ExternalSecret | NextAuth session signing | +| `ENCRYPTION_KEY` | ExternalSecret | AES-256-GCM for stored API keys | +| `NEXTAUTH_URL` | Hardcoded | `https://jobsync.ops.eblu.me` | +| `AUTH_TRUST_HOST` | Hardcoded | `true` | +| `TZ` | Hardcoded | `America/Los_Angeles` | +| `OLLAMA_BASE_URL` | Hardcoded | `http://ollama.ollama.svc.cluster.local:11434` | + +## Updating the Container + +1. Build and push: `mise run container-release jobsync ` +2. Update `newTag` in `kustomization.yaml` to the full tag (e.g. `v1.1.4-e51ec83-nix`) +3. Sync: `argocd app sync jobsync` + +See [[build-jobsync-container]] for nix build details. + +## Notes + +- **1Password item:** "JobSync" in blumeops vault, fields `auth_secret` and `encryption_key` +- **Caddy route:** `jobsync.ops.eblu.me` → `https://jobsync.tail8d86e.ts.net` (in `ansible/roles/caddy/defaults/main.yml`) +- **`service-versions.yaml`:** Must have a `jobsync` entry or the pre-commit hook rejects container changes + +## Related + +- [[build-jobsync-container]] +- [[deploy-k8s-service]] diff --git a/service-versions.yaml b/service-versions.yaml index 3c75f49..2bf9419 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -155,6 +155,13 @@ services: current-version: "2026.2.0" upstream-source: https://github.com/goauthentik/authentik/releases + - name: jobsync + type: argocd + last-reviewed: null + current-version: "1.1.4" + upstream-source: https://github.com/Gsync/jobsync/releases + notes: Job application tracker; nix container on ringtail k3s + - name: ollama type: argocd last-reviewed: "2026-03-02" From c9270c764595beb3e0ed13bead6c0709fed77b72 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sun, 8 Mar 2026 11:13:34 -0700 Subject: [PATCH 018/430] Update jobsync image to v1.1.4-3a811fb-nix (main build) Co-Authored-By: Claude Opus 4.6 --- argocd/manifests/jobsync/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/argocd/manifests/jobsync/kustomization.yaml b/argocd/manifests/jobsync/kustomization.yaml index d0d0c84c..7929f1e 100644 --- a/argocd/manifests/jobsync/kustomization.yaml +++ b/argocd/manifests/jobsync/kustomization.yaml @@ -12,4 +12,4 @@ resources: images: - name: blumeops/jobsync newName: registry.ops.eblu.me/blumeops/jobsync - newTag: "v1.1.4-e51ec83-nix" + newTag: "v1.1.4-3a811fb-nix" From 770a7b2d6a1128da5e665a6d5739293882a772ee Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sun, 8 Mar 2026 15:06:52 -0700 Subject: [PATCH 019/430] Add JobSync reference card, observability docs, and RAPIDAPI_KEY plumbing (#289) ## Summary - Add JobSync service reference card (`docs/reference/services/jobsync.md`) with architecture, secrets, observability, and JSearch API docs - Add JobSync and Ollama to ringtail's workloads table (both were missing) - Add JobSync to the reference index - Wire `RAPIDAPI_KEY` through ExternalSecret and deployment env var for JSearch job search automation - Document Loki log queries for observability (no metrics endpoint exists) - Update deploy-jobsync how-to with new env var, observability section, and reference card link ## Deployment and Testing - [ ] Sign up for RapidAPI JSearch API (free tier: 500 req/month) - [ ] Add `rapidapi_key` field to "JobSync" 1Password item - [ ] Merge PR - [ ] `argocd app sync jobsync` to pick up new env var - [ ] Verify job search works at https://jobsync.ops.eblu.me/dashboard/automations Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/289 --- argocd/manifests/jobsync/deployment.yaml | 5 + argocd/manifests/jobsync/external-secret.yaml | 4 + .../feature-jobsync-docs-and-rapidapi.doc.md | 1 + docs/how-to/jobsync/deploy-jobsync.md | 14 ++- docs/reference/infrastructure/ringtail.md | 2 + docs/reference/reference.md | 1 + docs/reference/services/jobsync.md | 104 ++++++++++++++++++ 7 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 docs/changelog.d/feature-jobsync-docs-and-rapidapi.doc.md create mode 100644 docs/reference/services/jobsync.md diff --git a/argocd/manifests/jobsync/deployment.yaml b/argocd/manifests/jobsync/deployment.yaml index 833a9b8..be19a81 100644 --- a/argocd/manifests/jobsync/deployment.yaml +++ b/argocd/manifests/jobsync/deployment.yaml @@ -45,6 +45,11 @@ spec: secretKeyRef: name: jobsync-secrets key: encryption_key + - name: RAPIDAPI_KEY + valueFrom: + secretKeyRef: + name: jobsync-secrets + key: rapidapi_key volumeMounts: - name: data mountPath: /data diff --git a/argocd/manifests/jobsync/external-secret.yaml b/argocd/manifests/jobsync/external-secret.yaml index e4ef3a2..39d58dc 100644 --- a/argocd/manifests/jobsync/external-secret.yaml +++ b/argocd/manifests/jobsync/external-secret.yaml @@ -21,3 +21,7 @@ spec: remoteRef: key: JobSync property: encryption_key + - secretKey: rapidapi_key + remoteRef: + key: JobSync + property: rapidapi_key diff --git a/docs/changelog.d/feature-jobsync-docs-and-rapidapi.doc.md b/docs/changelog.d/feature-jobsync-docs-and-rapidapi.doc.md new file mode 100644 index 0000000..6da4b27 --- /dev/null +++ b/docs/changelog.d/feature-jobsync-docs-and-rapidapi.doc.md @@ -0,0 +1 @@ +Add JobSync reference card, update ringtail workloads table, document observability via Loki, and wire RAPIDAPI_KEY through ExternalSecret for job search automation. diff --git a/docs/how-to/jobsync/deploy-jobsync.md b/docs/how-to/jobsync/deploy-jobsync.md index 6b72ad7..0683a4b 100644 --- a/docs/how-to/jobsync/deploy-jobsync.md +++ b/docs/how-to/jobsync/deploy-jobsync.md @@ -39,6 +39,7 @@ All in `argocd/manifests/jobsync/`: | `AUTH_TRUST_HOST` | Hardcoded | `true` | | `TZ` | Hardcoded | `America/Los_Angeles` | | `OLLAMA_BASE_URL` | Hardcoded | `http://ollama.ollama.svc.cluster.local:11434` | +| `RAPIDAPI_KEY` | ExternalSecret | JSearch job search API key | ## Updating the Container @@ -50,11 +51,22 @@ See [[build-jobsync-container]] for nix build details. ## Notes -- **1Password item:** "JobSync" in blumeops vault, fields `auth_secret` and `encryption_key` +- **1Password item:** "JobSync" in blumeops vault, fields `auth_secret`, `encryption_key`, and `rapidapi_key` - **Caddy route:** `jobsync.ops.eblu.me` → `https://jobsync.tail8d86e.ts.net` (in `ansible/roles/caddy/defaults/main.yml`) - **`service-versions.yaml`:** Must have a `jobsync` entry or the pre-commit hook rejects container changes +## Observability + +JobSync has no metrics endpoint. Logs are collected by Alloy on ringtail and shipped to Loki. Query in Grafana: + +```logql +{namespace="jobsync", app="jobsync"} +``` + +The app runs a scheduled job search daily at 4 AM. Search failures appear in logs during those executions. + ## Related +- [[jobsync]] — Service reference card - [[build-jobsync-container]] - [[deploy-k8s-service]] diff --git a/docs/reference/infrastructure/ringtail.md b/docs/reference/infrastructure/ringtail.md index ef77702..d8e2a05 100644 --- a/docs/reference/infrastructure/ringtail.md +++ b/docs/reference/infrastructure/ringtail.md @@ -70,6 +70,8 @@ Sync order: `1password-connect-ringtail` -> `external-secrets-crds-ringtail` -> | Mosquitto | `mqtt` | MQTT broker for Frigate events | | [[authentik]] | `authentik` | OIDC identity provider | | [[ntfy]] | `ntfy` | Push notification server | +| [[ollama]] | `ollama` | LLM inference with GPU (RTX 4080) | +| [[jobsync]] | `jobsync` | Job application tracker | | nvidia-device-plugin | `nvidia-device-plugin` | Exposes GPU to pods via CDI + nvidia RuntimeClass | ### Manual Cluster Registration diff --git a/docs/reference/reference.md b/docs/reference/reference.md index e9baa20..40cec80 100644 --- a/docs/reference/reference.md +++ b/docs/reference/reference.md @@ -25,6 +25,7 @@ Individual service reference cards with URLs and configuration details. | [[grafana]] | Dashboards & visualization | k8s | | [[immich]] | Photo management | k8s | | [[jellyfin]] | Media server | indri | +| [[jobsync]] | Job application tracker | k8s (ringtail) | | [[kiwix]] | Offline Wikipedia & ZIM archives | k8s | | [[loki]] | Log aggregation | k8s | | [[tempo]] | Distributed tracing | k8s | diff --git a/docs/reference/services/jobsync.md b/docs/reference/services/jobsync.md new file mode 100644 index 0000000..0a28de4 --- /dev/null +++ b/docs/reference/services/jobsync.md @@ -0,0 +1,104 @@ +--- +title: JobSync +modified: 2026-03-08 +tags: + - service + - job-search +--- + +# JobSync + +Self-hosted job application tracker. Tracks job applications, automates job searching via the JSearch API, and provides AI-powered resume tailoring via [[ollama|Ollama]]. + +## Quick Reference + +| Property | Value | +|----------|-------| +| **URL** | https://jobsync.ops.eblu.me | +| **Tailscale URL** | https://jobsync.tail8d86e.ts.net | +| **Namespace** | `jobsync` | +| **Cluster** | ringtail k3s | +| **Image** | `blumeops/jobsync` (Nix-built) | +| **Upstream** | https://github.com/Gsync/jobsync | +| **Manifests** | `argocd/manifests/jobsync/` | +| **Port** | 3000 | + +## Architecture + +``` +Browser ──HTTPS──► Caddy (jobsync.ops.eblu.me) + │ + ▼ + Tailscale ProxyGroup + │ + ▼ + JobSync (Next.js) + ┌───────┴───────┐ + │ │ + SQLite (/data) Ollama (in-cluster) + │ │ + PVC 5Gi GPU-accelerated LLM +``` + +- **Framework:** Next.js 15 + Prisma ORM +- **Database:** SQLite on a 5Gi PVC at `/data` +- **Auth:** Local email/password accounts (NextAuth v5), no SSO +- **AI:** Ollama at `http://ollama.ollama.svc.cluster.local:11434` for resume tailoring +- **Job Search:** JSearch API via RapidAPI (requires `RAPIDAPI_KEY`) + +## Job Search (JSearch / RapidAPI) + +The automated job search feature uses the [JSearch API](https://rapidapi.com/letscrape-6bRBa3QguO5/api/jsearch) on RapidAPI. The API key can be configured two ways (checked in order): + +1. **Per-user:** Added via Settings > API Keys in the web UI (encrypted with `ENCRYPTION_KEY`) +2. **Environment variable:** `RAPIDAPI_KEY` env var as a fallback for all users + +Without either, job search automations fail with: `Search failed: network - RAPIDAPI_KEY is not configured` + +The free tier allows 200 requests/month. The key is stored in 1Password ("JobSync" item, `rapidapi_key` field) and synced via ExternalSecret. + +## Secrets + +All secrets are in the **JobSync** 1Password item (blumeops vault), synced by ExternalSecret: + +| Secret | 1Password Field | Purpose | +|--------|-----------------|---------| +| `auth_secret` | `auth_secret` | NextAuth session signing | +| `encryption_key` | `encryption_key` | AES-256-GCM for stored API keys | +| `rapidapi_key` | `rapidapi_key` | JSearch job search API | + +## Observability + +JobSync has no metrics endpoint or Grafana dashboard. Logs are collected by [[alloy|Alloy]] on ringtail and shipped to Loki on indri. + +**Querying logs in Grafana:** + +```logql +{namespace="jobsync", app="jobsync"} +``` + +To search for job search errors specifically: + +```logql +{namespace="jobsync", app="jobsync"} |~ "(?i)(rapid|search failed|error)" +``` + +The app runs a scheduled job search daily at 4 AM. Failures appear in logs during those executions. + +## Container + +Built with Nix on ringtail (x86_64). See [[build-jobsync-container]] for details. + +```fish +mise run container-release jobsync +``` + +Update `newTag` in `argocd/manifests/jobsync/kustomization.yaml` after building, then `argocd app sync jobsync`. + +## Related + +- [[ollama]] — AI backend for resume tailoring +- [[ringtail]] — Host node +- [[deploy-jobsync]] — Deployment how-to +- [[build-jobsync-container]] — Container build guide +- [[apps]] — ArgoCD application registry From ede9a5139408d3c9567d0d5833552aa44a6d647c Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sun, 8 Mar 2026 19:57:45 -0700 Subject: [PATCH 020/430] Fix robots.txt: block /explore/ and /tags/ (was /explorer/) The previous robots.txt had a typo blocking /explorer/ instead of /explore/, allowing Facebook's crawler to hit the spider trap. Also block /tags/ which has the same infinite relative-link issue. Co-Authored-By: Claude Opus 4.6 --- containers/quartz/default.conf | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/containers/quartz/default.conf b/containers/quartz/default.conf index 70b8fcc..396db72 100644 --- a/containers/quartz/default.conf +++ b/containers/quartz/default.conf @@ -14,12 +14,12 @@ server { add_header Cache-Control "public, immutable"; } - # Serve robots.txt inline to prevent crawlers from entering /explorer/, + # Serve robots.txt inline to prevent crawlers from entering /explore/ and /tags/, # which is an SPA feature that generates infinite relative-link trees # when crawled (the March 2026 spider-trap incident). location = /robots.txt { default_type text/plain; - return 200 "User-agent: *\nDisallow: /explorer/\n"; + return 200 "User-agent: *\nDisallow: /explore/\nDisallow: /tags/\n"; } # SPA fallback - serve index.html for client-side routing From 953640d2b78402fd648c4ea45c272c809a3e9a92 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sun, 8 Mar 2026 20:21:05 -0700 Subject: [PATCH 021/430] Deploy docs with fixed robots.txt (v1.28.2-ede9a51) Co-Authored-By: Claude Opus 4.6 --- argocd/manifests/docs/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/argocd/manifests/docs/kustomization.yaml b/argocd/manifests/docs/kustomization.yaml index ec97659..466c9c5 100644 --- a/argocd/manifests/docs/kustomization.yaml +++ b/argocd/manifests/docs/kustomization.yaml @@ -9,4 +9,4 @@ resources: - pdb.yaml images: - name: registry.ops.eblu.me/blumeops/quartz - newTag: v1.28.2-b64010b + newTag: v1.28.2-ede9a51 From 4f0476a851a9769d34bbc3fa0333107230e38b99 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 9 Mar 2026 11:59:43 -0700 Subject: [PATCH 022/430] Fix spider trap: disable SPA mode, remove index files, relax wiki-links (#290) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes the Facebook crawler spider trap that's been generating infinite recursive URLs like `/how-to/tutorials/tutorials/how-to/explanation/...` for several days. **Root cause:** Quartz SPA mode + nginx `try_files` fallback to `index.html` meant any fabricated URL returned the root HTML shell with HTTP 200. Crawlers followed relative links from those fake URLs, creating infinite recursion. **Fix:** - Disable Quartz SPA mode (`enableSPA: false`) — all pages are now fully static HTML - Replace nginx SPA fallback with `=404` + Quartz's static `404.html` - Remove `robots.txt` exclusions (no longer needed) **Docs cleanup (Obsidian.nvim compat no longer needed):** - Delete hand-curated category index files (`tutorials.md`, `reference.md`, `how-to.md`, `explanation.md`) — Quartz auto-generates folder pages - Delete `postgresql-storage.md` (redirect stub) and `migrate-forgejo-from-brew.md` (stale history) - Drop `docs-check-index` and `docs-check-filenames` prek hooks - Rewrite `docs-check-links` to allow path-based wiki-links (`[[path/to/file]]`) and only error on true ambiguity - Add `ai-docs` doc tree listing to replace index files for AI context - Add natural cross-links from reference cards to fix orphan docs ## Deployment and Testing - [ ] Merge and let the build pipeline run - [ ] Verify docs.eblu.me serves pages correctly with full page loads - [ ] Verify non-existent URLs return 404 - [ ] Monitor crawler traffic — should drop to near zero for fabricated URLs Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/290 --- containers/quartz/default.conf | 18 ++- .../fix-disable-spa-relax-docs.doc.md | 1 + .../fix-disable-spa-relax-docs.infra.md | 1 + docs/explanation/explanation.md | 25 ---- .../forgejo/migrate-forgejo-from-brew.md | 47 ------- docs/how-to/how-to.md | 100 -------------- docs/index.md | 8 +- docs/quartz.config.ts | 2 +- docs/reference/reference.md | 95 ------------- docs/reference/services/authentik.md | 5 +- docs/reference/services/forgejo.md | 4 + docs/reference/services/grafana.md | 1 + docs/reference/services/zot.md | 2 + docs/reference/storage/postgresql-storage.md | 11 -- docs/reference/tools/mise-tasks.md | 6 +- docs/tutorials/ai-assistance-guide.md | 8 +- docs/tutorials/exploring-the-docs.md | 32 ++--- docs/tutorials/replicating-blumeops.md | 2 +- docs/tutorials/tutorials.md | 49 ------- mise-tasks/ai-docs | 14 +- mise-tasks/docs-check-filenames | 85 ------------ mise-tasks/docs-check-index | 117 ---------------- mise-tasks/docs-check-links | 125 ++++++++---------- prek.toml | 16 --- 24 files changed, 109 insertions(+), 665 deletions(-) create mode 100644 docs/changelog.d/fix-disable-spa-relax-docs.doc.md create mode 100644 docs/changelog.d/fix-disable-spa-relax-docs.infra.md delete mode 100644 docs/explanation/explanation.md delete mode 100644 docs/how-to/forgejo/migrate-forgejo-from-brew.md delete mode 100644 docs/how-to/how-to.md delete mode 100644 docs/reference/reference.md delete mode 100644 docs/reference/storage/postgresql-storage.md delete mode 100644 docs/tutorials/tutorials.md delete mode 100755 mise-tasks/docs-check-filenames delete mode 100755 mise-tasks/docs-check-index diff --git a/containers/quartz/default.conf b/containers/quartz/default.conf index 396db72..64eec4e 100644 --- a/containers/quartz/default.conf +++ b/containers/quartz/default.conf @@ -14,18 +14,16 @@ server { add_header Cache-Control "public, immutable"; } - # Serve robots.txt inline to prevent crawlers from entering /explore/ and /tags/, - # which is an SPA feature that generates infinite relative-link trees - # when crawled (the March 2026 spider-trap incident). - location = /robots.txt { - default_type text/plain; - return 200 "User-agent: *\nDisallow: /explore/\nDisallow: /tags/\n"; + # Static file serving — no SPA fallback. + # Quartz generates complete HTML for every page, so all valid URLs + # map to real files. Non-existent paths get 404.html (generated by + # Quartz's NotFoundPage plugin), preventing the spider-trap issue + # where crawlers would get index.html for fabricated URLs. + location / { + try_files $uri $uri/ $uri.html =404; } - # SPA fallback - serve index.html for client-side routing - location / { - try_files $uri $uri/ $uri.html /index.html; - } + error_page 404 /404.html; # Health check endpoint location /healthz { diff --git a/docs/changelog.d/fix-disable-spa-relax-docs.doc.md b/docs/changelog.d/fix-disable-spa-relax-docs.doc.md new file mode 100644 index 0000000..f393f72 --- /dev/null +++ b/docs/changelog.d/fix-disable-spa-relax-docs.doc.md @@ -0,0 +1 @@ +Relax wiki-link constraints: allow path-based links for disambiguation, drop global filename uniqueness requirement, remove docs-check-filenames and docs-check-index hooks. diff --git a/docs/changelog.d/fix-disable-spa-relax-docs.infra.md b/docs/changelog.d/fix-disable-spa-relax-docs.infra.md new file mode 100644 index 0000000..bac7b7e --- /dev/null +++ b/docs/changelog.d/fix-disable-spa-relax-docs.infra.md @@ -0,0 +1 @@ +Disable Quartz SPA mode and remove robots.txt crawler exclusions to fix the Facebook crawler spider trap. Remove hand-curated category index files in favor of Quartz auto-generated folder pages. diff --git a/docs/explanation/explanation.md b/docs/explanation/explanation.md deleted file mode 100644 index 1f46eaa..0000000 --- a/docs/explanation/explanation.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -title: Explanation -modified: 2026-02-10 -last-reviewed: 2026-02-10 -tags: - - explanation ---- - -# Explanation - -Understanding-oriented content explaining the "why" behind BlumeOps design decisions. - -## Philosophy - -| Article | Description | -|---------|-------------| -| [[why-gitops]] | Why infrastructure-as-code and GitOps for a homelab | - -## Design - -| Article | Description | -|---------|-------------| -| [[architecture]] | How all the pieces fit together | -| [[federated-login]] | How SSO works across BlumeOps (Authentik) | -| [[security-model]] | Network security, secrets, and access control | diff --git a/docs/how-to/forgejo/migrate-forgejo-from-brew.md b/docs/how-to/forgejo/migrate-forgejo-from-brew.md deleted file mode 100644 index 29d540b..0000000 --- a/docs/how-to/forgejo/migrate-forgejo-from-brew.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -title: Migrate Forgejo from Brew to Source Build -status: active -modified: 2026-03-04 -last-reviewed: 2026-03-05 -tags: - - how-to - - forgejo ---- - -# Migrate Forgejo from Brew to Source Build - -Transition Forgejo on indri from Homebrew to a source-built binary with LaunchAgent, matching the pattern used by [[zot]], [[caddy]], and [[alloy]]. - -## Motivation - -Forgejo was force-upgraded from v13 to v14 by `brew upgrade`, breaking version control. A source build pins versions and aligns with the established native service pattern. - -## Architecture Decisions - -| Decision | Choice | Rationale | -|----------|--------|-----------| -| **Source remote** | Codeberg upstream | Avoids circular dependency (Forgejo hosting its own source) | -| **Secondary remote** | `forge.eblu.me/mirrors/forgejo` | Convenience and backup | -| **Version tracking** | `indri-deployment` branch on tag | Rebase to upgrade; explicit version pinning | -| **Build deps** | Go 1.24+, Node 20+ via mise | Consistent with other mise-managed tooling | -| **Process manager** | LaunchAgent plist | Matches zot, caddy, alloy | -| **Data location** | `~/forgejo` | Migrated from `/opt/homebrew/var/forgejo` | -| **Run user** | `erichblume` | LaunchAgent session user (SSH git user stays `forgejo`) | - -## Key Steps - -1. Clone from Codeberg, add forge mirror remote -2. Check out target tag, create `indri-deployment` branch -3. Build with `TAGS="bindata timedzdata sqlite sqlite_unlock_notify" mise x -- make build` -4. Stop brew service, copy data to `~/forgejo`, fix ownership -5. Run Ansible (`--tags forgejo`) to deploy updated role with LaunchAgent -6. Verify (API version, SSH clone, push, Actions runners, services-check) -7. `brew uninstall forgejo` - -## Reference Patterns - -- `ansible/roles/zot/` — primary pattern for source-built binary roles (tasks, defaults, handlers, plist template) - -## Related - -- [[forgejo]] — Service reference diff --git a/docs/how-to/how-to.md b/docs/how-to/how-to.md deleted file mode 100644 index 0ca60a6..0000000 --- a/docs/how-to/how-to.md +++ /dev/null @@ -1,100 +0,0 @@ ---- -title: How-To -modified: 2026-03-06 -last-reviewed: 2026-03-06 -tags: - - how-to ---- - -# How-To Guides - -## Deployment - -- [[deploy-k8s-service]] -- [[add-ansible-role]] -- [[create-release-artifact-workflow]] -- [[build-container-image]] - -## Configuration - -- [[update-tailscale-acls]] -- [[gandi-operations]] -- [[use-pypi-proxy]] -- [[expose-service-publicly]] -- [[manage-forgejo-mirrors]] -- [[update-documentation]] -- [[update-tooling-dependencies]] - -## Knowledge Base - -- [[review-documentation]] -- [[review-services]] -- [[agent-change-process]] - -## Operations - -- [[connect-to-postgres]] -- [[restart-indri]] -- [[manage-flyio-proxy]] -- [[restore-1password-backup]] -- [[troubleshooting]] - -## Forgejo - -- [[migrate-forgejo-from-brew]] - -## Ringtail - -- [[manage-lockfile]] - -## Zot - -- [[harden-zot-registry]] -- [[register-zot-oidc-client]] -- [[wire-ci-registry-auth]] -- [[enforce-tag-immutability]] -- [[adopt-commit-based-container-tags]] -- [[add-container-version-sync-check]] -- [[install-dagger-on-nix-runner]] -- [[pin-container-versions]] -- [[add-dagger-nix-build]] -- [[fix-ntfy-nix-version]] - -## Authentik - -- [[deploy-authentik]] -- [[build-authentik-container]] -- [[provision-authentik-database]] -- [[create-authentik-secrets]] -- [[migrate-grafana-to-authentik]] - -## Authentik Source Build - -- [[build-authentik-from-source]] -- [[mirror-authentik-build-deps]] -- [[authentik-api-client-generation]] -- [[authentik-python-backend-derivation]] -- [[authentik-web-ui-derivation]] -- [[authentik-go-server-derivation]] - -## Grafana - -- [[upgrade-grafana]] -- [[kustomize-grafana-deployment]] -- [[build-grafana-container]] -- [[build-grafana-sidecar]] - -## Dagger - -- [[upgrade-dagger]] - -## JobSync - -- [[deploy-jobsync]] -- [[build-jobsync-container]] - -## Forgejo Runner - -- [[upgrade-k8s-runner]] -- [[validate-workflows-against-v12]] -- [[review-runner-config-v12]] diff --git a/docs/index.md b/docs/index.md index 306e73c..6da90a4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -39,8 +39,8 @@ The goal of BlumeOps is threefold: ## Sections -- [[tutorials|Tutorials]] - Learning-oriented guides for getting started -- [[reference|Reference]] - Technical specifications and service details -- [[how-to|How-to]] - Task-oriented instructions for common operations -- [[explanation|Explanation]] - Understanding the "why" behind BlumeOps +- [Tutorials](/tutorials/) - Learning-oriented guides for getting started +- [Reference](/reference/) - Technical specifications and service details +- [How-to](/how-to/) - Task-oriented instructions for common operations +- [Explanation](/explanation/) - Understanding the "why" behind BlumeOps - [[CHANGELOG]] - Release history and changes diff --git a/docs/quartz.config.ts b/docs/quartz.config.ts index bfdaa22..51743a5 100644 --- a/docs/quartz.config.ts +++ b/docs/quartz.config.ts @@ -9,7 +9,7 @@ const config: QuartzConfig = { configuration: { pageTitle: "BlumeOps Docs", pageTitleSuffix: "", - enableSPA: true, + enableSPA: false, enablePopovers: true, analytics: null, locale: "en-US", diff --git a/docs/reference/reference.md b/docs/reference/reference.md deleted file mode 100644 index 40cec80..0000000 --- a/docs/reference/reference.md +++ /dev/null @@ -1,95 +0,0 @@ ---- -title: Reference -modified: 2026-03-04 -tags: - - reference ---- - -# Reference - -Technical specifications, inventories, and configuration details for BlumeOps infrastructure. - -## Services - -Individual service reference cards with URLs and configuration details. - -| Service | Description | Location | -|---------|-------------|----------| -| [[alloy|Alloy]] | Observability collector (metrics & logs) | indri + k8s | -| [[argocd]] | GitOps continuous delivery | k8s | -| [[borgmatic]] | Backup system | indri | -| [[caddy]] | Reverse proxy & TLS termination | indri | -| [[1password]] | Secrets management | cloud + k8s | -| [[forgejo]] | Git forge & CI/CD | indri | -| [[frigate]] | Network video recorder | k8s (ringtail) | -| [[grafana]] | Dashboards & visualization | k8s | -| [[immich]] | Photo management | k8s | -| [[jellyfin]] | Media server | indri | -| [[jobsync]] | Job application tracker | k8s (ringtail) | -| [[kiwix]] | Offline Wikipedia & ZIM archives | k8s | -| [[loki]] | Log aggregation | k8s | -| [[tempo]] | Distributed tracing | k8s | -| [[miniflux]] | RSS feed reader | k8s | -| [[navidrome]] | Music streaming | k8s | -| [[ntfy]] | Push notifications | k8s (ringtail) | -| [[postgresql]] | Database cluster | k8s | -| [[prometheus]] | Metrics collection | k8s | -| [[teslamate]] | Tesla data logger | k8s | -| [[transmission]] | BitTorrent daemon | k8s | -| [[zot]] | Container registry | indri | -| [[devpi]] | PyPI caching proxy | k8s | -| [[cv]] | Resume / CV site | k8s | -| [[authentik]] | OIDC identity provider | k8s (ringtail) | -| [[docs]] | Documentation site (Quartz) | k8s | -| [[flyio-proxy]] | Public reverse proxy (Fly.io + Tailscale) | Fly.io | -| [[ollama]] | LLM inference server | k8s (ringtail) | -| [[automounter]] | SMB share automounter | indri | - -## Infrastructure - -Host inventory and network configuration. - -- [[hosts|Hosts]] - Device inventory -- [[indri]] - Primary server -- [[ringtail]] - Service host & gaming PC -- [[gilbert]] - Development workstation -- [[tailscale]] - ACLs, groups, tags -- [[gandi]] - DNS hosting for `eblu.me` -- [[unifi]] - Home WiFi router (UniFi Express 7) -- [[routing|Routing]] - DNS domains, port mappings -- [[power]] - Battery-backed power chain - -## Tools - -Build, deployment, and IaC tool reference. - -- [[mise-tasks]] - Operational task runner (all `mise run` tasks) -- [[dagger]] - CI/CD build engine (Python SDK) -- [[argocd-cli]] - ArgoCD CLI workflows -- [[ansible]] - Configuration management for indri -- [[pulumi]] - Infrastructure-as-Code (DNS, Tailscale ACLs) - -## Kubernetes - -Cluster configuration and application registry. - -- [[cluster|Cluster]] - Minikube specs, storage, networking -- [[apps|Apps]] - ArgoCD application registry -- [[tailscale-operator]] - Tailscale ingress for k8s services -- [[external-secrets]] - Secrets management - -## Storage - -Network storage and backup configuration. - -- [[sifaka|Sifaka]] - Synology NAS configuration -- [[postgresql-storage]] - Database cluster -- [[backups|Backups]] - Backup policy and schedule - -## Operations - -Operational concerns and their components. - -- [[observability]] - Metrics, logs, dashboards -- [[backup]] - Data protection -- [[disaster-recovery]] - Recovery procedures (TBD) diff --git a/docs/reference/services/authentik.md b/docs/reference/services/authentik.md index 2be467f..67e223b 100644 --- a/docs/reference/services/authentik.md +++ b/docs/reference/services/authentik.md @@ -60,7 +60,7 @@ Future clients: [[argocd]], [[miniflux]], [[zot]] ## Secrets -Injected via [[external-secrets]] from the "Authentik (blumeops)" 1Password item. +Injected via [[external-secrets]] from the "Authentik (blumeops)" 1Password item (see [[create-authentik-secrets]] for setup). | 1Password Field | Purpose | |-----------------|---------| @@ -79,4 +79,7 @@ Nix-built via `dockerTools.buildLayeredImage`. The entrypoint wrapper symlinks b - [[federated-login]] - How authentication works across BlumeOps - [[grafana]] - First OIDC client - [[deploy-authentik]] - Deployment how-to +- [[migrate-grafana-to-authentik]] - Grafana SSO migration from Dex +- [[build-authentik-from-source]] - Nix-based container build +- [[mirror-authentik-build-deps]] - Supply chain mirrors for the build - [[external-secrets]] - Secrets injection from 1Password diff --git a/docs/reference/services/forgejo.md b/docs/reference/services/forgejo.md index 1f56029..e96c247 100644 --- a/docs/reference/services/forgejo.md +++ b/docs/reference/services/forgejo.md @@ -120,6 +120,10 @@ The UI shows `forge.eblu.me` for HTTPS clone URLs and `forge.ops.eblu.me` for SS `mise run fly-shutoff` stops all public traffic immediately. forge.ops.eblu.me continues to work from the tailnet. See [[expose-service-publicly#Break-glass shutoff]]. +## Mirrors + +Forgejo hosts pull mirrors of external repositories (GitHub, etc.) for supply chain control. Mirrors live in the `mirrors/` org and sync on a configurable interval. See [[manage-forgejo-mirrors]] for operations. + ## Related - [[argocd]] - Uses Forgejo as git source diff --git a/docs/reference/services/grafana.md b/docs/reference/services/grafana.md index 0c62515..b0459d5 100644 --- a/docs/reference/services/grafana.md +++ b/docs/reference/services/grafana.md @@ -63,6 +63,7 @@ Optional annotation: `grafana_folder: "FolderName"` - [[build-grafana-sidecar]] - Home-built sidecar container - [[kustomize-grafana-deployment]] - Kustomize manifest structure - [[authentik]] - OIDC identity provider for SSO +- [[migrate-grafana-to-authentik]] - How SSO was migrated from Dex to Authentik - [[prometheus]] - Metrics datasource - [[loki]] - Logs datasource - [[tempo]] - Traces datasource diff --git a/docs/reference/services/zot.md b/docs/reference/services/zot.md index c481316..c113695 100644 --- a/docs/reference/services/zot.md +++ b/docs/reference/services/zot.md @@ -65,3 +65,5 @@ The `zot-ci` API key expires every **90 days**. To rotate: - [[forgejo]] - Container build CI - [[cluster|Cluster]] - Registry consumer - [[authentik]] - OIDC identity provider +- [[harden-zot-registry]] - Security hardening guide +- [[install-dagger-on-nix-runner]] - Why Dagger can't run on the Nix builder diff --git a/docs/reference/storage/postgresql-storage.md b/docs/reference/storage/postgresql-storage.md deleted file mode 100644 index b9f34a4..0000000 --- a/docs/reference/storage/postgresql-storage.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -title: PostgreSQL Storage -modified: 2026-02-07 -tags: - - storage - - database ---- - -# PostgreSQL Storage - -See [[postgresql]] in Services. diff --git a/docs/reference/tools/mise-tasks.md b/docs/reference/tools/mise-tasks.md index 9d83b2d..baacf10 100644 --- a/docs/reference/tools/mise-tasks.md +++ b/docs/reference/tools/mise-tasks.md @@ -17,11 +17,9 @@ Run `mise tasks --sort name` for the live list with descriptions. | Task | Description | |------|-------------| -| `ai-docs` | Prime AI context with key documentation | -| `docs-check-filenames` | Detect duplicate filenames in documentation | +| `ai-docs` | Prime AI context with key documentation and doc tree | | `docs-check-frontmatter` | Check required frontmatter fields | -| `docs-check-index` | Check every doc is referenced in its category index | -| `docs-check-links` | Validate wiki-links point to existing filenames | +| `docs-check-links` | Validate wiki-links resolve correctly (supports path-based links) | | `docs-mikado` | View active Mikado dependency chains (C2 changes) | | `docs-review` | Review the most stale doc by `last-reviewed` date | | `docs-review-stale` | Report docs by last-modified date | diff --git a/docs/tutorials/ai-assistance-guide.md b/docs/tutorials/ai-assistance-guide.md index 07192a1..8b54290 100644 --- a/docs/tutorials/ai-assistance-guide.md +++ b/docs/tutorials/ai-assistance-guide.md @@ -91,7 +91,7 @@ BlumeOps operations are driven by mise tasks. Run `mise tasks` to list all avail | Task | When to Use | |------|-------------| -| `ai-docs` | At session start - review infrastructure documentation | +| `ai-docs` | At session start - review infrastructure documentation (see [[mise-tasks]]) | | `docs-mikado` | View active Mikado dependency chains for C2 changes | | `docs-mikado --resume` | Resume a C2 chain: detect branch, show state and next steps | | `provision-indri` | Deploy changes to [[indri]]-hosted services via Ansible | @@ -104,9 +104,7 @@ BlumeOps operations are driven by mise tasks. Run `mise tasks` to list all avail | `dns-up` | Apply DNS changes via Pulumi | | `tailnet-preview` | Preview Tailscale ACL changes | | `tailnet-up` | Apply Tailscale ACL changes via Pulumi | -| `docs-check-links` | Validate wiki-links in documentation (includes orphan detection) | -| `docs-check-index` | Check every doc is referenced in its category index | -| `docs-check-filenames` | Check for duplicate doc filenames | +| `docs-check-links` | Validate wiki-links resolve correctly (supports path-based links, orphan detection) | | `docs-review-stale` | Report docs by last-modified date, highlight stale ones | | `docs-review-tags` | Print frontmatter tag inventory across all docs | | `docs-review` | Review the most stale doc by last-reviewed date | @@ -120,7 +118,7 @@ For ArgoCD operations, use the `argocd` CLI directly: For AI agents building context: -- [[reference|Reference]] - Entry point for technical details +- [Reference](/reference/) - Entry point for technical details - [[hosts|Host Inventory]] - What hardware exists - [[apps|ArgoCD Apps]] - What's deployed in Kubernetes - [[routing|Routing]] - How services are exposed diff --git a/docs/tutorials/exploring-the-docs.md b/docs/tutorials/exploring-the-docs.md index bd0ea70..83aec43 100644 --- a/docs/tutorials/exploring-the-docs.md +++ b/docs/tutorials/exploring-the-docs.md @@ -18,18 +18,18 @@ The docs follow the [Diataxis](https://diataxis.fr/) framework: | Section | Purpose | When to Use | |---------|---------|-------------| -| **[[tutorials|Tutorials]]** | Learning-oriented | "I'm new and want to understand" | -| **[[reference|Reference]]** | Information-oriented | "I need specific technical details" | -| **[[how-to|How-to]]** | Task-oriented | "I need to do X" | -| **[[explanation|Explanation]]** | Understanding-oriented | "I want to understand why" | +| **[Tutorials](/tutorials/)** | Learning-oriented | "I'm new and want to understand" | +| **[Reference](/reference/)** | Information-oriented | "I need specific technical details" | +| **[How-to](/how-to/)** | Task-oriented | "I need to do X" | +| **[Explanation](/explanation/)** | Understanding-oriented | "I want to understand why" | ## Quick Paths by Audience ### For Erich (Owner) You probably want quick access to operational details: -- [[how-to]] guides for common operations (deploy, troubleshoot, update ACLs) -- [[reference]] has service URLs, commands, and config locations +- [How-to](/how-to/) guides for common operations (deploy, troubleshoot, update ACLs) +- [Reference](/reference/) has service URLs, commands, and config locations - [[ai-assistance-guide]] explains how to work effectively with Claude - Run `mise run ai-docs` to prime AI context with key documentation @@ -37,40 +37,41 @@ You probably want quick access to operational details: Context for effective assistance: - Read [[ai-assistance-guide]] for operational conventions -- [[reference]] has the technical specifics you'll need +- [Reference](/reference/) has the technical specifics you'll need - The repo's `CLAUDE.md` has critical rules (especially the kubectl context requirement) ### For External Readers Understanding what this is: -- [[explanation]] covers the "why" behind design decisions -- [[reference]] shows what's actually running +- [Explanation](/explanation/) covers the "why" behind design decisions +- [Reference](/reference/) shows what's actually running - Browse service pages to see specific implementations ### For Contributors Getting started with changes: - [[contributing]] walks through the workflow -- [[how-to]] guides for specific tasks (deploy services, add roles) -- [[reference]] tells you where things live +- [How-to](/how-to/) guides for specific tasks (deploy services, add roles) +- [Reference](/reference/) tells you where things live ### For Replicators Replicators are people who want to build their own similar homelab GitOps setup, using BlumeOps as inspiration. - [[replicating-blumeops]] provides the overview, with linked tutorials that go deep on individual components -- [[explanation]] covers architecture and design rationale +- [Explanation](/explanation/) covers architecture and design rationale - Reference pages show specific configuration choices ## Using Wiki Links Documentation uses `[[wiki-links]]` for cross-references: -- `[[service-name]]` links to a reference page +- `[[service-name]]` links by filename stem (must be unambiguous) +- `[[path/to/file]]` links by path from docs root (for disambiguation) - `[[page|Display Text]]` customizes the link text When reading on the web (docs.eblu.me), these render as clickable links. The backlinks panel shows what references each page. -Prek hooks automatically validate that all wiki-links point to existing files and that link targets are unambiguous. +Prek hooks validate that all wiki-links resolve to existing files and flag ambiguous bare-name links. ## AI Context Priming @@ -80,10 +81,9 @@ The `ai-docs` mise task concatenates key documentation files for AI context: mise run ai-docs ``` -This outputs the AI assistance guide, reference index, how-to index, architecture overview, and tutorials index in plain text with file headers - providing Claude with essential context for BlumeOps operations. +This outputs key documentation files and a full tree listing of all docs, providing Claude with essential context for BlumeOps operations. ## Related -- [[tutorials]] - Parent index of all tutorials - [[update-documentation]] - How to publish doc changes - [[review-documentation]] - Periodic doc review process diff --git a/docs/tutorials/replicating-blumeops.md b/docs/tutorials/replicating-blumeops.md index f70956b..f2ed8ca 100644 --- a/docs/tutorials/replicating-blumeops.md +++ b/docs/tutorials/replicating-blumeops.md @@ -136,5 +136,5 @@ Begin with [[tailscale-setup]] - networking is the foundation everything else bu ## Related -- [[reference]] - See BlumeOps' specific configurations +- [Reference](/reference/) - See BlumeOps' specific configurations - [[contributing]] - Help improve BlumeOps instead diff --git a/docs/tutorials/tutorials.md b/docs/tutorials/tutorials.md deleted file mode 100644 index c3d8f19..0000000 --- a/docs/tutorials/tutorials.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -title: Tutorials -modified: 2026-02-07 -tags: - - tutorials ---- - -# Tutorials - -Learning-oriented guides for understanding and working with BlumeOps. - -## Audience Guide - -Each tutorial indicates which audiences it serves: - -| Icon | Audience | Description | -|------|----------|-------------| -| **Owner** | Erich | Quick recall and operational refreshers | -| **AI** | Claude/AI agents | Context for AI-assisted operations | -| **Reader** | External readers | Understanding what BlumeOps is | -| **Contributor** | Operators/contributors | Helping with BlumeOps development | -| **Replicator** | Replicators | Building your own similar setup | - -## Getting Started - -| Tutorial | Audiences | Description | -|----------|-----------|-------------| -| [[exploring-the-docs]] | All | How to navigate and use this documentation | -| [[ai-assistance-guide]] | AI, Owner | Context for effective AI-assisted operations | - -## Contributing - -| Tutorial | Audiences | Description | -|----------|-----------|-------------| -| [[contributing]] | Contributor | Your first contribution to BlumeOps | -| [[adding-a-service]] | Contributor, Replicator | Deploy a new service via ArgoCD | - -## Replication - -For those building their own homelab GitOps setup. - -| Tutorial | Audiences | Description | -|----------|-----------|-------------| -| [[replicating-blumeops]] | Replicator | Overview: building a similar environment | -| [[tailscale-setup|Tailscale Setup]] | Replicator | Setting up Tailscale networking | -| [[core-services|Core Services]] | Replicator | Forgejo and container registry | -| [[kubernetes-bootstrap|Kubernetes Bootstrap]] | Replicator | Bootstrapping a Kubernetes cluster | -| [[argocd-config|ArgoCD Config]] | Replicator | Configuring GitOps with ArgoCD | -| [[observability-stack|Observability Stack]] | Replicator | Metrics, logs, and dashboards | diff --git a/mise-tasks/ai-docs b/mise-tasks/ai-docs index c7e1d06..2509705 100755 --- a/mise-tasks/ai-docs +++ b/mise-tasks/ai-docs @@ -1,5 +1,5 @@ #!/usr/bin/env bash -#MISE description="Prime AI context with key BlumeOps documentation (formerly zk-docs)" +#MISE description="Prime AI context with key BlumeOps documentation" set -euo pipefail @@ -10,15 +10,17 @@ FILES=( "$DOCS_DIR/tutorials/ai-assistance-guide.md" "$DOCS_DIR/how-to/agent-change-process.md" "$DOCS_DIR/index.md" - "$DOCS_DIR/reference/reference.md" - "$DOCS_DIR/how-to/how-to.md" "$DOCS_DIR/how-to/operations/troubleshooting.md" - "$DOCS_DIR/explanation/explanation.md" "$DOCS_DIR/explanation/architecture.md" - "$DOCS_DIR/tutorials/tutorials.md" "$DOCS_DIR/reference/tools/mise-tasks.md" ) # Concatenate files with headers showing paths -# Defaults are tuned for AI consumption (plain text, file headers only) bat --style=header --color=never --decorations=always "$@" "${FILES[@]}" + +# Documentation tree — replaces the old hand-curated index files +echo "" +echo "=== Documentation Structure ===" +echo "All docs under $DOCS_DIR (excluding changelog.d/):" +echo "" +find "$DOCS_DIR" -name '*.md' -not -path '*/changelog.d/*' | sort | sed "s|$DOCS_DIR/||" diff --git a/mise-tasks/docs-check-filenames b/mise-tasks/docs-check-filenames deleted file mode 100755 index 9cb5db8..0000000 --- a/mise-tasks/docs-check-filenames +++ /dev/null @@ -1,85 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.12" -# dependencies = ["rich>=13.0.0"] -# /// -#MISE description="Detect duplicate filenames in documentation" -"""Detect duplicate filenames in documentation. - -This script scans all markdown files in the docs/ directory (excluding -changelog.d/ and zk/) and reports any duplicate filenames that could -cause wiki-link resolution issues. - -With Quartz, wiki-links like [[filename]] resolve by filename, -so filenames must be unique across the documentation. - -Usage: mise run docs-check-filenames -""" - -import sys -from collections import defaultdict -from pathlib import Path - -from rich.console import Console -from rich.table import Table - -DOCS_DIR = Path(__file__).parent.parent / "docs" - - -def main() -> int: - console = Console() - - # Collect all filenames and their paths - # Key: filename (without .md), Value: list of file paths - filenames: dict[str, list[str]] = defaultdict(list) - - # Scan all markdown files (excluding zk/ and changelog.d/) - for md_file in sorted(DOCS_DIR.rglob("*.md")): - if "changelog.d" in md_file.parts or "zk" in md_file.parts: - continue - - rel_path = str(md_file.relative_to(DOCS_DIR)) - filename = md_file.stem # filename without .md - filenames[filename].append(rel_path) - - # Find duplicates - duplicates = {name: paths for name, paths in filenames.items() if len(paths) > 1} - - # Print results - console.print("[bold]Doc Filename Inventory[/bold]") - console.print() - console.print("With Quartz, wiki-links like [[filename]] resolve by filename,") - console.print("so filenames must be unique across the documentation.") - console.print() - - # Duplicates table (if any) - if duplicates: - console.print("[bold red]Duplicate Filenames Found[/bold red]") - dup_table = Table(show_header=True, header_style="bold") - dup_table.add_column("Filename") - dup_table.add_column("Paths") - - for name in sorted(duplicates.keys()): - paths = duplicates[name] - dup_table.add_row(name, "\n".join(paths)) - - console.print(dup_table) - console.print() - - # Summary - console.print(f"Total files: {sum(len(p) for p in filenames.values())}") - console.print(f"Unique filenames: {len(filenames)}") - console.print(f"Duplicate filenames: {len(duplicates)}") - - if duplicates: - console.print() - console.print("[bold red]Action required:[/bold red] Rename files to ensure unique wiki-link resolution.") - return 1 - - console.print() - console.print("[bold green]All filenames are unique![/bold green]") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/mise-tasks/docs-check-index b/mise-tasks/docs-check-index deleted file mode 100755 index 47695d6..0000000 --- a/mise-tasks/docs-check-index +++ /dev/null @@ -1,117 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.12" -# dependencies = ["rich>=13.0.0"] -# /// -#MISE description="Check that every doc is referenced in its category index" -"""Check that every doc in a Diataxis category is referenced in its index. - -Each Diataxis category (tutorials, reference, how-to, explanation) has an -index file that should wiki-link to every doc in that category directory. - -A doc is considered referenced if its filename stem appears as a wiki-link -target (e.g., alloy.md is matched by [[alloy]]) in the category index. - -Index files are excluded from the self-check. - -Usage: mise run docs-check-index -""" - -import re -import sys -from pathlib import Path - -from rich.console import Console -from rich.markup import escape -from rich.table import Table - -DOCS_DIR = Path(__file__).parent.parent / "docs" - -# Category directories and their index files -CATEGORIES = { - "tutorials": "tutorials/tutorials.md", - "reference": "reference/reference.md", - "how-to": "how-to/how-to.md", - "explanation": "explanation/explanation.md", -} - -# Regex to match wiki-links: [[Target]] or [[Target|Display]] -WIKILINK_PATTERN = re.compile(r"\[\[([^\]|]+)(\|[^\]]+)?\]\]") - -# Regex to match inline code (backticks) -INLINE_CODE_PATTERN = re.compile(r"`[^`]+`") - - -def extract_link_targets(file_path: Path) -> set[str]: - """Extract all wiki-link targets from a file (ignoring inline code).""" - content = file_path.read_text() - targets: set[str] = set() - - for line in content.splitlines(): - line_without_code = INLINE_CODE_PATTERN.sub("", line) - for match in WIKILINK_PATTERN.finditer(line_without_code): - targets.add(match.group(1).strip()) - - return targets - - -def main() -> int: - console = Console() - console.print("[bold]Category Index Validation[/bold]") - console.print() - - has_errors = False - missing: list[tuple[str, str, str]] = [] # (category, stem, file) - - for category, index_rel in CATEGORIES.items(): - index_path = DOCS_DIR / index_rel - if not index_path.exists(): - console.print(f"[yellow]Warning: index file not found: {index_rel}[/yellow]") - continue - - category_dir = DOCS_DIR / category - if not category_dir.is_dir(): - continue - - # Get all wiki-link targets from the index - index_targets = extract_link_targets(index_path) - index_stem = index_path.stem - - # Check each doc in the category directory - for md_file in sorted(category_dir.rglob("*.md")): - if "changelog.d" in md_file.parts: - continue - stem = md_file.stem - # Skip the index file itself - if stem == index_stem: - continue - if stem not in index_targets: - rel_path = str(md_file.relative_to(DOCS_DIR)) - missing.append((category, stem, rel_path)) - - if missing: - has_errors = True - console.print("[bold red]Docs Missing From Category Index[/bold red]") - console.print("These docs are not wiki-linked from their category index file.") - console.print() - table = Table(show_header=True, header_style="bold") - table.add_column("Category") - table.add_column("File") - table.add_column("Add To") - - for category, stem, rel_path in missing: - table.add_row(category, rel_path, CATEGORIES[category]) - - console.print(table) - console.print() - - if has_errors: - return 1 - - console.print(f"Checked {len(CATEGORIES)} category indexes.") - console.print("[bold green]All docs are referenced in their category index![/bold green]") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/mise-tasks/docs-check-links b/mise-tasks/docs-check-links index 46deab9..20d48fb 100755 --- a/mise-tasks/docs-check-links +++ b/mise-tasks/docs-check-links @@ -3,19 +3,21 @@ # requires-python = ">=3.12" # dependencies = ["rich>=13.0.0"] # /// -#MISE description="Validate all wiki-links point to existing doc filenames" +#MISE description="Validate all wiki-links point to existing doc files" """Validate that all wiki-links in documentation point to existing files. This script scans all markdown files in the docs/ directory (excluding -changelog.d/), extracts wiki-links, and verifies each link target -exists as a unique filename in the documentation. +changelog.d/), extracts wiki-links, and verifies each link target resolves +to an existing file. Wiki-link formats supported: -- [[filename]] - links to filename.md (must be unique across all docs) -- [[target|Display Text]] - filename with display text +- [[filename]] - resolves by stem (errors if ambiguous) +- [[path/to/file]] - resolves by relative path from docs root +- [[target|Display Text]] - either form with display text +- [[target#Heading]] - with anchor fragment (file part validated) -Path-based links (containing '/') are NOT supported to ensure all -filenames are unique and links work correctly in obsidian.nvim. +Resolution mirrors Quartz's "shortest" markdownLinkResolution: +bare names resolve when unique; use paths to disambiguate duplicates. Usage: mise run docs-check-links """ @@ -31,7 +33,6 @@ from rich.table import Table DOCS_DIR = Path(__file__).parent.parent / "docs" # Regex to match wiki-links: [[Target]] or [[Target|Display]] -# Captures: group(1) = target (may have spaces), group(2) = full "|Display" part if present WIKILINK_PATTERN = re.compile(r"\[\[([^\]|]+)(\|[^\]]+)?\]\]") # Regex to match inline code (backticks) @@ -68,51 +69,42 @@ def extract_wikilinks(file_path: Path) -> list[tuple[str, int, bool]]: def main() -> int: console = Console() - # Collect all valid targets (both filenames and paths) - valid_targets: set[str] = set() - # Track which filenames are ambiguous (appear multiple times) - filename_counts: dict[str, list[str]] = {} + # Build lookup structures: + # - path_targets: set of relative paths without extension (e.g., "reference/services/alloy") + # - stem_to_paths: map from filename stem to list of paths (for ambiguity detection) + path_targets: set[str] = set() + stem_to_paths: dict[str, list[str]] = {} - # Scan all markdown files (excluding changelog.d/) for md_file in DOCS_DIR.rglob("*.md"): if "changelog.d" in md_file.parts: continue - # Track filename occurrences - filename = md_file.stem + stem = md_file.stem rel_path_str = str(md_file.relative_to(DOCS_DIR).with_suffix("")) - if filename not in filename_counts: - filename_counts[filename] = [] - filename_counts[filename].append(rel_path_str) - # Add relative path without extension (e.g., "reference/services/alloy") - valid_targets.add(rel_path_str) + path_targets.add(rel_path_str) + if stem not in stem_to_paths: + stem_to_paths[stem] = [] + stem_to_paths[stem].append(rel_path_str) - # Only add filenames that are unique (not ambiguous) - ambiguous_filenames: set[str] = set() - for filename, paths in filename_counts.items(): - if len(paths) == 1: - valid_targets.add(filename) - else: - ambiguous_filenames.add(filename) - - # Special case: files at repo root that are copied into docs during build - # These are valid link targets even though they don't exist in docs/ + # Special case: files at repo root copied into docs during build REPO_ROOT = DOCS_DIR.parent BUILD_TIME_DOCS = ["CHANGELOG.md"] for filename in BUILD_TIME_DOCS: if (REPO_ROOT / filename).exists(): - valid_targets.add(Path(filename).stem) + stem = Path(filename).stem + if stem not in stem_to_paths: + stem_to_paths[stem] = [] + stem_to_paths[stem].append(stem) + path_targets.add(stem) - # Collect all broken, ambiguous, path-based, and spaced links + # Collect errors broken_links: list[tuple[str, int, str]] = [] ambiguous_links: list[tuple[str, int, str, list[str]]] = [] - path_links: list[tuple[str, int, str]] = [] spaced_links: list[tuple[str, int, str]] = [] - # Track which doc stems are linked-to from other docs (for orphan detection) - all_doc_stems: set[str] = set(filename_counts.keys()) + # Track linked stems for orphan detection + all_doc_stems: set[str] = set(stem_to_paths.keys()) linked_stems: set[str] = set() - # Scan all markdown files for wiki-links (excluding changelog.d/) for md_file in sorted(DOCS_DIR.rglob("*.md")): if "changelog.d" in md_file.parts: continue @@ -123,35 +115,41 @@ def main() -> int: for target, line_num, has_spaces in links: if has_spaces: - # Links with spaces in target or around pipe are not allowed spaced_links.append((rel_path, line_num, target)) continue - # Handle anchor links: [[#Heading]] or [[file#Heading]] - # Strip the #fragment for validation; pure anchors (#Heading) skip file check + # Strip anchor fragment for file validation file_target = target if "#" in target: file_target = target.split("#", 1)[0] if not file_target: - # Pure in-page anchor like [[#Break-glass shutoff]] — always valid + # Pure in-page anchor like [[#Heading]] — always valid continue if "/" in file_target: - # Path-based links are not allowed - use simple filenames only - path_links.append((rel_path, line_num, target)) - elif file_target in ambiguous_filenames: - # Link uses an ambiguous filename - needs to be renamed - ambiguous_links.append((rel_path, line_num, target, filename_counts[file_target])) - elif file_target not in valid_targets: - broken_links.append((rel_path, line_num, target)) - elif file_target != source_stem: - # Valid link to a different doc — record it for orphan detection - linked_stems.add(file_target) + # Path-based link — resolve against path_targets + if file_target not in path_targets: + broken_links.append((rel_path, line_num, target)) + else: + # Extract the stem for orphan tracking + linked_stem = file_target.rsplit("/", 1)[-1] + if linked_stem != source_stem: + linked_stems.add(linked_stem) + else: + # Bare stem link — check for existence and ambiguity + paths = stem_to_paths.get(file_target) + if paths is None: + broken_links.append((rel_path, line_num, target)) + elif len(paths) > 1: + # Ambiguous: multiple files share this stem + ambiguous_links.append((rel_path, line_num, target, paths)) + elif file_target != source_stem: + linked_stems.add(file_target) # Print results console.print("[bold]Wiki-Link Validation[/bold]") console.print() - console.print(f"Found {len(valid_targets)} valid link targets in documentation.") + console.print(f"Found {len(path_targets)} valid link targets in documentation.") console.print() has_errors = False @@ -173,28 +171,11 @@ def main() -> int: console.print(table) console.print() - if path_links: - has_errors = True - console.print("[bold red]Path-Based Wiki-Links Found[/bold red]") - console.print("Wiki-links must use simple filenames only (no '/' paths).") - console.print("Rename files to be unique, then use [[filename]] format.") - console.print() - table = Table(show_header=True, header_style="bold") - table.add_column("File") - table.add_column("Line", justify="right") - table.add_column("Target") - - for file_path, line_num, target in path_links: - table.add_row(file_path, str(line_num), escape(f"[[{target}]]")) - - console.print(table) - console.print() - if ambiguous_links: has_errors = True console.print("[bold red]Ambiguous Wiki-Links Found[/bold red]") - console.print("These links use filenames that exist in multiple locations.") - console.print("Rename files to be unique across all documentation.") + console.print("These bare-name links match multiple files.") + console.print("Use a path-based link to disambiguate: [[path/to/file]]") console.print() table = Table(show_header=True, header_style="bold") table.add_column("File") @@ -221,7 +202,7 @@ def main() -> int: console.print(table) console.print() - console.print("Each wiki-link target must match a filename or path in docs/.") + console.print("Each wiki-link target must match a filename stem or path in docs/.") console.print() # Orphan detection: docs not linked from any other doc @@ -237,7 +218,7 @@ def main() -> int: table.add_column("Stem") for stem in orphan_stems: - paths = filename_counts[stem] + paths = stem_to_paths[stem] for path in paths: table.add_row(f"{path}.md", stem) diff --git a/prek.toml b/prek.toml index 6acf7b7..b780d94 100644 --- a/prek.toml +++ b/prek.toml @@ -148,14 +148,6 @@ stages = ["commit-msg"] [[repos]] repo = "local" -[[repos.hooks]] -id = "docs-check-filenames" -name = "docs-check-filenames" -entry = "mise run docs-check-filenames" -language = "system" -files = '^docs/.*\.md$' -pass_filenames = false - [[repos.hooks]] id = "docs-check-links" name = "docs-check-links" @@ -164,14 +156,6 @@ language = "system" files = '^docs/.*\.md$' pass_filenames = false -[[repos.hooks]] -id = "docs-check-index" -name = "docs-check-index" -entry = "mise run docs-check-index" -language = "system" -files = '^docs/.*\.md$' -pass_filenames = false - [[repos.hooks]] id = "docs-check-frontmatter" name = "docs-check-frontmatter" From 0ef5fe5792e1ce95b871ab021f33910eeb86b301 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 9 Mar 2026 12:00:54 -0700 Subject: [PATCH 023/430] Update docs container to v1.28.2-4f0476a (SPA disabled) Co-Authored-By: Claude Opus 4.6 --- argocd/manifests/docs/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/argocd/manifests/docs/kustomization.yaml b/argocd/manifests/docs/kustomization.yaml index 466c9c5..dcb03ac 100644 --- a/argocd/manifests/docs/kustomization.yaml +++ b/argocd/manifests/docs/kustomization.yaml @@ -9,4 +9,4 @@ resources: - pdb.yaml images: - name: registry.ops.eblu.me/blumeops/quartz - newTag: v1.28.2-ede9a51 + newTag: v1.28.2-4f0476a From ebba3d6e5ba2511cdb034f0396629eac0cef8617 Mon Sep 17 00:00:00 2001 From: Forgejo Actions Date: Mon, 9 Mar 2026 12:03:30 -0700 Subject: [PATCH 024/430] Update docs release to v1.14.0 - Built changelog from towncrier fragments [skip ci] --- CHANGELOG.md | 21 +++++++++++++++++++ argocd/manifests/docs/deployment.yaml | 2 +- .../+fix-mikado-close-without-impl.bugfix.md | 1 - .../feature-jobsync-docs-and-rapidapi.doc.md | 1 - .../fix-disable-spa-relax-docs.doc.md | 1 - .../fix-disable-spa-relax-docs.infra.md | 1 - ...x-onepassword-numeric-log-levels.bugfix.md | 1 - docs/changelog.d/mikado-jobsync.feature.md | 1 - 8 files changed, 22 insertions(+), 7 deletions(-) delete mode 100644 docs/changelog.d/+fix-mikado-close-without-impl.bugfix.md delete mode 100644 docs/changelog.d/feature-jobsync-docs-and-rapidapi.doc.md delete mode 100644 docs/changelog.d/fix-disable-spa-relax-docs.doc.md delete mode 100644 docs/changelog.d/fix-disable-spa-relax-docs.infra.md delete mode 100644 docs/changelog.d/fix-onepassword-numeric-log-levels.bugfix.md delete mode 100644 docs/changelog.d/mikado-jobsync.feature.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 2701491..8c8833d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,27 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [v1.14.0] - 2026-03-09 + +### Features + +- Deploy JobSync to ringtail k3s — nix-built container, Tailscale Ingress, Caddy route at `jobsync.ops.eblu.me`, Ollama integration for AI features. + +### Bug Fixes + +- Fix 1Password Connect logs showing as errors in Grafana by normalizing numeric log levels (1-5) to standard strings (error/warn/info/debug/trace) in the Alloy log processing pipeline. +- Fix mikado-branch-invariant-check false positive: close commits without preceding impl commits are valid (e.g., operational tasks with no code changes). + +### Infrastructure + +- Disable Quartz SPA mode and remove robots.txt crawler exclusions to fix the Facebook crawler spider trap. Remove hand-curated category index files in favor of Quartz auto-generated folder pages. + +### Documentation + +- Add JobSync reference card, update ringtail workloads table, document observability via Loki, and wire RAPIDAPI_KEY through ExternalSecret for job search automation. +- Relax wiki-link constraints: allow path-based links for disambiguation, drop global filename uniqueness requirement, remove docs-check-filenames and docs-check-index hooks. + + ## [v1.13.3] - 2026-03-06 ### Infrastructure diff --git a/argocd/manifests/docs/deployment.yaml b/argocd/manifests/docs/deployment.yaml index aef1e20..05811ba 100644 --- a/argocd/manifests/docs/deployment.yaml +++ b/argocd/manifests/docs/deployment.yaml @@ -27,7 +27,7 @@ spec: name: http env: - name: DOCS_RELEASE_URL - value: "https://forge.eblu.me/eblume/blumeops/releases/download/v1.13.3/docs-v1.13.3.tar.gz" + value: "https://forge.eblu.me/eblume/blumeops/releases/download/v1.14.0/docs-v1.14.0.tar.gz" resources: requests: memory: "64Mi" diff --git a/docs/changelog.d/+fix-mikado-close-without-impl.bugfix.md b/docs/changelog.d/+fix-mikado-close-without-impl.bugfix.md deleted file mode 100644 index f6501ae..0000000 --- a/docs/changelog.d/+fix-mikado-close-without-impl.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -Fix mikado-branch-invariant-check false positive: close commits without preceding impl commits are valid (e.g., operational tasks with no code changes). diff --git a/docs/changelog.d/feature-jobsync-docs-and-rapidapi.doc.md b/docs/changelog.d/feature-jobsync-docs-and-rapidapi.doc.md deleted file mode 100644 index 6da4b27..0000000 --- a/docs/changelog.d/feature-jobsync-docs-and-rapidapi.doc.md +++ /dev/null @@ -1 +0,0 @@ -Add JobSync reference card, update ringtail workloads table, document observability via Loki, and wire RAPIDAPI_KEY through ExternalSecret for job search automation. diff --git a/docs/changelog.d/fix-disable-spa-relax-docs.doc.md b/docs/changelog.d/fix-disable-spa-relax-docs.doc.md deleted file mode 100644 index f393f72..0000000 --- a/docs/changelog.d/fix-disable-spa-relax-docs.doc.md +++ /dev/null @@ -1 +0,0 @@ -Relax wiki-link constraints: allow path-based links for disambiguation, drop global filename uniqueness requirement, remove docs-check-filenames and docs-check-index hooks. diff --git a/docs/changelog.d/fix-disable-spa-relax-docs.infra.md b/docs/changelog.d/fix-disable-spa-relax-docs.infra.md deleted file mode 100644 index bac7b7e..0000000 --- a/docs/changelog.d/fix-disable-spa-relax-docs.infra.md +++ /dev/null @@ -1 +0,0 @@ -Disable Quartz SPA mode and remove robots.txt crawler exclusions to fix the Facebook crawler spider trap. Remove hand-curated category index files in favor of Quartz auto-generated folder pages. diff --git a/docs/changelog.d/fix-onepassword-numeric-log-levels.bugfix.md b/docs/changelog.d/fix-onepassword-numeric-log-levels.bugfix.md deleted file mode 100644 index 74f2a99..0000000 --- a/docs/changelog.d/fix-onepassword-numeric-log-levels.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -Fix 1Password Connect logs showing as errors in Grafana by normalizing numeric log levels (1-5) to standard strings (error/warn/info/debug/trace) in the Alloy log processing pipeline. diff --git a/docs/changelog.d/mikado-jobsync.feature.md b/docs/changelog.d/mikado-jobsync.feature.md deleted file mode 100644 index bdd3eb7..0000000 --- a/docs/changelog.d/mikado-jobsync.feature.md +++ /dev/null @@ -1 +0,0 @@ -Deploy JobSync to ringtail k3s — nix-built container, Tailscale Ingress, Caddy route at `jobsync.ops.eblu.me`, Ollama integration for AI features. From 87d4de244b242a98f6ba8d87d19d1f13a3835a4e Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 11 Mar 2026 17:36:51 -0700 Subject: [PATCH 025/430] Review jobsync: add to services-check and homepage (#291) ## Summary - Add jobsync pod check (ringtail k3s) and HTTP endpoint to `services-check` - Add JobSync entry to homepage dashboard under new "Apps" group - Mark jobsync as reviewed at v1.1.4 (current with upstream) - Changelog fragment added ## Deployment and Testing - [ ] Sync homepage app from branch: `argocd app set homepage --revision review/jobsync && argocd app sync homepage` - [ ] Verify JobSync appears on go.ops.eblu.me dashboard - [ ] Run `mise run services-check` to verify new checks pass - [ ] After merge: `argocd app set homepage --revision main && argocd app sync homepage` Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/291 --- argocd/manifests/homepage/services.yaml | 5 +++++ argocd/manifests/jobsync/ingress-tailscale.yaml | 2 +- docs/changelog.d/review-jobsync.infra.md | 1 + mise-tasks/services-check | 2 ++ service-versions.yaml | 2 +- 5 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 docs/changelog.d/review-jobsync.infra.md diff --git a/argocd/manifests/homepage/services.yaml b/argocd/manifests/homepage/services.yaml index fcde74f..9861e0a 100644 --- a/argocd/manifests/homepage/services.yaml +++ b/argocd/manifests/homepage/services.yaml @@ -61,6 +61,11 @@ # widget: # type: caddy # url: http://indri.tail8d86e.ts.net:2019 +- Services: + - JobSync: + href: https://jobsync.ops.eblu.me + icon: mdi-briefcase-search + description: Job application tracker - Infrastructure: - Authentik: href: https://authentik.ops.eblu.me diff --git a/argocd/manifests/jobsync/ingress-tailscale.yaml b/argocd/manifests/jobsync/ingress-tailscale.yaml index a8e24c8..54b7ce6 100644 --- a/argocd/manifests/jobsync/ingress-tailscale.yaml +++ b/argocd/manifests/jobsync/ingress-tailscale.yaml @@ -9,7 +9,7 @@ metadata: tailscale.com/proxy-group: "ingress" gethomepage.dev/enabled: "true" gethomepage.dev/name: "JobSync" - gethomepage.dev/group: "Apps" + gethomepage.dev/group: "Services" gethomepage.dev/icon: "mdi-briefcase-search" gethomepage.dev/description: "Job application tracker" gethomepage.dev/href: "https://jobsync.ops.eblu.me" diff --git a/docs/changelog.d/review-jobsync.infra.md b/docs/changelog.d/review-jobsync.infra.md new file mode 100644 index 0000000..dd221ab --- /dev/null +++ b/docs/changelog.d/review-jobsync.infra.md @@ -0,0 +1 @@ +Add jobsync to services-check and homepage dashboard; mark as reviewed at v1.1.4 diff --git a/mise-tasks/services-check b/mise-tasks/services-check index 189a183..1f3b664 100755 --- a/mise-tasks/services-check +++ b/mise-tasks/services-check @@ -83,6 +83,7 @@ check_http "CV" "https://cv.ops.eblu.me/" check_http "Ntfy" "https://ntfy.ops.eblu.me/v1/health" check_http "Authentik" "https://authentik.ops.eblu.me/-/health/live/" check_http "Frigate" "https://nvr.ops.eblu.me/api/version" +check_http "JobSync" "https://jobsync.ops.eblu.me/" echo "" echo "Ringtail (NixOS):" @@ -100,6 +101,7 @@ check_service "authentik" "kubectl --context=k3s-ringtail -n authentik get pods check_service "frigate" "kubectl --context=k3s-ringtail -n frigate get pods -l app=frigate -o jsonpath='{.items[0].status.phase}' | grep -q Running" check_service "frigate-notify" "kubectl --context=k3s-ringtail -n frigate get pods -l app=frigate-notify -o jsonpath='{.items[0].status.phase}' | grep -q Running" check_service "nvidia-device-plugin" "kubectl --context=k3s-ringtail -n nvidia-device-plugin get pods -l app=nvidia-device-plugin -o jsonpath='{.items[0].status.phase}' | grep -q Running" +check_service "jobsync" "kubectl --context=k3s-ringtail -n jobsync get pods -l app=jobsync -o jsonpath='{.items[0].status.phase}' | grep -q Running" echo "" echo "Public services (via Fly.io):" diff --git a/service-versions.yaml b/service-versions.yaml index 2bf9419..535af7c 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -157,7 +157,7 @@ services: - name: jobsync type: argocd - last-reviewed: null + last-reviewed: 2026-03-11 current-version: "1.1.4" upstream-source: https://github.com/Gsync/jobsync/releases notes: Job application tracker; nix container on ringtail k3s From d01a165b917346b69a9960cbd3b237bb67eb8bea Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 11 Mar 2026 18:04:01 -0700 Subject: [PATCH 026/430] Add docs-preview task and visual preview step to doc review New `mise run docs-preview ` task builds docs via Dagger and serves them locally in the production quartz container (image parsed from ArgoCD kustomization), opening the browser directly to the specified card. Container auto-cleans after 1 hour. Also updates docs-review checklist and review-documentation how-to to reference the visual preview workflow. Co-Authored-By: Claude Opus 4.6 --- docs/changelog.d/+docs-preview.feature.md | 1 + .../knowledgebase/review-documentation.md | 14 ++ mise-tasks/docs-preview | 133 ++++++++++++++++++ mise-tasks/docs-review | 3 + 4 files changed, 151 insertions(+) create mode 100644 docs/changelog.d/+docs-preview.feature.md create mode 100644 mise-tasks/docs-preview diff --git a/docs/changelog.d/+docs-preview.feature.md b/docs/changelog.d/+docs-preview.feature.md new file mode 100644 index 0000000..f865783 --- /dev/null +++ b/docs/changelog.d/+docs-preview.feature.md @@ -0,0 +1 @@ +Add `docs-preview` mise task: builds docs with Dagger and serves them locally in the production quartz container, opening the browser directly to the specified card. Also adds visual preview hints to the `docs-review` checklist and the review-documentation how-to. diff --git a/docs/how-to/knowledgebase/review-documentation.md b/docs/how-to/knowledgebase/review-documentation.md index d6dc064..fe17449 100644 --- a/docs/how-to/knowledgebase/review-documentation.md +++ b/docs/how-to/knowledgebase/review-documentation.md @@ -92,6 +92,20 @@ mise run dns-preview # DNS (Gandi) If changes are pending, investigate whether docs or infrastructure is stale. +## Visual Preview + +After reviewing and editing a card, visually verify the rendered output. + +**Quick scan (agent):** Have the agent display the card with `bat` for a terminal-based visual check. + +**Full rendered preview:** Build the entire Quartz docs site locally and open directly to the card: + +```bash +mise run docs-preview how-to/knowledgebase/review-documentation +``` + +This builds the docs with Dagger, serves them on `localhost:8484`, and opens the browser to the specified card. Press Ctrl-C to stop. Accepts paths with or without the `.md` suffix. + ## Making Changes If a card needs updates, classify the change (see [[agent-change-process]]): diff --git a/mise-tasks/docs-preview b/mise-tasks/docs-preview new file mode 100644 index 0000000..a968797 --- /dev/null +++ b/mise-tasks/docs-preview @@ -0,0 +1,133 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.12" +# dependencies = ["pyyaml>=6.0", "rich>=13.0.0", "typer>=0.15.0"] +# /// +#MISE description="Build docs with Dagger and serve locally, opening to a specific card" +#USAGE arg "" help="Card path relative to docs/, e.g. how-to/knowledgebase/review-documentation" +#USAGE flag "--port " default="8484" help="Port for preview server (default 8484)" +"""Build the full Quartz docs site and serve locally for visual preview. + +Builds the documentation using Dagger's build_docs function, extracts the +result, and serves it in the same quartz container used in production +(image parsed from the ArgoCD kustomization). Opens the browser directly +to the specified card. The container auto-removes after 1 hour. + +Usage: mise run docs-preview how-to/knowledgebase/review-documentation +""" + +import shutil +import subprocess +import tarfile +import tempfile +import webbrowser +from pathlib import Path +from typing import Annotated + +import typer +import yaml +from rich.console import Console + +REPO_ROOT = Path(__file__).parent.parent +CONTAINER_NAME = "docs-preview" + + +def get_quartz_image() -> str: + """Parse the quartz container image from the ArgoCD kustomization.""" + kustomization = REPO_ROOT / "argocd" / "manifests" / "docs" / "kustomization.yaml" + data = yaml.safe_load(kustomization.read_text()) + for img in data.get("images", []): + if img["name"] == "registry.ops.eblu.me/blumeops/quartz": + return f"{img['name']}:{img['newTag']}" + raise RuntimeError("Could not find quartz image in kustomization.yaml") + + +def main( + card: Annotated[str, typer.Argument(help="Card path relative to docs/")], + port: Annotated[int, typer.Option(help="Port for preview server")] = 8484, +) -> None: + console = Console() + + # Normalize: accept with or without .md suffix + card_stem = card.removesuffix(".md") + card_file = REPO_ROOT / "docs" / f"{card_stem}.md" + if not card_file.exists(): + console.print(f"[bold red]Card not found:[/bold red] {card_file}") + raise typer.Exit(code=1) + + url_path = "/" + card_stem + image = get_quartz_image() + console.print(f"[dim]Using image: {image}[/dim]") + + # Clean up any previous preview container and its docroot + subprocess.run( + ["docker", "rm", "-f", CONTAINER_NAME], + capture_output=True, + ) + docroot = Path(tempfile.gettempdir()) / "docs-preview" + if docroot.exists(): + shutil.rmtree(docroot) + docroot.mkdir() + + with tempfile.TemporaryDirectory() as tmpdir: + tarball = Path(tmpdir) / "docs-preview.tar.gz" + + console.print("[bold]Building docs with Dagger...[/bold]") + subprocess.run( + [ + "dagger", + "call", + "build-docs", + "--src=.", + "--version=preview", + "export", + f"--path={tarball}", + ], + cwd=REPO_ROOT, + check=True, + ) + + console.print("[bold]Extracting docs...[/bold]") + with tarfile.open(tarball, "r:gz") as tf: + tf.extractall(docroot) + + console.print("[bold]Starting preview container...[/bold]") + subprocess.run( + [ + "docker", + "run", + "-d", + "--rm", + "--name", CONTAINER_NAME, + "--stop-timeout", "0", + "-p", f"{port}:80", + "-v", f"{docroot}:/usr/share/nginx/html:ro", + "--entrypoint", "nginx", + image, + "-g", "daemon off;", + ], + check=True, + ) + + url = f"http://localhost:{port}{url_path}" + console.print(f"\n[bold green]Preview running at http://localhost:{port}[/bold green]") + console.print(f"[bold cyan]Opening {url}[/bold cyan]\n") + webbrowser.open(url) + + console.print(f"[yellow]Container will auto-stop in 1 hour.[/yellow]") + console.print(f"[yellow]To stop sooner: docker rm -f {CONTAINER_NAME}[/yellow]\n") + + # Schedule auto-cleanup after 1 hour (container + docroot) + subprocess.Popen( + [ + "sh", "-c", + f"sleep 3600 && docker rm -f {CONTAINER_NAME} 2>/dev/null && rm -rf {docroot}", + ], + start_new_session=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + +if __name__ == "__main__": + typer.run(main) diff --git a/mise-tasks/docs-review b/mise-tasks/docs-review index 6d07b4b..e7c7aa2 100755 --- a/mise-tasks/docs-review +++ b/mise-tasks/docs-review @@ -156,6 +156,9 @@ def main( "• If ArgoCD app: is it synced? (argocd app get )\n" "• If Ansible role: does it apply idempotently? (--check --diff)\n" "• If Pulumi: is there drift? (pulumi preview)\n\n" + "[bold]Visual Preview:[/bold]\n\n" + "• Agent: use [cyan]bat[/cyan] to display the reviewed card for user visual scan\n" + "• For full rendered preview: [cyan]mise run docs-preview [/cyan]\n\n" "[bold]After Review:[/bold]\n\n" "• Update the card's frontmatter: [cyan]last-reviewed: " + str(today) From d5a92fead817ca7b4a1db5bc70415490109ffc5e Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 11 Mar 2026 18:11:34 -0700 Subject: [PATCH 027/430] Review build-jobsync-container, refine docs-preview tooling - Review build-jobsync-container.md: fix nonexistent `mirror-sync` task reference (Forgejo mirrors sync automatically), mark reviewed - Remove bat hint from docs-review checklist (output not visible in agent sessions), keep docs-preview hint as user-facing step - Simplify review-documentation.md visual preview section - Fix Python 3.14 tarfile deprecation warning in docs-preview Co-Authored-By: Claude Opus 4.6 --- docs/how-to/jobsync/build-jobsync-container.md | 5 +++-- docs/how-to/knowledgebase/review-documentation.md | 6 +----- mise-tasks/docs-preview | 2 +- mise-tasks/docs-review | 6 ++---- 4 files changed, 7 insertions(+), 12 deletions(-) mode change 100644 => 100755 mise-tasks/docs-preview diff --git a/docs/how-to/jobsync/build-jobsync-container.md b/docs/how-to/jobsync/build-jobsync-container.md index de75915..d9653e9 100644 --- a/docs/how-to/jobsync/build-jobsync-container.md +++ b/docs/how-to/jobsync/build-jobsync-container.md @@ -1,6 +1,7 @@ --- title: Build JobSync Container -modified: 2026-03-08 +modified: 2026-03-11 +last-reviewed: 2026-03-11 tags: - how-to - jobsync @@ -19,7 +20,7 @@ The derivation is at `containers/jobsync/default.nix`. It uses `buildNpmPackage` ## Upgrading JobSync -1. Update the forge mirror: `mise run mirror-sync jobsync` +1. Verify the forge mirror is current: check `https://forge.eblu.me/mirrors/jobsync` (mirrors sync automatically) 2. Update `version` in `default.nix` to match the new upstream tag 3. Clear `hash` in `fetchgit` (set to `""`), build, grab the correct hash from the error 4. Clear `npmDepsHash` (set to `""`), build again, grab the correct hash diff --git a/docs/how-to/knowledgebase/review-documentation.md b/docs/how-to/knowledgebase/review-documentation.md index fe17449..1dfba4e 100644 --- a/docs/how-to/knowledgebase/review-documentation.md +++ b/docs/how-to/knowledgebase/review-documentation.md @@ -94,11 +94,7 @@ If changes are pending, investigate whether docs or infrastructure is stale. ## Visual Preview -After reviewing and editing a card, visually verify the rendered output. - -**Quick scan (agent):** Have the agent display the card with `bat` for a terminal-based visual check. - -**Full rendered preview:** Build the entire Quartz docs site locally and open directly to the card: +After reviewing and editing a card, visually verify the rendered output. This step is for the human reviewer — build the full Quartz docs site locally and open directly to the card: ```bash mise run docs-preview how-to/knowledgebase/review-documentation diff --git a/mise-tasks/docs-preview b/mise-tasks/docs-preview old mode 100644 new mode 100755 index a968797..38c14c3 --- a/mise-tasks/docs-preview +++ b/mise-tasks/docs-preview @@ -89,7 +89,7 @@ def main( console.print("[bold]Extracting docs...[/bold]") with tarfile.open(tarball, "r:gz") as tf: - tf.extractall(docroot) + tf.extractall(docroot, filter="data") console.print("[bold]Starting preview container...[/bold]") subprocess.run( diff --git a/mise-tasks/docs-review b/mise-tasks/docs-review index e7c7aa2..d2aee76 100755 --- a/mise-tasks/docs-review +++ b/mise-tasks/docs-review @@ -156,14 +156,12 @@ def main( "• If ArgoCD app: is it synced? (argocd app get )\n" "• If Ansible role: does it apply idempotently? (--check --diff)\n" "• If Pulumi: is there drift? (pulumi preview)\n\n" - "[bold]Visual Preview:[/bold]\n\n" - "• Agent: use [cyan]bat[/cyan] to display the reviewed card for user visual scan\n" - "• For full rendered preview: [cyan]mise run docs-preview [/cyan]\n\n" "[bold]After Review:[/bold]\n\n" "• Update the card's frontmatter: [cyan]last-reviewed: " + str(today) + "[/cyan]\n" - "• Commit the change (along with any fixes)", + "• Commit the change (along with any fixes)\n" + "• User: run [cyan]mise run docs-preview [/cyan] for a rendered visual check", title="[bold yellow]Review Guidance[/bold yellow]", border_style="yellow", )) From 8b9cc4effd011913d06fb6b15226682085bd83ab Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 11 Mar 2026 18:17:45 -0700 Subject: [PATCH 028/430] Add how-to card for running 1Password backup Co-Authored-By: Claude Opus 4.6 --- .../+run-1password-backup-howto.doc.md | 1 + .../operations/restore-1password-backup.md | 1 + .../how-to/operations/run-1password-backup.md | 74 +++++++++++++++++++ docs/reference/services/1password.md | 3 +- 4 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 docs/changelog.d/+run-1password-backup-howto.doc.md create mode 100644 docs/how-to/operations/run-1password-backup.md diff --git a/docs/changelog.d/+run-1password-backup-howto.doc.md b/docs/changelog.d/+run-1password-backup-howto.doc.md new file mode 100644 index 0000000..907d9eb --- /dev/null +++ b/docs/changelog.d/+run-1password-backup-howto.doc.md @@ -0,0 +1 @@ +Add how-to card for running the 1Password backup (`mise run op-backup`), with bidirectional links to restore procedure and service reference. diff --git a/docs/how-to/operations/restore-1password-backup.md b/docs/how-to/operations/restore-1password-backup.md index 325f468..b839b10 100644 --- a/docs/how-to/operations/restore-1password-backup.md +++ b/docs/how-to/operations/restore-1password-backup.md @@ -95,6 +95,7 @@ The borg repo uses `repokey` encryption — the key is stored in the repo itself ## Related +- [[run-1password-backup]] - How to create the backup (export + encrypt + transfer) - [[borgmatic]] - Backup system - [[1password]] - Credential management - [[backups]] - Backup policy and schedule diff --git a/docs/how-to/operations/run-1password-backup.md b/docs/how-to/operations/run-1password-backup.md new file mode 100644 index 0000000..bbed3ab --- /dev/null +++ b/docs/how-to/operations/run-1password-backup.md @@ -0,0 +1,74 @@ +--- +title: Run 1Password Backup +modified: 2026-03-11 +tags: + - how-to + - operations + - backup +--- + +# Run 1Password Backup + +How to export and encrypt your 1Password vaults for inclusion in [[borgmatic]] backups. Run this periodically from your local machine (Gilbert). + +## Prerequisites + +- 1Password desktop app running (for the vault export) +- `op`, `age`, `openssl`, `ssh`, and `scp` installed locally +- SSH access to [[indri]] +- The `op` CLI signed in (biometric unlock) + +## Procedure + +### 1. Export Vaults From 1Password + +1. Open the 1Password desktop app +2. **File > Export > All Vaults** +3. Choose **1PUX** format +4. Save to `~/Documents/1Password-export.1pux` + +### 2. Run the Backup Task + +```fish +mise run op-backup +``` + +Or, if you saved the export to a non-default location: + +```fish +mise run op-backup ~/path/to/export.1pux +``` + +The task will: + +1. Prompt for the `.1pux` path if not provided +2. Fetch your master password and secret key from 1Password (triggers biometric) +3. Generate a temporary age key pair +4. Encrypt the `.1pux` with the age public key +5. Encrypt the age private key with OpenSSL AES-256-CBC (passphrase: `{master_password}:{secret_key}`) +6. SCP both encrypted files to `indri:/Users/erichblume/Documents/1password-backup/` +7. Clean up old backups on indri (keeps last 3 sets) +8. **Delete the plaintext `.1pux` from Gilbert** + +No cleanup needed — the script automatically deletes the plaintext `.1pux` from Gilbert and shreds the temporary encryption keys. + +### 3. Verify + +After the script completes, confirm the files landed on indri: + +```fish +ssh indri 'ls -lh /Users/erichblume/Documents/1password-backup/' +``` + +You should see a `.age` file (~30-45 MB) and a `.key.enc` file (~200 bytes) with today's timestamp. + +## What Happens Next + +Borgmatic picks up the encrypted files during its daily 2:00 AM backup run, archiving them to both [[sifaka]] (local NAS) and BorgBase (offsite). No further action needed. + +## Related + +- [[restore-1password-backup]] - Disaster recovery: how to decrypt and restore +- [[1password]] - 1Password service overview +- [[borgmatic]] - Backup system +- [[backups]] - Backup policy and schedule diff --git a/docs/reference/services/1password.md b/docs/reference/services/1password.md index 4d3d5a1..4489194 100644 --- a/docs/reference/services/1password.md +++ b/docs/reference/services/1password.md @@ -37,11 +37,12 @@ Services reference 1Password items via `ExternalSecret` manifests. ## Disaster Recovery Backup -The `mise run op-backup` task encrypts a `.1pux` vault export and transfers it to [[indri]] for inclusion in [[borgmatic]] backups. See [[restore-1password-backup]] for the full recovery procedure. +The `mise run op-backup` task encrypts a `.1pux` vault export and transfers it to [[indri]] for inclusion in [[borgmatic]] backups. See [[run-1password-backup]] for the step-by-step procedure and [[restore-1password-backup]] for disaster recovery. ## Related - [[argocd]] - Uses secrets for git access - [[postgresql]] - Database credentials +- [[run-1password-backup]] - Periodic backup procedure - [[restore-1password-backup]] - Recovery from backup - [[borgmatic]] - Backup system From 009196f6c1cf69324c7f6381f728870d6da10e51 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 11 Mar 2026 18:23:24 -0700 Subject: [PATCH 029/430] Fix op-backup: auto-detect .1pux exports with suffixed filenames 1Password adds account ID and timestamp to export filenames. The script now globs ~/Documents for .1pux files instead of expecting a fixed name. Also fixes a Rich markup error with bracket characters in the prompt. Co-Authored-By: Claude Opus 4.6 --- mise-tasks/op-backup | 38 +++++++++++++++++++++++++++++--------- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/mise-tasks/op-backup b/mise-tasks/op-backup index 68c46ae..4b5b660 100755 --- a/mise-tasks/op-backup +++ b/mise-tasks/op-backup @@ -37,7 +37,7 @@ from pathlib import Path from rich.console import Console REMOTE_DIR = "/Users/erichblume/Documents/1password-backup" -DEFAULT_EXPORT_PATH = Path.home() / "Documents" / "1Password-export.1pux" +EXPORT_DIR = Path.home() / "Documents" KEEP_RECENT = 3 # 1Password vault/item references for the encryption credentials @@ -59,20 +59,40 @@ def check_dependencies() -> bool: return ok +def _find_1pux_files() -> list[Path]: + """Find .1pux files in the default export directory.""" + return sorted(EXPORT_DIR.glob("*.1pux"), key=lambda p: p.stat().st_mtime, reverse=True) + + def get_export_path(argv_path: str | None) -> Path | None: - """Resolve the .1pux file path from argument or interactive prompt.""" + """Resolve the .1pux file path from argument, auto-detection, or interactive prompt.""" if argv_path: path = Path(argv_path).expanduser() else: console.print("[bold]=== 1Password Disaster Recovery Backup ===[/bold]") console.print() - console.print("Export your vaults from the 1Password desktop app:") - console.print(" 1. Open 1Password") - console.print(" 2. File > Export > All Vaults (or select specific vaults)") - console.print(f" 3. Save as 1PUX format to: [cyan]{DEFAULT_EXPORT_PATH}[/cyan]") - console.print() - raw = console.input(f"Path to .1pux file [{DEFAULT_EXPORT_PATH}]: ").strip() - path = Path(raw).expanduser() if raw else DEFAULT_EXPORT_PATH + + candidates = _find_1pux_files() + if len(candidates) == 1: + path = candidates[0] + console.print(f"Found export: [cyan]{path.name}[/cyan]") + elif len(candidates) > 1: + console.print("[red]ERROR:[/red] Multiple .1pux files found in ~/Documents:") + for c in candidates: + console.print(f" {c.name}") + console.print("Delete the extras and try again, or pass the path explicitly.") + return None + else: + console.print("Export your vaults from the 1Password desktop app:") + console.print(" 1. Open 1Password") + console.print(" 2. File > Export > All Vaults (or select specific vaults)") + console.print(f" 3. Save as 1PUX format to: [cyan]{EXPORT_DIR}[/cyan]") + console.print() + raw = console.input("Path to .1pux file: ").strip() + if not raw: + console.print("[red]ERROR:[/red] No path provided") + return None + path = Path(raw).expanduser() if not path.is_file(): console.print(f"[red]ERROR:[/red] File not found: {path}") From 40f1568088e3d4a8d270eb5b7d925780d1d5374f Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 11 Mar 2026 18:37:31 -0700 Subject: [PATCH 030/430] Remove unused Mosquitto MQTT broker from ringtail Mosquitto has been dormant since frigate-notify switched from MQTT to webapi polling (529ba10). Tear down live infra (ArgoCD app, namespace) and remove all manifests, service-versions entry, services-check, and doc references. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 2 +- argocd/apps/mqtt.yaml | 18 ------- argocd/manifests/frigate/frigate-config.yml | 4 -- .../frigate/frigate-notify-config.yml | 2 - argocd/manifests/mosquitto/deployment.yaml | 47 ------------------- argocd/manifests/mosquitto/kustomization.yaml | 14 ------ argocd/manifests/mosquitto/mosquitto.conf | 3 -- argocd/manifests/mosquitto/service.yaml | 13 ----- docs/changelog.d/+remove-mosquitto.infra.md | 1 + docs/explanation/architecture.md | 4 +- docs/reference/infrastructure/indri.md | 2 +- docs/reference/infrastructure/ringtail.md | 3 +- docs/reference/kubernetes/cluster.md | 4 +- docs/reference/services/frigate.md | 4 +- docs/reference/services/ntfy.md | 4 +- docs/reference/services/tempo.md | 2 +- mise-tasks/services-check | 1 - service-versions.yaml | 5 -- 18 files changed, 13 insertions(+), 120 deletions(-) delete mode 100644 argocd/apps/mqtt.yaml delete mode 100644 argocd/manifests/mosquitto/deployment.yaml delete mode 100644 argocd/manifests/mosquitto/kustomization.yaml delete mode 100644 argocd/manifests/mosquitto/mosquitto.conf delete mode 100644 argocd/manifests/mosquitto/service.yaml create mode 100644 docs/changelog.d/+remove-mosquitto.infra.md diff --git a/CLAUDE.md b/CLAUDE.md index 050b0f9..a6d883e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,7 +74,7 @@ encounter wiki-links (`[[like-this]]`) it is referring to docs/ cards. ### Kubernetes (ArgoCD) -Most services run in minikube on indri via ArgoCD (app-of-apps, manual sync). GPU workloads (Frigate, Mosquitto, ntfy) run on ringtail's k3s cluster, also managed by ArgoCD. +Most services run in minikube on indri via ArgoCD (app-of-apps, manual sync). GPU workloads (Frigate, ntfy) run on ringtail's k3s cluster, also managed by ArgoCD. **PR workflow:** 1. Create branch, modify `argocd/manifests//` diff --git a/argocd/apps/mqtt.yaml b/argocd/apps/mqtt.yaml deleted file mode 100644 index 61959aa..0000000 --- a/argocd/apps/mqtt.yaml +++ /dev/null @@ -1,18 +0,0 @@ ---- -apiVersion: argoproj.io/v1alpha1 -kind: Application -metadata: - name: mqtt - namespace: argocd -spec: - project: default - source: - repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git - targetRevision: main - path: argocd/manifests/mosquitto - destination: - server: https://ringtail.tail8d86e.ts.net:6443 - namespace: mqtt - syncPolicy: - syncOptions: - - CreateNamespace=true diff --git a/argocd/manifests/frigate/frigate-config.yml b/argocd/manifests/frigate/frigate-config.yml index baad1e7..4a6def5 100644 --- a/argocd/manifests/frigate/frigate-config.yml +++ b/argocd/manifests/frigate/frigate-config.yml @@ -1,10 +1,6 @@ database: path: /db/frigate.db -mqtt: - host: mosquitto.mqtt.svc.cluster.local - port: 1883 - go2rtc: streams: # GableCam IP is reserved in UX7 DHCP config diff --git a/argocd/manifests/frigate/frigate-notify-config.yml b/argocd/manifests/frigate/frigate-notify-config.yml index 69072df..886e3fd 100644 --- a/argocd/manifests/frigate/frigate-notify-config.yml +++ b/argocd/manifests/frigate/frigate-notify-config.yml @@ -6,8 +6,6 @@ frigate: enabled: true interval: 15 - mqtt: - enabled: false alerts: general: diff --git a/argocd/manifests/mosquitto/deployment.yaml b/argocd/manifests/mosquitto/deployment.yaml deleted file mode 100644 index 3c8b8fa..0000000 --- a/argocd/manifests/mosquitto/deployment.yaml +++ /dev/null @@ -1,47 +0,0 @@ ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: mosquitto - namespace: mqtt -spec: - replicas: 1 - selector: - matchLabels: - app: mosquitto - template: - metadata: - labels: - app: mosquitto - spec: - containers: - - name: mosquitto - image: eclipse-mosquitto:kustomized - ports: - - containerPort: 1883 - name: mqtt - volumeMounts: - - name: config - mountPath: /mosquitto/config/mosquitto.conf - subPath: mosquitto.conf - resources: - requests: - memory: "32Mi" - cpu: "50m" - limits: - memory: "128Mi" - cpu: "100m" - livenessProbe: - tcpSocket: - port: 1883 - initialDelaySeconds: 5 - periodSeconds: 30 - readinessProbe: - tcpSocket: - port: 1883 - initialDelaySeconds: 3 - periodSeconds: 10 - volumes: - - name: config - configMap: - name: mosquitto-config diff --git a/argocd/manifests/mosquitto/kustomization.yaml b/argocd/manifests/mosquitto/kustomization.yaml deleted file mode 100644 index e468120..0000000 --- a/argocd/manifests/mosquitto/kustomization.yaml +++ /dev/null @@ -1,14 +0,0 @@ ---- -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization -namespace: mqtt -resources: - - deployment.yaml - - service.yaml -images: - - name: eclipse-mosquitto - newTag: "2.0.22" -configMapGenerator: - - name: mosquitto-config - files: - - mosquitto.conf diff --git a/argocd/manifests/mosquitto/mosquitto.conf b/argocd/manifests/mosquitto/mosquitto.conf deleted file mode 100644 index 0e3b61e..0000000 --- a/argocd/manifests/mosquitto/mosquitto.conf +++ /dev/null @@ -1,3 +0,0 @@ -listener 1883 -allow_anonymous true -persistence false diff --git a/argocd/manifests/mosquitto/service.yaml b/argocd/manifests/mosquitto/service.yaml deleted file mode 100644 index 7a66aa0..0000000 --- a/argocd/manifests/mosquitto/service.yaml +++ /dev/null @@ -1,13 +0,0 @@ ---- -apiVersion: v1 -kind: Service -metadata: - name: mosquitto - namespace: mqtt -spec: - selector: - app: mosquitto - ports: - - name: mqtt - port: 1883 - targetPort: 1883 diff --git a/docs/changelog.d/+remove-mosquitto.infra.md b/docs/changelog.d/+remove-mosquitto.infra.md new file mode 100644 index 0000000..9b452cf --- /dev/null +++ b/docs/changelog.d/+remove-mosquitto.infra.md @@ -0,0 +1 @@ +Remove Mosquitto (MQTT broker) — unused since frigate-notify switched to webapi polling. Deleted ArgoCD app, k8s manifests, namespace, and updated all docs. diff --git a/docs/explanation/architecture.md b/docs/explanation/architecture.md index f4872cc..4080b1e 100644 --- a/docs/explanation/architecture.md +++ b/docs/explanation/architecture.md @@ -39,7 +39,7 @@ Three always-on devices form the infrastructure backbone: ``` - **[[indri]]** runs most services (native and containerized) -- **[[ringtail]]** runs GPU workloads (Frigate NVR) and related services (MQTT, ntfy) +- **[[ringtail]]** runs GPU workloads (Frigate NVR) and related services (ntfy) - **[[sifaka]]** provides bulk storage and backup targets - **[[gilbert]]** is the development workstation @@ -73,7 +73,7 @@ Services run across three compute targets: **Minikube on indri (ArgoCD)** — most services run in minikube, managed via ArgoCD from `argocd/manifests/`. See [[apps]] for the application registry. -**K3s on ringtail (ArgoCD)** — GPU workloads and related services run on [[ringtail]]'s single-node k3s cluster. Frigate NVR uses the RTX 4080 for object detection; Mosquitto and ntfy support its alerting pipeline. +**K3s on ringtail (ArgoCD)** — GPU workloads and related services run on [[ringtail]]'s single-node k3s cluster. Frigate NVR uses the RTX 4080 for object detection; ntfy supports its alerting pipeline. ## Data Flow diff --git a/docs/reference/infrastructure/indri.md b/docs/reference/infrastructure/indri.md index 54465de..cbb2a0f 100644 --- a/docs/reference/infrastructure/indri.md +++ b/docs/reference/infrastructure/indri.md @@ -32,7 +32,7 @@ Primary BlumeOps server. Mac Mini M1 (2020). - [[caddy]] - Reverse proxy for `*.ops.eblu.me` **Kubernetes (via minikube):** -- [[apps|Most k8s applications]] (Frigate, Mosquitto, ntfy migrated to [[ringtail]] k3s) +- [[apps|Most k8s applications]] (Frigate, ntfy migrated to [[ringtail]] k3s) **GUI Applications (manual start required):** - Docker Desktop - Container runtime for minikube diff --git a/docs/reference/infrastructure/ringtail.md b/docs/reference/infrastructure/ringtail.md index d8e2a05..74d5a7d 100644 --- a/docs/reference/infrastructure/ringtail.md +++ b/docs/reference/infrastructure/ringtail.md @@ -66,8 +66,7 @@ Sync order: `1password-connect-ringtail` -> `external-secrets-crds-ringtail` -> | Workload | Namespace | Notes | |----------|-----------|-------| | [[frigate]] | `frigate` | NVR with GPU-accelerated detection (RTX 4080) | -| [[frigate]]-notify | `frigate` | MQTT-to-ntfy alert bridge | -| Mosquitto | `mqtt` | MQTT broker for Frigate events | +| [[frigate]]-notify | `frigate` | Webapi-to-ntfy alert bridge | | [[authentik]] | `authentik` | OIDC identity provider | | [[ntfy]] | `ntfy` | Push notification server | | [[ollama]] | `ollama` | LLM inference with GPU (RTX 4080) | diff --git a/docs/reference/kubernetes/cluster.md b/docs/reference/kubernetes/cluster.md index e7e49bf..9b632bd 100644 --- a/docs/reference/kubernetes/cluster.md +++ b/docs/reference/kubernetes/cluster.md @@ -7,7 +7,7 @@ tags: # Kubernetes Cluster -BlumeOps runs two Kubernetes clusters: a Minikube cluster on [[indri]] (most services) and a k3s cluster on [[ringtail]] (GPU workloads, MQTT, notifications). Both are managed by [[argocd]] on indri. +BlumeOps runs two Kubernetes clusters: a Minikube cluster on [[indri]] (most services) and a k3s cluster on [[ringtail]] (GPU workloads, notifications). Both are managed by [[argocd]] on indri. ## Cluster Specifications @@ -41,7 +41,7 @@ Single-node k3s cluster for workloads requiring amd64 or GPU access. See [[ringt |----------|-------| | **Context** | `k3s-ringtail` | | **API Server** | `https://ringtail.tail8d86e.ts.net:6443` | -| **Workloads** | Frigate (GPU), Mosquitto, ntfy, frigate-notify, nvidia-device-plugin | +| **Workloads** | Frigate (GPU), ntfy, frigate-notify, nvidia-device-plugin | ## Related diff --git a/docs/reference/services/frigate.md b/docs/reference/services/frigate.md index 6486fe5..46363bd 100644 --- a/docs/reference/services/frigate.md +++ b/docs/reference/services/frigate.md @@ -34,7 +34,7 @@ Frigate pod (ringtail k3s) ├── /media/frigate — NFS recordings (sifaka) └── /db — SQLite (local PVC) │ - └──→ MQTT (Mosquitto) → frigate-notify → ntfy → mobile + └──→ frigate-notify (webapi poll) → ntfy → mobile ``` ## Cameras @@ -70,7 +70,7 @@ Two zones are configured: `driveway_entrance` (triggers review alerts for person ## Alerting (frigate-notify) -A separate **frigate-notify** pod (`ghcr.io/0x2142/frigate-notify:v0.3.5`) subscribes to Frigate's MQTT events via Mosquitto and pushes alerts to [[ntfy]] on the `frigate-alerts` topic. Alert messages include action buttons linking back to the Frigate review UI. +A separate **frigate-notify** pod polls Frigate's webapi every 15 seconds for detection events and pushes alerts to [[ntfy]] on the `frigate-alerts` topic. Alert messages include action buttons linking back to the Frigate review UI. ## Related diff --git a/docs/reference/services/ntfy.md b/docs/reference/services/ntfy.md index 0504559..b549a6d 100644 --- a/docs/reference/services/ntfy.md +++ b/docs/reference/services/ntfy.md @@ -29,10 +29,10 @@ The upstream relay (`ntfy.sh`) is configured so mobile app clients can receive p ## Producers -Currently the only producer is **frigate-notify**, which publishes camera detection alerts (person, vehicle, animal) from [[frigate|Frigate]] via MQTT to ntfy: +Currently the only producer is **frigate-notify**, which polls Frigate's webapi for camera detection alerts (person, vehicle, animal) and forwards them to ntfy: ``` -Frigate → MQTT (Mosquitto) → frigate-notify → ntfy → mobile clients +Frigate → frigate-notify (webapi polling) → ntfy → mobile clients ``` The frigate-notify config points to ntfy's cluster-internal address: diff --git a/docs/reference/services/tempo.md b/docs/reference/services/tempo.md index 567168a..771b97f 100644 --- a/docs/reference/services/tempo.md +++ b/docs/reference/services/tempo.md @@ -41,7 +41,7 @@ Distributed tracing backend for BlumeOps infrastructure. Receives traces via OTL | [[ollama]] | HTTP REST | Same (model inference latency) | | [[immich]] | HTTP REST | Same | -Beyla auto-instruments HTTP services via eBPF kernel hooks — no code changes needed. MQTT (Mosquitto) is not instrumented (no eBPF parser for MQTT). +Beyla auto-instruments HTTP services via eBPF kernel hooks — no code changes needed. **Future: SDK instrumentation** Services with OTel SDK support (e.g., Hermes) can send traces directly to the OTLP endpoint for deeper internal spans (DB queries, business logic) alongside eBPF envelope traces. diff --git a/mise-tasks/services-check b/mise-tasks/services-check index 1f3b664..d0de329 100755 --- a/mise-tasks/services-check +++ b/mise-tasks/services-check @@ -95,7 +95,6 @@ check_service "forgejo-runner" "ssh ringtail 'systemctl is-active gitea-runner-n echo "" echo "Ringtail k3s pods:" -check_service "mosquitto" "kubectl --context=k3s-ringtail -n mqtt get pods -l app=mosquitto -o jsonpath='{.items[0].status.phase}' | grep -q Running" check_service "ntfy" "kubectl --context=k3s-ringtail -n ntfy get pods -l app=ntfy -o jsonpath='{.items[0].status.phase}' | grep -q Running" check_service "authentik" "kubectl --context=k3s-ringtail -n authentik get pods -l component=server -o jsonpath='{.items[0].status.phase}' | grep -q Running" check_service "frigate" "kubectl --context=k3s-ringtail -n frigate get pods -l app=frigate -o jsonpath='{.items[0].status.phase}' | grep -q Running" diff --git a/service-versions.yaml b/service-versions.yaml index 535af7c..83daab1 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -30,11 +30,6 @@ services: current-version: "v2.18.0" upstream-source: https://github.com/kubernetes/kube-state-metrics/releases - - name: mosquitto - type: argocd - last-reviewed: 2026-02-16 - current-version: "2.0.22" - upstream-source: https://github.com/eclipse/mosquitto/releases - name: ntfy type: argocd From 6d4929a66cf150ed251c1155b705ddc86d3d9aad Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 11 Mar 2026 18:55:51 -0700 Subject: [PATCH 031/430] Add qwen3.5:27b to Ollama and bump memory limit to 22Gi The 27B Q4_K_M model is ~17 GB, exceeding the 16 GB VRAM on the RTX 4080 by ~1 GB. Ollama will offload a few layers to CPU RAM, so the pod memory limit needs headroom beyond the previous 16Gi. Co-Authored-By: Claude Opus 4.6 --- argocd/manifests/ollama/deployment.yaml | 2 +- argocd/manifests/ollama/models.txt | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/argocd/manifests/ollama/deployment.yaml b/argocd/manifests/ollama/deployment.yaml index 65f17c6..6d02ca3 100644 --- a/argocd/manifests/ollama/deployment.yaml +++ b/argocd/manifests/ollama/deployment.yaml @@ -40,7 +40,7 @@ spec: memory: "512Mi" cpu: "500m" limits: - memory: "16Gi" + memory: "22Gi" cpu: "4000m" nvidia.com/gpu: "1" livenessProbe: diff --git a/argocd/manifests/ollama/models.txt b/argocd/manifests/ollama/models.txt index dac83c5..856618d 100644 --- a/argocd/manifests/ollama/models.txt +++ b/argocd/manifests/ollama/models.txt @@ -5,3 +5,4 @@ deepseek-r1:14b phi4:14b gemma3:12b qwen3.5:9b +qwen3.5:27b From c26026f4e9010d2a51c1464264d86d16baaf4187 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 11 Mar 2026 20:33:22 -0700 Subject: [PATCH 032/430] Bump Ollama memory to 24Gi and enable flash attention The 27B Q4_K_M model needs ~7.3 GiB system RAM for CPU-offloaded layers but only 6.8 GiB was available within the 22Gi cgroup. Bumping to 24Gi and enabling flash attention (reduces KV cache memory) should provide enough headroom. Co-Authored-By: Claude Opus 4.6 --- argocd/manifests/ollama/deployment.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/argocd/manifests/ollama/deployment.yaml b/argocd/manifests/ollama/deployment.yaml index 6d02ca3..060fe8f 100644 --- a/argocd/manifests/ollama/deployment.yaml +++ b/argocd/manifests/ollama/deployment.yaml @@ -32,6 +32,8 @@ spec: value: "1" - name: OLLAMA_NUM_PARALLEL value: "1" + - name: OLLAMA_FLASH_ATTENTION + value: "1" volumeMounts: - name: models mountPath: /models @@ -40,7 +42,7 @@ spec: memory: "512Mi" cpu: "500m" limits: - memory: "22Gi" + memory: "24Gi" cpu: "4000m" nvidia.com/gpu: "1" livenessProbe: From 30fbb87c22b1cd24c8b3feea674966187f46935a Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Fri, 13 Mar 2026 15:32:09 -0700 Subject: [PATCH 033/430] Update ringtail flake inputs (nixpkgs, home-manager, disko) Co-Authored-By: Claude Opus 4.6 --- nixos/ringtail/flake.lock | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/nixos/ringtail/flake.lock b/nixos/ringtail/flake.lock index 9782206..98fbbf9 100644 --- a/nixos/ringtail/flake.lock +++ b/nixos/ringtail/flake.lock @@ -7,11 +7,11 @@ ] }, "locked": { - "lastModified": 1772699110, - "narHash": "sha256-jkyo/9fZVB3F/PHk3fVK1ImxJBZ71DCOYZvAz4R4v4E=", + "lastModified": 1773025010, + "narHash": "sha256-khlHllTsovXgT2GZ0WxT4+RvuMjNeR5OW0UYeEHPYQo=", "owner": "nix-community", "repo": "disko", - "rev": "42affa9d33750ac0a0a89761644af20d8d03e6ee", + "rev": "7b9f7f88ab3b339f8142dc246445abb3c370d3d3", "type": "github" }, "original": { @@ -27,11 +27,11 @@ ] }, "locked": { - "lastModified": 1772633058, - "narHash": "sha256-SO7JapRy2HPhgmqiLbfnW1kMx5rakPMKZ9z3wtRLQjI=", + "lastModified": 1773264488, + "narHash": "sha256-rK0507bDuWBrZo+0zts9bCs/+RRUEHuvFE5DHWPxX/Q=", "owner": "nix-community", "repo": "home-manager", - "rev": "080657a04188aca25f8a6c70a0fb2ea7e37f1865", + "rev": "5c0f63f8d55040a7eed69df7e3fcdd15dfb5a04c", "type": "github" }, "original": { @@ -43,11 +43,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1772598333, - "narHash": "sha256-YaHht/C35INEX3DeJQNWjNaTcPjYmBwwjFJ2jdtr+5U=", + "lastModified": 1773375660, + "narHash": "sha256-SEzUWw2Rf5Ki3bcM26nSKgbeoqi2uYy8IHVBqOKjX3w=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "fabb8c9deee281e50b1065002c9828f2cf7b2239", + "rev": "3e20095fe3c6cbb1ddcef89b26969a69a1570776", "type": "github" }, "original": { From 4c5e7d763d0cab8c6c2174f984a709d0762b75d5 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Fri, 13 Mar 2026 15:45:07 -0700 Subject: [PATCH 034/430] Review deploy-jobsync doc: add missing env var, update tag example Co-Authored-By: Claude Opus 4.6 --- docs/how-to/jobsync/deploy-jobsync.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/how-to/jobsync/deploy-jobsync.md b/docs/how-to/jobsync/deploy-jobsync.md index 0683a4b..325af62 100644 --- a/docs/how-to/jobsync/deploy-jobsync.md +++ b/docs/how-to/jobsync/deploy-jobsync.md @@ -1,6 +1,7 @@ --- title: Deploy JobSync -modified: 2026-03-08 +modified: 2026-03-13 +last-reviewed: 2026-03-13 tags: - how-to - jobsync @@ -37,6 +38,7 @@ All in `argocd/manifests/jobsync/`: | `ENCRYPTION_KEY` | ExternalSecret | AES-256-GCM for stored API keys | | `NEXTAUTH_URL` | Hardcoded | `https://jobsync.ops.eblu.me` | | `AUTH_TRUST_HOST` | Hardcoded | `true` | +| `NEXT_TELEMETRY_DISABLED` | Hardcoded | `1` (opt out of Next.js telemetry) | | `TZ` | Hardcoded | `America/Los_Angeles` | | `OLLAMA_BASE_URL` | Hardcoded | `http://ollama.ollama.svc.cluster.local:11434` | | `RAPIDAPI_KEY` | ExternalSecret | JSearch job search API key | @@ -44,7 +46,7 @@ All in `argocd/manifests/jobsync/`: ## Updating the Container 1. Build and push: `mise run container-release jobsync ` -2. Update `newTag` in `kustomization.yaml` to the full tag (e.g. `v1.1.4-e51ec83-nix`) +2. Update `newTag` in `kustomization.yaml` to the full tag (e.g. `v1.1.4-3a811fb-nix`) 3. Sync: `argocd app sync jobsync` See [[build-jobsync-container]] for nix build details. From ab8ea6f30181c3b7b5a189133f75feabc5bbfdc3 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Fri, 13 Mar 2026 16:25:27 -0700 Subject: [PATCH 035/430] Bump Grafana Alloy to v1.14.0 (#292) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Bump alloy-k8s, alloy-ringtail, and alloy-tracing-ringtail image tags from v1.13.1 to v1.14.0 - Mark indri alloy (ansible) as reviewed at v1.14.0 — source rebuild from forge mirror needed - Add missing alloy-ringtail entry to service-versions.yaml - Update alloy reference doc ## Breaking changes reviewed - `loki.secretfilter` options removed — not used in our configs - OTel Collector upgraded to v0.142.0 — Kafka receiver changes don't affect us - Exporter queue default changes — our tracing pipeline (Beyla → batch → otlphttp) uses simple config, low risk ## Deployment and Testing - [ ] Sync alloy-k8s: `argocd app set alloy-k8s --revision bump/alloy-v1.14.0 && argocd app sync alloy-k8s` - [ ] Sync alloy-ringtail: `argocd app set alloy-ringtail --revision bump/alloy-v1.14.0 --server ringtail-argocd && argocd app sync alloy-ringtail` - [ ] Sync alloy-tracing-ringtail similarly - [ ] Verify metrics flowing in Grafana - [ ] Verify traces flowing to Tempo (ringtail) - [ ] Rebuild indri alloy from source (`v1.14.0` tag on forge mirror), SCP to indri, restart - [ ] After merge: reset ArgoCD revisions to main, re-sync Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/292 --- ansible/roles/alloy/defaults/main.yml | 7 +++++-- argocd/manifests/alloy-k8s/kustomization.yaml | 2 +- .../alloy-ringtail/kustomization.yaml | 2 +- .../alloy-tracing-ringtail/kustomization.yaml | 2 +- docs/changelog.d/bump-alloy-v1.14.0.infra.md | 1 + docs/reference/services/alloy.md | 4 ++-- service-versions.yaml | 19 +++++++++++++------ 7 files changed, 24 insertions(+), 13 deletions(-) create mode 100644 docs/changelog.d/bump-alloy-v1.14.0.infra.md diff --git a/ansible/roles/alloy/defaults/main.yml b/ansible/roles/alloy/defaults/main.yml index 8954d87..fa840d4 100644 --- a/ansible/roles/alloy/defaults/main.yml +++ b/ansible/roles/alloy/defaults/main.yml @@ -13,7 +13,7 @@ # git clone ssh://forgejo@forge.ops.eblu.me:2222/mirrors/alloy.git ~/code/3rd/alloy # # 2. Set up build tools via mise: -# cd ~/code/3rd/alloy && mise use go@1.25 node yarn +# cd ~/code/3rd/alloy && mise use go@1.25.7 node yarn # # 3. Build with CGO enabled (default in Makefile): # cd ~/code/3rd/alloy && mise x -- make alloy @@ -21,7 +21,10 @@ # 4. Copy binary to indri: # scp ~/code/3rd/alloy/build/alloy indri:~/.local/bin/alloy # -# 5. Run ansible to deploy config and LaunchAgent +# 5. Ad-hoc codesign on indri (SCP'd binaries get quarantined by macOS): +# ssh indri 'codesign --sign - --force ~/.local/bin/alloy' +# +# 6. Run ansible to deploy config and LaunchAgent # Binary and paths alloy_binary: /Users/erichblume/.local/bin/alloy diff --git a/argocd/manifests/alloy-k8s/kustomization.yaml b/argocd/manifests/alloy-k8s/kustomization.yaml index 1d43d8f..6209a0b 100644 --- a/argocd/manifests/alloy-k8s/kustomization.yaml +++ b/argocd/manifests/alloy-k8s/kustomization.yaml @@ -10,7 +10,7 @@ resources: images: - name: grafana/alloy - newTag: v1.13.1 + newTag: v1.14.0 configMapGenerator: - name: alloy-config diff --git a/argocd/manifests/alloy-ringtail/kustomization.yaml b/argocd/manifests/alloy-ringtail/kustomization.yaml index 1d43d8f..6209a0b 100644 --- a/argocd/manifests/alloy-ringtail/kustomization.yaml +++ b/argocd/manifests/alloy-ringtail/kustomization.yaml @@ -10,7 +10,7 @@ resources: images: - name: grafana/alloy - newTag: v1.13.1 + newTag: v1.14.0 configMapGenerator: - name: alloy-config diff --git a/argocd/manifests/alloy-tracing-ringtail/kustomization.yaml b/argocd/manifests/alloy-tracing-ringtail/kustomization.yaml index 6956f14..fec545b 100644 --- a/argocd/manifests/alloy-tracing-ringtail/kustomization.yaml +++ b/argocd/manifests/alloy-tracing-ringtail/kustomization.yaml @@ -9,7 +9,7 @@ resources: images: - name: grafana/alloy - newTag: v1.13.1 + newTag: v1.14.0 configMapGenerator: - name: alloy-tracing-config diff --git a/docs/changelog.d/bump-alloy-v1.14.0.infra.md b/docs/changelog.d/bump-alloy-v1.14.0.infra.md new file mode 100644 index 0000000..2bd5caa --- /dev/null +++ b/docs/changelog.d/bump-alloy-v1.14.0.infra.md @@ -0,0 +1 @@ +Bump Grafana Alloy to v1.14.0 across all deployments (indri, alloy-k8s, alloy-ringtail, alloy-tracing-ringtail) diff --git a/docs/reference/services/alloy.md b/docs/reference/services/alloy.md index d946be7..d781f2f 100644 --- a/docs/reference/services/alloy.md +++ b/docs/reference/services/alloy.md @@ -1,6 +1,6 @@ --- title: Alloy -modified: 2026-02-08 +modified: 2026-03-13 tags: - service - observability @@ -20,7 +20,7 @@ Unified observability collector for metrics and logs with three deployments: | **Indri Binary** | `~/.local/bin/alloy` | | **Indri Config** | `~/.config/grafana-alloy/config.alloy` | | **K8s Namespace** | `alloy` | -| **K8s Image** | `grafana/alloy:v1.8.2` | +| **K8s Image** | `grafana/alloy:v1.14.0` | | **ArgoCD App** | `alloy-k8s` | | **Fly.io Config** | `fly/alloy.river` | | **Fly.io Image** | `grafana/alloy:v1.5.1` (binary copied into nginx container) | diff --git a/service-versions.yaml b/service-versions.yaml index 83daab1..f060499 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -71,15 +71,22 @@ services: - name: alloy-tracing-ringtail type: argocd - last-reviewed: 2026-03-05 - current-version: "v1.13.1" + last-reviewed: 2026-03-13 + current-version: "v1.14.0" upstream-source: https://github.com/grafana/alloy/releases notes: Privileged DaemonSet with Beyla eBPF for HTTP tracing on ringtail + - name: alloy-ringtail + type: argocd + last-reviewed: 2026-03-13 + current-version: "v1.14.0" + upstream-source: https://github.com/grafana/alloy/releases + notes: DaemonSet on ringtail for host metrics and pod logs + - name: alloy-k8s type: argocd - last-reviewed: 2026-02-16 - current-version: "v1.13.1" + last-reviewed: 2026-03-13 + current-version: "v1.14.0" upstream-source: https://github.com/grafana/alloy/releases - name: tailscale-operator @@ -255,8 +262,8 @@ services: - name: alloy type: ansible - last-reviewed: null - current-version: null + last-reviewed: 2026-03-13 + current-version: "v1.14.0" upstream-source: https://github.com/grafana/alloy/releases notes: Built from source on indri From 53d620365a9d5df9cbf8d927290eeb16b5017e46 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sat, 14 Mar 2026 10:00:40 -0700 Subject: [PATCH 036/430] Bump zot registry to v2.1.15 (#293) ## Summary - Upgrade zot OCI registry from v2.1.13 to v2.1.15 on indri - Addresses CVE-2025-30204 (golang-jwt memory) and open redirect via callback_ui - No config template changes needed (externalUrl is auto-allowlisted) - Requires Go 1.25.7 (bump from 1.25.6 via mise) ## Data Safety - Data directory ~/erichblume/zot is NOT touched during build or deploy - No schema migrations in v2.1.14 or v2.1.15 - Storage format remains OCI spec 1.1.0 ## Deployment Steps - [ ] SSH to indri: bump Go to 1.25.7 via `mise use go@1.25.7` - [ ] Fetch and checkout v2.1.15 in ~/code/3rd/zot - [ ] Build: `mise x -- make binary` - [ ] Restart LaunchAgent - [ ] Verify: `curl -s http://localhost:5050/v2/` returns 200 - [ ] Verify: `curl -s https://registry.ops.eblu.me/v2/_catalog` lists repos - [ ] Verify: `mise run services-check` Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/293 --- ansible/roles/zot/templates/zot.plist.j2 | 5 +++++ docs/changelog.d/bump-zot-v2.1.15.infra.md | 1 + docs/reference/services/zot.md | 2 +- service-versions.yaml | 4 ++-- 4 files changed, 9 insertions(+), 3 deletions(-) create mode 100644 docs/changelog.d/bump-zot-v2.1.15.infra.md diff --git a/ansible/roles/zot/templates/zot.plist.j2 b/ansible/roles/zot/templates/zot.plist.j2 index 25b7da1..b777fb8 100644 --- a/ansible/roles/zot/templates/zot.plist.j2 +++ b/ansible/roles/zot/templates/zot.plist.j2 @@ -16,6 +16,11 @@ KeepAlive + EnvironmentVariables + + PATH + /usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin + StandardOutPath {{ zot_log_dir }}/mcquack.zot.out.log StandardErrorPath diff --git a/docs/changelog.d/bump-zot-v2.1.15.infra.md b/docs/changelog.d/bump-zot-v2.1.15.infra.md new file mode 100644 index 0000000..67e5ccd --- /dev/null +++ b/docs/changelog.d/bump-zot-v2.1.15.infra.md @@ -0,0 +1 @@ +Upgrade zot container registry from v2.1.13 to v2.1.15 (CVE-2025-30204, open redirect fix). Fix trivy CVE DB downloads by adding /usr/local/bin to LaunchAgent PATH. diff --git a/docs/reference/services/zot.md b/docs/reference/services/zot.md index c113695..c309557 100644 --- a/docs/reference/services/zot.md +++ b/docs/reference/services/zot.md @@ -1,6 +1,6 @@ --- title: Zot -modified: 2026-02-21 +modified: 2026-03-14 tags: - service - registry diff --git a/service-versions.yaml b/service-versions.yaml index f060499..7d03295 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -269,8 +269,8 @@ services: - name: zot type: ansible - last-reviewed: null - current-version: null + last-reviewed: 2026-03-14 + current-version: "v2.1.15" upstream-source: https://github.com/project-zot/zot/releases notes: Built from source on indri From 8b3b17d555a631083fe6a2002367fe15bed795a9 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sat, 14 Mar 2026 10:09:38 -0700 Subject: [PATCH 037/430] Review restart-indri doc: fix Caddy/Jellyfin service management, fix docs-preview path handling - Caddy is now a mcquack LaunchAgent, not brew services - Add missing Jellyfin and Caddy to shutdown commands and autostart list - docs-preview: accept paths with or without docs/ prefix Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/how-to/operations/restart-indri.md | 7 +++++-- mise-tasks/docs-preview | 13 ++++++++++--- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/docs/how-to/operations/restart-indri.md b/docs/how-to/operations/restart-indri.md index 4a3944f..768ec9a 100644 --- a/docs/how-to/operations/restart-indri.md +++ b/docs/how-to/operations/restart-indri.md @@ -1,6 +1,7 @@ --- title: Restart Indri -modified: 2026-02-10 +modified: 2026-03-14 +last-reviewed: 2026-03-14 tags: - how-to - operations @@ -40,7 +41,9 @@ Native services managed by launchd will stop automatically during macOS shutdown ssh indri 'brew services stop forgejo' # LaunchAgent services +ssh indri 'launchctl unload ~/Library/LaunchAgents/mcquack.eblume.caddy.plist' ssh indri 'launchctl unload ~/Library/LaunchAgents/mcquack.eblume.zot.plist' +ssh indri 'launchctl unload ~/Library/LaunchAgents/mcquack.jellyfin.plist' ssh indri 'launchctl unload ~/Library/LaunchAgents/mcquack.eblume.alloy.plist' ssh indri 'launchctl unload ~/Library/LaunchAgents/mcquack.eblume.borgmatic.plist' ``` @@ -65,7 +68,7 @@ Or if you're at the console, use the Apple menu. After indri boots, most services recover automatically. Only a few things need manual attention. -**What autostarts:** Docker Desktop, brew services (Forgejo, Caddy), and all mcquack LaunchAgent services (Zot, Alloy, Borgmatic, metrics collectors). +**What autostarts:** Docker Desktop, brew services (Forgejo), and all mcquack LaunchAgent services (Caddy, Zot, Jellyfin, Alloy, Borgmatic, metrics collectors). **What needs manual action:** Amphetamine, AutoMounter, and minikube (including its Tailscale serve port). diff --git a/mise-tasks/docs-preview b/mise-tasks/docs-preview index 38c14c3..d9b90ab 100755 --- a/mise-tasks/docs-preview +++ b/mise-tasks/docs-preview @@ -50,9 +50,16 @@ def main( # Normalize: accept with or without .md suffix card_stem = card.removesuffix(".md") - card_file = REPO_ROOT / "docs" / f"{card_stem}.md" - if not card_file.exists(): - console.print(f"[bold red]Card not found:[/bold red] {card_file}") + # Try exact path first (e.g. "docs/how-to/..."), then inside docs/ + exact_file = REPO_ROOT / f"{card_stem}.md" + docs_file = REPO_ROOT / "docs" / f"{card_stem}.md" + if exact_file.exists() and card_stem.startswith("docs/"): + card_stem = card_stem.removeprefix("docs/") + card_file = exact_file + elif docs_file.exists(): + card_file = docs_file + else: + console.print(f"[bold red]Card not found:[/bold red] {docs_file}") raise typer.Exit(code=1) url_path = "/" + card_stem From cb95db0bc995eba5b840b591bd0ffddf21f1cd94 Mon Sep 17 00:00:00 2001 From: Forgejo Actions Date: Sat, 14 Mar 2026 10:11:06 -0700 Subject: [PATCH 038/430] Update docs release to v1.14.1 - Built changelog from towncrier fragments [skip ci] --- CHANGELOG.md | 18 ++++++++++++++++++ argocd/manifests/docs/deployment.yaml | 2 +- docs/changelog.d/+docs-preview.feature.md | 1 - docs/changelog.d/+remove-mosquitto.infra.md | 1 - .../+run-1password-backup-howto.doc.md | 1 - docs/changelog.d/bump-alloy-v1.14.0.infra.md | 1 - docs/changelog.d/bump-zot-v2.1.15.infra.md | 1 - docs/changelog.d/review-jobsync.infra.md | 1 - 8 files changed, 19 insertions(+), 7 deletions(-) delete mode 100644 docs/changelog.d/+docs-preview.feature.md delete mode 100644 docs/changelog.d/+remove-mosquitto.infra.md delete mode 100644 docs/changelog.d/+run-1password-backup-howto.doc.md delete mode 100644 docs/changelog.d/bump-alloy-v1.14.0.infra.md delete mode 100644 docs/changelog.d/bump-zot-v2.1.15.infra.md delete mode 100644 docs/changelog.d/review-jobsync.infra.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c8833d..d7501c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [v1.14.1] - 2026-03-14 + +### Features + +- Add `docs-preview` mise task: builds docs with Dagger and serves them locally in the production quartz container, opening the browser directly to the specified card. Also adds visual preview hints to the `docs-review` checklist and the review-documentation how-to. + +### Infrastructure + +- Add jobsync to services-check and homepage dashboard; mark as reviewed at v1.1.4 +- Bump Grafana Alloy to v1.14.0 across all deployments (indri, alloy-k8s, alloy-ringtail, alloy-tracing-ringtail) +- Upgrade zot container registry from v2.1.13 to v2.1.15 (CVE-2025-30204, open redirect fix). Fix trivy CVE DB downloads by adding /usr/local/bin to LaunchAgent PATH. +- Remove Mosquitto (MQTT broker) — unused since frigate-notify switched to webapi polling. Deleted ArgoCD app, k8s manifests, namespace, and updated all docs. + +### Documentation + +- Add how-to card for running the 1Password backup (`mise run op-backup`), with bidirectional links to restore procedure and service reference. + + ## [v1.14.0] - 2026-03-09 ### Features diff --git a/argocd/manifests/docs/deployment.yaml b/argocd/manifests/docs/deployment.yaml index 05811ba..a38061d 100644 --- a/argocd/manifests/docs/deployment.yaml +++ b/argocd/manifests/docs/deployment.yaml @@ -27,7 +27,7 @@ spec: name: http env: - name: DOCS_RELEASE_URL - value: "https://forge.eblu.me/eblume/blumeops/releases/download/v1.14.0/docs-v1.14.0.tar.gz" + value: "https://forge.eblu.me/eblume/blumeops/releases/download/v1.14.1/docs-v1.14.1.tar.gz" resources: requests: memory: "64Mi" diff --git a/docs/changelog.d/+docs-preview.feature.md b/docs/changelog.d/+docs-preview.feature.md deleted file mode 100644 index f865783..0000000 --- a/docs/changelog.d/+docs-preview.feature.md +++ /dev/null @@ -1 +0,0 @@ -Add `docs-preview` mise task: builds docs with Dagger and serves them locally in the production quartz container, opening the browser directly to the specified card. Also adds visual preview hints to the `docs-review` checklist and the review-documentation how-to. diff --git a/docs/changelog.d/+remove-mosquitto.infra.md b/docs/changelog.d/+remove-mosquitto.infra.md deleted file mode 100644 index 9b452cf..0000000 --- a/docs/changelog.d/+remove-mosquitto.infra.md +++ /dev/null @@ -1 +0,0 @@ -Remove Mosquitto (MQTT broker) — unused since frigate-notify switched to webapi polling. Deleted ArgoCD app, k8s manifests, namespace, and updated all docs. diff --git a/docs/changelog.d/+run-1password-backup-howto.doc.md b/docs/changelog.d/+run-1password-backup-howto.doc.md deleted file mode 100644 index 907d9eb..0000000 --- a/docs/changelog.d/+run-1password-backup-howto.doc.md +++ /dev/null @@ -1 +0,0 @@ -Add how-to card for running the 1Password backup (`mise run op-backup`), with bidirectional links to restore procedure and service reference. diff --git a/docs/changelog.d/bump-alloy-v1.14.0.infra.md b/docs/changelog.d/bump-alloy-v1.14.0.infra.md deleted file mode 100644 index 2bd5caa..0000000 --- a/docs/changelog.d/bump-alloy-v1.14.0.infra.md +++ /dev/null @@ -1 +0,0 @@ -Bump Grafana Alloy to v1.14.0 across all deployments (indri, alloy-k8s, alloy-ringtail, alloy-tracing-ringtail) diff --git a/docs/changelog.d/bump-zot-v2.1.15.infra.md b/docs/changelog.d/bump-zot-v2.1.15.infra.md deleted file mode 100644 index 67e5ccd..0000000 --- a/docs/changelog.d/bump-zot-v2.1.15.infra.md +++ /dev/null @@ -1 +0,0 @@ -Upgrade zot container registry from v2.1.13 to v2.1.15 (CVE-2025-30204, open redirect fix). Fix trivy CVE DB downloads by adding /usr/local/bin to LaunchAgent PATH. diff --git a/docs/changelog.d/review-jobsync.infra.md b/docs/changelog.d/review-jobsync.infra.md deleted file mode 100644 index dd221ab..0000000 --- a/docs/changelog.d/review-jobsync.infra.md +++ /dev/null @@ -1 +0,0 @@ -Add jobsync to services-check and homepage dashboard; mark as reviewed at v1.1.4 From 4d195f7fb4c435b3760627bc5c3379f38f07fb1f Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sun, 15 Mar 2026 10:12:53 -0700 Subject: [PATCH 039/430] Review restore-1password-backup doc: fix offsite TBD, clarify archive name, add BorgBase to backups Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/how-to/operations/restore-1password-backup.md | 9 +++++++-- docs/reference/storage/backups.md | 9 ++++++--- mise-tasks/op-backup | 2 +- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/docs/how-to/operations/restore-1password-backup.md b/docs/how-to/operations/restore-1password-backup.md index b839b10..7b89004 100644 --- a/docs/how-to/operations/restore-1password-backup.md +++ b/docs/how-to/operations/restore-1password-backup.md @@ -1,6 +1,7 @@ --- title: Restore 1Password Backup -modified: 2026-02-10 +modified: 2026-03-15 +last-reviewed: 2026-03-15 tags: - how-to - operations @@ -13,7 +14,7 @@ How to recover a 1Password `.1pux` export from a [[borgmatic]] backup. This proc ## Prerequisites -- A copy of the borg backup repository (from [[sifaka]], or an off-site copy — TBD) +- A copy of the borg backup repository (from [[sifaka]], or the BorgBase offsite repo) - `borg`, `age`, and `openssl` installed on any machine - Your **1Password Emergency Kit** (fire safety box) — contains the master password and secret key - The borg repo passphrase (printed on the Emergency Kit, or from `/Users/erichblume/.borg/config.yaml` if [[indri]] is accessible) @@ -30,7 +31,11 @@ If you have direct access to the borg repository (e.g. mounted from [[sifaka]] o ```bash mkdir -p /tmp/op-restore && cd /tmp/op-restore + +# List recent archives — pick one from the output (e.g. "indri-2026-03-15T02:00:07") BORG_PASSPHRASE="" borg list /path/to/borg/repo --last 5 + +# Extract using the archive name from the list above BORG_PASSPHRASE="" borg extract \ "/path/to/borg/repo::" \ Users/erichblume/Documents/1password-backup/ diff --git a/docs/reference/storage/backups.md b/docs/reference/storage/backups.md index 211eb5b..7061d2c 100644 --- a/docs/reference/storage/backups.md +++ b/docs/reference/storage/backups.md @@ -1,6 +1,6 @@ --- title: Backups -modified: 2026-02-10 +modified: 2026-03-15 tags: - storage - backup @@ -55,9 +55,12 @@ Some data lives directly on [[sifaka]] rather than being backed up to it (photos | Monthly | 12 backups | | Yearly | 1000 backups | -## Backup Target +## Backup Targets -Repository: `/Volumes/backups/borg/` on [[sifaka|Sifaka]] +| Repository | Location | Label | +|------------|----------|-------| +| `/Volumes/backups/borg/` | [[sifaka]] (local NAS) | — | +| `ssh://u3ugi1x1@u3ugi1x1.repo.borgbase.com/./repo` | BorgBase (offsite) | `borgbase-offsite` | ## Monitoring diff --git a/mise-tasks/op-backup b/mise-tasks/op-backup index 4b5b660..0b486cc 100755 --- a/mise-tasks/op-backup +++ b/mise-tasks/op-backup @@ -315,7 +315,7 @@ def main() -> int: console.print() console.print("[bold]DISASTER RECOVERY:[/bold]") console.print(f" 1. Restore borgmatic archive containing {REMOTE_DIR}/") - console.print(" 2. Retrieve Emergency Kit from safety deposit box") + console.print(" 2. Retrieve Emergency Kit from fire safety box") console.print(f" 3. openssl enc -d -aes-256-cbc -pbkdf2 < ...key.enc > key.txt") console.print(" Passphrase: {master_password}:{secret_key}") console.print(f" 4. age -d -i key.txt < ...age > export.1pux") From 272ea1e767d808d66121d131e34fc1c109471736 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sun, 15 Mar 2026 10:33:48 -0700 Subject: [PATCH 040/430] =?UTF-8?q?Upgrade=20Caddy=20v2.10.2=20=E2=86=92?= =?UTF-8?q?=20v2.11.2,=20fix=20forge=20mirrors=20(#294)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Upgrade Caddy from v2.10.2 to v2.11.2 (7 CVE fixes across v2.11.1 and v2.11.2) - Create `mirrors/caddy-l4` forge mirror for Layer 4 plugin - Migrate all `~/code/3rd` clones on indri from `localhost:3001` to HTTPS `forge.ops.eblu.me/mirrors/` remotes - Remove stale clones (`apple-silicon-detector`, `whisper.cpp`) - Update caddy docs and service-versions tracking ## CVEs Fixed - CVE-2026-27585 through CVE-2026-27590 (path/host bypass, TLS fail-open, FastCGI issues) - Forward auth identity injection (privilege escalation) - `vars_regexp` placeholder secret exposure - Built on Go 1.26.1 (patches Go-level CVEs) ## What was done on indri (not in repo) - `xcaddy build` with Gandi DNS + Layer 4 plugins → `~/code/3rd/caddy/bin/caddy` now v2.11.2 - Remotes updated: caddy, forgejo-runner, zot → `https://forge.ops.eblu.me/mirrors/*.git` - Deleted: `~/code/3rd/apple-silicon-detector`, `~/code/3rd/whisper.cpp` ## Deployment and Testing - [x] Ansible dry-run passed (`--tags caddy --check --diff`) - [ ] Restart caddy LaunchAgent to pick up the new binary - [ ] Verify all proxied services respond via `*.ops.eblu.me` - [ ] Run `mise run services-check` Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/294 --- ansible/roles/caddy/defaults/main.yml | 2 +- .../feature-caddy-upgrade-v2.11.2.infra.md | 1 + docs/reference/services/caddy.md | 14 ++++++++++---- service-versions.yaml | 6 +++--- 4 files changed, 15 insertions(+), 8 deletions(-) create mode 100644 docs/changelog.d/feature-caddy-upgrade-v2.11.2.infra.md diff --git a/ansible/roles/caddy/defaults/main.yml b/ansible/roles/caddy/defaults/main.yml index 931e2a0..a9576a1 100644 --- a/ansible/roles/caddy/defaults/main.yml +++ b/ansible/roles/caddy/defaults/main.yml @@ -1,6 +1,6 @@ --- # Caddy reverse proxy configuration -# Caddy is built manually from ~/code/3rd/caddy with the Gandi DNS plugin +# Caddy is built from ~/code/3rd/caddy with Gandi DNS and Layer 4 plugins caddy_repo_dir: /Users/erichblume/code/3rd/caddy caddy_binary: "{{ caddy_repo_dir }}/bin/caddy" diff --git a/docs/changelog.d/feature-caddy-upgrade-v2.11.2.infra.md b/docs/changelog.d/feature-caddy-upgrade-v2.11.2.infra.md new file mode 100644 index 0000000..f0f213f --- /dev/null +++ b/docs/changelog.d/feature-caddy-upgrade-v2.11.2.infra.md @@ -0,0 +1 @@ +Upgrade Caddy from v2.10.2 to v2.11.2 (7 CVE fixes), create caddy-l4 forge mirror, migrate all ~/code/3rd clones on indri to HTTPS forge.ops.eblu.me remotes. diff --git a/docs/reference/services/caddy.md b/docs/reference/services/caddy.md index c6e5e4f..8896a86 100644 --- a/docs/reference/services/caddy.md +++ b/docs/reference/services/caddy.md @@ -1,6 +1,6 @@ --- title: Caddy -modified: 2026-02-12 +modified: 2026-03-15 tags: - service - networking @@ -87,14 +87,20 @@ Caddy has no authentication layer — it is a plain reverse proxy. Access contro ## Custom Build -Caddy is built from source with the Gandi DNS plugin: +Caddy is built from source using `xcaddy` with two plugins: + +- `github.com/caddy-dns/gandi` — ACME DNS-01 challenges via Gandi API +- `github.com/mholt/caddy-l4` — Layer 4 (TCP/UDP) proxying ```bash -# Build location +# Source and build location (mirrored on forge) ~/code/3rd/caddy/bin/caddy + +# Build via mise task in the caddy clone +cd ~/code/3rd/caddy && mise run build ``` -The build includes the `github.com/caddy-dns/gandi` plugin for ACME DNS-01 challenges. +Forge mirrors: `mirrors/caddy`, `mirrors/caddy-gandi`, `mirrors/xcaddy`, `mirrors/caddy-l4`. ## Related diff --git a/service-versions.yaml b/service-versions.yaml index 7d03295..85705cc 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -276,10 +276,10 @@ services: - name: caddy type: ansible - last-reviewed: null - current-version: null + last-reviewed: 2026-03-15 + current-version: "v2.11.2" upstream-source: https://github.com/caddyserver/caddy/releases - notes: Built from source with Gandi DNS plugin + notes: Built from source with Gandi DNS and Layer 4 plugins - name: borgmatic type: ansible From 2bea048dbfefabd9eccfe5b7e59821c3f3741f40 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sun, 15 Mar 2026 17:44:35 -0700 Subject: [PATCH 041/430] Externalize Tailscale operator to forge mirror (#295) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Mirrors `tailscale/tailscale` on forge (`mirrors/tailscale`) - Replaces vendored `operator.yaml` (495 KB / 5,386 lines) with ArgoCD apps sourcing the upstream static manifest, pinned via `targetRevision: v1.94.2` - Adds `tailscale-operator-base` app for indri and `tailscale-operator-base-ringtail` for ringtail - Local kustomization retains only ProxyClass and DNSConfig custom resources - Updates `[[tailscale-operator]]` doc to reflect new sourcing ## Deployment and Testing - [ ] Register `mirrors/tailscale` repo in ArgoCD (it needs to know about the new repo) - [ ] Sync `apps` app to pick up the new `tailscale-operator-base` app definitions - [ ] Sync `tailscale-operator-base` — verify CRDs, RBAC, operator Deployment come up - [ ] Sync `tailscale-operator` — verify ProxyClass, DNSConfig still apply cleanly - [ ] Verify existing Tailscale Ingresses still work (ProxyGroup pods healthy) - [ ] Repeat for ringtail cluster - [ ] After merge: apps already point at tags, no revision reset needed Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/295 --- .../kustomization.yaml | 24 +- .../tailscale-operator-base/operator.yaml | 5386 ----------------- .../tailscale-operator-base/proxyclass.yaml | 3 +- ...ternalize-tailscale-operator-base.infra.md | 1 + .../kubernetes/tailscale-operator.md | 4 +- 5 files changed, 23 insertions(+), 5395 deletions(-) delete mode 100644 argocd/manifests/tailscale-operator-base/operator.yaml create mode 100644 docs/changelog.d/externalize-tailscale-operator-base.infra.md diff --git a/argocd/manifests/tailscale-operator-base/kustomization.yaml b/argocd/manifests/tailscale-operator-base/kustomization.yaml index 54750fa..4519af6 100644 --- a/argocd/manifests/tailscale-operator-base/kustomization.yaml +++ b/argocd/manifests/tailscale-operator-base/kustomization.yaml @@ -4,15 +4,27 @@ kind: Kustomization namespace: tailscale +# Upstream Tailscale operator manifest from forge mirror. +# To upgrade: update the ref in the URL AND the newTag below. resources: - - operator.yaml + - https://forge.eblu.me/mirrors/tailscale/raw/tag/v1.94.2/cmd/k8s-operator/deploy/manifests/operator.yaml - proxyclass.yaml - dnsconfig.yaml -# NOTE: also update proxyclass.yaml when changing the Tailscale version. -# The kustomize images transformer only processes standard k8s container specs -# (Deployments, StatefulSets, etc.), not CRD fields like ProxyClass, so -# proxyclass.yaml tags must be updated manually. images: - - name: docker.io/tailscale/k8s-operator + - name: tailscale/k8s-operator + newName: docker.io/tailscale/k8s-operator newTag: v1.94.2 + +# The upstream manifest includes a placeholder OAuth Secret with empty values. +# We manage this secret via ExternalSecret, so drop the upstream copy. +patches: + - target: + kind: Secret + name: operator-oauth + patch: | + $patch: delete + apiVersion: v1 + kind: Secret + metadata: + name: operator-oauth diff --git a/argocd/manifests/tailscale-operator-base/operator.yaml b/argocd/manifests/tailscale-operator-base/operator.yaml deleted file mode 100644 index 644bf9a..0000000 --- a/argocd/manifests/tailscale-operator-base/operator.yaml +++ /dev/null @@ -1,5386 +0,0 @@ -# Copyright (c) Tailscale Inc & AUTHORS -# SPDX-License-Identifier: BSD-3-Clause - -apiVersion: v1 -kind: Namespace -metadata: - name: tailscale ---- -apiVersion: v1 -kind: ServiceAccount -metadata: - name: operator - namespace: tailscale ---- -apiVersion: v1 -kind: ServiceAccount -metadata: - name: proxies - namespace: tailscale ---- -apiVersion: apiextensions.k8s.io/v1 -kind: CustomResourceDefinition -metadata: - annotations: - controller-gen.kubebuilder.io/version: v0.17.0 - name: connectors.tailscale.com -spec: - group: tailscale.com - names: - kind: Connector - listKind: ConnectorList - plural: connectors - shortNames: - - cn - singular: connector - scope: Cluster - versions: - - additionalPrinterColumns: - - description: CIDR ranges exposed to tailnet by a subnet router defined via this Connector instance. - jsonPath: .status.subnetRoutes - name: SubnetRoutes - type: string - - description: Whether this Connector instance defines an exit node. - jsonPath: .status.isExitNode - name: IsExitNode - type: string - - description: Whether this Connector instance is an app connector. - jsonPath: .status.isAppConnector - name: IsAppConnector - type: string - - description: Status of the deployed Connector resources. - jsonPath: .status.conditions[?(@.type == "ConnectorReady")].reason - name: Status - type: string - - jsonPath: .metadata.creationTimestamp - name: Age - type: date - name: v1alpha1 - schema: - openAPIV3Schema: - description: |- - Connector defines a Tailscale node that will be deployed in the cluster. The - node can be configured to act as a Tailscale subnet router and/or a Tailscale - exit node. - Connector is a cluster-scoped resource. - More info: - https://tailscale.com/kb/1441/kubernetes-operator-connector - properties: - apiVersion: - description: |- - APIVersion defines the versioned schema of this representation of an object. - Servers should convert recognized schemas to the latest internal value, and - may reject unrecognized values. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources - type: string - kind: - description: |- - Kind is a string value representing the REST resource this object represents. - Servers may infer this from the endpoint the client submits requests to. - Cannot be updated. - In CamelCase. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds - type: string - metadata: - type: object - spec: - description: |- - ConnectorSpec describes the desired Tailscale component. - More info: - https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status - properties: - appConnector: - description: |- - AppConnector defines whether the Connector device should act as a Tailscale app connector. A Connector that is - configured as an app connector cannot be a subnet router or an exit node. If this field is unset, the - Connector does not act as an app connector. - Note that you will need to manually configure the permissions and the domains for the app connector via the - Admin panel. - Note also that the main tested and supported use case of this config option is to deploy an app connector on - Kubernetes to access SaaS applications available on the public internet. Using the app connector to expose - cluster workloads or other internal workloads to tailnet might work, but this is not a use case that we have - tested or optimised for. - If you are using the app connector to access SaaS applications because you need a predictable egress IP that - can be whitelisted, it is also your responsibility to ensure that cluster traffic from the connector flows - via that predictable IP, for example by enforcing that cluster egress traffic is routed via an egress NAT - device with a static IP address. - https://tailscale.com/kb/1281/app-connectors - properties: - routes: - description: |- - Routes are optional preconfigured routes for the domains routed via the app connector. - If not set, routes for the domains will be discovered dynamically. - If set, the app connector will immediately be able to route traffic using the preconfigured routes, but may - also dynamically discover other routes. - https://tailscale.com/kb/1332/apps-best-practices#preconfiguration - items: - format: cidr - type: string - minItems: 1 - type: array - type: object - exitNode: - description: |- - ExitNode defines whether the Connector device should act as a Tailscale exit node. Defaults to false. - This field is mutually exclusive with the appConnector field. - https://tailscale.com/kb/1103/exit-nodes - type: boolean - hostname: - description: |- - Hostname is the tailnet hostname that should be assigned to the - Connector node. If unset, hostname defaults to -connector. Hostname can contain lower case letters, numbers and - dashes, it must not start or end with a dash and must be between 2 - and 63 characters long. This field should only be used when creating a connector - with an unspecified number of replicas, or a single replica. - pattern: ^[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$ - type: string - hostnamePrefix: - description: |- - HostnamePrefix specifies the hostname prefix for each - replica. Each device will have the integer number - from its StatefulSet pod appended to this prefix to form the full hostname. - HostnamePrefix can contain lower case letters, numbers and dashes, it - must not start with a dash and must be between 1 and 62 characters long. - pattern: ^[a-z0-9][a-z0-9-]{0,61}$ - type: string - proxyClass: - description: |- - ProxyClass is the name of the ProxyClass custom resource that - contains configuration options that should be applied to the - resources created for this Connector. If unset, the operator will - create resources with the default configuration. - type: string - replicas: - description: |- - Replicas specifies how many devices to create. Set this to enable - high availability for app connectors, subnet routers, or exit nodes. - https://tailscale.com/kb/1115/high-availability. Defaults to 1. - format: int32 - minimum: 0 - type: integer - subnetRouter: - description: |- - SubnetRouter defines subnet routes that the Connector device should - expose to tailnet as a Tailscale subnet router. - https://tailscale.com/kb/1019/subnets/ - If this field is unset, the device does not get configured as a Tailscale subnet router. - This field is mutually exclusive with the appConnector field. - properties: - advertiseRoutes: - description: |- - AdvertiseRoutes refer to CIDRs that the subnet router should make - available. Route values must be strings that represent a valid IPv4 - or IPv6 CIDR range. Values can be Tailscale 4via6 subnet routes. - https://tailscale.com/kb/1201/4via6-subnets/ - items: - format: cidr - type: string - minItems: 1 - type: array - required: - - advertiseRoutes - type: object - tags: - description: |- - Tags that the Tailscale node will be tagged with. - Defaults to [tag:k8s]. - To autoapprove the subnet routes or exit node defined by a Connector, - you can configure Tailscale ACLs to give these tags the necessary - permissions. - See https://tailscale.com/kb/1337/acl-syntax#autoapprovers. - If you specify custom tags here, you must also make the operator an owner of these tags. - See https://tailscale.com/kb/1236/kubernetes-operator/#setting-up-the-kubernetes-operator. - Tags cannot be changed once a Connector node has been created. - Tag values must be in form ^tag:[a-zA-Z][a-zA-Z0-9-]*$. - items: - pattern: ^tag:[a-zA-Z][a-zA-Z0-9-]*$ - type: string - type: array - type: object - x-kubernetes-validations: - - message: A Connector needs to have at least one of exit node, subnet router or app connector configured. - rule: has(self.subnetRouter) || (has(self.exitNode) && self.exitNode == true) || has(self.appConnector) - - message: The appConnector field is mutually exclusive with exitNode and subnetRouter fields. - rule: '!((has(self.subnetRouter) || (has(self.exitNode) && self.exitNode == true)) && has(self.appConnector))' - - message: The hostname field cannot be specified when replicas is greater than 1. - rule: '!(has(self.hostname) && has(self.replicas) && self.replicas > 1)' - - message: The hostname and hostnamePrefix fields are mutually exclusive. - rule: '!(has(self.hostname) && has(self.hostnamePrefix))' - status: - description: |- - ConnectorStatus describes the status of the Connector. This is set - and managed by the Tailscale operator. - properties: - conditions: - description: |- - List of status conditions to indicate the status of the Connector. - Known condition types are `ConnectorReady`. - items: - description: Condition contains details for one aspect of the current state of this API Resource. - properties: - lastTransitionTime: - description: |- - lastTransitionTime is the last time the condition transitioned from one status to another. - This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. - format: date-time - type: string - message: - description: |- - message is a human readable message indicating details about the transition. - This may be an empty string. - maxLength: 32768 - type: string - observedGeneration: - description: |- - observedGeneration represents the .metadata.generation that the condition was set based upon. - For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date - with respect to the current state of the instance. - format: int64 - minimum: 0 - type: integer - reason: - description: |- - reason contains a programmatic identifier indicating the reason for the condition's last transition. - Producers of specific condition types may define expected values and meanings for this field, - and whether the values are considered a guaranteed API. - The value should be a CamelCase string. - This field may not be empty. - maxLength: 1024 - minLength: 1 - pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ - type: string - status: - description: status of the condition, one of True, False, Unknown. - enum: - - "True" - - "False" - - Unknown - type: string - type: - description: type of condition in CamelCase or in foo.example.com/CamelCase. - maxLength: 316 - pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ - type: string - required: - - lastTransitionTime - - message - - reason - - status - - type - type: object - type: array - x-kubernetes-list-map-keys: - - type - x-kubernetes-list-type: map - devices: - description: Devices contains information on each device managed by the Connector resource. - items: - properties: - hostname: - description: |- - Hostname is the fully qualified domain name of the Connector replica. - If MagicDNS is enabled in your tailnet, it is the MagicDNS name of the - node. - type: string - tailnetIPs: - description: |- - TailnetIPs is the set of tailnet IP addresses (both IPv4 and IPv6) - assigned to the Connector replica. - items: - type: string - type: array - type: object - type: array - hostname: - description: |- - Hostname is the fully qualified domain name of the Connector node. - If MagicDNS is enabled in your tailnet, it is the MagicDNS name of the - node. When using multiple replicas, this field will be populated with the - first replica's hostname. Use the Hostnames field for the full list - of hostnames. - type: string - isAppConnector: - description: IsAppConnector is set to true if the Connector acts as an app connector. - type: boolean - isExitNode: - description: IsExitNode is set to true if the Connector acts as an exit node. - type: boolean - subnetRoutes: - description: |- - SubnetRoutes are the routes currently exposed to tailnet via this - Connector instance. - type: string - tailnetIPs: - description: |- - TailnetIPs is the set of tailnet IP addresses (both IPv4 and IPv6) - assigned to the Connector node. - items: - type: string - type: array - type: object - required: - - spec - type: object - served: true - storage: true - subresources: - status: {} ---- -apiVersion: apiextensions.k8s.io/v1 -kind: CustomResourceDefinition -metadata: - annotations: - controller-gen.kubebuilder.io/version: v0.17.0 - name: dnsconfigs.tailscale.com -spec: - group: tailscale.com - names: - kind: DNSConfig - listKind: DNSConfigList - plural: dnsconfigs - shortNames: - - dc - singular: dnsconfig - scope: Cluster - versions: - - additionalPrinterColumns: - - description: Service IP address of the nameserver - jsonPath: .status.nameserver.ip - name: NameserverIP - type: string - - jsonPath: .metadata.creationTimestamp - name: Age - type: date - name: v1alpha1 - schema: - openAPIV3Schema: - description: |- - DNSConfig can be deployed to cluster to make a subset of Tailscale MagicDNS - names resolvable by cluster workloads. Use this if: A) you need to refer to - tailnet services, exposed to cluster via Tailscale Kubernetes operator egress - proxies by the MagicDNS names of those tailnet services (usually because the - services run over HTTPS) - B) you have exposed a cluster workload to the tailnet using Tailscale Ingress - and you also want to refer to the workload from within the cluster over the - Ingress's MagicDNS name (usually because you have some callback component - that needs to use the same URL as that used by a non-cluster client on - tailnet). - When a DNSConfig is applied to a cluster, Tailscale Kubernetes operator will - deploy a nameserver for ts.net DNS names and automatically populate it with records - for any Tailscale egress or Ingress proxies deployed to that cluster. - Currently you must manually update your cluster DNS configuration to add the - IP address of the deployed nameserver as a ts.net stub nameserver. - Instructions for how to do it: - https://kubernetes.io/docs/tasks/administer-cluster/dns-custom-nameservers/#configuration-of-stub-domain-and-upstream-nameserver-using-coredns (for CoreDNS), - https://cloud.google.com/kubernetes-engine/docs/how-to/kube-dns (for kube-dns). - Tailscale Kubernetes operator will write the address of a Service fronting - the nameserver to dsnconfig.status.nameserver.ip. - DNSConfig is a singleton - you must not create more than one. - NB: if you want cluster workloads to be able to refer to Tailscale Ingress - using its MagicDNS name, you must also annotate the Ingress resource with - tailscale.com/experimental-forward-cluster-traffic-via-ingress annotation to - ensure that the proxy created for the Ingress listens on its Pod IP address. - properties: - apiVersion: - description: |- - APIVersion defines the versioned schema of this representation of an object. - Servers should convert recognized schemas to the latest internal value, and - may reject unrecognized values. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources - type: string - kind: - description: |- - Kind is a string value representing the REST resource this object represents. - Servers may infer this from the endpoint the client submits requests to. - Cannot be updated. - In CamelCase. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds - type: string - metadata: - type: object - spec: - description: |- - Spec describes the desired DNS configuration. - More info: - https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status - properties: - nameserver: - description: |- - Configuration for a nameserver that can resolve ts.net DNS names - associated with in-cluster proxies for Tailscale egress Services and - Tailscale Ingresses. The operator will always deploy this nameserver - when a DNSConfig is applied. - properties: - image: - description: Nameserver image. Defaults to tailscale/k8s-nameserver:unstable. - properties: - repo: - description: Repo defaults to tailscale/k8s-nameserver. - type: string - tag: - description: Tag defaults to unstable. - type: string - type: object - pod: - description: Pod configuration. - properties: - tolerations: - description: If specified, applies tolerations to the pods deployed by the DNSConfig resource. - items: - description: |- - The pod this Toleration is attached to tolerates any taint that matches - the triple using the matching operator . - properties: - effect: - description: |- - Effect indicates the taint effect to match. Empty means match all taint effects. - When specified, allowed values are NoSchedule, PreferNoSchedule and NoExecute. - type: string - key: - description: |- - Key is the taint key that the toleration applies to. Empty means match all taint keys. - If the key is empty, operator must be Exists; this combination means to match all values and all keys. - type: string - operator: - description: |- - Operator represents a key's relationship to the value. - Valid operators are Exists and Equal. Defaults to Equal. - Exists is equivalent to wildcard for value, so that a pod can - tolerate all taints of a particular category. - type: string - tolerationSeconds: - description: |- - TolerationSeconds represents the period of time the toleration (which must be - of effect NoExecute, otherwise this field is ignored) tolerates the taint. By default, - it is not set, which means tolerate the taint forever (do not evict). Zero and - negative values will be treated as 0 (evict immediately) by the system. - format: int64 - type: integer - value: - description: |- - Value is the taint value the toleration matches to. - If the operator is Exists, the value should be empty, otherwise just a regular string. - type: string - type: object - type: array - type: object - replicas: - description: Replicas specifies how many Pods to create. Defaults to 1. - format: int32 - minimum: 0 - type: integer - service: - description: Service configuration. - properties: - clusterIP: - description: ClusterIP sets the static IP of the service used by the nameserver. - type: string - type: object - type: object - required: - - nameserver - type: object - status: - description: |- - Status describes the status of the DNSConfig. This is set - and managed by the Tailscale operator. - properties: - conditions: - items: - description: Condition contains details for one aspect of the current state of this API Resource. - properties: - lastTransitionTime: - description: |- - lastTransitionTime is the last time the condition transitioned from one status to another. - This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. - format: date-time - type: string - message: - description: |- - message is a human readable message indicating details about the transition. - This may be an empty string. - maxLength: 32768 - type: string - observedGeneration: - description: |- - observedGeneration represents the .metadata.generation that the condition was set based upon. - For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date - with respect to the current state of the instance. - format: int64 - minimum: 0 - type: integer - reason: - description: |- - reason contains a programmatic identifier indicating the reason for the condition's last transition. - Producers of specific condition types may define expected values and meanings for this field, - and whether the values are considered a guaranteed API. - The value should be a CamelCase string. - This field may not be empty. - maxLength: 1024 - minLength: 1 - pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ - type: string - status: - description: status of the condition, one of True, False, Unknown. - enum: - - "True" - - "False" - - Unknown - type: string - type: - description: type of condition in CamelCase or in foo.example.com/CamelCase. - maxLength: 316 - pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ - type: string - required: - - lastTransitionTime - - message - - reason - - status - - type - type: object - type: array - x-kubernetes-list-map-keys: - - type - x-kubernetes-list-type: map - nameserver: - description: Nameserver describes the status of nameserver cluster resources. - properties: - ip: - description: |- - IP is the ClusterIP of the Service fronting the deployed ts.net nameserver. - Currently, you must manually update your cluster DNS config to add - this address as a stub nameserver for ts.net for cluster workloads to be - able to resolve MagicDNS names associated with egress or Ingress - proxies. - The IP address will change if you delete and recreate the DNSConfig. - type: string - type: object - type: object - required: - - spec - type: object - served: true - storage: true - subresources: - status: {} ---- -apiVersion: apiextensions.k8s.io/v1 -kind: CustomResourceDefinition -metadata: - annotations: - controller-gen.kubebuilder.io/version: v0.17.0 - name: proxyclasses.tailscale.com -spec: - group: tailscale.com - names: - kind: ProxyClass - listKind: ProxyClassList - plural: proxyclasses - singular: proxyclass - scope: Cluster - versions: - - additionalPrinterColumns: - - description: Status of the ProxyClass. - jsonPath: .status.conditions[?(@.type == "ProxyClassReady")].reason - name: Status - type: string - - jsonPath: .metadata.creationTimestamp - name: Age - type: date - name: v1alpha1 - schema: - openAPIV3Schema: - description: |- - ProxyClass describes a set of configuration parameters that can be applied to - proxy resources created by the Tailscale Kubernetes operator. - To apply a given ProxyClass to resources created for a tailscale Ingress or - Service, use tailscale.com/proxy-class= label. To apply a - given ProxyClass to resources created for a Connector, use - connector.spec.proxyClass field. - ProxyClass is a cluster scoped resource. - More info: - https://tailscale.com/kb/1445/kubernetes-operator-customization#cluster-resource-customization-using-proxyclass-custom-resource - properties: - apiVersion: - description: |- - APIVersion defines the versioned schema of this representation of an object. - Servers should convert recognized schemas to the latest internal value, and - may reject unrecognized values. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources - type: string - kind: - description: |- - Kind is a string value representing the REST resource this object represents. - Servers may infer this from the endpoint the client submits requests to. - Cannot be updated. - In CamelCase. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds - type: string - metadata: - type: object - spec: - description: |- - Specification of the desired state of the ProxyClass resource. - https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status - properties: - metrics: - description: |- - Configuration for proxy metrics. Metrics are currently not supported - for egress proxies and for Ingress proxies that have been configured - with tailscale.com/experimental-forward-cluster-traffic-via-ingress - annotation. Note that the metrics are currently considered unstable - and will likely change in breaking ways in the future - we only - recommend that you use those for debugging purposes. - properties: - enable: - description: |- - Setting enable to true will make the proxy serve Tailscale metrics - at :9002/metrics. - A metrics Service named -metrics will also be created in the operator's namespace and will - serve the metrics at :9002/metrics. - - In 1.78.x and 1.80.x, this field also serves as the default value for - .spec.statefulSet.pod.tailscaleContainer.debug.enable. From 1.82.0, both - fields will independently default to false. - - Defaults to false. - type: boolean - serviceMonitor: - description: |- - Enable to create a Prometheus ServiceMonitor for scraping the proxy's Tailscale metrics. - The ServiceMonitor will select the metrics Service that gets created when metrics are enabled. - The ingested metrics for each Service monitor will have labels to identify the proxy: - ts_proxy_type: ingress_service|ingress_resource|connector|proxygroup - ts_proxy_parent_name: name of the parent resource (i.e name of the Connector, Tailscale Ingress, Tailscale Service or ProxyGroup) - ts_proxy_parent_namespace: namespace of the parent resource (if the parent resource is not cluster scoped) - job: ts__[]_ - properties: - enable: - description: If Enable is set to true, a Prometheus ServiceMonitor will be created. Enable can only be set to true if metrics are enabled. - type: boolean - labels: - additionalProperties: - maxLength: 63 - pattern: ^(([a-zA-Z0-9][-._a-zA-Z0-9]*)?[a-zA-Z0-9])?$ - type: string - description: |- - Labels to add to the ServiceMonitor. - Labels must be valid Kubernetes labels. - https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#syntax-and-character-set - type: object - required: - - enable - type: object - required: - - enable - type: object - x-kubernetes-validations: - - message: ServiceMonitor can only be enabled if metrics are enabled - rule: '!(has(self.serviceMonitor) && self.serviceMonitor.enable && !self.enable)' - statefulSet: - description: |- - Configuration parameters for the proxy's StatefulSet. Tailscale - Kubernetes operator deploys a StatefulSet for each of the user - configured proxies (Tailscale Ingress, Tailscale Service, Connector). - properties: - annotations: - additionalProperties: - type: string - description: |- - Annotations that will be added to the StatefulSet created for the proxy. - Any Annotations specified here will be merged with the default annotations - applied to the StatefulSet by the Tailscale Kubernetes operator as - well as any other annotations that might have been applied by other - actors. - Annotations must be valid Kubernetes annotations. - https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/#syntax-and-character-set - type: object - labels: - additionalProperties: - maxLength: 63 - pattern: ^(([a-zA-Z0-9][-._a-zA-Z0-9]*)?[a-zA-Z0-9])?$ - type: string - description: |- - Labels that will be added to the StatefulSet created for the proxy. - Any labels specified here will be merged with the default labels - applied to the StatefulSet by the Tailscale Kubernetes operator as - well as any other labels that might have been applied by other - actors. - Label keys and values must be valid Kubernetes label keys and values. - https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#syntax-and-character-set - type: object - pod: - description: Configuration for the proxy Pod. - properties: - affinity: - description: |- - Proxy Pod's affinity rules. - By default, the Tailscale Kubernetes operator does not apply any affinity rules. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#affinity - properties: - nodeAffinity: - description: Describes node affinity scheduling rules for the pod. - properties: - preferredDuringSchedulingIgnoredDuringExecution: - description: |- - The scheduler will prefer to schedule pods to nodes that satisfy - the affinity expressions specified by this field, but it may choose - a node that violates one or more of the expressions. The node that is - most preferred is the one with the greatest sum of weights, i.e. - for each node that meets all of the scheduling requirements (resource - request, requiredDuringScheduling affinity expressions, etc.), - compute a sum by iterating through the elements of this field and adding - "weight" to the sum if the node matches the corresponding matchExpressions; the - node(s) with the highest sum are the most preferred. - items: - description: |- - An empty preferred scheduling term matches all objects with implicit weight 0 - (i.e. it's a no-op). A null preferred scheduling term matches no objects (i.e. is also a no-op). - properties: - preference: - description: A node selector term, associated with the corresponding weight. - properties: - matchExpressions: - description: A list of node selector requirements by node's labels. - items: - description: |- - A node selector requirement is a selector that contains values, a key, and an operator - that relates the key and values. - properties: - key: - description: The label key that the selector applies to. - type: string - operator: - description: |- - Represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. - type: string - values: - description: |- - An array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. If the operator is Gt or Lt, the values - array must have a single element, which will be interpreted as an integer. - This array is replaced during a strategic merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - matchFields: - description: A list of node selector requirements by node's fields. - items: - description: |- - A node selector requirement is a selector that contains values, a key, and an operator - that relates the key and values. - properties: - key: - description: The label key that the selector applies to. - type: string - operator: - description: |- - Represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. - type: string - values: - description: |- - An array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. If the operator is Gt or Lt, the values - array must have a single element, which will be interpreted as an integer. - This array is replaced during a strategic merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - type: object - x-kubernetes-map-type: atomic - weight: - description: Weight associated with matching the corresponding nodeSelectorTerm, in the range 1-100. - format: int32 - type: integer - required: - - preference - - weight - type: object - type: array - x-kubernetes-list-type: atomic - requiredDuringSchedulingIgnoredDuringExecution: - description: |- - If the affinity requirements specified by this field are not met at - scheduling time, the pod will not be scheduled onto the node. - If the affinity requirements specified by this field cease to be met - at some point during pod execution (e.g. due to an update), the system - may or may not try to eventually evict the pod from its node. - properties: - nodeSelectorTerms: - description: Required. A list of node selector terms. The terms are ORed. - items: - description: |- - A null or empty node selector term matches no objects. The requirements of - them are ANDed. - The TopologySelectorTerm type implements a subset of the NodeSelectorTerm. - properties: - matchExpressions: - description: A list of node selector requirements by node's labels. - items: - description: |- - A node selector requirement is a selector that contains values, a key, and an operator - that relates the key and values. - properties: - key: - description: The label key that the selector applies to. - type: string - operator: - description: |- - Represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. - type: string - values: - description: |- - An array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. If the operator is Gt or Lt, the values - array must have a single element, which will be interpreted as an integer. - This array is replaced during a strategic merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - matchFields: - description: A list of node selector requirements by node's fields. - items: - description: |- - A node selector requirement is a selector that contains values, a key, and an operator - that relates the key and values. - properties: - key: - description: The label key that the selector applies to. - type: string - operator: - description: |- - Represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. - type: string - values: - description: |- - An array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. If the operator is Gt or Lt, the values - array must have a single element, which will be interpreted as an integer. - This array is replaced during a strategic merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - type: object - x-kubernetes-map-type: atomic - type: array - x-kubernetes-list-type: atomic - required: - - nodeSelectorTerms - type: object - x-kubernetes-map-type: atomic - type: object - podAffinity: - description: Describes pod affinity scheduling rules (e.g. co-locate this pod in the same node, zone, etc. as some other pod(s)). - properties: - preferredDuringSchedulingIgnoredDuringExecution: - description: |- - The scheduler will prefer to schedule pods to nodes that satisfy - the affinity expressions specified by this field, but it may choose - a node that violates one or more of the expressions. The node that is - most preferred is the one with the greatest sum of weights, i.e. - for each node that meets all of the scheduling requirements (resource - request, requiredDuringScheduling affinity expressions, etc.), - compute a sum by iterating through the elements of this field and adding - "weight" to the sum if the node has pods which matches the corresponding podAffinityTerm; the - node(s) with the highest sum are the most preferred. - items: - description: The weights of all of the matched WeightedPodAffinityTerm fields are added per-node to find the most preferred node(s) - properties: - podAffinityTerm: - description: Required. A pod affinity term, associated with the corresponding weight. - properties: - labelSelector: - description: |- - A label query over a set of resources, in this case pods. - If it's null, this PodAffinityTerm matches with no Pods. - properties: - matchExpressions: - description: matchExpressions is a list of label selector requirements. The requirements are ANDed. - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - properties: - key: - description: key is the label key that the selector applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - matchLabels: - additionalProperties: - type: string - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - type: object - x-kubernetes-map-type: atomic - matchLabelKeys: - description: |- - MatchLabelKeys is a set of pod label keys to select which pods will - be taken into consideration. The keys are used to lookup values from the - incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` - to select the group of existing pods which pods will be taken into consideration - for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming - pod labels will be ignored. The default value is empty. - The same key is forbidden to exist in both matchLabelKeys and labelSelector. - Also, matchLabelKeys cannot be set when labelSelector isn't set. - items: - type: string - type: array - x-kubernetes-list-type: atomic - mismatchLabelKeys: - description: |- - MismatchLabelKeys is a set of pod label keys to select which pods will - be taken into consideration. The keys are used to lookup values from the - incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` - to select the group of existing pods which pods will be taken into consideration - for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming - pod labels will be ignored. The default value is empty. - The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. - Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - items: - type: string - type: array - x-kubernetes-list-type: atomic - namespaceSelector: - description: |- - A label query over the set of namespaces that the term applies to. - The term is applied to the union of the namespaces selected by this field - and the ones listed in the namespaces field. - null selector and null or empty namespaces list means "this pod's namespace". - An empty selector ({}) matches all namespaces. - properties: - matchExpressions: - description: matchExpressions is a list of label selector requirements. The requirements are ANDed. - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - properties: - key: - description: key is the label key that the selector applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - matchLabels: - additionalProperties: - type: string - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - type: object - x-kubernetes-map-type: atomic - namespaces: - description: |- - namespaces specifies a static list of namespace names that the term applies to. - The term is applied to the union of the namespaces listed in this field - and the ones selected by namespaceSelector. - null or empty namespaces list and null namespaceSelector means "this pod's namespace". - items: - type: string - type: array - x-kubernetes-list-type: atomic - topologyKey: - description: |- - This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching - the labelSelector in the specified namespaces, where co-located is defined as running on a node - whose value of the label with key topologyKey matches that of any node on which any of the - selected pods is running. - Empty topologyKey is not allowed. - type: string - required: - - topologyKey - type: object - weight: - description: |- - weight associated with matching the corresponding podAffinityTerm, - in the range 1-100. - format: int32 - type: integer - required: - - podAffinityTerm - - weight - type: object - type: array - x-kubernetes-list-type: atomic - requiredDuringSchedulingIgnoredDuringExecution: - description: |- - If the affinity requirements specified by this field are not met at - scheduling time, the pod will not be scheduled onto the node. - If the affinity requirements specified by this field cease to be met - at some point during pod execution (e.g. due to a pod label update), the - system may or may not try to eventually evict the pod from its node. - When there are multiple elements, the lists of nodes corresponding to each - podAffinityTerm are intersected, i.e. all terms must be satisfied. - items: - description: |- - Defines a set of pods (namely those matching the labelSelector - relative to the given namespace(s)) that this pod should be - co-located (affinity) or not co-located (anti-affinity) with, - where co-located is defined as running on a node whose value of - the label with key matches that of any node on which - a pod of the set of pods is running - properties: - labelSelector: - description: |- - A label query over a set of resources, in this case pods. - If it's null, this PodAffinityTerm matches with no Pods. - properties: - matchExpressions: - description: matchExpressions is a list of label selector requirements. The requirements are ANDed. - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - properties: - key: - description: key is the label key that the selector applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - matchLabels: - additionalProperties: - type: string - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - type: object - x-kubernetes-map-type: atomic - matchLabelKeys: - description: |- - MatchLabelKeys is a set of pod label keys to select which pods will - be taken into consideration. The keys are used to lookup values from the - incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` - to select the group of existing pods which pods will be taken into consideration - for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming - pod labels will be ignored. The default value is empty. - The same key is forbidden to exist in both matchLabelKeys and labelSelector. - Also, matchLabelKeys cannot be set when labelSelector isn't set. - items: - type: string - type: array - x-kubernetes-list-type: atomic - mismatchLabelKeys: - description: |- - MismatchLabelKeys is a set of pod label keys to select which pods will - be taken into consideration. The keys are used to lookup values from the - incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` - to select the group of existing pods which pods will be taken into consideration - for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming - pod labels will be ignored. The default value is empty. - The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. - Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - items: - type: string - type: array - x-kubernetes-list-type: atomic - namespaceSelector: - description: |- - A label query over the set of namespaces that the term applies to. - The term is applied to the union of the namespaces selected by this field - and the ones listed in the namespaces field. - null selector and null or empty namespaces list means "this pod's namespace". - An empty selector ({}) matches all namespaces. - properties: - matchExpressions: - description: matchExpressions is a list of label selector requirements. The requirements are ANDed. - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - properties: - key: - description: key is the label key that the selector applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - matchLabels: - additionalProperties: - type: string - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - type: object - x-kubernetes-map-type: atomic - namespaces: - description: |- - namespaces specifies a static list of namespace names that the term applies to. - The term is applied to the union of the namespaces listed in this field - and the ones selected by namespaceSelector. - null or empty namespaces list and null namespaceSelector means "this pod's namespace". - items: - type: string - type: array - x-kubernetes-list-type: atomic - topologyKey: - description: |- - This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching - the labelSelector in the specified namespaces, where co-located is defined as running on a node - whose value of the label with key topologyKey matches that of any node on which any of the - selected pods is running. - Empty topologyKey is not allowed. - type: string - required: - - topologyKey - type: object - type: array - x-kubernetes-list-type: atomic - type: object - podAntiAffinity: - description: Describes pod anti-affinity scheduling rules (e.g. avoid putting this pod in the same node, zone, etc. as some other pod(s)). - properties: - preferredDuringSchedulingIgnoredDuringExecution: - description: |- - The scheduler will prefer to schedule pods to nodes that satisfy - the anti-affinity expressions specified by this field, but it may choose - a node that violates one or more of the expressions. The node that is - most preferred is the one with the greatest sum of weights, i.e. - for each node that meets all of the scheduling requirements (resource - request, requiredDuringScheduling anti-affinity expressions, etc.), - compute a sum by iterating through the elements of this field and subtracting - "weight" from the sum if the node has pods which matches the corresponding podAffinityTerm; the - node(s) with the highest sum are the most preferred. - items: - description: The weights of all of the matched WeightedPodAffinityTerm fields are added per-node to find the most preferred node(s) - properties: - podAffinityTerm: - description: Required. A pod affinity term, associated with the corresponding weight. - properties: - labelSelector: - description: |- - A label query over a set of resources, in this case pods. - If it's null, this PodAffinityTerm matches with no Pods. - properties: - matchExpressions: - description: matchExpressions is a list of label selector requirements. The requirements are ANDed. - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - properties: - key: - description: key is the label key that the selector applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - matchLabels: - additionalProperties: - type: string - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - type: object - x-kubernetes-map-type: atomic - matchLabelKeys: - description: |- - MatchLabelKeys is a set of pod label keys to select which pods will - be taken into consideration. The keys are used to lookup values from the - incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` - to select the group of existing pods which pods will be taken into consideration - for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming - pod labels will be ignored. The default value is empty. - The same key is forbidden to exist in both matchLabelKeys and labelSelector. - Also, matchLabelKeys cannot be set when labelSelector isn't set. - items: - type: string - type: array - x-kubernetes-list-type: atomic - mismatchLabelKeys: - description: |- - MismatchLabelKeys is a set of pod label keys to select which pods will - be taken into consideration. The keys are used to lookup values from the - incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` - to select the group of existing pods which pods will be taken into consideration - for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming - pod labels will be ignored. The default value is empty. - The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. - Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - items: - type: string - type: array - x-kubernetes-list-type: atomic - namespaceSelector: - description: |- - A label query over the set of namespaces that the term applies to. - The term is applied to the union of the namespaces selected by this field - and the ones listed in the namespaces field. - null selector and null or empty namespaces list means "this pod's namespace". - An empty selector ({}) matches all namespaces. - properties: - matchExpressions: - description: matchExpressions is a list of label selector requirements. The requirements are ANDed. - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - properties: - key: - description: key is the label key that the selector applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - matchLabels: - additionalProperties: - type: string - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - type: object - x-kubernetes-map-type: atomic - namespaces: - description: |- - namespaces specifies a static list of namespace names that the term applies to. - The term is applied to the union of the namespaces listed in this field - and the ones selected by namespaceSelector. - null or empty namespaces list and null namespaceSelector means "this pod's namespace". - items: - type: string - type: array - x-kubernetes-list-type: atomic - topologyKey: - description: |- - This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching - the labelSelector in the specified namespaces, where co-located is defined as running on a node - whose value of the label with key topologyKey matches that of any node on which any of the - selected pods is running. - Empty topologyKey is not allowed. - type: string - required: - - topologyKey - type: object - weight: - description: |- - weight associated with matching the corresponding podAffinityTerm, - in the range 1-100. - format: int32 - type: integer - required: - - podAffinityTerm - - weight - type: object - type: array - x-kubernetes-list-type: atomic - requiredDuringSchedulingIgnoredDuringExecution: - description: |- - If the anti-affinity requirements specified by this field are not met at - scheduling time, the pod will not be scheduled onto the node. - If the anti-affinity requirements specified by this field cease to be met - at some point during pod execution (e.g. due to a pod label update), the - system may or may not try to eventually evict the pod from its node. - When there are multiple elements, the lists of nodes corresponding to each - podAffinityTerm are intersected, i.e. all terms must be satisfied. - items: - description: |- - Defines a set of pods (namely those matching the labelSelector - relative to the given namespace(s)) that this pod should be - co-located (affinity) or not co-located (anti-affinity) with, - where co-located is defined as running on a node whose value of - the label with key matches that of any node on which - a pod of the set of pods is running - properties: - labelSelector: - description: |- - A label query over a set of resources, in this case pods. - If it's null, this PodAffinityTerm matches with no Pods. - properties: - matchExpressions: - description: matchExpressions is a list of label selector requirements. The requirements are ANDed. - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - properties: - key: - description: key is the label key that the selector applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - matchLabels: - additionalProperties: - type: string - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - type: object - x-kubernetes-map-type: atomic - matchLabelKeys: - description: |- - MatchLabelKeys is a set of pod label keys to select which pods will - be taken into consideration. The keys are used to lookup values from the - incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` - to select the group of existing pods which pods will be taken into consideration - for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming - pod labels will be ignored. The default value is empty. - The same key is forbidden to exist in both matchLabelKeys and labelSelector. - Also, matchLabelKeys cannot be set when labelSelector isn't set. - items: - type: string - type: array - x-kubernetes-list-type: atomic - mismatchLabelKeys: - description: |- - MismatchLabelKeys is a set of pod label keys to select which pods will - be taken into consideration. The keys are used to lookup values from the - incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` - to select the group of existing pods which pods will be taken into consideration - for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming - pod labels will be ignored. The default value is empty. - The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. - Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - items: - type: string - type: array - x-kubernetes-list-type: atomic - namespaceSelector: - description: |- - A label query over the set of namespaces that the term applies to. - The term is applied to the union of the namespaces selected by this field - and the ones listed in the namespaces field. - null selector and null or empty namespaces list means "this pod's namespace". - An empty selector ({}) matches all namespaces. - properties: - matchExpressions: - description: matchExpressions is a list of label selector requirements. The requirements are ANDed. - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - properties: - key: - description: key is the label key that the selector applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - matchLabels: - additionalProperties: - type: string - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - type: object - x-kubernetes-map-type: atomic - namespaces: - description: |- - namespaces specifies a static list of namespace names that the term applies to. - The term is applied to the union of the namespaces listed in this field - and the ones selected by namespaceSelector. - null or empty namespaces list and null namespaceSelector means "this pod's namespace". - items: - type: string - type: array - x-kubernetes-list-type: atomic - topologyKey: - description: |- - This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching - the labelSelector in the specified namespaces, where co-located is defined as running on a node - whose value of the label with key topologyKey matches that of any node on which any of the - selected pods is running. - Empty topologyKey is not allowed. - type: string - required: - - topologyKey - type: object - type: array - x-kubernetes-list-type: atomic - type: object - type: object - annotations: - additionalProperties: - type: string - description: |- - Annotations that will be added to the proxy Pod. - Any annotations specified here will be merged with the default - annotations applied to the Pod by the Tailscale Kubernetes operator. - Annotations must be valid Kubernetes annotations. - https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/#syntax-and-character-set - type: object - dnsConfig: - description: |- - DNSConfig defines DNS parameters for the proxy Pod in addition to those generated from DNSPolicy. - When DNSPolicy is set to "None", DNSConfig must be specified. - https://kubernetes.io/docs/concepts/services-networking/dns-pod-service/#pod-dns-config - properties: - nameservers: - description: |- - A list of DNS name server IP addresses. - This will be appended to the base nameservers generated from DNSPolicy. - Duplicated nameservers will be removed. - items: - type: string - type: array - x-kubernetes-list-type: atomic - options: - description: |- - A list of DNS resolver options. - This will be merged with the base options generated from DNSPolicy. - Duplicated entries will be removed. Resolution options given in Options - will override those that appear in the base DNSPolicy. - items: - description: PodDNSConfigOption defines DNS resolver options of a pod. - properties: - name: - description: |- - Name is this DNS resolver option's name. - Required. - type: string - value: - description: Value is this DNS resolver option's value. - type: string - type: object - type: array - x-kubernetes-list-type: atomic - searches: - description: |- - A list of DNS search domains for host-name lookup. - This will be appended to the base search paths generated from DNSPolicy. - Duplicated search paths will be removed. - items: - type: string - type: array - x-kubernetes-list-type: atomic - type: object - dnsPolicy: - description: |- - DNSPolicy defines how DNS will be configured for the proxy Pod. - By default the Tailscale Kubernetes Operator does not set a DNS policy (uses cluster default). - https://kubernetes.io/docs/concepts/services-networking/dns-pod-service/#pod-s-dns-policy - enum: - - ClusterFirstWithHostNet - - ClusterFirst - - Default - - None - type: string - imagePullSecrets: - description: |- - Proxy Pod's image pull Secrets. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#PodSpec - items: - description: |- - LocalObjectReference contains enough information to let you locate the - referenced object inside the same namespace. - properties: - name: - default: "" - description: |- - Name of the referent. - This field is effectively required, but due to backwards compatibility is - allowed to be empty. Instances of this type with an empty value here are - almost certainly wrong. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - type: string - type: object - x-kubernetes-map-type: atomic - type: array - labels: - additionalProperties: - maxLength: 63 - pattern: ^(([a-zA-Z0-9][-._a-zA-Z0-9]*)?[a-zA-Z0-9])?$ - type: string - description: |- - Labels that will be added to the proxy Pod. - Any labels specified here will be merged with the default labels - applied to the Pod by the Tailscale Kubernetes operator. - Label keys and values must be valid Kubernetes label keys and values. - https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#syntax-and-character-set - type: object - nodeName: - description: |- - Proxy Pod's node name. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#scheduling - type: string - nodeSelector: - additionalProperties: - type: string - description: |- - Proxy Pod's node selector. - By default Tailscale Kubernetes operator does not apply any node - selector. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#scheduling - type: object - priorityClassName: - description: |- - PriorityClassName for the proxy Pod. - By default Tailscale Kubernetes operator does not apply any priority class. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#scheduling - type: string - securityContext: - description: |- - Proxy Pod's security context. - By default Tailscale Kubernetes operator does not apply any Pod - security context. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#security-context-2 - properties: - appArmorProfile: - description: |- - appArmorProfile is the AppArmor options to use by the containers in this pod. - Note that this field cannot be set when spec.os.name is windows. - properties: - localhostProfile: - description: |- - localhostProfile indicates a profile loaded on the node that should be used. - The profile must be preconfigured on the node to work. - Must match the loaded name of the profile. - Must be set if and only if type is "Localhost". - type: string - type: - description: |- - type indicates which kind of AppArmor profile will be applied. - Valid options are: - Localhost - a profile pre-loaded on the node. - RuntimeDefault - the container runtime's default profile. - Unconfined - no AppArmor enforcement. - type: string - required: - - type - type: object - fsGroup: - description: |- - A special supplemental group that applies to all containers in a pod. - Some volume types allow the Kubelet to change the ownership of that volume - to be owned by the pod: - - 1. The owning GID will be the FSGroup - 2. The setgid bit is set (new files created in the volume will be owned by FSGroup) - 3. The permission bits are OR'd with rw-rw---- - - If unset, the Kubelet will not modify the ownership and permissions of any volume. - Note that this field cannot be set when spec.os.name is windows. - format: int64 - type: integer - fsGroupChangePolicy: - description: |- - fsGroupChangePolicy defines behavior of changing ownership and permission of the volume - before being exposed inside Pod. This field will only apply to - volume types which support fsGroup based ownership(and permissions). - It will have no effect on ephemeral volume types such as: secret, configmaps - and emptydir. - Valid values are "OnRootMismatch" and "Always". If not specified, "Always" is used. - Note that this field cannot be set when spec.os.name is windows. - type: string - runAsGroup: - description: |- - The GID to run the entrypoint of the container process. - Uses runtime default if unset. - May also be set in SecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence - for that container. - Note that this field cannot be set when spec.os.name is windows. - format: int64 - type: integer - runAsNonRoot: - description: |- - Indicates that the container must run as a non-root user. - If true, the Kubelet will validate the image at runtime to ensure that it - does not run as UID 0 (root) and fail to start the container if it does. - If unset or false, no such validation will be performed. - May also be set in SecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - type: boolean - runAsUser: - description: |- - The UID to run the entrypoint of the container process. - Defaults to user specified in image metadata if unspecified. - May also be set in SecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence - for that container. - Note that this field cannot be set when spec.os.name is windows. - format: int64 - type: integer - seLinuxChangePolicy: - description: |- - seLinuxChangePolicy defines how the container's SELinux label is applied to all volumes used by the Pod. - It has no effect on nodes that do not support SELinux or to volumes does not support SELinux. - Valid values are "MountOption" and "Recursive". - - "Recursive" means relabeling of all files on all Pod volumes by the container runtime. - This may be slow for large volumes, but allows mixing privileged and unprivileged Pods sharing the same volume on the same node. - - "MountOption" mounts all eligible Pod volumes with `-o context` mount option. - This requires all Pods that share the same volume to use the same SELinux label. - It is not possible to share the same volume among privileged and unprivileged Pods. - Eligible volumes are in-tree FibreChannel and iSCSI volumes, and all CSI volumes - whose CSI driver announces SELinux support by setting spec.seLinuxMount: true in their - CSIDriver instance. Other volumes are always re-labelled recursively. - "MountOption" value is allowed only when SELinuxMount feature gate is enabled. - - If not specified and SELinuxMount feature gate is enabled, "MountOption" is used. - If not specified and SELinuxMount feature gate is disabled, "MountOption" is used for ReadWriteOncePod volumes - and "Recursive" for all other volumes. - - This field affects only Pods that have SELinux label set, either in PodSecurityContext or in SecurityContext of all containers. - - All Pods that use the same volume should use the same seLinuxChangePolicy, otherwise some pods can get stuck in ContainerCreating state. - Note that this field cannot be set when spec.os.name is windows. - type: string - seLinuxOptions: - description: |- - The SELinux context to be applied to all containers. - If unspecified, the container runtime will allocate a random SELinux context for each - container. May also be set in SecurityContext. If set in - both SecurityContext and PodSecurityContext, the value specified in SecurityContext - takes precedence for that container. - Note that this field cannot be set when spec.os.name is windows. - properties: - level: - description: Level is SELinux level label that applies to the container. - type: string - role: - description: Role is a SELinux role label that applies to the container. - type: string - type: - description: Type is a SELinux type label that applies to the container. - type: string - user: - description: User is a SELinux user label that applies to the container. - type: string - type: object - seccompProfile: - description: |- - The seccomp options to use by the containers in this pod. - Note that this field cannot be set when spec.os.name is windows. - properties: - localhostProfile: - description: |- - localhostProfile indicates a profile defined in a file on the node should be used. - The profile must be preconfigured on the node to work. - Must be a descending path, relative to the kubelet's configured seccomp profile location. - Must be set if type is "Localhost". Must NOT be set for any other type. - type: string - type: - description: |- - type indicates which kind of seccomp profile will be applied. - Valid options are: - - Localhost - a profile defined in a file on the node should be used. - RuntimeDefault - the container runtime default profile should be used. - Unconfined - no profile should be applied. - type: string - required: - - type - type: object - supplementalGroups: - description: |- - A list of groups applied to the first process run in each container, in - addition to the container's primary GID and fsGroup (if specified). If - the SupplementalGroupsPolicy feature is enabled, the - supplementalGroupsPolicy field determines whether these are in addition - to or instead of any group memberships defined in the container image. - If unspecified, no additional groups are added, though group memberships - defined in the container image may still be used, depending on the - supplementalGroupsPolicy field. - Note that this field cannot be set when spec.os.name is windows. - items: - format: int64 - type: integer - type: array - x-kubernetes-list-type: atomic - supplementalGroupsPolicy: - description: |- - Defines how supplemental groups of the first container processes are calculated. - Valid values are "Merge" and "Strict". If not specified, "Merge" is used. - (Alpha) Using the field requires the SupplementalGroupsPolicy feature gate to be enabled - and the container runtime must implement support for this feature. - Note that this field cannot be set when spec.os.name is windows. - type: string - sysctls: - description: |- - Sysctls hold a list of namespaced sysctls used for the pod. Pods with unsupported - sysctls (by the container runtime) might fail to launch. - Note that this field cannot be set when spec.os.name is windows. - items: - description: Sysctl defines a kernel parameter to be set - properties: - name: - description: Name of a property to set - type: string - value: - description: Value of a property to set - type: string - required: - - name - - value - type: object - type: array - x-kubernetes-list-type: atomic - windowsOptions: - description: |- - The Windows specific settings applied to all containers. - If unspecified, the options within a container's SecurityContext will be used. - If set in both SecurityContext and PodSecurityContext, the value specified in SecurityContext takes precedence. - Note that this field cannot be set when spec.os.name is linux. - properties: - gmsaCredentialSpec: - description: |- - GMSACredentialSpec is where the GMSA admission webhook - (https://github.com/kubernetes-sigs/windows-gmsa) inlines the contents of the - GMSA credential spec named by the GMSACredentialSpecName field. - type: string - gmsaCredentialSpecName: - description: GMSACredentialSpecName is the name of the GMSA credential spec to use. - type: string - hostProcess: - description: |- - HostProcess determines if a container should be run as a 'Host Process' container. - All of a Pod's containers must have the same effective HostProcess value - (it is not allowed to have a mix of HostProcess containers and non-HostProcess containers). - In addition, if HostProcess is true then HostNetwork must also be set to true. - type: boolean - runAsUserName: - description: |- - The UserName in Windows to run the entrypoint of the container process. - Defaults to the user specified in image metadata if unspecified. - May also be set in PodSecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - type: string - type: object - type: object - tailscaleContainer: - description: Configuration for the proxy container running tailscale. - properties: - debug: - description: |- - Configuration for enabling extra debug information in the container. - Not recommended for production use. - properties: - enable: - description: |- - Enable tailscaled's HTTP pprof endpoints at :9001/debug/pprof/ - and internal debug metrics endpoint at :9001/debug/metrics, where - 9001 is a container port named "debug". The endpoints and their responses - may change in backwards incompatible ways in the future, and should not - be considered stable. - - In 1.78.x and 1.80.x, this setting will default to the value of - .spec.metrics.enable, and requests to the "metrics" port matching the - mux pattern /debug/ will be forwarded to the "debug" port. In 1.82.x, - this setting will default to false, and no requests will be proxied. - type: boolean - type: object - env: - description: |- - List of environment variables to set in the container. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#environment-variables - Note that environment variables provided here will take precedence - over Tailscale-specific environment variables set by the operator, - however running proxies with custom values for Tailscale environment - variables (i.e TS_USERSPACE) is not recommended and might break in - the future. - items: - properties: - name: - description: Name of the environment variable. Must be a C_IDENTIFIER. - pattern: ^[-._a-zA-Z][-._a-zA-Z0-9]*$ - type: string - value: - description: |- - Variable references $(VAR_NAME) are expanded using the previously defined - environment variables in the container and any service environment - variables. If a variable cannot be resolved, the reference in the input - string will be unchanged. Double $$ are reduced to a single $, which - allows for escaping the $(VAR_NAME) syntax: i.e. "$$(VAR_NAME)" will - produce the string literal "$(VAR_NAME)". Escaped references will never - be expanded, regardless of whether the variable exists or not. Defaults - to "". - type: string - required: - - name - type: object - type: array - image: - description: |- - Container image name. By default images are pulled from docker.io/tailscale, - but the official images are also available at ghcr.io/tailscale. - - For all uses except on ProxyGroups of type "kube-apiserver", this image must - be either tailscale/tailscale, or an equivalent mirror of that image. - To apply to ProxyGroups of type "kube-apiserver", this image must be - tailscale/k8s-proxy or a mirror of that image. - - For "tailscale/tailscale"-based proxies, specifying image name here will - override any proxy image values specified via the Kubernetes operator's - Helm chart values or PROXY_IMAGE env var in the operator Deployment. - For "tailscale/k8s-proxy"-based proxies, there is currently no way to - configure your own default, and this field is the only way to use a - custom image. - - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#image - type: string - imagePullPolicy: - description: |- - Image pull policy. One of Always, Never, IfNotPresent. Defaults to Always. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#image - enum: - - Always - - Never - - IfNotPresent - type: string - resources: - description: |- - Container resource requirements. - By default Tailscale Kubernetes operator does not apply any resource - requirements. The amount of resources required wil depend on the - amount of resources the operator needs to parse, usage patterns and - cluster size. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#resources - properties: - claims: - description: |- - Claims lists the names of resources, defined in spec.resourceClaims, - that are used by this container. - - This field depends on the - DynamicResourceAllocation feature gate. - - This field is immutable. It can only be set for containers. - items: - description: ResourceClaim references one entry in PodSpec.ResourceClaims. - properties: - name: - description: |- - Name must match the name of one entry in pod.spec.resourceClaims of - the Pod where this field is used. It makes that resource available - inside a container. - type: string - request: - description: |- - Request is the name chosen for a request in the referenced claim. - If empty, everything from the claim is made available, otherwise - only the result of this request. - type: string - required: - - name - type: object - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - limits: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Limits describes the maximum amount of compute resources allowed. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - requests: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Requests describes the minimum amount of compute resources required. - If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, - otherwise to an implementation-defined value. Requests cannot exceed Limits. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - type: object - securityContext: - description: |- - Container security context. - Security context specified here will override the security context set by the operator. - By default the operator sets the Tailscale container and the Tailscale init container to privileged - for proxies created for Tailscale ingress and egress Service, Connector and ProxyGroup. - You can reduce the permissions of the Tailscale container to cap NET_ADMIN by - installing device plugin in your cluster and configuring the proxies tun device to be created - by the device plugin, see https://github.com/tailscale/tailscale/issues/10814#issuecomment-2479977752 - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#security-context - properties: - allowPrivilegeEscalation: - description: |- - AllowPrivilegeEscalation controls whether a process can gain more - privileges than its parent process. This bool directly controls if - the no_new_privs flag will be set on the container process. - AllowPrivilegeEscalation is true always when the container is: - 1) run as Privileged - 2) has CAP_SYS_ADMIN - Note that this field cannot be set when spec.os.name is windows. - type: boolean - appArmorProfile: - description: |- - appArmorProfile is the AppArmor options to use by this container. If set, this profile - overrides the pod's appArmorProfile. - Note that this field cannot be set when spec.os.name is windows. - properties: - localhostProfile: - description: |- - localhostProfile indicates a profile loaded on the node that should be used. - The profile must be preconfigured on the node to work. - Must match the loaded name of the profile. - Must be set if and only if type is "Localhost". - type: string - type: - description: |- - type indicates which kind of AppArmor profile will be applied. - Valid options are: - Localhost - a profile pre-loaded on the node. - RuntimeDefault - the container runtime's default profile. - Unconfined - no AppArmor enforcement. - type: string - required: - - type - type: object - capabilities: - description: |- - The capabilities to add/drop when running containers. - Defaults to the default set of capabilities granted by the container runtime. - Note that this field cannot be set when spec.os.name is windows. - properties: - add: - description: Added capabilities - items: - description: Capability represent POSIX capabilities type - type: string - type: array - x-kubernetes-list-type: atomic - drop: - description: Removed capabilities - items: - description: Capability represent POSIX capabilities type - type: string - type: array - x-kubernetes-list-type: atomic - type: object - privileged: - description: |- - Run container in privileged mode. - Processes in privileged containers are essentially equivalent to root on the host. - Defaults to false. - Note that this field cannot be set when spec.os.name is windows. - type: boolean - procMount: - description: |- - procMount denotes the type of proc mount to use for the containers. - The default value is Default which uses the container runtime defaults for - readonly paths and masked paths. - This requires the ProcMountType feature flag to be enabled. - Note that this field cannot be set when spec.os.name is windows. - type: string - readOnlyRootFilesystem: - description: |- - Whether this container has a read-only root filesystem. - Default is false. - Note that this field cannot be set when spec.os.name is windows. - type: boolean - runAsGroup: - description: |- - The GID to run the entrypoint of the container process. - Uses runtime default if unset. - May also be set in PodSecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - Note that this field cannot be set when spec.os.name is windows. - format: int64 - type: integer - runAsNonRoot: - description: |- - Indicates that the container must run as a non-root user. - If true, the Kubelet will validate the image at runtime to ensure that it - does not run as UID 0 (root) and fail to start the container if it does. - If unset or false, no such validation will be performed. - May also be set in PodSecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - type: boolean - runAsUser: - description: |- - The UID to run the entrypoint of the container process. - Defaults to user specified in image metadata if unspecified. - May also be set in PodSecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - Note that this field cannot be set when spec.os.name is windows. - format: int64 - type: integer - seLinuxOptions: - description: |- - The SELinux context to be applied to the container. - If unspecified, the container runtime will allocate a random SELinux context for each - container. May also be set in PodSecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - Note that this field cannot be set when spec.os.name is windows. - properties: - level: - description: Level is SELinux level label that applies to the container. - type: string - role: - description: Role is a SELinux role label that applies to the container. - type: string - type: - description: Type is a SELinux type label that applies to the container. - type: string - user: - description: User is a SELinux user label that applies to the container. - type: string - type: object - seccompProfile: - description: |- - The seccomp options to use by this container. If seccomp options are - provided at both the pod & container level, the container options - override the pod options. - Note that this field cannot be set when spec.os.name is windows. - properties: - localhostProfile: - description: |- - localhostProfile indicates a profile defined in a file on the node should be used. - The profile must be preconfigured on the node to work. - Must be a descending path, relative to the kubelet's configured seccomp profile location. - Must be set if type is "Localhost". Must NOT be set for any other type. - type: string - type: - description: |- - type indicates which kind of seccomp profile will be applied. - Valid options are: - - Localhost - a profile defined in a file on the node should be used. - RuntimeDefault - the container runtime default profile should be used. - Unconfined - no profile should be applied. - type: string - required: - - type - type: object - windowsOptions: - description: |- - The Windows specific settings applied to all containers. - If unspecified, the options from the PodSecurityContext will be used. - If set in both SecurityContext and PodSecurityContext, the value specified in SecurityContext takes precedence. - Note that this field cannot be set when spec.os.name is linux. - properties: - gmsaCredentialSpec: - description: |- - GMSACredentialSpec is where the GMSA admission webhook - (https://github.com/kubernetes-sigs/windows-gmsa) inlines the contents of the - GMSA credential spec named by the GMSACredentialSpecName field. - type: string - gmsaCredentialSpecName: - description: GMSACredentialSpecName is the name of the GMSA credential spec to use. - type: string - hostProcess: - description: |- - HostProcess determines if a container should be run as a 'Host Process' container. - All of a Pod's containers must have the same effective HostProcess value - (it is not allowed to have a mix of HostProcess containers and non-HostProcess containers). - In addition, if HostProcess is true then HostNetwork must also be set to true. - type: boolean - runAsUserName: - description: |- - The UserName in Windows to run the entrypoint of the container process. - Defaults to the user specified in image metadata if unspecified. - May also be set in PodSecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - type: string - type: object - type: object - type: object - tailscaleInitContainer: - description: |- - Configuration for the proxy init container that enables forwarding. - Not valid to apply to ProxyGroups of type "kube-apiserver". - properties: - debug: - description: |- - Configuration for enabling extra debug information in the container. - Not recommended for production use. - properties: - enable: - description: |- - Enable tailscaled's HTTP pprof endpoints at :9001/debug/pprof/ - and internal debug metrics endpoint at :9001/debug/metrics, where - 9001 is a container port named "debug". The endpoints and their responses - may change in backwards incompatible ways in the future, and should not - be considered stable. - - In 1.78.x and 1.80.x, this setting will default to the value of - .spec.metrics.enable, and requests to the "metrics" port matching the - mux pattern /debug/ will be forwarded to the "debug" port. In 1.82.x, - this setting will default to false, and no requests will be proxied. - type: boolean - type: object - env: - description: |- - List of environment variables to set in the container. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#environment-variables - Note that environment variables provided here will take precedence - over Tailscale-specific environment variables set by the operator, - however running proxies with custom values for Tailscale environment - variables (i.e TS_USERSPACE) is not recommended and might break in - the future. - items: - properties: - name: - description: Name of the environment variable. Must be a C_IDENTIFIER. - pattern: ^[-._a-zA-Z][-._a-zA-Z0-9]*$ - type: string - value: - description: |- - Variable references $(VAR_NAME) are expanded using the previously defined - environment variables in the container and any service environment - variables. If a variable cannot be resolved, the reference in the input - string will be unchanged. Double $$ are reduced to a single $, which - allows for escaping the $(VAR_NAME) syntax: i.e. "$$(VAR_NAME)" will - produce the string literal "$(VAR_NAME)". Escaped references will never - be expanded, regardless of whether the variable exists or not. Defaults - to "". - type: string - required: - - name - type: object - type: array - image: - description: |- - Container image name. By default images are pulled from docker.io/tailscale, - but the official images are also available at ghcr.io/tailscale. - - For all uses except on ProxyGroups of type "kube-apiserver", this image must - be either tailscale/tailscale, or an equivalent mirror of that image. - To apply to ProxyGroups of type "kube-apiserver", this image must be - tailscale/k8s-proxy or a mirror of that image. - - For "tailscale/tailscale"-based proxies, specifying image name here will - override any proxy image values specified via the Kubernetes operator's - Helm chart values or PROXY_IMAGE env var in the operator Deployment. - For "tailscale/k8s-proxy"-based proxies, there is currently no way to - configure your own default, and this field is the only way to use a - custom image. - - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#image - type: string - imagePullPolicy: - description: |- - Image pull policy. One of Always, Never, IfNotPresent. Defaults to Always. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#image - enum: - - Always - - Never - - IfNotPresent - type: string - resources: - description: |- - Container resource requirements. - By default Tailscale Kubernetes operator does not apply any resource - requirements. The amount of resources required wil depend on the - amount of resources the operator needs to parse, usage patterns and - cluster size. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#resources - properties: - claims: - description: |- - Claims lists the names of resources, defined in spec.resourceClaims, - that are used by this container. - - This field depends on the - DynamicResourceAllocation feature gate. - - This field is immutable. It can only be set for containers. - items: - description: ResourceClaim references one entry in PodSpec.ResourceClaims. - properties: - name: - description: |- - Name must match the name of one entry in pod.spec.resourceClaims of - the Pod where this field is used. It makes that resource available - inside a container. - type: string - request: - description: |- - Request is the name chosen for a request in the referenced claim. - If empty, everything from the claim is made available, otherwise - only the result of this request. - type: string - required: - - name - type: object - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - limits: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Limits describes the maximum amount of compute resources allowed. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - requests: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Requests describes the minimum amount of compute resources required. - If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, - otherwise to an implementation-defined value. Requests cannot exceed Limits. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - type: object - securityContext: - description: |- - Container security context. - Security context specified here will override the security context set by the operator. - By default the operator sets the Tailscale container and the Tailscale init container to privileged - for proxies created for Tailscale ingress and egress Service, Connector and ProxyGroup. - You can reduce the permissions of the Tailscale container to cap NET_ADMIN by - installing device plugin in your cluster and configuring the proxies tun device to be created - by the device plugin, see https://github.com/tailscale/tailscale/issues/10814#issuecomment-2479977752 - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#security-context - properties: - allowPrivilegeEscalation: - description: |- - AllowPrivilegeEscalation controls whether a process can gain more - privileges than its parent process. This bool directly controls if - the no_new_privs flag will be set on the container process. - AllowPrivilegeEscalation is true always when the container is: - 1) run as Privileged - 2) has CAP_SYS_ADMIN - Note that this field cannot be set when spec.os.name is windows. - type: boolean - appArmorProfile: - description: |- - appArmorProfile is the AppArmor options to use by this container. If set, this profile - overrides the pod's appArmorProfile. - Note that this field cannot be set when spec.os.name is windows. - properties: - localhostProfile: - description: |- - localhostProfile indicates a profile loaded on the node that should be used. - The profile must be preconfigured on the node to work. - Must match the loaded name of the profile. - Must be set if and only if type is "Localhost". - type: string - type: - description: |- - type indicates which kind of AppArmor profile will be applied. - Valid options are: - Localhost - a profile pre-loaded on the node. - RuntimeDefault - the container runtime's default profile. - Unconfined - no AppArmor enforcement. - type: string - required: - - type - type: object - capabilities: - description: |- - The capabilities to add/drop when running containers. - Defaults to the default set of capabilities granted by the container runtime. - Note that this field cannot be set when spec.os.name is windows. - properties: - add: - description: Added capabilities - items: - description: Capability represent POSIX capabilities type - type: string - type: array - x-kubernetes-list-type: atomic - drop: - description: Removed capabilities - items: - description: Capability represent POSIX capabilities type - type: string - type: array - x-kubernetes-list-type: atomic - type: object - privileged: - description: |- - Run container in privileged mode. - Processes in privileged containers are essentially equivalent to root on the host. - Defaults to false. - Note that this field cannot be set when spec.os.name is windows. - type: boolean - procMount: - description: |- - procMount denotes the type of proc mount to use for the containers. - The default value is Default which uses the container runtime defaults for - readonly paths and masked paths. - This requires the ProcMountType feature flag to be enabled. - Note that this field cannot be set when spec.os.name is windows. - type: string - readOnlyRootFilesystem: - description: |- - Whether this container has a read-only root filesystem. - Default is false. - Note that this field cannot be set when spec.os.name is windows. - type: boolean - runAsGroup: - description: |- - The GID to run the entrypoint of the container process. - Uses runtime default if unset. - May also be set in PodSecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - Note that this field cannot be set when spec.os.name is windows. - format: int64 - type: integer - runAsNonRoot: - description: |- - Indicates that the container must run as a non-root user. - If true, the Kubelet will validate the image at runtime to ensure that it - does not run as UID 0 (root) and fail to start the container if it does. - If unset or false, no such validation will be performed. - May also be set in PodSecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - type: boolean - runAsUser: - description: |- - The UID to run the entrypoint of the container process. - Defaults to user specified in image metadata if unspecified. - May also be set in PodSecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - Note that this field cannot be set when spec.os.name is windows. - format: int64 - type: integer - seLinuxOptions: - description: |- - The SELinux context to be applied to the container. - If unspecified, the container runtime will allocate a random SELinux context for each - container. May also be set in PodSecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - Note that this field cannot be set when spec.os.name is windows. - properties: - level: - description: Level is SELinux level label that applies to the container. - type: string - role: - description: Role is a SELinux role label that applies to the container. - type: string - type: - description: Type is a SELinux type label that applies to the container. - type: string - user: - description: User is a SELinux user label that applies to the container. - type: string - type: object - seccompProfile: - description: |- - The seccomp options to use by this container. If seccomp options are - provided at both the pod & container level, the container options - override the pod options. - Note that this field cannot be set when spec.os.name is windows. - properties: - localhostProfile: - description: |- - localhostProfile indicates a profile defined in a file on the node should be used. - The profile must be preconfigured on the node to work. - Must be a descending path, relative to the kubelet's configured seccomp profile location. - Must be set if type is "Localhost". Must NOT be set for any other type. - type: string - type: - description: |- - type indicates which kind of seccomp profile will be applied. - Valid options are: - - Localhost - a profile defined in a file on the node should be used. - RuntimeDefault - the container runtime default profile should be used. - Unconfined - no profile should be applied. - type: string - required: - - type - type: object - windowsOptions: - description: |- - The Windows specific settings applied to all containers. - If unspecified, the options from the PodSecurityContext will be used. - If set in both SecurityContext and PodSecurityContext, the value specified in SecurityContext takes precedence. - Note that this field cannot be set when spec.os.name is linux. - properties: - gmsaCredentialSpec: - description: |- - GMSACredentialSpec is where the GMSA admission webhook - (https://github.com/kubernetes-sigs/windows-gmsa) inlines the contents of the - GMSA credential spec named by the GMSACredentialSpecName field. - type: string - gmsaCredentialSpecName: - description: GMSACredentialSpecName is the name of the GMSA credential spec to use. - type: string - hostProcess: - description: |- - HostProcess determines if a container should be run as a 'Host Process' container. - All of a Pod's containers must have the same effective HostProcess value - (it is not allowed to have a mix of HostProcess containers and non-HostProcess containers). - In addition, if HostProcess is true then HostNetwork must also be set to true. - type: boolean - runAsUserName: - description: |- - The UserName in Windows to run the entrypoint of the container process. - Defaults to the user specified in image metadata if unspecified. - May also be set in PodSecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - type: string - type: object - type: object - type: object - tolerations: - description: |- - Proxy Pod's tolerations. - By default Tailscale Kubernetes operator does not apply any - tolerations. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#scheduling - items: - description: |- - The pod this Toleration is attached to tolerates any taint that matches - the triple using the matching operator . - properties: - effect: - description: |- - Effect indicates the taint effect to match. Empty means match all taint effects. - When specified, allowed values are NoSchedule, PreferNoSchedule and NoExecute. - type: string - key: - description: |- - Key is the taint key that the toleration applies to. Empty means match all taint keys. - If the key is empty, operator must be Exists; this combination means to match all values and all keys. - type: string - operator: - description: |- - Operator represents a key's relationship to the value. - Valid operators are Exists and Equal. Defaults to Equal. - Exists is equivalent to wildcard for value, so that a pod can - tolerate all taints of a particular category. - type: string - tolerationSeconds: - description: |- - TolerationSeconds represents the period of time the toleration (which must be - of effect NoExecute, otherwise this field is ignored) tolerates the taint. By default, - it is not set, which means tolerate the taint forever (do not evict). Zero and - negative values will be treated as 0 (evict immediately) by the system. - format: int64 - type: integer - value: - description: |- - Value is the taint value the toleration matches to. - If the operator is Exists, the value should be empty, otherwise just a regular string. - type: string - type: object - type: array - topologySpreadConstraints: - description: |- - Proxy Pod's topology spread constraints. - By default Tailscale Kubernetes operator does not apply any topology spread constraints. - https://kubernetes.io/docs/concepts/scheduling-eviction/topology-spread-constraints/ - items: - description: TopologySpreadConstraint specifies how to spread matching pods among the given topology. - properties: - labelSelector: - description: |- - LabelSelector is used to find matching pods. - Pods that match this label selector are counted to determine the number of pods - in their corresponding topology domain. - properties: - matchExpressions: - description: matchExpressions is a list of label selector requirements. The requirements are ANDed. - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - properties: - key: - description: key is the label key that the selector applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - matchLabels: - additionalProperties: - type: string - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - type: object - x-kubernetes-map-type: atomic - matchLabelKeys: - description: |- - MatchLabelKeys is a set of pod label keys to select the pods over which - spreading will be calculated. The keys are used to lookup values from the - incoming pod labels, those key-value labels are ANDed with labelSelector - to select the group of existing pods over which spreading will be calculated - for the incoming pod. The same key is forbidden to exist in both MatchLabelKeys and LabelSelector. - MatchLabelKeys cannot be set when LabelSelector isn't set. - Keys that don't exist in the incoming pod labels will - be ignored. A null or empty list means only match against labelSelector. - - This is a beta field and requires the MatchLabelKeysInPodTopologySpread feature gate to be enabled (enabled by default). - items: - type: string - type: array - x-kubernetes-list-type: atomic - maxSkew: - description: |- - MaxSkew describes the degree to which pods may be unevenly distributed. - When `whenUnsatisfiable=DoNotSchedule`, it is the maximum permitted difference - between the number of matching pods in the target topology and the global minimum. - The global minimum is the minimum number of matching pods in an eligible domain - or zero if the number of eligible domains is less than MinDomains. - For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same - labelSelector spread as 2/2/1: - In this case, the global minimum is 1. - | zone1 | zone2 | zone3 | - | P P | P P | P | - - if MaxSkew is 1, incoming pod can only be scheduled to zone3 to become 2/2/2; - scheduling it onto zone1(zone2) would make the ActualSkew(3-1) on zone1(zone2) - violate MaxSkew(1). - - if MaxSkew is 2, incoming pod can be scheduled onto any zone. - When `whenUnsatisfiable=ScheduleAnyway`, it is used to give higher precedence - to topologies that satisfy it. - It's a required field. Default value is 1 and 0 is not allowed. - format: int32 - type: integer - minDomains: - description: |- - MinDomains indicates a minimum number of eligible domains. - When the number of eligible domains with matching topology keys is less than minDomains, - Pod Topology Spread treats "global minimum" as 0, and then the calculation of Skew is performed. - And when the number of eligible domains with matching topology keys equals or greater than minDomains, - this value has no effect on scheduling. - As a result, when the number of eligible domains is less than minDomains, - scheduler won't schedule more than maxSkew Pods to those domains. - If value is nil, the constraint behaves as if MinDomains is equal to 1. - Valid values are integers greater than 0. - When value is not nil, WhenUnsatisfiable must be DoNotSchedule. - - For example, in a 3-zone cluster, MaxSkew is set to 2, MinDomains is set to 5 and pods with the same - labelSelector spread as 2/2/2: - | zone1 | zone2 | zone3 | - | P P | P P | P P | - The number of domains is less than 5(MinDomains), so "global minimum" is treated as 0. - In this situation, new pod with the same labelSelector cannot be scheduled, - because computed skew will be 3(3 - 0) if new Pod is scheduled to any of the three zones, - it will violate MaxSkew. - format: int32 - type: integer - nodeAffinityPolicy: - description: |- - NodeAffinityPolicy indicates how we will treat Pod's nodeAffinity/nodeSelector - when calculating pod topology spread skew. Options are: - - Honor: only nodes matching nodeAffinity/nodeSelector are included in the calculations. - - Ignore: nodeAffinity/nodeSelector are ignored. All nodes are included in the calculations. - - If this value is nil, the behavior is equivalent to the Honor policy. - type: string - nodeTaintsPolicy: - description: |- - NodeTaintsPolicy indicates how we will treat node taints when calculating - pod topology spread skew. Options are: - - Honor: nodes without taints, along with tainted nodes for which the incoming pod - has a toleration, are included. - - Ignore: node taints are ignored. All nodes are included. - - If this value is nil, the behavior is equivalent to the Ignore policy. - type: string - topologyKey: - description: |- - TopologyKey is the key of node labels. Nodes that have a label with this key - and identical values are considered to be in the same topology. - We consider each as a "bucket", and try to put balanced number - of pods into each bucket. - We define a domain as a particular instance of a topology. - Also, we define an eligible domain as a domain whose nodes meet the requirements of - nodeAffinityPolicy and nodeTaintsPolicy. - e.g. If TopologyKey is "kubernetes.io/hostname", each Node is a domain of that topology. - And, if TopologyKey is "topology.kubernetes.io/zone", each zone is a domain of that topology. - It's a required field. - type: string - whenUnsatisfiable: - description: |- - WhenUnsatisfiable indicates how to deal with a pod if it doesn't satisfy - the spread constraint. - - DoNotSchedule (default) tells the scheduler not to schedule it. - - ScheduleAnyway tells the scheduler to schedule the pod in any location, - but giving higher precedence to topologies that would help reduce the - skew. - A constraint is considered "Unsatisfiable" for an incoming pod - if and only if every possible node assignment for that pod would violate - "MaxSkew" on some topology. - For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same - labelSelector spread as 3/1/1: - | zone1 | zone2 | zone3 | - | P P P | P | P | - If WhenUnsatisfiable is set to DoNotSchedule, incoming pod can only be scheduled - to zone2(zone3) to become 3/2/1(3/1/2) as ActualSkew(2-1) on zone2(zone3) satisfies - MaxSkew(1). In other words, the cluster can still be imbalanced, but scheduler - won't make it *more* imbalanced. - It's a required field. - type: string - required: - - maxSkew - - topologyKey - - whenUnsatisfiable - type: object - type: array - type: object - type: object - staticEndpoints: - description: |- - Configuration for 'static endpoints' on proxies in order to facilitate - direct connections from other devices on the tailnet. - See https://tailscale.com/kb/1445/kubernetes-operator-customization#static-endpoints. - properties: - nodePort: - description: The configuration for static endpoints using NodePort Services. - properties: - ports: - description: |- - The port ranges from which the operator will select NodePorts for the Services. - You must ensure that firewall rules allow UDP ingress traffic for these ports - to the node's external IPs. - The ports must be in the range of service node ports for the cluster (default `30000-32767`). - See https://kubernetes.io/docs/concepts/services-networking/service/#type-nodeport. - items: - properties: - endPort: - description: |- - endPort indicates that the range of ports from port to endPort if set, inclusive, - should be used. This field cannot be defined if the port field is not defined. - The endPort must be either unset, or equal or greater than port. - type: integer - port: - description: port represents a port selected to be used. This is a required field. - type: integer - required: - - port - type: object - minItems: 1 - type: array - selector: - additionalProperties: - type: string - description: |- - A selector which will be used to select the node's that will have their `ExternalIP`'s advertised - by the ProxyGroup as Static Endpoints. - type: object - required: - - ports - type: object - required: - - nodePort - type: object - tailscale: - description: |- - TailscaleConfig contains options to configure the tailscale-specific - parameters of proxies. - properties: - acceptRoutes: - description: |- - AcceptRoutes can be set to true to make the proxy instance accept - routes advertized by other nodes on the tailnet, such as subnet - routes. - This is equivalent of passing --accept-routes flag to a tailscale Linux client. - https://tailscale.com/kb/1019/subnets#use-your-subnet-routes-from-other-devices - Defaults to false. - type: boolean - type: object - useLetsEncryptStagingEnvironment: - description: |- - Set UseLetsEncryptStagingEnvironment to true to issue TLS - certificates for any HTTPS endpoints exposed to the tailnet from - LetsEncrypt's staging environment. - https://letsencrypt.org/docs/staging-environment/ - This setting only affects Tailscale Ingress resources. - By default Ingress TLS certificates are issued from LetsEncrypt's - production environment. - Changing this setting true -> false, will result in any - existing certs being re-issued from the production environment. - Changing this setting false (default) -> true, when certs have already - been provisioned from production environment will NOT result in certs - being re-issued from the staging environment before they need to be - renewed. - type: boolean - type: object - status: - description: |- - Status of the ProxyClass. This is set and managed automatically. - https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status - properties: - conditions: - description: |- - List of status conditions to indicate the status of the ProxyClass. - Known condition types are `ProxyClassReady`. - items: - description: Condition contains details for one aspect of the current state of this API Resource. - properties: - lastTransitionTime: - description: |- - lastTransitionTime is the last time the condition transitioned from one status to another. - This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. - format: date-time - type: string - message: - description: |- - message is a human readable message indicating details about the transition. - This may be an empty string. - maxLength: 32768 - type: string - observedGeneration: - description: |- - observedGeneration represents the .metadata.generation that the condition was set based upon. - For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date - with respect to the current state of the instance. - format: int64 - minimum: 0 - type: integer - reason: - description: |- - reason contains a programmatic identifier indicating the reason for the condition's last transition. - Producers of specific condition types may define expected values and meanings for this field, - and whether the values are considered a guaranteed API. - The value should be a CamelCase string. - This field may not be empty. - maxLength: 1024 - minLength: 1 - pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ - type: string - status: - description: status of the condition, one of True, False, Unknown. - enum: - - "True" - - "False" - - Unknown - type: string - type: - description: type of condition in CamelCase or in foo.example.com/CamelCase. - maxLength: 316 - pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ - type: string - required: - - lastTransitionTime - - message - - reason - - status - - type - type: object - type: array - x-kubernetes-list-map-keys: - - type - x-kubernetes-list-type: map - type: object - required: - - spec - type: object - served: true - storage: true - subresources: - status: {} ---- -apiVersion: apiextensions.k8s.io/v1 -kind: CustomResourceDefinition -metadata: - annotations: - controller-gen.kubebuilder.io/version: v0.17.0 - name: proxygroups.tailscale.com -spec: - group: tailscale.com - names: - kind: ProxyGroup - listKind: ProxyGroupList - plural: proxygroups - shortNames: - - pg - singular: proxygroup - scope: Cluster - versions: - - additionalPrinterColumns: - - description: Status of the deployed ProxyGroup resources. - jsonPath: .status.conditions[?(@.type == "ProxyGroupReady")].reason - name: Status - type: string - - description: URL of the kube-apiserver proxy advertised by the ProxyGroup devices, if any. Only applies to ProxyGroups of type kube-apiserver. - jsonPath: .status.url - name: URL - type: string - - description: ProxyGroup type. - jsonPath: .spec.type - name: Type - type: string - - jsonPath: .metadata.creationTimestamp - name: Age - type: date - name: v1alpha1 - schema: - openAPIV3Schema: - description: |- - ProxyGroup defines a set of Tailscale devices that will act as proxies. - Depending on spec.Type, it can be a group of egress, ingress, or kube-apiserver - proxies. In addition to running a highly available set of proxies, ingress - and egress ProxyGroups also allow for serving many annotated Services from a - single set of proxies to minimise resource consumption. - - For ingress and egress, use the tailscale.com/proxy-group annotation on a - Service to specify that the proxy should be implemented by a ProxyGroup - instead of a single dedicated proxy. - - More info: - * https://tailscale.com/kb/1438/kubernetes-operator-cluster-egress - * https://tailscale.com/kb/1439/kubernetes-operator-cluster-ingress - - For kube-apiserver, the ProxyGroup is a standalone resource. Use the - spec.kubeAPIServer field to configure options specific to the kube-apiserver - ProxyGroup type. - properties: - apiVersion: - description: |- - APIVersion defines the versioned schema of this representation of an object. - Servers should convert recognized schemas to the latest internal value, and - may reject unrecognized values. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources - type: string - kind: - description: |- - Kind is a string value representing the REST resource this object represents. - Servers may infer this from the endpoint the client submits requests to. - Cannot be updated. - In CamelCase. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds - type: string - metadata: - type: object - spec: - description: Spec describes the desired ProxyGroup instances. - properties: - hostnamePrefix: - description: |- - HostnamePrefix is the hostname prefix to use for tailnet devices created - by the ProxyGroup. Each device will have the integer number from its - StatefulSet pod appended to this prefix to form the full hostname. - HostnamePrefix can contain lower case letters, numbers and dashes, it - must not start with a dash and must be between 1 and 62 characters long. - pattern: ^[a-z0-9][a-z0-9-]{0,61}$ - type: string - kubeAPIServer: - description: |- - KubeAPIServer contains configuration specific to the kube-apiserver - ProxyGroup type. This field is only used when Type is set to "kube-apiserver". - properties: - hostname: - description: |- - Hostname is the hostname with which to expose the Kubernetes API server - proxies. Must be a valid DNS label no longer than 63 characters. If not - specified, the name of the ProxyGroup is used as the hostname. Must be - unique across the whole tailnet. - pattern: ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ - type: string - mode: - description: |- - Mode to run the API server proxy in. Supported modes are auth and noauth. - In auth mode, requests from the tailnet proxied over to the Kubernetes - API server are additionally impersonated using the sender's tailnet identity. - If not specified, defaults to auth mode. - enum: - - auth - - noauth - type: string - type: object - proxyClass: - description: |- - ProxyClass is the name of the ProxyClass custom resource that contains - configuration options that should be applied to the resources created - for this ProxyGroup. If unset, and there is no default ProxyClass - configured, the operator will create resources with the default - configuration. - type: string - replicas: - description: |- - Replicas specifies how many replicas to create the StatefulSet with. - Defaults to 2. - format: int32 - minimum: 0 - type: integer - tags: - description: |- - Tags that the Tailscale devices will be tagged with. Defaults to [tag:k8s]. - If you specify custom tags here, make sure you also make the operator - an owner of these tags. - See https://tailscale.com/kb/1236/kubernetes-operator/#setting-up-the-kubernetes-operator. - Tags cannot be changed once a ProxyGroup device has been created. - Tag values must be in form ^tag:[a-zA-Z][a-zA-Z0-9-]*$. - items: - pattern: ^tag:[a-zA-Z][a-zA-Z0-9-]*$ - type: string - type: array - type: - description: |- - Type of the ProxyGroup proxies. Supported types are egress, ingress, and kube-apiserver. - Type is immutable once a ProxyGroup is created. - enum: - - egress - - ingress - - kube-apiserver - type: string - x-kubernetes-validations: - - message: ProxyGroup type is immutable - rule: self == oldSelf - required: - - type - type: object - status: - description: |- - ProxyGroupStatus describes the status of the ProxyGroup resources. This is - set and managed by the Tailscale operator. - properties: - conditions: - description: |- - List of status conditions to indicate the status of the ProxyGroup - resources. Known condition types include `ProxyGroupReady` and - `ProxyGroupAvailable`. - - * `ProxyGroupReady` indicates all ProxyGroup resources are reconciled and - all expected conditions are true. - * `ProxyGroupAvailable` indicates that at least one proxy is ready to - serve traffic. - - For ProxyGroups of type kube-apiserver, there are two additional conditions: - - * `KubeAPIServerProxyConfigured` indicates that at least one API server - proxy is configured and ready to serve traffic. - * `KubeAPIServerProxyValid` indicates that spec.kubeAPIServer config is - valid. - items: - description: Condition contains details for one aspect of the current state of this API Resource. - properties: - lastTransitionTime: - description: |- - lastTransitionTime is the last time the condition transitioned from one status to another. - This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. - format: date-time - type: string - message: - description: |- - message is a human readable message indicating details about the transition. - This may be an empty string. - maxLength: 32768 - type: string - observedGeneration: - description: |- - observedGeneration represents the .metadata.generation that the condition was set based upon. - For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date - with respect to the current state of the instance. - format: int64 - minimum: 0 - type: integer - reason: - description: |- - reason contains a programmatic identifier indicating the reason for the condition's last transition. - Producers of specific condition types may define expected values and meanings for this field, - and whether the values are considered a guaranteed API. - The value should be a CamelCase string. - This field may not be empty. - maxLength: 1024 - minLength: 1 - pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ - type: string - status: - description: status of the condition, one of True, False, Unknown. - enum: - - "True" - - "False" - - Unknown - type: string - type: - description: type of condition in CamelCase or in foo.example.com/CamelCase. - maxLength: 316 - pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ - type: string - required: - - lastTransitionTime - - message - - reason - - status - - type - type: object - type: array - x-kubernetes-list-map-keys: - - type - x-kubernetes-list-type: map - devices: - description: List of tailnet devices associated with the ProxyGroup StatefulSet. - items: - properties: - hostname: - description: |- - Hostname is the fully qualified domain name of the device. - If MagicDNS is enabled in your tailnet, it is the MagicDNS name of the - node. - type: string - staticEndpoints: - description: StaticEndpoints are user configured, 'static' endpoints by which tailnet peers can reach this device. - items: - type: string - type: array - tailnetIPs: - description: |- - TailnetIPs is the set of tailnet IP addresses (both IPv4 and IPv6) - assigned to the device. - items: - type: string - type: array - required: - - hostname - type: object - type: array - x-kubernetes-list-map-keys: - - hostname - x-kubernetes-list-type: map - url: - description: |- - URL of the kube-apiserver proxy advertised by the ProxyGroup devices, if - any. Only applies to ProxyGroups of type kube-apiserver. - type: string - type: object - required: - - spec - type: object - served: true - storage: true - subresources: - status: {} ---- -apiVersion: apiextensions.k8s.io/v1 -kind: CustomResourceDefinition -metadata: - annotations: - controller-gen.kubebuilder.io/version: v0.17.0 - name: recorders.tailscale.com -spec: - group: tailscale.com - names: - kind: Recorder - listKind: RecorderList - plural: recorders - shortNames: - - rec - singular: recorder - scope: Cluster - versions: - - additionalPrinterColumns: - - description: Status of the deployed Recorder resources. - jsonPath: .status.conditions[?(@.type == "RecorderReady")].reason - name: Status - type: string - - description: URL on which the UI is exposed if enabled. - jsonPath: .status.devices[?(@.url != "")].url - name: URL - type: string - - jsonPath: .metadata.creationTimestamp - name: Age - type: date - name: v1alpha1 - schema: - openAPIV3Schema: - description: |- - Recorder defines a tsrecorder device for recording SSH sessions. By default, - it will store recordings in a local ephemeral volume. If you want to persist - recordings, you can configure an S3-compatible API for storage. - - More info: https://tailscale.com/kb/1484/kubernetes-operator-deploying-tsrecorder - properties: - apiVersion: - description: |- - APIVersion defines the versioned schema of this representation of an object. - Servers should convert recognized schemas to the latest internal value, and - may reject unrecognized values. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources - type: string - kind: - description: |- - Kind is a string value representing the REST resource this object represents. - Servers may infer this from the endpoint the client submits requests to. - Cannot be updated. - In CamelCase. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds - type: string - metadata: - type: object - spec: - description: Spec describes the desired recorder instance. - properties: - enableUI: - description: |- - Set to true to enable the Recorder UI. The UI lists and plays recorded sessions. - The UI will be served at :443. Defaults to false. - Corresponds to --ui tsrecorder flag https://tailscale.com/kb/1246/tailscale-ssh-session-recording#deploy-a-recorder-node. - Required if S3 storage is not set up, to ensure that recordings are accessible. - type: boolean - replicas: - description: Replicas specifies how many instances of tsrecorder to run. Defaults to 1. - format: int32 - minimum: 0 - type: integer - statefulSet: - description: |- - Configuration parameters for the Recorder's StatefulSet. The operator - deploys a StatefulSet for each Recorder resource. - properties: - annotations: - additionalProperties: - type: string - description: |- - Annotations that will be added to the StatefulSet created for the Recorder. - Any Annotations specified here will be merged with the default annotations - applied to the StatefulSet by the operator. - https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/#syntax-and-character-set - type: object - labels: - additionalProperties: - type: string - description: |- - Labels that will be added to the StatefulSet created for the Recorder. - Any labels specified here will be merged with the default labels applied - to the StatefulSet by the operator. - https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#syntax-and-character-set - type: object - pod: - description: Configuration for pods created by the Recorder's StatefulSet. - properties: - affinity: - description: |- - Affinity rules for Recorder Pods. By default, the operator does not - apply any affinity rules. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#affinity - properties: - nodeAffinity: - description: Describes node affinity scheduling rules for the pod. - properties: - preferredDuringSchedulingIgnoredDuringExecution: - description: |- - The scheduler will prefer to schedule pods to nodes that satisfy - the affinity expressions specified by this field, but it may choose - a node that violates one or more of the expressions. The node that is - most preferred is the one with the greatest sum of weights, i.e. - for each node that meets all of the scheduling requirements (resource - request, requiredDuringScheduling affinity expressions, etc.), - compute a sum by iterating through the elements of this field and adding - "weight" to the sum if the node matches the corresponding matchExpressions; the - node(s) with the highest sum are the most preferred. - items: - description: |- - An empty preferred scheduling term matches all objects with implicit weight 0 - (i.e. it's a no-op). A null preferred scheduling term matches no objects (i.e. is also a no-op). - properties: - preference: - description: A node selector term, associated with the corresponding weight. - properties: - matchExpressions: - description: A list of node selector requirements by node's labels. - items: - description: |- - A node selector requirement is a selector that contains values, a key, and an operator - that relates the key and values. - properties: - key: - description: The label key that the selector applies to. - type: string - operator: - description: |- - Represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. - type: string - values: - description: |- - An array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. If the operator is Gt or Lt, the values - array must have a single element, which will be interpreted as an integer. - This array is replaced during a strategic merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - matchFields: - description: A list of node selector requirements by node's fields. - items: - description: |- - A node selector requirement is a selector that contains values, a key, and an operator - that relates the key and values. - properties: - key: - description: The label key that the selector applies to. - type: string - operator: - description: |- - Represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. - type: string - values: - description: |- - An array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. If the operator is Gt or Lt, the values - array must have a single element, which will be interpreted as an integer. - This array is replaced during a strategic merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - type: object - x-kubernetes-map-type: atomic - weight: - description: Weight associated with matching the corresponding nodeSelectorTerm, in the range 1-100. - format: int32 - type: integer - required: - - preference - - weight - type: object - type: array - x-kubernetes-list-type: atomic - requiredDuringSchedulingIgnoredDuringExecution: - description: |- - If the affinity requirements specified by this field are not met at - scheduling time, the pod will not be scheduled onto the node. - If the affinity requirements specified by this field cease to be met - at some point during pod execution (e.g. due to an update), the system - may or may not try to eventually evict the pod from its node. - properties: - nodeSelectorTerms: - description: Required. A list of node selector terms. The terms are ORed. - items: - description: |- - A null or empty node selector term matches no objects. The requirements of - them are ANDed. - The TopologySelectorTerm type implements a subset of the NodeSelectorTerm. - properties: - matchExpressions: - description: A list of node selector requirements by node's labels. - items: - description: |- - A node selector requirement is a selector that contains values, a key, and an operator - that relates the key and values. - properties: - key: - description: The label key that the selector applies to. - type: string - operator: - description: |- - Represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. - type: string - values: - description: |- - An array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. If the operator is Gt or Lt, the values - array must have a single element, which will be interpreted as an integer. - This array is replaced during a strategic merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - matchFields: - description: A list of node selector requirements by node's fields. - items: - description: |- - A node selector requirement is a selector that contains values, a key, and an operator - that relates the key and values. - properties: - key: - description: The label key that the selector applies to. - type: string - operator: - description: |- - Represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. - type: string - values: - description: |- - An array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. If the operator is Gt or Lt, the values - array must have a single element, which will be interpreted as an integer. - This array is replaced during a strategic merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - type: object - x-kubernetes-map-type: atomic - type: array - x-kubernetes-list-type: atomic - required: - - nodeSelectorTerms - type: object - x-kubernetes-map-type: atomic - type: object - podAffinity: - description: Describes pod affinity scheduling rules (e.g. co-locate this pod in the same node, zone, etc. as some other pod(s)). - properties: - preferredDuringSchedulingIgnoredDuringExecution: - description: |- - The scheduler will prefer to schedule pods to nodes that satisfy - the affinity expressions specified by this field, but it may choose - a node that violates one or more of the expressions. The node that is - most preferred is the one with the greatest sum of weights, i.e. - for each node that meets all of the scheduling requirements (resource - request, requiredDuringScheduling affinity expressions, etc.), - compute a sum by iterating through the elements of this field and adding - "weight" to the sum if the node has pods which matches the corresponding podAffinityTerm; the - node(s) with the highest sum are the most preferred. - items: - description: The weights of all of the matched WeightedPodAffinityTerm fields are added per-node to find the most preferred node(s) - properties: - podAffinityTerm: - description: Required. A pod affinity term, associated with the corresponding weight. - properties: - labelSelector: - description: |- - A label query over a set of resources, in this case pods. - If it's null, this PodAffinityTerm matches with no Pods. - properties: - matchExpressions: - description: matchExpressions is a list of label selector requirements. The requirements are ANDed. - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - properties: - key: - description: key is the label key that the selector applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - matchLabels: - additionalProperties: - type: string - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - type: object - x-kubernetes-map-type: atomic - matchLabelKeys: - description: |- - MatchLabelKeys is a set of pod label keys to select which pods will - be taken into consideration. The keys are used to lookup values from the - incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` - to select the group of existing pods which pods will be taken into consideration - for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming - pod labels will be ignored. The default value is empty. - The same key is forbidden to exist in both matchLabelKeys and labelSelector. - Also, matchLabelKeys cannot be set when labelSelector isn't set. - items: - type: string - type: array - x-kubernetes-list-type: atomic - mismatchLabelKeys: - description: |- - MismatchLabelKeys is a set of pod label keys to select which pods will - be taken into consideration. The keys are used to lookup values from the - incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` - to select the group of existing pods which pods will be taken into consideration - for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming - pod labels will be ignored. The default value is empty. - The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. - Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - items: - type: string - type: array - x-kubernetes-list-type: atomic - namespaceSelector: - description: |- - A label query over the set of namespaces that the term applies to. - The term is applied to the union of the namespaces selected by this field - and the ones listed in the namespaces field. - null selector and null or empty namespaces list means "this pod's namespace". - An empty selector ({}) matches all namespaces. - properties: - matchExpressions: - description: matchExpressions is a list of label selector requirements. The requirements are ANDed. - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - properties: - key: - description: key is the label key that the selector applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - matchLabels: - additionalProperties: - type: string - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - type: object - x-kubernetes-map-type: atomic - namespaces: - description: |- - namespaces specifies a static list of namespace names that the term applies to. - The term is applied to the union of the namespaces listed in this field - and the ones selected by namespaceSelector. - null or empty namespaces list and null namespaceSelector means "this pod's namespace". - items: - type: string - type: array - x-kubernetes-list-type: atomic - topologyKey: - description: |- - This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching - the labelSelector in the specified namespaces, where co-located is defined as running on a node - whose value of the label with key topologyKey matches that of any node on which any of the - selected pods is running. - Empty topologyKey is not allowed. - type: string - required: - - topologyKey - type: object - weight: - description: |- - weight associated with matching the corresponding podAffinityTerm, - in the range 1-100. - format: int32 - type: integer - required: - - podAffinityTerm - - weight - type: object - type: array - x-kubernetes-list-type: atomic - requiredDuringSchedulingIgnoredDuringExecution: - description: |- - If the affinity requirements specified by this field are not met at - scheduling time, the pod will not be scheduled onto the node. - If the affinity requirements specified by this field cease to be met - at some point during pod execution (e.g. due to a pod label update), the - system may or may not try to eventually evict the pod from its node. - When there are multiple elements, the lists of nodes corresponding to each - podAffinityTerm are intersected, i.e. all terms must be satisfied. - items: - description: |- - Defines a set of pods (namely those matching the labelSelector - relative to the given namespace(s)) that this pod should be - co-located (affinity) or not co-located (anti-affinity) with, - where co-located is defined as running on a node whose value of - the label with key matches that of any node on which - a pod of the set of pods is running - properties: - labelSelector: - description: |- - A label query over a set of resources, in this case pods. - If it's null, this PodAffinityTerm matches with no Pods. - properties: - matchExpressions: - description: matchExpressions is a list of label selector requirements. The requirements are ANDed. - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - properties: - key: - description: key is the label key that the selector applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - matchLabels: - additionalProperties: - type: string - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - type: object - x-kubernetes-map-type: atomic - matchLabelKeys: - description: |- - MatchLabelKeys is a set of pod label keys to select which pods will - be taken into consideration. The keys are used to lookup values from the - incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` - to select the group of existing pods which pods will be taken into consideration - for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming - pod labels will be ignored. The default value is empty. - The same key is forbidden to exist in both matchLabelKeys and labelSelector. - Also, matchLabelKeys cannot be set when labelSelector isn't set. - items: - type: string - type: array - x-kubernetes-list-type: atomic - mismatchLabelKeys: - description: |- - MismatchLabelKeys is a set of pod label keys to select which pods will - be taken into consideration. The keys are used to lookup values from the - incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` - to select the group of existing pods which pods will be taken into consideration - for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming - pod labels will be ignored. The default value is empty. - The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. - Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - items: - type: string - type: array - x-kubernetes-list-type: atomic - namespaceSelector: - description: |- - A label query over the set of namespaces that the term applies to. - The term is applied to the union of the namespaces selected by this field - and the ones listed in the namespaces field. - null selector and null or empty namespaces list means "this pod's namespace". - An empty selector ({}) matches all namespaces. - properties: - matchExpressions: - description: matchExpressions is a list of label selector requirements. The requirements are ANDed. - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - properties: - key: - description: key is the label key that the selector applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - matchLabels: - additionalProperties: - type: string - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - type: object - x-kubernetes-map-type: atomic - namespaces: - description: |- - namespaces specifies a static list of namespace names that the term applies to. - The term is applied to the union of the namespaces listed in this field - and the ones selected by namespaceSelector. - null or empty namespaces list and null namespaceSelector means "this pod's namespace". - items: - type: string - type: array - x-kubernetes-list-type: atomic - topologyKey: - description: |- - This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching - the labelSelector in the specified namespaces, where co-located is defined as running on a node - whose value of the label with key topologyKey matches that of any node on which any of the - selected pods is running. - Empty topologyKey is not allowed. - type: string - required: - - topologyKey - type: object - type: array - x-kubernetes-list-type: atomic - type: object - podAntiAffinity: - description: Describes pod anti-affinity scheduling rules (e.g. avoid putting this pod in the same node, zone, etc. as some other pod(s)). - properties: - preferredDuringSchedulingIgnoredDuringExecution: - description: |- - The scheduler will prefer to schedule pods to nodes that satisfy - the anti-affinity expressions specified by this field, but it may choose - a node that violates one or more of the expressions. The node that is - most preferred is the one with the greatest sum of weights, i.e. - for each node that meets all of the scheduling requirements (resource - request, requiredDuringScheduling anti-affinity expressions, etc.), - compute a sum by iterating through the elements of this field and subtracting - "weight" from the sum if the node has pods which matches the corresponding podAffinityTerm; the - node(s) with the highest sum are the most preferred. - items: - description: The weights of all of the matched WeightedPodAffinityTerm fields are added per-node to find the most preferred node(s) - properties: - podAffinityTerm: - description: Required. A pod affinity term, associated with the corresponding weight. - properties: - labelSelector: - description: |- - A label query over a set of resources, in this case pods. - If it's null, this PodAffinityTerm matches with no Pods. - properties: - matchExpressions: - description: matchExpressions is a list of label selector requirements. The requirements are ANDed. - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - properties: - key: - description: key is the label key that the selector applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - matchLabels: - additionalProperties: - type: string - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - type: object - x-kubernetes-map-type: atomic - matchLabelKeys: - description: |- - MatchLabelKeys is a set of pod label keys to select which pods will - be taken into consideration. The keys are used to lookup values from the - incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` - to select the group of existing pods which pods will be taken into consideration - for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming - pod labels will be ignored. The default value is empty. - The same key is forbidden to exist in both matchLabelKeys and labelSelector. - Also, matchLabelKeys cannot be set when labelSelector isn't set. - items: - type: string - type: array - x-kubernetes-list-type: atomic - mismatchLabelKeys: - description: |- - MismatchLabelKeys is a set of pod label keys to select which pods will - be taken into consideration. The keys are used to lookup values from the - incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` - to select the group of existing pods which pods will be taken into consideration - for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming - pod labels will be ignored. The default value is empty. - The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. - Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - items: - type: string - type: array - x-kubernetes-list-type: atomic - namespaceSelector: - description: |- - A label query over the set of namespaces that the term applies to. - The term is applied to the union of the namespaces selected by this field - and the ones listed in the namespaces field. - null selector and null or empty namespaces list means "this pod's namespace". - An empty selector ({}) matches all namespaces. - properties: - matchExpressions: - description: matchExpressions is a list of label selector requirements. The requirements are ANDed. - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - properties: - key: - description: key is the label key that the selector applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - matchLabels: - additionalProperties: - type: string - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - type: object - x-kubernetes-map-type: atomic - namespaces: - description: |- - namespaces specifies a static list of namespace names that the term applies to. - The term is applied to the union of the namespaces listed in this field - and the ones selected by namespaceSelector. - null or empty namespaces list and null namespaceSelector means "this pod's namespace". - items: - type: string - type: array - x-kubernetes-list-type: atomic - topologyKey: - description: |- - This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching - the labelSelector in the specified namespaces, where co-located is defined as running on a node - whose value of the label with key topologyKey matches that of any node on which any of the - selected pods is running. - Empty topologyKey is not allowed. - type: string - required: - - topologyKey - type: object - weight: - description: |- - weight associated with matching the corresponding podAffinityTerm, - in the range 1-100. - format: int32 - type: integer - required: - - podAffinityTerm - - weight - type: object - type: array - x-kubernetes-list-type: atomic - requiredDuringSchedulingIgnoredDuringExecution: - description: |- - If the anti-affinity requirements specified by this field are not met at - scheduling time, the pod will not be scheduled onto the node. - If the anti-affinity requirements specified by this field cease to be met - at some point during pod execution (e.g. due to a pod label update), the - system may or may not try to eventually evict the pod from its node. - When there are multiple elements, the lists of nodes corresponding to each - podAffinityTerm are intersected, i.e. all terms must be satisfied. - items: - description: |- - Defines a set of pods (namely those matching the labelSelector - relative to the given namespace(s)) that this pod should be - co-located (affinity) or not co-located (anti-affinity) with, - where co-located is defined as running on a node whose value of - the label with key matches that of any node on which - a pod of the set of pods is running - properties: - labelSelector: - description: |- - A label query over a set of resources, in this case pods. - If it's null, this PodAffinityTerm matches with no Pods. - properties: - matchExpressions: - description: matchExpressions is a list of label selector requirements. The requirements are ANDed. - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - properties: - key: - description: key is the label key that the selector applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - matchLabels: - additionalProperties: - type: string - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - type: object - x-kubernetes-map-type: atomic - matchLabelKeys: - description: |- - MatchLabelKeys is a set of pod label keys to select which pods will - be taken into consideration. The keys are used to lookup values from the - incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` - to select the group of existing pods which pods will be taken into consideration - for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming - pod labels will be ignored. The default value is empty. - The same key is forbidden to exist in both matchLabelKeys and labelSelector. - Also, matchLabelKeys cannot be set when labelSelector isn't set. - items: - type: string - type: array - x-kubernetes-list-type: atomic - mismatchLabelKeys: - description: |- - MismatchLabelKeys is a set of pod label keys to select which pods will - be taken into consideration. The keys are used to lookup values from the - incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` - to select the group of existing pods which pods will be taken into consideration - for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming - pod labels will be ignored. The default value is empty. - The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. - Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - items: - type: string - type: array - x-kubernetes-list-type: atomic - namespaceSelector: - description: |- - A label query over the set of namespaces that the term applies to. - The term is applied to the union of the namespaces selected by this field - and the ones listed in the namespaces field. - null selector and null or empty namespaces list means "this pod's namespace". - An empty selector ({}) matches all namespaces. - properties: - matchExpressions: - description: matchExpressions is a list of label selector requirements. The requirements are ANDed. - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - properties: - key: - description: key is the label key that the selector applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - matchLabels: - additionalProperties: - type: string - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - type: object - x-kubernetes-map-type: atomic - namespaces: - description: |- - namespaces specifies a static list of namespace names that the term applies to. - The term is applied to the union of the namespaces listed in this field - and the ones selected by namespaceSelector. - null or empty namespaces list and null namespaceSelector means "this pod's namespace". - items: - type: string - type: array - x-kubernetes-list-type: atomic - topologyKey: - description: |- - This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching - the labelSelector in the specified namespaces, where co-located is defined as running on a node - whose value of the label with key topologyKey matches that of any node on which any of the - selected pods is running. - Empty topologyKey is not allowed. - type: string - required: - - topologyKey - type: object - type: array - x-kubernetes-list-type: atomic - type: object - type: object - annotations: - additionalProperties: - type: string - description: |- - Annotations that will be added to Recorder Pods. Any annotations - specified here will be merged with the default annotations applied to - the Pod by the operator. - https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/#syntax-and-character-set - type: object - container: - description: Configuration for the Recorder container running tailscale. - properties: - env: - description: |- - List of environment variables to set in the container. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#environment-variables - Note that environment variables provided here will take precedence - over Tailscale-specific environment variables set by the operator, - however running proxies with custom values for Tailscale environment - variables (i.e TS_USERSPACE) is not recommended and might break in - the future. - items: - properties: - name: - description: Name of the environment variable. Must be a C_IDENTIFIER. - pattern: ^[-._a-zA-Z][-._a-zA-Z0-9]*$ - type: string - value: - description: |- - Variable references $(VAR_NAME) are expanded using the previously defined - environment variables in the container and any service environment - variables. If a variable cannot be resolved, the reference in the input - string will be unchanged. Double $$ are reduced to a single $, which - allows for escaping the $(VAR_NAME) syntax: i.e. "$$(VAR_NAME)" will - produce the string literal "$(VAR_NAME)". Escaped references will never - be expanded, regardless of whether the variable exists or not. Defaults - to "". - type: string - required: - - name - type: object - type: array - image: - description: |- - Container image name including tag. Defaults to docker.io/tailscale/tsrecorder - with the same tag as the operator, but the official images are also - available at ghcr.io/tailscale/tsrecorder. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#image - type: string - imagePullPolicy: - description: |- - Image pull policy. One of Always, Never, IfNotPresent. Defaults to Always. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#image - enum: - - Always - - Never - - IfNotPresent - type: string - resources: - description: |- - Container resource requirements. - By default, the operator does not apply any resource requirements. The - amount of resources required wil depend on the volume of recordings sent. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#resources - properties: - claims: - description: |- - Claims lists the names of resources, defined in spec.resourceClaims, - that are used by this container. - - This field depends on the - DynamicResourceAllocation feature gate. - - This field is immutable. It can only be set for containers. - items: - description: ResourceClaim references one entry in PodSpec.ResourceClaims. - properties: - name: - description: |- - Name must match the name of one entry in pod.spec.resourceClaims of - the Pod where this field is used. It makes that resource available - inside a container. - type: string - request: - description: |- - Request is the name chosen for a request in the referenced claim. - If empty, everything from the claim is made available, otherwise - only the result of this request. - type: string - required: - - name - type: object - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - limits: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Limits describes the maximum amount of compute resources allowed. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - requests: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Requests describes the minimum amount of compute resources required. - If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, - otherwise to an implementation-defined value. Requests cannot exceed Limits. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - type: object - securityContext: - description: |- - Container security context. By default, the operator does not apply any - container security context. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#security-context - properties: - allowPrivilegeEscalation: - description: |- - AllowPrivilegeEscalation controls whether a process can gain more - privileges than its parent process. This bool directly controls if - the no_new_privs flag will be set on the container process. - AllowPrivilegeEscalation is true always when the container is: - 1) run as Privileged - 2) has CAP_SYS_ADMIN - Note that this field cannot be set when spec.os.name is windows. - type: boolean - appArmorProfile: - description: |- - appArmorProfile is the AppArmor options to use by this container. If set, this profile - overrides the pod's appArmorProfile. - Note that this field cannot be set when spec.os.name is windows. - properties: - localhostProfile: - description: |- - localhostProfile indicates a profile loaded on the node that should be used. - The profile must be preconfigured on the node to work. - Must match the loaded name of the profile. - Must be set if and only if type is "Localhost". - type: string - type: - description: |- - type indicates which kind of AppArmor profile will be applied. - Valid options are: - Localhost - a profile pre-loaded on the node. - RuntimeDefault - the container runtime's default profile. - Unconfined - no AppArmor enforcement. - type: string - required: - - type - type: object - capabilities: - description: |- - The capabilities to add/drop when running containers. - Defaults to the default set of capabilities granted by the container runtime. - Note that this field cannot be set when spec.os.name is windows. - properties: - add: - description: Added capabilities - items: - description: Capability represent POSIX capabilities type - type: string - type: array - x-kubernetes-list-type: atomic - drop: - description: Removed capabilities - items: - description: Capability represent POSIX capabilities type - type: string - type: array - x-kubernetes-list-type: atomic - type: object - privileged: - description: |- - Run container in privileged mode. - Processes in privileged containers are essentially equivalent to root on the host. - Defaults to false. - Note that this field cannot be set when spec.os.name is windows. - type: boolean - procMount: - description: |- - procMount denotes the type of proc mount to use for the containers. - The default value is Default which uses the container runtime defaults for - readonly paths and masked paths. - This requires the ProcMountType feature flag to be enabled. - Note that this field cannot be set when spec.os.name is windows. - type: string - readOnlyRootFilesystem: - description: |- - Whether this container has a read-only root filesystem. - Default is false. - Note that this field cannot be set when spec.os.name is windows. - type: boolean - runAsGroup: - description: |- - The GID to run the entrypoint of the container process. - Uses runtime default if unset. - May also be set in PodSecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - Note that this field cannot be set when spec.os.name is windows. - format: int64 - type: integer - runAsNonRoot: - description: |- - Indicates that the container must run as a non-root user. - If true, the Kubelet will validate the image at runtime to ensure that it - does not run as UID 0 (root) and fail to start the container if it does. - If unset or false, no such validation will be performed. - May also be set in PodSecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - type: boolean - runAsUser: - description: |- - The UID to run the entrypoint of the container process. - Defaults to user specified in image metadata if unspecified. - May also be set in PodSecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - Note that this field cannot be set when spec.os.name is windows. - format: int64 - type: integer - seLinuxOptions: - description: |- - The SELinux context to be applied to the container. - If unspecified, the container runtime will allocate a random SELinux context for each - container. May also be set in PodSecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - Note that this field cannot be set when spec.os.name is windows. - properties: - level: - description: Level is SELinux level label that applies to the container. - type: string - role: - description: Role is a SELinux role label that applies to the container. - type: string - type: - description: Type is a SELinux type label that applies to the container. - type: string - user: - description: User is a SELinux user label that applies to the container. - type: string - type: object - seccompProfile: - description: |- - The seccomp options to use by this container. If seccomp options are - provided at both the pod & container level, the container options - override the pod options. - Note that this field cannot be set when spec.os.name is windows. - properties: - localhostProfile: - description: |- - localhostProfile indicates a profile defined in a file on the node should be used. - The profile must be preconfigured on the node to work. - Must be a descending path, relative to the kubelet's configured seccomp profile location. - Must be set if type is "Localhost". Must NOT be set for any other type. - type: string - type: - description: |- - type indicates which kind of seccomp profile will be applied. - Valid options are: - - Localhost - a profile defined in a file on the node should be used. - RuntimeDefault - the container runtime default profile should be used. - Unconfined - no profile should be applied. - type: string - required: - - type - type: object - windowsOptions: - description: |- - The Windows specific settings applied to all containers. - If unspecified, the options from the PodSecurityContext will be used. - If set in both SecurityContext and PodSecurityContext, the value specified in SecurityContext takes precedence. - Note that this field cannot be set when spec.os.name is linux. - properties: - gmsaCredentialSpec: - description: |- - GMSACredentialSpec is where the GMSA admission webhook - (https://github.com/kubernetes-sigs/windows-gmsa) inlines the contents of the - GMSA credential spec named by the GMSACredentialSpecName field. - type: string - gmsaCredentialSpecName: - description: GMSACredentialSpecName is the name of the GMSA credential spec to use. - type: string - hostProcess: - description: |- - HostProcess determines if a container should be run as a 'Host Process' container. - All of a Pod's containers must have the same effective HostProcess value - (it is not allowed to have a mix of HostProcess containers and non-HostProcess containers). - In addition, if HostProcess is true then HostNetwork must also be set to true. - type: boolean - runAsUserName: - description: |- - The UserName in Windows to run the entrypoint of the container process. - Defaults to the user specified in image metadata if unspecified. - May also be set in PodSecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - type: string - type: object - type: object - type: object - imagePullSecrets: - description: |- - Image pull Secrets for Recorder Pods. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#PodSpec - items: - description: |- - LocalObjectReference contains enough information to let you locate the - referenced object inside the same namespace. - properties: - name: - default: "" - description: |- - Name of the referent. - This field is effectively required, but due to backwards compatibility is - allowed to be empty. Instances of this type with an empty value here are - almost certainly wrong. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - type: string - type: object - x-kubernetes-map-type: atomic - type: array - labels: - additionalProperties: - type: string - description: |- - Labels that will be added to Recorder Pods. Any labels specified here - will be merged with the default labels applied to the Pod by the operator. - https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#syntax-and-character-set - type: object - nodeSelector: - additionalProperties: - type: string - description: |- - Node selector rules for Recorder Pods. By default, the operator does - not apply any node selector rules. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#scheduling - type: object - securityContext: - description: |- - Security context for Recorder Pods. By default, the operator does not - apply any Pod security context. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#security-context-2 - properties: - appArmorProfile: - description: |- - appArmorProfile is the AppArmor options to use by the containers in this pod. - Note that this field cannot be set when spec.os.name is windows. - properties: - localhostProfile: - description: |- - localhostProfile indicates a profile loaded on the node that should be used. - The profile must be preconfigured on the node to work. - Must match the loaded name of the profile. - Must be set if and only if type is "Localhost". - type: string - type: - description: |- - type indicates which kind of AppArmor profile will be applied. - Valid options are: - Localhost - a profile pre-loaded on the node. - RuntimeDefault - the container runtime's default profile. - Unconfined - no AppArmor enforcement. - type: string - required: - - type - type: object - fsGroup: - description: |- - A special supplemental group that applies to all containers in a pod. - Some volume types allow the Kubelet to change the ownership of that volume - to be owned by the pod: - - 1. The owning GID will be the FSGroup - 2. The setgid bit is set (new files created in the volume will be owned by FSGroup) - 3. The permission bits are OR'd with rw-rw---- - - If unset, the Kubelet will not modify the ownership and permissions of any volume. - Note that this field cannot be set when spec.os.name is windows. - format: int64 - type: integer - fsGroupChangePolicy: - description: |- - fsGroupChangePolicy defines behavior of changing ownership and permission of the volume - before being exposed inside Pod. This field will only apply to - volume types which support fsGroup based ownership(and permissions). - It will have no effect on ephemeral volume types such as: secret, configmaps - and emptydir. - Valid values are "OnRootMismatch" and "Always". If not specified, "Always" is used. - Note that this field cannot be set when spec.os.name is windows. - type: string - runAsGroup: - description: |- - The GID to run the entrypoint of the container process. - Uses runtime default if unset. - May also be set in SecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence - for that container. - Note that this field cannot be set when spec.os.name is windows. - format: int64 - type: integer - runAsNonRoot: - description: |- - Indicates that the container must run as a non-root user. - If true, the Kubelet will validate the image at runtime to ensure that it - does not run as UID 0 (root) and fail to start the container if it does. - If unset or false, no such validation will be performed. - May also be set in SecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - type: boolean - runAsUser: - description: |- - The UID to run the entrypoint of the container process. - Defaults to user specified in image metadata if unspecified. - May also be set in SecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence - for that container. - Note that this field cannot be set when spec.os.name is windows. - format: int64 - type: integer - seLinuxChangePolicy: - description: |- - seLinuxChangePolicy defines how the container's SELinux label is applied to all volumes used by the Pod. - It has no effect on nodes that do not support SELinux or to volumes does not support SELinux. - Valid values are "MountOption" and "Recursive". - - "Recursive" means relabeling of all files on all Pod volumes by the container runtime. - This may be slow for large volumes, but allows mixing privileged and unprivileged Pods sharing the same volume on the same node. - - "MountOption" mounts all eligible Pod volumes with `-o context` mount option. - This requires all Pods that share the same volume to use the same SELinux label. - It is not possible to share the same volume among privileged and unprivileged Pods. - Eligible volumes are in-tree FibreChannel and iSCSI volumes, and all CSI volumes - whose CSI driver announces SELinux support by setting spec.seLinuxMount: true in their - CSIDriver instance. Other volumes are always re-labelled recursively. - "MountOption" value is allowed only when SELinuxMount feature gate is enabled. - - If not specified and SELinuxMount feature gate is enabled, "MountOption" is used. - If not specified and SELinuxMount feature gate is disabled, "MountOption" is used for ReadWriteOncePod volumes - and "Recursive" for all other volumes. - - This field affects only Pods that have SELinux label set, either in PodSecurityContext or in SecurityContext of all containers. - - All Pods that use the same volume should use the same seLinuxChangePolicy, otherwise some pods can get stuck in ContainerCreating state. - Note that this field cannot be set when spec.os.name is windows. - type: string - seLinuxOptions: - description: |- - The SELinux context to be applied to all containers. - If unspecified, the container runtime will allocate a random SELinux context for each - container. May also be set in SecurityContext. If set in - both SecurityContext and PodSecurityContext, the value specified in SecurityContext - takes precedence for that container. - Note that this field cannot be set when spec.os.name is windows. - properties: - level: - description: Level is SELinux level label that applies to the container. - type: string - role: - description: Role is a SELinux role label that applies to the container. - type: string - type: - description: Type is a SELinux type label that applies to the container. - type: string - user: - description: User is a SELinux user label that applies to the container. - type: string - type: object - seccompProfile: - description: |- - The seccomp options to use by the containers in this pod. - Note that this field cannot be set when spec.os.name is windows. - properties: - localhostProfile: - description: |- - localhostProfile indicates a profile defined in a file on the node should be used. - The profile must be preconfigured on the node to work. - Must be a descending path, relative to the kubelet's configured seccomp profile location. - Must be set if type is "Localhost". Must NOT be set for any other type. - type: string - type: - description: |- - type indicates which kind of seccomp profile will be applied. - Valid options are: - - Localhost - a profile defined in a file on the node should be used. - RuntimeDefault - the container runtime default profile should be used. - Unconfined - no profile should be applied. - type: string - required: - - type - type: object - supplementalGroups: - description: |- - A list of groups applied to the first process run in each container, in - addition to the container's primary GID and fsGroup (if specified). If - the SupplementalGroupsPolicy feature is enabled, the - supplementalGroupsPolicy field determines whether these are in addition - to or instead of any group memberships defined in the container image. - If unspecified, no additional groups are added, though group memberships - defined in the container image may still be used, depending on the - supplementalGroupsPolicy field. - Note that this field cannot be set when spec.os.name is windows. - items: - format: int64 - type: integer - type: array - x-kubernetes-list-type: atomic - supplementalGroupsPolicy: - description: |- - Defines how supplemental groups of the first container processes are calculated. - Valid values are "Merge" and "Strict". If not specified, "Merge" is used. - (Alpha) Using the field requires the SupplementalGroupsPolicy feature gate to be enabled - and the container runtime must implement support for this feature. - Note that this field cannot be set when spec.os.name is windows. - type: string - sysctls: - description: |- - Sysctls hold a list of namespaced sysctls used for the pod. Pods with unsupported - sysctls (by the container runtime) might fail to launch. - Note that this field cannot be set when spec.os.name is windows. - items: - description: Sysctl defines a kernel parameter to be set - properties: - name: - description: Name of a property to set - type: string - value: - description: Value of a property to set - type: string - required: - - name - - value - type: object - type: array - x-kubernetes-list-type: atomic - windowsOptions: - description: |- - The Windows specific settings applied to all containers. - If unspecified, the options within a container's SecurityContext will be used. - If set in both SecurityContext and PodSecurityContext, the value specified in SecurityContext takes precedence. - Note that this field cannot be set when spec.os.name is linux. - properties: - gmsaCredentialSpec: - description: |- - GMSACredentialSpec is where the GMSA admission webhook - (https://github.com/kubernetes-sigs/windows-gmsa) inlines the contents of the - GMSA credential spec named by the GMSACredentialSpecName field. - type: string - gmsaCredentialSpecName: - description: GMSACredentialSpecName is the name of the GMSA credential spec to use. - type: string - hostProcess: - description: |- - HostProcess determines if a container should be run as a 'Host Process' container. - All of a Pod's containers must have the same effective HostProcess value - (it is not allowed to have a mix of HostProcess containers and non-HostProcess containers). - In addition, if HostProcess is true then HostNetwork must also be set to true. - type: boolean - runAsUserName: - description: |- - The UserName in Windows to run the entrypoint of the container process. - Defaults to the user specified in image metadata if unspecified. - May also be set in PodSecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - type: string - type: object - type: object - serviceAccount: - description: |- - Config for the ServiceAccount to create for the Recorder's StatefulSet. - By default, the operator will create a ServiceAccount with the same - name as the Recorder resource. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#service-account - properties: - annotations: - additionalProperties: - type: string - description: |- - Annotations to add to the ServiceAccount. - https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/#syntax-and-character-set - - You can use this to add IAM roles to the ServiceAccount (IRSA) instead of - providing static S3 credentials in a Secret. - https://docs.aws.amazon.com/eks/latest/userguide/iam-roles-for-service-accounts.html - - For example: - eks.amazonaws.com/role-arn: arn:aws:iam:::role/ - type: object - name: - description: |- - Name of the ServiceAccount to create. Defaults to the name of the - Recorder resource. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#service-account - maxLength: 253 - pattern: ^[a-z0-9]([a-z0-9-.]{0,61}[a-z0-9])?$ - type: string - type: object - tolerations: - description: |- - Tolerations for Recorder Pods. By default, the operator does not apply - any tolerations. - https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#scheduling - items: - description: |- - The pod this Toleration is attached to tolerates any taint that matches - the triple using the matching operator . - properties: - effect: - description: |- - Effect indicates the taint effect to match. Empty means match all taint effects. - When specified, allowed values are NoSchedule, PreferNoSchedule and NoExecute. - type: string - key: - description: |- - Key is the taint key that the toleration applies to. Empty means match all taint keys. - If the key is empty, operator must be Exists; this combination means to match all values and all keys. - type: string - operator: - description: |- - Operator represents a key's relationship to the value. - Valid operators are Exists and Equal. Defaults to Equal. - Exists is equivalent to wildcard for value, so that a pod can - tolerate all taints of a particular category. - type: string - tolerationSeconds: - description: |- - TolerationSeconds represents the period of time the toleration (which must be - of effect NoExecute, otherwise this field is ignored) tolerates the taint. By default, - it is not set, which means tolerate the taint forever (do not evict). Zero and - negative values will be treated as 0 (evict immediately) by the system. - format: int64 - type: integer - value: - description: |- - Value is the taint value the toleration matches to. - If the operator is Exists, the value should be empty, otherwise just a regular string. - type: string - type: object - type: array - type: object - type: object - storage: - description: |- - Configure where to store session recordings. By default, recordings will - be stored in a local ephemeral volume, and will not be persisted past the - lifetime of a specific pod. - properties: - s3: - description: |- - Configure an S3-compatible API for storage. Required if the UI is not - enabled, to ensure that recordings are accessible. - properties: - bucket: - description: |- - Bucket name to write to. The bucket is expected to be used solely for - recordings, as there is no stable prefix for written object names. - type: string - credentials: - description: |- - Configure environment variable credentials for managing objects in the - configured bucket. If not set, tsrecorder will try to acquire credentials - first from the file system and then the STS API. - properties: - secret: - description: |- - Use a Kubernetes Secret from the operator's namespace as the source of - credentials. - properties: - name: - description: |- - The name of a Kubernetes Secret in the operator's namespace that contains - credentials for writing to the configured bucket. Each key-value pair - from the secret's data will be mounted as an environment variable. It - should include keys for AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY if - using a static access key. - type: string - type: object - type: object - endpoint: - description: S3-compatible endpoint, e.g. s3.us-east-1.amazonaws.com. - type: string - type: object - type: object - tags: - description: |- - Tags that the Tailscale device will be tagged with. Defaults to [tag:k8s]. - If you specify custom tags here, make sure you also make the operator - an owner of these tags. - See https://tailscale.com/kb/1236/kubernetes-operator/#setting-up-the-kubernetes-operator. - Tags cannot be changed once a Recorder node has been created. - Tag values must be in form ^tag:[a-zA-Z][a-zA-Z0-9-]*$. - items: - pattern: ^tag:[a-zA-Z][a-zA-Z0-9-]*$ - type: string - type: array - type: object - x-kubernetes-validations: - - message: S3 storage must be used when deploying multiple Recorder replicas - rule: '!(self.replicas > 1 && (!has(self.storage) || !has(self.storage.s3)))' - status: - description: |- - RecorderStatus describes the status of the recorder. This is set - and managed by the Tailscale operator. - properties: - conditions: - description: |- - List of status conditions to indicate the status of the Recorder. - Known condition types are `RecorderReady`. - items: - description: Condition contains details for one aspect of the current state of this API Resource. - properties: - lastTransitionTime: - description: |- - lastTransitionTime is the last time the condition transitioned from one status to another. - This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. - format: date-time - type: string - message: - description: |- - message is a human readable message indicating details about the transition. - This may be an empty string. - maxLength: 32768 - type: string - observedGeneration: - description: |- - observedGeneration represents the .metadata.generation that the condition was set based upon. - For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date - with respect to the current state of the instance. - format: int64 - minimum: 0 - type: integer - reason: - description: |- - reason contains a programmatic identifier indicating the reason for the condition's last transition. - Producers of specific condition types may define expected values and meanings for this field, - and whether the values are considered a guaranteed API. - The value should be a CamelCase string. - This field may not be empty. - maxLength: 1024 - minLength: 1 - pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ - type: string - status: - description: status of the condition, one of True, False, Unknown. - enum: - - "True" - - "False" - - Unknown - type: string - type: - description: type of condition in CamelCase or in foo.example.com/CamelCase. - maxLength: 316 - pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ - type: string - required: - - lastTransitionTime - - message - - reason - - status - - type - type: object - type: array - x-kubernetes-list-map-keys: - - type - x-kubernetes-list-type: map - devices: - description: List of tailnet devices associated with the Recorder StatefulSet. - items: - properties: - hostname: - description: |- - Hostname is the fully qualified domain name of the device. - If MagicDNS is enabled in your tailnet, it is the MagicDNS name of the - node. - type: string - tailnetIPs: - description: |- - TailnetIPs is the set of tailnet IP addresses (both IPv4 and IPv6) - assigned to the device. - items: - type: string - type: array - url: - description: |- - URL where the UI is available if enabled for replaying recordings. This - will be an HTTPS MagicDNS URL. You must be connected to the same tailnet - as the recorder to access it. - type: string - required: - - hostname - type: object - type: array - x-kubernetes-list-map-keys: - - hostname - x-kubernetes-list-type: map - type: object - required: - - spec - type: object - served: true - storage: true - subresources: - status: {} ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - name: tailscale-operator -rules: - - apiGroups: - - "" - resources: - - nodes - verbs: - - get - - list - - watch - - apiGroups: - - "" - resources: - - events - - services - - services/status - verbs: - - create - - delete - - deletecollection - - get - - list - - patch - - update - - watch - - apiGroups: - - networking.k8s.io - resources: - - ingresses - - ingresses/status - verbs: - - create - - delete - - deletecollection - - get - - list - - patch - - update - - watch - - apiGroups: - - networking.k8s.io - resources: - - ingressclasses - verbs: - - get - - list - - watch - - apiGroups: - - discovery.k8s.io - resources: - - endpointslices - verbs: - - get - - list - - watch - - apiGroups: - - tailscale.com - resources: - - connectors - - connectors/status - - proxyclasses - - proxyclasses/status - - proxygroups - - proxygroups/status - verbs: - - get - - list - - watch - - update - - apiGroups: - - tailscale.com - resources: - - dnsconfigs - - dnsconfigs/status - verbs: - - get - - list - - watch - - update - - apiGroups: - - tailscale.com - resources: - - recorders - - recorders/status - verbs: - - get - - list - - watch - - update - - apiGroups: - - apiextensions.k8s.io - resourceNames: - - servicemonitors.monitoring.coreos.com - resources: - - customresourcedefinitions - verbs: - - get - - list - - watch ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRoleBinding -metadata: - name: tailscale-operator -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: ClusterRole - name: tailscale-operator -subjects: - - kind: ServiceAccount - name: operator - namespace: tailscale ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: Role -metadata: - name: operator - namespace: tailscale -rules: - - apiGroups: - - "" - resources: - - secrets - - serviceaccounts - - configmaps - verbs: - - create - - delete - - deletecollection - - get - - list - - patch - - update - - watch - - apiGroups: - - "" - resources: - - pods - verbs: - - get - - list - - watch - - update - - apiGroups: - - "" - resources: - - pods/status - verbs: - - update - - apiGroups: - - apps - resources: - - statefulsets - - deployments - verbs: - - create - - delete - - deletecollection - - get - - list - - patch - - update - - watch - - apiGroups: - - discovery.k8s.io - resources: - - endpointslices - verbs: - - get - - list - - watch - - create - - update - - deletecollection - - apiGroups: - - rbac.authorization.k8s.io - resources: - - roles - - rolebindings - verbs: - - get - - create - - patch - - update - - list - - watch - - deletecollection - - apiGroups: - - monitoring.coreos.com - resources: - - servicemonitors - verbs: - - get - - list - - update - - create - - delete ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: Role -metadata: - name: proxies - namespace: tailscale -rules: - - apiGroups: - - "" - resources: - - secrets - verbs: - - create - - delete - - deletecollection - - get - - list - - patch - - update - - watch - - apiGroups: - - "" - resources: - - events - verbs: - - create - - patch - - get ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - name: operator - namespace: tailscale -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: Role - name: operator -subjects: - - kind: ServiceAccount - name: operator - namespace: tailscale ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - name: proxies - namespace: tailscale -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: Role - name: proxies -subjects: - - kind: ServiceAccount - name: proxies - namespace: tailscale ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: operator - namespace: tailscale -spec: - replicas: 1 - selector: - matchLabels: - app: operator - strategy: - type: Recreate - template: - metadata: - labels: - app: operator - spec: - containers: - - env: - - name: OPERATOR_INITIAL_TAGS - value: tag:k8s-operator - - name: OPERATOR_HOSTNAME - value: tailscale-operator - - name: OPERATOR_SECRET - value: operator - - name: OPERATOR_LOGGING - value: info - - name: OPERATOR_NAMESPACE - valueFrom: - fieldRef: - fieldPath: metadata.namespace - - name: OPERATOR_LOGIN_SERVER - value: null - - name: OPERATOR_INGRESS_CLASS_NAME - value: tailscale - - name: CLIENT_ID_FILE - value: /oauth/client_id - - name: CLIENT_SECRET_FILE - value: /oauth/client_secret - - name: PROXY_IMAGE - value: tailscale/tailscale:stable - - name: PROXY_TAGS - value: tag:k8s - - name: APISERVER_PROXY - value: "false" - - name: PROXY_FIREWALL_MODE - value: auto - - name: POD_NAME - valueFrom: - fieldRef: - fieldPath: metadata.name - - name: POD_UID - valueFrom: - fieldRef: - fieldPath: metadata.uid - image: docker.io/tailscale/k8s-operator:kustomized - imagePullPolicy: Always - name: operator - volumeMounts: - - mountPath: /oauth - name: oauth - readOnly: true - nodeSelector: - kubernetes.io/os: linux - serviceAccountName: operator - volumes: - - name: oauth - secret: - secretName: operator-oauth ---- -apiVersion: networking.k8s.io/v1 -kind: IngressClass -metadata: - annotations: {} - name: tailscale -spec: - controller: tailscale.com/ts-ingress diff --git a/argocd/manifests/tailscale-operator-base/proxyclass.yaml b/argocd/manifests/tailscale-operator-base/proxyclass.yaml index aff896d..a5c4675 100644 --- a/argocd/manifests/tailscale-operator-base/proxyclass.yaml +++ b/argocd/manifests/tailscale-operator-base/proxyclass.yaml @@ -3,6 +3,8 @@ # Specifies fully-qualified image names for Tailscale proxy pods. # This ensures consistent behavior across different container runtimes. # +# Version must match targetRevision in argocd/apps/tailscale-operator-base.yaml. +# # Usage: # Add this annotation to any Tailscale Service or Ingress: # tailscale.com/proxy-class: "default" @@ -18,7 +20,6 @@ spec: statefulSet: pod: tailscaleContainer: - # NOTE: keep in sync with kustomization.yaml (CRD fields aren't processed by kustomize images) image: docker.io/tailscale/tailscale:v1.94.2 tailscaleInitContainer: image: docker.io/tailscale/tailscale:v1.94.2 diff --git a/docs/changelog.d/externalize-tailscale-operator-base.infra.md b/docs/changelog.d/externalize-tailscale-operator-base.infra.md new file mode 100644 index 0000000..1ecd7da --- /dev/null +++ b/docs/changelog.d/externalize-tailscale-operator-base.infra.md @@ -0,0 +1 @@ +Externalize Tailscale operator manifest to forge mirror, removing 495 KB vendored file from the repo. diff --git a/docs/reference/kubernetes/tailscale-operator.md b/docs/reference/kubernetes/tailscale-operator.md index ad66206..c102e02 100644 --- a/docs/reference/kubernetes/tailscale-operator.md +++ b/docs/reference/kubernetes/tailscale-operator.md @@ -15,8 +15,8 @@ The Tailscale operator enables Kubernetes services to be exposed directly on the | Property | Value | |----------|-------| | **Namespace** | `tailscale` | -| **Helm Chart** | `tailscale/tailscale-operator` | -| **ArgoCD App** | `tailscale-operator` | +| **Upstream** | `mirrors/tailscale` on forge (static manifest) | +| **ArgoCD Apps** | `tailscale-operator-base` (upstream), `tailscale-operator` (config) | ## How It Works From 1f0308bbd2e5127900743be94b9725236ba89bbd Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sun, 15 Mar 2026 18:28:18 -0700 Subject: [PATCH 042/430] Fix Caddy v2.11 Host header rewrite breaking proxied services Caddy v2.11 (#7454) auto-rewrites the Host header to match the upstream address for HTTPS backends. This causes services behind Tailscale Ingress to see *.tail8d86e.ts.net instead of *.ops.eblu.me, breaking Authentik OAuth flows, Homepage host validation, and other services that check the Host header. Only apply header_up for HTTPS backends (Tailscale Ingress); HTTP backends (forge, registry, jellyfin, sifaka) are unaffected. Co-Authored-By: Claude Opus 4.6 (1M context) --- ansible/roles/caddy/templates/Caddyfile.j2 | 8 ++++++++ docs/changelog.d/+caddy-v2.11-host-header.bugfix.md | 1 + 2 files changed, 9 insertions(+) create mode 100644 docs/changelog.d/+caddy-v2.11-host-header.bugfix.md diff --git a/ansible/roles/caddy/templates/Caddyfile.j2 b/ansible/roles/caddy/templates/Caddyfile.j2 index 2bc4c87..dc3c7ff 100644 --- a/ansible/roles/caddy/templates/Caddyfile.j2 +++ b/ansible/roles/caddy/templates/Caddyfile.j2 @@ -31,7 +31,15 @@ {% for service in caddy_services %} @{{ service.name }} host {{ service.host }} handle @{{ service.name }} { +{% if service.backend.startswith('https://') %} + reverse_proxy {{ service.backend }} { + # Caddy v2.11+ rewrites Host to upstream for HTTPS backends. + # Preserve the original Host so services see *.ops.eblu.me. + header_up Host {http.request.host} + } +{% else %} reverse_proxy {{ service.backend }} +{% endif %} } {% endfor %} diff --git a/docs/changelog.d/+caddy-v2.11-host-header.bugfix.md b/docs/changelog.d/+caddy-v2.11-host-header.bugfix.md new file mode 100644 index 0000000..a300bd3 --- /dev/null +++ b/docs/changelog.d/+caddy-v2.11-host-header.bugfix.md @@ -0,0 +1 @@ +Fix Caddy v2.11 breaking change: preserve original Host header for HTTPS upstreams. From 4ca2e399016e31ab655e692aa5a02f34ee26bc86 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sun, 15 Mar 2026 18:31:19 -0700 Subject: [PATCH 043/430] Externalize TeslaMate dashboards to forge mirror (#296) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Replaces 18 TeslaMate dashboard ConfigMaps (713 KB / 22,080 lines) with a Grafana init container - Init container fetches dashboard JSON directly from `mirrors/teslamate` on forge, pinned to `v3.0.0` - Grafana's file provider picks them up from `/tmp/dashboards/TeslaMate/` via `foldersFromFilesStructure` - Non-TeslaMate dashboards remain as ConfigMaps (unchanged) ## How it works - New `init-teslamate-dashboards` init container uses busybox `wget` to fetch each JSON file from `https://forge.eblu.me/mirrors/teslamate/raw/tag/v3.0.0/grafana/dashboards/` - Files land in `/tmp/dashboards/TeslaMate/`, same emptyDir volume the sidecar uses - The sidecar continues to handle ConfigMap-based dashboards; the init container handles TeslaMate - Version pin is in the init container args (TESLAMATE_VERSION) ## Deployment and Testing - [ ] Sync `grafana` app from branch — verify init container runs and fetches dashboards - [ ] Sync `grafana-config` app from branch — verify TeslaMate ConfigMaps are pruned - [ ] Check Grafana UI: TeslaMate folder should still contain all 18 dashboards - [ ] Verify non-TeslaMate dashboards are unaffected - [ ] After merge: sync both apps from main Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/296 --- .../configmap-teslamate-battery-health.yaml | 1761 ---------- .../configmap-teslamate-charge-level.yaml | 419 --- .../configmap-teslamate-charges.yaml | 1537 --------- .../configmap-teslamate-charging-stats.yaml | 2428 -------------- .../configmap-teslamate-drive-stats.yaml | 1603 ---------- .../configmap-teslamate-drives.yaml | 1636 ---------- .../configmap-teslamate-efficiency.yaml | 1187 ------- .../configmap-teslamate-locations.yaml | 1200 ------- .../configmap-teslamate-mileage.yaml | 293 -- .../configmap-teslamate-overview.yaml | 1990 ------------ .../configmap-teslamate-projected-range.yaml | 772 ----- .../configmap-teslamate-states.yaml | 528 --- .../configmap-teslamate-statistics.yaml | 1262 -------- .../configmap-teslamate-timeline.yaml | 770 ----- .../dashboards/configmap-teslamate-trip.yaml | 2819 ----------------- .../configmap-teslamate-updates.yaml | 619 ---- .../configmap-teslamate-vampire-drain.yaml | 666 ---- .../configmap-teslamate-visited.yaml | 571 ---- .../grafana-config/kustomization.yaml | 22 +- argocd/manifests/grafana/deployment.yaml | 45 + argocd/manifests/grafana/kustomization.yaml | 2 + .../externalize-teslamate-dashboards.infra.md | 1 + 22 files changed, 51 insertions(+), 22080 deletions(-) delete mode 100644 argocd/manifests/grafana-config/dashboards/configmap-teslamate-battery-health.yaml delete mode 100644 argocd/manifests/grafana-config/dashboards/configmap-teslamate-charge-level.yaml delete mode 100644 argocd/manifests/grafana-config/dashboards/configmap-teslamate-charges.yaml delete mode 100644 argocd/manifests/grafana-config/dashboards/configmap-teslamate-charging-stats.yaml delete mode 100644 argocd/manifests/grafana-config/dashboards/configmap-teslamate-drive-stats.yaml delete mode 100644 argocd/manifests/grafana-config/dashboards/configmap-teslamate-drives.yaml delete mode 100644 argocd/manifests/grafana-config/dashboards/configmap-teslamate-efficiency.yaml delete mode 100644 argocd/manifests/grafana-config/dashboards/configmap-teslamate-locations.yaml delete mode 100644 argocd/manifests/grafana-config/dashboards/configmap-teslamate-mileage.yaml delete mode 100644 argocd/manifests/grafana-config/dashboards/configmap-teslamate-overview.yaml delete mode 100644 argocd/manifests/grafana-config/dashboards/configmap-teslamate-projected-range.yaml delete mode 100644 argocd/manifests/grafana-config/dashboards/configmap-teslamate-states.yaml delete mode 100644 argocd/manifests/grafana-config/dashboards/configmap-teslamate-statistics.yaml delete mode 100644 argocd/manifests/grafana-config/dashboards/configmap-teslamate-timeline.yaml delete mode 100644 argocd/manifests/grafana-config/dashboards/configmap-teslamate-trip.yaml delete mode 100644 argocd/manifests/grafana-config/dashboards/configmap-teslamate-updates.yaml delete mode 100644 argocd/manifests/grafana-config/dashboards/configmap-teslamate-vampire-drain.yaml delete mode 100644 argocd/manifests/grafana-config/dashboards/configmap-teslamate-visited.yaml create mode 100644 docs/changelog.d/externalize-teslamate-dashboards.infra.md diff --git a/argocd/manifests/grafana-config/dashboards/configmap-teslamate-battery-health.yaml b/argocd/manifests/grafana-config/dashboards/configmap-teslamate-battery-health.yaml deleted file mode 100644 index 0ca9f0f..0000000 --- a/argocd/manifests/grafana-config/dashboards/configmap-teslamate-battery-health.yaml +++ /dev/null @@ -1,1761 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: grafana-dashboard-teslamate-battery-health - namespace: monitoring - labels: - grafana_dashboard: "1" - annotations: - grafana_folder: "TeslaMate" -data: - teslamate-battery-health.json: | - { - "annotations": { - "list": [ - { - "builtIn": 1, - "datasource": { - "type": "grafana", - "uid": "-- Grafana --" - }, - "enable": true, - "hide": true, - "iconColor": "rgba(0, 211, 255, 1)", - "name": "Annotations & Alerts", - "type": "dashboard" - } - ] - }, - "editable": true, - "fiscalYearStartMonth": 0, - "graphTooltip": 1, - "links": [ - { - "icon": "dashboard", - "tags": [], - "title": "TeslaMate", - "tooltip": "", - "type": "link", - "url": "${base_url:raw}" - }, - { - "asDropdown": true, - "icon": "external link", - "tags": [ - "tesla" - ], - "title": "Dashboards", - "type": "dashboards" - } - ], - "panels": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "description": "**Usable (now)** is the estimated current battery capacity. It is average of the estimated capacity reported by the last 10 charging sessions to have a better estimation.\n\nIf you see just '1.0 kWh' here, it means that you need at least a long charge session.\n\n**Usable (new)** is the estimated Battery Capacity since you begun to use TeslaMate. That's why, the more data you have logged from your brand new car the better. For those who have not used TeslaMate since they got their new car, or for those who have bought it second hand, it's possible to set the max range to 100% and the battery capacity of the car battery when it was new in order to get a better and accurate estimation.", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "decimals": 1, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "super-light-blue", - "value": 0 - }, - { - "color": "dark-red", - "value": 1 - }, - { - "color": "super-light-blue", - "value": 2 - } - ] - }, - "unit": "kwatth" - }, - "overrides": [] - }, - "gridPos": { - "h": 6, - "w": 6, - "x": 0, - "y": 0 - }, - "id": 13, - "options": { - "colorMode": "value", - "graphMode": "none", - "justifyMode": "auto", - "orientation": "horizontal", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "value_and_name", - "wideLayout": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "SELECT \n CASE WHEN $custom_kwh_new > 0 THEN $custom_kwh_new ELSE ('$aux'::json ->> 'MaxCapacity')::float END as \"Usable (new)\", \n ('$aux'::json ->> 'CurrentCapacity')::float as \"Usable (now)\",\n ('$aux'::json ->> 'CurrentCapacity')::float - CASE WHEN $custom_kwh_new > 0 THEN $custom_kwh_new ELSE ('$aux'::json ->> 'MaxCapacity')::float END as \"Difference\"", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Battery Capacity", - "type": "stat" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "decimals": 1, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "super-light-blue", - "value": 0 - } - ] - } - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*_km/" - }, - "properties": [ - { - "id": "unit", - "value": "lengthkm" - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*_mi/" - }, - "properties": [ - { - "id": "unit", - "value": "lengthmi" - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/maxrange_.*/" - }, - "properties": [ - { - "id": "displayName", - "value": "Max range (new)" - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/currentrange_.*/" - }, - "properties": [ - { - "id": "displayName", - "value": "Max range (now)" - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/range_lost.*/" - }, - "properties": [ - { - "id": "displayName", - "value": "Range lost" - } - ] - } - ] - }, - "gridPos": { - "h": 6, - "w": 6, - "x": 6, - "y": 0 - }, - "id": 14, - "options": { - "colorMode": "value", - "graphMode": "none", - "justifyMode": "auto", - "orientation": "horizontal", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "value_and_name", - "wideLayout": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "SELECT \n CASE WHEN $custom_max_range > 0 THEN $custom_max_range ELSE ('$aux'::json ->> 'MaxRange')::float END as \"maxrange_$length_unit\",\n ('$aux'::json ->> 'CurrentRange')::float as \"currentrange_$length_unit\",\n CASE WHEN $custom_max_range > 0 THEN $custom_max_range ELSE ('$aux'::json ->> 'MaxRange')::float END - ('$aux'::json ->> 'CurrentRange')::float as \"range_lost_$length_unit\"", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Ranges [$preferred_range]", - "type": "stat" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "description": "\"Logged\" is the distance traveled that is saved on TeslaMate database.\n\n\"Mileage\" is the distance the car has traveled since using TeslaMate.\n\nSo, if there is a difference between both values, it is the distance that for some reason a drive hasn't been fully recorded, for example due to a bug or an unexpected restart and that TeslaMate has not been able to record, either due to lack of connection, areas without signal, or that it has been out of service.", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "super-light-blue", - "value": 0 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 6, - "w": 6, - "x": 12, - "y": 0 - }, - "id": 37, - "links": [ - { - "targetBlank": true, - "title": "Drive Stats", - "url": "/d/_7WkNSyWk/drive-stats" - } - ], - "options": { - "colorMode": "value", - "graphMode": "none", - "justifyMode": "auto", - "orientation": "horizontal", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "/.*/", - "values": false - }, - "showPercentChange": false, - "textMode": "value_and_name", - "wideLayout": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "select ROUND(convert_km(sum(distance)::numeric, '$length_unit'),0)|| ' $length_unit' as \"Logged\"\r\nfrom drives \r\nwhere car_id = $car_id;\r\n", - "refId": "Logged", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "SELECT ROUND(convert_km((max(end_km) - min(start_km))::numeric, '$length_unit'),0)|| ' $length_unit' as \"Mileage\"\nFROM drives WHERE car_id = $car_id;", - "refId": "Mileage", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "SELECT ROUND(convert_km(max(end_km)::numeric, '$length_unit'),0) || ' $length_unit' as \"Odometer\"\nFROM drives WHERE car_id = $car_id;", - "refId": "Odometer", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "SELECT (\r\n (SELECT ROUND(convert_km((max(end_km) - min(start_km))::numeric, '$length_unit'),0) FROM drives WHERE car_id = $car_id) - \r\n (SELECT ROUND(convert_km(sum(distance)::numeric, '$length_unit'),0) from drives where car_id = $car_id) || ' $length_unit'\r\n)\r\nAS \"Data lost (not logged)\"", - "refId": "Data Lost", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Drive Stats", - "type": "stat" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - } - }, - "decimals": 2, - "mappings": [], - "unit": "kwatth" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "AC" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "semi-dark-green", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "DC" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "light-orange", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 13, - "w": 6, - "x": 18, - "y": 0 - }, - "id": 34, - "maxDataPoints": 3, - "options": { - "displayLabels": [ - "name", - "percent", - "value" - ], - "legend": { - "displayMode": "list", - "placement": "right", - "showLegend": false, - "values": [ - "value" - ] - }, - "pieType": "pie", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "tooltip": { - "hideZeros": false, - "maxHeight": 600, - "mode": "single", - "sort": "none" - } - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "time_series", - "rawQuery": true, - "rawSql": "WITH data AS (\n SELECT\n\t\tcp.id,\n\t\tcp.charge_energy_added,\n\t\tCASE WHEN NULLIF(mode() within group (order by charger_phases),0) is null THEN 'DC'\n\t\t\t\t ELSE 'AC'\n\t\tEND AS current,\n\t\tcp.charge_energy_used\n\tFROM charging_processes cp\n RIGHT JOIN charges ON cp.id = charges.charging_process_id\n WHERE\n\t cp.car_id = $car_id\n\t AND cp.charge_energy_added > 0.01\n GROUP BY 1,2\n)\nSELECT\n\tnow() AS time,\n\tSUM(GREATEST(charge_energy_added, charge_energy_used)) AS value,\n\tcurrent AS metric\nFROM data\nGROUP BY 3\nORDER BY metric DESC;", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "AC/DC - Energy Used", - "type": "piechart" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "description": "This dashboard is meant to have a look of the Battery health based on the data logged in TeslaMate. So, the more data you have logged from your brand new car the better.\n\n**Degradation** is just an estimated value to have a reference, measured on **usable battery level** of every charging session with enough kWh added (in order to avoid dirty data from the sample), calculated according to the rated efficiency of the car.", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "decimals": 1, - "mappings": [], - "max": 100, - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": 0 - }, - { - "color": "#EAB839", - "value": 10 - }, - { - "color": "red", - "value": 20 - }, - { - "color": "dark-red", - "value": 30 - } - ] - }, - "unit": "%" - }, - "overrides": [] - }, - "gridPos": { - "h": 7, - "w": 6, - "x": 0, - "y": 6 - }, - "id": 17, - "options": { - "minVizHeight": 75, - "minVizWidth": 75, - "orientation": "auto", - "reduceOptions": { - "calcs": [], - "fields": "/^greatest$/", - "values": false - }, - "showThresholdLabels": false, - "showThresholdMarkers": true, - "sizing": "auto" - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "SELECT GREATEST(0, 100.0 - (('$aux'::json ->> 'CurrentCapacity')::float * 100.0 / CASE WHEN $custom_kwh_new > 0 THEN $custom_kwh_new ELSE ('$aux'::json ->> 'MaxCapacity')::float END))\n\n", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Estimated Degradation", - "type": "gauge" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "decimals": 1, - "mappings": [], - "max": 100, - "min": 1, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "light-red", - "value": 0 - }, - { - "color": "#EAB839", - "value": 80 - }, - { - "color": "light-green", - "value": 90 - } - ] - }, - "unit": "%" - }, - "overrides": [] - }, - "gridPos": { - "h": 3, - "w": 6, - "x": 6, - "y": 6 - }, - "id": 12, - "options": { - "displayMode": "gradient", - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": false - }, - "maxVizHeight": 300, - "minVizHeight": 10, - "minVizWidth": 0, - "namePlacement": "auto", - "orientation": "horizontal", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "showUnfilled": true, - "sizing": "auto", - "valueMode": "color" - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "SELECT \n LEAST(100, (100 - GREATEST(0, 100.0 - (('$aux'::json ->> 'CurrentCapacity')::float * 100.0 / CASE WHEN $custom_kwh_new > 0 THEN $custom_kwh_new ELSE ('$aux'::json ->> 'MaxCapacity')::float END)))) as \"Battery Health (%)\"\n \n", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Battery Health", - "type": "bargauge" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "description": "\"# of Charging cycles\" is estimated by dividing the whole energy added to the battery by the battery capacity when new.\n\n\"Charging Efficiency\" is estimated on the difference between energy used from the charger and energy added to the battery.", - "fieldConfig": { - "defaults": { - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "light-yellow", - "value": 0 - } - ] - } - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Total Energy added" - }, - "properties": [ - { - "id": "unit", - "value": "kwatth" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total Energy used" - }, - "properties": [ - { - "id": "unit", - "value": "kwatth" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Charging Efficiency" - }, - "properties": [ - { - "id": "unit", - "value": "percentunit" - } - ] - } - ] - }, - "gridPos": { - "h": 7, - "w": 6, - "x": 12, - "y": 6 - }, - "id": 36, - "links": [ - { - "targetBlank": true, - "title": "Charging Stats", - "url": "/d/-pkIkhmRz/charging-stats" - } - ], - "maxDataPoints": 100, - "options": { - "colorMode": "value", - "fieldOptions": { - "calcs": [ - "mean" - ] - }, - "graphMode": "none", - "justifyMode": "auto", - "orientation": "horizontal", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "mean" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "value_and_name", - "wideLayout": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "SELECT\r\n\tCOUNT(*) AS \"# of Charges\"\r\nFROM\r\n\tcharging_processes\r\nWHERE\r\n\tcar_id = $car_id AND charge_energy_added > 0.01\r\n\t", - "refId": "# of Charges", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "SELECT\n\tfloor(sum(charge_energy_added) / CASE WHEN $custom_kwh_new > 0 THEN $custom_kwh_new ELSE ('$aux'::json ->> 'MaxCapacity')::float END) AS \"# of Charging cycles\"\nFROM charging_processes WHERE car_id = $car_id AND charge_energy_added > 0.01", - "refId": "# of Charging cycles", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "SELECT\n\tsum(charge_energy_added) as \"Total Energy added\"\nFROM\n\tcharging_processes\nWHERE\n\tcar_id = $car_id AND charge_energy_added > 0.01", - "refId": "Total Energy added", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "SELECT\r\n\tSUM(greatest(charge_energy_added, charge_energy_used)) AS \"Total Energy used\"\r\nFROM\r\n\tcharging_processes\r\nWHERE\r\n\tcar_id = $car_id AND charge_energy_added > 0.01\r\n", - "refId": "Total Energy used", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "SELECT\r\n\tSUM(charge_energy_added) / SUM(greatest(charge_energy_added, charge_energy_used)) AS \"Charging Efficiency\"\r\nFROM\r\n\tcharging_processes\r\nWHERE\r\n\tcar_id = $car_id AND charge_energy_added > 0.01\r\n", - "refId": "Charging Efficiency", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Charging Stats", - "type": "stat" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "decimals": 1, - "mappings": [], - "max": 100, - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "transparent", - "value": 0 - } - ] - }, - "unit": "%" - }, - "overrides": [] - }, - "gridPos": { - "h": 2, - "w": 4, - "x": 6, - "y": 9 - }, - "id": 25, - "options": { - "displayMode": "lcd", - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": false - }, - "maxVizHeight": 300, - "minVizHeight": 10, - "minVizWidth": 0, - "namePlacement": "auto", - "orientation": "horizontal", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "showUnfilled": true, - "sizing": "auto", - "text": {}, - "valueMode": "color" - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "SELECT * FROM ((SELECT usable_battery_level, date\r\nFROM positions\r\nWHERE car_id = $car_id AND usable_battery_level IS NOT NULL\r\nORDER BY date DESC\r\nLIMIT 1)\r\nUNION\r\n(SELECT usable_battery_level, date\r\nFROM charges c\r\nJOIN charging_processes p ON p.id = c.charging_process_id\r\nWHERE p.car_id = $car_id AND usable_battery_level IS NOT NULL\r\nORDER BY date DESC\r\nLIMIT 1)) AS last_usable_battery_level LIMIT 1", - "refId": "SOC", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "SELECT\r\n 0 as lowest,\r\n 20 as lower,\r\n CASE WHEN lfp_battery THEN 100 ELSE 81 END as upper\r\nfrom cars inner join car_settings on cars.settings_id = car_settings.id\r\nwhere cars.id = $car_id", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Current SOC", - "transformations": [ - { - "id": "configFromData", - "options": { - "applyTo": { - "id": "byFrameRefID", - "options": "SOC" - }, - "configRefId": "A", - "mappings": [ - { - "fieldName": "lower", - "handlerArguments": { - "threshold": { - "color": "green" - } - }, - "handlerKey": "threshold1" - }, - { - "fieldName": "upper", - "handlerArguments": { - "threshold": { - "color": "orange" - } - }, - "handlerKey": "threshold1" - }, - { - "fieldName": "lowest", - "handlerKey": "threshold1" - } - ] - } - } - ], - "type": "bargauge" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "description": "This is the Derived Rated Efficiency that TeslaMate calculates based on battery charges. \nThis information can be seen in more detail on the \"Efficiency\" dashboard.", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "decimals": 0, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "super-light-blue", - "value": 0 - } - ] - } - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*_km/" - }, - "properties": [ - { - "id": "unit", - "value": "Wh/km" - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*_mi/" - }, - "properties": [ - { - "id": "unit", - "value": "Wh/mi" - } - ] - } - ] - }, - "gridPos": { - "h": 4, - "w": 2, - "x": 10, - "y": 9 - }, - "id": 32, - "options": { - "colorMode": "value", - "graphMode": "none", - "justifyMode": "auto", - "orientation": "horizontal", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "/.*/", - "values": false - }, - "showPercentChange": false, - "textMode": "value", - "wideLayout": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "SELECT ('$aux'::json ->> 'RatedEfficiency')::float * 10 / convert_km(1, '$length_unit') AS efficiency_$length_unit", - "refId": "Logged", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Efficiency", - "type": "stat" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "decimals": 1, - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "dark-red", - "value": 0 - }, - { - "color": "dark-green", - "value": 7.84 - }, - { - "color": "semi-dark-orange", - "value": 31.36 - }, - { - "color": "light-blue", - "value": 35.28 - } - ] - }, - "unit": "kwatth" - }, - "overrides": [] - }, - "gridPos": { - "h": 2, - "w": 4, - "x": 6, - "y": 11 - }, - "id": 27, - "options": { - "displayMode": "gradient", - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": false - }, - "maxVizHeight": 300, - "minVizHeight": 10, - "minVizWidth": 0, - "namePlacement": "auto", - "orientation": "horizontal", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "/^kwh$/", - "values": false - }, - "showUnfilled": true, - "sizing": "auto", - "text": {}, - "valueMode": "color" - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "SELECT * FROM ((SELECT usable_battery_level * ('$aux'::json ->> 'CurrentCapacity')::float / 100 as kWh, date, ('$aux'::json ->> 'CurrentCapacity')::float as Total\nFROM positions\nWHERE car_id = $car_id AND usable_battery_level IS NOT NULL\nORDER BY date DESC\nLIMIT 1)\nUNION\n(SELECT battery_level * ('$aux'::json ->> 'CurrentCapacity')::float / 100 as kWh, date, ('$aux'::json ->> 'CurrentCapacity')::float as Total\nFROM charges c\nJOIN charging_processes p ON p.id = c.charging_process_id\nWHERE p.car_id = $car_id AND usable_battery_level IS NOT NULL\nORDER BY date DESC\nLIMIT 1)) AS last_usable_battery_level LIMIT 1", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Current Stored Energy", - "type": "bargauge" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "continuous-RdYlGr", - "seriesBy": "last" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "fillOpacity": 50, - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineWidth": 2, - "pointShape": "circle", - "pointSize": { - "fixed": 5 - }, - "pointStrokeWidth": 1, - "scaleDistribution": { - "type": "linear" - }, - "show": "points" - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": 0 - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [ - { - "matcher": { - "id": "byFrameRefID", - "options": "Median" - }, - "properties": [ - { - "id": "custom.show", - "value": "lines" - }, - { - "id": "color", - "value": { - "fixedColor": "dark-red", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Odometer" - }, - "properties": [ - { - "id": "displayName", - "value": "Mileage" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "kWh" - }, - "properties": [ - { - "id": "displayName", - "value": "Battery Capacity" - }, - { - "id": "unit", - "value": "kwatth" - } - ] - } - ] - }, - "gridPos": { - "h": 8, - "w": 24, - "x": 0, - "y": 13 - }, - "id": 28, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": false - }, - "mapping": "manual", - "series": [ - { - "color": { - "matcher": { - "id": "byName", - "options": "kWh" - } - }, - "frame": { - "matcher": { - "id": "byIndex", - "options": 0 - } - }, - "x": { - "matcher": { - "id": "byName", - "options": "Odometer" - } - }, - "y": { - "matcher": { - "id": "byName", - "options": "kWh" - } - } - }, - { - "frame": { - "matcher": { - "id": "byIndex", - "options": 1 - } - }, - "x": { - "matcher": { - "id": "byName", - "options": "Odometer" - } - }, - "y": { - "matcher": { - "id": "byName", - "options": "kWh" - } - } - } - ], - "tooltip": { - "hideZeros": false, - "maxHeight": 600, - "mode": "single", - "sort": "none" - } - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "SELECT convert_km(AVG(p.odometer)::numeric,'$length_unit') AS \"Odometer\", \r\n\tAVG(c.rated_battery_range_km * ('$aux'::json ->> 'RatedEfficiency')::float / c.usable_battery_level) AS \"kWh\",\r\n\t--MAX(cp.id) AS id,\r\n\tto_char(timezone('$__timezone', timezone('UTC', cp.end_date)), 'YYYY-MM-dd') AS \"Date\"\r\n\tFROM charging_processes cp\r\n\t\tJOIN (SELECT charging_process_id, MAX(date) as date\tFROM charges WHERE usable_battery_level > 0 GROUP BY charging_process_id) AS last_charges\tON cp.id = last_charges.charging_process_id\r\n\t\tINNER JOIN charges c\r\n\t\tON c.charging_process_id = cp.id AND c.date = last_charges.date\r\n\t\tINNER JOIN positions p ON p.id = cp.position_id\r\n\tWHERE cp.car_id = $car_id\r\n\t\tAND cp.end_date IS NOT NULL\r\n\t\tAND cp.charge_energy_added >= ('$aux'::json ->> 'RatedEfficiency')::float\r\n\tGROUP BY 3", - "refId": "Projected Range", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "SELECT \n ROUND(MIN(convert_km(p.odometer::numeric,'$length_unit')),0) AS \"Odometer\",\n\tROUND(PERCENTILE_CONT(0.5) WITHIN GROUP(ORDER BY c.rated_battery_range_km * ('$aux'::json ->> 'RatedEfficiency')::float / c.usable_battery_level)::numeric,1) AS \"kWh\",\n\tto_char(timezone('$__timezone', timezone('UTC', cp.end_date)), 'YYYYMM') || CASE WHEN to_char(timezone('$__timezone', timezone('UTC', cp.end_date)), 'DD')::int <= 15 THEN '1' ELSE '2' END AS Title\n\tFROM charging_processes cp\n\t\tJOIN (SELECT charging_process_id, MAX(date) as date\tFROM charges WHERE usable_battery_level > 0 GROUP BY charging_process_id) AS last_charges\tON cp.id = last_charges.charging_process_id\n\t\tINNER JOIN charges c\n\t\tON c.charging_process_id = cp.id AND c.date = last_charges.date\n\t\tINNER JOIN positions p ON p.id = cp.position_id\n\tWHERE cp.car_id = $car_id\n\t\tAND cp.end_date IS NOT NULL\n\t\tAND cp.charge_energy_added >= ('$aux'::json ->> 'RatedEfficiency')::float\n\tGROUP BY 3", - "refId": "Median", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Battery Capacity by Mileage", - "type": "xychart" - } - ], - "preload": false, - "refresh": "", - "schemaVersion": 41, - "tags": [ - "tesla" - ], - "templating": { - "list": [ - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "SELECT\n id as __value,\n CASE WHEN COUNT(id) OVER (PARTITION BY name) > 1 AND name IS NOT NULL THEN CONCAT(name, ' - ', RIGHT(vin, 6)) ELSE COALESCE(name, CONCAT('VIN ', vin)) end as __text \nFROM cars\nORDER BY display_priority ASC, name ASC, vin ASC", - "includeAll": false, - "label": "Car", - "name": "car_id", - "options": [], - "query": "SELECT\n id as __value,\n CASE WHEN COUNT(id) OVER (PARTITION BY name) > 1 AND name IS NOT NULL THEN CONCAT(name, ' - ', RIGHT(vin, 6)) ELSE COALESCE(name, CONCAT('VIN ', vin)) end as __text \nFROM cars\nORDER BY display_priority ASC, name ASC, vin ASC", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "SELECT unit_of_length FROM settings LIMIT 1", - "hide": 2, - "includeAll": false, - "name": "length_unit", - "options": [], - "query": "SELECT unit_of_length FROM settings LIMIT 1", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "SELECT preferred_range FROM settings LIMIT 1", - "hide": 2, - "includeAll": false, - "name": "preferred_range", - "options": [], - "query": "SELECT preferred_range FROM settings LIMIT 1", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "SELECT base_url FROM settings LIMIT 1", - "hide": 2, - "includeAll": false, - "name": "base_url", - "options": [], - "query": "SELECT base_url FROM settings LIMIT 1", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "WITH Aux as (\n SELECT \n car_id,\n COALESCE(derived_efficiency, car_efficiency) AS efficiency\n FROM (\n SELECT\n ROUND((charge_energy_added / NULLIF(end_rated_range_km - start_rated_range_km, 0))::numeric, 3) * 100 AS derived_efficiency,\n COUNT(*) as count,\n cars.id as car_id,\n cars.efficiency * 100 AS car_efficiency\n FROM cars\n LEFT JOIN charging_processes ON\n cars.id = charging_processes.car_id \n AND duration_min > 10\n AND end_battery_level <= 95\n AND start_rated_range_km IS NOT NULL\n AND end_rated_range_km IS NOT NULL\n AND charge_energy_added > 0\n WHERE cars.id = $car_id\n GROUP BY 1, 3, 4\n ORDER BY 2 DESC\n LIMIT 1\n ) AS Efficiency\n),\n\nCurrentCapacity AS (\n SELECT\n AVG(Capacity) AS Capacity\n FROM (\n SELECT \n c.rated_battery_range_km * aux.efficiency / c.usable_battery_level AS Capacity\n FROM charging_processes cp\n INNER JOIN charges c ON c.charging_process_id = cp.id \n INNER JOIN aux ON cp.car_id = aux.car_id\n WHERE\n cp.car_id = $car_id\n AND cp.end_date IS NOT NULL\n AND cp.charge_energy_added >= aux.efficiency\n AND c.usable_battery_level > 0\n ORDER BY cp.end_date DESC, c.date desc\n LIMIT 100\n ) AS lastCharges\n),\n\nMaxCapacity AS (\n SELECT \n MAX(c.rated_battery_range_km * aux.efficiency / c.usable_battery_level) AS Capacity\n FROM charging_processes cp\n INNER JOIN (\n SELECT\n charging_process_id,\n MAX(date) as date FROM charges WHERE usable_battery_level > 0 GROUP BY charging_process_id\n ) AS gcharges ON\n cp.id = gcharges.charging_process_id\n INNER JOIN charges c ON\n c.charging_process_id = cp.id\n AND c.date = gcharges.date\n INNER JOIN aux ON cp.car_id = aux.car_id\n WHERE\n cp.car_id = $car_id\n AND cp.end_date IS NOT NULL\n AND cp.charge_energy_added >= aux.efficiency\n),\n\nCurrentRange AS (\n SELECT\n (range * 100.0 / usable_battery_level) AS range\n FROM (\n (\n SELECT\n date,\n ${preferred_range}_battery_range_km AS range,\n usable_battery_level AS usable_battery_level\n FROM positions\n WHERE\n car_id = $car_id\n AND ideal_battery_range_km IS NOT NULL\n AND usable_battery_level > 0 \n ORDER BY date DESC\n LIMIT 1\n )\n UNION ALL\n (\n SELECT date,\n ${preferred_range}_battery_range_km AS range,\n usable_battery_level as usable_battery_level\n FROM charges c\n INNER JOIN charging_processes p ON p.id = c.charging_process_id\n WHERE\n p.car_id = $car_id\n AND usable_battery_level > 0\n ORDER BY date DESC\n LIMIT 1\n )\n ) AS data\n ORDER BY date DESC\n LIMIT 1\n),\n\nMaxRange AS (\n SELECT\n floor(extract(epoch from date)/86400)*86400 AS time,\n CASE\n WHEN sum(usable_battery_level) = 0 THEN sum(${preferred_range}_battery_range_km) * 100\n ELSE sum(${preferred_range}_battery_range_km) / sum(usable_battery_level) * 100\n END AS range\n FROM (\n SELECT\n battery_level,\n usable_battery_level,\n date,\n ${preferred_range}_battery_range_km\n FROM charges c \n INNER JOIN charging_processes p ON p.id = c.charging_process_id \n WHERE\n p.car_id = $car_id\n AND usable_battery_level IS NOT NULL\n ) AS data\n GROUP BY 1\n ORDER BY 2 DESC\n LIMIT 1\n),\n\nBase AS (\n SELECT NULL\n)\n\nSELECT\n json_build_object(\n 'MaxRange', convert_km(MaxRange.range,'$length_unit'),\n 'CurrentRange', convert_km(CurrentRange.range,'$length_unit'),\n 'MaxCapacity', MaxCapacity.Capacity,\n 'CurrentCapacity', CASE WHEN CurrentCapacity.Capacity IS NULL THEN 1 ELSE CurrentCapacity.Capacity END,\n 'RatedEfficiency', aux.efficiency\n )\nFROM Base\n LEFT JOIN MaxRange ON true\n LEFT JOIN CurrentRange ON true\n LEFT JOIN Aux ON true\n LEFT JOIN MaxCapacity ON true\n LEFT JOIN CurrentCapacity ON true", - "hide": 2, - "includeAll": false, - "name": "aux", - "options": [], - "query": "WITH Aux as (\n SELECT \n car_id,\n COALESCE(derived_efficiency, car_efficiency) AS efficiency\n FROM (\n SELECT\n ROUND((charge_energy_added / NULLIF(end_rated_range_km - start_rated_range_km, 0))::numeric, 3) * 100 AS derived_efficiency,\n COUNT(*) as count,\n cars.id as car_id,\n cars.efficiency * 100 AS car_efficiency\n FROM cars\n LEFT JOIN charging_processes ON\n cars.id = charging_processes.car_id \n AND duration_min > 10\n AND end_battery_level <= 95\n AND start_rated_range_km IS NOT NULL\n AND end_rated_range_km IS NOT NULL\n AND charge_energy_added > 0\n WHERE cars.id = $car_id\n GROUP BY 1, 3, 4\n ORDER BY 2 DESC\n LIMIT 1\n ) AS Efficiency\n),\n\nCurrentCapacity AS (\n SELECT\n AVG(Capacity) AS Capacity\n FROM (\n SELECT \n c.rated_battery_range_km * aux.efficiency / c.usable_battery_level AS Capacity\n FROM charging_processes cp\n INNER JOIN charges c ON c.charging_process_id = cp.id \n INNER JOIN aux ON cp.car_id = aux.car_id\n WHERE\n cp.car_id = $car_id\n AND cp.end_date IS NOT NULL\n AND cp.charge_energy_added >= aux.efficiency\n AND c.usable_battery_level > 0\n ORDER BY cp.end_date DESC, c.date desc\n LIMIT 100\n ) AS lastCharges\n),\n\nMaxCapacity AS (\n SELECT \n MAX(c.rated_battery_range_km * aux.efficiency / c.usable_battery_level) AS Capacity\n FROM charging_processes cp\n INNER JOIN (\n SELECT\n charging_process_id,\n MAX(date) as date FROM charges WHERE usable_battery_level > 0 GROUP BY charging_process_id\n ) AS gcharges ON\n cp.id = gcharges.charging_process_id\n INNER JOIN charges c ON\n c.charging_process_id = cp.id\n AND c.date = gcharges.date\n INNER JOIN aux ON cp.car_id = aux.car_id\n WHERE\n cp.car_id = $car_id\n AND cp.end_date IS NOT NULL\n AND cp.charge_energy_added >= aux.efficiency\n),\n\nCurrentRange AS (\n SELECT\n (range * 100.0 / usable_battery_level) AS range\n FROM (\n (\n SELECT\n date,\n ${preferred_range}_battery_range_km AS range,\n usable_battery_level AS usable_battery_level\n FROM positions\n WHERE\n car_id = $car_id\n AND ideal_battery_range_km IS NOT NULL\n AND usable_battery_level > 0 \n ORDER BY date DESC\n LIMIT 1\n )\n UNION ALL\n (\n SELECT date,\n ${preferred_range}_battery_range_km AS range,\n usable_battery_level as usable_battery_level\n FROM charges c\n INNER JOIN charging_processes p ON p.id = c.charging_process_id\n WHERE\n p.car_id = $car_id\n AND usable_battery_level > 0\n ORDER BY date DESC\n LIMIT 1\n )\n ) AS data\n ORDER BY date DESC\n LIMIT 1\n),\n\nMaxRange AS (\n SELECT\n floor(extract(epoch from date)/86400)*86400 AS time,\n CASE\n WHEN sum(usable_battery_level) = 0 THEN sum(${preferred_range}_battery_range_km) * 100\n ELSE sum(${preferred_range}_battery_range_km) / sum(usable_battery_level) * 100\n END AS range\n FROM (\n SELECT\n battery_level,\n usable_battery_level,\n date,\n ${preferred_range}_battery_range_km\n FROM charges c \n INNER JOIN charging_processes p ON p.id = c.charging_process_id \n WHERE\n p.car_id = $car_id\n AND usable_battery_level IS NOT NULL\n ) AS data\n GROUP BY 1\n ORDER BY 2 DESC\n LIMIT 1\n),\n\nBase AS (\n SELECT NULL\n)\n\nSELECT\n json_build_object(\n 'MaxRange', convert_km(MaxRange.range,'$length_unit'),\n 'CurrentRange', convert_km(CurrentRange.range,'$length_unit'),\n 'MaxCapacity', MaxCapacity.Capacity,\n 'CurrentCapacity', CASE WHEN CurrentCapacity.Capacity IS NULL THEN 1 ELSE CurrentCapacity.Capacity END,\n 'RatedEfficiency', aux.efficiency\n )\nFROM Base\n LEFT JOIN MaxRange ON true\n LEFT JOIN CurrentRange ON true\n LEFT JOIN Aux ON true\n LEFT JOIN MaxCapacity ON true\n LEFT JOIN CurrentCapacity ON true", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": { - "text": "0", - "value": "0" - }, - "description": "Set the capacity of your car battery when it was new, in case you started using TeslaMate after a while of having it. If not, leave it at 0, it will be calculated with the data that is logged in TeslaMate", - "label": "Custom Battery Capacity (kWh) when new", - "name": "custom_kwh_new", - "options": [ - { - "selected": true, - "text": "0", - "value": "0" - } - ], - "query": "0", - "type": "textbox" - }, - { - "current": { - "text": "0", - "value": "0" - }, - "description": "Set the max range to 100% of your car when it was new, in case you started using TeslaMate after a while of having it. If not, leave it at 0, the degradation will be calculated with the data that is logged in TeslaMate", - "label": "Custom Max Range when new", - "name": "custom_max_range", - "options": [ - { - "selected": true, - "text": "0", - "value": "0" - } - ], - "query": "0", - "type": "textbox" - } - ] - }, - "time": { - "from": "now-6h", - "to": "now" - }, - "timepicker": { - "hidden": true - }, - "timezone": "browser", - "title": "Tesla Battery Health", - "uid": "jchmRiqUfXgTM", - "version": 1 - } diff --git a/argocd/manifests/grafana-config/dashboards/configmap-teslamate-charge-level.yaml b/argocd/manifests/grafana-config/dashboards/configmap-teslamate-charge-level.yaml deleted file mode 100644 index 7ecc25b..0000000 --- a/argocd/manifests/grafana-config/dashboards/configmap-teslamate-charge-level.yaml +++ /dev/null @@ -1,419 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: grafana-dashboard-teslamate-charge-level - namespace: monitoring - labels: - grafana_dashboard: "1" - annotations: - grafana_folder: "TeslaMate" -data: - teslamate-charge-level.json: | - { - "annotations": { - "list": [ - { - "builtIn": 1, - "datasource": { - "type": "grafana", - "uid": "-- Grafana --" - }, - "enable": true, - "hide": true, - "iconColor": "rgba(0, 211, 255, 1)", - "name": "Annotations & Alerts", - "type": "dashboard" - } - ] - }, - "editable": true, - "fiscalYearStartMonth": 0, - "graphTooltip": 0, - "links": [ - { - "icon": "dashboard", - "tags": [], - "title": "TeslaMate", - "tooltip": "", - "type": "link", - "url": "${base_url:raw}" - }, - { - "asDropdown": true, - "icon": "external link", - "tags": [ - "tesla" - ], - "title": "Dashboards", - "type": "dashboards" - } - ], - "panels": [ - { - "collapsed": false, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 0 - }, - "id": 4, - "panels": [], - "repeat": "car_id", - "title": "$car_id", - "type": "row" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "Charge Level", - "axisPlacement": "auto", - "barAlignment": 0, - "barWidthFactor": 0.6, - "drawStyle": "line", - "fillOpacity": 10, - "gradientMode": "opacity", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "stepAfter", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": true, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "line" - } - }, - "decimals": 0, - "links": [], - "mappings": [], - "max": 100, - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "transparent", - "value": 0 - } - ] - }, - "unit": "percent" - }, - "overrides": [] - }, - "gridPos": { - "h": 21, - "w": 24, - "x": 0, - "y": 1 - }, - "id": 2, - "options": { - "legend": { - "calcs": [ - "mean", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "hideZeros": false, - "maxHeight": 600, - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "time_series", - "rawQuery": true, - "rawSql": "SELECT\n\tdate_bin('2 minutes'::interval, timezone('UTC', date), to_timestamp(${__from:date:seconds})) as time,\n\tavg(battery_level) AS \"Battery Level\",\n\tavg(usable_battery_level) AS \"Usable Battery Level\"\nfrom positions\n\tWHERE $__timeFilter(date) AND car_id = $car_id and ideal_battery_range_km is not null\n\tgroup by time\n\tORDER BY time ASC\n;", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "SELECT\r\n 20 as lower,\r\n CASE WHEN lfp_battery THEN 100 ELSE 80 END as upper\r\nfrom cars inner join car_settings on cars.settings_id = car_settings.id\r\nwhere cars.id = $car_id", - "refId": "B", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "-- To be able to calculate percentiles for unevenly sampled values we are bucketing & gapfilling values before running calculations\r\nwith positions_filtered as (\r\n select\r\n date,\r\n battery_level\r\n from\r\n positions p\r\n where\r\n p.car_id = $car_id\r\n -- p.ideal_battery_range_km condition is added to reduce overall amount of data and avoid data biases while driving (unevenly sampled data)\r\n and p.ideal_battery_range_km is not null\r\n and 1 = $include_average_percentiles\r\n),\r\ngen_date_series as (\r\n select\r\n -- series is used to bucket data and avoid gaps in series used to determine percentiles\r\n generate_series(to_timestamp(${__from:date:seconds} - (86400 * $days_moving_average_percentiles / 2)), to_timestamp(${__to:date:seconds}), concat($bucket_width, ' seconds')::INTERVAL) as series_id\r\n),\r\ndate_series as (\r\n select\r\n timezone('UTC', series_id) as series_id,\r\n -- before joining, get beginning of next series to be able to left join `positions_filtered`\r\n timezone('UTC', lead(series_id) over (order by series_id asc)) as next_series_id\r\n from\r\n gen_date_series\r\n),\r\npositions_bucketed as (\r\n select\r\n series_id,\r\n -- simple average can result in loss of accuracy, see https://www.timescale.com/blog/what-time-weighted-averages-are-and-why-you-should-care/ for details\r\n avg(battery_level) as battery_level,\r\n min(positions_filtered.date) as series_min_date\r\n from\r\n date_series\r\n left join positions_filtered on\r\n positions_filtered.date >= date_series.series_id\r\n and positions_filtered.date < date_series.next_series_id\r\n group by\r\n series_id\r\n),\r\n-- PostgreSQL cannot IGNORE NULLS via Window Functions LAST_VALUE - therefore use natural behavior of COUNT & MAX, see https://www.reddit.com/r/SQL/comments/wb949v/comment/ii5mmmi/ for details\r\npositions_bucketed_gapfilling_locf_intermediate as (\r\n select\r\n series_id,\r\n battery_level,\r\n series_min_date,\r\n count(battery_level) over (order by series_id) as i\r\n from\r\n positions_bucketed\r\n\r\n),\r\npositions_bucketed_gapfilled_locf as (\r\n select\r\n series_id,\r\n series_min_date,\r\n max(battery_level) over (partition by i) as battery_level_locf\r\n from\r\n positions_bucketed_gapfilling_locf_intermediate\r\n),\r\n-- PostgreSQL cannot use PERCENTILE_DISC as Window Function - therefore use ARRAY_AGG and UNNEST, see https://stackoverflow.com/a/72718604 for details\r\npositions_bucketed_gapfilled_locf_percentile_intermediate as (\r\n select\r\n series_id,\r\n series_min_date,\r\n min(series_min_date) over () as min_date,\r\n array_agg(battery_level_locf) over w as arr,\r\n avg(battery_level_locf) over w as battery_level_avg\r\n from\r\n positions_bucketed_gapfilled_locf\r\n window w as (rows between (86400 / $bucket_width) * ($days_moving_average_percentiles / 2) preceding and (86400 / $bucket_width) * ($days_moving_average_percentiles / 2) following)\r\n)\r\n\r\nselect\r\n series_id::timestamptz,\r\n (select percentile_cont(0.075) within group (order by s) from unnest(arr) trick(s)) as \"$days_moving_average_percentiles Day Moving 7.5% Percentile (${bucket_width:text} buckets)\",\r\n battery_level_avg as \"$days_moving_average_percentiles Day Moving Average (${bucket_width:text} buckets)\",\r\n (select percentile_cont(0.5) within group (order by s) from unnest(arr) trick(s)) as \"$days_moving_average_percentiles Day Moving Median (${bucket_width:text} buckets)\",\r\n (select percentile_cont(0.925) within group (order by s) from unnest(arr) trick(s)) as \"$days_moving_average_percentiles Day Moving 92.5% Percentile (${bucket_width:text} buckets)\"\r\nfrom\r\n positions_bucketed_gapfilled_locf_percentile_intermediate where $__timeFilter(series_id) and series_min_date >= min_date", - "refId": "C", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Charge Level", - "transformations": [ - { - "id": "configFromData", - "options": { - "applyTo": { - "id": "byFrameRefID", - "options": "A" - }, - "configRefId": "B", - "mappings": [ - { - "fieldName": "lower", - "handlerArguments": { - "threshold": { - "color": "green" - } - }, - "handlerKey": "threshold1" - }, - { - "fieldName": "upper", - "handlerArguments": { - "threshold": { - "color": "green" - } - }, - "handlerKey": "threshold1" - } - ] - } - } - ], - "type": "timeseries" - } - ], - "preload": false, - "refresh": "", - "schemaVersion": 41, - "tags": [ - "tesla" - ], - "templating": { - "list": [ - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "SELECT\n id as __value,\n CASE WHEN COUNT(id) OVER (PARTITION BY name) > 1 AND name IS NOT NULL THEN CONCAT(name, ' - ', RIGHT(vin, 6)) ELSE COALESCE(name, CONCAT('VIN ', vin)) end as __text \nFROM cars\nORDER BY display_priority ASC, name ASC, vin ASC;", - "hide": 2, - "includeAll": true, - "label": "Car", - "name": "car_id", - "options": [], - "query": "SELECT\n id as __value,\n CASE WHEN COUNT(id) OVER (PARTITION BY name) > 1 AND name IS NOT NULL THEN CONCAT(name, ' - ', RIGHT(vin, 6)) ELSE COALESCE(name, CONCAT('VIN ', vin)) end as __text \nFROM cars\nORDER BY display_priority ASC, name ASC, vin ASC;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "select base_url from settings limit 1;", - "hide": 2, - "includeAll": false, - "name": "base_url", - "options": [], - "query": "select base_url from settings limit 1;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": { - "text": "2h", - "value": "7200" - }, - "description": "Data used to calculate Moving Average / Percentiles is unevenly sampled in TeslaMate. To avoid biases towards more frequently sampled values, the data is bucketed. For buckets without sampled values, the last observed value is carried forward. Bucketing is not time-weighted but is a simple average. Increasing the bucket width results in a loss of accuracy.", - "includeAll": false, - "label": "Bucket Width", - "name": "bucket_width", - "options": [ - { - "selected": false, - "text": "1h", - "value": "3600" - }, - { - "selected": true, - "text": "2h", - "value": "7200" - }, - { - "selected": false, - "text": "4h", - "value": "14400" - } - ], - "query": "1h : 3600, 2h : 7200, 4h : 14400", - "type": "custom" - }, - { - "current": { - "text": "yes", - "value": "1" - }, - "includeAll": false, - "label": "Include Moving Average / Percentiles", - "name": "include_average_percentiles", - "options": [ - { - "selected": false, - "text": "no", - "value": "0" - }, - { - "selected": true, - "text": "yes", - "value": "1" - } - ], - "query": "no : 0, yes : 1", - "type": "custom" - }, - { - "current": { - "text": "1/6 of interval", - "value": "6" - }, - "description": "", - "includeAll": false, - "label": "Moving Average / Percentiles Width", - "name": "intervals_moving_average_percentiles", - "options": [ - { - "selected": true, - "text": "1/6 of interval", - "value": "6" - }, - { - "selected": false, - "text": "1/12 of interval", - "value": "12" - } - ], - "query": "1/6 of interval : 6, 1/12 of interval : 12", - "type": "custom" - }, - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "select ((${__to:date:seconds} - ${__from:date:seconds}) / 86400 / $intervals_moving_average_percentiles)", - "hide": 2, - "includeAll": false, - "name": "days_moving_average_percentiles", - "options": [], - "query": "select ((${__to:date:seconds} - ${__from:date:seconds}) / 86400 / $intervals_moving_average_percentiles)", - "refresh": 2, - "regex": "", - "type": "query" - } - ] - }, - "time": { - "from": "now-6M", - "to": "now" - }, - "timepicker": {}, - "timezone": "", - "title": "Tesla Charge Level", - "uid": "WopVO_mgz", - "version": 1 - } diff --git a/argocd/manifests/grafana-config/dashboards/configmap-teslamate-charges.yaml b/argocd/manifests/grafana-config/dashboards/configmap-teslamate-charges.yaml deleted file mode 100644 index 6b33cee..0000000 --- a/argocd/manifests/grafana-config/dashboards/configmap-teslamate-charges.yaml +++ /dev/null @@ -1,1537 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: grafana-dashboard-teslamate-charges - namespace: monitoring - labels: - grafana_dashboard: "1" - annotations: - grafana_folder: "TeslaMate" -data: - teslamate-charges.json: | - { - "annotations": { - "list": [ - { - "builtIn": 1, - "datasource": { - "type": "grafana", - "uid": "-- Grafana --" - }, - "enable": true, - "hide": true, - "iconColor": "rgba(0, 211, 255, 1)", - "name": "Annotations & Alerts", - "type": "dashboard" - } - ] - }, - "editable": true, - "fiscalYearStartMonth": 0, - "graphTooltip": 0, - "links": [ - { - "icon": "dashboard", - "tags": [], - "title": "TeslaMate", - "tooltip": "", - "type": "link", - "url": "${base_url:raw}" - }, - { - "asDropdown": true, - "icon": "external link", - "tags": [ - "tesla" - ], - "title": "Dashboards", - "type": "dashboards" - } - ], - "panels": [ - { - "collapsed": false, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 0 - }, - "id": 16, - "panels": [], - "title": "Summary of this period", - "type": "row" - }, - { - "datasource": { - "type": "datasource", - "uid": "-- Dashboard --" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "mappings": [ - { - "options": { - "match": "null", - "result": { - "text": "N/A" - } - }, - "type": "special" - } - ], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "text", - "value": 0 - } - ] - }, - "unit": "kwatth" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "charge_energy_added" - }, - "properties": [ - { - "id": "displayName", - "value": "Total Energy added:" - } - ] - } - ] - }, - "gridPos": { - "h": 2, - "w": 6, - "x": 0, - "y": 1 - }, - "id": 10, - "maxDataPoints": 100, - "options": { - "colorMode": "background", - "graphMode": "none", - "justifyMode": "center", - "orientation": "auto", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "sum" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "auto", - "wideLayout": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "datasource", - "uid": "-- Dashboard --" - }, - "panelId": 6, - "refId": "A" - } - ], - "title": "", - "transformations": [ - { - "id": "filterFieldsByName", - "options": { - "include": { - "names": [ - "charge_energy_added" - ] - } - } - } - ], - "transparent": true, - "type": "stat" - }, - { - "datasource": { - "type": "datasource", - "uid": "-- Dashboard --" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "mappings": [ - { - "options": { - "match": "null", - "result": { - "text": "N/A" - } - }, - "type": "special" - } - ], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "text", - "value": 0 - } - ] - }, - "unit": "kwatth" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "charge_energy_used" - }, - "properties": [ - { - "id": "displayName", - "value": "Total Energy used:" - } - ] - } - ] - }, - "gridPos": { - "h": 2, - "w": 6, - "x": 6, - "y": 1 - }, - "id": 20, - "maxDataPoints": 100, - "options": { - "colorMode": "background", - "graphMode": "none", - "justifyMode": "center", - "orientation": "auto", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "sum" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "auto", - "wideLayout": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "datasource", - "uid": "-- Dashboard --" - }, - "panelId": 6, - "refId": "A" - } - ], - "title": "", - "transformations": [ - { - "id": "filterFieldsByName", - "options": { - "include": { - "names": [ - "charge_energy_used" - ] - } - } - } - ], - "transparent": true, - "type": "stat" - }, - { - "datasource": { - "type": "datasource", - "uid": "-- Dashboard --" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "decimals": 2, - "mappings": [ - { - "options": { - "match": "null", - "result": { - "text": "N/A" - } - }, - "type": "special" - } - ], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "text", - "value": 0 - } - ] - }, - "unit": "none" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "cost" - }, - "properties": [ - { - "id": "displayName", - "value": "Total Charging Cost:" - } - ] - } - ] - }, - "gridPos": { - "h": 2, - "w": 6, - "x": 12, - "y": 1 - }, - "id": 14, - "maxDataPoints": 100, - "options": { - "colorMode": "background", - "graphMode": "none", - "justifyMode": "center", - "orientation": "horizontal", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "sum" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "auto", - "wideLayout": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "datasource", - "uid": "-- Dashboard --" - }, - "panelId": 6, - "refId": "A" - } - ], - "title": "", - "transformations": [ - { - "id": "filterFieldsByName", - "options": { - "include": { - "names": [ - "cost" - ] - } - } - } - ], - "transparent": true, - "type": "stat" - }, - { - "datasource": { - "type": "datasource", - "uid": "-- Dashboard --" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "decimals": 0, - "mappings": [ - { - "options": { - "match": "null", - "result": { - "text": "N/A" - } - }, - "type": "special" - } - ], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "text", - "value": 0 - } - ] - }, - "unit": "m" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "duration_min" - }, - "properties": [ - { - "id": "displayName", - "value": "Ø Duration:" - } - ] - } - ] - }, - "gridPos": { - "h": 2, - "w": 6, - "x": 18, - "y": 1 - }, - "id": 15, - "maxDataPoints": 100, - "options": { - "colorMode": "background", - "graphMode": "none", - "justifyMode": "center", - "orientation": "horizontal", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "mean" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "auto", - "wideLayout": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "datasource", - "uid": "-- Dashboard --" - }, - "panelId": 6, - "refId": "A" - } - ], - "title": "", - "transformations": [ - { - "id": "filterFieldsByName", - "options": { - "include": { - "names": [ - "duration_min" - ] - } - } - } - ], - "transparent": true, - "type": "stat" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "description": "Browse your charges by Geofence, Location, Type, Cost and Duration in order to have an accurate Total of kWh added and their respective costs", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "custom": { - "align": "auto", - "cellOptions": { - "type": "auto" - }, - "filterable": false, - "inspect": false, - "minWidth": 150 - }, - "displayName": "", - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": 0 - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "start_date" - }, - "properties": [ - { - "id": "displayName", - "value": "Date" - }, - { - "id": "links", - "value": [ - { - "targetBlank": false, - "title": "View charge details", - "url": "/d/BHhxFeZRz/charge-details?from=${__data.fields.start_date_ts.numeric}&to=${__data.fields.end_date_ts.numeric}&var-car_id=${__data.fields.car_id.numeric}&var-charging_process_id=${__data.fields.id.numeric}" - } - ] - }, - { - "id": "custom.minWidth", - "value": 210 - }, - { - "id": "unit", - "value": "dateTimeAsLocal" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "charge_energy_added" - }, - "properties": [ - { - "id": "displayName", - "value": "Energy added" - }, - { - "id": "decimals", - "value": 2 - }, - { - "id": "custom.minWidth", - "value": 115 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "start_battery_level" - }, - "properties": [ - { - "id": "displayName", - "value": "% Start" - }, - { - "id": "unit", - "value": "percent" - }, - { - "id": "decimals", - "value": 0 - }, - { - "id": "custom.minWidth", - "value": 70 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "end_battery_level" - }, - "properties": [ - { - "id": "displayName", - "value": "% End" - }, - { - "id": "unit", - "value": "percent" - }, - { - "id": "decimals", - "value": 0 - }, - { - "id": "custom.minWidth", - "value": 65 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "duration_min" - }, - "properties": [ - { - "id": "displayName", - "value": "Duration" - }, - { - "id": "unit", - "value": "m" - }, - { - "id": "decimals", - "value": 1 - }, - { - "id": "custom.minWidth", - "value": 80 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "outside_temp_avg_c" - }, - "properties": [ - { - "id": "displayName", - "value": "Temp" - }, - { - "id": "unit", - "value": "celsius" - }, - { - "id": "decimals", - "value": 1 - }, - { - "id": "custom.cellOptions", - "value": { - "type": "color-text" - } - }, - { - "id": "thresholds", - "value": { - "mode": "absolute", - "steps": [ - { - "color": "#C0D8FF", - "value": 0 - }, - { - "color": "#C8F2C2", - "value": 10 - }, - { - "color": "#FFA6B0", - "value": 20 - } - ] - } - }, - { - "id": "custom.minWidth", - "value": 70 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "cost" - }, - "properties": [ - { - "id": "displayName", - "value": "Cost" - }, - { - "id": "decimals", - "value": 2 - }, - { - "id": "links", - "value": [ - { - "targetBlank": false, - "title": "Set Cost", - "url": "${base_url:raw}/charge-cost/${__data.fields.id.numeric}" - } - ] - }, - { - "id": "noValue", - "value": "-" - }, - { - "id": "custom.minWidth", - "value": 70 - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*_ts/" - }, - "properties": [ - { - "id": "custom.hidden", - "value": true - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "id" - }, - "properties": [ - { - "id": "custom.hidden", - "value": true - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "address" - }, - "properties": [ - { - "id": "displayName", - "value": "Location" - }, - { - "id": "links", - "value": [ - { - "targetBlank": true, - "title": "Create or edit geo-fence", - "url": "${base_url:raw}/geo-fences/${__data.fields.path}" - } - ] - }, - { - "id": "custom.minWidth", - "value": 180 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "range_added_km" - }, - "properties": [ - { - "id": "displayName", - "value": "Range gained" - }, - { - "id": "decimals", - "value": 0 - }, - { - "id": "custom.minWidth", - "value": 120 - }, - { - "id": "unit", - "value": "lengthkm" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "range_added_mi" - }, - "properties": [ - { - "id": "displayName", - "value": "Range gained" - }, - { - "id": "decimals", - "value": 0 - }, - { - "id": "custom.minWidth", - "value": 120 - }, - { - "id": "unit", - "value": "lengthmi" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "charge_energy_added_per_hour" - }, - "properties": [ - { - "id": "displayName", - "value": "Ø Power" - }, - { - "id": "unit", - "value": "kwatt" - }, - { - "id": "decimals", - "value": 1 - }, - { - "id": "custom.cellOptions", - "value": { - "type": "color-text" - } - }, - { - "id": "thresholds", - "value": { - "mode": "absolute", - "steps": [ - { - "color": "#96D98D", - "value": 0 - }, - { - "color": "#56A64B", - "value": 20 - }, - { - "color": "#37872D", - "value": 55 - } - ] - } - }, - { - "id": "custom.minWidth", - "value": 90 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "range_added_per_hour_km" - }, - "properties": [ - { - "id": "displayName", - "value": "Ø Charge rate" - }, - { - "id": "unit", - "value": "velocitykmh" - }, - { - "id": "custom.minWidth", - "value": 120 - }, - { - "id": "decimals", - "value": 0 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "outside_temp_avg_f" - }, - "properties": [ - { - "id": "displayName", - "value": "Temp" - }, - { - "id": "unit", - "value": "fahrenheit" - }, - { - "id": "decimals", - "value": 1 - }, - { - "id": "custom.minWidth", - "value": 70 - }, - { - "id": "custom.cellOptions", - "value": { - "type": "color-text" - } - }, - { - "id": "thresholds", - "value": { - "mode": "absolute", - "steps": [ - { - "color": "super-light-blue", - "value": 0 - }, - { - "color": "super-light-green", - "value": 50 - }, - { - "color": "super-light-red", - "value": 68 - } - ] - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "range_added_per_hour_mi" - }, - "properties": [ - { - "id": "displayName", - "value": "Ø Charge rate" - }, - { - "id": "unit", - "value": "velocitymph" - }, - { - "id": "custom.minWidth", - "value": 120 - }, - { - "id": "decimals", - "value": 0 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "path" - }, - "properties": [ - { - "id": "custom.hidden", - "value": true - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "charge_energy_used" - }, - "properties": [ - { - "id": "displayName", - "value": "Energy used" - }, - { - "id": "decimals", - "value": 2 - }, - { - "id": "custom.minWidth", - "value": 105 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "charging_efficiency" - }, - "properties": [ - { - "id": "displayName", - "value": "Efficiency" - }, - { - "id": "unit", - "value": "percentunit" - }, - { - "id": "decimals", - "value": 0 - }, - { - "id": "custom.cellOptions", - "value": { - "mode": "basic", - "type": "gauge" - } - }, - { - "id": "color", - "value": { - "mode": "continuous-RdYlGr" - } - }, - { - "id": "max", - "value": 1 - }, - { - "id": "custom.minWidth", - "value": 120 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "car_id" - }, - "properties": [ - { - "id": "custom.hidden", - "value": true - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "end_date" - }, - "properties": [ - { - "id": "custom.hidden", - "value": true - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "cost_per_kwh" - }, - "properties": [ - { - "id": "displayName", - "value": "Cost / kWh" - }, - { - "id": "decimals", - "value": 2 - }, - { - "id": "noValue", - "value": "-" - }, - { - "id": "custom.minWidth", - "value": 100 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "charge_type" - }, - "properties": [ - { - "id": "displayName", - "value": "Type" - }, - { - "id": "custom.cellOptions", - "value": { - "type": "color-text" - } - }, - { - "id": "custom.minWidth", - "value": 40 - }, - { - "id": "mappings", - "value": [ - { - "options": { - "AC": { - "color": "green", - "index": 0 - }, - "DC": { - "color": "light-orange", - "index": 1 - } - }, - "type": "value" - } - ] - }, - { - "id": "custom.align", - "value": "center" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "odometer_km" - }, - "properties": [ - { - "id": "unit", - "value": "km" - }, - { - "id": "displayName", - "value": "Odometer" - }, - { - "id": "custom.minWidth", - "value": 95 - }, - { - "id": "decimals", - "value": 0 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "odometer_mi" - }, - "properties": [ - { - "id": "unit", - "value": "mi" - }, - { - "id": "displayName", - "value": "Odometer" - }, - { - "id": "custom.minWidth", - "value": 95 - }, - { - "id": "decimals", - "value": 0 - } - ] - } - ] - }, - "gridPos": { - "h": 19, - "w": 24, - "x": 0, - "y": 3 - }, - "id": 6, - "options": { - "cellHeight": "sm", - "footer": { - "countRows": false, - "fields": "", - "reducer": [ - "sum" - ], - "show": false - }, - "showHeader": true, - "sortBy": [] - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "WITH data AS (\n SELECT\n (round(extract(epoch FROM start_date) - 10) * 1000) AS start_date_ts,\n (round(extract(epoch FROM end_date) + 10) * 1000) AS end_date_ts,\n start_date,\n end_date,\n CONCAT_WS(', ', COALESCE(addresses.name, nullif(CONCAT_WS(' ', addresses.road, addresses.house_number), '')), addresses.city) AS address,\n g.name as geofence_name,\n g.id as geofence_id,\n p.latitude,\n p.longitude,\n cp.charge_energy_added,\n cp.charge_energy_used,\n duration_min,\n start_battery_level,\n end_battery_level,\n end_${preferred_range}_range_km - start_${preferred_range}_range_km as range_added,\n outside_temp_avg,\n cp.id,\n p.odometer - lag(p.odometer) OVER (ORDER BY start_date) AS distance,\n cars.efficiency,\n cp.car_id,\n cost,\n max(c.charger_voltage) as max_charger_voltage,\n CASE WHEN NULLIF(mode() within group (order by charger_phases),0) is null THEN 'DC' ELSE 'AC' END AS charge_type,\n p.odometer as odometer\n FROM\n charging_processes cp\n\tLEFT JOIN charges c ON cp.id = c.charging_process_id\n LEFT JOIN positions p ON p.id = cp.position_id\n LEFT JOIN cars ON cars.id = cp.car_id\n LEFT JOIN addresses ON addresses.id = cp.address_id\n LEFT JOIN geofences g ON g.id = geofence_id\n WHERE \n cp.car_id = $car_id AND\n $__timeFilter(start_date) AND\n (cp.charge_energy_added IS NULL OR cp.charge_energy_added > 0) AND\n ('${geofence:pipe}' = '-1' OR geofence_id in ($geofence))\n GROUP BY 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 19, p.odometer\n ORDER BY\n start_date\n)\nSELECT\n start_date_ts,\n end_date_ts,\n CASE WHEN geofence_id IS NULL THEN CONCAT('new?lat=', latitude, '&lng=', longitude)\n WHEN geofence_id IS NOT NULL THEN CONCAT(geofence_id, '/edit')\n END as path,\n car_id,\n id,\n -- Columns\n start_date,\n end_date,\n COALESCE(geofence_name, address) as address, \n charge_type,\n duration_min,\n cost,\n cost / NULLIF(greatest(charge_energy_added, charge_energy_used), 0) as cost_per_kwh,\n charge_energy_added,\n greatest(charge_energy_used, charge_energy_added) as charge_energy_used,\n charge_energy_added / greatest(charge_energy_used, charge_energy_added) as charging_efficiency,\n convert_celsius(outside_temp_avg, '$temp_unit') AS outside_temp_avg_$temp_unit,\n charge_energy_added * 60 / NULLIF (duration_min, 0) AS charge_energy_added_per_hour,\n convert_km(range_added * 60 / NULLIF (duration_min, 0), '$length_unit') AS range_added_per_hour_$length_unit,\n convert_km(range_added, '$length_unit') AS range_added_$length_unit,\n start_battery_level,\n end_battery_level,\n convert_km(odometer::numeric, '$length_unit') AS odometer_$length_unit\n FROM\n data\nWHERE\n (distance >= 0 OR distance IS NULL)\n AND duration_min >= '$min_duration_min'\n AND \n CASE\n WHEN '$cost' !~ '^[0-9]+$' THEN TRUE \n ELSE cost >= COALESCE(NULLIF('$cost', '')::NUMERIC, 0) \n END\n AND charge_type = ANY(CASE WHEN array_to_string(ARRAY[$charge_type], ',') = 'DC' THEN ARRAY['DC'] WHEN array_to_string(ARRAY[$charge_type], ',') = 'AC' THEN ARRAY['AC'] ELSE ARRAY['DC', 'AC'] END)\n AND address ILIKE '%$location%'\nORDER BY\n start_date DESC;", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Charger type: $charge_type", - "type": "table" - }, - { - "collapsed": false, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 22 - }, - "id": 18, - "panels": [], - "title": "General information (All charges)", - "type": "row" - }, - { - "fieldConfig": { - "defaults": {}, - "overrides": [] - }, - "gridPos": { - "h": 2, - "w": 24, - "x": 0, - "y": 23 - }, - "id": 19, - "options": { - "code": { - "language": "plaintext", - "showLineNumbers": false, - "showMiniMap": false - }, - "content": "From here you can check if you have \nincomplete data of **Charges** (charges without ending date)\nIf so, you may follow the official \nguide by Manually fixing data", - "mode": "markdown" - }, - "pluginVersion": "12.1.1", - "title": "", - "type": "text" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "fixed" - }, - "custom": { - "align": "center", - "cellOptions": { - "type": "auto" - }, - "inspect": false - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": 0 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 6, - "w": 24, - "x": 0, - "y": 25 - }, - "id": 17, - "options": { - "cellHeight": "sm", - "footer": { - "countRows": false, - "enablePagination": true, - "fields": "", - "reducer": [ - "sum" - ], - "show": false - }, - "showHeader": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "SELECT id as \"Charging Process ID\", start_date, end_date, charge_energy_added, charge_energy_used, start_battery_level, end_battery_level, duration_min\nFROM charging_processes \nWHERE car_id = $car_id AND end_date is null\nORDER BY start_date DESC\n", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Incomplete Charges 🪫", - "type": "table" - } - ], - "preload": false, - "refresh": "", - "schemaVersion": 41, - "tags": [ - "tesla" - ], - "templating": { - "list": [ - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "SELECT\n id as __value,\n CASE WHEN COUNT(id) OVER (PARTITION BY name) > 1 AND name IS NOT NULL THEN CONCAT(name, ' - ', RIGHT(vin, 6)) ELSE COALESCE(name, CONCAT('VIN ', vin)) end as __text \nFROM cars\nORDER BY display_priority ASC, name ASC, vin ASC;", - "includeAll": false, - "label": "Car", - "name": "car_id", - "options": [], - "query": "SELECT\n id as __value,\n CASE WHEN COUNT(id) OVER (PARTITION BY name) > 1 AND name IS NOT NULL THEN CONCAT(name, ' - ', RIGHT(vin, 6)) ELSE COALESCE(name, CONCAT('VIN ', vin)) end as __text \nFROM cars\nORDER BY display_priority ASC, name ASC, vin ASC;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "select unit_of_length from settings limit 1;", - "hide": 2, - "includeAll": false, - "name": "length_unit", - "options": [], - "query": "select unit_of_length from settings limit 1;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "select unit_of_temperature from settings limit 1;", - "hide": 2, - "includeAll": false, - "name": "temp_unit", - "options": [], - "query": "select unit_of_temperature from settings limit 1;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "select preferred_range from settings limit 1;", - "hide": 2, - "includeAll": false, - "name": "preferred_range", - "options": [], - "query": "select preferred_range from settings limit 1;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "select base_url from settings limit 1;", - "hide": 2, - "includeAll": false, - "name": "base_url", - "options": [], - "query": "select base_url from settings limit 1;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "allValue": "-1", - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "SELECT name AS __text, id AS __value FROM geofences ORDER BY name COLLATE \"C\" ASC;", - "includeAll": true, - "label": "Geofence", - "multi": true, - "name": "geofence", - "options": [], - "query": "SELECT name AS __text, id AS __value FROM geofences ORDER BY name COLLATE \"C\" ASC;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": { - "text": "", - "value": "" - }, - "description": "Type a text contained in Location", - "label": "Location", - "name": "location", - "options": [ - { - "selected": true, - "text": "", - "value": "" - } - ], - "query": "", - "type": "textbox" - }, - { - "current": {}, - "includeAll": true, - "label": "Type", - "multi": true, - "name": "charge_type", - "options": [ - { - "selected": false, - "text": "AC", - "value": "AC" - }, - { - "selected": false, - "text": "DC", - "value": "DC" - } - ], - "query": "AC, DC", - "type": "custom" - }, - { - "current": { - "text": "", - "value": "" - }, - "label": "Cost >=", - "name": "cost", - "options": [ - { - "selected": true, - "text": "", - "value": "" - } - ], - "query": "", - "type": "textbox" - }, - { - "current": { - "text": "0", - "value": "0" - }, - "label": "Duration (minutes) >=", - "name": "min_duration_min", - "options": [ - { - "selected": true, - "text": "0", - "value": "0" - } - ], - "query": "0", - "type": "textbox" - } - ] - }, - "time": { - "from": "now-3M", - "to": "now" - }, - "timepicker": {}, - "timezone": "", - "title": "Tesla Charges", - "uid": "TSmNYvRRk", - "version": 1 - } diff --git a/argocd/manifests/grafana-config/dashboards/configmap-teslamate-charging-stats.yaml b/argocd/manifests/grafana-config/dashboards/configmap-teslamate-charging-stats.yaml deleted file mode 100644 index 3bc6ec1..0000000 --- a/argocd/manifests/grafana-config/dashboards/configmap-teslamate-charging-stats.yaml +++ /dev/null @@ -1,2428 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: grafana-dashboard-teslamate-charging-stats - namespace: monitoring - labels: - grafana_dashboard: "1" - annotations: - grafana_folder: "TeslaMate" -data: - teslamate-charging-stats.json: | - { - "annotations": { - "list": [ - { - "builtIn": 1, - "datasource": { - "type": "grafana", - "uid": "-- Grafana --" - }, - "enable": true, - "hide": true, - "iconColor": "rgba(0, 211, 255, 1)", - "name": "Annotations & Alerts", - "type": "dashboard" - } - ] - }, - "editable": true, - "fiscalYearStartMonth": 0, - "graphTooltip": 1, - "links": [ - { - "icon": "dashboard", - "tags": [], - "title": "TeslaMate", - "tooltip": "", - "type": "link", - "url": "${base_url:raw}" - }, - { - "asDropdown": true, - "icon": "external link", - "tags": [ - "tesla" - ], - "title": "Dashboards", - "type": "dashboards" - } - ], - "panels": [ - { - "collapsed": false, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 0 - }, - "id": 12, - "panels": [], - "repeat": "car_id", - "title": "$car_id", - "type": "row" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "#c7d0d9", - "value": 0 - } - ] - }, - "unit": "none" - }, - "overrides": [] - }, - "gridPos": { - "h": 3, - "w": 3, - "x": 0, - "y": 1 - }, - "id": 8, - "maxDataPoints": 100, - "options": { - "colorMode": "value", - "fieldOptions": { - "calcs": [ - "mean" - ] - }, - "graphMode": "none", - "justifyMode": "auto", - "orientation": "horizontal", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "mean" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "value", - "wideLayout": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "SELECT\n\tcount(*)\nFROM\n\tcharging_processes\nWHERE\n\t$__timeFilter(end_date)\n\tAND duration_min >= $min_duration\n\tAND car_id = $car_id;", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "# of Charges", - "type": "stat" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "#c7d0d9", - "value": 0 - } - ] - }, - "unit": "kwatth" - }, - "overrides": [] - }, - "gridPos": { - "h": 3, - "w": 3, - "x": 3, - "y": 1 - }, - "id": 10, - "maxDataPoints": 100, - "options": { - "colorMode": "value", - "fieldOptions": { - "calcs": [ - "mean" - ] - }, - "graphMode": "none", - "justifyMode": "auto", - "orientation": "horizontal", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "mean" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "value", - "wideLayout": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "SELECT\n\tsum(charge_energy_added)\nFROM\n\tcharging_processes\nWHERE\n\t$__timeFilter(end_date)\n\tAND duration_min >= $min_duration\n\tAND car_id = $car_id;\n", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Total Energy added", - "type": "stat" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "decimals": 2, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "#c7d0d9", - "value": 0 - } - ] - }, - "unit": "none" - }, - "overrides": [] - }, - "gridPos": { - "h": 3, - "w": 3, - "x": 6, - "y": 1 - }, - "id": 14, - "maxDataPoints": 100, - "options": { - "colorMode": "value", - "fieldOptions": { - "calcs": [ - "mean" - ] - }, - "graphMode": "none", - "justifyMode": "auto", - "orientation": "horizontal", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "mean" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "value", - "wideLayout": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "SELECT\n\tCOALESCE(sum(cp.cost),0)\nFROM\n\tcharging_processes cp\nLEFT JOIN \n\taddresses addr ON addr.id = address_id\nLEFT JOIN\n geofences geo ON geo.id = geofence_id\nJOIN\n charges char ON char.charging_process_id = cp.id AND char.date = end_date\t\nWHERE\n $__timeFilter(end_date)\n AND (addr.name ILIKE '%supercharger%' OR geo.name ILIKE '%supercharger%' OR char.fast_charger_brand = 'Tesla')\n\tAND NULLIF(char.charger_phases, 0) IS NULL\n\tAND char.fast_charger_type != 'ACSingleWireCAN'\n\tAND cp.cost IS NOT NULL\n\tAND duration_min >= $min_duration\n\tAND cp.car_id = $car_id;", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "SuC Charging Cost", - "type": "stat" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "decimals": 2, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "#c7d0d9", - "value": 0 - } - ] - }, - "unit": "none" - }, - "overrides": [] - }, - "gridPos": { - "h": 3, - "w": 3, - "x": 9, - "y": 1 - }, - "id": 27, - "maxDataPoints": 100, - "options": { - "colorMode": "value", - "fieldOptions": { - "calcs": [ - "mean" - ] - }, - "graphMode": "none", - "justifyMode": "auto", - "orientation": "horizontal", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "mean" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "value", - "wideLayout": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "SELECT\n\tsum(cost)\nFROM\n\tcharging_processes\nWHERE\n\t$__timeFilter(end_date)\n\tAND duration_min >= $min_duration\n\tAND car_id = $car_id;", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Total Charging Cost", - "type": "stat" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "decimals": 2, - "displayName": "", - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "#d8d9da", - "value": 0 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 3, - "w": 3, - "x": 12, - "y": 1 - }, - "id": 26, - "options": { - "colorMode": "value", - "graphMode": "none", - "justifyMode": "auto", - "orientation": "auto", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "mean" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "auto", - "wideLayout": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "-- Query shared between Charging Stats, Statistics & Trip Dashboards (with minor changes) - ensure to modify in all places when necessary\n\nwith drives_start_event as (\n\n select\n 'drive_start' as event, start_date as date, start_${preferred_range}_range_km as range, start_km as odometer, car_id\n from drives\n where car_id = $car_id and $__timeFilter(start_date) and 48 <= extract(hour FROM to_timestamp(${__to:date:seconds}) - to_timestamp(${__from:date:seconds}))\n\n),\n\ndrives_end_event as (\n\n select\n 'drive_end' as event, end_date as date, end_${preferred_range}_range_km as range, end_km as odometer, car_id\n from drives\n where car_id = $car_id and $__timeFilter(start_date) and 48 <= extract(hour FROM to_timestamp(${__to:date:seconds}) - to_timestamp(${__from:date:seconds}))\n\n),\n\ncharging_processes_start_event as (\n\n select\n 'charging_process_start' as event, start_date as date, start_${preferred_range}_range_km as range, p.odometer, cp.car_id\n from charging_processes cp\n inner join positions p on cp.position_id = p.id\n where cp.car_id = $car_id and $__timeFilter(end_date) and 48 <= extract(hour FROM to_timestamp(${__to:date:seconds}) - to_timestamp(${__from:date:seconds}))\n\n),\n\ncharging_processes_end_event as (\n\n select\n 'charging_process_end' as event, end_date as date, end_${preferred_range}_range_km as range, p.odometer, cp.car_id\n from charging_processes cp\n inner join positions p on cp.position_id = p.id\n where cp.car_id = $car_id and $__timeFilter(end_date) and 48 <= extract(hour FROM to_timestamp(${__to:date:seconds}) - to_timestamp(${__from:date:seconds}))\n\n),\n\npositions as (\n\n select\n case\n when drive_id is not null and lead(drive_id) over w is not null then 'drive_start'\n else 'something'\n end as event,\n date, ${preferred_range}_battery_range_km as range, p.odometer, p.car_id\n from positions p\n where ideal_battery_range_km is not null and car_id = $car_id and 48 > extract(hour FROM to_timestamp(${__to:date:seconds}) - to_timestamp(${__from:date:seconds}))\n and (drive_id in (select id from drives where $__timeFilter(start_date)) or drive_id is null and $__timeFilter(date))\n window w as (order by date)\n\n),\n\ncombined as (\n\n select * from drives_start_event\n union all\n select * from drives_end_event\n union all\n select * from charging_processes_start_event\n union all\n select * from charging_processes_end_event\n union all\n select * from positions\n\n),\n\nfinal as (\n\n select\n car_id,\n lead(odometer) over w - odometer as distance,\n case when event != 'drive_start' then greatest(range - lead(range) over w, 0) else range - lead(range) over w end as range_loss\n from combined\n window w as (order by date asc)\n\n),\n\nderived as (\n\n select\n convert_km(sum(distance)::numeric, '$length_unit') as distance,\n sum(range_loss) * c.efficiency as consumption\n from final\n inner join cars c on car_id = c.id\n group by c.efficiency\n\n),\n\ncharges as (\n\n SELECT\n sum(cost) / sum(charge_energy_added) as cost_per_kwh\n FROM charging_processes\n where car_id = $car_id and $__timeFilter(end_date)\n\n)\n\nselect\n consumption / distance * 100 * cost_per_kwh as cost_mileage\nfrom derived cross join charges", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Ø Cost per 100 $length_unit", - "type": "stat" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "decimals": 2, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "#d8d9da", - "value": 0 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 3, - "w": 3, - "x": 15, - "y": 1 - }, - "id": 31, - "options": { - "colorMode": "value", - "graphMode": "none", - "justifyMode": "auto", - "orientation": "auto", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "mean" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "auto", - "wideLayout": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "SELECT \n sum(cost) / sum(greatest(charge_energy_added, charge_energy_used))\nFROM charging_processes\n WHERE $__timeFilter(end_date) AND duration_min >= $min_duration AND car_id = $car_id\n", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Ø Cost per kWh", - "type": "stat" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "decimals": 2, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "#d8d9da", - "value": 0 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 3, - "w": 3, - "x": 18, - "y": 1 - }, - "id": 32, - "options": { - "colorMode": "value", - "graphMode": "none", - "justifyMode": "auto", - "orientation": "auto", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "mean" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "auto", - "wideLayout": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "WITH data AS (\n SELECT\n\t\tcp.id,\n cp.cost,\n cp.charge_energy_added,\n cp.charge_energy_used,\n\t\tCASE WHEN NULLIF(mode() within group (order by charger_phases),0) is null THEN 'DC'\n\t\t\t\t ELSE 'AC'\n\t\tEND AS current\n\tFROM charging_processes cp\n RIGHT JOIN charges ON cp.id = charges.charging_process_id\n WHERE\n\t cp.car_id = $car_id\n AND duration_min >= $min_duration\n\t AND $__timeFilter(end_date)\n GROUP BY 1\n)\n\nSELECT \n sum(cost) / sum(greatest(charge_energy_added, charge_energy_used))\nFROM data\n WHERE current = 'DC'", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Ø Cost per kWh DC", - "type": "stat" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "decimals": 2, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "#d8d9da", - "value": 0 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 3, - "w": 3, - "x": 21, - "y": 1 - }, - "id": 33, - "options": { - "colorMode": "value", - "graphMode": "none", - "justifyMode": "auto", - "orientation": "auto", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "mean" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "auto", - "wideLayout": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "WITH data AS (\n SELECT\n\t\tcp.id,\n cp.cost,\n cp.charge_energy_added,\n cp.charge_energy_used,\n\t\tCASE WHEN NULLIF(mode() within group (order by charger_phases),0) is null THEN 'DC'\n\t\t\t\t ELSE 'AC'\n\t\tEND AS current\n\tFROM charging_processes cp\n RIGHT JOIN charges ON cp.id = charges.charging_process_id\n WHERE\n\t cp.car_id = $car_id\n AND duration_min >= $min_duration\n\t AND $__timeFilter(end_date)\n GROUP BY 1\n)\n\nSELECT \n sum(cost) / sum(greatest(charge_energy_added, charge_energy_used))\nFROM data\n WHERE current = 'AC'", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Ø Cost per kWh AC", - "type": "stat" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "custom": { - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "scaleDistribution": { - "type": "linear" - } - } - }, - "overrides": [] - }, - "gridPos": { - "h": 6, - "w": 12, - "x": 0, - "y": 4 - }, - "id": 15, - "options": { - "calculate": true, - "calculation": { - "yBuckets": { - "mode": "size", - "value": "10.00001" - } - }, - "cellGap": 2, - "cellValues": {}, - "color": { - "exponent": 0.5, - "fill": "#b4ff00", - "min": 0, - "mode": "opacity", - "reverse": false, - "scale": "exponential", - "scheme": "Oranges", - "steps": 128 - }, - "exemplars": { - "color": "rgba(255,0,255,0.7)" - }, - "filterValues": { - "le": 1e-9 - }, - "legend": { - "show": false - }, - "rowsFrame": { - "layout": "auto" - }, - "showValue": "never", - "tooltip": { - "maxHeight": 600, - "mode": "single", - "showColorScale": false, - "yHistogram": false - }, - "yAxis": { - "axisPlacement": "left", - "max": "100", - "reverse": false, - "unit": "short" - } - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "time_series", - "rawQuery": true, - "rawSql": "SELECT\n\t$__time(end_date),\n\tstart_battery_level,\n\tend_battery_level\nFROM\n\tcharging_processes\nWHERE\n\t$__timeFilter(end_date)\n\tAND duration_min >= $min_duration \n\tAND car_id = $car_id\nORDER BY\n\tend_date;", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "timeFrom": "6M", - "title": "Charge Heatmap", - "type": "heatmap" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "barWidthFactor": 0.6, - "drawStyle": "line", - "fillOpacity": 35, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": true, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "line" - } - }, - "mappings": [], - "max": 100, - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "transparent", - "value": 0 - } - ] - }, - "unit": "percent" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Start SOC" - }, - "properties": [ - { - "id": "custom.lineWidth", - "value": 0 - }, - { - "id": "custom.fillBelowTo", - "value": "End SOC" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "End SOC" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#73BF69", - "mode": "fixed" - } - }, - { - "id": "custom.fillBelowTo", - "value": "Start SOC" - }, - { - "id": "custom.lineWidth", - "value": 0 - } - ] - } - ] - }, - "gridPos": { - "h": 6, - "w": 12, - "x": 12, - "y": 4 - }, - "id": 16, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": false - }, - "tooltip": { - "hideZeros": false, - "maxHeight": 600, - "mode": "multi", - "sort": "desc" - } - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "time_series", - "rawQuery": true, - "rawSql": "WITH charges AS (\n\tSELECT\n\t\tend_date,\n\t\tstart_battery_level,\n\t\tend_battery_level,\n\t\tp.odometer,\n\t\tCOALESCE(\n\t\t\tLAG(p.odometer) OVER (\n\t\t\t\tORDER BY cp.end_date\n\t\t\t),\n\t\t\tp.odometer\n\t\t) as odometer_prev\n\tFROM\n\t\tcharging_processes cp\n\tJOIN positions p\n\tON p.id = cp.position_id\n\tWHERE\n\t\t$__timeFilter(cp.end_date)\n\t\tAND cp.duration_min >= $min_duration\n\t\tAND cp.car_id = $car_id\n)\nSELECT\n\tMIN(end_date) as time,\n\tMIN(start_battery_level) as \"Start SOC\",\n\tMAX(end_battery_level) as \"End SOC\"\nFROM charges\nGROUP BY\n\tCASE WHEN odometer - odometer_prev < 2 THEN odometer_prev ELSE odometer END\nORDER BY\n\ttime;", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "SELECT\r\n 20 as lower,\r\n CASE WHEN lfp_battery THEN 100 ELSE 80 END as upper\r\nfrom cars inner join car_settings on cars.settings_id = car_settings.id\r\nwhere cars.id = $car_id", - "refId": "B", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "timeFrom": "6M", - "title": "Charge Delta", - "transformations": [ - { - "id": "configFromData", - "options": { - "applyTo": { - "id": "byFrameRefID", - "options": "A" - }, - "configRefId": "B", - "mappings": [ - { - "fieldName": "lower", - "handlerArguments": { - "threshold": { - "color": "green" - } - }, - "handlerKey": "threshold1" - }, - { - "fieldName": "upper", - "handlerArguments": { - "threshold": { - "color": "green" - } - }, - "handlerKey": "threshold1" - } - ] - } - } - ], - "type": "timeseries" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - } - }, - "decimals": 2, - "mappings": [], - "unit": "kwatth" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "AC" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#73BF69", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "DC" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "light-orange", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 13, - "w": 5, - "x": 0, - "y": 10 - }, - "id": 18, - "maxDataPoints": 3, - "options": { - "displayLabels": [ - "name" - ], - "legend": { - "calcs": [], - "displayMode": "table", - "placement": "bottom", - "showLegend": true, - "values": [ - "value", - "percent" - ] - }, - "pieType": "pie", - "reduceOptions": { - "calcs": [ - "sum" - ], - "fields": "", - "values": false - }, - "tooltip": { - "hideZeros": false, - "maxHeight": 600, - "mode": "single", - "sort": "none" - } - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "time_series", - "rawQuery": true, - "rawSql": "WITH data AS (\n SELECT\n\t\tcp.id,\n\t\tcp.charge_energy_added,\n\t\tCASE WHEN NULLIF(mode() within group (order by charger_phases),0) is null THEN 'DC'\n\t\t\t\t ELSE 'AC'\n\t\tEND AS current,\n\t\tcp.charge_energy_used\n\tFROM charging_processes cp\n RIGHT JOIN charges ON cp.id = charges.charging_process_id\n WHERE\n\t cp.car_id = $car_id\n\t AND duration_min >= $min_duration\n\t AND $__timeFilter(end_date)\n GROUP BY 1,2\n)\nSELECT\n\tnow() AS time,\n\tSUM(GREATEST(charge_energy_added, charge_energy_used)) AS value,\n\tcurrent AS metric\nFROM data\nGROUP BY 3\nORDER BY metric DESC;", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "AC/DC - Energy Used", - "type": "piechart" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "continuous-reds" - }, - "custom": { - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": 0 - } - ] - }, - "unit": "none" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "pct" - }, - "properties": [ - { - "id": "unit", - "value": "percent" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "chg_total" - }, - "properties": [ - { - "id": "unit", - "value": "kwatth" - } - ] - } - ] - }, - "gridPos": { - "h": 13, - "w": 14, - "x": 5, - "y": 10 - }, - "id": 24, - "maxDataPoints": 1, - "options": { - "basemap": { - "config": {}, - "name": "Layer 0", - "tooltip": true, - "type": "osm-standard" - }, - "controls": { - "mouseWheelZoom": true, - "showAttribution": true, - "showDebug": false, - "showMeasure": false, - "showScale": false, - "showZoom": true - }, - "layers": [ - { - "config": { - "showLegend": false, - "style": { - "color": { - "field": "pct", - "fixed": "red" - }, - "opacity": 0.4, - "rotation": { - "fixed": 0, - "max": 360, - "min": -360, - "mode": "mod" - }, - "size": { - "field": "chg_total", - "fixed": 5, - "max": 30, - "min": 5 - }, - "symbol": { - "fixed": "img/icons/marker/circle.svg", - "mode": "fixed" - }, - "symbolAlign": { - "horizontal": "center", - "vertical": "center" - }, - "text": { - "field": "chg_total", - "fixed": "", - "mode": "field" - }, - "textConfig": { - "fontSize": 12, - "offsetX": 15, - "offsetY": 0, - "textAlign": "left", - "textBaseline": "middle" - } - } - }, - "location": { - "mode": "auto" - }, - "name": "Charge location", - "tooltip": true, - "type": "markers" - } - ], - "tooltip": { - "mode": "details" - }, - "view": { - "allLayers": true, - "id": "fit", - "lat": 0, - "lon": 0, - "zoom": 15 - } - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "WITH charge_data AS (\r\nSELECT COALESCE(geofence.name, CONCAT_WS(', ', COALESCE(address.name, nullif(CONCAT_WS(' ', address.road, address.house_number), '')), address.city)) AS loc_nm\r\n, AVG(position.latitude) AS latitude\r\n, AVG(position.longitude) AS longitude\r\n, sum(charge.charge_energy_added) AS chg_total\r\n, count(*) as charges\r\nFROM charging_processes charge\r\nLEFT JOIN addresses address ON charge.address_id = address.id\r\nLEFT JOIN positions position ON charge.position_id = position.id\r\nLEFT JOIN geofences geofence ON charge.geofence_id = geofence.id\r\nWHERE $__timeFilter(charge.end_date)\r\nAND charge.duration_min >= $min_duration\r\nAND charge.car_id = $car_id\r\nGROUP BY COALESCE(geofence.name, CONCAT_WS(', ', COALESCE(address.name, nullif(CONCAT_WS(' ', address.road, address.house_number), '')), address.city))\r\n) \r\nSELECT loc_nm\r\n\t,latitude\r\n\t,longitude\r\n\t,chg_total\r\n\t,chg_total * 1.0 / (SELECT sum(chg_total) FROM charge_data) * 100 AS pct\r\n\t,charges\r\nFROM charge_data", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Charging heat map by kWh", - "type": "geomap" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - } - }, - "decimals": 1, - "mappings": [], - "unit": "dtdurations" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "AC" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#73BF69", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "DC" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "light-orange", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 13, - "w": 5, - "x": 19, - "y": 10 - }, - "id": 20, - "maxDataPoints": 3, - "options": { - "displayLabels": [ - "name" - ], - "legend": { - "calcs": [], - "displayMode": "table", - "placement": "bottom", - "showLegend": true, - "values": [ - "value", - "percent" - ] - }, - "pieType": "pie", - "reduceOptions": { - "calcs": [ - "sum" - ], - "fields": "", - "values": false - }, - "tooltip": { - "hideZeros": false, - "maxHeight": 600, - "mode": "single", - "sort": "none" - } - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "time_series", - "rawQuery": true, - "rawSql": "WITH data AS (\n SELECT\n\t\tcp.id,\n\t\tcp.duration_min,\n\t\tCASE WHEN NULLIF(mode() within group (order by charger_phases),0) is null THEN 'DC'\n\t\t\t\t ELSE 'AC'\n\t\tEND AS current\n\tFROM charging_processes cp\n RIGHT JOIN charges ON cp.id = charges.charging_process_id\n WHERE\n\t cp.car_id = $car_id\n\t AND cp.duration_min >= $min_duration\n\t AND $__timeFilter(end_date)\n GROUP BY 1,2\n)\nSELECT\n\tnow() AS time,\n\tsum(duration_min) * 60 AS value,\n\tcurrent AS metric\nFROM data\nGROUP BY 3\nORDER BY metric DESC;", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "AC/DC - Duration", - "type": "piechart" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "continuous-RdYlGr", - "seriesBy": "last" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "fillOpacity": 50, - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "pointShape": "circle", - "pointSize": { - "fixed": 3 - }, - "pointStrokeWidth": 1, - "scaleDistribution": { - "type": "linear" - }, - "show": "points" - }, - "fieldMinMax": false, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": 0 - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [ - { - "matcher": { - "id": "byFrameRefID", - "options": "A" - }, - "properties": [ - { - "id": "links", - "value": [ - { - "title": "Show charge details", - "url": "/d/BHhxFeZRz/charge-details?from=${__data.fields.start_date.numeric}&to=${__data.fields.end_date.numeric}&var-car_id=${car_id}&var-charging_process_id=${__data.fields.charging_process_id.numeric}" - } - ] - } - ] - }, - { - "matcher": { - "id": "byFrameRefID", - "options": "B" - }, - "properties": [ - { - "id": "custom.pointSize.fixed", - "value": 15 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Power" - }, - "properties": [ - { - "id": "unit", - "value": "kwatt" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "SoC" - }, - "properties": [ - { - "id": "unit", - "value": "percent" - } - ] - } - ] - }, - "gridPos": { - "h": 16, - "w": 24, - "x": 0, - "y": 23 - }, - "id": 29, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": false - }, - "mapping": "manual", - "series": [ - { - "color": { - "matcher": { - "id": "byName", - "options": "Power" - } - }, - "frame": { - "matcher": { - "id": "byIndex", - "options": 0 - } - }, - "x": { - "matcher": { - "id": "byName", - "options": "SoC" - } - }, - "y": { - "matcher": { - "id": "byName", - "options": "Power" - } - } - }, - { - "color": { - "matcher": { - "id": "byName", - "options": "Power" - } - }, - "frame": { - "matcher": { - "id": "byIndex", - "options": 1 - } - }, - "x": { - "matcher": { - "id": "byName", - "options": "SoC" - } - }, - "y": { - "matcher": { - "id": "byName", - "options": "Power" - } - } - } - ], - "tooltip": { - "hideZeros": false, - "maxHeight": 600, - "mode": "single", - "sort": "none" - } - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "SELECT\r\n c.battery_level as \"SoC\",\r\n round(avg(c.charger_power), 0) as \"Power\",\r\n c.charging_process_id as \"charging_process_id\",\r\n p.start_date as \"start_date\",\r\n p.end_date as \"end_date\",\r\n COALESCE(g.name, a.name) || ' ' || to_char(timezone('$__timezone', timezone('UTC', c.date)), 'YYYY-MM-dd') as \"Charge\"\r\nFROM\r\n charges c\r\nJOIN charging_processes p ON p.id = c.charging_process_id \r\nJOIN addresses a ON a.id = p.address_id\r\nLEFT JOIN geofences g ON g.id = p.geofence_id\r\nWHERE\r\n $__timeFilter(date)\r\n AND p.car_id = $car_id\r\n AND charger_power > 0\r\n AND c.fast_charger_present\r\nGROUP BY c.battery_level, c.charging_process_id, a.name, g.name, p,start_date, p.end_date, to_char(timezone('$__timezone', timezone('UTC', c.date)), 'YYYY-MM-dd')", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "SELECT\n c.battery_level as \"SoC\",\n PERCENTILE_CONT(0.5) WITHIN GROUP(ORDER BY charger_power) as \"Power\"\nFROM\n charges c\njoin\n charging_processes p ON p.id = c.charging_process_id \nWHERE\n $__timeFilter(date)\n AND p.car_id = $car_id\n AND charger_power > 0\n AND c.fast_charger_present\nGROUP BY battery_level", - "refId": "B", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "DC Charging Curve", - "type": "xychart" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "description": "Only End Battery Level of last Charging Process considered for consecutive Charging Processes", - "fieldConfig": { - "defaults": { - "custom": { - "align": "auto", - "cellOptions": { - "type": "auto" - }, - "inspect": false, - "minWidth": 50 - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": 0 - } - ] - } - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "soc" - }, - "properties": [ - { - "id": "custom.width", - "value": 50 - }, - { - "id": "displayName", - "value": "SOC" - }, - { - "id": "custom.cellOptions", - "value": { - "type": "color-text" - } - }, - { - "id": "unit", - "value": "percent" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "n" - }, - "properties": [ - { - "id": "displayName", - "value": "# of Charges" - }, - { - "id": "custom.cellOptions", - "value": { - "mode": "gradient", - "type": "gauge", - "valueDisplayMode": "text" - } - }, - { - "id": "max" - }, - { - "id": "min", - "value": 0 - }, - { - "id": "custom.align", - "value": "left" - } - ] - } - ] - }, - "gridPos": { - "h": 18, - "w": 3, - "x": 0, - "y": 39 - }, - "id": 2, - "options": { - "cellHeight": "sm", - "footer": { - "countRows": false, - "fields": "", - "reducer": [ - "sum" - ], - "show": false - }, - "showHeader": true, - "sortBy": [] - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "with data as (\n\n select id, end_battery_level, end_date, 'Charging Process' as activity\n from charging_processes cp\n where car_id = $car_id and $__timeFilter(cp.end_date) and duration_min >= $min_duration\n \n union all\n \n select d.id, p.battery_level as end_battery_level, end_date, 'Drive' as activity\n from drives d\n inner join positions p on d.end_position_id = p.id\n where d.car_id = $car_id and $__timeFilter(d.end_date)\n\n),\n\nflag_consecutive_charges as (\n\n select *, lead(activity) over (order by end_date) as next_activity from data\n\n)\n\nSELECT\n ROUND(end_battery_level / 5, 0) * 5 AS SOC,\n count(*) AS n\nFROM\n flag_consecutive_charges\nwhere\n activity = 'Charging Process' and (next_activity != 'Charging Process' or next_activity is null)\nGROUP BY\n ROUND(end_battery_level / 5, 0) * 5\nORDER BY\n SOC DESC", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "SELECT\r\n CASE WHEN lfp_battery THEN 100 ELSE 81 END as high,\r\n CASE WHEN lfp_battery THEN 100 ELSE 91 END as highest\r\nfrom cars inner join car_settings on cars.settings_id = car_settings.id\r\nwhere cars.id = $car_id", - "refId": "B", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Charge Stats", - "transformations": [ - { - "id": "configFromData", - "options": { - "applyTo": { - "id": "byName", - "options": "soc" - }, - "configRefId": "B", - "mappings": [ - { - "fieldName": "high", - "handlerArguments": { - "threshold": { - "color": "yellow" - } - }, - "handlerKey": "threshold1" - }, - { - "fieldName": "highest", - "handlerKey": "threshold1" - } - ] - } - } - ], - "type": "table" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "description": "Only Start Battery Level of first Charging Process considered for consecutive Charging Processes", - "fieldConfig": { - "defaults": { - "custom": { - "align": "auto", - "cellOptions": { - "type": "auto" - }, - "inspect": false, - "minWidth": 50 - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": 0 - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "soc" - }, - "properties": [ - { - "id": "displayName", - "value": "SOC" - }, - { - "id": "unit", - "value": "percent" - }, - { - "id": "thresholds", - "value": { - "mode": "absolute", - "steps": [ - { - "color": "red", - "value": 0 - }, - { - "color": "#EAB839", - "value": 10 - }, - { - "color": "green", - "value": 20 - } - ] - } - }, - { - "id": "custom.cellOptions", - "value": { - "type": "color-text" - } - }, - { - "id": "custom.width", - "value": 50 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "n" - }, - "properties": [ - { - "id": "displayName", - "value": "# of Discharges" - }, - { - "id": "custom.cellOptions", - "value": { - "mode": "gradient", - "type": "gauge" - } - }, - { - "id": "min", - "value": 0 - }, - { - "id": "custom.align", - "value": "left" - } - ] - } - ] - }, - "gridPos": { - "h": 18, - "w": 3, - "x": 3, - "y": 39 - }, - "id": 13, - "options": { - "cellHeight": "sm", - "footer": { - "countRows": false, - "fields": "", - "reducer": [ - "sum" - ], - "show": false - }, - "showHeader": true, - "sortBy": [] - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "with data as (\n\n select id, start_battery_level, end_date, 'Charging Process' as activity\n from charging_processes cp\n where car_id = $car_id and $__timeFilter(cp.end_date) and duration_min >= $min_duration\n \n union all\n \n select d.id, p.battery_level as start_battery_level, end_date, 'Drive' as activity\n from drives d\n inner join positions p on d.start_position_id = p.id\n where d.car_id = $car_id and $__timeFilter(d.end_date)\n\n),\n\nflag_consecutive_charges as (\n\n select *, lag(activity) over (order by end_date) as previous_activity from data\n\n)\n\nSELECT\n ROUND(start_battery_level / 5, 0) * 5 AS SOC,\n count(*) AS n\nFROM\n flag_consecutive_charges\nwhere\n activity = 'Charging Process' and (previous_activity != 'Charging Process' or previous_activity is null)\nGROUP BY\n ROUND(start_battery_level / 5, 0) * 5\nORDER BY\n SOC DESC", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Discharge Stats", - "type": "table" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "custom": { - "align": "auto", - "cellOptions": { - "type": "auto" - }, - "inspect": false - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": 0 - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "location" - }, - "properties": [ - { - "id": "displayName", - "value": "Location" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "charge_energy_added" - }, - "properties": [ - { - "id": "displayName", - "value": "Charged" - }, - { - "id": "custom.width", - "value": 120 - }, - { - "id": "custom.align", - "value": "left" - }, - { - "id": "unit", - "value": "kwatth" - }, - { - "id": "decimals", - "value": 2 - } - ] - } - ] - }, - "gridPos": { - "h": 18, - "w": 9, - "x": 6, - "y": 39 - }, - "id": 4, - "options": { - "cellHeight": "sm", - "footer": { - "countRows": false, - "fields": "", - "reducer": [ - "sum" - ], - "show": false - }, - "showHeader": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "SELECT\n\tCOALESCE(geofence.name, CONCAT_WS(', ', COALESCE(address.name, nullif(CONCAT_WS(' ', address.road, address.house_number), '')), address.city)) AS location,\n sum(charge_energy_added) as charge_energy_added\nFROM\n\tcharging_processes c\nLEFT JOIN addresses address ON c.address_id = address.id\nLEFT JOIN geofences geofence ON geofence_id = geofence.id\nWHERE\n\t$__timeFilter(end_date)\n\tAND duration_min >= $min_duration\n\tAND car_id = $car_id\nGROUP BY\n\t1\nORDER BY\n\tSUM(charge_energy_added) DESC\nLIMIT 17;", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Top Charging Stations (Charged)", - "type": "table" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "custom": { - "align": "auto", - "cellOptions": { - "type": "auto" - }, - "inspect": false - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": 0 - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "location" - }, - "properties": [ - { - "id": "displayName", - "value": "Location" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "cost" - }, - "properties": [ - { - "id": "displayName", - "value": "Cost" - }, - { - "id": "custom.width", - "value": 120 - }, - { - "id": "decimals", - "value": 2 - }, - { - "id": "custom.align", - "value": "left" - } - ] - } - ] - }, - "gridPos": { - "h": 18, - "w": 9, - "x": 15, - "y": 39 - }, - "id": 6, - "options": { - "cellHeight": "sm", - "footer": { - "countRows": false, - "fields": "", - "reducer": [ - "sum" - ], - "show": false - }, - "showHeader": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "SELECT\n\tCOALESCE(geofence.name, CONCAT_WS(', ', COALESCE(address.name, CONCAT_WS(' ', address.road, address.house_number)), address.city)) AS location,\n\tsum(cost) as cost\nFROM\n\tcharging_processes c\n\tLEFT JOIN addresses address ON c.address_id = address.id\n\tLEFT JOIN geofences geofence ON geofence_id = geofence.id\nWHERE\n $__timeFilter(end_date) AND\n\tduration_min >= $min_duration AND\n\tcar_id = $car_id AND\n\tCOST IS NOT NULL\nGROUP BY\n\t1\nORDER BY\n\t2 DESC NULLS LAST\nLIMIT 17;", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Top Charging Stations (Cost)", - "type": "table" - } - ], - "preload": false, - "refresh": "", - "schemaVersion": 41, - "tags": [ - "tesla" - ], - "templating": { - "list": [ - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "SELECT\n id as __value,\n CASE WHEN COUNT(id) OVER (PARTITION BY name) > 1 AND name IS NOT NULL THEN CONCAT(name, ' - ', RIGHT(vin, 6)) ELSE COALESCE(name, CONCAT('VIN ', vin)) end as __text \nFROM cars\nORDER BY display_priority ASC, name ASC, vin ASC;", - "hide": 2, - "includeAll": true, - "label": "Car", - "name": "car_id", - "options": [], - "query": "SELECT\n id as __value,\n CASE WHEN COUNT(id) OVER (PARTITION BY name) > 1 AND name IS NOT NULL THEN CONCAT(name, ' - ', RIGHT(vin, 6)) ELSE COALESCE(name, CONCAT('VIN ', vin)) end as __text \nFROM cars\nORDER BY display_priority ASC, name ASC, vin ASC;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "select base_url from settings limit 1;", - "hide": 2, - "includeAll": false, - "name": "base_url", - "options": [], - "query": "select base_url from settings limit 1;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "select unit_of_length from settings limit 1;", - "hide": 2, - "includeAll": false, - "name": "length_unit", - "options": [], - "query": "select unit_of_length from settings limit 1;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": {}, - "label": "Duration >=", - "name": "min_duration", - "options": [ - { - "selected": true, - "text": "0", - "value": "0" - } - ], - "query": "0", - "type": "textbox" - }, - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "select preferred_range from settings limit 1;", - "hide": 2, - "name": "preferred_range", - "options": [], - "query": "select preferred_range from settings limit 1;", - "refresh": 1, - "regex": "", - "type": "query" - } - ] - }, - "time": { - "from": "now-10y", - "to": "now" - }, - "timepicker": {}, - "timezone": "", - "title": "Tesla Charging Stats", - "uid": "-pkIkhmRz", - "version": 1 - } diff --git a/argocd/manifests/grafana-config/dashboards/configmap-teslamate-drive-stats.yaml b/argocd/manifests/grafana-config/dashboards/configmap-teslamate-drive-stats.yaml deleted file mode 100644 index de7665d..0000000 --- a/argocd/manifests/grafana-config/dashboards/configmap-teslamate-drive-stats.yaml +++ /dev/null @@ -1,1603 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: grafana-dashboard-teslamate-drive-stats - namespace: monitoring - labels: - grafana_dashboard: "1" - annotations: - grafana_folder: "TeslaMate" -data: - teslamate-drive-stats.json: | - { - "annotations": { - "list": [ - { - "builtIn": 1, - "datasource": { - "type": "grafana", - "uid": "-- Grafana --" - }, - "enable": true, - "hide": true, - "iconColor": "rgba(0, 211, 255, 1)", - "name": "Annotations & Alerts", - "type": "dashboard" - } - ] - }, - "editable": true, - "fiscalYearStartMonth": 0, - "graphTooltip": 0, - "links": [ - { - "icon": "dashboard", - "tags": [], - "title": "TeslaMate", - "tooltip": "", - "type": "link", - "url": "${base_url:raw}" - }, - { - "asDropdown": true, - "icon": "external link", - "tags": [ - "tesla" - ], - "title": "Dashboards", - "type": "dashboards" - } - ], - "panels": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "semi-dark-blue", - "value": 0 - } - ] - }, - "unit": "none" - }, - "overrides": [] - }, - "gridPos": { - "h": 4, - "w": 8, - "x": 0, - "y": 0 - }, - "id": 20, - "maxDataPoints": 100, - "options": { - "colorMode": "value", - "fieldOptions": { - "calcs": [ - "sum" - ] - }, - "graphMode": "area", - "justifyMode": "auto", - "orientation": "horizontal", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "sum" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "value", - "wideLayout": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "time_series", - "rawQuery": true, - "rawSql": "WITH since as (\n\tSELECT timezone('UTC', min(start_date)) as date FROM drives\n\tWHERE car_id = $car_id\n\tGROUP BY car_id\n),\n\nactual AS (\n\tSELECT\n\t\tdate_trunc('day', timezone('UTC', start_date), '$__timezone') AS date,\n\t\tcount(*) AS number_of_drives\n\tFROM drives\n\tWHERE car_id = $car_id and $__timeFilter(start_date) and end_date is not null\n\tGROUP BY 1\n),\n\nbase_line AS (\n\tSELECT date from generate_series(date_trunc('day', (select date from since), '$__timezone'), date_trunc('day', timestamp with time zone $__timeTo(), '$__timezone'), '1 day'::interval, '$__timezone') date\n)\n\nSELECT\n base_line.date as time,\n\tCOALESCE(actual.number_of_drives, 0) as number_of_drives\nFROM base_line\nLEFT JOIN actual ON actual.date = base_line.date\nWHERE date_trunc('day', timestamp with time zone $__timeFrom(), '$__timezone') <= base_line.date\norder by base_line.date\n", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "# of Drives", - "type": "stat" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": 0 - } - ] - }, - "unit": "none" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "distance_km" - }, - "properties": [ - { - "id": "unit", - "value": "km" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "distance_mi" - }, - "properties": [ - { - "id": "unit", - "value": "mi" - } - ] - } - ] - }, - "gridPos": { - "h": 4, - "w": 8, - "x": 8, - "y": 0 - }, - "id": 16, - "maxDataPoints": 100, - "options": { - "colorMode": "value", - "fieldOptions": { - "calcs": [ - "sum" - ] - }, - "graphMode": "area", - "justifyMode": "auto", - "orientation": "horizontal", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "sum" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "value", - "wideLayout": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "time_series", - "rawQuery": true, - "rawSql": "WITH since as (\n\tSELECT timezone('UTC', min(start_date)) as date FROM drives\n\tWHERE car_id = $car_id\n\tGROUP BY car_id\n),\n\nactual AS (\n\tSELECT\n\t\tdate_trunc('day', timezone('UTC', start_date), '$__timezone') AS date,\n\t\tsum(distance) AS distance\n\tFROM drives\n\tWHERE car_id = $car_id and $__timeFilter(start_date) and end_date is not null\n\tGROUP BY 1\n),\n\nbase_line AS (\n\tSELECT date from generate_series(date_trunc('day', (select date from since), '$__timezone'), date_trunc('day', timestamp with time zone $__timeTo(), '$__timezone'), '1 day'::interval, '$__timezone') date)\n\nSELECT\n base_line.date as time,\n\tconvert_km(COALESCE(actual.distance, 0)::numeric, '$length_unit') as \"distance_$length_unit\"\nFROM base_line\nLEFT JOIN actual ON actual.date = base_line.date\nWHERE date_trunc('day', timestamp with time zone $__timeFrom(), '$__timezone') <= base_line.date\norder by base_line.date\n", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Total Distance logged", - "type": "stat" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "semi-dark-yellow", - "value": 0 - } - ] - }, - "unit": "kwatth" - }, - "overrides": [] - }, - "gridPos": { - "h": 4, - "w": 8, - "x": 16, - "y": 0 - }, - "id": 22, - "maxDataPoints": 100, - "options": { - "colorMode": "value", - "fieldOptions": { - "calcs": [ - "sum" - ] - }, - "graphMode": "area", - "justifyMode": "auto", - "orientation": "horizontal", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "sum" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "value", - "wideLayout": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "time_series", - "rawQuery": true, - "rawSql": "WITH since as (\n\tSELECT timezone('UTC', min(start_date)) as date FROM drives\n\tWHERE car_id = $car_id\n\tGROUP BY car_id\n),\n\nactual AS (\n\tSELECT\n\t\tdate_trunc('day', timezone('UTC', start_date), '$__timezone') AS date,\n\t\tsum((start_${preferred_range}_range_km - end_${preferred_range}_range_km) * cars.efficiency) AS energy\n\tFROM drives\n INNER JOIN cars on drives.car_id = cars.id\n\tWHERE car_id = $car_id and $__timeFilter(start_date) and end_date is not null\n\tGROUP BY 1\n),\n\nbase_line AS (\n\tSELECT date from generate_series(date_trunc('day', (select date from since), '$__timezone'), date_trunc('day', timestamp with time zone $__timeTo(), '$__timezone'), '1 day'::interval, '$__timezone') date)\n\nSELECT\n base_line.date as time,\n\tcoalesce(energy, 0) as energy\nFROM base_line\nLEFT JOIN actual ON actual.date = base_line.date\nWHERE date_trunc('day', timestamp with time zone $__timeFrom(), '$__timezone') <= base_line.date\norder by base_line.date\n", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Total Energy consumed (net)", - "type": "stat" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "mappings": [ - { - "options": { - "match": "null", - "result": { - "text": "N/A" - } - }, - "type": "special" - } - ], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "#c7d0d9", - "value": 0 - } - ] - }, - "unit": "none" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "distance_km" - }, - "properties": [ - { - "id": "unit", - "value": "lengthkm" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "distance_mi" - }, - "properties": [ - { - "id": "unit", - "value": "mi" - } - ] - } - ] - }, - "gridPos": { - "h": 3, - "w": 8, - "x": 0, - "y": 4 - }, - "id": 26, - "maxDataPoints": 100, - "options": { - "colorMode": "value", - "fieldOptions": { - "calcs": [ - "mean" - ] - }, - "graphMode": "none", - "justifyMode": "auto", - "orientation": "horizontal", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "value", - "wideLayout": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "SELECT convert_km((percentile_cont(0.5) WITHIN GROUP (ORDER BY distance))::numeric, '$length_unit') as \"distance_$length_unit\"\nFROM drives\nWHERE car_id = $car_id AND $__timeFilter(start_date) AND end_date IS NOT NULL;", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Median distance of a drive", - "type": "stat" - }, - { - "datasource": { - "default": false, - "type": "datasource", - "uid": "-- Dashboard --" - }, - "fieldConfig": { - "defaults": { - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "#c7d0d9", - "value": 0 - } - ] - }, - "unit": "none" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "distance_km" - }, - "properties": [ - { - "id": "unit", - "value": "lengthkm" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "distance_mi" - }, - "properties": [ - { - "id": "unit", - "value": "mi" - } - ] - } - ] - }, - "gridPos": { - "h": 3, - "w": 8, - "x": 8, - "y": 4 - }, - "id": 8, - "maxDataPoints": 100, - "options": { - "colorMode": "value", - "fieldOptions": { - "calcs": [ - "mean" - ] - }, - "graphMode": "none", - "justifyMode": "auto", - "orientation": "horizontal", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "mean" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "value", - "wideLayout": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "datasource", - "uid": "-- Dashboard --" - }, - "panelId": 16, - "refId": "A" - } - ], - "title": "Ø Distance driven per day", - "type": "stat" - }, - { - "datasource": { - "default": false, - "type": "datasource", - "uid": "-- Dashboard --" - }, - "fieldConfig": { - "defaults": { - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "#c7d0d9", - "value": 0 - } - ] - }, - "unit": "kwatth" - }, - "overrides": [] - }, - "gridPos": { - "h": 3, - "w": 8, - "x": 16, - "y": 4 - }, - "id": 14, - "maxDataPoints": 100, - "options": { - "colorMode": "value", - "fieldOptions": { - "calcs": [ - "lastNotNull" - ] - }, - "graphMode": "none", - "justifyMode": "auto", - "orientation": "horizontal", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "mean" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "value", - "wideLayout": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "datasource", - "uid": "-- Dashboard --" - }, - "panelId": 22, - "refId": "A" - } - ], - "title": "Ø Energy consumed (net) per day", - "type": "stat" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "mappings": [ - { - "options": { - "match": "null", - "result": { - "text": "N/A" - } - }, - "type": "special" - } - ], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "#c7d0d9", - "value": 0 - } - ] - }, - "unit": "none" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "speed_kmh" - }, - "properties": [ - { - "id": "unit", - "value": "velocitykmh" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "speed_mih" - }, - "properties": [ - { - "id": "unit", - "value": "velocitymph" - } - ] - } - ] - }, - "gridPos": { - "h": 3, - "w": 8, - "x": 0, - "y": 7 - }, - "id": 33, - "maxDataPoints": 100, - "options": { - "colorMode": "value", - "fieldOptions": { - "calcs": [ - "mean" - ] - }, - "graphMode": "none", - "justifyMode": "auto", - "orientation": "horizontal", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "last" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "value", - "wideLayout": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "SELECT convert_km(max(speed_max), '$length_unit') AS speed_${length_unit}h\nFROM drives\nWHERE car_id = $car_id and $__timeFilter(start_date) and end_date is not null;", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Max Speed", - "type": "stat" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "mappings": [ - { - "options": { - "match": "null", - "result": { - "text": "N/A" - } - }, - "type": "special" - } - ], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "#c7d0d9", - "value": 0 - } - ] - }, - "unit": "none" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "speed_kmh" - }, - "properties": [ - { - "id": "unit", - "value": "velocitykmh" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "speed_mih" - }, - "properties": [ - { - "id": "unit", - "value": "velocitymph" - } - ] - } - ] - }, - "gridPos": { - "h": 3, - "w": 8, - "x": 8, - "y": 7 - }, - "id": 35, - "maxDataPoints": 100, - "options": { - "colorMode": "value", - "fieldOptions": { - "calcs": [ - "mean" - ] - }, - "graphMode": "none", - "justifyMode": "auto", - "orientation": "horizontal", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "last" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "value", - "wideLayout": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "SELECT convert_km(max(speed_max), '$length_unit') AS speed_${length_unit}h\nFROM drives\nWHERE car_id = $car_id and $__timeFilter(start_date) and end_date is not null;", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "timeFrom": "30d", - "title": "Max Speed", - "type": "stat" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "mappings": [ - { - "options": { - "match": "null", - "result": { - "text": "N/A" - } - }, - "type": "special" - } - ], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "#c7d0d9", - "value": 0 - } - ] - }, - "unit": "none" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "speed_kmh" - }, - "properties": [ - { - "id": "unit", - "value": "velocitykmh" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "speed_mih" - }, - "properties": [ - { - "id": "unit", - "value": "velocitymph" - } - ] - } - ] - }, - "gridPos": { - "h": 3, - "w": 8, - "x": 16, - "y": 7 - }, - "id": 34, - "maxDataPoints": 100, - "options": { - "colorMode": "value", - "fieldOptions": { - "calcs": [ - "mean" - ] - }, - "graphMode": "none", - "justifyMode": "auto", - "orientation": "horizontal", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "last" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "value", - "wideLayout": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "SELECT convert_km(max(speed_max), '$length_unit') AS speed_${length_unit}h\nFROM drives\nWHERE car_id = $car_id and $__timeFilter(start_date) and end_date is not null;", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "timeFrom": "7d", - "title": "Max Speed", - "type": "stat" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "continuous-GrYlRd" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisGridShow": false, - "axisLabel": "", - "axisPlacement": "auto", - "axisWidth": -10, - "fillOpacity": 90, - "gradientMode": "hue", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineWidth": 1, - "scaleDistribution": { - "type": "linear" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "fieldMinMax": true, - "mappings": [], - "noValue": "0", - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "super-light-green", - "value": 0 - } - ] - } - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Elapsed" - }, - "properties": [ - { - "id": "unit", - "value": "percent" - }, - { - "id": "decimals", - "value": 1 - } - ] - } - ] - }, - "gridPos": { - "h": 7, - "w": 24, - "x": 0, - "y": 10 - }, - "id": 36, - "options": { - "barRadius": 0.05, - "barWidth": 0.97, - "colorByField": "Elapsed", - "fullHighlight": false, - "groupWidth": 0.7, - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": false - }, - "orientation": "auto", - "showValue": "auto", - "stacking": "none", - "text": {}, - "tooltip": { - "hideZeros": false, - "maxHeight": 600, - "mode": "multi", - "sort": "none" - }, - "xField": "Speed", - "xTickLabelRotation": 0, - "xTickLabelSpacing": 0 - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "WITH drivedata AS (\r\n SELECT\r\n ROUND(convert_km(p.speed::numeric, '$length_unit') / 10, 0) * 10 AS speed_section_${length_unit},\r\n EXTRACT(EPOCH FROM (LEAD(p.\"date\") OVER (PARTITION BY p.drive_id ORDER BY p.\"date\") - p.\"date\")) AS seconds_elapsed\r\n FROM positions p\r\n WHERE p.car_id = $car_id AND $__timeFilter(p.date) AND p.ideal_battery_range_km IS NOT NULL\r\n)\r\n\r\nSELECT \r\n speed_section_${length_unit} AS \"Speed\",\r\n SUM(seconds_elapsed) * 100 / SUM(SUM(seconds_elapsed)) OVER () AS \"Elapsed\", \r\n TO_CHAR((SUM(seconds_elapsed) || ' second')::interval, 'HH24:MI:SS') AS \"Time\"\r\nFROM drivedata\r\nWHERE speed_section_${length_unit} > 0\r\nGROUP BY speed_section_${length_unit}\r\nORDER BY speed_section_${length_unit};\r\n", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Speed Histogram ($speed_unit)", - "transformations": [ - { - "id": "filterFieldsByName", - "options": { - "include": { - "names": [ - "Elapsed", - "Time", - "Speed", - "SpeedUnit" - ] - } - } - } - ], - "type": "barchart" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "#c7d0d9", - "value": 0 - } - ] - }, - "unit": "none" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "mileage_km" - }, - "properties": [ - { - "id": "unit", - "value": "km" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "mileage_mi" - }, - "properties": [ - { - "id": "unit", - "value": "mi" - } - ] - } - ] - }, - "gridPos": { - "h": 3, - "w": 12, - "x": 0, - "y": 17 - }, - "id": 32, - "maxDataPoints": 100, - "options": { - "colorMode": "value", - "fieldOptions": { - "calcs": [ - "lastNotNull" - ] - }, - "graphMode": "none", - "justifyMode": "auto", - "orientation": "horizontal", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "mean" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "value", - "wideLayout": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "WITH since as (\r\n\tSELECT timezone('UTC', min(start_date)) as date FROM drives\r\n\tWHERE car_id = $car_id\r\n\tGROUP BY car_id\r\n)\r\n\r\nselect\r\n convert_km(((max(end_km) - min(start_km)) / greatest(extract(days from (timestamp with time zone $__timeTo() - greatest(timestamp with time zone $__timeFrom(), (select date from since)))), 1) * (365/12))::numeric, '$length_unit') as \"mileage_$length_unit\"\r\nfrom drives\r\nwhere car_id = $car_id and $__timeFilter(start_date) and end_date is not null\r\ngroup by car_id", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Extrapolated monthly mileage", - "type": "stat" - }, - { - "datasource": { - "default": false, - "type": "datasource", - "uid": "-- Dashboard --" - }, - "fieldConfig": { - "defaults": { - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "#c7d0d9", - "value": 0 - } - ] - }, - "unit": "none" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "yearly_mileage_km" - }, - "properties": [ - { - "id": "unit", - "value": "km" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "yearly_mileage_mi" - }, - "properties": [ - { - "id": "unit", - "value": "mi" - } - ] - } - ] - }, - "gridPos": { - "h": 3, - "w": 12, - "x": 12, - "y": 17 - }, - "id": 30, - "maxDataPoints": 100, - "options": { - "colorMode": "value", - "fieldOptions": { - "calcs": [ - "lastNotNull" - ] - }, - "graphMode": "none", - "justifyMode": "auto", - "orientation": "horizontal", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "mean" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "value", - "wideLayout": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "datasource", - "uid": "-- Dashboard --" - }, - "panelId": 32, - "refId": "A" - } - ], - "title": "Extrapolated annual mileage", - "transformations": [ - { - "id": "calculateField", - "options": { - "alias": "yearly_mileage_km", - "binary": { - "left": { - "matcher": { - "id": "byName", - "options": "mileage_km" - } - }, - "operator": "*", - "right": { - "fixed": "12" - } - }, - "mode": "binary", - "reduce": { - "reducer": "sum" - }, - "replaceFields": false - } - }, - { - "id": "calculateField", - "options": { - "alias": "yearly_mileage_mi", - "binary": { - "left": { - "matcher": { - "id": "byName", - "options": "mileage_mi" - } - }, - "operator": "*", - "right": { - "fixed": "12" - } - }, - "mode": "binary", - "reduce": { - "reducer": "sum" - }, - "replaceFields": false - } - }, - { - "id": "filterFieldsByName", - "options": { - "include": { - "pattern": "yearly.*" - } - } - } - ], - "type": "stat" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "displayName": "$__cell_0", - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "semi-dark-blue", - "value": 0 - }, - { - "color": "light-red", - "value": 50 - } - ] - }, - "unit": "none" - }, - "overrides": [] - }, - "gridPos": { - "h": 11, - "w": 24, - "x": 0, - "y": 20 - }, - "id": 24, - "options": { - "displayMode": "gradient", - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": false - }, - "maxVizHeight": 300, - "minVizHeight": 16, - "minVizWidth": 8, - "namePlacement": "auto", - "orientation": "horizontal", - "reduceOptions": { - "calcs": [ - "mean" - ], - "fields": "", - "values": true - }, - "showUnfilled": true, - "sizing": "auto", - "valueMode": "color" - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "SELECT * FROM (\nSELECT\n\tCOALESCE(g.name, COALESCE(a.name, nullif(CONCAT_WS(' ', a.road, a.house_number), ''))) as name,\n\tcount(*) AS visited\nFROM drives t\nINNER JOIN addresses a ON end_address_id = a.id\nLEFT JOIN geofences g ON end_geofence_id = g.id\nWHERE t.car_id = $car_id AND $__timeFilter(t.start_date) and $__timeFilter(t.end_date) \nGROUP BY 1\nORDER BY visited DESC) AS destinations\nWHERE name NOT ILIKE ALL ${exclude_formatted_string:raw}\nLIMIT 10;", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Top 10 Destinations (in this period)", - "type": "bargauge" - } - ], - "preload": false, - "refresh": false, - "schemaVersion": 41, - "tags": [ - "tesla" - ], - "templating": { - "list": [ - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "SELECT\n id as __value,\n CASE WHEN COUNT(id) OVER (PARTITION BY name) > 1 AND name IS NOT NULL THEN CONCAT(name, ' - ', RIGHT(vin, 6)) ELSE COALESCE(name, CONCAT('VIN ', vin)) end as __text \nFROM cars\nORDER BY display_priority ASC, name ASC, vin ASC;", - "includeAll": false, - "label": "Car", - "name": "car_id", - "options": [], - "query": "SELECT\n id as __value,\n CASE WHEN COUNT(id) OVER (PARTITION BY name) > 1 AND name IS NOT NULL THEN CONCAT(name, ' - ', RIGHT(vin, 6)) ELSE COALESCE(name, CONCAT('VIN ', vin)) end as __text \nFROM cars\nORDER BY display_priority ASC, name ASC, vin ASC;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "select unit_of_length from settings limit 1;", - "hide": 2, - "includeAll": false, - "name": "length_unit", - "options": [], - "query": "select unit_of_length from settings limit 1;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "SELECT CASE WHEN '$length_unit' = 'km' THEN 'km/h' WHEN '$length_unit' = 'mi' THEN 'mph' END", - "hide": 2, - "includeAll": false, - "name": "speed_unit", - "options": [], - "query": "SELECT CASE WHEN '$length_unit' = 'km' THEN 'km/h' WHEN '$length_unit' = 'mi' THEN 'mph' END", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "select preferred_range from settings limit 1;", - "hide": 2, - "includeAll": false, - "name": "preferred_range", - "options": [], - "query": "select preferred_range from settings limit 1;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "select base_url from settings limit 1;", - "hide": 2, - "includeAll": false, - "name": "base_url", - "options": [], - "query": "select base_url from settings limit 1;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": { - "text": "", - "value": "" - }, - "description": "Comma separated list of locations to exclude. Ex: home, work", - "label": "Exclude locations", - "name": "exclude", - "options": [ - { - "selected": true, - "text": "", - "value": "" - } - ], - "query": "", - "type": "textbox" - }, - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "WITH splits AS (\n SELECT unnest(string_to_array('$exclude', ', ')) AS part\n),\nsplit_strings AS (\n\tSELECT part AS part\n\tFROM (VALUES (NULL)) AS v(dummy)\n\tLEFT JOIN splits ON TRUE\n),\nexclude_string AS (\n\tSELECT array_to_string(array_agg(case when part is null then '''''' else '''%' || part || '%''' end), ', ') AS formatted_string\n\tFROM split_strings\n)\nSELECT '(ARRAY[' || formatted_string || '])' FROM exclude_string", - "hide": 2, - "includeAll": false, - "name": "exclude_formatted_string", - "options": [], - "query": "WITH splits AS (\n SELECT unnest(string_to_array('$exclude', ', ')) AS part\n),\nsplit_strings AS (\n\tSELECT part AS part\n\tFROM (VALUES (NULL)) AS v(dummy)\n\tLEFT JOIN splits ON TRUE\n),\nexclude_string AS (\n\tSELECT array_to_string(array_agg(case when part is null then '''''' else '''%' || part || '%''' end), ', ') AS formatted_string\n\tFROM split_strings\n)\nSELECT '(ARRAY[' || formatted_string || '])' FROM exclude_string", - "refresh": 1, - "regex": "", - "type": "query" - } - ] - }, - "time": { - "from": "now-1y", - "to": "now" - }, - "timepicker": {}, - "timezone": "", - "title": "Tesla Drive Stats", - "uid": "_7WkNSyWk", - "version": 1 - } diff --git a/argocd/manifests/grafana-config/dashboards/configmap-teslamate-drives.yaml b/argocd/manifests/grafana-config/dashboards/configmap-teslamate-drives.yaml deleted file mode 100644 index 1a86d57..0000000 --- a/argocd/manifests/grafana-config/dashboards/configmap-teslamate-drives.yaml +++ /dev/null @@ -1,1636 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: grafana-dashboard-teslamate-drives - namespace: monitoring - labels: - grafana_dashboard: "1" - annotations: - grafana_folder: "TeslaMate" -data: - teslamate-drives.json: | - { - "annotations": { - "list": [ - { - "builtIn": 1, - "datasource": { - "type": "grafana", - "uid": "-- Grafana --" - }, - "enable": true, - "hide": true, - "iconColor": "rgba(0, 211, 255, 1)", - "name": "Annotations & Alerts", - "target": { - "limit": 100, - "matchAny": false, - "tags": [], - "type": "dashboard" - }, - "type": "dashboard" - } - ] - }, - "editable": true, - "fiscalYearStartMonth": 0, - "graphTooltip": 0, - "links": [ - { - "icon": "dashboard", - "tags": [], - "title": "TeslaMate", - "tooltip": "", - "type": "link", - "url": "${base_url:raw}" - }, - { - "asDropdown": true, - "icon": "external link", - "tags": [ - "tesla" - ], - "title": "Dashboards", - "type": "dashboards" - } - ], - "panels": [ - { - "collapsed": false, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 0 - }, - "id": 3, - "panels": [], - "title": "Summary of this period", - "type": "row" - }, - { - "datasource": { - "type": "datasource", - "uid": "-- Dashboard --" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "mappings": [ - { - "options": { - "match": "null", - "result": { - "text": "N/A" - } - }, - "type": "special" - } - ], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "text", - "value": 0 - } - ] - }, - "unit": "kwatth" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "consumption_kWh" - }, - "properties": [ - { - "id": "displayName", - "value": "Total Energy consumed (net):" - } - ] - } - ] - }, - "gridPos": { - "h": 2, - "w": 6, - "x": 0, - "y": 1 - }, - "id": 4, - "maxDataPoints": 100, - "options": { - "colorMode": "background", - "graphMode": "none", - "justifyMode": "center", - "orientation": "horizontal", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "sum" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "auto", - "wideLayout": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "datasource", - "uid": "-- Dashboard --" - }, - "panelId": 2, - "refId": "A" - } - ], - "title": "", - "transformations": [ - { - "id": "filterFieldsByName", - "options": { - "include": { - "names": [ - "consumption_kWh" - ] - } - } - } - ], - "transparent": true, - "type": "stat" - }, - { - "datasource": { - "type": "datasource", - "uid": "-- Dashboard --" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "decimals": 0, - "mappings": [ - { - "options": { - "match": "null", - "result": { - "text": "N/A" - } - }, - "type": "special" - } - ], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "text", - "value": 0 - } - ] - }, - "unit": "m" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "duration_min" - }, - "properties": [ - { - "id": "displayName", - "value": "Total Duration:" - } - ] - } - ] - }, - "gridPos": { - "h": 2, - "w": 6, - "x": 6, - "y": 1 - }, - "id": 5, - "maxDataPoints": 100, - "options": { - "colorMode": "background", - "graphMode": "none", - "justifyMode": "center", - "orientation": "horizontal", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "sum" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "auto", - "wideLayout": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "datasource", - "uid": "-- Dashboard --" - }, - "panelId": 2, - "refId": "A" - } - ], - "title": "", - "transformations": [ - { - "id": "filterFieldsByName", - "options": { - "include": { - "names": [ - "duration_min" - ] - } - } - } - ], - "transparent": true, - "type": "stat" - }, - { - "datasource": { - "type": "datasource", - "uid": "-- Dashboard --" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "decimals": 0, - "mappings": [ - { - "options": { - "match": "null", - "result": { - "text": "N/A" - } - }, - "type": "special" - } - ], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "text", - "value": 0 - } - ] - } - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "distance_mi" - }, - "properties": [ - { - "id": "unit", - "value": "mi" - }, - { - "id": "displayName", - "value": "Total Distance logged:" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "distance_km" - }, - "properties": [ - { - "id": "unit", - "value": "km" - }, - { - "id": "displayName", - "value": "Total Distance logged:" - } - ] - } - ] - }, - "gridPos": { - "h": 2, - "w": 6, - "x": 12, - "y": 1 - }, - "id": 6, - "maxDataPoints": 100, - "options": { - "colorMode": "background", - "graphMode": "none", - "justifyMode": "center", - "orientation": "horizontal", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "sum" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "auto", - "wideLayout": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "datasource", - "uid": "-- Dashboard --" - }, - "panelId": 2, - "refId": "A" - } - ], - "title": "", - "transformations": [ - { - "id": "filterFieldsByName", - "options": { - "include": { - "names": [ - "distance_mi", - "distance_km" - ] - } - } - } - ], - "transparent": true, - "type": "stat" - }, - { - "datasource": { - "default": false, - "type": "datasource", - "uid": "-- Dashboard --" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "decimals": 0, - "mappings": [ - { - "options": { - "match": "null", - "result": { - "text": "N/A" - } - }, - "type": "special" - } - ], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "text", - "value": 0 - } - ] - } - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "consumption_kwh_mi" - }, - "properties": [ - { - "id": "unit", - "value": "Wh/mi" - }, - { - "id": "displayName", - "value": "Ø Consumption (net):" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "consumption_kwh_km" - }, - "properties": [ - { - "id": "unit", - "value": "Wh/km" - }, - { - "id": "displayName", - "value": "Ø Consumption (net):" - } - ] - } - ] - }, - "gridPos": { - "h": 2, - "w": 6, - "x": 18, - "y": 1 - }, - "id": 7, - "maxDataPoints": 100, - "options": { - "colorMode": "background", - "graphMode": "none", - "justifyMode": "center", - "orientation": "horizontal", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "auto", - "wideLayout": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "datasource", - "uid": "-- Dashboard --" - }, - "panelId": 2, - "refId": "A" - } - ], - "title": "", - "transformations": [ - { - "id": "filterFieldsByName", - "options": { - "include": { - "pattern": "distance_km|distance_mi|consumption_kWh" - } - } - }, - { - "id": "renameByRegex", - "options": { - "regex": "distance_(mi|km)", - "renamePattern": "distance" - } - }, - { - "id": "reduce", - "options": { - "includeTimeField": false, - "mode": "reduceFields", - "reducers": [ - "sum" - ] - } - }, - { - "id": "calculateField", - "options": { - "binary": { - "left": "consumption_kWh", - "operator": "/", - "right": "distance" - }, - "mode": "binary", - "reduce": { - "reducer": "sum" - }, - "replaceFields": true - } - }, - { - "id": "calculateField", - "options": { - "alias": "consumption_kwh_$length_unit", - "binary": { - "left": "1000", - "operator": "*", - "right": "consumption_kWh / distance" - }, - "mode": "binary", - "reduce": { - "reducer": "sum" - }, - "replaceFields": true - } - } - ], - "transparent": true, - "type": "stat" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "custom": { - "align": "auto", - "cellOptions": { - "type": "auto" - }, - "filterable": false, - "inspect": false, - "minWidth": 150 - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": 0 - } - ] - } - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "start_date" - }, - "properties": [ - { - "id": "displayName", - "value": "Date" - }, - { - "id": "unit", - "value": "dateTimeAsLocal" - }, - { - "id": "links", - "value": [ - { - "targetBlank": false, - "title": "View drive details", - "url": "/d/zm7wN6Zgz/drive-details?from=${__data.fields.start_date_ts.numeric}&to=${__data.fields.end_date_ts.numeric}&var-car_id=${__data.fields.car_id.numeric}&var-drive_id=${__data.fields.drive_id.numeric}" - } - ] - }, - { - "id": "custom.minWidth", - "value": 210 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "consumption_kwh_km" - }, - "properties": [ - { - "id": "displayName", - "value": "Ø Consumption (net)" - }, - { - "id": "unit", - "value": "Wh/km" - }, - { - "id": "custom.minWidth", - "value": 165 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "consumption_kwh_mi" - }, - "properties": [ - { - "id": "displayName", - "value": "Ø Consumption (net)" - }, - { - "id": "unit", - "value": "Wh/mi" - }, - { - "id": "custom.minWidth", - "value": 165 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "distance_km" - }, - "properties": [ - { - "id": "displayName", - "value": "Distance" - }, - { - "id": "unit", - "value": "lengthkm" - }, - { - "id": "decimals", - "value": 1 - }, - { - "id": "custom.minWidth", - "value": 90 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "consumption_kWh" - }, - "properties": [ - { - "id": "displayName", - "value": "Energy consumed (net)" - }, - { - "id": "unit", - "value": "kwatth" - }, - { - "id": "decimals", - "value": 1 - }, - { - "id": "custom.cellOptions", - "value": { - "type": "color-text" - } - }, - { - "id": "thresholds", - "value": { - "mode": "absolute", - "steps": [ - { - "color": "super-light-green", - "value": 0 - }, - { - "color": "green", - "value": 20 - }, - { - "color": "dark-green", - "value": 30 - } - ] - } - }, - { - "id": "custom.minWidth", - "value": 180 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "start_address" - }, - "properties": [ - { - "id": "displayName", - "value": "Start" - }, - { - "id": "links", - "value": [ - { - "targetBlank": true, - "title": "Create or edit geo-fence", - "url": "${base_url:raw}/geo-fences/${__data.fields.start_path}" - } - ] - }, - { - "id": "custom.minWidth", - "value": 200 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "end_address" - }, - "properties": [ - { - "id": "displayName", - "value": "Destination" - }, - { - "id": "links", - "value": [ - { - "targetBlank": true, - "title": "Create or edit geo-fence", - "url": "${base_url:raw}/geo-fences/${__data.fields.end_path}" - } - ] - }, - { - "id": "custom.minWidth", - "value": 200 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "outside_temp_c" - }, - "properties": [ - { - "id": "displayName", - "value": "Temp" - }, - { - "id": "unit", - "value": "celsius" - }, - { - "id": "decimals", - "value": 1 - }, - { - "id": "custom.minWidth", - "value": 70 - }, - { - "id": "custom.cellOptions", - "value": { - "type": "color-text" - } - }, - { - "id": "thresholds", - "value": { - "mode": "absolute", - "steps": [ - { - "color": "super-light-blue", - "value": 0 - }, - { - "color": "super-light-green", - "value": 10 - }, - { - "color": "super-light-red", - "value": 20 - } - ] - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "duration_min" - }, - "properties": [ - { - "id": "displayName", - "value": "Duration" - }, - { - "id": "unit", - "value": "m" - }, - { - "id": "decimals", - "value": 1 - }, - { - "id": "custom.minWidth", - "value": 90 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "efficiency" - }, - "properties": [ - { - "id": "displayName", - "value": "Efficiency" - }, - { - "id": "unit", - "value": "percentunit" - }, - { - "id": "custom.cellOptions", - "value": { - "mode": "lcd", - "type": "gauge" - } - }, - { - "id": "max", - "value": 1.25 - }, - { - "id": "min", - "value": 0 - }, - { - "id": "color", - "value": { - "mode": "thresholds" - } - }, - { - "id": "custom.minWidth", - "value": 120 - }, - { - "id": "thresholds", - "value": { - "mode": "absolute", - "steps": [ - { - "color": "super-light-orange", - "value": 0 - }, - { - "color": "light-orange", - "value": 0.65 - }, - { - "color": "green", - "value": 0.99 - } - ] - } - }, - { - "id": "decimals" - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*_ts/" - }, - "properties": [ - { - "id": "custom.hidden", - "value": true - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "speed_avg_km" - }, - "properties": [ - { - "id": "displayName", - "value": "Ø Speed" - }, - { - "id": "unit", - "value": "velocitykmh" - }, - { - "id": "decimals", - "value": 0 - }, - { - "id": "custom.minWidth", - "value": 90 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "distance_mi" - }, - "properties": [ - { - "id": "displayName", - "value": "Distance" - }, - { - "id": "unit", - "value": "lengthmi" - }, - { - "id": "decimals", - "value": 1 - }, - { - "id": "custom.minWidth", - "value": 90 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "outside_temp_f" - }, - "properties": [ - { - "id": "displayName", - "value": "Temp" - }, - { - "id": "unit", - "value": "fahrenheit" - }, - { - "id": "decimals", - "value": 1 - }, - { - "id": "custom.minWidth", - "value": 70 - }, - { - "id": "custom.cellOptions", - "value": { - "type": "color-text" - } - }, - { - "id": "thresholds", - "value": { - "mode": "absolute", - "steps": [ - { - "color": "super-light-blue", - "value": 0 - }, - { - "color": "super-light-green", - "value": 50 - }, - { - "color": "super-light-red", - "value": 68 - } - ] - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "speed_avg_mi" - }, - "properties": [ - { - "id": "displayName", - "value": "Ø Speed" - }, - { - "id": "unit", - "value": "velocitymph" - }, - { - "id": "decimals", - "value": 0 - }, - { - "id": "custom.minWidth", - "value": 90 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "speed_max_mi" - }, - "properties": [ - { - "id": "displayName", - "value": "max Speed" - }, - { - "id": "unit", - "value": "velocitymph" - }, - { - "id": "decimals", - "value": 0 - }, - { - "id": "custom.minWidth", - "value": 95 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "speed_max_km" - }, - "properties": [ - { - "id": "displayName", - "value": "max Speed" - }, - { - "id": "unit", - "value": "velocitykmh" - }, - { - "id": "decimals", - "value": 0 - }, - { - "id": "custom.minWidth", - "value": 95 - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/(start|end)_path/" - }, - "properties": [ - { - "id": "custom.hidden", - "value": true - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "duration_str" - }, - "properties": [ - { - "id": "custom.hidden", - "value": true - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "car_id" - }, - "properties": [ - { - "id": "custom.hidden", - "value": true - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "% Start" - }, - "properties": [ - { - "id": "unit", - "value": "percent" - }, - { - "id": "decimals", - "value": 0 - }, - { - "id": "custom.minWidth", - "value": 75 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "% End" - }, - "properties": [ - { - "id": "unit", - "value": "percent" - }, - { - "id": "decimals", - "value": 0 - }, - { - "id": "custom.minWidth", - "value": 65 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "has_reduced_range" - }, - "properties": [ - { - "id": "displayName", - "value": "❄" - }, - { - "id": "custom.cellOptions", - "value": { - "type": "color-text" - } - }, - { - "id": "custom.align", - "value": "center" - }, - { - "id": "mappings", - "value": [ - { - "options": { - "false": { - "color": "transparent", - "index": 1, - "text": "." - }, - "true": { - "color": "dark-blue", - "index": 0, - "text": "❄" - } - }, - "type": "value" - } - ] - }, - { - "id": "custom.minWidth", - "value": 50 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "drive_id" - }, - "properties": [ - { - "id": "custom.hidden", - "value": true - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "power_max" - }, - "properties": [ - { - "id": "displayName", - "value": "max Power" - }, - { - "id": "unit", - "value": "kwatt" - }, - { - "id": "custom.minWidth", - "value": 90 - } - ] - } - ] - }, - "gridPos": { - "h": 19, - "w": 24, - "x": 0, - "y": 3 - }, - "id": 2, - "options": { - "cellHeight": "sm", - "footer": { - "countRows": false, - "enablePagination": false, - "fields": "", - "reducer": [ - "sum" - ], - "show": false - }, - "showHeader": true, - "sortBy": [] - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "WITH data AS (\n SELECT\n round(extract(epoch FROM start_date)) * 1000 AS start_date_ts,\n round(extract(epoch FROM end_date)) * 1000 AS end_date_ts,\n car.id as car_id,\n CASE\n WHEN start_geofence.id IS NULL THEN CONCAT('new?lat=', start_position.latitude, '&lng=', start_position.longitude)\n WHEN start_geofence.id IS NOT NULL THEN CONCAT(start_geofence.id, '/edit')\n END as start_path,\n CASE\n WHEN end_geofence.id IS NULL THEN CONCAT('new?lat=', end_position.latitude, '&lng=', end_position.longitude)\n WHEN end_geofence.id IS NOT NULL THEN CONCAT(end_geofence.id, '/edit')\n END as end_path,\n TO_CHAR((duration_min * INTERVAL '1 minute'), 'HH24:MI') as duration_str,\n drives.id as drive_id,\n -- Columns\n start_date,\n COALESCE(start_geofence.name, CONCAT_WS(', ', COALESCE(start_address.name, nullif(CONCAT_WS(' ', start_address.road, start_address.house_number), '')), start_address.city)) AS start_address,\n COALESCE(end_geofence.name, CONCAT_WS(', ', COALESCE(end_address.name, nullif(CONCAT_WS(' ', end_address.road, end_address.house_number), '')), end_address.city)) AS end_address,\n duration_min,\n distance,\n start_position.battery_level as start_battery_level,\n end_position.battery_level as end_battery_level,\n start_${preferred_range}_range_km - end_${preferred_range}_range_km as range_diff,\n car.efficiency as car_efficiency,\n outside_temp_avg,\n distance / coalesce(NULLIF(duration_min, 0) * 60, extract(epoch from end_date - start_date)) * 3600 AS avg_speed,\n speed_max,\n power_max,\n ascent,\n descent\n FROM drives\n LEFT JOIN addresses start_address ON start_address_id = start_address.id\n LEFT JOIN addresses end_address ON end_address_id = end_address.id\n LEFT JOIN positions start_position ON start_position_id = start_position.id\n LEFT JOIN positions end_position ON end_position_id = end_position.id\n LEFT JOIN geofences start_geofence ON start_geofence_id = start_geofence.id\n LEFT JOIN geofences end_geofence ON end_geofence_id = end_geofence.id\n LEFT JOIN cars car ON car.id = drives.car_id\n WHERE $__timeFilter(start_date) AND drives.car_id = $car_id \n AND convert_km(distance::numeric, '$length_unit') >= $min_dist \n AND convert_km(distance::numeric, '$length_unit') / coalesce(NULLIF(duration_min, 0) * 60, extract(epoch from end_date - start_date)) * 3600 >= $min_speed \n AND ('${geofence:pipe}' = '-1' OR start_geofence.id in ($geofence) OR end_geofence.id in ($geofence)) \n),\n\nreduced_range_info as (\n\n select\n drive_id,\n case\n when sum(case when battery_level - usable_battery_level > 0 then 1 else 0 end)::numeric / count(*) > 0.25 then true\n else false\n end as reduced_range\n from positions p where $__timeFilter(date) AND car_id = $car_id and p.ideal_battery_range_km is not null group by p.drive_id \n\n)\n\nSELECT\n start_date_ts,\n end_date_ts,\n car_id,\n start_path,\n end_path,\n duration_str,\n data.drive_id,\n -- Columns\n start_date,\n start_address,\n end_address,\n duration_min,\n convert_km(distance::numeric, '$length_unit') AS distance_$length_unit,\n start_battery_level as \"% Start\",\n end_battery_level as \"% End\",\n convert_celsius(outside_temp_avg, '$temp_unit') AS outside_temp_$temp_unit,\n convert_km(avg_speed::numeric, '$length_unit') AS speed_avg_$length_unit,\n convert_km(speed_max::numeric, '$length_unit') AS speed_max_$length_unit,\n power_max,\n reduced_range as has_reduced_range,\n CASE\n WHEN range_diff > 0 and 'by distance' = '$efficiency' THEN distance / range_diff\n WHEN 'slope-adjusted' = '$efficiency' THEN\n distance * car_efficiency -- Energy at 100% efficiency\n / nullif((\n (range_diff) * car_efficiency -- Actual Energy\n + 2100 * 0.85 * 9.81 * descent / 3600 / 1000 -- Potential energy recovered from descent\n - 2100 * 9.81 * ascent / 3600 / 1000 -- Potential energy for ascent\n ), 0)\n ELSE NULL\n END as efficiency,\n range_diff * car_efficiency as \"consumption_kWh\",\n range_diff * car_efficiency / convert_km(distance::numeric, '$length_unit') * 1000 as consumption_kWh_$length_unit\nFROM data\n left join reduced_range_info on data.drive_id = reduced_range_info.drive_id\nWHERE\n start_address ILIKE '%$location%' OR end_address ILIKE '%$location%'\nORDER BY data.drive_id DESC;", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Drive", - "type": "table" - }, - { - "collapsed": false, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 22 - }, - "id": 10, - "panels": [], - "title": "General information (All drives)", - "type": "row" - }, - { - "fieldConfig": { - "defaults": {}, - "overrides": [] - }, - "gridPos": { - "h": 2, - "w": 24, - "x": 0, - "y": 23 - }, - "id": 8, - "options": { - "code": { - "language": "plaintext", - "showLineNumbers": false, - "showMiniMap": false - }, - "content": "From here you can check if you have \nincomplete data of **Drives** (drives without ending date)\nIf so, you may follow the official \nguide by Manually fixing data", - "mode": "markdown" - }, - "pluginVersion": "12.1.1", - "title": "", - "type": "text" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "fixed" - }, - "custom": { - "align": "center", - "cellOptions": { - "type": "auto" - }, - "inspect": false - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": 0 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 7, - "w": 24, - "x": 0, - "y": 25 - }, - "id": 9, - "options": { - "cellHeight": "sm", - "footer": { - "countRows": false, - "enablePagination": true, - "fields": "", - "reducer": [ - "sum" - ], - "show": false - }, - "showHeader": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "SELECT id AS \"Drive ID\", start_date, end_date, distance, duration_min \nFROM drives \nWHERE car_id = $car_id AND end_date is null\nORDER BY start_date DESC", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Incomplete Drives 🛣️", - "type": "table" - } - ], - "preload": false, - "refresh": "", - "schemaVersion": 41, - "tags": [ - "tesla" - ], - "templating": { - "list": [ - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "SELECT\n id as __value,\n CASE WHEN COUNT(id) OVER (PARTITION BY name) > 1 AND name IS NOT NULL THEN CONCAT(name, ' - ', RIGHT(vin, 6)) ELSE COALESCE(name, CONCAT('VIN ', vin)) end as __text \nFROM cars\nORDER BY display_priority ASC, name ASC, vin ASC;", - "includeAll": false, - "label": "Car", - "name": "car_id", - "options": [], - "query": "SELECT\n id as __value,\n CASE WHEN COUNT(id) OVER (PARTITION BY name) > 1 AND name IS NOT NULL THEN CONCAT(name, ' - ', RIGHT(vin, 6)) ELSE COALESCE(name, CONCAT('VIN ', vin)) end as __text \nFROM cars\nORDER BY display_priority ASC, name ASC, vin ASC;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "allValue": "-1", - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "SELECT name AS __text, id AS __value FROM geofences ORDER BY name COLLATE \"C\" ASC;", - "description": "Start or Destination Geofence", - "includeAll": true, - "label": "Geofence", - "multi": true, - "name": "geofence", - "options": [], - "query": "SELECT name AS __text, id AS __value FROM geofences ORDER BY name COLLATE \"C\" ASC;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": { - "text": "", - "value": "" - }, - "description": "Type a text contained in Start or Destination Location ", - "label": "Location", - "name": "location", - "options": [ - { - "selected": true, - "text": "", - "value": "" - } - ], - "query": "", - "type": "textbox" - }, - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "select unit_of_temperature from settings limit 1;", - "hide": 2, - "includeAll": false, - "label": "temperature unit", - "name": "temp_unit", - "options": [], - "query": "select unit_of_temperature from settings limit 1;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "select unit_of_length from settings limit 1;", - "hide": 2, - "includeAll": false, - "label": "length unit", - "name": "length_unit", - "options": [], - "query": "select unit_of_length from settings limit 1;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "select preferred_range from settings limit 1;", - "hide": 2, - "includeAll": false, - "name": "preferred_range", - "options": [], - "query": "select preferred_range from settings limit 1;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "select base_url from settings limit 1;", - "hide": 2, - "includeAll": false, - "name": "base_url", - "options": [], - "query": "select base_url from settings limit 1;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": { - "text": "0", - "value": "0" - }, - "label": "Distance >=", - "name": "min_dist", - "options": [ - { - "selected": true, - "text": "0", - "value": "0" - } - ], - "query": "0", - "type": "textbox" - }, - { - "current": { - "text": "0", - "value": "0" - }, - "label": "Speed >=", - "name": "min_speed", - "options": [ - { - "selected": true, - "text": "0", - "value": "0" - } - ], - "query": "0", - "type": "textbox" - }, - { - "allowCustomValue": false, - "current": { - "text": "slope-adjusted", - "value": "slope-adjusted" - }, - "description": "Select how Efficiency ratings should be calculated.\n\n\"by distance\" is doing a simple comparison based on distance driven and range lost while driving.\n\n\"slope-adjusted\" takes ascent / descent of the drive into account and adjusts the energy consumed accordingly. regen breaking efficiency is set to 85% and the vehicle is assumed to have a weight of 2100 kg.", - "label": "Efficiency", - "name": "efficiency", - "options": [ - { - "selected": true, - "text": "slope-adjusted", - "value": "slope-adjusted" - }, - { - "selected": false, - "text": "by distance", - "value": "by distance" - } - ], - "query": "slope-adjusted,by distance", - "type": "custom" - } - ] - }, - "time": { - "from": "now-3M", - "to": "now" - }, - "timepicker": {}, - "timezone": "", - "title": "Tesla Drives", - "uid": "Y8upc6ZRk", - "version": 1 - } diff --git a/argocd/manifests/grafana-config/dashboards/configmap-teslamate-efficiency.yaml b/argocd/manifests/grafana-config/dashboards/configmap-teslamate-efficiency.yaml deleted file mode 100644 index 594dea9..0000000 --- a/argocd/manifests/grafana-config/dashboards/configmap-teslamate-efficiency.yaml +++ /dev/null @@ -1,1187 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: grafana-dashboard-teslamate-efficiency - namespace: monitoring - labels: - grafana_dashboard: "1" - annotations: - grafana_folder: "TeslaMate" -data: - teslamate-efficiency.json: | - { - "annotations": { - "list": [ - { - "builtIn": 1, - "datasource": { - "type": "grafana", - "uid": "-- Grafana --" - }, - "enable": true, - "hide": true, - "iconColor": "rgba(0, 211, 255, 1)", - "name": "Annotations & Alerts", - "type": "dashboard" - } - ] - }, - "editable": true, - "fiscalYearStartMonth": 0, - "graphTooltip": 0, - "links": [ - { - "icon": "dashboard", - "tags": [], - "title": "TeslaMate", - "tooltip": "", - "type": "link", - "url": "${base_url:raw}" - }, - { - "asDropdown": true, - "icon": "external link", - "tags": [ - "tesla" - ], - "title": "Dashboards", - "type": "dashboards" - } - ], - "panels": [ - { - "collapsed": false, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 0 - }, - "id": 10, - "panels": [], - "repeat": "car_id", - "title": "$car_id", - "type": "row" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "description": "(Range lost while driving * Efficiency) / Distance driven", - "fieldConfig": { - "defaults": { - "decimals": 0, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "#c7d0d9", - "value": 0 - } - ] - }, - "unit": "none" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "consumption_km" - }, - "properties": [ - { - "id": "unit", - "value": "Wh/km" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "consumption_mi" - }, - "properties": [ - { - "id": "unit", - "value": "Wh/mi" - } - ] - } - ] - }, - "gridPos": { - "h": 3, - "w": 8, - "x": 0, - "y": 1 - }, - "id": 4, - "maxDataPoints": 100, - "options": { - "colorMode": "value", - "fieldOptions": { - "calcs": [ - "mean" - ] - }, - "graphMode": "none", - "justifyMode": "auto", - "orientation": "horizontal", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "mean" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "value", - "wideLayout": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "select \n sum((start_${preferred_range}_range_km - end_${preferred_range}_range_km) * cars.efficiency) / convert_km(sum(distance)::numeric, '$length_unit') * 1000 AS \"consumption_$length_unit\"\nfrom drives \ninner join cars on cars.id = car_id\nwhere \n distance is not null and\n start_${preferred_range}_range_km - end_${preferred_range}_range_km >= 0.1 and\n car_id = $car_id", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Ø Consumption (net)", - "type": "stat" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "description": "(Range lost between charges * Efficiency) / Distance driven between charges", - "fieldConfig": { - "defaults": { - "decimals": 0, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "#c7d0d9", - "value": 0 - } - ] - }, - "unit": "none" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "consumption_km" - }, - "properties": [ - { - "id": "unit", - "value": "Wh/km" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "consumption_mi" - }, - "properties": [ - { - "id": "unit", - "value": "Wh/mi" - } - ] - } - ] - }, - "gridPos": { - "h": 3, - "w": 8, - "x": 8, - "y": 1 - }, - "id": 8, - "maxDataPoints": 100, - "options": { - "colorMode": "value", - "fieldOptions": { - "calcs": [ - "mean" - ] - }, - "graphMode": "none", - "justifyMode": "auto", - "orientation": "horizontal", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "mean" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "value", - "wideLayout": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "WITH d1 AS (\n\tSELECT\n\t\tc.car_id,\n\t\tlag(end_${preferred_range}_range_km) OVER (ORDER BY start_date) - start_${preferred_range}_range_km AS range_loss,\n\t\tp.odometer - lag(p.odometer) OVER (ORDER BY start_date) AS distance\n\tFROM\n\t\tcharging_processes c\n\tLEFT JOIN positions p ON p.id = c.position_id \n\tWHERE\n\t end_date IS NOT NULL AND\n\t c.car_id = $car_id\n\tORDER BY\n\t\tstart_date\n),\nd2 AS (\nSELECT\n\tcar_id,\n\tsum(range_loss) AS range_loss,\n\tsum(distance) AS distance\nFROM\n\td1\nWHERE\n\tdistance >= 0 AND range_loss >= 0\nGROUP BY\n\tcar_id\n)\nSELECT\nrange_loss * c.efficiency / convert_km(distance::numeric, '$length_unit') * 1000 AS \"consumption_$length_unit\"\nFROM\n\td2\n\tLEFT JOIN cars c ON c.id = car_id", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Ø Consumption (gross)", - "type": "stat" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "description": "Distance of all logged drives", - "fieldConfig": { - "defaults": { - "decimals": 0, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "#c7d0d9", - "value": 0 - } - ] - }, - "unit": "none" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "distance_km" - }, - "properties": [ - { - "id": "unit", - "value": "km" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "distance_mi" - }, - "properties": [ - { - "id": "unit", - "value": "mi" - } - ] - } - ] - }, - "gridPos": { - "h": 3, - "w": 8, - "x": 16, - "y": 1 - }, - "id": 6, - "maxDataPoints": 100, - "options": { - "colorMode": "value", - "fieldOptions": { - "calcs": [ - "mean" - ] - }, - "graphMode": "none", - "justifyMode": "auto", - "orientation": "horizontal", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "mean" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "value", - "wideLayout": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "select convert_km(sum(distance)::numeric, '$length_unit') as \"distance_$length_unit\" \nfrom drives \nwhere car_id = $car_id;", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Logged Distance", - "type": "stat" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "custom": { - "align": "auto", - "cellOptions": { - "type": "auto" - }, - "filterable": false, - "inspect": false - }, - "mappings": [], - "max": 100, - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": 0 - } - ] - } - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "outside_temp_c" - }, - "properties": [ - { - "id": "displayName", - "value": "Temperature" - }, - { - "id": "unit", - "value": "celsius" - }, - { - "id": "custom.width", - "value": 125 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "outside_temp_f" - }, - "properties": [ - { - "id": "displayName", - "value": "Temperature" - }, - { - "id": "unit", - "value": "fahrenheit" - }, - { - "id": "custom.width", - "value": 125 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "efficiency" - }, - "properties": [ - { - "id": "displayName", - "value": "Driving Efficiency" - }, - { - "id": "unit", - "value": "percentunit" - }, - { - "id": "decimals", - "value": 1 - }, - { - "id": "custom.cellOptions", - "value": { - "mode": "lcd", - "type": "gauge" - } - }, - { - "id": "thresholds", - "value": { - "mode": "absolute", - "steps": [ - { - "color": "super-light-orange", - "value": 0 - }, - { - "color": "light-orange", - "value": 0.65 - }, - { - "color": "light-green", - "value": 0.99 - } - ] - } - }, - { - "id": "max", - "value": 1.15 - }, - { - "id": "min", - "value": 0 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "consumption_km" - }, - "properties": [ - { - "id": "displayName", - "value": "Ø Consumption (net)" - }, - { - "id": "unit", - "value": "Wh/km" - }, - { - "id": "custom.width", - "value": 200 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "consumption_mi" - }, - "properties": [ - { - "id": "displayName", - "value": "Ø Consumption (net)" - }, - { - "id": "unit", - "value": "Wh/mi" - }, - { - "id": "custom.width", - "value": 200 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "total_distance_km" - }, - "properties": [ - { - "id": "displayName", - "value": "Distance" - }, - { - "id": "unit", - "value": "km" - }, - { - "id": "custom.width", - "value": 200 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "total_distance_mi" - }, - "properties": [ - { - "id": "displayName", - "value": "Distance" - }, - { - "id": "unit", - "value": "mi" - }, - { - "id": "custom.width", - "value": 200 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "avg_speed_km" - }, - "properties": [ - { - "id": "displayName", - "value": "Ø Speed" - }, - { - "id": "unit", - "value": "velocitykmh" - }, - { - "id": "custom.width", - "value": 200 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "avg_speed_mi" - }, - "properties": [ - { - "id": "displayName", - "value": "Ø Speed" - }, - { - "id": "unit", - "value": "velocitymph" - }, - { - "id": "custom.width", - "value": 200 - } - ] - } - ] - }, - "gridPos": { - "h": 12, - "w": 24, - "x": 0, - "y": 4 - }, - "id": 2, - "options": { - "cellHeight": "sm", - "footer": { - "countRows": false, - "fields": "", - "reducer": [ - "sum" - ], - "show": false - }, - "showHeader": true, - "sortBy": [ - { - "desc": true, - "displayName": "Temperature" - } - ] - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "WITH t AS (\n\tSELECT\n\t CASE WHEN '$temp_unit' = 'C' THEN ROUND(cast(outside_temp_avg AS numeric) / 5, 0) * 5 \n\t\t\t WHEN '$temp_unit' = 'F' THEN ROUND(cast(convert_celsius(outside_temp_avg, '$temp_unit') AS numeric) / 10, 0) * 10\n\t\tEND AS outside_temp,\n\t\tsum(start_ideal_range_km - end_ideal_range_km) AS total_ideal_range,\n\t\tsum(start_rated_range_km - end_rated_range_km) AS total_rated_range,\n\t\tsum(distance) AS total_distance,\n\t\tsum(duration_min) as duration,\n\t\tcar_id\n\tFROM\n\t\tdrives\n\tWHERE\n\t\tdistance IS NOT NULL\n\t\tAND car_id = $car_id\n\t\tAND convert_km(distance::numeric, '$length_unit') >= $min_distance \n\t\tAND start_${preferred_range}_range_km - end_${preferred_range}_range_km > 0.1\n\tGROUP BY\n\t\t1,\n\t\tcar_id\n)\n\nSELECT\n\toutside_temp as outside_temp_$temp_unit,\n total_distance / total_${preferred_range}_range AS efficiency,\n\ttotal_${preferred_range}_range / convert_km(total_distance::numeric, '$length_unit') * c.efficiency * 1000 AS consumption_$length_unit,\n convert_km(total_distance::numeric, '$length_unit') as total_distance_$length_unit,\n\t(convert_km(total_distance::numeric, '$length_unit') / duration) * 60 as avg_speed_$length_unit\nFROM\n\tt\nJOIN cars c ON t.car_id = c.id\nWHERE outside_temp IS NOT NULL\norder by 1 desc\n", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Temperature – Driving Efficiency", - "type": "table" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "decimals": 0, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "#c7d0d9", - "value": 0 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "efficiency_km" - }, - "properties": [ - { - "id": "unit", - "value": "Wh/km" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "efficiency_mi" - }, - "properties": [ - { - "id": "unit", - "value": "Wh/mi" - } - ] - } - ] - }, - "gridPos": { - "h": 5, - "w": 4, - "x": 0, - "y": 16 - }, - "id": 14, - "maxDataPoints": 100, - "options": { - "colorMode": "value", - "fieldOptions": { - "calcs": [ - "mean" - ] - }, - "graphMode": "none", - "justifyMode": "auto", - "orientation": "horizontal", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "mean" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "value", - "wideLayout": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "SELECT\n\tefficiency / convert_km(1, '$length_unit') * 1000 as \"efficiency_$length_unit\"\nFROM\n\tcars\nWHERE\n\tid = $car_id;", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Current $preferred_range efficiency", - "type": "stat" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "custom": { - "align": "auto", - "cellOptions": { - "type": "auto" - }, - "filterable": false, - "inspect": false - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": 0 - } - ] - } - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "efficiency_km" - }, - "properties": [ - { - "id": "displayName", - "value": "Efficiency" - }, - { - "id": "unit", - "value": "Wh/km" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "efficiency_mi" - }, - "properties": [ - { - "id": "displayName", - "value": "Efficiency" - }, - { - "id": "unit", - "value": "Wh/mi" - } - ] - } - ] - }, - "gridPos": { - "h": 5, - "w": 10, - "x": 4, - "y": 16 - }, - "id": 12, - "options": { - "cellHeight": "sm", - "footer": { - "countRows": false, - "fields": "", - "reducer": [ - "sum" - ], - "show": false - }, - "showHeader": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "SELECT\n round((charge_energy_added / NULLIF(end_ideal_range_km - start_ideal_range_km, 0))::numeric / convert_km(1, '$length_unit'), 3) * 1000 as \"efficiency_$length_unit\",\n count(*) as count\nFROM\n charging_processes\nWHERE \n car_id = $car_id\n AND duration_min > 10\n AND end_battery_level <= 95\n AND start_ideal_range_km IS NOT NULL\n AND end_ideal_range_km IS NOT NULL\n AND charge_energy_added > 0\nGROUP BY\n 1\nORDER BY\n 2 DESC\nLIMIT 3", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Derived ideal efficiencies", - "type": "table" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "custom": { - "align": "auto", - "cellOptions": { - "type": "auto" - }, - "filterable": false, - "inspect": false - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": 0 - } - ] - } - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "efficiency_km" - }, - "properties": [ - { - "id": "unit", - "value": "Wh/km" - }, - { - "id": "displayName", - "value": "Efficiency" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "efficiency_mi" - }, - "properties": [ - { - "id": "displayName", - "value": "Efficiency" - }, - { - "id": "unit", - "value": "Wh/mi" - } - ] - } - ] - }, - "gridPos": { - "h": 5, - "w": 10, - "x": 14, - "y": 16 - }, - "id": 15, - "options": { - "cellHeight": "sm", - "footer": { - "countRows": false, - "fields": "", - "reducer": [ - "sum" - ], - "show": false - }, - "showHeader": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "SELECT\n round((charge_energy_added / NULLIF(end_rated_range_km - start_rated_range_km, 0))::numeric / convert_km(1, '$length_unit'), 3) * 1000 as \"efficiency_$length_unit\",\n\tcount(*) as count\nFROM\n charging_processes\nWHERE \n car_id = $car_id\n AND duration_min > 10\n AND end_battery_level <= 95\n AND start_rated_range_km IS NOT NULL\n AND end_rated_range_km IS NOT NULL\n AND charge_energy_added > 0\nGROUP BY\n 1\nORDER BY\n 2 DESC\nLIMIT 3", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Derived rated efficiencies", - "type": "table" - } - ], - "preload": false, - "refresh": "", - "schemaVersion": 41, - "tags": [ - "tesla" - ], - "templating": { - "list": [ - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "SELECT\n id as __value,\n CASE WHEN COUNT(id) OVER (PARTITION BY name) > 1 AND name IS NOT NULL THEN CONCAT(name, ' - ', RIGHT(vin, 6)) ELSE COALESCE(name, CONCAT('VIN ', vin)) end as __text \nFROM cars\nORDER BY display_priority ASC, name ASC, vin ASC;", - "hide": 2, - "includeAll": true, - "label": "Car", - "name": "car_id", - "options": [], - "query": "SELECT\n id as __value,\n CASE WHEN COUNT(id) OVER (PARTITION BY name) > 1 AND name IS NOT NULL THEN CONCAT(name, ' - ', RIGHT(vin, 6)) ELSE COALESCE(name, CONCAT('VIN ', vin)) end as __text \nFROM cars\nORDER BY display_priority ASC, name ASC, vin ASC;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "select unit_of_temperature from settings limit 1;", - "hide": 2, - "includeAll": false, - "name": "temp_unit", - "options": [], - "query": "select unit_of_temperature from settings limit 1;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "select unit_of_length from settings limit 1;", - "hide": 2, - "includeAll": false, - "name": "length_unit", - "options": [], - "query": "select unit_of_length from settings limit 1;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "select preferred_range from settings limit 1;", - "hide": 2, - "includeAll": false, - "name": "preferred_range", - "options": [], - "query": "select preferred_range from settings limit 1;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": { - "text": "1", - "value": "1" - }, - "includeAll": false, - "label": "min. distance per drive", - "name": "min_distance", - "options": [ - { - "selected": true, - "text": "1", - "value": "1" - }, - { - "selected": false, - "text": "5", - "value": "5" - }, - { - "selected": false, - "text": "10", - "value": "10" - }, - { - "selected": false, - "text": "25", - "value": "25" - }, - { - "selected": false, - "text": "50", - "value": "50" - } - ], - "query": "1, 5, 10, 25, 50", - "type": "custom" - }, - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "select base_url from settings limit 1;", - "hide": 2, - "includeAll": false, - "name": "base_url", - "options": [], - "query": "select base_url from settings limit 1;", - "refresh": 1, - "regex": "", - "type": "query" - } - ] - }, - "time": { - "from": "now-6h", - "to": "now" - }, - "timepicker": { - "hidden": true - }, - "timezone": "", - "title": "Tesla Efficiency", - "uid": "fu4SiQgWz", - "version": 1 - } diff --git a/argocd/manifests/grafana-config/dashboards/configmap-teslamate-locations.yaml b/argocd/manifests/grafana-config/dashboards/configmap-teslamate-locations.yaml deleted file mode 100644 index a638512..0000000 --- a/argocd/manifests/grafana-config/dashboards/configmap-teslamate-locations.yaml +++ /dev/null @@ -1,1200 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: grafana-dashboard-teslamate-locations - namespace: monitoring - labels: - grafana_dashboard: "1" - annotations: - grafana_folder: "TeslaMate" -data: - teslamate-locations.json: | - { - "annotations": { - "list": [ - { - "builtIn": 1, - "datasource": { - "type": "grafana", - "uid": "-- Grafana --" - }, - "enable": true, - "hide": true, - "iconColor": "rgba(0, 211, 255, 1)", - "name": "Annotations & Alerts", - "type": "dashboard" - } - ] - }, - "editable": true, - "fiscalYearStartMonth": 0, - "graphTooltip": 0, - "links": [ - { - "icon": "dashboard", - "tags": [], - "title": "TeslaMate", - "tooltip": "", - "type": "link", - "url": "${base_url:raw}" - }, - { - "asDropdown": true, - "icon": "external link", - "tags": [ - "tesla" - ], - "title": "Dashboards", - "type": "dashboards" - } - ], - "panels": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "#c7d0d9", - "value": 0 - } - ] - }, - "unit": "none" - }, - "overrides": [] - }, - "gridPos": { - "h": 3, - "w": 6, - "x": 0, - "y": 0 - }, - "id": 12, - "maxDataPoints": 100, - "options": { - "colorMode": "value", - "fieldOptions": { - "calcs": [ - "mean" - ] - }, - "graphMode": "none", - "justifyMode": "auto", - "orientation": "horizontal", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "mean" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "value", - "wideLayout": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "select count(*), count(distinct city) as city_count, count(distinct state) as state_count, count(distinct country) as country_count from addresses where id in (\r\n select start_address_id from drives where car_id in ($car_id) and $__timeFilter(start_date)\r\n union\r\n select end_address_id from drives where car_id in ($car_id) and $__timeFilter(end_date)\r\n union\r\n select address_id from charging_processes where car_id in ($car_id) and ($__timeFilter(start_date) or $__timeFilter(end_date))\r\n);", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "# of Addresses", - "transformations": [ - { - "id": "organize", - "options": { - "excludeByName": { - "city_count": true, - "country_count": true, - "state_count": true - }, - "includeByName": {}, - "indexByName": {}, - "renameByName": {} - } - } - ], - "type": "stat" - }, - { - "datasource": { - "type": "datasource", - "uid": "-- Dashboard --" - }, - "fieldConfig": { - "defaults": { - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "#c7d0d9", - "value": 0 - } - ] - }, - "unit": "none" - }, - "overrides": [] - }, - "gridPos": { - "h": 3, - "w": 6, - "x": 6, - "y": 0 - }, - "id": 20, - "maxDataPoints": 100, - "options": { - "colorMode": "value", - "fieldOptions": { - "calcs": [ - "mean" - ] - }, - "graphMode": "none", - "justifyMode": "auto", - "orientation": "horizontal", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "mean" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "value", - "wideLayout": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "datasource", - "uid": "-- Dashboard --" - }, - "panelId": 12, - "refId": "A" - } - ], - "title": "# of Cities", - "transformations": [ - { - "id": "organize", - "options": { - "excludeByName": { - "count": true, - "country_count": true, - "state_count": true - }, - "includeByName": {}, - "indexByName": {}, - "renameByName": {} - } - } - ], - "type": "stat" - }, - { - "datasource": { - "type": "datasource", - "uid": "-- Dashboard --" - }, - "fieldConfig": { - "defaults": { - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "#c7d0d9", - "value": 0 - } - ] - }, - "unit": "none" - }, - "overrides": [] - }, - "gridPos": { - "h": 3, - "w": 6, - "x": 12, - "y": 0 - }, - "id": 18, - "maxDataPoints": 100, - "options": { - "colorMode": "value", - "fieldOptions": { - "calcs": [ - "mean" - ] - }, - "graphMode": "none", - "justifyMode": "auto", - "orientation": "horizontal", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "mean" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "value", - "wideLayout": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "datasource", - "uid": "-- Dashboard --" - }, - "panelId": 12, - "refId": "A" - } - ], - "title": "# of States", - "transformations": [ - { - "id": "organize", - "options": { - "excludeByName": { - "city_count": true, - "count": true, - "country_count": true - }, - "includeByName": {}, - "indexByName": {}, - "renameByName": {} - } - } - ], - "type": "stat" - }, - { - "datasource": { - "type": "datasource", - "uid": "-- Dashboard --" - }, - "fieldConfig": { - "defaults": { - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "#c7d0d9", - "value": 0 - } - ] - }, - "unit": "none" - }, - "overrides": [] - }, - "gridPos": { - "h": 3, - "w": 6, - "x": 18, - "y": 0 - }, - "id": 16, - "maxDataPoints": 100, - "options": { - "colorMode": "value", - "fieldOptions": { - "calcs": [ - "mean" - ] - }, - "graphMode": "none", - "justifyMode": "auto", - "orientation": "horizontal", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "mean" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "value", - "wideLayout": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "datasource", - "uid": "-- Dashboard --" - }, - "panelId": 12, - "refId": "A" - } - ], - "title": "# of Countries", - "transformations": [ - { - "id": "organize", - "options": { - "excludeByName": { - "city_count": true, - "count": true, - "state_count": true - }, - "includeByName": {}, - "indexByName": {}, - "renameByName": {} - } - } - ], - "type": "stat" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "displayName": "$__cell_0", - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "semi-dark-green", - "value": 0 - }, - { - "color": "super-light-green", - "value": 50 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 0, - "y": 3 - }, - "id": 10, - "options": { - "displayMode": "gradient", - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": false - }, - "maxVizHeight": 300, - "minVizHeight": 16, - "minVizWidth": 8, - "namePlacement": "auto", - "orientation": "horizontal", - "reduceOptions": { - "calcs": [ - "mean" - ], - "fields": "", - "values": true - }, - "showUnfilled": true, - "sizing": "auto", - "valueMode": "color" - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "SELECT\n\tcity,\n\tcount(*) as \"# of Addresses\"\nFROM\n\taddresses\nWHERE\n\tcity IS NOT NULL and\n id in (\n\t\tselect start_address_id from drives where car_id in ($car_id) and $__timeFilter(start_date) \n\t\tunion\n\t\tselect end_address_id from drives where car_id in ($car_id) and $__timeFilter(end_date) \n\t\tunion\n\t\tselect address_id from charging_processes where car_id in ($car_id) and ($__timeFilter(start_date) or $__timeFilter(end_date))\n\t)\nGROUP BY\n\t1\nORDER BY\n\t2 DESC\nLIMIT 10;", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Cities", - "type": "bargauge" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "displayName": "$__cell_0", - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "semi-dark-orange", - "value": 0 - }, - { - "color": "super-light-orange", - "value": 50 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 12, - "y": 3 - }, - "id": 14, - "options": { - "displayMode": "gradient", - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": false - }, - "maxVizHeight": 300, - "minVizHeight": 16, - "minVizWidth": 8, - "namePlacement": "auto", - "orientation": "horizontal", - "reduceOptions": { - "calcs": [ - "mean" - ], - "fields": "", - "values": true - }, - "showUnfilled": true, - "sizing": "auto", - "valueMode": "color" - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "SELECT\n\tstate,\n\tcount(*) as \"# of Addresses\"\nFROM\n\taddresses\nWHERE\n\tstate IS NOT NULL and\n id in (\n\t\tselect start_address_id from drives where car_id in ($car_id) and $__timeFilter(start_date)\n\t\tunion\n\t\tselect end_address_id from drives where car_id in ($car_id) and $__timeFilter(end_date)\n\t\tunion\n\t\tselect address_id from charging_processes where car_id in ($car_id) and ($__timeFilter(start_date) or $__timeFilter(end_date))\n\t)\nGROUP BY\n\t1\nORDER BY\n\t2 DESC\nLIMIT 10;", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "States", - "type": "bargauge" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "custom": { - "align": "auto", - "cellOptions": { - "type": "auto" - }, - "filterable": false, - "inspect": false - }, - "mappings": [], - "max": 100, - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "semi-dark-blue", - "value": 0 - }, - { - "color": "light-red", - "value": 50 - } - ] - }, - "unit": "none" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Date" - }, - "properties": [ - { - "id": "unit", - "value": "dateTimeAsLocal" - }, - { - "id": "custom.width", - "value": 210 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "City" - }, - "properties": [ - { - "id": "custom.width" - } - ] - } - ] - }, - "gridPos": { - "h": 11, - "w": 24, - "x": 0, - "y": 11 - }, - "id": 22, - "options": { - "cellHeight": "sm", - "footer": { - "countRows": false, - "fields": "", - "reducer": [ - "sum" - ], - "show": false - }, - "showHeader": true, - "sortBy": [ - { - "desc": true, - "displayName": "Date" - } - ] - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "with locations as (\n\n select address_id, geofence_id, start_date as end_date from charging_processes where car_id in ($car_id) and ($__timeFilter(start_date) or $__timeFilter(end_date))\n union\n select end_address_id as address_id, end_geofence_id as geofence_id, end_date from drives where car_id in ($car_id) and $__timeFilter(end_date)\n\n)\n\nSELECT\n max(l.end_date) as \"Date\",\n COALESCE(g.name, array_to_string(((string_to_array(a.display_name, ', ', ''))[0:2]), ', ')) AS \"Address\",\n\tCOALESCE(city, neighbourhood) as \"City\"\nFROM locations l\nINNER JOIN addresses a ON l.address_id = a.id\nLEFT JOIN geofences g ON l.geofence_id = g.id\nWHERE\n (a.display_name ilike '%$address_filter%' or g.name ilike '%$address_filter%')\nGROUP BY 2,3\nLIMIT 100", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Last visited", - "type": "table" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "custom": { - "align": "auto", - "cellOptions": { - "type": "auto" - }, - "inspect": false - }, - "decimals": 2, - "displayName": "", - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": 0 - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "updated_at" - }, - "properties": [ - { - "id": "displayName", - "value": "Updated at" - }, - { - "id": "unit", - "value": "time: YYYY-MM-DD HH:mm:ss" - }, - { - "id": "custom.align" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "name" - }, - "properties": [ - { - "id": "displayName", - "value": "Name" - }, - { - "id": "unit", - "value": "short" - }, - { - "id": "decimals", - "value": 2 - }, - { - "id": "links", - "value": [ - { - "targetBlank": true, - "title": "", - "url": "${base_url:raw}/geo-fences/${__data.fields.path}" - } - ] - }, - { - "id": "custom.align" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "neighbourhood" - }, - "properties": [ - { - "id": "displayName", - "value": "Neighbourhood" - }, - { - "id": "unit", - "value": "short" - }, - { - "id": "decimals", - "value": 2 - }, - { - "id": "custom.align" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "city" - }, - "properties": [ - { - "id": "displayName", - "value": "City" - }, - { - "id": "unit", - "value": "short" - }, - { - "id": "decimals", - "value": 2 - }, - { - "id": "custom.align" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "state" - }, - "properties": [ - { - "id": "displayName", - "value": "State" - }, - { - "id": "unit", - "value": "short" - }, - { - "id": "decimals", - "value": 2 - }, - { - "id": "custom.align" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "country" - }, - "properties": [ - { - "id": "displayName", - "value": "Country" - }, - { - "id": "unit", - "value": "short" - }, - { - "id": "decimals", - "value": 2 - }, - { - "id": "custom.align" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "path" - }, - "properties": [ - { - "id": "custom.align" - }, - { - "id": "custom.hidden", - "value": true - } - ] - } - ] - }, - "gridPos": { - "h": 11, - "w": 18, - "x": 0, - "y": 22 - }, - "id": 2, - "options": { - "cellHeight": "sm", - "footer": { - "countRows": false, - "fields": "", - "reducer": [ - "sum" - ], - "show": false - }, - "showHeader": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "SELECT\n CONCAT('new?lat=', latitude, '&lng=', longitude) as path,\n\tCOALESCE(name, CONCAT(road, ' ', house_number)) AS name,\n\tneighbourhood,\n\tcity,\n\tstate,\n\tcountry\nFROM addresses\nWHERE display_name ilike '%$address_filter%' and id in (\n select start_address_id from drives where car_id in ($car_id) and $__timeFilter(start_date)\n union\n select end_address_id from drives where car_id in ($car_id) and $__timeFilter(end_date)\n union\n select address_id from charging_processes where car_id in ($car_id) and ($__timeFilter(start_date) or $__timeFilter(end_date))\n)\nORDER BY inserted_at DESC\nLIMIT 100;", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Addresses", - "transformations": [ - { - "id": "merge", - "options": { - "reducers": [] - } - } - ], - "type": "table" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "custom": { - "align": "auto", - "cellOptions": { - "type": "auto" - }, - "inspect": false - }, - "decimals": 2, - "displayName": "", - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": 0 - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Time" - }, - "properties": [ - { - "id": "displayName", - "value": "Time" - }, - { - "id": "unit", - "value": "time: YYYY-MM-DD HH:mm:ss" - }, - { - "id": "custom.align" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "name" - }, - "properties": [ - { - "id": "displayName", - "value": "Name" - }, - { - "id": "unit", - "value": "short" - }, - { - "id": "decimals", - "value": 2 - }, - { - "id": "links", - "value": [ - { - "targetBlank": true, - "title": "", - "url": "${base_url:raw}/geo-fences/${__data.fields.id.numeric:raw}/edit" - } - ] - }, - { - "id": "custom.align" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "id" - }, - "properties": [ - { - "id": "custom.align" - }, - { - "id": "custom.hidden", - "value": true - } - ] - } - ] - }, - "gridPos": { - "h": 11, - "w": 6, - "x": 18, - "y": 22 - }, - "id": 6, - "options": { - "cellHeight": "sm", - "footer": { - "countRows": false, - "fields": "", - "reducer": [ - "sum" - ], - "show": false - }, - "showHeader": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "SELECT id, name \nFROM geofences where id in (\n select start_geofence_id from drives where car_id in ($car_id) and $__timeFilter(start_date)\n union\n select end_geofence_id from drives where car_id in ($car_id) and $__timeFilter(end_date)\n union\n select geofence_id from charging_processes where car_id in ($car_id) and ($__timeFilter(start_date) or $__timeFilter(end_date))\n)\nORDER BY inserted_at DESC\nLIMIT 100;", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Geo-fences", - "transformations": [ - { - "id": "merge", - "options": { - "reducers": [] - } - } - ], - "type": "table" - } - ], - "preload": false, - "refresh": "", - "schemaVersion": 41, - "tags": [ - "tesla" - ], - "templating": { - "list": [ - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "SELECT\n id as __value,\n CASE WHEN COUNT(id) OVER (PARTITION BY name) > 1 AND name IS NOT NULL THEN CONCAT(name, ' - ', RIGHT(vin, 6)) ELSE COALESCE(name, CONCAT('VIN ', vin)) end as __text \nFROM cars\nORDER BY display_priority ASC, name ASC, vin ASC;", - "hide": 1, - "includeAll": true, - "label": "Car", - "multi": true, - "name": "car_id", - "options": [], - "query": "SELECT\n id as __value,\n CASE WHEN COUNT(id) OVER (PARTITION BY name) > 1 AND name IS NOT NULL THEN CONCAT(name, ' - ', RIGHT(vin, 6)) ELSE COALESCE(name, CONCAT('VIN ', vin)) end as __text \nFROM cars\nORDER BY display_priority ASC, name ASC, vin ASC;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "select base_url from settings limit 1;", - "hide": 2, - "includeAll": false, - "name": "base_url", - "options": [], - "query": "select base_url from settings limit 1;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": { - "text": "", - "value": "" - }, - "label": "Address", - "name": "address_filter", - "options": [ - { - "selected": true, - "text": "", - "value": "" - } - ], - "query": "", - "type": "textbox" - } - ] - }, - "time": { - "from": "now-1y", - "to": "now" - }, - "timepicker": {}, - "timezone": "", - "title": "Tesla Locations", - "uid": "ZzhF-aRWz", - "version": 1 - } diff --git a/argocd/manifests/grafana-config/dashboards/configmap-teslamate-mileage.yaml b/argocd/manifests/grafana-config/dashboards/configmap-teslamate-mileage.yaml deleted file mode 100644 index 25e4965..0000000 --- a/argocd/manifests/grafana-config/dashboards/configmap-teslamate-mileage.yaml +++ /dev/null @@ -1,293 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: grafana-dashboard-teslamate-mileage - namespace: monitoring - labels: - grafana_dashboard: "1" - annotations: - grafana_folder: "TeslaMate" -data: - teslamate-mileage.json: | - { - "annotations": { - "list": [ - { - "builtIn": 1, - "datasource": { - "type": "grafana", - "uid": "-- Grafana --" - }, - "enable": true, - "hide": true, - "iconColor": "rgba(0, 211, 255, 1)", - "name": "Annotations & Alerts", - "type": "dashboard" - } - ] - }, - "editable": true, - "fiscalYearStartMonth": 0, - "graphTooltip": 0, - "links": [ - { - "icon": "dashboard", - "tags": [], - "title": "TeslaMate", - "tooltip": "", - "type": "link", - "url": "${base_url:raw}" - }, - { - "asDropdown": true, - "icon": "external link", - "tags": [ - "tesla" - ], - "title": "Dashboards", - "type": "dashboards" - } - ], - "panels": [ - { - "collapsed": false, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 0 - }, - "id": 4, - "panels": [], - "repeat": "car_id", - "title": "$car_id", - "type": "row" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "barWidthFactor": 0.6, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "opacity", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "stepAfter", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": true, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "decimals": 0, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": 0 - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": ".*_km$" - }, - "properties": [ - { - "id": "unit", - "value": "km" - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": ".*_mi$" - }, - "properties": [ - { - "id": "unit", - "value": "mi" - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "mileage_.*" - }, - "properties": [ - { - "id": "displayName", - "value": "Mileage" - } - ] - } - ] - }, - "gridPos": { - "h": 21, - "w": 24, - "x": 0, - "y": 1 - }, - "id": 2, - "options": { - "legend": { - "calcs": [ - "min", - "max" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "hideZeros": false, - "maxHeight": 600, - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "time_series", - "rawQuery": true, - "rawSql": "WITH o AS (SELECT\n start_date AS time,\n car_id,\n start_km AS \"odometer\"\nFROM drives\nUNION ALL\nSELECT\n end_date,\n car_id,\n end_km AS \"odometer\"\nFROM drives)\n\nSELECT\n time, \n convert_km(odometer::numeric, '$length_unit') as mileage_$length_unit\nFROM o\nWHERE\n\tcar_id = $car_id AND\n\t$__timeFilter(time)\norder by 1;", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Mileage", - "type": "timeseries" - } - ], - "preload": false, - "refresh": "", - "schemaVersion": 41, - "tags": [ - "tesla" - ], - "templating": { - "list": [ - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "SELECT\n id as __value,\n CASE WHEN COUNT(id) OVER (PARTITION BY name) > 1 AND name IS NOT NULL THEN CONCAT(name, ' - ', RIGHT(vin, 6)) ELSE COALESCE(name, CONCAT('VIN ', vin)) end as __text \nFROM cars\nORDER BY display_priority ASC, name ASC, vin ASC;", - "hide": 2, - "includeAll": true, - "label": "Car", - "name": "car_id", - "options": [], - "query": "SELECT\n id as __value,\n CASE WHEN COUNT(id) OVER (PARTITION BY name) > 1 AND name IS NOT NULL THEN CONCAT(name, ' - ', RIGHT(vin, 6)) ELSE COALESCE(name, CONCAT('VIN ', vin)) end as __text \nFROM cars\nORDER BY display_priority ASC, name ASC, vin ASC;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "select unit_of_length from settings limit 1;", - "hide": 2, - "includeAll": false, - "name": "length_unit", - "options": [], - "query": "select unit_of_length from settings limit 1;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "select base_url from settings limit 1;", - "hide": 2, - "includeAll": false, - "name": "base_url", - "options": [], - "query": "select base_url from settings limit 1;", - "refresh": 1, - "regex": "", - "type": "query" - } - ] - }, - "time": { - "from": "now-6M", - "to": "now" - }, - "timepicker": {}, - "timezone": "", - "title": "Tesla Mileage", - "uid": "NjtMTFggz", - "version": 1 - } diff --git a/argocd/manifests/grafana-config/dashboards/configmap-teslamate-overview.yaml b/argocd/manifests/grafana-config/dashboards/configmap-teslamate-overview.yaml deleted file mode 100644 index ca860be..0000000 --- a/argocd/manifests/grafana-config/dashboards/configmap-teslamate-overview.yaml +++ /dev/null @@ -1,1990 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: grafana-dashboard-teslamate-overview - namespace: monitoring - labels: - grafana_dashboard: "1" - annotations: - grafana_folder: "TeslaMate" -data: - teslamate-overview.json: | - { - "annotations": { - "list": [ - { - "builtIn": 1, - "datasource": { - "type": "grafana", - "uid": "-- Grafana --" - }, - "enable": true, - "hide": true, - "iconColor": "rgba(0, 211, 255, 1)", - "name": "Annotations & Alerts", - "target": { - "limit": 100, - "matchAny": false, - "tags": [], - "type": "dashboard" - }, - "type": "dashboard" - } - ] - }, - "description": "A high level overview of your car", - "editable": true, - "fiscalYearStartMonth": 0, - "graphTooltip": 1, - "links": [ - { - "icon": "dashboard", - "tags": [], - "title": "TeslaMate", - "tooltip": "", - "type": "link", - "url": "${base_url:raw}" - }, - { - "asDropdown": true, - "icon": "external link", - "tags": [ - "tesla" - ], - "title": "Dashboards", - "type": "dashboards" - } - ], - "panels": [ - { - "collapsed": false, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 0 - }, - "id": 18, - "panels": [], - "repeat": "car_id", - "title": "$car_id", - "type": "row" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "displayName": "", - "mappings": [], - "max": 100, - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "transparent", - "value": 0 - } - ] - }, - "unit": "percent" - }, - "overrides": [] - }, - "gridPos": { - "h": 4, - "w": 3, - "x": 0, - "y": 1 - }, - "id": 4, - "options": { - "minVizHeight": 75, - "minVizWidth": 75, - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "mean" - ], - "fields": "", - "values": false - }, - "showThresholdLabels": false, - "showThresholdMarkers": true, - "sizing": "auto" - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "(SELECT battery_level, date\nFROM positions\nWHERE car_id = $car_id and ideal_battery_range_km is not null\nORDER BY date DESC\nLIMIT 1)\nUNION\nSELECT battery_level, date\nFROM charges c\nJOIN charging_processes p ON p.id = c.charging_process_id\nWHERE $__timeFilter(date) AND p.car_id = $car_id\nORDER BY date DESC\nLIMIT 1", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "SELECT\r\n 0 as lowest,\r\n 10 as low,\r\n 20 as mid,\r\n CASE WHEN lfp_battery THEN 101 ELSE 81 END as high,\r\n CASE WHEN lfp_battery THEN 101 ELSE 91 END as highest\r\nfrom cars inner join car_settings on cars.settings_id = car_settings.id\r\nwhere cars.id = $car_id", - "refId": "B", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Battery Level", - "transformations": [ - { - "id": "configFromData", - "options": { - "applyTo": { - "id": "byFrameRefID", - "options": "A" - }, - "configRefId": "B", - "mappings": [ - { - "fieldName": "lowest", - "handlerKey": "threshold1" - }, - { - "fieldName": "low", - "handlerArguments": { - "threshold": { - "color": "yellow" - } - }, - "handlerKey": "threshold1" - }, - { - "fieldName": "mid", - "handlerArguments": { - "threshold": { - "color": "green" - } - }, - "handlerKey": "threshold1" - }, - { - "fieldName": "high", - "handlerArguments": { - "threshold": { - "color": "yellow" - } - }, - "handlerKey": "threshold1" - }, - { - "fieldName": "highest", - "handlerArguments": { - "threshold": { - "color": "red" - } - }, - "handlerKey": "threshold1" - } - ] - } - } - ], - "type": "gauge" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "decimals": 0, - "mappings": [], - "max": 260, - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "semi-dark-green", - "value": 0 - } - ] - }, - "unit": "volt" - }, - "overrides": [] - }, - "gridPos": { - "h": 4, - "w": 3, - "x": 3, - "y": 1 - }, - "id": 10, - "options": { - "minVizHeight": 75, - "minVizWidth": 75, - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "firstNotNull" - ], - "fields": "", - "values": false - }, - "showThresholdLabels": false, - "showThresholdMarkers": true, - "sizing": "auto" - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "time_series", - "rawQuery": true, - "rawSql": "WITH charging_process AS (\n SELECT id, end_date\n FROM charging_processes\n WHERE car_id = $car_id\n ORDER BY start_date DESC\n LIMIT 1\n)\nSELECT\n $__time(date),\n CASE WHEN charging_process.end_date IS NULL THEN charger_voltage\n ELSE 0\n END AS \"Charging Voltage [V]\"\nFROM charges, charging_process\nWHERE charging_process.id = charging_process_id\nORDER BY date DESC\nLIMIT 1;", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Charging Voltage", - "type": "gauge" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "decimals": 0, - "fieldMinMax": false, - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "semi-dark-green", - "value": 0 - } - ] - }, - "unit": "kwatt" - }, - "overrides": [] - }, - "gridPos": { - "h": 4, - "w": 3, - "x": 6, - "y": 1 - }, - "id": 11, - "options": { - "minVizHeight": 75, - "minVizWidth": 75, - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "firstNotNull" - ], - "fields": "", - "values": false - }, - "showThresholdLabels": false, - "showThresholdMarkers": true, - "sizing": "auto" - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "WITH charging_process AS (\n SELECT id, end_date\n FROM charging_processes\n WHERE car_id = $car_id\n ORDER BY start_date DESC\n LIMIT 1\n)\nSELECT\n $__time(date),\n CASE WHEN charging_process.end_date IS NULL THEN charger_power\n ELSE 0\n END AS \"Power [kW]\"\nFROM charges, charging_process\nWHERE charging_process.id = charging_process_id\nORDER BY date DESC\nLIMIT 1;", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "SELECT\r\n CASE WHEN lfp_battery THEN 170 ELSE 250 END as max_charging_kw\r\nfrom cars inner join car_settings on cars.settings_id = car_settings.id\r\nwhere cars.id = $car_id", - "refId": "B", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Charging Power", - "transformations": [ - { - "id": "configFromData", - "options": { - "configRefId": "B", - "mappings": [ - { - "fieldName": "max_charging_kw", - "handlerKey": "max" - } - ] - } - } - ], - "type": "gauge" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "barWidthFactor": 0.6, - "drawStyle": "line", - "fillOpacity": 10, - "gradientMode": "opacity", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "max": 100, - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": 0 - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "percent" - }, - "overrides": [] - }, - "gridPos": { - "h": 7, - "w": 15, - "x": 9, - "y": 1 - }, - "id": 13, - "links": [ - { - "targetBlank": true, - "title": "Charge Level", - "url": "/d/WopVO_mgz/charge-level?${__url_time_range}" - } - ], - "options": { - "legend": { - "calcs": [ - "max", - "min" - ], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "hideZeros": false, - "maxHeight": 600, - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "time_series", - "rawQuery": true, - "rawSql": "SELECT $__time(date), battery_level AS \"SOC\"\nFROM (\n\tSELECT battery_level, date\n\tFROM positions\n\tWHERE car_id = $car_id AND ideal_battery_range_km IS NOT NULL AND $__timeFilter(date)\n\tUNION ALL\n\tSELECT battery_level, date\n\tFROM charges c \n JOIN charging_processes p ON p.id = c.charging_process_id\n\tWHERE $__timeFilter(date) AND p.car_id = $car_id) AS data\nORDER BY date ASC;", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Charge Level", - "type": "timeseries" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "decimals": 0, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": 0 - } - ] - }, - "unit": "none" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": ".*_km" - }, - "properties": [ - { - "id": "unit", - "value": "Wh/km" - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": ".*_mi" - }, - "properties": [ - { - "id": "unit", - "value": "Wh/mi" - } - ] - } - ] - }, - "gridPos": { - "h": 3, - "w": 3, - "x": 0, - "y": 5 - }, - "id": 14, - "maxDataPoints": 100, - "options": { - "colorMode": "value", - "fieldOptions": { - "calcs": [ - "first" - ] - }, - "graphMode": "none", - "justifyMode": "auto", - "orientation": "auto", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "mean" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "value", - "wideLayout": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "SELECT\n sum((start_${preferred_range}_range_km - end_${preferred_range}_range_km) * car.efficiency * 1000) / \n convert_km(sum(distance)::numeric, '$length_unit') as \"consumption_$length_unit\"\nFROM drives\nJOIN cars car ON car.id = car_id\nWHERE $__timeFilter(start_date) AND car_id = $car_id", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Ø Consumption (net)", - "type": "stat" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": 0 - } - ] - } - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": ".*_km" - }, - "properties": [ - { - "id": "unit", - "value": "Wh/km" - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": ".*_mi" - }, - "properties": [ - { - "id": "unit", - "value": "Wh/mi" - } - ] - } - ] - }, - "gridPos": { - "h": 3, - "w": 3, - "x": 3, - "y": 5 - }, - "id": 22, - "options": { - "colorMode": "value", - "graphMode": "none", - "justifyMode": "auto", - "orientation": "auto", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "mean" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "auto", - "wideLayout": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "WITH d AS (\n\tSELECT\n\t\tc.car_id,\n\t\tlag(end_${preferred_range}_range_km) OVER (ORDER BY start_date) - start_${preferred_range}_range_km AS range_loss,\n\t\tp.odometer - lag(p.odometer) OVER (ORDER BY start_date) AS distance\n\tFROM charging_processes c\n\tLEFT JOIN positions p ON p.id = c.position_id \n\tWHERE\n\t end_date IS NOT NULL AND\n\t c.car_id = $car_id AND\n\t $__timeFilter(start_date)\n\tORDER BY start_date\n),\n\nrange_loss_between_charges AS (\n SELECT sum(range_loss) AS range_loss\n FROM d\n WHERE distance >= 0 AND range_loss >= 0\n GROUP BY car_id\n),\n\ncharge_dates AS (\n\tSELECT\n\t\tmin(start_date) as first_charge,\n\t\tmax(end_date) as last_charge\n\tFROM\n\t\tcharging_processes\n\tWHERE\n\t\tend_date IS NOT NULL\n\t\tAND car_id = $car_id\n\t\tAND $__timeFilter(start_date)\n),\n\nrange_loss_before_first_charge AS (\n\tSELECT\n\t\tmax(${preferred_range}_battery_range_km) - min(${preferred_range}_battery_range_km) AS range_loss\n\tFROM positions, charge_dates\n\tWHERE\n\t\tcar_id = $car_id\n\t\tAND $__timeFilter(date)\n\t\tAND ((select first_charge from charge_dates) is null OR date < (select first_charge from charge_dates))\n),\n\nrange_loss_after_last_charge AS (\n\tSELECT\n\t\tmax(${preferred_range}_battery_range_km) - min(${preferred_range}_battery_range_km) AS range_loss\n\tFROM positions, charge_dates\n\tWHERE\n\t\tcar_id = $car_id\n\t\tAND $__timeFilter(date)\n\t\tAND date > (select last_charge from charge_dates)\t\n),\n\ntotal_range_loss AS (\n SELECT sum(range_loss) as range_loss\n FROM (\n SELECT range_loss FROM range_loss_between_charges\n UNION ALL\n SELECT range_loss FROM range_loss_before_first_charge\n UNION ALL\n SELECT range_loss FROM range_loss_after_last_charge\n ) r\n),\n\ndistance AS (\n SELECT max(odometer) - min(odometer) as distance\n FROM positions\n WHERE car_id = $car_id AND $__timeFilter(date)\n)\n\nSELECT \n NULLIF(range_loss, 0) * (c.efficiency * 1000) / convert_km(NULLIF(distance::numeric, 0), '$length_unit') as \"consumption_$length_unit\"\nFROM total_range_loss, distance\nLEFT JOIN cars c ON c.id = $car_id", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Ø Consumption (gross)", - "type": "stat" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": 0 - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "none" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "distance_km" - }, - "properties": [ - { - "id": "unit", - "value": "km" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "distance_mi" - }, - "properties": [ - { - "id": "unit", - "value": "mi" - } - ] - } - ] - }, - "gridPos": { - "h": 3, - "w": 3, - "x": 6, - "y": 5 - }, - "id": 24, - "options": { - "colorMode": "value", - "graphMode": "area", - "justifyMode": "auto", - "orientation": "auto", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "auto", - "wideLayout": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "SELECT\r\n convert_km(sum(distance)::numeric, '$length_unit') AS distance_$length_unit\r\nFROM drives\r\nWHERE $__timeFilter(start_date) AND car_id = $car_id", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Total Distance logged", - "type": "stat" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "decimals": 0, - "mappings": [ - { - "options": { - "match": "null", - "result": { - "text": "N/A" - } - }, - "type": "special" - } - ], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "#c7d0d9", - "value": 0 - } - ] - }, - "unit": "none" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "range_km" - }, - "properties": [ - { - "id": "unit", - "value": "lengthkm" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "range_mi" - }, - "properties": [ - { - "id": "unit", - "value": "lengthmi" - } - ] - } - ] - }, - "gridPos": { - "h": 3, - "w": 3, - "x": 0, - "y": 8 - }, - "id": 25, - "maxDataPoints": 100, - "options": { - "colorMode": "value", - "fieldOptions": { - "calcs": [ - "first" - ] - }, - "graphMode": "none", - "justifyMode": "auto", - "orientation": "horizontal", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "mean" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "value", - "wideLayout": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "time_series", - "rawQuery": true, - "rawSql": "SELECT $__time(date), range as \"range_$length_unit\"\nFROM (\n\t(SELECT date, convert_km(${preferred_range}_battery_range_km, '$length_unit') AS range\n\tFROM positions\n\tWHERE car_id = $car_id AND ideal_battery_range_km IS NOT NULL\n ORDER BY date DESC\n\tLIMIT 1)\n\tUNION ALL\n\t(SELECT date, convert_km(${preferred_range}_battery_range_km, '$length_unit') AS range\n\tFROM charges c\n\tJOIN charging_processes p ON p.id = c.charging_process_id\n\tWHERE p.car_id = $car_id\n\tORDER BY date DESC\n\tLIMIT 1)\n) AS data\nORDER BY date DESC\nLIMIT 1;", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Range", - "type": "stat" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "#c7d0d9", - "value": 0 - } - ] - }, - "unit": "none" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "version" - }, - "properties": [ - { - "id": "unit", - "value": "string" - } - ] - } - ] - }, - "gridPos": { - "h": 3, - "w": 3, - "x": 3, - "y": 8 - }, - "id": 2, - "links": [ - { - "targetBlank": true, - "title": "Updates", - "url": "/d/IiC07mgWz/updates" - } - ], - "maxDataPoints": 100, - "options": { - "colorMode": "value", - "fieldOptions": { - "calcs": [ - "first" - ] - }, - "graphMode": "none", - "justifyMode": "auto", - "orientation": "horizontal", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "mean" - ], - "fields": "/^version$/", - "values": true - }, - "showPercentChange": false, - "textMode": "value", - "wideLayout": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "select split_part(version, ' ', 1) as version \nfrom updates \nwhere car_id = $car_id \norder by start_date desc \nlimit 1", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Firmware", - "type": "stat" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "decimals": 0, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "#c7d0d9", - "value": 0 - } - ] - }, - "unit": "none" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "odometer_km" - }, - "properties": [ - { - "id": "unit", - "value": "km" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "odometer_mi" - }, - "properties": [ - { - "id": "unit", - "value": "mi" - } - ] - } - ] - }, - "gridPos": { - "h": 3, - "w": 3, - "x": 6, - "y": 8 - }, - "id": 6, - "links": [ - { - "targetBlank": true, - "title": "Mileage", - "url": "/d/NjtMTFggz/mileage" - } - ], - "maxDataPoints": 100, - "options": { - "colorMode": "value", - "fieldOptions": { - "calcs": [ - "first" - ] - }, - "graphMode": "none", - "justifyMode": "auto", - "orientation": "horizontal", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "mean" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "value", - "wideLayout": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "time_series", - "rawQuery": true, - "rawSql": "select $__time(date), convert_km(odometer::numeric, '$length_unit') as \"odometer_$length_unit\"\nfrom positions \nwhere car_id = $car_id and ideal_battery_range_km is not null\norder by date desc \nlimit 1;", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Odometer", - "type": "stat" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "barWidthFactor": 0.6, - "drawStyle": "line", - "fillOpacity": 10, - "gradientMode": "opacity", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "stepAfter", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": 0 - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Charging Voltage [V]" - }, - "properties": [ - { - "id": "min", - "value": 0 - }, - { - "id": "max", - "value": 250 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "charger_power" - }, - "properties": [ - { - "id": "displayName", - "value": "Power" - }, - { - "id": "unit", - "value": "kwatt" - }, - { - "id": "custom.axisPlacement", - "value": "hidden" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "battery_heater" - }, - "properties": [ - { - "id": "displayName", - "value": "Battery heater" - }, - { - "id": "custom.axisPlacement", - "value": "hidden" - }, - { - "id": "unit", - "value": "bool_on_off" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "charger_actual_current" - }, - "properties": [ - { - "id": "displayName", - "value": "Current" - }, - { - "id": "unit", - "value": "amp" - }, - { - "id": "custom.axisPlacement", - "value": "hidden" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "charge_energy_added" - }, - "properties": [ - { - "id": "displayName", - "value": "Energy added" - }, - { - "id": "unit", - "value": "kwatth" - } - ] - } - ] - }, - "gridPos": { - "h": 7, - "w": 15, - "x": 9, - "y": 8 - }, - "id": 15, - "links": [ - { - "targetBlank": true, - "title": "Charging Stats", - "url": "/d/-pkIkhmRz/charging-stats?${__url_time_range}" - } - ], - "options": { - "legend": { - "calcs": [ - "max", - "min" - ], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "hideZeros": false, - "maxHeight": 600, - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "time_series", - "rawQuery": true, - "rawSql": "SELECT\n $__time(date),\n charger_power,\n (case when battery_heater_on then 1 when battery_heater then 1 else 0 end) as battery_heater,\n charger_actual_current,\n c.charge_energy_added\nFROM\n charges c\njoin\n charging_processes p ON p.id = c.charging_process_id \nWHERE\n $__timeFilter(date) and\n p.car_id = $car_id\nORDER BY\n date ASC", - "refId": "B", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "time_series", - "rawQuery": true, - "rawSql": "SELECT\n $__time(date),\n charger_voltage as \"Charging Voltage [V]\"\nFROM\n charges c\njoin\n charging_processes p ON p.id = c.charging_process_id \nWHERE\n $__timeFilter(date) and\n p.car_id = $car_id\nORDER BY\n date ASC", - "refId": "C", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Charging Details", - "type": "timeseries" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "decimals": 0, - "mappings": [], - "max": 100, - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "semi-dark-green", - "value": 0 - } - ] - }, - "unit": "degree" - }, - "overrides": [] - }, - "gridPos": { - "h": 4, - "w": 3, - "x": 0, - "y": 11 - }, - "id": 16, - "options": { - "minVizHeight": 75, - "minVizWidth": 75, - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "firstNotNull" - ], - "fields": "", - "values": false - }, - "showThresholdLabels": false, - "showThresholdMarkers": true, - "sizing": "auto" - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "SELECT\r\n\t$__time(date),\r\n\tconvert_celsius(driver_temp_setting, '$temp_unit') as \"Driver Temperature [°$temp_unit]\",\r\n\tconvert_celsius(inside_temp, '$temp_unit') AS \"Inside Temperature [°$temp_unit]\"\r\nFROM positions\r\nWHERE driver_temp_setting IS NOT NULL AND inside_temp IS NOT NULL AND car_id = $car_id AND date >= (TIMEZONE('UTC', NOW()) - INTERVAL '60m')\r\nORDER BY date DESC\r\nLIMIT 1;", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Driver Temp", - "transformations": [ - { - "id": "filterFieldsByName", - "options": { - "byVariable": false, - "include": { - "pattern": "time|Driver.*" - } - } - } - ], - "type": "gauge" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "decimals": 0, - "mappings": [], - "max": 100, - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "semi-dark-green", - "value": 0 - } - ] - }, - "unit": "degree" - }, - "overrides": [] - }, - "gridPos": { - "h": 4, - "w": 3, - "x": 3, - "y": 11 - }, - "id": 8, - "options": { - "minVizHeight": 75, - "minVizWidth": 75, - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "firstNotNull" - ], - "fields": "", - "values": false - }, - "showThresholdLabels": false, - "showThresholdMarkers": true, - "sizing": "auto" - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "WITH last_position AS (\n\tSELECT date, convert_celsius(outside_temp, '$temp_unit') AS \"Outside Temperature [°$temp_unit]\"\n\tFROM positions\n\tWHERE car_id = $car_id AND outside_temp IS NOT NULL AND date >= (TIMEZONE('UTC', NOW()) - INTERVAL '60m')\n\tORDER BY date DESC\n\tLIMIT 1\n),\nlast_charge AS (\n\tSELECT date, convert_celsius(outside_temp, '$temp_unit') AS \"Outside Temperature [°$temp_unit]\"\n\tFROM charges\n\tJOIN charging_processes ON charges.charging_process_id = charging_processes.id\n\tWHERE car_id = $car_id AND outside_temp IS NOT NULL AND date >= (TIMEZONE('UTC', NOW()) - INTERVAL '60m')\n\tORDER BY date DESC\n\tLIMIT 1\n)\nSELECT * FROM last_position\nUNION ALL\nSELECT * FROM last_charge\nORDER BY date DESC\nLIMIT 1;", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Outside Temp", - "type": "gauge" - }, - { - "datasource": { - "type": "datasource", - "uid": "-- Dashboard --" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "decimals": 0, - "mappings": [], - "max": 100, - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "semi-dark-green", - "value": 0 - } - ] - }, - "unit": "degree" - }, - "overrides": [] - }, - "gridPos": { - "h": 4, - "w": 3, - "x": 6, - "y": 11 - }, - "id": 9, - "options": { - "minVizHeight": 75, - "minVizWidth": 75, - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "firstNotNull" - ], - "fields": "", - "values": false - }, - "showThresholdLabels": false, - "showThresholdMarkers": true, - "sizing": "auto" - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "datasource", - "uid": "-- Dashboard --" - }, - "panelId": 16, - "refId": "A" - } - ], - "title": "Inside Temp", - "transformations": [ - { - "id": "filterFieldsByName", - "options": { - "include": { - "pattern": "time|Inside.*" - } - } - } - ], - "type": "gauge" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "continuous-GrYlRd" - }, - "custom": { - "axisPlacement": "auto", - "fillOpacity": 100, - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineWidth": 0, - "spanNulls": false - }, - "mappings": [ - { - "options": { - "0": { - "color": "#6ED0E0", - "index": 0, - "text": "online" - }, - "1": { - "color": "#8F3BB8", - "index": 1, - "text": "driving" - }, - "2": { - "color": "#F2CC0C", - "index": 2, - "text": "charging" - }, - "3": { - "color": "#FFB357", - "index": 3, - "text": "offline" - }, - "4": { - "color": "#56A64B", - "index": 4, - "text": "asleep" - }, - "5": { - "color": "#6ED0E0", - "index": 5, - "text": "online" - }, - "6": { - "color": "#E02F44", - "index": 6, - "text": "updating" - }, - "null": { - "index": 7, - "text": "N/A" - } - }, - "type": "value" - } - ], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": 0 - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 5, - "w": 24, - "x": 0, - "y": 15 - }, - "id": 20, - "links": [ - { - "targetBlank": true, - "title": "States", - "url": "/d/xo4BNRkZz/states?${__url_time_range}" - } - ], - "options": { - "alignValue": "center", - "legend": { - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "mergeValues": true, - "rowHeight": 0.9, - "showValue": "auto", - "tooltip": { - "hideZeros": false, - "maxHeight": 600, - "mode": "single", - "sort": "none" - } - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "time_series", - "rawQuery": true, - "rawSql": "WITH states AS (\n SELECT\n unnest(ARRAY [start_date + interval '1 second', end_date]) AS date,\n unnest(ARRAY [2, 0]) AS state\n FROM charging_processes\n WHERE\n car_id = $car_id AND \n ($__timeFrom() :: timestamp - interval '30 day') < start_date AND \n (end_date < ($__timeTo() :: timestamp + interval '30 day') OR end_date IS NULL)\n UNION\n SELECT\n unnest(ARRAY [start_date + interval '1 second', end_date]) AS date,\n unnest(ARRAY [1, 0]) AS state\n FROM drives\n WHERE\n car_id = $car_id AND \n ($__timeFrom() :: timestamp - interval '30 day') < start_date AND \n (end_date < ($__timeTo() :: timestamp + interval '30 day') OR end_date IS NULL)\n UNION\n SELECT\n start_date AS date,\n CASE\n WHEN state = 'offline' THEN 3\n WHEN state = 'asleep' THEN 4\n WHEN state = 'online' THEN 5\n END AS state\n FROM states\n WHERE\n car_id = $car_id AND \n ($__timeFrom() :: timestamp - interval '30 day') < start_date AND \n (end_date < ($__timeTo() :: timestamp + interval '30 day') OR end_date IS NULL)\n UNION\n SELECT\n unnest(ARRAY [start_date + interval '1 second', end_date]) AS date,\n unnest(ARRAY [6, 0]) AS state\n FROM updates\n WHERE\n car_id = $car_id AND \n ($__timeFrom() :: timestamp - interval '30 day') < start_date AND \n (end_date < ($__timeTo() :: timestamp + interval '30 day') OR end_date IS NULL)\n)\nSELECT date AS \"time\", state\nFROM states\nWHERE \n date IS NOT NULL AND\n ($__timeFrom() :: timestamp - interval '30 day') < date AND \n date < ($__timeTo() :: timestamp + interval '30 day') \nORDER BY date ASC, state ASC;", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "States", - "type": "state-timeline" - } - ], - "preload": false, - "refresh": "30s", - "schemaVersion": 41, - "tags": [ - "tesla" - ], - "templating": { - "list": [ - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "SELECT\n id as __value,\n CASE WHEN COUNT(id) OVER (PARTITION BY name) > 1 AND name IS NOT NULL THEN CONCAT(name, ' - ', RIGHT(vin, 6)) ELSE COALESCE(name, CONCAT('VIN ', vin)) end as __text \nFROM cars\nORDER BY display_priority ASC, name ASC, vin ASC;", - "hide": 2, - "includeAll": true, - "label": "Car", - "name": "car_id", - "options": [], - "query": "SELECT\n id as __value,\n CASE WHEN COUNT(id) OVER (PARTITION BY name) > 1 AND name IS NOT NULL THEN CONCAT(name, ' - ', RIGHT(vin, 6)) ELSE COALESCE(name, CONCAT('VIN ', vin)) end as __text \nFROM cars\nORDER BY display_priority ASC, name ASC, vin ASC;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "select unit_of_length from settings limit 1;", - "hide": 2, - "includeAll": false, - "name": "length_unit", - "options": [], - "query": "select unit_of_length from settings limit 1;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "select unit_of_temperature from settings limit 1;", - "hide": 2, - "includeAll": false, - "name": "temp_unit", - "options": [], - "query": "select unit_of_temperature from settings limit 1;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "select preferred_range from settings limit 1;", - "hide": 2, - "includeAll": false, - "name": "preferred_range", - "options": [], - "query": "select preferred_range from settings limit 1;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "select base_url from settings limit 1;", - "hide": 2, - "includeAll": false, - "name": "base_url", - "options": [], - "query": "select base_url from settings limit 1;", - "refresh": 1, - "regex": "", - "type": "query" - } - ] - }, - "time": { - "from": "now-24h", - "to": "now" - }, - "timepicker": {}, - "timezone": "", - "title": "Tesla Overview", - "uid": "kOuP_Fggz", - "version": 1 - } diff --git a/argocd/manifests/grafana-config/dashboards/configmap-teslamate-projected-range.yaml b/argocd/manifests/grafana-config/dashboards/configmap-teslamate-projected-range.yaml deleted file mode 100644 index 33e5a24..0000000 --- a/argocd/manifests/grafana-config/dashboards/configmap-teslamate-projected-range.yaml +++ /dev/null @@ -1,772 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: grafana-dashboard-teslamate-projected-range - namespace: monitoring - labels: - grafana_dashboard: "1" - annotations: - grafana_folder: "TeslaMate" -data: - teslamate-projected-range.json: | - { - "annotations": { - "list": [ - { - "builtIn": 1, - "datasource": { - "type": "grafana", - "uid": "-- Grafana --" - }, - "enable": true, - "hide": true, - "iconColor": "rgba(0, 211, 255, 1)", - "name": "Annotations & Alerts", - "type": "dashboard" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "enable": false, - "hide": false, - "iconColor": "rgba(255, 96, 96, 1)", - "limit": 100, - "name": "Charged", - "rawQuery": "SELECT\n$__time(start_date),\nend_date as timeend,\nconcat('Charged: ',round(cast(charge_energy_added as numeric),2),' kWh') AS text\nFROM charging_processes\nWHERE\n$__timeFilter(start_date) AND duration_min > 5\nORDER BY start_date DESC", - "showIn": 0, - "tags": [], - "type": "tags" - } - ] - }, - "editable": true, - "fiscalYearStartMonth": 0, - "graphTooltip": 1, - "links": [ - { - "icon": "dashboard", - "tags": [], - "title": "TeslaMate", - "tooltip": "", - "type": "link", - "url": "${base_url:raw}" - }, - { - "asDropdown": true, - "icon": "external link", - "tags": [ - "tesla" - ], - "title": "Dashboards", - "type": "dashboards" - } - ], - "panels": [ - { - "collapsed": false, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 0 - }, - "id": 4, - "panels": [], - "repeat": "car_id", - "title": "$car_id", - "type": "row" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "Projected Range", - "axisPlacement": "auto", - "barAlignment": 0, - "barWidthFactor": 0.6, - "drawStyle": "line", - "fillOpacity": 30, - "gradientMode": "opacity", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "stepAfter", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "decimals": 0, - "links": [], - "mappings": [], - "min": 200, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": 0 - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "none" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/Mileage.*/" - }, - "properties": [ - { - "id": "custom.fillOpacity", - "value": 0 - }, - { - "id": "custom.axisLabel", - "value": "Mileage" - }, - { - "id": "min" - } - ] - } - ] - }, - "gridPos": { - "h": 21, - "w": 24, - "x": 0, - "y": 1 - }, - "id": 2, - "options": { - "legend": { - "calcs": [ - "mean", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "hideZeros": false, - "maxHeight": 600, - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "time_series", - "rawQuery": true, - "rawSql": "SELECT\n\t$__timeGroup(date, $interval) AS time,\n\tconvert_km((sum(${preferred_range}_battery_range_km) / nullif(sum(coalesce(usable_battery_level,battery_level)),0) * 100)::numeric, '$length_unit') AS \"Projected ${preferred_range} range [$length_unit]\"\nFROM\n\t(\n select battery_level, usable_battery_level, date,\n rated_battery_range_km, ideal_battery_range_km, outside_temp\n from positions\n where\n car_id = $car_id and $__timeFilter(date) and ideal_battery_range_km is not null\n union all\n select battery_level, coalesce(usable_battery_level,battery_level) as usable_battery_level, date,\n rated_battery_range_km, ideal_battery_range_km, outside_temp\n from charges c\n join\n charging_processes p ON p.id = c.charging_process_id \n where\n $__timeFilter(date) and p.car_id = $car_id\n ) as data\n\nGROUP BY\n\t1\nhaving convert_km((sum(${preferred_range}_battery_range_km) / nullif(sum(coalesce(usable_battery_level,battery_level)),0) * 100)::numeric, '$length_unit') is not null\nORDER BY\n\t1,2 DESC", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "time_series", - "rawQuery": true, - "rawSql": "SELECT\n\t$__timeGroup(date, $interval) AS time,\n\tconvert_km(avg(odometer)::numeric, '$length_unit') AS \"Mileage [$length_unit]\"\nFROM\n\tpositions\nWHERE\n\t$__timeFilter(date) and\n\tcar_id = $car_id and ideal_battery_range_km is not null\nGROUP BY\n\t1\nORDER BY\n\t1,2 DESC;", - "refId": "B", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Projected Range - Mileage", - "type": "timeseries" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "Projected Range", - "axisPlacement": "auto", - "barAlignment": 0, - "barWidthFactor": 0.6, - "drawStyle": "line", - "fillOpacity": 30, - "gradientMode": "opacity", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "stepAfter", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "decimals": 0, - "links": [], - "mappings": [], - "min": 200, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": 0 - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "none" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/Battery.*/" - }, - "properties": [ - { - "id": "custom.fillOpacity", - "value": 0 - }, - { - "id": "max", - "value": 100 - }, - { - "id": "custom.axisLabel", - "value": "Battery Level" - } - ] - } - ] - }, - "gridPos": { - "h": 21, - "w": 24, - "x": 0, - "y": 22 - }, - "id": 6, - "options": { - "legend": { - "calcs": [ - "mean", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "hideZeros": false, - "maxHeight": 600, - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "time_series", - "rawQuery": true, - "rawSql": "SELECT\n\t$__timeGroup(date, $interval) AS time,\n\tconvert_km((sum(${preferred_range}_battery_range_km) / sum(battery_level) * 100 ) - (sum(${preferred_range}_battery_range_km) / sum(battery_level) * 100 * (avg(battery_level)-avg(coalesce(usable_battery_level,battery_level)))/100 ), '$length_unit') AS \"Projected Range (using usable_battery_level) [$length_unit]\",\n\tconvert_km(max(${preferred_range}_battery_range_km) / max(battery_level) * 100, '$length_unit') AS \"Projected Range (using battery_level)[$length_unit]\"\nFROM\n\t(\n select battery_level, usable_battery_level, date,\n rated_battery_range_km, ideal_battery_range_km, outside_temp\n from positions\n where\n car_id = $car_id and $__timeFilter(date) and ideal_battery_range_km is not null\n union all\n select battery_level, coalesce(usable_battery_level,battery_level) as usable_battery_level, date,\n rated_battery_range_km, ideal_battery_range_km, outside_temp\n from charges c\n join\n charging_processes p ON p.id = c.charging_process_id \n where\n $__timeFilter(date) and p.car_id = $car_id\n ) as data\n\nGROUP BY\n\t1\nORDER BY\n\t1,2 DESC", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "time_series", - "rawQuery": true, - "rawSql": "select \n\t$__timeGroup(date, $interval) AS time,\n avg(battery_level) AS \"Battery Level [%]\", avg(coalesce(usable_battery_level, battery_level)) as \"Usable Battery Level [%]\"\nfrom\n (SELECT\n battery_level, usable_battery_level\n , date\n FROM\n positions\n WHERE\n car_id = $car_id AND\n $__timeFilter(date) and ideal_battery_range_km is not null\n UNION ALL\n select\n battery_level, null as usable_battery_level\n , date\n from charges c\njoin\n charging_processes p ON p.id = c.charging_process_id \nWHERE\n $__timeFilter(date) and\n p.car_id = $car_id) as data\n\nGROUP BY\n 1\nORDER BY\n 1 ASC", - "refId": "B", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Projected Range - Battery Level", - "type": "timeseries" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "Projected Range", - "axisPlacement": "auto", - "barAlignment": 0, - "barWidthFactor": 0.6, - "drawStyle": "line", - "fillOpacity": 30, - "gradientMode": "opacity", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "stepAfter", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": true, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "decimals": 0, - "links": [], - "mappings": [], - "min": 200, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": 0 - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "none" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/Temp.*/" - }, - "properties": [ - { - "id": "custom.fillOpacity", - "value": 0 - }, - { - "id": "custom.axisLabel", - "value": "Temp" - }, - { - "id": "min" - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*using usable_battery_level.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#56A64B", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*using battery_level.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#C8F2C2", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 21, - "w": 24, - "x": 0, - "y": 43 - }, - "id": 5, - "options": { - "legend": { - "calcs": [ - "mean", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "hideZeros": false, - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "time_series", - "rawQuery": true, - "rawSql": "\nSELECT\n\t$__timeGroup(date, $interval) AS time,\n\tconvert_km((sum(${preferred_range}_battery_range_km) / sum(battery_level) * 100 ) - (sum(${preferred_range}_battery_range_km) / sum(battery_level) * 100 * (avg(battery_level)-avg(coalesce(usable_battery_level,battery_level)))/100 ), '$length_unit') AS \"Projected Range (using usable_battery_level) [$length_unit]\",\n\tconvert_km(max(${preferred_range}_battery_range_km) / max(battery_level) * 100, '$length_unit') AS \"Projected Range (using battery_level)[$length_unit]\"\nFROM\n\t(\n select battery_level, usable_battery_level, date,\n rated_battery_range_km, ideal_battery_range_km, outside_temp\n from positions\n where\n car_id = $car_id and $__timeFilter(date) and ideal_battery_range_km is not null\n union all\n select battery_level, coalesce(usable_battery_level,battery_level) as usable_battery_level, date,\n rated_battery_range_km, ideal_battery_range_km, outside_temp\n from charges c\n join\n charging_processes p ON p.id = c.charging_process_id \n where\n $__timeFilter(date) and p.car_id = $car_id\n ) as data\n\nGROUP BY\n\t1\nORDER BY\n\t1,2 DESC", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "time_series", - "rawQuery": true, - "rawSql": "SELECT\n\t$__timeGroup(date, $interval) AS time,\n\tavg(convert_celsius(outside_temp, '$temp_unit')) as \"Outdoor Temperature [°$temp_unit]\"\n\nFROM\n\tpositions\nWHERE\n\t$__timeFilter(date) and\n\tcar_id = $car_id and ideal_battery_range_km is not null\nGROUP BY\n\t1\nORDER BY\n\t1,2 DESC", - "refId": "B", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Projected Range - Outdoor Temp", - "type": "timeseries" - } - ], - "preload": false, - "refresh": "", - "schemaVersion": 41, - "tags": [ - "tesla" - ], - "templating": { - "list": [ - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "SELECT\n id as __value,\n CASE WHEN COUNT(id) OVER (PARTITION BY name) > 1 AND name IS NOT NULL THEN CONCAT(name, ' - ', RIGHT(vin, 6)) ELSE COALESCE(name, CONCAT('VIN ', vin)) end as __text \nFROM cars\nORDER BY display_priority ASC, name ASC, vin ASC;", - "hide": 2, - "includeAll": true, - "label": "Car", - "name": "car_id", - "options": [], - "query": "SELECT\n id as __value,\n CASE WHEN COUNT(id) OVER (PARTITION BY name) > 1 AND name IS NOT NULL THEN CONCAT(name, ' - ', RIGHT(vin, 6)) ELSE COALESCE(name, CONCAT('VIN ', vin)) end as __text \nFROM cars\nORDER BY display_priority ASC, name ASC, vin ASC;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "select unit_of_length from settings limit 1;", - "hide": 2, - "includeAll": false, - "name": "length_unit", - "options": [], - "query": "select unit_of_length from settings limit 1;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "select preferred_range from settings limit 1;", - "hide": 2, - "includeAll": false, - "name": "preferred_range", - "options": [], - "query": "select preferred_range from settings limit 1;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "select unit_of_temperature from settings limit 1;", - "hide": 2, - "includeAll": false, - "name": "temp_unit", - "options": [], - "query": "select unit_of_temperature from settings limit 1;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "select base_url from settings limit 1;", - "hide": 2, - "includeAll": false, - "name": "base_url", - "options": [], - "query": "select base_url from settings limit 1;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "auto": false, - "auto_count": 30, - "auto_min": "10s", - "current": { - "text": "6h", - "value": "6h" - }, - "hide": 1, - "label": "Time Resolution", - "name": "interval", - "options": [ - { - "selected": false, - "text": "5m", - "value": "5m" - }, - { - "selected": false, - "text": "15m", - "value": "15m" - }, - { - "selected": false, - "text": "30m", - "value": "30m" - }, - { - "selected": false, - "text": "1h", - "value": "1h" - }, - { - "selected": false, - "text": "3h", - "value": "3h" - }, - { - "selected": true, - "text": "6h", - "value": "6h" - } - ], - "query": "5m,15m,30m,1h,3h,6h", - "refresh": 2, - "type": "interval" - } - ] - }, - "time": { - "from": "now-6M", - "to": "now" - }, - "timepicker": {}, - "timezone": "", - "title": "Tesla Projected Range", - "uid": "riqUfXgRz", - "version": 1 - } diff --git a/argocd/manifests/grafana-config/dashboards/configmap-teslamate-states.yaml b/argocd/manifests/grafana-config/dashboards/configmap-teslamate-states.yaml deleted file mode 100644 index 37b4806..0000000 --- a/argocd/manifests/grafana-config/dashboards/configmap-teslamate-states.yaml +++ /dev/null @@ -1,528 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: grafana-dashboard-teslamate-states - namespace: monitoring - labels: - grafana_dashboard: "1" - annotations: - grafana_folder: "TeslaMate" -data: - teslamate-states.json: | - { - "annotations": { - "list": [ - { - "builtIn": 1, - "datasource": { - "type": "grafana", - "uid": "-- Grafana --" - }, - "enable": true, - "hide": true, - "iconColor": "rgba(0, 211, 255, 1)", - "name": "Annotations & Alerts", - "type": "dashboard" - } - ] - }, - "editable": true, - "fiscalYearStartMonth": 0, - "graphTooltip": 0, - "links": [ - { - "icon": "dashboard", - "tags": [], - "title": "TeslaMate", - "tooltip": "", - "type": "link", - "url": "${base_url:raw}" - }, - { - "asDropdown": true, - "icon": "external link", - "tags": [ - "tesla" - ], - "title": "Dashboards", - "type": "dashboards" - } - ], - "panels": [ - { - "collapsed": false, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 0 - }, - "id": 16, - "panels": [], - "repeat": "car_id", - "title": "$car_id", - "type": "row" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "description": "Only distinguishes between online, offline and asleep.", - "fieldConfig": { - "defaults": { - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "#c7d0d9", - "value": 0 - } - ] - }, - "unit": "dateTimeAsLocal" - }, - "overrides": [] - }, - "gridPos": { - "h": 3, - "w": 12, - "x": 0, - "y": 1 - }, - "id": 2, - "maxDataPoints": 100, - "options": { - "colorMode": "value", - "fieldOptions": { - "calcs": [ - "mean" - ] - }, - "graphMode": "none", - "justifyMode": "auto", - "orientation": "horizontal", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "mean" - ], - "fields": "/^time$/", - "values": true - }, - "showPercentChange": false, - "textMode": "value", - "wideLayout": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "select $__time(start_date), state from states where car_id = $car_id order by start_date desc limit 1;", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Last state change", - "type": "stat" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "description": "Only distinguishes between online, offline and asleep.", - "fieldConfig": { - "defaults": { - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "#c7d0d9", - "value": 0 - } - ] - }, - "unit": "none" - }, - "overrides": [] - }, - "gridPos": { - "h": 3, - "w": 6, - "x": 12, - "y": 1 - }, - "id": 6, - "maxDataPoints": 100, - "options": { - "colorMode": "value", - "fieldOptions": { - "calcs": [ - "first" - ] - }, - "graphMode": "none", - "justifyMode": "auto", - "orientation": "horizontal", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "mean" - ], - "fields": "/^state$/", - "values": true - }, - "showPercentChange": false, - "textMode": "value", - "wideLayout": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "select $__time(start_date), state from states where car_id = $car_id order by start_date desc limit 1;", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Current State", - "type": "stat" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "description": "based on any data ever recorded.", - "fieldConfig": { - "defaults": { - "mappings": [ - { - "options": { - "match": "null", - "result": { - "text": "N/A" - } - }, - "type": "special" - } - ], - "max": 1, - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "#c7d0d9", - "value": 0 - } - ] - }, - "unit": "percentunit" - }, - "overrides": [] - }, - "gridPos": { - "h": 3, - "w": 6, - "x": 18, - "y": 1 - }, - "id": 8, - "maxDataPoints": 100, - "options": { - "colorMode": "value", - "fieldOptions": { - "calcs": [ - "lastNotNull" - ] - }, - "graphMode": "none", - "justifyMode": "auto", - "orientation": "horizontal", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "mean" - ], - "fields": "", - "values": true - }, - "showPercentChange": false, - "textMode": "value", - "wideLayout": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "time_series", - "rawQuery": true, - "rawSql": "select 1 - sum(duration_min) / (EXTRACT(EPOCH FROM (max(end_date) - min(start_date))) / 60), 1 as time from drives where car_id = $car_id;", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "parked (%)", - "type": "stat" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "continuous-GrYlRd" - }, - "custom": { - "axisPlacement": "auto", - "fillOpacity": 100, - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineWidth": 0, - "spanNulls": false - }, - "mappings": [ - { - "options": { - "0": { - "color": "#6ED0E0", - "index": 0, - "text": "online" - }, - "1": { - "color": "#8F3BB8", - "index": 1, - "text": "driving" - }, - "2": { - "color": "#F2CC0C", - "index": 2, - "text": "charging" - }, - "3": { - "color": "#FFB357", - "index": 3, - "text": "offline" - }, - "4": { - "color": "#56A64B", - "index": 4, - "text": "asleep" - }, - "5": { - "color": "#6ED0E0", - "index": 5, - "text": "online" - }, - "6": { - "color": "#E02F44", - "index": 6, - "text": "updating" - }, - "null": { - "index": 7, - "text": "N/A" - } - }, - "type": "value" - } - ], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": 0 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 7, - "w": 24, - "x": 0, - "y": 4 - }, - "id": 14, - "options": { - "alignValue": "center", - "legend": { - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "mergeValues": true, - "rowHeight": 0.9, - "showValue": "auto", - "tooltip": { - "hideZeros": false, - "maxHeight": 600, - "mode": "single", - "sort": "none" - } - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "time_series", - "rawQuery": true, - "rawSql": "WITH states AS (\n SELECT\n unnest(ARRAY [start_date + interval '1 second', end_date]) AS date,\n unnest(ARRAY [2, 0]) AS state\n FROM charging_processes\n WHERE\n car_id = $car_id AND \n ($__timeFrom() :: timestamp - interval '30 day') < start_date AND \n (end_date < ($__timeTo() :: timestamp + interval '30 day') OR end_date IS NULL)\n UNION\n SELECT\n unnest(ARRAY [start_date + interval '1 second', end_date]) AS date,\n unnest(ARRAY [1, 0]) AS state\n FROM drives\n WHERE\n car_id = $car_id AND \n ($__timeFrom() :: timestamp - interval '30 day') < start_date AND \n (end_date < ($__timeTo() :: timestamp + interval '30 day') OR end_date IS NULL)\n UNION\n SELECT\n start_date AS date,\n CASE\n WHEN state = 'offline' THEN 3\n WHEN state = 'asleep' THEN 4\n WHEN state = 'online' THEN 5\n END AS state\n FROM states\n WHERE\n car_id = $car_id AND \n ($__timeFrom() :: timestamp - interval '30 day') < start_date AND \n (end_date < ($__timeTo() :: timestamp + interval '30 day') OR end_date IS NULL)\n UNION\n SELECT\n unnest(ARRAY [start_date + interval '1 second', end_date]) AS date,\n unnest(ARRAY [6, 0]) AS state\n FROM updates\n WHERE\n car_id = $car_id AND \n ($__timeFrom() :: timestamp - interval '30 day') < start_date AND \n (end_date < ($__timeTo() :: timestamp + interval '30 day') OR end_date IS NULL)\n)\nSELECT date AS \"time\", state\nFROM states\nWHERE \n date IS NOT NULL AND\n ($__timeFrom() :: timestamp - interval '30 day') < date AND \n date < ($__timeTo() :: timestamp + interval '30 day') \nORDER BY date ASC, state ASC;", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "States", - "type": "state-timeline" - } - ], - "preload": false, - "refresh": "", - "schemaVersion": 41, - "tags": [ - "tesla" - ], - "templating": { - "list": [ - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "SELECT\n id as __value,\n CASE WHEN COUNT(id) OVER (PARTITION BY name) > 1 AND name IS NOT NULL THEN CONCAT(name, ' - ', RIGHT(vin, 6)) ELSE COALESCE(name, CONCAT('VIN ', vin)) end as __text \nFROM cars\nORDER BY display_priority ASC, name ASC, vin ASC;", - "hide": 2, - "includeAll": true, - "label": "Car", - "name": "car_id", - "options": [], - "query": "SELECT\n id as __value,\n CASE WHEN COUNT(id) OVER (PARTITION BY name) > 1 AND name IS NOT NULL THEN CONCAT(name, ' - ', RIGHT(vin, 6)) ELSE COALESCE(name, CONCAT('VIN ', vin)) end as __text \nFROM cars\nORDER BY display_priority ASC, name ASC, vin ASC;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "select base_url from settings limit 1;", - "hide": 2, - "includeAll": false, - "name": "base_url", - "options": [], - "query": "select base_url from settings limit 1;", - "refresh": 1, - "regex": "", - "type": "query" - } - ] - }, - "time": { - "from": "now-2d", - "to": "now" - }, - "timepicker": {}, - "timezone": "", - "title": "Tesla States", - "uid": "xo4BNRkZz", - "version": 1 - } diff --git a/argocd/manifests/grafana-config/dashboards/configmap-teslamate-statistics.yaml b/argocd/manifests/grafana-config/dashboards/configmap-teslamate-statistics.yaml deleted file mode 100644 index 9cec1a6..0000000 --- a/argocd/manifests/grafana-config/dashboards/configmap-teslamate-statistics.yaml +++ /dev/null @@ -1,1262 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: grafana-dashboard-teslamate-statistics - namespace: monitoring - labels: - grafana_dashboard: "1" - annotations: - grafana_folder: "TeslaMate" -data: - teslamate-statistics.json: | - { - "annotations": { - "list": [ - { - "builtIn": 1, - "datasource": { - "type": "grafana", - "uid": "-- Grafana --" - }, - "enable": true, - "hide": true, - "iconColor": "rgba(0, 211, 255, 1)", - "name": "Annotations & Alerts", - "type": "dashboard" - } - ] - }, - "editable": true, - "fiscalYearStartMonth": 0, - "graphTooltip": 0, - "links": [ - { - "icon": "dashboard", - "tags": [], - "title": "TeslaMate", - "type": "link", - "url": "${base_url:raw}" - }, - { - "asDropdown": true, - "icon": "external link", - "tags": [ - "tesla" - ], - "title": "Dashboards", - "type": "dashboards" - } - ], - "panels": [ - { - "collapsed": false, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 0 - }, - "id": 4, - "panels": [], - "repeat": "car_id", - "title": "$car_id", - "type": "row" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "description": "", - "fieldConfig": { - "defaults": { - "custom": { - "align": "auto", - "cellOptions": { - "type": "auto" - }, - "filterable": false, - "inspect": false - }, - "mappings": [], - "noValue": "--", - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "red", - "value": 0 - } - ] - } - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Time driven" - }, - "properties": [ - { - "id": "unit", - "value": "dtdurations" - }, - { - "id": "decimals", - "value": 1 - }, - { - "id": "custom.minWidth", - "value": 170 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Period" - }, - "properties": [ - { - "id": "links", - "value": [ - { - "targetBlank": true, - "title": "Trip", - "url": "/d/FkUpJpQZk/trip?from=${__data.fields.date_from}&to=${__data.fields.date_to}&var-car_id=$car_id" - } - ] - }, - { - "id": "custom.minWidth", - "value": 195 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Driving Efficiency" - }, - "properties": [ - { - "id": "unit", - "value": "percentunit" - }, - { - "id": "thresholds", - "value": { - "mode": "absolute", - "steps": [ - { - "color": "super-light-orange", - "value": 0 - }, - { - "color": "light-orange", - "value": 0.65 - }, - { - "color": "light-green", - "value": 0.99 - } - ] - } - }, - { - "id": "max", - "value": 1.15 - }, - { - "id": "min", - "value": 0 - }, - { - "id": "custom.cellOptions", - "value": { - "mode": "lcd", - "type": "gauge" - } - }, - { - "id": "decimals" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Energy used" - }, - "properties": [ - { - "id": "decimals", - "value": 1 - }, - { - "id": "links", - "value": [ - { - "targetBlank": true, - "title": "Charging stats", - "url": "/d/-pkIkhmRz/charging-stats?from=${__data.fields.date_from}&to=${__data.fields.date_to}&var-car_id=$car_id" - } - ] - }, - { - "id": "unit", - "value": "kwatth" - }, - { - "id": "custom.minWidth", - "value": 120 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Ø Energy used / Charge" - }, - "properties": [ - { - "id": "unit", - "value": "kwatth" - }, - { - "id": "decimals", - "value": 1 - }, - { - "id": "custom.minWidth", - "value": 190 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Costs" - }, - "properties": [ - { - "id": "decimals", - "value": 2 - }, - { - "id": "custom.minWidth", - "value": 75 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "# of Charges" - }, - "properties": [ - { - "id": "links", - "value": [ - { - "targetBlank": true, - "title": "Charges", - "url": "/d/TSmNYvRRk/charges?from=${__data.fields.date_from}&to=${__data.fields.date_to}&var-car_id=$car_id" - } - ] - }, - { - "id": "custom.minWidth", - "value": 110 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "# of Drives" - }, - "properties": [ - { - "id": "links", - "value": [ - { - "targetBlank": true, - "title": "Drives", - "url": "/d/Y8upc6ZRk/drives?from=${__data.fields.date_from}&to=${__data.fields.date_to}&var-car_id=$car_id" - } - ] - }, - { - "id": "custom.minWidth", - "value": 95 - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/sum_distance_km/" - }, - "properties": [ - { - "id": "unit", - "value": "km" - }, - { - "id": "displayName", - "value": "Distance" - }, - { - "id": "custom.minWidth", - "value": 100 - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/avg_outside_temp_c/" - }, - "properties": [ - { - "id": "unit", - "value": "celsius" - }, - { - "id": "displayName", - "value": "Ø Temp" - }, - { - "id": "thresholds", - "value": { - "mode": "absolute", - "steps": [ - { - "color": "super-light-blue", - "value": 0 - }, - { - "color": "super-light-green", - "value": 10 - }, - { - "color": "super-light-red", - "value": 20 - } - ] - } - }, - { - "id": "custom.cellOptions", - "value": { - "type": "color-text" - } - }, - { - "id": "custom.minWidth", - "value": 80 - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/sum_distance_mi/" - }, - "properties": [ - { - "id": "displayName", - "value": "Distance" - }, - { - "id": "unit", - "value": "mi" - }, - { - "id": "custom.minWidth", - "value": 100 - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/consumption_net_mi/" - }, - "properties": [ - { - "id": "unit", - "value": "Wh/mi" - }, - { - "id": "custom.width", - "value": 170 - }, - { - "id": "displayName", - "value": "Ø Consumption (net)" - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/consumption_gross_mi/" - }, - "properties": [ - { - "id": "displayName", - "value": "Ø Consumption (gross)" - }, - { - "id": "unit", - "value": "Wh/mi" - }, - { - "id": "custom.width", - "value": 190 - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/consumption_net_km/" - }, - "properties": [ - { - "id": "displayName", - "value": "Ø Consumption (net)" - }, - { - "id": "unit", - "value": "Wh/km" - }, - { - "id": "custom.width", - "value": 170 - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/consumption_gross_km/" - }, - "properties": [ - { - "id": "displayName", - "value": "Ø Consumption (gross)" - }, - { - "id": "unit", - "value": "Wh/km" - }, - { - "id": "custom.width", - "value": 190 - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/avg_outside_temp_f/" - }, - "properties": [ - { - "id": "displayName", - "value": "Ø Temp" - }, - { - "id": "unit", - "value": "fahrenheit" - }, - { - "id": "custom.cellOptions", - "value": { - "type": "color-text" - } - }, - { - "id": "thresholds", - "value": { - "mode": "absolute", - "steps": [ - { - "color": "super-light-blue", - "value": 0 - }, - { - "color": "super-light-green", - "value": 50 - }, - { - "color": "super-light-red", - "value": 68 - } - ] - } - }, - { - "id": "custom.minWidth", - "value": 80 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "date_from" - }, - "properties": [ - { - "id": "custom.hidden", - "value": true - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "date_to" - }, - "properties": [ - { - "id": "custom.hidden", - "value": true - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Ø Cost / kWh" - }, - "properties": [ - { - "id": "decimals", - "value": 2 - }, - { - "id": "mappings", - "value": [ - { - "options": { - "NaN": { - "index": 0, - "text": "--" - } - }, - "type": "value" - } - ] - }, - { - "id": "custom.minWidth", - "value": 115 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Ø Cost / 100 km" - }, - "properties": [ - { - "id": "decimals", - "value": 2 - }, - { - "id": "custom.minWidth", - "value": 135 - }, - { - "id": "mappings", - "value": [ - { - "options": { - "NaN": { - "index": 0, - "text": "--" - } - }, - "type": "value" - } - ] - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Consumption OH" - }, - "properties": [ - { - "id": "custom.minWidth", - "value": 140 - }, - { - "id": "unit", - "value": "percentunit" - }, - { - "id": "decimals", - "value": 0 - }, - { - "id": "mappings", - "value": [ - { - "options": { - "NaN": { - "index": 0, - "text": "--" - } - }, - "type": "value" - } - ] - } - ] - } - ] - }, - "gridPos": { - "h": 18, - "w": 24, - "x": 0, - "y": 1 - }, - "id": 2, - "maxPerRow": 2, - "options": { - "cellHeight": "sm", - "footer": { - "countRows": false, - "fields": "", - "reducer": [ - "sum" - ], - "show": false - }, - "frameIndex": 1, - "showHeader": true, - "sortBy": [ - { - "desc": true, - "displayName": "Starting at" - } - ] - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "WITH data AS (\nSELECT\n duration_min > 1 AND\n distance > 1 AND\n ( \n start_position.usable_battery_level IS NULL OR\n (end_position.battery_level - end_position.usable_battery_level) = 0 \n ) AS is_sufficiently_precise,\n start_${preferred_range}_range_km - end_${preferred_range}_range_km AS range_diff,\n date_trunc('$period', timezone('UTC', start_date), '$__timezone') as date,\n drives.*\nFROM drives\n LEFT JOIN positions start_position ON start_position_id = start_position.id\n LEFT JOIN positions end_position ON end_position_id = end_position.id)\nSELECT\n EXTRACT(EPOCH FROM date)*1000 AS date_from,\n EXTRACT(EPOCH FROM date + interval '1 $period')*1000 AS date_to,\n CASE '$period'\n WHEN 'month' THEN to_char(timezone('$__timezone', date), 'YYYY Month')\n WHEN 'year' THEN to_char(timezone('$__timezone', date), 'YYYY')\n WHEN 'week' THEN 'week ' || to_char(timezone('$__timezone', date), 'WW') || ' starting ' || to_char(timezone('$__timezone', date), 'YYYY-MM-DD')\n ELSE to_char(timezone('$__timezone', date), 'YYYY-MM-DD')\n END AS display,\n date,\n sum(duration_min)*60 AS sum_duration_h, \n convert_km(max(end_km)::numeric - min(start_km)::numeric, '$length_unit') AS sum_distance_$length_unit,\n convert_celsius(avg(outside_temp_avg), '$temp_unit') AS avg_outside_temp_$temp_unit,\n count(*) AS cnt,\n case when sum(range_diff) > 0 then sum(distance)/sum(range_diff) else null end AS efficiency\nFROM data WHERE\n car_id = $car_id AND\n $__timeFilter(start_date)\nGROUP BY date", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "WITH data AS (\n SELECT\n charging_processes.*,\n \tdate_trunc('$period', timezone('UTC', start_date), '$__timezone') as date\n FROM charging_processes)\nSELECT\n EXTRACT(EPOCH FROM date)*1000 AS date_from,\n EXTRACT(EPOCH FROM date + interval '1 $period')*1000 AS date_to,\n CASE '$period'\n WHEN 'month' THEN to_char(timezone('$__timezone', date), 'YYYY Month')\n WHEN 'year' THEN to_char(timezone('$__timezone', date), 'YYYY')\n WHEN 'week' THEN 'week ' || to_char(timezone('$__timezone', date), 'WW') || ' starting ' || to_char(timezone('$__timezone', date), 'YYYY-MM-DD')\n ELSE to_char(timezone('$__timezone', date), 'YYYY-MM-DD')\n END AS display,\n date,\n sum(greatest(charge_energy_added,charge_energy_used)) AS sum_energy_used_kwh,\n sum(charge_energy_added) as sum_energy_added_kwh,\n sum(greatest(charge_energy_added,charge_energy_used)) / count(*) AS avg_energy_charged_kwh,\n sum(cost) AS cost_charges,\n count(*) AS cnt_charges\nFROM data WHERE\n car_id = $car_id AND\n $__timeFilter(start_date) AND\n (charge_energy_added IS NULL OR charge_energy_added > 0.1)\nGROUP BY date", - "refId": "B", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "WITH data AS (\n SELECT\n drives.*,\n date_trunc('$period', timezone('UTC', start_date), '$__timezone') as date\n FROM drives)\nSELECT\n EXTRACT(EPOCH FROM date)*1000 AS date_from,\n EXTRACT(EPOCH FROM date + interval '1 $period')*1000 AS date_to,\n CASE '$period'\n WHEN 'month' THEN to_char(timezone('$__timezone', date), 'YYYY Month')\n WHEN 'year' THEN to_char(timezone('$__timezone', date), 'YYYY')\n WHEN 'week' THEN 'week ' || to_char(timezone('$__timezone', date), 'WW') || ' starting ' || to_char(timezone('$__timezone', date), 'YYYY-MM-DD')\n ELSE to_char(timezone('$__timezone', date), 'YYYY-MM-DD')\n END AS display,\n date,\n sum((start_${preferred_range}_range_km - end_${preferred_range}_range_km) * car.efficiency * 1000) / \n convert_km(sum(distance)::numeric, '$length_unit') as consumption_net_$length_unit\nFROM data\nJOIN cars car ON car.id = car_id\nWHERE\n car_id = $car_id AND\n $__timeFilter(start_date)\nGROUP BY date", - "refId": "C", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "-- Query shared between Charging Stats, Statistics & Trip Dashboards (with minor changes) - ensure to modify in all places when necessary\n\nwith drives_start_event as (\n\n select\n 'drive_start' as event, start_date as date, start_${preferred_range}_range_km as range, start_km as odometer, car_id\n from drives\n where car_id = $car_id and $__timeFilter(start_date) and 0 = $high_precision\n\n),\n\ndrives_end_event as (\n\n select\n 'drive_end' as event, end_date as date, end_${preferred_range}_range_km as range, end_km as odometer, car_id\n from drives\n where car_id = $car_id and $__timeFilter(start_date) and 0 = $high_precision\n\n),\n\ncharging_processes_start_event as (\n\n select\n 'charging_process_start' as event, start_date as date, start_${preferred_range}_range_km as range, p.odometer, cp.car_id\n from charging_processes cp\n inner join positions p on cp.position_id = p.id\n where cp.car_id = $car_id and $__timeFilter(start_date) and 0 = $high_precision\n\n),\n\ncharging_processes_end_event as (\n\n select\n 'charging_process_end' as event, end_date as date, end_${preferred_range}_range_km as range, p.odometer, cp.car_id\n from charging_processes cp\n inner join positions p on cp.position_id = p.id\n where cp.car_id = $car_id and $__timeFilter(start_date) and 0 = $high_precision\n\n),\n\npositions as (\n\n select\n case\n when drive_id is not null and lead(drive_id) over w is not null then 'drive_start'\n else 'something'\n end as event,\n date, ${preferred_range}_battery_range_km as range, p.odometer, p.car_id\n from positions p\n where ideal_battery_range_km is not null and car_id = $car_id and 1 = $high_precision\n and (drive_id in (select id from drives where $__timeFilter(start_date)) or drive_id is null and $__timeFilter(date))\n window w as (order by date)\n\n),\n\ncombined as (\n\n select * from drives_start_event\n union all\n select * from drives_end_event\n union all\n select * from charging_processes_start_event\n union all\n select * from charging_processes_end_event\n union all\n select * from positions\n\n),\n\nfinal as (\n\n select\n car_id,\n date_trunc('$period', timezone('UTC', date), '$__timezone') as date,\n lead(odometer) over w - odometer as distance,\n case when event != 'drive_start' then greatest(range - lead(range) over w, 0) else range - lead(range) over w end as range_loss\n from combined\n window w as (order by date asc)\n\n)\n\nselect\n EXTRACT(EPOCH FROM date)*1000 AS date_from,\n EXTRACT(EPOCH FROM date + interval '1 $period')*1000 AS date_to,\n CASE '$period'\n WHEN 'month' THEN to_char(timezone('$__timezone', date), 'YYYY Month')\n WHEN 'year' THEN to_char(timezone('$__timezone', date), 'YYYY')\n WHEN 'week' THEN 'week ' || to_char(timezone('$__timezone', date), 'WW') || ' starting ' || to_char(timezone('$__timezone', date), 'YYYY-MM-DD')\n ELSE to_char(timezone('$__timezone', date), 'YYYY-MM-DD')\n END AS display,\n date,\n (sum(range_loss) * c.efficiency * 1000) / nullif(convert_km(sum(distance)::numeric, '$length_unit'), 0) as consumption_gross_$length_unit\nfrom final\n inner join cars c on car_id = c.id\ngroup by 1, 2, 3, 4, c.efficiency", - "refId": "D", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "per ${period}", - "transformations": [ - { - "id": "merge", - "options": {} - }, - { - "id": "seriesToColumns", - "options": { - "byField": "date" - } - }, - { - "id": "sortBy", - "options": { - "fields": {}, - "sort": [ - { - "desc": true, - "field": "date" - } - ] - } - }, - { - "id": "calculateField", - "options": { - "alias": "avg_cost_kwh", - "binary": { - "left": { - "matcher": { - "id": "byName", - "options": "cost_charges" - } - }, - "operator": "/", - "right": { - "matcher": { - "id": "byName", - "options": "sum_energy_used_kwh" - } - } - }, - "mode": "binary", - "reduce": { - "reducer": "sum" - } - } - }, - { - "id": "calculateField", - "options": { - "alias": "avg_cost_added_kwh", - "binary": { - "left": { - "matcher": { - "id": "byName", - "options": "cost_charges" - } - }, - "operator": "/", - "right": { - "matcher": { - "id": "byName", - "options": "sum_energy_added_kwh" - } - } - }, - "mode": "binary", - "reduce": { - "reducer": "sum" - } - } - }, - { - "id": "calculateField", - "options": { - "alias": "cost_per_1000km", - "binary": { - "left": { - "matcher": { - "id": "byName", - "options": "consumption_gross_km" - } - }, - "operator": "*", - "right": { - "matcher": { - "id": "byName", - "options": "avg_cost_added_kwh" - } - } - }, - "mode": "binary", - "reduce": { - "reducer": "sum" - } - } - }, - { - "id": "calculateField", - "options": { - "alias": "cost_per_1000mi", - "binary": { - "left": { - "matcher": { - "id": "byName", - "options": "consumption_gross_mi" - } - }, - "operator": "*", - "right": { - "matcher": { - "id": "byName", - "options": "avg_cost_added_kwh" - } - } - }, - "mode": "binary", - "reduce": { - "reducer": "sum" - } - } - }, - { - "id": "calculateField", - "options": { - "alias": "avg_cost_km", - "binary": { - "left": { - "matcher": { - "id": "byName", - "options": "cost_per_1000km" - } - }, - "operator": "/", - "right": { - "fixed": "10" - } - }, - "mode": "binary", - "reduce": { - "reducer": "sum" - } - } - }, - { - "id": "calculateField", - "options": { - "alias": "avg_cost_mi", - "binary": { - "left": { - "matcher": { - "id": "byName", - "options": "cost_per_1000mi" - } - }, - "operator": "/", - "right": { - "fixed": "10" - } - }, - "mode": "binary", - "reduce": { - "reducer": "sum" - } - } - }, - { - "id": "calculateField", - "options": { - "alias": "overhead_pct_km_temp", - "binary": { - "left": { - "matcher": { - "id": "byName", - "options": "consumption_net_km" - } - }, - "operator": "/", - "right": { - "matcher": { - "id": "byName", - "options": "consumption_gross_km" - } - } - }, - "mode": "binary", - "reduce": { - "reducer": "sum" - } - } - }, - { - "id": "calculateField", - "options": { - "alias": "overhead_pct_km", - "binary": { - "left": { - "fixed": "1" - }, - "operator": "-", - "right": { - "matcher": { - "id": "byName", - "options": "overhead_pct_km_temp" - } - } - }, - "mode": "binary", - "reduce": { - "reducer": "sum" - } - } - }, - { - "id": "calculateField", - "options": { - "alias": "overhead_pct_mi_temp", - "binary": { - "left": { - "matcher": { - "id": "byName", - "options": "consumption_net_mi" - } - }, - "operator": "/", - "right": { - "matcher": { - "id": "byName", - "options": "consumption_gross_mi" - } - } - }, - "mode": "binary", - "reduce": { - "reducer": "sum" - } - } - }, - { - "id": "calculateField", - "options": { - "alias": "overhead_pct_mi", - "binary": { - "left": { - "fixed": "1" - }, - "operator": "-", - "right": { - "matcher": { - "id": "byName", - "options": "overhead_pct_mi_temp" - } - } - }, - "mode": "binary", - "reduce": { - "reducer": "sum" - } - } - }, - { - "id": "organize", - "options": { - "excludeByName": { - "avg_cost_added_kwh": true, - "cost_per_1000km": true, - "cost_per_1000mi": true, - "date": true, - "overhead_pct_km_temp": true, - "overhead_pct_mi_temp": true, - "sum_energy_added_kwh": true - }, - "includeByName": {}, - "indexByName": { - "avg_cost_km": 12, - "avg_cost_kwh": 11, - "avg_cost_mi": 12, - "avg_energy_charged_kwh": 8, - "avg_outside_temp_c": 4, - "avg_outside_temp_f": 4, - "cnt": 5, - "cnt_charges": 10, - "consumption_gross_km": 14, - "consumption_gross_mi": 14, - "consumption_net_km": 13, - "consumption_net_mi": 13, - "cost_charges": 9, - "date": 1, - "date_from": 15, - "date_to": 16, - "display": 0, - "efficiency": 6, - "overhead_pct_km": 17, - "overhead_pct_mi": 17, - "sum_distance_km": 3, - "sum_distance_mi": 3, - "sum_duration_h": 2, - "sum_energy_used_kwh": 7 - }, - "renameByName": { - "avg_cost_km": "Ø Cost / 100 km", - "avg_cost_kwh": "Ø Cost / kWh", - "avg_cost_mi": "Ø Cost / 100 mi", - "avg_energy_charged_kwh": "Ø Energy used / Charge", - "avg_outside_temp_c": "", - "avg_outside_temp_f": "", - "cnt": "# of Drives", - "cnt_charges": "# of Charges", - "consumption_gross_km": "", - "consumption_gross_mi": "", - "consumption_net_km": "", - "consumption_net_mi": "", - "cost_charges": "Costs", - "date": "", - "date_from": "", - "date_to": "", - "display": "Period", - "efficiency": "Driving Efficiency", - "overhead_pct_km": "Consumption OH", - "overhead_pct_mi": "Consumption OH", - "sum_distance_km": "", - "sum_distance_mi": "", - "sum_duration_h": "Time driven", - "sum_energy_used_kwh": "Energy used" - } - } - } - ], - "type": "table" - } - ], - "preload": false, - "refresh": "", - "schemaVersion": 41, - "tags": [ - "tesla" - ], - "templating": { - "list": [ - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "SELECT\n id as __value,\n CASE WHEN COUNT(id) OVER (PARTITION BY name) > 1 AND name IS NOT NULL THEN CONCAT(name, ' - ', RIGHT(vin, 6)) ELSE COALESCE(name, CONCAT('VIN ', vin)) end as __text \nFROM cars\nORDER BY display_priority ASC, name ASC, vin ASC;", - "hide": 2, - "includeAll": true, - "label": "Car", - "name": "car_id", - "options": [], - "query": "SELECT\n id as __value,\n CASE WHEN COUNT(id) OVER (PARTITION BY name) > 1 AND name IS NOT NULL THEN CONCAT(name, ' - ', RIGHT(vin, 6)) ELSE COALESCE(name, CONCAT('VIN ', vin)) end as __text \nFROM cars\nORDER BY display_priority ASC, name ASC, vin ASC;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "select unit_of_length from settings limit 1;", - "hide": 2, - "includeAll": false, - "label": "length unit", - "name": "length_unit", - "options": [], - "query": "select unit_of_length from settings limit 1;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "select unit_of_temperature from settings limit 1;", - "hide": 2, - "includeAll": false, - "label": "temperature unit", - "name": "temp_unit", - "options": [], - "query": "select unit_of_temperature from settings limit 1;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": { - "text": "month", - "value": "month" - }, - "includeAll": false, - "label": "Period", - "name": "period", - "options": [ - { - "selected": false, - "text": "day", - "value": "day" - }, - { - "selected": false, - "text": "week", - "value": "week" - }, - { - "selected": true, - "text": "month", - "value": "month" - }, - { - "selected": false, - "text": "year", - "value": "year" - } - ], - "query": "day,week,month,year", - "type": "custom" - }, - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "select preferred_range from settings limit 1;", - "hide": 2, - "includeAll": false, - "name": "preferred_range", - "options": [], - "query": "select preferred_range from settings limit 1;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "select base_url from settings limit 1;", - "hide": 2, - "includeAll": false, - "name": "base_url", - "options": [], - "query": "select base_url from settings limit 1;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": { - "text": "no", - "value": "0" - }, - "description": "When enabled \"Ø Consumption (gross)\" will be calculated via Positions instead of Charging Processes and Drives.\n\nWhile being more accurate (especially for shorter periods) this will be slow on slow hardware!", - "includeAll": false, - "label": "High Precision", - "name": "high_precision", - "options": [ - { - "selected": true, - "text": "no", - "value": "0" - }, - { - "selected": false, - "text": "yes", - "value": "1" - } - ], - "query": "no : 0, yes : 1", - "type": "custom" - } - ] - }, - "time": { - "from": "now-10y", - "to": "now" - }, - "timepicker": {}, - "timezone": "", - "title": "Tesla Statistics", - "uid": "1EZnXszMk", - "version": 1 - } diff --git a/argocd/manifests/grafana-config/dashboards/configmap-teslamate-timeline.yaml b/argocd/manifests/grafana-config/dashboards/configmap-teslamate-timeline.yaml deleted file mode 100644 index ed5eb08..0000000 --- a/argocd/manifests/grafana-config/dashboards/configmap-teslamate-timeline.yaml +++ /dev/null @@ -1,770 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: grafana-dashboard-teslamate-timeline - namespace: monitoring - labels: - grafana_dashboard: "1" - annotations: - grafana_folder: "TeslaMate" -data: - teslamate-timeline.json: | - { - "annotations": { - "list": [ - { - "builtIn": 1, - "datasource": { - "type": "grafana", - "uid": "-- Grafana --" - }, - "enable": true, - "hide": true, - "iconColor": "rgba(0, 211, 255, 1)", - "name": "Annotations & Alerts", - "type": "dashboard" - } - ] - }, - "editable": true, - "fiscalYearStartMonth": 0, - "graphTooltip": 0, - "links": [ - { - "asDropdown": false, - "icon": "dashboard", - "includeVars": false, - "keepTime": false, - "tags": [], - "targetBlank": false, - "title": "TeslaMate", - "tooltip": "", - "type": "link", - "url": "${base_url:raw}" - }, - { - "asDropdown": true, - "icon": "external link", - "includeVars": false, - "keepTime": false, - "tags": [ - "tesla" - ], - "targetBlank": false, - "title": "Dashboards", - "tooltip": "", - "type": "dashboards", - "url": "" - } - ], - "panels": [ - { - "collapsed": false, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 0 - }, - "id": 4, - "panels": [], - "repeat": "car_id", - "title": "$car_id", - "type": "row" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "custom": { - "align": "auto", - "cellOptions": { - "type": "auto" - }, - "filterable": false, - "inspect": false - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": 0 - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Start" - }, - "properties": [ - { - "id": "unit", - "value": "dateTimeAsLocal" - }, - { - "id": "custom.width", - "value": 210 - }, - { - "id": "links", - "value": [ - { - "targetBlank": true, - "title": "", - "url": "/d/FkUpJpQZk/trip?from=${__data.fields.start_date_ts}&to=${__data.fields.end_date_ts}&var-car_id=$car_id" - } - ] - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "SoC" - }, - "properties": [ - { - "id": "custom.width", - "value": 65 - }, - { - "id": "unit", - "value": "percent" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "SoC Diff" - }, - "properties": [ - { - "id": "custom.width", - "value": 80 - }, - { - "id": "unit", - "value": "percent" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "start_path" - }, - "properties": [ - { - "id": "custom.hidden", - "value": true - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "end_path" - }, - "properties": [ - { - "id": "custom.hidden", - "value": true - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Action" - }, - "properties": [ - { - "id": "custom.width", - "value": 150 - }, - { - "id": "custom.filterable", - "value": true - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "kWh" - }, - "properties": [ - { - "id": "custom.width", - "value": 100 - }, - { - "id": "unit", - "value": "kwatth" - }, - { - "id": "decimals", - "value": 1 - }, - { - "id": "displayName", - "value": "Energy Diff" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Duration" - }, - "properties": [ - { - "id": "unit", - "value": "m" - }, - { - "id": "custom.width", - "value": 100 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Start Address" - }, - "properties": [ - { - "id": "links", - "value": [ - { - "targetBlank": true, - "title": "Create or edit geo-fence", - "url": "${base_url:raw}/geo-fences/${__data.fields.start_path:raw}" - } - ] - }, - { - "id": "custom.filterable", - "value": true - }, - { - "id": "custom.minWidth", - "value": 200 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "End Address" - }, - "properties": [ - { - "id": "links", - "value": [ - { - "targetBlank": true, - "title": "Create or edit geo-fence", - "url": "${base_url:raw}/geo-fences/${__data.fields.end_path:raw}" - } - ] - }, - { - "id": "custom.filterable", - "value": true - }, - { - "id": "custom.minWidth", - "value": 200 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "start_date_ts" - }, - "properties": [ - { - "id": "custom.hidden", - "value": true - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "end_date_ts" - }, - "properties": [ - { - "id": "custom.hidden", - "value": true - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "odometer_km" - }, - "properties": [ - { - "id": "custom.width", - "value": 100 - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*_km/" - }, - "properties": [ - { - "id": "unit", - "value": "km" - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*_mi/" - }, - "properties": [ - { - "id": "unit", - "value": "mi" - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*_c/" - }, - "properties": [ - { - "id": "unit", - "value": "celsius" - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*_f/" - }, - "properties": [ - { - "id": "unit", - "value": "fahrenheit" - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/odometer_.*/" - }, - "properties": [ - { - "id": "displayName", - "value": "Odometer" - }, - { - "id": "custom.width", - "value": 100 - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/distance_.*/" - }, - "properties": [ - { - "id": "displayName", - "value": "Distance" - }, - { - "id": "custom.width", - "value": 100 - }, - { - "id": "decimals", - "value": 1 - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/range_diff_.*/" - }, - "properties": [ - { - "id": "displayName", - "value": "Range Diff" - }, - { - "id": "custom.width", - "value": 100 - }, - { - "id": "decimals", - "value": 1 - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/outside_temp_avg_.*/" - }, - "properties": [ - { - "id": "displayName", - "value": "Temp" - }, - { - "id": "custom.width", - "value": 75 - }, - { - "id": "decimals", - "value": 1 - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/end_range_.*/" - }, - "properties": [ - { - "id": "displayName", - "value": "Range" - }, - { - "id": "custom.width", - "value": 100 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Action" - }, - "properties": [ - { - "id": "links", - "value": [ - { - "targetBlank": true, - "title": "Slot details", - "url": "${__data.fields.slotlink:raw}" - } - ] - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "slotlink" - }, - "properties": [ - { - "id": "custom.hidden", - "value": true - } - ] - } - ] - }, - "gridPos": { - "h": 22, - "w": 24, - "x": 0, - "y": 1 - }, - "id": 2, - "options": { - "cellHeight": "sm", - "footer": { - "countRows": false, - "fields": "", - "reducer": [ - "sum" - ], - "show": false - }, - "showHeader": true, - "sortBy": [ - { - "desc": true, - "displayName": "Start" - } - ] - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "-- CTE is used in Parking Query\r\nwith drives_and_charging_processes as (\r\n\r\n select 'Drive' as activity, d.start_date, d.end_date, d.start_position_id, d.end_position_id, d.end_address_id, d.end_geofence_id, d.start_${preferred_range}_range_km, d.end_${preferred_range}_range_km, d.car_id, d.outside_temp_avg from drives d\r\n \r\n union all\r\n \r\n select 'Charging Process' as activity, cp.start_date, cp.end_date, cp.position_id as start_position_id, cp.position_id as end_position_id, cp.address_id as end_address_id, cp.geofence_id as end_geofence_id, cp.start_${preferred_range}_range_km, cp.end_${preferred_range}_range_km, cp.car_id, cp.outside_temp_avg from charging_processes cp\r\n\r\n)\r\n\r\nSELECT\r\n start_date AS \"Start\",\r\n end_date AS \"End\",\r\n ROUND(EXTRACT(EPOCH FROM start_date))*1000 AS start_date_ts,\r\n ROUND(EXTRACT(EPOCH FROM end_date))*1000 AS end_date_ts,\r\n '🚗 Driving' AS \"Action\",\r\n drives.duration_min AS \"Duration\",\r\n CASE WHEN start_geofence_id IS NULL THEN CONCAT('new?lat=', TP1.latitude, '&lng=', TP1.longitude)\r\n WHEN start_geofence_id IS NOT NULL THEN CONCAT(start_geofence_id, '/edit')\r\n END AS start_path,\r\n CASE WHEN end_geofence_id IS NULL THEN CONCAT('new?lat=', TP2.latitude, '&lng=', TP2.longitude)\r\n WHEN start_geofence_id IS NOT NULL THEN CONCAT(end_geofence_id, '/edit')\r\n END AS end_path,\r\n COALESCE(start_geofence.name, CONCAT_WS(', ', COALESCE(start_address.name, nullif(CONCAT_WS(' ', start_address.road, start_address.house_number), '')), start_address.city)) AS \"Start Address\",\r\n COALESCE(end_geofence.name, CONCAT_WS(', ', COALESCE(end_address.name, nullif(CONCAT_WS(' ', end_address.road, end_address.house_number), '')), end_address.city)) AS \"End Address\",\r\n convert_km(end_km::NUMERIC, '$length_unit') AS odometer_$length_unit,\r\n convert_km(distance::NUMERIC, '$length_unit') AS distance_$length_unit,\r\n convert_km(end_${preferred_range}_range_km::NUMERIC, '$length_unit') AS end_range_$length_unit,\r\n (end_${preferred_range}_range_km - start_${preferred_range}_range_km) * car.efficiency AS \"kWh\",\r\n convert_km((end_${preferred_range}_range_km - start_${preferred_range}_range_km)::NUMERIC, '$length_unit') AS range_diff_$length_unit,\r\n TP2.battery_level AS \"SoC\",\r\n TP2.battery_level-TP1.battery_level AS \"SoC Diff\",\r\n convert_celsius(outside_temp_avg, '$temp_unit') AS outside_temp_avg_$temp_unit,\r\n CONCAT('d/zm7wN6Zgz/drive-details?from=', ROUND(EXTRACT(EPOCH FROM start_date))*1000, '&to=', ROUND(EXTRACT(EPOCH FROM end_date))*1000, '&var-car_id=', drives.car_id, '&var-drive_id=', drives.id) AS slotlink\r\nFROM drives\r\n INNER JOIN cars AS car ON drives.car_id = car.id\r\n INNER JOIN positions AS TP1 on drives.start_position_id = TP1.id\r\n INNER JOIN positions AS TP2 on drives.end_position_id = TP2.id\r\n INNER JOIN addresses start_address ON start_address_id = start_address.id\r\n INNER JOIN addresses end_address ON end_address_id = end_address.id\r\n LEFT JOIN geofences start_geofence ON start_geofence_id = start_geofence.id\r\n LEFT JOIN geofences end_geofence ON end_geofence_id = end_geofence.id\r\nWHERE \r\n $__timeFilter(drives.start_date)\r\n AND drives.car_id = $car_id\r\n AND '🚗 Driving' in ($action_filter)\r\n AND\r\n (COALESCE(start_geofence.name, CONCAT_WS(', ', COALESCE(start_address.name, nullif(CONCAT_WS(' ', start_address.road, start_address.house_number), '')), start_address.city))::TEXT ILIKE'%$text_filter%' or\r\n COALESCE(end_geofence.name, CONCAT_WS(', ', COALESCE(end_address.name, nullif(CONCAT_WS(' ', end_address.road, end_address.house_number), '')), end_address.city))::TEXT ILIKE'%$text_filter%')\r\n\r\nUNION\r\nSELECT\r\n start_date AS \"Start\",\r\n end_date AS \"End\",\r\n ROUND(EXTRACT(EPOCH FROM start_date))*1000 AS start_date_ts,\r\n ROUND(EXTRACT(EPOCH FROM end_date))*1000 AS end_date_ts,\r\n '🔋 Charging' AS \"Action\",\r\n charging_processes.duration_min AS \"Duration\",\r\n CASE WHEN geofence_id IS NULL THEN CONCAT('new?lat=', address.latitude, '&lng=', address.longitude)\r\n WHEN geofence_id IS NOT NULL THEN CONCAT(geofence_id, '/edit')\r\n END AS start_path,\r\n NULL AS end_path,\r\n COALESCE(geofence.name, CONCAT_WS(', ', COALESCE(address.name, nullif(CONCAT_WS(' ', address.road, address.house_number), '')), address.city)) AS \"Start Address\",\r\n '' AS \"End Address\",\r\n convert_km(position.odometer::NUMERIC, '$length_unit') AS odometer_$length_unit,\r\n NULL AS distance_$length_unit,\r\n convert_km(end_${preferred_range}_range_km::NUMERIC, '$length_unit') AS end_range_$length_unit,\r\n charging_processes.charge_energy_added AS \"kWh\",\r\n convert_km((end_${preferred_range}_range_km - start_${preferred_range}_range_km)::NUMERIC, '$length_unit') AS range_diff_$length_unit, \r\n end_battery_level AS \"SoC\",\r\n end_battery_level - start_battery_level AS \"SoC Diff\",\r\n convert_celsius(outside_temp_avg, '$temp_unit') AS outside_temp_avg_$temp_unit,\r\n CONCAT('d/BHhxFeZRz/charge-details?from=', ROUND(EXTRACT(EPOCH FROM start_date)-10)*1000, '&to=', ROUND(EXTRACT(EPOCH FROM end_date)+10)*1000, '&var-car_id=', charging_processes.car_id, '&var-charging_process_id=', charging_processes.id) AS slotlink\r\nFROM charging_processes\r\n INNER JOIN positions AS position ON position_id = position.id\r\n INNER JOIN addresses AS address ON address_id = address.id\r\n LEFT JOIN geofences AS geofence ON geofence_id = geofence.id\r\nWHERE\r\n $__timeFilter(charging_processes.start_date)\r\n AND charging_processes.charge_energy_added > 0\r\n AND charging_processes.car_id = $car_id\r\n AND '🔋 Charging' in ($action_filter)\r\n AND COALESCE(geofence.name, CONCAT_WS(', ', COALESCE(address.name, nullif(CONCAT_WS(' ', address.road, address.house_number), '')), address.city))::TEXT ILIKE'%$text_filter%'\r\nUNION\r\nSELECT\r\n d.end_date AS \"Start\",\r\n LEAD(d.start_date) over w AS \"End\",\r\n ROUND(EXTRACT(EPOCH FROM d.end_date)) * 1000 AS start_date_ts,\r\n ROUND(EXTRACT(EPOCH FROM LEAD(d.start_date) over w))*1000 AS end_date_ts,\r\n '🅿️ Parking' AS \"Action\",\r\n EXTRACT(EPOCH FROM LEAD(d.start_date) over w - d.end_date)/60 AS \"Duration\",\r\n CASE WHEN d.end_geofence_id IS NULL THEN CONCAT('new?lat=', end_position.latitude, '&lng=', end_position.longitude)\r\n WHEN d.end_geofence_id IS NOT NULL THEN CONCAT(d.end_geofence_id, '/edit')\r\n END AS start_path,\r\n NULL AS end_path,\r\n COALESCE(geofence.name, CONCAT_WS(', ', COALESCE(address.name, nullif(CONCAT_WS(' ', address.road, address.house_number), '')), address.city)) AS \"Start Address\",\r\n '' AS \"End Address\",\r\n convert_km(end_position.odometer::NUMERIC, '$length_unit') AS odometer_$length_unit,\r\n NULL AS distance_$length_unit,\r\n convert_km(LEAD(d.start_${preferred_range}_range_km) over w::NUMERIC, '$length_unit') AS end_range_$length_unit,\r\n ((LEAD(d.start_${preferred_range}_range_km) over w + (LEAD(start_position.odometer) over w - end_position.odometer)) - d.end_${preferred_range}_range_km) * car.efficiency AS \"kWh\",\r\n convert_km(((LEAD(d.start_${preferred_range}_range_km) over w + (LEAD(start_position.odometer) over w - end_position.odometer)) - d.end_${preferred_range}_range_km)::NUMERIC, '$length_unit') AS range_diff_$length_unit,\r\n LEAD(start_position.battery_level) over w AS \"SoC\",\r\n LEAD(start_position.battery_level) over w - end_position.battery_level AS \"SoC Diff\",\r\n convert_celsius(outside_temp_avg, '$temp_unit') AS outside_temp_avg_$temp_unit,\r\n CONCAT('d/FkUpJpQZk/trip?from=', ROUND(EXTRACT(EPOCH FROM d.end_date))*1000, '&to=', ROUND(EXTRACT(EPOCH FROM LEAD(d.start_date) over w))*1000, '&var-car_id=', d.car_id) AS slotlink\r\nFROM drives_and_charging_processes AS d\r\n INNER JOIN cars AS car ON d.car_id = car.id\r\n INNER JOIN positions AS start_position on d.start_position_id = start_position.id\r\n INNER JOIN positions AS end_position on d.end_position_id = end_position.id\r\n INNER JOIN addresses AS address ON d.end_address_id = address.id\r\n LEFT JOIN geofences AS geofence ON d.end_geofence_id = geofence.id\r\nWHERE\r\n $__timeFilter(d.end_date)\r\n AND d.car_id=$car_id\r\n AND '🅿️ Parking' in ($action_filter)\r\n AND COALESCE(geofence.name, CONCAT_WS(', ', COALESCE(address.name, nullif(CONCAT_WS(' ', address.road, address.house_number), '')), address.city))::TEXT ILIKE'%$text_filter%'\r\nWINDOW w as (ORDER BY d.start_date ASC)\r\n\r\nUNION\r\nSELECT\r\n\tT1.end_date +(1 * interval '1 second') AS \"Start\", -- added 1 sec to get it after the corresponding Parking row\r\n\tT2.start_date AS \"End\",\r\n\tROUND(EXTRACT(EPOCH FROM T1.end_date)) * 1000 - 1 AS start_date_ts,\r\n\tROUND(EXTRACT(EPOCH FROM T2.start_date)) * 1000 - 1 AS end_date_ts,\r\n\t'❓ Missing' AS \"Action\",\r\n\t-- EXTRACT(EPOCH FROM T2.start_date - T1.end_date)/60 AS \"Duration\",\r\n\tNULL AS \"Duration\",\r\n\tCASE WHEN T1.end_geofence_id IS NULL THEN CONCAT('new?lat=', TP1.latitude, '&lng=', TP1.longitude)\r\n\t\tWHEN T1.end_geofence_id IS NOT NULL THEN CONCAT(T1.end_geofence_id, '/edit')\r\n\tEND AS start_path,\r\n\tCASE WHEN T2.start_geofence_id IS NULL THEN CONCAT('new?lat=', TP2.latitude, '&lng=', TP2.longitude)\r\n\t\tWHEN T2.start_geofence_id IS NOT NULL THEN CONCAT(T2.start_geofence_id, '/edit')\r\n\tEND AS end_path,\r\n\tCOALESCE(start_geofence.name, CONCAT_WS(', ', COALESCE(start_address.name, nullif(CONCAT_WS(' ', start_address.road, start_address.house_number), '')), start_address.city)) AS \"Start Address\",\r\n\tCOALESCE(end_geofence.name, CONCAT_WS(', ', COALESCE(end_address.name, nullif(CONCAT_WS(' ', end_address.road, end_address.house_number), '')), end_address.city)) AS \"End Address\",\r\n\tconvert_km(TP2.odometer::NUMERIC, '$length_unit') AS odometer_$length_unit,\r\n\tconvert_km((TP2.odometer - TP1.odometer)::NUMERIC, '$length_unit') AS distance_$length_unit,\r\n convert_km(T2.end_${preferred_range}_range_km::NUMERIC, '$length_unit') AS end_range_$length_unit,\r\n\t((TP2.${preferred_range}_battery_range_km + (TP2.odometer - TP1.odometer)) - TP1.${preferred_range}_battery_range_km) * car.efficiency AS \"kWh\",\r\n\tconvert_km(((TP2.${preferred_range}_battery_range_km + (TP2.odometer - TP1.odometer)) - TP1.${preferred_range}_battery_range_km)::NUMERIC, '$length_unit') AS range_diff_$length_unit,\r\n\tNULL AS \"SoC\",\r\n\tNULL AS \"SoC Diff\",\r\n\tNULL AS outside_temp_avg_$temp_unit,\r\n\tNULL AS slotlink\r\n\t-- TP2.battery_level AS \"SoC\",\r\n\t-- TP2.battery_level-TP1.battery_level AS \"SoC Diff\",\r\n\t-- (T1.outside_temp_avg+T2.outside_temp_avg)/2 AS outside_temp_avg_$temp_unit\r\nFROM drives AS T1\r\n INNER JOIN cars AS car ON T1.car_id = car.id\r\n\tINNER JOIN (SELECT d.*, LAG(id) OVER (ORDER BY id ASC) AS previous_id FROM drives d WHERE d.car_id = $car_id) AS T2 ON T1.id = T2.previous_id\r\n\tINNER JOIN positions AS TP1 ON T1.end_position_id = TP1.id\r\n\tINNER JOIN positions AS TP2 ON T2.start_position_id = TP2.id\r\n\tINNER JOIN addresses AS start_address ON T1.end_address_id = start_address.id\r\n\tINNER JOIN addresses AS end_address ON T2.start_address_id = end_address.id\r\n\tLEFT JOIN geofences AS start_geofence ON T1.end_geofence_id = start_geofence.id\r\n\tLEFT JOIN geofences AS end_geofence ON T2.start_geofence_id = end_geofence.id\r\nWHERE\r\n\t$__timeFilter(T1.end_date)\r\n\tAND TP2.odometer - TP1.odometer > 0.5\r\n AND T1.end_address_id <> T2.start_address_id AND ((COALESCE(T1.end_geofence_id, 0) <> COALESCE(T2.start_geofence_id, 0)) OR (T1.end_geofence_id IS NULL AND T2.start_geofence_id IS NULL))\r\n AND '❓ Missing' in ($action_filter)\r\n\tAND (\r\n\t (COALESCE(start_geofence.name, CONCAT_WS(', ', COALESCE(start_address.name, nullif(CONCAT_WS(' ', start_address.road, start_address.house_number), '')), start_address.city))::TEXT ILIKE'%$text_filter%') or\r\n\t (COALESCE(end_geofence.name, CONCAT_WS(', ', COALESCE(end_address.name, nullif(CONCAT_WS(' ', end_address.road, end_address.house_number), '')), end_address.city)))::TEXT ILIKE'%$text_filter%')\r\nUNION\r\nSELECT\r\n start_date AS \"Start\",\r\n end_date AS \"End\",\r\n ROUND(EXTRACT(EPOCH FROM start_date))*1000 AS start_date_ts, \r\n ROUND(EXTRACT(EPOCH FROM end_date))*1000 AS end_date_ts, \r\n '💾 Updating' AS \"Action\",\r\n\tEXTRACT(EPOCH FROM end_date - start_date)/60 AS \"Duration\",\r\n NULL AS start_path,\r\n NULL AS end_path,\r\n version AS \"Start Address\",\r\n '' AS \"End Address\",\r\n NULL AS odometer_$length_unit,\r\n NULL AS distance_$length_unit,\r\n NULL AS end_range_$length_unit,\r\n NULL AS \"kWh\",\r\n NULL AS range_diff_$length_unit,\r\n NULL AS \"SoC\",\r\n NULL AS \"SoC Diff\",\r\n NULL AS outside_temp_avg_$temp_unit,\r\n CONCAT('https://www.notateslaapp.com/software-updates/version/', split_part(version, ' ', 1), '/release-notes') AS slotlink\r\nFROM updates\r\nWHERE \r\n $__timeFilter(start_date)\r\n AND car_id = $car_id \r\n AND '💾 Updating' in ($action_filter)\r\n AND version::TEXT ILIKE'%$text_filter%'\r\n\r\nORDER BY \"Start\" DESC;", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Timeline", - "transformations": [ - { - "id": "organize", - "options": { - "excludeByName": { - "End": true, - "start_date_ts": false - }, - "indexByName": { - "Action": 2, - "Duration": 7, - "End": 1, - "End Address": 4, - "SoC": 15, - "SoC Diff": 16, - "Start": 0, - "Start Address": 3, - "distance_km": 8, - "distance_mi": 9, - "end_date_ts": 22, - "end_path": 20, - "end_range_km": 10, - "end_range_mi": 11, - "kWh": 13, - "odometer_km": 5, - "odometer_mi": 6, - "outside_temp_avg_c": 17, - "outside_temp_avg_f": 18, - "range_diff_km": 12, - "range_diff_mi": 13, - "start_date_ts": 21, - "start_path": 19 - }, - "renameByName": { - "action": "", - "end_address": "End", - "km_diff": "Km", - "kwh": "", - "minutediff": "Time", - "odometer": "", - "outside_temp_avg": "Temperature", - "rangediff": "Range Difference", - "soc": "", - "soc_diff": "SoC Difference", - "start_address": "Start", - "start_date": "Date", - "start_date_ts": "" - } - } - } - ], - "type": "table" - } - ], - "preload": false, - "refresh": "", - "schemaVersion": 41, - "tags": [ - "tesla" - ], - "templating": { - "list": [ - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "SELECT\n id as __value,\n CASE WHEN COUNT(id) OVER (PARTITION BY name) > 1 AND name IS NOT NULL THEN CONCAT(name, ' - ', RIGHT(vin, 6)) ELSE COALESCE(name, CONCAT('VIN ', vin)) end as __text \nFROM cars\nORDER BY display_priority ASC, name ASC, vin ASC;", - "hide": 2, - "includeAll": true, - "label": "Car", - "name": "car_id", - "options": [], - "query": "SELECT\n id as __value,\n CASE WHEN COUNT(id) OVER (PARTITION BY name) > 1 AND name IS NOT NULL THEN CONCAT(name, ' - ', RIGHT(vin, 6)) ELSE COALESCE(name, CONCAT('VIN ', vin)) end as __text \nFROM cars\nORDER BY display_priority ASC, name ASC, vin ASC;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "select base_url from settings limit 1;", - "hide": 2, - "includeAll": false, - "name": "base_url", - "options": [], - "query": "select base_url from settings limit 1;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": {}, - "includeAll": true, - "label": "Action", - "multi": true, - "name": "action_filter", - "options": [ - { - "selected": false, - "text": "🚗 Driving", - "value": "🚗 Driving" - }, - { - "selected": false, - "text": "🔋 Charging", - "value": "🔋 Charging" - }, - { - "selected": false, - "text": "🅿️ Parking", - "value": "🅿️ Parking" - }, - { - "selected": false, - "text": "❓ Missing", - "value": "❓ Missing" - }, - { - "selected": false, - "text": "💾 Updating", - "value": "💾 Updating" - } - ], - "query": "🚗 Driving,🔋 Charging,🅿️ Parking,❓ Missing,💾 Updating", - "type": "custom" - }, - { - "current": { - "text": "", - "value": "" - }, - "label": "Address Filter", - "name": "text_filter", - "options": [ - { - "selected": true, - "text": "", - "value": "" - } - ], - "query": "", - "type": "textbox" - }, - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "select unit_of_length from settings limit 1;", - "hide": 2, - "includeAll": false, - "label": "length unit", - "name": "length_unit", - "options": [], - "query": "select unit_of_length from settings limit 1;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "select unit_of_temperature from settings limit 1;", - "hide": 2, - "includeAll": false, - "label": "temperature unit", - "name": "temp_unit", - "options": [], - "query": "select unit_of_temperature from settings limit 1;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "select preferred_range from settings limit 1;", - "hide": 2, - "includeAll": false, - "name": "preferred_range", - "options": [], - "query": "select preferred_range from settings limit 1;", - "refresh": 1, - "regex": "", - "type": "query" - } - ] - }, - "time": { - "from": "now-7d", - "to": "now" - }, - "timepicker": {}, - "timezone": "", - "title": "Tesla Timeline", - "uid": "SUBgwtigz", - "version": 1 - } diff --git a/argocd/manifests/grafana-config/dashboards/configmap-teslamate-trip.yaml b/argocd/manifests/grafana-config/dashboards/configmap-teslamate-trip.yaml deleted file mode 100644 index 771ac97..0000000 --- a/argocd/manifests/grafana-config/dashboards/configmap-teslamate-trip.yaml +++ /dev/null @@ -1,2819 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: grafana-dashboard-teslamate-trip - namespace: monitoring - labels: - grafana_dashboard: "1" - annotations: - grafana_folder: "TeslaMate" -data: - teslamate-trip.json: | - { - "annotations": { - "list": [ - { - "builtIn": 1, - "datasource": { - "type": "grafana", - "uid": "-- Grafana --" - }, - "enable": true, - "hide": true, - "iconColor": "rgba(0, 211, 255, 1)", - "name": "Annotations & Alerts", - "type": "dashboard" - } - ] - }, - "editable": true, - "fiscalYearStartMonth": 0, - "graphTooltip": 1, - "links": [ - { - "icon": "doc", - "tags": [], - "targetBlank": true, - "title": "Select last three drives", - "type": "link", - "url": "/d/FkUpJpQZk/trip?from=$from" - }, - { - "icon": "dashboard", - "tags": [], - "title": "TeslaMate", - "tooltip": "", - "type": "link", - "url": "${base_url:raw}" - }, - { - "asDropdown": true, - "icon": "external link", - "tags": [ - "tesla" - ], - "title": "Dashboards", - "type": "dashboards" - } - ], - "panels": [ - { - "collapsed": false, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 0 - }, - "id": 4, - "panels": [], - "repeat": "car_id", - "title": "$car_id", - "type": "row" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "custom": { - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": 0 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 15, - "w": 13, - "x": 0, - "y": 1 - }, - "id": 6, - "maxDataPoints": 500, - "options": { - "basemap": { - "config": {}, - "name": "Layer 0", - "type": "osm-standard" - }, - "controls": { - "mouseWheelZoom": true, - "showAttribution": true, - "showDebug": false, - "showMeasure": false, - "showScale": false, - "showZoom": true - }, - "layers": [ - { - "config": { - "arrow": 0, - "style": { - "color": { - "fixed": "dark-blue" - }, - "lineWidth": 2, - "opacity": 1, - "rotation": { - "fixed": 0, - "max": 360, - "min": -360, - "mode": "mod" - }, - "size": { - "fixed": 3, - "max": 15, - "min": 2 - }, - "symbol": { - "fixed": "img/icons/marker/circle.svg", - "mode": "fixed" - }, - "symbolAlign": { - "horizontal": "center", - "vertical": "center" - }, - "textConfig": { - "fontSize": 12, - "offsetX": 0, - "offsetY": 0, - "textAlign": "center", - "textBaseline": "middle" - } - } - }, - "name": "Layer 1", - "tooltip": true, - "type": "route" - } - ], - "tooltip": { - "mode": "details" - }, - "view": { - "allLayers": true, - "id": "fit", - "lat": 0, - "lon": 0, - "zoom": 15 - } - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "time_series", - "rawQuery": true, - "rawSql": "with unioned_positions as (\n\n -- fetch all positions based on start_date of drives so the map aligns with data shown in other panels\n select p.*\n from positions p\n inner join drives d on p.drive_id = d.id\n where p.car_id = $car_id and $__timeFilter(d.start_date)\n\n union all\n\n -- get all positions logged while not driving\n select *\n from positions p\n where p.car_id = $car_id and drive_id is null and $__timeFilter(date))\n\nSELECT $__timeGroup(date, '5s') AS time,\n avg(latitude) AS latitude,\n avg(longitude) AS longitude\nfrom unioned_positions\nGROUP BY 1\nORDER BY 1 ASC", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "", - "transparent": true, - "type": "geomap" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "#c7d0d9", - "value": 0 - } - ] - }, - "unit": "none" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "distance_km" - }, - "properties": [ - { - "id": "unit", - "value": "km" - }, - { - "id": "displayName", - "value": "Mileage" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "distance_mi" - }, - "properties": [ - { - "id": "unit", - "value": "mi" - }, - { - "id": "displayName", - "value": "Mileage" - } - ] - } - ] - }, - "gridPos": { - "h": 2, - "w": 6, - "x": 13, - "y": 1 - }, - "id": 10, - "maxDataPoints": 100, - "options": { - "colorMode": "value", - "fieldOptions": { - "calcs": [ - "lastNotNull" - ] - }, - "graphMode": "area", - "justifyMode": "auto", - "orientation": "auto", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "mean" - ], - "fields": "", - "values": true - }, - "showPercentChange": false, - "textMode": "auto", - "wideLayout": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "SELECT convert_km((max(odometer) - min(odometer))::numeric, '$length_unit') as \"distance_$length_unit\"\nFROM positions\nWHERE car_id = $car_id AND ideal_battery_range_km IS NOT NULL\nand (drive_id in (select id from drives where $__timeFilter(start_date)) or drive_id is null and $__timeFilter(date))\nORDER BY 1", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "", - "type": "stat" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - } - }, - "decimals": 1, - "mappings": [], - "unit": "dtdurations" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "charging (AC)" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#73BF69", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "charging (DC)" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#FADE2A", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "driving" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#5794F2", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 5, - "x": 19, - "y": 1 - }, - "id": 38, - "maxDataPoints": 3, - "options": { - "displayLabels": [ - "name", - "percent" - ], - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true, - "values": [ - "value" - ] - }, - "pieType": "pie", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": true - }, - "tooltip": { - "hideZeros": false, - "maxHeight": 600, - "mode": "single", - "sort": "none" - } - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "time_series", - "rawQuery": true, - "rawSql": "SELECT\n\tnow() AS time,\n\tsum(extract(epoch FROM end_position.date - start_position.date)) as duration_sec,\n\t'driving' as metric\nFROM\n\tdrives\n\tJOIN positions start_position ON start_position_id = start_position.id\n\tJOIN positions end_position ON end_position_id = end_position.id\nWHERE\n\tdrives.car_id = $car_id\n\tAND $__timeFilter(start_date)\n\tAND end_date IS NOT NULL;", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "time_series", - "rawQuery": true, - "rawSql": "WITH charges_current AS (\n SELECT\n\t\tcp.id,\n \textract(epoch FROM LEAST(end_date, $__timeTo()) - GREATEST(start_date, $__timeFrom())) as duration_sec,\n\t\tCASE WHEN NULLIF(mode() within group (order by charger_phases),0) is null THEN 'charging (DC)'\n\t\t\t\t ELSE 'charging (AC)'\n\t\tEND AS current\n\tFROM charging_processes cp\n RIGHT JOIN charges ON cp.id = charges.charging_process_id\n WHERE\n\t cp.car_id = $car_id\n\t AND cp.charge_energy_added > 0\n \tAND ($__timeFilter(start_date) OR $__timeFilter(end_date))\n GROUP BY 1,2\n),\n\ncharges_total AS (\n SELECT\n \tsum(duration_sec) AS duration_sec,\n \tcurrent AS metric\n FROM charges_current\n GROUP BY 2\n ORDER BY metric\n)\n\nSELECT\n\tnow() AS time,\n\tcoalesce(duration_sec, 0) as duration_sec,\n metric\nFROM\n\tcharges_total;", - "refId": "B", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Time spent", - "type": "piechart" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "description": "", - "fieldConfig": { - "defaults": { - "mappings": [ - { - "options": { - "match": "null", - "result": { - "text": "N/A" - } - }, - "type": "special" - } - ], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "#c7d0d9", - "value": 0 - } - ] - }, - "unit": "none" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "speed_km" - }, - "properties": [ - { - "id": "unit", - "value": "velocitykmh" - }, - { - "id": "displayName", - "value": "Ø Speed excl. breaks" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "speed_mi" - }, - "properties": [ - { - "id": "unit", - "value": "velocitymph" - }, - { - "id": "displayName", - "value": "Ø Speed excl. breaks" - } - ] - } - ] - }, - "gridPos": { - "h": 2, - "w": 6, - "x": 13, - "y": 3 - }, - "id": 26, - "maxDataPoints": 100, - "options": { - "colorMode": "value", - "fieldOptions": { - "calcs": [ - "lastNotNull" - ] - }, - "graphMode": "none", - "justifyMode": "auto", - "orientation": "horizontal", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "mean" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "value_and_name", - "wideLayout": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "SELECT\n convert_km(sum(end_position.odometer - start_position.odometer)::numeric, '$length_unit') / (sum(extract(epoch FROM end_position.date - start_position.date)) / 3600) as \"speed_$length_unit\"\nFROM\n\tdrives\n\tJOIN positions start_position ON start_position_id = start_position.id\n\tJOIN positions end_position ON end_position_id = end_position.id\nWHERE\n\tdrives.car_id = $car_id\n\tAND $__timeFilter(start_date)\n\tAND end_date IS NOT NULL;", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "", - "type": "stat" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "#c7d0d9", - "value": 0 - } - ] - }, - "unit": "none" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "speed_km" - }, - "properties": [ - { - "id": "unit", - "value": "velocitykmh" - }, - { - "id": "displayName", - "value": "Ø Speed incl. DC charging" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "speed_mi" - }, - "properties": [ - { - "id": "unit", - "value": "velocitymph" - }, - { - "id": "displayName", - "value": "Ø Speed incl. DC charging" - } - ] - } - ] - }, - "gridPos": { - "h": 2, - "w": 6, - "x": 13, - "y": 5 - }, - "id": 28, - "maxDataPoints": 100, - "options": { - "colorMode": "value", - "fieldOptions": { - "calcs": [ - "lastNotNull" - ] - }, - "graphMode": "none", - "justifyMode": "auto", - "orientation": "horizontal", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "mean" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "value_and_name", - "wideLayout": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "WITH dc_charges AS (\n SELECT\n\t\tcp.id,\n extract(epoch FROM cp.end_date - cp.start_date) as duration_sec,\n\t\tCASE WHEN NULLIF(mode() within group (order by charger_phases),0) is null THEN 'DC'\n\t\t\t\t ELSE 'AC'\n\t\tEND AS current\n\tFROM charging_processes cp\n RIGHT JOIN charges ON cp.id = charges.charging_process_id\n WHERE\n\t cp.car_id = $car_id\n\t AND cp.charge_energy_added > 0\n AND ($__timeFilter(start_date) OR $__timeFilter(end_date))\n GROUP BY 1,2\n),\n\ndata AS (\n (\n SELECT\n sum(end_position.odometer - start_position.odometer) as distance, \n sum(extract(epoch FROM end_position.date - start_position.date)) as duration_sec\n FROM\n drives\n JOIN positions start_position ON start_position_id = start_position.id\n JOIN positions end_position ON end_position_id = end_position.id\n WHERE\n drives.car_id = $car_id\n AND $__timeFilter(start_date)\n ) UNION ALL (\n SELECT\n NULL as distance,\n sum(duration_sec)\n FROM\n dc_charges\n WHERE\n current = 'DC'\n )\n)\n\nSELECT convert_km(sum(distance)::numeric, '$length_unit') / (sum(duration_sec) / 3600) as \"speed_$length_unit\"\nfrom data", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "", - "type": "stat" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "description": "", - "fieldConfig": { - "defaults": { - "decimals": 0, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "#c7d0d9", - "value": 0 - } - ] - }, - "unit": "none" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "consumption_km" - }, - "properties": [ - { - "id": "unit", - "value": "Wh/km" - }, - { - "id": "displayName", - "value": "Ø Consumption (net)" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "consumption_mi" - }, - "properties": [ - { - "id": "unit", - "value": "Wh/mi" - }, - { - "id": "displayName", - "value": "Ø Consumption (net)" - } - ] - } - ] - }, - "gridPos": { - "h": 4, - "w": 3, - "x": 13, - "y": 7 - }, - "id": 30, - "maxDataPoints": 100, - "options": { - "colorMode": "value", - "fieldOptions": { - "calcs": [ - "lastNotNull" - ] - }, - "graphMode": "none", - "justifyMode": "auto", - "orientation": "auto", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "mean" - ], - "fields": "", - "values": true - }, - "showPercentChange": false, - "text": {}, - "textMode": "auto", - "wideLayout": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "SELECT\n sum((start_${preferred_range}_range_km - end_${preferred_range}_range_km) * car.efficiency * 1000) / \n convert_km(sum(distance)::numeric, '$length_unit') as \"consumption_$length_unit\"\nFROM drives\nJOIN cars car ON car.id = car_id\nWHERE $__timeFilter(start_date) AND car_id = $car_id", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "", - "type": "stat" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "description": "", - "fieldConfig": { - "defaults": { - "decimals": 0, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "#c7d0d9", - "value": 0 - } - ] - }, - "unit": "none" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "consumption_km" - }, - "properties": [ - { - "id": "unit", - "value": "Wh/km" - }, - { - "id": "displayName", - "value": "Ø Consumption (gross)" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "consumption_mi" - }, - "properties": [ - { - "id": "unit", - "value": "Wh/mi" - }, - { - "id": "displayName", - "value": "Ø Consumption (gross)" - } - ] - } - ] - }, - "gridPos": { - "h": 4, - "w": 3, - "x": 16, - "y": 7 - }, - "id": 32, - "maxDataPoints": 100, - "options": { - "colorMode": "value", - "fieldOptions": { - "calcs": [ - "lastNotNull" - ] - }, - "graphMode": "none", - "justifyMode": "auto", - "orientation": "auto", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "mean" - ], - "fields": "", - "values": true - }, - "showPercentChange": false, - "textMode": "value_and_name", - "wideLayout": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "-- Query shared between Charging Stats, Statistics & Trip Dashboards (with minor changes) - ensure to modify in all places when necessary\n\nwith drives_start_event as (\n\n select\n 'drive_start' as event, start_date as date, start_${preferred_range}_range_km as range, start_km as odometer, car_id\n from drives\n where car_id = $car_id and $__timeFilter(start_date) and 48 <= extract(hour FROM to_timestamp(${__to:date:seconds}) - to_timestamp(${__from:date:seconds}))\n\n),\n\ndrives_end_event as (\n\n select\n 'drive_end' as event, end_date as date, end_${preferred_range}_range_km as range, end_km as odometer, car_id\n from drives\n where car_id = $car_id and $__timeFilter(start_date) and 48 <= extract(hour FROM to_timestamp(${__to:date:seconds}) - to_timestamp(${__from:date:seconds}))\n\n),\n\ncharging_processes_start_event as (\n\n select\n 'charging_process_start' as event, start_date as date, start_${preferred_range}_range_km as range, p.odometer, cp.car_id\n from charging_processes cp\n inner join positions p on cp.position_id = p.id\n where cp.car_id = $car_id and $__timeFilter(start_date) and 48 <= extract(hour FROM to_timestamp(${__to:date:seconds}) - to_timestamp(${__from:date:seconds}))\n\n),\n\ncharging_processes_end_event as (\n\n select\n 'charging_process_end' as event, end_date as date, end_${preferred_range}_range_km as range, p.odometer, cp.car_id\n from charging_processes cp\n inner join positions p on cp.position_id = p.id\n where cp.car_id = $car_id and $__timeFilter(start_date) and 48 <= extract(hour FROM to_timestamp(${__to:date:seconds}) - to_timestamp(${__from:date:seconds}))\n\n),\n\npositions as (\n\n select\n case\n when drive_id is not null and lead(drive_id) over w is not null then 'drive_start'\n else 'something'\n end as event,\n date, ${preferred_range}_battery_range_km as range, p.odometer, p.car_id\n from positions p\n where ideal_battery_range_km is not null and car_id = $car_id and 48 > extract(hour FROM to_timestamp(${__to:date:seconds}) - to_timestamp(${__from:date:seconds}))\n and (drive_id in (select id from drives where $__timeFilter(start_date)) or drive_id is null and $__timeFilter(date))\n window w as (order by date)\n\n),\n\ncombined as (\n\n select * from drives_start_event\n union all\n select * from drives_end_event\n union all\n select * from charging_processes_start_event\n union all\n select * from charging_processes_end_event\n union all\n select * from positions\n\n),\n\nfinal as (\n\n select\n car_id,\n lead(odometer) over w - odometer as distance,\n case when event != 'drive_start' then greatest(range - lead(range) over w, 0) else range - lead(range) over w end as range_loss\n from combined\n window w as (order by date asc)\n\n)\n\nselect\n 'Total Energy consumed (gross)' as metric,\n sum(range_loss) * c.efficiency as value,\n (sum(range_loss) * c.efficiency * 1000) / nullif(convert_km(sum(distance)::numeric, '$length_unit'), 0) as consumption_$length_unit\nfrom final\n inner join cars c on car_id = c.id\ngroup by c.efficiency", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "", - "transformations": [ - { - "id": "organize", - "options": { - "excludeByName": { - "metric": true, - "value": true - }, - "includeByName": {}, - "indexByName": {}, - "renameByName": {} - } - } - ], - "type": "stat" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "description": "", - "fieldConfig": { - "defaults": { - "decimals": 2, - "mappings": [ - { - "options": { - "match": "null", - "result": { - "text": "N/A" - } - }, - "type": "special" - } - ], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "#c7d0d9", - "value": 0 - } - ] - }, - "unit": "none" - }, - "overrides": [] - }, - "gridPos": { - "h": 2, - "w": 6, - "x": 13, - "y": 11 - }, - "id": 22, - "maxDataPoints": 100, - "options": { - "colorMode": "value", - "fieldOptions": { - "calcs": [ - "lastNotNull" - ] - }, - "graphMode": "none", - "justifyMode": "auto", - "orientation": "auto", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "mean" - ], - "fields": "", - "values": true - }, - "showPercentChange": false, - "textMode": "value_and_name", - "wideLayout": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "select sum(cost) as \"Total Charging Cost\" from charging_processes where $__timeFilter(end_date) AND car_id = $car_id;", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "", - "type": "stat" - }, - { - "datasource": { - "type": "datasource", - "uid": "-- Mixed --" - }, - "description": "", - "fieldConfig": { - "defaults": { - "decimals": 2, - "mappings": [ - { - "options": { - "match": "null", - "result": { - "text": "N/A" - } - }, - "type": "special" - } - ], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "#c7d0d9", - "value": 0 - } - ] - }, - "unit": "none" - }, - "overrides": [] - }, - "gridPos": { - "h": 2, - "w": 5, - "x": 19, - "y": 11 - }, - "id": 43, - "maxDataPoints": 100, - "options": { - "colorMode": "value", - "fieldOptions": { - "calcs": [ - "lastNotNull" - ] - }, - "graphMode": "none", - "justifyMode": "auto", - "orientation": "auto", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "mean" - ], - "fields": "", - "values": true - }, - "showPercentChange": false, - "textMode": "value_and_name", - "wideLayout": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "WITH charges as (\r\n SELECT\r\n sum(cost) / sum(charge_energy_added) as cost_per_kwh\r\n FROM charging_processes\r\n where car_id = $car_id and $__timeFilter(start_date)\r\n),\r\n\r\nmileage as (\r\n SELECT convert_km((max(odometer) - min(odometer))::numeric, '$length_unit') as distance\r\n FROM positions\r\n WHERE car_id = $car_id and ideal_battery_range_km IS NOT NULL\r\n and (drive_id in (select id from drives where $__timeFilter(start_date)) or drive_id is null and $__timeFilter(date))\r\n)\r\n\r\nselect\r\n 'Total Energy consumed (gross)' as metric, -- Hack required for Join Transformation\r\n cost_per_kwh / distance * 100 as cost_mileage\r\nfrom mileage cross join charges", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - }, - { - "datasource": { - "type": "datasource", - "uid": "-- Dashboard --" - }, - "hide": false, - "panelId": 32, - "refId": "B" - } - ], - "title": "", - "transformations": [ - { - "id": "joinByField", - "options": { - "byField": "metric", - "mode": "inner" - } - }, - { - "id": "calculateField", - "options": { - "alias": "Ø Cost per 100 $length_unit", - "binary": { - "left": { - "matcher": { - "id": "byName", - "options": "cost_mileage" - } - }, - "operator": "*", - "right": { - "matcher": { - "id": "byName", - "options": "value" - } - } - }, - "mode": "binary", - "reduce": { - "reducer": "sum" - }, - "replaceFields": true, - "window": { - "reducer": "mean", - "windowAlignment": "trailing", - "windowSize": 0.1, - "windowSizeMode": "percentage" - } - } - } - ], - "type": "stat" - }, - { - "datasource": { - "type": "datasource", - "uid": "-- Mixed --" - }, - "fieldConfig": { - "defaults": { - "decimals": 2, - "displayName": "${__cell_0}", - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "light-yellow", - "value": 0 - }, - { - "color": "semi-dark-yellow", - "value": 10 - }, - { - "color": "semi-dark-orange", - "value": 100 - } - ] - }, - "unit": "kwatth" - }, - "overrides": [] - }, - "gridPos": { - "h": 3, - "w": 11, - "x": 13, - "y": 13 - }, - "id": 40, - "options": { - "displayMode": "gradient", - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": false - }, - "maxVizHeight": 300, - "minVizHeight": 10, - "minVizWidth": 0, - "namePlacement": "auto", - "orientation": "horizontal", - "reduceOptions": { - "calcs": [ - "mean" - ], - "fields": "", - "values": true - }, - "showUnfilled": false, - "sizing": "auto", - "valueMode": "color" - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "datasource", - "uid": "-- Dashboard --" - }, - "panelId": 32, - "refId": "A" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "WITH charges_current AS (\n SELECT\n\t\tcp.id,\n\t\tcp.charge_energy_added as energy_added,\n\t\tCASE WHEN NULLIF(mode() within group (order by charger_phases),0) is null THEN 'Total Energy added (DC)'\n\t\t\t\t ELSE 'Total Energy added (AC)'\n\t\tEND AS metric\n\tFROM charging_processes cp\n RIGHT JOIN charges ON cp.id = charges.charging_process_id\n WHERE\n\t cp.car_id = $car_id\n\t AND cp.charge_energy_added > 0\n \tAND ($__timeFilter(start_date) OR $__timeFilter(end_date))\n GROUP BY 1,2\n)\n\nSELECT metric, sum(energy_added) AS energy_added\nFROM charges_current\nGROUP BY 1\nORDER BY 1 DESC;", - "refId": "B", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "", - "transformations": [ - { - "id": "filterFieldsByName", - "options": { - "include": { - "pattern": "^(?:(?!consumption).)*$" - } - } - } - ], - "type": "bargauge" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "continuous-GrYlRd" - }, - "custom": { - "axisPlacement": "auto", - "fillOpacity": 100, - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineWidth": 0, - "spanNulls": false - }, - "mappings": [ - { - "options": { - "0": { - "color": "#6ED0E0", - "index": 0, - "text": "online" - }, - "1": { - "color": "#8F3BB8", - "index": 1, - "text": "driving" - }, - "2": { - "color": "#F2CC0C", - "index": 2, - "text": "charging" - }, - "3": { - "color": "#FFB357", - "index": 3, - "text": "offline" - }, - "4": { - "color": "#56A64B", - "index": 4, - "text": "asleep" - }, - "5": { - "color": "#6ED0E0", - "index": 5, - "text": "online" - }, - "6": { - "color": "#E02F44", - "index": 6, - "text": "updating" - }, - "null": { - "index": 7, - "text": "N/A" - } - }, - "type": "value" - } - ], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": 0 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 2, - "w": 24, - "x": 0, - "y": 16 - }, - "id": 20, - "options": { - "alignValue": "center", - "legend": { - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "mergeValues": true, - "rowHeight": 1, - "showValue": "never", - "tooltip": { - "hideZeros": false, - "maxHeight": 600, - "mode": "single", - "sort": "none" - } - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "time_series", - "rawQuery": true, - "rawSql": "WITH states AS (\n SELECT\n unnest(ARRAY [start_date + interval '1 second', end_date]) AS date,\n unnest(ARRAY [2, 0]) AS state\n FROM charging_processes\n WHERE\n car_id = $car_id AND \n ($__timeFrom() :: timestamp - interval '30 day') < start_date AND \n (end_date < ($__timeTo() :: timestamp + interval '30 day') OR end_date IS NULL)\n UNION\n SELECT\n unnest(ARRAY [start_date + interval '1 second', end_date]) AS date,\n unnest(ARRAY [1, 0]) AS state\n FROM drives\n WHERE\n car_id = $car_id AND \n ($__timeFrom() :: timestamp - interval '30 day') < start_date AND \n (end_date < ($__timeTo() :: timestamp + interval '30 day') OR end_date IS NULL)\n UNION\n SELECT\n start_date AS date,\n CASE\n WHEN state = 'offline' THEN 3\n WHEN state = 'asleep' THEN 4\n WHEN state = 'online' THEN 5\n END AS state\n FROM states\n WHERE\n car_id = $car_id AND \n ($__timeFrom() :: timestamp - interval '30 day') < start_date AND \n (end_date < ($__timeTo() :: timestamp + interval '30 day') OR end_date IS NULL)\n UNION\n SELECT\n unnest(ARRAY [start_date + interval '1 second', end_date]) AS date,\n unnest(ARRAY [6, 0]) AS state\n FROM updates\n WHERE\n car_id = $car_id AND \n ($__timeFrom() :: timestamp - interval '30 day') < start_date AND \n (end_date < ($__timeTo() :: timestamp + interval '30 day') OR end_date IS NULL)\n)\nSELECT date AS \"time\", state\nFROM states\nWHERE \n date IS NOT NULL AND\n ($__timeFrom() :: timestamp - interval '30 day') < date AND \n date < ($__timeTo() :: timestamp + interval '30 day') \nORDER BY date ASC, state ASC;", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "", - "transparent": true, - "type": "state-timeline" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "custom": { - "align": "auto", - "cellOptions": { - "type": "auto" - }, - "inspect": false - }, - "decimals": 2, - "displayName": "", - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": 0 - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "start_date" - }, - "properties": [ - { - "id": "displayName", - "value": "Date" - }, - { - "id": "unit", - "value": "dateTimeAsLocal" - }, - { - "id": "links", - "value": [ - { - "targetBlank": false, - "title": "View drive details", - "url": "/d/zm7wN6Zgz/drive-details?from=${__data.fields.start_date_ts.numeric}&to=${__data.fields.end_date_ts.numeric}&var-car_id=${__data.fields.car_id.numeric}&var-drive_id=${__data.fields.drive_id.numeric}" - } - ] - }, - { - "id": "custom.width", - "value": 210 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "consumption_kwh_km" - }, - "properties": [ - { - "id": "displayName", - "value": "Ø Consumption (net)" - }, - { - "id": "unit", - "value": "Wh/km" - }, - { - "id": "decimals", - "value": 0 - }, - { - "id": "custom.width", - "value": 165 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "consumption_kwh_mi" - }, - "properties": [ - { - "id": "displayName", - "value": "Ø Consumption (net)" - }, - { - "id": "unit", - "value": "Wh/mi" - }, - { - "id": "decimals", - "value": 0 - }, - { - "id": "custom.width", - "value": 165 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "distance_km" - }, - "properties": [ - { - "id": "displayName", - "value": "Distance" - }, - { - "id": "unit", - "value": "lengthkm" - }, - { - "id": "decimals", - "value": 0 - }, - { - "id": "custom.width", - "value": 80 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "start_address" - }, - "properties": [ - { - "id": "displayName", - "value": "Start" - }, - { - "id": "links", - "value": [ - { - "targetBlank": true, - "title": "Create or edit geo-fence", - "url": "${base_url:raw}/geo-fences/${__data.fields.start_path}" - } - ] - }, - { - "id": "custom.minWidth", - "value": 200 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "end_address" - }, - "properties": [ - { - "id": "displayName", - "value": "Destination" - }, - { - "id": "links", - "value": [ - { - "targetBlank": true, - "title": "Create or edit geo-fence", - "url": "${base_url:raw}/geo-fences/${__data.fields.end_path}" - } - ] - }, - { - "id": "custom.minWidth", - "value": 200 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "duration_min" - }, - "properties": [ - { - "id": "displayName", - "value": "Duration" - }, - { - "id": "unit", - "value": "m" - }, - { - "id": "decimals", - "value": 0 - }, - { - "id": "links", - "value": [ - { - "title": "${__data.fields.duration_str}", - "url": "" - } - ] - }, - { - "id": "custom.width", - "value": 100 - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*_ts/" - }, - "properties": [ - { - "id": "custom.hidden", - "value": true - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "distance_mi" - }, - "properties": [ - { - "id": "displayName", - "value": "Distance" - }, - { - "id": "unit", - "value": "lengthmi" - }, - { - "id": "decimals", - "value": 0 - }, - { - "id": "custom.width", - "value": 80 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "% Start" - }, - "properties": [ - { - "id": "unit", - "value": "percent" - }, - { - "id": "custom.align", - "value": "auto" - }, - { - "id": "decimals", - "value": 0 - }, - { - "id": "custom.width", - "value": 75 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "% End" - }, - "properties": [ - { - "id": "unit", - "value": "percent" - }, - { - "id": "custom.align", - "value": "auto" - }, - { - "id": "decimals", - "value": 0 - }, - { - "id": "custom.width", - "value": 65 - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "(start_path|end_path|duration_str|car_id|drive_id)" - }, - "properties": [ - { - "id": "custom.hidden", - "value": true - } - ] - } - ] - }, - "gridPos": { - "h": 11, - "w": 24, - "x": 0, - "y": 18 - }, - "id": 2, - "options": { - "cellHeight": "sm", - "footer": { - "countRows": false, - "fields": "", - "reducer": [ - "sum" - ], - "show": false - }, - "showHeader": true, - "sortBy": [ - { - "desc": true, - "displayName": "Date" - } - ] - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "WITH data AS (\n SELECT\n round(extract(epoch FROM start_date)) * 1000 AS start_date_ts,\n round(extract(epoch FROM end_date)) * 1000 AS end_date_ts,\n car.id as car_id,\n CASE WHEN start_geofence.id IS NULL THEN CONCAT('new?lat=', start_position.latitude, '&lng=', start_position.longitude)\n WHEN start_geofence.id IS NOT NULL THEN CONCAT(start_geofence.id, '/edit')\n END as start_path,\n CASE WHEN end_geofence.id IS NULL THEN CONCAT('new?lat=', end_position.latitude, '&lng=', end_position.longitude)\n WHEN end_geofence.id IS NOT NULL THEN CONCAT(end_geofence.id, '/edit')\n END as end_path,\n TO_CHAR((duration_min * INTERVAL '1 minute'), 'HH24:MI') as duration_str,\n drives.id as drive_id,\n -- Columns\n start_date,\n COALESCE(start_geofence.name, CONCAT_WS(', ', COALESCE(start_address.name, nullif(CONCAT_WS(' ', start_address.road, start_address.house_number), '')), start_address.city)) AS start_address,\n COALESCE(end_geofence.name, CONCAT_WS(', ', COALESCE(end_address.name, nullif(CONCAT_WS(' ', end_address.road, end_address.house_number), '')), end_address.city)) AS end_address,\n duration_min,\n distance,\n start_position.usable_battery_level as start_usable_battery_level,\n start_position.battery_level as start_battery_level,\n end_position.usable_battery_level as end_usable_battery_level,\n end_position.battery_level as end_battery_level,\n start_position.battery_level != start_position.usable_battery_level OR end_position.battery_level != end_position.usable_battery_level as reduced_range,\n duration_min > 1 AND distance > 1 AND ( \n start_position.usable_battery_level IS NULL OR end_position.usable_battery_level IS NULL\tOR\n (end_position.battery_level - end_position.usable_battery_level) = 0 \n ) as is_sufficiently_precise,\n start_${preferred_range}_range_km - end_${preferred_range}_range_km as range_diff,\n car.efficiency as car_efficiency,\n outside_temp_avg,\n distance / NULLIF(duration_min, 0) * 60 AS avg_speed\n FROM drives\n LEFT JOIN addresses start_address ON start_address_id = start_address.id\n LEFT JOIN addresses end_address ON end_address_id = end_address.id\n LEFT JOIN positions start_position ON start_position_id = start_position.id\n LEFT JOIN positions end_position ON end_position_id = end_position.id\n LEFT JOIN geofences start_geofence ON start_geofence_id = start_geofence.id\n LEFT JOIN geofences end_geofence ON end_geofence_id = end_geofence.id\n LEFT JOIN cars car ON car.id = drives.car_id\n WHERE $__timeFilter(start_date) AND drives.car_id = $car_id\n ORDER BY start_date DESC\n)\nSELECT\n start_date_ts,\n end_date_ts,\n car_id,\n start_path,\n end_path,\n duration_str,\n drive_id,\n -- Columns\n start_date,\n start_address,\n end_address,\n duration_min,\n convert_km(distance::numeric, '$length_unit') AS distance_$length_unit,\n start_battery_level as \"% Start\",\n end_battery_level as \"% End\",\n CASE WHEN is_sufficiently_precise THEN range_diff * car_efficiency / convert_km(distance::numeric, '$length_unit') * 1000\n END AS consumption_kWh_$length_unit\nFROM data;", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Drives", - "type": "table" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "custom": { - "align": "auto", - "cellOptions": { - "type": "auto" - }, - "inspect": false - }, - "decimals": 2, - "displayName": "", - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": 0 - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "start_date" - }, - "properties": [ - { - "id": "displayName", - "value": "Date" - }, - { - "id": "unit", - "value": "dateTimeAsLocal" - }, - { - "id": "links", - "value": [ - { - "targetBlank": false, - "title": "View charge details", - "url": "/d/BHhxFeZRz/charge-details?from=${__data.fields.start_date_ts.numeric}&to=${__data.fields.end_date_ts.numeric}&var-car_id=${__data.fields.car_id.numeric}&var-charging_process_id=${__data.fields.id.numeric:raw}" - } - ] - }, - { - "id": "custom.width", - "value": 210 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "charge_energy_added" - }, - "properties": [ - { - "id": "displayName", - "value": "Energy added" - }, - { - "id": "decimals", - "value": 2 - }, - { - "id": "custom.width", - "value": 115 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "start_battery_level" - }, - "properties": [ - { - "id": "displayName", - "value": "% Start" - }, - { - "id": "unit", - "value": "percent" - }, - { - "id": "decimals", - "value": 0 - }, - { - "id": "custom.width", - "value": 72 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "end_battery_level" - }, - "properties": [ - { - "id": "displayName", - "value": "% End" - }, - { - "id": "unit", - "value": "percent" - }, - { - "id": "decimals", - "value": 0 - }, - { - "id": "custom.width", - "value": 62 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "duration_min" - }, - "properties": [ - { - "id": "displayName", - "value": "Duration" - }, - { - "id": "unit", - "value": "m" - }, - { - "id": "decimals", - "value": 1 - }, - { - "id": "custom.width", - "value": 80 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "cost" - }, - "properties": [ - { - "id": "displayName", - "value": "Cost" - }, - { - "id": "unit", - "value": "none" - }, - { - "id": "decimals", - "value": 2 - }, - { - "id": "links", - "value": [ - { - "targetBlank": false, - "title": "Set Cost", - "url": "${base_url:raw}/charge-cost/${__data.fields.id.numeric:raw}" - } - ] - }, - { - "id": "custom.width", - "value": 70 - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*_ts/" - }, - "properties": [ - { - "id": "custom.hidden", - "value": true - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "id" - }, - "properties": [ - { - "id": "custom.hidden", - "value": true - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "address" - }, - "properties": [ - { - "id": "displayName", - "value": "Location" - }, - { - "id": "links", - "value": [ - { - "targetBlank": true, - "title": "Create or edit geo-fence", - "url": "${base_url:raw}/geo-fences/${__data.fields.path}" - } - ] - }, - { - "id": "custom.minWidth", - "value": 200 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "range_added_km" - }, - "properties": [ - { - "id": "displayName", - "value": "Range gained" - }, - { - "id": "unit", - "value": "lengthkm" - }, - { - "id": "decimals", - "value": 0 - }, - { - "id": "custom.width", - "value": 120 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "charge_energy_added_per_hour" - }, - "properties": [ - { - "id": "displayName", - "value": "Ø Power" - }, - { - "id": "unit", - "value": "kwatt" - }, - { - "id": "decimals", - "value": 1 - }, - { - "id": "custom.cellOptions", - "value": { - "type": "color-text" - } - }, - { - "id": "thresholds", - "value": { - "mode": "absolute", - "steps": [ - { - "color": "#96D98D", - "value": 0 - }, - { - "color": "#56A64B", - "value": 20 - }, - { - "color": "#37872D", - "value": 55 - } - ] - } - }, - { - "id": "custom.width", - "value": 80 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "range_added_mi" - }, - "properties": [ - { - "id": "displayName", - "value": "Range gained" - }, - { - "id": "unit", - "value": "lengthmi" - }, - { - "id": "decimals", - "value": 0 - }, - { - "id": "custom.minWidth", - "value": 120 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "path" - }, - "properties": [ - { - "id": "custom.hidden", - "value": true - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "charge_energy_used" - }, - "properties": [ - { - "id": "displayName", - "value": "Energy used" - }, - { - "id": "decimals", - "value": 2 - }, - { - "id": "custom.width", - "value": 105 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "car_id" - }, - "properties": [ - { - "id": "custom.hidden", - "value": true - } - ] - } - ] - }, - "gridPos": { - "h": 8, - "w": 24, - "x": 0, - "y": 29 - }, - "id": 36, - "options": { - "cellHeight": "sm", - "footer": { - "countRows": false, - "fields": "", - "reducer": [ - "sum" - ], - "show": false - }, - "showHeader": true, - "sortBy": [ - { - "desc": true, - "displayName": "Date" - } - ] - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "WITH data AS (\n SELECT\n (round(extract(epoch FROM start_date) - 10) * 1000) AS start_date_ts,\n (round(extract(epoch FROM end_date) + 10) * 1000) AS end_date_ts,\n start_date,\n end_date,\n CONCAT_WS(', ', COALESCE(addresses.name, CONCAT_WS(' ', addresses.road, addresses.house_number)), addresses.city) AS address,\n g.name as geofence_name,\n g.id as geofence_id,\n p.latitude,\n p.longitude,\n charge_energy_added,\n charge_energy_used,\n duration_min,\n start_battery_level,\n end_battery_level,\n end_${preferred_range}_range_km - start_${preferred_range}_range_km as range_added,\n outside_temp_avg,\n c.id,\n p.odometer - lag(p.odometer) OVER (ORDER BY start_date) AS distance,\n cars.efficiency,\n c.car_id,\n cost\n FROM\n charging_processes c\n LEFT JOIN positions p ON p.id = c.position_id\n LEFT JOIN cars ON cars.id = c.car_id\n LEFT JOIN addresses ON addresses.id = c.address_id\n LEFT JOIN geofences g ON g.id = geofence_id\nWHERE \n (charge_energy_added IS NULL OR charge_energy_added > 0) AND\n c.car_id = $car_id AND\n $__timeFilter(start_date)\nORDER BY\n start_date\n)\nSELECT\n start_date_ts,\n end_date_ts,\n CASE WHEN geofence_id IS NULL THEN CONCAT('new?lat=', latitude, '&lng=', longitude)\n WHEN geofence_id IS NOT NULL THEN CONCAT(geofence_id, '/edit')\n END as path,\n car_id,\n id,\n -- Columns\n start_date,\n COALESCE(geofence_name, address) as address, \n duration_min,\n cost,\n charge_energy_added,\n charge_energy_used,\n charge_energy_added * 60 / NULLIF (duration_min, 0) AS charge_energy_added_per_hour,\n convert_km(range_added, '$length_unit') AS range_added_$length_unit,\n start_battery_level,\n end_battery_level\nFROM\n data\nWHERE\n (distance >= 0 OR distance IS NULL)\nORDER BY\n start_date DESC;\n ", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Charges", - "transformations": [ - { - "id": "merge", - "options": { - "reducers": [] - } - } - ], - "type": "table" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "barWidthFactor": 0.6, - "drawStyle": "line", - "fillOpacity": 10, - "gradientMode": "opacity", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": true, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "decimals": 0, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": 0 - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "percent" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "battery_level" - }, - "properties": [ - { - "id": "unit", - "value": "percent" - }, - { - "id": "displayName", - "value": "SOC" - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": ".*_km$" - }, - "properties": [ - { - "id": "unit", - "value": "lengthkm" - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": ".*_mi$" - }, - "properties": [ - { - "id": "unit", - "value": "lengthmi" - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "range_ideal_.*" - }, - "properties": [ - { - "id": "displayName", - "value": "Range (ideal)" - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "range_rated_.*" - }, - "properties": [ - { - "id": "displayName", - "value": "Range (rated)" - } - ] - } - ] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 0, - "y": 37 - }, - "id": 42, - "options": { - "legend": { - "calcs": [ - "min", - "max", - "lastNotNull" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "hideZeros": false, - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "time_series", - "rawQuery": true, - "rawSql": "(\n SELECT $__timeGroup(date, '5s'), avg(battery_level) as battery_level, convert_km(avg(${preferred_range}_battery_range_km), '$length_unit') as range_${preferred_range}_${length_unit}\n FROM positions\n WHERE date BETWEEN ($__timeFrom()::timestamp - interval '1 day') AND ($__timeTo()::timestamp + interval '1 day') AND car_id = $car_id\n GROUP BY 1\n) UNION ALL (\n SELECT $__timeGroup(date, '5s'), avg(battery_level) as battery_level, convert_km(avg(${preferred_range}_battery_range_km), '$length_unit') as range_${preferred_range}_${length_unit}\n FROM charges c\n LEFT JOIN charging_processes p ON c.charging_process_id = p.id\n WHERE date BETWEEN ($__timeFrom()::timestamp - interval '1 day') AND ($__timeTo()::timestamp + interval '1 day') AND p.car_id = $car_id\n GROUP BY 1\n)\nORDER BY 1", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Battery Level & Range", - "type": "timeseries" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "barWidthFactor": 0.6, - "drawStyle": "line", - "fillOpacity": 10, - "gradientMode": "opacity", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": true, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": 0 - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": ".*_m$" - }, - "properties": [ - { - "id": "unit", - "value": "lengthm" - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": ".*_ft$" - }, - "properties": [ - { - "id": "unit", - "value": "lengthft" - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "elevation_.*" - }, - "properties": [ - { - "id": "displayName", - "value": "Elevation" - }, - { - "id": "color", - "value": { - "fixedColor": "semi-dark-blue", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 12, - "y": 37 - }, - "id": 8, - "options": { - "legend": { - "calcs": [ - "min", - "max", - "lastNotNull" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "hideZeros": false, - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "time_series", - "rawQuery": true, - "rawSql": "SELECT\n\t$__timeGroup(date, '5s'),\n\tROUND(convert_m(avg(elevation), '$alternative_length_unit')) AS elevation_${alternative_length_unit}\nFROM\n\tpositions\nWHERE\n car_id = $car_id AND\n date BETWEEN ($__timeFrom()::timestamp - interval '1 day') AND ($__timeTo()::timestamp + interval '1 day')\nGROUP BY\n 1\nORDER BY\n 1 ASC", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Elevation", - "type": "timeseries" - } - ], - "preload": false, - "refresh": "", - "schemaVersion": 41, - "tags": [ - "tesla" - ], - "templating": { - "list": [ - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "SELECT\n id as __value,\n CASE WHEN COUNT(id) OVER (PARTITION BY name) > 1 AND name IS NOT NULL THEN CONCAT(name, ' - ', RIGHT(vin, 6)) ELSE COALESCE(name, CONCAT('VIN ', vin)) end as __text \nFROM cars\nORDER BY display_priority ASC, name ASC, vin ASC;", - "hide": 2, - "includeAll": true, - "label": "Car", - "name": "car_id", - "options": [], - "query": "SELECT\n id as __value,\n CASE WHEN COUNT(id) OVER (PARTITION BY name) > 1 AND name IS NOT NULL THEN CONCAT(name, ' - ', RIGHT(vin, 6)) ELSE COALESCE(name, CONCAT('VIN ', vin)) end as __text \nFROM cars\nORDER BY display_priority ASC, name ASC, vin ASC;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "select unit_of_temperature from settings limit 1;", - "hide": 2, - "includeAll": false, - "label": "temperature unit", - "name": "temp_unit", - "options": [], - "query": "select unit_of_temperature from settings limit 1;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "select unit_of_length from settings limit 1;", - "hide": 2, - "includeAll": false, - "label": "length unit", - "name": "length_unit", - "options": [], - "query": "select unit_of_length from settings limit 1;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "select case when unit_of_length = 'km' then 'm' when unit_of_length = 'mi' then 'ft' end from settings limit 1;", - "hide": 2, - "includeAll": false, - "name": "alternative_length_unit", - "options": [], - "query": "select case when unit_of_length = 'km' then 'm' when unit_of_length = 'mi' then 'ft' end from settings limit 1;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "select preferred_range from settings limit 1;", - "hide": 2, - "includeAll": false, - "name": "preferred_range", - "options": [], - "query": "select preferred_range from settings limit 1;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "select base_url from settings limit 1;", - "hide": 2, - "includeAll": false, - "name": "base_url", - "options": [], - "query": "select base_url from settings limit 1;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "with last_drives as (select start_date from drives order by start_date desc limit 3)\nselect extract(epoch from min(start_date)) * 1000 from last_drives;", - "hide": 2, - "includeAll": false, - "name": "from", - "options": [], - "query": "with last_drives as (select start_date from drives order by start_date desc limit 3)\nselect extract(epoch from min(start_date)) * 1000 from last_drives;", - "refresh": 1, - "regex": "", - "type": "query" - } - ] - }, - "time": { - "from": "now-24h", - "to": "now" - }, - "timepicker": {}, - "timezone": "", - "title": "Tesla Trip", - "uid": "FkUpJpQZk", - "version": 1 - } diff --git a/argocd/manifests/grafana-config/dashboards/configmap-teslamate-updates.yaml b/argocd/manifests/grafana-config/dashboards/configmap-teslamate-updates.yaml deleted file mode 100644 index f1de925..0000000 --- a/argocd/manifests/grafana-config/dashboards/configmap-teslamate-updates.yaml +++ /dev/null @@ -1,619 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: grafana-dashboard-teslamate-updates - namespace: monitoring - labels: - grafana_dashboard: "1" - annotations: - grafana_folder: "TeslaMate" -data: - teslamate-updates.json: | - { - "annotations": { - "list": [ - { - "builtIn": 1, - "datasource": { - "type": "grafana", - "uid": "-- Grafana --" - }, - "enable": true, - "hide": true, - "iconColor": "rgba(0, 211, 255, 1)", - "name": "Annotations & Alerts", - "type": "dashboard" - } - ] - }, - "editable": true, - "fiscalYearStartMonth": 0, - "graphTooltip": 0, - "links": [ - { - "icon": "dashboard", - "tags": [], - "title": "TeslaMate", - "tooltip": "", - "type": "link", - "url": "${base_url:raw}" - }, - { - "asDropdown": true, - "icon": "external link", - "tags": [ - "tesla" - ], - "title": "Dashboards", - "type": "dashboards" - } - ], - "panels": [ - { - "collapsed": false, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 0 - }, - "id": 4, - "panels": [], - "repeat": "car_id", - "title": "$car_id", - "type": "row" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "#c7d0d9", - "value": 0 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 3, - "w": 8, - "x": 0, - "y": 1 - }, - "id": 8, - "options": { - "colorMode": "value", - "graphMode": "none", - "justifyMode": "auto", - "orientation": "auto", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "count" - ], - "fields": "", - "values": true - }, - "showPercentChange": false, - "textMode": "value", - "wideLayout": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "SELECT count(*)\nFROM updates\nWHERE $__timeFilter(start_date) AND car_id = $car_id", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Updates", - "type": "stat" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "decimals": 1, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "#c7d0d9", - "value": 0 - } - ] - }, - "unit": "dtdurations" - }, - "overrides": [] - }, - "gridPos": { - "h": 3, - "w": 16, - "x": 8, - "y": 1 - }, - "id": 6, - "options": { - "colorMode": "value", - "graphMode": "none", - "justifyMode": "auto", - "orientation": "auto", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "mean" - ], - "fields": "", - "values": true - }, - "showPercentChange": false, - "textMode": "value", - "wideLayout": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "SELECT percentile_disc(0.5) WITHIN GROUP (ORDER BY since_last_update) FROM (\n\tSELECT extract(EPOCH FROM start_date - lag(start_date) OVER (ORDER BY start_date)) AS since_last_update\n\tFROM updates\n\tWHERE $__timeFilter(start_date) AND car_id = $car_id\n) d;", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Median time between updates", - "type": "stat" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "custom": { - "align": "auto", - "cellOptions": { - "type": "auto" - }, - "filterable": false, - "inspect": false - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": 0 - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "time" - }, - "properties": [ - { - "id": "custom.minWidth", - "value": 210 - }, - { - "id": "displayName", - "value": "Date" - }, - { - "id": "unit", - "value": "dateTimeAsLocal" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "update_duration" - }, - "properties": [ - { - "id": "custom.minWidth", - "value": 120 - }, - { - "id": "displayName", - "value": "Duration" - }, - { - "id": "unit", - "value": "dtdurations" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "since_last_update" - }, - "properties": [ - { - "id": "custom.minWidth", - "value": 180 - }, - { - "id": "displayName", - "value": "Since Previous Update" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "version" - }, - "properties": [ - { - "id": "displayName", - "value": "Installed Version" - }, - { - "id": "custom.align", - "value": "right" - }, - { - "id": "decimals", - "value": 2 - }, - { - "id": "links", - "value": [ - { - "targetBlank": true, - "title": "${__data.fields[version]} release notes", - "url": "https://www.notateslaapp.com/software-updates/version/${__data.fields[version]}/release-notes" - } - ] - }, - { - "id": "unit", - "value": "string" - }, - { - "id": "custom.minWidth", - "value": 150 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "chg_ct" - }, - "properties": [ - { - "id": "custom.minWidth", - "value": 120 - }, - { - "id": "displayName", - "value": "# of Charges" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "avg_ideal_range_km" - }, - "properties": [ - { - "id": "custom.minWidth", - "value": 130 - }, - { - "id": "decimals", - "value": 1 - }, - { - "id": "unit", - "value": "lengthkm" - }, - { - "id": "displayName", - "value": "Ø Ideal range" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "avg_rated_range_km" - }, - "properties": [ - { - "id": "custom.minWidth", - "value": 130 - }, - { - "id": "decimals", - "value": 1 - }, - { - "id": "unit", - "value": "lengthkm" - }, - { - "id": "displayName", - "value": "Ø Rated range" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "avg_ideal_range_mi" - }, - "properties": [ - { - "id": "custom.minWidth", - "value": 130 - }, - { - "id": "decimals", - "value": 1 - }, - { - "id": "unit", - "value": "lengthmi" - }, - { - "id": "displayName", - "value": "Ø Ideal range" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "avg_rated_range_mi" - }, - "properties": [ - { - "id": "custom.minWidth", - "value": 130 - }, - { - "id": "decimals", - "value": 1 - }, - { - "id": "unit", - "value": "lengthmi" - }, - { - "id": "displayName", - "value": "Ø Rated range" - } - ] - } - ] - }, - "gridPos": { - "h": 28, - "w": 24, - "x": 0, - "y": 4 - }, - "id": 2, - "options": { - "cellHeight": "sm", - "footer": { - "countRows": false, - "fields": "", - "reducer": [ - "sum" - ], - "show": false - }, - "showHeader": true, - "sortBy": [ - { - "desc": true, - "displayName": "Date" - } - ] - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "with u as (\r\n select *, coalesce(lag(start_date) over(order by start_date desc), now()) as next_start_date \r\n from updates\r\n where car_id = $car_id and $__timeFilter(start_date)\r\n),\r\nrng as (\r\n SELECT\r\n\t date_trunc('hour', timezone('UTC', date), '$__timezone') AS date,\r\n\t (sum(${preferred_range}_battery_range_km)/ nullif(sum(usable_battery_level),0) * 100 ) AS \"battery_rng\",\r\n\t sum(case when action = 'Charge' then 1 else 0 end) as chg_ct\r\n FROM (\r\n select usable_battery_level, start_date as date, start_rated_range_km as rated_battery_range_km, start_ideal_range_km as ideal_battery_range_km, 'Drive' as action\r\n from drives d\r\n inner join positions p on d.start_position_id = p.id \r\n where d.car_id = $car_id and $__timeFilter(start_date) and usable_battery_level > 0\r\n union all\r\n select end_battery_level as usable_battery_level, end_date, end_rated_range_km as rated_battery_range_km, end_ideal_range_km as ideal_battery_range_km, 'Charge' as action\r\n from charging_processes p\r\n where $__timeFilter(end_date) and p.car_id = $car_id\r\n ) as data\r\n GROUP BY 1\r\n)\r\n\r\nselect\t\r\n u.start_date as time,\r\n\textract(EPOCH FROM u.end_date - u.start_date) AS update_duration,\r\n\tage(date(u.start_date), date(lag(u.start_date) OVER (ORDER BY u.start_date))) AS since_last_update,\r\n\tsplit_part(u.version, ' ', 1) as version,\r\n\tsum(r.chg_ct) as chg_ct,\r\n\tconvert_km(avg(r.battery_rng), '$length_unit')::numeric(6,2) AS avg_${preferred_range}_range_${length_unit}\r\nfrom u u\r\nleft join rng r\r\n\tON r.date between u.start_date and u.next_start_date\r\ngroup by u.car_id,\r\n\tu.start_date,\r\n\tu.end_date,\r\n\tnext_start_date,\r\n\tsplit_part(u.version, ' ', 1)", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Updates", - "type": "table" - } - ], - "preload": false, - "refresh": "", - "schemaVersion": 41, - "tags": [ - "tesla" - ], - "templating": { - "list": [ - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "SELECT\n id as __value,\n CASE WHEN COUNT(id) OVER (PARTITION BY name) > 1 AND name IS NOT NULL THEN CONCAT(name, ' - ', RIGHT(vin, 6)) ELSE COALESCE(name, CONCAT('VIN ', vin)) end as __text \nFROM cars\nORDER BY display_priority ASC, name ASC, vin ASC;", - "hide": 2, - "includeAll": true, - "label": "Car", - "name": "car_id", - "options": [], - "query": "SELECT\n id as __value,\n CASE WHEN COUNT(id) OVER (PARTITION BY name) > 1 AND name IS NOT NULL THEN CONCAT(name, ' - ', RIGHT(vin, 6)) ELSE COALESCE(name, CONCAT('VIN ', vin)) end as __text \nFROM cars\nORDER BY display_priority ASC, name ASC, vin ASC;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "select preferred_range from settings limit 1;", - "hide": 2, - "includeAll": false, - "name": "preferred_range", - "options": [], - "query": "select preferred_range from settings limit 1;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "select unit_of_length from settings limit 1;", - "hide": 2, - "includeAll": false, - "name": "length_unit", - "options": [], - "query": "select unit_of_length from settings limit 1;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "select base_url from settings limit 1;", - "hide": 2, - "includeAll": false, - "name": "base_url", - "options": [], - "query": "select base_url from settings limit 1;", - "refresh": 1, - "regex": "", - "type": "query" - } - ] - }, - "time": { - "from": "now-10y", - "to": "now" - }, - "timepicker": { - "refresh_intervals": [ - "10s", - "30s", - "1m", - "5m", - "15m", - "30m", - "1h", - "2h", - "1d" - ] - }, - "timezone": "", - "title": "Tesla Updates", - "uid": "IiC07mgWz", - "version": 1 - } diff --git a/argocd/manifests/grafana-config/dashboards/configmap-teslamate-vampire-drain.yaml b/argocd/manifests/grafana-config/dashboards/configmap-teslamate-vampire-drain.yaml deleted file mode 100644 index 9eed637..0000000 --- a/argocd/manifests/grafana-config/dashboards/configmap-teslamate-vampire-drain.yaml +++ /dev/null @@ -1,666 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: grafana-dashboard-teslamate-vampire-drain - namespace: monitoring - labels: - grafana_dashboard: "1" - annotations: - grafana_folder: "TeslaMate" -data: - teslamate-vampire-drain.json: | - { - "annotations": { - "list": [ - { - "builtIn": 1, - "datasource": { - "type": "grafana", - "uid": "-- Grafana --" - }, - "enable": true, - "hide": true, - "iconColor": "rgba(0, 211, 255, 1)", - "name": "Annotations & Alerts", - "type": "dashboard" - } - ] - }, - "editable": true, - "fiscalYearStartMonth": 0, - "graphTooltip": 0, - "links": [ - { - "icon": "dashboard", - "tags": [], - "title": "TeslaMate", - "tooltip": "", - "type": "link", - "url": "${base_url:raw}" - }, - { - "asDropdown": true, - "icon": "external link", - "tags": [ - "tesla" - ], - "title": "Dashboards", - "type": "dashboards" - } - ], - "panels": [ - { - "collapsed": false, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 0 - }, - "id": 4, - "panels": [], - "repeat": "car_id", - "title": "$car_id", - "type": "row" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "custom": { - "align": "auto", - "cellOptions": { - "type": "auto" - }, - "inspect": false - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": 0 - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "start_date" - }, - "properties": [ - { - "id": "displayName", - "value": "Start" - }, - { - "id": "unit", - "value": "dateTimeAsLocal" - }, - { - "id": "links", - "value": [ - { - "targetBlank": false, - "title": "", - "url": "/d/zm7wN6Zgz/drive-details?from=${__data.fields.start_date_ts.numeric}&to=${__data.fields.end_date_ts.numeric}" - } - ] - }, - { - "id": "custom.minWidth", - "value": 210 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "end_date" - }, - "properties": [ - { - "id": "displayName", - "value": "End" - }, - { - "id": "unit", - "value": "dateTimeAsLocal" - }, - { - "id": "custom.minWidth", - "value": 210 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "range_diff_km" - }, - "properties": [ - { - "id": "displayName", - "value": "Range loss" - }, - { - "id": "unit", - "value": "lengthkm" - }, - { - "id": "decimals", - "value": 2 - }, - { - "id": "custom.minWidth", - "value": 95 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "duration" - }, - "properties": [ - { - "id": "displayName", - "value": "Period" - }, - { - "id": "unit", - "value": "s" - }, - { - "id": "decimals", - "value": 1 - }, - { - "id": "custom.cellOptions", - "value": { - "type": "color-text" - } - }, - { - "id": "thresholds", - "value": { - "mode": "absolute", - "steps": [ - { - "color": "rgb(133, 142, 133)", - "value": 0 - }, - { - "color": "#56A64B", - "value": 43200 - } - ] - } - }, - { - "id": "custom.minWidth", - "value": 100 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "range_lost_per_hour_km" - }, - "properties": [ - { - "id": "displayName", - "value": "Ø Range loss / h" - }, - { - "id": "unit", - "value": "lengthkm" - }, - { - "id": "decimals", - "value": 2 - }, - { - "id": "custom.minWidth", - "value": 135 - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*_ts/" - }, - "properties": [ - { - "id": "custom.hidden", - "value": true - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "standby" - }, - "properties": [ - { - "id": "displayName", - "value": "Standby" - }, - { - "id": "unit", - "value": "percentunit" - }, - { - "id": "custom.cellOptions", - "value": { - "type": "color-text" - } - }, - { - "id": "thresholds", - "value": { - "mode": "absolute", - "steps": [ - { - "color": "#FF7383", - "value": 0 - }, - { - "color": "#FFB357", - "value": 0.3 - }, - { - "color": "#56A64B", - "value": 0.85 - } - ] - } - }, - { - "id": "decimals", - "value": 0 - }, - { - "id": "custom.minWidth", - "value": 75 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "consumption" - }, - "properties": [ - { - "id": "displayName", - "value": "Energy drained" - }, - { - "id": "unit", - "value": "kwatth" - }, - { - "id": "decimals", - "value": 2 - }, - { - "id": "custom.minWidth", - "value": 125 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "avg_power" - }, - "properties": [ - { - "id": "displayName", - "value": "Ø Power" - }, - { - "id": "unit", - "value": "watt" - }, - { - "id": "decimals", - "value": 1 - }, - { - "id": "custom.minWidth", - "value": 80 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "range_lost_per_hour_mi" - }, - "properties": [ - { - "id": "displayName", - "value": "Ø Range loss / h" - }, - { - "id": "unit", - "value": "lengthmi" - }, - { - "id": "decimals", - "value": 2 - }, - { - "id": "custom.minWidth", - "value": 135 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "range_diff_mi" - }, - "properties": [ - { - "id": "displayName", - "value": "Range loss" - }, - { - "id": "unit", - "value": "lengthmi" - }, - { - "id": "decimals", - "value": 2 - }, - { - "id": "custom.minWidth", - "value": 95 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "soc_diff" - }, - "properties": [ - { - "id": "displayName", - "value": "SoC Diff" - }, - { - "id": "unit", - "value": "percent" - }, - { - "id": "custom.minWidth", - "value": 80 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "has_reduced_range" - }, - "properties": [ - { - "id": "displayName", - "value": " " - }, - { - "id": "custom.align", - "value": "center" - }, - { - "id": "mappings", - "value": [ - { - "options": { - "0": { - "color": "transparent", - "index": 1, - "text": " " - }, - "1": { - "color": "dark-blue", - "index": 0, - "text": "❄" - } - }, - "type": "value" - } - ] - }, - { - "id": "links", - "value": [ - { - "title": "In cold weather, the estimated range loss cannot be estimated correctly and is therefore hidden.", - "url": "" - } - ] - }, - { - "id": "custom.width", - "value": 50 - } - ] - } - ] - }, - "gridPos": { - "h": 23, - "w": 24, - "x": 0, - "y": 1 - }, - "id": 2, - "options": { - "cellHeight": "sm", - "footer": { - "countRows": false, - "fields": "", - "reducer": [ - "sum" - ], - "show": false - }, - "showHeader": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "with merge as (\n SELECT \n c.start_date AS start_date,\n c.end_date AS end_date,\n c.start_ideal_range_km AS start_ideal_range_km,\n c.end_ideal_range_km AS end_ideal_range_km,\n c.start_rated_range_km AS start_rated_range_km,\n c.end_rated_range_km AS end_rated_range_km,\n start_battery_level,\n end_battery_level,\n p.usable_battery_level AS start_usable_battery_level,\n NULL AS end_usable_battery_level,\n p.odometer AS start_km,\n p.odometer AS end_km\n FROM charging_processes c\n JOIN positions p ON c.position_id = p.id\n WHERE c.car_id = $car_id AND $__timeFilter(start_date)\n UNION\n SELECT \n d.start_date AS start_date,\n d.end_date AS end_date,\n d.start_ideal_range_km AS start_ideal_range_km,\n d.end_ideal_range_km AS end_ideal_range_km,\n d.start_rated_range_km AS start_rated_range_km,\n d.end_rated_range_km AS end_rated_range_km,\n start_position.battery_level AS start_battery_level,\n end_position.battery_level AS end_battery_level,\n start_position.usable_battery_level AS start_usable_battery_level,\n end_position.usable_battery_level AS end_usable_battery_level,\n d.start_km AS start_km,\n d.end_km AS end_km\n FROM drives d\n JOIN positions start_position ON d.start_position_id = start_position.id\n JOIN positions end_position ON d.end_position_id = end_position.id\n WHERE d.car_id = $car_id AND $__timeFilter(start_date)\n), \nv as (\n SELECT\n lag(t.end_date) OVER w AS start_date,\n t.start_date AS end_date,\n lag(t.end_${preferred_range}_range_km) OVER w AS start_range,\n t.start_${preferred_range}_range_km AS end_range,\n lag(t.end_km) OVER w AS start_km,\n t.start_km AS end_km,\n EXTRACT(EPOCH FROM age(t.start_date, lag(t.end_date) OVER w)) AS duration,\n lag(t.end_battery_level) OVER w AS start_battery_level,\n lag(t.end_usable_battery_level) OVER w AS start_usable_battery_level,\n\t\tstart_battery_level AS end_battery_level,\n\t\tstart_usable_battery_level AS end_usable_battery_level,\n\t\tstart_battery_level > COALESCE(start_usable_battery_level, start_battery_level) AS has_reduced_range\n FROM merge t\n WINDOW w AS (ORDER BY t.start_date ASC)\n ORDER BY start_date DESC\n)\n\nSELECT\n round(extract(epoch FROM v.start_date)) * 1000 AS start_date_ts,\n round(extract(epoch FROM v.end_date)) * 1000 AS end_date_ts,\n -- Columns\n v.start_date,\n v.end_date,\n v.duration,\n (coalesce(s_asleep.sleep, 0) + coalesce(s_offline.sleep, 0)) / v.duration as standby,\n\t-greatest(v.start_battery_level - v.end_battery_level, 0) as soc_diff,\n\tCASE WHEN has_reduced_range THEN 1 ELSE 0 END as has_reduced_range,\n\tconvert_km(CASE WHEN has_reduced_range THEN NULL ELSE (v.start_range - v.end_range)::numeric END, '$length_unit') AS range_diff_$length_unit,\n CASE WHEN has_reduced_range THEN NULL ELSE (v.start_range - v.end_range) * c.efficiency END AS consumption,\n CASE WHEN has_reduced_range THEN NULL ELSE ((v.start_range - v.end_range) * c.efficiency) / (v.duration / 3600) * 1000 END as avg_power,\n convert_km(CASE WHEN has_reduced_range THEN NULL ELSE ((v.start_range - v.end_range) / (v.duration / 3600))::numeric END, '$length_unit') AS range_lost_per_hour_${length_unit}\nFROM v,\n LATERAL (\n SELECT EXTRACT(EPOCH FROM sum(age(s.end_date, s.start_date))) as sleep\n FROM states s\n WHERE\n state = 'asleep' AND\n v.start_date <= s.start_date AND s.end_date <= v.end_date AND\n s.car_id = $car_id\n ) s_asleep,\n LATERAL (\n SELECT EXTRACT(EPOCH FROM sum(age(s.end_date, s.start_date))) as sleep\n FROM states s\n WHERE\n state = 'offline' AND\n v.start_date <= s.start_date AND s.end_date <= v.end_date AND\n s.car_id = $car_id\n ) s_offline\nJOIN cars c ON c.id = $car_id\nWHERE\n v.duration > ($duration * 60 * 60)\n AND v.start_range - v.end_range >= 0\n AND v.end_km - v.start_km < 1;", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "Vampire Drain", - "transformations": [ - { - "id": "merge", - "options": { - "reducers": [] - } - } - ], - "type": "table" - } - ], - "preload": false, - "refresh": "", - "schemaVersion": 41, - "tags": [ - "tesla" - ], - "templating": { - "list": [ - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "SELECT\n id as __value,\n CASE WHEN COUNT(id) OVER (PARTITION BY name) > 1 AND name IS NOT NULL THEN CONCAT(name, ' - ', RIGHT(vin, 6)) ELSE COALESCE(name, CONCAT('VIN ', vin)) end as __text \nFROM cars\nORDER BY display_priority ASC, name ASC, vin ASC;", - "hide": 2, - "includeAll": true, - "label": "Car", - "name": "car_id", - "options": [], - "query": "SELECT\n id as __value,\n CASE WHEN COUNT(id) OVER (PARTITION BY name) > 1 AND name IS NOT NULL THEN CONCAT(name, ' - ', RIGHT(vin, 6)) ELSE COALESCE(name, CONCAT('VIN ', vin)) end as __text \nFROM cars\nORDER BY display_priority ASC, name ASC, vin ASC;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": { - "text": "6", - "value": "6" - }, - "includeAll": false, - "label": "min. Idle Time (h)", - "name": "duration", - "options": [ - { - "selected": false, - "text": "0", - "value": "0" - }, - { - "selected": false, - "text": "1", - "value": "1" - }, - { - "selected": false, - "text": "3", - "value": "3" - }, - { - "selected": true, - "text": "6", - "value": "6" - }, - { - "selected": false, - "text": "12", - "value": "12" - }, - { - "selected": false, - "text": "18", - "value": "18" - }, - { - "selected": false, - "text": "24", - "value": "24" - } - ], - "query": "0,1,3,6,12,18,24", - "type": "custom" - }, - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "select unit_of_length from settings limit 1;", - "hide": 2, - "includeAll": false, - "name": "length_unit", - "options": [], - "query": "select unit_of_length from settings limit 1;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "select preferred_range from settings limit 1;", - "hide": 2, - "includeAll": false, - "name": "preferred_range", - "options": [], - "query": "select preferred_range from settings limit 1;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "select base_url from settings limit 1;", - "hide": 2, - "includeAll": false, - "name": "base_url", - "options": [], - "query": "select base_url from settings limit 1;", - "refresh": 1, - "regex": "", - "type": "query" - } - ] - }, - "time": { - "from": "now-90d", - "to": "now" - }, - "timepicker": {}, - "timezone": "", - "title": "Tesla Vampire Drain", - "uid": "zhHx2Fggk", - "version": 1 - } diff --git a/argocd/manifests/grafana-config/dashboards/configmap-teslamate-visited.yaml b/argocd/manifests/grafana-config/dashboards/configmap-teslamate-visited.yaml deleted file mode 100644 index b78d5ac..0000000 --- a/argocd/manifests/grafana-config/dashboards/configmap-teslamate-visited.yaml +++ /dev/null @@ -1,571 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: grafana-dashboard-teslamate-visited - namespace: monitoring - labels: - grafana_dashboard: "1" - annotations: - grafana_folder: "TeslaMate" -data: - teslamate-visited.json: | - { - "annotations": { - "list": [ - { - "builtIn": 1, - "datasource": { - "type": "grafana", - "uid": "-- Grafana --" - }, - "enable": true, - "hide": true, - "iconColor": "rgba(0, 211, 255, 1)", - "name": "Annotations & Alerts", - "type": "dashboard" - } - ] - }, - "editable": true, - "fiscalYearStartMonth": 0, - "graphTooltip": 0, - "links": [ - { - "icon": "dashboard", - "tags": [], - "title": "TeslaMate", - "tooltip": "", - "type": "link", - "url": "${base_url:raw}" - }, - { - "asDropdown": true, - "icon": "external link", - "tags": [ - "tesla" - ], - "title": "Dashboards", - "type": "dashboards" - } - ], - "panels": [ - { - "collapsed": false, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 0 - }, - "id": 4, - "panels": [], - "repeat": "car_id", - "title": "$car_id", - "type": "row" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "custom": { - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "dark-red", - "value": 0 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 21, - "w": 24, - "x": 0, - "y": 1 - }, - "id": 2, - "maxDataPoints": 10000000, - "options": { - "basemap": { - "config": {}, - "name": "Layer 0", - "type": "osm-standard" - }, - "controls": { - "mouseWheelZoom": true, - "showAttribution": true, - "showDebug": false, - "showMeasure": false, - "showScale": false, - "showZoom": true - }, - "layers": [ - { - "config": { - "arrow": 0, - "style": { - "color": { - "fixed": "dark-blue" - }, - "lineWidth": 2, - "opacity": 1, - "rotation": { - "fixed": 0, - "max": 360, - "min": -360, - "mode": "mod" - }, - "size": { - "fixed": 3, - "max": 15, - "min": 2 - }, - "symbol": { - "fixed": "img/icons/marker/circle.svg", - "mode": "fixed" - }, - "symbolAlign": { - "horizontal": "center", - "vertical": "center" - }, - "textConfig": { - "fontSize": 12, - "offsetX": 0, - "offsetY": 0, - "textAlign": "center", - "textBaseline": "middle" - } - } - }, - "location": { - "latitude": "lat", - "longitude": "long", - "mode": "auto" - }, - "name": "Layer 1", - "tooltip": true, - "type": "route" - } - ], - "tooltip": { - "mode": "none" - }, - "view": { - "allLayers": true, - "id": "fit", - "lat": 0, - "lon": 0, - "zoom": 15 - } - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "SELECT\n date_trunc('minute', timezone('UTC', date), '$__timezone') as time,\n avg(latitude) as latitude,\n avg(longitude) as longitude\nFROM\n positions\nWHERE\n car_id = $car_id AND $__timeFilter(date) and ideal_battery_range_km is not null\nGROUP BY 1\nORDER BY 1", - "refId": "Positions", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "", - "type": "geomap" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "super-light-blue", - "value": 0 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 2, - "w": 5, - "x": 0, - "y": 22 - }, - "id": 5, - "options": { - "colorMode": "value", - "graphMode": "none", - "justifyMode": "auto", - "orientation": "horizontal", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "/.*/", - "values": true - }, - "showPercentChange": false, - "textMode": "value_and_name", - "wideLayout": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "SELECT ROUND(convert_km((max(end_km) - min(start_km))::numeric, '$length_unit'),0)|| ' $length_unit' as \"Mileage\"\nFROM drives WHERE car_id = $car_id AND $__timeFilter(start_date)", - "refId": "distance traveled", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "", - "type": "stat" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": 0 - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Total Energy added" - }, - "properties": [ - { - "id": "unit", - "value": "kwatth" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total Energy used" - }, - "properties": [ - { - "id": "unit", - "value": "kwatth" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Charging Efficiency" - }, - "properties": [ - { - "id": "unit", - "value": "percent" - } - ] - } - ] - }, - "gridPos": { - "h": 2, - "w": 14, - "x": 5, - "y": 22 - }, - "id": 6, - "maxDataPoints": 100, - "options": { - "colorMode": "value", - "graphMode": "none", - "justifyMode": "center", - "orientation": "vertical", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "mean" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "value_and_name", - "wideLayout": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "SELECT\n\tsum(charge_energy_added) as \"Total Energy added\",\n\tSUM(greatest(charge_energy_added, charge_energy_used)) AS \"Total Energy used\",\n\tSUM(charge_energy_added) * 100 / SUM(greatest(charge_energy_added, charge_energy_used)) AS \"Charging Efficiency\"\nFROM\n\tcharging_processes\nWHERE\n\tcar_id = $car_id AND $__timeFilter(start_date) AND charge_energy_added > 0.01", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "", - "type": "stat" - }, - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "description": "", - "fieldConfig": { - "defaults": { - "decimals": 2, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": 0 - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 2, - "w": 5, - "x": 19, - "y": 22 - }, - "id": 7, - "maxDataPoints": 100, - "options": { - "colorMode": "value", - "graphMode": "none", - "justifyMode": "auto", - "orientation": "horizontal", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "value_and_name", - "wideLayout": true - }, - "pluginVersion": "12.1.1", - "targets": [ - { - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "editorMode": "code", - "format": "table", - "rawQuery": true, - "rawSql": "select sum(cost) as \"Total Charging Cost\" from charging_processes where $__timeFilter(start_date) AND car_id = $car_id;", - "refId": "A", - "sql": { - "columns": [ - { - "parameters": [], - "type": "function" - } - ], - "groupBy": [ - { - "property": { - "type": "string" - }, - "type": "groupBy" - } - ], - "limit": 50 - } - } - ], - "title": "", - "type": "stat" - } - ], - "preload": false, - "refresh": "", - "schemaVersion": 41, - "tags": [ - "tesla" - ], - "templating": { - "list": [ - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "SELECT\n id as __value,\n CASE WHEN COUNT(id) OVER (PARTITION BY name) > 1 AND name IS NOT NULL THEN CONCAT(name, ' - ', RIGHT(vin, 6)) ELSE COALESCE(name, CONCAT('VIN ', vin)) end as __text \nFROM cars\nORDER BY display_priority ASC, name ASC, vin ASC;", - "hide": 2, - "includeAll": true, - "label": "Car", - "name": "car_id", - "options": [], - "query": "SELECT\n id as __value,\n CASE WHEN COUNT(id) OVER (PARTITION BY name) > 1 AND name IS NOT NULL THEN CONCAT(name, ' - ', RIGHT(vin, 6)) ELSE COALESCE(name, CONCAT('VIN ', vin)) end as __text \nFROM cars\nORDER BY display_priority ASC, name ASC, vin ASC;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "select base_url from settings limit 1;", - "hide": 2, - "includeAll": false, - "name": "base_url", - "options": [], - "query": "select base_url from settings limit 1;", - "refresh": 1, - "regex": "", - "type": "query" - }, - { - "current": {}, - "datasource": { - "type": "grafana-postgresql-datasource", - "uid": "TeslaMate" - }, - "definition": "SELECT unit_of_length FROM settings LIMIT 1", - "hide": 2, - "includeAll": false, - "name": "length_unit", - "options": [], - "query": "SELECT unit_of_length FROM settings LIMIT 1", - "refresh": 1, - "regex": "", - "type": "query" - } - ] - }, - "time": { - "from": "now-90d", - "to": "now" - }, - "timepicker": {}, - "timezone": "", - "title": "Tesla Visited", - "uid": "RG_DxSmgk", - "version": 1 - } diff --git a/argocd/manifests/grafana-config/kustomization.yaml b/argocd/manifests/grafana-config/kustomization.yaml index 45a8380..bb7ef87 100644 --- a/argocd/manifests/grafana-config/kustomization.yaml +++ b/argocd/manifests/grafana-config/kustomization.yaml @@ -26,22 +26,6 @@ resources: - dashboards/configmap-sifaka-disks.yaml - dashboards/configmap-forgejo.yaml - dashboards/configmap-tempo.yaml - # TeslaMate dashboards - - dashboards/configmap-teslamate-overview.yaml - - dashboards/configmap-teslamate-charges.yaml - - dashboards/configmap-teslamate-drives.yaml - - dashboards/configmap-teslamate-efficiency.yaml - - dashboards/configmap-teslamate-states.yaml - - dashboards/configmap-teslamate-vampire-drain.yaml - - dashboards/configmap-teslamate-battery-health.yaml - - dashboards/configmap-teslamate-statistics.yaml - - dashboards/configmap-teslamate-charge-level.yaml - - dashboards/configmap-teslamate-updates.yaml - - dashboards/configmap-teslamate-trip.yaml - - dashboards/configmap-teslamate-locations.yaml - - dashboards/configmap-teslamate-mileage.yaml - - dashboards/configmap-teslamate-drive-stats.yaml - - dashboards/configmap-teslamate-charging-stats.yaml - - dashboards/configmap-teslamate-projected-range.yaml - - dashboards/configmap-teslamate-timeline.yaml - - dashboards/configmap-teslamate-visited.yaml + # TeslaMate dashboards are fetched by the init-teslamate-dashboards init + # container in the Grafana deployment, sourced from mirrors/teslamate on forge. + # See argocd/manifests/grafana/deployment.yaml for the version pin. diff --git a/argocd/manifests/grafana/deployment.yaml b/argocd/manifests/grafana/deployment.yaml index e352e92..bdf4a6f 100644 --- a/argocd/manifests/grafana/deployment.yaml +++ b/argocd/manifests/grafana/deployment.yaml @@ -45,6 +45,51 @@ spec: volumeMounts: - name: storage mountPath: /var/lib/grafana + # Fetch TeslaMate dashboards from forge mirror at a pinned tag. + # To upgrade: update TESLAMATE_VERSION below. + - name: init-teslamate-dashboards + image: docker.io/library/alpine:kustomized + imagePullPolicy: IfNotPresent + command: ["sh", "-c"] + args: + - | + set -e + TESLAMATE_VERSION="v3.0.0" + BASE_URL="https://forge.ops.eblu.me/mirrors/teslamate/raw/tag/${TESLAMATE_VERSION}/grafana/dashboards" + DEST="/tmp/dashboards/TeslaMate" + mkdir -p "$DEST" + for f in \ + battery-health.json \ + charge-level.json \ + charges.json \ + charging-stats.json \ + drive-stats.json \ + drives.json \ + efficiency.json \ + locations.json \ + mileage.json \ + overview.json \ + projected-range.json \ + states.json \ + statistics.json \ + timeline.json \ + trip.json \ + updates.json \ + vampire-drain.json \ + visited.json \ + ; do + wget -q -O "$DEST/$f" "$BASE_URL/$f" + done + echo "Fetched $(ls "$DEST" | wc -l) TeslaMate dashboards" + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + seccompProfile: + type: RuntimeDefault + volumeMounts: + - name: sc-dashboard-volume + mountPath: /tmp/dashboards containers: # Dashboard sidecar - watches ConfigMaps with grafana_dashboard=1 - name: grafana-sc-dashboard diff --git a/argocd/manifests/grafana/kustomization.yaml b/argocd/manifests/grafana/kustomization.yaml index 38e1269..e307db1 100644 --- a/argocd/manifests/grafana/kustomization.yaml +++ b/argocd/manifests/grafana/kustomization.yaml @@ -11,6 +11,8 @@ resources: - rbac.yaml images: + - name: docker.io/library/alpine + newTag: "3.21" - name: docker.io/library/busybox newTag: 1.31.1 - name: registry.ops.eblu.me/blumeops/grafana-sidecar diff --git a/docs/changelog.d/externalize-teslamate-dashboards.infra.md b/docs/changelog.d/externalize-teslamate-dashboards.infra.md new file mode 100644 index 0000000..684ce74 --- /dev/null +++ b/docs/changelog.d/externalize-teslamate-dashboards.infra.md @@ -0,0 +1 @@ +Externalize TeslaMate Grafana dashboards to forge mirror, removing 713 KB of ConfigMaps from the repo. From 9566d8fec9356500cf79329c0fd30c547c762743 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sun, 15 Mar 2026 18:37:50 -0700 Subject: [PATCH 044/430] Add ai-sources task, update ai-docs to include all docs ai-sources: concatenates all git-tracked files (excluding lock files and other non-useful artifacts) for full codebase AI context (~353K tokens). ai-docs: now concatenates all docs instead of a curated subset (~85K tokens). Co-Authored-By: Claude Opus 4.6 (1M context) --- mise-tasks/ai-docs | 27 +++++++-------------------- mise-tasks/ai-sources | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 20 deletions(-) create mode 100755 mise-tasks/ai-sources diff --git a/mise-tasks/ai-docs b/mise-tasks/ai-docs index 2509705..66e11d7 100755 --- a/mise-tasks/ai-docs +++ b/mise-tasks/ai-docs @@ -1,26 +1,13 @@ #!/usr/bin/env bash -#MISE description="Prime AI context with key BlumeOps documentation" +#MISE description="Prime AI context with all BlumeOps documentation" set -euo pipefail DOCS_DIR="$(cd "$(dirname "$0")/.." && pwd)/docs" -# Key files for AI context priming, in order of importance -FILES=( - "$DOCS_DIR/tutorials/ai-assistance-guide.md" - "$DOCS_DIR/how-to/agent-change-process.md" - "$DOCS_DIR/index.md" - "$DOCS_DIR/how-to/operations/troubleshooting.md" - "$DOCS_DIR/explanation/architecture.md" - "$DOCS_DIR/reference/tools/mise-tasks.md" -) - -# Concatenate files with headers showing paths -bat --style=header --color=never --decorations=always "$@" "${FILES[@]}" - -# Documentation tree — replaces the old hand-curated index files -echo "" -echo "=== Documentation Structure ===" -echo "All docs under $DOCS_DIR (excluding changelog.d/):" -echo "" -find "$DOCS_DIR" -name '*.md' -not -path '*/changelog.d/*' | sort | sed "s|$DOCS_DIR/||" +# Concatenate all docs (excluding changelog fragments) +find "$DOCS_DIR" -name '*.md' -not -path '*/changelog.d/*' | sort | while read -r f; do + printf '=== %s ===\n' "${f#"$DOCS_DIR/"}" + cat "$f" + printf '\n' +done diff --git a/mise-tasks/ai-sources b/mise-tasks/ai-sources new file mode 100755 index 0000000..4048c68 --- /dev/null +++ b/mise-tasks/ai-sources @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +#MISE description="Concatenate all BlumeOps source files for AI context" + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +# All git-tracked files, excluding lock files and other non-useful artifacts +git -C "$ROOT" ls-files \ + | grep -v '\.lock$' \ + | grep -v '\.gitignore$' \ + | grep -v '\.gitkeep$' \ + | grep -v '\.gitattributes$' \ + | grep -v '^LICENSE$' \ + | while read -r f; do + printf '=== %s ===\n' "$f" + cat "$ROOT/$f" + printf '\n' + done From d121006086d24bbc176719ae6f6ee16ab5624c3f Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sun, 15 Mar 2026 18:40:35 -0700 Subject: [PATCH 045/430] Exclude docs from ai-sources, mention ai-sources in CLAUDE.md ai-sources now skips docs/ to avoid duplicating ai-docs output. CLAUDE.md notes ai-sources as available for deep context on problems with a large surface area. Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 1 + mise-tasks/ai-sources | 1 + 2 files changed, 2 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index a6d883e..289725a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,6 +15,7 @@ blumeops is Erich Blume's GitOps repository for personal infrastructure, orchest 1. **Always run `mise run ai-docs` at session start** This will refresh your context with important information you will be assumed to know and follow. **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 3. **Classify the change as C0/C1/C2 before starting** (see below) — this determines branching and PR requirements 4. **Feature branches + PRs for C1/C2** - checkout main, pull, create branch, open PR via `tea pr create`. C0 goes direct to main. diff --git a/mise-tasks/ai-sources b/mise-tasks/ai-sources index 4048c68..325b6e5 100755 --- a/mise-tasks/ai-sources +++ b/mise-tasks/ai-sources @@ -12,6 +12,7 @@ git -C "$ROOT" ls-files \ | grep -v '\.gitkeep$' \ | grep -v '\.gitattributes$' \ | grep -v '^LICENSE$' \ + | grep -v '^docs/' \ | while read -r f; do printf '=== %s ===\n' "$f" cat "$ROOT/$f" From 5b008a6ab6c822c7a062a14b27ec6aecb4f0c40f Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sun, 15 Mar 2026 18:43:39 -0700 Subject: [PATCH 046/430] Document ai-sources in AI guide, change process, and mise-tasks ref Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/how-to/agent-change-process.md | 2 ++ docs/reference/tools/mise-tasks.md | 3 ++- docs/tutorials/ai-assistance-guide.md | 3 ++- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/how-to/agent-change-process.md b/docs/how-to/agent-change-process.md index ed7702d..dca2b36 100644 --- a/docs/how-to/agent-change-process.md +++ b/docs/how-to/agent-change-process.md @@ -23,6 +23,8 @@ Before starting work, classify the change: When in doubt, start at C1. Upgrade to C2 if complexity spirals or the user requests it. +**Context loading:** All change classes start with `mise run ai-docs` (~85K tokens of documentation). 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). Together they cover the full codebase without overlap. + ## C0 — Quick Fix A change where the risk is low enough that problems can be quickly fixed forward. diff --git a/docs/reference/tools/mise-tasks.md b/docs/reference/tools/mise-tasks.md index baacf10..d2385a6 100644 --- a/docs/reference/tools/mise-tasks.md +++ b/docs/reference/tools/mise-tasks.md @@ -17,7 +17,8 @@ Run `mise tasks --sort name` for the live list with descriptions. | Task | Description | |------|-------------| -| `ai-docs` | Prime AI context with key documentation and doc tree | +| `ai-docs` | All documentation concatenated for AI context (~85K tokens) | +| `ai-sources` | All non-doc source files for deep AI context (~270K tokens) | | `docs-check-frontmatter` | Check required frontmatter fields | | `docs-check-links` | Validate wiki-links resolve correctly (supports path-based links) | | `docs-mikado` | View active Mikado dependency chains (C2 changes) | diff --git a/docs/tutorials/ai-assistance-guide.md b/docs/tutorials/ai-assistance-guide.md index 8b54290..9138526 100644 --- a/docs/tutorials/ai-assistance-guide.md +++ b/docs/tutorials/ai-assistance-guide.md @@ -91,7 +91,8 @@ BlumeOps operations are driven by mise tasks. Run `mise tasks` to list all avail | Task | When to Use | |------|-------------| -| `ai-docs` | At session start - review infrastructure documentation (see [[mise-tasks]]) | +| `ai-docs` | At session start - all documentation concatenated for AI context (~85K tokens, see [[mise-tasks]]) | +| `ai-sources` | Deep context - all non-doc source files (~270K tokens). Ask user before running; useful for problems with a large surface area | | `docs-mikado` | View active Mikado dependency chains for C2 changes | | `docs-mikado --resume` | Resume a C2 chain: detect branch, show state and next steps | | `provision-indri` | Deploy changes to [[indri]]-hosted services via Ansible | From ac01c2d6e2062222f665555ba1d83a731f6591d1 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sun, 15 Mar 2026 19:25:27 -0700 Subject: [PATCH 047/430] Fix stale docs and shell quoting in devpi start script - ArgoCD ref: correct Git Source URL to forge.ops.eblu.me:2222 - Authentik ref: add Zot as active OIDC client, blueprint, and secret - Federated login: remove Zot from Future Work (completed in PR #236) - devpi/start.sh: use bash array for command building (proper quoting) Co-Authored-By: Claude Opus 4.6 (1M context) --- containers/devpi/start.sh | 6 +++--- docs/explanation/federated-login.md | 2 +- docs/reference/services/argocd.md | 2 +- docs/reference/services/authentik.md | 5 ++++- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/containers/devpi/start.sh b/containers/devpi/start.sh index e34e60c..8ed46a2 100644 --- a/containers/devpi/start.sh +++ b/containers/devpi/start.sh @@ -21,11 +21,11 @@ if [ ! -f "$SERVERDIR/.serverversion" ]; then fi # Build command -CMD="devpi-server --serverdir $SERVERDIR --host $HOST --port $PORT" +CMD=(devpi-server --serverdir "$SERVERDIR" --host "$HOST" --port "$PORT") if [ -n "$OUTSIDE_URL" ]; then - CMD="$CMD --outside-url $OUTSIDE_URL" + CMD+=(--outside-url "$OUTSIDE_URL") fi echo "Starting devpi-server..." -exec $CMD +exec "${CMD[@]}" diff --git a/docs/explanation/federated-login.md b/docs/explanation/federated-login.md index c6142e2..8accad0 100644 --- a/docs/explanation/federated-login.md +++ b/docs/explanation/federated-login.md @@ -76,7 +76,7 @@ Authentik enforces TOTP MFA on its default authentication flow (`not_configured_ ## Future Work -- **Additional services:** ArgoCD, Miniflux, Immich, Zot (see [[harden-zot-registry]]) +- **Additional services:** ArgoCD, Miniflux, Immich ## Related diff --git a/docs/reference/services/argocd.md b/docs/reference/services/argocd.md index 8972714..e890cc5 100644 --- a/docs/reference/services/argocd.md +++ b/docs/reference/services/argocd.md @@ -17,7 +17,7 @@ GitOps continuous delivery platform for the [[cluster|Kubernetes cluster]]. | **URL** | https://argocd.ops.eblu.me | | **Tailscale URL** | https://argocd.tail8d86e.ts.net | | **Namespace** | `argocd` | -| **Git Source** | `ssh://forgejo@indri.tail8d86e.ts.net:2200/eblume/blumeops.git` | +| **Git Source** | `ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git` | | **Manifests Path** | `argocd/` | ## Sync Policy diff --git a/docs/reference/services/authentik.md b/docs/reference/services/authentik.md index 67e223b..89a17cc 100644 --- a/docs/reference/services/authentik.md +++ b/docs/reference/services/authentik.md @@ -44,6 +44,7 @@ Authentik configuration is managed via Blueprints (YAML) stored as a ConfigMap m - **`mfa.yaml`** — MFA enforcement on the default authentication flow (`not_configured_action: configure`) - **`grafana.yaml`** — Grafana OAuth2 provider, application, and policy binding - **`forgejo.yaml`** — Forgejo OAuth2 provider, application, and policy binding +- **`zot.yaml`** — Zot registry OAuth2 provider, application, and policy binding Group membership is included in the `profile` scope claim (Authentik built-in). Services use `--group-claim-name groups` to read it. @@ -55,8 +56,9 @@ Blueprint file: `argocd/manifests/authentik/configmap-blueprint.yaml` |--------|--------| | [[grafana]] | Active | | [[forgejo]] | Active | +| [[zot]] | Active | -Future clients: [[argocd]], [[miniflux]], [[zot]] +Future clients: [[argocd]], [[miniflux]] ## Secrets @@ -68,6 +70,7 @@ Injected via [[external-secrets]] from the "Authentik (blumeops)" 1Password item | `db-password` | PostgreSQL password | | `grafana-client-secret` | OIDC client secret for Grafana | | `forgejo-client-secret` | OIDC client secret for Forgejo | +| `zot-client-secret` | OIDC client secret for Zot | | `api-token` | Authentik API token | ## Container Image From f46a04b902a6a3f8fc23c96b6590a19f2d127efe Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sun, 15 Mar 2026 19:55:59 -0700 Subject: [PATCH 048/430] Restructure docs: consolidate, recategorize, and extract - Consolidate 4 Authentik Nix derivation docs into one card (authentik-nix-build-components.md) - Merge build-grafana-container + build-grafana-sidecar into build-grafana-images.md - Move agent-change-process from how-to/ to explanation/ (it's a methodology doc, not a task guide) - Extract Caddy custom build section from reference card into how-to/deployment/build-caddy-with-plugins.md - Move expose-service-publicly from how-to/ to tutorials/ (it's a comprehensive walkthrough, not a quick task reference) - Update all wiki-link references across affected docs Co-Authored-By: Claude Opus 4.6 (1M context) --- .../agent-change-process.md | 6 +- .../authentik-api-client-generation.md | 50 ------- .../authentik-go-server-derivation.md | 50 ------- .../authentik-nix-build-components.md | 136 ++++++++++++++++++ .../authentik-python-backend-derivation.md | 76 ---------- .../authentik/authentik-web-ui-derivation.md | 41 ------ .../authentik/build-authentik-from-source.md | 2 +- .../authentik/mirror-authentik-build-deps.md | 2 +- .../deployment/build-caddy-with-plugins.md | 38 +++++ .../how-to/grafana/build-grafana-container.md | 40 ------ docs/how-to/grafana/build-grafana-images.md | 60 ++++++++ docs/how-to/grafana/build-grafana-sidecar.md | 39 ----- .../grafana/kustomize-grafana-deployment.md | 2 +- docs/how-to/grafana/upgrade-grafana.md | 3 +- docs/reference/services/caddy.md | 15 +- docs/reference/services/grafana.md | 3 +- .../expose-service-publicly.md | 4 +- 17 files changed, 246 insertions(+), 321 deletions(-) rename docs/{how-to => explanation}/agent-change-process.md (98%) delete mode 100644 docs/how-to/authentik/authentik-api-client-generation.md delete mode 100644 docs/how-to/authentik/authentik-go-server-derivation.md create mode 100644 docs/how-to/authentik/authentik-nix-build-components.md delete mode 100644 docs/how-to/authentik/authentik-python-backend-derivation.md delete mode 100644 docs/how-to/authentik/authentik-web-ui-derivation.md create mode 100644 docs/how-to/deployment/build-caddy-with-plugins.md delete mode 100644 docs/how-to/grafana/build-grafana-container.md create mode 100644 docs/how-to/grafana/build-grafana-images.md delete mode 100644 docs/how-to/grafana/build-grafana-sidecar.md rename docs/{how-to/configuration => tutorials}/expose-service-publicly.md (99%) diff --git a/docs/how-to/agent-change-process.md b/docs/explanation/agent-change-process.md similarity index 98% rename from docs/how-to/agent-change-process.md rename to docs/explanation/agent-change-process.md index dca2b36..38b5a26 100644 --- a/docs/how-to/agent-change-process.md +++ b/docs/explanation/agent-change-process.md @@ -1,14 +1,16 @@ --- title: Agent Change Process -modified: 2026-03-04 +modified: 2026-03-15 last-reviewed: 2026-02-23 tags: - - how-to + - explanation - ai --- # Agent Change Process +> **Note:** This article was drafted by AI and reviewed by Erich. I plan to rewrite all explanatory content in my own words - these serve as placeholders to establish the documentation structure. + How to classify and execute infrastructure changes, especially when working with AI agents that may lose context across sessions. ## Change Classification diff --git a/docs/how-to/authentik/authentik-api-client-generation.md b/docs/how-to/authentik/authentik-api-client-generation.md deleted file mode 100644 index 480d9c7..0000000 --- a/docs/how-to/authentik/authentik-api-client-generation.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -title: Generate Authentik API Clients -modified: 2026-03-01 -last-reviewed: 2026-03-01 -tags: - - how-to - - authentik - - nix ---- - -# Generate Authentik API Clients - -Build Go and TypeScript API client bindings from authentik's OpenAPI spec (`schema.yml`). These are build-time inputs for the Go server and web UI respectively. - -## Context - -Authentik maintains a separate repo ([`goauthentik/client-go`](https://github.com/goauthentik/client-go)) with pre-generated Go client code. The nixpkgs derivation fetches this and injects it into the Go vendor directory via a setup hook (`apiGoVendorHook`). The TypeScript client is generated inline from `schema.yml` using `openapi-generator-cli`. - -Both clients are generated from the same `schema.yml` OpenAPI spec in the main authentik repo. - -## What to Do - -1. Create a Nix derivation (`client-go`) that generates Go API client bindings from `schema.yml` using `openapi-generator-cli` -2. Create a Nix derivation (`client-ts`) that generates TypeScript fetch client bindings from the same spec -3. Create a setup hook (`apiGoVendorHook`) that replaces `goauthentik.io/api/v3` in the Go vendor directory with the generated client -4. Verify the generated code compiles (Go: `go build`, TypeScript: type-check with `tsc`) - -## Key Details - -- Source spec: `schema.yml` in the authentik repo root -- Go client replaces `vendor/goauthentik.io/api/v3/` in the server build (via `api-go-vendor-hook.nix`) -- TypeScript client replaces `web/node_modules/@goauthentik/api/` in the web UI build (symlinked in `webui.nix`) - -## Testing on Ringtail - -The `test-build.nix` harness in `containers/authentik/` supports individual component builds: - -```fish -set tmpdir (ssh ringtail 'mktemp -d /tmp/authentik-test.XXXXXX') -scp containers/authentik/*.nix ringtail:$tmpdir/ -ssh ringtail "cd $tmpdir && nix-build test-build.nix -A client-go --extra-experimental-features 'nix-command flakes'" -ssh ringtail "cd $tmpdir && nix-build test-build.nix -A client-ts --extra-experimental-features 'nix-command flakes'" -ssh ringtail "rm -rf $tmpdir" -``` - -## Related - -- [[build-authentik-from-source]] — Parent goal -- [[authentik-go-server-derivation]] — Consumer of Go client -- [[authentik-web-ui-derivation]] — Consumer of TypeScript client diff --git a/docs/how-to/authentik/authentik-go-server-derivation.md b/docs/how-to/authentik/authentik-go-server-derivation.md deleted file mode 100644 index 8cb7335..0000000 --- a/docs/how-to/authentik/authentik-go-server-derivation.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -title: Build Authentik Go Server -modified: 2026-03-02 -last-reviewed: 2026-03-02 -tags: - - how-to - - authentik - - nix ---- - -# Build Authentik Go Server - -Build the Go HTTP server binary (`cmd/server`) that serves the web UI, REST API, and spawns gunicorn for the Django backend. - -## Context - -The Go server is built with `buildGoModule` from the `cmd/server` subpackage. It's a Cobra-based binary that: - -- Serves static web assets and the REST API -- Runs an embedded reverse proxy outpost -- Spawns `gounicorn` (gunicorn) to run the Django application -- Manages health checks - -The nixpkgs derivation patches store paths into two Go source files so the compiled binary can find Python lifecycle scripts and web assets at runtime. - -## What to Do - -1. Create a `buildGoModule` derivation for `cmd/server` from the authentik source -2. Inject the generated Go API client into the vendor directory (via `apiGoVendorHook`) -3. Apply `substituteInPlace` patches to hardcode Nix store paths: - - `internal/gounicorn/gounicorn.go`: `./lifecycle` → `${authentik-django}/lifecycle` - - `web/static.go`: `./web` → `${webAssetsPath}` (the webui derivation) - - `internal/web/static.go`: `./web` → `${webAssetsPath}` (the webui derivation) -4. Compute the `vendorHash` — note that the hook replaces vendored API code *after* hash verification, so the hash reflects `go.sum` only -5. Rename the output binary from `server` to `authentik` -6. Verify: `./authentik --help` runs successfully - -## Key Details - -- Go module: `goauthentik.io` -- Subpackage: `./cmd/server` -- CGO: disabled -- The `vendorHash` must be computed with the vendor replacement hook excluded (`overrideModAttrs`) -- Outpost binaries (`cmd/ldap`, `cmd/proxy`, `cmd/radius`) are separate and not needed for basic deployment - -## Related - -- [[build-authentik-from-source]] — Parent goal -- [[authentik-api-client-generation]] — Provides Go client (prerequisite) -- [[authentik-python-backend-derivation]] — Provides lifecycle scripts and web assets (prerequisite) diff --git a/docs/how-to/authentik/authentik-nix-build-components.md b/docs/how-to/authentik/authentik-nix-build-components.md new file mode 100644 index 0000000..548ec83 --- /dev/null +++ b/docs/how-to/authentik/authentik-nix-build-components.md @@ -0,0 +1,136 @@ +--- +title: Authentik Nix Build Components +modified: 2026-03-15 +last-reviewed: 2026-03-15 +tags: + - how-to + - authentik + - nix +--- + +# Authentik Nix Build Components + +Detailed reference for the four Nix derivations that make up the authentik from-source build. See [[build-authentik-from-source]] for the parent overview and version update workflow. + +## API Client Generation + +Go and TypeScript API client bindings generated from authentik's OpenAPI spec (`schema.yml`). + +Authentik maintains a separate repo ([`goauthentik/client-go`](https://github.com/goauthentik/client-go)) with pre-generated Go client code. The nixpkgs derivation fetches this and injects it into the Go vendor directory via a setup hook (`apiGoVendorHook`). The TypeScript client is generated inline from `schema.yml` using `openapi-generator-cli`. + +**What to do:** + +1. Create a Nix derivation (`client-go`) that generates Go API client bindings from `schema.yml` using `openapi-generator-cli` +2. Create a Nix derivation (`client-ts`) that generates TypeScript fetch client bindings from the same spec +3. Create a setup hook (`apiGoVendorHook`) that replaces `goauthentik.io/api/v3` in the vendor directory with the generated client +4. Verify the generated code compiles (Go: `go build`, TypeScript: type-check with `tsc`) + +**Key details:** + +- Source spec: `schema.yml` in the authentik repo root +- Go client replaces `vendor/goauthentik.io/api/v3/` in the server build (via `api-go-vendor-hook.nix`) +- TypeScript client replaces `web/node_modules/@goauthentik/api/` in the web UI build (symlinked in `webui.nix`) + +## Python Backend + +`authentik-django` — the Python/Django application that forms the core backend. + +Authentik 2026.2.0 requires Python 3.14 (`requires-python = "==3.14.*"`). Instead of carrying individual overrides for each broken nixpkgs python314 package, we use **`uv`** to install Python dependencies from PyPI, where upstream maintainers have already published Python 3.14-compatible wheels. + +### Approach: uv sync FOD + autoPatchelfHook + +Nix builds are sandboxed with no network access. The pattern is: + +1. **Fixed-output derivation (FOD)** — `uv sync --frozen` fetches and installs all dependencies into a venv. FODs are allowed network access because the output hash is declared upfront. Compiled `.so` files reference Nix store paths (RPATHs to libxml2, krb5, etc.), which FODs must not contain, so we strip references with `remove-references-to` and delete `bin/` and `.pyc` files. +2. **Main derivation** — copies the FOD's `lib/python3.14/site-packages/`, recreates `bin/` with proper python symlinks, restores `pyvenv.cfg`, and runs `autoPatchelfHook` to re-link `.so` files against the correct Nix store libraries. + +**Why not `uv pip download` + `uv pip install --no-index`?** `uv pip download` does not exist in uv 0.9.29 (nixpkgs). And the download-only approach has further complications with sdist-only packages (psycopg-c, gssapi) that must be compiled anyway. + +**What to do:** + +1. Create the FOD (`python-deps.nix`) that runs `uv sync --frozen --no-install-project --no-install-workspace --no-dev`, then strips all Nix store references from the output +2. Create the main derivation (`authentik-django.nix`) that copies the FOD's site-packages, recreates venv, runs `autoPatchelfHook`, copies in-tree workspace packages, and applies path patches +3. Verify: `$out/bin/python3.14 -c "import authentik"` succeeds + +**Key details:** + +- Nix provides: `python314`, `uv`, system libraries (`libxml2`, `libxslt`, `openssl`, `libffi`, `zlib`, etc.) +- PyPI provides: all Python packages (via pre-built `cp314` wheels where available, sdist builds otherwise) +- The FOD hash must be recomputed when `uv.lock` changes +- The 4 in-tree packages are installed from monorepo source, not PyPI +- Standard `djangorestframework` 3.16.1 from PyPI (no longer forked as of 2026.2.0) + +### Lessons Learned + +| Issue | Fix | +|-------|-----| +| `pg_config` not found for psycopg-c | Use `pkgs.postgresql.pg_config` (separate derivation), not `pkgs.postgresql` | +| gssapi `gss_acquire_cred_impersonate_name` undeclared | `NIX_CFLAGS_COMPILE="-include gssapi/gssapi_ext.h"` | +| xmlsec linker error `-lltdl` | Add `pkgs.libtool` to buildInputs (provides libltdl) | +| psycopg-c needs `libpq` | Add `pkgs.libpq` to buildInputs | +| Static `refTargets` list missed 6 store refs | Dynamic discovery: `grep -aohE '/nix/store/...'` finds all refs | +| autoPatchelfHook can't find libraries | `buildInputs` in main derivation must include all libraries that `.so` files link against | +| `FieldError: Cannot resolve keyword 'group_id'` | Django migration ordering bug — add explicit dependency via `substituteInPlace`. Upstream [#19616](https://github.com/goauthentik/authentik/issues/19616) | + +The `uv sync` completes in ~3.5 minutes. Dynamic reference discovery finds 19 unique store paths. `autoPatchelfHook` in the main derivation resolves all NEEDED entries with 0 unsatisfied dependencies. + +## Web UI + +Lit-based TypeScript web frontend built with esbuild + rollup. + +As of 2026.2.0, the main build uses **esbuild** (via wireit) and the SFE sub-package uses **rollup**. Two-phase Nix build: + +1. **`webui-deps.nix`** — Fixed-output derivation that runs `npm ci` to fetch Node dependencies. Platform-specific output hash. +2. **`webui.nix`** — Copies deps, patches in the generated TypeScript API client (`client-ts`), patches shebangs, runs `npm run build` (wireit/esbuild) and `npm run build:sfe` (rollup). + +**Key details:** + +- **Node.js:** `nodejs_24` (authentik requires Node >= 24, npm >= 11.6.2) +- **Build time:** ~33s on ringtail (x86_64-linux) +- **FOD hash:** Platform-specific — updates needed on each version bump +- **Output:** `$out/dist/` (JS/CSS bundles) and `$out/authentik/` (static icons) +- **Docusaurus website** (`/help` endpoint) is not built — optional + +**Key lessons:** + +- The 2026.2.0 build switched from rollup to esbuild for the main frontend +- The version string in `packages/core/version/node.js` uses a JSON import-with-assertion that must be patched to hardcode the version +- `NODE_OPTIONS=--openssl-legacy-provider` is needed for compatibility +- Workspace packages have separate `node_modules/` — the FOD must collect all of them + +## Go Server + +The Go HTTP server binary (`cmd/server`) that serves the web UI, REST API, and spawns gunicorn for the Django backend. + +**What to do:** + +1. Create a `buildGoModule` derivation for `cmd/server` from the authentik source +2. Inject the generated Go API client into the vendor directory (via `apiGoVendorHook`) +3. Apply `substituteInPlace` patches to hardcode Nix store paths for lifecycle scripts and web assets +4. Compute the `vendorHash` — the hook replaces vendored API code *after* hash verification +5. Rename the output binary from `server` to `authentik` + +**Key details:** + +- Go module: `goauthentik.io`, subpackage: `./cmd/server` +- CGO: disabled +- The `vendorHash` must be computed with the vendor replacement hook excluded (`overrideModAttrs`) +- Outpost binaries (`cmd/ldap`, `cmd/proxy`, `cmd/radius`) are separate and not needed for basic deployment + +## Testing on Ringtail + +The `test-build.nix` harness in `containers/authentik/` supports individual component builds: + +```fish +set tmpdir (ssh ringtail 'mktemp -d /tmp/authentik-test.XXXXXX') +scp containers/authentik/*.nix ringtail:$tmpdir/ +ssh ringtail "cd $tmpdir && nix-build test-build.nix -A client-go --extra-experimental-features 'nix-command flakes'" +ssh ringtail "cd $tmpdir && nix-build test-build.nix -A assembled --extra-experimental-features 'nix-command flakes'" +ssh ringtail "rm -rf $tmpdir" +``` + +## Related + +- [[build-authentik-from-source]] — Parent overview and version update workflow +- [[mirror-authentik-build-deps]] — Supply chain mirrors for source repos +- [[deploy-authentik]] — Deployment goal diff --git a/docs/how-to/authentik/authentik-python-backend-derivation.md b/docs/how-to/authentik/authentik-python-backend-derivation.md deleted file mode 100644 index 4e2c240..0000000 --- a/docs/how-to/authentik/authentik-python-backend-derivation.md +++ /dev/null @@ -1,76 +0,0 @@ ---- -title: Build Authentik Python Backend -modified: 2026-03-01 -last-reviewed: 2026-03-02 -tags: - - how-to - - authentik - - nix ---- - -# Build Authentik Python Backend - -Build `authentik-django` — the Python/Django application that forms the core backend of authentik. - -## Context - -Authentik 2026.2.0 requires Python 3.14 (`requires-python = "==3.14.*"`). The nixpkgs reference derivation (2025.12.4) builds all 60+ Python deps through nix's `python3.override` with `packageOverrides`. This approach breaks on Python 3.14 because many nixpkgs python314 packages haven't been updated — astor, dacite, exceptiongroup, and pydantic-core all fail to build. - -Instead of carrying individual overrides for each broken package, we use **`uv`** to install Python dependencies from PyPI, where upstream maintainers have already published Python 3.14-compatible wheels. Nix provides only the Python interpreter and system libraries. - -## Approach: uv sync FOD + autoPatchelfHook - -Nix builds are sandboxed with no network access. The pattern is: - -1. **Fixed-output derivation (FOD)** — `uv sync --frozen` fetches and installs all dependencies into a venv. FODs are allowed network access because the output hash is declared upfront. Compiled `.so` files reference Nix store paths (RPATHs to libxml2, krb5, etc.), which FODs must not contain, so we strip references with `remove-references-to` and delete `bin/` and `.pyc` files. -2. **Main derivation** — copies the FOD's `lib/python3.14/site-packages/`, recreates `bin/` with proper python symlinks, restores `pyvenv.cfg`, and runs `autoPatchelfHook` to re-link `.so` files against the correct Nix store libraries. - -**Why not `uv pip download` + `uv pip install --no-index`?** `uv pip download` does not exist in uv 0.9.29 (nixpkgs). And the download-only approach has further complications with sdist-only packages (psycopg-c, gssapi) that must be compiled anyway. - -## What to Do - -1. Create the FOD (`python-deps.nix`) that runs `uv sync --frozen --no-install-project --no-install-workspace --no-dev`, then strips all Nix store references from the output -2. Create the main derivation (`authentik-django.nix`) that: - - Copies the FOD's site-packages - - Recreates venv `bin/` and `pyvenv.cfg` - - Runs `autoPatchelfHook` to restore `.so` RPATHs - - Copies 4 in-tree workspace packages directly into site-packages - - Copies `authentik/` and `lifecycle/` into site-packages - - Copies `opencontainers` from `fetchFromGitHub` into site-packages -3. Apply `substituteInPlace` patches for Nix store paths in `settings.py`, `default.yml`, `email/utils.py` -4. Copy lifecycle scripts, `manage.py`, blueprints into the output -5. Verify: `$out/bin/python3.14 -c "import authentik"` succeeds - -## Key Details - -- Nix provides: `python314`, `uv`, system libraries (`libxml2`, `libxslt`, `openssl`, `libffi`, `zlib`, etc.) -- PyPI provides: all Python packages (via pre-built `cp314` wheels where available, sdist builds otherwise) -- The FOD hash must be recomputed when `uv.lock` changes -- `manylinux` wheels bundle some `.so` files — acceptable for a container image -- The 4 in-tree packages are installed from monorepo source, not PyPI -- Standard `djangorestframework` 3.16.1 from PyPI (no longer forked as of 2026.2.0) - -## Lessons Learned - -Build issues encountered and resolved: - -| Issue | Fix | -|-------|-----| -| `pg_config` not found for psycopg-c | Use `pkgs.postgresql.pg_config` (separate derivation), not `pkgs.postgresql` | -| gssapi `gss_acquire_cred_impersonate_name` undeclared | `NIX_CFLAGS_COMPILE="-include gssapi/gssapi_ext.h"` — function is in `gssapi_ext.h`, not auto-included | -| xmlsec linker error `-lltdl` | Add `pkgs.libtool` to buildInputs (provides libltdl) | -| psycopg-c needs `libpq` | Add `pkgs.libpq` to buildInputs | -| Static `refTargets` list missed 6 store refs | Replaced with dynamic discovery: `grep -aohE '/nix/store/...'` finds all refs, `remove-references-to` strips them | -| `xargs grep` exit code 123 under `pipefail` | Wrap pipeline in `{ ... \|\| true; }` — grep returning 1 (no match) causes xargs to return 123 | -| `grep -aoE` includes filename prefix in output | Use `grep -aohE` (`-h` suppresses filenames) to get clean store paths | -| autoPatchelfHook can't find libraries | `buildInputs` in main derivation must include all libraries that `.so` files link against | -| `FieldError: Cannot resolve keyword 'group_id'` on startup | Django migration ordering bug: `authentik_rbac/0010` (drops `Role.group_id`) can run before `authentik_core/0056` (reads it). Add explicit dependency via `substituteInPlace` on the migration file. Upstream [#19616](https://github.com/goauthentik/authentik/issues/19616) | - -The `uv sync` completes in ~3.5 minutes. Dynamic reference discovery finds 19 unique store paths and strips all of them. After stripping, `remove-references-to` mangles hashes to `eeee...` bytes — about 40 files still "contain" `/nix/store/` strings but with invalid hashes, which is expected and harmless. `autoPatchelfHook` in the main derivation resolves all NEEDED entries with 0 unsatisfied dependencies. - -Build verified: `$out/bin/python3.14 -c "import authentik"` succeeds, along with all key dependencies (django 5.2.11, lxml, xmlsec, psycopg, guardian, opencontainers). - -## Related - -- [[build-authentik-from-source]] — Parent goal -- [[authentik-go-server-derivation]] — Depends on this for lifecycle scripts and web assets diff --git a/docs/how-to/authentik/authentik-web-ui-derivation.md b/docs/how-to/authentik/authentik-web-ui-derivation.md deleted file mode 100644 index 3a9572b..0000000 --- a/docs/how-to/authentik/authentik-web-ui-derivation.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -title: Build Authentik Web UI -modified: 2026-03-01 -last-reviewed: 2026-03-02 -tags: - - how-to - - authentik - - nix ---- - -# Build Authentik Web UI - -Build the Lit-based TypeScript web frontend for authentik. - -## Overview - -The web UI lives in `web/` in the authentik repo. As of 2026.2.0, the main build uses **esbuild** (via wireit) and the SFE sub-package uses **rollup**. The Nix build uses a two-phase approach: - -1. **`webui-deps.nix`** — Fixed-output derivation that runs `npm ci` to fetch Node dependencies. Platform-specific output hash (npm downloads architecture-specific native binaries for esbuild, rollup, and SWC). -2. **`webui.nix`** — Copies deps, patches in the generated TypeScript API client (`client-ts`), patches shebangs, then runs `npm run build` (wireit/esbuild) and `npm run build:sfe` (rollup). Output includes `dist/` and `authentik/` static directories. - -## Build Details - -- **Node.js:** `nodejs_24` (authentik requires Node >= 24, npm >= 11.6.2) -- **Build time:** ~33s on ringtail (x86_64-linux) -- **FOD hash:** Platform-specific — will need updating on each authentik version bump -- **Output:** `$out/dist/` (JS/CSS bundles) and `$out/authentik/` (static SVG/PNG icons) -- **Consumed by:** Go server (`authentik-server.nix` via `webui` parameter) for static file serving, and `authentik-django.nix` for email template icon paths -- **Docusaurus website** (`/help` endpoint) is not built — optional and can be added later - -## Key Lessons - -- The 2026.2.0 build switched from rollup to esbuild for the main frontend. Only the SFE sub-package still uses rollup. -- The version string in `packages/core/version/node.js` uses a JSON import-with-assertion that doesn't resolve in the Nix sandbox — must be patched to hardcode the version. -- `NODE_OPTIONS=--openssl-legacy-provider` is needed for compatibility. -- Workspace packages have separate `node_modules/` directories — the FOD must collect all of them via `find`. - -## Related - -- [[build-authentik-from-source]] — Parent goal -- [[authentik-api-client-generation]] — Provides TypeScript client (prerequisite) diff --git a/docs/how-to/authentik/build-authentik-from-source.md b/docs/how-to/authentik/build-authentik-from-source.md index 1f526df..806b4cc 100644 --- a/docs/how-to/authentik/build-authentik-from-source.md +++ b/docs/how-to/authentik/build-authentik-from-source.md @@ -21,7 +21,7 @@ The nix-container-builder runner on ringtail resolves `nixpkgs` via the NixOS ni Authentik has four build components assembled by `containers/authentik/default.nix`: 1. **API client generation** (`client-go.nix`, `client-ts.nix`) — Go and TypeScript bindings generated from `schema.yml` (OpenAPI) -2. **Python backend** (`authentik-django.nix`) — Django application with 60+ Python dependencies installed via `uv` from PyPI (see [[authentik-python-backend-derivation]]) +2. **Python backend** (`authentik-django.nix`) — Django application with 60+ Python dependencies installed via `uv` from PyPI (see [[authentik-nix-build-components#Python Backend]]) 3. **Web UI** (`webui.nix`) — Lit-based TypeScript frontend built with esbuild + rollup 4. **Go server** (`authentik-server.nix`) — HTTP server binary that serves the web UI and spawns gunicorn for Django diff --git a/docs/how-to/authentik/mirror-authentik-build-deps.md b/docs/how-to/authentik/mirror-authentik-build-deps.md index c916983..e5619ed 100644 --- a/docs/how-to/authentik/mirror-authentik-build-deps.md +++ b/docs/how-to/authentik/mirror-authentik-build-deps.md @@ -32,4 +32,4 @@ Previously, `authentik-community/django-rest-framework` (a DRF fork) was also ne ## Related - [[build-authentik-from-source]] — Parent goal -- [[authentik-api-client-generation]] — Consumes client-go mirror +- [[authentik-nix-build-components]] — Consumes client-go mirror diff --git a/docs/how-to/deployment/build-caddy-with-plugins.md b/docs/how-to/deployment/build-caddy-with-plugins.md new file mode 100644 index 0000000..67e857c --- /dev/null +++ b/docs/how-to/deployment/build-caddy-with-plugins.md @@ -0,0 +1,38 @@ +--- +title: Build Caddy with Plugins +modified: 2026-03-15 +last-reviewed: 2026-03-15 +tags: + - how-to + - caddy + - networking +--- + +# Build Caddy with Plugins + +Caddy is built from source using `xcaddy` with two plugins: + +- `github.com/caddy-dns/gandi` — ACME DNS-01 challenges via Gandi API +- `github.com/mholt/caddy-l4` — Layer 4 (TCP/UDP) proxying + +## How to Build + +```bash +# Source and build location (mirrored on forge) +~/code/3rd/caddy/bin/caddy + +# Build via mise task in the caddy clone +cd ~/code/3rd/caddy && mise run build +``` + +## Forge Mirrors + +- `mirrors/caddy` +- `mirrors/caddy-gandi` +- `mirrors/xcaddy` +- `mirrors/caddy-l4` + +## Related + +- [[caddy]] — Service reference card +- [[routing]] — Service routing architecture diff --git a/docs/how-to/grafana/build-grafana-container.md b/docs/how-to/grafana/build-grafana-container.md deleted file mode 100644 index 31edecf..0000000 --- a/docs/how-to/grafana/build-grafana-container.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -title: Build Grafana Container -modified: 2026-02-28 -last-reviewed: 2026-02-28 -tags: - - how-to - - grafana - - containers ---- - -# Build Grafana Container - -Home-built Grafana container image published to `registry.ops.eblu.me/blumeops/grafana`. - -## How It Works - -The Dockerfile at `containers/grafana/Dockerfile` downloads the official Grafana OSS tarball for the target architecture (arm64/amd64), installs it into Alpine, and sets up standard paths. - -To build and push a new version: - -```fish -# Update version in Dockerfile -# ARG CONTAINER_APP_VERSION=12.3.3 - -mise run container-build-and-release grafana -``` - -## Gotchas - -- **Tarball directory name:** Extracts to `grafana-` (e.g. `grafana-12.3.3`), *not* `grafana-v`. -- **Binary PATH:** The binary lives at `bin/grafana` inside the extracted directory. The Dockerfile sets `ENV PATH="/usr/share/grafana/bin:$PATH"`. -- **UID 472:** Matches the official Grafana image for PVC ownership compatibility. - -## Related - -- [[grafana]] — Service reference card -- [[upgrade-grafana]] — Migration context -- [[kustomize-grafana-deployment]] — Kustomize manifest structure -- [[build-grafana-sidecar]] — Home-built sidecar container -- [[build-container-image]] — Standard container build workflow diff --git a/docs/how-to/grafana/build-grafana-images.md b/docs/how-to/grafana/build-grafana-images.md new file mode 100644 index 0000000..0a5f6fd --- /dev/null +++ b/docs/how-to/grafana/build-grafana-images.md @@ -0,0 +1,60 @@ +--- +title: Build Grafana Images +modified: 2026-03-15 +last-reviewed: 2026-03-15 +tags: + - how-to + - grafana + - containers +--- + +# Build Grafana Images + +Home-built container images for Grafana and its dashboard sidecar, published to `registry.ops.eblu.me/blumeops/`. + +## Grafana + +**Dockerfile:** `containers/grafana/Dockerfile` +**Image:** `registry.ops.eblu.me/blumeops/grafana` + +Downloads the official Grafana OSS tarball for the target architecture (arm64/amd64), installs it into Alpine, and sets up standard paths. + +```fish +# Update version in Dockerfile +# ARG CONTAINER_APP_VERSION=12.3.3 + +mise run container-build-and-release grafana +``` + +**Gotchas:** + +- **Tarball directory name:** Extracts to `grafana-` (e.g. `grafana-12.3.3`), *not* `grafana-v`. +- **Binary PATH:** The binary lives at `bin/grafana` inside the extracted directory. The Dockerfile sets `ENV PATH="/usr/share/grafana/bin:$PATH"`. +- **UID 472:** Matches the official Grafana image for PVC ownership compatibility. + +## Grafana Sidecar + +**Dockerfile:** `containers/grafana-sidecar/Dockerfile` +**Image:** `registry.ops.eblu.me/blumeops/grafana-sidecar` + +Clones the [kiwigrid/k8s-sidecar](https://github.com/kiwigrid/k8s-sidecar) source from the forge mirror, installs Python dependencies into a venv, and copies the application into a minimal Alpine runtime image. + +```fish +# Update version in Dockerfile +# ARG CONTAINER_APP_VERSION=1.28.0 + +mise run container-build-and-release grafana-sidecar +``` + +**Gotchas:** + +- **Pinned to v1.28.0:** v2.x has a 135% memory regression ([#462](https://github.com/kiwigrid/k8s-sidecar/issues/462)) and `readOnlyRootFilesystem` crashloop ([#3936](https://github.com/grafana/helm-charts/issues/3936)). Upgrade separately after upstream fixes land. +- **UID 65534:** Matches upstream's `nobody` user convention for non-root execution. +- **Forge mirror name:** `mirrors/kiwigrid-grafana-sidecar` (not `k8s-sidecar`). + +## Related + +- [[grafana]] — Service reference card +- [[upgrade-grafana]] — Migration context and future upgrade steps +- [[kustomize-grafana-deployment]] — Kustomize manifest structure +- [[build-container-image]] — Standard container build workflow diff --git a/docs/how-to/grafana/build-grafana-sidecar.md b/docs/how-to/grafana/build-grafana-sidecar.md deleted file mode 100644 index b4ef412..0000000 --- a/docs/how-to/grafana/build-grafana-sidecar.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -title: Build Grafana Sidecar -modified: 2026-03-03 -last-reviewed: 2026-03-03 -tags: - - how-to - - grafana - - containers ---- - -# Build Grafana Sidecar - -Home-built k8s-sidecar container image published to `registry.ops.eblu.me/blumeops/grafana-sidecar`. - -## How It Works - -The Dockerfile at `containers/grafana-sidecar/Dockerfile` clones the [kiwigrid/k8s-sidecar](https://github.com/kiwigrid/k8s-sidecar) source from the forge mirror, installs Python dependencies into a venv, and copies the application into a minimal Alpine runtime image. - -To build and push a new version: - -```fish -# Update version in Dockerfile -# ARG CONTAINER_APP_VERSION=1.28.0 - -mise run container-build-and-release grafana-sidecar -``` - -## Gotchas - -- **Pinned to v1.28.0:** v2.x has a 135% memory regression ([#462](https://github.com/kiwigrid/k8s-sidecar/issues/462)) and `readOnlyRootFilesystem` crashloop ([#3936](https://github.com/grafana/helm-charts/issues/3936)). Upgrade separately after upstream fixes land. -- **UID 65534:** Matches upstream's `nobody` user convention for non-root execution. -- **Forge mirror name:** `mirrors/kiwigrid-grafana-sidecar` (not `k8s-sidecar`). - -## Related - -- [[grafana]] — Service reference card -- [[build-grafana-container]] — Home-built Grafana container -- [[kustomize-grafana-deployment]] — Kustomize manifest structure -- [[build-container-image]] — Standard container build workflow diff --git a/docs/how-to/grafana/kustomize-grafana-deployment.md b/docs/how-to/grafana/kustomize-grafana-deployment.md index da96115..d5d2773 100644 --- a/docs/how-to/grafana/kustomize-grafana-deployment.md +++ b/docs/how-to/grafana/kustomize-grafana-deployment.md @@ -34,5 +34,5 @@ Grafana is deployed via plain Kustomize manifests in `argocd/manifests/grafana/` ## Related - [[upgrade-grafana]] — Migration context -- [[build-grafana-sidecar]] — Home-built sidecar container +- [[build-grafana-images]] — Home-built container images - [[grafana]] — Service reference card diff --git a/docs/how-to/grafana/upgrade-grafana.md b/docs/how-to/grafana/upgrade-grafana.md index 39af369..88d41f9 100644 --- a/docs/how-to/grafana/upgrade-grafana.md +++ b/docs/how-to/grafana/upgrade-grafana.md @@ -43,6 +43,5 @@ The SQLite PVC is disposable — dashboards come from ConfigMaps and datasources ## Related - [[grafana]] — Service reference card -- [[build-grafana-container]] — Building the container image -- [[build-grafana-sidecar]] — Building the dashboard sidecar image +- [[build-grafana-images]] — Building the container images - [[kustomize-grafana-deployment]] — Kustomize manifest structure diff --git a/docs/reference/services/caddy.md b/docs/reference/services/caddy.md index 8896a86..daadcb0 100644 --- a/docs/reference/services/caddy.md +++ b/docs/reference/services/caddy.md @@ -87,20 +87,7 @@ Caddy has no authentication layer — it is a plain reverse proxy. Access contro ## Custom Build -Caddy is built from source using `xcaddy` with two plugins: - -- `github.com/caddy-dns/gandi` — ACME DNS-01 challenges via Gandi API -- `github.com/mholt/caddy-l4` — Layer 4 (TCP/UDP) proxying - -```bash -# Source and build location (mirrored on forge) -~/code/3rd/caddy/bin/caddy - -# Build via mise task in the caddy clone -cd ~/code/3rd/caddy && mise run build -``` - -Forge mirrors: `mirrors/caddy`, `mirrors/caddy-gandi`, `mirrors/xcaddy`, `mirrors/caddy-l4`. +Custom `xcaddy` build with Gandi DNS and L4 plugins. See [[build-caddy-with-plugins]] for build instructions and forge mirror details. ## Related diff --git a/docs/reference/services/grafana.md b/docs/reference/services/grafana.md index b0459d5..3a9ae01 100644 --- a/docs/reference/services/grafana.md +++ b/docs/reference/services/grafana.md @@ -59,8 +59,7 @@ Optional annotation: `grafana_folder: "FolderName"` ## Related -- [[build-grafana-container]] - Home-built container image -- [[build-grafana-sidecar]] - Home-built sidecar container +- [[build-grafana-images]] - Home-built container images (Grafana + sidecar) - [[kustomize-grafana-deployment]] - Kustomize manifest structure - [[authentik]] - OIDC identity provider for SSO - [[migrate-grafana-to-authentik]] - How SSO was migrated from Dex to Authentik diff --git a/docs/how-to/configuration/expose-service-publicly.md b/docs/tutorials/expose-service-publicly.md similarity index 99% rename from docs/how-to/configuration/expose-service-publicly.md rename to docs/tutorials/expose-service-publicly.md index bb6b258..b3fdda6 100644 --- a/docs/how-to/configuration/expose-service-publicly.md +++ b/docs/tutorials/expose-service-publicly.md @@ -1,9 +1,9 @@ --- title: Expose a Service Publicly -modified: 2026-03-03 +modified: 2026-03-15 last-reviewed: 2026-03-03 tags: - - how-to + - tutorials - fly-io - tailscale - networking From 0f5377568d005246f16a26fb4b74183c01b4a8bc Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 16 Mar 2026 07:38:02 -0700 Subject: [PATCH 049/430] Review operations docs: add last-reviewed dates and improve troubleshooting Mark run-1password-backup and troubleshooting as reviewed. Troubleshooting gets inline wiki-links for all referenced services, a new ringtail/k3s section, and a cross-reference to restart-indri. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../how-to/operations/run-1password-backup.md | 1 + docs/how-to/operations/troubleshooting.md | 55 +++++++++++++++---- 2 files changed, 44 insertions(+), 12 deletions(-) diff --git a/docs/how-to/operations/run-1password-backup.md b/docs/how-to/operations/run-1password-backup.md index bbed3ab..b0807da 100644 --- a/docs/how-to/operations/run-1password-backup.md +++ b/docs/how-to/operations/run-1password-backup.md @@ -1,6 +1,7 @@ --- title: Run 1Password Backup modified: 2026-03-11 +last-reviewed: 2026-03-16 tags: - how-to - operations diff --git a/docs/how-to/operations/troubleshooting.md b/docs/how-to/operations/troubleshooting.md index 567b9e7..63dc79a 100644 --- a/docs/how-to/operations/troubleshooting.md +++ b/docs/how-to/operations/troubleshooting.md @@ -1,6 +1,7 @@ --- title: Troubleshooting -modified: 2026-02-07 +modified: 2026-03-16 +last-reviewed: 2026-03-16 tags: - how-to - operations @@ -20,7 +21,9 @@ mise run services-check This checks all services on indri and in Kubernetes. -## Kubernetes Issues +## Kubernetes Issues (Indri / Minikube) + +Most services run on [[indri]]'s minikube. For [[ringtail]] (k3s) services, see the ringtail section below. ### Pod not starting @@ -44,7 +47,7 @@ Common causes: - **Pending** - Insufficient resources or node issues - **ContainerCreating** - Waiting for volumes or secrets -### ArgoCD sync issues +### [[argocd|ArgoCD]] sync issues ```bash # Check app status @@ -102,7 +105,7 @@ ssh indri 'tail -50 ~/Library/Logs/mcquack..err.log' ssh indri 'tail -50 ~/Library/Logs/mcquack..out.log' ``` -### Forgejo not accessible +### [[forgejo|Forgejo]] not accessible ```bash # Check if forgejo is running @@ -115,7 +118,7 @@ ssh indri 'tail -50 ~/Library/Logs/mcquack.forgejo.err.log' ssh indri 'launchctl kickstart -k gui/$(id -u)/mcquack.forgejo' ``` -### Registry (Zot) issues +### Registry ([[zot|Zot]]) issues ```bash # Test registry API @@ -132,7 +135,7 @@ ssh indri 'launchctl kickstart -k gui/$(id -u)/mcquack.zot' ### Service unreachable via *.ops.eblu.me -Caddy handles routing for `*.ops.eblu.me`: +[[caddy|Caddy]] handles routing for `*.ops.eblu.me`: ```bash # Check if Caddy is running @@ -161,10 +164,10 @@ ssh indri 'tailscale down && tailscale up' ### Check metrics ```bash -# Open Grafana +# Open [[grafana|Grafana]] open https://grafana.ops.eblu.me -# Check Prometheus directly +# Check [[prometheus|Prometheus]] directly open https://prometheus.ops.eblu.me ``` @@ -174,13 +177,13 @@ open https://prometheus.ops.eblu.me # Open Grafana Explore open https://grafana.ops.eblu.me/explore -# Query Loki directly +# Query [[loki|Loki]] directly curl -G 'https://loki.ops.eblu.me/loki/api/v1/query_range' \ --data-urlencode 'query={service=""}' \ --data-urlencode 'limit=100' ``` -### Alloy (metrics/logs collector) issues +### [[alloy|Alloy]] (metrics/logs collector) issues ```bash # Indri alloy (host metrics) @@ -193,7 +196,7 @@ kubectl --context=minikube-indri -n monitoring logs -l app=alloy ## Database Issues -### PostgreSQL connection failed +### [[postgresql|PostgreSQL]] connection failed ```bash # Check CNPG cluster status @@ -208,7 +211,7 @@ kubectl --context=minikube-indri -n databases exec -it blumeops-pg-1 -- psql -U ## Backup Issues -### Check backup status +### Check [[borgmatic|backup]] status ```bash # View latest backup info @@ -221,9 +224,37 @@ ssh indri 'borgmatic --verbosity 1' ssh indri 'tail -100 /opt/homebrew/var/log/borgmatic/borgmatic.log' ``` +## Kubernetes Issues (Ringtail / k3s) + +[[ringtail]] runs GPU workloads ([[frigate|Frigate]], [[ntfy]]) and [[authentik|Authentik]] on a single-node k3s cluster. The same debugging patterns apply, but use `--context=k3s-ringtail`: + +```bash +# Check pod status +kubectl --context=k3s-ringtail -n get pods + +# Describe pod for events +kubectl --context=k3s-ringtail -n describe pod + +# Check logs +kubectl --context=k3s-ringtail -n logs +``` + +### Ringtail unreachable + +```bash +# Check if ringtail is on the tailnet +tailscale ping ringtail + +# SSH in directly +ssh ringtail +``` + +If ringtail is unreachable, it may need a physical power cycle. See [[ringtail]] for details. + ## Related - [[observability]] - Metrics and logs - [[argocd]] - GitOps platform - [[cluster]] - Kubernetes cluster - [[routing]] - Service routing +- [[restart-indri]] - Shutdown/startup procedure and CNI conflict fix From a29ced71b52570460546ed2edd9d05680a2e6b09 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 16 Mar 2026 11:05:24 -0700 Subject: [PATCH 050/430] =?UTF-8?q?Upgrade=20borgmatic=202.0.13=20?= =?UTF-8?q?=E2=86=92=202.1.3=20(#297)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Upgraded borgmatic from 2.0.13 to 2.1.3 on indri (via mise/pipx) - Key changes: improved borg warning handling, memory/performance improvements, `source_directories_must_exist` now defaults to true (already set in our config) - Verified: config validates, dry-run passed against both sifaka (local) and borgbase (offsite) repos ## Borg Warnings Investigation The main concern was 2.1.0's change to treat borg warnings as errors. In 2.1.3 this was partially reverted — "file not found" warnings (exit code 107) are back to being warnings. Our config already sets `source_directories_must_exist: true`, and all four source directories were verified present on indri. ## Test plan - [x] `borgmatic --version` confirms 2.1.3 - [x] `borgmatic config validate` passes - [x] `borgmatic create --dry-run` succeeds against both repositories - [x] All source directories verified present on indri - [ ] Verify next scheduled backup (2:00 AM) completes successfully 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/297 --- docs/changelog.d/upgrade-borgmatic-2.1.3.infra.md | 1 + service-versions.yaml | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) create mode 100644 docs/changelog.d/upgrade-borgmatic-2.1.3.infra.md diff --git a/docs/changelog.d/upgrade-borgmatic-2.1.3.infra.md b/docs/changelog.d/upgrade-borgmatic-2.1.3.infra.md new file mode 100644 index 0000000..74542e4 --- /dev/null +++ b/docs/changelog.d/upgrade-borgmatic-2.1.3.infra.md @@ -0,0 +1 @@ +Upgrade borgmatic from 2.0.13 to 2.1.3 on indri (improved borg warning handling, memory/performance improvements) diff --git a/service-versions.yaml b/service-versions.yaml index 85705cc..0ad1733 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -283,9 +283,10 @@ services: - name: borgmatic type: ansible - last-reviewed: null - current-version: null + last-reviewed: 2026-03-16 + current-version: "2.1.3" upstream-source: https://github.com/borgmatic-collective/borgmatic/releases + notes: Installed via mise (pipx), not managed by Ansible role - name: jellyfin type: ansible From 4dc3e5cae2afc102d1633ce7598538881c097e16 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 16 Mar 2026 15:52:45 -0700 Subject: [PATCH 051/430] Add UnPoller for UniFi network metrics (#298) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Deploy UnPoller as a k8s service on indri to export UniFi controller metrics to Prometheus - Custom-built container from forge mirror (`containers/unpoller/Dockerfile`) - Credentials pulled from 1Password via external-secrets - Prometheus scrape job added, docs and service-versions updated ## Test plan - [ ] Build container: `mise run container-release unpoller v2.34.0` - [ ] Update kustomization tag with built image tag - [ ] Deploy from branch: `argocd app set unpoller --revision feature/unpoller && argocd app sync unpoller` - [ ] Verify pod connects to UX7 controller (check logs) - [ ] Confirm `unpoller` target appears in Prometheus - [ ] Query `unifi_` metrics in Grafana 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/298 --- argocd/apps/unpoller.yaml | 17 ++++++++ argocd/manifests/grafana/deployment.yaml | 36 ++++++++++++++++ argocd/manifests/prometheus/prometheus.yml | 8 ++++ argocd/manifests/unpoller/deployment.yaml | 42 +++++++++++++++++++ .../manifests/unpoller/external-secret.yaml | 18 ++++++++ argocd/manifests/unpoller/kustomization.yaml | 18 ++++++++ argocd/manifests/unpoller/service.yaml | 13 ++++++ argocd/manifests/unpoller/up.conf | 16 +++++++ containers/unpoller/Dockerfile | 40 ++++++++++++++++++ docs/changelog.d/feature-unpoller.feature.md | 1 + docs/reference/infrastructure/unifi.md | 10 ++++- service-versions.yaml | 7 ++++ 12 files changed, 225 insertions(+), 1 deletion(-) create mode 100644 argocd/apps/unpoller.yaml create mode 100644 argocd/manifests/unpoller/deployment.yaml create mode 100644 argocd/manifests/unpoller/external-secret.yaml create mode 100644 argocd/manifests/unpoller/kustomization.yaml create mode 100644 argocd/manifests/unpoller/service.yaml create mode 100644 argocd/manifests/unpoller/up.conf create mode 100644 containers/unpoller/Dockerfile create mode 100644 docs/changelog.d/feature-unpoller.feature.md diff --git a/argocd/apps/unpoller.yaml b/argocd/apps/unpoller.yaml new file mode 100644 index 0000000..5eaadfb --- /dev/null +++ b/argocd/apps/unpoller.yaml @@ -0,0 +1,17 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: unpoller + namespace: argocd +spec: + project: default + source: + repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git + targetRevision: main + path: argocd/manifests/unpoller + destination: + server: https://kubernetes.default.svc + namespace: monitoring + syncPolicy: + syncOptions: + - CreateNamespace=true diff --git a/argocd/manifests/grafana/deployment.yaml b/argocd/manifests/grafana/deployment.yaml index bdf4a6f..130618c 100644 --- a/argocd/manifests/grafana/deployment.yaml +++ b/argocd/manifests/grafana/deployment.yaml @@ -90,6 +90,42 @@ spec: volumeMounts: - name: sc-dashboard-volume mountPath: /tmp/dashboards + # Fetch UnPoller (UniFi) dashboards from forge mirror. + # Source: github.com/unpoller/dashboards (v2.0.0 Prometheus set) + - name: init-unpoller-dashboards + image: docker.io/library/alpine:kustomized + imagePullPolicy: IfNotPresent + command: ["sh", "-c"] + args: + - | + set -e + BASE_URL="https://forge.ops.eblu.me/mirrors/unpoller-dashboards/raw/branch/master/v2.0.0" + DEST="/tmp/dashboards/UniFi" + mkdir -p "$DEST" + for f in \ + # DPI dashboard requires DPI enabled on both UX7 and UnPoller + # "UniFi-Poller_ Client DPI - Prometheus.json" \ + "UniFi-Poller_ Client Insights - Prometheus.json" \ + "UniFi-Poller_ Network Sites - Prometheus.json" \ + "UniFi-Poller_ UAP Insights - Prometheus.json" \ + "UniFi-Poller_ USG Insights - Prometheus.json" \ + "UniFi-Poller_ USW Insights - Prometheus.json" \ + ; do + wget -q -O "$DEST/$f" "$BASE_URL/$(echo "$f" | sed 's/ /%20/g')" + done + # Fix datasource UIDs to match our Prometheus instance + sed -i 's/"uid": *"bdkj55oguty4gd"/"uid": "prometheus"/g' "$DEST"/*.json + sed -i 's/"uid": *"\${DS_PROMETHEUS}"/"uid": "prometheus"/g' "$DEST"/*.json + echo "Fetched $(ls "$DEST" | wc -l) UnPoller dashboards" + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + seccompProfile: + type: RuntimeDefault + volumeMounts: + - name: sc-dashboard-volume + mountPath: /tmp/dashboards containers: # Dashboard sidecar - watches ConfigMaps with grafana_dashboard=1 - name: grafana-sc-dashboard diff --git a/argocd/manifests/prometheus/prometheus.yml b/argocd/manifests/prometheus/prometheus.yml index 2fd3252..2d2dbcf 100644 --- a/argocd/manifests/prometheus/prometheus.yml +++ b/argocd/manifests/prometheus/prometheus.yml @@ -72,6 +72,14 @@ scrape_configs: - target_label: cluster replacement: indri + # UniFi network metrics (via UnPoller exporter) + - job_name: "unpoller" + static_configs: + - targets: ["unpoller.monitoring.svc.cluster.local:9130"] + metric_relabel_configs: + - target_label: cluster + replacement: indri + # Frigate NVR metrics (via Caddy on indri — Frigate runs on ringtail) - job_name: "frigate" scheme: https diff --git a/argocd/manifests/unpoller/deployment.yaml b/argocd/manifests/unpoller/deployment.yaml new file mode 100644 index 0000000..2f7d13c --- /dev/null +++ b/argocd/manifests/unpoller/deployment.yaml @@ -0,0 +1,42 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: unpoller + namespace: monitoring + labels: + app: unpoller +spec: + replicas: 1 + selector: + matchLabels: + app: unpoller + template: + metadata: + labels: + app: unpoller + spec: + containers: + - name: unpoller + image: registry.ops.eblu.me/blumeops/unpoller:kustomized + ports: + - containerPort: 9130 + name: metrics + env: + - name: UP_UNIFI_DEFAULT_API_KEY + valueFrom: + secretKeyRef: + name: unpoller-unifi + key: api-key + volumeMounts: + - name: config + mountPath: /etc/unpoller + resources: + requests: + cpu: 10m + memory: 32Mi + limits: + memory: 128Mi + volumes: + - name: config + configMap: + name: unpoller-config diff --git a/argocd/manifests/unpoller/external-secret.yaml b/argocd/manifests/unpoller/external-secret.yaml new file mode 100644 index 0000000..c82ec0d --- /dev/null +++ b/argocd/manifests/unpoller/external-secret.yaml @@ -0,0 +1,18 @@ +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: unpoller-unifi + namespace: monitoring +spec: + refreshInterval: 1h + secretStoreRef: + kind: ClusterSecretStore + name: onepassword-blumeops + target: + name: unpoller-unifi + creationPolicy: Owner + data: + - secretKey: api-key + remoteRef: + key: unpoller + property: credential diff --git a/argocd/manifests/unpoller/kustomization.yaml b/argocd/manifests/unpoller/kustomization.yaml new file mode 100644 index 0000000..da7524d --- /dev/null +++ b/argocd/manifests/unpoller/kustomization.yaml @@ -0,0 +1,18 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: monitoring + +resources: + - deployment.yaml + - service.yaml + - external-secret.yaml + +images: + - name: registry.ops.eblu.me/blumeops/unpoller + newTag: v2.34.0-6b0005d + +configMapGenerator: + - name: unpoller-config + files: + - up.conf diff --git a/argocd/manifests/unpoller/service.yaml b/argocd/manifests/unpoller/service.yaml new file mode 100644 index 0000000..1ce870b --- /dev/null +++ b/argocd/manifests/unpoller/service.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Service +metadata: + name: unpoller + namespace: monitoring +spec: + selector: + app: unpoller + ports: + - port: 9130 + targetPort: metrics + protocol: TCP + name: metrics diff --git a/argocd/manifests/unpoller/up.conf b/argocd/manifests/unpoller/up.conf new file mode 100644 index 0000000..0430067 --- /dev/null +++ b/argocd/manifests/unpoller/up.conf @@ -0,0 +1,16 @@ +[prometheus] + http_listen = "0.0.0.0:9130" + report_errors = true + +[influxdb] + disable = true + +[unifi] + dynamic = false + +[unifi.defaults] + # API key comes from environment variable: UP_UNIFI_DEFAULT_API_KEY + url = "https://192.168.1.1" + verify_ssl = false + save_sites = true + save_dpi = false diff --git a/containers/unpoller/Dockerfile b/containers/unpoller/Dockerfile new file mode 100644 index 0000000..0391f6d --- /dev/null +++ b/containers/unpoller/Dockerfile @@ -0,0 +1,40 @@ +# UnPoller — UniFi metrics exporter for Prometheus +# Two-stage build: Go compilation, then minimal Alpine runtime + +ARG CONTAINER_APP_VERSION=v2.34.0 + +FROM golang:alpine3.22 AS build + +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/unpoller.git /app + +WORKDIR /app + +ENV CGO_ENABLED=0 + +RUN go build -ldflags="-s -w \ + -X main.version=${CONTAINER_APP_VERSION} \ + -X main.builtBy=blumeops \ + -X golift.io/version.Version=${CONTAINER_APP_VERSION} \ + -X golift.io/version.Branch=HEAD \ + -X golift.io/version.BuildUser=blumeops \ + -X golift.io/version.Revision=blumeops-build" \ + -o /bin/unpoller . + +FROM alpine:3.22 + +LABEL org.opencontainers.image.title="UnPoller" +LABEL org.opencontainers.image.description="UniFi metrics exporter for Prometheus" +LABEL org.opencontainers.image.source="https://github.com/unpoller/unpoller" + +RUN apk add --no-cache ca-certificates tzdata + +COPY --from=build /bin/unpoller /usr/bin/unpoller + +EXPOSE 9130 +USER 65534:65534 +ENTRYPOINT ["/usr/bin/unpoller"] +CMD ["--config", "/etc/unpoller/up.conf"] diff --git a/docs/changelog.d/feature-unpoller.feature.md b/docs/changelog.d/feature-unpoller.feature.md new file mode 100644 index 0000000..848cbbc --- /dev/null +++ b/docs/changelog.d/feature-unpoller.feature.md @@ -0,0 +1 @@ +Add UnPoller deployment to monitor UniFi network metrics via Prometheus diff --git a/docs/reference/infrastructure/unifi.md b/docs/reference/infrastructure/unifi.md index 71ca744..6182880 100644 --- a/docs/reference/infrastructure/unifi.md +++ b/docs/reference/infrastructure/unifi.md @@ -1,6 +1,6 @@ --- title: UniFi -modified: 2026-02-24 +modified: 2026-03-16 tags: - infrastructure - networking @@ -69,6 +69,14 @@ Local admin account on the UX7. Credentials stored in 1Password (vault `blumeops Attempted Feb 2026 with the `ubiquiti-community/unifi` Terraform provider via Pulumi. A "no-op" update on the default LAN network reset undeclared properties, bricking the network and requiring a factory reset. The provider ecosystem is too immature for single-device infrastructure. +## Monitoring + +UniFi metrics are exported to Prometheus via [UnPoller](https://github.com/unpoller/unpoller), running as a k8s deployment in the `monitoring` namespace on indri. UnPoller polls the UX7 controller API using an API key and exposes metrics on port 9130. + +- **Prometheus job:** `unpoller` +- **Metrics prefix:** `unifi_` +- **Credentials:** 1Password item `unpoller` (vault `blumeops`, API key) + ## Related - [[hosts]] — Device inventory diff --git a/service-versions.yaml b/service-versions.yaml index 0ad1733..686a529 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -253,6 +253,13 @@ services: upstream-source: https://code.forgejo.org/forgejo/runner/releases notes: Forgejo runner on ringtail via nixpkgs; version tracks flake.lock + - name: unpoller + type: argocd + last-reviewed: 2026-03-16 + current-version: "v2.34.0" + upstream-source: https://github.com/unpoller/unpoller/releases + notes: UniFi metrics exporter for Prometheus + - name: forgejo type: ansible last-reviewed: 2026-02-22 From b0846ab5fa6cda30a414186f561bb916b35ef916 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 16 Mar 2026 15:54:03 -0700 Subject: [PATCH 052/430] Update unpoller container tag to main build Co-Authored-By: Claude Opus 4.6 (1M context) --- argocd/manifests/unpoller/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/argocd/manifests/unpoller/kustomization.yaml b/argocd/manifests/unpoller/kustomization.yaml index da7524d..142e748 100644 --- a/argocd/manifests/unpoller/kustomization.yaml +++ b/argocd/manifests/unpoller/kustomization.yaml @@ -10,7 +10,7 @@ resources: images: - name: registry.ops.eblu.me/blumeops/unpoller - newTag: v2.34.0-6b0005d + newTag: v2.34.0-4dc3e5c configMapGenerator: - name: unpoller-config From b54d87e071c685caa82307213089cb8dde927987 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 16 Mar 2026 15:59:03 -0700 Subject: [PATCH 053/430] Fix shell syntax error in unpoller dashboard initcontainer Comments can't appear inside a for-in list in sh. Co-Authored-By: Claude Opus 4.6 (1M context) --- argocd/manifests/grafana/deployment.yaml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/argocd/manifests/grafana/deployment.yaml b/argocd/manifests/grafana/deployment.yaml index 130618c..86e7eb7 100644 --- a/argocd/manifests/grafana/deployment.yaml +++ b/argocd/manifests/grafana/deployment.yaml @@ -102,9 +102,8 @@ spec: BASE_URL="https://forge.ops.eblu.me/mirrors/unpoller-dashboards/raw/branch/master/v2.0.0" DEST="/tmp/dashboards/UniFi" mkdir -p "$DEST" + # DPI dashboard omitted — requires DPI enabled on both UX7 and UnPoller for f in \ - # DPI dashboard requires DPI enabled on both UX7 and UnPoller - # "UniFi-Poller_ Client DPI - Prometheus.json" \ "UniFi-Poller_ Client Insights - Prometheus.json" \ "UniFi-Poller_ Network Sites - Prometheus.json" \ "UniFi-Poller_ UAP Insights - Prometheus.json" \ From 11330ebea0b5ee314e7eade61fa44e48c597a3be Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 16 Mar 2026 21:59:10 -0700 Subject: [PATCH 054/430] Deploy Mealie recipe manager (#299) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Deploy Mealie (self-hosted recipe manager) on minikube-indri via ArgoCD - Build container from source via forge mirror (`mirrors/mealie`) — multi-stage Dockerfile with Node.js frontend + Python/uv backend - Add Caddy proxy entry for `meals.ops.eblu.me` - Part of a larger meal planning pipeline: Mealie stores categorized recipes, a planner script selects balanced meals, and Ollama generates unified cooking timelines ## Status - [x] Mirror mealie repo on forge - [x] Dockerfile (from-source build) - [x] ArgoCD app + k8s manifests - [x] Caddy proxy entry - [x] Service docs, routing table, app registry - [ ] Local Dagger build test - [ ] Container build + push to registry - [ ] Update kustomization.yaml with real image tag - [ ] Deploy and verify - [ ] Provision Caddy ## Test plan - Build container locally via `dagger call build --src=. --container-name=mealie` - Trigger CI build via `mise run container-build-and-release mealie` - Deploy from branch: `argocd app set mealie --revision deploy-mealie && argocd app sync mealie` - Verify Mealie UI at `https://meals.ops.eblu.me` - Verify API docs at `https://meals.ops.eblu.me/docs` Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/299 --- ansible/roles/borgmatic/defaults/main.yml | 14 ++ ansible/roles/borgmatic/tasks/main.yml | 7 + .../roles/borgmatic/templates/config.yaml.j2 | 10 ++ ansible/roles/caddy/defaults/main.yml | 3 + argocd/apps/mealie.yaml | 17 +++ .../authentik/configmap-blueprint.yaml | 44 ++++++ .../authentik/deployment-worker.yaml | 5 + .../manifests/authentik/external-secret.yaml | 4 + argocd/manifests/mealie/deployment.yaml | 79 ++++++++++ argocd/manifests/mealie/external-secret.yaml | 19 +++ .../manifests/mealie/ingress-tailscale.yaml | 25 +++ argocd/manifests/mealie/kustomization.yaml | 15 ++ argocd/manifests/mealie/pvc.yaml | 13 ++ argocd/manifests/mealie/service.yaml | 13 ++ containers/mealie/Dockerfile | 142 ++++++++++++++++++ docs/changelog.d/deploy-mealie.feature.md | 1 + docs/explanation/federated-login.md | 3 +- docs/reference/infrastructure/routing.md | 1 + docs/reference/kubernetes/apps.md | 1 + docs/reference/services/borgmatic.md | 8 +- docs/reference/services/mealie.md | 61 ++++++++ service-versions.yaml | 7 + 22 files changed, 489 insertions(+), 3 deletions(-) create mode 100644 argocd/apps/mealie.yaml create mode 100644 argocd/manifests/mealie/deployment.yaml create mode 100644 argocd/manifests/mealie/external-secret.yaml create mode 100644 argocd/manifests/mealie/ingress-tailscale.yaml create mode 100644 argocd/manifests/mealie/kustomization.yaml create mode 100644 argocd/manifests/mealie/pvc.yaml create mode 100644 argocd/manifests/mealie/service.yaml create mode 100644 containers/mealie/Dockerfile create mode 100644 docs/changelog.d/deploy-mealie.feature.md create mode 100644 docs/reference/services/mealie.md diff --git a/ansible/roles/borgmatic/defaults/main.yml b/ansible/roles/borgmatic/defaults/main.yml index 7d2ef49..5980915 100644 --- a/ansible/roles/borgmatic/defaults/main.yml +++ b/ansible/roles/borgmatic/defaults/main.yml @@ -16,6 +16,7 @@ borgmatic_source_directories: - /opt/homebrew/var/forgejo - /Users/erichblume/.config/borgmatic - /Users/erichblume/Documents + - /Users/erichblume/.local/share/borgmatic/k8s-dumps # Backup repositories borgmatic_repositories: @@ -31,6 +32,19 @@ borgmatic_repositories: # BorgBase SSH key (fetched from 1Password in playbook pre_tasks) borgmatic_borgbase_ssh_key_path: /Users/erichblume/.ssh/borgbase_ed25519 +# Directory for pre-backup database dumps from k8s pods +borgmatic_k8s_dump_dir: /Users/erichblume/.local/share/borgmatic/k8s-dumps + +# K8s SQLite databases to dump before backup via kubectl exec +# Each entry runs: kubectl exec -- sqlite3 ".backup /tmp/backup.db" +# then copies the dump to borgmatic_k8s_dump_dir/.db +borgmatic_k8s_sqlite_dumps: + - name: mealie + namespace: mealie + label_selector: app=mealie + db_path: /app/data/mealie.db + context: minikube-indri + # Exclude patterns borgmatic_exclude_patterns: [] diff --git a/ansible/roles/borgmatic/tasks/main.yml b/ansible/roles/borgmatic/tasks/main.yml index ea82cb2..a4b1d7b 100644 --- a/ansible/roles/borgmatic/tasks/main.yml +++ b/ansible/roles/borgmatic/tasks/main.yml @@ -33,6 +33,13 @@ key: "u3ugi1x1.repo.borgbase.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGU0mISTyHBw9tBs6SuhSq8tvNM8m9eifQxM+88TowPO" state: present +- name: Ensure k8s dump directory exists + ansible.builtin.file: + path: "{{ borgmatic_k8s_dump_dir }}" + state: directory + mode: '0700' + when: borgmatic_k8s_sqlite_dumps | length > 0 + - name: Deploy borgmatic configuration ansible.builtin.template: src: config.yaml.j2 diff --git a/ansible/roles/borgmatic/templates/config.yaml.j2 b/ansible/roles/borgmatic/templates/config.yaml.j2 index 9b8da14..85804b7 100644 --- a/ansible/roles/borgmatic/templates/config.yaml.j2 +++ b/ansible/roles/borgmatic/templates/config.yaml.j2 @@ -31,6 +31,16 @@ exclude_patterns: encryption_passcommand: {{ borgmatic_encryption_passcommand }} +{% if borgmatic_k8s_sqlite_dumps %} +# Pre-backup: dump SQLite databases from k8s pods +# Uses sqlite3 .backup for a safe, consistent copy (no corruption from concurrent writes) +before_backup: + - mkdir -p {{ borgmatic_k8s_dump_dir }} +{% for db in borgmatic_k8s_sqlite_dumps %} + - /opt/homebrew/bin/kubectl --context={{ db.context }} exec -n {{ db.namespace }} deploy/{{ db.name }} -- python3 -c "import sqlite3; sqlite3.connect('{{ db.db_path }}').backup(sqlite3.connect('/tmp/{{ db.name }}-backup.db'))" && /opt/homebrew/bin/kubectl --context={{ db.context }} cp {{ db.namespace }}/$(/opt/homebrew/bin/kubectl --context={{ db.context }} get pod -n {{ db.namespace }} -l {{ db.label_selector }} -o jsonpath='{.items[0].metadata.name}'):/tmp/{{ db.name }}-backup.db {{ borgmatic_k8s_dump_dir }}/{{ db.name }}.db +{% endfor %} +{% endif %} + ssh_command: ssh -o IdentitiesOnly=yes -i {{ borgmatic_borgbase_ssh_key_path }} # Retention policy diff --git a/ansible/roles/caddy/defaults/main.yml b/ansible/roles/caddy/defaults/main.yml index a9576a1..dbf0b13 100644 --- a/ansible/roles/caddy/defaults/main.yml +++ b/ansible/roles/caddy/defaults/main.yml @@ -91,6 +91,9 @@ caddy_services: - name: ollama host: "ollama.{{ caddy_domain }}" backend: "https://ollama.tail8d86e.ts.net" + - name: mealie + host: "meals.{{ caddy_domain }}" + backend: "https://meals.tail8d86e.ts.net" - name: sifaka host: "nas.{{ caddy_domain }}" backend: "http://sifaka:5000" diff --git a/argocd/apps/mealie.yaml b/argocd/apps/mealie.yaml new file mode 100644 index 0000000..af33469 --- /dev/null +++ b/argocd/apps/mealie.yaml @@ -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 diff --git a/argocd/manifests/authentik/configmap-blueprint.yaml b/argocd/manifests/authentik/configmap-blueprint.yaml index f6ea4d6..cc3ff43 100644 --- a/argocd/manifests/authentik/configmap-blueprint.yaml +++ b/argocd/manifests/authentik/configmap-blueprint.yaml @@ -345,3 +345,47 @@ data: provider: !KeyOf jellyfin-provider meta_launch_url: https://jellyfin.ops.eblu.me policy_engine_mode: all + + mealie.yaml: | + version: 1 + metadata: + name: BlumeOps Mealie SSO + labels: + blueprints.goauthentik.io/description: "Mealie OIDC provider and application" + entries: + # OAuth2 provider for Mealie (confidential — Mealie requires client_secret) + - model: authentik_providers_oauth2.oauth2provider + id: mealie-provider + identifiers: + name: Mealie + attrs: + name: Mealie + 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: confidential + client_id: mealie + client_secret: !Env AUTHENTIK_MEALIE_CLIENT_SECRET + redirect_uris: + - matching_mode: strict + url: https://meals.ops.eblu.me/login + - matching_mode: strict + url: https://meals.tail8d86e.ts.net/login + 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]] + sub_mode: hashed_user_id + include_claims_in_id_token: true + + # Mealie application — all authenticated users allowed (admin mapped via OIDC_ADMIN_GROUP) + - model: authentik_core.application + id: mealie-app + identifiers: + slug: mealie + attrs: + name: Mealie + slug: mealie + provider: !KeyOf mealie-provider + meta_launch_url: https://meals.ops.eblu.me + policy_engine_mode: all diff --git a/argocd/manifests/authentik/deployment-worker.yaml b/argocd/manifests/authentik/deployment-worker.yaml index 2b341bf..3d4fb0c 100644 --- a/argocd/manifests/authentik/deployment-worker.yaml +++ b/argocd/manifests/authentik/deployment-worker.yaml @@ -78,6 +78,11 @@ spec: secretKeyRef: name: authentik-config key: argocd-client-secret + - name: AUTHENTIK_MEALIE_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: authentik-config + key: mealie-client-secret volumeMounts: - name: blueprints mountPath: /blueprints/custom diff --git a/argocd/manifests/authentik/external-secret.yaml b/argocd/manifests/authentik/external-secret.yaml index 495eda8..fb22f2b 100644 --- a/argocd/manifests/authentik/external-secret.yaml +++ b/argocd/manifests/authentik/external-secret.yaml @@ -57,3 +57,7 @@ spec: remoteRef: key: "Authentik (blumeops)" property: argocd-client-secret + - secretKey: mealie-client-secret + remoteRef: + key: "Authentik (blumeops)" + property: mealie-client-secret diff --git a/argocd/manifests/mealie/deployment.yaml b/argocd/manifests/mealie/deployment.yaml new file mode 100644 index 0000000..4eab901 --- /dev/null +++ b/argocd/manifests/mealie/deployment.yaml @@ -0,0 +1,79 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: mealie + namespace: mealie +spec: + replicas: 1 + selector: + matchLabels: + app: mealie + template: + metadata: + labels: + app: mealie + spec: + containers: + - name: mealie + image: registry.ops.eblu.me/blumeops/mealie:kustomized + ports: + - containerPort: 9000 + env: + - name: BASE_URL + value: "https://meals.ops.eblu.me" + - name: ALLOW_SIGNUP + value: "false" + - name: TZ + value: "America/Los_Angeles" + - name: MAX_WORKERS + value: "1" + - name: WEB_CONCURRENCY + value: "1" + # OIDC — Authentik (public client, PKCE) + - name: OIDC_AUTH_ENABLED + value: "true" + - name: OIDC_CONFIGURATION_URL + value: "https://authentik.ops.eblu.me/application/o/mealie/.well-known/openid-configuration" + - name: OIDC_CLIENT_ID + value: "mealie" + - name: OIDC_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: mealie-oidc + key: client-secret + - name: OIDC_AUTO_REDIRECT + value: "false" + - name: OIDC_PROVIDER_NAME + value: "Authentik" + - name: OIDC_ADMIN_GROUP + value: "admins" + - name: OIDC_SIGNUP_ENABLED + value: "true" + - name: OIDC_USER_CLAIM + value: "email" + volumeMounts: + - name: data + mountPath: /app/data + resources: + requests: + memory: "128Mi" + cpu: "50m" + limits: + memory: "1000Mi" + cpu: "500m" + livenessProbe: + httpGet: + path: /api/app/about + port: 9000 + initialDelaySeconds: 30 + periodSeconds: 30 + readinessProbe: + httpGet: + path: /api/app/about + port: 9000 + initialDelaySeconds: 10 + periodSeconds: 10 + volumes: + - name: data + persistentVolumeClaim: + claimName: mealie-data diff --git a/argocd/manifests/mealie/external-secret.yaml b/argocd/manifests/mealie/external-secret.yaml new file mode 100644 index 0000000..6a77c5d --- /dev/null +++ b/argocd/manifests/mealie/external-secret.yaml @@ -0,0 +1,19 @@ +--- +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: mealie-oidc + namespace: mealie +spec: + refreshInterval: 1h + secretStoreRef: + kind: ClusterSecretStore + name: onepassword-blumeops + target: + name: mealie-oidc + creationPolicy: Owner + data: + - secretKey: client-secret + remoteRef: + key: "Authentik (blumeops)" + property: mealie-client-secret diff --git a/argocd/manifests/mealie/ingress-tailscale.yaml b/argocd/manifests/mealie/ingress-tailscale.yaml new file mode 100644 index 0000000..a885e15 --- /dev/null +++ b/argocd/manifests/mealie/ingress-tailscale.yaml @@ -0,0 +1,25 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: mealie-tailscale + namespace: mealie + annotations: + tailscale.com/proxy-class: "default" + tailscale.com/proxy-group: "ingress" + gethomepage.dev/enabled: "true" + gethomepage.dev/name: "Mealie" + gethomepage.dev/group: "Home" + gethomepage.dev/icon: "mealie.png" + gethomepage.dev/description: "Recipe manager" + gethomepage.dev/href: "https://meals.ops.eblu.me" + gethomepage.dev/pod-selector: "app=mealie" +spec: + ingressClassName: tailscale + defaultBackend: + service: + name: mealie + port: + number: 9000 + tls: + - hosts: + - meals diff --git a/argocd/manifests/mealie/kustomization.yaml b/argocd/manifests/mealie/kustomization.yaml new file mode 100644 index 0000000..0d1ee04 --- /dev/null +++ b/argocd/manifests/mealie/kustomization.yaml @@ -0,0 +1,15 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: mealie + +resources: + - deployment.yaml + - service.yaml + - pvc.yaml + - ingress-tailscale.yaml + - external-secret.yaml + +images: + - name: registry.ops.eblu.me/blumeops/mealie + newTag: v3.12.0-5c5fd18 diff --git a/argocd/manifests/mealie/pvc.yaml b/argocd/manifests/mealie/pvc.yaml new file mode 100644 index 0000000..f473e07 --- /dev/null +++ b/argocd/manifests/mealie/pvc.yaml @@ -0,0 +1,13 @@ +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: mealie-data + namespace: mealie +spec: + accessModes: + - ReadWriteOnce + storageClassName: standard + resources: + requests: + storage: 2Gi diff --git a/argocd/manifests/mealie/service.yaml b/argocd/manifests/mealie/service.yaml new file mode 100644 index 0000000..4162b96 --- /dev/null +++ b/argocd/manifests/mealie/service.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Service +metadata: + name: mealie + namespace: mealie +spec: + selector: + app: mealie + ports: + - name: http + port: 9000 + targetPort: 9000 + protocol: TCP diff --git a/containers/mealie/Dockerfile b/containers/mealie/Dockerfile new file mode 100644 index 0000000..fe1bf02 --- /dev/null +++ b/containers/mealie/Dockerfile @@ -0,0 +1,142 @@ +# 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 + +LABEL org.opencontainers.image.title="Mealie" +LABEL org.opencontainers.image.description="Self-hosted recipe manager" +LABEL org.opencontainers.image.source="https://github.com/mealie-recipes/mealie" + +ENTRYPOINT ["/app/run.sh"] diff --git a/docs/changelog.d/deploy-mealie.feature.md b/docs/changelog.d/deploy-mealie.feature.md new file mode 100644 index 0000000..5b85426 --- /dev/null +++ b/docs/changelog.d/deploy-mealie.feature.md @@ -0,0 +1 @@ +Deploy Mealie recipe manager on minikube-indri for meal planning and prep automation. diff --git a/docs/explanation/federated-login.md b/docs/explanation/federated-login.md index 8accad0..e576d9f 100644 --- a/docs/explanation/federated-login.md +++ b/docs/explanation/federated-login.md @@ -76,11 +76,12 @@ Authentik enforces TOTP MFA on its default authentication flow (`not_configured_ ## Future Work -- **Additional services:** ArgoCD, Miniflux, Immich +- **Additional services:** Miniflux, Immich ## Related - [[authentik]] - OIDC identity provider reference - [[grafana]] - First OIDC client +- [[mealie]] - Recipe manager (public PKCE client) - [[security-model]] - Network security and access control - [[deploy-authentik]] - Deployment how-to diff --git a/docs/reference/infrastructure/routing.md b/docs/reference/infrastructure/routing.md index 91457e9..c85dbb5 100644 --- a/docs/reference/infrastructure/routing.md +++ b/docs/reference/infrastructure/routing.md @@ -40,6 +40,7 @@ DNS points to [[indri]]'s Tailscale IP. TLS via Let's Encrypt (ACME DNS-01 with | [[navidrome]] | https://dj.ops.eblu.me | Music streaming | | [[jellyfin]] | https://jellyfin.ops.eblu.me | Media server | | [[postgresql]] | pg.ops.eblu.me:5432 | Database | +| [[mealie]] | https://meals.ops.eblu.me | Recipe manager | | [[sifaka|Sifaka]] | https://nas.ops.eblu.me | NAS dashboard | ## Public Services (`*.eblu.me`) diff --git a/docs/reference/kubernetes/apps.md b/docs/reference/kubernetes/apps.md index f584d5a..270cc55 100644 --- a/docs/reference/kubernetes/apps.md +++ b/docs/reference/kubernetes/apps.md @@ -39,6 +39,7 @@ Registry of all applications deployed via [[argocd]]. | `cv` | cv | `argocd/manifests/cv/` | [[cv]] | | `forgejo-runner` | forgejo-runner | `argocd/manifests/forgejo-runner/` | [[forgejo]] CI | | `ollama` | ollama | `argocd/manifests/ollama/` | [[ollama]] | +| `mealie` | mealie | `argocd/manifests/mealie/` | [[mealie]] | ## Sync Policies diff --git a/docs/reference/services/borgmatic.md b/docs/reference/services/borgmatic.md index 05c851e..1020327 100644 --- a/docs/reference/services/borgmatic.md +++ b/docs/reference/services/borgmatic.md @@ -1,6 +1,6 @@ --- title: Borgmatic -modified: 2026-02-10 +modified: 2026-03-16 tags: - service - backup @@ -26,11 +26,15 @@ Daily backup system using Borg backup, running on indri. - `/opt/homebrew/var/forgejo` - Git forge data - `~/.config/borgmatic` - Borgmatic config - `~/Documents` - Personal documents +- `~/.local/share/borgmatic/k8s-dumps/` - SQLite dumps from k8s pods -**Databases:** +**PostgreSQL databases:** - `miniflux` on [[postgresql]] - `teslamate` on [[postgresql]] +**K8s SQLite databases (pre-backup dump via kubectl exec):** +- [[mealie]] - Recipe manager (`/app/data/mealie.db`) + **Not backed up (by design):** - ZIM archives (re-downloadable) - Prometheus metrics (ephemeral) diff --git a/docs/reference/services/mealie.md b/docs/reference/services/mealie.md new file mode 100644 index 0000000..f309a5e --- /dev/null +++ b/docs/reference/services/mealie.md @@ -0,0 +1,61 @@ +--- +title: Mealie +modified: 2026-03-16 +tags: + - service + - recipes +--- + +# Mealie + +Self-hosted recipe manager with a REST API. Part of the meal planning pipeline: Mealie stores categorized recipes, a planner script selects balanced meals, and [[ollama]] generates a unified cooking timeline. + +## Quick Reference + +| Property | Value | +|----------|-------| +| **URL** | https://meals.ops.eblu.me | +| **Tailscale URL** | https://meals.tail8d86e.ts.net | +| **Namespace** | `mealie` | +| **Image** | `registry.ops.eblu.me/blumeops/mealie` (built from source) | +| **Database** | SQLite (local, at `/app/data/`) | +| **API Docs** | https://meals.ops.eblu.me/docs | +| **Upstream** | https://github.com/mealie-recipes/mealie | +| **Manifests** | `argocd/manifests/mealie/` | + +## Features + +- Full REST API (FastAPI) for recipe CRUD, filtering by tag/category +- Structured recipe data: ingredients (quantity/unit/food), step-by-step instructions +- Built-in meal planning and shopping lists +- Recipe import from URLs +- API token auth for automation +- OIDC login via [[authentik]] (confidential client) + +## Authentication + +OIDC via [[authentik]] using a confidential client. Client secret stored in 1Password (`Authentik (blumeops)` / `mealie-client-secret`) and delivered via ExternalSecret. All Authentik users can log in; members of the `admins` group get Mealie admin privileges via `OIDC_ADMIN_GROUP`. + +## Storage + +- 2Gi PVC at `/app/data/` via `standard` storageClassName (minikube-hostpath) +- SQLite database (sufficient for single-user) +- Recipe images and assets stored alongside the database + +## Backup + +SQLite database backed up via [[borgmatic]]'s `before_backup` hook. Borgmatic runs `kubectl exec` to create a safe `.backup` copy (via Python's `sqlite3` module), then `kubectl cp` to the host. The dump lands in `~/.local/share/borgmatic/k8s-dumps/mealie.db` and is included in both local (sifaka) and offsite (BorgBase) backups. + +## Networking + +| Endpoint | Reachable from | +|----------|----------------| +| `https://meals.ops.eblu.me` | Tailnet clients (via Caddy) | +| `https://meals.tail8d86e.ts.net` | Tailnet clients | +| `http://mealie.mealie.svc.cluster.local:9000` | In-cluster | + +## Related + +- [[authentik]] — OIDC identity provider +- [[ollama]] — LLM backend for meal timeline generation +- [[borgmatic]] — Data backup diff --git a/service-versions.yaml b/service-versions.yaml index 686a529..7877eda 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -253,6 +253,13 @@ services: upstream-source: https://code.forgejo.org/forgejo/runner/releases notes: Forgejo runner on ringtail via nixpkgs; version tracks flake.lock + - name: mealie + type: argocd + last-reviewed: 2026-03-16 + current-version: "v3.12.0" + upstream-source: https://github.com/mealie-recipes/mealie/releases + notes: Recipe manager; built from source via forge mirror + - name: unpoller type: argocd last-reviewed: 2026-03-16 From c2a1e168bd4ddc1bfebdef0ab19ef161a49013b8 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 16 Mar 2026 21:59:48 -0700 Subject: [PATCH 055/430] Update Mealie container tag to main build Co-Authored-By: Claude Opus 4.6 (1M context) --- argocd/manifests/mealie/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/argocd/manifests/mealie/kustomization.yaml b/argocd/manifests/mealie/kustomization.yaml index 0d1ee04..40dd4f2 100644 --- a/argocd/manifests/mealie/kustomization.yaml +++ b/argocd/manifests/mealie/kustomization.yaml @@ -12,4 +12,4 @@ resources: images: - name: registry.ops.eblu.me/blumeops/mealie - newTag: v3.12.0-5c5fd18 + newTag: v3.12.0-11330eb From 3602ed778162acd1f1bf206a38e5e3599090f4d5 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Tue, 17 Mar 2026 07:12:51 -0700 Subject: [PATCH 056/430] Add OpenAI integration to Mealie Enable recipe parsing from images/photos, ingredient extraction, and URL scraping via OpenAI API (gpt-4o). Rename ExternalSecret from mealie-oidc to mealie-secrets to hold both OIDC and OpenAI credentials. Co-Authored-By: Claude Opus 4.6 (1M context) --- argocd/manifests/mealie/deployment.yaml | 16 ++++++++++++++-- argocd/manifests/mealie/external-secret.yaml | 10 +++++++--- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/argocd/manifests/mealie/deployment.yaml b/argocd/manifests/mealie/deployment.yaml index 4eab901..5c522fe 100644 --- a/argocd/manifests/mealie/deployment.yaml +++ b/argocd/manifests/mealie/deployment.yaml @@ -39,8 +39,8 @@ spec: - name: OIDC_CLIENT_SECRET valueFrom: secretKeyRef: - name: mealie-oidc - key: client-secret + name: mealie-secrets + key: oidc-client-secret - name: OIDC_AUTO_REDIRECT value: "false" - name: OIDC_PROVIDER_NAME @@ -51,6 +51,18 @@ spec: value: "true" - name: OIDC_USER_CLAIM value: "email" + # OpenAI — recipe parsing, image OCR, ingredient extraction + - name: OPENAI_API_KEY + valueFrom: + secretKeyRef: + name: mealie-secrets + key: openai-api-key + - name: OPENAI_MODEL + value: "gpt-4o" + - name: OPENAI_REQUEST_TIMEOUT + value: "120" + - name: OPENAI_WORKERS + value: "1" volumeMounts: - name: data mountPath: /app/data diff --git a/argocd/manifests/mealie/external-secret.yaml b/argocd/manifests/mealie/external-secret.yaml index 6a77c5d..99c2793 100644 --- a/argocd/manifests/mealie/external-secret.yaml +++ b/argocd/manifests/mealie/external-secret.yaml @@ -2,7 +2,7 @@ apiVersion: external-secrets.io/v1 kind: ExternalSecret metadata: - name: mealie-oidc + name: mealie-secrets namespace: mealie spec: refreshInterval: 1h @@ -10,10 +10,14 @@ spec: kind: ClusterSecretStore name: onepassword-blumeops target: - name: mealie-oidc + name: mealie-secrets creationPolicy: Owner data: - - secretKey: client-secret + - secretKey: oidc-client-secret remoteRef: key: "Authentik (blumeops)" property: mealie-client-secret + - secretKey: openai-api-key + remoteRef: + key: "openai (blumeops)" + property: credential From 6d7597670e1434267c26c5fc8f7ca9bacfb12aea Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Tue, 17 Mar 2026 11:07:16 -0700 Subject: [PATCH 057/430] Add plan-a-meal how-to for Mealie cooking timelines Agent-facing guide for generating unified cooking timelines from Mealie meal plans. Covers querying the API, picking balanced meals (protein/carb/vegetable), and interleaving recipe steps into a relative timeline so everything finishes together. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/how-to/mealie/plan-a-meal.md | 134 ++++++++++++++++++++++++++++++ docs/reference/services/mealie.md | 1 + 2 files changed, 135 insertions(+) create mode 100644 docs/how-to/mealie/plan-a-meal.md diff --git a/docs/how-to/mealie/plan-a-meal.md b/docs/how-to/mealie/plan-a-meal.md new file mode 100644 index 0000000..c21a861 --- /dev/null +++ b/docs/how-to/mealie/plan-a-meal.md @@ -0,0 +1,134 @@ +--- +title: Plan a Meal +modified: 2026-03-17 +tags: + - how-to + - mealie +--- + +# Plan a Meal + +Generate a unified cooking timeline for a meal using [[mealie]]'s API. The timeline interleaves steps from multiple recipes so everything finishes at the same time. + +## When to Use + +The user says something like "Let's plan a meal" or references this card. Default to **dinner for today** unless the user specifies otherwise. + +## Prerequisites + +- Mealie running at `https://meals.ops.eblu.me` +- API token in 1Password: `op://blumeops/mealie/credential` + +## Process + +### 1. Check the Meal Planner + +Query today's (or the requested date's) meal plan: + +```fish +set MEALIE_TOKEN (op read "op://blumeops/mealie/credential") +set DATE (date +%Y-%m-%d) # or the requested date +curl -sf "https://meals.ops.eblu.me/api/households/mealplans?start_date=$DATE&end_date=$DATE" \ + -H "Authorization: Bearer $MEALIE_TOKEN" +``` + +**If the plan has recipes:** the user wants a cooking timeline for those dishes. Skip to step 3. + +**If the plan is empty (or user asked for a new meal):** pick recipes in step 2. + +### 2. Pick a Balanced Meal + +Select one recipe from each tag category to build a balanced dinner: + +- **protein** — a main dish (chicken, tofu, meatloaf, etc.) +- **carb** — a starch side (potatoes, noodles, bread, rice) +- **vegetable** — a veggie side (salad, roasted veg, brussels sprouts) + +Query by tag: + +```fish +# Get a random protein +curl -sf "https://meals.ops.eblu.me/api/recipes?tags=protein&orderBy=random&perPage=1" \ + -H "Authorization: Bearer $MEALIE_TOKEN" + +# Get a random carb +curl -sf "https://meals.ops.eblu.me/api/recipes?tags=carb&orderBy=random&perPage=1" \ + -H "Authorization: Bearer $MEALIE_TOKEN" + +# Get a random vegetable +curl -sf "https://meals.ops.eblu.me/api/recipes?tags=vegetable&orderBy=random&perPage=1" \ + -H "Authorization: Bearer $MEALIE_TOKEN" +``` + +Many recipes are multi-tagged (e.g., "Spicy Chicken Meal Prep with Rice and Beans" has `protein`, `carb`, and `beans`). If the protein pick already covers carb or vegetable, skip that category or offer a lighter side instead. The protein+carb+vegetable split is a rule of thumb for balance, not a rigid requirement — a one-pot meal with all three doesn't need two more sides. + +Present the picks to the user and let them swap any out. Once confirmed, optionally add to the meal plan via the API. + +### 3. Fetch Full Recipe Data + +For each recipe in the meal, fetch the full details (ingredients + instructions + timing): + +```fish +curl -sf "https://meals.ops.eblu.me/api/recipes/$SLUG" \ + -H "Authorization: Bearer $MEALIE_TOKEN" +``` + +Key fields: `recipeIngredient` (structured ingredients), `recipeInstructions` (ordered steps), `prepTime`, `totalTime`, `performTime`. + +### 4. Generate the Cooking Timeline + +Using the full recipe data, create a unified timeline that interleaves steps from all dishes. The timeline should use **relative time**: + +- **T-X** — mise en place, gathering ingredients, prep that happens before active cooking +- **T=0** — the first active cooking step (usually preheating or starting the longest-cook item) +- **T+X** — cooking steps, with concurrent tasks noted + +**Guidelines for the timeline:** + +- Start with the dish that takes longest (usually the protein) +- Identify natural wait times (oven time, boiling, simmering) and fill them with prep/cooking of other dishes +- Call out the "busy moments" where multiple things need attention +- End with a **mise en place checklist** — everything to gather before T=0 +- Use minutes, not clock times (the user decides when to start) + +**Example format:** + +``` +## Dinner Timeline: Turkey Meatloaf + Mashed Potatoes + Roasted Broccoli + +### Mise en Place (gather before you start) +- Ground turkey, egg, breadcrumbs, ketchup, ... +- Russet potatoes, butter, milk, ... +- Broccoli, olive oil, ... + +### Timeline +| Time | Action | +|------|--------| +| T-10 | Preheat oven to 350°F | +| T-5 | Meatloaf: sauté onion, mix ingredients | +| T=0 | Meatloaf goes in the oven (55 min) | +| T=0 | Potatoes: peel, dice, rinse, start boiling | +| T+15 | Potatoes: should be boiling, cook 6-7 min | +| T+22 | Potatoes: drain, mash with butter/milk. Cover and set aside | +| T+25 | Broccoli: prep florets, toss with oil on sheet pan | +| T+55 | Meatloaf out. Rest 5 min. Crank oven to 400°F | +| T+55 | Broccoli goes in (15 min at 400°F) | +| T+60 | Slice meatloaf | +| T+70 | Broccoli out. Plate everything. Dinner is served. | + +### Busy moments +- Around T+20-25: draining potatoes, mashing, and prepping broccoli overlap +``` + +## Notes + +- The user's wife currently handles breakfast and lunch, so default to dinner unless asked otherwise +- Recipes are tagged with `protein`, `carb`, `vegetable`, and `beans` for meal composition +- Recipes are categorized as `Dinner` or `Side` for the built-in Mealie meal planner +- Mealie API docs are at `https://meals.ops.eblu.me/docs` +- Meal plan rules are configured so the random button in Mealie's UI picks from the correct categories + +## Related + +- [[mealie]] — Recipe manager service reference +- [[ollama]] — LLM backend (future: automated timeline generation) diff --git a/docs/reference/services/mealie.md b/docs/reference/services/mealie.md index f309a5e..c658046 100644 --- a/docs/reference/services/mealie.md +++ b/docs/reference/services/mealie.md @@ -56,6 +56,7 @@ SQLite database backed up via [[borgmatic]]'s `before_backup` hook. Borgmatic ru ## Related +- [[plan-a-meal]] — Generate unified cooking timelines from meal plans - [[authentik]] — OIDC identity provider - [[ollama]] — LLM backend for meal timeline generation - [[borgmatic]] — Data backup From e5ce510fdcefa28acfdd4c2fe51961a3d45c76c3 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Tue, 17 Mar 2026 11:10:48 -0700 Subject: [PATCH 058/430] Fix plan-a-meal random recipe API queries Mealie's orderBy=random requires a paginationSeed parameter, otherwise the API returns 422. Added the seed to all random query examples. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/changelog.d/+fix-plan-a-meal-random-query.bugfix.md | 1 + docs/how-to/mealie/plan-a-meal.md | 8 +++++--- 2 files changed, 6 insertions(+), 3 deletions(-) create mode 100644 docs/changelog.d/+fix-plan-a-meal-random-query.bugfix.md diff --git a/docs/changelog.d/+fix-plan-a-meal-random-query.bugfix.md b/docs/changelog.d/+fix-plan-a-meal-random-query.bugfix.md new file mode 100644 index 0000000..179a9fe --- /dev/null +++ b/docs/changelog.d/+fix-plan-a-meal-random-query.bugfix.md @@ -0,0 +1 @@ +Fix plan-a-meal random recipe queries — add required `paginationSeed` parameter diff --git a/docs/how-to/mealie/plan-a-meal.md b/docs/how-to/mealie/plan-a-meal.md index c21a861..1e6eb48 100644 --- a/docs/how-to/mealie/plan-a-meal.md +++ b/docs/how-to/mealie/plan-a-meal.md @@ -47,16 +47,18 @@ Select one recipe from each tag category to build a balanced dinner: Query by tag: ```fish +set SEED (date +%s) + # Get a random protein -curl -sf "https://meals.ops.eblu.me/api/recipes?tags=protein&orderBy=random&perPage=1" \ +curl -sf "https://meals.ops.eblu.me/api/recipes?tags=protein&orderBy=random&paginationSeed=$SEED&perPage=1" \ -H "Authorization: Bearer $MEALIE_TOKEN" # Get a random carb -curl -sf "https://meals.ops.eblu.me/api/recipes?tags=carb&orderBy=random&perPage=1" \ +curl -sf "https://meals.ops.eblu.me/api/recipes?tags=carb&orderBy=random&paginationSeed=$SEED&perPage=1" \ -H "Authorization: Bearer $MEALIE_TOKEN" # Get a random vegetable -curl -sf "https://meals.ops.eblu.me/api/recipes?tags=vegetable&orderBy=random&perPage=1" \ +curl -sf "https://meals.ops.eblu.me/api/recipes?tags=vegetable&orderBy=random&paginationSeed=$SEED&perPage=1" \ -H "Authorization: Bearer $MEALIE_TOKEN" ``` From 995478b91fe5f5e6d6e97e7134da5e843fdf4723 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Tue, 17 Mar 2026 13:06:23 -0700 Subject: [PATCH 059/430] Review jellyfin and automounter services Both services current: jellyfin 10.11.6 (latest upstream), automounter 1.11.0 (Mac App Store). Add missing frigate share to automounter docs. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/changelog.d/+review-jellyfin-automounter.doc.md | 1 + docs/reference/services/automounter.md | 1 + service-versions.yaml | 12 ++++++------ 3 files changed, 8 insertions(+), 6 deletions(-) create mode 100644 docs/changelog.d/+review-jellyfin-automounter.doc.md diff --git a/docs/changelog.d/+review-jellyfin-automounter.doc.md b/docs/changelog.d/+review-jellyfin-automounter.doc.md new file mode 100644 index 0000000..ce704f8 --- /dev/null +++ b/docs/changelog.d/+review-jellyfin-automounter.doc.md @@ -0,0 +1 @@ +Review jellyfin (10.11.6, current) and automounter (1.11.0) services; add missing frigate share to automounter docs. diff --git a/docs/reference/services/automounter.md b/docs/reference/services/automounter.md index 12d53a9..c8205ea 100644 --- a/docs/reference/services/automounter.md +++ b/docs/reference/services/automounter.md @@ -28,6 +28,7 @@ macOS app that automatically mounts [[sifaka]] SMB shares on [[indri]]. | music | `/Volumes/music` | [[navidrome]] | | allisonflix | `/Volumes/allisonflix` | [[jellyfin]] | | photos | `/Volumes/photos` | [[immich]] | +| frigate | `/Volumes/frigate` | [[frigate]] | ## Why AutoMounter? diff --git a/service-versions.yaml b/service-versions.yaml index 7877eda..9a19eaf 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -304,13 +304,13 @@ services: - name: jellyfin type: ansible - last-reviewed: null - current-version: null + last-reviewed: 2026-03-17 + current-version: "10.11.6" upstream-source: https://github.com/jellyfin/jellyfin/releases - name: automounter type: ansible - last-reviewed: null - current-version: null - upstream-source: null - notes: Custom service, no upstream + last-reviewed: 2026-03-17 + current-version: "1.11.0" + upstream-source: https://www.pixeleyes.co.nz/automounter/ + notes: Mac App Store app, no Ansible role. Updates via App Store. From 1f000c8e396056f4ba752bff2cb03b8da8676453 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Tue, 17 Mar 2026 13:22:01 -0700 Subject: [PATCH 060/430] Add last-updated subsort to docs-review, review gilbert card Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/changelog.d/+docs-review-subsort.doc.md | 1 + docs/reference/infrastructure/gilbert.md | 1 + mise-tasks/docs-review | 48 ++++++++++++++++---- 3 files changed, 40 insertions(+), 10 deletions(-) create mode 100644 docs/changelog.d/+docs-review-subsort.doc.md diff --git a/docs/changelog.d/+docs-review-subsort.doc.md b/docs/changelog.d/+docs-review-subsort.doc.md new file mode 100644 index 0000000..e6db6e7 --- /dev/null +++ b/docs/changelog.d/+docs-review-subsort.doc.md @@ -0,0 +1 @@ +Add git last-modified subsort to docs-review script, so ties in review date are broken by least recently updated first. diff --git a/docs/reference/infrastructure/gilbert.md b/docs/reference/infrastructure/gilbert.md index 74804b9..e4ef584 100644 --- a/docs/reference/infrastructure/gilbert.md +++ b/docs/reference/infrastructure/gilbert.md @@ -1,6 +1,7 @@ --- title: Gilbert modified: 2026-02-07 +last-reviewed: 2026-03-17 tags: - infrastructure - host diff --git a/mise-tasks/docs-review b/mise-tasks/docs-review index d2aee76..e353b30 100755 --- a/mise-tasks/docs-review +++ b/mise-tasks/docs-review @@ -8,7 +8,8 @@ """Review the most stale documentation card by last-reviewed date. Scans all markdown files in docs/ (excluding changelog.d/) and sorts them -by the ``last-reviewed`` frontmatter field. Docs without the field are +by the ``last-reviewed`` frontmatter field, with git last-modified date as +a tiebreaker (least recently updated first). Docs without the field are treated as never-reviewed and float to the top. Displays a staleness table and then shows the most stale doc with a review checklist. @@ -19,8 +20,9 @@ After reviewing, update the card's frontmatter: Usage: mise run docs-review [-- --limit 10] """ +import subprocess import sys -from datetime import date +from datetime import date, datetime, timezone from pathlib import Path from typing import Annotated @@ -64,13 +66,30 @@ def get_last_reviewed(frontmatter: dict) -> date | None: return None +def git_last_modified(file_path: Path) -> datetime | None: + """Get the last git commit date for a file.""" + try: + result = subprocess.run( + ["git", "log", "-1", "--format=%aI", "--", str(file_path)], + capture_output=True, + text=True, + check=True, + ) + date_str = result.stdout.strip() + if not date_str: + return None + return datetime.fromisoformat(date_str) + except subprocess.CalledProcessError: + return None + + def main( limit: Annotated[int, typer.Option(help="Number of docs to show in the table")] = 15, ) -> None: console = Console() today = date.today() - entries: list[tuple[str, date | None, Path]] = [] + entries: list[tuple[str, date | None, datetime | None, Path]] = [] for md_file in sorted(DOCS_DIR.rglob("*.md")): if "changelog.d" in md_file.parts: @@ -78,11 +97,17 @@ def main( frontmatter = extract_frontmatter(md_file) last_reviewed = get_last_reviewed(frontmatter) if frontmatter else None + last_updated = git_last_modified(md_file) rel_path = str(md_file.relative_to(DOCS_DIR)) - entries.append((rel_path, last_reviewed, md_file)) + entries.append((rel_path, last_reviewed, last_updated, md_file)) - # Sort: never-reviewed first (None), then oldest reviewed - entries.sort(key=lambda e: (e[1] is not None, e[1] or date.min)) + # Sort: never-reviewed first (None), then oldest reviewed, + # then least recently updated as tiebreaker + entries.sort(key=lambda e: ( + e[1] is not None, + e[1] or date.min, + e[2] or datetime.min.replace(tzinfo=timezone.utc), + )) never_reviewed = sum(1 for e in entries if e[1] is None) @@ -101,14 +126,17 @@ def main( table.add_column("File") table.add_column("Last Reviewed", justify="right") table.add_column("Age (days)", justify="right") + table.add_column("Last Updated", justify="right") - for i, (rel_path, last_reviewed, _) in enumerate(entries[:limit], 1): + for i, (rel_path, last_reviewed, last_updated, _) in enumerate(entries[:limit], 1): + updated_str = last_updated.strftime("%Y-%m-%d") if last_updated else "?" if last_reviewed is None: table.add_row( str(i), f"[red]{rel_path}[/red]", "[red]never[/red]", "[red]—[/red]", + updated_str, ) else: age = (today - last_reviewed).days @@ -116,11 +144,11 @@ def main( age_str = f"[{style}]{age}[/{style}]" if style else str(age) path_str = f"[{style}]{rel_path}[/{style}]" if style else rel_path date_str = f"[{style}]{last_reviewed}[/{style}]" if style else str(last_reviewed) - table.add_row(str(i), path_str, date_str, age_str) + table.add_row(str(i), path_str, date_str, age_str, updated_str) remaining = len(entries) - limit if remaining > 0: - table.add_row("", f"[dim]… {remaining} more[/dim]", "", "") + table.add_row("", f"[dim]… {remaining} more[/dim]", "", "", "") console.print(table) console.print() @@ -130,7 +158,7 @@ def main( console.print("[bold red]No documentation files found![/bold red]") raise typer.Exit(code=1) - top_path, top_reviewed, top_file = entries[0] + top_path, top_reviewed, _, top_file = entries[0] console.print(Panel( f"[bold cyan]{top_path}[/bold cyan]\n" + (f"[dim]Last reviewed: {top_reviewed}[/dim]" if top_reviewed else "[dim red]Never reviewed[/dim red]"), From cdba9dca966d08530f755e57d1061f0dafb2d35b Mon Sep 17 00:00:00 2001 From: Forgejo Actions Date: Tue, 17 Mar 2026 13:24:13 -0700 Subject: [PATCH 061/430] Update docs release to v1.14.2 - Built changelog from towncrier fragments [skip ci] --- CHANGELOG.md | 25 +++++++++++++++++++ argocd/manifests/docs/deployment.yaml | 2 +- .../+caddy-v2.11-host-header.bugfix.md | 1 - docs/changelog.d/+docs-review-subsort.doc.md | 1 - .../+fix-plan-a-meal-random-query.bugfix.md | 1 - .../+review-jellyfin-automounter.doc.md | 1 - docs/changelog.d/deploy-mealie.feature.md | 1 - ...ternalize-tailscale-operator-base.infra.md | 1 - .../externalize-teslamate-dashboards.infra.md | 1 - .../feature-caddy-upgrade-v2.11.2.infra.md | 1 - docs/changelog.d/feature-unpoller.feature.md | 1 - .../upgrade-borgmatic-2.1.3.infra.md | 1 - 12 files changed, 26 insertions(+), 11 deletions(-) delete mode 100644 docs/changelog.d/+caddy-v2.11-host-header.bugfix.md delete mode 100644 docs/changelog.d/+docs-review-subsort.doc.md delete mode 100644 docs/changelog.d/+fix-plan-a-meal-random-query.bugfix.md delete mode 100644 docs/changelog.d/+review-jellyfin-automounter.doc.md delete mode 100644 docs/changelog.d/deploy-mealie.feature.md delete mode 100644 docs/changelog.d/externalize-tailscale-operator-base.infra.md delete mode 100644 docs/changelog.d/externalize-teslamate-dashboards.infra.md delete mode 100644 docs/changelog.d/feature-caddy-upgrade-v2.11.2.infra.md delete mode 100644 docs/changelog.d/feature-unpoller.feature.md delete mode 100644 docs/changelog.d/upgrade-borgmatic-2.1.3.infra.md diff --git a/CHANGELOG.md b/CHANGELOG.md index d7501c0..b7d2da4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,31 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [v1.14.2] - 2026-03-17 + +### Features + +- Deploy Mealie recipe manager on minikube-indri for meal planning and prep automation. +- Add UnPoller deployment to monitor UniFi network metrics via Prometheus + +### Bug Fixes + +- Fix Caddy v2.11 breaking change: preserve original Host header for HTTPS upstreams. +- Fix plan-a-meal random recipe queries — add required `paginationSeed` parameter + +### Infrastructure + +- Externalize Tailscale operator manifest to forge mirror, removing 495 KB vendored file from the repo. +- Externalize TeslaMate Grafana dashboards to forge mirror, removing 713 KB of ConfigMaps from the repo. +- Upgrade Caddy from v2.10.2 to v2.11.2 (7 CVE fixes), create caddy-l4 forge mirror, migrate all ~/code/3rd clones on indri to HTTPS forge.ops.eblu.me remotes. +- Upgrade borgmatic from 2.0.13 to 2.1.3 on indri (improved borg warning handling, memory/performance improvements) + +### Documentation + +- Add git last-modified subsort to docs-review script, so ties in review date are broken by least recently updated first. +- Review jellyfin (10.11.6, current) and automounter (1.11.0) services; add missing frigate share to automounter docs. + + ## [v1.14.1] - 2026-03-14 ### Features diff --git a/argocd/manifests/docs/deployment.yaml b/argocd/manifests/docs/deployment.yaml index a38061d..4aca950 100644 --- a/argocd/manifests/docs/deployment.yaml +++ b/argocd/manifests/docs/deployment.yaml @@ -27,7 +27,7 @@ spec: name: http env: - name: DOCS_RELEASE_URL - value: "https://forge.eblu.me/eblume/blumeops/releases/download/v1.14.1/docs-v1.14.1.tar.gz" + value: "https://forge.eblu.me/eblume/blumeops/releases/download/v1.14.2/docs-v1.14.2.tar.gz" resources: requests: memory: "64Mi" diff --git a/docs/changelog.d/+caddy-v2.11-host-header.bugfix.md b/docs/changelog.d/+caddy-v2.11-host-header.bugfix.md deleted file mode 100644 index a300bd3..0000000 --- a/docs/changelog.d/+caddy-v2.11-host-header.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -Fix Caddy v2.11 breaking change: preserve original Host header for HTTPS upstreams. diff --git a/docs/changelog.d/+docs-review-subsort.doc.md b/docs/changelog.d/+docs-review-subsort.doc.md deleted file mode 100644 index e6db6e7..0000000 --- a/docs/changelog.d/+docs-review-subsort.doc.md +++ /dev/null @@ -1 +0,0 @@ -Add git last-modified subsort to docs-review script, so ties in review date are broken by least recently updated first. diff --git a/docs/changelog.d/+fix-plan-a-meal-random-query.bugfix.md b/docs/changelog.d/+fix-plan-a-meal-random-query.bugfix.md deleted file mode 100644 index 179a9fe..0000000 --- a/docs/changelog.d/+fix-plan-a-meal-random-query.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -Fix plan-a-meal random recipe queries — add required `paginationSeed` parameter diff --git a/docs/changelog.d/+review-jellyfin-automounter.doc.md b/docs/changelog.d/+review-jellyfin-automounter.doc.md deleted file mode 100644 index ce704f8..0000000 --- a/docs/changelog.d/+review-jellyfin-automounter.doc.md +++ /dev/null @@ -1 +0,0 @@ -Review jellyfin (10.11.6, current) and automounter (1.11.0) services; add missing frigate share to automounter docs. diff --git a/docs/changelog.d/deploy-mealie.feature.md b/docs/changelog.d/deploy-mealie.feature.md deleted file mode 100644 index 5b85426..0000000 --- a/docs/changelog.d/deploy-mealie.feature.md +++ /dev/null @@ -1 +0,0 @@ -Deploy Mealie recipe manager on minikube-indri for meal planning and prep automation. diff --git a/docs/changelog.d/externalize-tailscale-operator-base.infra.md b/docs/changelog.d/externalize-tailscale-operator-base.infra.md deleted file mode 100644 index 1ecd7da..0000000 --- a/docs/changelog.d/externalize-tailscale-operator-base.infra.md +++ /dev/null @@ -1 +0,0 @@ -Externalize Tailscale operator manifest to forge mirror, removing 495 KB vendored file from the repo. diff --git a/docs/changelog.d/externalize-teslamate-dashboards.infra.md b/docs/changelog.d/externalize-teslamate-dashboards.infra.md deleted file mode 100644 index 684ce74..0000000 --- a/docs/changelog.d/externalize-teslamate-dashboards.infra.md +++ /dev/null @@ -1 +0,0 @@ -Externalize TeslaMate Grafana dashboards to forge mirror, removing 713 KB of ConfigMaps from the repo. diff --git a/docs/changelog.d/feature-caddy-upgrade-v2.11.2.infra.md b/docs/changelog.d/feature-caddy-upgrade-v2.11.2.infra.md deleted file mode 100644 index f0f213f..0000000 --- a/docs/changelog.d/feature-caddy-upgrade-v2.11.2.infra.md +++ /dev/null @@ -1 +0,0 @@ -Upgrade Caddy from v2.10.2 to v2.11.2 (7 CVE fixes), create caddy-l4 forge mirror, migrate all ~/code/3rd clones on indri to HTTPS forge.ops.eblu.me remotes. diff --git a/docs/changelog.d/feature-unpoller.feature.md b/docs/changelog.d/feature-unpoller.feature.md deleted file mode 100644 index 848cbbc..0000000 --- a/docs/changelog.d/feature-unpoller.feature.md +++ /dev/null @@ -1 +0,0 @@ -Add UnPoller deployment to monitor UniFi network metrics via Prometheus diff --git a/docs/changelog.d/upgrade-borgmatic-2.1.3.infra.md b/docs/changelog.d/upgrade-borgmatic-2.1.3.infra.md deleted file mode 100644 index 74542e4..0000000 --- a/docs/changelog.d/upgrade-borgmatic-2.1.3.infra.md +++ /dev/null @@ -1 +0,0 @@ -Upgrade borgmatic from 2.0.13 to 2.1.3 on indri (improved borg warning handling, memory/performance improvements) From 61f02a03358d518ebf58c3fbf07c4f70fcc11be9 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Tue, 17 Mar 2026 16:42:53 -0700 Subject: [PATCH 062/430] Localize Alloy container image (#300) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Add `containers/alloy/` with dual Dockerfile + Nix build files for Grafana Alloy v1.14.0 - Both builds fetch source from forge mirror (`forge.ops.eblu.me/mirrors/alloy.git`), build the web UI (Node), then compile the Go binary with `netgo embedalloyui` tags - Update all three alloy deployments (alloy-k8s, alloy-ringtail, alloy-tracing-ringtail) to use `registry.ops.eblu.me/blumeops/alloy` - `promtail_journal_enabled` tag omitted — requires systemd headers and none of our configs use `loki.source.journal` ## Build verification - **Dockerfile:** Tested locally via `docker build`, binary reports `v1.14.0` with correct tags - **Nix:** Tested on ringtail via `nix-build`, all three hashes (fetchgit, npmDeps, goModules) resolved and build succeeds ## Post-merge steps 1. Wait for CI to build the container from main (both Dockerfile and Nix workflows) 2. `mise run container-list alloy` to find the `[main]` tagged image 3. C0 follow-up to update `newTag` in all three kustomizations from `v1.14.0-placeholder` to the real tag 4. Sync ArgoCD apps and verify pods come up healthy Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/300 --- argocd/manifests/alloy-k8s/daemonset.yaml | 2 +- argocd/manifests/alloy-k8s/kustomization.yaml | 4 +- .../manifests/alloy-ringtail/daemonset.yaml | 2 +- .../alloy-ringtail/kustomization.yaml | 4 +- .../alloy-tracing-ringtail/daemonset.yaml | 2 +- .../alloy-tracing-ringtail/kustomization.yaml | 4 +- containers/alloy/Dockerfile | 65 ++++++++ containers/alloy/default.nix | 140 ++++++++++++++++++ .../feature-localize-alloy-container.infra.md | 1 + 9 files changed, 215 insertions(+), 9 deletions(-) create mode 100644 containers/alloy/Dockerfile create mode 100644 containers/alloy/default.nix create mode 100644 docs/changelog.d/feature-localize-alloy-container.infra.md diff --git a/argocd/manifests/alloy-k8s/daemonset.yaml b/argocd/manifests/alloy-k8s/daemonset.yaml index 98d69dc..60b8883 100644 --- a/argocd/manifests/alloy-k8s/daemonset.yaml +++ b/argocd/manifests/alloy-k8s/daemonset.yaml @@ -19,7 +19,7 @@ spec: fsGroup: 473 # alloy user group containers: - name: alloy - image: grafana/alloy:kustomized + image: registry.ops.eblu.me/blumeops/alloy:kustomized args: - run - --server.http.listen-addr=0.0.0.0:12345 diff --git a/argocd/manifests/alloy-k8s/kustomization.yaml b/argocd/manifests/alloy-k8s/kustomization.yaml index 6209a0b..38f0676 100644 --- a/argocd/manifests/alloy-k8s/kustomization.yaml +++ b/argocd/manifests/alloy-k8s/kustomization.yaml @@ -9,8 +9,8 @@ resources: - daemonset.yaml images: - - name: grafana/alloy - newTag: v1.14.0 + - name: registry.ops.eblu.me/blumeops/alloy + newTag: v1.14.0-placeholder configMapGenerator: - name: alloy-config diff --git a/argocd/manifests/alloy-ringtail/daemonset.yaml b/argocd/manifests/alloy-ringtail/daemonset.yaml index 6f723b8..fffd66e 100644 --- a/argocd/manifests/alloy-ringtail/daemonset.yaml +++ b/argocd/manifests/alloy-ringtail/daemonset.yaml @@ -19,7 +19,7 @@ spec: fsGroup: 473 # alloy user group containers: - name: alloy - image: grafana/alloy:kustomized + image: registry.ops.eblu.me/blumeops/alloy:kustomized args: - run - --server.http.listen-addr=0.0.0.0:12345 diff --git a/argocd/manifests/alloy-ringtail/kustomization.yaml b/argocd/manifests/alloy-ringtail/kustomization.yaml index 6209a0b..38f0676 100644 --- a/argocd/manifests/alloy-ringtail/kustomization.yaml +++ b/argocd/manifests/alloy-ringtail/kustomization.yaml @@ -9,8 +9,8 @@ resources: - daemonset.yaml images: - - name: grafana/alloy - newTag: v1.14.0 + - name: registry.ops.eblu.me/blumeops/alloy + newTag: v1.14.0-placeholder configMapGenerator: - name: alloy-config diff --git a/argocd/manifests/alloy-tracing-ringtail/daemonset.yaml b/argocd/manifests/alloy-tracing-ringtail/daemonset.yaml index 75cfea7..e56cc9d 100644 --- a/argocd/manifests/alloy-tracing-ringtail/daemonset.yaml +++ b/argocd/manifests/alloy-tracing-ringtail/daemonset.yaml @@ -18,7 +18,7 @@ spec: hostPID: true containers: - name: alloy - image: grafana/alloy:kustomized + image: registry.ops.eblu.me/blumeops/alloy:kustomized args: - run - --server.http.listen-addr=0.0.0.0:12346 diff --git a/argocd/manifests/alloy-tracing-ringtail/kustomization.yaml b/argocd/manifests/alloy-tracing-ringtail/kustomization.yaml index fec545b..fd342c3 100644 --- a/argocd/manifests/alloy-tracing-ringtail/kustomization.yaml +++ b/argocd/manifests/alloy-tracing-ringtail/kustomization.yaml @@ -8,8 +8,8 @@ resources: - daemonset.yaml images: - - name: grafana/alloy - newTag: v1.14.0 + - name: registry.ops.eblu.me/blumeops/alloy + newTag: v1.14.0-placeholder configMapGenerator: - name: alloy-tracing-config diff --git a/containers/alloy/Dockerfile b/containers/alloy/Dockerfile new file mode 100644 index 0000000..e9bae40 --- /dev/null +++ b/containers/alloy/Dockerfile @@ -0,0 +1,65 @@ +# Grafana Alloy telemetry collector +# Three-stage build: Web UI (Node), server (Go), runtime (Alpine) + +ARG CONTAINER_APP_VERSION=1.14.0 +ARG ALLOY_VERSION=v${CONTAINER_APP_VERSION} +ARG ALLOY_COMMIT=626a738319812d58ebc25ca6d71651f4925b8b18 + +FROM node:22-alpine AS ui-build + +ARG ALLOY_COMMIT +RUN apk add --no-cache git + +RUN mkdir /app && cd /app \ + && git init \ + && git remote add origin https://forge.ops.eblu.me/mirrors/alloy.git \ + && git fetch --depth 1 origin ${ALLOY_COMMIT} \ + && git checkout FETCH_HEAD + +WORKDIR /app/internal/web/ui +RUN npm ci +RUN npx tsc -b && npx vite build + +FROM golang:1.25-alpine3.22 AS build + +ARG ALLOY_VERSION +ARG ALLOY_COMMIT +RUN apk add --no-cache build-base git + +RUN mkdir /app && cd /app \ + && git init \ + && git remote add origin https://forge.ops.eblu.me/mirrors/alloy.git \ + && git fetch --depth 1 origin ${ALLOY_COMMIT} \ + && git checkout FETCH_HEAD + +WORKDIR /app + +# Copy pre-built web UI assets +COPY --from=ui-build /app/internal/web/ui/dist /app/internal/web/ui/dist + +ENV CGO_ENABLED=1 + +# promtail_journal_enabled omitted: requires systemd headers (libsystemd-dev) +# and our k8s deployments read pod logs from the filesystem, not journald +RUN RELEASE_BUILD=1 VERSION=${ALLOY_VERSION} \ + GO_TAGS="netgo embedalloyui" \ + SKIP_UI_BUILD=1 \ + make alloy + +FROM alpine:3.22 + +LABEL org.opencontainers.image.title=alloy +LABEL org.opencontainers.image.description="Grafana Alloy is an OpenTelemetry Collector distribution" +LABEL org.opencontainers.image.source=https://github.com/grafana/alloy + +RUN apk --no-cache add ca-certificates tzdata \ + && addgroup -g 473 alloy \ + && adduser -D -u 473 -G alloy alloy \ + && mkdir -p /var/lib/alloy/data \ + && chown -R alloy:alloy /var/lib/alloy + +COPY --from=build --chown=473:473 /app/build/alloy /bin/alloy + +ENTRYPOINT ["/bin/alloy"] +ENV ALLOY_DEPLOY_MODE=docker +CMD ["run", "/etc/alloy/config.alloy", "--storage.path=/var/lib/alloy/data"] diff --git a/containers/alloy/default.nix b/containers/alloy/default.nix new file mode 100644 index 0000000..f8e4966 --- /dev/null +++ b/containers/alloy/default.nix @@ -0,0 +1,140 @@ +# Nix-built Grafana Alloy telemetry collector +# Builds v1.14.0 from forge mirror with embedded web UI +# Uses stdenv + make (not buildGoModule) due to multi-module workspace +# with local replace directives (collector/ -> ../, ../syntax, ../extension) +# Built with dockerTools.buildLayeredImage for efficient layer caching +{ pkgs ? import { } }: + +let + version = "1.14.0"; + + src = pkgs.fetchgit { + url = "https://forge.ops.eblu.me/mirrors/alloy.git"; + rev = "v${version}"; + hash = "sha256-gxNz4XDE8XSl6LsP3k8DERqDdMLcmbWKfXZGGyRULkg="; + }; + + ui = pkgs.buildNpmPackage { + inherit version; + pname = "alloy-ui"; + src = "${src}/internal/web/ui"; + npmDepsHash = "sha256-GT0yisPn+3FCtWL3he0i5zPMlaWNparQDefU69G4Yis="; + + buildPhase = '' + runHook preBuild + npx tsc -b + npx vite build + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + mkdir -p $out/dist + cp -r dist/* $out/dist/ + runHook postInstall + ''; + }; + + # Pre-fetch Go modules for all three go.mod files (fixed-output derivation) + goModules = pkgs.stdenv.mkDerivation { + pname = "alloy-go-modules"; + inherit src version; + + nativeBuildInputs = with pkgs; [ go git cacert ]; + + buildPhase = '' + export GOPATH=$TMPDIR/go + export GOFLAGS=-modcacherw + # Download modules for all three go.mod files + go mod download + cd syntax && go mod download && cd .. + cd collector && go mod download && cd .. + ''; + + installPhase = '' + cp -r $TMPDIR/go/pkg/mod $out + ''; + + outputHashMode = "recursive"; + outputHash = "sha256-rD7zqomSVv4d8NaC7jXXgihuQvK8guaAN0KrsBRWMVQ="; + outputHashAlgo = "sha256"; + }; + + alloy = pkgs.stdenv.mkDerivation { + inherit src version; + pname = "alloy"; + + nativeBuildInputs = with pkgs; [ + go + git + gnumake + cacert + ]; + + buildPhase = '' + runHook preBuild + + export HOME=$TMPDIR + export GOPATH=$TMPDIR/go + export GOFLAGS=-modcacherw + + # Populate module cache from pre-fetched modules + mkdir -p $GOPATH/pkg + cp -r ${goModules} $GOPATH/pkg/mod + chmod -R u+w $GOPATH/pkg/mod + + # Copy pre-built web UI assets + cp -r ${ui}/dist/ internal/web/ui/dist + + # Build using upstream Makefile + # promtail_journal_enabled omitted: requires systemd headers + # and our k8s deployments read pod logs from the filesystem, not journald + RELEASE_BUILD=1 \ + VERSION=v${version} \ + GO_TAGS="netgo embedalloyui" \ + SKIP_UI_BUILD=1 \ + make alloy + + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + mkdir -p $out/bin + cp build/alloy $out/bin/alloy + runHook postInstall + ''; + + meta = with pkgs.lib; { + description = "OpenTelemetry Collector distribution with programmable pipelines"; + homepage = "https://grafana.com/docs/alloy/"; + license = licenses.asl20; + mainProgram = "alloy"; + }; + }; +in + +pkgs.dockerTools.buildLayeredImage { + name = "blumeops/alloy"; + tag = "latest"; + + contents = [ + alloy + pkgs.cacert + pkgs.tzdata + ]; + + config = { + Entrypoint = [ "${alloy}/bin/alloy" ]; + Cmd = [ "run" "/etc/alloy/config.alloy" "--storage.path=/var/lib/alloy/data" ]; + Env = [ + "SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" + "TZDIR=${pkgs.tzdata}/share/zoneinfo" + "ALLOY_DEPLOY_MODE=docker" + ]; + ExposedPorts = { + "12345/tcp" = { }; + }; + User = "65534"; + }; +} diff --git a/docs/changelog.d/feature-localize-alloy-container.infra.md b/docs/changelog.d/feature-localize-alloy-container.infra.md new file mode 100644 index 0000000..42a2c21 --- /dev/null +++ b/docs/changelog.d/feature-localize-alloy-container.infra.md @@ -0,0 +1 @@ +Localize Grafana Alloy container image with dual Dockerfile + Nix builds from forge mirror From 4f99b7edaa4ba3081fd591f6ea32dc3eac546355 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Tue, 17 Mar 2026 16:55:55 -0700 Subject: [PATCH 063/430] Update alloy kustomizations to local container tags Point alloy-k8s at v1.14.0-61f02a0 (Dockerfile) and both ringtail deployments at v1.14.0-61f02a0-nix (Nix build). Co-Authored-By: Claude Opus 4.6 (1M context) --- argocd/manifests/alloy-k8s/kustomization.yaml | 2 +- argocd/manifests/alloy-ringtail/kustomization.yaml | 2 +- argocd/manifests/alloy-tracing-ringtail/kustomization.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/argocd/manifests/alloy-k8s/kustomization.yaml b/argocd/manifests/alloy-k8s/kustomization.yaml index 38f0676..885d06d 100644 --- a/argocd/manifests/alloy-k8s/kustomization.yaml +++ b/argocd/manifests/alloy-k8s/kustomization.yaml @@ -10,7 +10,7 @@ resources: images: - name: registry.ops.eblu.me/blumeops/alloy - newTag: v1.14.0-placeholder + newTag: v1.14.0-61f02a0 configMapGenerator: - name: alloy-config diff --git a/argocd/manifests/alloy-ringtail/kustomization.yaml b/argocd/manifests/alloy-ringtail/kustomization.yaml index 38f0676..0d71f55 100644 --- a/argocd/manifests/alloy-ringtail/kustomization.yaml +++ b/argocd/manifests/alloy-ringtail/kustomization.yaml @@ -10,7 +10,7 @@ resources: images: - name: registry.ops.eblu.me/blumeops/alloy - newTag: v1.14.0-placeholder + newTag: v1.14.0-61f02a0-nix configMapGenerator: - name: alloy-config diff --git a/argocd/manifests/alloy-tracing-ringtail/kustomization.yaml b/argocd/manifests/alloy-tracing-ringtail/kustomization.yaml index fd342c3..b402a18 100644 --- a/argocd/manifests/alloy-tracing-ringtail/kustomization.yaml +++ b/argocd/manifests/alloy-tracing-ringtail/kustomization.yaml @@ -9,7 +9,7 @@ resources: images: - name: registry.ops.eblu.me/blumeops/alloy - newTag: v1.14.0-placeholder + newTag: v1.14.0-61f02a0-nix configMapGenerator: - name: alloy-tracing-config From 6617e44e5b19fdff2b96cfe28e475954252d362f Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Tue, 17 Mar 2026 18:10:23 -0700 Subject: [PATCH 064/430] Fix Frigate crash: re-add required mqtt config section Frigate's config schema requires an `mqtt` field even when MQTT isn't used. Commit 40f1568 removed it along with Mosquitto, causing Frigate to fail validation on startup. Add `mqtt.enabled: false` to satisfy the schema without needing a broker. Co-Authored-By: Claude Opus 4.6 (1M context) --- argocd/manifests/frigate/frigate-config.yml | 3 +++ docs/changelog.d/+fix-frigate-mqtt-config.bugfix.md | 1 + 2 files changed, 4 insertions(+) create mode 100644 docs/changelog.d/+fix-frigate-mqtt-config.bugfix.md diff --git a/argocd/manifests/frigate/frigate-config.yml b/argocd/manifests/frigate/frigate-config.yml index 4a6def5..08d6819 100644 --- a/argocd/manifests/frigate/frigate-config.yml +++ b/argocd/manifests/frigate/frigate-config.yml @@ -1,6 +1,9 @@ database: path: /db/frigate.db +mqtt: + enabled: false + go2rtc: streams: # GableCam IP is reserved in UX7 DHCP config diff --git a/docs/changelog.d/+fix-frigate-mqtt-config.bugfix.md b/docs/changelog.d/+fix-frigate-mqtt-config.bugfix.md new file mode 100644 index 0000000..8679317 --- /dev/null +++ b/docs/changelog.d/+fix-frigate-mqtt-config.bugfix.md @@ -0,0 +1 @@ +Fix Frigate NVR crash by re-adding required `mqtt` config section (disabled) after Mosquitto removal. From cfe3391f1a16080580ae335d232f2777866fb5f8 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Tue, 17 Mar 2026 18:24:11 -0700 Subject: [PATCH 065/430] Bump Frigate retention and add recording health check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Increase retention: continuous 3→180d, detections 14→30d, alerts 30→730d. Plenty of NFS headroom (~9.4 TiB free, ~6.6 GB/day for one camera). Add frigate-recording check to services-check that verifies camera_fps > 0, which would have caught the 6-day outage from the mqtt config removal. Co-Authored-By: Claude Opus 4.6 (1M context) --- argocd/manifests/frigate/frigate-config.yml | 6 +++--- docs/changelog.d/+frigate-retention-and-check.infra.md | 1 + mise-tasks/services-check | 1 + 3 files changed, 5 insertions(+), 3 deletions(-) create mode 100644 docs/changelog.d/+frigate-retention-and-check.infra.md diff --git a/argocd/manifests/frigate/frigate-config.yml b/argocd/manifests/frigate/frigate-config.yml index 08d6819..a697d2a 100644 --- a/argocd/manifests/frigate/frigate-config.yml +++ b/argocd/manifests/frigate/frigate-config.yml @@ -70,14 +70,14 @@ model: record: enabled: true continuous: - days: 3 + days: 180 alerts: retain: - days: 30 + days: 730 mode: active_objects detections: retain: - days: 14 + days: 30 mode: motion snapshots: diff --git a/docs/changelog.d/+frigate-retention-and-check.infra.md b/docs/changelog.d/+frigate-retention-and-check.infra.md new file mode 100644 index 0000000..d09e510 --- /dev/null +++ b/docs/changelog.d/+frigate-retention-and-check.infra.md @@ -0,0 +1 @@ +Bump Frigate recording retention (180d continuous, 30d detections, 730d alerts) and add camera-fps health check to services-check. diff --git a/mise-tasks/services-check b/mise-tasks/services-check index d0de329..44d5722 100755 --- a/mise-tasks/services-check +++ b/mise-tasks/services-check @@ -83,6 +83,7 @@ check_http "CV" "https://cv.ops.eblu.me/" check_http "Ntfy" "https://ntfy.ops.eblu.me/v1/health" check_http "Authentik" "https://authentik.ops.eblu.me/-/health/live/" check_http "Frigate" "https://nvr.ops.eblu.me/api/version" +check_service "frigate-recording" "curl -sf --max-time 5 https://nvr.ops.eblu.me/api/stats | jq -e '.camera_fps > 0'" check_http "JobSync" "https://jobsync.ops.eblu.me/" echo "" From 3e9873d6698136dfa0bb2275d279bc7767fbaf36 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 18 Mar 2026 06:07:44 -0700 Subject: [PATCH 066/430] Fix borgmatic backup: use correct kubectl context on indri The Mealie SQLite dump hook used `minikube-indri` (the context name on gilbert), but on indri itself the context is just `minikube`. This caused the before_backup hook to fail, aborting all backups since the hook was added. Co-Authored-By: Claude Opus 4.6 (1M context) --- ansible/roles/borgmatic/defaults/main.yml | 2 +- docs/changelog.d/+fix-borgmatic-kubectl-context.bugfix.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 docs/changelog.d/+fix-borgmatic-kubectl-context.bugfix.md diff --git a/ansible/roles/borgmatic/defaults/main.yml b/ansible/roles/borgmatic/defaults/main.yml index 5980915..428f21e 100644 --- a/ansible/roles/borgmatic/defaults/main.yml +++ b/ansible/roles/borgmatic/defaults/main.yml @@ -43,7 +43,7 @@ borgmatic_k8s_sqlite_dumps: namespace: mealie label_selector: app=mealie db_path: /app/data/mealie.db - context: minikube-indri + context: minikube # Exclude patterns borgmatic_exclude_patterns: [] diff --git a/docs/changelog.d/+fix-borgmatic-kubectl-context.bugfix.md b/docs/changelog.d/+fix-borgmatic-kubectl-context.bugfix.md new file mode 100644 index 0000000..e91075a --- /dev/null +++ b/docs/changelog.d/+fix-borgmatic-kubectl-context.bugfix.md @@ -0,0 +1 @@ +Fix borgmatic backup failure: use correct kubectl context (`minikube`) on indri for Mealie SQLite dump hook From 96d0f668fd4e159b7093a157ec29e4e73dc5aa50 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 18 Mar 2026 06:15:52 -0700 Subject: [PATCH 067/430] Reorganize Homepage groups: add Home, move Grafana to Infrastructure Move NVR, Jellyfin, and DJ to new Home group. Move Grafana from Content to Infrastructure. Switch all layout groups from column to row style. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../manifests/frigate/ingress-tailscale.yaml | 2 +- .../grafana-config/ingress-tailscale.yaml | 2 +- argocd/manifests/homepage/services.yaml | 25 ++++++++++--------- argocd/manifests/homepage/settings.yaml | 10 +++++--- .../navidrome/ingress-tailscale.yaml | 2 +- 5 files changed, 22 insertions(+), 19 deletions(-) diff --git a/argocd/manifests/frigate/ingress-tailscale.yaml b/argocd/manifests/frigate/ingress-tailscale.yaml index f814b70..d66b14f 100644 --- a/argocd/manifests/frigate/ingress-tailscale.yaml +++ b/argocd/manifests/frigate/ingress-tailscale.yaml @@ -9,7 +9,7 @@ metadata: tailscale.com/proxy-group: "ingress" gethomepage.dev/enabled: "true" gethomepage.dev/name: "NVR" - gethomepage.dev/group: "Infrastructure" + gethomepage.dev/group: "Home" gethomepage.dev/icon: "frigate.png" gethomepage.dev/description: "Network video recorder" gethomepage.dev/href: "https://nvr.ops.eblu.me" diff --git a/argocd/manifests/grafana-config/ingress-tailscale.yaml b/argocd/manifests/grafana-config/ingress-tailscale.yaml index e02269c..8233592 100644 --- a/argocd/manifests/grafana-config/ingress-tailscale.yaml +++ b/argocd/manifests/grafana-config/ingress-tailscale.yaml @@ -12,7 +12,7 @@ metadata: tailscale.com/proxy-group: "ingress" gethomepage.dev/enabled: "true" gethomepage.dev/name: "Grafana" - gethomepage.dev/group: "Content" + gethomepage.dev/group: "Infrastructure" gethomepage.dev/icon: "grafana.png" gethomepage.dev/description: "Metrics dashboards" gethomepage.dev/href: "https://grafana.ops.eblu.me" diff --git a/argocd/manifests/homepage/services.yaml b/argocd/manifests/homepage/services.yaml index 9861e0a..0a3db25 100644 --- a/argocd/manifests/homepage/services.yaml +++ b/argocd/manifests/homepage/services.yaml @@ -43,6 +43,19 @@ query: borgmatic_repo_deduplicated_size_bytes format: type: bytes + # TODO: Add Caddy widget when admin API is enabled (currently admin off) + # - Caddy: + # href: https://indri.tail8d86e.ts.net + # icon: caddy + # description: Reverse proxy + # widget: + # type: caddy + # url: http://indri.tail8d86e.ts.net:2019 +- Home: + - NVR: + href: https://nvr.ops.eblu.me + icon: frigate.png + description: Network video recorder - Jellyfin: href: https://jellyfin.ops.eblu.me icon: jellyfin @@ -53,14 +66,6 @@ key: "{{HOMEPAGE_VAR_JELLYFIN_API_KEY}}" enableBlocks: true enableNowPlaying: true - # TODO: Add Caddy widget when admin API is enabled (currently admin off) - # - Caddy: - # href: https://indri.tail8d86e.ts.net - # icon: caddy - # description: Reverse proxy - # widget: - # type: caddy - # url: http://indri.tail8d86e.ts.net:2019 - Services: - JobSync: href: https://jobsync.ops.eblu.me @@ -71,10 +76,6 @@ href: https://authentik.ops.eblu.me icon: authentik description: Identity provider - - NVR: - href: https://nvr.ops.eblu.me - icon: frigate.png - description: Network video recorder - Ntfy: href: https://ntfy.ops.eblu.me icon: ntfy.png diff --git a/argocd/manifests/homepage/settings.yaml b/argocd/manifests/homepage/settings.yaml index f8c06fc..c03fa09 100644 --- a/argocd/manifests/homepage/settings.yaml +++ b/argocd/manifests/homepage/settings.yaml @@ -8,10 +8,12 @@ quicklaunch: suggestionUrl: https://kagisuggest.com/api/autosuggest?q= layout: Host Services: - style: column + style: row + Home: + style: row Content: - style: column + style: row Infrastructure: - style: column + style: row Services: - style: column + style: row diff --git a/argocd/manifests/navidrome/ingress-tailscale.yaml b/argocd/manifests/navidrome/ingress-tailscale.yaml index 12eb53e..7264086 100644 --- a/argocd/manifests/navidrome/ingress-tailscale.yaml +++ b/argocd/manifests/navidrome/ingress-tailscale.yaml @@ -9,7 +9,7 @@ metadata: tailscale.com/proxy-group: "ingress" gethomepage.dev/enabled: "true" gethomepage.dev/name: "DJ" - gethomepage.dev/group: "Content" + gethomepage.dev/group: "Home" gethomepage.dev/icon: "navidrome.png" gethomepage.dev/description: "Music streaming server" gethomepage.dev/href: "https://dj.ops.eblu.me" From 816fd552f09e37d6bc2d719554b026068ee1276b Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 18 Mar 2026 06:19:40 -0700 Subject: [PATCH 068/430] Set Homepage to single-column group layout Add maxGroupColumns: 1 so each category gets its own full-width row, with service tiles arranged side-by-side within each group. Co-Authored-By: Claude Opus 4.6 (1M context) --- argocd/manifests/homepage/settings.yaml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/argocd/manifests/homepage/settings.yaml b/argocd/manifests/homepage/settings.yaml index c03fa09..d879ddb 100644 --- a/argocd/manifests/homepage/settings.yaml +++ b/argocd/manifests/homepage/settings.yaml @@ -6,14 +6,15 @@ quicklaunch: provider: custom url: https://kagi.com/search?q= suggestionUrl: https://kagisuggest.com/api/autosuggest?q= +maxGroupColumns: 1 layout: Host Services: - style: row + style: column Home: - style: row + style: column Content: - style: row + style: column Infrastructure: - style: row + style: column Services: - style: row + style: column From b0ce9be30b8cba0f66aa438ffa0a40d5fa188e57 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 18 Mar 2026 06:21:44 -0700 Subject: [PATCH 069/430] Fix Homepage layout: use row style with columns for full-width groups style: row makes each group span the full page width (one per row), while columns: 4 tiles services horizontally within each group. Co-Authored-By: Claude Opus 4.6 (1M context) --- argocd/manifests/homepage/settings.yaml | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/argocd/manifests/homepage/settings.yaml b/argocd/manifests/homepage/settings.yaml index d879ddb..43d860a 100644 --- a/argocd/manifests/homepage/settings.yaml +++ b/argocd/manifests/homepage/settings.yaml @@ -6,15 +6,19 @@ quicklaunch: provider: custom url: https://kagi.com/search?q= suggestionUrl: https://kagisuggest.com/api/autosuggest?q= -maxGroupColumns: 1 layout: Host Services: - style: column + style: row + columns: 4 Home: - style: column + style: row + columns: 4 Content: - style: column + style: row + columns: 4 Infrastructure: - style: column + style: row + columns: 4 Services: - style: column + style: row + columns: 4 From 443e090ec65a2dcffa3e11c802878ac2a4760757 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 18 Mar 2026 06:23:53 -0700 Subject: [PATCH 070/430] Enable equal height tiles in Homepage groups Add useEqualHeights: true so service tiles within each row expand to match the tallest tile, fixing uneven layout from widget metrics. Co-Authored-By: Claude Opus 4.6 (1M context) --- argocd/manifests/homepage/settings.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/argocd/manifests/homepage/settings.yaml b/argocd/manifests/homepage/settings.yaml index 43d860a..49bbf4e 100644 --- a/argocd/manifests/homepage/settings.yaml +++ b/argocd/manifests/homepage/settings.yaml @@ -10,15 +10,20 @@ layout: Host Services: style: row columns: 4 + useEqualHeights: true Home: style: row columns: 4 + useEqualHeights: true Content: style: row columns: 4 + useEqualHeights: true Infrastructure: style: row columns: 4 + useEqualHeights: true Services: style: row columns: 4 + useEqualHeights: true From 98584d0d678b470f3d7556c7f01a90f0262b4cec Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 18 Mar 2026 06:26:15 -0700 Subject: [PATCH 071/430] Trim Homepage widget metrics for cleaner layout - Forgejo: show only notifications and pull requests - Jellyfin: show only movies/series/episodes, hide now playing - Grafana: hide data sources, show dashboards and alerts only Co-Authored-By: Claude Opus 4.6 (1M context) --- argocd/manifests/grafana-config/ingress-tailscale.yaml | 1 + argocd/manifests/homepage/services.yaml | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/argocd/manifests/grafana-config/ingress-tailscale.yaml b/argocd/manifests/grafana-config/ingress-tailscale.yaml index 8233592..2329cdf 100644 --- a/argocd/manifests/grafana-config/ingress-tailscale.yaml +++ b/argocd/manifests/grafana-config/ingress-tailscale.yaml @@ -21,6 +21,7 @@ metadata: gethomepage.dev/widget.url: "https://grafana.ops.eblu.me" gethomepage.dev/widget.username: "{{HOMEPAGE_VAR_GRAFANA_USERNAME}}" gethomepage.dev/widget.password: "{{HOMEPAGE_VAR_GRAFANA_PASSWORD}}" + gethomepage.dev/widget.fields: '["dashboards", "totalAlerts", "alertsTriggered"]' spec: ingressClassName: tailscale defaultBackend: diff --git a/argocd/manifests/homepage/services.yaml b/argocd/manifests/homepage/services.yaml index 0a3db25..29899e4 100644 --- a/argocd/manifests/homepage/services.yaml +++ b/argocd/manifests/homepage/services.yaml @@ -7,6 +7,7 @@ type: gitea url: https://forge.eblu.me key: "{{HOMEPAGE_VAR_FORGEJO_API_KEY}}" + fields: ["notifications", "pulls"] - Registry: href: https://registry.ops.eblu.me icon: zot-registry @@ -65,7 +66,8 @@ url: https://jellyfin.ops.eblu.me key: "{{HOMEPAGE_VAR_JELLYFIN_API_KEY}}" enableBlocks: true - enableNowPlaying: true + enableNowPlaying: false + fields: ["movies", "series", "episodes"] - Services: - JobSync: href: https://jobsync.ops.eblu.me From 64afd40a299cc192d770d6de87726c933530b4a8 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 18 Mar 2026 06:28:41 -0700 Subject: [PATCH 072/430] Fix Grafana widget fields (lowercase) and hide Miniflux read count Co-Authored-By: Claude Opus 4.6 (1M context) --- argocd/manifests/grafana-config/ingress-tailscale.yaml | 2 +- argocd/manifests/miniflux/ingress-tailscale.yaml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/argocd/manifests/grafana-config/ingress-tailscale.yaml b/argocd/manifests/grafana-config/ingress-tailscale.yaml index 2329cdf..56888ac 100644 --- a/argocd/manifests/grafana-config/ingress-tailscale.yaml +++ b/argocd/manifests/grafana-config/ingress-tailscale.yaml @@ -21,7 +21,7 @@ metadata: gethomepage.dev/widget.url: "https://grafana.ops.eblu.me" gethomepage.dev/widget.username: "{{HOMEPAGE_VAR_GRAFANA_USERNAME}}" gethomepage.dev/widget.password: "{{HOMEPAGE_VAR_GRAFANA_PASSWORD}}" - gethomepage.dev/widget.fields: '["dashboards", "totalAlerts", "alertsTriggered"]' + gethomepage.dev/widget.fields: '["dashboards", "totalalerts", "alertstriggered"]' spec: ingressClassName: tailscale defaultBackend: diff --git a/argocd/manifests/miniflux/ingress-tailscale.yaml b/argocd/manifests/miniflux/ingress-tailscale.yaml index a35c221..c6807ea 100644 --- a/argocd/manifests/miniflux/ingress-tailscale.yaml +++ b/argocd/manifests/miniflux/ingress-tailscale.yaml @@ -16,6 +16,7 @@ metadata: gethomepage.dev/widget.type: "miniflux" gethomepage.dev/widget.url: "https://feed.ops.eblu.me" gethomepage.dev/widget.key: "{{HOMEPAGE_VAR_MINIFLUX_API_KEY}}" + gethomepage.dev/widget.fields: '["unread"]' spec: ingressClassName: tailscale defaultBackend: From 8425f56dc3efdf75a5b11793648cfe9eef49e480 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 18 Mar 2026 06:29:44 -0700 Subject: [PATCH 073/430] Add Fly.io dashboard to Homepage admin bookmarks Co-Authored-By: Claude Opus 4.6 (1M context) --- argocd/manifests/homepage/bookmarks.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/argocd/manifests/homepage/bookmarks.yaml b/argocd/manifests/homepage/bookmarks.yaml index 8bf0ee7..bc58a1e 100644 --- a/argocd/manifests/homepage/bookmarks.yaml +++ b/argocd/manifests/homepage/bookmarks.yaml @@ -14,6 +14,9 @@ - UniFi: - href: https://unifi.ui.com icon: ubiquiti + - Fly.io: + - href: https://fly.io/dashboard + icon: si-flydotio - Gandi: - href: https://admin.gandi.net icon: si-gandi From e8bdecdb113c8284916ca78df146d3577e5c8d63 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 18 Mar 2026 06:33:27 -0700 Subject: [PATCH 074/430] Rename Borgmatic dashboard to Borg Backups, add duration graph Rename dashboard title since borgmatic is just the execution layer. Add Backup Duration Over Time panel next to New Data Per Backup. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../dashboards/configmap-borgmatic.yaml | 89 ++++++++++++++++++- 1 file changed, 86 insertions(+), 3 deletions(-) diff --git a/argocd/manifests/grafana-config/dashboards/configmap-borgmatic.yaml b/argocd/manifests/grafana-config/dashboards/configmap-borgmatic.yaml index 4694a5f..0e16982 100644 --- a/argocd/manifests/grafana-config/dashboards/configmap-borgmatic.yaml +++ b/argocd/manifests/grafana-config/dashboards/configmap-borgmatic.yaml @@ -743,7 +743,7 @@ data: }, "overrides": [] }, - "gridPos": { "h": 8, "w": 24, "x": 0, "y": 16 }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 16 }, "id": 13, "options": { "legend": { @@ -769,11 +769,94 @@ data: "title": "New Data Per Backup", "description": "How much new (deduplicated) data each backup added to the repository", "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "yellow", + "mode": "fixed" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "bars", + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 60 }, + { "color": "red", "value": 300 } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 16 }, + "id": 15, + "options": { + "legend": { + "calcs": ["mean", "max", "lastNotNull"], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "borgmatic_last_archive_duration_seconds", + "legendFormat": "Backup Duration", + "refId": "A" + } + ], + "title": "Backup Duration Over Time", + "description": "How long each backup took to complete", + "type": "timeseries" } ], "refresh": "5m", "schemaVersion": 38, - "tags": ["borgmatic", "backup", "borg"], + "tags": ["borg", "backup"], "templating": { "list": [] }, @@ -783,7 +866,7 @@ data: }, "timepicker": {}, "timezone": "browser", - "title": "Borgmatic Backups", + "title": "Borg Backups", "uid": "borgmatic", "version": 1, "weekStart": "" From 50d3b3b21ed8616256a99dd5099e3252275a3e39 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 18 Mar 2026 06:34:13 -0700 Subject: [PATCH 075/430] Rename Borgmatic to Borg Backups on Homepage Co-Authored-By: Claude Opus 4.6 (1M context) --- argocd/manifests/homepage/services.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/argocd/manifests/homepage/services.yaml b/argocd/manifests/homepage/services.yaml index 29899e4..0305a42 100644 --- a/argocd/manifests/homepage/services.yaml +++ b/argocd/manifests/homepage/services.yaml @@ -28,7 +28,7 @@ query: node_filesystem_size_bytes{mountpoint="/Volumes/backups"} format: type: bytes - - Borgmatic: + - Borg Backups: href: https://grafana.ops.eblu.me/d/borgmatic icon: borgmatic description: Backup system From ef199b70f006642b85bac10d73e566316a849ddd Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 18 Mar 2026 06:44:00 -0700 Subject: [PATCH 076/430] Increase Prometheus and Loki data retention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prometheus: 15d → 10y (3650d), PVC 20Gi → 200Gi Loki: 31d (744h) → 365d (8760h), PVC 20Gi → 50Gi Indri has 1.6 TB free on the minikube backing disk — the previous 15-day Prometheus retention was losing valuable long-term metrics data. Co-Authored-By: Claude Opus 4.6 (1M context) --- argocd/manifests/loki/loki-config.yaml | 2 +- argocd/manifests/loki/statefulset.yaml | 2 +- argocd/manifests/prometheus/statefulset.yaml | 4 ++-- docs/changelog.d/+increase-retention.infra.md | 1 + 4 files changed, 5 insertions(+), 4 deletions(-) create mode 100644 docs/changelog.d/+increase-retention.infra.md diff --git a/argocd/manifests/loki/loki-config.yaml b/argocd/manifests/loki/loki-config.yaml index 5210f1b..41aac8e 100644 --- a/argocd/manifests/loki/loki-config.yaml +++ b/argocd/manifests/loki/loki-config.yaml @@ -40,7 +40,7 @@ storage_config: cache_location: /loki/tsdb-cache limits_config: - retention_period: 744h # 31 days + retention_period: 8760h # 365 days compactor: working_directory: /loki/compactor diff --git a/argocd/manifests/loki/statefulset.yaml b/argocd/manifests/loki/statefulset.yaml index eb4fe3b..18a6302 100644 --- a/argocd/manifests/loki/statefulset.yaml +++ b/argocd/manifests/loki/statefulset.yaml @@ -63,4 +63,4 @@ spec: accessModes: ["ReadWriteOnce"] resources: requests: - storage: 20Gi + storage: 50Gi diff --git a/argocd/manifests/prometheus/statefulset.yaml b/argocd/manifests/prometheus/statefulset.yaml index c549368..1d90038 100644 --- a/argocd/manifests/prometheus/statefulset.yaml +++ b/argocd/manifests/prometheus/statefulset.yaml @@ -24,7 +24,7 @@ spec: args: - --config.file=/etc/prometheus/prometheus.yml - --storage.tsdb.path=/prometheus - - --storage.tsdb.retention.time=15d + - --storage.tsdb.retention.time=3650d - --web.enable-remote-write-receiver - --web.enable-lifecycle ports: @@ -65,4 +65,4 @@ spec: accessModes: ["ReadWriteOnce"] resources: requests: - storage: 20Gi + storage: 200Gi diff --git a/docs/changelog.d/+increase-retention.infra.md b/docs/changelog.d/+increase-retention.infra.md new file mode 100644 index 0000000..066e65c --- /dev/null +++ b/docs/changelog.d/+increase-retention.infra.md @@ -0,0 +1 @@ +Increase data retention: Prometheus 15d → 10y (200Gi), Loki 31d → 365d (50Gi) From 21ddc74cdc367cbffaa3b60f7630be2667f194ba Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 18 Mar 2026 06:46:17 -0700 Subject: [PATCH 077/430] Revert PVC size changes, add hostpath comment StatefulSet volumeClaimTemplates are immutable and minikube's hostpath provisioner doesn't enforce PVC size limits anyway. Add comments noting the data grows freely on the 1.8TB backing disk. Co-Authored-By: Claude Opus 4.6 (1M context) --- argocd/manifests/loki/statefulset.yaml | 2 +- argocd/manifests/prometheus/statefulset.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/argocd/manifests/loki/statefulset.yaml b/argocd/manifests/loki/statefulset.yaml index 18a6302..3fb9be2 100644 --- a/argocd/manifests/loki/statefulset.yaml +++ b/argocd/manifests/loki/statefulset.yaml @@ -63,4 +63,4 @@ spec: accessModes: ["ReadWriteOnce"] resources: requests: - storage: 50Gi + storage: 20Gi # Not enforced by minikube hostpath; data grows freely on 1.8TB disk diff --git a/argocd/manifests/prometheus/statefulset.yaml b/argocd/manifests/prometheus/statefulset.yaml index 1d90038..5b4bf82 100644 --- a/argocd/manifests/prometheus/statefulset.yaml +++ b/argocd/manifests/prometheus/statefulset.yaml @@ -65,4 +65,4 @@ spec: accessModes: ["ReadWriteOnce"] resources: requests: - storage: 200Gi + storage: 20Gi # Not enforced by minikube hostpath; data grows freely on 1.8TB disk From e0dbcbd99793db2376b7696e0a7b7fb21de9407f Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 18 Mar 2026 06:46:55 -0700 Subject: [PATCH 078/430] Update retention changelog to reflect final PVC decision Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/changelog.d/+increase-retention.infra.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.d/+increase-retention.infra.md b/docs/changelog.d/+increase-retention.infra.md index 066e65c..54bb4fd 100644 --- a/docs/changelog.d/+increase-retention.infra.md +++ b/docs/changelog.d/+increase-retention.infra.md @@ -1 +1 @@ -Increase data retention: Prometheus 15d → 10y (200Gi), Loki 31d → 365d (50Gi) +Increase data retention: Prometheus 15d → 10y, Loki 31d → 365d (PVC sizes unchanged; minikube hostpath doesn't enforce limits) From 528d3da3276cddb3e691a0641bb19b2483d0d818 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 18 Mar 2026 07:37:31 -0700 Subject: [PATCH 079/430] Review power.md: add ringtail, mark reviewed Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/reference/infrastructure/power.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/reference/infrastructure/power.md b/docs/reference/infrastructure/power.md index 2ee1055..33b000b 100644 --- a/docs/reference/infrastructure/power.md +++ b/docs/reference/infrastructure/power.md @@ -1,8 +1,10 @@ --- title: Power modified: 2026-02-09 +last-reviewed: 2026-03-18 tags: - infrastructure + - reference --- # Power Infrastructure @@ -26,6 +28,7 @@ AC Grid (120V) → Anker SOLIX F2000 → CyberPower CP1000PFCLCD → Homelab | Device | Role | |--------|------| | [[indri]] | Primary server | +| [[ringtail]] | GPU compute / gaming PC | | [[sifaka]] | NAS | | UniFi Express 7 | WiFi router | | Starlink | Satellite internet uplink | From 0d2779762aab03f649bc89ed21827b5690b5c57f Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 18 Mar 2026 07:47:46 -0700 Subject: [PATCH 080/430] Upgrade Prometheus to v3.10.0 (#301) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Bump Prometheus from v3.9.1 to v3.10.0 in custom container Dockerfile - v3.10.0 adds distroless Docker image variants, new PromQL `fill` operators, and performance improvements - Dagger build tested locally — builds cleanly ## Remaining after merge - Update `kustomization.yaml` newTag with the auto-built image tag - Update `service-versions.yaml` (last-reviewed + current-version) - ArgoCD sync 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/301 --- containers/prometheus/Dockerfile | 2 +- docs/changelog.d/upgrade-prometheus-3.10.0.infra.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 docs/changelog.d/upgrade-prometheus-3.10.0.infra.md diff --git a/containers/prometheus/Dockerfile b/containers/prometheus/Dockerfile index 973ceb6..90be789 100644 --- a/containers/prometheus/Dockerfile +++ b/containers/prometheus/Dockerfile @@ -1,7 +1,7 @@ # Prometheus monitoring system # Three-stage build: Web UI (Node), binaries (Go), runtime (Alpine) -ARG CONTAINER_APP_VERSION=v3.9.1 +ARG CONTAINER_APP_VERSION=v3.10.0 ARG PROMETHEUS_VERSION=${CONTAINER_APP_VERSION} FROM node:22-alpine AS ui-build diff --git a/docs/changelog.d/upgrade-prometheus-3.10.0.infra.md b/docs/changelog.d/upgrade-prometheus-3.10.0.infra.md new file mode 100644 index 0000000..a473ace --- /dev/null +++ b/docs/changelog.d/upgrade-prometheus-3.10.0.infra.md @@ -0,0 +1 @@ +Upgrade Prometheus from v3.9.1 to v3.10.0 (distroless variants, PromQL fill operators, performance improvements) From 86220b7b88c3d18e91e5e5ebb7ec4af0b981d48e Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 18 Mar 2026 08:46:07 -0700 Subject: [PATCH 081/430] Update Prometheus deployment to v3.10.0-0d27797 C0 fix-forward: update kustomization newTag and mark service reviewed. Co-Authored-By: Claude Opus 4.6 (1M context) --- argocd/manifests/prometheus/kustomization.yaml | 2 +- service-versions.yaml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/argocd/manifests/prometheus/kustomization.yaml b/argocd/manifests/prometheus/kustomization.yaml index b70b911..66057c2 100644 --- a/argocd/manifests/prometheus/kustomization.yaml +++ b/argocd/manifests/prometheus/kustomization.yaml @@ -10,7 +10,7 @@ resources: images: - name: registry.ops.eblu.me/blumeops/prometheus - newTag: v3.9.1-33b7f0f + newTag: v3.10.0-0d27797 configMapGenerator: - name: prometheus-config diff --git a/service-versions.yaml b/service-versions.yaml index 9a19eaf..4ddf21f 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -14,8 +14,8 @@ services: - name: prometheus type: argocd - last-reviewed: 2026-02-16 - current-version: "v3.9.1" + last-reviewed: 2026-03-18 + current-version: "v3.10.0" upstream-source: https://github.com/prometheus/prometheus/releases - name: loki From ef8c2118a1396be68211e5ee8d1566ea2d22c493 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 18 Mar 2026 11:42:01 -0700 Subject: [PATCH 082/430] Standardize USAGE pragmas and typer parsing across mise tasks Co-Authored-By: Claude Opus 4.6 (1M context) --- .../+usage-pragma-consistency.misc.md | 1 + mise-tasks/mikado-branch-invariant-check | 31 +++++++++++------ mise-tasks/op-backup | 33 ++++++++++++------- mise-tasks/pr-comments | 32 ++++++++---------- 4 files changed, 56 insertions(+), 41 deletions(-) create mode 100644 docs/changelog.d/+usage-pragma-consistency.misc.md diff --git a/docs/changelog.d/+usage-pragma-consistency.misc.md b/docs/changelog.d/+usage-pragma-consistency.misc.md new file mode 100644 index 0000000..aeb93ad --- /dev/null +++ b/docs/changelog.d/+usage-pragma-consistency.misc.md @@ -0,0 +1 @@ +Standardized USAGE pragmas and typer CLI parsing across all mise tasks: added missing `#USAGE` directive to `mikado-branch-invariant-check`, converted `pr-comments` and `op-backup` from raw `sys.argv` to typer for consistency with all other uv python scripts. diff --git a/mise-tasks/mikado-branch-invariant-check b/mise-tasks/mikado-branch-invariant-check index ffe3d1a..9060fc8 100755 --- a/mise-tasks/mikado-branch-invariant-check +++ b/mise-tasks/mikado-branch-invariant-check @@ -1,9 +1,10 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.12" -# dependencies = ["rich>=13.0.0"] +# dependencies = ["rich>=13.0.0", "typer>=0.15.0"] # /// #MISE description="Validate Mikado Branch Invariant on mikado/* branches" +#USAGE arg "[commit_msg_file]" help="Commit message file (passed by commit-msg hook)" """Validate the Mikado Branch Invariant for C2 change branches. Runs as a commit-msg hook on mikado/* branches. Receives the commit message @@ -24,9 +25,10 @@ Exit code 0 if valid (or not on a mikado/* branch), 1 if violations found. import re import subprocess -import sys from pathlib import Path +from typing import Annotated +import typer from rich.console import Console REPO_DIR = Path(__file__).parent.parent @@ -242,27 +244,36 @@ def check_invariant(commits: list[dict], chain_stem: str) -> list[str]: return errors -def main() -> None: +app = typer.Typer() + + +@app.command() +def main( + commit_msg_file: Annotated[ + str | None, + typer.Argument(help="Commit message file (passed by commit-msg hook)"), + ] = None, +) -> None: console = Console(stderr=True) branch = get_current_branch() if not branch or not branch.startswith("mikado/"): # Not on a mikado branch — nothing to check - sys.exit(0) + raise SystemExit(0) chain_stem = branch.removeprefix("mikado/") commits = get_branch_commits(branch) # If called with a commit message file (commit-msg hook), include the # pending commit in the validation - if len(sys.argv) > 1: - subject = parse_commit_message(sys.argv[1]) + if commit_msg_file is not None: + subject = parse_commit_message(commit_msg_file) if subject: commits.append(make_pending_commit(subject)) if not commits: # No commits on branch yet — valid (length-zero case) - sys.exit(0) + raise SystemExit(0) errors = check_invariant(commits, chain_stem) @@ -286,10 +297,10 @@ def main() -> None: "[dim]See: docs/how-to/agent-change-process.md " "§ The Mikado Branch Invariant[/dim]" ) - sys.exit(1) + raise SystemExit(1) - sys.exit(0) + raise SystemExit(0) if __name__ == "__main__": - main() + app() diff --git a/mise-tasks/op-backup b/mise-tasks/op-backup index 0b486cc..202cb3e 100755 --- a/mise-tasks/op-backup +++ b/mise-tasks/op-backup @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.12" -# dependencies = ["rich>=13.0.0"] +# dependencies = ["rich>=13.0.0", "typer>=0.15.0"] # /// #MISE description="Encrypt a 1Password .1pux export and send to indri for borgmatic" #USAGE arg "[export_path]" help="Path to .1pux export file (prompted if omitted)" @@ -29,11 +29,12 @@ DISASTER RECOVERY: import os import shutil import subprocess -import sys import tempfile from datetime import datetime from pathlib import Path +from typing import Annotated +import typer from rich.console import Console REMOTE_DIR = "/Users/erichblume/Documents/1password-backup" @@ -281,26 +282,35 @@ def transfer_to_indri(encrypted_export: Path, encrypted_key: Path, timestamp: st return True -def main() -> int: - if not check_dependencies(): - return 1 +app = typer.Typer() - export_path = get_export_path(sys.argv[1] if len(sys.argv) > 1 else None) + +@app.command() +def main( + export_path_arg: Annotated[ + str | None, + typer.Argument(help="Path to .1pux export file (prompted if omitted)"), + ] = None, +) -> None: + if not check_dependencies(): + raise SystemExit(1) + + export_path = get_export_path(export_path_arg) if not export_path: - return 1 + raise SystemExit(1) file_size = f"{export_path.stat().st_size / 1024 / 1024:.1f} MB" console.print(f"Source: {export_path} ({file_size})") passphrase = fetch_credentials() if not passphrase: - return 1 + raise SystemExit(1) with tempfile.TemporaryDirectory() as tmpdir: result = encrypt(export_path, passphrase, Path(tmpdir)) del passphrase if not result: - return 1 + raise SystemExit(1) encrypted_export, encrypted_key = result @@ -310,7 +320,7 @@ def main() -> int: timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") if not transfer_to_indri(encrypted_export, encrypted_key, timestamp): - return 1 + raise SystemExit(1) console.print() console.print("[bold]DISASTER RECOVERY:[/bold]") @@ -320,8 +330,7 @@ def main() -> int: console.print(" Passphrase: {master_password}:{secret_key}") console.print(f" 4. age -d -i key.txt < ...age > export.1pux") console.print(" 5. Open export.1pux with 1Password or unzip to inspect") - return 0 if __name__ == "__main__": - sys.exit(main()) + app() diff --git a/mise-tasks/pr-comments b/mise-tasks/pr-comments index 9c33f9d..1ec60ef 100755 --- a/mise-tasks/pr-comments +++ b/mise-tasks/pr-comments @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.12" -# dependencies = ["httpx>=0.28.0", "rich>=13.0.0"] +# dependencies = ["httpx>=0.28.0", "rich>=13.0.0", "typer>=0.15.0"] # /// #MISE description="List unresolved comments on a PR" #USAGE arg "" help="Pull request number" @@ -14,9 +14,10 @@ if its 'resolver' field is null. Usage: mise run pr-comments """ -import sys +from typing import Annotated import httpx +import typer from rich.console import Console from rich.text import Text @@ -43,20 +44,15 @@ def get_review_comments(client: httpx.Client, pr_number: int, review_id: int) -> return response.json() -def main() -> int: +app = typer.Typer() + + +@app.command() +def main( + pr_number: Annotated[int, typer.Argument(help="Pull request number")], +) -> None: console = Console() - if len(sys.argv) < 2: - console.print("[red]Error:[/red] Please provide a PR number") - console.print("Usage: mise run pr-comments ") - return 1 - - try: - pr_number = int(sys.argv[1]) - except ValueError: - console.print(f"[red]Error:[/red] '{sys.argv[1]}' is not a valid PR number") - return 1 - unresolved_comments: list[tuple[dict, dict]] = [] # (review, comment) pairs with httpx.Client() as client: @@ -68,7 +64,7 @@ def main() -> int: console.print(f"[red]Error:[/red] PR #{pr_number} not found") else: console.print(f"[red]Error:[/red] API request failed: {e}") - return 1 + raise SystemExit(1) # For each review, get comments and filter to unresolved for review in reviews: @@ -83,7 +79,7 @@ def main() -> int: if not unresolved_comments: console.print(f"[green]No unresolved comments on PR #{pr_number}[/green]") - return 0 + raise SystemExit(0) # Display unresolved comments console.print(f"[bold]Unresolved Comments on PR #{pr_number}[/bold] ({len(unresolved_comments)} comments)") @@ -111,8 +107,6 @@ def main() -> int: console.print() - return 0 - if __name__ == "__main__": - sys.exit(main()) + app() From 0dffdb99746f783fee3cedeae44bba92e4b8705a Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 18 Mar 2026 11:57:36 -0700 Subject: [PATCH 083/430] Add Claude Code subagents for infrastructure workflows Four project-scoped subagents that formalize existing mise task workflows as constrained, specialized AI agents: - infra-health: background health monitor (wraps services-check) - doc-reviewer: persistent-memory documentation reviewer - change-classifier: C0/C1/C2 triage before work begins - mikado-navigator: C2 chain state advisor (wraps docs-mikado) Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/agents/change-classifier.md | 62 +++++++++++++++++ .claude/agents/doc-reviewer.md | 62 +++++++++++++++++ .claude/agents/infra-health.md | 36 ++++++++++ .claude/agents/mikado-navigator.md | 69 +++++++++++++++++++ docs/changelog.d/+claude-code-subagents.ai.md | 1 + 5 files changed, 230 insertions(+) create mode 100644 .claude/agents/change-classifier.md create mode 100644 .claude/agents/doc-reviewer.md create mode 100644 .claude/agents/infra-health.md create mode 100644 .claude/agents/mikado-navigator.md create mode 100644 docs/changelog.d/+claude-code-subagents.ai.md diff --git a/.claude/agents/change-classifier.md b/.claude/agents/change-classifier.md new file mode 100644 index 0000000..ab877e9 --- /dev/null +++ b/.claude/agents/change-classifier.md @@ -0,0 +1,62 @@ +--- +name: change-classifier +description: Classifies proposed changes as C0/C1/C2 before work begins. Use proactively when the user describes a new task or change, before any implementation starts. +tools: Read, Glob, Grep, Bash +model: haiku +permissionMode: dontAsk +--- + +You are a change classifier for the BlumeOps infrastructure project. Your job is to assess a proposed change and classify it as C0, C1, or C2 before any work begins. + +## Classification Criteria + +| Class | Name | When to use | Key trait | +|-------|------|-------------|-----------| +| **C0** | Quick Fix | Small, low-risk, fix-forward safe | Direct to main, no PR | +| **C1** | Human Review | Moderate complexity or risk | Feature branch + PR, docs-first | +| **C2** | Mikado Chain | Multi-phase, multi-session, high complexity | Mikado Branch Invariant | + +## Assessment Process + +1. Understand what the user wants to change +2. Identify which files/services are affected — use Glob/Grep to check the blast radius +3. Assess risk factors: + - How many files change? + - Are critical services affected (networking, auth, DNS)? + - Is the change easily reversible? + - Could it cause downtime? + - Does it span multiple services or systems? + - Does it require multi-step sequencing? +4. Classify and explain your reasoning + +## C0 Indicators +- Single file or small number of related files +- Config value change, version bump, typo fix, doc update +- No service restart needed, or restart is safe +- Easy to fix-forward if wrong + +## C1 Indicators +- Multiple files across a service boundary +- New feature or significant behavior change +- Could affect service availability +- Needs human review for correctness +- Touching Ansible roles, ArgoCD manifests, or routing config + +## C2 Indicators +- Multi-phase work with ordering dependencies +- Spans multiple sessions or multiple services +- Requires prerequisite changes before the main goal +- User explicitly requests Mikado methodology +- Discovery-heavy work where the full scope isn't known upfront + +## Output Format + +``` +Classification: C0 / C1 / C2 +Confidence: high / medium / low +Rationale: <1-2 sentences> +Blast radius: +Risk factors: +``` + +If confidence is low, explain what additional information would help. When in doubt, classify one level higher (C0 → C1, C1 → C2). diff --git a/.claude/agents/doc-reviewer.md b/.claude/agents/doc-reviewer.md new file mode 100644 index 0000000..7e73f8b --- /dev/null +++ b/.claude/agents/doc-reviewer.md @@ -0,0 +1,62 @@ +--- +name: doc-reviewer +description: Documentation reviewer with persistent memory. Use when the user wants to review a doc, run a docs review cycle, or asks about documentation staleness. Reviews docs for accuracy, links, and structure. +tools: Read, Glob, Grep, Bash +model: sonnet +memory: project +--- + +You are a documentation reviewer for the BlumeOps homelab infrastructure project. + +## Workflow + +1. Run `mise run docs-review` to see the staleness table and identify the most stale doc +2. Read the identified doc thoroughly +3. Perform the review checklist (below) +4. Check your agent memory for notes from past reviews of this doc or related docs +5. Present your findings as a structured report +6. Update your agent memory with anything you learned + +## Review Checklist + +For each doc, evaluate: + +- **Accuracy:** Is the information still correct? Cross-reference with actual source files (manifests, playbooks, configs) when possible +- **Wiki-links:** Do all `[[wiki-links]]` point to existing docs? Run `mise run docs-check-links` if unsure +- **Cross-references:** Should this doc link to other related docs that it doesn't currently reference? +- **Structure:** Is the doc in the right Diataxis category (reference/how-to/explanation/tutorial)? +- **Frontmatter:** Are tags, title, and dates correct? +- **Size:** Is the doc too large (should split) or too small (should merge)? +- **Staleness signals:** Are there version numbers, URLs, or process descriptions that may have drifted + +## Output Format + +Present findings as: +1. **One-line verdict:** healthy / needs minor updates / needs significant revision +2. **Issues found** (if any), grouped by severity +3. **Suggested changes** — be specific about what to change and where +4. **Proposed frontmatter update** — the `last-reviewed: YYYY-MM-DD` line to add + +## Memory Guidelines + +After each review, save notes about: +- Recurring issues you've seen across docs (e.g., "many docs still reference old routing pattern") +- Docs that reference each other and should be reviewed together +- Services or areas where documentation tends to drift fastest + +Before each review, check your memory for relevant context. + +## Important + +- Do NOT edit files directly. Present your findings so the main conversation can implement changes. +- Wiki-link format: `[[card-stem]]` — prefer simple links without alternate text unless grammatically needed. +- The docs directory is at `docs/` with Diataxis structure (reference/, how-to/, explanation/, tutorials/). + +## Handoff to Main Conversation + +Your output goes back to the main conversation, which will: +1. Present your findings to the user +2. Offer to implement the suggested changes +3. Run `mise run docs-preview` for visual verification before committing + +So make your suggested changes **specific and actionable** — include exact text replacements, frontmatter updates, and wiki-links to add/fix. The main conversation needs enough detail to implement without re-reading the entire doc. diff --git a/.claude/agents/infra-health.md b/.claude/agents/infra-health.md new file mode 100644 index 0000000..94bf14f --- /dev/null +++ b/.claude/agents/infra-health.md @@ -0,0 +1,36 @@ +--- +name: infra-health +description: Infrastructure health monitor. Use proactively after deployments, provisioning, or when the user asks about service status. Runs services-check and diagnoses failures. +tools: Bash, Read, Grep, Glob +model: haiku +permissionMode: dontAsk +background: true +--- + +You are an infrastructure health monitor for the BlumeOps homelab. + +When invoked, run the full health check suite and report results: + +1. Run `mise run services-check` and capture the full output +2. Parse the results — identify any FAILED services +3. For each failure, provide a brief diagnosis: + - Is the service process down? + - Is it a network/connectivity issue? + - Is it an ArgoCD sync issue? +4. Summarize: total services checked, how many passed, how many failed + +If everything is healthy, keep the summary to one line. + +If there are failures, group them by category: +- **Process failures** (service not running) +- **HTTP failures** (endpoint not responding) +- **Kubernetes failures** (pod not running, sync issues) +- **Connectivity failures** (SSH, network) + +Do NOT attempt to fix anything. Report findings only. + +Context: +- Services run across indri (Mac Mini, native + minikube), ringtail (NixOS, k3s), and Fly.io +- Use `--context=minikube-indri` for indri k8s commands, `--context=k3s-ringtail` for ringtail +- HTTP endpoints are proxied through Caddy at `*.ops.eblu.me` +- Public endpoints go through Fly.io at `*.eblu.me` diff --git a/.claude/agents/mikado-navigator.md b/.claude/agents/mikado-navigator.md new file mode 100644 index 0000000..1bd0176 --- /dev/null +++ b/.claude/agents/mikado-navigator.md @@ -0,0 +1,69 @@ +--- +name: mikado-navigator +description: Mikado chain navigator for C2 changes. Use when resuming a C2 chain, checking chain status, or deciding which leaf node to work next. Understands the Mikado Branch Invariant. +tools: Read, Glob, Grep, Bash +model: sonnet +permissionMode: dontAsk +--- + +You are a Mikado chain navigator for the BlumeOps C2 change process. You help the user understand the current state of a Mikado chain and decide what to do next. + +## What You Do + +1. Run `mise run docs-mikado --resume` to detect the current chain state +2. Read the relevant Mikado cards (docs in `docs/how-to/` with `status: active`) +3. Analyze the dependency graph and branch position +4. Recommend the next action + +## Chain State Analysis + +After running `docs-mikado --resume`, interpret the output: + +- **Planning phase:** Cards are being added, no code yet. Suggest reviewing the dependency graph for completeness. +- **Mid-cycle:** An `impl` is in progress. Identify which leaf is being worked and what remains. +- **Between cycles:** A leaf was just closed. Identify the next ready leaf and summarize what it requires. +- **Finalized:** The chain is complete and awaiting merge. +- **Invariant violation:** A plan commit was found after impl. Explain the reset procedure. + +## Recommending Next Actions + +For each ready leaf node: +1. Read the card content to understand what it requires +2. Check if there are related source files (manifests, playbooks, configs) +3. Assess relative complexity and suggest an ordering if multiple leaves are ready +4. Note any potential risks or dependencies not captured in the card graph + +## The Mikado Branch Invariant + +The branch must always have this structure: +``` +main <- [plan commits] <- [impl, close] <- [impl, close] <- ... <- [finalize] +``` + +Rules: +- First N commits are card-only (plan phase) +- Then repeating cycles of impl + close +- No card introductions after any code commit +- New prerequisites require a branch reset + +## Output Format + +``` +Chain: +Branch: +Position: +PR: # (if exists) + +Ready leaves: + 1. — <brief description of work needed> + 2. ... + +Recommendation: <what to do next and why> +``` + +## Important + +- Do NOT make any changes. You are advisory only. +- If the user is on `main`, list all active chains and suggest which to resume. +- If PR comments exist, remind the user to check them with `mise run pr-comments <number>`. +- Check for stashed work — resets sometimes leave stashed changes. diff --git a/docs/changelog.d/+claude-code-subagents.ai.md b/docs/changelog.d/+claude-code-subagents.ai.md new file mode 100644 index 0000000..584231b --- /dev/null +++ b/docs/changelog.d/+claude-code-subagents.ai.md @@ -0,0 +1 @@ +Add four Claude Code subagents: infra-health (background health monitor), doc-reviewer (persistent-memory doc review), change-classifier (C0/C1/C2 triage), and mikado-navigator (C2 chain state advisor). From 6f88baeb9120f061dcc3f10b647b1cdd5da8d7b7 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Wed, 18 Mar 2026 12:40:44 -0700 Subject: [PATCH 084/430] Fix Grafana starred dashboards lost on pod restart Add init container to pre-populate ConfigMap dashboards before Grafana starts, eliminating the race between the sidecar and the provisioner that caused dashboard DB records to be deleted and re-created with new IDs. Also stamp stable UIDs on TeslaMate and UnPoller dashboards fetched from upstream. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- argocd/manifests/grafana/deployment.yaml | 40 ++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/argocd/manifests/grafana/deployment.yaml b/argocd/manifests/grafana/deployment.yaml index 86e7eb7..2f4c7b8 100644 --- a/argocd/manifests/grafana/deployment.yaml +++ b/argocd/manifests/grafana/deployment.yaml @@ -80,6 +80,11 @@ spec: ; do wget -q -O "$DEST/$f" "$BASE_URL/$f" done + # Stamp stable UIDs so stars/bookmarks survive pod restarts + for f in "$DEST"/*.json; do + uid="teslamate-$(basename "$f" .json)" + sed -i "s/\"uid\": *\"[^\"]*\"/\"uid\": \"${uid}\"/" "$f" + done echo "Fetched $(ls "$DEST" | wc -l) TeslaMate dashboards" securityContext: allowPrivilegeEscalation: false @@ -115,6 +120,12 @@ spec: # Fix datasource UIDs to match our Prometheus instance sed -i 's/"uid": *"bdkj55oguty4gd"/"uid": "prometheus"/g' "$DEST"/*.json sed -i 's/"uid": *"\${DS_PROMETHEUS}"/"uid": "prometheus"/g' "$DEST"/*.json + # Stamp stable UIDs so stars/bookmarks survive pod restarts + for f in "$DEST"/*.json; do + slug=$(basename "$f" .json | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//') + uid="unpoller-${slug}" + sed -i "s/\"uid\": *\"[^\"]*\"/\"uid\": \"${uid}\"/" "$f" + done echo "Fetched $(ls "$DEST" | wc -l) UnPoller dashboards" securityContext: allowPrivilegeEscalation: false @@ -125,6 +136,35 @@ spec: volumeMounts: - name: sc-dashboard-volume mountPath: /tmp/dashboards + # Pre-populate ConfigMap dashboards so they exist before Grafana starts. + # Without this, the sidecar and Grafana race: if the provisioner scans + # before the sidecar writes files, it deletes existing DB records and + # re-creates them with new IDs, breaking starred dashboards. + - name: init-configmap-dashboards + image: registry.ops.eblu.me/blumeops/grafana-sidecar:kustomized + imagePullPolicy: IfNotPresent + env: + - name: METHOD + value: LIST + - name: LABEL + value: grafana_dashboard + - name: LABEL_VALUE + value: "1" + - name: FOLDER + value: /tmp/dashboards + - name: RESOURCE + value: both + - name: FOLDER_ANNOTATION + value: grafana_folder + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + seccompProfile: + type: RuntimeDefault + volumeMounts: + - name: sc-dashboard-volume + mountPath: /tmp/dashboards containers: # Dashboard sidecar - watches ConfigMaps with grafana_dashboard=1 - name: grafana-sc-dashboard From 334fbbb9e3d1c71f4e42593a3c37036bf68171d2 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Wed, 18 Mar 2026 12:53:00 -0700 Subject: [PATCH 085/430] Fix TeslaMate/UnPoller dashboard UID sed clobbering datasource refs The previous sed replaced ALL "uid" fields in dashboard JSON files, including datasource references inside panels, causing dashboards to go dark. Scope the replacement to only the first occurrence (the top-level dashboard UID) using GNU sed 0,/pattern/ addressing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- argocd/manifests/grafana/deployment.yaml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/argocd/manifests/grafana/deployment.yaml b/argocd/manifests/grafana/deployment.yaml index 2f4c7b8..3fda945 100644 --- a/argocd/manifests/grafana/deployment.yaml +++ b/argocd/manifests/grafana/deployment.yaml @@ -80,10 +80,12 @@ spec: ; do wget -q -O "$DEST/$f" "$BASE_URL/$f" done - # Stamp stable UIDs so stars/bookmarks survive pod restarts + # Stamp stable top-level UIDs so stars/bookmarks survive pod restarts + # Uses 0,/pattern/ to replace only the first "uid" (the dashboard UID), + # leaving datasource UIDs inside panels untouched. for f in "$DEST"/*.json; do uid="teslamate-$(basename "$f" .json)" - sed -i "s/\"uid\": *\"[^\"]*\"/\"uid\": \"${uid}\"/" "$f" + sed -i "0,/\"uid\": *\"[^\"]*\"/{s/\"uid\": *\"[^\"]*\"/\"uid\": \"${uid}\"/}" "$f" done echo "Fetched $(ls "$DEST" | wc -l) TeslaMate dashboards" securityContext: @@ -120,11 +122,11 @@ spec: # Fix datasource UIDs to match our Prometheus instance sed -i 's/"uid": *"bdkj55oguty4gd"/"uid": "prometheus"/g' "$DEST"/*.json sed -i 's/"uid": *"\${DS_PROMETHEUS}"/"uid": "prometheus"/g' "$DEST"/*.json - # Stamp stable UIDs so stars/bookmarks survive pod restarts + # Stamp stable top-level UIDs so stars/bookmarks survive pod restarts for f in "$DEST"/*.json; do slug=$(basename "$f" .json | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//') uid="unpoller-${slug}" - sed -i "s/\"uid\": *\"[^\"]*\"/\"uid\": \"${uid}\"/" "$f" + sed -i "0,/\"uid\": *\"[^\"]*\"/{s/\"uid\": *\"[^\"]*\"/\"uid\": \"${uid}\"/}" "$f" done echo "Fetched $(ls "$DEST" | wc -l) UnPoller dashboards" securityContext: From c92b949a205216149c0305b9ac85aa05411748b2 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Wed, 18 Mar 2026 12:56:50 -0700 Subject: [PATCH 086/430] Fix UID sed to target root-level dashboard uid only The top-level "uid" in Grafana dashboard JSON is at 2-space indent near the end of the file, not the first occurrence. Match on ^ "uid" to avoid clobbering nested datasource uid references. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- argocd/manifests/grafana/deployment.yaml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/argocd/manifests/grafana/deployment.yaml b/argocd/manifests/grafana/deployment.yaml index 3fda945..61a2f88 100644 --- a/argocd/manifests/grafana/deployment.yaml +++ b/argocd/manifests/grafana/deployment.yaml @@ -80,12 +80,11 @@ spec: ; do wget -q -O "$DEST/$f" "$BASE_URL/$f" done - # Stamp stable top-level UIDs so stars/bookmarks survive pod restarts - # Uses 0,/pattern/ to replace only the first "uid" (the dashboard UID), - # leaving datasource UIDs inside panels untouched. + # Stamp stable top-level UIDs so stars/bookmarks survive pod restarts. + # Match root-level uid (2-space indent) to avoid clobbering datasource refs. for f in "$DEST"/*.json; do uid="teslamate-$(basename "$f" .json)" - sed -i "0,/\"uid\": *\"[^\"]*\"/{s/\"uid\": *\"[^\"]*\"/\"uid\": \"${uid}\"/}" "$f" + sed -i "s/^ \"uid\": *\"[^\"]*\"/ \"uid\": \"${uid}\"/" "$f" done echo "Fetched $(ls "$DEST" | wc -l) TeslaMate dashboards" securityContext: @@ -122,11 +121,12 @@ spec: # Fix datasource UIDs to match our Prometheus instance sed -i 's/"uid": *"bdkj55oguty4gd"/"uid": "prometheus"/g' "$DEST"/*.json sed -i 's/"uid": *"\${DS_PROMETHEUS}"/"uid": "prometheus"/g' "$DEST"/*.json - # Stamp stable top-level UIDs so stars/bookmarks survive pod restarts + # Stamp stable top-level UIDs so stars/bookmarks survive pod restarts. + # Match root-level uid (2-space indent) to avoid clobbering datasource refs. for f in "$DEST"/*.json; do slug=$(basename "$f" .json | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//') uid="unpoller-${slug}" - sed -i "0,/\"uid\": *\"[^\"]*\"/{s/\"uid\": *\"[^\"]*\"/\"uid\": \"${uid}\"/}" "$f" + sed -i "s/^ \"uid\": *\"[^\"]*\"/ \"uid\": \"${uid}\"/" "$f" done echo "Fetched $(ls "$DEST" | wc -l) UnPoller dashboards" securityContext: From 613f05dfdea0148484c3b72431544f5967defd29 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Wed, 18 Mar 2026 20:42:00 -0700 Subject: [PATCH 087/430] Add consistent OCI labels to all container Dockerfiles Every container now carries title, description, version, source, and vendor labels per the OCI image spec. Version is derived from the existing CONTAINER_APP_VERSION ARG at build time. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- containers/alloy/Dockerfile | 7 +++++-- containers/cv/Dockerfile | 7 +++++++ containers/devpi/Dockerfile | 6 ++++++ containers/grafana-sidecar/Dockerfile | 5 ++++- containers/grafana/Dockerfile | 5 ++++- containers/homepage/Dockerfile | 7 +++++-- containers/kiwix-serve/Dockerfile | 7 +++++++ containers/kubectl/Dockerfile | 7 +++++++ containers/loki/Dockerfile | 7 +++++-- containers/mealie/Dockerfile | 5 ++++- containers/miniflux/Dockerfile | 7 +++++-- containers/navidrome/Dockerfile | 8 +++++--- containers/nettest/Dockerfile | 7 +++++++ containers/ntfy/Dockerfile | 7 +++++-- containers/prometheus/Dockerfile | 7 +++++-- containers/quartz/Dockerfile | 7 +++++++ containers/runner-job-image/Dockerfile | 6 ++++++ containers/teslamate/Dockerfile | 7 +++++++ containers/transmission-exporter/Dockerfile | 4 ++++ containers/transmission/Dockerfile | 6 ++++++ containers/unpoller/Dockerfile | 5 ++++- docs/changelog.d/+oci-labels.infra.md | 1 + 22 files changed, 116 insertions(+), 19 deletions(-) create mode 100644 docs/changelog.d/+oci-labels.infra.md diff --git a/containers/alloy/Dockerfile b/containers/alloy/Dockerfile index e9bae40..f2f30f6 100644 --- a/containers/alloy/Dockerfile +++ b/containers/alloy/Dockerfile @@ -48,9 +48,12 @@ RUN RELEASE_BUILD=1 VERSION=${ALLOY_VERSION} \ FROM alpine:3.22 -LABEL org.opencontainers.image.title=alloy +ARG CONTAINER_APP_VERSION +LABEL org.opencontainers.image.title="Alloy" LABEL org.opencontainers.image.description="Grafana Alloy is an OpenTelemetry Collector distribution" -LABEL org.opencontainers.image.source=https://github.com/grafana/alloy +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" RUN apk --no-cache add ca-certificates tzdata \ && addgroup -g 473 alloy \ diff --git a/containers/cv/Dockerfile b/containers/cv/Dockerfile index 517e387..9bfebe0 100644 --- a/containers/cv/Dockerfile +++ b/containers/cv/Dockerfile @@ -10,6 +10,13 @@ ARG CONTAINER_APP_VERSION=1.0.3 FROM nginx:alpine +ARG CONTAINER_APP_VERSION +LABEL org.opencontainers.image.title="CV" +LABEL org.opencontainers.image.description="Static site server for CV/resume" +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" + # Install curl for downloading release assets RUN apk add --no-cache curl diff --git a/containers/devpi/Dockerfile b/containers/devpi/Dockerfile index 6a881e7..69e14c3 100644 --- a/containers/devpi/Dockerfile +++ b/containers/devpi/Dockerfile @@ -4,6 +4,12 @@ FROM python:3.12-slim ARG CONTAINER_APP_VERSION ARG DEVPI_SERVER_VERSION=${CONTAINER_APP_VERSION} + +LABEL org.opencontainers.image.title="devpi" +LABEL org.opencontainers.image.description="devpi PyPI server and caching proxy" +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" ARG DEVPI_WEB_VERSION=5.0.1 # Install devpi-server and devpi-web diff --git a/containers/grafana-sidecar/Dockerfile b/containers/grafana-sidecar/Dockerfile index e3f83c8..28dd983 100644 --- a/containers/grafana-sidecar/Dockerfile +++ b/containers/grafana-sidecar/Dockerfile @@ -20,9 +20,12 @@ RUN python -m venv .venv && \ FROM base +ARG CONTAINER_APP_VERSION LABEL org.opencontainers.image.title="Grafana Sidecar" LABEL org.opencontainers.image.description="K8s sidecar to sync ConfigMap dashboards into Grafana" -LABEL org.opencontainers.image.source="https://github.com/kiwigrid/k8s-sidecar" +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" ENV PYTHONUNBUFFERED=1 WORKDIR /app diff --git a/containers/grafana/Dockerfile b/containers/grafana/Dockerfile index f89adda..3d5b12b 100644 --- a/containers/grafana/Dockerfile +++ b/containers/grafana/Dockerfile @@ -51,9 +51,12 @@ USER grafana WORKDIR /usr/share/grafana EXPOSE 3000 +ARG CONTAINER_APP_VERSION LABEL org.opencontainers.image.title="Grafana" LABEL org.opencontainers.image.description="Grafana OSS observability platform" -LABEL org.opencontainers.image.source="https://github.com/grafana/grafana" +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 ["/usr/bin/dumb-init", "--"] CMD ["grafana", "server", \ diff --git a/containers/homepage/Dockerfile b/containers/homepage/Dockerfile index a3133ad..31b72f9 100644 --- a/containers/homepage/Dockerfile +++ b/containers/homepage/Dockerfile @@ -21,9 +21,12 @@ RUN mkdir -p config \ FROM node:24-alpine -LABEL org.opencontainers.image.title=Homepage +ARG CONTAINER_APP_VERSION +LABEL org.opencontainers.image.title="Homepage" LABEL org.opencontainers.image.description="A self-hosted services landing page" -LABEL org.opencontainers.image.source=https://github.com/gethomepage/homepage +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" WORKDIR /app diff --git a/containers/kiwix-serve/Dockerfile b/containers/kiwix-serve/Dockerfile index 5fe6df7..17167e5 100644 --- a/containers/kiwix-serve/Dockerfile +++ b/containers/kiwix-serve/Dockerfile @@ -38,6 +38,13 @@ RUN set -e && \ curl -k -L $url | tar -xz -C /usr/local/bin/ --strip-components 1 && \ apk del curl +ARG CONTAINER_APP_VERSION +LABEL org.opencontainers.image.title="kiwix-serve" +LABEL org.opencontainers.image.description="Kiwix content server for offline ZIM files" +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" + EXPOSE 80 # Run as non-root diff --git a/containers/kubectl/Dockerfile b/containers/kubectl/Dockerfile index ef37e20..bcec94a 100644 --- a/containers/kubectl/Dockerfile +++ b/containers/kubectl/Dockerfile @@ -27,6 +27,13 @@ RUN apk add --no-cache curl && \ FROM alpine:3.22 +ARG CONTAINER_APP_VERSION +LABEL org.opencontainers.image.title="kubectl" +LABEL org.opencontainers.image.description="Minimal kubectl container" +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" + COPY --from=downloader /kubectl /usr/local/bin/kubectl # Add ca-certificates for HTTPS connections and bash for scripts diff --git a/containers/loki/Dockerfile b/containers/loki/Dockerfile index 5edb71b..518c493 100644 --- a/containers/loki/Dockerfile +++ b/containers/loki/Dockerfile @@ -25,9 +25,12 @@ RUN go build -tags netgo \ FROM alpine:3.22 -LABEL org.opencontainers.image.title=Loki +ARG CONTAINER_APP_VERSION +LABEL org.opencontainers.image.title="Loki" LABEL org.opencontainers.image.description="Grafana Loki log aggregation system" -LABEL org.opencontainers.image.source=https://github.com/grafana/loki +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" RUN apk add --no-cache ca-certificates tzdata RUN mkdir -p /loki && chown 10001:10001 /loki diff --git a/containers/mealie/Dockerfile b/containers/mealie/Dockerfile index fe1bf02..8df38bf 100644 --- a/containers/mealie/Dockerfile +++ b/containers/mealie/Dockerfile @@ -135,8 +135,11 @@ 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.source="https://github.com/mealie-recipes/mealie" +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"] diff --git a/containers/miniflux/Dockerfile b/containers/miniflux/Dockerfile index 83a1034..4e987cc 100644 --- a/containers/miniflux/Dockerfile +++ b/containers/miniflux/Dockerfile @@ -18,9 +18,12 @@ RUN make miniflux FROM alpine:3.22 -LABEL org.opencontainers.image.title=Miniflux +ARG CONTAINER_APP_VERSION +LABEL org.opencontainers.image.title="Miniflux" LABEL org.opencontainers.image.description="Miniflux is a minimalist and opinionated feed reader" -LABEL org.opencontainers.image.source=https://github.com/miniflux/v2 +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" EXPOSE 8080 ENV LISTEN_ADDR=0.0.0.0:8080 diff --git a/containers/navidrome/Dockerfile b/containers/navidrome/Dockerfile index 285fd06..7f78d36 100644 --- a/containers/navidrome/Dockerfile +++ b/containers/navidrome/Dockerfile @@ -38,10 +38,12 @@ RUN go build -tags=netgo \ FROM alpine:3.22 -LABEL org.opencontainers.image.title=Navidrome +ARG CONTAINER_APP_VERSION +LABEL org.opencontainers.image.title="Navidrome" LABEL org.opencontainers.image.description="Navidrome is a self-hosted music server and streamer" -# Points to upstream canonical source, not the forge mirror used for builds -LABEL org.opencontainers.image.source=https://github.com/navidrome/navidrome +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" RUN apk add --no-cache ca-certificates tzdata taglib ffmpeg \ && addgroup -g 1000 navidrome \ diff --git a/containers/nettest/Dockerfile b/containers/nettest/Dockerfile index 2f6991b..4bb1284 100644 --- a/containers/nettest/Dockerfile +++ b/containers/nettest/Dockerfile @@ -8,6 +8,13 @@ ARG CONTAINER_APP_VERSION=0.1.0 FROM alpine:3.22 +ARG CONTAINER_APP_VERSION +LABEL org.opencontainers.image.title="nettest" +LABEL org.opencontainers.image.description="Network connectivity test container for CI/CD debugging" +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" + RUN apk add --no-cache \ curl \ ca-certificates \ diff --git a/containers/ntfy/Dockerfile b/containers/ntfy/Dockerfile index 3fa5c30..238e8c1 100644 --- a/containers/ntfy/Dockerfile +++ b/containers/ntfy/Dockerfile @@ -49,9 +49,12 @@ RUN go build \ FROM alpine:3.22 -LABEL org.opencontainers.image.title=ntfy +ARG CONTAINER_APP_VERSION +LABEL org.opencontainers.image.title="ntfy" LABEL org.opencontainers.image.description="ntfy is a simple HTTP-based pub-sub notification service" -LABEL org.opencontainers.image.source=https://github.com/binwiederhier/ntfy +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" RUN apk --no-cache add tzdata diff --git a/containers/prometheus/Dockerfile b/containers/prometheus/Dockerfile index 90be789..717293d 100644 --- a/containers/prometheus/Dockerfile +++ b/containers/prometheus/Dockerfile @@ -54,9 +54,12 @@ RUN go build -tags netgo,builtinassets \ FROM alpine:3.22 -LABEL org.opencontainers.image.title=Prometheus +ARG CONTAINER_APP_VERSION +LABEL org.opencontainers.image.title="Prometheus" LABEL org.opencontainers.image.description="Prometheus monitoring system and time series database" -LABEL org.opencontainers.image.source=https://github.com/prometheus/prometheus +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" RUN apk add --no-cache ca-certificates tzdata diff --git a/containers/quartz/Dockerfile b/containers/quartz/Dockerfile index 5d81920..8ffd44c 100644 --- a/containers/quartz/Dockerfile +++ b/containers/quartz/Dockerfile @@ -11,6 +11,13 @@ ARG NGINX_VERSION=${CONTAINER_APP_VERSION} FROM nginx:${NGINX_VERSION}-alpine +ARG CONTAINER_APP_VERSION +LABEL org.opencontainers.image.title="Quartz" +LABEL org.opencontainers.image.description="Static site server for Quartz-built documentation" +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" + # Install curl for downloading release assets RUN apk add --no-cache curl diff --git a/containers/runner-job-image/Dockerfile b/containers/runner-job-image/Dockerfile index b814339..0018c64 100644 --- a/containers/runner-job-image/Dockerfile +++ b/containers/runner-job-image/Dockerfile @@ -17,6 +17,12 @@ ARG TARGETARCH ARG CONTAINER_APP_VERSION ARG DAGGER_VERSION=${CONTAINER_APP_VERSION} +LABEL org.opencontainers.image.title="Runner Job Image" +LABEL org.opencontainers.image.description="Forgejo Actions job execution environment" +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" + # Install base dependencies RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates \ diff --git a/containers/teslamate/Dockerfile b/containers/teslamate/Dockerfile index 2624822..70c6d71 100644 --- a/containers/teslamate/Dockerfile +++ b/containers/teslamate/Dockerfile @@ -45,6 +45,13 @@ RUN SKIP_LOCALE_DOWNLOAD=true mix release --path /opt/built # Runtime image FROM debian:trixie-slim AS app +ARG CONTAINER_APP_VERSION +LABEL org.opencontainers.image.title="TeslaMate" +LABEL org.opencontainers.image.description="Tesla data logger and visualization" +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" + ENV LANG=C.UTF-8 \ SRTM_CACHE=/opt/app/.srtm_cache \ HOME=/opt/app diff --git a/containers/transmission-exporter/Dockerfile b/containers/transmission-exporter/Dockerfile index 6a2f2dd..c9d0655 100644 --- a/containers/transmission-exporter/Dockerfile +++ b/containers/transmission-exporter/Dockerfile @@ -5,8 +5,12 @@ ARG CONTAINER_APP_VERSION=1.0.1 FROM python:3.13-alpine3.23 +ARG CONTAINER_APP_VERSION LABEL org.opencontainers.image.title="Transmission Exporter" LABEL org.opencontainers.image.description="Prometheus exporter for Transmission BitTorrent client" +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" COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv diff --git a/containers/transmission/Dockerfile b/containers/transmission/Dockerfile index 67ffab2..6d7bcab 100644 --- a/containers/transmission/Dockerfile +++ b/containers/transmission/Dockerfile @@ -8,6 +8,12 @@ FROM alpine:3.22 ARG CONTAINER_APP_VERSION ARG TRANSMISSION_VERSION=${CONTAINER_APP_VERSION} +LABEL org.opencontainers.image.title="Transmission" +LABEL org.opencontainers.image.description="Transmission BitTorrent daemon" +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" + # Transmission 4.1.x is only in edge; base OS stays on stable 3.22 RUN apk add --no-cache \ --repository=https://dl-cdn.alpinelinux.org/alpine/edge/community \ diff --git a/containers/unpoller/Dockerfile b/containers/unpoller/Dockerfile index 0391f6d..241b375 100644 --- a/containers/unpoller/Dockerfile +++ b/containers/unpoller/Dockerfile @@ -26,9 +26,12 @@ RUN go build -ldflags="-s -w \ FROM alpine:3.22 +ARG CONTAINER_APP_VERSION LABEL org.opencontainers.image.title="UnPoller" LABEL org.opencontainers.image.description="UniFi metrics exporter for Prometheus" -LABEL org.opencontainers.image.source="https://github.com/unpoller/unpoller" +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" RUN apk add --no-cache ca-certificates tzdata diff --git a/docs/changelog.d/+oci-labels.infra.md b/docs/changelog.d/+oci-labels.infra.md new file mode 100644 index 0000000..a09b3a8 --- /dev/null +++ b/docs/changelog.d/+oci-labels.infra.md @@ -0,0 +1 @@ +Standardize OCI labels across all container Dockerfiles with consistent title, description, version, source, and vendor metadata. From 3d2a97aaf93018a5f8e84d2de9b0c1e8830187ee Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Thu, 19 Mar 2026 06:34:12 -0700 Subject: [PATCH 088/430] Update kustomization tags to OCI-labeled builds (613f05d) Point all services at the 613f05d images which carry the new consistent OCI labels. Skipped kiwix/transmission (old v4.0.6-r4 version, no matching build) and docs/quartz (no 613f05d build). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- argocd/manifests/alloy-k8s/kustomization.yaml | 2 +- argocd/manifests/alloy-ringtail/kustomization.yaml | 2 +- argocd/manifests/alloy-tracing-ringtail/kustomization.yaml | 2 +- argocd/manifests/cv/kustomization.yaml | 2 +- argocd/manifests/devpi/kustomization.yaml | 2 +- argocd/manifests/grafana/kustomization.yaml | 4 ++-- argocd/manifests/homepage/kustomization.yaml | 2 +- argocd/manifests/kiwix/kustomization.yaml | 4 ++-- argocd/manifests/loki/kustomization.yaml | 2 +- argocd/manifests/mealie/kustomization.yaml | 2 +- argocd/manifests/miniflux/kustomization.yaml | 2 +- argocd/manifests/navidrome/kustomization.yaml | 2 +- argocd/manifests/ntfy/kustomization.yaml | 2 +- argocd/manifests/prometheus/kustomization.yaml | 2 +- argocd/manifests/teslamate/kustomization.yaml | 2 +- argocd/manifests/torrent/kustomization.yaml | 4 ++-- argocd/manifests/unpoller/kustomization.yaml | 2 +- 17 files changed, 20 insertions(+), 20 deletions(-) diff --git a/argocd/manifests/alloy-k8s/kustomization.yaml b/argocd/manifests/alloy-k8s/kustomization.yaml index 885d06d..ad0fc0b 100644 --- a/argocd/manifests/alloy-k8s/kustomization.yaml +++ b/argocd/manifests/alloy-k8s/kustomization.yaml @@ -10,7 +10,7 @@ resources: images: - name: registry.ops.eblu.me/blumeops/alloy - newTag: v1.14.0-61f02a0 + newTag: v1.14.0-613f05d configMapGenerator: - name: alloy-config diff --git a/argocd/manifests/alloy-ringtail/kustomization.yaml b/argocd/manifests/alloy-ringtail/kustomization.yaml index 0d71f55..dbecb6a 100644 --- a/argocd/manifests/alloy-ringtail/kustomization.yaml +++ b/argocd/manifests/alloy-ringtail/kustomization.yaml @@ -10,7 +10,7 @@ resources: images: - name: registry.ops.eblu.me/blumeops/alloy - newTag: v1.14.0-61f02a0-nix + newTag: v1.14.0-613f05d-nix configMapGenerator: - name: alloy-config diff --git a/argocd/manifests/alloy-tracing-ringtail/kustomization.yaml b/argocd/manifests/alloy-tracing-ringtail/kustomization.yaml index b402a18..4dda1f7 100644 --- a/argocd/manifests/alloy-tracing-ringtail/kustomization.yaml +++ b/argocd/manifests/alloy-tracing-ringtail/kustomization.yaml @@ -9,7 +9,7 @@ resources: images: - name: registry.ops.eblu.me/blumeops/alloy - newTag: v1.14.0-61f02a0-nix + newTag: v1.14.0-613f05d-nix configMapGenerator: - name: alloy-tracing-config diff --git a/argocd/manifests/cv/kustomization.yaml b/argocd/manifests/cv/kustomization.yaml index 21e5a1f..199108d 100644 --- a/argocd/manifests/cv/kustomization.yaml +++ b/argocd/manifests/cv/kustomization.yaml @@ -9,4 +9,4 @@ resources: - pdb.yaml images: - name: registry.ops.eblu.me/blumeops/cv - newTag: v1.0.3-ffa8727 + newTag: v1.0.3-613f05d diff --git a/argocd/manifests/devpi/kustomization.yaml b/argocd/manifests/devpi/kustomization.yaml index 1943401..a9cc2a4 100644 --- a/argocd/manifests/devpi/kustomization.yaml +++ b/argocd/manifests/devpi/kustomization.yaml @@ -11,4 +11,4 @@ resources: images: - name: registry.ops.eblu.me/blumeops/devpi - newTag: v6.19.1-ffa8727 + newTag: v6.19.1-613f05d diff --git a/argocd/manifests/grafana/kustomization.yaml b/argocd/manifests/grafana/kustomization.yaml index e307db1..c052bf9 100644 --- a/argocd/manifests/grafana/kustomization.yaml +++ b/argocd/manifests/grafana/kustomization.yaml @@ -16,9 +16,9 @@ images: - name: docker.io/library/busybox newTag: 1.31.1 - name: registry.ops.eblu.me/blumeops/grafana-sidecar - newTag: v1.28.0-a2bb9ab + newTag: v1.28.0-613f05d - name: registry.ops.eblu.me/blumeops/grafana - newTag: v12.3.3-d05d2fb + newTag: v12.3.3-613f05d configMapGenerator: - name: grafana diff --git a/argocd/manifests/homepage/kustomization.yaml b/argocd/manifests/homepage/kustomization.yaml index ee794ad..f70d347 100644 --- a/argocd/manifests/homepage/kustomization.yaml +++ b/argocd/manifests/homepage/kustomization.yaml @@ -17,7 +17,7 @@ resources: images: - name: registry.ops.eblu.me/blumeops/homepage - newTag: v1.10.1-cd57814 + newTag: v1.10.1-613f05d configMapGenerator: - name: homepage-config diff --git a/argocd/manifests/kiwix/kustomization.yaml b/argocd/manifests/kiwix/kustomization.yaml index 2f940b6..c89eec7 100644 --- a/argocd/manifests/kiwix/kustomization.yaml +++ b/argocd/manifests/kiwix/kustomization.yaml @@ -10,11 +10,11 @@ resources: images: - name: registry.ops.eblu.me/blumeops/kiwix-serve - newTag: v3.8.2-f6f0f79 + newTag: v3.8.2-613f05d - name: registry.ops.eblu.me/blumeops/transmission newTag: v4.0.6-r4-ffa8727 - name: registry.ops.eblu.me/blumeops/kubectl - newTag: v1.34.4-a72a0d8 + newTag: v1.34.4-613f05d configMapGenerator: - name: kiwix-zim-torrents diff --git a/argocd/manifests/loki/kustomization.yaml b/argocd/manifests/loki/kustomization.yaml index 3036340..a5a0303 100644 --- a/argocd/manifests/loki/kustomization.yaml +++ b/argocd/manifests/loki/kustomization.yaml @@ -11,7 +11,7 @@ resources: images: - name: grafana/loki newName: registry.ops.eblu.me/blumeops/loki - newTag: "v3.6.5-3dc4ed7" + newTag: v3.6.5-613f05d configMapGenerator: - name: loki-config diff --git a/argocd/manifests/mealie/kustomization.yaml b/argocd/manifests/mealie/kustomization.yaml index 40dd4f2..fb0713b 100644 --- a/argocd/manifests/mealie/kustomization.yaml +++ b/argocd/manifests/mealie/kustomization.yaml @@ -12,4 +12,4 @@ resources: images: - name: registry.ops.eblu.me/blumeops/mealie - newTag: v3.12.0-11330eb + newTag: v3.12.0-613f05d diff --git a/argocd/manifests/miniflux/kustomization.yaml b/argocd/manifests/miniflux/kustomization.yaml index a3c53ce..8207d55 100644 --- a/argocd/manifests/miniflux/kustomization.yaml +++ b/argocd/manifests/miniflux/kustomization.yaml @@ -10,4 +10,4 @@ resources: images: - name: registry.ops.eblu.me/blumeops/miniflux - newTag: v2.2.17-33b7f0f + newTag: v2.2.17-613f05d diff --git a/argocd/manifests/navidrome/kustomization.yaml b/argocd/manifests/navidrome/kustomization.yaml index e100d89..df5677e 100644 --- a/argocd/manifests/navidrome/kustomization.yaml +++ b/argocd/manifests/navidrome/kustomization.yaml @@ -11,4 +11,4 @@ resources: - ingress-tailscale.yaml images: - name: registry.ops.eblu.me/blumeops/navidrome - newTag: v0.60.3-cd57814 + newTag: v0.60.3-613f05d diff --git a/argocd/manifests/ntfy/kustomization.yaml b/argocd/manifests/ntfy/kustomization.yaml index 226cde2..aa4355f 100644 --- a/argocd/manifests/ntfy/kustomization.yaml +++ b/argocd/manifests/ntfy/kustomization.yaml @@ -8,7 +8,7 @@ resources: - ingress-tailscale.yaml images: - name: registry.ops.eblu.me/blumeops/ntfy - newTag: v2.17.0-cd57814-nix + newTag: v2.17.0-613f05d-nix configMapGenerator: - name: ntfy-config files: diff --git a/argocd/manifests/prometheus/kustomization.yaml b/argocd/manifests/prometheus/kustomization.yaml index 66057c2..f9fc21a 100644 --- a/argocd/manifests/prometheus/kustomization.yaml +++ b/argocd/manifests/prometheus/kustomization.yaml @@ -10,7 +10,7 @@ resources: images: - name: registry.ops.eblu.me/blumeops/prometheus - newTag: v3.10.0-0d27797 + newTag: v3.10.0-613f05d configMapGenerator: - name: prometheus-config diff --git a/argocd/manifests/teslamate/kustomization.yaml b/argocd/manifests/teslamate/kustomization.yaml index 5e769cf..ac9e1ea 100644 --- a/argocd/manifests/teslamate/kustomization.yaml +++ b/argocd/manifests/teslamate/kustomization.yaml @@ -12,4 +12,4 @@ resources: images: - name: registry.ops.eblu.me/blumeops/teslamate - newTag: v3.0.0-eb9bc57 + newTag: v3.0.0-613f05d diff --git a/argocd/manifests/torrent/kustomization.yaml b/argocd/manifests/torrent/kustomization.yaml index d2394be..08e5414 100644 --- a/argocd/manifests/torrent/kustomization.yaml +++ b/argocd/manifests/torrent/kustomization.yaml @@ -10,6 +10,6 @@ resources: - ingress-tailscale.yaml images: - name: registry.ops.eblu.me/blumeops/transmission - newTag: v4.1.1-r1-ab34cbd + newTag: v4.1.1-r1-613f05d - name: registry.ops.eblu.me/blumeops/transmission-exporter - newTag: v1.0.1-c93448f + newTag: v1.0.1-613f05d diff --git a/argocd/manifests/unpoller/kustomization.yaml b/argocd/manifests/unpoller/kustomization.yaml index 142e748..5b7a9e2 100644 --- a/argocd/manifests/unpoller/kustomization.yaml +++ b/argocd/manifests/unpoller/kustomization.yaml @@ -10,7 +10,7 @@ resources: images: - name: registry.ops.eblu.me/blumeops/unpoller - newTag: v2.34.0-4dc3e5c + newTag: v2.34.0-613f05d configMapGenerator: - name: unpoller-config From 0f0ee2a3196036b7aed35f91675be5fecffcb7d0 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Thu, 19 Mar 2026 06:40:49 -0700 Subject: [PATCH 089/430] Update docs and kiwix kustomization tags to 613f05d builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also catches kiwix's transmission sidecar up from v4.0.6-r4 to v4.1.1-r1, matching the torrent service (upgraded in PR #282 but the kiwix sidecar was missed). No breaking changes — old RPC protocol is supported through 4.x. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- argocd/manifests/docs/kustomization.yaml | 2 +- argocd/manifests/kiwix/kustomization.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/argocd/manifests/docs/kustomization.yaml b/argocd/manifests/docs/kustomization.yaml index dcb03ac..a16185f 100644 --- a/argocd/manifests/docs/kustomization.yaml +++ b/argocd/manifests/docs/kustomization.yaml @@ -9,4 +9,4 @@ resources: - pdb.yaml images: - name: registry.ops.eblu.me/blumeops/quartz - newTag: v1.28.2-4f0476a + newTag: v1.28.2-613f05d diff --git a/argocd/manifests/kiwix/kustomization.yaml b/argocd/manifests/kiwix/kustomization.yaml index c89eec7..2af4065 100644 --- a/argocd/manifests/kiwix/kustomization.yaml +++ b/argocd/manifests/kiwix/kustomization.yaml @@ -12,7 +12,7 @@ images: - name: registry.ops.eblu.me/blumeops/kiwix-serve newTag: v3.8.2-613f05d - name: registry.ops.eblu.me/blumeops/transmission - newTag: v4.0.6-r4-ffa8727 + newTag: v4.1.1-r1-613f05d - name: registry.ops.eblu.me/blumeops/kubectl newTag: v1.34.4-613f05d From f9426b734ced1ce95e9cd7c8b81e65543e260f28 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Fri, 20 Mar 2026 16:02:28 -0700 Subject: [PATCH 090/430] Update loki to 3.6.7 (#302) Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/302 --- containers/loki/Dockerfile | 2 +- update-loki-3.6.7.infra.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 update-loki-3.6.7.infra.md diff --git a/containers/loki/Dockerfile b/containers/loki/Dockerfile index 518c493..8ef0b2a 100644 --- a/containers/loki/Dockerfile +++ b/containers/loki/Dockerfile @@ -1,7 +1,7 @@ # Grafana Loki log aggregation system # Two-stage build: Go binary, Alpine runtime -ARG CONTAINER_APP_VERSION=3.6.5 +ARG CONTAINER_APP_VERSION=3.6.7 ARG LOKI_VERSION=v${CONTAINER_APP_VERSION} FROM golang:alpine3.22 AS build diff --git a/update-loki-3.6.7.infra.md b/update-loki-3.6.7.infra.md new file mode 100644 index 0000000..c30a619 --- /dev/null +++ b/update-loki-3.6.7.infra.md @@ -0,0 +1 @@ +Update loki from 3.6.5 to 3.6.7 From 531a49abeb8f576bc989d25a4b46099eea11d54d Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Fri, 20 Mar 2026 16:06:29 -0700 Subject: [PATCH 091/430] C0 update deployment for loki to 3.6.7 --- argocd/manifests/loki/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/argocd/manifests/loki/kustomization.yaml b/argocd/manifests/loki/kustomization.yaml index a5a0303..dc86060 100644 --- a/argocd/manifests/loki/kustomization.yaml +++ b/argocd/manifests/loki/kustomization.yaml @@ -11,7 +11,7 @@ resources: images: - name: grafana/loki newName: registry.ops.eblu.me/blumeops/loki - newTag: v3.6.5-613f05d + newTag: v3.6.7-f9426b7 configMapGenerator: - name: loki-config From 810340a328f9380ff5d95e9b6c5e0852d3c5cf6b Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Fri, 20 Mar 2026 16:10:19 -0700 Subject: [PATCH 092/430] Update service-versions.yaml for loki --- service-versions.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/service-versions.yaml b/service-versions.yaml index 4ddf21f..8e5d0f3 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -20,8 +20,8 @@ services: - name: loki type: argocd - last-reviewed: 2026-02-16 - current-version: "3.6.5" + last-reviewed: 2026-03-20 + current-version: "3.6.7" upstream-source: https://github.com/grafana/loki/releases - name: kube-state-metrics From dcab489b607c0d3ce49c8a5da5e7ea874fbde324 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Sat, 21 Mar 2026 19:03:21 -0700 Subject: [PATCH 093/430] agent memory ignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index f6fe9e7..b39d114 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .claude/settings.local.json +.claude/agent-memory/ # Python __pycache__/ From f1620abb178295060e54078996101916157a042a Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Sun, 22 Mar 2026 09:55:53 -0700 Subject: [PATCH 094/430] Improve Frigate health checks to catch NFS and camera failures Replace single aggregate camera_fps check with per-camera FPS validation and NFS storage accessibility check. Motivated by an outage where Frigate API responded OK but NFS mount was inaccessible, causing "no frames" in UI. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- docs/changelog.d/+frigate-health-checks.infra.md | 1 + mise-tasks/services-check | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 docs/changelog.d/+frigate-health-checks.infra.md diff --git a/docs/changelog.d/+frigate-health-checks.infra.md b/docs/changelog.d/+frigate-health-checks.infra.md new file mode 100644 index 0000000..a249765 --- /dev/null +++ b/docs/changelog.d/+frigate-health-checks.infra.md @@ -0,0 +1 @@ +Improve Frigate health checks in services-check: per-camera FPS validation and NFS storage accessibility check. diff --git a/mise-tasks/services-check b/mise-tasks/services-check index 44d5722..94ced03 100755 --- a/mise-tasks/services-check +++ b/mise-tasks/services-check @@ -83,7 +83,8 @@ check_http "CV" "https://cv.ops.eblu.me/" check_http "Ntfy" "https://ntfy.ops.eblu.me/v1/health" check_http "Authentik" "https://authentik.ops.eblu.me/-/health/live/" check_http "Frigate" "https://nvr.ops.eblu.me/api/version" -check_service "frigate-recording" "curl -sf --max-time 5 https://nvr.ops.eblu.me/api/stats | jq -e '.camera_fps > 0'" +check_service "frigate-camera-fps" "curl -sf --max-time 5 https://nvr.ops.eblu.me/api/stats | jq -e '.cameras | to_entries | all(.value.camera_fps > 0)'" +check_service "frigate-storage" "curl -sf --max-time 5 https://nvr.ops.eblu.me/api/stats | jq -e '.service.storage | to_entries | map(select(.key | startswith(\"/media\"))) | length > 0 and all(.[]; .value.free > 0)'" check_http "JobSync" "https://jobsync.ops.eblu.me/" echo "" From 6d65e6928cb6f6326a669630aa6f72e0e0af4115 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Sun, 22 Mar 2026 14:52:56 -0700 Subject: [PATCH 095/430] C2: Deploy infrastructure alerting pipeline (#303) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Mikado chain to replace `mise run services-check` with Grafana Unified Alerting backed by ntfy push notifications. **Design:** - Grafana Unified Alerting evaluates rules against Prometheus/Loki - ntfy webhook contact point delivers iOS notifications - Anti-noise policy: page once per 24h per alert group - Every alert links to a runbook in `docs/how-to/alerts/` - services-check eventually queries the alerting API instead of doing its own probes **Chain (bottom-up):** 1. `configure-grafana-alerting-pipeline` — enable alerting, ntfy contact point, notification policy 2. `first-alert-and-runbook` — end-to-end proof of concept with blackbox probe failure 3. `port-services-check-alerts` — migrate all services-check probes to alert rules + runbooks 4. `refactor-services-check-to-query-alerts` — rewrite services-check to query Grafana API 5. `deploy-infra-alerting` — goal card 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/303 --- argocd/manifests/alloy-k8s/config.alloy | 37 ++ argocd/manifests/grafana/alerting.yaml | 393 ++++++++++++++++++ argocd/manifests/grafana/deployment.yaml | 3 + argocd/manifests/grafana/grafana.ini | 5 + argocd/manifests/grafana/kustomization.yaml | 1 + argocd/manifests/prometheus/prometheus.yml | 8 + .../mikado-deploy-infra-alerting.feature.md | 1 + .../configure-grafana-alerting-pipeline.md | 59 +++ docs/how-to/runbooks/deploy-infra-alerting.md | 77 ++++ .../runbooks/first-alert-and-runbook.md | 68 +++ .../runbooks/port-services-check-alerts.md | 74 ++++ ...refactor-services-check-to-query-alerts.md | 53 +++ .../runbooks/runbook-argocd-out-of-sync.md | 65 +++ .../runbooks/runbook-frigate-camera-down.md | 39 ++ docs/how-to/runbooks/runbook-pod-not-ready.md | 55 +++ .../runbooks/runbook-postgres-unhealthy.md | 63 +++ .../runbooks/runbook-service-probe-failure.md | 75 ++++ .../how-to/runbooks/runbook-textfile-stale.md | 58 +++ docs/reference/operations/observability.md | 12 +- mise-tasks/services-check | 159 +++++-- 20 files changed, 1259 insertions(+), 46 deletions(-) create mode 100644 argocd/manifests/grafana/alerting.yaml create mode 100644 docs/changelog.d/mikado-deploy-infra-alerting.feature.md create mode 100644 docs/how-to/runbooks/configure-grafana-alerting-pipeline.md create mode 100644 docs/how-to/runbooks/deploy-infra-alerting.md create mode 100644 docs/how-to/runbooks/first-alert-and-runbook.md create mode 100644 docs/how-to/runbooks/port-services-check-alerts.md create mode 100644 docs/how-to/runbooks/refactor-services-check-to-query-alerts.md create mode 100644 docs/how-to/runbooks/runbook-argocd-out-of-sync.md create mode 100644 docs/how-to/runbooks/runbook-frigate-camera-down.md create mode 100644 docs/how-to/runbooks/runbook-pod-not-ready.md create mode 100644 docs/how-to/runbooks/runbook-postgres-unhealthy.md create mode 100644 docs/how-to/runbooks/runbook-service-probe-failure.md create mode 100644 docs/how-to/runbooks/runbook-textfile-stale.md diff --git a/argocd/manifests/alloy-k8s/config.alloy b/argocd/manifests/alloy-k8s/config.alloy index c169c93..a716ddc 100644 --- a/argocd/manifests/alloy-k8s/config.alloy +++ b/argocd/manifests/alloy-k8s/config.alloy @@ -169,6 +169,43 @@ prometheus.exporter.blackbox "services" { address = "http://argocd-server.argocd.svc.cluster.local:80/healthz" module = "http_2xx" } + + target { + name = "prometheus" + address = "http://prometheus.monitoring.svc.cluster.local:9090/-/healthy" + module = "http_2xx" + } + + target { + name = "loki" + address = "http://loki.monitoring.svc.cluster.local:3100/ready" + module = "http_2xx" + } + + target { + name = "grafana" + address = "http://grafana.monitoring.svc.cluster.local:80/api/health" + module = "http_2xx" + } + + target { + name = "teslamate" + address = "http://teslamate.teslamate.svc.cluster.local:4000/" + module = "http_2xx" + } + + target { + name = "immich" + address = "http://immich-server.immich.svc.cluster.local:2283/api/server/ping" + module = "http_2xx" + } + + target { + name = "navidrome" + address = "http://navidrome.navidrome.svc.cluster.local:4533/" + module = "http_2xx" + } + } // Scrape blackbox probe results diff --git a/argocd/manifests/grafana/alerting.yaml b/argocd/manifests/grafana/alerting.yaml new file mode 100644 index 0000000..abc4c0f --- /dev/null +++ b/argocd/manifests/grafana/alerting.yaml @@ -0,0 +1,393 @@ +apiVersion: 1 + +contactPoints: + - orgId: 1 + name: ntfy-infra + receivers: + - uid: ntfy-infra-webhook + type: webhook + settings: + url: https://ntfy.ops.eblu.me + httpMethod: POST + maxAlerts: "0" + payload: + template: >- + {{ template "ntfy-infra.payload" . }} + disableResolveMessage: false + +policies: + - orgId: 1 + receiver: ntfy-infra + group_by: + - alertname + - service + group_wait: 1m + group_interval: 12h + repeat_interval: 24h + +groups: + - orgId: 1 + name: service-health + folder: Infrastructure Alerts + interval: 30s + rules: + - uid: service-probe-failure + title: ServiceProbeFailure + condition: C + for: 2m + noDataState: Alerting + execErrState: Alerting + annotations: + summary: >- + {{ index $labels "service" }} health check is failing + runbook_url: https://docs.eblu.me/how-to/runbooks/runbook-service-probe-failure + labels: + severity: warning + data: + - refId: A + datasourceUid: prometheus + relativeTimeRange: + from: 300 + to: 0 + model: + expr: >- + label_replace(probe_success, "service", + "$1", "job", "integrations/blackbox/(.*)") + interval: "" + refId: A + - refId: B + datasourceUid: "__expr__" + relativeTimeRange: + from: 0 + to: 0 + model: + type: reduce + expression: A + reducer: last + settings: + mode: dropNN + refId: B + - refId: C + datasourceUid: "__expr__" + relativeTimeRange: + from: 0 + to: 0 + model: + type: threshold + expression: B + conditions: + - evaluator: + type: lt + params: + - 1 + operator: + type: and + refId: C + + - orgId: 1 + name: textfile-freshness + folder: Infrastructure Alerts + interval: 60s + rules: + - uid: textfile-stale + title: TextfileStale + condition: C + for: 15m + noDataState: Alerting + execErrState: Alerting + annotations: + summary: >- + Metrics textfile {{ index $labels "file" }} has not been updated in over 1 hour + runbook_url: https://docs.eblu.me/how-to/runbooks/runbook-textfile-stale + labels: + severity: warning + service: indri-metrics + data: + - refId: A + datasourceUid: prometheus + relativeTimeRange: + from: 300 + to: 0 + model: + expr: >- + time() - node_textfile_mtime_seconds + interval: "" + refId: A + - refId: B + datasourceUid: "__expr__" + relativeTimeRange: + from: 0 + to: 0 + model: + type: reduce + expression: A + reducer: last + settings: + mode: dropNN + refId: B + - refId: C + datasourceUid: "__expr__" + relativeTimeRange: + from: 0 + to: 0 + model: + type: threshold + expression: B + conditions: + - evaluator: + type: gt + params: + - 3600 + operator: + type: and + refId: C + + - orgId: 1 + name: frigate-health + folder: Infrastructure Alerts + interval: 60s + rules: + - uid: frigate-camera-down + title: FrigateCameraDown + condition: C + for: 5m + noDataState: Alerting + execErrState: Alerting + annotations: + summary: >- + Frigate camera {{ index $labels "camera_name" }} has 0 FPS + runbook_url: https://docs.eblu.me/how-to/runbooks/runbook-frigate-camera-down + labels: + severity: warning + service: frigate + data: + - refId: A + datasourceUid: prometheus + relativeTimeRange: + from: 300 + to: 0 + model: + expr: frigate_camera_fps + interval: "" + refId: A + - refId: B + datasourceUid: "__expr__" + relativeTimeRange: + from: 0 + to: 0 + model: + type: reduce + expression: A + reducer: last + settings: + mode: dropNN + refId: B + - refId: C + datasourceUid: "__expr__" + relativeTimeRange: + from: 0 + to: 0 + model: + type: threshold + expression: B + conditions: + - evaluator: + type: lt + params: + - 1 + operator: + type: and + refId: C + + - orgId: 1 + name: database-health + folder: Infrastructure Alerts + interval: 60s + rules: + - uid: postgres-cluster-unhealthy + title: PostgresClusterUnhealthy + condition: C + for: 3m + noDataState: Alerting + execErrState: Alerting + annotations: + summary: >- + PostgreSQL cluster {{ index $labels "cluster" }} is unhealthy + runbook_url: https://docs.eblu.me/how-to/runbooks/runbook-postgres-unhealthy + labels: + severity: critical + service: postgresql + data: + - refId: A + datasourceUid: prometheus + relativeTimeRange: + from: 300 + to: 0 + model: + expr: cnpg_collector_up + interval: "" + refId: A + - refId: B + datasourceUid: "__expr__" + relativeTimeRange: + from: 0 + to: 0 + model: + type: reduce + expression: A + reducer: last + settings: + mode: dropNN + refId: B + - refId: C + datasourceUid: "__expr__" + relativeTimeRange: + from: 0 + to: 0 + model: + type: threshold + expression: B + conditions: + - evaluator: + type: lt + params: + - 1 + operator: + type: and + refId: C + + - orgId: 1 + name: pod-health + folder: Infrastructure Alerts + interval: 60s + rules: + - uid: pod-not-ready + title: PodNotReady + condition: C + for: 5m + noDataState: OK + execErrState: Alerting + annotations: + summary: >- + Pod {{ index $labels "pod" }} in {{ index $labels "namespace" }} is not ready + runbook_url: https://docs.eblu.me/how-to/runbooks/runbook-pod-not-ready + labels: + severity: warning + data: + - refId: A + datasourceUid: prometheus + relativeTimeRange: + from: 300 + to: 0 + model: + expr: >- + kube_pod_status_ready{condition="true"} == 0 + unless on (namespace, pod) + kube_pod_owner{owner_kind="Job"} + interval: "" + refId: A + - refId: B + datasourceUid: "__expr__" + relativeTimeRange: + from: 0 + to: 0 + model: + type: reduce + expression: A + reducer: last + settings: + mode: dropNN + refId: B + - refId: C + datasourceUid: "__expr__" + relativeTimeRange: + from: 0 + to: 0 + model: + type: threshold + expression: B + conditions: + - evaluator: + type: lt + params: + - 1 + operator: + type: and + refId: C + + - orgId: 1 + name: argocd-health + folder: Infrastructure Alerts + interval: 60s + rules: + - uid: argocd-app-out-of-sync + title: ArgoCDAppOutOfSync + condition: C + for: 30m + noDataState: OK + execErrState: Alerting + annotations: + summary: >- + ArgoCD app {{ index $labels "name" }} is {{ index $labels "sync_status" }} + runbook_url: https://docs.eblu.me/how-to/runbooks/runbook-argocd-out-of-sync + labels: + severity: warning + service: argocd + data: + - refId: A + datasourceUid: prometheus + relativeTimeRange: + from: 300 + to: 0 + model: + expr: >- + argocd_app_info{sync_status!="Synced"} + interval: "" + refId: A + - refId: B + datasourceUid: "__expr__" + relativeTimeRange: + from: 0 + to: 0 + model: + type: reduce + expression: A + reducer: last + settings: + mode: dropNN + refId: B + - refId: C + datasourceUid: "__expr__" + relativeTimeRange: + from: 0 + to: 0 + model: + type: threshold + expression: B + conditions: + - evaluator: + type: gt + params: + - 0 + operator: + type: and + refId: C + +templates: + - orgId: 1 + name: ntfy-infra + template: | + {{ define "ntfy-infra.payload" -}} + {{- $msg := "" -}} + {{- range .Alerts -}} + {{- $msg = (printf "%s%s\n" $msg .Annotations.summary) -}} + {{- end -}} + {{- $title := (printf "[%s] %s" (.Status | toUpper) .CommonLabels.alertname) -}} + {{- $actions := coll.Slice -}} + {{- range .Alerts -}} + {{- if .Annotations.runbook_url -}} + {{- $actions = coll.Append (coll.Dict "action" "view" "label" "Open Runbook" "url" .Annotations.runbook_url) $actions -}} + {{- end -}} + {{- end -}} + {{- coll.Dict "topic" "infra-alerts" "title" $title "message" $msg "priority" 3 "actions" $actions | data.ToJSON -}} + {{- end }} diff --git a/argocd/manifests/grafana/deployment.yaml b/argocd/manifests/grafana/deployment.yaml index 61a2f88..5fbb8eb 100644 --- a/argocd/manifests/grafana/deployment.yaml +++ b/argocd/manifests/grafana/deployment.yaml @@ -277,6 +277,9 @@ spec: - name: config mountPath: /etc/grafana/provisioning/datasources/datasources.yaml subPath: datasources.yaml + - name: config + mountPath: /etc/grafana/provisioning/alerting/alerting.yaml + subPath: alerting.yaml - name: storage mountPath: /var/lib/grafana - name: sc-dashboard-volume diff --git a/argocd/manifests/grafana/grafana.ini b/argocd/manifests/grafana/grafana.ini index 61cdd7e..a0a6db8 100644 --- a/argocd/manifests/grafana/grafana.ini +++ b/argocd/manifests/grafana/grafana.ini @@ -30,3 +30,8 @@ allow_embedding = false [server] root_url = https://grafana.ops.eblu.me + +[unified_alerting] +enabled = true +evaluation_timeout = 30s +min_interval = 10s diff --git a/argocd/manifests/grafana/kustomization.yaml b/argocd/manifests/grafana/kustomization.yaml index c052bf9..3aeaa26 100644 --- a/argocd/manifests/grafana/kustomization.yaml +++ b/argocd/manifests/grafana/kustomization.yaml @@ -25,6 +25,7 @@ configMapGenerator: files: - grafana.ini - datasources.yaml + - alerting.yaml options: labels: app.kubernetes.io/name: grafana diff --git a/argocd/manifests/prometheus/prometheus.yml b/argocd/manifests/prometheus/prometheus.yml index 2d2dbcf..f96ce12 100644 --- a/argocd/manifests/prometheus/prometheus.yml +++ b/argocd/manifests/prometheus/prometheus.yml @@ -80,6 +80,14 @@ scrape_configs: - target_label: cluster replacement: indri + # ArgoCD application metrics + - job_name: "argocd" + static_configs: + - targets: ["argocd-metrics.argocd.svc.cluster.local:8082"] + metric_relabel_configs: + - target_label: cluster + replacement: indri + # Frigate NVR metrics (via Caddy on indri — Frigate runs on ringtail) - job_name: "frigate" scheme: https diff --git a/docs/changelog.d/mikado-deploy-infra-alerting.feature.md b/docs/changelog.d/mikado-deploy-infra-alerting.feature.md new file mode 100644 index 0000000..7106014 --- /dev/null +++ b/docs/changelog.d/mikado-deploy-infra-alerting.feature.md @@ -0,0 +1 @@ +Deploy infrastructure alerting pipeline using Grafana Unified Alerting with ntfy push notifications. 7 alert rules with runbooks covering service health, pod readiness, PostgreSQL, textfile freshness, Frigate cameras, and ArgoCD sync status. services-check now queries the alerting API for covered checks. diff --git a/docs/how-to/runbooks/configure-grafana-alerting-pipeline.md b/docs/how-to/runbooks/configure-grafana-alerting-pipeline.md new file mode 100644 index 0000000..eb90128 --- /dev/null +++ b/docs/how-to/runbooks/configure-grafana-alerting-pipeline.md @@ -0,0 +1,59 @@ +--- +title: Configure Grafana Alerting Pipeline +modified: 2026-03-22 +tags: + - how-to + - alerting + - grafana +--- + +# Configure Grafana Alerting Pipeline + +Enable Grafana Unified Alerting, create an ntfy webhook contact point, configure the notification policy with anti-noise settings, and set up a message template with runbook links. + +## What to Do + +### 1. Enable Unified Alerting in grafana.ini + +Add the `[unified_alerting]` section to the Grafana ConfigMap. Grafana 11+ has unified alerting enabled by default, but we should be explicit and configure the evaluation interval. + +### 2. Create Alerting Provisioning Files + +Grafana supports provisioning alert resources via YAML files in `/etc/grafana/provisioning/alerting/`. Create: + +- **Contact point** — ntfy webhook targeting `http://ntfy.ntfy.svc.cluster.local:80/infra-alerts` (cluster-internal, since Grafana and ntfy are on different clusters, use `ntfy.ops.eblu.me` via Caddy instead) +- **Notification policy** — root policy with `group_wait: 1m`, `group_interval: 12h`, `repeat_interval: 24h`, grouped by `alertname` and `service` +- **Message template** — format that includes alert name, summary, and a clickable runbook URL as an ntfy action button + +### 3. Mount Provisioning into Grafana + +Add the alerting provisioning ConfigMap to the Grafana deployment, mounted at `/etc/grafana/provisioning/alerting/`. + +### 4. Create the `infra-alerts` Topic + +ntfy topics are created on first publish — no explicit setup needed. But verify that the topic works by sending a test notification. + +### 5. Verify End-to-End + +- Grafana UI shows the ntfy contact point under Alerting → Contact Points +- Notification policy shows the anti-noise settings +- Test notification from Grafana reaches the ntfy iOS app + +## Key Details + +- Grafana runs on minikube (indri), ntfy runs on k3s (ringtail). The contact point URL must go through Caddy: `https://ntfy.ops.eblu.me/infra-alerts` +- ntfy action buttons use the `X-Actions` header or JSON body format: `view, Open Runbook, <url>` +- Grafana provisioning files are applied on startup and cannot be edited from the UI (which is what we want for GitOps) + +## Verification + +- [ ] Grafana starts with unified alerting enabled +- [ ] Contact point `ntfy-infra` visible in Grafana UI +- [ ] Notification policy shows correct group/repeat intervals +- [ ] Test notification arrives on iOS via ntfy app +- [ ] Test notification includes a clickable runbook link + +## Related + +- [[deploy-infra-alerting]] — Parent goal +- [[first-alert-and-runbook]] — Next: create the first real alert diff --git a/docs/how-to/runbooks/deploy-infra-alerting.md b/docs/how-to/runbooks/deploy-infra-alerting.md new file mode 100644 index 0000000..e02523d --- /dev/null +++ b/docs/how-to/runbooks/deploy-infra-alerting.md @@ -0,0 +1,77 @@ +--- +title: Deploy Infrastructure Alerting Pipeline +modified: 2026-03-22 +tags: + - how-to + - alerting + - observability +--- + +# Deploy Infrastructure Alerting Pipeline + +Replace the manual `mise run services-check` approach with Grafana Unified Alerting backed by ntfy push notifications, so infrastructure problems page once and include actionable runbook links. + +## Architecture + +``` +Prometheus (metrics) ──┐ + ├──▶ Grafana Alert Rules ──▶ ntfy webhook ──▶ iOS push +Loki (logs) ──────────┘ │ + │ + Notification Policy + (group_wait: 1m, + group_interval: 12h, + repeat_interval: 24h) +``` + +## Design Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| **Alert engine** | Grafana Unified Alerting | Already deployed, no new service needed | +| **Notification** | ntfy webhook contact point | Already deployed on ringtail, iOS app works | +| **Anti-noise** | 24h repeat interval | Page once per day max per alert group | +| **Runbooks** | `docs/how-to/runbooks/<name>.md` | Clickable link in every notification | +| **Provisioning** | Grafana provisioning YAML (GitOps) | Alerts defined in repo, not just UI | +| **Topic** | `infra-alerts` (separate from `frigate-alerts`) | Different severity/audience | + +## Alerting Policy + +- Each alert fires **once** and does not re-notify for 24 hours +- A "resolved" notification is sent when the condition clears +- Every alert annotation includes `runbook_url` linking to its how-to doc +- The ntfy message template renders the runbook URL as a clickable action button +- Alerts are grouped by service to avoid notification storms + +## Migration Path + +1. Stand up the pipeline: Grafana alerting config, ntfy contact point, notification policy, message template +2. Create the first alert + runbook as proof of concept (e.g., a blackbox probe failure) +3. Port services-check health checks to Grafana alert rules, one by one, each with a runbook +4. Refactor services-check to query the Grafana alerting API instead of doing its own probes + +## What services-check Covers Today + +These checks will be migrated to alert rules: + +| Category | Checks | Data Source | +|----------|--------|-------------| +| Local services (indri) | forgejo, alloy, borgmatic, zot via brew/launchctl | Need new probes or textfile metrics | +| Metrics textfiles | freshness of `.prom` files | Existing node_textfile metrics | +| K8s cluster health | minikube API, k3s API | kube-state-metrics | +| HTTP endpoints | ~12 services via Caddy | Alloy blackbox exporter (already exists) | +| Ringtail | SSH, tailscale, k3s health | Need new probes | +| K3s pods | ntfy, authentik, frigate, etc. | kube-state-metrics on ringtail | +| Public services | docs, cv, forge via Fly.io | Alloy on Fly.io or external probe | +| PostgreSQL | CNPG readiness | CNPG metrics (already scraped) | +| ArgoCD sync | app sync/health status | ArgoCD metrics or API | + +## Related + +- [[configure-grafana-alerting-pipeline]] — Foundation: contact point, policy, template +- [[first-alert-and-runbook]] — Proof of concept alert +- [[port-services-check-alerts]] — Systematic migration +- [[refactor-services-check-to-query-alerts]] — Final integration +- [[observability]] — Current observability stack +- [[ntfy]] — Push notification service +- [[grafana]] — Dashboard and alerting platform diff --git a/docs/how-to/runbooks/first-alert-and-runbook.md b/docs/how-to/runbooks/first-alert-and-runbook.md new file mode 100644 index 0000000..6ce13bf --- /dev/null +++ b/docs/how-to/runbooks/first-alert-and-runbook.md @@ -0,0 +1,68 @@ +--- +title: First Alert and Runbook +modified: 2026-03-22 +tags: + - how-to + - alerting +--- + +# First Alert and Runbook + +Create one end-to-end alert as proof of concept — an alert rule that fires, delivers a notification to ntfy with a runbook link, and has a corresponding runbook doc. + +## What to Do + +### 1. Choose the First Alert + +The best candidate is a **blackbox probe failure** because: +- Alloy's blackbox exporter already probes 5 services (miniflux, kiwix, transmission, devpi, argocd) at 30s intervals +- The metric `probe_success` is already in Prometheus +- It maps directly to what services-check does (HTTP health checks) +- A single alert rule with a `service` label can cover all probed services + +### 2. Create the Alert Rule + +Provision via YAML in the alerting provisioning ConfigMap. The rule should: +- Query `probe_success == 0` from Prometheus +- Fire after the condition persists for 2 minutes (avoid flapping) +- Include labels: `severity: warning`, `service: {{ $labels.instance }}` +- Include annotations: `summary`, `runbook_url` pointing to the runbook doc + +### 3. Create the Runbook + +Write `docs/how-to/runbooks/runbook-service-probe-failure.md` as a how-to doc explaining: +- What the alert means +- How to check which service is down +- Common causes and resolution steps +- How to silence the alert if the downtime is planned + +### 4. Verify End-to-End + +- Stop one of the probed services (e.g., scale miniflux to 0) +- Wait for the alert to fire (~2 minutes) +- Confirm ntfy notification arrives with correct summary and runbook link +- Click the runbook link and verify it reaches docs.eblu.me +- Scale the service back up +- Confirm "resolved" notification arrives +- Confirm no repeat notification during the 24h window + +## Key Details + +- Grafana alert rules can be provisioned as YAML files alongside contact points and notification policies +- The blackbox probe metrics from Alloy use the job name `blackbox` and include an `instance` label with the service name +- The runbook URL format: `https://docs.eblu.me/how-to/runbooks/runbook-service-probe-failure` + +## Verification + +- [ ] Alert rule appears in Grafana UI under Alerting → Alert Rules +- [ ] Simulated failure triggers ntfy notification within ~3 minutes +- [ ] Notification includes service name, summary, and clickable runbook link +- [ ] Resolution triggers a "resolved" notification +- [ ] No repeat notification within 24h window + +## Related + +- [[configure-grafana-alerting-pipeline]] — Prerequisite: pipeline must be working +- [[deploy-infra-alerting]] — Parent goal +- [[port-services-check-alerts]] — Next: port remaining checks +- [[runbook-service-probe-failure]] — The runbook created for this alert diff --git a/docs/how-to/runbooks/port-services-check-alerts.md b/docs/how-to/runbooks/port-services-check-alerts.md new file mode 100644 index 0000000..4420f58 --- /dev/null +++ b/docs/how-to/runbooks/port-services-check-alerts.md @@ -0,0 +1,74 @@ +--- +title: Port services-check Alerts to Grafana +modified: 2026-03-22 +tags: + - how-to + - alerting +--- + +# Port services-check Alerts to Grafana + +Systematically migrate the health checks from `mise run services-check` to Grafana alert rules, each with a corresponding runbook. After this card, the alerting system covers everything services-check does today. + +## What to Do + +### 1. Inventory and Prioritize + +Map each services-check probe to a data source and alert rule. Some checks already have metrics in Prometheus; others need new instrumentation. + +**Already have metrics (easy):** +- HTTP endpoint probes → Alloy blackbox exporter (`probe_success`) +- PostgreSQL health → CNPG metrics (`cnpg_pg_replication_streaming`, `cnpg_collector_up`) +- K8s pod health → kube-state-metrics (`kube_pod_status_phase`) +- ArgoCD sync status → ArgoCD metrics (`argocd_app_info` with sync/health labels) + +**Need new probes or metrics:** +- Local indri services (forgejo, alloy, borgmatic, zot via brew/launchctl) → Alloy host textfile or new probes +- Metrics textfile freshness → `node_textfile_mtime_seconds` (already collected by Alloy on indri) +- Ringtail SSH/tailscale health → Alloy blackbox on ringtail or cross-cluster probe +- Public services (docs, cv, forge via Fly.io) → Alloy on Fly.io or Grafana synthetic monitoring + +### 2. Add Missing Probes + +Extend Alloy configurations where needed: +- **Alloy on indri:** Add blackbox targets for forgejo, zot (local HTTP endpoints) +- **Alloy on ringtail:** Add blackbox targets for ringtail-local services +- **Consider:** Whether public endpoint probing belongs in Fly.io Alloy or a separate prober + +### 3. Create Alert Rules + +For each check category, create provisioned Grafana alert rules. Group related checks into alert rule groups (e.g., "indri-services", "k8s-health", "public-endpoints"). + +### 4. Create Runbooks + +One runbook per alert type in `docs/how-to/runbooks/runbook-<name>.md`. Each runbook should cover: +- What the alert means +- Diagnostic steps +- Common fixes +- How to silence for planned maintenance + +### 5. Remove from services-check + +As each check is ported, remove it from the services-check script (or mark it as "now handled by alerting"). The goal is that services-check shrinks as alerting grows. + +## Key Details + +- Don't try to port everything in one session — this card may span multiple work cycles within the C2 chain +- Prioritize checks that have caught real problems in the past +- Some checks (like ArgoCD sync status table) may remain in services-check as a human-readable summary even after alerting covers the failure cases +- The Alloy blackbox exporter on k8s already covers 5 services; extending it to more is straightforward + +## Verification + +- [ ] All HTTP endpoint checks from services-check have corresponding alert rules +- [ ] Pod health checks have corresponding alert rules +- [ ] PostgreSQL health has a corresponding alert rule +- [ ] Each alert rule has a runbook doc in `docs/how-to/runbooks/` +- [ ] Test at least 2-3 failure scenarios end-to-end +- [ ] services-check script has been updated to reflect ported checks + +## Related + +- [[first-alert-and-runbook]] — Prerequisite: established the pattern +- [[deploy-infra-alerting]] — Parent goal +- [[refactor-services-check-to-query-alerts]] — Next: make services-check query alerts diff --git a/docs/how-to/runbooks/refactor-services-check-to-query-alerts.md b/docs/how-to/runbooks/refactor-services-check-to-query-alerts.md new file mode 100644 index 0000000..244be1f --- /dev/null +++ b/docs/how-to/runbooks/refactor-services-check-to-query-alerts.md @@ -0,0 +1,53 @@ +--- +title: Refactor services-check to Query Alerts +modified: 2026-03-22 +tags: + - how-to + - alerting +--- + +# Refactor services-check to Query Alerts + +Change `mise run services-check` from doing its own health probes to querying the Grafana alerting API for currently firing alerts. The script becomes a CLI view into the same alerting system that sends ntfy notifications. + +## What to Do + +### 1. Query the Grafana Alerting API + +Grafana exposes alert state via: +- `GET /api/v1/provisioning/alert-rules` — all configured rules +- `GET /api/prometheus/grafana/api/v1/alerts` — currently firing alerts (Prometheus-compatible format) + +The second endpoint is simpler — it returns only active alerts with labels and annotations, similar to Alertmanager's `/api/v1/alerts`. + +### 2. Rewrite services-check + +The new services-check should: +1. Query the Grafana alerting API for firing alerts +2. Display them in a table with service name, alert name, duration, and runbook link +3. If no alerts are firing, print a green "all clear" message +4. Exit 0 if no alerts, exit 1 if any are firing +5. Optionally keep a few checks that don't map to alerting (e.g., the ArgoCD sync status table as a summary view) + +### 3. Handle Authentication + +services-check will need a Grafana API token or service account token. Options: +- Use the existing Grafana admin credentials from 1Password (`op read`) +- Create a dedicated read-only service account in Grafana + +### 4. Preserve the ArgoCD Summary + +The ArgoCD sync/health table in services-check is a useful quick view even when nothing is alerting. Consider keeping it as a separate section that always displays, independent of the alert query. + +## Verification + +- [ ] `mise run services-check` queries Grafana instead of doing direct probes +- [ ] Firing alerts are displayed with service name, alert name, and runbook link +- [ ] Exit code reflects alert state (0 = clear, 1 = firing) +- [ ] Works when Grafana is unreachable (graceful error, not a crash) +- [ ] ArgoCD summary table still works + +## Related + +- [[port-services-check-alerts]] — Prerequisite: alerts must exist to query +- [[deploy-infra-alerting]] — Parent goal diff --git a/docs/how-to/runbooks/runbook-argocd-out-of-sync.md b/docs/how-to/runbooks/runbook-argocd-out-of-sync.md new file mode 100644 index 0000000..753b336 --- /dev/null +++ b/docs/how-to/runbooks/runbook-argocd-out-of-sync.md @@ -0,0 +1,65 @@ +--- +title: "Runbook: ArgoCD App Out of Sync" +modified: 2026-03-22 +tags: + - how-to + - alerting + - runbook +--- + +# Runbook: ArgoCD App Out of Sync + +**Alert name:** `ArgoCDAppOutOfSync` + +An ArgoCD application has been out of sync for 30+ minutes. This means the live state in Kubernetes differs from what's declared in Git. + +## Diagnostic Steps + +1. **Check which app is out of sync** — the `name` label in the alert tells you: + ```fish + argocd app get <app-name> + ``` + +2. **View the diff**: + ```fish + argocd app diff <app-name> + ``` + +3. **Check if it's a branch revision issue** — during C1/C2 work, apps may be pointed at a feature branch. After merge, they need to be reset to main: + ```fish + argocd app get <app-name> -o json | python3 -c "import json,sys; print(json.load(sys.stdin)['spec']['source']['targetRevision'])" + ``` + +4. **Check ArgoCD UI** — https://argocd.ops.eblu.me — look for sync errors or degraded status. + +## Common Causes + +- **Forgot to sync after push** — ArgoCD uses manual sync; changes require explicit `argocd app sync` +- **Branch revision not reset after PR merge** — app still points at a deleted branch +- **Kustomize/manifest error** — invalid YAML or unsatisfiable resource requirements +- **Pruning needed** — old ConfigMaps from `configMapGenerator` need pruning + +## Resolution + +```fish +# Simple sync +argocd app sync <app-name> + +# If pruning is needed +argocd app sync <app-name> --prune + +# If stuck on a deleted branch +argocd app set <app-name> --revision main +argocd app sync <app-name> +``` + +## Silencing + +During active C1/C2 development, apps may intentionally be out of sync: +1. Grafana → Alerting → Silences → Create Silence +2. Match `alertname = ArgoCDAppOutOfSync` and `name = <app-name>` + +## Related + +- [[argocd]] — ArgoCD reference +- [[deploy-infra-alerting]] — Alerting pipeline overview diff --git a/docs/how-to/runbooks/runbook-frigate-camera-down.md b/docs/how-to/runbooks/runbook-frigate-camera-down.md new file mode 100644 index 0000000..ea04e79 --- /dev/null +++ b/docs/how-to/runbooks/runbook-frigate-camera-down.md @@ -0,0 +1,39 @@ +--- +title: "Runbook: Frigate Camera Down" +modified: 2026-03-22 +tags: + - how-to + - alerting + - runbook +--- + +# Runbook: Frigate Camera Down + +**Alert name:** `FrigateCameraDown` + +A Frigate camera has reported 0 FPS for 5+ minutes, meaning the camera feed is not being received. + +## Diagnostic Steps + +1. **Check Frigate UI** — https://nvr.ops.eblu.me — look at the camera thumbnail and status +2. **Check Frigate API stats**: + ```fish + curl -s https://nvr.ops.eblu.me/api/stats | python3 -m json.tool + ``` +3. **Check Frigate pod logs** on ringtail: + ```fish + kubectl logs -n frigate -l app=frigate --context=k3s-ringtail --tail=30 + ``` +4. **Check the camera itself** — verify it's powered on and network-connected. Try accessing the RTSP stream directly. + +## Common Causes + +- **Camera offline** — power outage, network issue, or camera crash +- **NFS mount lost** — Frigate storage on sifaka; if the NFS mount drops, recording stops and FPS may drop +- **Frigate pod restart** — during restart, camera FPS briefly drops to 0 +- **RTSP stream timeout** — camera firmware issue; power cycle the camera + +## Related + +- [[frigate]] — Frigate NVR reference +- [[deploy-infra-alerting]] — Alerting pipeline overview diff --git a/docs/how-to/runbooks/runbook-pod-not-ready.md b/docs/how-to/runbooks/runbook-pod-not-ready.md new file mode 100644 index 0000000..49dd35e --- /dev/null +++ b/docs/how-to/runbooks/runbook-pod-not-ready.md @@ -0,0 +1,55 @@ +--- +title: "Runbook: Pod Not Ready" +modified: 2026-03-22 +tags: + - how-to + - alerting + - runbook +--- + +# Runbook: Pod Not Ready + +**Alert name:** `PodNotReady` + +A Kubernetes pod has been in a not-ready state for 5+ minutes. + +## Diagnostic Steps + +1. **Identify the pod** from the alert labels (`pod`, `namespace`): + ```fish + kubectl describe pod <pod> -n <namespace> --context=minikube-indri + ``` + +2. **Check events** — look for scheduling failures, image pull errors, or probe failures: + ```fish + kubectl get events -n <namespace> --context=minikube-indri --sort-by='.lastTimestamp' | tail -20 + ``` + +3. **Check logs**: + ```fish + kubectl logs <pod> -n <namespace> --context=minikube-indri --tail=50 + ``` + +4. **Check node resources**: + ```fish + kubectl top nodes --context=minikube-indri + kubectl top pods -n <namespace> --context=minikube-indri + ``` + +## Common Causes + +- **CrashLoopBackOff** — app is crashing on startup, check logs +- **ImagePullBackOff** — container image not found or registry unreachable +- **Pending** — insufficient resources (CPU/memory), or PVC not bound +- **Readiness probe failing** — service is running but not healthy +- **NFS mount issue** — services depending on sifaka (kiwix, transmission, navidrome, jellyfin) will fail if NFS is down + +## Silencing + +1. Grafana → Alerting → Silences → Create Silence +2. Match `alertname = PodNotReady` +3. Optionally match `namespace = <namespace>` to silence a specific service + +## Related + +- [[deploy-infra-alerting]] — Alerting pipeline overview diff --git a/docs/how-to/runbooks/runbook-postgres-unhealthy.md b/docs/how-to/runbooks/runbook-postgres-unhealthy.md new file mode 100644 index 0000000..2910851 --- /dev/null +++ b/docs/how-to/runbooks/runbook-postgres-unhealthy.md @@ -0,0 +1,63 @@ +--- +title: "Runbook: PostgreSQL Cluster Unhealthy" +modified: 2026-03-22 +tags: + - how-to + - alerting + - runbook +--- + +# Runbook: PostgreSQL Cluster Unhealthy + +**Alert name:** `PostgresClusterUnhealthy` + +The CNPG collector metrics endpoint is down, indicating the PostgreSQL cluster is not responding. + +## Affected Services + +The `blumeops-pg` CNPG cluster on indri's minikube runs databases for: +- TeslaMate +- Authentik (cross-cluster from ringtail) +- Immich +- Grafana dashboards (TeslaMate datasource) + +## Diagnostic Steps + +1. **Check CNPG cluster status**: + ```fish + kubectl get cluster blumeops-pg -n databases --context=minikube-indri + kubectl get pods -n databases -l cnpg.io/cluster=blumeops-pg --context=minikube-indri + ``` + +2. **Check pod logs**: + ```fish + kubectl logs -n databases -l cnpg.io/cluster=blumeops-pg --context=minikube-indri --tail=30 + ``` + +3. **Check if pg_isready**: + ```fish + pg_isready -h pg.ops.eblu.me -p 5432 + ``` + +4. **Check PVC storage**: + ```fish + kubectl get pvc -n databases --context=minikube-indri + ``` + +## Common Causes + +- **Pod crash** — OOM, disk full, or configuration error +- **PVC storage full** — check with `kubectl exec` into the pod and `df -h` +- **Minikube issue** — if the node is under memory pressure, CNPG pods may be evicted +- **Network** — Caddy L4 proxy (`pg.ops.eblu.me`) may be misconfigured + +## Silencing + +For planned database maintenance: +1. Grafana → Alerting → Silences → Create Silence +2. Match `alertname = PostgresClusterUnhealthy` + +## Related + +- [[postgresql]] — CNPG cluster reference +- [[deploy-infra-alerting]] — Alerting pipeline overview diff --git a/docs/how-to/runbooks/runbook-service-probe-failure.md b/docs/how-to/runbooks/runbook-service-probe-failure.md new file mode 100644 index 0000000..575606e --- /dev/null +++ b/docs/how-to/runbooks/runbook-service-probe-failure.md @@ -0,0 +1,75 @@ +--- +title: "Runbook: Service Probe Failure" +modified: 2026-03-22 +tags: + - how-to + - alerting + - runbook +--- + +# Runbook: Service Probe Failure + +**Alert name:** `ServiceProbeFailure` + +A blackbox HTTP health check has failed for 2+ minutes, meaning a service is not responding to its health endpoint. + +## Affected Services + +This alert covers services probed by the Alloy blackbox exporter on indri's minikube cluster: + +| Service | Health Endpoint | +|---------|----------------| +| miniflux | `/healthcheck` | +| kiwix | `/` | +| transmission | `/transmission/web/` | +| devpi | `/+api` | +| argocd | `/healthz` | + +The failing service is identified by the `service` label in the alert (extracted from the `job` label). + +## Diagnostic Steps + +1. **Check which service is down** — the alert label `service` tells you. You can also run: + ```fish + kubectl get pods -n <namespace> --context=minikube-indri + ``` + +2. **Check pod status** — look for CrashLoopBackOff, OOMKilled, or pending pods: + ```fish + kubectl describe pod -n <namespace> <pod-name> --context=minikube-indri + ``` + +3. **Check pod logs**: + ```fish + kubectl logs -n <namespace> <pod-name> --context=minikube-indri --tail=50 + ``` + +4. **Check if minikube itself is healthy**: + ```fish + ssh indri 'minikube status' + ``` + +5. **Check NFS mounts** (kiwix, transmission depend on sifaka NFS): + ```fish + ssh indri 'df -h | grep Volumes' + ``` + +## Common Causes + +- **Pod crashed** — check logs, restart with `kubectl delete pod` +- **NFS mount lost** — sifaka offline or AutoMounter not running. SSH to indri and check `/Volumes/` +- **Resource exhaustion** — check `kubectl top pods -n <namespace>` for memory/CPU pressure +- **Minikube paused/stopped** — `ssh indri 'minikube status'`, restart if needed + +## Silencing + +For planned maintenance, silence this alert in Grafana: +1. Go to Alerting → Silences → Create Silence +2. Match label `alertname = ServiceProbeFailure` +3. Optionally match `service = <specific-service>` to silence only one +4. Set duration for your maintenance window + +## Related + +- [[deploy-infra-alerting]] — Alerting pipeline overview +- [[configure-grafana-alerting-pipeline]] — Pipeline configuration diff --git a/docs/how-to/runbooks/runbook-textfile-stale.md b/docs/how-to/runbooks/runbook-textfile-stale.md new file mode 100644 index 0000000..2a70adf --- /dev/null +++ b/docs/how-to/runbooks/runbook-textfile-stale.md @@ -0,0 +1,58 @@ +--- +title: "Runbook: Textfile Stale" +modified: 2026-03-22 +tags: + - how-to + - alerting + - runbook +--- + +# Runbook: Textfile Stale + +**Alert name:** `TextfileStale` + +A Prometheus textfile collector `.prom` file on indri has not been updated for over 1 hour, indicating the metrics exporter script has stopped running. + +## Affected Textfiles + +| File | LaunchAgent | What it monitors | +|------|-------------|------------------| +| `borgmatic.prom` | `mcquack.eblume.borgmatic` | Backup status | +| `zot.prom` | `mcquack.eblume.zot` | Container registry | +| `minikube.prom` | `mcquack.minikube-metrics` | Minikube cluster status | +| `jellyfin.prom` | `mcquack.eblume.jellyfin-metrics` | Media server | + +## Diagnostic Steps + +1. **Check which file is stale** — the `file` label in the alert tells you. Verify on indri: + ```fish + ssh indri 'ls -la /opt/homebrew/var/node_exporter/textfile/' + ``` + +2. **Check if the LaunchAgent is running**: + ```fish + ssh indri 'launchctl list | grep mcquack' + ``` + +3. **Check LaunchAgent logs** (plist defines stdout/stderr paths): + ```fish + ssh indri 'cat ~/Library/Logs/mcquack/<agent-name>.log' + ``` + +4. **Try running the exporter manually**: + ```fish + ssh indri 'cat ~/Library/LaunchAgents/mcquack.<agent>.plist' + # Find the ProgramArguments, run them manually + ``` + +## Common Causes + +- **LaunchAgent not loaded** — `launchctl load ~/Library/LaunchAgents/mcquack.<agent>.plist` +- **Script error** — the exporter script crashed; check logs +- **Permissions** — the textfile directory is not writable +- **Indri reboot** — some LaunchAgents may not auto-start + +## Related + +- [[alloy]] — Collects textfile metrics via `prometheus.exporter.unix` +- [[deploy-infra-alerting]] — Alerting pipeline overview diff --git a/docs/reference/operations/observability.md b/docs/reference/operations/observability.md index 5890147..35136d5 100644 --- a/docs/reference/operations/observability.md +++ b/docs/reference/operations/observability.md @@ -1,6 +1,6 @@ --- title: Observability -modified: 2026-02-07 +modified: 2026-03-22 tags: - operations --- @@ -16,3 +16,13 @@ Metrics, logs, traces, and dashboards for BlumeOps infrastructure. - [[tempo]] - Distributed tracing - [[alloy|Alloy]] - Metrics, log, and trace collection - [[grafana]] - Dashboards and visualization + +## Alerting + +- [[deploy-infra-alerting]] - Alerting pipeline (Grafana Unified Alerting → ntfy) +- [[runbook-service-probe-failure]] - Service health check failure runbook +- [[runbook-postgres-unhealthy]] - PostgreSQL cluster health runbook +- [[runbook-pod-not-ready]] - Pod not ready runbook +- [[runbook-textfile-stale]] - Metrics textfile freshness runbook +- [[runbook-frigate-camera-down]] - Frigate camera health runbook +- [[runbook-argocd-out-of-sync]] - ArgoCD sync status runbook diff --git a/mise-tasks/services-check b/mise-tasks/services-check index 94ced03..9ba2c8e 100755 --- a/mise-tasks/services-check +++ b/mise-tasks/services-check @@ -6,6 +6,7 @@ set -euo pipefail # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' +YELLOW='\033[0;33m' NC='\033[0m' # No Color FAILED=0 @@ -36,11 +37,88 @@ check_http() { fi } +# ============== Grafana Alerting API ============== + +GRAFANA_URL="https://grafana.ops.eblu.me" +GRAFANA_CREDS="" + +fetch_alerts() { + if [ -z "$GRAFANA_CREDS" ]; then + local pass + pass=$(op read 'op://vg6xf6vvfmoh5hqjjhlhbeoaie/oxkcr3xtxnewy7noep2izvyr6y/password' 2>/dev/null) || true + if [ -n "$pass" ]; then + GRAFANA_CREDS=$(echo -n "admin:$pass" | base64) + fi + fi + + if [ -z "$GRAFANA_CREDS" ]; then + echo "" + return + fi + + curl -sf --max-time 10 \ + -H "Authorization: Basic $GRAFANA_CREDS" \ + "$GRAFANA_URL/api/prometheus/grafana/api/v1/alerts" 2>/dev/null || echo "" +} + +# Fetch all alerts once +ALERTS_JSON=$(fetch_alerts) + +check_alert() { + local name="$1" + local alertname="$2" + # Optional: filter by a label key=value + local filter_key="${3:-}" + local filter_value="${4:-}" + + printf "%-24s " "$name..." + + if [ -z "$ALERTS_JSON" ]; then + echo -e "${YELLOW}NO DATA${NC} (can't reach Grafana alerting API)" + return + fi + + local firing + firing=$(echo "$ALERTS_JSON" | python3 -c " +import json, sys +try: + data = json.load(sys.stdin) +except: + sys.exit(1) +alerts = data.get('data', {}).get('alerts', []) +for a in alerts: + if a['labels'].get('alertname') != '$alertname': + continue + if '$filter_key' and a['labels'].get('$filter_key') != '$filter_value': + continue + if a['state'] in ('Alerting', 'Pending'): + url = a.get('annotations', {}).get('runbook_url', '') + summary = a.get('annotations', {}).get('summary', '') + print(f'{summary}|{url}') +" 2>/dev/null) + + if [ -z "$firing" ]; then + echo -e "${GREEN}OK${NC}" + else + local summary runbook + summary=$(echo "$firing" | head -1 | cut -d'|' -f1) + runbook=$(echo "$firing" | head -1 | cut -d'|' -f2) + echo -e "${RED}FIRING${NC}" + if [ -n "$summary" ]; then + echo -e " $summary" + fi + if [ -n "$runbook" ]; then + echo -e " Runbook: $runbook" + fi + FAILED=1 + fi +} + echo "Checking services..." echo "====================" echo "" -# Local services on indri +# Local services on indri (not yet covered by alerting) echo "Local services on indri:" check_service "forgejo (brew)" "ssh indri 'brew services list | grep forgejo | grep started'" check_service "alloy" "ssh indri 'launchctl list mcquack.eblume.alloy | grep -v \"^-\"'" @@ -52,43 +130,47 @@ check_service "minikube-metrics" "ssh indri 'launchctl list mcquack.minikube-met check_service "jellyfin-metrics" "ssh indri 'launchctl list mcquack.eblume.jellyfin-metrics | grep -v \"^-\"'" echo "" -echo "Metrics textfiles:" -check_service "borgmatic.prom" "ssh indri 'test -f /opt/homebrew/var/node_exporter/textfile/borgmatic.prom'" -check_service "zot.prom" "ssh indri 'test -f /opt/homebrew/var/node_exporter/textfile/zot.prom'" -check_service "minikube.prom" "ssh indri 'test -f /opt/homebrew/var/node_exporter/textfile/minikube.prom'" -check_service "jellyfin.prom" "ssh indri 'test -f /opt/homebrew/var/node_exporter/textfile/jellyfin.prom'" +echo "Metrics textfiles (via alerting):" +check_alert "textfile-freshness" "TextfileStale" echo "" -echo "Kubernetes cluster:" +echo "Kubernetes cluster (not yet covered by alerting):" check_service "minikube" "ssh indri 'minikube status --format={{.Host}} | grep -q Running'" check_service "k8s-apiserver (indri)" "ssh indri 'kubectl get --raw /healthz'" check_service "k8s-apiserver (remote)" "kubectl --kubeconfig=$HOME/.kube/minikube-indri/config.yml --context=minikube-indri get --raw /healthz" echo "" -echo "HTTP endpoints (via Caddy):" -check_http "Prometheus" "https://prometheus.ops.eblu.me/-/healthy" -check_http "Loki" "https://loki.ops.eblu.me/ready" -check_http "Grafana" "https://grafana.ops.eblu.me/api/health" -check_http "ArgoCD" "https://argocd.ops.eblu.me/healthz" +echo "HTTP endpoints (via alerting):" +check_alert "Prometheus" "ServiceProbeFailure" "service" "prometheus" +check_alert "Loki" "ServiceProbeFailure" "service" "loki" +check_alert "Grafana" "ServiceProbeFailure" "service" "grafana" +check_alert "ArgoCD" "ServiceProbeFailure" "service" "argocd" +check_alert "Kiwix" "ServiceProbeFailure" "service" "kiwix" +check_alert "Miniflux" "ServiceProbeFailure" "service" "miniflux" +check_alert "TeslaMate" "ServiceProbeFailure" "service" "teslamate" +check_alert "Devpi" "ServiceProbeFailure" "service" "devpi" +check_alert "Transmission" "ServiceProbeFailure" "service" "transmission" +check_alert "Immich" "ServiceProbeFailure" "service" "immich" +check_alert "Navidrome" "ServiceProbeFailure" "service" "navidrome" + +echo "" +echo "HTTP endpoints (not yet covered by alerting):" check_http "Forgejo" "https://forge.eblu.me/" check_http "Zot Registry" "https://registry.ops.eblu.me/v2/_catalog" -check_http "Kiwix" "https://kiwix.ops.eblu.me/" -check_http "Miniflux" "https://feed.ops.eblu.me/healthcheck" -check_http "TeslaMate" "https://tesla.ops.eblu.me/" -check_http "Devpi" "https://pypi.ops.eblu.me/+api" -check_http "Transmission" "https://torrent.ops.eblu.me/" -check_http "Immich" "https://photos.ops.eblu.me/" -check_http "Navidrome" "https://dj.ops.eblu.me/" check_http "CV" "https://cv.ops.eblu.me/" check_http "Ntfy" "https://ntfy.ops.eblu.me/v1/health" check_http "Authentik" "https://authentik.ops.eblu.me/-/health/live/" check_http "Frigate" "https://nvr.ops.eblu.me/api/version" -check_service "frigate-camera-fps" "curl -sf --max-time 5 https://nvr.ops.eblu.me/api/stats | jq -e '.cameras | to_entries | all(.value.camera_fps > 0)'" -check_service "frigate-storage" "curl -sf --max-time 5 https://nvr.ops.eblu.me/api/stats | jq -e '.service.storage | to_entries | map(select(.key | startswith(\"/media\"))) | length > 0 and all(.[]; .value.free > 0)'" check_http "JobSync" "https://jobsync.ops.eblu.me/" echo "" -echo "Ringtail (NixOS):" +echo "Frigate (via alerting):" +check_alert "camera-fps" "FrigateCameraDown" +echo "Frigate (not yet covered by alerting):" +check_service "frigate-storage" "curl -sf --max-time 5 https://nvr.ops.eblu.me/api/stats | jq -e '.service.storage | to_entries | map(select(.key | startswith(\"/media\"))) | length > 0 and all(.[]; .value.free > 0)'" + +echo "" +echo "Ringtail (not yet covered by alerting):" check_service "ssh" "ssh -o ConnectTimeout=5 ringtail true" check_service "tailscale" "ssh ringtail 'tailscale status --self --json' | jq -e '.Self.Online' > /dev/null" check_service "k3s" "ssh ringtail 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml k3s kubectl get nodes --no-headers | grep -q Ready'" @@ -96,43 +178,30 @@ check_service "k3s-apiserver (remote)" "kubectl --context=k3s-ringtail get --raw check_service "forgejo-runner" "ssh ringtail 'systemctl is-active gitea-runner-nix_container_builder.service'" echo "" -echo "Ringtail k3s pods:" -check_service "ntfy" "kubectl --context=k3s-ringtail -n ntfy get pods -l app=ntfy -o jsonpath='{.items[0].status.phase}' | grep -q Running" -check_service "authentik" "kubectl --context=k3s-ringtail -n authentik get pods -l component=server -o jsonpath='{.items[0].status.phase}' | grep -q Running" -check_service "frigate" "kubectl --context=k3s-ringtail -n frigate get pods -l app=frigate -o jsonpath='{.items[0].status.phase}' | grep -q Running" -check_service "frigate-notify" "kubectl --context=k3s-ringtail -n frigate get pods -l app=frigate-notify -o jsonpath='{.items[0].status.phase}' | grep -q Running" -check_service "nvidia-device-plugin" "kubectl --context=k3s-ringtail -n nvidia-device-plugin get pods -l app=nvidia-device-plugin -o jsonpath='{.items[0].status.phase}' | grep -q Running" -check_service "jobsync" "kubectl --context=k3s-ringtail -n jobsync get pods -l app=jobsync -o jsonpath='{.items[0].status.phase}' | grep -q Running" +echo "Pod health (via alerting):" +check_alert "pod-readiness" "PodNotReady" echo "" -echo "Public services (via Fly.io):" +echo "Database (via alerting):" +check_alert "PostgreSQL" "PostgresClusterUnhealthy" + +echo "" +echo "Public services (not yet covered by alerting):" check_http "Docs (public)" "https://docs.eblu.me/" check_http "CV (public)" "https://cv.eblu.me/" check_http "Forge (public)" "https://forge.eblu.me/" check_http "Fly.io healthz" "https://blumeops-proxy.fly.dev/healthz" echo "" -echo "Database:" -check_service "PostgreSQL (k8s)" "pg_isready -h pg.ops.eblu.me -p 5432" - -echo "" -echo "Indri minikube pods:" -check_service "prometheus-0" "kubectl --context=minikube-indri -n monitoring get pod prometheus-0 -o jsonpath='{.status.phase}' | grep -q Running" -check_service "loki-0" "kubectl --context=minikube-indri -n monitoring get pod loki-0 -o jsonpath='{.status.phase}' | grep -q Running" -check_service "grafana" "kubectl --context=minikube-indri -n monitoring get pods -l app.kubernetes.io/name=grafana -o jsonpath='{.items[0].status.phase}' | grep -q Running" -check_service "miniflux" "kubectl --context=minikube-indri -n miniflux get pods -l app=miniflux -o jsonpath='{.items[0].status.phase}' | grep -q Running" -check_service "teslamate" "kubectl --context=minikube-indri -n teslamate get pods -l app=teslamate -o jsonpath='{.items[0].status.phase}' | grep -q Running" -check_service "blumeops-pg" "kubectl --context=minikube-indri -n databases get pods -l cnpg.io/cluster=blumeops-pg -o jsonpath='{.items[0].status.phase}' | grep -q Running" - -echo "" -echo "ArgoCD app sync status:" +echo "ArgoCD app sync status (via alerting):" +check_alert "argocd-sync" "ArgoCDAppOutOfSync" +# Keep the detailed table as a summary view printf "%-20s %-12s %-12s %s\n" "NAME" "SYNC" "HEALTH" "TARGET" while read -r name sync health target; do if [[ "$sync" == "Synced" ]]; then printf "%-20s ${GREEN}%-12s${NC} %-12s %s\n" "$name" "$sync" "$health" "$target" elif [[ "$sync" == "OutOfSync" ]]; then printf "%-20s ${RED}%-12s${NC} %-12s %s\n" "$name" "$sync" "$health" "$target" - FAILED=1 else printf "%-20s %-12s %-12s %s\n" "$name" "$sync" "$health" "$target" fi From f7221f3990a62716cc5bb3edbed7e54d01fc66fa Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Sun, 22 Mar 2026 17:57:54 -0700 Subject: [PATCH 096/430] Update ringtail flake inputs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- nixos/ringtail/flake.lock | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/nixos/ringtail/flake.lock b/nixos/ringtail/flake.lock index 98fbbf9..89ccbc8 100644 --- a/nixos/ringtail/flake.lock +++ b/nixos/ringtail/flake.lock @@ -7,11 +7,11 @@ ] }, "locked": { - "lastModified": 1773025010, - "narHash": "sha256-khlHllTsovXgT2GZ0WxT4+RvuMjNeR5OW0UYeEHPYQo=", + "lastModified": 1773889306, + "narHash": "sha256-PAqwnsBSI9SVC2QugvQ3xeYCB0otOwCacB1ueQj2tgw=", "owner": "nix-community", "repo": "disko", - "rev": "7b9f7f88ab3b339f8142dc246445abb3c370d3d3", + "rev": "5ad85c82cc52264f4beddc934ba57f3789f28347", "type": "github" }, "original": { @@ -27,11 +27,11 @@ ] }, "locked": { - "lastModified": 1773264488, - "narHash": "sha256-rK0507bDuWBrZo+0zts9bCs/+RRUEHuvFE5DHWPxX/Q=", + "lastModified": 1773963144, + "narHash": "sha256-WzBOBfSay3GYilUfKaUa1Mbf8/jtuAiJIedx7fWuIX4=", "owner": "nix-community", "repo": "home-manager", - "rev": "5c0f63f8d55040a7eed69df7e3fcdd15dfb5a04c", + "rev": "a91b3ea73a765614d90360580b689c48102d1d33", "type": "github" }, "original": { @@ -43,11 +43,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1773375660, - "narHash": "sha256-SEzUWw2Rf5Ki3bcM26nSKgbeoqi2uYy8IHVBqOKjX3w=", + "lastModified": 1773964973, + "narHash": "sha256-NV/J+tTER0P5iJhUDL/8HO5MDjDceLQPRUYgdmy5wXw=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "3e20095fe3c6cbb1ddcef89b26969a69a1570776", + "rev": "812b3986fd1568f7a858f97fcf425ad996ba7d25", "type": "github" }, "original": { From 2cb0ce428933fa8725d5c05a3a1e8e2ada9dbf52 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Sun, 22 Mar 2026 18:18:45 -0700 Subject: [PATCH 097/430] Review and correct Tailscale reference doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix wrong ACL path, add missing device tags (ringtail, per-service tags, ci-gateway, flyio-proxy), correct access matrix (PyPI→DevPI, homelab grants), add homelab→homelab SSH rule, document auto approvers section, and add last-reviewed frontmatter. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- docs/changelog.d/+tailscale-doc-review.doc.md | 1 + docs/reference/infrastructure/tailscale.md | 53 +++++++++++++------ 2 files changed, 38 insertions(+), 16 deletions(-) create mode 100644 docs/changelog.d/+tailscale-doc-review.doc.md diff --git a/docs/changelog.d/+tailscale-doc-review.doc.md b/docs/changelog.d/+tailscale-doc-review.doc.md new file mode 100644 index 0000000..25a1fcc --- /dev/null +++ b/docs/changelog.d/+tailscale-doc-review.doc.md @@ -0,0 +1 @@ +Review and correct Tailscale reference doc: fix ACL path, add missing device tags (ringtail, per-service tags, ci-gateway, flyio-proxy), correct access matrix (PyPI→DevPI, homelab grants), add SSH homelab→homelab rule, document auto approvers, add last-reviewed frontmatter. diff --git a/docs/reference/infrastructure/tailscale.md b/docs/reference/infrastructure/tailscale.md index 5ccbaf1..e266a05 100644 --- a/docs/reference/infrastructure/tailscale.md +++ b/docs/reference/infrastructure/tailscale.md @@ -1,6 +1,7 @@ --- title: Tailscale -modified: 2026-02-08 +modified: 2026-03-22 +last-reviewed: 2026-03-22 tags: - infrastructure - networking @@ -12,7 +13,7 @@ Tailnet `tail8d86e.ts.net` provides secure networking for all BlumeOps infrastru ## ACL Management -ACLs managed via Pulumi in `pulumi/policy.hujson`. +ACLs managed via Pulumi in `pulumi/tailscale/policy.hujson`. ## Groups @@ -24,27 +25,42 @@ ACLs managed via Pulumi in `pulumi/policy.hujson`. | Tag | Devices | Purpose | |-----|---------|---------| -| `tag:homelab` | indri | Server infrastructure | +| `tag:homelab` | indri, ringtail | Server infrastructure | | `tag:nas` | sifaka | Network-attached storage | -| `tag:blumeops` | indri, sifaka | Pulumi IaC managed resources | -| `tag:registry` | indri | Container registry access | -| `tag:k8s-api` | indri | Kubernetes API server access | -| `tag:k8s-operator` | (operator pod) | Tailscale operator for k8s | -| `tag:k8s` | (Ingress proxy pods) | Kubernetes Tailscale Ingress nodes | -| `tag:flyio-target` | (k8s Ingress nodes) | Endpoints reachable by fly.io proxy | +| `tag:blumeops` | indri, sifaka, ringtail | Pulumi IaC managed resources | +| `tag:registry` | indri | Container registry (Zot) | +| `tag:forge` | indri | Forgejo git hosting | +| `tag:loki` | indri | Loki log aggregation | +| `tag:k8s-api` | indri | Kubernetes API server (minikube) | +| `tag:k8s-operator` | (operator pod) | Tailscale operator for k8s — see [[tailscale-operator]] | +| `tag:k8s` | (Ingress proxy pods) | Kubernetes Tailscale Ingress nodes; each also carries a per-service tag (`tag:grafana`, `tag:kiwix`, `tag:devpi`, `tag:feed`, `tag:pg`) | +| `tag:ci-gateway` | (ephemeral CI containers) | CI containers pushing images to registry | +| `tag:flyio-proxy` | (Fly.io proxy container) | Public reverse proxy | +| `tag:flyio-target` | (designated Ingress endpoints) | Endpoints reachable by the Fly.io proxy | -**Important:** Don't tag user-owned devices (like gilbert). Tagging converts them to "tagged devices" which lose user identity and break user-based SSH rules. +**Important:** Don't tag user-owned devices (like gilbert) via Pulumi. Tagging converts them to "tagged devices" which lose user identity and break user-based SSH rules. Gilbert is referenced as `tag:workstation` in tagOwners for ownership purposes but remains user-owned so `blume.erich@gmail.com` identity is preserved. ## Access Matrix -| Source | Kiwix | Forge | PyPI | Miniflux | PostgreSQL | NAS | Grafana | Loki | -|--------|-------|-------|------|----------|------------|-----|---------|------| +| Source | Kiwix | Forge | DevPI | Miniflux | PostgreSQL | NAS | Grafana | Loki | +|--------|-------|-------|-------|----------|------------|-----|---------|------| | `autogroup:admin` | Y | Y | Y | Y | Y | Y | Y | Y | -| `autogroup:member` | Y | Y | Y | Y | Y | - | - | - | -| `tag:homelab` | - | - | - | - | - | Y | - | - | +| `autogroup:member` | Y | Y (443, SSH) | Y | Y | Y (5432) | - | - | - | +| `tag:homelab` | - | - | - | - | Y (5432) | Y | - | Y (3100) | +| `tag:k8s` | - | Y (3001, 2200) | - | - | - | - | - | - | -- **Admins** - full access to all services -- **Members** - member services only, no Grafana/Loki/NAS +- **Admins** — full access to all services +- **Members** — user-facing services only; no Grafana, Loki, or NAS +- **Homelab** — server-to-server: full mutual access between homelab peers (including SSH), full NAS access, and k8s service access (443, 5432, 9187) +- **K8s** — can reach registry (443) and forge on indri (HTTP 3001, SSH 2200) for GitOps + +Additional grants not shown in the matrix: +- `tag:flyio-proxy` → `tag:flyio-target` on tcp:443 only +- `tag:ci-gateway` → `tag:registry` on tcp:443 +- `tag:k8s` → `tag:registry` on tcp:443 +- `tag:homelab` → `tag:k8s` on tcp:443, tcp:5432, tcp:9187 + +See `pulumi/tailscale/policy.hujson` for the full grant definitions. ## SSH Access @@ -53,6 +69,11 @@ ACLs managed via Pulumi in `pulumi/policy.hujson`. | `autogroup:member` | `autogroup:self` | check | | `autogroup:admin` | `tag:homelab` | check (12h) | | `autogroup:admin` | `tag:nas` | check (12h) | +| `tag:homelab` | `tag:homelab` | accept (tagged devices cannot perform interactive auth) | + +## Auto Approvers + +ProxyGroup pods (`tag:k8s`) can auto-approve their own VIP Services. This is required for multi-cluster Tailscale Ingress routing — without it, advertised ProxyGroup routes are not approved. See [[tailscale-operator]] for ProxyGroup configuration details. ## OAuth Credentials From 262299c82ab259048319319f3784398e2c807711 Mon Sep 17 00:00:00 2001 From: Forgejo Actions <actions@forge.ops.eblu.me> Date: Sun, 22 Mar 2026 18:20:41 -0700 Subject: [PATCH 098/430] Update docs release to v1.14.3 - Built changelog from towncrier fragments [skip ci] --- CHANGELOG.md | 33 +++++++++++++++++++ argocd/manifests/docs/deployment.yaml | 2 +- docs/changelog.d/+claude-code-subagents.ai.md | 1 - .../+fix-borgmatic-kubectl-context.bugfix.md | 1 - .../+fix-frigate-mqtt-config.bugfix.md | 1 - .../+frigate-health-checks.infra.md | 1 - .../+frigate-retention-and-check.infra.md | 1 - docs/changelog.d/+increase-retention.infra.md | 1 - docs/changelog.d/+oci-labels.infra.md | 1 - docs/changelog.d/+tailscale-doc-review.doc.md | 1 - .../+usage-pragma-consistency.misc.md | 1 - .../feature-localize-alloy-container.infra.md | 1 - .../mikado-deploy-infra-alerting.feature.md | 1 - .../upgrade-prometheus-3.10.0.infra.md | 1 - 14 files changed, 34 insertions(+), 13 deletions(-) delete mode 100644 docs/changelog.d/+claude-code-subagents.ai.md delete mode 100644 docs/changelog.d/+fix-borgmatic-kubectl-context.bugfix.md delete mode 100644 docs/changelog.d/+fix-frigate-mqtt-config.bugfix.md delete mode 100644 docs/changelog.d/+frigate-health-checks.infra.md delete mode 100644 docs/changelog.d/+frigate-retention-and-check.infra.md delete mode 100644 docs/changelog.d/+increase-retention.infra.md delete mode 100644 docs/changelog.d/+oci-labels.infra.md delete mode 100644 docs/changelog.d/+tailscale-doc-review.doc.md delete mode 100644 docs/changelog.d/+usage-pragma-consistency.misc.md delete mode 100644 docs/changelog.d/feature-localize-alloy-container.infra.md delete mode 100644 docs/changelog.d/mikado-deploy-infra-alerting.feature.md delete mode 100644 docs/changelog.d/upgrade-prometheus-3.10.0.infra.md diff --git a/CHANGELOG.md b/CHANGELOG.md index b7d2da4..fe58f8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,39 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). <!-- towncrier release notes start --> +## [v1.14.3] - 2026-03-22 + +### Features + +- Deploy infrastructure alerting pipeline using Grafana Unified Alerting with ntfy push notifications. 7 alert rules with runbooks covering service health, pod readiness, PostgreSQL, textfile freshness, Frigate cameras, and ArgoCD sync status. services-check now queries the alerting API for covered checks. + +### Bug Fixes + +- Fix Frigate NVR crash by re-adding required `mqtt` config section (disabled) after Mosquitto removal. +- Fix borgmatic backup failure: use correct kubectl context (`minikube`) on indri for Mealie SQLite dump hook + +### Infrastructure + +- Localize Grafana Alloy container image with dual Dockerfile + Nix builds from forge mirror +- Upgrade Prometheus from v3.9.1 to v3.10.0 (distroless variants, PromQL fill operators, performance improvements) +- Bump Frigate recording retention (180d continuous, 30d detections, 730d alerts) and add camera-fps health check to services-check. +- Improve Frigate health checks in services-check: per-camera FPS validation and NFS storage accessibility check. +- Increase data retention: Prometheus 15d → 10y, Loki 31d → 365d (PVC sizes unchanged; minikube hostpath doesn't enforce limits) +- Standardize OCI labels across all container Dockerfiles with consistent title, description, version, source, and vendor metadata. + +### Documentation + +- Review and correct Tailscale reference doc: fix ACL path, add missing device tags (ringtail, per-service tags, ci-gateway, flyio-proxy), correct access matrix (PyPI→DevPI, homelab grants), add SSH homelab→homelab rule, document auto approvers, add last-reviewed frontmatter. + +### AI Assistance + +- Add four Claude Code subagents: infra-health (background health monitor), doc-reviewer (persistent-memory doc review), change-classifier (C0/C1/C2 triage), and mikado-navigator (C2 chain state advisor). + +### Miscellaneous + +- Standardized USAGE pragmas and typer CLI parsing across all mise tasks: added missing `#USAGE` directive to `mikado-branch-invariant-check`, converted `pr-comments` and `op-backup` from raw `sys.argv` to typer for consistency with all other uv python scripts. + + ## [v1.14.2] - 2026-03-17 ### Features diff --git a/argocd/manifests/docs/deployment.yaml b/argocd/manifests/docs/deployment.yaml index 4aca950..85378f0 100644 --- a/argocd/manifests/docs/deployment.yaml +++ b/argocd/manifests/docs/deployment.yaml @@ -27,7 +27,7 @@ spec: name: http env: - name: DOCS_RELEASE_URL - value: "https://forge.eblu.me/eblume/blumeops/releases/download/v1.14.2/docs-v1.14.2.tar.gz" + value: "https://forge.eblu.me/eblume/blumeops/releases/download/v1.14.3/docs-v1.14.3.tar.gz" resources: requests: memory: "64Mi" diff --git a/docs/changelog.d/+claude-code-subagents.ai.md b/docs/changelog.d/+claude-code-subagents.ai.md deleted file mode 100644 index 584231b..0000000 --- a/docs/changelog.d/+claude-code-subagents.ai.md +++ /dev/null @@ -1 +0,0 @@ -Add four Claude Code subagents: infra-health (background health monitor), doc-reviewer (persistent-memory doc review), change-classifier (C0/C1/C2 triage), and mikado-navigator (C2 chain state advisor). diff --git a/docs/changelog.d/+fix-borgmatic-kubectl-context.bugfix.md b/docs/changelog.d/+fix-borgmatic-kubectl-context.bugfix.md deleted file mode 100644 index e91075a..0000000 --- a/docs/changelog.d/+fix-borgmatic-kubectl-context.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -Fix borgmatic backup failure: use correct kubectl context (`minikube`) on indri for Mealie SQLite dump hook diff --git a/docs/changelog.d/+fix-frigate-mqtt-config.bugfix.md b/docs/changelog.d/+fix-frigate-mqtt-config.bugfix.md deleted file mode 100644 index 8679317..0000000 --- a/docs/changelog.d/+fix-frigate-mqtt-config.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -Fix Frigate NVR crash by re-adding required `mqtt` config section (disabled) after Mosquitto removal. diff --git a/docs/changelog.d/+frigate-health-checks.infra.md b/docs/changelog.d/+frigate-health-checks.infra.md deleted file mode 100644 index a249765..0000000 --- a/docs/changelog.d/+frigate-health-checks.infra.md +++ /dev/null @@ -1 +0,0 @@ -Improve Frigate health checks in services-check: per-camera FPS validation and NFS storage accessibility check. diff --git a/docs/changelog.d/+frigate-retention-and-check.infra.md b/docs/changelog.d/+frigate-retention-and-check.infra.md deleted file mode 100644 index d09e510..0000000 --- a/docs/changelog.d/+frigate-retention-and-check.infra.md +++ /dev/null @@ -1 +0,0 @@ -Bump Frigate recording retention (180d continuous, 30d detections, 730d alerts) and add camera-fps health check to services-check. diff --git a/docs/changelog.d/+increase-retention.infra.md b/docs/changelog.d/+increase-retention.infra.md deleted file mode 100644 index 54bb4fd..0000000 --- a/docs/changelog.d/+increase-retention.infra.md +++ /dev/null @@ -1 +0,0 @@ -Increase data retention: Prometheus 15d → 10y, Loki 31d → 365d (PVC sizes unchanged; minikube hostpath doesn't enforce limits) diff --git a/docs/changelog.d/+oci-labels.infra.md b/docs/changelog.d/+oci-labels.infra.md deleted file mode 100644 index a09b3a8..0000000 --- a/docs/changelog.d/+oci-labels.infra.md +++ /dev/null @@ -1 +0,0 @@ -Standardize OCI labels across all container Dockerfiles with consistent title, description, version, source, and vendor metadata. diff --git a/docs/changelog.d/+tailscale-doc-review.doc.md b/docs/changelog.d/+tailscale-doc-review.doc.md deleted file mode 100644 index 25a1fcc..0000000 --- a/docs/changelog.d/+tailscale-doc-review.doc.md +++ /dev/null @@ -1 +0,0 @@ -Review and correct Tailscale reference doc: fix ACL path, add missing device tags (ringtail, per-service tags, ci-gateway, flyio-proxy), correct access matrix (PyPI→DevPI, homelab grants), add SSH homelab→homelab rule, document auto approvers, add last-reviewed frontmatter. diff --git a/docs/changelog.d/+usage-pragma-consistency.misc.md b/docs/changelog.d/+usage-pragma-consistency.misc.md deleted file mode 100644 index aeb93ad..0000000 --- a/docs/changelog.d/+usage-pragma-consistency.misc.md +++ /dev/null @@ -1 +0,0 @@ -Standardized USAGE pragmas and typer CLI parsing across all mise tasks: added missing `#USAGE` directive to `mikado-branch-invariant-check`, converted `pr-comments` and `op-backup` from raw `sys.argv` to typer for consistency with all other uv python scripts. diff --git a/docs/changelog.d/feature-localize-alloy-container.infra.md b/docs/changelog.d/feature-localize-alloy-container.infra.md deleted file mode 100644 index 42a2c21..0000000 --- a/docs/changelog.d/feature-localize-alloy-container.infra.md +++ /dev/null @@ -1 +0,0 @@ -Localize Grafana Alloy container image with dual Dockerfile + Nix builds from forge mirror diff --git a/docs/changelog.d/mikado-deploy-infra-alerting.feature.md b/docs/changelog.d/mikado-deploy-infra-alerting.feature.md deleted file mode 100644 index 7106014..0000000 --- a/docs/changelog.d/mikado-deploy-infra-alerting.feature.md +++ /dev/null @@ -1 +0,0 @@ -Deploy infrastructure alerting pipeline using Grafana Unified Alerting with ntfy push notifications. 7 alert rules with runbooks covering service health, pod readiness, PostgreSQL, textfile freshness, Frigate cameras, and ArgoCD sync status. services-check now queries the alerting API for covered checks. diff --git a/docs/changelog.d/upgrade-prometheus-3.10.0.infra.md b/docs/changelog.d/upgrade-prometheus-3.10.0.infra.md deleted file mode 100644 index a473ace..0000000 --- a/docs/changelog.d/upgrade-prometheus-3.10.0.infra.md +++ /dev/null @@ -1 +0,0 @@ -Upgrade Prometheus from v3.9.1 to v3.10.0 (distroless variants, PromQL fill operators, performance improvements) From 2e46f9982073ea225d90a41c62ec274d9cbe31f4 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Sun, 22 Mar 2026 19:31:22 -0700 Subject: [PATCH 099/430] =?UTF-8?q?Upgrade=20Tailscale=20operator=20v1.94.?= =?UTF-8?q?2=20=E2=86=92=20v1.96.3=20(#304)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Bump Tailscale operator, proxy containers, and init containers from v1.94.2 to v1.96.3 across both clusters (indri + ringtail via shared base kustomization) - Replace hand-rolled `until tailscale status` polling loop in `fly/start.sh` with `tailscale wait --timeout 60s` (new in v1.96.2) - Stamp kube-state-metrics review date (already current at v2.18.0) ## Notable upstream changes (v1.94.2 → v1.96.3) - Go upgraded from 1.25 to 1.26 - `tailscale wait` command — blocks until daemon is running + interface has IP - AuthKey policy now applies only when users are not logged in (behavioral change) - Peer Relay improvements (metrics, EC2 IMDS, UDP socket scaling) - UPnP stability fixes ## Deploy plan 1. Merge PR 2. Sync tailscale-operator on indri: `argocd app sync tailscale-operator` 3. Sync tailscale-operator on ringtail: `argocd app sync tailscale-operator-ringtail --server ringtail...` 4. Verify proxy pods roll with new image: `kubectl --context=minikube-indri -n tailscale get pods` 5. Verify ingress connectivity (spot-check a few `*.tail8d86e.ts.net` services) 6. Rebuild + deploy Fly proxy container (separate step, picks up `tailscale wait` change) ## Test plan - [ ] ArgoCD diff looks clean for both apps before sync - [ ] Proxy pods on indri come up healthy with v1.96.3 images - [ ] Proxy pods on ringtail come up healthy with v1.96.3 images - [ ] Tailscale ingress services remain reachable (e.g., grafana, prometheus) - [ ] Fly proxy rebuild deploys successfully with `tailscale wait` 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/304 --- argocd/manifests/tailscale-operator-base/kustomization.yaml | 4 ++-- argocd/manifests/tailscale-operator-base/proxyclass.yaml | 4 ++-- docs/changelog.d/upgrade-tailscale-operator-1.96.3.infra.md | 1 + fly/start.sh | 3 +-- service-versions.yaml | 6 +++--- 5 files changed, 9 insertions(+), 9 deletions(-) create mode 100644 docs/changelog.d/upgrade-tailscale-operator-1.96.3.infra.md diff --git a/argocd/manifests/tailscale-operator-base/kustomization.yaml b/argocd/manifests/tailscale-operator-base/kustomization.yaml index 4519af6..bd52505 100644 --- a/argocd/manifests/tailscale-operator-base/kustomization.yaml +++ b/argocd/manifests/tailscale-operator-base/kustomization.yaml @@ -7,14 +7,14 @@ namespace: tailscale # Upstream Tailscale operator manifest from forge mirror. # To upgrade: update the ref in the URL AND the newTag below. resources: - - https://forge.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.96.3/cmd/k8s-operator/deploy/manifests/operator.yaml - proxyclass.yaml - dnsconfig.yaml images: - name: tailscale/k8s-operator newName: docker.io/tailscale/k8s-operator - newTag: v1.94.2 + newTag: v1.96.3 # The upstream manifest includes a placeholder OAuth Secret with empty values. # We manage this secret via ExternalSecret, so drop the upstream copy. diff --git a/argocd/manifests/tailscale-operator-base/proxyclass.yaml b/argocd/manifests/tailscale-operator-base/proxyclass.yaml index a5c4675..e0935d4 100644 --- a/argocd/manifests/tailscale-operator-base/proxyclass.yaml +++ b/argocd/manifests/tailscale-operator-base/proxyclass.yaml @@ -20,6 +20,6 @@ spec: statefulSet: pod: tailscaleContainer: - image: docker.io/tailscale/tailscale:v1.94.2 + image: docker.io/tailscale/tailscale:v1.96.3 tailscaleInitContainer: - image: docker.io/tailscale/tailscale:v1.94.2 + image: docker.io/tailscale/tailscale:v1.96.3 diff --git a/docs/changelog.d/upgrade-tailscale-operator-1.96.3.infra.md b/docs/changelog.d/upgrade-tailscale-operator-1.96.3.infra.md new file mode 100644 index 0000000..21fbf2e --- /dev/null +++ b/docs/changelog.d/upgrade-tailscale-operator-1.96.3.infra.md @@ -0,0 +1 @@ +Upgrade Tailscale operator v1.94.2 → v1.96.3; replace Fly proxy polling loop with `tailscale wait` diff --git a/fly/start.sh b/fly/start.sh index 5ec45db..96f6038 100644 --- a/fly/start.sh +++ b/fly/start.sh @@ -7,9 +7,8 @@ set -e # natively — no need for --tun=userspace-networking. tailscaled --statedir=/var/lib/tailscale & sleep 2 - tailscale up --authkey="${TS_AUTHKEY}" --hostname=flyio-proxy -until tailscale status > /dev/null 2>&1; do sleep 1; done +tailscale wait --timeout 60s echo "Tailscale connected" # Ensure fail2ban deny file exists before nginx starts diff --git a/service-versions.yaml b/service-versions.yaml index 8e5d0f3..e0c932c 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -26,7 +26,7 @@ services: - name: kube-state-metrics type: argocd - last-reviewed: 2026-02-16 + last-reviewed: 2026-03-22 current-version: "v2.18.0" upstream-source: https://github.com/kubernetes/kube-state-metrics/releases @@ -91,8 +91,8 @@ services: - name: tailscale-operator type: argocd - last-reviewed: 2026-02-16 - current-version: "v1.94.2" + last-reviewed: 2026-03-22 + current-version: "v1.96.3" upstream-source: https://github.com/tailscale/tailscale/releases - name: grafana From e9b8e3d80bc18dc525d81222eb8c987f8d2db32c Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Sun, 22 Mar 2026 19:41:40 -0700 Subject: [PATCH 100/430] =?UTF-8?q?Revert=20Tailscale=20operator=20to=20v1?= =?UTF-8?q?.94.2=20=E2=80=94=20images=20not=20yet=20published?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1.96.3 exists as a GitHub release but Docker Hub images for both tailscale/tailscale and tailscale/k8s-operator haven't been published yet (v1.94.2 is still latest). Revert the image tags; the fly/start.sh `tailscale wait` improvement and review date stamps are retained. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- argocd/manifests/tailscale-operator-base/kustomization.yaml | 4 ++-- argocd/manifests/tailscale-operator-base/proxyclass.yaml | 4 ++-- docs/changelog.d/upgrade-tailscale-operator-1.96.3.infra.md | 2 +- service-versions.yaml | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/argocd/manifests/tailscale-operator-base/kustomization.yaml b/argocd/manifests/tailscale-operator-base/kustomization.yaml index bd52505..4519af6 100644 --- a/argocd/manifests/tailscale-operator-base/kustomization.yaml +++ b/argocd/manifests/tailscale-operator-base/kustomization.yaml @@ -7,14 +7,14 @@ namespace: tailscale # Upstream Tailscale operator manifest from forge mirror. # To upgrade: update the ref in the URL AND the newTag below. resources: - - https://forge.eblu.me/mirrors/tailscale/raw/tag/v1.96.3/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 - dnsconfig.yaml images: - name: tailscale/k8s-operator newName: docker.io/tailscale/k8s-operator - newTag: v1.96.3 + newTag: v1.94.2 # The upstream manifest includes a placeholder OAuth Secret with empty values. # We manage this secret via ExternalSecret, so drop the upstream copy. diff --git a/argocd/manifests/tailscale-operator-base/proxyclass.yaml b/argocd/manifests/tailscale-operator-base/proxyclass.yaml index e0935d4..a5c4675 100644 --- a/argocd/manifests/tailscale-operator-base/proxyclass.yaml +++ b/argocd/manifests/tailscale-operator-base/proxyclass.yaml @@ -20,6 +20,6 @@ spec: statefulSet: pod: tailscaleContainer: - image: docker.io/tailscale/tailscale:v1.96.3 + image: docker.io/tailscale/tailscale:v1.94.2 tailscaleInitContainer: - image: docker.io/tailscale/tailscale:v1.96.3 + image: docker.io/tailscale/tailscale:v1.94.2 diff --git a/docs/changelog.d/upgrade-tailscale-operator-1.96.3.infra.md b/docs/changelog.d/upgrade-tailscale-operator-1.96.3.infra.md index 21fbf2e..a0f50db 100644 --- a/docs/changelog.d/upgrade-tailscale-operator-1.96.3.infra.md +++ b/docs/changelog.d/upgrade-tailscale-operator-1.96.3.infra.md @@ -1 +1 @@ -Upgrade Tailscale operator v1.94.2 → v1.96.3; replace Fly proxy polling loop with `tailscale wait` +Revert Tailscale operator to v1.94.2 (v1.96.3 images not yet published); keep Fly proxy `tailscale wait` improvement diff --git a/service-versions.yaml b/service-versions.yaml index e0c932c..23a31f9 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -92,7 +92,7 @@ services: - name: tailscale-operator type: argocd last-reviewed: 2026-03-22 - current-version: "v1.96.3" + current-version: "v1.94.2" upstream-source: https://github.com/tailscale/tailscale/releases - name: grafana From 044ad7dad7d25db300b67f9e1a90d89272816880 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Sun, 22 Mar 2026 19:44:47 -0700 Subject: [PATCH 101/430] =?UTF-8?q?Revert=20fly/start.sh=20to=20polling=20?= =?UTF-8?q?loop=20=E2=80=94=20tailscale=20wait=20needs=20v1.96.2+?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Fly container pulls from tailscale/tailscale:stable which is still v1.94.2. The `tailscale wait` command doesn't exist until v1.96.2. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- fly/start.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fly/start.sh b/fly/start.sh index 96f6038..5b08490 100644 --- a/fly/start.sh +++ b/fly/start.sh @@ -8,7 +8,7 @@ set -e tailscaled --statedir=/var/lib/tailscale & sleep 2 tailscale up --authkey="${TS_AUTHKEY}" --hostname=flyio-proxy -tailscale wait --timeout 60s +until tailscale status > /dev/null 2>&1; do sleep 1; done echo "Tailscale connected" # Ensure fail2ban deny file exists before nginx starts From 3750428b589c70d4a052faefa513f7b1246e45b1 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Sun, 22 Mar 2026 20:42:37 -0700 Subject: [PATCH 102/430] Fix ArgoCD apps app permanent OutOfSync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove `group: ""` from ignoreDifferences in tailscale-operator and tailscale-operator-ringtail — ArgoCD normalizes away the empty string field, so the live state never matches git. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- argocd/apps/tailscale-operator-ringtail.yaml | 3 +-- argocd/apps/tailscale-operator.yaml | 3 +-- docs/changelog.d/+fix-apps-outofsync.bugfix.md | 1 + 3 files changed, 3 insertions(+), 4 deletions(-) create mode 100644 docs/changelog.d/+fix-apps-outofsync.bugfix.md diff --git a/argocd/apps/tailscale-operator-ringtail.yaml b/argocd/apps/tailscale-operator-ringtail.yaml index a261354..1e15d09 100644 --- a/argocd/apps/tailscale-operator-ringtail.yaml +++ b/argocd/apps/tailscale-operator-ringtail.yaml @@ -11,8 +11,7 @@ spec: project: default # Tailscale operator mutates externalName from "placeholder" to actual proxy service ignoreDifferences: - - group: "" - kind: Service + - kind: Service jsonPointers: - /spec/externalName source: diff --git a/argocd/apps/tailscale-operator.yaml b/argocd/apps/tailscale-operator.yaml index 4ca5ea7..9e95c16 100644 --- a/argocd/apps/tailscale-operator.yaml +++ b/argocd/apps/tailscale-operator.yaml @@ -9,8 +9,7 @@ spec: project: default # Tailscale operator mutates externalName from "placeholder" to actual proxy service ignoreDifferences: - - group: "" - kind: Service + - kind: Service jsonPointers: - /spec/externalName source: diff --git a/docs/changelog.d/+fix-apps-outofsync.bugfix.md b/docs/changelog.d/+fix-apps-outofsync.bugfix.md new file mode 100644 index 0000000..00faf4f --- /dev/null +++ b/docs/changelog.d/+fix-apps-outofsync.bugfix.md @@ -0,0 +1 @@ +Remove `group: ""` from tailscale-operator ignoreDifferences — ArgoCD normalizes away the empty string, causing permanent OutOfSync on the apps app. From 06e721841c5087be27bed520cd4b74d91fa1ecf6 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Mon, 23 Mar 2026 09:51:57 -0700 Subject: [PATCH 103/430] Review 12 reference docs: fix stale image refs, expand stubs, add cross-refs Replace hardcoded image tags in Quick Reference tables with pointers to kustomization manifests (tags drift with every container release). Fix Prometheus CNPG scrape target, remove misleading .ts.net URLs, expand external-secrets stub, add backup/disaster-recovery cross-references. Limit doc-reviewer agent to one doc per cycle. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- .claude/agents/doc-reviewer.md | 11 ++++++----- .../changelog.d/+doc-review-march-2026.doc.md | 1 + docs/reference/kubernetes/external-secrets.md | 19 +++++++++++++++++-- docs/reference/operations/backup.md | 6 ++++-- .../reference/operations/disaster-recovery.md | 4 +++- docs/reference/services/devpi.md | 5 +++-- docs/reference/services/docs.md | 16 ++++++++-------- docs/reference/services/immich.md | 1 + docs/reference/services/jellyfin.md | 1 + docs/reference/services/loki.md | 6 +++--- docs/reference/services/miniflux.md | 6 +++--- docs/reference/services/prometheus.md | 8 ++++---- docs/reference/services/teslamate.md | 8 ++++---- docs/reference/storage/sifaka.md | 1 + 14 files changed, 59 insertions(+), 34 deletions(-) create mode 100644 docs/changelog.d/+doc-review-march-2026.doc.md diff --git a/.claude/agents/doc-reviewer.md b/.claude/agents/doc-reviewer.md index 7e73f8b..5ab941d 100644 --- a/.claude/agents/doc-reviewer.md +++ b/.claude/agents/doc-reviewer.md @@ -11,11 +11,12 @@ You are a documentation reviewer for the BlumeOps homelab infrastructure project ## Workflow 1. Run `mise run docs-review` to see the staleness table and identify the most stale doc -2. Read the identified doc thoroughly -3. Perform the review checklist (below) -4. Check your agent memory for notes from past reviews of this doc or related docs -5. Present your findings as a structured report -6. Update your agent memory with anything you learned +2. **Review exactly ONE document** — the single most stale doc from the table. Do not review multiple docs in one cycle. The main conversation will invoke you again if more reviews are needed. +3. Read the identified doc thoroughly +4. Perform the review checklist (below) +5. Check your agent memory for notes from past reviews of this doc or related docs +6. Present your findings as a structured report +7. Update your agent memory with anything you learned ## Review Checklist diff --git a/docs/changelog.d/+doc-review-march-2026.doc.md b/docs/changelog.d/+doc-review-march-2026.doc.md new file mode 100644 index 0000000..40cbc7f --- /dev/null +++ b/docs/changelog.d/+doc-review-march-2026.doc.md @@ -0,0 +1 @@ +Review and update 12 reference docs: fix stale image references to point at kustomization manifests instead of hardcoded tags, correct Prometheus scrape target, expand external-secrets stub, add cross-references between backup/disaster-recovery docs, and remove misleading `.ts.net` URLs from Quick Reference tables. diff --git a/docs/reference/kubernetes/external-secrets.md b/docs/reference/kubernetes/external-secrets.md index 8efcbaf..3a1e08e 100644 --- a/docs/reference/kubernetes/external-secrets.md +++ b/docs/reference/kubernetes/external-secrets.md @@ -1,6 +1,7 @@ --- title: External Secrets -modified: 2026-02-07 +modified: 2026-03-23 +last-reviewed: 2026-03-23 tags: - kubernetes - secrets @@ -8,4 +9,18 @@ tags: # External Secrets -See [[1password]] in Services. +The [External Secrets Operator](https://external-secrets.io/) syncs secrets from 1Password into Kubernetes Secrets. It runs in the `1password-connect` namespace alongside the 1Password Connect server. + +## How It Works + +Each service that needs secrets defines an `ExternalSecret` resource referencing a 1Password item and field. The operator polls 1Password Connect and creates/updates native Kubernetes Secrets. + +## Manifests + +- **Operator + Connect server:** `argocd/manifests/1password-connect/` +- **Per-service ExternalSecrets:** in each service's manifest directory (e.g., `argocd/manifests/grafana-config/external-secret-*.yaml`) + +## Related + +- [[1password]] - Credential management +- [[security-model]] - Secrets flow architecture diff --git a/docs/reference/operations/backup.md b/docs/reference/operations/backup.md index 5403d13..50d8daa 100644 --- a/docs/reference/operations/backup.md +++ b/docs/reference/operations/backup.md @@ -1,6 +1,7 @@ --- title: Backup -modified: 2026-02-07 +modified: 2026-03-23 +last-reviewed: 2026-03-23 tags: - operations --- @@ -13,4 +14,5 @@ Daily automated backups of BlumeOps data. - [[borgmatic]] - Backup orchestration - [[sifaka|Sifaka]] - Backup target (NAS) -- [[backups|backup-policy]] - What gets backed up and retention +- [[backups]] - What gets backed up and retention +- [[disaster-recovery]] - Recovery procedures diff --git a/docs/reference/operations/disaster-recovery.md b/docs/reference/operations/disaster-recovery.md index 475cf1c..b144aaf 100644 --- a/docs/reference/operations/disaster-recovery.md +++ b/docs/reference/operations/disaster-recovery.md @@ -1,6 +1,7 @@ --- title: Disaster Recovery -modified: 2026-02-10 +modified: 2026-03-23 +last-reviewed: 2026-03-23 tags: - operations --- @@ -18,6 +19,7 @@ Recovery procedures for BlumeOps infrastructure. ## Components +- [[backup]] - Backup overview - [[borgmatic]] - Backup restoration - [[1password]] - Credential recovery (backed up via `mise run op-backup`) - [[forgejo]] - Source of truth for infrastructure code diff --git a/docs/reference/services/devpi.md b/docs/reference/services/devpi.md index 74a05a3..c6493fe 100644 --- a/docs/reference/services/devpi.md +++ b/docs/reference/services/devpi.md @@ -1,6 +1,7 @@ --- title: Devpi -modified: 2026-02-07 +modified: 2026-03-23 +last-reviewed: 2026-03-23 tags: - service - python @@ -18,7 +19,7 @@ PyPI caching proxy and private package index. | **Namespace** | `devpi` | | **ArgoCD App** | `devpi` | | **Storage** | 50Gi PVC | -| **Image** | `registry.ops.eblu.me/blumeops/devpi:latest` | +| **Image** | `registry.ops.eblu.me/blumeops/devpi` (see `argocd/manifests/devpi/kustomization.yaml` for current tag) | ## Indices diff --git a/docs/reference/services/docs.md b/docs/reference/services/docs.md index 6c3bd21..1361d02 100644 --- a/docs/reference/services/docs.md +++ b/docs/reference/services/docs.md @@ -1,6 +1,7 @@ --- title: Docs -modified: 2026-02-08 +modified: 2026-03-23 +last-reviewed: 2026-03-23 tags: - service - documentation @@ -17,7 +18,7 @@ Documentation site built with [Quartz](https://quartz.jzhao.xyz/) and served via | **Public URL** | https://docs.eblu.me | | **Private URL** | `docs.ops.eblu.me` (tailnet only, via [[caddy]]) | | **Namespace** | `docs` | -| **Container** | `registry.ops.eblu.me/blumeops/quartz:v1.0.0` | +| **Image** | `registry.ops.eblu.me/blumeops/quartz` (see `argocd/manifests/docs/kustomization.yaml` for current tag) | | **Source** | `docs/` directory in blumeops repo | | **Build** | Forgejo workflow `build-blumeops.yaml` | | **Public proxy** | [[flyio-proxy]] (Fly.io → Tailscale tunnel) | @@ -31,13 +32,12 @@ Documentation site built with [Quartz](https://quartz.jzhao.xyz/) and served via ## Release Process -Documentation is automatically built and released when changes are pushed to main: +Documentation is built and released via the `build-blumeops` Forgejo workflow (manual dispatch): -1. Workflow detects changes in `docs/` directory -2. Quartz builds static HTML/CSS/JS -3. Assets uploaded as release attachment -4. ArgoCD deployment updated with new `DOCS_RELEASE_URL` -5. Pod restarts and downloads new bundle +1. Quartz builds static HTML/CSS/JS +2. Assets uploaded as Forgejo release attachment +3. Workflow updates `DOCS_RELEASE_URL` in `argocd/manifests/docs/deployment.yaml` and commits to main +4. ArgoCD syncs the updated deployment; new pod downloads the release bundle at startup ## Configuration diff --git a/docs/reference/services/immich.md b/docs/reference/services/immich.md index 915bbed..740dfa4 100644 --- a/docs/reference/services/immich.md +++ b/docs/reference/services/immich.md @@ -1,6 +1,7 @@ --- title: Immich modified: 2026-02-07 +last-reviewed: 2026-03-23 tags: - service - media diff --git a/docs/reference/services/jellyfin.md b/docs/reference/services/jellyfin.md index 85040fc..bbdfafd 100644 --- a/docs/reference/services/jellyfin.md +++ b/docs/reference/services/jellyfin.md @@ -1,6 +1,7 @@ --- title: Jellyfin modified: 2026-02-07 +last-reviewed: 2026-03-23 tags: - service - media diff --git a/docs/reference/services/loki.md b/docs/reference/services/loki.md index cbdc573..2b3b44e 100644 --- a/docs/reference/services/loki.md +++ b/docs/reference/services/loki.md @@ -1,6 +1,7 @@ --- title: Loki -modified: 2026-02-08 +modified: 2026-03-23 +last-reviewed: 2026-03-23 tags: - service - observability @@ -15,9 +16,8 @@ Log aggregation system for BlumeOps infrastructure. | Property | Value | |----------|-------| | **URL** | https://loki.ops.eblu.me | -| **Tailscale URL** | https://loki.tail8d86e.ts.net | | **Namespace** | `monitoring` | -| **Image** | `grafana/loki:3.4.2` | +| **Image** | `registry.ops.eblu.me/blumeops/loki` (see `argocd/manifests/loki/kustomization.yaml` for current tag) | | **Storage** | 50Gi PVC | | **Retention** | 31 days | diff --git a/docs/reference/services/miniflux.md b/docs/reference/services/miniflux.md index 70c1ff2..c34e5f7 100644 --- a/docs/reference/services/miniflux.md +++ b/docs/reference/services/miniflux.md @@ -1,6 +1,7 @@ --- title: Miniflux -modified: 2026-02-07 +modified: 2026-03-23 +last-reviewed: 2026-03-23 tags: - service - rss @@ -15,9 +16,8 @@ Minimalist RSS/Atom feed reader. | Property | Value | |----------|-------| | **URL** | https://feed.ops.eblu.me | -| **Tailscale URL** | https://feed.tail8d86e.ts.net | | **Namespace** | `miniflux` | -| **Image** | `ghcr.io/miniflux/miniflux:latest` | +| **Image** | `registry.ops.eblu.me/blumeops/miniflux` (see `argocd/manifests/miniflux/kustomization.yaml` for current tag) | | **Database** | [[postgresql]] | ## Features diff --git a/docs/reference/services/prometheus.md b/docs/reference/services/prometheus.md index eaf48b1..4d23588 100644 --- a/docs/reference/services/prometheus.md +++ b/docs/reference/services/prometheus.md @@ -1,6 +1,7 @@ --- title: Prometheus -modified: 2026-02-08 +modified: 2026-03-23 +last-reviewed: 2026-03-23 tags: - service - observability @@ -15,9 +16,8 @@ Metrics storage and querying for BlumeOps infrastructure. | Property | Value | |----------|-------| | **URL** | https://prometheus.ops.eblu.me | -| **Tailscale URL** | https://prometheus.tail8d86e.ts.net | | **Namespace** | `monitoring` | -| **Image** | `prom/prometheus:v3.2.1` | +| **Image** | `registry.ops.eblu.me/blumeops/prometheus` (see `argocd/manifests/prometheus/kustomization.yaml` for current tag) | | **Storage** | 50Gi PVC | | **Manifests** | `argocd/manifests/prometheus/` | @@ -33,7 +33,7 @@ Metrics storage and querying for BlumeOps infrastructure. | Target | Metrics | |--------|---------| | `sifaka:9100` | [[sifaka|Sifaka]] NAS (node_exporter) | -| `cnpg-metrics.tail8d86e.ts.net:9187` | [[postgresql|CloudNativePG]] metrics | +| `blumeops-pg-metrics-tailscale.databases.svc.cluster.local:9187` | [[postgresql|CloudNativePG]] metrics | | `kube-state-metrics.monitoring.svc:8080` | Kubernetes resource metrics | ## Related diff --git a/docs/reference/services/teslamate.md b/docs/reference/services/teslamate.md index a891255..f02e979 100644 --- a/docs/reference/services/teslamate.md +++ b/docs/reference/services/teslamate.md @@ -1,6 +1,7 @@ --- title: TeslaMate -modified: 2026-02-07 +modified: 2026-03-23 +last-reviewed: 2026-03-23 tags: - service - vehicle @@ -8,16 +9,15 @@ tags: # TeslaMate -Self-hosted Tesla data logger collecting vehicle telemetry from the Tesla Owner API. +Self-hosted Tesla data logger collecting vehicle telemetry from the Tesla API. ## Quick Reference | Property | Value | |----------|-------| | **URL** | https://tesla.ops.eblu.me | -| **Tailscale URL** | https://tesla.tail8d86e.ts.net | | **Namespace** | `teslamate` | -| **Image** | `teslamate/teslamate:2.2.0` | +| **Image** | `registry.ops.eblu.me/blumeops/teslamate` (see `argocd/manifests/teslamate/kustomization.yaml` for current tag) | | **Database** | [[postgresql]] | ## Data Collected diff --git a/docs/reference/storage/sifaka.md b/docs/reference/storage/sifaka.md index a994923..31fe90a 100644 --- a/docs/reference/storage/sifaka.md +++ b/docs/reference/storage/sifaka.md @@ -1,6 +1,7 @@ --- title: Sifaka modified: 2026-02-09 +last-reviewed: 2026-03-23 tags: - storage --- From d1dac0c2413c87198453b1f7a1e4d7a9d33f4ed6 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Mon, 23 Mar 2026 10:32:06 -0700 Subject: [PATCH 104/430] =?UTF-8?q?Upgrade=20ntfy=20v2.17.0=20=E2=86=92=20?= =?UTF-8?q?v2.19.2=20(#305)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Upgrade ntfy from v2.17.0 to v2.19.2 - Update Dockerfile and Nix build definitions with new version, commit SHA, and hashes - Add `subPackages = [ "." ]` to Nix build to handle new `tools/loadtest` module in upstream ## Upstream changes (no breaking changes) - **v2.18.0:** Experimental PostgreSQL backend support - **v2.19.0:** PostgreSQL read replica support, notification sound throttling - **v2.19.1-2:** PostgreSQL bug fixes, web push race condition fix ## Test plan - [ ] Container builds complete on Forgejo Actions (both Dockerfile and Nix) - [ ] Update kustomization.yaml `newTag` to the built nix image tag - [ ] `argocd app set ntfy --revision upgrade/ntfy-v2.19.2 && argocd app sync ntfy` - [ ] Verify ntfy health: `curl https://ntfy.ops.eblu.me/v1/health` - [ ] Send a test notification Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/305 --- argocd/manifests/ntfy/kustomization.yaml | 2 +- containers/ntfy/Dockerfile | 4 ++-- containers/ntfy/default.nix | 12 +++++++----- docs/changelog.d/upgrade-ntfy-v2.19.2.infra.md | 1 + service-versions.yaml | 4 ++-- 5 files changed, 13 insertions(+), 10 deletions(-) create mode 100644 docs/changelog.d/upgrade-ntfy-v2.19.2.infra.md diff --git a/argocd/manifests/ntfy/kustomization.yaml b/argocd/manifests/ntfy/kustomization.yaml index aa4355f..a2bca10 100644 --- a/argocd/manifests/ntfy/kustomization.yaml +++ b/argocd/manifests/ntfy/kustomization.yaml @@ -8,7 +8,7 @@ resources: - ingress-tailscale.yaml images: - name: registry.ops.eblu.me/blumeops/ntfy - newTag: v2.17.0-613f05d-nix + newTag: v2.19.2-f654c57-nix configMapGenerator: - name: ntfy-config files: diff --git a/containers/ntfy/Dockerfile b/containers/ntfy/Dockerfile index 238e8c1..e53a39d 100644 --- a/containers/ntfy/Dockerfile +++ b/containers/ntfy/Dockerfile @@ -1,9 +1,9 @@ # ntfy push notification server # Three-stage build: Web UI (Node), server (Go+SQLite), runtime (Alpine) -ARG CONTAINER_APP_VERSION=v2.17.0 +ARG CONTAINER_APP_VERSION=v2.19.2 ARG NTFY_VERSION=${CONTAINER_APP_VERSION} -ARG NTFY_COMMIT=a03a37feb1869e84e3af0dd6190bdc7183f211ec +ARG NTFY_COMMIT=2ad78edca12f1a43c566ffab9e93b3ed26426a6c FROM node:22-alpine AS web-build diff --git a/containers/ntfy/default.nix b/containers/ntfy/default.nix index 5164bba..7c13afd 100644 --- a/containers/ntfy/default.nix +++ b/containers/ntfy/default.nix @@ -1,21 +1,21 @@ # Nix-built ntfy push notification server -# Builds v2.17.0 from forge mirror (nixpkgs has 2.15.0) +# Builds v2.19.2 from forge mirror # Built with dockerTools.buildLayeredImage for efficient layer caching { pkgs ? import <nixpkgs> { } }: let - version = "2.17.0"; + version = "2.19.2"; src = pkgs.fetchgit { url = "https://forge.ops.eblu.me/mirrors/ntfy.git"; rev = "v${version}"; - hash = "sha256-/dxILAkye1HwYcybnx1WrMRK2jXZMrxal2ZKm6y2bWc="; + hash = "sha256-HISQnb6LkKGujZsWCzVD3dTuobhUXqrmTFuov7dU+lY="; }; ui = pkgs.buildNpmPackage { inherit src version; pname = "ntfy-sh-ui"; - npmDepsHash = "sha256-d73rymqCKalsjAwHSJshEovmUHJStfGt8wcZYN49sHY="; + npmDepsHash = "sha256-PmhWzktybM6Cg7yYRfbxWE83C+XkmHh4garHhsydwwE="; prePatch = '' cd web/ @@ -34,10 +34,12 @@ let ntfy = pkgs.buildGoModule { inherit src version; pname = "ntfy-sh"; - vendorHash = "sha256-/mQ+UwBYz78mPVVwYgsSYatE00ce2AKXJdx+nl6oT8E="; + vendorHash = "sha256-mr2PbxT5QWf4HZGgUg+oUjauqmZ6bh6N3f0ytwPDppU="; doCheck = false; + subPackages = [ "." ]; + ldflags = [ "-s" "-w" diff --git a/docs/changelog.d/upgrade-ntfy-v2.19.2.infra.md b/docs/changelog.d/upgrade-ntfy-v2.19.2.infra.md new file mode 100644 index 0000000..4eccbfe --- /dev/null +++ b/docs/changelog.d/upgrade-ntfy-v2.19.2.infra.md @@ -0,0 +1 @@ +Upgrade ntfy v2.17.0 → v2.19.2 (adds experimental PostgreSQL support, read replicas, web push fixes) diff --git a/service-versions.yaml b/service-versions.yaml index 23a31f9..28013bc 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -33,8 +33,8 @@ services: - name: ntfy type: argocd - last-reviewed: 2026-02-17 - current-version: "v2.17.0" + last-reviewed: 2026-03-23 + current-version: "v2.19.2" upstream-source: https://github.com/binwiederhier/ntfy/releases - name: homepage From 4cc26ed5ebd95b1f090e63fa195174cebb6b5245 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Mon, 23 Mar 2026 10:36:34 -0700 Subject: [PATCH 105/430] Update ntfy tag to main build v2.19.2-d1dac0c-nix C0 fix-forward: switch from branch-built image to main-built image. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- argocd/manifests/ntfy/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/argocd/manifests/ntfy/kustomization.yaml b/argocd/manifests/ntfy/kustomization.yaml index a2bca10..2465c83 100644 --- a/argocd/manifests/ntfy/kustomization.yaml +++ b/argocd/manifests/ntfy/kustomization.yaml @@ -8,7 +8,7 @@ resources: - ingress-tailscale.yaml images: - name: registry.ops.eblu.me/blumeops/ntfy - newTag: v2.19.2-f654c57-nix + newTag: v2.19.2-d1dac0c-nix configMapGenerator: - name: ntfy-config files: From bd0ff30d3f1319ca9e002b80f1869719d4811d01 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Mon, 23 Mar 2026 20:55:50 -0700 Subject: [PATCH 106/430] Unify container build workflows (#306) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Merges `build-container.yaml` and `build-container-nix.yaml` into a single workflow - Detect job classifies each changed container by presence of `Dockerfile` and/or `default.nix` - Dockerfile containers build on `k8s` (indri) via Dagger; Nix containers build on `nix-container-builder` (ringtail) via nix-build + skopeo - Containers with both build files (alloy, nettest, ntfy) get built on both runners ## Test plan - [ ] Push a change to a Dockerfile-only container (e.g. grafana) — verify it builds on k8s only - [ ] Push a change to a nix-only container (e.g. jobsync) — verify it builds on nix-container-builder only - [ ] Push a change to a dual container (e.g. ntfy) — verify it builds on both runners - [ ] Test workflow_dispatch with a specific container name 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/306 --- .forgejo/workflows/build-container-nix.yaml | 147 ------------------ .forgejo/workflows/build-container.yaml | 143 +++++++++++++---- containers/nettest/Dockerfile | 27 ---- containers/nettest/default.nix | 39 ----- containers/nettest/test-connectivity.sh | 115 -------------- .../unify-container-workflows.infra.md | 1 + .../deployment/build-container-image.md | 6 +- .../zot/add-container-version-sync-check.md | 2 +- docs/how-to/zot/add-dagger-nix-build.md | 4 +- docs/how-to/zot/pin-container-versions.md | 1 - docs/reference/tools/dagger.md | 2 +- mise-tasks/container-version-check | 2 +- 12 files changed, 124 insertions(+), 365 deletions(-) delete mode 100644 .forgejo/workflows/build-container-nix.yaml delete mode 100644 containers/nettest/Dockerfile delete mode 100644 containers/nettest/default.nix delete mode 100644 containers/nettest/test-connectivity.sh create mode 100644 docs/changelog.d/unify-container-workflows.infra.md diff --git a/.forgejo/workflows/build-container-nix.yaml b/.forgejo/workflows/build-container-nix.yaml deleted file mode 100644 index f095881..0000000 --- a/.forgejo/workflows/build-container-nix.yaml +++ /dev/null @@ -1,147 +0,0 @@ -# Nix container build workflow -# Triggers on pushes to main that modify containers/*, or via manual dispatch. -# Detects which containers changed, builds from default.nix, and pushes via -# skopeo with commit-SHA-based tags: vX.Y.Z-<sha>-nix -name: Build Container (Nix) - -on: - push: - branches: [main] - paths: ['containers/**'] - workflow_dispatch: - inputs: - container: - description: 'Container name (directory under containers/)' - required: true - type: string - ref: - description: 'Commit SHA to build (defaults to current HEAD)' - required: false - type: string - -jobs: - detect: - runs-on: nix-container-builder - outputs: - containers: ${{ steps.list.outputs.containers }} - steps: - - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - with: - fetch-depth: 2 - - - name: Detect changed containers - id: list - run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - CONTAINERS='["${{ inputs.container }}"]' - else - CONTAINERS=$(git diff --name-only HEAD~1 HEAD -- containers/ \ - | cut -d/ -f2 | sort -u \ - | jq -R -s -c 'split("\n") | map(select(length > 0))') - fi - echo "containers=$CONTAINERS" >> "$GITHUB_OUTPUT" - echo "Containers to build: $CONTAINERS" - - build: - needs: detect - if: needs.detect.outputs.containers != '[]' - runs-on: nix-container-builder - strategy: - matrix: - container: ${{ fromJson(needs.detect.outputs.containers) }} - steps: - - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - with: - ref: ${{ inputs.ref || github.sha }} - - - name: Check for default.nix - id: check - run: | - if [ -f "containers/${{ matrix.container }}/default.nix" ]; then - echo "exists=true" >> "$GITHUB_OUTPUT" - else - echo "No default.nix for ${{ matrix.container }} — skipping" - echo "exists=false" >> "$GITHUB_OUTPUT" - fi - - - name: Extract version and SHA - if: steps.check.outputs.exists == 'true' - id: meta - run: | - CONTAINER="${{ matrix.container }}" - NIX_FILE="containers/$CONTAINER/default.nix" - - # Try extracting version = "..." from the nix file (e.g. ntfy) - VERSION=$(grep -m1 '^\s*version\s*=\s*"' "$NIX_FILE" \ - | sed 's/.*"\(.*\)".*/\1/' || true) - - # Fall back to CONTAINER_APP_VERSION from Dockerfile (e.g. nettest) - if [ -z "$VERSION" ] && [ -f "containers/$CONTAINER/Dockerfile" ]; then - VERSION=$(grep -m1 '^ARG CONTAINER_APP_VERSION=' \ - "containers/$CONTAINER/Dockerfile" \ - | sed 's/^ARG CONTAINER_APP_VERSION=//') - fi - - # Last resort: nix eval for nixpkgs packages (e.g. authentik) - if [ -z "$VERSION" ]; then - VERSION=$(nix --extra-experimental-features "nix-command flakes" \ - eval --raw "nixpkgs#${CONTAINER}.version") - fi - - if [ -z "$VERSION" ]; then - echo "Error: Could not determine version for $CONTAINER" - exit 1 - fi - - REF="${{ inputs.ref }}" - if [ -z "$REF" ]; then - REF="${GITHUB_SHA}" - fi - SHORT_SHA=$(echo "$REF" | head -c 7) - - # Ensure version starts with 'v' - case "$VERSION" in - v*) ;; - *) VERSION="v${VERSION}" ;; - esac - - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - echo "sha=$SHORT_SHA" >> "$GITHUB_OUTPUT" - echo "Version: $VERSION, SHA: $SHORT_SHA" - - - name: Resolve nixpkgs - if: steps.check.outputs.exists == 'true' - id: nixpkgs - run: | - NIXPKGS_PATH=$(nix flake metadata nixpkgs --json | jq -r '.path') - echo "Resolved nixpkgs: $NIXPKGS_PATH" - echo "path=$NIXPKGS_PATH" >> "$GITHUB_OUTPUT" - - - name: Build with nix - if: steps.check.outputs.exists == 'true' - env: - NIX_PATH: "nixpkgs=${{ steps.nixpkgs.outputs.path }}" - run: | - echo "Building containers/${{ matrix.container }}/default.nix" - echo "NIX_PATH=$NIX_PATH" - nix-build "containers/${{ matrix.container }}/default.nix" -o result - echo "Build complete: $(readlink result)" - - - name: Push to registry - if: steps.check.outputs.exists == 'true' - env: - ZOT_CI_API_KEY: ${{ secrets.ZOT_CI_API_KEY }} - run: | - CONTAINER="${{ matrix.container }}" - VERSION="${{ steps.meta.outputs.version }}" - SHORT_SHA="${{ steps.meta.outputs.sha }}" - IMAGE="registry.ops.eblu.me/blumeops/$CONTAINER:${VERSION}-${SHORT_SHA}-nix" - - echo "Pushing to $IMAGE" - skopeo copy \ - --dest-creds="zot-ci:$ZOT_CI_API_KEY" \ - "docker-archive:result" \ - "docker://$IMAGE" - echo "Push complete: $IMAGE" diff --git a/.forgejo/workflows/build-container.yaml b/.forgejo/workflows/build-container.yaml index bc682c5..36134b8 100644 --- a/.forgejo/workflows/build-container.yaml +++ b/.forgejo/workflows/build-container.yaml @@ -1,7 +1,8 @@ -# Dockerfile container build workflow +# Unified container build workflow # Triggers on pushes to main that modify containers/*, or via manual dispatch. -# Detects which containers changed, extracts version from CONTAINER_APP_VERSION, -# and publishes with commit-SHA-based tags: vX.Y.Z-<sha> +# Detects which containers changed and routes to the correct runner: +# - Dockerfile containers build on k8s (indri) via Dagger +# - Nix containers build on nix-container-builder (ringtail) via nix-build + skopeo name: Build Container on: @@ -23,52 +24,64 @@ jobs: detect: runs-on: k8s outputs: - containers: ${{ steps.list.outputs.containers }} + dockerfile: ${{ steps.classify.outputs.dockerfile }} + nix: ${{ steps.classify.outputs.nix }} steps: - name: Checkout uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 2 - - name: Detect changed containers - id: list + - name: Detect and classify changed containers + id: classify run: | if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - CONTAINERS='["${{ inputs.container }}"]' + CHANGED='["${{ inputs.container }}"]' else - # Diff against parent commit to find changed container dirs - CONTAINERS=$(git diff --name-only HEAD~1 HEAD -- containers/ \ + CHANGED=$(git diff --name-only HEAD~1 HEAD -- containers/ \ | cut -d/ -f2 | sort -u \ | jq -R -s -c 'split("\n") | map(select(length > 0))') fi - echo "containers=$CONTAINERS" >> "$GITHUB_OUTPUT" - echo "Containers to build: $CONTAINERS" - build: + echo "Changed containers: $CHANGED" + + # Classify each container by build type (a container can appear in both) + DOCKERFILE='[]' + NIX='[]' + for name in $(echo "$CHANGED" | jq -r '.[]'); do + has_any=false + if [ -f "containers/$name/Dockerfile" ]; then + DOCKERFILE=$(echo "$DOCKERFILE" | jq -c --arg n "$name" '. + [$n]') + has_any=true + fi + if [ -f "containers/$name/default.nix" ]; then + NIX=$(echo "$NIX" | jq -c --arg n "$name" '. + [$n]') + has_any=true + fi + if [ "$has_any" = "false" ]; then + echo "Warning: $name has neither Dockerfile nor default.nix — skipping" + fi + done + + echo "dockerfile=$DOCKERFILE" >> "$GITHUB_OUTPUT" + echo "nix=$NIX" >> "$GITHUB_OUTPUT" + echo "Dockerfile builds: $DOCKERFILE" + echo "Nix builds: $NIX" + + build-dockerfile: needs: detect - if: needs.detect.outputs.containers != '[]' + if: needs.detect.outputs.dockerfile != '[]' runs-on: k8s strategy: matrix: - container: ${{ fromJson(needs.detect.outputs.containers) }} + container: ${{ fromJson(needs.detect.outputs.dockerfile) }} steps: - name: Checkout uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: ref: ${{ inputs.ref || github.sha }} - - name: Check for Dockerfile - id: check - run: | - if [ -f "containers/${{ matrix.container }}/Dockerfile" ]; then - echo "exists=true" >> "$GITHUB_OUTPUT" - else - echo "No Dockerfile for ${{ matrix.container }} — skipping" - echo "exists=false" >> "$GITHUB_OUTPUT" - fi - - name: Extract version and SHA - if: steps.check.outputs.exists == 'true' id: meta run: | VERSION=$(grep -m1 '^ARG CONTAINER_APP_VERSION=' \ @@ -80,7 +93,6 @@ jobs: exit 1 fi - # Use dispatch input ref if provided, otherwise current commit REF="${{ inputs.ref }}" if [ -z "$REF" ]; then REF="${GITHUB_SHA}" @@ -89,7 +101,7 @@ jobs: # Ensure version starts with 'v' case "$VERSION" in - v*) ;; # already has v prefix + v*) ;; *) VERSION="v${VERSION}" ;; esac @@ -98,7 +110,6 @@ jobs: echo "Version: $VERSION, SHA: $SHORT_SHA" - name: Publish - if: steps.check.outputs.exists == 'true' env: ZOT_CI_API_KEY: ${{ secrets.ZOT_CI_API_KEY }} run: | @@ -108,3 +119,79 @@ jobs: --version=${{ steps.meta.outputs.version }} \ --commit-sha=${{ steps.meta.outputs.sha }} \ --registry-password=env:ZOT_CI_API_KEY + + build-nix: + needs: detect + if: needs.detect.outputs.nix != '[]' + runs-on: nix-container-builder + strategy: + matrix: + container: ${{ fromJson(needs.detect.outputs.nix) }} + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + ref: ${{ inputs.ref || github.sha }} + + - name: Extract version and SHA + id: meta + run: | + CONTAINER="${{ matrix.container }}" + NIX_FILE="containers/$CONTAINER/default.nix" + + # Extract version = "..." from the nix file + VERSION=$(grep -m1 '^\s*version\s*=\s*"' "$NIX_FILE" \ + | sed 's/.*"\(.*\)".*/\1/' || true) + + if [ -z "$VERSION" ]; then + echo "Error: No version declaration found in $NIX_FILE" + exit 1 + fi + + REF="${{ inputs.ref }}" + if [ -z "$REF" ]; then + REF="${GITHUB_SHA}" + fi + SHORT_SHA=$(echo "$REF" | head -c 7) + + # Ensure version starts with 'v' + case "$VERSION" in + v*) ;; + *) VERSION="v${VERSION}" ;; + esac + + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "sha=$SHORT_SHA" >> "$GITHUB_OUTPUT" + echo "Version: $VERSION, SHA: $SHORT_SHA" + + - name: Resolve nixpkgs + id: nixpkgs + run: | + NIXPKGS_PATH=$(nix flake metadata nixpkgs --json | jq -r '.path') + echo "Resolved nixpkgs: $NIXPKGS_PATH" + echo "path=$NIXPKGS_PATH" >> "$GITHUB_OUTPUT" + + - name: Build with nix + env: + NIX_PATH: "nixpkgs=${{ steps.nixpkgs.outputs.path }}" + run: | + echo "Building containers/${{ matrix.container }}/default.nix" + echo "NIX_PATH=$NIX_PATH" + nix-build "containers/${{ matrix.container }}/default.nix" -o result + echo "Build complete: $(readlink result)" + + - name: Push to registry + env: + ZOT_CI_API_KEY: ${{ secrets.ZOT_CI_API_KEY }} + run: | + CONTAINER="${{ matrix.container }}" + VERSION="${{ steps.meta.outputs.version }}" + SHORT_SHA="${{ steps.meta.outputs.sha }}" + IMAGE="registry.ops.eblu.me/blumeops/$CONTAINER:${VERSION}-${SHORT_SHA}-nix" + + echo "Pushing to $IMAGE" + skopeo copy \ + --dest-creds="zot-ci:$ZOT_CI_API_KEY" \ + "docker-archive:result" \ + "docker://$IMAGE" + echo "Push complete: $IMAGE" diff --git a/containers/nettest/Dockerfile b/containers/nettest/Dockerfile deleted file mode 100644 index 4bb1284..0000000 --- a/containers/nettest/Dockerfile +++ /dev/null @@ -1,27 +0,0 @@ -# Network connectivity test container for blumeops CI/CD debugging -# -# This container tests connectivity to tailnet services from various environments: -# - Docker on indri (during CI build) -# - Minikube pods (manual testing) - -ARG CONTAINER_APP_VERSION=0.1.0 - -FROM alpine:3.22 - -ARG CONTAINER_APP_VERSION -LABEL org.opencontainers.image.title="nettest" -LABEL org.opencontainers.image.description="Network connectivity test container for CI/CD debugging" -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" - -RUN apk add --no-cache \ - curl \ - ca-certificates \ - jq \ - bind-tools - -COPY test-connectivity.sh /test-connectivity.sh -RUN chmod +x /test-connectivity.sh - -ENTRYPOINT ["/test-connectivity.sh"] diff --git a/containers/nettest/default.nix b/containers/nettest/default.nix deleted file mode 100644 index 4520804..0000000 --- a/containers/nettest/default.nix +++ /dev/null @@ -1,39 +0,0 @@ -# Nix-built nettest container -# Equivalent to the Dockerfile: curl, jq, bind (nslookup), ca-certs, bash -# Built with dockerTools.buildLayeredImage for efficient layer caching -{ pkgs ? import <nixpkgs> { } }: - -let - testScript = ./test-connectivity.sh; - - tools = pkgs.buildEnv { - name = "nettest-tools"; - paths = [ - pkgs.curl - pkgs.jq - pkgs.dnsutils # provides nslookup, dig - pkgs.cacert - pkgs.coreutils - pkgs.hostname - pkgs.bashInteractive - ]; - }; -in -pkgs.dockerTools.buildLayeredImage { - name = "blumeops/nettest"; - tag = "latest"; - - contents = [ tools ]; - - extraCommands = '' - cp ${testScript} test-connectivity.sh - chmod +x test-connectivity.sh - ''; - - config = { - Entrypoint = [ "/bin/bash" "/test-connectivity.sh" ]; - Env = [ - "SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" - ]; - }; -} diff --git a/containers/nettest/test-connectivity.sh b/containers/nettest/test-connectivity.sh deleted file mode 100644 index e97f417..0000000 --- a/containers/nettest/test-connectivity.sh +++ /dev/null @@ -1,115 +0,0 @@ -#!/bin/ash -# shellcheck shell=dash -# Network connectivity test script for blumeops -# Tests access to tailnet services from within the container - -set -e - -echo "========================================" -echo "BlumeOps Network Connectivity Test" -echo "========================================" -echo "" -echo "Timestamp: $(date -Iseconds)" -echo "Hostname: $(hostname)" -echo "" - -# Test targets -FORGE_HOST="forge.ops.eblu.me" -REGISTRY_HOST="registry.ops.eblu.me" - -test_dns() { - local host="$1" - echo "--- DNS: $host ---" - if nslookup "$host" 2>/dev/null; then - echo "DNS: OK" - return 0 - else - echo "DNS: FAILED" - return 1 - fi -} - -test_https() { - local url="$1" - local name="$2" - echo "" - echo "--- HTTPS: $name ---" - echo "URL: $url" - - # Try to fetch with verbose output - http_code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "$url" 2>&1) || true - - if [ "$http_code" = "200" ] || [ "$http_code" = "401" ] || [ "$http_code" = "302" ]; then - echo "HTTP Status: $http_code" - echo "Result: OK (service reachable)" - return 0 - elif [ -n "$http_code" ] && [ "$http_code" != "000" ]; then - echo "HTTP Status: $http_code" - echo "Result: OK (service reachable, status $http_code)" - return 0 - else - echo "HTTP Status: $http_code" - echo "Result: FAILED (could not connect)" - return 1 - fi -} - -test_registry_api() { - local host="$1" - echo "" - echo "--- Registry API: $host ---" - - # Try to query the registry API - response=$(curl -sf --max-time 10 "https://$host/v2/_catalog" 2>/dev/null) || true - - if [ -n "$response" ]; then - echo "Response: $response" - repo_count=$(echo "$response" | jq -r '.repositories | length' 2>/dev/null) || repo_count="unknown" - echo "Repository count: $repo_count" - echo "Result: OK" - return 0 - else - echo "Result: FAILED (no response from /v2/_catalog)" - return 1 - fi -} - -echo "========================================" -echo "Testing DNS Resolution" -echo "========================================" -dns_ok=0 -test_dns "$FORGE_HOST" && dns_ok=$((dns_ok + 1)) || true -echo "" -test_dns "$REGISTRY_HOST" && dns_ok=$((dns_ok + 1)) || true - -echo "" -echo "========================================" -echo "Testing HTTPS Connectivity" -echo "========================================" -https_ok=0 -test_https "https://$FORGE_HOST" "Forgejo" && https_ok=$((https_ok + 1)) || true -test_https "https://$REGISTRY_HOST/v2/" "Zot Registry" && https_ok=$((https_ok + 1)) || true - -echo "" -echo "========================================" -echo "Testing Registry API" -echo "========================================" -api_ok=0 -test_registry_api "$REGISTRY_HOST" && api_ok=1 || true - -echo "" -echo "========================================" -echo "Summary" -echo "========================================" -echo "DNS tests passed: $dns_ok/2" -echo "HTTPS tests passed: $https_ok/2" -echo "Registry API: $([ $api_ok -eq 1 ] && echo 'OK' || echo 'FAILED')" -echo "" - -if [ "$dns_ok" -eq 2 ] && [ "$https_ok" -eq 2 ] && [ "$api_ok" -eq 1 ]; then - echo "OVERALL: ALL TESTS PASSED" - exit 0 -else - echo "OVERALL: SOME TESTS FAILED" - exit 1 -fi diff --git a/docs/changelog.d/unify-container-workflows.infra.md b/docs/changelog.d/unify-container-workflows.infra.md new file mode 100644 index 0000000..2225297 --- /dev/null +++ b/docs/changelog.d/unify-container-workflows.infra.md @@ -0,0 +1 @@ +Unified Dockerfile and Nix container build workflows into a single workflow that auto-classifies containers by build type and routes to the correct runner (k8s for Dockerfile, nix-container-builder for Nix). Removed nettest container (outgrown). Nix builds now require an explicit `version = "..."` declaration — no implicit nixpkgs fallback. diff --git a/docs/how-to/deployment/build-container-image.md b/docs/how-to/deployment/build-container-image.md index b3b9cbe..4b47b3f 100644 --- a/docs/how-to/deployment/build-container-image.md +++ b/docs/how-to/deployment/build-container-image.md @@ -117,7 +117,7 @@ Existing containers demonstrate several build approaches: | Multi-stage with Node + Go | [[#navidrome]] | Separate UI and backend build stages | | Multi-stage Elixir | [[#teslamate]] | Elixir release with Node assets | | Runtime tarball download | [[#kiwix-serve]] | Download pre-built binary with arch detection | -| Nix `dockerTools` | [[#nettest-nix]] | `buildLayeredImage` with nixpkgs tools | +| Nix `dockerTools` | [[#ntfy-nix]] | `buildLayeredImage` with nix-built app | ### transmission @@ -139,9 +139,9 @@ Existing containers demonstrate several build approaches: `containers/kiwix-serve/Dockerfile` — Downloads a pre-built binary from upstream, with architecture detection for cross-platform support. -### nettest (nix) +### ntfy (nix) -`containers/nettest/default.nix` — Uses `dockerTools.buildLayeredImage` with `buildEnv` to merge nixpkgs tools (curl, jq, dnsutils, bash). Runs alongside the existing Dockerfile; the nix variant is tagged `:version-nix` in the registry. +`containers/ntfy/default.nix` — Builds ntfy from source using `buildGoModule` and packages it with `dockerTools.buildLayeredImage`. Runs alongside the existing Dockerfile; the nix variant is tagged `:version-nix` in the registry. ## Related diff --git a/docs/how-to/zot/add-container-version-sync-check.md b/docs/how-to/zot/add-container-version-sync-check.md index 7c98492..ebf1056 100644 --- a/docs/how-to/zot/add-container-version-sync-check.md +++ b/docs/how-to/zot/add-container-version-sync-check.md @@ -30,7 +30,7 @@ A typer-based uv-script that iterates over `containers/*/` and validates five ru Scoping: by default only checks containers changed vs main. `--all-files` checks everything. If `service-versions.yaml` itself changed, all containers are checked. -Blacklisted containers (utility images, not tracked services): `kubectl`, `nettest`. +Blacklisted containers (utility images, not tracked services): `kubectl`. Container-to-service name mapping: `quartz` → `docs`, `kiwix-serve` → `kiwix`. diff --git a/docs/how-to/zot/add-dagger-nix-build.md b/docs/how-to/zot/add-dagger-nix-build.md index 40841a8..fa5f261 100644 --- a/docs/how-to/zot/add-dagger-nix-build.md +++ b/docs/how-to/zot/add-dagger-nix-build.md @@ -15,7 +15,7 @@ Add Dagger functions for building nix container images and extracting version in ## Context -Discovered during analysis of [[adopt-commit-based-container-tags]]: nix containers (authentik, ntfy, nettest) derive their bundled app version from the nixpkgs pin, not from an explicit declaration. To validate that a VERSION file matches the actual nix-built version, we need a way to query the version from nix. +Discovered during analysis of [[adopt-commit-based-container-tags]]: nix containers (authentik, ntfy) derive their bundled app version from the nixpkgs pin, not from an explicit declaration. To validate that a VERSION file matches the actual nix-built version, we need a way to query the version from nix. Currently, nix containers can only be built on ringtail (the `nix-container-builder` runner). There is no local build path for developers — the only option is to push and wait for CI. Adding a Dagger-based nix build gives both local evaluation and version extraction. @@ -84,7 +84,7 @@ The `flake_lock` function already demonstrates running nix inside Dagger using ` ## Verification -- [ ] `dagger call build-nix --src=. --container-name=nettest` produces a valid docker-archive tarball +- [ ] `dagger call build-nix --src=. --container-name=ntfy` produces a valid docker-archive tarball - [ ] `dagger call nix-version --src=. --package=ntfy-sh` returns the correct version string - [ ] `dagger call nix-version --src=. --package=authentik` returns the Authentik version - [ ] Tarball from `build-nix` can be loaded with `docker load` and run locally diff --git a/docs/how-to/zot/pin-container-versions.md b/docs/how-to/zot/pin-container-versions.md index 714523c..4d0a64c 100644 --- a/docs/how-to/zot/pin-container-versions.md +++ b/docs/how-to/zot/pin-container-versions.md @@ -29,7 +29,6 @@ Specific changes: - **devpi**: Pinned devpi-server==6.19.1 and devpi-web==5.0.1 - **cv**: `CONTAINER_APP_VERSION=1.0.3` (matches latest Forgejo package release) - **quartz**: `CONTAINER_APP_VERSION=1.28.2` (pinned nginx:1.28.2-alpine base) -- **nettest**: `CONTAINER_APP_VERSION=0.1.0` (internal, no upstream) - **All others**: Existing versions carried forward with new uniform ARG pattern ## Key Files diff --git a/docs/reference/tools/dagger.md b/docs/reference/tools/dagger.md index 5d8a46d..b07ed78 100644 --- a/docs/reference/tools/dagger.md +++ b/docs/reference/tools/dagger.md @@ -49,7 +49,7 @@ dagger call --interactive build --src=. --container-name=devpi dagger call publish --src=. --container-name=devpi --version=v1.1.0 # Build a nix container (no local nix required) -dagger call build-nix --src=. --container-name=nettest export --path=./nettest.tar.gz +dagger call build-nix --src=. --container-name=ntfy export --path=./ntfy.tar.gz # Check a nixpkgs package version dagger call nix-version --package=authentik diff --git a/mise-tasks/container-version-check b/mise-tasks/container-version-check index 29be3ab..87eed64 100755 --- a/mise-tasks/container-version-check +++ b/mise-tasks/container-version-check @@ -37,7 +37,7 @@ CONTAINERS_DIR = REPO_ROOT / "containers" SERVICE_VERSIONS_FILE = REPO_ROOT / "service-versions.yaml" # Containers that are utility/test images, not tracked services -BLACKLIST = {"kubectl", "nettest"} +BLACKLIST = {"kubectl"} # Container dir name → service-versions.yaml name (when they differ) CONTAINER_TO_SERVICE = { From 9efe5c97fe61f8bf76a05c14404c2c4bc5d1abc5 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Mon, 23 Mar 2026 21:05:16 -0700 Subject: [PATCH 107/430] Fix authentik worker OOMKill: limit concurrency to 2 Dramatiq defaults to one worker process per CPU core. On ringtail (16 cores) this spawned 16 processes, each loading the full Django app, exceeding the 1Gi memory limit and causing a crash loop (228 restarts over 7 days). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- argocd/manifests/authentik/deployment-worker.yaml | 2 ++ docs/changelog.d/+authentik-worker-concurrency.bugfix.md | 1 + 2 files changed, 3 insertions(+) create mode 100644 docs/changelog.d/+authentik-worker-concurrency.bugfix.md diff --git a/argocd/manifests/authentik/deployment-worker.yaml b/argocd/manifests/authentik/deployment-worker.yaml index 3d4fb0c..ed8a753 100644 --- a/argocd/manifests/authentik/deployment-worker.yaml +++ b/argocd/manifests/authentik/deployment-worker.yaml @@ -53,6 +53,8 @@ spec: key: postgresql-password - name: AUTHENTIK_REDIS__HOST value: authentik-redis + - name: AUTHENTIK_WORKER_CONCURRENCY + value: "2" - name: AUTHENTIK_GRAFANA_CLIENT_SECRET valueFrom: secretKeyRef: diff --git a/docs/changelog.d/+authentik-worker-concurrency.bugfix.md b/docs/changelog.d/+authentik-worker-concurrency.bugfix.md new file mode 100644 index 0000000..f438361 --- /dev/null +++ b/docs/changelog.d/+authentik-worker-concurrency.bugfix.md @@ -0,0 +1 @@ +Fix authentik worker OOMKill by setting AUTHENTIK_WORKER_CONCURRENCY=2 (was defaulting to 16 based on CPU count). From 9024d412301cf60f9baf06a96b7bdc271b5d3ce7 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Mon, 23 Mar 2026 21:16:54 -0700 Subject: [PATCH 108/430] Add Grafana alerts dashboard for mobile-friendly alert overview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two panels: currently firing alerts (firing/pending/noData/error) and recent state changes. Refreshes every 30s. Uses Grafana's built-in alertlist panel type — no datasource needed. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- .../dashboards/configmap-alerts.yaml | 77 +++++++++++++++++++ .../grafana-config/kustomization.yaml | 1 + docs/changelog.d/+alerts-dashboard.feature.md | 1 + 3 files changed, 79 insertions(+) create mode 100644 argocd/manifests/grafana-config/dashboards/configmap-alerts.yaml create mode 100644 docs/changelog.d/+alerts-dashboard.feature.md diff --git a/argocd/manifests/grafana-config/dashboards/configmap-alerts.yaml b/argocd/manifests/grafana-config/dashboards/configmap-alerts.yaml new file mode 100644 index 0000000..a94acc3 --- /dev/null +++ b/argocd/manifests/grafana-config/dashboards/configmap-alerts.yaml @@ -0,0 +1,77 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: grafana-dashboard-alerts + namespace: monitoring + labels: + grafana_dashboard: "1" +data: + alerts.json: | + { + "annotations": { + "list": [] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "gridPos": { "h": 10, "w": 24, "x": 0, "y": 0 }, + "id": 1, + "options": { + "alertListOptions": { + "showOptions": "current", + "maxItems": 50, + "sortOrder": 1, + "stateFilter": { + "firing": true, + "pending": true, + "noData": true, + "normal": false, + "error": true + } + } + }, + "title": "Firing Alerts", + "type": "alertlist" + }, + { + "gridPos": { "h": 10, "w": 24, "x": 0, "y": 10 }, + "id": 2, + "options": { + "alertListOptions": { + "showOptions": "changes", + "maxItems": 50, + "sortOrder": 1, + "stateFilter": { + "firing": true, + "pending": true, + "noData": true, + "normal": true, + "error": true + } + } + }, + "title": "Recent State Changes", + "type": "alertlist" + } + ], + "refresh": "30s", + "schemaVersion": 38, + "tags": ["alerts"], + "templating": { + "list": [] + }, + "time": { + "from": "now-24h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Alerts", + "uid": "alerts", + "version": 1, + "weekStart": "" + } diff --git a/argocd/manifests/grafana-config/kustomization.yaml b/argocd/manifests/grafana-config/kustomization.yaml index bb7ef87..6412e8b 100644 --- a/argocd/manifests/grafana-config/kustomization.yaml +++ b/argocd/manifests/grafana-config/kustomization.yaml @@ -26,6 +26,7 @@ resources: - dashboards/configmap-sifaka-disks.yaml - dashboards/configmap-forgejo.yaml - dashboards/configmap-tempo.yaml + - dashboards/configmap-alerts.yaml # TeslaMate dashboards are fetched by the init-teslamate-dashboards init # container in the Grafana deployment, sourced from mirrors/teslamate on forge. # See argocd/manifests/grafana/deployment.yaml for the version pin. diff --git a/docs/changelog.d/+alerts-dashboard.feature.md b/docs/changelog.d/+alerts-dashboard.feature.md new file mode 100644 index 0000000..d69802f --- /dev/null +++ b/docs/changelog.d/+alerts-dashboard.feature.md @@ -0,0 +1 @@ +Add Grafana "Alerts" dashboard showing currently firing alerts and recent state changes. From b96b4dad474fa3a6a458bb2f228a92c390116668 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Mon, 23 Mar 2026 21:20:14 -0700 Subject: [PATCH 109/430] Move Alerts dashboard into Infrastructure Alerts folder Uses the grafana_folder annotation to place the dashboard in the existing folder created by alert rule provisioning. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- .../manifests/grafana-config/dashboards/configmap-alerts.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/argocd/manifests/grafana-config/dashboards/configmap-alerts.yaml b/argocd/manifests/grafana-config/dashboards/configmap-alerts.yaml index a94acc3..bcc6ad7 100644 --- a/argocd/manifests/grafana-config/dashboards/configmap-alerts.yaml +++ b/argocd/manifests/grafana-config/dashboards/configmap-alerts.yaml @@ -3,6 +3,8 @@ kind: ConfigMap metadata: name: grafana-dashboard-alerts namespace: monitoring + annotations: + grafana_folder: "Infrastructure Alerts" labels: grafana_dashboard: "1" data: From bec554110a7c05dc4c5ef966e01d145c430c8670 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Tue, 24 Mar 2026 07:30:18 -0700 Subject: [PATCH 110/430] =?UTF-8?q?Upgrade=20Frigate=200.17.0-rc2=20?= =?UTF-8?q?=E2=86=92=200.17.1,=20add=20motion=20retention=20tier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump from RC to latest stable (security fixes for config endpoint and cross-camera auth). Add new 0.17 motion retention tier at 365 days, reduce continuous from 180 to 30 days. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- argocd/manifests/frigate/frigate-config.yml | 4 +++- argocd/manifests/frigate/kustomization.yaml | 2 +- docs/changelog.d/+frigate-0.17.1.infra.md | 1 + service-versions.yaml | 4 ++-- 4 files changed, 7 insertions(+), 4 deletions(-) create mode 100644 docs/changelog.d/+frigate-0.17.1.infra.md diff --git a/argocd/manifests/frigate/frigate-config.yml b/argocd/manifests/frigate/frigate-config.yml index a697d2a..35f7ccd 100644 --- a/argocd/manifests/frigate/frigate-config.yml +++ b/argocd/manifests/frigate/frigate-config.yml @@ -70,7 +70,9 @@ model: record: enabled: true continuous: - days: 180 + days: 30 + motion: + days: 365 alerts: retain: days: 730 diff --git a/argocd/manifests/frigate/kustomization.yaml b/argocd/manifests/frigate/kustomization.yaml index ac6079c..b424bd0 100644 --- a/argocd/manifests/frigate/kustomization.yaml +++ b/argocd/manifests/frigate/kustomization.yaml @@ -16,7 +16,7 @@ images: - name: busybox newTag: "1.37" - name: ghcr.io/blakeblackshear/frigate - newTag: 0.17.0-rc2-tensorrt + newTag: 0.17.1-tensorrt - name: ghcr.io/0x2142/frigate-notify newTag: v0.5.4 diff --git a/docs/changelog.d/+frigate-0.17.1.infra.md b/docs/changelog.d/+frigate-0.17.1.infra.md new file mode 100644 index 0000000..8d2025b --- /dev/null +++ b/docs/changelog.d/+frigate-0.17.1.infra.md @@ -0,0 +1 @@ +Upgrade Frigate from 0.17.0-rc2 to 0.17.1 (security fixes, bugfixes). Add motion retention tier (365 days), reduce continuous retention from 180 to 30 days. diff --git a/service-versions.yaml b/service-versions.yaml index 28013bc..2711b32 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -53,8 +53,8 @@ services: - name: frigate type: argocd - last-reviewed: 2026-02-17 - current-version: "0.17.0-rc2" + last-reviewed: 2026-03-24 + current-version: "0.17.1" upstream-source: https://github.com/blakeblackshear/frigate/releases - name: frigate-notify From 0698013355f4af4d6df607a762021c4e18fd03fb Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Tue, 24 Mar 2026 07:55:00 -0700 Subject: [PATCH 111/430] Review ArgoCD config tutorial: fix sync policy, typo, add cross-references Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- .../+argocd-config-doc-review.doc.md | 1 + docs/tutorials/replication/argocd-config.md | 17 ++++++++++------- 2 files changed, 11 insertions(+), 7 deletions(-) create mode 100644 docs/changelog.d/+argocd-config-doc-review.doc.md diff --git a/docs/changelog.d/+argocd-config-doc-review.doc.md b/docs/changelog.d/+argocd-config-doc-review.doc.md new file mode 100644 index 0000000..00c0283 --- /dev/null +++ b/docs/changelog.d/+argocd-config-doc-review.doc.md @@ -0,0 +1 @@ +Review and fix ArgoCD config tutorial: correct sync policy example, fix typo, add missing cross-references and frontmatter. diff --git a/docs/tutorials/replication/argocd-config.md b/docs/tutorials/replication/argocd-config.md index 380537d..4a0c0f6 100644 --- a/docs/tutorials/replication/argocd-config.md +++ b/docs/tutorials/replication/argocd-config.md @@ -1,6 +1,7 @@ --- title: ArgoCD Config -modified: 2026-02-07 +modified: 2026-03-24 +last-reviewed: 2026-03-24 tags: - tutorials - replication @@ -52,7 +53,7 @@ tailscale serve --bg --https 8443 https+insecure://localhost:$(kubectl -n argocd Or create a Tailscale Ingress in Kubernetes (see [[tailscale-operator]]). -Access at `https://your-server.tailnet.ts.net:8443` +Access at `https://your-server.tailnet.ts.net:8443` (replace `tailnet` with your tailnet name, found in the Tailscale admin console) ### Install the CLI @@ -78,6 +79,8 @@ argocd repo add https://github.com/you/your-repo.git \ --password your-token ``` +For BlumeOps, the git server is [[forgejo]] at `ssh://forgejo@forge.ops.eblu.me:2222`. + ## Step 4: Create Your First Application Create a directory in your repo: @@ -173,11 +176,11 @@ spec: server: https://kubernetes.default.svc namespace: argocd syncPolicy: - automated: - prune: true + syncOptions: + - CreateNamespace=true ``` -Now adding a new application is just creating a YAML file. +Now adding a new application is just creating a YAML file. BlumeOps syncs the `apps` Application manually — run `argocd app sync apps` after adding new Application YAMLs. ## Step 7: Configure Sync Policies @@ -188,7 +191,7 @@ Now adding a new application is just creating a YAML file. | Auto prune | Remove resources deleted from git | | Self heal | Revert manual kubectl changes | -BlumeOps uses manual sync for workloads, auto sync only for the `apps` Application itself. +BlumeOps uses manual sync for all applications, including the root `apps` Application. ## What You Now Have @@ -203,7 +206,7 @@ BlumeOps uses manual sync for workloads, auto sync only for the `apps` Applicati - Add more applications to your repo - Set up notifications for sync failures -## BluemeOps Specifics +## BlumeOps Specifics BlumeOps' ArgoCD configuration includes: - SSH connection to [[forgejo]] git server From 0d422f5234df0096a0d5f1ef734346fc38ba5228 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Tue, 24 Mar 2026 08:11:46 -0700 Subject: [PATCH 112/430] Update tooling dependencies (March 2026) (#307) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Monthly tooling dependency update per [[update-tooling-dependencies]]. - **Prek hooks:** trufflehog v3.93.4→v3.94.0, ruff v0.15.2→v0.15.7, shfmt v3.12.0-2→v3.13.0-1, ansible-lint floor→26.3.0, ansible-core floor→2.18 - **Fly.io proxy:** nginx 1.28.2→1.29.6, Grafana Alloy v1.13.1→v1.14.1 - **Forgejo workflows:** actions/checkout v4.3.1→v6.0.2 (SHA-pinned across all 5 workflows) - **Mise tasks:** tightened Python lower bounds — rich≥14.0.0, typer≥0.24.0, httpx≥0.28.1, pyyaml≥6.0.2 ## Test plan - [x] `prek run --all-files` passes - [ ] Verify Fly.io deploy succeeds after merge (nginx minor bump + Alloy bump) - [ ] Spot-check a workflow run with the new actions/checkout v6 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/307 --- .forgejo/workflows/branch-cleanup.yaml | 2 +- .forgejo/workflows/build-blumeops.yaml | 2 +- .forgejo/workflows/build-container.yaml | 6 +++--- .forgejo/workflows/cv-deploy.yaml | 2 +- .forgejo/workflows/deploy-fly.yaml | 2 +- docs/changelog.d/update-tooling-deps-2026-03.infra.md | 1 + fly/Dockerfile | 4 ++-- mise-tasks/blumeops-tasks | 2 +- mise-tasks/branch-cleanup | 2 +- mise-tasks/container-build-and-release | 2 +- mise-tasks/container-list | 2 +- mise-tasks/container-version-check | 2 +- mise-tasks/docs-check-frontmatter | 2 +- mise-tasks/docs-check-links | 2 +- mise-tasks/docs-mikado | 2 +- mise-tasks/docs-preview | 2 +- mise-tasks/docs-review | 2 +- mise-tasks/docs-review-stale | 2 +- mise-tasks/docs-review-tags | 2 +- mise-tasks/mikado-branch-invariant-check | 2 +- mise-tasks/op-backup | 2 +- mise-tasks/pr-comments | 2 +- mise-tasks/runner-logs | 2 +- mise-tasks/service-review | 2 +- prek.toml | 8 ++++---- 25 files changed, 31 insertions(+), 30 deletions(-) create mode 100644 docs/changelog.d/update-tooling-deps-2026-03.infra.md diff --git a/.forgejo/workflows/branch-cleanup.yaml b/.forgejo/workflows/branch-cleanup.yaml index 61307ea..29ed67c 100644 --- a/.forgejo/workflows/branch-cleanup.yaml +++ b/.forgejo/workflows/branch-cleanup.yaml @@ -26,7 +26,7 @@ jobs: runs-on: k8s steps: - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Run branch cleanup env: diff --git a/.forgejo/workflows/build-blumeops.yaml b/.forgejo/workflows/build-blumeops.yaml index e6fe92d..383542f 100644 --- a/.forgejo/workflows/build-blumeops.yaml +++ b/.forgejo/workflows/build-blumeops.yaml @@ -104,7 +104,7 @@ jobs: echo "Building BlumeOps release: $VERSION" - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 diff --git a/.forgejo/workflows/build-container.yaml b/.forgejo/workflows/build-container.yaml index 36134b8..6bd08e0 100644 --- a/.forgejo/workflows/build-container.yaml +++ b/.forgejo/workflows/build-container.yaml @@ -28,7 +28,7 @@ jobs: nix: ${{ steps.classify.outputs.nix }} steps: - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 2 @@ -77,7 +77,7 @@ jobs: container: ${{ fromJson(needs.detect.outputs.dockerfile) }} steps: - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ inputs.ref || github.sha }} @@ -129,7 +129,7 @@ jobs: container: ${{ fromJson(needs.detect.outputs.nix) }} steps: - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ inputs.ref || github.sha }} diff --git a/.forgejo/workflows/cv-deploy.yaml b/.forgejo/workflows/cv-deploy.yaml index b03b925..f99352d 100644 --- a/.forgejo/workflows/cv-deploy.yaml +++ b/.forgejo/workflows/cv-deploy.yaml @@ -58,7 +58,7 @@ jobs: echo "version=$VERSION" >> "$GITHUB_OUTPUT" - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Update CV deployment run: | diff --git a/.forgejo/workflows/deploy-fly.yaml b/.forgejo/workflows/deploy-fly.yaml index 0a63345..a2b389b 100644 --- a/.forgejo/workflows/deploy-fly.yaml +++ b/.forgejo/workflows/deploy-fly.yaml @@ -12,7 +12,7 @@ jobs: runs-on: k8s steps: - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install flyctl run: | diff --git a/docs/changelog.d/update-tooling-deps-2026-03.infra.md b/docs/changelog.d/update-tooling-deps-2026-03.infra.md new file mode 100644 index 0000000..b0f162f --- /dev/null +++ b/docs/changelog.d/update-tooling-deps-2026-03.infra.md @@ -0,0 +1 @@ +Monthly tooling dependency update: bump prek hooks (trufflehog 3.94.0, ruff 0.15.7, shfmt 3.13.0), Fly.io images (nginx 1.29.6, Alloy 1.14.1), actions/checkout v4.3.1→v6.0.2, tighten mise task Python lower bounds (rich 14, typer 0.24, httpx 0.28.1, pyyaml 6.0.2), and bump ansible-lint/ansible-core floors. diff --git a/fly/Dockerfile b/fly/Dockerfile index 65135c1..3f866fa 100644 --- a/fly/Dockerfile +++ b/fly/Dockerfile @@ -1,4 +1,4 @@ -FROM nginx:1.28.2-alpine +FROM nginx:1.29.6-alpine # Copy tailscale binaries from official image COPY --from=docker.io/tailscale/tailscale:stable \ @@ -13,7 +13,7 @@ RUN mkdir -p /var/run/tailscale /var/lib/tailscale \ && rm -f /etc/fail2ban/jail.d/alpine-ssh.conf # Copy Alloy binary from official image (Ubuntu-based, needs libc6-compat) -COPY --from=docker.io/grafana/alloy:v1.13.1 \ +COPY --from=docker.io/grafana/alloy:v1.14.1 \ /bin/alloy /usr/local/bin/alloy RUN mkdir -p /var/log/nginx /etc/alloy /tmp/alloy-data diff --git a/mise-tasks/blumeops-tasks b/mise-tasks/blumeops-tasks index f64c284..94daa51 100755 --- a/mise-tasks/blumeops-tasks +++ b/mise-tasks/blumeops-tasks @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.12" -# dependencies = ["httpx>=0.28.0", "rich>=13.0.0"] +# dependencies = ["httpx>=0.28.1", "rich>=14.0.0"] # /// #MISE description="List Blumeops tasks from Todoist sorted by priority" """Fetch and display Blumeops tasks from Todoist, sorted by priority. diff --git a/mise-tasks/branch-cleanup b/mise-tasks/branch-cleanup index 88e9152..0b5a301 100755 --- a/mise-tasks/branch-cleanup +++ b/mise-tasks/branch-cleanup @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.12" -# dependencies = ["httpx>=0.28.0", "rich>=13.0.0", "typer>=0.15.0"] +# dependencies = ["httpx>=0.28.1", "rich>=14.0.0", "typer>=0.24.0"] # /// #MISE description="Delete branches that have been merged into main (local and remote)" #MISE alias="bc" diff --git a/mise-tasks/container-build-and-release b/mise-tasks/container-build-and-release index dd78923..ce57c2e 100755 --- a/mise-tasks/container-build-and-release +++ b/mise-tasks/container-build-and-release @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.12" -# dependencies = ["typer>=0.15.0", "httpx>=0.28.0"] +# dependencies = ["typer>=0.24.0", "httpx>=0.28.1"] # /// #MISE description="Trigger container build workflows via Forgejo API" #USAGE arg "<container>" help="Container name (directory under containers/)" diff --git a/mise-tasks/container-list b/mise-tasks/container-list index 91db763..5c554b6 100755 --- a/mise-tasks/container-list +++ b/mise-tasks/container-list @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.12" -# dependencies = ["httpx>=0.28.0", "rich>=13.0.0", "typer>=0.15.0"] +# dependencies = ["httpx>=0.28.1", "rich>=14.0.0", "typer>=0.24.0"] # /// #MISE description="List available containers and their recent tags" #USAGE arg "[name]" help="Optional container name to filter output" diff --git a/mise-tasks/container-version-check b/mise-tasks/container-version-check index 87eed64..1df062f 100755 --- a/mise-tasks/container-version-check +++ b/mise-tasks/container-version-check @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.12" -# dependencies = ["pyyaml>=6.0", "rich>=13.0.0", "typer>=0.15.0"] +# dependencies = ["pyyaml>=6.0.2", "rich>=14.0.0", "typer>=0.24.0"] # /// #MISE description="Validate container version consistency across Dockerfiles, nix derivations, and service-versions.yaml" #USAGE flag "--all-files" help="Check all containers, not just changed ones" diff --git a/mise-tasks/docs-check-frontmatter b/mise-tasks/docs-check-frontmatter index 3571801..11d1a49 100755 --- a/mise-tasks/docs-check-frontmatter +++ b/mise-tasks/docs-check-frontmatter @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.12" -# dependencies = ["rich>=13.0.0"] +# dependencies = ["rich>=14.0.0"] # /// #MISE description="Check that all docs have required frontmatter fields" """Validate that all documentation files have required YAML frontmatter. diff --git a/mise-tasks/docs-check-links b/mise-tasks/docs-check-links index 20d48fb..78e871a 100755 --- a/mise-tasks/docs-check-links +++ b/mise-tasks/docs-check-links @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.12" -# dependencies = ["rich>=13.0.0"] +# dependencies = ["rich>=14.0.0"] # /// #MISE description="Validate all wiki-links point to existing doc files" """Validate that all wiki-links in documentation point to existing files. diff --git a/mise-tasks/docs-mikado b/mise-tasks/docs-mikado index 17a363d..0b37f51 100755 --- a/mise-tasks/docs-mikado +++ b/mise-tasks/docs-mikado @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.12" -# dependencies = ["httpx>=0.28.0", "pyyaml>=6.0", "rich>=13.0.0", "typer>=0.15.0"] +# dependencies = ["httpx>=0.28.1", "pyyaml>=6.0.2", "rich>=14.0.0", "typer>=0.24.0"] # /// #MISE description="View active Mikado dependency chains for C2 changes" #USAGE arg "[card]" help="Card stem to show chain for" diff --git a/mise-tasks/docs-preview b/mise-tasks/docs-preview index d9b90ab..f63b1d1 100755 --- a/mise-tasks/docs-preview +++ b/mise-tasks/docs-preview @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.12" -# dependencies = ["pyyaml>=6.0", "rich>=13.0.0", "typer>=0.15.0"] +# dependencies = ["pyyaml>=6.0.2", "rich>=14.0.0", "typer>=0.24.0"] # /// #MISE description="Build docs with Dagger and serve locally, opening to a specific card" #USAGE arg "<card>" help="Card path relative to docs/, e.g. how-to/knowledgebase/review-documentation" diff --git a/mise-tasks/docs-review b/mise-tasks/docs-review index e353b30..49cf4d0 100755 --- a/mise-tasks/docs-review +++ b/mise-tasks/docs-review @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.12" -# dependencies = ["pyyaml>=6.0", "rich>=13.0.0", "typer>=0.15.0"] +# dependencies = ["pyyaml>=6.0.2", "rich>=14.0.0", "typer>=0.24.0"] # /// #MISE description="Review the most stale documentation card by last-reviewed date" #USAGE flag "--limit <limit>" default="15" help="Number of docs to show in the table" diff --git a/mise-tasks/docs-review-stale b/mise-tasks/docs-review-stale index 25ec743..facbf6b 100755 --- a/mise-tasks/docs-review-stale +++ b/mise-tasks/docs-review-stale @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.12" -# dependencies = ["rich>=13.0.0", "typer>=0.15.0"] +# dependencies = ["rich>=14.0.0", "typer>=0.24.0"] # /// #MISE description="Report docs by git-last-modified date, highlighting stale ones" #USAGE flag "--threshold <threshold>" default="180" help="Days before a doc is considered stale" diff --git a/mise-tasks/docs-review-tags b/mise-tasks/docs-review-tags index a4712e4..0e7f1d4 100755 --- a/mise-tasks/docs-review-tags +++ b/mise-tasks/docs-review-tags @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.12" -# dependencies = ["pyyaml>=6.0", "rich>=13.0.0"] +# dependencies = ["pyyaml>=6.0.2", "rich>=14.0.0"] # /// #MISE description="Print frontmatter tag inventory across all docs" """Print every frontmatter tag with usage count and file list. diff --git a/mise-tasks/mikado-branch-invariant-check b/mise-tasks/mikado-branch-invariant-check index 9060fc8..8760a39 100755 --- a/mise-tasks/mikado-branch-invariant-check +++ b/mise-tasks/mikado-branch-invariant-check @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.12" -# dependencies = ["rich>=13.0.0", "typer>=0.15.0"] +# dependencies = ["rich>=14.0.0", "typer>=0.24.0"] # /// #MISE description="Validate Mikado Branch Invariant on mikado/* branches" #USAGE arg "[commit_msg_file]" help="Commit message file (passed by commit-msg hook)" diff --git a/mise-tasks/op-backup b/mise-tasks/op-backup index 202cb3e..6ffef14 100755 --- a/mise-tasks/op-backup +++ b/mise-tasks/op-backup @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.12" -# dependencies = ["rich>=13.0.0", "typer>=0.15.0"] +# dependencies = ["rich>=14.0.0", "typer>=0.24.0"] # /// #MISE description="Encrypt a 1Password .1pux export and send to indri for borgmatic" #USAGE arg "[export_path]" help="Path to .1pux export file (prompted if omitted)" diff --git a/mise-tasks/pr-comments b/mise-tasks/pr-comments index 1ec60ef..a44a430 100755 --- a/mise-tasks/pr-comments +++ b/mise-tasks/pr-comments @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.12" -# dependencies = ["httpx>=0.28.0", "rich>=13.0.0", "typer>=0.15.0"] +# dependencies = ["httpx>=0.28.1", "rich>=14.0.0", "typer>=0.24.0"] # /// #MISE description="List unresolved comments on a PR" #USAGE arg "<pr_number>" help="Pull request number" diff --git a/mise-tasks/runner-logs b/mise-tasks/runner-logs index 22e4640..ec51608 100755 --- a/mise-tasks/runner-logs +++ b/mise-tasks/runner-logs @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.12" -# dependencies = ["httpx>=0.28.0", "rich>=13.0.0", "typer>=0.15.0"] +# dependencies = ["httpx>=0.28.1", "rich>=14.0.0", "typer>=0.24.0"] # /// #MISE description="Get logs for a Forgejo Actions workflow run (indri or ringtail runner)" #USAGE arg "<runner>" help="Runner filter: indri, ringtail, or all" diff --git a/mise-tasks/service-review b/mise-tasks/service-review index 1581781..1bc2ae4 100755 --- a/mise-tasks/service-review +++ b/mise-tasks/service-review @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.12" -# dependencies = ["pyyaml>=6.0", "rich>=13.0.0", "typer>=0.15.0"] +# dependencies = ["pyyaml>=6.0.2", "rich>=14.0.0", "typer>=0.24.0"] # /// #MISE description="Review the most stale service for version freshness" #USAGE flag "--limit <limit>" default="15" help="Number of services to show in the table" diff --git a/prek.toml b/prek.toml index b780d94..b679a6f 100644 --- a/prek.toml +++ b/prek.toml @@ -28,7 +28,7 @@ hooks = [{ id = "check-yaml", args = ["--unsafe"] }] # Secret detection [[repos]] repo = "https://github.com/trufflesecurity/trufflehog" -rev = "v3.93.4" +rev = "v3.94.0" hooks = [ { id = "trufflehog", entry = "trufflehog git file://. --since-commit HEAD --no-verification --fail", stages = [ "pre-commit", @@ -52,12 +52,12 @@ name = "ansible-lint" entry = "env ANSIBLE_ROLES_PATH=ansible/roles ansible-lint" language = "python" files = "^ansible/" -additional_dependencies = ["ansible-lint>=26.1.1", "ansible-core>=2.15"] +additional_dependencies = ["ansible-lint>=26.3.0", "ansible-core>=2.18"] # Python - ruff for linting and formatting [[repos]] repo = "https://github.com/astral-sh/ruff-pre-commit" -rev = "v0.15.2" +rev = "v0.15.7" hooks = [{ id = "ruff", args = ["--fix"] }, { id = "ruff-format" }] # Shell scripts - shellcheck and shfmt @@ -68,7 +68,7 @@ hooks = [{ id = "shellcheck", args = ["--severity=warning"] }] [[repos]] repo = "https://github.com/scop/pre-commit-shfmt" -rev = "v3.12.0-2" +rev = "v3.13.0-1" hooks = [{ id = "shfmt", args = ["-i", "2", "-ci", "-bn"] }] # TOML - taplo From fc45989a6cba566a92324a7be13187fac3d77cd3 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Tue, 24 Mar 2026 08:44:23 -0700 Subject: [PATCH 113/430] Decommission JobSync service (#308) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Remove all JobSync infrastructure: ArgoCD app, k8s manifests, container build (nix), Caddy reverse proxy entry, Homepage dashboard entry, service-versions tracking, and all documentation - Runtime teardown already completed: ArgoCD app cascade-deleted (removes deployment, PVC, service, ingress, external-secret), forge mirror deleted, 1Password item archived, local clone removed ## Motivation Replacing JobSync with a datasette-based job tracking pipeline driven by mise tasks and a Claude agent frontend. JobSync's Next.js server actions don't expose a useful API for automation. ## Remaining manual steps after merge - Provision Caddy to remove the stale proxy route: `mise run provision-indri -- --tags caddy` - Sync Homepage: `argocd app sync homepage` - Verify namespace cleanup on ringtail: `kubectl get ns jobsync --context=k3s-ringtail` (should be gone) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/308 --- ansible/roles/caddy/defaults/main.yml | 3 - argocd/apps/jobsync.yaml | 18 --- argocd/manifests/homepage/services.yaml | 5 - argocd/manifests/jobsync/deployment.yaml | 78 ----------- argocd/manifests/jobsync/external-secret.yaml | 27 ---- .../manifests/jobsync/ingress-tailscale.yaml | 26 ---- argocd/manifests/jobsync/kustomization.yaml | 15 --- argocd/manifests/jobsync/pvc.yaml | 13 -- argocd/manifests/jobsync/service.yaml | 13 -- containers/jobsync/default.nix | 126 ------------------ containers/jobsync/entrypoint.sh | 15 --- .../changelog.d/decommission-jobsync.infra.md | 1 + .../how-to/jobsync/build-jobsync-container.md | 62 --------- docs/how-to/jobsync/deploy-jobsync.md | 74 ---------- docs/reference/infrastructure/ringtail.md | 1 - docs/reference/services/jobsync.md | 104 --------------- mise-tasks/services-check | 1 - service-versions.yaml | 7 - 18 files changed, 1 insertion(+), 588 deletions(-) delete mode 100644 argocd/apps/jobsync.yaml delete mode 100644 argocd/manifests/jobsync/deployment.yaml delete mode 100644 argocd/manifests/jobsync/external-secret.yaml delete mode 100644 argocd/manifests/jobsync/ingress-tailscale.yaml delete mode 100644 argocd/manifests/jobsync/kustomization.yaml delete mode 100644 argocd/manifests/jobsync/pvc.yaml delete mode 100644 argocd/manifests/jobsync/service.yaml delete mode 100644 containers/jobsync/default.nix delete mode 100644 containers/jobsync/entrypoint.sh create mode 100644 docs/changelog.d/decommission-jobsync.infra.md delete mode 100644 docs/how-to/jobsync/build-jobsync-container.md delete mode 100644 docs/how-to/jobsync/deploy-jobsync.md delete mode 100644 docs/reference/services/jobsync.md diff --git a/ansible/roles/caddy/defaults/main.yml b/ansible/roles/caddy/defaults/main.yml index dbf0b13..6b1ec61 100644 --- a/ansible/roles/caddy/defaults/main.yml +++ b/ansible/roles/caddy/defaults/main.yml @@ -85,9 +85,6 @@ caddy_services: - name: ntfy host: "ntfy.{{ caddy_domain }}" backend: "https://ntfy.tail8d86e.ts.net" - - name: jobsync - host: "jobsync.{{ caddy_domain }}" - backend: "https://jobsync.tail8d86e.ts.net" - name: ollama host: "ollama.{{ caddy_domain }}" backend: "https://ollama.tail8d86e.ts.net" diff --git a/argocd/apps/jobsync.yaml b/argocd/apps/jobsync.yaml deleted file mode 100644 index 11d8beb..0000000 --- a/argocd/apps/jobsync.yaml +++ /dev/null @@ -1,18 +0,0 @@ ---- -apiVersion: argoproj.io/v1alpha1 -kind: Application -metadata: - name: jobsync - namespace: argocd -spec: - project: default - source: - repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git - targetRevision: main - path: argocd/manifests/jobsync - destination: - server: https://ringtail.tail8d86e.ts.net:6443 - namespace: jobsync - syncPolicy: - syncOptions: - - CreateNamespace=true diff --git a/argocd/manifests/homepage/services.yaml b/argocd/manifests/homepage/services.yaml index 0305a42..58b8bb7 100644 --- a/argocd/manifests/homepage/services.yaml +++ b/argocd/manifests/homepage/services.yaml @@ -68,11 +68,6 @@ enableBlocks: true enableNowPlaying: false fields: ["movies", "series", "episodes"] -- Services: - - JobSync: - href: https://jobsync.ops.eblu.me - icon: mdi-briefcase-search - description: Job application tracker - Infrastructure: - Authentik: href: https://authentik.ops.eblu.me diff --git a/argocd/manifests/jobsync/deployment.yaml b/argocd/manifests/jobsync/deployment.yaml deleted file mode 100644 index be19a81..0000000 --- a/argocd/manifests/jobsync/deployment.yaml +++ /dev/null @@ -1,78 +0,0 @@ ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: jobsync - namespace: jobsync -spec: - replicas: 1 - strategy: - type: Recreate - selector: - matchLabels: - app: jobsync - template: - metadata: - labels: - app: jobsync - spec: - containers: - - name: jobsync - image: blumeops/jobsync:kustomized - ports: - - containerPort: 3000 - name: http - env: - - name: DATABASE_URL - value: "file:/data/dev.db" - - name: NEXTAUTH_URL - value: "https://jobsync.ops.eblu.me" - - name: AUTH_TRUST_HOST - value: "true" - - name: NEXT_TELEMETRY_DISABLED - value: "1" - - name: TZ - value: "America/Los_Angeles" - - name: OLLAMA_BASE_URL - value: "http://ollama.ollama.svc.cluster.local:11434" - - name: AUTH_SECRET - valueFrom: - secretKeyRef: - name: jobsync-secrets - key: auth_secret - - name: ENCRYPTION_KEY - valueFrom: - secretKeyRef: - name: jobsync-secrets - key: encryption_key - - name: RAPIDAPI_KEY - valueFrom: - secretKeyRef: - name: jobsync-secrets - key: rapidapi_key - volumeMounts: - - name: data - mountPath: /data - resources: - requests: - memory: "256Mi" - cpu: "100m" - limits: - memory: "512Mi" - cpu: "500m" - livenessProbe: - httpGet: - path: / - port: 3000 - initialDelaySeconds: 30 - periodSeconds: 30 - readinessProbe: - httpGet: - path: / - port: 3000 - initialDelaySeconds: 10 - periodSeconds: 10 - volumes: - - name: data - persistentVolumeClaim: - claimName: jobsync-data diff --git a/argocd/manifests/jobsync/external-secret.yaml b/argocd/manifests/jobsync/external-secret.yaml deleted file mode 100644 index 39d58dc..0000000 --- a/argocd/manifests/jobsync/external-secret.yaml +++ /dev/null @@ -1,27 +0,0 @@ ---- -apiVersion: external-secrets.io/v1 -kind: ExternalSecret -metadata: - name: jobsync-secrets - namespace: jobsync -spec: - refreshInterval: 1h - secretStoreRef: - kind: ClusterSecretStore - name: onepassword-blumeops - target: - name: jobsync-secrets - creationPolicy: Owner - data: - - secretKey: auth_secret - remoteRef: - key: JobSync - property: auth_secret - - secretKey: encryption_key - remoteRef: - key: JobSync - property: encryption_key - - secretKey: rapidapi_key - remoteRef: - key: JobSync - property: rapidapi_key diff --git a/argocd/manifests/jobsync/ingress-tailscale.yaml b/argocd/manifests/jobsync/ingress-tailscale.yaml deleted file mode 100644 index 54b7ce6..0000000 --- a/argocd/manifests/jobsync/ingress-tailscale.yaml +++ /dev/null @@ -1,26 +0,0 @@ ---- -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: jobsync-tailscale - namespace: jobsync - annotations: - tailscale.com/proxy-class: "default" - tailscale.com/proxy-group: "ingress" - gethomepage.dev/enabled: "true" - gethomepage.dev/name: "JobSync" - gethomepage.dev/group: "Services" - gethomepage.dev/icon: "mdi-briefcase-search" - gethomepage.dev/description: "Job application tracker" - gethomepage.dev/href: "https://jobsync.ops.eblu.me" - gethomepage.dev/pod-selector: "app=jobsync" -spec: - ingressClassName: tailscale - defaultBackend: - service: - name: jobsync - port: - number: 3000 - tls: - - hosts: - - jobsync diff --git a/argocd/manifests/jobsync/kustomization.yaml b/argocd/manifests/jobsync/kustomization.yaml deleted file mode 100644 index 7929f1e..0000000 --- a/argocd/manifests/jobsync/kustomization.yaml +++ /dev/null @@ -1,15 +0,0 @@ ---- -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization -namespace: jobsync -resources: - - pvc.yaml - - external-secret.yaml - - deployment.yaml - - service.yaml - - ingress-tailscale.yaml - -images: - - name: blumeops/jobsync - newName: registry.ops.eblu.me/blumeops/jobsync - newTag: "v1.1.4-3a811fb-nix" diff --git a/argocd/manifests/jobsync/pvc.yaml b/argocd/manifests/jobsync/pvc.yaml deleted file mode 100644 index 01ab796..0000000 --- a/argocd/manifests/jobsync/pvc.yaml +++ /dev/null @@ -1,13 +0,0 @@ ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: jobsync-data - namespace: jobsync -spec: - accessModes: - - ReadWriteOnce - storageClassName: local-path - resources: - requests: - storage: 5Gi diff --git a/argocd/manifests/jobsync/service.yaml b/argocd/manifests/jobsync/service.yaml deleted file mode 100644 index dc2d73a..0000000 --- a/argocd/manifests/jobsync/service.yaml +++ /dev/null @@ -1,13 +0,0 @@ ---- -apiVersion: v1 -kind: Service -metadata: - name: jobsync - namespace: jobsync -spec: - selector: - app: jobsync - ports: - - name: http - port: 3000 - targetPort: 3000 diff --git a/containers/jobsync/default.nix b/containers/jobsync/default.nix deleted file mode 100644 index 198dd70..0000000 --- a/containers/jobsync/default.nix +++ /dev/null @@ -1,126 +0,0 @@ -# Nix-built JobSync container -# Next.js job application tracker with Prisma/SQLite -# Built with dockerTools.buildLayeredImage for efficient layer caching -{ pkgs ? import <nixpkgs> { } }: - -let - version = "1.1.4"; - - prismaEngines = pkgs.prisma-engines; - - src = pkgs.fetchgit { - url = "https://forge.ops.eblu.me/mirrors/jobsync.git"; - rev = "v${version}"; - hash = "sha256-59W5OF36yD67jEK5xa9jSL4EVN9RG+Ez/w9Mq2VykSA="; - }; - - jobsync = pkgs.buildNpmPackage { - inherit src version; - pname = "jobsync"; - npmDepsHash = "sha256-yRNOxtz66qSlmfjR3QDPUQe0C8sdg06tBbuK1Ws1gEA="; - - nodejs = pkgs.nodejs_20; - - # Patch out Google Fonts import (nix sandbox blocks network access at - # build time). Replace with a simple object; app uses system sans-serif. - postPatch = '' - substituteInPlace src/app/layout.tsx \ - --replace-fail 'import { Inter } from "next/font/google";' "" \ - --replace-fail 'const inter = Inter({ - subsets: ["latin"], - variable: "--font-inter", -});' 'const inter = { variable: "" };' - ''; - - # Point Prisma at nixpkgs-built engines (no network download in sandbox) - env = { - PRISMA_QUERY_ENGINE_LIBRARY = "${prismaEngines}/lib/libquery_engine.node"; - PRISMA_QUERY_ENGINE_BINARY = "${prismaEngines}/bin/query-engine"; - PRISMA_SCHEMA_ENGINE_BINARY = "${prismaEngines}/bin/schema-engine"; - PRISMA_FMT_BINARY = "${prismaEngines}/bin/prisma-fmt"; - PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING = "1"; - DATABASE_URL = "file:/tmp/build.db"; - NEXT_TELEMETRY_DISABLED = "1"; - }; - - buildPhase = '' - runHook preBuild - - # Generate Prisma client using nixpkgs engines - npx prisma generate - - # Build Next.js - npm run build - - runHook postBuild - ''; - - installPhase = '' - runHook preInstall - - mkdir -p $out/app - - # Copy Next.js standalone output - cp -r .next/standalone/. $out/app/ - cp -r .next/static $out/app/.next/static - cp -r public $out/app/public - - # Copy Prisma schema and migrations for runtime migrate deploy - cp -r prisma $out/app/prisma - - # Copy entrypoint - cp ${./entrypoint.sh} $out/app/entrypoint.sh - - runHook postInstall - ''; - - dontNpmBuild = true; - }; - - entrypoint = pkgs.writeShellScript "jobsync-entrypoint" '' - cd ${jobsync}/app - exec ${pkgs.bash}/bin/bash entrypoint.sh "$@" - ''; -in - -pkgs.dockerTools.buildLayeredImage { - name = "blumeops/jobsync"; - tag = "latest"; - - contents = [ - jobsync - prismaEngines - pkgs.nodejs_20 - pkgs.cacert - pkgs.tzdata - pkgs.bash - pkgs.coreutils - ]; - - # Create writable directories and FHS symlinks for nix container - extraCommands = '' - mkdir -p tmp data usr/bin - ln -s ${pkgs.coreutils}/bin/env usr/bin/env - ''; - - config = { - Entrypoint = [ "${entrypoint}" ]; - WorkingDir = "${jobsync}/app"; - Env = [ - "SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" - "TZDIR=${pkgs.tzdata}/share/zoneinfo" - "NODE_ENV=production" - "PORT=3000" - "DATABASE_URL=file:/data/dev.db" - "PRISMA_QUERY_ENGINE_LIBRARY=${prismaEngines}/lib/libquery_engine.node" - "PRISMA_SCHEMA_ENGINE_BINARY=${prismaEngines}/bin/schema-engine" - "PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING=1" - ]; - ExposedPorts = { - "3000/tcp" = { }; - }; - Volumes = { - "/data" = { }; - }; - }; -} diff --git a/containers/jobsync/entrypoint.sh b/containers/jobsync/entrypoint.sh deleted file mode 100644 index 4dc611f..0000000 --- a/containers/jobsync/entrypoint.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/bin/sh -set -e - -# Auto-generate AUTH_SECRET if not provided -if [ -z "$AUTH_SECRET" ]; then - AUTH_SECRET="$(node -e "console.log(require('crypto').randomBytes(32).toString('base64'))")" - export AUTH_SECRET - echo "AUTH_SECRET was not set — generated a temporary secret for this container." -fi - -# Run Prisma migrations (npx -y downloads prisma if not in local node_modules) -npx -y prisma@6.19.0 migrate deploy - -# Start the Next.js server -exec node server.js diff --git a/docs/changelog.d/decommission-jobsync.infra.md b/docs/changelog.d/decommission-jobsync.infra.md new file mode 100644 index 0000000..c0e81ee --- /dev/null +++ b/docs/changelog.d/decommission-jobsync.infra.md @@ -0,0 +1 @@ +Decommission JobSync service — removed ArgoCD app, k8s manifests, container build, Caddy proxy, Homepage entry, docs, and forge mirror. Replaced by datasette-based job tracking (coming soon). diff --git a/docs/how-to/jobsync/build-jobsync-container.md b/docs/how-to/jobsync/build-jobsync-container.md deleted file mode 100644 index d9653e9..0000000 --- a/docs/how-to/jobsync/build-jobsync-container.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -title: Build JobSync Container -modified: 2026-03-11 -last-reviewed: 2026-03-11 -tags: - - how-to - - jobsync - - nix ---- - -# Build JobSync Container - -Build and release the JobSync nix container image. - -```fish -mise run container-release jobsync 1.1.4 -``` - -The derivation is at `containers/jobsync/default.nix`. It uses `buildNpmPackage` for the Next.js app and `dockerTools.buildLayeredImage` for the container. The entrypoint (`containers/jobsync/entrypoint.sh`) runs `prisma migrate deploy` then starts `node server.js`. - -## Upgrading JobSync - -1. Verify the forge mirror is current: check `https://forge.eblu.me/mirrors/jobsync` (mirrors sync automatically) -2. Update `version` in `default.nix` to match the new upstream tag -3. Clear `hash` in `fetchgit` (set to `""`), build, grab the correct hash from the error -4. Clear `npmDepsHash` (set to `""`), build again, grab the correct hash -5. Check if `postPatch` still applies — the Google Fonts import may change between versions -6. `mise run container-release jobsync <new-version>` -7. Update `newTag` in `argocd/manifests/jobsync/kustomization.yaml` - -## Nix + Prisma + Next.js Pitfalls - -### Prisma engine downloads blocked by sandbox - -Prisma tries to download platform-specific engine binaries during `prisma generate`. The nix sandbox blocks network access at build time. - -**Fix:** Use `pkgs.prisma-engines` from nixpkgs and set env vars pointing at the nix store paths: `PRISMA_QUERY_ENGINE_LIBRARY`, `PRISMA_QUERY_ENGINE_BINARY`, `PRISMA_SCHEMA_ENGINE_BINARY`, `PRISMA_FMT_BINARY`. Set `PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING=1` to tolerate minor version mismatch. - -### Google Fonts blocked by sandbox - -`next/font/google` fetches from `fonts.googleapis.com` during `next build`. - -**Fix:** Patch `src/app/layout.tsx` in `postPatch` to replace the Google font import with a no-op object. The app falls back to system sans-serif. - -### Missing FHS paths in nix containers - -Nix containers lack `/usr/bin/env`, `/tmp`, etc. `npx`-downloaded packages use `#!/usr/bin/env node` shebangs. - -**Fix:** In `extraCommands`: `mkdir -p tmp data usr/bin` and `ln -s ${pkgs.coreutils}/bin/env usr/bin/env`. - -### Runtime migrations via npx - -The nix sandbox blocks network at build time, but runtime has full network access. Use `npx -y prisma@<version> migrate deploy` in the entrypoint — npx downloads the prisma CLI on first run. - -### Build on ringtail, not via Dagger - -The Dagger `build-nix` pipeline runs in host architecture. On macOS (arm64), this produces arm64 images. Build on ringtail (x86_64) using the CI workflow or `mise run container-release`. - -## Related - -- [[deploy-jobsync]] -- [[build-container-image]] diff --git a/docs/how-to/jobsync/deploy-jobsync.md b/docs/how-to/jobsync/deploy-jobsync.md deleted file mode 100644 index 325af62..0000000 --- a/docs/how-to/jobsync/deploy-jobsync.md +++ /dev/null @@ -1,74 +0,0 @@ ---- -title: Deploy JobSync -modified: 2026-03-13 -last-reviewed: 2026-03-13 -tags: - - how-to - - jobsync ---- - -# Deploy JobSync - -[JobSync](https://github.com/Gsync/jobsync) is a self-hosted job application tracker (Next.js + Prisma/SQLite) running on ringtail's k3s cluster via ArgoCD. - -- **URL:** `https://jobsync.ops.eblu.me` -- **Auth:** Local accounts (email/password), no SSO -- **Storage:** 5Gi PVC at `/data` (SQLite DB + resume uploads) -- **AI:** Ollama at `ollama.ollama.svc.cluster.local:11434` - -## Manifests - -All in `argocd/manifests/jobsync/`: - -| File | Purpose | -|------|---------| -| `deployment.yaml` | Single-replica deployment | -| `service.yaml` | ClusterIP on port 3000 | -| `ingress-tailscale.yaml` | Tailscale Ingress (ProxyGroup) | -| `pvc.yaml` | 5Gi local-path for `/data` | -| `external-secret.yaml` | `auth_secret` + `encryption_key` from 1Password | -| `kustomization.yaml` | Image tag override | - -## Environment Variables - -| Variable | Source | Purpose | -|----------|--------|---------| -| `DATABASE_URL` | Hardcoded | `file:/data/dev.db` | -| `AUTH_SECRET` | ExternalSecret | NextAuth session signing | -| `ENCRYPTION_KEY` | ExternalSecret | AES-256-GCM for stored API keys | -| `NEXTAUTH_URL` | Hardcoded | `https://jobsync.ops.eblu.me` | -| `AUTH_TRUST_HOST` | Hardcoded | `true` | -| `NEXT_TELEMETRY_DISABLED` | Hardcoded | `1` (opt out of Next.js telemetry) | -| `TZ` | Hardcoded | `America/Los_Angeles` | -| `OLLAMA_BASE_URL` | Hardcoded | `http://ollama.ollama.svc.cluster.local:11434` | -| `RAPIDAPI_KEY` | ExternalSecret | JSearch job search API key | - -## Updating the Container - -1. Build and push: `mise run container-release jobsync <version>` -2. Update `newTag` in `kustomization.yaml` to the full tag (e.g. `v1.1.4-3a811fb-nix`) -3. Sync: `argocd app sync jobsync` - -See [[build-jobsync-container]] for nix build details. - -## Notes - -- **1Password item:** "JobSync" in blumeops vault, fields `auth_secret`, `encryption_key`, and `rapidapi_key` -- **Caddy route:** `jobsync.ops.eblu.me` → `https://jobsync.tail8d86e.ts.net` (in `ansible/roles/caddy/defaults/main.yml`) -- **`service-versions.yaml`:** Must have a `jobsync` entry or the pre-commit hook rejects container changes - -## Observability - -JobSync has no metrics endpoint. Logs are collected by Alloy on ringtail and shipped to Loki. Query in Grafana: - -```logql -{namespace="jobsync", app="jobsync"} -``` - -The app runs a scheduled job search daily at 4 AM. Search failures appear in logs during those executions. - -## Related - -- [[jobsync]] — Service reference card -- [[build-jobsync-container]] -- [[deploy-k8s-service]] diff --git a/docs/reference/infrastructure/ringtail.md b/docs/reference/infrastructure/ringtail.md index 74d5a7d..95d6ee2 100644 --- a/docs/reference/infrastructure/ringtail.md +++ b/docs/reference/infrastructure/ringtail.md @@ -70,7 +70,6 @@ Sync order: `1password-connect-ringtail` -> `external-secrets-crds-ringtail` -> | [[authentik]] | `authentik` | OIDC identity provider | | [[ntfy]] | `ntfy` | Push notification server | | [[ollama]] | `ollama` | LLM inference with GPU (RTX 4080) | -| [[jobsync]] | `jobsync` | Job application tracker | | nvidia-device-plugin | `nvidia-device-plugin` | Exposes GPU to pods via CDI + nvidia RuntimeClass | ### Manual Cluster Registration diff --git a/docs/reference/services/jobsync.md b/docs/reference/services/jobsync.md deleted file mode 100644 index 0a28de4..0000000 --- a/docs/reference/services/jobsync.md +++ /dev/null @@ -1,104 +0,0 @@ ---- -title: JobSync -modified: 2026-03-08 -tags: - - service - - job-search ---- - -# JobSync - -Self-hosted job application tracker. Tracks job applications, automates job searching via the JSearch API, and provides AI-powered resume tailoring via [[ollama|Ollama]]. - -## Quick Reference - -| Property | Value | -|----------|-------| -| **URL** | https://jobsync.ops.eblu.me | -| **Tailscale URL** | https://jobsync.tail8d86e.ts.net | -| **Namespace** | `jobsync` | -| **Cluster** | ringtail k3s | -| **Image** | `blumeops/jobsync` (Nix-built) | -| **Upstream** | https://github.com/Gsync/jobsync | -| **Manifests** | `argocd/manifests/jobsync/` | -| **Port** | 3000 | - -## Architecture - -``` -Browser ──HTTPS──► Caddy (jobsync.ops.eblu.me) - │ - ▼ - Tailscale ProxyGroup - │ - ▼ - JobSync (Next.js) - ┌───────┴───────┐ - │ │ - SQLite (/data) Ollama (in-cluster) - │ │ - PVC 5Gi GPU-accelerated LLM -``` - -- **Framework:** Next.js 15 + Prisma ORM -- **Database:** SQLite on a 5Gi PVC at `/data` -- **Auth:** Local email/password accounts (NextAuth v5), no SSO -- **AI:** Ollama at `http://ollama.ollama.svc.cluster.local:11434` for resume tailoring -- **Job Search:** JSearch API via RapidAPI (requires `RAPIDAPI_KEY`) - -## Job Search (JSearch / RapidAPI) - -The automated job search feature uses the [JSearch API](https://rapidapi.com/letscrape-6bRBa3QguO5/api/jsearch) on RapidAPI. The API key can be configured two ways (checked in order): - -1. **Per-user:** Added via Settings > API Keys in the web UI (encrypted with `ENCRYPTION_KEY`) -2. **Environment variable:** `RAPIDAPI_KEY` env var as a fallback for all users - -Without either, job search automations fail with: `Search failed: network - RAPIDAPI_KEY is not configured` - -The free tier allows 200 requests/month. The key is stored in 1Password ("JobSync" item, `rapidapi_key` field) and synced via ExternalSecret. - -## Secrets - -All secrets are in the **JobSync** 1Password item (blumeops vault), synced by ExternalSecret: - -| Secret | 1Password Field | Purpose | -|--------|-----------------|---------| -| `auth_secret` | `auth_secret` | NextAuth session signing | -| `encryption_key` | `encryption_key` | AES-256-GCM for stored API keys | -| `rapidapi_key` | `rapidapi_key` | JSearch job search API | - -## Observability - -JobSync has no metrics endpoint or Grafana dashboard. Logs are collected by [[alloy|Alloy]] on ringtail and shipped to Loki on indri. - -**Querying logs in Grafana:** - -```logql -{namespace="jobsync", app="jobsync"} -``` - -To search for job search errors specifically: - -```logql -{namespace="jobsync", app="jobsync"} |~ "(?i)(rapid|search failed|error)" -``` - -The app runs a scheduled job search daily at 4 AM. Failures appear in logs during those executions. - -## Container - -Built with Nix on ringtail (x86_64). See [[build-jobsync-container]] for details. - -```fish -mise run container-release jobsync <version> -``` - -Update `newTag` in `argocd/manifests/jobsync/kustomization.yaml` after building, then `argocd app sync jobsync`. - -## Related - -- [[ollama]] — AI backend for resume tailoring -- [[ringtail]] — Host node -- [[deploy-jobsync]] — Deployment how-to -- [[build-jobsync-container]] — Container build guide -- [[apps]] — ArgoCD application registry diff --git a/mise-tasks/services-check b/mise-tasks/services-check index 9ba2c8e..2417f74 100755 --- a/mise-tasks/services-check +++ b/mise-tasks/services-check @@ -161,7 +161,6 @@ check_http "CV" "https://cv.ops.eblu.me/" check_http "Ntfy" "https://ntfy.ops.eblu.me/v1/health" check_http "Authentik" "https://authentik.ops.eblu.me/-/health/live/" check_http "Frigate" "https://nvr.ops.eblu.me/api/version" -check_http "JobSync" "https://jobsync.ops.eblu.me/" echo "" echo "Frigate (via alerting):" diff --git a/service-versions.yaml b/service-versions.yaml index 2711b32..370e134 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -157,13 +157,6 @@ services: current-version: "2026.2.0" upstream-source: https://github.com/goauthentik/authentik/releases - - name: jobsync - type: argocd - last-reviewed: 2026-03-11 - current-version: "1.1.4" - upstream-source: https://github.com/Gsync/jobsync/releases - notes: Job application tracker; nix container on ringtail k3s - - name: ollama type: argocd last-reviewed: "2026-03-02" From fd0bebb0fc471de9c329d1bad06ba2e3fa0f67e7 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Tue, 24 Mar 2026 13:27:36 -0700 Subject: [PATCH 114/430] Localize authentik-redis container (#309) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Replace upstream `docker.io/library/redis:7-alpine` (Redis 7.4.8) with a nix-built container using Redis 8.2.3 from nixpkgs - Introduce **attached service pattern**: `parent` field in service-versions.yaml, `<parent>-<component>` naming convention, and `assert pkgs.redis.version == version` in default.nix to prevent silent version drift on `flake.lock` updates - Document the pattern in [[review-services]] so future attached services slot in cleanly - Backfill `parent: grafana` on existing `grafana-sidecar` entry ## Version drift protection 1. `flake.lock` update bumps nixpkgs redis → `assert` in `default.nix` breaks `nix-build` 2. Developer updates `version` in `default.nix` → prek's `container-version-check` demands matching `service-versions.yaml` update 3. Both must agree before commit succeeds ## Test plan - [ ] Build container from branch on ringtail (`mise run container-build-and-release authentik-redis`) - [ ] Update kustomization `newTag` to branch-built image tag - [ ] Sync authentik ArgoCD app from branch (`argocd app set authentik --revision localize-redis && argocd app sync authentik`) - [ ] Verify Authentik login, session persistence, and task queue still work - [ ] After merge: C0 follow-up to update `newTag` to the main-built image tag 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/309 --- .forgejo/workflows/build-container.yaml | 1 + argocd/manifests/authentik/kustomization.yaml | 1 + containers/alloy/default.nix | 2 -- containers/authentik-redis/default.nix | 29 +++++++++++++++++++ containers/authentik/default.nix | 2 -- containers/ntfy/default.nix | 2 -- docs/changelog.d/localize-redis.infra.md | 1 + docs/how-to/knowledgebase/review-services.md | 25 +++++++++++++++- service-versions.yaml | 11 +++++++ 9 files changed, 67 insertions(+), 7 deletions(-) create mode 100644 containers/authentik-redis/default.nix create mode 100644 docs/changelog.d/localize-redis.infra.md diff --git a/.forgejo/workflows/build-container.yaml b/.forgejo/workflows/build-container.yaml index 6bd08e0..6e5ed38 100644 --- a/.forgejo/workflows/build-container.yaml +++ b/.forgejo/workflows/build-container.yaml @@ -30,6 +30,7 @@ jobs: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: + ref: ${{ inputs.ref || github.sha }} fetch-depth: 2 - name: Detect and classify changed containers diff --git a/argocd/manifests/authentik/kustomization.yaml b/argocd/manifests/authentik/kustomization.yaml index fa1e92c..1228d87 100644 --- a/argocd/manifests/authentik/kustomization.yaml +++ b/argocd/manifests/authentik/kustomization.yaml @@ -15,4 +15,5 @@ images: - name: registry.ops.eblu.me/blumeops/authentik newTag: v2026.2.0-2d4098e-nix - name: docker.io/library/redis + newName: registry.ops.eblu.me/blumeops/authentik-redis newTag: 7-alpine diff --git a/containers/alloy/default.nix b/containers/alloy/default.nix index f8e4966..e508a10 100644 --- a/containers/alloy/default.nix +++ b/containers/alloy/default.nix @@ -116,8 +116,6 @@ in pkgs.dockerTools.buildLayeredImage { name = "blumeops/alloy"; - tag = "latest"; - contents = [ alloy pkgs.cacert diff --git a/containers/authentik-redis/default.nix b/containers/authentik-redis/default.nix new file mode 100644 index 0000000..7b66f84 --- /dev/null +++ b/containers/authentik-redis/default.nix @@ -0,0 +1,29 @@ +# Nix-built Redis for Authentik +# Attached service: cache/broker (sessions, Celery task queue, caching) +# Uses Redis from nixpkgs, packaged with dockerTools.buildLayeredImage +# +# The version assertion ensures nix-build fails if a flake.lock update +# changes the Redis version — forcing an explicit version acknowledgment +# here and in service-versions.yaml (enforced by container-version-check). +{ pkgs ? import <nixpkgs> { } }: + +let + version = "8.2.3"; +in + +assert pkgs.redis.version == version; + +pkgs.dockerTools.buildLayeredImage { + name = "blumeops/authentik-redis"; + contents = [ + pkgs.redis + ]; + + config = { + Entrypoint = [ "${pkgs.redis}/bin/redis-server" ]; + Cmd = [ "--protected-mode" "no" ]; + ExposedPorts = { + "6379/tcp" = { }; + }; + }; +} diff --git a/containers/authentik/default.nix b/containers/authentik/default.nix index 7d5a976..5b965bd 100644 --- a/containers/authentik/default.nix +++ b/containers/authentik/default.nix @@ -41,8 +41,6 @@ in pkgs.dockerTools.buildLayeredImage { name = "blumeops/authentik"; - tag = "latest"; - contents = [ ak authentik-django diff --git a/containers/ntfy/default.nix b/containers/ntfy/default.nix index 7c13afd..cc1bc2a 100644 --- a/containers/ntfy/default.nix +++ b/containers/ntfy/default.nix @@ -67,8 +67,6 @@ in pkgs.dockerTools.buildLayeredImage { name = "blumeops/ntfy"; - tag = "latest"; - contents = [ ntfy pkgs.cacert diff --git a/docs/changelog.d/localize-redis.infra.md b/docs/changelog.d/localize-redis.infra.md new file mode 100644 index 0000000..2d6b382 --- /dev/null +++ b/docs/changelog.d/localize-redis.infra.md @@ -0,0 +1 @@ +Localize authentik-redis container: replace upstream `redis:7-alpine` with nix-built image from nixpkgs (Redis 8.2.3). Introduces attached service pattern with `parent` field in service-versions.yaml and version assertion in default.nix to prevent silent version drift. diff --git a/docs/how-to/knowledgebase/review-services.md b/docs/how-to/knowledgebase/review-services.md index 713a021..675bdd6 100644 --- a/docs/how-to/knowledgebase/review-services.md +++ b/docs/how-to/knowledgebase/review-services.md @@ -1,6 +1,6 @@ --- title: Review Services -modified: 2026-02-19 +modified: 2026-03-24 last-reviewed: 2026-03-07 tags: - how-to @@ -59,6 +59,29 @@ mise run service-review --type hybrid 2. Review the Nix derivation or flake input for version pins 3. If upgrading, update and deploy via `mise run provision-ringtail` +## Attached Services + +Some services have auxiliary dependencies that run as separate containers — caches, sidecars, init helpers. These are tracked as **attached services** with a naming convention and an optional `parent` field: + +```yaml +- name: authentik-redis + type: argocd + parent: authentik + current-version: "8.2.3" + upstream-source: https://github.com/redis/redis/releases + notes: >- + Attached service: Redis cache/broker for Authentik. +``` + +**Conventions:** + +- **Naming:** `<parent>-<component>` (e.g., `authentik-redis`, `grafana-sidecar`) +- **`parent` field:** points to the parent service entry. Currently informational — the review task doesn't use it yet, but it enables future grouping/dependency-aware reviews. +- **`notes` field:** always starts with "Attached service:" to make the relationship clear at a glance. +- **Version tracking:** attached services that use nixpkgs packages should include a version assertion in `default.nix` (`assert pkgs.<pkg>.version == version;`) so that `flake.lock` updates that change the package version break the build and force explicit acknowledgment. + +Existing attached services: `grafana-sidecar`, `authentik-redis`. + ## Version Tracking Convention The `current-version` field in `service-versions.yaml` tracks the **upstream application version**, not the container image tag. For services with custom-built containers, the container image tag (e.g., `v1.0.0`) is decoupled from the contained app version (e.g., `v1.10.1`). This allows container rebuilds (base image updates, build fixes) without implying an upstream version change. diff --git a/service-versions.yaml b/service-versions.yaml index 370e134..2aa82fd 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -104,6 +104,7 @@ services: - name: grafana-sidecar type: argocd + parent: grafana last-reviewed: "2026-03-03" current-version: "1.28.0" upstream-source: https://github.com/kiwigrid/k8s-sidecar/releases @@ -157,6 +158,16 @@ services: current-version: "2026.2.0" upstream-source: https://github.com/goauthentik/authentik/releases + - name: authentik-redis + type: argocd + parent: authentik + last-reviewed: "2026-03-24" + current-version: "8.2.3" + upstream-source: https://github.com/redis/redis/releases + notes: >- + Attached service: Redis cache/broker for Authentik (sessions, Celery task + queue, caching). Nix-built container from nixpkgs with version assertion. + - name: ollama type: argocd last-reviewed: "2026-03-02" From 3b7abbd689443d343e37f7370c259637ce986b29 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Tue, 24 Mar 2026 13:39:26 -0700 Subject: [PATCH 115/430] Update container tags to fd0bebb (post-merge rebuild) C0 follow-up to #309: update kustomization newTag for all containers rebuilt by the merge (authentik, authentik-redis, ntfy, alloy). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- argocd/manifests/alloy-k8s/kustomization.yaml | 2 +- argocd/manifests/alloy-ringtail/kustomization.yaml | 2 +- argocd/manifests/alloy-tracing-ringtail/kustomization.yaml | 2 +- argocd/manifests/authentik/kustomization.yaml | 4 ++-- argocd/manifests/ntfy/kustomization.yaml | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/argocd/manifests/alloy-k8s/kustomization.yaml b/argocd/manifests/alloy-k8s/kustomization.yaml index ad0fc0b..f51bd3a 100644 --- a/argocd/manifests/alloy-k8s/kustomization.yaml +++ b/argocd/manifests/alloy-k8s/kustomization.yaml @@ -10,7 +10,7 @@ resources: images: - name: registry.ops.eblu.me/blumeops/alloy - newTag: v1.14.0-613f05d + newTag: v1.14.0-fd0bebb configMapGenerator: - name: alloy-config diff --git a/argocd/manifests/alloy-ringtail/kustomization.yaml b/argocd/manifests/alloy-ringtail/kustomization.yaml index dbecb6a..df472aa 100644 --- a/argocd/manifests/alloy-ringtail/kustomization.yaml +++ b/argocd/manifests/alloy-ringtail/kustomization.yaml @@ -10,7 +10,7 @@ resources: images: - name: registry.ops.eblu.me/blumeops/alloy - newTag: v1.14.0-613f05d-nix + newTag: v1.14.0-fd0bebb-nix configMapGenerator: - name: alloy-config diff --git a/argocd/manifests/alloy-tracing-ringtail/kustomization.yaml b/argocd/manifests/alloy-tracing-ringtail/kustomization.yaml index 4dda1f7..5c8e683 100644 --- a/argocd/manifests/alloy-tracing-ringtail/kustomization.yaml +++ b/argocd/manifests/alloy-tracing-ringtail/kustomization.yaml @@ -9,7 +9,7 @@ resources: images: - name: registry.ops.eblu.me/blumeops/alloy - newTag: v1.14.0-613f05d-nix + newTag: v1.14.0-fd0bebb-nix configMapGenerator: - name: alloy-tracing-config diff --git a/argocd/manifests/authentik/kustomization.yaml b/argocd/manifests/authentik/kustomization.yaml index 1228d87..8aade37 100644 --- a/argocd/manifests/authentik/kustomization.yaml +++ b/argocd/manifests/authentik/kustomization.yaml @@ -13,7 +13,7 @@ resources: - ingress-tailscale.yaml images: - name: registry.ops.eblu.me/blumeops/authentik - newTag: v2026.2.0-2d4098e-nix + newTag: v2026.2.0-fd0bebb-nix - name: docker.io/library/redis newName: registry.ops.eblu.me/blumeops/authentik-redis - newTag: 7-alpine + newTag: v8.2.3-fd0bebb-nix diff --git a/argocd/manifests/ntfy/kustomization.yaml b/argocd/manifests/ntfy/kustomization.yaml index 2465c83..7f81c6d 100644 --- a/argocd/manifests/ntfy/kustomization.yaml +++ b/argocd/manifests/ntfy/kustomization.yaml @@ -8,7 +8,7 @@ resources: - ingress-tailscale.yaml images: - name: registry.ops.eblu.me/blumeops/ntfy - newTag: v2.19.2-d1dac0c-nix + newTag: v2.19.2-fd0bebb-nix configMapGenerator: - name: ntfy-config files: From d021b3534f59b5499d41db96479f90738cbe709b Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Tue, 24 Mar 2026 16:08:09 -0700 Subject: [PATCH 116/430] Deploy Prowler CIS scanner (#310) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Deploy Prowler 5 as a weekly CronJob on minikube-indri for CIS Kubernetes Benchmark v1.11 scanning - Custom slim container build (strips PowerShell, Trivy, and non-K8s providers from upstream) - Reports (HTML, CSV, JSON-OCSF) written to NFS share on sifaka at `/volume1/reports/prowler/` - Read-only ClusterRole for pod, RBAC, and control plane inspection - Host path mounts + hostPID for kubelet file permission checks ## Follow-ups - Mirror prowler-cloud/prowler on forge for supply chain control - Build and push container image, update kustomization.yaml newTag - Consider adding k3s-ringtail scanning (core + RBAC checks only) ## Test plan - [ ] Build container: `mise run container-release prowler v5.22.0` - [ ] Update `argocd/manifests/prowler/kustomization.yaml` newTag to built image tag - [ ] Sync ArgoCD: `argocd app sync apps && argocd app set prowler --revision deploy-prowler && argocd app sync prowler` - [ ] Trigger manual job: `kubectl create job --from=cronjob/prowler prowler-manual -n prowler --context=minikube-indri` - [ ] Verify reports appear on sifaka NFS share - [ ] `mise run services-check` 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/310 --- argocd/apps/prowler.yaml | 17 ++++ argocd/manifests/prowler/cronjob.yaml | 55 +++++++++++++ argocd/manifests/prowler/kustomization.yaml | 15 ++++ argocd/manifests/prowler/pv-nfs.yaml | 22 ++++++ argocd/manifests/prowler/pvc.yaml | 13 +++ argocd/manifests/prowler/rbac.yaml | 24 ++++++ argocd/manifests/prowler/serviceaccount.yaml | 5 ++ containers/prowler/Dockerfile | 45 +++++++++++ docs/changelog.d/deploy-prowler.feature.md | 1 + docs/how-to/operations/deploy-prowler.md | 61 ++++++++++++++ .../operations/read-compliance-reports.md | 79 +++++++++++++++++++ docs/reference/kubernetes/apps.md | 1 + docs/reference/operations/security.md | 53 +++++++++++++ docs/reference/services/prowler.md | 32 ++++++++ mise-tasks/container-build-and-release | 44 +++++------ service-versions.yaml | 7 ++ 16 files changed, 449 insertions(+), 25 deletions(-) create mode 100644 argocd/apps/prowler.yaml create mode 100644 argocd/manifests/prowler/cronjob.yaml create mode 100644 argocd/manifests/prowler/kustomization.yaml create mode 100644 argocd/manifests/prowler/pv-nfs.yaml create mode 100644 argocd/manifests/prowler/pvc.yaml create mode 100644 argocd/manifests/prowler/rbac.yaml create mode 100644 argocd/manifests/prowler/serviceaccount.yaml create mode 100644 containers/prowler/Dockerfile create mode 100644 docs/changelog.d/deploy-prowler.feature.md create mode 100644 docs/how-to/operations/deploy-prowler.md create mode 100644 docs/how-to/operations/read-compliance-reports.md create mode 100644 docs/reference/operations/security.md create mode 100644 docs/reference/services/prowler.md diff --git a/argocd/apps/prowler.yaml b/argocd/apps/prowler.yaml new file mode 100644 index 0000000..a98aa4f --- /dev/null +++ b/argocd/apps/prowler.yaml @@ -0,0 +1,17 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: prowler + namespace: argocd +spec: + project: default + source: + repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git + targetRevision: main + path: argocd/manifests/prowler + destination: + server: https://kubernetes.default.svc + namespace: prowler + syncPolicy: + syncOptions: + - CreateNamespace=true diff --git a/argocd/manifests/prowler/cronjob.yaml b/argocd/manifests/prowler/cronjob.yaml new file mode 100644 index 0000000..bc00831 --- /dev/null +++ b/argocd/manifests/prowler/cronjob.yaml @@ -0,0 +1,55 @@ +apiVersion: batch/v1 +kind: CronJob +metadata: + name: prowler + namespace: prowler +spec: + schedule: "0 3 * * 0" # Sunday 3am + concurrencyPolicy: Forbid + jobTemplate: + spec: + ttlSecondsAfterFinished: 604800 # Auto-delete after 7 days + template: + spec: + serviceAccountName: prowler + containers: + - name: prowler + image: registry.ops.eblu.me/blumeops/prowler:kustomized + args: + - kubernetes + - --compliance + - cis_1.11_kubernetes + - -z + - --output-formats + - html + - csv + - json-ocsf + - --output-directory + - /reports/prowler + volumeMounts: + - name: reports + mountPath: /reports + - name: var-lib-kubelet + mountPath: /var/lib/kubelet + readOnly: true + - name: etc-kubernetes + mountPath: /etc/kubernetes + readOnly: true + - name: var-lib-etcd + mountPath: /var/lib/etcd + readOnly: true + hostPID: true + restartPolicy: OnFailure + volumes: + - name: reports + persistentVolumeClaim: + claimName: prowler-reports + - name: var-lib-kubelet + hostPath: + path: /var/lib/kubelet + - name: etc-kubernetes + hostPath: + path: /etc/kubernetes + - name: var-lib-etcd + hostPath: + path: /var/lib/etcd diff --git a/argocd/manifests/prowler/kustomization.yaml b/argocd/manifests/prowler/kustomization.yaml new file mode 100644 index 0000000..a8b9840 --- /dev/null +++ b/argocd/manifests/prowler/kustomization.yaml @@ -0,0 +1,15 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: prowler + +resources: + - serviceaccount.yaml + - rbac.yaml + - pv-nfs.yaml + - pvc.yaml + - cronjob.yaml + +images: + - name: registry.ops.eblu.me/blumeops/prowler + newTag: v5.22.0-870be4e diff --git a/argocd/manifests/prowler/pv-nfs.yaml b/argocd/manifests/prowler/pv-nfs.yaml new file mode 100644 index 0000000..aa81405 --- /dev/null +++ b/argocd/manifests/prowler/pv-nfs.yaml @@ -0,0 +1,22 @@ +# NFS PersistentVolume for Prowler compliance reports +# Requires: NFS share on sifaka at /volume1/reports with NFS permissions for indri +# +# To create on Synology: +# 1. Control Panel > Shared Folder > Create +# 2. Name: reports, Location: Volume 1 +# 3. Control Panel > File Services > NFS > NFS Rules +# 4. Add rule for "reports" share: Hostname=indri, Privilege=Read/Write, Squash=No mapping +apiVersion: v1 +kind: PersistentVolume +metadata: + name: prowler-reports-nfs-pv +spec: + capacity: + storage: 10Gi + accessModes: + - ReadWriteMany + persistentVolumeReclaimPolicy: Retain + storageClassName: "" + nfs: + server: sifaka + path: /volume1/reports diff --git a/argocd/manifests/prowler/pvc.yaml b/argocd/manifests/prowler/pvc.yaml new file mode 100644 index 0000000..8d94378 --- /dev/null +++ b/argocd/manifests/prowler/pvc.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: prowler-reports + namespace: prowler +spec: + accessModes: + - ReadWriteMany + storageClassName: "" + volumeName: prowler-reports-nfs-pv + resources: + requests: + storage: 10Gi diff --git a/argocd/manifests/prowler/rbac.yaml b/argocd/manifests/prowler/rbac.yaml new file mode 100644 index 0000000..38fcfae --- /dev/null +++ b/argocd/manifests/prowler/rbac.yaml @@ -0,0 +1,24 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: prowler-reader +rules: + - apiGroups: [""] + resources: ["pods", "configmaps", "nodes", "namespaces"] + verbs: ["get", "list", "watch"] + - apiGroups: ["rbac.authorization.k8s.io"] + resources: ["clusterroles", "clusterrolebindings", "roles", "rolebindings"] + verbs: ["get", "list", "watch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: prowler-reader +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: prowler-reader +subjects: + - kind: ServiceAccount + name: prowler + namespace: prowler diff --git a/argocd/manifests/prowler/serviceaccount.yaml b/argocd/manifests/prowler/serviceaccount.yaml new file mode 100644 index 0000000..26aaaa7 --- /dev/null +++ b/argocd/manifests/prowler/serviceaccount.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: prowler + namespace: prowler diff --git a/containers/prowler/Dockerfile b/containers/prowler/Dockerfile new file mode 100644 index 0000000..cb557ca --- /dev/null +++ b/containers/prowler/Dockerfile @@ -0,0 +1,45 @@ +# Prowler CIS scanner — slim build for Kubernetes provider only +# Strips PowerShell (M365), Trivy (IaC), and dashboard dependencies from upstream +ARG CONTAINER_APP_VERSION=5.22.0 + +FROM python:3.12-slim-bookworm AS build + +ARG CONTAINER_APP_VERSION + +RUN apt-get update && apt-get install -y --no-install-recommends \ + git ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /build + +RUN git clone --depth 1 --branch ${CONTAINER_APP_VERSION} \ + https://forge.ops.eblu.me/mirrors/prowler.git . + +# Install prowler into a virtualenv so we can copy it cleanly +RUN python -m venv /opt/prowler \ + && /opt/prowler/bin/pip install --no-cache-dir --upgrade pip \ + && /opt/prowler/bin/pip install --no-cache-dir . + +# --- + +FROM python:3.12-slim-bookworm + +ARG CONTAINER_APP_VERSION + +LABEL org.opencontainers.image.title="prowler" +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" +LABEL org.opencontainers.image.description="Prowler CIS scanner (Kubernetes provider)" + +RUN addgroup --gid 1000 prowler \ + && adduser --uid 1000 --gid 1000 --disabled-password --gecos "" prowler + +COPY --from=build /opt/prowler /opt/prowler + +ENV PATH="/opt/prowler/bin:${PATH}" + +USER prowler +WORKDIR /home/prowler + +ENTRYPOINT ["prowler"] diff --git a/docs/changelog.d/deploy-prowler.feature.md b/docs/changelog.d/deploy-prowler.feature.md new file mode 100644 index 0000000..64236c7 --- /dev/null +++ b/docs/changelog.d/deploy-prowler.feature.md @@ -0,0 +1 @@ +Deploy Prowler CIS scanner as a weekly CronJob on minikube-indri, with reports written to sifaka NFS share. diff --git a/docs/how-to/operations/deploy-prowler.md b/docs/how-to/operations/deploy-prowler.md new file mode 100644 index 0000000..2b29d39 --- /dev/null +++ b/docs/how-to/operations/deploy-prowler.md @@ -0,0 +1,61 @@ +--- +title: Deploy Prowler CIS Scanner +modified: 2026-03-24 +last-reviewed: 2026-03-24 +tags: + - how-to + - kubernetes + - security + - compliance +--- + +# Deploy Prowler CIS Scanner + +Prowler runs weekly CIS Kubernetes Benchmark scans against minikube-indri and writes HTML/CSV/JSON reports to the NFS share on sifaka. + +## What it checks + +Prowler's Kubernetes provider runs ~70 checks from the CIS Kubernetes Benchmark v1.11, grouped into: + +| Category | Checks | How it works | +|----------|--------|-------------| +| **Core (pod security)** | 13 | Queries K8s API for privileged containers, hostPID/hostNetwork, capabilities, secrets in env vars, seccomp | +| **RBAC** | 9 | Queries RBAC API for overprivileged roles, wildcard access, cluster-admin bindings | +| **Apiserver** | 29 | Inspects `kube-apiserver` pod args in kube-system (TLS, auth, audit, admission plugins) | +| **Etcd** | 7 | Inspects `etcd` pod args (TLS, cert auth) | +| **Controller Manager** | 7 | Inspects `kube-controller-manager` pod args | +| **Kubelet** | 16 | Reads kubelet-config ConfigMap + node file permissions (file checks need hostPID) | +| **Scheduler** | 2 | Inspects `kube-scheduler` pod args | + +**Minikube relevance:** Most checks work because minikube runs control plane as static pods. Kubelet file permission checks return MANUAL unless Prowler runs on the node (we mount host paths to enable this). + +**k3s note:** k3s embeds the control plane in a single binary — no static pods exist. Only core + RBAC checks (~22 of 70) produce results. Consider `kube-bench` for k3s control plane checks. + +## Reports + +Reports are written to `sifaka:/volume1/reports/prowler/` with timestamped filenames. See [[read-compliance-reports]] for how to access and interpret them. + +## Running an ad-hoc scan + +```fish +kubectl create job --from=cronjob/prowler prowler-manual -n prowler --context=minikube-indri +``` + +Watch progress: + +```fish +kubectl logs -f job/prowler-manual -n prowler --context=minikube-indri +``` + +## Container + +Custom slim build at `containers/prowler/Dockerfile` — strips PowerShell, Trivy, and non-Kubernetes providers from upstream. See [[build-container-image]] for the build/release process. + +Source is mirrored at `forge.ops.eblu.me/mirrors/prowler`. + +## See also + +- [[security]] — security & compliance posture overview +- [[read-compliance-reports]] — how to access and interpret scan reports +- [[deploy-k8s-service]] — general K8s deployment how-to +- [[build-container-image]] — container build pipeline diff --git a/docs/how-to/operations/read-compliance-reports.md b/docs/how-to/operations/read-compliance-reports.md new file mode 100644 index 0000000..e2088f7 --- /dev/null +++ b/docs/how-to/operations/read-compliance-reports.md @@ -0,0 +1,79 @@ +--- +title: Read Compliance Reports +modified: 2026-03-24 +last-reviewed: 2026-03-24 +tags: + - how-to + - security + - compliance +--- + +# Read Compliance Reports + +How to access and interpret compliance scan reports from [[prowler]] and other security scanners. + +## Accessing reports + +Reports are stored on sifaka at `/volume1/reports/`. Each scanner writes to its own subdirectory: + +| Scanner | Path | Schedule | +|---------|------|----------| +| [[prowler]] | `sifaka:/volume1/reports/prowler/` | Weekly (Sunday 3am) | + +Copy reports to your local machine (remember `scp -O` for sifaka): + +```fish +scp -O sifaka:/volume1/reports/prowler/prowler-output-In-Cluster-*.html /tmp/ +open /tmp/prowler-output-In-Cluster-*.html +``` + +## Report formats + +### HTML + +Open in a browser. Self-contained, filterable by severity, status, and service. Best for human review — shows pass/fail per check with remediation guidance. + +### CSV + +One row per finding. Columns include check ID, status, severity, resource, namespace, description, and remediation. Good for filtering in a spreadsheet or scripting. + +### JSON-OCSF + +Open Cybersecurity Schema Framework format. Machine-parseable, suitable for SIEM ingestion or programmatic analysis. + +### Compliance CSV + +In the `compliance/` subdirectory. Findings mapped to specific framework requirement IDs (e.g., CIS 1.11 section numbers). Shows which controls pass or fail. + +## Interpreting results + +### Status values + +- **PASS** — the resource is configured securely per the check +- **FAIL** — remediation is recommended +- **MANUAL** — Prowler cannot determine the result automatically (e.g., kubelet file permissions when not running on the node) +- **MUTED** — the finding was explicitly suppressed via a mutelist + +### Severity + +Findings are categorized as **critical**, **high**, **medium**, or **low**. Focus on critical and high first. + +### Expected failures + +Not all failures require action. Common expected failures in our minikube cluster: + +- **Core/pod security (high):** System pods (ArgoCD, external-secrets, tailscale-operator) legitimately need elevated privileges. These can be mutelisted. +- **Apiserver (medium):** Audit logging, profiling, and some admission plugins are not configured in minikube defaults. Low risk for a homelab. +- **Kubelet (high):** Anonymous auth or read-only port settings from minikube defaults. + +### Acting on findings + +1. **Triage** — review new failures, distinguish real issues from expected noise +2. **Remediate** — fix what you can (pod security contexts, RBAC tightening) +3. **Mutelist** — suppress expected/accepted failures via Prowler's `--mutelist-file` to reduce noise in future scans +4. **Track** — compare reports over time to spot regressions + +## See also + +- [[security]] — security & compliance posture overview +- [[deploy-prowler]] — Prowler deployment and ad-hoc scans diff --git a/docs/reference/kubernetes/apps.md b/docs/reference/kubernetes/apps.md index 270cc55..02215fc 100644 --- a/docs/reference/kubernetes/apps.md +++ b/docs/reference/kubernetes/apps.md @@ -40,6 +40,7 @@ Registry of all applications deployed via [[argocd]]. | `forgejo-runner` | forgejo-runner | `argocd/manifests/forgejo-runner/` | [[forgejo]] CI | | `ollama` | ollama | `argocd/manifests/ollama/` | [[ollama]] | | `mealie` | mealie | `argocd/manifests/mealie/` | [[mealie]] | +| `prowler` | prowler | `argocd/manifests/prowler/` | [[prowler]] | ## Sync Policies diff --git a/docs/reference/operations/security.md b/docs/reference/operations/security.md new file mode 100644 index 0000000..8a621b1 --- /dev/null +++ b/docs/reference/operations/security.md @@ -0,0 +1,53 @@ +--- +title: Security & Compliance +modified: 2026-03-24 +last-reviewed: 2026-03-24 +tags: + - operations + - security +--- + +# Security & Compliance + +Security posture and compliance scanning for BlumeOps infrastructure. + +## Compliance frameworks + +| Framework | Tool | Cluster | Notes | +|-----------|------|---------|-------| +| CIS Kubernetes Benchmark v1.11 | [[prowler]] | minikube-indri | Weekly CronJob, ~82 checks | +| PCI DSS v4.0 (K8s mapping) | [[prowler]] | minikube-indri | Reuses CIS checks mapped to PCI requirements | +| ISO 27001:2022 (K8s mapping) | [[prowler]] | minikube-indri | Partial — 22 of 92 controls mapped | + +## Scanning tools + +- [[prowler]] — CIS Kubernetes Benchmark scanner (weekly CronJob) + - [[deploy-prowler]] — deployment and ad-hoc scan how-to + - [[read-compliance-reports]] — accessing and interpreting reports + +## Identity & access + +- [[authentik]] — SSO/OIDC provider for all web services +- RBAC — Kubernetes role-based access control (audited by Prowler RBAC checks) + +## Network & TLS + +- [[caddy]] — TLS termination for `*.ops.eblu.me` services +- [[flyio-proxy]] — public ingress via Fly.io tunnel +- Tailscale — zero-trust mesh networking across all nodes + +## Secrets management + +- [[1password]] — root credential store +- [[external-secrets]] — Kubernetes secrets synced from 1Password + +## Reports + +All compliance scan reports are stored on `sifaka:/volume1/reports/`. See [[read-compliance-reports]] for access and interpretation. + +## Known gaps + +- No SOC 2 compliance mapping for Kubernetes (Prowler only maps SOC 2 for AWS/Azure/GCP) +- k3s control plane checks produce no results (embedded binary, no static pods) — consider kube-bench +- No container image vulnerability scanning yet (Prowler has an `image` provider) +- No IaC scanning of manifests/Dockerfiles yet (Prowler has an `iac` provider using Trivy) diff --git a/docs/reference/services/prowler.md b/docs/reference/services/prowler.md new file mode 100644 index 0000000..f68a573 --- /dev/null +++ b/docs/reference/services/prowler.md @@ -0,0 +1,32 @@ +--- +title: Prowler +modified: 2026-03-24 +last-reviewed: 2026-03-24 +tags: + - service + - security +--- + +# Prowler + +CIS Kubernetes Benchmark scanner for compliance posture reporting. + +## Quick Reference + +| Property | Value | +|----------|-------| +| **Namespace** | `prowler` | +| **Image** | `registry.ops.eblu.me/blumeops/prowler` (see `argocd/manifests/prowler/kustomization.yaml` for current tag) | +| **Schedule** | Weekly (Sunday 3am) | +| **Reports** | `sifaka:/volume1/reports/prowler/` (NFS) | +| **Manifests** | `argocd/manifests/prowler/` | + +## What it does + +Runs Prowler 5 as a CronJob against minikube-indri, executing CIS Kubernetes Benchmark v1.11 checks across pod security, RBAC, apiserver, etcd, kubelet, controller-manager, and scheduler. Reports are written in HTML, CSV, and JSON-OCSF to the NFS share on sifaka. + +## See also + +- [[security]] — security & compliance posture overview +- [[deploy-prowler]] — deployment how-to, ad-hoc scan instructions, check relevance notes +- [[read-compliance-reports]] — how to access and interpret reports diff --git a/mise-tasks/container-build-and-release b/mise-tasks/container-build-and-release index ce57c2e..508d586 100755 --- a/mise-tasks/container-build-and-release +++ b/mise-tasks/container-build-and-release @@ -7,10 +7,10 @@ #USAGE arg "<container>" help="Container name (directory under containers/)" #USAGE flag "--ref <ref>" help="Commit SHA or branch to build (defaults to current HEAD)" #USAGE flag "--dry-run" help="Show what would be done without triggering" -"""Trigger container build workflows via Forgejo API dispatch. +"""Trigger container build workflow via Forgejo API dispatch. -Dispatches both Build Container and Build Container (Nix) workflows. -Each workflow checks for its build file and skips if not present. +Dispatches the unified build-container workflow, which handles both +Dockerfile and Nix builds in a single workflow. """ import subprocess @@ -26,10 +26,7 @@ FORGE_API = f"{FORGE_URL}/api/v1" REPO = "eblume/blumeops" FORGE_ACTIONS = f"{FORGE_URL}/{REPO}/actions" -WORKFLOWS = [ - "build-container.yaml", - "build-container-nix.yaml", -] +WORKFLOW = "build-container.yaml" app = typer.Typer(add_completion=False) @@ -108,9 +105,7 @@ def main( typer.echo() if dry_run: - typer.echo("[dry-run] Would dispatch workflows:") - for wf in WORKFLOWS: - typer.echo(f" - {wf}") + typer.echo(f"[dry-run] Would dispatch {WORKFLOW}") typer.echo() typer.echo(f"Monitor builds at: {FORGE_ACTIONS}") return @@ -121,21 +116,20 @@ def main( "Content-Type": "application/json", } - for wf in WORKFLOWS: - url = f"{FORGE_API}/repos/{REPO}/actions/workflows/{wf}/dispatches" - payload = { - "ref": "main", - "inputs": { - "container": container, - "ref": ref, - }, - } - resp = httpx.post(url, json=payload, headers=headers, timeout=30) - if resp.status_code == 204: - typer.echo(f"Dispatched {wf}") - else: - typer.echo(f"Error dispatching {wf}: {resp.status_code} {resp.text}") - raise typer.Exit(1) + url = f"{FORGE_API}/repos/{REPO}/actions/workflows/{WORKFLOW}/dispatches" + payload = { + "ref": "main", + "inputs": { + "container": container, + "ref": ref, + }, + } + resp = httpx.post(url, json=payload, headers=headers, timeout=30) + if resp.status_code == 204: + typer.echo(f"Dispatched {WORKFLOW}") + else: + typer.echo(f"Error dispatching {WORKFLOW}: {resp.status_code} {resp.text}") + raise typer.Exit(1) typer.echo() typer.echo(f"Monitor builds at: {FORGE_ACTIONS}") diff --git a/service-versions.yaml b/service-versions.yaml index 2aa82fd..321efe8 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -271,6 +271,13 @@ services: upstream-source: https://github.com/unpoller/unpoller/releases notes: UniFi metrics exporter for Prometheus + - name: prowler + type: argocd + last-reviewed: 2026-03-24 + current-version: "5.22.0" + upstream-source: https://github.com/prowler-cloud/prowler/releases + notes: CIS Kubernetes Benchmark scanner; weekly CronJob on minikube-indri + - name: forgejo type: ansible last-reviewed: 2026-02-22 From 87f56f78b35f6fdaf7ec055f8ac32d8b34a29585 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Tue, 24 Mar 2026 16:09:07 -0700 Subject: [PATCH 117/430] Update container tags to d021b35 (post-merge rebuild) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- argocd/manifests/prowler/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/argocd/manifests/prowler/kustomization.yaml b/argocd/manifests/prowler/kustomization.yaml index a8b9840..7d870ff 100644 --- a/argocd/manifests/prowler/kustomization.yaml +++ b/argocd/manifests/prowler/kustomization.yaml @@ -12,4 +12,4 @@ resources: images: - name: registry.ops.eblu.me/blumeops/prowler - newTag: v5.22.0-870be4e + newTag: v5.22.0-d021b35 From 07e9c810ca61a336a779026e6fbe48772f71eb45 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Tue, 24 Mar 2026 16:19:40 -0700 Subject: [PATCH 118/430] Add RuntimeDefault seccomp profiles to all managed workloads Addresses 32 CIS Kubernetes Benchmark failures from Prowler scan (core_seccomp_profile_docker_default). Applied pod-level seccomp RuntimeDefault to 18 deployments/statefulsets and 2 cronjobs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- argocd/manifests/cv/deployment.yaml | 3 +++ argocd/manifests/devpi/statefulset.yaml | 2 ++ argocd/manifests/docs/deployment.yaml | 3 +++ argocd/manifests/forgejo-runner/deployment.yaml | 3 +++ argocd/manifests/frigate/deployment.yaml | 3 +++ argocd/manifests/homepage/deployment.yaml | 2 ++ argocd/manifests/kiwix/cronjob-zim-watcher.yaml | 3 +++ argocd/manifests/kiwix/deployment.yaml | 3 +++ argocd/manifests/loki/statefulset.yaml | 2 ++ argocd/manifests/mealie/deployment.yaml | 3 +++ argocd/manifests/miniflux/deployment.yaml | 3 +++ argocd/manifests/navidrome/deployment.yaml | 2 ++ argocd/manifests/ntfy/deployment.yaml | 3 +++ argocd/manifests/ollama/deployment.yaml | 3 +++ argocd/manifests/prometheus/statefulset.yaml | 2 ++ argocd/manifests/prowler/cronjob.yaml | 3 +++ argocd/manifests/tempo/statefulset.yaml | 2 ++ argocd/manifests/teslamate/deployment.yaml | 3 +++ argocd/manifests/torrent/deployment.yaml | 3 +++ argocd/manifests/unpoller/deployment.yaml | 3 +++ docs/changelog.d/+seccomp-profiles.infra.md | 1 + 21 files changed, 55 insertions(+) create mode 100644 docs/changelog.d/+seccomp-profiles.infra.md diff --git a/argocd/manifests/cv/deployment.yaml b/argocd/manifests/cv/deployment.yaml index cda0bfe..f2b00e6 100644 --- a/argocd/manifests/cv/deployment.yaml +++ b/argocd/manifests/cv/deployment.yaml @@ -19,6 +19,9 @@ spec: labels: app: cv spec: + securityContext: + seccompProfile: + type: RuntimeDefault containers: - name: cv image: registry.ops.eblu.me/blumeops/cv:kustomized diff --git a/argocd/manifests/devpi/statefulset.yaml b/argocd/manifests/devpi/statefulset.yaml index bd383d9..91875df 100644 --- a/argocd/manifests/devpi/statefulset.yaml +++ b/argocd/manifests/devpi/statefulset.yaml @@ -16,6 +16,8 @@ spec: spec: securityContext: fsGroup: 1000 + seccompProfile: + type: RuntimeDefault containers: - name: devpi image: registry.ops.eblu.me/blumeops/devpi:kustomized diff --git a/argocd/manifests/docs/deployment.yaml b/argocd/manifests/docs/deployment.yaml index 85378f0..5b54ee6 100644 --- a/argocd/manifests/docs/deployment.yaml +++ b/argocd/manifests/docs/deployment.yaml @@ -19,6 +19,9 @@ spec: labels: app: docs spec: + securityContext: + seccompProfile: + type: RuntimeDefault containers: - name: docs image: registry.ops.eblu.me/blumeops/quartz:kustomized diff --git a/argocd/manifests/forgejo-runner/deployment.yaml b/argocd/manifests/forgejo-runner/deployment.yaml index f61fb77..1eda6dc 100644 --- a/argocd/manifests/forgejo-runner/deployment.yaml +++ b/argocd/manifests/forgejo-runner/deployment.yaml @@ -15,6 +15,9 @@ spec: labels: app: forgejo-runner spec: + securityContext: + seccompProfile: + type: RuntimeDefault containers: # Forgejo runner daemon - name: runner diff --git a/argocd/manifests/frigate/deployment.yaml b/argocd/manifests/frigate/deployment.yaml index ba69a5b..1200e76 100644 --- a/argocd/manifests/frigate/deployment.yaml +++ b/argocd/manifests/frigate/deployment.yaml @@ -17,6 +17,9 @@ spec: app: frigate spec: runtimeClassName: nvidia + securityContext: + seccompProfile: + type: RuntimeDefault initContainers: - name: copy-config image: busybox:kustomized diff --git a/argocd/manifests/homepage/deployment.yaml b/argocd/manifests/homepage/deployment.yaml index 7f66c41..76cbda3 100644 --- a/argocd/manifests/homepage/deployment.yaml +++ b/argocd/manifests/homepage/deployment.yaml @@ -18,6 +18,8 @@ spec: runAsUser: 1000 runAsGroup: 1000 fsGroup: 1000 + seccompProfile: + type: RuntimeDefault containers: - name: homepage image: registry.ops.eblu.me/blumeops/homepage:kustomized diff --git a/argocd/manifests/kiwix/cronjob-zim-watcher.yaml b/argocd/manifests/kiwix/cronjob-zim-watcher.yaml index 2373343..9d5b558 100644 --- a/argocd/manifests/kiwix/cronjob-zim-watcher.yaml +++ b/argocd/manifests/kiwix/cronjob-zim-watcher.yaml @@ -13,6 +13,9 @@ spec: template: spec: serviceAccountName: zim-watcher + securityContext: + seccompProfile: + type: RuntimeDefault containers: - name: watcher image: registry.ops.eblu.me/blumeops/kubectl:kustomized diff --git a/argocd/manifests/kiwix/deployment.yaml b/argocd/manifests/kiwix/deployment.yaml index 01532a2..a63fa49 100644 --- a/argocd/manifests/kiwix/deployment.yaml +++ b/argocd/manifests/kiwix/deployment.yaml @@ -17,6 +17,9 @@ spec: labels: app: kiwix spec: + securityContext: + seccompProfile: + type: RuntimeDefault containers: # Main kiwix-serve container - name: kiwix-serve diff --git a/argocd/manifests/loki/statefulset.yaml b/argocd/manifests/loki/statefulset.yaml index 3fb9be2..a776d47 100644 --- a/argocd/manifests/loki/statefulset.yaml +++ b/argocd/manifests/loki/statefulset.yaml @@ -18,6 +18,8 @@ spec: fsGroup: 10001 runAsNonRoot: true runAsUser: 10001 + seccompProfile: + type: RuntimeDefault containers: - name: loki image: grafana/loki:kustomized diff --git a/argocd/manifests/mealie/deployment.yaml b/argocd/manifests/mealie/deployment.yaml index 5c522fe..bdcf91e 100644 --- a/argocd/manifests/mealie/deployment.yaml +++ b/argocd/manifests/mealie/deployment.yaml @@ -13,6 +13,9 @@ spec: labels: app: mealie spec: + securityContext: + seccompProfile: + type: RuntimeDefault containers: - name: mealie image: registry.ops.eblu.me/blumeops/mealie:kustomized diff --git a/argocd/manifests/miniflux/deployment.yaml b/argocd/manifests/miniflux/deployment.yaml index b5b3239..94e805a 100644 --- a/argocd/manifests/miniflux/deployment.yaml +++ b/argocd/manifests/miniflux/deployment.yaml @@ -13,6 +13,9 @@ spec: labels: app: miniflux spec: + securityContext: + seccompProfile: + type: RuntimeDefault containers: - name: miniflux image: registry.ops.eblu.me/blumeops/miniflux:kustomized diff --git a/argocd/manifests/navidrome/deployment.yaml b/argocd/manifests/navidrome/deployment.yaml index 6074d28..e70519c 100644 --- a/argocd/manifests/navidrome/deployment.yaml +++ b/argocd/manifests/navidrome/deployment.yaml @@ -18,6 +18,8 @@ spec: runAsUser: 1000 runAsGroup: 1000 fsGroup: 1000 + seccompProfile: + type: RuntimeDefault containers: - name: navidrome image: registry.ops.eblu.me/blumeops/navidrome:kustomized diff --git a/argocd/manifests/ntfy/deployment.yaml b/argocd/manifests/ntfy/deployment.yaml index 3bbb172..a41387f 100644 --- a/argocd/manifests/ntfy/deployment.yaml +++ b/argocd/manifests/ntfy/deployment.yaml @@ -14,6 +14,9 @@ spec: labels: app: ntfy spec: + securityContext: + seccompProfile: + type: RuntimeDefault containers: - name: ntfy image: registry.ops.eblu.me/blumeops/ntfy:kustomized diff --git a/argocd/manifests/ollama/deployment.yaml b/argocd/manifests/ollama/deployment.yaml index 060fe8f..e8864c9 100644 --- a/argocd/manifests/ollama/deployment.yaml +++ b/argocd/manifests/ollama/deployment.yaml @@ -17,6 +17,9 @@ spec: app: ollama spec: runtimeClassName: nvidia + securityContext: + seccompProfile: + type: RuntimeDefault containers: - name: ollama image: ollama/ollama:kustomized diff --git a/argocd/manifests/prometheus/statefulset.yaml b/argocd/manifests/prometheus/statefulset.yaml index 5b4bf82..8a8e06f 100644 --- a/argocd/manifests/prometheus/statefulset.yaml +++ b/argocd/manifests/prometheus/statefulset.yaml @@ -18,6 +18,8 @@ spec: fsGroup: 65534 runAsNonRoot: true runAsUser: 65534 + seccompProfile: + type: RuntimeDefault containers: - name: prometheus image: registry.ops.eblu.me/blumeops/prometheus:kustomized diff --git a/argocd/manifests/prowler/cronjob.yaml b/argocd/manifests/prowler/cronjob.yaml index bc00831..545a9c8 100644 --- a/argocd/manifests/prowler/cronjob.yaml +++ b/argocd/manifests/prowler/cronjob.yaml @@ -12,6 +12,9 @@ spec: template: spec: serviceAccountName: prowler + securityContext: + seccompProfile: + type: RuntimeDefault containers: - name: prowler image: registry.ops.eblu.me/blumeops/prowler:kustomized diff --git a/argocd/manifests/tempo/statefulset.yaml b/argocd/manifests/tempo/statefulset.yaml index f871ebc..3df5c66 100644 --- a/argocd/manifests/tempo/statefulset.yaml +++ b/argocd/manifests/tempo/statefulset.yaml @@ -18,6 +18,8 @@ spec: fsGroup: 10001 runAsNonRoot: true runAsUser: 10001 + seccompProfile: + type: RuntimeDefault containers: - name: tempo image: grafana/tempo:kustomized diff --git a/argocd/manifests/teslamate/deployment.yaml b/argocd/manifests/teslamate/deployment.yaml index a2f7aca..42859a7 100644 --- a/argocd/manifests/teslamate/deployment.yaml +++ b/argocd/manifests/teslamate/deployment.yaml @@ -13,6 +13,9 @@ spec: labels: app: teslamate spec: + securityContext: + seccompProfile: + type: RuntimeDefault containers: - name: teslamate image: registry.ops.eblu.me/blumeops/teslamate:kustomized diff --git a/argocd/manifests/torrent/deployment.yaml b/argocd/manifests/torrent/deployment.yaml index c109861..ab42537 100644 --- a/argocd/manifests/torrent/deployment.yaml +++ b/argocd/manifests/torrent/deployment.yaml @@ -14,6 +14,9 @@ spec: labels: app: transmission spec: + securityContext: + seccompProfile: + type: RuntimeDefault containers: - name: transmission image: registry.ops.eblu.me/blumeops/transmission:kustomized diff --git a/argocd/manifests/unpoller/deployment.yaml b/argocd/manifests/unpoller/deployment.yaml index 2f7d13c..44c89b7 100644 --- a/argocd/manifests/unpoller/deployment.yaml +++ b/argocd/manifests/unpoller/deployment.yaml @@ -15,6 +15,9 @@ spec: labels: app: unpoller spec: + securityContext: + seccompProfile: + type: RuntimeDefault containers: - name: unpoller image: registry.ops.eblu.me/blumeops/unpoller:kustomized diff --git a/docs/changelog.d/+seccomp-profiles.infra.md b/docs/changelog.d/+seccomp-profiles.infra.md new file mode 100644 index 0000000..c0ee00d --- /dev/null +++ b/docs/changelog.d/+seccomp-profiles.infra.md @@ -0,0 +1 @@ +Add RuntimeDefault seccomp profiles to all managed deployments, statefulsets, and cronjobs. From 696024306c70600adfd6cd57d0ac579f4bda13d8 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Tue, 24 Mar 2026 16:43:08 -0700 Subject: [PATCH 119/430] Add Prowler image vulnerability scanning for blumeops containers Add Trivy to the Prowler container for image and IaC scanning. New CronJob (Saturday 3am) scans all blumeops/* images in the registry for CVEs, embedded secrets, and Dockerfile misconfigs. Reports written to sifaka:/volume1/reports/prowler-images/. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- .../manifests/prowler/cronjob-image-scan.yaml | 40 +++++++++++++++++++ argocd/manifests/prowler/kustomization.yaml | 1 + containers/prowler/Dockerfile | 26 ++++++++++-- .../+prowler-image-scan.feature.md | 1 + docs/how-to/operations/deploy-prowler.md | 18 +++++++++ .../operations/read-compliance-reports.md | 3 +- docs/reference/operations/security.md | 2 +- docs/reference/services/prowler.md | 11 +++-- 8 files changed, 93 insertions(+), 9 deletions(-) create mode 100644 argocd/manifests/prowler/cronjob-image-scan.yaml create mode 100644 docs/changelog.d/+prowler-image-scan.feature.md diff --git a/argocd/manifests/prowler/cronjob-image-scan.yaml b/argocd/manifests/prowler/cronjob-image-scan.yaml new file mode 100644 index 0000000..969fe49 --- /dev/null +++ b/argocd/manifests/prowler/cronjob-image-scan.yaml @@ -0,0 +1,40 @@ +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 + args: + - image + - --registry + - registry.ops.eblu.me + - --image-filter + - blumeops/ + - -z + - --output-formats + - html + - csv + - json-ocsf + - --output-directory + - /reports/prowler-images + volumeMounts: + - name: reports + mountPath: /reports + restartPolicy: OnFailure + volumes: + - name: reports + persistentVolumeClaim: + claimName: prowler-reports diff --git a/argocd/manifests/prowler/kustomization.yaml b/argocd/manifests/prowler/kustomization.yaml index 7d870ff..ca6ef56 100644 --- a/argocd/manifests/prowler/kustomization.yaml +++ b/argocd/manifests/prowler/kustomization.yaml @@ -9,6 +9,7 @@ resources: - pv-nfs.yaml - pvc.yaml - cronjob.yaml + - cronjob-image-scan.yaml images: - name: registry.ops.eblu.me/blumeops/prowler diff --git a/containers/prowler/Dockerfile b/containers/prowler/Dockerfile index cb557ca..7cafd17 100644 --- a/containers/prowler/Dockerfile +++ b/containers/prowler/Dockerfile @@ -1,5 +1,6 @@ -# Prowler CIS scanner — slim build for Kubernetes provider only -# Strips PowerShell (M365), Trivy (IaC), and dashboard dependencies from upstream +# Prowler CIS scanner — slim build for Kubernetes, image, and IaC providers +# Strips PowerShell (M365) and dashboard dependencies from upstream +# Includes Trivy for image vulnerability and IaC scanning ARG CONTAINER_APP_VERSION=5.22.0 FROM python:3.12-slim-bookworm AS build @@ -30,14 +31,31 @@ LABEL org.opencontainers.image.title="prowler" 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" -LABEL org.opencontainers.image.description="Prowler CIS scanner (Kubernetes provider)" +LABEL org.opencontainers.image.description="Prowler scanner (Kubernetes, image, IaC providers)" + +ARG TRIVY_VERSION=0.69.2 + +RUN ARCH=$(dpkg --print-architecture) \ + && case "$ARCH" in \ + amd64) TRIVY_ARCH="Linux-64bit" ;; \ + arm64) TRIVY_ARCH="Linux-ARM64" ;; \ + *) echo "Unsupported architecture: $ARCH" && exit 1 ;; \ + esac \ + && apt-get update && apt-get install -y --no-install-recommends wget ca-certificates \ + && wget -q "https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_${TRIVY_ARCH}.tar.gz" -O /tmp/trivy.tar.gz \ + && tar xzf /tmp/trivy.tar.gz -C /usr/local/bin trivy \ + && chmod +x /usr/local/bin/trivy \ + && rm /tmp/trivy.tar.gz \ + && apt-get purge -y wget && apt-get autoremove -y && rm -rf /var/lib/apt/lists/* RUN addgroup --gid 1000 prowler \ - && adduser --uid 1000 --gid 1000 --disabled-password --gecos "" prowler + && adduser --uid 1000 --gid 1000 --disabled-password --gecos "" prowler \ + && mkdir -p /tmp/.cache/trivy && chown prowler:prowler /tmp/.cache/trivy COPY --from=build /opt/prowler /opt/prowler ENV PATH="/opt/prowler/bin:${PATH}" +ENV TRIVY_CACHE_DIR="/tmp/.cache/trivy" USER prowler WORKDIR /home/prowler diff --git a/docs/changelog.d/+prowler-image-scan.feature.md b/docs/changelog.d/+prowler-image-scan.feature.md new file mode 100644 index 0000000..e109074 --- /dev/null +++ b/docs/changelog.d/+prowler-image-scan.feature.md @@ -0,0 +1 @@ +Add container image vulnerability scanning via Prowler image provider (Saturday 3am, all blumeops/* images). diff --git a/docs/how-to/operations/deploy-prowler.md b/docs/how-to/operations/deploy-prowler.md index 2b29d39..c42f65f 100644 --- a/docs/how-to/operations/deploy-prowler.md +++ b/docs/how-to/operations/deploy-prowler.md @@ -15,6 +15,8 @@ Prowler runs weekly CIS Kubernetes Benchmark scans against minikube-indri and wr ## What it checks +### Kubernetes CIS benchmarks (Sunday 3am) + Prowler's Kubernetes provider runs ~70 checks from the CIS Kubernetes Benchmark v1.11, grouped into: | Category | Checks | How it works | @@ -31,6 +33,22 @@ Prowler's Kubernetes provider runs ~70 checks from the CIS Kubernetes Benchmark **k3s note:** k3s embeds the control plane in a single binary — no static pods exist. Only core + RBAC checks (~22 of 70) produce results. Consider `kube-bench` for k3s control plane checks. +### Image vulnerability scanning (Saturday 3am) + +Prowler's image provider scans all `blumeops/*` container images in `registry.ops.eblu.me` for: + +- **CVEs** — known vulnerabilities from NVD, Alpine SecDB, Debian Security Tracker, and other sources +- **Embedded secrets** — credentials or API keys baked into image layers +- **Misconfigurations** — Dockerfile best practices (running as root, missing HEALTHCHECK, etc.) + +Uses Trivy under the hood. Reports are written to `sifaka:/volume1/reports/prowler-images/`. + +To run an ad-hoc image scan: + +```fish +kubectl create job --from=cronjob/prowler-image-scan prowler-image-manual -n prowler --context=minikube-indri +``` + ## Reports Reports are written to `sifaka:/volume1/reports/prowler/` with timestamped filenames. See [[read-compliance-reports]] for how to access and interpret them. diff --git a/docs/how-to/operations/read-compliance-reports.md b/docs/how-to/operations/read-compliance-reports.md index e2088f7..bfc0afa 100644 --- a/docs/how-to/operations/read-compliance-reports.md +++ b/docs/how-to/operations/read-compliance-reports.md @@ -18,7 +18,8 @@ Reports are stored on sifaka at `/volume1/reports/`. Each scanner writes to its | Scanner | Path | Schedule | |---------|------|----------| -| [[prowler]] | `sifaka:/volume1/reports/prowler/` | Weekly (Sunday 3am) | +| [[prowler]] K8s CIS | `sifaka:/volume1/reports/prowler/` | Weekly (Sunday 3am) | +| [[prowler]] Image | `sifaka:/volume1/reports/prowler-images/` | Weekly (Saturday 3am) | Copy reports to your local machine (remember `scp -O` for sifaka): diff --git a/docs/reference/operations/security.md b/docs/reference/operations/security.md index 8a621b1..ab9ef25 100644 --- a/docs/reference/operations/security.md +++ b/docs/reference/operations/security.md @@ -49,5 +49,5 @@ All compliance scan reports are stored on `sifaka:/volume1/reports/`. See [[read - No SOC 2 compliance mapping for Kubernetes (Prowler only maps SOC 2 for AWS/Azure/GCP) - k3s control plane checks produce no results (embedded binary, no static pods) — consider kube-bench -- No container image vulnerability scanning yet (Prowler has an `image` provider) +- Container image scanning covers `blumeops/*` images only — upstream images (ollama, immich, etc.) are not scanned - No IaC scanning of manifests/Dockerfiles yet (Prowler has an `iac` provider using Trivy) diff --git a/docs/reference/services/prowler.md b/docs/reference/services/prowler.md index f68a573..d617a7c 100644 --- a/docs/reference/services/prowler.md +++ b/docs/reference/services/prowler.md @@ -17,13 +17,18 @@ CIS Kubernetes Benchmark scanner for compliance posture reporting. |----------|-------| | **Namespace** | `prowler` | | **Image** | `registry.ops.eblu.me/blumeops/prowler` (see `argocd/manifests/prowler/kustomization.yaml` for current tag) | -| **Schedule** | Weekly (Sunday 3am) | -| **Reports** | `sifaka:/volume1/reports/prowler/` (NFS) | +| **Schedule** | K8s CIS: Sunday 3am / Image scan: Saturday 3am | +| **Reports** | `sifaka:/volume1/reports/prowler/` and `prowler-images/` (NFS) | | **Manifests** | `argocd/manifests/prowler/` | ## What it does -Runs Prowler 5 as a CronJob against minikube-indri, executing CIS Kubernetes Benchmark v1.11 checks across pod security, RBAC, apiserver, etcd, kubelet, controller-manager, and scheduler. Reports are written in HTML, CSV, and JSON-OCSF to the NFS share on sifaka. +Runs Prowler 5 as two CronJobs: + +- **K8s CIS scan** (Sunday) — CIS Kubernetes Benchmark v1.11 checks across pod security, RBAC, apiserver, etcd, kubelet, controller-manager, and scheduler +- **Image scan** (Saturday) — CVE, secret, and misconfiguration scanning of all `blumeops/*` container images in the registry via Trivy + +Reports are written in HTML, CSV, and JSON-OCSF to the NFS share on sifaka. ## See also From fe201a495c279575226d8597acc3f4af2ad62d77 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Tue, 24 Mar 2026 16:49:38 -0700 Subject: [PATCH 120/430] Add Prowler IaC scanning of blumeops repo (Saturday 2am) Clone repo in init container, scan Dockerfiles and K8s manifests with Prowler's IaC provider (Trivy). Reports written to sifaka:/volume1/reports/prowler-iac/. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- .../manifests/prowler/cronjob-iac-scan.yaml | 57 +++++++++++++++++++ argocd/manifests/prowler/kustomization.yaml | 4 ++ docs/changelog.d/+prowler-iac-scan.feature.md | 1 + docs/how-to/operations/deploy-prowler.md | 16 ++++++ .../operations/read-compliance-reports.md | 1 + docs/reference/operations/security.md | 2 +- docs/reference/services/prowler.md | 5 +- 7 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 argocd/manifests/prowler/cronjob-iac-scan.yaml create mode 100644 docs/changelog.d/+prowler-iac-scan.feature.md diff --git a/argocd/manifests/prowler/cronjob-iac-scan.yaml b/argocd/manifests/prowler/cronjob-iac-scan.yaml new file mode 100644 index 0000000..178399b --- /dev/null +++ b/argocd/manifests/prowler/cronjob-iac-scan.yaml @@ -0,0 +1,57 @@ +--- +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 + initContainers: + - name: clone-repo + image: alpine/git:kustomized + command: + - git + - clone + - --depth + - "1" + - https://forge.ops.eblu.me/eblume/blumeops.git + - /repo + volumeMounts: + - name: repo + mountPath: /repo + containers: + - name: prowler + image: registry.ops.eblu.me/blumeops/prowler:kustomized + args: + - iac + - --directory + - /repo + - -z + - --output-formats + - html + - csv + - json-ocsf + - --output-directory + - /reports/prowler-iac + volumeMounts: + - name: reports + mountPath: /reports + - name: repo + mountPath: /repo + readOnly: true + restartPolicy: OnFailure + volumes: + - name: reports + persistentVolumeClaim: + claimName: prowler-reports + - name: repo + emptyDir: {} diff --git a/argocd/manifests/prowler/kustomization.yaml b/argocd/manifests/prowler/kustomization.yaml index ca6ef56..a37e7ed 100644 --- a/argocd/manifests/prowler/kustomization.yaml +++ b/argocd/manifests/prowler/kustomization.yaml @@ -1,3 +1,4 @@ +--- apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization @@ -10,7 +11,10 @@ resources: - pvc.yaml - cronjob.yaml - cronjob-image-scan.yaml + - cronjob-iac-scan.yaml images: - name: registry.ops.eblu.me/blumeops/prowler newTag: v5.22.0-d021b35 + - name: alpine/git + newTag: v2.47.2 diff --git a/docs/changelog.d/+prowler-iac-scan.feature.md b/docs/changelog.d/+prowler-iac-scan.feature.md new file mode 100644 index 0000000..b422efa --- /dev/null +++ b/docs/changelog.d/+prowler-iac-scan.feature.md @@ -0,0 +1 @@ +Add IaC scanning via Prowler IaC provider (Saturday 2am, Dockerfiles and K8s manifests). diff --git a/docs/how-to/operations/deploy-prowler.md b/docs/how-to/operations/deploy-prowler.md index c42f65f..75dced2 100644 --- a/docs/how-to/operations/deploy-prowler.md +++ b/docs/how-to/operations/deploy-prowler.md @@ -49,6 +49,22 @@ To run an ad-hoc image scan: kubectl create job --from=cronjob/prowler-image-scan prowler-image-manual -n prowler --context=minikube-indri ``` +### IaC scanning (Saturday 2am) + +Prowler's IaC provider scans the blumeops repository (cloned at scan time) for misconfigurations in: + +- **Dockerfiles** — running as root, using `latest` tags, missing `HEALTHCHECK` +- **Kubernetes manifests** — missing resource limits, privileged containers, insecure settings +- **Other IaC files** — Terraform, CloudFormation, etc. if present + +Uses Trivy under the hood. Reports are written to `sifaka:/volume1/reports/prowler-iac/`. + +To run an ad-hoc IaC scan: + +```fish +kubectl create job --from=cronjob/prowler-iac-scan prowler-iac-manual -n prowler --context=minikube-indri +``` + ## Reports Reports are written to `sifaka:/volume1/reports/prowler/` with timestamped filenames. See [[read-compliance-reports]] for how to access and interpret them. diff --git a/docs/how-to/operations/read-compliance-reports.md b/docs/how-to/operations/read-compliance-reports.md index bfc0afa..1e1b993 100644 --- a/docs/how-to/operations/read-compliance-reports.md +++ b/docs/how-to/operations/read-compliance-reports.md @@ -20,6 +20,7 @@ Reports are stored on sifaka at `/volume1/reports/`. Each scanner writes to its |---------|------|----------| | [[prowler]] K8s CIS | `sifaka:/volume1/reports/prowler/` | Weekly (Sunday 3am) | | [[prowler]] Image | `sifaka:/volume1/reports/prowler-images/` | Weekly (Saturday 3am) | +| [[prowler]] IaC | `sifaka:/volume1/reports/prowler-iac/` | Weekly (Saturday 2am) | Copy reports to your local machine (remember `scp -O` for sifaka): diff --git a/docs/reference/operations/security.md b/docs/reference/operations/security.md index ab9ef25..d66efe1 100644 --- a/docs/reference/operations/security.md +++ b/docs/reference/operations/security.md @@ -50,4 +50,4 @@ All compliance scan reports are stored on `sifaka:/volume1/reports/`. See [[read - No SOC 2 compliance mapping for Kubernetes (Prowler only maps SOC 2 for AWS/Azure/GCP) - k3s control plane checks produce no results (embedded binary, no static pods) — consider kube-bench - Container image scanning covers `blumeops/*` images only — upstream images (ollama, immich, etc.) are not scanned -- No IaC scanning of manifests/Dockerfiles yet (Prowler has an `iac` provider using Trivy) +- IaC scanning covers the blumeops repo only — no scanning of third-party Helm charts or vendored manifests diff --git a/docs/reference/services/prowler.md b/docs/reference/services/prowler.md index d617a7c..f45955f 100644 --- a/docs/reference/services/prowler.md +++ b/docs/reference/services/prowler.md @@ -17,8 +17,8 @@ CIS Kubernetes Benchmark scanner for compliance posture reporting. |----------|-------| | **Namespace** | `prowler` | | **Image** | `registry.ops.eblu.me/blumeops/prowler` (see `argocd/manifests/prowler/kustomization.yaml` for current tag) | -| **Schedule** | K8s CIS: Sunday 3am / Image scan: Saturday 3am | -| **Reports** | `sifaka:/volume1/reports/prowler/` and `prowler-images/` (NFS) | +| **Schedule** | K8s CIS: Sunday 3am / Image: Saturday 3am / IaC: Saturday 2am | +| **Reports** | `sifaka:/volume1/reports/prowler/`, `prowler-images/`, `prowler-iac/` (NFS) | | **Manifests** | `argocd/manifests/prowler/` | ## What it does @@ -27,6 +27,7 @@ Runs Prowler 5 as two CronJobs: - **K8s CIS scan** (Sunday) — CIS Kubernetes Benchmark v1.11 checks across pod security, RBAC, apiserver, etcd, kubelet, controller-manager, and scheduler - **Image scan** (Saturday) — CVE, secret, and misconfiguration scanning of all `blumeops/*` container images in the registry via Trivy +- **IaC scan** (Saturday) — static analysis of Dockerfiles, K8s manifests, and other IaC files in the repo via Trivy Reports are written in HTML, CSV, and JSON-OCSF to the NFS share on sifaka. From 38281a35fd1512d13e742e8aeefbd08afc100cce Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Tue, 24 Mar 2026 16:54:36 -0700 Subject: [PATCH 121/430] Update prowler container tag to 6960243 (with Trivy) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- argocd/manifests/prowler/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/argocd/manifests/prowler/kustomization.yaml b/argocd/manifests/prowler/kustomization.yaml index a37e7ed..18d7d9e 100644 --- a/argocd/manifests/prowler/kustomization.yaml +++ b/argocd/manifests/prowler/kustomization.yaml @@ -15,6 +15,6 @@ resources: images: - name: registry.ops.eblu.me/blumeops/prowler - newTag: v5.22.0-d021b35 + newTag: v5.22.0-6960243 - name: alpine/git newTag: v2.47.2 From 7f2d53bc771529754d183886b5a414702d4b1426 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Tue, 24 Mar 2026 16:57:05 -0700 Subject: [PATCH 122/430] Fix prowler image scan registry URL (add https:// scheme) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- argocd/manifests/prowler/cronjob-image-scan.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/argocd/manifests/prowler/cronjob-image-scan.yaml b/argocd/manifests/prowler/cronjob-image-scan.yaml index 969fe49..89f4493 100644 --- a/argocd/manifests/prowler/cronjob-image-scan.yaml +++ b/argocd/manifests/prowler/cronjob-image-scan.yaml @@ -20,7 +20,7 @@ spec: args: - image - --registry - - registry.ops.eblu.me + - https://registry.ops.eblu.me - --image-filter - blumeops/ - -z From 7d1ae1a57e1203f3895f004df08531b8984b5abc Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Tue, 24 Mar 2026 16:58:33 -0700 Subject: [PATCH 123/430] Fix prowler image and IaC scan arguments Image scan: add https:// scheme to registry URL. IaC scan: use --scan-repository-url (Prowler clones the repo itself), removing the need for an init container. The flag is --scan-path for local dirs, --scan-repository-url for git. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- .../manifests/prowler/cronjob-iac-scan.yaml | 22 ++----------------- argocd/manifests/prowler/kustomization.yaml | 2 -- 2 files changed, 2 insertions(+), 22 deletions(-) diff --git a/argocd/manifests/prowler/cronjob-iac-scan.yaml b/argocd/manifests/prowler/cronjob-iac-scan.yaml index 178399b..c2e2fac 100644 --- a/argocd/manifests/prowler/cronjob-iac-scan.yaml +++ b/argocd/manifests/prowler/cronjob-iac-scan.yaml @@ -15,26 +15,13 @@ spec: securityContext: seccompProfile: type: RuntimeDefault - initContainers: - - name: clone-repo - image: alpine/git:kustomized - command: - - git - - clone - - --depth - - "1" - - https://forge.ops.eblu.me/eblume/blumeops.git - - /repo - volumeMounts: - - name: repo - mountPath: /repo containers: - name: prowler image: registry.ops.eblu.me/blumeops/prowler:kustomized args: - iac - - --directory - - /repo + - --scan-repository-url + - https://forge.ops.eblu.me/eblume/blumeops.git - -z - --output-formats - html @@ -45,13 +32,8 @@ spec: volumeMounts: - name: reports mountPath: /reports - - name: repo - mountPath: /repo - readOnly: true restartPolicy: OnFailure volumes: - name: reports persistentVolumeClaim: claimName: prowler-reports - - name: repo - emptyDir: {} diff --git a/argocd/manifests/prowler/kustomization.yaml b/argocd/manifests/prowler/kustomization.yaml index 18d7d9e..b34b2c1 100644 --- a/argocd/manifests/prowler/kustomization.yaml +++ b/argocd/manifests/prowler/kustomization.yaml @@ -16,5 +16,3 @@ resources: images: - name: registry.ops.eblu.me/blumeops/prowler newTag: v5.22.0-6960243 - - name: alpine/git - newTag: v2.47.2 From d90be355ddfb9e970252f4277665724e5ec73c87 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Tue, 24 Mar 2026 17:29:25 -0700 Subject: [PATCH 124/430] Work around Prowler --registry bug with init container Prowler's --registry flag doesn't work (registry args not passed to ImageProvider constructor, prowler-cloud/prowler PR #10128 regression). Use an init container to enumerate images from the zot catalog API and generate an image list file instead. See: https://github.com/eblume/prowler/tree/fix/image-provider-registry-args Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- .../manifests/prowler/cronjob-image-scan.yaml | 45 +++++++++++++++++-- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/argocd/manifests/prowler/cronjob-image-scan.yaml b/argocd/manifests/prowler/cronjob-image-scan.yaml index 89f4493..8ad85ad 100644 --- a/argocd/manifests/prowler/cronjob-image-scan.yaml +++ b/argocd/manifests/prowler/cronjob-image-scan.yaml @@ -1,3 +1,4 @@ +--- apiVersion: batch/v1 kind: CronJob metadata: @@ -14,15 +15,46 @@ spec: securityContext: seccompProfile: type: RuntimeDefault + initContainers: + # Workaround: Prowler's --registry flag is broken (registry args + # not passed to provider constructor). Generate image list from + # zot catalog API instead. + - name: enumerate-images + image: registry.ops.eblu.me/blumeops/kubectl:kustomized + command: ["/bin/bash", "-c"] + args: + - | + set -euo pipefail + REGISTRY="https://registry.ops.eblu.me" + repos=$(curl -sf "${REGISTRY}/v2/_catalog" | python3 -c " + import json, sys + for r in json.load(sys.stdin)['repositories']: + if r.startswith('blumeops/'): + print(r) + ") + > /shared/images.txt + for repo in $repos; do + tags=$(curl -sf "${REGISTRY}/v2/${repo}/tags/list" | python3 -c " + import json, sys + for t in (json.load(sys.stdin).get('tags') or []): + print(t) + ") + for tag in $tags; do + echo "registry.ops.eblu.me/${repo}:${tag}" >> /shared/images.txt + done + done + echo "Discovered $(wc -l < /shared/images.txt) images" + cat /shared/images.txt + volumeMounts: + - name: shared + mountPath: /shared containers: - name: prowler image: registry.ops.eblu.me/blumeops/prowler:kustomized args: - image - - --registry - - https://registry.ops.eblu.me - - --image-filter - - blumeops/ + - --image-list + - /shared/images.txt - -z - --output-formats - html @@ -33,8 +65,13 @@ spec: volumeMounts: - name: reports mountPath: /reports + - name: shared + mountPath: /shared + readOnly: true restartPolicy: OnFailure volumes: - name: reports persistentVolumeClaim: claimName: prowler-reports + - name: shared + emptyDir: {} From d365e79068608094da67d41f60f768af72d4ff05 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Tue, 24 Mar 2026 17:34:24 -0700 Subject: [PATCH 125/430] Add kubectl image tag to prowler kustomization The image scan init container uses the kubectl image for curl/python. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- argocd/manifests/prowler/kustomization.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/argocd/manifests/prowler/kustomization.yaml b/argocd/manifests/prowler/kustomization.yaml index b34b2c1..68d7523 100644 --- a/argocd/manifests/prowler/kustomization.yaml +++ b/argocd/manifests/prowler/kustomization.yaml @@ -16,3 +16,5 @@ resources: images: - name: registry.ops.eblu.me/blumeops/prowler newTag: v5.22.0-6960243 + - name: registry.ops.eblu.me/blumeops/kubectl + newTag: v1.34.4-613f05d From 75fd5b029d682e2f2a1337519bc6f3c5a1a210fc Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Tue, 24 Mar 2026 17:36:02 -0700 Subject: [PATCH 126/430] Use prowler image for registry enumeration init container The kubectl image lacks curl/python3. Use the prowler image (which has Python) with a pure-Python urllib script instead. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- .../manifests/prowler/cronjob-image-scan.yaml | 42 +++++++++---------- argocd/manifests/prowler/kustomization.yaml | 2 - 2 files changed, 19 insertions(+), 25 deletions(-) diff --git a/argocd/manifests/prowler/cronjob-image-scan.yaml b/argocd/manifests/prowler/cronjob-image-scan.yaml index 8ad85ad..b8dc4bf 100644 --- a/argocd/manifests/prowler/cronjob-image-scan.yaml +++ b/argocd/manifests/prowler/cronjob-image-scan.yaml @@ -20,31 +20,27 @@ spec: # not passed to provider constructor). Generate image list from # zot catalog API instead. - name: enumerate-images - image: registry.ops.eblu.me/blumeops/kubectl:kustomized - command: ["/bin/bash", "-c"] + image: registry.ops.eblu.me/blumeops/prowler:kustomized + command: ["python3", "-c"] args: - | - set -euo pipefail - REGISTRY="https://registry.ops.eblu.me" - repos=$(curl -sf "${REGISTRY}/v2/_catalog" | python3 -c " - import json, sys - for r in json.load(sys.stdin)['repositories']: - if r.startswith('blumeops/'): - print(r) - ") - > /shared/images.txt - for repo in $repos; do - tags=$(curl -sf "${REGISTRY}/v2/${repo}/tags/list" | python3 -c " - import json, sys - for t in (json.load(sys.stdin).get('tags') or []): - print(t) - ") - for tag in $tags; do - echo "registry.ops.eblu.me/${repo}:${tag}" >> /shared/images.txt - done - done - echo "Discovered $(wc -l < /shared/images.txt) images" - cat /shared/images.txt + import json, urllib.request + + REGISTRY = "https://registry.ops.eblu.me" + catalog = json.loads(urllib.request.urlopen(f"{REGISTRY}/v2/_catalog").read()) + images = [] + for repo in catalog["repositories"]: + if not repo.startswith("blumeops/"): + continue + tags = json.loads(urllib.request.urlopen(f"{REGISTRY}/v2/{repo}/tags/list").read()) + for tag in tags.get("tags") or []: + images.append(f"registry.ops.eblu.me/{repo}:{tag}") + + with open("/shared/images.txt", "w") as f: + f.write("\n".join(images) + "\n") + print(f"Discovered {len(images)} images") + for img in images: + print(img) volumeMounts: - name: shared mountPath: /shared diff --git a/argocd/manifests/prowler/kustomization.yaml b/argocd/manifests/prowler/kustomization.yaml index 68d7523..b34b2c1 100644 --- a/argocd/manifests/prowler/kustomization.yaml +++ b/argocd/manifests/prowler/kustomization.yaml @@ -16,5 +16,3 @@ resources: images: - name: registry.ops.eblu.me/blumeops/prowler newTag: v5.22.0-6960243 - - name: registry.ops.eblu.me/blumeops/kubectl - newTag: v1.34.4-613f05d From a1c2e0833d6285782dd4c188393738852a27b594 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Tue, 24 Mar 2026 19:48:43 -0700 Subject: [PATCH 127/430] Include link to upstream prowler issue --- argocd/manifests/prowler/cronjob-image-scan.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/argocd/manifests/prowler/cronjob-image-scan.yaml b/argocd/manifests/prowler/cronjob-image-scan.yaml index b8dc4bf..b69ad63 100644 --- a/argocd/manifests/prowler/cronjob-image-scan.yaml +++ b/argocd/manifests/prowler/cronjob-image-scan.yaml @@ -19,6 +19,7 @@ spec: # Workaround: Prowler's --registry flag is broken (registry args # not passed to provider constructor). Generate image list from # zot catalog API instead. + # See: https://github.com/prowler-cloud/prowler/issues/10457 - name: enumerate-images image: registry.ops.eblu.me/blumeops/prowler:kustomized command: ["python3", "-c"] From 243a8629017d3cd3fc54d13a851f97e5492e985e Mon Sep 17 00:00:00 2001 From: Forgejo Actions <actions@forge.ops.eblu.me> Date: Tue, 24 Mar 2026 19:51:17 -0700 Subject: [PATCH 128/430] Update docs release to v1.15.0 - Built changelog from towncrier fragments [skip ci] --- CHANGELOG.md | 31 +++++++++++++++++++ argocd/manifests/docs/deployment.yaml | 2 +- docs/changelog.d/+alerts-dashboard.feature.md | 1 - .../+argocd-config-doc-review.doc.md | 1 - .../+authentik-worker-concurrency.bugfix.md | 1 - .../changelog.d/+doc-review-march-2026.doc.md | 1 - .../changelog.d/+fix-apps-outofsync.bugfix.md | 1 - docs/changelog.d/+frigate-0.17.1.infra.md | 1 - docs/changelog.d/+prowler-iac-scan.feature.md | 1 - .../+prowler-image-scan.feature.md | 1 - docs/changelog.d/+seccomp-profiles.infra.md | 1 - .../changelog.d/decommission-jobsync.infra.md | 1 - docs/changelog.d/deploy-prowler.feature.md | 1 - docs/changelog.d/localize-redis.infra.md | 1 - .../unify-container-workflows.infra.md | 1 - .../update-tooling-deps-2026-03.infra.md | 1 - .../changelog.d/upgrade-ntfy-v2.19.2.infra.md | 1 - ...upgrade-tailscale-operator-1.96.3.infra.md | 1 - 18 files changed, 32 insertions(+), 17 deletions(-) delete mode 100644 docs/changelog.d/+alerts-dashboard.feature.md delete mode 100644 docs/changelog.d/+argocd-config-doc-review.doc.md delete mode 100644 docs/changelog.d/+authentik-worker-concurrency.bugfix.md delete mode 100644 docs/changelog.d/+doc-review-march-2026.doc.md delete mode 100644 docs/changelog.d/+fix-apps-outofsync.bugfix.md delete mode 100644 docs/changelog.d/+frigate-0.17.1.infra.md delete mode 100644 docs/changelog.d/+prowler-iac-scan.feature.md delete mode 100644 docs/changelog.d/+prowler-image-scan.feature.md delete mode 100644 docs/changelog.d/+seccomp-profiles.infra.md delete mode 100644 docs/changelog.d/decommission-jobsync.infra.md delete mode 100644 docs/changelog.d/deploy-prowler.feature.md delete mode 100644 docs/changelog.d/localize-redis.infra.md delete mode 100644 docs/changelog.d/unify-container-workflows.infra.md delete mode 100644 docs/changelog.d/update-tooling-deps-2026-03.infra.md delete mode 100644 docs/changelog.d/upgrade-ntfy-v2.19.2.infra.md delete mode 100644 docs/changelog.d/upgrade-tailscale-operator-1.96.3.infra.md diff --git a/CHANGELOG.md b/CHANGELOG.md index fe58f8c..ced38b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,37 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). <!-- towncrier release notes start --> +## [v1.15.0] - 2026-03-24 + +### Features + +- Deploy Prowler CIS scanner as a weekly CronJob on minikube-indri, with reports written to sifaka NFS share. +- Add Grafana "Alerts" dashboard showing currently firing alerts and recent state changes. +- Add IaC scanning via Prowler IaC provider (Saturday 2am, Dockerfiles and K8s manifests). +- Add container image vulnerability scanning via Prowler image provider (Saturday 3am, all blumeops/* images). + +### Bug Fixes + +- Fix authentik worker OOMKill by setting AUTHENTIK_WORKER_CONCURRENCY=2 (was defaulting to 16 based on CPU count). +- Remove `group: ""` from tailscale-operator ignoreDifferences — ArgoCD normalizes away the empty string, causing permanent OutOfSync on the apps app. + +### Infrastructure + +- Decommission JobSync service — removed ArgoCD app, k8s manifests, container build, Caddy proxy, Homepage entry, docs, and forge mirror. Replaced by datasette-based job tracking (coming soon). +- Localize authentik-redis container: replace upstream `redis:7-alpine` with nix-built image from nixpkgs (Redis 8.2.3). Introduces attached service pattern with `parent` field in service-versions.yaml and version assertion in default.nix to prevent silent version drift. +- Unified Dockerfile and Nix container build workflows into a single workflow that auto-classifies containers by build type and routes to the correct runner (k8s for Dockerfile, nix-container-builder for Nix). Removed nettest container (outgrown). Nix builds now require an explicit `version = "..."` declaration — no implicit nixpkgs fallback. +- Monthly tooling dependency update: bump prek hooks (trufflehog 3.94.0, ruff 0.15.7, shfmt 3.13.0), Fly.io images (nginx 1.29.6, Alloy 1.14.1), actions/checkout v4.3.1→v6.0.2, tighten mise task Python lower bounds (rich 14, typer 0.24, httpx 0.28.1, pyyaml 6.0.2), and bump ansible-lint/ansible-core floors. +- Upgrade ntfy v2.17.0 → v2.19.2 (adds experimental PostgreSQL support, read replicas, web push fixes) +- Revert Tailscale operator to v1.94.2 (v1.96.3 images not yet published); keep Fly proxy `tailscale wait` improvement +- Add RuntimeDefault seccomp profiles to all managed deployments, statefulsets, and cronjobs. +- Upgrade Frigate from 0.17.0-rc2 to 0.17.1 (security fixes, bugfixes). Add motion retention tier (365 days), reduce continuous retention from 180 to 30 days. + +### Documentation + +- Review and fix ArgoCD config tutorial: correct sync policy example, fix typo, add missing cross-references and frontmatter. +- Review and update 12 reference docs: fix stale image references to point at kustomization manifests instead of hardcoded tags, correct Prometheus scrape target, expand external-secrets stub, add cross-references between backup/disaster-recovery docs, and remove misleading `.ts.net` URLs from Quick Reference tables. + + ## [v1.14.3] - 2026-03-22 ### Features diff --git a/argocd/manifests/docs/deployment.yaml b/argocd/manifests/docs/deployment.yaml index 5b54ee6..c1203dd 100644 --- a/argocd/manifests/docs/deployment.yaml +++ b/argocd/manifests/docs/deployment.yaml @@ -30,7 +30,7 @@ spec: name: http env: - name: DOCS_RELEASE_URL - value: "https://forge.eblu.me/eblume/blumeops/releases/download/v1.14.3/docs-v1.14.3.tar.gz" + value: "https://forge.eblu.me/eblume/blumeops/releases/download/v1.15.0/docs-v1.15.0.tar.gz" resources: requests: memory: "64Mi" diff --git a/docs/changelog.d/+alerts-dashboard.feature.md b/docs/changelog.d/+alerts-dashboard.feature.md deleted file mode 100644 index d69802f..0000000 --- a/docs/changelog.d/+alerts-dashboard.feature.md +++ /dev/null @@ -1 +0,0 @@ -Add Grafana "Alerts" dashboard showing currently firing alerts and recent state changes. diff --git a/docs/changelog.d/+argocd-config-doc-review.doc.md b/docs/changelog.d/+argocd-config-doc-review.doc.md deleted file mode 100644 index 00c0283..0000000 --- a/docs/changelog.d/+argocd-config-doc-review.doc.md +++ /dev/null @@ -1 +0,0 @@ -Review and fix ArgoCD config tutorial: correct sync policy example, fix typo, add missing cross-references and frontmatter. diff --git a/docs/changelog.d/+authentik-worker-concurrency.bugfix.md b/docs/changelog.d/+authentik-worker-concurrency.bugfix.md deleted file mode 100644 index f438361..0000000 --- a/docs/changelog.d/+authentik-worker-concurrency.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -Fix authentik worker OOMKill by setting AUTHENTIK_WORKER_CONCURRENCY=2 (was defaulting to 16 based on CPU count). diff --git a/docs/changelog.d/+doc-review-march-2026.doc.md b/docs/changelog.d/+doc-review-march-2026.doc.md deleted file mode 100644 index 40cbc7f..0000000 --- a/docs/changelog.d/+doc-review-march-2026.doc.md +++ /dev/null @@ -1 +0,0 @@ -Review and update 12 reference docs: fix stale image references to point at kustomization manifests instead of hardcoded tags, correct Prometheus scrape target, expand external-secrets stub, add cross-references between backup/disaster-recovery docs, and remove misleading `.ts.net` URLs from Quick Reference tables. diff --git a/docs/changelog.d/+fix-apps-outofsync.bugfix.md b/docs/changelog.d/+fix-apps-outofsync.bugfix.md deleted file mode 100644 index 00faf4f..0000000 --- a/docs/changelog.d/+fix-apps-outofsync.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -Remove `group: ""` from tailscale-operator ignoreDifferences — ArgoCD normalizes away the empty string, causing permanent OutOfSync on the apps app. diff --git a/docs/changelog.d/+frigate-0.17.1.infra.md b/docs/changelog.d/+frigate-0.17.1.infra.md deleted file mode 100644 index 8d2025b..0000000 --- a/docs/changelog.d/+frigate-0.17.1.infra.md +++ /dev/null @@ -1 +0,0 @@ -Upgrade Frigate from 0.17.0-rc2 to 0.17.1 (security fixes, bugfixes). Add motion retention tier (365 days), reduce continuous retention from 180 to 30 days. diff --git a/docs/changelog.d/+prowler-iac-scan.feature.md b/docs/changelog.d/+prowler-iac-scan.feature.md deleted file mode 100644 index b422efa..0000000 --- a/docs/changelog.d/+prowler-iac-scan.feature.md +++ /dev/null @@ -1 +0,0 @@ -Add IaC scanning via Prowler IaC provider (Saturday 2am, Dockerfiles and K8s manifests). diff --git a/docs/changelog.d/+prowler-image-scan.feature.md b/docs/changelog.d/+prowler-image-scan.feature.md deleted file mode 100644 index e109074..0000000 --- a/docs/changelog.d/+prowler-image-scan.feature.md +++ /dev/null @@ -1 +0,0 @@ -Add container image vulnerability scanning via Prowler image provider (Saturday 3am, all blumeops/* images). diff --git a/docs/changelog.d/+seccomp-profiles.infra.md b/docs/changelog.d/+seccomp-profiles.infra.md deleted file mode 100644 index c0ee00d..0000000 --- a/docs/changelog.d/+seccomp-profiles.infra.md +++ /dev/null @@ -1 +0,0 @@ -Add RuntimeDefault seccomp profiles to all managed deployments, statefulsets, and cronjobs. diff --git a/docs/changelog.d/decommission-jobsync.infra.md b/docs/changelog.d/decommission-jobsync.infra.md deleted file mode 100644 index c0e81ee..0000000 --- a/docs/changelog.d/decommission-jobsync.infra.md +++ /dev/null @@ -1 +0,0 @@ -Decommission JobSync service — removed ArgoCD app, k8s manifests, container build, Caddy proxy, Homepage entry, docs, and forge mirror. Replaced by datasette-based job tracking (coming soon). diff --git a/docs/changelog.d/deploy-prowler.feature.md b/docs/changelog.d/deploy-prowler.feature.md deleted file mode 100644 index 64236c7..0000000 --- a/docs/changelog.d/deploy-prowler.feature.md +++ /dev/null @@ -1 +0,0 @@ -Deploy Prowler CIS scanner as a weekly CronJob on minikube-indri, with reports written to sifaka NFS share. diff --git a/docs/changelog.d/localize-redis.infra.md b/docs/changelog.d/localize-redis.infra.md deleted file mode 100644 index 2d6b382..0000000 --- a/docs/changelog.d/localize-redis.infra.md +++ /dev/null @@ -1 +0,0 @@ -Localize authentik-redis container: replace upstream `redis:7-alpine` with nix-built image from nixpkgs (Redis 8.2.3). Introduces attached service pattern with `parent` field in service-versions.yaml and version assertion in default.nix to prevent silent version drift. diff --git a/docs/changelog.d/unify-container-workflows.infra.md b/docs/changelog.d/unify-container-workflows.infra.md deleted file mode 100644 index 2225297..0000000 --- a/docs/changelog.d/unify-container-workflows.infra.md +++ /dev/null @@ -1 +0,0 @@ -Unified Dockerfile and Nix container build workflows into a single workflow that auto-classifies containers by build type and routes to the correct runner (k8s for Dockerfile, nix-container-builder for Nix). Removed nettest container (outgrown). Nix builds now require an explicit `version = "..."` declaration — no implicit nixpkgs fallback. diff --git a/docs/changelog.d/update-tooling-deps-2026-03.infra.md b/docs/changelog.d/update-tooling-deps-2026-03.infra.md deleted file mode 100644 index b0f162f..0000000 --- a/docs/changelog.d/update-tooling-deps-2026-03.infra.md +++ /dev/null @@ -1 +0,0 @@ -Monthly tooling dependency update: bump prek hooks (trufflehog 3.94.0, ruff 0.15.7, shfmt 3.13.0), Fly.io images (nginx 1.29.6, Alloy 1.14.1), actions/checkout v4.3.1→v6.0.2, tighten mise task Python lower bounds (rich 14, typer 0.24, httpx 0.28.1, pyyaml 6.0.2), and bump ansible-lint/ansible-core floors. diff --git a/docs/changelog.d/upgrade-ntfy-v2.19.2.infra.md b/docs/changelog.d/upgrade-ntfy-v2.19.2.infra.md deleted file mode 100644 index 4eccbfe..0000000 --- a/docs/changelog.d/upgrade-ntfy-v2.19.2.infra.md +++ /dev/null @@ -1 +0,0 @@ -Upgrade ntfy v2.17.0 → v2.19.2 (adds experimental PostgreSQL support, read replicas, web push fixes) diff --git a/docs/changelog.d/upgrade-tailscale-operator-1.96.3.infra.md b/docs/changelog.d/upgrade-tailscale-operator-1.96.3.infra.md deleted file mode 100644 index a0f50db..0000000 --- a/docs/changelog.d/upgrade-tailscale-operator-1.96.3.infra.md +++ /dev/null @@ -1 +0,0 @@ -Revert Tailscale operator to v1.94.2 (v1.96.3 images not yet published); keep Fly proxy `tailscale wait` improvement From b97e37543f7f5f4a6641a1a6568c76595587745a Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Tue, 24 Mar 2026 20:51:40 -0700 Subject: [PATCH 129/430] Deploy Tor Snowflake proxy on ringtail (#311) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Add Snowflake proxy as a native systemd service on ringtail (NixOS) - Uses `pkgs.snowflake` from nixpkgs (v2.11.0) - Hardened systemd unit with DynamicUser, ProtectSystem=strict, 512MB memory limit - Prometheus metrics enabled on localhost:9999 ## What is Snowflake? A Tor pluggable transport that helps censored users reach the Tor network via WebRTC. **This is NOT a Tor exit node** — traffic exits through Tor exit nodes operated by others. The proxy operator cannot see traffic content (double-encrypted) and destination servers never see the proxy's IP. ## Changes - `nixos/ringtail/configuration.nix` — new systemd service definition - `docs/reference/services/snowflake-proxy.md` — service reference card - `docs/reference/infrastructure/ringtail.md` — updated systemd services section - `service-versions.yaml` — added entry (type: nixos) ## Deploy plan After review, deploy via `mise run provision-ringtail`. Service starts automatically. ## Test plan - [ ] `mise run provision-ringtail` succeeds - [ ] `ssh ringtail 'systemctl status snowflake-proxy'` shows active - [ ] `ssh ringtail 'journalctl -u snowflake-proxy --no-pager -n 20'` shows broker connections - [ ] `ssh ringtail 'curl -s localhost:9999/metrics'` returns Prometheus metrics Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/311 --- argocd/manifests/alloy-ringtail/config.alloy | 10 + .../manifests/alloy-ringtail/daemonset.yaml | 4 + .../dashboards/configmap-snowflake-proxy.yaml | 323 ++++++++++++++++++ .../grafana-config/kustomization.yaml | 1 + .../deploy-snowflake-proxy.feature.md | 1 + docs/reference/infrastructure/ringtail.md | 9 + docs/reference/services/snowflake-proxy.md | 74 ++++ nixos/ringtail/configuration.nix | 31 ++ service-versions.yaml | 7 + 9 files changed, 460 insertions(+) create mode 100644 argocd/manifests/grafana-config/dashboards/configmap-snowflake-proxy.yaml create mode 100644 docs/changelog.d/deploy-snowflake-proxy.feature.md create mode 100644 docs/reference/services/snowflake-proxy.md diff --git a/argocd/manifests/alloy-ringtail/config.alloy b/argocd/manifests/alloy-ringtail/config.alloy index c63b478..e92ab0f 100644 --- a/argocd/manifests/alloy-ringtail/config.alloy +++ b/argocd/manifests/alloy-ringtail/config.alloy @@ -27,6 +27,16 @@ prometheus.relabel "instance" { } } +// ============== SNOWFLAKE PROXY METRICS ============== + +// Scrape Tor Snowflake proxy metrics from host (systemd service on port 9999) +prometheus.scrape "snowflake_proxy" { + targets = [{"__address__" = coalesce(sys.env("HOST_IP"), "localhost") + ":9999", "job" = "snowflake_proxy"}] + metrics_path = "/internal/metrics" + scrape_interval = "30s" + forward_to = [prometheus.relabel.instance.receiver] +} + // ============== KUBE-STATE-METRICS SCRAPE ============== prometheus.scrape "kube_state_metrics" { diff --git a/argocd/manifests/alloy-ringtail/daemonset.yaml b/argocd/manifests/alloy-ringtail/daemonset.yaml index fffd66e..cdd264d 100644 --- a/argocd/manifests/alloy-ringtail/daemonset.yaml +++ b/argocd/manifests/alloy-ringtail/daemonset.yaml @@ -33,6 +33,10 @@ spec: valueFrom: fieldRef: fieldPath: spec.nodeName + - name: HOST_IP + valueFrom: + fieldRef: + fieldPath: status.hostIP resources: requests: cpu: 50m diff --git a/argocd/manifests/grafana-config/dashboards/configmap-snowflake-proxy.yaml b/argocd/manifests/grafana-config/dashboards/configmap-snowflake-proxy.yaml new file mode 100644 index 0000000..089cae3 --- /dev/null +++ b/argocd/manifests/grafana-config/dashboards/configmap-snowflake-proxy.yaml @@ -0,0 +1,323 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: grafana-dashboard-snowflake-proxy + namespace: monitoring + labels: + grafana_dashboard: "1" +data: + snowflake-proxy.json: | + { + "annotations": { "list": [] }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "id": null, + "links": [], + "panels": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null } + ] + } + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 6, "x": 0, "y": 0 }, + "id": 1, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "textMode": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false } + }, + "title": "Total Connections", + "type": "stat", + "targets": [ + { + "expr": "sum(tor_snowflake_proxy_connections_total)", + "legendFormat": "connections", + "refId": "A" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null } + ] + }, + "unit": "bytes" + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 6, "x": 6, "y": 0 }, + "id": 2, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "textMode": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false } + }, + "title": "Total Traffic (Inbound)", + "type": "stat", + "targets": [ + { + "expr": "tor_snowflake_proxy_traffic_inbound_bytes_total", + "legendFormat": "inbound", + "refId": "A" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "blue", "value": null } + ] + }, + "unit": "bytes" + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 6, "x": 12, "y": 0 }, + "id": 3, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "textMode": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false } + }, + "title": "Total Traffic (Outbound)", + "type": "stat", + "targets": [ + { + "expr": "tor_snowflake_proxy_traffic_outbound_bytes_total", + "legendFormat": "outbound", + "refId": "A" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "orange", "value": null } + ] + } + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 6, "x": 18, "y": 0 }, + "id": 4, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "textMode": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false } + }, + "title": "Connection Timeouts", + "type": "stat", + "targets": [ + { + "expr": "tor_snowflake_proxy_connection_timeouts_total", + "legendFormat": "timeouts", + "refId": "A" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisLabel": "", + "drawStyle": "line", + "fillOpacity": 20, + "lineWidth": 2, + "pointSize": 5, + "showPoints": "never", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" } + }, + "unit": "cps" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 4 }, + "id": 5, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom" }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "Connection Rate", + "type": "timeseries", + "targets": [ + { + "expr": "rate(tor_snowflake_proxy_connections_total[5m])", + "legendFormat": "{{ country }}", + "refId": "A" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisLabel": "", + "drawStyle": "line", + "fillOpacity": 20, + "lineWidth": 2, + "pointSize": 5, + "showPoints": "never", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" } + }, + "unit": "Bps" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 4 }, + "id": 6, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom" }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "Traffic Rate", + "type": "timeseries", + "targets": [ + { + "expr": "rate(tor_snowflake_proxy_traffic_inbound_bytes_total[5m])", + "legendFormat": "inbound", + "refId": "A" + }, + { + "expr": "rate(tor_snowflake_proxy_traffic_outbound_bytes_total[5m])", + "legendFormat": "outbound", + "refId": "B" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisLabel": "", + "drawStyle": "bars", + "fillOpacity": 80, + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "spanNulls": false, + "stacking": { "group": "A", "mode": "normal" } + } + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 12 }, + "id": 7, + "options": { + "legend": { "calcs": ["sum"], "displayMode": "table", "placement": "right", "sortBy": "Total", "sortDesc": true }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "Connections by Country", + "type": "timeseries", + "targets": [ + { + "expr": "increase(tor_snowflake_proxy_connections_total[1h])", + "legendFormat": "{{ country }}", + "refId": "A" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisLabel": "", + "drawStyle": "line", + "fillOpacity": 10, + "lineWidth": 2, + "pointSize": 5, + "showPoints": "never", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" } + }, + "unit": "bytes" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 12 }, + "id": 8, + "options": { + "legend": { "calcs": ["lastNotNull"], "displayMode": "table", "placement": "bottom" }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "Process Memory", + "type": "timeseries", + "targets": [ + { + "expr": "process_resident_memory_bytes{job=\"snowflake_proxy\"}", + "legendFormat": "RSS", + "refId": "A" + }, + { + "expr": "process_virtual_memory_bytes{job=\"snowflake_proxy\"}", + "legendFormat": "Virtual", + "refId": "B" + } + ] + } + ], + "schemaVersion": 39, + "tags": ["snowflake", "tor", "anti-censorship"], + "templating": { "list": [] }, + "time": { "from": "now-24h", "to": "now" }, + "timepicker": {}, + "timezone": "browser", + "title": "Snowflake Proxy", + "uid": "snowflake-proxy", + "version": 1 + } diff --git a/argocd/manifests/grafana-config/kustomization.yaml b/argocd/manifests/grafana-config/kustomization.yaml index 6412e8b..a6e8000 100644 --- a/argocd/manifests/grafana-config/kustomization.yaml +++ b/argocd/manifests/grafana-config/kustomization.yaml @@ -27,6 +27,7 @@ resources: - dashboards/configmap-forgejo.yaml - dashboards/configmap-tempo.yaml - dashboards/configmap-alerts.yaml + - dashboards/configmap-snowflake-proxy.yaml # TeslaMate dashboards are fetched by the init-teslamate-dashboards init # container in the Grafana deployment, sourced from mirrors/teslamate on forge. # See argocd/manifests/grafana/deployment.yaml for the version pin. diff --git a/docs/changelog.d/deploy-snowflake-proxy.feature.md b/docs/changelog.d/deploy-snowflake-proxy.feature.md new file mode 100644 index 0000000..e34af2b --- /dev/null +++ b/docs/changelog.d/deploy-snowflake-proxy.feature.md @@ -0,0 +1 @@ +Add Tor Snowflake proxy on ringtail as a systemd service to support anti-censorship efforts. diff --git a/docs/reference/infrastructure/ringtail.md b/docs/reference/infrastructure/ringtail.md index 95d6ee2..d5bbd91 100644 --- a/docs/reference/infrastructure/ringtail.md +++ b/docs/reference/infrastructure/ringtail.md @@ -86,6 +86,15 @@ argocd cluster add default --name k3s-ringtail ## Systemd Services +### Snowflake Proxy + +A Tor [[snowflake-proxy]] that helps censored users reach the Tor network. Runs as a simple systemd service using the `snowflake` nixpkgs package. The proxy is not a Tor exit node — it only bridges encrypted WebRTC connections to Tor relays. + +| Property | Value | +|----------|-------| +| **Service unit** | `snowflake-proxy.service` | +| **Metrics** | `localhost:9999/metrics` (Prometheus) | + ### Forgejo Actions Runner A native Forgejo Actions runner (`ringtail-nix-builder`) runs as a systemd service via the NixOS `services.gitea-actions-runner` module. It builds containers using `nix-build` and pushes them to Zot via `skopeo`. diff --git a/docs/reference/services/snowflake-proxy.md b/docs/reference/services/snowflake-proxy.md new file mode 100644 index 0000000..2322c5f --- /dev/null +++ b/docs/reference/services/snowflake-proxy.md @@ -0,0 +1,74 @@ +--- +title: Snowflake Proxy +modified: 2026-03-24 +tags: + - service + - privacy + - anti-censorship +--- + +# Snowflake Proxy + +Tor Snowflake proxy that helps censored users reach the Tor network. Runs as a native systemd service on [[ringtail]]. + +## Quick Reference + +| Property | Value | +|----------|-------| +| **Host** | ringtail | +| **Type** | NixOS systemd service | +| **Package** | `pkgs.snowflake` (nixpkgs) | +| **Binary** | `proxy` | +| **Upstream** | https://snowflake.torproject.org/ | +| **Source** | https://gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake | +| **Metrics** | `localhost:9999/metrics` (Prometheus) | + +## Architecture + +Snowflake is a pluggable transport for Tor that uses WebRTC to provide short-lived proxies. The proxy: + +1. Polls the Tor broker for censored clients needing a bridge +2. Establishes a WebRTC connection with the client +3. Forwards the encrypted traffic to a Tor bridge (relay) + +**This proxy is NOT a Tor exit node.** Traffic exits through Tor exit nodes operated by others. The proxy operator cannot see traffic content (double-encrypted: WebRTC DTLS + Tor onion routing) and destination servers never see the proxy's IP. + +``` +Censored user ──[WebRTC/DTLS]──▶ THIS PROXY ──[encrypted]──▶ Tor bridge ──▶ Tor network ──▶ Exit node +``` + +## Configuration + +The service runs with default settings — no special configuration needed. Key defaults: + +| Setting | Value | +|---------|-------| +| **Broker** | `https://snowflake-broker.torproject.net/` | +| **Relay** | `wss://snowflake.torproject.net/` | +| **STUN** | Google + BlackBerry STUN servers | +| **Capacity** | Unlimited concurrent clients | +| **Summary interval** | 1 hour | +| **Metrics port** | 9999 (Prometheus format) | + +## Resource Usage + +Based on community reports, a Snowflake proxy typically uses: + +- **Bandwidth:** ~5-10 GB/day (varies with client demand) +- **Memory:** Under 100 MB +- **CPU:** Negligible + +## Legal Considerations + +Running a Snowflake proxy carries very low legal risk in the US: + +- Traffic does not exit from the proxy's IP (exit nodes are elsewhere) +- Content is not visible to the proxy operator (end-to-end encrypted) +- No known legal cases against Snowflake proxy operators worldwide +- EFF and Tor Project both classify this as minimal-risk activity +- US intermediary protections (Section 230, ECPA) apply + +## Related + +- [[ringtail]] - Host machine +- [[architecture]] - Overall system design diff --git a/nixos/ringtail/configuration.nix b/nixos/ringtail/configuration.nix index db682f6..7d948a2 100644 --- a/nixos/ringtail/configuration.nix +++ b/nixos/ringtail/configuration.nix @@ -492,6 +492,37 @@ in unqualified-search-registries = ["registry.ops.eblu.me", "docker.io", "ghcr.io", "quay.io"] ''; + # Tor Snowflake proxy (anti-censorship bridge, not an exit node) + systemd.services.snowflake-proxy = { + description = "Tor Snowflake Proxy"; + after = [ "network-online.target" ]; + wants = [ "network-online.target" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + ExecStart = toString [ + "${pkgs.snowflake}/bin/proxy" + "-metrics" "-metrics-address" "0.0.0.0" + "-geoipdb" "${pkgs.tor.geoip}/share/tor/geoip" + "-geoip6db" "${pkgs.tor.geoip}/share/tor/geoip6" + ]; + DynamicUser = true; + Restart = "always"; + RestartSec = 10; + # Hardening + NoNewPrivileges = true; + ProtectSystem = "strict"; + ProtectHome = true; + PrivateTmp = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectControlGroups = true; + RestrictNamespaces = true; + RestrictRealtime = true; + MemoryDenyWriteExecute = true; + MemoryMax = "512M"; + }; + }; + # Forgejo Actions runner (nix container builder) services.gitea-actions-runner = { package = pkgs.forgejo-runner; diff --git a/service-versions.yaml b/service-versions.yaml index 321efe8..26c1d08 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -257,6 +257,13 @@ services: upstream-source: https://code.forgejo.org/forgejo/runner/releases notes: Forgejo runner on ringtail via nixpkgs; version tracks flake.lock + - name: snowflake-proxy + type: nixos + last-reviewed: 2026-03-24 + current-version: "2.11.0" + upstream-source: https://gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake/-/releases + notes: Tor Snowflake proxy on ringtail; anti-censorship bridge, not an exit node + - name: mealie type: argocd last-reviewed: 2026-03-16 From 796baaa41a33afa20bd33aacd367e78e341c34c9 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Wed, 25 Mar 2026 15:56:41 -0700 Subject: [PATCH 130/430] Upgrade External Secrets Operator v2.2.0 + migrate Helm to kustomize (#312) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Upgrade External Secrets Operator from v1.3.2 (helm-chart-2.0.0) to v2.2.0 - Migrate from Helm chart deployment to static kustomize manifests, matching the repo's kustomize-first pattern - Merge separate `-config` ArgoCD apps into the main operator apps (6 → 4 apps) - Clean up Helm-specific labels (`helm.sh/chart`, `managed-by: Helm`) - Update README example from v1beta1 to v1 API ## Breaking changes assessment Low risk — v2.0.0 removed Alibaba and Device42 providers (we use neither). No templating changes affect us. All ExternalSecrets already use v1 API. ## Deployment steps 1. Sync CRDs first on both clusters (new CRD version) 2. Sync operator apps (now kustomize-based) 3. Verify ClusterSecretStore and all ExternalSecrets are healthy 4. Delete orphaned config apps: `argocd app delete external-secrets-config` and `-config-ringtail` 5. `mise run services-check` Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/312 --- .../external-secrets-config-ringtail.yaml | 24 - argocd/apps/external-secrets-config.yaml | 26 - .../apps/external-secrets-crds-ringtail.yaml | 2 +- argocd/apps/external-secrets-crds.yaml | 2 +- argocd/apps/external-secrets-ringtail.yaml | 17 +- argocd/apps/external-secrets.yaml | 19 +- argocd/manifests/external-secrets/README.md | 2 +- .../external-secrets/deployment.yaml | 218 +++++++++ .../external-secrets/kustomization.yaml | 10 + argocd/manifests/external-secrets/rbac.yaml | 445 ++++++++++++++++++ .../manifests/external-secrets/service.yaml | 22 + .../external-secrets/serviceaccount.yaml | 33 ++ argocd/manifests/external-secrets/values.yaml | 31 -- .../manifests/external-secrets/webhook.yaml | 83 ++++ .../upgrade-external-secrets-v2.infra.md | 1 + service-versions.yaml | 6 +- 16 files changed, 830 insertions(+), 111 deletions(-) delete mode 100644 argocd/apps/external-secrets-config-ringtail.yaml delete mode 100644 argocd/apps/external-secrets-config.yaml create mode 100644 argocd/manifests/external-secrets/deployment.yaml create mode 100644 argocd/manifests/external-secrets/rbac.yaml create mode 100644 argocd/manifests/external-secrets/service.yaml create mode 100644 argocd/manifests/external-secrets/serviceaccount.yaml delete mode 100644 argocd/manifests/external-secrets/values.yaml create mode 100644 argocd/manifests/external-secrets/webhook.yaml create mode 100644 docs/changelog.d/upgrade-external-secrets-v2.infra.md diff --git a/argocd/apps/external-secrets-config-ringtail.yaml b/argocd/apps/external-secrets-config-ringtail.yaml deleted file mode 100644 index d3f9e58..0000000 --- a/argocd/apps/external-secrets-config-ringtail.yaml +++ /dev/null @@ -1,24 +0,0 @@ -# External Secrets Configuration for ringtail k3s cluster -# Same ClusterSecretStore manifests as indri, different destination -# -# Prerequisites: -# - 1password-connect-ringtail is deployed and healthy -# - external-secrets-ringtail operator is deployed and CRDs are installed -# -apiVersion: argoproj.io/v1alpha1 -kind: Application -metadata: - name: external-secrets-config-ringtail - namespace: argocd -spec: - project: default - source: - repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git - targetRevision: main - path: argocd/manifests/external-secrets - destination: - server: https://ringtail.tail8d86e.ts.net:6443 - namespace: external-secrets - syncPolicy: - syncOptions: - - CreateNamespace=true diff --git a/argocd/apps/external-secrets-config.yaml b/argocd/apps/external-secrets-config.yaml deleted file mode 100644 index e741d22..0000000 --- a/argocd/apps/external-secrets-config.yaml +++ /dev/null @@ -1,26 +0,0 @@ -# External Secrets Configuration - ClusterSecretStore for 1Password -# -# Deploys the ClusterSecretStore that connects ESO to 1Password Connect. -# Must be synced AFTER external-secrets operator is running. -# -# Prerequisites: -# - 1password-connect is deployed and healthy -# - external-secrets operator is deployed and CRDs are installed -# -apiVersion: argoproj.io/v1alpha1 -kind: Application -metadata: - name: external-secrets-config - namespace: argocd -spec: - project: default - source: - repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git - targetRevision: main - path: argocd/manifests/external-secrets - destination: - server: https://kubernetes.default.svc - namespace: external-secrets - syncPolicy: - syncOptions: - - CreateNamespace=true diff --git a/argocd/apps/external-secrets-crds-ringtail.yaml b/argocd/apps/external-secrets-crds-ringtail.yaml index 8fbc304..00d7fec 100644 --- a/argocd/apps/external-secrets-crds-ringtail.yaml +++ b/argocd/apps/external-secrets-crds-ringtail.yaml @@ -12,7 +12,7 @@ spec: project: default source: repoURL: ssh://forgejo@forge.ops.eblu.me:2222/mirrors/external-secrets.git - targetRevision: helm-chart-2.0.0 + targetRevision: helm-chart-2.2.0 path: config/crds/bases directory: exclude: 'kustomization.yaml' diff --git a/argocd/apps/external-secrets-crds.yaml b/argocd/apps/external-secrets-crds.yaml index 2b2178d..d822960 100644 --- a/argocd/apps/external-secrets-crds.yaml +++ b/argocd/apps/external-secrets-crds.yaml @@ -16,7 +16,7 @@ spec: project: default source: repoURL: ssh://forgejo@forge.ops.eblu.me:2222/mirrors/external-secrets.git - targetRevision: helm-chart-2.0.0 + targetRevision: helm-chart-2.2.0 path: config/crds/bases directory: exclude: 'kustomization.yaml' diff --git a/argocd/apps/external-secrets-ringtail.yaml b/argocd/apps/external-secrets-ringtail.yaml index c7cacec..e2f5898 100644 --- a/argocd/apps/external-secrets-ringtail.yaml +++ b/argocd/apps/external-secrets-ringtail.yaml @@ -1,5 +1,5 @@ # External Secrets Operator for ringtail k3s cluster -# Same chart/values as indri, different destination +# Same manifests as indri, different destination # # Prerequisites: # - 1password-connect-ringtail must be deployed and healthy @@ -12,17 +12,10 @@ metadata: namespace: argocd spec: project: default - sources: - - repoURL: ssh://forgejo@forge.ops.eblu.me:2222/mirrors/external-secrets.git - targetRevision: helm-chart-2.0.0 - path: deploy/charts/external-secrets - helm: - releaseName: external-secrets - valueFiles: - - $values/argocd/manifests/external-secrets/values.yaml - - repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git - targetRevision: main - ref: values + source: + repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git + targetRevision: main + path: argocd/manifests/external-secrets destination: server: https://ringtail.tail8d86e.ts.net:6443 namespace: external-secrets diff --git a/argocd/apps/external-secrets.yaml b/argocd/apps/external-secrets.yaml index 369bef5..85ac21d 100644 --- a/argocd/apps/external-secrets.yaml +++ b/argocd/apps/external-secrets.yaml @@ -1,10 +1,12 @@ # External Secrets Operator - Kubernetes secret sync from external providers # Syncs secrets from 1Password Connect to native Kubernetes Secrets # -# Chart mirrored from https://github.com/external-secrets/external-secrets +# Static manifests rendered from upstream Helm chart v2.2.0 +# Upstream: https://github.com/external-secrets/external-secrets # # Prerequisites: # - 1password-connect must be deployed and healthy +# - external-secrets-crds must be synced first # apiVersion: argoproj.io/v1alpha1 kind: Application @@ -13,17 +15,10 @@ metadata: namespace: argocd spec: project: default - sources: - - repoURL: ssh://forgejo@forge.ops.eblu.me:2222/mirrors/external-secrets.git - targetRevision: helm-chart-2.0.0 - path: deploy/charts/external-secrets - helm: - releaseName: external-secrets - valueFiles: - - $values/argocd/manifests/external-secrets/values.yaml - - repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git - targetRevision: main - ref: values + source: + repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git + targetRevision: main + path: argocd/manifests/external-secrets destination: server: https://kubernetes.default.svc namespace: external-secrets diff --git a/argocd/manifests/external-secrets/README.md b/argocd/manifests/external-secrets/README.md index 71d9e90..abf1c14 100644 --- a/argocd/manifests/external-secrets/README.md +++ b/argocd/manifests/external-secrets/README.md @@ -35,7 +35,7 @@ kubectl --context=minikube-indri get externalsecret -A To sync a secret from 1Password, create an ExternalSecret in the target namespace: ```yaml -apiVersion: external-secrets.io/v1beta1 +apiVersion: external-secrets.io/v1 kind: ExternalSecret metadata: name: my-secret diff --git a/argocd/manifests/external-secrets/deployment.yaml b/argocd/manifests/external-secrets/deployment.yaml new file mode 100644 index 0000000..993d8df --- /dev/null +++ b/argocd/manifests/external-secrets/deployment.yaml @@ -0,0 +1,218 @@ +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: external-secrets-cert-controller + namespace: external-secrets + labels: + app.kubernetes.io/name: external-secrets-cert-controller + app.kubernetes.io/instance: external-secrets + app.kubernetes.io/version: "v2.2.0" + app.kubernetes.io/managed-by: kustomize +spec: + replicas: 1 + revisionHistoryLimit: 10 + selector: + matchLabels: + app.kubernetes.io/name: external-secrets-cert-controller + app.kubernetes.io/instance: external-secrets + template: + metadata: + labels: + app.kubernetes.io/name: external-secrets-cert-controller + app.kubernetes.io/instance: external-secrets + app.kubernetes.io/version: "v2.2.0" + app.kubernetes.io/managed-by: kustomize + spec: + serviceAccountName: external-secrets-cert-controller + automountServiceAccountToken: true + hostNetwork: false + containers: + - name: cert-controller + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + runAsNonRoot: true + runAsUser: 1000 + seccompProfile: + type: RuntimeDefault + image: ghcr.io/external-secrets/external-secrets:kustomized + imagePullPolicy: IfNotPresent + args: + - certcontroller + - --crd-requeue-interval=5m + - --service-name=external-secrets-webhook + - --service-namespace=external-secrets + - --secret-name=external-secrets-webhook + - --secret-namespace=external-secrets + - --metrics-addr=:8080 + - --healthz-addr=:8081 + - --loglevel=info + - --zap-time-encoding=epoch + ports: + - containerPort: 8080 + protocol: TCP + name: metrics + - containerPort: 8081 + protocol: TCP + name: ready + readinessProbe: + httpGet: + port: ready + path: /readyz + initialDelaySeconds: 20 + periodSeconds: 5 + resources: + limits: + cpu: 100m + memory: 128Mi + requests: + cpu: 25m + memory: 32Mi +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: external-secrets + namespace: external-secrets + labels: + app.kubernetes.io/name: external-secrets + app.kubernetes.io/instance: external-secrets + app.kubernetes.io/version: "v2.2.0" + app.kubernetes.io/managed-by: kustomize +spec: + replicas: 1 + revisionHistoryLimit: 10 + selector: + matchLabels: + app.kubernetes.io/name: external-secrets + app.kubernetes.io/instance: external-secrets + template: + metadata: + labels: + app.kubernetes.io/name: external-secrets + app.kubernetes.io/instance: external-secrets + app.kubernetes.io/version: "v2.2.0" + app.kubernetes.io/managed-by: kustomize + spec: + serviceAccountName: external-secrets + automountServiceAccountToken: true + hostNetwork: false + containers: + - name: external-secrets + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + runAsNonRoot: true + runAsUser: 1000 + seccompProfile: + type: RuntimeDefault + image: ghcr.io/external-secrets/external-secrets:kustomized + imagePullPolicy: IfNotPresent + args: + - --concurrent=1 + - --metrics-addr=:8080 + - --loglevel=info + - --zap-time-encoding=epoch + ports: + - containerPort: 8080 + protocol: TCP + name: metrics + resources: + limits: + cpu: 200m + memory: 256Mi + requests: + cpu: 50m + memory: 64Mi + dnsPolicy: ClusterFirst +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: external-secrets-webhook + namespace: external-secrets + labels: + app.kubernetes.io/name: external-secrets-webhook + app.kubernetes.io/instance: external-secrets + app.kubernetes.io/version: "v2.2.0" + app.kubernetes.io/managed-by: kustomize +spec: + replicas: 1 + revisionHistoryLimit: 10 + selector: + matchLabels: + app.kubernetes.io/name: external-secrets-webhook + app.kubernetes.io/instance: external-secrets + template: + metadata: + labels: + app.kubernetes.io/name: external-secrets-webhook + app.kubernetes.io/instance: external-secrets + app.kubernetes.io/version: "v2.2.0" + app.kubernetes.io/managed-by: kustomize + spec: + hostNetwork: false + serviceAccountName: external-secrets-webhook + automountServiceAccountToken: true + containers: + - name: webhook + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + runAsNonRoot: true + runAsUser: 1000 + seccompProfile: + type: RuntimeDefault + image: ghcr.io/external-secrets/external-secrets:kustomized + imagePullPolicy: IfNotPresent + args: + - webhook + - --port=10250 + - --dns-name=external-secrets-webhook.external-secrets.svc + - --cert-dir=/tmp/certs + - --check-interval=5m + - --metrics-addr=:8080 + - --healthz-addr=:8081 + - --loglevel=info + - --zap-time-encoding=epoch + ports: + - containerPort: 8080 + protocol: TCP + name: metrics + - containerPort: 10250 + protocol: TCP + name: webhook + - containerPort: 8081 + protocol: TCP + name: ready + readinessProbe: + httpGet: + port: ready + path: /readyz + initialDelaySeconds: 20 + periodSeconds: 5 + resources: + limits: + cpu: 100m + memory: 128Mi + requests: + cpu: 25m + memory: 32Mi + volumeMounts: + - name: certs + mountPath: /tmp/certs + readOnly: true + volumes: + - name: certs + secret: + secretName: external-secrets-webhook diff --git a/argocd/manifests/external-secrets/kustomization.yaml b/argocd/manifests/external-secrets/kustomization.yaml index bf834d1..574aaa7 100644 --- a/argocd/manifests/external-secrets/kustomization.yaml +++ b/argocd/manifests/external-secrets/kustomization.yaml @@ -1,5 +1,15 @@ +--- apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization resources: + - serviceaccount.yaml + - rbac.yaml + - service.yaml + - webhook.yaml + - deployment.yaml - cluster-secret-store.yaml + +images: + - name: ghcr.io/external-secrets/external-secrets + newTag: v2.2.0 diff --git a/argocd/manifests/external-secrets/rbac.yaml b/argocd/manifests/external-secrets/rbac.yaml new file mode 100644 index 0000000..0e68cd2 --- /dev/null +++ b/argocd/manifests/external-secrets/rbac.yaml @@ -0,0 +1,445 @@ +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: external-secrets-cert-controller + labels: + app.kubernetes.io/name: external-secrets-cert-controller + app.kubernetes.io/instance: external-secrets + app.kubernetes.io/version: "v2.2.0" + app.kubernetes.io/managed-by: kustomize +rules: + - apiGroups: + - "apiextensions.k8s.io" + resources: + - "customresourcedefinitions" + verbs: + - "get" + - "list" + - "watch" + - "update" + - "patch" + - apiGroups: + - "admissionregistration.k8s.io" + resources: + - "validatingwebhookconfigurations" + verbs: + - "list" + - "watch" + - "get" + - apiGroups: + - "admissionregistration.k8s.io" + resources: + - "validatingwebhookconfigurations" + resourceNames: + - "secretstore-validate" + - "externalsecret-validate" + verbs: + - "update" + - "patch" + - apiGroups: + - "" + resources: + - "endpoints" + verbs: + - "list" + - "get" + - "watch" + - apiGroups: + - "discovery.k8s.io" + resources: + - "endpointslices" + verbs: + - "list" + - "get" + - "watch" + - apiGroups: + - "" + resources: + - "events" + verbs: + - "create" + - "patch" + - apiGroups: + - "" + resources: + - "secrets" + verbs: + - "get" + - "list" + - "watch" + - "update" + - "patch" + - apiGroups: + - "coordination.k8s.io" + resources: + - "leases" + verbs: + - "get" + - "create" + - "update" + - "patch" +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: external-secrets-controller + labels: + app.kubernetes.io/name: external-secrets + app.kubernetes.io/instance: external-secrets + app.kubernetes.io/version: "v2.2.0" + app.kubernetes.io/managed-by: kustomize +rules: + - apiGroups: + - "external-secrets.io" + resources: + - "secretstores" + - "clustersecretstores" + - "externalsecrets" + - "clusterexternalsecrets" + - "pushsecrets" + - "clusterpushsecrets" + verbs: + - "get" + - "list" + - "watch" + - apiGroups: + - "external-secrets.io" + resources: + - "externalsecrets" + - "externalsecrets/status" + - "externalsecrets/finalizers" + - "secretstores" + - "secretstores/status" + - "secretstores/finalizers" + - "clustersecretstores" + - "clustersecretstores/status" + - "clustersecretstores/finalizers" + - "clusterexternalsecrets" + - "clusterexternalsecrets/status" + - "clusterexternalsecrets/finalizers" + - "pushsecrets" + - "pushsecrets/status" + - "pushsecrets/finalizers" + - "clusterpushsecrets" + - "clusterpushsecrets/status" + - "clusterpushsecrets/finalizers" + verbs: + - "get" + - "update" + - "patch" + - apiGroups: + - "generators.external-secrets.io" + resources: + - "generatorstates" + verbs: + - "get" + - "list" + - "watch" + - "create" + - "update" + - "patch" + - "delete" + - "deletecollection" + - apiGroups: + - "generators.external-secrets.io" + resources: + - "acraccesstokens" + - "cloudsmithaccesstokens" + - "clustergenerators" + - "ecrauthorizationtokens" + - "fakes" + - "gcraccesstokens" + - "githubaccesstokens" + - "quayaccesstokens" + - "passwords" + - "sshkeys" + - "stssessiontokens" + - "uuids" + - "vaultdynamicsecrets" + - "webhooks" + - "grafanas" + - "mfas" + verbs: + - "get" + - "list" + - "watch" + - apiGroups: + - "" + resources: + - "serviceaccounts" + - "namespaces" + verbs: + - "get" + - "list" + - "watch" + - apiGroups: + - "" + resources: + - "namespaces" + verbs: + - "update" + - "patch" + - apiGroups: + - "" + resources: + - "configmaps" + verbs: + - "get" + - "list" + - "watch" + - apiGroups: + - "" + resources: + - "secrets" + verbs: + - "get" + - "list" + - "watch" + - "create" + - "update" + - "delete" + - "patch" + - apiGroups: + - "" + resources: + - "serviceaccounts/token" + verbs: + - "create" + - apiGroups: + - "" + resources: + - "events" + verbs: + - "create" + - "patch" + - apiGroups: + - "external-secrets.io" + resources: + - "externalsecrets" + verbs: + - "create" + - "update" + - "delete" + - apiGroups: + - "external-secrets.io" + resources: + - "pushsecrets" + verbs: + - "create" + - "update" + - "delete" +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: external-secrets-view + labels: + app.kubernetes.io/name: external-secrets + app.kubernetes.io/instance: external-secrets + app.kubernetes.io/version: "v2.2.0" + app.kubernetes.io/managed-by: kustomize + rbac.authorization.k8s.io/aggregate-to-view: "true" + rbac.authorization.k8s.io/aggregate-to-edit: "true" + rbac.authorization.k8s.io/aggregate-to-admin: "true" +rules: + - apiGroups: + - "external-secrets.io" + resources: + - "externalsecrets" + - "secretstores" + - "clustersecretstores" + - "pushsecrets" + - "clusterpushsecrets" + verbs: + - "get" + - "watch" + - "list" + - apiGroups: + - "generators.external-secrets.io" + resources: + - "acraccesstokens" + - "cloudsmithaccesstokens" + - "clustergenerators" + - "ecrauthorizationtokens" + - "fakes" + - "gcraccesstokens" + - "githubaccesstokens" + - "quayaccesstokens" + - "passwords" + - "sshkeys" + - "vaultdynamicsecrets" + - "webhooks" + - "grafanas" + - "generatorstates" + - "mfas" + - "uuids" + verbs: + - "get" + - "watch" + - "list" +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: external-secrets-edit + labels: + app.kubernetes.io/name: external-secrets + app.kubernetes.io/instance: external-secrets + app.kubernetes.io/version: "v2.2.0" + app.kubernetes.io/managed-by: kustomize + rbac.authorization.k8s.io/aggregate-to-edit: "true" + rbac.authorization.k8s.io/aggregate-to-admin: "true" +rules: + - apiGroups: + - "external-secrets.io" + resources: + - "externalsecrets" + - "secretstores" + - "clustersecretstores" + - "pushsecrets" + - "clusterpushsecrets" + verbs: + - "create" + - "delete" + - "deletecollection" + - "patch" + - "update" + - apiGroups: + - "generators.external-secrets.io" + resources: + - "acraccesstokens" + - "cloudsmithaccesstokens" + - "clustergenerators" + - "ecrauthorizationtokens" + - "fakes" + - "gcraccesstokens" + - "githubaccesstokens" + - "quayaccesstokens" + - "passwords" + - "sshkeys" + - "vaultdynamicsecrets" + - "webhooks" + - "grafanas" + - "generatorstates" + - "mfas" + - "uuids" + verbs: + - "create" + - "delete" + - "deletecollection" + - "patch" + - "update" +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: external-secrets-servicebindings + labels: + servicebinding.io/controller: "true" + app.kubernetes.io/name: external-secrets + app.kubernetes.io/instance: external-secrets + app.kubernetes.io/version: "v2.2.0" + app.kubernetes.io/managed-by: kustomize +rules: + - apiGroups: + - "external-secrets.io" + resources: + - "externalsecrets" + - "pushsecrets" + verbs: + - "get" + - "list" + - "watch" +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: external-secrets-cert-controller + labels: + app.kubernetes.io/name: external-secrets-cert-controller + app.kubernetes.io/instance: external-secrets + app.kubernetes.io/version: "v2.2.0" + app.kubernetes.io/managed-by: kustomize +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: external-secrets-cert-controller +subjects: + - name: external-secrets-cert-controller + namespace: external-secrets + kind: ServiceAccount +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: external-secrets-controller + labels: + app.kubernetes.io/name: external-secrets + app.kubernetes.io/instance: external-secrets + app.kubernetes.io/version: "v2.2.0" + app.kubernetes.io/managed-by: kustomize +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: external-secrets-controller +subjects: + - name: external-secrets + namespace: external-secrets + kind: ServiceAccount +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: external-secrets-leaderelection + namespace: external-secrets + labels: + app.kubernetes.io/name: external-secrets + app.kubernetes.io/instance: external-secrets + app.kubernetes.io/version: "v2.2.0" + app.kubernetes.io/managed-by: kustomize +rules: + - apiGroups: + - "" + resources: + - "configmaps" + resourceNames: + - "external-secrets-controller" + verbs: + - "get" + - "update" + - "patch" + - apiGroups: + - "" + resources: + - "configmaps" + verbs: + - "create" + - apiGroups: + - "coordination.k8s.io" + resources: + - "leases" + verbs: + - "get" + - "create" + - "update" + - "patch" +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: external-secrets-leaderelection + namespace: external-secrets + labels: + app.kubernetes.io/name: external-secrets + app.kubernetes.io/instance: external-secrets + app.kubernetes.io/version: "v2.2.0" + app.kubernetes.io/managed-by: kustomize +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: external-secrets-leaderelection +subjects: + - kind: ServiceAccount + name: external-secrets + namespace: external-secrets diff --git a/argocd/manifests/external-secrets/service.yaml b/argocd/manifests/external-secrets/service.yaml new file mode 100644 index 0000000..3b019d7 --- /dev/null +++ b/argocd/manifests/external-secrets/service.yaml @@ -0,0 +1,22 @@ +--- +apiVersion: v1 +kind: Service +metadata: + name: external-secrets-webhook + namespace: external-secrets + labels: + app.kubernetes.io/name: external-secrets-webhook + app.kubernetes.io/instance: external-secrets + app.kubernetes.io/version: "v2.2.0" + app.kubernetes.io/managed-by: kustomize + external-secrets.io/component: webhook +spec: + type: ClusterIP + ports: + - port: 443 + targetPort: webhook + protocol: TCP + name: webhook + selector: + app.kubernetes.io/name: external-secrets-webhook + app.kubernetes.io/instance: external-secrets diff --git a/argocd/manifests/external-secrets/serviceaccount.yaml b/argocd/manifests/external-secrets/serviceaccount.yaml new file mode 100644 index 0000000..6bd412d --- /dev/null +++ b/argocd/manifests/external-secrets/serviceaccount.yaml @@ -0,0 +1,33 @@ +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: external-secrets-cert-controller + namespace: external-secrets + labels: + app.kubernetes.io/name: external-secrets-cert-controller + app.kubernetes.io/instance: external-secrets + app.kubernetes.io/version: "v2.2.0" + app.kubernetes.io/managed-by: kustomize +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: external-secrets + namespace: external-secrets + labels: + app.kubernetes.io/name: external-secrets + app.kubernetes.io/instance: external-secrets + app.kubernetes.io/version: "v2.2.0" + app.kubernetes.io/managed-by: kustomize +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: external-secrets-webhook + namespace: external-secrets + labels: + app.kubernetes.io/name: external-secrets-webhook + app.kubernetes.io/instance: external-secrets + app.kubernetes.io/version: "v2.2.0" + app.kubernetes.io/managed-by: kustomize diff --git a/argocd/manifests/external-secrets/values.yaml b/argocd/manifests/external-secrets/values.yaml deleted file mode 100644 index c5bffbc..0000000 --- a/argocd/manifests/external-secrets/values.yaml +++ /dev/null @@ -1,31 +0,0 @@ -# External Secrets Operator Helm values for blumeops -# Chart: https://github.com/external-secrets/external-secrets - -installCRDs: true - -# Resource limits for minikube -resources: - requests: - memory: "64Mi" - cpu: "50m" - limits: - memory: "256Mi" - cpu: "200m" - -webhook: - resources: - requests: - memory: "32Mi" - cpu: "25m" - limits: - memory: "128Mi" - cpu: "100m" - -certController: - resources: - requests: - memory: "32Mi" - cpu: "25m" - limits: - memory: "128Mi" - cpu: "100m" diff --git a/argocd/manifests/external-secrets/webhook.yaml b/argocd/manifests/external-secrets/webhook.yaml new file mode 100644 index 0000000..d53fa60 --- /dev/null +++ b/argocd/manifests/external-secrets/webhook.yaml @@ -0,0 +1,83 @@ +--- +apiVersion: v1 +kind: Secret +metadata: + name: external-secrets-webhook + namespace: external-secrets + labels: + app.kubernetes.io/name: external-secrets-webhook + app.kubernetes.io/instance: external-secrets + app.kubernetes.io/version: "v2.2.0" + app.kubernetes.io/managed-by: kustomize + external-secrets.io/component: webhook +--- +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingWebhookConfiguration +metadata: + name: secretstore-validate + labels: + app.kubernetes.io/name: external-secrets-webhook + app.kubernetes.io/instance: external-secrets + app.kubernetes.io/version: "v2.2.0" + app.kubernetes.io/managed-by: kustomize + external-secrets.io/component: webhook +webhooks: + - name: "validate.secretstore.external-secrets.io" + rules: + - apiGroups: ["external-secrets.io"] + apiVersions: ["v1"] + operations: ["CREATE", "UPDATE", "DELETE"] + resources: ["secretstores"] + scope: "Namespaced" + clientConfig: + service: + namespace: external-secrets + name: external-secrets-webhook + path: /validate-external-secrets-io-v1-secretstore + admissionReviewVersions: ["v1", "v1beta1"] + sideEffects: None + timeoutSeconds: 5 + failurePolicy: Fail + - name: "validate.clustersecretstore.external-secrets.io" + rules: + - apiGroups: ["external-secrets.io"] + apiVersions: ["v1"] + operations: ["CREATE", "UPDATE", "DELETE"] + resources: ["clustersecretstores"] + scope: "Cluster" + clientConfig: + service: + namespace: external-secrets + name: external-secrets-webhook + path: /validate-external-secrets-io-v1-clustersecretstore + admissionReviewVersions: ["v1", "v1beta1"] + sideEffects: None + timeoutSeconds: 5 +--- +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingWebhookConfiguration +metadata: + name: externalsecret-validate + labels: + app.kubernetes.io/name: external-secrets-webhook + app.kubernetes.io/instance: external-secrets + app.kubernetes.io/version: "v2.2.0" + app.kubernetes.io/managed-by: kustomize + external-secrets.io/component: webhook +webhooks: + - name: "validate.externalsecret.external-secrets.io" + rules: + - apiGroups: ["external-secrets.io"] + apiVersions: ["v1"] + operations: ["CREATE", "UPDATE", "DELETE"] + resources: ["externalsecrets"] + scope: "Namespaced" + clientConfig: + service: + namespace: external-secrets + name: external-secrets-webhook + path: /validate-external-secrets-io-v1-externalsecret + admissionReviewVersions: ["v1", "v1beta1"] + sideEffects: None + timeoutSeconds: 5 + failurePolicy: Fail diff --git a/docs/changelog.d/upgrade-external-secrets-v2.infra.md b/docs/changelog.d/upgrade-external-secrets-v2.infra.md new file mode 100644 index 0000000..606a937 --- /dev/null +++ b/docs/changelog.d/upgrade-external-secrets-v2.infra.md @@ -0,0 +1 @@ +Upgrade External Secrets Operator from v1.3.2 to v2.2.0 and migrate from Helm chart to static kustomize manifests. diff --git a/service-versions.yaml b/service-versions.yaml index 26c1d08..bb42903 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -126,10 +126,10 @@ services: - name: external-secrets type: argocd - last-reviewed: 2026-02-17 - current-version: "helm-chart-2.0.0" + last-reviewed: 2026-03-25 + current-version: "v2.2.0" upstream-source: https://github.com/external-secrets/external-secrets/releases - notes: Deployed via Helm chart (operator v1.3.2) + notes: Static kustomize manifests rendered from upstream Helm chart - name: 1password-connect type: argocd From 4ae55f9bf446670206ae24b4e67deabee47a1164 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Wed, 25 Mar 2026 16:16:33 -0700 Subject: [PATCH 131/430] Review kubernetes-bootstrap tutorial: fix inaccuracies - Fix k3s table entry (BlumeOps uses k3s on ringtail) - Fix broken tailscale serve command (minikube ip returns IP, not port) - Rewrite NFS section to match actual static PV/PVC binding pattern - Fix "BluemeOps" typo - Add last-reviewed frontmatter Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- .../replication/kubernetes-bootstrap.md | 39 +++++++++++++++---- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/docs/tutorials/replication/kubernetes-bootstrap.md b/docs/tutorials/replication/kubernetes-bootstrap.md index cd74962..92d6aec 100644 --- a/docs/tutorials/replication/kubernetes-bootstrap.md +++ b/docs/tutorials/replication/kubernetes-bootstrap.md @@ -1,6 +1,7 @@ --- title: Kubernetes Bootstrap -modified: 2026-02-07 +modified: 2026-03-25 +last-reviewed: 2026-03-25 tags: - tutorials - replication @@ -20,7 +21,7 @@ For homelab use, lightweight distributions work well: | Distribution | Best For | BlumeOps Uses | |--------------|----------|---------------| | **Minikube** | Single-node, macOS | Yes | -| **k3s** | Single-node, Linux | - | +| **k3s** | Single-node, Linux | Yes (ringtail) | | **kind** | Local development | - | | **kubeadm** | Multi-node clusters | - | @@ -76,7 +77,7 @@ To access the cluster from other Tailscale devices, expose the API server: ### Option A: Tailscale Serve (Simple) ```bash -tailscale serve --bg --tcp 6443 tcp://localhost:$(minikube ip --format '{{.Port}}') +tailscale serve --bg --tcp 6443 tcp://$(minikube ip):8443 ``` ### Option B: Tailscale Kubernetes Operator (Advanced) @@ -125,22 +126,44 @@ kubectl patch storageclass local-path -p '{"metadata": {"annotations":{"storagec ### NFS for Shared Storage -If you have a NAS: +If you have a NAS on your tailnet, create a static PersistentVolume and PersistentVolumeClaim pair: + ```yaml apiVersion: v1 kind: PersistentVolume metadata: - name: nfs-share + name: media-nfs-pv spec: capacity: storage: 1Ti accessModes: - ReadWriteMany + persistentVolumeReclaimPolicy: Retain + storageClassName: "" nfs: - server: nas.your-tailnet.ts.net - path: /volume1/k8s + server: nas # Tailscale MagicDNS hostname + path: /volume1/media +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: media-nfs-pvc +spec: + accessModes: + - ReadWriteMany + storageClassName: "" + volumeName: media-nfs-pv + resources: + requests: + storage: 1Ti ``` +Key details: +- `storageClassName: ""` ensures static binding (not dynamic provisioning) +- `volumeName` in the PVC binds it to the specific PV +- `Retain` reclaim policy prevents accidental data loss +- Use the NAS's Tailscale hostname, not an IP address + ## What You Now Have - A Kubernetes cluster running on your server @@ -152,7 +175,7 @@ spec: - [[argocd-config|Configure ArgoCD]] - GitOps deployments - Install essential addons (ingress controller, cert-manager) -## BluemeOps Specifics +## BlumeOps Specifics BlumeOps' cluster configuration includes: - Tailscale operator for automatic ingress From a5e51bd600f5cb7d729fe1f7575e64259f24658f Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Thu, 26 Mar 2026 07:44:36 -0700 Subject: [PATCH 132/430] Review tailscale-setup tutorial: fix inaccuracies Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- .../+review-tailscale-setup.doc.md | 1 + docs/tutorials/replication/tailscale-setup.md | 23 ++++++++++++++----- 2 files changed, 18 insertions(+), 6 deletions(-) create mode 100644 docs/changelog.d/+review-tailscale-setup.doc.md diff --git a/docs/changelog.d/+review-tailscale-setup.doc.md b/docs/changelog.d/+review-tailscale-setup.doc.md new file mode 100644 index 0000000..e3395a0 --- /dev/null +++ b/docs/changelog.d/+review-tailscale-setup.doc.md @@ -0,0 +1 @@ +Review tailscale-setup tutorial: fix macOS install steps, add `--accept-routes` tip, correct tag name, add ACL apply instructions, add `[[tailscale-operator]]` cross-reference. diff --git a/docs/tutorials/replication/tailscale-setup.md b/docs/tutorials/replication/tailscale-setup.md index 7cf42f4..463de42 100644 --- a/docs/tutorials/replication/tailscale-setup.md +++ b/docs/tutorials/replication/tailscale-setup.md @@ -1,6 +1,7 @@ --- title: Tailscale Setup -modified: 2026-02-07 +modified: 2026-03-26 +last-reviewed: 2026-03-26 tags: - tutorials - replication @@ -34,8 +35,13 @@ For BlumeOps context, see [[tailscale|Tailscale Reference]]. ### macOS ```bash +# Option A: GUI app (recommended for desktop Macs) +brew install --cask tailscale +# Then launch Tailscale from Applications and follow the UI + +# Option B: Headless CLI (servers/VMs) brew install tailscale -sudo tailscaled & +brew services start tailscale tailscale up ``` @@ -65,7 +71,8 @@ ping <other-device>.yourname.ts.net Default Tailscale allows all-to-all connectivity. For a homelab, you'll want restrictions. -Create `policy.hujson` (or use the web admin): +You can edit ACLs directly in the [Tailscale admin console](https://login.tailscale.com/admin/acls), or manage them as code with `tailscale policy` (see `tailscale policy --help`). Here's an example policy to start from: + ```json { "groups": { @@ -83,7 +90,9 @@ Create `policy.hujson` (or use the web admin): } ``` -BlumeOps manages ACLs via Pulumi - see [[tailscale|Tailscale Reference]] for the actual configuration. +If editing as code, save this as `policy.hujson` and apply it with `tailscale policy set policy.hujson`. + +BlumeOps manages ACLs via Pulumi — see [[tailscale|Tailscale Reference]] for the actual configuration. ## Step 5: Enable MagicDNS @@ -104,6 +113,8 @@ sudo tailscale up --advertise-tags=tag:homelab Tags must be defined in ACLs before use. +> **Tip:** If you plan to use subnet routing or Tailscale ProxyGroup Ingress, clients must also run `tailscale up --accept-routes` (or enable "Accept Routes" in the GUI). Without this, advertised routes are invisible to the client. + ## What You Now Have - Encrypted mesh network between all your devices @@ -114,12 +125,12 @@ Tags must be defined in ACLs before use. With networking established: - [[core-services|Set Up Core Services]] - Install Forgejo and optionally a container registry -- [[kubernetes-bootstrap|Bootstrap Kubernetes]] - Your cluster will join the tailnet +- [[kubernetes-bootstrap|Bootstrap Kubernetes]] - Your cluster will join the tailnet via the [[tailscale-operator|Tailscale Operator]] ## BlumeOps Specifics BlumeOps' Tailscale configuration includes: -- Multiple device tags (`homelab`, `nas`, `registry`, `k8s-api`) +- Multiple device tags (`homelab`, `nas`, `registry`, `k8s-operator`) - Group-based access for family members - SSH access rules with authentication requirements From e375859221d9aeaa2b24d305c9fa8a0a9ac00947 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Thu, 26 Mar 2026 10:17:36 -0700 Subject: [PATCH 133/430] Upgrade Homepage container to v1.11.0 Minor release with new widgets (Tracearr, SparklyFitness), Seerr rename, and dependency bumps. No breaking changes for our config. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- containers/homepage/Dockerfile | 2 +- docs/changelog.d/+homepage-v1.11.0.infra.md | 1 + service-versions.yaml | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) create mode 100644 docs/changelog.d/+homepage-v1.11.0.infra.md diff --git a/containers/homepage/Dockerfile b/containers/homepage/Dockerfile index 31b72f9..6e53e1c 100644 --- a/containers/homepage/Dockerfile +++ b/containers/homepage/Dockerfile @@ -1,7 +1,7 @@ # Homepage - self-hosted services dashboard # Two-stage build: Node.js build, Alpine runtime -ARG CONTAINER_APP_VERSION=v1.10.1 +ARG CONTAINER_APP_VERSION=v1.11.0 ARG HOMEPAGE_VERSION=${CONTAINER_APP_VERSION} FROM node:24-slim AS builder diff --git a/docs/changelog.d/+homepage-v1.11.0.infra.md b/docs/changelog.d/+homepage-v1.11.0.infra.md new file mode 100644 index 0000000..a35eaed --- /dev/null +++ b/docs/changelog.d/+homepage-v1.11.0.infra.md @@ -0,0 +1 @@ +Upgrade Homepage dashboard from v1.10.1 to v1.11.0 diff --git a/service-versions.yaml b/service-versions.yaml index bb42903..909aa8c 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -39,8 +39,8 @@ services: - name: homepage type: argocd - last-reviewed: 2026-02-19 - current-version: "v1.10.1" + last-reviewed: 2026-03-26 + current-version: "v1.11.0" upstream-source: https://github.com/gethomepage/homepage/releases notes: Custom container, kustomize manifests From f97b5c9d5dbe9c369603f0001bc628863a77c584 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Thu, 26 Mar 2026 10:25:07 -0700 Subject: [PATCH 134/430] Deploy Homepage v1.11.0-e375859 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- argocd/manifests/homepage/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/argocd/manifests/homepage/kustomization.yaml b/argocd/manifests/homepage/kustomization.yaml index f70d347..27de0eb 100644 --- a/argocd/manifests/homepage/kustomization.yaml +++ b/argocd/manifests/homepage/kustomization.yaml @@ -17,7 +17,7 @@ resources: images: - name: registry.ops.eblu.me/blumeops/homepage - newTag: v1.10.1-613f05d + newTag: v1.11.0-e375859 configMapGenerator: - name: homepage-config From fc8d2cdb12cecf8fb443e61c1f0421c883c986d3 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Thu, 26 Mar 2026 15:32:25 -0700 Subject: [PATCH 135/430] Add preserve/* branch protection and document Pyroscope blocker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit branch-cleanup: Add PROTECTED_PREFIXES with preserve/* exclusion so preserved work-in-progress branches are never deleted. observability.md: Document Pyroscope profiling work on branch preserve/pyroscope-profiling/pr-313, blocked on ringtail kernel sysctl settings (kptr_restrict=0, perf_event_paranoid≤1). Also document Faro/RUM as future potential with privacy considerations. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- .../+branch-cleanup-preserve.misc.md | 1 + docs/reference/operations/observability.md | 18 +++++++++++++++++- mise-tasks/branch-cleanup | 12 +++++++++--- 3 files changed, 27 insertions(+), 4 deletions(-) create mode 100644 docs/changelog.d/+branch-cleanup-preserve.misc.md diff --git a/docs/changelog.d/+branch-cleanup-preserve.misc.md b/docs/changelog.d/+branch-cleanup-preserve.misc.md new file mode 100644 index 0000000..425e8cc --- /dev/null +++ b/docs/changelog.d/+branch-cleanup-preserve.misc.md @@ -0,0 +1 @@ +Add `preserve/*` branch prefix exclusion to `branch-cleanup` task; document Pyroscope profiling work and blockers in observability reference. diff --git a/docs/reference/operations/observability.md b/docs/reference/operations/observability.md index 35136d5..622779e 100644 --- a/docs/reference/operations/observability.md +++ b/docs/reference/operations/observability.md @@ -1,6 +1,6 @@ --- title: Observability -modified: 2026-03-22 +modified: 2026-03-26 tags: - operations --- @@ -17,6 +17,22 @@ Metrics, logs, traces, and dashboards for BlumeOps infrastructure. - [[alloy|Alloy]] - Metrics, log, and trace collection - [[grafana]] - Dashboards and visualization +## Future: Continuous Profiling (Pyroscope) + +Full implementation on branch `preserve/pyroscope-profiling/pr-313` (PR #313, closed). Includes Pyroscope server (StatefulSet on ringtail), Alloy profiling DaemonSet (`pyroscope.ebpf`), Grafana datasource with traces-to-profiles linking, Nix container build with embedded frontend, and documentation. + +**Blocked on ringtail kernel sysctl settings.** The `pyroscope.ebpf` Alloy component requires: +- `kernel.kptr_restrict = 0` (currently `1` — kallsyms addresses are zeroed) +- `kernel.perf_event_paranoid ≤ 1` (currently `2` — eBPF perf events restricted) + +These must be set in ringtail's NixOS configuration (`boot.kernel.sysctl`). Once applied, the branch can be rebased onto main and deployed. + +## Future: Frontend Monitoring (RUM) + +Grafana Faro is a Real User Monitoring SDK that captures page loads, web vitals, errors, and network timings from the browser, feeding into Loki (logs) and Tempo (traces) via Alloy's `faro.receiver` component. This would add an "outside-in" view of service health from the user's perspective. + +**Not currently deployed.** RUM captures browsing behavior from visitors to public services, creating a data retention liability. Would require careful sanitization before deploying. + ## Alerting - [[deploy-infra-alerting]] - Alerting pipeline (Grafana Unified Alerting → ntfy) diff --git a/mise-tasks/branch-cleanup b/mise-tasks/branch-cleanup index 0b5a301..bd5ac66 100755 --- a/mise-tasks/branch-cleanup +++ b/mise-tasks/branch-cleanup @@ -44,12 +44,18 @@ from rich.console import Console from rich.table import Table PROTECTED_BRANCHES = {"main", "master"} +PROTECTED_PREFIXES = ("preserve/",) FORGE_API = "https://forge.eblu.me/api/v1" REPO_OWNER = "eblume" REPO_NAME = "blumeops" OP_TOKEN_REF = "op://vg6xf6vvfmoh5hqjjhlhbeoaie/w3663ffnvkewbftncqxtcpeavy/api-token" +def is_protected(name: str) -> bool: + """Check if a branch is protected by name or prefix.""" + return name in PROTECTED_BRANCHES or name.startswith(PROTECTED_PREFIXES) + + def run_git(*args: str) -> str: """Run a git command and return stdout.""" result = subprocess.run( @@ -113,7 +119,7 @@ def get_git_merged_local_branches() -> set[str]: branches = set() for line in output.splitlines(): name = line.strip().lstrip("* ") - if name and name not in PROTECTED_BRANCHES: + if name and not is_protected(name): branches.add(name) return branches @@ -143,7 +149,7 @@ def get_all_local_branches() -> set[str]: branches = set() for line in output.splitlines(): name = line.strip().lstrip("* ") - if name and name not in PROTECTED_BRANCHES: + if name and not is_protected(name): branches.add(name) return branches @@ -164,7 +170,7 @@ def get_api_branches(client: httpx.Client) -> dict[str, str]: break for branch in data: name = branch["name"] - if name not in PROTECTED_BRANCHES: + if not is_protected(name): date = branch.get("commit", {}).get("timestamp", "") branches[name] = date page += 1 From a37012385f0ccee91b9597d36f7f2c51abb685f2 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Thu, 26 Mar 2026 15:44:09 -0700 Subject: [PATCH 136/430] Tighten ArgoCDAppOutOfSync alert timing to clear faster after sync Reduced `for` from 30m to 5m and lookback window from 5m to 1m. The old values caused alerts to linger long after apps returned to Synced state. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- argocd/manifests/grafana/alerting.yaml | 4 ++-- docs/changelog.d/+tune-argocd-outofsync-alert.infra.md | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) create mode 100644 docs/changelog.d/+tune-argocd-outofsync-alert.infra.md diff --git a/argocd/manifests/grafana/alerting.yaml b/argocd/manifests/grafana/alerting.yaml index abc4c0f..69dbec5 100644 --- a/argocd/manifests/grafana/alerting.yaml +++ b/argocd/manifests/grafana/alerting.yaml @@ -323,7 +323,7 @@ groups: - uid: argocd-app-out-of-sync title: ArgoCDAppOutOfSync condition: C - for: 30m + for: 5m noDataState: OK execErrState: Alerting annotations: @@ -337,7 +337,7 @@ groups: - refId: A datasourceUid: prometheus relativeTimeRange: - from: 300 + from: 60 to: 0 model: expr: >- diff --git a/docs/changelog.d/+tune-argocd-outofsync-alert.infra.md b/docs/changelog.d/+tune-argocd-outofsync-alert.infra.md new file mode 100644 index 0000000..cac4b46 --- /dev/null +++ b/docs/changelog.d/+tune-argocd-outofsync-alert.infra.md @@ -0,0 +1 @@ +Tighten ArgoCDAppOutOfSync alert: reduce pending duration from 30m to 5m and lookback window from 5m to 1m so alerts clear faster after sync. From 2c1652604becfb356c270590ad46a4d24a90bf3b Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Thu, 26 Mar 2026 19:48:37 -0700 Subject: [PATCH 137/430] Reduce PodNotReady alert lookback from 5m to 60s The 5-minute lookback window kept stale data from terminated pods visible during rollouts, causing the alert to sit in Pending for ~5 minutes after every routine deployment. 60s still covers two scrape cycles (30s interval) while clearing stale data much faster. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- argocd/manifests/grafana/alerting.yaml | 2 +- docs/changelog.d/+podnotready-lookback.infra.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 docs/changelog.d/+podnotready-lookback.infra.md diff --git a/argocd/manifests/grafana/alerting.yaml b/argocd/manifests/grafana/alerting.yaml index 69dbec5..b220044 100644 --- a/argocd/manifests/grafana/alerting.yaml +++ b/argocd/manifests/grafana/alerting.yaml @@ -277,7 +277,7 @@ groups: - refId: A datasourceUid: prometheus relativeTimeRange: - from: 300 + from: 60 to: 0 model: expr: >- diff --git a/docs/changelog.d/+podnotready-lookback.infra.md b/docs/changelog.d/+podnotready-lookback.infra.md new file mode 100644 index 0000000..fec02df --- /dev/null +++ b/docs/changelog.d/+podnotready-lookback.infra.md @@ -0,0 +1 @@ +Reduce PodNotReady alert lookback window from 5m to 60s to clear faster after rollouts. From 687e97271364e2d20b2055b94a655d91ce588c46 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Fri, 27 Mar 2026 07:11:22 -0700 Subject: [PATCH 138/430] Review CV doc and close build-dep review gap Fix stale CV service doc (URL, forge domain, container tag) and add guidance for reviewing build-time dependencies in private forge repos during service reviews. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- docs/changelog.d/+cv-doc-review.doc.md | 1 + docs/how-to/knowledgebase/review-services.md | 14 ++++++++++++++ docs/reference/services/cv.md | 13 +++++++------ service-versions.yaml | 4 ++-- 4 files changed, 24 insertions(+), 8 deletions(-) create mode 100644 docs/changelog.d/+cv-doc-review.doc.md diff --git a/docs/changelog.d/+cv-doc-review.doc.md b/docs/changelog.d/+cv-doc-review.doc.md new file mode 100644 index 0000000..ecace7d --- /dev/null +++ b/docs/changelog.d/+cv-doc-review.doc.md @@ -0,0 +1 @@ +Review and fix CV service doc (correct URL, forge domain, container tag link) and add private forge repo review guidance to review-services process. diff --git a/docs/how-to/knowledgebase/review-services.md b/docs/how-to/knowledgebase/review-services.md index 675bdd6..30b5833 100644 --- a/docs/how-to/knowledgebase/review-services.md +++ b/docs/how-to/knowledgebase/review-services.md @@ -38,6 +38,8 @@ mise run service-review --type hybrid ## Review Process by Service Type +For all service types, start by reading the service's reference card (`docs/reference/services/<service>.md`) for architecture, configuration, and endpoint details. + ### ArgoCD Services (`type: argocd`) 1. Check the upstream releases page for new versions @@ -59,6 +61,18 @@ mise run service-review --type hybrid 2. Review the Nix derivation or flake input for version pins 3. If upgrading, update and deploy via `mise run provision-ringtail` +### Private Forge Repos (`upstream-source` under `forge.eblu.me/eblume/`) + +Some services are built from private repos on the forge rather than tracking an external upstream project. When `upstream-source` points to a `forge.eblu.me/eblume/` repo: + +1. Clone the repo to `~/code/personal/` if not already checked out +2. Review the repo's dependency pins — uv script metadata, `pyproject.toml`, `package.json`, `flake.nix` inputs, etc. +3. Update stale dependencies and rebuild locally to verify nothing breaks +4. If changes were made, commit, push, and trigger a new release from that repo +5. Back in blumeops, update the container image or release artifact reference as needed + +This extends the service review into the source repo's build-time dependencies, which would otherwise be a blind spot — the blumeops-side review only covers the deployment manifest and container base image. + ## Attached Services Some services have auxiliary dependencies that run as separate containers — caches, sidecars, init helpers. These are tracked as **attached services** with a naming convention and an optional `parent` field: diff --git a/docs/reference/services/cv.md b/docs/reference/services/cv.md index 0c546d9..55805d6 100644 --- a/docs/reference/services/cv.md +++ b/docs/reference/services/cv.md @@ -1,6 +1,7 @@ --- title: CV -modified: 2026-02-12 +modified: 2026-03-27 +last-reviewed: 2026-03-27 tags: - service - resume @@ -14,11 +15,11 @@ Personal resume/CV served as a static HTML page with PDF download, built from YA | Property | Value | |----------|-------| -| **URL** | `cv.ops.eblu.me` (tailnet only, via [[caddy]]) | +| **URL** | `cv.eblu.me` (public, via [[flyio-proxy]]) | | **Namespace** | `cv` | -| **Container** | `registry.ops.eblu.me/blumeops/cv:v1.0.0` | -| **Source repo** | `forge.ops.eblu.me/eblume/cv` (private, not mirrored to GitHub) | -| **Content packages** | `forge.ops.eblu.me/eblume/-/packages` (generic package `cv`) | +| **Container** | `registry.ops.eblu.me/blumeops/cv` ([kustomization](https://forge.eblu.me/eblume/blumeops/src/branch/main/argocd/manifests/cv/kustomization.yaml)) | +| **Source repo** | `forge.eblu.me/eblume/cv` (private, not mirrored to GitHub) | +| **Content packages** | `forge.eblu.me/eblume/-/packages` (generic package `cv`) | | **ArgoCD App** | `cv` | ## Architecture @@ -66,6 +67,6 @@ Provisioned via `forgejo_actions_secrets` Ansible role. See [[create-release-art ## Related - [[docs]] — Similar architecture (nginx container + content tarball) -- [[caddy]] — Reverse proxy for `cv.ops.eblu.me` +- [[flyio-proxy]] — Exposes `cv.eblu.me` publicly via Tailscale tunnel - [[create-release-artifact-workflow]] — How to set up release artifact workflows - [[deploy-k8s-service]] — General k8s deployment guide diff --git a/service-versions.yaml b/service-versions.yaml index 909aa8c..ccc062e 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -222,8 +222,8 @@ services: type: argocd last-reviewed: 2026-03-07 current-version: "1.0.3" - upstream-source: null - notes: Personal static site, no upstream + upstream-source: https://forge.eblu.me/eblume/cv + notes: Personal static site; review build deps (WeasyPrint, Jinja2) in source repo - name: docs type: argocd From 831b82950a0637d83041e43b79b75b2cd632e355 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Fri, 27 Mar 2026 07:19:24 -0700 Subject: [PATCH 139/430] =?UTF-8?q?Upgrade=20nvidia-device-plugin=20v0.18.?= =?UTF-8?q?2=20=E2=86=92=20v0.19.0=20and=20add=20reference=20card?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- .../nvidia-device-plugin/kustomization.yaml | 2 +- .../+nvidia-device-plugin-v0.19.0.infra.md | 1 + docs/reference/services/frigate.md | 1 + .../services/nvidia-device-plugin.md | 26 +++++++++++++++++++ service-versions.yaml | 4 +-- 5 files changed, 31 insertions(+), 3 deletions(-) create mode 100644 docs/changelog.d/+nvidia-device-plugin-v0.19.0.infra.md create mode 100644 docs/reference/services/nvidia-device-plugin.md diff --git a/argocd/manifests/nvidia-device-plugin/kustomization.yaml b/argocd/manifests/nvidia-device-plugin/kustomization.yaml index 102127f..a46edf6 100644 --- a/argocd/manifests/nvidia-device-plugin/kustomization.yaml +++ b/argocd/manifests/nvidia-device-plugin/kustomization.yaml @@ -10,4 +10,4 @@ resources: images: - name: nvcr.io/nvidia/k8s-device-plugin - newTag: v0.18.2 + newTag: v0.19.0 diff --git a/docs/changelog.d/+nvidia-device-plugin-v0.19.0.infra.md b/docs/changelog.d/+nvidia-device-plugin-v0.19.0.infra.md new file mode 100644 index 0000000..95abf25 --- /dev/null +++ b/docs/changelog.d/+nvidia-device-plugin-v0.19.0.infra.md @@ -0,0 +1 @@ +Upgrade nvidia-device-plugin from v0.18.2 to v0.19.0 diff --git a/docs/reference/services/frigate.md b/docs/reference/services/frigate.md index 46363bd..000d4ee 100644 --- a/docs/reference/services/frigate.md +++ b/docs/reference/services/frigate.md @@ -74,6 +74,7 @@ A separate **frigate-notify** pod polls Frigate's webapi every 15 seconds for de ## Related +- [[nvidia-device-plugin]] - GPU device plugin enabling CUDA access - [[ntfy]] - Push notification delivery - [[sifaka]] - NAS storage for recordings - [[observability]] - Prometheus metrics at `/api/metrics` diff --git a/docs/reference/services/nvidia-device-plugin.md b/docs/reference/services/nvidia-device-plugin.md new file mode 100644 index 0000000..7eb28d9 --- /dev/null +++ b/docs/reference/services/nvidia-device-plugin.md @@ -0,0 +1,26 @@ +--- +title: NVIDIA Device Plugin +modified: 2026-03-27 +tags: + - service + - gpu +--- + +# NVIDIA Device Plugin + +Kubernetes device plugin that exposes NVIDIA GPUs to pods on [[ringtail]]. Required for GPU workloads like [[frigate]] (object detection) and [[ollama]] (LLM inference). + +## Quick Reference + +| Property | Value | +|----------|-------| +| **Namespace** | `nvidia-device-plugin` | +| **Image** | `nvcr.io/nvidia/k8s-device-plugin` | +| **Upstream** | https://github.com/NVIDIA/k8s-device-plugin | +| **Manifests** | [argocd/manifests/nvidia-device-plugin/](https://forge.eblu.me/eblume/blumeops/src/branch/main/argocd/manifests/nvidia-device-plugin) | + +## Architecture + +Runs as a DaemonSet with `privileged` security context, mounting the host's device-plugins socket, CDI specs, and NVIDIA driver libraries. A `RuntimeClass` named `nvidia` is defined for pods that need GPU access. + +Time-slicing is configured with 2 replicas per GPU, allowing two pods to share a single physical GPU. diff --git a/service-versions.yaml b/service-versions.yaml index ccc062e..6821488 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -46,8 +46,8 @@ services: - name: nvidia-device-plugin type: argocd - last-reviewed: 2026-02-19 - current-version: "v0.18.2" + last-reviewed: 2026-03-27 + current-version: "v0.19.0" upstream-source: https://github.com/NVIDIA/k8s-device-plugin/releases notes: DaemonSet + RuntimeClass on ringtail for GPU workloads From a5b33591d30a32cd45775f4a3bef8135dbf40be3 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Fri, 27 Mar 2026 07:37:43 -0700 Subject: [PATCH 140/430] Update ringtail flake inputs (nixpkgs, home-manager) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- docs/changelog.d/+update-ringtail-flake.infra.md | 1 + nixos/ringtail/flake.lock | 12 ++++++------ 2 files changed, 7 insertions(+), 6 deletions(-) create mode 100644 docs/changelog.d/+update-ringtail-flake.infra.md diff --git a/docs/changelog.d/+update-ringtail-flake.infra.md b/docs/changelog.d/+update-ringtail-flake.infra.md new file mode 100644 index 0000000..d2c1ce8 --- /dev/null +++ b/docs/changelog.d/+update-ringtail-flake.infra.md @@ -0,0 +1 @@ +Update ringtail flake inputs (nixpkgs, home-manager). diff --git a/nixos/ringtail/flake.lock b/nixos/ringtail/flake.lock index 89ccbc8..9195dbd 100644 --- a/nixos/ringtail/flake.lock +++ b/nixos/ringtail/flake.lock @@ -27,11 +27,11 @@ ] }, "locked": { - "lastModified": 1773963144, - "narHash": "sha256-WzBOBfSay3GYilUfKaUa1Mbf8/jtuAiJIedx7fWuIX4=", + "lastModified": 1774559029, + "narHash": "sha256-deix7yg3j6AhjMPnFDCmWB3f83LsajaaULP5HH2j34k=", "owner": "nix-community", "repo": "home-manager", - "rev": "a91b3ea73a765614d90360580b689c48102d1d33", + "rev": "a0bb0d11514f92b639514220114ac8063c72d0a3", "type": "github" }, "original": { @@ -43,11 +43,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1773964973, - "narHash": "sha256-NV/J+tTER0P5iJhUDL/8HO5MDjDceLQPRUYgdmy5wXw=", + "lastModified": 1774388614, + "narHash": "sha256-tFwzTI0DdDzovdE9+Ras6CUss0yn8P9XV4Ja6RjA+nU=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "812b3986fd1568f7a858f97fcf425ad996ba7d25", + "rev": "1073dad219cb244572b74da2b20c7fe39cb3fa9e", "type": "github" }, "original": { From 66a47738dd552a60d1cb038132360b08991c7049 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Fri, 27 Mar 2026 07:55:45 -0700 Subject: [PATCH 141/430] Add ringtail post-deploy maintenance: kernel check, generation pruning, GC Update manage-lockfile doc with post-deploy steps (kernel update detection, reboot guidance, generation pruning). Add prune-ringtail-generations mise task that keeps the 5 most recent generations plus the most recent one matching the booted kernel for safe rollback. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- ...+ringtail-post-deploy-maintenance.infra.md | 1 + docs/how-to/ringtail/manage-lockfile.md | 40 ++++- mise-tasks/prune-ringtail-generations | 166 ++++++++++++++++++ 3 files changed, 204 insertions(+), 3 deletions(-) create mode 100644 docs/changelog.d/+ringtail-post-deploy-maintenance.infra.md create mode 100755 mise-tasks/prune-ringtail-generations diff --git a/docs/changelog.d/+ringtail-post-deploy-maintenance.infra.md b/docs/changelog.d/+ringtail-post-deploy-maintenance.infra.md new file mode 100644 index 0000000..c85a3da --- /dev/null +++ b/docs/changelog.d/+ringtail-post-deploy-maintenance.infra.md @@ -0,0 +1 @@ +Add post-deploy maintenance docs and generation pruning task for ringtail. diff --git a/docs/how-to/ringtail/manage-lockfile.md b/docs/how-to/ringtail/manage-lockfile.md index b393d24..aae5344 100644 --- a/docs/how-to/ringtail/manage-lockfile.md +++ b/docs/how-to/ringtail/manage-lockfile.md @@ -1,6 +1,6 @@ --- title: Manage Ringtail Lockfile -modified: 2026-02-22 +modified: 2026-03-27 tags: - how-to - ringtail @@ -16,23 +16,57 @@ Two [[dagger]] pipelines manage the ringtail NixOS flake lockfile (`nixos/ringta To pull the latest versions of all flake inputs (equivalent to `nix flake update`): ```fish -# Update flake.lock +# 1. Update flake.lock dagger call flake-update --src=. --flake-path=nixos/ringtail \ export --path=nixos/ringtail/flake.lock -# Commit, push, then deploy +# 2. Commit, push, then deploy git add nixos/ringtail/flake.lock git commit -m "Update ringtail flake inputs" git push mise run provision-ringtail ``` +After deploying, continue with [post-deploy maintenance](#post-deploy-maintenance). + ## Lock New Inputs Only `mise run provision-ringtail` automatically runs `flake-lock` before deploying. This resolves any newly added inputs without upgrading existing ones (equivalent to `nix flake lock`). If the lockfile changes, the task stages the file and exits — commit, push, and re-run. This is the right behavior for provisioning: configuration changes that add a new input get locked, but existing inputs stay pinned until explicitly updated. +## Post-Deploy Maintenance + +After `provision-ringtail` completes (whether from a full update or a config change), perform these steps. + +### Check for Kernel Update + +Compare the booted kernel against the one in the current system profile: + +```fish +ssh ringtail 'echo "Booted: $(uname -r)"; echo "Staged: $(readlink /run/current-system/kernel | grep -oP "linux-\K[^/]+")"' +``` + +If they differ, a reboot is needed for the new kernel to take effect. Reboot at a convenient time: + +```fish +ssh ringtail 'sudo reboot' +``` + +> **AI agents:** Do not reboot automatically. Inform the user that a kernel update is pending and suggest they reboot when convenient. + +### Prune Old Generations and Garbage Collect + +Old NixOS system generations accumulate over time. The `prune-ringtail-generations` task handles pruning and garbage collection together: + +```fish +mise run prune-ringtail-generations # keep 5 most recent + kernel-safe gen +mise run prune-ringtail-generations --dry-run # preview only +mise run prune-ringtail-generations --keep 3 # keep fewer generations +``` + +The task keeps the 5 most recent generations plus the most recent generation whose kernel matches the currently **booted** kernel — this preserves a rollback target that won't require a reboot. After pruning, it runs `nix-collect-garbage` to free unreferenced store paths. + ## Related - [[ringtail]] — Host reference diff --git a/mise-tasks/prune-ringtail-generations b/mise-tasks/prune-ringtail-generations new file mode 100755 index 0000000..8066f8b --- /dev/null +++ b/mise-tasks/prune-ringtail-generations @@ -0,0 +1,166 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.12" +# dependencies = ["rich>=14.0.0", "typer>=0.24.0"] +# /// +#MISE description="Prune old NixOS generations on ringtail, preserving rollback safety" +#MISE alias="prg" +#USAGE flag "--dry-run" help="Show what would be deleted without deleting" +#USAGE flag "--keep <keep>" default="5" help="Number of most recent generations to keep (default 5)" +"""Prune old NixOS system generations on ringtail. + +Keeps the N most recent generations (default 5), plus the most recent generation +whose kernel matches the currently booted kernel. This ensures at least one +rollback target that won't require a reboot. + +After pruning, runs nix-collect-garbage to free unreferenced store paths. + +Usage: + mise run prune-ringtail-generations # keep 5 + kernel-safe gen + mise run prune-ringtail-generations --keep 3 # keep 3 + kernel-safe gen + mise run prune-ringtail-generations --dry-run # preview only +""" + +import re +import subprocess +import sys +from dataclasses import dataclass +from typing import Annotated + +from rich.console import Console +from rich.table import Table + +console = Console() + + +@dataclass +class Generation: + number: int + profile_path: str + kernel_path: str + + +def ssh(cmd: str) -> str: + """Run a command on ringtail via SSH and return stdout.""" + result = subprocess.run( + ["ssh", "ringtail", cmd], + capture_output=True, + text=True, + check=True, + ) + return result.stdout.strip() + + +def get_generations() -> list[Generation]: + """List all system generations with their kernel store paths.""" + # Single SSH call: for each generation, print "gen_number<TAB>profile_path<TAB>kernel_path" + output = ssh( + "command bash -c '" + 'for p in /nix/var/nix/profiles/system-*-link; do ' + ' [ -e "$p" ] || continue; ' + ' num=$(basename "$p" | grep -oP "\\d+"); ' + ' kern=$(readlink "$p/kernel"); ' + ' printf "%s\\t%s\\t%s\\n" "$num" "$p" "$kern"; ' + "done'" + ) + if not output: + return [] + + generations = [] + for line in output.splitlines(): + parts = line.split("\t") + if len(parts) != 3: + continue + gen_num, profile_path, kernel_path = int(parts[0]), parts[1], parts[2] + generations.append(Generation(gen_num, profile_path, kernel_path)) + + # Sort newest-first + generations.sort(key=lambda g: g.number, reverse=True) + return generations + + +def main( + dry_run: Annotated[bool, "dry_run"] = False, + keep: Annotated[int, "keep"] = 5, +) -> None: + console.print(f"[bold]Scanning ringtail NixOS generations...[/bold]") + + booted_kernel = ssh("readlink /run/booted-system/kernel") + console.print(f"Booted kernel: [cyan]{booted_kernel}[/cyan]") + + generations = get_generations() + if not generations: + console.print("[yellow]No generations found.[/yellow]") + return + + # Build the keep set: top N newest + most recent kernel-matching gen + keep_set: set[int] = set() + + # Keep the N most recent + for gen in generations[:keep]: + keep_set.add(gen.number) + + # Find and keep the most recent generation matching booted kernel + kernel_gen: Generation | None = None + for gen in generations: + if gen.kernel_path == booted_kernel: + kernel_gen = gen + keep_set.add(gen.number) + break + + to_delete = [g for g in generations if g.number not in keep_set] + + # Display a summary table + table = Table(title="System Generations") + table.add_column("Gen", style="bold") + table.add_column("Kernel Match", justify="center") + table.add_column("Action") + + for gen in generations: + matches_booted = gen.kernel_path == booted_kernel + kernel_col = "[green]yes[/green]" if matches_booted else "no" + + if gen.number not in keep_set: + action = "[red]delete[/red]" + elif kernel_gen and gen.number == kernel_gen.number and gen.number not in {g.number for g in generations[:keep]}: + action = "[blue]keep (kernel safety)[/blue]" + else: + action = f"[green]keep (top {keep})[/green]" + + table.add_row(str(gen.number), kernel_col, action) + + console.print(table) + + if kernel_gen is None: + console.print( + "[yellow]Warning: no generation matches booted kernel. " + "Rollback will require a reboot.[/yellow]" + ) + + if not to_delete: + console.print("[green]Nothing to prune.[/green]") + return + + delete_nums = " ".join(str(g.number) for g in to_delete) + + if dry_run: + console.print(f"[yellow]Dry run:[/yellow] would delete generations: {delete_nums}") + console.print("[yellow]Dry run:[/yellow] would run nix-collect-garbage") + return + + console.print(f"Deleting generations: {delete_nums}") + ssh(f"sudo nix-env --delete-generations {delete_nums} -p /nix/var/nix/profiles/system") + + console.print("Running nix-collect-garbage...") + ssh("sudo nix-collect-garbage") + + console.print("[green]Done.[/green]") + + +if __name__ == "__main__": + try: + import typer + + typer.run(main) + except ImportError: + main() From 33463764d167805e926ecf7f4f2068f21280d792 Mon Sep 17 00:00:00 2001 From: Erich Blume <blume.erich@gmail.com> Date: Fri, 27 Mar 2026 15:33:36 -0700 Subject: [PATCH 142/430] Add QArt Tuner: QR code art generator with interactive web UI Single-file Go tool implementing the QArt technique (Russ Cox, 2012) using only the public rsc.io/qr API. Generates QR codes whose data modules form a recognizable image by exploiting error correction freedom via GF(2) Gaussian elimination. Includes a web UI with live-updating sliders for version, mask, rotation, dx/dy offset, and scale. Keyboard shortcuts for rapid iteration. Also works as a CLI for batch generation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- docs/changelog.d/+qart-tuner.feature.md | 1 + docs/reference/tools/mise-tasks.md | 1 + docs/reference/tools/qart-tuner.md | 61 ++ utils/qart/.gitignore | 3 + utils/qart/README.md | 83 +++ utils/qart/go.mod | 5 + utils/qart/go.sum | 2 + utils/qart/main.go | 753 ++++++++++++++++++++++++ utils/qart/mise.toml | 11 + 9 files changed, 920 insertions(+) create mode 100644 docs/changelog.d/+qart-tuner.feature.md create mode 100644 docs/reference/tools/qart-tuner.md create mode 100644 utils/qart/.gitignore create mode 100644 utils/qart/README.md create mode 100644 utils/qart/go.mod create mode 100644 utils/qart/go.sum create mode 100644 utils/qart/main.go create mode 100644 utils/qart/mise.toml diff --git a/docs/changelog.d/+qart-tuner.feature.md b/docs/changelog.d/+qart-tuner.feature.md new file mode 100644 index 0000000..720774d --- /dev/null +++ b/docs/changelog.d/+qart-tuner.feature.md @@ -0,0 +1 @@ +Add QArt Tuner — a Go tool that generates QR codes whose data modules form a recognizable image, with an interactive web UI for parameter tuning. Based on the [QArt technique](https://research.swtch.com/qart) by Russ Cox. Lives in `utils/qart/`. diff --git a/docs/reference/tools/mise-tasks.md b/docs/reference/tools/mise-tasks.md index d2385a6..ae92013 100644 --- a/docs/reference/tools/mise-tasks.md +++ b/docs/reference/tools/mise-tasks.md @@ -89,3 +89,4 @@ Run `mise tasks --sort name` for the live list with descriptions. - [[ansible]] — Configuration management - [[argocd-cli]] — ArgoCD deployment workflows - [[pulumi]] — DNS and Tailscale IaC +- [[qart-tuner]] — QR code art generator (`utils/qart/`) diff --git a/docs/reference/tools/qart-tuner.md b/docs/reference/tools/qart-tuner.md new file mode 100644 index 0000000..a05c302 --- /dev/null +++ b/docs/reference/tools/qart-tuner.md @@ -0,0 +1,61 @@ +--- +title: QArt Tuner +modified: 2026-03-27 +tags: + - reference + - tools + - utils +--- + +# QArt Tuner + +Generates QR codes whose data modules form a recognizable image, using the [QArt technique](https://research.swtch.com/qart) by Russ Cox. Lives in `utils/qart/`. + +## Quick Reference + +| Item | Value | +|------|-------| +| **Source** | `utils/qart/main.go` | +| **Language** | Go (managed via mise) | +| **Dependency** | [rsc.io/qr](https://github.com/rsc/qr) (BSD 3-clause) | +| **Launch web UI** | `QART_IMAGE=photo.png mise run serve` (from `utils/qart/`) | +| **CLI** | `mise x go -- go run . -url URL -image IMG -out out.png` | + +## How It Works + +QR error correction (Reed-Solomon coding) allows some data and check bits to be freely chosen. The tool: + +1. Builds a QR code plan for the given URL and version +2. Converts the source photo to a grayscale brightness grid at QR module resolution +3. For each ECC block, models the data/check bit relationships as a matrix over GF(2) +4. Uses Gaussian elimination to find which bits can be independently assigned +5. Assigns bits to match the target image brightness, prioritizing high-contrast areas + +The result is a valid, scannable QR code whose black/white modules approximate the source image. + +## Web UI + +The interactive tuner (`-serve` flag) provides sliders for all parameters with live preview. + +**Keyboard shortcuts:** arrow keys (dx/dy offset), `[`/`]` (mask), `-`/`=` (version), `r` (rotate). + +## Parameters + +| Parameter | Range | Effect | +|-----------|-------|--------| +| **version** | 1-8 | QR density — higher = more modules = finer detail | +| **mask** | 0-7 | QR mask pattern — dramatically affects which pixels are controllable | +| **dx/dy** | -15 to 15 | Shifts image relative to QR structure (avoids alignment dot on eyes) | +| **rotation** | 0-3 | Quarter turns | +| **scale** | 1-16 | Output pixels per QR module | +| **dither** | on/off | Floyd-Steinberg dithering | + +## Credits + +- **Technique:** [Russ Cox](https://swtch.com/~rsc/), [QArt Codes](https://research.swtch.com/qart) (2012) +- **QR library:** [rsc.io/qr](https://github.com/rsc/qr) — QR layout, encoding, GF(256) arithmetic +- **Implementation:** Claude Code (Opus 4.6) with direction from Erich Blume + +## Related + +- [[mise-tasks]] — Task runner for BlumeOps operations diff --git a/utils/qart/.gitignore b/utils/qart/.gitignore new file mode 100644 index 0000000..7b4f4ae --- /dev/null +++ b/utils/qart/.gitignore @@ -0,0 +1,3 @@ +qart +qart-gen +*.png diff --git a/utils/qart/README.md b/utils/qart/README.md new file mode 100644 index 0000000..459f65e --- /dev/null +++ b/utils/qart/README.md @@ -0,0 +1,83 @@ +# QArt Tuner + +Generate QR codes whose data modules form a recognizable image. + +This implements the [QArt technique](https://research.swtch.com/qart) invented +by [Russ Cox](https://swtch.com/~rsc/). The trick: QR error correction gives +some freedom in choosing bit values. By picking bits that satisfy the +Reed-Solomon constraints *and* match a target image's brightness, the QR +modules themselves draw a picture — no logo overlay, no center cutout. + +This tool uses the [rsc.io/qr](https://github.com/rsc/qr) library (BSD +3-clause) for QR layout, data encoding, and GF(256) arithmetic. The +image-targeting algorithm — contrast-priority bit selection via GF(2) Gaussian +elimination — is an original implementation based on the technique description +in Russ Cox's blog post. + +## Quick start + +```fish +# Launch the interactive web UI +QART_IMAGE=~/path/to/photo.png mise run serve + +# Or with a custom URL and port +QART_URL=https://example.com QART_IMAGE=photo.png QART_PORT=9090 mise run serve +``` + +The web UI lets you adjust version, mask, rotation, x/y offset, and scale with +live preview. Keyboard shortcuts: arrow keys (dx/dy), `[`/`]` (mask), `-`/`=` +(version), `r` (rotate). + +## CLI usage + +```fish +# Single image +mise x go -- go run . -url "https://docs.eblu.me" -image photo.png -out qart.png \ + -version 6 -mask 4 -dx 6 -dy 4 -scale 8 + +# All 8 mask variants +mise x go -- go run . -url "https://docs.eblu.me" -image photo.png -out qart.png -all-masks +``` + +### Flags + +| Flag | Default | Description | +|------|---------|-------------| +| `-url` | (required) | URL to encode in the QR code | +| `-image` | (required) | Source photo (PNG or JPEG) | +| `-out` | `qart.png` | Output file path | +| `-version` | `6` | QR version (1-8, higher = more modules = more detail) | +| `-mask` | `0` | QR mask pattern (0-7, affects visual texture) | +| `-scale` | `8` | Pixels per QR module | +| `-rotation` | `0` | Quarter turns (0-3) | +| `-dx` | `0` | Horizontal image offset (-15 to 15) | +| `-dy` | `0` | Vertical image offset (-15 to 15) | +| `-dither` | `false` | Enable Floyd-Steinberg dithering | +| `-seed` | (random) | RNG seed for reproducible output | +| `-all-masks` | `false` | Generate all 8 mask variants | +| `-serve` | `false` | Launch web UI instead of writing a file | +| `-port` | `8088` | Web UI port | + +## Tips + +- **Version** controls QR density. Higher = more modules = finer image detail, + but the code becomes harder to scan at small sizes. +- **Mask** dramatically affects which pixels the algorithm can control. Try all + 8 — the best one varies per image. +- **dx/dy offsets** shift the image relative to the QR structure. Use this to + avoid the central alignment dot landing on an eye (it makes you look + unhinged). +- The QR code uses **error correction level L** (lowest) to maximize the number + of bits available for image rendering. + +## Credits + +The QArt technique was invented by Russ Cox and described in his 2012 blog +post [QArt Codes](https://research.swtch.com/qart). The +[rsc.io/qr](https://github.com/rsc/qr) library provides the QR code +primitives this tool builds on. Thank you, Russ. + +This tool was written by [Claude Code](https://claude.ai/claude-code) (Opus +4.6) with direction from Erich Blume. The image-targeting algorithm is an +original implementation based on the technique description — not a copy of +rsc's reference implementation. diff --git a/utils/qart/go.mod b/utils/qart/go.mod new file mode 100644 index 0000000..51c6481 --- /dev/null +++ b/utils/qart/go.mod @@ -0,0 +1,5 @@ +module qart + +go 1.25.7 + +require rsc.io/qr v0.2.0 // indirect diff --git a/utils/qart/go.sum b/utils/qart/go.sum new file mode 100644 index 0000000..19a61d9 --- /dev/null +++ b/utils/qart/go.sum @@ -0,0 +1,2 @@ +rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= +rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= diff --git a/utils/qart/main.go b/utils/qart/main.go new file mode 100644 index 0000000..103fe72 --- /dev/null +++ b/utils/qart/main.go @@ -0,0 +1,753 @@ +// QArt Tuner — generates QR codes whose data modules form a recognizable image. +// +// This tool implements the QArt technique described by Russ Cox at +// https://research.swtch.com/qart. The technique exploits QR error correction: +// by choosing data/check bit values that satisfy the Reed-Solomon constraints +// while also matching a target image's brightness, the QR modules themselves +// draw a picture. +// +// This implementation uses the rsc.io/qr library for QR layout, encoding, and +// GF(256) arithmetic. The image-targeting algorithm (bit selection via GF(2) +// Gaussian elimination, contrast-priority ordering, and image preprocessing) +// is an original implementation based on the technique description. +// +// Written by Claude Code (Opus 4.6) with direction from Erich Blume. +package main + +import ( + "bytes" + "flag" + "fmt" + "image" + _ "image/jpeg" + _ "image/png" + "math/rand" + "net/http" + "os" + "os/exec" + "runtime" + "sort" + "strconv" + "time" + + "rsc.io/qr" + "rsc.io/qr/coding" + "rsc.io/qr/gf256" +) + +// --------------------------------------------------------------------------- +// CLI & web server +// --------------------------------------------------------------------------- + +func main() { + var ( + url = flag.String("url", "", "URL to encode") + imgPath = flag.String("image", "", "path to source image") + outPath = flag.String("out", "qart.png", "output PNG path") + version = flag.Int("version", 6, "QR version (1-8)") + mask = flag.Int("mask", 0, "QR mask pattern (0-7)") + scale = flag.Int("scale", 8, "pixel scale factor") + dither = flag.Bool("dither", false, "use dithering") + rotation = flag.Int("rotation", 0, "rotation (0-3, quarter turns)") + dx = flag.Int("dx", 0, "image X offset (positive = shift right)") + dy = flag.Int("dy", 0, "image Y offset (positive = shift down)") + seed = flag.Int64("seed", 0, "random seed (0 = use time)") + allMasks = flag.Bool("all-masks", false, "generate all 8 mask variants") + serve = flag.Bool("serve", false, "start web UI for interactive tuning") + port = flag.Int("port", 8088, "port for web UI") + ) + flag.Parse() + + if *url == "" || *imgPath == "" { + fmt.Fprintf(os.Stderr, "usage: qart-gen -url URL -image IMAGE [-out OUTPUT] [-serve]\n") + os.Exit(1) + } + + imgData, err := os.ReadFile(*imgPath) + if err != nil { + fmt.Fprintf(os.Stderr, "reading image: %v\n", err) + os.Exit(1) + } + + if *serve { + startServer(imgData, *url, *port) + return + } + + pickSeed := func() int64 { + if *seed != 0 { + return *seed + } + return time.Now().UnixNano() + } + + if *allMasks { + for m := 0; m < 8; m++ { + out := fmt.Sprintf("%s_mask%d.png", (*outPath)[:len(*outPath)-4], m) + pngBytes, err := renderQArt(imgData, *url, *version, m, *scale, *rotation, *dx, *dy, *dither, pickSeed()) + if err != nil { + fmt.Fprintf(os.Stderr, "mask %d: %v\n", m, err) + continue + } + os.WriteFile(out, pngBytes, 0644) + fmt.Printf("wrote %s\n", out) + } + } else { + pngBytes, err := renderQArt(imgData, *url, *version, *mask, *scale, *rotation, *dx, *dy, *dither, pickSeed()) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + os.WriteFile(*outPath, pngBytes, 0644) + fmt.Printf("wrote %s\n", *outPath) + } +} + +func intParam(r *http.Request, name string, def int) int { + v := r.URL.Query().Get(name) + if v == "" { + return def + } + n, err := strconv.Atoi(v) + if err != nil { + return def + } + return n +} + +func startServer(imgData []byte, url string, port int) { + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.Write([]byte(indexHTML)) + }) + + renderHandler := func(w http.ResponseWriter, r *http.Request, download bool) { + version := intParam(r, "version", 6) + mask := intParam(r, "mask", 0) + scale := intParam(r, "scale", 8) + rotation := intParam(r, "rotation", 0) + dx := intParam(r, "dx", 0) + dy := intParam(r, "dy", 0) + dither := r.URL.Query().Get("dither") == "1" + seed := int64(intParam(r, "seed", 0)) + if seed == 0 { + seed = time.Now().UnixNano() + } + + pngData, err := renderQArt(imgData, url, version, mask, scale, rotation, dx, dy, dither, seed) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + + w.Header().Set("Content-Type", "image/png") + if download { + w.Header().Set("Content-Disposition", + fmt.Sprintf("attachment; filename=qart_v%d_m%d_dx%d_dy%d.png", version, mask, dx, dy)) + } else { + w.Header().Set("Cache-Control", "no-cache") + } + w.Write(pngData) + } + + http.HandleFunc("/generate", func(w http.ResponseWriter, r *http.Request) { + renderHandler(w, r, false) + }) + http.HandleFunc("/save", func(w http.ResponseWriter, r *http.Request) { + renderHandler(w, r, true) + }) + + addr := fmt.Sprintf("localhost:%d", port) + fmt.Printf("QArt tuner running at http://%s\n", addr) + if runtime.GOOS == "darwin" { + exec.Command("open", "http://"+addr).Start() + } + if err := http.ListenAndServe(addr, nil); err != nil { + fmt.Fprintf(os.Stderr, "server error: %v\n", err) + os.Exit(1) + } +} + +// --------------------------------------------------------------------------- +// QArt rendering — original implementation of the technique from +// https://research.swtch.com/qart using only the public rsc.io/qr API. +// --------------------------------------------------------------------------- + +// renderQArt produces a PNG of a QR code encoding url whose data modules +// approximate the brightness pattern of the source image. +func renderQArt(imgData []byte, url string, version, mask, scale, rotation, dx, dy int, dither bool, seed int64) ([]byte, error) { + if version > 8 { + version = 8 + } + if scale < 1 { + scale = 8 + } + + // Build the QR plan — this tells us where every pixel goes and its role. + plan, err := coding.NewPlan(coding.Version(version), coding.L, coding.Mask(mask)) + if err != nil { + return nil, err + } + + rotatePixels(plan, rotation) + + // Build the grayscale target image scaled to QR module grid size. + gridSize := 17 + 4*version + target, err := imageToTarget(imgData, gridSize) + if err != nil { + return nil, err + } + + // Encode the URL into QR data, filling remaining capacity with numeric + // padding that we can freely manipulate. + urlStr := url + "#" + var bits coding.Bits + coding.String(urlStr).Encode(&bits, plan.Version) + coding.Num("").Encode(&bits, plan.Version) + fixedBits := bits.Bits() + freeBits := plan.DataBytes*8 - fixedBits + if freeBits < 0 { + return nil, fmt.Errorf("URL too long for QR version %d", version) + } + + // Fill free space with numeric '0's — 10 bits per 3 digits. + numDigits := freeBits / 10 * 3 + num := make([]byte, numDigits) + for i := range num { + num[i] = '0' + } + + rng := rand.New(rand.NewSource(seed)) + + // Iterate: encode, manipulate bits to match image, extract numeric + // digits back out, re-encode. Loop if any 10-bit group >= 1000. + type pixInfo struct { + x, y int + pix coding.Pixel + targ byte // target brightness (0=black, 255=white) + contrast int // local contrast (variance); higher = more visually important + hardZero bool + } + + var hardZeros map[int]bool + + for attempt := 0; attempt < 10; attempt++ { + bits.Pad(freeBits) + bits.Reset() + coding.String(urlStr).Encode(&bits, plan.Version) + coding.Num(coding.Num(num)).Encode(&bits, plan.Version) + bits.AddCheckBytes(plan.Version, plan.Level) + data := bits.Bytes() + + // Index every data/check pixel by its bit offset in the codeword stream. + totalBits := (plan.DataBytes + plan.CheckBytes) * 8 + pixByOff := make([]pixInfo, totalBits) + for y, row := range plan.Pixel { + for x, pix := range row { + role := pix.Role() + if role != coding.Data && role != coding.Check { + continue + } + t, c := targetAt(target, x+dx, y+dy) + if c >= 0 { + c = c<<8 | rng.Intn(256) + } + pi := pixInfo{x: x, y: y, pix: pix, targ: t, contrast: c} + if hardZeros[int(pix.Offset())] { + pi.hardZero = true + pi.contrast = 1<<30 | rng.Intn(256) + } + pixByOff[pix.Offset()] = pi + } + } + + // Process each ECC block independently. + ndBase := plan.DataBytes / plan.Blocks + nc := plan.CheckBytes / plan.Blocks + extra := plan.DataBytes - ndBase*plan.Blocks + rs := gf256.NewRSEncoder(coding.Field, nc) + usableBits := fixedBits + freeBits/10*10 + + doff, coff := 0, 0 + for block := 0; block < plan.Blocks; block++ { + nd := ndBase + if block >= plan.Blocks-extra { + nd++ + } + bdata := data[doff/8 : doff/8+nd] + cdata := data[plan.DataBytes+coff/8 : plan.DataBytes+coff/8+nc] + + bb := newBitBlock(nd, nc, rs, bdata, cdata) + + // Determine the editable bit range within this block's data bytes. + lo, hi := 0, nd*8 + if fixedBits-doff > lo { + lo = fixedBits - doff + } + if lo > hi { + lo = hi + } + if usableBits-doff < hi { + hi = usableBits - doff + } + if hi < lo { + hi = lo + } + + // Lock the preserved bits. + for i := 0; i < lo; i++ { + bb.fix(uint(i), (bdata[i/8]>>uint(7-i&7))&1) + } + for i := hi; i < nd*8; i++ { + bb.fix(uint(i), (bdata[i/8]>>uint(7-i&7))&1) + } + + // Collect editable bits, sorted by visual importance. + type candidate struct { + globalOff int + priority int + } + candidates := make([]candidate, 0, (hi-lo)+nc*8) + for i := lo; i < hi; i++ { + candidates = append(candidates, candidate{doff + i, pixByOff[doff+i].contrast}) + } + for i := 0; i < nc*8; i++ { + candidates = append(candidates, candidate{plan.DataBytes*8 + coff + i, pixByOff[plan.DataBytes*8+coff+i].contrast}) + } + sort.Slice(candidates, func(i, j int) bool { + return candidates[i].priority > candidates[j].priority + }) + + // Try to set each bit to match target brightness. + for _, cand := range candidates { + pi := &pixByOff[cand.globalOff] + desired := byte(1) // dark target → black pixel + if pi.targ >= 128 { + desired = 0 + } + if pi.pix&coding.Invert != 0 { + desired ^= 1 + } + if pi.hardZero { + desired = 0 + } + + var localBit int + if pi.pix.Role() == coding.Data { + localBit = cand.globalOff - doff + } else { + localBit = cand.globalOff - plan.DataBytes*8 - coff + nd*8 + } + bb.trySet(uint(localBit), desired) + } + + bb.writeback() + doff += nd * 8 + coff += nc * 8 + } + + // Extract numeric digits back from the modified data stream. + overflow := false + for i := 0; i < freeBits/10; i++ { + v := 0 + for j := 0; j < 10; j++ { + bi := uint(fixedBits + 10*i + j) + v = v<<1 | int((data[bi/8]>>(7-bi&7))&1) + } + if v >= 1000 { + if hardZeros == nil { + hardZeros = make(map[int]bool) + } + hardZeros[fixedBits+10*i+3] = true + overflow = true + } + num[i*3+0] = byte(v/100 + '0') + num[i*3+1] = byte(v/10%10 + '0') + num[i*3+2] = byte(v%10 + '0') + } + if overflow { + continue + } + + // Final encode with the settled numeric digits. + code, err := plan.Encode(coding.String(urlStr), coding.Num(coding.Num(num))) + if err != nil { + return nil, err + } + + qrCode := &qr.Code{Bitmap: code.Bitmap, Size: code.Size, Stride: code.Stride, Scale: scale} + return qrCode.PNG(), nil + } + + return nil, fmt.Errorf("could not settle numeric encoding after retries") +} + +// --------------------------------------------------------------------------- +// BitBlock — GF(2) linear system for choosing ECC-consistent bit values. +// +// A QR ECC block has nd data bytes and nc check bytes related by Reed-Solomon +// coding. Flipping a data bit deterministically changes certain check bits. +// We model this as a matrix over GF(2): each data bit has a row showing which +// bytes change when it's toggled. Gaussian elimination lets us find a set of +// independent bits we can freely assign while keeping the block valid. +// --------------------------------------------------------------------------- + +type bitBlock struct { + nd, nc int + buf []byte // current data+check bytes (nd+nc) + rows [][]byte // unconsumed basis rows (Gaussian elimination workspace) + used [][]byte // rows that have been consumed (for reset) + rs *gf256.RSEncoder + bdata []byte // slice into the original data array + cdata []byte // slice into the original check array +} + +func newBitBlock(nd, nc int, rs *gf256.RSEncoder, bdata, cdata []byte) *bitBlock { + bb := &bitBlock{ + nd: nd, + nc: nc, + buf: make([]byte, nd+nc), + rows: make([][]byte, 0, nd*8), + rs: rs, + bdata: bdata, + cdata: cdata, + } + + // Initialize buf with current data and compute its check bytes. + copy(bb.buf, bdata) + rs.ECC(bb.buf[:nd], bb.buf[nd:]) + + // Build the basis matrix: for each data bit, compute the effect of + // toggling that bit on the full data+check byte vector. + for i := 0; i < nd*8; i++ { + row := make([]byte, nd+nc) + row[i/8] = 1 << (7 - uint(i%8)) + rs.ECC(row[:nd], row[nd:]) + bb.rows = append(bb.rows, row) + } + + return bb +} + +// fix locks a data bit to a specific value, consuming one degree of freedom. +func (bb *bitBlock) fix(bit uint, val byte) { + bb.trySet(bit, val) +} + +// trySet attempts to set a bit to val using Gaussian elimination. +// Finds a row with a 1 in the target column, eliminates that column from all +// other rows, then applies the row to buf if needed. +func (bb *bitBlock) trySet(bit uint, val byte) bool { + byteIdx, bitMask := bit/8, byte(1<<(7-bit&7)) + + // Find a row with a 1 in this column. + found := -1 + for i, row := range bb.rows { + if row[byteIdx]&bitMask != 0 { + found = i + break + } + } + if found < 0 { + return (bb.buf[byteIdx]>>(7-bit&7))&1 == val + } + + // Move pivot to front. + bb.rows[0], bb.rows[found] = bb.rows[found], bb.rows[0] + pivot := bb.rows[0] + + // Eliminate this column from all other rows. + for _, row := range bb.rows[1:] { + if row[byteIdx]&bitMask != 0 { + xorBytes(row, pivot) + } + } + for _, row := range bb.used { + if row[byteIdx]&bitMask != 0 { + xorBytes(row, pivot) + } + } + + // Apply if needed. + if (bb.buf[byteIdx]>>(7-bit&7))&1 != val { + xorBytes(bb.buf, pivot) + } + + // Consume the pivot. + bb.used = append(bb.used, pivot) + bb.rows = bb.rows[1:] + return true +} + +// writeback copies solved bytes back to the original arrays. +func (bb *bitBlock) writeback() { + copy(bb.bdata, bb.buf[:bb.nd]) + copy(bb.cdata, bb.buf[bb.nd:]) +} + +func xorBytes(dst, src []byte) { + for i := range dst { + dst[i] ^= src[i] + } +} + +// --------------------------------------------------------------------------- +// Image processing +// --------------------------------------------------------------------------- + +// imageToTarget decodes an image and converts it to a grayscale grid at the +// given resolution, returning brightness values (0-255, -1 for transparent). +func imageToTarget(data []byte, size int) ([][]int, error) { + src, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + return nil, err + } + + b := src.Bounds() + tw, th := size, size + if b.Dx() > b.Dy() { + th = b.Dy() * tw / b.Dx() + } else { + tw = b.Dx() * th / b.Dy() + } + + grid := make([][]int, size) + for y := range grid { + row := make([]int, size) + for x := range row { + row[x] = -1 + } + grid[y] = row + } + + for y := 0; y < th; y++ { + for x := 0; x < tw; x++ { + sx := b.Min.X + x*b.Dx()/tw + sy := b.Min.Y + y*b.Dy()/th + r, g, bl, a := src.At(sx, sy).RGBA() + if a == 0 { + continue + } + lum := (299*uint32(r>>8) + 587*uint32(g>>8) + 114*uint32(bl>>8) + 500) / 1000 + grid[y][x] = int(lum) + } + } + return grid, nil +} + +// targetAt returns brightness and local contrast for a pixel. +func targetAt(target [][]int, x, y int) (brightness byte, contrast int) { + if y < 0 || y >= len(target) || x < 0 || x >= len(target[y]) { + return 255, -1 + } + v := target[y][x] + if v < 0 { + return 255, -1 + } + + n, sum, sumsq := 0, 0, 0 + const radius = 5 + for dy := -radius; dy <= radius; dy++ { + for dx := -radius; dx <= radius; dx++ { + ny, nx := y+dy, x+dx + if ny >= 0 && ny < len(target) && nx >= 0 && nx < len(target[ny]) && target[ny][nx] >= 0 { + val := target[ny][nx] + sum += val + sumsq += val * val + n++ + } + } + } + if n == 0 { + return byte(v), 0 + } + avg := sum / n + return byte(v), sumsq/n - avg*avg +} + +// rotatePixels rotates the QR plan's pixel grid by rot quarter turns. +func rotatePixels(plan *coding.Plan, rot int) { + rot = rot % 4 + if rot == 0 { + return + } + n := len(plan.Pixel) + dst := make([][]coding.Pixel, n) + for i := range dst { + dst[i] = make([]coding.Pixel, n) + } + for y := 0; y < n; y++ { + for x := 0; x < n; x++ { + switch rot { + case 1: + dst[y][x] = plan.Pixel[x][n-1-y] + case 2: + dst[y][x] = plan.Pixel[n-1-y][n-1-x] + case 3: + dst[y][x] = plan.Pixel[n-1-x][y] + } + } + } + plan.Pixel = dst +} + +// --------------------------------------------------------------------------- +// Embedded web UI +// --------------------------------------------------------------------------- + +const indexHTML = `<!DOCTYPE html> +<html> +<head> +<meta charset="utf-8"> +<title>QArt Tuner + + + +
+

QArt Tuner

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+
+
+
+ QArt code +
+ + +` diff --git a/utils/qart/mise.toml b/utils/qart/mise.toml new file mode 100644 index 0000000..c173ea5 --- /dev/null +++ b/utils/qart/mise.toml @@ -0,0 +1,11 @@ +[tools] +go = "1.25" + +[tasks.serve] +description = "Build and launch the QArt Tuner web UI" +run = """ +go run . \ + -url "${QART_URL:-https://docs.eblu.me}" \ + -image "${QART_IMAGE:?Set QART_IMAGE to path of source photo}" \ + -serve -port "${QART_PORT:-8088}" +""" From ca0c9354ee918124819fd4decefd1d3bb20a26c9 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Fri, 27 Mar 2026 16:59:58 -0700 Subject: [PATCH 143/430] Add borgmatic backups for authentik and immich databases (#314) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Add `authentik` database (blumeops-pg cluster) to borgmatic pg_dump backups - Add `immich` database (immich-pg cluster) to borgmatic pg_dump backups - For immich-pg: new borgmatic managed role with `pg_read_all_data`, ExternalSecret, Tailscale LoadBalancer service, and Caddy L4 TCP proxy on port 5433 - Update backup docs to reflect all four CNPG databases + mealie SQLite ## Deploy plan Deploy order matters — k8s resources must exist before ansible can route to them: 1. **ArgoCD (databases app):** sync to pick up immich-pg borgmatic role, ExternalSecret, and Tailscale service ``` argocd app set blumeops-pg --revision feature/borgmatic-all-pg-backups argocd app sync blumeops-pg ``` 2. **Wait** for `immich-pg-tailscale` service to get a Tailscale IP and `immich-pg.tail8d86e.ts.net` to resolve 3. **Ansible (caddy):** deploy Caddy L4 route for port 5433 ``` mise run provision-indri -- --tags caddy ``` 4. **Ansible (borgmatic):** deploy updated config and .pgpass ``` mise run provision-indri -- --tags borgmatic ``` 5. **Verify:** trigger a manual borgmatic run and check all four pg_dump streams succeed ``` borgmatic --verbosity 1 2>&1 | grep -E '(Dumping|ERROR)' ``` ## Test plan - [x] `kubectl kustomize` builds cleanly - [x] `ansible --check --diff` for borgmatic and caddy show expected changes - [ ] ArgoCD sync succeeds for databases app - [ ] `immich-pg.tail8d86e.ts.net` resolves - [ ] `pg.ops.eblu.me:5433` accepts connections - [ ] `borgmatic --verbosity 1` dumps all four databases without errors Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/314 --- ansible/roles/borgmatic/defaults/main.yml | 9 ++++++ ansible/roles/borgmatic/tasks/main.yml | 1 + ansible/roles/caddy/defaults/main.yml | 4 ++- .../external-secret-immich-borgmatic.yaml | 29 +++++++++++++++++++ argocd/manifests/databases/immich-pg.yaml | 15 ++++++++++ argocd/manifests/databases/kustomization.yaml | 2 ++ .../service-immich-pg-tailscale.yaml | 22 ++++++++++++++ .../feature-borgmatic-all-pg-backups.infra.md | 1 + docs/reference/storage/backups.md | 13 +++++---- 9 files changed, 90 insertions(+), 6 deletions(-) create mode 100644 argocd/manifests/databases/external-secret-immich-borgmatic.yaml create mode 100644 argocd/manifests/databases/service-immich-pg-tailscale.yaml create mode 100644 docs/changelog.d/feature-borgmatic-all-pg-backups.infra.md diff --git a/ansible/roles/borgmatic/defaults/main.yml b/ansible/roles/borgmatic/defaults/main.yml index 428f21e..93b02ba 100644 --- a/ansible/roles/borgmatic/defaults/main.yml +++ b/ansible/roles/borgmatic/defaults/main.yml @@ -70,3 +70,12 @@ borgmatic_postgresql_databases: hostname: pg.ops.eblu.me port: 5432 username: borgmatic + - name: authentik + hostname: pg.ops.eblu.me + port: 5432 + username: borgmatic + # immich-pg cluster (VectorChord) via Caddy L4 on port 5433 + - name: immich + hostname: pg.ops.eblu.me + port: 5433 + username: borgmatic diff --git a/ansible/roles/borgmatic/tasks/main.yml b/ansible/roles/borgmatic/tasks/main.yml index a4b1d7b..e7380dd 100644 --- a/ansible/roles/borgmatic/tasks/main.yml +++ b/ansible/roles/borgmatic/tasks/main.yml @@ -15,6 +15,7 @@ content: | # Managed by ansible (borgmatic role) - k8s PostgreSQL backup credentials pg.ops.eblu.me:5432:*:borgmatic:{{ borgmatic_db_password }} + pg.ops.eblu.me:5433:*:borgmatic:{{ borgmatic_db_password }} dest: ~/.pgpass mode: '0600' no_log: true diff --git a/ansible/roles/caddy/defaults/main.yml b/ansible/roles/caddy/defaults/main.yml index 6b1ec61..784738f 100644 --- a/ansible/roles/caddy/defaults/main.yml +++ b/ansible/roles/caddy/defaults/main.yml @@ -101,7 +101,9 @@ caddy_tcp_services: - port: 2222 backend: "localhost:2200" # Forgejo SSH - port: 5432 - backend: "pg.tail8d86e.ts.net:5432" # PostgreSQL + backend: "pg.tail8d86e.ts.net:5432" # PostgreSQL (blumeops-pg) + - port: 5433 + backend: "immich-pg.tail8d86e.ts.net:5432" # PostgreSQL (immich-pg) - port: "{{ sifaka_node_exporter_port }}" backend: "sifaka:{{ sifaka_node_exporter_port }}" # Sifaka node_exporter - port: "{{ sifaka_smartctl_exporter_port }}" diff --git a/argocd/manifests/databases/external-secret-immich-borgmatic.yaml b/argocd/manifests/databases/external-secret-immich-borgmatic.yaml new file mode 100644 index 0000000..8801c1a --- /dev/null +++ b/argocd/manifests/databases/external-secret-immich-borgmatic.yaml @@ -0,0 +1,29 @@ +# ExternalSecret for borgmatic backup user password on immich-pg cluster +# +# Reuses the same 1Password item as blumeops-pg-borgmatic. +# 1Password item: "borgmatic" in blumeops vault +# Field: "db-password" +# +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: immich-pg-borgmatic + namespace: databases +spec: + refreshInterval: 1h + secretStoreRef: + kind: ClusterSecretStore + name: onepassword-blumeops + target: + name: immich-pg-borgmatic + creationPolicy: Owner + template: + type: kubernetes.io/basic-auth + data: + username: borgmatic + password: "{{ .password }}" + data: + - secretKey: password + remoteRef: + key: borgmatic + property: db-password diff --git a/argocd/manifests/databases/immich-pg.yaml b/argocd/manifests/databases/immich-pg.yaml index ec60387..74c6f4e 100644 --- a/argocd/manifests/databases/immich-pg.yaml +++ b/argocd/manifests/databases/immich-pg.yaml @@ -30,6 +30,21 @@ spec: - CREATE EXTENSION IF NOT EXISTS cube CASCADE; - CREATE EXTENSION IF NOT EXISTS earthdistance CASCADE; + # Managed roles + # Note: connectionLimit, ensure, inherit are CNPG defaults added to prevent ArgoCD drift + managed: + roles: + # borgmatic read-only user for backups + - name: borgmatic + login: true + connectionLimit: -1 + ensure: present + inherit: true + inRoles: + - pg_read_all_data + passwordSecret: + name: immich-pg-borgmatic + # Resource limits for minikube environment resources: requests: diff --git a/argocd/manifests/databases/kustomization.yaml b/argocd/manifests/databases/kustomization.yaml index 8c4f506..68d28b2 100644 --- a/argocd/manifests/databases/kustomization.yaml +++ b/argocd/manifests/databases/kustomization.yaml @@ -7,8 +7,10 @@ resources: - blumeops-pg.yaml - immich-pg.yaml - service-tailscale.yaml + - service-immich-pg-tailscale.yaml - service-metrics-tailscale.yaml - external-secret-eblume.yaml - external-secret-borgmatic.yaml + - external-secret-immich-borgmatic.yaml - external-secret-teslamate.yaml - external-secret-authentik.yaml diff --git a/argocd/manifests/databases/service-immich-pg-tailscale.yaml b/argocd/manifests/databases/service-immich-pg-tailscale.yaml new file mode 100644 index 0000000..78891dd --- /dev/null +++ b/argocd/manifests/databases/service-immich-pg-tailscale.yaml @@ -0,0 +1,22 @@ +# Tailscale LoadBalancer for immich-pg PostgreSQL access +# Canonical hostname: immich-pg.tail8d86e.ts.net +# Caddy L4 proxies pg.ops.eblu.me:5433 → this service for borgmatic backups +apiVersion: v1 +kind: Service +metadata: + name: immich-pg-tailscale + namespace: databases + annotations: + tailscale.com/hostname: "immich-pg" + tailscale.com/proxy-class: "default" +spec: + type: LoadBalancer + loadBalancerClass: tailscale + selector: + cnpg.io/cluster: immich-pg + role: primary + ports: + - name: postgresql + port: 5432 + targetPort: 5432 + protocol: TCP diff --git a/docs/changelog.d/feature-borgmatic-all-pg-backups.infra.md b/docs/changelog.d/feature-borgmatic-all-pg-backups.infra.md new file mode 100644 index 0000000..892ee65 --- /dev/null +++ b/docs/changelog.d/feature-borgmatic-all-pg-backups.infra.md @@ -0,0 +1 @@ +Add borgmatic pg_dump backups for authentik and immich databases. Authentik uses the existing blumeops-pg cluster on port 5432. Immich requires a new borgmatic role on the immich-pg cluster, a Tailscale service, and Caddy L4 proxy on port 5433. diff --git a/docs/reference/storage/backups.md b/docs/reference/storage/backups.md index 7061d2c..e830822 100644 --- a/docs/reference/storage/backups.md +++ b/docs/reference/storage/backups.md @@ -1,6 +1,6 @@ --- title: Backups -modified: 2026-03-15 +modified: 2026-03-27 tags: - storage - backup @@ -29,10 +29,13 @@ Daily automated backups from [[indri]] to [[sifaka|Sifaka]] NAS. ### Databases -| Database | Host | Method | -|----------|------|--------| -| miniflux | [[postgresql|pg.ops.eblu.me]] | pg_dump stream | -| teslamate | [[postgresql|pg.ops.eblu.me]] | pg_dump stream | +| Database | Cluster | Host | Method | +|----------|---------|------|--------| +| miniflux | blumeops-pg | [[postgresql|pg.ops.eblu.me:5432]] | pg_dump stream | +| teslamate | blumeops-pg | [[postgresql|pg.ops.eblu.me:5432]] | pg_dump stream | +| authentik | blumeops-pg | [[postgresql|pg.ops.eblu.me:5432]] | pg_dump stream | +| immich | immich-pg | [[postgresql|pg.ops.eblu.me:5433]] | pg_dump stream | +| mealie | — (SQLite) | k8s pod | kubectl exec sqlite3 .backup | ## Sifaka-Native Data From c78b86c72ce2b1ad0c2cfbfff8147ba2254877d7 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Fri, 27 Mar 2026 19:43:05 -0700 Subject: [PATCH 144/430] Add offsite backup for immich photo library to BorgBase (#315) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Adds a second borgmatic config (`photos.yaml`) that backs up `/Volumes/photos` (sifaka SMB mount, ~128 GB) to a dedicated BorgBase repo (`immich-photos`), running daily at 4 AM - Separate launchd agent (`mcquack.eblume.borgmatic-photos`) so photo backups run independently from the main backup - Refactors `borgmatic_metrics` script to support multiple repos with a `repo` Prometheus label - Updates Grafana "Borg Backups" dashboard with a `repo` template variable so you can filter/compare repos - Docs updated: `backups.md`, `borgmatic.md` ## Prerequisites (manual) - [x] Create `immich-photos` repo on BorgBase with same SSH key - [ ] Upgrade BorgBase plan to Small ($24/yr) if currently on free tier (128 GB exceeds 10 GB limit) - [ ] After deploy: `borg init` the new repo (borgmatic does this automatically on first run) ## Test plan - [ ] Dry run: `mise run provision-indri -- --check --diff --tags borgmatic,borgmatic_metrics` - [ ] Deploy borgmatic role and verify both configs deployed - [ ] Run `borgmatic --config ~/.config/borgmatic/photos.yaml create --verbosity 1` manually for first backup (will take hours) - [ ] Verify metrics script collects from both repos: `~/.local/bin/borgmatic-metrics && cat /opt/homebrew/var/node_exporter/textfile/borgmatic.prom` - [ ] Sync grafana-config in ArgoCD and verify dashboard repo selector works 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/315 --- ansible/roles/borgmatic/defaults/main.yml | 12 ++ ansible/roles/borgmatic/handlers/main.yml | 6 + ansible/roles/borgmatic/tasks/main.yml | 36 +++- .../templates/borgmatic-photos.plist.j2 | 39 ++++ .../roles/borgmatic/templates/photos.yaml.j2 | 29 +++ .../roles/borgmatic_metrics/defaults/main.yml | 10 +- .../templates/borgmatic-metrics.sh.j2 | 180 ++++++++---------- .../dashboards/configmap-borgmatic.yaml | 93 ++++++--- .../immich-photos-backup.feature.md | 1 + docs/reference/services/borgmatic.md | 12 +- docs/reference/storage/backups.md | 25 ++- 11 files changed, 306 insertions(+), 137 deletions(-) create mode 100644 ansible/roles/borgmatic/templates/borgmatic-photos.plist.j2 create mode 100644 ansible/roles/borgmatic/templates/photos.yaml.j2 create mode 100644 docs/changelog.d/immich-photos-backup.feature.md diff --git a/ansible/roles/borgmatic/defaults/main.yml b/ansible/roles/borgmatic/defaults/main.yml index 93b02ba..c7a9793 100644 --- a/ansible/roles/borgmatic/defaults/main.yml +++ b/ansible/roles/borgmatic/defaults/main.yml @@ -59,6 +59,18 @@ borgmatic_keep_yearly: 1000 # PostgreSQL databases to backup (streamed via pg_dump) # Password is read from ~/.pgpass (managed by this role) # pg_dump_command must be full path since LaunchAgent doesn't have homebrew in PATH +# --- Immich photo library backup (BorgBase offsite only) --- +borgmatic_photos_config: /Users/erichblume/.config/borgmatic/photos.yaml +borgmatic_photos_source_dir: /Volumes/photos +borgmatic_photos_borgbase_repo: ssh://xcrtl5tg@xcrtl5tg.repo.borgbase.com/./repo +# Schedule: runs daily at 4:00 AM (offset from main backup at 2:00 AM) +borgmatic_photos_schedule_hour: 4 +borgmatic_photos_schedule_minute: 0 +# Retention: photos are precious, keep more history +borgmatic_photos_keep_daily: 7 +borgmatic_photos_keep_monthly: 12 +borgmatic_photos_keep_yearly: 1000 + borgmatic_pg_dump_command: /opt/homebrew/opt/postgresql@18/bin/pg_dump borgmatic_postgresql_databases: # k8s PostgreSQL (CloudNativePG) via Caddy L4 proxy diff --git a/ansible/roles/borgmatic/handlers/main.yml b/ansible/roles/borgmatic/handlers/main.yml index 5fd6174..3463cce 100644 --- a/ansible/roles/borgmatic/handlers/main.yml +++ b/ansible/roles/borgmatic/handlers/main.yml @@ -4,3 +4,9 @@ launchctl unload ~/Library/LaunchAgents/mcquack.eblume.borgmatic.plist 2>/dev/null || true launchctl load ~/Library/LaunchAgents/mcquack.eblume.borgmatic.plist changed_when: true + +- name: Reload borgmatic-photos + ansible.builtin.shell: | + launchctl unload ~/Library/LaunchAgents/mcquack.eblume.borgmatic-photos.plist 2>/dev/null || true + launchctl load ~/Library/LaunchAgents/mcquack.eblume.borgmatic-photos.plist + changed_when: true diff --git a/ansible/roles/borgmatic/tasks/main.yml b/ansible/roles/borgmatic/tasks/main.yml index e7380dd..dd6efdd 100644 --- a/ansible/roles/borgmatic/tasks/main.yml +++ b/ansible/roles/borgmatic/tasks/main.yml @@ -28,11 +28,14 @@ mode: '0600' no_log: true -- name: Add BorgBase host key to known_hosts +- name: Add BorgBase host keys to known_hosts ansible.builtin.known_hosts: - name: u3ugi1x1.repo.borgbase.com - key: "u3ugi1x1.repo.borgbase.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGU0mISTyHBw9tBs6SuhSq8tvNM8m9eifQxM+88TowPO" + name: "{{ item }}" + key: "{{ item }} ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGU0mISTyHBw9tBs6SuhSq8tvNM8m9eifQxM+88TowPO" state: present + loop: + - u3ugi1x1.repo.borgbase.com + - xcrtl5tg.repo.borgbase.com - name: Ensure k8s dump directory exists ansible.builtin.file: @@ -65,3 +68,30 @@ when: borgmatic_launchctl_check.rc != 0 changed_when: true failed_when: false + +# --- Immich photo library backup (BorgBase offsite only) --- + +- name: Deploy borgmatic photos configuration + ansible.builtin.template: + src: photos.yaml.j2 + dest: "{{ borgmatic_photos_config }}" + mode: '0600' + +- name: Deploy borgmatic-photos LaunchAgent plist + ansible.builtin.template: + src: borgmatic-photos.plist.j2 + dest: ~/Library/LaunchAgents/mcquack.eblume.borgmatic-photos.plist + mode: '0644' + notify: Reload borgmatic-photos + +- name: Check if borgmatic-photos LaunchAgent is loaded + ansible.builtin.command: launchctl list mcquack.eblume.borgmatic-photos + register: borgmatic_photos_launchctl_check + changed_when: false + failed_when: false + +- name: Load borgmatic-photos LaunchAgent if not loaded + ansible.builtin.command: launchctl load ~/Library/LaunchAgents/mcquack.eblume.borgmatic-photos.plist + when: borgmatic_photos_launchctl_check.rc != 0 + changed_when: true + failed_when: false diff --git a/ansible/roles/borgmatic/templates/borgmatic-photos.plist.j2 b/ansible/roles/borgmatic/templates/borgmatic-photos.plist.j2 new file mode 100644 index 0000000..6e69159 --- /dev/null +++ b/ansible/roles/borgmatic/templates/borgmatic-photos.plist.j2 @@ -0,0 +1,39 @@ + + + + + + KeepAlive + + Label + mcquack.eblume.borgmatic-photos + EnvironmentVariables + + PATH + /opt/homebrew/bin:/usr/bin:/bin + + ProgramArguments + + /opt/homebrew/opt/mise/bin/mise + x + -- + borgmatic + --config + {{ borgmatic_photos_config }} + create + + RunAtLoad + + StandardErrorPath + {{ borgmatic_log_dir }}/mcquack.borgmatic-photos.err.log + StandardOutPath + {{ borgmatic_log_dir }}/mcquack.borgmatic-photos.out.log + StartCalendarInterval + + Hour + {{ borgmatic_photos_schedule_hour }} + Minute + {{ borgmatic_photos_schedule_minute }} + + + diff --git a/ansible/roles/borgmatic/templates/photos.yaml.j2 b/ansible/roles/borgmatic/templates/photos.yaml.j2 new file mode 100644 index 0000000..1c118df --- /dev/null +++ b/ansible/roles/borgmatic/templates/photos.yaml.j2 @@ -0,0 +1,29 @@ +# {{ ansible_managed }} +# +# Borgmatic config for immich photo library backup. +# Backs up /Volumes/photos (sifaka SMB mount) to BorgBase offsite ONLY. +# Separate from the main borgmatic config to keep concerns isolated: +# - main config: indri data → sifaka + borgbase +# - this config: sifaka photos → borgbase (different repo) + +local_path: {{ borgmatic_local_path }} + +source_directories: + - {{ borgmatic_photos_source_dir }} + +source_directories_must_exist: true + +repositories: + - path: {{ borgmatic_photos_borgbase_repo }} + label: borgbase-immich-photos + encryption: repokey + append_only: true + +encryption_passcommand: {{ borgmatic_encryption_passcommand }} + +ssh_command: ssh -o IdentitiesOnly=yes -i {{ borgmatic_borgbase_ssh_key_path }} + +# Retention policy — photos are precious, keep more history +keep_daily: {{ borgmatic_photos_keep_daily }} +keep_monthly: {{ borgmatic_photos_keep_monthly }} +keep_yearly: {{ borgmatic_photos_keep_yearly }} diff --git a/ansible/roles/borgmatic_metrics/defaults/main.yml b/ansible/roles/borgmatic_metrics/defaults/main.yml index c8207ba..8fcd91e 100644 --- a/ansible/roles/borgmatic_metrics/defaults/main.yml +++ b/ansible/roles/borgmatic_metrics/defaults/main.yml @@ -1,6 +1,14 @@ --- -borgmatic_metrics_repo: /Volumes/backups/borg/ +# Borg repositories to collect metrics from +# Each entry needs a path (local or ssh://) and a label for Prometheus metrics +borgmatic_metrics_repos: + - path: /Volumes/backups/borg/ + label: sifaka-local + - path: ssh://xcrtl5tg@xcrtl5tg.repo.borgbase.com/./repo + label: borgbase-immich-photos + borgmatic_metrics_passcommand: cat /Users/erichblume/.borg/config.yaml +borgmatic_metrics_ssh_key: /Users/erichblume/.ssh/borgbase_ed25519 borgmatic_metrics_dir: /opt/homebrew/var/node_exporter/textfile borgmatic_metrics_script: /Users/erichblume/.local/bin/borgmatic-metrics borgmatic_metrics_interval: 3600 # seconds between metric collection (hourly) diff --git a/ansible/roles/borgmatic_metrics/templates/borgmatic-metrics.sh.j2 b/ansible/roles/borgmatic_metrics/templates/borgmatic-metrics.sh.j2 index 856cbe9..b3ad605 100644 --- a/ansible/roles/borgmatic_metrics/templates/borgmatic-metrics.sh.j2 +++ b/ansible/roles/borgmatic_metrics/templates/borgmatic-metrics.sh.j2 @@ -1,11 +1,12 @@ #!/bin/bash # {{ ansible_managed }} # Collects borg backup metrics for node_exporter textfile collector +# Supports multiple repositories with a repo label for Prometheus set -euo pipefail export BORG_PASSCOMMAND="{{ borgmatic_metrics_passcommand }}" -BORG_REPO="{{ borgmatic_metrics_repo }}" +export BORG_RSH="ssh -o IdentitiesOnly=yes -i {{ borgmatic_metrics_ssh_key }}" OUTPUT_FILE="{{ borgmatic_metrics_dir }}/borgmatic.prom" TEMP_FILE="${OUTPUT_FILE}.tmp" @@ -13,129 +14,109 @@ TEMP_FILE="${OUTPUT_FILE}.tmp" BORG_CMD="/opt/homebrew/bin/borg" JQ_CMD="/opt/homebrew/bin/jq" -# Get repository info -repo_json=$($BORG_CMD info --json "$BORG_REPO" 2>/dev/null) || { - echo "Failed to get borg repo info" >&2 - # Write down metric - cat > "$TEMP_FILE" << 'EOF' +# Start fresh +cat > "$TEMP_FILE" << 'EOF' # HELP borgmatic_up Borg backup repository is accessible # TYPE borgmatic_up gauge -borgmatic_up 0 -EOF - mv "$TEMP_FILE" "$OUTPUT_FILE" - exit 0 -} - -# Get archive list -archives_json=$($BORG_CMD list --json "$BORG_REPO" 2>/dev/null) || { - echo "Failed to list borg archives" >&2 - exit 1 -} - -# Extract repository stats -total_size=$(echo "$repo_json" | $JQ_CMD -r '.cache.stats.total_size') -total_csize=$(echo "$repo_json" | $JQ_CMD -r '.cache.stats.total_csize') -unique_size=$(echo "$repo_json" | $JQ_CMD -r '.cache.stats.unique_size') -unique_csize=$(echo "$repo_json" | $JQ_CMD -r '.cache.stats.unique_csize') -total_chunks=$(echo "$repo_json" | $JQ_CMD -r '.cache.stats.total_chunks') -unique_chunks=$(echo "$repo_json" | $JQ_CMD -r '.cache.stats.total_unique_chunks') - -# Count archives -archive_count=$(echo "$archives_json" | $JQ_CMD -r '.archives | length') - -# Get last archive info -last_archive_name=$(echo "$archives_json" | $JQ_CMD -r '.archives[-1].name // empty') - -if [ -n "$last_archive_name" ]; then - # Get detailed info for the last archive - last_archive_json=$($BORG_CMD info --json "${BORG_REPO}::${last_archive_name}" 2>/dev/null) || { - echo "Failed to get last archive info" >&2 - last_archive_json="" - } - - if [ -n "$last_archive_json" ]; then - last_original_size=$(echo "$last_archive_json" | $JQ_CMD -r '.archives[0].stats.original_size') - last_compressed_size=$(echo "$last_archive_json" | $JQ_CMD -r '.archives[0].stats.compressed_size') - last_deduplicated_size=$(echo "$last_archive_json" | $JQ_CMD -r '.archives[0].stats.deduplicated_size') - last_nfiles=$(echo "$last_archive_json" | $JQ_CMD -r '.archives[0].stats.nfiles') - last_start=$(echo "$last_archive_json" | $JQ_CMD -r '.archives[0].start') - last_end=$(echo "$last_archive_json" | $JQ_CMD -r '.archives[0].end') - last_duration=$(echo "$last_archive_json" | $JQ_CMD -r '.archives[0].duration') - - # Convert timestamp to unix epoch - last_timestamp=$(date -j -f "%Y-%m-%dT%H:%M:%S" "${last_start%.*}" "+%s" 2>/dev/null || echo "0") - fi -fi - -# Write metrics -cat > "$TEMP_FILE" << EOF -# HELP borgmatic_up Borg backup repository is accessible -# TYPE borgmatic_up gauge -borgmatic_up 1 - # HELP borgmatic_repo_original_size_bytes Total original size of all archives (sum of what each backup contains) # TYPE borgmatic_repo_original_size_bytes gauge -borgmatic_repo_original_size_bytes $total_size - # HELP borgmatic_repo_compressed_size_bytes Total compressed size of all archives # TYPE borgmatic_repo_compressed_size_bytes gauge -borgmatic_repo_compressed_size_bytes $total_csize - # HELP borgmatic_repo_deduplicated_size_bytes Actual disk usage after deduplication (unique data) # TYPE borgmatic_repo_deduplicated_size_bytes gauge -borgmatic_repo_deduplicated_size_bytes $unique_csize - # HELP borgmatic_repo_total_chunks Total number of chunks across all archives # TYPE borgmatic_repo_total_chunks gauge -borgmatic_repo_total_chunks $total_chunks - # HELP borgmatic_repo_unique_chunks Number of unique chunks (after deduplication) # TYPE borgmatic_repo_unique_chunks gauge -borgmatic_repo_unique_chunks $unique_chunks - # HELP borgmatic_archive_count Number of archives in the repository # TYPE borgmatic_archive_count gauge -borgmatic_archive_count $archive_count -EOF - -# Add last archive metrics if available -if [ -n "${last_original_size:-}" ]; then - cat >> "$TEMP_FILE" << EOF - # HELP borgmatic_last_archive_original_size_bytes Original size of the last archive (data being backed up) # TYPE borgmatic_last_archive_original_size_bytes gauge -borgmatic_last_archive_original_size_bytes $last_original_size - # HELP borgmatic_last_archive_compressed_size_bytes Compressed size of the last archive # TYPE borgmatic_last_archive_compressed_size_bytes gauge -borgmatic_last_archive_compressed_size_bytes $last_compressed_size - # HELP borgmatic_last_archive_deduplicated_size_bytes Deduplicated size of last archive (new data added) # TYPE borgmatic_last_archive_deduplicated_size_bytes gauge -borgmatic_last_archive_deduplicated_size_bytes $last_deduplicated_size - # HELP borgmatic_last_archive_files Number of files in the last archive # TYPE borgmatic_last_archive_files gauge -borgmatic_last_archive_files $last_nfiles - # HELP borgmatic_last_archive_timestamp Unix timestamp of the last backup # TYPE borgmatic_last_archive_timestamp gauge -borgmatic_last_archive_timestamp $last_timestamp - # HELP borgmatic_last_archive_duration_seconds Duration of the last backup in seconds # TYPE borgmatic_last_archive_duration_seconds gauge -borgmatic_last_archive_duration_seconds ${last_duration:-0} -EOF - - # Collect per-source-directory sizes - cat >> "$TEMP_FILE" << 'EOF' - # HELP borgmatic_source_size_bytes Size of each backup source directory in bytes # TYPE borgmatic_source_size_bytes gauge EOF - # List archive contents and group by source directory - $BORG_CMD list "${BORG_REPO}::${last_archive_name}" --format "{size} {path}{NL}" 2>/dev/null | awk ' +collect_repo_metrics() { + local repo_path="$1" + local repo_label="$2" + + # Get repository info + repo_json=$($BORG_CMD info --json "$repo_path" 2>/dev/null) || { + echo "Failed to get borg repo info for $repo_label" >&2 + echo "borgmatic_up{repo=\"$repo_label\"} 0" >> "$TEMP_FILE" + return + } + + # Get archive list + archives_json=$($BORG_CMD list --json "$repo_path" 2>/dev/null) || { + echo "Failed to list borg archives for $repo_label" >&2 + echo "borgmatic_up{repo=\"$repo_label\"} 0" >> "$TEMP_FILE" + return + } + + # Extract repository stats + total_size=$(echo "$repo_json" | $JQ_CMD -r '.cache.stats.total_size') + total_csize=$(echo "$repo_json" | $JQ_CMD -r '.cache.stats.total_csize') + unique_size=$(echo "$repo_json" | $JQ_CMD -r '.cache.stats.unique_size') + unique_csize=$(echo "$repo_json" | $JQ_CMD -r '.cache.stats.unique_csize') + total_chunks=$(echo "$repo_json" | $JQ_CMD -r '.cache.stats.total_chunks') + unique_chunks=$(echo "$repo_json" | $JQ_CMD -r '.cache.stats.total_unique_chunks') + archive_count=$(echo "$archives_json" | $JQ_CMD -r '.archives | length') + + cat >> "$TEMP_FILE" << EOF +borgmatic_up{repo="$repo_label"} 1 +borgmatic_repo_original_size_bytes{repo="$repo_label"} $total_size +borgmatic_repo_compressed_size_bytes{repo="$repo_label"} $total_csize +borgmatic_repo_deduplicated_size_bytes{repo="$repo_label"} $unique_csize +borgmatic_repo_total_chunks{repo="$repo_label"} $total_chunks +borgmatic_repo_unique_chunks{repo="$repo_label"} $unique_chunks +borgmatic_archive_count{repo="$repo_label"} $archive_count +EOF + + # Get last archive info + last_archive_name=$(echo "$archives_json" | $JQ_CMD -r '.archives[-1].name // empty') + + if [ -z "$last_archive_name" ]; then + return + fi + + # Get detailed info for the last archive + last_archive_json=$($BORG_CMD info --json "${repo_path}::${last_archive_name}" 2>/dev/null) || { + echo "Failed to get last archive info for $repo_label" >&2 + return + } + + last_original_size=$(echo "$last_archive_json" | $JQ_CMD -r '.archives[0].stats.original_size') + last_compressed_size=$(echo "$last_archive_json" | $JQ_CMD -r '.archives[0].stats.compressed_size') + last_deduplicated_size=$(echo "$last_archive_json" | $JQ_CMD -r '.archives[0].stats.deduplicated_size') + last_nfiles=$(echo "$last_archive_json" | $JQ_CMD -r '.archives[0].stats.nfiles') + last_start=$(echo "$last_archive_json" | $JQ_CMD -r '.archives[0].start') + last_duration=$(echo "$last_archive_json" | $JQ_CMD -r '.archives[0].duration') + + # Convert timestamp to unix epoch + last_timestamp=$(date -j -f "%Y-%m-%dT%H:%M:%S" "${last_start%.*}" "+%s" 2>/dev/null || echo "0") + + cat >> "$TEMP_FILE" << EOF +borgmatic_last_archive_original_size_bytes{repo="$repo_label"} $last_original_size +borgmatic_last_archive_compressed_size_bytes{repo="$repo_label"} $last_compressed_size +borgmatic_last_archive_deduplicated_size_bytes{repo="$repo_label"} $last_deduplicated_size +borgmatic_last_archive_files{repo="$repo_label"} $last_nfiles +borgmatic_last_archive_timestamp{repo="$repo_label"} $last_timestamp +borgmatic_last_archive_duration_seconds{repo="$repo_label"} ${last_duration:-0} +EOF + + # Collect per-source-directory sizes + $BORG_CMD list "${repo_path}::${last_archive_name}" --format "{size} {path}{NL}" 2>/dev/null | awk -v repo="$repo_label" ' { size = $1 path = $2 @@ -145,8 +126,10 @@ EOF else if (path ~ /^Users\/[^\/]+\/devpi/) { source = "devpi" } else if (path ~ /^Users\/[^\/]+\/code\/personal\/zk/) { source = "Zettelkasten" } else if (path ~ /^Users\/[^\/]+\/.config\/borgmatic/) { source = "borgmatic_config" } + else if (path ~ /^Users\/[^\/]+\/.local\/share\/borgmatic/) { source = "k8s_dumps" } else if (path ~ /^opt\/homebrew\/var\/forgejo/) { source = "Forgejo" } else if (path ~ /^opt\/homebrew\/var\/loki/) { source = "Loki" } + else if (path ~ /^Volumes\/photos/) { source = "immich_photos" } else if (path ~ /^borgmatic\/postgresql_databases/) { source = "PostgreSQL" } else if (path ~ /^borgmatic\//) { source = "borgmatic_metadata" } else { source = "other" } @@ -155,10 +138,15 @@ EOF } END { for (src in totals) { - printf "borgmatic_source_size_bytes{source=\"%s\"} %.0f\n", src, totals[src] + printf "borgmatic_source_size_bytes{repo=\"%s\",source=\"%s\"} %.0f\n", repo, src, totals[src] } }' >> "$TEMP_FILE" -fi +} + +# Collect metrics for each configured repository +{% for repo in borgmatic_metrics_repos %} +collect_repo_metrics "{{ repo.path }}" "{{ repo.label }}" +{% endfor %} # Atomic move mv "$TEMP_FILE" "$OUTPUT_FILE" diff --git a/argocd/manifests/grafana-config/dashboards/configmap-borgmatic.yaml b/argocd/manifests/grafana-config/dashboards/configmap-borgmatic.yaml index 0e16982..c29f021 100644 --- a/argocd/manifests/grafana-config/dashboards/configmap-borgmatic.yaml +++ b/argocd/manifests/grafana-config/dashboards/configmap-borgmatic.yaml @@ -70,7 +70,8 @@ data: "targets": [ { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "borgmatic_up", + "expr": "borgmatic_up{repo=~\"$repo\"}", + "legendFormat": "{{repo}}", "refId": "A" } ], @@ -114,7 +115,8 @@ data: "targets": [ { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "borgmatic_repo_deduplicated_size_bytes", + "expr": "borgmatic_repo_deduplicated_size_bytes{repo=~\"$repo\"}", + "legendFormat": "{{repo}}", "refId": "A" } ], @@ -158,7 +160,8 @@ data: "targets": [ { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "borgmatic_last_archive_original_size_bytes", + "expr": "borgmatic_last_archive_original_size_bytes{repo=~\"$repo\"}", + "legendFormat": "{{repo}}", "refId": "A" } ], @@ -202,7 +205,8 @@ data: "targets": [ { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "borgmatic_archive_count", + "expr": "borgmatic_archive_count{repo=~\"$repo\"}", + "legendFormat": "{{repo}}", "refId": "A" } ], @@ -250,7 +254,8 @@ data: "targets": [ { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "time() - borgmatic_last_archive_timestamp", + "expr": "time() - borgmatic_last_archive_timestamp{repo=~\"$repo\"}", + "legendFormat": "{{repo}}", "refId": "A" } ], @@ -299,7 +304,8 @@ data: "targets": [ { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "borgmatic_repo_original_size_bytes / borgmatic_repo_deduplicated_size_bytes", + "expr": "borgmatic_repo_original_size_bytes{repo=~\"$repo\"} / borgmatic_repo_deduplicated_size_bytes{repo=~\"$repo\"}", + "legendFormat": "{{repo}}", "refId": "A" } ], @@ -343,7 +349,8 @@ data: "targets": [ { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "borgmatic_last_archive_deduplicated_size_bytes", + "expr": "borgmatic_last_archive_deduplicated_size_bytes{repo=~\"$repo\"}", + "legendFormat": "{{repo}}", "refId": "A" } ], @@ -387,7 +394,8 @@ data: "targets": [ { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "borgmatic_last_archive_files", + "expr": "borgmatic_last_archive_files{repo=~\"$repo\"}", + "legendFormat": "{{repo}}", "refId": "A" } ], @@ -435,7 +443,8 @@ data: "targets": [ { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "borgmatic_last_archive_duration_seconds", + "expr": "borgmatic_last_archive_duration_seconds{repo=~\"$repo\"}", + "legendFormat": "{{repo}}", "refId": "A" } ], @@ -479,7 +488,8 @@ data: "targets": [ { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "borgmatic_repo_unique_chunks", + "expr": "borgmatic_repo_unique_chunks{repo=~\"$repo\"}", + "legendFormat": "{{repo}}", "refId": "A" } ], @@ -524,8 +534,8 @@ data: "targets": [ { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "topk(10, borgmatic_source_size_bytes)", - "legendFormat": "{{source}}", + "expr": "topk(10, borgmatic_source_size_bytes{repo=~\"$repo\"})", + "legendFormat": "{{repo}} / {{source}}", "refId": "A" } ], @@ -541,8 +551,7 @@ data: "fieldConfig": { "defaults": { "color": { - "fixedColor": "green", - "mode": "fixed" + "mode": "palette-classic" }, "custom": { "axisBorderShow": false, @@ -603,8 +612,8 @@ data: "targets": [ { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "borgmatic_last_archive_original_size_bytes", - "legendFormat": "Backup Size (if extracted)", + "expr": "borgmatic_last_archive_original_size_bytes{repo=~\"$repo\"}", + "legendFormat": "{{repo}}", "refId": "A" } ], @@ -620,8 +629,7 @@ data: "fieldConfig": { "defaults": { "color": { - "fixedColor": "blue", - "mode": "fixed" + "mode": "palette-classic" }, "custom": { "axisBorderShow": false, @@ -682,8 +690,8 @@ data: "targets": [ { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "borgmatic_repo_deduplicated_size_bytes", - "legendFormat": "Repository Size on Disk", + "expr": "borgmatic_repo_deduplicated_size_bytes{repo=~\"$repo\"}", + "legendFormat": "{{repo}}", "refId": "A" } ], @@ -699,8 +707,7 @@ data: "fieldConfig": { "defaults": { "color": { - "fixedColor": "orange", - "mode": "fixed" + "mode": "palette-classic" }, "custom": { "axisBorderShow": false, @@ -761,8 +768,8 @@ data: "targets": [ { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "borgmatic_last_archive_deduplicated_size_bytes", - "legendFormat": "New Data Added", + "expr": "borgmatic_last_archive_deduplicated_size_bytes{repo=~\"$repo\"}", + "legendFormat": "{{repo}}", "refId": "A" } ], @@ -778,8 +785,7 @@ data: "fieldConfig": { "defaults": { "color": { - "fixedColor": "yellow", - "mode": "fixed" + "mode": "palette-classic" }, "custom": { "axisBorderShow": false, @@ -844,8 +850,8 @@ data: "targets": [ { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "borgmatic_last_archive_duration_seconds", - "legendFormat": "Backup Duration", + "expr": "borgmatic_last_archive_duration_seconds{repo=~\"$repo\"}", + "legendFormat": "{{repo}}", "refId": "A" } ], @@ -858,7 +864,36 @@ data: "schemaVersion": 38, "tags": ["borg", "backup"], "templating": { - "list": [] + "list": [ + { + "current": { + "selected": true, + "text": ["All"], + "value": ["$__all"] + }, + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "definition": "label_values(borgmatic_up, repo)", + "hide": 0, + "includeAll": true, + "label": "Repository", + "multi": true, + "name": "repo", + "options": [], + "query": { + "qryType": 1, + "query": "label_values(borgmatic_up, repo)", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "refresh": 2, + "regex": "", + "skipUrlSync": false, + "sort": 1, + "type": "query" + } + ] }, "time": { "from": "now-30d", diff --git a/docs/changelog.d/immich-photos-backup.feature.md b/docs/changelog.d/immich-photos-backup.feature.md new file mode 100644 index 0000000..6391af5 --- /dev/null +++ b/docs/changelog.d/immich-photos-backup.feature.md @@ -0,0 +1 @@ +Add offsite backup for immich photo library to BorgBase, running daily at 4 AM from indri via sifaka SMB mount. diff --git a/docs/reference/services/borgmatic.md b/docs/reference/services/borgmatic.md index 1020327..fea4551 100644 --- a/docs/reference/services/borgmatic.md +++ b/docs/reference/services/borgmatic.md @@ -15,9 +15,12 @@ Daily backup system using Borg backup, running on indri. | Property | Value | |----------|-------| | **Install** | mise (pipx) | -| **Config** | `~/.config/borgmatic/config.yaml` | -| **Schedule** | Daily at 2:00 AM | -| **Repository** | `/Volumes/backups/borg/` on [[sifaka|Sifaka]] | +| **Main config** | `~/.config/borgmatic/config.yaml` | +| **Photos config** | `~/.config/borgmatic/photos.yaml` | +| **Main schedule** | Daily at 2:00 AM | +| **Photos schedule** | Daily at 4:00 AM | +| **Main targets** | [[sifaka]] local + BorgBase offsite | +| **Photos target** | BorgBase offsite only | ## What Gets Backed Up @@ -35,6 +38,9 @@ Daily backup system using Borg backup, running on indri. **K8s SQLite databases (pre-backup dump via kubectl exec):** - [[mealie]] - Recipe manager (`/app/data/mealie.db`) +**Immich photo library** (separate config, BorgBase offsite only): +- `/Volumes/photos` (sifaka SMB mount, ~128 GB) + **Not backed up (by design):** - ZIM archives (re-downloadable) - Prometheus metrics (ephemeral) diff --git a/docs/reference/storage/backups.md b/docs/reference/storage/backups.md index e830822..9ca3bcb 100644 --- a/docs/reference/storage/backups.md +++ b/docs/reference/storage/backups.md @@ -37,9 +37,23 @@ Daily automated backups from [[indri]] to [[sifaka|Sifaka]] NAS. | immich | immich-pg | [[postgresql|pg.ops.eblu.me:5433]] | pg_dump stream | | mealie | — (SQLite) | k8s pod | kubectl exec sqlite3 .backup | +## Immich Photo Library (Offsite Only) + +The [[immich]] photo library lives on [[sifaka]] at `/volume1/photos` (SMB-mounted on [[indri]] as `/Volumes/photos`). Since sifaka is already the local backup target, photos are backed up to BorgBase offsite only — not back to sifaka. + +| Property | Value | +|----------|-------| +| **Config** | `~/.config/borgmatic/photos.yaml` | +| **Schedule** | Daily at 4:00 AM (offset from main backup) | +| **Source** | `/Volumes/photos` (sifaka SMB mount) | +| **Target** | BorgBase `borgbase-immich-photos` repo | +| **Size** | ~128 GB | + +Uses the same encryption passphrase and SSH key as the main borgmatic config. + ## Sifaka-Native Data -Some data lives directly on [[sifaka]] rather than being backed up to it (photos via [[immich]], music via [[navidrome]], video via [[jellyfin]]). See [[sifaka]] for data protection details. +Other data lives directly on [[sifaka]] (music via [[navidrome]], video via [[jellyfin]]). See [[sifaka]] for data protection details. ## What Is NOT Backed Up @@ -60,10 +74,11 @@ Some data lives directly on [[sifaka]] rather than being backed up to it (photos ## Backup Targets -| Repository | Location | Label | -|------------|----------|-------| -| `/Volumes/backups/borg/` | [[sifaka]] (local NAS) | — | -| `ssh://u3ugi1x1@u3ugi1x1.repo.borgbase.com/./repo` | BorgBase (offsite) | `borgbase-offsite` | +| Repository | Location | Label | Backs up | +|------------|----------|-------|----------| +| `/Volumes/backups/borg/` | [[sifaka]] (local NAS) | `sifaka-borg-backups` | indri data | +| `ssh://u3ugi1x1@...repo.borgbase.com/./repo` | BorgBase (offsite) | `borgbase-offsite` | indri data | +| `ssh://xcrtl5tg@...repo.borgbase.com/./repo` | BorgBase (offsite) | `borgbase-immich-photos` | immich photos | ## Monitoring From b632cd9ffb81d6a8be876765dc9b89687d6f964c Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Fri, 27 Mar 2026 22:36:32 -0700 Subject: [PATCH 145/430] Fix Immich resource limits and probe timeouts Resources were under wrong Helm value keys (server.resources, machine-learning.resources) and never applied to pods. Move to correct bjw-s chart paths (*.controllers.main.containers.main.resources). Increase liveness/readiness probe timeouts from 1s to 5s to prevent kubelet from killing healthy-but-busy pods during ML inference load. Remove CPU limits (keep requests only) to avoid throttling. Co-Authored-By: Claude Opus 4.6 (1M context) --- argocd/manifests/immich/values.yaml | 50 ++++++++++++++++++++--------- 1 file changed, 35 insertions(+), 15 deletions(-) diff --git a/argocd/manifests/immich/values.yaml b/argocd/manifests/immich/values.yaml index 493d9b1..7d23cb8 100644 --- a/argocd/manifests/immich/values.yaml +++ b/argocd/manifests/immich/values.yaml @@ -37,19 +37,29 @@ immich: # Machine Learning service machine-learning: enabled: true + controllers: + main: + containers: + main: + resources: + requests: + memory: "512Mi" + cpu: "100m" + limits: + memory: "4Gi" + probes: + liveness: + spec: + timeoutSeconds: 5 + readiness: + spec: + timeoutSeconds: 5 persistence: cache: enabled: true type: persistentVolumeClaim accessMode: ReadWriteOnce size: 10Gi - resources: - requests: - memory: "512Mi" - cpu: "100m" - limits: - memory: "4Gi" - cpu: "2000m" # Valkey (Redis fork) - included in chart valkey: @@ -60,12 +70,22 @@ valkey: type: emptyDir size: 1Gi -# Server resources for minikube +# Server configuration server: - resources: - requests: - memory: "256Mi" - cpu: "100m" - limits: - memory: "2Gi" - cpu: "2000m" + controllers: + main: + containers: + main: + resources: + requests: + memory: "256Mi" + cpu: "100m" + limits: + memory: "2Gi" + probes: + liveness: + spec: + timeoutSeconds: 5 + readiness: + spec: + timeoutSeconds: 5 From 3cb4303a540ca5c111b6c754dabf1610d608ad86 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Fri, 27 Mar 2026 22:37:15 -0700 Subject: [PATCH 146/430] Add changelog for Immich resource/probe fix Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/changelog.d/+immich-resource-probes.infra.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 docs/changelog.d/+immich-resource-probes.infra.md diff --git a/docs/changelog.d/+immich-resource-probes.infra.md b/docs/changelog.d/+immich-resource-probes.infra.md new file mode 100644 index 0000000..86c3a92 --- /dev/null +++ b/docs/changelog.d/+immich-resource-probes.infra.md @@ -0,0 +1 @@ +Fix Immich Helm values: resource limits and probe timeouts were silently ignored due to wrong value keys. Resources now actually apply to pods, and liveness/readiness probe timeouts increased from 1s to 5s to prevent kubelet from killing pods during ML inference. From 3017f759a7d4d39ca7827f16f8cbc4906860308b Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sat, 28 Mar 2026 08:19:23 -0700 Subject: [PATCH 147/430] Migrate Forgejo from Homebrew to source build (#316) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Migrate Forgejo from Homebrew to source-built binary with mcquack LaunchAgent - Matches the established pattern used by zot, caddy, and alloy - Upgrades to v14.0.3 (7 security fixes: PKCE bypass, OAuth scope bypass, open redirect, and more) ## Changes - **Ansible role**: Replace brew install/services with binary stat check + LaunchAgent - **Paths**: `/opt/homebrew/var/forgejo` → `~/forgejo`, binary at `~/code/3rd/forgejo/forgejo` - **Run user**: `forgejo` → `erichblume` (LaunchAgent user; SSH git user stays `forgejo`) - **Docs**: Updated Forgejo reference card, restart-indri guide - **Service review**: Stamped frigate-notify, cloudnative-pg, blumeops-pg as current ## One-time migration steps (manual, on indri) 1. Clone from Codeberg, add forge mirror remote 2. Check out v14.0.3, build with `make build && make forgejo` 3. Stop brew, `cp -a` data to `~/forgejo`, fix ownership 4. Run `provision-indri --tags forgejo` 5. Verify, then `brew uninstall forgejo` ## Data safety - `cp -a` preserves everything (repos, SQLite DB, LFS, sessions, OAuth config) - Brew version stays installed as rollback until verification passes - No schema changes between 14.0.2 → 14.0.3 Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/316 --- ansible/roles/forgejo/defaults/main.yml | 11 ++-- ansible/roles/forgejo/handlers/main.yml | 4 +- ansible/roles/forgejo/tasks/main.yml | 53 +++++++++++++++---- .../roles/forgejo/templates/forgejo.plist.j2 | 26 +++++++++ .../build-forgejo-from-source.infra.md | 1 + docs/how-to/operations/restart-indri.md | 6 +-- docs/reference/services/forgejo.md | 37 ++++++++++++- service-versions.yaml | 12 ++--- 8 files changed, 124 insertions(+), 26 deletions(-) create mode 100644 ansible/roles/forgejo/templates/forgejo.plist.j2 create mode 100644 docs/changelog.d/build-forgejo-from-source.infra.md diff --git a/ansible/roles/forgejo/defaults/main.yml b/ansible/roles/forgejo/defaults/main.yml index 435caf1..a178d99 100644 --- a/ansible/roles/forgejo/defaults/main.yml +++ b/ansible/roles/forgejo/defaults/main.yml @@ -4,16 +4,21 @@ forgejo_app_name: Forgejo forgejo_app_slogan: "Beyond coding. We Forge." -forgejo_run_user: forgejo +forgejo_run_user: erichblume forgejo_run_mode: prod -# Paths (brew-managed for now, will change to mcquack per migrate-forgejo-from-brew) -forgejo_work_path: /opt/homebrew/var/forgejo +# Source build paths +forgejo_repo_dir: /Users/erichblume/code/3rd/forgejo +forgejo_binary: "{{ forgejo_repo_dir }}/forgejo" + +# Data paths (migrated from brew to ~/forgejo) +forgejo_work_path: /Users/erichblume/forgejo forgejo_config_path: "{{ forgejo_work_path }}/custom/conf/app.ini" forgejo_data_path: "{{ forgejo_work_path }}/data" forgejo_repo_root: "{{ forgejo_data_path }}/forgejo-repositories" forgejo_lfs_path: "{{ forgejo_data_path }}/lfs" forgejo_log_path: "{{ forgejo_work_path }}/log" +forgejo_log_dir: /Users/erichblume/Library/Logs # Server settings forgejo_http_addr: 0.0.0.0 diff --git a/ansible/roles/forgejo/handlers/main.yml b/ansible/roles/forgejo/handlers/main.yml index dc67cbc..c00a3dd 100644 --- a/ansible/roles/forgejo/handlers/main.yml +++ b/ansible/roles/forgejo/handlers/main.yml @@ -1,4 +1,6 @@ --- - name: Restart forgejo - ansible.builtin.command: brew services restart forgejo + ansible.builtin.shell: | + launchctl unload ~/Library/LaunchAgents/mcquack.eblume.forgejo.plist 2>/dev/null || true + launchctl load ~/Library/LaunchAgents/mcquack.eblume.forgejo.plist changed_when: true diff --git a/ansible/roles/forgejo/tasks/main.yml b/ansible/roles/forgejo/tasks/main.yml index a6d27b9..7cafb12 100644 --- a/ansible/roles/forgejo/tasks/main.yml +++ b/ansible/roles/forgejo/tasks/main.yml @@ -1,16 +1,34 @@ --- -# Forgejo role +# Forgejo role — source-built binary with LaunchAgent # -# Currently uses brew-managed forgejo. Phase 3 of ci-cd-bootstrap will -# transition to mcquack LaunchAgent with CI-built binary. +# ONE-TIME SETUP (before running ansible): +# +# 1. Clone forgejo from codeberg (avoid circular dependency): +# ssh indri 'git clone https://codeberg.org/forgejo/forgejo.git ~/code/3rd/forgejo' +# +# 2. Add forge mirror as secondary remote: +# ssh indri 'cd ~/code/3rd/forgejo && git remote add forge https://forge.eblu.me/mirrors/forgejo.git' +# +# 3. Build (mise.toml handles Go/Node versions and build tags): +# ssh indri 'cd ~/code/3rd/forgejo && mise run build' +# +# 4. Run ansible to deploy config and LaunchAgent # # Secrets (lfs_jwt_secret, internal_token, oauth2_jwt_secret) are fetched # from 1Password in the playbook pre_tasks. -- name: Install forgejo via homebrew - community.general.homebrew: - name: forgejo - state: present +- name: Verify forgejo binary exists + ansible.builtin.stat: + path: "{{ forgejo_binary }}" + register: forgejo_binary_stat + +- name: Fail if forgejo binary not found + ansible.builtin.fail: + msg: | + Forgejo binary not found at {{ forgejo_binary }}. + Please build from source first: + ssh indri 'cd ~/code/3rd/forgejo && mise run build' + when: not forgejo_binary_stat.stat.exists - name: Ensure forgejo config directory exists ansible.builtin.file: @@ -25,8 +43,21 @@ mode: '0600' notify: Restart forgejo -- name: Ensure forgejo service is started - ansible.builtin.command: brew services start forgejo - register: forgejo_brew_start - changed_when: "'Successfully started' in forgejo_brew_start.stdout" +- name: Deploy forgejo LaunchAgent plist + ansible.builtin.template: + src: forgejo.plist.j2 + dest: ~/Library/LaunchAgents/mcquack.eblume.forgejo.plist + mode: '0644' + notify: Restart forgejo + +- name: Check if forgejo LaunchAgent is loaded + ansible.builtin.command: launchctl list mcquack.eblume.forgejo + register: forgejo_launchctl_check + changed_when: false + failed_when: false + +- name: Load forgejo LaunchAgent if not loaded + ansible.builtin.command: launchctl load ~/Library/LaunchAgents/mcquack.eblume.forgejo.plist + when: forgejo_launchctl_check.rc != 0 + changed_when: true failed_when: false diff --git a/ansible/roles/forgejo/templates/forgejo.plist.j2 b/ansible/roles/forgejo/templates/forgejo.plist.j2 new file mode 100644 index 0000000..85d63f3 --- /dev/null +++ b/ansible/roles/forgejo/templates/forgejo.plist.j2 @@ -0,0 +1,26 @@ + + + + + + Label + mcquack.eblume.forgejo + ProgramArguments + + {{ forgejo_binary }} + -w + {{ forgejo_work_path }} + -c + {{ forgejo_config_path }} + web + + RunAtLoad + + KeepAlive + + StandardOutPath + {{ forgejo_log_dir }}/mcquack.forgejo.out.log + StandardErrorPath + {{ forgejo_log_dir }}/mcquack.forgejo.err.log + + diff --git a/docs/changelog.d/build-forgejo-from-source.infra.md b/docs/changelog.d/build-forgejo-from-source.infra.md new file mode 100644 index 0000000..bffd5c7 --- /dev/null +++ b/docs/changelog.d/build-forgejo-from-source.infra.md @@ -0,0 +1 @@ +Migrate Forgejo from Homebrew to source build with mcquack LaunchAgent, matching the pattern used by zot, caddy, and alloy. Upgrades to v14.0.3 (7 security fixes including PKCE bypass and OAuth scope bypass). diff --git a/docs/how-to/operations/restart-indri.md b/docs/how-to/operations/restart-indri.md index 768ec9a..a3f29a0 100644 --- a/docs/how-to/operations/restart-indri.md +++ b/docs/how-to/operations/restart-indri.md @@ -37,10 +37,8 @@ ssh indri 'minikube status' Native services managed by launchd will stop automatically during macOS shutdown. However, if you want to stop them explicitly first: ```bash -# Forgejo (managed by brew services) -ssh indri 'brew services stop forgejo' - # LaunchAgent services +ssh indri 'launchctl unload ~/Library/LaunchAgents/mcquack.eblume.forgejo.plist' ssh indri 'launchctl unload ~/Library/LaunchAgents/mcquack.eblume.caddy.plist' ssh indri 'launchctl unload ~/Library/LaunchAgents/mcquack.eblume.zot.plist' ssh indri 'launchctl unload ~/Library/LaunchAgents/mcquack.jellyfin.plist' @@ -68,7 +66,7 @@ Or if you're at the console, use the Apple menu. After indri boots, most services recover automatically. Only a few things need manual attention. -**What autostarts:** Docker Desktop, brew services (Forgejo), and all mcquack LaunchAgent services (Caddy, Zot, Jellyfin, Alloy, Borgmatic, metrics collectors). +**What autostarts:** Docker Desktop and all mcquack LaunchAgent services (Forgejo, Caddy, Zot, Jellyfin, Alloy, Borgmatic, metrics collectors). **What needs manual action:** Amphetamine, AutoMounter, and minikube (including its Tailscale serve port). diff --git a/docs/reference/services/forgejo.md b/docs/reference/services/forgejo.md index e96c247..fbbc506 100644 --- a/docs/reference/services/forgejo.md +++ b/docs/reference/services/forgejo.md @@ -1,6 +1,6 @@ --- title: Forgejo -modified: 2026-03-03 +modified: 2026-03-28 tags: - service - git @@ -11,6 +11,8 @@ tags: Git forge and CI/CD platform. **Primary source of truth for blumeops** (mirrored to GitHub). +Built from source on indri, managed via Ansible + mcquack LaunchAgent. Source cloned from Codeberg with a forge mirror as secondary remote. + ## Quick Reference | Property | Value | @@ -20,6 +22,39 @@ Git forge and CI/CD platform. **Primary source of truth for blumeops** (mirrored | **SSH** | `ssh://forgejo@forge.ops.eblu.me:2222` | | **Local Ports** | 3001 (HTTP), 2200 (SSH) | | **Config** | `ansible/roles/forgejo/templates/app.ini.j2` | +| **Binary** | `~/code/3rd/forgejo/forgejo` (source-built) | +| **Data** | `~/forgejo` | +| **LaunchAgent** | `mcquack.eblume.forgejo` | +| **Source** | `~/code/3rd/forgejo` (cloned from Codeberg) | + +## Building from Source + +Forgejo is built from source on indri, matching the pattern used by [[zot]], [[caddy]], and [[alloy]]. + +**One-time setup:** + +```fish +# Clone from Codeberg (avoids circular dependency with forge) +ssh indri 'git clone https://codeberg.org/forgejo/forgejo.git ~/code/3rd/forgejo' + +# Add forge mirror as secondary remote +ssh indri 'cd ~/code/3rd/forgejo && git remote add forge https://forge.eblu.me/mirrors/forgejo.git' +``` + +**Building a specific version:** + +```fish +ssh indri 'cd ~/code/3rd/forgejo && git fetch --tags && git checkout v14.0.3' +ssh indri 'cd ~/code/3rd/forgejo && mise run build' +``` + +The `build` mise task (defined in the repo's `mise.toml`) runs `make build` with the correct tags and creates the `./forgejo` hardlink. It uses `go@1.25.8` and `node@24` as configured by `mise use`. + +**WARNING:** Do NOT use `make forgejo` directly — it rebuilds with empty TAGS, stripping SQLite support. Always use `mise run build` or pass TAGS explicitly to `make build` and `ln -f gitea forgejo` afterwards. + +Build tags: `bindata` (embed assets), `timetzdata` (embed timezone data), `sqlite sqlite_unlock_notify` (SQLite support). + +After building, run `mise run provision-indri -- --tags forgejo` to deploy the config and restart the service. ## Repositories diff --git a/service-versions.yaml b/service-versions.yaml index 6821488..bca2528 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -59,7 +59,7 @@ services: - name: frigate-notify type: argocd - last-reviewed: 2026-02-22 + last-reviewed: 2026-03-28 current-version: "v0.5.4" upstream-source: https://github.com/0x2142/frigate-notify/releases @@ -112,7 +112,7 @@ services: - name: cloudnative-pg type: argocd - last-reviewed: 2026-02-24 + last-reviewed: 2026-03-28 current-version: "v1.28.1" upstream-source: https://github.com/cloudnative-pg/cloudnative-pg/releases notes: Deployed via Helm chart (chart v0.27.1 from forge mirror) @@ -147,7 +147,7 @@ services: - name: blumeops-pg type: argocd - last-reviewed: 2026-02-27 + last-reviewed: 2026-03-28 current-version: "18.3" upstream-source: https://github.com/cloudnative-pg/cloudnative-pg/releases notes: CloudNativePG Cluster resource; pinned to PG minor version @@ -287,10 +287,10 @@ services: - name: forgejo type: ansible - last-reviewed: 2026-02-22 - current-version: "14.0.2" + last-reviewed: 2026-03-28 + current-version: "14.0.3" upstream-source: https://codeberg.org/forgejo/forgejo/releases - notes: Installed via Homebrew on indri; plan to migrate to source build + notes: Built from source on indri (~/code/3rd/forgejo) - name: alloy type: ansible From 8cbd4123807fec1adc154657f4d5ebb8b6c8ee69 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sat, 28 Mar 2026 08:21:51 -0700 Subject: [PATCH 148/430] Update services-check: forgejo uses launchctl not brew Co-Authored-By: Claude Opus 4.6 (1M context) --- mise-tasks/services-check | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mise-tasks/services-check b/mise-tasks/services-check index 2417f74..1da86f7 100755 --- a/mise-tasks/services-check +++ b/mise-tasks/services-check @@ -120,7 +120,7 @@ echo "" # Local services on indri (not yet covered by alerting) echo "Local services on indri:" -check_service "forgejo (brew)" "ssh indri 'brew services list | grep forgejo | grep started'" +check_service "forgejo" "ssh indri 'launchctl list mcquack.eblume.forgejo | grep -v \"^-\"'" check_service "alloy" "ssh indri 'launchctl list mcquack.eblume.alloy | grep -v \"^-\"'" check_service "borgmatic" "ssh indri 'launchctl list mcquack.eblume.borgmatic | grep -v \"^-\"'" check_service "borgmatic-metrics" "ssh indri 'launchctl list mcquack.borgmatic-metrics | grep -v \"^-\"'" From 2bd1611ac1ac146ac401bf23dc8ac5555c6aa125 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sat, 28 Mar 2026 09:12:00 -0700 Subject: [PATCH 149/430] Document sifaka NFS/Tailscale TUN troubleshooting Sifaka's Tailscale can revert to userspace networking after package updates, causing NFS mounts to fail because the NFS daemon sees 127.0.0.1 instead of the client's Tailscale IP. Added troubleshooting how-to doc and updated sifaka reference card with frigate export and TUN requirement. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../operations/troubleshoot-sifaka-nfs.md | 73 +++++++++++++++++++ docs/reference/storage/sifaka.md | 22 ++++-- 2 files changed, 88 insertions(+), 7 deletions(-) create mode 100644 docs/how-to/operations/troubleshoot-sifaka-nfs.md diff --git a/docs/how-to/operations/troubleshoot-sifaka-nfs.md b/docs/how-to/operations/troubleshoot-sifaka-nfs.md new file mode 100644 index 0000000..85514d4 --- /dev/null +++ b/docs/how-to/operations/troubleshoot-sifaka-nfs.md @@ -0,0 +1,73 @@ +--- +title: Troubleshoot Sifaka NFS +modified: 2026-03-28 +last-reviewed: 2026-03-28 +tags: + - how-to + - storage + - nfs +--- + +# Troubleshoot Sifaka NFS + +How to diagnose and fix NFS permission failures on [[sifaka]]. + +## Symptom + +NFS mounts from ringtail (or any Tailscale client) to sifaka fail with "Permission denied". Frigate shows empty storage stats. The `frigate-storage` check in `mise run services-check` fails. Existing mounts go stale — even `ls` on the mount point returns EACCES. + +## Root Cause: Tailscale Userspace Networking + +Sifaka runs Tailscale via the Synology DSM package. On DSM 7, the package can run in two modes: + +| Mode | `TUN` flag | NFS sees source IP as | NFS result | +|------|-----------|----------------------|------------| +| **TUN (kernel)** | `True` | Client's Tailscale IP (e.g. `100.121.200.77`) | Works — matches `100.64.0.0/10` export rule | +| **Userspace** | `False` | `127.0.0.1` (loopback) | Fails — doesn't match any export rule | + +In userspace mode, Tailscale proxies connections through loopback. The NFS daemon sees `127.0.0.1` as the source IP, which doesn't match the `100.64.0.0/10` or `192.168.1.0/24` export rules, so it rejects the mount. + +## Diagnosis + +```bash +# Check Tailscale mode on sifaka +ssh sifaka '/var/packages/Tailscale/target/bin/tailscale status --json' | python3 -c "import sys,json; print('TUN:', json.load(sys.stdin).get('TUN'))" + +# If TUN: False, that's the problem + +# Confirm NFS lease failures on ringtail +ssh ringtail 'sudo dmesg | grep -i nfs | tail -5' +# Look for: "check lease failed on NFSv4 server sifaka with error 13" +``` + +## Fix + +The DSM Task Scheduler has a boot-up task ("Enable tailscale outbound TUN") that runs: + +```bash +/var/packages/Tailscale/target/bin/tailscale configure-host; +synosystemctl restart pkgctl-Tailscale.service +``` + +`configure-host` grants the Tailscale package permission to open `/dev/net/tun` (which is `crw-------` root-only by default on DSM 7). The service restart then picks up TUN mode. + +**To fix immediately:** In DSM, go to Control Panel > Task Scheduler, select "Enable tailscale outbound TUN", and click Run. + +**Note:** Running this task restarts Tailscale, which briefly drops all Tailscale connections to sifaka. SSH sessions over Tailscale will disconnect but reconnect within seconds. + +After Tailscale restarts, restart the affected pods to get fresh NFS mounts: + +```bash +kubectl --context=k3s-ringtail rollout restart deployment/frigate -n frigate +``` + +## Why It Recurs + +The "Update Tailscale" scheduled task runs nightly (`tailscale update --yes`). Package updates can reset the TUN device permissions, reverting to userspace mode. The boot-up task only runs at boot, not after updates. + +If this keeps recurring, consider adding `tailscale configure-host` to the update task as well, or running it on a schedule. + +## Related + +- [[sifaka]] — NAS reference card +- [[frigate]] — Primary NFS consumer affected by this issue diff --git a/docs/reference/storage/sifaka.md b/docs/reference/storage/sifaka.md index 31fe90a..b3387c1 100644 --- a/docs/reference/storage/sifaka.md +++ b/docs/reference/storage/sifaka.md @@ -1,7 +1,7 @@ --- title: Sifaka -modified: 2026-02-09 -last-reviewed: 2026-03-23 +modified: 2026-03-28 +last-reviewed: 2026-03-28 tags: - storage --- @@ -28,14 +28,19 @@ Synology NAS providing network storage and backup target. | music | `/volume1/music` | Music library | [[navidrome]] | | allisonflix | `/volume1/allisonflix` | Video library | [[jellyfin]] | | photos | `/volume1/photos` | Photo library | [[immich]] | +| frigate | `/volume1/frigate` | NVR recordings, clips, models | [[frigate]] | ## NFS Exports -| Export | Allowed Clients | Purpose | -|--------|-----------------|---------| -| `/volume1/torrents` | 192.168.1.0/24, 100.64.0.0/10 | k8s pods via Docker NAT | -| `/volume1/music` | 192.168.1.0/24, 100.64.0.0/10 | k8s pods via Docker NAT | -| `/volume1/photos` | 192.168.1.0/24, 100.64.0.0/10 | k8s pods via Docker NAT | +| Export | Allowed Clients | Squash | Purpose | +|--------|-----------------|--------|---------| +| `/volume1/torrents` | 192.168.1.0/24, 100.64.0.0/10 | Map all users to admin | k8s pods | +| `/volume1/music` | 192.168.1.0/24, 100.64.0.0/10 | Map all users to admin | k8s pods | +| `/volume1/photos` | 192.168.1.0/24, 100.64.0.0/10 | Map all users to admin | k8s pods | +| `/volume1/frigate` | 192.168.1.0/24, 100.64.0.0/10 | Map all users to admin | k8s pods on ringtail | +| `/volume1/reports` | 192.168.1.0/24, 100.64.0.0/10 | Map all users to admin | Prowler reports | + +All exports use `all_squash` mapping to uid 1024 (`admin`) / gid 100 (`users`). If NFS mounts fail with permission denied, see [[troubleshoot-sifaka-nfs]]. ## Monitoring @@ -108,6 +113,7 @@ Synology uses `/dev/sata*` (e.g., `/dev/sata1` through `/dev/sata4`) instead of - Tag: `tag:nas` - ACL: `tag:homelab` can access for backups +- **Must run in TUN mode** for NFS — see [[troubleshoot-sifaka-nfs]] ## Backup @@ -117,8 +123,10 @@ Data protection for sifaka itself currently relies on the Synology RAID 5 config ## Related +- [[troubleshoot-sifaka-nfs]] - NFS permission denied troubleshooting - [[backups|Backups]] - Backup policy - [[borgmatic]] - Backup system +- [[frigate]] - NVR recordings consumer - [[immich]] - Photo consumer - [[jellyfin]] - Media consumer - [[navidrome]] - Music consumer From 7fb6eff3887df0ef6e79553344764ac4f91ee6ad Mon Sep 17 00:00:00 2001 From: Forgejo Actions Date: Sat, 28 Mar 2026 09:15:21 -0700 Subject: [PATCH 150/430] Update docs release to v1.15.1 - Built changelog from towncrier fragments [skip ci] --- CHANGELOG.md | 31 +++++++++++++++++++ argocd/manifests/docs/deployment.yaml | 2 +- .../+branch-cleanup-preserve.misc.md | 1 - docs/changelog.d/+cv-doc-review.doc.md | 1 - docs/changelog.d/+homepage-v1.11.0.infra.md | 1 - .../+immich-resource-probes.infra.md | 1 - .../+nvidia-device-plugin-v0.19.0.infra.md | 1 - .../+podnotready-lookback.infra.md | 1 - docs/changelog.d/+qart-tuner.feature.md | 1 - .../+review-tailscale-setup.doc.md | 1 - ...+ringtail-post-deploy-maintenance.infra.md | 1 - .../+tune-argocd-outofsync-alert.infra.md | 1 - .../+update-ringtail-flake.infra.md | 1 - .../build-forgejo-from-source.infra.md | 1 - .../deploy-snowflake-proxy.feature.md | 1 - .../feature-borgmatic-all-pg-backups.infra.md | 1 - .../immich-photos-backup.feature.md | 1 - .../upgrade-external-secrets-v2.infra.md | 1 - 18 files changed, 32 insertions(+), 17 deletions(-) delete mode 100644 docs/changelog.d/+branch-cleanup-preserve.misc.md delete mode 100644 docs/changelog.d/+cv-doc-review.doc.md delete mode 100644 docs/changelog.d/+homepage-v1.11.0.infra.md delete mode 100644 docs/changelog.d/+immich-resource-probes.infra.md delete mode 100644 docs/changelog.d/+nvidia-device-plugin-v0.19.0.infra.md delete mode 100644 docs/changelog.d/+podnotready-lookback.infra.md delete mode 100644 docs/changelog.d/+qart-tuner.feature.md delete mode 100644 docs/changelog.d/+review-tailscale-setup.doc.md delete mode 100644 docs/changelog.d/+ringtail-post-deploy-maintenance.infra.md delete mode 100644 docs/changelog.d/+tune-argocd-outofsync-alert.infra.md delete mode 100644 docs/changelog.d/+update-ringtail-flake.infra.md delete mode 100644 docs/changelog.d/build-forgejo-from-source.infra.md delete mode 100644 docs/changelog.d/deploy-snowflake-proxy.feature.md delete mode 100644 docs/changelog.d/feature-borgmatic-all-pg-backups.infra.md delete mode 100644 docs/changelog.d/immich-photos-backup.feature.md delete mode 100644 docs/changelog.d/upgrade-external-secrets-v2.infra.md diff --git a/CHANGELOG.md b/CHANGELOG.md index ced38b3..4b93e4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,37 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [v1.15.1] - 2026-03-28 + +### Features + +- Add Tor Snowflake proxy on ringtail as a systemd service to support anti-censorship efforts. +- Add offsite backup for immich photo library to BorgBase, running daily at 4 AM from indri via sifaka SMB mount. +- Add QArt Tuner — a Go tool that generates QR codes whose data modules form a recognizable image, with an interactive web UI for parameter tuning. Based on the [QArt technique](https://research.swtch.com/qart) by Russ Cox. Lives in `utils/qart/`. + +### Infrastructure + +- Migrate Forgejo from Homebrew to source build with mcquack LaunchAgent, matching the pattern used by zot, caddy, and alloy. Upgrades to v14.0.3 (7 security fixes including PKCE bypass and OAuth scope bypass). +- Add borgmatic pg_dump backups for authentik and immich databases. Authentik uses the existing blumeops-pg cluster on port 5432. Immich requires a new borgmatic role on the immich-pg cluster, a Tailscale service, and Caddy L4 proxy on port 5433. +- Upgrade External Secrets Operator from v1.3.2 to v2.2.0 and migrate from Helm chart to static kustomize manifests. +- Add post-deploy maintenance docs and generation pruning task for ringtail. +- Fix Immich Helm values: resource limits and probe timeouts were silently ignored due to wrong value keys. Resources now actually apply to pods, and liveness/readiness probe timeouts increased from 1s to 5s to prevent kubelet from killing pods during ML inference. +- Reduce PodNotReady alert lookback window from 5m to 60s to clear faster after rollouts. +- Tighten ArgoCDAppOutOfSync alert: reduce pending duration from 30m to 5m and lookback window from 5m to 1m so alerts clear faster after sync. +- Update ringtail flake inputs (nixpkgs, home-manager). +- Upgrade Homepage dashboard from v1.10.1 to v1.11.0 +- Upgrade nvidia-device-plugin from v0.18.2 to v0.19.0 + +### Documentation + +- Review and fix CV service doc (correct URL, forge domain, container tag link) and add private forge repo review guidance to review-services process. +- Review tailscale-setup tutorial: fix macOS install steps, add `--accept-routes` tip, correct tag name, add ACL apply instructions, add `[[tailscale-operator]]` cross-reference. + +### Miscellaneous + +- Add `preserve/*` branch prefix exclusion to `branch-cleanup` task; document Pyroscope profiling work and blockers in observability reference. + + ## [v1.15.0] - 2026-03-24 ### Features diff --git a/argocd/manifests/docs/deployment.yaml b/argocd/manifests/docs/deployment.yaml index c1203dd..9b61fb0 100644 --- a/argocd/manifests/docs/deployment.yaml +++ b/argocd/manifests/docs/deployment.yaml @@ -30,7 +30,7 @@ spec: name: http env: - name: DOCS_RELEASE_URL - value: "https://forge.eblu.me/eblume/blumeops/releases/download/v1.15.0/docs-v1.15.0.tar.gz" + value: "https://forge.eblu.me/eblume/blumeops/releases/download/v1.15.1/docs-v1.15.1.tar.gz" resources: requests: memory: "64Mi" diff --git a/docs/changelog.d/+branch-cleanup-preserve.misc.md b/docs/changelog.d/+branch-cleanup-preserve.misc.md deleted file mode 100644 index 425e8cc..0000000 --- a/docs/changelog.d/+branch-cleanup-preserve.misc.md +++ /dev/null @@ -1 +0,0 @@ -Add `preserve/*` branch prefix exclusion to `branch-cleanup` task; document Pyroscope profiling work and blockers in observability reference. diff --git a/docs/changelog.d/+cv-doc-review.doc.md b/docs/changelog.d/+cv-doc-review.doc.md deleted file mode 100644 index ecace7d..0000000 --- a/docs/changelog.d/+cv-doc-review.doc.md +++ /dev/null @@ -1 +0,0 @@ -Review and fix CV service doc (correct URL, forge domain, container tag link) and add private forge repo review guidance to review-services process. diff --git a/docs/changelog.d/+homepage-v1.11.0.infra.md b/docs/changelog.d/+homepage-v1.11.0.infra.md deleted file mode 100644 index a35eaed..0000000 --- a/docs/changelog.d/+homepage-v1.11.0.infra.md +++ /dev/null @@ -1 +0,0 @@ -Upgrade Homepage dashboard from v1.10.1 to v1.11.0 diff --git a/docs/changelog.d/+immich-resource-probes.infra.md b/docs/changelog.d/+immich-resource-probes.infra.md deleted file mode 100644 index 86c3a92..0000000 --- a/docs/changelog.d/+immich-resource-probes.infra.md +++ /dev/null @@ -1 +0,0 @@ -Fix Immich Helm values: resource limits and probe timeouts were silently ignored due to wrong value keys. Resources now actually apply to pods, and liveness/readiness probe timeouts increased from 1s to 5s to prevent kubelet from killing pods during ML inference. diff --git a/docs/changelog.d/+nvidia-device-plugin-v0.19.0.infra.md b/docs/changelog.d/+nvidia-device-plugin-v0.19.0.infra.md deleted file mode 100644 index 95abf25..0000000 --- a/docs/changelog.d/+nvidia-device-plugin-v0.19.0.infra.md +++ /dev/null @@ -1 +0,0 @@ -Upgrade nvidia-device-plugin from v0.18.2 to v0.19.0 diff --git a/docs/changelog.d/+podnotready-lookback.infra.md b/docs/changelog.d/+podnotready-lookback.infra.md deleted file mode 100644 index fec02df..0000000 --- a/docs/changelog.d/+podnotready-lookback.infra.md +++ /dev/null @@ -1 +0,0 @@ -Reduce PodNotReady alert lookback window from 5m to 60s to clear faster after rollouts. diff --git a/docs/changelog.d/+qart-tuner.feature.md b/docs/changelog.d/+qart-tuner.feature.md deleted file mode 100644 index 720774d..0000000 --- a/docs/changelog.d/+qart-tuner.feature.md +++ /dev/null @@ -1 +0,0 @@ -Add QArt Tuner — a Go tool that generates QR codes whose data modules form a recognizable image, with an interactive web UI for parameter tuning. Based on the [QArt technique](https://research.swtch.com/qart) by Russ Cox. Lives in `utils/qart/`. diff --git a/docs/changelog.d/+review-tailscale-setup.doc.md b/docs/changelog.d/+review-tailscale-setup.doc.md deleted file mode 100644 index e3395a0..0000000 --- a/docs/changelog.d/+review-tailscale-setup.doc.md +++ /dev/null @@ -1 +0,0 @@ -Review tailscale-setup tutorial: fix macOS install steps, add `--accept-routes` tip, correct tag name, add ACL apply instructions, add `[[tailscale-operator]]` cross-reference. diff --git a/docs/changelog.d/+ringtail-post-deploy-maintenance.infra.md b/docs/changelog.d/+ringtail-post-deploy-maintenance.infra.md deleted file mode 100644 index c85a3da..0000000 --- a/docs/changelog.d/+ringtail-post-deploy-maintenance.infra.md +++ /dev/null @@ -1 +0,0 @@ -Add post-deploy maintenance docs and generation pruning task for ringtail. diff --git a/docs/changelog.d/+tune-argocd-outofsync-alert.infra.md b/docs/changelog.d/+tune-argocd-outofsync-alert.infra.md deleted file mode 100644 index cac4b46..0000000 --- a/docs/changelog.d/+tune-argocd-outofsync-alert.infra.md +++ /dev/null @@ -1 +0,0 @@ -Tighten ArgoCDAppOutOfSync alert: reduce pending duration from 30m to 5m and lookback window from 5m to 1m so alerts clear faster after sync. diff --git a/docs/changelog.d/+update-ringtail-flake.infra.md b/docs/changelog.d/+update-ringtail-flake.infra.md deleted file mode 100644 index d2c1ce8..0000000 --- a/docs/changelog.d/+update-ringtail-flake.infra.md +++ /dev/null @@ -1 +0,0 @@ -Update ringtail flake inputs (nixpkgs, home-manager). diff --git a/docs/changelog.d/build-forgejo-from-source.infra.md b/docs/changelog.d/build-forgejo-from-source.infra.md deleted file mode 100644 index bffd5c7..0000000 --- a/docs/changelog.d/build-forgejo-from-source.infra.md +++ /dev/null @@ -1 +0,0 @@ -Migrate Forgejo from Homebrew to source build with mcquack LaunchAgent, matching the pattern used by zot, caddy, and alloy. Upgrades to v14.0.3 (7 security fixes including PKCE bypass and OAuth scope bypass). diff --git a/docs/changelog.d/deploy-snowflake-proxy.feature.md b/docs/changelog.d/deploy-snowflake-proxy.feature.md deleted file mode 100644 index e34af2b..0000000 --- a/docs/changelog.d/deploy-snowflake-proxy.feature.md +++ /dev/null @@ -1 +0,0 @@ -Add Tor Snowflake proxy on ringtail as a systemd service to support anti-censorship efforts. diff --git a/docs/changelog.d/feature-borgmatic-all-pg-backups.infra.md b/docs/changelog.d/feature-borgmatic-all-pg-backups.infra.md deleted file mode 100644 index 892ee65..0000000 --- a/docs/changelog.d/feature-borgmatic-all-pg-backups.infra.md +++ /dev/null @@ -1 +0,0 @@ -Add borgmatic pg_dump backups for authentik and immich databases. Authentik uses the existing blumeops-pg cluster on port 5432. Immich requires a new borgmatic role on the immich-pg cluster, a Tailscale service, and Caddy L4 proxy on port 5433. diff --git a/docs/changelog.d/immich-photos-backup.feature.md b/docs/changelog.d/immich-photos-backup.feature.md deleted file mode 100644 index 6391af5..0000000 --- a/docs/changelog.d/immich-photos-backup.feature.md +++ /dev/null @@ -1 +0,0 @@ -Add offsite backup for immich photo library to BorgBase, running daily at 4 AM from indri via sifaka SMB mount. diff --git a/docs/changelog.d/upgrade-external-secrets-v2.infra.md b/docs/changelog.d/upgrade-external-secrets-v2.infra.md deleted file mode 100644 index 606a937..0000000 --- a/docs/changelog.d/upgrade-external-secrets-v2.infra.md +++ /dev/null @@ -1 +0,0 @@ -Upgrade External Secrets Operator from v1.3.2 to v2.2.0 and migrate from Helm chart to static kustomize manifests. From 6b1717bf28ec6f634e85c48081bfb9c38931beae Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sat, 28 Mar 2026 21:06:16 -0700 Subject: [PATCH 151/430] Add Kingfisher secret scanner to prek hooks Running alongside TruffleHog to compare coverage. Kingfisher uses staged-only mode with validation disabled for fast, offline-safe pre-commit checks. Validation will be enabled in the planned cron job. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/changelog.d/+kingfisher-prek.feature.md | 1 + prek.toml | 19 ++++++++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 docs/changelog.d/+kingfisher-prek.feature.md diff --git a/docs/changelog.d/+kingfisher-prek.feature.md b/docs/changelog.d/+kingfisher-prek.feature.md new file mode 100644 index 0000000..dadedc1 --- /dev/null +++ b/docs/changelog.d/+kingfisher-prek.feature.md @@ -0,0 +1 @@ +Add MongoDB Kingfisher secret scanner as a prek hook alongside TruffleHog for comparative coverage evaluation. diff --git a/prek.toml b/prek.toml index b679a6f..7f0f9ab 100644 --- a/prek.toml +++ b/prek.toml @@ -25,7 +25,7 @@ repo = "https://github.com/pre-commit/pre-commit-hooks" rev = "v6.0.0" hooks = [{ id = "check-yaml", args = ["--unsafe"] }] -# Secret detection +# Secret detection (running both tools in parallel to compare coverage) [[repos]] repo = "https://github.com/trufflesecurity/trufflehog" rev = "v3.94.0" @@ -36,6 +36,23 @@ hooks = [ ] }, ] +[[repos]] +repo = "https://github.com/mongodb/kingfisher" +rev = "v1.91.0" +hooks = [ + { id = "kingfisher", args = [ + "scan", + ".", + "--staged", + "--quiet", + "--no-update-check", + "--no-validate", + ], stages = [ + "pre-commit", + "pre-push", + ] }, +] + # YAML linting [[repos]] repo = "https://github.com/adrienverge/yamllint" From 35705faca2e1867117a266e8d9f5aed853fff5b4 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sat, 28 Mar 2026 21:39:55 -0700 Subject: [PATCH 152/430] Add Kingfisher secret scanner CronJob (#317) ## Summary - Deploys MongoDB Kingfisher as a weekly CronJob on minikube-indri - Scans all Forgejo repos (eblume + all orgs) for leaked secrets with live validation - Produces timestamped HTML and JSON reports on sifaka NFS (`/volume1/reports/kingfisher/`) - Forgejo API token sourced from 1Password via ExternalSecret - Uses official `ghcr.io/mongodb/kingfisher:1.91.0` container image - Runs Sunday 4am (after Prowler's 3am k8s scan) ## Resources - CronJob, PV/PVC (sifaka NFS), ExternalSecret - ArgoCD Application with manual sync + CreateNamespace ## Test plan - [x] Sync ArgoCD `apps` app to pick up new kingfisher Application - [x] Set `--revision feature/kingfisher-cronjob` on kingfisher app - [x] Verify ExternalSecret creates the `kingfisher-forgejo-token` Secret - [x] Trigger manual job: `kubectl create job --from=cronjob/kingfisher kingfisher-manual -n kingfisher --context=minikube-indri` - [ ] Verify reports appear on sifaka at `/volume1/reports/kingfisher/` - [ ] After merge: set `--revision main` and re-sync Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/317 --- argocd/apps/kingfisher.yaml | 17 +++++ argocd/manifests/kingfisher/cronjob.yaml | 65 +++++++++++++++++++ .../manifests/kingfisher/external-secret.yaml | 22 +++++++ .../manifests/kingfisher/kustomization.yaml | 15 +++++ argocd/manifests/kingfisher/pv-nfs.yaml | 17 +++++ argocd/manifests/kingfisher/pvc.yaml | 13 ++++ .../feature-kingfisher-cronjob.feature.md | 1 + 7 files changed, 150 insertions(+) create mode 100644 argocd/apps/kingfisher.yaml create mode 100644 argocd/manifests/kingfisher/cronjob.yaml create mode 100644 argocd/manifests/kingfisher/external-secret.yaml create mode 100644 argocd/manifests/kingfisher/kustomization.yaml create mode 100644 argocd/manifests/kingfisher/pv-nfs.yaml create mode 100644 argocd/manifests/kingfisher/pvc.yaml create mode 100644 docs/changelog.d/feature-kingfisher-cronjob.feature.md diff --git a/argocd/apps/kingfisher.yaml b/argocd/apps/kingfisher.yaml new file mode 100644 index 0000000..27d24a5 --- /dev/null +++ b/argocd/apps/kingfisher.yaml @@ -0,0 +1,17 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: kingfisher + namespace: argocd +spec: + project: default + source: + repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git + targetRevision: main + path: argocd/manifests/kingfisher + destination: + server: https://kubernetes.default.svc + namespace: kingfisher + syncPolicy: + syncOptions: + - CreateNamespace=true diff --git a/argocd/manifests/kingfisher/cronjob.yaml b/argocd/manifests/kingfisher/cronjob.yaml new file mode 100644 index 0000000..c035940 --- /dev/null +++ b/argocd/manifests/kingfisher/cronjob.yaml @@ -0,0 +1,65 @@ +--- +apiVersion: batch/v1 +kind: CronJob +metadata: + name: kingfisher + namespace: kingfisher +spec: + schedule: "0 4 * * 0" # Sunday 4am (after Prowler k8s scan at 3am) + concurrencyPolicy: Forbid + jobTemplate: + spec: + ttlSecondsAfterFinished: 604800 # Auto-delete after 7 days + template: + spec: + securityContext: + seccompProfile: + type: RuntimeDefault + containers: + - name: kingfisher + image: ghcr.io/mongodb/kingfisher:kustomized + command: ["/bin/sh", "-c"] + args: + - | + set -e + STAMP=$(date +%Y%m%d-%H%M%S) + OUTDIR=/reports/kingfisher + mkdir -p "$OUTDIR" + + COMMON_ARGS="scan gitea \ + --api-url https://forge.ops.eblu.me/api/v1/ \ + --user eblume \ + --repo-type all \ + --no-update-check \ + --tls-mode lax \ + --allow-internal-ips" + + # HTML report for human review + kingfisher $COMMON_ARGS \ + --format html \ + --output "$OUTDIR/scan-${STAMP}.html" || true + + # JSON report for machine parsing + kingfisher $COMMON_ARGS \ + --format json \ + --output "$OUTDIR/scan-${STAMP}.json" + env: + - name: KF_GITEA_TOKEN + valueFrom: + secretKeyRef: + name: kingfisher-forgejo-token + key: KF_GITEA_TOKEN + volumeMounts: + - name: reports + mountPath: /reports + resources: + requests: + memory: 256Mi + cpu: 100m + limits: + memory: 1Gi + restartPolicy: OnFailure + volumes: + - name: reports + persistentVolumeClaim: + claimName: kingfisher-reports diff --git a/argocd/manifests/kingfisher/external-secret.yaml b/argocd/manifests/kingfisher/external-secret.yaml new file mode 100644 index 0000000..6f6a5f2 --- /dev/null +++ b/argocd/manifests/kingfisher/external-secret.yaml @@ -0,0 +1,22 @@ +# ExternalSecret for Forgejo API token used by Kingfisher to enumerate repos +# +# 1Password item: "Forgejo Secrets" in blumeops vault +# Field: api-token +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: kingfisher-forgejo-token + namespace: kingfisher +spec: + refreshInterval: 1h + secretStoreRef: + kind: ClusterSecretStore + name: onepassword-blumeops + target: + name: kingfisher-forgejo-token + creationPolicy: Owner + data: + - secretKey: KF_GITEA_TOKEN + remoteRef: + key: Forgejo Secrets + property: api-token diff --git a/argocd/manifests/kingfisher/kustomization.yaml b/argocd/manifests/kingfisher/kustomization.yaml new file mode 100644 index 0000000..97d951c --- /dev/null +++ b/argocd/manifests/kingfisher/kustomization.yaml @@ -0,0 +1,15 @@ +--- +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: kingfisher + +resources: + - pv-nfs.yaml + - pvc.yaml + - external-secret.yaml + - cronjob.yaml + +images: + - name: ghcr.io/mongodb/kingfisher + newTag: "1.91.0" diff --git a/argocd/manifests/kingfisher/pv-nfs.yaml b/argocd/manifests/kingfisher/pv-nfs.yaml new file mode 100644 index 0000000..ddff3b1 --- /dev/null +++ b/argocd/manifests/kingfisher/pv-nfs.yaml @@ -0,0 +1,17 @@ +# NFS PersistentVolume for Kingfisher secret scan reports +# Reuses the same sifaka:/volume1/reports share as Prowler +# NFS rules already configured for indri +apiVersion: v1 +kind: PersistentVolume +metadata: + name: kingfisher-reports-nfs-pv +spec: + capacity: + storage: 1Gi + accessModes: + - ReadWriteMany + persistentVolumeReclaimPolicy: Retain + storageClassName: "" + nfs: + server: sifaka + path: /volume1/reports diff --git a/argocd/manifests/kingfisher/pvc.yaml b/argocd/manifests/kingfisher/pvc.yaml new file mode 100644 index 0000000..f48da95 --- /dev/null +++ b/argocd/manifests/kingfisher/pvc.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: kingfisher-reports + namespace: kingfisher +spec: + accessModes: + - ReadWriteMany + storageClassName: "" + volumeName: kingfisher-reports-nfs-pv + resources: + requests: + storage: 1Gi diff --git a/docs/changelog.d/feature-kingfisher-cronjob.feature.md b/docs/changelog.d/feature-kingfisher-cronjob.feature.md new file mode 100644 index 0000000..871c9d8 --- /dev/null +++ b/docs/changelog.d/feature-kingfisher-cronjob.feature.md @@ -0,0 +1 @@ +Add Kingfisher secret scanner as a weekly CronJob scanning all Forgejo repos, with HTML and JSON reports written to sifaka NFS. From 2808ffd450327abfa971f904f15f96708c911d0a Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sat, 28 Mar 2026 21:47:37 -0700 Subject: [PATCH 153/430] Document Kingfisher secret scanner service Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/changelog.d/+kingfisher-docs.doc.md | 1 + docs/reference/operations/security.md | 1 + docs/reference/services/kingfisher.md | 55 ++++++++++++++++++++++++ 3 files changed, 57 insertions(+) create mode 100644 docs/changelog.d/+kingfisher-docs.doc.md create mode 100644 docs/reference/services/kingfisher.md diff --git a/docs/changelog.d/+kingfisher-docs.doc.md b/docs/changelog.d/+kingfisher-docs.doc.md new file mode 100644 index 0000000..42fe085 --- /dev/null +++ b/docs/changelog.d/+kingfisher-docs.doc.md @@ -0,0 +1 @@ +Add service reference documentation for Kingfisher secret scanner. diff --git a/docs/reference/operations/security.md b/docs/reference/operations/security.md index d66efe1..17a6ff6 100644 --- a/docs/reference/operations/security.md +++ b/docs/reference/operations/security.md @@ -24,6 +24,7 @@ Security posture and compliance scanning for BlumeOps infrastructure. - [[prowler]] — CIS Kubernetes Benchmark scanner (weekly CronJob) - [[deploy-prowler]] — deployment and ad-hoc scan how-to - [[read-compliance-reports]] — accessing and interpreting reports +- [[kingfisher]] — Secret detection and live validation for Forgejo repos (weekly CronJob + prek hook) ## Identity & access diff --git a/docs/reference/services/kingfisher.md b/docs/reference/services/kingfisher.md new file mode 100644 index 0000000..dea47f1 --- /dev/null +++ b/docs/reference/services/kingfisher.md @@ -0,0 +1,55 @@ +--- +title: Kingfisher +modified: 2026-03-28 +last-reviewed: 2026-03-28 +tags: + - service + - security +--- + +# Kingfisher + +Secret detection and live validation scanner for Forgejo repositories, using MongoDB's open-source [Kingfisher](https://github.com/mongodb/kingfisher) tool. + +## Quick Reference + +| Property | Value | +|----------|-------| +| **Namespace** | `kingfisher` | +| **Image** | `ghcr.io/mongodb/kingfisher` (see `argocd/manifests/kingfisher/kustomization.yaml` for current tag) | +| **Schedule** | Sunday 4am (after Prowler k8s scan at 3am) | +| **Reports** | `sifaka:/volume1/reports/kingfisher/` (NFS) | +| **Manifests** | `argocd/manifests/kingfisher/` | +| **Upstream** | `forge.eblu.me/mirrors/kingfisher` (GitHub mirror) | + +## What it does + +Runs as a weekly CronJob that scans all repositories in the `eblume` user on Forgejo for leaked secrets, API keys, and credentials. Produces timestamped HTML and JSON reports on the sifaka NFS share. + +Uses the Forgejo/Gitea API to enumerate repos, then clones and scans each one. Validation is enabled (secrets are tested against their respective APIs to confirm they're live). + +## Pre-commit hook + +Kingfisher also runs as a prek hook alongside TruffleHog for comparative secret detection coverage. The hook uses `--staged` mode (only checks staged files) with validation disabled for fast, offline-safe commits. + +## Known false positives + +- **Postgres URL with `op://` template** — 1Password External Secrets template references match the postgres connection string pattern. Not a real credential. +- **GitHub legacy secret key in `.git/`** — git commit SHAs are 40-char hex strings matching the old GitHub PAT format. Only appears in full-repo scans, not `--staged` mode. + +## Ad-hoc scan + +```fish +kubectl create job --from=cronjob/kingfisher kingfisher-manual -n kingfisher --context=minikube-indri +kubectl logs -f job/kingfisher-manual -n kingfisher --context=minikube-indri +``` + +## Limitations + +- Clone URLs come from Forgejo's API response using the instance's public `ROOT_URL` (`forge.eblu.me`), so clones roundtrip through Fly.io. Mirror/org scanning is excluded for now to avoid unnecessary external bandwidth. A clone URL rewrite option would need an upstream contribution. +- Only one output format per invocation, so the CronJob runs Kingfisher twice (HTML then JSON). + +## See also + +- [[prowler]] — CIS Kubernetes, image, and IaC compliance scanning +- [[read-compliance-reports]] — how to access and interpret reports From bb603699562295b55e62b7f1c9868e5ed2e7ea3d Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sat, 28 Mar 2026 21:50:54 -0700 Subject: [PATCH 154/430] Simplify Kingfisher CronJob to HTML-only output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the second scan pass for JSON — one format is enough for now. Co-Authored-By: Claude Opus 4.6 (1M context) --- argocd/manifests/kingfisher/cronjob.yaml | 14 +++----------- docs/reference/services/kingfisher.md | 4 ++-- 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/argocd/manifests/kingfisher/cronjob.yaml b/argocd/manifests/kingfisher/cronjob.yaml index c035940..0efd7ab 100644 --- a/argocd/manifests/kingfisher/cronjob.yaml +++ b/argocd/manifests/kingfisher/cronjob.yaml @@ -26,23 +26,15 @@ spec: OUTDIR=/reports/kingfisher mkdir -p "$OUTDIR" - COMMON_ARGS="scan gitea \ + kingfisher scan gitea \ --api-url https://forge.ops.eblu.me/api/v1/ \ --user eblume \ --repo-type all \ --no-update-check \ --tls-mode lax \ - --allow-internal-ips" - - # HTML report for human review - kingfisher $COMMON_ARGS \ + --allow-internal-ips \ --format html \ - --output "$OUTDIR/scan-${STAMP}.html" || true - - # JSON report for machine parsing - kingfisher $COMMON_ARGS \ - --format json \ - --output "$OUTDIR/scan-${STAMP}.json" + --output "$OUTDIR/scan-${STAMP}.html" env: - name: KF_GITEA_TOKEN valueFrom: diff --git a/docs/reference/services/kingfisher.md b/docs/reference/services/kingfisher.md index dea47f1..d6c5cf2 100644 --- a/docs/reference/services/kingfisher.md +++ b/docs/reference/services/kingfisher.md @@ -26,7 +26,7 @@ Secret detection and live validation scanner for Forgejo repositories, using Mon Runs as a weekly CronJob that scans all repositories in the `eblume` user on Forgejo for leaked secrets, API keys, and credentials. Produces timestamped HTML and JSON reports on the sifaka NFS share. -Uses the Forgejo/Gitea API to enumerate repos, then clones and scans each one. Validation is enabled (secrets are tested against their respective APIs to confirm they're live). +Uses the Forgejo/Gitea API to enumerate repos, then clones and scans each one. Validation is enabled (secrets are tested against their respective APIs to confirm they're live). Reports are HTML only. ## Pre-commit hook @@ -47,7 +47,7 @@ kubectl logs -f job/kingfisher-manual -n kingfisher --context=minikube-indri ## Limitations - Clone URLs come from Forgejo's API response using the instance's public `ROOT_URL` (`forge.eblu.me`), so clones roundtrip through Fly.io. Mirror/org scanning is excluded for now to avoid unnecessary external bandwidth. A clone URL rewrite option would need an upstream contribution. -- Only one output format per invocation, so the CronJob runs Kingfisher twice (HTML then JSON). +- Only one output format per invocation. Currently producing HTML only. ## See also From 6ecfaf02b66cad8cc21cd12453befa481ad1eb2d Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sat, 28 Mar 2026 22:58:10 -0700 Subject: [PATCH 155/430] Add spork strategy: tooling and documentation Spork-create mise task sets up a floating-branch soft-fork of a mirrored upstream project with daily mirror-sync via Forgejo Actions. Includes explanation card, how-to guides for setup and branch management, and the spork-create uv script. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/changelog.d/+spork-strategy.feature.md | 1 + docs/explanation/spork-strategy.md | 41 ++ docs/how-to/configuration/create-a-spork.md | 84 ++++ .../configuration/manage-forgejo-mirrors.md | 2 + .../configuration/manage-spork-branches.md | 116 +++++ mise-tasks/spork-create | 431 ++++++++++++++++++ 6 files changed, 675 insertions(+) create mode 100644 docs/changelog.d/+spork-strategy.feature.md create mode 100644 docs/explanation/spork-strategy.md create mode 100644 docs/how-to/configuration/create-a-spork.md create mode 100644 docs/how-to/configuration/manage-spork-branches.md create mode 100755 mise-tasks/spork-create diff --git a/docs/changelog.d/+spork-strategy.feature.md b/docs/changelog.d/+spork-strategy.feature.md new file mode 100644 index 0000000..1f47bc1 --- /dev/null +++ b/docs/changelog.d/+spork-strategy.feature.md @@ -0,0 +1 @@ +Add spork strategy: floating-branch soft-fork tooling (`mise run spork-create`) and documentation for maintaining local patches against upstream projects. diff --git a/docs/explanation/spork-strategy.md b/docs/explanation/spork-strategy.md new file mode 100644 index 0000000..0b885f8 --- /dev/null +++ b/docs/explanation/spork-strategy.md @@ -0,0 +1,41 @@ +--- +title: Spork Strategy +modified: 2026-03-28 +last-reviewed: 2026-03-28 +tags: + - explanation + - git + - forgejo +--- + +# Spork Strategy + +> **Note:** This article was drafted by AI and reviewed by Erich. I plan to rewrite all explanatory content in my own words - these serve as placeholders to establish the documentation structure. + +A "spork" is a floating-branch soft-fork strategy for maintaining local changes against upstream projects without creating a true fork. The name: a fork that's trying its hardest not to be one. + +## The problem + +We mirror upstream projects on forge for supply-chain control. Sometimes we need to carry local patches — workflow support, build tooling, bug fixes. A real fork diverges silently until merge day becomes a nightmare. A spork stays perpetually close to upstream with patches "floating" on top, rebased daily. + +## The trade-off + +A spork chooses "small frequent pain" (constant rebasing, shifting branch targets) over "rare catastrophic pain" (fork divergence). For a solo operator carrying a handful of patches, this is the right trade-off. The key property: `git log main..blumeops` always shows your complete delta from upstream. No mystery divergence. + +Long-lived work against a sporked repo must accept that there is no "safe" branch — everything is an ever-shifting target. Anyone with a local checkout needs to be comfortable with `git pull --rebase`. + +## Architecture + +Three remotes, five branch types, one daily sync workflow. The `blumeops` branch is the default — it looks just like upstream with local workflows overlaid. Feature branches come in two flavors: upstreamable (branched off `main`, clean for contribution) and non-upstreamable (branched off `blumeops`, local-only). A `deploy` branch merges everything together as a build artifact. + +Forgejo Actions only checks `.forgejo/workflows/` when that directory exists, so upstream's `.github/workflows/` won't run on forge — no deletion needed. If upstream has its own `.forgejo/` directory (rare), it's removed during spork setup. + +## How-to guides + +- [[create-a-spork]] — initial setup with `mise run spork-create` +- [[manage-spork-branches]] — feature branches, the deploy branch, handling rebase conflicts + +## See also + +- [[manage-forgejo-mirrors]] — how upstream mirrors work +- [[kingfisher]] — first project using the spork strategy diff --git a/docs/how-to/configuration/create-a-spork.md b/docs/how-to/configuration/create-a-spork.md new file mode 100644 index 0000000..cd1a6f3 --- /dev/null +++ b/docs/how-to/configuration/create-a-spork.md @@ -0,0 +1,84 @@ +--- +title: Create a Spork +modified: 2026-03-28 +last-reviewed: 2026-03-28 +tags: + - how-to + - git + - forgejo +--- + +# Create a Spork + +How to set up a floating-branch soft-fork ("spork") of a mirrored upstream project using `mise run spork-create`. + +## Prerequisites + +- Mirror already exists at `mirrors/` on forge (see [[manage-forgejo-mirrors]]) +- 1Password CLI authenticated (`op` CLI) +- SSH access to `forge.ops.eblu.me:2222` + +## Create the spork + +```fish +mise run spork-create kingfisher +``` + +This will: + +1. Fork `mirrors/kingfisher` → `eblume/kingfisher` on forge +2. Create a `blumeops` branch from upstream's main branch +3. Remove any upstream `.forgejo/` directory (if present) +4. Add `.forgejo/workflows/mirror-sync.yaml` and commit it +5. Set `blumeops` as the default branch +6. Clone to `~/code/3rd/kingfisher` with three remotes: `origin`, `mirror`, `upstream` + +Options: + +```fish +mise run spork-create kingfisher --dry-run # preview only +mise run spork-create kingfisher --no-clone # skip local clone +mise run spork-create kingfisher --main-branch dev # override branch name +``` + +## Verify the setup + +```fish +cd ~/code/3rd/kingfisher +git remote -v +# origin ssh://forgejo@forge.ops.eblu.me:2222/eblume/kingfisher.git (fetch) +# mirror ssh://forgejo@forge.ops.eblu.me:2222/mirrors/kingfisher.git (fetch) +# upstream https://github.com/mongodb/kingfisher.git (fetch) + +git branch -a +# * blumeops +# remotes/origin/blumeops +# remotes/origin/main +``` + +## What happens next + +The mirror-sync workflow runs daily at 05:00 UTC and: + +- Fast-forwards `main` from the mirror +- Rebases `blumeops` on top of `main` +- Rebases any `feature/local/*` and `feature/upstream/*` branches +- Rebuilds the `deploy` branch (all features merged) + +See [[manage-spork-branches]] for working with feature branches. + +## Terminology + +| Term | Meaning | +|------|---------| +| `origin` | Your mutable fork at `eblume/` on forge | +| `mirror` | Read-only upstream mirror at `mirrors/` on forge | +| `upstream` | Canonical upstream repository (e.g., GitHub) | +| `main` | Clean upstream tracking branch (may be named `master`, `dev`, etc.) | +| `blumeops` | Default branch — upstream + local workflows/tooling | +| `deploy` | Build artifact branch — everything merged, used for deployments | + +## See also + +- [[manage-spork-branches]] — creating feature branches, upstreamable vs local +- [[manage-forgejo-mirrors]] — mirror setup and PAT rotation diff --git a/docs/how-to/configuration/manage-forgejo-mirrors.md b/docs/how-to/configuration/manage-forgejo-mirrors.md index 7a1d36a..7f98549 100644 --- a/docs/how-to/configuration/manage-forgejo-mirrors.md +++ b/docs/how-to/configuration/manage-forgejo-mirrors.md @@ -145,3 +145,5 @@ Trigger a manual sync on one mirror to confirm the new PAT works: - [[forgejo]] — Forgejo service reference - [[gandi-operations]] — Similar PAT rotation workflow for Gandi DNS +- [[spork-strategy]] — floating-branch soft-fork strategy explanation +- [[create-a-spork]] — create a spork on top of a mirror diff --git a/docs/how-to/configuration/manage-spork-branches.md b/docs/how-to/configuration/manage-spork-branches.md new file mode 100644 index 0000000..82778ef --- /dev/null +++ b/docs/how-to/configuration/manage-spork-branches.md @@ -0,0 +1,116 @@ +--- +title: Manage Spork Branches +modified: 2026-03-28 +last-reviewed: 2026-03-28 +tags: + - how-to + - git + - forgejo +--- + +# Manage Spork Branches + +How to create, maintain, and reason about feature branches on a sporked repository. See [[create-a-spork]] for initial setup. + +## Branch types + +### Upstreamable features (`feature/upstream/*`) + +Changes intended to be contributed upstream. Branch off `main` so the diff is clean — no local tooling or workflows mixed in. + +```fish +cd ~/code/3rd/kingfisher +git fetch origin +git checkout -b feature/upstream/forgejo-support origin/main + +# Make changes, commit as normal +git push -u origin feature/upstream/forgejo-support +``` + +The mirror-sync workflow will automatically rebase this branch onto `main` each day. + +To see what the upstream contribution looks like: + +```fish +git log main..feature/upstream/forgejo-support --oneline +git diff main...feature/upstream/forgejo-support +``` + +To create a preview PR on forge (targets the mirror, not upstream): + +```fish +# From the eblume/ repo, PR targeting mirrors/:main +# This gives a public URL showing the diff without filing upstream +tea pr create --repo mirrors/kingfisher --head eblume/kingfisher:feature/upstream/forgejo-support --base main +``` + +When ready to contribute upstream, manually translate the branch to a GitHub PR. + +### Non-upstreamable features (`feature/local/*`) + +Local-only changes that will never go upstream. Branch off `blumeops` so you have access to all local tooling. + +```fish +cd ~/code/3rd/kingfisher +git fetch origin +git checkout -b feature/local/custom-rules origin/blumeops + +# Make changes, commit as normal +git push -u origin feature/local/custom-rules +``` + +The mirror-sync workflow will automatically rebase this branch onto `blumeops` each day. + +## The `deploy` branch + +The `deploy` branch is a build artifact — rebuilt fresh by mirror-sync daily. It contains everything merged together: `blumeops` + all `feature/local/*` + all `feature/upstream/*`. Use this branch for deployments (e.g., ArgoCD `targetRevision`). + +**Never commit to or work from `deploy`.** + +## Working with rebasing branches + +Because mirror-sync force-pushes rebased branches daily, local checkouts will diverge. Always pull with rebase: + +```fish +git pull --rebase origin feature/upstream/my-change +``` + +Or set it as default for the repo: + +```fish +git config pull.rebase true +``` + +This is the fundamental trade-off of the spork strategy: small frequent rebases instead of rare catastrophic merges. + +## When rebases fail + +If upstream changes conflict with a feature branch, mirror-sync will skip that branch and log an error. Recovery: + +```fish +cd ~/code/3rd/kingfisher +git fetch origin +git checkout feature/upstream/my-change +git rebase origin/main +# Resolve conflicts... +git push --force-with-lease origin feature/upstream/my-change +``` + +The next mirror-sync run will pick up the resolved branch and rebuild `deploy`. + +**TODO:** Rebase failures are currently only visible in the Forgejo Actions UI. Alerting via Grafana is planned but not yet implemented. + +## Future: `.spork.toml` + +For repos with multiple feature branches, a `.spork.toml` file on the `blumeops` branch could declare: + +- **Branch dependencies** (stacked branches — `bar` depends on `foo`) +- **Feature descriptions** (what the branch is for, in prose) +- **Upstream/local classification** (as an alternative to the naming convention) + +This is not yet implemented. For now, the `feature/upstream/*` vs `feature/local/*` naming convention is the source of truth. + +## See also + +- [[create-a-spork]] — initial setup +- [[manage-forgejo-mirrors]] — mirror setup and PAT rotation diff --git a/mise-tasks/spork-create b/mise-tasks/spork-create new file mode 100755 index 0000000..0ed5d86 --- /dev/null +++ b/mise-tasks/spork-create @@ -0,0 +1,431 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.12" +# dependencies = ["httpx>=0.28.1", "rich>=14.0.0", "typer>=0.24.0"] +# /// +#MISE description="Create a spork (floating-branch soft-fork) of a mirrored upstream project" +#USAGE arg "" help="Repository name in the mirrors/ org on forge (e.g. kingfisher)" +#USAGE flag "--description " help="Repository description override" +#USAGE flag "--main-branch " help="Name of the upstream main branch (default: auto-detect)" +#USAGE flag "--dry-run" help="Show what would be done without creating" +#USAGE flag "--no-clone" help="Skip cloning to ~/code/3rd/" +"""Create a spork of a mirrored upstream project. + +A "spork" is a floating-branch soft-fork strategy. It creates a mutable +clone of a read-only mirror in the eblume/ org on Forge, sets up a +'blumeops' branch with a mirror-sync workflow, and optionally clones +locally with the three-remote setup (origin, mirror, upstream). + +Prerequisites: + - Mirror must already exist at mirrors/ on forge + - 1Password CLI authenticated (for Forge API token) + +See docs/explanation/spork-strategy.md for the full strategy. +""" + +import json +import subprocess +import sys +import textwrap +from pathlib import Path +from typing import Annotated, Optional + +import httpx +import typer +from rich.console import Console + +FORGE_URL = "https://forge.eblu.me" +FORGE_API = f"{FORGE_URL}/api/v1" +FORGE_SSH = "ssh://forgejo@forge.ops.eblu.me:2222" +MIRROR_ORG = "mirrors" +OWNER = "eblume" +LOCAL_BASE = Path.home() / "code" / "3rd" +OP_TOKEN_REF = "op://blumeops/w3663ffnvkewbftncqxtcpeavy/api-token" + +console = Console() +app = typer.Typer(add_completion=False) + + +def op_read(ref: str) -> str: + """Read a secret from 1Password.""" + result = subprocess.run( + ["op", "read", ref], capture_output=True, text=True, check=True + ) + return result.stdout.strip() + + +def forge_api( + client: httpx.Client, + method: str, + path: str, + token: str, + json_data: dict | None = None, +) -> httpx.Response: + """Make an authenticated Forge API request.""" + return client.request( + method, + f"{FORGE_API}{path}", + headers={"Authorization": f"token {token}"}, + json=json_data, + ) + + +def get_mirror_info(client: httpx.Client, token: str, name: str) -> dict: + """Get mirror repo info, or exit if it doesn't exist.""" + resp = forge_api(client, "GET", f"/repos/{MIRROR_ORG}/{name}", token) + if resp.status_code == 404: + console.print( + f"[red]Error:[/red] Mirror [bold]{MIRROR_ORG}/{name}[/bold] not found on forge." + ) + console.print("Create it first: [dim]mise run mirror-create [/dim]") + raise SystemExit(1) + resp.raise_for_status() + return resp.json() + + +def detect_main_branch(client: httpx.Client, token: str, name: str) -> str: + """Detect the default branch of the mirror.""" + info = get_mirror_info(client, token, name) + return info.get("default_branch", "main") + + +def repo_exists(client: httpx.Client, token: str, owner: str, name: str) -> bool: + """Check if a repo already exists.""" + resp = forge_api(client, "GET", f"/repos/{owner}/{name}", token) + return resp.status_code == 200 + + +def fork_mirror(client: httpx.Client, token: str, name: str) -> dict: + """Fork the mirror into the eblume org.""" + resp = forge_api( + client, + "POST", + f"/repos/{MIRROR_ORG}/{name}/forks", + token, + json_data={"name": name}, + ) + if resp.status_code == 409: + console.print(f"[yellow]Fork already exists:[/yellow] {OWNER}/{name}") + return forge_api(client, "GET", f"/repos/{OWNER}/{name}", token).json() + resp.raise_for_status() + return resp.json() + + +def mirror_sync_workflow(main_branch: str, repo_name: str) -> str: + """Generate the mirror-sync workflow YAML.""" + return textwrap.dedent(f"""\ + # Mirror Sync — Spork Strategy + # + # Keeps the '{main_branch}' branch tracking upstream (via mirror) and + # rebases the 'blumeops' branch on top. See docs/explanation/spork-strategy.md + # in the blumeops repo for the full strategy. + # + # On conflict: the workflow fails. Manual rebase resolution required. + + name: Mirror Sync + + on: + schedule: + - cron: '0 5 * * *' # Daily at 05:00 UTC + workflow_dispatch: + + jobs: + sync: + runs-on: k8s + steps: + - name: Checkout blumeops branch + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: blumeops + fetch-depth: 0 + + - name: Configure git + run: | + git config user.name "Forgejo Actions" + git config user.email "actions@forge.eblu.me" + + - name: Add mirror remote + run: | + git remote add mirror "${{{{ env.MIRROR_URL }}}}" || true + git fetch mirror + env: + MIRROR_URL: {FORGE_URL}/{MIRROR_ORG}/{repo_name}.git + + - name: Fast-forward {main_branch} from mirror + run: | + git checkout {main_branch} + git merge --ff-only mirror/{main_branch} + git push origin {main_branch} + + - name: Rebase blumeops onto {main_branch} + run: | + git checkout blumeops + git rebase {main_branch} + git push --force-with-lease origin blumeops + + - name: Rebase feature branches + run: | + # Rebase feature/local/* onto blumeops + for branch in $(git branch -r --list 'origin/feature/local/*'); do + local_name="${{branch#origin/}}" + echo "Rebasing $local_name onto blumeops..." + git checkout -B "$local_name" "$branch" + git rebase blumeops || {{ + echo "::error::Rebase conflict on $local_name" + git rebase --abort + continue + }} + git push --force-with-lease origin "$local_name" + done + + # Rebase feature/upstream/* onto {main_branch} + for branch in $(git branch -r --list 'origin/feature/upstream/*'); do + local_name="${{branch#origin/}}" + echo "Rebasing $local_name onto {main_branch}..." + git checkout -B "$local_name" "$branch" + git rebase {main_branch} || {{ + echo "::error::Rebase conflict on $local_name" + git rebase --abort + continue + }} + git push --force-with-lease origin "$local_name" + done + + - name: Build deploy branch + run: | + git checkout -B deploy blumeops + + # Merge all feature branches into deploy + for branch in $(git branch -r --list 'origin/feature/local/*' 'origin/feature/upstream/*'); do + local_name="${{branch#origin/}}" + echo "Merging $local_name into deploy..." + git merge --no-ff "$local_name" -m "deploy: merge $local_name" || {{ + echo "::error::Merge conflict on $local_name into deploy" + git merge --abort + continue + }} + done + + git push --force-with-lease origin deploy + """) + + +def set_default_branch( + client: httpx.Client, token: str, owner: str, name: str, branch: str +) -> None: + """Set the default branch of a repo.""" + resp = forge_api( + client, + "PATCH", + f"/repos/{owner}/{name}", + token, + json_data={"default_branch": branch}, + ) + resp.raise_for_status() + + +def get_upstream_url(client: httpx.Client, token: str, name: str) -> str | None: + """Try to find the upstream URL from the mirror's config.""" + info = get_mirror_info(client, token, name) + return info.get("original_url") or info.get("clone_url") + + +@app.command() +def main( + repo_name: Annotated[str, typer.Argument(help="Repository name in mirrors/ org")], + description: Annotated[Optional[str], typer.Option(help="Description override")] = None, + main_branch: Annotated[Optional[str], typer.Option("--main-branch", help="Upstream main branch name")] = None, + dry_run: Annotated[bool, typer.Option("--dry-run", help="Preview without creating")] = False, + no_clone: Annotated[bool, typer.Option("--no-clone", help="Skip local clone")] = False, +) -> None: + """Create a spork of a mirrored upstream project.""" + console.print(f"[bold]Sporking[/bold] {MIRROR_ORG}/{repo_name}") + console.print() + + token = op_read(OP_TOKEN_REF) + + with httpx.Client(timeout=30) as client: + # 1. Verify mirror exists and get info + mirror_info = get_mirror_info(client, token, repo_name) + detected_main = main_branch or mirror_info.get("default_branch", "main") + upstream_url = mirror_info.get("original_url", "unknown") + desc = description or mirror_info.get("description", "") + + console.print(f" Mirror: {MIRROR_ORG}/{repo_name}") + console.print(f" Upstream: {upstream_url}") + console.print(f" Main branch: {detected_main}") + console.print(f" Description: {desc}") + console.print(f" Fork target: {OWNER}/{repo_name}") + console.print(f" Local clone: {LOCAL_BASE / repo_name}") + console.print() + + if dry_run: + console.print("[dim][dry-run] Would perform the following:[/dim]") + console.print(f" 1. Fork {MIRROR_ORG}/{repo_name} → {OWNER}/{repo_name}") + console.print(f" 2. Create 'blumeops' branch from '{detected_main}'") + console.print(" 3. Add .forgejo/workflows/mirror-sync.yaml") + console.print(" 4. Set 'blumeops' as default branch") + if not no_clone: + console.print(f" 5. Clone to {LOCAL_BASE / repo_name} with 3 remotes") + return + + # 2. Fork (or confirm existing) + if repo_exists(client, token, OWNER, repo_name): + console.print(f"[yellow]Fork already exists:[/yellow] {OWNER}/{repo_name}") + else: + console.print("Forking mirror...") + fork_mirror(client, token, repo_name) + console.print(f"[green]Created fork:[/green] {OWNER}/{repo_name}") + + # 3. Clone to temp dir, create blumeops branch with workflow + console.print("Setting up blumeops branch...") + import tempfile + + with tempfile.TemporaryDirectory() as tmpdir: + clone_url = f"{FORGE_SSH}/{OWNER}/{repo_name}.git" + subprocess.run( + ["git", "clone", clone_url, tmpdir], + check=True, + capture_output=True, + ) + + # Create blumeops branch from detected main + subprocess.run( + ["git", "checkout", "-b", "blumeops", f"origin/{detected_main}"], + cwd=tmpdir, + check=True, + capture_output=True, + ) + + # Remove any upstream .forgejo/ directory (rare, but possible) + existing_forgejo = Path(tmpdir) / ".forgejo" + if existing_forgejo.exists(): + import shutil + shutil.rmtree(existing_forgejo) + console.print("[yellow]Removed upstream .forgejo/ directory[/yellow]") + + # Add mirror-sync workflow + workflow_dir = Path(tmpdir) / ".forgejo" / "workflows" + workflow_dir.mkdir(parents=True, exist_ok=True) + workflow_path = workflow_dir / "mirror-sync.yaml" + workflow_path.write_text(mirror_sync_workflow(detected_main, repo_name)) + + # Commit and push + subprocess.run( + ["git", "add", ".forgejo/"], + cwd=tmpdir, + check=True, + capture_output=True, + ) + subprocess.run( + ["git", "config", "user.name", "Erich Blume"], + cwd=tmpdir, + check=True, + capture_output=True, + ) + subprocess.run( + ["git", "config", "user.email", "blume.erich@gmail.com"], + cwd=tmpdir, + check=True, + capture_output=True, + ) + subprocess.run( + ["git", "commit", "-m", "spork: add mirror-sync workflow\n\nBootstrap the blumeops branch with the spork mirror-sync\nworkflow. See blumeops docs/explanation/spork-strategy.md."], + cwd=tmpdir, + check=True, + capture_output=True, + ) + subprocess.run( + ["git", "push", "-u", "origin", "blumeops"], + cwd=tmpdir, + check=True, + capture_output=True, + ) + console.print("[green]Created and pushed blumeops branch[/green]") + + # 4. Set default branch to blumeops + console.print("Setting default branch to blumeops...") + set_default_branch(client, token, OWNER, repo_name, "blumeops") + console.print("[green]Default branch set to blumeops[/green]") + + # 5. Local clone with three remotes + if no_clone: + console.print("[dim]Skipping local clone (--no-clone)[/dim]") + else: + local_path = LOCAL_BASE / repo_name + if local_path.exists(): + console.print(f"[yellow]Local directory already exists:[/yellow] {local_path}") + console.print("Setting up remotes on existing clone...") + # Add missing remotes + mirror_url = f"{FORGE_SSH}/{MIRROR_ORG}/{repo_name}.git" + subprocess.run( + ["git", "remote", "add", "mirror", mirror_url], + cwd=local_path, + capture_output=True, # may already exist + ) + if upstream_url and upstream_url != "unknown": + subprocess.run( + ["git", "remote", "add", "upstream", upstream_url], + cwd=local_path, + capture_output=True, + ) + subprocess.run( + ["git", "fetch", "--all"], + cwd=local_path, + check=True, + capture_output=True, + ) + else: + console.print(f"Cloning to {local_path}...") + clone_url = f"{FORGE_SSH}/{OWNER}/{repo_name}.git" + subprocess.run( + ["git", "clone", clone_url, str(local_path)], + check=True, + capture_output=True, + ) + # Add mirror and upstream remotes + mirror_url = f"{FORGE_SSH}/{MIRROR_ORG}/{repo_name}.git" + subprocess.run( + ["git", "remote", "add", "mirror", mirror_url], + cwd=local_path, + check=True, + capture_output=True, + ) + if upstream_url and upstream_url != "unknown": + subprocess.run( + ["git", "remote", "add", "upstream", upstream_url], + cwd=local_path, + check=True, + capture_output=True, + ) + subprocess.run( + ["git", "fetch", "--all"], + cwd=local_path, + check=True, + capture_output=True, + ) + console.print(f"[green]Local clone ready at {local_path}[/green]") + + # Summary + console.print() + console.print("[bold green]Spork complete![/bold green]") + console.print() + console.print(" Remotes:") + console.print(f" origin → {FORGE_URL}/{OWNER}/{repo_name}") + console.print(f" mirror → {FORGE_URL}/{MIRROR_ORG}/{repo_name}") + console.print(f" upstream → {upstream_url}") + console.print() + console.print(" Branches:") + console.print(f" {detected_main:<12} — clean upstream tracking (never commit here)") + console.print(" blumeops — local infra + workflows (default branch)") + console.print() + console.print(" Next steps:") + console.print(" • Create feature branches:") + console.print(f" git checkout -b feature/upstream/my-change {detected_main}") + console.print(" git checkout -b feature/local/my-change blumeops") + console.print(" • Mirror-sync runs daily at 05:00 UTC") + console.print(" • See: docs/explanation/spork-strategy.md") + + +if __name__ == "__main__": + app() From ee6f516b2bac34b029f9be46376e520ff00c6b67 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sat, 28 Mar 2026 23:03:38 -0700 Subject: [PATCH 156/430] spork-create: bail if local clone already exists Trying to add remotes to an existing clone gets the origin wrong. Better to error out and let the user handle it. Co-Authored-By: Claude Opus 4.6 (1M context) --- mise-tasks/spork-create | 74 +++++++++++++++++------------------------ 1 file changed, 30 insertions(+), 44 deletions(-) diff --git a/mise-tasks/spork-create b/mise-tasks/spork-create index 0ed5d86..9cf1b78 100755 --- a/mise-tasks/spork-create +++ b/mise-tasks/spork-create @@ -354,56 +354,42 @@ def main( else: local_path = LOCAL_BASE / repo_name if local_path.exists(): - console.print(f"[yellow]Local directory already exists:[/yellow] {local_path}") - console.print("Setting up remotes on existing clone...") - # Add missing remotes - mirror_url = f"{FORGE_SSH}/{MIRROR_ORG}/{repo_name}.git" - subprocess.run( - ["git", "remote", "add", "mirror", mirror_url], - cwd=local_path, - capture_output=True, # may already exist + console.print( + f"[red]Error:[/red] Local directory already exists: [bold]{local_path}[/bold]" ) - if upstream_url and upstream_url != "unknown": - subprocess.run( - ["git", "remote", "add", "upstream", upstream_url], - cwd=local_path, - capture_output=True, - ) + console.print( + "Remove it first or use [dim]--no-clone[/dim] to skip local setup." + ) + raise SystemExit(1) + + console.print(f"Cloning to {local_path}...") + clone_url = f"{FORGE_SSH}/{OWNER}/{repo_name}.git" + subprocess.run( + ["git", "clone", clone_url, str(local_path)], + check=True, + capture_output=True, + ) + # Add mirror and upstream remotes + mirror_url = f"{FORGE_SSH}/{MIRROR_ORG}/{repo_name}.git" + subprocess.run( + ["git", "remote", "add", "mirror", mirror_url], + cwd=local_path, + check=True, + capture_output=True, + ) + if upstream_url and upstream_url != "unknown": subprocess.run( - ["git", "fetch", "--all"], - cwd=local_path, - check=True, - capture_output=True, - ) - else: - console.print(f"Cloning to {local_path}...") - clone_url = f"{FORGE_SSH}/{OWNER}/{repo_name}.git" - subprocess.run( - ["git", "clone", clone_url, str(local_path)], - check=True, - capture_output=True, - ) - # Add mirror and upstream remotes - mirror_url = f"{FORGE_SSH}/{MIRROR_ORG}/{repo_name}.git" - subprocess.run( - ["git", "remote", "add", "mirror", mirror_url], - cwd=local_path, - check=True, - capture_output=True, - ) - if upstream_url and upstream_url != "unknown": - subprocess.run( - ["git", "remote", "add", "upstream", upstream_url], - cwd=local_path, - check=True, - capture_output=True, - ) - subprocess.run( - ["git", "fetch", "--all"], + ["git", "remote", "add", "upstream", upstream_url], cwd=local_path, check=True, capture_output=True, ) + subprocess.run( + ["git", "fetch", "--all"], + cwd=local_path, + check=True, + capture_output=True, + ) console.print(f"[green]Local clone ready at {local_path}[/green]") # Summary From 007b4fecdde6b5b303b2da6115551acac4dfd4d8 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sat, 28 Mar 2026 23:06:10 -0700 Subject: [PATCH 157/430] spork-create: preflight all checks before any mutations Check local path, mirror existence, and fork absence upfront. Fail fast with clear error messages before touching forge or disk. Co-Authored-By: Claude Opus 4.6 (1M context) --- mise-tasks/spork-create | 49 ++++++++++++++++++++++++----------------- 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/mise-tasks/spork-create b/mise-tasks/spork-create index 9cf1b78..db2f39c 100755 --- a/mise-tasks/spork-create +++ b/mise-tasks/spork-create @@ -242,21 +242,43 @@ def main( console.print(f"[bold]Sporking[/bold] {MIRROR_ORG}/{repo_name}") console.print() + # --- Preflight checks (fail fast before any mutations) --- + local_path = LOCAL_BASE / repo_name + if not no_clone and local_path.exists(): + console.print( + f"[red]Error:[/red] Local directory already exists: [bold]{local_path}[/bold]" + ) + console.print( + "Remove it first or use [dim]--no-clone[/dim] to skip local setup." + ) + raise SystemExit(1) + token = op_read(OP_TOKEN_REF) with httpx.Client(timeout=30) as client: - # 1. Verify mirror exists and get info + # Verify mirror exists mirror_info = get_mirror_info(client, token, repo_name) detected_main = main_branch or mirror_info.get("default_branch", "main") upstream_url = mirror_info.get("original_url", "unknown") desc = description or mirror_info.get("description", "") + # Verify fork doesn't already exist + if repo_exists(client, token, OWNER, repo_name): + console.print( + f"[red]Error:[/red] Fork already exists: [bold]{OWNER}/{repo_name}[/bold]" + ) + console.print( + "If re-sporking, delete the fork on forge first." + ) + raise SystemExit(1) + console.print(f" Mirror: {MIRROR_ORG}/{repo_name}") console.print(f" Upstream: {upstream_url}") console.print(f" Main branch: {detected_main}") console.print(f" Description: {desc}") console.print(f" Fork target: {OWNER}/{repo_name}") - console.print(f" Local clone: {LOCAL_BASE / repo_name}") + if not no_clone: + console.print(f" Local clone: {local_path}") console.print() if dry_run: @@ -266,16 +288,13 @@ def main( console.print(" 3. Add .forgejo/workflows/mirror-sync.yaml") console.print(" 4. Set 'blumeops' as default branch") if not no_clone: - console.print(f" 5. Clone to {LOCAL_BASE / repo_name} with 3 remotes") + console.print(f" 5. Clone to {local_path} with 3 remotes") return - # 2. Fork (or confirm existing) - if repo_exists(client, token, OWNER, repo_name): - console.print(f"[yellow]Fork already exists:[/yellow] {OWNER}/{repo_name}") - else: - console.print("Forking mirror...") - fork_mirror(client, token, repo_name) - console.print(f"[green]Created fork:[/green] {OWNER}/{repo_name}") + # --- All checks passed, start mutating --- + console.print("Forking mirror...") + fork_mirror(client, token, repo_name) + console.print(f"[green]Created fork:[/green] {OWNER}/{repo_name}") # 3. Clone to temp dir, create blumeops branch with workflow console.print("Setting up blumeops branch...") @@ -352,16 +371,6 @@ def main( if no_clone: console.print("[dim]Skipping local clone (--no-clone)[/dim]") else: - local_path = LOCAL_BASE / repo_name - if local_path.exists(): - console.print( - f"[red]Error:[/red] Local directory already exists: [bold]{local_path}[/bold]" - ) - console.print( - "Remove it first or use [dim]--no-clone[/dim] to skip local setup." - ) - raise SystemExit(1) - console.print(f"Cloning to {local_path}...") clone_url = f"{FORGE_SSH}/{OWNER}/{repo_name}.git" subprocess.run( From 21b465bf1833563f76083cdb0b5acc8cdbc0da7c Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sun, 29 Mar 2026 00:26:25 -0700 Subject: [PATCH 158/430] spork-create: enable Actions on fork after creation Forks from mirror repos have has_actions disabled by default. PATCH the repo settings to enable it so the mirror-sync workflow runs. Co-Authored-By: Claude Opus 4.6 (1M context) --- mise-tasks/spork-create | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/mise-tasks/spork-create b/mise-tasks/spork-create index db2f39c..789cc4e 100755 --- a/mise-tasks/spork-create +++ b/mise-tasks/spork-create @@ -296,6 +296,18 @@ def main( fork_mirror(client, token, repo_name) console.print(f"[green]Created fork:[/green] {OWNER}/{repo_name}") + # Enable Actions (forks from mirrors have it disabled by default) + console.print("Enabling Actions...") + resp = forge_api( + client, + "PATCH", + f"/repos/{OWNER}/{repo_name}", + token, + json_data={"has_actions": True}, + ) + resp.raise_for_status() + console.print("[green]Actions enabled[/green]") + # 3. Clone to temp dir, create blumeops branch with workflow console.print("Setting up blumeops branch...") import tempfile From 4e09aed9d8e6076f12d226c392956ed9a788af80 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sun, 29 Mar 2026 00:28:00 -0700 Subject: [PATCH 159/430] spork-create: fix ambiguous main branch checkout in mirror-sync template git checkout is ambiguous when both origin and mirror remotes have the same branch name. Use -B to explicitly create from origin. Co-Authored-By: Claude Opus 4.6 (1M context) --- mise-tasks/spork-create | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mise-tasks/spork-create b/mise-tasks/spork-create index 789cc4e..4aed2b1 100755 --- a/mise-tasks/spork-create +++ b/mise-tasks/spork-create @@ -153,7 +153,7 @@ def mirror_sync_workflow(main_branch: str, repo_name: str) -> str: - name: Fast-forward {main_branch} from mirror run: | - git checkout {main_branch} + git checkout -B {main_branch} origin/{main_branch} git merge --ff-only mirror/{main_branch} git push origin {main_branch} From e1429fc3e7a4963485247ff84096bdcc2e0ef571 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sun, 29 Mar 2026 08:16:09 -0700 Subject: [PATCH 160/430] Document Spork Attack supply-chain risk Upstream can push workflows (in .github/ or .forgejo/) that execute on our runners via any trigger mechanism including cron. Runner label mismatch is the current defense but is fragile. No complete fix exists short of disabling Actions entirely. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/explanation/spork-strategy.md | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/docs/explanation/spork-strategy.md b/docs/explanation/spork-strategy.md index 0b885f8..ce1c999 100644 --- a/docs/explanation/spork-strategy.md +++ b/docs/explanation/spork-strategy.md @@ -28,7 +28,33 @@ Long-lived work against a sporked repo must accept that there is no "safe" branc Three remotes, five branch types, one daily sync workflow. The `blumeops` branch is the default — it looks just like upstream with local workflows overlaid. Feature branches come in two flavors: upstreamable (branched off `main`, clean for contribution) and non-upstreamable (branched off `blumeops`, local-only). A `deploy` branch merges everything together as a build artifact. -Forgejo Actions only checks `.forgejo/workflows/` when that directory exists, so upstream's `.github/workflows/` won't run on forge — no deletion needed. If upstream has its own `.forgejo/` directory (rare), it's removed during spork setup. +Forgejo Actions checks `.forgejo/workflows/` first; if that directory exists, `.github/workflows/` is ignored. This protects the `blumeops` branch and `feature/local/*` branches (which inherit `.forgejo/` from `blumeops`). However, `main` and `feature/upstream/*` branches do NOT have `.forgejo/workflows/` — they're clean upstream code — so Forgejo falls back to `.github/workflows/` on those branches. See [[spork-strategy#Spork Attack]] for the security implications. + +## Spork Attack + +A "spork attack" is a supply-chain risk inherent to the spork strategy. Because `main` and `feature/upstream/*` branches carry upstream's `.github/workflows/`, those workflows are registered by Forgejo Actions. If an upstream project publishes a workflow targeting runner labels that match your infrastructure, it will execute on your runners. + +**Attack chain:** + +1. Upstream pushes a workflow (in `.github/workflows/` or `.forgejo/workflows/`) with `runs-on: ` +2. Mirror auto-syncs, mirror-sync fast-forwards `main` on your fork +3. The workflow triggers — via push event, PR event, cron schedule, or any other trigger mechanism +4. Workflow executes on your runner with access to `GITHUB_TOKEN` and the runner's environment + +Note that a cron-triggered workflow is especially dangerous: it requires no user interaction at all. As soon as `main` is updated with the malicious workflow, Forgejo schedules it automatically. + +**Current mitigations:** + +- **Runner label mismatch** — our runner uses `k8s`, upstream workflows typically use `ubuntu-24.04` / `macos-latest` / `windows-latest`. Jobs queue but never execute. This is effective but fragile — it depends on upstream never guessing our label. +- **Trust boundary** — we only spork projects we trust. Kingfisher is maintained by a MongoDB security engineer. +- **Mirror review** — mirror syncs are visible in Forgejo; malicious workflow changes would appear in the commit history. But this is not a real-time defense — the workflow may execute before anyone reviews. + +**What would fix this properly:** + +- A Forgejo per-repo setting to disable workflow discovery entirely on specific branches, or to require an explicit allow-list of workflow files. Neither exists today. +- Runner-level repo allow-lists could limit blast radius, but the workflow files still come from the sporked repo via upstream, so the runner would still execute them. + +**Recommendation:** Use non-standard runner labels (not `ubuntu-latest`, `linux`, etc.) and only spork projects you trust. Document which projects are sporked and review upstream workflow changes periodically. Consider this an open problem — there is no complete defense short of disabling Actions on the repo entirely (which breaks mirror-sync). ## How-to guides From 99df78664eab1ec3758998b491c2128f2ffb557e Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sun, 29 Mar 2026 08:22:11 -0700 Subject: [PATCH 161/430] Note upstream history rewrite as a spork sync failure mode Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/how-to/configuration/manage-spork-branches.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/how-to/configuration/manage-spork-branches.md b/docs/how-to/configuration/manage-spork-branches.md index 82778ef..7bcf4fc 100644 --- a/docs/how-to/configuration/manage-spork-branches.md +++ b/docs/how-to/configuration/manage-spork-branches.md @@ -98,7 +98,7 @@ git push --force-with-lease origin feature/upstream/my-change The next mirror-sync run will pick up the resolved branch and rebuild `deploy`. -**TODO:** Rebase failures are currently only visible in the Forgejo Actions UI. Alerting via Grafana is planned but not yet implemented. +**TODO:** Workflow failures — whether from rebase conflicts or upstream history rewrites (force-push on main) — are currently only visible in the Forgejo Actions UI. Alerting via Grafana is planned but not yet implemented. ## Future: `.spork.toml` From 91150442190242736d1062014db47cbfb39565cc Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sun, 29 Mar 2026 09:36:53 -0700 Subject: [PATCH 162/430] spork-create: check for conflicting branch names before sporking Bail if upstream already has branches named 'blumeops' or 'deploy', which would conflict with the spork branch naming strategy. Co-Authored-By: Claude Opus 4.6 (1M context) --- mise-tasks/spork-create | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/mise-tasks/spork-create b/mise-tasks/spork-create index 4aed2b1..84d2999 100755 --- a/mise-tasks/spork-create +++ b/mise-tasks/spork-create @@ -272,6 +272,21 @@ def main( ) raise SystemExit(1) + # Verify upstream doesn't have branches that conflict with spork names + for reserved in ("blumeops", "deploy"): + resp = forge_api( + client, + "GET", + f"/repos/{MIRROR_ORG}/{repo_name}/branches/{reserved}", + token, + ) + if resp.status_code == 200: + console.print( + f"[red]Error:[/red] Upstream already has a branch named " + f"[bold]{reserved}[/bold] — this conflicts with the spork strategy." + ) + raise SystemExit(1) + console.print(f" Mirror: {MIRROR_ORG}/{repo_name}") console.print(f" Upstream: {upstream_url}") console.print(f" Main branch: {detected_main}") From 924325ebd51c91f60895ccf784105b312f247002 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sun, 29 Mar 2026 17:09:57 -0700 Subject: [PATCH 163/430] Fix DinD seccomp profile broken by RuntimeDefault rollout The pod-level RuntimeDefault seccomp profile (07e9c81) overrides the DinD sidecar's privileged flag in newer Kubernetes versions, blocking Docker daemon syscalls. Set Unconfined explicitly on the DinD container while keeping RuntimeDefault on the runner container. Co-Authored-By: Claude Opus 4.6 (1M context) --- argocd/manifests/forgejo-runner/deployment.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/argocd/manifests/forgejo-runner/deployment.yaml b/argocd/manifests/forgejo-runner/deployment.yaml index 1eda6dc..c793895 100644 --- a/argocd/manifests/forgejo-runner/deployment.yaml +++ b/argocd/manifests/forgejo-runner/deployment.yaml @@ -74,6 +74,8 @@ spec: image: docker:kustomized securityContext: privileged: true + seccompProfile: + type: Unconfined env: - name: DOCKER_TLS_CERTDIR value: "" From a842b9c1e815c5633f303871b2dc81a77dcc4c58 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sun, 29 Mar 2026 20:52:07 -0700 Subject: [PATCH 164/430] Skip kingfisher in CI container builds Kingfisher's Rust + Boost/vectorscan build exhausts indri's memory (aws-sdk-ec2 alone needs 2-3GB for rustc). Build locally on Gilbert and push manually until we have a beefier build host. Co-Authored-By: Claude Opus 4.6 (1M context) --- .forgejo/workflows/build-container.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.forgejo/workflows/build-container.yaml b/.forgejo/workflows/build-container.yaml index 6e5ed38..6b03742 100644 --- a/.forgejo/workflows/build-container.yaml +++ b/.forgejo/workflows/build-container.yaml @@ -46,10 +46,18 @@ jobs: echo "Changed containers: $CHANGED" + # Containers that are too resource-intensive to build on k8s (indri). + # These must be built locally and pushed manually. + SKIP_CONTAINERS="kingfisher" + # Classify each container by build type (a container can appear in both) DOCKERFILE='[]' NIX='[]' for name in $(echo "$CHANGED" | jq -r '.[]'); do + if echo "$SKIP_CONTAINERS" | grep -qw "$name"; then + echo "Skipping $name (in SKIP_CONTAINERS — build locally)" + continue + fi has_any=false if [ -f "containers/$name/Dockerfile" ]; then DOCKERFILE=$(echo "$DOCKERFILE" | jq -c --arg n "$name" '. + [$n]') From 99a1a4917526d343cecb0cf9b009cc66e2ae4a38 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sun, 29 Mar 2026 21:42:38 -0700 Subject: [PATCH 165/430] Revert kingfisher skip in container build workflow Kingfisher will build via Nix on ringtail instead of Dockerfile on indri, so the skip is no longer needed. Co-Authored-By: Claude Opus 4.6 (1M context) --- .forgejo/workflows/build-container.yaml | 8 -------- 1 file changed, 8 deletions(-) diff --git a/.forgejo/workflows/build-container.yaml b/.forgejo/workflows/build-container.yaml index 6b03742..6e5ed38 100644 --- a/.forgejo/workflows/build-container.yaml +++ b/.forgejo/workflows/build-container.yaml @@ -46,18 +46,10 @@ jobs: echo "Changed containers: $CHANGED" - # Containers that are too resource-intensive to build on k8s (indri). - # These must be built locally and pushed manually. - SKIP_CONTAINERS="kingfisher" - # Classify each container by build type (a container can appear in both) DOCKERFILE='[]' NIX='[]' for name in $(echo "$CHANGED" | jq -r '.[]'); do - if echo "$SKIP_CONTAINERS" | grep -qw "$name"; then - echo "Skipping $name (in SKIP_CONTAINERS — build locally)" - continue - fi has_any=false if [ -f "containers/$name/Dockerfile" ]; then DOCKERFILE=$(echo "$DOCKERFILE" | jq -c --arg n "$name" '. + [$n]') From f9206bf10b46badb7a896efaf1f375917d4c53f2 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 30 Mar 2026 06:34:49 -0700 Subject: [PATCH 166/430] Build custom Kingfisher container from sporked deploy branch (#318) ## Summary - Add Dockerfile for Kingfisher built from source (sporked deploy branch) - Multi-stage: Rust build with Boost/vectorscan, debian-slim runtime - Switch CronJob from upstream `ghcr.io/mongodb/kingfisher` to `registry.ops.eblu.me/blumeops/kingfisher` - Add kingfisher to service-versions.yaml (version tracks upstream main SHA) - Document spork workflow in CLAUDE.md ## Test plan - [ ] Build container: `mise run container-build-and-release kingfisher 1d37d29` - [ ] Verify image on registry: `mise run container-list` - [ ] Update kustomization newTag - [ ] Sync ArgoCD kingfisher app from branch - [ ] Trigger manual CronJob and verify scan completes - [ ] Verify reports on sifaka Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/318 --- CLAUDE.md | 12 + argocd/apps/kingfisher.yaml | 2 +- argocd/manifests/kingfisher/cronjob.yaml | 4 +- .../manifests/kingfisher/kustomization.yaml | 4 +- argocd/manifests/kingfisher/pv-nfs.yaml | 1 - containers/kingfisher/Cargo.lock | 10002 ++++++++++++++++ containers/kingfisher/default.nix | 114 + .../feature-kingfisher-container.feature.md | 1 + docs/explanation/spork-strategy.md | 1 + .../configuration/build-spork-container.md | 101 + docs/reference/services/kingfisher.md | 6 +- service-versions.yaml | 7 + 12 files changed, 10247 insertions(+), 8 deletions(-) create mode 100644 containers/kingfisher/Cargo.lock create mode 100644 containers/kingfisher/default.nix create mode 100644 docs/changelog.d/feature-kingfisher-container.feature.md create mode 100644 docs/how-to/configuration/build-spork-container.md diff --git a/CLAUDE.md b/CLAUDE.md index 289725a..f1d640e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -121,6 +121,18 @@ from upstream. Ask user to mirror on forge first, then clone to `~/code/3rd//`. +### Sporked Projects + +Some mirrored projects are "sporked" — a floating-branch soft-fork strategy +where local patches are continuously rebased on top of upstream. See +[[spork-strategy]] and [[create-a-spork]] for the full methodology. + +Sporked projects live in `~/code/3rd//` with three remotes: +`origin` (eblume/ fork on forge), `mirror` (mirrors/ on forge), `upstream` +(canonical). The `blumeops` branch is the default; `deploy` merges everything. + +Create a new spork: `mise run spork-create ` + ## Task Discovery ```fish diff --git a/argocd/apps/kingfisher.yaml b/argocd/apps/kingfisher.yaml index 27d24a5..ad659eb 100644 --- a/argocd/apps/kingfisher.yaml +++ b/argocd/apps/kingfisher.yaml @@ -10,7 +10,7 @@ spec: targetRevision: main path: argocd/manifests/kingfisher destination: - server: https://kubernetes.default.svc + server: https://ringtail.tail8d86e.ts.net:6443 namespace: kingfisher syncPolicy: syncOptions: diff --git a/argocd/manifests/kingfisher/cronjob.yaml b/argocd/manifests/kingfisher/cronjob.yaml index 0efd7ab..5720810 100644 --- a/argocd/manifests/kingfisher/cronjob.yaml +++ b/argocd/manifests/kingfisher/cronjob.yaml @@ -17,7 +17,7 @@ spec: type: RuntimeDefault containers: - name: kingfisher - image: ghcr.io/mongodb/kingfisher:kustomized + image: registry.ops.eblu.me/blumeops/kingfisher:kustomized command: ["/bin/sh", "-c"] args: - | @@ -28,7 +28,9 @@ spec: kingfisher scan gitea \ --api-url https://forge.ops.eblu.me/api/v1/ \ + --clone-url-base https://forge.ops.eblu.me/ \ --user eblume \ + --all-organizations \ --repo-type all \ --no-update-check \ --tls-mode lax \ diff --git a/argocd/manifests/kingfisher/kustomization.yaml b/argocd/manifests/kingfisher/kustomization.yaml index 97d951c..d4c48ec 100644 --- a/argocd/manifests/kingfisher/kustomization.yaml +++ b/argocd/manifests/kingfisher/kustomization.yaml @@ -11,5 +11,5 @@ resources: - cronjob.yaml images: - - name: ghcr.io/mongodb/kingfisher - newTag: "1.91.0" + - name: registry.ops.eblu.me/blumeops/kingfisher + newTag: v165768b-5cd32f8-nix diff --git a/argocd/manifests/kingfisher/pv-nfs.yaml b/argocd/manifests/kingfisher/pv-nfs.yaml index ddff3b1..61ca4ce 100644 --- a/argocd/manifests/kingfisher/pv-nfs.yaml +++ b/argocd/manifests/kingfisher/pv-nfs.yaml @@ -1,6 +1,5 @@ # NFS PersistentVolume for Kingfisher secret scan reports # Reuses the same sifaka:/volume1/reports share as Prowler -# NFS rules already configured for indri apiVersion: v1 kind: PersistentVolume metadata: diff --git a/containers/kingfisher/Cargo.lock b/containers/kingfisher/Cargo.lock new file mode 100644 index 0000000..0612332 --- /dev/null +++ b/containers/kingfisher/Cargo.lock @@ -0,0 +1,10002 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "Inflector" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3" +dependencies = [ + "lazy_static", + "regex", +] + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "anymap2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d301b3b94cb4b2f23d7917810addbbaff90738e0ca2be692bd027e70d7e0330c" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "arc-swap" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a07d1f37ff60921c83bdfc7407723bdefe89b44b98a9b772f225c8f9d67141a6" +dependencies = [ + "rustversion", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "asar" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9051a11bd40b01b4f8b926956b9f22ff5ef08fcbc7eb34693e2a73982ff85e" +dependencies = [ + "byteorder", + "clap", + "color-eyre", + "hex", + "is_executable", + "serde", + "serde_json", + "serde_with 3.18.0", + "sha2", + "thiserror 1.0.69", + "walkdir", + "wax", +] + +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "assert_cmd" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a686bbee5efb88a82df0621b236e74d925f470e5445d3220a5648b892ec99c9" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + +[[package]] +name = "async-compression" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "aws-config" +version = "1.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11493b0bad143270fb8ad284a096dd529ba91924c5409adeac856cc1bf047dbc" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sdk-sso", + "aws-sdk-ssooidc", + "aws-sdk-sts", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "hex", + "http 1.4.0", + "sha1", + "time", + "tokio", + "tracing", + "url", + "zeroize", +] + +[[package]] +name = "aws-credential-types" +version = "1.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f20799b373a1be121fe3005fba0c2090af9411573878f224df44b42727fcaf7" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "zeroize", +] + +[[package]] +name = "aws-lc-rs" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "aws-runtime" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fc0651c57e384202e47153c1260b84a9936e19803d747615edf199dc3b98d17" +dependencies = [ + "aws-credential-types", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "bytes-utils", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "percent-encoding", + "pin-project-lite", + "tracing", + "uuid", +] + +[[package]] +name = "aws-sdk-dynamodb" +version = "1.110.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c597424385739456cd04535982831c15bd58f97ca28c5bcb232c0d730d5cb39" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-ec2" +version = "1.220.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4d073e665c1303edc348511ec2509b58a2cee148196f72db33aef5cd47c3573" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-query", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-iam" +version = "1.107.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fb72c86b8b7aeb3967a3a9cebcb773d88350c04b9d0770ad75bea8f76c89bfa" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-query", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-kms" +version = "1.104.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41ae6a33da941457e89075ef8ca5b4870c8009fe4dceeba82fce2f30f313ac6" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-lambda" +version = "1.119.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bcacc7c2d94698c49fb73086d16ccdff68442ed21a70fbaa924c46158e37a93" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-s3" +version = "1.127.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "151783f64e0dcddeb4965d08e36c276b4400a46caa88805a2e36d497deaf031a" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-checksums", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "bytes", + "fastrand", + "hex", + "hmac", + "http 0.2.12", + "http 1.4.0", + "http-body 1.0.1", + "lru 0.16.3", + "percent-encoding", + "regex-lite", + "sha2", + "tracing", + "url", +] + +[[package]] +name = "aws-sdk-secretsmanager" +version = "1.103.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae64963d3d16d8070aaa2fb79c11cd3b13f44d2f13bba3fe8f49dcd2c42f2987" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-sso" +version = "1.97.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aadc669e184501caaa6beafb28c6267fc1baef0810fb58f9b205485ca3f2567" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-ssooidc" +version = "1.99.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1342a7db8f358d3de0aed2007a0b54e875458e39848d54cc1d46700b2bfcb0a8" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-sts" +version = "1.101.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab41ad64e4051ecabeea802d6a17845a91e83287e1dd249e6963ea1ba78c428a" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-query", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sigv4" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0b660013a6683ab23797778e21f1f854744fdf05f68204b4cca4c8c04b5d1f4" +dependencies = [ + "aws-credential-types", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "form_urlencoded", + "hex", + "hmac", + "http 0.2.12", + "http 1.4.0", + "percent-encoding", + "sha2", + "time", + "tracing", +] + +[[package]] +name = "aws-smithy-async" +version = "1.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffcaf626bdda484571968400c326a244598634dc75fd451325a54ad1a59acfc" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "aws-smithy-checksums" +version = "0.64.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6750f3dd509b0694a4377f0293ed2f9630d710b1cebe281fa8bac8f099f88bc6" +dependencies = [ + "aws-smithy-http", + "aws-smithy-types", + "bytes", + "crc-fast", + "hex", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "md-5", + "pin-project-lite", + "sha1", + "sha2", + "tracing", +] + +[[package]] +name = "aws-smithy-eventstream" +version = "0.60.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf09d74e5e32f76b8762da505a3cd59303e367a664ca67295387baa8c1d7548" +dependencies = [ + "aws-smithy-types", + "bytes", + "crc32fast", +] + +[[package]] +name = "aws-smithy-http" +version = "0.63.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba1ab2dc1c2c3749ead27180d333c42f11be8b0e934058fb4b2258ee8dbe5231" +dependencies = [ + "aws-smithy-eventstream", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", +] + +[[package]] +name = "aws-smithy-http-client" +version = "1.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a2f165a7feee6f263028b899d0a181987f4fa7179a6411a32a439fba7c5f769" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "h2", + "http 1.4.0", + "hyper", + "hyper-rustls", + "hyper-util", + "pin-project-lite", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower", + "tracing", +] + +[[package]] +name = "aws-smithy-json" +version = "0.62.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9648b0bb82a2eedd844052c6ad2a1a822d1f8e3adee5fbf668366717e428856a" +dependencies = [ + "aws-smithy-types", +] + +[[package]] +name = "aws-smithy-observability" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06c2315d173edbf1920da8ba3a7189695827002e4c0fc961973ab1c54abca9c" +dependencies = [ + "aws-smithy-runtime-api", +] + +[[package]] +name = "aws-smithy-query" +version = "0.60.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a56d79744fb3edb5d722ef79d86081e121d3b9422cb209eb03aea6aa4f21ebd" +dependencies = [ + "aws-smithy-types", + "urlencoding", +] + +[[package]] +name = "aws-smithy-runtime" +version = "1.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "028999056d2d2fd58a697232f9eec4a643cf73a71cf327690a7edad1d2af2110" +dependencies = [ + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-http-client", + "aws-smithy-observability", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "pin-project-lite", + "pin-utils", + "tokio", + "tracing", +] + +[[package]] +name = "aws-smithy-runtime-api" +version = "1.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "876ab3c9c29791ba4ba02b780a3049e21ec63dabda09268b175272c3733a79e6" +dependencies = [ + "aws-smithy-async", + "aws-smithy-types", + "bytes", + "http 0.2.12", + "http 1.4.0", + "pin-project-lite", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-types" +version = "1.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d73dbfbaa8e4bc57b9045137680b958d274823509a360abfd8e1d514d40c95c" +dependencies = [ + "base64-simd", + "bytes", + "bytes-utils", + "futures-core", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "itoa", + "num-integer", + "pin-project-lite", + "pin-utils", + "ryu", + "serde", + "time", + "tokio", + "tokio-util", +] + +[[package]] +name = "aws-smithy-xml" +version = "0.60.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce02add1aa3677d022f8adf81dcbe3046a95f17a1b1e8979c145cd21d3d22b3" +dependencies = [ + "xmlparser", +] + +[[package]] +name = "aws-types" +version = "1.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47c8323699dd9b3c8d5b3c13051ae9cdef58fd179957c882f8374dd8725962d9" +dependencies = [ + "aws-credential-types", + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "rustc_version", + "tracing", +] + +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base32" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +dependencies = [ + "serde_core", +] + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "blake3" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.2.17", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bloomfilter" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f6d7f06817e48ea4e17532fa61bc4e8b9a101437f0623f69d2ea54284f3a817" +dependencies = [ + "getrandom 0.2.17", + "siphasher", +] + +[[package]] +name = "bollard-stubs" +version = "1.42.0-rc.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed59b5c00048f48d7af971b71f800fdf23e858844a6f9e4d32ca72e9399e7864" +dependencies = [ + "serde", + "serde_with 1.14.0", +] + +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bson" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969a9ba84b0ff843813e7249eed1678d9b6607ce5a3b8f0a47af3fcf7978e6e" +dependencies = [ + "ahash", + "base64 0.22.1", + "bitvec", + "getrandom 0.2.17", + "getrandom 0.3.4", + "hex", + "indexmap 2.13.0", + "js-sys", + "once_cell", + "rand 0.9.2", + "serde", + "serde_bytes", + "serde_json", + "time", + "uuid", +] + +[[package]] +name = "bson" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3f109694c4f45353972af96bf97d8a057f82e2d6e496457f4d135b9867a518c" +dependencies = [ + "ahash", + "base64 0.22.1", + "bitvec", + "getrandom 0.3.4", + "hex", + "indexmap 2.13.0", + "js-sys", + "rand 0.9.2", + "serde", + "serde_bytes", + "simdutf8", + "thiserror 2.0.18", + "time", + "uuid", +] + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + +[[package]] +name = "btoi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd6407f73a9b8b6162d8a2ef999fe6afd7cc15902ebf42c5cd296addf17e0ad" +dependencies = [ + "num-traits", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] + +[[package]] +name = "bytes-utils" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +dependencies = [ + "bytes", + "either", +] + +[[package]] +name = "bytesize" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bd91ee7b2422bcb158d90ef4d14f75ef67f340943fc4149891dcce8f8b972a3" + +[[package]] +name = "bzip2-rs" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "beeb59e7e4c811ab37cc73680c798c7a5da77fc9989c62b09138e31ee740f735" +dependencies = [ + "crc32fast", + "tinyvec", +] + +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4acbb09d9ee8e23699b9634375c72795d095bf268439da88562cf9b501f181fa" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", +] + +[[package]] +name = "cc" +version = "1.2.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.0", +] + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "chrono-tz" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93698b29de5e97ad0ae26447b344c482a7284c737d9ddc5f9e52b74a336671bb" +dependencies = [ + "chrono", + "chrono-tz-build", + "phf 0.11.3", +] + +[[package]] +name = "chrono-tz-build" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c088aee841df9c3041febbb73934cfc39708749bf96dc827e3359cd39ef11b1" +dependencies = [ + "parse-zoneinfo", + "phf 0.11.3", + "phf_codegen", +] + +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap-cargo" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "936551935c8258754bb8216aec040957d261f977303754b9bf1a213518388006" +dependencies = [ + "anstyle", + "clap", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim 0.11.1", + "terminal_size", + "unicase", + "unicode-width", +] + +[[package]] +name = "clap_derive" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "clru" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "197fd99cb113a8d5d9b6376f3aa817f32c1078f2343b714fff7d2ca44fdf67d5" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "color-backtrace" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "308329d5d62e877ba02943db3a8e8c052de9fde7ab48283395ba0e6494efbabd" +dependencies = [ + "backtrace", + "termcolor", +] + +[[package]] +name = "color-eyre" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5920befb47832a6d61ee3a3a846565cfa39b331331e68a3b1d1116630f2f26d" +dependencies = [ + "backtrace", + "color-spantrace", + "eyre", + "indenter", + "once_cell", + "owo-colors", + "tracing-error", +] + +[[package]] +name = "color-spantrace" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8b88ea9df13354b55bc7234ebcce36e6ef896aca2e42a15de9e10edce01b427" +dependencies = [ + "once_cell", + "owo-colors", + "tracing-core", + "tracing-error", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "colored" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" +dependencies = [ + "lazy_static", + "windows-sys 0.59.0", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "compression-codecs" +version = "0.4.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7" +dependencies = [ + "brotli", + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" + +[[package]] +name = "console" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +dependencies = [ + "encode_unicode", + "libc", + "unicode-width", + "windows-sys 0.61.2", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "const_format" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7faa7469a93a566e9ccc1c73fe783b4a65c274c5ace346038dca9c39fe0030ad" +dependencies = [ + "const_format_proc_macros", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "content_inspector" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7bda66e858c683005a53a9a60c69a4aca7eeaa45d124526e389f7aec8e62f38" +dependencies = [ + "memchr", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "indexmap 2.13.0", + "log", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crc-fast" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd92aca2c6001b1bf5ba0ff84ee74ec8501b52bbef0cac80bf25a6c1d87a83d" +dependencies = [ + "crc", + "digest", + "rustversion", + "spin", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "cron" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5877d3fbf742507b66bc2a1945106bd30dd8504019d596901ddd012a4dd01740" +dependencies = [ + "chrono", + "once_cell", + "winnow 0.6.26", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-skiplist" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df29de440c58ca2cc6e587ec3d22347551a32435fbde9d2bff64e78a9ffa151b" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a01d95850c592940db9b8194bc39f4bc0e89dee5c4265e4b1807c34a9aba453c" +dependencies = [ + "darling_core 0.13.4", + "darling_macro 0.13.4", +] + +[[package]] +name = "darling" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b750cb3417fd1b327431a470f388520309479ab0bf5e323505daf0290cd3850" +dependencies = [ + "darling_core 0.14.4", + "darling_macro 0.14.4", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "859d65a907b6852c9361e3185c862aae7fafd2887876799fa55f5f99dc40d610" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.10.0", + "syn 1.0.109", +] + +[[package]] +name = "darling_core" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "109c1ca6e6b7f82cc233a97004ea8ed7ca123a9af07a8230878fcfda9b158bf0" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.10.0", + "syn 1.0.109", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.11.1", + "syn 2.0.117", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim 0.11.1", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c972679f83bdf9c42bd905396b6c3588a843a17f0f16dfcfa3e2c5d57441835" +dependencies = [ + "darling_core 0.13.4", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "darling_macro" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e" +dependencies = [ + "darling_core 0.14.4", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core 0.9.12", + "serde", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + +[[package]] +name = "deflate64" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "der_derive", + "flagset", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derive-syn-parse" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d65d7ce8132b7c0e54497a4d9a55a1c2a0912a0d786cf894472ba818fba45762" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "derive-where" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d08b3a0bcc0d079199cd476b2cae8435016ec11d1c0986c6901c5ac223041534" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "derive_builder" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d67778784b508018359cbc8696edb3db78160bab2c2a28ba7f56ef6932997f8" +dependencies = [ + "derive_builder_macro 0.12.0", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro 0.20.2", +] + +[[package]] +name = "derive_builder_core" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c11bdc11a0c47bc7d37d582b5285da6849c96681023680b906673c5707af7b0f" +dependencies = [ + "darling 0.14.4", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "derive_builder_macro" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebcda35c7a396850a55ffeac740804b40ffec779b98fffbb1738f4033f0ee79e" +dependencies = [ + "derive_builder_core 0.12.0", + "syn 1.0.109", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core 0.20.2", + "syn 2.0.117", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", + "unicode-xid", +] + +[[package]] +name = "deunicode" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "signature", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "encoding_rs_io" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cc3c5651fb62ab8aa3103998dade57efdd028544bd300516baa31840c252a83" +dependencies = [ + "encoding_rs", +] + +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "env_filter" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-chain" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2f06b9cac1506ece98fe3231e3cc9c4410ec3d5b1f24ae1c8946f0742cdefc" +dependencies = [ + "version_check", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "eyre" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" +dependencies = [ + "indenter", + "once_cell", +] + +[[package]] +name = "fallible-iterator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "faster-hex" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7223ae2d2f179b803433d9c830478527e92b8117eab39460edae7f1614d9fb73" +dependencies = [ + "heapless", + "serde", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gcloud-auth" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b43924e3df02cb3b846ca66a7ee58e8c13eb2556d0308c71f6154083f6980365" +dependencies = [ + "async-trait", + "base64 0.22.1", + "gcloud-metadata", + "home", + "jsonwebtoken 10.3.0", + "reqwest 0.13.2", + "serde", + "serde_json", + "thiserror 2.0.18", + "time", + "token-source", + "tokio", + "tracing", + "urlencoding", +] + +[[package]] +name = "gcloud-metadata" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd3152612316be627be52fe9ca72331eb48425059b3a6a700e7adde223e061d5" +dependencies = [ + "reqwest 0.13.2", + "thiserror 2.0.18", + "tokio", +] + +[[package]] +name = "gcloud-storage" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17f9662a6966402de91daf0edb5accaae05c87f1a85479e57b95d2af7284b9f" +dependencies = [ + "anyhow", + "base64 0.22.1", + "bytes", + "futures-util", + "gcloud-auth", + "gcloud-metadata", + "hex", + "once_cell", + "percent-encoding", + "pkcs8", + "regex", + "reqwest 0.13.2", + "reqwest-middleware 0.5.1", + "ring", + "serde", + "serde_json", + "sha2", + "thiserror 2.0.18", + "time", + "token-source", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "getset" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf0fc11e47561d47397154977bc219f4cf809b2974facc3ccb3b89e2436f912" +dependencies = [ + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "git2" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" +dependencies = [ + "bitflags 2.11.0", + "libc", + "libgit2-sys", + "log", + "url", +] + +[[package]] +name = "gitlab" +version = "0.1801.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bba984dbd32d06cf8e360163134974cefb351f78a021e9a7e84ca749b3590f4" +dependencies = [ + "async-trait", + "base64 0.22.1", + "bytes", + "chrono", + "cron", + "derive_builder 0.20.2", + "futures-util", + "graphql_client", + "http 1.4.0", + "itertools 0.14.0", + "log", + "percent-encoding", + "reqwest 0.12.28", + "serde", + "serde_json", + "serde_urlencoded", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "gix" +version = "0.81.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0473c64d9ccbcfb9953a133b47c8b9a335b87ac6c52b983ee4b03d49000b0f3f" +dependencies = [ + "gix-actor", + "gix-archive", + "gix-attributes", + "gix-blame", + "gix-command", + "gix-commitgraph", + "gix-config", + "gix-credentials", + "gix-date", + "gix-diff", + "gix-dir", + "gix-discover", + "gix-error", + "gix-features", + "gix-filter", + "gix-fs", + "gix-glob", + "gix-hash", + "gix-hashtable", + "gix-ignore", + "gix-index", + "gix-lock", + "gix-mailmap", + "gix-merge", + "gix-negotiate", + "gix-object", + "gix-odb", + "gix-pack", + "gix-path", + "gix-pathspec", + "gix-prompt", + "gix-protocol", + "gix-ref", + "gix-refspec", + "gix-revision", + "gix-revwalk", + "gix-sec", + "gix-shallow", + "gix-status", + "gix-submodule", + "gix-tempfile", + "gix-trace", + "gix-transport", + "gix-traverse", + "gix-url", + "gix-utils", + "gix-validate", + "gix-worktree", + "gix-worktree-state", + "gix-worktree-stream", + "nonempty", + "parking_lot 0.12.5", + "regex", + "serde", + "signal-hook", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-actor" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e5e5b518339d5e6718af108fd064d4e9ba33caf728cf487352873d76411df35" +dependencies = [ + "bstr", + "gix-date", + "gix-error", + "serde", + "winnow 0.7.15", +] + +[[package]] +name = "gix-archive" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "651c99be11aac9b303483193ae50b45eb6e094da4f5ed797019b03948f51aad6" +dependencies = [ + "bstr", + "gix-date", + "gix-error", + "gix-object", + "gix-worktree-stream", +] + +[[package]] +name = "gix-attributes" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c233d6eaa098c0ca5ce03236fd7a96e27f1abe72fad74b46003fbd11fe49563c" +dependencies = [ + "bstr", + "gix-glob", + "gix-path", + "gix-quote", + "gix-trace", + "kstring", + "serde", + "smallvec", + "thiserror 2.0.18", + "unicode-bom", +] + +[[package]] +name = "gix-bitmap" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7add20f40d060db8c9b1314d499bac6ed7480f33eb113ce3e1cf5d6ff85d989" +dependencies = [ + "gix-error", +] + +[[package]] +name = "gix-blame" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c77aaf9f7348f4da3ebfbfbbc35fa0d07155d98377856198dde6f695fd648705" +dependencies = [ + "gix-commitgraph", + "gix-date", + "gix-diff", + "gix-error", + "gix-hash", + "gix-object", + "gix-revwalk", + "gix-trace", + "gix-traverse", + "gix-worktree", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-chunk" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1096b6608fbe5d27fb4984e20f992b4e76fb8c613f6acb87d07c5831b53a6959" +dependencies = [ + "gix-error", +] + +[[package]] +name = "gix-command" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b849c65a609f50d02f8a2774fe371650b3384a743c79c2a070ce0da49b7fb7da" +dependencies = [ + "bstr", + "gix-path", + "gix-quote", + "gix-trace", + "shell-words", +] + +[[package]] +name = "gix-commitgraph" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3196655fd1443f3c58a48c114aa480be3e4e87b393d7292daaa0d543862eb445" +dependencies = [ + "bstr", + "gix-chunk", + "gix-error", + "gix-hash", + "memmap2", + "nonempty", + "serde", +] + +[[package]] +name = "gix-config" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08939b4c4ed7a663d0e64be9e1e9bdf23a1fb4fcee1febdf449f12229542e50d" +dependencies = [ + "bstr", + "gix-config-value", + "gix-features", + "gix-glob", + "gix-path", + "gix-ref", + "gix-sec", + "memchr", + "smallvec", + "thiserror 2.0.18", + "unicode-bom", + "winnow 0.7.15", +] + +[[package]] +name = "gix-config-value" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "441a300bc3645a1f45cba495b9175f90f47256ce43f2ee161da0031e3ac77c92" +dependencies = [ + "bitflags 2.11.0", + "bstr", + "gix-path", + "libc", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-credentials" +version = "0.37.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b2a34b8715e3bbd514f3d1705f5d51c4b250e5bfe506b9fb60b133c85c93d9" +dependencies = [ + "bstr", + "gix-command", + "gix-config-value", + "gix-date", + "gix-path", + "gix-prompt", + "gix-sec", + "gix-trace", + "gix-url", + "serde", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-date" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39acf819aa9fee65e4838a2eec5cb2506e47ebb89e02a5ab9918196e491571ea" +dependencies = [ + "bstr", + "gix-error", + "itoa", + "jiff", + "serde", + "smallvec", +] + +[[package]] +name = "gix-diff" +version = "0.61.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88f3b3475e5d3877d7c30c40827cc2441936ce890efc226e5ba4afe3a7ae33f0" +dependencies = [ + "bstr", + "gix-attributes", + "gix-command", + "gix-filter", + "gix-fs", + "gix-hash", + "gix-index", + "gix-object", + "gix-path", + "gix-pathspec", + "gix-tempfile", + "gix-trace", + "gix-traverse", + "gix-worktree", + "imara-diff 0.1.8", + "imara-diff 0.2.0", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-dir" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5da4604a360988f0ba8efe6f90093ca5a844f4a7f8e1a3dcda501ec44e600ea9" +dependencies = [ + "bstr", + "gix-discover", + "gix-fs", + "gix-ignore", + "gix-index", + "gix-object", + "gix-path", + "gix-pathspec", + "gix-trace", + "gix-utils", + "gix-worktree", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-discover" +version = "0.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c65bd3330fe0cb9d40d875bf862fd5e8ad6fa4164ddbc4842fbeb889c3f0b2c6" +dependencies = [ + "bstr", + "dunce", + "gix-fs", + "gix-path", + "gix-ref", + "gix-sec", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-error" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e86d01da904d4a9265def43bd42a18c5e6dc7000a73af512946ba14579c9fbd" +dependencies = [ + "bstr", +] + +[[package]] +name = "gix-features" +version = "0.46.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "752493cd4b1d5eaaa0138a7493f65c96863fefa990fc021e0e519579e389ab20" +dependencies = [ + "bytes", + "bytesize", + "crc32fast", + "crossbeam-channel", + "gix-path", + "gix-trace", + "gix-utils", + "libc", + "once_cell", + "parking_lot 0.12.5", + "prodash", + "thiserror 2.0.18", + "walkdir", + "zlib-rs", +] + +[[package]] +name = "gix-filter" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d37598282a6566da6fb52667570c7fe0aedcb122ac886724a9e62a2180523e35" +dependencies = [ + "bstr", + "encoding_rs", + "gix-attributes", + "gix-command", + "gix-hash", + "gix-object", + "gix-packetline", + "gix-path", + "gix-quote", + "gix-trace", + "gix-utils", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-fs" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a964b4aec683eb0bacb87533defa80805bb4768056371a47ab38b00a2d377b72" +dependencies = [ + "bstr", + "fastrand", + "gix-features", + "gix-path", + "gix-utils", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-glob" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b03e6cd88cc0dc1eafa1fddac0fb719e4e74b6ea58dd016e71125fde4a326bee" +dependencies = [ + "bitflags 2.11.0", + "bstr", + "gix-features", + "gix-path", + "serde", +] + +[[package]] +name = "gix-hash" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fb896a02d9ab96fa518475a5f30ad3952010f801a8de5840f633f4a6b985dfb" +dependencies = [ + "faster-hex", + "gix-features", + "serde", + "sha1-checked", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-hashtable" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2664216fc5e89b51e756a4a3ac676315602ce2dac07acf1da959a22038d69b33" +dependencies = [ + "gix-hash", + "hashbrown 0.16.1", + "parking_lot 0.12.5", +] + +[[package]] +name = "gix-ignore" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f915dcf6911e3027537166d34e13f0fe101ed12225178d2ae29cd1272cff26" +dependencies = [ + "bstr", + "gix-glob", + "gix-path", + "gix-trace", + "serde", + "unicode-bom", +] + +[[package]] +name = "gix-index" +version = "0.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bae54ab14e4e74d5dda60b82ea7afad7c8eb3be68283d6d5f29bd2e6d47fff7" +dependencies = [ + "bitflags 2.11.0", + "bstr", + "filetime", + "fnv", + "gix-bitmap", + "gix-features", + "gix-fs", + "gix-hash", + "gix-lock", + "gix-object", + "gix-traverse", + "gix-utils", + "gix-validate", + "hashbrown 0.16.1", + "itoa", + "libc", + "memmap2", + "rustix", + "serde", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-lock" +version = "21.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054fbd0989700c69dc5aa80bc66944f05df1e15aa7391a9e42aca7366337905f" +dependencies = [ + "gix-tempfile", + "gix-utils", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-mailmap" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7b4818da522786ec7e32a00884ee8fc40fa4c215c3997c0b15f7b62684d1199" +dependencies = [ + "bstr", + "gix-actor", + "gix-date", + "gix-error", + "serde", +] + +[[package]] +name = "gix-merge" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4606747466512d22c2dffc019142e1941238f543987ea51353c938cca80c500" +dependencies = [ + "bstr", + "gix-command", + "gix-diff", + "gix-filter", + "gix-fs", + "gix-hash", + "gix-index", + "gix-object", + "gix-path", + "gix-quote", + "gix-revision", + "gix-revwalk", + "gix-tempfile", + "gix-trace", + "gix-worktree", + "imara-diff 0.1.8", + "nonempty", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-negotiate" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea064c7595eea08fdd01c70748af747d9acc40f727b61f4c8a2145a5c5fc28c" +dependencies = [ + "bitflags 2.11.0", + "gix-commitgraph", + "gix-date", + "gix-hash", + "gix-object", + "gix-revwalk", +] + +[[package]] +name = "gix-object" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cafb802bb688a7c1e69ef965612ff5ff859f046bfb616377e4a0ba4c01e43d47" +dependencies = [ + "bstr", + "gix-actor", + "gix-date", + "gix-features", + "gix-hash", + "gix-hashtable", + "gix-path", + "gix-utils", + "gix-validate", + "itoa", + "serde", + "smallvec", + "thiserror 2.0.18", + "winnow 0.7.15", +] + +[[package]] +name = "gix-odb" +version = "0.78.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24833ae9323b4f7079575fb9f961cf9c414b0afbec428a536ab8e7dd93bc002b" +dependencies = [ + "arc-swap", + "gix-features", + "gix-fs", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-pack", + "gix-path", + "gix-quote", + "parking_lot 0.12.5", + "serde", + "tempfile", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-pack" +version = "0.68.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3484119cd19859d7d7639413c27e192478fa354d3f4ff5f7e3c041e8040f0f4" +dependencies = [ + "clru", + "gix-chunk", + "gix-error", + "gix-features", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-path", + "gix-tempfile", + "memmap2", + "parking_lot 0.12.5", + "serde", + "smallvec", + "thiserror 2.0.18", + "uluru", +] + +[[package]] +name = "gix-packetline" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be19313dcdb7dff75a3ce2f99be00878458295bcc3b6c7f0005591597573345c" +dependencies = [ + "bstr", + "faster-hex", + "gix-trace", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-path" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09c31d4373bda7fab9eb01822927b55185a378d6e1bf737e0a54c743ad806658" +dependencies = [ + "bstr", + "gix-trace", + "gix-validate", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-pathspec" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f89611f13544ca5ebeb68a502673814ef57200df60c24a61c2ce7b96f612f08b" +dependencies = [ + "bitflags 2.11.0", + "bstr", + "gix-attributes", + "gix-config-value", + "gix-glob", + "gix-path", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-prompt" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f61f6264e1f6c5a951531fe127722c7522bc02ebda80c4528286bda4642055f" +dependencies = [ + "gix-command", + "gix-config-value", + "parking_lot 0.12.5", + "rustix", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-protocol" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f38666350736b5877c79f57ddae02bde07a4ce186d889adc391e831cddcbe76" +dependencies = [ + "bstr", + "gix-credentials", + "gix-date", + "gix-features", + "gix-hash", + "gix-lock", + "gix-negotiate", + "gix-object", + "gix-ref", + "gix-refspec", + "gix-revwalk", + "gix-shallow", + "gix-trace", + "gix-transport", + "gix-utils", + "maybe-async", + "nonempty", + "serde", + "thiserror 2.0.18", + "winnow 0.7.15", +] + +[[package]] +name = "gix-quote" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68533db71259c8776dd4e770d2b7b98696213ecdc1f5c9e3507119e274e0c578" +dependencies = [ + "bstr", + "gix-error", + "gix-utils", +] + +[[package]] +name = "gix-ref" +version = "0.61.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2159978abb99b7027c8579d15211e262ef0ef2594d5cecb3334fbcbdfe2997c" +dependencies = [ + "gix-actor", + "gix-features", + "gix-fs", + "gix-hash", + "gix-lock", + "gix-object", + "gix-path", + "gix-tempfile", + "gix-utils", + "gix-validate", + "memmap2", + "serde", + "thiserror 2.0.18", + "winnow 0.7.15", +] + +[[package]] +name = "gix-refspec" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc806ee13f437428f8a1ba4c72ecfaa3f20e14f5f0d4c2bc17d0b33e794aa6ac" +dependencies = [ + "bstr", + "gix-error", + "gix-glob", + "gix-hash", + "gix-revision", + "gix-validate", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-revision" +version = "0.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c08f1ec5d1e6a524f8ba291c41f0ccaef64e48ed0e8cf790b3461cae45f6d3d" +dependencies = [ + "bitflags 2.11.0", + "bstr", + "gix-commitgraph", + "gix-date", + "gix-error", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-revwalk", + "gix-trace", + "nonempty", + "serde", +] + +[[package]] +name = "gix-revwalk" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e4b2b87772b21ca449249e86d32febadba5cba32b0fcce804ab9cefc6f2111c" +dependencies = [ + "gix-commitgraph", + "gix-date", + "gix-error", + "gix-hash", + "gix-hashtable", + "gix-object", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-sec" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf82ae037de9c62850ce67beaa92ec8e3e17785ea307cdde7618edc215603b4f" +dependencies = [ + "bitflags 2.11.0", + "gix-path", + "libc", + "serde", + "windows-sys 0.61.2", +] + +[[package]] +name = "gix-shallow" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbf60711c9083b2364b3fac8a352444af76b17201f3682fdebe74fa66d89a772" +dependencies = [ + "bstr", + "gix-hash", + "gix-lock", + "nonempty", + "serde", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-status" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d6c598e3fdbc352fba1c5ba7e709e69402fafbc44d9295edad2e3c4738996b" +dependencies = [ + "bstr", + "filetime", + "gix-diff", + "gix-dir", + "gix-features", + "gix-filter", + "gix-fs", + "gix-hash", + "gix-index", + "gix-object", + "gix-path", + "gix-pathspec", + "gix-worktree", + "portable-atomic", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-submodule" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce5c3929c5e6821f651d35e8420f72fea3cfafe9fc1e928a61e718b462c72a5" +dependencies = [ + "bstr", + "gix-config", + "gix-path", + "gix-pathspec", + "gix-refspec", + "gix-url", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-tempfile" +version = "21.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22227f6b203f511ff451c33c89899e87e4f571fc596b06f68e6e613a6508528" +dependencies = [ + "dashmap", + "gix-fs", + "libc", + "parking_lot 0.12.5", + "signal-hook", + "signal-hook-registry", + "tempfile", +] + +[[package]] +name = "gix-trace" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f69a13643b8437d4ca6845e08143e847a36ca82903eed13303475d0ae8b162e0" + +[[package]] +name = "gix-transport" +version = "0.55.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a521e39c6235ce63ed6c001e2dd79818c830b82c3b7b59247ee7b229c39ec9bb" +dependencies = [ + "bstr", + "gix-command", + "gix-features", + "gix-packetline", + "gix-quote", + "gix-sec", + "gix-url", + "serde", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-traverse" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "963dc2afcdb611092aa587c3f9365e749ac0a0892ff27662dbc75f26c953fbec" +dependencies = [ + "bitflags 2.11.0", + "gix-commitgraph", + "gix-date", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-revwalk", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-url" +version = "0.35.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d28e8af3d42581190da884f013caf254d2fd4d6ab102408f08d21bfa11de6c8d" +dependencies = [ + "bstr", + "gix-path", + "percent-encoding", + "serde", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-utils" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "befcdbdfb1238d2854591f760a48711bed85e72d80a10e8f2f93f656746ef7c5" +dependencies = [ + "bstr", + "fastrand", + "unicode-normalization", +] + +[[package]] +name = "gix-validate" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec1eff98d91941f47766367cba1be746bab662bad761d9891ae6f7882f7840b" +dependencies = [ + "bstr", +] + +[[package]] +name = "gix-worktree" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6bd5830cbc43c9c00918b826467d2afad685b195cb82329cde2b2d116d2c578" +dependencies = [ + "bstr", + "gix-attributes", + "gix-fs", + "gix-glob", + "gix-hash", + "gix-ignore", + "gix-index", + "gix-object", + "gix-path", + "gix-validate", + "serde", +] + +[[package]] +name = "gix-worktree-state" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "644a1681f96e1be43c2a8384337d9d220e7624f50db54beda70997052aebf707" +dependencies = [ + "bstr", + "gix-features", + "gix-filter", + "gix-fs", + "gix-index", + "gix-object", + "gix-path", + "gix-worktree", + "io-close", + "thiserror 2.0.18", +] + +[[package]] +name = "gix-worktree-stream" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24e3fb70a1f650a5cec7d5b8d10d6d6fe86daf3cf15bde08ba0c70988a2932c3" +dependencies = [ + "gix-attributes", + "gix-error", + "gix-features", + "gix-filter", + "gix-fs", + "gix-hash", + "gix-object", + "gix-path", + "gix-traverse", + "parking_lot 0.12.5", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "globwalk" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" +dependencies = [ + "bitflags 2.11.0", + "ignore", + "walkdir", +] + +[[package]] +name = "gouqi" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "360ceeadce44b226639db2d4f3ac716ce243ccd56b9fe194a1b133a1a5b8fdb0" +dependencies = [ + "futures", + "humantime-serde", + "reqwest 0.12.28", + "serde", + "serde_json", + "skeptic", + "thiserror 2.0.18", + "time", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "graphql-introspection-query" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f2a4732cf5140bd6c082434494f785a19cfb566ab07d1382c3671f5812fed6d" +dependencies = [ + "serde", +] + +[[package]] +name = "graphql-parser" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a818c0d883d7c0801df27be910917750932be279c7bc82dc541b8769425f409" +dependencies = [ + "combine", + "thiserror 1.0.69", +] + +[[package]] +name = "graphql_client" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a50cfdc7f34b7f01909d55c2dcb71d4c13cbcbb4a1605d6c8bd760d654c1144b" +dependencies = [ + "graphql_query_derive", + "serde", + "serde_json", +] + +[[package]] +name = "graphql_client_codegen" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e27ed0c2cf0c0cc52c6bcf3b45c907f433015e580879d14005386251842fb0a" +dependencies = [ + "graphql-introspection-query", + "graphql-parser", + "heck 0.4.1", + "lazy_static", + "proc-macro2", + "quote", + "serde", + "serde_json", + "syn 1.0.109", +] + +[[package]] +name = "graphql_query_derive" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83febfa838f898cfa73dfaa7a8eb69ff3409021ac06ee94cfb3d622f6eeb1a97" +dependencies = [ + "graphql_client_codegen", + "proc-macro2", + "syn 1.0.109", +] + +[[package]] +name = "grep-matcher" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36d7b71093325ab22d780b40d7df3066ae4aebb518ba719d38c697a8228a8023" +dependencies = [ + "memchr", +] + +[[package]] +name = "grep-searcher" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac63295322dc48ebb20a25348147905d816318888e64f531bfc2a2bc0577dc34" +dependencies = [ + "bstr", + "encoding_rs", + "encoding_rs_io", + "grep-matcher", + "log", + "memchr", + "memmap2", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.4.0", + "indexmap 2.13.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashlink" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hickory-proto" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "idna", + "ipnet", + "once_cell", + "rand 0.9.2", + "ring", + "thiserror 2.0.18", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-proto", + "ipconfig", + "moka", + "once_cell", + "parking_lot 0.12.5", + "rand 0.9.2", + "resolv-conf", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-auth" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "150fa4a9462ef926824cf4519c84ed652ca8f4fbae34cb8af045b5cbcaf98822" +dependencies = [ + "memchr", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.4.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "human_format" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaec953f16e5bcf6b8a3cb3aa959b17e5577dbd2693e94554c462c08be22624b" + +[[package]] +name = "humansize" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7" +dependencies = [ + "libm", +] + +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + +[[package]] +name = "humantime-serde" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57a3db5ea5923d99402c94e9feb261dc5ee9b4efa158b0315f788cf549cc200c" +dependencies = [ + "humantime", + "serde", +] + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http 1.4.0", + "http-body 1.0.1", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http 1.4.0", + "hyper", + "hyper-util", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.6", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.3", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ignore" +version = "0.4.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "imara-diff" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17d34b7d42178945f775e84bc4c36dde7c1c6cdfea656d3354d009056f2bb3d2" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "imara-diff" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f01d462f766df78ab820dd06f5eb700233c51f0f4c2e846520eaf4ba6aa5c5c" +dependencies = [ + "hashbrown 0.15.5", + "memchr", +] + +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "glob", + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "indenter" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "indicatif" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb" +dependencies = [ + "console", + "portable-atomic", + "unicode-segmentation", + "unicode-width", + "unit-prefix", + "web-time", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "io-close" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cadcf447f06744f8ce713d2d6239bb5bde2c357a452397a9ed90c625da390bc" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "ipconfig" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" +dependencies = [ + "socket2 0.6.3", + "widestring", + "windows-registry", + "windows-result 0.4.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_executable" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baabb8b4867b26294d818bf3f651a454b6901431711abb96e296245888d6e8c4" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" +dependencies = [ + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-sys 0.61.2", +] + +[[package]] +name = "jiff-static" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c900ef84826f1338a557697dc8fc601df9ca9af4ac137c7fb61d4c6f2dfd3076" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.117", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "json5" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" +dependencies = [ + "pest", + "pest_derive", + "serde", +] + +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64 0.22.1", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + +[[package]] +name = "jsonwebtoken" +version = "10.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1" +dependencies = [ + "aws-lc-rs", + "base64 0.22.1", + "getrandom 0.2.17", + "js-sys", + "pem", + "serde", + "serde_json", + "signature", + "simple_asn1", +] + +[[package]] +name = "jwt" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6204285f77fe7d9784db3fdc449ecce1a0114927a51d5a41c4c7a292011c015f" +dependencies = [ + "base64 0.13.1", + "crypto-common", + "digest", + "hmac", + "serde", + "serde_json", + "sha2", +] + +[[package]] +name = "keyed_priority_queue" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ee7893dab2e44ae5f9d0173f26ff4aa327c10b01b06a72b52dd9405b628640d" +dependencies = [ + "indexmap 2.13.0", +] + +[[package]] +name = "kingfisher" +version = "1.91.0" +dependencies = [ + "anyhow", + "asar", + "assert_cmd", + "aws-config", + "aws-credential-types", + "aws-sdk-dynamodb", + "aws-sdk-ec2", + "aws-sdk-iam", + "aws-sdk-kms", + "aws-sdk-lambda", + "aws-sdk-s3", + "aws-sdk-secretsmanager", + "aws-sdk-sts", + "aws-smithy-http-client", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "axum", + "base32", + "base64 0.22.1", + "blake3", + "bloomfilter", + "bson 3.1.0", + "bstr", + "byteorder", + "bytes", + "bzip2-rs", + "chrono", + "clap", + "color-backtrace", + "console", + "content_inspector", + "crc32fast", + "crossbeam-channel", + "crossbeam-skiplist", + "dashmap", + "ed25519-dalek", + "fixedbitset", + "flate2", + "futures", + "gcloud-storage", + "git2", + "gitlab", + "gix", + "globset", + "gouqi", + "h2", + "hex", + "hmac", + "http 1.4.0", + "humantime", + "ignore", + "include_dir", + "indenter", + "indicatif", + "ipnet", + "jsonwebtoken 10.3.0", + "kingfisher-core", + "kingfisher-rules", + "kingfisher-scanner", + "lazy_static", + "liquid", + "liquid-core", + "lzma-rs", + "memchr", + "memmap2", + "mimalloc", + "mongodb", + "mysql_async", + "num_cpus", + "oci-client", + "octorust", + "once_cell", + "p256", + "parking_lot 0.12.5", + "path-dedot", + "pem", + "percent-encoding", + "petgraph", + "predicates", + "pretty_assertions", + "proptest", + "quick-xml 0.39.2", + "rand 0.10.0", + "rand_chacha 0.10.0", + "rayon", + "regex", + "reqwest 0.12.28", + "reqwest-middleware 0.4.2", + "reqwest-middleware 0.5.1", + "ring", + "roaring", + "rusqlite", + "rustc-hash", + "rustls", + "rustls-native-certs", + "schemars 0.8.22", + "self_update", + "semver", + "serde", + "serde-sarif", + "serde_json", + "serde_yaml", + "sha1", + "sha2", + "smallvec", + "streaming-iterator", + "strum 0.26.3", + "strum_macros 0.28.0", + "sysinfo", + "tar", + "temp-env", + "tempfile", + "testcontainers", + "thiserror 2.0.18", + "thousands", + "thread_local", + "tikv-jemallocator", + "time", + "tokei", + "tokio", + "tokio-postgres", + "tokio-postgres-rustls", + "tokio-rustls", + "toon-format", + "tracing", + "tracing-core", + "tracing-subscriber", + "tree-sitter", + "tree-sitter-bash", + "tree-sitter-c", + "tree-sitter-c-sharp", + "tree-sitter-cpp", + "tree-sitter-css", + "tree-sitter-go", + "tree-sitter-html", + "tree-sitter-java", + "tree-sitter-javascript", + "tree-sitter-php", + "tree-sitter-python", + "tree-sitter-regex", + "tree-sitter-ruby", + "tree-sitter-rust", + "tree-sitter-toml-ng", + "tree-sitter-typescript", + "tree-sitter-yaml", + "tree_magic_mini", + "url", + "uuid", + "vectorscan-rs", + "walkdir", + "webbrowser", + "wiremock", + "xxhash-rust", + "zip 2.4.2", +] + +[[package]] +name = "kingfisher-core" +version = "0.1.0" +dependencies = [ + "anyhow", + "bstr", + "console", + "dashmap", + "gix", + "hex", + "memchr", + "memmap2", + "once_cell", + "parking_lot 0.12.5", + "pretty_assertions", + "rustc-hash", + "schemars 0.8.22", + "serde", + "serde_json", + "sha1", + "smallvec", + "thiserror 2.0.18", + "tokei", +] + +[[package]] +name = "kingfisher-rules" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64 0.22.1", + "crc32fast", + "hmac", + "ignore", + "include_dir", + "kingfisher-core", + "lazy_static", + "liquid", + "liquid-core", + "percent-encoding", + "pretty_assertions", + "proptest", + "rand 0.10.0", + "regex", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_yaml", + "sha1", + "sha2", + "thiserror 2.0.18", + "time", + "tracing", + "uuid", + "vectorscan-rs", + "walkdir", + "xxhash-rust", +] + +[[package]] +name = "kingfisher-scanner" +version = "0.1.0" +dependencies = [ + "anyhow", + "aws-config", + "aws-credential-types", + "aws-sdk-iam", + "aws-sdk-sts", + "aws-smithy-http-client", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "base32", + "base64 0.22.1", + "bson 3.1.0", + "bstr", + "byteorder", + "chrono", + "crossbeam-skiplist", + "ed25519-dalek", + "hex", + "hmac", + "http 1.4.0", + "jsonwebtoken 10.3.0", + "kingfisher-core", + "kingfisher-rules", + "liquid", + "liquid-core", + "mongodb", + "mysql_async", + "once_cell", + "p256", + "parking_lot 0.12.5", + "pem", + "percent-encoding", + "pretty_assertions", + "quick-xml 0.39.2", + "rand 0.10.0", + "regex", + "reqwest 0.12.28", + "ring", + "rustc-hash", + "rustls", + "rustls-native-certs", + "schemars 0.8.22", + "serde", + "serde_json", + "sha1", + "sha2", + "smallvec", + "tempfile", + "thiserror 2.0.18", + "thread_local", + "tokio", + "tokio-postgres", + "tokio-postgres-rustls", + "tracing", + "url", + "vectorscan-rs", + "xxhash-rust", +] + +[[package]] +name = "kstring" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "558bf9508a558512042d3095138b1f7b8fe90c5467d94f9f1da28b3731c5dbd1" +dependencies = [ + "serde", + "static_assertions", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "libgit2-sys" +version = "0.18.3+1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9b3acc4b91781bb0b3386669d325163746af5f6e4f73e6d2d630e09a35f3487" +dependencies = [ + "cc", + "libc", + "libz-sys", + "pkg-config", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libmimalloc-sys" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "667f4fec20f29dfc6bc7357c582d91796c169ad7e2fce709468aefeb2c099870" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "libredox" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" +dependencies = [ + "bitflags 2.11.0", + "libc", + "plain", + "redox_syscall 0.7.3", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libz-sys" +version = "1.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52f4c29e2a68ac30c9087e1b772dc9f44a2b66ed44edf2266cf2be9b03dafc1" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "liquid" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a494c3f9dad3cb7ed16f1c51812cbe4b29493d6c2e5cd1e2b87477263d9534d" +dependencies = [ + "liquid-core", + "liquid-derive", + "liquid-lib", + "serde", +] + +[[package]] +name = "liquid-core" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc623edee8a618b4543e8e8505584f4847a4e51b805db1af6d9af0a3395d0d57" +dependencies = [ + "anymap2", + "itertools 0.14.0", + "kstring", + "liquid-derive", + "pest", + "pest_derive", + "regex", + "serde", + "time", +] + +[[package]] +name = "liquid-derive" +version = "0.26.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de66c928222984aea59fcaed8ba627f388aaac3c1f57dcb05cc25495ef8faefe" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "liquid-lib" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9befeedd61f5995bc128c571db65300aeb50d62e4f0542c88282dbcb5f72372a" +dependencies = [ + "itertools 0.14.0", + "liquid-core", + "percent-encoding", + "regex", + "time", + "unicode-segmentation", +] + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +dependencies = [ + "serde_core", +] + +[[package]] +name = "lru" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f8cc7106155f10bdf99a6f379688f543ad6596a415375b36a59a054ceda1198" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "lru" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "lzma-rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e" +dependencies = [ + "byteorder", + "crc", +] + +[[package]] +name = "macro_magic" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc33f9f0351468d26fbc53d9ce00a096c8522ecb42f19b50f34f2c422f76d21d" +dependencies = [ + "macro_magic_core", + "macro_magic_macros", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "macro_magic_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1687dc887e42f352865a393acae7cf79d98fab6351cde1f58e9e057da89bf150" +dependencies = [ + "const-random", + "derive-syn-parse", + "macro_magic_core_macros", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "macro_magic_core_macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b02abfe41815b5bd98dbd4260173db2c116dda171dc0fe7838cb206333b83308" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "macro_magic_macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ea28ee64b88876bf45277ed9a5817c1817df061a74f2b988971a12570e5869" +dependencies = [ + "macro_magic_core", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "maybe-async" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cf92c10c7e361d6b99666ec1c6f9805b0bea2c3bd8c78dc6fe98ac5bd78db11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + +[[package]] +name = "mimalloc" +version = "0.1.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1ee66a4b64c74f4ef288bcbb9192ad9c3feaad75193129ac8509af543894fd8" +dependencies = [ + "libmimalloc-sys", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.61.2", +] + +[[package]] +name = "moka" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot 0.12.5", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "mongocrypt" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8da0cd419a51a5fb44819e290fbdb0665a54f21dead8923446a799c7f4d26ad9" +dependencies = [ + "bson 2.15.0", + "mongocrypt-sys", + "once_cell", + "serde", +] + +[[package]] +name = "mongocrypt-sys" +version = "0.1.5+1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224484c5d09285a7b8cb0a0c117e847ebd14cb6e4470ecf68cdb89c503b0edb9" + +[[package]] +name = "mongodb" +version = "3.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c5941683db2ab2697f71e58dc0319024e808d3b28e7cf20f4bfb445fe54a30b" +dependencies = [ + "aws-config", + "aws-credential-types", + "aws-sigv4", + "base64 0.22.1", + "bitflags 2.11.0", + "bson 2.15.0", + "chrono", + "derive-where", + "derive_more", + "futures-core", + "futures-io", + "futures-util", + "hex", + "hickory-proto", + "hickory-resolver", + "hmac", + "http 1.4.0", + "macro_magic", + "md-5", + "mongocrypt", + "mongodb-internal-macros", + "pbkdf2", + "percent-encoding", + "rand 0.9.2", + "rustc_version_runtime", + "rustls", + "rustversion", + "serde", + "serde_bytes", + "serde_with 3.18.0", + "sha1", + "sha2", + "socket2 0.6.3", + "stringprep", + "strsim 0.11.1", + "take_mut", + "thiserror 2.0.18", + "tokio", + "tokio-rustls", + "tokio-util", + "typed-builder", + "uuid", + "webpki-roots 1.0.6", +] + +[[package]] +name = "mongodb-internal-macros" +version = "3.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47021a12bbf0dffde9c890fa2d36ff6ae342c532016226b04a42301b2b912660" +dependencies = [ + "macro_magic", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "mysql-common-derive" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66f62cad7623a9cb6f8f64037f0c4f69c8db8e82914334a83c9788201c2c1bfa" +dependencies = [ + "darling 0.20.11", + "heck 0.5.0", + "num-bigint", + "proc-macro-crate", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.117", + "termcolor", + "thiserror 2.0.18", +] + +[[package]] +name = "mysql_async" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "277ce2f2459b2af4cc6d0a0b7892381f80800832f57c533f03e2845f4ea331ea" +dependencies = [ + "bytes", + "crossbeam-queue", + "flate2", + "futures-core", + "futures-sink", + "futures-util", + "keyed_priority_queue", + "lru 0.14.0", + "mysql_common", + "pem", + "percent-encoding", + "rand 0.9.2", + "rustls", + "rustls-pemfile", + "serde", + "serde_json", + "socket2 0.5.10", + "thiserror 2.0.18", + "tokio", + "tokio-rustls", + "tokio-util", + "twox-hash", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "mysql_common" +version = "0.35.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbb9f371618ce723f095c61fbcdc36e8936956d2b62832f9c7648689b338e052" +dependencies = [ + "base64 0.22.1", + "bitflags 2.11.0", + "btoi", + "byteorder", + "bytes", + "crc32fast", + "flate2", + "getrandom 0.3.4", + "mysql-common-derive", + "num-bigint", + "num-traits", + "regex", + "saturating", + "serde", + "serde_json", + "sha1", + "sha2", + "thiserror 2.0.18", + "uuid", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nonempty" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6" +dependencies = [ + "serde", +] + +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[package]] +name = "num-format" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a652d9771a63711fd3c3deb670acfbe5c30a4072e664d7a3bf5a9e1056ac72c3" +dependencies = [ + "arrayvec", + "itoa", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.11.0", + "objc2", +] + +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "objc2-core-foundation", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "oci-client" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b74df13319e08bc386d333d3dc289c774c88cc543cae31f5347db07b5ec2172" +dependencies = [ + "bytes", + "chrono", + "futures-util", + "http 1.4.0", + "http-auth", + "jwt", + "lazy_static", + "oci-spec", + "olpc-cjson", + "regex", + "reqwest 0.12.28", + "serde", + "serde_json", + "sha2", + "thiserror 2.0.18", + "tokio", + "tracing", + "unicase", +] + +[[package]] +name = "oci-spec" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc3da52b83ce3258fbf29f66ac784b279453c2ac3c22c5805371b921ede0d308" +dependencies = [ + "const_format", + "derive_builder 0.20.2", + "getset", + "regex", + "serde", + "serde_json", + "strum 0.27.2", + "strum_macros 0.27.2", + "thiserror 2.0.18", +] + +[[package]] +name = "octorust" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c488b641cf652f023d371f6d472191bf3b3fd8075a11f274e95d4fe6e5e3878" +dependencies = [ + "async-recursion", + "async-trait", + "bytes", + "chrono", + "http 1.4.0", + "jsonwebtoken 9.3.1", + "log", + "mime", + "parse_link_header", + "pem", + "percent-encoding", + "reqwest 0.12.28", + "reqwest-conditional-middleware", + "reqwest-middleware 0.4.2", + "reqwest-retry", + "reqwest-tracing", + "ring", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_urlencoded", + "thiserror 1.0.69", + "tokio", + "url", + "uuid", +] + +[[package]] +name = "olpc-cjson" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "696183c9b5fe81a7715d074fd632e8bd46f4ccc0231a3ed7fc580a80de5f7083" +dependencies = [ + "serde", + "serde_json", + "unicode-normalization", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "parking_lot" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" +dependencies = [ + "instant", + "lock_api", + "parking_lot_core 0.8.6", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core 0.9.12", +] + +[[package]] +name = "parking_lot_core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a2cfe6f0ad2bfc16aefa463b497d5c7a5ecd44a23efa72aa342d90177356dc" +dependencies = [ + "cfg-if", + "instant", + "libc", + "redox_syscall 0.2.16", + "smallvec", + "winapi", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "parse-zoneinfo" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24" +dependencies = [ + "regex", +] + +[[package]] +name = "parse_link_header" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3687fe9debbbf2a019f381a8bc6b42049b22647449b39af54b3013985c0cf6de" +dependencies = [ + "http 0.2.12", + "lazy_static", + "regex", +] + +[[package]] +name = "path-dedot" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07ba0ad7e047712414213ff67533e6dd477af0a4e1d14fb52343e53d30ea9397" +dependencies = [ + "once_cell", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap 2.13.0", + "serde", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_shared 0.13.1", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.5", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "pori" +version = "0.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a63d338dec139f56dacc692ca63ad35a6be6a797442479b55acd611d79e906" +dependencies = [ + "nom 7.1.3", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "postgres-protocol" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ee9dd5fe15055d2b6806f4736aa0c9637217074e224bbec46d4041b91bb9491" +dependencies = [ + "base64 0.22.1", + "byteorder", + "bytes", + "fallible-iterator 0.2.0", + "hmac", + "md-5", + "memchr", + "rand 0.9.2", + "sha2", + "stringprep", +] + +[[package]] +name = "postgres-types" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b858f82211e84682fecd373f68e1ceae642d8d751a1ebd13f33de6257b3e20" +dependencies = [ + "bytes", + "fallible-iterator 0.2.0", + "postgres-protocol", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.8+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prodash" +version = "31.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "962200e2d7d551451297d9fdce85138374019ada198e30ea9ede38034e27604c" +dependencies = [ + "bytesize", + "human_format", + "parking_lot 0.12.5", +] + +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags 2.11.0", + "num-traits", + "rand 0.9.2", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "pulldown-cmark" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57206b407293d2bcd3af849ce869d52068623f19e1b5ff8e8778e3309439682b" +dependencies = [ + "bitflags 2.11.0", + "memchr", + "unicase", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", +] + +[[package]] +name = "quick-xml" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.6.3", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.3", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.0", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e6af7f3e25ded52c41df4e0b1af2d047e45896c2f3281792ed68a1c243daedb" +dependencies = [ + "ppv-lite86", + "rand_core 0.10.0", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "redox_syscall" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.4.2", + "web-sys", + "webpki-roots 1.0.6", +] + +[[package]] +name = "reqwest" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.5.0", + "web-sys", +] + +[[package]] +name = "reqwest-conditional-middleware" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f67ad7fdf5c0a015763fcd164bee294b13fb7b6f89f1b55961d40f00c3e32d6b" +dependencies = [ + "async-trait", + "http 1.4.0", + "reqwest 0.12.28", + "reqwest-middleware 0.4.2", +] + +[[package]] +name = "reqwest-middleware" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57f17d28a6e6acfe1733fe24bcd30774d13bffa4b8a22535b4c8c98423088d4e" +dependencies = [ + "anyhow", + "async-trait", + "http 1.4.0", + "reqwest 0.12.28", + "serde", + "thiserror 1.0.69", + "tower-service", +] + +[[package]] +name = "reqwest-middleware" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "199dda04a536b532d0cc04d7979e39b1c763ea749bf91507017069c00b96056f" +dependencies = [ + "anyhow", + "async-trait", + "http 1.4.0", + "reqwest 0.13.2", + "serde", + "thiserror 2.0.18", + "tower-service", +] + +[[package]] +name = "reqwest-retry" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29c73e4195a6bfbcb174b790d9b3407ab90646976c55de58a6515da25d851178" +dependencies = [ + "anyhow", + "async-trait", + "futures", + "getrandom 0.2.17", + "http 1.4.0", + "hyper", + "parking_lot 0.11.2", + "reqwest 0.12.28", + "reqwest-middleware 0.4.2", + "retry-policies", + "thiserror 1.0.69", + "tokio", + "tracing", + "wasm-timer", +] + +[[package]] +name = "reqwest-tracing" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d70ea85f131b2ee9874f0b160ac5976f8af75f3c9badfe0d955880257d10bd83" +dependencies = [ + "anyhow", + "async-trait", + "getrandom 0.2.17", + "http 1.4.0", + "matchit", + "reqwest 0.12.28", + "reqwest-middleware 0.4.2", + "tracing", +] + +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + +[[package]] +name = "retry-policies" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5875471e6cab2871bc150ecb8c727db5113c9338cc3354dc5ee3425b6aa40a1c" +dependencies = [ + "rand 0.8.5", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted 0.9.0", + "windows-sys 0.52.0", +] + +[[package]] +name = "roaring" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ba9ce64a8f45d7fc86358410bb1a82e8c987504c0d4900e9141d69a9f26c885" +dependencies = [ + "bytemuck", + "byteorder", +] + +[[package]] +name = "rsqlite-vfs" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.18", +] + +[[package]] +name = "rusqlite" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" +dependencies = [ + "bitflags 2.11.0", + "fallible-iterator 0.3.0", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustc_version_runtime" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dd18cd2bae1820af0b6ad5e54f4a51d0f3fcc53b05f845675074efcc7af071d" +dependencies = [ + "rustc_version", + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni 0.21.1", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted 0.9.0", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "saturating" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ece8e78b2f38ec51c51f5d475df0a7187ba5111b2a28bdc761ee05b075d40a71" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemafy_core" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bec29dddcfe60f92f3c0d422707b8b56473983ef0481df8d5236ed3ab8fdf24" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "schemafy_lib" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af3d87f1df246a9b7e2bfd1f4ee5f88e48b11ef9cfc62e63f0dead255b1a6f5f" +dependencies = [ + "Inflector", + "proc-macro2", + "quote", + "schemafy_core", + "serde", + "serde_derive", + "serde_json", + "syn 1.0.109", + "uriparse", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "bytes", + "chrono", + "dyn-clone", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.0", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "self-replace" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ec815b5eab420ab893f63393878d89c90fdd94c0bcc44c07abb8ad95552fb7" +dependencies = [ + "fastrand", + "tempfile", + "windows-sys 0.52.0", +] + +[[package]] +name = "self_update" +version = "0.43.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6644febaa58f323b28f7321d04e24d0020d117c27619ab869d6abdf76be9aac6" +dependencies = [ + "either", + "flate2", + "http 1.4.0", + "indicatif", + "log", + "quick-xml 0.38.4", + "regex", + "reqwest 0.12.28", + "self-replace", + "semver", + "serde", + "serde_json", + "tar", + "tempfile", + "ureq", + "urlencoding", + "zip 6.0.0", + "zipsign-api", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-sarif" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d878dc2c454b118932f9e8aef2a228ec99dcac000d6204a0061d9b2ef322304" +dependencies = [ + "anyhow", + "derive_builder 0.12.0", + "prettyplease", + "proc-macro2", + "quote", + "schemafy_lib", + "serde", + "serde_json", + "strum 0.25.0", + "strum_macros 0.24.3", + "syn 2.0.117", + "thiserror 1.0.69", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "indexmap 2.13.0", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "678b5a069e50bf00ecd22d0cd8ddf7c236f68581b03db652061ed5eb13a312ff" +dependencies = [ + "serde", + "serde_with_macros 1.5.2", +] + +[[package]] +name = "serde_with" +version = "3.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.13.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros 3.18.0", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e182d6ec6f05393cc0e5ed1bf81ad6db3a8feedf8ee515ecdd369809bcce8082" +dependencies = [ + "darling 0.13.4", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "serde_with_macros" +version = "3.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.13.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", + "sha1-asm", +] + +[[package]] +name = "sha1-asm" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "286acebaf8b67c1130aedffad26f594eff0c1292389158135327d2e23aed582b" +dependencies = [ + "cc", +] + +[[package]] +name = "sha1-checked" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89f599ac0c323ebb1c6082821a54962b839832b03984598375bff3975b804423" +dependencies = [ + "digest", + "sha1", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b57709da74f9ff9f4a27dce9526eec25ca8407c45a7887243b031a58935fb8e" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + +[[package]] +name = "skeptic" +version = "0.13.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16d23b015676c90a0f01c197bfdc786c20342c73a0afdda9025adb0bc42940a8" +dependencies = [ + "bytecount", + "cargo_metadata", + "error-chain", + "glob", + "pulldown-cmark", + "tempfile", + "walkdir", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slug" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "882a80f72ee45de3cc9a5afeb2da0331d58df69e4e7d8eeb5d3c7784ae67e724" +dependencies = [ + "deunicode", + "wasm-bindgen", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "socks" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b" +dependencies = [ + "byteorder", + "libc", + "winapi", +] + +[[package]] +name = "spin" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4206ed3a67690b9c29b77d728f6acc3ce78f16bf846d83c94f76400320181b" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros 0.26.4", +] + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" + +[[package]] +name = "strum_macros" +version = "0.24.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e385be0d24f186b4ce2f9982191e7101bb737312ad61c1f2f984f34bcf85d59" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "rustversion", + "syn 1.0.109", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.117", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "sysinfo" +version = "0.31.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "355dbe4f8799b304b05e1b0f05fc59b2a18d36645cf169607da45bde2f69a1be" +dependencies = [ + "core-foundation-sys", + "libc", + "memchr", + "ntapi", + "rayon", + "windows", +] + +[[package]] +name = "table_formatter" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "beef5d3fd5472c911d41286849de6a9aee93327f7fae9fb9148fe9ff0102c17d" +dependencies = [ + "colored", + "itertools 0.11.0", + "thiserror 1.0.69", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "take_mut" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f764005d11ee5f36500a149ace24e00e3da98b0158b3e2d53a7495660d3f4d60" + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tar" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "temp-env" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96374855068f47402c3121c6eed88d29cb1de8f3ab27090e273e420bdabcf050" +dependencies = [ + "parking_lot 0.12.5", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tera" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8004bca281f2d32df3bacd59bc67b312cb4c70cea46cbd79dbe8ac5ed206722" +dependencies = [ + "chrono", + "chrono-tz", + "globwalk", + "humansize", + "lazy_static", + "percent-encoding", + "pest", + "pest_derive", + "rand 0.8.5", + "regex", + "serde", + "serde_json", + "slug", + "unicode-segmentation", +] + +[[package]] +name = "term_size" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4129646ca0ed8f45d09b929036bafad5377103edd06e50bf574b353d2b08d9" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + +[[package]] +name = "testcontainers" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d2931d7f521af5bae989f716c3fa43a6af9af7ec7a5e21b59ae40878cec00" +dependencies = [ + "bollard-stubs", + "futures", + "hex", + "hmac", + "log", + "rand 0.8.5", + "serde", + "serde_json", + "sha2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thousands" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bf63baf9f5039dadc247375c29eb13706706cfde997d0330d05aa63a77d8820" + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tikv-jemalloc-sys" +version = "0.6.1+5.3.0-1-ge13ca993e8ccb9ba9847cc330696e02839f328f7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd8aa5b2ab86a2cefa406d889139c162cbb230092f7d1d7cbc1716405d852a3b" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "tikv-jemallocator" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0359b4327f954e0567e69fb191cf1436617748813819c94b8cd4a431422d053a" +dependencies = [ + "libc", + "tikv-jemalloc-sys", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tls_codec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" +dependencies = [ + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokei" +version = "14.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4de7875c0c312f30e090edb0da5df9f6586033132d36d94c8f9133e82826797" +dependencies = [ + "aho-corasick", + "arbitrary", + "clap", + "clap-cargo", + "colored", + "crossbeam-channel", + "dashmap", + "encoding_rs_io", + "env_logger", + "etcetera", + "grep-searcher", + "ignore", + "json5", + "log", + "num-format", + "once_cell", + "parking_lot 0.12.5", + "rayon", + "regex", + "serde", + "serde_json", + "table_formatter", + "tera", + "term_size", + "toml", +] + +[[package]] +name = "token-source" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75746ae15bef509f21039a652383104424208fdae172a964a8930858b9a78412" +dependencies = [ + "async-trait", +] + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot 0.12.5", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.3", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-postgres" +version = "0.7.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcea47c8f71744367793f16c2db1f11cb859d28f436bdb4ca9193eb1f787ee42" +dependencies = [ + "async-trait", + "byteorder", + "bytes", + "fallible-iterator 0.2.0", + "futures-channel", + "futures-util", + "log", + "parking_lot 0.12.5", + "percent-encoding", + "phf 0.13.1", + "pin-project-lite", + "postgres-protocol", + "postgres-types", + "rand 0.9.2", + "socket2 0.6.3", + "tokio", + "tokio-util", + "whoami", +] + +[[package]] +name = "tokio-postgres-rustls" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27d684bad428a0f2481f42241f821db42c54e2dc81d8c00db8536c506b0a0144" +dependencies = [ + "const-oid", + "ring", + "rustls", + "tokio", + "tokio-postgres", + "tokio-rustls", + "x509-cert", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-io", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "1.1.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap 2.13.0", + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c" +dependencies = [ + "indexmap 2.13.0", + "toml_datetime 1.1.0+spec-1.1.0", + "toml_parser", + "winnow 1.0.0", +] + +[[package]] +name = "toml_parser" +version = "1.1.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" +dependencies = [ + "winnow 1.0.0", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "toon-format" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d25e33e50b37f95f3b55b6e664218cac7e1a50f056a75bb4c7a6cccfbc8a8c4" +dependencies = [ + "indexmap 2.13.0", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "async-compression", + "bitflags 2.11.0", + "bytes", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "iri-string", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-error" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db" +dependencies = [ + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "tree-sitter" +version = "0.26.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a6592b1aec0109df37b6bafea77eb4e61466e37b0a5a98bef4f89bfb81b7a2" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "serde_json", + "streaming-iterator", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-bash" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5ec769279cc91b561d3df0d8a5deb26b0ad40d183127f409494d6d8fc53062" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-c" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a3aad8f0129083a59fe8596157552d2bb7148c492d44c21558d68ca1c722707" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-c-sharp" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67f06accca7b45351758663b8215089e643d53bd9a660ce0349314263737fcb0" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-cpp" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df2196ea9d47b4ab4a31b9297eaa5a5d19a0b121dceb9f118f6790ad0ab94743" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-css" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5cbc5e18f29a2c6d6435891f42569525cf95435a3e01c2f1947abcde178686f" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-go" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8560a4d2f835cc0d4d2c2e03cbd0dde2f6114b43bc491164238d333e28b16ea" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-html" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "261b708e5d92061ede329babaaa427b819329a9d427a1d710abb0f67bbef63ee" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-java" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa6cbcdc8c679b214e616fd3300da67da0e492e066df01bcf5a5921a71e90d6" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-javascript" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68204f2abc0627a90bdf06e605f5c470aa26fdcb2081ea553a04bdad756693f5" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" + +[[package]] +name = "tree-sitter-php" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c17c3ab69052c5eeaa7ff5cd972dd1bc25d1b97ee779fec391ad3b5df5592" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-python" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bf85fd39652e740bf60f46f4cda9492c3a9ad75880575bf14960f775cb74a1c" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-regex" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd8a59be9f0ac131fd8f062eaaba14882b2fa5a6a7882a20134cb1d60df2e625" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-ruby" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be0484ea4ef6bb9c575b4fdabde7e31340a8d2dbc7d52b321ac83da703249f95" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-rust" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439e577dbe07423ec2582ac62c7531120dbfccfa6e5f92406f93dd271a120e45" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-toml-ng" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9adc2c898ae49730e857d75be403da3f92bb81d8e37a2f918a08dd10de5ebb1" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-typescript" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-yaml" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c223db85f05e34794f065454843b0668ebc15d240ada63e2b5939f43ce7c97" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom 8.0.0", + "petgraph", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "twox-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" + +[[package]] +name = "typed-builder" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "398a3a3c918c96de527dc11e6e846cd549d4508030b8a33e1da12789c856b81a" +dependencies = [ + "typed-builder-macro", +] + +[[package]] +name = "typed-builder-macro" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e48cea23f68d1f78eb7bc092881b6bb88d3d6b5b7e6234f6f9c911da1ffb221" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "uluru" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c8a2469e56e6e5095c82ccd3afb98dad95f7af7929aab6d8ba8d6e0f73657da" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-bom" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eec5d1121208364f6793f7d2e222bf75a915c19557537745b195b253dd64217" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unit-prefix" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64 0.22.1", + "cookie_store", + "encoding_rs", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "socks", + "ureq-proto", + "utf8-zero", + "webpki-roots 1.0.6", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64 0.22.1", + "http 1.4.0", + "httparse", + "log", +] + +[[package]] +name = "uriparse" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0200d0fc04d809396c2ad43f3c95da3582a2556eba8d453c1087f4120ee352ff" +dependencies = [ + "fnv", + "lazy_static", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "vectorscan-rs" +version = "0.0.5" +dependencies = [ + "bitflags 2.11.0", + "foreign-types", + "libc", + "thiserror 1.0.69", + "vectorscan-rs-sys", +] + +[[package]] +name = "vectorscan-rs-sys" +version = "0.0.5" +dependencies = [ + "cmake", + "flate2", + "tar", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasi" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" +dependencies = [ + "wasi 0.14.7+wasi-0.2.4", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d1faf851e778dfa54db7cd438b70758eba9755cb47403f3496edd7c8fc212f0" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.13.0", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasm-timer" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be0ecb0db480561e9a7642b5d3e4187c128914e58aa84330b9493e3eb68c5e7f" +dependencies = [ + "futures", + "js-sys", + "parking_lot 0.11.2", + "pin-utils", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap 2.13.0", + "semver", +] + +[[package]] +name = "wax" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d12a78aa0bab22d2f26ed1a96df7ab58e8a93506a3e20adb47c51a93b4e1357" +dependencies = [ + "const_format", + "itertools 0.11.0", + "nom 7.1.3", + "pori", + "regex", + "thiserror 1.0.69", + "walkdir", +] + +[[package]] +name = "web-sys" +version = "0.3.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84cde8507f4d7cfcb1185b8cb5890c494ffea65edbe1ba82cfd63661c805ed94" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webbrowser" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe985f41e291eecef5e5c0770a18d28390addb03331c043964d9e916453d6f16" +dependencies = [ + "core-foundation", + "jni 0.22.4", + "log", + "ndk-context", + "objc2", + "objc2-foundation", + "url", + "web-sys", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.6", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6a5b12f9df4f978d2cfdb1bd3bac52433f44393342d7ee9c25f5a1c14c0f45d" +dependencies = [ + "libc", + "libredox", + "objc2-system-configuration", + "wasite", + "web-sys", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" +dependencies = [ + "windows-core 0.57.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" +dependencies = [ + "windows-implement 0.57.0", + "windows-interface 0.57.0", + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link", + "windows-result 0.4.1", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result 0.4.1", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.6.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e90edd2ac1aa278a5c4599b1d89cf03074b610800f866d4026dc199d7929a28" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" +dependencies = [ + "memchr", +] + +[[package]] +name = "wiremock" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" +dependencies = [ + "assert-json-diff", + "base64 0.22.1", + "deadpool", + "futures", + "http 1.4.0", + "http-body-util", + "hyper", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap 2.13.0", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "indexmap 2.13.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.13.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid", + "der", + "spki", + "tls_codec", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "xmlparser" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" + +[[package]] +name = "xxhash-rust" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "deflate64", + "displaydoc", + "flate2", + "indexmap 2.13.0", + "memchr", + "thiserror 2.0.18", + "time", + "zopfli", +] + +[[package]] +name = "zip" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a05c7c36fde6c09b08576c9f7fb4cda705990f73b58fe011abf7dfb24168b" +dependencies = [ + "arbitrary", + "crc32fast", + "indexmap 2.13.0", + "memchr", + "time", +] + +[[package]] +name = "zipsign-api" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dba6063ff82cdbd9a765add16d369abe81e520f836054e997c2db217ceca40c0" +dependencies = [ + "base64 0.22.1", + "ed25519-dalek", + "thiserror 2.0.18", +] + +[[package]] +name = "zlib-rs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/containers/kingfisher/default.nix b/containers/kingfisher/default.nix new file mode 100644 index 0000000..c937955 --- /dev/null +++ b/containers/kingfisher/default.nix @@ -0,0 +1,114 @@ +# Nix-built Kingfisher secret scanner +# Built from upstream main + sporked feature branches applied as patches. +# Runs on ringtail (amd64) via nix-container-builder runner. +# +# How it works: +# 1. builtins.fetchGit fetches upstream and feature branches at eval time +# 2. diff generates patches from upstream→feature in a sandboxed derivation +# 3. buildRustPackage applies patches to the upstream source and builds +# +# To update: +# 1. Update upstreamRev to the new main SHA +# 2. Rebase feature branches onto new main (mirror-sync does this daily) +# 3. Update feature revs to the new rebased SHAs +# 4. Update Cargo.lock if dependencies changed +# +# The upstream rev must be an ancestor of each feature rev. +{ pkgs ? import { } }: + +let + version = "165768b"; + repoUrl = "https://forge.ops.eblu.me/eblume/kingfisher.git"; + + upstreamRev = "165768b5ca9a85c2e8c64bed19bb197e82b45360"; + + features = [ + { + name = "clone-url-base"; + ref = "feature/upstream/clone-url-base"; + rev = "4d5ce57a12650ec54c41b909f8623a1d395aa0a9"; + } + ]; + + # Fetch upstream source at the pinned rev (eval-time, network access) + upstreamSrc = builtins.fetchGit { + url = repoUrl; + ref = "main"; + rev = upstreamRev; + }; + + # Fetch each feature branch source and generate a patch against upstream + featurePatches = map (f: + let + featureSrc = builtins.fetchGit { + url = repoUrl; + ref = f.ref; + rev = f.rev; + }; + in + pkgs.runCommand "spork-${f.name}.patch" { + nativeBuildInputs = [ pkgs.diffutils pkgs.gnused ]; + } '' + diff -ruN --no-dereference ${upstreamSrc} ${featureSrc} \ + | sed -e 's|${upstreamSrc}/|a/|g' -e 's|${featureSrc}/|b/|g' \ + > $out || true + '' + ) features; + + kingfisher = pkgs.rustPlatform.buildRustPackage { + pname = "kingfisher"; + inherit version; + src = upstreamSrc; + + patches = featurePatches; + + # Cargo.lock is not committed upstream; we vendor a copy alongside default.nix + cargoLock.lockFile = ./Cargo.lock; + + # Patch the source to include Cargo.lock (buildRustPackage needs it in-tree) + postPatch = '' + cp ${./Cargo.lock} Cargo.lock + chmod +w Cargo.lock + ''; + + nativeBuildInputs = with pkgs; [ + cmake + pkg-config + python3 + ]; + + buildInputs = with pkgs; [ + boost + openssl + ]; + + # Don't run tests — they need network access for wiremock + doCheck = false; + + meta = with pkgs.lib; { + description = "Secret detection and live validation tool"; + homepage = "https://github.com/mongodb/kingfisher"; + license = licenses.asl20; + mainProgram = "kingfisher"; + }; + }; +in + +pkgs.dockerTools.buildLayeredImage { + name = "blumeops/kingfisher"; + contents = [ + kingfisher + pkgs.cacert + pkgs.git + pkgs.tzdata + ]; + + config = { + Entrypoint = [ "${kingfisher}/bin/kingfisher" ]; + Env = [ + "SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" + "TZDIR=${pkgs.tzdata}/share/zoneinfo" + ]; + User = "65534"; + }; +} diff --git a/docs/changelog.d/feature-kingfisher-container.feature.md b/docs/changelog.d/feature-kingfisher-container.feature.md new file mode 100644 index 0000000..9054e81 --- /dev/null +++ b/docs/changelog.d/feature-kingfisher-container.feature.md @@ -0,0 +1 @@ +Build custom Kingfisher container from sporked deploy branch, replacing upstream image with locally-built version including --clone-url-base patch. diff --git a/docs/explanation/spork-strategy.md b/docs/explanation/spork-strategy.md index ce1c999..f5ac4ea 100644 --- a/docs/explanation/spork-strategy.md +++ b/docs/explanation/spork-strategy.md @@ -60,6 +60,7 @@ Note that a cron-triggered workflow is especially dangerous: it requires no user - [[create-a-spork]] — initial setup with `mise run spork-create` - [[manage-spork-branches]] — feature branches, the deploy branch, handling rebase conflicts +- [[build-spork-container]] — building reproducible containers from pinned SHAs ## See also diff --git a/docs/how-to/configuration/build-spork-container.md b/docs/how-to/configuration/build-spork-container.md new file mode 100644 index 0000000..cdb637a --- /dev/null +++ b/docs/how-to/configuration/build-spork-container.md @@ -0,0 +1,101 @@ +--- +title: Build a Spork Container +modified: 2026-03-29 +last-reviewed: 2026-03-29 +tags: + - how-to + - containers + - git +--- + +# Build a Spork Container + +How to build a container image from a [[spork-strategy|sporked]] project with fully-pinned, reproducible inputs. + +## Why not use the `deploy` branch directly? + +The `deploy` branch is force-pushed on every mirror-sync. Building from `deploy` is not reproducible — the same build a week later fetches different code (or fails because the old commit was garbage collected). + +Instead, spork containers use Nix to fetch upstream `main` at a pinned SHA and generate patches from feature branches at pinned SHAs. Both upstream and feature commits are on stable branches that are never force-pushed. + +## How the Nix build works + +The `default.nix` uses `builtins.fetchGit` (eval-time, network access) to fetch two source trees: + +1. **Upstream source** at the pinned `upstreamRev` on `main` +2. **Feature branch source** at the pinned `rev` for each feature + +Then a sandboxed `diff -ruN` generates a patch from upstream→feature for each feature branch. `buildRustPackage` applies the patches to the upstream source and builds. + +This means: +- The upstream rev persists forever (main only fast-forwards) +- Feature revs are on your branches (you control them) +- No dependency on the `deploy` branch +- Fully reproducible given the same revs + +## Prerequisites + +- Sporked project set up (see [[create-a-spork]]) +- Nix build runs on ringtail (`nix-container-builder` runner) + +## Get the SHAs + +```fish +cd ~/code/3rd/kingfisher +git fetch origin + +# Upstream SHA (main branch) +git rev-parse origin/main +# e.g., 1d37d2983cd4a58c12663dd8df0e79dfe89a5d75 + +# Feature branch SHAs +git rev-parse origin/feature/upstream/clone-url-base +# e.g., 677c7a5d5fc42b655d38fbf95dc8b814d89ceabb +``` + +## Update `default.nix` + +Edit `containers/kingfisher/default.nix`: + +- `version` — short upstream SHA (for container tag) +- `upstreamRev` — full upstream main SHA +- `features[].rev` — full feature branch SHA + +If dependencies changed, update `Cargo.lock` too: + +```fish +cd ~/code/3rd/kingfisher +git checkout origin/main +cargo update +cp Cargo.lock ~/code/personal/blumeops/containers/kingfisher/Cargo.lock +``` + +## Build and push + +The build is triggered via the standard container build workflow on ringtail's `nix-container-builder` runner, or manually: + +```fish +mise run container-build-and-release kingfisher +``` + +## Update the deployment + +1. Update `argocd/manifests/kingfisher/kustomization.yaml` with the new tag +2. Update `service-versions.yaml` if the upstream SHA changed +3. Sync the ArgoCD app + +## Note on `CONTAINER_APP_VERSION` + +The `default.nix` includes `version` which maps to `CONTAINER_APP_VERSION` for the `container-version-check` hook. For sporked containers this is a git SHA, not a release version. Don't confuse it with an upstream release number. + +## Reproducibility + +The upstream rev must be an ancestor of each feature rev. If you bump the upstream rev without rebasing your feature branches, the generated patch will conflict and the build fails — which is the correct behavior. + +The invariant: **feature revs are descendants of the upstream rev**. Mirror-sync maintains this automatically. You just need to update the revs in `default.nix` after an upgrade. + +## See also + +- [[create-a-spork]] — initial spork setup +- [[manage-spork-branches]] — feature branch workflow +- [[kingfisher]] — first sporked project diff --git a/docs/reference/services/kingfisher.md b/docs/reference/services/kingfisher.md index d6c5cf2..7512d6b 100644 --- a/docs/reference/services/kingfisher.md +++ b/docs/reference/services/kingfisher.md @@ -16,7 +16,7 @@ Secret detection and live validation scanner for Forgejo repositories, using Mon | Property | Value | |----------|-------| | **Namespace** | `kingfisher` | -| **Image** | `ghcr.io/mongodb/kingfisher` (see `argocd/manifests/kingfisher/kustomization.yaml` for current tag) | +| **Image** | `registry.ops.eblu.me/blumeops/kingfisher` (see `argocd/manifests/kingfisher/kustomization.yaml` for current tag) | | **Schedule** | Sunday 4am (after Prowler k8s scan at 3am) | | **Reports** | `sifaka:/volume1/reports/kingfisher/` (NFS) | | **Manifests** | `argocd/manifests/kingfisher/` | @@ -24,7 +24,7 @@ Secret detection and live validation scanner for Forgejo repositories, using Mon ## What it does -Runs as a weekly CronJob that scans all repositories in the `eblume` user on Forgejo for leaked secrets, API keys, and credentials. Produces timestamped HTML and JSON reports on the sifaka NFS share. +Runs as a weekly CronJob that scans all Forgejo repos (eblume + all orgs) for leaked secrets, API keys, and credentials. Produces timestamped HTML reports on the sifaka NFS share. Uses `--clone-url-base` to route git clones via the internal tailnet instead of the public Fly.io proxy. Uses the Forgejo/Gitea API to enumerate repos, then clones and scans each one. Validation is enabled (secrets are tested against their respective APIs to confirm they're live). Reports are HTML only. @@ -46,7 +46,7 @@ kubectl logs -f job/kingfisher-manual -n kingfisher --context=minikube-indri ## Limitations -- Clone URLs come from Forgejo's API response using the instance's public `ROOT_URL` (`forge.eblu.me`), so clones roundtrip through Fly.io. Mirror/org scanning is excluded for now to avoid unnecessary external bandwidth. A clone URL rewrite option would need an upstream contribution. +- Built from a [[spork-strategy|sporked]] fork with a local `--clone-url-base` patch. See [[build-spork-container]] for the build process. - Only one output format per invocation. Currently producing HTML only. ## See also diff --git a/service-versions.yaml b/service-versions.yaml index bca2528..6e67b24 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -285,6 +285,13 @@ services: upstream-source: https://github.com/prowler-cloud/prowler/releases notes: CIS Kubernetes Benchmark scanner; weekly CronJob on minikube-indri + - name: kingfisher + type: argocd + last-reviewed: 2026-03-29 + current-version: "165768b" + upstream-source: https://github.com/mongodb/kingfisher/releases + notes: Secret scanner; sporked from upstream with --clone-url-base patch. Version is upstream main SHA. + - name: forgejo type: ansible last-reviewed: 2026-03-28 From f0c6845f0fb379a61a37ecd6dbec3883cad50f50 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 30 Mar 2026 06:42:24 -0700 Subject: [PATCH 167/430] Deploy custom Kingfisher container v165768b-f9206bf-nix Co-Authored-By: Claude Opus 4.6 (1M context) --- argocd/manifests/kingfisher/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/argocd/manifests/kingfisher/kustomization.yaml b/argocd/manifests/kingfisher/kustomization.yaml index d4c48ec..6f9db43 100644 --- a/argocd/manifests/kingfisher/kustomization.yaml +++ b/argocd/manifests/kingfisher/kustomization.yaml @@ -12,4 +12,4 @@ resources: images: - name: registry.ops.eblu.me/blumeops/kingfisher - newTag: v165768b-5cd32f8-nix + newTag: v165768b-f9206bf-nix From aa9cc709ec03097276a974e7004667a1541930ec Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 30 Mar 2026 06:45:39 -0700 Subject: [PATCH 168/430] Fix Kingfisher container: add bash and coreutils for CronJob shell Nix containers don't include a shell by default. The CronJob needs /bin/bash for the inline script that generates timestamped filenames. Co-Authored-By: Claude Opus 4.6 (1M context) --- argocd/manifests/kingfisher/cronjob.yaml | 2 +- containers/kingfisher/default.nix | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/argocd/manifests/kingfisher/cronjob.yaml b/argocd/manifests/kingfisher/cronjob.yaml index 5720810..610d862 100644 --- a/argocd/manifests/kingfisher/cronjob.yaml +++ b/argocd/manifests/kingfisher/cronjob.yaml @@ -18,7 +18,7 @@ spec: containers: - name: kingfisher image: registry.ops.eblu.me/blumeops/kingfisher:kustomized - command: ["/bin/sh", "-c"] + command: ["/bin/bash", "-c"] args: - | set -e diff --git a/containers/kingfisher/default.nix b/containers/kingfisher/default.nix index c937955..c9e95b4 100644 --- a/containers/kingfisher/default.nix +++ b/containers/kingfisher/default.nix @@ -98,6 +98,8 @@ pkgs.dockerTools.buildLayeredImage { name = "blumeops/kingfisher"; contents = [ kingfisher + pkgs.bashInteractive + pkgs.coreutils pkgs.cacert pkgs.git pkgs.tzdata From b01afb1c1d08be22df28751c0e0e620289d030f1 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 30 Mar 2026 06:47:26 -0700 Subject: [PATCH 169/430] Deploy Kingfisher v165768b-aa9cc70-nix (bash fix) Co-Authored-By: Claude Opus 4.6 (1M context) --- argocd/manifests/kingfisher/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/argocd/manifests/kingfisher/kustomization.yaml b/argocd/manifests/kingfisher/kustomization.yaml index 6f9db43..de6002a 100644 --- a/argocd/manifests/kingfisher/kustomization.yaml +++ b/argocd/manifests/kingfisher/kustomization.yaml @@ -12,4 +12,4 @@ resources: images: - name: registry.ops.eblu.me/blumeops/kingfisher - newTag: v165768b-f9206bf-nix + newTag: v165768b-aa9cc70-nix From c494b627133b3ccf725b34ddb796975f9e9ac12e Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 30 Mar 2026 06:49:59 -0700 Subject: [PATCH 170/430] Fix Kingfisher container: add /tmp directory Kingfisher needs a writable temp directory for git clones and scanning. Nix containers don't create /tmp by default. Co-Authored-By: Claude Opus 4.6 (1M context) --- containers/kingfisher/default.nix | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/containers/kingfisher/default.nix b/containers/kingfisher/default.nix index c9e95b4..93c8384 100644 --- a/containers/kingfisher/default.nix +++ b/containers/kingfisher/default.nix @@ -105,11 +105,16 @@ pkgs.dockerTools.buildLayeredImage { pkgs.tzdata ]; + extraCommands = '' + mkdir -p tmp + ''; + config = { Entrypoint = [ "${kingfisher}/bin/kingfisher" ]; Env = [ "SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" "TZDIR=${pkgs.tzdata}/share/zoneinfo" + "TMPDIR=/tmp" ]; User = "65534"; }; From 14f366f993b9d61a03dd46ea968a0870ed1a2f52 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 30 Mar 2026 06:51:17 -0700 Subject: [PATCH 171/430] Deploy Kingfisher v165768b-c494b62-nix (/tmp fix) Co-Authored-By: Claude Opus 4.6 (1M context) --- argocd/manifests/kingfisher/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/argocd/manifests/kingfisher/kustomization.yaml b/argocd/manifests/kingfisher/kustomization.yaml index de6002a..1cb330c 100644 --- a/argocd/manifests/kingfisher/kustomization.yaml +++ b/argocd/manifests/kingfisher/kustomization.yaml @@ -12,4 +12,4 @@ resources: images: - name: registry.ops.eblu.me/blumeops/kingfisher - newTag: v165768b-aa9cc70-nix + newTag: v165768b-c494b62-nix From 0fe0eed35a39371f4ae1005f508caccf36b126fd Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 30 Mar 2026 06:53:34 -0700 Subject: [PATCH 172/430] Fix Kingfisher container: make /tmp world-writable Container runs as user 65534 (nobody) but /tmp was owned by root. Set sticky bit + world-writable (1777) like a standard /tmp. Co-Authored-By: Claude Opus 4.6 (1M context) --- containers/kingfisher/default.nix | 1 + 1 file changed, 1 insertion(+) diff --git a/containers/kingfisher/default.nix b/containers/kingfisher/default.nix index 93c8384..8618b88 100644 --- a/containers/kingfisher/default.nix +++ b/containers/kingfisher/default.nix @@ -107,6 +107,7 @@ pkgs.dockerTools.buildLayeredImage { extraCommands = '' mkdir -p tmp + chmod 1777 tmp ''; config = { From 2c1f0abefc6aeaaed270d3890957b33a8edf7ca8 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 30 Mar 2026 06:54:57 -0700 Subject: [PATCH 173/430] Deploy Kingfisher v165768b-0fe0eed-nix (tmp permissions fix) Co-Authored-By: Claude Opus 4.6 (1M context) --- argocd/manifests/kingfisher/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/argocd/manifests/kingfisher/kustomization.yaml b/argocd/manifests/kingfisher/kustomization.yaml index 1cb330c..d501bbb 100644 --- a/argocd/manifests/kingfisher/kustomization.yaml +++ b/argocd/manifests/kingfisher/kustomization.yaml @@ -12,4 +12,4 @@ resources: images: - name: registry.ops.eblu.me/blumeops/kingfisher - newTag: v165768b-c494b62-nix + newTag: v165768b-0fe0eed-nix From 457ab194164c7d7ca14caeeeb9fa91f4564e3988 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 30 Mar 2026 07:11:52 -0700 Subject: [PATCH 174/430] Scope Kingfisher scan to eblume user only on ringtail Mirror repos cause scan failures (likely ephemeral storage or timeout). Scan only eblume/ repos until we investigate the root cause. Co-Authored-By: Claude Opus 4.6 (1M context) --- argocd/manifests/kingfisher/cronjob.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/argocd/manifests/kingfisher/cronjob.yaml b/argocd/manifests/kingfisher/cronjob.yaml index 610d862..9874eae 100644 --- a/argocd/manifests/kingfisher/cronjob.yaml +++ b/argocd/manifests/kingfisher/cronjob.yaml @@ -30,7 +30,6 @@ spec: --api-url https://forge.ops.eblu.me/api/v1/ \ --clone-url-base https://forge.ops.eblu.me/ \ --user eblume \ - --all-organizations \ --repo-type all \ --no-update-check \ --tls-mode lax \ From b000efd6c333d192563ce0772d72e6bae6171ece Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 30 Mar 2026 07:16:02 -0700 Subject: [PATCH 175/430] Fix Kingfisher CronJob exit code handling Kingfisher exits 200 (findings) or 205 (validated findings) on success. Normalize these to 0 so the CronJob completes instead of restarting. Co-Authored-By: Claude Opus 4.6 (1M context) --- argocd/manifests/kingfisher/cronjob.yaml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/argocd/manifests/kingfisher/cronjob.yaml b/argocd/manifests/kingfisher/cronjob.yaml index 9874eae..d05fc0c 100644 --- a/argocd/manifests/kingfisher/cronjob.yaml +++ b/argocd/manifests/kingfisher/cronjob.yaml @@ -26,6 +26,9 @@ spec: OUTDIR=/reports/kingfisher mkdir -p "$OUTDIR" + # Exit codes: 0=clean, 200=findings, 205=validated findings. + # All are successful scans; only other codes are real errors. + rc=0 kingfisher scan gitea \ --api-url https://forge.ops.eblu.me/api/v1/ \ --clone-url-base https://forge.ops.eblu.me/ \ @@ -35,7 +38,13 @@ spec: --tls-mode lax \ --allow-internal-ips \ --format html \ - --output "$OUTDIR/scan-${STAMP}.html" + --output "$OUTDIR/scan-${STAMP}.html" \ + || rc=$? + + if [ "$rc" -eq 0 ] || [ "$rc" -eq 200 ] || [ "$rc" -eq 205 ]; then + exit 0 + fi + exit "$rc" env: - name: KF_GITEA_TOKEN valueFrom: From c069f889d2018bddf33de3a1f051e0fb61a550ab Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 30 Mar 2026 10:30:02 -0700 Subject: [PATCH 176/430] Harden borgmatic photos backup: restrict dirs, add keepalives + checkpoints Restrict backup to library/ and upload/ only (skip regenerable encoded-video/, thumbs/, backups/). Add SSH ServerAliveInterval to prevent broken pipe on long transfers, and checkpoint_interval so interrupted backups save progress. Co-Authored-By: Claude Opus 4.6 (1M context) --- ansible/roles/borgmatic/defaults/main.yml | 4 +++- ansible/roles/borgmatic/templates/photos.yaml.j2 | 14 +++++++++++--- .../+borgmatic-photos-hardening.infra.md | 1 + 3 files changed, 15 insertions(+), 4 deletions(-) create mode 100644 docs/changelog.d/+borgmatic-photos-hardening.infra.md diff --git a/ansible/roles/borgmatic/defaults/main.yml b/ansible/roles/borgmatic/defaults/main.yml index c7a9793..e1622e6 100644 --- a/ansible/roles/borgmatic/defaults/main.yml +++ b/ansible/roles/borgmatic/defaults/main.yml @@ -61,7 +61,9 @@ borgmatic_keep_yearly: 1000 # pg_dump_command must be full path since LaunchAgent doesn't have homebrew in PATH # --- Immich photo library backup (BorgBase offsite only) --- borgmatic_photos_config: /Users/erichblume/.config/borgmatic/photos.yaml -borgmatic_photos_source_dir: /Volumes/photos +borgmatic_photos_source_directories: + - /Volumes/photos/library + - /Volumes/photos/upload borgmatic_photos_borgbase_repo: ssh://xcrtl5tg@xcrtl5tg.repo.borgbase.com/./repo # Schedule: runs daily at 4:00 AM (offset from main backup at 2:00 AM) borgmatic_photos_schedule_hour: 4 diff --git a/ansible/roles/borgmatic/templates/photos.yaml.j2 b/ansible/roles/borgmatic/templates/photos.yaml.j2 index 1c118df..2bd0a4f 100644 --- a/ansible/roles/borgmatic/templates/photos.yaml.j2 +++ b/ansible/roles/borgmatic/templates/photos.yaml.j2 @@ -1,7 +1,10 @@ # {{ ansible_managed }} # # Borgmatic config for immich photo library backup. -# Backs up /Volumes/photos (sifaka SMB mount) to BorgBase offsite ONLY. +# Backs up library/ and upload/ from /Volumes/photos (sifaka SMB mount) +# to BorgBase offsite ONLY. Excludes encoded-video/, thumbs/, backups/ +# since those are regenerable from originals. +# # Separate from the main borgmatic config to keep concerns isolated: # - main config: indri data → sifaka + borgbase # - this config: sifaka photos → borgbase (different repo) @@ -9,7 +12,9 @@ local_path: {{ borgmatic_local_path }} source_directories: - - {{ borgmatic_photos_source_dir }} +{% for dir in borgmatic_photos_source_directories %} + - {{ dir }} +{% endfor %} source_directories_must_exist: true @@ -21,7 +26,10 @@ repositories: encryption_passcommand: {{ borgmatic_encryption_passcommand }} -ssh_command: ssh -o IdentitiesOnly=yes -i {{ borgmatic_borgbase_ssh_key_path }} +ssh_command: ssh -o IdentitiesOnly=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=5 -i {{ borgmatic_borgbase_ssh_key_path }} + +# Save checkpoints every 10 minutes so interrupted backups don't lose all progress +checkpoint_interval: 600 # Retention policy — photos are precious, keep more history keep_daily: {{ borgmatic_photos_keep_daily }} diff --git a/docs/changelog.d/+borgmatic-photos-hardening.infra.md b/docs/changelog.d/+borgmatic-photos-hardening.infra.md new file mode 100644 index 0000000..c68580a --- /dev/null +++ b/docs/changelog.d/+borgmatic-photos-hardening.infra.md @@ -0,0 +1 @@ +Borgmatic photos backup: restrict to library/ and upload/ (skip regenerable dirs), add SSH keepalives and checkpoint interval to prevent broken pipe failures on large initial syncs. From 77eebe507e4b9ba4041ccc0f06ecd4bfd4e21a97 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 30 Mar 2026 16:10:24 -0700 Subject: [PATCH 177/430] Review Ansible reference doc: add missing roles, clarify IaC positioning Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/changelog.d/+ansible-doc-review.doc.md | 1 + docs/reference/tools/ansible.md | 21 ++++++++++++++++++--- 2 files changed, 19 insertions(+), 3 deletions(-) create mode 100644 docs/changelog.d/+ansible-doc-review.doc.md diff --git a/docs/changelog.d/+ansible-doc-review.doc.md b/docs/changelog.d/+ansible-doc-review.doc.md new file mode 100644 index 0000000..976517a --- /dev/null +++ b/docs/changelog.d/+ansible-doc-review.doc.md @@ -0,0 +1 @@ +Review and update Ansible reference doc: add missing roles, sibling playbooks, and clarify Ansible's role in the IaC stack. diff --git a/docs/reference/tools/ansible.md b/docs/reference/tools/ansible.md index 2f21eb2..7c0ebc9 100644 --- a/docs/reference/tools/ansible.md +++ b/docs/reference/tools/ansible.md @@ -1,6 +1,7 @@ --- title: Ansible -modified: 2026-02-12 +modified: 2026-03-30 +last-reviewed: 2026-03-30 tags: - ansible - reference @@ -8,7 +9,7 @@ tags: # Ansible -Configuration management for native services on [[indri]]. The primary playbook is `ansible/playbooks/indri.yml`. +Host-level configuration management — the layer between cloud infrastructure ([[pulumi]]) and containerized workloads ([[argocd]]). The primary playbook is `ansible/playbooks/indri.yml` (targets [[indri]]); separate playbooks exist for [[ringtail]] and [[sifaka]]. ## CLI Patterns @@ -23,6 +24,16 @@ mise run provision-indri -- --tags caddy mise run provision-indri -- --check --diff ``` +Other hosts have their own playbooks: + +```bash +# Ringtail (NixOS, k3s) +mise run provision-ringtail + +# Sifaka (Synology NAS exporters) +mise run provision-sifaka +``` + ## Available Roles | Role | Purpose | Service | @@ -32,6 +43,8 @@ mise run provision-indri -- --check --diff | **borgmatic_metrics** | Backup metrics exporter | [[borgmatic]] | | **caddy** | Reverse proxy & TLS | [[routing]] | | **forgejo** | Git forge | [[forgejo]] | +| **forgejo_actions_secrets** | CI/CD secrets for Forgejo Actions | [[forgejo]] | +| **forgejo_metrics** | Forge metrics exporter | [[forgejo]] | | **jellyfin** | Media server | [[jellyfin]] | | **jellyfin_metrics** | Media metrics exporter | [[jellyfin]] | | **minikube** | Kubernetes cluster | [[cluster]] | @@ -57,5 +70,7 @@ Roles that need secrets use 1Password via the playbook's `pre_tasks`. Secrets ar ## Related -- [[indri]] — Target host +- [[indri]] — Primary managed host +- [[ringtail]] — NixOS host managed by its own playbook +- [[sifaka]] — Synology NAS managed by its own playbook - [[observability]] — Metrics collection From 0ec024684728d79752e135a3dd6c979c02a658a5 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 30 Mar 2026 16:12:48 -0700 Subject: [PATCH 178/430] Remove doc-reviewer agent --- .claude/agents/doc-reviewer.md | 63 ---------------------------------- 1 file changed, 63 deletions(-) delete mode 100644 .claude/agents/doc-reviewer.md diff --git a/.claude/agents/doc-reviewer.md b/.claude/agents/doc-reviewer.md deleted file mode 100644 index 5ab941d..0000000 --- a/.claude/agents/doc-reviewer.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -name: doc-reviewer -description: Documentation reviewer with persistent memory. Use when the user wants to review a doc, run a docs review cycle, or asks about documentation staleness. Reviews docs for accuracy, links, and structure. -tools: Read, Glob, Grep, Bash -model: sonnet -memory: project ---- - -You are a documentation reviewer for the BlumeOps homelab infrastructure project. - -## Workflow - -1. Run `mise run docs-review` to see the staleness table and identify the most stale doc -2. **Review exactly ONE document** — the single most stale doc from the table. Do not review multiple docs in one cycle. The main conversation will invoke you again if more reviews are needed. -3. Read the identified doc thoroughly -4. Perform the review checklist (below) -5. Check your agent memory for notes from past reviews of this doc or related docs -6. Present your findings as a structured report -7. Update your agent memory with anything you learned - -## Review Checklist - -For each doc, evaluate: - -- **Accuracy:** Is the information still correct? Cross-reference with actual source files (manifests, playbooks, configs) when possible -- **Wiki-links:** Do all `[[wiki-links]]` point to existing docs? Run `mise run docs-check-links` if unsure -- **Cross-references:** Should this doc link to other related docs that it doesn't currently reference? -- **Structure:** Is the doc in the right Diataxis category (reference/how-to/explanation/tutorial)? -- **Frontmatter:** Are tags, title, and dates correct? -- **Size:** Is the doc too large (should split) or too small (should merge)? -- **Staleness signals:** Are there version numbers, URLs, or process descriptions that may have drifted - -## Output Format - -Present findings as: -1. **One-line verdict:** healthy / needs minor updates / needs significant revision -2. **Issues found** (if any), grouped by severity -3. **Suggested changes** — be specific about what to change and where -4. **Proposed frontmatter update** — the `last-reviewed: YYYY-MM-DD` line to add - -## Memory Guidelines - -After each review, save notes about: -- Recurring issues you've seen across docs (e.g., "many docs still reference old routing pattern") -- Docs that reference each other and should be reviewed together -- Services or areas where documentation tends to drift fastest - -Before each review, check your memory for relevant context. - -## Important - -- Do NOT edit files directly. Present your findings so the main conversation can implement changes. -- Wiki-link format: `[[card-stem]]` — prefer simple links without alternate text unless grammatically needed. -- The docs directory is at `docs/` with Diataxis structure (reference/, how-to/, explanation/, tutorials/). - -## Handoff to Main Conversation - -Your output goes back to the main conversation, which will: -1. Present your findings to the user -2. Offer to implement the suggested changes -3. Run `mise run docs-preview` for visual verification before committing - -So make your suggested changes **specific and actionable** — include exact text replacements, frontmatter updates, and wiki-links to add/fix. The main conversation needs enough detail to implement without re-reading the entire doc. From 1e391f96bbc4a814d6e55e247ac2c634a69009da Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 30 Mar 2026 16:31:06 -0700 Subject: [PATCH 179/430] =?UTF-8?q?Upgrade=20forgejo-runner=2012.7.0=20?= =?UTF-8?q?=E2=86=92=2012.7.3,=20add=20service=20card?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Patch upgrade picks up idempotent FetchTask API, offline registration fix, cloudflare/circl security dep update, and custom gRPC user-agent. No config defaults changed. Co-Authored-By: Claude Opus 4.6 (1M context) --- argocd/manifests/forgejo-runner/config.yaml | 2 +- .../forgejo-runner/kustomization.yaml | 2 +- .../+forgejo-runner-12.7.3.infra.md | 1 + docs/reference/services/forgejo-runner.md | 56 +++++++++++++++++++ docs/reference/services/forgejo.md | 1 + service-versions.yaml | 4 +- 6 files changed, 62 insertions(+), 4 deletions(-) create mode 100644 docs/changelog.d/+forgejo-runner-12.7.3.infra.md create mode 100644 docs/reference/services/forgejo-runner.md diff --git a/argocd/manifests/forgejo-runner/config.yaml b/argocd/manifests/forgejo-runner/config.yaml index c92d616..4894825 100644 --- a/argocd/manifests/forgejo-runner/config.yaml +++ b/argocd/manifests/forgejo-runner/config.yaml @@ -1,4 +1,4 @@ -# Reviewed against v12.7.0 defaults (2026-02-22) +# Reviewed against v12.7.3 defaults (2026-03-30) log: level: info diff --git a/argocd/manifests/forgejo-runner/kustomization.yaml b/argocd/manifests/forgejo-runner/kustomization.yaml index 67527de..2c845ee 100644 --- a/argocd/manifests/forgejo-runner/kustomization.yaml +++ b/argocd/manifests/forgejo-runner/kustomization.yaml @@ -10,7 +10,7 @@ resources: images: - name: code.forgejo.org/forgejo/runner - newTag: "12.7.0" + newTag: "12.7.3" - name: docker newTag: 27-dind diff --git a/docs/changelog.d/+forgejo-runner-12.7.3.infra.md b/docs/changelog.d/+forgejo-runner-12.7.3.infra.md new file mode 100644 index 0000000..379ca3e --- /dev/null +++ b/docs/changelog.d/+forgejo-runner-12.7.3.infra.md @@ -0,0 +1 @@ +Upgrade forgejo-runner from 12.7.0 to 12.7.3 (bug fixes, security dep update). Add service reference card. diff --git a/docs/reference/services/forgejo-runner.md b/docs/reference/services/forgejo-runner.md new file mode 100644 index 0000000..d61f378 --- /dev/null +++ b/docs/reference/services/forgejo-runner.md @@ -0,0 +1,56 @@ +--- +title: Forgejo Runner +modified: 2026-03-30 +last-reviewed: 2026-03-30 +tags: + - service + - ci-cd +--- + +# Forgejo Runner + +Forgejo Actions runner daemon for CI/CD job execution. Runs as a Kubernetes pod on [[indri]] (minikube) with a Docker-in-Docker sidecar. + +## Quick Reference + +| Property | Value | +|----------|-------| +| **Namespace** | `forgejo-runner` | +| **ArgoCD App** | `forgejo-runner` | +| **Runner Name** | `k8s-runner` | +| **Labels** | `k8s` | +| **Capacity** | 2 concurrent jobs | +| **Timeout** | 3h | +| **Forgejo Instance** | https://forge.ops.eblu.me | +| **Image** | `code.forgejo.org/forgejo/runner` (see `argocd/manifests/forgejo-runner/kustomization.yaml` for current tag) | +| **DinD Sidecar** | `docker:27-dind` | + +## Architecture + +The pod runs two containers: + +1. **runner** - The Forgejo runner daemon. Registers with the forge on first start, then polls for jobs. Talks to DinD via `tcp://localhost:2375`. +2. **dind** - Docker-in-Docker sidecar (privileged). Provides the Docker daemon for job container execution. Uses a registry mirror at `host.minikube.internal:5050` ([[zot]]). + +Runner state (`/data/.runner`) is stored in an `emptyDir` volume, so re-registration happens on pod restart. The registration token comes from 1Password via [[external-secrets]]. + +## Job Execution Image + +The actual container image used to run workflow steps is set via `RUNNER_LABELS` in the deployment, not in the runner config. This image is tracked separately as `runner-job-image` in `service-versions.yaml`. See [[build-container-image]] for how it's built. + +## Network + +Jobs run with `network: "host"` to share the DinD network namespace. This gives job containers access to the same DNS and network as the pod, including cluster-internal services. + +## Credentials + +| Secret | Source | Purpose | +|--------|--------|---------| +| `RUNNER_TOKEN` | 1Password ("Forgejo Secrets" → `runner_reg`) | Runner registration with forge | + +## Related + +- [[forgejo]] - The forge this runner connects to +- [[argocd]] - Deployment mechanism +- [[zot]] - Registry mirror for job image pulls +- [[build-container-image]] - How container images are built via this runner diff --git a/docs/reference/services/forgejo.md b/docs/reference/services/forgejo.md index fbbc506..635f479 100644 --- a/docs/reference/services/forgejo.md +++ b/docs/reference/services/forgejo.md @@ -161,6 +161,7 @@ Forgejo hosts pull mirrors of external repositories (GitHub, etc.) for supply ch ## Related +- [[forgejo-runner]] - k8s CI/CD runner (minikube on indri) - [[argocd]] - Uses Forgejo as git source - [[authentik]] - OIDC identity provider - [[zot]] - Container registry for built images diff --git a/service-versions.yaml b/service-versions.yaml index 6e67b24..adb0974 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -234,8 +234,8 @@ services: - name: forgejo-runner type: argocd - last-reviewed: 2026-02-22 - current-version: "12.7.0" + last-reviewed: 2026-03-30 + current-version: "12.7.3" upstream-source: https://code.forgejo.org/forgejo/runner/releases notes: >- Runner daemon version (code.forgejo.org/forgejo/runner). Job execution From a76e471d5497c1083ff51a081ca30ec8fd0d9903 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 30 Mar 2026 17:22:31 -0700 Subject: [PATCH 180/430] Add Prowler mutelist and fix kube-state-metrics seccomp (#319) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Add mutelist files to suppress expected/accepted Prowler CIS findings from components we don't control - Mutelist files stored in `mutelist/` directory, grouped by category, merged at runtime via initContainer - Fix missing seccomp `RuntimeDefault` profile on kube-state-metrics deployment ### Mutelist categories | File | Checks | Covers | |------|--------|--------| | `apiserver.yaml` | 12 | Minikube apiserver flags | | `control-plane.yaml` | 3 | Scheduler, controller-manager, kubelet | | `core-pod-security.yaml` | 7 | System pods, Tailscale operator, Grafana init, Prowler hostPID, forgejo-runner | | `rbac.yaml` | 3 | Built-in K8s roles, ArgoCD, CNPG | Muted findings appear as `status=MUTED` in reports (not hidden), preserving audit trail. ### Not muted (follow-up) - Alloy, Immich pods missing seccomp — need separate investigation (Helm/operator-managed) ## Test plan - [ ] `kubectl kustomize argocd/manifests/prowler/` renders cleanly - [ ] Trigger manual scan: `kubectl --context=minikube-indri -n prowler create job prowler-mutelist-test --from=cronjob/prowler` - [ ] Verify initContainer merges successfully (check pod logs) - [ ] Verify muted findings show as `MUTED` in report - [ ] Sync kube-state-metrics and verify pod starts with seccomp profile 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/319 --- .../kube-state-metrics/deployment.yaml | 2 + argocd/manifests/prowler/cronjob.yaml | 32 +++++++ argocd/manifests/prowler/kustomization.yaml | 10 +++ .../manifests/prowler/mutelist/apiserver.yaml | 54 ++++++++++++ .../prowler/mutelist/control-plane.yaml | 18 ++++ .../prowler/mutelist/core-pod-security.yaml | 86 +++++++++++++++++++ argocd/manifests/prowler/mutelist/rbac.yaml | 37 ++++++++ docs/changelog.d/prowler-mutelist.infra.md | 1 + 8 files changed, 240 insertions(+) create mode 100644 argocd/manifests/prowler/mutelist/apiserver.yaml create mode 100644 argocd/manifests/prowler/mutelist/control-plane.yaml create mode 100644 argocd/manifests/prowler/mutelist/core-pod-security.yaml create mode 100644 argocd/manifests/prowler/mutelist/rbac.yaml create mode 100644 docs/changelog.d/prowler-mutelist.infra.md diff --git a/argocd/manifests/kube-state-metrics/deployment.yaml b/argocd/manifests/kube-state-metrics/deployment.yaml index ae34339..ddaf3e2 100644 --- a/argocd/manifests/kube-state-metrics/deployment.yaml +++ b/argocd/manifests/kube-state-metrics/deployment.yaml @@ -51,3 +51,5 @@ spec: capabilities: drop: - ALL + seccompProfile: + type: RuntimeDefault diff --git a/argocd/manifests/prowler/cronjob.yaml b/argocd/manifests/prowler/cronjob.yaml index 545a9c8..5b2199b 100644 --- a/argocd/manifests/prowler/cronjob.yaml +++ b/argocd/manifests/prowler/cronjob.yaml @@ -15,6 +15,28 @@ spec: securityContext: seccompProfile: type: RuntimeDefault + initContainers: + - name: merge-mutelist + image: registry.ops.eblu.me/blumeops/prowler:kustomized + command: ["python3", "-c"] + args: + - | + import yaml, glob, pathlib + merged = {"Mutelist": {"Accounts": {"*": {"Checks": {}}}}} + for f in sorted(glob.glob("/mutelist-parts/*.yaml")): + with open(f) as fh: + data = yaml.safe_load(fh) + checks = data.get("Mutelist", {}).get("Accounts", {}).get("*", {}).get("Checks", {}) + merged["Mutelist"]["Accounts"]["*"]["Checks"].update(checks) + pathlib.Path("/tmp/mutelist").mkdir(exist_ok=True) + with open("/tmp/mutelist/mutelist.yaml", "w") as fh: + yaml.dump(merged, fh, default_flow_style=False) + print(f"Merged {len(merged['Mutelist']['Accounts']['*']['Checks'])} checks from {len(glob.glob('/mutelist-parts/*.yaml'))} files") + volumeMounts: + - name: mutelist-parts + mountPath: /mutelist-parts + - name: mutelist-merged + mountPath: /tmp/mutelist containers: - name: prowler image: registry.ops.eblu.me/blumeops/prowler:kustomized @@ -22,6 +44,8 @@ spec: - kubernetes - --compliance - cis_1.11_kubernetes + - --mutelist-file + - /tmp/mutelist/mutelist.yaml - -z - --output-formats - html @@ -32,6 +56,9 @@ spec: volumeMounts: - name: reports mountPath: /reports + - name: mutelist-merged + mountPath: /tmp/mutelist + readOnly: true - name: var-lib-kubelet mountPath: /var/lib/kubelet readOnly: true @@ -47,6 +74,11 @@ spec: - name: reports persistentVolumeClaim: claimName: prowler-reports + - name: mutelist-parts + configMap: + name: prowler-mutelist + - name: mutelist-merged + emptyDir: {} - name: var-lib-kubelet hostPath: path: /var/lib/kubelet diff --git a/argocd/manifests/prowler/kustomization.yaml b/argocd/manifests/prowler/kustomization.yaml index b34b2c1..162a2ad 100644 --- a/argocd/manifests/prowler/kustomization.yaml +++ b/argocd/manifests/prowler/kustomization.yaml @@ -13,6 +13,16 @@ resources: - cronjob-image-scan.yaml - cronjob-iac-scan.yaml +configMapGenerator: + - name: prowler-mutelist + options: + disableNameSuffixHash: true + files: + - mutelist/apiserver.yaml + - mutelist/control-plane.yaml + - mutelist/core-pod-security.yaml + - mutelist/rbac.yaml + images: - name: registry.ops.eblu.me/blumeops/prowler newTag: v5.22.0-6960243 diff --git a/argocd/manifests/prowler/mutelist/apiserver.yaml b/argocd/manifests/prowler/mutelist/apiserver.yaml new file mode 100644 index 0000000..a48c249 --- /dev/null +++ b/argocd/manifests/prowler/mutelist/apiserver.yaml @@ -0,0 +1,54 @@ +# Minikube apiserver — flags managed by static pod manifests. +# Compensating control: cluster not internet-exposed; access via Tailscale ACLs. +Mutelist: + Accounts: + "*": + Checks: + "apiserver_always_pull_images_plugin": + Regions: ["*"] + Resources: ["^kube-apiserver-minikube$"] + Description: "Minikube default; AlwaysPullImages not enabled." + "apiserver_audit_log_maxage_set": + Regions: ["*"] + Resources: ["^kube-apiserver-minikube$"] + Description: "Minikube does not configure audit logging." + "apiserver_audit_log_maxbackup_set": + Regions: ["*"] + Resources: ["^kube-apiserver-minikube$"] + Description: "Minikube does not configure audit logging." + "apiserver_audit_log_maxsize_set": + Regions: ["*"] + Resources: ["^kube-apiserver-minikube$"] + Description: "Minikube does not configure audit logging." + "apiserver_audit_log_path_set": + Regions: ["*"] + Resources: ["^kube-apiserver-minikube$"] + Description: "Minikube does not configure audit logging." + "apiserver_deny_service_external_ips": + Regions: ["*"] + Resources: ["^kube-apiserver-minikube$"] + Description: "Minikube default; no external IPs in use." + "apiserver_disable_profiling": + Regions: ["*"] + Resources: ["^kube-apiserver-minikube$"] + Description: "Minikube default; profiling endpoint not exposed." + "apiserver_encryption_provider_config_set": + Regions: ["*"] + Resources: ["^kube-apiserver-minikube$"] + Description: "Minikube does not configure etcd encryption at rest." + "apiserver_kubelet_cert_auth": + Regions: ["*"] + Resources: ["^kube-apiserver-minikube$"] + Description: "Minikube manages kubelet certificates automatically." + "apiserver_request_timeout_set": + Regions: ["*"] + Resources: ["^kube-apiserver-minikube$"] + Description: "Minikube default; using K8s default timeout." + "apiserver_service_account_lookup_true": + Regions: ["*"] + Resources: ["^kube-apiserver-minikube$"] + Description: "Minikube default." + "apiserver_strong_ciphers_only": + Regions: ["*"] + Resources: ["^kube-apiserver-minikube$"] + Description: "Minikube default TLS cipher suite." diff --git a/argocd/manifests/prowler/mutelist/control-plane.yaml b/argocd/manifests/prowler/mutelist/control-plane.yaml new file mode 100644 index 0000000..95e01bc --- /dev/null +++ b/argocd/manifests/prowler/mutelist/control-plane.yaml @@ -0,0 +1,18 @@ +# Minikube control-plane components — managed by static pod manifests. +# Compensating control: cluster not internet-exposed; access via Tailscale ACLs. +Mutelist: + Accounts: + "*": + Checks: + "controllermanager_disable_profiling": + Regions: ["*"] + Resources: ["^kube-controller-manager-minikube$"] + Description: "Minikube default; profiling endpoint not exposed outside tailnet." + "scheduler_profiling": + Regions: ["*"] + Resources: ["^kube-scheduler-minikube$"] + Description: "Minikube default; profiling endpoint not exposed outside tailnet." + "kubelet_tls_cert_and_key": + Regions: ["*"] + Resources: ["^kubelet-config$"] + Description: "Minikube uses auto-generated kubelet certificates." diff --git a/argocd/manifests/prowler/mutelist/core-pod-security.yaml b/argocd/manifests/prowler/mutelist/core-pod-security.yaml new file mode 100644 index 0000000..2c2169b --- /dev/null +++ b/argocd/manifests/prowler/mutelist/core-pod-security.yaml @@ -0,0 +1,86 @@ +# Pod security checks — system pods, operator-managed pods, and accepted +# operational needs. Each check ID appears once with all matching resources. +Mutelist: + Accounts: + "*": + Checks: + "core_minimize_hostNetwork_containers": + Regions: ["*"] + Resources: + # Minikube control plane — requires hostNetwork by design + - "^etcd-minikube$" + - "^kube-apiserver-minikube$" + - "^kube-controller-manager-minikube$" + - "^kube-scheduler-minikube$" + # Minikube system pods + - "^kube-proxy-" + - "^kindnet-" + - "^storage-provisioner$" + Description: >- + Control-plane and networking pods require hostNetwork. + All managed by minikube. + "core_minimize_privileged_containers": + Regions: ["*"] + Resources: + # Minikube system + - "^kube-proxy-" + # Tailscale operator-managed proxies + - "^ts-" + - "^ingress-" + # Forgejo runner — Docker-in-Docker for CI builds + - "^forgejo-runner-" + Description: >- + kube-proxy: iptables (minikube). ts-*/ingress-*: network + namespace manipulation (Tailscale operator). forgejo-runner: + Docker-in-Docker for CI. + "core_seccomp_profile_docker_default": + Regions: ["*"] + Resources: + # Minikube system pods + - "^coredns-" + - "^kube-proxy-" + - "^kindnet-" + - "^storage-provisioner$" + # Tailscale operator-managed pods + - "^ts-" + - "^operator-" + - "^nameserver-" + - "^ingress-" + Description: >- + System pods (minikube) and Tailscale operator pods — seccomp + profiles set by upstream/operator, not user manifests. + "core_minimize_hostPID_containers": + Regions: ["*"] + Resources: + - "^prowler-" + Description: >- + Prowler CIS scanner requires hostPID to check file + permissions on kubelet and etcd data directories. + "core_minimize_root_containers_admission": + Regions: ["*"] + Resources: + - "^grafana-" + Description: >- + Grafana init-chown-data runs as root to fix PVC ownership. + Main containers run as UID 472. Standard pattern. + "core_minimize_containers_added_capabilities": + Regions: ["*"] + Resources: + # Minikube system pods + - "^coredns-" + - "^kindnet-" + # Grafana init-chown-data (CHOWN capability) + - "^grafana-" + Description: >- + System pods: NET_BIND_SERVICE/NET_RAW required by function + (minikube). Grafana: CHOWN for PVC init; all other + containers drop ALL. + "core_minimize_containers_capabilities_assigned": + Regions: ["*"] + Resources: + - "^coredns-" + - "^kindnet-" + - "^grafana-" + Description: >- + System pods (minikube) and Grafana init-chown-data. + See core_minimize_containers_added_capabilities. diff --git a/argocd/manifests/prowler/mutelist/rbac.yaml b/argocd/manifests/prowler/mutelist/rbac.yaml new file mode 100644 index 0000000..c5d0ceb --- /dev/null +++ b/argocd/manifests/prowler/mutelist/rbac.yaml @@ -0,0 +1,37 @@ +# RBAC checks — built-in Kubernetes roles and operator roles that require +# broad permissions by design. +Mutelist: + Accounts: + "*": + Checks: + "rbac_minimize_wildcard_use_roles": + Regions: ["*"] + Resources: + # Built-in Kubernetes roles + - "^cluster-admin$" + - "^system:" + # ArgoCD — requires broad access for deployment management; + # ArgoCD itself is SSO-gated via Authentik + - "^argocd-" + Description: >- + Built-in K8s roles and ArgoCD. ArgoCD access is SSO-gated + via Authentik. + "rbac_minimize_pod_creation_access": + Regions: ["*"] + Resources: + # Built-in Kubernetes roles + - "^admin$" + - "^edit$" + - "^system:" + # CloudNativePG operator + - "^cnpg-manager$" + Description: >- + Built-in K8s roles required for workload controllers. + cnpg-manager: CloudNativePG operator manages PostgreSQL pods. + "rbac_minimize_service_account_token_creation": + Regions: ["*"] + Resources: + - "^system:" + Description: >- + kube-controller-manager requires token creation for service + account management. Built-in role. diff --git a/docs/changelog.d/prowler-mutelist.infra.md b/docs/changelog.d/prowler-mutelist.infra.md new file mode 100644 index 0000000..a8bf246 --- /dev/null +++ b/docs/changelog.d/prowler-mutelist.infra.md @@ -0,0 +1 @@ +Add Prowler mutelist to suppress expected findings from system components, operator-managed pods, and accepted operational needs. Fix missing seccomp profile on kube-state-metrics. From 4059b3d27be508c32f97d5610656d3d7809a13dc Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 30 Mar 2026 17:44:11 -0700 Subject: [PATCH 181/430] Add compensating controls framework and date-based report dirs (#320) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Add `compensating-controls.yaml` tracking 9 named controls that justify suppressed security findings - Update all Prowler mutelist descriptions with `CC: ` references to named controls - Add `mise run review-compensating-controls` task — surfaces stalest control with all codebase references - Add [[review-compensating-controls]] how-to doc - Organize Prowler and Kingfisher reports into `YYYY-MM-DD` subdirectories ### Compensating controls | ID | Mitigates | |----|-----------| | `single-user-cluster` | Image cache abuse, RBAC breadth, system pod privileges | | `tailscale-network-isolation` | Profiling endpoints, weak TLS, debug ports | | `local-registry` | AlwaysPullImages gap | | `sso-gated-admin-tools` | ArgoCD wildcard RBAC | | `operator-managed-pods` | Tailscale proxy pod security settings | | `ephemeral-privileged-jobs` | Prowler hostPID exposure | | `trusted-ci-only` | Forgejo runner DinD | | `init-container-isolation` | Grafana root init container | | `observability-stack-audit` | Missing apiserver audit logging | ## Test plan - [ ] `mise run review-compensating-controls` shows table and references - [ ] `kubectl kustomize argocd/manifests/prowler/` renders correctly - [ ] Sync prowler and kingfisher, verify next scan writes to dated subdirectory - [ ] Grep for `CC:` in mutelist files — every muted finding should have at least one 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/320 --- argocd/manifests/kingfisher/cronjob.yaml | 2 +- .../manifests/prowler/cronjob-iac-scan.yaml | 19 +- .../manifests/prowler/cronjob-image-scan.yaml | 19 +- argocd/manifests/prowler/cronjob.yaml | 22 +- .../manifests/prowler/mutelist/apiserver.yaml | 25 +- .../prowler/mutelist/control-plane.yaml | 7 +- .../prowler/mutelist/core-pod-security.yaml | 44 ++-- argocd/manifests/prowler/mutelist/rbac.yaml | 18 +- compensating-controls.yaml | 122 ++++++++++ .../compensating-controls.infra.md | 1 + .../review-compensating-controls.md | 77 ++++++ docs/reference/operations/security.md | 8 + mise-tasks/review-compensating-controls | 229 ++++++++++++++++++ 13 files changed, 516 insertions(+), 77 deletions(-) create mode 100644 compensating-controls.yaml create mode 100644 docs/changelog.d/compensating-controls.infra.md create mode 100644 docs/how-to/operations/review-compensating-controls.md create mode 100755 mise-tasks/review-compensating-controls diff --git a/argocd/manifests/kingfisher/cronjob.yaml b/argocd/manifests/kingfisher/cronjob.yaml index d05fc0c..3c47528 100644 --- a/argocd/manifests/kingfisher/cronjob.yaml +++ b/argocd/manifests/kingfisher/cronjob.yaml @@ -23,7 +23,7 @@ spec: - | set -e STAMP=$(date +%Y%m%d-%H%M%S) - OUTDIR=/reports/kingfisher + OUTDIR=/reports/kingfisher/$(date +%Y-%m-%d) mkdir -p "$OUTDIR" # Exit codes: 0=clean, 200=findings, 205=validated findings. diff --git a/argocd/manifests/prowler/cronjob-iac-scan.yaml b/argocd/manifests/prowler/cronjob-iac-scan.yaml index c2e2fac..49c8ce6 100644 --- a/argocd/manifests/prowler/cronjob-iac-scan.yaml +++ b/argocd/manifests/prowler/cronjob-iac-scan.yaml @@ -18,17 +18,16 @@ spec: containers: - name: prowler image: registry.ops.eblu.me/blumeops/prowler:kustomized + command: ["/bin/sh", "-c"] args: - - iac - - --scan-repository-url - - https://forge.ops.eblu.me/eblume/blumeops.git - - -z - - --output-formats - - html - - csv - - json-ocsf - - --output-directory - - /reports/prowler-iac + - | + 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 diff --git a/argocd/manifests/prowler/cronjob-image-scan.yaml b/argocd/manifests/prowler/cronjob-image-scan.yaml index b69ad63..5d8ea7e 100644 --- a/argocd/manifests/prowler/cronjob-image-scan.yaml +++ b/argocd/manifests/prowler/cronjob-image-scan.yaml @@ -48,17 +48,16 @@ spec: containers: - name: prowler image: registry.ops.eblu.me/blumeops/prowler:kustomized + command: ["/bin/sh", "-c"] args: - - image - - --image-list - - /shared/images.txt - - -z - - --output-formats - - html - - csv - - json-ocsf - - --output-directory - - /reports/prowler-images + - | + DATEDIR=/reports/prowler-images/$(date +%Y-%m-%d) + mkdir -p "$DATEDIR" + prowler image \ + --image-list /shared/images.txt \ + -z \ + --output-formats html csv json-ocsf \ + --output-directory "$DATEDIR" volumeMounts: - name: reports mountPath: /reports diff --git a/argocd/manifests/prowler/cronjob.yaml b/argocd/manifests/prowler/cronjob.yaml index 5b2199b..95b7dee 100644 --- a/argocd/manifests/prowler/cronjob.yaml +++ b/argocd/manifests/prowler/cronjob.yaml @@ -40,19 +40,17 @@ spec: containers: - name: prowler image: registry.ops.eblu.me/blumeops/prowler:kustomized + command: ["/bin/sh", "-c"] args: - - kubernetes - - --compliance - - cis_1.11_kubernetes - - --mutelist-file - - /tmp/mutelist/mutelist.yaml - - -z - - --output-formats - - html - - csv - - json-ocsf - - --output-directory - - /reports/prowler + - | + DATEDIR=/reports/prowler/$(date +%Y-%m-%d) + mkdir -p "$DATEDIR" + prowler kubernetes \ + --compliance cis_1.11_kubernetes \ + --mutelist-file /tmp/mutelist/mutelist.yaml \ + -z \ + --output-formats html csv json-ocsf \ + --output-directory "$DATEDIR" volumeMounts: - name: reports mountPath: /reports diff --git a/argocd/manifests/prowler/mutelist/apiserver.yaml b/argocd/manifests/prowler/mutelist/apiserver.yaml index a48c249..5a25d4f 100644 --- a/argocd/manifests/prowler/mutelist/apiserver.yaml +++ b/argocd/manifests/prowler/mutelist/apiserver.yaml @@ -1,5 +1,4 @@ # Minikube apiserver — flags managed by static pod manifests. -# Compensating control: cluster not internet-exposed; access via Tailscale ACLs. Mutelist: Accounts: "*": @@ -7,48 +6,48 @@ Mutelist: "apiserver_always_pull_images_plugin": Regions: ["*"] Resources: ["^kube-apiserver-minikube$"] - Description: "Minikube default; AlwaysPullImages not enabled." + Description: "CC: single-user-cluster, local-registry. Only the operator has cluster access; all images pulled from private zot registry." "apiserver_audit_log_maxage_set": Regions: ["*"] Resources: ["^kube-apiserver-minikube$"] - Description: "Minikube does not configure audit logging." + Description: "CC: observability-stack-audit. Alloy/Loki provides pod-level audit trail." "apiserver_audit_log_maxbackup_set": Regions: ["*"] Resources: ["^kube-apiserver-minikube$"] - Description: "Minikube does not configure audit logging." + Description: "CC: observability-stack-audit. Alloy/Loki provides pod-level audit trail." "apiserver_audit_log_maxsize_set": Regions: ["*"] Resources: ["^kube-apiserver-minikube$"] - Description: "Minikube does not configure audit logging." + Description: "CC: observability-stack-audit. Alloy/Loki provides pod-level audit trail." "apiserver_audit_log_path_set": Regions: ["*"] Resources: ["^kube-apiserver-minikube$"] - Description: "Minikube does not configure audit logging." + Description: "CC: observability-stack-audit. Alloy/Loki provides pod-level audit trail." "apiserver_deny_service_external_ips": Regions: ["*"] Resources: ["^kube-apiserver-minikube$"] - Description: "Minikube default; no external IPs in use." + Description: "CC: tailscale-network-isolation. No external IPs routable; cluster only reachable via tailnet." "apiserver_disable_profiling": Regions: ["*"] Resources: ["^kube-apiserver-minikube$"] - Description: "Minikube default; profiling endpoint not exposed." + Description: "CC: tailscale-network-isolation. Profiling endpoint unreachable from public internet." "apiserver_encryption_provider_config_set": Regions: ["*"] Resources: ["^kube-apiserver-minikube$"] - Description: "Minikube does not configure etcd encryption at rest." + Description: "CC: tailscale-network-isolation, single-user-cluster. Etcd not network-exposed; only operator has node access." "apiserver_kubelet_cert_auth": Regions: ["*"] Resources: ["^kube-apiserver-minikube$"] - Description: "Minikube manages kubelet certificates automatically." + Description: "CC: tailscale-network-isolation. Kubelet API not exposed outside the node; minikube auto-generates certificates." "apiserver_request_timeout_set": Regions: ["*"] Resources: ["^kube-apiserver-minikube$"] - Description: "Minikube default; using K8s default timeout." + Description: "CC: tailscale-network-isolation. API server only reachable via tailnet; DoS risk limited to trusted clients." "apiserver_service_account_lookup_true": Regions: ["*"] Resources: ["^kube-apiserver-minikube$"] - Description: "Minikube default." + Description: "CC: single-user-cluster. Only operator manages service accounts; no revoked tokens in circulation." "apiserver_strong_ciphers_only": Regions: ["*"] Resources: ["^kube-apiserver-minikube$"] - Description: "Minikube default TLS cipher suite." + Description: "CC: tailscale-network-isolation. API server traffic encrypted by WireGuard at the network layer." diff --git a/argocd/manifests/prowler/mutelist/control-plane.yaml b/argocd/manifests/prowler/mutelist/control-plane.yaml index 95e01bc..2056691 100644 --- a/argocd/manifests/prowler/mutelist/control-plane.yaml +++ b/argocd/manifests/prowler/mutelist/control-plane.yaml @@ -1,5 +1,4 @@ # Minikube control-plane components — managed by static pod manifests. -# Compensating control: cluster not internet-exposed; access via Tailscale ACLs. Mutelist: Accounts: "*": @@ -7,12 +6,12 @@ Mutelist: "controllermanager_disable_profiling": Regions: ["*"] Resources: ["^kube-controller-manager-minikube$"] - Description: "Minikube default; profiling endpoint not exposed outside tailnet." + Description: "CC: tailscale-network-isolation. Profiling endpoint unreachable from public internet." "scheduler_profiling": Regions: ["*"] Resources: ["^kube-scheduler-minikube$"] - Description: "Minikube default; profiling endpoint not exposed outside tailnet." + Description: "CC: tailscale-network-isolation. Profiling endpoint unreachable from public internet." "kubelet_tls_cert_and_key": Regions: ["*"] Resources: ["^kubelet-config$"] - Description: "Minikube uses auto-generated kubelet certificates." + Description: "CC: tailscale-network-isolation, single-user-cluster. Kubelet API not exposed outside node; minikube auto-generates certificates." diff --git a/argocd/manifests/prowler/mutelist/core-pod-security.yaml b/argocd/manifests/prowler/mutelist/core-pod-security.yaml index 2c2169b..c39e0c6 100644 --- a/argocd/manifests/prowler/mutelist/core-pod-security.yaml +++ b/argocd/manifests/prowler/mutelist/core-pod-security.yaml @@ -7,7 +7,7 @@ Mutelist: "core_minimize_hostNetwork_containers": Regions: ["*"] Resources: - # Minikube control plane — requires hostNetwork by design + # Minikube control plane - "^etcd-minikube$" - "^kube-apiserver-minikube$" - "^kube-controller-manager-minikube$" @@ -17,8 +17,9 @@ Mutelist: - "^kindnet-" - "^storage-provisioner$" Description: >- - Control-plane and networking pods require hostNetwork. - All managed by minikube. + CC: tailscale-network-isolation. Control-plane and networking + pods require hostNetwork by design. Host network itself is + only reachable via tailnet. "core_minimize_privileged_containers": Regions: ["*"] Resources: @@ -27,12 +28,13 @@ Mutelist: # Tailscale operator-managed proxies - "^ts-" - "^ingress-" - # Forgejo runner — Docker-in-Docker for CI builds + # Forgejo runner - "^forgejo-runner-" Description: >- - kube-proxy: iptables (minikube). ts-*/ingress-*: network - namespace manipulation (Tailscale operator). forgejo-runner: - Docker-in-Docker for CI. + CC: single-user-cluster, operator-managed-pods, trusted-ci-only. + kube-proxy: system pod, single-user cluster. ts-*/ingress-*: + Tailscale operator-managed. forgejo-runner: DinD limited to + trusted private forge repos. "core_seccomp_profile_docker_default": Regions: ["*"] Resources: @@ -47,34 +49,38 @@ Mutelist: - "^nameserver-" - "^ingress-" Description: >- - System pods (minikube) and Tailscale operator pods — seccomp - profiles set by upstream/operator, not user manifests. + CC: single-user-cluster, operator-managed-pods. System pods + managed by minikube and Tailscale operator; seccomp profiles + set by upstream. Single-user cluster limits exploit surface. "core_minimize_hostPID_containers": Regions: ["*"] Resources: - "^prowler-" Description: >- - Prowler CIS scanner requires hostPID to check file - permissions on kubelet and etcd data directories. + CC: ephemeral-privileged-jobs. Prowler CIS scanner requires + hostPID for file permission checks. Runs as CronJob with + 7-day TTL, not a persistent workload. "core_minimize_root_containers_admission": Regions: ["*"] Resources: - "^grafana-" Description: >- - Grafana init-chown-data runs as root to fix PVC ownership. - Main containers run as UID 472. Standard pattern. + CC: init-container-isolation. Root limited to init-chown-data + container; all runtime containers run as UID 472 with caps + dropped. "core_minimize_containers_added_capabilities": Regions: ["*"] Resources: # Minikube system pods - "^coredns-" - "^kindnet-" - # Grafana init-chown-data (CHOWN capability) + # Grafana init-chown-data - "^grafana-" Description: >- - System pods: NET_BIND_SERVICE/NET_RAW required by function - (minikube). Grafana: CHOWN for PVC init; all other - containers drop ALL. + CC: single-user-cluster, init-container-isolation. System + pods: capabilities required by function (minikube-managed). + Grafana: CHOWN limited to init phase; runtime containers + drop ALL. "core_minimize_containers_capabilities_assigned": Regions: ["*"] Resources: @@ -82,5 +88,5 @@ Mutelist: - "^kindnet-" - "^grafana-" Description: >- - System pods (minikube) and Grafana init-chown-data. - See core_minimize_containers_added_capabilities. + CC: single-user-cluster, init-container-isolation. See + core_minimize_containers_added_capabilities. diff --git a/argocd/manifests/prowler/mutelist/rbac.yaml b/argocd/manifests/prowler/mutelist/rbac.yaml index c5d0ceb..c9c52e4 100644 --- a/argocd/manifests/prowler/mutelist/rbac.yaml +++ b/argocd/manifests/prowler/mutelist/rbac.yaml @@ -10,12 +10,12 @@ Mutelist: # Built-in Kubernetes roles - "^cluster-admin$" - "^system:" - # ArgoCD — requires broad access for deployment management; - # ArgoCD itself is SSO-gated via Authentik + # ArgoCD - "^argocd-" Description: >- - Built-in K8s roles and ArgoCD. ArgoCD access is SSO-gated - via Authentik. + CC: single-user-cluster, sso-gated-admin-tools. Built-in + K8s roles: only operator can bind them. ArgoCD: requires + broad access but is SSO-gated via Authentik OIDC. "rbac_minimize_pod_creation_access": Regions: ["*"] Resources: @@ -26,12 +26,14 @@ Mutelist: # CloudNativePG operator - "^cnpg-manager$" Description: >- - Built-in K8s roles required for workload controllers. - cnpg-manager: CloudNativePG operator manages PostgreSQL pods. + CC: single-user-cluster. Built-in K8s roles and CNPG + operator. Only the operator can assign these roles; no + untrusted users have cluster access. "rbac_minimize_service_account_token_creation": Regions: ["*"] Resources: - "^system:" Description: >- - kube-controller-manager requires token creation for service - account management. Built-in role. + CC: single-user-cluster. kube-controller-manager requires + token creation for SA management. Only operator manages + service accounts. diff --git a/compensating-controls.yaml b/compensating-controls.yaml new file mode 100644 index 0000000..3f42f77 --- /dev/null +++ b/compensating-controls.yaml @@ -0,0 +1,122 @@ +# Compensating Controls +# +# Documents controls that mitigate risks from suppressed or accepted security +# findings. Referenced by security tools (Prowler mutelist, Kingfisher config, +# etc.) via "CC: " in finding descriptions or suppression notes. +# +# Used by `mise run review-compensating-controls` to surface stale controls. +# +# Fields: +# id - kebab-case unique identifier, referenced from tool configs +# description - what the control actually does to mitigate risk +# created - date (YYYY-MM-DD) the control was documented +# last-reviewed - date (YYYY-MM-DD) or null +# notes - optional context + +controls: + - id: single-user-cluster + description: >- + Only the cluster operator (eblume) has kubectl access. No untrusted + users can create pods, access cached images, or bind RBAC roles. + created: 2026-03-30 + last-reviewed: 2026-03-30 + notes: >- + Verify by checking kubeconfig distribution and Tailscale ACLs. + If additional users gain cluster access, re-evaluate all findings + muted under this control. + + - id: tailscale-network-isolation + description: >- + Cluster is not internet-exposed. All access requires Tailscale + identity with ACL enforcement. Profiling endpoints, debug ports, + and control-plane APIs are unreachable from the public internet. + created: 2026-03-30 + last-reviewed: 2026-03-30 + notes: >- + Verify with 'tailscale serve status --json' on indri and review + Tailscale ACLs in pulumi/tailscale/. Only tag:flyio-target services + are publicly routable. + + - id: local-registry + description: >- + All container images are pulled from private zot registry + (registry.ops.eblu.me). No shared external registry credentials + are cached on cluster nodes. + created: 2026-03-30 + last-reviewed: 2026-03-30 + notes: >- + Verify by checking image prefixes in kustomization.yaml files. + Upstream images (immich, ollama) are exceptions — track in + service-versions.yaml. + + - id: sso-gated-admin-tools + description: >- + ArgoCD and Grafana require SSO authentication via Authentik OIDC. + Wildcard RBAC in ArgoCD is mitigated by requiring authenticated + identity before any API access. + created: 2026-03-30 + last-reviewed: 2026-03-30 + notes: >- + Verify Authentik provider config and that anonymous access is + disabled. Check ArgoCD --auth-token isn't leaked. + + - id: operator-managed-pods + description: >- + Tailscale operator manages proxy pod specs (ts-*, ingress-*, + operator-*, nameserver-*). Pod security settings are set by the + operator, not user manifests. Operator is tracked in + service-versions.yaml and regularly updated. + created: 2026-03-30 + last-reviewed: 2026-03-30 + notes: >- + Verify operator version is current via 'mise run service-review'. + Check Tailscale changelog for security fixes. If operator adds + seccomp support, remove these mutes. + + - id: ephemeral-privileged-jobs + description: >- + Prowler CIS scanner runs as a CronJob with 7-day TTL + auto-deletion, not as a persistent privileged workload. hostPID + exposure is time-bounded to scan duration (~20s). + created: 2026-03-30 + last-reviewed: 2026-03-30 + notes: >- + Verify TTL is set in cronjob.yaml. Check that no persistent + pods run with hostPID. + + - id: trusted-ci-only + description: >- + Forgejo runner only executes workflows from repos on the private + forge (forge.ops.eblu.me). No external or untrusted repos can + trigger privileged CI jobs. + created: 2026-03-30 + last-reviewed: 2026-03-30 + notes: >- + Verify runner registration is limited to the forge instance. + Check Forgejo runner config for repo allow-lists. + + - id: init-container-isolation + description: >- + Root privileges and added capabilities (CHOWN) are limited to + init containers that run once at pod startup. All runtime + containers run as non-root (UID 472) with all capabilities + dropped. + created: 2026-03-30 + last-reviewed: 2026-03-30 + notes: >- + Verify by inspecting grafana deployment.yaml securityContext + for both init and runtime containers. If fsGroup alone can + handle PVC ownership, remove init-chown-data and this control. + + - id: observability-stack-audit + description: >- + Alloy collects pod logs and ships them to Loki, providing an + audit trail for cluster activity. Compensates for missing + apiserver audit logging which minikube does not configure. + created: 2026-03-30 + last-reviewed: 2026-03-30 + notes: >- + Verify Alloy DaemonSet is running and Loki is receiving logs. + Note this is weaker than native apiserver audit logs — it + captures pod stdout/stderr, not API request-level auditing. + Consider enabling minikube audit logging if supported. diff --git a/docs/changelog.d/compensating-controls.infra.md b/docs/changelog.d/compensating-controls.infra.md new file mode 100644 index 0000000..c865a90 --- /dev/null +++ b/docs/changelog.d/compensating-controls.infra.md @@ -0,0 +1 @@ +Add compensating controls framework: tracking file, review mise task, and how-to doc. Map all Prowler mutelist entries to named controls with CC: prefixes. diff --git a/docs/how-to/operations/review-compensating-controls.md b/docs/how-to/operations/review-compensating-controls.md new file mode 100644 index 0000000..7341256 --- /dev/null +++ b/docs/how-to/operations/review-compensating-controls.md @@ -0,0 +1,77 @@ +--- +title: Review Compensating Controls +modified: 2026-03-30 +last-reviewed: 2026-03-30 +tags: + - how-to + - security + - maintenance +--- + +# Review Compensating Controls + +How to periodically review compensating controls that justify suppressed security findings. + +## Review by Staleness + +Show controls sorted by when they were last reviewed (most stale first): + +```bash +mise run review-compensating-controls +``` + +This reads `compensating-controls.yaml` (repo root), sorts by `last-reviewed`, and displays the most stale control with all codebase references. It also searches for every file that references the control ID, so you can see exactly which suppressed findings depend on it. + +To show more entries: + +```bash +mise run review-compensating-controls --limit 20 +``` + +## What is a Compensating Control? + +A compensating control is a security measure that mitigates the risk a finding was designed to detect, when the finding itself cannot be directly remediated. For example: + +- **Finding:** API server does not enable AlwaysPullImages admission plugin +- **Risk:** Untrusted users could run pods using cached images they shouldn't have access to +- **Compensating control:** `single-user-cluster` — only the operator has kubectl access; no untrusted users can create pods + +Controls are documented in `compensating-controls.yaml` and referenced from security tool configurations (Prowler mutelist files, Kingfisher config, etc.) using the format `CC: `. + +## Review Process + +For each control up for review: + +1. **Understand the risk.** Read each suppressed finding that references this control. What attack or misconfiguration does the original check guard against? + +2. **Verify the control is in effect.** Follow the verification steps in the control's `notes` field. For example, for `tailscale-network-isolation`, check that the cluster is not directly internet-exposed and Tailscale ACLs are enforced. + +3. **Assess whether the control actually mitigates the risk.** A compensating control should address the same threat the check was designed to catch, not just be a vaguely related security measure. If it doesn't hold up, either: + - Fix the underlying finding and remove the suppression + - Document a stronger or more specific compensating control + +4. **Check for changed circumstances.** Has the cluster gained new users? Has a service been exposed publicly? Has an operator added native support for the missing feature? Any of these could invalidate the control. + +5. **Update the review date.** Edit `compensating-controls.yaml` and set `last-reviewed` to today's date. Commit alongside any changes. + +## Adding a New Control + +When suppressing a new security finding, either map it to an existing control or add a new one: + +```yaml +- id: my-new-control + description: >- + What this control does and how it mitigates the specific risk. + created: 2026-03-30 + last-reviewed: 2026-03-30 + notes: >- + How to verify this control is still in effect. +``` + +Then reference it in the suppression configuration with `CC: my-new-control`. + +## Related + +- [[security]] — Security posture overview +- [[read-compliance-reports]] — Accessing and interpreting Prowler reports +- [[review-services]] — Periodic service version review (similar staleness pattern) diff --git a/docs/reference/operations/security.md b/docs/reference/operations/security.md index 17a6ff6..18561a5 100644 --- a/docs/reference/operations/security.md +++ b/docs/reference/operations/security.md @@ -46,6 +46,14 @@ Security posture and compliance scanning for BlumeOps infrastructure. All compliance scan reports are stored on `sifaka:/volume1/reports/`. See [[read-compliance-reports]] for access and interpretation. +## Compensating controls + +Suppressed findings reference named compensating controls tracked in `compensating-controls.yaml` (repo root). Each control has a review date and verification steps. See [[review-compensating-controls]] for the review process. + +```bash +mise run review-compensating-controls +``` + ## Known gaps - No SOC 2 compliance mapping for Kubernetes (Prowler only maps SOC 2 for AWS/Azure/GCP) diff --git a/mise-tasks/review-compensating-controls b/mise-tasks/review-compensating-controls new file mode 100755 index 0000000..09e2d16 --- /dev/null +++ b/mise-tasks/review-compensating-controls @@ -0,0 +1,229 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.12" +# dependencies = ["pyyaml>=6.0.2", "rich>=14.0.0", "typer>=0.24.0"] +# /// +#MISE description="Review the most stale compensating control" +#USAGE flag "--limit " default="10" help="Number of controls to show in the table" +"""Review compensating controls by staleness. + +Reads ``compensating-controls.yaml`` and sorts by ``last-reviewed``. +Shows a staleness table, then displays the most stale control with all +references found in the codebase. + +After reviewing, update the control entry: + + last-reviewed: YYYY-MM-DD + +Usage: mise run review-compensating-controls [--limit 10] +""" + +import subprocess +import sys +from datetime import date +from pathlib import Path +from typing import Annotated + +import typer +import yaml +from rich.console import Console +from rich.panel import Panel +from rich.table import Table + +CONTROLS_FILE = Path(__file__).parent.parent / "compensating-controls.yaml" +REPO_ROOT = Path(__file__).parent.parent + + +def load_controls(path: Path) -> list[dict]: + data = yaml.safe_load(path.read_text()) + return data.get("controls", []) + + +def parse_date(raw) -> date | None: + if raw is None: + return None + if isinstance(raw, date): + return raw + try: + return date.fromisoformat(str(raw)) + except ValueError: + return None + + +def find_references(control_id: str) -> list[str]: + """Find all files referencing a control ID using ripgrep.""" + try: + result = subprocess.run( + ["rg", "--no-heading", "-n", control_id, str(REPO_ROOT)], + capture_output=True, + text=True, + timeout=10, + ) + lines = result.stdout.strip().splitlines() + # Exclude the controls file itself and this script + return [ + ln + for ln in lines + if "compensating-controls.yaml" not in ln + and "review-compensating-controls" not in ln + ] + except (FileNotFoundError, subprocess.TimeoutExpired): + return [] + + +def main( + limit: Annotated[ + int, typer.Option(help="Number of controls to show in the table") + ] = 10, +) -> None: + console = Console() + today = date.today() + + if not CONTROLS_FILE.exists(): + console.print( + f"[bold red]Controls file not found:[/bold red] {CONTROLS_FILE}" + ) + raise typer.Exit(code=1) + + controls = load_controls(CONTROLS_FILE) + + # Parse dates and build sortable entries + entries: list[tuple[dict, date | None]] = [] + for ctrl in controls: + reviewed = parse_date(ctrl.get("last-reviewed")) + entries.append((ctrl, reviewed)) + + # Sort: never-reviewed first, then oldest + entries.sort(key=lambda e: (e[1] is not None, e[1] or date.min)) + + never_reviewed = sum(1 for _, r in entries if r is None) + + # --- Summary panel --- + console.print() + console.print( + Panel( + f"[bold]{len(entries)}[/bold] compensating controls, " + f"[bold red]{never_reviewed}[/bold red] never reviewed", + title="[bold]Compensating Control Review Queue[/bold]", + border_style="cyan", + ) + ) + console.print() + + # --- Staleness table --- + table = Table(show_header=True, header_style="bold") + table.add_column("#", justify="right") + table.add_column("Control ID") + table.add_column("Last Reviewed", justify="right") + table.add_column("Age (days)", justify="right") + table.add_column("Refs", justify="right") + + for i, (ctrl, reviewed) in enumerate(entries[:limit], 1): + control_id = ctrl["id"] + refs = len(find_references(control_id)) + + if reviewed is None: + table.add_row( + str(i), + f"[red]{control_id}[/red]", + "[red]never[/red]", + "[red]—[/red]", + str(refs), + ) + else: + age = (today - reviewed).days + style = "yellow" if age > 90 else "" + id_str = f"[{style}]{control_id}[/{style}]" if style else control_id + date_str = f"[{style}]{reviewed}[/{style}]" if style else str(reviewed) + age_str = f"[{style}]{age}[/{style}]" if style else str(age) + table.add_row(str(i), id_str, date_str, age_str, str(refs)) + + remaining = len(entries) - limit + if remaining > 0: + table.add_row("", f"[dim]… {remaining} more[/dim]", "", "", "") + + console.print(table) + console.print() + + # --- Most stale control detail --- + if not entries: + console.print("[bold red]No controls found![/bold red]") + raise typer.Exit(code=1) + + top_ctrl, top_reviewed = entries[0] + control_id = top_ctrl["id"] + refs = find_references(control_id) + + detail_lines = [ + f"[bold cyan]{control_id}[/bold cyan]", + f"[dim]Last reviewed: {top_reviewed or 'never'}[/dim]", + "", + f"[bold]Description:[/bold] {top_ctrl.get('description', '').strip()}", + ] + notes = top_ctrl.get("notes", "").strip() + if notes: + detail_lines.append(f"[bold]Notes:[/bold] {notes}") + + console.print( + Panel( + "\n".join(detail_lines), + title="[bold]Up For Review[/bold]", + border_style="green", + ) + ) + console.print() + + # --- References --- + if refs: + ref_table = Table( + show_header=True, header_style="bold", title="References in codebase" + ) + ref_table.add_column("File", style="cyan") + ref_table.add_column("Line") + + for ref in refs: + # rg output: file:line:content + parts = ref.split(":", 2) + if len(parts) >= 3: + filepath = parts[0].replace(str(REPO_ROOT) + "/", "") + line_no = parts[1] + content = parts[2].strip() + ref_table.add_row(f"{filepath}:{line_no}", content) + else: + ref_table.add_row(ref, "") + + console.print(ref_table) + else: + console.print( + f"[yellow]No references to '{control_id}' found in the codebase.[/yellow]" + ) + console.print() + + # --- Review checklist --- + checklist = [ + "[bold]Verification:[/bold]\n", + f"• {notes}\n" if notes else "", + "\n[bold]Review each reference:[/bold]\n", + "• For each muted finding referencing this control, confirm:\n", + " 1. The risk the original check guards against\n", + " 2. That this control actually mitigates that risk\n", + " 3. That the control is still in effect (not degraded or bypassed)\n", + "\n[bold]After review:[/bold]\n", + f"• Update compensating-controls.yaml: [cyan]last-reviewed: {today}[/cyan]\n", + "• If the control is no longer valid, either:\n", + " - Fix the underlying finding and remove the mute, or\n", + " - Document a new/updated compensating control\n", + "• Commit the change", + ] + + console.print( + Panel( + "".join(checklist), + title="[bold yellow]Review Guidance[/bold yellow]", + border_style="yellow", + ) + ) + + +if __name__ == "__main__": + typer.run(main) From 2b7b21dc9bc927eec2a5dacbfbe1df407c4754a4 Mon Sep 17 00:00:00 2001 From: Forgejo Actions Date: Mon, 30 Mar 2026 17:48:40 -0700 Subject: [PATCH 182/430] Update docs release to v1.15.2 - Built changelog from towncrier fragments [skip ci] --- CHANGELOG.md | 22 +++++++++++++++++++ argocd/manifests/docs/deployment.yaml | 2 +- docs/changelog.d/+ansible-doc-review.doc.md | 1 - .../+borgmatic-photos-hardening.infra.md | 1 - .../+forgejo-runner-12.7.3.infra.md | 1 - docs/changelog.d/+kingfisher-docs.doc.md | 1 - docs/changelog.d/+kingfisher-prek.feature.md | 1 - docs/changelog.d/+spork-strategy.feature.md | 1 - .../compensating-controls.infra.md | 1 - .../feature-kingfisher-container.feature.md | 1 - .../feature-kingfisher-cronjob.feature.md | 1 - docs/changelog.d/prowler-mutelist.infra.md | 1 - 12 files changed, 23 insertions(+), 11 deletions(-) delete mode 100644 docs/changelog.d/+ansible-doc-review.doc.md delete mode 100644 docs/changelog.d/+borgmatic-photos-hardening.infra.md delete mode 100644 docs/changelog.d/+forgejo-runner-12.7.3.infra.md delete mode 100644 docs/changelog.d/+kingfisher-docs.doc.md delete mode 100644 docs/changelog.d/+kingfisher-prek.feature.md delete mode 100644 docs/changelog.d/+spork-strategy.feature.md delete mode 100644 docs/changelog.d/compensating-controls.infra.md delete mode 100644 docs/changelog.d/feature-kingfisher-container.feature.md delete mode 100644 docs/changelog.d/feature-kingfisher-cronjob.feature.md delete mode 100644 docs/changelog.d/prowler-mutelist.infra.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b93e4a..78c0d12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,28 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [v1.15.2] - 2026-03-30 + +### Features + +- Build custom Kingfisher container from sporked deploy branch, replacing upstream image with locally-built version including --clone-url-base patch. +- Add Kingfisher secret scanner as a weekly CronJob scanning all Forgejo repos, with HTML and JSON reports written to sifaka NFS. +- Add MongoDB Kingfisher secret scanner as a prek hook alongside TruffleHog for comparative coverage evaluation. +- Add spork strategy: floating-branch soft-fork tooling (`mise run spork-create`) and documentation for maintaining local patches against upstream projects. + +### Infrastructure + +- Add compensating controls framework: tracking file, review mise task, and how-to doc. Map all Prowler mutelist entries to named controls with CC: prefixes. +- Add Prowler mutelist to suppress expected findings from system components, operator-managed pods, and accepted operational needs. Fix missing seccomp profile on kube-state-metrics. +- Borgmatic photos backup: restrict to library/ and upload/ (skip regenerable dirs), add SSH keepalives and checkpoint interval to prevent broken pipe failures on large initial syncs. +- Upgrade forgejo-runner from 12.7.0 to 12.7.3 (bug fixes, security dep update). Add service reference card. + +### Documentation + +- Add service reference documentation for Kingfisher secret scanner. +- Review and update Ansible reference doc: add missing roles, sibling playbooks, and clarify Ansible's role in the IaC stack. + + ## [v1.15.1] - 2026-03-28 ### Features diff --git a/argocd/manifests/docs/deployment.yaml b/argocd/manifests/docs/deployment.yaml index 9b61fb0..3224a23 100644 --- a/argocd/manifests/docs/deployment.yaml +++ b/argocd/manifests/docs/deployment.yaml @@ -30,7 +30,7 @@ spec: name: http env: - name: DOCS_RELEASE_URL - value: "https://forge.eblu.me/eblume/blumeops/releases/download/v1.15.1/docs-v1.15.1.tar.gz" + value: "https://forge.eblu.me/eblume/blumeops/releases/download/v1.15.2/docs-v1.15.2.tar.gz" resources: requests: memory: "64Mi" diff --git a/docs/changelog.d/+ansible-doc-review.doc.md b/docs/changelog.d/+ansible-doc-review.doc.md deleted file mode 100644 index 976517a..0000000 --- a/docs/changelog.d/+ansible-doc-review.doc.md +++ /dev/null @@ -1 +0,0 @@ -Review and update Ansible reference doc: add missing roles, sibling playbooks, and clarify Ansible's role in the IaC stack. diff --git a/docs/changelog.d/+borgmatic-photos-hardening.infra.md b/docs/changelog.d/+borgmatic-photos-hardening.infra.md deleted file mode 100644 index c68580a..0000000 --- a/docs/changelog.d/+borgmatic-photos-hardening.infra.md +++ /dev/null @@ -1 +0,0 @@ -Borgmatic photos backup: restrict to library/ and upload/ (skip regenerable dirs), add SSH keepalives and checkpoint interval to prevent broken pipe failures on large initial syncs. diff --git a/docs/changelog.d/+forgejo-runner-12.7.3.infra.md b/docs/changelog.d/+forgejo-runner-12.7.3.infra.md deleted file mode 100644 index 379ca3e..0000000 --- a/docs/changelog.d/+forgejo-runner-12.7.3.infra.md +++ /dev/null @@ -1 +0,0 @@ -Upgrade forgejo-runner from 12.7.0 to 12.7.3 (bug fixes, security dep update). Add service reference card. diff --git a/docs/changelog.d/+kingfisher-docs.doc.md b/docs/changelog.d/+kingfisher-docs.doc.md deleted file mode 100644 index 42fe085..0000000 --- a/docs/changelog.d/+kingfisher-docs.doc.md +++ /dev/null @@ -1 +0,0 @@ -Add service reference documentation for Kingfisher secret scanner. diff --git a/docs/changelog.d/+kingfisher-prek.feature.md b/docs/changelog.d/+kingfisher-prek.feature.md deleted file mode 100644 index dadedc1..0000000 --- a/docs/changelog.d/+kingfisher-prek.feature.md +++ /dev/null @@ -1 +0,0 @@ -Add MongoDB Kingfisher secret scanner as a prek hook alongside TruffleHog for comparative coverage evaluation. diff --git a/docs/changelog.d/+spork-strategy.feature.md b/docs/changelog.d/+spork-strategy.feature.md deleted file mode 100644 index 1f47bc1..0000000 --- a/docs/changelog.d/+spork-strategy.feature.md +++ /dev/null @@ -1 +0,0 @@ -Add spork strategy: floating-branch soft-fork tooling (`mise run spork-create`) and documentation for maintaining local patches against upstream projects. diff --git a/docs/changelog.d/compensating-controls.infra.md b/docs/changelog.d/compensating-controls.infra.md deleted file mode 100644 index c865a90..0000000 --- a/docs/changelog.d/compensating-controls.infra.md +++ /dev/null @@ -1 +0,0 @@ -Add compensating controls framework: tracking file, review mise task, and how-to doc. Map all Prowler mutelist entries to named controls with CC: prefixes. diff --git a/docs/changelog.d/feature-kingfisher-container.feature.md b/docs/changelog.d/feature-kingfisher-container.feature.md deleted file mode 100644 index 9054e81..0000000 --- a/docs/changelog.d/feature-kingfisher-container.feature.md +++ /dev/null @@ -1 +0,0 @@ -Build custom Kingfisher container from sporked deploy branch, replacing upstream image with locally-built version including --clone-url-base patch. diff --git a/docs/changelog.d/feature-kingfisher-cronjob.feature.md b/docs/changelog.d/feature-kingfisher-cronjob.feature.md deleted file mode 100644 index 871c9d8..0000000 --- a/docs/changelog.d/feature-kingfisher-cronjob.feature.md +++ /dev/null @@ -1 +0,0 @@ -Add Kingfisher secret scanner as a weekly CronJob scanning all Forgejo repos, with HTML and JSON reports written to sifaka NFS. diff --git a/docs/changelog.d/prowler-mutelist.infra.md b/docs/changelog.d/prowler-mutelist.infra.md deleted file mode 100644 index a8bf246..0000000 --- a/docs/changelog.d/prowler-mutelist.infra.md +++ /dev/null @@ -1 +0,0 @@ -Add Prowler mutelist to suppress expected findings from system components, operator-managed pods, and accepted operational needs. Fix missing seccomp profile on kube-state-metrics. From cfbf4cadbdf78b8369b05b476b0199926a356906 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 1 Apr 2026 20:35:18 -0700 Subject: [PATCH 183/430] Review argocd-cli reference doc: stamp last-reviewed Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/reference/tools/argocd-cli.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/reference/tools/argocd-cli.md b/docs/reference/tools/argocd-cli.md index 67fb281..7a60490 100644 --- a/docs/reference/tools/argocd-cli.md +++ b/docs/reference/tools/argocd-cli.md @@ -1,6 +1,7 @@ --- title: ArgoCD CLI modified: 2026-02-12 +last-reviewed: 2026-04-01 tags: - reference - gitops From a18a424866e438f9ff43f1732ddd45d6fab31c58 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 1 Apr 2026 21:37:57 -0700 Subject: [PATCH 184/430] Pin NixOS service versions via nixpkgs-services overlay (#321) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Add `nixpkgs-services` flake input pinned to a specific nixpkgs commit, with an overlay that pulls `forgejo-runner`, `snowflake`, and `k3s` from it instead of the rolling `nixpkgs` - Dagger `flake-update` pipeline now excludes `nixpkgs-services` via `--exclude` - Fix stale nix-container-builder version in service-versions.yaml (was 12.6.4, actually running 12.7.2) - Add k3s and minikube to service-versions.yaml tracking - Document the pinning approach in review-services how-to and ringtail reference ## Motivation During service review, discovered that flake updates had silently upgraded forgejo-runner from 12.6.4 → 12.7.2 without updating service-versions.yaml. This "sneak-in upgrade" bypasses the service review process. The overlay ensures these three services only change versions deliberately. ## Test plan - [ ] Verify `nix flake update` from `nixos/ringtail/` does not change `nixpkgs-services` lock entry - [ ] Verify `mise run provision-ringtail` builds successfully with the overlay - [ ] Confirm running service versions unchanged after deploy 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/321 --- .dagger/src/blumeops_ci/main.py | 8 ++++- ansible/roles/caddy/defaults/main.yml | 1 + ansible/roles/caddy/templates/Caddyfile.j2 | 8 +++++ .../pin-nixos-service-versions.infra.md | 1 + docs/how-to/knowledgebase/review-services.md | 8 +++-- docs/reference/infrastructure/ringtail.md | 4 +++ nixos/ringtail/flake.lock | 19 ++++++++++- nixos/ringtail/flake.nix | 20 +++++++++++- service-versions.yaml | 32 ++++++++++++++++--- 9 files changed, 91 insertions(+), 10 deletions(-) create mode 100644 docs/changelog.d/pin-nixos-service-versions.infra.md diff --git a/.dagger/src/blumeops_ci/main.py b/.dagger/src/blumeops_ci/main.py index f24b9e8..641f0db 100644 --- a/.dagger/src/blumeops_ci/main.py +++ b/.dagger/src/blumeops_ci/main.py @@ -258,7 +258,11 @@ class BlumeopsCi: async def flake_update( self, src: dagger.Directory, flake_path: str = "nixos/ringtail" ) -> dagger.File: - """Update all flake inputs to latest and return updated flake.lock.""" + """Update rolling flake inputs to latest and return updated flake.lock. + + Skips nixpkgs-services, which is pinned to a specific commit and should + only be updated deliberately during service reviews. + """ return await ( dag.container() .from_(NIX_IMAGE) @@ -271,6 +275,8 @@ class BlumeopsCi: "nix-command flakes", "flake", "update", + "--exclude", + "nixpkgs-services", "--accept-flake-config", ] ) diff --git a/ansible/roles/caddy/defaults/main.yml b/ansible/roles/caddy/defaults/main.yml index 784738f..f8f9156 100644 --- a/ansible/roles/caddy/defaults/main.yml +++ b/ansible/roles/caddy/defaults/main.yml @@ -82,6 +82,7 @@ caddy_services: - name: authentik host: "authentik.{{ caddy_domain }}" backend: "https://authentik.tail8d86e.ts.net" + cache_policy: spa - name: ntfy host: "ntfy.{{ caddy_domain }}" backend: "https://ntfy.tail8d86e.ts.net" diff --git a/ansible/roles/caddy/templates/Caddyfile.j2 b/ansible/roles/caddy/templates/Caddyfile.j2 index dc3c7ff..4f103f1 100644 --- a/ansible/roles/caddy/templates/Caddyfile.j2 +++ b/ansible/roles/caddy/templates/Caddyfile.j2 @@ -31,6 +31,14 @@ {% for service in caddy_services %} @{{ service.name }} host {{ service.host }} handle @{{ service.name }} { +{% if service.cache_policy | default('') == 'spa' %} + # SPA cache policy: hashed static assets are immutable, HTML must revalidate. + # Prevents stale HTML from referencing chunk hashes that no longer exist. + @{{ service.name }}_static path /static/dist/* + header @{{ service.name }}_static Cache-Control "public, max-age=31536000, immutable" + @{{ service.name }}_html path /if/* + header @{{ service.name }}_html Cache-Control "no-cache" +{% endif %} {% if service.backend.startswith('https://') %} reverse_proxy {{ service.backend }} { # Caddy v2.11+ rewrites Host to upstream for HTTPS backends. diff --git a/docs/changelog.d/pin-nixos-service-versions.infra.md b/docs/changelog.d/pin-nixos-service-versions.infra.md new file mode 100644 index 0000000..92bc07c --- /dev/null +++ b/docs/changelog.d/pin-nixos-service-versions.infra.md @@ -0,0 +1 @@ +Pin NixOS service versions (forgejo-runner, snowflake, k3s) via `nixpkgs-services` overlay in ringtail flake, preventing silent upgrades from `nix flake update`. Add k3s and minikube to service-versions.yaml tracking. Fix stale nix-container-builder version (was 12.6.4, actually running 12.7.2). diff --git a/docs/how-to/knowledgebase/review-services.md b/docs/how-to/knowledgebase/review-services.md index 30b5833..8a2fc0d 100644 --- a/docs/how-to/knowledgebase/review-services.md +++ b/docs/how-to/knowledgebase/review-services.md @@ -57,9 +57,13 @@ For all service types, start by reading the service's reference card (`docs/refe ### NixOS Services (`type: nixos`) +Versioned NixOS services (forgejo-runner, snowflake, k3s) are pinned via a `nixpkgs-services` overlay in `nixos/ringtail/flake.nix`. This prevents `nix flake update` from silently upgrading them — they only change when the `nixpkgs-services` input is deliberately updated. + 1. Check the upstream project for new releases -2. Review the Nix derivation or flake input for version pins -3. If upgrading, update and deploy via `mise run provision-ringtail` +2. Check what version nixpkgs has: `ssh ringtail 'nix eval nixpkgs#.version'` +3. To upgrade, update the `nixpkgs-services` rev in `flake.nix` to a nixpkgs commit that includes the desired version, then run `nix flake update nixpkgs-services` from `nixos/ringtail/` +4. Deploy via `mise run provision-ringtail` +5. Update `service-versions.yaml` with the new version ### Private Forge Repos (`upstream-source` under `forge.eblu.me/eblume/`) diff --git a/docs/reference/infrastructure/ringtail.md b/docs/reference/infrastructure/ringtail.md index d5bbd91..8b93d4d 100644 --- a/docs/reference/infrastructure/ringtail.md +++ b/docs/reference/infrastructure/ringtail.md @@ -108,6 +108,10 @@ A native Forgejo Actions runner (`ringtail-nix-builder`) runs as a systemd servi The runner resolves `` from the flake registry at build time. Container trust policy (`/etc/containers/policy.json`) and registry search order (`/etc/containers/registries.conf`) are configured minimally in `configuration.nix` for skopeo — no full `virtualisation.containers` module needed. +## Pinned Service Versions + +Versioned services (forgejo-runner, snowflake, k3s) are pinned via a `nixpkgs-services` overlay in `flake.nix`, separate from the rolling `nixpkgs` input. This prevents `nix flake update` from silently upgrading them. The Dagger `flake-update` pipeline excludes `nixpkgs-services` automatically. See [[review-services]] for the upgrade procedure. + ## Maintenance Notes **1Password:** Desktop app must be running for `op` CLI. Use `$mod+Shift+minus` to send to scratchpad. diff --git a/nixos/ringtail/flake.lock b/nixos/ringtail/flake.lock index 9195dbd..c7f865c 100644 --- a/nixos/ringtail/flake.lock +++ b/nixos/ringtail/flake.lock @@ -57,11 +57,28 @@ "type": "github" } }, + "nixpkgs-services": { + "locked": { + "lastModified": 1774388614, + "narHash": "sha256-tFwzTI0DdDzovdE9+Ras6CUss0yn8P9XV4Ja6RjA+nU=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "1073dad219cb244572b74da2b20c7fe39cb3fa9e", + "type": "github" + }, + "original": { + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "1073dad219cb244572b74da2b20c7fe39cb3fa9e", + "type": "github" + } + }, "root": { "inputs": { "disko": "disko", "home-manager": "home-manager", - "nixpkgs": "nixpkgs" + "nixpkgs": "nixpkgs", + "nixpkgs-services": "nixpkgs-services" } } }, diff --git a/nixos/ringtail/flake.nix b/nixos/ringtail/flake.nix index 70a1d73..188b707 100644 --- a/nixos/ringtail/flake.nix +++ b/nixos/ringtail/flake.nix @@ -3,6 +3,12 @@ inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; + + # Pinned nixpkgs for versioned services (forgejo-runner, snowflake, k3s). + # Update this deliberately during service reviews, not via `nix flake update`. + # Current versions: forgejo-runner 12.7.2, snowflake 2.11.0, k3s 1.34.5+k3s1 + nixpkgs-services.url = "github:NixOS/nixpkgs/1073dad219cb244572b74da2b20c7fe39cb3fa9e"; + disko = { url = "github:nix-community/disko"; inputs.nixpkgs.follows = "nixpkgs"; @@ -13,7 +19,7 @@ }; }; - outputs = { nixpkgs, disko, home-manager, ... }: { + outputs = { nixpkgs, nixpkgs-services, disko, home-manager, ... }: { nixosConfigurations.ringtail = nixpkgs.lib.nixosSystem { system = "x86_64-linux"; modules = [ @@ -22,6 +28,18 @@ ./disk-config.nix ./hardware-configuration.nix ./configuration.nix + # Pin versioned services to nixpkgs-services instead of the rolling nixpkgs. + # This prevents `nix flake update nixpkgs` from silently upgrading them. + # Bump nixpkgs-services explicitly during service reviews. + ({ ... }: { + nixpkgs.overlays = [ + (final: prev: let svcPkgs = nixpkgs-services.legacyPackages.x86_64-linux; in { + forgejo-runner = svcPkgs.forgejo-runner; + snowflake = svcPkgs.snowflake; + k3s = svcPkgs.k3s; + }) + ]; + }) ]; }; }; diff --git a/service-versions.yaml b/service-versions.yaml index adb0974..b8441c0 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -252,17 +252,39 @@ services: - name: nix-container-builder type: nixos - last-reviewed: 2026-02-22 - current-version: "12.6.4" + last-reviewed: 2026-04-01 + current-version: "12.7.2" upstream-source: https://code.forgejo.org/forgejo/runner/releases - notes: Forgejo runner on ringtail via nixpkgs; version tracks flake.lock + notes: >- + Forgejo runner on ringtail; pinned via nixpkgs-services overlay in flake.nix. + Update nixpkgs-services rev during service reviews, not via nix flake update. - name: snowflake-proxy type: nixos - last-reviewed: 2026-03-24 + last-reviewed: 2026-04-01 current-version: "2.11.0" upstream-source: https://gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake/-/releases - notes: Tor Snowflake proxy on ringtail; anti-censorship bridge, not an exit node + notes: >- + Tor Snowflake proxy on ringtail; pinned via nixpkgs-services overlay in flake.nix. + Anti-censorship bridge, not an exit node. + + - name: k3s + type: nixos + last-reviewed: 2026-04-01 + current-version: "1.34.5+k3s1" + upstream-source: https://github.com/k3s-io/k3s/releases + notes: >- + Single-node k3s cluster on ringtail; pinned via nixpkgs-services overlay in flake.nix. + Update nixpkgs-services rev during service reviews. + + - name: minikube + type: ansible + last-reviewed: 2026-04-01 + current-version: "1.38.0" + upstream-source: https://github.com/kubernetes/minikube/releases + notes: >- + Single-node minikube on indri; installed via homebrew (not version-pinned). + Homebrew may silently upgrade on brew update/upgrade. - name: mealie type: argocd From baee7ae54b98b9cefd6b1fe8aeeb642b37633741 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 1 Apr 2026 22:01:57 -0700 Subject: [PATCH 185/430] Review single-user-cluster control and add evidence collection card Stamp single-user-cluster last-reviewed to 2026-04-01 after verifying Tailscale ACLs and kubeconfig distribution. Add aspirational how-to card documenting what PCI DSS evidence collection would look like (CCW, artifacts, Drata workflow). Link from existing review process card. Co-Authored-By: Claude Opus 4.6 (1M context) --- compensating-controls.yaml | 2 +- .../operations/record-review-evidence.md | 50 +++++++++++++++++++ .../review-compensating-controls.md | 1 + 3 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 docs/how-to/operations/record-review-evidence.md diff --git a/compensating-controls.yaml b/compensating-controls.yaml index 3f42f77..b90da40 100644 --- a/compensating-controls.yaml +++ b/compensating-controls.yaml @@ -19,7 +19,7 @@ controls: Only the cluster operator (eblume) has kubectl access. No untrusted users can create pods, access cached images, or bind RBAC roles. created: 2026-03-30 - last-reviewed: 2026-03-30 + last-reviewed: 2026-04-01 notes: >- Verify by checking kubeconfig distribution and Tailscale ACLs. If additional users gain cluster access, re-evaluate all findings diff --git a/docs/how-to/operations/record-review-evidence.md b/docs/how-to/operations/record-review-evidence.md new file mode 100644 index 0000000..9de4e37 --- /dev/null +++ b/docs/how-to/operations/record-review-evidence.md @@ -0,0 +1,50 @@ +--- +title: Record Review Evidence +modified: 2026-04-01 +last-reviewed: 2026-04-01 +tags: + - how-to + - security + - compliance +--- + +# Record Review Evidence + +How review evidence *would* be captured after a [[review-compensating-controls|compensating control review]], to make the review auditable under a compliance framework. + +blumeops does not currently collect review evidence. This card documents the target process for reference and practice. + +## Why Record Evidence? + +Reviewing a control and updating `last-reviewed` proves the review *happened* but not *what was checked*. Under frameworks like PCI DSS v4.0, a QSA needs to see dated, immutable evidence that the reviewer verified the control and that an appropriate party accepted the residual risk. Compliance platforms like Drata automate this collection, but the underlying artifacts are the same whether you use a platform or a directory of files. + +## What Evidence Would Be Captured + +For each control reviewed, artifacts should answer: + +1. **Who reviewed it** — reviewer name, date +2. **What was verified** — the specific checks performed (e.g., Tailscale ACL policy snapshot, `tailscale status` output, kubectl auth checks) +3. **What was found** — the outcome: control still in effect, circumstances changed, or control invalidated +4. **Residual risk** — what the control does *not* cover (the gap a QSA will ask about) +5. **Acceptance** — formal sign-off that the residual risk is accepted by an appropriate party (reviewer + approver, typically a manager or CTO) + +Supporting artifacts would include command output, policy snapshots, screenshots, or API responses — anything that demonstrates the verification was actually performed. + +## PCI DSS Context + +Under PCI DSS v4.0, compensating controls require a **Compensating Control Worksheet (CCW)** that maps each control to the original requirement it substitutes for. The CCW fields are: + +- **Original requirement** — the specific PCI DSS requirement not directly met +- **Constraint** — why direct compliance isn't feasible +- **Compensating control definition** — what is done instead +- **Risk addressed** — how the control mitigates the original threat +- **Residual risk** — what remains unmitigated +- **Validation procedure** — steps to verify (what `notes` captures in `compensating-controls.yaml`) + +Req 12.3.2 mandates review **at least annually** (quarterly is typical for Level 1 Service Providers). In a platform like Drata, these map to Controls with uploaded Evidence and review workflows requiring sign-off from both the reviewer and an approver. + +## Related + +- [[review-compensating-controls]] — The technical review process +- [[security]] — Security posture overview +- [[read-compliance-reports]] — Interpreting Prowler/Kingfisher reports diff --git a/docs/how-to/operations/review-compensating-controls.md b/docs/how-to/operations/review-compensating-controls.md index 7341256..b05958e 100644 --- a/docs/how-to/operations/review-compensating-controls.md +++ b/docs/how-to/operations/review-compensating-controls.md @@ -72,6 +72,7 @@ Then reference it in the suppression configuration with `CC: my-new-control`. ## Related +- [[record-review-evidence]] — Capturing evidence artifacts for audit (aspirational) - [[security]] — Security posture overview - [[read-compliance-reports]] — Accessing and interpreting Prowler reports - [[review-services]] — Periodic service version review (similar staleness pattern) From 1e67975acbab0ee994a54a85f19b62ac1d7435bb Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 1 Apr 2026 22:04:38 -0700 Subject: [PATCH 186/430] Add changelog fragment for compensating control review Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/changelog.d/+review-single-user-cluster.doc.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 docs/changelog.d/+review-single-user-cluster.doc.md diff --git a/docs/changelog.d/+review-single-user-cluster.doc.md b/docs/changelog.d/+review-single-user-cluster.doc.md new file mode 100644 index 0000000..eddd1d4 --- /dev/null +++ b/docs/changelog.d/+review-single-user-cluster.doc.md @@ -0,0 +1 @@ +First compensating control review: verified `single-user-cluster` still in effect. Added aspirational how-to card for PCI DSS evidence collection. From 08d57ef4d48f0632b90b32c7d4f1edef54a7c81f Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Thu, 2 Apr 2026 10:55:57 -0700 Subject: [PATCH 187/430] Review pulumi reference doc: fix Tailscale auth, add how-to links Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/reference/tools/pulumi.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/reference/tools/pulumi.md b/docs/reference/tools/pulumi.md index 3af94cd..bdc7e8f 100644 --- a/docs/reference/tools/pulumi.md +++ b/docs/reference/tools/pulumi.md @@ -1,6 +1,7 @@ --- title: Pulumi -modified: 2026-02-12 +modified: 2026-04-02 +last-reviewed: 2026-04-02 tags: - reference - iac @@ -42,12 +43,14 @@ mise run tailnet-up # Apply ACL/tag changes ## Authentication -- **Gandi**: `GANDI_PERSONAL_ACCESS_TOKEN` environment variable -- **Tailscale**: `TAILSCALE_API_KEY` environment variable +- **Gandi**: `GANDI_PERSONAL_ACCESS_TOKEN` (fetched from 1Password by the mise task) +- **Tailscale**: `TAILSCALE_OAUTH_CLIENT_ID` + `TAILSCALE_OAUTH_CLIENT_SECRET` (fetched from 1Password by the mise task) - **Pulumi state**: Local backend (no Pulumi Cloud) ## Related +- [[gandi-operations]] — DNS PAT rotation and Pulumi workflow +- [[update-tailscale-acls]] — ACL editing and Pulumi workflow - [[gandi]] — DNS hosting - [[tailscale]] — Tailnet configuration - [[routing]] — How DNS records map to services From b1e2811077b384466e6308096abb9572c4ab440c Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Thu, 2 Apr 2026 11:33:19 -0700 Subject: [PATCH 188/430] =?UTF-8?q?Upgrade=20Grafana=2012.3.3=20=E2=86=92?= =?UTF-8?q?=2012.4.2=20(#322)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Bumps Grafana from 12.3.3 to 12.4.2 - Patches 7 CVEs, notably CVE-2026-27880 (unauthenticated OOM DoS, CVSS 7.5) and CVE-2026-27879 (authenticated OOM via resample queries) - No config changes required — reviewed alerting, datasources, OIDC, and feature toggles against 12.4.x breaking changes ## Breaking changes reviewed | Change | Impact | |--------|--------| | Alerting: pending period applies to NoData/Error | Net positive — reduces noise from transient blips | | Default notification uses empty receiver | No impact — we explicitly set `ntfy-infra` | | Removed feature toggles (4) | No impact — none configured | | OAuth ID token signature validation | Low risk — verify OIDC login post-deploy | | OpsGenie deprecated | No impact — using webhook | ## Test plan - [ ] Container build completes at forge - [ ] Update kustomization.yaml with new image tag - [ ] `argocd app set grafana --revision upgrade/grafana-12.4.2 && argocd app sync grafana` - [ ] Verify Grafana UI loads at grafana.ops.eblu.me - [ ] Verify OIDC login via Authentik - [ ] Verify dashboards and datasources load - [ ] Check alerting rules are intact 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/322 --- argocd/manifests/grafana/kustomization.yaml | 2 +- containers/grafana/Dockerfile | 2 +- docs/changelog.d/upgrade-grafana-12.4.2.infra.md | 1 + service-versions.yaml | 4 ++-- 4 files changed, 5 insertions(+), 4 deletions(-) create mode 100644 docs/changelog.d/upgrade-grafana-12.4.2.infra.md diff --git a/argocd/manifests/grafana/kustomization.yaml b/argocd/manifests/grafana/kustomization.yaml index 3aeaa26..4fe53a9 100644 --- a/argocd/manifests/grafana/kustomization.yaml +++ b/argocd/manifests/grafana/kustomization.yaml @@ -18,7 +18,7 @@ images: - name: registry.ops.eblu.me/blumeops/grafana-sidecar newTag: v1.28.0-613f05d - name: registry.ops.eblu.me/blumeops/grafana - newTag: v12.3.3-613f05d + newTag: v12.4.2-4c54774 configMapGenerator: - name: grafana diff --git a/containers/grafana/Dockerfile b/containers/grafana/Dockerfile index 3d5b12b..3b33dd9 100644 --- a/containers/grafana/Dockerfile +++ b/containers/grafana/Dockerfile @@ -1,4 +1,4 @@ -ARG CONTAINER_APP_VERSION=12.3.3 +ARG CONTAINER_APP_VERSION=12.4.2 FROM alpine:3.22 diff --git a/docs/changelog.d/upgrade-grafana-12.4.2.infra.md b/docs/changelog.d/upgrade-grafana-12.4.2.infra.md new file mode 100644 index 0000000..11bba26 --- /dev/null +++ b/docs/changelog.d/upgrade-grafana-12.4.2.infra.md @@ -0,0 +1 @@ +Upgrade Grafana from 12.3.3 to 12.4.2 — patches 7 CVEs including an unauthenticated DoS (CVE-2026-27880). diff --git a/service-versions.yaml b/service-versions.yaml index b8441c0..2a568b4 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -97,8 +97,8 @@ services: - name: grafana type: argocd - last-reviewed: 2026-02-23 - current-version: "12.3.3" + last-reviewed: 2026-04-02 + current-version: "12.4.2" upstream-source: https://github.com/grafana/grafana/releases notes: Home-built container from Alpine; upgraded from Helm to Kustomize From 75f9ba494307ead6b5988c26ea659d06febb06ed Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Thu, 2 Apr 2026 13:45:02 -0700 Subject: [PATCH 189/430] Build Tempo container from source (2.10.3) (#323) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Add `containers/tempo/Dockerfile` — two-stage Go build from forge mirror, modeled on loki - Switch kustomization from upstream `grafana/tempo` to `registry.ops.eblu.me/blumeops/tempo` - Bump Tempo 2.10.1 → 2.10.3 ## Test plan - [ ] Kick off container build via `mise run container-build-and-release tempo` - [ ] Update kustomization `newTag` with built image tag - [ ] Deploy from branch: `argocd app set tempo --revision local-tempo-container && argocd app sync tempo` - [ ] Verify Tempo health: `curl tempo.ops.eblu.me/ready` - [ ] Verify traces flowing in Grafana Tempo datasource 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/323 --- argocd/manifests/tempo/kustomization.yaml | 3 +- containers/tempo/Dockerfile | 40 +++++++++++++++++++ .../local-tempo-container.infra.md | 1 + service-versions.yaml | 5 ++- 4 files changed, 46 insertions(+), 3 deletions(-) create mode 100644 containers/tempo/Dockerfile create mode 100644 docs/changelog.d/local-tempo-container.infra.md diff --git a/argocd/manifests/tempo/kustomization.yaml b/argocd/manifests/tempo/kustomization.yaml index 68a209c..a40a954 100644 --- a/argocd/manifests/tempo/kustomization.yaml +++ b/argocd/manifests/tempo/kustomization.yaml @@ -11,7 +11,8 @@ resources: images: - name: grafana/tempo - newTag: "2.10.1" + newName: registry.ops.eblu.me/blumeops/tempo + newTag: "v2.10.3-49ce545" configMapGenerator: - name: tempo-config diff --git a/containers/tempo/Dockerfile b/containers/tempo/Dockerfile new file mode 100644 index 0000000..aeca55e --- /dev/null +++ b/containers/tempo/Dockerfile @@ -0,0 +1,40 @@ +# Grafana Tempo distributed tracing backend +# Two-stage build: Go binary, Alpine runtime + +ARG CONTAINER_APP_VERSION=2.10.3 +ARG TEMPO_VERSION=v${CONTAINER_APP_VERSION} + +FROM golang:alpine3.22 AS build + +ARG TEMPO_VERSION +RUN apk add --no-cache build-base git + +RUN git clone --depth 1 --branch ${TEMPO_VERSION} \ + https://forge.ops.eblu.me/mirrors/tempo.git /go/src/app + +WORKDIR /go/src/app +ENV CGO_ENABLED=0 + +RUN go build -mod vendor \ + -ldflags="-w -s \ + -X main.Version=${TEMPO_VERSION} \ + -X main.Branch=HEAD \ + -X main.Revision=blumeops-build" \ + -o /bin/tempo ./cmd/tempo + +FROM alpine:3.22 + +ARG CONTAINER_APP_VERSION +LABEL org.opencontainers.image.title="Tempo" +LABEL org.opencontainers.image.description="Grafana Tempo distributed tracing backend" +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" + +RUN apk add --no-cache ca-certificates tzdata +RUN mkdir -p /var/tempo && chown 10001:10001 /var/tempo + +USER 10001 +COPY --from=build /bin/tempo /usr/bin/tempo +EXPOSE 3200 4317 4318 9095 +ENTRYPOINT ["/usr/bin/tempo"] diff --git a/docs/changelog.d/local-tempo-container.infra.md b/docs/changelog.d/local-tempo-container.infra.md new file mode 100644 index 0000000..3771c24 --- /dev/null +++ b/docs/changelog.d/local-tempo-container.infra.md @@ -0,0 +1 @@ +Build Tempo container from source via forge mirror; bump 2.10.1 → 2.10.3 diff --git a/service-versions.yaml b/service-versions.yaml index 2a568b4..f3a8394 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -65,9 +65,10 @@ services: - name: tempo type: argocd - last-reviewed: 2026-03-05 - current-version: "2.10.1" + last-reviewed: 2026-04-02 + current-version: "2.10.3" upstream-source: https://github.com/grafana/tempo/releases + notes: Home-built container from forge mirror - name: alloy-tracing-ringtail type: argocd From 306f580bdb0bf0f45c5a278d3cb6babd23f0a011 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Thu, 2 Apr 2026 13:45:57 -0700 Subject: [PATCH 190/430] Point Tempo at main-built container v2.10.3-75f9ba4 C0 follow-up: update tag from branch-built image to main-SHA image. Co-Authored-By: Claude Opus 4.6 (1M context) --- argocd/manifests/tempo/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/argocd/manifests/tempo/kustomization.yaml b/argocd/manifests/tempo/kustomization.yaml index a40a954..1ccbdc8 100644 --- a/argocd/manifests/tempo/kustomization.yaml +++ b/argocd/manifests/tempo/kustomization.yaml @@ -12,7 +12,7 @@ resources: images: - name: grafana/tempo newName: registry.ops.eblu.me/blumeops/tempo - newTag: "v2.10.3-49ce545" + newTag: "v2.10.3-75f9ba4" configMapGenerator: - name: tempo-config From 5de2ed9f963b53e73d9f0c13294c6c0b5cb789b9 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Thu, 2 Apr 2026 15:48:36 -0700 Subject: [PATCH 191/430] Add gaming.nix for ringtail: gamescope + consolidate Steam config Move Steam config from configuration.nix to a dedicated gaming.nix module and add gamescope for fullscreen/resolution management with Proton games. Co-Authored-By: Claude Opus 4.6 (1M context) --- nixos/ringtail/configuration.nix | 6 ------ nixos/ringtail/flake.nix | 1 + nixos/ringtail/gaming.nix | 16 ++++++++++++++++ 3 files changed, 17 insertions(+), 6 deletions(-) create mode 100644 nixos/ringtail/gaming.nix diff --git a/nixos/ringtail/configuration.nix b/nixos/ringtail/configuration.nix index 7d948a2..8bb2d8d 100644 --- a/nixos/ringtail/configuration.nix +++ b/nixos/ringtail/configuration.nix @@ -113,12 +113,6 @@ in polkitPolicyOwners = [ "eblume" ]; }; - # Steam - programs.steam = { - enable = true; - dedicatedServer.openFirewall = true; - }; - # K3s single-node cluster services.k3s = { enable = true; diff --git a/nixos/ringtail/flake.nix b/nixos/ringtail/flake.nix index 188b707..541bafa 100644 --- a/nixos/ringtail/flake.nix +++ b/nixos/ringtail/flake.nix @@ -28,6 +28,7 @@ ./disk-config.nix ./hardware-configuration.nix ./configuration.nix + ./gaming.nix # Pin versioned services to nixpkgs-services instead of the rolling nixpkgs. # This prevents `nix flake update nixpkgs` from silently upgrading them. # Bump nixpkgs-services explicitly during service reviews. diff --git a/nixos/ringtail/gaming.nix b/nixos/ringtail/gaming.nix new file mode 100644 index 0000000..2b361b3 --- /dev/null +++ b/nixos/ringtail/gaming.nix @@ -0,0 +1,16 @@ +{ pkgs, ... }: + +{ + # Steam + programs.steam = { + enable = true; + dedicatedServer.openFirewall = true; + }; + + # Gamescope — micro-compositor for game fullscreen/resolution management. + # Use as Steam launch option: gamescope -W 2560 -H 1440 -f -- %command% + programs.gamescope = { + enable = true; + capSysNice = true; # Allow gamescope to set realtime scheduling + }; +} From afb184fefc92981a2e48ae5bf567eae9ea73f122 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Thu, 2 Apr 2026 16:04:29 -0700 Subject: [PATCH 192/430] Add Sway fullscreen rule for RDR2 (gamescope broken on NVIDIA 580.x) Gamescope 3.16.17 segfaults on NVIDIA 580.x in nested Wayland/Sway due to explicit sync issues (ValveSoftware/gamescope#1662). Use a Sway window rule to force RDR2 fullscreen instead. Co-Authored-By: Claude Opus 4.6 (1M context) --- nixos/ringtail/configuration.nix | 1 + 1 file changed, 1 insertion(+) diff --git a/nixos/ringtail/configuration.nix b/nixos/ringtail/configuration.nix index 8bb2d8d..ac8e669 100644 --- a/nixos/ringtail/configuration.nix +++ b/nixos/ringtail/configuration.nix @@ -247,6 +247,7 @@ in commands = [ { command = "inhibit_idle fullscreen"; criteria = { class = ".*"; }; } { command = "inhibit_idle fullscreen"; criteria = { app_id = ".*"; }; } + { command = "fullscreen enable"; criteria = { class = "steam_app_1174180"; }; } ]; }; colors = { From 464e3222d2a09ac5863eb091fdcc4df518c5f5c7 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Thu, 2 Apr 2026 20:21:19 -0700 Subject: [PATCH 193/430] Document upstream fix for Prowler --registry bug (pending release) PR #10470 merged 2026-03-30; initContainer workaround stays until a Prowler release includes the fix (latest is 5.22.0). Co-Authored-By: Claude Opus 4.6 (1M context) --- argocd/manifests/prowler/cronjob-image-scan.yaml | 3 +++ docs/changelog.d/+prowler-registry-fix-upstream.doc.md | 1 + 2 files changed, 4 insertions(+) create mode 100644 docs/changelog.d/+prowler-registry-fix-upstream.doc.md diff --git a/argocd/manifests/prowler/cronjob-image-scan.yaml b/argocd/manifests/prowler/cronjob-image-scan.yaml index 5d8ea7e..84df1e0 100644 --- a/argocd/manifests/prowler/cronjob-image-scan.yaml +++ b/argocd/manifests/prowler/cronjob-image-scan.yaml @@ -20,6 +20,9 @@ spec: # not passed to provider constructor). Generate image list from # zot catalog API instead. # See: https://github.com/prowler-cloud/prowler/issues/10457 + # Fix merged upstream (PR #10470, 2026-03-30) but not yet in a + # release (latest: 5.22.0). Remove this initContainer once a + # release includes the fix and we upgrade. - name: enumerate-images image: registry.ops.eblu.me/blumeops/prowler:kustomized command: ["python3", "-c"] diff --git a/docs/changelog.d/+prowler-registry-fix-upstream.doc.md b/docs/changelog.d/+prowler-registry-fix-upstream.doc.md new file mode 100644 index 0000000..360e460 --- /dev/null +++ b/docs/changelog.d/+prowler-registry-fix-upstream.doc.md @@ -0,0 +1 @@ +Prowler `--registry` fix merged upstream (PR #10470); initContainer workaround documented as pending release. From 64200a55c5e19d5880ee83b8f50ef09306c12d06 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sat, 4 Apr 2026 09:42:25 -0700 Subject: [PATCH 194/430] =?UTF-8?q?Migrate=20Immich=20from=20Helm=20chart?= =?UTF-8?q?=20to=20kustomize=20manifests=20(v2.5.6=20=E2=86=92=20v2.6.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the Helm chart deployment with plain kustomize manifests following the Authentik pattern (separate deployments per component). Consolidate the immich-storage ArgoCD app into the main immich app. Add no-helm-policy doc establishing kustomize as the standard deployment mechanism. Co-Authored-By: Claude Opus 4.6 (1M context) --- argocd/apps/immich-storage.yaml | 25 ----- argocd/apps/immich.yaml | 29 +++--- argocd/manifests/immich/README.md | 46 ++++++---- argocd/manifests/immich/deployment-ml.yaml | 60 ++++++++++++ .../manifests/immich/deployment-server.yaml | 71 +++++++++++++++ .../manifests/immich/deployment-valkey.yaml | 39 ++++++++ argocd/manifests/immich/kustomization.yaml | 19 +++- argocd/manifests/immich/pvc-ml-cache.yaml | 12 +++ argocd/manifests/immich/service-ml.yaml | 14 +++ argocd/manifests/immich/service-valkey.yaml | 14 +++ argocd/manifests/immich/service.yaml | 14 +++ argocd/manifests/immich/values.yaml | 91 ------------------- .../immich-kustomize-v2.6.3.infra.md | 1 + docs/explanation/no-helm-policy.md | 46 ++++++++++ docs/how-to/knowledgebase/review-services.md | 5 + docs/reference/kubernetes/apps.md | 4 +- docs/reference/services/immich.md | 4 +- mise-tasks/service-review | 2 +- service-versions.yaml | 6 +- 19 files changed, 340 insertions(+), 162 deletions(-) delete mode 100644 argocd/apps/immich-storage.yaml create mode 100644 argocd/manifests/immich/deployment-ml.yaml create mode 100644 argocd/manifests/immich/deployment-server.yaml create mode 100644 argocd/manifests/immich/deployment-valkey.yaml create mode 100644 argocd/manifests/immich/pvc-ml-cache.yaml create mode 100644 argocd/manifests/immich/service-ml.yaml create mode 100644 argocd/manifests/immich/service-valkey.yaml create mode 100644 argocd/manifests/immich/service.yaml delete mode 100644 argocd/manifests/immich/values.yaml create mode 100644 docs/changelog.d/immich-kustomize-v2.6.3.infra.md create mode 100644 docs/explanation/no-helm-policy.md diff --git a/argocd/apps/immich-storage.yaml b/argocd/apps/immich-storage.yaml deleted file mode 100644 index 7227681..0000000 --- a/argocd/apps/immich-storage.yaml +++ /dev/null @@ -1,25 +0,0 @@ -# Immich Storage - PersistentVolume and PVC for photo library -# Must be synced BEFORE the main immich app -# -# Prerequisites: -# 1. NFS share on sifaka at /volume1/photos with permissions for indri -apiVersion: argoproj.io/v1alpha1 -kind: Application -metadata: - name: immich-storage - namespace: argocd -spec: - project: default - source: - repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git - targetRevision: main - path: argocd/manifests/immich - # Only deploy storage resources (PV/PVC/Ingress), not Helm values.yaml - directory: - include: "{pv-nfs.yaml,pvc.yaml,ingress-tailscale.yaml}" - destination: - server: https://kubernetes.default.svc - namespace: immich - syncPolicy: - syncOptions: - - CreateNamespace=true diff --git a/argocd/apps/immich.yaml b/argocd/apps/immich.yaml index 22b95cc..7efd263 100644 --- a/argocd/apps/immich.yaml +++ b/argocd/apps/immich.yaml @@ -1,15 +1,16 @@ # Immich - Self-hosted photo and video management # High-performance Google Photos/iCloud alternative with AI features # -# Chart mirrored from https://github.com/immich-app/immich-charts to forge +# Kustomize manifests in argocd/manifests/immich/ +# Components: server, machine-learning, valkey (Redis) # # Prerequisites: -# 1. Mirror immich-charts to forge: https://github.com/immich-app/immich-charts -# 2. Create immich namespace and secrets: +# 1. Create immich namespace and secrets: # kubectl create namespace immich -# op inject -i argocd/manifests/immich/secret-db.yaml.tpl | kubectl apply -f - -# 3. Create immich-pg database and user (see immich-pg app) -# 4. Mount photos directory from indri to minikube +# kubectl --context=minikube-indri create secret generic immich-db -n immich \ +# --from-literal=password="$(kubectl --context=minikube-indri -n databases get secret immich-pg-app -o jsonpath='{.data.password}' | base64 -d)" +# 2. Create immich-pg database and user (see immich-pg app) +# 3. NFS share on sifaka at /volume1/photos with read/write for indri apiVersion: argoproj.io/v1alpha1 kind: Application metadata: @@ -17,18 +18,10 @@ metadata: namespace: argocd spec: project: default - sources: - # Helm chart from forge mirror (SSH via egress) - - repoURL: ssh://forgejo@forge.ops.eblu.me:2222/mirrors/immich-charts.git - targetRevision: immich-0.10.3 - path: charts/immich - helm: - releaseName: immich - valueFiles: - - $values/argocd/manifests/immich/values.yaml - - repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git - targetRevision: main - ref: values + source: + repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git + targetRevision: main + path: argocd/manifests/immich destination: server: https://kubernetes.default.svc namespace: immich diff --git a/argocd/manifests/immich/README.md b/argocd/manifests/immich/README.md index 76e8ac9..a82a856 100644 --- a/argocd/manifests/immich/README.md +++ b/argocd/manifests/immich/README.md @@ -11,11 +11,18 @@ Self-hosted photo and video management solution with AI-powered search and face ## Deployment Order 1. Sync `blumeops-pg` (to get CloudNativePG operator if not already running) -2. Sync `immich-storage` (creates PV, PVC, and Tailscale Ingress) -3. Wait for `immich-pg` cluster to be healthy -4. Create secrets (see below) -5. Sync `immich` (deploys the Helm chart) -6. Run `mise run provision-indri -- --tags caddy` to update Caddy config +2. Wait for `immich-pg` cluster to be healthy +3. Create secrets (see below) +4. Sync `immich` (deploys all resources: storage, services, deployments) +5. Run `mise run provision-indri -- --tags caddy` to update Caddy config + +## Components + +| Component | Deployment | Service | Port | +|-----------|------------|---------|------| +| Server (web/API) | `immich-server` | `immich-server` | 2283 | +| Machine Learning | `immich-machine-learning` | `immich-machine-learning` | 3003 | +| Valkey (Redis) | `immich-valkey` | `immich-valkey` | 6379 | ## Secret Setup @@ -72,30 +79,37 @@ To import existing photos from iCloud sync on indri: └─────────────────┘ ``` -## Helm Values +## Version Management -The Helm chart is configured via `values.yaml`. Key settings: +Image versions are controlled via `kustomization.yaml`: -- `image.tag`: Immich version (update manually) -- `immich.persistence.library.existingClaim`: Points to `immich-library` PVC -- `machine-learning.enabled`: AI features for face/object recognition -- `valkey.enabled`: Redis cache included in chart +```yaml +images: + - name: ghcr.io/immich-app/immich-server + newTag: v2.6.3 + - name: ghcr.io/immich-app/immich-machine-learning + newTag: v2.6.3 + - name: docker.io/valkey/valkey + newTag: "8.1-alpine" +``` + +To upgrade, update `newTag` values and sync via ArgoCD. ## Troubleshooting ```bash # Check pods -kubectl -n immich get pods +kubectl --context=minikube-indri -n immich get pods # Check immich-pg cluster -kubectl -n databases get cluster immich-pg +kubectl --context=minikube-indri -n databases get cluster immich-pg # View server logs -kubectl -n immich logs -l app.kubernetes.io/name=immich-server +kubectl --context=minikube-indri -n immich logs -l app=immich,component=server # View ML logs -kubectl -n immich logs -l app.kubernetes.io/name=immich-machine-learning +kubectl --context=minikube-indri -n immich logs -l app=immich,component=machine-learning # Check PVC binding -kubectl -n immich get pvc +kubectl --context=minikube-indri -n immich get pvc ``` diff --git a/argocd/manifests/immich/deployment-ml.yaml b/argocd/manifests/immich/deployment-ml.yaml new file mode 100644 index 0000000..d55898d --- /dev/null +++ b/argocd/manifests/immich/deployment-ml.yaml @@ -0,0 +1,60 @@ +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: immich-machine-learning + namespace: immich +spec: + replicas: 1 + selector: + matchLabels: + app: immich + component: machine-learning + template: + metadata: + labels: + app: immich + component: machine-learning + spec: + containers: + - name: machine-learning + image: ghcr.io/immich-app/immich-machine-learning:kustomized + ports: + - name: http + containerPort: 3003 + env: + - name: TZ + value: "America/Los_Angeles" + - name: TRANSFORMERS_CACHE + value: /cache + - name: HF_XET_CACHE + value: /cache/huggingface-xet + - name: MPLCONFIGDIR + value: /cache/matplotlib-config + volumeMounts: + - name: cache + mountPath: /cache + livenessProbe: + httpGet: + path: /ping + port: 3003 + initialDelaySeconds: 30 + periodSeconds: 30 + timeoutSeconds: 5 + readinessProbe: + httpGet: + path: /ping + port: 3003 + initialDelaySeconds: 15 + periodSeconds: 10 + timeoutSeconds: 5 + resources: + requests: + memory: "512Mi" + cpu: "100m" + limits: + memory: "4Gi" + volumes: + - name: cache + persistentVolumeClaim: + claimName: immich-ml-cache diff --git a/argocd/manifests/immich/deployment-server.yaml b/argocd/manifests/immich/deployment-server.yaml new file mode 100644 index 0000000..56e920a --- /dev/null +++ b/argocd/manifests/immich/deployment-server.yaml @@ -0,0 +1,71 @@ +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: immich-server + namespace: immich +spec: + replicas: 1 + selector: + matchLabels: + app: immich + component: server + template: + metadata: + labels: + app: immich + component: server + spec: + containers: + - name: server + image: ghcr.io/immich-app/immich-server:kustomized + ports: + - name: http + containerPort: 2283 + env: + - name: TZ + value: "America/Los_Angeles" + - name: DB_HOSTNAME + value: "immich-pg-rw.databases.svc.cluster.local" + - name: DB_PORT + value: "5432" + - name: DB_DATABASE_NAME + value: "immich" + - name: DB_USERNAME + value: "immich" + - name: DB_PASSWORD + valueFrom: + secretKeyRef: + name: immich-db + key: password + - name: REDIS_HOSTNAME + value: immich-valkey + - name: IMMICH_MACHINE_LEARNING_URL + value: "http://immich-machine-learning:3003" + volumeMounts: + - name: library + mountPath: /usr/src/app/upload + livenessProbe: + httpGet: + path: /api/server/ping + port: 2283 + initialDelaySeconds: 30 + periodSeconds: 30 + timeoutSeconds: 5 + readinessProbe: + httpGet: + path: /api/server/ping + port: 2283 + initialDelaySeconds: 15 + periodSeconds: 10 + timeoutSeconds: 5 + resources: + requests: + memory: "256Mi" + cpu: "100m" + limits: + memory: "2Gi" + volumes: + - name: library + persistentVolumeClaim: + claimName: immich-library diff --git a/argocd/manifests/immich/deployment-valkey.yaml b/argocd/manifests/immich/deployment-valkey.yaml new file mode 100644 index 0000000..4034f94 --- /dev/null +++ b/argocd/manifests/immich/deployment-valkey.yaml @@ -0,0 +1,39 @@ +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: immich-valkey + namespace: immich +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app: immich + component: valkey + template: + metadata: + labels: + app: immich + component: valkey + spec: + containers: + - name: valkey + image: docker.io/valkey/valkey:kustomized + ports: + - name: redis + containerPort: 6379 + volumeMounts: + - name: data + mountPath: /data + resources: + requests: + memory: "64Mi" + cpu: "25m" + limits: + memory: "256Mi" + volumes: + - name: data + emptyDir: + sizeLimit: 1Gi diff --git a/argocd/manifests/immich/kustomization.yaml b/argocd/manifests/immich/kustomization.yaml index 1c1c6d8..c7c54e1 100644 --- a/argocd/manifests/immich/kustomization.yaml +++ b/argocd/manifests/immich/kustomization.yaml @@ -1,11 +1,22 @@ -# Immich non-Helm resources (storage) -# These must be deployed before the Helm chart +--- apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization - namespace: immich - resources: + - deployment-server.yaml + - deployment-ml.yaml + - deployment-valkey.yaml + - service.yaml + - service-ml.yaml + - service-valkey.yaml + - pvc-ml-cache.yaml - pv-nfs.yaml - pvc.yaml - ingress-tailscale.yaml +images: + - name: ghcr.io/immich-app/immich-server + newTag: v2.6.3 + - name: ghcr.io/immich-app/immich-machine-learning + newTag: v2.6.3 + - name: docker.io/valkey/valkey + newTag: "8.1-alpine" diff --git a/argocd/manifests/immich/pvc-ml-cache.yaml b/argocd/manifests/immich/pvc-ml-cache.yaml new file mode 100644 index 0000000..1e5a3d6 --- /dev/null +++ b/argocd/manifests/immich/pvc-ml-cache.yaml @@ -0,0 +1,12 @@ +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: immich-ml-cache + namespace: immich +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 10Gi diff --git a/argocd/manifests/immich/service-ml.yaml b/argocd/manifests/immich/service-ml.yaml new file mode 100644 index 0000000..9bb935a --- /dev/null +++ b/argocd/manifests/immich/service-ml.yaml @@ -0,0 +1,14 @@ +--- +apiVersion: v1 +kind: Service +metadata: + name: immich-machine-learning + namespace: immich +spec: + selector: + app: immich + component: machine-learning + ports: + - name: http + port: 3003 + targetPort: 3003 diff --git a/argocd/manifests/immich/service-valkey.yaml b/argocd/manifests/immich/service-valkey.yaml new file mode 100644 index 0000000..eb42d3b --- /dev/null +++ b/argocd/manifests/immich/service-valkey.yaml @@ -0,0 +1,14 @@ +--- +apiVersion: v1 +kind: Service +metadata: + name: immich-valkey + namespace: immich +spec: + selector: + app: immich + component: valkey + ports: + - name: redis + port: 6379 + targetPort: 6379 diff --git a/argocd/manifests/immich/service.yaml b/argocd/manifests/immich/service.yaml new file mode 100644 index 0000000..d35410f --- /dev/null +++ b/argocd/manifests/immich/service.yaml @@ -0,0 +1,14 @@ +--- +apiVersion: v1 +kind: Service +metadata: + name: immich-server + namespace: immich +spec: + selector: + app: immich + component: server + ports: + - name: http + port: 2283 + targetPort: 2283 diff --git a/argocd/manifests/immich/values.yaml b/argocd/manifests/immich/values.yaml deleted file mode 100644 index 7d23cb8..0000000 --- a/argocd/manifests/immich/values.yaml +++ /dev/null @@ -1,91 +0,0 @@ -# Immich Helm values for blumeops -# Chart: https://github.com/immich-app/immich-charts (v0.10.3) -# -# Immich requires: -# - PostgreSQL with VectorChord extension (separate immich-pg cluster) -# - Redis/Valkey (included in chart) -# - Library storage PVC (photos directory from sifaka NFS) - -# Shared environment variables -env: - TZ: "America/Los_Angeles" - -# Shared controller settings - image tag and DB connection -controllers: - main: - containers: - main: - image: - tag: v2.5.6 - env: - DB_HOSTNAME: "immich-pg-rw.databases.svc.cluster.local" - DB_PORT: "5432" - DB_DATABASE_NAME: "immich" - DB_USERNAME: "immich" - DB_PASSWORD: - valueFrom: - secretKeyRef: - name: immich-db - key: password - -# Immich server configuration -immich: - persistence: - library: - existingClaim: immich-library - -# Machine Learning service -machine-learning: - enabled: true - controllers: - main: - containers: - main: - resources: - requests: - memory: "512Mi" - cpu: "100m" - limits: - memory: "4Gi" - probes: - liveness: - spec: - timeoutSeconds: 5 - readiness: - spec: - timeoutSeconds: 5 - persistence: - cache: - enabled: true - type: persistentVolumeClaim - accessMode: ReadWriteOnce - size: 10Gi - -# Valkey (Redis fork) - included in chart -valkey: - enabled: true - persistence: - data: - enabled: true - type: emptyDir - size: 1Gi - -# Server configuration -server: - controllers: - main: - containers: - main: - resources: - requests: - memory: "256Mi" - cpu: "100m" - limits: - memory: "2Gi" - probes: - liveness: - spec: - timeoutSeconds: 5 - readiness: - spec: - timeoutSeconds: 5 diff --git a/docs/changelog.d/immich-kustomize-v2.6.3.infra.md b/docs/changelog.d/immich-kustomize-v2.6.3.infra.md new file mode 100644 index 0000000..4d42094 --- /dev/null +++ b/docs/changelog.d/immich-kustomize-v2.6.3.infra.md @@ -0,0 +1 @@ +Migrate Immich from Helm chart to kustomize manifests and upgrade from v2.5.6 to v2.6.3 diff --git a/docs/explanation/no-helm-policy.md b/docs/explanation/no-helm-policy.md new file mode 100644 index 0000000..73f6835 --- /dev/null +++ b/docs/explanation/no-helm-policy.md @@ -0,0 +1,46 @@ +--- +title: No Helm Policy +modified: 2026-04-04 +tags: + - explanation + - kubernetes +--- + +# No Helm Policy + +BlumeOps avoids Helm charts as a deployment mechanism. Plain kustomize manifests are the standard for all services. + +## Rationale + +Helm templates add a layer of abstraction that works against the simplicity of Kubernetes YAML manifests. Go templates embedded in YAML are hard to read, hard to diff, and hard to reason about. A manifest should be a manifest — not a program that generates one. + +Kustomize overlays preserve the readability of plain YAML while providing the composition and patching features needed for environment-specific configuration. Version bumps are a one-line `newTag` edit in `kustomization.yaml`, and `kubectl diff` shows exactly what will change. + +## Current State + +All services in blumeops use kustomize manifests except: + +- **1Password Connect** — still deployed via Helm chart (`connect-helm-charts v2.3.0`). Migration is a future goal. + +## Migration History + +Services previously deployed via Helm that have been migrated to kustomize: + +| Service | Migrated | Notes | +|---------|----------|-------| +| Grafana | 2026-02 | Converted during v12.x upgrade | +| CloudNative-PG | 2026-02 | Switched to upstream release manifest via forge mirror | +| External Secrets | 2026-03 | Static manifests rendered from chart | +| Homepage | 2025-12 | Replaced chart with plain manifests | +| Immich | 2026-04 | Converted during v2.6.3 upgrade | + +## Guidelines + +- **Do not introduce new Helm chart dependencies.** When deploying a new service, write kustomize manifests directly — even if the upstream project provides a Helm chart. The chart's `helm template` output is a fine starting point for writing those manifests. +- **When upgrading a Helm-based service**, consider whether it's a good time to migrate off Helm as part of the upgrade. +- **Upstream manifests** can be referenced directly in `kustomization.yaml` resources (like ArgoCD and Tailscale operator do) or applied via ArgoCD's `directory.include` (like CloudNative-PG). Both avoid Helm. + +## Related + +- [[review-services]] — Service review process +- [[architecture]] — Overall infrastructure design diff --git a/docs/how-to/knowledgebase/review-services.md b/docs/how-to/knowledgebase/review-services.md index 8a2fc0d..de0a970 100644 --- a/docs/how-to/knowledgebase/review-services.md +++ b/docs/how-to/knowledgebase/review-services.md @@ -118,8 +118,13 @@ After reviewing, edit `service-versions.yaml` (repo root) and update the service Commit this change alongside any upgrades you make during the review. +## Deployment Policy + +BlumeOps uses kustomize manifests for all services. Helm charts should not be introduced for new services. See [[no-helm-policy]] for rationale and migration history. + ## Related +- [[no-helm-policy]] - Why blumeops avoids Helm charts - [[review-documentation]] - Periodically review documentation cards - [[deploy-k8s-service]] - Deploy changes to Kubernetes services - [[build-container-image]] - Build and release custom container images diff --git a/docs/reference/kubernetes/apps.md b/docs/reference/kubernetes/apps.md index 02215fc..e162c7a 100644 --- a/docs/reference/kubernetes/apps.md +++ b/docs/reference/kubernetes/apps.md @@ -24,9 +24,9 @@ Registry of all applications deployed via [[argocd]]. | `blumeops-pg` | databases | `argocd/manifests/databases/` | [[postgresql]] | | `prometheus` | monitoring | `argocd/manifests/prometheus/` | [[prometheus]] | | `loki` | monitoring | `argocd/manifests/loki/` | [[loki]] | -| `grafana` | monitoring | Helm chart (forge mirror) | [[grafana]] | +| `grafana` | monitoring | `argocd/manifests/grafana/` | [[grafana]] | | `grafana-config` | monitoring | `argocd/manifests/grafana-config/` | [[grafana]] | -| `immich` | immich | Helm chart | [[immich]] | +| `immich` | immich | `argocd/manifests/immich/` | [[immich]] | | `tempo` | monitoring | `argocd/manifests/tempo/` | [[tempo]] | | `alloy-k8s` | alloy | `argocd/manifests/alloy-k8s/` | [[alloy|Alloy]] | | `alloy-tracing-ringtail` | alloy | `argocd/manifests/alloy-tracing-ringtail/` | [[alloy|Alloy]] (eBPF tracing) | diff --git a/docs/reference/services/immich.md b/docs/reference/services/immich.md index 740dfa4..063deac 100644 --- a/docs/reference/services/immich.md +++ b/docs/reference/services/immich.md @@ -1,6 +1,6 @@ --- title: Immich -modified: 2026-02-07 +modified: 2026-04-04 last-reviewed: 2026-03-23 tags: - service @@ -17,7 +17,7 @@ Self-hosted photo and video management. |----------|-------| | **URL** | https://photos.ops.eblu.me | | **Namespace** | `immich` | -| **Deployment** | Helm chart (k8s) | +| **Deployment** | Kustomize (k8s) | | **Database** | [[postgresql]] (CNPG) | | **Storage** | [[sifaka|Sifaka]] photos volume | diff --git a/mise-tasks/service-review b/mise-tasks/service-review index 1bc2ae4..28b6dc4 100755 --- a/mise-tasks/service-review +++ b/mise-tasks/service-review @@ -169,7 +169,7 @@ def main( if svc_type == "argocd": checklist_parts += [ "\n[bold]ArgoCD Deployment:[/bold]\n", - "• Update image tag or Helm chart version in argocd/manifests/\n", + "• Update image tag in argocd/manifests//kustomization.yaml\n", f"• Verify sync status: argocd app get {top_svc['name']}\n", ] elif svc_type == "ansible": diff --git a/service-versions.yaml b/service-versions.yaml index f3a8394..3ad22ff 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -120,10 +120,10 @@ services: - name: immich type: argocd - last-reviewed: 2026-02-25 - current-version: "v2.5.6" + last-reviewed: 2026-04-04 + current-version: "v2.6.3" upstream-source: https://github.com/immich-app/immich/releases - notes: Deployed via Helm chart + notes: Kustomize manifests with upstream images - name: external-secrets type: argocd From 6e06efa6d0d21e735c71b21fa1c9accfd8866b8d Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sat, 4 Apr 2026 09:50:53 -0700 Subject: [PATCH 195/430] Add AI-drafted disclaimer to no-helm-policy explanation doc Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/explanation/no-helm-policy.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/explanation/no-helm-policy.md b/docs/explanation/no-helm-policy.md index 73f6835..ea6c025 100644 --- a/docs/explanation/no-helm-policy.md +++ b/docs/explanation/no-helm-policy.md @@ -8,6 +8,8 @@ tags: # No Helm Policy +> **Note:** This article was drafted by AI and reviewed by Erich. I plan to rewrite all explanatory content in my own words - these serve as placeholders to establish the documentation structure. + BlumeOps avoids Helm charts as a deployment mechanism. Plain kustomize manifests are the standard for all services. ## Rationale From 3c819cf16e317b1d571102f57604ea34eec0f378 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sat, 4 Apr 2026 09:53:48 -0700 Subject: [PATCH 196/430] =?UTF-8?q?Fix=20Homepage=20migration=20date:=2020?= =?UTF-8?q?25-12=20=E2=86=92=202026-02=20(per=20git=20history)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/explanation/no-helm-policy.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/explanation/no-helm-policy.md b/docs/explanation/no-helm-policy.md index ea6c025..ea617f0 100644 --- a/docs/explanation/no-helm-policy.md +++ b/docs/explanation/no-helm-policy.md @@ -33,7 +33,7 @@ Services previously deployed via Helm that have been migrated to kustomize: | Grafana | 2026-02 | Converted during v12.x upgrade | | CloudNative-PG | 2026-02 | Switched to upstream release manifest via forge mirror | | External Secrets | 2026-03 | Static manifests rendered from chart | -| Homepage | 2025-12 | Replaced chart with plain manifests | +| Homepage | 2026-02 | Replaced chart with plain manifests | | Immich | 2026-04 | Converted during v2.6.3 upgrade | ## Guidelines From 6cab5091eadd1be3e0c866959da21759eb52dfb3 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sat, 4 Apr 2026 12:04:25 -0700 Subject: [PATCH 197/430] Add storage-provisioner health check to minikube Ansible role The storage-provisioner is a bare Pod with no controller. If the node restarts via Docker Desktop (rather than `minikube start`), kubelet restores static pods but bare pods are lost. Detect this and re-run `minikube start` to restore addons. Co-Authored-By: Claude Opus 4.6 (1M context) --- ansible/roles/minikube/tasks/main.yml | 31 +++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/ansible/roles/minikube/tasks/main.yml b/ansible/roles/minikube/tasks/main.yml index c09d420..e79f4de 100644 --- a/ansible/roles/minikube/tasks/main.yml +++ b/ansible/roles/minikube/tasks/main.yml @@ -77,6 +77,37 @@ msg: "WARNING: minikube may not have started properly. Run 'minikube start' manually on indri if needed. Status: {{ minikube_final_status.stdout | default('unknown') }}" when: minikube_final_status.rc != 0 +# The storage-provisioner is a bare Pod (no controller). If the node restarts +# via Docker Desktop rather than `minikube start`, kubelet brings back static +# pods (apiserver, etcd) but bare pods like storage-provisioner are lost. +# `minikube start` on a running cluster is safe and re-applies all addons. +- name: Check storage-provisioner pod is running + ansible.builtin.command: + cmd: kubectl -n kube-system get pod storage-provisioner -o jsonpath='{.status.phase}' + register: minikube_storage_provisioner + changed_when: false + failed_when: false + when: minikube_final_status.rc == 0 + +- name: Re-run minikube start to restore addons + ansible.builtin.command: + cmd: > + minikube start + --driver={{ minikube_driver }} + --container-runtime={{ minikube_container_runtime }} + --cpus={{ minikube_cpus }} + --memory={{ minikube_memory }} + --disk-size={{ minikube_disk_size }} + {% for name in minikube_apiserver_names %} + --apiserver-names={{ name }} + {% endfor %} + --apiserver-port={{ minikube_apiserver_port }} + --listen-address={{ minikube_listen_address }} + when: + - minikube_final_status.rc == 0 + - minikube_storage_provisioner.stdout | default('') != 'Running' + changed_when: true + # Configure containerd to use zot registry as pull-through cache # With docker driver, use host.minikube.internal to reach the host # Zot runs on indri:5050 and caches images from docker.io, ghcr.io, quay.io From 5597e02467aa46258328b546ea9dea6a99382e62 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sat, 4 Apr 2026 12:12:33 -0700 Subject: [PATCH 198/430] =?UTF-8?q?Fix=20Homepage=20pod-selector=20for=20I?= =?UTF-8?q?mmich=20(Helm=20labels=20=E2=86=92=20kustomize=20labels)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- argocd/manifests/immich/ingress-tailscale.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/argocd/manifests/immich/ingress-tailscale.yaml b/argocd/manifests/immich/ingress-tailscale.yaml index 777bfb6..59a4c05 100644 --- a/argocd/manifests/immich/ingress-tailscale.yaml +++ b/argocd/manifests/immich/ingress-tailscale.yaml @@ -15,7 +15,7 @@ metadata: gethomepage.dev/icon: "immich.png" gethomepage.dev/description: "Photo management" gethomepage.dev/href: "https://photos.ops.eblu.me" - gethomepage.dev/pod-selector: "app.kubernetes.io/name=server" + gethomepage.dev/pod-selector: "app=immich,component=server" # TODO: Add Immich widget - requires API key from Account Settings > API Keys # See: https://gethomepage.dev/widgets/services/immich/ # gethomepage.dev/widget.type: "immich" From f9397b7fa051078e3a3c2cf0b551fb2904bdae92 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sun, 5 Apr 2026 21:22:00 -0700 Subject: [PATCH 199/430] Review core-services tutorial: add SSH rationale, runner example, TODO notice Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/tutorials/replication/core-services.md | 50 ++++++++++++++++++--- 1 file changed, 44 insertions(+), 6 deletions(-) diff --git a/docs/tutorials/replication/core-services.md b/docs/tutorials/replication/core-services.md index 6657eab..12c79e9 100644 --- a/docs/tutorials/replication/core-services.md +++ b/docs/tutorials/replication/core-services.md @@ -1,6 +1,7 @@ --- title: Core Services modified: 2026-02-07 +last-reviewed: 2026-04-05 tags: - tutorials - replication @@ -9,6 +10,8 @@ tags: # Core Services Setup +> **TODO:** This tutorial is light on specifics and should be expanded. In its current form it serves as a general sketch of how BlumeOps got started — every component mentioned here (Forgejo, Zot, CI runners, etc.) receives much deeper treatment elsewhere in the blumeops codebase and documentation. + > **Audiences:** Replicator > > **Prerequisites:** [[tailscale-setup|Tailscale Setup]] @@ -46,11 +49,13 @@ Key configuration points: ## Step 2: Configure SSH Access +Forgejo runs its own SSH server on a non-standard port (e.g., 2222) to avoid conflicting with the host's SSH daemon on port 22. Later, [[caddy]] with the L4 plugin can map port 22 to 2222 using a DNS name (e.g., `forge.ops.eblu.me`), so git clients don't need to specify a port. + Set up SSH for git operations: ```bash # Add your SSH key to Forgejo via the web UI -# Then test access: +# Then test access (using the non-standard port directly): ssh -T git@your-server.tailnet.ts.net -p 2222 ``` @@ -75,13 +80,46 @@ your-repo/ ## Step 4: Set Up CI/CD Runner (Optional) -Forgejo Actions runs workflows defined in `.forgejo/workflows/`. To use it: +Forgejo Actions runs workflows defined in `.forgejo/workflows/`. The simplest setup is a native runner that executes jobs directly on the host (no Docker required): -1. Register a runner on your server -2. Configure runner to access your build tools -3. Create workflow files for builds and deployments +1. Download the `forgejo-runner` binary from [code.forgejo.org](https://code.forgejo.org/forgejo/runner/releases): -BlumeOps runs a Forgejo runner in Kubernetes - see [[forgejo]] for details. +```bash +# macOS ARM64 example +curl -L -o forgejo-runner \ + https://code.forgejo.org/forgejo/runner/releases/download/v6.3.1/forgejo-runner-6.3.1-darwin-arm64 +chmod +x forgejo-runner +``` + +2. Generate a registration token from the Forgejo admin UI (Site Administration → Actions → Runners) + +3. Register and start the runner using the `host` scheme (no container isolation): + +```bash +forgejo-runner register \ + --instance https://your-forge.tailnet.ts.net \ + --token \ + --name local-runner \ + --labels "native:host" \ + --no-interactive + +forgejo-runner daemon +``` + +4. Reference the label in your workflows: + +```yaml +# .forgejo/workflows/example.yaml +jobs: + build: + runs-on: native + steps: + - run: echo "Running on bare metal" +``` + +> **Note:** Host mode has no isolation — jobs run as whatever user runs `forgejo-runner`. This is fine for a personal setup with trusted repos. Use a `launchd` plist or `brew services` wrapper to keep it running. + +BlumeOps runs its Forgejo runner inside Kubernetes instead — see [[forgejo]] for that approach. ## Step 5: Container Registry (Optional) From facb803010bc7548d71c122204c5862699c8732b Mon Sep 17 00:00:00 2001 From: Forgejo Actions Date: Sun, 5 Apr 2026 21:24:25 -0700 Subject: [PATCH 200/430] Update docs release to v1.15.3 - Built changelog from towncrier fragments [skip ci] --- CHANGELOG.md | 15 +++++++++++++++ argocd/manifests/docs/deployment.yaml | 2 +- .../+prowler-registry-fix-upstream.doc.md | 1 - .../+review-single-user-cluster.doc.md | 1 - docs/changelog.d/immich-kustomize-v2.6.3.infra.md | 1 - docs/changelog.d/local-tempo-container.infra.md | 1 - .../pin-nixos-service-versions.infra.md | 1 - docs/changelog.d/upgrade-grafana-12.4.2.infra.md | 1 - 8 files changed, 16 insertions(+), 7 deletions(-) delete mode 100644 docs/changelog.d/+prowler-registry-fix-upstream.doc.md delete mode 100644 docs/changelog.d/+review-single-user-cluster.doc.md delete mode 100644 docs/changelog.d/immich-kustomize-v2.6.3.infra.md delete mode 100644 docs/changelog.d/local-tempo-container.infra.md delete mode 100644 docs/changelog.d/pin-nixos-service-versions.infra.md delete mode 100644 docs/changelog.d/upgrade-grafana-12.4.2.infra.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 78c0d12..c2953cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [v1.15.3] - 2026-04-05 + +### Infrastructure + +- Build Tempo container from source via forge mirror; bump 2.10.1 → 2.10.3 +- Pin NixOS service versions (forgejo-runner, snowflake, k3s) via `nixpkgs-services` overlay in ringtail flake, preventing silent upgrades from `nix flake update`. Add k3s and minikube to service-versions.yaml tracking. Fix stale nix-container-builder version (was 12.6.4, actually running 12.7.2). +- Migrate Immich from Helm chart to kustomize manifests and upgrade from v2.5.6 to v2.6.3 +- Upgrade Grafana from 12.3.3 to 12.4.2 — patches 7 CVEs including an unauthenticated DoS (CVE-2026-27880). + +### Documentation + +- First compensating control review: verified `single-user-cluster` still in effect. Added aspirational how-to card for PCI DSS evidence collection. +- Prowler `--registry` fix merged upstream (PR #10470); initContainer workaround documented as pending release. + + ## [v1.15.2] - 2026-03-30 ### Features diff --git a/argocd/manifests/docs/deployment.yaml b/argocd/manifests/docs/deployment.yaml index 3224a23..82140db 100644 --- a/argocd/manifests/docs/deployment.yaml +++ b/argocd/manifests/docs/deployment.yaml @@ -30,7 +30,7 @@ spec: name: http env: - name: DOCS_RELEASE_URL - value: "https://forge.eblu.me/eblume/blumeops/releases/download/v1.15.2/docs-v1.15.2.tar.gz" + value: "https://forge.eblu.me/eblume/blumeops/releases/download/v1.15.3/docs-v1.15.3.tar.gz" resources: requests: memory: "64Mi" diff --git a/docs/changelog.d/+prowler-registry-fix-upstream.doc.md b/docs/changelog.d/+prowler-registry-fix-upstream.doc.md deleted file mode 100644 index 360e460..0000000 --- a/docs/changelog.d/+prowler-registry-fix-upstream.doc.md +++ /dev/null @@ -1 +0,0 @@ -Prowler `--registry` fix merged upstream (PR #10470); initContainer workaround documented as pending release. diff --git a/docs/changelog.d/+review-single-user-cluster.doc.md b/docs/changelog.d/+review-single-user-cluster.doc.md deleted file mode 100644 index eddd1d4..0000000 --- a/docs/changelog.d/+review-single-user-cluster.doc.md +++ /dev/null @@ -1 +0,0 @@ -First compensating control review: verified `single-user-cluster` still in effect. Added aspirational how-to card for PCI DSS evidence collection. diff --git a/docs/changelog.d/immich-kustomize-v2.6.3.infra.md b/docs/changelog.d/immich-kustomize-v2.6.3.infra.md deleted file mode 100644 index 4d42094..0000000 --- a/docs/changelog.d/immich-kustomize-v2.6.3.infra.md +++ /dev/null @@ -1 +0,0 @@ -Migrate Immich from Helm chart to kustomize manifests and upgrade from v2.5.6 to v2.6.3 diff --git a/docs/changelog.d/local-tempo-container.infra.md b/docs/changelog.d/local-tempo-container.infra.md deleted file mode 100644 index 3771c24..0000000 --- a/docs/changelog.d/local-tempo-container.infra.md +++ /dev/null @@ -1 +0,0 @@ -Build Tempo container from source via forge mirror; bump 2.10.1 → 2.10.3 diff --git a/docs/changelog.d/pin-nixos-service-versions.infra.md b/docs/changelog.d/pin-nixos-service-versions.infra.md deleted file mode 100644 index 92bc07c..0000000 --- a/docs/changelog.d/pin-nixos-service-versions.infra.md +++ /dev/null @@ -1 +0,0 @@ -Pin NixOS service versions (forgejo-runner, snowflake, k3s) via `nixpkgs-services` overlay in ringtail flake, preventing silent upgrades from `nix flake update`. Add k3s and minikube to service-versions.yaml tracking. Fix stale nix-container-builder version (was 12.6.4, actually running 12.7.2). diff --git a/docs/changelog.d/upgrade-grafana-12.4.2.infra.md b/docs/changelog.d/upgrade-grafana-12.4.2.infra.md deleted file mode 100644 index 11bba26..0000000 --- a/docs/changelog.d/upgrade-grafana-12.4.2.infra.md +++ /dev/null @@ -1 +0,0 @@ -Upgrade Grafana from 12.3.3 to 12.4.2 — patches 7 CVEs including an unauthenticated DoS (CVE-2026-27880). From c7e5af6d5188bd5484186d409add13d32307e516 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 6 Apr 2026 07:31:40 -0700 Subject: [PATCH 201/430] =?UTF-8?q?Migrate=201Password=20Connect=20from=20?= =?UTF-8?q?Helm=20to=20kustomize=20(1.8.1=20=E2=86=92=201.8.2)=20(#326)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Renders manifests from `connect-helm-charts v2.4.1` as plain kustomize (deployment + service) - Bumps 1Password Connect from 1.8.1 → 1.8.2 - Completes the no-helm-policy migration — all services now use kustomize - Retains all production hardening from the Helm chart (securityContext, runAsNonRoot, drop ALL, seccomp, resource limits) ## Changes - **New:** `deployment.yaml`, `service.yaml`, `kustomization.yaml` in `argocd/manifests/1password-connect/` - **Rewritten:** Both ArgoCD app definitions (indri + ringtail) — single source kustomize instead of multi-source Helm - **Deleted:** `values.yaml` (Helm values no longer needed) - **Updated:** `no-helm-policy.md`, `service-versions.yaml`, `README.md` ## Deployment plan 1. Sync `apps` app to pick up the new app definitions 2. `argocd app set 1password-connect --revision 1password-connect-kustomize` 3. `argocd app sync 1password-connect` — verify on indri 4. Repeat for ringtail 5. After merge: reset revision to main, re-sync both ## Test plan - [ ] `kubectl kustomize` renders cleanly (verified locally) - [ ] ArgoCD diff shows expected changes (Helm labels removed, images bumped) - [ ] Pods come up healthy on indri - [ ] External Secrets still resolves 1Password items - [ ] Repeat on ringtail Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/326 --- argocd/apps/1password-connect-ringtail.yaml | 17 +-- argocd/apps/1password-connect.yaml | 17 +-- argocd/manifests/1password-connect/README.md | 9 ++ .../1password-connect/deployment.yaml | 131 ++++++++++++++++++ .../1password-connect/kustomization.yaml | 15 ++ .../manifests/1password-connect/service.yaml | 18 +++ .../manifests/1password-connect/values.yaml | 33 ----- .../1password-connect-kustomize.infra.md | 1 + docs/explanation/no-helm-policy.md | 7 +- service-versions.yaml | 6 +- 10 files changed, 190 insertions(+), 64 deletions(-) create mode 100644 argocd/manifests/1password-connect/deployment.yaml create mode 100644 argocd/manifests/1password-connect/kustomization.yaml create mode 100644 argocd/manifests/1password-connect/service.yaml delete mode 100644 argocd/manifests/1password-connect/values.yaml create mode 100644 docs/changelog.d/1password-connect-kustomize.infra.md diff --git a/argocd/apps/1password-connect-ringtail.yaml b/argocd/apps/1password-connect-ringtail.yaml index 620bfab..60c6e43 100644 --- a/argocd/apps/1password-connect-ringtail.yaml +++ b/argocd/apps/1password-connect-ringtail.yaml @@ -1,5 +1,5 @@ # 1Password Connect for ringtail k3s cluster -# Same chart/values as indri, different destination +# Same manifests as indri, different destination # # Prerequisites: # 1. Bootstrap secrets via ansible (provision-ringtail creates 1password namespace, @@ -13,17 +13,10 @@ metadata: namespace: argocd spec: project: default - sources: - - repoURL: ssh://forgejo@forge.ops.eblu.me:2222/mirrors/connect-helm-charts.git - targetRevision: connect-2.3.0 - path: charts/connect - helm: - releaseName: onepassword-connect - valueFiles: - - $values/argocd/manifests/1password-connect/values.yaml - - repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git - targetRevision: main - ref: values + source: + repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git + targetRevision: main + path: argocd/manifests/1password-connect destination: server: https://ringtail.tail8d86e.ts.net:6443 namespace: 1password diff --git a/argocd/apps/1password-connect.yaml b/argocd/apps/1password-connect.yaml index 4831868..ba0a474 100644 --- a/argocd/apps/1password-connect.yaml +++ b/argocd/apps/1password-connect.yaml @@ -1,7 +1,7 @@ # 1Password Connect - Secrets Automation Server # Provides REST API access to 1Password vault items for External Secrets Operator # -# Chart mirrored from https://github.com/1Password/connect-helm-charts +# Manifests rendered from connect-helm-charts v2.4.1, maintained as plain kustomize. # # Prerequisites (one-time setup): # 1. Create Connect server: op connect server create blumeops --vaults blumeops @@ -19,17 +19,10 @@ metadata: namespace: argocd spec: project: default - sources: - - repoURL: ssh://forgejo@forge.ops.eblu.me:2222/mirrors/connect-helm-charts.git - targetRevision: connect-2.3.0 - path: charts/connect - helm: - releaseName: onepassword-connect - valueFiles: - - $values/argocd/manifests/1password-connect/values.yaml - - repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git - targetRevision: main - ref: values + source: + repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git + targetRevision: main + path: argocd/manifests/1password-connect destination: server: https://kubernetes.default.svc namespace: 1password diff --git a/argocd/manifests/1password-connect/README.md b/argocd/manifests/1password-connect/README.md index 29e6748..26989f3 100644 --- a/argocd/manifests/1password-connect/README.md +++ b/argocd/manifests/1password-connect/README.md @@ -55,6 +55,15 @@ op inject -i argocd/manifests/1password-connect/secret-credentials.yaml.tpl | \ kubectl --context=minikube-indri apply -f - ``` +## Version Management + +Image versions are pinned in `kustomization.yaml` via `images[].newTag`. To upgrade: + +1. Update `newTag` for both `1password/connect-api` and `1password/connect-sync` +2. Sync via ArgoCD + +The manifests were rendered from `connect-helm-charts v2.4.1` and are maintained as plain kustomize. + ## Deployment ```bash diff --git a/argocd/manifests/1password-connect/deployment.yaml b/argocd/manifests/1password-connect/deployment.yaml new file mode 100644 index 0000000..3296e19 --- /dev/null +++ b/argocd/manifests/1password-connect/deployment.yaml @@ -0,0 +1,131 @@ +# Rendered from connect-helm-charts v2.4.1 with blumeops values, then de-Helmed. +# Image tags managed by kustomization.yaml images[] — do not edit here. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: onepassword-connect + namespace: 1password + labels: + app.kubernetes.io/component: connect + app.kubernetes.io/name: connect +spec: + replicas: 1 + selector: + matchLabels: + app: onepassword-connect + template: + metadata: + labels: + app: onepassword-connect + app.kubernetes.io/component: connect + spec: + securityContext: + fsGroup: 999 + runAsGroup: 999 + runAsNonRoot: true + runAsUser: 999 + seccompProfile: + type: RuntimeDefault + volumes: + - name: shared-data + emptyDir: {} + - name: credentials + secret: + secretName: op-credentials + items: + - key: 1password-credentials.json + path: 1password-credentials.json + containers: + - name: connect-api + image: 1password/connect-api:kustomized + imagePullPolicy: IfNotPresent + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + resources: + limits: + cpu: 200m + memory: 256Mi + requests: + cpu: 50m + memory: 64Mi + env: + - name: OP_SESSION + value: /home/opuser/.op/1password-credentials.json + - name: OP_BUS_PORT + value: "11220" + - name: OP_BUS_PEERS + value: localhost:11221 + - name: OP_HTTP_PORT + value: "8080" + - name: OP_LOG_LEVEL + value: "info" + readinessProbe: + httpGet: + path: /health + scheme: HTTP + port: 8080 + initialDelaySeconds: 15 + livenessProbe: + httpGet: + path: /heartbeat + scheme: HTTP + port: 8080 + failureThreshold: 3 + periodSeconds: 30 + initialDelaySeconds: 15 + volumeMounts: + - mountPath: /home/opuser/.op/data + name: shared-data + - name: credentials + mountPath: /home/opuser/.op/1password-credentials.json + subPath: 1password-credentials.json + - name: connect-sync + image: 1password/connect-sync:kustomized + imagePullPolicy: IfNotPresent + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + resources: + limits: + cpu: 200m + memory: 256Mi + requests: + cpu: 50m + memory: 64Mi + env: + - name: OP_HTTP_PORT + value: "8081" + - name: OP_SESSION + value: /home/opuser/.op/1password-credentials.json + - name: OP_BUS_PORT + value: "11221" + - name: OP_BUS_PEERS + value: localhost:11220 + - name: OP_LOG_LEVEL + value: "info" + readinessProbe: + httpGet: + path: /health + port: 8081 + initialDelaySeconds: 15 + livenessProbe: + httpGet: + path: /heartbeat + port: 8081 + scheme: HTTP + failureThreshold: 3 + periodSeconds: 30 + initialDelaySeconds: 15 + volumeMounts: + - mountPath: /home/opuser/.op/data + name: shared-data + - name: credentials + mountPath: /home/opuser/.op/1password-credentials.json + subPath: 1password-credentials.json diff --git a/argocd/manifests/1password-connect/kustomization.yaml b/argocd/manifests/1password-connect/kustomization.yaml new file mode 100644 index 0000000..d6da84d --- /dev/null +++ b/argocd/manifests/1password-connect/kustomization.yaml @@ -0,0 +1,15 @@ +--- +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: 1password + +resources: + - deployment.yaml + - service.yaml + +images: + - name: 1password/connect-api + newTag: "1.8.2" + - name: 1password/connect-sync + newTag: "1.8.2" diff --git a/argocd/manifests/1password-connect/service.yaml b/argocd/manifests/1password-connect/service.yaml new file mode 100644 index 0000000..1ea8a7e --- /dev/null +++ b/argocd/manifests/1password-connect/service.yaml @@ -0,0 +1,18 @@ +# Rendered from connect-helm-charts v2.4.1, then de-Helmed. +apiVersion: v1 +kind: Service +metadata: + name: onepassword-connect + namespace: 1password + labels: + app.kubernetes.io/component: connect + app.kubernetes.io/name: connect +spec: + type: ClusterIP + selector: + app: onepassword-connect + ports: + - port: 8081 + name: connect-sync + - port: 8080 + name: connect-api diff --git a/argocd/manifests/1password-connect/values.yaml b/argocd/manifests/1password-connect/values.yaml deleted file mode 100644 index 443290b..0000000 --- a/argocd/manifests/1password-connect/values.yaml +++ /dev/null @@ -1,33 +0,0 @@ -# 1Password Connect Helm values for blumeops -# Chart: https://github.com/1Password/connect-helm-charts -# -# The credentials are bootstrapped manually via secret-credentials.yaml.tpl -# before deploying this chart. - -connect: - # Use pre-created credentials secret (from bootstrap) - credentialsKey: 1password-credentials.json - credentialsName: op-credentials - - # Resource limits for minikube - api: - resources: - requests: - memory: "64Mi" - cpu: "50m" - limits: - memory: "256Mi" - cpu: "200m" - - sync: - resources: - requests: - memory: "64Mi" - cpu: "50m" - limits: - memory: "256Mi" - cpu: "200m" - -# We don't use the 1Password Operator (using External Secrets instead) -operator: - create: false diff --git a/docs/changelog.d/1password-connect-kustomize.infra.md b/docs/changelog.d/1password-connect-kustomize.infra.md new file mode 100644 index 0000000..298eba4 --- /dev/null +++ b/docs/changelog.d/1password-connect-kustomize.infra.md @@ -0,0 +1 @@ +Migrate 1Password Connect from Helm to kustomize (1.8.1 → 1.8.2), completing the no-helm-policy migration. diff --git a/docs/explanation/no-helm-policy.md b/docs/explanation/no-helm-policy.md index ea617f0..760c234 100644 --- a/docs/explanation/no-helm-policy.md +++ b/docs/explanation/no-helm-policy.md @@ -1,6 +1,6 @@ --- title: No Helm Policy -modified: 2026-04-04 +modified: 2026-04-06 tags: - explanation - kubernetes @@ -20,9 +20,7 @@ Kustomize overlays preserve the readability of plain YAML while providing the co ## Current State -All services in blumeops use kustomize manifests except: - -- **1Password Connect** — still deployed via Helm chart (`connect-helm-charts v2.3.0`). Migration is a future goal. +All services in blumeops use kustomize manifests. The last Helm dependency (1Password Connect) was migrated in 2026-04. ## Migration History @@ -35,6 +33,7 @@ Services previously deployed via Helm that have been migrated to kustomize: | External Secrets | 2026-03 | Static manifests rendered from chart | | Homepage | 2026-02 | Replaced chart with plain manifests | | Immich | 2026-04 | Converted during v2.6.3 upgrade | +| 1Password Connect | 2026-04 | Rendered from chart v2.4.1, bumped to 1.8.2 | ## Guidelines diff --git a/service-versions.yaml b/service-versions.yaml index 3ad22ff..b8f62ac 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -134,10 +134,10 @@ services: - name: 1password-connect type: argocd - last-reviewed: 2026-02-26 - current-version: "1.8.1" + last-reviewed: 2026-04-06 + current-version: "1.8.2" upstream-source: https://hub.docker.com/r/1password/connect-api/tags - notes: Deployed via Helm chart (chart v2.3.0) + notes: Kustomize manifests rendered from connect-helm-charts v2.4.1 - name: argocd type: argocd From f42fa2d55840140d14e568bb2bacee507f63b8b1 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 6 Apr 2026 07:37:21 -0700 Subject: [PATCH 202/430] Remove stale Helm chart mirror references from forgejo docs All Helm chart mirrors (grafana-helm-charts, connect-helm-charts, cloudnative-pg-charts) have been deleted from forge. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/reference/services/forgejo.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/reference/services/forgejo.md b/docs/reference/services/forgejo.md index 635f479..ad64cf4 100644 --- a/docs/reference/services/forgejo.md +++ b/docs/reference/services/forgejo.md @@ -63,7 +63,6 @@ After building, run `mise run provision-indri -- --tags forgejo` to deploy the c | `eblume/blumeops` | Infrastructure as code (primary) | | `eblume/alloy` | Grafana Alloy fork (CGO build) | | `eblume/tesla_auth` | Tesla OAuth helper | -| Helm chart mirrors | cloudnative-pg-charts, grafana-helm-charts | ## CI/CD (Forgejo Actions) From 0eaf8680fd5c953a5e101cf773624c76207f91db Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 6 Apr 2026 07:52:35 -0700 Subject: [PATCH 203/430] Rewrite observability stack tutorial to match actual practices Replace generic Helm install instructions with kustomize/ArgoCD patterns that reflect how BlumeOps actually deploys Prometheus, Loki, Grafana, and Alloy. Fix "BluemeOps" typos, document Alloy as a core (not optional) component, remove hardcoded admin password, add proper prerequisites and cross-references. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../+rewrite-observability-tutorial.doc.md | 1 + .../replication/observability-stack.md | 307 +++++++++--------- 2 files changed, 158 insertions(+), 150 deletions(-) create mode 100644 docs/changelog.d/+rewrite-observability-tutorial.doc.md diff --git a/docs/changelog.d/+rewrite-observability-tutorial.doc.md b/docs/changelog.d/+rewrite-observability-tutorial.doc.md new file mode 100644 index 0000000..5b727c2 --- /dev/null +++ b/docs/changelog.d/+rewrite-observability-tutorial.doc.md @@ -0,0 +1 @@ +Rewrite observability stack tutorial: replace Helm instructions with actual kustomize/ArgoCD patterns, fix typos, document Alloy as core component diff --git a/docs/tutorials/replication/observability-stack.md b/docs/tutorials/replication/observability-stack.md index db98683..d62731e 100644 --- a/docs/tutorials/replication/observability-stack.md +++ b/docs/tutorials/replication/observability-stack.md @@ -1,6 +1,7 @@ --- title: Observability Stack -modified: 2026-02-07 +modified: 2026-04-06 +last-reviewed: 2026-04-06 tags: - tutorials - replication @@ -10,12 +11,14 @@ tags: # Building the Observability Stack > **Audiences:** Replicator +> +> **Prerequisites:** [[kubernetes-bootstrap|Kubernetes Bootstrap]], [[argocd-config|ArgoCD Config]] -This tutorial walks through deploying metrics, logs, and dashboards for your homelab - because you can't fix what you can't see. +This tutorial walks through deploying metrics, logs, and dashboards for your homelab — because you can't fix what you can't see. ## The Stack -A complete observability solution has three pillars: +A complete observability solution has three pillars plus a collection layer: | Component | Purpose | BlumeOps Uses | |-----------|---------|---------------| @@ -24,9 +27,11 @@ A complete observability solution has three pillars: | **Dashboards** | Visualization and alerting | [[grafana]] | | **Collection** | Gathering and forwarding data | [[alloy]] | -For BlumeOps specifics, see [[observability|Observability Reference]]. +BlumeOps deploys all of these as plain kustomize manifests managed by ArgoCD — no Helm charts. See [[no-helm-policy]] for the rationale and [[observability]] for the full reference. -## Step 1: Create Monitoring Namespace +## Step 1: Create the Monitoring Namespace + +ArgoCD can create this automatically via `CreateNamespace=true` in the Application spec, but if you're bootstrapping manually: ```bash kubectl create namespace monitoring @@ -34,20 +39,46 @@ kubectl create namespace monitoring ## Step 2: Deploy Prometheus -Prometheus collects and stores metrics. +Prometheus collects and stores metrics. BlumeOps runs it as a StatefulSet with local persistent storage. -### Using Helm +### Write the Manifests -```bash -helm repo add prometheus-community https://prometheus-community.github.io/helm-charts -helm install prometheus prometheus-community/prometheus \ - --namespace monitoring \ - --set server.persistentVolume.size=10Gi +Create `argocd/manifests/prometheus/` with: + +- **`kustomization.yaml`** — references the manifests and patches the container image +- **`statefulset.yaml`** — a single-replica StatefulSet with a 20Gi PVC for `/prometheus` +- **`configmap.yaml`** — the `prometheus.yml` scrape configuration +- **`service.yaml`** — exposes port 9090 within the cluster + +Key StatefulSet settings: + +```yaml +args: + - "--config.file=/etc/prometheus/prometheus.yml" + - "--storage.tsdb.retention.time=3650d" + - "--web.enable-remote-write-receiver" + - "--web.enable-lifecycle" ``` -### Or via ArgoCD +The remote-write-receiver flag is important — it lets [[alloy]] push metrics into Prometheus from both the host and in-cluster collectors. + +### Tag the Image + +Use your local container registry and the `:kustomized` sentinel pattern: + +```yaml +# kustomization.yaml +images: + - name: registry.ops.eblu.me/blumeops/prometheus + newTag: v3.10.0-abcdef0 +``` + +See [[build-container-image]] for how to build and tag images. + +### Create the ArgoCD Application + +Add `argocd/apps/prometheus.yaml`: -Create an Application pointing to a values file in your repo: ```yaml apiVersion: argoproj.io/v1alpha1 kind: Application @@ -57,17 +88,15 @@ metadata: spec: project: default source: - repoURL: https://prometheus-community.github.io/helm-charts - chart: prometheus - targetRevision: 25.0.0 - helm: - values: | - server: - persistentVolume: - size: 10Gi + repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git + path: argocd/manifests/prometheus + targetRevision: main destination: server: https://kubernetes.default.svc namespace: monitoring + syncPolicy: + syncOptions: + - CreateNamespace=true ``` ### Verify @@ -78,155 +107,133 @@ kubectl -n monitoring get pods -l app.kubernetes.io/name=prometheus ## Step 3: Deploy Loki -Loki aggregates logs (like Prometheus but for logs). +Loki aggregates logs — think Prometheus, but for log lines instead of metrics. -```bash -helm repo add grafana https://grafana.github.io/helm-charts -helm install loki grafana/loki-stack \ - --namespace monitoring \ - --set loki.persistence.enabled=true \ - --set loki.persistence.size=10Gi -``` +### Write the Manifests -This also installs Promtail for log collection from pods. +Create `argocd/manifests/loki/` with a StatefulSet, ConfigMap, and Service similar to Prometheus. Loki listens on port 3100 (HTTP) and 9096 (gRPC). + +The config file (`loki-config.yaml`) defines storage, compaction, and retention. For a homelab, a simple single-binary mode with local filesystem storage works well — no need for S3 or distributed mode. + +### Create the ArgoCD Application + +Same pattern as Prometheus — point to `argocd/manifests/loki`, target `monitoring` namespace. ## Step 4: Deploy Grafana -Grafana provides dashboards and visualization. +Grafana provides dashboards, visualization, and alerting. -```bash -helm install grafana grafana/grafana \ - --namespace monitoring \ - --set persistence.enabled=true \ - --set persistence.size=1Gi \ - --set adminPassword=admin # Change this! +### Write the Manifests + +Grafana has more moving parts than Prometheus or Loki: + +- **Deployment** with a PVC for `/var/lib/grafana` +- **ConfigMap** containing `grafana.ini`, `datasources.yaml`, and `alerting.yaml` +- **Dashboard ConfigMaps** labeled `grafana_dashboard: "1"` — a sidecar container watches for these and auto-loads them +- **ExternalSecret** for the admin password (from 1Password via [[external-secrets]]) + +Configure data sources declaratively in the ConfigMap: + +```yaml +# datasources.yaml +apiVersion: 1 +datasources: + - name: Prometheus + type: prometheus + url: http://prometheus.monitoring.svc:9090 + isDefault: true + - name: Loki + type: loki + url: http://loki.monitoring.svc:3100 ``` -### Configure Data Sources +### Secrets -After installation, add data sources in Grafana UI or via ConfigMap: +Grafana's admin password and any OAuth credentials (for [[authentik]] SSO) should come from 1Password via ExternalSecret — never hardcode passwords in manifests. See [[external-secrets]] and [[security-model]]. + +### Expose via Caddy + +BlumeOps exposes Grafana at `grafana.ops.eblu.me` through [[caddy]] on [[indri]], which reverse-proxies to the Kubernetes service via its Tailscale Ingress endpoint. This is the standard pattern for all services — see [[routing]] for details. + +## Step 5: Deploy Alloy + +Grafana Alloy is a unified telemetry collector that replaces multiple agents (Promtail, node_exporter, etc.). BlumeOps runs Alloy in **two places** — it is not optional; it's the glue that connects everything. + +### In-Cluster (DaemonSet) + +Create `argocd/manifests/alloy-k8s/` with: + +- **DaemonSet** — runs on every node, mounts `/var/log` read-only for pod log access +- **ServiceAccount + RBAC** — needs pod list/watch for Kubernetes discovery +- **ConfigMap** — the `config.alloy` file defining: + - Kubernetes pod log discovery and collection + - Service health probes (blackbox-style checks for key services) + - Remote write to Prometheus (`/api/v1/write`) and Loki (`/loki/api/v1/push`) + +The DaemonSet goes in a dedicated `alloy` namespace, separate from `monitoring`. + +### On the Host (Ansible) + +For metrics and logs from native services (Forgejo, Zot, Caddy, Borgmatic), Alloy runs directly on [[indri]] as a macOS LaunchAgent, managed by [[ansible]]. + +The host Alloy collects: +- System metrics via `prometheus.exporter.unix` +- Logs from Homebrew services and LaunchAgents +- Optional: PostgreSQL metrics, container registry metrics + +It pushes to the same Prometheus and Loki endpoints via `*.ops.eblu.me`. + +## What You Now Have + +- **Prometheus** scraping metrics from all services +- **Loki** aggregating logs from all pods and host services +- **Grafana** with declarative dashboards and data sources +- **Alloy** collecting from both Kubernetes and the host +- A foundation for alerting via Grafana Unified Alerting + +## Adding Alerts + +BlumeOps uses Grafana Unified Alerting (not Prometheus Alertmanager). Alerts are defined declaratively in `alerting.yaml` within the Grafana ConfigMap. Notifications go to [[ntfy]] — a self-hosted push notification service. + +Example alert categories: +- Service probe failures (is Grafana/Prometheus/Loki reachable?) +- Pod readiness (are pods healthy?) +- Metrics freshness (is data still flowing?) +- Storage and resource thresholds + +See [[observability]] for the full alerting reference. + +## Adding Dashboards + +Import community dashboards or create custom ones. BlumeOps uses a sidecar pattern — any ConfigMap in the `monitoring` namespace with the label `grafana_dashboard: "1"` is automatically loaded by Grafana's sidecar container. + +Create dashboard ConfigMaps in `argocd/manifests/grafana-config/dashboards/`: ```yaml apiVersion: v1 kind: ConfigMap metadata: - name: grafana-datasources - namespace: monitoring + name: grafana-dashboard-my-service labels: - grafana_datasource: "1" + grafana_dashboard: "1" data: - datasources.yaml: | - apiVersion: 1 - datasources: - - name: Prometheus - type: prometheus - url: http://prometheus-server.monitoring.svc:80 - isDefault: true - - name: Loki - type: loki - url: http://loki.monitoring.svc:3100 + my-service.json: | + { ... dashboard JSON ... } ``` -## Step 5: Access Grafana - -Expose via Tailscale: -```bash -kubectl -n monitoring port-forward svc/grafana 3000:80 & -tailscale serve --bg --https 3000 http://localhost:3000 -``` - -Or create an Ingress. - -Default credentials: `admin` / (password you set or retrieve from secret) - -## Step 6: Add Dashboards - -Import community dashboards from [grafana.com/grafana/dashboards](https://grafana.com/grafana/dashboards/): - -| Dashboard | ID | Shows | -|-----------|-----|-------| -| Node Exporter Full | 1860 | Host metrics | -| Kubernetes Cluster | 7249 | Cluster overview | -| Loki Logs | 13639 | Log exploration | - -In Grafana: Dashboards > Import > Enter ID - -## Step 7: Deploy Alloy (Optional) - -Grafana Alloy is a unified collector that replaces multiple agents (Promtail, node_exporter, etc.). - -```yaml -apiVersion: argoproj.io/v1alpha1 -kind: Application -metadata: - name: alloy - namespace: argocd -spec: - project: default - source: - repoURL: https://grafana.github.io/helm-charts - chart: alloy - targetRevision: 0.1.0 - helm: - values: | - alloy: - configMap: - content: | - // Alloy configuration here - destination: - server: https://kubernetes.default.svc - namespace: monitoring -``` - -BluemeOps uses Alloy on both [[indri]] (for host metrics, via [[ansible|Ansible role]]) and in the [[cluster]] (for pod logs and service probes). - -## What You Now Have - -- Metrics collection and storage (Prometheus) -- Log aggregation (Loki) -- Dashboards and visualization (Grafana) -- Foundation for alerting - -## Adding Alerts - -Configure alerting rules in Prometheus: - -```yaml -groups: -- name: example - rules: - - alert: HighMemoryUsage - expr: node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes < 0.1 - for: 5m - labels: - severity: warning - annotations: - summary: "High memory usage detected" -``` - -And notification channels in Grafana (email, Slack, PagerDuty, etc.). - ## Next Steps +- Set up [[authentik]] SSO for Grafana login (see [[federated-login]]) - Create custom dashboards for your services -- Set up alerting for critical conditions +- Configure alerting rules and notification channels - Add service-specific metrics exporters -## BluemeOps Specifics +## Related -BlumeOps' observability setup includes: -- Prometheus scraping all services via annotations -- Loki collecting logs from all pods and [[indri]] services -- Custom dashboards for [[jellyfin]], [[teslamate]], and cluster health -- [[alloy]] running on both host and in-cluster - -See [[observability|Observability Reference]] for full details. - -## Troubleshooting - -| Problem | Solution | -|---------|----------| -| No metrics appearing | Check Prometheus targets (`/targets` endpoint) | -| No logs in Loki | Verify Promtail/Alloy is collecting (`/ready` endpoint) | -| Dashboard shows no data | Check data source configuration and time range | -| High storage usage | Adjust retention settings in Prometheus/Loki | +- [[observability]] — Full observability reference +- [[no-helm-policy]] — Why kustomize instead of Helm +- [[alloy]] — Alloy collector reference +- [[prometheus]] — Prometheus reference +- [[loki]] — Loki reference +- [[grafana]] — Grafana reference +- [[routing]] — Service routing and exposure From 370a3574b2944ab377e380998075524d8685f5ed Mon Sep 17 00:00:00 2001 From: Forgejo Actions Date: Mon, 6 Apr 2026 07:53:54 -0700 Subject: [PATCH 204/430] Update docs release to v1.15.4 - Built changelog from towncrier fragments [skip ci] --- CHANGELOG.md | 11 +++++++++++ argocd/manifests/docs/deployment.yaml | 2 +- .../+rewrite-observability-tutorial.doc.md | 1 - docs/changelog.d/1password-connect-kustomize.infra.md | 1 - 4 files changed, 12 insertions(+), 3 deletions(-) delete mode 100644 docs/changelog.d/+rewrite-observability-tutorial.doc.md delete mode 100644 docs/changelog.d/1password-connect-kustomize.infra.md diff --git a/CHANGELOG.md b/CHANGELOG.md index c2953cc..f66713c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [v1.15.4] - 2026-04-06 + +### Infrastructure + +- Migrate 1Password Connect from Helm to kustomize (1.8.1 → 1.8.2), completing the no-helm-policy migration. + +### Documentation + +- Rewrite observability stack tutorial: replace Helm instructions with actual kustomize/ArgoCD patterns, fix typos, document Alloy as core component + + ## [v1.15.3] - 2026-04-05 ### Infrastructure diff --git a/argocd/manifests/docs/deployment.yaml b/argocd/manifests/docs/deployment.yaml index 82140db..e3cf51c 100644 --- a/argocd/manifests/docs/deployment.yaml +++ b/argocd/manifests/docs/deployment.yaml @@ -30,7 +30,7 @@ spec: name: http env: - name: DOCS_RELEASE_URL - value: "https://forge.eblu.me/eblume/blumeops/releases/download/v1.15.3/docs-v1.15.3.tar.gz" + value: "https://forge.eblu.me/eblume/blumeops/releases/download/v1.15.4/docs-v1.15.4.tar.gz" resources: requests: memory: "64Mi" diff --git a/docs/changelog.d/+rewrite-observability-tutorial.doc.md b/docs/changelog.d/+rewrite-observability-tutorial.doc.md deleted file mode 100644 index 5b727c2..0000000 --- a/docs/changelog.d/+rewrite-observability-tutorial.doc.md +++ /dev/null @@ -1 +0,0 @@ -Rewrite observability stack tutorial: replace Helm instructions with actual kustomize/ArgoCD patterns, fix typos, document Alloy as core component diff --git a/docs/changelog.d/1password-connect-kustomize.infra.md b/docs/changelog.d/1password-connect-kustomize.infra.md deleted file mode 100644 index 298eba4..0000000 --- a/docs/changelog.d/1password-connect-kustomize.infra.md +++ /dev/null @@ -1 +0,0 @@ -Migrate 1Password Connect from Helm to kustomize (1.8.1 → 1.8.2), completing the no-helm-policy migration. From 54213ab8103c3dbe464356957a7807a1b039d5f8 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 6 Apr 2026 08:24:48 -0700 Subject: [PATCH 205/430] Fix flake-update pipeline and update ringtail flake inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `--exclude` flag added in #321 never existed in nix — it was introduced broken and never tested. Replace with dynamic input discovery: query `nix flake metadata --json` for all input names, filter out skip_inputs (default: nixpkgs-services), pass the rest as positional args. Also bump NIX_IMAGE 2.33.3 → 2.34.4. Updated inputs: nixpkgs, home-manager, disko. nixpkgs-services stays pinned. Co-Authored-By: Claude Opus 4.6 (1M context) --- .dagger/src/blumeops_ci/main.py | 55 +++++++++++++------ .../+fix-flake-update-pipeline.bugfix.md | 1 + nixos/ringtail/flake.lock | 12 ++-- 3 files changed, 46 insertions(+), 22 deletions(-) create mode 100644 docs/changelog.d/+fix-flake-update-pipeline.bugfix.md diff --git a/.dagger/src/blumeops_ci/main.py b/.dagger/src/blumeops_ci/main.py index 641f0db..39c3586 100644 --- a/.dagger/src/blumeops_ci/main.py +++ b/.dagger/src/blumeops_ci/main.py @@ -1,7 +1,7 @@ import dagger from dagger import dag, function, object_type -NIX_IMAGE = "nixos/nix:2.33.3" +NIX_IMAGE = "nixos/nix:2.34.4" @object_type @@ -256,29 +256,52 @@ class BlumeopsCi: @function async def flake_update( - self, src: dagger.Directory, flake_path: str = "nixos/ringtail" + self, + src: dagger.Directory, + flake_path: str = "nixos/ringtail", + skip_inputs: str = "nixpkgs-services", ) -> dagger.File: """Update rolling flake inputs to latest and return updated flake.lock. - Skips nixpkgs-services, which is pinned to a specific commit and should - only be updated deliberately during service reviews. + Dynamically discovers all flake inputs, filters out skip_inputs + (comma-separated), and passes the rest as positional args to + `nix flake update`. This avoids hardcoding input names. + + Args: + src: Source directory containing the flake. + flake_path: Path to the flake within src. + skip_inputs: Comma-separated input names to exclude from update. """ + # nix has no --exclude flag; instead we enumerate inputs via + # `nix flake metadata --json` and pass the ones we want as + # positional args. + update_script = ( + "set -e; " + "SKIP='$SKIP_INPUTS'; " + "ALL=$(nix --extra-experimental-features 'nix-command flakes' " + "flake metadata --json 2>/dev/null " + "| nix-instantiate --eval -E " + '"builtins.concatStringsSep \\" \\" ' + "(builtins.attrNames " + "(builtins.fromJSON (builtins.readFile /dev/stdin))" + '.locks.nodes.root.inputs)" ' + "| tr -d '\"'); " + "INPUTS=''; " + "for i in $ALL; do " + ' case ",$SKIP," in *",$i,"*) continue ;; esac; ' + ' INPUTS="$INPUTS $i"; ' + "done; " + 'echo "Updating inputs:$INPUTS"; ' + 'echo "Skipping: $SKIP"; ' + "nix --extra-experimental-features 'nix-command flakes' " + "flake update $INPUTS --accept-flake-config" + ) return await ( dag.container() .from_(NIX_IMAGE) .with_directory("/workspace", src) .with_workdir(f"/workspace/{flake_path}") - .with_exec( - [ - "nix", - "--extra-experimental-features", - "nix-command flakes", - "flake", - "update", - "--exclude", - "nixpkgs-services", - "--accept-flake-config", - ] - ) + .with_env_variable("SKIP_INPUTS", skip_inputs) + .with_exec(["sh", "-c", update_script]) .file(f"/workspace/{flake_path}/flake.lock") ) diff --git a/docs/changelog.d/+fix-flake-update-pipeline.bugfix.md b/docs/changelog.d/+fix-flake-update-pipeline.bugfix.md new file mode 100644 index 0000000..1ebae57 --- /dev/null +++ b/docs/changelog.d/+fix-flake-update-pipeline.bugfix.md @@ -0,0 +1 @@ +Fix dagger flake-update pipeline: replace nonexistent `--exclude` flag with dynamic input discovery diff --git a/nixos/ringtail/flake.lock b/nixos/ringtail/flake.lock index c7f865c..def21b2 100644 --- a/nixos/ringtail/flake.lock +++ b/nixos/ringtail/flake.lock @@ -27,11 +27,11 @@ ] }, "locked": { - "lastModified": 1774559029, - "narHash": "sha256-deix7yg3j6AhjMPnFDCmWB3f83LsajaaULP5HH2j34k=", + "lastModified": 1775425411, + "narHash": "sha256-KY6HsebJHEe5nHOWP7ur09mb0drGxYSzE3rQxy62rJo=", "owner": "nix-community", "repo": "home-manager", - "rev": "a0bb0d11514f92b639514220114ac8063c72d0a3", + "rev": "0d02ec1d0a05f88ef9e74b516842900c41f0f2fe", "type": "github" }, "original": { @@ -43,11 +43,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1774388614, - "narHash": "sha256-tFwzTI0DdDzovdE9+Ras6CUss0yn8P9XV4Ja6RjA+nU=", + "lastModified": 1775305101, + "narHash": "sha256-/74n1oQPtKG52Yw41cbToxspxHbYz6O3vi+XEw16Qe8=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "1073dad219cb244572b74da2b20c7fe39cb3fa9e", + "rev": "36a601196c4ebf49e035270e10b2d103fe39076b", "type": "github" }, "original": { From a059d813141ec66fda4041cb2fd44180eedf15b3 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 6 Apr 2026 10:16:46 -0700 Subject: [PATCH 206/430] Add review-compliance-reports task and reorganize report storage New mise task fetches Prowler reports from sifaka, parses with proper muted/unmuted distinction, shows week-over-week delta, and includes a scaffold for Kingfisher once JSON/CSV output is available upstream. Moved all legacy top-level reports on sifaka into date subdirectories to match the current CronJob output structure. Updated read-compliance-reports doc with task reference and links. Co-Authored-By: Claude Opus 4.6 (1M context) --- ...+review-compliance-reports-task.feature.md | 1 + .../operations/read-compliance-reports.md | 15 +- mise-tasks/review-compliance-reports | 378 ++++++++++++++++++ 3 files changed, 391 insertions(+), 3 deletions(-) create mode 100644 docs/changelog.d/+review-compliance-reports-task.feature.md create mode 100755 mise-tasks/review-compliance-reports diff --git a/docs/changelog.d/+review-compliance-reports-task.feature.md b/docs/changelog.d/+review-compliance-reports-task.feature.md new file mode 100644 index 0000000..13cec0a --- /dev/null +++ b/docs/changelog.d/+review-compliance-reports-task.feature.md @@ -0,0 +1 @@ +Add `mise run review-compliance-reports` task for weekly compliance report review with muted/unmuted distinction and week-over-week delta diff --git a/docs/how-to/operations/read-compliance-reports.md b/docs/how-to/operations/read-compliance-reports.md index 1e1b993..75fd3ab 100644 --- a/docs/how-to/operations/read-compliance-reports.md +++ b/docs/how-to/operations/read-compliance-reports.md @@ -1,7 +1,7 @@ --- title: Read Compliance Reports -modified: 2026-03-24 -last-reviewed: 2026-03-24 +modified: 2026-04-06 +last-reviewed: 2026-04-06 tags: - how-to - security @@ -12,6 +12,14 @@ tags: How to access and interpret compliance scan reports from [[prowler]] and other security scanners. +## Quick summary + +```fish +mise run review-compliance-reports +``` + +This fetches the latest Prowler report from sifaka, parses it (respecting muted status), compares against the previous week, and shows only actionable unmuted failures. Use `--show-muted` to also see muted findings, or `--full` for complete detail. + ## Accessing reports Reports are stored on sifaka at `/volume1/reports/`. Each scanner writes to its own subdirectory: @@ -75,7 +83,8 @@ Not all failures require action. Common expected failures in our minikube cluste 3. **Mutelist** — suppress expected/accepted failures via Prowler's `--mutelist-file` to reduce noise in future scans 4. **Track** — compare reports over time to spot regressions -## See also +## Related - [[security]] — security & compliance posture overview - [[deploy-prowler]] — Prowler deployment and ad-hoc scans +- [[kingfisher]] — secret detection scanner diff --git a/mise-tasks/review-compliance-reports b/mise-tasks/review-compliance-reports new file mode 100755 index 0000000..075302d --- /dev/null +++ b/mise-tasks/review-compliance-reports @@ -0,0 +1,378 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.12" +# dependencies = ["rich>=14.0.0", "typer>=0.24.0"] +# /// +#MISE description="Summarize the latest Prowler and Kingfisher compliance reports from sifaka" +#USAGE flag "--full" help="Show all unmuted failures, not just new ones" +#USAGE flag "--show-muted" help="Also show muted failures" +"""Fetch and summarize compliance reports from sifaka. + +Covers: + - Prowler K8s CIS: CSV-based, full analysis with delta tracking + - Kingfisher secret scanning: TODO — pending upstream JSON/CSV output + support (currently HTML-only; contribute from spork) + +For Prowler, copies the two most recent K8s CIS reports, parses them, +and displays: + 1. Overall status (pass/fail/manual/muted counts) + 2. Unmuted failures by severity + 3. Delta from the previous report (new vs resolved) + 4. Actionable unmuted failures with details + +This is the primary tool for the weekly compliance report review. +""" + +import csv +import subprocess +import sys +import tempfile +from collections import Counter +from pathlib import Path +from typing import Annotated + +import typer +from rich.console import Console +from rich.panel import Panel +from rich.table import Table + +REPORT_BASE = "sifaka:/volume1/reports/prowler" + +console = Console() + + +def scp(remote: str, local: str) -> bool: + """Copy a file from sifaka (requires scp -O for Synology).""" + result = subprocess.run( + ["scp", "-O", remote, local], + capture_output=True, + text=True, + timeout=30, + ) + return result.returncode == 0 + + +def list_reports() -> list[str]: + """List Prowler CSV reports on sifaka, sorted by embedded timestamp.""" + result = subprocess.run( + ["ssh", "sifaka", "find /volume1/reports/prowler/ -name '*.csv' " + "-not -path '*/compliance/*' -not -name '@*'"], + capture_output=True, + text=True, + timeout=15, + ) + if result.returncode != 0: + console.print("[bold red]Failed to list reports on sifaka[/bold red]") + raise typer.Exit(code=1) + + csvs = [p.strip() for p in result.stdout.strip().splitlines() if p.strip()] + # Sort by the timestamp embedded in the filename (e.g. 20260405030007) + import re + + def sort_key(path: str) -> str: + m = re.search(r"(\d{14})", Path(path).name) + return m.group(1) if m else Path(path).name + + return sorted(csvs, key=sort_key) + + +def load_csv(path: str) -> list[dict]: + """Load a Prowler CSV report.""" + with open(path) as f: + return list(csv.DictReader(f, delimiter=";")) + + +def parse_findings(rows: list[dict]) -> dict: + """Categorize findings from a report.""" + statuses = Counter(r["STATUS"] for r in rows) + + fails = [r for r in rows if r["STATUS"] == "FAIL"] + unmuted = [r for r in fails if r.get("MUTED", "") != "True"] + muted = [r for r in fails if r.get("MUTED", "") == "True"] + + return { + "total": len(rows), + "statuses": statuses, + "fails": fails, + "unmuted": unmuted, + "muted": muted, + } + + +def finding_key(r: dict) -> tuple[str, str]: + """Stable identity for a finding (check + resource name, not UID).""" + return (r["CHECK_ID"], r.get("RESOURCE_NAME", "")) + + +SEVERITY_ORDER = ["critical", "high", "medium", "low", "informational"] + + +def severity_sort(r: dict) -> int: + sev = r.get("SEVERITY", "").lower() + return SEVERITY_ORDER.index(sev) if sev in SEVERITY_ORDER else 99 + + +def main( + full: Annotated[ + bool, typer.Option(help="Show all unmuted failures, not just new ones") + ] = False, + show_muted: Annotated[ + bool, typer.Option(help="Also show muted failures") + ] = False, +) -> None: + csvs = list_reports() + if not csvs: + console.print("[bold red]No Prowler CSV reports found on sifaka[/bold red]") + raise typer.Exit(code=1) + + with tempfile.TemporaryDirectory() as tmpdir: + # Fetch the two most recent reports + latest_remote = csvs[-1] + latest_local = Path(tmpdir) / "latest.csv" + + console.print(f"[dim]Fetching {latest_remote}...[/dim]") + if not scp(f"sifaka:{latest_remote}", str(latest_local)): + console.print("[bold red]Failed to copy latest report[/bold red]") + raise typer.Exit(code=1) + + prev_local = None + if len(csvs) >= 2: + prev_remote = csvs[-2] + prev_local = Path(tmpdir) / "prev.csv" + console.print(f"[dim]Fetching {prev_remote}...[/dim]") + if not scp(f"sifaka:{prev_remote}", str(prev_local)): + prev_local = None + + latest = parse_findings(load_csv(str(latest_local))) + + # Extract report date from filename + report_name = Path(latest_remote).stem + console.print() + + # --- Overall status --- + status_table = Table( + show_header=True, header_style="bold", title=f"Report: {report_name}" + ) + status_table.add_column("Status") + status_table.add_column("Count", justify="right") + + for status in ["PASS", "FAIL", "MANUAL"]: + count = latest["statuses"].get(status, 0) + style = "red" if status == "FAIL" and count > 0 else "" + status_table.add_row( + f"[{style}]{status}[/{style}]" if style else status, + f"[{style}]{count}[/{style}]" if style else str(count), + ) + + fail_count = len(latest["fails"]) + muted_count = len(latest["muted"]) + unmuted_count = len(latest["unmuted"]) + status_table.add_row("", "") + status_table.add_row("[dim]↳ muted[/dim]", f"[dim]{muted_count}[/dim]") + status_table.add_row( + "[bold]↳ unmuted (action needed)[/bold]", + f"[bold red]{unmuted_count}[/bold red]" + if unmuted_count > 0 + else "[bold green]0[/bold green]", + ) + status_table.add_row("", "") + status_table.add_row("[bold]Total[/bold]", f"[bold]{latest['total']}[/bold]") + + console.print(status_table) + console.print() + + # --- Unmuted failures by severity --- + if latest["unmuted"]: + sev_table = Table( + show_header=True, + header_style="bold", + title="Unmuted Failures by Severity", + ) + sev_table.add_column("Severity") + sev_table.add_column("Count", justify="right") + + for sev, count in Counter( + r["SEVERITY"] for r in latest["unmuted"] + ).most_common(): + style = ( + "bold red" + if sev == "critical" + else "red" + if sev == "high" + else "yellow" + if sev == "medium" + else "" + ) + sev_table.add_row( + f"[{style}]{sev}[/{style}]" if style else sev, + f"[{style}]{count}[/{style}]" if style else str(count), + ) + + console.print(sev_table) + console.print() + + # --- Delta from previous report --- + if prev_local: + prev = parse_findings(load_csv(str(prev_local))) + + prev_keys = {finding_key(r): r for r in prev["unmuted"]} + curr_keys = {finding_key(r): r for r in latest["unmuted"]} + + new_keys = set(curr_keys.keys()) - set(prev_keys.keys()) + resolved_keys = set(prev_keys.keys()) - set(curr_keys.keys()) + + prev_name = Path(csvs[-2]).stem + delta_lines = [ + f"Compared against: [dim]{prev_name}[/dim]", + "", + f"Previous unmuted FAILs: {len(prev['unmuted'])}", + f"Current unmuted FAILs: {len(latest['unmuted'])}", + f"[green]Resolved: {len(resolved_keys)}[/green]", + f"[red]New: {len(new_keys)}[/red]" + if new_keys + else f"[green]New: 0[/green]", + ] + + console.print( + Panel( + "\n".join(delta_lines), + title="[bold]Week-over-Week Delta (unmuted only)[/bold]", + border_style="cyan", + ) + ) + console.print() + + if new_keys: + console.print("[bold red]New Unmuted Failures:[/bold red]") + for k in sorted(new_keys): + r = curr_keys[k] + console.print( + f" [{r['SEVERITY']}] {r['CHECK_ID']}: " + f"{r['STATUS_EXTENDED'][:120]}" + ) + console.print() + + if resolved_keys: + console.print("[bold green]Resolved:[/bold green]") + for k in sorted(resolved_keys): + r = prev_keys[k] + console.print( + f" [dim][{r['SEVERITY']}] {r['CHECK_ID']}: " + f"{r['STATUS_EXTENDED'][:120]}[/dim]" + ) + console.print() + + # --- Unmuted failure details --- + findings_to_show = latest["unmuted"] if full else [] + if not full and latest["unmuted"]: + findings_to_show = latest["unmuted"] + + if findings_to_show: + detail_table = Table( + show_header=True, + header_style="bold", + title="Unmuted Failures — Action Needed", + ) + detail_table.add_column("Severity") + detail_table.add_column("Check") + detail_table.add_column("Resource") + detail_table.add_column("Detail", max_width=60) + + for r in sorted(findings_to_show, key=severity_sort): + sev = r["SEVERITY"] + style = ( + "bold red" + if sev == "critical" + else "red" + if sev == "high" + else "yellow" + if sev == "medium" + else "" + ) + detail_table.add_row( + f"[{style}]{sev}[/{style}]" if style else sev, + r["CHECK_ID"], + r.get("RESOURCE_NAME", ""), + r["STATUS_EXTENDED"][:60], + ) + + console.print(detail_table) + console.print() + + # --- Muted findings summary --- + if show_muted and latest["muted"]: + muted_table = Table( + show_header=True, + header_style="bold", + title="Muted Failures (for reference)", + ) + muted_table.add_column("Severity") + muted_table.add_column("Check") + muted_table.add_column("Count", justify="right") + + muted_groups: dict[tuple[str, str], int] = Counter() + for r in latest["muted"]: + muted_groups[(r["SEVERITY"], r["CHECK_ID"])] += 1 + + for (sev, check), count in sorted( + muted_groups.items(), key=lambda x: severity_sort({"SEVERITY": x[0][0]}) + ): + muted_table.add_row(f"[dim]{sev}[/dim]", f"[dim]{check}[/dim]", f"[dim]{count}[/dim]") + + console.print(muted_table) + console.print() + + # --- Verdict --- + if not latest["unmuted"]: + console.print( + Panel( + "[bold green]All clear.[/bold green] No unmuted failures.", + title="Prowler Verdict", + border_style="green", + ) + ) + else: + console.print( + Panel( + f"[bold yellow]{len(latest['unmuted'])} unmuted failure(s) " + f"need triage.[/bold yellow]\n\n" + "For each: remediate (fix the pod spec) or mute " + "(add to mutelist + compensating control).", + title="Prowler Verdict", + border_style="yellow", + ) + ) + + # --- Kingfisher secret scanning --- + # TODO: Kingfisher currently only outputs HTML. Once JSON or CSV output + # is supported upstream (contribute from our spork), add parsing here: + # + # KINGFISHER_BASE = "/volume1/reports/kingfisher" + # - Fetch latest JSON/CSV from sifaka:{KINGFISHER_BASE}/ + # - Parse findings: active vs inactive vs skipped validations + # - Flag any "Active Credential" findings as critical + # - Compare against previous scan for delta + # - Show summary panel similar to Prowler + # + # For now, check that a recent report exists and warn if missing. + kf_check = subprocess.run( + ["ssh", "sifaka", "ls -1t /volume1/reports/kingfisher/ | head -1"], + capture_output=True, + text=True, + timeout=15, + ) + kf_latest = kf_check.stdout.strip() if kf_check.returncode == 0 else "" + if kf_latest and kf_latest.startswith("202"): + console.print( + f"[dim]Kingfisher: latest report directory is {kf_latest} " + f"(HTML only — JSON/CSV pending upstream)[/dim]" + ) + else: + console.print( + "[bold yellow]Warning: No recent Kingfisher report found on " + "sifaka. Check the CronJob on ringtail.[/bold yellow]" + ) + + +if __name__ == "__main__": + typer.run(main) From 18fe172a54c9b9746e9b327325b6d8c74691aeb8 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 6 Apr 2026 10:21:23 -0700 Subject: [PATCH 207/430] Add seccomp RuntimeDefault profiles to alloy-k8s and immich pods Resolves 4 unmuted Prowler core_seccomp_profile_docker_default findings on alloy, immich-server, immich-machine-learning, and immich-valkey. Co-Authored-By: Claude Opus 4.6 (1M context) --- argocd/manifests/alloy-k8s/daemonset.yaml | 2 ++ argocd/manifests/immich/deployment-ml.yaml | 3 +++ argocd/manifests/immich/deployment-server.yaml | 3 +++ argocd/manifests/immich/deployment-valkey.yaml | 3 +++ 4 files changed, 11 insertions(+) diff --git a/argocd/manifests/alloy-k8s/daemonset.yaml b/argocd/manifests/alloy-k8s/daemonset.yaml index 60b8883..f1758cd 100644 --- a/argocd/manifests/alloy-k8s/daemonset.yaml +++ b/argocd/manifests/alloy-k8s/daemonset.yaml @@ -17,6 +17,8 @@ spec: serviceAccountName: alloy securityContext: fsGroup: 473 # alloy user group + seccompProfile: + type: RuntimeDefault containers: - name: alloy image: registry.ops.eblu.me/blumeops/alloy:kustomized diff --git a/argocd/manifests/immich/deployment-ml.yaml b/argocd/manifests/immich/deployment-ml.yaml index d55898d..57c4242 100644 --- a/argocd/manifests/immich/deployment-ml.yaml +++ b/argocd/manifests/immich/deployment-ml.yaml @@ -16,6 +16,9 @@ spec: app: immich component: machine-learning spec: + securityContext: + seccompProfile: + type: RuntimeDefault containers: - name: machine-learning image: ghcr.io/immich-app/immich-machine-learning:kustomized diff --git a/argocd/manifests/immich/deployment-server.yaml b/argocd/manifests/immich/deployment-server.yaml index 56e920a..8ac7ab0 100644 --- a/argocd/manifests/immich/deployment-server.yaml +++ b/argocd/manifests/immich/deployment-server.yaml @@ -16,6 +16,9 @@ spec: app: immich component: server spec: + securityContext: + seccompProfile: + type: RuntimeDefault containers: - name: server image: ghcr.io/immich-app/immich-server:kustomized diff --git a/argocd/manifests/immich/deployment-valkey.yaml b/argocd/manifests/immich/deployment-valkey.yaml index 4034f94..1cf3346 100644 --- a/argocd/manifests/immich/deployment-valkey.yaml +++ b/argocd/manifests/immich/deployment-valkey.yaml @@ -18,6 +18,9 @@ spec: app: immich component: valkey spec: + securityContext: + seccompProfile: + type: RuntimeDefault containers: - name: valkey image: docker.io/valkey/valkey:kustomized From 59f3422d3e64cf898aa1c23737d5263918aafce1 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 6 Apr 2026 10:35:13 -0700 Subject: [PATCH 208/430] Review compensating control: tailscale-network-isolation Verified: tailscale serve status shows only svc:k8s, ACLs restrict tag:flyio-target to port 443 with admin/operator ownership only, indri has no flyio-target tag. All 10 muted findings remain valid. Noted gap: no automated alerting on new flyio-target devices. Tracked in Todoist as MC4 (Manual Compliance Control Check CronJob). Co-Authored-By: Claude Opus 4.6 (1M context) --- compensating-controls.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compensating-controls.yaml b/compensating-controls.yaml index b90da40..6b0af70 100644 --- a/compensating-controls.yaml +++ b/compensating-controls.yaml @@ -31,7 +31,7 @@ controls: identity with ACL enforcement. Profiling endpoints, debug ports, and control-plane APIs are unreachable from the public internet. created: 2026-03-30 - last-reviewed: 2026-03-30 + last-reviewed: 2026-04-06 notes: >- Verify with 'tailscale serve status --json' on indri and review Tailscale ACLs in pulumi/tailscale/. Only tag:flyio-target services From d3d67272a7eaeda842da7f8fb5fcd6e15b0bee64 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 6 Apr 2026 10:37:40 -0700 Subject: [PATCH 209/430] Fix blumeops-tasks swallowing bracket content in descriptions Rich markup parser interprets [text] as style tags, stripping wiki-links like [[review-compensating-controls]] to empty []. Escape description lines with rich.markup.escape(). Co-Authored-By: Claude Opus 4.6 (1M context) --- mise-tasks/blumeops-tasks | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/mise-tasks/blumeops-tasks b/mise-tasks/blumeops-tasks index 94daa51..333178e 100755 --- a/mise-tasks/blumeops-tasks +++ b/mise-tasks/blumeops-tasks @@ -26,6 +26,7 @@ from datetime import date import httpx from rich.console import Console +from rich.markup import escape from rich.text import Text TODOIST_API_BASE = "https://api.todoist.com/api/v1" @@ -150,10 +151,10 @@ def main() -> int: header.append(f" {content}") console.print(header) - # Description indented + # Description indented (escape rich markup to preserve brackets) if description: for line in description.split("\n"): - console.print(f" {line}", style="dim") + console.print(f" {escape(line)}", style="dim") console.print() From e85c71e73fb4df3292944af03b9afe3454825948 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 6 Apr 2026 10:38:41 -0700 Subject: [PATCH 210/430] Add changelog fragments for seccomp hardening and bracket fix Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/changelog.d/+fix-blumeops-tasks-brackets.bugfix.md | 1 + docs/changelog.d/+seccomp-alloy-immich.infra.md | 1 + 2 files changed, 2 insertions(+) create mode 100644 docs/changelog.d/+fix-blumeops-tasks-brackets.bugfix.md create mode 100644 docs/changelog.d/+seccomp-alloy-immich.infra.md diff --git a/docs/changelog.d/+fix-blumeops-tasks-brackets.bugfix.md b/docs/changelog.d/+fix-blumeops-tasks-brackets.bugfix.md new file mode 100644 index 0000000..faa7306 --- /dev/null +++ b/docs/changelog.d/+fix-blumeops-tasks-brackets.bugfix.md @@ -0,0 +1 @@ +Fix blumeops-tasks swallowing wiki-link brackets in task descriptions (rich markup escaping) diff --git a/docs/changelog.d/+seccomp-alloy-immich.infra.md b/docs/changelog.d/+seccomp-alloy-immich.infra.md new file mode 100644 index 0000000..81480f3 --- /dev/null +++ b/docs/changelog.d/+seccomp-alloy-immich.infra.md @@ -0,0 +1 @@ +Add seccomp RuntimeDefault profiles to alloy-k8s and immich pods, resolving 4 unmuted Prowler findings From 1fd8aae8f627deced7eec470be857ca82894aeef Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Tue, 7 Apr 2026 08:21:11 -0700 Subject: [PATCH 211/430] =?UTF-8?q?Upgrade=20ArgoCD=20v3.3.2=20=E2=86=92?= =?UTF-8?q?=20v3.3.6,=20SHA-pin=20install=20manifest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Patch upgrade with bug fixes (diff normalization, installation ID cache). Pin the upstream manifest URL to commit SHA for supply chain integrity. Co-Authored-By: Claude Opus 4.6 (1M context) --- argocd/manifests/argocd/kustomization.yaml | 3 ++- docs/changelog.d/+argocd-v3.3.6.infra.md | 1 + service-versions.yaml | 4 ++-- 3 files changed, 5 insertions(+), 3 deletions(-) create mode 100644 docs/changelog.d/+argocd-v3.3.6.infra.md diff --git a/argocd/manifests/argocd/kustomization.yaml b/argocd/manifests/argocd/kustomization.yaml index 89b5970..85dfa7e 100644 --- a/argocd/manifests/argocd/kustomization.yaml +++ b/argocd/manifests/argocd/kustomization.yaml @@ -5,7 +5,8 @@ namespace: argocd resources: # Pin to specific version for intentional upgrades - - https://raw.githubusercontent.com/argoproj/argo-cd/v3.3.2/manifests/install.yaml + # ArgoCD v3.3.6 + - https://raw.githubusercontent.com/argoproj/argo-cd/998fb59dc355653c0657908a6ea2f87136e022d1/manifests/install.yaml - ingress-tailscale.yaml - external-secret-repo-forge.yaml - external-secret-oidc-authentik.yaml diff --git a/docs/changelog.d/+argocd-v3.3.6.infra.md b/docs/changelog.d/+argocd-v3.3.6.infra.md new file mode 100644 index 0000000..0a51216 --- /dev/null +++ b/docs/changelog.d/+argocd-v3.3.6.infra.md @@ -0,0 +1 @@ +Upgrade ArgoCD from v3.3.2 to v3.3.6 (bug-fix patches), SHA-pin install manifest diff --git a/service-versions.yaml b/service-versions.yaml index b8f62ac..c845629 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -141,8 +141,8 @@ services: - name: argocd type: argocd - last-reviewed: 2026-02-26 - current-version: "v3.3.2" + last-reviewed: 2026-04-07 + current-version: "v3.3.6" upstream-source: https://github.com/argoproj/argo-cd/releases notes: Kustomize-based install with ServerSideApply From fc34a7da5b5168af2aa47ce81fea70a2b81568bf Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Tue, 7 Apr 2026 15:21:48 -0700 Subject: [PATCH 212/430] Review postgresql.md: add authentik user/db, immich-pg borgmatic secret Doc review found the authentik database, user, and external secret were missing, along with the immich-pg borgmatic secret. Added Cluster column to Users table for clarity. Set last-reviewed: 2026-04-07. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/reference/services/postgresql.md | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/docs/reference/services/postgresql.md b/docs/reference/services/postgresql.md index 9d08d08..4cccf4e 100644 --- a/docs/reference/services/postgresql.md +++ b/docs/reference/services/postgresql.md @@ -1,6 +1,7 @@ --- title: PostgreSQL -modified: 2026-02-15 +modified: 2026-04-07 +last-reviewed: 2026-04-07 tags: - service - database @@ -26,19 +27,21 @@ Database clusters via CloudNativePG operator. |----------|---------|-------|---------| | miniflux | blumeops-pg | miniflux | [[miniflux]] feed data | | teslamate | blumeops-pg | teslamate | [[teslamate]] vehicle data | +| authentik | blumeops-pg | authentik | [[authentik]] identity provider | | immich | immich-pg | immich | [[immich]] photo management | The `immich-pg` cluster uses a custom image (`cloudnative-vectorchord`) with vector search extensions (vector, vchord, cube, earthdistance). ## Users -| User | Role | Purpose | -|------|------|---------| -| postgres | superuser | CNPG internal | -| miniflux | app owner | Owns miniflux database | -| teslamate | superuser | TeslaMate (needs extensions) | -| eblume | superuser | Admin access | -| borgmatic | pg_read_all_data | [[borgmatic|Backup]] access | +| User | Cluster | Role | Purpose | +|------|---------|------|---------| +| postgres | both | superuser | CNPG internal | +| miniflux | blumeops-pg | app owner | Owns miniflux database | +| teslamate | blumeops-pg | superuser | TeslaMate (needs extensions) | +| authentik | blumeops-pg | createdb | [[authentik]] identity provider | +| eblume | blumeops-pg | superuser | Admin access | +| borgmatic | both | pg_read_all_data | [[borgmatic|Backup]] access | ## Backup @@ -55,9 +58,11 @@ Backed up via [[borgmatic]] `postgresql_databases` hook. Streams `pg_dump` direc - `blumeops-pg-eblume` - eblume superuser - `blumeops-pg-borgmatic` - borgmatic backup user - `blumeops-pg-teslamate` - teslamate user +- `blumeops-pg-authentik` - authentik user **CNPG-managed secrets (immich-pg):** - `immich-pg-app` - immich user +- `immich-pg-borgmatic` - borgmatic backup user ## Related @@ -65,4 +70,5 @@ Backed up via [[borgmatic]] `postgresql_databases` hook. Streams `pg_dump` direc - [[miniflux]] - Feed reader database - [[teslamate]] - Vehicle data database - [[immich]] - Photo management database +- [[authentik]] - Identity provider database - [[borgmatic]] - Database backup From efae404d1e2c4a7538478bb35ea1128271697167 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Tue, 7 Apr 2026 15:36:39 -0700 Subject: [PATCH 213/430] Remove superuser from teslamate PG role, transfer extension ownership teslamate had superuser on the shared blumeops-pg cluster (which also hosts miniflux and authentik). Downgraded to plain database owner with extension ownership (cube, earthdistance) transferred manually so it can still ALTER EXTENSION UPDATE. earthdistance is untrusted in PG so DROP+CREATE would need temporary superuser escalation. Co-Authored-By: Claude Opus 4.6 (1M context) --- argocd/manifests/databases/blumeops-pg.yaml | 7 ++++--- docs/reference/services/postgresql.md | 2 +- docs/reference/services/teslamate.md | 16 ++++++++++++++-- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/argocd/manifests/databases/blumeops-pg.yaml b/argocd/manifests/databases/blumeops-pg.yaml index ea68c9b..8f1b878 100644 --- a/argocd/manifests/databases/blumeops-pg.yaml +++ b/argocd/manifests/databases/blumeops-pg.yaml @@ -45,14 +45,15 @@ spec: passwordSecret: name: blumeops-pg-borgmatic # teslamate user for TeslaMate Tesla data logger - # Note: superuser required for extension management during migrations + # Superuser removed. Extension ownership (cube, earthdistance) + # transferred manually so teslamate can ALTER EXTENSION UPDATE. + # earthdistance is untrusted — DROP+CREATE needs temporary + # superuser escalation during upgrades. - name: teslamate login: true - superuser: true connectionLimit: -1 ensure: present inherit: true - createdb: true passwordSecret: name: blumeops-pg-teslamate # authentik user for Authentik identity provider (runs on ringtail) diff --git a/docs/reference/services/postgresql.md b/docs/reference/services/postgresql.md index 4cccf4e..ef86418 100644 --- a/docs/reference/services/postgresql.md +++ b/docs/reference/services/postgresql.md @@ -38,7 +38,7 @@ The `immich-pg` cluster uses a custom image (`cloudnative-vectorchord`) with vec |------|---------|------|---------| | postgres | both | superuser | CNPG internal | | miniflux | blumeops-pg | app owner | Owns miniflux database | -| teslamate | blumeops-pg | superuser | TeslaMate (needs extensions) | +| teslamate | blumeops-pg | db owner | TeslaMate (owns extensions) | | authentik | blumeops-pg | createdb | [[authentik]] identity provider | | eblume | blumeops-pg | superuser | Admin access | | borgmatic | both | pg_read_all_data | [[borgmatic|Backup]] access | diff --git a/docs/reference/services/teslamate.md b/docs/reference/services/teslamate.md index f02e979..a11ed0e 100644 --- a/docs/reference/services/teslamate.md +++ b/docs/reference/services/teslamate.md @@ -1,6 +1,6 @@ --- title: TeslaMate -modified: 2026-03-23 +modified: 2026-04-07 last-reviewed: 2026-03-23 tags: - service @@ -39,7 +39,19 @@ Self-hosted Tesla data logger collecting vehicle telemetry from the Tesla API. - Drive Stats, Charging Stats, Projected Range - Timeline, Updates, Visited -Dashboards use PostgreSQL datasource (not Prometheus). +Dashboards use PostgreSQL datasource (not Prometheus). The Grafana datasource connects as the `teslamate` database user. + +## Database Permissions + +The `teslamate` role was initially provisioned as superuser to allow extension creation (`cube`, `earthdistance`) during initial setup. Superuser has been removed — `teslamate` is now a plain database owner with extension ownership transferred so it can `ALTER EXTENSION ... UPDATE` without superuser. + +Note: `earthdistance` is not a trusted extension in PostgreSQL, so `CREATE EXTENSION earthdistance` still requires superuser. If a future TeslaMate migration does `DROP EXTENSION ... CASCADE` + re-create (as happened in the 2024 migration), it will fail. In that case, temporarily grant superuser for the migration and remove it afterward. + +Extension ownership persists across pod restarts and CNPG failovers, but a full cluster rebuild (major PG upgrade, fresh `initdb`) would re-create extensions as `postgres`. After any rebuild, transfer ownership back: + +```sql +UPDATE pg_extension SET extowner = (SELECT oid FROM pg_roles WHERE rolname = 'teslamate') WHERE extname IN ('cube', 'earthdistance'); +``` ## Authentication From 84eda0301f1af749fa8fb1aafd925b256a10c2bd Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Tue, 7 Apr 2026 15:39:29 -0700 Subject: [PATCH 214/430] =?UTF-8?q?Bump=20authentik=20worker=20memory=20li?= =?UTF-8?q?mit=201Gi=20=E2=86=92=202Gi=20(OOMKilled=20after=20ringtail=20r?= =?UTF-8?q?estart)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Worker forks 4 Dramatiq processes each loading the full Django app (~250MB each), hitting the 1Gi limit on startup. Ringtail has ample RAM headroom. Co-Authored-By: Claude Opus 4.6 (1M context) --- argocd/manifests/authentik/deployment-worker.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/argocd/manifests/authentik/deployment-worker.yaml b/argocd/manifests/authentik/deployment-worker.yaml index ed8a753..5fe473e 100644 --- a/argocd/manifests/authentik/deployment-worker.yaml +++ b/argocd/manifests/authentik/deployment-worker.yaml @@ -91,10 +91,10 @@ spec: readOnly: true resources: requests: - memory: "256Mi" + memory: "512Mi" cpu: "100m" limits: - memory: "1Gi" + memory: "2Gi" cpu: "1000m" volumes: - name: blueprints From f59f8859dc1a63cf92f1dab5434d691841fb743f Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Tue, 7 Apr 2026 16:09:25 -0700 Subject: [PATCH 215/430] Localize kube-state-metrics container (Dockerfile + nix) (#327) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Build kube-state-metrics v2.18.0 locally from forge mirror, replacing upstream `registry.k8s.io` image - Dockerfile (two-stage Go build) for indri/minikube - default.nix (buildGoModule + buildLayeredImage) for ringtail/k3s - Both kustomization files updated with `newName` pointing to local registry ## Verification - [x] Nix build succeeded on ringtail (`nix-build` → 10-layer image) - [x] Dockerfile build succeeded locally (`dagger call build` → ~2min) - [x] `container-version-check --all-files` passes (2.18.0 consistent across Dockerfile, nix, service-versions.yaml) - [ ] CI builds container images from this branch - [ ] Update kustomization `newTag` with SHA-tagged version from CI - [ ] ArgoCD sync on both clusters ## Test plan - Trigger CI build: `mise run container-build-and-release kube-state-metrics` - Verify tags: `mise run container-list kube-state-metrics` - Update newTag in kustomization files with CI-produced tag - Sync ArgoCD on indri: `argocd app sync kube-state-metrics` - Sync ArgoCD on ringtail: `argocd app sync kube-state-metrics --context=k3s-ringtail` (note: argocd uses its own auth, not kubectl context) - Verify metrics still flowing to Prometheus Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/327 --- .../kustomization.yaml | 3 +- .../kube-state-metrics/kustomization.yaml | 3 +- containers/kube-state-metrics/Dockerfile | 44 ++++++++++++++ containers/kube-state-metrics/default.nix | 59 +++++++++++++++++++ .../localize-kube-state-metrics.infra.md | 1 + 5 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 containers/kube-state-metrics/Dockerfile create mode 100644 containers/kube-state-metrics/default.nix create mode 100644 docs/changelog.d/localize-kube-state-metrics.infra.md diff --git a/argocd/manifests/kube-state-metrics-ringtail/kustomization.yaml b/argocd/manifests/kube-state-metrics-ringtail/kustomization.yaml index 005cba8..7717ed1 100644 --- a/argocd/manifests/kube-state-metrics-ringtail/kustomization.yaml +++ b/argocd/manifests/kube-state-metrics-ringtail/kustomization.yaml @@ -6,4 +6,5 @@ resources: - service.yaml images: - name: registry.k8s.io/kube-state-metrics/kube-state-metrics - newTag: v2.18.0 + newName: registry.ops.eblu.me/blumeops/kube-state-metrics + newTag: v2.18.0-e2e35cc-nix diff --git a/argocd/manifests/kube-state-metrics/kustomization.yaml b/argocd/manifests/kube-state-metrics/kustomization.yaml index 005cba8..c370239 100644 --- a/argocd/manifests/kube-state-metrics/kustomization.yaml +++ b/argocd/manifests/kube-state-metrics/kustomization.yaml @@ -6,4 +6,5 @@ resources: - service.yaml images: - name: registry.k8s.io/kube-state-metrics/kube-state-metrics - newTag: v2.18.0 + newName: registry.ops.eblu.me/blumeops/kube-state-metrics + newTag: v2.18.0-e2e35cc diff --git a/containers/kube-state-metrics/Dockerfile b/containers/kube-state-metrics/Dockerfile new file mode 100644 index 0000000..ebaf8e6 --- /dev/null +++ b/containers/kube-state-metrics/Dockerfile @@ -0,0 +1,44 @@ +# kube-state-metrics — Kubernetes state metrics exporter +# Two-stage build: Go binary, Alpine runtime + +ARG CONTAINER_APP_VERSION=2.18.0 +ARG KSM_VERSION=v${CONTAINER_APP_VERSION} +ARG KSM_COMMIT=ab562f78ebf4cb97cc2f87c1235e457076035d16 + +FROM golang:alpine3.22 AS build + +ARG KSM_VERSION +ARG KSM_COMMIT +RUN apk add --no-cache build-base git + +RUN mkdir /app && cd /app \ + && git init \ + && git remote add origin https://forge.ops.eblu.me/mirrors/kube-state-metrics.git \ + && git fetch --depth 1 origin ${KSM_COMMIT} \ + && git checkout FETCH_HEAD + +WORKDIR /app + +ENV CGO_ENABLED=0 + +RUN go build \ + -o /kube-state-metrics \ + -ldflags "-s -w -X k8s.io/kube-state-metrics/v2/pkg/version.Version=${KSM_VERSION}" + +FROM alpine:3.22 + +ARG CONTAINER_APP_VERSION +LABEL org.opencontainers.image.title="kube-state-metrics" +LABEL org.opencontainers.image.description="Generates metrics about the state of Kubernetes objects" +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" + +RUN apk --no-cache add ca-certificates tzdata + +COPY --from=build /kube-state-metrics /usr/bin/kube-state-metrics + +EXPOSE 8080 8081 + +USER 65534 +ENTRYPOINT ["/usr/bin/kube-state-metrics"] diff --git a/containers/kube-state-metrics/default.nix b/containers/kube-state-metrics/default.nix new file mode 100644 index 0000000..bd83db5 --- /dev/null +++ b/containers/kube-state-metrics/default.nix @@ -0,0 +1,59 @@ +# Nix-built kube-state-metrics +# Builds v2.18.0 from forge mirror +# Built with dockerTools.buildLayeredImage for efficient layer caching +{ pkgs ? import { } }: + +let + version = "2.18.0"; + + src = pkgs.fetchgit { + url = "https://forge.ops.eblu.me/mirrors/kube-state-metrics.git"; + rev = "v${version}"; + hash = "sha256-oLkIjc6VC3hTrFg9LmgSUtwt4ek0dT7h2u2DfNRx5Gg="; + }; + + kube-state-metrics = pkgs.buildGoModule { + inherit src version; + pname = "kube-state-metrics"; + vendorHash = "sha256-ccP34lywpQnIx3R5IyGURuvb4ijNfCu2VVAeVjBrN0w="; + + doCheck = false; + + subPackages = [ "." ]; + + ldflags = [ + "-s" + "-w" + "-X k8s.io/kube-state-metrics/v2/pkg/version.Version=v${version}" + ]; + + meta = with pkgs.lib; { + description = "Generates metrics about the state of Kubernetes objects"; + homepage = "https://github.com/kubernetes/kube-state-metrics"; + license = licenses.asl20; + mainProgram = "kube-state-metrics"; + }; + }; +in + +pkgs.dockerTools.buildLayeredImage { + name = "blumeops/kube-state-metrics"; + contents = [ + kube-state-metrics + pkgs.cacert + pkgs.tzdata + ]; + + config = { + Entrypoint = [ "${kube-state-metrics}/bin/kube-state-metrics" ]; + Env = [ + "SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" + "TZDIR=${pkgs.tzdata}/share/zoneinfo" + ]; + ExposedPorts = { + "8080/tcp" = { }; + "8081/tcp" = { }; + }; + User = "65534"; + }; +} diff --git a/docs/changelog.d/localize-kube-state-metrics.infra.md b/docs/changelog.d/localize-kube-state-metrics.infra.md new file mode 100644 index 0000000..f6a709a --- /dev/null +++ b/docs/changelog.d/localize-kube-state-metrics.infra.md @@ -0,0 +1 @@ +Build kube-state-metrics container locally (Dockerfile + nix) from forge mirror, replacing upstream registry.k8s.io image on both indri and ringtail. From 3c894e659d67710eaebb3427c0f3f961d206eda5 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Tue, 7 Apr 2026 16:10:14 -0700 Subject: [PATCH 216/430] Pin kube-state-metrics to main-SHA container tags C0 follow-up to #327: update from branch-SHA tags to main-SHA tags after squash-merge rebuild. indri: v2.18.0-f59f885 ringtail: v2.18.0-f59f885-nix Co-Authored-By: Claude Opus 4.6 (1M context) --- argocd/manifests/kube-state-metrics-ringtail/kustomization.yaml | 2 +- argocd/manifests/kube-state-metrics/kustomization.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/argocd/manifests/kube-state-metrics-ringtail/kustomization.yaml b/argocd/manifests/kube-state-metrics-ringtail/kustomization.yaml index 7717ed1..9d36e2d 100644 --- a/argocd/manifests/kube-state-metrics-ringtail/kustomization.yaml +++ b/argocd/manifests/kube-state-metrics-ringtail/kustomization.yaml @@ -7,4 +7,4 @@ resources: images: - name: registry.k8s.io/kube-state-metrics/kube-state-metrics newName: registry.ops.eblu.me/blumeops/kube-state-metrics - newTag: v2.18.0-e2e35cc-nix + newTag: v2.18.0-f59f885-nix diff --git a/argocd/manifests/kube-state-metrics/kustomization.yaml b/argocd/manifests/kube-state-metrics/kustomization.yaml index c370239..efac6ff 100644 --- a/argocd/manifests/kube-state-metrics/kustomization.yaml +++ b/argocd/manifests/kube-state-metrics/kustomization.yaml @@ -7,4 +7,4 @@ resources: images: - name: registry.k8s.io/kube-state-metrics/kube-state-metrics newName: registry.ops.eblu.me/blumeops/kube-state-metrics - newTag: v2.18.0-e2e35cc + newTag: v2.18.0-f59f885 From 936d29bbe1d9d8bedd4ec6b62b414de3f4df6f78 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 8 Apr 2026 07:03:39 -0700 Subject: [PATCH 217/430] Fix UnPoller dashboard UIDs exceeding Grafana 12's 40-char limit Strip redundant "unifi-poller-" prefix from generated slugs, bringing UIDs from 45-48 chars down to 32-35 chars. Co-Authored-By: Claude Opus 4.6 (1M context) --- argocd/manifests/grafana/deployment.yaml | 3 ++- docs/changelog.d/+fix-unpoller-dashboard-uids.bugfix.md | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 docs/changelog.d/+fix-unpoller-dashboard-uids.bugfix.md diff --git a/argocd/manifests/grafana/deployment.yaml b/argocd/manifests/grafana/deployment.yaml index 5fbb8eb..57d3b10 100644 --- a/argocd/manifests/grafana/deployment.yaml +++ b/argocd/manifests/grafana/deployment.yaml @@ -123,8 +123,9 @@ spec: sed -i 's/"uid": *"\${DS_PROMETHEUS}"/"uid": "prometheus"/g' "$DEST"/*.json # Stamp stable top-level UIDs so stars/bookmarks survive pod restarts. # Match root-level uid (2-space indent) to avoid clobbering datasource refs. + # UIDs must be ≤40 chars (Grafana 12+ enforcement). for f in "$DEST"/*.json; do - slug=$(basename "$f" .json | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//') + slug=$(basename "$f" .json | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//' | sed 's/^unifi-poller-//') uid="unpoller-${slug}" sed -i "s/^ \"uid\": *\"[^\"]*\"/ \"uid\": \"${uid}\"/" "$f" done diff --git a/docs/changelog.d/+fix-unpoller-dashboard-uids.bugfix.md b/docs/changelog.d/+fix-unpoller-dashboard-uids.bugfix.md new file mode 100644 index 0000000..f29e05f --- /dev/null +++ b/docs/changelog.d/+fix-unpoller-dashboard-uids.bugfix.md @@ -0,0 +1 @@ +Fix UnPoller (UniFi) Grafana dashboards failing to load due to UID exceeding Grafana 12's 40-character limit. From 0366a0346b0f98a05e3c5647bd8f22a27b2d7094 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 8 Apr 2026 08:43:43 -0700 Subject: [PATCH 218/430] Set Frigate preview quality to CRF 8 for faster timeline loading Previews are ~4MB/hour at default quality (CRF 1), served over NFS from sifaka. Reducing to CRF 8 shrinks preview files to improve review page load times when scrubbing older footage. Co-Authored-By: Claude Opus 4.6 (1M context) --- argocd/manifests/frigate/frigate-config.yml | 3 +++ docs/changelog.d/+frigate-preview-quality.infra.md | 1 + 2 files changed, 4 insertions(+) create mode 100644 docs/changelog.d/+frigate-preview-quality.infra.md diff --git a/argocd/manifests/frigate/frigate-config.yml b/argocd/manifests/frigate/frigate-config.yml index 35f7ccd..b10f18e 100644 --- a/argocd/manifests/frigate/frigate-config.yml +++ b/argocd/manifests/frigate/frigate-config.yml @@ -67,6 +67,9 @@ model: path: /media/frigate/models/yolov9-c-640.onnx labelmap_path: /labelmap/coco-80.txt +preview: + quality: 8 + record: enabled: true continuous: diff --git a/docs/changelog.d/+frigate-preview-quality.infra.md b/docs/changelog.d/+frigate-preview-quality.infra.md new file mode 100644 index 0000000..9b13333 --- /dev/null +++ b/docs/changelog.d/+frigate-preview-quality.infra.md @@ -0,0 +1 @@ +Set Frigate preview quality to CRF 8 (from default 1) to reduce preview file sizes and improve review timeline loading over NFS. From 2eb28301e4c014a1c7d9b089ec1a401c53297649 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 8 Apr 2026 10:53:03 -0700 Subject: [PATCH 219/430] =?UTF-8?q?Upgrade=20authentik=202026.2.0=20?= =?UTF-8?q?=E2=86=92=202026.2.2=20(patch=20release)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug-fix release with web UI fixes, LDAP page size, and SAML SLO redirect. Also bumps client-go to v3.2026.2.1. Co-Authored-By: Claude Opus 4.6 (1M context) --- containers/authentik/default.nix | 2 +- containers/authentik/python-deps.nix | 2 +- containers/authentik/sources.nix | 10 +++++----- docs/changelog.d/+authentik-2026.2.2.infra.md | 1 + service-versions.yaml | 4 ++-- 5 files changed, 10 insertions(+), 9 deletions(-) create mode 100644 docs/changelog.d/+authentik-2026.2.2.infra.md diff --git a/containers/authentik/default.nix b/containers/authentik/default.nix index 5b965bd..e65467a 100644 --- a/containers/authentik/default.nix +++ b/containers/authentik/default.nix @@ -12,7 +12,7 @@ let sources = import ./sources.nix { inherit pkgs; }; # Duplicated from sources.nix so build-container-nix.yaml can grep it - version = "2026.2.0"; + version = "2026.2.2"; webui = import ./webui.nix { inherit pkgs sources; }; authentik-django = import ./authentik-django.nix { inherit pkgs sources webui; }; authentik-server = import ./authentik-server.nix { inherit pkgs sources authentik-django webui; }; diff --git a/containers/authentik/python-deps.nix b/containers/authentik/python-deps.nix index 17d557c..030bac4 100644 --- a/containers/authentik/python-deps.nix +++ b/containers/authentik/python-deps.nix @@ -119,7 +119,7 @@ pkgs.stdenv.mkDerivation { outputHashMode = "recursive"; outputHashAlgo = "sha256"; - outputHash = "sha256-DtpcYQyI07m7v84D/UC28Tj35R9wye6IX+1D0gMZPgY="; + outputHash = "sha256-PMNooWKoEWy/G0G3BLAWEJTqvj3FJi34ibougjwdE+c="; dontFixup = true; } diff --git a/containers/authentik/sources.nix b/containers/authentik/sources.nix index 9134fa8..96aed12 100644 --- a/containers/authentik/sources.nix +++ b/containers/authentik/sources.nix @@ -1,9 +1,9 @@ -# Centralized version and source pinning for authentik 2026.2.0 +# Centralized version and source pinning for authentik 2026.2.2 # All sources fetched from forge mirrors for supply chain control { pkgs ? import { } }: let - version = "2026.2.0"; + version = "2026.2.2"; in { inherit version; @@ -12,14 +12,14 @@ in src = pkgs.fetchgit { url = "https://forge.ops.eblu.me/mirrors/authentik.git"; rev = "version/${version}"; - hash = "sha256-pVQ34cZYX3hlk6hF1aZ/n32xMqTF4Jmp0G0VGDU7iXc="; + hash = "sha256-Xq7JGI/8ppIydIuWd9KRJKUrh7UpeniwvZ4NAtXbYJ4="; }; # Go API client repo — provides config.yaml, go.mod, go.sum, templates client-go-src = pkgs.fetchgit { url = "https://forge.ops.eblu.me/mirrors/authentik-client-go.git"; - rev = "v3.${version}"; - hash = "sha256-DwXw/0QcSDYQKVhPA8tStrSoZooriQex/9FxSJtR/QY="; + rev = "v3.2026.2.1"; + hash = "sha256-sFj+KAFHe3ajOFUtfBl9X3AVIvMCO8+Xba+/Jsy7Cgo="; }; meta = with pkgs.lib; { diff --git a/docs/changelog.d/+authentik-2026.2.2.infra.md b/docs/changelog.d/+authentik-2026.2.2.infra.md new file mode 100644 index 0000000..7cb6e3f --- /dev/null +++ b/docs/changelog.d/+authentik-2026.2.2.infra.md @@ -0,0 +1 @@ +Upgrade authentik 2026.2.0 → 2026.2.2 (bug-fix patch release) diff --git a/service-versions.yaml b/service-versions.yaml index c845629..64d043a 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -155,8 +155,8 @@ services: - name: authentik type: argocd - last-reviewed: "2026-03-01" - current-version: "2026.2.0" + last-reviewed: "2026-04-08" + current-version: "2026.2.2" upstream-source: https://github.com/goauthentik/authentik/releases - name: authentik-redis From ec63d560f3b3601d3e6c71ad5ba7878deeea3407 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 8 Apr 2026 10:56:50 -0700 Subject: [PATCH 220/430] Deploy authentik 2026.2.2 container to ringtail Co-Authored-By: Claude Opus 4.6 (1M context) --- argocd/manifests/authentik/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/argocd/manifests/authentik/kustomization.yaml b/argocd/manifests/authentik/kustomization.yaml index 8aade37..cae2c7f 100644 --- a/argocd/manifests/authentik/kustomization.yaml +++ b/argocd/manifests/authentik/kustomization.yaml @@ -13,7 +13,7 @@ resources: - ingress-tailscale.yaml images: - name: registry.ops.eblu.me/blumeops/authentik - newTag: v2026.2.0-fd0bebb-nix + newTag: v2026.2.2-2eb2830-nix - name: docker.io/library/redis newName: registry.ops.eblu.me/blumeops/authentik-redis newTag: v8.2.3-fd0bebb-nix From 22b77ac1410474644c70c136754a2ddc07335d4a Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 8 Apr 2026 11:12:42 -0700 Subject: [PATCH 221/430] Fix Frigate preview config and services-check NoData detection preview.quality was at the top level (invalid); moved under record with a valid preset (very_low). Also fix services-check to catch Grafana "Alerting (NoData)" state which was silently passing. Co-Authored-By: Claude Opus 4.6 (1M context) --- argocd/manifests/frigate/frigate-config.yml | 5 ++--- mise-tasks/services-check | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/argocd/manifests/frigate/frigate-config.yml b/argocd/manifests/frigate/frigate-config.yml index b10f18e..3033dd4 100644 --- a/argocd/manifests/frigate/frigate-config.yml +++ b/argocd/manifests/frigate/frigate-config.yml @@ -67,11 +67,10 @@ model: path: /media/frigate/models/yolov9-c-640.onnx labelmap_path: /labelmap/coco-80.txt -preview: - quality: 8 - record: enabled: true + preview: + quality: very_low continuous: days: 30 motion: diff --git a/mise-tasks/services-check b/mise-tasks/services-check index 1da86f7..454b769 100755 --- a/mise-tasks/services-check +++ b/mise-tasks/services-check @@ -91,7 +91,7 @@ for a in alerts: continue if '$filter_key' and a['labels'].get('$filter_key') != '$filter_value': continue - if a['state'] in ('Alerting', 'Pending'): + if a['state'] in ('Alerting', 'Pending') or a['state'].startswith('Alerting'): url = a.get('annotations', {}).get('runbook_url', '') summary = a.get('annotations', {}).get('summary', '') print(f'{summary}|{url}') From d3235c5ca9db233347fbed8ebdebfd16d8259908 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 8 Apr 2026 11:28:46 -0700 Subject: [PATCH 222/430] Review adding-a-service tutorial: fix ingress, repoURL, add kustomize and reference card steps - Fix Tailscale Ingress: move hostname to tls.hosts, remove from rules (ProxyGroup compat) - Update ArgoCD repoURL to forge.ops.eblu.me:2222 - Add kustomization.yaml section with :kustomized sentinel tag pattern - Add Step 5: Create a Reference Card (keep under 30s reading time) - Set last-reviewed: 2026-04-08 Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/tutorials/adding-a-service.md | 80 ++++++++++++++++++++++++++---- 1 file changed, 71 insertions(+), 9 deletions(-) diff --git a/docs/tutorials/adding-a-service.md b/docs/tutorials/adding-a-service.md index 5fdce11..52d4965 100644 --- a/docs/tutorials/adding-a-service.md +++ b/docs/tutorials/adding-a-service.md @@ -1,6 +1,7 @@ --- title: Adding a Service -modified: 2026-02-07 +modified: 2026-04-08 +last-reviewed: 2026-04-08 tags: - tutorials - argocd @@ -26,7 +27,8 @@ Adding a service involves: 2. Creating an ArgoCD Application 3. Configuring Tailscale ingress 4. Adding Homepage dashboard entry -5. Setting up Grafana dashboards (optional) +5. Creating a reference card +6. Setting up Grafana dashboards (optional) ## Step 1: Create Manifests Directory @@ -34,12 +36,34 @@ Create a directory for your service's Kubernetes manifests: ``` argocd/manifests// +├── kustomization.yaml ├── deployment.yaml ├── service.yaml ├── ingress-tailscale.yaml └── configmap.yaml # if needed ``` +### Kustomization + +Every service needs a `kustomization.yaml` that lists its resources and pins the container image tag. ArgoCD uses kustomize to render manifests. + +```yaml +# argocd/manifests/myservice/kustomization.yaml +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - deployment.yaml + - service.yaml + - ingress-tailscale.yaml + +images: + - name: registry.ops.eblu.me/myservice + newTag: v1.0.0 +``` + +Use the `:kustomized` sentinel tag in `deployment.yaml` — kustomize replaces it with the `newTag` from above. To deploy a new version, update `newTag` here (not in the deployment). + ### Example Deployment ```yaml @@ -61,7 +85,7 @@ spec: spec: containers: - name: myservice - image: registry.ops.eblu.me/myservice:v1.0.0 + image: registry.ops.eblu.me/myservice:kustomized ports: - containerPort: 8080 ``` @@ -96,9 +120,11 @@ metadata: namespace: myservice spec: ingressClassName: tailscale + tls: + - hosts: + - myservice rules: - - host: myservice - http: + - http: paths: - path: / pathType: Prefix @@ -143,7 +169,7 @@ metadata: spec: project: default source: - repoURL: ssh://forgejo@indri.tail8d86e.ts.net:2200/eblume/blumeops.git + repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git targetRevision: main path: argocd/manifests/myservice destination: @@ -154,7 +180,42 @@ spec: - CreateNamespace=true ``` -## Step 5: Add Caddy Route (Optional) +## Step 5: Create a Reference Card + +Add a reference card at `docs/reference/services/.md` so the service is discoverable in documentation. Keep it short — target a 30-second reading time or less. Include a Quick Reference table with URLs, namespace, and image, then link out to how-to cards or other docs for anything deeper. + +```yaml +--- +title: My Service +modified: 2026-04-08 +tags: + - service +--- +``` + +```markdown +# My Service + +One-sentence description of what the service does. + +## Quick Reference + +| Property | Value | +|----------|-------| +| **URL** | https://myservice.ops.eblu.me | +| **Tailscale URL** | https://myservice.tail8d86e.ts.net | +| **Namespace** | `myservice` | +| **Image** | `registry.ops.eblu.me/myservice` | +| **Manifests** | `argocd/manifests/myservice/` | + +## Related + +- [[adding-a-service]] - Deployment tutorial +``` + +See existing cards like [[navidrome]] or [[kiwix]] for examples. + +## Step 6: Add Caddy Route (Optional) If the service needs to be accessible from other pods or containers, add a Caddy route in `ansible/roles/caddy/defaults/main.yml`: @@ -169,7 +230,7 @@ Then run `mise run provision-indri -- --tags caddy` to apply. This enables access via `https://myservice.ops.eblu.me`. See [[routing]] for details on when this is needed. -## Step 6: Deploy +## Step 7: Deploy ### Testing on a Feature Branch @@ -199,7 +260,7 @@ argocd app set myservice --revision main argocd app sync myservice ``` -## Step 7: Add Observability (Optional) +## Step 8: Add Observability (Optional) ### Prometheus Metrics @@ -241,6 +302,7 @@ See [[grafana]] for dashboard provisioning details. - [ ] ArgoCD Application created in `argocd/apps/` - [ ] Tailscale Ingress configured - [ ] Homepage annotations added +- [ ] Reference card created in `docs/reference/services/` - [ ] Caddy route added (if needed for pod access) - [ ] Feature branch tested via ArgoCD - [ ] Metrics/dashboard configured (if applicable) From e04455c9119dfb165676af69f42bfbf70bc29bf1 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 8 Apr 2026 11:29:54 -0700 Subject: [PATCH 223/430] Add changelog fragment for adding-a-service tutorial review Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/changelog.d/+enhance-adding-a-service-tutorial.doc.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 docs/changelog.d/+enhance-adding-a-service-tutorial.doc.md diff --git a/docs/changelog.d/+enhance-adding-a-service-tutorial.doc.md b/docs/changelog.d/+enhance-adding-a-service-tutorial.doc.md new file mode 100644 index 0000000..3571dbf --- /dev/null +++ b/docs/changelog.d/+enhance-adding-a-service-tutorial.doc.md @@ -0,0 +1 @@ +Enhanced the adding-a-service tutorial with kustomization setup, corrected Tailscale ingress format, updated ArgoCD repoURL, and added a step for creating service reference cards. From 07f52e9488430944d7c2bbbf62c087a86a5eb616 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 8 Apr 2026 17:54:12 -0700 Subject: [PATCH 224/430] Deploy Paperless-ngx document management (#328) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Add paperless-ngx (v2.20.13) as a new ArgoCD-managed service on indri - Dockerfile built from forge mirror (`mirrors/paperless-ngx`), multi-stage with s6-overlay - PostgreSQL database via `blumeops-pg` CNPG cluster, Redis sidecar for Celery - NFS document storage on sifaka (`/volume1/paperless`) - Authentik OIDC SSO via baked JSON blob from 1Password - Caddy route at `paperless.ops.eblu.me` - 1Password item "Paperless (blumeops)" created with all secrets ## Files - `containers/paperless/Dockerfile` — multi-stage build - `argocd/manifests/paperless/` — full k8s manifest set - `argocd/apps/paperless.yaml` — ArgoCD application - `argocd/manifests/databases/` — CNPG role + ExternalSecret - `ansible/roles/caddy/defaults/main.yml` — Caddy route - `service-versions.yaml` — version tracking entry - `docs/reference/services/paperless.md` — reference card ## Remaining deploy steps 1. Build container: `mise run container-build-and-release paperless` 2. Update kustomization.yaml `newTag` with actual image tag 3. Create Authentik application/provider for paperless 4. Create `paperless` database on blumeops-pg 5. Sync ArgoCD apps, then sync paperless from branch 6. Provision Caddy: `mise run provision-indri -- --tags caddy` 7. Verify at https://paperless.ops.eblu.me 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/328 --- ansible/roles/caddy/defaults/main.yml | 3 + argocd/apps/paperless.yaml | 17 ++ .../authentik/configmap-blueprint.yaml | 44 +++++ .../authentik/deployment-worker.yaml | 5 + .../manifests/authentik/external-secret.yaml | 4 + argocd/manifests/databases/blumeops-pg.yaml | 8 + .../databases/external-secret-paperless.yaml | 28 ++++ argocd/manifests/databases/kustomization.yaml | 1 + argocd/manifests/paperless/deployment.yaml | 130 +++++++++++++++ .../manifests/paperless/external-secret.yaml | 31 ++++ .../paperless/ingress-tailscale.yaml | 25 +++ argocd/manifests/paperless/kustomization.yaml | 21 +++ argocd/manifests/paperless/pv-nfs.yaml | 22 +++ argocd/manifests/paperless/pvc.yaml | 15 ++ argocd/manifests/paperless/service.yaml | 13 ++ containers/paperless/Dockerfile | 156 ++++++++++++++++++ docs/changelog.d/deploy-paperless.feature.md | 1 + docs/reference/infrastructure/routing.md | 1 + docs/reference/kubernetes/apps.md | 1 + docs/reference/services/paperless.md | 45 +++++ service-versions.yaml | 7 + 21 files changed, 578 insertions(+) create mode 100644 argocd/apps/paperless.yaml create mode 100644 argocd/manifests/databases/external-secret-paperless.yaml create mode 100644 argocd/manifests/paperless/deployment.yaml create mode 100644 argocd/manifests/paperless/external-secret.yaml create mode 100644 argocd/manifests/paperless/ingress-tailscale.yaml create mode 100644 argocd/manifests/paperless/kustomization.yaml create mode 100644 argocd/manifests/paperless/pv-nfs.yaml create mode 100644 argocd/manifests/paperless/pvc.yaml create mode 100644 argocd/manifests/paperless/service.yaml create mode 100644 containers/paperless/Dockerfile create mode 100644 docs/changelog.d/deploy-paperless.feature.md create mode 100644 docs/reference/services/paperless.md diff --git a/ansible/roles/caddy/defaults/main.yml b/ansible/roles/caddy/defaults/main.yml index f8f9156..ebb210b 100644 --- a/ansible/roles/caddy/defaults/main.yml +++ b/ansible/roles/caddy/defaults/main.yml @@ -92,6 +92,9 @@ caddy_services: - name: mealie host: "meals.{{ caddy_domain }}" backend: "https://meals.tail8d86e.ts.net" + - name: paperless + host: "paperless.{{ caddy_domain }}" + backend: "https://paperless.tail8d86e.ts.net" - name: sifaka host: "nas.{{ caddy_domain }}" backend: "http://sifaka:5000" diff --git a/argocd/apps/paperless.yaml b/argocd/apps/paperless.yaml new file mode 100644 index 0000000..88437eb --- /dev/null +++ b/argocd/apps/paperless.yaml @@ -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 diff --git a/argocd/manifests/authentik/configmap-blueprint.yaml b/argocd/manifests/authentik/configmap-blueprint.yaml index cc3ff43..27910ef 100644 --- a/argocd/manifests/authentik/configmap-blueprint.yaml +++ b/argocd/manifests/authentik/configmap-blueprint.yaml @@ -346,6 +346,50 @@ data: meta_launch_url: https://jellyfin.ops.eblu.me policy_engine_mode: all + paperless.yaml: | + version: 1 + metadata: + name: BlumeOps Paperless SSO + labels: + blueprints.goauthentik.io/description: "Paperless-ngx OIDC provider and application" + entries: + # OAuth2 provider for Paperless-ngx (confidential client) + - model: authentik_providers_oauth2.oauth2provider + id: paperless-provider + identifiers: + name: Paperless + attrs: + name: Paperless + 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: confidential + client_id: paperless + client_secret: !Env AUTHENTIK_PAPERLESS_CLIENT_SECRET + redirect_uris: + - matching_mode: strict + url: https://paperless.ops.eblu.me/accounts/oidc/authentik/login/callback/ + - matching_mode: strict + url: https://paperless.tail8d86e.ts.net/accounts/oidc/authentik/login/callback/ + 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]] + sub_mode: hashed_user_id + include_claims_in_id_token: true + + # Paperless application — all authenticated users allowed + - model: authentik_core.application + id: paperless-app + identifiers: + slug: paperless + attrs: + name: Paperless + slug: paperless + provider: !KeyOf paperless-provider + meta_launch_url: https://paperless.ops.eblu.me + policy_engine_mode: all + mealie.yaml: | version: 1 metadata: diff --git a/argocd/manifests/authentik/deployment-worker.yaml b/argocd/manifests/authentik/deployment-worker.yaml index 5fe473e..b81ec32 100644 --- a/argocd/manifests/authentik/deployment-worker.yaml +++ b/argocd/manifests/authentik/deployment-worker.yaml @@ -85,6 +85,11 @@ spec: secretKeyRef: name: authentik-config key: mealie-client-secret + - name: AUTHENTIK_PAPERLESS_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: authentik-config + key: paperless-client-secret volumeMounts: - name: blueprints mountPath: /blueprints/custom diff --git a/argocd/manifests/authentik/external-secret.yaml b/argocd/manifests/authentik/external-secret.yaml index fb22f2b..9abf699 100644 --- a/argocd/manifests/authentik/external-secret.yaml +++ b/argocd/manifests/authentik/external-secret.yaml @@ -61,3 +61,7 @@ spec: remoteRef: key: "Authentik (blumeops)" property: mealie-client-secret + - secretKey: paperless-client-secret + remoteRef: + key: "Authentik (blumeops)" + property: paperless-client-secret diff --git a/argocd/manifests/databases/blumeops-pg.yaml b/argocd/manifests/databases/blumeops-pg.yaml index 8f1b878..58c771a 100644 --- a/argocd/manifests/databases/blumeops-pg.yaml +++ b/argocd/manifests/databases/blumeops-pg.yaml @@ -65,6 +65,14 @@ spec: createdb: true passwordSecret: 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 resources: diff --git a/argocd/manifests/databases/external-secret-paperless.yaml b/argocd/manifests/databases/external-secret-paperless.yaml new file mode 100644 index 0000000..e5742be --- /dev/null +++ b/argocd/manifests/databases/external-secret-paperless.yaml @@ -0,0 +1,28 @@ +# ExternalSecret for Paperless database user password +# +# 1Password item: "Paperless (blumeops)" in blumeops vault +# Field: "postgresql-password" +# +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: blumeops-pg-paperless + namespace: databases +spec: + refreshInterval: 1h + secretStoreRef: + kind: ClusterSecretStore + name: onepassword-blumeops + target: + name: blumeops-pg-paperless + creationPolicy: Owner + template: + type: kubernetes.io/basic-auth + data: + username: paperless + password: "{{ .password }}" + data: + - secretKey: password + remoteRef: + key: Paperless (blumeops) + property: postgresql-password diff --git a/argocd/manifests/databases/kustomization.yaml b/argocd/manifests/databases/kustomization.yaml index 68d28b2..b25e09e 100644 --- a/argocd/manifests/databases/kustomization.yaml +++ b/argocd/manifests/databases/kustomization.yaml @@ -14,3 +14,4 @@ resources: - external-secret-immich-borgmatic.yaml - external-secret-teslamate.yaml - external-secret-authentik.yaml + - external-secret-paperless.yaml diff --git a/argocd/manifests/paperless/deployment.yaml b/argocd/manifests/paperless/deployment.yaml new file mode 100644 index 0000000..cc2c013 --- /dev/null +++ b/argocd/manifests/paperless/deployment.yaml @@ -0,0 +1,130 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: paperless + namespace: paperless +spec: + replicas: 1 + selector: + matchLabels: + app: paperless + template: + metadata: + labels: + app: paperless + spec: + securityContext: + seccompProfile: + type: RuntimeDefault + containers: + - name: paperless + image: registry.ops.eblu.me/blumeops/paperless:kustomized + ports: + - containerPort: 8000 + name: http + env: + - name: PAPERLESS_URL + value: "https://paperless.ops.eblu.me" + - name: PAPERLESS_REDIS + value: "redis://localhost:6379" + - name: PAPERLESS_DBHOST + value: "pg.ops.eblu.me" + - name: PAPERLESS_DBPORT + value: "5432" + - name: PAPERLESS_DBNAME + 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 + value: "paperless" + - name: PAPERLESS_DBPASS + valueFrom: + secretKeyRef: + name: paperless-secrets + key: db-password + - name: PAPERLESS_SECRET_KEY + valueFrom: + secretKeyRef: + name: paperless-secrets + key: secret-key + - name: PAPERLESS_TIME_ZONE + value: "America/Los_Angeles" + - name: PAPERLESS_OCR_LANGUAGE + value: "eng" + - name: PAPERLESS_TASK_WORKERS + value: "1" + # Admin account (created on first startup) + - name: PAPERLESS_ADMIN_USER + value: "eblume" + - name: PAPERLESS_ADMIN_PASSWORD + valueFrom: + secretKeyRef: + name: paperless-secrets + key: admin-password + - name: PAPERLESS_ADMIN_MAIL + value: "blume.erich@gmail.com" + # OIDC via Authentik + # Full JSON blob pulled from 1Password (includes client secret) + - name: PAPERLESS_APPS + value: "allauth.socialaccount.providers.openid_connect" + - name: PAPERLESS_SOCIALACCOUNT_PROVIDERS + valueFrom: + secretKeyRef: + name: paperless-secrets + key: socialaccount-providers + - name: PAPERLESS_SOCIALACCOUNT_ALLOW_SIGNUPS + value: "true" + - name: PAPERLESS_SOCIAL_AUTO_SIGNUP + value: "true" + - name: PAPERLESS_ACCOUNT_ALLOW_SIGNUPS + value: "false" + - name: PAPERLESS_REDIRECT_LOGIN_TO_SSO + value: "false" + volumeMounts: + - name: data + mountPath: /usr/src/paperless/data + - name: media + mountPath: /usr/src/paperless/media + - name: consume + mountPath: /usr/src/paperless/consume + resources: + requests: + memory: "256Mi" + cpu: "100m" + limits: + memory: "2Gi" + cpu: "1000m" + livenessProbe: + httpGet: + path: / + port: 8000 + initialDelaySeconds: 60 + periodSeconds: 30 + readinessProbe: + httpGet: + path: / + port: 8000 + initialDelaySeconds: 30 + periodSeconds: 10 + + - name: redis + image: docker.io/library/redis:kustomized + ports: + - containerPort: 6379 + resources: + requests: + memory: "32Mi" + cpu: "10m" + limits: + memory: "128Mi" + + volumes: + - name: data + emptyDir: {} + - name: media + persistentVolumeClaim: + claimName: paperless-media + - name: consume + emptyDir: {} diff --git a/argocd/manifests/paperless/external-secret.yaml b/argocd/manifests/paperless/external-secret.yaml new file mode 100644 index 0000000..750b7c5 --- /dev/null +++ b/argocd/manifests/paperless/external-secret.yaml @@ -0,0 +1,31 @@ +--- +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: paperless-secrets + namespace: paperless +spec: + refreshInterval: 1h + secretStoreRef: + kind: ClusterSecretStore + name: onepassword-blumeops + target: + name: paperless-secrets + creationPolicy: Owner + data: + - secretKey: db-password + remoteRef: + key: "Paperless (blumeops)" + property: postgresql-password + - secretKey: secret-key + remoteRef: + key: "Paperless (blumeops)" + property: secret-key + - secretKey: admin-password + remoteRef: + key: "Paperless (blumeops)" + property: admin-password + - secretKey: socialaccount-providers + remoteRef: + key: "Paperless (blumeops)" + property: socialaccount-providers diff --git a/argocd/manifests/paperless/ingress-tailscale.yaml b/argocd/manifests/paperless/ingress-tailscale.yaml new file mode 100644 index 0000000..d09ef67 --- /dev/null +++ b/argocd/manifests/paperless/ingress-tailscale.yaml @@ -0,0 +1,25 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: paperless-tailscale + namespace: paperless + annotations: + tailscale.com/proxy-class: "default" + tailscale.com/proxy-group: "ingress" + gethomepage.dev/enabled: "true" + gethomepage.dev/name: "Paperless" + gethomepage.dev/group: "Home" + gethomepage.dev/icon: "paperless-ngx.png" + gethomepage.dev/description: "Document management" + gethomepage.dev/href: "https://paperless.ops.eblu.me" + gethomepage.dev/pod-selector: "app=paperless" +spec: + ingressClassName: tailscale + defaultBackend: + service: + name: paperless + port: + number: 8000 + tls: + - hosts: + - paperless diff --git a/argocd/manifests/paperless/kustomization.yaml b/argocd/manifests/paperless/kustomization.yaml new file mode 100644 index 0000000..0810a44 --- /dev/null +++ b/argocd/manifests/paperless/kustomization.yaml @@ -0,0 +1,21 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: paperless + +resources: + - deployment.yaml + - service.yaml + - pv-nfs.yaml + - pvc.yaml + - ingress-tailscale.yaml + - external-secret.yaml + +images: + - name: registry.ops.eblu.me/blumeops/paperless + newTag: v2.20.13-42f6299 + # TODO: borrowing authentik-redis image — consider building a generic + # blumeops/redis container if more services need Redis sidecars + - name: docker.io/library/redis + newName: registry.ops.eblu.me/blumeops/authentik-redis + newTag: v8.2.3-fd0bebb-nix diff --git a/argocd/manifests/paperless/pv-nfs.yaml b/argocd/manifests/paperless/pv-nfs.yaml new file mode 100644 index 0000000..8ee7526 --- /dev/null +++ b/argocd/manifests/paperless/pv-nfs.yaml @@ -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 diff --git a/argocd/manifests/paperless/pvc.yaml b/argocd/manifests/paperless/pvc.yaml new file mode 100644 index 0000000..4365c9f --- /dev/null +++ b/argocd/manifests/paperless/pvc.yaml @@ -0,0 +1,15 @@ +# PersistentVolumeClaim for Paperless document library +# Binds to the NFS PV for sifaka:/volume1/paperless +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: paperless-media + namespace: paperless +spec: + accessModes: + - ReadWriteMany + storageClassName: "" + volumeName: paperless-media-nfs-pv + resources: + requests: + storage: 500Gi diff --git a/argocd/manifests/paperless/service.yaml b/argocd/manifests/paperless/service.yaml new file mode 100644 index 0000000..cff2972 --- /dev/null +++ b/argocd/manifests/paperless/service.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Service +metadata: + name: paperless + namespace: paperless +spec: + selector: + app: paperless + ports: + - name: http + port: 8000 + targetPort: 8000 + protocol: TCP diff --git a/containers/paperless/Dockerfile b/containers/paperless/Dockerfile new file mode 100644 index 0000000..a7b4e65 --- /dev/null +++ b/containers/paperless/Dockerfile @@ -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" diff --git a/docs/changelog.d/deploy-paperless.feature.md b/docs/changelog.d/deploy-paperless.feature.md new file mode 100644 index 0000000..07b7899 --- /dev/null +++ b/docs/changelog.d/deploy-paperless.feature.md @@ -0,0 +1 @@ +Deploy Paperless-ngx document management system at paperless.ops.eblu.me with OCR, Authentik SSO, and NFS storage on sifaka. diff --git a/docs/reference/infrastructure/routing.md b/docs/reference/infrastructure/routing.md index c85dbb5..a8049d6 100644 --- a/docs/reference/infrastructure/routing.md +++ b/docs/reference/infrastructure/routing.md @@ -41,6 +41,7 @@ DNS points to [[indri]]'s Tailscale IP. TLS via Let's Encrypt (ACME DNS-01 with | [[jellyfin]] | https://jellyfin.ops.eblu.me | Media server | | [[postgresql]] | pg.ops.eblu.me:5432 | Database | | [[mealie]] | https://meals.ops.eblu.me | Recipe manager | +| [[paperless]] | https://paperless.ops.eblu.me | Document management | | [[sifaka|Sifaka]] | https://nas.ops.eblu.me | NAS dashboard | ## Public Services (`*.eblu.me`) diff --git a/docs/reference/kubernetes/apps.md b/docs/reference/kubernetes/apps.md index e162c7a..80ea72e 100644 --- a/docs/reference/kubernetes/apps.md +++ b/docs/reference/kubernetes/apps.md @@ -40,6 +40,7 @@ Registry of all applications deployed via [[argocd]]. | `forgejo-runner` | forgejo-runner | `argocd/manifests/forgejo-runner/` | [[forgejo]] CI | | `ollama` | ollama | `argocd/manifests/ollama/` | [[ollama]] | | `mealie` | mealie | `argocd/manifests/mealie/` | [[mealie]] | +| `paperless` | paperless | `argocd/manifests/paperless/` | [[paperless]] | | `prowler` | prowler | `argocd/manifests/prowler/` | [[prowler]] | ## Sync Policies diff --git a/docs/reference/services/paperless.md b/docs/reference/services/paperless.md new file mode 100644 index 0000000..c74543e --- /dev/null +++ b/docs/reference/services/paperless.md @@ -0,0 +1,45 @@ +--- +title: Paperless-ngx +modified: 2026-04-08 +tags: + - service +--- + +# Paperless-ngx + +Self-hosted document management system with OCR, tagging, and full-text search. + +## Quick Reference + +| Property | Value | +|----------|-------| +| **URL** | https://paperless.ops.eblu.me | +| **Tailscale URL** | https://paperless.tail8d86e.ts.net | +| **Namespace** | `paperless` | +| **Image** | `registry.ops.eblu.me/blumeops/paperless` | +| **Manifests** | `argocd/manifests/paperless/` | +| **Container source** | `containers/paperless/Dockerfile` | +| **Upstream** | [paperless-ngx/paperless-ngx](https://github.com/paperless-ngx/paperless-ngx) | +| **Database** | `paperless` on [[postgresql|blumeops-pg]] | +| **Storage** | NFS on [[sifaka]] at `/volume1/paperless` | +| **Auth** | [[authentik]] OIDC + local admin | + +## Architecture + +- **Web server**: Granian (ASGI), port 8000 +- **Task queue**: Celery worker + beat (Redis sidecar) +- **OCR**: Tesseract (English) +- **Process supervisor**: s6-overlay + +## Secrets + +1Password item "Paperless (blumeops)" in vault `blumeops`: +- `secret-key`: Django SECRET_KEY +- `postgresql-password`: database credential +- `admin-password`: initial admin account password +- `socialaccount-providers`: OIDC provider JSON (includes Authentik client secret) + +## Related + +- [[adding-a-service]] — Deployment tutorial +- [[authentik]] — SSO provider diff --git a/service-versions.yaml b/service-versions.yaml index 64d043a..9a94c6a 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -294,6 +294,13 @@ services: upstream-source: https://github.com/mealie-recipes/mealie/releases notes: Recipe manager; built from source via forge mirror + - name: paperless + type: argocd + last-reviewed: "2026-04-08" + current-version: "v2.20.13" + upstream-source: https://github.com/paperless-ngx/paperless-ngx/releases + notes: Document management; built from source via forge mirror + - name: unpoller type: argocd last-reviewed: 2026-03-16 From 22fc615a28786407fbc9fa1bc6c57cd95d3585f8 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 8 Apr 2026 19:01:02 -0700 Subject: [PATCH 225/430] Update paperless image tag to main build Co-Authored-By: Claude Opus 4.6 (1M context) --- argocd/manifests/paperless/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/argocd/manifests/paperless/kustomization.yaml b/argocd/manifests/paperless/kustomization.yaml index 0810a44..51df2c1 100644 --- a/argocd/manifests/paperless/kustomization.yaml +++ b/argocd/manifests/paperless/kustomization.yaml @@ -13,7 +13,7 @@ resources: images: - name: registry.ops.eblu.me/blumeops/paperless - newTag: v2.20.13-42f6299 + newTag: v2.20.13-07f52e9 # TODO: borrowing authentik-redis image — consider building a generic # blumeops/redis container if more services need Redis sidecars - name: docker.io/library/redis From 5757df115d6a912706f1179a5298a02c91f0bafa Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Thu, 9 Apr 2026 06:42:05 -0700 Subject: [PATCH 226/430] Upgrade ollama from 0.17.5 to 0.20.4 Co-Authored-By: Claude Opus 4.6 (1M context) --- argocd/manifests/ollama/kustomization.yaml | 2 +- docs/changelog.d/+ollama-0.20.4.infra.md | 1 + service-versions.yaml | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) create mode 100644 docs/changelog.d/+ollama-0.20.4.infra.md diff --git a/argocd/manifests/ollama/kustomization.yaml b/argocd/manifests/ollama/kustomization.yaml index 75add74..fd54eec 100644 --- a/argocd/manifests/ollama/kustomization.yaml +++ b/argocd/manifests/ollama/kustomization.yaml @@ -11,7 +11,7 @@ resources: images: - name: ollama/ollama - newTag: "0.17.5" + newTag: "0.20.4" configMapGenerator: - name: ollama-models diff --git a/docs/changelog.d/+ollama-0.20.4.infra.md b/docs/changelog.d/+ollama-0.20.4.infra.md new file mode 100644 index 0000000..e93c8c7 --- /dev/null +++ b/docs/changelog.d/+ollama-0.20.4.infra.md @@ -0,0 +1 @@ +Upgrade ollama from 0.17.5 to 0.20.4 (adds Gemma 4 support, benchmark tooling, Apple Silicon perf improvements) diff --git a/service-versions.yaml b/service-versions.yaml index 9a94c6a..af9f8ec 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -171,8 +171,8 @@ services: - name: ollama type: argocd - last-reviewed: "2026-03-02" - current-version: "0.17.5" + last-reviewed: "2026-04-09" + current-version: "0.20.4" upstream-source: https://github.com/ollama/ollama/releases notes: LLM inference server on ringtail (GPU); upstream container image From 40556e5a2de7e5f92d5afe2ffddfa4c5b6909cd0 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Thu, 9 Apr 2026 09:54:46 -0700 Subject: [PATCH 227/430] Review gandi.md: add missing forge.eblu.me CNAME record The Pulumi code has had a forge.eblu.me CNAME since it was added, but the doc's DNS table only listed docs and cv. Also fixed the __main__.py description to mention CNAMEs alongside A records. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/changelog.d/+review-gandi-doc.doc.md | 1 + docs/reference/infrastructure/gandi.md | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) create mode 100644 docs/changelog.d/+review-gandi-doc.doc.md diff --git a/docs/changelog.d/+review-gandi-doc.doc.md b/docs/changelog.d/+review-gandi-doc.doc.md new file mode 100644 index 0000000..1f85ce0 --- /dev/null +++ b/docs/changelog.d/+review-gandi-doc.doc.md @@ -0,0 +1 @@ +Review gandi.md: add missing forge.eblu.me CNAME, fix program description, stamp review date. diff --git a/docs/reference/infrastructure/gandi.md b/docs/reference/infrastructure/gandi.md index c374b05..ae1fe56 100644 --- a/docs/reference/infrastructure/gandi.md +++ b/docs/reference/infrastructure/gandi.md @@ -1,6 +1,7 @@ --- title: Gandi -modified: 2026-02-17 +modified: 2026-04-09 +last-reviewed: 2026-04-09 tags: - infrastructure - networking @@ -43,6 +44,7 @@ Both records point to [[indri]], which runs [[caddy]] as the reverse proxy for a |--------|------|-------|-----| | `docs.eblu.me` | CNAME | `blumeops-proxy.fly.dev` | 300s | | `cv.eblu.me` | CNAME | `blumeops-proxy.fly.dev` | 300s | +| `forge.eblu.me` | CNAME | `blumeops-proxy.fly.dev` | 300s | Public CNAMEs point to [[flyio-proxy]] on Fly.io. See [[expose-service-publicly]] for adding new public services. @@ -52,7 +54,7 @@ See [[routing]] for the full service URL map. The Pulumi program lives in `pulumi/gandi/`: -- `__main__.py` - Creates the two A records via `pulumiverse_gandi` +- `__main__.py` - Creates A and CNAME records via `pulumiverse_gandi` - `Pulumi.eblu-me.yaml` - Stack config (domain, subdomain) Stack config values: From a75f28e073dda0c39af58e5ea319630bd2b6c329 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Fri, 10 Apr 2026 19:00:33 -0700 Subject: [PATCH 228/430] Fix fly.io proxy rate limit to key on real client IP The general rate limit zone used $binary_remote_addr (Fly's internal proxy IP), causing all external clients to share one bucket. Switch to $http_fly_client_ip to match forge_auth's correct behavior. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/changelog.d/+fix-flyio-rate-limit-key.bugfix.md | 1 + fly/nginx.conf | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 docs/changelog.d/+fix-flyio-rate-limit-key.bugfix.md diff --git a/docs/changelog.d/+fix-flyio-rate-limit-key.bugfix.md b/docs/changelog.d/+fix-flyio-rate-limit-key.bugfix.md new file mode 100644 index 0000000..1473ab1 --- /dev/null +++ b/docs/changelog.d/+fix-flyio-rate-limit-key.bugfix.md @@ -0,0 +1 @@ +Fix Fly.io proxy rate limiting to key on real client IP instead of Fly's internal proxy IP, so crawlers no longer consume the shared rate limit bucket for all clients. diff --git a/fly/nginx.conf b/fly/nginx.conf index 992a5df..75cd102 100644 --- a/fly/nginx.conf +++ b/fly/nginx.conf @@ -27,7 +27,7 @@ http { access_log /var/log/nginx/access.json.log json_log; # Rate limiting zones — define per-service zones as needed - limit_req_zone $binary_remote_addr zone=general:10m rate=10r/s; + limit_req_zone $http_fly_client_ip zone=general:10m rate=10r/s; # Forge-specific rate limit keyed on real client IP (Fly-Client-IP header). # $binary_remote_addr is Fly's internal proxy IP — all clients share one From b08b1a833f1ca2a73a7272b22571d4d109a4da45 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Fri, 10 Apr 2026 19:10:09 -0700 Subject: [PATCH 229/430] Fix services-check to show all firing alerts per alert name check_alert() used head -1 to display only the first firing instance, silently swallowing additional alerts (e.g. frigate pod-not-ready was hidden behind ollama). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../+services-check-show-all-alerts.bugfix.md | 1 + mise-tasks/services-check | 19 ++++++++++--------- 2 files changed, 11 insertions(+), 9 deletions(-) create mode 100644 docs/changelog.d/+services-check-show-all-alerts.bugfix.md diff --git a/docs/changelog.d/+services-check-show-all-alerts.bugfix.md b/docs/changelog.d/+services-check-show-all-alerts.bugfix.md new file mode 100644 index 0000000..221748a --- /dev/null +++ b/docs/changelog.d/+services-check-show-all-alerts.bugfix.md @@ -0,0 +1 @@ +Fix services-check to display all firing alerts for a given alert name, not just the first one. diff --git a/mise-tasks/services-check b/mise-tasks/services-check index 454b769..1e90f93 100755 --- a/mise-tasks/services-check +++ b/mise-tasks/services-check @@ -100,16 +100,17 @@ for a in alerts: if [ -z "$firing" ]; then echo -e "${GREEN}OK${NC}" else - local summary runbook - summary=$(echo "$firing" | head -1 | cut -d'|' -f1) - runbook=$(echo "$firing" | head -1 | cut -d'|' -f2) echo -e "${RED}FIRING${NC}" - if [ -n "$summary" ]; then - echo -e " $summary" - fi - if [ -n "$runbook" ]; then - echo -e " Runbook: $runbook" - fi + local runbook_printed=false + while IFS='|' read -r summary runbook; do + if [ -n "$summary" ]; then + echo -e " $summary" + fi + if [ -n "$runbook" ] && [ "$runbook_printed" = false ]; then + echo -e " Runbook: $runbook" + runbook_printed=true + fi + done <<< "$firing" FAILED=1 fi } From e02305e72d455469121ae9ef528444c6a7397de2 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Fri, 10 Apr 2026 19:32:38 -0700 Subject: [PATCH 230/430] Pin Fly.io Tailscale to v1.94.1 to fix MagicDNS regression in v1.96.5 Tailscale :stable pulled v1.96.5 during last deploy, which returns SERVFAIL for tailnet DNS names (no upstream resolvers set). This broke all public routing (forge/docs/cv.eblu.me) through the Fly proxy. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/changelog.d/+pin-tailscale-fly.bugfix.md | 1 + fly/Dockerfile | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) create mode 100644 docs/changelog.d/+pin-tailscale-fly.bugfix.md diff --git a/docs/changelog.d/+pin-tailscale-fly.bugfix.md b/docs/changelog.d/+pin-tailscale-fly.bugfix.md new file mode 100644 index 0000000..59f1e30 --- /dev/null +++ b/docs/changelog.d/+pin-tailscale-fly.bugfix.md @@ -0,0 +1 @@ +Pin Fly.io proxy Tailscale to v1.94.1 — the `:stable` tag pulled v1.96.5 which has a MagicDNS regression (SERVFAIL on tailnet names), breaking all public routing through forge.eblu.me, docs.eblu.me, and cv.eblu.me. diff --git a/fly/Dockerfile b/fly/Dockerfile index 3f866fa..8a6df31 100644 --- a/fly/Dockerfile +++ b/fly/Dockerfile @@ -1,9 +1,9 @@ FROM nginx:1.29.6-alpine # Copy tailscale binaries from official image -COPY --from=docker.io/tailscale/tailscale:stable \ +COPY --from=docker.io/tailscale/tailscale:v1.94.1 \ /usr/local/bin/tailscaled /usr/local/bin/tailscaled -COPY --from=docker.io/tailscale/tailscale:stable \ +COPY --from=docker.io/tailscale/tailscale:v1.94.1 \ /usr/local/bin/tailscale /usr/local/bin/tailscale RUN mkdir -p /var/run/tailscale /var/lib/tailscale \ From 4fc019273108b64833db75c7fac5f374d782dbef Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Fri, 10 Apr 2026 19:40:57 -0700 Subject: [PATCH 231/430] Track Fly.io proxy component versions in service-versions.yaml Add flyio-tailscale (v1.94.1), flyio-nginx (1.29.6-alpine), and flyio-alloy (v1.14.1) entries with new `fly` service type so future upgrades go through the service-review workflow. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/changelog.d/+track-fly-versions.infra.md | 1 + service-versions.yaml | 26 ++++++++++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 docs/changelog.d/+track-fly-versions.infra.md diff --git a/docs/changelog.d/+track-fly-versions.infra.md b/docs/changelog.d/+track-fly-versions.infra.md new file mode 100644 index 0000000..2ec5b87 --- /dev/null +++ b/docs/changelog.d/+track-fly-versions.infra.md @@ -0,0 +1 @@ +Track Fly.io proxy component versions (Tailscale, nginx, Alloy) in service-versions.yaml with new `fly` service type. diff --git a/service-versions.yaml b/service-versions.yaml index af9f8ec..4f58a5b 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -5,7 +5,7 @@ # # Fields: # name - kebab-case service identifier -# type - argocd | ansible | nixos +# type - argocd | ansible | nixos | fly # last-reviewed - date (YYYY-MM-DD) or null # current-version - deployed version string or null # upstream-source - URL to upstream releases/changelog @@ -369,3 +369,27 @@ services: current-version: "1.11.0" upstream-source: https://www.pixeleyes.co.nz/automounter/ notes: Mac App Store app, no Ansible role. Updates via App Store. + + - name: flyio-tailscale + type: fly + last-reviewed: "2026-04-10" + current-version: "v1.94.1" + upstream-source: https://github.com/tailscale/tailscale/releases + notes: >- + Pinned after v1.96.5 broke MagicDNS in containers. Test DNS resolution + inside Fly container before upgrading. COPY --from in fly/Dockerfile. + + - name: flyio-nginx + type: fly + last-reviewed: "2026-04-10" + current-version: "1.29.6-alpine" + upstream-source: https://hub.docker.com/_/nginx + notes: Base image for Fly proxy (fly/Dockerfile) + + - name: flyio-alloy + type: fly + parent: flyio-nginx + last-reviewed: "2026-04-10" + current-version: "v1.14.1" + upstream-source: https://github.com/grafana/alloy/releases + notes: COPY --from in fly/Dockerfile for log shipping and metrics From c86b5d777254154024b47799a84a5193d646c184 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sat, 11 Apr 2026 17:11:56 -0700 Subject: [PATCH 232/430] Native Dagger container builds + Navidrome v0.61.1 (#330) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Move Dagger module from `.dagger/` to repo root (`src/blumeops/`), rename `blumeops-ci` → `blumeops` - Replace opaque `docker_build()` with native Dagger pipelines that surface full build errors per step - Migrate navidrome as the first container (`containers/navidrome/container.py`) - Upgrade navidrome from v0.60.3 to v0.61.1 (major artwork overhaul, SQLite FTS5 search, server-managed transcoding) - Add `dagger call container-version` for CI version extraction without Dockerfile parsing - All mise tasks (`container-list`, `container-version-check`, `container-build-and-release`) updated for hybrid mode - Legacy `docker_build()` fallback preserved for all other containers ## Motivation When navidrome v0.61.0 added a new Go build tag (`sqlite_fts5`), `docker_build()` showed only "exit code: 1". We had to run `docker build --progress=plain` manually to find `undefined: buildtags.SQLITE_FTS5`. Native Dagger pipelines show the full error inline. ## Container build dispatch needed After merge, dispatch container build for navidrome: ``` mise run container-build-and-release navidrome --ref 470b4bd ``` ## Deploy steps 1. Wait for container build to complete 2. Back up navidrome-data PVC (non-reversible DB migrations) 3. `argocd app set navidrome --revision main && argocd app sync navidrome` 4. Verify at https://dj.ops.eblu.me ## Future Remaining containers migrate incrementally in follow-up PRs using the same pattern. Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/330 --- .dagger/.gitignore | 4 - .dagger/src/blumeops_ci/__init__.py | 3 - .dagger/uv.lock | 768 ------------------ .forgejo/workflows/build-container.yaml | 35 +- .dagger/.gitattributes => .gitattributes | 0 .gitignore | 3 + argocd/manifests/navidrome/kustomization.yaml | 2 +- containers/navidrome/Dockerfile | 57 -- containers/navidrome/container.py | 54 ++ dagger.json | 4 +- .../native-dagger-containers.infra.md | 1 + .../upgrade-navidrome-v0.61.1.feature.md | 1 + docs/how-to/dagger/upgrade-dagger.md | 6 +- .../deployment/build-container-image.md | 58 +- .../validate-workflows-against-v12.md | 4 +- docs/how-to/knowledgebase/review-services.md | 1 + .../zot/add-container-version-sync-check.md | 17 +- docs/how-to/zot/add-dagger-nix-build.md | 6 +- .../zot/adopt-commit-based-container-tags.md | 4 +- docs/how-to/zot/harden-zot-registry.md | 4 +- docs/how-to/zot/pin-container-versions.md | 6 +- docs/how-to/zot/wire-ci-registry-auth.md | 4 +- docs/reference/tools/dagger.md | 23 +- docs/reference/tools/mise-tasks.md | 4 +- mise-tasks/container-build-and-release | 14 +- mise-tasks/container-list | 5 +- mise-tasks/container-version-check | 39 +- mise-tasks/service-review | 14 +- .dagger/pyproject.toml => pyproject.toml | 2 +- service-versions.yaml | 4 +- src/blumeops/__init__.py | 3 + src/blumeops/containers.py | 167 ++++ .../src/blumeops_ci => src/blumeops}/main.py | 40 +- 33 files changed, 425 insertions(+), 932 deletions(-) delete mode 100644 .dagger/.gitignore delete mode 100644 .dagger/src/blumeops_ci/__init__.py delete mode 100644 .dagger/uv.lock rename .dagger/.gitattributes => .gitattributes (100%) delete mode 100644 containers/navidrome/Dockerfile create mode 100644 containers/navidrome/container.py create mode 100644 docs/changelog.d/native-dagger-containers.infra.md create mode 100644 docs/changelog.d/upgrade-navidrome-v0.61.1.feature.md rename .dagger/pyproject.toml => pyproject.toml (91%) create mode 100644 src/blumeops/__init__.py create mode 100644 src/blumeops/containers.py rename {.dagger/src/blumeops_ci => src/blumeops}/main.py (89%) diff --git a/.dagger/.gitignore b/.dagger/.gitignore deleted file mode 100644 index 2343dfc..0000000 --- a/.dagger/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -/.venv -/**/__pycache__ -/sdk -/.env diff --git a/.dagger/src/blumeops_ci/__init__.py b/.dagger/src/blumeops_ci/__init__.py deleted file mode 100644 index a3601d0..0000000 --- a/.dagger/src/blumeops_ci/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""BlumeOps CI — Dagger build functions for container images.""" - -from .main import BlumeopsCi as BlumeopsCi diff --git a/.dagger/uv.lock b/.dagger/uv.lock deleted file mode 100644 index 13acbbf..0000000 --- a/.dagger/uv.lock +++ /dev/null @@ -1,768 +0,0 @@ -version = 1 -revision = 3 -requires-python = ">=3.13" - -[[package]] -name = "anyio" -version = "4.12.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, -] - -[[package]] -name = "attrs" -version = "25.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, -] - -[[package]] -name = "backoff" -version = "2.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, -] - -[[package]] -name = "beartype" -version = "0.22.9" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/94/1009e248bbfbab11397abca7193bea6626806be9a327d399810d523a07cb/beartype-0.22.9.tar.gz", hash = "sha256:8f82b54aa723a2848a56008d18875f91c1db02c32ef6a62319a002e3e25a975f", size = 1608866, upload-time = "2025-12-13T06:50:30.72Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" }, -] - -[[package]] -name = "blumeops-ci" -version = "0.1.0" -source = { editable = "." } -dependencies = [ - { name = "dagger-io" }, -] - -[package.metadata] -requires-dist = [{ name = "dagger-io", editable = "sdk" }] - -[[package]] -name = "cattrs" -version = "25.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/6e/00/2432bb2d445b39b5407f0a90e01b9a271475eea7caf913d7a86bcb956385/cattrs-25.3.0.tar.gz", hash = "sha256:1ac88d9e5eda10436c4517e390a4142d88638fe682c436c93db7ce4a277b884a", size = 509321, upload-time = "2025-10-07T12:26:08.737Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d8/2b/a40e1488fdfa02d3f9a653a61a5935ea08b3c2225ee818db6a76c7ba9695/cattrs-25.3.0-py3-none-any.whl", hash = "sha256:9896e84e0a5bf723bc7b4b68f4481785367ce07a8a02e7e9ee6eb2819bc306ff", size = 70738, upload-time = "2025-10-07T12:26:06.603Z" }, -] - -[[package]] -name = "certifi" -version = "2026.1.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, -] - -[[package]] -name = "charset-normalizer" -version = "3.4.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, - { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, - { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, - { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, - { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, - { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, - { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, - { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, - { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, - { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, - { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, - { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, - { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, - { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, - { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, - { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, - { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, - { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, - { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, - { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, - { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, - { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, - { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, - { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, - { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, - { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, - { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, - { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, - { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, - { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, - { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, - { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, - { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, -] - -[[package]] -name = "dagger-io" -version = "0.0.0" -source = { editable = "sdk" } -dependencies = [ - { name = "anyio" }, - { name = "beartype" }, - { name = "cattrs" }, - { name = "exceptiongroup" }, - { name = "gql", extra = ["httpx"] }, - { name = "httpcore" }, - { name = "opentelemetry-exporter-otlp-proto-http" }, - { name = "opentelemetry-instrumentation-logging" }, - { name = "opentelemetry-sdk" }, - { name = "platformdirs" }, - { name = "rich" }, - { name = "typing-extensions" }, -] - -[package.metadata] -requires-dist = [ - { name = "anyio", specifier = ">=3.6.2" }, - { name = "beartype", specifier = ">=0.22.0" }, - { name = "cattrs", specifier = ">=25.1.0" }, - { name = "exceptiongroup", specifier = ">=1.3.0" }, - { name = "gql", extras = ["httpx"], specifier = ">=4.0" }, - { name = "httpcore", specifier = ">=1.0.8" }, - { name = "opentelemetry-exporter-otlp-proto-http", specifier = ">=1.23.0" }, - { name = "opentelemetry-instrumentation-logging", specifier = ">=0.54b1" }, - { name = "opentelemetry-sdk", specifier = ">=1.23.0" }, - { name = "platformdirs", specifier = ">=2.6.2" }, - { name = "rich", specifier = ">=10.11.0" }, - { name = "typing-extensions", specifier = ">=4.13.0" }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "aiohttp", specifier = ">=3.9.3" }, - { name = "codegen", editable = "sdk/codegen" }, - { name = "mypy", specifier = ">=1.8.0" }, - { name = "pytest", specifier = ">=8.0.2" }, - { name = "pytest-httpx", specifier = ">=0.30.0" }, - { name = "pytest-mock", specifier = ">=3.12.0" }, - { name = "pytest-subprocess", specifier = ">=1.5.0" }, - { name = "ruff", specifier = ">=0.3.4" }, - { name = "sphinx", specifier = ">=7.2.6" }, - { name = "sphinx-rtd-theme", specifier = ">=2.0.0" }, -] - -[[package]] -name = "exceptiongroup" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, -] - -[[package]] -name = "googleapis-common-protos" -version = "1.72.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e5/7b/adfd75544c415c487b33061fe7ae526165241c1ea133f9a9125a56b39fd8/googleapis_common_protos-1.72.0.tar.gz", hash = "sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5", size = 147433, upload-time = "2025-11-06T18:29:24.087Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c4/ab/09169d5a4612a5f92490806649ac8d41e3ec9129c636754575b3553f4ea4/googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", size = 297515, upload-time = "2025-11-06T18:29:13.14Z" }, -] - -[[package]] -name = "gql" -version = "4.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "backoff" }, - { name = "graphql-core" }, - { name = "yarl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/06/9f/cf224a88ed71eb223b7aa0b9ff0aa10d7ecc9a4acdca2279eb046c26d5dc/gql-4.0.0.tar.gz", hash = "sha256:f22980844eb6a7c0266ffc70f111b9c7e7c7c13da38c3b439afc7eab3d7c9c8e", size = 215644, upload-time = "2025-08-17T14:32:35.397Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ac/94/30bbd09e8d45339fa77a48f5778d74d47e9242c11b3cd1093b3d994770a5/gql-4.0.0-py3-none-any.whl", hash = "sha256:f3beed7c531218eb24d97cb7df031b4a84fdb462f4a2beb86e2633d395937479", size = 89900, upload-time = "2025-08-17T14:32:34.029Z" }, -] - -[package.optional-dependencies] -httpx = [ - { name = "httpx" }, -] - -[[package]] -name = "graphql-core" -version = "3.2.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ac/9b/037a640a2983b09aed4a823f9cf1729e6d780b0671f854efa4727a7affbe/graphql_core-3.2.7.tar.gz", hash = "sha256:27b6904bdd3b43f2a0556dad5d579bdfdeab1f38e8e8788e555bdcb586a6f62c", size = 513484, upload-time = "2025-11-01T22:30:40.436Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0a/14/933037032608787fb92e365883ad6a741c235e0ff992865ec5d904a38f1e/graphql_core-3.2.7-py3-none-any.whl", hash = "sha256:17fc8f3ca4a42913d8e24d9ac9f08deddf0a0b2483076575757f6c412ead2ec0", size = 207262, upload-time = "2025-11-01T22:30:38.912Z" }, -] - -[[package]] -name = "h11" -version = "0.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, -] - -[[package]] -name = "httpcore" -version = "1.0.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, -] - -[[package]] -name = "httpx" -version = "0.28.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, -] - -[[package]] -name = "idna" -version = "3.11" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, -] - -[[package]] -name = "importlib-metadata" -version = "8.7.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "zipp" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, -] - -[[package]] -name = "markdown-it-py" -version = "4.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mdurl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, -] - -[[package]] -name = "mdurl" -version = "0.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, -] - -[[package]] -name = "multidict" -version = "6.7.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, - { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, - { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, - { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, - { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, - { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, - { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, - { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, - { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, - { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, - { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, - { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, - { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, - { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, - { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, - { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, - { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, - { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, - { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, - { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, - { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, - { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, - { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, - { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, - { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, - { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, - { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, - { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, - { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, - { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, - { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, - { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, - { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, - { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, - { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, - { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, - { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, - { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, - { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, - { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, - { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, - { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, - { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, - { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, - { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, - { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, - { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, - { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, - { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, - { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, - { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, - { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, - { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, - { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, - { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, - { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, - { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, - { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, - { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, - { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, - { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, - { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, - { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, - { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, - { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, - { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, - { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, - { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, - { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, - { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, - { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, - { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, - { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, -] - -[[package]] -name = "opentelemetry-api" -version = "1.39.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "importlib-metadata" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/97/b9/3161be15bb8e3ad01be8be5a968a9237c3027c5be504362ff800fca3e442/opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c", size = 65767, upload-time = "2025-12-11T13:32:39.182Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cf/df/d3f1ddf4bb4cb50ed9b1139cc7b1c54c34a1e7ce8fd1b9a37c0d1551a6bd/opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950", size = 66356, upload-time = "2025-12-11T13:32:17.304Z" }, -] - -[[package]] -name = "opentelemetry-exporter-otlp-proto-common" -version = "1.39.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-proto" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e9/9d/22d241b66f7bbde88a3bfa6847a351d2c46b84de23e71222c6aae25c7050/opentelemetry_exporter_otlp_proto_common-1.39.1.tar.gz", hash = "sha256:763370d4737a59741c89a67b50f9e39271639ee4afc999dadfe768541c027464", size = 20409, upload-time = "2025-12-11T13:32:40.885Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8c/02/ffc3e143d89a27ac21fd557365b98bd0653b98de8a101151d5805b5d4c33/opentelemetry_exporter_otlp_proto_common-1.39.1-py3-none-any.whl", hash = "sha256:08f8a5862d64cc3435105686d0216c1365dc5701f86844a8cd56597d0c764fde", size = 18366, upload-time = "2025-12-11T13:32:20.2Z" }, -] - -[[package]] -name = "opentelemetry-exporter-otlp-proto-http" -version = "1.39.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "googleapis-common-protos" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-exporter-otlp-proto-common" }, - { name = "opentelemetry-proto" }, - { name = "opentelemetry-sdk" }, - { name = "requests" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/80/04/2a08fa9c0214ae38880df01e8bfae12b067ec0793446578575e5080d6545/opentelemetry_exporter_otlp_proto_http-1.39.1.tar.gz", hash = "sha256:31bdab9745c709ce90a49a0624c2bd445d31a28ba34275951a6a362d16a0b9cb", size = 17288, upload-time = "2025-12-11T13:32:42.029Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/95/f1/b27d3e2e003cd9a3592c43d099d2ed8d0a947c15281bf8463a256db0b46c/opentelemetry_exporter_otlp_proto_http-1.39.1-py3-none-any.whl", hash = "sha256:d9f5207183dd752a412c4cd564ca8875ececba13be6e9c6c370ffb752fd59985", size = 19641, upload-time = "2025-12-11T13:32:22.248Z" }, -] - -[[package]] -name = "opentelemetry-instrumentation" -version = "0.60b1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "packaging" }, - { name = "wrapt" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/41/0f/7e6b713ac117c1f5e4e3300748af699b9902a2e5e34c9cf443dde25a01fa/opentelemetry_instrumentation-0.60b1.tar.gz", hash = "sha256:57ddc7974c6eb35865af0426d1a17132b88b2ed8586897fee187fd5b8944bd6a", size = 31706, upload-time = "2025-12-11T13:36:42.515Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/77/d2/6788e83c5c86a2690101681aeef27eeb2a6bf22df52d3f263a22cee20915/opentelemetry_instrumentation-0.60b1-py3-none-any.whl", hash = "sha256:04480db952b48fb1ed0073f822f0ee26012b7be7c3eac1a3793122737c78632d", size = 33096, upload-time = "2025-12-11T13:35:33.067Z" }, -] - -[[package]] -name = "opentelemetry-instrumentation-logging" -version = "0.60b1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-instrumentation" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/60/a6/4515895b383113677fd2ad21813df5e56108a2df14ebb7916c962c9a0234/opentelemetry_instrumentation_logging-0.60b1.tar.gz", hash = "sha256:98f4b9c7aeb9314a30feee7c002c7ea9abea07c90df5f97fb058b850bc45b89a", size = 9968, upload-time = "2025-12-11T13:37:03.974Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/f9/8a4ce3901bc52277794e4b18c4ac43dc5929806eff01d22812364132f45f/opentelemetry_instrumentation_logging-0.60b1-py3-none-any.whl", hash = "sha256:f2e18cbc7e1dd3628c80e30d243897fdc93c5b7e0c8ae60abd2b9b6a99f82343", size = 12577, upload-time = "2025-12-11T13:36:08.123Z" }, -] - -[[package]] -name = "opentelemetry-proto" -version = "1.39.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/49/1d/f25d76d8260c156c40c97c9ed4511ec0f9ce353f8108ca6e7561f82a06b2/opentelemetry_proto-1.39.1.tar.gz", hash = "sha256:6c8e05144fc0d3ed4d22c2289c6b126e03bcd0e6a7da0f16cedd2e1c2772e2c8", size = 46152, upload-time = "2025-12-11T13:32:48.681Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/51/95/b40c96a7b5203005a0b03d8ce8cd212ff23f1793d5ba289c87a097571b18/opentelemetry_proto-1.39.1-py3-none-any.whl", hash = "sha256:22cdc78efd3b3765d09e68bfbd010d4fc254c9818afd0b6b423387d9dee46007", size = 72535, upload-time = "2025-12-11T13:32:33.866Z" }, -] - -[[package]] -name = "opentelemetry-sdk" -version = "1.39.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/eb/fb/c76080c9ba07e1e8235d24cdcc4d125ef7aa3edf23eb4e497c2e50889adc/opentelemetry_sdk-1.39.1.tar.gz", hash = "sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6", size = 171460, upload-time = "2025-12-11T13:32:49.369Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/98/e91cf858f203d86f4eccdf763dcf01cf03f1dae80c3750f7e635bfa206b6/opentelemetry_sdk-1.39.1-py3-none-any.whl", hash = "sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c", size = 132565, upload-time = "2025-12-11T13:32:35.069Z" }, -] - -[[package]] -name = "opentelemetry-semantic-conventions" -version = "0.60b1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-api" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/91/df/553f93ed38bf22f4b999d9be9c185adb558982214f33eae539d3b5cd0858/opentelemetry_semantic_conventions-0.60b1.tar.gz", hash = "sha256:87c228b5a0669b748c76d76df6c364c369c28f1c465e50f661e39737e84bc953", size = 137935, upload-time = "2025-12-11T13:32:50.487Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7a/5e/5958555e09635d09b75de3c4f8b9cae7335ca545d77392ffe7331534c402/opentelemetry_semantic_conventions-0.60b1-py3-none-any.whl", hash = "sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb", size = 219982, upload-time = "2025-12-11T13:32:36.955Z" }, -] - -[[package]] -name = "packaging" -version = "26.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, -] - -[[package]] -name = "platformdirs" -version = "4.5.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, -] - -[[package]] -name = "propcache" -version = "0.4.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750, upload-time = "2025-10-08T19:47:07.648Z" }, - { url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780, upload-time = "2025-10-08T19:47:08.851Z" }, - { url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308, upload-time = "2025-10-08T19:47:09.982Z" }, - { url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182, upload-time = "2025-10-08T19:47:11.319Z" }, - { url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215, upload-time = "2025-10-08T19:47:13.146Z" }, - { url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112, upload-time = "2025-10-08T19:47:14.913Z" }, - { url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442, upload-time = "2025-10-08T19:47:16.277Z" }, - { url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398, upload-time = "2025-10-08T19:47:17.962Z" }, - { url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920, upload-time = "2025-10-08T19:47:19.355Z" }, - { url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748, upload-time = "2025-10-08T19:47:21.338Z" }, - { url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877, upload-time = "2025-10-08T19:47:23.059Z" }, - { url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437, upload-time = "2025-10-08T19:47:24.445Z" }, - { url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586, upload-time = "2025-10-08T19:47:25.736Z" }, - { url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790, upload-time = "2025-10-08T19:47:26.847Z" }, - { url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158, upload-time = "2025-10-08T19:47:27.961Z" }, - { url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451, upload-time = "2025-10-08T19:47:29.445Z" }, - { url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374, upload-time = "2025-10-08T19:47:30.579Z" }, - { url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396, upload-time = "2025-10-08T19:47:31.79Z" }, - { url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950, upload-time = "2025-10-08T19:47:33.481Z" }, - { url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856, upload-time = "2025-10-08T19:47:34.906Z" }, - { url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420, upload-time = "2025-10-08T19:47:36.338Z" }, - { url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254, upload-time = "2025-10-08T19:47:37.692Z" }, - { url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205, upload-time = "2025-10-08T19:47:39.659Z" }, - { url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873, upload-time = "2025-10-08T19:47:41.084Z" }, - { url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739, upload-time = "2025-10-08T19:47:42.51Z" }, - { url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514, upload-time = "2025-10-08T19:47:43.927Z" }, - { url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781, upload-time = "2025-10-08T19:47:45.448Z" }, - { url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload-time = "2025-10-08T19:47:47.202Z" }, - { url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload-time = "2025-10-08T19:47:48.336Z" }, - { url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload-time = "2025-10-08T19:47:49.876Z" }, - { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" }, - { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" }, - { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" }, - { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" }, - { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" }, - { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" }, - { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" }, - { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" }, - { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" }, - { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" }, - { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" }, - { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" }, - { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" }, - { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" }, - { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" }, - { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" }, - { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" }, - { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" }, - { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" }, - { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" }, - { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" }, - { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" }, - { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" }, - { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" }, - { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" }, - { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" }, - { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" }, - { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" }, - { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" }, - { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" }, - { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, -] - -[[package]] -name = "protobuf" -version = "6.33.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ba/25/7c72c307aafc96fa87062aa6291d9f7c94836e43214d43722e86037aac02/protobuf-6.33.5.tar.gz", hash = "sha256:6ddcac2a081f8b7b9642c09406bc6a4290128fce5f471cddd165960bb9119e5c", size = 444465, upload-time = "2026-01-29T21:51:33.494Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/79/af92d0a8369732b027e6d6084251dd8e782c685c72da161bd4a2e00fbabb/protobuf-6.33.5-cp310-abi3-win32.whl", hash = "sha256:d71b040839446bac0f4d162e758bea99c8251161dae9d0983a3b88dee345153b", size = 425769, upload-time = "2026-01-29T21:51:21.751Z" }, - { url = "https://files.pythonhosted.org/packages/55/75/bb9bc917d10e9ee13dee8607eb9ab963b7cf8be607c46e7862c748aa2af7/protobuf-6.33.5-cp310-abi3-win_amd64.whl", hash = "sha256:3093804752167bcab3998bec9f1048baae6e29505adaf1afd14a37bddede533c", size = 437118, upload-time = "2026-01-29T21:51:24.022Z" }, - { url = "https://files.pythonhosted.org/packages/a2/6b/e48dfc1191bc5b52950246275bf4089773e91cb5ba3592621723cdddca62/protobuf-6.33.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:a5cb85982d95d906df1e2210e58f8e4f1e3cdc088e52c921a041f9c9a0386de5", size = 427766, upload-time = "2026-01-29T21:51:25.413Z" }, - { url = "https://files.pythonhosted.org/packages/4e/b1/c79468184310de09d75095ed1314b839eb2f72df71097db9d1404a1b2717/protobuf-6.33.5-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:9b71e0281f36f179d00cbcb119cb19dec4d14a81393e5ea220f64b286173e190", size = 324638, upload-time = "2026-01-29T21:51:26.423Z" }, - { url = "https://files.pythonhosted.org/packages/c5/f5/65d838092fd01c44d16037953fd4c2cc851e783de9b8f02b27ec4ffd906f/protobuf-6.33.5-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:8afa18e1d6d20af15b417e728e9f60f3aa108ee76f23c3b2c07a2c3b546d3afd", size = 339411, upload-time = "2026-01-29T21:51:27.446Z" }, - { url = "https://files.pythonhosted.org/packages/9b/53/a9443aa3ca9ba8724fdfa02dd1887c1bcd8e89556b715cfbacca6b63dbec/protobuf-6.33.5-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:cbf16ba3350fb7b889fca858fb215967792dc125b35c7976ca4818bee3521cf0", size = 323465, upload-time = "2026-01-29T21:51:28.925Z" }, - { url = "https://files.pythonhosted.org/packages/57/bf/2086963c69bdac3d7cff1cc7ff79b8ce5ea0bec6797a017e1be338a46248/protobuf-6.33.5-py3-none-any.whl", hash = "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02", size = 170687, upload-time = "2026-01-29T21:51:32.557Z" }, -] - -[[package]] -name = "pygments" -version = "2.19.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, -] - -[[package]] -name = "requests" -version = "2.32.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "charset-normalizer" }, - { name = "idna" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, -] - -[[package]] -name = "rich" -version = "14.3.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown-it-py" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/74/99/a4cab2acbb884f80e558b0771e97e21e939c5dfb460f488d19df485e8298/rich-14.3.2.tar.gz", hash = "sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8", size = 230143, upload-time = "2026-02-01T16:20:47.908Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/45/615f5babd880b4bd7d405cc0dc348234c5ffb6ed1ea33e152ede08b2072d/rich-14.3.2-py3-none-any.whl", hash = "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69", size = 309963, upload-time = "2026-02-01T16:20:46.078Z" }, -] - -[[package]] -name = "typing-extensions" -version = "4.15.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, -] - -[[package]] -name = "urllib3" -version = "2.6.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, -] - -[[package]] -name = "wrapt" -version = "1.17.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/f6/759ece88472157acb55fc195e5b116e06730f1b651b5b314c66291729193/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", size = 54003, upload-time = "2025-08-12T05:51:48.627Z" }, - { url = "https://files.pythonhosted.org/packages/4f/a9/49940b9dc6d47027dc850c116d79b4155f15c08547d04db0f07121499347/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", size = 39025, upload-time = "2025-08-12T05:51:37.156Z" }, - { url = "https://files.pythonhosted.org/packages/45/35/6a08de0f2c96dcdd7fe464d7420ddb9a7655a6561150e5fc4da9356aeaab/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", size = 39108, upload-time = "2025-08-12T05:51:58.425Z" }, - { url = "https://files.pythonhosted.org/packages/0c/37/6faf15cfa41bf1f3dba80cd3f5ccc6622dfccb660ab26ed79f0178c7497f/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", size = 88072, upload-time = "2025-08-12T05:52:37.53Z" }, - { url = "https://files.pythonhosted.org/packages/78/f2/efe19ada4a38e4e15b6dff39c3e3f3f73f5decf901f66e6f72fe79623a06/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", size = 88214, upload-time = "2025-08-12T05:52:15.886Z" }, - { url = "https://files.pythonhosted.org/packages/40/90/ca86701e9de1622b16e09689fc24b76f69b06bb0150990f6f4e8b0eeb576/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", size = 87105, upload-time = "2025-08-12T05:52:17.914Z" }, - { url = "https://files.pythonhosted.org/packages/fd/e0/d10bd257c9a3e15cbf5523025252cc14d77468e8ed644aafb2d6f54cb95d/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", size = 87766, upload-time = "2025-08-12T05:52:39.243Z" }, - { url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711, upload-time = "2025-08-12T05:53:10.074Z" }, - { url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885, upload-time = "2025-08-12T05:53:08.695Z" }, - { url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896, upload-time = "2025-08-12T05:52:55.34Z" }, - { url = "https://files.pythonhosted.org/packages/02/a2/cd864b2a14f20d14f4c496fab97802001560f9f41554eef6df201cd7f76c/wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39", size = 54132, upload-time = "2025-08-12T05:51:49.864Z" }, - { url = "https://files.pythonhosted.org/packages/d5/46/d011725b0c89e853dc44cceb738a307cde5d240d023d6d40a82d1b4e1182/wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235", size = 39091, upload-time = "2025-08-12T05:51:38.935Z" }, - { url = "https://files.pythonhosted.org/packages/2e/9e/3ad852d77c35aae7ddebdbc3b6d35ec8013af7d7dddad0ad911f3d891dae/wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c", size = 39172, upload-time = "2025-08-12T05:51:59.365Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f7/c983d2762bcce2326c317c26a6a1e7016f7eb039c27cdf5c4e30f4160f31/wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b", size = 87163, upload-time = "2025-08-12T05:52:40.965Z" }, - { url = "https://files.pythonhosted.org/packages/e4/0f/f673f75d489c7f22d17fe0193e84b41540d962f75fce579cf6873167c29b/wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa", size = 87963, upload-time = "2025-08-12T05:52:20.326Z" }, - { url = "https://files.pythonhosted.org/packages/df/61/515ad6caca68995da2fac7a6af97faab8f78ebe3bf4f761e1b77efbc47b5/wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7", size = 86945, upload-time = "2025-08-12T05:52:21.581Z" }, - { url = "https://files.pythonhosted.org/packages/d3/bd/4e70162ce398462a467bc09e768bee112f1412e563620adc353de9055d33/wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4", size = 86857, upload-time = "2025-08-12T05:52:43.043Z" }, - { url = "https://files.pythonhosted.org/packages/2b/b8/da8560695e9284810b8d3df8a19396a6e40e7518059584a1a394a2b35e0a/wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10", size = 37178, upload-time = "2025-08-12T05:53:12.605Z" }, - { url = "https://files.pythonhosted.org/packages/db/c8/b71eeb192c440d67a5a0449aaee2310a1a1e8eca41676046f99ed2487e9f/wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6", size = 39310, upload-time = "2025-08-12T05:53:11.106Z" }, - { url = "https://files.pythonhosted.org/packages/45/20/2cda20fd4865fa40f86f6c46ed37a2a8356a7a2fde0773269311f2af56c7/wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58", size = 37266, upload-time = "2025-08-12T05:52:56.531Z" }, - { url = "https://files.pythonhosted.org/packages/77/ed/dd5cf21aec36c80443c6f900449260b80e2a65cf963668eaef3b9accce36/wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a", size = 56544, upload-time = "2025-08-12T05:51:51.109Z" }, - { url = "https://files.pythonhosted.org/packages/8d/96/450c651cc753877ad100c7949ab4d2e2ecc4d97157e00fa8f45df682456a/wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067", size = 40283, upload-time = "2025-08-12T05:51:39.912Z" }, - { url = "https://files.pythonhosted.org/packages/d1/86/2fcad95994d9b572db57632acb6f900695a648c3e063f2cd344b3f5c5a37/wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454", size = 40366, upload-time = "2025-08-12T05:52:00.693Z" }, - { url = "https://files.pythonhosted.org/packages/64/0e/f4472f2fdde2d4617975144311f8800ef73677a159be7fe61fa50997d6c0/wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e", size = 108571, upload-time = "2025-08-12T05:52:44.521Z" }, - { url = "https://files.pythonhosted.org/packages/cc/01/9b85a99996b0a97c8a17484684f206cbb6ba73c1ce6890ac668bcf3838fb/wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f", size = 113094, upload-time = "2025-08-12T05:52:22.618Z" }, - { url = "https://files.pythonhosted.org/packages/25/02/78926c1efddcc7b3aa0bc3d6b33a822f7d898059f7cd9ace8c8318e559ef/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056", size = 110659, upload-time = "2025-08-12T05:52:24.057Z" }, - { url = "https://files.pythonhosted.org/packages/dc/ee/c414501ad518ac3e6fe184753632fe5e5ecacdcf0effc23f31c1e4f7bfcf/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804", size = 106946, upload-time = "2025-08-12T05:52:45.976Z" }, - { url = "https://files.pythonhosted.org/packages/be/44/a1bd64b723d13bb151d6cc91b986146a1952385e0392a78567e12149c7b4/wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977", size = 38717, upload-time = "2025-08-12T05:53:15.214Z" }, - { url = "https://files.pythonhosted.org/packages/79/d9/7cfd5a312760ac4dd8bf0184a6ee9e43c33e47f3dadc303032ce012b8fa3/wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116", size = 41334, upload-time = "2025-08-12T05:53:14.178Z" }, - { url = "https://files.pythonhosted.org/packages/46/78/10ad9781128ed2f99dbc474f43283b13fea8ba58723e98844367531c18e9/wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6", size = 38471, upload-time = "2025-08-12T05:52:57.784Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, -] - -[[package]] -name = "yarl" -version = "1.22.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "idna" }, - { name = "multidict" }, - { name = "propcache" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ea/f3/d67de7260456ee105dc1d162d43a019ecad6b91e2f51809d6cddaa56690e/yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53", size = 139980, upload-time = "2025-10-06T14:10:14.601Z" }, - { url = "https://files.pythonhosted.org/packages/01/88/04d98af0b47e0ef42597b9b28863b9060bb515524da0a65d5f4db160b2d5/yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a", size = 93424, upload-time = "2025-10-06T14:10:16.115Z" }, - { url = "https://files.pythonhosted.org/packages/18/91/3274b215fd8442a03975ce6bee5fe6aa57a8326b29b9d3d56234a1dca244/yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c", size = 93821, upload-time = "2025-10-06T14:10:17.993Z" }, - { url = "https://files.pythonhosted.org/packages/61/3a/caf4e25036db0f2da4ca22a353dfeb3c9d3c95d2761ebe9b14df8fc16eb0/yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601", size = 373243, upload-time = "2025-10-06T14:10:19.44Z" }, - { url = "https://files.pythonhosted.org/packages/6e/9e/51a77ac7516e8e7803b06e01f74e78649c24ee1021eca3d6a739cb6ea49c/yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a", size = 342361, upload-time = "2025-10-06T14:10:21.124Z" }, - { url = "https://files.pythonhosted.org/packages/d4/f8/33b92454789dde8407f156c00303e9a891f1f51a0330b0fad7c909f87692/yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df", size = 387036, upload-time = "2025-10-06T14:10:22.902Z" }, - { url = "https://files.pythonhosted.org/packages/d9/9a/c5db84ea024f76838220280f732970aa4ee154015d7f5c1bfb60a267af6f/yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2", size = 397671, upload-time = "2025-10-06T14:10:24.523Z" }, - { url = "https://files.pythonhosted.org/packages/11/c9/cd8538dc2e7727095e0c1d867bad1e40c98f37763e6d995c1939f5fdc7b1/yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b", size = 377059, upload-time = "2025-10-06T14:10:26.406Z" }, - { url = "https://files.pythonhosted.org/packages/a1/b9/ab437b261702ced75122ed78a876a6dec0a1b0f5e17a4ac7a9a2482d8abe/yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273", size = 365356, upload-time = "2025-10-06T14:10:28.461Z" }, - { url = "https://files.pythonhosted.org/packages/b2/9d/8e1ae6d1d008a9567877b08f0ce4077a29974c04c062dabdb923ed98e6fe/yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a", size = 361331, upload-time = "2025-10-06T14:10:30.541Z" }, - { url = "https://files.pythonhosted.org/packages/ca/5a/09b7be3905962f145b73beb468cdd53db8aa171cf18c80400a54c5b82846/yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d", size = 382590, upload-time = "2025-10-06T14:10:33.352Z" }, - { url = "https://files.pythonhosted.org/packages/aa/7f/59ec509abf90eda5048b0bc3e2d7b5099dffdb3e6b127019895ab9d5ef44/yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02", size = 385316, upload-time = "2025-10-06T14:10:35.034Z" }, - { url = "https://files.pythonhosted.org/packages/e5/84/891158426bc8036bfdfd862fabd0e0fa25df4176ec793e447f4b85cf1be4/yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67", size = 374431, upload-time = "2025-10-06T14:10:37.76Z" }, - { url = "https://files.pythonhosted.org/packages/bb/49/03da1580665baa8bef5e8ed34c6df2c2aca0a2f28bf397ed238cc1bbc6f2/yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95", size = 81555, upload-time = "2025-10-06T14:10:39.649Z" }, - { url = "https://files.pythonhosted.org/packages/9a/ee/450914ae11b419eadd067c6183ae08381cfdfcb9798b90b2b713bbebddda/yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d", size = 86965, upload-time = "2025-10-06T14:10:41.313Z" }, - { url = "https://files.pythonhosted.org/packages/98/4d/264a01eae03b6cf629ad69bae94e3b0e5344741e929073678e84bf7a3e3b/yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b", size = 81205, upload-time = "2025-10-06T14:10:43.167Z" }, - { url = "https://files.pythonhosted.org/packages/88/fc/6908f062a2f77b5f9f6d69cecb1747260831ff206adcbc5b510aff88df91/yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10", size = 146209, upload-time = "2025-10-06T14:10:44.643Z" }, - { url = "https://files.pythonhosted.org/packages/65/47/76594ae8eab26210b4867be6f49129861ad33da1f1ebdf7051e98492bf62/yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3", size = 95966, upload-time = "2025-10-06T14:10:46.554Z" }, - { url = "https://files.pythonhosted.org/packages/ab/ce/05e9828a49271ba6b5b038b15b3934e996980dd78abdfeb52a04cfb9467e/yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9", size = 97312, upload-time = "2025-10-06T14:10:48.007Z" }, - { url = "https://files.pythonhosted.org/packages/d1/c5/7dffad5e4f2265b29c9d7ec869c369e4223166e4f9206fc2243ee9eea727/yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f", size = 361967, upload-time = "2025-10-06T14:10:49.997Z" }, - { url = "https://files.pythonhosted.org/packages/50/b2/375b933c93a54bff7fc041e1a6ad2c0f6f733ffb0c6e642ce56ee3b39970/yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0", size = 323949, upload-time = "2025-10-06T14:10:52.004Z" }, - { url = "https://files.pythonhosted.org/packages/66/50/bfc2a29a1d78644c5a7220ce2f304f38248dc94124a326794e677634b6cf/yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e", size = 361818, upload-time = "2025-10-06T14:10:54.078Z" }, - { url = "https://files.pythonhosted.org/packages/46/96/f3941a46af7d5d0f0498f86d71275696800ddcdd20426298e572b19b91ff/yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708", size = 372626, upload-time = "2025-10-06T14:10:55.767Z" }, - { url = "https://files.pythonhosted.org/packages/c1/42/8b27c83bb875cd89448e42cd627e0fb971fa1675c9ec546393d18826cb50/yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f", size = 341129, upload-time = "2025-10-06T14:10:57.985Z" }, - { url = "https://files.pythonhosted.org/packages/49/36/99ca3122201b382a3cf7cc937b95235b0ac944f7e9f2d5331d50821ed352/yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d", size = 346776, upload-time = "2025-10-06T14:10:59.633Z" }, - { url = "https://files.pythonhosted.org/packages/85/b4/47328bf996acd01a4c16ef9dcd2f59c969f495073616586f78cd5f2efb99/yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8", size = 334879, upload-time = "2025-10-06T14:11:01.454Z" }, - { url = "https://files.pythonhosted.org/packages/c2/ad/b77d7b3f14a4283bffb8e92c6026496f6de49751c2f97d4352242bba3990/yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5", size = 350996, upload-time = "2025-10-06T14:11:03.452Z" }, - { url = "https://files.pythonhosted.org/packages/81/c8/06e1d69295792ba54d556f06686cbd6a7ce39c22307100e3fb4a2c0b0a1d/yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f", size = 356047, upload-time = "2025-10-06T14:11:05.115Z" }, - { url = "https://files.pythonhosted.org/packages/4b/b8/4c0e9e9f597074b208d18cef227d83aac36184bfbc6eab204ea55783dbc5/yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62", size = 342947, upload-time = "2025-10-06T14:11:08.137Z" }, - { url = "https://files.pythonhosted.org/packages/e0/e5/11f140a58bf4c6ad7aca69a892bff0ee638c31bea4206748fc0df4ebcb3a/yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03", size = 86943, upload-time = "2025-10-06T14:11:10.284Z" }, - { url = "https://files.pythonhosted.org/packages/31/74/8b74bae38ed7fe6793d0c15a0c8207bbb819cf287788459e5ed230996cdd/yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249", size = 93715, upload-time = "2025-10-06T14:11:11.739Z" }, - { url = "https://files.pythonhosted.org/packages/69/66/991858aa4b5892d57aef7ee1ba6b4d01ec3b7eb3060795d34090a3ca3278/yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b", size = 83857, upload-time = "2025-10-06T14:11:13.586Z" }, - { url = "https://files.pythonhosted.org/packages/46/b3/e20ef504049f1a1c54a814b4b9bed96d1ac0e0610c3b4da178f87209db05/yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4", size = 140520, upload-time = "2025-10-06T14:11:15.465Z" }, - { url = "https://files.pythonhosted.org/packages/e4/04/3532d990fdbab02e5ede063676b5c4260e7f3abea2151099c2aa745acc4c/yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683", size = 93504, upload-time = "2025-10-06T14:11:17.106Z" }, - { url = "https://files.pythonhosted.org/packages/11/63/ff458113c5c2dac9a9719ac68ee7c947cb621432bcf28c9972b1c0e83938/yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b", size = 94282, upload-time = "2025-10-06T14:11:19.064Z" }, - { url = "https://files.pythonhosted.org/packages/a7/bc/315a56aca762d44a6aaaf7ad253f04d996cb6b27bad34410f82d76ea8038/yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e", size = 372080, upload-time = "2025-10-06T14:11:20.996Z" }, - { url = "https://files.pythonhosted.org/packages/3f/3f/08e9b826ec2e099ea6e7c69a61272f4f6da62cb5b1b63590bb80ca2e4a40/yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590", size = 338696, upload-time = "2025-10-06T14:11:22.847Z" }, - { url = "https://files.pythonhosted.org/packages/e3/9f/90360108e3b32bd76789088e99538febfea24a102380ae73827f62073543/yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2", size = 387121, upload-time = "2025-10-06T14:11:24.889Z" }, - { url = "https://files.pythonhosted.org/packages/98/92/ab8d4657bd5b46a38094cfaea498f18bb70ce6b63508fd7e909bd1f93066/yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da", size = 394080, upload-time = "2025-10-06T14:11:27.307Z" }, - { url = "https://files.pythonhosted.org/packages/f5/e7/d8c5a7752fef68205296201f8ec2bf718f5c805a7a7e9880576c67600658/yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784", size = 372661, upload-time = "2025-10-06T14:11:29.387Z" }, - { url = "https://files.pythonhosted.org/packages/b6/2e/f4d26183c8db0bb82d491b072f3127fb8c381a6206a3a56332714b79b751/yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b", size = 364645, upload-time = "2025-10-06T14:11:31.423Z" }, - { url = "https://files.pythonhosted.org/packages/80/7c/428e5812e6b87cd00ee8e898328a62c95825bf37c7fa87f0b6bb2ad31304/yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694", size = 355361, upload-time = "2025-10-06T14:11:33.055Z" }, - { url = "https://files.pythonhosted.org/packages/ec/2a/249405fd26776f8b13c067378ef4d7dd49c9098d1b6457cdd152a99e96a9/yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d", size = 381451, upload-time = "2025-10-06T14:11:35.136Z" }, - { url = "https://files.pythonhosted.org/packages/67/a8/fb6b1adbe98cf1e2dd9fad71003d3a63a1bc22459c6e15f5714eb9323b93/yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd", size = 383814, upload-time = "2025-10-06T14:11:37.094Z" }, - { url = "https://files.pythonhosted.org/packages/d9/f9/3aa2c0e480fb73e872ae2814c43bc1e734740bb0d54e8cb2a95925f98131/yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da", size = 370799, upload-time = "2025-10-06T14:11:38.83Z" }, - { url = "https://files.pythonhosted.org/packages/50/3c/af9dba3b8b5eeb302f36f16f92791f3ea62e3f47763406abf6d5a4a3333b/yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2", size = 82990, upload-time = "2025-10-06T14:11:40.624Z" }, - { url = "https://files.pythonhosted.org/packages/ac/30/ac3a0c5bdc1d6efd1b41fa24d4897a4329b3b1e98de9449679dd327af4f0/yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79", size = 88292, upload-time = "2025-10-06T14:11:42.578Z" }, - { url = "https://files.pythonhosted.org/packages/df/0a/227ab4ff5b998a1b7410abc7b46c9b7a26b0ca9e86c34ba4b8d8bc7c63d5/yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33", size = 82888, upload-time = "2025-10-06T14:11:44.863Z" }, - { url = "https://files.pythonhosted.org/packages/06/5e/a15eb13db90abd87dfbefb9760c0f3f257ac42a5cac7e75dbc23bed97a9f/yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1", size = 146223, upload-time = "2025-10-06T14:11:46.796Z" }, - { url = "https://files.pythonhosted.org/packages/18/82/9665c61910d4d84f41a5bf6837597c89e665fa88aa4941080704645932a9/yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca", size = 95981, upload-time = "2025-10-06T14:11:48.845Z" }, - { url = "https://files.pythonhosted.org/packages/5d/9a/2f65743589809af4d0a6d3aa749343c4b5f4c380cc24a8e94a3c6625a808/yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53", size = 97303, upload-time = "2025-10-06T14:11:50.897Z" }, - { url = "https://files.pythonhosted.org/packages/b0/ab/5b13d3e157505c43c3b43b5a776cbf7b24a02bc4cccc40314771197e3508/yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c", size = 361820, upload-time = "2025-10-06T14:11:52.549Z" }, - { url = "https://files.pythonhosted.org/packages/fb/76/242a5ef4677615cf95330cfc1b4610e78184400699bdda0acb897ef5e49a/yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf", size = 323203, upload-time = "2025-10-06T14:11:54.225Z" }, - { url = "https://files.pythonhosted.org/packages/8c/96/475509110d3f0153b43d06164cf4195c64d16999e0c7e2d8a099adcd6907/yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face", size = 363173, upload-time = "2025-10-06T14:11:56.069Z" }, - { url = "https://files.pythonhosted.org/packages/c9/66/59db471aecfbd559a1fd48aedd954435558cd98c7d0da8b03cc6c140a32c/yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b", size = 373562, upload-time = "2025-10-06T14:11:58.783Z" }, - { url = "https://files.pythonhosted.org/packages/03/1f/c5d94abc91557384719da10ff166b916107c1b45e4d0423a88457071dd88/yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486", size = 339828, upload-time = "2025-10-06T14:12:00.686Z" }, - { url = "https://files.pythonhosted.org/packages/5f/97/aa6a143d3afba17b6465733681c70cf175af89f76ec8d9286e08437a7454/yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138", size = 347551, upload-time = "2025-10-06T14:12:02.628Z" }, - { url = "https://files.pythonhosted.org/packages/43/3c/45a2b6d80195959239a7b2a8810506d4eea5487dce61c2a3393e7fc3c52e/yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a", size = 334512, upload-time = "2025-10-06T14:12:04.871Z" }, - { url = "https://files.pythonhosted.org/packages/86/a0/c2ab48d74599c7c84cb104ebd799c5813de252bea0f360ffc29d270c2caa/yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529", size = 352400, upload-time = "2025-10-06T14:12:06.624Z" }, - { url = "https://files.pythonhosted.org/packages/32/75/f8919b2eafc929567d3d8411f72bdb1a2109c01caaab4ebfa5f8ffadc15b/yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093", size = 357140, upload-time = "2025-10-06T14:12:08.362Z" }, - { url = "https://files.pythonhosted.org/packages/cf/72/6a85bba382f22cf78add705d8c3731748397d986e197e53ecc7835e76de7/yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c", size = 341473, upload-time = "2025-10-06T14:12:10.994Z" }, - { url = "https://files.pythonhosted.org/packages/35/18/55e6011f7c044dc80b98893060773cefcfdbf60dfefb8cb2f58b9bacbd83/yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e", size = 89056, upload-time = "2025-10-06T14:12:13.317Z" }, - { url = "https://files.pythonhosted.org/packages/f9/86/0f0dccb6e59a9e7f122c5afd43568b1d31b8ab7dda5f1b01fb5c7025c9a9/yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27", size = 96292, upload-time = "2025-10-06T14:12:15.398Z" }, - { url = "https://files.pythonhosted.org/packages/48/b7/503c98092fb3b344a179579f55814b613c1fbb1c23b3ec14a7b008a66a6e/yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1", size = 85171, upload-time = "2025-10-06T14:12:16.935Z" }, - { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" }, -] - -[[package]] -name = "zipp" -version = "3.23.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, -] diff --git a/.forgejo/workflows/build-container.yaml b/.forgejo/workflows/build-container.yaml index 6e5ed38..efa9007 100644 --- a/.forgejo/workflows/build-container.yaml +++ b/.forgejo/workflows/build-container.yaml @@ -24,7 +24,7 @@ jobs: detect: runs-on: k8s outputs: - dockerfile: ${{ steps.classify.outputs.dockerfile }} + dagger: ${{ steps.classify.outputs.dagger }} nix: ${{ steps.classify.outputs.nix }} steps: - name: Checkout @@ -47,12 +47,12 @@ jobs: echo "Changed containers: $CHANGED" # Classify each container by build type (a container can appear in both) - DOCKERFILE='[]' + DAGGER='[]' NIX='[]' for name in $(echo "$CHANGED" | jq -r '.[]'); do has_any=false - if [ -f "containers/$name/Dockerfile" ]; then - DOCKERFILE=$(echo "$DOCKERFILE" | jq -c --arg n "$name" '. + [$n]') + if [ -f "containers/$name/container.py" ] || [ -f "containers/$name/Dockerfile" ]; then + DAGGER=$(echo "$DAGGER" | jq -c --arg n "$name" '. + [$n]') has_any=true fi if [ -f "containers/$name/default.nix" ]; then @@ -60,22 +60,22 @@ jobs: has_any=true fi if [ "$has_any" = "false" ]; then - echo "Warning: $name has neither Dockerfile nor default.nix — skipping" + echo "Warning: $name has neither container.py, Dockerfile, nor default.nix — skipping" fi done - echo "dockerfile=$DOCKERFILE" >> "$GITHUB_OUTPUT" + echo "dagger=$DAGGER" >> "$GITHUB_OUTPUT" echo "nix=$NIX" >> "$GITHUB_OUTPUT" - echo "Dockerfile builds: $DOCKERFILE" + echo "Dagger builds: $DAGGER" echo "Nix builds: $NIX" - build-dockerfile: + build-dagger: needs: detect - if: needs.detect.outputs.dockerfile != '[]' + if: needs.detect.outputs.dagger != '[]' runs-on: k8s strategy: matrix: - container: ${{ fromJson(needs.detect.outputs.dockerfile) }} + container: ${{ fromJson(needs.detect.outputs.dagger) }} steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -85,12 +85,19 @@ jobs: - name: Extract version and SHA id: meta run: | - VERSION=$(grep -m1 '^ARG CONTAINER_APP_VERSION=' \ - "containers/${{ matrix.container }}/Dockerfile" \ - | sed 's/^ARG CONTAINER_APP_VERSION=//') + CONTAINER="${{ matrix.container }}" + + # Try native Dagger pipeline (container.py) first, fall back to Dockerfile + if [ -f "containers/$CONTAINER/container.py" ]; then + VERSION=$(dagger call container-version --container-name="$CONTAINER") + elif [ -f "containers/$CONTAINER/Dockerfile" ]; then + VERSION=$(grep -m1 '^ARG CONTAINER_APP_VERSION=' \ + "containers/$CONTAINER/Dockerfile" \ + | sed 's/^ARG CONTAINER_APP_VERSION=//') + fi if [ -z "$VERSION" ]; then - echo "Error: No CONTAINER_APP_VERSION found in Dockerfile" + echo "Error: Could not extract version for $CONTAINER" exit 1 fi diff --git a/.dagger/.gitattributes b/.gitattributes similarity index 100% rename from .dagger/.gitattributes rename to .gitattributes diff --git a/.gitignore b/.gitignore index b39d114..acfafba 100644 --- a/.gitignore +++ b/.gitignore @@ -7,5 +7,8 @@ __pycache__/ *.pyo .venv/ +# Dagger (auto-generated SDK) +/sdk/ + # OS .DS_Store diff --git a/argocd/manifests/navidrome/kustomization.yaml b/argocd/manifests/navidrome/kustomization.yaml index df5677e..7e4665b 100644 --- a/argocd/manifests/navidrome/kustomization.yaml +++ b/argocd/manifests/navidrome/kustomization.yaml @@ -11,4 +11,4 @@ resources: - ingress-tailscale.yaml images: - name: registry.ops.eblu.me/blumeops/navidrome - newTag: v0.60.3-613f05d + newTag: v0.61.1-470b4bd diff --git a/containers/navidrome/Dockerfile b/containers/navidrome/Dockerfile deleted file mode 100644 index 7f78d36..0000000 --- a/containers/navidrome/Dockerfile +++ /dev/null @@ -1,57 +0,0 @@ -# Navidrome music server -# Three-stage build: UI (Node), backend (Go+taglib), runtime (Alpine) - -ARG CONTAINER_APP_VERSION=v0.60.3 -ARG NAVIDROME_VERSION=${CONTAINER_APP_VERSION} - -FROM node:22-alpine AS ui-build - -ARG NAVIDROME_VERSION -RUN apk add --no-cache git - -RUN git clone --depth 1 --branch ${NAVIDROME_VERSION} \ - https://forge.ops.eblu.me/mirrors/navidrome.git /app - -WORKDIR /app/ui -RUN npm ci -RUN npm run build - -FROM golang:alpine3.22 AS build - -ARG NAVIDROME_VERSION -RUN apk add --no-cache build-base git taglib-dev zlib-dev - -RUN git clone --depth 1 --branch ${NAVIDROME_VERSION} \ - https://forge.ops.eblu.me/mirrors/navidrome.git /app - -WORKDIR /app - -# Copy pre-built UI assets -COPY --from=ui-build /app/ui/build /app/ui/build - -ENV CGO_ENABLED=1 -ENV CGO_CFLAGS_ALLOW="--define-prefix" - -RUN go build -tags=netgo \ - -ldflags="-w -s -X github.com/navidrome/navidrome/consts.gitTag=${NAVIDROME_VERSION}" \ - -o /navidrome . - -FROM alpine:3.22 - -ARG CONTAINER_APP_VERSION -LABEL org.opencontainers.image.title="Navidrome" -LABEL org.opencontainers.image.description="Navidrome is a self-hosted music server and streamer" -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" - -RUN apk add --no-cache ca-certificates tzdata taglib ffmpeg \ - && addgroup -g 1000 navidrome \ - && adduser -u 1000 -G navidrome -D navidrome - -COPY --from=build /navidrome /usr/bin/navidrome - -EXPOSE 4533 - -USER 1000 -CMD ["/usr/bin/navidrome"] diff --git a/containers/navidrome/container.py b/containers/navidrome/container.py new file mode 100644 index 0000000..a50a71f --- /dev/null +++ b/containers/navidrome/container.py @@ -0,0 +1,54 @@ +"""Navidrome music server — native Dagger build. + +Three-stage build: Node (UI), Go (backend with taglib + FTS5), Alpine (runtime). +Source cloned from forge mirror. +""" + +import dagger + +from blumeops.containers import ( + alpine_runtime, + clone_from_forge, + go_build, + node_build, + oci_labels, +) + +VERSION = "v0.61.1" + + +async def build(src: dagger.Directory) -> dagger.Container: + source = clone_from_forge("navidrome", VERSION) + + # Stage 1: Build UI assets + ui = node_build(source, "ui") + + # Stage 2: Build Go backend with CGO (taglib) and FTS5 + backend = go_build( + source.with_directory("ui/build", ui.directory("/app/ui/build")), + "/navidrome", + tags="netgo,sqlite_fts5", + ldflags=f"-w -s -X github.com/navidrome/navidrome/consts.gitTag={VERSION}", + cgo_enabled=True, + extra_apk=["taglib-dev", "zlib-dev"], + ) + + # Stage 3: Runtime + runtime = alpine_runtime( + extra_apk=["ca-certificates", "tzdata", "taglib", "ffmpeg"], + uid=1000, + gid=1000, + username="navidrome", + ) + runtime = oci_labels( + runtime, + title="Navidrome", + description="Navidrome is a self-hosted music server and streamer", + version=VERSION, + ) + return ( + runtime.with_file("/usr/bin/navidrome", backend.file("/navidrome")) + .with_exposed_port(4533) + .with_user("1000") + .with_default_args(args=["/usr/bin/navidrome"]) + ) diff --git a/dagger.json b/dagger.json index 684aa80..c982487 100644 --- a/dagger.json +++ b/dagger.json @@ -1,8 +1,8 @@ { - "name": "blumeops-ci", + "name": "blumeops", "engineVersion": "v0.20.1", "sdk": { "source": "python" }, - "source": ".dagger" + "source": "." } diff --git a/docs/changelog.d/native-dagger-containers.infra.md b/docs/changelog.d/native-dagger-containers.infra.md new file mode 100644 index 0000000..a207ea2 --- /dev/null +++ b/docs/changelog.d/native-dagger-containers.infra.md @@ -0,0 +1 @@ +Migrate Dagger module from .dagger/ to repo root (src/blumeops/) and replace docker_build() with native Dagger pipelines for container builds. Navidrome is the first container migrated, with full build error visibility. diff --git a/docs/changelog.d/upgrade-navidrome-v0.61.1.feature.md b/docs/changelog.d/upgrade-navidrome-v0.61.1.feature.md new file mode 100644 index 0000000..1f7a8a2 --- /dev/null +++ b/docs/changelog.d/upgrade-navidrome-v0.61.1.feature.md @@ -0,0 +1 @@ +Upgrade Navidrome to v0.61.1 — major artwork overhaul with per-disc cover art, rebuilt search engine (SQLite FTS5), server-managed transcoding, and WebP performance fix. diff --git a/docs/how-to/dagger/upgrade-dagger.md b/docs/how-to/dagger/upgrade-dagger.md index d41ea09..0ee66a6 100644 --- a/docs/how-to/dagger/upgrade-dagger.md +++ b/docs/how-to/dagger/upgrade-dagger.md @@ -1,6 +1,6 @@ --- title: Upgrade Dagger -modified: 2026-03-06 +modified: 2026-04-11 last-reviewed: 2026-03-06 tags: - how-to @@ -26,7 +26,7 @@ Dagger versions are pinned in multiple places. The runner job image (which execu | `service-versions.yaml` | `runner-job-image` version and `last-reviewed` | 1 | | `mise.toml` | `dagger` tool version | 2 | | `dagger.json` | `engineVersion` | 2 | -| `.dagger/uv.lock` | SDK dependency lock (regenerated automatically) | 2 | +| `uv.lock` | SDK dependency lock (regenerated automatically) | 2 | | `docs/reference/tools/dagger.md` | Version references in documentation | 2 | | `argocd/manifests/forgejo-runner/deployment.yaml` | `RUNNER_LABELS` image tag | 2 | @@ -63,7 +63,7 @@ Once the Phase 1 build completes, upgrade the module engine version and deploy t "engineVersion": "v" ``` -4. Regenerate the SDK lock file — run any `dagger call` command (e.g., `dagger call --help` or `dagger functions`). This updates `.dagger/uv.lock` if SDK dependencies changed. +4. Regenerate the SDK lock file — run any `dagger call` command (e.g., `dagger call --help` or `dagger functions`). This updates `uv.lock` if SDK dependencies changed. 5. Update `docs/reference/tools/dagger.md` — bump the version in the Quick Reference table and any version references in the body text. diff --git a/docs/how-to/deployment/build-container-image.md b/docs/how-to/deployment/build-container-image.md index 4b47b3f..ce746b0 100644 --- a/docs/how-to/deployment/build-container-image.md +++ b/docs/how-to/deployment/build-container-image.md @@ -1,6 +1,6 @@ --- title: Build Container Image -modified: 2026-02-24 +modified: 2026-04-11 last-reviewed: 2026-02-15 tags: - how-to @@ -14,8 +14,8 @@ How to create a custom container image in BlumeOps, build it locally, and releas ## Prerequisites -- [Dagger CLI](https://docs.dagger.io/install) installed locally (for Dockerfile builds) -- A `Dockerfile` and/or `default.nix` for the service +- [Dagger CLI](https://docs.dagger.io/install) installed locally +- A `container.py`, `Dockerfile`, and/or `default.nix` for the service ## 1. Create the container directory @@ -23,16 +23,21 @@ Add build files under `containers//`: ``` containers// -├── Dockerfile (built by Dagger on the k8s runner) +├── container.py (native Dagger pipeline — preferred for new containers) +├── Dockerfile (legacy — built via docker_build() fallback) ├── default.nix (built by nix-build on the ringtail runner) └── (optional scripts, configs) ``` -A container can have one or both build files. The directory name becomes the image name: `registry.ops.eblu.me/blumeops/`. +A container can have one or more build files. The directory name becomes the image name: `registry.ops.eblu.me/blumeops/`. + +**New containers for indri (k8s runner) should use `container.py`** — native Dagger pipelines surface full build errors per step, while `docker_build()` (used for Dockerfiles) swallows errors. See `containers/navidrome/container.py` for the reference pattern. Existing Dockerfile containers are migrated incrementally during [[review-services|service reviews]]. + +**Ringtail containers should continue using `default.nix`** — these are built by `nix-build` on the ringtail runner and don't benefit from the Dagger migration. ## 2. Build locally -**Dockerfile** — test with Dagger: +**Any container** (native `container.py` or legacy Dockerfile) — test with Dagger: ```bash dagger call build --src=. --container-name= @@ -65,10 +70,11 @@ Use `--dry-run` to preview without dispatching. | Build file | Workflow | Runner | Registry tag | |------------|----------|--------|--------------| +| `container.py` | `build-container.yaml` | `k8s` (indri) | `:vX.Y.Z-` | | `Dockerfile` | `build-container.yaml` | `k8s` (indri) | `:vX.Y.Z-` | -| `default.nix` | `build-container-nix.yaml` | `nix-container-builder` ([[ringtail]]) | `:vX.Y.Z--nix` | +| `default.nix` | `build-container.yaml` | `nix-container-builder` ([[ringtail]]) | `:vX.Y.Z--nix` | -The version (`X.Y.Z`) is extracted from `ARG CONTAINER_APP_VERSION=` in the Dockerfile or `version = "..."` in `default.nix`. The SHA is the short (7-char) commit hash. +The version (`X.Y.Z`) is extracted from `VERSION` in `container.py` (via `dagger call container-version`), `ARG CONTAINER_APP_VERSION=` in Dockerfiles, or `version = "..."` in `default.nix`. The SHA is the short (7-char) commit hash. Check available images and tags with: @@ -112,36 +118,36 @@ Existing containers demonstrate several build approaches: | Pattern | Example | Notes | |---------|---------|-------| -| Alpine package install | [[#transmission]] | Simplest — install from apk | -| Go from source | [[#miniflux]] | Clone upstream, `go build` | -| Multi-stage with Node + Go | [[#navidrome]] | Separate UI and backend build stages | -| Multi-stage Elixir | [[#teslamate]] | Elixir release with Node assets | -| Runtime tarball download | [[#kiwix-serve]] | Download pre-built binary with arch detection | -| Nix `dockerTools` | [[#ntfy-nix]] | `buildLayeredImage` with nix-built app | - -### transmission - -`containers/transmission/Dockerfile` — Installs transmission-daemon directly from Alpine packages. Good starting point for services available in apk. - -### miniflux - -`containers/miniflux/Dockerfile` — Two-stage Go build. Clones upstream at a pinned version tag, runs `make`, copies the binary into a minimal Alpine runtime. +| Native Dagger (Go + Node) | [[#navidrome]] | `container.py` with helper functions — preferred for new containers | +| Alpine package install | [[#transmission]] | Simplest Dockerfile — install from apk | +| Go from source | [[#miniflux]] | Dockerfile: clone upstream, `go build` | +| Multi-stage Elixir | [[#teslamate]] | Dockerfile: Elixir release with Node assets | +| Runtime tarball download | [[#kiwix-serve]] | Dockerfile: download pre-built binary with arch detection | +| Nix `dockerTools` | [[#ntfy-nix]] | `buildLayeredImage` with nix-built app (ringtail runner) | ### navidrome -`containers/navidrome/Dockerfile` — Three-stage build with separate Node.js UI compilation, Go backend build with CGO (taglib), and a minimal Alpine runtime with ffmpeg. +`containers/navidrome/container.py` — Native Dagger build. Three-stage pipeline using helper functions: `node_build()` for UI, `go_build()` with CGO/taglib/FTS5 for backend, `alpine_runtime()` with ffmpeg. This is the reference pattern for migrating Dockerfile containers to native Dagger builds. + +### transmission + +`containers/transmission/Dockerfile` — Installs transmission-daemon directly from Alpine packages. Good starting point for services available in apk. (Legacy Dockerfile — migrate to `container.py` during review.) + +### miniflux + +`containers/miniflux/Dockerfile` — Two-stage Go build. Clones upstream at a pinned version tag, runs `make`, copies the binary into a minimal Alpine runtime. (Legacy Dockerfile — migrate to `container.py` during review.) ### teslamate -`containers/teslamate/Dockerfile` — Two-stage Elixir build with Node.js asset compilation. Uses Debian-based images due to Elixir/OTP dependencies. +`containers/teslamate/Dockerfile` — Two-stage Elixir build with Node.js asset compilation. Uses Debian-based images due to Elixir/OTP dependencies. (Legacy Dockerfile — migrate to `container.py` during review.) ### kiwix-serve -`containers/kiwix-serve/Dockerfile` — Downloads a pre-built binary from upstream, with architecture detection for cross-platform support. +`containers/kiwix-serve/Dockerfile` — Downloads a pre-built binary from upstream, with architecture detection for cross-platform support. (Legacy Dockerfile — migrate to `container.py` during review.) ### ntfy (nix) -`containers/ntfy/default.nix` — Builds ntfy from source using `buildGoModule` and packages it with `dockerTools.buildLayeredImage`. Runs alongside the existing Dockerfile; the nix variant is tagged `:version-nix` in the registry. +`containers/ntfy/default.nix` — Builds ntfy from source using `buildGoModule` and packages it with `dockerTools.buildLayeredImage`. Runs alongside the existing Dockerfile; the nix variant is tagged `:version-nix` in the registry. Nix containers should continue using `default.nix`. ## Related diff --git a/docs/how-to/forgejo-runner/validate-workflows-against-v12.md b/docs/how-to/forgejo-runner/validate-workflows-against-v12.md index 1b6cdc0..5f98502 100644 --- a/docs/how-to/forgejo-runner/validate-workflows-against-v12.md +++ b/docs/how-to/forgejo-runner/validate-workflows-against-v12.md @@ -1,6 +1,6 @@ --- title: Validate Workflows Against v12 -modified: 2026-02-27 +modified: 2026-04-11 last-reviewed: 2026-02-27 tags: - how-to @@ -25,7 +25,7 @@ All 6 workflows pass v12.7.0 schema validation with no changes needed: ## Deliverables -1. `validate_workflows` function added to `.dagger/src/blumeops_ci/main.py` +1. `validate_workflows` function added to `src/blumeops/main.py` (formerly `.dagger/src/blumeops_ci/main.py`) - Uses `forgejo-runner validate --directory .` inside the upstream runner container - `runner_version` parameter (default `12.7.0`) pins to deployed version 2. `mise run validate-workflows` task wired to `dagger call validate-workflows` diff --git a/docs/how-to/knowledgebase/review-services.md b/docs/how-to/knowledgebase/review-services.md index de0a970..43615fb 100644 --- a/docs/how-to/knowledgebase/review-services.md +++ b/docs/how-to/knowledgebase/review-services.md @@ -47,6 +47,7 @@ For all service types, start by reading the service's reference card (`docs/refe 3. Review the upstream changelog for breaking changes 4. If the service uses a custom-built container, also check the base image for security updates and follow [[build-container-image]] to rebuild 5. If upgrading, update the manifest and follow [[deploy-k8s-service]] +6. If the container still uses a Dockerfile (no `container.py`), consider migrating to a native Dagger build — see the `containers/navidrome/container.py` pattern for reference ### Ansible Services (`type: ansible`) diff --git a/docs/how-to/zot/add-container-version-sync-check.md b/docs/how-to/zot/add-container-version-sync-check.md index ebf1056..ff137fb 100644 --- a/docs/how-to/zot/add-container-version-sync-check.md +++ b/docs/how-to/zot/add-container-version-sync-check.md @@ -1,6 +1,6 @@ --- title: Add Container Version Sync Check -modified: 2026-02-20 +modified: 2026-04-11 tags: - how-to - containers @@ -10,7 +10,7 @@ tags: # Add Container Version Sync Check -Add a prek check that validates version consistency across the three places container versions are declared: Dockerfile ARGs, `service-versions.yaml`, and nix derivations. No VERSION files needed — the existing sources are the source of truth, and the check enforces they agree. +Add a prek check that validates version consistency across the places container versions are declared: `container.py` VERSION constants, Dockerfile ARGs, `service-versions.yaml`, and nix derivations. The check enforces they agree. ## Context @@ -20,13 +20,14 @@ Discovered during analysis of [[adopt-commit-based-container-tags]]: the new com ### 1. Created `mise run container-version-check` task -A typer-based uv-script that iterates over `containers/*/` and validates five rules per container: +A typer-based uv-script that iterates over `containers/*/` and validates six rules per container: -1. Any Dockerfile must declare `ARG CONTAINER_APP_VERSION=` -2. Any `default.nix` must produce a version via `dagger call nix-version` -3. At least one build file must exist (Dockerfile or default.nix) -4. A matching `service-versions.yaml` entry must exist with non-null `current-version` -5. All resolved versions from (1), (2), and (4) must agree (v-prefix stripped for comparison) +1. Any `container.py` must declare `VERSION = ""` +2. Any Dockerfile must declare `ARG CONTAINER_APP_VERSION=` +3. Any `default.nix` must produce a version via `dagger call nix-version` +4. At least one build file must exist (`container.py`, Dockerfile, or `default.nix`) +5. A matching `service-versions.yaml` entry must exist with non-null `current-version` +6. All resolved versions from (1), (2), (3), and (5) must agree (v-prefix stripped for comparison) Scoping: by default only checks containers changed vs main. `--all-files` checks everything. If `service-versions.yaml` itself changed, all containers are checked. diff --git a/docs/how-to/zot/add-dagger-nix-build.md b/docs/how-to/zot/add-dagger-nix-build.md index fa5f261..c84661a 100644 --- a/docs/how-to/zot/add-dagger-nix-build.md +++ b/docs/how-to/zot/add-dagger-nix-build.md @@ -1,6 +1,6 @@ --- title: Add Dagger Nix Build Function -modified: 2026-02-20 +modified: 2026-04-11 tags: - how-to - containers @@ -23,7 +23,7 @@ Currently, nix containers can only be built on ringtail (the `nix-container-buil ### 1. Add `build_nix` Dagger function -A new function in `.dagger/src/blumeops_ci/main.py` that builds a nix container inside a `nixos/nix` container: +A new function in `src/blumeops/main.py` (formerly `.dagger/src/blumeops_ci/main.py`) that builds a nix container inside a `nixos/nix` container: ```python @function @@ -80,7 +80,7 @@ The `flake_lock` function already demonstrates running nix inside Dagger using ` | File | Change | |------|--------| -| `.dagger/src/blumeops_ci/main.py` | Add `build_nix`, `nix_version`, optionally `publish_nix` | +| `src/blumeops/main.py` | Add `build_nix`, `nix_version`, optionally `publish_nix` | ## Verification diff --git a/docs/how-to/zot/adopt-commit-based-container-tags.md b/docs/how-to/zot/adopt-commit-based-container-tags.md index 82c90fc..80a3f37 100644 --- a/docs/how-to/zot/adopt-commit-based-container-tags.md +++ b/docs/how-to/zot/adopt-commit-based-container-tags.md @@ -1,6 +1,6 @@ --- title: Adopt Commit-Based Container Tags -modified: 2026-02-20 +modified: 2026-04-11 tags: - how-to - containers @@ -64,7 +64,7 @@ Where: |------|--------| | `.forgejo/workflows/build-container.yaml` | Replace tag trigger with path + dispatch triggers; compute version and SHA | | `.forgejo/workflows/build-container-nix.yaml` | Same trigger changes; add `-nix` suffix to new tag format | -| `.dagger/src/blumeops_ci/main.py` | Accept SHA parameter; publish with new tag format | +| `src/blumeops/main.py` | Accept SHA parameter; publish with new tag format | | `mise-tasks/container-build-and-release` | New task replacing `container-tag-and-release`; triggers workflow dispatch | | `mise-tasks/container-list` | Updated tag display for new format | | `docs/how-to/deployment/build-container-image.md` | Updated documentation | diff --git a/docs/how-to/zot/harden-zot-registry.md b/docs/how-to/zot/harden-zot-registry.md index 47ca322..d74a5d0 100644 --- a/docs/how-to/zot/harden-zot-registry.md +++ b/docs/how-to/zot/harden-zot-registry.md @@ -1,6 +1,6 @@ --- title: Harden Zot Registry -modified: 2026-02-21 +modified: 2026-04-11 tags: - how-to - zot @@ -32,7 +32,7 @@ Updated `ansible/roles/zot/templates/config.json.j2` with: | `ansible/roles/zot/templates/config.json.j2` | Zot config with auth + access control | | `ansible/roles/zot/defaults/main.yml` | OIDC issuer and external URL variables | | `ansible/roles/zot/templates/oidc-credentials.json.j2` | OIDC client credentials | -| `.dagger/src/blumeops_ci/main.py` | `publish()` with registry auth | +| `src/blumeops/main.py` | `publish()` with registry auth | | `.forgejo/workflows/build-container.yaml` | Dagger push with API key | | `.forgejo/workflows/build-container-nix.yaml` | Skopeo push with API key | diff --git a/docs/how-to/zot/pin-container-versions.md b/docs/how-to/zot/pin-container-versions.md index 4d0a64c..b592728 100644 --- a/docs/how-to/zot/pin-container-versions.md +++ b/docs/how-to/zot/pin-container-versions.md @@ -1,6 +1,6 @@ --- title: Pin Container Versions -modified: 2026-02-20 +modified: 2026-04-11 tags: - how-to - containers @@ -18,13 +18,15 @@ Discovered during analysis of [[adopt-commit-based-container-tags]]: containers ## What Was Done -Every container Dockerfile now declares `ARG CONTAINER_APP_VERSION=X.Y.Z` as its first ARG, providing a uniform parsing target. Containers that use the version in build commands chain it to a semantic ARG: +Every container Dockerfile declares `ARG CONTAINER_APP_VERSION=X.Y.Z` as its first ARG, providing a uniform parsing target. Containers that use the version in build commands chain it to a semantic ARG: ```dockerfile ARG CONTAINER_APP_VERSION=v0.60.3 ARG NAVIDROME_VERSION=${CONTAINER_APP_VERSION} ``` +> **Note:** Containers migrated to native Dagger builds use `VERSION = "X.Y.Z"` in `container.py` instead. See `containers/navidrome/container.py` for the pattern. New containers should use `container.py` rather than Dockerfiles. + Specific changes: - **devpi**: Pinned devpi-server==6.19.1 and devpi-web==5.0.1 - **cv**: `CONTAINER_APP_VERSION=1.0.3` (matches latest Forgejo package release) diff --git a/docs/how-to/zot/wire-ci-registry-auth.md b/docs/how-to/zot/wire-ci-registry-auth.md index cce0655..e2507c9 100644 --- a/docs/how-to/zot/wire-ci-registry-auth.md +++ b/docs/how-to/zot/wire-ci-registry-auth.md @@ -1,6 +1,6 @@ --- title: Wire CI Registry Auth -modified: 2026-02-21 +modified: 2026-04-11 tags: - how-to - zot @@ -36,7 +36,7 @@ Authentication uses a zot API key generated after the service account's first OI | File | Purpose | |------|---------| -| `.dagger/src/blumeops_ci/main.py` | `publish()` accepts optional `registry_password` | +| `src/blumeops/main.py` | `publish()` accepts optional `registry_password` | | `.forgejo/workflows/build-container.yaml` | Passes API key to Dagger | | `.forgejo/workflows/build-container-nix.yaml` | Passes API key to skopeo | | `ansible/playbooks/indri.yml` | Pre_task fetches API key from 1Password | diff --git a/docs/reference/tools/dagger.md b/docs/reference/tools/dagger.md index b07ed78..379c10f 100644 --- a/docs/reference/tools/dagger.md +++ b/docs/reference/tools/dagger.md @@ -1,6 +1,6 @@ --- title: Dagger -modified: 2026-03-06 +modified: 2026-04-11 tags: - reference - ci-cd @@ -15,17 +15,18 @@ Build engine for BlumeOps CI/CD pipelines. Replaces shell-based build scripts wi | Property | Value | |----------|-------| -| **Module** | `blumeops-ci` | +| **Module** | `blumeops` | | **Engine Version** | v0.20.1 | | **SDK** | Python | -| **Source** | `.dagger/src/blumeops_ci/main.py` | -| **Config** | `dagger.json` | +| **Source** | `src/blumeops/main.py` | +| **Config** | `dagger.json` (source: `.`) | ## Functions | Function | Signature | Description | |----------|-----------|-------------| -| `build` | `(src, container_name) → Container` | Build a container from `containers//Dockerfile` | +| `build` | `(src, container_name) → Container` | Build a container — uses native pipeline (`container.py`) if available, falls back to `docker_build()` for Dockerfile containers | +| `container_version` | `(container_name) → str` | Return the `VERSION` from a container's `container.py` (empty string if no `container.py`) | | `publish` | `(src, container_name, version, registry?) → str` | Build and push to registry (default: `registry.ops.eblu.me`) | | `build_nix` | `(src, container_name) → File` | Build a nix container from `containers//default.nix`, return docker-archive tarball | | `nix_version` | `(package) → str` | Extract the version of a nixpkgs package | @@ -33,6 +34,18 @@ Build engine for BlumeOps CI/CD pipelines. Replaces shell-based build scripts wi | `flake_lock` | `(src, flake_path?) → File` | Resolve flake inputs, return updated `flake.lock` | | `flake_update` | `(src, flake_path?) → File` | Update all flake inputs to latest, return `flake.lock` | +## Container Build Types + +Containers can be built in three ways: + +| Build file | How it works | Error visibility | +|------------|-------------|-----------------| +| `container.py` | Native Dagger pipeline (preferred) | Full per-step output | +| `Dockerfile` | `docker_build()` fallback (legacy) | Opaque — errors swallowed | +| `default.nix` | `nix-build` on ringtail runner | Full nix output | + +New containers for indri (k8s runner) should use `container.py`. Ringtail containers should continue using `default.nix`. Existing Dockerfile containers are migrated incrementally during [[review-services|service reviews]]. See `containers/navidrome/container.py` for the reference pattern. + ## CLI Examples ```bash diff --git a/docs/reference/tools/mise-tasks.md b/docs/reference/tools/mise-tasks.md index ae92013..16bc10e 100644 --- a/docs/reference/tools/mise-tasks.md +++ b/docs/reference/tools/mise-tasks.md @@ -1,6 +1,6 @@ --- title: Mise Tasks -modified: 2026-02-24 +modified: 2026-04-11 tags: - reference - tools @@ -47,7 +47,7 @@ Run `mise tasks --sort name` for the live list with descriptions. |------|-------------| | `container-list` | List containers and their recent tags | | `container-build-and-release` | Trigger container build workflows via Forgejo API | -| `container-version-check` | Validate version consistency across Dockerfiles, nix, and manifests | +| `container-version-check` | Validate version consistency across container.py, Dockerfiles, nix, and manifests | | `mirror-create` | Create an upstream mirror in the `mirrors/` Forgejo org | | `mirror-update-pats` | Update GitHub PAT on all mirror repos on indri | diff --git a/mise-tasks/container-build-and-release b/mise-tasks/container-build-and-release index 508d586..3549597 100755 --- a/mise-tasks/container-build-and-release +++ b/mise-tasks/container-build-and-release @@ -54,6 +54,8 @@ def list_containers() -> None: if not d.is_dir(): continue types = [] + if (d / "container.py").exists(): + types.append("dagger") if (d / "Dockerfile").exists(): types.append("dockerfile") if (d / "default.nix").exists(): @@ -70,11 +72,12 @@ def main( ) -> None: """Trigger container build workflows via Forgejo API dispatch.""" container_dir = Path("containers") / container + has_container_py = (container_dir / "container.py").exists() has_dockerfile = (container_dir / "Dockerfile").exists() has_nix = (container_dir / "default.nix").exists() - if not has_dockerfile and not has_nix: - typer.echo(f"Error: No Dockerfile or default.nix found in '{container_dir}'") + if not has_container_py and not has_dockerfile and not has_nix: + typer.echo(f"Error: No container.py, Dockerfile, or default.nix found in '{container_dir}'") typer.echo() list_containers() raise typer.Exit(1) @@ -90,10 +93,11 @@ def main( # Show expected builds builds = [] - if has_dockerfile: - builds.append(f" dockerfile -> {REGISTRY}/{image}:v-{short_sha}") + if has_container_py or has_dockerfile: + label = "dagger" if has_container_py else "dockerfile" + builds.append(f" {label:12s} -> {REGISTRY}/{image}:v-{short_sha}") if has_nix: - builds.append(f" nix -> {REGISTRY}/{image}:v-{short_sha}-nix") + builds.append(f" {'nix':12s} -> {REGISTRY}/{image}:v-{short_sha}-nix") if dry_run: typer.echo("[dry-run mode]") diff --git a/mise-tasks/container-list b/mise-tasks/container-list index 5c554b6..b1bd433 100755 --- a/mise-tasks/container-list +++ b/mise-tasks/container-list @@ -78,11 +78,14 @@ def discover_containers() -> list[dict]: for d in sorted(CONTAINER_DIR.iterdir()): if not d.is_dir(): continue + has_container_py = (d / "container.py").exists() has_dockerfile = (d / "Dockerfile").exists() has_nix = (d / "default.nix").exists() - if not has_dockerfile and not has_nix: + if not has_container_py and not has_dockerfile and not has_nix: continue types = [] + if has_container_py: + types.append("dagger") if has_dockerfile: types.append("dockerfile") if has_nix: diff --git a/mise-tasks/container-version-check b/mise-tasks/container-version-check index 1df062f..6270ae1 100755 --- a/mise-tasks/container-version-check +++ b/mise-tasks/container-version-check @@ -3,16 +3,17 @@ # requires-python = ">=3.12" # dependencies = ["pyyaml>=6.0.2", "rich>=14.0.0", "typer>=0.24.0"] # /// -#MISE description="Validate container version consistency across Dockerfiles, nix derivations, and service-versions.yaml" +#MISE description="Validate container version consistency across container.py, Dockerfiles, nix derivations, and service-versions.yaml" #USAGE flag "--all-files" help="Check all containers, not just changed ones" """Validate that container versions are consistent across all declaration sites. For each container directory under containers/, checks: -1. Any Dockerfile must declare ARG CONTAINER_APP_VERSION= -2. Any default.nix must produce a version (via dagger call nix-version) -3. At least one build file (Dockerfile or default.nix) must exist -4. A matching entry in service-versions.yaml must exist with non-null current-version -5. All resolved versions from (1), (2), and (4) must agree +1. Any container.py must declare VERSION= +2. Any Dockerfile must declare ARG CONTAINER_APP_VERSION= +3. Any default.nix must produce a version (via dagger call nix-version) +4. At least one build file (container.py, Dockerfile, or default.nix) must exist +5. A matching entry in service-versions.yaml must exist with non-null current-version +6. All resolved versions from (1), (2), (3), and (5) must agree By default, only checks containers whose files differ from main. Pass --all-files to check every container. @@ -53,6 +54,7 @@ NIX_PACKAGE_MAP = { "authentik": "authentik", } +CONTAINER_PY_VERSION_PATTERN = re.compile(r'^VERSION\s*=\s*["\']([^"\']+)["\']', re.MULTILINE) VERSION_ARG_PATTERN = re.compile(r"^ARG\s+CONTAINER_APP_VERSION=(\S+)", re.MULTILINE) NIX_VERSION_PATTERN = re.compile(r'^\s*version\s*=\s*"([^"]+)"\s*;', re.MULTILINE) @@ -109,7 +111,7 @@ def get_nix_version(container_name: str, nix_file: Path) -> str | None: return None result = subprocess.run( - ["dagger", "-m", ".dagger", "call", "nix-version", f"--package={pkg}"], + ["dagger", "call", "nix-version", f"--package={pkg}"], capture_output=True, text=True, cwd=REPO_ROOT, @@ -148,26 +150,37 @@ def main( if scope is not None and name not in scope: continue + container_py = container_dir / "container.py" dockerfile = container_dir / "Dockerfile" nix_file = container_dir / "default.nix" + has_container_py = container_py.exists() has_dockerfile = dockerfile.exists() has_nix = nix_file.exists() versions: dict[str, str] = {} entry = { "name": name, + "has_container_py": has_container_py, "has_dockerfile": has_dockerfile, "has_nix": has_nix, "versions": versions, } results.append(entry) - # Rule 3: at least one build file - if not has_dockerfile and not has_nix: - errors.append((name, "No Dockerfile or default.nix found")) + # Rule 4: at least one build file + if not has_container_py and not has_dockerfile and not has_nix: + errors.append((name, "No container.py, Dockerfile, or default.nix found")) continue - # Rule 1: Dockerfile must declare CONTAINER_APP_VERSION + # Rule 1: container.py must declare VERSION + if has_container_py: + match = CONTAINER_PY_VERSION_PATTERN.search(container_py.read_text()) + if match: + versions["container.py"] = match.group(1) + else: + errors.append((name, "container.py missing VERSION declaration")) + + # Rule 2: Dockerfile must declare CONTAINER_APP_VERSION if has_dockerfile: match = VERSION_ARG_PATTERN.search(dockerfile.read_text()) if match: @@ -175,7 +188,7 @@ def main( else: errors.append((name, "Dockerfile missing ARG CONTAINER_APP_VERSION")) - # Rule 2: nix derivation must produce a version + # Rule 3: nix derivation must produce a version if has_nix: nix_ver = get_nix_version(name, nix_file) if nix_ver is not None: @@ -219,6 +232,8 @@ def main( for entry in results: name = entry["name"] build_parts = [] + if entry["has_container_py"]: + build_parts.append("dagger") if entry["has_dockerfile"]: build_parts.append("dockerfile") if entry["has_nix"]: diff --git a/mise-tasks/service-review b/mise-tasks/service-review index 28b6dc4..92bac53 100755 --- a/mise-tasks/service-review +++ b/mise-tasks/service-review @@ -21,7 +21,6 @@ After reviewing, update the service entry in the YAML file: Usage: mise run service-review [-- --limit 15] [-- --type argocd] """ -import sys from datetime import date from pathlib import Path from typing import Annotated @@ -166,12 +165,25 @@ def main( ] svc_type = top_svc.get("type", "") + container_dir = Path(__file__).parent.parent / "containers" / top_svc["name"] + has_dockerfile_only = ( + (container_dir / "Dockerfile").exists() + and not (container_dir / "container.py").exists() + ) + if svc_type == "argocd": checklist_parts += [ "\n[bold]ArgoCD Deployment:[/bold]\n", "• Update image tag in argocd/manifests//kustomization.yaml\n", f"• Verify sync status: argocd app get {top_svc['name']}\n", ] + if has_dockerfile_only: + checklist_parts += [ + "\n[bold yellow]Dagger Migration:[/bold yellow]\n", + "• This container still uses a Dockerfile (no container.py)\n", + "• Consider migrating to a native Dagger build for better error visibility\n", + f"• See containers/{top_svc['name']}/Dockerfile\n", + ] elif svc_type == "ansible": checklist_parts += [ "\n[bold]Ansible Deployment:[/bold]\n", diff --git a/.dagger/pyproject.toml b/pyproject.toml similarity index 91% rename from .dagger/pyproject.toml rename to pyproject.toml index 721a14a..8b6cf8e 100644 --- a/.dagger/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [project] -name = "blumeops-ci" +name = "blumeops" version = "0.1.0" requires-python = ">=3.13" dependencies = ["dagger-io"] diff --git a/service-versions.yaml b/service-versions.yaml index 4f58a5b..1250698 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -178,8 +178,8 @@ services: - name: navidrome type: argocd - last-reviewed: 2026-03-02 - current-version: "v0.60.3" + last-reviewed: 2026-04-11 + current-version: "v0.61.1" upstream-source: https://github.com/navidrome/navidrome/releases - name: miniflux diff --git a/src/blumeops/__init__.py b/src/blumeops/__init__.py new file mode 100644 index 0000000..1d1b128 --- /dev/null +++ b/src/blumeops/__init__.py @@ -0,0 +1,3 @@ +"""BlumeOps — Dagger build functions for container images and CI.""" + +from .main import Blumeops as Blumeops diff --git a/src/blumeops/containers.py b/src/blumeops/containers.py new file mode 100644 index 0000000..728129f --- /dev/null +++ b/src/blumeops/containers.py @@ -0,0 +1,167 @@ +"""Container build discovery and reusable helpers. + +Discovers native Dagger builds from containers//container.py files. +Each container.py must define a top-level `build(src)` async function that +returns a dagger.Container, and a `VERSION` string constant. +""" + +import importlib.util +from pathlib import Path +from types import ModuleType + +import dagger +from dagger import dag + +FORGE_MIRROR = "https://forge.ops.eblu.me/mirrors" + + +# --- Discovery --- + + +def _discover_modules(containers_dir: Path) -> dict[str, Path]: + """Find all containers//container.py files.""" + result = {} + if not containers_dir.is_dir(): + return result + for child in sorted(containers_dir.iterdir()): + container_py = child / "container.py" + if child.is_dir() and container_py.exists(): + result[child.name] = container_py + return result + + +def _load_module(name: str, path: Path) -> ModuleType: + """Dynamically load a container.py as a Python module.""" + spec = importlib.util.spec_from_file_location(f"containers.{name}", path) + if spec is None or spec.loader is None: + msg = f"Cannot load {path}" + raise ImportError(msg) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def discover(containers_dir: Path) -> dict[str, ModuleType]: + """Discover and load all container.py modules. + + Returns a dict mapping container name to loaded module. + Each module must define: + - VERSION: str — the upstream application version + - async def build(src: dagger.Directory) -> dagger.Container + """ + modules = {} + for name, path in _discover_modules(containers_dir).items(): + modules[name] = _load_module(name, path) + return modules + + +# --- Reusable Helpers --- + + +def clone_from_forge(mirror: str, tag: str) -> dagger.Directory: + """Git clone from forge mirror at a given tag. Returns the repo tree.""" + return dag.git(f"{FORGE_MIRROR}/{mirror}.git").tag(tag).tree() + + +def go_build( + source: dagger.Directory, + output: str, + *, + cmd_path: str = ".", + tags: str = "netgo", + ldflags: str = "-w -s", + cgo_enabled: bool = False, + extra_apk: list[str] | None = None, +) -> dagger.Container: + """Go build stage on golang:alpine3.22. + + Returns a container with the built binary at `output`. + """ + apk_packages = ["build-base", "git"] + (extra_apk or []) + return ( + dag.container() + .from_("golang:alpine3.22") + .with_exec(["apk", "add", "--no-cache", *apk_packages]) + .with_directory("/app", source) + .with_workdir("/app") + .with_env_variable("CGO_ENABLED", "1" if cgo_enabled else "0") + .with_exec( + [ + "go", + "build", + f"-tags={tags}", + f"-ldflags={ldflags}", + "-o", + output, + cmd_path, + ] + ) + ) + + +def node_build( + source: dagger.Directory, + workdir: str, + *, + install_cmd: list[str] | None = None, + build_cmd: list[str] | None = None, +) -> dagger.Container: + """Node.js build stage on node:22-alpine. + + Returns a container with built assets in the workdir. + """ + if install_cmd is None: + install_cmd = ["npm", "ci"] + if build_cmd is None: + build_cmd = ["npm", "run", "build"] + + return ( + dag.container() + .from_("node:22-alpine") + .with_directory("/app", source) + .with_workdir(f"/app/{workdir}" if workdir != "." else "/app") + .with_exec(install_cmd) + .with_exec(build_cmd) + ) + + +def alpine_runtime( + *, + extra_apk: list[str] | None = None, + uid: int = 65534, + gid: int = 65534, + username: str = "app", +) -> dagger.Container: + """Standard Alpine 3.22 runtime base with non-root user.""" + packages = extra_apk or [] + setup_cmds = [] + if packages: + setup_cmds.append(f"apk add --no-cache {' '.join(packages)}") + setup_cmds.append(f"addgroup -g {gid} {username}") + setup_cmds.append(f"adduser -u {uid} -G {username} -D {username}") + + return ( + dag.container() + .from_("alpine:3.22") + .with_exec(["sh", "-c", " && ".join(setup_cmds)]) + ) + + +def oci_labels( + ctr: dagger.Container, + *, + title: str, + description: str, + version: str, +) -> dagger.Container: + """Apply standard BlumeOps OCI labels.""" + return ( + ctr.with_label("org.opencontainers.image.title", title) + .with_label("org.opencontainers.image.description", description) + .with_label("org.opencontainers.image.version", version) + .with_label( + "org.opencontainers.image.source", + "https://forge.eblu.me/eblume/blumeops", + ) + .with_label("org.opencontainers.image.vendor", "blumeops") + ) diff --git a/.dagger/src/blumeops_ci/main.py b/src/blumeops/main.py similarity index 89% rename from .dagger/src/blumeops_ci/main.py rename to src/blumeops/main.py index 39c3586..94b932b 100644 --- a/.dagger/src/blumeops_ci/main.py +++ b/src/blumeops/main.py @@ -1,17 +1,49 @@ +from pathlib import Path + import dagger from dagger import dag, function, object_type +from .containers import discover + NIX_IMAGE = "nixos/nix:2.34.4" +# Module root is src/blumeops/, repo root is two levels up +_REPO_ROOT = Path(__file__).parent.parent.parent +_CONTAINERS_DIR = _REPO_ROOT / "containers" + @object_type -class BlumeopsCi: +class Blumeops: @function - def build(self, src: dagger.Directory, container_name: str) -> dagger.Container: - """Build a container from containers//Dockerfile.""" + async def build( + self, src: dagger.Directory, container_name: str + ) -> dagger.Container: + """Build a container by name. + + Uses the native Dagger pipeline from containers//container.py + if available, otherwise falls back to docker_build() for containers + still using Dockerfiles. + """ + registry = discover(_CONTAINERS_DIR) + if container_name in registry: + mod = registry[container_name] + return await mod.build(src) + # Legacy fallback for containers still using Dockerfiles context = src.directory(f"containers/{container_name}") return context.docker_build() + @function + async def container_version(self, container_name: str) -> str: + """Return the VERSION declared in a container's container.py. + + Used by CI and mise tasks to extract version without parsing + Dockerfiles. Returns empty string if no container.py exists. + """ + registry = discover(_CONTAINERS_DIR) + if container_name in registry: + return getattr(registry[container_name], "VERSION", "") + return "" + @function async def publish( self, @@ -27,7 +59,7 @@ class BlumeopsCi: Tag format: {version}-{commit_sha} (e.g. v1.0.0-abc1234) """ - ctr = self.build(src, container_name) + ctr = await self.build(src, container_name) if registry_password is not None: ctr = ctr.with_registry_auth(registry, registry_username, registry_password) ref = f"{registry}/blumeops/{container_name}:{version}-{commit_sha}" From 94c937d58888cf2241d691e46a33207169a94cad Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sat, 11 Apr 2026 17:26:25 -0700 Subject: [PATCH 233/430] Disable OTLP metrics exporter in CI, update navidrome to main tag The Dagger Python SDK's OTLP metrics exporter hits a non-functional local endpoint (500s), burning ~9s per retry cycle. Set OTEL_METRICS_EXPORTER=none in the build-dagger CI job. Also update navidrome kustomization to the main-SHA tag (c86b5d7). Co-Authored-By: Claude Opus 4.6 (1M context) --- .forgejo/workflows/build-container.yaml | 4 ++++ argocd/manifests/navidrome/kustomization.yaml | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.forgejo/workflows/build-container.yaml b/.forgejo/workflows/build-container.yaml index efa9007..fe6b000 100644 --- a/.forgejo/workflows/build-container.yaml +++ b/.forgejo/workflows/build-container.yaml @@ -73,6 +73,10 @@ jobs: needs: detect if: needs.detect.outputs.dagger != '[]' runs-on: k8s + env: + # Disable Python SDK OTLP metrics exporter — the Dagger engine's local + # OTLP endpoint returns 500s, causing ~9s retry cycles per minute. + OTEL_METRICS_EXPORTER: none strategy: matrix: container: ${{ fromJson(needs.detect.outputs.dagger) }} diff --git a/argocd/manifests/navidrome/kustomization.yaml b/argocd/manifests/navidrome/kustomization.yaml index 7e4665b..a2914f5 100644 --- a/argocd/manifests/navidrome/kustomization.yaml +++ b/argocd/manifests/navidrome/kustomization.yaml @@ -11,4 +11,4 @@ resources: - ingress-tailscale.yaml images: - name: registry.ops.eblu.me/blumeops/navidrome - newTag: v0.61.1-470b4bd + newTag: v0.61.1-c86b5d7 From c06eccc61ccac7412a2d7093092fcd96db0d0f10 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sat, 11 Apr 2026 21:06:53 -0700 Subject: [PATCH 234/430] Review hosts.md: add last-reviewed, normalize links, add reference tag Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/reference/infrastructure/hosts.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/reference/infrastructure/hosts.md b/docs/reference/infrastructure/hosts.md index f8b07ff..439edf7 100644 --- a/docs/reference/infrastructure/hosts.md +++ b/docs/reference/infrastructure/hosts.md @@ -1,7 +1,9 @@ --- title: Hosts -modified: 2026-02-18 +modified: 2026-04-11 +last-reviewed: 2026-04-11 tags: + - reference - infrastructure --- @@ -13,12 +15,12 @@ All devices connected via [Tailscale](https://login.tailscale.com/) tailnet `tai | Host | Description | Card | |------|-------------|------| -| **Indri** | Mac Mini M1, 2020 - Primary server | [[indri|Details]] | -| **Gilbert** | MacBook Air M4, 2025 - Workstation | [[gilbert|Details]] | +| **[[indri|Indri]]** | Mac Mini M1, 2020 - Primary server | [[indri|Details]] | +| **[[gilbert|Gilbert]]** | MacBook Air M4, 2025 - Workstation | [[gilbert|Details]] | | **[[sifaka|Sifaka]]** | Synology NAS - Storage & backups | [[sifaka|Details]] | | **[[ringtail|Ringtail]]** | Custom PC, NixOS - Service host & gaming | [[ringtail|Details]] | | **Mouse** | MacBook Air M2 - Allison's laptop | - | -| **UniFi** | UniFi Express 7 - Home WiFi | [[unifi|Details]] | +| **[[unifi|UniFi]]** | UniFi Express 7 - Home WiFi | [[unifi|Details]] | | **Dwarf** | iPad Air - Employer-provided, off tailnet | - | ## Related From dc5bffdd97df0142554f46f1f152f34e1476f638 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sat, 11 Apr 2026 21:14:46 -0700 Subject: [PATCH 235/430] Update ringtail flake inputs Co-Authored-By: Claude Opus 4.6 (1M context) --- nixos/ringtail/flake.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/nixos/ringtail/flake.lock b/nixos/ringtail/flake.lock index def21b2..86c20af 100644 --- a/nixos/ringtail/flake.lock +++ b/nixos/ringtail/flake.lock @@ -43,11 +43,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1775305101, - "narHash": "sha256-/74n1oQPtKG52Yw41cbToxspxHbYz6O3vi+XEw16Qe8=", + "lastModified": 1775811116, + "narHash": "sha256-t+HZK42pB6N+i5RGbuy7Xluez/VvWbembBdvzsc23Ss=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "36a601196c4ebf49e035270e10b2d103fe39076b", + "rev": "54170c54449ea4d6725efd30d719c5e505f1c10e", "type": "github" }, "original": { From 138e23d525908558285031de7fef12ac36fda1fc Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sun, 12 Apr 2026 08:54:32 -0700 Subject: [PATCH 236/430] Miniflux 2.2.19 + container.py migration + ty typechecker (#331) ## Summary - Upgrade miniflux from 2.2.17 to 2.2.19 (security hardening, performance improvements) - Migrate miniflux from Dockerfile to native Dagger container.py build - Refactor `alpine_runtime()` helper to support existing users (nobody/65534) - Add `ty` (Astral) Python typechecker to prek hooks ## Test plan - [ ] `dagger call build --src=. --container-name=miniflux` succeeds - [ ] `dagger call container-version --container-name=miniflux` returns 2.2.19 - [ ] `mise run container-version-check` passes - [ ] `ty check` passes cleanly - [ ] `prek run --all-files` passes - [ ] CI builds container successfully - [ ] Miniflux healthcheck passes after deploy from branch Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/331 --- argocd/manifests/miniflux/kustomization.yaml | 2 +- containers/miniflux/Dockerfile | 35 ----------- containers/miniflux/container.py | 61 +++++++++++++++++++ .../miniflux-upgrade-and-ty.feature.md | 1 + .../miniflux-upgrade-and-ty.infra.md | 1 + docs/how-to/knowledgebase/review-services.md | 14 ++++- mise-tasks/service-review | 9 ++- mise.toml | 7 ++- prek.toml | 12 ++++ pyproject.toml | 7 +++ service-versions.yaml | 43 ++++++++++++- src/blumeops/containers.py | 22 ++++--- 12 files changed, 161 insertions(+), 53 deletions(-) delete mode 100644 containers/miniflux/Dockerfile create mode 100644 containers/miniflux/container.py create mode 100644 docs/changelog.d/miniflux-upgrade-and-ty.feature.md create mode 100644 docs/changelog.d/miniflux-upgrade-and-ty.infra.md diff --git a/argocd/manifests/miniflux/kustomization.yaml b/argocd/manifests/miniflux/kustomization.yaml index 8207d55..9105d42 100644 --- a/argocd/manifests/miniflux/kustomization.yaml +++ b/argocd/manifests/miniflux/kustomization.yaml @@ -10,4 +10,4 @@ resources: images: - name: registry.ops.eblu.me/blumeops/miniflux - newTag: v2.2.17-613f05d + newTag: v2.2.19-e08c95c diff --git a/containers/miniflux/Dockerfile b/containers/miniflux/Dockerfile deleted file mode 100644 index 4e987cc..0000000 --- a/containers/miniflux/Dockerfile +++ /dev/null @@ -1,35 +0,0 @@ -# Miniflux RSS feed reader -# Based on upstream packaging/docker/alpine/Dockerfile - -ARG CONTAINER_APP_VERSION=2.2.17 -ARG MINIFLUX_VERSION=${CONTAINER_APP_VERSION} - -FROM golang:alpine3.22 AS build - -ARG MINIFLUX_VERSION -RUN apk add --no-cache build-base git make - -# Clone specific version -RUN git clone --depth 1 --branch ${MINIFLUX_VERSION} \ - https://forge.ops.eblu.me/mirrors/miniflux.git /go/src/app - -WORKDIR /go/src/app -RUN make miniflux - -FROM alpine:3.22 - -ARG CONTAINER_APP_VERSION -LABEL org.opencontainers.image.title="Miniflux" -LABEL org.opencontainers.image.description="Miniflux is a minimalist and opinionated feed reader" -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" - -EXPOSE 8080 -ENV LISTEN_ADDR=0.0.0.0:8080 - -RUN apk --no-cache add ca-certificates tzdata -COPY --from=build /go/src/app/miniflux /usr/bin/miniflux - -USER 65534 -CMD ["/usr/bin/miniflux"] diff --git a/containers/miniflux/container.py b/containers/miniflux/container.py new file mode 100644 index 0000000..ef2050a --- /dev/null +++ b/containers/miniflux/container.py @@ -0,0 +1,61 @@ +"""Miniflux RSS feed reader — native Dagger build. + +Two-stage build: Go (backend with PIE), Alpine (runtime). +Source cloned from forge mirror. +""" + +import dagger +from dagger import dag + +from blumeops.containers import ( + alpine_runtime, + clone_from_forge, + oci_labels, +) + +VERSION = "2.2.19" + + +async def build(src: dagger.Directory) -> dagger.Container: + source = clone_from_forge("miniflux", VERSION) + + # Stage 1: Build Go backend (PIE mode, matching upstream Makefile) + ldflags = f"-s -w -X 'miniflux.app/v2/internal/version.Version={VERSION}'" + backend = ( + dag.container() + .from_("golang:alpine3.22") + .with_exec(["apk", "add", "--no-cache", "build-base", "git"]) + .with_directory("/app", source) + .with_workdir("/app") + .with_env_variable("CGO_ENABLED", "1") + .with_exec( + [ + "go", + "build", + "-buildmode=pie", + f"-ldflags={ldflags}", + "-o", + "/miniflux", + ".", + ] + ) + ) + + # Stage 2: Runtime (uses Alpine's built-in nobody:65534) + runtime = alpine_runtime( + extra_apk=["ca-certificates", "tzdata"], + create_user=False, + ) + runtime = oci_labels( + runtime, + title="Miniflux", + description="Miniflux is a minimalist and opinionated feed reader", + version=VERSION, + ) + return ( + runtime.with_file("/usr/bin/miniflux", backend.file("/miniflux")) + .with_exposed_port(8080) + .with_env_variable("LISTEN_ADDR", "0.0.0.0:8080") + .with_user("65534") + .with_default_args(args=["/usr/bin/miniflux"]) + ) diff --git a/docs/changelog.d/miniflux-upgrade-and-ty.feature.md b/docs/changelog.d/miniflux-upgrade-and-ty.feature.md new file mode 100644 index 0000000..fa88736 --- /dev/null +++ b/docs/changelog.d/miniflux-upgrade-and-ty.feature.md @@ -0,0 +1 @@ +Add `ty` (Astral) Python typechecker to prek hooks, configured for Dagger SDK and container.py modules. Add `type: mise` to service-versions.yaml for tracking development tool versions (dagger, ansible-core, prek, pulumi, ty) through the standard service review process. diff --git a/docs/changelog.d/miniflux-upgrade-and-ty.infra.md b/docs/changelog.d/miniflux-upgrade-and-ty.infra.md new file mode 100644 index 0000000..1c124f5 --- /dev/null +++ b/docs/changelog.d/miniflux-upgrade-and-ty.infra.md @@ -0,0 +1 @@ +Upgrade miniflux from 2.2.17 to 2.2.19 and migrate from Dockerfile to native Dagger container.py build (second container after navidrome). Refactor `alpine_runtime()` with `create_user` parameter to support Alpine's built-in nobody user. Pin all mise.toml tool versions to explicit versions instead of "latest". diff --git a/docs/how-to/knowledgebase/review-services.md b/docs/how-to/knowledgebase/review-services.md index 43615fb..f995d1a 100644 --- a/docs/how-to/knowledgebase/review-services.md +++ b/docs/how-to/knowledgebase/review-services.md @@ -1,7 +1,7 @@ --- title: Review Services -modified: 2026-03-24 -last-reviewed: 2026-03-07 +modified: 2026-04-12 +last-reviewed: 2026-04-12 tags: - how-to - maintenance @@ -66,6 +66,16 @@ Versioned NixOS services (forgejo-runner, snowflake, k3s) are pinned via a `nixp 4. Deploy via `mise run provision-ringtail` 5. Update `service-versions.yaml` with the new version +### Mise Tools (`type: mise`) + +Development tools managed via `mise.toml` with pinned versions. These are local CLI tools (dagger, pulumi, prek, ty, ansible-core) rather than deployed services. + +1. Check the upstream releases page for new versions +2. Review the changelog for breaking changes +3. Update the pinned version in `mise.toml` +4. Run `mise install` to verify the new version installs correctly +5. Update `service-versions.yaml` with the new version + ### Private Forge Repos (`upstream-source` under `forge.eblu.me/eblume/`) Some services are built from private repos on the forge rather than tracking an external upstream project. When `upstream-source` points to a `forge.eblu.me/eblume/` repo: diff --git a/mise-tasks/service-review b/mise-tasks/service-review index 92bac53..01c4ce0 100755 --- a/mise-tasks/service-review +++ b/mise-tasks/service-review @@ -5,7 +5,7 @@ # /// #MISE description="Review the most stale service for version freshness" #USAGE flag "--limit " default="15" help="Number of services to show in the table" -#USAGE flag "--type " help="Filter by service type (argocd, ansible, nixos)" +#USAGE flag "--type " help="Filter by service type (argocd, ansible, nixos, fly, mise)" """Review the most stale service for version freshness. Reads ``docs/reference/services/service-versions.yaml`` and sorts services @@ -197,6 +197,13 @@ def main( "• Update: dagger call flake-update --src=. export --path=nixos/ringtail/flake.lock\n", "• Deploy: mise run provision-ringtail\n", ] + elif svc_type == "mise": + checklist_parts += [ + "\n[bold]Mise Tool Update:[/bold]\n", + "• Update pinned version in mise.toml\n", + "• Run: mise install to verify\n", + "• Check for breaking changes in release notes\n", + ] checklist_parts += [ "\n[bold]Health Check:[/bold]\n", diff --git a/mise.toml b/mise.toml index 5bb2829..a5bad84 100644 --- a/mise.toml +++ b/mise.toml @@ -1,5 +1,6 @@ [tools] -"pipx:ansible-core" = { version = "latest", uvx = "true", uvx_args = "--with botocore --with boto3" } -prek = "latest" -pulumi = "latest" +"pipx:ansible-core" = { version = "2.20.1", uvx = "true", uvx_args = "--with botocore --with boto3" } +prek = "0.3.4" +pulumi = "3.215.0" dagger = "0.20.1" +ty = "0.0.29" diff --git a/prek.toml b/prek.toml index 7f0f9ab..28776c5 100644 --- a/prek.toml +++ b/prek.toml @@ -77,6 +77,18 @@ repo = "https://github.com/astral-sh/ruff-pre-commit" rev = "v0.15.7" hooks = [{ id = "ruff", args = ["--fix"] }, { id = "ruff-format" }] +# Python - ty type checker +[[repos]] +repo = "local" + +[[repos.hooks]] +id = "ty-check" +name = "ty type check" +entry = "ty check" +language = "system" +types = ["python"] +pass_filenames = false + # Shell scripts - shellcheck and shfmt [[repos]] repo = "https://github.com/shellcheck-py/shellcheck-py" diff --git a/pyproject.toml b/pyproject.toml index 8b6cf8e..f612759 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,3 +10,10 @@ build-backend = "uv_build" [tool.uv.sources] dagger-io = { path = "sdk", editable = true } + +[tool.ty.environment] +python-version = "3.13" +extra-paths = ["sdk/src"] + +[tool.ty.src] +exclude = ["pulumi/", "containers/transmission-exporter/"] diff --git a/service-versions.yaml b/service-versions.yaml index 1250698..50f167d 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -5,7 +5,7 @@ # # Fields: # name - kebab-case service identifier -# type - argocd | ansible | nixos | fly +# type - argocd | ansible | nixos | fly | mise # last-reviewed - date (YYYY-MM-DD) or null # current-version - deployed version string or null # upstream-source - URL to upstream releases/changelog @@ -184,8 +184,8 @@ services: - name: miniflux type: argocd - last-reviewed: 2026-03-02 - current-version: "2.2.17" + last-reviewed: 2026-04-12 + current-version: "2.2.19" upstream-source: https://github.com/miniflux/v2/releases - name: teslamate @@ -393,3 +393,40 @@ services: current-version: "v1.14.1" upstream-source: https://github.com/grafana/alloy/releases notes: COPY --from in fly/Dockerfile for log shipping and metrics + + # --- Mise-managed development tools --- + + - name: dagger + type: mise + last-reviewed: 2026-04-12 + current-version: "0.20.1" + upstream-source: https://github.com/dagger/dagger/releases + notes: Dagger CI/CD engine; pinned in mise.toml + + - name: ansible-core + type: mise + last-reviewed: 2026-04-12 + current-version: "2.20.1" + upstream-source: https://github.com/ansible/ansible/releases + notes: Installed via pipx/uvx with botocore and boto3 + + - name: prek + type: mise + last-reviewed: 2026-04-12 + current-version: "0.3.4" + upstream-source: https://github.com/j178/prek/releases + notes: Pre-commit hook runner (Rust reimplementation) + + - name: pulumi-cli + type: mise + last-reviewed: 2026-04-12 + current-version: "3.215.0" + upstream-source: https://github.com/pulumi/pulumi/releases + notes: IaC CLI for tailscale and gandi stacks + + - name: ty + type: mise + last-reviewed: 2026-04-12 + current-version: "0.0.29" + upstream-source: https://github.com/astral-sh/ty/releases + notes: Astral Python typechecker (beta); prek hook diff --git a/src/blumeops/containers.py b/src/blumeops/containers.py index 728129f..4805c5c 100644 --- a/src/blumeops/containers.py +++ b/src/blumeops/containers.py @@ -131,20 +131,26 @@ def alpine_runtime( uid: int = 65534, gid: int = 65534, username: str = "app", + create_user: bool = True, ) -> dagger.Container: - """Standard Alpine 3.22 runtime base with non-root user.""" + """Standard Alpine 3.22 runtime base. + + When create_user is True (default), creates a non-root user with the given + uid/gid/username. Set create_user=False to use an existing user (e.g. + Alpine's built-in nobody:65534). + """ packages = extra_apk or [] setup_cmds = [] if packages: setup_cmds.append(f"apk add --no-cache {' '.join(packages)}") - setup_cmds.append(f"addgroup -g {gid} {username}") - setup_cmds.append(f"adduser -u {uid} -G {username} -D {username}") + if create_user: + setup_cmds.append(f"addgroup -g {gid} {username}") + setup_cmds.append(f"adduser -u {uid} -G {username} -D {username}") - return ( - dag.container() - .from_("alpine:3.22") - .with_exec(["sh", "-c", " && ".join(setup_cmds)]) - ) + ctr = dag.container().from_("alpine:3.22") + if setup_cmds: + ctr = ctr.with_exec(["sh", "-c", " && ".join(setup_cmds)]) + return ctr def oci_labels( From a18ec9d9584862b7531f0fc8974c9bf7713a5031 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sun, 12 Apr 2026 08:59:32 -0700 Subject: [PATCH 237/430] Update miniflux to main image tag, disable OTEL metrics in Dagger module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Point miniflux kustomization at the main-built v2.2.19-138e23d image (replacing the branch tag). Disable the OTLP metrics exporter at module import time to prevent ~11s retry delays in CI — the env var must be set inside the module, not the runner shell, because the SDK runs inside the Dagger engine container. Co-Authored-By: Claude Opus 4.6 (1M context) --- argocd/manifests/miniflux/kustomization.yaml | 2 +- src/blumeops/main.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/argocd/manifests/miniflux/kustomization.yaml b/argocd/manifests/miniflux/kustomization.yaml index 9105d42..6fc00cd 100644 --- a/argocd/manifests/miniflux/kustomization.yaml +++ b/argocd/manifests/miniflux/kustomization.yaml @@ -10,4 +10,4 @@ resources: images: - name: registry.ops.eblu.me/blumeops/miniflux - newTag: v2.2.19-e08c95c + newTag: v2.2.19-138e23d diff --git a/src/blumeops/main.py b/src/blumeops/main.py index 94b932b..56595e2 100644 --- a/src/blumeops/main.py +++ b/src/blumeops/main.py @@ -1,5 +1,10 @@ +import os from pathlib import Path +# Disable OTLP metrics exporter before the Dagger SDK initializes OpenTelemetry. +# The Dagger engine's local OTLP endpoint returns 500s, causing ~11s retry delays. +os.environ.setdefault("OTEL_METRICS_EXPORTER", "none") + import dagger from dagger import dag, function, object_type From 8d80a4a3a5403e2d3e1b1b3e8a058ce3d5ade8bd Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sun, 12 Apr 2026 09:42:58 -0700 Subject: [PATCH 238/430] Rewrite runner-logs: API-based log fetching, multi-repo support Replace broken SSH+filesystem log retrieval with Forgejo web API endpoint. Fix CLI to use run numbers (not task IDs), add --repo for querying any forge repo (e.g. sporks), --limit/-n for listing size. Document runner-logs as the way to verify build success in CLAUDE.md and container build docs. Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 11 + .../+runner-logs-rewrite.bugfix.md | 1 + .../deployment/build-container-image.md | 10 +- docs/reference/tools/mise-tasks.md | 2 +- mise-tasks/runner-logs | 219 ++++++++++++------ 5 files changed, 172 insertions(+), 71 deletions(-) create mode 100644 docs/changelog.d/+runner-logs-rewrite.bugfix.md diff --git a/CLAUDE.md b/CLAUDE.md index f1d640e..60757ad 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -117,6 +117,17 @@ The goal is to eventually use only locally built containers in all cases, with full supply chain control via forge.ops.eblu.me repositories, mirroring source from upstream. +**After triggering a build** (manual dispatch or push to main), verify the +workflow succeeded before proceeding: + +```fish +mise run runner-logs # find the run number +mise run runner-logs # see jobs in the run +mise run runner-logs -j # fetch logs on failure +``` + +This also works for other forge repos (`--repo eblume/hermes`). + ## Third-Party Projects Ask user to mirror on forge first, then clone to `~/code/3rd//`. diff --git a/docs/changelog.d/+runner-logs-rewrite.bugfix.md b/docs/changelog.d/+runner-logs-rewrite.bugfix.md new file mode 100644 index 0000000..7962ac4 --- /dev/null +++ b/docs/changelog.d/+runner-logs-rewrite.bugfix.md @@ -0,0 +1 @@ +Rewrite `mise run runner-logs` CLI: list runs by run number (not task ID), drill into jobs per run, fetch logs via Forgejo web API instead of SSH+filesystem. Fixes broken log retrieval caused by incorrect hex path calculation and stale data directory. Added `--repo` to query any forge repo (e.g. sporks) and `--limit`/`-n` to control listing size (0 for all). diff --git a/docs/how-to/deployment/build-container-image.md b/docs/how-to/deployment/build-container-image.md index ce746b0..a0e7d03 100644 --- a/docs/how-to/deployment/build-container-image.md +++ b/docs/how-to/deployment/build-container-image.md @@ -68,6 +68,14 @@ mise run container-build-and-release --ref Use `--dry-run` to preview without dispatching. +After dispatching, verify the workflow succeeded with `runner-logs`: + +```bash +mise run runner-logs # find the new run number +mise run runner-logs # see jobs and their status +mise run runner-logs -j # fetch full logs (e.g. on failure) +``` + | Build file | Workflow | Runner | Registry tag | |------------|----------|--------|--------------| | `container.py` | `build-container.yaml` | `k8s` (indri) | `:vX.Y.Z-` | @@ -99,7 +107,7 @@ Container image tags include the git commit SHA they were built from (e.g. `v3.9 **The rule:** Production manifests must reference images built from a commit on main. After merging a PR that changed `containers//`: 1. The merge to main automatically triggers a rebuild (the `build-container.yaml` / `build-container-nix.yaml` workflows fire on pushes to `main` that touch `containers/**`) -2. Wait for the workflow to complete — check at `https://forge.eblu.me/eblume/blumeops/actions` +2. Wait for the workflow to complete — verify with `mise run runner-logs` (find the run, check status) 3. Find the new main-SHA tag: ```bash mise run container-list diff --git a/docs/reference/tools/mise-tasks.md b/docs/reference/tools/mise-tasks.md index 16bc10e..02b8859 100644 --- a/docs/reference/tools/mise-tasks.md +++ b/docs/reference/tools/mise-tasks.md @@ -57,7 +57,7 @@ Run `mise tasks --sort name` for the live list with descriptions. |------|-------------| | `branch-cleanup` | Delete merged branches (local and remote) | | `pr-comments` | List unresolved PR comments | -| `runner-logs` | View Forgejo Actions workflow logs | +| `runner-logs` | List Forgejo Actions runs and fetch job logs (supports `--repo`, `--limit`) | | `validate-workflows` | Validate workflow files against runner schema | | `mikado-branch-invariant-check` | Validate Mikado Branch Invariant on `mikado/*` branches | diff --git a/mise-tasks/runner-logs b/mise-tasks/runner-logs index ec51608..4db203d 100755 --- a/mise-tasks/runner-logs +++ b/mise-tasks/runner-logs @@ -3,22 +3,23 @@ # requires-python = ">=3.12" # dependencies = ["httpx>=0.28.1", "rich>=14.0.0", "typer>=0.24.0"] # /// -#MISE description="Get logs for a Forgejo Actions workflow run (indri or ringtail runner)" -#USAGE arg "" help="Runner filter: indri, ringtail, or all" -#USAGE arg "[run_id]" help="Run ID to fetch logs for (omit to list recent runs)" -"""Fetch Forgejo Actions workflow logs from indri's log storage. - -Both the indri k8s runner and ringtail nix-container-builder runner report -logs back to the Forgejo server on indri. This tool lists recent runs -(optionally filtered by runner) and fetches compressed logs by run ID. +#MISE description="List recent Forgejo Actions runs or fetch logs for a specific job" +#USAGE arg "[run_number]" help="Run number to show jobs for (omit to list recent runs)" +#USAGE flag "--job -j " help="Job index (0-based) to fetch logs for" +#USAGE flag "--runner -r " help="Filter listing by runner: indri, ringtail, or all" +#USAGE flag "--repo " help="Forge repo (owner/name), default eblume/blumeops" +#USAGE flag "--limit -n " help="Max runs to display (0 for all)" +"""List recent Forgejo Actions runs and fetch job logs. Usage: - mise run runner-logs all # list recent runs from all runners - mise run runner-logs ringtail # list recent ringtail runs - mise run runner-logs all 337 # fetch logs for run 337 + mise run runner-logs # list recent runs (default 15) + mise run runner-logs -n 0 # list ALL runs + mise run runner-logs -r ringtail # list recent ringtail runs + mise run runner-logs --repo eblume/hermes # list runs for a different repo + mise run runner-logs 474 # show jobs in run 474 + mise run runner-logs 474 -j 1 # fetch logs for job 1 of run 474 """ -import subprocess import sys from typing import Annotated @@ -27,9 +28,8 @@ import typer from rich.console import Console from rich.table import Table -FORGE_API = "https://forge.eblu.me/api/v1" -REPO = "eblume/blumeops" -ACTIONS_LOG_DIR = "/opt/homebrew/var/forgejo/data/actions_log/eblume/blumeops" +FORGE_URL = "https://forge.ops.eblu.me" +FORGE_API = f"{FORGE_URL}/api/v1" # Workflows using the ringtail nix-container-builder runner; everything else # runs on the indri k8s runner. @@ -42,89 +42,170 @@ def runner_for_workflow(workflow_id: str) -> str: return "ringtail" if workflow_id in RINGTAIL_WORKFLOWS else "indri" -def list_runs(runner: str, console: Console) -> None: - resp = httpx.get( - f"{FORGE_API}/repos/{REPO}/actions/tasks", - timeout=15, - ) - resp.raise_for_status() - runs = resp.json().get("workflow_runs", []) +def fetch_tasks(repo: str) -> list[dict]: + """Fetch all tasks from the Forgejo API, paginating if needed.""" + tasks: list[dict] = [] + page = 1 + while True: + resp = httpx.get( + f"{FORGE_API}/repos/{repo}/actions/tasks", + params={"page": page, "limit": 50}, + timeout=15, + ) + resp.raise_for_status() + batch = resp.json().get("workflow_runs", []) + if not batch: + break + tasks.extend(batch) + page += 1 + return tasks - table = Table(title=f"Recent runs (filter: {runner})") - table.add_column("ID", style="cyan", no_wrap=True) + +def list_runs(runner: str, repo: str, limit: int, console: Console) -> None: + """List recent workflow runs, grouped by run number.""" + tasks = fetch_tasks(repo) + + # Group tasks by run_number + runs: dict[int, list[dict]] = {} + for t in tasks: + rn = t["run_number"] + runs.setdefault(rn, []).append(t) + + table = Table(title=f"Recent runs — {repo} (filter: {runner})") + table.add_column("Run #", style="cyan", no_wrap=True) table.add_column("Status") table.add_column("Runner") - table.add_column("Name") + table.add_column("Jobs") table.add_column("Title") + table.add_column("Event") - for run in runs[:20]: - host = runner_for_workflow(run.get("workflow_id", "")) + shown = 0 + for rn in sorted(runs, reverse=True): + if limit > 0 and shown >= limit: + break + + jobs = sorted(runs[rn], key=lambda x: x["id"]) + workflow_id = jobs[0].get("workflow_id", "") + host = runner_for_workflow(workflow_id) if runner != "all" and host != runner: continue - status = run.get("status", "") - style = "green" if status == "success" else "red" if status == "failure" else "yellow" + + # Aggregate status: worst status wins + statuses = [j.get("status", "") for j in jobs] + if "failure" in statuses: + status, style = "failure", "red" + elif "running" in statuses or "waiting" in statuses: + status, style = "running", "yellow" + elif all(s == "success" for s in statuses): + status, style = "success", "green" + else: + status, style = statuses[0], "yellow" + + job_names = ", ".join(j.get("name", "?")[:30] for j in jobs) + title = (jobs[0].get("display_title") or "")[:40] + event = jobs[0].get("event", "") + table.add_row( - str(run["id"]), + str(rn), f"[{style}]{status}[/{style}]", host, - (run.get("name") or "")[:40], - (run.get("display_title") or "")[:30], + job_names, + title, + event, + ) + shown += 1 + + console.print(table) + console.print("\n[dim]Use: mise run runner-logs to see jobs in a run[/dim]") + console.print("[dim] mise run runner-logs -j N to fetch logs for job N[/dim]") + + +def show_jobs(run_number: int, repo: str, console: Console) -> None: + """Show the jobs within a specific run.""" + tasks = fetch_tasks(repo) + + jobs = sorted( + [t for t in tasks if t["run_number"] == run_number], + key=lambda x: x["id"], + ) + if not jobs: + typer.echo(f"Error: No jobs found for run #{run_number}", err=True) + raise typer.Exit(1) + + table = Table(title=f"Jobs in run #{run_number} — {repo}") + table.add_column("Job #", style="cyan", no_wrap=True) + table.add_column("Status") + table.add_column("Name") + table.add_column("Created") + + for i, job in enumerate(jobs): + status = job.get("status", "") + style = "green" if status == "success" else "red" if status == "failure" else "yellow" + table.add_row( + str(i), + f"[{style}]{status}[/{style}]", + job.get("name", ""), + job.get("created_at", ""), ) console.print(table) + console.print(f"\n[dim]Use: mise run runner-logs {run_number} -j N to fetch logs for job N[/dim]") -def fetch_log(run_id: int) -> None: - hex_subdir = f"{run_id:02x}" - log_file = f"{ACTIONS_LOG_DIR}/{hex_subdir}/{run_id}.log.zst" - - # All logs live on indri (the Forgejo server) regardless of runner - result = subprocess.run( - ["ssh", "indri", f"test -f '{log_file}' && zstd -d -c '{log_file}'"], - capture_output=True, - text=True, - ) - - if result.returncode == 0: - sys.stdout.write(result.stdout) - else: - typer.echo(f"Error: Log file not found for run {run_id}", err=True) - typer.echo(f"Expected path: {log_file}", err=True) - typer.echo("", err=True) - typer.echo("Available logs:", err=True) - avail = subprocess.run( - [ - "ssh", - "indri", - f"find '{ACTIONS_LOG_DIR}' -name '*.log.zst' -exec basename {{}} .log.zst \\; | sort -n | tail -10", - ], - capture_output=True, - text=True, +def fetch_log(run_number: int, job_index: int, repo: str) -> None: + """Fetch logs for a specific job via the Forgejo web endpoint.""" + url = f"{FORGE_URL}/{repo}/actions/runs/{run_number}/jobs/{job_index}/attempt/1/logs" + resp = httpx.get(url, timeout=30, follow_redirects=True) + if resp.status_code == 404: + typer.echo( + f"Error: No logs found for run #{run_number} job {job_index}", + err=True, ) - typer.echo(avail.stdout, err=True) + typer.echo(f"URL: {url}", err=True) raise typer.Exit(1) + resp.raise_for_status() + sys.stdout.write(resp.text) @app.command() def main( + run_number: Annotated[ + int | None, + typer.Argument(help="Run number to show jobs for (omit to list recent runs)"), + ] = None, + job: Annotated[ + int | None, + typer.Option("--job", "-j", help="Job index (0-based) to fetch logs for"), + ] = None, runner: Annotated[ str, - typer.Argument(help="Runner filter: indri, ringtail, or all"), - ], - run_id: Annotated[ - int | None, - typer.Argument(help="Run ID to fetch logs for (omit to list recent runs)"), - ] = None, + typer.Option("--runner", "-r", help="Filter listing by runner: indri, ringtail, or all"), + ] = "all", + repo: Annotated[ + str, + typer.Option("--repo", help="Forge repo (owner/name)"), + ] = "eblume/blumeops", + limit: Annotated[ + int, + typer.Option("--limit", "-n", help="Max runs to display (0 for all)"), + ] = 15, ) -> None: - """Get logs for a Forgejo Actions workflow run.""" + """List recent Forgejo Actions runs or fetch logs for a specific job.""" if runner not in ("indri", "ringtail", "all"): typer.echo(f"Error: runner must be 'indri', 'ringtail', or 'all', got '{runner}'") raise typer.Exit(1) - if run_id is None: - list_runs(runner, Console()) + console = Console() + + if run_number is None: + if job is not None: + typer.echo("Error: --job requires a run number", err=True) + raise typer.Exit(1) + list_runs(runner, repo, limit, console) + elif job is None: + show_jobs(run_number, repo, console) else: - fetch_log(run_id) + fetch_log(run_number, job, repo) if __name__ == "__main__": From 6e60287e99756f25f22ae8758b938c481f2331af Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sun, 12 Apr 2026 09:52:38 -0700 Subject: [PATCH 239/430] Doc review: delete install-dagger-on-nix-runner, add service-versions ref card Outdated leaf card removed; zot.md now links to new service-versions reference card instead. Added reverse link from review-services. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../+service-versions-ref-card.doc.md | 1 + docs/how-to/knowledgebase/review-services.md | 1 + .../zot/install-dagger-on-nix-runner.md | 32 ------------------- docs/reference/operations/service-versions.md | 19 +++++++++++ docs/reference/services/zot.md | 2 +- 5 files changed, 22 insertions(+), 33 deletions(-) create mode 100644 docs/changelog.d/+service-versions-ref-card.doc.md delete mode 100644 docs/how-to/zot/install-dagger-on-nix-runner.md create mode 100644 docs/reference/operations/service-versions.md diff --git a/docs/changelog.d/+service-versions-ref-card.doc.md b/docs/changelog.d/+service-versions-ref-card.doc.md new file mode 100644 index 0000000..95cb07c --- /dev/null +++ b/docs/changelog.d/+service-versions-ref-card.doc.md @@ -0,0 +1 @@ +Delete outdated install-dagger-on-nix-runner card; add service-versions reference card; clean up zot.md and review-services.md links. diff --git a/docs/how-to/knowledgebase/review-services.md b/docs/how-to/knowledgebase/review-services.md index f995d1a..9969e4c 100644 --- a/docs/how-to/knowledgebase/review-services.md +++ b/docs/how-to/knowledgebase/review-services.md @@ -140,3 +140,4 @@ BlumeOps uses kustomize manifests for all services. Helm charts should not be in - [[deploy-k8s-service]] - Deploy changes to Kubernetes services - [[build-container-image]] - Build and release custom container images - [[add-ansible-role]] - Add or modify Ansible roles +- [[service-versions]] - Version tracking file reference diff --git a/docs/how-to/zot/install-dagger-on-nix-runner.md b/docs/how-to/zot/install-dagger-on-nix-runner.md deleted file mode 100644 index 7d5fda7..0000000 --- a/docs/how-to/zot/install-dagger-on-nix-runner.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -title: Install Dagger on Nix Runner -modified: 2026-02-20 -tags: - - how-to - - ci - - zot ---- - -# Install Dagger on Nix Runner - -Use `nix eval` instead of `dagger call nix-version` for version extraction on the ringtail nix-container-builder runner. - -## Context - -The `build-container-nix.yaml` workflow extracts container versions in this order: - -1. `version = "..."` from `default.nix` (e.g. ntfy) -2. `ARG CONTAINER_APP_VERSION=` from Dockerfile (e.g. nettest) -3. Nixpkgs package version for packages without explicit versions (e.g. authentik) - -Step 3 originally used `dagger call nix-version`, but dagger can't run on the bare nix runner: - -- **Dagger is not in nixpkgs** — removed due to [trademark concerns](https://github.com/NixOS/nixpkgs/issues/260848). Available via `github:dagger/nix` flake. -- **Dagger needs a container runtime** — the CLI is just an API client; the engine runs as a container via Docker/containerd, which the nix runner doesn't have. - -The fix was to use `nix eval --raw "nixpkgs#.version"` directly, which is already available on the nix host and more appropriate. - -## Related - -- [[adopt-commit-based-container-tags]] — Parent card -- [[harden-zot-registry]] — Root goal diff --git a/docs/reference/operations/service-versions.md b/docs/reference/operations/service-versions.md new file mode 100644 index 0000000..23d23e1 --- /dev/null +++ b/docs/reference/operations/service-versions.md @@ -0,0 +1,19 @@ +--- +title: Service Versions +modified: 2026-04-12 +last-reviewed: 2026-04-12 +tags: + - reference + - maintenance + - services +--- + +# Service Versions + +`service-versions.yaml` (repo root) tracks version information for all deployed services and tools in blumeops. Each entry records the service name, deployment type, current version, upstream source, and when it was last reviewed. + +This file enables a regular update cadence via `mise run service-review`, which surfaces stale services sorted by review date. See [[review-services]] for the full review process. + +## Related + +- [[review-services]] — How to review services for version freshness diff --git a/docs/reference/services/zot.md b/docs/reference/services/zot.md index c309557..d00a200 100644 --- a/docs/reference/services/zot.md +++ b/docs/reference/services/zot.md @@ -66,4 +66,4 @@ The `zot-ci` API key expires every **90 days**. To rotate: - [[cluster|Cluster]] - Registry consumer - [[authentik]] - OIDC identity provider - [[harden-zot-registry]] - Security hardening guide -- [[install-dagger-on-nix-runner]] - Why Dagger can't run on the Nix builder +- [[service-versions]] - Version tracking for deployed services From 6455d93cb369610cd3a6e400620c795570de574b Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sun, 12 Apr 2026 09:59:37 -0700 Subject: [PATCH 240/430] Review local-registry control: fix inaccurate description, enumerate exceptions The control claimed all images came from the private registry, but 12+ services pull from external public registries. Updated description to reflect reality and catalogued external-image categories in notes. Co-Authored-By: Claude Opus 4.6 (1M context) --- compensating-controls.yaml | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/compensating-controls.yaml b/compensating-controls.yaml index 6b0af70..ae4865b 100644 --- a/compensating-controls.yaml +++ b/compensating-controls.yaml @@ -39,15 +39,23 @@ controls: - id: local-registry description: >- - All container images are pulled from private zot registry - (registry.ops.eblu.me). No shared external registry credentials - are cached on cluster nodes. + Operator-built services use a private zot registry + (registry.ops.eblu.me) for supply-chain control. Remaining + images are pulled from public registries without stored + credentials. No shared registry secrets are cached on cluster + nodes. created: 2026-03-30 - last-reviewed: 2026-03-30 + last-reviewed: 2026-04-12 notes: >- Verify by checking image prefixes in kustomization.yaml files. - Upstream images (immich, ollama) are exceptions — track in - service-versions.yaml. + Known external-image categories: (1) upstream apps not yet + mirrored — immich, ollama, frigate, frigate-notify, valkey; + (2) infrastructure components — tailscale operator/proxy, + external-secrets, 1password-connect, forgejo-runner, docker + DinD, nvidia-device-plugin; (3) utility base images — busybox, + alpine (grafana init containers). Track upstream versions in + service-versions.yaml. Goal is to progressively mirror these + into zot. - id: sso-gated-admin-tools description: >- From 61fcd5d70aead2e7146091f6aea81d5eaea373e7 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 13 Apr 2026 07:57:13 -0700 Subject: [PATCH 241/430] =?UTF-8?q?Upgrade=20grafana-sidecar=201.28.0=20?= =?UTF-8?q?=E2=86=92=202.6.0=20+=20container.py=20port=20(#332)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Upgrade grafana-sidecar from 1.28.0 to 2.6.0 (the 2.x memory regression #462 is resolved; ~35MB static overhead is acceptable) - Port build from Dockerfile to native Dagger container.py - Add liveness/readiness probes using the new /healthz endpoint on port 8080 - Update docs to reflect container.py migration and remove stale pin note ## Test plan - [ ] Build container: `mise run container-build-and-release grafana-sidecar` - [ ] Update kustomization tag with new image tag - [ ] Deploy from branch: `argocd app set grafana --revision grafana-sidecar-2.6.0 && argocd app sync grafana` - [ ] Verify sidecar health endpoint: `kubectl exec -n monitoring -c grafana-sc-dashboard -- wget -qO- http://localhost:8080/healthz` - [ ] Verify dashboards load in Grafana UI - [ ] `mise run services-check` Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/332 --- argocd/manifests/grafana/deployment.yaml | 12 ++++ argocd/manifests/grafana/kustomization.yaml | 2 +- containers/grafana-sidecar/Dockerfile | 36 ----------- containers/grafana-sidecar/container.py | 63 +++++++++++++++++++ .../grafana-sidecar-2.6.0.feature.md | 1 + docs/how-to/grafana/build-grafana-images.md | 9 ++- service-versions.yaml | 4 +- 7 files changed, 83 insertions(+), 44 deletions(-) delete mode 100644 containers/grafana-sidecar/Dockerfile create mode 100644 containers/grafana-sidecar/container.py create mode 100644 docs/changelog.d/grafana-sidecar-2.6.0.feature.md diff --git a/argocd/manifests/grafana/deployment.yaml b/argocd/manifests/grafana/deployment.yaml index 57d3b10..848503e 100644 --- a/argocd/manifests/grafana/deployment.yaml +++ b/argocd/manifests/grafana/deployment.yaml @@ -200,6 +200,18 @@ spec: value: http://localhost:3000/api/admin/provisioning/dashboards/reload - name: REQ_METHOD value: POST + livenessProbe: + httpGet: + path: /healthz + port: 8080 + initialDelaySeconds: 10 + periodSeconds: 30 + readinessProbe: + httpGet: + path: /healthz + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 10 securityContext: allowPrivilegeEscalation: false capabilities: diff --git a/argocd/manifests/grafana/kustomization.yaml b/argocd/manifests/grafana/kustomization.yaml index 4fe53a9..115ca69 100644 --- a/argocd/manifests/grafana/kustomization.yaml +++ b/argocd/manifests/grafana/kustomization.yaml @@ -16,7 +16,7 @@ images: - name: docker.io/library/busybox newTag: 1.31.1 - name: registry.ops.eblu.me/blumeops/grafana-sidecar - newTag: v1.28.0-613f05d + newTag: v2.6.0-b75c4f9 - name: registry.ops.eblu.me/blumeops/grafana newTag: v12.4.2-4c54774 diff --git a/containers/grafana-sidecar/Dockerfile b/containers/grafana-sidecar/Dockerfile deleted file mode 100644 index 28dd983..0000000 --- a/containers/grafana-sidecar/Dockerfile +++ /dev/null @@ -1,36 +0,0 @@ -# Grafana dashboard sidecar - watches ConfigMaps and syncs into Grafana -# Two-stage build: Python venv (builder), runtime (Alpine) - -ARG CONTAINER_APP_VERSION=1.28.0 - -FROM python:3.12-alpine3.22 AS base - -FROM base AS builder -ARG CONTAINER_APP_VERSION -WORKDIR /app -RUN apk add --no-cache git gcc musl-dev -RUN git clone --depth 1 --branch ${CONTAINER_APP_VERSION} \ - https://forge.ops.eblu.me/mirrors/kiwigrid-grafana-sidecar.git /tmp/k8s-sidecar -RUN python -m venv .venv && \ - .venv/bin/pip install --no-cache-dir -U pip setuptools && \ - .venv/bin/pip install --no-cache-dir -r /tmp/k8s-sidecar/src/requirements.txt && \ - cp /tmp/k8s-sidecar/src/*.py /app/ && \ - find /app/.venv \( -type d -a -name test -o -name tests \) \ - -o \( -type f -a -name '*.pyc' -o -name '*.pyo' \) -exec rm -rf '{}' \+ - -FROM base - -ARG CONTAINER_APP_VERSION -LABEL org.opencontainers.image.title="Grafana Sidecar" -LABEL org.opencontainers.image.description="K8s sidecar to sync ConfigMap dashboards into Grafana" -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" - -ENV PYTHONUNBUFFERED=1 -WORKDIR /app -COPY --from=builder /app /app -ENV PATH="/app/.venv/bin:$PATH" - -USER 65534:65534 -CMD ["python", "-u", "/app/sidecar.py"] diff --git a/containers/grafana-sidecar/container.py b/containers/grafana-sidecar/container.py new file mode 100644 index 0000000..83950a7 --- /dev/null +++ b/containers/grafana-sidecar/container.py @@ -0,0 +1,63 @@ +"""Grafana dashboard sidecar — native Dagger build. + +Two-stage build: Python venv (builder), Python Alpine (runtime). +Source cloned from forge mirror. +""" + +import dagger +from dagger import dag + +from blumeops.containers import clone_from_forge, oci_labels + +VERSION = "2.6.0" + +PYTHON_BASE = "python:3.14-alpine3.23" + + +async def build(src: dagger.Directory) -> dagger.Container: + source = clone_from_forge("kiwigrid-grafana-sidecar", VERSION) + + # Stage 1: Build Python venv with dependencies + builder = ( + dag.container() + .from_(PYTHON_BASE) + .with_exec(["apk", "add", "--no-cache", "gcc", "musl-dev"]) + .with_workdir("/app") + .with_exec( + ["python", "-m", "venv", ".venv"], + ) + .with_exec( + [".venv/bin/pip", "install", "--no-cache-dir", "-U", "pip", "setuptools"], + ) + .with_file("/app/pyproject.toml", source.file("pyproject.toml")) + .with_directory("/app/src", source.directory("src")) + .with_exec([".venv/bin/pip", "install", "--no-cache-dir", "."]) + # Strip test dirs and bytecode from venv to shrink the image + .with_exec( + [ + "sh", + "-c", + "find /app/.venv" + " \\( -type d -a -name test -o -name tests \\)" + " -o \\( -type f -a -name '*.pyc' -o -name '*.pyo' \\)" + " -exec rm -rf {} +", + ] + ) + ) + + # Stage 2: Runtime + runtime = dag.container().from_(PYTHON_BASE) + runtime = oci_labels( + runtime, + title="Grafana Sidecar", + description="K8s sidecar to sync ConfigMap dashboards into Grafana", + version=VERSION, + ) + return ( + runtime.with_env_variable("PYTHONUNBUFFERED", "1") + .with_workdir("/app") + .with_directory("/app/.venv", builder.directory("/app/.venv")) + .with_env_variable("PATH", "/app/.venv/bin:$PATH") + .with_user("65534:65534") + .with_default_args(args=["python", "-u", "-m", "sidecar"]) + ) diff --git a/docs/changelog.d/grafana-sidecar-2.6.0.feature.md b/docs/changelog.d/grafana-sidecar-2.6.0.feature.md new file mode 100644 index 0000000..cb729ee --- /dev/null +++ b/docs/changelog.d/grafana-sidecar-2.6.0.feature.md @@ -0,0 +1 @@ +Upgrade grafana-sidecar from 1.28.0 to 2.6.0, adding health probes and porting build to native Dagger container.py. diff --git a/docs/how-to/grafana/build-grafana-images.md b/docs/how-to/grafana/build-grafana-images.md index 0a5f6fd..8a5ca3c 100644 --- a/docs/how-to/grafana/build-grafana-images.md +++ b/docs/how-to/grafana/build-grafana-images.md @@ -34,23 +34,22 @@ mise run container-build-and-release grafana ## Grafana Sidecar -**Dockerfile:** `containers/grafana-sidecar/Dockerfile` +**Build:** `containers/grafana-sidecar/container.py` (native Dagger) **Image:** `registry.ops.eblu.me/blumeops/grafana-sidecar` -Clones the [kiwigrid/k8s-sidecar](https://github.com/kiwigrid/k8s-sidecar) source from the forge mirror, installs Python dependencies into a venv, and copies the application into a minimal Alpine runtime image. +Clones the [kiwigrid/k8s-sidecar](https://github.com/kiwigrid/k8s-sidecar) source from the forge mirror, installs the Python package into a venv, and copies it into a Python Alpine runtime image. ```fish -# Update version in Dockerfile -# ARG CONTAINER_APP_VERSION=1.28.0 +# Update VERSION in container.py mise run container-build-and-release grafana-sidecar ``` **Gotchas:** -- **Pinned to v1.28.0:** v2.x has a 135% memory regression ([#462](https://github.com/kiwigrid/k8s-sidecar/issues/462)) and `readOnlyRootFilesystem` crashloop ([#3936](https://github.com/grafana/helm-charts/issues/3936)). Upgrade separately after upstream fixes land. - **UID 65534:** Matches upstream's `nobody` user convention for non-root execution. - **Forge mirror name:** `mirrors/kiwigrid-grafana-sidecar` (not `k8s-sidecar`). +- **Health endpoint:** 2.x exposes `/healthz` on port 8080 (liveness + readiness probes configured in deployment). ## Related diff --git a/service-versions.yaml b/service-versions.yaml index 50f167d..dc1df2e 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -106,8 +106,8 @@ services: - name: grafana-sidecar type: argocd parent: grafana - last-reviewed: "2026-03-03" - current-version: "1.28.0" + last-reviewed: "2026-04-13" + current-version: "2.6.0" upstream-source: https://github.com/kiwigrid/k8s-sidecar/releases notes: Dashboard ConfigMap watcher sidecar in grafana deployment From db6d8af8b10334f7ee9cae0195a95498ec2c8281 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 13 Apr 2026 08:02:39 -0700 Subject: [PATCH 242/430] Update grafana-sidecar image tag to v2.6.0-61fcd5d (merge build) Co-Authored-By: Claude Opus 4.6 (1M context) --- argocd/manifests/grafana/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/argocd/manifests/grafana/kustomization.yaml b/argocd/manifests/grafana/kustomization.yaml index 115ca69..a511fe1 100644 --- a/argocd/manifests/grafana/kustomization.yaml +++ b/argocd/manifests/grafana/kustomization.yaml @@ -16,7 +16,7 @@ images: - name: docker.io/library/busybox newTag: 1.31.1 - name: registry.ops.eblu.me/blumeops/grafana-sidecar - newTag: v2.6.0-b75c4f9 + newTag: v2.6.0-61fcd5d - name: registry.ops.eblu.me/blumeops/grafana newTag: v12.4.2-4c54774 From ab834b641a129e03b3533307b3834b2144596cbe Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 13 Apr 2026 08:11:15 -0700 Subject: [PATCH 243/430] Fix OTEL metrics exporter warnings in Dagger builds The Dagger engine shim sets OTEL_METRICS_EXPORTER before our module loads, so os.environ.setdefault was a no-op. Switch to a hard override. Remove the redundant workflow-level env var since the fix belongs in the module. Co-Authored-By: Claude Opus 4.6 (1M context) --- .forgejo/workflows/build-container.yaml | 4 ---- docs/changelog.d/+dagger-otel-metrics-fix.bugfix.md | 1 + src/blumeops/main.py | 9 ++++++--- 3 files changed, 7 insertions(+), 7 deletions(-) create mode 100644 docs/changelog.d/+dagger-otel-metrics-fix.bugfix.md diff --git a/.forgejo/workflows/build-container.yaml b/.forgejo/workflows/build-container.yaml index fe6b000..efa9007 100644 --- a/.forgejo/workflows/build-container.yaml +++ b/.forgejo/workflows/build-container.yaml @@ -73,10 +73,6 @@ jobs: needs: detect if: needs.detect.outputs.dagger != '[]' runs-on: k8s - env: - # Disable Python SDK OTLP metrics exporter — the Dagger engine's local - # OTLP endpoint returns 500s, causing ~9s retry cycles per minute. - OTEL_METRICS_EXPORTER: none strategy: matrix: container: ${{ fromJson(needs.detect.outputs.dagger) }} diff --git a/docs/changelog.d/+dagger-otel-metrics-fix.bugfix.md b/docs/changelog.d/+dagger-otel-metrics-fix.bugfix.md new file mode 100644 index 0000000..6c02765 --- /dev/null +++ b/docs/changelog.d/+dagger-otel-metrics-fix.bugfix.md @@ -0,0 +1 @@ +Fix OTEL metrics exporter warnings in Dagger builds by hard-overriding the env var before SDK init. diff --git a/src/blumeops/main.py b/src/blumeops/main.py index 56595e2..9f60fd4 100644 --- a/src/blumeops/main.py +++ b/src/blumeops/main.py @@ -1,9 +1,12 @@ import os from pathlib import Path -# Disable OTLP metrics exporter before the Dagger SDK initializes OpenTelemetry. -# The Dagger engine's local OTLP endpoint returns 500s, causing ~11s retry delays. -os.environ.setdefault("OTEL_METRICS_EXPORTER", "none") +# Force-disable OTLP metrics exporter before the Dagger SDK initializes +# OpenTelemetry. The engine shim may set OTEL_METRICS_EXPORTER=otlp before +# our module loads, so setdefault won't work — we need a hard override. +# Without this, the engine's local OTLP endpoint returns 500s on metrics, +# causing ~9s retry cycles per pipeline step. +os.environ["OTEL_METRICS_EXPORTER"] = "none" import dagger from dagger import dag, function, object_type From b5551e227e6c25599d53516478a6192de1d97c2c Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 13 Apr 2026 08:27:12 -0700 Subject: [PATCH 244/430] Route Dagger build telemetry to Tempo The Dagger engine's internal OTLP proxy returns 500 on /v1/metrics when there's no real backend, causing ~9s retry warnings per pipeline step. Point OTEL_EXPORTER_OTLP_ENDPOINT at Tempo to give it a real endpoint. Also removes the stale os.environ workaround from main.py (the SDK initializes telemetry before our module loads, so it had no effect). Co-Authored-By: Claude Opus 4.6 (1M context) --- .forgejo/workflows/build-container.yaml | 5 +++++ docs/changelog.d/+dagger-otel-metrics-fix.bugfix.md | 2 +- src/blumeops/main.py | 8 -------- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/.forgejo/workflows/build-container.yaml b/.forgejo/workflows/build-container.yaml index efa9007..78ee586 100644 --- a/.forgejo/workflows/build-container.yaml +++ b/.forgejo/workflows/build-container.yaml @@ -73,6 +73,11 @@ jobs: needs: detect if: needs.detect.outputs.dagger != '[]' runs-on: k8s + env: + # Send Dagger OTLP telemetry to Tempo. Without a real backend the + # engine's internal proxy returns 500 on /v1/metrics, causing noisy + # retry warnings in every build. + OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo.tracing.svc.cluster.local:4318 strategy: matrix: container: ${{ fromJson(needs.detect.outputs.dagger) }} diff --git a/docs/changelog.d/+dagger-otel-metrics-fix.bugfix.md b/docs/changelog.d/+dagger-otel-metrics-fix.bugfix.md index 6c02765..85475c2 100644 --- a/docs/changelog.d/+dagger-otel-metrics-fix.bugfix.md +++ b/docs/changelog.d/+dagger-otel-metrics-fix.bugfix.md @@ -1 +1 @@ -Fix OTEL metrics exporter warnings in Dagger builds by hard-overriding the env var before SDK init. +Route Dagger build telemetry to Tempo, fixing OTEL metrics exporter warnings. diff --git a/src/blumeops/main.py b/src/blumeops/main.py index 9f60fd4..94b932b 100644 --- a/src/blumeops/main.py +++ b/src/blumeops/main.py @@ -1,13 +1,5 @@ -import os from pathlib import Path -# Force-disable OTLP metrics exporter before the Dagger SDK initializes -# OpenTelemetry. The engine shim may set OTEL_METRICS_EXPORTER=otlp before -# our module loads, so setdefault won't work — we need a hard override. -# Without this, the engine's local OTLP endpoint returns 500s on metrics, -# causing ~9s retry cycles per pipeline step. -os.environ["OTEL_METRICS_EXPORTER"] = "none" - import dagger from dagger import dag, function, object_type From f61bb4f2e79c22e4664b429b1aecc2a6ecd7f99f Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 13 Apr 2026 08:35:01 -0700 Subject: [PATCH 245/430] Add uv.lock for version pinning of dagger pipeline --- uv.lock | 806 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 806 insertions(+) create mode 100644 uv.lock diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..b86a906 --- /dev/null +++ b/uv.lock @@ -0,0 +1,806 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://pypi.ops.eblu.me/root/pypi/+f/334/b70e641fd2221/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc" } +wheels = [ + { url = "https://pypi.ops.eblu.me/root/pypi/+f/08b/310f9e24a9594/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } +sdist = { url = "https://pypi.ops.eblu.me/root/pypi/+f/d03/ceb89cb322a8f/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32" } +wheels = [ + { url = "https://pypi.ops.eblu.me/root/pypi/+f/c64/7aa4a12dfbad9/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309" }, +] + +[[package]] +name = "backoff" +version = "2.2.1" +source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } +sdist = { url = "https://pypi.ops.eblu.me/root/pypi/+f/03f/829f5bb192318/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba" } +wheels = [ + { url = "https://pypi.ops.eblu.me/root/pypi/+f/635/79f9a0628e062/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8" }, +] + +[[package]] +name = "beartype" +version = "0.22.9" +source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } +sdist = { url = "https://pypi.ops.eblu.me/root/pypi/+f/8f8/2b54aa723a284/beartype-0.22.9.tar.gz", hash = "sha256:8f82b54aa723a2848a56008d18875f91c1db02c32ef6a62319a002e3e25a975f" } +wheels = [ + { url = "https://pypi.ops.eblu.me/root/pypi/+f/d16/c9bbc61ea1463/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2" }, +] + +[[package]] +name = "blumeops" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "dagger-io" }, +] + +[package.metadata] +requires-dist = [{ name = "dagger-io", editable = "sdk" }] + +[[package]] +name = "cattrs" +version = "26.1.0" +source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } +dependencies = [ + { name = "attrs" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://pypi.ops.eblu.me/root/pypi/+f/fa2/39e0f0ec0715b/cattrs-26.1.0.tar.gz", hash = "sha256:fa239e0f0ec0715ba34852ce813986dfed1e12117e209b816ab87401271cdd40" } +wheels = [ + { url = "https://pypi.ops.eblu.me/root/pypi/+f/d1e/0804c42639494/cattrs-26.1.0-py3-none-any.whl", hash = "sha256:d1e0804c42639494d469d08d4f26d6b9de9b8ab26b446db7b5f8c2e97f7c3096" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } +sdist = { url = "https://pypi.ops.eblu.me/root/pypi/+f/e88/7ab5cee78ea81/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7" } +wheels = [ + { url = "https://pypi.ops.eblu.me/root/pypi/+f/027/692e4402ad994/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } +sdist = { url = "https://pypi.ops.eblu.me/root/pypi/+f/ae8/9db9e5f98a11a/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5" } +wheels = [ + { url = "https://pypi.ops.eblu.me/root/pypi/+f/f49/6c9c3cc022300/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/0ea/948db76d31190/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/a27/7ab8928b9f299/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/3be/c022aec2c514d/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/e04/4c39e41b92c84/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/f49/5a1652cf3fbab/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/e71/2b419df8ba5e4/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/780/4338df6fcc081/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/481/551899c856c70/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/f59/099f9b66f0d71/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/f59/ad4c0e8f6bba2/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/3de/dcc22d73ec993/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/64f/02c6841d7d83f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/404/2d5c8f957e152/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/394/6fa46a0cf3e4c/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/80d/04837f55fc81d/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/c36/c333c39be2dbc/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/1c2/aed2e5e41f24e/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/545/23e136b894806/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/715/479b9a2802eca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/bd6/c2a1c7573c647/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/c45/e9440fb78f8dd/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/353/4e7dcbdcf757d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/e8a/c484bf18ce697/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/a5f/e03b42827c13c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/2d6/eb928e13016ce/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/e74/327fb75de8986/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/d60/38d37043bced9/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/757/9e913a5339fb8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/5b7/7459df20e0815/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/92a/0a01ead5e6684/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/67f/6279d125ca004/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/eff/c3f4497871172/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/fbc/cdc05410c9ee2/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/733/784b6d6def852/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/a89/c23ef8d2c6b27/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/6c1/14670c45346af/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/a18/0c5e59792af26/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/3c9/a494bc5ec77d4/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/8d8/28b6667a32a72/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/cf1/493cd8607bec4/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/0c9/6c3b819b5c3e9/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/752/a45dc4a693406/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/877/8f0c7a52e56f7/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/ce3/412fbe1e31eb8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/c03/a41a8784091e6/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/038/53ed82eeebbce/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/c35/abb8bfff0185e/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/3dc/e51d0f5e7951f/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d" }, +] + +[[package]] +name = "dagger-io" +version = "0.0.0" +source = { editable = "sdk" } +dependencies = [ + { name = "anyio" }, + { name = "beartype" }, + { name = "cattrs" }, + { name = "exceptiongroup" }, + { name = "gql", extra = ["httpx"] }, + { name = "httpcore" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-instrumentation-logging" }, + { name = "opentelemetry-sdk" }, + { name = "platformdirs" }, + { name = "rich" }, + { name = "typing-extensions" }, +] + +[package.metadata] +requires-dist = [ + { name = "anyio", specifier = ">=3.6.2" }, + { name = "beartype", specifier = ">=0.22.0" }, + { name = "cattrs", specifier = ">=25.1.0" }, + { name = "exceptiongroup", specifier = ">=1.3.0" }, + { name = "gql", extras = ["httpx"], specifier = ">=4.0" }, + { name = "httpcore", specifier = ">=1.0.8" }, + { name = "opentelemetry-exporter-otlp-proto-http", specifier = ">=1.23.0" }, + { name = "opentelemetry-instrumentation-logging", specifier = ">=0.54b1" }, + { name = "opentelemetry-sdk", specifier = ">=1.23.0" }, + { name = "platformdirs", specifier = ">=2.6.2" }, + { name = "rich", specifier = ">=10.11.0" }, + { name = "typing-extensions", specifier = ">=4.13.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "aiohttp", specifier = ">=3.9.3" }, + { name = "codegen", editable = "sdk/codegen" }, + { name = "mypy", specifier = ">=1.8.0" }, + { name = "pytest", specifier = ">=8.0.2" }, + { name = "pytest-httpx", specifier = ">=0.30.0" }, + { name = "pytest-mock", specifier = ">=3.12.0" }, + { name = "pytest-subprocess", specifier = ">=1.5.0" }, + { name = "ruff", specifier = ">=0.3.4" }, + { name = "sphinx", specifier = ">=7.2.6" }, + { name = "sphinx-rtd-theme", specifier = ">=2.0.0" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } +sdist = { url = "https://pypi.ops.eblu.me/root/pypi/+f/8b4/12432c6055b0b/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219" } +wheels = [ + { url = "https://pypi.ops.eblu.me/root/pypi/+f/a7a/39a3bd276781e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598" }, +] + +[[package]] +name = "googleapis-common-protos" +version = "1.74.0" +source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://pypi.ops.eblu.me/root/pypi/+f/579/71e4eeeba6aad/googleapis_common_protos-1.74.0.tar.gz", hash = "sha256:57971e4eeeba6aad1163c1f0fc88543f965bb49129b8bb55b2b7b26ecab084f1" } +wheels = [ + { url = "https://pypi.ops.eblu.me/root/pypi/+f/702/216f78610bb51/googleapis_common_protos-1.74.0-py3-none-any.whl", hash = "sha256:702216f78610bb510e3f12ac3cafd281b7ac45cc5d86e90ad87e4d301a3426b5" }, +] + +[[package]] +name = "gql" +version = "4.0.0" +source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } +dependencies = [ + { name = "anyio" }, + { name = "backoff" }, + { name = "graphql-core" }, + { name = "yarl" }, +] +sdist = { url = "https://pypi.ops.eblu.me/root/pypi/+f/f22/980844eb6a7c0/gql-4.0.0.tar.gz", hash = "sha256:f22980844eb6a7c0266ffc70f111b9c7e7c7c13da38c3b439afc7eab3d7c9c8e" } +wheels = [ + { url = "https://pypi.ops.eblu.me/root/pypi/+f/f3b/eed7c531218eb/gql-4.0.0-py3-none-any.whl", hash = "sha256:f3beed7c531218eb24d97cb7df031b4a84fdb462f4a2beb86e2633d395937479" }, +] + +[package.optional-dependencies] +httpx = [ + { name = "httpx" }, +] + +[[package]] +name = "graphql-core" +version = "3.2.8" +source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } +sdist = { url = "https://pypi.ops.eblu.me/root/pypi/+f/015/457da5d996c92/graphql_core-3.2.8.tar.gz", hash = "sha256:015457da5d996c924ddf57a43f4e959b0b94fb695b85ed4c29446e508ed65cf3" } +wheels = [ + { url = "https://pypi.ops.eblu.me/root/pypi/+f/cbe/e07bee1b3ed5e/graphql_core-3.2.8-py3-none-any.whl", hash = "sha256:cbee07bee1b3ed5e531723685369039f32ff815ef60166686e0162f540f1520c" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } +sdist = { url = "https://pypi.ops.eblu.me/root/pypi/+f/4e3/5b956cf45792e/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1" } +wheels = [ + { url = "https://pypi.ops.eblu.me/root/pypi/+f/63c/f8bbe7522de3b/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://pypi.ops.eblu.me/root/pypi/+f/6e3/4463af53fd2ab/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8" } +wheels = [ + { url = "https://pypi.ops.eblu.me/root/pypi/+f/2d4/00746a40668fc/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://pypi.ops.eblu.me/root/pypi/+f/75e/98c5f16b0f35b/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc" } +wheels = [ + { url = "https://pypi.ops.eblu.me/root/pypi/+f/d90/9fcccc110f8c7/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } +sdist = { url = "https://pypi.ops.eblu.me/root/pypi/+f/795/dafcc9c04ed0c/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902" } +wheels = [ + { url = "https://pypi.ops.eblu.me/root/pypi/+f/771/a87f49d9defaf/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea" }, +] + +[[package]] +name = "importlib-metadata" +version = "8.7.1" +source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://pypi.ops.eblu.me/root/pypi/+f/49f/ef1ae6440c182/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb" } +wheels = [ + { url = "https://pypi.ops.eblu.me/root/pypi/+f/5a1/f80bf1daa4894/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://pypi.ops.eblu.me/root/pypi/+f/cb0/a2b4aa34f932c/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3" } +wheels = [ + { url = "https://pypi.ops.eblu.me/root/pypi/+f/873/27c59b172c501/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } +sdist = { url = "https://pypi.ops.eblu.me/root/pypi/+f/bb4/13d29f5eea38f/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" } +wheels = [ + { url = "https://pypi.ops.eblu.me/root/pypi/+f/840/08a41e51615a4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8" }, +] + +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } +sdist = { url = "https://pypi.ops.eblu.me/root/pypi/+f/ec6/652a1bee61c53/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d" } +wheels = [ + { url = "https://pypi.ops.eblu.me/root/pypi/+f/2b4/1f5fed0ed5636/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/84e/61e3af5463c19/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/935/434b9853c7c11/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/432/feb25a1cb67fe/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/e82/d14e3c948952a/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/4cf/b48c6ea66c83b/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/1d5/40e51b7e8e170/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/273/d23f4b40f3dce/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/9d6/24335fd4fa1c0/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/12f/ad252f8b267cc/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/03e/de2a6ffbe8ef9/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/90e/fbcf47dbe33dc/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/5c4/b9bfc148f5a91/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/401/c5a650f3add24/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/978/91f3b1b3ffbde/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/e1c/5988359516095/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/960/c83bf01a95b12/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/563/fe25c678aaba3/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/c76/c4bec1538375d/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/57b/46b24b5d5ebcc/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/e95/4b24433c768ce/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/3bd/231490fa7217c/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/253/282d70d67885a/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/0b4/c48648d7649c9/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/98b/c624954ec4d2c/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/1b9/9af4d9eec0b49/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/6aa/c4f16b472d5b7/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/21f/830fe223215df/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/f5d/d81c45b05518b/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/eb3/04767bca2bb92/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/c90/35dde0f916702/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/af9/59b9beeb66c82/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/41f/2952231456154/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/df9/f19c28adcb40b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/d54/ecf9f301853f2/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/5a3/7ca18e360377c/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/8f3/33ec9c5eb1b71/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/a40/7f13c188f804c/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/0e1/61ddf326db557/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/1e3/a8bb24342a820/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/972/31140a50f5d44/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/6b1/0359683bd8806/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/283/ddac99f7ac25a/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/538/cec1e18c067d0/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/7ee/e46ccb30ff48a/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/fa2/63a02f4f2dd2d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/2e1/425e2f99ec5bd/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/497/394b3239fc6f0/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/233/b398c29d3f1b9/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/93b/1818e4a6e0930/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/f33/dc2a3abe9249e/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/3ab/8b9d8b75aef9d/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/5e0/1429a929600e7/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/488/5cb0e817aef5d/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/045/8c978acd8e6ea/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/c0a/bd12629b0af3c/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/145/25a5f61d7d0c9/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/173/07b22c217b4cf/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/7a7/e590ff876a3ea/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/5fa/6a95dfee63893/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/a05/43217a6a01769/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/f99/fe611c312b3c1/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/900/4d8386d133b7e/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/e62/8ef0e6859ffd8/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/841/189848ba629c3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/ce1/bbd7d780bb5a0/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/b26/684587228afed/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/9f9/af11306994335/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/b49/38326284c4f12/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/986/55c737850c064/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/497/bde6223c212ba/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/2bb/d113e0d4af5db/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/55d/97cc6dae627ef/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.41.0" +source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } +dependencies = [ + { name = "importlib-metadata" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://pypi.ops.eblu.me/root/pypi/+f/942/1d911326ec12d/opentelemetry_api-1.41.0.tar.gz", hash = "sha256:9421d911326ec12dee8bc933f7839090cad7a3f13fcfb0f9e82f8174dc003c09" } +wheels = [ + { url = "https://pypi.ops.eblu.me/root/pypi/+f/0e7/7c806e6a89c9e/opentelemetry_api-1.41.0-py3-none-any.whl", hash = "sha256:0e77c806e6a89c9e4f8d372034622f3e1418a11bdbe1c80a50b3d3397ad0fa4f" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.41.0" +source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } +dependencies = [ + { name = "opentelemetry-proto" }, +] +sdist = { url = "https://pypi.ops.eblu.me/root/pypi/+f/966/bbce537e9edb1/opentelemetry_exporter_otlp_proto_common-1.41.0.tar.gz", hash = "sha256:966bbce537e9edb166154779a7c4f8ab6b8654a03a28024aeaf1a3eacb07d6ee" } +wheels = [ + { url = "https://pypi.ops.eblu.me/root/pypi/+f/7a9/9177bf61f85f4/opentelemetry_exporter_otlp_proto_common-1.41.0-py3-none-any.whl", hash = "sha256:7a99177bf61f85f4f9ed2072f54d676364719c066f6d11f515acc6c745c7acf0" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-http" +version = "1.41.0" +source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://pypi.ops.eblu.me/root/pypi/+f/dcd/6e0686f56277d/opentelemetry_exporter_otlp_proto_http-1.41.0.tar.gz", hash = "sha256:dcd6e0686f56277db4eecbadd5262124e8f2cc739cadbc3fae3d08a12c976cf5" } +wheels = [ + { url = "https://pypi.ops.eblu.me/root/pypi/+f/a9c/4ee69cce9c3f4/opentelemetry_exporter_otlp_proto_http-1.41.0-py3-none-any.whl", hash = "sha256:a9c4ee69cce9c3f4d7ee736ad1b44e3c9654002c0816900abbafd9f3cf289751" }, +] + +[[package]] +name = "opentelemetry-instrumentation" +version = "0.62b0" +source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "packaging" }, + { name = "wrapt" }, +] +sdist = { url = "https://pypi.ops.eblu.me/root/pypi/+f/aa1/b0b9ab2e1722c/opentelemetry_instrumentation-0.62b0.tar.gz", hash = "sha256:aa1b0b9ab2e1722c2a8a5384fb016fc28d30bba51826676c8036074790d2861e" } +wheels = [ + { url = "https://pypi.ops.eblu.me/root/pypi/+f/30d/4e76486eae64f/opentelemetry_instrumentation-0.62b0-py3-none-any.whl", hash = "sha256:30d4e76486eae64fb095264a70c2c809c4bed17b73373e53091470661f7d477c" }, +] + +[[package]] +name = "opentelemetry-instrumentation-logging" +version = "0.62b0" +source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, +] +sdist = { url = "https://pypi.ops.eblu.me/root/pypi/+f/61f/23be960e04705/opentelemetry_instrumentation_logging-0.62b0.tar.gz", hash = "sha256:61f23be960e047054b3aa38998e7d1eb1fd9bef6f52097e28bc113af8b6f3bd8" } +wheels = [ + { url = "https://pypi.ops.eblu.me/root/pypi/+f/9a2/7b6c3d419170d/opentelemetry_instrumentation_logging-0.62b0-py3-none-any.whl", hash = "sha256:9a27b6c3d419170d96dedcea7d38cc0418f0dd1054365f52499a0a1eb70b8faf" }, +] + +[[package]] +name = "opentelemetry-proto" +version = "1.41.0" +source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://pypi.ops.eblu.me/root/pypi/+f/95d/2e576f9fb1800/opentelemetry_proto-1.41.0.tar.gz", hash = "sha256:95d2e576f9fb1800473a3e4cfcca054295d06bdb869fda4dc9f4f779dc68f7b6" } +wheels = [ + { url = "https://pypi.ops.eblu.me/root/pypi/+f/b97/0ab537309f9ee/opentelemetry_proto-1.41.0-py3-none-any.whl", hash = "sha256:b970ab537309f9eed296be482c3e7cca05d8aca8165346e929f658dbe153b247" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.41.0" +source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://pypi.ops.eblu.me/root/pypi/+f/7bd/df3961131b318/opentelemetry_sdk-1.41.0.tar.gz", hash = "sha256:7bddf3961131b318fc2d158947971a8e37e38b1cd23470cfb72b624e7cc108bd" } +wheels = [ + { url = "https://pypi.ops.eblu.me/root/pypi/+f/a59/6f5687964a3e0/opentelemetry_sdk-1.41.0-py3-none-any.whl", hash = "sha256:a596f5687964a3e0d7f8edfdcf5b79cbca9c93c7025ebf5fb00f398a9443b0bd" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.62b0" +source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://pypi.ops.eblu.me/root/pypi/+f/cbf/b3c8fc259575c/opentelemetry_semantic_conventions-0.62b0.tar.gz", hash = "sha256:cbfb3c8fc259575cf68a6e1b94083cc35adc4a6b06e8cf431efa0d62606c0097" } +wheels = [ + { url = "https://pypi.ops.eblu.me/root/pypi/+f/0dd/ac1ce59eaf1a8/opentelemetry_semantic_conventions-0.62b0-py3-none-any.whl", hash = "sha256:0ddac1ce59eaf1a827d9987ab60d9315fb27aea23304144242d1fcad9e16b489" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } +sdist = { url = "https://pypi.ops.eblu.me/root/pypi/+f/002/43ae351a25711/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4" } +wheels = [ + { url = "https://pypi.ops.eblu.me/root/pypi/+f/b36/f1fef9334a558/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529" }, +] + +[[package]] +name = "platformdirs" +version = "4.9.6" +source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } +sdist = { url = "https://pypi.ops.eblu.me/root/pypi/+f/3bf/a75b0ad0db840/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a" } +wheels = [ + { url = "https://pypi.ops.eblu.me/root/pypi/+f/e61/adb1d5e5cb344/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917" }, +] + +[[package]] +name = "propcache" +version = "0.4.1" +source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } +sdist = { url = "https://pypi.ops.eblu.me/root/pypi/+f/f48/107a8c637e803/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d" } +wheels = [ + { url = "https://pypi.ops.eblu.me/root/pypi/+f/43e/edf29202c0855/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/d62/cdfcfd89ccb8d/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/cae/65ad55793da34/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/333/ddb9031d2704a/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/fd0/858c20f078a32/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/678/ae89ebc632c5c/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/d47/2aeb4fbf9865e/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/4d3/df5fa7e36b322/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/ee1/7f18d2498f267/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/580/e97762b950f99/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/501/d20b891688eb8/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/9a0/bd56e5b100aef/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/bcc/9aaa5d80322bc/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/381/914df18634f54/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/887/3eb4460fd5533/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/92d/1935ee1f8d744/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/473/c61b39e1460d3/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/c0e/f0aaafc66fbd8/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/f95/393b4d66bfae9/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/c07/fda85708bc485/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/af2/23b406d6d0008/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/a78/372c932c90ee4/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/564/d9f0d4d9509e1/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/176/12831fda01380/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/41a/89040cb10bd34/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/e35/b88984e7fa64a/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/6f8/b465489f927b0/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/2ad/890caa1d928c7/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/f7e/e0e597f495cf4/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/929/d7cbe1f01bb7b/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/3f7/124c9d820ba55/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/c0d/4b719b7da3359/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/9f3/02f4783709a78/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/c80/ee5802e3fb9ea/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/ed5/a841e8bb29a55/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/55c/72fd6ea2da4c3/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/832/6e14434146040/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/060/b16ae65bc098d/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/89e/b3fa9524f7bec/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/dee/69d7015dc235f/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/555/8992a00dfd54c/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/c9b/822a577f560fb/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/ab4/c29b49d560fe4/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/5a1/03c3eb905fcea/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/74c/1fb26515153e4/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/824/e908bce90fb27/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/c2b/5e7db5328427c/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/6f6/ff873ed40292c/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/49a/2dc67c154db2c/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/005/f08e6a0529984/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/5c3/310452e0d3139/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/4c3/c70630930447f/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/8e5/7061305815dfc/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/521/a463429ef5414/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/120/c964da3fdc75e/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/d8f/353eb14ee3441/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/ab2/943be7c652f09/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/056/74a162469f313/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/990/f6b3e2a27d683/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/ece/f2343af4cc68e/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/af2/a6052aeb6cf17/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237" }, +] + +[[package]] +name = "protobuf" +version = "6.33.6" +source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } +sdist = { url = "https://pypi.ops.eblu.me/root/pypi/+f/a67/68d25248312c2/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135" } +wheels = [ + { url = "https://pypi.ops.eblu.me/root/pypi/+f/7d2/9d9b65f8afef1/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/0cd/27b587afca21b/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/972/0e6961b251bde/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/e2a/fbae9b8e1825e/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/c96/c37eec15086b7/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/e9d/b7e292e0ab79d/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/771/79e006c476e69/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } +sdist = { url = "https://pypi.ops.eblu.me/root/pypi/+f/675/7cd03768053ff/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f" } +wheels = [ + { url = "https://pypi.ops.eblu.me/root/pypi/+f/81a/9e26dd42fd28a/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176" }, +] + +[[package]] +name = "requests" +version = "2.33.1" +source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://pypi.ops.eblu.me/root/pypi/+f/188/17f8c57c62639/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517" } +wheels = [ + { url = "https://pypi.ops.eblu.me/root/pypi/+f/4e6/d1ef462f3626a/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://pypi.ops.eblu.me/root/pypi/+f/edd/07a4824c6b401/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36" } +wheels = [ + { url = "https://pypi.ops.eblu.me/root/pypi/+f/33b/d4ef74232fb73/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } +sdist = { url = "https://pypi.ops.eblu.me/root/pypi/+f/0ce/a48d173cc12fa/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466" } +wheels = [ + { url = "https://pypi.ops.eblu.me/root/pypi/+f/f0f/a19c6845758ab/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } +sdist = { url = "https://pypi.ops.eblu.me/root/pypi/+f/1b6/2b6884944a57d/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed" } +wheels = [ + { url = "https://pypi.ops.eblu.me/root/pypi/+f/bf2/72323e553dfb2/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4" }, +] + +[[package]] +name = "wrapt" +version = "2.1.2" +source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } +sdist = { url = "https://pypi.ops.eblu.me/root/pypi/+f/399/6a67eecc2c68f/wrapt-2.1.2.tar.gz", hash = "sha256:3996a67eecc2c68fd47b4e3c564405a5777367adfd9b8abb58387b63ee83b21e" } +wheels = [ + { url = "https://pypi.ops.eblu.me/root/pypi/+f/787/fd6f4d67befa6/wrapt-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787fd6f4d67befa6fe2abdffcbd3de2d82dfc6fb8a6d850407c53332709d030b" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/4bd/f26e03e6d0da3/wrapt-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4bdf26e03e6d0da3f0e9422fd36bcebf7bc0eeb55fdf9c727a09abc6b9fe472e" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/bba/c24d879aa2299/wrapt-2.1.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bbac24d879aa22998e87f6b3f481a5216311e7d53c7db87f189a7a0266dafffb" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/169/97dfb9d67addc/wrapt-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16997dfb9d67addc2e3f41b62a104341e80cac52f91110dece393923c0ebd5ca" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/162/e4e2ba7542da9/wrapt-2.1.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:162e4e2ba7542da9027821cb6e7c5e068d64f9a10b5f15512ea28e954893a267" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/f29/c827a8d9936ac/wrapt-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f29c827a8d9936ac320746747a016c4bc66ef639f5cd0d32df24f5eacbf9c69f" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/a9d/d9813825f7ecb/wrapt-2.1.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a9dd9813825f7ecb018c17fd147a01845eb330254dff86d3b5816f20f4d6aaf8" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/6f8/dbdd3719e5348/wrapt-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f8dbdd3719e534860d6a78526aafc220e0241f981367018c2875178cf83a413" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/5c3/5b5d82b16a3bc/wrapt-2.1.2-cp313-cp313-win32.whl", hash = "sha256:5c35b5d82b16a3bc6e0a04349b606a0582bc29f573786aebe98e0c159bc48db6" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/f8b/c1c264d8d1cf5/wrapt-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:f8bc1c264d8d1cf5b3560a87bbdd31131573eb25f9f9447bb6252b8d4c44a3a1" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/3be/b22f674550d56/wrapt-2.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:3beb22f674550d5634642c645aba4c72a2c66fb185ae1aebe1e955fae5a13baf" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/0fc/04bc8664a8bc4/wrapt-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0fc04bc8664a8bc4c8e00b37b5355cffca2535209fba1abb09ae2b7c76ddf82b" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/a9b/9d50c9af99887/wrapt-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a9b9d50c9af998875a1482a038eb05755dfd6fe303a313f6a940bb53a83c3f18" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/2d3/ff4f0024dd224/wrapt-2.1.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2d3ff4f0024dd224290c0eabf0240f1bfc1f26363431505fb1b0283d3b08f11d" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/327/8c471f4468ad5/wrapt-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3278c471f4468ad544a691b31bb856374fbdefb7fee1a152153e64019379f015" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/a89/14c754d3134a3/wrapt-2.1.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8914c754d3134a3032601c6984db1c576e6abaf3fc68094bb8ab1379d75ff92" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/ff9/5d4264e55839b/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ff95d4264e55839be37bafe1536db2ab2de19da6b65f9244f01f332b5286cfbf" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/764/05518ca4e1b76/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:76405518ca4e1b76fbb1b9f686cff93aebae03920cc55ceeec48ff9f719c5f67" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/c0b/e8b5a74c5824e/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c0be8b5a74c5824e9359b53e7e58bef71a729bacc82e16587db1c4ebc91f7c5a" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/f01/277d9a5fc1862/wrapt-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:f01277d9a5fc1862f26f7626da9cf443bebc0abd2f303f41c5e995b15887dabd" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/84c/e8f1c2104d2f6/wrapt-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:84ce8f1c2104d2f6daa912b1b5b039f331febfeee74f8042ad4e04992bd95c8f" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/a93/cd767e37faedd/wrapt-2.1.2-cp313-cp313t-win_arm64.whl", hash = "sha256:a93cd767e37faeddbe07d8fc4212d5cba660af59bdb0f6372c93faaa13e6e679" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/137/0e516598854e5/wrapt-2.1.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1370e516598854e5b4366e09ce81e08bfe94d42b0fd569b88ec46cc56d9164a9" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/6de/1a3851c27e0bd/wrapt-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6de1a3851c27e0bd6a04ca993ea6f80fc53e6c742ee1601f486c08e9f9b900a9" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/de9/f1a2bbc5ac7f6/wrapt-2.1.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:de9f1a2bbc5ac7f6012ec24525bdd444765a2ff64b5985ac6e0692144838542e" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/970/d57ed83fa040d/wrapt-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:970d57ed83fa040d8b20c52fe74a6ae7e3775ae8cff5efd6a81e06b19078484c" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/396/9c56e4563c375/wrapt-2.1.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3969c56e4563c375861c8df14fa55146e81ac11c8db49ea6fb7f2ba58bc1ff9a" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/57d/7c0c980abdc5f/wrapt-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:57d7c0c980abdc5f1d98b11a2aa3bb159790add80258c717fa49a99921456d90" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/776/867878e83130c/wrapt-2.1.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:776867878e83130c7a04237010463372e877c1c994d449ca6aaafeab6aab2586" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/fab/036efe5464ec3/wrapt-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fab036efe5464ec3291411fabb80a7a39e2dd80bae9bcbeeca5087fdfa891e19" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/e6e/d62c82ddf58d0/wrapt-2.1.2-cp314-cp314-win32.whl", hash = "sha256:e6ed62c82ddf58d001096ae84ce7f833db97ae2263bff31c9b336ba8cfe3f508" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/467/e7c7631539033/wrapt-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:467e7c76315390331c67073073d00662015bb730c566820c9ca9b54e4d67fd04" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/da1/f00a557c66225/wrapt-2.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:da1f00a557c66225d53b095a97eace0fc5349e3bfda28fa34ffae238978ee575" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/625/03ffbc2d3a698/wrapt-2.1.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:62503ffbc2d3a69891cf29beeaccdb4d5e0a126e2b6a851688d4777e01428dbb" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/c7e/6cd120ef837d5/wrapt-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c7e6cd120ef837d5b6f860a6ea3745f8763805c418bb2f12eeb1fa6e25f22d22" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/376/9a77df8e756d6/wrapt-2.1.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3769a77df8e756d65fbc050333f423c01ae012b4f6731aaf70cf2bef61b34596" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/a76/d61a2e8519961/wrapt-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a76d61a2e851996150ba0f80582dd92a870643fa481f3b3846f229de88caf044" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/6f9/7edc9842cf215/wrapt-2.1.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6f97edc9842cf215312b75fe737ee7c8adda75a89979f8e11558dfff6343cc4b" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/400/6c351de6d5007/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4006c351de6d5007aa33a551f600404ba44228a89e833d2fadc5caa5de8edfbf" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/a93/72fc3639a878c/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a9372fc3639a878c8e7d87e1556fa209091b0a66e912c611e3f833e2c4202be2" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/314/4b027ff30cbd2/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3144b027ff30cbd2fca07c0a87e67011adb717eb5f5bd8496325c17e454257a3" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/3b8/d15e52e195813/wrapt-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:3b8d15e52e195813efe5db8cec156eebe339aaf84222f4f4f051a6c01f237ed7" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/08f/fa54146a7559f/wrapt-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:08ffa54146a7559f5b8df4b289b46d963a8e74ed16ba3687f99896101a3990c5" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/72a/aa9d0d8e4ed0e/wrapt-2.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:72aaa9d0d8e4ed0e2e98019cea47a21f823c9dd4b43c7b77bba6679ffcca6a00" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/b8f/d6fa2b2c4e762/wrapt-2.1.2-py3-none-any.whl", hash = "sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8" }, +] + +[[package]] +name = "yarl" +version = "1.23.0" +source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://pypi.ops.eblu.me/root/pypi/+f/53b/1ea6ca88ebd44/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5" } +wheels = [ + { url = "https://pypi.ops.eblu.me/root/pypi/+f/16c/6994ac35c3e74/yarl-1.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:16c6994ac35c3e74fb0ae93323bf8b9c2a9088d55946109489667c510a7d010e" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/4a4/2e651629dafb6/yarl-1.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4a42e651629dafb64fd5b0286a3580613702b5809ad3f24934ea87595804f2c5" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/7c6/b9461a2a8b47c/yarl-1.23.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c6b9461a2a8b47c65eef63bb1c76a4f1c119618ffa99ea79bc5bb1e46c5821b" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/256/9b67d616eab45/yarl-1.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2569b67d616eab450d262ca7cb9f9e19d2f718c70a8b88712859359d0ab17035" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/e9d/9a4d06d3481ea/yarl-1.23.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e9d9a4d06d3481eab79803beb4d9bd6f6a8e781ec078ac70d7ef2dcc29d1bea5" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/f51/4f6474e04179d/yarl-1.23.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f514f6474e04179d3d33175ed3f3e31434d3130d42ec153540d5b157deefd735" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/fda/207c815b253e3/yarl-1.23.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fda207c815b253e34f7e1909840fd14299567b1c0eb4908f8c2ce01a41265401" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/34b/6cf500e61c90f/yarl-1.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34b6cf500e61c90f305094911f9acc9c86da1a05a7a3f5be9f68817043f486e4" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/d75/04f2b476d2165/yarl-1.23.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d7504f2b476d21653e4d143f44a175f7f751cd41233525312696c76aa3dbb23f" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/578/110dd426f0d20/yarl-1.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:578110dd426f0d209d1509244e6d4a3f1a3e9077655d98c5f22583d63252a08a" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/609/d3614d78d74eb/yarl-1.23.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:609d3614d78d74ebe35f54953c5bbd2ac647a7ddb9c30a5d877580f5e86b22f2" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/496/6242ec68afc74/yarl-1.23.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4966242ec68afc74c122f8459abd597afd7d8a60dc93d695c1334c5fd25f762f" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/e0f/d068364a6759b/yarl-1.23.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e0fd068364a6759bc794459f0a735ab151d11304346332489c7972bacbe9e72b" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/390/04f0ad156da43/yarl-1.23.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:39004f0ad156da43e86aa71f44e033de68a44e5a31fc53507b36dd253970054a" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/e57/23c01a56c5028/yarl-1.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e5723c01a56c5028c807c701aa66722916d2747ad737a046853f6c46f4875543" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/1b6/b572edd95b4fa/yarl-1.23.0-cp313-cp313-win32.whl", hash = "sha256:1b6b572edd95b4fa8df75de10b04bc81acc87c1c7d16bcdd2035b09d30acc957" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/baa/f55442359053c/yarl-1.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:baaf55442359053c7d62f6f8413a62adba3205119bcb6f49594894d8be47e5e3" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/fb4/948814a2a98e3/yarl-1.23.0-cp313-cp313-win_arm64.whl", hash = "sha256:fb4948814a2a98e3912505f09c9e7493b1506226afb1f881825368d6fb776ee3" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/aec/fed0b41aa72b7/yarl-1.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:aecfed0b41aa72b7881712c65cf764e39ce2ec352324f5e0837c7048d9e6daaa" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/a41/bcf68efd19073/yarl-1.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a41bcf68efd19073376eb8cf948b8d9be0af26256403e512bb18f3966f1f9120" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/cde/9a2ecd91668bc/yarl-1.23.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cde9a2ecd91668bcb7f077c4966d8ceddb60af01b52e6e3e2680e4cf00ad1a59" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/502/3346c4ee7992f/yarl-1.23.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5023346c4ee7992febc0068e7593de5fa2bf611848c08404b35ebbb76b1b0512" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/d10/09abedb49ae95/yarl-1.23.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1009abedb49ae95b136a8904a3f71b342f849ffeced2d3747bf29caeda218c4" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/a8d/00f29b42f534c/yarl-1.23.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a8d00f29b42f534cc8aa3931cfe773b13b23e561e10d2b26f27a8d309b0e82a1" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/954/51e6ce06c3e10/yarl-1.23.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:95451e6ce06c3e104556d73b559f5da6c34a069b6b62946d3ad66afcd51642ea" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/531/ef597132086b6/yarl-1.23.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:531ef597132086b6cf96faa7c6c1dcd0361dd5f1694e5cc30375907b9b7d3ea9" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/88f/9fb0116fbfcef/yarl-1.23.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88f9fb0116fbfcefcab70f85cf4b74a2b6ce5d199c41345296f49d974ddb4123" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/e7b/0460976dc75cb/yarl-1.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e7b0460976dc75cb87ad9cc1f9899a4b97751e7d4e77ab840fc9b6d377b8fd24" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/115/136c4a426f9da/yarl-1.23.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:115136c4a426f9da976187d238e84139ff6b51a20839aa6e3720cd1026d768de" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/ead/11956716a940c/yarl-1.23.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:ead11956716a940c1abc816b7df3fa2b84d06eaed8832ca32f5c5e058c65506b" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/fe8/f8f5e70e6dbdf/yarl-1.23.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:fe8f8f5e70e6dbdfca9882cd9deaac058729bcf323cf7a58660901e55c9c94f6" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/a0e/317df055958a0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:a0e317df055958a0c1e79e5d2aa5a5eaa4a6d05a20d4b0c9c3f48918139c9fc6" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/6f0/fd84de0c957b2/yarl-1.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f0fd84de0c957b2d280143522c4f91a73aada1923caee763e24a2b3fda9f8a5" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/93a/784271881035a/yarl-1.23.0-cp313-cp313t-win32.whl", hash = "sha256:93a784271881035ab4406a172edb0faecb6e7d00f4b53dc2f55919d6c9688595" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/dd0/0607bffbf3025/yarl-1.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dd00607bffbf30250fe108065f07453ec124dbf223420f57f5e749b04295e090" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/ac0/9d42f48f80c9e/yarl-1.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ac09d42f48f80c9ee1635b2fcaa819496a44502737660d3c0f2ade7526d29144" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/21d/1b7305a71a15b/yarl-1.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:21d1b7305a71a15b4794b5ff22e8eef96ff4a6d7f9657155e5aa419444b28912" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/856/10b4f27f69984/yarl-1.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:85610b4f27f69984932a7abbe52703688de3724d9f72bceb1cca667deff27474" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/23f/371bd662cf44a/yarl-1.23.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23f371bd662cf44a7630d4d113101eafc0cfa7518a2760d20760b26021454719" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/c4a/80f77dc1acaaa/yarl-1.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a80f77dc1acaaa61f0934176fccca7096d9b1ff08c8ba9cddf5ae034a24319" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/bd6/54fad46d8d9e8/yarl-1.23.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd654fad46d8d9e823afbb4f87c79160b5a374ed1ff5bde24e542e6ba8f41434" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/682/bae25f0a0dd23/yarl-1.23.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:682bae25f0a0dd23a056739f23a134db9f52a63e2afd6bfb37ddc76292bbd723" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/a82/836cab5f197a0/yarl-1.23.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a82836cab5f197a0514235aaf7ffccdc886ccdaa2324bc0aafdd4ae898103039" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/1c5/7676bdedc94cd/yarl-1.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c57676bdedc94cd3bc37724cf6f8cd2779f02f6aba48de45feca073e714fe52" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/c7f/8dc16c498ff06/yarl-1.23.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7f8dc16c498ff06497c015642333219871effba93e4a2e8604a06264aca5c5c" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/5ee/586fb17ff8f90/yarl-1.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5ee586fb17ff8f90c91cf73c6108a434b02d69925f44f5f8e0d7f2f260607eae" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/172/35362f5801497/yarl-1.23.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:17235362f580149742739cc3828b80e24029d08cbb9c4bda0242c7b5bc610a8e" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/079/3e2bd0cf14234/yarl-1.23.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0793e2bd0cf14234983bbb371591e6bea9e876ddf6896cdcc93450996b0b5c85" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/365/0dc2480f94f71/yarl-1.23.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3650dc2480f94f7116c364096bc84b1d602f44224ef7d5c7208425915c0475dd" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/f40/e782d49630ad3/yarl-1.23.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f40e782d49630ad384db66d4d8b73ff4f1b8955dc12e26b09a3e3af064b3b9d6" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/94f/8575fbdf81749/yarl-1.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94f8575fbdf81749008d980c17796097e645574a3b8c28ee313931068dad14fe" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/c8a/a34a5c864db10/yarl-1.23.0-cp314-cp314-win32.whl", hash = "sha256:c8aa34a5c864db1087d911a0b902d60d203ea3607d91f615acd3f3108ac32169" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/63e/92247f383c85a/yarl-1.23.0-cp314-cp314-win_amd64.whl", hash = "sha256:63e92247f383c85ab00dd0091e8c3fa331a96e865459f5ee80353c70a4a42d70" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/70e/fd20be968c76e/yarl-1.23.0-cp314-cp314-win_arm64.whl", hash = "sha256:70efd20be968c76ece7baa8dafe04c5be06abc57f754d6f36f3741f7aa7a208e" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/9a1/8d6f9359e4572/yarl-1.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9a18d6f9359e45722c064c97464ec883eb0e0366d33eda61cb19a244bf222679" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/280/3ed8b21ca47a4/yarl-1.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2803ed8b21ca47a43da80a6fd1ed3019d30061f7061daa35ac54f63933409412" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/394/906945aa8b19f/yarl-1.23.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:394906945aa8b19fc14a61cf69743a868bb8c465efe85eee687109cc540b98f4" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/71d/006bee8397a4a/yarl-1.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71d006bee8397a4a89f469b8deb22469fe7508132d3c17fa6ed871e79832691c" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/626/94e275c93d54f/yarl-1.23.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:62694e275c93d54f7ccedcfef57d42761b2aad5234b6be1f3e3026cae4001cd4" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/a31/de1613658308e/yarl-1.23.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31de1613658308efdb21ada98cbc86a97c181aa050ba22a808120bb5be3ab94" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/fb1/e8b8d66c278b2/yarl-1.23.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb1e8b8d66c278b21d13b0a7ca22c41dd757a7c209c6b12c313e445c31dd3b28" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/50f/9d8d531dfb767/yarl-1.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50f9d8d531dfb767c565f348f33dd5139a6c43f5cbdf3f67da40d54241df93f6" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/575/aa4405a656e61/yarl-1.23.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575aa4405a656e61a540f4a80eaa5260f2a38fff7bfdc4b5f611840d76e9e277" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/041/b1a4cefacf658/yarl-1.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:041b1a4cefacf65840b4e295c6985f334ba83c30607441ae3cf206a0eed1a2e4" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/d38/c1e8231722c4c/yarl-1.23.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d38c1e8231722c4ce40d7593f28d92b5fc72f3e9774fe73d7e800ec32299f63a" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/d53/834e23c015ee8/yarl-1.23.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d53834e23c015ee83a99377db6e5e37d8484f333edb03bd15b4bc312cc7254fb" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/2e2/7c8841126e017/yarl-1.23.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2e27c8841126e017dd2a054a95771569e6070b9ee1b133366d8b31beb5018a41" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/768/55800ac56f878/yarl-1.23.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:76855800ac56f878847a09ce6dba727c93ca2d89c9e9d63002d26b916810b0a2" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/e09/fd068c2e169a7/yarl-1.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e09fd068c2e169a7070d83d3bde728a4d48de0549f975290be3c108c02e499b4" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/733/09162a6a571d4/yarl-1.23.0-cp314-cp314t-win32.whl", hash = "sha256:73309162a6a571d4cbd3b6a1dcc703c7311843ae0d1578df6f09be4e98df38d4" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/450/3053d296bc6e4/yarl-1.23.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4503053d296bc6e4cbd1fad61cf3b6e33b939886c4f249ba7c78b602214fabe2" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/44b/b7bef4ea40938/yarl-1.23.0-cp314-cp314t-win_arm64.whl", hash = "sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25" }, + { url = "https://pypi.ops.eblu.me/root/pypi/+f/a2d/f6afe50dea8ae/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f" }, +] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } +sdist = { url = "https://pypi.ops.eblu.me/root/pypi/+f/a07/157588a12518c/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166" } +wheels = [ + { url = "https://pypi.ops.eblu.me/root/pypi/+f/071/652d6115ed432/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e" }, +] From 22a417ac3c66e35f4b7cf55e0a2a1e04f9b534f9 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 13 Apr 2026 08:36:20 -0700 Subject: [PATCH 246/430] Oops, looks like a log file got lost, nbd --- update-loki-3.6.7.infra.md | 1 - 1 file changed, 1 deletion(-) delete mode 100644 update-loki-3.6.7.infra.md diff --git a/update-loki-3.6.7.infra.md b/update-loki-3.6.7.infra.md deleted file mode 100644 index c30a619..0000000 --- a/update-loki-3.6.7.infra.md +++ /dev/null @@ -1 +0,0 @@ -Update loki from 3.6.5 to 3.6.7 From fca3010042c5355a505ddfa2ef1d0fc16698af1d Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 13 Apr 2026 08:40:49 -0700 Subject: [PATCH 247/430] Hints about service version tracking --- mise.toml | 5 +++++ service-versions.yaml | 4 +--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/mise.toml b/mise.toml index a5bad84..ee3200e 100644 --- a/mise.toml +++ b/mise.toml @@ -1,4 +1,9 @@ [tools] +# Versions set here are referenced against service-versions.yaml +# If you add a new tool, please: +# 1. pin a specific version (or even better, a SHA) +# 2. create a new entry in service-versions.yaml +# This will help ensure reviewed upgrades at a steady cadence "pipx:ansible-core" = { version = "2.20.1", uvx = "true", uvx_args = "--with botocore --with boto3" } prek = "0.3.4" pulumi = "3.215.0" diff --git a/service-versions.yaml b/service-versions.yaml index dc1df2e..ffb1e0f 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -1,4 +1,4 @@ -# Service Version Tracking +# Service / Tooling/ Application Version Tracking # # Tracks when each BlumeOps service was last reviewed for version freshness. # Used by `mise run service-review` to surface stale services. @@ -394,8 +394,6 @@ services: upstream-source: https://github.com/grafana/alloy/releases notes: COPY --from in fly/Dockerfile for log shipping and metrics - # --- Mise-managed development tools --- - - name: dagger type: mise last-reviewed: 2026-04-12 From 2d2d495f95c3c9f4a09115fe410259f32cc0b882 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 13 Apr 2026 17:48:20 -0700 Subject: [PATCH 248/430] Fix paperless redis: use upstream valkey instead of amd64-only nix image The authentik-redis image is nix-built on ringtail (amd64 only) and was previously running under QEMU emulation on arm64 minikube. Discovered during DR recovery when fresh minikube lacked binfmt registration. Co-Authored-By: Claude Opus 4.6 (1M context) --- argocd/manifests/paperless/kustomization.yaml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/argocd/manifests/paperless/kustomization.yaml b/argocd/manifests/paperless/kustomization.yaml index 51df2c1..3e65578 100644 --- a/argocd/manifests/paperless/kustomization.yaml +++ b/argocd/manifests/paperless/kustomization.yaml @@ -14,8 +14,9 @@ resources: images: - name: registry.ops.eblu.me/blumeops/paperless newTag: v2.20.13-07f52e9 - # TODO: borrowing authentik-redis image — consider building a generic - # blumeops/redis container if more services need Redis sidecars + # TODO(DR-2026-04): authentik-redis is amd64-only (nix-built on ringtail). + # Was running under QEMU emulation before. Switched to upstream valkey + # during DR recovery. Build a multi-arch blumeops/redis or keep upstream. - name: docker.io/library/redis - newName: registry.ops.eblu.me/blumeops/authentik-redis - newTag: v8.2.3-fd0bebb-nix + newName: docker.io/valkey/valkey + newTag: "8.1-alpine" From cd5b6b63f7d30686e90066009bff1dd27fe18a0b Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 13 Apr 2026 17:58:06 -0700 Subject: [PATCH 249/430] Add paperless DB to borgmatic backups Discovered during DR that paperless was the only service DB not backed up by borgmatic. Uses same blumeops-pg cluster on port 5432. Co-Authored-By: Claude Opus 4.6 (1M context) --- ansible/roles/borgmatic/defaults/main.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ansible/roles/borgmatic/defaults/main.yml b/ansible/roles/borgmatic/defaults/main.yml index e1622e6..4dd0671 100644 --- a/ansible/roles/borgmatic/defaults/main.yml +++ b/ansible/roles/borgmatic/defaults/main.yml @@ -88,6 +88,10 @@ borgmatic_postgresql_databases: hostname: pg.ops.eblu.me port: 5432 username: borgmatic + - name: paperless + hostname: pg.ops.eblu.me + port: 5432 + username: borgmatic # immich-pg cluster (VectorChord) via Caddy L4 on port 5433 - name: immich hostname: pg.ops.eblu.me From 405dab8b59e19bbe5796d391b9f8a73ec42b4fcd Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 13 Apr 2026 17:59:16 -0700 Subject: [PATCH 250/430] Add changelog fragments for DR recovery work Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/changelog.d/+dr-paperless-backup.bugfix.md | 1 + docs/changelog.d/+dr-paperless-redis.bugfix.md | 1 + docs/changelog.d/+dr-recovery-2026-04.infra.md | 1 + 3 files changed, 3 insertions(+) create mode 100644 docs/changelog.d/+dr-paperless-backup.bugfix.md create mode 100644 docs/changelog.d/+dr-paperless-redis.bugfix.md create mode 100644 docs/changelog.d/+dr-recovery-2026-04.infra.md diff --git a/docs/changelog.d/+dr-paperless-backup.bugfix.md b/docs/changelog.d/+dr-paperless-backup.bugfix.md new file mode 100644 index 0000000..4882d14 --- /dev/null +++ b/docs/changelog.d/+dr-paperless-backup.bugfix.md @@ -0,0 +1 @@ +Add paperless database to borgmatic backup configuration. Previously the only service DB not included in nightly pg_dump backups. diff --git a/docs/changelog.d/+dr-paperless-redis.bugfix.md b/docs/changelog.d/+dr-paperless-redis.bugfix.md new file mode 100644 index 0000000..04f4ae3 --- /dev/null +++ b/docs/changelog.d/+dr-paperless-redis.bugfix.md @@ -0,0 +1 @@ +Switch paperless redis sidecar from amd64-only nix-built `authentik-redis` image to upstream `valkey:8.1-alpine` (multi-arch). The nix image was previously running under QEMU emulation on arm64 minikube. diff --git a/docs/changelog.d/+dr-recovery-2026-04.infra.md b/docs/changelog.d/+dr-recovery-2026-04.infra.md new file mode 100644 index 0000000..62f5bbc --- /dev/null +++ b/docs/changelog.d/+dr-recovery-2026-04.infra.md @@ -0,0 +1 @@ +Full DR recovery from power loss and minikube cluster rebuild. Validated bootstrap procedure, identified circular dependencies (forge.eblu.me, Zot/Authentik OIDC), Tailscale device name collision issues, and documented recovery steps for restart-indri. From d7c3c687f4ee89645b072661091cb273e6c5f8fb Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 13 Apr 2026 18:07:54 -0700 Subject: [PATCH 251/430] Document DR rebuild procedure and update restart-indri - New how-to: rebuild-minikube-cluster with full bootstrap procedure validated during 2026-04-13 DR event - Update restart-indri: warn about minikube delete, macOS permission dialog on first Tailscale SSH, forgejo_actions_secrets dep cycle - Update disaster-recovery reference: link to rebuild procedure - Update CLAUDE.md: never run minikube delete Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 1 + .../operations/rebuild-minikube-cluster.md | 247 ++++++++++++++++++ docs/how-to/operations/restart-indri.md | 8 + .../reference/operations/disaster-recovery.md | 3 +- 4 files changed, 258 insertions(+), 1 deletion(-) create mode 100644 docs/how-to/operations/rebuild-minikube-cluster.md diff --git a/CLAUDE.md b/CLAUDE.md index 60757ad..ee071cb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,6 +17,7 @@ blumeops is Erich Blume's GitOps repository for personal infrastructure, orchest **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 + **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 4. **Feature branches + PRs for C1/C2** - checkout main, pull, create branch, open PR via `tea pr create`. C0 goes direct to main. 5. **Check PR comments with `mise run pr-comments `** before proceeding diff --git a/docs/how-to/operations/rebuild-minikube-cluster.md b/docs/how-to/operations/rebuild-minikube-cluster.md new file mode 100644 index 0000000..2b97b78 --- /dev/null +++ b/docs/how-to/operations/rebuild-minikube-cluster.md @@ -0,0 +1,247 @@ +--- +title: Rebuild Minikube Cluster (DR) +modified: 2026-04-13 +last-reviewed: 2026-04-13 +tags: + - how-to + - operations + - disaster-recovery +--- + +# Rebuild Minikube Cluster (DR) + +How to rebuild the minikube cluster from scratch after data loss (e.g., accidental `minikube delete`). This is a DR procedure — for normal restarts, see [[restart-indri]]. + +> **This procedure was validated during a real DR event on 2026-04-13** after a power loss and accidental `minikube delete` destroyed all cluster state. + +## Prerequisites + +- SSH access to indri (dismiss the macOS tailscaled permission dialog first — see [[restart-indri#0. Dismiss macOS Permission Dialogs]]) +- Docker Desktop running on indri +- Tailscale connected +- 1Password CLI (`op`) authenticated + +## Before You Start + +### Clean Stale Tailscale Devices + +Before bringing up the Tailscale operator, **delete stale service devices from the Tailscale admin console** (admin.tailscale.com). Old devices from the destroyed cluster will cause name collisions (new devices get `-1`, `-2` suffixes). + +Look for offline tagged devices like: `pg`, `immich-pg`, `cnpg-metrics`, `ingress-0`, `ingress-1`, and any other `tag:k8s` devices that show "last seen" timestamps from before the rebuild. + +If you miss this step, you'll need to: delete stale devices from the console, delete the Tailscale state secrets in k8s (`kubectl delete secret -n tailscale `), and restart the affected pods. + +> **Watch out for cross-cluster name collisions.** Both indri (minikube) and ringtail (k3s) use a ProxyGroup named `ingress`, producing pods named `ingress-0`, `ingress-1`. Deleting the wrong device can break the other cluster. Check which IPs are active before deleting. This is tech debt — the ProxyGroups should eventually be renamed to `indri-ingress` / `ringtail-ingress`. + +## Phase 1: Start Minikube + +```bash +minikube start --driver=docker --container-runtime=docker \ + --cpus=6 --memory=11264 --disk-size=200g \ + --apiserver-names=k8s.tail8d86e.ts.net --apiserver-names=indri \ + --apiserver-port=6443 --listen-address=0.0.0.0 +``` + +Then run the ansible minikube role to configure Tailscale serve and registry mirrors: + +```bash +mise run provision-indri -- --tags minikube +``` + +## Phase 2: Bootstrap Tailscale Operator + +The Tailscale operator must be deployed before ArgoCD (ArgoCD uses Tailscale Ingress). + +```bash +# 1. Create namespace +kubectl --context=minikube-indri create namespace tailscale + +# 2. Create OAuth secret manually (ExternalSecrets isn't available yet) +CLIENT_ID=$(op read "op://blumeops/Tailscale K8s Operator OAuth/client-id") +CLIENT_SECRET=$(op read "op://blumeops/Tailscale K8s Operator OAuth/client-secret") +kubectl --context=minikube-indri create secret generic operator-oauth -n tailscale \ + --from-literal=client_id="$CLIENT_ID" \ + --from-literal=client_secret="$CLIENT_SECRET" + +# 3. Apply operator manifests +# NOTE: The kustomization fetches from forge.eblu.me which routes through +# Fly → Tailscale → k8s (not yet up). Use forge.ops.eblu.me or github.com/eblume/blumeops. +# Fetch the upstream manifest locally and build a temp kustomization: +curl -s "https://forge.ops.eblu.me/mirrors/tailscale/raw/tag/v1.94.2/cmd/k8s-operator/deploy/manifests/operator.yaml" \ + -o /tmp/ts-operator.yaml +# (create temp kustomization referencing local file — see memory/project_dr_lessons_2026_04.md for details) +kubectl --context=minikube-indri apply -k /tmp/ts-bootstrap/ + +# 4. Apply ProxyGroup for ingress +kubectl --context=minikube-indri apply -f argocd/manifests/tailscale-operator/proxygroup-ingress.yaml +``` + +## Phase 3: Bootstrap ArgoCD + +```bash +# 1. Create namespace +kubectl --context=minikube-indri create namespace argocd + +# 2. Apply ArgoCD (skip ExternalSecret resources — not available yet) +# Create a temp kustomization without external-secret-*.yaml resources. +# Use --server-side --force-conflicts for large CRDs (applicationsets). +kubectl --context=minikube-indri apply -k /tmp/argocd-bootstrap/ --server-side --force-conflicts + +# 3. Wait for ArgoCD +kubectl --context=minikube-indri wait --for=condition=available deployment/argocd-server -n argocd --timeout=300s + +# 4. Create forge SSH repo credentials +PRIV_KEY=$(op read "op://vg6xf6vvfmoh5hqjjhlhbeoaie/csjncynh6htjvnh2l2da65y32q/private key?ssh-format=openssh")$'\n' +KNOWN_HOSTS=$(ssh-keyscan -p 2222 forge.ops.eblu.me 2>/dev/null | grep ssh-rsa) +kubectl --context=minikube-indri create secret generic repo-creds-forge -n argocd \ + --from-literal=type=git \ + --from-literal=url='ssh://forgejo@forge.ops.eblu.me:2222/' \ + --from-literal=insecure=false \ + --from-literal=sshPrivateKey="$PRIV_KEY" \ + --from-literal=sshKnownHosts="$KNOWN_HOSTS" +kubectl --context=minikube-indri label secret repo-creds-forge -n argocd argocd.argoproj.io/secret-type=repo-creds + +# 5. Apply app-of-apps +kubectl --context=minikube-indri apply -f argocd/apps/argocd.yaml +kubectl --context=minikube-indri apply -f argocd/apps/apps.yaml + +# 6. Login and sync apps +argocd login argocd.tail8d86e.ts.net --username admin \ + --password "$(kubectl --context=minikube-indri -n argocd get secret argocd-initial-admin-secret -o jsonpath='{.data.password}' | base64 -d)" \ + --grpc-web +argocd app sync apps --grpc-web +``` + +## Phase 4: Bootstrap 1Password Connect + External Secrets + +```bash +# 1. Sync foundation +argocd app sync external-secrets-crds --grpc-web +argocd app sync external-secrets --grpc-web +argocd app sync 1password-connect --grpc-web + +# 2. Create 1Password Connect secrets manually +CREDS_RAW=$(op read "op://blumeops/1Password Connect/credentials-file") +echo "$CREDS_RAW" | kubectl --context=minikube-indri create secret generic op-credentials -n 1password \ + --from-file=1password-credentials.json=/dev/stdin +TOKEN=$(op read "op://blumeops/1Password Connect/token") +kubectl --context=minikube-indri create secret generic onepassword-token -n 1password \ + --from-literal=token="$TOKEN" + +# 3. Wait for 1Password Connect to start, then restart External Secrets +kubectl --context=minikube-indri wait --for=condition=available deployment/onepassword-connect -n 1password --timeout=120s +kubectl --context=minikube-indri rollout restart deployment -n external-secrets external-secrets + +# 4. Verify ClusterSecretStore becomes Valid +kubectl --context=minikube-indri get clustersecretstores +``` + +## Phase 5: Sync Services (Dependency Order) + +```bash +# Foundation (CRDs, operators) +argocd app sync cloudnative-pg kube-state-metrics --grpc-web + +# Databases +argocd app sync blumeops-pg --grpc-web + +# Observability +argocd app sync loki prometheus tempo grafana grafana-config --grpc-web + +# Register ringtail cluster (for authentik, ntfy, ollama, frigate) +ssh ringtail 'sudo cat /etc/rancher/k3s/k3s.yaml' | \ + sed 's|127.0.0.1|ringtail.tail8d86e.ts.net|' > /tmp/k3s-ringtail.yaml +KUBECONFIG=/tmp/k3s-ringtail.yaml argocd cluster add default --name k3s-ringtail --grpc-web -y + +# Authentik (critical — Zot OIDC depends on it, most image pulls depend on Zot) +argocd app sync authentik --grpc-web + +# Everything else +argocd app sync tailscale-operator alloy-k8s --grpc-web +# ... remaining apps +``` + +## Phase 6: Restore Databases from Borgmatic + +Databases come up empty. Restore from the latest borgmatic backup. + +```bash +# Extract dumps +ssh indri 'mkdir -p /tmp/borg-restore && borgmatic extract --repository /Volumes/backups/borg --archive latest --destination /tmp/borg-restore --path borgmatic/postgresql_databases' + +# Create databases that don't exist yet +kubectl --context=minikube-indri exec -n databases blumeops-pg-1 -c postgres -- \ + psql -U postgres -c "CREATE DATABASE teslamate OWNER teslamate;" +kubectl --context=minikube-indri exec -n databases blumeops-pg-1 -c postgres -- \ + psql -U postgres -c "CREATE DATABASE authentik OWNER authentik;" +# (repeat for other DBs as needed) + +# For immich: create extensions BEFORE restoring +kubectl --context=minikube-indri exec -n databases immich-pg-1 -c postgres -- \ + psql -U postgres -d immich -c "CREATE EXTENSION IF NOT EXISTS vector; CREATE EXTENSION IF NOT EXISTS vchord CASCADE; CREATE EXTENSION IF NOT EXISTS cube CASCADE; CREATE EXTENSION IF NOT EXISTS earthdistance CASCADE; CREATE EXTENSION IF NOT EXISTS pg_trgm; CREATE EXTENSION IF NOT EXISTS unaccent; CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\";" + +# Restore (dumps are in custom format — use pg_restore, not psql) +scp indri:/tmp/borg-restore/borgmatic/postgresql_databases/pg.ops.eblu.me:5432/miniflux /tmp/miniflux.sql +kubectl --context=minikube-indri exec -i -n databases blumeops-pg-1 -c postgres -- \ + pg_restore -U postgres -d miniflux --no-owner --role=miniflux < /tmp/miniflux.sql +# (repeat for teslamate, authentik, immich) + +# Reset passwords to match current ExternalSecrets/CNPG-generated credentials +# The restored dumps contain OLD password hashes +PASS=$(kubectl --context=minikube-indri -n databases get secret blumeops-pg-app -o jsonpath='{.data.password}' | base64 -d) +kubectl --context=minikube-indri exec -n databases blumeops-pg-1 -c postgres -- \ + psql -U postgres -c "ALTER USER miniflux WITH PASSWORD '${PASS}';" +# (repeat for each user with the appropriate secret source) + +# Create manually-managed DB secrets +kubectl --context=minikube-indri create secret generic miniflux-db -n miniflux \ + --from-literal=url="$(kubectl --context=minikube-indri -n databases get secret blumeops-pg-app -o jsonpath='{.data.uri}' | base64 -d)" +kubectl --context=minikube-indri create secret generic immich-db -n immich \ + --from-literal=password="$(kubectl --context=minikube-indri -n databases get secret immich-pg-app -o jsonpath='{.data.password}' | base64 -d)" +``` + +## Phase 7: Manual Fixups + +### Forge Tailscale Ingress + Endpoints + +The forge-external Endpoints must be applied manually (ArgoCD excludes Endpoints resources): + +```bash +kubectl --context=minikube-indri apply -f argocd/manifests/tailscale-operator/svc-forge-external.yaml +kubectl --context=minikube-indri apply -f argocd/manifests/tailscale-operator/ingress-forge.yaml +kubectl --context=minikube-indri apply -f argocd/manifests/tailscale-operator/endpoints-forge.yaml +``` + +### Restart Fly.io Proxy + +After the Tailscale ingress ProxyGroup gets new VIPs, the Fly.io proxy's MagicDNS cache may be stale: + +```bash +FLY_API_TOKEN=$(op read "op://blumeops/fly.io admin/deploy-token") fly machine restart --app blumeops-proxy +``` + +### Grafana SQLite + +If Grafana crashes with migration errors (`no such column: help_flags1`), delete its PVC and resync — Grafana is fully stateless (all config provisioned via ConfigMaps). + +## Phase 8: Verify + +```bash +mise run services-check +``` + +## Known Circular Dependencies + +| Dependency | Breaks | Workaround | +|-----------|--------|------------| +| `forge.eblu.me` → Fly → Tailscale → k8s | tailscale-operator kustomization fetch | Fetch manifests from `forge.ops.eblu.me` or `github.com/eblume/blumeops` | +| Forgejo Actions secrets → Forgejo API → Caddy → k8s | Full ansible playbook | Use `--tags minikube` during bootstrap | +| Zot → Authentik OIDC | All container image pulls from Zot | Sync authentik early; Zot will crash-loop until OIDC is reachable | +| ArgoCD Endpoints exclusion → forge-external | Forge Tailscale ingress has no backend | Manual `kubectl apply` for Endpoints | + +## Related + +- [[restart-indri]] — Normal restart procedure (no data loss) +- [[disaster-recovery]] — DR overview +- [[borgmatic]] — Backup restoration +- [[cluster]] — Kubernetes cluster details diff --git a/docs/how-to/operations/restart-indri.md b/docs/how-to/operations/restart-indri.md index a3f29a0..a956644 100644 --- a/docs/how-to/operations/restart-indri.md +++ b/docs/how-to/operations/restart-indri.md @@ -70,6 +70,12 @@ After indri boots, most services recover automatically. Only a few things need m **What needs manual action:** Amphetamine, AutoMounter, and minikube (including its Tailscale serve port). +> **Warning:** Do NOT run `minikube delete` — it destroys all PersistentVolumes, etcd state, and requires a full DR rebuild. Use `minikube stop` / `minikube start` instead. If minikube is stuck, see [[#Troubleshooting CNI Conflict After Unclean Shutdown]]. For full cluster rebuild, see [[rebuild-minikube-cluster]]. + +### 0. Dismiss macOS Permission Dialogs + +After a cold boot, the **first inbound Tailscale SSH connection** to indri triggers a macOS GUI permission dialog from tailscaled. This blocks the SSH session (and anything downstream like ansible) until dismissed at the console. You must be logged in to indri (via Screen Sharing or physically) to approve it before running any remote commands. + ### 1. Log In and Start GUI Apps Log in to indri (via Screen Sharing or physically) and launch: @@ -103,6 +109,8 @@ Run the minikube ansible role to detect the new port and update Tailscale serve: mise run provision-indri -- --tags minikube ``` +> **Note:** Do NOT run the full `mise run provision-indri` without tags during startup — the `forgejo_actions_secrets` role will timeout because the Forgejo API routes through Caddy → k8s, which isn't up yet. Use `--tags minikube` (or `--tags minikube,minikube_metrics`) to target just the minikube role. + This will: - Start minikube if it hasn't started yet - Detect the current API server port diff --git a/docs/reference/operations/disaster-recovery.md b/docs/reference/operations/disaster-recovery.md index b144aaf..1a498ba 100644 --- a/docs/reference/operations/disaster-recovery.md +++ b/docs/reference/operations/disaster-recovery.md @@ -14,8 +14,9 @@ Recovery procedures for BlumeOps infrastructure. | Scenario | Guide | |----------|-------| -| Lost 1Password access | [[restore-1password-backup]] | | Indri reboot/power loss | [[restart-indri]] | +| Full minikube cluster rebuild | [[rebuild-minikube-cluster]] | +| Lost 1Password access | [[restore-1password-backup]] | ## Components From 4ca0630d76d6a41ea7ef83f7318ec61cf8269431 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Tue, 14 Apr 2026 07:00:55 -0700 Subject: [PATCH 252/430] Review enforce-tag-immutability doc: add review date and zot reference link Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/how-to/zot/enforce-tag-immutability.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/how-to/zot/enforce-tag-immutability.md b/docs/how-to/zot/enforce-tag-immutability.md index 4d65d82..c6f27d2 100644 --- a/docs/how-to/zot/enforce-tag-immutability.md +++ b/docs/how-to/zot/enforce-tag-immutability.md @@ -1,6 +1,7 @@ --- title: Enforce Tag Immutability modified: 2026-02-21 +last-reviewed: 2026-04-14 tags: - how-to - zot @@ -26,3 +27,4 @@ This approach requires authentication to be meaningful — without auth, everyon ## Related - [[harden-zot-registry]] — Parent goal (includes this requirement) +- [[zot]] — Zot registry service reference From 08c698e8338fe4b9f753eeb130aeae85d1e2af68 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Tue, 14 Apr 2026 07:20:52 -0700 Subject: [PATCH 253/430] Migrate teslamate to native Dagger container.py (#333) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Replace legacy Dockerfile with native Dagger `container.py` build - Two-stage pipeline: Elixir+Node builder, Debian slim runtime - Uses shared helpers (`clone_from_forge`, `oci_labels`) - Delete old Dockerfile (pipeline auto-discovers container.py) - Update build-container-image docs and mark service reviewed ## Test plan - [x] `dagger call build --src=. --container-name=teslamate` succeeds locally - [ ] CI container build passes - [ ] Deploy from branch and verify teslamate starts cleanly 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/333 --- containers/teslamate/Dockerfile | 84 -------------- containers/teslamate/container.py | 104 ++++++++++++++++++ .../teslamate-dagger-migration.infra.md | 1 + .../deployment/build-container-image.md | 4 +- service-versions.yaml | 2 +- 5 files changed, 108 insertions(+), 87 deletions(-) delete mode 100644 containers/teslamate/Dockerfile create mode 100644 containers/teslamate/container.py create mode 100644 docs/changelog.d/teslamate-dagger-migration.infra.md diff --git a/containers/teslamate/Dockerfile b/containers/teslamate/Dockerfile deleted file mode 100644 index 70c6d71..0000000 --- a/containers/teslamate/Dockerfile +++ /dev/null @@ -1,84 +0,0 @@ -# TeslaMate - Tesla data logger -# Based on upstream Dockerfile - -ARG CONTAINER_APP_VERSION=v3.0.0 -ARG TESLAMATE_VERSION=${CONTAINER_APP_VERSION} - -FROM elixir:1.19.5-otp-26 AS builder - -ARG TESLAMATE_VERSION - -SHELL ["/bin/bash", "-o", "pipefail", "-c"] - -RUN 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 \ - && NODE_MAJOR=22 \ - && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" \ - | tee /etc/apt/sources.list.d/nodesource.list \ - && apt-get update \ - && apt-get install nodejs -y \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* - -RUN mix local.rebar --force && \ - mix local.hex --force - -# Clone specific version -RUN git clone --depth 1 --branch ${TESLAMATE_VERSION} \ - https://forge.ops.eblu.me/mirrors/teslamate.git /opt/app - -ENV MIX_ENV=prod -WORKDIR /opt/app - -RUN mix deps.get --only $MIX_ENV -RUN mix deps.compile - -RUN npm ci --prefix ./assets --progress=false --no-audit --loglevel=error -RUN mix assets.deploy - -RUN mix compile -RUN SKIP_LOCALE_DOWNLOAD=true mix release --path /opt/built - -# Runtime image -FROM debian:trixie-slim AS app - -ARG CONTAINER_APP_VERSION -LABEL org.opencontainers.image.title="TeslaMate" -LABEL org.opencontainers.image.description="Tesla data logger and visualization" -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" - -ENV LANG=C.UTF-8 \ - SRTM_CACHE=/opt/app/.srtm_cache \ - HOME=/opt/app - -WORKDIR $HOME - -RUN 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 \ - && chown -R nonroot:nonroot . - -COPY --chown=nonroot:nonroot --chmod=555 entrypoint.sh / -COPY --from=builder --chown=nonroot:nonroot /opt/built . -RUN mkdir $SRTM_CACHE - -USER nonroot:nonroot - -EXPOSE 4000 - -ENTRYPOINT ["tini", "--", "/bin/dash", "/entrypoint.sh"] -CMD ["bin/teslamate", "start"] diff --git a/containers/teslamate/container.py b/containers/teslamate/container.py new file mode 100644 index 0000000..519d77d --- /dev/null +++ b/containers/teslamate/container.py @@ -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"]) + ) diff --git a/docs/changelog.d/teslamate-dagger-migration.infra.md b/docs/changelog.d/teslamate-dagger-migration.infra.md new file mode 100644 index 0000000..7938365 --- /dev/null +++ b/docs/changelog.d/teslamate-dagger-migration.infra.md @@ -0,0 +1 @@ +Migrate teslamate container build from legacy Dockerfile to native Dagger container.py. diff --git a/docs/how-to/deployment/build-container-image.md b/docs/how-to/deployment/build-container-image.md index a0e7d03..ce66a46 100644 --- a/docs/how-to/deployment/build-container-image.md +++ b/docs/how-to/deployment/build-container-image.md @@ -129,7 +129,7 @@ Existing containers demonstrate several build approaches: | Native Dagger (Go + Node) | [[#navidrome]] | `container.py` with helper functions — preferred for new containers | | Alpine package install | [[#transmission]] | Simplest Dockerfile — install from apk | | Go from source | [[#miniflux]] | Dockerfile: clone upstream, `go build` | -| Multi-stage Elixir | [[#teslamate]] | Dockerfile: Elixir release with Node assets | +| Native Dagger (Elixir + Node) | [[#teslamate]] | `container.py` with Debian runtime — Elixir release with Node assets | | Runtime tarball download | [[#kiwix-serve]] | Dockerfile: download pre-built binary with arch detection | | Nix `dockerTools` | [[#ntfy-nix]] | `buildLayeredImage` with nix-built app (ringtail runner) | @@ -147,7 +147,7 @@ Existing containers demonstrate several build approaches: ### teslamate -`containers/teslamate/Dockerfile` — Two-stage Elixir build with Node.js asset compilation. Uses Debian-based images due to Elixir/OTP dependencies. (Legacy Dockerfile — migrate to `container.py` during review.) +`containers/teslamate/container.py` — Native Dagger build. Two-stage pipeline: Elixir builder with Node.js for asset compilation, Debian slim runtime. Uses Debian-based images (not Alpine) due to Elixir/OTP dependencies. Includes entrypoint script for pg-wait and migrations. ### kiwix-serve diff --git a/service-versions.yaml b/service-versions.yaml index ffb1e0f..e7e2ac9 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -190,7 +190,7 @@ services: - name: teslamate type: argocd - last-reviewed: 2026-03-03 + last-reviewed: 2026-04-14 current-version: "v3.0.0" upstream-source: https://github.com/teslamate-org/teslamate/releases From ccaef4c1a77cd7431f33d0d6e3fe516ad0ed3829 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Tue, 14 Apr 2026 07:38:06 -0700 Subject: [PATCH 254/430] Document devpi cold cache failure mode and deploy teslamate v3.0.0-08c698e MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a DR rebuild, devpi's empty cache causes race conditions under concurrent load — metadata is served but wheel files 404. Also deploys the first container.py-built teslamate image. Co-Authored-By: Claude Opus 4.6 (1M context) --- argocd/manifests/teslamate/kustomization.yaml | 2 +- .../operations/rebuild-minikube-cluster.md | 20 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/argocd/manifests/teslamate/kustomization.yaml b/argocd/manifests/teslamate/kustomization.yaml index ac9e1ea..a00586f 100644 --- a/argocd/manifests/teslamate/kustomization.yaml +++ b/argocd/manifests/teslamate/kustomization.yaml @@ -12,4 +12,4 @@ resources: images: - name: registry.ops.eblu.me/blumeops/teslamate - newTag: v3.0.0-613f05d + newTag: v3.0.0-08c698e diff --git a/docs/how-to/operations/rebuild-minikube-cluster.md b/docs/how-to/operations/rebuild-minikube-cluster.md index 2b97b78..26207a7 100644 --- a/docs/how-to/operations/rebuild-minikube-cluster.md +++ b/docs/how-to/operations/rebuild-minikube-cluster.md @@ -239,6 +239,26 @@ mise run services-check | Zot → Authentik OIDC | All container image pulls from Zot | Sync authentik early; Zot will crash-loop until OIDC is reachable | | ArgoCD Endpoints exclusion → forge-external | Forge Tailscale ingress has no backend | Manual `kubectl apply` for Endpoints | +## Post-Rebuild: Cold Cache Failures + +### Devpi (PyPI Cache) + +After a rebuild, devpi's package cache is empty. The first Dagger-based container build will trigger a flood of concurrent package downloads. Devpi uses lazy caching — it serves package metadata (simple index) immediately from upstream PyPI but fetches wheel files on demand. Under heavy concurrent load with a cold cache, the upstream fetch can race with the client request, causing devpi to return `no such file` (HTTP 404) for packages it knows about but hasn't finished downloading yet. + +**Symptoms:** Forgejo Actions Dagger builds fail during module initialization with errors like: +``` +Failed to download `googleapis-common-protos==1.74.0` +HTTP status client error (404 Not Found) for url (https://pypi.ops.eblu.me/root/pypi/+f/...) +``` + +**Fix:** Re-run the failed build. The first attempt warms the cache; subsequent builds succeed. Alternatively, warm the cache manually before triggering CI builds: + +```bash +# From any machine that can reach pypi.ops.eblu.me, install the Dagger SDK +# to pre-populate the most common packages: +pip install --dry-run --index-url https://pypi.ops.eblu.me/root/pypi/+simple/ dagger-io +``` + ## Related - [[restart-indri]] — Normal restart procedure (no data loss) From 223b1347766636106164478a81507686089325ca Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Tue, 14 Apr 2026 07:41:45 -0700 Subject: [PATCH 255/430] Document uv.lock as the source of devpi dependency in Dagger builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lockfile bakes in devpi URLs — Dagger does a locked install, not fresh resolution. This is the mechanism behind the cold-cache failure. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/how-to/operations/rebuild-minikube-cluster.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/how-to/operations/rebuild-minikube-cluster.md b/docs/how-to/operations/rebuild-minikube-cluster.md index 26207a7..3eaffd7 100644 --- a/docs/how-to/operations/rebuild-minikube-cluster.md +++ b/docs/how-to/operations/rebuild-minikube-cluster.md @@ -245,6 +245,8 @@ mise run services-check After a rebuild, devpi's package cache is empty. The first Dagger-based container build will trigger a flood of concurrent package downloads. Devpi uses lazy caching — it serves package metadata (simple index) immediately from upstream PyPI but fetches wheel files on demand. Under heavy concurrent load with a cold cache, the upstream fetch can race with the client request, causing devpi to return `no such file` (HTTP 404) for packages it knows about but hasn't finished downloading yet. +**Why devpi, not PyPI?** The repo's `uv.lock` was generated with devpi as the index, so every package source URL points at `pypi.ops.eblu.me`. Dagger's Python SDK runtime does a locked install (`uv sync`), not fresh resolution — it fetches from whatever URLs are in the lockfile. This is intentional (supply chain control), but means all builds — local and CI — depend on devpi being available and warm. + **Symptoms:** Forgejo Actions Dagger builds fail during module initialization with errors like: ``` Failed to download `googleapis-common-protos==1.74.0` From 0e93cc08b4d9765d361b207aadee72a9ce09aaaa Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Tue, 14 Apr 2026 11:06:36 -0700 Subject: [PATCH 256/430] Build forgejo-runner container locally (#334) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Add native Dagger `container.py` for forgejo-runner (Go + Alpine runtime, static binary with CGO for SQLite) - Update kustomization to point to local registry image (tag is placeholder until CI builds) - Uses existing `clone_from_forge("forgejo-runner", ...)` mirror ## Test plan - [x] `dagger call build --src=. --container-name=forgejo-runner` passes locally - [ ] CI container build from branch succeeds - [ ] Update kustomization tag to built image, deploy from branch via ArgoCD `--revision` - [ ] Verify runner registers and picks up jobs 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/334 --- .../forgejo-runner/kustomization.yaml | 3 +- containers/forgejo-runner/container.py | 67 +++++++++++++++++++ .../changelog.d/local-forgejo-runner.infra.md | 1 + 3 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 containers/forgejo-runner/container.py create mode 100644 docs/changelog.d/local-forgejo-runner.infra.md diff --git a/argocd/manifests/forgejo-runner/kustomization.yaml b/argocd/manifests/forgejo-runner/kustomization.yaml index 2c845ee..b652821 100644 --- a/argocd/manifests/forgejo-runner/kustomization.yaml +++ b/argocd/manifests/forgejo-runner/kustomization.yaml @@ -10,7 +10,8 @@ resources: images: - name: code.forgejo.org/forgejo/runner - newTag: "12.7.3" + newName: registry.ops.eblu.me/blumeops/forgejo-runner + newTag: v12.7.3-12d83ba - name: docker newTag: 27-dind diff --git a/containers/forgejo-runner/container.py b/containers/forgejo-runner/container.py new file mode 100644 index 0000000..16f6986 --- /dev/null +++ b/containers/forgejo-runner/container.py @@ -0,0 +1,67 @@ +"""Forgejo Runner — native Dagger build. + +Two-stage build: Go (static binary with CGO for SQLite), Alpine (runtime). +Source cloned from forge mirror. +""" + +import dagger +from dagger import dag + +from blumeops.containers import ( + alpine_runtime, + clone_from_forge, + oci_labels, +) + +VERSION = "12.7.3" + + +async def build(src: dagger.Directory) -> dagger.Container: + source = clone_from_forge("forgejo-runner", f"v{VERSION}") + + # Stage 1: Build Go binary (static, CGO enabled for SQLite) + ldflags = ( + '-extldflags "-static" -s -w' + f' -X "code.forgejo.org/forgejo/runner/v12/internal/pkg/ver.version=v{VERSION}"' + ) + backend = ( + dag.container() + .from_("golang:alpine3.22") + .with_exec(["apk", "add", "--no-cache", "build-base", "git"]) + .with_directory("/app", source) + .with_workdir("/app") + .with_env_variable("CGO_ENABLED", "1") + .with_env_variable("CGO_CFLAGS", "-DSQLITE_MAX_VARIABLE_NUMBER=32766") + .with_exec( + [ + "go", + "build", + "-tags=netgo osusergo", + f"-ldflags={ldflags}", + "-o", + "/forgejo-runner", + ".", + ] + ) + ) + + # Stage 2: Runtime + runtime = alpine_runtime( + extra_apk=["git", "bash", "ca-certificates"], + uid=1000, + gid=1000, + username="runner", + ) + runtime = oci_labels( + runtime, + title="Forgejo Runner", + description="A runner for Forgejo Actions", + version=VERSION, + ) + return ( + runtime.with_file("/bin/forgejo-runner", backend.file("/forgejo-runner")) + .with_env_variable("HOME", "/data") + .with_user("1000:1000") + .with_workdir("/data") + .with_default_args(args=["/bin/forgejo-runner"]) + ) diff --git a/docs/changelog.d/local-forgejo-runner.infra.md b/docs/changelog.d/local-forgejo-runner.infra.md new file mode 100644 index 0000000..ffef62e --- /dev/null +++ b/docs/changelog.d/local-forgejo-runner.infra.md @@ -0,0 +1 @@ +Build forgejo-runner container locally via native Dagger pipeline instead of pulling from upstream. From 9d85c97b9b0dca0fb786758ef18262a47e2ecf49 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Tue, 14 Apr 2026 11:10:36 -0700 Subject: [PATCH 257/430] Update forgejo-runner kustomization tag to main-branch image C0 follow-up: switch from branch-built tag to main-built v12.7.3-0e93cc0. Co-Authored-By: Claude Opus 4.6 (1M context) --- argocd/manifests/forgejo-runner/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/argocd/manifests/forgejo-runner/kustomization.yaml b/argocd/manifests/forgejo-runner/kustomization.yaml index b652821..2876f8e 100644 --- a/argocd/manifests/forgejo-runner/kustomization.yaml +++ b/argocd/manifests/forgejo-runner/kustomization.yaml @@ -11,7 +11,7 @@ resources: images: - name: code.forgejo.org/forgejo/runner newName: registry.ops.eblu.me/blumeops/forgejo-runner - newTag: v12.7.3-12d83ba + newTag: v12.7.3-0e93cc0 - name: docker newTag: 27-dind From f2514a6f029b0b8a87eed53e0a4f2b06d3062d89 Mon Sep 17 00:00:00 2001 From: Forgejo Actions Date: Tue, 14 Apr 2026 11:29:27 -0700 Subject: [PATCH 258/430] Update docs release to v1.15.5 - Built changelog from towncrier fragments [skip ci] --- CHANGELOG.md | 45 +++++++++++++++++++ argocd/manifests/docs/deployment.yaml | 2 +- docs/changelog.d/+argocd-v3.3.6.infra.md | 1 - docs/changelog.d/+authentik-2026.2.2.infra.md | 1 - .../+dagger-otel-metrics-fix.bugfix.md | 1 - .../+dr-paperless-backup.bugfix.md | 1 - .../changelog.d/+dr-paperless-redis.bugfix.md | 1 - .../changelog.d/+dr-recovery-2026-04.infra.md | 1 - .../+enhance-adding-a-service-tutorial.doc.md | 1 - .../+fix-blumeops-tasks-brackets.bugfix.md | 1 - .../+fix-flake-update-pipeline.bugfix.md | 1 - .../+fix-flyio-rate-limit-key.bugfix.md | 1 - .../+fix-unpoller-dashboard-uids.bugfix.md | 1 - .../+frigate-preview-quality.infra.md | 1 - docs/changelog.d/+ollama-0.20.4.infra.md | 1 - docs/changelog.d/+pin-tailscale-fly.bugfix.md | 1 - ...+review-compliance-reports-task.feature.md | 1 - docs/changelog.d/+review-gandi-doc.doc.md | 1 - .../+runner-logs-rewrite.bugfix.md | 1 - .../+seccomp-alloy-immich.infra.md | 1 - .../+service-versions-ref-card.doc.md | 1 - .../+services-check-show-all-alerts.bugfix.md | 1 - docs/changelog.d/+track-fly-versions.infra.md | 1 - docs/changelog.d/deploy-paperless.feature.md | 1 - .../grafana-sidecar-2.6.0.feature.md | 1 - .../changelog.d/local-forgejo-runner.infra.md | 1 - .../localize-kube-state-metrics.infra.md | 1 - .../miniflux-upgrade-and-ty.feature.md | 1 - .../miniflux-upgrade-and-ty.infra.md | 1 - .../native-dagger-containers.infra.md | 1 - .../teslamate-dagger-migration.infra.md | 1 - .../upgrade-navidrome-v0.61.1.feature.md | 1 - 32 files changed, 46 insertions(+), 31 deletions(-) delete mode 100644 docs/changelog.d/+argocd-v3.3.6.infra.md delete mode 100644 docs/changelog.d/+authentik-2026.2.2.infra.md delete mode 100644 docs/changelog.d/+dagger-otel-metrics-fix.bugfix.md delete mode 100644 docs/changelog.d/+dr-paperless-backup.bugfix.md delete mode 100644 docs/changelog.d/+dr-paperless-redis.bugfix.md delete mode 100644 docs/changelog.d/+dr-recovery-2026-04.infra.md delete mode 100644 docs/changelog.d/+enhance-adding-a-service-tutorial.doc.md delete mode 100644 docs/changelog.d/+fix-blumeops-tasks-brackets.bugfix.md delete mode 100644 docs/changelog.d/+fix-flake-update-pipeline.bugfix.md delete mode 100644 docs/changelog.d/+fix-flyio-rate-limit-key.bugfix.md delete mode 100644 docs/changelog.d/+fix-unpoller-dashboard-uids.bugfix.md delete mode 100644 docs/changelog.d/+frigate-preview-quality.infra.md delete mode 100644 docs/changelog.d/+ollama-0.20.4.infra.md delete mode 100644 docs/changelog.d/+pin-tailscale-fly.bugfix.md delete mode 100644 docs/changelog.d/+review-compliance-reports-task.feature.md delete mode 100644 docs/changelog.d/+review-gandi-doc.doc.md delete mode 100644 docs/changelog.d/+runner-logs-rewrite.bugfix.md delete mode 100644 docs/changelog.d/+seccomp-alloy-immich.infra.md delete mode 100644 docs/changelog.d/+service-versions-ref-card.doc.md delete mode 100644 docs/changelog.d/+services-check-show-all-alerts.bugfix.md delete mode 100644 docs/changelog.d/+track-fly-versions.infra.md delete mode 100644 docs/changelog.d/deploy-paperless.feature.md delete mode 100644 docs/changelog.d/grafana-sidecar-2.6.0.feature.md delete mode 100644 docs/changelog.d/local-forgejo-runner.infra.md delete mode 100644 docs/changelog.d/localize-kube-state-metrics.infra.md delete mode 100644 docs/changelog.d/miniflux-upgrade-and-ty.feature.md delete mode 100644 docs/changelog.d/miniflux-upgrade-and-ty.infra.md delete mode 100644 docs/changelog.d/native-dagger-containers.infra.md delete mode 100644 docs/changelog.d/teslamate-dagger-migration.infra.md delete mode 100644 docs/changelog.d/upgrade-navidrome-v0.61.1.feature.md diff --git a/CHANGELOG.md b/CHANGELOG.md index f66713c..3615e45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,51 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [v1.15.5] - 2026-04-14 + +### Features + +- Deploy Paperless-ngx document management system at paperless.ops.eblu.me with OCR, Authentik SSO, and NFS storage on sifaka. +- Add `ty` (Astral) Python typechecker to prek hooks, configured for Dagger SDK and container.py modules. Add `type: mise` to service-versions.yaml for tracking development tool versions (dagger, ansible-core, prek, pulumi, ty) through the standard service review process. +- Upgrade grafana-sidecar from 1.28.0 to 2.6.0, adding health probes and porting build to native Dagger container.py. +- Upgrade Navidrome to v0.61.1 — major artwork overhaul with per-disc cover art, rebuilt search engine (SQLite FTS5), server-managed transcoding, and WebP performance fix. +- Add `mise run review-compliance-reports` task for weekly compliance report review with muted/unmuted distinction and week-over-week delta + +### Bug Fixes + +- Add paperless database to borgmatic backup configuration. Previously the only service DB not included in nightly pg_dump backups. +- Fix Fly.io proxy rate limiting to key on real client IP instead of Fly's internal proxy IP, so crawlers no longer consume the shared rate limit bucket for all clients. +- Fix UnPoller (UniFi) Grafana dashboards failing to load due to UID exceeding Grafana 12's 40-character limit. +- Fix blumeops-tasks swallowing wiki-link brackets in task descriptions (rich markup escaping) +- Fix dagger flake-update pipeline: replace nonexistent `--exclude` flag with dynamic input discovery +- Fix services-check to display all firing alerts for a given alert name, not just the first one. +- Pin Fly.io proxy Tailscale to v1.94.1 — the `:stable` tag pulled v1.96.5 which has a MagicDNS regression (SERVFAIL on tailnet names), breaking all public routing through forge.eblu.me, docs.eblu.me, and cv.eblu.me. +- Rewrite `mise run runner-logs` CLI: list runs by run number (not task ID), drill into jobs per run, fetch logs via Forgejo web API instead of SSH+filesystem. Fixes broken log retrieval caused by incorrect hex path calculation and stale data directory. Added `--repo` to query any forge repo (e.g. sporks) and `--limit`/`-n` to control listing size (0 for all). +- Route Dagger build telemetry to Tempo, fixing OTEL metrics exporter warnings. +- Switch paperless redis sidecar from amd64-only nix-built `authentik-redis` image to upstream `valkey:8.1-alpine` (multi-arch). The nix image was previously running under QEMU emulation on arm64 minikube. + +### Infrastructure + +- Build forgejo-runner container locally via native Dagger pipeline instead of pulling from upstream. +- Build kube-state-metrics container locally (Dockerfile + nix) from forge mirror, replacing upstream registry.k8s.io image on both indri and ringtail. +- Upgrade miniflux from 2.2.17 to 2.2.19 and migrate from Dockerfile to native Dagger container.py build (second container after navidrome). Refactor `alpine_runtime()` with `create_user` parameter to support Alpine's built-in nobody user. Pin all mise.toml tool versions to explicit versions instead of "latest". +- Migrate Dagger module from .dagger/ to repo root (src/blumeops/) and replace docker_build() with native Dagger pipelines for container builds. Navidrome is the first container migrated, with full build error visibility. +- Migrate teslamate container build from legacy Dockerfile to native Dagger container.py. +- Add seccomp RuntimeDefault profiles to alloy-k8s and immich pods, resolving 4 unmuted Prowler findings +- Full DR recovery from power loss and minikube cluster rebuild. Validated bootstrap procedure, identified circular dependencies (forge.eblu.me, Zot/Authentik OIDC), Tailscale device name collision issues, and documented recovery steps for restart-indri. +- Set Frigate preview quality to CRF 8 (from default 1) to reduce preview file sizes and improve review timeline loading over NFS. +- Track Fly.io proxy component versions (Tailscale, nginx, Alloy) in service-versions.yaml with new `fly` service type. +- Upgrade ArgoCD from v3.3.2 to v3.3.6 (bug-fix patches), SHA-pin install manifest +- Upgrade authentik 2026.2.0 → 2026.2.2 (bug-fix patch release) +- Upgrade ollama from 0.17.5 to 0.20.4 (adds Gemma 4 support, benchmark tooling, Apple Silicon perf improvements) + +### Documentation + +- Delete outdated install-dagger-on-nix-runner card; add service-versions reference card; clean up zot.md and review-services.md links. +- Enhanced the adding-a-service tutorial with kustomization setup, corrected Tailscale ingress format, updated ArgoCD repoURL, and added a step for creating service reference cards. +- Review gandi.md: add missing forge.eblu.me CNAME, fix program description, stamp review date. + + ## [v1.15.4] - 2026-04-06 ### Infrastructure diff --git a/argocd/manifests/docs/deployment.yaml b/argocd/manifests/docs/deployment.yaml index e3cf51c..e46bd6b 100644 --- a/argocd/manifests/docs/deployment.yaml +++ b/argocd/manifests/docs/deployment.yaml @@ -30,7 +30,7 @@ spec: name: http env: - name: DOCS_RELEASE_URL - value: "https://forge.eblu.me/eblume/blumeops/releases/download/v1.15.4/docs-v1.15.4.tar.gz" + value: "https://forge.eblu.me/eblume/blumeops/releases/download/v1.15.5/docs-v1.15.5.tar.gz" resources: requests: memory: "64Mi" diff --git a/docs/changelog.d/+argocd-v3.3.6.infra.md b/docs/changelog.d/+argocd-v3.3.6.infra.md deleted file mode 100644 index 0a51216..0000000 --- a/docs/changelog.d/+argocd-v3.3.6.infra.md +++ /dev/null @@ -1 +0,0 @@ -Upgrade ArgoCD from v3.3.2 to v3.3.6 (bug-fix patches), SHA-pin install manifest diff --git a/docs/changelog.d/+authentik-2026.2.2.infra.md b/docs/changelog.d/+authentik-2026.2.2.infra.md deleted file mode 100644 index 7cb6e3f..0000000 --- a/docs/changelog.d/+authentik-2026.2.2.infra.md +++ /dev/null @@ -1 +0,0 @@ -Upgrade authentik 2026.2.0 → 2026.2.2 (bug-fix patch release) diff --git a/docs/changelog.d/+dagger-otel-metrics-fix.bugfix.md b/docs/changelog.d/+dagger-otel-metrics-fix.bugfix.md deleted file mode 100644 index 85475c2..0000000 --- a/docs/changelog.d/+dagger-otel-metrics-fix.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -Route Dagger build telemetry to Tempo, fixing OTEL metrics exporter warnings. diff --git a/docs/changelog.d/+dr-paperless-backup.bugfix.md b/docs/changelog.d/+dr-paperless-backup.bugfix.md deleted file mode 100644 index 4882d14..0000000 --- a/docs/changelog.d/+dr-paperless-backup.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -Add paperless database to borgmatic backup configuration. Previously the only service DB not included in nightly pg_dump backups. diff --git a/docs/changelog.d/+dr-paperless-redis.bugfix.md b/docs/changelog.d/+dr-paperless-redis.bugfix.md deleted file mode 100644 index 04f4ae3..0000000 --- a/docs/changelog.d/+dr-paperless-redis.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -Switch paperless redis sidecar from amd64-only nix-built `authentik-redis` image to upstream `valkey:8.1-alpine` (multi-arch). The nix image was previously running under QEMU emulation on arm64 minikube. diff --git a/docs/changelog.d/+dr-recovery-2026-04.infra.md b/docs/changelog.d/+dr-recovery-2026-04.infra.md deleted file mode 100644 index 62f5bbc..0000000 --- a/docs/changelog.d/+dr-recovery-2026-04.infra.md +++ /dev/null @@ -1 +0,0 @@ -Full DR recovery from power loss and minikube cluster rebuild. Validated bootstrap procedure, identified circular dependencies (forge.eblu.me, Zot/Authentik OIDC), Tailscale device name collision issues, and documented recovery steps for restart-indri. diff --git a/docs/changelog.d/+enhance-adding-a-service-tutorial.doc.md b/docs/changelog.d/+enhance-adding-a-service-tutorial.doc.md deleted file mode 100644 index 3571dbf..0000000 --- a/docs/changelog.d/+enhance-adding-a-service-tutorial.doc.md +++ /dev/null @@ -1 +0,0 @@ -Enhanced the adding-a-service tutorial with kustomization setup, corrected Tailscale ingress format, updated ArgoCD repoURL, and added a step for creating service reference cards. diff --git a/docs/changelog.d/+fix-blumeops-tasks-brackets.bugfix.md b/docs/changelog.d/+fix-blumeops-tasks-brackets.bugfix.md deleted file mode 100644 index faa7306..0000000 --- a/docs/changelog.d/+fix-blumeops-tasks-brackets.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -Fix blumeops-tasks swallowing wiki-link brackets in task descriptions (rich markup escaping) diff --git a/docs/changelog.d/+fix-flake-update-pipeline.bugfix.md b/docs/changelog.d/+fix-flake-update-pipeline.bugfix.md deleted file mode 100644 index 1ebae57..0000000 --- a/docs/changelog.d/+fix-flake-update-pipeline.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -Fix dagger flake-update pipeline: replace nonexistent `--exclude` flag with dynamic input discovery diff --git a/docs/changelog.d/+fix-flyio-rate-limit-key.bugfix.md b/docs/changelog.d/+fix-flyio-rate-limit-key.bugfix.md deleted file mode 100644 index 1473ab1..0000000 --- a/docs/changelog.d/+fix-flyio-rate-limit-key.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -Fix Fly.io proxy rate limiting to key on real client IP instead of Fly's internal proxy IP, so crawlers no longer consume the shared rate limit bucket for all clients. diff --git a/docs/changelog.d/+fix-unpoller-dashboard-uids.bugfix.md b/docs/changelog.d/+fix-unpoller-dashboard-uids.bugfix.md deleted file mode 100644 index f29e05f..0000000 --- a/docs/changelog.d/+fix-unpoller-dashboard-uids.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -Fix UnPoller (UniFi) Grafana dashboards failing to load due to UID exceeding Grafana 12's 40-character limit. diff --git a/docs/changelog.d/+frigate-preview-quality.infra.md b/docs/changelog.d/+frigate-preview-quality.infra.md deleted file mode 100644 index 9b13333..0000000 --- a/docs/changelog.d/+frigate-preview-quality.infra.md +++ /dev/null @@ -1 +0,0 @@ -Set Frigate preview quality to CRF 8 (from default 1) to reduce preview file sizes and improve review timeline loading over NFS. diff --git a/docs/changelog.d/+ollama-0.20.4.infra.md b/docs/changelog.d/+ollama-0.20.4.infra.md deleted file mode 100644 index e93c8c7..0000000 --- a/docs/changelog.d/+ollama-0.20.4.infra.md +++ /dev/null @@ -1 +0,0 @@ -Upgrade ollama from 0.17.5 to 0.20.4 (adds Gemma 4 support, benchmark tooling, Apple Silicon perf improvements) diff --git a/docs/changelog.d/+pin-tailscale-fly.bugfix.md b/docs/changelog.d/+pin-tailscale-fly.bugfix.md deleted file mode 100644 index 59f1e30..0000000 --- a/docs/changelog.d/+pin-tailscale-fly.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -Pin Fly.io proxy Tailscale to v1.94.1 — the `:stable` tag pulled v1.96.5 which has a MagicDNS regression (SERVFAIL on tailnet names), breaking all public routing through forge.eblu.me, docs.eblu.me, and cv.eblu.me. diff --git a/docs/changelog.d/+review-compliance-reports-task.feature.md b/docs/changelog.d/+review-compliance-reports-task.feature.md deleted file mode 100644 index 13cec0a..0000000 --- a/docs/changelog.d/+review-compliance-reports-task.feature.md +++ /dev/null @@ -1 +0,0 @@ -Add `mise run review-compliance-reports` task for weekly compliance report review with muted/unmuted distinction and week-over-week delta diff --git a/docs/changelog.d/+review-gandi-doc.doc.md b/docs/changelog.d/+review-gandi-doc.doc.md deleted file mode 100644 index 1f85ce0..0000000 --- a/docs/changelog.d/+review-gandi-doc.doc.md +++ /dev/null @@ -1 +0,0 @@ -Review gandi.md: add missing forge.eblu.me CNAME, fix program description, stamp review date. diff --git a/docs/changelog.d/+runner-logs-rewrite.bugfix.md b/docs/changelog.d/+runner-logs-rewrite.bugfix.md deleted file mode 100644 index 7962ac4..0000000 --- a/docs/changelog.d/+runner-logs-rewrite.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -Rewrite `mise run runner-logs` CLI: list runs by run number (not task ID), drill into jobs per run, fetch logs via Forgejo web API instead of SSH+filesystem. Fixes broken log retrieval caused by incorrect hex path calculation and stale data directory. Added `--repo` to query any forge repo (e.g. sporks) and `--limit`/`-n` to control listing size (0 for all). diff --git a/docs/changelog.d/+seccomp-alloy-immich.infra.md b/docs/changelog.d/+seccomp-alloy-immich.infra.md deleted file mode 100644 index 81480f3..0000000 --- a/docs/changelog.d/+seccomp-alloy-immich.infra.md +++ /dev/null @@ -1 +0,0 @@ -Add seccomp RuntimeDefault profiles to alloy-k8s and immich pods, resolving 4 unmuted Prowler findings diff --git a/docs/changelog.d/+service-versions-ref-card.doc.md b/docs/changelog.d/+service-versions-ref-card.doc.md deleted file mode 100644 index 95cb07c..0000000 --- a/docs/changelog.d/+service-versions-ref-card.doc.md +++ /dev/null @@ -1 +0,0 @@ -Delete outdated install-dagger-on-nix-runner card; add service-versions reference card; clean up zot.md and review-services.md links. diff --git a/docs/changelog.d/+services-check-show-all-alerts.bugfix.md b/docs/changelog.d/+services-check-show-all-alerts.bugfix.md deleted file mode 100644 index 221748a..0000000 --- a/docs/changelog.d/+services-check-show-all-alerts.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -Fix services-check to display all firing alerts for a given alert name, not just the first one. diff --git a/docs/changelog.d/+track-fly-versions.infra.md b/docs/changelog.d/+track-fly-versions.infra.md deleted file mode 100644 index 2ec5b87..0000000 --- a/docs/changelog.d/+track-fly-versions.infra.md +++ /dev/null @@ -1 +0,0 @@ -Track Fly.io proxy component versions (Tailscale, nginx, Alloy) in service-versions.yaml with new `fly` service type. diff --git a/docs/changelog.d/deploy-paperless.feature.md b/docs/changelog.d/deploy-paperless.feature.md deleted file mode 100644 index 07b7899..0000000 --- a/docs/changelog.d/deploy-paperless.feature.md +++ /dev/null @@ -1 +0,0 @@ -Deploy Paperless-ngx document management system at paperless.ops.eblu.me with OCR, Authentik SSO, and NFS storage on sifaka. diff --git a/docs/changelog.d/grafana-sidecar-2.6.0.feature.md b/docs/changelog.d/grafana-sidecar-2.6.0.feature.md deleted file mode 100644 index cb729ee..0000000 --- a/docs/changelog.d/grafana-sidecar-2.6.0.feature.md +++ /dev/null @@ -1 +0,0 @@ -Upgrade grafana-sidecar from 1.28.0 to 2.6.0, adding health probes and porting build to native Dagger container.py. diff --git a/docs/changelog.d/local-forgejo-runner.infra.md b/docs/changelog.d/local-forgejo-runner.infra.md deleted file mode 100644 index ffef62e..0000000 --- a/docs/changelog.d/local-forgejo-runner.infra.md +++ /dev/null @@ -1 +0,0 @@ -Build forgejo-runner container locally via native Dagger pipeline instead of pulling from upstream. diff --git a/docs/changelog.d/localize-kube-state-metrics.infra.md b/docs/changelog.d/localize-kube-state-metrics.infra.md deleted file mode 100644 index f6a709a..0000000 --- a/docs/changelog.d/localize-kube-state-metrics.infra.md +++ /dev/null @@ -1 +0,0 @@ -Build kube-state-metrics container locally (Dockerfile + nix) from forge mirror, replacing upstream registry.k8s.io image on both indri and ringtail. diff --git a/docs/changelog.d/miniflux-upgrade-and-ty.feature.md b/docs/changelog.d/miniflux-upgrade-and-ty.feature.md deleted file mode 100644 index fa88736..0000000 --- a/docs/changelog.d/miniflux-upgrade-and-ty.feature.md +++ /dev/null @@ -1 +0,0 @@ -Add `ty` (Astral) Python typechecker to prek hooks, configured for Dagger SDK and container.py modules. Add `type: mise` to service-versions.yaml for tracking development tool versions (dagger, ansible-core, prek, pulumi, ty) through the standard service review process. diff --git a/docs/changelog.d/miniflux-upgrade-and-ty.infra.md b/docs/changelog.d/miniflux-upgrade-and-ty.infra.md deleted file mode 100644 index 1c124f5..0000000 --- a/docs/changelog.d/miniflux-upgrade-and-ty.infra.md +++ /dev/null @@ -1 +0,0 @@ -Upgrade miniflux from 2.2.17 to 2.2.19 and migrate from Dockerfile to native Dagger container.py build (second container after navidrome). Refactor `alpine_runtime()` with `create_user` parameter to support Alpine's built-in nobody user. Pin all mise.toml tool versions to explicit versions instead of "latest". diff --git a/docs/changelog.d/native-dagger-containers.infra.md b/docs/changelog.d/native-dagger-containers.infra.md deleted file mode 100644 index a207ea2..0000000 --- a/docs/changelog.d/native-dagger-containers.infra.md +++ /dev/null @@ -1 +0,0 @@ -Migrate Dagger module from .dagger/ to repo root (src/blumeops/) and replace docker_build() with native Dagger pipelines for container builds. Navidrome is the first container migrated, with full build error visibility. diff --git a/docs/changelog.d/teslamate-dagger-migration.infra.md b/docs/changelog.d/teslamate-dagger-migration.infra.md deleted file mode 100644 index 7938365..0000000 --- a/docs/changelog.d/teslamate-dagger-migration.infra.md +++ /dev/null @@ -1 +0,0 @@ -Migrate teslamate container build from legacy Dockerfile to native Dagger container.py. diff --git a/docs/changelog.d/upgrade-navidrome-v0.61.1.feature.md b/docs/changelog.d/upgrade-navidrome-v0.61.1.feature.md deleted file mode 100644 index 1f7a8a2..0000000 --- a/docs/changelog.d/upgrade-navidrome-v0.61.1.feature.md +++ /dev/null @@ -1 +0,0 @@ -Upgrade Navidrome to v0.61.1 — major artwork overhaul with per-disc cover art, rebuilt search engine (SQLite FTS5), server-managed transcoding, and WebP performance fix. From 04b44b350b34cc52f2b1694013175bc338fa6855 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Tue, 14 Apr 2026 11:45:00 -0700 Subject: [PATCH 259/430] Add changelog for ArgoCD token rotation after DR Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/changelog.d/+rotate-argocd-token-post-dr.bugfix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 docs/changelog.d/+rotate-argocd-token-post-dr.bugfix.md diff --git a/docs/changelog.d/+rotate-argocd-token-post-dr.bugfix.md b/docs/changelog.d/+rotate-argocd-token-post-dr.bugfix.md new file mode 100644 index 0000000..4c2229a --- /dev/null +++ b/docs/changelog.d/+rotate-argocd-token-post-dr.bugfix.md @@ -0,0 +1 @@ +Rotate ArgoCD workflow-bot token and admin password after DR rebuild invalidated signing keys, fixing build-blumeops workflow failures. From 8c2f035e6d7c34315c6c828aca43ca543340a1b2 Mon Sep 17 00:00:00 2001 From: Forgejo Actions Date: Tue, 14 Apr 2026 11:46:42 -0700 Subject: [PATCH 260/430] Update docs release to v1.15.6 - Built changelog from towncrier fragments [skip ci] --- CHANGELOG.md | 7 +++++++ argocd/manifests/docs/deployment.yaml | 2 +- docs/changelog.d/+rotate-argocd-token-post-dr.bugfix.md | 1 - 3 files changed, 8 insertions(+), 2 deletions(-) delete mode 100644 docs/changelog.d/+rotate-argocd-token-post-dr.bugfix.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 3615e45..e218bc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [v1.15.6] - 2026-04-14 + +### Bug Fixes + +- Rotate ArgoCD workflow-bot token and admin password after DR rebuild invalidated signing keys, fixing build-blumeops workflow failures. + + ## [v1.15.5] - 2026-04-14 ### Features diff --git a/argocd/manifests/docs/deployment.yaml b/argocd/manifests/docs/deployment.yaml index e46bd6b..67de0c2 100644 --- a/argocd/manifests/docs/deployment.yaml +++ b/argocd/manifests/docs/deployment.yaml @@ -30,7 +30,7 @@ spec: name: http env: - name: DOCS_RELEASE_URL - value: "https://forge.eblu.me/eblume/blumeops/releases/download/v1.15.5/docs-v1.15.5.tar.gz" + value: "https://forge.eblu.me/eblume/blumeops/releases/download/v1.15.6/docs-v1.15.6.tar.gz" resources: requests: memory: "64Mi" diff --git a/docs/changelog.d/+rotate-argocd-token-post-dr.bugfix.md b/docs/changelog.d/+rotate-argocd-token-post-dr.bugfix.md deleted file mode 100644 index 4c2229a..0000000 --- a/docs/changelog.d/+rotate-argocd-token-post-dr.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -Rotate ArgoCD workflow-bot token and admin password after DR rebuild invalidated signing keys, fixing build-blumeops workflow failures. From be30668eefd9b8a238159314a138975fbe155813 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Tue, 14 Apr 2026 13:00:44 -0700 Subject: [PATCH 261/430] Automate Prowler MANUAL finding verification (#335) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Adds automated node-level verification to `review-compliance-reports`: kubelet file perms/ownership, kubelet config args, etcd CA separation, RBAC cluster-admin bindings - Mutes the 14 MANUAL Prowler findings via new `manual-node-checks.yaml` mutelist file - New `node-config-automated-verification` compensating control documents the approach - Script fails loudly (red FAIL + verdict panel) if any check deviates from expected values ## Test plan - [x] `mise run review-compliance-reports` — all 12 node checks PASS - [x] Injected bad expected value (perms 400 vs actual 600) — FAIL rendered correctly - [x] Fixed colon-in-binding-name bug (kubeadm:cluster-admins) with tab-separated jsonpath - [ ] After merge: sync prowler mutelist ConfigMap and verify next scan shows 0 MANUAL findings ## Note Prowler coverage is minikube-indri only — ringtail/k3s is a known gap tracked separately. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/335 --- argocd/manifests/prowler/kustomization.yaml | 1 + .../prowler/mutelist/manual-node-checks.yaml | 59 +++++ compensating-controls.yaml | 16 ++ .../automate-manual-prowler-checks.infra.md | 1 + mise-tasks/review-compliance-reports | 202 +++++++++++++++++- 5 files changed, 278 insertions(+), 1 deletion(-) create mode 100644 argocd/manifests/prowler/mutelist/manual-node-checks.yaml create mode 100644 docs/changelog.d/automate-manual-prowler-checks.infra.md diff --git a/argocd/manifests/prowler/kustomization.yaml b/argocd/manifests/prowler/kustomization.yaml index 162a2ad..b6b11fe 100644 --- a/argocd/manifests/prowler/kustomization.yaml +++ b/argocd/manifests/prowler/kustomization.yaml @@ -21,6 +21,7 @@ configMapGenerator: - mutelist/apiserver.yaml - mutelist/control-plane.yaml - mutelist/core-pod-security.yaml + - mutelist/manual-node-checks.yaml - mutelist/rbac.yaml images: diff --git a/argocd/manifests/prowler/mutelist/manual-node-checks.yaml b/argocd/manifests/prowler/mutelist/manual-node-checks.yaml new file mode 100644 index 0000000..9c8354d --- /dev/null +++ b/argocd/manifests/prowler/mutelist/manual-node-checks.yaml @@ -0,0 +1,59 @@ +# Node-level and RBAC checks that Prowler reports as MANUAL because it +# cannot evaluate them from inside a pod. Compensated by automated +# verification in `mise run review-compliance-reports`, which SSHes into +# the minikube node and checks each condition directly every week. +Mutelist: + Accounts: + "*": + Checks: + "etcd_unique_ca": + Regions: ["*"] + Resources: ["^etcd-minikube$"] + Description: "CC: node-config-automated-verification. Etcd CA fingerprint verified different from cluster CA by review-compliance-reports." + "kubelet_conf_file_ownership": + Regions: ["*"] + Resources: ["^kubelet-config$"] + Description: "CC: node-config-automated-verification. File ownership verified root:root by review-compliance-reports." + "kubelet_conf_file_permissions": + Regions: ["*"] + Resources: ["^kubelet-config$"] + Description: "CC: node-config-automated-verification. File permissions verified 600 by review-compliance-reports." + "kubelet_config_yaml_ownership": + Regions: ["*"] + Resources: ["^kubelet-config$"] + Description: "CC: node-config-automated-verification. File ownership verified root:root by review-compliance-reports." + "kubelet_config_yaml_permissions": + Regions: ["*"] + Resources: ["^kubelet-config$"] + Description: "CC: node-config-automated-verification. File permissions verified 644 by review-compliance-reports." + "kubelet_service_file_ownership_root": + Regions: ["*"] + Resources: ["^kubelet-config$"] + Description: "CC: node-config-automated-verification. File ownership verified root:root by review-compliance-reports." + "kubelet_service_file_permissions": + Regions: ["*"] + Resources: ["^kubelet-config$"] + Description: "CC: node-config-automated-verification. File permissions verified 644 by review-compliance-reports." + "kubelet_disable_read_only_port": + Regions: ["*"] + Resources: ["^kubelet-config$"] + Description: "CC: node-config-automated-verification. readOnlyPort absence (defaults to 0) verified by review-compliance-reports." + "kubelet_event_record_qps": + Regions: ["*"] + Resources: ["^kubelet-config$"] + Description: "CC: node-config-automated-verification. eventRecordQPS absence (defaults to 5) verified by review-compliance-reports." + "kubelet_manage_iptables": + Regions: ["*"] + Resources: ["^kubelet-config$"] + Description: "CC: node-config-automated-verification. makeIPTablesUtilChains absence (defaults to true) verified by review-compliance-reports." + "kubelet_strong_ciphers_only": + Regions: ["*"] + Resources: ["^kubelet-config$"] + Description: "CC: node-config-automated-verification, tailscale-network-isolation. Go default ciphers used; all traffic WireGuard-encrypted via tailnet." + "rbac_cluster_admin_usage": + Regions: ["*"] + Resources: + - "^cluster-admin$" + - "^kubeadm:cluster-admins$" + - "^minikube-rbac$" + Description: "CC: node-config-automated-verification, single-user-cluster. Only built-in/minikube cluster-admin bindings present; verified by review-compliance-reports." diff --git a/compensating-controls.yaml b/compensating-controls.yaml index ae4865b..459a991 100644 --- a/compensating-controls.yaml +++ b/compensating-controls.yaml @@ -116,6 +116,22 @@ controls: for both init and runtime containers. If fsGroup alone can handle PVC ownership, remove init-chown-data and this control. + - id: node-config-automated-verification + description: >- + Prowler reports certain node-level checks as MANUAL because it runs + inside a pod and cannot evaluate kubelet file permissions, kubelet + config arguments, etcd CA separation, or cluster-admin RBAC bindings. + The review-compliance-reports script SSHes into the minikube node + weekly and programmatically verifies each condition, failing loudly + if any check deviates from expected values. + created: 2026-04-14 + last-reviewed: 2026-04-14 + notes: >- + Verification runs as part of 'mise run review-compliance-reports'. + If minikube node is unreachable, all checks report as FAIL. If new + MANUAL findings appear in Prowler, add corresponding verification + logic to the script and update the mutelist. + - id: observability-stack-audit description: >- Alloy collects pod logs and ships them to Loki, providing an diff --git a/docs/changelog.d/automate-manual-prowler-checks.infra.md b/docs/changelog.d/automate-manual-prowler-checks.infra.md new file mode 100644 index 0000000..07f132b --- /dev/null +++ b/docs/changelog.d/automate-manual-prowler-checks.infra.md @@ -0,0 +1 @@ +Automate verification of Prowler MANUAL findings (kubelet file perms, kubelet config, etcd CA, RBAC cluster-admin) in `review-compliance-reports` and mute them with `node-config-automated-verification` compensating control. diff --git a/mise-tasks/review-compliance-reports b/mise-tasks/review-compliance-reports index 075302d..080271c 100755 --- a/mise-tasks/review-compliance-reports +++ b/mise-tasks/review-compliance-reports @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.12" -# dependencies = ["rich>=14.0.0", "typer>=0.24.0"] +# dependencies = ["rich>=14.0.0", "typer>=0.24.0", "pyyaml>=6.0"] # /// #MISE description="Summarize the latest Prowler and Kingfisher compliance reports from sifaka" #USAGE flag "--full" help="Show all unmuted failures, not just new ones" @@ -112,6 +112,200 @@ def severity_sort(r: dict) -> int: return SEVERITY_ORDER.index(sev) if sev in SEVERITY_ORDER else 99 +def _ssh_minikube(cmd: str, timeout: int = 15) -> subprocess.CompletedProcess: + """Run a command inside the minikube node via SSH.""" + return subprocess.run( + ["ssh", "indri", f"minikube ssh -- {cmd}"], + capture_output=True, + text=True, + timeout=timeout, + ) + + +def _kubectl(args: str, timeout: int = 15) -> subprocess.CompletedProcess: + """Run a kubectl command against minikube-indri.""" + return subprocess.run( + ["kubectl", "--context=minikube-indri"] + args.split(), + capture_output=True, + text=True, + timeout=timeout, + ) + + +def run_node_verification(console: Console) -> None: + """Verify node-level conditions that Prowler reports as MANUAL. + + Compensating control: node-config-automated-verification + """ + checks: list[tuple[str, str, bool]] = [] # (name, detail, passed) + + # --- File ownership and permissions --- + file_expectations = [ + ("kubelet.conf ownership", "/etc/kubernetes/kubelet.conf", "root:root", None), + ("kubelet.conf permissions", "/etc/kubernetes/kubelet.conf", None, "600"), + ("config.yaml ownership", "/var/lib/kubelet/config.yaml", "root:root", None), + ("config.yaml permissions", "/var/lib/kubelet/config.yaml", None, "644"), + ("kubelet service ownership", "/etc/systemd/system/kubelet.service.d/10-kubeadm.conf", "root:root", None), + ("kubelet service permissions", "/etc/systemd/system/kubelet.service.d/10-kubeadm.conf", None, "644"), + ] + + for name, path, expected_owner, expected_perms in file_expectations: + if expected_owner: + result = _ssh_minikube(f'"sudo stat -c %U:%G {path}"') + else: + result = _ssh_minikube(f'"sudo stat -c %a {path}"') + + if result.returncode != 0: + checks.append((name, f"could not stat {path}", False)) + else: + actual = result.stdout.strip() + expected = expected_owner or expected_perms + passed = actual == expected + checks.append((name, f"{actual} (expected {expected})", passed)) + + # --- Kubelet config arguments --- + kubelet_result = _ssh_minikube('"sudo cat /var/lib/kubelet/config.yaml"') + if kubelet_result.returncode != 0: + checks.append(("kubelet config", "could not read config.yaml", False)) + else: + import yaml as _yaml + + try: + kubelet_cfg = _yaml.safe_load(kubelet_result.stdout) or {} + except Exception: + kubelet_cfg = {} + checks.append(("kubelet config parse", "failed to parse config.yaml", False)) + + # readOnlyPort: absent or 0 is safe + rop = kubelet_cfg.get("readOnlyPort") + checks.append(( + "readOnlyPort", + f"{rop!r} (absent or 0 is safe)", + rop is None or rop == 0, + )) + + # makeIPTablesUtilChains: absent (defaults true) or true + miu = kubelet_cfg.get("makeIPTablesUtilChains") + checks.append(( + "makeIPTablesUtilChains", + f"{miu!r} (absent or true is safe)", + miu is None or miu is True, + )) + + # eventRecordQPS: absent (defaults 5) or > 0 + erq = kubelet_cfg.get("eventRecordQPS") + checks.append(( + "eventRecordQPS", + f"{erq!r} (absent or > 0 is safe)", + erq is None or (isinstance(erq, (int, float)) and erq > 0), + )) + + # tlsCipherSuites: absent uses Go defaults (acceptable) + tcs = kubelet_cfg.get("tlsCipherSuites") + checks.append(( + "tlsCipherSuites", + "Go defaults" if tcs is None else f"{tcs!r}", + True, # Go defaults are acceptable; explicit suites also fine + )) + + # --- Etcd CA separation --- + etcd_fp = _ssh_minikube( + '"sudo openssl x509 -in /var/lib/minikube/certs/etcd/ca.crt -noout -fingerprint -sha256"' + ) + cluster_fp = _ssh_minikube( + '"sudo openssl x509 -in /var/lib/minikube/certs/ca.crt -noout -fingerprint -sha256"' + ) + if etcd_fp.returncode != 0 or cluster_fp.returncode != 0: + checks.append(("etcd CA separation", "could not read certificates", False)) + else: + etcd_hash = etcd_fp.stdout.strip() + cluster_hash = cluster_fp.stdout.strip() + different = etcd_hash != cluster_hash + checks.append(( + "etcd CA separation", + "different CAs" if different else "SAME CA (unexpected)", + different, + )) + + # --- RBAC cluster-admin bindings --- + expected_bindings = {"cluster-admin", "kubeadm:cluster-admins", "minikube-rbac"} + # Use a jsonpath that emits "name\troleRef" pairs to avoid N+1 queries + # Tab-separated because binding names can contain colons (e.g. kubeadm:cluster-admins) + rb_result = subprocess.run( + [ + "kubectl", "--context=minikube-indri", + "get", "clusterrolebindings", + "-o", "jsonpath={range .items[*]}{.metadata.name}{'\\t'}{.roleRef.name}{'\\n'}{end}", + ], + capture_output=True, + text=True, + timeout=15, + ) + if rb_result.returncode != 0: + checks.append(("cluster-admin bindings", "kubectl failed", False)) + else: + admin_bindings: set[str] = set() + for line in rb_result.stdout.strip().splitlines(): + if "\t" in line: + name, role = line.split("\t", 1) + if role == "cluster-admin": + admin_bindings.add(name) + + unexpected = admin_bindings - expected_bindings + if unexpected: + checks.append(( + "cluster-admin bindings", + f"unexpected: {', '.join(sorted(unexpected))}", + False, + )) + else: + checks.append(( + "cluster-admin bindings", + f"only expected: {', '.join(sorted(admin_bindings))}", + True, + )) + + # --- Display results --- + all_passed = all(passed for _, _, passed in checks) + table = Table( + show_header=True, + header_style="bold", + title="Node Verification (CC: node-config-automated-verification)", + ) + table.add_column("Check") + table.add_column("Detail") + table.add_column("Result", justify="center") + + for name, detail, passed in checks: + status = "[green]PASS[/green]" if passed else "[bold red]FAIL[/bold red]" + table.add_row(name, detail, status) + + console.print(table) + console.print() + + if all_passed: + console.print( + Panel( + "[bold green]All node-level checks passed.[/bold green] " + "Muted MANUAL findings are verified.", + title="Node Verification Verdict", + border_style="green", + ) + ) + else: + failed = [(n, d) for n, d, p in checks if not p] + console.print( + Panel( + f"[bold red]{len(failed)} node-level check(s) FAILED.[/bold red]\n" + "Review the failures above — muted MANUAL findings may no longer " + "be valid.", + title="Node Verification Verdict", + border_style="red", + ) + ) + console.print() + + def main( full: Annotated[ bool, typer.Option(help="Show all unmuted failures, not just new ones") @@ -343,6 +537,12 @@ def main( ) ) + # --- Node-level MANUAL check verification --- + # Compensating control: node-config-automated-verification + # These checks verify conditions Prowler reports as MANUAL because it + # runs inside a pod and cannot evaluate them directly. + run_node_verification(console) + # --- Kingfisher secret scanning --- # TODO: Kingfisher currently only outputs HTML. Once JSON or CSV output # is supported upstream (contribute from our spork), add parsing here: From 6b690eb03374ab6ff86dd639680fbc5d530bb4ab Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Tue, 14 Apr 2026 13:07:52 -0700 Subject: [PATCH 262/430] Review CC sso-gated-admin-tools: scope to ArgoCD only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removed Grafana from the control description — no Prowler finding references it. Tightened scope to match actual usage (ArgoCD wildcard RBAC mute). Added workflow-bot scoping note. Co-Authored-By: Claude Opus 4.6 (1M context) --- compensating-controls.yaml | 14 ++++++++------ .../+review-sso-gated-admin-tools.misc.md | 1 + 2 files changed, 9 insertions(+), 6 deletions(-) create mode 100644 docs/changelog.d/+review-sso-gated-admin-tools.misc.md diff --git a/compensating-controls.yaml b/compensating-controls.yaml index 459a991..b441341 100644 --- a/compensating-controls.yaml +++ b/compensating-controls.yaml @@ -59,14 +59,16 @@ controls: - id: sso-gated-admin-tools description: >- - ArgoCD and Grafana require SSO authentication via Authentik OIDC. - Wildcard RBAC in ArgoCD is mitigated by requiring authenticated - identity before any API access. + ArgoCD requires SSO authentication via Authentik OIDC. Wildcard + RBAC roles are mitigated by requiring authenticated identity + before any API access. created: 2026-03-30 - last-reviewed: 2026-03-30 + last-reviewed: 2026-04-14 notes: >- - Verify Authentik provider config and that anonymous access is - disabled. Check ArgoCD --auth-token isn't leaked. + Verify Authentik OIDC provider config for ArgoCD and that + anonymous access is disabled. Check ArgoCD --auth-token isn't + leaked. The workflow-bot API key account is scoped to sync/get + only. - id: operator-managed-pods description: >- diff --git a/docs/changelog.d/+review-sso-gated-admin-tools.misc.md b/docs/changelog.d/+review-sso-gated-admin-tools.misc.md new file mode 100644 index 0000000..7e337df --- /dev/null +++ b/docs/changelog.d/+review-sso-gated-admin-tools.misc.md @@ -0,0 +1 @@ +Review compensating control `sso-gated-admin-tools`: tightened scope to ArgoCD only, removed Grafana reference. From 7c1cd11e452c7eb1a284b3b0ae0ced5bf47d3042 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Tue, 14 Apr 2026 13:45:28 -0700 Subject: [PATCH 263/430] Upgrade Prowler to 5.23.0, remove registry workaround (#336) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Upgrade Prowler from 5.22.0 to 5.23.0 - Remove the `enumerate-images` init container workaround from `cronjob-image-scan.yaml` - Use native `--registry` and `--image-filter` flags now that upstream fix (PR prowler-cloud/prowler#10470) is released The init container was a workaround for prowler-cloud/prowler#10457 where `--registry` args weren't forwarded to the provider constructor. We wrote the fix, it was merged, and v5.23.0 includes it. ## Test plan - [ ] Build new container (`mise run container-release prowler 5.23.0`) - [ ] Update kustomization.yaml with new image tag - [ ] Sync prowler ArgoCD app from branch - [ ] Manually trigger image scan job and verify `--registry` works natively - [ ] Verify CIS and IaC scan cronjobs still work 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/336 --- .../manifests/prowler/cronjob-image-scan.yaml | 41 +------------------ argocd/manifests/prowler/kustomization.yaml | 2 +- containers/prowler/Dockerfile | 2 +- .../changelog.d/upgrade-prowler-5.23.infra.md | 1 + service-versions.yaml | 4 +- 5 files changed, 7 insertions(+), 43 deletions(-) create mode 100644 docs/changelog.d/upgrade-prowler-5.23.infra.md diff --git a/argocd/manifests/prowler/cronjob-image-scan.yaml b/argocd/manifests/prowler/cronjob-image-scan.yaml index 84df1e0..b779d08 100644 --- a/argocd/manifests/prowler/cronjob-image-scan.yaml +++ b/argocd/manifests/prowler/cronjob-image-scan.yaml @@ -15,39 +15,6 @@ spec: securityContext: seccompProfile: type: RuntimeDefault - initContainers: - # Workaround: Prowler's --registry flag is broken (registry args - # not passed to provider constructor). Generate image list from - # zot catalog API instead. - # See: https://github.com/prowler-cloud/prowler/issues/10457 - # Fix merged upstream (PR #10470, 2026-03-30) but not yet in a - # release (latest: 5.22.0). Remove this initContainer once a - # release includes the fix and we upgrade. - - name: enumerate-images - image: registry.ops.eblu.me/blumeops/prowler:kustomized - command: ["python3", "-c"] - args: - - | - import json, urllib.request - - REGISTRY = "https://registry.ops.eblu.me" - catalog = json.loads(urllib.request.urlopen(f"{REGISTRY}/v2/_catalog").read()) - images = [] - for repo in catalog["repositories"]: - if not repo.startswith("blumeops/"): - continue - tags = json.loads(urllib.request.urlopen(f"{REGISTRY}/v2/{repo}/tags/list").read()) - for tag in tags.get("tags") or []: - images.append(f"registry.ops.eblu.me/{repo}:{tag}") - - with open("/shared/images.txt", "w") as f: - f.write("\n".join(images) + "\n") - print(f"Discovered {len(images)} images") - for img in images: - print(img) - volumeMounts: - - name: shared - mountPath: /shared containers: - name: prowler image: registry.ops.eblu.me/blumeops/prowler:kustomized @@ -57,20 +24,16 @@ spec: DATEDIR=/reports/prowler-images/$(date +%Y-%m-%d) mkdir -p "$DATEDIR" prowler image \ - --image-list /shared/images.txt \ + --registry https://registry.ops.eblu.me \ + --image-filter "^blumeops/" \ -z \ --output-formats html csv json-ocsf \ --output-directory "$DATEDIR" volumeMounts: - name: reports mountPath: /reports - - name: shared - mountPath: /shared - readOnly: true restartPolicy: OnFailure volumes: - name: reports persistentVolumeClaim: claimName: prowler-reports - - name: shared - emptyDir: {} diff --git a/argocd/manifests/prowler/kustomization.yaml b/argocd/manifests/prowler/kustomization.yaml index b6b11fe..fb5f233 100644 --- a/argocd/manifests/prowler/kustomization.yaml +++ b/argocd/manifests/prowler/kustomization.yaml @@ -26,4 +26,4 @@ configMapGenerator: images: - name: registry.ops.eblu.me/blumeops/prowler - newTag: v5.22.0-6960243 + newTag: v5.23.0-d05b503 diff --git a/containers/prowler/Dockerfile b/containers/prowler/Dockerfile index 7cafd17..bd74bdb 100644 --- a/containers/prowler/Dockerfile +++ b/containers/prowler/Dockerfile @@ -1,7 +1,7 @@ # Prowler CIS scanner — slim build for Kubernetes, image, and IaC providers # Strips PowerShell (M365) and dashboard dependencies from upstream # Includes Trivy for image vulnerability and IaC scanning -ARG CONTAINER_APP_VERSION=5.22.0 +ARG CONTAINER_APP_VERSION=5.23.0 FROM python:3.12-slim-bookworm AS build diff --git a/docs/changelog.d/upgrade-prowler-5.23.infra.md b/docs/changelog.d/upgrade-prowler-5.23.infra.md new file mode 100644 index 0000000..df2d0ab --- /dev/null +++ b/docs/changelog.d/upgrade-prowler-5.23.infra.md @@ -0,0 +1 @@ +Upgrade Prowler from 5.22.0 to 5.23.0; remove init container workaround for broken `--registry` flag (upstream fix in PR #10470). diff --git a/service-versions.yaml b/service-versions.yaml index e7e2ac9..f6f3be4 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -310,8 +310,8 @@ services: - name: prowler type: argocd - last-reviewed: 2026-03-24 - current-version: "5.22.0" + last-reviewed: 2026-04-14 + current-version: "5.23.0" upstream-source: https://github.com/prowler-cloud/prowler/releases notes: CIS Kubernetes Benchmark scanner; weekly CronJob on minikube-indri From 30ed018fd8fd59cf3fcf5c80a4a643e66f530015 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Tue, 14 Apr 2026 13:51:26 -0700 Subject: [PATCH 264/430] Update prowler image tag to v5.23.0-7c1cd11 [main] Co-Authored-By: Claude Opus 4.6 (1M context) --- argocd/manifests/prowler/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/argocd/manifests/prowler/kustomization.yaml b/argocd/manifests/prowler/kustomization.yaml index fb5f233..7024aff 100644 --- a/argocd/manifests/prowler/kustomization.yaml +++ b/argocd/manifests/prowler/kustomization.yaml @@ -26,4 +26,4 @@ configMapGenerator: images: - name: registry.ops.eblu.me/blumeops/prowler - newTag: v5.23.0-d05b503 + newTag: v5.23.0-7c1cd11 From 519175c6725e936a07af6951d1a73e3845d28b68 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 15 Apr 2026 07:23:46 -0700 Subject: [PATCH 265/430] Fix borgmatic LaunchAgent TCC dialog hang by removing mise wrapper LaunchAgents now call borgmatic directly at its mise-installed path instead of routing through `mise x`, which triggered macOS TCC permission dialogs (e.g. "mise wants to access Documents") that hung headless sessions and caused backup failures. Also adds `mise install` to the ansible role so borgmatic installation is fully managed, and pins the version in both mise.toml and the role defaults. Co-Authored-By: Claude Opus 4.6 (1M context) --- ansible/roles/borgmatic/defaults/main.yml | 10 ++++++++++ ansible/roles/borgmatic/tasks/main.yml | 9 +++++++-- .../borgmatic/templates/borgmatic-photos.plist.j2 | 5 +---- ansible/roles/borgmatic/templates/borgmatic.plist.j2 | 5 +---- docs/changelog.d/+borgmatic-launchagent-tcc.bugfix.md | 1 + mise.toml | 1 + service-versions.yaml | 6 +++--- 7 files changed, 24 insertions(+), 13 deletions(-) create mode 100644 docs/changelog.d/+borgmatic-launchagent-tcc.bugfix.md diff --git a/ansible/roles/borgmatic/defaults/main.yml b/ansible/roles/borgmatic/defaults/main.yml index 4dd0671..25d0149 100644 --- a/ansible/roles/borgmatic/defaults/main.yml +++ b/ansible/roles/borgmatic/defaults/main.yml @@ -6,6 +6,16 @@ borgmatic_log_dir: /Users/erichblume/Library/Logs # Full path to borg binary since LaunchAgent doesn't have homebrew in PATH borgmatic_local_path: /opt/homebrew/bin/borg +# Borgmatic version — keep in sync with mise.toml in the repo root. +# Ansible installs this via `mise install` so indri doesn't need the repo cloned. +borgmatic_version: "2.1.4" + +# Full path to borgmatic binary — called directly by LaunchAgents to avoid +# routing through mise, which triggers macOS TCC permission dialogs for +# protected folders (e.g. ~/Documents) that hang headless LaunchAgent sessions. +# Uses mise's "latest" symlink so version bumps don't break the LaunchAgent path. +borgmatic_bin: /Users/erichblume/.local/share/mise/installs/pipx-borgmatic/latest/bin/borgmatic + # Schedule: runs daily at 2:00 AM borgmatic_schedule_hour: 2 borgmatic_schedule_minute: 0 diff --git a/ansible/roles/borgmatic/tasks/main.yml b/ansible/roles/borgmatic/tasks/main.yml index dd6efdd..eacefa5 100644 --- a/ansible/roles/borgmatic/tasks/main.yml +++ b/ansible/roles/borgmatic/tasks/main.yml @@ -1,6 +1,11 @@ --- -# Note: borgmatic is installed via mise (pipx), not managed here. -# This role manages the config file and scheduled LaunchAgent. +# Borgmatic is installed via mise (pipx) and called directly by LaunchAgents. +# This role manages installation, config, and the scheduled LaunchAgents. + +- name: Install borgmatic via mise + ansible.builtin.command: mise install pipx:borgmatic@{{ borgmatic_version }} + register: borgmatic_install + changed_when: "'installed' in borgmatic_install.stderr" - name: Ensure borgmatic config directory exists ansible.builtin.file: diff --git a/ansible/roles/borgmatic/templates/borgmatic-photos.plist.j2 b/ansible/roles/borgmatic/templates/borgmatic-photos.plist.j2 index 6e69159..d5b5578 100644 --- a/ansible/roles/borgmatic/templates/borgmatic-photos.plist.j2 +++ b/ansible/roles/borgmatic/templates/borgmatic-photos.plist.j2 @@ -14,10 +14,7 @@ ProgramArguments - /opt/homebrew/opt/mise/bin/mise - x - -- - borgmatic + {{ borgmatic_bin }} --config {{ borgmatic_photos_config }} create diff --git a/ansible/roles/borgmatic/templates/borgmatic.plist.j2 b/ansible/roles/borgmatic/templates/borgmatic.plist.j2 index c7da8e8..a6422fe 100644 --- a/ansible/roles/borgmatic/templates/borgmatic.plist.j2 +++ b/ansible/roles/borgmatic/templates/borgmatic.plist.j2 @@ -14,10 +14,7 @@ ProgramArguments - /opt/homebrew/opt/mise/bin/mise - x - -- - borgmatic + {{ borgmatic_bin }} --config {{ borgmatic_config }} create diff --git a/docs/changelog.d/+borgmatic-launchagent-tcc.bugfix.md b/docs/changelog.d/+borgmatic-launchagent-tcc.bugfix.md new file mode 100644 index 0000000..0f941e9 --- /dev/null +++ b/docs/changelog.d/+borgmatic-launchagent-tcc.bugfix.md @@ -0,0 +1 @@ +Fix borgmatic LaunchAgent failing silently due to macOS TCC permission dialogs. LaunchAgents now call borgmatic directly instead of routing through `mise x`, which triggered "wants to access Documents" dialogs that hung headless sessions. The ansible role now also manages borgmatic installation via `mise install`. diff --git a/mise.toml b/mise.toml index ee3200e..12c92df 100644 --- a/mise.toml +++ b/mise.toml @@ -5,6 +5,7 @@ # 2. create a new entry in service-versions.yaml # This will help ensure reviewed upgrades at a steady cadence "pipx:ansible-core" = { version = "2.20.1", uvx = "true", uvx_args = "--with botocore --with boto3" } +"pipx:borgmatic" = "2.1.4" prek = "0.3.4" pulumi = "3.215.0" dagger = "0.20.1" diff --git a/service-versions.yaml b/service-versions.yaml index f6f3be4..277216d 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -352,10 +352,10 @@ services: - name: borgmatic type: ansible - last-reviewed: 2026-03-16 - current-version: "2.1.3" + last-reviewed: 2026-04-15 + current-version: "2.1.4" upstream-source: https://github.com/borgmatic-collective/borgmatic/releases - notes: Installed via mise (pipx), not managed by Ansible role + notes: Installed via mise (pipx); version pinned in ansible/roles/borgmatic/defaults/main.yml and mise.toml - name: jellyfin type: ansible From 2c483cefff206b8f8efc7e5f2620e4472e6f2dc7 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 15 Apr 2026 11:26:00 -0700 Subject: [PATCH 266/430] Migrate transmission containers from Dockerfile to Dagger builds Replace Dockerfiles with native container.py for both transmission and transmission-exporter. Updates base images (Alpine 3.23, Python 3.14), pins uv to 0.11.6 instead of :latest. Co-Authored-By: Claude Opus 4.6 (1M context) --- containers/transmission-exporter/Dockerfile | 25 ---------- containers/transmission-exporter/container.py | 37 ++++++++++++++ containers/transmission/Dockerfile | 39 --------------- containers/transmission/container.py | 49 +++++++++++++++++++ .../dagger-transmission-containers.infra.md | 1 + service-versions.yaml | 4 +- 6 files changed, 89 insertions(+), 66 deletions(-) delete mode 100644 containers/transmission-exporter/Dockerfile create mode 100644 containers/transmission-exporter/container.py delete mode 100644 containers/transmission/Dockerfile create mode 100644 containers/transmission/container.py create mode 100644 docs/changelog.d/dagger-transmission-containers.infra.md diff --git a/containers/transmission-exporter/Dockerfile b/containers/transmission-exporter/Dockerfile deleted file mode 100644 index c9d0655..0000000 --- a/containers/transmission-exporter/Dockerfile +++ /dev/null @@ -1,25 +0,0 @@ -# Transmission Prometheus exporter - collect-on-scrape, no polling loop -# uv run --script handles dependency resolution at runtime - -ARG CONTAINER_APP_VERSION=1.0.1 - -FROM python:3.13-alpine3.23 - -ARG CONTAINER_APP_VERSION -LABEL org.opencontainers.image.title="Transmission Exporter" -LABEL org.opencontainers.image.description="Prometheus exporter for Transmission BitTorrent client" -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" - -COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv - -ENV PYTHONUNBUFFERED=1 -ENV UV_CACHE_DIR=/tmp/uv-cache - -WORKDIR /app -COPY exporter.py . - -EXPOSE 19091 -USER 65534:65534 -CMD ["uv", "run", "--script", "/app/exporter.py"] diff --git a/containers/transmission-exporter/container.py b/containers/transmission-exporter/container.py new file mode 100644 index 0000000..e88fc70 --- /dev/null +++ b/containers/transmission-exporter/container.py @@ -0,0 +1,37 @@ +"""Transmission Prometheus exporter — native Dagger build. + +Minimal collect-on-scrape exporter using uv to resolve deps at runtime. +""" + +import dagger +from dagger import dag + +from blumeops.containers import oci_labels + +VERSION = "1.0.1" + +PYTHON_BASE = "python:3.14-alpine3.23" +UV_IMAGE = "ghcr.io/astral-sh/uv:0.11.6" + + +async def build(src: dagger.Directory) -> dagger.Container: + ctr = ( + dag.container() + .from_(PYTHON_BASE) + .with_file("/usr/local/bin/uv", dag.container().from_(UV_IMAGE).file("/uv")) + .with_env_variable("PYTHONUNBUFFERED", "1") + .with_env_variable("UV_CACHE_DIR", "/tmp/uv-cache") + .with_workdir("/app") + .with_file( + "/app/exporter.py", src.file("containers/transmission-exporter/exporter.py") + ) + .with_exposed_port(19091) + .with_user("65534:65534") + .with_default_args(args=["uv", "run", "--script", "/app/exporter.py"]) + ) + return oci_labels( + ctr, + title="Transmission Exporter", + description="Prometheus exporter for Transmission BitTorrent client", + version=VERSION, + ) diff --git a/containers/transmission/Dockerfile b/containers/transmission/Dockerfile deleted file mode 100644 index 6d7bcab..0000000 --- a/containers/transmission/Dockerfile +++ /dev/null @@ -1,39 +0,0 @@ -# Transmission BitTorrent daemon -# Simpler alternative to linuxserver image - -ARG CONTAINER_APP_VERSION=4.1.1-r1 - -FROM alpine:3.22 - -ARG CONTAINER_APP_VERSION -ARG TRANSMISSION_VERSION=${CONTAINER_APP_VERSION} - -LABEL org.opencontainers.image.title="Transmission" -LABEL org.opencontainers.image.description="Transmission BitTorrent daemon" -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" - -# Transmission 4.1.x is only in edge; base OS stays on stable 3.22 -RUN apk add --no-cache \ - --repository=https://dl-cdn.alpinelinux.org/alpine/edge/community \ - transmission-daemon=${TRANSMISSION_VERSION} \ - transmission-cli=${TRANSMISSION_VERSION} \ - transmission-remote=${TRANSMISSION_VERSION} \ - && apk add --no-cache \ - bash \ - curl \ - tzdata \ - su-exec - -# Create directories (user is created dynamically by start.sh based on PUID/PGID) -RUN mkdir -p /config /downloads/complete /downloads/incomplete - -COPY start.sh /start.sh -RUN chmod +x /start.sh - -EXPOSE 9091 51413/tcp 51413/udp - -VOLUME ["/config", "/downloads"] - -ENTRYPOINT ["/start.sh"] diff --git a/containers/transmission/container.py b/containers/transmission/container.py new file mode 100644 index 0000000..c7989aa --- /dev/null +++ b/containers/transmission/container.py @@ -0,0 +1,49 @@ +"""Transmission BitTorrent daemon — native Dagger build. + +Alpine-based container with transmission-daemon from edge repo. +Includes start.sh for dynamic PUID/PGID user creation at runtime. +""" + +import dagger +from dagger import dag + +from blumeops.containers import oci_labels + +VERSION = "4.1.1-r1" + +ALPINE_BASE = "alpine:3.23" + + +async def build(src: dagger.Directory) -> dagger.Container: + ctr = ( + dag.container() + .from_(ALPINE_BASE) + # Transmission 4.1.x is only in edge community + .with_exec( + [ + "apk", + "add", + "--no-cache", + "--repository=https://dl-cdn.alpinelinux.org/alpine/edge/community", + f"transmission-daemon={VERSION}", + f"transmission-cli={VERSION}", + f"transmission-remote={VERSION}", + ] + ) + .with_exec(["apk", "add", "--no-cache", "bash", "curl", "tzdata", "su-exec"]) + .with_exec( + ["mkdir", "-p", "/config", "/downloads/complete", "/downloads/incomplete"] + ) + .with_file("/start.sh", src.file("containers/transmission/start.sh")) + .with_exec(["chmod", "+x", "/start.sh"]) + .with_exposed_port(9091) + .with_exposed_port(51413, protocol=dagger.NetworkProtocol.TCP) + .with_exposed_port(51413, protocol=dagger.NetworkProtocol.UDP) + .with_default_args(args=["/start.sh"]) + ) + return oci_labels( + ctr, + title="Transmission", + description="Transmission BitTorrent daemon", + version=VERSION, + ) diff --git a/docs/changelog.d/dagger-transmission-containers.infra.md b/docs/changelog.d/dagger-transmission-containers.infra.md new file mode 100644 index 0000000..4937a06 --- /dev/null +++ b/docs/changelog.d/dagger-transmission-containers.infra.md @@ -0,0 +1 @@ +Migrate transmission and transmission-exporter containers from Dockerfile to native Dagger builds (`container.py`). Updates base images to Alpine 3.23 and Python 3.14, pins uv to 0.11.6. diff --git a/service-versions.yaml b/service-versions.yaml index 277216d..62d8835 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -196,13 +196,13 @@ services: - name: transmission type: argocd - last-reviewed: 2026-03-04 + last-reviewed: 2026-04-15 current-version: "4.1.1-r1" upstream-source: https://github.com/transmission/transmission/releases - name: transmission-exporter type: argocd - last-reviewed: 2026-03-05 + last-reviewed: 2026-04-15 current-version: "1.0.1" upstream-source: null notes: Homegrown Python exporter, no upstream From fb1e8ff672c1644de91a9326ab975d2c02194d6a Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 15 Apr 2026 11:34:28 -0700 Subject: [PATCH 267/430] Deploy transmission containers from Dagger builds Update kustomization image tags to the new container.py-built images (v4.1.1-r1-2c483ce, v1.0.1-2c483ce). Co-Authored-By: Claude Opus 4.6 (1M context) --- argocd/manifests/torrent/kustomization.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/argocd/manifests/torrent/kustomization.yaml b/argocd/manifests/torrent/kustomization.yaml index 08e5414..671687c 100644 --- a/argocd/manifests/torrent/kustomization.yaml +++ b/argocd/manifests/torrent/kustomization.yaml @@ -10,6 +10,6 @@ resources: - ingress-tailscale.yaml images: - name: registry.ops.eblu.me/blumeops/transmission - newTag: v4.1.1-r1-613f05d + newTag: v4.1.1-r1-2c483ce - name: registry.ops.eblu.me/blumeops/transmission-exporter - newTag: v1.0.1-613f05d + newTag: v1.0.1-2c483ce From 99f78c874599d0b0c2b31e181c3c17d9287dce8c Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Thu, 16 Apr 2026 07:42:52 -0700 Subject: [PATCH 268/430] Register claude-cli:// URI handler on ringtail for Claude Code OAuth Co-Authored-By: Claude Opus 4.6 (1M context) --- .../+claude-code-uri-handler.bugfix.md | 1 + nixos/ringtail/configuration.nix | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) create mode 100644 docs/changelog.d/+claude-code-uri-handler.bugfix.md diff --git a/docs/changelog.d/+claude-code-uri-handler.bugfix.md b/docs/changelog.d/+claude-code-uri-handler.bugfix.md new file mode 100644 index 0000000..e2814f9 --- /dev/null +++ b/docs/changelog.d/+claude-code-uri-handler.bugfix.md @@ -0,0 +1 @@ +Register `claude-cli://` URI scheme handler on ringtail so Claude Code's OAuth browser callback completes instead of hanging Librewolf. diff --git a/nixos/ringtail/configuration.nix b/nixos/ringtail/configuration.nix index ac8e669..25475c4 100644 --- a/nixos/ringtail/configuration.nix +++ b/nixos/ringtail/configuration.nix @@ -361,6 +361,22 @@ in ]; }; + # Claude Code OAuth callback handler (claude-cli:// URI scheme) + xdg.desktopEntries.claude-code-url-handler = { + name = "Claude Code URL Handler"; + exec = "/run/current-system/sw/bin/mise exec -- claude --handle-uri %u"; + type = "Application"; + noDisplay = true; + mimeType = [ "x-scheme-handler/claude-cli" ]; + }; + + xdg.mimeApps = { + enable = true; + defaultApplications = { + "x-scheme-handler/claude-cli" = [ "claude-code-url-handler.desktop" ]; + }; + }; + programs.fuzzel = { enable = true; settings = { From 352b95c14114cffd45d7647243bb9fce89456d43 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Thu, 16 Apr 2026 10:10:46 -0700 Subject: [PATCH 269/430] Refactor Dagger go_build() helper and standardize Alpine 3.23 Extend go_build() with buildmode and extra_env params, migrate miniflux and forgejo-runner to use it, and bump all Alpine bases from 3.22 to 3.23. Co-Authored-By: Claude Opus 4.6 (1M context) --- containers/forgejo-runner/container.py | 35 ++++++------------- containers/miniflux/container.py | 27 ++++---------- .../+dagger-go-build-refactor.infra.md | 1 + src/blumeops/containers.py | 30 ++++++++-------- 4 files changed, 33 insertions(+), 60 deletions(-) create mode 100644 docs/changelog.d/+dagger-go-build-refactor.infra.md diff --git a/containers/forgejo-runner/container.py b/containers/forgejo-runner/container.py index 16f6986..ffaca88 100644 --- a/containers/forgejo-runner/container.py +++ b/containers/forgejo-runner/container.py @@ -5,11 +5,11 @@ Source cloned from forge mirror. """ import dagger -from dagger import dag from blumeops.containers import ( alpine_runtime, clone_from_forge, + go_build, oci_labels, ) @@ -20,29 +20,16 @@ async def build(src: dagger.Directory) -> dagger.Container: source = clone_from_forge("forgejo-runner", f"v{VERSION}") # Stage 1: Build Go binary (static, CGO enabled for SQLite) - ldflags = ( - '-extldflags "-static" -s -w' - f' -X "code.forgejo.org/forgejo/runner/v12/internal/pkg/ver.version=v{VERSION}"' - ) - backend = ( - dag.container() - .from_("golang:alpine3.22") - .with_exec(["apk", "add", "--no-cache", "build-base", "git"]) - .with_directory("/app", source) - .with_workdir("/app") - .with_env_variable("CGO_ENABLED", "1") - .with_env_variable("CGO_CFLAGS", "-DSQLITE_MAX_VARIABLE_NUMBER=32766") - .with_exec( - [ - "go", - "build", - "-tags=netgo osusergo", - f"-ldflags={ldflags}", - "-o", - "/forgejo-runner", - ".", - ] - ) + backend = go_build( + source, + "/forgejo-runner", + tags="netgo osusergo", + ldflags=( + '-extldflags "-static" -s -w' + f' -X "code.forgejo.org/forgejo/runner/v12/internal/pkg/ver.version=v{VERSION}"' + ), + cgo_enabled=True, + extra_env={"CGO_CFLAGS": "-DSQLITE_MAX_VARIABLE_NUMBER=32766"}, ) # Stage 2: Runtime diff --git a/containers/miniflux/container.py b/containers/miniflux/container.py index ef2050a..e25485c 100644 --- a/containers/miniflux/container.py +++ b/containers/miniflux/container.py @@ -5,11 +5,11 @@ Source cloned from forge mirror. """ import dagger -from dagger import dag from blumeops.containers import ( alpine_runtime, clone_from_forge, + go_build, oci_labels, ) @@ -20,25 +20,12 @@ async def build(src: dagger.Directory) -> dagger.Container: source = clone_from_forge("miniflux", VERSION) # Stage 1: Build Go backend (PIE mode, matching upstream Makefile) - ldflags = f"-s -w -X 'miniflux.app/v2/internal/version.Version={VERSION}'" - backend = ( - dag.container() - .from_("golang:alpine3.22") - .with_exec(["apk", "add", "--no-cache", "build-base", "git"]) - .with_directory("/app", source) - .with_workdir("/app") - .with_env_variable("CGO_ENABLED", "1") - .with_exec( - [ - "go", - "build", - "-buildmode=pie", - f"-ldflags={ldflags}", - "-o", - "/miniflux", - ".", - ] - ) + backend = go_build( + source, + "/miniflux", + buildmode="pie", + ldflags=f"-s -w -X 'miniflux.app/v2/internal/version.Version={VERSION}'", + cgo_enabled=True, ) # Stage 2: Runtime (uses Alpine's built-in nobody:65534) diff --git a/docs/changelog.d/+dagger-go-build-refactor.infra.md b/docs/changelog.d/+dagger-go-build-refactor.infra.md new file mode 100644 index 0000000..af09d7f --- /dev/null +++ b/docs/changelog.d/+dagger-go-build-refactor.infra.md @@ -0,0 +1 @@ +Refactored Dagger container pipelines: extended `go_build()` helper with `buildmode` and `extra_env` params, migrated miniflux and forgejo-runner to use it, and standardized all Alpine bases from 3.22 to 3.23. diff --git a/src/blumeops/containers.py b/src/blumeops/containers.py index 4805c5c..d63c127 100644 --- a/src/blumeops/containers.py +++ b/src/blumeops/containers.py @@ -70,33 +70,31 @@ def go_build( cmd_path: str = ".", tags: str = "netgo", ldflags: str = "-w -s", + buildmode: str | None = None, cgo_enabled: bool = False, extra_apk: list[str] | None = None, + extra_env: dict[str, str] | None = None, ) -> dagger.Container: - """Go build stage on golang:alpine3.22. + """Go build stage on golang:alpine3.23. Returns a container with the built binary at `output`. """ apk_packages = ["build-base", "git"] + (extra_apk or []) - return ( + ctr = ( dag.container() - .from_("golang:alpine3.22") + .from_("golang:alpine3.23") .with_exec(["apk", "add", "--no-cache", *apk_packages]) .with_directory("/app", source) .with_workdir("/app") .with_env_variable("CGO_ENABLED", "1" if cgo_enabled else "0") - .with_exec( - [ - "go", - "build", - f"-tags={tags}", - f"-ldflags={ldflags}", - "-o", - output, - cmd_path, - ] - ) ) + for key, val in (extra_env or {}).items(): + ctr = ctr.with_env_variable(key, val) + build_cmd = ["go", "build"] + if buildmode: + build_cmd.append(f"-buildmode={buildmode}") + build_cmd += [f"-tags={tags}", f"-ldflags={ldflags}", "-o", output, cmd_path] + return ctr.with_exec(build_cmd) def node_build( @@ -133,7 +131,7 @@ def alpine_runtime( username: str = "app", create_user: bool = True, ) -> dagger.Container: - """Standard Alpine 3.22 runtime base. + """Standard Alpine 3.23 runtime base. When create_user is True (default), creates a non-root user with the given uid/gid/username. Set create_user=False to use an existing user (e.g. @@ -147,7 +145,7 @@ def alpine_runtime( setup_cmds.append(f"addgroup -g {gid} {username}") setup_cmds.append(f"adduser -u {uid} -G {username} -D {username}") - ctr = dag.container().from_("alpine:3.22") + ctr = dag.container().from_("alpine:3.23") if setup_cmds: ctr = ctr.with_exec(["sh", "-c", " && ".join(setup_cmds)]) return ctr From 3ecd88853750583ab107e160800b720d53cbdfeb Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Thu, 16 Apr 2026 14:25:14 -0700 Subject: [PATCH 270/430] Switch container builds to manual-only workflow dispatch Shared Dagger helpers (src/blumeops/) affect all Dagger-built containers, making path-based auto-triggers unreliable. All builds now go through `mise run container-build-and-release `. Co-Authored-By: Claude Opus 4.6 (1M context) --- .forgejo/workflows/build-container.yaml | 24 +++++++------------ .../+container-manual-builds.infra.md | 1 + docs/explanation/agent-change-process.md | 6 ++--- docs/how-to/dagger/upgrade-dagger.md | 2 +- .../deployment/build-container-image.md | 6 ++--- .../zot/adopt-commit-based-container-tags.md | 9 ++++--- 6 files changed, 20 insertions(+), 28 deletions(-) create mode 100644 docs/changelog.d/+container-manual-builds.infra.md diff --git a/.forgejo/workflows/build-container.yaml b/.forgejo/workflows/build-container.yaml index 78ee586..1fcfd7f 100644 --- a/.forgejo/workflows/build-container.yaml +++ b/.forgejo/workflows/build-container.yaml @@ -1,14 +1,13 @@ # Unified container build workflow -# Triggers on pushes to main that modify containers/*, or via manual dispatch. -# Detects which containers changed and routes to the correct runner: -# - Dockerfile containers build on k8s (indri) via Dagger +# Manual dispatch only — use `mise run container-build-and-release `. +# Shared Dagger helpers (src/blumeops/) make path-based auto-triggers unreliable, +# so all container builds are triggered explicitly. +# Routes to the correct runner: +# - Dockerfile/Dagger containers build on k8s (indri) via Dagger # - Nix containers build on nix-container-builder (ringtail) via nix-build + skopeo name: Build Container on: - push: - branches: [main] - paths: ['containers/**'] workflow_dispatch: inputs: container: @@ -33,18 +32,11 @@ jobs: ref: ${{ inputs.ref || github.sha }} fetch-depth: 2 - - name: Detect and classify changed containers + - name: Classify container build type id: classify run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - CHANGED='["${{ inputs.container }}"]' - else - CHANGED=$(git diff --name-only HEAD~1 HEAD -- containers/ \ - | cut -d/ -f2 | sort -u \ - | jq -R -s -c 'split("\n") | map(select(length > 0))') - fi - - echo "Changed containers: $CHANGED" + CHANGED='["${{ inputs.container }}"]' + echo "Building container: $CHANGED" # Classify each container by build type (a container can appear in both) DAGGER='[]' diff --git a/docs/changelog.d/+container-manual-builds.infra.md b/docs/changelog.d/+container-manual-builds.infra.md new file mode 100644 index 0000000..78a29f4 --- /dev/null +++ b/docs/changelog.d/+container-manual-builds.infra.md @@ -0,0 +1 @@ +Container builds are now manual-only via `mise run container-build-and-release`. Removed auto-trigger on push to main — shared Dagger helpers made path-based detection unreliable. diff --git a/docs/explanation/agent-change-process.md b/docs/explanation/agent-change-process.md index 38b5a26..5141950 100644 --- a/docs/explanation/agent-change-process.md +++ b/docs/explanation/agent-change-process.md @@ -58,7 +58,7 @@ A change with enough complexity or risk that a human should review it, but not s - **Workflows:** point workflow triggers at the branch if needed 8. After user review and successful deployment, the user merges the PR 9. **After merge:** reset ArgoCD revisions back to main, re-sync -10. **If the PR changed `containers/`:** the merge triggers a rebuild from main automatically. Once it completes, commit a C0 updating the manifest to the new `[main]`-tagged image (see [[build-container-image#Squash-merge and container tags]]) +10. **If the PR changed `containers/`:** trigger a rebuild with `mise run container-build-and-release `. Once it completes, commit a C0 updating the manifest to the new `[main]`-tagged image (see [[build-container-image#Squash-merge and container tags]]) ### Upgrading to C2 @@ -235,8 +235,8 @@ When starting a new session to continue C2 work: Mikado resets apply to branch code, not build artifacts. Container images in the registry are independent of branch lifecycle: - **Registry images** are build outputs cached in zot — tagged with commit SHAs, so each build is unique and traceable -- **Squash-merge orphans:** Images built during PR development reference branch SHAs that won't exist on main after merge. After merge, a rebuild triggers automatically; commit a C0 to update manifests to the new `[main]`-tagged image. Use `mise run container-list ` to find it -- **Automatic builds** trigger when container changes merge to main. Use `mise run container-build-and-release` for manual dispatch +- **Squash-merge orphans:** Images built during PR development reference branch SHAs that won't exist on main after merge. After merge, trigger a rebuild with `mise run container-build-and-release ` and commit a C0 to update manifests to the new `[main]`-tagged image. Use `mise run container-list ` to find it +- **All builds are manual** — use `mise run container-build-and-release ` to dispatch - **If a build succeeds but deployment fails**, the image is fine; the problem is elsewhere. Document what you learned and try again - **If a build fails in CI**, no image is pushed. Fix the nix/dockerfile and re-merge or re-dispatch diff --git a/docs/how-to/dagger/upgrade-dagger.md b/docs/how-to/dagger/upgrade-dagger.md index 0ee66a6..99058e4 100644 --- a/docs/how-to/dagger/upgrade-dagger.md +++ b/docs/how-to/dagger/upgrade-dagger.md @@ -43,7 +43,7 @@ The runner job image contains the Dagger CLI binary. Upgrading it first means th 2. Update `service-versions.yaml` — bump `current-version` and `last-reviewed` for `runner-job-image`. -3. Commit and push to main. The `Build Container` workflow triggers automatically (it watches `containers/**`), building and publishing the new runner-job-image with the updated Dagger CLI. +3. Commit and push to main. Trigger a build with `mise run container-build-and-release runner-job-image`. 4. Verify the build succeeds — check the workflow run on Forgejo. Note the image tag from the build output (format: `v-`). diff --git a/docs/how-to/deployment/build-container-image.md b/docs/how-to/deployment/build-container-image.md index ce66a46..2f0a980 100644 --- a/docs/how-to/deployment/build-container-image.md +++ b/docs/how-to/deployment/build-container-image.md @@ -57,9 +57,9 @@ nix-build containers//default.nix -o result ## 3. Release -Container builds trigger automatically when changes to `containers//` are merged to `main`. Both workflows fire and each skips if the relevant build file is absent. +Container builds are triggered manually. Shared Dagger helpers (`src/blumeops/`) affect all Dagger-built containers, making path-based auto-triggers unreliable. -To trigger a manual build (e.g. from a branch or to rebuild at a specific commit): +To trigger a build: ```bash mise run container-build-and-release @@ -106,7 +106,7 @@ Container image tags include the git commit SHA they were built from (e.g. `v3.9 **The rule:** Production manifests must reference images built from a commit on main. After merging a PR that changed `containers//`: -1. The merge to main automatically triggers a rebuild (the `build-container.yaml` / `build-container-nix.yaml` workflows fire on pushes to `main` that touch `containers/**`) +1. Trigger a rebuild: `mise run container-build-and-release ` 2. Wait for the workflow to complete — verify with `mise run runner-logs` (find the run, check status) 3. Find the new main-SHA tag: ```bash diff --git a/docs/how-to/zot/adopt-commit-based-container-tags.md b/docs/how-to/zot/adopt-commit-based-container-tags.md index 80a3f37..8344ce6 100644 --- a/docs/how-to/zot/adopt-commit-based-container-tags.md +++ b/docs/how-to/zot/adopt-commit-based-container-tags.md @@ -25,12 +25,11 @@ Currently, container builds trigger on git tags matching `-vX.Y.Z`. T ### Triggers -1. **Merged changes to main** — any push to `main` that modifies files under `containers//` triggers builds for that container -2. **Manual workflow dispatch** — for ad-hoc builds. Accepts two inputs: - - `container` (required) — which container to build - - `ref` (optional, string) — the source commit SHA to build, defaulting to `GITHUB_SHA` +All container builds are triggered manually via `mise run container-build-and-release ` (which dispatches the workflow). Accepts two inputs: +- `container` (required) — which container to build +- `ref` (optional, string) — the source commit SHA to build, defaulting to `GITHUB_SHA` -Both the Dockerfile and Nix workflows fire for each trigger, each bailing out if the container lacks the relevant build file (same as today). +The workflow classifies the container by build type and routes to the correct runner. ### Version Source From 5ec2411e200fbd1f0dd7c62aabf1304c0266d74d Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Thu, 16 Apr 2026 15:37:30 -0700 Subject: [PATCH 271/430] Update navidrome, miniflux, forgejo-runner image tags to Alpine 3.23 builds [main] Co-Authored-By: Claude Opus 4.6 (1M context) --- argocd/manifests/forgejo-runner/kustomization.yaml | 2 +- argocd/manifests/miniflux/kustomization.yaml | 2 +- argocd/manifests/navidrome/kustomization.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/argocd/manifests/forgejo-runner/kustomization.yaml b/argocd/manifests/forgejo-runner/kustomization.yaml index 2876f8e..f8d9377 100644 --- a/argocd/manifests/forgejo-runner/kustomization.yaml +++ b/argocd/manifests/forgejo-runner/kustomization.yaml @@ -11,7 +11,7 @@ resources: images: - name: code.forgejo.org/forgejo/runner newName: registry.ops.eblu.me/blumeops/forgejo-runner - newTag: v12.7.3-0e93cc0 + newTag: v12.7.3-352b95c - name: docker newTag: 27-dind diff --git a/argocd/manifests/miniflux/kustomization.yaml b/argocd/manifests/miniflux/kustomization.yaml index 6fc00cd..1acc708 100644 --- a/argocd/manifests/miniflux/kustomization.yaml +++ b/argocd/manifests/miniflux/kustomization.yaml @@ -10,4 +10,4 @@ resources: images: - name: registry.ops.eblu.me/blumeops/miniflux - newTag: v2.2.19-138e23d + newTag: v2.2.19-352b95c diff --git a/argocd/manifests/navidrome/kustomization.yaml b/argocd/manifests/navidrome/kustomization.yaml index a2914f5..41689f4 100644 --- a/argocd/manifests/navidrome/kustomization.yaml +++ b/argocd/manifests/navidrome/kustomization.yaml @@ -11,4 +11,4 @@ resources: - ingress-tailscale.yaml images: - name: registry.ops.eblu.me/blumeops/navidrome - newTag: v0.61.1-c86b5d7 + newTag: v0.61.1-3ecd888 From 7f6bbdc82c67fd3e00d9893bcedb6a03490d3c28 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Thu, 16 Apr 2026 15:39:48 -0700 Subject: [PATCH 272/430] Add robots.txt to forge.eblu.me blocking crawlers from /mirrors/ Facebook has been scraping forge mirror repos at ~3-4 req/s, slowing down the Forgejo instance. Serve robots.txt directly from nginx to disallow /mirrors/ while leaving eblume/* accessible to crawlers. Co-Authored-By: Claude Opus 4.6 (1M context) --- fly/nginx.conf | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/fly/nginx.conf b/fly/nginx.conf index 75cd102..995042e 100644 --- a/fly/nginx.conf +++ b/fly/nginx.conf @@ -155,6 +155,12 @@ http { internal; } + # Serve robots.txt directly — block crawlers from mirror repos + location = /robots.txt { + default_type text/plain; + return 200 "User-agent: *\nDisallow: /mirrors/\n"; + } + # Block swagger API docs — use forge.ops.eblu.me from tailnet location /swagger { return 403 "API documentation is only available at forge.ops.eblu.me (tailnet).\n"; From 68f845e773513fdb59ba5d40852d9063bffb2662 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Thu, 16 Apr 2026 15:40:34 -0700 Subject: [PATCH 273/430] Add changelog fragment for forge robots.txt Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/changelog.d/+forge-robots-txt.infra.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 docs/changelog.d/+forge-robots-txt.infra.md diff --git a/docs/changelog.d/+forge-robots-txt.infra.md b/docs/changelog.d/+forge-robots-txt.infra.md new file mode 100644 index 0000000..fe96ad5 --- /dev/null +++ b/docs/changelog.d/+forge-robots-txt.infra.md @@ -0,0 +1 @@ +Added `robots.txt` to `forge.eblu.me` blocking crawlers from `/mirrors/` to reduce load from Facebook scraping. From dd1cf4f198fb549d7a0970382d6a0855a8f6be41 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Thu, 16 Apr 2026 16:37:16 -0700 Subject: [PATCH 274/430] Configure Librewolf to delegate claude-cli:// URIs to xdg-open The xdg desktop entry and mimeapps were already registered but Librewolf doesn't delegate unknown URI schemes to the system handler by default. This adds user.js prefs to complete the chain. Co-Authored-By: Claude Opus 4.6 (1M context) --- nixos/ringtail/configuration.nix | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/nixos/ringtail/configuration.nix b/nixos/ringtail/configuration.nix index 25475c4..b1723e3 100644 --- a/nixos/ringtail/configuration.nix +++ b/nixos/ringtail/configuration.nix @@ -226,6 +226,13 @@ in home-manager.users.eblume = { home.stateVersion = "25.11"; + # Librewolf: delegate claude-cli:// URIs to system handler (xdg-open) + home.file.".config/librewolf/librewolf/backlhkh.default/user.js".text = '' + user_pref("network.protocol-handler.expose.claude-cli", false); + user_pref("network.protocol-handler.external.claude-cli", true); + user_pref("network.protocol-handler.warn-external.claude-cli", false); + ''; + wayland.windowManager.sway = { enable = true; checkConfig = false; From 50dfdba4e65ee51ab53893693451df92fd3a9d6b Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Thu, 16 Apr 2026 16:42:36 -0700 Subject: [PATCH 275/430] Add Firefox, remove claude-cli:// handler workarounds The xdg desktop entry and Librewolf user.js prefs didn't fix the OAuth callback hang. Try stock Firefox instead as a simpler path. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../+claude-code-uri-handler.bugfix.md | 1 - nixos/ringtail/configuration.nix | 24 +------------------ 2 files changed, 1 insertion(+), 24 deletions(-) delete mode 100644 docs/changelog.d/+claude-code-uri-handler.bugfix.md diff --git a/docs/changelog.d/+claude-code-uri-handler.bugfix.md b/docs/changelog.d/+claude-code-uri-handler.bugfix.md deleted file mode 100644 index e2814f9..0000000 --- a/docs/changelog.d/+claude-code-uri-handler.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -Register `claude-cli://` URI scheme handler on ringtail so Claude Code's OAuth browser callback completes instead of hanging Librewolf. diff --git a/nixos/ringtail/configuration.nix b/nixos/ringtail/configuration.nix index b1723e3..5349f9b 100644 --- a/nixos/ringtail/configuration.nix +++ b/nixos/ringtail/configuration.nix @@ -202,6 +202,7 @@ in fuzzel pulseaudio librewolf + firefox ]; # Allow running dynamically linked binaries (mise-installed runtimes, etc.) @@ -226,13 +227,6 @@ in home-manager.users.eblume = { home.stateVersion = "25.11"; - # Librewolf: delegate claude-cli:// URIs to system handler (xdg-open) - home.file.".config/librewolf/librewolf/backlhkh.default/user.js".text = '' - user_pref("network.protocol-handler.expose.claude-cli", false); - user_pref("network.protocol-handler.external.claude-cli", true); - user_pref("network.protocol-handler.warn-external.claude-cli", false); - ''; - wayland.windowManager.sway = { enable = true; checkConfig = false; @@ -368,22 +362,6 @@ in ]; }; - # Claude Code OAuth callback handler (claude-cli:// URI scheme) - xdg.desktopEntries.claude-code-url-handler = { - name = "Claude Code URL Handler"; - exec = "/run/current-system/sw/bin/mise exec -- claude --handle-uri %u"; - type = "Application"; - noDisplay = true; - mimeType = [ "x-scheme-handler/claude-cli" ]; - }; - - xdg.mimeApps = { - enable = true; - defaultApplications = { - "x-scheme-handler/claude-cli" = [ "claude-code-url-handler.desktop" ]; - }; - }; - programs.fuzzel = { enable = true; settings = { From f283f9453daf00529490405a5a09ebd73aec7ef3 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Thu, 16 Apr 2026 16:43:35 -0700 Subject: [PATCH 276/430] Set Firefox as default browser via home-manager xdg.mimeApps Co-Authored-By: Claude Opus 4.6 (1M context) --- nixos/ringtail/configuration.nix | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/nixos/ringtail/configuration.nix b/nixos/ringtail/configuration.nix index 5349f9b..84d1803 100644 --- a/nixos/ringtail/configuration.nix +++ b/nixos/ringtail/configuration.nix @@ -227,6 +227,15 @@ in home-manager.users.eblume = { home.stateVersion = "25.11"; + xdg.mimeApps = { + enable = true; + defaultApplications = { + "x-scheme-handler/http" = [ "firefox.desktop" ]; + "x-scheme-handler/https" = [ "firefox.desktop" ]; + "text/html" = [ "firefox.desktop" ]; + }; + }; + wayland.windowManager.sway = { enable = true; checkConfig = false; From e60e3d5fc7c0379562da83b529d2b4a935f0203a Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Thu, 16 Apr 2026 16:54:33 -0700 Subject: [PATCH 277/430] Use programs.firefox module with 1Password extension via policy Co-Authored-By: Claude Opus 4.6 (1M context) --- nixos/ringtail/configuration.nix | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/nixos/ringtail/configuration.nix b/nixos/ringtail/configuration.nix index 84d1803..565d9e4 100644 --- a/nixos/ringtail/configuration.nix +++ b/nixos/ringtail/configuration.nix @@ -106,6 +106,20 @@ in # Fish shell programs.fish.enable = true; + # Firefox with 1Password extension + programs.firefox = { + enable = true; + nativeMessagingHosts.packages = [ pkgs._1password-gui ]; + policies = { + ExtensionSettings = { + "{d634138d-c276-4fc8-924b-40a0ea21d284}" = { + install_url = "https://addons.mozilla.org/firefox/downloads/latest/1password-x-password-manager/latest.xpi"; + installation_mode = "force_installed"; + }; + }; + }; + }; + # 1Password (modules handle CLI group/setgid and polkit for GUI integration) programs._1password.enable = true; programs._1password-gui = { @@ -202,7 +216,6 @@ in fuzzel pulseaudio librewolf - firefox ]; # Allow running dynamically linked binaries (mise-installed runtimes, etc.) From fd9e1ac93be23a3794ac416831fc7bdacb2a6676 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Thu, 16 Apr 2026 16:57:44 -0700 Subject: [PATCH 278/430] =?UTF-8?q?Remove=20nativeMessagingHosts.packages?= =?UTF-8?q?=20=E2=80=94=20breaks=20Firefox=20wrapper=20build?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The _1password-gui package doesn't export native messaging manifests in the format the Firefox wrapper expects. The 1Password NixOS module already handles native messaging host registration separately. Co-Authored-By: Claude Opus 4.6 (1M context) --- nixos/ringtail/configuration.nix | 1 - 1 file changed, 1 deletion(-) diff --git a/nixos/ringtail/configuration.nix b/nixos/ringtail/configuration.nix index 565d9e4..c350d04 100644 --- a/nixos/ringtail/configuration.nix +++ b/nixos/ringtail/configuration.nix @@ -109,7 +109,6 @@ in # Firefox with 1Password extension programs.firefox = { enable = true; - nativeMessagingHosts.packages = [ pkgs._1password-gui ]; policies = { ExtensionSettings = { "{d634138d-c276-4fc8-924b-40a0ea21d284}" = { From 5f38779d52501199410a14f996734872cd8ef42a Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Fri, 17 Apr 2026 13:56:32 -0700 Subject: [PATCH 279/430] Migrate kiwix-serve container from Dockerfile to native Dagger build Replaces the hand-written Dockerfile with container.py using the shared alpine_runtime helper, which bumps the base image from Alpine 3.22 to 3.23. Co-Authored-By: Claude Opus 4.6 (1M context) --- containers/kiwix-serve/Dockerfile | 55 ------------------- containers/kiwix-serve/container.py | 53 ++++++++++++++++++ .../+kiwix-dagger-migration.infra.md | 1 + service-versions.yaml | 2 +- 4 files changed, 55 insertions(+), 56 deletions(-) delete mode 100644 containers/kiwix-serve/Dockerfile create mode 100644 containers/kiwix-serve/container.py create mode 100644 docs/changelog.d/+kiwix-dagger-migration.infra.md diff --git a/containers/kiwix-serve/Dockerfile b/containers/kiwix-serve/Dockerfile deleted file mode 100644 index 17167e5..0000000 --- a/containers/kiwix-serve/Dockerfile +++ /dev/null @@ -1,55 +0,0 @@ -# kiwix-serve container -# Downloads pre-built binary from kiwix mirror - -ARG CONTAINER_APP_VERSION=3.8.2 - -FROM alpine:3.22 - -ARG TARGETPLATFORM -ARG CONTAINER_APP_VERSION -ARG KIWIX_VERSION=${CONTAINER_APP_VERSION} - -RUN set -e && \ - apk --no-cache add dumb-init curl && \ - # Detect architecture - use TARGETPLATFORM if set, otherwise detect from uname - if [ -n "$TARGETPLATFORM" ]; then \ - echo "TARGETPLATFORM: $TARGETPLATFORM"; \ - case "$TARGETPLATFORM" in \ - linux/arm64*) ARCH="aarch64" ;; \ - linux/amd64*) ARCH="x86_64" ;; \ - *) ARCH="" ;; \ - esac; \ - else \ - echo "TARGETPLATFORM not set, detecting from uname..."; \ - UNAME_ARCH=$(uname -m); \ - echo "uname -m: $UNAME_ARCH"; \ - case "$UNAME_ARCH" in \ - aarch64|arm64) ARCH="aarch64" ;; \ - x86_64) ARCH="x86_64" ;; \ - *) ARCH="" ;; \ - esac; \ - fi && \ - if [ -z "$ARCH" ]; then \ - echo "ERROR: Unsupported architecture"; \ - exit 1; \ - fi && \ - url="http://mirror.download.kiwix.org/release/kiwix-tools/kiwix-tools_linux-$ARCH-$KIWIX_VERSION.tar.gz" && \ - echo "URL: $url" && \ - curl -k -L $url | tar -xz -C /usr/local/bin/ --strip-components 1 && \ - apk del curl - -ARG CONTAINER_APP_VERSION -LABEL org.opencontainers.image.title="kiwix-serve" -LABEL org.opencontainers.image.description="Kiwix content server for offline ZIM files" -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" - -EXPOSE 80 - -# Run as non-root -RUN adduser -D -u 1000 kiwix -USER kiwix - -ENTRYPOINT ["/usr/bin/dumb-init", "--"] -CMD ["/bin/sh", "-c", "echo 'Use: kiwix-serve [options] ' && kiwix-serve --help"] diff --git a/containers/kiwix-serve/container.py b/containers/kiwix-serve/container.py new file mode 100644 index 0000000..1e6596e --- /dev/null +++ b/containers/kiwix-serve/container.py @@ -0,0 +1,53 @@ +"""Kiwix content server — native Dagger build. + +Downloads pre-built kiwix-tools binary from the Kiwix mirror. +Multi-arch support (aarch64, x86_64) via platform detection. +""" + +import dagger + +from blumeops.containers import alpine_runtime, oci_labels + +VERSION = "3.8.2" + +MIRROR = "http://mirror.download.kiwix.org/release/kiwix-tools" + + +async def build(src: dagger.Directory) -> dagger.Container: + runtime = alpine_runtime( + extra_apk=["dumb-init", "curl"], + uid=1000, + gid=1000, + username="kiwix", + ) + + # Download and install the pre-built binary for the target platform + runtime = runtime.with_exec( + [ + "sh", + "-c", + f"ARCH=$(uname -m) && " + f'case "$ARCH" in aarch64|arm64) ARCH=aarch64;; x86_64) ARCH=x86_64;; *) echo "Unsupported: $ARCH"; exit 1;; esac && ' + f'curl -fsSL "{MIRROR}/kiwix-tools_linux-$ARCH-{VERSION}.tar.gz" | tar -xz -C /usr/local/bin/ --strip-components 1', + ] + ).with_exec(["apk", "del", "curl"]) + + runtime = oci_labels( + runtime, + title="kiwix-serve", + description="Kiwix content server for offline ZIM files", + version=VERSION, + ) + + return ( + runtime.with_exposed_port(80) + .with_user("1000") + .with_entrypoint(["/usr/bin/dumb-init", "--"]) + .with_default_args( + args=[ + "/bin/sh", + "-c", + "echo 'Use: kiwix-serve [options] ' && kiwix-serve --help", + ] + ) + ) diff --git a/docs/changelog.d/+kiwix-dagger-migration.infra.md b/docs/changelog.d/+kiwix-dagger-migration.infra.md new file mode 100644 index 0000000..462e040 --- /dev/null +++ b/docs/changelog.d/+kiwix-dagger-migration.infra.md @@ -0,0 +1 @@ +Migrated kiwix-serve container from Dockerfile to native Dagger build, bumping Alpine base from 3.22 to 3.23. diff --git a/service-versions.yaml b/service-versions.yaml index 62d8835..6d5226f 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -209,7 +209,7 @@ services: - name: kiwix type: argocd - last-reviewed: 2026-03-05 + last-reviewed: 2026-04-17 current-version: "3.8.2" upstream-source: https://github.com/kiwix/kiwix-tools/releases From 7a42aeb77c1a55f9b1c26e73f1aa9d3d51e2d2ef Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Fri, 17 Apr 2026 14:21:22 -0700 Subject: [PATCH 280/430] Mitigate Forgejo archive endpoint DoS from crawler abuse Crawlers hitting /archive/ endpoints with unique commit SHAs generated 54GB of git bundles in 2 days, pegging Forgejo at 43% CPU. Fix at multiple layers: - Redirect archive requests to tailnet at Fly proxy (302) - Expand robots.txt: block /users/, /*/archive/, /*/releases/download/ - Cache release artifact downloads at nginx (immutable, 7d TTL) - Enable [cron.archive_cleanup] with 2h TTL and run-at-start Co-Authored-By: Claude Opus 4.6 (1M context) --- ansible/roles/forgejo/templates/app.ini.j2 | 6 ++++ .../+forgejo-archive-dos-mitigation.infra.md | 1 + fly/nginx.conf | 34 +++++++++++++++++-- 3 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 docs/changelog.d/+forgejo-archive-dos-mitigation.infra.md diff --git a/ansible/roles/forgejo/templates/app.ini.j2 b/ansible/roles/forgejo/templates/app.ini.j2 index de931be..fe3de38 100644 --- a/ansible/roles/forgejo/templates/app.ini.j2 +++ b/ansible/roles/forgejo/templates/app.ini.j2 @@ -61,6 +61,12 @@ MIN_INTERVAL = 10m [cron.update_checker] ENABLED = false +[cron.archive_cleanup] +ENABLED = true +RUN_AT_START = true +SCHEDULE = @midnight +OLDER_THAN = 2h + [session] PROVIDER = {{ forgejo_session_provider }} diff --git a/docs/changelog.d/+forgejo-archive-dos-mitigation.infra.md b/docs/changelog.d/+forgejo-archive-dos-mitigation.infra.md new file mode 100644 index 0000000..3b9ad84 --- /dev/null +++ b/docs/changelog.d/+forgejo-archive-dos-mitigation.infra.md @@ -0,0 +1 @@ +Mitigated Forgejo archive endpoint DoS: redirect public archive requests to tailnet, expanded robots.txt, enabled archive cleanup cron, cached release downloads at proxy. diff --git a/fly/nginx.conf b/fly/nginx.conf index 995042e..de77ee6 100644 --- a/fly/nginx.conf +++ b/fly/nginx.conf @@ -155,10 +155,10 @@ http { internal; } - # Serve robots.txt directly — block crawlers from mirror repos + # Serve robots.txt directly — block crawlers from expensive endpoints location = /robots.txt { default_type text/plain; - return 200 "User-agent: *\nDisallow: /mirrors/\n"; + return 200 "User-agent: *\nDisallow: /mirrors/\nDisallow: /users/\nDisallow: /*/archive/\nDisallow: /*/releases/download/\n"; } # Block swagger API docs — use forge.ops.eblu.me from tailnet @@ -166,6 +166,15 @@ http { return 403 "API documentation is only available at forge.ops.eblu.me (tailnet).\n"; } + # Redirect archive endpoints to tailnet — archive requests generate full + # git bundles on demand. Unauthenticated crawlers hitting unique commit + # SHAs cause unbounded CPU and disk usage (DoS vector). Legitimate users + # can download via forge.ops.eblu.me on the tailnet. + location ~ ^/[^/]+/[^/]+/archive/ { + default_type text/html; + return 302 https://forge.ops.eblu.me$request_uri; + } + # Rate-limit authentication endpoints location ~ ^/user/(login|sign_up|forgot_password) { limit_req zone=forge_auth burst=5 nodelay; @@ -186,6 +195,27 @@ http { proxy_set_header Connection "upgrade"; } + # Cache release artifact downloads — immutable files keyed by tag+filename. + # Avoids hammering Forgejo when crawlers or users re-download the same asset. + location ~ ^/[^/]+/[^/]+/releases/download/ { + set $upstream_forge_releases https://forge.tail8d86e.ts.net; + proxy_pass $upstream_forge_releases$request_uri; + proxy_ssl_verify off; + proxy_ssl_server_name on; + + proxy_cache services; + proxy_cache_valid 200 7d; + proxy_cache_key $host$uri; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $http_fly_client_ip; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + add_header X-Cache-Status $upstream_cache_status; + add_header X-Clacks-Overhead "GNU Terry Pratchett" always; + } + # Selectively cache static assets only location ~* \.(css|js|png|jpg|svg|woff2?)$ { set $upstream_forge_static https://forge.tail8d86e.ts.net; From 65bc21b16293c8eb0ef492dd83bfd79dc22cdb7e Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Fri, 17 Apr 2026 14:26:29 -0700 Subject: [PATCH 281/430] Add op-based auth to fly-deploy mise task The task was missing FLY_API_TOKEN injection, requiring manual fly auth login. Now uses op read to fetch the deploy token from 1Password. Co-Authored-By: Claude Opus 4.6 (1M context) --- mise-tasks/fly-deploy | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mise-tasks/fly-deploy b/mise-tasks/fly-deploy index bb2b4f8..8d693fd 100755 --- a/mise-tasks/fly-deploy +++ b/mise-tasks/fly-deploy @@ -3,5 +3,8 @@ set -euo pipefail +export FLY_API_TOKEN +FLY_API_TOKEN="$(op read 'op://blumeops/fly.io admin/add more/deploy-token')" + cd "$(dirname "$0")/../fly" fly deploy "$@" From 0a98f76068b0b769a3ebde0330d28f9a5809740f Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Fri, 17 Apr 2026 14:27:42 -0700 Subject: [PATCH 282/430] Update kiwix-serve to Dagger-built container (Alpine 3.23) Points kustomization at v3.8.2-7a42aeb, the first image built from the new container.py (replacing the Dockerfile). Co-Authored-By: Claude Opus 4.6 (1M context) --- argocd/manifests/kiwix/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/argocd/manifests/kiwix/kustomization.yaml b/argocd/manifests/kiwix/kustomization.yaml index 2af4065..1e11bdb 100644 --- a/argocd/manifests/kiwix/kustomization.yaml +++ b/argocd/manifests/kiwix/kustomization.yaml @@ -10,7 +10,7 @@ resources: images: - name: registry.ops.eblu.me/blumeops/kiwix-serve - newTag: v3.8.2-613f05d + newTag: v3.8.2-7a42aeb - name: registry.ops.eblu.me/blumeops/transmission newTag: v4.1.1-r1-613f05d - name: registry.ops.eblu.me/blumeops/kubectl From 1631e111374438a843c59718a83edb106c9bdec4 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Fri, 17 Apr 2026 14:34:24 -0700 Subject: [PATCH 283/430] Add /user/ to forge robots.txt exclusion Crawlers follow auth redirects to /user/login which is pointless for them. Saves round-trips for both sides. Co-Authored-By: Claude Opus 4.6 (1M context) --- fly/nginx.conf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fly/nginx.conf b/fly/nginx.conf index de77ee6..9af2509 100644 --- a/fly/nginx.conf +++ b/fly/nginx.conf @@ -158,7 +158,7 @@ http { # Serve robots.txt directly — block crawlers from expensive endpoints location = /robots.txt { default_type text/plain; - return 200 "User-agent: *\nDisallow: /mirrors/\nDisallow: /users/\nDisallow: /*/archive/\nDisallow: /*/releases/download/\n"; + return 200 "User-agent: *\nDisallow: /mirrors/\nDisallow: /user/\nDisallow: /users/\nDisallow: /*/archive/\nDisallow: /*/releases/download/\n"; } # Block swagger API docs — use forge.ops.eblu.me from tailnet From 8fccbda5739d3b9f4d41724e4d6a8d8253c815a3 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Fri, 17 Apr 2026 14:50:28 -0700 Subject: [PATCH 284/430] Extend Fly proxy latency histogram buckets to 60s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous max bucket was 10s — all slower requests collapsed into +Inf, making p50/p90/p99 unreadable during the Forgejo archive DoS. Co-Authored-By: Claude Opus 4.6 (1M context) --- fly/alloy.river | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fly/alloy.river b/fly/alloy.river index 06ad977..c504247 100644 --- a/fly/alloy.river +++ b/fly/alloy.river @@ -61,7 +61,7 @@ loki.process "nginx" { name = "flyio_nginx_http_request_duration_seconds" description = "HTTP request latency in seconds." source = "request_time" - buckets = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10] + buckets = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 15, 20, 30, 45, 60] } } From d7af0048426548a9c8a50761282cc4d64192df0d Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Fri, 17 Apr 2026 15:05:59 -0700 Subject: [PATCH 285/430] Add Forgejo metrics + upstream latency histogram to Fly proxy dashboard - Enable Forgejo /metrics endpoint (app.ini [metrics] section) - Add Alloy scrape target for Forgejo metrics on indri - Add upstream_response_time histogram to Fly proxy Alloy config - Replace single p95 panel with p50/p90/p99 + upstream breakdown filtered to forge.eblu.me host Co-Authored-By: Claude Opus 4.6 (1M context) --- ansible/roles/alloy/defaults/main.yml | 4 ++ ansible/roles/alloy/templates/config.alloy.j2 | 12 +++++ ansible/roles/forgejo/templates/app.ini.j2 | 5 ++ .../dashboards/configmap-flyio.yaml | 52 ++++++++++++++++++- fly/alloy.river | 9 ++++ 5 files changed, 80 insertions(+), 2 deletions(-) diff --git a/ansible/roles/alloy/defaults/main.yml b/ansible/roles/alloy/defaults/main.yml index fa840d4..4cf7432 100644 --- a/ansible/roles/alloy/defaults/main.yml +++ b/ansible/roles/alloy/defaults/main.yml @@ -101,6 +101,10 @@ alloy_op_vault: vg6xf6vvfmoh5hqjjhlhbeoaie alloy_op_postgres_item: guxu3j7ajhjyey6xxl2ovsl2ui alloy_op_postgres_field: alloy-user-pw +# Forgejo metrics collection +alloy_collect_forgejo: true +alloy_forgejo_port: 3001 + # macOS power metrics collection (via powermetrics, requires root) alloy_collect_power_metrics: true alloy_power_metrics_script: /usr/local/bin/macos-power-metrics diff --git a/ansible/roles/alloy/templates/config.alloy.j2 b/ansible/roles/alloy/templates/config.alloy.j2 index 51d2c94..39e4dad 100644 --- a/ansible/roles/alloy/templates/config.alloy.j2 +++ b/ansible/roles/alloy/templates/config.alloy.j2 @@ -74,6 +74,18 @@ prometheus.scrape "zot" { } {% endif %} +{% if alloy_collect_forgejo | default(false) %} +// ============== FORGEJO METRICS ============== + +// Scrape Forgejo's native metrics endpoint +prometheus.scrape "forgejo" { + targets = [{"__address__" = "localhost:{{ alloy_forgejo_port }}"}] + metrics_path = "/metrics" + forward_to = [prometheus.relabel.instance.receiver] + scrape_interval = "{{ alloy_scrape_interval }}" +} +{% endif %} + {% if alloy_collect_logs %} // ============== LOG COLLECTION ============== diff --git a/ansible/roles/forgejo/templates/app.ini.j2 b/ansible/roles/forgejo/templates/app.ini.j2 index fe3de38..9c5b4d5 100644 --- a/ansible/roles/forgejo/templates/app.ini.j2 +++ b/ansible/roles/forgejo/templates/app.ini.j2 @@ -95,6 +95,11 @@ ACCOUNT_LINKING = login USERNAME = nickname REGISTER_EMAIL_CONFIRM = false +[metrics] +ENABLED = true +ENABLED_ISSUE_BY_LABEL = false +ENABLED_ISSUE_BY_REPOSITORY = false + [actions] ENABLED = {{ forgejo_actions_enabled | lower }} DEFAULT_ACTIONS_URL = {{ forgejo_actions_default_url }} diff --git a/argocd/manifests/grafana-config/dashboards/configmap-flyio.yaml b/argocd/manifests/grafana-config/dashboards/configmap-flyio.yaml index 981f7ea..8d97918 100644 --- a/argocd/manifests/grafana-config/dashboards/configmap-flyio.yaml +++ b/argocd/manifests/grafana-config/dashboards/configmap-flyio.yaml @@ -249,9 +249,57 @@ data: "tooltip": { "mode": "multi", "sort": "desc" } }, "targets": [ - { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "histogram_quantile(0.95, sum by (le) (rate(flyio_nginx_http_request_duration_seconds_bucket{instance=\"flyio-proxy\"}[5m])))", "legendFormat": "p95", "refId": "A" } + { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "histogram_quantile(0.50, sum by (le) (rate(flyio_nginx_http_request_duration_seconds_bucket{instance=\"flyio-proxy\",host=\"forge.eblu.me\"}[5m])))", "legendFormat": "p50", "refId": "A" }, + { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "histogram_quantile(0.90, sum by (le) (rate(flyio_nginx_http_request_duration_seconds_bucket{instance=\"flyio-proxy\",host=\"forge.eblu.me\"}[5m])))", "legendFormat": "p90", "refId": "B" }, + { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "histogram_quantile(0.99, sum by (le) (rate(flyio_nginx_http_request_duration_seconds_bucket{instance=\"flyio-proxy\",host=\"forge.eblu.me\"}[5m])))", "legendFormat": "p99", "refId": "C" } ], - "title": "Upstream Response Time p95", + "title": "Proxy: Latency Percentiles", + "type": "timeseries" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "seconds", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "mappings": [], + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 24, "x": 0, "y": 22 }, + "id": 8, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "right", "showLegend": true }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "targets": [ + { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "histogram_quantile(0.50, sum by (le) (rate(flyio_nginx_upstream_response_time_seconds_bucket{instance=\"flyio-proxy\",host=\"forge.eblu.me\"}[5m])))", "legendFormat": "upstream p50", "refId": "A" }, + { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "histogram_quantile(0.90, sum by (le) (rate(flyio_nginx_upstream_response_time_seconds_bucket{instance=\"flyio-proxy\",host=\"forge.eblu.me\"}[5m])))", "legendFormat": "upstream p90", "refId": "B" }, + { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "histogram_quantile(0.99, sum by (le) (rate(flyio_nginx_upstream_response_time_seconds_bucket{instance=\"flyio-proxy\",host=\"forge.eblu.me\"}[5m])))", "legendFormat": "upstream p99", "refId": "C" } + ], + "title": "Forgejo: Upstream Response Time", "type": "timeseries" } ], diff --git a/fly/alloy.river b/fly/alloy.river index c504247..015583c 100644 --- a/fly/alloy.river +++ b/fly/alloy.river @@ -65,6 +65,15 @@ loki.process "nginx" { } } + stage.metrics { + metric.histogram { + name = "flyio_nginx_upstream_response_time_seconds" + description = "Upstream (Forgejo) response time in seconds, excluding proxy overhead." + source = "upstream_response_time" + buckets = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 15, 20, 30, 45, 60] + } + } + stage.metrics { metric.counter { name = "flyio_nginx_http_response_bytes_total" From 1c0ee099fb5713c4a910b4c4807361e856ea2a60 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Fri, 17 Apr 2026 15:13:40 -0700 Subject: [PATCH 286/430] Move forge-specific latency panels to Forgejo dashboard Fly.io dashboard keeps aggregate all-hosts p50/p90/p99. Forge-filtered upstream response time panel moves to Forgejo's "Public Proxy" section. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../dashboards/configmap-flyio.yaml | 54 ++----------------- .../dashboards/configmap-forgejo.yaml | 46 ++++++++++++++++ 2 files changed, 50 insertions(+), 50 deletions(-) diff --git a/argocd/manifests/grafana-config/dashboards/configmap-flyio.yaml b/argocd/manifests/grafana-config/dashboards/configmap-flyio.yaml index 8d97918..abbf2b3 100644 --- a/argocd/manifests/grafana-config/dashboards/configmap-flyio.yaml +++ b/argocd/manifests/grafana-config/dashboards/configmap-flyio.yaml @@ -249,57 +249,11 @@ data: "tooltip": { "mode": "multi", "sort": "desc" } }, "targets": [ - { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "histogram_quantile(0.50, sum by (le) (rate(flyio_nginx_http_request_duration_seconds_bucket{instance=\"flyio-proxy\",host=\"forge.eblu.me\"}[5m])))", "legendFormat": "p50", "refId": "A" }, - { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "histogram_quantile(0.90, sum by (le) (rate(flyio_nginx_http_request_duration_seconds_bucket{instance=\"flyio-proxy\",host=\"forge.eblu.me\"}[5m])))", "legendFormat": "p90", "refId": "B" }, - { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "histogram_quantile(0.99, sum by (le) (rate(flyio_nginx_http_request_duration_seconds_bucket{instance=\"flyio-proxy\",host=\"forge.eblu.me\"}[5m])))", "legendFormat": "p99", "refId": "C" } + { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "histogram_quantile(0.50, sum by (le) (rate(flyio_nginx_http_request_duration_seconds_bucket{instance=\"flyio-proxy\"}[5m])))", "legendFormat": "p50", "refId": "A" }, + { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "histogram_quantile(0.90, sum by (le) (rate(flyio_nginx_http_request_duration_seconds_bucket{instance=\"flyio-proxy\"}[5m])))", "legendFormat": "p90", "refId": "B" }, + { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "histogram_quantile(0.99, sum by (le) (rate(flyio_nginx_http_request_duration_seconds_bucket{instance=\"flyio-proxy\"}[5m])))", "legendFormat": "p99", "refId": "C" } ], - "title": "Proxy: Latency Percentiles", - "type": "timeseries" - }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "palette-classic" }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "seconds", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 10, - "gradientMode": "none", - "hideFrom": { "legend": false, "tooltip": false, "viz": false }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { "type": "linear" }, - "showPoints": "never", - "spanNulls": false, - "stacking": { "group": "A", "mode": "none" }, - "thresholdsStyle": { "mode": "off" } - }, - "mappings": [], - "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }, - "unit": "s" - }, - "overrides": [] - }, - "gridPos": { "h": 8, "w": 24, "x": 0, "y": 22 }, - "id": 8, - "options": { - "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "right", "showLegend": true }, - "tooltip": { "mode": "multi", "sort": "desc" } - }, - "targets": [ - { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "histogram_quantile(0.50, sum by (le) (rate(flyio_nginx_upstream_response_time_seconds_bucket{instance=\"flyio-proxy\",host=\"forge.eblu.me\"}[5m])))", "legendFormat": "upstream p50", "refId": "A" }, - { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "histogram_quantile(0.90, sum by (le) (rate(flyio_nginx_upstream_response_time_seconds_bucket{instance=\"flyio-proxy\",host=\"forge.eblu.me\"}[5m])))", "legendFormat": "upstream p90", "refId": "B" }, - { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "histogram_quantile(0.99, sum by (le) (rate(flyio_nginx_upstream_response_time_seconds_bucket{instance=\"flyio-proxy\",host=\"forge.eblu.me\"}[5m])))", "legendFormat": "upstream p99", "refId": "C" } - ], - "title": "Forgejo: Upstream Response Time", + "title": "Latency Percentiles (all hosts)", "type": "timeseries" } ], diff --git a/argocd/manifests/grafana-config/dashboards/configmap-forgejo.yaml b/argocd/manifests/grafana-config/dashboards/configmap-forgejo.yaml index f0de4aa..782135e 100644 --- a/argocd/manifests/grafana-config/dashboards/configmap-forgejo.yaml +++ b/argocd/manifests/grafana-config/dashboards/configmap-forgejo.yaml @@ -858,6 +858,52 @@ data: "title": "Proxy: Bandwidth", "type": "timeseries" }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "seconds", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "mappings": [], + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 24, "x": 0, "y": 45 }, + "id": 22, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "right", "showLegend": true }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "targets": [ + { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "histogram_quantile(0.50, sum by (le) (rate(flyio_nginx_upstream_response_time_seconds_bucket{host=\"forge.eblu.me\"}[5m])))", "legendFormat": "upstream p50", "refId": "A" }, + { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "histogram_quantile(0.90, sum by (le) (rate(flyio_nginx_upstream_response_time_seconds_bucket{host=\"forge.eblu.me\"}[5m])))", "legendFormat": "upstream p90", "refId": "B" }, + { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "histogram_quantile(0.99, sum by (le) (rate(flyio_nginx_upstream_response_time_seconds_bucket{host=\"forge.eblu.me\"}[5m])))", "legendFormat": "upstream p99", "refId": "C" } + ], + "title": "Forgejo: Upstream Response Time", + "type": "timeseries" + }, { "datasource": { "type": "loki", "uid": "loki" }, "gridPos": { "h": 8, "w": 24, "x": 0, "y": 45 }, From 54b1cee9503af6d3e68284e48df78660037508e7 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Fri, 17 Apr 2026 15:27:40 -0700 Subject: [PATCH 287/430] Fix Connection header: only send 'upgrade' for WebSocket requests Was sending Connection: upgrade on every proxied request, which is semantically wrong for normal HTTP traffic. Use a map to conditionally send 'upgrade' only when the client requests a WebSocket switch, 'close' otherwise. Co-Authored-By: Claude Opus 4.6 (1M context) --- fly/nginx.conf | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/fly/nginx.conf b/fly/nginx.conf index 9af2509..db02a21 100644 --- a/fly/nginx.conf +++ b/fly/nginx.conf @@ -52,6 +52,14 @@ http { resolver 100.100.100.100 valid=30s; resolver_timeout 5s; + # WebSocket-aware Connection header. Only send "upgrade" when the client + # actually requests a protocol switch; otherwise "close" (the HTTP/1.1 + # default when keepalive pooling is not available). + map $http_upgrade $connection_upgrade { + default close; + websocket upgrade; + } + # --- docs.eblu.me (static site) --- server { listen 8080; @@ -192,7 +200,7 @@ http { proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; + proxy_set_header Connection $connection_upgrade; } # Cache release artifact downloads — immutable files keyed by tag+filename. @@ -248,7 +256,7 @@ http { # WebSocket support (Forgejo uses it for live updates) proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; + proxy_set_header Connection $connection_upgrade; add_header X-Clacks-Overhead "GNU Terry Pratchett" always; } From fe0e913963729db3f7c1dccbc845ecfe3e17c71e Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Fri, 17 Apr 2026 16:39:52 -0700 Subject: [PATCH 288/430] Switch Fly proxy to upstream keepalive pools (#337) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Replace per-request DNS resolution (variable-based `proxy_pass`) with static `upstream` blocks and `keepalive` connection pools - Reuses TLS connections through the Tailscale tunnel instead of handshaking per request - Add `mise run fly-reload` for nginx config reload without full redeploy (re-resolves upstream DNS) ## Trade-off DNS is resolved at config load, not per-request. If Tailscale Ingress pods get new IPs (restart, reschedule), `mise run fly-reload` is needed. A Grafana alert will be added to detect this. ## Still TODO on this branch - [ ] Grafana alert for upstream unreachable (triggers fly-reload reminder) - [ ] Docs pass - [ ] Deploy from branch and verify latency improvement - [ ] Changelog fragment 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/337 --- argocd/manifests/grafana/alerting.yaml | 60 ++++++++++++ .../tailscale-operator-base/proxyclass.yaml | 4 + docs/changelog.d/fly-proxy-keepalive.infra.md | 1 + docs/how-to/operations/manage-flyio-proxy.md | 20 +++- docs/reference/infrastructure/routing.md | 3 +- docs/reference/services/flyio-proxy.md | 33 ++++++- docs/reference/services/forgejo.md | 14 ++- docs/reference/tools/mise-tasks.md | 3 +- docs/tutorials/expose-service-publicly.md | 97 ++++++------------- fly/nginx.conf | 70 +++++++++---- fly/start.sh | 10 +- mise-tasks/fly-reload | 16 +++ 12 files changed, 229 insertions(+), 102 deletions(-) create mode 100644 docs/changelog.d/fly-proxy-keepalive.infra.md create mode 100755 mise-tasks/fly-reload diff --git a/argocd/manifests/grafana/alerting.yaml b/argocd/manifests/grafana/alerting.yaml index b220044..4ae70d3 100644 --- a/argocd/manifests/grafana/alerting.yaml +++ b/argocd/manifests/grafana/alerting.yaml @@ -373,6 +373,66 @@ groups: type: and refId: C + - orgId: 1 + name: flyio-proxy-health + folder: Infrastructure Alerts + interval: 30s + rules: + - uid: flyio-upstream-unreachable + title: FlyioUpstreamUnreachable + condition: C + for: 3m + noDataState: OK + execErrState: Alerting + annotations: + summary: >- + Fly.io proxy returning elevated 502s — upstream DNS may be stale. Run: mise run fly-reload + runbook_url: https://docs.eblu.me/how-to/operations/manage-flyio-proxy + labels: + severity: warning + service: flyio-proxy + data: + - refId: A + datasourceUid: prometheus + relativeTimeRange: + from: 300 + to: 0 + model: + expr: >- + sum(rate(flyio_nginx_http_requests_total{instance="flyio-proxy",status="502"}[5m])) + / sum(rate(flyio_nginx_http_requests_total{instance="flyio-proxy"}[5m])) + > 0.5 + interval: "" + refId: A + - refId: B + datasourceUid: "__expr__" + relativeTimeRange: + from: 0 + to: 0 + model: + type: reduce + expression: A + reducer: last + settings: + mode: dropNN + refId: B + - refId: C + datasourceUid: "__expr__" + relativeTimeRange: + from: 0 + to: 0 + model: + type: threshold + expression: B + conditions: + - evaluator: + type: gt + params: + - 0 + operator: + type: and + refId: C + templates: - orgId: 1 name: ntfy-infra diff --git a/argocd/manifests/tailscale-operator-base/proxyclass.yaml b/argocd/manifests/tailscale-operator-base/proxyclass.yaml index a5c4675..9fb46d6 100644 --- a/argocd/manifests/tailscale-operator-base/proxyclass.yaml +++ b/argocd/manifests/tailscale-operator-base/proxyclass.yaml @@ -21,5 +21,9 @@ spec: pod: tailscaleContainer: image: docker.io/tailscale/tailscale:v1.94.2 + resources: + requests: + cpu: 100m + memory: 128Mi tailscaleInitContainer: image: docker.io/tailscale/tailscale:v1.94.2 diff --git a/docs/changelog.d/fly-proxy-keepalive.infra.md b/docs/changelog.d/fly-proxy-keepalive.infra.md new file mode 100644 index 0000000..8853150 --- /dev/null +++ b/docs/changelog.d/fly-proxy-keepalive.infra.md @@ -0,0 +1 @@ +Switched Fly proxy to upstream keepalive pools, reducing forge.eblu.me latency from 35s+ p50 to sub-second. Added `mise run fly-reload` for DNS re-resolution without redeploy. diff --git a/docs/how-to/operations/manage-flyio-proxy.md b/docs/how-to/operations/manage-flyio-proxy.md index 519481f..73e61d1 100644 --- a/docs/how-to/operations/manage-flyio-proxy.md +++ b/docs/how-to/operations/manage-flyio-proxy.md @@ -1,7 +1,7 @@ --- title: Manage Fly.io Proxy -modified: 2026-02-08 -last-reviewed: 2026-03-07 +modified: 2026-04-17 +last-reviewed: 2026-04-17 tags: - how-to - fly-io @@ -23,6 +23,16 @@ mise run fly-deploy Pushes to `fly/` on main also trigger automatic deployment via the Forgejo CI workflow. +## Reload Nginx (Re-resolve Upstream DNS) + +Nginx uses `upstream` blocks with keepalive connection pools. DNS is resolved at config load. If Tailscale Ingress pods get new IPs (restart, reschedule, minikube restart), reload nginx to re-resolve without a full redeploy: + +```bash +mise run fly-reload +``` + +A Grafana alert fires when upstreams are unreachable, prompting this action. A full `fly-deploy` also re-resolves DNS (it replaces the container). + ## Add a New Public Service See [[expose-service-publicly#Per-service setup]] for the full walkthrough. In short: @@ -78,12 +88,16 @@ The auth key expires every 90 days. To rotate: ## Troubleshooting -**502 Bad Gateway**: Check `fly logs` for nginx upstream errors. Verify the backend Tailscale service is running (`tailscale status` from inside the container via `fly ssh console`). +**502 Bad Gateway after Tailscale Ingress restart**: Upstream DNS is stale. Run `mise run fly-reload` to re-resolve. This is the most common cause of 502s. + +**502 Bad Gateway on fresh deploy**: MagicDNS may not be ready when nginx starts. The `start.sh` script polls `nslookup` before launching nginx, but if it still fails, check that `tailscale status` is healthy inside the container. **Health check failing**: `fly ssh console -a blumeops-proxy` then `curl localhost:8080/healthz` to test locally. **TLS errors on custom domain**: Check cert status with `fly certs show -a blumeops-proxy`. Certs auto-provision via Let's Encrypt and may take a few minutes. +**High latency (>1s p50)**: Likely lost keepalive — redeploy with `mise run fly-deploy`. Before the keepalive change (April 2026), per-request TLS handshakes through the WireGuard tunnel caused 35s+ p50 at >1 req/s. + ## Related - [[flyio-proxy]] - Service reference card diff --git a/docs/reference/infrastructure/routing.md b/docs/reference/infrastructure/routing.md index a8049d6..229e724 100644 --- a/docs/reference/infrastructure/routing.md +++ b/docs/reference/infrastructure/routing.md @@ -1,6 +1,6 @@ --- title: Routing -modified: 2026-03-03 +modified: 2026-04-17 tags: - infrastructure - networking @@ -51,6 +51,7 @@ DNS CNAMEs point to `blumeops-proxy.fly.dev`. TLS via Fly.io-managed Let's Encry | Service | URL | Description | |---------|-----|-------------| | [[docs]] | https://docs.eblu.me | Documentation site | +| [[cv]] | https://cv.eblu.me | CV / resume | | [[forgejo]] | https://forge.eblu.me | Git hosting (public) | ## Tailscale-Only Services diff --git a/docs/reference/services/flyio-proxy.md b/docs/reference/services/flyio-proxy.md index 3c66d4e..ad32b8a 100644 --- a/docs/reference/services/flyio-proxy.md +++ b/docs/reference/services/flyio-proxy.md @@ -1,6 +1,6 @@ --- title: Fly.io Proxy -modified: 2026-02-08 +modified: 2026-04-17 tags: - service - networking @@ -26,11 +26,21 @@ Public reverse proxy on [Fly.io](https://fly.io) that exposes selected BlumeOps | Public domain | Backend | Service | |---------------|---------|---------| | `docs.eblu.me` | `docs.tail8d86e.ts.net` | [[docs]] | +| `cv.eblu.me` | `cv.tail8d86e.ts.net` | [[cv]] | +| `forge.eblu.me` | `forge.tail8d86e.ts.net` | [[forgejo]] | ## Architecture Internet traffic hits Fly.io's Anycast edge, terminates TLS with a Let's Encrypt certificate, and is proxied by nginx to the backend service over a Tailscale WireGuard tunnel. See [[expose-service-publicly]] for the full architecture diagram. +### Upstream Keepalive + +Nginx uses `upstream` blocks with `keepalive` connection pools to reuse TLS connections through the WireGuard tunnel. This avoids a per-request TLS handshake, which was previously the dominant source of latency (35s+ p50 before keepalive, sub-second after). + +**Trade-off:** DNS for upstream hostnames is resolved once at config load, not per-request. If Tailscale Ingress pods get new IPs (restart, reschedule, minikube restart), run `mise run fly-reload` to re-resolve without a full redeploy. A Grafana alert fires when upstreams are unreachable. + +Each upstream requires `proxy_ssl_name` set to the actual Tailscale hostname — nginx sends the upstream block name as SNI by default, which the Tailscale Ingress proxy won't recognize. + ## Key Files | File | Purpose | @@ -39,7 +49,7 @@ Internet traffic hits Fly.io's Anycast edge, terminates TLS with a Let's Encrypt | `fly/Dockerfile` | nginx + Tailscale + Alloy container | | `fly/nginx.conf` | Reverse proxy, caching, rate limiting, JSON logging | | `fly/alloy.river` | Alloy config: log tailing, metric extraction, remote_write | -| `fly/start.sh` | Entrypoint: start Tailscale, Alloy, then nginx | +| `fly/start.sh` | Entrypoint: start Tailscale, wait for MagicDNS, then nginx + Alloy | | `pulumi/tailscale/__main__.py` | Auth key (`tag:flyio-proxy`) | | `pulumi/tailscale/policy.hujson` | ACL grants for proxy | | `pulumi/gandi/__main__.py` | DNS CNAMEs | @@ -57,7 +67,8 @@ The Tailscale auth key is `preauthorized=True` to avoid device approval hangs on - **Logs**: nginx JSON access logs tailed and pushed to [[loki|Loki]] (`{instance="flyio-proxy", job="flyio-nginx"}`) - **Metrics**: Derived from access logs, pushed to [[prometheus|Prometheus]] via `remote_write` - `flyio_nginx_http_requests_total` — request rate by status/method/host - - `flyio_nginx_http_request_duration_seconds` — latency histogram + - `flyio_nginx_http_request_duration_seconds` — total request latency histogram (includes proxy overhead) + - `flyio_nginx_upstream_response_time_seconds` — backend response time histogram (Forgejo processing only) - `flyio_nginx_http_response_bytes_total` — response bandwidth - `flyio_nginx_cache_requests_total` — cache HIT/MISS/EXPIRED counts @@ -74,7 +85,21 @@ Alloy listens on `127.0.0.1:12345` for self-scraping its `/metrics` endpoint. Al The `tag:flyio-proxy` ACL grants access only to `tag:flyio-target:443`. Services must explicitly opt in by adding a `tailscale.com/tags: "tag:k8s,tag:flyio-target"` annotation to their Tailscale Ingress. This means the proxy can only reach endpoints that have been individually tagged — a compromised nginx config cannot route to arbitrary services on the tailnet. -Currently tagged as `tag:flyio-target`: [[docs]], [[loki]], [[prometheus]]. Loki and Prometheus are tagged so that [[alloy|Alloy]] (running inside the container) can push logs and metrics directly via their Tailscale Ingress endpoints — the restricted ACL means Caddy on indri (`tag:homelab`) is not reachable from the proxy. +Currently tagged as `tag:flyio-target`: [[docs]], [[cv]], [[forgejo]], [[loki]], [[prometheus]]. Loki and Prometheus are tagged so that [[alloy|Alloy]] (running inside the container) can push logs and metrics directly via their Tailscale Ingress endpoints — the restricted ACL means Caddy on indri (`tag:homelab`) is not reachable from the proxy. + +### Crawler Mitigation + +The proxy serves a `robots.txt` blocking crawlers from expensive endpoints: + +- `/mirrors/` — large mirrored repos +- `/user/` — auth endpoints (crawlers follow redirect loops) +- `/users/` — user profile pages +- `/*/archive/` — git bundle generation (DoS vector, see below) +- `/*/releases/download/` — release artifacts + +Archive requests (`///archive/*`) are 302-redirected to `forge.ops.eblu.me` (tailnet-only), preventing unauthenticated archive generation. This mitigates a known Forgejo DoS vector where crawlers requesting unique commit SHAs trigger unbounded git bundle generation. + +Release downloads are cached at the proxy layer (7-day TTL, keyed by URI) to absorb repeated downloads of the same artifact. To expose an additional service through the proxy, add the `tag:flyio-target` annotation to its Tailscale Ingress. See [[expose-service-publicly]] for the full workflow. diff --git a/docs/reference/services/forgejo.md b/docs/reference/services/forgejo.md index ad64cf4..11bb9a5 100644 --- a/docs/reference/services/forgejo.md +++ b/docs/reference/services/forgejo.md @@ -1,6 +1,6 @@ --- title: Forgejo -modified: 2026-03-28 +modified: 2026-04-17 tags: - service - git @@ -148,12 +148,24 @@ The UI shows `forge.eblu.me` for HTTPS clone URLs and `forge.ops.eblu.me` for SS - **Rate limiting:** nginx rate limits login/signup/forgot-password endpoints (3r/s per client IP via `Fly-Client-IP` header) - **fail2ban:** Runs in the Fly.io container; bans IPs after 5 failed logins in 10 minutes via nginx deny list (ephemeral across deploys) - **Swagger:** Blocked at the proxy (`/swagger` returns 403); use forge.ops.eblu.me for API access +- **Archive redirect:** Archive endpoints (`/*/archive/*`) are 302-redirected to `forge.ops.eblu.me` — prevents unauthenticated crawlers from triggering unbounded git bundle generation (known DoS vector, see [[flyio-proxy#Crawler Mitigation]]) +- **robots.txt:** Blocks crawlers from `/mirrors/`, `/user/`, `/users/`, `/*/archive/`, `/*/releases/download/` - **OAuth dead-end:** "Sign in with Authentik" redirects to the (tailnet-only) Authentik URL — SSO only works from the tailnet ### Break-glass `mise run fly-shutoff` stops all public traffic immediately. forge.ops.eblu.me continues to work from the tailnet. See [[expose-service-publicly#Break-glass shutoff]]. +## Monitoring + +Forgejo exposes a Prometheus `/metrics` endpoint (enabled via `[metrics]` in `app.ini`). Alloy on indri scrapes it at `localhost:3001/metrics`. Metrics are mostly Go runtime stats and repo counters (no per-request latency histogram). + +Request latency is measured at the Fly.io proxy layer via the `flyio_nginx_upstream_response_time_seconds` histogram, visible on the Forgejo Grafana dashboard under "Forgejo: Upstream Response Time". + +### Archive Cleanup + +The `[cron.archive_cleanup]` section is enabled with `OLDER_THAN = 2h` and `RUN_AT_START = true`. This prevents the `repo-archive/` directory from growing unboundedly when crawlers or users trigger archive downloads. Without this, the directory grew to 54GB in 2 days during a crawler incident in April 2026. + ## Mirrors Forgejo hosts pull mirrors of external repositories (GitHub, etc.) for supply chain control. Mirrors live in the `mirrors/` org and sync on a configurable interval. See [[manage-forgejo-mirrors]] for operations. diff --git a/docs/reference/tools/mise-tasks.md b/docs/reference/tools/mise-tasks.md index 02b8859..fefb30f 100644 --- a/docs/reference/tools/mise-tasks.md +++ b/docs/reference/tools/mise-tasks.md @@ -33,7 +33,8 @@ Run `mise tasks --sort name` for the live list with descriptions. | `provision-indri` | Run Ansible playbook for [[indri]] | | `provision-ringtail` | Run Ansible playbook for [[ringtail]] (NixOS) | | `provision-sifaka` | Run Ansible playbook for [[sifaka]] | -| `fly-deploy` | Deploy Fly.io public proxy | +| `fly-deploy` | Deploy Fly.io public proxy (uses op for auth) | +| `fly-reload` | Reload nginx config, re-resolve upstream DNS (no redeploy) | | `fly-setup` | One-time Fly.io secrets and certs setup | | `fly-shutoff` | Emergency shutoff: stop all Fly.io proxy machines | | `dns-preview` | Preview DNS changes with [[pulumi]] | diff --git a/docs/tutorials/expose-service-publicly.md b/docs/tutorials/expose-service-publicly.md index b3fdda6..9a44c15 100644 --- a/docs/tutorials/expose-service-publicly.md +++ b/docs/tutorials/expose-service-publicly.md @@ -1,7 +1,7 @@ --- title: Expose a Service Publicly -modified: 2026-03-15 -last-reviewed: 2026-03-03 +modified: 2026-04-17 +last-reviewed: 2026-04-17 tags: - tutorials - fly-io @@ -116,8 +116,8 @@ See the actual files in `fly/` for current configuration. Key design points: - **`fly.toml`** — uses bluegreen deploys so the old machine serves traffic until the new one passes health checks. `auto_stop_machines = "off"` keeps the proxy always-on. - **`Dockerfile`** — multi-stage build pulling nginx, Tailscale, and [[alloy]] binaries. Alloy runs as a sidecar inside the container for observability (see below). -- **`start.sh`** — starts `tailscaled` first (MagicDNS must be available before nginx resolves upstreams), then nginx in the background, then Alloy, and blocks on the nginx process. -- **`nginx.conf`** — uses a `resolver 100.100.100.100` directive so upstream DNS resolution is deferred to request time (not config load time). Each service gets a `server` block with a `set $upstream` variable pattern. Includes a JSON access log format that Alloy tails for log collection and metric extraction. A catch-all server block serves `/healthz` and rejects unknown hosts. +- **`start.sh`** — starts `tailscaled` first, waits for MagicDNS readiness (polls `nslookup` against `100.100.100.100`), then starts nginx, fail2ban, and Alloy, and blocks on the nginx process. The MagicDNS check is required because `upstream` blocks resolve DNS at config load. +- **`nginx.conf`** — uses `upstream` blocks with `keepalive` connection pools for each backend service. DNS is resolved at config load via MagicDNS (`resolver 100.100.100.100`). Each upstream requires `proxy_ssl_name` set explicitly to the Tailscale hostname (nginx sends the block name as SNI by default). A `map` directive conditionally sets the `Connection` header — empty string for keepalive on normal requests, `upgrade` only for WebSocket requests. Includes a JSON access log format that Alloy tails for log collection and metric extraction. A catch-all server block serves `/healthz` and rejects unknown hosts. - **`error.html`** — shown via `proxy_intercept_errors` when upstreams are unreachable (indri offline, tunnel down, etc.). Cached responses still take priority via `proxy_cache_use_stale`. #### Observability sidecar @@ -216,11 +216,18 @@ To expose an additional service (example: `wiki.eblu.me`): ### 1. Add nginx server block -Edit `fly/nginx.conf` — add a new `server` block. The configuration -differs significantly between static and dynamic services. See the -existing `docs.eblu.me` and `cv.eblu.me` blocks in `fly/nginx.conf` -for the current pattern (uses `set $upstream` variable for deferred -DNS resolution, `proxy_intercept_errors` for error pages, etc.). +Edit `fly/nginx.conf` — two changes needed: + +1. **Add an `upstream` block** (in the `http` context, alongside the existing ones): + +```nginx +upstream wiki_backend { + server wiki.tail8d86e.ts.net:443; + keepalive 4; +} +``` + +2. **Add a `server` block.** The configuration differs significantly between static and dynamic services. See the existing blocks in `fly/nginx.conf` for the current pattern. **Static site template** (simplified — adapt from existing blocks): @@ -239,12 +246,16 @@ server { } location / { - set $upstream_wiki https://wiki.tail8d86e.ts.net; - proxy_pass $upstream_wiki$request_uri; + proxy_pass https://wiki_backend$request_uri; proxy_ssl_verify off; proxy_ssl_server_name on; + proxy_ssl_name wiki.tail8d86e.ts.net; + proxy_set_header Host wiki.tail8d86e.ts.net; proxy_intercept_errors on; + proxy_http_version 1.1; + proxy_set_header Connection $connection_upgrade; + proxy_cache services; proxy_cache_valid 200 1d; proxy_cache_valid 404 1m; @@ -259,66 +270,12 @@ server { } ``` -**Dynamic service template** (e.g., Forgejo — see `fly/nginx.conf` for the live configuration): +**Key points for all upstream blocks:** +- `proxy_ssl_name` must be set explicitly — nginx sends the upstream block name as SNI by default, which the Tailscale Ingress won't recognize +- `proxy_http_version 1.1` + `Connection $connection_upgrade` enables keepalive (empty string for normal requests, "upgrade" for WebSocket) +- `keepalive` pool size: 4 for low-traffic static sites, 8 for higher-traffic dynamic services -```nginx -# --- forge.eblu.me (dynamic, authenticated) --- -server { - listen 8080; - server_name forge.eblu.me; - - # Higher rate limit — git operations, CI webhooks, and API calls - # can legitimately burst. Forgejo also has its own rate limiting, - # so this is a safety net, not the primary control. - limit_req zone=general burst=50 nodelay; - - # Git LFS and repo uploads can be large - client_max_body_size 512m; - - error_page 502 503 504 /error.html; - location = /error.html { - root /usr/share/nginx/html; - internal; - } - - location / { - set $upstream_forge https://forge.tail8d86e.ts.net; - proxy_pass $upstream_forge$request_uri; - proxy_ssl_verify off; - proxy_ssl_server_name on; - proxy_intercept_errors on; - - # NO proxy_cache — dynamic content with sessions. - # Caching would serve stale pages and break authentication. - - # Pass through headers needed for proper proxying - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - - # WebSocket support (Forgejo uses it for live updates) - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - } - - # Selectively cache static assets only - location ~* \.(css|js|png|jpg|svg|woff2?)$ { - set $upstream_forge_static https://forge.tail8d86e.ts.net; - proxy_pass $upstream_forge_static$request_uri; - proxy_ssl_verify off; - proxy_ssl_server_name on; - - proxy_cache services; - proxy_cache_valid 200 7d; - proxy_cache_key $host$uri; - - add_header X-Cache-Status $upstream_cache_status; - add_header X-Clacks-Overhead "GNU Terry Pratchett" always; - } -} -``` +**Dynamic service template** — see `fly/nginx.conf` for the live Forgejo configuration, which includes rate-limited auth endpoints, cached static assets and release downloads, archive endpoint redirects, robots.txt, and WebSocket support. Key differences for dynamic services: - **No blanket caching** — only static assets (CSS, JS, images) are cached diff --git a/fly/nginx.conf b/fly/nginx.conf index db02a21..5723722 100644 --- a/fly/nginx.conf +++ b/fly/nginx.conf @@ -46,18 +46,32 @@ http { proxy_cache_path /tmp/cache levels=1:2 keys_zone=services:10m max_size=200m inactive=24h; - # MagicDNS resolver — using a variable in proxy_pass defers upstream DNS - # resolution to request time (not config time). Results are cached for - # 30s per worker to avoid per-request DNS lookups. + # WebSocket-aware Connection header. Only send "upgrade" when the client + # actually requests a protocol switch; otherwise empty string to preserve + # upstream keepalive connections. + map $http_upgrade $connection_upgrade { + default ""; + websocket upgrade; + } + + # --- Upstream pools with keepalive --- + # DNS is resolved once at config load via MagicDNS. If Tailscale Ingress + # pods get new IPs (restart, reschedule), run `mise run fly-reload` to + # re-resolve. A Grafana alert fires when upstreams are unreachable. resolver 100.100.100.100 valid=30s; resolver_timeout 5s; - # WebSocket-aware Connection header. Only send "upgrade" when the client - # actually requests a protocol switch; otherwise "close" (the HTTP/1.1 - # default when keepalive pooling is not available). - map $http_upgrade $connection_upgrade { - default close; - websocket upgrade; + upstream forge_backend { + server forge.tail8d86e.ts.net:443; + keepalive 8; + } + upstream docs_backend { + server docs.tail8d86e.ts.net:443; + keepalive 4; + } + upstream cv_backend { + server cv.tail8d86e.ts.net:443; + keepalive 4; } # --- docs.eblu.me (static site) --- @@ -76,12 +90,16 @@ http { internal; } location / { - set $upstream_docs https://docs.tail8d86e.ts.net; - proxy_pass $upstream_docs$request_uri; + proxy_pass https://docs_backend$request_uri; proxy_ssl_verify off; proxy_ssl_server_name on; + proxy_ssl_name docs.tail8d86e.ts.net; + proxy_set_header Host docs.tail8d86e.ts.net; proxy_intercept_errors on; + proxy_http_version 1.1; + proxy_set_header Connection $connection_upgrade; + # Cache aggressively — static site only. # Do NOT use these settings for dynamic services. proxy_cache services; @@ -116,12 +134,16 @@ http { } location / { - set $upstream_cv https://cv.tail8d86e.ts.net; - proxy_pass $upstream_cv$request_uri; + proxy_pass https://cv_backend$request_uri; proxy_ssl_verify off; proxy_ssl_server_name on; + proxy_ssl_name cv.tail8d86e.ts.net; + proxy_set_header Host cv.tail8d86e.ts.net; proxy_intercept_errors on; + proxy_http_version 1.1; + proxy_set_header Connection $connection_upgrade; + proxy_cache services; proxy_cache_valid 200 1d; proxy_cache_valid 404 1m; @@ -187,10 +209,10 @@ http { location ~ ^/user/(login|sign_up|forgot_password) { limit_req zone=forge_auth burst=5 nodelay; - set $upstream_forge https://forge.tail8d86e.ts.net; - proxy_pass $upstream_forge$request_uri; + proxy_pass https://forge_backend$request_uri; proxy_ssl_verify off; proxy_ssl_server_name on; + proxy_ssl_name forge.tail8d86e.ts.net; proxy_intercept_errors on; proxy_set_header Host $host; @@ -206,10 +228,13 @@ http { # Cache release artifact downloads — immutable files keyed by tag+filename. # Avoids hammering Forgejo when crawlers or users re-download the same asset. location ~ ^/[^/]+/[^/]+/releases/download/ { - set $upstream_forge_releases https://forge.tail8d86e.ts.net; - proxy_pass $upstream_forge_releases$request_uri; + proxy_pass https://forge_backend$request_uri; proxy_ssl_verify off; proxy_ssl_server_name on; + proxy_ssl_name forge.tail8d86e.ts.net; + + proxy_http_version 1.1; + proxy_set_header Connection $connection_upgrade; proxy_cache services; proxy_cache_valid 200 7d; @@ -226,10 +251,13 @@ http { # Selectively cache static assets only location ~* \.(css|js|png|jpg|svg|woff2?)$ { - set $upstream_forge_static https://forge.tail8d86e.ts.net; - proxy_pass $upstream_forge_static$request_uri; + proxy_pass https://forge_backend$request_uri; proxy_ssl_verify off; proxy_ssl_server_name on; + proxy_ssl_name forge.tail8d86e.ts.net; + + proxy_http_version 1.1; + proxy_set_header Connection $connection_upgrade; proxy_cache services; proxy_cache_valid 200 7d; @@ -240,10 +268,10 @@ http { } location / { - set $upstream_forge https://forge.tail8d86e.ts.net; - proxy_pass $upstream_forge$request_uri; + proxy_pass https://forge_backend$request_uri; proxy_ssl_verify off; proxy_ssl_server_name on; + proxy_ssl_name forge.tail8d86e.ts.net; proxy_intercept_errors on; # NO proxy_cache — dynamic content with sessions diff --git a/fly/start.sh b/fly/start.sh index 5b08490..8fd1fd4 100644 --- a/fly/start.sh +++ b/fly/start.sh @@ -11,10 +11,18 @@ tailscale up --authkey="${TS_AUTHKEY}" --hostname=flyio-proxy until tailscale status > /dev/null 2>&1; do sleep 1; done echo "Tailscale connected" +# Wait for MagicDNS to be ready — upstream blocks resolve DNS at config +# load, so nginx will fail to start if MagicDNS can't resolve yet. +echo "Waiting for MagicDNS..." +until nslookup forge.tail8d86e.ts.net 100.100.100.100 > /dev/null 2>&1; do + sleep 1 +done +echo "MagicDNS ready" + # Ensure fail2ban deny file exists before nginx starts touch /etc/nginx/forge-deny.conf -# Start nginx — MagicDNS is available, health check passes immediately. +# Start nginx — MagicDNS is available, upstreams resolved. nginx -g "daemon off;" & NGINX_PID=$! echo "Nginx started" diff --git a/mise-tasks/fly-reload b/mise-tasks/fly-reload new file mode 100755 index 0000000..34806c5 --- /dev/null +++ b/mise-tasks/fly-reload @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +#MISE description="Reload Fly.io proxy nginx config (re-resolves upstream DNS)" + +set -euo pipefail + +export FLY_API_TOKEN +FLY_API_TOKEN="$(op read 'op://blumeops/fly.io admin/add more/deploy-token')" + +# SSH into the Fly machine and send nginx a reload signal. +# This re-resolves upstream DNS without a full redeploy. +APP="blumeops-proxy" +MACHINE_ID=$(fly machines list -a "$APP" --json | python3 -c "import sys,json; print(json.load(sys.stdin)[0]['id'])") + +echo "Reloading nginx on machine $MACHINE_ID..." +fly ssh console -a "$APP" -C "nginx -s reload" +echo "Done. Upstream DNS re-resolved." From 37b8a21524b6902bcdc5e641085e37964fade47e Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sat, 18 Apr 2026 07:57:05 -0700 Subject: [PATCH 289/430] Migrate devpi to Dagger build and bump to 6.19.3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace Dockerfile with container.py for native Dagger builds. Bump devpi-server 6.19.1→6.19.3, devpi-web 5.0.1→5.0.2. Co-Authored-By: Claude Opus 4.6 (1M context) --- containers/devpi/Dockerfile | 33 ----------- containers/devpi/container.py | 56 +++++++++++++++++++ .../+devpi-dagger-migration.infra.md | 1 + service-versions.yaml | 4 +- 4 files changed, 59 insertions(+), 35 deletions(-) delete mode 100644 containers/devpi/Dockerfile create mode 100644 containers/devpi/container.py create mode 100644 docs/changelog.d/+devpi-dagger-migration.infra.md diff --git a/containers/devpi/Dockerfile b/containers/devpi/Dockerfile deleted file mode 100644 index 69e14c3..0000000 --- a/containers/devpi/Dockerfile +++ /dev/null @@ -1,33 +0,0 @@ -ARG CONTAINER_APP_VERSION=6.19.1 - -FROM python:3.12-slim - -ARG CONTAINER_APP_VERSION -ARG DEVPI_SERVER_VERSION=${CONTAINER_APP_VERSION} - -LABEL org.opencontainers.image.title="devpi" -LABEL org.opencontainers.image.description="devpi PyPI server and caching proxy" -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" -ARG DEVPI_WEB_VERSION=5.0.1 - -# Install devpi-server and devpi-web -RUN pip install --no-cache-dir \ - devpi-server==${DEVPI_SERVER_VERSION} \ - devpi-web==${DEVPI_WEB_VERSION} - -# Create non-root user -RUN useradd -r -u 1000 devpi && mkdir -p /devpi && chown devpi:devpi /devpi - -# Add startup script -COPY --chown=devpi:devpi start.sh /usr/local/bin/start.sh -RUN chmod +x /usr/local/bin/start.sh - -USER devpi -WORKDIR /devpi - -# Expose default port -EXPOSE 3141 - -ENTRYPOINT ["/usr/local/bin/start.sh"] diff --git a/containers/devpi/container.py b/containers/devpi/container.py new file mode 100644 index 0000000..0067e95 --- /dev/null +++ b/containers/devpi/container.py @@ -0,0 +1,56 @@ +"""devpi PyPI server and caching proxy — native Dagger build. + +Single-stage build: install devpi-server and devpi-web into a Python slim image. +""" + +import dagger +from dagger import dag + +from blumeops.containers import oci_labels + +VERSION = "6.19.3" + +DEVPI_WEB_VERSION = "5.0.2" +PYTHON_BASE = "python:3.12-slim" + + +async def build(src: dagger.Directory) -> dagger.Container: + ctr = ( + dag.container() + .from_(PYTHON_BASE) + .with_exec( + [ + "pip", + "install", + "--no-cache-dir", + f"devpi-server=={VERSION}", + f"devpi-web=={DEVPI_WEB_VERSION}", + ] + ) + .with_exec( + [ + "useradd", + "-r", + "-u", + "1000", + "devpi", + ] + ) + .with_exec(["mkdir", "-p", "/devpi"]) + .with_exec(["chown", "devpi:devpi", "/devpi"]) + .with_file( + "/usr/local/bin/start.sh", + src.file("containers/devpi/start.sh"), + ) + .with_exec(["chmod", "+x", "/usr/local/bin/start.sh"]) + .with_user("devpi") + .with_workdir("/devpi") + .with_exposed_port(3141) + .with_entrypoint(["/usr/local/bin/start.sh"]) + ) + return oci_labels( + ctr, + title="devpi", + description="devpi PyPI server and caching proxy", + version=VERSION, + ) diff --git a/docs/changelog.d/+devpi-dagger-migration.infra.md b/docs/changelog.d/+devpi-dagger-migration.infra.md new file mode 100644 index 0000000..6c5f226 --- /dev/null +++ b/docs/changelog.d/+devpi-dagger-migration.infra.md @@ -0,0 +1 @@ +Migrate devpi container from Dockerfile to native Dagger build; bump devpi-server 6.19.1→6.19.3 and devpi-web 5.0.1→5.0.2. diff --git a/service-versions.yaml b/service-versions.yaml index 6d5226f..761aa8d 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -215,8 +215,8 @@ services: - name: devpi type: argocd - last-reviewed: 2026-03-06 - current-version: "6.19.1" + last-reviewed: 2026-04-18 + current-version: "6.19.3" upstream-source: https://github.com/devpi/devpi/releases - name: cv From 4dab6d11bb8fc121abd2d467a6966122559faecd Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sat, 18 Apr 2026 08:03:21 -0700 Subject: [PATCH 290/430] Add remote commit check to container-build-and-release Queries the Forgejo API to verify the target commit exists on the remote before dispatching a build, preventing wasted CI runs on unpushed commits. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../+container-release-push-check.misc.md | 1 + mise-tasks/container-build-and-release | 12 ++++++++++++ 2 files changed, 13 insertions(+) create mode 100644 docs/changelog.d/+container-release-push-check.misc.md diff --git a/docs/changelog.d/+container-release-push-check.misc.md b/docs/changelog.d/+container-release-push-check.misc.md new file mode 100644 index 0000000..ab9aedc --- /dev/null +++ b/docs/changelog.d/+container-release-push-check.misc.md @@ -0,0 +1 @@ +container-build-and-release now verifies the commit exists on the remote before dispatching a build. diff --git a/mise-tasks/container-build-and-release b/mise-tasks/container-build-and-release index 3549597..2e1be27 100755 --- a/mise-tasks/container-build-and-release +++ b/mise-tasks/container-build-and-release @@ -89,6 +89,7 @@ def main( ref = git("rev-parse", ref) short_sha = ref[:7] + image = f"blumeops/{container}" # Show expected builds @@ -120,6 +121,17 @@ def main( "Content-Type": "application/json", } + # Verify the commit has been pushed to the remote + resp = httpx.get( + f"{FORGE_API}/repos/{REPO}/git/commits/{ref}", + headers=headers, + timeout=15, + ) + if resp.status_code != 200: + typer.echo(f"Error: commit {short_sha} not found on remote") + typer.echo("Push your changes before triggering a build: git push origin main") + raise typer.Exit(1) + url = f"{FORGE_API}/repos/{REPO}/actions/workflows/{WORKFLOW}/dispatches" payload = { "ref": "main", From b4472c784931f68091c6528def057aa2952a4f87 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sat, 18 Apr 2026 08:04:23 -0700 Subject: [PATCH 291/430] Deploy devpi 6.19.3 Co-Authored-By: Claude Opus 4.6 (1M context) --- argocd/manifests/devpi/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/argocd/manifests/devpi/kustomization.yaml b/argocd/manifests/devpi/kustomization.yaml index a9cc2a4..2083aaa 100644 --- a/argocd/manifests/devpi/kustomization.yaml +++ b/argocd/manifests/devpi/kustomization.yaml @@ -11,4 +11,4 @@ resources: images: - name: registry.ops.eblu.me/blumeops/devpi - newTag: v6.19.1-613f05d + newTag: v6.19.3-37b8a21 From 9bafe85b2bd1f0f2c8291fa243b8edbb9bbe0270 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sat, 18 Apr 2026 08:12:26 -0700 Subject: [PATCH 292/430] Add teslamate extensions to DR restore procedure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earthdistance extension (depends on cube) must be created before restoring the teslamate database — discovered missing after 2026-04-13 DR. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/how-to/operations/rebuild-minikube-cluster.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/how-to/operations/rebuild-minikube-cluster.md b/docs/how-to/operations/rebuild-minikube-cluster.md index 3eaffd7..ad64c89 100644 --- a/docs/how-to/operations/rebuild-minikube-cluster.md +++ b/docs/how-to/operations/rebuild-minikube-cluster.md @@ -176,6 +176,10 @@ kubectl --context=minikube-indri exec -n databases blumeops-pg-1 -c postgres -- psql -U postgres -c "CREATE DATABASE authentik OWNER authentik;" # (repeat for other DBs as needed) +# For teslamate: create extensions BEFORE restoring +kubectl --context=minikube-indri exec -n databases blumeops-pg-1 -c postgres -- \ + psql -U postgres -d teslamate -c "CREATE EXTENSION IF NOT EXISTS cube CASCADE; CREATE EXTENSION IF NOT EXISTS earthdistance CASCADE;" + # For immich: create extensions BEFORE restoring kubectl --context=minikube-indri exec -n databases immich-pg-1 -c postgres -- \ psql -U postgres -d immich -c "CREATE EXTENSION IF NOT EXISTS vector; CREATE EXTENSION IF NOT EXISTS vchord CASCADE; CREATE EXTENSION IF NOT EXISTS cube CASCADE; CREATE EXTENSION IF NOT EXISTS earthdistance CASCADE; CREATE EXTENSION IF NOT EXISTS pg_trgm; CREATE EXTENSION IF NOT EXISTS unaccent; CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\";" From a72a2c2bd4df994ae08d921ea08bc289ae8f4488 Mon Sep 17 00:00:00 2001 From: Forgejo Actions Date: Sat, 18 Apr 2026 08:14:58 -0700 Subject: [PATCH 293/430] Update docs release to v1.15.7 - Built changelog from towncrier fragments [skip ci] --- CHANGELOG.md | 25 +++++++++++++++++++ argocd/manifests/docs/deployment.yaml | 2 +- .../+borgmatic-launchagent-tcc.bugfix.md | 1 - .../+container-manual-builds.infra.md | 1 - .../+container-release-push-check.misc.md | 1 - .../+dagger-go-build-refactor.infra.md | 1 - .../+devpi-dagger-migration.infra.md | 1 - docs/changelog.d/+forge-robots-txt.infra.md | 1 - .../+forgejo-archive-dos-mitigation.infra.md | 1 - .../+kiwix-dagger-migration.infra.md | 1 - .../+review-sso-gated-admin-tools.misc.md | 1 - .../automate-manual-prowler-checks.infra.md | 1 - .../dagger-transmission-containers.infra.md | 1 - docs/changelog.d/fly-proxy-keepalive.infra.md | 1 - .../changelog.d/upgrade-prowler-5.23.infra.md | 1 - 15 files changed, 26 insertions(+), 14 deletions(-) delete mode 100644 docs/changelog.d/+borgmatic-launchagent-tcc.bugfix.md delete mode 100644 docs/changelog.d/+container-manual-builds.infra.md delete mode 100644 docs/changelog.d/+container-release-push-check.misc.md delete mode 100644 docs/changelog.d/+dagger-go-build-refactor.infra.md delete mode 100644 docs/changelog.d/+devpi-dagger-migration.infra.md delete mode 100644 docs/changelog.d/+forge-robots-txt.infra.md delete mode 100644 docs/changelog.d/+forgejo-archive-dos-mitigation.infra.md delete mode 100644 docs/changelog.d/+kiwix-dagger-migration.infra.md delete mode 100644 docs/changelog.d/+review-sso-gated-admin-tools.misc.md delete mode 100644 docs/changelog.d/automate-manual-prowler-checks.infra.md delete mode 100644 docs/changelog.d/dagger-transmission-containers.infra.md delete mode 100644 docs/changelog.d/fly-proxy-keepalive.infra.md delete mode 100644 docs/changelog.d/upgrade-prowler-5.23.infra.md diff --git a/CHANGELOG.md b/CHANGELOG.md index e218bc3..3b4d299 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,31 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [v1.15.7] - 2026-04-18 + +### Bug Fixes + +- Fix borgmatic LaunchAgent failing silently due to macOS TCC permission dialogs. LaunchAgents now call borgmatic directly instead of routing through `mise x`, which triggered "wants to access Documents" dialogs that hung headless sessions. The ansible role now also manages borgmatic installation via `mise install`. + +### Infrastructure + +- Automate verification of Prowler MANUAL findings (kubelet file perms, kubelet config, etcd CA, RBAC cluster-admin) in `review-compliance-reports` and mute them with `node-config-automated-verification` compensating control. +- Migrate transmission and transmission-exporter containers from Dockerfile to native Dagger builds (`container.py`). Updates base images to Alpine 3.23 and Python 3.14, pins uv to 0.11.6. +- Switched Fly proxy to upstream keepalive pools, reducing forge.eblu.me latency from 35s+ p50 to sub-second. Added `mise run fly-reload` for DNS re-resolution without redeploy. +- Upgrade Prowler from 5.22.0 to 5.23.0; remove init container workaround for broken `--registry` flag (upstream fix in PR #10470). +- Added `robots.txt` to `forge.eblu.me` blocking crawlers from `/mirrors/` to reduce load from Facebook scraping. +- Container builds are now manual-only via `mise run container-build-and-release`. Removed auto-trigger on push to main — shared Dagger helpers made path-based detection unreliable. +- Migrate devpi container from Dockerfile to native Dagger build; bump devpi-server 6.19.1→6.19.3 and devpi-web 5.0.1→5.0.2. +- Migrated kiwix-serve container from Dockerfile to native Dagger build, bumping Alpine base from 3.22 to 3.23. +- Mitigated Forgejo archive endpoint DoS: redirect public archive requests to tailnet, expanded robots.txt, enabled archive cleanup cron, cached release downloads at proxy. +- Refactored Dagger container pipelines: extended `go_build()` helper with `buildmode` and `extra_env` params, migrated miniflux and forgejo-runner to use it, and standardized all Alpine bases from 3.22 to 3.23. + +### Miscellaneous + +- Review compensating control `sso-gated-admin-tools`: tightened scope to ArgoCD only, removed Grafana reference. +- container-build-and-release now verifies the commit exists on the remote before dispatching a build. + + ## [v1.15.6] - 2026-04-14 ### Bug Fixes diff --git a/argocd/manifests/docs/deployment.yaml b/argocd/manifests/docs/deployment.yaml index 67de0c2..a3911e8 100644 --- a/argocd/manifests/docs/deployment.yaml +++ b/argocd/manifests/docs/deployment.yaml @@ -30,7 +30,7 @@ spec: name: http env: - name: DOCS_RELEASE_URL - value: "https://forge.eblu.me/eblume/blumeops/releases/download/v1.15.6/docs-v1.15.6.tar.gz" + value: "https://forge.eblu.me/eblume/blumeops/releases/download/v1.15.7/docs-v1.15.7.tar.gz" resources: requests: memory: "64Mi" diff --git a/docs/changelog.d/+borgmatic-launchagent-tcc.bugfix.md b/docs/changelog.d/+borgmatic-launchagent-tcc.bugfix.md deleted file mode 100644 index 0f941e9..0000000 --- a/docs/changelog.d/+borgmatic-launchagent-tcc.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -Fix borgmatic LaunchAgent failing silently due to macOS TCC permission dialogs. LaunchAgents now call borgmatic directly instead of routing through `mise x`, which triggered "wants to access Documents" dialogs that hung headless sessions. The ansible role now also manages borgmatic installation via `mise install`. diff --git a/docs/changelog.d/+container-manual-builds.infra.md b/docs/changelog.d/+container-manual-builds.infra.md deleted file mode 100644 index 78a29f4..0000000 --- a/docs/changelog.d/+container-manual-builds.infra.md +++ /dev/null @@ -1 +0,0 @@ -Container builds are now manual-only via `mise run container-build-and-release`. Removed auto-trigger on push to main — shared Dagger helpers made path-based detection unreliable. diff --git a/docs/changelog.d/+container-release-push-check.misc.md b/docs/changelog.d/+container-release-push-check.misc.md deleted file mode 100644 index ab9aedc..0000000 --- a/docs/changelog.d/+container-release-push-check.misc.md +++ /dev/null @@ -1 +0,0 @@ -container-build-and-release now verifies the commit exists on the remote before dispatching a build. diff --git a/docs/changelog.d/+dagger-go-build-refactor.infra.md b/docs/changelog.d/+dagger-go-build-refactor.infra.md deleted file mode 100644 index af09d7f..0000000 --- a/docs/changelog.d/+dagger-go-build-refactor.infra.md +++ /dev/null @@ -1 +0,0 @@ -Refactored Dagger container pipelines: extended `go_build()` helper with `buildmode` and `extra_env` params, migrated miniflux and forgejo-runner to use it, and standardized all Alpine bases from 3.22 to 3.23. diff --git a/docs/changelog.d/+devpi-dagger-migration.infra.md b/docs/changelog.d/+devpi-dagger-migration.infra.md deleted file mode 100644 index 6c5f226..0000000 --- a/docs/changelog.d/+devpi-dagger-migration.infra.md +++ /dev/null @@ -1 +0,0 @@ -Migrate devpi container from Dockerfile to native Dagger build; bump devpi-server 6.19.1→6.19.3 and devpi-web 5.0.1→5.0.2. diff --git a/docs/changelog.d/+forge-robots-txt.infra.md b/docs/changelog.d/+forge-robots-txt.infra.md deleted file mode 100644 index fe96ad5..0000000 --- a/docs/changelog.d/+forge-robots-txt.infra.md +++ /dev/null @@ -1 +0,0 @@ -Added `robots.txt` to `forge.eblu.me` blocking crawlers from `/mirrors/` to reduce load from Facebook scraping. diff --git a/docs/changelog.d/+forgejo-archive-dos-mitigation.infra.md b/docs/changelog.d/+forgejo-archive-dos-mitigation.infra.md deleted file mode 100644 index 3b9ad84..0000000 --- a/docs/changelog.d/+forgejo-archive-dos-mitigation.infra.md +++ /dev/null @@ -1 +0,0 @@ -Mitigated Forgejo archive endpoint DoS: redirect public archive requests to tailnet, expanded robots.txt, enabled archive cleanup cron, cached release downloads at proxy. diff --git a/docs/changelog.d/+kiwix-dagger-migration.infra.md b/docs/changelog.d/+kiwix-dagger-migration.infra.md deleted file mode 100644 index 462e040..0000000 --- a/docs/changelog.d/+kiwix-dagger-migration.infra.md +++ /dev/null @@ -1 +0,0 @@ -Migrated kiwix-serve container from Dockerfile to native Dagger build, bumping Alpine base from 3.22 to 3.23. diff --git a/docs/changelog.d/+review-sso-gated-admin-tools.misc.md b/docs/changelog.d/+review-sso-gated-admin-tools.misc.md deleted file mode 100644 index 7e337df..0000000 --- a/docs/changelog.d/+review-sso-gated-admin-tools.misc.md +++ /dev/null @@ -1 +0,0 @@ -Review compensating control `sso-gated-admin-tools`: tightened scope to ArgoCD only, removed Grafana reference. diff --git a/docs/changelog.d/automate-manual-prowler-checks.infra.md b/docs/changelog.d/automate-manual-prowler-checks.infra.md deleted file mode 100644 index 07f132b..0000000 --- a/docs/changelog.d/automate-manual-prowler-checks.infra.md +++ /dev/null @@ -1 +0,0 @@ -Automate verification of Prowler MANUAL findings (kubelet file perms, kubelet config, etcd CA, RBAC cluster-admin) in `review-compliance-reports` and mute them with `node-config-automated-verification` compensating control. diff --git a/docs/changelog.d/dagger-transmission-containers.infra.md b/docs/changelog.d/dagger-transmission-containers.infra.md deleted file mode 100644 index 4937a06..0000000 --- a/docs/changelog.d/dagger-transmission-containers.infra.md +++ /dev/null @@ -1 +0,0 @@ -Migrate transmission and transmission-exporter containers from Dockerfile to native Dagger builds (`container.py`). Updates base images to Alpine 3.23 and Python 3.14, pins uv to 0.11.6. diff --git a/docs/changelog.d/fly-proxy-keepalive.infra.md b/docs/changelog.d/fly-proxy-keepalive.infra.md deleted file mode 100644 index 8853150..0000000 --- a/docs/changelog.d/fly-proxy-keepalive.infra.md +++ /dev/null @@ -1 +0,0 @@ -Switched Fly proxy to upstream keepalive pools, reducing forge.eblu.me latency from 35s+ p50 to sub-second. Added `mise run fly-reload` for DNS re-resolution without redeploy. diff --git a/docs/changelog.d/upgrade-prowler-5.23.infra.md b/docs/changelog.d/upgrade-prowler-5.23.infra.md deleted file mode 100644 index df2d0ab..0000000 --- a/docs/changelog.d/upgrade-prowler-5.23.infra.md +++ /dev/null @@ -1 +0,0 @@ -Upgrade Prowler from 5.22.0 to 5.23.0; remove init container workaround for broken `--registry` flag (upstream fix in PR #10470). From 24f3f9b24ad8f161fa777e89825bce63085e2788 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sat, 18 Apr 2026 08:27:07 -0700 Subject: [PATCH 294/430] Raise k3s memlock rlimit for eBPF tracing on ringtail Beyla (alloy-tracing) has been failing since April 13 with "failed to set memlock rlimit: operation not permitted" because k3s inherits the default 8MB memlock limit. Set LimitMEMLOCK=infinity on the k3s systemd service so privileged containers can use eBPF. Co-Authored-By: Claude Opus 4.6 (1M context) --- nixos/ringtail/configuration.nix | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/nixos/ringtail/configuration.nix b/nixos/ringtail/configuration.nix index c350d04..4349154 100644 --- a/nixos/ringtail/configuration.nix +++ b/nixos/ringtail/configuration.nix @@ -153,6 +153,10 @@ in ''; }; + # Raise memlock rlimit for k3s so eBPF workloads (Beyla/Alloy tracing) can + # call setrlimit(RLIMIT_MEMLOCK, unlimited) inside privileged containers. + systemd.services.k3s.serviceConfig.LimitMEMLOCK = "infinity"; + # K3s containerd registry mirrors (pull through Zot on indri) environment.etc."rancher/k3s/registries.yaml".source = ./k3s-registries.yaml; From 3a2913ba1fb34e137413fbaca2def05bd25c2ce6 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sat, 18 Apr 2026 08:32:30 -0700 Subject: [PATCH 295/430] Allow BPF in privileged containers on ringtail NixOS defaults kernel.unprivileged_bpf_disabled=2, which blocks BPF syscalls outside the init namespace even with CAP_BPF. Set to 1 so privileged containers (Beyla/Alloy tracing) can create BPF maps. Co-Authored-By: Claude Opus 4.6 (1M context) --- nixos/ringtail/configuration.nix | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/nixos/ringtail/configuration.nix b/nixos/ringtail/configuration.nix index 4349154..052f38d 100644 --- a/nixos/ringtail/configuration.nix +++ b/nixos/ringtail/configuration.nix @@ -157,6 +157,11 @@ in # call setrlimit(RLIMIT_MEMLOCK, unlimited) inside privileged containers. systemd.services.k3s.serviceConfig.LimitMEMLOCK = "infinity"; + # Allow BPF in privileged containers (Beyla eBPF tracing). NixOS defaults + # to 2 (block BPF outside init namespace even with CAP_BPF). Value 1 allows + # BPF for processes with CAP_BPF/CAP_SYS_ADMIN in any namespace. + boot.kernel.sysctl."kernel.unprivileged_bpf_disabled" = 1; + # K3s containerd registry mirrors (pull through Zot on indri) environment.etc."rancher/k3s/registries.yaml".source = ./k3s-registries.yaml; From c8da243663146419ae983c21d12db1ad9b1aab2a Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sat, 18 Apr 2026 08:42:26 -0700 Subject: [PATCH 296/430] Run alloy-tracing as root for eBPF capabilities The nix-built Alloy image sets User=65534 (nobody). Even with privileged: true, a non-root user gets no effective capabilities (CapEff=0). Override with runAsUser: 0 so Beyla gets CAP_BPF and CAP_SYS_ADMIN needed for eBPF instrumentation. Co-Authored-By: Claude Opus 4.6 (1M context) --- argocd/manifests/alloy-tracing-ringtail/daemonset.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/argocd/manifests/alloy-tracing-ringtail/daemonset.yaml b/argocd/manifests/alloy-tracing-ringtail/daemonset.yaml index e56cc9d..b3de1de 100644 --- a/argocd/manifests/alloy-tracing-ringtail/daemonset.yaml +++ b/argocd/manifests/alloy-tracing-ringtail/daemonset.yaml @@ -46,6 +46,7 @@ spec: mountPath: /var/lib/alloy/data securityContext: privileged: true + runAsUser: 0 tolerations: - operator: Exists volumes: From bca4c2beded205e6cff2d622132aec97fe3e8c4b Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sat, 18 Apr 2026 09:17:03 -0700 Subject: [PATCH 297/430] Expose Tailscale WireGuard UDP port on Fly proxy Enable direct peer-to-peer WireGuard connections by pinning tailscaled to port 41641 and exposing it as a UDP service. Without this, all traffic routes through Tailscale DERP relays causing 20+ second latency. Requires dedicated IPv4 (allocated: 168.220.82.221). Co-Authored-By: Claude Opus 4.6 (1M context) --- fly/fly.toml | 9 +++++++++ fly/start.sh | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/fly/fly.toml b/fly/fly.toml index 17e3de8..11aac9c 100644 --- a/fly/fly.toml +++ b/fly/fly.toml @@ -22,3 +22,12 @@ interval = "10s" method = "GET" path = "/healthz" timeout = "5s" + +# Expose Tailscale's WireGuard port so direct peer-to-peer connections can +# establish instead of falling back to DERP relay. Requires a dedicated IPv4. +[[services]] +internal_port = 41641 +protocol = "udp" + +[[services.ports]] +port = 41641 diff --git a/fly/start.sh b/fly/start.sh index 8fd1fd4..1f2acaa 100644 --- a/fly/start.sh +++ b/fly/start.sh @@ -5,7 +5,7 @@ set -e # With bluegreen deploys, the old machine serves traffic until this one is # fully ready. Fly.io runs Firecracker microVMs that support TUN devices # natively — no need for --tun=userspace-networking. -tailscaled --statedir=/var/lib/tailscale & +tailscaled --statedir=/var/lib/tailscale --port=41641 & sleep 2 tailscale up --authkey="${TS_AUTHKEY}" --hostname=flyio-proxy until tailscale status > /dev/null 2>&1; do sleep 1; done From 12b2786ca2e3cbc5f371bdb06e3338f6b79c52de Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sat, 18 Apr 2026 09:40:20 -0700 Subject: [PATCH 298/430] Route Fly proxy through Caddy on indri for direct WireGuard peering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tailscale Ingress pods in k8s can't establish direct WireGuard connections (stuck behind pod-network NAT → DERP relay → 20s latency). Indri's host-level Tailscale CAN peer directly with Fly. Change all nginx upstreams to route through Caddy on indri instead of per-service Tailscale Ingress endpoints. Tag indri as flyio-target in the Tailscale ACL so the Fly proxy can reach it. Co-Authored-By: Claude Opus 4.6 (1M context) --- fly/nginx.conf | 57 +++++++++++++++------------------- pulumi/tailscale/__main__.py | 1 + pulumi/tailscale/policy.hujson | 6 ++-- 3 files changed, 30 insertions(+), 34 deletions(-) diff --git a/fly/nginx.conf b/fly/nginx.conf index 5723722..ceafbdd 100644 --- a/fly/nginx.conf +++ b/fly/nginx.conf @@ -54,24 +54,17 @@ http { websocket upgrade; } - # --- Upstream pools with keepalive --- - # DNS is resolved once at config load via MagicDNS. If Tailscale Ingress - # pods get new IPs (restart, reschedule), run `mise run fly-reload` to - # re-resolve. A Grafana alert fires when upstreams are unreachable. + # --- Upstream --- + # DNS resolved via Tailscale MagicDNS at config load. resolver 100.100.100.100 valid=30s; resolver_timeout 5s; - upstream forge_backend { - server forge.tail8d86e.ts.net:443; - keepalive 8; - } - upstream docs_backend { - server docs.tail8d86e.ts.net:443; - keepalive 4; - } - upstream cv_backend { - server cv.tail8d86e.ts.net:443; - keepalive 4; + # All services route through Caddy on indri. Indri's host-level Tailscale + # can establish direct WireGuard peering, avoiding the DERP relay + # bottleneck that k8s-hosted Tailscale Ingress pods cannot escape. + upstream indri_backend { + server indri.tail8d86e.ts.net:443; + keepalive 16; } # --- docs.eblu.me (static site) --- @@ -90,11 +83,11 @@ http { internal; } location / { - proxy_pass https://docs_backend$request_uri; + proxy_pass https://indri_backend$request_uri; proxy_ssl_verify off; proxy_ssl_server_name on; - proxy_ssl_name docs.tail8d86e.ts.net; - proxy_set_header Host docs.tail8d86e.ts.net; + proxy_ssl_name docs.ops.eblu.me; + proxy_set_header Host docs.ops.eblu.me; proxy_intercept_errors on; proxy_http_version 1.1; @@ -134,11 +127,11 @@ http { } location / { - proxy_pass https://cv_backend$request_uri; + proxy_pass https://indri_backend$request_uri; proxy_ssl_verify off; proxy_ssl_server_name on; - proxy_ssl_name cv.tail8d86e.ts.net; - proxy_set_header Host cv.tail8d86e.ts.net; + proxy_ssl_name cv.ops.eblu.me; + proxy_set_header Host cv.ops.eblu.me; proxy_intercept_errors on; proxy_http_version 1.1; @@ -209,13 +202,13 @@ http { location ~ ^/user/(login|sign_up|forgot_password) { limit_req zone=forge_auth burst=5 nodelay; - proxy_pass https://forge_backend$request_uri; + proxy_pass https://indri_backend$request_uri; proxy_ssl_verify off; proxy_ssl_server_name on; - proxy_ssl_name forge.tail8d86e.ts.net; + proxy_ssl_name forge.ops.eblu.me; proxy_intercept_errors on; - proxy_set_header Host $host; + proxy_set_header Host forge.ops.eblu.me; proxy_set_header X-Real-IP $http_fly_client_ip; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; @@ -228,10 +221,10 @@ http { # Cache release artifact downloads — immutable files keyed by tag+filename. # Avoids hammering Forgejo when crawlers or users re-download the same asset. location ~ ^/[^/]+/[^/]+/releases/download/ { - proxy_pass https://forge_backend$request_uri; + proxy_pass https://indri_backend$request_uri; proxy_ssl_verify off; proxy_ssl_server_name on; - proxy_ssl_name forge.tail8d86e.ts.net; + proxy_ssl_name forge.ops.eblu.me; proxy_http_version 1.1; proxy_set_header Connection $connection_upgrade; @@ -240,7 +233,7 @@ http { proxy_cache_valid 200 7d; proxy_cache_key $host$uri; - proxy_set_header Host $host; + proxy_set_header Host forge.ops.eblu.me; proxy_set_header X-Real-IP $http_fly_client_ip; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; @@ -251,10 +244,10 @@ http { # Selectively cache static assets only location ~* \.(css|js|png|jpg|svg|woff2?)$ { - proxy_pass https://forge_backend$request_uri; + proxy_pass https://indri_backend$request_uri; proxy_ssl_verify off; proxy_ssl_server_name on; - proxy_ssl_name forge.tail8d86e.ts.net; + proxy_ssl_name forge.ops.eblu.me; proxy_http_version 1.1; proxy_set_header Connection $connection_upgrade; @@ -268,15 +261,15 @@ http { } location / { - proxy_pass https://forge_backend$request_uri; + proxy_pass https://indri_backend$request_uri; proxy_ssl_verify off; proxy_ssl_server_name on; - proxy_ssl_name forge.tail8d86e.ts.net; + proxy_ssl_name forge.ops.eblu.me; proxy_intercept_errors on; # NO proxy_cache — dynamic content with sessions - proxy_set_header Host $host; + proxy_set_header Host forge.ops.eblu.me; proxy_set_header X-Real-IP $http_fly_client_ip; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; diff --git a/pulumi/tailscale/__main__.py b/pulumi/tailscale/__main__.py index 2bbecfd..2f5262b 100644 --- a/pulumi/tailscale/__main__.py +++ b/pulumi/tailscale/__main__.py @@ -50,6 +50,7 @@ indri_tags = tailscale.DeviceTags( "tag:loki", "tag:registry", # Zot container registry "tag:k8s-api", # Kubernetes API server (minikube) + "tag:flyio-target", # Fly proxy routes through Caddy on indri ], ) diff --git a/pulumi/tailscale/policy.hujson b/pulumi/tailscale/policy.hujson index e6ddb85..84f1f17 100644 --- a/pulumi/tailscale/policy.hujson +++ b/pulumi/tailscale/policy.hujson @@ -193,11 +193,13 @@ "src": "tag:ci-gateway", "accept": ["tag:registry:443"], }, - // Fly.io proxy can only reach flyio-target tagged endpoints, nothing else + // Fly.io proxy can only reach flyio-target tagged endpoints, nothing else. + // indri has tag:flyio-target (Caddy) so tag:homelab:443 is reachable on + // indri specifically but not other homelab devices. { "src": "tag:flyio-proxy", "accept": ["tag:flyio-target:443"], - "deny": ["tag:k8s:443", "tag:homelab:443", "tag:homelab:22", "tag:nas:445", "tag:registry:443"], + "deny": ["tag:k8s:443", "tag:homelab:22", "tag:nas:445", "tag:registry:443"], }, ], } From d26a6ae3b22884881fb4d52dc69b734d5ea118ef Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sat, 18 Apr 2026 09:57:30 -0700 Subject: [PATCH 299/430] Update docs for Caddy routing and direct WireGuard peering Comprehensive docs pass reflecting the new Fly proxy architecture: - Fly proxy routes through Caddy on indri (not per-service TS Ingress) - Direct WireGuard peering via --port=41641 pinning - DERP relay performance lesson in Tailscale docs - Caddy now in public traffic path - indri tagged as flyio-target - Removed fly-reload references - Updated architecture diagrams and per-service setup guide - Added changelog fragment Co-Authored-By: Claude Opus 4.6 (1M context) --- .../+fly-caddy-direct-peering.infra.md | 1 + docs/explanation/architecture.md | 4 +- docs/how-to/operations/manage-flyio-proxy.md | 18 +-- docs/reference/infrastructure/routing.md | 4 +- docs/reference/infrastructure/tailscale.md | 19 +++- docs/reference/services/caddy.md | 6 +- docs/reference/services/flyio-proxy.md | 34 +++--- docs/tutorials/expose-service-publicly.md | 103 ++++++------------ 8 files changed, 81 insertions(+), 108 deletions(-) create mode 100644 docs/changelog.d/+fly-caddy-direct-peering.infra.md diff --git a/docs/changelog.d/+fly-caddy-direct-peering.infra.md b/docs/changelog.d/+fly-caddy-direct-peering.infra.md new file mode 100644 index 0000000..9e308eb --- /dev/null +++ b/docs/changelog.d/+fly-caddy-direct-peering.infra.md @@ -0,0 +1 @@ +Route Fly.io proxy through Caddy on indri with direct WireGuard peering, reducing public-facing latency from 20+ seconds (DERP relay) to sub-second. Fixed Beyla eBPF tracing on ringtail (memlock rlimit + BPF permissions). Restored trace collection to Tempo. diff --git a/docs/explanation/architecture.md b/docs/explanation/architecture.md index 4080b1e..a99956f 100644 --- a/docs/explanation/architecture.md +++ b/docs/explanation/architecture.md @@ -59,9 +59,9 @@ Three layers of reverse proxying expose services at different scopes: **Tailscale** is the base layer — every service gets a MagicDNS hostname. The [[tailscale-operator]] gives Kubernetes services their own Tailscale Ingress endpoints. -**[[caddy]]** runs natively on [[indri]] and provides a unified `*.ops.eblu.me` wildcard with TLS (Let's Encrypt via DNS-01/Gandi). It proxies to both local services (Forgejo, Zot, Jellyfin) and Kubernetes services (via their Tailscale Ingress endpoints). Access is restricted by Tailscale ACLs — only `tag:homelab` and `autogroup:admin` can reach Caddy. +**[[caddy]]** runs natively on [[indri]] and provides a unified `*.ops.eblu.me` wildcard with TLS (Let's Encrypt via DNS-01/Gandi). It proxies to both local services (Forgejo, Zot, Jellyfin) and Kubernetes services (via their Tailscale Ingress endpoints). Caddy serves both tailnet clients and public traffic (via the Fly proxy). -**[[flyio-proxy]]** runs on Fly.io for select services that need public internet access. Traffic hits Fly.io's Anycast edge, terminates TLS, and tunnels back to the homelab over Tailscale. Only services explicitly tagged `tag:flyio-target` are reachable — a compromised proxy cannot route to arbitrary services on the tailnet. +**[[flyio-proxy]]** runs on Fly.io for select services that need public internet access. Traffic hits Fly.io's Anycast edge, terminates TLS, and tunnels back to Caddy on indri over a direct Tailscale WireGuard connection. The proxy uses `tag:flyio-target` ACLs — indri carries this tag so the proxy can reach Caddy, but cannot route to arbitrary services on the tailnet. See [[routing]] for the full service URL table and port map. diff --git a/docs/how-to/operations/manage-flyio-proxy.md b/docs/how-to/operations/manage-flyio-proxy.md index 73e61d1..5cea783 100644 --- a/docs/how-to/operations/manage-flyio-proxy.md +++ b/docs/how-to/operations/manage-flyio-proxy.md @@ -1,7 +1,7 @@ --- title: Manage Fly.io Proxy -modified: 2026-04-17 -last-reviewed: 2026-04-17 +modified: 2026-04-18 +last-reviewed: 2026-04-18 tags: - how-to - fly-io @@ -23,16 +23,6 @@ mise run fly-deploy Pushes to `fly/` on main also trigger automatic deployment via the Forgejo CI workflow. -## Reload Nginx (Re-resolve Upstream DNS) - -Nginx uses `upstream` blocks with keepalive connection pools. DNS is resolved at config load. If Tailscale Ingress pods get new IPs (restart, reschedule, minikube restart), reload nginx to re-resolve without a full redeploy: - -```bash -mise run fly-reload -``` - -A Grafana alert fires when upstreams are unreachable, prompting this action. A full `fly-deploy` also re-resolves DNS (it replaces the container). - ## Add a New Public Service See [[expose-service-publicly#Per-service setup]] for the full walkthrough. In short: @@ -88,15 +78,13 @@ The auth key expires every 90 days. To rotate: ## Troubleshooting -**502 Bad Gateway after Tailscale Ingress restart**: Upstream DNS is stale. Run `mise run fly-reload` to re-resolve. This is the most common cause of 502s. - **502 Bad Gateway on fresh deploy**: MagicDNS may not be ready when nginx starts. The `start.sh` script polls `nslookup` before launching nginx, but if it still fails, check that `tailscale status` is healthy inside the container. **Health check failing**: `fly ssh console -a blumeops-proxy` then `curl localhost:8080/healthz` to test locally. **TLS errors on custom domain**: Check cert status with `fly certs show -a blumeops-proxy`. Certs auto-provision via Let's Encrypt and may take a few minutes. -**High latency (>1s p50)**: Likely lost keepalive — redeploy with `mise run fly-deploy`. Before the keepalive change (April 2026), per-request TLS handshakes through the WireGuard tunnel caused 35s+ p50 at >1 req/s. +**High latency (>1s p50)**: Check if direct WireGuard peering is established: `fly ssh console -a blumeops-proxy -C "tailscale ping indri"`. If it shows `via DERP`, the tunnel is relayed and latency will be 10-30s. See [[tailscale#Direct Peering vs DERP Relay]] for diagnosis. ## Related diff --git a/docs/reference/infrastructure/routing.md b/docs/reference/infrastructure/routing.md index 229e724..6708a92 100644 --- a/docs/reference/infrastructure/routing.md +++ b/docs/reference/infrastructure/routing.md @@ -1,6 +1,6 @@ --- title: Routing -modified: 2026-04-17 +modified: 2026-04-18 tags: - infrastructure - networking @@ -46,7 +46,7 @@ DNS points to [[indri]]'s Tailscale IP. TLS via Let's Encrypt (ACME DNS-01 with ## Public Services (`*.eblu.me`) -DNS CNAMEs point to `blumeops-proxy.fly.dev`. TLS via Fly.io-managed Let's Encrypt. Traffic tunnels back to the homelab over Tailscale. Only services tagged `tag:flyio-target` are reachable by the proxy — see [[flyio-proxy]] for details. +DNS CNAMEs point to `blumeops-proxy.fly.dev`. TLS via Fly.io-managed Let's Encrypt. Traffic tunnels back to [[caddy]] on [[indri]] over a direct Tailscale WireGuard connection, then Caddy routes to the service. See [[flyio-proxy]] for details. | Service | URL | Description | |---------|-----|-------------| diff --git a/docs/reference/infrastructure/tailscale.md b/docs/reference/infrastructure/tailscale.md index e266a05..2794111 100644 --- a/docs/reference/infrastructure/tailscale.md +++ b/docs/reference/infrastructure/tailscale.md @@ -1,7 +1,7 @@ --- title: Tailscale -modified: 2026-03-22 -last-reviewed: 2026-03-22 +modified: 2026-04-18 +last-reviewed: 2026-04-18 tags: - infrastructure - networking @@ -36,7 +36,7 @@ ACLs managed via Pulumi in `pulumi/tailscale/policy.hujson`. | `tag:k8s` | (Ingress proxy pods) | Kubernetes Tailscale Ingress nodes; each also carries a per-service tag (`tag:grafana`, `tag:kiwix`, `tag:devpi`, `tag:feed`, `tag:pg`) | | `tag:ci-gateway` | (ephemeral CI containers) | CI containers pushing images to registry | | `tag:flyio-proxy` | (Fly.io proxy container) | Public reverse proxy | -| `tag:flyio-target` | (designated Ingress endpoints) | Endpoints reachable by the Fly.io proxy | +| `tag:flyio-target` | indri, designated Ingress endpoints | Endpoints reachable by the Fly.io proxy (indri for Caddy routing, Ingress pods for Alloy metrics/logs) | **Important:** Don't tag user-owned devices (like gilbert) via Pulumi. Tagging converts them to "tagged devices" which lose user identity and break user-based SSH rules. Gilbert is referenced as `tag:workstation` in tagOwners for ownership purposes but remains user-owned so `blume.erich@gmail.com` identity is preserved. @@ -81,6 +81,19 @@ Pulumi uses OAuth client from 1Password (blumeops vault): - Scopes: acl, dns, devices, services - Auto-applies `tag:blumeops` to IaC-managed resources +## Direct Peering vs DERP Relay + +Just because Tailscale can route traffic does not mean it routes it efficiently. DERP relay servers are a fallback for when direct WireGuard connections cannot be established — they add significant latency (20+ seconds observed under load) because every packet bounces through a relay server. + +**Direct peering is critical for any production-like traffic path.** Check with `tailscale ping ` — it should say `via :`, not `via DERP()`. + +Common reasons direct peering fails: +- **k8s pods**: Tailscale Ingress pods behind pod-network NAT cannot hole-punch. Route through a host-level Tailscale node (e.g., Caddy on indri) instead. +- **Cloud VMs**: Some cloud providers block incoming UDP. Pin the WireGuard port (`tailscaled --port=41641`) and expose it as a UDP service if possible. +- **Double NAT / CGNAT**: Multiple NAT layers make hole punching unreliable. + +The [[flyio-proxy]] uses `--port=41641` pinning to enable direct peering with indri, and routes through [[caddy]] (host-level Tailscale) to avoid the DERP bottleneck of k8s-hosted Tailscale Ingress pods. + ## Related - [[routing|Routing]] - Service URLs diff --git a/docs/reference/services/caddy.md b/docs/reference/services/caddy.md index daadcb0..04861ec 100644 --- a/docs/reference/services/caddy.md +++ b/docs/reference/services/caddy.md @@ -1,6 +1,6 @@ --- title: Caddy -modified: 2026-03-15 +modified: 2026-04-18 tags: - service - networking @@ -83,7 +83,9 @@ The token is written to `~/.config/caddy/gandi-token` (chmod 0600) and sourced b ## Security Considerations -Caddy has no authentication layer — it is a plain reverse proxy. Access control relies entirely on Tailscale ACLs restricting which devices can reach indri on port 443. Currently `tag:homelab` and `autogroup:admin` can reach Caddy. The [[flyio-proxy]] no longer routes through Caddy — it pushes logs and metrics directly to [[loki]] and [[prometheus]] via their Tailscale Ingress endpoints. +Caddy has no authentication layer — it is a plain reverse proxy. Access control relies entirely on Tailscale ACLs restricting which devices can reach indri on port 443. Currently `tag:homelab`, `autogroup:admin`, and `tag:flyio-proxy` (via `tag:flyio-target` on indri) can reach Caddy. + +The [[flyio-proxy]] routes all public traffic through Caddy. This is the path for `*.eblu.me` requests from the public internet. Caddy sees these as requests from the Fly VM with `Host: *.ops.eblu.me` headers — the same routes used by tailnet clients. ## Custom Build diff --git a/docs/reference/services/flyio-proxy.md b/docs/reference/services/flyio-proxy.md index ad32b8a..182e80c 100644 --- a/docs/reference/services/flyio-proxy.md +++ b/docs/reference/services/flyio-proxy.md @@ -1,6 +1,6 @@ --- title: Fly.io Proxy -modified: 2026-04-17 +modified: 2026-04-18 tags: - service - networking @@ -23,23 +23,27 @@ Public reverse proxy on [Fly.io](https://fly.io) that exposes selected BlumeOps ## Exposed Services -| Public domain | Backend | Service | -|---------------|---------|---------| -| `docs.eblu.me` | `docs.tail8d86e.ts.net` | [[docs]] | -| `cv.eblu.me` | `cv.tail8d86e.ts.net` | [[cv]] | -| `forge.eblu.me` | `forge.tail8d86e.ts.net` | [[forgejo]] | +| Public domain | Backend (via Caddy) | Service | +|---------------|---------------------|---------| +| `docs.eblu.me` | `docs.ops.eblu.me` | [[docs]] | +| `cv.eblu.me` | `cv.ops.eblu.me` | [[cv]] | +| `forge.eblu.me` | `forge.ops.eblu.me` | [[forgejo]] | ## Architecture -Internet traffic hits Fly.io's Anycast edge, terminates TLS with a Let's Encrypt certificate, and is proxied by nginx to the backend service over a Tailscale WireGuard tunnel. See [[expose-service-publicly]] for the full architecture diagram. +Internet traffic hits Fly.io's Anycast edge, terminates TLS with a Let's Encrypt certificate, and is proxied by nginx to [[caddy]] on [[indri]] over a direct Tailscale WireGuard tunnel. Caddy then routes to the actual service. See [[expose-service-publicly]] for the full architecture diagram. -### Upstream Keepalive +### Why Caddy, not per-service Tailscale Ingress? -Nginx uses `upstream` blocks with `keepalive` connection pools to reuse TLS connections through the WireGuard tunnel. This avoids a per-request TLS handshake, which was previously the dominant source of latency (35s+ p50 before keepalive, sub-second after). +Previously, nginx connected directly to each service's `*.tail8d86e.ts.net` Tailscale Ingress endpoint. This caused **20+ second latency** because the Tailscale Ingress pods (running inside k8s) are behind pod-network NAT and can only reach the Fly VM via Tailscale DERP relay servers — not direct WireGuard peering. -**Trade-off:** DNS for upstream hostnames is resolved once at config load, not per-request. If Tailscale Ingress pods get new IPs (restart, reschedule, minikube restart), run `mise run fly-reload` to re-resolve without a full redeploy. A Grafana alert fires when upstreams are unreachable. +Routing through Caddy on indri solves this because indri's host-level Tailscale can establish direct WireGuard connections with the Fly VM (45ms round trip). This generalizes to all services regardless of where they run (native on indri, minikube, or ringtail k3s), since Caddy already routes to everything. -Each upstream requires `proxy_ssl_name` set to the actual Tailscale hostname — nginx sends the upstream block name as SNI by default, which the Tailscale Ingress proxy won't recognize. +### Direct WireGuard Peering + +The Fly VM pins its Tailscale WireGuard listener to port 41641 (`tailscaled --port=41641`). Combined with well-behaved NAT on both sides (`MappingVariesByDestIP: false`), this allows Tailscale to establish direct peer-to-peer connections via UDP hole punching — no dedicated IPv4 required. + +If direct peering fails (observable via `tailscale ping indri` showing "via DERP"), allocate a dedicated IPv4 ($2/month) with `fly ips allocate-v4` to provide a guaranteed inbound UDP path. ## Key Files @@ -58,6 +62,8 @@ Each upstream requires `proxy_ssl_name` set to the actual Tailscale hostname — Fly.io runs Firecracker microVMs which support TUN devices natively. Tailscale runs with a real TUN interface (not userspace networking), so MagicDNS and direct Tailscale IP routing work normally. +The `tailscaled` process is started with `--port=41641` to pin the WireGuard listener to a fixed port. This is critical for direct peering — without it, hole punching is unreliable. A `[[services]]` block in `fly.toml` exposes this port as UDP, though it is only active when a dedicated IPv4 is allocated. + The Tailscale auth key is `preauthorized=True` to avoid device approval hangs on container restarts. ## Observability @@ -83,9 +89,7 @@ Alloy listens on `127.0.0.1:12345` for self-scraping its `/metrics` endpoint. Al ## Security Considerations -The `tag:flyio-proxy` ACL grants access only to `tag:flyio-target:443`. Services must explicitly opt in by adding a `tailscale.com/tags: "tag:k8s,tag:flyio-target"` annotation to their Tailscale Ingress. This means the proxy can only reach endpoints that have been individually tagged — a compromised nginx config cannot route to arbitrary services on the tailnet. - -Currently tagged as `tag:flyio-target`: [[docs]], [[cv]], [[forgejo]], [[loki]], [[prometheus]]. Loki and Prometheus are tagged so that [[alloy|Alloy]] (running inside the container) can push logs and metrics directly via their Tailscale Ingress endpoints — the restricted ACL means Caddy on indri (`tag:homelab`) is not reachable from the proxy. +The `tag:flyio-proxy` ACL grants access only to `tag:flyio-target:443`. Indri carries this tag (for Caddy), and the k8s Tailscale Ingress pods for Loki and Prometheus also carry it so [[alloy|Alloy]] can push logs and metrics directly. A compromised proxy cannot route to arbitrary services on the tailnet — only `tag:flyio-target` endpoints on port 443. ### Crawler Mitigation @@ -101,7 +105,7 @@ Archive requests (`///archive/*`) are 302-redirected to `forge.ops. Release downloads are cached at the proxy layer (7-day TTL, keyed by URI) to absorb repeated downloads of the same artifact. -To expose an additional service through the proxy, add the `tag:flyio-target` annotation to its Tailscale Ingress. See [[expose-service-publicly]] for the full workflow. +To expose an additional service through the proxy, add a Caddy route for it and an nginx `server` block. See [[expose-service-publicly]] for the full workflow. ## Spider Trap Mitigation diff --git a/docs/tutorials/expose-service-publicly.md b/docs/tutorials/expose-service-publicly.md index 9a44c15..6bc8fae 100644 --- a/docs/tutorials/expose-service-publicly.md +++ b/docs/tutorials/expose-service-publicly.md @@ -1,7 +1,7 @@ --- title: Expose a Service Publicly -modified: 2026-04-17 -last-reviewed: 2026-04-17 +modified: 2026-04-18 +last-reviewed: 2026-04-18 tags: - tutorials - fly-io @@ -27,24 +27,21 @@ Internet → .eblu.me Fly.io edge (Anycast, TLS via Let's Encrypt) │ Fly.io VM (nginx reverse proxy + Tailscale) - │ (WireGuard tunnel) - tailnet (tail8d86e.ts.net) + │ (direct WireGuard tunnel to indri) + Caddy on indri (*.ops.eblu.me routing) │ - .tail8d86e.ts.net (Tailscale ingress) - │ - k8s Service → pod + backend service (k8s, native, or remote) ``` -(The approach works similarly for non-k8s services via `tailscale serve` -service definitions, eg. [[forgejo]] and [[zot]]) A single Fly.io container serves as the public-facing proxy for all exposed -services. Each service gets a `server` block in the nginx config and a DNS -CNAME. The container joins the tailnet via an ephemeral auth key and reaches -backend services through Tailscale ingress endpoints. +services. Nginx routes all traffic through [[caddy]] on [[indri]] via a +direct Tailscale WireGuard connection. Caddy already knows how to route +to every service (native, minikube, or ringtail k3s), so adding a new +public service only requires an nginx `server` block and a DNS CNAME. -Existing `*.ops.eblu.me` services remain private behind Tailscale — this -approach does not touch [[caddy]], [[gandi]] DNS-01, or any other existing -infrastructure. They can continue to operate in parallel for private access. +The `*.ops.eblu.me` routes continue to work in parallel for private tailnet +access — the Fly proxy sends `Host: .ops.eblu.me` headers that +match the same Caddy routes. ## Key decisions @@ -61,21 +58,18 @@ infrastructure. They can continue to operate in parallel for private access. ## TLS in this architecture -There are three independent TLS segments — none involve Caddy: +There are three independent TLS segments: 1. **Browser → Fly.io edge**: Fly.io auto-provisions a Let's Encrypt certificate for each custom domain (e.g., `docs.eblu.me`). Validated via TLS-ALPN challenge — no DNS API needed. -2. **nginx → Tailscale ingress**: nginx proxies to - `https://.tail8d86e.ts.net`. The Tailscale ingress serves a - Tailscale-issued cert. nginx uses `proxy_ssl_verify off` since the - underlying tunnel is already encrypted. +2. **nginx → Caddy on indri**: nginx proxies to `https://indri.tail8d86e.ts.net` + with `Host: .ops.eblu.me`. Caddy serves its `*.ops.eblu.me` + Let's Encrypt wildcard cert. nginx uses `proxy_ssl_verify off` since the + underlying WireGuard tunnel is already encrypted. 3. **WireGuard tunnel**: All Tailscale traffic is encrypted at the network layer regardless of application-level TLS. -Caddy continues to serve `*.ops.eblu.me` with its existing Gandi DNS-01 -certificates. The two TLS domains are completely independent. - ## External references - [Tailscale on Fly.io](https://tailscale.com/kb/1132/flydotio) — official guide for running Tailscale in a Fly.io container @@ -116,8 +110,8 @@ See the actual files in `fly/` for current configuration. Key design points: - **`fly.toml`** — uses bluegreen deploys so the old machine serves traffic until the new one passes health checks. `auto_stop_machines = "off"` keeps the proxy always-on. - **`Dockerfile`** — multi-stage build pulling nginx, Tailscale, and [[alloy]] binaries. Alloy runs as a sidecar inside the container for observability (see below). -- **`start.sh`** — starts `tailscaled` first, waits for MagicDNS readiness (polls `nslookup` against `100.100.100.100`), then starts nginx, fail2ban, and Alloy, and blocks on the nginx process. The MagicDNS check is required because `upstream` blocks resolve DNS at config load. -- **`nginx.conf`** — uses `upstream` blocks with `keepalive` connection pools for each backend service. DNS is resolved at config load via MagicDNS (`resolver 100.100.100.100`). Each upstream requires `proxy_ssl_name` set explicitly to the Tailscale hostname (nginx sends the block name as SNI by default). A `map` directive conditionally sets the `Connection` header — empty string for keepalive on normal requests, `upgrade` only for WebSocket requests. Includes a JSON access log format that Alloy tails for log collection and metric extraction. A catch-all server block serves `/healthz` and rejects unknown hosts. +- **`start.sh`** — starts `tailscaled --port=41641` first (pinned port enables direct WireGuard peering), waits for MagicDNS readiness (polls `nslookup` against `100.100.100.100`), then starts nginx, fail2ban, and Alloy, and blocks on the nginx process. The MagicDNS check is required because the `upstream` block resolves DNS at config load. +- **`nginx.conf`** — uses a single `upstream` block with `keepalive` pointing at Caddy on indri (`indri.tail8d86e.ts.net:443`). All services route through this upstream with `Host: .ops.eblu.me` headers for Caddy routing. Includes a JSON access log format that Alloy tails for log collection and metric extraction. A catch-all server block serves `/healthz` and rejects unknown hosts. - **`error.html`** — shown via `proxy_intercept_errors` when upstreams are unreachable (indri offline, tunnel down, etc.). Cached responses still take priority via `proxy_cache_use_stale`. #### Observability sidecar @@ -174,11 +168,11 @@ ACL test: { "src": "tag:flyio-proxy", "accept": ["tag:flyio-target:443"], - "deny": ["tag:k8s:443", "tag:homelab:443", "tag:homelab:22", "tag:nas:445", "tag:registry:443"], + "deny": ["tag:k8s:443", "tag:homelab:22", "tag:nas:445", "tag:registry:443"], }, ``` -Each service's Tailscale Ingress must be annotated with `tag:flyio-target` to be reachable by the proxy — see [[#7. Tag the Tailscale Ingress with tag:flyio-target]]. +Indri carries `tag:flyio-target` so the Fly proxy can reach Caddy. No per-service tagging is needed — Caddy handles routing to all services. Deploy: `mise run tailnet-preview` then `mise run tailnet-up`. @@ -214,20 +208,17 @@ The `FLY_DEPLOY_TOKEN` Forgejo Actions secret must be set via the [[forgejo]] AP To expose an additional service (example: `wiki.eblu.me`): -### 1. Add nginx server block +### 1. Ensure the service has a Caddy route -Edit `fly/nginx.conf` — two changes needed: +The service must be accessible via `.ops.eblu.me` through [[caddy]]. +Most services already have this. If not, add it to `ansible/roles/caddy/defaults/main.yml` +and deploy with `mise run provision-indri -- --tags caddy`. -1. **Add an `upstream` block** (in the `http` context, alongside the existing ones): +### 2. Add nginx server block -```nginx -upstream wiki_backend { - server wiki.tail8d86e.ts.net:443; - keepalive 4; -} -``` - -2. **Add a `server` block.** The configuration differs significantly between static and dynamic services. See the existing blocks in `fly/nginx.conf` for the current pattern. +Edit `fly/nginx.conf` — add a `server` block. All services use the shared +`indri_backend` upstream (Caddy on indri). Set `Host` and `proxy_ssl_name` +to the service's `*.ops.eblu.me` hostname so Caddy routes correctly. **Static site template** (simplified — adapt from existing blocks): @@ -246,11 +237,11 @@ server { } location / { - proxy_pass https://wiki_backend$request_uri; + proxy_pass https://indri_backend$request_uri; proxy_ssl_verify off; proxy_ssl_server_name on; - proxy_ssl_name wiki.tail8d86e.ts.net; - proxy_set_header Host wiki.tail8d86e.ts.net; + proxy_ssl_name wiki.ops.eblu.me; + proxy_set_header Host wiki.ops.eblu.me; proxy_intercept_errors on; proxy_http_version 1.1; @@ -270,25 +261,8 @@ server { } ``` -**Key points for all upstream blocks:** -- `proxy_ssl_name` must be set explicitly — nginx sends the upstream block name as SNI by default, which the Tailscale Ingress won't recognize -- `proxy_http_version 1.1` + `Connection $connection_upgrade` enables keepalive (empty string for normal requests, "upgrade" for WebSocket) -- `keepalive` pool size: 4 for low-traffic static sites, 8 for higher-traffic dynamic services - **Dynamic service template** — see `fly/nginx.conf` for the live Forgejo configuration, which includes rate-limited auth endpoints, cached static assets and release downloads, archive endpoint redirects, robots.txt, and WebSocket support. -Key differences for dynamic services: -- **No blanket caching** — only static assets (CSS, JS, images) are cached -- **Respect `Set-Cookie`** — do not ignore session headers -- **Include query strings** in non-cached requests (default behavior when - `proxy_cache_key` is not overridden) -- **Higher rate limits** — legitimate usage patterns are burstier -- **Proxy headers** — pass `X-Real-IP`, `X-Forwarded-For`, `X-Forwarded-Proto` - so the backend sees the real client IP (important for Forgejo's audit logs - and its own rate limiting) -- **WebSocket support** — many modern web apps use WebSockets -- **Larger body size** — git pushes and file uploads need more than the default 1MB - ### 2. Add Fly.io certificate ```bash @@ -345,18 +319,9 @@ curl -I https://wiki.eblu.me # Should return 200 with X-Cache-Status header ``` -### 7. Tag the Tailscale Ingress with `tag:flyio-target` +### 7. Verify routing -The fly.io proxy can only reach endpoints tagged with `tag:flyio-target`. Add the annotation to the service's Tailscale Ingress: - -```yaml -annotations: - tailscale.com/tags: "tag:k8s,tag:flyio-target" -``` - -Include `tag:k8s` to preserve existing access rules for the Ingress proxy node. The `tag:flyio-target` tag opts this specific endpoint into being reachable by the fly.io proxy — no broad ACL changes needed. - -For non-k8s services (e.g., Forgejo on indri), create a k8s ExternalName Service pointing to the host, then a Tailscale Ingress with the same annotation. +Since all traffic routes through Caddy on indri, no per-service Tailscale Ingress tagging is needed. As long as the service has a Caddy route (step 1), the Fly proxy can reach it. --- From bdfcb4b6778a5f993270d97ca725c8bff0ef4f5d Mon Sep 17 00:00:00 2001 From: Forgejo Actions Date: Sat, 18 Apr 2026 10:00:54 -0700 Subject: [PATCH 300/430] Update docs release to v1.16.0 - Built changelog from towncrier fragments [skip ci] --- CHANGELOG.md | 7 +++++++ argocd/manifests/docs/deployment.yaml | 2 +- docs/changelog.d/+fly-caddy-direct-peering.infra.md | 1 - 3 files changed, 8 insertions(+), 2 deletions(-) delete mode 100644 docs/changelog.d/+fly-caddy-direct-peering.infra.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b4d299..7ae5f8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [v1.16.0] - 2026-04-18 + +### Infrastructure + +- Route Fly.io proxy through Caddy on indri with direct WireGuard peering, reducing public-facing latency from 20+ seconds (DERP relay) to sub-second. Fixed Beyla eBPF tracing on ringtail (memlock rlimit + BPF permissions). Restored trace collection to Tempo. + + ## [v1.15.7] - 2026-04-18 ### Bug Fixes diff --git a/argocd/manifests/docs/deployment.yaml b/argocd/manifests/docs/deployment.yaml index a3911e8..c477b83 100644 --- a/argocd/manifests/docs/deployment.yaml +++ b/argocd/manifests/docs/deployment.yaml @@ -30,7 +30,7 @@ spec: name: http env: - name: DOCS_RELEASE_URL - value: "https://forge.eblu.me/eblume/blumeops/releases/download/v1.15.7/docs-v1.15.7.tar.gz" + value: "https://forge.eblu.me/eblume/blumeops/releases/download/v1.16.0/docs-v1.16.0.tar.gz" resources: requests: memory: "64Mi" diff --git a/docs/changelog.d/+fly-caddy-direct-peering.infra.md b/docs/changelog.d/+fly-caddy-direct-peering.infra.md deleted file mode 100644 index 9e308eb..0000000 --- a/docs/changelog.d/+fly-caddy-direct-peering.infra.md +++ /dev/null @@ -1 +0,0 @@ -Route Fly.io proxy through Caddy on indri with direct WireGuard peering, reducing public-facing latency from 20+ seconds (DERP relay) to sub-second. Fixed Beyla eBPF tracing on ringtail (memlock rlimit + BPF permissions). Restored trace collection to Tempo. From 55abb17f5081d0d4bb2939413919bd2d5970cf40 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sat, 18 Apr 2026 13:03:57 -0700 Subject: [PATCH 301/430] Add resource limits to ArgoCD pods to prevent unbounded consumption All 7 ArgoCD containers had no resource limits, allowing them to consume unlimited CPU/memory during node pressure events. This contributed to cluster-wide probe timeout cascades on minikube-indri. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../argocd/argocd-resources-patch.yaml | 118 ++++++++++++++++++ argocd/manifests/argocd/kustomization.yaml | 1 + .../+argocd-resource-limits.infra.md | 1 + 3 files changed, 120 insertions(+) create mode 100644 argocd/manifests/argocd/argocd-resources-patch.yaml create mode 100644 docs/changelog.d/+argocd-resource-limits.infra.md diff --git a/argocd/manifests/argocd/argocd-resources-patch.yaml b/argocd/manifests/argocd/argocd-resources-patch.yaml new file mode 100644 index 0000000..1ae0675 --- /dev/null +++ b/argocd/manifests/argocd/argocd-resources-patch.yaml @@ -0,0 +1,118 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: argocd-server +spec: + template: + spec: + containers: + - name: argocd-server + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: argocd-repo-server +spec: + template: + spec: + containers: + - name: argocd-repo-server + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: argocd-application-controller +spec: + template: + spec: + containers: + - name: argocd-application-controller + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: "1" + memory: 1Gi +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: argocd-applicationset-controller +spec: + template: + spec: + containers: + - name: argocd-applicationset-controller + resources: + requests: + cpu: 25m + memory: 64Mi + limits: + cpu: 250m + memory: 256Mi +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: argocd-dex-server +spec: + template: + spec: + containers: + - name: dex + resources: + requests: + cpu: 25m + memory: 64Mi + limits: + cpu: 250m + memory: 256Mi +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: argocd-redis +spec: + template: + spec: + containers: + - name: redis + resources: + requests: + cpu: 25m + memory: 64Mi + limits: + cpu: 250m + memory: 256Mi +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: argocd-notifications-controller +spec: + template: + spec: + containers: + - name: argocd-notifications-controller + resources: + requests: + cpu: 25m + memory: 64Mi + limits: + cpu: 250m + memory: 256Mi diff --git a/argocd/manifests/argocd/kustomization.yaml b/argocd/manifests/argocd/kustomization.yaml index 85dfa7e..9bdac10 100644 --- a/argocd/manifests/argocd/kustomization.yaml +++ b/argocd/manifests/argocd/kustomization.yaml @@ -16,3 +16,4 @@ patches: - path: argocd-ssh-known-hosts-cm.yaml - path: argocd-cm-patch.yaml - path: argocd-rbac-cm-patch.yaml + - path: argocd-resources-patch.yaml diff --git a/docs/changelog.d/+argocd-resource-limits.infra.md b/docs/changelog.d/+argocd-resource-limits.infra.md new file mode 100644 index 0000000..ba24a5a --- /dev/null +++ b/docs/changelog.d/+argocd-resource-limits.infra.md @@ -0,0 +1 @@ +Add resource limits to all ArgoCD pods to prevent unbounded resource consumption during node-wide pressure events. From 1d62653871de76a9ec1d13b32f0438ced63028f1 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sat, 18 Apr 2026 16:00:56 -0700 Subject: [PATCH 302/430] Fix forge.eblu.me static assets by adding missing Host header The static asset cache block (css/js/png/etc) was missing proxy_set_header Host, so Caddy received "forge.eblu.me" instead of "forge.ops.eblu.me" and couldn't route the request. HTML loaded fine because the main location / block had the header. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/changelog.d/+fix-forge-static-assets.bugfix.md | 1 + fly/nginx.conf | 5 +++++ 2 files changed, 6 insertions(+) create mode 100644 docs/changelog.d/+fix-forge-static-assets.bugfix.md diff --git a/docs/changelog.d/+fix-forge-static-assets.bugfix.md b/docs/changelog.d/+fix-forge-static-assets.bugfix.md new file mode 100644 index 0000000..de0517e --- /dev/null +++ b/docs/changelog.d/+fix-forge-static-assets.bugfix.md @@ -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. diff --git a/fly/nginx.conf b/fly/nginx.conf index ceafbdd..5e49d88 100644 --- a/fly/nginx.conf +++ b/fly/nginx.conf @@ -252,6 +252,11 @@ http { proxy_http_version 1.1; proxy_set_header Connection $connection_upgrade; + proxy_set_header Host forge.ops.eblu.me; + proxy_set_header X-Real-IP $http_fly_client_ip; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache services; proxy_cache_valid 200 7d; proxy_cache_key $host$uri; From 4f5a963ef681923b84ced5d5bc0a650d77b29636 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sat, 18 Apr 2026 16:39:21 -0700 Subject: [PATCH 303/430] Add API token auth and git remote detection to runner-logs runner-logs now always authenticates with the Forgejo API token (via --token flag, FORGEJO_TOKEN env, or 1Password) so it works on private repos. The --repo default is auto-detected from the git remote origin URL instead of being hardcoded. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/changelog.d/+runner-logs-auth.feature.md | 1 + mise-tasks/runner-logs | 94 ++++++++++++++++--- 2 files changed, 81 insertions(+), 14 deletions(-) create mode 100644 docs/changelog.d/+runner-logs-auth.feature.md diff --git a/docs/changelog.d/+runner-logs-auth.feature.md b/docs/changelog.d/+runner-logs-auth.feature.md new file mode 100644 index 0000000..7329bc6 --- /dev/null +++ b/docs/changelog.d/+runner-logs-auth.feature.md @@ -0,0 +1 @@ +runner-logs now authenticates with Forgejo API token (works on private repos) and auto-detects the repo from git remote. diff --git a/mise-tasks/runner-logs b/mise-tasks/runner-logs index 4db203d..dddeaa1 100755 --- a/mise-tasks/runner-logs +++ b/mise-tasks/runner-logs @@ -7,8 +7,9 @@ #USAGE arg "[run_number]" help="Run number to show jobs for (omit to list recent runs)" #USAGE flag "--job -j " help="Job index (0-based) to fetch logs for" #USAGE flag "--runner -r " help="Filter listing by runner: indri, ringtail, or all" -#USAGE flag "--repo " help="Forge repo (owner/name), default eblume/blumeops" +#USAGE flag "--repo " help="Forge repo (owner/name), default: detected from git remote" #USAGE flag "--limit -n " help="Max runs to display (0 for all)" +#USAGE flag "--token " help="Forgejo API token (default: read from 1Password)" """List recent Forgejo Actions runs and fetch job logs. Usage: @@ -20,6 +21,9 @@ Usage: mise run runner-logs 474 -j 1 # fetch logs for job 1 of run 474 """ +import os +import re +import subprocess import sys from typing import Annotated @@ -30,6 +34,7 @@ from rich.table import Table FORGE_URL = "https://forge.ops.eblu.me" FORGE_API = f"{FORGE_URL}/api/v1" +OP_TOKEN_REF = "op://vg6xf6vvfmoh5hqjjhlhbeoaie/w3663ffnvkewbftncqxtcpeavy/api-token" # Workflows using the ringtail nix-container-builder runner; everything else # runs on the indri k8s runner. @@ -38,11 +43,55 @@ RINGTAIL_WORKFLOWS = {"build-container-nix.yaml"} app = typer.Typer(add_completion=False) +def resolve_token(explicit_token: str | None, console: Console) -> str: + """Resolve Forgejo API token: explicit flag > FORGEJO_TOKEN env > 1Password.""" + if explicit_token: + return explicit_token + env_token = os.environ.get("FORGEJO_TOKEN", "").strip() + if env_token: + return env_token + console.print("[dim]Reading Forgejo API token from 1Password...[/dim]") + result = subprocess.run( + ["op", "read", OP_TOKEN_REF], + capture_output=True, + text=True, + check=True, + ) + return result.stdout.strip() + + +def detect_repo_from_git() -> str | None: + """Sniff owner/repo from the git remote 'origin' URL.""" + try: + result = subprocess.run( + ["git", "remote", "get-url", "origin"], + capture_output=True, + text=True, + check=True, + ) + except (subprocess.CalledProcessError, FileNotFoundError): + return None + url = result.stdout.strip() + # Match SSH (git@host:owner/repo.git) or HTTPS (https://host/owner/repo.git) + m = re.search(r"[/:]([^/]+/[^/]+?)(?:\.git)?$", url) + if not m: + return None + candidate = m.group(1) + # Only use it if the remote points at our forge + if "forge.ops.eblu.me" in url or "forge.eblu.me" in url: + return candidate + return None + + def runner_for_workflow(workflow_id: str) -> str: return "ringtail" if workflow_id in RINGTAIL_WORKFLOWS else "indri" -def fetch_tasks(repo: str) -> list[dict]: +def auth_headers(token: str) -> dict[str, str]: + return {"Authorization": f"token {token}"} + + +def fetch_tasks(repo: str, token: str) -> list[dict]: """Fetch all tasks from the Forgejo API, paginating if needed.""" tasks: list[dict] = [] page = 1 @@ -50,6 +99,7 @@ def fetch_tasks(repo: str) -> list[dict]: resp = httpx.get( f"{FORGE_API}/repos/{repo}/actions/tasks", params={"page": page, "limit": 50}, + headers=auth_headers(token), timeout=15, ) resp.raise_for_status() @@ -61,9 +111,9 @@ def fetch_tasks(repo: str) -> list[dict]: return tasks -def list_runs(runner: str, repo: str, limit: int, console: Console) -> None: +def list_runs(runner: str, repo: str, limit: int, token: str, console: Console) -> None: """List recent workflow runs, grouped by run number.""" - tasks = fetch_tasks(repo) + tasks = fetch_tasks(repo, token) # Group tasks by run_number runs: dict[int, list[dict]] = {} @@ -120,9 +170,9 @@ def list_runs(runner: str, repo: str, limit: int, console: Console) -> None: console.print("[dim] mise run runner-logs -j N to fetch logs for job N[/dim]") -def show_jobs(run_number: int, repo: str, console: Console) -> None: +def show_jobs(run_number: int, repo: str, token: str, console: Console) -> None: """Show the jobs within a specific run.""" - tasks = fetch_tasks(repo) + tasks = fetch_tasks(repo, token) jobs = sorted( [t for t in tasks if t["run_number"] == run_number], @@ -152,10 +202,10 @@ def show_jobs(run_number: int, repo: str, console: Console) -> None: console.print(f"\n[dim]Use: mise run runner-logs {run_number} -j N to fetch logs for job N[/dim]") -def fetch_log(run_number: int, job_index: int, repo: str) -> None: +def fetch_log(run_number: int, job_index: int, repo: str, token: str) -> None: """Fetch logs for a specific job via the Forgejo web endpoint.""" url = f"{FORGE_URL}/{repo}/actions/runs/{run_number}/jobs/{job_index}/attempt/1/logs" - resp = httpx.get(url, timeout=30, follow_redirects=True) + resp = httpx.get(url, headers=auth_headers(token), timeout=30, follow_redirects=True) if resp.status_code == 404: typer.echo( f"Error: No logs found for run #{run_number} job {job_index}", @@ -182,13 +232,17 @@ def main( typer.Option("--runner", "-r", help="Filter listing by runner: indri, ringtail, or all"), ] = "all", repo: Annotated[ - str, - typer.Option("--repo", help="Forge repo (owner/name)"), - ] = "eblume/blumeops", + str | None, + typer.Option("--repo", help="Forge repo (owner/name), default: detected from git remote"), + ] = None, limit: Annotated[ int, typer.Option("--limit", "-n", help="Max runs to display (0 for all)"), ] = 15, + token: Annotated[ + str | None, + typer.Option("--token", help="Forgejo API token (default: read from 1Password)"), + ] = None, ) -> None: """List recent Forgejo Actions runs or fetch logs for a specific job.""" if runner not in ("indri", "ringtail", "all"): @@ -197,15 +251,27 @@ def main( console = Console() + if repo is None: + repo = detect_repo_from_git() + if repo is None: + typer.echo( + "Error: could not detect repo from git remote; use --repo owner/name", + err=True, + ) + raise typer.Exit(1) + console.print(f"[dim]Detected repo: {repo}[/dim]") + + resolved_token = resolve_token(token, console) + if run_number is None: if job is not None: typer.echo("Error: --job requires a run number", err=True) raise typer.Exit(1) - list_runs(runner, repo, limit, console) + list_runs(runner, repo, limit, resolved_token, console) elif job is None: - show_jobs(run_number, repo, console) + show_jobs(run_number, repo, resolved_token, console) else: - fetch_log(run_number, job, repo) + fetch_log(run_number, job, repo, resolved_token) if __name__ == "__main__": From 71c1c453d64f99436d40e8aab78af3a59526501f Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sat, 18 Apr 2026 17:08:46 -0700 Subject: [PATCH 304/430] Fetch job logs via SSH to indri instead of Forgejo web endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Forgejo's web action routes don't support API token auth for private repos (only session cookies or public access). Switch log fetching to read the zstd-compressed log files directly from indri via SSH — Forgejo stores all runner logs on disk regardless of which runner executed the job. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/changelog.d/+runner-logs-auth.feature.md | 2 +- mise-tasks/runner-logs | 45 +++++++++++++++---- 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/docs/changelog.d/+runner-logs-auth.feature.md b/docs/changelog.d/+runner-logs-auth.feature.md index 7329bc6..9ee6fa1 100644 --- a/docs/changelog.d/+runner-logs-auth.feature.md +++ b/docs/changelog.d/+runner-logs-auth.feature.md @@ -1 +1 @@ -runner-logs now authenticates with Forgejo API token (works on private repos) and auto-detects the repo from git remote. +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. diff --git a/mise-tasks/runner-logs b/mise-tasks/runner-logs index dddeaa1..579a5fd 100755 --- a/mise-tasks/runner-logs +++ b/mise-tasks/runner-logs @@ -203,18 +203,47 @@ def show_jobs(run_number: int, repo: str, token: str, console: Console) -> None: def fetch_log(run_number: int, job_index: int, repo: str, token: str) -> None: - """Fetch logs for a specific job via the Forgejo web endpoint.""" - url = f"{FORGE_URL}/{repo}/actions/runs/{run_number}/jobs/{job_index}/attempt/1/logs" - resp = httpx.get(url, headers=auth_headers(token), timeout=30, follow_redirects=True) - if resp.status_code == 404: + """Fetch logs for a specific job via SSH to indri. + + Forgejo stores action logs as zstd-compressed files on disk at + ~/forgejo/data/actions_log/{owner}/{repo}/{hex_prefix}/{task_id}.log.zst + regardless of which runner executed the job. The web log endpoint doesn't + support API-token auth for private repos, so we read the files directly. + """ + tasks = fetch_tasks(repo, token) + jobs = sorted( + [t for t in tasks if t["run_number"] == run_number], + key=lambda x: x["id"], + ) + if not jobs: + typer.echo(f"Error: No jobs found for run #{run_number}", err=True) + raise typer.Exit(1) + if job_index < 0 or job_index >= len(jobs): typer.echo( - f"Error: No logs found for run #{run_number} job {job_index}", + f"Error: job index {job_index} out of range (run #{run_number} has {len(jobs)} jobs)", err=True, ) - typer.echo(f"URL: {url}", err=True) raise typer.Exit(1) - resp.raise_for_status() - sys.stdout.write(resp.text) + + task_id = jobs[job_index]["id"] + hex_prefix = f"{task_id & 0xff:02x}" + log_path = f"~/forgejo/data/actions_log/{repo}/{hex_prefix}/{task_id}.log.zst" + + result = subprocess.run( + ["ssh", "indri", f"zstdcat {log_path}"], + capture_output=True, + text=True, + ) + if result.returncode != 0: + typer.echo( + f"Error: could not read log for run #{run_number} job {job_index} (task {task_id})", + err=True, + ) + typer.echo(f"Path: indri:{log_path}", err=True) + if result.stderr.strip(): + typer.echo(result.stderr.strip(), err=True) + raise typer.Exit(1) + sys.stdout.write(result.stdout) @app.command() From deedeecef98457d67dbfa07c3f90e3d1c77ab31d Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sat, 18 Apr 2026 20:15:30 -0700 Subject: [PATCH 305/430] C0: adopt AGENTS.md as canonical agent config --- AGENTS.md | 165 ++++++++++++++++++ CLAUDE.md | 164 +---------------- README.md | 6 +- .../+agent-file-neutralization.ai.md | 1 + docs/tutorials/ai-assistance-guide.md | 4 +- docs/tutorials/exploring-the-docs.md | 8 +- mise-tasks/mikado-branch-invariant-check | 2 +- 7 files changed, 179 insertions(+), 171 deletions(-) create mode 100644 AGENTS.md create mode 100644 docs/changelog.d/+agent-file-neutralization.ai.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..80f9852 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,165 @@ +# AGENTS.md + +Guidance for AI agents working in this repository. See also [[ai-assistance-guide]]. + +## Overview + +blumeops is Erich Blume's GitOps repository for personal infrastructure, orchestrated via tailnet `tail8d86e.ts.net`. + +**CRITICAL: Public repo at github.com/eblume/blumeops - never commit secrets!** + +**Shell:** The user's interactive shell may differ from the current harness shell. Prefer repo-safe, non-interactive commands when possible, and match the user's shell conventions when giving interactive examples. + +## Rules + +1. **Always run `mise run ai-docs` at session start** + This will refresh your context with important information you will be assumed to know and follow. + **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 + **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 +4. **Feature branches + PRs for C1/C2** - checkout main, pull, create branch, open PR via `tea pr create`. C0 goes direct to main. +5. **Check PR comments with `mise run pr-comments `** before proceeding +6. **Add changelog fragments (all change levels)** - `docs/changelog.d/..md` + Types: `feature`, `bugfix`, `infra`, `doc`, `ai`, `misc` + Applies to C0, C1, and C2 whenever the change is user-visible or noteworthy. + - **C1/C2:** Use branch name: `..md` + - **C0:** Use orphan prefix: `+..md` (avoids `main.*` collisions) +7. **Test before applying** - dry runs (`--check --diff`), syntax checks, `ssh indri '...'` +8. **Wait for user review before deploying** (C1/C2) +9. **Never merge PRs or push to main without explicit request** (C0 commits to main are fine) +10. **Verify deployments** - `mise run services-check` + +## Change Classification + +Before starting work, classify the change: + +| Class | Name | When to use | Key trait | +|-------|------|-------------|-----------| +| **C0** | Quick Fix | Small, low-risk, fix-forward safe | Direct to main, no PR | +| **C1** | Human Review | Moderate complexity or risk | Feature branch + PR, docs-first | +| **C2** | Mikado Chain | Multi-phase, multi-session, high complexity | Mikado Branch Invariant | + +**C0** — commit directly to main. No branch or PR needed. Fix forward if problems arise. + +**C1** — feature branch with early PR. Search related docs first, write documentation changes before code, deploy from the unmerged branch (ArgoCD `--revision`, Ansible from checkout). Upgrade to C2 if complexity spirals. + +**C2** — branch `mikado/` governed by the Mikado Branch Invariant: all card commits first, then code progress, then card closures. Commits use `C2(): plan/impl/close/finalize` convention. Reset the branch when new prerequisites are discovered. Resume with `mise run docs-mikado --resume`. + +See [[agent-change-process]] for the full methodology. + +## Project Structure + +``` +./docs/ # documentation (Diataxis, Quartz) +./docs/changelog.d/ # towncrier fragments +./.dagger/ # dagger pipelines +./.forgejo/ # forgejo-runner actions and workflows +./mise-tasks/ # scripts via `mise run` +./ansible/playbooks/ # ansible (indri.yml primary) +./ansible/roles/ # indri service roles +./argocd/apps/ # ArgoCD Application definitions +./argocd/manifests/ # k8s manifests per service +./fly/ # fly.io proxy for public routing +./pulumi/ # Pulumi IaC (tailnet ACLs, dns, cloud) +~/.config/{nvim,fish} # user's shell config, managed by chezmoi +~/code/personal/ # user's projects +~/code/personal/zk # user's Obsidian-sync managed zettelkasten. Potential source for reference data. +~/code/3rd/ # mirrored external projects +~/code/work # FORBIDDEN +``` +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. + +## Service Deployment + +### Kubernetes (ArgoCD) + +Most services run in minikube on indri via ArgoCD (app-of-apps, manual sync). GPU workloads (Frigate, ntfy) run on ringtail's k3s cluster, also managed by ArgoCD. + +**PR workflow:** +1. Create branch, modify `argocd/manifests//` +2. Push. Sync 'apps' app if service definition changed (set --revision to branch). +3. Test on branch: `argocd app set --revision && argocd app sync ` +4. After merge: `argocd app set --revision main && argocd app sync ` + +**Commands:** `argocd app list|get|diff|sync ` + +**Login:** `argocd login argocd.ops.eblu.me --username admin --password "$(op read 'op://vg6xf6vvfmoh5hqjjhlhbeoaie/srogeebssulhtb6tnqd7ls6qey/password')"` + +### Indri (Ansible) + +Native services: Forgejo, Zot, Caddy, Borgmatic, Alloy + +```fish +mise run provision-indri # full +mise run provision-indri -- --tags # specific +mise run provision-indri -- --check --diff # dry run +``` + +### Routing + +| Domain | Mechanism | Reachable from | +|--------|-----------|----------------| +| `*.eblu.me` | Fly.io proxy (Tailscale tunnel) | public internet | +| `*.ops.eblu.me` | Caddy on indri | k8s pods, containers, tailnet | +| `*.tail8d86e.ts.net` | Tailscale MagicDNS | tailnet clients only | + +Check tailscale serve: `ssh indri 'tailscale serve status --json'` + +## Container Releases + +```fish +mise run container-list # show images/tags +mise run container-release # tag and build +``` +The goal is to eventually use only locally built containers in all cases, with +full supply chain control via forge.ops.eblu.me repositories, mirroring source +from upstream. + +**After triggering a build** (manual dispatch or push to main), verify the +workflow succeeded before proceeding: + +```fish +mise run runner-logs # find the run number +mise run runner-logs # see jobs in the run +mise run runner-logs -j # fetch logs on failure +``` + +This also works for other forge repos (`--repo eblume/hermes`). + +## Third-Party Projects + +Ask user to mirror on forge first, then clone to `~/code/3rd//`. + +### Sporked Projects + +Some mirrored projects are "sporked" — a floating-branch soft-fork strategy +where local patches are continuously rebased on top of upstream. See +[[spork-strategy]] and [[create-a-spork]] for the full methodology. + +Sporked projects live in `~/code/3rd//` with three remotes: +`origin` (eblume/ fork on forge), `mirror` (mirrors/ on forge), `upstream` +(canonical). The `blumeops` branch is the default; `deploy` merges everything. + +Create a new spork: `mise run spork-create ` + +## Task Discovery + +```fish +mise run blumeops-tasks # fetch from Todoist, sorted by priority +``` +Most tasks are stored in `./mise-tasks/`. For scripts with any logic or +complexity, use uv run --script 's with explicit dependencies. Complex +workflows with artifacts should become dagger pipelines. Mise tasks are for +development processes and operations - tools for the user or the agent. + +## Credentials + +Root store is 1Password. Never grab directly - use existing patterns (ansible +pre_tasks, external-secrets, scripts with `op` CLI). It's ok to use `op item +get` without `--reveal` to explore what secrets are available, however. + +Prefer `op read "op://vault/item/field"` over `op item get --fields` to avoid +quoting issues with multi-line values. diff --git a/CLAUDE.md b/CLAUDE.md index ee071cb..d825c0f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,165 +1,7 @@ # CLAUDE.md -Guidance for Claude Code working in this repository. See also [[ai-assistance-guide]]. +Claude Code compatibility shim. -## Overview +The canonical agent instructions for this repository now live in [`AGENTS.md`](AGENTS.md). -blumeops is Erich Blume's GitOps repository for personal infrastructure, orchestrated via tailnet `tail8d86e.ts.net`. - -**CRITICAL: Public repo at github.com/eblume/blumeops - never commit secrets!** - -**Shell:** The user's shell is **fish**. Use `$status` not `$?` for exit codes. Use fish syntax in interactive examples. - -## Rules - -1. **Always run `mise run ai-docs` at session start** - This will refresh your context with important information you will be assumed to know and follow. - **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 - **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 -4. **Feature branches + PRs for C1/C2** - checkout main, pull, create branch, open PR via `tea pr create`. C0 goes direct to main. -5. **Check PR comments with `mise run pr-comments `** before proceeding -6. **Add changelog fragments (all change levels)** - `docs/changelog.d/..md` - Types: `feature`, `bugfix`, `infra`, `doc`, `ai`, `misc` - Applies to C0, C1, and C2 whenever the change is user-visible or noteworthy. - - **C1/C2:** Use branch name: `..md` - - **C0:** Use orphan prefix: `+..md` (avoids `main.*` collisions) -7. **Test before applying** - dry runs (`--check --diff`), syntax checks, `ssh indri '...'` -8. **Wait for user review before deploying** (C1/C2) -9. **Never merge PRs or push to main without explicit request** (C0 commits to main are fine) -10. **Verify deployments** - `mise run services-check` - -## Change Classification - -Before starting work, classify the change: - -| Class | Name | When to use | Key trait | -|-------|------|-------------|-----------| -| **C0** | Quick Fix | Small, low-risk, fix-forward safe | Direct to main, no PR | -| **C1** | Human Review | Moderate complexity or risk | Feature branch + PR, docs-first | -| **C2** | Mikado Chain | Multi-phase, multi-session, high complexity | Mikado Branch Invariant | - -**C0** — commit directly to main. No branch or PR needed. Fix forward if problems arise. - -**C1** — feature branch with early PR. Search related docs first, write documentation changes before code, deploy from the unmerged branch (ArgoCD `--revision`, Ansible from checkout). Upgrade to C2 if complexity spirals. - -**C2** — branch `mikado/` governed by the Mikado Branch Invariant: all card commits first, then code progress, then card closures. Commits use `C2(): plan/impl/close/finalize` convention. Reset the branch when new prerequisites are discovered. Resume with `mise run docs-mikado --resume`. - -See [[agent-change-process]] for the full methodology. - -## Project Structure - -``` -./docs/ # documentation (Diataxis, Quartz) -./docs/changelog.d/ # towncrier fragments -./.dagger/ # dagger pipelines -./.forgejo/ # forgejo-runner actions and workflows -./mise-tasks/ # scripts via `mise run` -./ansible/playbooks/ # ansible (indri.yml primary) -./ansible/roles/ # indri service roles -./argocd/apps/ # ArgoCD Application definitions -./argocd/manifests/ # k8s manifests per service -./fly/ # fly.io proxy for public routing -./pulumi/ # Pulumi IaC (tailnet ACLs, dns, cloud) -~/.config/{nvim,fish} # user's shell config, managed by chezmoi -~/code/personal/ # user's projects -~/code/personal/zk # user's Obsidian-sync managed zettelkasten. Potential source for reference data. -~/code/3rd/ # mirrored external projects -~/code/work # FORBIDDEN -``` -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. - -## Service Deployment - -### Kubernetes (ArgoCD) - -Most services run in minikube on indri via ArgoCD (app-of-apps, manual sync). GPU workloads (Frigate, ntfy) run on ringtail's k3s cluster, also managed by ArgoCD. - -**PR workflow:** -1. Create branch, modify `argocd/manifests//` -2. Push. Sync 'apps' app if service definition changed (set --revision to branch). -3. Test on branch: `argocd app set --revision && argocd app sync ` -4. After merge: `argocd app set --revision main && argocd app sync ` - -**Commands:** `argocd app list|get|diff|sync ` - -**Login:** `argocd login argocd.ops.eblu.me --username admin --password "$(op read 'op://vg6xf6vvfmoh5hqjjhlhbeoaie/srogeebssulhtb6tnqd7ls6qey/password')"` - -### Indri (Ansible) - -Native services: Forgejo, Zot, Caddy, Borgmatic, Alloy - -```fish -mise run provision-indri # full -mise run provision-indri -- --tags # specific -mise run provision-indri -- --check --diff # dry run -``` - -### Routing - -| Domain | Mechanism | Reachable from | -|--------|-----------|----------------| -| `*.eblu.me` | Fly.io proxy (Tailscale tunnel) | public internet | -| `*.ops.eblu.me` | Caddy on indri | k8s pods, containers, tailnet | -| `*.tail8d86e.ts.net` | Tailscale MagicDNS | tailnet clients only | - -Check tailscale serve: `ssh indri 'tailscale serve status --json'` - -## Container Releases - -```fish -mise run container-list # show images/tags -mise run container-release # tag and build -``` -The goal is to eventually use only locally built containers in all cases, with -full supply chain control via forge.ops.eblu.me repositories, mirroring source -from upstream. - -**After triggering a build** (manual dispatch or push to main), verify the -workflow succeeded before proceeding: - -```fish -mise run runner-logs # find the run number -mise run runner-logs # see jobs in the run -mise run runner-logs -j # fetch logs on failure -``` - -This also works for other forge repos (`--repo eblume/hermes`). - -## Third-Party Projects - -Ask user to mirror on forge first, then clone to `~/code/3rd//`. - -### Sporked Projects - -Some mirrored projects are "sporked" — a floating-branch soft-fork strategy -where local patches are continuously rebased on top of upstream. See -[[spork-strategy]] and [[create-a-spork]] for the full methodology. - -Sporked projects live in `~/code/3rd//` with three remotes: -`origin` (eblume/ fork on forge), `mirror` (mirrors/ on forge), `upstream` -(canonical). The `blumeops` branch is the default; `deploy` merges everything. - -Create a new spork: `mise run spork-create ` - -## Task Discovery - -```fish -mise run blumeops-tasks # fetch from Todoist, sorted by priority -``` -Most tasks are stored in `./mise-tasks/`. For scripts with any logic or -complexity, use uv run --script 's with explicit dependencies. Complex -workflows with artifacts should become dagger pipelines. Mise tasks are for -development processes and operations - tools for the user or the agent. - -## Credentials - -Root store is 1Password. Never grab directly - use existing patterns (ansible -pre_tasks, external-secrets, scripts with `op` CLI). It's ok to use `op item -get` without `--reveal` to explore what secrets are available, however. - -Prefer `op read "op://vault/item/field"` over `op item get --fields` to avoid -quoting issues with multi-line values. +If a tool specifically looks for `CLAUDE.md`, read `AGENTS.md` and follow that file as the source of truth. diff --git a/README.md b/README.md index 8ba6b8d..e5945e5 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Tools and configuration for Erich Blume's personal infrastructure, orchestrated across a Tailscale tailnet. This is a homelab, but it's also a testing ground for AI-assisted -infrastructure development. Much of this codebase was co-authored with [Claude +infrastructure development. Much of this codebase was initially co-authored with [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/overview), and the repo places heavy emphasis on documentation, process, and change classification to make that collaboration work well. I don't know entirely how @@ -77,7 +77,7 @@ mise run container-list # list tracked container images ## AI-assisted development This repo is designed to be worked on by both humans and AI agents. The -[`CLAUDE.md`](CLAUDE.md) file provides instructions for Claude Code, and the +[`AGENTS.md`](AGENTS.md) file provides shared instructions for agentic tools, and the [`docs/tutorials/ai-assistance-guide.md`](docs/tutorials/ai-assistance-guide.md) explains the full workflow. @@ -87,7 +87,7 @@ Changes are classified before starting work: - **C1** - feature branch + PR, documentation written before code - **C2** - multi-phase work using the Mikado method for dependency tracking -See the [agent change process](docs/how-to/agent-change-process.md) for +See the [agent change process](docs/explanation/agent-change-process.md) for details. ## License diff --git a/docs/changelog.d/+agent-file-neutralization.ai.md b/docs/changelog.d/+agent-file-neutralization.ai.md new file mode 100644 index 0000000..da16fba --- /dev/null +++ b/docs/changelog.d/+agent-file-neutralization.ai.md @@ -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. diff --git a/docs/tutorials/ai-assistance-guide.md b/docs/tutorials/ai-assistance-guide.md index 9138526..3ee1ffa 100644 --- a/docs/tutorials/ai-assistance-guide.md +++ b/docs/tutorials/ai-assistance-guide.md @@ -10,7 +10,7 @@ tags: > **Audiences:** AI, Owner -This guide provides context for AI agents (like Claude Code) assisting with BlumeOps operations, and helps Erich understand how to work effectively with AI assistance. +This guide provides context for AI agents assisting with BlumeOps operations, and helps Erich understand how to work effectively with AI assistance. ## Critical Rules @@ -22,7 +22,7 @@ These are non-negotiable for AI agents working in this repo: 4. **Wait for user review before deploying** - Create PRs, don't auto-deploy 5. **Never merge PRs without explicit request** - The user merges after review -Full rules are in the repo's `CLAUDE.md`. See [[agent-change-process]] for the C0/C1/C2 change classification methodology — C0 (direct to main), C1 (feature branch + PR), C2 (Mikado Branch Invariant). +Full rules are in the repo's `AGENTS.md`. See [[agent-change-process]] for the C0/C1/C2 change classification methodology — C0 (direct to main), C1 (feature branch + PR), C2 (Mikado Branch Invariant). ## Workflow Conventions diff --git a/docs/tutorials/exploring-the-docs.md b/docs/tutorials/exploring-the-docs.md index 83aec43..2fd5f66 100644 --- a/docs/tutorials/exploring-the-docs.md +++ b/docs/tutorials/exploring-the-docs.md @@ -30,15 +30,15 @@ The docs follow the [Diataxis](https://diataxis.fr/) framework: You probably want quick access to operational details: - [How-to](/how-to/) guides for common operations (deploy, troubleshoot, update ACLs) - [Reference](/reference/) has service URLs, commands, and config locations -- [[ai-assistance-guide]] explains how to work effectively with Claude +- [[ai-assistance-guide]] explains how to work effectively with AI agents - Run `mise run ai-docs` to prime AI context with key documentation -### For Claude/AI Agents +### For AI Agents Context for effective assistance: - Read [[ai-assistance-guide]] for operational conventions - [Reference](/reference/) has the technical specifics you'll need -- The repo's `CLAUDE.md` has critical rules (especially the kubectl context requirement) +- The repo's `AGENTS.md` has critical rules (especially the kubectl context requirement) ### For External Readers @@ -81,7 +81,7 @@ The `ai-docs` mise task concatenates key documentation files for AI context: mise run ai-docs ``` -This outputs key documentation files and a full tree listing of all docs, providing Claude with essential context for BlumeOps operations. +This outputs key documentation files and a full tree listing of all docs, providing an agent with essential context for BlumeOps operations. ## Related diff --git a/mise-tasks/mikado-branch-invariant-check b/mise-tasks/mikado-branch-invariant-check index 8760a39..ca9f79a 100755 --- a/mise-tasks/mikado-branch-invariant-check +++ b/mise-tasks/mikado-branch-invariant-check @@ -294,7 +294,7 @@ def main( console.print(f" [red]✗[/red] {error}") console.print() console.print( - "[dim]See: docs/how-to/agent-change-process.md " + "[dim]See: docs/explanation/agent-change-process.md " "§ The Mikado Branch Invariant[/dim]" ) raise SystemExit(1) From 51a878cddbc0e02a682d5dd678d80c2d4b57f063 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sat, 18 Apr 2026 20:25:19 -0700 Subject: [PATCH 306/430] C0: review navidrome reference doc --- docs/changelog.d/+review-navidrome-doc.doc.md | 1 + docs/reference/services/navidrome.md | 28 +++++++++++++++++-- 2 files changed, 26 insertions(+), 3 deletions(-) create mode 100644 docs/changelog.d/+review-navidrome-doc.doc.md diff --git a/docs/changelog.d/+review-navidrome-doc.doc.md b/docs/changelog.d/+review-navidrome-doc.doc.md new file mode 100644 index 0000000..fbe5e79 --- /dev/null +++ b/docs/changelog.d/+review-navidrome-doc.doc.md @@ -0,0 +1 @@ +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. diff --git a/docs/reference/services/navidrome.md b/docs/reference/services/navidrome.md index 5f93331..68a2a21 100644 --- a/docs/reference/services/navidrome.md +++ b/docs/reference/services/navidrome.md @@ -1,6 +1,7 @@ --- title: Navidrome -modified: 2026-02-21 +modified: 2026-04-18 +last-reviewed: 2026-04-18 tags: - service - media @@ -16,8 +17,15 @@ Self-hosted music streaming server. |----------|-------| | **URL** | https://dj.ops.eblu.me | | **Tailscale URL** | https://dj.tail8d86e.ts.net | +| **ArgoCD app** | `navidrome` | +| **Sync policy** | Manual | | **Namespace** | `navidrome` | | **Manifests** | `argocd/manifests/navidrome/` | +| **Image** | `registry.ops.eblu.me/blumeops/navidrome:v0.61.1-3ecd888` | +| **Tracked upstream version** | `v0.61.1` | + +Traffic reaches Navidrome through a Tailscale Ingress at `dj.tail8d86e.ts.net`, +with [[caddy]] proxying `dj.ops.eblu.me` to that tailnet endpoint. ## Storage @@ -32,16 +40,30 @@ The `/data` directory contains SQLite database, configuration, and cache. | Variable | Value | |----------|-------| -| `ND_SCANSCHEDULE` | 1h | +| `ND_SCANNER_SCHEDULE` | `@every 1h` | | `ND_LOGLEVEL` | info | | `ND_MUSICFOLDER` | /music | | `ND_DATAFOLDER` | /data | +## Runtime + +| Property | Value | +|----------|-------| +| **Replicas** | 1 | +| **Container port** | `4533` | +| **Requests** | `100m` CPU, `128Mi` memory | +| **Limits** | `500m` CPU, `512Mi` memory | +| **Security context** | Runs as uid/gid `1000`, `fsGroup: 1000`, `RuntimeDefault` seccomp | +| **Health checks** | Liveness/readiness probe on `GET /ping` | + ## Authentication Local accounts only. Authentik SSO integration was evaluated (Feb 2026) but not pursued — Navidrome lacks native OIDC support. The reverse proxy auth approach (`ND_EXTAUTH_*`) can pass a username header from Authentik, but cannot map Authentik groups to Navidrome admin status, making group-based admin delegation impossible. ## Related -- [[jellyfin]] - Video streaming +- [[routing]] - URL and exposure model +- [[caddy]] - Reverse proxy from `dj.ops.eblu.me` to the tailnet ingress - [[sifaka|Sifaka]] - Music storage +- [[jellyfin]] - Video streaming +- [[service-versions]] - Tracked upstream version inventory From 53a7374ac1cc79626bc96131d965c6f3fac2e59a Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 20 Apr 2026 07:26:53 -0700 Subject: [PATCH 307/430] C0: drop fix-ntfy-nix-version mikado card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Historical one-shot fix from the zot hardening chain — knowledge is self-evident in containers/ntfy/default.nix and container-version-check regex. Should have been removed at mikado finalization. Scrubbed the two wiki-link references in add-container-version-sync-check. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../zot/add-container-version-sync-check.md | 5 +-- docs/how-to/zot/fix-ntfy-nix-version.md | 41 ------------------- 2 files changed, 2 insertions(+), 44 deletions(-) delete mode 100644 docs/how-to/zot/fix-ntfy-nix-version.md diff --git a/docs/how-to/zot/add-container-version-sync-check.md b/docs/how-to/zot/add-container-version-sync-check.md index ff137fb..12d758b 100644 --- a/docs/how-to/zot/add-container-version-sync-check.md +++ b/docs/how-to/zot/add-container-version-sync-check.md @@ -52,7 +52,7 @@ Filled in `current-version` for all hybrid services: navidrome (v0.60.3), minifl ### ntfy nix version skew (resolved) -The check discovered that ntfy's Dockerfile pins v2.17.0 but nixpkgs has ntfy-sh 2.15.0. This was resolved in [[fix-ntfy-nix-version]] by building a custom nix derivation from the forge mirror. The version check now extracts the version from local nix files via regex, falling back to Dagger for unmodified nixpkgs packages. +The check discovered that ntfy's Dockerfile pinned a newer version than nixpkgs `ntfy-sh` provided. Resolved by replacing the nixpkgs reference in `containers/ntfy/default.nix` with a custom derivation built from the forge mirror. The version check now extracts the version from local nix files via regex, falling back to Dagger for unmodified nixpkgs packages. ## Key Files @@ -68,12 +68,11 @@ The check discovered that ntfy's Dockerfile pins v2.17.0 but nixpkgs has ntfy-sh - [x] Intentionally changing a Dockerfile ARG without updating `service-versions.yaml` fails the check - [x] `service-versions.yaml` has `current-version` populated for all hybrid services - [x] Nix-only container versions (authentik) checked via Dagger -- [x] ntfy nix version resolved via [[fix-ntfy-nix-version]] +- [x] ntfy nix version resolved via custom derivation in `containers/ntfy/default.nix` ## Related - [[pin-container-versions]] — Prereq: containers need parseable version ARGs first - [[add-dagger-nix-build]] — Prereq: nix version extraction -- [[fix-ntfy-nix-version]] — Prereq: ntfy nix derivation version skew - [[adopt-commit-based-container-tags]] — Parent: CI uses the same version extraction at build time - [[harden-zot-registry]] — Root goal diff --git a/docs/how-to/zot/fix-ntfy-nix-version.md b/docs/how-to/zot/fix-ntfy-nix-version.md deleted file mode 100644 index cd08efa..0000000 --- a/docs/how-to/zot/fix-ntfy-nix-version.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -title: Fix ntfy Nix Version -modified: 2026-02-20 -tags: - - how-to - - containers - - nix - - zot ---- - -# Fix ntfy Nix Version - -Override the nixpkgs ntfy-sh derivation to build v2.17.0 from the forge mirror, aligning the nix-built container with the Dockerfile version. - -## Context - -Discovered during [[add-container-version-sync-check]]: the ntfy container has both a Dockerfile and a `default.nix`. The Dockerfile builds v2.17.0 from `forge.ops.eblu.me/mirrors/ntfy.git`, but the nix derivation uses `pkgs.ntfy-sh` from nixpkgs which is pinned at 2.15.0. The version sync check currently excludes ntfy from nix version validation as a workaround. - -## What Was Done - -Replaced the nixpkgs `pkgs.ntfy-sh` reference in `containers/ntfy/default.nix` with a custom derivation that builds v2.17.0 from the forge mirror using `fetchgit`, `buildNpmPackage` (web UI), and `buildGoModule` (server). Docs are skipped (placeholder for `go:embed`, matching the Dockerfile approach). - -The `container-version-check` script was updated to extract versions from local nix files via regex (`version = "X.Y.Z"`) before falling back to the Dagger `nix-version` function for unmodified nixpkgs packages. This avoids the issue where `nix eval nixpkgs#ntfy-sh.version` returns the upstream 2.15.0 instead of our overridden 2.17.0. - -## Key Files - -| File | Change | -|------|--------| -| `containers/ntfy/default.nix` | Custom derivation building v2.17.0 from forge | -| `mise-tasks/container-version-check` | Regex-based local nix version extraction | - -## Verification - -- [x] `dagger call build-nix --src=. --container-name=ntfy` produces a working image -- [x] Version extractable from local `default.nix` via regex (2.17.0) -- [x] `mise run container-version-check --all-files` passes with ntfy included - -## Related - -- [[add-container-version-sync-check]] — Parent: needs ntfy in NIX_PACKAGE_MAP -- [[harden-zot-registry]] — Root goal From 353e2785c38ee56386a69d5e8ec636d8da2ea081 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 20 Apr 2026 07:55:25 -0700 Subject: [PATCH 308/430] docs: review zot oidc client card --- docs/how-to/zot/register-zot-oidc-client.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/how-to/zot/register-zot-oidc-client.md b/docs/how-to/zot/register-zot-oidc-client.md index b3696d0..744b81e 100644 --- a/docs/how-to/zot/register-zot-oidc-client.md +++ b/docs/how-to/zot/register-zot-oidc-client.md @@ -1,6 +1,7 @@ --- title: Register Zot OIDC Client modified: 2026-02-21 +last-reviewed: 2026-04-20 tags: - how-to - zot From 1425bf1f5c1486a8f739bc8c91bc99ee94a761d9 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 20 Apr 2026 09:03:54 -0700 Subject: [PATCH 309/430] Upgrade forgejo-runner to v12.8, adopt server.connections, and clean up docs (#338) ## Summary - consolidate forgejo-runner how-to docs into current cards - upgrade the k8s forgejo-runner deployment to the latest v12.8.x runner image - switch the k8s runner from first-boot register flow to declarative server.connections config - keep the runner image on the native Dagger build path and update the surrounding manifests/secrets ## Notes - PR opened early for C1 review - implementation and deployment verification will follow in subsequent commits Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/338 --- argocd/manifests/forgejo-runner/config.yaml | 13 ++- .../manifests/forgejo-runner/deployment.yaml | 22 +--- .../forgejo-runner/external-secret.yaml | 16 +-- .../forgejo-runner/kustomization.yaml | 2 +- containers/forgejo-runner/container.py | 4 +- ...o-runner-v12-8-server-connections.infra.md | 1 + .../forgejo-runner/configure-k8s-runner.md | 100 ++++++++++++++++++ .../review-runner-config-v12.md | 39 ------- .../forgejo-runner/upgrade-k8s-runner.md | 52 --------- ...t-v12.md => validate-forgejo-workflows.md} | 15 ++- docs/reference/services/forgejo-runner.md | 15 +-- docs/reference/services/forgejo.md | 1 + service-versions.yaml | 2 +- 13 files changed, 142 insertions(+), 140 deletions(-) create mode 100644 docs/changelog.d/forgejo-runner-v12-8-server-connections.infra.md create mode 100644 docs/how-to/forgejo-runner/configure-k8s-runner.md delete mode 100644 docs/how-to/forgejo-runner/review-runner-config-v12.md delete mode 100644 docs/how-to/forgejo-runner/upgrade-k8s-runner.md rename docs/how-to/forgejo-runner/{validate-workflows-against-v12.md => validate-forgejo-workflows.md} (61%) diff --git a/argocd/manifests/forgejo-runner/config.yaml b/argocd/manifests/forgejo-runner/config.yaml index 4894825..121d327 100644 --- a/argocd/manifests/forgejo-runner/config.yaml +++ b/argocd/manifests/forgejo-runner/config.yaml @@ -1,9 +1,8 @@ -# Reviewed against v12.7.3 defaults (2026-03-30) +# Reviewed against v12.8.2 defaults (2026-04-20) log: level: info runner: - file: /data/.runner capacity: 2 timeout: 3h shutdown_timeout: 3h @@ -13,7 +12,15 @@ runner: TZ: America/Los_Angeles container: - # Job execution image is set via RUNNER_LABELS in deployment.yaml network: "host" # Connect to DinD sidecar via TCP (not socket) docker_host: tcp://127.0.0.1:2375 + +server: + connections: + forgejo: + url: https://forge.ops.eblu.me/ + uuid: ${FORGEJO_RUNNER_UUID} + token: ${FORGEJO_RUNNER_TOKEN} + labels: + - k8s:docker://registry.ops.eblu.me/blumeops/runner-job-image:v0.20.1-24f7512 diff --git a/argocd/manifests/forgejo-runner/deployment.yaml b/argocd/manifests/forgejo-runner/deployment.yaml index c793895..7db7798 100644 --- a/argocd/manifests/forgejo-runner/deployment.yaml +++ b/argocd/manifests/forgejo-runner/deployment.yaml @@ -25,14 +25,6 @@ spec: env: - name: TZ value: America/Los_Angeles - - name: DOCKER_HOST - value: tcp://localhost:2375 - - name: FORGEJO_URL - value: "https://forge.ops.eblu.me" - - name: RUNNER_NAME - value: "k8s-runner" - - name: RUNNER_LABELS - value: "k8s:docker://registry.ops.eblu.me/blumeops/runner-job-image:v0.20.1-24f7512" command: - /bin/sh - -c @@ -44,19 +36,11 @@ spec: done echo "Docker daemon ready" - # Register if not already registered - if [ ! -f /data/.runner ]; then - echo "Registering runner..." - forgejo-runner register \ - --instance "$FORGEJO_URL" \ - --token "$RUNNER_TOKEN" \ - --name "$RUNNER_NAME" \ - --labels "$RUNNER_LABELS" \ - --no-interactive - fi + # Render config with credentials from ExternalSecret. + envsubst < /config/config.yaml > /tmp/config.yaml # Start daemon - exec forgejo-runner daemon --config /config/config.yaml + exec forgejo-runner daemon --config /tmp/config.yaml envFrom: - secretRef: name: forgejo-runner-env diff --git a/argocd/manifests/forgejo-runner/external-secret.yaml b/argocd/manifests/forgejo-runner/external-secret.yaml index fce28bb..ab7a691 100644 --- a/argocd/manifests/forgejo-runner/external-secret.yaml +++ b/argocd/manifests/forgejo-runner/external-secret.yaml @@ -1,11 +1,7 @@ -# ExternalSecret for Forgejo Runner token +# ExternalSecret for Forgejo Runner credentials # # 1Password item: "Forgejo Secrets" in blumeops vault -# Field: runner_reg (runner registration token) -# -# Non-secret env vars (FORGEJO_URL, RUNNER_NAME, RUNNER_LABELS) live in the -# deployment spec so that changes (e.g. image version bumps) trigger a rollout -# automatically. +# Fields: runner_k8s_uuid, runner_k8s_token # apiVersion: external-secrets.io/v1 kind: ExternalSecret @@ -21,7 +17,11 @@ spec: name: forgejo-runner-env creationPolicy: Owner data: - - secretKey: RUNNER_TOKEN + - secretKey: FORGEJO_RUNNER_UUID remoteRef: key: Forgejo Secrets - property: runner_reg + property: runner_k8s_uuid + - secretKey: FORGEJO_RUNNER_TOKEN + remoteRef: + key: Forgejo Secrets + property: runner_k8s_token diff --git a/argocd/manifests/forgejo-runner/kustomization.yaml b/argocd/manifests/forgejo-runner/kustomization.yaml index f8d9377..0df16e2 100644 --- a/argocd/manifests/forgejo-runner/kustomization.yaml +++ b/argocd/manifests/forgejo-runner/kustomization.yaml @@ -11,7 +11,7 @@ resources: images: - name: code.forgejo.org/forgejo/runner newName: registry.ops.eblu.me/blumeops/forgejo-runner - newTag: v12.7.3-352b95c + newTag: v12.8.2-bf16b8a - name: docker newTag: 27-dind diff --git a/containers/forgejo-runner/container.py b/containers/forgejo-runner/container.py index ffaca88..dfb2edf 100644 --- a/containers/forgejo-runner/container.py +++ b/containers/forgejo-runner/container.py @@ -13,7 +13,7 @@ from blumeops.containers import ( oci_labels, ) -VERSION = "12.7.3" +VERSION = "12.8.2" async def build(src: dagger.Directory) -> dagger.Container: @@ -34,7 +34,7 @@ async def build(src: dagger.Directory) -> dagger.Container: # Stage 2: Runtime runtime = alpine_runtime( - extra_apk=["git", "bash", "ca-certificates"], + extra_apk=["git", "bash", "ca-certificates", "gettext-envsubst"], uid=1000, gid=1000, username="runner", diff --git a/docs/changelog.d/forgejo-runner-v12-8-server-connections.infra.md b/docs/changelog.d/forgejo-runner-v12-8-server-connections.infra.md new file mode 100644 index 0000000..cc35684 --- /dev/null +++ b/docs/changelog.d/forgejo-runner-v12-8-server-connections.infra.md @@ -0,0 +1 @@ +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. diff --git a/docs/how-to/forgejo-runner/configure-k8s-runner.md b/docs/how-to/forgejo-runner/configure-k8s-runner.md new file mode 100644 index 0000000..3c095d0 --- /dev/null +++ b/docs/how-to/forgejo-runner/configure-k8s-runner.md @@ -0,0 +1,100 @@ +--- +title: Configure K8s Forgejo Runner +modified: 2026-04-20 +last-reviewed: 2026-04-20 +tags: + - how-to + - forgejo-runner + - ci +--- + +# Configure K8s Forgejo Runner + +Configure the Kubernetes Forgejo runner on [[indri]] using declarative `server.connections` config instead of first-boot `register`. + +## Why This Flow + +The older bootstrap pattern used `forgejo-runner register` on container start and persisted `/data/.runner` in an `emptyDir`. That works, but it depends on deprecated CLI flows and mutates runner identity at runtime. + +The preferred pattern is: + +- Create runner credentials once on the Forgejo host +- Store the runner UUID and token in 1Password +- Inject them into Kubernetes via [[external-secrets]] +- Render `server.connections` in `argocd/manifests/forgejo-runner/config.yaml` + +This keeps runner identity under secret management and makes pod restarts idempotent. + +## Create Runner Credentials + +On [[indri]], use Forgejo's local CLI instead of the web UI: + +```bash +ssh indri 'cd ~/code/3rd/forgejo && ./forgejo forgejo-cli actions register \ + --name k8s-runner \ + --scope instance \ + --secret "$(openssl rand -hex 32)"' +``` + +This returns a runner UUID. The generated secret becomes the runner token. Store both in 1Password under the "Forgejo Secrets" item as: + +- `runner_k8s_uuid` +- `runner_k8s_token` + +## Kubernetes Secret Wiring + +Expose those fields with `argocd/manifests/forgejo-runner/external-secret.yaml` and make them available to the runner container as environment variables. + +The deployment should not carry registration-only env vars like `FORGEJO_URL`, `RUNNER_NAME`, or `RUNNER_TOKEN`. + +## Runner Config + +Keep the runner configuration in `argocd/manifests/forgejo-runner/config.yaml`. The key change is adopting `server.connections`: + +```yaml +server: + connections: + forgejo: + url: https://forge.ops.eblu.me + uuid: ${FORGEJO_RUNNER_UUID} + token: ${FORGEJO_RUNNER_TOKEN} + labels: + - k8s:docker://registry.ops.eblu.me/blumeops/runner-job-image: +``` + +Other settings that still matter for this deployment: + +- `runner.capacity: 2` +- `runner.timeout: 3h` +- `runner.shutdown_timeout: 3h` +- `container.network: host` +- `container.docker_host: tcp://127.0.0.1:2375` + +We do not currently use cache configuration, extra volume mounts, or multiple Forgejo connections. + +## Deployment Shape + +The pod still runs two containers: + +1. `runner` — Forgejo runner daemon +2. `dind` — Docker-in-Docker sidecar + +The startup script only needs to wait for DinD and then launch the daemon. It should no longer call `forgejo-runner register` or depend on `/data/.runner`. + +## Upgrade Procedure + +When bumping the runner version: + +1. Update `VERSION` in `containers/forgejo-runner/container.py` +2. Review release notes for runner breaking changes +3. Confirm `config.yaml` is still compatible with the current runner defaults +4. Build and release the updated `forgejo-runner` image +5. Update `argocd/manifests/forgejo-runner/kustomization.yaml` to the new image tag +6. Validate workflows with [[validate-forgejo-workflows]] +7. Sync the `forgejo-runner` ArgoCD app and trigger a test workflow + +## Related + +- [[validate-forgejo-workflows]] — Validate workflow schema against the deployed runner line +- [[forgejo-runner]] — Service reference +- [[build-container-image]] — Build and release the runner image diff --git a/docs/how-to/forgejo-runner/review-runner-config-v12.md b/docs/how-to/forgejo-runner/review-runner-config-v12.md deleted file mode 100644 index af50090..0000000 --- a/docs/how-to/forgejo-runner/review-runner-config-v12.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -title: Review Runner Config for v12 -modified: 2026-02-27 -last-reviewed: 2026-02-27 -tags: - - how-to - - forgejo-runner - - ci ---- - -# Review Runner Config for v12 - -Compare the current runner ConfigMap against the v12.7.0 default config to identify new, changed, or deprecated keys. - -## Findings - -Compared `forgejo-runner generate-config` output from v6.3.1 and v12.7.0. Our config is minimal and remains valid for v12. - -### New sections in v12 (not adopted) - -- **`server.connections`** — multi-server polling. Not needed (single Forgejo instance). -- **`cache.secret_url`** — load cache secret from file URL. Not needed. -- **`runner.report_retry`** — retry config for log uploads. Defaults are fine. - -### Changed semantics - -- **`container.docker_host`** — v12 supports `unix://` and `ssh://` URLs. Our explicit `tcp://127.0.0.1:2375` still correct for DinD sidecar. -- **`cache`** section restructured with proxy/server split and better docs. We don't configure cache, so defaults apply. - -### Config update applied - -Added `shutdown_timeout: 3h` to allow graceful job completion on pod termination (v12 default, was missing from our v6 config). Added review date comment. - -`container.valid_volumes` and `container.options` left empty — our jobs use host networking and don't mount volumes. Can harden later if needed. - -## Related - -- [[upgrade-k8s-runner]] — Parent goal -- [[validate-workflows-against-v12]] — Sibling prerequisite diff --git a/docs/how-to/forgejo-runner/upgrade-k8s-runner.md b/docs/how-to/forgejo-runner/upgrade-k8s-runner.md deleted file mode 100644 index 3d285ac..0000000 --- a/docs/how-to/forgejo-runner/upgrade-k8s-runner.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -title: Upgrade K8s Forgejo Runner to v12 -modified: 2026-02-27 -last-reviewed: 2026-02-27 -tags: - - how-to - - forgejo-runner - - ci ---- - -# Upgrade K8s Forgejo Runner to v12 - -Upgrade the k8s forgejo-runner daemon from v6.3.1 to v12.7.0 (or latest v12.x at time of execution). - -## Background - -The k8s runner on indri (minikube) uses the upstream `code.forgejo.org/forgejo/runner` image, currently pinned to v6.3.1. The latest is v12.7.0. The runner is still in alpha and uses major version bumps for each breaking change, so v6→v12 crosses six major versions. The ringtail runner is already at ~v12.6.4 via nixpkgs and needs no work. - -Blast radius is low — if the upgrade breaks CI, revert the image tag in `argocd/manifests/forgejo-runner/deployment.yaml` and sync. - -## Breaking Changes Crossed - -| Version | Change | Impact | -|---------|--------|--------| -| v7.0 | CLI `--gitea-instance` → `--forgejo-instance`; `FORGEJO_*` env vars | Low — our registration doesn't use the old flag | -| v8.0 | Workflow schema validation; default image → `node:22-bookworm` | Workflows must pass validation | -| v9.0 | Stricter schema + actions validation; `forgejo-runner validate` added | Same — but now we have a tool | -| v10.0 | Cache isolation; skip v10.0.0 (regression) | Low | -| v11.0 | License MIT → GPLv3 | Non-technical | -| v12.0 | Git binary required; git worktrees for remote actions | Low — OCI image includes git | - -## Execution Steps - -Once prerequisites are met: - -1. Update `argocd/manifests/forgejo-runner/deployment.yaml`: - - Change runner image from `code.forgejo.org/forgejo/runner:6.3.1` to `code.forgejo.org/forgejo/runner:12.7.0` -2. Update `argocd/manifests/forgejo-runner/config.yaml` with any config changes from [[review-runner-config-v12]] -3. Push, sync ArgoCD: `argocd app sync forgejo-runner` -4. Verify runner registers and connects: check Forgejo admin → runners -5. Trigger a test workflow (manual dispatch of `build-container.yaml` or `branch-cleanup.yaml`) -6. Update `service-versions.yaml` to note the daemon version - -## Rollback - -Revert the image tag to `6.3.1` in `deployment.yaml`, push, and sync. - -## Related - -- [[forgejo]] — Forgejo service reference -- [[validate-workflows-against-v12]] — Pre-upgrade workflow validation -- [[review-runner-config-v12]] — Config format review diff --git a/docs/how-to/forgejo-runner/validate-workflows-against-v12.md b/docs/how-to/forgejo-runner/validate-forgejo-workflows.md similarity index 61% rename from docs/how-to/forgejo-runner/validate-workflows-against-v12.md rename to docs/how-to/forgejo-runner/validate-forgejo-workflows.md index 5f98502..ed21de7 100644 --- a/docs/how-to/forgejo-runner/validate-workflows-against-v12.md +++ b/docs/how-to/forgejo-runner/validate-forgejo-workflows.md @@ -1,20 +1,20 @@ --- -title: Validate Workflows Against v12 +title: Validate Forgejo Workflows modified: 2026-04-11 -last-reviewed: 2026-02-27 +last-reviewed: 2026-04-20 tags: - how-to - forgejo-runner - ci --- -# Validate Workflows Against v12 +# Validate Forgejo Workflows -Run `forgejo-runner validate` (available from v9.0+) against all workflow files to catch schema issues before upgrading the k8s runner daemon. +Run `forgejo-runner validate` against all workflow files to catch schema issues before upgrading the k8s runner daemon. ## Result -All 6 workflows pass v12.7.0 schema validation with no changes needed: +All current workflows pass the validation step with no changes needed: - `branch-cleanup.yaml` — OK - `build-blumeops.yaml` — OK @@ -27,7 +27,7 @@ All 6 workflows pass v12.7.0 schema validation with no changes needed: 1. `validate_workflows` function added to `src/blumeops/main.py` (formerly `.dagger/src/blumeops_ci/main.py`) - Uses `forgejo-runner validate --directory .` inside the upstream runner container - - `runner_version` parameter (default `12.7.0`) pins to deployed version + - `runner_version` parameter pins validation to the deployed runner line 2. `mise run validate-workflows` task wired to `dagger call validate-workflows` 3. Pre-commit hook triggers on `.forgejo/workflows/` changes @@ -41,5 +41,4 @@ dagger call validate-workflows --src=. ## Related -- [[upgrade-k8s-runner]] — Parent goal -- [[review-runner-config-v12]] — Sibling prerequisite +- [[configure-k8s-runner]] — Runner configuration and upgrade flow diff --git a/docs/reference/services/forgejo-runner.md b/docs/reference/services/forgejo-runner.md index d61f378..612f20f 100644 --- a/docs/reference/services/forgejo-runner.md +++ b/docs/reference/services/forgejo-runner.md @@ -1,7 +1,7 @@ --- title: Forgejo Runner -modified: 2026-03-30 -last-reviewed: 2026-03-30 +modified: 2026-04-20 +last-reviewed: 2026-04-20 tags: - service - ci-cd @@ -22,21 +22,21 @@ Forgejo Actions runner daemon for CI/CD job execution. Runs as a Kubernetes pod | **Capacity** | 2 concurrent jobs | | **Timeout** | 3h | | **Forgejo Instance** | https://forge.ops.eblu.me | -| **Image** | `code.forgejo.org/forgejo/runner` (see `argocd/manifests/forgejo-runner/kustomization.yaml` for current tag) | +| **Image** | `registry.ops.eblu.me/blumeops/forgejo-runner` (see `argocd/manifests/forgejo-runner/kustomization.yaml` for current tag) | | **DinD Sidecar** | `docker:27-dind` | ## Architecture The pod runs two containers: -1. **runner** - The Forgejo runner daemon. Registers with the forge on first start, then polls for jobs. Talks to DinD via `tcp://localhost:2375`. +1. **runner** - The Forgejo runner daemon. Loads a rendered `server.connections` config at startup, then polls for jobs. Talks to DinD via `tcp://localhost:2375`. 2. **dind** - Docker-in-Docker sidecar (privileged). Provides the Docker daemon for job container execution. Uses a registry mirror at `host.minikube.internal:5050` ([[zot]]). -Runner state (`/data/.runner`) is stored in an `emptyDir` volume, so re-registration happens on pod restart. The registration token comes from 1Password via [[external-secrets]]. +The runner daemon image is built from `containers/forgejo-runner/container.py`, not pulled directly from upstream. Credentials come from 1Password via [[external-secrets]], and the startup script renders the final config before launching the daemon. The `/data` volume remains for the runner home directory and job scratch space, not for `.runner` registration state. ## Job Execution Image -The actual container image used to run workflow steps is set via `RUNNER_LABELS` in the deployment, not in the runner config. This image is tracked separately as `runner-job-image` in `service-versions.yaml`. See [[build-container-image]] for how it's built. +The actual container image used to run workflow steps is declared in `server.connections.labels` in the runner config. This image is tracked separately as `runner-job-image` in `service-versions.yaml`. See [[build-container-image]] for how it's built. ## Network @@ -46,7 +46,8 @@ Jobs run with `network: "host"` to share the DinD network namespace. This gives | Secret | Source | Purpose | |--------|--------|---------| -| `RUNNER_TOKEN` | 1Password ("Forgejo Secrets" → `runner_reg`) | Runner registration with forge | +| `FORGEJO_RUNNER_UUID` | 1Password ("Forgejo Secrets" → `runner_k8s_uuid`) | Static runner identity for `server.connections` | +| `FORGEJO_RUNNER_TOKEN` | 1Password ("Forgejo Secrets" → `runner_k8s_token`) | Static runner credential for `server.connections` | ## Related diff --git a/docs/reference/services/forgejo.md b/docs/reference/services/forgejo.md index 11bb9a5..5b16b0e 100644 --- a/docs/reference/services/forgejo.md +++ b/docs/reference/services/forgejo.md @@ -85,6 +85,7 @@ Both container workflows trigger on the same tag pattern (`*-v[0-9]*`). Each che Server configuration secrets managed via 1Password → Ansible: - `lfs-jwt-secret`, `internal-token`, `oauth2-jwt-secret` - Forgejo server tokens - `runner_reg` - Runner registration token (also in k8s via [[external-secrets]]) +- `runner_k8s_uuid`, `runner_k8s_token` - Static credentials for the k8s runner `server.connections` flow ## Forgejo Actions Secrets diff --git a/service-versions.yaml b/service-versions.yaml index 761aa8d..8584322 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -236,7 +236,7 @@ services: - name: forgejo-runner type: argocd last-reviewed: 2026-03-30 - current-version: "12.7.3" + current-version: "12.8.2" upstream-source: https://code.forgejo.org/forgejo/runner/releases notes: >- Runner daemon version (code.forgejo.org/forgejo/runner). Job execution From 21177ff47f48de9ade604c794c124e91478ca3fc Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 20 Apr 2026 09:11:37 -0700 Subject: [PATCH 310/430] chore: update forgejo-runner image tag --- argocd/manifests/forgejo-runner/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/argocd/manifests/forgejo-runner/kustomization.yaml b/argocd/manifests/forgejo-runner/kustomization.yaml index 0df16e2..93cd33b 100644 --- a/argocd/manifests/forgejo-runner/kustomization.yaml +++ b/argocd/manifests/forgejo-runner/kustomization.yaml @@ -11,7 +11,7 @@ resources: images: - name: code.forgejo.org/forgejo/runner newName: registry.ops.eblu.me/blumeops/forgejo-runner - newTag: v12.8.2-bf16b8a + newTag: v12.8.2-1425bf1 - name: docker newTag: 27-dind From d6ad8e8e59a73faf9ab71e22ff6d9da728aaa82f Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 20 Apr 2026 09:15:35 -0700 Subject: [PATCH 311/430] chore: refresh forgejo-runner review date --- service-versions.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/service-versions.yaml b/service-versions.yaml index 8584322..75ad89d 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -235,7 +235,7 @@ services: - name: forgejo-runner type: argocd - last-reviewed: 2026-03-30 + last-reviewed: 2026-04-20 current-version: "12.8.2" upstream-source: https://code.forgejo.org/forgejo/runner/releases notes: >- From 54841dbf70f2f61000ff77f9685055db09d7597e Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 20 Apr 2026 10:09:16 -0700 Subject: [PATCH 312/430] Update ringtail flake inputs --- nixos/ringtail/flake.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/nixos/ringtail/flake.lock b/nixos/ringtail/flake.lock index 86c20af..90fdff1 100644 --- a/nixos/ringtail/flake.lock +++ b/nixos/ringtail/flake.lock @@ -7,11 +7,11 @@ ] }, "locked": { - "lastModified": 1773889306, - "narHash": "sha256-PAqwnsBSI9SVC2QugvQ3xeYCB0otOwCacB1ueQj2tgw=", + "lastModified": 1776613567, + "narHash": "sha256-gC9Cp5ibBmGD5awCA9z7xy6MW6iJufhazTYJOiGlCUI=", "owner": "nix-community", "repo": "disko", - "rev": "5ad85c82cc52264f4beddc934ba57f3789f28347", + "rev": "32f4236bfc141ae930b5ba2fb604f561fed5219d", "type": "github" }, "original": { @@ -43,11 +43,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1775811116, - "narHash": "sha256-t+HZK42pB6N+i5RGbuy7Xluez/VvWbembBdvzsc23Ss=", + "lastModified": 1776434932, + "narHash": "sha256-gyqXNMgk3sh+ogY5svd2eNLJ6oEwzbAeaoBrrxD0lKk=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "54170c54449ea4d6725efd30d719c5e505f1c10e", + "rev": "c7f47036d3df2add644c46d712d14262b7d86c0c", "type": "github" }, "original": { From 58fe4f0073dbec11242d451b2a946cbd54c34436 Mon Sep 17 00:00:00 2001 From: Erich Blume <725328+eblume@users.noreply.github.com> Date: Mon, 20 Apr 2026 15:48:15 -0700 Subject: [PATCH 313/430] ty --- mise.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mise.toml b/mise.toml index 12c92df..82821c6 100644 --- a/mise.toml +++ b/mise.toml @@ -9,4 +9,4 @@ prek = "0.3.4" pulumi = "3.215.0" dagger = "0.20.1" -ty = "0.0.29" +"pipx:ty" = "0.0.29" From db8fd946ae0de2288b3f7126c4106a936c8a5227 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Tue, 21 Apr 2026 08:12:33 -0700 Subject: [PATCH 314/430] Bump Dagger to 0.20.6 and migrate runner-job-image to Alpine container.py Bumps the Dagger engine/CLI from v0.20.1 to v0.20.6 (mise pin, dagger.json engineVersion, SDK regen) and rewrites the runner-job-image container as a native Dagger pipeline on Alpine 3.23 using the shared alpine_runtime helper, replacing the Debian-based Dockerfile. All Forgejo Actions in this repo use actions/checkout (a JS action), so musl is not a compatibility concern. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 2 + containers/runner-job-image/Dockerfile | 84 ------------------- containers/runner-job-image/container.py | 79 +++++++++++++++++ dagger.json | 5 +- ...dagger-0-20-6-runner-image-alpine.infra.md | 1 + docs/reference/tools/dagger.md | 2 +- mise.toml | 2 +- service-versions.yaml | 8 +- 8 files changed, 90 insertions(+), 93 deletions(-) delete mode 100644 containers/runner-job-image/Dockerfile create mode 100644 containers/runner-job-image/container.py create mode 100644 docs/changelog.d/dagger-0-20-6-runner-image-alpine.infra.md diff --git a/.gitignore b/.gitignore index acfafba..48c4b97 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,5 @@ __pycache__/ # OS .DS_Store +/**/__pycache__ +/.env diff --git a/containers/runner-job-image/Dockerfile b/containers/runner-job-image/Dockerfile deleted file mode 100644 index 0018c64..0000000 --- a/containers/runner-job-image/Dockerfile +++ /dev/null @@ -1,84 +0,0 @@ -# Forgejo Actions Job Execution Image -# -# This image is used as the job execution environment for Forgejo Actions. -# The host runner daemon creates containers from this image to run workflow steps. -# -# Build logic (container images, docs site) runs inside Dagger containers, -# so this image only needs: git, Docker CLI, Dagger CLI, ArgoCD CLI, uv, yq, and basic tools. -# -# Usage: Configure runner with label like: -# docker:docker://registry.ops.eblu.me/blumeops/runner-job-image:latest - -ARG CONTAINER_APP_VERSION=0.20.1 - -FROM debian:bookworm-slim - -ARG TARGETARCH -ARG CONTAINER_APP_VERSION -ARG DAGGER_VERSION=${CONTAINER_APP_VERSION} - -LABEL org.opencontainers.image.title="Runner Job Image" -LABEL org.opencontainers.image.description="Forgejo Actions job execution environment" -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" - -# Install base dependencies -RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates \ - curl \ - git \ - gnupg \ - jq \ - tzdata \ - && rm -rf /var/lib/apt/lists/* - -# Install Node.js (required by actions/checkout and other JavaScript Actions) -RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ - && apt-get install -y --no-install-recommends nodejs \ - && rm -rf /var/lib/apt/lists/* \ - && node --version - -# Install Docker CLI (Dagger shells out to `docker` to provision its engine) -RUN install -m 0755 -d /etc/apt/keyrings \ - && curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc \ - && chmod a+r /etc/apt/keyrings/docker.asc \ - && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian bookworm stable" > /etc/apt/sources.list.d/docker.list \ - && apt-get update \ - && apt-get install -y --no-install-recommends docker-ce-cli \ - && rm -rf /var/lib/apt/lists/* - -# Install uv (Python package runner for towncrier) -RUN curl -LsSf https://astral.sh/uv/install.sh | sh \ - && mv /root/.local/bin/uv /usr/local/bin/uv \ - && mv /root/.local/bin/uvx /usr/local/bin/uvx - -# Install argocd CLI (for syncing apps from workflows) -RUN ARCH="${TARGETARCH:-$(dpkg --print-architecture)}" \ - && curl -fsSL -o /usr/local/bin/argocd \ - "https://github.com/argoproj/argo-cd/releases/latest/download/argocd-linux-${ARCH}" \ - && chmod +x /usr/local/bin/argocd \ - && argocd version --client - -# Install Dagger CLI (for running Dagger CI pipelines) -RUN ARCH="${TARGETARCH:-$(dpkg --print-architecture)}" \ - && curl -fsSL -o /tmp/dagger.tar.gz \ - "https://dl.dagger.io/dagger/releases/${DAGGER_VERSION}/dagger_v${DAGGER_VERSION}_linux_${ARCH}.tar.gz" \ - && tar -xzf /tmp/dagger.tar.gz -C /usr/local/bin dagger \ - && rm /tmp/dagger.tar.gz \ - && dagger version - -# Install yq (for editing YAML files in workflows) -RUN ARCH="${TARGETARCH:-$(dpkg --print-architecture)}" \ - && curl -fsSL -o /usr/local/bin/yq \ - "https://github.com/mikefarah/yq/releases/latest/download/yq_linux_${ARCH}" \ - && chmod +x /usr/local/bin/yq \ - && yq --version - -# Install flyctl (for Fly.io cache purge after docs deploy) -RUN curl -L https://fly.io/install.sh | sh \ - && mv /root/.fly/bin/flyctl /usr/local/bin/fly \ - && rm -rf /root/.fly - -# Default to bash -CMD ["/bin/bash"] diff --git a/containers/runner-job-image/container.py b/containers/runner-job-image/container.py new file mode 100644 index 0000000..c5710ff --- /dev/null +++ b/containers/runner-job-image/container.py @@ -0,0 +1,79 @@ +"""Forgejo Actions job execution image — native Dagger build. + +The forgejo-runner daemon creates containers from this image to run +workflow steps. Contains the tools workflows reach for: git, Docker CLI, +Node.js (for JavaScript Actions), Dagger CLI, ArgoCD CLI, uv, yq, flyctl. + +VERSION tracks the Dagger CLI version, the primary build tool. +""" + +import dagger + +from blumeops.containers import alpine_runtime, oci_labels + +VERSION = "0.20.6" + + +async def build(src: dagger.Directory) -> dagger.Container: + # Map `uname -m` to the arch suffix each upstream uses. + arch_setup = ( + 'ARCH_UNAME="$(uname -m)"; ' + 'case "$ARCH_UNAME" in ' + " x86_64) ARCH=amd64 ;; " + " aarch64) ARCH=arm64 ;; " + ' *) echo "unsupported arch: $ARCH_UNAME" >&2; exit 1 ;; ' + "esac; " + ) + + runtime = alpine_runtime( + extra_apk=[ + "bash", + "ca-certificates", + "curl", + "docker-cli", + "git", + "gnupg", + "jq", + "nodejs", + "npm", + "tzdata", + ], + create_user=False, + ) + runtime = oci_labels( + runtime, + title="Runner Job Image", + description="Forgejo Actions job execution environment", + version=VERSION, + ) + + install_tools = ( + arch_setup + + "set -eux; " + # Dagger CLI (pinned) + + f'curl -fsSL -o /tmp/dagger.tar.gz "https://dl.dagger.io/dagger/releases/{VERSION}/dagger_v{VERSION}_linux_${{ARCH}}.tar.gz"; ' + + "tar -xzf /tmp/dagger.tar.gz -C /usr/local/bin dagger; " + + "rm /tmp/dagger.tar.gz; " + + "dagger version; " + # ArgoCD CLI (latest — matches cluster server version over time) + + 'curl -fsSL -o /usr/local/bin/argocd "https://github.com/argoproj/argo-cd/releases/latest/download/argocd-linux-${ARCH}"; ' + + "chmod +x /usr/local/bin/argocd; " + + "argocd version --client; " + # yq (latest) + + 'curl -fsSL -o /usr/local/bin/yq "https://github.com/mikefarah/yq/releases/latest/download/yq_linux_${ARCH}"; ' + + "chmod +x /usr/local/bin/yq; " + + "yq --version; " + # uv / uvx (latest; musl target auto-selected by installer) + + "curl -LsSf https://astral.sh/uv/install.sh " + + '| env UV_INSTALL_DIR=/usr/local/bin UV_UNMANAGED_INSTALL="/usr/local/bin" sh; ' + + "uv --version; " + # flyctl (latest) + + "curl -L https://fly.io/install.sh | sh; " + + "mv /root/.fly/bin/flyctl /usr/local/bin/fly; " + + "rm -rf /root/.fly; " + + "fly version" + ) + + return runtime.with_exec(["sh", "-c", install_tools]).with_default_args( + args=["/bin/bash"] + ) diff --git a/dagger.json b/dagger.json index c982487..3309378 100644 --- a/dagger.json +++ b/dagger.json @@ -1,8 +1,7 @@ { "name": "blumeops", - "engineVersion": "v0.20.1", + "engineVersion": "v0.20.6", "sdk": { "source": "python" - }, - "source": "." + } } diff --git a/docs/changelog.d/dagger-0-20-6-runner-image-alpine.infra.md b/docs/changelog.d/dagger-0-20-6-runner-image-alpine.infra.md new file mode 100644 index 0000000..35f77c2 --- /dev/null +++ b/docs/changelog.d/dagger-0-20-6-runner-image-alpine.infra.md @@ -0,0 +1 @@ +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. diff --git a/docs/reference/tools/dagger.md b/docs/reference/tools/dagger.md index 379c10f..89be50c 100644 --- a/docs/reference/tools/dagger.md +++ b/docs/reference/tools/dagger.md @@ -16,7 +16,7 @@ Build engine for BlumeOps CI/CD pipelines. Replaces shell-based build scripts wi | Property | Value | |----------|-------| | **Module** | `blumeops` | -| **Engine Version** | v0.20.1 | +| **Engine Version** | v0.20.6 | | **SDK** | Python | | **Source** | `src/blumeops/main.py` | | **Config** | `dagger.json` (source: `.`) | diff --git a/mise.toml b/mise.toml index 82821c6..286c4e0 100644 --- a/mise.toml +++ b/mise.toml @@ -8,5 +8,5 @@ "pipx:borgmatic" = "2.1.4" prek = "0.3.4" pulumi = "3.215.0" -dagger = "0.20.1" +dagger = "0.20.6" "pipx:ty" = "0.0.29" diff --git a/service-versions.yaml b/service-versions.yaml index 75ad89d..f5811b5 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -244,8 +244,8 @@ services: - name: runner-job-image type: argocd - last-reviewed: 2026-03-06 - current-version: "0.20.1" + last-reviewed: 2026-04-21 + current-version: "0.20.6" upstream-source: https://github.com/dagger/dagger/releases notes: >- Forgejo Actions job execution image. CONTAINER_APP_VERSION tracks the @@ -396,8 +396,8 @@ services: - name: dagger type: mise - last-reviewed: 2026-04-12 - current-version: "0.20.1" + last-reviewed: 2026-04-21 + current-version: "0.20.6" upstream-source: https://github.com/dagger/dagger/releases notes: Dagger CI/CD engine; pinned in mise.toml From 50f8c2a33f53bcea167a185412bdf54a49b36be9 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Tue, 21 Apr 2026 08:18:25 -0700 Subject: [PATCH 315/430] Roll k8s runner to runner-job-image v0.20.6-9b6be09 Points the k8s Forgejo runner label at the locally-bootstrapped runner-job-image built from the Alpine container.py on this branch. Once merged, CI will rebuild the same image from the same SHA. Co-Authored-By: Claude Opus 4.7 (1M context) --- argocd/manifests/forgejo-runner/config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/argocd/manifests/forgejo-runner/config.yaml b/argocd/manifests/forgejo-runner/config.yaml index 121d327..7c5196e 100644 --- a/argocd/manifests/forgejo-runner/config.yaml +++ b/argocd/manifests/forgejo-runner/config.yaml @@ -23,4 +23,4 @@ server: uuid: ${FORGEJO_RUNNER_UUID} token: ${FORGEJO_RUNNER_TOKEN} labels: - - k8s:docker://registry.ops.eblu.me/blumeops/runner-job-image:v0.20.1-24f7512 + - k8s:docker://registry.ops.eblu.me/blumeops/runner-job-image:v0.20.6-9b6be09 From fb32cc07c4b3fe2e64d0387a26765ba9c7b25397 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Tue, 21 Apr 2026 08:38:33 -0700 Subject: [PATCH 316/430] chore: repoint runner-job-image tag at CI-built v0.20.6-50f8c2a Swaps the k8s runner label from the local bootstrap tag (v0.20.6-9b6be09) to the equivalent image rebuilt by CI from main. Functionally identical; closes the bootstrap loop. Co-Authored-By: Claude Opus 4.7 (1M context) --- argocd/manifests/forgejo-runner/config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/argocd/manifests/forgejo-runner/config.yaml b/argocd/manifests/forgejo-runner/config.yaml index 7c5196e..01ede7c 100644 --- a/argocd/manifests/forgejo-runner/config.yaml +++ b/argocd/manifests/forgejo-runner/config.yaml @@ -23,4 +23,4 @@ server: uuid: ${FORGEJO_RUNNER_UUID} token: ${FORGEJO_RUNNER_TOKEN} labels: - - k8s:docker://registry.ops.eblu.me/blumeops/runner-job-image:v0.20.6-9b6be09 + - k8s:docker://registry.ops.eblu.me/blumeops/runner-job-image:v0.20.6-50f8c2a From 30f39ae0507e46bc4caa0c777393452b25eef052 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Tue, 21 Apr 2026 08:53:41 -0700 Subject: [PATCH 317/430] Review contributing tutorial: add last-reviewed, .ai.md fragment type, prek provenance Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/changelog.d/+review-contributing-doc.doc.md | 1 + docs/tutorials/contributing.md | 10 ++++++---- 2 files changed, 7 insertions(+), 4 deletions(-) create mode 100644 docs/changelog.d/+review-contributing-doc.doc.md diff --git a/docs/changelog.d/+review-contributing-doc.doc.md b/docs/changelog.d/+review-contributing-doc.doc.md new file mode 100644 index 0000000..c394a01 --- /dev/null +++ b/docs/changelog.d/+review-contributing-doc.doc.md @@ -0,0 +1 @@ +Refresh the contributing tutorial: add `last-reviewed`, include the `.ai.md` changelog fragment type, and clarify that `prek` is pinned via `mise`. diff --git a/docs/tutorials/contributing.md b/docs/tutorials/contributing.md index cddafea..a2a7069 100644 --- a/docs/tutorials/contributing.md +++ b/docs/tutorials/contributing.md @@ -1,6 +1,7 @@ --- title: Contributing -modified: 2026-02-07 +modified: 2026-04-21 +last-reviewed: 2026-04-21 tags: - tutorials - contributing @@ -37,14 +38,14 @@ brew bundle # installs tea, argocd, mise, etc. ### Using Mise (Optional) -Mise manages language toolchains and runs tasks: +Mise manages language toolchains, runs tasks, and pins tools like `prek`: ```bash -mise install # installs Python, Node.js, etc. from mise.toml +mise install # installs Python, Node.js, prek, etc. from mise.toml ``` ### Git Hooks (prek) -Git hooks validate changes on `git commit`: +Git hooks validate changes on `git commit` (prek is pinned in `mise.toml`): ```bash prek install prek run --all-files # verify setup @@ -104,6 +105,7 @@ Fragment types (file suffix): - `.bugfix.md` - Bug fixes - `.infra.md` - Infrastructure changes - `.doc.md` - Documentation +- `.ai.md` - AI-assisted changes - `.misc.md` - Other ### 4. Test Your Changes From fb4bf5a7a350b677856f6f1a5ebc16c78190e71d Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Tue, 21 Apr 2026 09:28:02 -0700 Subject: [PATCH 318/430] Add frigate-notify nix container build (#339) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Mirrors `github.com/0x2142/frigate-notify` at `v0.5.4` to `forge.ops.eblu.me/mirrors/frigate-notify`. - Adds `containers/frigate-notify/default.nix` — `buildGoModule` + `dockerTools.buildLayeredImage`, following the `ntfy` pattern. - Uses `-tags goolm` to avoid the libolm CGO dependency (matrix notifier is imported unconditionally in the upstream but we only use ntfy alerts). - Runs as nonroot (UID 65534), exposes port 8000, bundles `cacert`/`tzdata`. ## Why Move `ghcr.io/0x2142/frigate-notify:v0.5.4` (ringtail-deployed) under local control. Aligns with the [[indri → ringtail migration plan]] and the `default.nix` convention for ringtail-targeted containers documented in [[build-container-image]]. ## Verification - `dagger call build-nix --src=. --container-name=frigate-notify export --path=./out.tar.gz` produces a valid 20MB docker archive (10 layers) with `blumeops/frigate-notify` tag locally. - Hashes pinned for `fetchgit` (src) and `vendorHash` (go modules). ## Follow-up (post-merge) 1. `mise run container-build-and-release frigate-notify` — release from main SHA. 2. C0 follow-up: update `argocd/manifests/frigate/kustomization.yaml` image ref to `registry.ops.eblu.me/blumeops/frigate-notify:v0.5.4--nix`. 3. ArgoCD auto-syncs the deployment. ## Test plan - [ ] `dagger call build-nix` succeeds from a clean checkout. - [ ] `mise run container-build-and-release frigate-notify --dry-run` looks correct. - [ ] After release + kustomization swap: frigate-notify pod comes up healthy on ringtail; ntfy alerts still fire on Frigate events. Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/339 --- containers/frigate-notify/default.nix | 57 +++++++++++++++++++ .../+frigate-notify-local.infra.md | 1 + 2 files changed, 58 insertions(+) create mode 100644 containers/frigate-notify/default.nix create mode 100644 docs/changelog.d/+frigate-notify-local.infra.md diff --git a/containers/frigate-notify/default.nix b/containers/frigate-notify/default.nix new file mode 100644 index 0000000..1ddbe4e --- /dev/null +++ b/containers/frigate-notify/default.nix @@ -0,0 +1,57 @@ +# Nix-built frigate-notify — polls Frigate webapi and pushes alerts to ntfy. +{ pkgs ? import { } }: + +let + version = "0.5.4"; + + src = pkgs.fetchgit { + url = "https://forge.ops.eblu.me/mirrors/frigate-notify.git"; + rev = "v${version}"; + hash = "sha256-c/QOSQNNJ+ElMDm45lBOsru/ujBhCWethiRefj3hBOk="; + }; + + frigate-notify = pkgs.buildGoModule { + inherit src version; + pname = "frigate-notify"; + + vendorHash = "sha256-Ho9oaK01wJDPf3ufV2klV1dG4qFNVNJkWmWvEgAy10s="; + + doCheck = false; + subPackages = [ "." ]; + + # `goolm` swaps the matrix crypto backend from libolm (CGO) to pure-Go olm, + # avoiding the libolm.h dependency. Our deployment doesn't use matrix, but + # the package is imported unconditionally. + tags = [ "goolm" ]; + + ldflags = [ "-s" "-w" ]; + + meta = with pkgs.lib; { + description = "Bridge between Frigate NVR events and notification services"; + homepage = "https://github.com/0x2142/frigate-notify"; + license = licenses.mit; + mainProgram = "frigate-notify"; + }; + }; +in + +pkgs.dockerTools.buildLayeredImage { + name = "blumeops/frigate-notify"; + contents = [ + frigate-notify + pkgs.cacert + pkgs.tzdata + ]; + + config = { + Entrypoint = [ "${frigate-notify}/bin/frigate-notify" ]; + Env = [ + "SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" + "TZDIR=${pkgs.tzdata}/share/zoneinfo" + ]; + ExposedPorts = { + "8000/tcp" = { }; + }; + User = "65534"; + }; +} diff --git a/docs/changelog.d/+frigate-notify-local.infra.md b/docs/changelog.d/+frigate-notify-local.infra.md new file mode 100644 index 0000000..120f915 --- /dev/null +++ b/docs/changelog.d/+frigate-notify-local.infra.md @@ -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`. From c88b6d773cc9513952a94bf547365f44fab5cf78 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Tue, 21 Apr 2026 09:31:29 -0700 Subject: [PATCH 319/430] C0: point frigate-notify at local registry tag v0.5.4-fb4bf5a-nix Built from main in run #516 after #339 merged. Follows the navidrome kustomization convention (deployment image = local ref + :kustomized, kustomization override = newTag only). Co-Authored-By: Claude Opus 4.7 (1M context) --- argocd/manifests/frigate/deployment-notify.yaml | 2 +- argocd/manifests/frigate/kustomization.yaml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/argocd/manifests/frigate/deployment-notify.yaml b/argocd/manifests/frigate/deployment-notify.yaml index 740d104..91f4237 100644 --- a/argocd/manifests/frigate/deployment-notify.yaml +++ b/argocd/manifests/frigate/deployment-notify.yaml @@ -16,7 +16,7 @@ spec: spec: containers: - name: frigate-notify - image: ghcr.io/0x2142/frigate-notify:kustomized + image: registry.ops.eblu.me/blumeops/frigate-notify:kustomized env: - name: TZ value: America/Los_Angeles diff --git a/argocd/manifests/frigate/kustomization.yaml b/argocd/manifests/frigate/kustomization.yaml index b424bd0..3a679c6 100644 --- a/argocd/manifests/frigate/kustomization.yaml +++ b/argocd/manifests/frigate/kustomization.yaml @@ -17,8 +17,8 @@ images: newTag: "1.37" - name: ghcr.io/blakeblackshear/frigate newTag: 0.17.1-tensorrt - - name: ghcr.io/0x2142/frigate-notify - newTag: v0.5.4 + - name: registry.ops.eblu.me/blumeops/frigate-notify + newTag: v0.5.4-fb4bf5a-nix configMapGenerator: - name: frigate-config From e92805409e05961919fb71ee32605e67a86eb21c Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Tue, 21 Apr 2026 09:43:00 -0700 Subject: [PATCH 320/430] fix(frigate-notify): set WorkingDir=/app and create writable /app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The upstream binary expects CWD=/app (relative config.yml lookup, lumberjack logfile at ./log/app.log). Without this, the pod crashed on startup — the ConfigMap-mounted /app/config.yml wasn't found and zerolog spammed "mkdir log: permission denied" as it tried to create ./log at / as nonroot. Creates /app as 1777 (tmp-style) so nonroot can write logs; WorkingDir set to /app so the default config path resolves correctly. Co-Authored-By: Claude Opus 4.7 (1M context) --- containers/frigate-notify/default.nix | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/containers/frigate-notify/default.nix b/containers/frigate-notify/default.nix index 1ddbe4e..701b194 100644 --- a/containers/frigate-notify/default.nix +++ b/containers/frigate-notify/default.nix @@ -43,8 +43,17 @@ pkgs.dockerTools.buildLayeredImage { pkgs.tzdata ]; + # Upstream Dockerfile expects WORKDIR=/app (config at ./config.yml, logfile at + # ./log/app.log via lumberjack). Create /app world-writable so nonroot can + # write logs; the config is mounted in from a ConfigMap. + extraCommands = '' + mkdir -p app + chmod 1777 app + ''; + config = { Entrypoint = [ "${frigate-notify}/bin/frigate-notify" ]; + WorkingDir = "/app"; Env = [ "SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" "TZDIR=${pkgs.tzdata}/share/zoneinfo" From a9ef02a602a0483a0cf061d4e9a01c9765bf9044 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Tue, 21 Apr 2026 09:44:24 -0700 Subject: [PATCH 321/430] C0: bump frigate-notify to v0.5.4-e928054-nix (workdir fix) Co-Authored-By: Claude Opus 4.7 (1M context) --- argocd/manifests/frigate/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/argocd/manifests/frigate/kustomization.yaml b/argocd/manifests/frigate/kustomization.yaml index 3a679c6..a61c758 100644 --- a/argocd/manifests/frigate/kustomization.yaml +++ b/argocd/manifests/frigate/kustomization.yaml @@ -18,7 +18,7 @@ images: - name: ghcr.io/blakeblackshear/frigate newTag: 0.17.1-tensorrt - name: registry.ops.eblu.me/blumeops/frigate-notify - newTag: v0.5.4-fb4bf5a-nix + newTag: v0.5.4-e928054-nix configMapGenerator: - name: frigate-config From 0ceafc374db22f21d35a72288d76a55464fd9b92 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Tue, 21 Apr 2026 09:59:48 -0700 Subject: [PATCH 322/430] C0: review operator-managed-pods CC (2026-04-21) Tailscale operator still defaults to privileged proxy pods with no seccomp profile (issue #7359 open upstream). Control remains valid. Added note about ProxyClass + device plugin remediation path. Co-Authored-By: Claude Opus 4.7 (1M context) --- compensating-controls.yaml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/compensating-controls.yaml b/compensating-controls.yaml index b441341..67bbf75 100644 --- a/compensating-controls.yaml +++ b/compensating-controls.yaml @@ -77,11 +77,16 @@ controls: operator, not user manifests. Operator is tracked in service-versions.yaml and regularly updated. created: 2026-03-30 - last-reviewed: 2026-03-30 + last-reviewed: 2026-04-21 notes: >- Verify operator version is current via 'mise run service-review'. Check Tailscale changelog for security fixes. If operator adds - seccomp support, remove these mutes. + seccomp support, remove these mutes. As of 2026-04-21: still no + default seccomp on operator-generated pods (upstream issue #7359 + open). A ProxyClass + generic device plugin can downgrade proxies + from privileged to NET_ADMIN+NET_RAW and set seccompProfile — + potential future remediation to remove the seccomp mute without + waiting for upstream defaults. - id: ephemeral-privileged-jobs description: >- From e6a6a6042e6765e5c1aba62c3ad4c315da64608c Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Tue, 21 Apr 2026 10:12:00 -0700 Subject: [PATCH 323/430] C0: suggest mise run runner-logs in container-build-and-release After dispatching, poll the Forgejo API for the run matching our head_sha and print `mise run runner-logs ` so the suggested monitor command is one copy-paste away. Falls back to the bare command if the poll times out. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...ontainer-build-suggest-runner-logs.misc.md | 1 + mise-tasks/container-build-and-release | 61 ++++++++++++++++++- 2 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 docs/changelog.d/+container-build-suggest-runner-logs.misc.md diff --git a/docs/changelog.d/+container-build-suggest-runner-logs.misc.md b/docs/changelog.d/+container-build-suggest-runner-logs.misc.md new file mode 100644 index 0000000..d10ea51 --- /dev/null +++ b/docs/changelog.d/+container-build-suggest-runner-logs.misc.md @@ -0,0 +1 @@ +`container-build-and-release` now prints the specific `mise run runner-logs ` command after dispatching, polling the Forgejo API to resolve the run number for the commit it just triggered. diff --git a/mise-tasks/container-build-and-release b/mise-tasks/container-build-and-release index 2e1be27..afa970e 100755 --- a/mise-tasks/container-build-and-release +++ b/mise-tasks/container-build-and-release @@ -15,6 +15,7 @@ Dockerfile and Nix builds in a single workflow. import subprocess import sys +import time from pathlib import Path import httpx @@ -48,6 +49,52 @@ def get_forge_token() -> str: return result.stdout.strip() +def max_run_number(headers: dict[str, str]) -> int: + """Return the highest current run_number for WORKFLOW, or 0 if none.""" + resp = httpx.get( + f"{FORGE_API}/repos/{REPO}/actions/tasks", + params={"limit": 50}, + headers=headers, + timeout=15, + ) + if resp.status_code != 200: + return 0 + runs = [ + t["run_number"] + for t in resp.json().get("workflow_runs", []) + if t.get("workflow_id") == WORKFLOW + ] + return max(runs, default=0) + + +def find_dispatched_run( + ref: str, floor: int, headers: dict[str, str], timeout_s: int = 20 +) -> int | None: + """Poll the tasks endpoint for the run triggered by our dispatch. + + Matches by head_sha + workflow + run_number > floor so we don't pick up + an older build of the same commit or a concurrent unrelated dispatch. + """ + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + resp = httpx.get( + f"{FORGE_API}/repos/{REPO}/actions/tasks", + params={"limit": 20}, + headers=headers, + timeout=15, + ) + if resp.status_code == 200: + for task in resp.json().get("workflow_runs", []): + if ( + task.get("head_sha") == ref + and task.get("workflow_id") == WORKFLOW + and task.get("run_number", 0) > floor + ): + return task["run_number"] + time.sleep(1) + return None + + def list_containers() -> None: typer.echo("Available containers:") for d in sorted(Path("containers").iterdir()): @@ -112,7 +159,8 @@ def main( if dry_run: typer.echo(f"[dry-run] Would dispatch {WORKFLOW}") typer.echo() - typer.echo(f"Monitor builds at: {FORGE_ACTIONS}") + typer.echo("Monitor builds with: mise run runner-logs") + typer.echo(f" or visit: {FORGE_ACTIONS}") return token = get_forge_token() @@ -132,6 +180,10 @@ def main( typer.echo("Push your changes before triggering a build: git push origin main") raise typer.Exit(1) + # Snapshot the highest existing run_number so we can identify the one + # our dispatch creates. + floor = max_run_number(headers) + url = f"{FORGE_API}/repos/{REPO}/actions/workflows/{WORKFLOW}/dispatches" payload = { "ref": "main", @@ -148,7 +200,12 @@ def main( raise typer.Exit(1) typer.echo() - typer.echo(f"Monitor builds at: {FORGE_ACTIONS}") + run_number = find_dispatched_run(ref, floor, headers) + if run_number is not None: + typer.echo(f"Monitor builds with: mise run runner-logs {run_number}") + else: + typer.echo("Monitor builds with: mise run runner-logs") + typer.echo(f" or visit: {FORGE_ACTIONS}") if __name__ == "__main__": From 225b0e700870725ef08c453cd879e5da06c69327 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Tue, 21 Apr 2026 10:18:08 -0700 Subject: [PATCH 324/430] C0: allow argocd CLI --sso localhost callback Adds http://localhost:8085/auth/callback to the ArgoCD OAuth2 provider's redirect_uris so `argocd login --sso` works. Loopback redirect is the RFC 8252 pattern for native CLI apps; PKCE (already enabled) covers the code-interception risk. Co-Authored-By: Claude Opus 4.7 (1M context) --- argocd/manifests/authentik/configmap-blueprint.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/argocd/manifests/authentik/configmap-blueprint.yaml b/argocd/manifests/authentik/configmap-blueprint.yaml index 27910ef..aa6a07e 100644 --- a/argocd/manifests/authentik/configmap-blueprint.yaml +++ b/argocd/manifests/authentik/configmap-blueprint.yaml @@ -270,6 +270,8 @@ data: url: https://argocd.ops.eblu.me/auth/callback - matching_mode: strict url: https://argocd.tail8d86e.ts.net/auth/callback + - matching_mode: strict + url: http://localhost:8085/auth/callback signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]] property_mappings: - !Find [authentik_providers_oauth2.scopemapping, [scope_name, openid]] From 0e62ad55961bca8b14a33d766cd4cd0f197c4a9c Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Tue, 21 Apr 2026 10:34:39 -0700 Subject: [PATCH 325/430] =?UTF-8?q?C0:=20argocd=20OIDC=20=E2=80=94=20switc?= =?UTF-8?q?h=20to=20public=20client=20for=20CLI=20SSO?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changes argocd's Authentik OAuth2 client from confidential to public and drops the clientSecret from argocd-cm. Public + PKCE works for both the web UI (argocd-server backend) and the argocd CLI (`argocd login --sso`) without a shared secret, matching OAuth 2.1 guidance. Confidential → public was needed because the CLI can't hold a client secret; Authentik's per-app issuer model made the alternative ("cliClientID" pattern with separate public client) awkward since it requires a shared issuer across apps which Authentik doesn't serve. Follow-up: deadcode AUTHENTIK_ARGOCD_CLIENT_SECRET env wiring and the argocd-oidc-authentik ExternalSecret once verified. Co-Authored-By: Claude Opus 4.7 (1M context) --- argocd/manifests/argocd/argocd-cm-patch.yaml | 1 - argocd/manifests/authentik/configmap-blueprint.yaml | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/argocd/manifests/argocd/argocd-cm-patch.yaml b/argocd/manifests/argocd/argocd-cm-patch.yaml index cb7e27f..54e4ede 100644 --- a/argocd/manifests/argocd/argocd-cm-patch.yaml +++ b/argocd/manifests/argocd/argocd-cm-patch.yaml @@ -16,7 +16,6 @@ data: name: Authentik issuer: https://authentik.ops.eblu.me/application/o/argocd/ clientID: argocd - clientSecret: $argocd-oidc-authentik:client-secret requestedScopes: - openid - profile diff --git a/argocd/manifests/authentik/configmap-blueprint.yaml b/argocd/manifests/authentik/configmap-blueprint.yaml index aa6a07e..fcbb99b 100644 --- a/argocd/manifests/authentik/configmap-blueprint.yaml +++ b/argocd/manifests/authentik/configmap-blueprint.yaml @@ -262,9 +262,8 @@ data: name: ArgoCD 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: confidential + client_type: public client_id: argocd - client_secret: !Env AUTHENTIK_ARGOCD_CLIENT_SECRET redirect_uris: - matching_mode: strict url: https://argocd.ops.eblu.me/auth/callback From 86317315edbb17a4013b1de9a8bf77325db7658f Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Tue, 21 Apr 2026 10:38:26 -0700 Subject: [PATCH 326/430] C0: remove argocd OIDC client_secret wiring Now that argocd's Authentik OAuth2 client is public (PKCE-only), the client_secret plumbing is dead code: - delete argocd-oidc-authentik ExternalSecret and drop it from kustomization - remove AUTHENTIK_ARGOCD_CLIENT_SECRET env from authentik-worker - remove argocd-client-secret mapping from authentik-config ExternalSecret The argocd-client-secret field in the 1Password "Authentik (blumeops)" item is now unreferenced and can be deleted there. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../external-secret-oidc-authentik.yaml | 31 ------------------- argocd/manifests/argocd/kustomization.yaml | 1 - .../authentik/deployment-worker.yaml | 5 --- .../manifests/authentik/external-secret.yaml | 4 --- 4 files changed, 41 deletions(-) delete mode 100644 argocd/manifests/argocd/external-secret-oidc-authentik.yaml diff --git a/argocd/manifests/argocd/external-secret-oidc-authentik.yaml b/argocd/manifests/argocd/external-secret-oidc-authentik.yaml deleted file mode 100644 index 475a713..0000000 --- a/argocd/manifests/argocd/external-secret-oidc-authentik.yaml +++ /dev/null @@ -1,31 +0,0 @@ -# ExternalSecret for ArgoCD OIDC client secret (Authentik) -# -# Referenced from argocd-cm as $argocd-oidc-authentik:client-secret -# Must have app.kubernetes.io/part-of: argocd label for ArgoCD to read it -# ---- -apiVersion: external-secrets.io/v1 -kind: ExternalSecret -metadata: - name: argocd-oidc-authentik - namespace: argocd -spec: - refreshInterval: 1h - secretStoreRef: - kind: ClusterSecretStore - name: onepassword-blumeops - target: - name: argocd-oidc-authentik - creationPolicy: Owner - template: - metadata: - labels: - app.kubernetes.io/part-of: argocd - data: - - secretKey: client-secret - remoteRef: - conversionStrategy: Default - decodingStrategy: None - key: "Authentik (blumeops)" - metadataPolicy: None - property: argocd-client-secret diff --git a/argocd/manifests/argocd/kustomization.yaml b/argocd/manifests/argocd/kustomization.yaml index 9bdac10..6deb7ec 100644 --- a/argocd/manifests/argocd/kustomization.yaml +++ b/argocd/manifests/argocd/kustomization.yaml @@ -9,7 +9,6 @@ resources: - https://raw.githubusercontent.com/argoproj/argo-cd/998fb59dc355653c0657908a6ea2f87136e022d1/manifests/install.yaml - ingress-tailscale.yaml - external-secret-repo-forge.yaml - - external-secret-oidc-authentik.yaml patches: - path: argocd-cmd-params-cm.yaml diff --git a/argocd/manifests/authentik/deployment-worker.yaml b/argocd/manifests/authentik/deployment-worker.yaml index b81ec32..053fa3d 100644 --- a/argocd/manifests/authentik/deployment-worker.yaml +++ b/argocd/manifests/authentik/deployment-worker.yaml @@ -75,11 +75,6 @@ spec: secretKeyRef: name: authentik-config key: jellyfin-client-secret - - name: AUTHENTIK_ARGOCD_CLIENT_SECRET - valueFrom: - secretKeyRef: - name: authentik-config - key: argocd-client-secret - name: AUTHENTIK_MEALIE_CLIENT_SECRET valueFrom: secretKeyRef: diff --git a/argocd/manifests/authentik/external-secret.yaml b/argocd/manifests/authentik/external-secret.yaml index 9abf699..93de499 100644 --- a/argocd/manifests/authentik/external-secret.yaml +++ b/argocd/manifests/authentik/external-secret.yaml @@ -53,10 +53,6 @@ spec: remoteRef: key: "Authentik (blumeops)" property: jellyfin-client-secret - - secretKey: argocd-client-secret - remoteRef: - key: "Authentik (blumeops)" - property: argocd-client-secret - secretKey: mealie-client-secret remoteRef: key: "Authentik (blumeops)" From 7d94b9073ae3230e78901f5b95351bf0f4fe6016 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Tue, 21 Apr 2026 10:43:21 -0700 Subject: [PATCH 327/430] =?UTF-8?q?C0:=20docs=20=E2=80=94=20default=20argo?= =?UTF-8?q?cd=20login=20to=20--sso;=20drop=20extraneous=20--grpc-web?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now that argocd's Authentik OAuth2 client is public, `argocd login --sso` works for day-to-day use. Promote it to the default in AGENTS.md, argocd-cli reference, and troubleshooting; keep the admin/password flow documented as a break-glass fallback for when Authentik is unavailable. Also drops --grpc-web from every interactive login command — confirmed extraneous (login succeeds without it). Left in CI workflows and `argocd cluster add` untouched; those are different contexts that I didn't re-test. Co-Authored-By: Claude Opus 4.7 (1M context) --- AGENTS.md | 2 +- argocd/manifests/argocd/README.md | 4 ++-- .../operations/rebuild-minikube-cluster.md | 24 ++++++------------- docs/how-to/operations/troubleshooting.md | 5 ++++ docs/reference/tools/argocd-cli.md | 8 +++++++ 5 files changed, 23 insertions(+), 20 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 80f9852..9e7350d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -86,7 +86,7 @@ Most services run in minikube on indri via ArgoCD (app-of-apps, manual sync). GP **Commands:** `argocd app list|get|diff|sync ` -**Login:** `argocd login argocd.ops.eblu.me --username admin --password "$(op read 'op://vg6xf6vvfmoh5hqjjhlhbeoaie/srogeebssulhtb6tnqd7ls6qey/password')"` +**Login:** `argocd login argocd.ops.eblu.me --sso` (opens browser for Authentik SSO). Admin fallback for break-glass: `argocd login argocd.ops.eblu.me --username admin --password "$(op read 'op://vg6xf6vvfmoh5hqjjhlhbeoaie/srogeebssulhtb6tnqd7ls6qey/password')"` ### Indri (Ansible) diff --git a/argocd/manifests/argocd/README.md b/argocd/manifests/argocd/README.md index 615e3bb..2eaf4d4 100644 --- a/argocd/manifests/argocd/README.md +++ b/argocd/manifests/argocd/README.md @@ -25,7 +25,7 @@ kubectl wait --for=condition=available deployment/argocd-server -n argocd --time kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath="{.data.password}" | base64 -d && echo # 5. Login and change password -argocd login argocd.tail8d86e.ts.net --username admin --grpc-web +argocd login argocd.tail8d86e.ts.net --username admin argocd account update-password # 6. Apply repo-creds-forge credential template for SSH access to all forge repos @@ -114,4 +114,4 @@ spec: Future improvement: integrate with a secrets operator (e.g., External Secrets). - The credential template (`repo-creds`) uses a URL prefix to match all repos on forge. - ArgoCD uses Tailscale Ingress with Let's Encrypt for TLS termination. -- The `--grpc-web` flag is required for CLI access through the Tailscale ingress. +- After Authentik is up, prefer `argocd login argocd.ops.eblu.me --sso` over the admin password login above; admin is only needed during bootstrap or as break-glass. diff --git a/docs/how-to/operations/rebuild-minikube-cluster.md b/docs/how-to/operations/rebuild-minikube-cluster.md index ad64c89..e23d027 100644 --- a/docs/how-to/operations/rebuild-minikube-cluster.md +++ b/docs/how-to/operations/rebuild-minikube-cluster.md @@ -108,18 +108,13 @@ kubectl --context=minikube-indri apply -f argocd/apps/apps.yaml # 6. Login and sync apps argocd login argocd.tail8d86e.ts.net --username admin \ --password "$(kubectl --context=minikube-indri -n argocd get secret argocd-initial-admin-secret -o jsonpath='{.data.password}' | base64 -d)" \ - --grpc-web -argocd app sync apps --grpc-web -``` + argocd app sync apps``` ## Phase 4: Bootstrap 1Password Connect + External Secrets ```bash # 1. Sync foundation -argocd app sync external-secrets-crds --grpc-web -argocd app sync external-secrets --grpc-web -argocd app sync 1password-connect --grpc-web - +argocd app sync external-secrets-crdsargocd app sync external-secretsargocd app sync 1password-connect # 2. Create 1Password Connect secrets manually CREDS_RAW=$(op read "op://blumeops/1Password Connect/credentials-file") echo "$CREDS_RAW" | kubectl --context=minikube-indri create secret generic op-credentials -n 1password \ @@ -140,25 +135,20 @@ kubectl --context=minikube-indri get clustersecretstores ```bash # Foundation (CRDs, operators) -argocd app sync cloudnative-pg kube-state-metrics --grpc-web - +argocd app sync cloudnative-pg kube-state-metrics # Databases -argocd app sync blumeops-pg --grpc-web - +argocd app sync blumeops-pg # Observability -argocd app sync loki prometheus tempo grafana grafana-config --grpc-web - +argocd app sync loki prometheus tempo grafana grafana-config # Register ringtail cluster (for authentik, ntfy, ollama, frigate) ssh ringtail 'sudo cat /etc/rancher/k3s/k3s.yaml' | \ sed 's|127.0.0.1|ringtail.tail8d86e.ts.net|' > /tmp/k3s-ringtail.yaml KUBECONFIG=/tmp/k3s-ringtail.yaml argocd cluster add default --name k3s-ringtail --grpc-web -y # Authentik (critical — Zot OIDC depends on it, most image pulls depend on Zot) -argocd app sync authentik --grpc-web - +argocd app sync authentik # Everything else -argocd app sync tailscale-operator alloy-k8s --grpc-web -# ... remaining apps +argocd app sync tailscale-operator alloy-k8s# ... remaining apps ``` ## Phase 6: Restore Databases from Borgmatic diff --git a/docs/how-to/operations/troubleshooting.md b/docs/how-to/operations/troubleshooting.md index 63dc79a..84301c3 100644 --- a/docs/how-to/operations/troubleshooting.md +++ b/docs/how-to/operations/troubleshooting.md @@ -72,6 +72,11 @@ kubectl --context=minikube-indri -n get pods --field-selector=status **ArgoCD login expired:** ```bash +argocd login argocd.ops.eblu.me --sso +``` + +If Authentik itself is down, fall back to admin: +```bash argocd login argocd.ops.eblu.me --username admin --password "$(op read 'op://vg6xf6vvfmoh5hqjjhlhbeoaie/srogeebssulhtb6tnqd7ls6qey/password')" ``` diff --git a/docs/reference/tools/argocd-cli.md b/docs/reference/tools/argocd-cli.md index 7a60490..a2aa223 100644 --- a/docs/reference/tools/argocd-cli.md +++ b/docs/reference/tools/argocd-cli.md @@ -24,6 +24,14 @@ argocd app sync apps # Sync the app-of-apps (picks up new Application ## Login +Default (Authentik SSO, PKCE, opens browser): + +```bash +argocd login argocd.ops.eblu.me --sso +``` + +Break-glass admin login (only if Authentik is down): + ```bash argocd login argocd.ops.eblu.me \ --username admin \ From 88eabc3de6f322e099fccd8c2df5d3bea9b5d587 Mon Sep 17 00:00:00 2001 From: Erich Blume <725328+eblume@users.noreply.github.com> Date: Tue, 21 Apr 2026 14:47:13 -0700 Subject: [PATCH 328/430] Disable Xalia --- nixos/ringtail/gaming.nix | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/nixos/ringtail/gaming.nix b/nixos/ringtail/gaming.nix index 2b361b3..d84ef9b 100644 --- a/nixos/ringtail/gaming.nix +++ b/nixos/ringtail/gaming.nix @@ -7,6 +7,11 @@ dedicatedServer.openFirewall = true; }; + # Proton Experimental ships an accessibility bridge (xalia) that hangs during + # game launch when AT-SPI is not running on the host. This host has no AT-SPI, + # so disable xalia globally to avoid wedging iscriptevaluator.exe. + environment.sessionVariables.PROTON_USE_XALIA = "0"; + # Gamescope — micro-compositor for game fullscreen/resolution management. # Use as Steam launch option: gamescope -W 2560 -H 1440 -f -- %command% programs.gamescope = { From 34fa2ef28abd785655e75a8ea7ac7bc1662e3f6f Mon Sep 17 00:00:00 2001 From: Erich Blume <725328+eblume@users.noreply.github.com> Date: Thu, 23 Apr 2026 12:16:02 -0700 Subject: [PATCH 329/430] =?UTF-8?q?C0:=20ringtail=20=E2=80=94=20restore=20?= =?UTF-8?q?sway=20default=20keybindings,=20fix=20fuzzel=20border=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend (not replace) home-manager's default sway keybindings via lib.mkOptionDefault, with lib.mkForce on the custom overrides that conflict with defaults. Add Mod+F1 cheatsheet binding (fuzzel-filterable). Move fuzzel's border-radius/border-width out of [main] into a proper [border] section with the expected short names. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../+ringtail-sway-fuzzel.bugfix.md | 3 +++ nixos/ringtail/configuration.nix | 23 +++++++++++-------- 2 files changed, 17 insertions(+), 9 deletions(-) create mode 100644 docs/changelog.d/+ringtail-sway-fuzzel.bugfix.md diff --git a/docs/changelog.d/+ringtail-sway-fuzzel.bugfix.md b/docs/changelog.d/+ringtail-sway-fuzzel.bugfix.md new file mode 100644 index 0000000..6801040 --- /dev/null +++ b/docs/changelog.d/+ringtail-sway-fuzzel.bugfix.md @@ -0,0 +1,3 @@ +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. diff --git a/nixos/ringtail/configuration.nix b/nixos/ringtail/configuration.nix index 052f38d..2cc5280 100644 --- a/nixos/ringtail/configuration.nix +++ b/nixos/ringtail/configuration.nix @@ -323,13 +323,16 @@ in bg = "~/.config/sway/wallpaper.jpg fill"; }; }; - keybindings = let mod = "Mod4"; in { - "${mod}+Return" = "exec wezterm"; - "${mod}+Shift+q" = "kill"; - "${mod}+d" = "exec wmenu-run"; - "${mod}+space" = "exec fuzzel"; - "${mod}+Shift+c" = "reload"; - "${mod}+l" = "exec swaylock -f"; + # Extend (not replace) the home-manager default sway keybindings. + # lib.mkForce is needed on keys whose defaults we want to override + # (same priority otherwise conflicts). Audio keys and Mod+d (wmenu-run + # vs the default menu binding) don't collide with defaults. + keybindings = let mod = "Mod4"; in lib.mkOptionDefault { + "${mod}+Return" = lib.mkForce "exec wezterm"; + "${mod}+d" = lib.mkForce "exec wmenu-run"; + "${mod}+space" = lib.mkForce "exec fuzzel"; + "${mod}+l" = lib.mkForce "exec swaylock -f"; + "${mod}+F1" = "exec grep '^bindsym' ~/.config/sway/config | fuzzel --dmenu"; "--locked XF86AudioMute" = "exec pactl set-sink-mute @DEFAULT_SINK@ toggle"; "--locked XF86AudioLowerVolume" = "exec pactl set-sink-volume @DEFAULT_SINK@ -5%"; "--locked XF86AudioRaiseVolume" = "exec pactl set-sink-volume @DEFAULT_SINK@ +5%"; @@ -401,8 +404,10 @@ in width = 40; horizontal-pad = 16; vertical-pad = 8; - border-radius = 8; - border-width = 2; + }; + border = { + radius = 8; + width = 2; }; colors = { background = "24273add"; From 72b27b7fd258482f225d2b60b11cd149a479ddd4 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Fri, 24 Apr 2026 19:04:28 -0700 Subject: [PATCH 330/430] =?UTF-8?q?C0:=20docs=20=E2=80=94=20add=20mealie?= =?UTF-8?q?=20borg=20restore=20how-to?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures the procedure used to restore mealie's SQLite DB from a borgmatic archive after the post-DR wipe: extract from borg, snapshot the wiped DB, swap via a helper pod on the ReadWriteOnce PVC, fix UID 911 ownership. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/how-to/mealie/restore-from-borg.md | 157 ++++++++++++++++++++++++ docs/reference/services/mealie.md | 2 + 2 files changed, 159 insertions(+) create mode 100644 docs/how-to/mealie/restore-from-borg.md diff --git a/docs/how-to/mealie/restore-from-borg.md b/docs/how-to/mealie/restore-from-borg.md new file mode 100644 index 0000000..7ff3625 --- /dev/null +++ b/docs/how-to/mealie/restore-from-borg.md @@ -0,0 +1,157 @@ +--- +title: Restore Mealie from Borg +modified: 2026-04-24 +last-reviewed: 2026-04-24 +tags: + - how-to + - mealie + - backup +--- + +# Restore Mealie from Borg + +How to restore [[mealie]]'s SQLite database from a [[borgmatic]] archive when data has been lost (e.g. PVC wiped, accidental deletion, post-DR rebuild). + +## Prerequisites + +- SSH access to [[indri]] (where borgmatic runs and stores k8s SQLite dumps) +- Mealie deployment present in the cluster (the PVC `mealie-data` exists in namespace `mealie`) +- Know which borg archive predates the data loss + +## Procedure + +### 1. Identify a Pre-Loss Archive + +List archives and pick one before the incident: + +```bash +ssh indri 'BORG_PASSCOMMAND="cat /Users/erichblume/.borg/config.yaml" \ + /opt/homebrew/bin/borg list /Volumes/backups/borg | tail -30' +``` + +Compare dump sizes across archives if you're unsure when the loss happened — the daily borgmatic run captures `/Users/erichblume/.local/share/borgmatic/k8s-dumps/mealie.db`. A sudden drop in size signals the wipe: + +```bash +ssh indri 'bash -c "BORG_PASSCOMMAND=\"cat /Users/erichblume/.borg/config.yaml\" \ + /opt/homebrew/bin/borg list /Volumes/backups/borg:: \ + --pattern=+Users/erichblume/.local/share/borgmatic/k8s-dumps/mealie.db"' +``` + +### 2. Extract the Pre-Loss Dump + +```bash +ssh indri 'mkdir -p ~/tmp/mealie-restore && cd ~/tmp/mealie-restore && \ + BORG_PASSCOMMAND="cat /Users/erichblume/.borg/config.yaml" \ + /opt/homebrew/bin/borg extract /Volumes/backups/borg:: \ + Users/erichblume/.local/share/borgmatic/k8s-dumps/mealie.db' +``` + +The file lands at `~/tmp/mealie-restore/Users/erichblume/.local/share/borgmatic/k8s-dumps/mealie.db` (borg preserves the full path). + +### 3. Verify the Extracted DB + +```bash +ssh indri 'sqlite3 ~/tmp/mealie-restore/Users/erichblume/.local/share/borgmatic/k8s-dumps/mealie.db \ + "PRAGMA integrity_check; SELECT COUNT(*) FROM recipes; SELECT COUNT(*) FROM users;"' +``` + +Expect `ok` and non-zero recipe/user counts. + +### 4. Snapshot the Current (Wiped) DB + +Belt and suspenders — keep a copy of the live DB before overwriting, in case the restore goes wrong: + +```bash +ssh indri 'bash -c "kubectl --context=minikube -n mealie exec deploy/mealie -- \ + python3 -c \"import sqlite3; sqlite3.connect(\\\"/app/data/mealie.db\\\").backup(sqlite3.connect(\\\"/tmp/wiped-mealie.db\\\"))\" && \ + POD=\$(kubectl --context=minikube -n mealie get pod -l app=mealie -o jsonpath=\"{.items[0].metadata.name}\") && \ + kubectl --context=minikube cp mealie/\$POD:/tmp/wiped-mealie.db /Users/erichblume/tmp/mealie-restore/wiped-mealie.db"' +``` + +### 5. Scale Mealie Down + +The PVC is `ReadWriteOnce`, so the helper pod can't mount it while mealie is running: + +```bash +ssh indri 'kubectl --context=minikube -n mealie scale deploy/mealie --replicas=0 && \ + kubectl --context=minikube -n mealie wait --for=delete pod -l app=mealie --timeout=60s' +``` + +### 6. Start a Helper Pod on the PVC + +```bash +ssh indri 'bash -c "cat > /tmp/mealie-helper.yaml < Date: Mon, 27 Apr 2026 09:48:46 -0700 Subject: [PATCH 331/430] C0: split gandi-operations docs; add dns-acme-cleanup mise task MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splits the nebulous gandi-operations how-to into two single-topic cards (manage-eblu-me-dns, rotate-gandi-pat) and adds a mise task for the recurring _acme-challenge TXT cleanup needed due to a value-comparison bug in libdns/gandi v1.1.0 that prevents certmagic's cleanup phase from removing presented TXT values. The gandi reference card is updated to drop the false "different credential from Pulumi PAT" claim — verified during the 2026-04-27 incident that Caddy and Pulumi share a single PAT. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/how-to/configuration/gandi-operations.md | 90 ------------- .../configuration/manage-eblu-me-dns.md | 52 ++++++++ .../configuration/manage-forgejo-mirrors.md | 2 +- docs/how-to/configuration/rotate-gandi-pat.md | 125 ++++++++++++++++++ docs/reference/infrastructure/gandi.md | 48 +++---- docs/reference/tools/mise-tasks.md | 1 + docs/reference/tools/pulumi.md | 3 +- mise-tasks/dns-acme-cleanup | 112 ++++++++++++++++ pulumi/gandi/README.md | 39 +----- pulumi/gandi/__main__.py | 2 +- 10 files changed, 315 insertions(+), 159 deletions(-) delete mode 100644 docs/how-to/configuration/gandi-operations.md create mode 100644 docs/how-to/configuration/manage-eblu-me-dns.md create mode 100644 docs/how-to/configuration/rotate-gandi-pat.md create mode 100755 mise-tasks/dns-acme-cleanup diff --git a/docs/how-to/configuration/gandi-operations.md b/docs/how-to/configuration/gandi-operations.md deleted file mode 100644 index 0be00dc..0000000 --- a/docs/how-to/configuration/gandi-operations.md +++ /dev/null @@ -1,90 +0,0 @@ ---- -title: Gandi Operations -modified: 2026-02-17 -last-reviewed: 2026-02-17 -tags: - - how-to - - dns - - pulumi ---- - -# Gandi Operations - -How to manage DNS records and cycle the Gandi API token. - -## Prerequisites - -- Pulumi CLI installed (`brew install pulumi`) -- Access to 1Password blumeops vault (for PAT) -- On the tailnet (Pulumi resolves indri's IP via MagicDNS) - -## Preview and Apply DNS Changes - -```bash -# Preview changes (always do this first) -mise run dns-preview - -# Apply changes -mise run dns-up -``` - -Both tasks fetch the Gandi PAT from 1Password automatically. - -To run Pulumi directly: - -```bash -export GANDI_PERSONAL_ACCESS_TOKEN=$(op read "op://vg6xf6vvfmoh5hqjjhlhbeoaie/mco6ka3dc3rmw7zkg2dhia5d2m/pat") -cd pulumi/gandi -pulumi preview -pulumi up --yes -``` - -## Cycle the Gandi PAT - -The Gandi Personal Access Token has a maximum lifetime of 90 days. Currently set to 30 days as a security compromise, though shorter may be appropriate given infrequent use. - -### 1. Create a new PAT - -Go to the [Gandi admin console](https://admin.gandi.net/organizations/1db8d76a-f729-11ed-b8d1-00163e94b645/account/pat) and create a new token: - -- **Name:** `blumeops-pulumi` (or similar) -- **Expiration:** 30 days (max 90; shorter is fine if you run this rarely) -- **Required permission:** Manage domain name technical configurations -- **Also enable:** See and renew domain names - -Copy the new PAT to your clipboard. - -### 2. Update 1Password - -With the new PAT on your clipboard: - -```bash -op item edit mco6ka3dc3rmw7zkg2dhia5d2m pat="$(pbpaste)" --vault vg6xf6vvfmoh5hqjjhlhbeoaie -``` - -### 3. Delete the old PAT - -Return to the Gandi admin console and delete the previous token. - -### 4. Verify - -```bash -mise run dns-preview -``` - -A successful preview confirms the new PAT is working. - -## Break-Glass Override - -If MagicDNS is unavailable and Pulumi can't resolve indri's IP, set the target IP manually. Find indri's current Tailscale IP via `tailscale status` or the admin console: - -```bash -export BLUMEOPS_REVERSE_PROXY_IP= -mise run dns-up -``` - -## Related - -- [[gandi]] - DNS configuration reference -- [[caddy]] - Reverse proxy (also uses a Gandi token for TLS) -- [[update-tailscale-acls]] - Similar Pulumi workflow for Tailscale diff --git a/docs/how-to/configuration/manage-eblu-me-dns.md b/docs/how-to/configuration/manage-eblu-me-dns.md new file mode 100644 index 0000000..4c37d4c --- /dev/null +++ b/docs/how-to/configuration/manage-eblu-me-dns.md @@ -0,0 +1,52 @@ +--- +title: Manage eblu.me DNS Records +modified: 2026-04-27 +last-reviewed: 2026-04-27 +tags: + - how-to + - dns + - pulumi +--- + +# Manage eblu.me DNS Records + +How to add, change, and apply DNS records for `eblu.me` via [[pulumi]]. + +## Prerequisites + +- Pulumi CLI installed (`brew install pulumi`) +- 1Password access (`blumeops` vault) — Pulumi reads the Gandi PAT from there +- On the tailnet — Pulumi resolves [[indri]]'s IP via MagicDNS at apply time + +## Preview and apply + +```bash +mise run dns-preview # always do this first +mise run dns-up # apply +``` + +Both fetch the PAT from 1Password automatically. The Pulumi program is in `pulumi/gandi/`; stack is `eblu-me`. + +## Adding a record + +Edit `pulumi/gandi/__main__.py` and add a `gandi.livedns.Record(...)`. The stack config (`Pulumi.eblu-me.yaml`) only holds `domain` and `subdomain`; everything else is in the program. + +After editing, preview, then apply. + +## Break-glass: override the indri target IP + +The wildcard `*.ops.eblu.me` is computed from `indri.tail8d86e.ts.net` via MagicDNS at apply time. If MagicDNS is unavailable: + +```bash +export BLUMEOPS_REVERSE_PROXY_IP= +mise run dns-up +``` + +Find the IP via `tailscale status` or the Tailscale admin console. + +## Related + +- [[gandi]] — Gandi reference card +- [[rotate-gandi-pat]] — Rotate the PAT shared with [[caddy]] +- [[pulumi]] — Pulumi tooling reference +- [[routing]] — Service URLs and routing architecture diff --git a/docs/how-to/configuration/manage-forgejo-mirrors.md b/docs/how-to/configuration/manage-forgejo-mirrors.md index 7f98549..9c0e113 100644 --- a/docs/how-to/configuration/manage-forgejo-mirrors.md +++ b/docs/how-to/configuration/manage-forgejo-mirrors.md @@ -144,6 +144,6 @@ Trigger a manual sync on one mirror to confirm the new PAT works: ## Related - [[forgejo]] — Forgejo service reference -- [[gandi-operations]] — Similar PAT rotation workflow for Gandi DNS +- [[rotate-gandi-pat]] — Similar PAT rotation workflow for Gandi DNS - [[spork-strategy]] — floating-branch soft-fork strategy explanation - [[create-a-spork]] — create a spork on top of a mirror diff --git a/docs/how-to/configuration/rotate-gandi-pat.md b/docs/how-to/configuration/rotate-gandi-pat.md new file mode 100644 index 0000000..94a0b4e --- /dev/null +++ b/docs/how-to/configuration/rotate-gandi-pat.md @@ -0,0 +1,125 @@ +--- +title: Rotate the Gandi PAT +modified: 2026-04-27 +last-reviewed: 2026-04-27 +tags: + - how-to + - dns + - secrets +--- + +# Rotate the Gandi PAT + +How to rotate the Gandi Personal Access Token. **One PAT** is shared by [[caddy]] (TLS via ACME DNS-01) and Pulumi (DNS records). It lives in 1Password at `op://blumeops/gandi - blumeops/pat`. + +## When to rotate + +- Every 60 days (Todoist recurring task) +- After any compromise / accidental disclosure +- Whenever Gandi starts rejecting the PAT (see [Debugging](#debugging)) + +Gandi caps PAT lifetime at 90 days; rotating at 60 leaves a 30-day buffer. + +## Prerequisites + +- Access to the [Gandi PAT admin console](https://admin.gandi.net/organizations/1db8d76a-f729-11ed-b8d1-00163e94b645/account/pat) +- 1Password (`blumeops` vault) +- Ability to run `mise run provision-indri` (ssh to [[indri]] + 1Password biometric) + +## Procedure + +### 1. Create a new PAT in Gandi + +In the [Gandi PAT console](https://admin.gandi.net/organizations/1db8d76a-f729-11ed-b8d1-00163e94b645/account/pat), create a token: + +- **Name:** `blumeops` +- **Expiration:** **90 days** (the max — paired with the 60-day rotation cadence) +- **Permissions:** + - Manage domain name technical configurations *(required — DNS records and ACME TXT writes)* + - See and renew domain names + +Other permissions are not used. + +Copy the new PAT to your clipboard. + +### 2. Update 1Password + +```bash +op item edit mco6ka3dc3rmw7zkg2dhia5d2m pat="$(pbpaste)" --vault vg6xf6vvfmoh5hqjjhlhbeoaie +``` + +### 3. Push to indri + +The PAT lives in two places: 1Password (read by Pulumi at runtime) and `~/.config/caddy/gandi-token` on indri (read by Caddy at startup). The 1Password edit only updates the first. + +```bash +mise run provision-indri --tags caddy +``` + +This re-fetches the PAT from 1Password, writes it to indri, and restarts Caddy. Caddy will renew any due certificates within minutes. + +### 4. Verify + +```bash +mise run dns-preview +``` + +A successful preview confirms Pulumi can use the PAT. + +```bash +ssh indri 'tail -50 ~/Library/Logs/mcquack.caddy.err.log' \ + | grep -E "obtained|renew|error" +``` + +Expect to see no `LiveDNS returned a 403` lines, and either no renewal activity (if no certs were due) or `certificate obtained successfully`. + +### 5. Delete the old PAT in Gandi + +Return to the Gandi PAT console and delete the previous token. + +### 6. Clean up orphan ACME records + +Each successful Caddy renewal leaves orphan `_acme-challenge.ops` TXT records in the zone (a bug in `libdns/gandi` v1.1.0 — see the script docstring). Cadence aligns with rotation: + +```bash +mise run dns-acme-cleanup --dry-run +mise run dns-acme-cleanup +``` + +## Debugging + +### Caddy logs `LiveDNS returned a 403` + +The PAT is invalid (expired, revoked, or insufficient scope). **Gandi returns 403 — not 401 — for an expired PAT**, which can read as a permissions issue. The most common cause is plain expiry. Rotate. + +### `mise run dns-preview` returns 403 + +Same root cause — Pulumi and Caddy share this PAT. + +### After a fresh PAT, Caddy still fails + +Check that the value on indri matches 1Password: + +```bash +diff <(ssh indri 'cat ~/.config/caddy/gandi-token') \ + <(op read 'op://blumeops/gandi - blumeops/pat') +``` + +If they differ, `mise run provision-indri --tags caddy` was skipped or failed. + +Confirm the new PAT works against Gandi directly: + +```bash +curl -s -o /dev/null -w "HTTP %{http_code}\n" \ + -H "Authorization: Bearer $(op read 'op://blumeops/gandi - blumeops/pat')" \ + https://api.gandi.net/v5/livedns/domains/eblu.me +``` + +`200` = healthy. `403` = scope or expiry. `401` = malformed token. + +## Related + +- [[gandi]] — Gandi reference card +- [[manage-eblu-me-dns]] — DNS records workflow (separate operation, same PAT) +- [[caddy]] — Reverse proxy that uses the PAT for TLS +- [[mise-tasks]] — `dns-acme-cleanup`, `provision-indri`, `dns-preview` reference diff --git a/docs/reference/infrastructure/gandi.md b/docs/reference/infrastructure/gandi.md index ae1fe56..763bae3 100644 --- a/docs/reference/infrastructure/gandi.md +++ b/docs/reference/infrastructure/gandi.md @@ -1,7 +1,7 @@ --- title: Gandi -modified: 2026-04-09 -last-reviewed: 2026-04-09 +modified: 2026-04-27 +last-reviewed: 2026-04-27 tags: - infrastructure - networking @@ -20,12 +20,11 @@ DNS hosting provider for the `eblu.me` domain, managed via Pulumi IaC. | **Provider** | Gandi LiveDNS | | **IaC** | `pulumi/gandi/` | | **Stack** | `eblu-me` | +| **PAT** | `op://blumeops/gandi - blumeops/pat` | ## What It Does -Gandi hosts the DNS records that make `*.ops.eblu.me` resolve to [[indri]]'s Tailscale IP (`indri.tail8d86e.ts.net`). Since Tailscale IPs are not publicly routable, this gives services real DNS names while keeping them private to the tailnet. - -The target IP is resolved dynamically from `indri.tail8d86e.ts.net` at deploy time, so if indri's Tailscale IP changes, re-running the deployment is sufficient. +Gandi hosts the DNS records that make `*.ops.eblu.me` resolve to [[indri]]'s Tailscale IP. Since Tailscale IPs are not publicly routable, this gives services real DNS names while keeping them private to the tailnet. The target IP is resolved dynamically from `indri.tail8d86e.ts.net` at deploy time. ## DNS Records @@ -46,38 +45,25 @@ Both records point to [[indri]], which runs [[caddy]] as the reverse proxy for a | `cv.eblu.me` | CNAME | `blumeops-proxy.fly.dev` | 300s | | `forge.eblu.me` | CNAME | `blumeops-proxy.fly.dev` | 300s | -Public CNAMEs point to [[flyio-proxy]] on Fly.io. See [[expose-service-publicly]] for adding new public services. - -See [[routing]] for the full service URL map. - -## Pulumi Configuration - -The Pulumi program lives in `pulumi/gandi/`: - -- `__main__.py` - Creates A and CNAME records via `pulumiverse_gandi` -- `Pulumi.eblu-me.yaml` - Stack config (domain, subdomain) - -Stack config values: - -| Key | Value | -|-----|-------| -| `blumeops-dns:domain` | `eblu.me` | -| `blumeops-dns:subdomain` | `ops` | - -A break-glass override is available via the `BLUMEOPS_REVERSE_PROXY_IP` environment variable, which bypasses dynamic IP resolution. +Public CNAMEs point to [[flyio-proxy]] on Fly.io. See [[expose-service-publicly]] for adding new public services. See [[routing]] for the full service URL map. ## TLS Integration -[[caddy]] uses Gandi's API separately (via `GANDI_BEARER_TOKEN`) for ACME DNS-01 challenges to obtain a wildcard Let's Encrypt certificate for `*.ops.eblu.me`. This is a different credential from the Pulumi PAT. +[[caddy]] uses this same Gandi PAT for ACME DNS-01 challenges to obtain a wildcard Let's Encrypt certificate for `*.ops.eblu.me`. Caddy reads the PAT from `~/.config/caddy/gandi-token` on [[indri]], populated by ansible from 1Password. ## Authentication -Gandi requires a Personal Access Token (PAT) for API access. PATs have a maximum lifetime of 90 days (currently set to 30). See [[gandi-operations]] for deployment and PAT cycling instructions. +One Gandi Personal Access Token, shared by Pulumi and Caddy. Gandi caps PATs at 90 days; rotate every 60 days via [[rotate-gandi-pat]]. + +## ACME Challenge Cleanup + +Caddy's renewal flow leaves `_acme-challenge.ops` TXT orphans in the zone — a value-comparison bug in `libdns/gandi` v1.1.0 makes the cleanup phase a no-op. Run `mise run dns-acme-cleanup` periodically (alongside PAT rotation works well). ## Related -- [[gandi-operations]] - PAT cycling and deployment how-to -- [[routing]] - Service URLs and routing architecture -- [[caddy]] - Reverse proxy using Gandi for TLS -- [[tailscale]] - Tailnet networking -- [[indri]] - Server hosting Caddy (DNS target) +- [[manage-eblu-me-dns]] — Add/change DNS records via Pulumi +- [[rotate-gandi-pat]] — Rotate the shared Gandi PAT +- [[routing]] — Service URLs and routing architecture +- [[caddy]] — Reverse proxy using this PAT for TLS +- [[tailscale]] — Tailnet networking +- [[indri]] — Server hosting Caddy (DNS target) diff --git a/docs/reference/tools/mise-tasks.md b/docs/reference/tools/mise-tasks.md index fefb30f..4ec3438 100644 --- a/docs/reference/tools/mise-tasks.md +++ b/docs/reference/tools/mise-tasks.md @@ -39,6 +39,7 @@ Run `mise tasks --sort name` for the live list with descriptions. | `fly-shutoff` | Emergency shutoff: stop all Fly.io proxy machines | | `dns-preview` | Preview DNS changes with [[pulumi]] | | `dns-up` | Apply DNS changes with [[pulumi]] | +| `dns-acme-cleanup` | Delete orphaned `_acme-challenge.ops` TXT records (libdns/gandi v1.1.0 workaround) | | `tailnet-preview` | Preview Tailscale ACL changes with [[pulumi]] | | `tailnet-up` | Apply Tailscale ACL changes with [[pulumi]] | diff --git a/docs/reference/tools/pulumi.md b/docs/reference/tools/pulumi.md index bdc7e8f..a716bb9 100644 --- a/docs/reference/tools/pulumi.md +++ b/docs/reference/tools/pulumi.md @@ -49,7 +49,8 @@ mise run tailnet-up # Apply ACL/tag changes ## Related -- [[gandi-operations]] — DNS PAT rotation and Pulumi workflow +- [[manage-eblu-me-dns]] — DNS records workflow +- [[rotate-gandi-pat]] — Rotate the Gandi PAT - [[update-tailscale-acls]] — ACL editing and Pulumi workflow - [[gandi]] — DNS hosting - [[tailscale]] — Tailnet configuration diff --git a/mise-tasks/dns-acme-cleanup b/mise-tasks/dns-acme-cleanup new file mode 100755 index 0000000..5152ae2 --- /dev/null +++ b/mise-tasks/dns-acme-cleanup @@ -0,0 +1,112 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.12" +# dependencies = ["httpx>=0.28.1", "rich>=14.0.0", "typer>=0.24.0"] +# /// +#MISE description="Delete orphaned ACME challenge TXT records in eblu.me" +#USAGE flag "--dry-run" help="List orphans without deleting" +"""Clean up orphaned _acme-challenge TXT records in the eblu.me zone. + +Workaround for libdns/gandi v1.1.0: its DeleteRecords compares unquoted +certmagic values to Gandi-quoted stored values, so cleanup is a silent +no-op. Without this script, the rrset grows by ~2 values per successful +Caddy renewal cycle. + +In healthy steady state these records should be absent. Run alongside +PAT rotation, or any time after Caddy ACME activity. +""" + +import os +import subprocess +from typing import Annotated + +import httpx +import typer +from rich.console import Console +from rich.table import Table + +DOMAIN = "eblu.me" +RRSET = "_acme-challenge.ops" +GANDI_API = "https://api.gandi.net/v5/livedns" +OP_PAT_REF = "op://blumeops/gandi - blumeops/pat" + + +def resolve_token(console: Console) -> str: + env_token = os.environ.get("GANDI_PERSONAL_ACCESS_TOKEN", "").strip() + if env_token: + return env_token + console.print("[dim]Reading Gandi PAT from 1Password...[/dim]") + try: + result = subprocess.run( + ["op", "read", OP_PAT_REF], + capture_output=True, + text=True, + check=True, + ) + return result.stdout.strip() + except (subprocess.CalledProcessError, FileNotFoundError) as e: + console.print(f"[red]Failed to read PAT from 1Password:[/red] {e}") + raise typer.Exit(1) + + +app = typer.Typer(add_completion=False) + + +@app.command() +def main( + dry_run: Annotated[ + bool, + typer.Option("--dry-run", help="List orphans without deleting"), + ] = False, +) -> None: + """Delete orphan _acme-challenge TXT records in eblu.me.""" + console = Console() + token = resolve_token(console) + + url = f"{GANDI_API}/domains/{DOMAIN}/records/{RRSET}/TXT" + headers = {"Authorization": f"Bearer {token}"} + + with httpx.Client(timeout=15, headers=headers) as client: + resp = client.get(url) + if resp.status_code == 404: + console.print( + f"[green]Clean — {RRSET}.{DOMAIN} TXT rrset is absent.[/green]" + ) + raise typer.Exit(0) + resp.raise_for_status() + values = resp.json().get("rrset_values", []) + + if not values: + console.print( + f"[green]Clean — {RRSET}.{DOMAIN} TXT rrset is empty.[/green]" + ) + raise typer.Exit(0) + + table = Table(title=f"Orphan ACME challenge values: {RRSET}.{DOMAIN}") + table.add_column("#", justify="right") + table.add_column("Value") + for i, v in enumerate(values, 1): + table.add_row(str(i), v) + console.print(table) + console.print(f"\n[bold]{len(values)}[/bold] orphan(s).") + + if dry_run: + console.print("\n[dim]Dry run — no records deleted.[/dim]") + raise typer.Exit(0) + + del_resp = client.delete(url) + if del_resp.status_code == 204: + console.print( + f"[green]Deleted {RRSET}.{DOMAIN} TXT " + f"({len(values)} values).[/green]" + ) + else: + console.print( + f"[red]Delete failed: HTTP {del_resp.status_code}[/red]\n" + f"{del_resp.text[:300]}" + ) + raise typer.Exit(1) + + +if __name__ == "__main__": + app() diff --git a/pulumi/gandi/README.md b/pulumi/gandi/README.md index 9d7b7aa..70d2821 100644 --- a/pulumi/gandi/README.md +++ b/pulumi/gandi/README.md @@ -27,50 +27,19 @@ pulumi stack select eblu-me # or: pulumi stack init eblu-me ## Authentication -This project requires a Gandi Personal Access Token (PAT) with LiveDNS permissions. +This project uses a Gandi Personal Access Token (PAT) shared with Caddy. See the [Gandi reference card](../../docs/reference/infrastructure/gandi.md) and [Rotate the Gandi PAT](../../docs/how-to/configuration/rotate-gandi-pat.md). -**The PAT expires every 30 days and must be cycled manually.** - -### Cycling the PAT - -1. Go to [Gandi PAT Management](https://admin.gandi.net/organizations/1db8d76a-f729-11ed-b8d1-00163e94b645/account/pat) - -2. Create a new PAT: - - Name: `blumeops-pulumi` (or similar) - - Expiration: 30 days (maximum is 90; shorter is fine if used rarely) - - Permissions required: - - **Manage domain name technical configurations** (required for DNS records) - - See and renew domain names - - Optional permissions (enabled but not strictly required): - - See & download SSL certificates - - Manage Cloud resources - - See Cloud resources - - View Organization - - Deploy Web Hosting instances - - Manage Web Hosting instances - - See and renew Web Hosting instances - -3. Update 1Password: - ```bash - # Update the existing item with the new PAT value - op item edit mco6ka3dc3rmw7zkg2dhia5d2m pat="" --vault vg6xf6vvfmoh5hqjjhlhbeoaie - ``` - -4. Delete the old PAT from Gandi admin console - -### Running with Authentication - -The mise task handles fetching the PAT from 1Password: +The mise tasks handle fetching the PAT from 1Password: ```bash -mise run dns-up # Preview and apply changes mise run dns-preview # Preview only +mise run dns-up # Preview and apply ``` Or manually: ```bash -export GANDI_PERSONAL_ACCESS_TOKEN=$(op read "op://vg6xf6vvfmoh5hqjjhlhbeoaie/mco6ka3dc3rmw7zkg2dhia5d2m/pat") +export GANDI_PERSONAL_ACCESS_TOKEN=$(op read "op://blumeops/gandi - blumeops/pat") pulumi up ``` diff --git a/pulumi/gandi/__main__.py b/pulumi/gandi/__main__.py index e448ed2..bda7a8a 100644 --- a/pulumi/gandi/__main__.py +++ b/pulumi/gandi/__main__.py @@ -8,7 +8,7 @@ This program manages DNS records for blumeops infrastructure: Authentication: Set GANDI_PERSONAL_ACCESS_TOKEN environment variable. - See docs/how-to/gandi-operations.md for PAT management instructions. + See docs/how-to/configuration/rotate-gandi-pat.md for PAT management. """ import os From f9d9e00057a6a00887b16b18d3831a36b7837f44 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 27 Apr 2026 11:18:16 -0700 Subject: [PATCH 332/430] =?UTF-8?q?C0:=20blumeops-tasks=20=E2=80=94=20show?= =?UTF-8?q?=20due=20offset=20+=20recurrence,=20sort=20by=20overdue-ness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../+blumeops-tasks-due-recurrence.feature.md | 1 + mise-tasks/blumeops-tasks | 50 ++++++++++++++++++- 2 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 docs/changelog.d/+blumeops-tasks-due-recurrence.feature.md diff --git a/docs/changelog.d/+blumeops-tasks-due-recurrence.feature.md b/docs/changelog.d/+blumeops-tasks-due-recurrence.feature.md new file mode 100644 index 0000000..3d00e1c --- /dev/null +++ b/docs/changelog.d/+blumeops-tasks-due-recurrence.feature.md @@ -0,0 +1 @@ +`blumeops-tasks` now annotates each task with a signed `due:±N` offset (or `due:today`) and a `↻ ` marker for recurring tasks, and sorts by overdue-ness (most overdue first, no-due-date last) with priority as tiebreaker. diff --git a/mise-tasks/blumeops-tasks b/mise-tasks/blumeops-tasks index 333178e..1c41dea 100755 --- a/mise-tasks/blumeops-tasks +++ b/mise-tasks/blumeops-tasks @@ -101,9 +101,45 @@ def is_due(task: dict) -> bool: return due_date <= date.today() +def days_until_due(task: dict) -> int | None: + """Return signed days offset from today, or None if no due date. + + Negative = days remaining before due (e.g. -2 = due in 2 days). + Positive = days past due (overdue). Zero = due today. + """ + due = task.get("due") + if due is None: + return None + due_date = date.fromisoformat(due["date"][:10]) + return (date.today() - due_date).days + + +def recurrence_string(task: dict) -> str | None: + """Return the Todoist natural-language recurrence string, or None. + + Todoist's REST API doesn't expose RFC 5545 RRULE; the natural-language + `due.string` (e.g. "every monday", "every 2 weeks") is the terse form. + """ + due = task.get("due") + if due is None or not due.get("is_recurring"): + return None + return due.get("string") + + def sort_tasks(tasks: list[dict]) -> list[dict]: - """Sort tasks by custom priority order: p1, p2, p4, p3.""" - return sorted(tasks, key=lambda t: PRIORITY_SORT_ORDER.get(t["priority"], 5)) + """Sort by overdue-ness, then priority. + + Most overdue first (largest +N); tasks with no due date come last. + Within a given day, tiebreaker is the custom priority order p1, p2, p4, p3. + """ + + def key(task: dict) -> tuple[int, int, int]: + days = days_until_due(task) + no_due = 1 if days is None else 0 + days_key = -(days if days is not None else 0) # descending + return (no_due, days_key, PRIORITY_SORT_ORDER.get(task["priority"], 5)) + + return sorted(tasks, key=key) def main() -> int: @@ -149,6 +185,16 @@ def main() -> int: header = Text() header.append(f"[{label}]", style="bold") header.append(f" {content}") + + meta = [] + days = days_until_due(task) + if days is not None: + meta.append(f"due:{days:+d}" if days != 0 else "due:today") + recurrence = recurrence_string(task) + if recurrence: + meta.append(f"↻ {recurrence}") + if meta: + header.append(f" ({', '.join(meta)})", style="dim") console.print(header) # Description indented (escape rich markup to preserve brackets) From 4a37ffcdc239772caedb17b03fcd2a284bef13d1 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 27 Apr 2026 11:41:13 -0700 Subject: [PATCH 333/430] =?UTF-8?q?C0:=20CLAUDE.md=20=E2=80=94=20import=20?= =?UTF-8?q?AGENTS.md=20instead=20of=20redirecting=20to=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code only auto-loads CLAUDE.md. The prose shim told agents to go read AGENTS.md, which is easy to skip. Replacing the shim with `@AGENTS.md` inlines AGENTS.md content into the session prompt, so the startup rules (ai-docs, blumeops-tasks, change classification) land in context unconditionally. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 8 +------- docs/changelog.d/+claude-md-import-agents.ai.md | 1 + 2 files changed, 2 insertions(+), 7 deletions(-) create mode 100644 docs/changelog.d/+claude-md-import-agents.ai.md diff --git a/CLAUDE.md b/CLAUDE.md index d825c0f..43c994c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1 @@ -# CLAUDE.md - -Claude Code compatibility shim. - -The canonical agent instructions for this repository now live in [`AGENTS.md`](AGENTS.md). - -If a tool specifically looks for `CLAUDE.md`, read `AGENTS.md` and follow that file as the source of truth. +@AGENTS.md diff --git a/docs/changelog.d/+claude-md-import-agents.ai.md b/docs/changelog.d/+claude-md-import-agents.ai.md new file mode 100644 index 0000000..f63231e --- /dev/null +++ b/docs/changelog.d/+claude-md-import-agents.ai.md @@ -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. From c9eb188e0530634ac7487f63b1a51a1e033f180f Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 27 Apr 2026 11:49:46 -0700 Subject: [PATCH 334/430] =?UTF-8?q?C0:=20blumeops-tasks=20=E2=80=94=20repl?= =?UTF-8?q?ace=20ambiguous=20due:+N=20with=20"Nd=20overdue"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The signed offset format read as "due in 5 days" rather than "5 days overdue", causing misreads. Switch to self-explanatory text: "5d overdue" / "due in 2d" / "due today". Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/changelog.d/+blumeops-tasks-due-recurrence.feature.md | 2 +- mise-tasks/blumeops-tasks | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/changelog.d/+blumeops-tasks-due-recurrence.feature.md b/docs/changelog.d/+blumeops-tasks-due-recurrence.feature.md index 3d00e1c..83072dd 100644 --- a/docs/changelog.d/+blumeops-tasks-due-recurrence.feature.md +++ b/docs/changelog.d/+blumeops-tasks-due-recurrence.feature.md @@ -1 +1 @@ -`blumeops-tasks` now annotates each task with a signed `due:±N` offset (or `due:today`) and a `↻ ` marker for recurring tasks, and sorts by overdue-ness (most overdue first, no-due-date last) with priority as tiebreaker. +`blumeops-tasks` now annotates each task with a human-readable due offset (`5d overdue` / `due in 2d` / `due today`) and a `↻ ` marker for recurring tasks, and sorts by overdue-ness (most overdue first, no-due-date last) with priority as tiebreaker. diff --git a/mise-tasks/blumeops-tasks b/mise-tasks/blumeops-tasks index 1c41dea..e07e9bf 100755 --- a/mise-tasks/blumeops-tasks +++ b/mise-tasks/blumeops-tasks @@ -189,7 +189,12 @@ def main() -> int: meta = [] days = days_until_due(task) if days is not None: - meta.append(f"due:{days:+d}" if days != 0 else "due:today") + if days == 0: + meta.append("due today") + elif days > 0: + meta.append(f"{days}d overdue") + else: + meta.append(f"due in {-days}d") recurrence = recurrence_string(task) if recurrence: meta.append(f"↻ {recurrence}") From cfb6d7a7aa32cde44eebfc139a9927ac7f03f8c0 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 27 Apr 2026 11:57:33 -0700 Subject: [PATCH 335/430] =?UTF-8?q?C0:=20service-review=20=E2=80=94=20mark?= =?UTF-8?q?=20cv=20reviewed=202026-04-27?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No version bump; build deps (jinja2, pyyaml) still loose-pinned and fine. Known issue: deployed v1.0.3 package predates phone-hide commit; tracked separately in Todoist by user. Co-Authored-By: Claude Opus 4.7 (1M context) --- service-versions.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/service-versions.yaml b/service-versions.yaml index f5811b5..0a4fe93 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -221,7 +221,7 @@ services: - name: cv type: argocd - last-reviewed: 2026-03-07 + last-reviewed: 2026-04-27 current-version: "1.0.3" upstream-source: https://forge.eblu.me/eblume/cv notes: Personal static site; review build deps (WeasyPrint, Jinja2) in source repo From 718e0a00433cc896acacd67d9718f9f6025a215c Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 27 Apr 2026 12:18:06 -0700 Subject: [PATCH 336/430] =?UTF-8?q?C0:=20review-compliance-reports=20?= =?UTF-8?q?=E2=80=94=20summarize=20image=20and=20IaC=20scans?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously only the K8s CIS in-cluster scan was processed; the weekly container-image and IaC Prowler scans were running on schedule but never reviewed. Now each scan gets its own status / severity / week-over-week delta, with top-N grouped tables (by check ID and resource) for the high-volume image and IaC outputs. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../+review-compliance-image-iac.feature.md | 1 + mise-tasks/review-compliance-reports | 531 +++++++++++------- 2 files changed, 324 insertions(+), 208 deletions(-) create mode 100644 docs/changelog.d/+review-compliance-image-iac.feature.md diff --git a/docs/changelog.d/+review-compliance-image-iac.feature.md b/docs/changelog.d/+review-compliance-image-iac.feature.md new file mode 100644 index 0000000..1125359 --- /dev/null +++ b/docs/changelog.d/+review-compliance-image-iac.feature.md @@ -0,0 +1 @@ +`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. diff --git a/mise-tasks/review-compliance-reports b/mise-tasks/review-compliance-reports index 080271c..72f35cc 100755 --- a/mise-tasks/review-compliance-reports +++ b/mise-tasks/review-compliance-reports @@ -9,23 +9,26 @@ """Fetch and summarize compliance reports from sifaka. Covers: - - Prowler K8s CIS: CSV-based, full analysis with delta tracking + - Prowler K8s CIS (in-cluster): per-finding detail + - Prowler container image scans: grouped by check + resource + - Prowler IaC manifest scans: grouped by check + resource - Kingfisher secret scanning: TODO — pending upstream JSON/CSV output support (currently HTML-only; contribute from spork) -For Prowler, copies the two most recent K8s CIS reports, parses them, -and displays: +For each Prowler scan, copies the two most recent CSV reports, parses +them, and displays: 1. Overall status (pass/fail/manual/muted counts) 2. Unmuted failures by severity 3. Delta from the previous report (new vs resolved) - 4. Actionable unmuted failures with details + 4. Actionable unmuted failures (per-finding for in-cluster; grouped + by check ID and resource for image/IaC because they have far too + many findings to list individually) This is the primary tool for the weekly compliance report review. """ import csv import subprocess -import sys import tempfile from collections import Counter from pathlib import Path @@ -36,7 +39,12 @@ from rich.console import Console from rich.panel import Panel from rich.table import Table -REPORT_BASE = "sifaka:/volume1/reports/prowler" +PROWLER_SCANS: list[tuple[str, str, bool]] = [ + # (label, sifaka base path, group_findings) + ("K8s CIS (In-Cluster)", "/volume1/reports/prowler", False), + ("Container Images", "/volume1/reports/prowler-images", True), + ("IaC (manifests)", "/volume1/reports/prowler-iac", True), +] console = Console() @@ -52,18 +60,18 @@ def scp(remote: str, local: str) -> bool: return result.returncode == 0 -def list_reports() -> list[str]: - """List Prowler CSV reports on sifaka, sorted by embedded timestamp.""" +def list_reports(base: str) -> list[str]: + """List Prowler CSV reports under `base` on sifaka, sorted by timestamp.""" result = subprocess.run( - ["ssh", "sifaka", "find /volume1/reports/prowler/ -name '*.csv' " + ["ssh", "sifaka", f"find {base}/ -name '*.csv' " "-not -path '*/compliance/*' -not -name '@*'"], capture_output=True, text=True, timeout=15, ) if result.returncode != 0: - console.print("[bold red]Failed to list reports on sifaka[/bold red]") - raise typer.Exit(code=1) + console.print(f"[bold red]Failed to list reports under {base}[/bold red]") + return [] csvs = [p.strip() for p in result.stdout.strip().splitlines() if p.strip()] # Sort by the timestamp embedded in the filename (e.g. 20260405030007) @@ -306,136 +314,151 @@ def run_node_verification(console: Console) -> None: console.print() -def main( - full: Annotated[ - bool, typer.Option(help="Show all unmuted failures, not just new ones") - ] = False, - show_muted: Annotated[ - bool, typer.Option(help="Also show muted failures") - ] = False, +SEVERITY_STYLE = { + "critical": "bold red", + "high": "red", + "medium": "yellow", +} + + +def _sev_style(sev: str) -> str: + return SEVERITY_STYLE.get(sev.lower(), "") + + +def summarize_report( + label: str, + base: str, + tmpdir: str, + *, + show_muted: bool = False, + group_findings: bool = False, ) -> None: - csvs = list_reports() + """Fetch and summarize the latest Prowler report under `base`. + + When `group_findings` is True, top-N CHECK_ID and RESOURCE_NAME tables + are shown instead of a per-finding detail table — appropriate for + image and IaC scans that produce thousands of findings. + """ + console.rule(f"[bold]{label}[/bold]") + csvs = list_reports(base) if not csvs: - console.print("[bold red]No Prowler CSV reports found on sifaka[/bold red]") - raise typer.Exit(code=1) - - with tempfile.TemporaryDirectory() as tmpdir: - # Fetch the two most recent reports - latest_remote = csvs[-1] - latest_local = Path(tmpdir) / "latest.csv" - - console.print(f"[dim]Fetching {latest_remote}...[/dim]") - if not scp(f"sifaka:{latest_remote}", str(latest_local)): - console.print("[bold red]Failed to copy latest report[/bold red]") - raise typer.Exit(code=1) - - prev_local = None - if len(csvs) >= 2: - prev_remote = csvs[-2] - prev_local = Path(tmpdir) / "prev.csv" - console.print(f"[dim]Fetching {prev_remote}...[/dim]") - if not scp(f"sifaka:{prev_remote}", str(prev_local)): - prev_local = None - - latest = parse_findings(load_csv(str(latest_local))) - - # Extract report date from filename - report_name = Path(latest_remote).stem - console.print() - - # --- Overall status --- - status_table = Table( - show_header=True, header_style="bold", title=f"Report: {report_name}" + console.print( + f"[bold yellow]{label}: no Prowler CSV reports found " + f"under {base}[/bold yellow]" ) - status_table.add_column("Status") - status_table.add_column("Count", justify="right") + console.print() + return - for status in ["PASS", "FAIL", "MANUAL"]: - count = latest["statuses"].get(status, 0) - style = "red" if status == "FAIL" and count > 0 else "" - status_table.add_row( - f"[{style}]{status}[/{style}]" if style else status, + safe = "".join(c if c.isalnum() else "_" for c in label.lower()) + latest_remote = csvs[-1] + latest_local = Path(tmpdir) / f"{safe}_latest.csv" + + console.print(f"[dim]Fetching {latest_remote}...[/dim]") + if not scp(f"sifaka:{latest_remote}", str(latest_local)): + console.print(f"[bold red]Failed to copy {latest_remote}[/bold red]") + return + + prev_local: Path | None = None + if len(csvs) >= 2: + prev_remote = csvs[-2] + prev_path = Path(tmpdir) / f"{safe}_prev.csv" + console.print(f"[dim]Fetching {prev_remote}...[/dim]") + if scp(f"sifaka:{prev_remote}", str(prev_path)): + prev_local = prev_path + + latest = parse_findings(load_csv(str(latest_local))) + report_name = Path(latest_remote).stem + console.print() + + # --- Overall status --- + status_table = Table( + show_header=True, header_style="bold", title=f"Report: {report_name}" + ) + status_table.add_column("Status") + status_table.add_column("Count", justify="right") + + for status in ["PASS", "FAIL", "MANUAL"]: + count = latest["statuses"].get(status, 0) + style = "red" if status == "FAIL" and count > 0 else "" + status_table.add_row( + f"[{style}]{status}[/{style}]" if style else status, + f"[{style}]{count}[/{style}]" if style else str(count), + ) + + muted_count = len(latest["muted"]) + unmuted_count = len(latest["unmuted"]) + status_table.add_row("", "") + status_table.add_row("[dim]↳ muted[/dim]", f"[dim]{muted_count}[/dim]") + status_table.add_row( + "[bold]↳ unmuted (action needed)[/bold]", + f"[bold red]{unmuted_count}[/bold red]" + if unmuted_count > 0 + else "[bold green]0[/bold green]", + ) + status_table.add_row("", "") + status_table.add_row("[bold]Total[/bold]", f"[bold]{latest['total']}[/bold]") + + console.print(status_table) + console.print() + + # --- Unmuted failures by severity --- + if latest["unmuted"]: + sev_table = Table( + show_header=True, + header_style="bold", + title="Unmuted Failures by Severity", + ) + sev_table.add_column("Severity") + sev_table.add_column("Count", justify="right") + + for sev, count in sorted( + Counter(r["SEVERITY"] for r in latest["unmuted"]).items(), + key=lambda kv: severity_sort({"SEVERITY": kv[0]}), + ): + style = _sev_style(sev) + sev_table.add_row( + f"[{style}]{sev}[/{style}]" if style else sev, f"[{style}]{count}[/{style}]" if style else str(count), ) - fail_count = len(latest["fails"]) - muted_count = len(latest["muted"]) - unmuted_count = len(latest["unmuted"]) - status_table.add_row("", "") - status_table.add_row("[dim]↳ muted[/dim]", f"[dim]{muted_count}[/dim]") - status_table.add_row( - "[bold]↳ unmuted (action needed)[/bold]", - f"[bold red]{unmuted_count}[/bold red]" - if unmuted_count > 0 - else "[bold green]0[/bold green]", - ) - status_table.add_row("", "") - status_table.add_row("[bold]Total[/bold]", f"[bold]{latest['total']}[/bold]") - - console.print(status_table) + console.print(sev_table) console.print() - # --- Unmuted failures by severity --- - if latest["unmuted"]: - sev_table = Table( - show_header=True, - header_style="bold", - title="Unmuted Failures by Severity", + # --- Delta from previous report --- + if prev_local: + prev = parse_findings(load_csv(str(prev_local))) + + prev_keys = {finding_key(r): r for r in prev["unmuted"]} + curr_keys = {finding_key(r): r for r in latest["unmuted"]} + + new_keys = set(curr_keys.keys()) - set(prev_keys.keys()) + resolved_keys = set(prev_keys.keys()) - set(curr_keys.keys()) + + prev_name = Path(csvs[-2]).stem + delta_lines = [ + f"Compared against: [dim]{prev_name}[/dim]", + "", + f"Previous unmuted FAILs: {len(prev['unmuted'])}", + f"Current unmuted FAILs: {len(latest['unmuted'])}", + f"[green]Resolved: {len(resolved_keys)}[/green]", + f"[red]New: {len(new_keys)}[/red]" + if new_keys + else "[green]New: 0[/green]", + ] + + console.print( + Panel( + "\n".join(delta_lines), + title="[bold]Week-over-Week Delta (unmuted only)[/bold]", + border_style="cyan", ) - sev_table.add_column("Severity") - sev_table.add_column("Count", justify="right") - - for sev, count in Counter( - r["SEVERITY"] for r in latest["unmuted"] - ).most_common(): - style = ( - "bold red" - if sev == "critical" - else "red" - if sev == "high" - else "yellow" - if sev == "medium" - else "" - ) - sev_table.add_row( - f"[{style}]{sev}[/{style}]" if style else sev, - f"[{style}]{count}[/{style}]" if style else str(count), - ) - - console.print(sev_table) - console.print() - - # --- Delta from previous report --- - if prev_local: - prev = parse_findings(load_csv(str(prev_local))) - - prev_keys = {finding_key(r): r for r in prev["unmuted"]} - curr_keys = {finding_key(r): r for r in latest["unmuted"]} - - new_keys = set(curr_keys.keys()) - set(prev_keys.keys()) - resolved_keys = set(prev_keys.keys()) - set(curr_keys.keys()) - - prev_name = Path(csvs[-2]).stem - delta_lines = [ - f"Compared against: [dim]{prev_name}[/dim]", - "", - f"Previous unmuted FAILs: {len(prev['unmuted'])}", - f"Current unmuted FAILs: {len(latest['unmuted'])}", - f"[green]Resolved: {len(resolved_keys)}[/green]", - f"[red]New: {len(new_keys)}[/red]" - if new_keys - else f"[green]New: 0[/green]", - ] - - console.print( - Panel( - "\n".join(delta_lines), - title="[bold]Week-over-Week Delta (unmuted only)[/bold]", - border_style="cyan", - ) - ) - console.print() + ) + console.print() + # For grouped scans the new/resolved listings are too noisy + # (potentially thousands of lines). Skip the listings; the count + # is in the panel above and detail is in the grouped tables. + if not group_findings: if new_keys: console.print("[bold red]New Unmuted Failures:[/bold red]") for k in sorted(new_keys): @@ -456,85 +479,177 @@ def main( ) console.print() - # --- Unmuted failure details --- - findings_to_show = latest["unmuted"] if full else [] - if not full and latest["unmuted"]: - findings_to_show = latest["unmuted"] - - if findings_to_show: - detail_table = Table( - show_header=True, - header_style="bold", - title="Unmuted Failures — Action Needed", - ) - detail_table.add_column("Severity") - detail_table.add_column("Check") - detail_table.add_column("Resource") - detail_table.add_column("Detail", max_width=60) - - for r in sorted(findings_to_show, key=severity_sort): - sev = r["SEVERITY"] - style = ( - "bold red" - if sev == "critical" - else "red" - if sev == "high" - else "yellow" - if sev == "medium" - else "" - ) - detail_table.add_row( - f"[{style}]{sev}[/{style}]" if style else sev, - r["CHECK_ID"], - r.get("RESOURCE_NAME", ""), - r["STATUS_EXTENDED"][:60], - ) - - console.print(detail_table) - console.print() - - # --- Muted findings summary --- - if show_muted and latest["muted"]: - muted_table = Table( - show_header=True, - header_style="bold", - title="Muted Failures (for reference)", - ) - muted_table.add_column("Severity") - muted_table.add_column("Check") - muted_table.add_column("Count", justify="right") - - muted_groups: dict[tuple[str, str], int] = Counter() - for r in latest["muted"]: - muted_groups[(r["SEVERITY"], r["CHECK_ID"])] += 1 - - for (sev, check), count in sorted( - muted_groups.items(), key=lambda x: severity_sort({"SEVERITY": x[0][0]}) - ): - muted_table.add_row(f"[dim]{sev}[/dim]", f"[dim]{check}[/dim]", f"[dim]{count}[/dim]") - - console.print(muted_table) - console.print() - - # --- Verdict --- - if not latest["unmuted"]: - console.print( - Panel( - "[bold green]All clear.[/bold green] No unmuted failures.", - title="Prowler Verdict", - border_style="green", - ) - ) + # --- Unmuted failure details (grouped or per-finding) --- + if latest["unmuted"]: + if group_findings: + _print_grouped_findings(latest["unmuted"]) else: - console.print( - Panel( - f"[bold yellow]{len(latest['unmuted'])} unmuted failure(s) " - f"need triage.[/bold yellow]\n\n" - "For each: remediate (fix the pod spec) or mute " - "(add to mutelist + compensating control).", - title="Prowler Verdict", - border_style="yellow", - ) + _print_findings_detail(latest["unmuted"]) + + # --- Muted findings summary --- + if show_muted and latest["muted"]: + muted_table = Table( + show_header=True, + header_style="bold", + title="Muted Failures (for reference)", + ) + muted_table.add_column("Severity") + muted_table.add_column("Check") + muted_table.add_column("Count", justify="right") + + muted_groups: dict[tuple[str, str], int] = Counter() + for r in latest["muted"]: + muted_groups[(r["SEVERITY"], r["CHECK_ID"])] += 1 + + for (sev, check), count in sorted( + muted_groups.items(), + key=lambda x: severity_sort({"SEVERITY": x[0][0]}), + ): + muted_table.add_row( + f"[dim]{sev}[/dim]", + f"[dim]{check}[/dim]", + f"[dim]{count}[/dim]", + ) + + console.print(muted_table) + console.print() + + # --- Verdict --- + if not latest["unmuted"]: + console.print( + Panel( + "[bold green]All clear.[/bold green] No unmuted failures.", + title=f"{label} Verdict", + border_style="green", + ) + ) + else: + console.print( + Panel( + f"[bold yellow]{len(latest['unmuted'])} unmuted failure(s) " + f"need triage.[/bold yellow]\n\n" + "For each: remediate or mute " + "(add to mutelist + compensating control).", + title=f"{label} Verdict", + border_style="yellow", + ) + ) + console.print() + + +def _print_findings_detail(unmuted: list[dict]) -> None: + """Per-finding detail table — appropriate when finding count is small.""" + detail_table = Table( + show_header=True, + header_style="bold", + title="Unmuted Failures — Action Needed", + ) + detail_table.add_column("Severity") + detail_table.add_column("Check") + detail_table.add_column("Resource") + detail_table.add_column("Detail", max_width=60) + + for r in sorted(unmuted, key=severity_sort): + sev = r["SEVERITY"] + style = _sev_style(sev) + detail_table.add_row( + f"[{style}]{sev}[/{style}]" if style else sev, + r["CHECK_ID"], + r.get("RESOURCE_NAME", ""), + r["STATUS_EXTENDED"][:60], + ) + + console.print(detail_table) + console.print() + + +def _worst_severity(rows: list[dict]) -> str: + """Return the most severe severity label across `rows`.""" + if not rows: + return "" + return min( + (r["SEVERITY"] for r in rows), + key=lambda s: severity_sort({"SEVERITY": s}), + ) + + +def _print_grouped_findings(unmuted: list[dict], top_n: int = 15) -> None: + """Top-N tables grouped by CHECK_ID and RESOURCE_NAME. + + Used for image and IaC scans where per-finding tables would be too + large to be useful. Shows count and worst severity for each group. + """ + by_check: dict[str, list[dict]] = {} + by_resource: dict[str, list[dict]] = {} + for r in unmuted: + by_check.setdefault(r["CHECK_ID"], []).append(r) + by_resource.setdefault(r.get("RESOURCE_NAME", "") or "(no resource)", []).append(r) + + check_table = Table( + show_header=True, + header_style="bold", + title=f"Top {top_n} Checks by Unmuted Finding Count", + ) + check_table.add_column("Worst Sev") + check_table.add_column("Check ID") + check_table.add_column("Count", justify="right") + + for check, rows in sorted( + by_check.items(), key=lambda kv: -len(kv[1]) + )[:top_n]: + worst = _worst_severity(rows) + style = _sev_style(worst) + check_table.add_row( + f"[{style}]{worst}[/{style}]" if style else worst, + check, + str(len(rows)), + ) + + console.print(check_table) + console.print() + + res_table = Table( + show_header=True, + header_style="bold", + title=f"Top {top_n} Resources by Unmuted Finding Count", + ) + res_table.add_column("Worst Sev") + res_table.add_column("Resource") + res_table.add_column("Count", justify="right") + + for resource, rows in sorted( + by_resource.items(), key=lambda kv: -len(kv[1]) + )[:top_n]: + worst = _worst_severity(rows) + style = _sev_style(worst) + res_table.add_row( + f"[{style}]{worst}[/{style}]" if style else worst, + resource[:80], + str(len(rows)), + ) + + console.print(res_table) + console.print() + + +def main( + full: Annotated[ + bool, typer.Option(help="(reserved) currently a no-op; all unmuted failures already shown") + ] = False, + show_muted: Annotated[ + bool, typer.Option(help="Also show muted failures") + ] = False, +) -> None: + del full # historical flag, kept for backwards compatibility + + with tempfile.TemporaryDirectory() as tmpdir: + for label, base, group in PROWLER_SCANS: + summarize_report( + label, + base, + tmpdir, + show_muted=show_muted, + group_findings=group, ) # --- Node-level MANUAL check verification --- From 495e45d01dc3aa6d42124a4f3ca88a6816cfe9bb Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 29 Apr 2026 10:43:32 -0700 Subject: [PATCH 337/430] Address 6 critical Prowler IaC findings (mute + grafana RBAC tighten) (#340) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The weekly Prowler IaC scan reported 6 critical findings against `argocd/manifests/`. They split cleanly into two patterns: - **Legitimate-by-design RBAC → mute with new compensating controls** - `external-secrets-controller`, `external-secrets-cert-controller` manage `secrets` (KSV-0041) and the cert-controller mutates its own webhook configurations (KSV-0114). This is what the operator is *for*. New CC: `operator-purpose-bound-rbac`. - `kube-state-metrics` (both `minikube-indri` and `k3s-ringtail`) holds `list/watch` on secrets to expose `kube_secret_info` and `kube_secret_labels` metrics. KSM's metric schema only reads metadata, never the `data:` field. New CC: `kube-state-metrics-metadata-only`. - **Over-broad RBAC → fix** - `grafana-clusterrole` had `get/watch/list` on `secrets` because the dashboard-sidecar config used `RESOURCE=both` (ConfigMaps + Secrets). Nothing in the cluster labels Secrets with `grafana_dashboard=1`, so this was unused power. Switched both sidecar instances to `RESOURCE=configmap` and removed `secrets` from the ClusterRole. The IaC cronjob also did not previously pass `--mutelist-file`, which is why every IaC finding reported as unmuted regardless of mutelist configuration. The new `mutelist/iac.yaml` is bundled into the existing `prowler-mutelist` ConfigMap and mounted via `items:` selector. ## Test plan - [ ] `kubectl --context=minikube-indri kustomize argocd/manifests/prowler/` — already passes locally - [ ] `kubectl --context=minikube-indri kustomize argocd/manifests/grafana/` — already passes locally - [ ] Deploy from this branch via `argocd app set prowler --revision prowler-iac-mutelist && argocd app sync prowler` and same for `grafana` - [ ] Manually trigger the IaC cronjob and verify `MUTED=True` on the 6 critical findings (`kubectl --context=minikube-indri -n prowler create job --from=cronjob/prowler-iac-scan prowler-iac-test`) - [ ] Restart grafana pod and confirm dashboards still render (sidecar still finds them via ConfigMap watch) - [ ] After verify, `argocd app set --revision main && argocd app sync ` post-merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/340 --- argocd/manifests/grafana/deployment.yaml | 6 ++- argocd/manifests/grafana/rbac.yaml | 2 +- .../manifests/prowler/cronjob-iac-scan.yaml | 16 ++++++++ argocd/manifests/prowler/kustomization.yaml | 3 +- .../prowler/mutelist/trivyignore.yaml | 39 +++++++++++++++++++ compensating-controls.yaml | 36 +++++++++++++++++ containers/prowler/Dockerfile | 20 +++++++++- .../changelog.d/prowler-iac-mutelist.infra.md | 1 + 8 files changed, 118 insertions(+), 5 deletions(-) create mode 100644 argocd/manifests/prowler/mutelist/trivyignore.yaml create mode 100644 docs/changelog.d/prowler-iac-mutelist.infra.md diff --git a/argocd/manifests/grafana/deployment.yaml b/argocd/manifests/grafana/deployment.yaml index 848503e..0aad9b3 100644 --- a/argocd/manifests/grafana/deployment.yaml +++ b/argocd/manifests/grafana/deployment.yaml @@ -156,7 +156,9 @@ spec: - name: FOLDER value: /tmp/dashboards - name: RESOURCE - value: both + # ConfigMap-only — no dashboards are sourced from Secrets, + # so the ServiceAccount has no read access to secrets. + value: configmap - name: FOLDER_ANNOTATION value: grafana_folder securityContext: @@ -183,7 +185,7 @@ spec: - name: FOLDER value: /tmp/dashboards - name: RESOURCE - value: both + value: configmap - name: FOLDER_ANNOTATION value: grafana_folder - name: REQ_USERNAME diff --git a/argocd/manifests/grafana/rbac.yaml b/argocd/manifests/grafana/rbac.yaml index d0d0c843..1c2dee3 100644 --- a/argocd/manifests/grafana/rbac.yaml +++ b/argocd/manifests/grafana/rbac.yaml @@ -7,7 +7,7 @@ metadata: app.kubernetes.io/instance: grafana rules: - apiGroups: [""] - resources: ["configmaps", "secrets"] + resources: ["configmaps"] verbs: ["get", "watch", "list"] --- apiVersion: rbac.authorization.k8s.io/v1 diff --git a/argocd/manifests/prowler/cronjob-iac-scan.yaml b/argocd/manifests/prowler/cronjob-iac-scan.yaml index 49c8ce6..c1303a5 100644 --- a/argocd/manifests/prowler/cronjob-iac-scan.yaml +++ b/argocd/manifests/prowler/cronjob-iac-scan.yaml @@ -19,6 +19,13 @@ spec: - 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) @@ -31,8 +38,17 @@ spec: 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 diff --git a/argocd/manifests/prowler/kustomization.yaml b/argocd/manifests/prowler/kustomization.yaml index 7024aff..cf644dc 100644 --- a/argocd/manifests/prowler/kustomization.yaml +++ b/argocd/manifests/prowler/kustomization.yaml @@ -23,7 +23,8 @@ configMapGenerator: - mutelist/core-pod-security.yaml - mutelist/manual-node-checks.yaml - mutelist/rbac.yaml + - mutelist/trivyignore.yaml images: - name: registry.ops.eblu.me/blumeops/prowler - newTag: v5.23.0-7c1cd11 + newTag: v5.23.0-2daf629 diff --git a/argocd/manifests/prowler/mutelist/trivyignore.yaml b/argocd/manifests/prowler/mutelist/trivyignore.yaml new file mode 100644 index 0000000..22c612a --- /dev/null +++ b/argocd/manifests/prowler/mutelist/trivyignore.yaml @@ -0,0 +1,39 @@ +# 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: >- + CC: operator-purpose-bound-rbac. 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: >- + CC: kube-state-metrics-metadata-only. 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: >- + CC: operator-purpose-bound-rbac. 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. diff --git a/compensating-controls.yaml b/compensating-controls.yaml index 67bbf75..d9d7c6c 100644 --- a/compensating-controls.yaml +++ b/compensating-controls.yaml @@ -139,6 +139,42 @@ controls: MANUAL findings appear in Prowler, add corresponding verification logic to the script and update the mutelist. + - id: operator-purpose-bound-rbac + description: >- + Operators whose entire function is to manage a sensitive resource + legitimately need RBAC over that resource. external-secrets-operator + manages Secret objects (its purpose) and the cert-controller mutates + its own ValidatingWebhookConfigurations to inject rotating CA bundles. + Risk is bounded by: (1) the operator code being upstream open-source + and reviewed; (2) RBAC scoped to specific named webhooks where + possible; (3) supply chain controls on the operator image (mirrored + to local registry, version tracked in service-versions.yaml). + created: 2026-04-27 + last-reviewed: 2026-04-27 + notes: >- + Verify by checking that the operators in question still match their + stated purpose (i.e. external-secrets is still the only consumer of + these ClusterRoles) and that upstream hasn't published advisories + for credential-handling bugs. Re-evaluate if a non-secrets-managing + ClusterRole appears under this control. + + - id: kube-state-metrics-metadata-only + description: >- + kube-state-metrics holds list/watch on Secrets cluster-wide but only + exposes Secret object *metadata* (name, namespace, type, creation + timestamp, labels) via the kube_secret_info / kube_secret_labels + metrics. Secret data fields are never read into KSM's exposed + metrics by upstream design. Mitigation rests on KSM's metric + schema, the version pin in service-versions.yaml, and the metrics + endpoint being reachable only on the cluster network. + created: 2026-04-27 + last-reviewed: 2026-04-27 + notes: >- + Verify by inspecting the /metrics endpoint output for any series + that include secret data (only *_info and *_labels metrics should + reference secrets, and labels should be limited to user-applied + labels — never the data:). Re-evaluate on KSM version bumps. + - id: observability-stack-audit description: >- Alloy collects pod logs and ships them to Loki, providing an diff --git a/containers/prowler/Dockerfile b/containers/prowler/Dockerfile index bd74bdb..c5157cb 100644 --- a/containers/prowler/Dockerfile +++ b/containers/prowler/Dockerfile @@ -44,10 +44,28 @@ RUN ARCH=$(dpkg --print-architecture) \ && apt-get update && apt-get install -y --no-install-recommends wget ca-certificates \ && wget -q "https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_${TRIVY_ARCH}.tar.gz" -O /tmp/trivy.tar.gz \ && tar xzf /tmp/trivy.tar.gz -C /usr/local/bin trivy \ - && chmod +x /usr/local/bin/trivy \ + && mv /usr/local/bin/trivy /usr/local/bin/trivy.real \ + && chmod +x /usr/local/bin/trivy.real \ && rm /tmp/trivy.tar.gz \ && apt-get purge -y wget && apt-get autoremove -y && rm -rf /var/lib/apt/lists/* +# Shim: Prowler's IaC provider invokes `trivy fs` directly with no +# --ignorefile flag, so any TRIVY_IGNOREFILE the user sets is ignored. +# This wrapper injects --ignorefile when the env var points at a real +# file and the invocation is `trivy fs ...`. Other subcommands and +# global-only invocations (--version, --help) pass through unchanged. +# TODO(upstream): contribute --ignorefile plumbing to prowler-cloud/prowler +# iac_provider.py so this shim isn't necessary. +RUN printf '%s\n' \ + '#!/bin/sh' \ + 'if [ "${1:-}" = "fs" ] && [ -n "${TRIVY_IGNOREFILE:-}" ] && [ -f "${TRIVY_IGNOREFILE}" ]; then' \ + ' shift' \ + ' exec /usr/local/bin/trivy.real fs --ignorefile "${TRIVY_IGNOREFILE}" "$@"' \ + 'fi' \ + 'exec /usr/local/bin/trivy.real "$@"' \ + > /usr/local/bin/trivy \ + && chmod +x /usr/local/bin/trivy + RUN addgroup --gid 1000 prowler \ && adduser --uid 1000 --gid 1000 --disabled-password --gecos "" prowler \ && mkdir -p /tmp/.cache/trivy && chown prowler:prowler /tmp/.cache/trivy diff --git a/docs/changelog.d/prowler-iac-mutelist.infra.md b/docs/changelog.d/prowler-iac-mutelist.infra.md new file mode 100644 index 0000000..793c1ec --- /dev/null +++ b/docs/changelog.d/prowler-iac-mutelist.infra.md @@ -0,0 +1 @@ +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. Two new compensating controls — `operator-purpose-bound-rbac` and `kube-state-metrics-metadata-only` — justify 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`. From 4d76fd5de5774013b997c7c8d9cf4623d3fe526c Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 29 Apr 2026 10:49:27 -0700 Subject: [PATCH 338/430] =?UTF-8?q?C0:=20prowler=20=E2=80=94=20rebuild=20i?= =?UTF-8?q?mage=20against=20main=20HEAD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squash-merge of #340 changed the SHA. Bump prowler tag from v5.23.0-2daf629 (PR branch) to v5.23.0-495e45d (main HEAD) so the Dockerfile changes are present in the image deployed off main. Co-Authored-By: Claude Opus 4.7 (1M context) --- argocd/manifests/prowler/kustomization.yaml | 2 +- docs/changelog.d/+prowler-rebuild-on-main.infra.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 docs/changelog.d/+prowler-rebuild-on-main.infra.md diff --git a/argocd/manifests/prowler/kustomization.yaml b/argocd/manifests/prowler/kustomization.yaml index cf644dc..1d92a6b 100644 --- a/argocd/manifests/prowler/kustomization.yaml +++ b/argocd/manifests/prowler/kustomization.yaml @@ -27,4 +27,4 @@ configMapGenerator: images: - name: registry.ops.eblu.me/blumeops/prowler - newTag: v5.23.0-2daf629 + newTag: v5.23.0-495e45d diff --git a/docs/changelog.d/+prowler-rebuild-on-main.infra.md b/docs/changelog.d/+prowler-rebuild-on-main.infra.md new file mode 100644 index 0000000..107b687 --- /dev/null +++ b/docs/changelog.d/+prowler-rebuild-on-main.infra.md @@ -0,0 +1 @@ +Rebuild Prowler container against main HEAD (v5.23.0-495e45d) after merging the IaC mutelist Dockerfile changes. From 817acc5e5eaa0db51276231fcb22af897391a5ab Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 29 Apr 2026 11:00:01 -0700 Subject: [PATCH 339/430] =?UTF-8?q?C0:=20transmission=20doc=20=E2=80=94=20?= =?UTF-8?q?review=20and=20correct=20storage/monitoring=20details?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marked last-reviewed: 2026-04-29. Fixed the storage layout table — `/config/` is an emptyDir (ephemeral), not NFS, and the watch directory is disabled. Documented the transmission-exporter sidecar that exposes Prometheus metrics on port 19091. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../+transmission-doc-review.doc.md | 1 + docs/reference/services/transmission.md | 18 +++++++++--------- 2 files changed, 10 insertions(+), 9 deletions(-) create mode 100644 docs/changelog.d/+transmission-doc-review.doc.md diff --git a/docs/changelog.d/+transmission-doc-review.doc.md b/docs/changelog.d/+transmission-doc-review.doc.md new file mode 100644 index 0000000..418504f --- /dev/null +++ b/docs/changelog.d/+transmission-doc-review.doc.md @@ -0,0 +1 @@ +Reviewed transmission card: corrected storage layout (`/config/` is emptyDir, watch dir disabled) and noted the Prometheus exporter sidecar. diff --git a/docs/reference/services/transmission.md b/docs/reference/services/transmission.md index 3676177..89904ce 100644 --- a/docs/reference/services/transmission.md +++ b/docs/reference/services/transmission.md @@ -1,6 +1,7 @@ --- title: Transmission -modified: 2026-02-07 +modified: 2026-04-29 +last-reviewed: 2026-04-29 tags: - service - torrent @@ -22,14 +23,13 @@ BitTorrent daemon, primarily for downloading ZIM archives for [[kiwix]]. ## Storage Layout -NFS share on sifaka (`/volume1/torrents`): +| Path | Backing | Purpose | +|------|---------|---------| +| `/downloads/incomplete/` | NFS (`sifaka:/volume1/torrents`) | Active downloads | +| `/downloads/complete/` | NFS (`sifaka:/volume1/torrents`) | Completed downloads | +| `/config/` | `emptyDir` (ephemeral) | Transmission `settings.json`, regenerated on pod start | -| Path | Purpose | -|------|---------| -| `/downloads/` | Active downloads and metadata | -| `/downloads/complete/` | Completed downloads | -| `/config/` | Transmission configuration | -| `/watch/` | Watch directory for .torrent files | +The watch directory is disabled (`watch-dir-enabled: false`); torrents are added via RPC (see Kiwix integration below). [[kiwix]] reads from `/downloads/complete/` to serve ZIM archives. @@ -44,7 +44,7 @@ When downloads complete, the zim-watcher CronJob detects new ZIMs and restarts K ## Monitoring -Basic uptime via blackbox probe in [[alloy|Alloy]] k8s (Services Health dashboard). +A `transmission-exporter` sidecar (image `registry.ops.eblu.me/blumeops/transmission-exporter`) scrapes the local RPC and exposes Prometheus metrics on port 19091. Uptime is also covered by a blackbox probe in [[alloy|Alloy]] k8s (Services Health dashboard). Web UI shows: active/seeding/paused counts, speeds, disk usage. From f4a24595b124cb21fcdcbf95ac9ddbdff3901caa Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 29 Apr 2026 11:09:34 -0700 Subject: [PATCH 340/430] C0: review CC ephemeral-privileged-jobs Verified TTL=604800s and hostPID limited to ephemeral Prowler CronJob on indri. Noted that alloy-tracing on ringtail also uses hostPID but is out of scope until Prowler scans ringtail (tracked in Todoist). Co-Authored-By: Claude Opus 4.7 (1M context) --- compensating-controls.yaml | 9 +++++++-- .../+review-cc-ephemeral-privileged-jobs.misc.md | 1 + 2 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 docs/changelog.d/+review-cc-ephemeral-privileged-jobs.misc.md diff --git a/compensating-controls.yaml b/compensating-controls.yaml index d9d7c6c..fb5450d 100644 --- a/compensating-controls.yaml +++ b/compensating-controls.yaml @@ -94,10 +94,15 @@ controls: auto-deletion, not as a persistent privileged workload. hostPID exposure is time-bounded to scan duration (~20s). created: 2026-03-30 - last-reviewed: 2026-03-30 + last-reviewed: 2026-04-29 notes: >- Verify TTL is set in cronjob.yaml. Check that no persistent - pods run with hostPID. + pods run with hostPID on the scanned cluster (indri). The + alloy-tracing DaemonSet on ringtail also uses hostPID but is + out of scope — Prowler only scans indri. Tracked in Todoist: + "prowler scan against ringtail" — once that lands, the + DaemonSet's hostPID+privileged posture will surface as a CIS + finding and need its own CC or remediation. - id: trusted-ci-only description: >- diff --git a/docs/changelog.d/+review-cc-ephemeral-privileged-jobs.misc.md b/docs/changelog.d/+review-cc-ephemeral-privileged-jobs.misc.md new file mode 100644 index 0000000..14dcdca --- /dev/null +++ b/docs/changelog.d/+review-cc-ephemeral-privileged-jobs.misc.md @@ -0,0 +1 @@ +Reviewed compensating control `ephemeral-privileged-jobs`: TTL and hostPID scope verified on indri. Noted that the alloy-tracing DaemonSet on ringtail is out of scope until Prowler scans ringtail (tracked in Todoist). From 14ca0160ba5f76ab8ad348f64f68c75fb6ec3659 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 29 Apr 2026 13:38:36 -0700 Subject: [PATCH 341/430] Migrate devpi from minikube to indri (launchd) (#341) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Devpi was crash-looping under memory pressure on the minikube StatefulSet, breaking the Python toolchain across the repo (`mise run docs-mikado`, `prek`, every `uv pip install`). It moves to indri as a native LaunchAgent. ## What changed - **New ansible role** `ansible/roles/devpi/`: installs `devpi-server` + `devpi-web` into a uv-managed venv, initializes the server-dir on first run via 1Password root password, runs as a LaunchAgent (`mcquack.eblume.devpi`) bound to `127.0.0.1:3141`. Bootstraps from upstream PyPI (so devpi can install itself on a fresh box). - **Caddy**: `pypi.ops.eblu.me` now proxies to `http://localhost:3141`. - **Playbook**: `indri.yml` gains pre_tasks for the root password and the new role. - **service-versions.yaml**: devpi flipped from `type: argocd` to `type: ansible`. - **ArgoCD**: removed `apps/devpi.yaml` and `manifests/devpi/`. The in-cluster Application, namespace, and PVC have been deleted. - **Docs**: new how-to `docs/how-to/operations/devpi-on-indri.md`; `restart-indri.md` lists devpi in the LaunchAgent stop list. ## Already deployed (live on indri) - Service running: `launchctl list mcquack.eblume.devpi` → PID 53888 - `curl https://pypi.ops.eblu.me/+api` returns 200 ✅ - `mise run docs-mikado` works again ✅ - 1.0G of cached PyPI data was migrated from the PVC to `~erichblume/devpi/server-dir/` - Minikube namespace and PVC fully reclaimed ## Test plan - [ ] `mise run services-check` (after merge) - [ ] CI workflows that use devpi succeed - [ ] No regressions in tools that depend on `pypi.ops.eblu.me` (prek, uv-script tasks, dagger pipelines) ## Context This is the C1 prelude to a planned C2 chain (`mikado/retire-minikube-indri`) to retire minikube on indri entirely. Doing devpi as a standalone C1 was the right call because (a) it was urgent — it was breaking the toolchain — and (b) it shakes out the migration recipe before we commit to a multi-leaf chain. Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/341 --- ansible/playbooks/indri.yml | 19 +++++ ansible/roles/caddy/defaults/main.yml | 2 +- ansible/roles/devpi/defaults/main.yml | 21 ++++++ ansible/roles/devpi/handlers/main.yml | 6 ++ ansible/roles/devpi/tasks/main.yml | 71 ++++++++++++++++++ ansible/roles/devpi/templates/devpi.plist.j2 | 34 +++++++++ argocd/apps/devpi.yaml | 29 -------- argocd/manifests/alloy-k8s/config.alloy | 4 +- argocd/manifests/devpi/README.md | 72 ------------------ argocd/manifests/devpi/external-secret.yaml | 25 ------- argocd/manifests/devpi/ingress-tailscale.yaml | 25 ------- argocd/manifests/devpi/kustomization.yaml | 14 ---- argocd/manifests/devpi/service.yaml | 13 ---- argocd/manifests/devpi/statefulset.yaml | 64 ---------------- .../migrate-devpi-to-indri.infra.md | 1 + docs/how-to/operations/devpi-on-indri.md | 74 +++++++++++++++++++ .../operations/rebuild-minikube-cluster.md | 20 +---- docs/how-to/operations/restart-indri.md | 1 + docs/reference/infrastructure/tailscale.md | 2 +- docs/reference/services/devpi.md | 34 +++++---- docs/reference/storage/backups.md | 2 +- pulumi/tailscale/__main__.py | 2 +- pulumi/tailscale/policy.hujson | 9 +-- service-versions.yaml | 5 +- 24 files changed, 260 insertions(+), 289 deletions(-) create mode 100644 ansible/roles/devpi/defaults/main.yml create mode 100644 ansible/roles/devpi/handlers/main.yml create mode 100644 ansible/roles/devpi/tasks/main.yml create mode 100644 ansible/roles/devpi/templates/devpi.plist.j2 delete mode 100644 argocd/apps/devpi.yaml delete mode 100644 argocd/manifests/devpi/README.md delete mode 100644 argocd/manifests/devpi/external-secret.yaml delete mode 100644 argocd/manifests/devpi/ingress-tailscale.yaml delete mode 100644 argocd/manifests/devpi/kustomization.yaml delete mode 100644 argocd/manifests/devpi/service.yaml delete mode 100644 argocd/manifests/devpi/statefulset.yaml create mode 100644 docs/changelog.d/migrate-devpi-to-indri.infra.md create mode 100644 docs/how-to/operations/devpi-on-indri.md diff --git a/ansible/playbooks/indri.yml b/ansible/playbooks/indri.yml index ce6a930..fa87b36 100644 --- a/ansible/playbooks/indri.yml +++ b/ansible/playbooks/indri.yml @@ -212,6 +212,23 @@ no_log: true tags: [forgejo_metrics] + # Devpi root password (PyPI mirror admin) + - name: Fetch devpi root password + ansible.builtin.command: + cmd: op read "op://vg6xf6vvfmoh5hqjjhlhbeoaie/kyhzfifryqnuk7jeyibmmjvxxm/add more/root password" + delegate_to: localhost + register: _devpi_root_password + changed_when: false + no_log: true + check_mode: false + tags: [devpi] + + - name: Set devpi root password fact + ansible.builtin.set_fact: + devpi_root_password: "{{ _devpi_root_password.stdout }}" + no_log: true + tags: [devpi] + roles: - role: alloy tags: alloy @@ -227,6 +244,8 @@ tags: zot - role: zot_metrics tags: zot_metrics + - role: devpi + tags: devpi - role: minikube tags: minikube - role: minikube_metrics diff --git a/ansible/roles/caddy/defaults/main.yml b/ansible/roles/caddy/defaults/main.yml index ebb210b..80993ee 100644 --- a/ansible/roles/caddy/defaults/main.yml +++ b/ansible/roles/caddy/defaults/main.yml @@ -51,7 +51,7 @@ caddy_services: backend: "https://feed.tail8d86e.ts.net" - name: devpi host: "pypi.{{ caddy_domain }}" - backend: "https://pypi.tail8d86e.ts.net" + backend: "http://localhost:3141" - name: kiwix host: "kiwix.{{ caddy_domain }}" backend: "https://kiwix.tail8d86e.ts.net" diff --git a/ansible/roles/devpi/defaults/main.yml b/ansible/roles/devpi/defaults/main.yml new file mode 100644 index 0000000..6d52b9b --- /dev/null +++ b/ansible/roles/devpi/defaults/main.yml @@ -0,0 +1,21 @@ +--- +# devpi PyPI caching mirror (native launchd, replaces minikube StatefulSet) + +devpi_home: /Users/erichblume/devpi +devpi_venv: "{{ devpi_home }}/venv" +devpi_server_dir: "{{ devpi_home }}/server-dir" +devpi_binary: "{{ devpi_venv }}/bin/devpi-server" +devpi_init_binary: "{{ devpi_venv }}/bin/devpi-init" + +devpi_python_version: "3.12" +devpi_server_version: "6.19.3" +devpi_web_version: "5.0.2" + +devpi_host: 127.0.0.1 +devpi_port: 3141 +devpi_outside_url: "https://pypi.ops.eblu.me" + +devpi_log_dir: /Users/erichblume/Library/Logs + +# uv binary on indri — mise shim so version bumps via `mise upgrade uv` flow through transparently +devpi_uv_binary: /Users/erichblume/.local/share/mise/shims/uv diff --git a/ansible/roles/devpi/handlers/main.yml b/ansible/roles/devpi/handlers/main.yml new file mode 100644 index 0000000..2765850 --- /dev/null +++ b/ansible/roles/devpi/handlers/main.yml @@ -0,0 +1,6 @@ +--- +- name: Restart devpi + ansible.builtin.shell: | + launchctl unload ~/Library/LaunchAgents/mcquack.eblume.devpi.plist 2>/dev/null || true + launchctl load ~/Library/LaunchAgents/mcquack.eblume.devpi.plist + changed_when: true diff --git a/ansible/roles/devpi/tasks/main.yml b/ansible/roles/devpi/tasks/main.yml new file mode 100644 index 0000000..985ca46 --- /dev/null +++ b/ansible/roles/devpi/tasks/main.yml @@ -0,0 +1,71 @@ +--- +# devpi role — devpi-server in a uv-managed venv, run via LaunchAgent. +# Replaces the prior minikube StatefulSet; see [[devpi-on-indri]]. +# +# The root password is fetched in the indri.yml playbook pre_tasks and +# exposed as `devpi_root_password`. + +- name: Ensure devpi home exists + ansible.builtin.file: + path: "{{ devpi_home }}" + state: directory + mode: '0755' + +- name: Ensure devpi server-dir exists + ansible.builtin.file: + path: "{{ devpi_server_dir }}" + state: directory + mode: '0700' + +- name: Create devpi venv if missing + ansible.builtin.command: + cmd: "{{ devpi_uv_binary }} venv --python {{ devpi_python_version }} {{ devpi_venv }}" + creates: "{{ devpi_venv }}/bin/python" + +- name: Install devpi-server and devpi-web into venv + # Always bootstrap from upstream PyPI — devpi is the index it would otherwise resolve through, + # and that's a circular dependency (devpi cannot install itself from itself). + ansible.builtin.command: + cmd: >- + {{ devpi_uv_binary }} pip install + --python {{ devpi_venv }}/bin/python + --index-url https://pypi.org/simple/ + devpi-server=={{ devpi_server_version }} + devpi-web=={{ devpi_web_version }} + register: devpi_pip_install + changed_when: "'Installed' in devpi_pip_install.stdout or 'Uninstalled' in devpi_pip_install.stdout" + notify: Restart devpi + +- name: Check if devpi server-dir is initialized + ansible.builtin.stat: + path: "{{ devpi_server_dir }}/.serverversion" + register: devpi_serverversion + +- name: Initialize devpi server-dir + ansible.builtin.command: + cmd: >- + {{ devpi_init_binary }} + --serverdir {{ devpi_server_dir }} + --root-passwd {{ devpi_root_password }} + when: not devpi_serverversion.stat.exists + changed_when: true + no_log: true + +- name: Deploy devpi LaunchAgent plist + ansible.builtin.template: + src: devpi.plist.j2 + dest: ~/Library/LaunchAgents/mcquack.eblume.devpi.plist + mode: '0644' + notify: Restart devpi + +- name: Check if devpi LaunchAgent is loaded + ansible.builtin.command: launchctl list mcquack.eblume.devpi + register: devpi_launchctl_check + changed_when: false + failed_when: false + +- name: Load devpi LaunchAgent if not loaded + ansible.builtin.command: launchctl load ~/Library/LaunchAgents/mcquack.eblume.devpi.plist + when: devpi_launchctl_check.rc != 0 + changed_when: true + failed_when: false diff --git a/ansible/roles/devpi/templates/devpi.plist.j2 b/ansible/roles/devpi/templates/devpi.plist.j2 new file mode 100644 index 0000000..b9485e6 --- /dev/null +++ b/ansible/roles/devpi/templates/devpi.plist.j2 @@ -0,0 +1,34 @@ + + + + + + Label + mcquack.eblume.devpi + ProgramArguments + + {{ devpi_binary }} + --serverdir + {{ devpi_server_dir }} + --host + {{ devpi_host }} + --port + {{ devpi_port }} + --outside-url + {{ devpi_outside_url }} + + RunAtLoad + + KeepAlive + + EnvironmentVariables + + PATH + {{ devpi_venv }}/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin + + StandardOutPath + {{ devpi_log_dir }}/mcquack.devpi.out.log + StandardErrorPath + {{ devpi_log_dir }}/mcquack.devpi.err.log + + diff --git a/argocd/apps/devpi.yaml b/argocd/apps/devpi.yaml deleted file mode 100644 index 4a15672..0000000 --- a/argocd/apps/devpi.yaml +++ /dev/null @@ -1,29 +0,0 @@ -# devpi PyPI Caching Proxy -# Provides PyPI cache and private package hosting -# -# After first deployment, initialize devpi: -# kubectl -n devpi exec -it devpi-0 -- devpi-init --serverdir /devpi --root-passwd -# kubectl -n devpi rollout restart statefulset devpi -# -# Then create user/index: -# uvx devpi use https://pypi.tail8d86e.ts.net -# uvx devpi login root -# uvx devpi user -c eblume email=blume.erich@gmail.com -# uvx devpi index -c eblume/dev bases=root/pypi -apiVersion: argoproj.io/v1alpha1 -kind: Application -metadata: - name: devpi - namespace: argocd -spec: - project: default - source: - repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git - targetRevision: main - path: argocd/manifests/devpi - destination: - server: https://kubernetes.default.svc - namespace: devpi - syncPolicy: - syncOptions: - - CreateNamespace=true diff --git a/argocd/manifests/alloy-k8s/config.alloy b/argocd/manifests/alloy-k8s/config.alloy index a716ddc..56a2e13 100644 --- a/argocd/manifests/alloy-k8s/config.alloy +++ b/argocd/manifests/alloy-k8s/config.alloy @@ -159,8 +159,10 @@ prometheus.exporter.blackbox "services" { } target { + // devpi runs natively on indri (LaunchAgent), not in-cluster. + // We probe through Caddy (https://pypi.ops.eblu.me) which the cluster can reach via Tailscale. name = "devpi" - address = "http://devpi.devpi.svc.cluster.local:3141/+api" + address = "https://pypi.ops.eblu.me/+api" module = "http_2xx" } diff --git a/argocd/manifests/devpi/README.md b/argocd/manifests/devpi/README.md deleted file mode 100644 index 11fd697..0000000 --- a/argocd/manifests/devpi/README.md +++ /dev/null @@ -1,72 +0,0 @@ -# devpi PyPI Caching Proxy - -devpi-server running in Kubernetes, providing: -- PyPI caching proxy at `root/pypi` -- Private package hosting at `eblume/dev` - -## Setup - -### 1. Create the root password secret - -```fish -kubectl create namespace devpi -op inject -i argocd/manifests/devpi/secret-root.yaml.tpl | kubectl apply -f - -``` - -### 2. Deploy via ArgoCD - -```fish -argocd app sync apps -argocd app sync devpi -``` - -The container will auto-initialize on first startup using the root password from the secret. - -### 3. Create user and index (first time only) - -After the pod is running: - -```fish -# Login to devpi as root -uvx --from devpi-client devpi use https://pypi.tail8d86e.ts.net -uvx --from devpi-client devpi login root -# Enter root password when prompted - -# Create eblume user (prompts for password - use the one from 1Password) -uvx --from devpi-client devpi user -c eblume email=blume.erich@gmail.com - -# Create private index inheriting from PyPI -uvx --from devpi-client devpi index -c eblume/dev bases=root/pypi -``` - -## Usage - -### As pip index (caching proxy) - -Configure `~/.config/pip/pip.conf`: - -```ini -[global] -index-url = https://pypi.tail8d86e.ts.net/root/pypi/+simple/ -trusted-host = pypi.tail8d86e.ts.net -``` - -### Upload private packages - -```fish -cd ~/code/personal/your-package -uv build -uv publish --publish-url https://pypi.tail8d86e.ts.net/eblume/dev/ -``` - -## URLs - -- Web UI: https://pypi.tail8d86e.ts.net -- PyPI cache: https://pypi.tail8d86e.ts.net/root/pypi/+simple/ -- Private index: https://pypi.tail8d86e.ts.net/eblume/dev/+simple/ - -## Credentials - -Stored in 1Password vault `blumeops`, item `kyhzfifryqnuk7jeyibmmjvxxm`: -- `root password` - devpi root user -- `password` - eblume user password diff --git a/argocd/manifests/devpi/external-secret.yaml b/argocd/manifests/devpi/external-secret.yaml deleted file mode 100644 index 290ea67..0000000 --- a/argocd/manifests/devpi/external-secret.yaml +++ /dev/null @@ -1,25 +0,0 @@ -# ExternalSecret for devpi root password -# -# Replaces the manual op inject workflow from secret-root.yaml.tpl -# -# 1Password item: "devpi" in blumeops vault -# Field: "root password" -# -apiVersion: external-secrets.io/v1 -kind: ExternalSecret -metadata: - name: devpi-root - namespace: devpi -spec: - refreshInterval: 1h - secretStoreRef: - kind: ClusterSecretStore - name: onepassword-blumeops - target: - name: devpi-root - creationPolicy: Owner - data: - - secretKey: password - remoteRef: - key: devpi - property: root password diff --git a/argocd/manifests/devpi/ingress-tailscale.yaml b/argocd/manifests/devpi/ingress-tailscale.yaml deleted file mode 100644 index 474bf72..0000000 --- a/argocd/manifests/devpi/ingress-tailscale.yaml +++ /dev/null @@ -1,25 +0,0 @@ -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: devpi-tailscale - namespace: devpi - annotations: - tailscale.com/proxy-class: "default" - tailscale.com/proxy-group: "ingress" - gethomepage.dev/enabled: "true" - gethomepage.dev/name: "PyPI" - gethomepage.dev/group: "Infrastructure" - gethomepage.dev/icon: "pypi.png" - gethomepage.dev/description: "PyPI cache" - gethomepage.dev/href: "https://pypi.ops.eblu.me" - gethomepage.dev/pod-selector: "app=devpi" -spec: - ingressClassName: tailscale - defaultBackend: - service: - name: devpi - port: - number: 3141 - tls: - - hosts: - - pypi diff --git a/argocd/manifests/devpi/kustomization.yaml b/argocd/manifests/devpi/kustomization.yaml deleted file mode 100644 index 2083aaa..0000000 --- a/argocd/manifests/devpi/kustomization.yaml +++ /dev/null @@ -1,14 +0,0 @@ -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization - -namespace: devpi - -resources: - - statefulset.yaml - - service.yaml - - ingress-tailscale.yaml - - external-secret.yaml - -images: - - name: registry.ops.eblu.me/blumeops/devpi - newTag: v6.19.3-37b8a21 diff --git a/argocd/manifests/devpi/service.yaml b/argocd/manifests/devpi/service.yaml deleted file mode 100644 index 42e1543..0000000 --- a/argocd/manifests/devpi/service.yaml +++ /dev/null @@ -1,13 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: devpi - namespace: devpi -spec: - selector: - app: devpi - ports: - - name: http - port: 3141 - targetPort: 3141 - protocol: TCP diff --git a/argocd/manifests/devpi/statefulset.yaml b/argocd/manifests/devpi/statefulset.yaml deleted file mode 100644 index 91875df..0000000 --- a/argocd/manifests/devpi/statefulset.yaml +++ /dev/null @@ -1,64 +0,0 @@ -apiVersion: apps/v1 -kind: StatefulSet -metadata: - name: devpi - namespace: devpi -spec: - serviceName: devpi - replicas: 1 - selector: - matchLabels: - app: devpi - template: - metadata: - labels: - app: devpi - spec: - securityContext: - fsGroup: 1000 - seccompProfile: - type: RuntimeDefault - containers: - - name: devpi - image: registry.ops.eblu.me/blumeops/devpi:kustomized - env: - - name: DEVPI_ROOT_PASSWORD - valueFrom: - secretKeyRef: - name: devpi-root - key: password - - name: DEVPI_OUTSIDE_URL - value: "https://pypi.ops.eblu.me" - ports: - - containerPort: 3141 - name: http - volumeMounts: - - name: data - mountPath: /devpi - resources: - requests: - memory: "256Mi" - cpu: "100m" - limits: - memory: "2Gi" # High limit for initial PyPI index build, reclaimed after - cpu: "500m" - livenessProbe: - httpGet: - path: /+api - port: 3141 - initialDelaySeconds: 30 - periodSeconds: 30 - readinessProbe: - httpGet: - path: /+api - port: 3141 - initialDelaySeconds: 10 - periodSeconds: 10 - volumeClaimTemplates: - - metadata: - name: data - spec: - accessModes: ["ReadWriteOnce"] - resources: - requests: - storage: 50Gi diff --git a/docs/changelog.d/migrate-devpi-to-indri.infra.md b/docs/changelog.d/migrate-devpi-to-indri.infra.md new file mode 100644 index 0000000..418db70 --- /dev/null +++ b/docs/changelog.d/migrate-devpi-to-indri.infra.md @@ -0,0 +1 @@ +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]]. diff --git a/docs/how-to/operations/devpi-on-indri.md b/docs/how-to/operations/devpi-on-indri.md new file mode 100644 index 0000000..0334d37 --- /dev/null +++ b/docs/how-to/operations/devpi-on-indri.md @@ -0,0 +1,74 @@ +--- +title: Devpi on Indri +modified: 2026-04-29 +last-reviewed: 2026-04-29 +tags: + - how-to + - operations +--- + +# Devpi on Indri + +How devpi (the PyPI caching mirror at `pypi.ops.eblu.me`) is deployed on indri as a launchd-managed native service. Replaces the prior minikube StatefulSet. + +## Why native, not Kubernetes + +Devpi has no runtime dependencies beyond a Python interpreter, a writable directory, and outbound HTTPS to upstream PyPI. Running it on indri natively removes a layer of operational complexity, frees minikube resources, and decouples this critical-path tooling (used by every Python build, including `mise run docs-mikado` itself) from cluster health. + +## Layout + +| Concern | Path / detail | +|---|---| +| Service binary | `/Users/erichblume/devpi/venv/bin/devpi-server` | +| Server-dir (data) | `/Users/erichblume/devpi/server-dir/` | +| Logs | `/Users/erichblume/Library/Logs/mcquack.devpi.{out,err}.log` | +| LaunchAgent label | `mcquack.eblume.devpi` | +| LaunchAgent plist | `~/Library/LaunchAgents/mcquack.eblume.devpi.plist` | +| Listen address | `127.0.0.1:3141` (loopback only) | +| Public URL | `https://pypi.ops.eblu.me` (via Caddy reverse proxy) | +| Root password secret | 1Password item `devpi`, field `root password` | + +The venv is built fresh by ansible from a pinned `devpi-server` and `devpi-web` version; bumping versions is a config change in `ansible/roles/devpi/defaults/main.yml`. + +## Deploy + +```fish +mise run provision-indri -- --tags devpi +``` + +Ansible will: + +1. Fetch the root password from 1Password (in playbook `pre_tasks`) +2. Create the venv at `~/devpi/venv` if absent and install/upgrade `devpi-server` + `devpi-web` to the pinned versions +3. Initialize the server-dir (only on first run, when `.serverversion` is missing) +4. Render and load the LaunchAgent plist +5. Restart the service if the plist or config changed + +Caddy already proxies `pypi.ops.eblu.me` → `127.0.0.1:3141`; nothing else routes traffic. + +## Verify + +```fish +ssh indri 'launchctl list mcquack.eblume.devpi' +curl -fsS https://pypi.ops.eblu.me/+api | jq +uv pip install --index-url https://pypi.ops.eblu.me/root/pypi/+simple/ requests +``` + +## Logs + +```fish +ssh indri 'tail -f ~/Library/Logs/mcquack.devpi.err.log' +``` + +## Bumping devpi versions + +Edit `devpi_server_version` / `devpi_web_version` in `ansible/roles/devpi/defaults/main.yml`, then re-run the playbook with `--tags devpi`. The role rebuilds the venv in-place; the server-dir survives. + +## Backup + +The server-dir is **not** in `borgmatic_source_directories` and is not backed up. The PyPI cache (`+files/`) is re-fetchable from upstream on first request; the local `eblume/dev` index can be republished from source. If retention becomes important, add `/Users/erichblume/devpi/server-dir/` to the borgmatic source list. + +## Related + +- [[restart-indri]] — devpi is one of the LaunchAgents to stop on graceful shutdown +- [[connect-to-postgres]] — pattern for indri-native services (different stack, similar shape) diff --git a/docs/how-to/operations/rebuild-minikube-cluster.md b/docs/how-to/operations/rebuild-minikube-cluster.md index e23d027..0d924e9 100644 --- a/docs/how-to/operations/rebuild-minikube-cluster.md +++ b/docs/how-to/operations/rebuild-minikube-cluster.md @@ -235,25 +235,7 @@ mise run services-check ## Post-Rebuild: Cold Cache Failures -### Devpi (PyPI Cache) - -After a rebuild, devpi's package cache is empty. The first Dagger-based container build will trigger a flood of concurrent package downloads. Devpi uses lazy caching — it serves package metadata (simple index) immediately from upstream PyPI but fetches wheel files on demand. Under heavy concurrent load with a cold cache, the upstream fetch can race with the client request, causing devpi to return `no such file` (HTTP 404) for packages it knows about but hasn't finished downloading yet. - -**Why devpi, not PyPI?** The repo's `uv.lock` was generated with devpi as the index, so every package source URL points at `pypi.ops.eblu.me`. Dagger's Python SDK runtime does a locked install (`uv sync`), not fresh resolution — it fetches from whatever URLs are in the lockfile. This is intentional (supply chain control), but means all builds — local and CI — depend on devpi being available and warm. - -**Symptoms:** Forgejo Actions Dagger builds fail during module initialization with errors like: -``` -Failed to download `googleapis-common-protos==1.74.0` -HTTP status client error (404 Not Found) for url (https://pypi.ops.eblu.me/root/pypi/+f/...) -``` - -**Fix:** Re-run the failed build. The first attempt warms the cache; subsequent builds succeed. Alternatively, warm the cache manually before triggering CI builds: - -```bash -# From any machine that can reach pypi.ops.eblu.me, install the Dagger SDK -# to pre-populate the most common packages: -pip install --dry-run --index-url https://pypi.ops.eblu.me/root/pypi/+simple/ dagger-io -``` +Devpi runs natively on indri (see [[devpi-on-indri]]) and is unaffected by minikube rebuilds, so the historical "devpi cold cache after rebuild" failure mode no longer applies. If devpi itself goes cold (fresh server-dir), the same lazy-cache race can still cause `404` on the first Dagger build under concurrent load — re-run the build to warm the cache, or pre-warm with `uv pip install --dry-run --index-url https://pypi.ops.eblu.me/root/pypi/+simple/ dagger-io`. ## Related diff --git a/docs/how-to/operations/restart-indri.md b/docs/how-to/operations/restart-indri.md index a956644..e92581e 100644 --- a/docs/how-to/operations/restart-indri.md +++ b/docs/how-to/operations/restart-indri.md @@ -41,6 +41,7 @@ Native services managed by launchd will stop automatically during macOS shutdown ssh indri 'launchctl unload ~/Library/LaunchAgents/mcquack.eblume.forgejo.plist' ssh indri 'launchctl unload ~/Library/LaunchAgents/mcquack.eblume.caddy.plist' ssh indri 'launchctl unload ~/Library/LaunchAgents/mcquack.eblume.zot.plist' +ssh indri 'launchctl unload ~/Library/LaunchAgents/mcquack.eblume.devpi.plist' # see [[devpi-on-indri]] ssh indri 'launchctl unload ~/Library/LaunchAgents/mcquack.jellyfin.plist' ssh indri 'launchctl unload ~/Library/LaunchAgents/mcquack.eblume.alloy.plist' ssh indri 'launchctl unload ~/Library/LaunchAgents/mcquack.eblume.borgmatic.plist' diff --git a/docs/reference/infrastructure/tailscale.md b/docs/reference/infrastructure/tailscale.md index 2794111..9c15d83 100644 --- a/docs/reference/infrastructure/tailscale.md +++ b/docs/reference/infrastructure/tailscale.md @@ -33,7 +33,7 @@ ACLs managed via Pulumi in `pulumi/tailscale/policy.hujson`. | `tag:loki` | indri | Loki log aggregation | | `tag:k8s-api` | indri | Kubernetes API server (minikube) | | `tag:k8s-operator` | (operator pod) | Tailscale operator for k8s — see [[tailscale-operator]] | -| `tag:k8s` | (Ingress proxy pods) | Kubernetes Tailscale Ingress nodes; each also carries a per-service tag (`tag:grafana`, `tag:kiwix`, `tag:devpi`, `tag:feed`, `tag:pg`) | +| `tag:k8s` | (Ingress proxy pods) | Kubernetes Tailscale Ingress nodes; each also carries a per-service tag (`tag:grafana`, `tag:kiwix`, `tag:feed`, `tag:pg`) | | `tag:ci-gateway` | (ephemeral CI containers) | CI containers pushing images to registry | | `tag:flyio-proxy` | (Fly.io proxy container) | Public reverse proxy | | `tag:flyio-target` | indri, designated Ingress endpoints | Endpoints reachable by the Fly.io proxy (indri for Caddy routing, Ingress pods for Alloy metrics/logs) | diff --git a/docs/reference/services/devpi.md b/docs/reference/services/devpi.md index c6493fe..589a802 100644 --- a/docs/reference/services/devpi.md +++ b/docs/reference/services/devpi.md @@ -1,7 +1,7 @@ --- title: Devpi -modified: 2026-03-23 -last-reviewed: 2026-03-23 +modified: 2026-04-29 +last-reviewed: 2026-04-29 tags: - service - python @@ -9,31 +9,37 @@ tags: # devpi (PyPI Proxy) -PyPI caching proxy and private package index. +PyPI caching proxy and private package index. Runs natively on [[indri]] as a LaunchAgent (not in-cluster). See [[devpi-on-indri]] for deploy and operations. ## Quick Reference | Property | Value | |----------|-------| -| **URL** | https://pypi.ops.eblu.me | -| **Namespace** | `devpi` | -| **ArgoCD App** | `devpi` | -| **Storage** | 50Gi PVC | -| **Image** | `registry.ops.eblu.me/blumeops/devpi` (see `argocd/manifests/devpi/kustomization.yaml` for current tag) | +| **URL** | `https://pypi.ops.eblu.me` | +| **Listen** | `127.0.0.1:3141` (loopback only; reached via Caddy) | +| **Service** | LaunchAgent `mcquack.eblume.devpi` on indri | +| **Server-dir** | `/Users/erichblume/devpi/server-dir/` | +| **Runtime** | uv-managed venv at `/Users/erichblume/devpi/venv/` | +| **Ansible role** | `ansible/roles/devpi/` | +| **Versions** | Pinned in `ansible/roles/devpi/defaults/main.yml` (`devpi_server_version`, `devpi_web_version`) | ## Indices | Index | Purpose | |-------|---------| -| `root/pypi` | PyPI mirror/cache (auto-created) | -| `eblume/dev` | Private packages (inherits from root/pypi) | +| `root/pypi` | PyPI mirror/cache (auto-created by `devpi-init`) | +| `eblume/dev` | Private packages (inherits from `root/pypi`) | ## Credentials -Root password stored in 1Password (blumeops vault), injected via ExternalSecret. +Root password stored in 1Password (`blumeops` vault, item `devpi`, field `root password`). Fetched via `op read` in the `ansible/playbooks/indri.yml` `pre_tasks` and passed to the role on first init. + +## Backup + +The server-dir is **not** backed up. The PyPI cache (`+files/`) is re-fetchable from upstream on first request. The local `eblume/dev` index metadata is small but also not critical to retain — packages can be republished from source. If retention becomes important, add `/Users/erichblume/devpi/server-dir/` to `borgmatic_source_directories`. ## Related -- [[use-pypi-proxy]] - Client configuration and package uploads -- [[argocd]] - Deployment -- [[1password]] - Secrets management +- [[devpi-on-indri]] — Deploy, verify, and version-bump procedures +- [[use-pypi-proxy]] — Client configuration and package uploads +- [[1password]] — Secrets management diff --git a/docs/reference/storage/backups.md b/docs/reference/storage/backups.md index 9ca3bcb..14dbcea 100644 --- a/docs/reference/storage/backups.md +++ b/docs/reference/storage/backups.md @@ -62,7 +62,7 @@ Other data lives directly on [[sifaka]] (music via [[navidrome]], video via [[je | ZIM archives (`~/transmission/`) | Re-downloadable via torrent | | Prometheus metrics | Ephemeral, in k8s PVC | | Loki logs | Ephemeral, in k8s PVC | -| devpi cache | Re-fetchable from PyPI | +| devpi cache (`~/devpi/server-dir/` on indri) | Re-fetchable from PyPI on first request | ## Retention Policy diff --git a/pulumi/tailscale/__main__.py b/pulumi/tailscale/__main__.py index 2f5262b..3acbb62 100644 --- a/pulumi/tailscale/__main__.py +++ b/pulumi/tailscale/__main__.py @@ -37,7 +37,7 @@ acl = tailscale.Acl( # indri - Mac Mini M1, primary homelab server # Hosts forge, loki, zot registry, and the k8s control plane. -# Other services (grafana, kiwix, devpi, etc.) run in k8s with their own Tailscale devices. +# Other services (grafana, kiwix, etc.) run in k8s with their own Tailscale devices. indri = tailscale.get_device(name="indri.tail8d86e.ts.net") indri_tags = tailscale.DeviceTags( "indri-tags", diff --git a/pulumi/tailscale/policy.hujson b/pulumi/tailscale/policy.hujson index 84f1f17..88408ef 100644 --- a/pulumi/tailscale/policy.hujson +++ b/pulumi/tailscale/policy.hujson @@ -20,7 +20,8 @@ }, // --- Members: user-facing services only --- - // Kiwix, Forge, devpi, Miniflux, PostgreSQL + // Kiwix, Forge, Miniflux, PostgreSQL + // (devpi moved off-cluster to indri; reachable via Caddy on tag:flyio-target) { "src": ["autogroup:member"], "dst": ["tag:kiwix"], @@ -31,11 +32,6 @@ "dst": ["tag:forge"], "ip": ["tcp:443", "tcp:22"], }, - { - "src": ["autogroup:member"], - "dst": ["tag:devpi"], - "ip": ["tcp:443"], - }, { "src": ["autogroup:member"], "dst": ["tag:feed"], @@ -152,7 +148,6 @@ "tag:grafana": ["autogroup:admin", "tag:blumeops"], "tag:kiwix": ["autogroup:admin", "tag:blumeops"], "tag:forge": ["autogroup:admin", "tag:blumeops"], - "tag:devpi": ["autogroup:admin", "tag:blumeops"], "tag:loki": ["autogroup:admin", "tag:blumeops"], "tag:pg": ["autogroup:admin", "tag:blumeops"], "tag:feed": ["autogroup:admin", "tag:blumeops"], diff --git a/service-versions.yaml b/service-versions.yaml index 0a4fe93..e819c6c 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -214,10 +214,11 @@ services: upstream-source: https://github.com/kiwix/kiwix-tools/releases - name: devpi - type: argocd - last-reviewed: 2026-04-18 + type: ansible + last-reviewed: 2026-04-29 current-version: "6.19.3" upstream-source: https://github.com/devpi/devpi/releases + notes: Installed via uv into a venv on indri; version pinned in ansible/roles/devpi/defaults/main.yml - name: cv type: argocd From a529d60f60f0864c0f839930ef43f2830a9ac56b Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 29 Apr 2026 13:40:45 -0700 Subject: [PATCH 342/430] C0: remove containers/devpi/ build artifact Devpi now runs natively on indri (uv venv via ansible role), so the Dagger container build at containers/devpi/ is unused. Removing it. Also updated dagger.md examples to use 'miniflux' as the example container-name argument. Co-Authored-By: Claude Opus 4.7 (1M context) --- containers/devpi/container.py | 56 ------------------- containers/devpi/start.sh | 31 ---------- .../+remove-devpi-container-build.misc.md | 1 + docs/reference/tools/dagger.md | 8 +-- 4 files changed, 5 insertions(+), 91 deletions(-) delete mode 100644 containers/devpi/container.py delete mode 100644 containers/devpi/start.sh create mode 100644 docs/changelog.d/+remove-devpi-container-build.misc.md diff --git a/containers/devpi/container.py b/containers/devpi/container.py deleted file mode 100644 index 0067e95..0000000 --- a/containers/devpi/container.py +++ /dev/null @@ -1,56 +0,0 @@ -"""devpi PyPI server and caching proxy — native Dagger build. - -Single-stage build: install devpi-server and devpi-web into a Python slim image. -""" - -import dagger -from dagger import dag - -from blumeops.containers import oci_labels - -VERSION = "6.19.3" - -DEVPI_WEB_VERSION = "5.0.2" -PYTHON_BASE = "python:3.12-slim" - - -async def build(src: dagger.Directory) -> dagger.Container: - ctr = ( - dag.container() - .from_(PYTHON_BASE) - .with_exec( - [ - "pip", - "install", - "--no-cache-dir", - f"devpi-server=={VERSION}", - f"devpi-web=={DEVPI_WEB_VERSION}", - ] - ) - .with_exec( - [ - "useradd", - "-r", - "-u", - "1000", - "devpi", - ] - ) - .with_exec(["mkdir", "-p", "/devpi"]) - .with_exec(["chown", "devpi:devpi", "/devpi"]) - .with_file( - "/usr/local/bin/start.sh", - src.file("containers/devpi/start.sh"), - ) - .with_exec(["chmod", "+x", "/usr/local/bin/start.sh"]) - .with_user("devpi") - .with_workdir("/devpi") - .with_exposed_port(3141) - .with_entrypoint(["/usr/local/bin/start.sh"]) - ) - return oci_labels( - ctr, - title="devpi", - description="devpi PyPI server and caching proxy", - version=VERSION, - ) diff --git a/containers/devpi/start.sh b/containers/devpi/start.sh deleted file mode 100644 index 8ed46a2..0000000 --- a/containers/devpi/start.sh +++ /dev/null @@ -1,31 +0,0 @@ -#!/bin/bash -set -e - -SERVERDIR="${DEVPI_SERVERDIR:-/devpi}" -HOST="${DEVPI_HOST:-0.0.0.0}" -# Note: Can't use DEVPI_PORT - Kubernetes auto-sets it for service discovery -PORT="${DEVPI_LISTEN_PORT:-3141}" -OUTSIDE_URL="${DEVPI_OUTSIDE_URL:-}" - -# Check if devpi is initialized -if [ ! -f "$SERVERDIR/.serverversion" ]; then - echo "Initializing devpi server..." - - if [ -z "$DEVPI_ROOT_PASSWORD" ]; then - echo "ERROR: DEVPI_ROOT_PASSWORD environment variable must be set for initialization" - exit 1 - fi - - devpi-init --serverdir "$SERVERDIR" --root-passwd "$DEVPI_ROOT_PASSWORD" - echo "Devpi initialized successfully" -fi - -# Build command -CMD=(devpi-server --serverdir "$SERVERDIR" --host "$HOST" --port "$PORT") - -if [ -n "$OUTSIDE_URL" ]; then - CMD+=(--outside-url "$OUTSIDE_URL") -fi - -echo "Starting devpi-server..." -exec "${CMD[@]}" diff --git a/docs/changelog.d/+remove-devpi-container-build.misc.md b/docs/changelog.d/+remove-devpi-container-build.misc.md new file mode 100644 index 0000000..8ebec54 --- /dev/null +++ b/docs/changelog.d/+remove-devpi-container-build.misc.md @@ -0,0 +1 @@ +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. diff --git a/docs/reference/tools/dagger.md b/docs/reference/tools/dagger.md index 89be50c..81c5caf 100644 --- a/docs/reference/tools/dagger.md +++ b/docs/reference/tools/dagger.md @@ -50,16 +50,16 @@ New containers for indri (k8s runner) should use `container.py`. Ringtail contai ```bash # Build a container -dagger call build --src=. --container-name=devpi +dagger call build --src=. --container-name=miniflux # Drop into container shell for inspection -dagger call build --src=. --container-name=devpi terminal +dagger call build --src=. --container-name=miniflux terminal # Debug a failure interactively -dagger call --interactive build --src=. --container-name=devpi +dagger call --interactive build --src=. --container-name=miniflux # Publish a container to zot -dagger call publish --src=. --container-name=devpi --version=v1.1.0 +dagger call publish --src=. --container-name=miniflux --version=v1.1.0 # Build a nix container (no local nix required) dagger call build-nix --src=. --container-name=ntfy export --path=./ntfy.tar.gz From 8d634861f606b5571a2de72f1f377ec9da32d654 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 29 Apr 2026 14:55:11 -0700 Subject: [PATCH 343/430] C1: migrate cv + docs from minikube to indri-native (#342) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Replace the cv (`cv.eblu.me`) and docs (`docs.eblu.me`) minikube Deployments with indri-native ansible roles. Caddy serves the extracted release tarballs directly via a new `kind: static` service-block — no daemon, no nginx pod, no ProxyGroup ingress on the request path. Mirrors the rationale of the recent devpi migration; part of the broader minikube wind-down. ## What's in this commit - `ansible/roles/{cv,docs}` — sentinel-gated tarball download + extract into `~/{cv,docs}/content/` - `ansible/roles/caddy/` — new `kind: static` branch in the Caddyfile template (encoded gzip, immutable cache headers for fingerprinted assets, optional `try_html` for Quartz-style clean URLs, optional per-path `download_paths` for the resume PDF's `Content-Disposition`) - `ansible/playbooks/indri.yml` — wires `cv` and `docs` roles before `caddy` - `service-versions.yaml` — both services flip to `type: ansible`. `docs.current-version` stays at `1.28.2` for this commit so `container-version-check` keeps passing while `containers/quartz/Dockerfile` still exists; it moves to the docs release tag in the cleanup commit - `.forgejo/workflows/{cv-deploy,build-blumeops}.yaml` — deploy step now bumps `cv_version`/`docs_version` in the role defaults and pushes; running ansible + purging the Fly cache is manual from gilbert (matches devpi) - Docs: `docs/how-to/operations/{cv,docs}-on-indri.md`, updated `docs/reference/services/{cv,docs}.md`, changelog fragment ## What is not in this commit The dead artifacts. After PR review and successful cutover, a follow-up commit deletes: - `argocd/apps/{cv,docs}.yaml` and `argocd/manifests/{cv,docs}/` - `containers/cv/`, `containers/quartz/` - `CONTAINER_TO_SERVICE['quartz']` mapping in `mise-tasks/container-version-check` - bumps `docs.current-version` in `service-versions.yaml` to the release tag ## Cutover plan (manual, from gilbert, after review) 1. **Take down old:** - Remove the cv and docs Applications: `argocd app delete cv --cascade && argocd app delete docs --cascade` - Verify k8s namespaces gone: `kubectl --context=minikube-indri get ns | grep -E '^(cv|docs)\\b'` (should be empty) - Verify tailnet MagicDNS no longer advertises the VIPs: `nslookup cv.tail8d86e.ts.net` and `nslookup docs.tail8d86e.ts.net` should both fail 2. **Bring up new:** - `mise run provision-indri -- --tags cv,docs,caddy --check --diff` (already validated on branch) - `mise run provision-indri -- --tags cv,docs,caddy` - `fly ssh console -a blumeops-proxy -C "sh -c 'rm -rf /tmp/cache && nginx -s reload'"` 3. **Verify:** `mise run services-check` and the curl checks listed in `docs/how-to/operations/{cv,docs}-on-indri.md` 4. **Cleanup commit + merge.** Total expected downtime: minutes (not the few-hour budget you authorized). ## Test plan - [ ] `mise run provision-indri -- --tags cv,docs --check --diff` clean - [ ] `mise run provision-indri -- --tags caddy --check --diff` shows only the cv + docs blocks changing as previewed in the PR thread - [ ] After cutover: `cv.eblu.me`, `cv.ops.eblu.me`, `docs.eblu.me`, `docs.ops.eblu.me` all return 200 - [ ] `cv.eblu.me/resume.pdf` includes `Content-Disposition: attachment` - [ ] A clean Quartz URL (e.g. `docs.eblu.me/explanation/agent-change-process`) resolves to the right page - [ ] `mise run services-check` clean - [ ] `mise run service-review --type ansible` shows cv and docs 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/342 --- .forgejo/workflows/build-blumeops.yaml | 58 +++++---------- .forgejo/workflows/cv-deploy.yaml | 58 +++++---------- ansible/playbooks/indri.yml | 4 ++ ansible/roles/caddy/defaults/main.yml | 10 ++- ansible/roles/caddy/templates/Caddyfile.j2 | 20 ++++++ ansible/roles/cv/defaults/main.yml | 10 +++ ansible/roles/cv/tasks/main.yml | 57 +++++++++++++++ ansible/roles/docs/defaults/main.yml | 11 +++ ansible/roles/docs/tasks/main.yml | 57 +++++++++++++++ argocd/manifests/homepage/services.yaml | 16 +++++ .../migrate-cv-docs-to-indri.infra.md | 1 + docs/how-to/operations/cv-on-indri.md | 72 +++++++++++++++++++ docs/how-to/operations/docs-on-indri.md | 66 +++++++++++++++++ docs/reference/services/cv.md | 43 ++++++----- docs/reference/services/docs.md | 46 ++++++------ service-versions.yaml | 22 ++++-- 16 files changed, 415 insertions(+), 136 deletions(-) create mode 100644 ansible/roles/cv/defaults/main.yml create mode 100644 ansible/roles/cv/tasks/main.yml create mode 100644 ansible/roles/docs/defaults/main.yml create mode 100644 ansible/roles/docs/tasks/main.yml create mode 100644 docs/changelog.d/migrate-cv-docs-to-indri.infra.md create mode 100644 docs/how-to/operations/cv-on-indri.md create mode 100644 docs/how-to/operations/docs-on-indri.md diff --git a/.forgejo/workflows/build-blumeops.yaml b/.forgejo/workflows/build-blumeops.yaml index 383542f..c6e6c3c 100644 --- a/.forgejo/workflows/build-blumeops.yaml +++ b/.forgejo/workflows/build-blumeops.yaml @@ -178,10 +178,11 @@ jobs: echo "## Documentation" echo "" - echo "Download \`$TARBALL\` and configure the quartz container with:" + echo "Download \`$TARBALL\` directly, or bump \`docs_version\`" + echo "in \`ansible/roles/docs/defaults/main.yml\` and run:" echo "" echo "\`\`\`" - echo "DOCS_RELEASE_URL=https://forge.eblu.me/eblume/blumeops/releases/download/$VERSION/$TARBALL" + echo "mise run provision-indri -- --tags docs" echo "\`\`\`" } > /tmp/release_body.txt @@ -223,18 +224,16 @@ jobs: echo "" echo "Release created successfully!" - - name: Update docs deployment + - name: Bump docs_version in ansible role run: | VERSION="${{ steps.version.outputs.version }}" - TARBALL="docs-${VERSION}.tar.gz" - DEPLOYMENT_FILE="argocd/manifests/docs/deployment.yaml" - RELEASE_URL="https://forge.eblu.me/eblume/blumeops/releases/download/${VERSION}/${TARBALL}" + DEFAULTS_FILE="ansible/roles/docs/defaults/main.yml" - echo "Updating $DEPLOYMENT_FILE with new release URL..." - yq -i "(.spec.template.spec.containers[0].env[] | select(.name == \"DOCS_RELEASE_URL\")).value = \"${RELEASE_URL}\"" "$DEPLOYMENT_FILE" + echo "Bumping docs_version in $DEFAULTS_FILE to ${VERSION}..." + yq -i ".docs_version = \"${VERSION}\"" "$DEFAULTS_FILE" - echo "Updated deployment:" - grep -A1 "DOCS_RELEASE_URL" "$DEPLOYMENT_FILE" + echo "Updated defaults:" + grep -E "^docs_version:" "$DEFAULTS_FILE" - name: Commit release changes env: @@ -248,7 +247,7 @@ jobs: git config user.email "actions@forge.ops.eblu.me" # Stage deployment changes - git add argocd/manifests/docs/deployment.yaml + git add ansible/roles/docs/defaults/main.yml # Stage changelog changes if updated if [ "$CHANGELOG_UPDATED" = "true" ]; then @@ -270,34 +269,6 @@ jobs: echo "Changes committed and pushed" fi - - name: Deploy docs - env: - ARGOCD_AUTH_TOKEN: ${{ secrets.ARGOCD_AUTH_TOKEN }} - run: | - echo "Syncing docs app via ArgoCD..." - - # Sync docs app (uses ARGOCD_AUTH_TOKEN env var for auth) - argocd app sync docs \ - --server argocd.ops.eblu.me \ - --grpc-web \ - --prune - - # Wait for sync to complete - argocd app wait docs \ - --server argocd.ops.eblu.me \ - --grpc-web \ - --timeout 120 - - echo "Docs app synced successfully!" - - - name: Purge Fly.io proxy cache - env: - FLY_API_TOKEN: ${{ secrets.FLY_DEPLOY_TOKEN }} - run: | - echo "Purging nginx cache on Fly.io proxy..." - fly ssh console -a blumeops-proxy -C "sh -c 'rm -rf /tmp/cache && nginx -s reload'" - echo "Cache purged" - - name: Summary run: | VERSION="${{ steps.version.outputs.version }}" @@ -309,5 +280,12 @@ jobs: echo "Release URL:" echo " https://forge.eblu.me/eblume/blumeops/releases/tag/$VERSION" echo "" - echo "Asset URL (for DOCS_RELEASE_URL ConfigMap):" + echo "Asset URL:" echo " https://forge.eblu.me/eblume/blumeops/releases/download/$VERSION/$TARBALL" + echo "" + echo "To deploy on indri, run from gilbert:" + echo " mise run provision-indri -- --tags docs" + echo "" + echo "Then purge the Fly.io proxy cache:" + echo " fly ssh console -a blumeops-proxy -C \\" + echo " \"sh -c 'rm -rf /tmp/cache && nginx -s reload'\"" diff --git a/.forgejo/workflows/cv-deploy.yaml b/.forgejo/workflows/cv-deploy.yaml index f99352d..001aa36 100644 --- a/.forgejo/workflows/cv-deploy.yaml +++ b/.forgejo/workflows/cv-deploy.yaml @@ -1,12 +1,14 @@ # CV Deploy Workflow # -# Updates the CV deployment to a specific package version, commits -# the change, and syncs via ArgoCD. +# Bumps cv_version in ansible/roles/cv/defaults/main.yml and pushes the change. +# Deployment to indri is manual (runner has no SSH access to indri): +# mise run provision-indri -- --tags cv # # Usage: # 1. Release a new CV package from the cv repo first # 2. Go to Actions > Deploy CV > Run workflow # 3. Enter the version to deploy, or leave as "latest" +# 4. Run the command above on gilbert to apply name: Deploy CV @@ -60,18 +62,16 @@ jobs: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Update CV deployment + - name: Bump cv_version in ansible role run: | VERSION="${{ steps.version.outputs.version }}" - TARBALL="cv-${VERSION}.tar.gz" - DEPLOYMENT_FILE="argocd/manifests/cv/deployment.yaml" - RELEASE_URL="https://forge.eblu.me/api/packages/eblume/generic/cv/${VERSION}/${TARBALL}" + DEFAULTS_FILE="ansible/roles/cv/defaults/main.yml" - echo "Updating $DEPLOYMENT_FILE with CV_RELEASE_URL..." - yq -i "(.spec.template.spec.containers[0].env[] | select(.name == \"CV_RELEASE_URL\")).value = \"${RELEASE_URL}\"" "$DEPLOYMENT_FILE" + echo "Bumping cv_version in $DEFAULTS_FILE to ${VERSION}..." + yq -i ".cv_version = \"${VERSION}\"" "$DEFAULTS_FILE" - echo "Updated deployment:" - grep -A1 "CV_RELEASE_URL" "$DEPLOYMENT_FILE" + echo "Updated defaults:" + grep -E "^cv_version:" "$DEFAULTS_FILE" - name: Commit release changes env: @@ -82,7 +82,7 @@ jobs: git config user.name "Forgejo Actions" git config user.email "actions@forge.ops.eblu.me" - git add argocd/manifests/cv/deployment.yaml + git add ansible/roles/cv/defaults/main.yml if git diff --cached --quiet; then echo "No changes to commit (already at $VERSION)" @@ -94,38 +94,16 @@ jobs: echo "Changes committed and pushed" fi - - name: Deploy CV - env: - ARGOCD_AUTH_TOKEN: ${{ secrets.ARGOCD_AUTH_TOKEN }} - run: | - echo "Syncing CV app via ArgoCD..." - - argocd app sync cv \ - --server argocd.ops.eblu.me \ - --grpc-web \ - --prune - - argocd app wait cv \ - --server argocd.ops.eblu.me \ - --grpc-web \ - --timeout 120 - - echo "CV app synced successfully!" - - - name: Purge Fly.io proxy cache - env: - FLY_API_TOKEN: ${{ secrets.FLY_DEPLOY_TOKEN }} - run: | - echo "Purging nginx cache on Fly.io proxy..." - fly ssh console -a blumeops-proxy -C "sh -c 'rm -rf /tmp/cache && nginx -s reload'" - echo "Cache purged" - - name: Summary run: | VERSION="${{ steps.version.outputs.version }}" echo "================================================" - echo "CV Deployed: $VERSION" + echo "CV version bumped: $VERSION" echo "================================================" echo "" - echo "CV should now be live at:" - echo " https://cv.ops.eblu.me/" + echo "To deploy on indri, run from gilbert:" + echo " mise run provision-indri -- --tags cv" + echo "" + echo "Then purge the Fly.io proxy cache:" + echo " fly ssh console -a blumeops-proxy -C \\" + echo " \"sh -c 'rm -rf /tmp/cache && nginx -s reload'\"" diff --git a/ansible/playbooks/indri.yml b/ansible/playbooks/indri.yml index fa87b36..ddb57f8 100644 --- a/ansible/playbooks/indri.yml +++ b/ansible/playbooks/indri.yml @@ -256,5 +256,9 @@ tags: jellyfin_metrics - role: forgejo_metrics tags: forgejo_metrics + - role: cv + tags: cv + - role: docs + tags: docs - role: caddy tags: caddy diff --git a/ansible/roles/caddy/defaults/main.yml b/ansible/roles/caddy/defaults/main.yml index 80993ee..6eada76 100644 --- a/ansible/roles/caddy/defaults/main.yml +++ b/ansible/roles/caddy/defaults/main.yml @@ -72,10 +72,16 @@ caddy_services: backend: "https://go.tail8d86e.ts.net" - name: docs host: "docs.{{ caddy_domain }}" - backend: "https://docs.tail8d86e.ts.net" + kind: static + root: "{{ docs_content_dir }}" + try_html: true # Quartz: path → path/ → path.html → 404.html - name: cv host: "cv.{{ caddy_domain }}" - backend: "https://cv.tail8d86e.ts.net" + kind: static + root: "{{ cv_content_dir }}" + download_paths: + - path: /resume.pdf + filename: erich-blume-resume.pdf - name: nvr host: "nvr.{{ caddy_domain }}" backend: "https://nvr.tail8d86e.ts.net" diff --git a/ansible/roles/caddy/templates/Caddyfile.j2 b/ansible/roles/caddy/templates/Caddyfile.j2 index 4f103f1..b08f16a 100644 --- a/ansible/roles/caddy/templates/Caddyfile.j2 +++ b/ansible/roles/caddy/templates/Caddyfile.j2 @@ -31,6 +31,25 @@ {% for service in caddy_services %} @{{ service.name }} host {{ service.host }} handle @{{ service.name }} { +{% if service.kind | default('proxy') == 'static' %} + root * {{ service.root }} + encode gzip + # Long-cache fingerprinted assets; everything else stays default. + @{{ service.name }}_assets path_regexp \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ + header @{{ service.name }}_assets Cache-Control "public, max-age=31536000, immutable" +{% for dl in service.download_paths | default([]) %} + @{{ service.name }}_dl{{ loop.index }} path {{ dl.path }} + header @{{ service.name }}_dl{{ loop.index }} Content-Disposition `attachment; filename="{{ dl.filename }}"` +{% endfor %} +{% if service.try_html | default(false) %} + try_files {path} {path}/ {path}.html + handle_errors 404 { + rewrite * /404.html + file_server + } +{% endif %} + file_server +{% else %} {% if service.cache_policy | default('') == 'spa' %} # SPA cache policy: hashed static assets are immutable, HTML must revalidate. # Prevents stale HTML from referencing chunk hashes that no longer exist. @@ -47,6 +66,7 @@ } {% else %} reverse_proxy {{ service.backend }} +{% endif %} {% endif %} } diff --git a/ansible/roles/cv/defaults/main.yml b/ansible/roles/cv/defaults/main.yml new file mode 100644 index 0000000..734e52b --- /dev/null +++ b/ansible/roles/cv/defaults/main.yml @@ -0,0 +1,10 @@ +--- +# CV / resume static site (native, replaces minikube Deployment) +# Caddy serves cv_content_dir directly via the static-kind service block. + +cv_version: "v1.0.3" +cv_release_url: "https://forge.eblu.me/api/packages/eblume/generic/cv/{{ cv_version }}/cv-{{ cv_version }}.tar.gz" + +cv_home: /Users/erichblume/blumeops/cv +cv_content_dir: "{{ cv_home }}/content" +cv_version_sentinel: "{{ cv_home }}/.installed-version" diff --git a/ansible/roles/cv/tasks/main.yml b/ansible/roles/cv/tasks/main.yml new file mode 100644 index 0000000..c254325 --- /dev/null +++ b/ansible/roles/cv/tasks/main.yml @@ -0,0 +1,57 @@ +--- +# cv role — download and extract the CV release tarball into cv_content_dir. +# Caddy serves the directory directly; there is no daemon to manage. +# +# Idempotency: a sentinel file records the installed cv_version. The +# download/extract steps only run when the sentinel doesn't match cv_version. +# +# We use curl rather than ansible.builtin.get_url because the forge generic- +# packages endpoint returns 405 on HEAD requests, which get_url issues before +# downloading. + +- name: Ensure cv home exists + ansible.builtin.file: + path: "{{ cv_home }}" + state: directory + mode: '0755' + +- name: Read installed cv version sentinel + ansible.builtin.slurp: + src: "{{ cv_version_sentinel }}" + register: cv_installed_raw + failed_when: false + changed_when: false + +- name: Set installed cv version fact + ansible.builtin.set_fact: + cv_installed_version: >- + {{ (cv_installed_raw.content | b64decode).strip() + if (cv_installed_raw.content is defined) else '' }} + +- name: Recreate cv content dir + ansible.builtin.file: + path: "{{ cv_content_dir }}" + state: "{{ item }}" + mode: '0755' + loop: + - absent + - directory + when: cv_installed_version != cv_version + +- name: Download and extract cv release tarball + ansible.builtin.shell: + cmd: >- + set -euo pipefail; + curl -fsSL {{ cv_release_url | quote }} -o {{ cv_home }}/cv.tar.gz && + tar -xzf {{ cv_home }}/cv.tar.gz -C {{ cv_content_dir }} && + rm -f {{ cv_home }}/cv.tar.gz + executable: /bin/bash + when: cv_installed_version != cv_version + changed_when: true + +- name: Write cv version sentinel + ansible.builtin.copy: + content: "{{ cv_version }}\n" + dest: "{{ cv_version_sentinel }}" + mode: '0644' + when: cv_installed_version != cv_version diff --git a/ansible/roles/docs/defaults/main.yml b/ansible/roles/docs/defaults/main.yml new file mode 100644 index 0000000..f09221b --- /dev/null +++ b/ansible/roles/docs/defaults/main.yml @@ -0,0 +1,11 @@ +--- +# Docs (Quartz-built static site) — replaces minikube Deployment. +# Caddy serves docs_content_dir directly via the static-kind service block, +# with Quartz-style try_files (path → path/ → path.html → 404). + +docs_version: "v1.16.0" +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_content_dir: "{{ docs_home }}/content" +docs_version_sentinel: "{{ docs_home }}/.installed-version" diff --git a/ansible/roles/docs/tasks/main.yml b/ansible/roles/docs/tasks/main.yml new file mode 100644 index 0000000..dec775e --- /dev/null +++ b/ansible/roles/docs/tasks/main.yml @@ -0,0 +1,57 @@ +--- +# docs role — download and extract the Quartz-built docs tarball into +# docs_content_dir. Caddy serves the directory directly with Quartz-style +# try_files; there is no daemon to manage. +# +# Idempotency: a sentinel file records the installed docs_version. The +# download/extract steps only run when the sentinel doesn't match docs_version. +# +# Mirrors the cv role's curl-based download for consistency, even though the +# forge releases endpoint here does support HEAD. + +- name: Ensure docs home exists + ansible.builtin.file: + path: "{{ docs_home }}" + state: directory + mode: '0755' + +- name: Read installed docs version sentinel + ansible.builtin.slurp: + src: "{{ docs_version_sentinel }}" + register: docs_installed_raw + failed_when: false + changed_when: false + +- name: Set installed docs version fact + ansible.builtin.set_fact: + docs_installed_version: >- + {{ (docs_installed_raw.content | b64decode).strip() + if (docs_installed_raw.content is defined) else '' }} + +- name: Recreate docs content dir + ansible.builtin.file: + path: "{{ docs_content_dir }}" + state: "{{ item }}" + mode: '0755' + loop: + - absent + - directory + when: docs_installed_version != docs_version + +- name: Download and extract docs release tarball + ansible.builtin.shell: + cmd: >- + set -euo pipefail; + curl -fsSL {{ docs_release_url | quote }} -o {{ docs_home }}/docs.tar.gz && + tar -xzf {{ docs_home }}/docs.tar.gz -C {{ docs_content_dir }} && + rm -f {{ docs_home }}/docs.tar.gz + executable: /bin/bash + when: docs_installed_version != docs_version + changed_when: true + +- name: Write docs version sentinel + ansible.builtin.copy: + content: "{{ docs_version }}\n" + dest: "{{ docs_version_sentinel }}" + mode: '0644' + when: docs_installed_version != docs_version diff --git a/argocd/manifests/homepage/services.yaml b/argocd/manifests/homepage/services.yaml index 58b8bb7..211e043 100644 --- a/argocd/manifests/homepage/services.yaml +++ b/argocd/manifests/homepage/services.yaml @@ -12,6 +12,10 @@ href: https://registry.ops.eblu.me icon: zot-registry description: Container registry + - Devpi: + href: https://pypi.ops.eblu.me + icon: mdi-language-python + description: PyPI caching mirror - Sifaka NAS: href: https://nas.ops.eblu.me icon: synology @@ -77,3 +81,15 @@ href: https://ntfy.ops.eblu.me icon: ntfy.png description: Push notifications +- Services: + # CV and Docs were previously auto-discovered from k8s Ingresses; after + # the indri-native migration ([[cv-on-indri]], [[docs-on-indri]]) there + # is no Ingress to discover, so they live here as static entries. + - CV: + href: https://cv.eblu.me + icon: mdi-file-document + description: Resume / CV + - Docs: + href: https://docs.eblu.me + icon: mdi-book-open-page-variant + description: BlumeOps Documentation diff --git a/docs/changelog.d/migrate-cv-docs-to-indri.infra.md b/docs/changelog.d/migrate-cv-docs-to-indri.infra.md new file mode 100644 index 0000000..608a6b9 --- /dev/null +++ b/docs/changelog.d/migrate-cv-docs-to-indri.infra.md @@ -0,0 +1 @@ +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. diff --git a/docs/how-to/operations/cv-on-indri.md b/docs/how-to/operations/cv-on-indri.md new file mode 100644 index 0000000..432acab --- /dev/null +++ b/docs/how-to/operations/cv-on-indri.md @@ -0,0 +1,72 @@ +--- +title: CV on Indri +modified: 2026-04-29 +last-reviewed: 2026-04-29 +tags: + - how-to + - operations +--- + +# CV on Indri + +How the CV/resume static site (`cv.eblu.me`) is deployed on indri natively. Replaces the prior minikube Deployment; mirrors the rationale of [[devpi-on-indri]]. + +## Why native, not Kubernetes + +CV is a tiny static site (HTML + CSS + PDF). It needs no daemon, no database, no auth. Caddy on indri can serve the extracted tarball directly via `file_server`. Removing the minikube Deployment shrinks the cluster's footprint and removes a network hop (Fly → indri Caddy → ProxyGroup ingress → minikube pod becomes Fly → indri Caddy → local files). + +## Layout + +| Concern | Path / detail | +|---|---| +| Content dir | `/Users/erichblume/blumeops/cv/content/` | +| Version sentinel | `/Users/erichblume/blumeops/cv/.installed-version` | +| Caddy entry | `cv` service in `ansible/roles/caddy/defaults/main.yml` (`kind: static`) | +| Public URL | `https://cv.eblu.me` (via [[flyio-proxy]]) | +| Private URL | `https://cv.ops.eblu.me` (Caddy on indri) | +| Tarball source | Forgejo generic package `cv` (`forge.eblu.me/eblume/-/packages`) | + +The role is driven by `cv_version` in `ansible/roles/cv/defaults/main.yml`. The download and extract steps only fire when the on-disk sentinel doesn't match `cv_version` — i.e. after a version bump. + +## Deploy + +Two paths: + +**From a release workflow** (most common): + +1. Run the `Release CV` workflow in the cv repo → produces a new generic package +2. Run the blumeops `Deploy CV` workflow → bumps `cv_version` in `ansible/roles/cv/defaults/main.yml` and pushes to main +3. From gilbert: `mise run provision-indri -- --tags cv` +4. From gilbert: `fly ssh console -a blumeops-proxy -C "sh -c 'rm -rf /tmp/cache && nginx -s reload'"` to purge the public-edge cache + +**Manual** (e.g., reverting): edit `cv_version` in the role defaults yourself, then steps 3–4. + +## Verify + +```fish +ssh indri 'cat ~/blumeops/cv/.installed-version' +ssh indri 'ls -la ~/blumeops/cv/content/' +curl -fsSI https://cv.ops.eblu.me/ # private +curl -fsSI https://cv.eblu.me/ # public +curl -fsSI https://cv.eblu.me/resume.pdf | grep -i disposition +``` + +The PDF response should include `content-disposition: attachment; filename="erich-blume-resume.pdf"`. + +## Bumping the cv version + +Edit `cv_version` in `ansible/roles/cv/defaults/main.yml` and re-run `mise run provision-indri -- --tags cv`. The role recreates the content dir from the new tarball; the sentinel update triggers the next idempotent skip. + +## Backup + +The content dir is **not** in `borgmatic_source_directories`. The tarball is re-downloadable from the Forgejo generic package store on every deploy, and the source is in the cv repo — recovery is just re-running the role. + +## Rollback + +If a bad version is published, set `cv_version` back to the previous tag in `ansible/roles/cv/defaults/main.yml` and re-run the role. The full minikube manifest set is preserved in git history (commits prior to the migration cleanup) for the worst case. + +## Related + +- [[devpi-on-indri]] — same shape, different upstream +- [[restart-indri]] — graceful indri restart procedure +- [[cv]] — service reference diff --git a/docs/how-to/operations/docs-on-indri.md b/docs/how-to/operations/docs-on-indri.md new file mode 100644 index 0000000..e683db5 --- /dev/null +++ b/docs/how-to/operations/docs-on-indri.md @@ -0,0 +1,66 @@ +--- +title: Docs on Indri +modified: 2026-04-29 +last-reviewed: 2026-04-29 +tags: + - how-to + - operations +--- + +# Docs on Indri + +How the Quartz documentation site (`docs.eblu.me`) is deployed on indri natively. Replaces the prior minikube Deployment; same shape as [[cv-on-indri]] with one extra wrinkle for Quartz's clean URLs. + +## Why native, not Kubernetes + +The docs site is fully static HTML produced by Quartz. Caddy can serve the extracted tarball directly. The Quartz-specific behavior the previous nginx container provided (`try_files $uri $uri/ $uri.html =404` and a custom `/404.html`) maps cleanly to Caddy's `try_files` and `handle_errors`. + +## Layout + +| Concern | Path / detail | +|---|---| +| Content dir | `/Users/erichblume/blumeops/docs/content/` | +| Version sentinel | `/Users/erichblume/blumeops/docs/.installed-version` | +| Caddy entry | `docs` service in `ansible/roles/caddy/defaults/main.yml` (`kind: static`, `try_html: true`) | +| Public URL | `https://docs.eblu.me` (via [[flyio-proxy]]) | +| Private URL | `https://docs.ops.eblu.me` (Caddy on indri) | +| Tarball source | Forgejo release asset on the blumeops repo (`docs-.tar.gz`) | + +`docs_version` in `ansible/roles/docs/defaults/main.yml` is the blumeops release tag (e.g. `v1.16.0`). The role's download/extract is gated by an on-disk sentinel. + +## Deploy + +1. Run the `Build BlumeOps` Forgejo workflow → builds the tarball, creates a release, bumps `docs_version` in the ansible role, pushes to main +2. From gilbert: `mise run provision-indri -- --tags docs` +3. From gilbert: `fly ssh console -a blumeops-proxy -C "sh -c 'rm -rf /tmp/cache && nginx -s reload'"` + +The Caddy block uses `try_files {path} {path}/ {path}.html` and a `handle_errors 404 → /404.html` rewrite, matching the original nginx behavior so Quartz's clean URLs continue to work. + +## Verify + +```fish +ssh indri 'cat ~/blumeops/docs/.installed-version' +ssh indri 'ls ~/blumeops/docs/content/' +curl -fsSI https://docs.ops.eblu.me/ # private +curl -fsSI https://docs.eblu.me/ # public +curl -fsSI https://docs.eblu.me/explanation/agent-change-process # clean URL → .html fallback +curl -fsSI https://docs.eblu.me/no-such-path-exists/ # → /404.html +``` + +## Bumping the docs version + +Normally driven by the workflow. If you need to pin manually, edit `docs_version` in `ansible/roles/docs/defaults/main.yml` and re-run `mise run provision-indri -- --tags docs`. + +## Backup + +Content dir is not borgmatic-backed. Source is in this repo; release tarballs are on the forge. + +## Rollback + +Set `docs_version` back to the previous release tag in the role defaults and re-run. Older release tarballs remain available as Forgejo release assets. + +## Related + +- [[cv-on-indri]] — sibling service, simpler (no `try_html`) +- [[devpi-on-indri]] — pattern reference for indri-native services +- [[docs]] — service reference diff --git a/docs/reference/services/cv.md b/docs/reference/services/cv.md index 55805d6..1bc5f15 100644 --- a/docs/reference/services/cv.md +++ b/docs/reference/services/cv.md @@ -1,7 +1,7 @@ --- title: CV -modified: 2026-03-27 -last-reviewed: 2026-03-27 +modified: 2026-04-29 +last-reviewed: 2026-04-29 tags: - service - resume @@ -15,37 +15,36 @@ Personal resume/CV served as a static HTML page with PDF download, built from YA | Property | Value | |----------|-------| -| **URL** | `cv.eblu.me` (public, via [[flyio-proxy]]) | -| **Namespace** | `cv` | -| **Container** | `registry.ops.eblu.me/blumeops/cv` ([kustomization](https://forge.eblu.me/eblume/blumeops/src/branch/main/argocd/manifests/cv/kustomization.yaml)) | +| **Public URL** | `cv.eblu.me` (via [[flyio-proxy]]) | +| **Private URL** | `cv.ops.eblu.me` (Caddy on indri) | +| **Deployment** | Ansible role `cv` on indri (no daemon — Caddy serves files directly) | +| **Content dir** | `~/blumeops/cv/content/` on indri | | **Source repo** | `forge.eblu.me/eblume/cv` (private, not mirrored to GitHub) | | **Content packages** | `forge.eblu.me/eblume/-/packages` (generic package `cv`) | -| **ArgoCD App** | `cv` | + +Migrated from minikube to indri-native on 2026-04-29 (see [[cv-on-indri]]). ## Architecture 1. **Source**: `resume.yaml` (content) + `template.html` (Jinja2) + `style.css` in the cv repo 2. **Build**: `render.py` (uv script runner) generates `index.html`; WeasyPrint generates `resume.pdf` 3. **Release**: Dagger `build` function packages `index.html`, `style.css`, `resume.pdf` into a tarball, uploaded to Forgejo generic packages -4. **Deploy**: nginx container downloads the tarball at startup via `CV_RELEASE_URL` env var +4. **Deploy**: ansible role downloads the tarball into `~/blumeops/cv/content/` on indri; Caddy serves the directory directly ## Endpoints | Path | Description | |------|-------------| | `/` | Resume HTML page | -| `/resume.pdf` | PDF download (Content-Disposition: attachment) | -| `/healthz` | Health check (200 OK) | +| `/resume.pdf` | PDF download (Caddy adds `Content-Disposition: attachment`) | ## Configuration **Key files (blumeops):** -- `containers/cv/Dockerfile` — nginx:alpine container -- `containers/cv/start.sh` — tarball download + extraction -- `containers/cv/default.conf` — nginx config (gzip, caching, PDF headers) -- `argocd/manifests/cv/deployment.yaml` — `CV_RELEASE_URL` env var -- `argocd/apps/cv.yaml` — ArgoCD Application +- `ansible/roles/cv/defaults/main.yml` — pinned `cv_version` and tarball URL +- `ansible/roles/cv/tasks/main.yml` — sentinel-gated download + extract +- `ansible/roles/caddy/defaults/main.yml` — `cv` service entry (`kind: static`, `download_paths` for the PDF) **Key files (cv repo):** @@ -56,17 +55,15 @@ Personal resume/CV served as a static HTML page with PDF download, built from YA - `src/cv_ci/main.py` — Dagger pipeline (alpine + uv + WeasyPrint) - `.forgejo/workflows/cv-release.yaml` — Release workflow -## Secrets +## Release flow -| Secret | Repo | Source | Description | -|--------|------|--------|-------------| -| `FORGE_TOKEN` | cv | 1Password (via Ansible) | Forgejo API token for package uploads | - -Provisioned via `forgejo_actions_secrets` Ansible role. See [[create-release-artifact-workflow]]. +1. Release a new package from the cv repo (`Release CV` workflow) +2. Run the blumeops `Deploy CV` workflow → bumps `cv_version` in the ansible role and pushes +3. Run `mise run provision-indri -- --tags cv` from gilbert +4. Purge the Fly.io proxy cache so the new content is fetched ## Related -- [[docs]] — Similar architecture (nginx container + content tarball) +- [[cv-on-indri]] — Operations how-to +- [[docs]] — Similar architecture (Caddy serving a tarball-extracted dir) - [[flyio-proxy]] — Exposes `cv.eblu.me` publicly via Tailscale tunnel -- [[create-release-artifact-workflow]] — How to set up release artifact workflows -- [[deploy-k8s-service]] — General k8s deployment guide diff --git a/docs/reference/services/docs.md b/docs/reference/services/docs.md index 1361d02..8ca8310 100644 --- a/docs/reference/services/docs.md +++ b/docs/reference/services/docs.md @@ -1,7 +1,7 @@ --- title: Docs -modified: 2026-03-23 -last-reviewed: 2026-03-23 +modified: 2026-04-29 +last-reviewed: 2026-04-29 tags: - service - documentation @@ -9,44 +9,42 @@ tags: # Docs (Quartz) -Documentation site built with [Quartz](https://quartz.jzhao.xyz/) and served via nginx. +Documentation site built with [Quartz](https://quartz.jzhao.xyz/). ## Quick Reference | Property | Value | |----------|-------| -| **Public URL** | https://docs.eblu.me | -| **Private URL** | `docs.ops.eblu.me` (tailnet only, via [[caddy]]) | -| **Namespace** | `docs` | -| **Image** | `registry.ops.eblu.me/blumeops/quartz` (see `argocd/manifests/docs/kustomization.yaml` for current tag) | +| **Public URL** | https://docs.eblu.me (via [[flyio-proxy]]) | +| **Private URL** | `docs.ops.eblu.me` (Caddy on indri) | +| **Deployment** | Ansible role `docs` on indri (no daemon — Caddy serves files directly) | +| **Content dir** | `~/blumeops/docs/content/` on indri | | **Source** | `docs/` directory in blumeops repo | | **Build** | Forgejo workflow `build-blumeops.yaml` | -| **Public proxy** | [[flyio-proxy]] (Fly.io → Tailscale tunnel) | + +Migrated from minikube to indri-native on 2026-04-29 (see [[docs-on-indri]]). ## Architecture 1. **Source**: Markdown files in `docs/` with Obsidian-compatible wiki-links -2. **Build**: Forgejo workflow builds Quartz static site on push to main -3. **Release**: Built assets published as Forgejo release attachments -4. **Deploy**: Container downloads release bundle on startup, serves via nginx - -## Release Process - -Documentation is built and released via the `build-blumeops` Forgejo workflow (manual dispatch): - -1. Quartz builds static HTML/CSS/JS -2. Assets uploaded as Forgejo release attachment -3. Workflow updates `DOCS_RELEASE_URL` in `argocd/manifests/docs/deployment.yaml` and commits to main -4. ArgoCD syncs the updated deployment; new pod downloads the release bundle at startup +2. **Build**: `Build BlumeOps` Forgejo workflow runs towncrier + Quartz, uploads tarball as a release asset, and bumps `docs_version` in the ansible role +3. **Deploy**: ansible role downloads the tarball into `~/blumeops/docs/content/` on indri; Caddy serves the directory directly with Quartz-style `try_files` (path → path/ → path.html → 404.html) ## Configuration - **Quartz config**: `quartz.config.ts` - **Layout**: `quartz.layout.ts` -- **ArgoCD app**: `argocd/apps/docs.yaml` -- **Manifests**: `argocd/manifests/docs/` +- **Ansible role**: `ansible/roles/docs/` +- **Caddy entry**: `ansible/roles/caddy/defaults/main.yml` (`kind: static`, `try_html: true`) + +## Release flow + +1. Run the `Build BlumeOps` workflow → builds tarball, creates release, bumps `docs_version` in the ansible role and pushes +2. Run `mise run provision-indri -- --tags docs` from gilbert +3. Purge the Fly.io proxy cache so the new content is fetched ## Related -- [[argocd]] - Deployment management -- [[forgejo]] - Build workflows +- [[docs-on-indri]] — Operations how-to +- [[cv]] — Similar architecture +- [[forgejo]] — Build workflows diff --git a/service-versions.yaml b/service-versions.yaml index e819c6c..d77fa13 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -221,18 +221,26 @@ services: notes: Installed via uv into a venv on indri; version pinned in ansible/roles/devpi/defaults/main.yml - name: cv - type: argocd - last-reviewed: 2026-04-27 + type: ansible + last-reviewed: 2026-04-29 current-version: "1.0.3" upstream-source: https://forge.eblu.me/eblume/cv - notes: Personal static site; review build deps (WeasyPrint, Jinja2) in source repo + notes: >- + Static tarball downloaded by ansible/roles/cv into ~/blumeops/cv/content on indri; + served directly by Caddy (kind=static). Migrated from minikube 2026-04-29. + Review build deps (WeasyPrint, Jinja2) in source repo on upstream review. - name: docs - type: argocd - last-reviewed: 2026-03-07 + type: ansible + last-reviewed: 2026-04-29 current-version: "1.28.2" - upstream-source: https://github.com/jackyzha0/quartz/releases - notes: Quartz static site generator; container version tracks nginx base + upstream-source: https://forge.eblu.me/eblume/blumeops/releases + notes: >- + Quartz-built tarball downloaded by ansible/roles/docs into ~/blumeops/docs/content + on indri; served directly by Caddy (kind=static, try_html). Migrated from + minikube 2026-04-29. current-version still tracks the legacy quartz/nginx + base; will switch to the docs release tag (e.g. v1.16.0) once the dead + containers/quartz Dockerfile is removed in the cleanup commit. - name: forgejo-runner type: argocd From 2ee53fe3758cbeac769b005c0774b040158df2c6 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 29 Apr 2026 15:16:44 -0700 Subject: [PATCH 344/430] =?UTF-8?q?C0:=20fix=20Caddyfile=20try=5Fhtml=20?= =?UTF-8?q?=E2=80=94=20handle=5Ferrors=20can't=20nest=20inside=20handle{}?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The kind=static branch added in #342 put handle_errors inside the @host handle{} block. handle_errors is a top-level site-block directive, not an ordered HTTP handler, so Caddy refuses to load the config: parsing caddyfile tokens for 'handle': directive 'handle_errors' is not an ordered HTTP handler This crash-loops the whole reverse proxy and takes down every *.ops.eblu.me service. Tripped today during the live cv/docs cutover. Fix: drop handle_errors and append /404.html as the final try_files candidate. The 404 page is served with status 200 instead of 404, but that's acceptable for a human-facing curated 404 — the page renders correctly. Documented inline. The running Caddy on indri already has the fixed config (deployed manually during the cutover); this lands the fix in main so future provision-indri --tags caddy runs don't re-break it. Co-Authored-By: Claude Opus 4.7 (1M context) --- ansible/roles/caddy/templates/Caddyfile.j2 | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ansible/roles/caddy/templates/Caddyfile.j2 b/ansible/roles/caddy/templates/Caddyfile.j2 index b08f16a..f6b5f64 100644 --- a/ansible/roles/caddy/templates/Caddyfile.j2 +++ b/ansible/roles/caddy/templates/Caddyfile.j2 @@ -42,11 +42,11 @@ header @{{ service.name }}_dl{{ loop.index }} Content-Disposition `attachment; filename="{{ dl.filename }}"` {% endfor %} {% if service.try_html | default(false) %} - try_files {path} {path}/ {path}.html - handle_errors 404 { - rewrite * /404.html - file_server - } + # Quartz clean URLs: path → path/ → path.html → /404.html (200). + # Caddy's handle_errors is a top-level directive and can't live in + # this nested handle, so the 404 page rides as the final try_files + # candidate (served with 200 — acceptable for a human-facing 404). + try_files {path} {path}/ {path}.html /404.html {% endif %} file_server {% else %} From 5096223b485308a6234eeb069a1cbb40d1c850b8 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 29 Apr 2026 15:18:39 -0700 Subject: [PATCH 345/430] C1: clean up cv + docs minikube artifacts (#343) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Follow-up to #342. The cv and docs services are now live on indri (Caddy file_server backed by ansible-managed tarball extraction) and verified working. This PR removes the dead minikube artifacts and the tooling shims that referenced them. ## Changes **Deletions:** - ``argocd/apps/{cv,docs}.yaml`` - ``argocd/manifests/{cv,docs}/`` (deployment, service, ingress, pdb, kustomization) - ``containers/{cv,quartz}/`` (Dockerfiles + start scripts) **Tooling:** - ``mise-tasks/container-version-check``: remove the ``quartz``→``docs`` CONTAINER_TO_SERVICE mapping (containers/quartz no longer exists) - ``service-versions.yaml``: bump ``docs.current-version`` to ``v1.16.0`` (the blumeops docs release tag) and trim the migration-window comment ## Live state context The argocd Applications ``cv`` and ``docs`` were already deleted from the cluster manually as part of the cutover; this PR just removes the YAML files that the ``apps`` app-of-apps was still ingesting. After merge, ``argocd app sync apps`` will reconcile and the ``apps`` Application returns to Synced. The Caddyfile ``handle_errors`` bug that briefly crashed all ``*.ops.eblu.me`` services during cutover is fixed in a separate C0 (``2ee53fe``) on main, not here. ## Test plan - [x] ``mise run container-version-check --all-files`` clean - [x] ``mise run service-review --type ansible`` shows cv at 1.0.3, docs at v1.16.0 - [ ] After merge: ``argocd app sync apps`` returns clean (cv/docs entries gone, no children to reconcile) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/343 --- argocd/apps/cv.yaml | 18 ------- argocd/apps/docs.yaml | 18 ------- argocd/manifests/cv/deployment.yaml | 51 ------------------- argocd/manifests/cv/ingress-tailscale.yaml | 27 ---------- argocd/manifests/cv/kustomization.yaml | 12 ----- argocd/manifests/cv/pdb.yaml | 10 ---- argocd/manifests/cv/service.yaml | 13 ----- argocd/manifests/docs/deployment.yaml | 51 ------------------- argocd/manifests/docs/ingress-tailscale.yaml | 27 ---------- argocd/manifests/docs/kustomization.yaml | 12 ----- argocd/manifests/docs/pdb.yaml | 10 ---- argocd/manifests/docs/service.yaml | 13 ----- containers/cv/Dockerfile | 30 ----------- containers/cv/default.conf | 33 ------------ containers/cv/start.sh | 31 ----------- containers/quartz/Dockerfile | 31 ----------- containers/quartz/default.conf | 34 ------------- containers/quartz/start.sh | 31 ----------- ...cleanup-cv-docs-minikube-artifacts.misc.md | 1 + mise-tasks/container-version-check | 1 - service-versions.yaml | 8 ++- 21 files changed, 4 insertions(+), 458 deletions(-) delete mode 100644 argocd/apps/cv.yaml delete mode 100644 argocd/apps/docs.yaml delete mode 100644 argocd/manifests/cv/deployment.yaml delete mode 100644 argocd/manifests/cv/ingress-tailscale.yaml delete mode 100644 argocd/manifests/cv/kustomization.yaml delete mode 100644 argocd/manifests/cv/pdb.yaml delete mode 100644 argocd/manifests/cv/service.yaml delete mode 100644 argocd/manifests/docs/deployment.yaml delete mode 100644 argocd/manifests/docs/ingress-tailscale.yaml delete mode 100644 argocd/manifests/docs/kustomization.yaml delete mode 100644 argocd/manifests/docs/pdb.yaml delete mode 100644 argocd/manifests/docs/service.yaml delete mode 100644 containers/cv/Dockerfile delete mode 100644 containers/cv/default.conf delete mode 100644 containers/cv/start.sh delete mode 100644 containers/quartz/Dockerfile delete mode 100644 containers/quartz/default.conf delete mode 100644 containers/quartz/start.sh create mode 100644 docs/changelog.d/cleanup-cv-docs-minikube-artifacts.misc.md diff --git a/argocd/apps/cv.yaml b/argocd/apps/cv.yaml deleted file mode 100644 index ad09a8d..0000000 --- a/argocd/apps/cv.yaml +++ /dev/null @@ -1,18 +0,0 @@ ---- -apiVersion: argoproj.io/v1alpha1 -kind: Application -metadata: - name: cv - namespace: argocd -spec: - project: default - source: - repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git - targetRevision: main - path: argocd/manifests/cv - destination: - server: https://kubernetes.default.svc - namespace: cv - syncPolicy: - syncOptions: - - CreateNamespace=true diff --git a/argocd/apps/docs.yaml b/argocd/apps/docs.yaml deleted file mode 100644 index cd8db35..0000000 --- a/argocd/apps/docs.yaml +++ /dev/null @@ -1,18 +0,0 @@ ---- -apiVersion: argoproj.io/v1alpha1 -kind: Application -metadata: - name: docs - namespace: argocd -spec: - project: default - source: - repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git - targetRevision: main - path: argocd/manifests/docs - destination: - server: https://kubernetes.default.svc - namespace: docs - syncPolicy: - syncOptions: - - CreateNamespace=true diff --git a/argocd/manifests/cv/deployment.yaml b/argocd/manifests/cv/deployment.yaml deleted file mode 100644 index f2b00e6..0000000 --- a/argocd/manifests/cv/deployment.yaml +++ /dev/null @@ -1,51 +0,0 @@ ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: cv - namespace: cv -spec: - replicas: 2 - strategy: - type: RollingUpdate - rollingUpdate: - maxUnavailable: 0 - maxSurge: 1 - selector: - matchLabels: - app: cv - template: - metadata: - labels: - app: cv - spec: - securityContext: - seccompProfile: - type: RuntimeDefault - containers: - - name: cv - image: registry.ops.eblu.me/blumeops/cv:kustomized - ports: - - containerPort: 80 - name: http - env: - - name: CV_RELEASE_URL - value: "https://forge.eblu.me/api/packages/eblume/generic/cv/v1.0.3/cv-v1.0.3.tar.gz" - resources: - requests: - memory: "64Mi" - cpu: "10m" - limits: - memory: "128Mi" - livenessProbe: - httpGet: - path: /healthz - port: 80 - initialDelaySeconds: 10 - periodSeconds: 30 - readinessProbe: - httpGet: - path: /healthz - port: 80 - initialDelaySeconds: 5 - periodSeconds: 10 diff --git a/argocd/manifests/cv/ingress-tailscale.yaml b/argocd/manifests/cv/ingress-tailscale.yaml deleted file mode 100644 index 489f95a..0000000 --- a/argocd/manifests/cv/ingress-tailscale.yaml +++ /dev/null @@ -1,27 +0,0 @@ ---- -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: cv-tailscale - namespace: cv - annotations: - tailscale.com/proxy-class: "default" - tailscale.com/proxy-group: "ingress" - tailscale.com/tags: "tag:k8s,tag:flyio-target" - gethomepage.dev/enabled: "true" - gethomepage.dev/name: "CV" - gethomepage.dev/group: "Services" - gethomepage.dev/icon: "mdi-file-document" - gethomepage.dev/description: "Resume / CV" - gethomepage.dev/href: "https://cv.eblu.me" - gethomepage.dev/pod-selector: "app=cv" -spec: - ingressClassName: tailscale - defaultBackend: - service: - name: cv - port: - number: 80 - tls: - - hosts: - - cv diff --git a/argocd/manifests/cv/kustomization.yaml b/argocd/manifests/cv/kustomization.yaml deleted file mode 100644 index 199108d..0000000 --- a/argocd/manifests/cv/kustomization.yaml +++ /dev/null @@ -1,12 +0,0 @@ ---- -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization -namespace: cv -resources: - - deployment.yaml - - service.yaml - - ingress-tailscale.yaml - - pdb.yaml -images: - - name: registry.ops.eblu.me/blumeops/cv - newTag: v1.0.3-613f05d diff --git a/argocd/manifests/cv/pdb.yaml b/argocd/manifests/cv/pdb.yaml deleted file mode 100644 index db5240d..0000000 --- a/argocd/manifests/cv/pdb.yaml +++ /dev/null @@ -1,10 +0,0 @@ ---- -apiVersion: policy/v1 -kind: PodDisruptionBudget -metadata: - name: cv -spec: - minAvailable: 1 - selector: - matchLabels: - app: cv diff --git a/argocd/manifests/cv/service.yaml b/argocd/manifests/cv/service.yaml deleted file mode 100644 index 23e0e94..0000000 --- a/argocd/manifests/cv/service.yaml +++ /dev/null @@ -1,13 +0,0 @@ ---- -apiVersion: v1 -kind: Service -metadata: - name: cv - namespace: cv -spec: - selector: - app: cv - ports: - - name: http - port: 80 - targetPort: 80 diff --git a/argocd/manifests/docs/deployment.yaml b/argocd/manifests/docs/deployment.yaml deleted file mode 100644 index c477b83..0000000 --- a/argocd/manifests/docs/deployment.yaml +++ /dev/null @@ -1,51 +0,0 @@ ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: docs - namespace: docs -spec: - replicas: 2 - strategy: - type: RollingUpdate - rollingUpdate: - maxUnavailable: 0 - maxSurge: 1 - selector: - matchLabels: - app: docs - template: - metadata: - labels: - app: docs - spec: - securityContext: - seccompProfile: - type: RuntimeDefault - containers: - - name: docs - image: registry.ops.eblu.me/blumeops/quartz:kustomized - ports: - - containerPort: 80 - name: http - env: - - name: DOCS_RELEASE_URL - value: "https://forge.eblu.me/eblume/blumeops/releases/download/v1.16.0/docs-v1.16.0.tar.gz" - resources: - requests: - memory: "64Mi" - cpu: "10m" - limits: - memory: "128Mi" - livenessProbe: - httpGet: - path: /healthz - port: 80 - initialDelaySeconds: 10 - periodSeconds: 30 - readinessProbe: - httpGet: - path: /healthz - port: 80 - initialDelaySeconds: 5 - periodSeconds: 10 diff --git a/argocd/manifests/docs/ingress-tailscale.yaml b/argocd/manifests/docs/ingress-tailscale.yaml deleted file mode 100644 index 047e823..0000000 --- a/argocd/manifests/docs/ingress-tailscale.yaml +++ /dev/null @@ -1,27 +0,0 @@ ---- -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: docs-tailscale - namespace: docs - annotations: - tailscale.com/proxy-class: "default" - tailscale.com/proxy-group: "ingress" - tailscale.com/tags: "tag:k8s,tag:flyio-target" - gethomepage.dev/enabled: "true" - gethomepage.dev/name: "Docs" - gethomepage.dev/group: "Services" - gethomepage.dev/icon: "mdi-book-open-page-variant" - gethomepage.dev/description: "BlumeOps Documentation" - gethomepage.dev/href: "https://docs.eblu.me" - gethomepage.dev/pod-selector: "app=docs" -spec: - ingressClassName: tailscale - defaultBackend: - service: - name: docs - port: - number: 80 - tls: - - hosts: - - docs diff --git a/argocd/manifests/docs/kustomization.yaml b/argocd/manifests/docs/kustomization.yaml deleted file mode 100644 index a16185f..0000000 --- a/argocd/manifests/docs/kustomization.yaml +++ /dev/null @@ -1,12 +0,0 @@ ---- -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization -namespace: docs -resources: - - deployment.yaml - - service.yaml - - ingress-tailscale.yaml - - pdb.yaml -images: - - name: registry.ops.eblu.me/blumeops/quartz - newTag: v1.28.2-613f05d diff --git a/argocd/manifests/docs/pdb.yaml b/argocd/manifests/docs/pdb.yaml deleted file mode 100644 index a87b8e9..0000000 --- a/argocd/manifests/docs/pdb.yaml +++ /dev/null @@ -1,10 +0,0 @@ ---- -apiVersion: policy/v1 -kind: PodDisruptionBudget -metadata: - name: docs -spec: - minAvailable: 1 - selector: - matchLabels: - app: docs diff --git a/argocd/manifests/docs/service.yaml b/argocd/manifests/docs/service.yaml deleted file mode 100644 index 62b0f83..0000000 --- a/argocd/manifests/docs/service.yaml +++ /dev/null @@ -1,13 +0,0 @@ ---- -apiVersion: v1 -kind: Service -metadata: - name: docs - namespace: docs -spec: - selector: - app: docs - ports: - - name: http - port: 80 - targetPort: 80 diff --git a/containers/cv/Dockerfile b/containers/cv/Dockerfile deleted file mode 100644 index 9bfebe0..0000000 --- a/containers/cv/Dockerfile +++ /dev/null @@ -1,30 +0,0 @@ -# CV/Resume Static Site Server -# Downloads and serves a CV site tarball (HTML+CSS+PDF) via nginx -# -# Configuration (via environment): -# CV_RELEASE_URL - URL to download the CV content tarball -# -# The container downloads the tarball on startup, extracts it, and serves with nginx. - -ARG CONTAINER_APP_VERSION=1.0.3 - -FROM nginx:alpine - -ARG CONTAINER_APP_VERSION -LABEL org.opencontainers.image.title="CV" -LABEL org.opencontainers.image.description="Static site server for CV/resume" -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" - -# Install curl for downloading release assets -RUN apk add --no-cache curl - -# Copy startup script and nginx config -COPY start.sh /start.sh -COPY default.conf /etc/nginx/conf.d/default.conf -RUN chmod +x /start.sh - -EXPOSE 80 - -CMD ["/start.sh"] diff --git a/containers/cv/default.conf b/containers/cv/default.conf deleted file mode 100644 index 7c89b08..0000000 --- a/containers/cv/default.conf +++ /dev/null @@ -1,33 +0,0 @@ -server { - listen 80; - server_name _; - root /usr/share/nginx/html; - index index.html; - - # Enable gzip compression - gzip on; - gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript; - - # Cache static assets - location ~* \.(css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ { - expires 1y; - add_header Cache-Control "public, immutable"; - } - - # Force PDF download - location = /resume.pdf { - add_header Content-Disposition 'attachment; filename="erich-blume-resume.pdf"'; - } - - # Serve files directly - location / { - try_files $uri $uri/ =404; - } - - # Health check endpoint - location /healthz { - access_log off; - return 200 "ok\n"; - add_header Content-Type text/plain; - } -} diff --git a/containers/cv/start.sh b/containers/cv/start.sh deleted file mode 100644 index bb81c20..0000000 --- a/containers/cv/start.sh +++ /dev/null @@ -1,31 +0,0 @@ -#!/bin/sh -set -e - -HTML_DIR="/usr/share/nginx/html" - -# Check for required environment variable -if [ -z "$CV_RELEASE_URL" ]; then - echo "Error: CV_RELEASE_URL environment variable is required" - echo "Set it to the URL of the CV content tarball to serve" - exit 1 -fi - -echo "Downloading CV content from: $CV_RELEASE_URL" - -# Download the tarball -if ! curl -fsSL "$CV_RELEASE_URL" -o /tmp/cv.tar.gz; then - echo "Error: Failed to download CV content from $CV_RELEASE_URL" - exit 1 -fi - -# Clear existing content and extract -rm -rf "${HTML_DIR:?}"/* -echo "Extracting CV content to $HTML_DIR" -tar -xzf /tmp/cv.tar.gz -C "$HTML_DIR" -rm /tmp/cv.tar.gz - -echo "CV content extracted successfully" -echo "Starting nginx..." - -# Start nginx in foreground -exec nginx -g "daemon off;" diff --git a/containers/quartz/Dockerfile b/containers/quartz/Dockerfile deleted file mode 100644 index 8ffd44c..0000000 --- a/containers/quartz/Dockerfile +++ /dev/null @@ -1,31 +0,0 @@ -# Quartz Static Site Server -# Downloads and serves a Quartz-built static site from a release bundle -# -# Configuration (via environment): -# DOCS_RELEASE_URL - URL to download the static site tarball -# -# The container downloads the tarball on startup, extracts it, and serves with nginx. - -ARG CONTAINER_APP_VERSION=1.28.2 -ARG NGINX_VERSION=${CONTAINER_APP_VERSION} - -FROM nginx:${NGINX_VERSION}-alpine - -ARG CONTAINER_APP_VERSION -LABEL org.opencontainers.image.title="Quartz" -LABEL org.opencontainers.image.description="Static site server for Quartz-built documentation" -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" - -# Install curl for downloading release assets -RUN apk add --no-cache curl - -# Copy startup script and nginx config -COPY start.sh /start.sh -COPY default.conf /etc/nginx/conf.d/default.conf -RUN chmod +x /start.sh - -EXPOSE 80 - -CMD ["/start.sh"] diff --git a/containers/quartz/default.conf b/containers/quartz/default.conf deleted file mode 100644 index 64eec4e..0000000 --- a/containers/quartz/default.conf +++ /dev/null @@ -1,34 +0,0 @@ -server { - listen 80; - server_name _; - root /usr/share/nginx/html; - index index.html; - - # Enable gzip compression - gzip on; - gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript; - - # Cache static assets - location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ { - expires 1y; - add_header Cache-Control "public, immutable"; - } - - # Static file serving — no SPA fallback. - # Quartz generates complete HTML for every page, so all valid URLs - # map to real files. Non-existent paths get 404.html (generated by - # Quartz's NotFoundPage plugin), preventing the spider-trap issue - # where crawlers would get index.html for fabricated URLs. - location / { - try_files $uri $uri/ $uri.html =404; - } - - error_page 404 /404.html; - - # Health check endpoint - location /healthz { - access_log off; - return 200 "ok\n"; - add_header Content-Type text/plain; - } -} diff --git a/containers/quartz/start.sh b/containers/quartz/start.sh deleted file mode 100644 index 778eeb1..0000000 --- a/containers/quartz/start.sh +++ /dev/null @@ -1,31 +0,0 @@ -#!/bin/sh -set -e - -HTML_DIR="/usr/share/nginx/html" - -# Check for required environment variable -if [ -z "$DOCS_RELEASE_URL" ]; then - echo "Error: DOCS_RELEASE_URL environment variable is required" - echo "Set it to the URL of the static site tarball to serve" - exit 1 -fi - -echo "Downloading docs from: $DOCS_RELEASE_URL" - -# Download the tarball -if ! curl -fsSL "$DOCS_RELEASE_URL" -o /tmp/docs.tar.gz; then - echo "Error: Failed to download docs from $DOCS_RELEASE_URL" - exit 1 -fi - -# Clear existing content and extract -rm -rf "${HTML_DIR:?}"/* -echo "Extracting docs to $HTML_DIR" -tar -xzf /tmp/docs.tar.gz -C "$HTML_DIR" -rm /tmp/docs.tar.gz - -echo "Docs extracted successfully" -echo "Starting nginx..." - -# Start nginx in foreground -exec nginx -g "daemon off;" diff --git a/docs/changelog.d/cleanup-cv-docs-minikube-artifacts.misc.md b/docs/changelog.d/cleanup-cv-docs-minikube-artifacts.misc.md new file mode 100644 index 0000000..79a81cf --- /dev/null +++ b/docs/changelog.d/cleanup-cv-docs-minikube-artifacts.misc.md @@ -0,0 +1 @@ +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. diff --git a/mise-tasks/container-version-check b/mise-tasks/container-version-check index 6270ae1..95cf6f0 100755 --- a/mise-tasks/container-version-check +++ b/mise-tasks/container-version-check @@ -42,7 +42,6 @@ BLACKLIST = {"kubectl"} # Container dir name → service-versions.yaml name (when they differ) CONTAINER_TO_SERVICE = { - "quartz": "docs", "kiwix-serve": "kiwix", } diff --git a/service-versions.yaml b/service-versions.yaml index d77fa13..f4f4a6a 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -233,14 +233,12 @@ services: - name: docs type: ansible last-reviewed: 2026-04-29 - current-version: "1.28.2" + current-version: "v1.16.0" upstream-source: https://forge.eblu.me/eblume/blumeops/releases notes: >- Quartz-built tarball downloaded by ansible/roles/docs into ~/blumeops/docs/content - on indri; served directly by Caddy (kind=static, try_html). Migrated from - minikube 2026-04-29. current-version still tracks the legacy quartz/nginx - base; will switch to the docs release tag (e.g. v1.16.0) once the dead - containers/quartz Dockerfile is removed in the cleanup commit. + on indri; served directly by Caddy (kind=static, try_html). current-version + tracks the blumeops docs release tag. - name: forgejo-runner type: argocd From f6e392b80cacda422f27af14af71c5a182bc4009 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Thu, 30 Apr 2026 16:51:43 -0700 Subject: [PATCH 346/430] C1: SHA-pin tooling dependencies (2026-04 cycle) (#344) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Monthly tooling dependency refresh, with a one-time conversion from version-tag pins (`rev = "vX.Y.Z"`, `image:tag`, `>=`) to SHA / digest pins everywhere. ## Changes - **prek hooks**: all `rev = "vX.Y.Z"` → commit SHA + `# vX.Y.Z` comment. Bumped trufflehog (3.94.0→3.95.2), kingfisher (1.91.0→1.97.0), ruff (0.15.7→0.15.12), shfmt (3.13.0→3.13.1), prettier (3.8.1→3.8.3), actionlint (1.7.11→1.7.12). - **fly/Dockerfile**: tag pins → `image@sha256:...` digest pins. Bumped nginx (1.29.6→1.30.0-alpine), tailscale (v1.94.1→v1.94.2 — still inside the safe pre-1.96.5 range), alloy (v1.14.1→v1.16.0). - **mise-tasks**: PEP 723 inline deps converted from `>=` to `==` (PEP 508 doesn't support hashes inline). All scripts pinned to current latest: rich 15.0.0, typer 0.25.0, pyyaml 6.0.3, httpx 0.28.1. - **prek `additional_dependencies`**: ansible-lint==26.4.0, ansible-core==2.20.5. - **taplo-lint**: pass `--no-schema`. Upstream's `--default-schema-catalogs` returns a format taplo v0.9.3 can't parse — we don't validate against TOML schemas anyway, so this turns off the broken catalog fetch. - **docs/update-tooling-dependencies**: documents the SHA-pin convention, `docker buildx imagetools inspect` for digest lookup, and `prek clean` before re-verifying (cache grows to several GiB). Forgejo workflow `actions/checkout@v6.0.2` was already at the latest SHA — no change. ## Test plan - [x] `prek run --all-files` passes after `prek clean` - [x] `deploy-fly` workflow builds and deploys the new fly image on merge - [x] `fly status -a blumeops-proxy` healthy after deploy - [x] Spot-check a few mise tasks (`mise run blumeops-tasks`, `mise run docs-check-links`) to confirm pinned deps resolve cleanly Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/344 --- .../update-tooling-deps-2026-04.doc.md | 1 + .../update-tooling-deps-2026-04.infra.md | 1 + .../configuration/rotate-fly-deploy-token.md | 108 ++++++++++++++++++ .../update-tooling-dependencies.md | 28 +++-- docs/how-to/operations/manage-flyio-proxy.md | 4 + fly/Dockerfile | 13 ++- mise-tasks/blumeops-tasks | 2 +- mise-tasks/branch-cleanup | 2 +- mise-tasks/container-build-and-release | 2 +- mise-tasks/container-list | 2 +- mise-tasks/container-version-check | 2 +- mise-tasks/dns-acme-cleanup | 2 +- mise-tasks/docs-check-frontmatter | 2 +- mise-tasks/docs-check-links | 2 +- mise-tasks/docs-mikado | 2 +- mise-tasks/docs-preview | 2 +- mise-tasks/docs-review | 2 +- mise-tasks/docs-review-stale | 2 +- mise-tasks/docs-review-tags | 2 +- mise-tasks/mikado-branch-invariant-check | 2 +- mise-tasks/op-backup | 2 +- mise-tasks/pr-comments | 2 +- mise-tasks/prune-ringtail-generations | 2 +- mise-tasks/review-compensating-controls | 2 +- mise-tasks/review-compliance-reports | 2 +- mise-tasks/runner-logs | 2 +- mise-tasks/service-review | 2 +- mise-tasks/spork-create | 2 +- prek.toml | 24 ++-- 29 files changed, 175 insertions(+), 48 deletions(-) create mode 100644 docs/changelog.d/update-tooling-deps-2026-04.doc.md create mode 100644 docs/changelog.d/update-tooling-deps-2026-04.infra.md create mode 100644 docs/how-to/configuration/rotate-fly-deploy-token.md diff --git a/docs/changelog.d/update-tooling-deps-2026-04.doc.md b/docs/changelog.d/update-tooling-deps-2026-04.doc.md new file mode 100644 index 0000000..141e975 --- /dev/null +++ b/docs/changelog.d/update-tooling-deps-2026-04.doc.md @@ -0,0 +1 @@ +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. diff --git a/docs/changelog.d/update-tooling-deps-2026-04.infra.md b/docs/changelog.d/update-tooling-deps-2026-04.infra.md new file mode 100644 index 0000000..4731eca --- /dev/null +++ b/docs/changelog.d/update-tooling-deps-2026-04.infra.md @@ -0,0 +1 @@ +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. diff --git a/docs/how-to/configuration/rotate-fly-deploy-token.md b/docs/how-to/configuration/rotate-fly-deploy-token.md new file mode 100644 index 0000000..58aba21 --- /dev/null +++ b/docs/how-to/configuration/rotate-fly-deploy-token.md @@ -0,0 +1,108 @@ +--- +title: Rotate the Fly.io API Token +modified: 2026-04-30 +last-reviewed: 2026-04-30 +tags: + - how-to + - fly-io + - secrets +--- + +# Rotate the Fly.io API Token + +How to rotate the Fly.io API token used to deploy [[flyio-proxy]]. The token lives in 1Password at `op://blumeops/fly.io admin/add more/deploy-token` and is consumed by [`mise run fly-deploy`](../../../mise-tasks/fly-deploy) and the `deploy-fly` Forgejo workflow (via the `FLY_DEPLOY_TOKEN` secret). + +## When to rotate + +- Every 75 days (Todoist recurring task) +- After any compromise / accidental disclosure +- If `fly deploy` starts returning auth errors + +Fly.io tokens default to a 20-year expiry, but a short rotation cadence limits the blast radius of an undetected leak. Token expiry is set to **90 days** (longer than the rotation window), leaving a 15-day buffer if a rotation is delayed. + +## Scope + +Use **`fly tokens create org`**, not `deploy`. + +| Scope | What it grants | Practical blast radius (this org) | +|-------|---------------|-----------------------------------| +| `deploy` | Manage one app and its resources | Same single-app surface as `org` for current setup | +| `org` | Manage one org and its resources | Adds: ability to create new apps (billing abuse) and read org-level metadata | +| `readonly` | Read one org | Not enough to deploy | +| Personal access token | Full account | Excessive | + +The personal Fly org currently contains a single app (`blumeops-proxy`), so the marginal blast radius of `org` over `deploy` is small. The benefit of `org` is that `fly status` works without a `Metrics token unavailable: ... context canceled` warning. That warning happens because `fly status` always tries to fetch org-level metrics-token info, and an app-scoped `deploy` token can't query the org. The warning is benign but persistent and could mask a real future failure. + +If a second Fly app is ever added to this org, reconsider — at that point the marginal scope cost of `org` grows. + +## Procedure + +### 1. Authenticate flyctl with the current token + +```fish +fly auth login +``` + +(Browser-based. Required to mint a new token, since the existing deploy token can't create tokens.) + +### 2. Mint the new token + +```fish +fly tokens create org \ + --org personal \ + --name "blumeops-proxy deploy $(date +%Y-%m-%d)" \ + --expiry 2160h +``` + +(`2160h` = 90 days, paired with the 75-day rotation cadence for a 15-day buffer. Capture the output — it's the only time the token is shown.) + +### 3. Update 1Password + +```fish +op item edit on5slfaygtdjrxmdwezyhfmqsq 'add more.deploy-token=' --vault vg6xf6vvfmoh5hqjjhlhbeoaie +``` + +### 4. Sync to Forgejo Actions + +The `deploy-fly` workflow reads the same token from a Forgejo Actions secret named `FLY_DEPLOY_TOKEN`, populated by the `forgejo_actions_secrets` ansible role: + +```fish +mise run provision-indri -- --tags forgejo_actions_secrets +``` + +### 5. Verify + +```fish +mise run fly-deploy +``` + +A successful deploy confirms the new token works locally. Watch for the metrics-token warning — it should be **absent** with an `org`-scoped token. If still present, the rotation produced a `deploy`-scoped token by mistake. + +Then trigger the CI workflow (push a no-op commit touching `fly/`, or dispatch manually) to confirm Forgejo Actions has the new secret. + +### 6. Revoke the old token + +```fish +fly tokens list +fly tokens revoke +``` + +## Debugging + +### `fly deploy` returns "unauthorized" + +Token is invalid (expired, revoked, or wrong scope). Repeat the procedure. + +### `Metrics token unavailable: ... context canceled` after rotation + +The new token was created with `deploy` scope, not `org`. Either accept it (cosmetic) or re-mint with `fly tokens create org`. + +### Forgejo Actions deploy fails but local works + +The Forgejo secret wasn't synced. Re-run `mise run provision-indri -- --tags forgejo_actions_secrets` and confirm the secret value in Forgejo matches 1Password. + +## Related + +- [[flyio-proxy]] — Service reference card +- [[manage-flyio-proxy]] — Day-to-day operations and Tailscale auth-key rotation (separate 90-day rotation) +- [[expose-service-publicly]] — Full setup architecture diff --git a/docs/how-to/configuration/update-tooling-dependencies.md b/docs/how-to/configuration/update-tooling-dependencies.md index 8b09e6d..2bfe887 100644 --- a/docs/how-to/configuration/update-tooling-dependencies.md +++ b/docs/how-to/configuration/update-tooling-dependencies.md @@ -28,33 +28,45 @@ Out of scope: ArgoCD-deployed service images, Ansible role versions, NixOS flake ### 1. Check prek hook versions -For each repo in `prek.toml` with a `rev =` value, check the upstream GitHub releases page for a newer tag. Update each `rev` to the latest release tag. Also check `additional_dependencies` entries for PyPI version bumps. - -Verify after updating: +For each repo in `prek.toml` with a `rev =` value, check the upstream GitHub releases page for a newer tag. Update each `rev` to the **commit SHA** of the latest release with a trailing `# vX.Y.Z` comment (matches the `additional_dependencies` and Forgejo workflow pinning style). Also check `additional_dependencies` entries for PyPI version bumps and pin them with `==`. ```fish +git ls-remote --tags https://github.com//.git 'refs/tags/v*' | sort -t/ -k3 -V | tail -5 +``` + +Clear the prek cache before verifying — it can grow to several GiB (one venv per hook per version) and old cached environments can mask resolution failures or stale catalogs: + +```fish +prek clean prek run --all-files ``` ### 2. Check Fly.io Dockerfile pins -Review `fly/Dockerfile` for pinned image tags: +Review `fly/Dockerfile` for pinned image digests. Each `FROM` and `COPY --from=` uses `image@sha256:...` digest pinning with a comment line above documenting the human-readable version. - **nginx** — check [Docker Hub](https://hub.docker.com/_/nginx) for latest stable alpine tag - **grafana/alloy** — check [GitHub releases](https://github.com/grafana/alloy/releases) -- **tailscale/tailscale** — uses `stable` rolling tag, no action needed +- **tailscale/tailscale** — pinned to a known-good version. Do not bump to v1.96.5 or later (MagicDNS regression breaks the proxy boot) + +To resolve a tag to a digest: + +```fish +docker buildx imagetools inspect docker.io/: +# Use the top-level "Digest:" line (multi-arch index) — not the per-platform sub-digest +``` After updating, the deploy-fly workflow will build and deploy on merge to main. Verify with `fly status -a blumeops-proxy` after deploy. -### 3. Normalize mise task dependency bounds +### 3. Pin mise task dependencies -Mise tasks use `uv run --script` with inline PEP 723 dependency metadata. Check that lower bounds are consistent across all scripts: +Mise tasks use `uv run --script` with inline PEP 723 dependency metadata. All packages are pinned with `==` (PEP 508 doesn't support hashes inline). Check that pinned versions are consistent across all scripts: ```fish grep -r 'dependencies' mise-tasks/ | grep '# dependencies' ``` -Ensure all scripts using the same package agree on the minimum version. When a package has a new major or breaking minor release, bump the lower bound across all scripts at once. +For each package in use (`httpx`, `rich`, `typer`, `pyyaml`), pick the latest PyPI version and update every script in lockstep — divergence between scripts is the failure mode this catches. Bump everything together; don't leave one script behind. ### 4. Pin Forgejo workflow action versions diff --git a/docs/how-to/operations/manage-flyio-proxy.md b/docs/how-to/operations/manage-flyio-proxy.md index 5cea783..d1a243d 100644 --- a/docs/how-to/operations/manage-flyio-proxy.md +++ b/docs/how-to/operations/manage-flyio-proxy.md @@ -76,6 +76,10 @@ The auth key expires every 90 days. To rotate: 2. Re-run setup to stage the new secret: `mise run fly-setup` 3. Deploy to pick up the new secret: `mise run fly-deploy` +## Rotate Fly.io API Token + +See [[rotate-fly-deploy-token]] for the full rotation procedure (75-day cadence, `org`-scoped). + ## Troubleshooting **502 Bad Gateway on fresh deploy**: MagicDNS may not be ready when nginx starts. The `start.sh` script polls `nslookup` before launching nginx, but if it still fails, check that `tailscale status` is healthy inside the container. diff --git a/fly/Dockerfile b/fly/Dockerfile index 8a6df31..eae8c35 100644 --- a/fly/Dockerfile +++ b/fly/Dockerfile @@ -1,9 +1,10 @@ -FROM nginx:1.29.6-alpine +# nginx 1.30.0-alpine +FROM nginx@sha256:0272e4604ed93c1792f03695a033a6e8546840f86e0de20a884bb17d2c924883 -# Copy tailscale binaries from official image -COPY --from=docker.io/tailscale/tailscale:v1.94.1 \ +# Copy tailscale binaries from official image (v1.94.2) +COPY --from=docker.io/tailscale/tailscale@sha256:95e528798bebe75f39b10e74e7051cf51188ee615934f232ba7ad06a3390ffa1 \ /usr/local/bin/tailscaled /usr/local/bin/tailscaled -COPY --from=docker.io/tailscale/tailscale:v1.94.1 \ +COPY --from=docker.io/tailscale/tailscale@sha256:95e528798bebe75f39b10e74e7051cf51188ee615934f232ba7ad06a3390ffa1 \ /usr/local/bin/tailscale /usr/local/bin/tailscale RUN mkdir -p /var/run/tailscale /var/lib/tailscale \ @@ -12,8 +13,8 @@ RUN mkdir -p /var/run/tailscale /var/lib/tailscale \ && apk add --no-cache fail2ban \ && rm -f /etc/fail2ban/jail.d/alpine-ssh.conf -# Copy Alloy binary from official image (Ubuntu-based, needs libc6-compat) -COPY --from=docker.io/grafana/alloy:v1.14.1 \ +# Copy Alloy binary from official image (v1.16.0, Ubuntu-based, needs libc6-compat) +COPY --from=docker.io/grafana/alloy@sha256:6e00cf7c5a692ff5f24844529416ed017d76fce922f8199004e73d5eca46b6b8 \ /bin/alloy /usr/local/bin/alloy RUN mkdir -p /var/log/nginx /etc/alloy /tmp/alloy-data diff --git a/mise-tasks/blumeops-tasks b/mise-tasks/blumeops-tasks index e07e9bf..035aa3b 100755 --- a/mise-tasks/blumeops-tasks +++ b/mise-tasks/blumeops-tasks @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.12" -# dependencies = ["httpx>=0.28.1", "rich>=14.0.0"] +# dependencies = ["httpx==0.28.1", "rich==15.0.0"] # /// #MISE description="List Blumeops tasks from Todoist sorted by priority" """Fetch and display Blumeops tasks from Todoist, sorted by priority. diff --git a/mise-tasks/branch-cleanup b/mise-tasks/branch-cleanup index bd5ac66..575c9a1 100755 --- a/mise-tasks/branch-cleanup +++ b/mise-tasks/branch-cleanup @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.12" -# dependencies = ["httpx>=0.28.1", "rich>=14.0.0", "typer>=0.24.0"] +# dependencies = ["httpx==0.28.1", "rich==15.0.0", "typer==0.25.0"] # /// #MISE description="Delete branches that have been merged into main (local and remote)" #MISE alias="bc" diff --git a/mise-tasks/container-build-and-release b/mise-tasks/container-build-and-release index afa970e..ba569e7 100755 --- a/mise-tasks/container-build-and-release +++ b/mise-tasks/container-build-and-release @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.12" -# dependencies = ["typer>=0.24.0", "httpx>=0.28.1"] +# dependencies = ["typer==0.25.0", "httpx==0.28.1"] # /// #MISE description="Trigger container build workflows via Forgejo API" #USAGE arg "" help="Container name (directory under containers/)" diff --git a/mise-tasks/container-list b/mise-tasks/container-list index b1bd433..26639f2 100755 --- a/mise-tasks/container-list +++ b/mise-tasks/container-list @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.12" -# dependencies = ["httpx>=0.28.1", "rich>=14.0.0", "typer>=0.24.0"] +# dependencies = ["httpx==0.28.1", "rich==15.0.0", "typer==0.25.0"] # /// #MISE description="List available containers and their recent tags" #USAGE arg "[name]" help="Optional container name to filter output" diff --git a/mise-tasks/container-version-check b/mise-tasks/container-version-check index 95cf6f0..4ebe3b6 100755 --- a/mise-tasks/container-version-check +++ b/mise-tasks/container-version-check @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.12" -# dependencies = ["pyyaml>=6.0.2", "rich>=14.0.0", "typer>=0.24.0"] +# dependencies = ["pyyaml==6.0.3", "rich==15.0.0", "typer==0.25.0"] # /// #MISE description="Validate container version consistency across container.py, Dockerfiles, nix derivations, and service-versions.yaml" #USAGE flag "--all-files" help="Check all containers, not just changed ones" diff --git a/mise-tasks/dns-acme-cleanup b/mise-tasks/dns-acme-cleanup index 5152ae2..432a6ce 100755 --- a/mise-tasks/dns-acme-cleanup +++ b/mise-tasks/dns-acme-cleanup @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.12" -# dependencies = ["httpx>=0.28.1", "rich>=14.0.0", "typer>=0.24.0"] +# dependencies = ["httpx==0.28.1", "rich==15.0.0", "typer==0.25.0"] # /// #MISE description="Delete orphaned ACME challenge TXT records in eblu.me" #USAGE flag "--dry-run" help="List orphans without deleting" diff --git a/mise-tasks/docs-check-frontmatter b/mise-tasks/docs-check-frontmatter index 11d1a49..35e1879 100755 --- a/mise-tasks/docs-check-frontmatter +++ b/mise-tasks/docs-check-frontmatter @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.12" -# dependencies = ["rich>=14.0.0"] +# dependencies = ["rich==15.0.0"] # /// #MISE description="Check that all docs have required frontmatter fields" """Validate that all documentation files have required YAML frontmatter. diff --git a/mise-tasks/docs-check-links b/mise-tasks/docs-check-links index 78e871a..9974fc7 100755 --- a/mise-tasks/docs-check-links +++ b/mise-tasks/docs-check-links @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.12" -# dependencies = ["rich>=14.0.0"] +# dependencies = ["rich==15.0.0"] # /// #MISE description="Validate all wiki-links point to existing doc files" """Validate that all wiki-links in documentation point to existing files. diff --git a/mise-tasks/docs-mikado b/mise-tasks/docs-mikado index 0b37f51..eea052f 100755 --- a/mise-tasks/docs-mikado +++ b/mise-tasks/docs-mikado @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.12" -# dependencies = ["httpx>=0.28.1", "pyyaml>=6.0.2", "rich>=14.0.0", "typer>=0.24.0"] +# dependencies = ["httpx==0.28.1", "pyyaml==6.0.3", "rich==15.0.0", "typer==0.25.0"] # /// #MISE description="View active Mikado dependency chains for C2 changes" #USAGE arg "[card]" help="Card stem to show chain for" diff --git a/mise-tasks/docs-preview b/mise-tasks/docs-preview index f63b1d1..faa79af 100755 --- a/mise-tasks/docs-preview +++ b/mise-tasks/docs-preview @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.12" -# dependencies = ["pyyaml>=6.0.2", "rich>=14.0.0", "typer>=0.24.0"] +# dependencies = ["pyyaml==6.0.3", "rich==15.0.0", "typer==0.25.0"] # /// #MISE description="Build docs with Dagger and serve locally, opening to a specific card" #USAGE arg "" help="Card path relative to docs/, e.g. how-to/knowledgebase/review-documentation" diff --git a/mise-tasks/docs-review b/mise-tasks/docs-review index 49cf4d0..d07904d 100755 --- a/mise-tasks/docs-review +++ b/mise-tasks/docs-review @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.12" -# dependencies = ["pyyaml>=6.0.2", "rich>=14.0.0", "typer>=0.24.0"] +# dependencies = ["pyyaml==6.0.3", "rich==15.0.0", "typer==0.25.0"] # /// #MISE description="Review the most stale documentation card by last-reviewed date" #USAGE flag "--limit " default="15" help="Number of docs to show in the table" diff --git a/mise-tasks/docs-review-stale b/mise-tasks/docs-review-stale index facbf6b..4449213 100755 --- a/mise-tasks/docs-review-stale +++ b/mise-tasks/docs-review-stale @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.12" -# dependencies = ["rich>=14.0.0", "typer>=0.24.0"] +# dependencies = ["rich==15.0.0", "typer==0.25.0"] # /// #MISE description="Report docs by git-last-modified date, highlighting stale ones" #USAGE flag "--threshold " default="180" help="Days before a doc is considered stale" diff --git a/mise-tasks/docs-review-tags b/mise-tasks/docs-review-tags index 0e7f1d4..869e2f2 100755 --- a/mise-tasks/docs-review-tags +++ b/mise-tasks/docs-review-tags @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.12" -# dependencies = ["pyyaml>=6.0.2", "rich>=14.0.0"] +# dependencies = ["pyyaml==6.0.3", "rich==15.0.0"] # /// #MISE description="Print frontmatter tag inventory across all docs" """Print every frontmatter tag with usage count and file list. diff --git a/mise-tasks/mikado-branch-invariant-check b/mise-tasks/mikado-branch-invariant-check index ca9f79a..1f0fbcf 100755 --- a/mise-tasks/mikado-branch-invariant-check +++ b/mise-tasks/mikado-branch-invariant-check @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.12" -# dependencies = ["rich>=14.0.0", "typer>=0.24.0"] +# dependencies = ["rich==15.0.0", "typer==0.25.0"] # /// #MISE description="Validate Mikado Branch Invariant on mikado/* branches" #USAGE arg "[commit_msg_file]" help="Commit message file (passed by commit-msg hook)" diff --git a/mise-tasks/op-backup b/mise-tasks/op-backup index 6ffef14..37a97a6 100755 --- a/mise-tasks/op-backup +++ b/mise-tasks/op-backup @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.12" -# dependencies = ["rich>=14.0.0", "typer>=0.24.0"] +# dependencies = ["rich==15.0.0", "typer==0.25.0"] # /// #MISE description="Encrypt a 1Password .1pux export and send to indri for borgmatic" #USAGE arg "[export_path]" help="Path to .1pux export file (prompted if omitted)" diff --git a/mise-tasks/pr-comments b/mise-tasks/pr-comments index a44a430..7205617 100755 --- a/mise-tasks/pr-comments +++ b/mise-tasks/pr-comments @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.12" -# dependencies = ["httpx>=0.28.1", "rich>=14.0.0", "typer>=0.24.0"] +# dependencies = ["httpx==0.28.1", "rich==15.0.0", "typer==0.25.0"] # /// #MISE description="List unresolved comments on a PR" #USAGE arg "" help="Pull request number" diff --git a/mise-tasks/prune-ringtail-generations b/mise-tasks/prune-ringtail-generations index 8066f8b..2b8e3f9 100755 --- a/mise-tasks/prune-ringtail-generations +++ b/mise-tasks/prune-ringtail-generations @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.12" -# dependencies = ["rich>=14.0.0", "typer>=0.24.0"] +# dependencies = ["rich==15.0.0", "typer==0.25.0"] # /// #MISE description="Prune old NixOS generations on ringtail, preserving rollback safety" #MISE alias="prg" diff --git a/mise-tasks/review-compensating-controls b/mise-tasks/review-compensating-controls index 09e2d16..e92d302 100755 --- a/mise-tasks/review-compensating-controls +++ b/mise-tasks/review-compensating-controls @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.12" -# dependencies = ["pyyaml>=6.0.2", "rich>=14.0.0", "typer>=0.24.0"] +# dependencies = ["pyyaml==6.0.3", "rich==15.0.0", "typer==0.25.0"] # /// #MISE description="Review the most stale compensating control" #USAGE flag "--limit " default="10" help="Number of controls to show in the table" diff --git a/mise-tasks/review-compliance-reports b/mise-tasks/review-compliance-reports index 72f35cc..bcbe090 100755 --- a/mise-tasks/review-compliance-reports +++ b/mise-tasks/review-compliance-reports @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.12" -# dependencies = ["rich>=14.0.0", "typer>=0.24.0", "pyyaml>=6.0"] +# dependencies = ["rich==15.0.0", "typer==0.25.0", "pyyaml==6.0.3"] # /// #MISE description="Summarize the latest Prowler and Kingfisher compliance reports from sifaka" #USAGE flag "--full" help="Show all unmuted failures, not just new ones" diff --git a/mise-tasks/runner-logs b/mise-tasks/runner-logs index 579a5fd..9c988ee 100755 --- a/mise-tasks/runner-logs +++ b/mise-tasks/runner-logs @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.12" -# dependencies = ["httpx>=0.28.1", "rich>=14.0.0", "typer>=0.24.0"] +# dependencies = ["httpx==0.28.1", "rich==15.0.0", "typer==0.25.0"] # /// #MISE description="List recent Forgejo Actions runs or fetch logs for a specific job" #USAGE arg "[run_number]" help="Run number to show jobs for (omit to list recent runs)" diff --git a/mise-tasks/service-review b/mise-tasks/service-review index 01c4ce0..2d50e0b 100755 --- a/mise-tasks/service-review +++ b/mise-tasks/service-review @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.12" -# dependencies = ["pyyaml>=6.0.2", "rich>=14.0.0", "typer>=0.24.0"] +# dependencies = ["pyyaml==6.0.3", "rich==15.0.0", "typer==0.25.0"] # /// #MISE description="Review the most stale service for version freshness" #USAGE flag "--limit " default="15" help="Number of services to show in the table" diff --git a/mise-tasks/spork-create b/mise-tasks/spork-create index 84d2999..92f4e5c 100755 --- a/mise-tasks/spork-create +++ b/mise-tasks/spork-create @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.12" -# dependencies = ["httpx>=0.28.1", "rich>=14.0.0", "typer>=0.24.0"] +# dependencies = ["httpx==0.28.1", "rich==15.0.0", "typer==0.25.0"] # /// #MISE description="Create a spork (floating-branch soft-fork) of a mirrored upstream project" #USAGE arg "" help="Repository name in the mirrors/ org on forge (e.g. kingfisher)" diff --git a/prek.toml b/prek.toml index 28776c5..add7799 100644 --- a/prek.toml +++ b/prek.toml @@ -22,13 +22,13 @@ hooks = [ # check-yaml with --unsafe (builtin fast path doesn't support --unsafe yet) [[repos]] repo = "https://github.com/pre-commit/pre-commit-hooks" -rev = "v6.0.0" +rev = "3e8a8703264a2f4a69428a0aa4dcb512790b2c8c" # v6.0.0 hooks = [{ id = "check-yaml", args = ["--unsafe"] }] # Secret detection (running both tools in parallel to compare coverage) [[repos]] repo = "https://github.com/trufflesecurity/trufflehog" -rev = "v3.94.0" +rev = "17456f8c7d042d8c82c9a8ca9e937231f9f42e26" # v3.95.2 hooks = [ { id = "trufflehog", entry = "trufflehog git file://. --since-commit HEAD --no-verification --fail", stages = [ "pre-commit", @@ -38,7 +38,7 @@ hooks = [ [[repos]] repo = "https://github.com/mongodb/kingfisher" -rev = "v1.91.0" +rev = "9ddec4ab8b53653d4941e6b3fd4ff602ce91d81b" # v1.97.0 hooks = [ { id = "kingfisher", args = [ "scan", @@ -56,7 +56,7 @@ hooks = [ # YAML linting [[repos]] repo = "https://github.com/adrienverge/yamllint" -rev = "v1.38.0" +rev = "cba56bcde1fdd01c1deb3f945e69764c291a6530" # v1.38.0 hooks = [{ id = "yamllint", args = ["-c", ".yamllint.yaml"] }] # Ansible linting @@ -69,12 +69,12 @@ name = "ansible-lint" entry = "env ANSIBLE_ROLES_PATH=ansible/roles ansible-lint" language = "python" files = "^ansible/" -additional_dependencies = ["ansible-lint>=26.3.0", "ansible-core>=2.18"] +additional_dependencies = ["ansible-lint==26.4.0", "ansible-core==2.20.5"] # Python - ruff for linting and formatting [[repos]] repo = "https://github.com/astral-sh/ruff-pre-commit" -rev = "v0.15.7" +rev = "6fec9b7edb08fd9989088709d864a7826dc74e80" # v0.15.12 hooks = [{ id = "ruff", args = ["--fix"] }, { id = "ruff-format" }] # Python - ty type checker @@ -92,30 +92,30 @@ pass_filenames = false # Shell scripts - shellcheck and shfmt [[repos]] repo = "https://github.com/shellcheck-py/shellcheck-py" -rev = "v0.11.0.1" +rev = "745eface02aef23e168a8afb6b5737818efbea95" # v0.11.0.1 hooks = [{ id = "shellcheck", args = ["--severity=warning"] }] [[repos]] repo = "https://github.com/scop/pre-commit-shfmt" -rev = "v3.13.0-1" +rev = "05c1426671b9237fb5e1444dd63aa5731bec0dfb" # v3.13.1-1 hooks = [{ id = "shfmt", args = ["-i", "2", "-ci", "-bn"] }] # TOML - taplo [[repos]] repo = "https://github.com/ComPWA/taplo-pre-commit" -rev = "v0.9.3" -hooks = [{ id = "taplo-format" }, { id = "taplo-lint" }] +rev = "23eab0f0eedcbedebff420f5fdfb284744adc7b3" # v0.9.3 +hooks = [{ id = "taplo-format" }, { id = "taplo-lint", args = ["--no-schema"] }] # JSON formatting (prettier for consistent style) [[repos]] repo = "https://github.com/rbubley/mirrors-prettier" -rev = "v3.8.1" +rev = "515f543f5718ebfd6ce22e16708bb32c68ff96e1" # v3.8.3 hooks = [{ id = "prettier", types_or = ["json"], args = ["--tab-width", "2"] }] # GitHub/Forgejo Actions workflow linting [[repos]] repo = "https://github.com/rhysd/actionlint" -rev = "v1.7.11" +rev = "914e7df21a07ef503a81201c76d2b11c789d3fca" # v1.7.12 hooks = [ { id = "actionlint-system", args = [ "-config-file", From 7fed166c18639bf4ec5950c39fed748d978725e3 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Thu, 30 Apr 2026 16:55:08 -0700 Subject: [PATCH 347/430] Update ringtail flake inputs Co-Authored-By: Claude Opus 4.7 (1M context) --- nixos/ringtail/flake.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/nixos/ringtail/flake.lock b/nixos/ringtail/flake.lock index 90fdff1..d6a85dc 100644 --- a/nixos/ringtail/flake.lock +++ b/nixos/ringtail/flake.lock @@ -43,11 +43,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1776434932, - "narHash": "sha256-gyqXNMgk3sh+ogY5svd2eNLJ6oEwzbAeaoBrrxD0lKk=", + "lastModified": 1777428379, + "narHash": "sha256-ypxFOeDz+CqADEQNL72haqGjvZQdBR5Vc7pyx2JDttI=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "c7f47036d3df2add644c46d712d14262b7d86c0c", + "rev": "755f5aa91337890c432639c60b6064bb7fe67769", "type": "github" }, "original": { From 9564435b11ec7285af83fef114a8658317389665 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Fri, 1 May 2026 08:05:37 -0700 Subject: [PATCH 348/430] Alloy V1.16.0 (#345) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump Grafana Alloy v1.14.0 → v1.16.0 across all four services (alloy-k8s, alloy-ringtail, alloy-tracing-ringtail; alloy native ansible). Also migrate the indri build path from `Dockerfile` to a native Dagger `container.py` per the build-container-image migration playbook. ## Highlights from upstream - v1.15: database observability promoted to stable, OTel Collector → v0.147.0 - v1.16: clustering for `loki.source.kubernetes_events`, MySQL exporter 0.19.0 - One pre-existing breaking change in v1.15 (`loki.source.awsfirehose` undocumented metric prefix rename) — not used here. ## Build infra Alloy v1.16.0's go.mod requires Go 1.26.2. The nix derivation now uses `pkgs.go_1_26` with `GOTOOLCHAIN=local` to avoid auto-downloading a toolchain blob that violated the fixed-output rule. ## Test plan - [ ] CI: `mise run container-build-and-release alloy --ref alloy-v1.16.0` (dispatched as run 522; nix job to be re-triggered with the v1.16.0 goModules outputHash once the local ringtail build surfaces it) - [ ] After CI green, bump `images[].newTag` in three kustomizations to the new `-` and `--nix` tags, deploy from this branch via `argocd app set --revision alloy-v1.16.0 && argocd app sync ` - [ ] Manual rebuild of macOS native binary on gilbert (per ansible/roles/alloy README) and `mise run provision-indri -- --tags alloy --check --diff` - [ ] `mise run services-check` after merge & redeploy Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/345 --- argocd/manifests/alloy-k8s/kustomization.yaml | 2 +- .../alloy-ringtail/kustomization.yaml | 2 +- .../alloy-tracing-ringtail/kustomization.yaml | 2 +- containers/alloy/Dockerfile | 68 ------------- containers/alloy/container.py | 95 +++++++++++++++++++ containers/alloy/default.nix | 16 ++-- docs/changelog.d/alloy-v1.16.0.infra.md | 5 + service-versions.yaml | 16 ++-- 8 files changed, 120 insertions(+), 86 deletions(-) delete mode 100644 containers/alloy/Dockerfile create mode 100644 containers/alloy/container.py create mode 100644 docs/changelog.d/alloy-v1.16.0.infra.md diff --git a/argocd/manifests/alloy-k8s/kustomization.yaml b/argocd/manifests/alloy-k8s/kustomization.yaml index f51bd3a..0326190 100644 --- a/argocd/manifests/alloy-k8s/kustomization.yaml +++ b/argocd/manifests/alloy-k8s/kustomization.yaml @@ -10,7 +10,7 @@ resources: images: - name: registry.ops.eblu.me/blumeops/alloy - newTag: v1.14.0-fd0bebb + newTag: v1.16.0-26a3ab5 configMapGenerator: - name: alloy-config diff --git a/argocd/manifests/alloy-ringtail/kustomization.yaml b/argocd/manifests/alloy-ringtail/kustomization.yaml index df472aa..cecae35 100644 --- a/argocd/manifests/alloy-ringtail/kustomization.yaml +++ b/argocd/manifests/alloy-ringtail/kustomization.yaml @@ -10,7 +10,7 @@ resources: images: - name: registry.ops.eblu.me/blumeops/alloy - newTag: v1.14.0-fd0bebb-nix + newTag: v1.16.0-26a3ab5-nix configMapGenerator: - name: alloy-config diff --git a/argocd/manifests/alloy-tracing-ringtail/kustomization.yaml b/argocd/manifests/alloy-tracing-ringtail/kustomization.yaml index 5c8e683..ac25f4a 100644 --- a/argocd/manifests/alloy-tracing-ringtail/kustomization.yaml +++ b/argocd/manifests/alloy-tracing-ringtail/kustomization.yaml @@ -9,7 +9,7 @@ resources: images: - name: registry.ops.eblu.me/blumeops/alloy - newTag: v1.14.0-fd0bebb-nix + newTag: v1.16.0-26a3ab5-nix configMapGenerator: - name: alloy-tracing-config diff --git a/containers/alloy/Dockerfile b/containers/alloy/Dockerfile deleted file mode 100644 index f2f30f6..0000000 --- a/containers/alloy/Dockerfile +++ /dev/null @@ -1,68 +0,0 @@ -# Grafana Alloy telemetry collector -# Three-stage build: Web UI (Node), server (Go), runtime (Alpine) - -ARG CONTAINER_APP_VERSION=1.14.0 -ARG ALLOY_VERSION=v${CONTAINER_APP_VERSION} -ARG ALLOY_COMMIT=626a738319812d58ebc25ca6d71651f4925b8b18 - -FROM node:22-alpine AS ui-build - -ARG ALLOY_COMMIT -RUN apk add --no-cache git - -RUN mkdir /app && cd /app \ - && git init \ - && git remote add origin https://forge.ops.eblu.me/mirrors/alloy.git \ - && git fetch --depth 1 origin ${ALLOY_COMMIT} \ - && git checkout FETCH_HEAD - -WORKDIR /app/internal/web/ui -RUN npm ci -RUN npx tsc -b && npx vite build - -FROM golang:1.25-alpine3.22 AS build - -ARG ALLOY_VERSION -ARG ALLOY_COMMIT -RUN apk add --no-cache build-base git - -RUN mkdir /app && cd /app \ - && git init \ - && git remote add origin https://forge.ops.eblu.me/mirrors/alloy.git \ - && git fetch --depth 1 origin ${ALLOY_COMMIT} \ - && git checkout FETCH_HEAD - -WORKDIR /app - -# Copy pre-built web UI assets -COPY --from=ui-build /app/internal/web/ui/dist /app/internal/web/ui/dist - -ENV CGO_ENABLED=1 - -# promtail_journal_enabled omitted: requires systemd headers (libsystemd-dev) -# and our k8s deployments read pod logs from the filesystem, not journald -RUN RELEASE_BUILD=1 VERSION=${ALLOY_VERSION} \ - GO_TAGS="netgo embedalloyui" \ - SKIP_UI_BUILD=1 \ - make alloy - -FROM alpine:3.22 - -ARG CONTAINER_APP_VERSION -LABEL org.opencontainers.image.title="Alloy" -LABEL org.opencontainers.image.description="Grafana Alloy is an OpenTelemetry Collector distribution" -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" - -RUN apk --no-cache add ca-certificates tzdata \ - && addgroup -g 473 alloy \ - && adduser -D -u 473 -G alloy alloy \ - && mkdir -p /var/lib/alloy/data \ - && chown -R alloy:alloy /var/lib/alloy - -COPY --from=build --chown=473:473 /app/build/alloy /bin/alloy - -ENTRYPOINT ["/bin/alloy"] -ENV ALLOY_DEPLOY_MODE=docker -CMD ["run", "/etc/alloy/config.alloy", "--storage.path=/var/lib/alloy/data"] diff --git a/containers/alloy/container.py b/containers/alloy/container.py new file mode 100644 index 0000000..41d3995 --- /dev/null +++ b/containers/alloy/container.py @@ -0,0 +1,95 @@ +"""Grafana Alloy — telemetry collector, native Dagger build. + +Three-stage build: Node (UI), Go (server via upstream Makefile with embedded +UI assets), Alpine (runtime). Source cloned from forge mirror. + +Notes: + - Builds via `make alloy` rather than plain `go build` so version stamping, + release flags, and the netgo+embedalloyui tags match upstream releases. + - promtail_journal_enabled is intentionally omitted: it requires + libsystemd-dev and our k8s deployments read pod logs from the filesystem, + not journald. + - Uses golang:alpine3.23 (currently Go 1.26.2 — matches alloy v1.16.0's + go.mod toolchain requirement and the go_build helper's image choice). +""" + +import dagger +from dagger import dag + +from blumeops.containers import ( + alpine_runtime, + clone_from_forge, + node_build, + oci_labels, +) + +VERSION = "v1.16.0" + + +async def build(src: dagger.Directory) -> dagger.Container: + source = clone_from_forge("alloy", VERSION) + + # Stage 1: Build the web UI (tsc + vite, not the package.json default). + ui = node_build( + source, + "internal/web/ui", + build_cmd=["sh", "-c", "npx tsc -b && npx vite build"], + ) + + # Stage 2: Build alloy via the upstream Makefile with embedded UI assets. + builder = ( + dag.container() + .from_("golang:alpine3.23") + .with_exec(["apk", "add", "--no-cache", "build-base", "git", "make"]) + .with_directory("/app", source) + .with_directory( + "/app/internal/web/ui/dist", + ui.directory("/app/internal/web/ui/dist"), + ) + .with_workdir("/app") + .with_env_variable("CGO_ENABLED", "1") + .with_env_variable("RELEASE_BUILD", "1") + .with_env_variable("VERSION", VERSION) + .with_env_variable("GO_TAGS", "netgo embedalloyui") + .with_env_variable("SKIP_UI_BUILD", "1") + .with_exec(["make", "alloy"]) + ) + + # Stage 3: Runtime as uid/gid 473 alloy. + runtime = alpine_runtime( + extra_apk=["ca-certificates", "tzdata"], + uid=473, + gid=473, + username="alloy", + ) + runtime = oci_labels( + runtime, + title="Alloy", + description="Grafana Alloy is an OpenTelemetry Collector distribution", + version=VERSION, + ) + return ( + runtime.with_file( + "/bin/alloy", + builder.file("/app/build/alloy"), + permissions=0o555, + ) + .with_exec( + [ + "sh", + "-c", + "mkdir -p /var/lib/alloy/data && chown -R alloy:alloy /var/lib/alloy", + ] + ) + .with_env_variable("ALLOY_DEPLOY_MODE", "docker") + .with_exposed_port(12345) + .with_user("alloy") + .with_entrypoint(["/bin/alloy"]) + .with_default_args( + args=[ + "run", + "/etc/alloy/config.alloy", + "--storage.path=/var/lib/alloy/data", + ] + ) + ) diff --git a/containers/alloy/default.nix b/containers/alloy/default.nix index e508a10..c884704 100644 --- a/containers/alloy/default.nix +++ b/containers/alloy/default.nix @@ -1,24 +1,24 @@ # Nix-built Grafana Alloy telemetry collector -# Builds v1.14.0 from forge mirror with embedded web UI +# Builds v1.16.0 from forge mirror with embedded web UI # Uses stdenv + make (not buildGoModule) due to multi-module workspace # with local replace directives (collector/ -> ../, ../syntax, ../extension) # Built with dockerTools.buildLayeredImage for efficient layer caching { pkgs ? import { } }: let - version = "1.14.0"; + version = "1.16.0"; src = pkgs.fetchgit { url = "https://forge.ops.eblu.me/mirrors/alloy.git"; rev = "v${version}"; - hash = "sha256-gxNz4XDE8XSl6LsP3k8DERqDdMLcmbWKfXZGGyRULkg="; + hash = "sha256-q5R2noxBZ3OPyZqmB+bx3iJKWFxC2WIprcgh9RwjLzk="; }; ui = pkgs.buildNpmPackage { inherit version; pname = "alloy-ui"; src = "${src}/internal/web/ui"; - npmDepsHash = "sha256-GT0yisPn+3FCtWL3he0i5zPMlaWNparQDefU69G4Yis="; + npmDepsHash = "sha256-vResNUT4auDsK9ngnJYfMUUOYr/ikPhrvakqCjGq2Q8="; buildPhase = '' runHook preBuild @@ -40,11 +40,12 @@ let pname = "alloy-go-modules"; inherit src version; - nativeBuildInputs = with pkgs; [ go git cacert ]; + nativeBuildInputs = with pkgs; [ go_1_26 git cacert ]; buildPhase = '' export GOPATH=$TMPDIR/go export GOFLAGS=-modcacherw + export GOTOOLCHAIN=local # Download modules for all three go.mod files go mod download cd syntax && go mod download && cd .. @@ -56,7 +57,7 @@ let ''; outputHashMode = "recursive"; - outputHash = "sha256-rD7zqomSVv4d8NaC7jXXgihuQvK8guaAN0KrsBRWMVQ="; + outputHash = "sha256-9/v85HyDInJB+9qHauKVuDol6Yf5mkXfMWgCr7RdRTk="; outputHashAlgo = "sha256"; }; @@ -65,7 +66,7 @@ let pname = "alloy"; nativeBuildInputs = with pkgs; [ - go + go_1_26 git gnumake cacert @@ -77,6 +78,7 @@ let export HOME=$TMPDIR export GOPATH=$TMPDIR/go export GOFLAGS=-modcacherw + export GOTOOLCHAIN=local # Populate module cache from pre-fetched modules mkdir -p $GOPATH/pkg diff --git a/docs/changelog.d/alloy-v1.16.0.infra.md b/docs/changelog.d/alloy-v1.16.0.infra.md new file mode 100644 index 0000000..cd9a1ef --- /dev/null +++ b/docs/changelog.d/alloy-v1.16.0.infra.md @@ -0,0 +1,5 @@ +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. diff --git a/service-versions.yaml b/service-versions.yaml index f4f4a6a..42f9c77 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -72,22 +72,22 @@ services: - name: alloy-tracing-ringtail type: argocd - last-reviewed: 2026-03-13 - current-version: "v1.14.0" + last-reviewed: 2026-04-30 + current-version: "v1.16.0" upstream-source: https://github.com/grafana/alloy/releases notes: Privileged DaemonSet with Beyla eBPF for HTTP tracing on ringtail - name: alloy-ringtail type: argocd - last-reviewed: 2026-03-13 - current-version: "v1.14.0" + last-reviewed: 2026-04-30 + current-version: "v1.16.0" upstream-source: https://github.com/grafana/alloy/releases notes: DaemonSet on ringtail for host metrics and pod logs - name: alloy-k8s type: argocd - last-reviewed: 2026-03-13 - current-version: "v1.14.0" + last-reviewed: 2026-04-30 + current-version: "v1.16.0" upstream-source: https://github.com/grafana/alloy/releases - name: tailscale-operator @@ -338,8 +338,8 @@ services: - name: alloy type: ansible - last-reviewed: 2026-03-13 - current-version: "v1.14.0" + last-reviewed: 2026-04-30 + current-version: "v1.16.0" upstream-source: https://github.com/grafana/alloy/releases notes: Built from source on indri From 55563afc7e15e179806808ba106e5c0e11bf033e Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Fri, 1 May 2026 08:31:27 -0700 Subject: [PATCH 349/430] =?UTF-8?q?C0:=20alloy=20=E2=80=94=20bump=20kustom?= =?UTF-8?q?ization=20tags=20to=20main-branch=20SHA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the build-container-image squash-merge convention, rebuild alloy v1.16.0 container images from the main SHA (9564435) and update the three alloy kustomizations to reference :v1.16.0-9564435[-nix] instead of the branch SHA :v1.16.0-26a3ab5[-nix] left over from #345. Both images were rebuilt locally on gilbert (dagger) and ringtail (nix) because indri is still under heavy macOS memory-compressor pressure (see separate ticket); CI on indri can't reliably run the dagger publish step. Co-Authored-By: Claude Opus 4.7 (1M context) --- argocd/manifests/alloy-k8s/kustomization.yaml | 2 +- argocd/manifests/alloy-ringtail/kustomization.yaml | 2 +- argocd/manifests/alloy-tracing-ringtail/kustomization.yaml | 2 +- docs/changelog.d/+alloy-main-sha-rebuild.infra.md | 5 +++++ 4 files changed, 8 insertions(+), 3 deletions(-) create mode 100644 docs/changelog.d/+alloy-main-sha-rebuild.infra.md diff --git a/argocd/manifests/alloy-k8s/kustomization.yaml b/argocd/manifests/alloy-k8s/kustomization.yaml index 0326190..3503ead 100644 --- a/argocd/manifests/alloy-k8s/kustomization.yaml +++ b/argocd/manifests/alloy-k8s/kustomization.yaml @@ -10,7 +10,7 @@ resources: images: - name: registry.ops.eblu.me/blumeops/alloy - newTag: v1.16.0-26a3ab5 + newTag: v1.16.0-9564435 configMapGenerator: - name: alloy-config diff --git a/argocd/manifests/alloy-ringtail/kustomization.yaml b/argocd/manifests/alloy-ringtail/kustomization.yaml index cecae35..526fec5 100644 --- a/argocd/manifests/alloy-ringtail/kustomization.yaml +++ b/argocd/manifests/alloy-ringtail/kustomization.yaml @@ -10,7 +10,7 @@ resources: images: - name: registry.ops.eblu.me/blumeops/alloy - newTag: v1.16.0-26a3ab5-nix + newTag: v1.16.0-9564435-nix configMapGenerator: - name: alloy-config diff --git a/argocd/manifests/alloy-tracing-ringtail/kustomization.yaml b/argocd/manifests/alloy-tracing-ringtail/kustomization.yaml index ac25f4a..b1e6338 100644 --- a/argocd/manifests/alloy-tracing-ringtail/kustomization.yaml +++ b/argocd/manifests/alloy-tracing-ringtail/kustomization.yaml @@ -9,7 +9,7 @@ resources: images: - name: registry.ops.eblu.me/blumeops/alloy - newTag: v1.16.0-26a3ab5-nix + newTag: v1.16.0-9564435-nix configMapGenerator: - name: alloy-tracing-config diff --git a/docs/changelog.d/+alloy-main-sha-rebuild.infra.md b/docs/changelog.d/+alloy-main-sha-rebuild.infra.md new file mode 100644 index 0000000..42a7b37 --- /dev/null +++ b/docs/changelog.d/+alloy-main-sha-rebuild.infra.md @@ -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. From 2d5530321357f496a0b607a56966b202bc59b206 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Fri, 1 May 2026 10:36:38 -0700 Subject: [PATCH 350/430] =?UTF-8?q?C0:=20alloy=20native=20macOS=20on=20ind?= =?UTF-8?q?ri=20=E2=80=94=20upgrade=20to=20v1.16.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the v1.16.0 fleet upgrade for the fourth alloy service (type: ansible, built from source on indri). Binary built on gilbert with Go 1.26.2 + CGO, scp'd to indri, codesigned, LaunchAgent reloaded. Service reports clean WAL replay and resumed metric/log shipping. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/changelog.d/+alloy-native-macos-v1.16.0.infra.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 docs/changelog.d/+alloy-native-macos-v1.16.0.infra.md diff --git a/docs/changelog.d/+alloy-native-macos-v1.16.0.infra.md b/docs/changelog.d/+alloy-native-macos-v1.16.0.infra.md new file mode 100644 index 0000000..471990f --- /dev/null +++ b/docs/changelog.d/+alloy-native-macos-v1.16.0.infra.md @@ -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. From 4aa08729493522e2b82181b38df040412e2a52c2 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Fri, 1 May 2026 10:42:33 -0700 Subject: [PATCH 351/430] =?UTF-8?q?C0:=20review=20ollama=20doc=20=E2=80=94?= =?UTF-8?q?=20refresh=20image,=20models,=20last-reviewed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumped documented image tag to 0.20.4 (matches kustomization newTag), added the two qwen3.5 models from models.txt, and stamped the card. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/changelog.d/+review-ollama-doc.doc.md | 1 + docs/reference/services/ollama.md | 7 +++++-- 2 files changed, 6 insertions(+), 2 deletions(-) create mode 100644 docs/changelog.d/+review-ollama-doc.doc.md diff --git a/docs/changelog.d/+review-ollama-doc.doc.md b/docs/changelog.d/+review-ollama-doc.doc.md new file mode 100644 index 0000000..05ef23e --- /dev/null +++ b/docs/changelog.d/+review-ollama-doc.doc.md @@ -0,0 +1 @@ +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`. diff --git a/docs/reference/services/ollama.md b/docs/reference/services/ollama.md index 75480cb..b749cf2 100644 --- a/docs/reference/services/ollama.md +++ b/docs/reference/services/ollama.md @@ -1,6 +1,7 @@ --- title: Ollama -modified: 2026-03-04 +modified: 2026-05-01 +last-reviewed: 2026-05-01 tags: - service - ai @@ -18,7 +19,7 @@ LLM inference server with GPU acceleration. Runs on [[ringtail]] with declarativ | **Tailscale URL** | https://ollama.tail8d86e.ts.net | | **Namespace** | `ollama` | | **Cluster** | ringtail k3s | -| **Image** | `ollama/ollama:0.17.5` | +| **Image** | `ollama/ollama:0.20.4` | | **Upstream** | https://github.com/ollama/ollama | | **Manifests** | `argocd/manifests/ollama/` | | **API Port** | 11434 | @@ -50,6 +51,8 @@ Declared in `argocd/manifests/ollama/models.txt`. The model-sync sidecar pulls m | `deepseek-r1:14b` | 14B | | `phi4:14b` | 14B | | `gemma3:12b` | 12B | +| `qwen3.5:9b` | 9B | +| `qwen3.5:27b` | 27B | To add or remove models, edit `models.txt` and sync via ArgoCD. From f84f5f02b3e10efa8468460c21a4e027cd91ff68 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Fri, 1 May 2026 10:49:22 -0700 Subject: [PATCH 352/430] C0: review compensating control trusted-ci-only Verified Forgejo runner is registered only to forge.ops.eblu.me and the forge has registration disabled, so no untrusted users can trigger privileged CI. Tightened notes to reflect the closed-forge mechanism (not a per-repo allow-list). Co-Authored-By: Claude Opus 4.7 (1M context) --- compensating-controls.yaml | 13 ++++++++++--- docs/changelog.d/+review-cc-trusted-ci-only.misc.md | 1 + 2 files changed, 11 insertions(+), 3 deletions(-) create mode 100644 docs/changelog.d/+review-cc-trusted-ci-only.misc.md diff --git a/compensating-controls.yaml b/compensating-controls.yaml index fb5450d..a6dbc56 100644 --- a/compensating-controls.yaml +++ b/compensating-controls.yaml @@ -110,10 +110,17 @@ controls: forge (forge.ops.eblu.me). No external or untrusted repos can trigger privileged CI jobs. created: 2026-03-30 - last-reviewed: 2026-03-30 + last-reviewed: 2026-05-01 notes: >- - Verify runner registration is limited to the forge instance. - Check Forgejo runner config for repo allow-lists. + Verification: (1) Runner config (argocd/manifests/forgejo-runner/ + config.yaml) connects only to https://forge.ops.eblu.me/. (2) Forge + app.ini has DISABLE_REGISTRATION=true and ALLOW_ONLY_EXTERNAL_REGISTRATION + =true (ansible/roles/forgejo/defaults/main.yml) — no untrusted users + can sign up or create repos. The runner registers at instance scope + (repo_id=0/owner_id=0 in action_runner table), but the instance itself + is closed, so no per-repo allow-list is needed. Re-evaluate if the + forge ever opens to additional users or if the runner is repointed + to an external forge. - id: init-container-isolation description: >- diff --git a/docs/changelog.d/+review-cc-trusted-ci-only.misc.md b/docs/changelog.d/+review-cc-trusted-ci-only.misc.md new file mode 100644 index 0000000..89dc653 --- /dev/null +++ b/docs/changelog.d/+review-cc-trusted-ci-only.misc.md @@ -0,0 +1 @@ +Reviewed compensating control `trusted-ci-only`: Forgejo runner is registered only to the private forge, which has registration disabled — no untrusted users can create repos or trigger privileged CI. Tightened the notes to reflect that the closed-forge property (not a per-repo allow-list) is what actually mitigates the risk. From fabca0477188b3e4713b27c71f11a7022e8a3add Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Fri, 1 May 2026 17:40:03 -0700 Subject: [PATCH 353/430] Mirror valkey 8.1 locally for paperless and immich (#346) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Add native Dagger build of valkey 8.1.6-r0 on Alpine 3.22 at `containers/valkey/` - Swap paperless redis sidecar and immich-valkey from `docker.io/valkey/valkey:8.1-alpine` to `registry.ops.eblu.me/blumeops/valkey:v8.1.6-r0-946fa75` - Resolves the DR-2026-04 TODO in paperless kustomization about multi-arch redis ## Why Move toward fully locally-built containers for supply chain control. Paperless and immich both pulled the same upstream tag — one mirror serves both. Authentik's nix-built Redis stays separate (different image entirely). ## Risk Low. Both sidecars are stateless caches: - paperless redis: no volumeMount (in-pod localhost, pure memory) - immich-valkey: `emptyDir` (cache only) Pod restart rebuilds the cache. Smoke-tested locally (PING/SET/GET roundtrip on `valkey 8.1.6` with `--bind 0.0.0.0 --protected-mode no`). ## Test plan - [ ] After merge: `mise run container-build-and-release valkey` to rebuild with main SHA - [ ] Update kustomizations to the `[main]` SHA tag (C0 follow-up) - [ ] `argocd app sync paperless` and `argocd app sync immich` - [ ] Verify pods come up healthy (paperless OCR queue functional, immich job queue functional) - [ ] `mise run services-check` 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/346 --- argocd/manifests/immich/kustomization.yaml | 3 +- argocd/manifests/paperless/kustomization.yaml | 7 +-- containers/valkey/container.py | 47 +++++++++++++++++++ docs/changelog.d/valkey-mirror.infra.md | 1 + service-versions.yaml | 12 +++++ 5 files changed, 64 insertions(+), 6 deletions(-) create mode 100644 containers/valkey/container.py create mode 100644 docs/changelog.d/valkey-mirror.infra.md diff --git a/argocd/manifests/immich/kustomization.yaml b/argocd/manifests/immich/kustomization.yaml index c7c54e1..399a975 100644 --- a/argocd/manifests/immich/kustomization.yaml +++ b/argocd/manifests/immich/kustomization.yaml @@ -19,4 +19,5 @@ images: - name: ghcr.io/immich-app/immich-machine-learning newTag: v2.6.3 - name: docker.io/valkey/valkey - newTag: "8.1-alpine" + newName: registry.ops.eblu.me/blumeops/valkey + newTag: v8.1.6-r0-946fa75 diff --git a/argocd/manifests/paperless/kustomization.yaml b/argocd/manifests/paperless/kustomization.yaml index 3e65578..4e1f658 100644 --- a/argocd/manifests/paperless/kustomization.yaml +++ b/argocd/manifests/paperless/kustomization.yaml @@ -14,9 +14,6 @@ resources: images: - name: registry.ops.eblu.me/blumeops/paperless newTag: v2.20.13-07f52e9 - # TODO(DR-2026-04): authentik-redis is amd64-only (nix-built on ringtail). - # Was running under QEMU emulation before. Switched to upstream valkey - # during DR recovery. Build a multi-arch blumeops/redis or keep upstream. - name: docker.io/library/redis - newName: docker.io/valkey/valkey - newTag: "8.1-alpine" + newName: registry.ops.eblu.me/blumeops/valkey + newTag: v8.1.6-r0-946fa75 diff --git a/containers/valkey/container.py b/containers/valkey/container.py new file mode 100644 index 0000000..5d150e7 --- /dev/null +++ b/containers/valkey/container.py @@ -0,0 +1,47 @@ +"""Valkey — native Dagger build. + +Alpine 3.22 base with the `valkey` apk package (8.1.x — Redis-compatible). +Mirrors `docker.io/valkey/valkey:8.1-alpine`, used by paperless and immich +as a cache/queue sidecar. +""" + +import dagger +from dagger import dag + +from blumeops.containers import oci_labels + +# Alpine 3.22 ships valkey 8.1.6-r0. Alpine 3.23 jumps to 9.0 — hold on 3.22 +# to keep this a 1:1 swap for the upstream `valkey:8.1-alpine` image. +VERSION = "8.1.6-r0" + +ALPINE_BASE = "alpine:3.22" + + +async def build(src: dagger.Directory) -> dagger.Container: + ctr = ( + dag.container() + .from_(ALPINE_BASE) + .with_exec(["apk", "add", "--no-cache", f"valkey={VERSION}"]) + .with_exec(["mkdir", "-p", "/data"]) + .with_exec(["chown", "valkey:valkey", "/data"]) + .with_workdir("/data") + .with_exposed_port(6379) + .with_user("valkey") + .with_default_args( + args=[ + "valkey-server", + "--bind", + "0.0.0.0", + "--protected-mode", + "no", + "--dir", + "/data", + ] + ) + ) + return oci_labels( + ctr, + title="Valkey", + description="Valkey high-performance key/value datastore (Redis-compatible)", + version=VERSION, + ) diff --git a/docs/changelog.d/valkey-mirror.infra.md b/docs/changelog.d/valkey-mirror.infra.md new file mode 100644 index 0000000..06f8d98 --- /dev/null +++ b/docs/changelog.d/valkey-mirror.infra.md @@ -0,0 +1 @@ +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. diff --git a/service-versions.yaml b/service-versions.yaml index 42f9c77..76c0655 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -125,6 +125,18 @@ services: upstream-source: https://github.com/immich-app/immich/releases notes: Kustomize manifests with upstream images + - name: valkey + type: argocd + last-reviewed: 2026-05-01 + current-version: "8.1.6-r0" + upstream-source: https://pkgs.alpinelinux.org/package/v3.22/community/aarch64/valkey + notes: >- + Shared Alpine-built valkey image, used as a sidecar/cache by paperless + (sidecar) and immich (separate Deployment). Mirrors the upstream + docker.io/valkey/valkey:8.1-alpine. Pinned to Alpine 3.22 for valkey 8.1.x; + Alpine 3.23 jumps to 9.0. Distinct from authentik-redis (nix-built Redis + 8.x) which has its own entry. + - name: external-secrets type: argocd last-reviewed: 2026-03-25 From 2c0917b266cc0f6715a733a92e106705d83584c9 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Fri, 1 May 2026 17:47:16 -0700 Subject: [PATCH 354/430] =?UTF-8?q?C0:=20valkey=20=E2=80=94=20bump=20kusto?= =?UTF-8?q?mization=20tags=20to=20main-branch=20SHA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Routine post-merge follow-up after #346. Branch SHA tag (946fa75) replaced with the main-SHA-built tag (fabca04) so paperless and immich reference an image traceable to a commit on main. Co-Authored-By: Claude Opus 4.7 (1M context) --- argocd/manifests/immich/kustomization.yaml | 2 +- argocd/manifests/paperless/kustomization.yaml | 2 +- docs/changelog.d/+valkey-main-tag-bump.infra.md | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) create mode 100644 docs/changelog.d/+valkey-main-tag-bump.infra.md diff --git a/argocd/manifests/immich/kustomization.yaml b/argocd/manifests/immich/kustomization.yaml index 399a975..5f8d02b 100644 --- a/argocd/manifests/immich/kustomization.yaml +++ b/argocd/manifests/immich/kustomization.yaml @@ -20,4 +20,4 @@ images: newTag: v2.6.3 - name: docker.io/valkey/valkey newName: registry.ops.eblu.me/blumeops/valkey - newTag: v8.1.6-r0-946fa75 + newTag: v8.1.6-r0-fabca04 diff --git a/argocd/manifests/paperless/kustomization.yaml b/argocd/manifests/paperless/kustomization.yaml index 4e1f658..9c6a086 100644 --- a/argocd/manifests/paperless/kustomization.yaml +++ b/argocd/manifests/paperless/kustomization.yaml @@ -16,4 +16,4 @@ images: newTag: v2.20.13-07f52e9 - name: docker.io/library/redis newName: registry.ops.eblu.me/blumeops/valkey - newTag: v8.1.6-r0-946fa75 + newTag: v8.1.6-r0-fabca04 diff --git a/docs/changelog.d/+valkey-main-tag-bump.infra.md b/docs/changelog.d/+valkey-main-tag-bump.infra.md new file mode 100644 index 0000000..cd19f60 --- /dev/null +++ b/docs/changelog.d/+valkey-main-tag-bump.infra.md @@ -0,0 +1 @@ +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. From a2c61b625d45311f1ebaaec90cd81e37c04a283a Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 4 May 2026 13:42:57 -0700 Subject: [PATCH 355/430] =?UTF-8?q?C0:=20rotate-fly-deploy-token=20?= =?UTF-8?q?=E2=80=94=20fish+bash=20one-shot,=20op=20validator=20gotcha?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Combine mint+store into a single command with both fish and bash forms (the doc previously only showed manual paste). Document the 1Password CLI "Password item requires ps value" validator error and the placeholder-password workaround for Password-category items with empty primary password fields. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...ate-fly-deploy-token-shell-examples.doc.md | 1 + .../configuration/rotate-fly-deploy-token.md | 38 +++++++++++++------ 2 files changed, 27 insertions(+), 12 deletions(-) create mode 100644 docs/changelog.d/+rotate-fly-deploy-token-shell-examples.doc.md diff --git a/docs/changelog.d/+rotate-fly-deploy-token-shell-examples.doc.md b/docs/changelog.d/+rotate-fly-deploy-token-shell-examples.doc.md new file mode 100644 index 0000000..24ffcb9 --- /dev/null +++ b/docs/changelog.d/+rotate-fly-deploy-token-shell-examples.doc.md @@ -0,0 +1 @@ +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. diff --git a/docs/how-to/configuration/rotate-fly-deploy-token.md b/docs/how-to/configuration/rotate-fly-deploy-token.md index 58aba21..5863f54 100644 --- a/docs/how-to/configuration/rotate-fly-deploy-token.md +++ b/docs/how-to/configuration/rotate-fly-deploy-token.md @@ -1,7 +1,7 @@ --- title: Rotate the Fly.io API Token -modified: 2026-04-30 -last-reviewed: 2026-04-30 +modified: 2026-05-04 +last-reviewed: 2026-05-04 tags: - how-to - fly-io @@ -45,24 +45,38 @@ fly auth login (Browser-based. Required to mint a new token, since the existing deploy token can't create tokens.) -### 2. Mint the new token +### 2. Mint the new token and store it + +The token is shown only once at creation, so combine the mint and the 1Password write into a single command. Pick the form for your shell. + +`fish`: ```fish -fly tokens create org \ - --org personal \ - --name "blumeops-proxy deploy $(date +%Y-%m-%d)" \ - --expiry 2160h +op item edit on5slfaygtdjrxmdwezyhfmqsq "add more.deploy-token=(fly tokens create org --org personal --name 'blumeops-proxy deploy '(date +%Y-%m-%d) --expiry 2160h)" --vault vg6xf6vvfmoh5hqjjhlhbeoaie ``` -(`2160h` = 90 days, paired with the 75-day rotation cadence for a 15-day buffer. Capture the output — it's the only time the token is shown.) +`bash` / `zsh`: -### 3. Update 1Password +```bash +op item edit on5slfaygtdjrxmdwezyhfmqsq "add more.deploy-token=$(fly tokens create org --org personal --name "blumeops-proxy deploy $(date +%Y-%m-%d)" --expiry 2160h)" --vault vg6xf6vvfmoh5hqjjhlhbeoaie +``` + +(`2160h` = 90 days, paired with the 75-day rotation cadence for a 15-day buffer.) + +If you'd rather paste manually: ```fish +fly tokens create org --org personal --name "blumeops-proxy deploy $(date +%Y-%m-%d)" --expiry 2160h op item edit on5slfaygtdjrxmdwezyhfmqsq 'add more.deploy-token=' --vault vg6xf6vvfmoh5hqjjhlhbeoaie ``` -### 4. Sync to Forgejo Actions +> **op validator gotcha:** If `op item edit` returns `Password item requires ps value`, the item's primary `password` field is empty. The 1Password CLI validator rejects edits to a Password-category item with no primary password, even when you're only touching a section field. Set a placeholder once and future rotations will work: +> +> ```fish +> op item edit on5slfaygtdjrxmdwezyhfmqsq 'password=unused - see deploy-token field' --vault vg6xf6vvfmoh5hqjjhlhbeoaie +> ``` + +### 3. Sync to Forgejo Actions The `deploy-fly` workflow reads the same token from a Forgejo Actions secret named `FLY_DEPLOY_TOKEN`, populated by the `forgejo_actions_secrets` ansible role: @@ -70,7 +84,7 @@ The `deploy-fly` workflow reads the same token from a Forgejo Actions secret nam mise run provision-indri -- --tags forgejo_actions_secrets ``` -### 5. Verify +### 4. Verify ```fish mise run fly-deploy @@ -80,7 +94,7 @@ A successful deploy confirms the new token works locally. Watch for the metrics- Then trigger the CI workflow (push a no-op commit touching `fly/`, or dispatch manually) to confirm Forgejo Actions has the new secret. -### 6. Revoke the old token +### 5. Revoke the old token ```fish fly tokens list From f16e1c81f1957e16f9f4fb611d1a618856585b53 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 4 May 2026 17:41:07 -0700 Subject: [PATCH 356/430] =?UTF-8?q?C0:=20zot=20=E2=80=94=20upgrade=20indri?= =?UTF-8?q?=20registry=20to=20v2.1.16?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security fixes only (TLS verification on metrics client, CORS Allow-Credentials suppression on wildcard origin, manifest/API-key body-size limits, dependabot bumps). No config changes required; re-built from source on indri and bounced launchagent. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/changelog.d/+zot-v2.1.16.infra.md | 1 + service-versions.yaml | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) create mode 100644 docs/changelog.d/+zot-v2.1.16.infra.md diff --git a/docs/changelog.d/+zot-v2.1.16.infra.md b/docs/changelog.d/+zot-v2.1.16.infra.md new file mode 100644 index 0000000..f007164 --- /dev/null +++ b/docs/changelog.d/+zot-v2.1.16.infra.md @@ -0,0 +1 @@ +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). diff --git a/service-versions.yaml b/service-versions.yaml index 76c0655..792f4eb 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -357,8 +357,8 @@ services: - name: zot type: ansible - last-reviewed: 2026-03-14 - current-version: "v2.1.15" + last-reviewed: 2026-05-04 + current-version: "v2.1.16" upstream-source: https://github.com/project-zot/zot/releases notes: Built from source on indri From 9fb5442ccd831f4a02ec7ceb8be3433d51495d0c Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 4 May 2026 17:46:16 -0700 Subject: [PATCH 357/430] =?UTF-8?q?C0:=20kiwix=20=E2=80=94=20doc=20review,?= =?UTF-8?q?=20fix=20Adding=20Archives=20source=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/reference/services/kiwix.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/reference/services/kiwix.md b/docs/reference/services/kiwix.md index 6806a5e..04fe0f6 100644 --- a/docs/reference/services/kiwix.md +++ b/docs/reference/services/kiwix.md @@ -1,6 +1,7 @@ --- title: Kiwix -modified: 2026-03-05 +modified: 2026-05-04 +last-reviewed: 2026-05-04 tags: - service - knowledge @@ -41,7 +42,7 @@ Full list: `argocd/manifests/kiwix/torrents.txt` ## Adding Archives -1. Edit `configmap-zim-torrents.yaml` +1. Edit `argocd/manifests/kiwix/torrents.txt` (rendered into a ConfigMap by `configMapGenerator`) 2. Add torrent URL from https://download.kiwix.org/zim/ 3. Sync: `argocd app sync kiwix` 4. Torrent-sync adds to [[transmission]] From 074887cd571eaf777baa134a8deaec55275fc82e Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 4 May 2026 18:19:53 -0700 Subject: [PATCH 358/430] =?UTF-8?q?C0:=20docs=20=E2=80=94=20explanation=20?= =?UTF-8?q?article=20on=20compliance=20mute=20categories?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures the CC vs NA vs RA distinction surfaced during the 2026-05-03 weekly compliance review (CVE-2026-31789), and the image-scan mutelist gap that blocks acting on it. Links the new article from the review-compensating-controls how-to so it isn't orphaned. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../+compliance-mute-categories.doc.md | 1 + .../explanation/compliance-mute-categories.md | 99 +++++++++++++++++++ .../review-compensating-controls.md | 2 + 3 files changed, 102 insertions(+) create mode 100644 docs/changelog.d/+compliance-mute-categories.doc.md create mode 100644 docs/explanation/compliance-mute-categories.md diff --git a/docs/changelog.d/+compliance-mute-categories.doc.md b/docs/changelog.d/+compliance-mute-categories.doc.md new file mode 100644 index 0000000..c776e46 --- /dev/null +++ b/docs/changelog.d/+compliance-mute-categories.doc.md @@ -0,0 +1 @@ +New explanation article [[compliance-mute-categories]] documenting the gap between current `CC:`-only mute tagging and the three structurally distinct categories (compensating control, not-applicable, risk-accepted) needed for real PCI DSS / SOC2 practice. Captures the current image-scan mutelist gap (`cronjob-image-scan.yaml` doesn't pass `--mutelist-file`) and proposes an order-of-operations for wiring it up alongside the new tag conventions. Triggered by CVE-2026-31789, an OpenSSL 32-bit-only finding that surfaced the need for an NA category. diff --git a/docs/explanation/compliance-mute-categories.md b/docs/explanation/compliance-mute-categories.md new file mode 100644 index 0000000..4c5f3a3 --- /dev/null +++ b/docs/explanation/compliance-mute-categories.md @@ -0,0 +1,99 @@ +--- +title: Compliance Mute Categories +modified: 2026-05-04 +last-reviewed: 2026-05-04 +tags: + - explanation + - security + - compliance +--- + +# Compliance Mute Categories + +> **Note:** This article was drafted by AI and reviewed by Erich. I plan to rewrite all explanatory content in my own words - these serve as placeholders to establish the documentation structure. + +How BlumeOps should categorize muted compliance findings, why a single "compensating control" tag is not enough, and what tooling work is needed to support multiple categories cleanly. + +## Why this matters + +When a compliance scanner ([[prowler]], Trivy via Prowler IaC, Kingfisher) reports a failing finding, there are three structurally different reasons we might suppress it: + +1. **Compensating control (CC)** — the requirement applies and we *do not* meet it directly, but an alternative control mitigates the same risk. +2. **Not applicable (NA)** — the requirement's preconditions cannot be satisfied in our environment, so the finding is structurally inert (e.g. a 32-bit-only CVE on 64-bit-only hosts). +3. **Risk accepted (RA)** — the requirement applies, we do not meet it, no compensating control exists, and we have explicitly chosen to accept the residual risk for a bounded period. + +Today every muted finding in BlumeOps uses the `CC: ` convention. That conflates all three categories. In a real PCI DSS or SOC2 environment, auditors treat them very differently: + +- A CC requires documentation of the constraint, the alternative measure, and recurring validation that the measure still works. +- An NA requires documentation of *why* the precondition cannot be met, with periodic verification that the environmental fact still holds. +- An RA requires an explicit decision-maker, an expiry date, and a scheduled re-decision. + +Mixing them under one tag means stale CCs hide stale RAs, and NAs that should be revisited when the environment changes get treated as permanent fixtures. + +## Trigger case: CVE-2026-31789 + +The 2026-05-03 weekly compliance review surfaced [CVE-2026-31789](https://nvd.nist.gov/vuln/detail/CVE-2026-31789), an OpenSSL heap buffer overflow during X.509 certificate processing on **32-bit systems**. Prowler's image scanner flagged 216 findings across 106 BlumeOps images carrying `libssl3` / `libcrypto3` below the fixed versions. + +The CVE is genuine, but its preconditions cannot be satisfied in our environment: indri is Apple Silicon (arm64), ringtail is x86_64, and we run no 32-bit containers. This is the canonical NA case — not a CC, because there is no "alternative measure mitigating the risk." The risk does not exist for us at all. + +A CC like `no-32bit-runtimes` would technically work, but conflates the categories: if we ever introduce a 32-bit runtime we would have to remember that this CC was load-bearing for the mute, retire or scope it down, and reopen the muted findings. An NA tag with a short justification makes the precondition explicit and self-documents the conditions under which it must be revisited. + +## Current tooling state + +Three Prowler scans run weekly. Their mute paths today: + +| Scan | Mute mechanism | File(s) | +|------|----------------|---------| +| K8s CIS (Sunday) | Prowler `--mutelist-file`, merged from ConfigMap | `argocd/manifests/prowler/mutelist/*.yaml` | +| IaC (Saturday) | Trivy `--ignorefile` shim (Prowler's `--mutelist-file` is a no-op for IaC) | `argocd/manifests/prowler/mutelist/trivyignore.yaml` | +| Container Images (Saturday) | **None — `cronjob-image-scan.yaml` does not pass `--mutelist-file`** | n/a | + +The image scan has never been wired to a mutelist. The CSV reports do contain a `MUTED` column, but it is always `False` because no mutelist is supplied. All 14k+ image findings flow through to `review-compliance-reports` unfiltered. + +The mute tag convention is consistent across the two configured scans: each entry's `Description:` (or `statement:` for trivyignore) starts with `CC: . `. `mise run review-compensating-controls` greps for those IDs to find every file that depends on each control. There is no NA tag, no RA tag, and no expiry field. + +## Proposed model + +### Tag prefixes + +Extend the description-prefix convention: + +- `CC: . ` — references an entry in `compensating-controls.yaml`. Existing convention, unchanged. +- `NA: . ` — environmental precondition fails. Reason should be specific enough that a reviewer can verify it (e.g. `NA: no 32-bit runtimes`, not `NA: doesn't apply`). +- `RA: ; expires . ` — explicit risk acceptance with a hard expiry. Past the expiry, re-review is mandatory. + +Tag choice is exclusive: a given mute is one of CC, NA, or RA. If two reasons apply, pick the strongest — CC > RA > NA. + +### Tooling changes required + +1. **Wire the image scan to a mutelist.** Add `argocd/manifests/prowler/mutelist/image-cves.yaml`, mount-and-merge it the same way `cronjob.yaml` mounts its mutelist parts, and pass `--mutelist-file` to `prowler image`. Verify experimentally that `prowler image` honors the flag — Prowler's behavior across providers is inconsistent, and the IaC provider notably does not. If `prowler image` ignores it, fall back to post-scan filtering inside `review-compliance-reports`. + +2. **Teach `review-compensating-controls` (or a sibling) to surface NA and RA entries.** CCs already get a staleness queue. NAs should appear in a separate queue keyed on the reason text — when an NA reason becomes false (e.g. we do introduce a 32-bit runtime), every NA mute citing that reason must be reopened. RAs should sort by expiry date, with anything past expiry flagged red. + +3. **Expiry parsing.** RA tags carry a hard date. The simplest path is to parse it from the description string at review time. A more durable path is to extend the mutelist YAML schema with a structured `expires:` field and a small wrapper that strips it before passing the file to Prowler. Either works; the structured field is friendlier to editors. + +### Out of scope (for now) + +- Changing the underlying Prowler mutelist YAML schema. Stay within the `Mutelist:` shape Prowler expects. +- Migrating existing `CC:` entries. The current set is genuinely CCs and should stay tagged that way. +- Building an issue-tracker integration. Todoist is the source of truth for "remember to re-review this" until that scales painfully. + +## Order of operations + +When this work is picked up, the suggested sequence is: + +1. **Scope and confirm.** Re-read this article, confirm the model still fits, adjust if not. +2. **Wire the image-scan mutelist.** Smallest atomic change; produces immediate value (the CVE-2026-31789 mute can land as the first NA entry). +3. **Add the NA convention.** Update [[read-compliance-reports]] and [[review-compensating-controls]] how-tos to describe the three tag prefixes. The convention can land before tooling supports it — review will just be manual until tooling catches up. +4. **Extend the review tools.** Add NA and RA queues to `review-compensating-controls` (or a new task). At this point, parse expiry from RA descriptions. +5. **Optionally: structured expiry.** If RA entries become common, migrate to a structured `expires:` YAML field with a wrapper that filters it out before Prowler reads the file. + +The first three steps are a coherent C1. Steps 4–5 can be split off if scope creeps. + +## Related + +- [[read-compliance-reports]] — the weekly review process this feeds into +- [[review-compensating-controls]] — current CC review tooling +- [[security-model]] — overall security posture +- [[prowler]] — scanner reference +- [[agent-change-process]] — how to scope and execute the implementation diff --git a/docs/how-to/operations/review-compensating-controls.md b/docs/how-to/operations/review-compensating-controls.md index b05958e..8a32d98 100644 --- a/docs/how-to/operations/review-compensating-controls.md +++ b/docs/how-to/operations/review-compensating-controls.md @@ -38,6 +38,8 @@ A compensating control is a security measure that mitigates the risk a finding w Controls are documented in `compensating-controls.yaml` and referenced from security tool configurations (Prowler mutelist files, Kingfisher config, etc.) using the format `CC: `. +A compensating control is only one of three structurally distinct ways to suppress a finding — see [[compliance-mute-categories]] for when to reach for a CC versus a not-applicable (`NA:`) or risk-accepted (`RA:`) tag instead. + ## Review Process For each control up for review: From 24e549025952e9eb17ab58fe2c1b9db2ac3b857f Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 4 May 2026 18:31:13 -0700 Subject: [PATCH 359/430] =?UTF-8?q?C0:=20review=20CC=20init-container-isol?= =?UTF-8?q?ation=20=E2=80=94=20defer=20retirement=20to=20post-ringtail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runtime grafana pod matches the manifest and the CC's claim; bumped last-reviewed. Noted that retiring init-chown-data in favor of fsGroup alone should wait until grafana migrates to ringtail's k3s, since the storage backend will change. Co-Authored-By: Claude Opus 4.7 (1M context) --- compensating-controls.yaml | 7 ++++++- .../+review-cc-init-container-isolation.misc.md | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 docs/changelog.d/+review-cc-init-container-isolation.misc.md diff --git a/compensating-controls.yaml b/compensating-controls.yaml index a6dbc56..658c99d 100644 --- a/compensating-controls.yaml +++ b/compensating-controls.yaml @@ -129,11 +129,16 @@ controls: containers run as non-root (UID 472) with all capabilities dropped. created: 2026-03-30 - last-reviewed: 2026-03-30 + last-reviewed: 2026-05-04 notes: >- Verify by inspecting grafana deployment.yaml securityContext for both init and runtime containers. If fsGroup alone can handle PVC ownership, remove init-chown-data and this control. + Retirement deferred until grafana lands on ringtail's k3s + (see [[indri-k8s-migration]]) — storage backend will change, + and removing init-chown-data right before that migration + trades a real safety net for marginal cleanup. Revisit + post-migration. - id: node-config-automated-verification description: >- diff --git a/docs/changelog.d/+review-cc-init-container-isolation.misc.md b/docs/changelog.d/+review-cc-init-container-isolation.misc.md new file mode 100644 index 0000000..295e7f8 --- /dev/null +++ b/docs/changelog.d/+review-cc-init-container-isolation.misc.md @@ -0,0 +1 @@ +Reviewed compensating control `init-container-isolation` (35 days stale). Grafana's running pod matches the manifest and the CC's claim — only `init-chown-data` runs as root with `CHOWN`; runtime containers all run as UID 472 with all caps dropped. Retirement (replacing init-chown-data with `fsGroup` alone) is plausible given the in-tree minikube-hostpath provisioner, but deferred until grafana lands on ringtail's k3s — note added to the CC. From 39b042e6383a59581a650035de0cb06a7c8ad8d2 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 6 May 2026 06:11:15 -0700 Subject: [PATCH 360/430] =?UTF-8?q?C0:=20service=20review=20=E2=80=94=20ca?= =?UTF-8?q?ddy=20v2.11.2=20(current=20latest,=20healthy)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- service-versions.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/service-versions.yaml b/service-versions.yaml index 792f4eb..f7f0f4e 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -364,7 +364,7 @@ services: - name: caddy type: ansible - last-reviewed: 2026-03-15 + last-reviewed: 2026-05-06 current-version: "v2.11.2" upstream-source: https://github.com/caddyserver/caddy/releases notes: Built from source with Gandi DNS and Layer 4 plugins From 6f0d80ca1e5013b5e176d81adab6403dff052964 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 6 May 2026 06:14:40 -0700 Subject: [PATCH 361/430] =?UTF-8?q?C0:=20doc=20review=20=E2=80=94=20index.?= =?UTF-8?q?md,=20add=20ringtail=20to=20infra=20overview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/changelog.d/+review-index-doc.doc.md | 1 + docs/index.md | 8 +++++--- 2 files changed, 6 insertions(+), 3 deletions(-) create mode 100644 docs/changelog.d/+review-index-doc.doc.md diff --git a/docs/changelog.d/+review-index-doc.doc.md b/docs/changelog.d/+review-index-doc.doc.md new file mode 100644 index 0000000..7016a7a --- /dev/null +++ b/docs/changelog.d/+review-index-doc.doc.md @@ -0,0 +1 @@ +Reviewed `index.md`; added ringtail to the infrastructure overview and stamped `last-reviewed`. diff --git a/docs/index.md b/docs/index.md index 6da90a4..fb04c47 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,6 +1,7 @@ --- title: BlumeOps -modified: 2026-02-08 +modified: 2026-05-06 +last-reviewed: 2026-05-06 aliases: [] id: index tags: [] @@ -22,8 +23,9 @@ raft I built for myself as I went, and you can see it all from within your editor of choice. (I recommend vim.) These services run on my home [[hosts|infrastructure]], primarily an m1 mac -mini named [[indri]] and a Synology NAS called [[sifaka]]. The infrastructure -is networked via [[tailscale]], with the domain `eblu.me` hosted via [[gandi]], +mini named [[indri]], a NixOS GPU host called [[ringtail]] running a k3s +cluster, and a Synology NAS called [[sifaka]]. The infrastructure is networked +via [[tailscale]], with the domain `eblu.me` hosted via [[gandi]], [[caddy]] providing a private reverse proxy for tailnet devices, and [[flyio-proxy|Fly.io]] serving public-facing services like [this documentation site](https://docs.eblu.me). From 0108b687693a3bbca2c43189fce819c20b25fabe Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 6 May 2026 06:50:31 -0700 Subject: [PATCH 362/430] C1: mirror tailscale container locally for ringtail proxyclass (#347) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds the first cut of a local nix build for `docker.io/tailscale/tailscale` and rewires only the ringtail tailscale-operator overlay to use it. Indri's overlay continues pulling upstream — minikube on indri is being decommissioned in favor of ringtail's k3s, so investing in dual-cluster routing here would be wasted churn. ## Changes - `containers/tailscale/default.nix` — `buildGoModule` over `cmd/tailscale`, `cmd/tailscaled`, `cmd/containerboot`; packaged via `dockerTools.buildLayeredImage` with `cacert`, `iptables` (legacy symlink to match upstream Synology compat), `iproute2`, `tzdata`, `busybox`. - `argocd/manifests/tailscale-operator-ringtail/kustomization.yaml` — kustomize `images:` rewrite swapping `docker.io/tailscale/tailscale` → `registry.ops.eblu.me/blumeops/tailscale:v1.94.2-67af7a8-nix`. - `docs/changelog.d/mirror-tailscale-container.infra.md` — fragment. ## Pin rationale v1.94.2 matches `service-versions.yaml:96` and the current ProxyClass exactly — this PR is "make it local," not "upgrade tailscale." Version bumps come as follow-up C0/C1 changes once we decide to test newer (v1.96.x had a Fly-side MagicDNS regression; v1.98.0 is current upstream stable). ## Test plan - [x] Image built successfully on ringtail nix-container-builder (run #528). - [x] Image visible in registry: `registry.ops.eblu.me/blumeops/tailscale:v1.94.2-67af7a8-nix`. - [ ] Deploy from branch: `argocd app set tailscale-operator-ringtail --revision mirror-tailscale-container && argocd app sync tailscale-operator-ringtail`. - [ ] Verify proxy pods restart with new image and existing tailnet ingresses (e.g., authentik, immich, tempo) keep resolving. - [ ] After merge: rebuild on main SHA, update kustomization, run `services-check`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/347 --- .../kustomization.yaml | 14 ++++ .../proxyclass-image.yaml | 11 +++ containers/tailscale/default.nix | 77 +++++++++++++++++++ .../mirror-tailscale-container.infra.md | 1 + 4 files changed, 103 insertions(+) create mode 100644 argocd/manifests/tailscale-operator-ringtail/proxyclass-image.yaml create mode 100644 containers/tailscale/default.nix create mode 100644 docs/changelog.d/mirror-tailscale-container.infra.md diff --git a/argocd/manifests/tailscale-operator-ringtail/kustomization.yaml b/argocd/manifests/tailscale-operator-ringtail/kustomization.yaml index a14ca81..2d9ceb2 100644 --- a/argocd/manifests/tailscale-operator-ringtail/kustomization.yaml +++ b/argocd/manifests/tailscale-operator-ringtail/kustomization.yaml @@ -8,3 +8,17 @@ resources: - ../tailscale-operator-base - proxygroup-ingress.yaml - external-secret.yaml + +# Rewrite the proxyclass image to our local nix-built mirror. +# Scoped to ringtail only; indri's tailscale-operator/kustomization.yaml still +# pulls from upstream docker.io. 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: + - path: proxyclass-image.yaml + target: + group: tailscale.com + version: v1alpha1 + kind: ProxyClass + name: default diff --git a/argocd/manifests/tailscale-operator-ringtail/proxyclass-image.yaml b/argocd/manifests/tailscale-operator-ringtail/proxyclass-image.yaml new file mode 100644 index 0000000..b585e22 --- /dev/null +++ b/argocd/manifests/tailscale-operator-ringtail/proxyclass-image.yaml @@ -0,0 +1,11 @@ +apiVersion: tailscale.com/v1alpha1 +kind: ProxyClass +metadata: + name: default +spec: + statefulSet: + pod: + tailscaleContainer: + image: registry.ops.eblu.me/blumeops/tailscale:v1.94.2-67af7a8-nix + tailscaleInitContainer: + image: registry.ops.eblu.me/blumeops/tailscale:v1.94.2-67af7a8-nix diff --git a/containers/tailscale/default.nix b/containers/tailscale/default.nix new file mode 100644 index 0000000..8e87f76 --- /dev/null +++ b/containers/tailscale/default.nix @@ -0,0 +1,77 @@ +# Nix-built tailscale container for ringtail's tailscale-operator ProxyClass +# Builds v1.94.2 from forge mirror; mirrors upstream Dockerfile contents. +# Built with dockerTools.buildLayeredImage on the ringtail nix-container-builder. +{ pkgs ? import { } }: + +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="; + }; + + tailscale = pkgs.buildGoModule { + inherit src version; + pname = "tailscale"; + vendorHash = "sha256-WeMTOkERj4hvdg4yPaZ1gRgKnhRIBXX55kUVbX/k/xM="; + + subPackages = [ + "cmd/tailscale" + "cmd/tailscaled" + "cmd/containerboot" + ]; + + ldflags = [ + "-s" + "-w" + "-X tailscale.com/version.longStamp=${version}" + "-X tailscale.com/version.shortStamp=${version}" + ]; + + doCheck = false; + + meta = with pkgs.lib; { + description = "The easiest, most secure way to use WireGuard"; + homepage = "https://tailscale.com"; + license = licenses.bsd3; + }; + }; +in + +pkgs.dockerTools.buildLayeredImage { + name = "blumeops/tailscale"; + tag = "v${version}"; + + contents = [ + tailscale + pkgs.cacert + pkgs.iptables + pkgs.iproute2 + pkgs.tzdata + pkgs.busybox + ]; + + # Match upstream Dockerfile: symlink iptables-legacy over iptables. + # Synology NAS and similar hosts don't support nftables. + # Also recreate the /tailscale/run.sh compat symlink. + extraCommands = '' + rm -f usr/sbin/iptables usr/sbin/ip6tables + ln -s ${pkgs.iptables}/bin/iptables-legacy usr/sbin/iptables || true + ln -s ${pkgs.iptables}/bin/ip6tables-legacy usr/sbin/ip6tables || true + mkdir -p tailscale + ln -s /bin/containerboot tailscale/run.sh + mkdir -p tmp + chmod 1777 tmp + ''; + + config = { + Entrypoint = [ "/bin/containerboot" ]; + Env = [ + "SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" + "TZDIR=${pkgs.tzdata}/share/zoneinfo" + "PATH=/bin:/usr/bin:/usr/sbin" + ]; + }; +} diff --git a/docs/changelog.d/mirror-tailscale-container.infra.md b/docs/changelog.d/mirror-tailscale-container.infra.md new file mode 100644 index 0000000..54ca3ba --- /dev/null +++ b/docs/changelog.d/mirror-tailscale-container.infra.md @@ -0,0 +1 @@ +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. From 8bc19fa46037faa3b564ceb4d1f13b268c6cffae Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 6 May 2026 06:52:39 -0700 Subject: [PATCH 363/430] C0: tailscale main-SHA rebuild for ringtail proxyclass Routine post-squash-merge cleanup. Bumps the ProxyClass image tag from the now-orphaned PR branch SHA (67af7a8) to the merge commit SHA (0108b68) so the deployed image stays traceable after branch cleanup. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../tailscale-operator-ringtail/proxyclass-image.yaml | 4 ++-- docs/changelog.d/+tailscale-main-sha-rebuild.infra.md | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) create mode 100644 docs/changelog.d/+tailscale-main-sha-rebuild.infra.md diff --git a/argocd/manifests/tailscale-operator-ringtail/proxyclass-image.yaml b/argocd/manifests/tailscale-operator-ringtail/proxyclass-image.yaml index b585e22..d1bf2a4 100644 --- a/argocd/manifests/tailscale-operator-ringtail/proxyclass-image.yaml +++ b/argocd/manifests/tailscale-operator-ringtail/proxyclass-image.yaml @@ -6,6 +6,6 @@ spec: statefulSet: pod: tailscaleContainer: - image: registry.ops.eblu.me/blumeops/tailscale:v1.94.2-67af7a8-nix + image: registry.ops.eblu.me/blumeops/tailscale:v1.94.2-0108b68-nix tailscaleInitContainer: - image: registry.ops.eblu.me/blumeops/tailscale:v1.94.2-67af7a8-nix + image: registry.ops.eblu.me/blumeops/tailscale:v1.94.2-0108b68-nix diff --git a/docs/changelog.d/+tailscale-main-sha-rebuild.infra.md b/docs/changelog.d/+tailscale-main-sha-rebuild.infra.md new file mode 100644 index 0000000..24bb81c --- /dev/null +++ b/docs/changelog.d/+tailscale-main-sha-rebuild.infra.md @@ -0,0 +1 @@ +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. From b87f62e0f57c22b8975adda029a96e6a10b1a6e1 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sun, 10 May 2026 20:32:38 -0700 Subject: [PATCH 364/430] C1: nix-build homepage container for amd64 ringtail migration Replace Dockerfile (arm64-only, indri-built) with a nix derivation adapted from nixpkgs pkgs/by-name/ho/homepage-dashboard. Built via the nix-container-builder runner on ringtail, producing an amd64 image suitable for k3s. Includes the upstream Next.js file-system-cache patch to avoid prerender cache write failures on a read-only nix store path (nixpkgs issues #328621 and #458494). Pinned to v1.11.0 (current production version). --- containers/homepage/Dockerfile | 47 ------------- containers/homepage/default.nix | 119 ++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 47 deletions(-) delete mode 100644 containers/homepage/Dockerfile create mode 100644 containers/homepage/default.nix diff --git a/containers/homepage/Dockerfile b/containers/homepage/Dockerfile deleted file mode 100644 index 6e53e1c..0000000 --- a/containers/homepage/Dockerfile +++ /dev/null @@ -1,47 +0,0 @@ -# Homepage - self-hosted services dashboard -# Two-stage build: Node.js build, Alpine runtime - -ARG CONTAINER_APP_VERSION=v1.11.0 -ARG HOMEPAGE_VERSION=${CONTAINER_APP_VERSION} - -FROM node:24-slim AS builder - -ARG HOMEPAGE_VERSION -RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates \ - && rm -rf /var/lib/apt/lists/* - -RUN git clone --depth 1 --branch ${HOMEPAGE_VERSION} \ - https://forge.ops.eblu.me/mirrors/homepage.git /app - -WORKDIR /app -RUN mkdir -p config \ - && corepack enable && corepack prepare pnpm@latest --activate \ - && pnpm install --frozen-lockfile \ - && NEXT_TELEMETRY_DISABLED=1 pnpm run build - -FROM node:24-alpine - -ARG CONTAINER_APP_VERSION -LABEL org.opencontainers.image.title="Homepage" -LABEL org.opencontainers.image.description="A self-hosted services landing page" -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" - -WORKDIR /app - -COPY --from=builder --chown=1000:1000 /app/public ./public -COPY --from=builder --chown=1000:1000 /app/.next/standalone/ ./ -COPY --from=builder --chown=1000:1000 /app/.next/static/ ./.next/static - -RUN mkdir -p /app/config && chown 1000:1000 /app/config - -ENV NODE_ENV=production -ENV PORT=3000 -EXPOSE 3000 - -HEALTHCHECK --interval=10s --timeout=3s --start-period=20s \ - CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/api/healthcheck || exit 1 - -USER 1000 -CMD ["node", "server.js"] diff --git a/containers/homepage/default.nix b/containers/homepage/default.nix new file mode 100644 index 0000000..7b4becb --- /dev/null +++ b/containers/homepage/default.nix @@ -0,0 +1,119 @@ +# Nix-built gethomepage/homepage dashboard +# Builds v1.11.0 from forge mirror. +# +# Adapted from nixpkgs pkgs/by-name/ho/homepage-dashboard (commit master), +# changed to fetch from our forge mirror and wrap with dockerTools for an +# amd64 image runnable on ringtail's k3s. +# +# The preBuild substitutions are not optional — without them Next.js writes +# its file-system-cache to a read-only path and prerender state breaks after +# restart (nixpkgs issues #328621 and #458494). +{ pkgs ? import { } }: + +let + version = "1.11.0"; + + homepage = pkgs.stdenv.mkDerivation (finalAttrs: { + pname = "homepage-dashboard"; + inherit version; + + src = pkgs.fetchgit { + url = "https://forge.ops.eblu.me/mirrors/homepage.git"; + rev = "v${version}"; + hash = "sha256-jnv9PnClm/jIQ4uU6c4A1UiAmwoihG0l6k3fUbD47I4="; + }; + + pnpmDeps = pkgs.fetchPnpmDeps { + inherit (finalAttrs) pname version src; + pnpm = pkgs.pnpm_10; + fetcherVersion = 3; + hash = "sha256-X5j9XppbcasGuC7fUsj4XzbaQFM9WcRcXjgJHN/inR8="; + }; + + nativeBuildInputs = [ + pkgs.makeBinaryWrapper + pkgs.nodejs_24 + pkgs.pnpmConfigHook + pkgs.pnpm_10 + ]; + + buildInputs = [ + pkgs.nodePackages.node-gyp-build + ]; + + env.PYTHON = "${pkgs.python3}/bin/python"; + + preBuild = '' + substituteInPlace node_modules/next/dist/server/lib/incremental-cache/file-system-cache.js \ + --replace-fail 'this.serverDistDir = ctx.serverDistDir;' \ + 'this.serverDistDir = require("path").join((process.env.NIXPKGS_HOMEPAGE_CACHE_DIR || "/tmp/homepage-cache"), "homepage");' + + for bundle in node_modules/next/dist/compiled/next-server/*.runtime.prod.js; do + substituteInPlace "$bundle" \ + --replace-fail 'this.serverDistDir=e.serverDistDir' \ + 'this.serverDistDir=(process.env.NIXPKGS_HOMEPAGE_CACHE_DIR||"/tmp/homepage-cache")+"/homepage"' + done + ''; + + buildPhase = '' + runHook preBuild + mkdir -p config + pnpm build + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + + mkdir -p $out/{bin,share} + cp -r .next/standalone $out/share/homepage/ + cp -r public $out/share/homepage/public + chmod +x $out/share/homepage/server.js + + mkdir -p $out/share/homepage/.next + cp -r .next/static $out/share/homepage/.next/static + + makeWrapper "${pkgs.lib.getExe pkgs.nodejs_24}" $out/bin/homepage \ + --set-default PORT 3000 \ + --set-default HOMEPAGE_CONFIG_DIR /app/config \ + --set-default NIXPKGS_HOMEPAGE_CACHE_DIR /tmp/homepage-cache \ + --add-flags "$out/share/homepage/server.js" \ + --prefix PATH : "${pkgs.lib.makeBinPath [ pkgs.unixtools.ping ]}" + + runHook postInstall + ''; + + doDist = false; + }); +in + +pkgs.dockerTools.buildLayeredImage { + name = "blumeops/homepage"; + contents = [ + homepage + pkgs.cacert + pkgs.tzdata + ]; + + extraCommands = '' + mkdir -p tmp + chmod 1777 tmp + ''; + + config = { + Entrypoint = [ "${homepage}/bin/homepage" ]; + Env = [ + "SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" + "TZDIR=${pkgs.tzdata}/share/zoneinfo" + "TMPDIR=/tmp" + "NIXPKGS_HOMEPAGE_CACHE_DIR=/tmp/homepage-cache" + "HOMEPAGE_CONFIG_DIR=/app/config" + "NEXT_TELEMETRY_DISABLED=1" + "PORT=3000" + ]; + ExposedPorts = { + "3000/tcp" = { }; + }; + User = "1000"; + }; +} From be54cc341179c60e1518aef194275ea3689d0447 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sun, 10 May 2026 20:37:03 -0700 Subject: [PATCH 365/430] C1: migrate homepage dashboard to ringtail k3s Repoint the ArgoCD Application destination from minikube to ringtail and bump the image tag to the new amd64 nix-built v1.11.0-b87f62e-nix. Rework services.yaml for the autodiscovery shift: 11 services that previously auto-populated via minikube Ingress annotations (ArgoCD, Immich, Kiwix, Mealie, Miniflux, Grafana, Prometheus, Navidrome, Paperless, TeslaMate, Transmission) become explicit static entries with their widget configs preserved. Conversely, the ringtail services that will now auto-populate (Frigate/NVR, Authentik, Ntfy) are removed from the static list to avoid duplicates; Ollama becomes newly visible. Add a Content group for Immich/Kiwix/Miniflux which previously lived under the autodiscovered "Content" group from annotations. --- argocd/apps/homepage.yaml | 2 +- argocd/manifests/homepage/kustomization.yaml | 2 +- argocd/manifests/homepage/services.yaml | 77 ++++++++++++++++--- .../changelog.d/homepage-to-ringtail.infra.md | 8 ++ 4 files changed, 75 insertions(+), 14 deletions(-) create mode 100644 docs/changelog.d/homepage-to-ringtail.infra.md diff --git a/argocd/apps/homepage.yaml b/argocd/apps/homepage.yaml index 86a0f8d..22147f2 100644 --- a/argocd/apps/homepage.yaml +++ b/argocd/apps/homepage.yaml @@ -14,7 +14,7 @@ spec: targetRevision: main path: argocd/manifests/homepage destination: - server: https://kubernetes.default.svc + server: https://ringtail.tail8d86e.ts.net:6443 namespace: homepage syncPolicy: syncOptions: diff --git a/argocd/manifests/homepage/kustomization.yaml b/argocd/manifests/homepage/kustomization.yaml index 27de0eb..ce627ac 100644 --- a/argocd/manifests/homepage/kustomization.yaml +++ b/argocd/manifests/homepage/kustomization.yaml @@ -17,7 +17,7 @@ resources: images: - name: registry.ops.eblu.me/blumeops/homepage - newTag: v1.11.0-e375859 + newTag: v1.11.0-b87f62e-nix configMapGenerator: - name: homepage-config diff --git a/argocd/manifests/homepage/services.yaml b/argocd/manifests/homepage/services.yaml index 211e043..d552ff2 100644 --- a/argocd/manifests/homepage/services.yaml +++ b/argocd/manifests/homepage/services.yaml @@ -1,3 +1,6 @@ +# Homepage runs on ringtail (k3s) — its k8s autodiscovery only sees ringtail +# Ingresses (frigate→NVR, authentik, ntfy, ollama). Services that live on +# minikube (and indri-native) need explicit static entries here. - Host Services: - Forgejo: href: https://forge.eblu.me @@ -57,10 +60,6 @@ # type: caddy # url: http://indri.tail8d86e.ts.net:2019 - Home: - - NVR: - href: https://nvr.ops.eblu.me - icon: frigate.png - description: Network video recorder - Jellyfin: href: https://jellyfin.ops.eblu.me icon: jellyfin @@ -72,15 +71,61 @@ enableBlocks: true enableNowPlaying: false fields: ["movies", "series", "episodes"] + - Mealie: + href: https://meals.ops.eblu.me + icon: mealie.png + description: Recipe manager + - DJ: + href: https://dj.ops.eblu.me + icon: navidrome.png + description: Music streaming server + widget: + type: navidrome + url: https://dj.ops.eblu.me + user: "{{HOMEPAGE_VAR_NAVIDROME_USER}}" + token: "{{HOMEPAGE_VAR_NAVIDROME_TOKEN}}" + salt: "{{HOMEPAGE_VAR_NAVIDROME_SALT}}" + - Paperless: + href: https://paperless.ops.eblu.me + icon: paperless-ngx.png + description: Document management +- Content: + - Immich: + href: https://photos.ops.eblu.me + icon: immich.png + description: Photo management + - Kiwix: + href: https://kiwix.ops.eblu.me + icon: kiwix.png + description: Offline Wikipedia + - Miniflux: + href: https://feed.ops.eblu.me + icon: miniflux.png + description: RSS reader + widget: + type: miniflux + url: https://feed.ops.eblu.me + key: "{{HOMEPAGE_VAR_MINIFLUX_API_KEY}}" + fields: ["unread"] - Infrastructure: - - Authentik: - href: https://authentik.ops.eblu.me - icon: authentik - description: Identity provider - - Ntfy: - href: https://ntfy.ops.eblu.me - icon: ntfy.png - description: Push notifications + - ArgoCD: + href: https://argocd.ops.eblu.me + icon: argo-cd.png + description: GitOps CD + - Grafana: + href: https://grafana.ops.eblu.me + icon: grafana.png + description: Metrics dashboards + widget: + type: grafana + url: https://grafana.ops.eblu.me + username: "{{HOMEPAGE_VAR_GRAFANA_USERNAME}}" + password: "{{HOMEPAGE_VAR_GRAFANA_PASSWORD}}" + fields: ["dashboards", "totalalerts", "alertstriggered"] + - Prometheus: + href: https://prometheus.ops.eblu.me + icon: prometheus.png + description: Metrics storage - Services: # CV and Docs were previously auto-discovered from k8s Ingresses; after # the indri-native migration ([[cv-on-indri]], [[docs-on-indri]]) there @@ -93,3 +138,11 @@ href: https://docs.eblu.me icon: mdi-book-open-page-variant description: BlumeOps Documentation + - TeslaMate: + href: https://tesla.ops.eblu.me + icon: teslamate.png + description: Tesla data logger + - Transmission: + href: https://torrent.ops.eblu.me + icon: transmission.png + description: Torrent client diff --git a/docs/changelog.d/homepage-to-ringtail.infra.md b/docs/changelog.d/homepage-to-ringtail.infra.md new file mode 100644 index 0000000..1e3e795 --- /dev/null +++ b/docs/changelog.d/homepage-to-ringtail.infra.md @@ -0,0 +1,8 @@ +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. From 678f26b0e7335d498549cdbb85e68ca62f2654ab Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sun, 10 May 2026 20:48:48 -0700 Subject: [PATCH 366/430] C0: fix homepage container /app/config write permissions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous Dockerfile chowned /app/config to 1000:1000 so the runtime user could seed missing skeleton configs (e.g. proxmox.yaml) and write /app/config/logs. The nix derivation didn't replicate that, so the new amd64 image crashed with EACCES on cold start (fixed-forward — caught during ringtail cutover, ArgoCD #348). Add fakeRootCommands to dockerTools to create /app and /app/config and chown them at build time. The deployment's ConfigMap subPath mounts leave the parent directory as image filesystem, so its ownership has to be set at build time, not at runtime. --- containers/homepage/default.nix | 11 +++++++++++ docs/changelog.d/+homepage-config-perms-fix.bugfix.md | 5 +++++ 2 files changed, 16 insertions(+) create mode 100644 docs/changelog.d/+homepage-config-perms-fix.bugfix.md diff --git a/containers/homepage/default.nix b/containers/homepage/default.nix index 7b4becb..6217847 100644 --- a/containers/homepage/default.nix +++ b/containers/homepage/default.nix @@ -100,6 +100,17 @@ pkgs.dockerTools.buildLayeredImage { chmod 1777 tmp ''; + # /app/config must be writable by the runtime user (1000): homepage seeds + # missing skeleton configs (proxmox.yaml, etc.) and writes /app/config/logs. + # The deployment mounts ConfigMap files at /app/config/.yaml via + # subPath, which leaves the parent dir as image filesystem — so its + # ownership has to be set at build time. + fakeRootCommands = '' + mkdir -p app/config + chown -R 1000:1000 app + ''; + enableFakechroot = true; + config = { Entrypoint = [ "${homepage}/bin/homepage" ]; Env = [ diff --git a/docs/changelog.d/+homepage-config-perms-fix.bugfix.md b/docs/changelog.d/+homepage-config-perms-fix.bugfix.md new file mode 100644 index 0000000..20e1135 --- /dev/null +++ b/docs/changelog.d/+homepage-config-perms-fix.bugfix.md @@ -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. From eceb2b99ce9c3edd041625e39f8ce051a81fb268 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sun, 10 May 2026 21:16:34 -0700 Subject: [PATCH 367/430] C0: bump homepage image to fixed-perms build (v1.11.0-678f26b-nix) Pulls in 678f26b0 (chowned /app/config). Resolves the EACCES crash loop on ringtail. --- argocd/manifests/homepage/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/argocd/manifests/homepage/kustomization.yaml b/argocd/manifests/homepage/kustomization.yaml index ce627ac..31b6847 100644 --- a/argocd/manifests/homepage/kustomization.yaml +++ b/argocd/manifests/homepage/kustomization.yaml @@ -17,7 +17,7 @@ resources: images: - name: registry.ops.eblu.me/blumeops/homepage - newTag: v1.11.0-b87f62e-nix + newTag: v1.11.0-678f26b-nix configMapGenerator: - name: homepage-config From 292d354902eb47b16dc0aed18044f7ccac3381a4 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 11 May 2026 13:47:18 -0700 Subject: [PATCH 368/430] C1: deploy adelaide-baby-shower-app to ringtail k3s (#349) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Brings up the Adelaide / Heidi / Addie baby shower app on ringtail k3s with the public/private split that the app's hosting contract calls for: `shower.eblu.me` (public, via Fly proxy) and `shower.ops.eblu.me` (tailnet). App is consumed as a wheel from the Forgejo PyPI index — source lives at [`adelaide-baby-shower-app`](https://forge.eblu.me/eblume/adelaide-baby-shower-app). ### What's included - **ArgoCD app + manifests** under `argocd/manifests/shower/` (deployment, service, ProxyGroup ingress, ConfigMap for `DJANGO_DEBUG`/`DJANGO_ADMIN_URL`, ExternalSecret for `DJANGO_SECRET_KEY` from 1Password item `Shower (blumeops)`, NFS PV on sifaka, RWX media PVC, RWO local-path data PVC for SQLite). Recreate rollout because SQLite is single-writer. - **Public surface** (`fly/`): new `shower.eblu.me` server block proxying to `shower.ops.eblu.me`. `/admin/` returns 403 at the edge except `/admin/login/` and `/admin/logout/`, which are rate-limited via a new `shower_auth` zone. `X-Clacks-Overhead` on. GNU Terry Pratchett. - **fail2ban** filter (`shower-admin-login.conf`) matching 401/403/429 on `/admin/login/` and jail (`shower.conf`) with `maxretry=5/findtime=600/bantime=3600`. The `nginx-deny` action was generalized to take a per-jail `nginx_deny_file` so the shower has its own deny list (forge keeps using the legacy default). - **Caddy** route on indri (`shower.ops.eblu.me` → `https://shower.tail8d86e.ts.net`). - **Pulumi** Gandi CNAME `shower.eblu.me → blumeops-proxy.fly.dev.`. - **Grafana** APM dashboard `configmap-shower-apm.yaml` (request rate, error rate, failed admin login count, latency percentiles, bandwidth, access logs) mirroring `docs-apm.json` with a `host="shower.eblu.me"` filter. - **Container** `containers/shower/default.nix` — `dockerTools.buildLayeredImage` with a nixpkgs Python and a startup wrapper that creates `/app/data/.venv`, pip-installs `adelaide-baby-shower-app==1.0.0` from the forge PyPI index on first boot, runs migrations + collectstatic, and execs gunicorn. A `local_settings.py` shim pins `DATABASES.NAME`/`MEDIA_ROOT`/`STATIC_ROOT` to absolute paths so they don't end up in site-packages. - **Docs** runbook at `docs/how-to/operations/shower-app.md` linked from the apps registry, plus changelog fragments. ### Defense layers on the public surface 1. fly nginx geo+fail2ban `$shower_banned` (per-service deny list) 2. fly nginx `limit_req zone=shower_auth` (3 r/s per Fly-Client-IP) 3. django-axes (5 fails / 1h, keyed on username+ip_address) 4. edge `/admin/` block (returns 403 for anything that isn't login/logout) ## Prerequisites for the user to do (NOT in this PR) Halted on these per request — they touch shared/manual systems: - [x] **NFS share** on sifaka: `/volume1/shower`, NFS rule for ringtail RW, `chown 1000:1000` - [ ] **1Password item** `Shower (blumeops)` in the blumeops vault with a freshly minted `secret-key` field (`openssl rand -base64 48`) — do NOT reuse anything that has lived in git - [ ] **Container build**: `mise run container-build-and-release shower`, then update `images[].newTag` in `argocd/manifests/shower/kustomization.yaml` to the resulting `v1.0.0--nix` - [x] **DNS**: `mise run dns-up` after merge - [x] **Fly cert**: `fly certs add shower.eblu.me -a blumeops-proxy` - [ ] **Caddy push**: `mise run provision-indri -- --tags caddy` - [ ] **Fly redeploy** to pick up the new nginx block + fail2ban jail: `mise run fly-deploy` - [ ] **ArgoCD sync**: `argocd app set shower --revision shower-app-deploy && argocd app sync shower` to test from this branch before merging ## Test plan - [ ] Container builds successfully on nix-container-builder runner - [ ] Pod starts, migrations run, gunicorn answers on :8000 - [ ] `kubectl --context=k3s-ringtail -n shower logs deploy/shower` clean - [ ] `curl -sf https://shower.ops.eblu.me/` returns the splash page (tailnet) - [ ] `curl -I -H "Host: shower.eblu.me" https://blumeops-proxy.fly.dev/` returns 200 (pre-DNS verification) - [ ] `curl -I -H "Host: shower.eblu.me" https://blumeops-proxy.fly.dev/admin/users/` returns 403 (edge block) - [ ] `curl -I -H "Host: shower.eblu.me" https://blumeops-proxy.fly.dev/admin/login/` returns a Django login response - [ ] After DNS is up: `curl -I https://shower.eblu.me/` returns 200 with `X-Clacks-Overhead` - [ ] Grafana dashboard "Shower APM" appears and starts showing traffic - [ ] `mise run services-check` passes Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/349 --- ansible/roles/borgmatic/defaults/main.yml | 8 + ansible/roles/caddy/defaults/main.yml | 3 + ansible/roles/cv/defaults/main.yml | 2 +- argocd/apps/shower.yaml | 20 ++ .../dashboards/configmap-shower-apm.yaml | 229 ++++++++++++++++ .../grafana-config/kustomization.yaml | 1 + argocd/manifests/shower/configmap.yaml | 22 ++ argocd/manifests/shower/deployment.yaml | 81 ++++++ argocd/manifests/shower/external-secret.yaml | 19 ++ .../manifests/shower/ingress-tailscale.yaml | 30 ++ argocd/manifests/shower/kustomization.yaml | 17 ++ argocd/manifests/shower/pv-nfs.yaml | 24 ++ argocd/manifests/shower/pvc.yaml | 30 ++ argocd/manifests/shower/service.yaml | 13 + containers/shower/default.nix | 259 ++++++++++++++++++ docs/changelog.d/shower-app-deploy.bugfix.md | 13 + docs/changelog.d/shower-app-deploy.feature.md | 4 + docs/changelog.d/shower-app-deploy.infra.md | 9 + docs/how-to/operations/shower-on-ringtail.md | 245 +++++++++++++++++ docs/reference/kubernetes/apps.md | 1 + docs/reference/services/shower-app.md | 55 ++++ docs/tutorials/expose-service-publicly.md | 36 ++- fly/fail2ban/action.d/nginx-deny.conf | 13 +- fly/nginx.conf | 160 +++++++++++ fly/start.sh | 1 + mise-tasks/fly-setup | 1 + pulumi/gandi/__main__.py | 10 + service-versions.yaml | 19 ++ 28 files changed, 1315 insertions(+), 10 deletions(-) create mode 100644 argocd/apps/shower.yaml create mode 100644 argocd/manifests/grafana-config/dashboards/configmap-shower-apm.yaml create mode 100644 argocd/manifests/shower/configmap.yaml create mode 100644 argocd/manifests/shower/deployment.yaml create mode 100644 argocd/manifests/shower/external-secret.yaml create mode 100644 argocd/manifests/shower/ingress-tailscale.yaml create mode 100644 argocd/manifests/shower/kustomization.yaml create mode 100644 argocd/manifests/shower/pv-nfs.yaml create mode 100644 argocd/manifests/shower/pvc.yaml create mode 100644 argocd/manifests/shower/service.yaml create mode 100644 containers/shower/default.nix create mode 100644 docs/changelog.d/shower-app-deploy.bugfix.md create mode 100644 docs/changelog.d/shower-app-deploy.feature.md create mode 100644 docs/changelog.d/shower-app-deploy.infra.md create mode 100644 docs/how-to/operations/shower-on-ringtail.md create mode 100644 docs/reference/services/shower-app.md diff --git a/ansible/roles/borgmatic/defaults/main.yml b/ansible/roles/borgmatic/defaults/main.yml index 25d0149..123cb0f 100644 --- a/ansible/roles/borgmatic/defaults/main.yml +++ b/ansible/roles/borgmatic/defaults/main.yml @@ -27,6 +27,9 @@ borgmatic_source_directories: - /Users/erichblume/.config/borgmatic - /Users/erichblume/Documents - /Users/erichblume/.local/share/borgmatic/k8s-dumps + # Shower app prize-photo uploads (sifaka SMB mount). Mounted manually + # on indri via Finder — see docs/how-to/operations/shower-app.md. + - /Volumes/shower # Backup repositories borgmatic_repositories: @@ -54,6 +57,11 @@ borgmatic_k8s_sqlite_dumps: label_selector: app=mealie db_path: /app/data/mealie.db context: minikube + - name: shower + namespace: shower + label_selector: app=shower + db_path: /app/data/db.sqlite3 + context: k3s-ringtail # Exclude patterns borgmatic_exclude_patterns: [] diff --git a/ansible/roles/caddy/defaults/main.yml b/ansible/roles/caddy/defaults/main.yml index 6eada76..da6f3f9 100644 --- a/ansible/roles/caddy/defaults/main.yml +++ b/ansible/roles/caddy/defaults/main.yml @@ -101,6 +101,9 @@ caddy_services: - name: paperless host: "paperless.{{ caddy_domain }}" backend: "https://paperless.tail8d86e.ts.net" + - name: shower + host: "shower.{{ caddy_domain }}" + backend: "https://shower.tail8d86e.ts.net" - name: sifaka host: "nas.{{ caddy_domain }}" backend: "http://sifaka:5000" diff --git a/ansible/roles/cv/defaults/main.yml b/ansible/roles/cv/defaults/main.yml index 734e52b..a18cc82 100644 --- a/ansible/roles/cv/defaults/main.yml +++ b/ansible/roles/cv/defaults/main.yml @@ -3,7 +3,7 @@ # Caddy serves cv_content_dir directly via the static-kind service block. cv_version: "v1.0.3" -cv_release_url: "https://forge.eblu.me/api/packages/eblume/generic/cv/{{ cv_version }}/cv-{{ cv_version }}.tar.gz" +cv_release_url: "https://forge.ops.eblu.me/api/packages/eblume/generic/cv/{{ cv_version }}/cv-{{ cv_version }}.tar.gz" cv_home: /Users/erichblume/blumeops/cv cv_content_dir: "{{ cv_home }}/content" diff --git a/argocd/apps/shower.yaml b/argocd/apps/shower.yaml new file mode 100644 index 0000000..c4a7a62 --- /dev/null +++ b/argocd/apps/shower.yaml @@ -0,0 +1,20 @@ +# Adelaide / Heidi / Addie baby shower app — Django guest/raffle/prize system. +# Public landing page at shower.eblu.me (via fly proxy), staff console + admin +# at shower.ops.eblu.me (tailnet only). Built from forge PyPI wheel. +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: shower + namespace: argocd +spec: + project: default + source: + repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git + targetRevision: main + path: argocd/manifests/shower + destination: + server: https://ringtail.tail8d86e.ts.net:6443 + namespace: shower + syncPolicy: + syncOptions: + - CreateNamespace=true diff --git a/argocd/manifests/grafana-config/dashboards/configmap-shower-apm.yaml b/argocd/manifests/grafana-config/dashboards/configmap-shower-apm.yaml new file mode 100644 index 0000000..96348e8 --- /dev/null +++ b/argocd/manifests/grafana-config/dashboards/configmap-shower-apm.yaml @@ -0,0 +1,229 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: grafana-dashboard-shower-apm + namespace: monitoring + labels: + grafana_dashboard: "1" +data: + shower-apm.json: | + { + "annotations": { "list": [] }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "id": null, + "links": [], + "panels": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisLabel": "req/s", + "drawStyle": "line", + "fillOpacity": 20, + "lineInterpolation": "linear", + "lineWidth": 1, + "showPoints": "never", + "spanNulls": false, + "stacking": { "group": "A", "mode": "normal" } + }, + "mappings": [], + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 16, "x": 0, "y": 0 }, + "id": 1, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "right", "showLegend": true }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "targets": [ + { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "sum by (status) (rate(flyio_nginx_http_requests_total{host=\"shower.eblu.me\"}[5m]))", "legendFormat": "{{status}}", "refId": "A" } + ], + "title": "Request Rate by Status", + "type": "timeseries" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 0.01 }, { "color": "red", "value": 0.05 }] }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 8, "x": 16, "y": 0 }, + "id": 2, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "auto" + }, + "targets": [ + { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "sum(rate(flyio_nginx_http_requests_total{host=\"shower.eblu.me\",status=~\"5..\"}[5m])) / sum(rate(flyio_nginx_http_requests_total{host=\"shower.eblu.me\"}[5m]))", "refId": "A" } + ], + "title": "Error Rate (5xx)", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 1 }, { "color": "red", "value": 5 }] }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 4, "x": 16, "y": 4 }, + "id": 3, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "auto" + }, + "targets": [ + { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "sum(increase(flyio_nginx_http_requests_total{host=\"shower.eblu.me\",request_uri=~\"/admin/login.*\",status=~\"4..\"}[$__range]))", "refId": "A" } + ], + "title": "Failed admin logins (range)", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 4, "x": 20, "y": 4 }, + "id": 4, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "auto" + }, + "targets": [ + { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "sum(rate(flyio_nginx_http_requests_total{host=\"shower.eblu.me\"}[5m]))", "refId": "A" } + ], + "title": "Current RPS", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisLabel": "seconds", + "drawStyle": "line", + "fillOpacity": 10, + "lineInterpolation": "linear", + "lineWidth": 1, + "showPoints": "never", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" } + }, + "mappings": [], + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 }, + "id": 5, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "right", "showLegend": true }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "targets": [ + { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "histogram_quantile(0.50, sum by (le) (rate(flyio_nginx_http_request_duration_seconds_bucket{host=\"shower.eblu.me\"}[5m])))", "legendFormat": "p50", "refId": "A" }, + { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "histogram_quantile(0.90, sum by (le) (rate(flyio_nginx_http_request_duration_seconds_bucket{host=\"shower.eblu.me\"}[5m])))", "legendFormat": "p90", "refId": "B" }, + { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "histogram_quantile(0.99, sum by (le) (rate(flyio_nginx_http_request_duration_seconds_bucket{host=\"shower.eblu.me\"}[5m])))", "legendFormat": "p99", "refId": "C" } + ], + "title": "Latency Percentiles", + "type": "timeseries" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisLabel": "", + "drawStyle": "line", + "fillOpacity": 20, + "lineInterpolation": "linear", + "lineWidth": 1, + "showPoints": "never", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" } + }, + "mappings": [], + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }, + "unit": "Bps" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 }, + "id": 6, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "right", "showLegend": true }, + "tooltip": { "mode": "single", "sort": "none" } + }, + "targets": [ + { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "sum(rate(flyio_nginx_http_response_bytes_total{host=\"shower.eblu.me\"}[5m]))", "legendFormat": "Bandwidth", "refId": "A" } + ], + "title": "Bandwidth", + "type": "timeseries" + }, + { + "datasource": { "type": "loki", "uid": "loki" }, + "gridPos": { "h": 8, "w": 24, "x": 0, "y": 16 }, + "id": 7, + "options": { + "dedupStrategy": "none", + "enableLogDetails": true, + "prettifyLogMessage": false, + "showCommonLabels": false, + "showLabels": false, + "showTime": true, + "sortOrder": "Descending", + "wrapLogMessage": false + }, + "targets": [ + { "datasource": { "type": "loki", "uid": "loki" }, "expr": "{instance=\"flyio-proxy\", job=\"flyio-nginx\"} |= \"shower.eblu.me\" | json | line_format \"{{.client_ip}} {{.request_method}} {{.request_uri}} {{.status}} {{.request_time}}s\"", "refId": "A" } + ], + "title": "Recent Access Logs", + "type": "logs" + } + ], + "refresh": "30s", + "schemaVersion": 38, + "tags": ["shower", "flyio", "apm"], + "templating": { "list": [] }, + "time": { "from": "now-6h", "to": "now" }, + "timepicker": {}, + "timezone": "", + "title": "Shower APM", + "uid": "shower-apm", + "version": 1, + "weekStart": "" + } diff --git a/argocd/manifests/grafana-config/kustomization.yaml b/argocd/manifests/grafana-config/kustomization.yaml index a6e8000..b518043 100644 --- a/argocd/manifests/grafana-config/kustomization.yaml +++ b/argocd/manifests/grafana-config/kustomization.yaml @@ -22,6 +22,7 @@ resources: - dashboards/configmap-transmission.yaml - dashboards/configmap-cv-apm.yaml - dashboards/configmap-docs-apm.yaml + - dashboards/configmap-shower-apm.yaml - dashboards/configmap-flyio.yaml - dashboards/configmap-sifaka-disks.yaml - dashboards/configmap-forgejo.yaml diff --git a/argocd/manifests/shower/configmap.yaml b/argocd/manifests/shower/configmap.yaml new file mode 100644 index 0000000..6102c1e --- /dev/null +++ b/argocd/manifests/shower/configmap.yaml @@ -0,0 +1,22 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: shower-app-config + namespace: shower +data: + DJANGO_DEBUG: "0" + # The app's settings.py hardcodes ALLOWED_HOSTS = ["shower.eblu.me", + # "localhost", "127.0.0.1"] and exposes this env var as a comma-separated + # extras list. shower.ops.eblu.me is what Caddy on indri and the + # Tailscale ProxyGroup both send as the Host header, so the app needs to + # accept it. + DJANGO_ALLOWED_HOSTS: "shower.ops.eblu.me" + # /host/, /admin/, and Django's login surface are all tailnet-only — the + # public proxy 403s everything outside of `/` and `/prizes//`. + # /host/'s "Django admin" link follows DJANGO_ADMIN_URL. + DJANGO_ADMIN_URL: "https://shower.ops.eblu.me/admin/" + # /host/ is served on shower.ops.eblu.me (tailnet), but the QR codes it + # generates need to point at the public WAN hostname so guest phones can + # reach them. PUBLIC_URL_BASE overrides Django's request.build_absolute_uri() + # in the QR views — see shower/views.py:_public_url. Added in app v1.0.1. + DJANGO_PUBLIC_URL_BASE: "https://shower.eblu.me" diff --git a/argocd/manifests/shower/deployment.yaml b/argocd/manifests/shower/deployment.yaml new file mode 100644 index 0000000..70547aa --- /dev/null +++ b/argocd/manifests/shower/deployment.yaml @@ -0,0 +1,81 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: shower + namespace: shower +spec: + replicas: 1 + # SQLite + RWO data PVC: only one writer at a time. Recreate ensures the + # old pod's lock on the local-path volume is released before the new one + # mounts it. + strategy: + type: Recreate + selector: + matchLabels: + app: shower + template: + metadata: + labels: + app: shower + spec: + securityContext: + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + seccompProfile: + type: RuntimeDefault + containers: + - name: shower + image: registry.ops.eblu.me/blumeops/shower:kustomized + securityContext: + runAsNonRoot: true + allowPrivilegeEscalation: false + ports: + - containerPort: 8000 + name: http + envFrom: + - configMapRef: + name: shower-app-config + - secretRef: + name: shower-app-secrets + volumeMounts: + - name: media + mountPath: /app/media + - name: data + mountPath: /app/data + resources: + requests: + memory: "128Mi" + cpu: "50m" + limits: + memory: "512Mi" + cpu: "500m" + livenessProbe: + httpGet: + path: / + port: 8000 + httpHeaders: + - name: Host + value: shower.ops.eblu.me + - name: X-Forwarded-Proto + value: https + initialDelaySeconds: 30 + periodSeconds: 30 + readinessProbe: + httpGet: + path: / + port: 8000 + httpHeaders: + - name: Host + value: shower.ops.eblu.me + - name: X-Forwarded-Proto + value: https + initialDelaySeconds: 10 + periodSeconds: 10 + volumes: + - name: media + persistentVolumeClaim: + claimName: shower-media + - name: data + persistentVolumeClaim: + claimName: shower-data diff --git a/argocd/manifests/shower/external-secret.yaml b/argocd/manifests/shower/external-secret.yaml new file mode 100644 index 0000000..005a7e9 --- /dev/null +++ b/argocd/manifests/shower/external-secret.yaml @@ -0,0 +1,19 @@ +--- +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: shower-app-secrets + namespace: shower +spec: + refreshInterval: 1h + secretStoreRef: + kind: ClusterSecretStore + name: onepassword-blumeops + target: + name: shower-app-secrets + creationPolicy: Owner + data: + - secretKey: DJANGO_SECRET_KEY + remoteRef: + key: "Shower (blumeops)" + property: secret-key diff --git a/argocd/manifests/shower/ingress-tailscale.yaml b/argocd/manifests/shower/ingress-tailscale.yaml new file mode 100644 index 0000000..d09a696 --- /dev/null +++ b/argocd/manifests/shower/ingress-tailscale.yaml @@ -0,0 +1,30 @@ +# Tailscale Ingress for shower app. +# Exposes at shower.tail8d86e.ts.net. +# Caddy on indri proxies shower.ops.eblu.me here. The fly proxy then proxies +# shower.eblu.me through Caddy to this same endpoint (fly does not contact +# the k8s service directly — all traffic routes through indri's Caddy). +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: shower-tailscale + namespace: shower + annotations: + tailscale.com/proxy-class: "default" + tailscale.com/proxy-group: "ingress" + gethomepage.dev/enabled: "true" + gethomepage.dev/name: "Shower" + gethomepage.dev/group: "Home" + gethomepage.dev/icon: "mdi-baby" + gethomepage.dev/description: "Adelaide baby shower" + gethomepage.dev/href: "https://shower.ops.eblu.me" + gethomepage.dev/pod-selector: "app=shower" +spec: + ingressClassName: tailscale + defaultBackend: + service: + name: shower + port: + number: 8000 + tls: + - hosts: + - shower diff --git a/argocd/manifests/shower/kustomization.yaml b/argocd/manifests/shower/kustomization.yaml new file mode 100644 index 0000000..0afc8e3 --- /dev/null +++ b/argocd/manifests/shower/kustomization.yaml @@ -0,0 +1,17 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: shower + +resources: + - configmap.yaml + - external-secret.yaml + - pv-nfs.yaml + - pvc.yaml + - service.yaml + - ingress-tailscale.yaml + - deployment.yaml + +images: + - name: registry.ops.eblu.me/blumeops/shower + newTag: v1.0.2-039d9b9-nix diff --git a/argocd/manifests/shower/pv-nfs.yaml b/argocd/manifests/shower/pv-nfs.yaml new file mode 100644 index 0000000..7354fb5 --- /dev/null +++ b/argocd/manifests/shower/pv-nfs.yaml @@ -0,0 +1,24 @@ +# NFS PersistentVolume for shower app media uploads (prize photos). +# +# Requires the `shower` share on sifaka with NFS exports matching the +# blumeops standard (192.168.1.0/24 + 100.64.0.0/10, all_squash → admin). +# See docs/how-to/operations/shower-app.md for the Synology web-UI walk +# and docs/reference/storage/sifaka.md for the exports table. +# +# Because all_squash rewrites every NFS write to admin:users (1024:100), +# the in-pod runAsUser does NOT have to match an on-disk uid. Mode 0777 +# on /volume1/shower lets the pod read back what it wrote. +apiVersion: v1 +kind: PersistentVolume +metadata: + name: shower-media-nfs-pv +spec: + capacity: + storage: 10Gi + accessModes: + - ReadWriteMany + persistentVolumeReclaimPolicy: Retain + storageClassName: "" + nfs: + server: sifaka + path: /volume1/shower diff --git a/argocd/manifests/shower/pvc.yaml b/argocd/manifests/shower/pvc.yaml new file mode 100644 index 0000000..47fee54 --- /dev/null +++ b/argocd/manifests/shower/pvc.yaml @@ -0,0 +1,30 @@ +# Media PVC — RWX NFS share for /app/media (prize photo uploads). +# SQLite DB lives in a separate local-path PVC; NFS file locking is not +# reliable enough for SQLite's WAL/journal. +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: shower-media + namespace: shower +spec: + accessModes: + - ReadWriteMany + storageClassName: "" + volumeName: shower-media-nfs-pv + resources: + requests: + storage: 10Gi +--- +# Database PVC — k3s local-path (default storage class) for SQLite. +# RWO is fine: the deployment runs with a single replica. +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: shower-data + namespace: shower +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 2Gi diff --git a/argocd/manifests/shower/service.yaml b/argocd/manifests/shower/service.yaml new file mode 100644 index 0000000..0a73aab --- /dev/null +++ b/argocd/manifests/shower/service.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Service +metadata: + name: shower + namespace: shower +spec: + selector: + app: shower + ports: + - name: http + port: 8000 + targetPort: 8000 + protocol: TCP diff --git a/containers/shower/default.nix b/containers/shower/default.nix new file mode 100644 index 0000000..d9863e1 --- /dev/null +++ b/containers/shower/default.nix @@ -0,0 +1,259 @@ +# Nix-built shower app container — Adelaide / Heidi / Addie baby shower. +# +# The app is published as a wheel to the Forgejo PyPI index at +# https://forge.eblu.me/api/packages/eblume/pypi/. The wheel + its +# transitive Python deps are baked in at build time via a fixed-output +# derivation that runs `pip install --target` against forge PyPI (proxied +# through pypi.ops.eblu.me for upstream packages). Build runs on the +# nix-container-builder runner (ringtail, amd64) so the image is native. +# +# Going through pip-install-target rather than nixpkgs Python packages +# sidesteps two issues we hit going through `python.pkgs.buildPythonPackage`: +# 1. python314Packages.django still aliases to Django 4.2 LTS, which +# doesn't support Python 3.14 at all. +# 2. django-axes pulls selenium + browser fonts into its check phase +# and the nix sandbox can't provide those. +# +# To bump the version: +# 1. Update `version` below. +# 2. Set `outputHash` to `pkgs.lib.fakeHash`, run the build, copy the +# real hash out of the error, and commit it. +{ pkgs ? import { } }: + +let + version = "1.0.2"; + + python = pkgs.python314; + + # The repo's top-level static/ directory (vendored Sortable + cropper + # JS/CSS, prize placeholder SVG) isn't shipped in the wheel — hatchling + # only packages config/ and shower/, leaving the repo-root static/ + # behind. Pull the sdist (which contains the full source tree) and + # extract just the static/ subtree into the image as /app/static. + # local_settings adds it to STATICFILES_DIRS so collectstatic at boot + # picks it up alongside the Django admin's static files. + # + # Fetched from forge.ops.eblu.me (tailnet) because /api/packages/* is + # blocked at the fly edge — see fly/nginx.conf forge.eblu.me block. + # Hash is the upstream sha256 from forge PyPI's simple index. + showerSdist = pkgs.fetchurl { + name = "adelaide_baby_shower_app-${version}.tar.gz"; + url = "https://forge.ops.eblu.me/api/packages/eblume/pypi/files/adelaide-baby-shower-app/${version}/adelaide_baby_shower_app-${version}.tar.gz"; + hash = "sha256-nlCtlx9zuYaLoJZSckybLV5YPpA8vZamN96O3RXOstM="; + }; + + staticAssets = pkgs.runCommand "shower-static-assets-${version}" { } '' + ${pkgs.gnutar}/bin/tar -xzf ${showerSdist} -C $TMPDIR + cp -r $TMPDIR/adelaide_baby_shower_app-${version}/static $out + ''; + + # Fixed-output derivation: pip-installs the app wheel + every transitive + # dep into a single target dir. FODs get network access in exchange for + # a pinned output hash, which means the whole dependency closure is + # immutable across rebuilds. + pyDepsFOD = pkgs.stdenv.mkDerivation { + pname = "shower-python-deps-fod"; + inherit version; + + dontUnpack = true; + + nativeBuildInputs = [ python pkgs.cacert pkgs.removeReferencesTo ]; + + buildPhase = '' + runHook preBuild + + export HOME=$TMPDIR + export SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt + export PIP_DISABLE_PIP_VERSION_CHECK=1 + + ${python}/bin/python -m venv "$TMPDIR/venv" + "$TMPDIR/venv/bin/pip" install --upgrade pip + "$TMPDIR/venv/bin/pip" install \ + --no-cache-dir \ + --index-url=https://pypi.ops.eblu.me/root/pypi/+simple/ \ + --extra-index-url=https://forge.ops.eblu.me/api/packages/eblume/pypi/simple/ \ + "adelaide-baby-shower-app==${version}" \ + gunicorn + + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + + mkdir -p $out/lib/python3.14 $out/bin + cp -r "$TMPDIR/venv/lib/python3.14/site-packages" $out/lib/python3.14/site-packages + + for script in "$TMPDIR/venv/bin/"*; do + [ -f "$script" ] || continue + name=$(basename "$script") + case "$name" in + python*|pip*|activate*) continue ;; + esac + cp "$script" "$out/bin/$name" + chmod +x "$out/bin/$name" + done + + # --- Strip Nix store references (FOD outputs must be self-contained) --- + # The wrapper derivation below restores them via autoPatchelfHook + a + # python wrapper that points pyc-less imports at the on-image python. + + # Strip bytecode entirely — pyc files embed compile-time paths. + find $out -type f -name '*.pyc' -delete + find $out -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null || true + + # Dynamically discover all nix store references and strip them. We + # don't have a static list because pip pulls in stdenv via Python's + # build env (gcc-lib, libstdc++, etc.) and the closure is opaque. + { find $out -type f -print0 \ + | xargs -0 grep -aohE '/nix/store/[a-z0-9]{32}-[^/"[:space:]]+' 2>/dev/null \ + || true; } | sort -u > $TMPDIR/store-refs.txt + echo "Found $(wc -l < $TMPDIR/store-refs.txt) unique store path references to strip" + + refs_args="" + while IFS= read -r ref; do + refs_args="$refs_args -t $ref" + done < $TMPDIR/store-refs.txt + + if [ -n "$refs_args" ]; then + find $out -type f -exec remove-references-to $refs_args {} + 2>/dev/null || true + fi + + remaining=$({ find $out -type f -print0 | xargs -0 grep -cl '/nix/store/' 2>/dev/null || true; } | wc -l) + echo "Files with remaining store references: $remaining" + + runHook postInstall + ''; + + outputHashMode = "recursive"; + outputHashAlgo = "sha256"; + # Pinned dep closure — reproducible until version bumps. To recompute, + # set to pkgs.lib.fakeHash and read the failure. + outputHash = "sha256-tSTH/HaDY7M0qxlauBTM+JekZAgF++K2lGP3PLvym/o="; + + dontFixup = true; + }; + + # Non-FOD wrapper: re-applies RPATHs to pre-built .so files (pillow, + # scipy) so they find libstdc++ / libz / etc. at runtime. autoPatchelfHook + # discovers needed libraries from buildInputs. + pyDeps = pkgs.stdenv.mkDerivation { + pname = "shower-python-deps"; + inherit version; + + dontUnpack = true; + + nativeBuildInputs = [ pkgs.autoPatchelfHook ]; + + buildInputs = with pkgs; [ + python + stdenv.cc.cc.lib # libstdc++, libgcc_s + zlib + libjpeg + libwebp + libtiff + openjpeg + lcms2 + freetype + ]; + + installPhase = '' + cp -r ${pyDepsFOD} $out + chmod -R u+w $out + ''; + }; + + sitePackages = "${pyDeps}/lib/python3.14/site-packages"; + + # Settings shim — config/settings.py's `BASE_DIR = parent.parent` would + # otherwise resolve to site-packages, scattering db.sqlite3 / media / + # staticfiles into the venv. Pin them to /app/{data,media,data/staticfiles}. + localSettings = pkgs.writeText "local_settings.py" '' + from pathlib import Path + + from config.settings import * # noqa: F401,F403 + + DATABASES["default"]["NAME"] = "/app/data/db.sqlite3" + MEDIA_ROOT = "/app/media" + STATIC_ROOT = "/app/data/staticfiles" + # /app/static comes from the repo-root static/ subtree of the sdist + # (see default.nix staticAssets). Added because the wheel doesn't + # ship vendored Sortable/cropper assets. + STATICFILES_DIRS = [Path("/app/static")] + ''; + + # PYTHONPATH, DJANGO_SETTINGS_MODULE, PATH, and HOME live in the image's + # `Env` block below — that way `kubectl exec deploy/shower -- python -m + # django ` Just Works without an inline `env` ceremony. + # The entrypoint just changes directory and runs the boot sequence. + entrypoint = pkgs.writeShellScript "shower-entrypoint" '' + set -eu + + cd /app + + mkdir -p /app/data /app/media + + echo "shower: running migrations" + python -m django migrate --noinput + + echo "shower: collecting static files" + python -m django collectstatic --noinput --clear + + echo "shower: starting gunicorn" + exec gunicorn \ + --bind 0.0.0.0:8000 \ + --workers 2 \ + --forwarded-allow-ips='*' \ + config.wsgi:application + ''; +in + +pkgs.dockerTools.buildLayeredImage { + name = "blumeops/shower"; + contents = [ + python + pyDeps + pkgs.cacert + pkgs.tzdata + pkgs.bashInteractive + pkgs.coreutils + ]; + + extraCommands = '' + mkdir -p app/data app/media tmp + chmod 1777 tmp + cp ${localSettings} app/local_settings.py + cp -r ${staticAssets} app/static + chmod -R u+w app/static + ''; + + fakeRootCommands = '' + chown -R 1000:1000 app + ''; + enableFakechroot = true; + + config = { + Entrypoint = [ "${entrypoint}" ]; + Env = [ + "SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" + "TZDIR=${pkgs.tzdata}/share/zoneinfo" + "TZ=America/Los_Angeles" + "TMPDIR=/tmp" + "LANG=C.UTF-8" + "LC_ALL=C.UTF-8" + "PYTHONDONTWRITEBYTECODE=1" + "HOME=/app/data" + "PATH=${pyDeps}/bin:${python}/bin:/bin" + # /app first so local_settings.py is importable; sitePackages second so + # django, gunicorn, etc. resolve. Inherited by entrypoint + any + # `kubectl exec` so manual django subcommands work without ceremony. + "PYTHONPATH=/app:${sitePackages}" + "DJANGO_SETTINGS_MODULE=local_settings" + ]; + ExposedPorts = { + "8000/tcp" = { }; + }; + User = "1000"; + WorkingDir = "/app"; + }; +} diff --git a/docs/changelog.d/shower-app-deploy.bugfix.md b/docs/changelog.d/shower-app-deploy.bugfix.md new file mode 100644 index 0000000..91d2b3b --- /dev/null +++ b/docs/changelog.d/shower-app-deploy.bugfix.md @@ -0,0 +1,13 @@ +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`). diff --git a/docs/changelog.d/shower-app-deploy.feature.md b/docs/changelog.d/shower-app-deploy.feature.md new file mode 100644 index 0000000..96218be --- /dev/null +++ b/docs/changelog.d/shower-app-deploy.feature.md @@ -0,0 +1,4 @@ +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). diff --git a/docs/changelog.d/shower-app-deploy.infra.md b/docs/changelog.d/shower-app-deploy.infra.md new file mode 100644 index 0000000..157a068 --- /dev/null +++ b/docs/changelog.d/shower-app-deploy.infra.md @@ -0,0 +1,9 @@ +Wire shower app for public exposure: fly nginx `shower.eblu.me` server +block as a guest-only surface — splash page, `/prizes//`, 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. diff --git a/docs/how-to/operations/shower-on-ringtail.md b/docs/how-to/operations/shower-on-ringtail.md new file mode 100644 index 0000000..daf1046 --- /dev/null +++ b/docs/how-to/operations/shower-on-ringtail.md @@ -0,0 +1,245 @@ +--- +title: Shower App on Ringtail +modified: 2026-05-10 +last-reviewed: 2026-05-10 +tags: + - how-to + - operations + - kubernetes + - django +--- + +# Shower App on Ringtail + +How the Adelaide / Heidi / Addie baby shower app is deployed. The app is a +Django project ([`adelaide-baby-shower-app`](https://forge.eblu.me/eblume/adelaide-baby-shower-app)) +released as a wheel to the Forgejo Packages PyPI index and run on +[[ringtail]]'s k3s cluster. Public landing page at `shower.eblu.me`, staff +console + admin UI at `shower.ops.eblu.me` (tailnet only). + +The contract this deploy implements is defined in the app repo's +`docs/how-to/hosting.md` — read that for the env-var contract, security +model, and storage requirements before changing anything here. + +## Routing + +``` +Internet → shower.eblu.me + │ (Fly.io nginx — public) + ▼ + Caddy on indri (shower.ops.eblu.me) + │ + ▼ + Tailscale ProxyGroup ingress (shower.tail8d86e.ts.net) + │ + ▼ + Service shower:8000 → Pod (Django + gunicorn) +``` + +| Hostname | Reachable from | Notes | +|---|---|---| +| `shower.eblu.me` | Public internet | Guest surface only — splash, `/prizes//`, `/static/`, `/media/`. Everything authenticated 403s with a tailnet pointer. | +| `shower.ops.eblu.me` | Tailnet | Full app surface — `/host/`, `/admin/`, the works | +| `shower.tail8d86e.ts.net` | Tailnet | Bare ProxyGroup endpoint Caddy proxies to | + +## Defense layers (public side) + +The public surface is guest-only, so the threat model collapses: there +is no credential-accepting endpoint reachable from WAN, and nothing on +WAN that requires authentication. + +1. **edge auth lockout** — fly nginx 403s `/admin/`, `/host/`, and + anything that would redirect into them. Anyone hitting an auth URL + on WAN gets a "tailnet only" message. +2. **fly nginx `limit_req zone=general`** — 10 r/s per Fly-Client-IP + cushion for the splash form. +3. **django-axes** — 5 fails / 1 hour lockout per `(username, ip_address)`, + running on the tailnet-side login. Provides the only credential + defense, since brute-force is only reachable to tailnet members. + +The QR codes that `/host/` (on tailnet) generates for guests embed +`https://shower.eblu.me/...` even though the QR view is served from +the tailnet host. The app's `PUBLIC_URL_BASE` setting (added in v1.0.1) +overrides Django's `request.build_absolute_uri()` for those URLs. + +## Persistent storage + +| Mount | PVC | Type | Why | +|---|---|---|---| +| `/app/media` | `shower-media` | NFS RWX on sifaka (`/volume1/shower`) | Prize photos survive pod rescheduling | +| `/app/data` | `shower-data` | k3s `local-path` RWO | SQLite DB; NFS file locking can't be trusted for WAL/journal | + +The container has the app + its Python deps baked in at nix build time +(`buildPythonPackage` against the wheel fetched from forge PyPI). The +entrypoint runs migrations, runs `collectstatic`, and `exec`s gunicorn — +no pip-at-boot. A `local_settings.py` shim overrides `DATABASES.NAME`, +`MEDIA_ROOT`, and `STATIC_ROOT` to absolute paths under `/app/`, +sidestepping the wheel's `BASE_DIR = parent.parent` of an +in-site-packages settings module. + +## Backups + +[[borgmatic]] (running on indri) captures both halves of the persistent +state on its daily 2 a.m. run: + +- **`/app/data/db.sqlite3`** — dumped via `kubectl exec`'s + `sqlite3.backup()` against the live pod (entry in + `borgmatic_k8s_sqlite_dumps`, context `k3s-ringtail`). The dumped + file lands in `borgmatic_k8s_dump_dir` on indri and is picked up by + the main source-directory sweep. +- **`/app/media`** — picked up via `/Volumes/shower`, the SMB mount of + `sifaka:/volume1/shower` on indri. The same Synology share is exposed + via SMB *and* NFS simultaneously; ringtail's pod uses the NFS export, + while indri reads the SMB side for the borgmatic source. + +Both archive to [[sifaka]] (`borg-backups`) and BorgBase offsite, with +retention `keep_daily=7 / keep_monthly=12 / keep_yearly=1000`. + +The SMB mount on indri is set up manually once via Finder (Cmd-K → +`smb://sifaka/shower`, save credentials, "Always log in" so it +reconnects after reboot). If `/Volumes/shower` is missing at backup +time borgmatic will fail loudly — `source_directories_must_exist: true` +applies to all entries. + +## One-time setup steps + +These steps are required the first time the service is deployed and are +not encoded in the manifests. + +### 1. NFS + SMB share on sifaka + +On the Synology DSM web UI: + +1. **Control Panel → Shared Folder → Create**. Name: `shower`, + Location: Volume 1. Leave the rest at default. +2. **Control Panel → File Services → NFS → NFS Rules** (on the + `shower` row's *Permissions* tab). Add a rule mirroring the other + shares' pattern: Hostname/IP=`192.168.1.0/24` and again for + `100.64.0.0/10`, Privilege=Read/Write, Squash=`Map all users to + admin` (= `all_squash`), and tick *Allow connections from + non-privileged ports*. (See [[sifaka#NFS Exports]] — the existing + `frigate`, `paperless`, etc. shares use this exact pattern.) +3. **Control Panel → File Services → SMB**: leave SMB enabled + globally. No per-share rule required — the share inherits the + default `eblume` access. +4. The directory ownership at `/volume1/shower` will end up + `root:root`, mode `0777` (DSM default) — which is fine because + `all_squash` rewrites every NFS write to `admin:users`, and the + `0777` lets pods read what other pods wrote. No `chown` needed. + +After the share exists, mount it on indri for borgmatic: + +- In Finder, **Cmd-K → `smb://sifaka/shower`**, sign in as `eblume`, + and tick **Remember in Keychain** + **Always log in** so it + reconnects on reboot. This produces `/Volumes/shower`, which the + borgmatic source-directory list points at. + +### 2. 1Password item + +Item name: **`Shower (blumeops)`** in the `blumeops` vault. +Required property: + +| Field | Value | +|---|---| +| `secret-key` | Output of `openssl rand -base64 48` | + +The `ExternalSecret` `shower-app-secrets` will sync this into the +`shower` namespace as a `Secret` and `envFrom` exposes it as +`DJANGO_SECRET_KEY` to the container. + +**Never reuse a key that has ever been in git history.** Per the app's +hosting.md, an early dev key was committed before being replaced with +the `django-insecure-...` placeholder; the production key must be +freshly generated. + +### 3. Container image + +Built by the `build-container` Forgejo Actions workflow on the +`nix-container-builder` runner (ringtail, amd64). The wheel is fetched +from forge PyPI at nix build time and baked into the image — no +pip-at-runtime. To bump the version, change `version` in +`containers/shower/default.nix` and update `wheelHash` (or set it to +`pkgs.lib.fakeHash` and let the next build print the correct one). + +Trigger with: + +```fish +mise run container-build-and-release shower +``` + +After the workflow finishes, update `images[].newTag` in +`argocd/manifests/shower/kustomization.yaml` to the resulting +`vX.Y.Z--nix` tag, then commit (C0). + +### 4. DNS + +`pulumi/gandi/__main__.py` declares the `shower-public` CNAME pointing +at `blumeops-proxy.fly.dev.`. Apply with: + +```fish +mise run dns-preview +mise run dns-up +``` + +### 5. Fly.io certificate + +```fish +fly certs add shower.eblu.me -a blumeops-proxy +``` + +(Add to `mise-tasks/fly-setup` so re-runs of the one-time setup pick +it up.) + +### 6. Caddy on indri + +`shower` is in `ansible/roles/caddy/defaults/main.yml`. Push with: + +```fish +mise run provision-indri -- --tags caddy +``` + +### 7. Create the admin user + +The container's entrypoint runs `migrate --noinput` + `collectstatic +--noinput --clear` before gunicorn, so a fresh `db.sqlite3` is schema- +ready as soon as the pod boots. It does *not* create a Django superuser +— that has to happen once, interactively, after the first pod is up: + +```fish +kubectl --context=k3s-ringtail -n shower exec -it deploy/shower -- \ + python -m django createsuperuser +``` + +Use `erich` / your usual email. The same account doubles as the +`@staff_member_required` login for `/host/`. Subsequent staff accounts +can be created from `/admin/auth/user/` once you're signed in. + +## Deploying a new version + +1. Bump the wheel version in the app repo (`adelaide-baby-shower-app`) + and release it to Forgejo PyPI. +2. Bump `appVersion` in `containers/shower/default.nix` to match. +3. `mise run container-build-and-release shower`. Verify the build + with `mise run runner-logs`. +4. Update the `newTag` in `argocd/manifests/shower/kustomization.yaml` + to the new `[main]` SHA tag. +5. Commit (C0 after PR merge — see [[build-container-image#Squash-merge and container tags]]). +6. `argocd app sync shower`. + +## Verifying after a deploy + +```fish +kubectl --context=k3s-ringtail -n shower get pods +kubectl --context=k3s-ringtail -n shower logs deploy/shower +curl -sf https://shower.ops.eblu.me/ # tailnet +curl -sf https://shower.eblu.me/ # public +curl -I https://shower.eblu.me/admin/users/ # expect 403 (edge block) +curl -I https://shower.ops.eblu.me/admin/ # expect 200 / 302 (login) +``` + +## Related + +- [[expose-service-publicly]] — Fly.io proxy + Tailscale pattern +- [[deploy-k8s-service]] — generic ArgoCD service onboarding +- [[ringtail]] — the cluster +- [`hosting.md`](https://forge.eblu.me/eblume/adelaide-baby-shower-app/src/branch/main/docs/how-to/hosting.md) — app's deployment contract diff --git a/docs/reference/kubernetes/apps.md b/docs/reference/kubernetes/apps.md index 80ea72e..fd5c06f 100644 --- a/docs/reference/kubernetes/apps.md +++ b/docs/reference/kubernetes/apps.md @@ -41,6 +41,7 @@ Registry of all applications deployed via [[argocd]]. | `ollama` | ollama | `argocd/manifests/ollama/` | [[ollama]] | | `mealie` | mealie | `argocd/manifests/mealie/` | [[mealie]] | | `paperless` | paperless | `argocd/manifests/paperless/` | [[paperless]] | +| `shower` | shower | `argocd/manifests/shower/` | [[shower-app]] | | `prowler` | prowler | `argocd/manifests/prowler/` | [[prowler]] | ## Sync Policies diff --git a/docs/reference/services/shower-app.md b/docs/reference/services/shower-app.md new file mode 100644 index 0000000..26d1764 --- /dev/null +++ b/docs/reference/services/shower-app.md @@ -0,0 +1,55 @@ +--- +title: Shower App +modified: 2026-05-10 +last-reviewed: 2026-05-10 +tags: + - service + - django +--- + +# Shower App + +Django web app for Adelaide / Heidi / Addie's baby shower — guest splash with +a "what did you bring?" form, raffle picker, contest-prize ranking via +QR-coded `/prizes//` URLs, and an `/host/` operator console with +drag-rank assignment solving via scipy. + +## Quick Reference + +| Property | Value | +|----------|-------| +| **Public URL** | `shower.eblu.me` (guest surface only — via [[flyio-proxy]]) | +| **Private URL** | `shower.ops.eblu.me` (admin + `/host/` console — Caddy on indri) | +| **Cluster** | [[ringtail]] k3s, namespace `shower` | +| **Container** | `registry.ops.eblu.me/blumeops/shower` (built from `containers/shower/default.nix`) | +| **App source** | `forge.eblu.me/eblume/adelaide-baby-shower-app` (wheel on Forgejo PyPI) | +| **Database** | SQLite on a local-path PVC (`shower-data`, RWO 2 Gi) | +| **Media (prize photos)** | NFS RWX PVC `shower-media` → `sifaka:/volume1/shower` | +| **Secrets** | `Shower (blumeops)` 1Password item → `DJANGO_SECRET_KEY` | + +## Routing + +``` +Internet → shower.eblu.me (Fly nginx, guest-only 403s on /admin/ /host/) + │ + ▼ + Caddy on indri (shower.ops.eblu.me — full surface) + │ + ▼ + Tailscale ProxyGroup → k3s Service → Deployment +``` + +## Backups + +- **SQLite** dumped via `kubectl exec` to indri's `borgmatic_k8s_dump_dir` on every 2 a.m. run (mealie-pattern entry in `borgmatic_k8s_sqlite_dumps`) +- **Media** picked up via `/Volumes/shower` (sifaka SMB mount on indri) in the main `borgmatic_source_directories` list + +Both archive to sifaka + BorgBase. + +## Related + +- [[shower-on-ringtail]] — onboarding + day-of runbook +- [[expose-service-publicly]] — Fly proxy + tailnet pattern this rides on +- [[ringtail]] — host cluster +- [[sifaka#NFS Exports]] — NFS share table +- [[borgmatic]] — backup system diff --git a/docs/tutorials/expose-service-publicly.md b/docs/tutorials/expose-service-publicly.md index 6bc8fae..886cad4 100644 --- a/docs/tutorials/expose-service-publicly.md +++ b/docs/tutorials/expose-service-publicly.md @@ -176,17 +176,39 @@ Indri carries `tag:flyio-target` so the Fly proxy can reach Caddy. No per-servic Deploy: `mise run tailnet-preview` then `mise run tailnet-up`. -After deploying, extract the auth key and set it as a Fly.io secret: +After deploying, push the auth key to Fly.io. The simplest path is +`mise run fly-setup`, which reads the current value from Pulumi state +and stages it as a Fly.io secret: ```bash -# Get the key from Pulumi state -cd pulumi/tailscale && pulumi stack output flyio_authkey --show-secrets - -# Set it in Fly.io -fly secrets set TS_AUTHKEY="tskey-auth-..." -a blumeops-proxy +mise run fly-setup ``` -Store the auth key in 1Password as well for the `fly-setup` mise task. +Manual equivalent for reference: + +```bash +cd pulumi/tailscale && pulumi stack output flyio_authkey --show-secrets +# then in fly/: +fly secrets set TS_AUTHKEY="tskey-auth-..." -a blumeops-proxy --stage +``` + +**Pulumi state is the only source of truth for this key.** No other +process (mise tasks, ansible, scripts) reads it from anywhere else — +in particular, the key is not stored in 1Password. To rotate +(every 90 days, or after a compromise), force-replace the resource +and re-run `fly-setup`: + +```bash +mise run tailnet-up -- \ + --replace='urn:pulumi:tail8d86e::blumeops-tailnet::tailscale:index/tailnetKey:TailnetKey::flyio-proxy-key' +mise run fly-setup +mise run fly-deploy +``` + +Pulumi destroys the old key and mints a new 90-day one in a single +operation. Older fly machines that already authed against the old key +are unaffected (they don't need it after the initial join); only +*new* machine starts read the rotated value. ### Step 4: Mise tasks diff --git a/fly/fail2ban/action.d/nginx-deny.conf b/fly/fail2ban/action.d/nginx-deny.conf index 1d3737b..bab8abb 100644 --- a/fly/fail2ban/action.d/nginx-deny.conf +++ b/fly/fail2ban/action.d/nginx-deny.conf @@ -2,13 +2,22 @@ # Standard iptables banning won't work in Fly.io because $remote_addr # is Fly's internal proxy IP. Instead, we write banned IPs to a file # that nginx checks via a geo directive keyed on $http_fly_client_ip. +# +# The deny file is per-service: each jail sets `nginx_deny_file = ...` +# (see jail.d/*.conf) and a matching `geo $http_fly_client_ip $..._banned` +# block in nginx.conf includes the same path. [Definition] -actionban = echo " 1;" >> /etc/nginx/forge-deny.conf && nginx -s reload +actionban = echo " 1;" >> && nginx -s reload -actionunban = sed -i '/ 1;/d' /etc/nginx/forge-deny.conf && nginx -s reload +actionunban = sed -i '/ 1;/d' && nginx -s reload actionstart = actionstop = actioncheck = + +[Init] + +# Default for jails that don't override (preserves forge behaviour). +nginx_deny_file = /etc/nginx/forge-deny.conf diff --git a/fly/nginx.conf b/fly/nginx.conf index 5e49d88..570e6c9 100644 --- a/fly/nginx.conf +++ b/fly/nginx.conf @@ -34,6 +34,15 @@ http { # bucket. $http_fly_client_ip has the actual client IP. limit_req_zone $http_fly_client_ip zone=forge_auth:10m rate=3r/s; + # Shower-specific zone: loose enough that ~30 guests sharing a single + # venue-wifi NAT'd public IP can all scan the QR and load the splash + # (HTML + a handful of static asset hits each) without anyone tripping + # the limit. 50r/s + burst=200 covers the simultaneous-load spike; + # exploit scanners still trip it (e.g. the .env-sweeping bot we saw + # fired ~30 req in 2s — that pattern stays caught). See the + # shower.eblu.me server block for the matching `limit_req`. + limit_req_zone $http_fly_client_ip zone=shower_general:10m rate=50r/s; + # fail2ban deny list — banned IPs are written here by fail2ban and # checked via the $forge_banned variable. The file is touched at # container start to ensure it exists. @@ -184,6 +193,23 @@ http { return 200 "User-agent: *\nDisallow: /mirrors/\nDisallow: /user/\nDisallow: /users/\nDisallow: /*/archive/\nDisallow: /*/releases/download/\n"; } + # Block the package registry at the public edge. Forgejo's per-user + # visibility model treats packages as world-readable when the owner + # has Visibility=Public — which means anyone on the internet can + # enumerate and download every wheel/sdist/generic artifact, even + # for private-repo releases (the sdist contains full source). We + # like keeping eblume's profile public, so we close the hole here + # at the proxy instead: WAN sees 403, tailnet (forge.ops.eblu.me) + # stays open for legitimate consumers (CI workflows, gilbert). + # See docs/tutorials/expose-service-publicly.md for the broader + # threat model on this proxy. + location /api/packages/ { + return 403 "Package downloads are tailnet-only — use forge.ops.eblu.me.\n"; + } + location /api/v1/packages { + return 403 "Package enumeration is tailnet-only — use forge.ops.eblu.me.\n"; + } + # Block swagger API docs — use forge.ops.eblu.me from tailnet location /swagger { return 403 "API documentation is only available at forge.ops.eblu.me (tailnet).\n"; @@ -288,6 +314,140 @@ http { } } + # --- shower.eblu.me (Adelaide baby shower — guest-only public surface) --- + # Only the guest paths (`/`, `/prizes//`, /static/, /media/) are + # exposed on WAN. /host/, /admin/, and Django's login views are blocked + # at the edge with a 403 pointing at the tailnet hostname — staff sign + # in on shower.ops.eblu.me, which is reachable from any device with + # Tailscale installed. Defense layers reduce to: general per-IP rate + # limit + django-axes (5 fails / 1h) on the tailnet-side login. No + # fail2ban needed here because the public surface no longer takes + # credentials of any kind. + server { + listen 8080; + server_name shower.eblu.me; + + # Per-IP rate limit. shower_general (50r/s, burst=200) instead of + # the global `general` zone because at the party, guests on the + # venue's wifi all NAT through a single Fly-Client-IP — 30 guests + # scanning the QR at once would each fetch HTML + a few static + # assets, easily clearing 20 burst on `general`. Exploit scanners + # still trip it (sustained ≫ 50r/s patterns). + limit_req zone=shower_general burst=200 nodelay; + + # Image uploads from /host/'s prize cropper are ~150-300 KiB JPEGs. + # The host page itself isn't reachable here, but /media/ reads can + # be larger than 1 MiB so set the cap to 5 MiB to match Django. + client_max_body_size 5m; + + # Security headers — HSTS matches Django's SECURE_HSTS_SECONDS. + add_header X-Frame-Options "DENY" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header Referrer-Policy "same-origin" always; + # GNU Terry Pratchett — keep the name moving. + add_header X-Clacks-Overhead "GNU Terry Pratchett" always; + + error_page 502 503 504 /error.html; + location = /error.html { + root /usr/share/nginx/html; + internal; + } + + # Reject indexers — there's nothing here we want crawled. + location = /robots.txt { + default_type text/plain; + return 200 "User-agent: *\nDisallow: /\n"; + } + + # Admin surface: tailnet-only. Anything under /admin/ — login, + # logout, CRUD UI, password reset — returns 403 with a pointer to + # the tailnet host. Django's `staff_member_required` will redirect + # /host/ to /admin/login/, which lands on this 403 if a guest + # device wanders into it. Staff hit the tailnet host directly. + location /admin/ { + return 403 "Authentication is tailnet-only — visit shower.ops.eblu.me.\n"; + } + + # Operator console: tailnet-only. Same rationale as /admin/. + location /host/ { + return 403 "The host console is tailnet-only — visit shower.ops.eblu.me.\n"; + } + + # Static assets — WhiteNoise + CompressedManifestStaticFilesStorage + # gives content-hashed filenames, so cache aggressively. Hashed + # names make cache invalidation automatic on app upgrades. + location /static/ { + proxy_pass https://indri_backend$request_uri; + proxy_ssl_verify off; + proxy_ssl_server_name on; + proxy_ssl_name shower.ops.eblu.me; + + proxy_http_version 1.1; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host shower.ops.eblu.me; + proxy_set_header X-Real-IP $http_fly_client_ip; + proxy_set_header X-Forwarded-For $http_fly_client_ip; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_cache services; + proxy_cache_valid 200 1y; + proxy_cache_valid 404 1m; + proxy_cache_use_stale error timeout updating; + proxy_cache_lock on; + proxy_cache_key $host$uri; + proxy_ignore_headers Cache-Control Set-Cookie; + + add_header X-Cache-Status $upstream_cache_status; + } + + # Prize photo uploads. Shorter TTL than /static/ because filenames + # aren't content-hashed — operators can re-upload a prize photo + # and we want guests to see the new image within a day. + location /media/ { + proxy_pass https://indri_backend$request_uri; + proxy_ssl_verify off; + proxy_ssl_server_name on; + proxy_ssl_name shower.ops.eblu.me; + + proxy_http_version 1.1; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host shower.ops.eblu.me; + proxy_set_header X-Real-IP $http_fly_client_ip; + proxy_set_header X-Forwarded-For $http_fly_client_ip; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_cache services; + proxy_cache_valid 200 1d; + proxy_cache_valid 404 1m; + proxy_cache_use_stale error timeout updating; + proxy_cache_lock on; + proxy_cache_key $host$uri; + proxy_ignore_headers Cache-Control Set-Cookie; + + add_header X-Cache-Status $upstream_cache_status; + } + + location / { + proxy_pass https://indri_backend$request_uri; + proxy_ssl_verify off; + proxy_ssl_server_name on; + proxy_ssl_name shower.ops.eblu.me; + proxy_intercept_errors on; + + # No proxy_cache — dynamic content with sessions and CSRF. + + proxy_set_header Host shower.ops.eblu.me; + proxy_set_header X-Real-IP $http_fly_client_ip; + proxy_set_header X-Forwarded-For $http_fly_client_ip; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + } + } + # Catch-all: reject unknown hosts, but serve health check server { listen 8080 default_server; diff --git a/fly/start.sh b/fly/start.sh index 1f2acaa..a924849 100644 --- a/fly/start.sh +++ b/fly/start.sh @@ -20,6 +20,7 @@ done echo "MagicDNS ready" # Ensure fail2ban deny file exists before nginx starts +# (the geo directive's `include` fails if the file is missing). touch /etc/nginx/forge-deny.conf # Start nginx — MagicDNS is available, upstreams resolved. diff --git a/mise-tasks/fly-setup b/mise-tasks/fly-setup index 0c5cb56..be797e5 100755 --- a/mise-tasks/fly-setup +++ b/mise-tasks/fly-setup @@ -23,6 +23,7 @@ echo "IPs allocated" fly certs add docs.eblu.me -a "$APP" 2>/dev/null || true fly certs add cv.eblu.me -a "$APP" 2>/dev/null || true fly certs add forge.eblu.me -a "$APP" 2>/dev/null || true +fly certs add shower.eblu.me -a "$APP" 2>/dev/null || true echo "Certificates configured" echo "Done. Run 'mise run fly-deploy' to deploy." diff --git a/pulumi/gandi/__main__.py b/pulumi/gandi/__main__.py index bda7a8a..25fd0f7 100644 --- a/pulumi/gandi/__main__.py +++ b/pulumi/gandi/__main__.py @@ -85,6 +85,15 @@ forge_public = gandi.livedns.Record( values=["blumeops-proxy.fly.dev."], ) +shower_public = gandi.livedns.Record( + "shower-public", + zone=domain, + name="shower", + type="CNAME", + ttl=300, + values=["blumeops-proxy.fly.dev."], +) + # ============== Exports ============== pulumi.export("domain", domain) pulumi.export("wildcard_fqdn", f"*.{subdomain}.{domain}") @@ -93,3 +102,4 @@ pulumi.export("target_ip", tailscale_ip) pulumi.export("docs_public_fqdn", f"docs.{domain}") pulumi.export("cv_public_fqdn", f"cv.{domain}") pulumi.export("forge_public_fqdn", f"forge.{domain}") +pulumi.export("shower_public_fqdn", f"shower.{domain}") diff --git a/service-versions.yaml b/service-versions.yaml index f7f0f4e..74d467e 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -44,6 +44,16 @@ services: upstream-source: https://github.com/gethomepage/homepage/releases notes: Custom container, kustomize manifests + - name: shower + type: argocd + last-reviewed: 2026-05-10 + current-version: "1.0.2" + upstream-source: https://forge.eblu.me/eblume/adelaide-baby-shower-app + notes: | + Django app for Adelaide / Heidi / Addie's baby shower. Wheel + published to Forgejo Packages PyPI; runs on ringtail k3s. Public + at shower.eblu.me (fly proxy), tailnet admin at shower.ops.eblu.me. + - name: nvidia-device-plugin type: argocd last-reviewed: 2026-03-27 @@ -96,6 +106,15 @@ services: current-version: "v1.94.2" upstream-source: https://github.com/tailscale/tailscale/releases + - name: tailscale + type: container + last-reviewed: 2026-05-10 + current-version: "1.94.2" + upstream-source: https://github.com/tailscale/tailscale/releases + notes: | + Locally mirrored tailscale image used by ringtail's tailscale-operator + ProxyClass. Built via containers/tailscale/default.nix. + - name: grafana type: argocd last-reviewed: 2026-04-02 From 40d9a1ef9e1e1f18128877b671025ea5b1d89e04 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 11 May 2026 13:55:25 -0700 Subject: [PATCH 369/430] =?UTF-8?q?C0:=20shower=20=E2=80=94=20rebuild=20fr?= =?UTF-8?q?om=20main=20SHA=20(post-PR-349=20retag)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standard squash-merge dance per docs/how-to/deployment/build-container-image.md#Squash-merge-and-container-tags — retags from v1.0.2-039d9b9-nix (branch SHA) to v1.0.2-292d354-nix ([main] tag from run 544 built off the merge commit). Functionally identical; preserves source traceability. Co-Authored-By: Claude Opus 4.7 (1M context) --- argocd/manifests/shower/kustomization.yaml | 2 +- docs/changelog.d/+shower-main-sha-rebuild.infra.md | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 docs/changelog.d/+shower-main-sha-rebuild.infra.md diff --git a/argocd/manifests/shower/kustomization.yaml b/argocd/manifests/shower/kustomization.yaml index 0afc8e3..d2ce83c 100644 --- a/argocd/manifests/shower/kustomization.yaml +++ b/argocd/manifests/shower/kustomization.yaml @@ -14,4 +14,4 @@ resources: images: - name: registry.ops.eblu.me/blumeops/shower - newTag: v1.0.2-039d9b9-nix + newTag: v1.0.2-292d354-nix diff --git a/docs/changelog.d/+shower-main-sha-rebuild.infra.md b/docs/changelog.d/+shower-main-sha-rebuild.infra.md new file mode 100644 index 0000000..f1751b5 --- /dev/null +++ b/docs/changelog.d/+shower-main-sha-rebuild.infra.md @@ -0,0 +1,5 @@ +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]]. From f83be3bf370105b6ad896353b117da34b78285c1 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 11 May 2026 16:10:39 -0700 Subject: [PATCH 370/430] C1: review CC observability-stack-audit (extend to k3s) (#353) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Recurring compensating-control review (oldest stale control: 42 days). - Verified the control is in effect on both clusters: - `alloy-k8s` on minikube-indri — Synced/Healthy, DaemonSet 1/1 ready - `alloy-ringtail` on k3s-ringtail — Synced/Healthy - `loki` (`monitoring/loki-0`) — Running, receiving logs (52 restarts in 18h is worth watching but not blocking review) - Generalized the description: previously named only minikube, but the indri→ringtail migration means we now operate two clusters and both rely on this control. - Added a follow-up note: enabling native apiserver audit logging is far more tractable on k3s (`--audit-log-path` / `--audit-policy-file`) than it was on minikube — worth revisiting once the migration concludes. ## Test plan - [x] `prek` hooks pass - [x] Verified alloy + loki status via `kubectl --context=minikube-indri` and `argocd app get` ## Notes - No deployment changes. Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/353 --- compensating-controls.yaml | 12 ++++++++---- ...-cc-observability-stack-audit-2026-05-11.infra.md | 1 + 2 files changed, 9 insertions(+), 4 deletions(-) create mode 100644 docs/changelog.d/review-cc-observability-stack-audit-2026-05-11.infra.md diff --git a/compensating-controls.yaml b/compensating-controls.yaml index 658c99d..01b3cfd 100644 --- a/compensating-controls.yaml +++ b/compensating-controls.yaml @@ -196,11 +196,15 @@ controls: description: >- Alloy collects pod logs and ships them to Loki, providing an audit trail for cluster activity. Compensates for missing - apiserver audit logging which minikube does not configure. + apiserver audit logging which neither minikube (indri) nor + k3s (ringtail) configures by default. created: 2026-03-30 - last-reviewed: 2026-03-30 + last-reviewed: 2026-05-11 notes: >- - Verify Alloy DaemonSet is running and Loki is receiving logs. + Verify Alloy DaemonSet is running on each cluster (alloy-k8s on + minikube, alloy-ringtail on k3s) and Loki is receiving logs. Note this is weaker than native apiserver audit logs — it captures pod stdout/stderr, not API request-level auditing. - Consider enabling minikube audit logging if supported. + Consider enabling apiserver audit logging on k3s post-migration + (`--audit-log-path` / `--audit-policy-file`) — minikube made it + hard, k3s makes it straightforward. diff --git a/docs/changelog.d/review-cc-observability-stack-audit-2026-05-11.infra.md b/docs/changelog.d/review-cc-observability-stack-audit-2026-05-11.infra.md new file mode 100644 index 0000000..8100c6a --- /dev/null +++ b/docs/changelog.d/review-cc-observability-stack-audit-2026-05-11.infra.md @@ -0,0 +1 @@ +Reviewed compensating control `observability-stack-audit`. Updated description to cover ringtail's k3s as well as indri's minikube; both Alloy DaemonSets and Loki are healthy. From bb7efa850ac8f07ba8ab8f86ddee47ef0726d70e Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 11 May 2026 16:11:35 -0700 Subject: [PATCH 371/430] =?UTF-8?q?C1:=20doc=20review=20=E2=80=94=20replic?= =?UTF-8?q?ating-blumeops=20tutorial=20(#350)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Periodic doc review of `tutorials/replicating-blumeops.md` (was never reviewed). - Fixed 4 instances of "BluemeOps" → "BlumeOps" (also caught 1 in `contributing.md`). - Added `last-reviewed: 2026-05-11` and bumped `modified`. - Verified all wiki-link targets resolve. ## Test plan - [x] `prek` hooks pass (link checker, frontmatter checker) - [ ] Optional: `mise run docs-preview docs/tutorials/replicating-blumeops.md` Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/350 --- .../doc-review-replicating-blumeops.doc.md | 1 + docs/tutorials/contributing.md | 2 +- docs/tutorials/replicating-blumeops.md | 11 ++++++----- 3 files changed, 8 insertions(+), 6 deletions(-) create mode 100644 docs/changelog.d/doc-review-replicating-blumeops.doc.md diff --git a/docs/changelog.d/doc-review-replicating-blumeops.doc.md b/docs/changelog.d/doc-review-replicating-blumeops.doc.md new file mode 100644 index 0000000..e9e6d0f --- /dev/null +++ b/docs/changelog.d/doc-review-replicating-blumeops.doc.md @@ -0,0 +1 @@ +Reviewed `replicating-blumeops` tutorial: fixed "BluemeOps" typos (also in `contributing.md`) and added `last-reviewed` frontmatter. diff --git a/docs/tutorials/contributing.md b/docs/tutorials/contributing.md index a2a7069..0d48e8f 100644 --- a/docs/tutorials/contributing.md +++ b/docs/tutorials/contributing.md @@ -11,7 +11,7 @@ tags: > **Audiences:** Contributor -This tutorial walks through making your first contribution to BluemeOps - from understanding the codebase to submitting a pull request. +This tutorial walks through making your first contribution to BlumeOps - from understanding the codebase to submitting a pull request. ## Prerequisites diff --git a/docs/tutorials/replicating-blumeops.md b/docs/tutorials/replicating-blumeops.md index f2ed8ca..e54ecb2 100644 --- a/docs/tutorials/replicating-blumeops.md +++ b/docs/tutorials/replicating-blumeops.md @@ -1,6 +1,7 @@ --- title: Replicating BlumeOps -modified: 2026-02-07 +modified: 2026-05-11 +last-reviewed: 2026-05-11 tags: - tutorials - replication @@ -10,7 +11,7 @@ tags: > **Audiences:** Replicator -This tutorial provides a roadmap for building your own homelab GitOps environment inspired by BluemeOps. It links to detailed component tutorials for each major piece. +This tutorial provides a roadmap for building your own homelab GitOps environment inspired by BlumeOps. It links to detailed component tutorials for each major piece. ## What You'll Build @@ -23,7 +24,7 @@ By following this guide, you'll have: ## Hardware Requirements -BluemeOps runs on modest hardware. At minimum: +BlumeOps runs on modest hardware. At minimum: | Component | BlumeOps Uses | Minimum Alternative | |-----------|---------------|---------------------| @@ -94,7 +95,7 @@ Without observability, you're flying blind. ### Phase 6: Your First Services -With the foundation in place, deploy actual workloads. BluemeOps runs: +With the foundation in place, deploy actual workloads. BlumeOps runs: - [[miniflux]] - RSS reader - [[jellyfin]] - Media server - [[immich]] - Photo management @@ -118,7 +119,7 @@ Protect your data. ## Alternative Approaches -BluemeOps makes specific choices that may not suit everyone: +BlumeOps makes specific choices that may not suit everyone: | BlumeOps Choice | Alternative | |-----------------|-------------| From 145df76d062b1a9757322cae4eab4199c3e1309c Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 11 May 2026 16:12:36 -0700 Subject: [PATCH 372/430] =?UTF-8?q?C1:=20service=20review=20=E2=80=94=20me?= =?UTF-8?q?alie=20(v3.12.0=20deployed;=20upstream=20v3.17.0)=20(#351)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Recurring service review for `mealie`. - Upstream is at **v3.17.0** (released 2026-05-06); deployed image is **v3.12.0** — 5 minor versions behind. - Container is built locally from the forge mirror (`containers/mealie/Dockerfile`), so upgrade requires a fresh build + changelog review for breaking changes between v3.12 and v3.17. - Deferring the actual upgrade to a separate task; this PR just refreshes `last-reviewed` and captures the gap in `notes`. ## Test plan - [x] `prek` hooks pass - [ ] Follow-up: open task to bump `containers/mealie/Dockerfile` `CONTAINER_APP_VERSION`, build, and update kustomization tag ## Notes - No deployment changes in this PR. Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/351 --- .../changelog.d/service-review-mealie-2026-05-11.infra.md | 1 + service-versions.yaml | 8 ++++++-- 2 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 docs/changelog.d/service-review-mealie-2026-05-11.infra.md diff --git a/docs/changelog.d/service-review-mealie-2026-05-11.infra.md b/docs/changelog.d/service-review-mealie-2026-05-11.infra.md new file mode 100644 index 0000000..074cd21 --- /dev/null +++ b/docs/changelog.d/service-review-mealie-2026-05-11.infra.md @@ -0,0 +1 @@ +Reviewed `mealie` service version freshness; upstream is 5 minor versions ahead (v3.17.0 vs deployed v3.12.0). Marked reviewed; upgrade deferred. diff --git a/service-versions.yaml b/service-versions.yaml index 74d467e..56000df 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -327,10 +327,14 @@ services: - name: mealie type: argocd - last-reviewed: 2026-03-16 + last-reviewed: 2026-05-11 current-version: "v3.12.0" upstream-source: https://github.com/mealie-recipes/mealie/releases - notes: Recipe manager; built from source via forge mirror + notes: >- + Recipe manager; built from source via forge mirror. + Upstream is at v3.17.0 as of 2026-05-11 (5 minor versions ahead). + Container/manifest still pinned to v3.12.0 — upgrade deferred to a + separate task (build new image, review changelog for breaking changes). - name: paperless type: argocd From 4133785119587335f570a406cefc09411699e0d6 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 11 May 2026 16:13:07 -0700 Subject: [PATCH 373/430] =?UTF-8?q?C1:=20ringtail=20=E2=80=94=20weekly=20f?= =?UTF-8?q?lake.lock=20update=20(#352)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Recurring weekly lockfile refresh for `nixos/ringtail/flake.lock`. - Inputs updated: `disko`, `home-manager`, `nixpkgs`. - `nixpkgs-services` was deliberately skipped (per overlay convention — pinned services bump only on intentional update). - Generated via `dagger call flake-update --src=. --flake-path=nixos/ringtail`. ## Test plan - [x] `prek` hooks pass - [ ] After merge: `mise run provision-ringtail` to deploy - [ ] Then check for kernel update per [[manage-lockfile]] ## Notes - Not deployed from this PR — provisioning is a follow-up. Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/352 --- .../review-ringtail-flake-2026-05-11.infra.md | 1 + nixos/ringtail/flake.lock | 18 +++++++++--------- 2 files changed, 10 insertions(+), 9 deletions(-) create mode 100644 docs/changelog.d/review-ringtail-flake-2026-05-11.infra.md diff --git a/docs/changelog.d/review-ringtail-flake-2026-05-11.infra.md b/docs/changelog.d/review-ringtail-flake-2026-05-11.infra.md new file mode 100644 index 0000000..f39f9f4 --- /dev/null +++ b/docs/changelog.d/review-ringtail-flake-2026-05-11.infra.md @@ -0,0 +1 @@ +Updated `nixos/ringtail/flake.lock` (weekly cadence): `disko`, `home-manager`, and `nixpkgs` inputs refreshed. `nixpkgs-services` skipped per overlay convention. diff --git a/nixos/ringtail/flake.lock b/nixos/ringtail/flake.lock index d6a85dc..0f53d0e 100644 --- a/nixos/ringtail/flake.lock +++ b/nixos/ringtail/flake.lock @@ -7,11 +7,11 @@ ] }, "locked": { - "lastModified": 1776613567, - "narHash": "sha256-gC9Cp5ibBmGD5awCA9z7xy6MW6iJufhazTYJOiGlCUI=", + "lastModified": 1777713215, + "narHash": "sha256-8GzXDOXckDWwST8TY5DbwYFjdvQLlP7K9CLSVx6iTTo=", "owner": "nix-community", "repo": "disko", - "rev": "32f4236bfc141ae930b5ba2fb604f561fed5219d", + "rev": "63b4e7e6cf75307c1d26ac3762b886b5b0247267", "type": "github" }, "original": { @@ -27,11 +27,11 @@ ] }, "locked": { - "lastModified": 1775425411, - "narHash": "sha256-KY6HsebJHEe5nHOWP7ur09mb0drGxYSzE3rQxy62rJo=", + "lastModified": 1778401693, + "narHash": "sha256-OVHdCqXXUF5UdGkH+FF2ZL06OLZjj2kvP2dIUmzVWoo=", "owner": "nix-community", "repo": "home-manager", - "rev": "0d02ec1d0a05f88ef9e74b516842900c41f0f2fe", + "rev": "389b83002efc26f1145e89a6a8e6edc5a6435948", "type": "github" }, "original": { @@ -43,11 +43,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1777428379, - "narHash": "sha256-ypxFOeDz+CqADEQNL72haqGjvZQdBR5Vc7pyx2JDttI=", + "lastModified": 1778430510, + "narHash": "sha256-Ti+ZBvW6yrWWAg2szExVTwCd4qOJ3KlVr1tFHfyfi8Q=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "755f5aa91337890c432639c60b6064bb7fe67769", + "rev": "8fd9daa3db09ced9700431c5b7ad0e8ba199b575", "type": "github" }, "original": { From fbc1f7720ee4c907112ab2621d5f1966b4a143ad Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 11 May 2026 18:37:29 -0700 Subject: [PATCH 374/430] C0: gitignore .claude/scheduled_tasks.lock Transient lock file written by the ScheduleWakeup harness tool when Claude paces its own work between long-running operations. Not config, not state worth checking in. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 48c4b97..09e937c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .claude/settings.local.json .claude/agent-memory/ +.claude/scheduled_tasks.lock # Python __pycache__/ From 3c7967e44507137e997fa9edf3c649954ef7807f Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 11 May 2026 20:08:03 -0700 Subject: [PATCH 375/430] C1: deploy shower v1.1.0 (phases + guest memories) (#354) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Deploys `adelaide-baby-shower-app` **v1.1.0** to ringtail k3s. ### App changes (since v1.0.2) - **Four-phase `ShowerState`** replaces the boolean `locked` flag — `pre_event` → `party` → `prizes_locked` → `event_locked` — with a backfill migration that maps `locked=True → pre_event`, `locked=False → party`. - **Guest memories**: append-only photos + comments panel where guests can leave notes for the baby. Adds `GuestPhoto` + `GuestComment` models with file-extension validators and a max-size validator; new `shower.imaging` module for thumbnail generation. - **Admin + QR polish**: configurable host link, fixed "View Site" URL, guest-facing QR copy improvements, contest tweaks. Three Django migrations run automatically in the entrypoint against the SQLite PV: - `0009_shower_phase` - `0010_guest_memories` - `0011_book_description` No ConfigMap / env-var changes. The deploy uses `strategy: Recreate` with a single replica, so the old pod releases the data PVC before the new one mounts it and runs migrations. ### Container build changes The v1.1.0 tag exposed a latent issue with the Forgejo PyPI install path: - The recent commit [2d38418e](https://forge.eblu.me/eblume/blumeops/commit/2d38418e) closed the forge package leak at the Fly edge by blocking `/api/packages/*` publicly. - Forgejo's PyPI simple index returns absolute file URLs hardcoded to its public `ROOT_URL` (`forge.eblu.me`), so pip-installing from the tailnet index URL still tries to download from `forge.eblu.me` → 403. - Previous shower builds escaped this because their FOD outputs were already in the nix store; bumping to a new version forced a fresh pip run that hit the block. Fix mirrors what we already do for the sdist: both wheel and sdist are pulled via direct `fetchurl` against `forge.ops.eblu.me`, then the wheel is copied to TMPDIR under its clean filename (nix store path's hash prefix breaks pip's wheel-filename parser) and handed to pip as a local path. The forge `--extra-index-url` is no longer needed. FOD outputHash pinned to `sha256-kTNOswobtkgyQmmqbQM8XO4vvaGg57nCuuZGbNXb0NM=` from run 547. Image: `registry.ops.eblu.me/blumeops/shower:v1.1.0-444ff91-nix`. ### Adjacent finding (already handled) The ringtail `gitea-runner-nix_container_builder` systemd unit was left `inactive` after the recent `provision-ringtail` (matches the known `sshd-restart-hangs-mux` lesson — the rebuild changed the unit's PATH closure + config.yaml, systemd stopped it, then the playbook hung before the activation could restart it). Manually started; the existing memory `lesson_provision_ringtail_ssh_hang.md` was extended to mention the runner as the canary service to check after provisions. ## Test plan - [ ] `argocd app diff shower --revision shower-v1.1.0` — review the manifest change - [ ] `argocd app set shower --revision shower-v1.1.0 && argocd app sync shower` - [ ] `kubectl --context=k3s-ringtail logs -n shower deploy/shower` — confirm migrations 0009/0010/0011 applied, no errors - [ ] Hit `https://shower.ops.eblu.me/` (tailnet) — splash page renders, phase indicator visible - [ ] Hit `https://shower.ops.eblu.me/host/` — host console loads, phase dropdown shows the four states - [ ] Hit `https://shower.eblu.me/` (public via Fly) — splash page still served - [ ] After merge: `argocd app set shower --revision main && argocd app sync shower` Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/354 --- argocd/manifests/shower/kustomization.yaml | 2 +- containers/shower/default.nix | 39 ++++++++++++++++------ docs/changelog.d/shower-v1.1.0.feature.md | 15 +++++++++ service-versions.yaml | 4 +-- 4 files changed, 47 insertions(+), 13 deletions(-) create mode 100644 docs/changelog.d/shower-v1.1.0.feature.md diff --git a/argocd/manifests/shower/kustomization.yaml b/argocd/manifests/shower/kustomization.yaml index d2ce83c..6fe641f 100644 --- a/argocd/manifests/shower/kustomization.yaml +++ b/argocd/manifests/shower/kustomization.yaml @@ -14,4 +14,4 @@ resources: images: - name: registry.ops.eblu.me/blumeops/shower - newTag: v1.0.2-292d354-nix + newTag: v1.1.0-444ff91-nix diff --git a/containers/shower/default.nix b/containers/shower/default.nix index d9863e1..e2d369d 100644 --- a/containers/shower/default.nix +++ b/containers/shower/default.nix @@ -1,11 +1,15 @@ # Nix-built shower app container — Adelaide / Heidi / Addie baby shower. # # The app is published as a wheel to the Forgejo PyPI index at -# https://forge.eblu.me/api/packages/eblume/pypi/. The wheel + its -# transitive Python deps are baked in at build time via a fixed-output -# derivation that runs `pip install --target` against forge PyPI (proxied -# through pypi.ops.eblu.me for upstream packages). Build runs on the -# nix-container-builder runner (ringtail, amd64) so the image is native. +# https://forge.ops.eblu.me/api/packages/eblume/pypi/ (tailnet-only — the +# public forge.eblu.me /api/packages/* surface is blocked at the Fly edge). +# We can't point pip at Forgejo's simple index even from the tailnet, +# because Forgejo's index returns absolute file URLs hardcoded to its +# public ROOT_URL (forge.eblu.me), which then 403s. So both the wheel and +# the sdist are pulled by direct `fetchurl` against forge.ops.eblu.me, and +# the wheel is then handed to `pip install` as a local path; transitive +# deps come from pypi.ops.eblu.me. Build runs on the nix-container-builder +# runner (ringtail, amd64) so the image is native. # # Going through pip-install-target rather than nixpkgs Python packages # sidesteps two issues we hit going through `python.pkgs.buildPythonPackage`: @@ -21,7 +25,7 @@ { pkgs ? import { } }: let - version = "1.0.2"; + version = "1.1.0"; python = pkgs.python314; @@ -39,7 +43,17 @@ let showerSdist = pkgs.fetchurl { name = "adelaide_baby_shower_app-${version}.tar.gz"; url = "https://forge.ops.eblu.me/api/packages/eblume/pypi/files/adelaide-baby-shower-app/${version}/adelaide_baby_shower_app-${version}.tar.gz"; - hash = "sha256-nlCtlx9zuYaLoJZSckybLV5YPpA8vZamN96O3RXOstM="; + hash = "sha256-5dp+0u4metOIC6s6/nPlT4cdpFBCV6S3+Z/3RO0sX5U="; + }; + + # Wheel pulled from forge.ops.eblu.me (tailnet) for the same reason the + # sdist is: Forgejo's PyPI simple index would return forge.eblu.me URLs + # that the Fly edge 403s on /api/packages/*. We hand this path to pip + # below so it never touches the forge index at all. + showerWheel = pkgs.fetchurl { + name = "adelaide_baby_shower_app-${version}-py3-none-any.whl"; + url = "https://forge.ops.eblu.me/api/packages/eblume/pypi/files/adelaide-baby-shower-app/${version}/adelaide_baby_shower_app-${version}-py3-none-any.whl"; + hash = "sha256-7orFbycON9dQxEIb6q45Xx2rFlEZ8xXSrC2tnrO5uug="; }; staticAssets = pkgs.runCommand "shower-static-assets-${version}" { } '' @@ -68,11 +82,16 @@ let ${python}/bin/python -m venv "$TMPDIR/venv" "$TMPDIR/venv/bin/pip" install --upgrade pip + + # Nix store paths embed a 32-char hash prefix, which pip's wheel + # filename parser rejects ("Invalid wheel filename"). Copy to a + # clean filename in TMPDIR before installing. + cp ${showerWheel} "$TMPDIR/${showerWheel.name}" + "$TMPDIR/venv/bin/pip" install \ --no-cache-dir \ --index-url=https://pypi.ops.eblu.me/root/pypi/+simple/ \ - --extra-index-url=https://forge.ops.eblu.me/api/packages/eblume/pypi/simple/ \ - "adelaide-baby-shower-app==${version}" \ + "$TMPDIR/${showerWheel.name}" \ gunicorn runHook postBuild @@ -129,7 +148,7 @@ let outputHashAlgo = "sha256"; # Pinned dep closure — reproducible until version bumps. To recompute, # set to pkgs.lib.fakeHash and read the failure. - outputHash = "sha256-tSTH/HaDY7M0qxlauBTM+JekZAgF++K2lGP3PLvym/o="; + outputHash = "sha256-kTNOswobtkgyQmmqbQM8XO4vvaGg57nCuuZGbNXb0NM="; dontFixup = true; }; diff --git a/docs/changelog.d/shower-v1.1.0.feature.md b/docs/changelog.d/shower-v1.1.0.feature.md new file mode 100644 index 0000000..d2c3400 --- /dev/null +++ b/docs/changelog.d/shower-v1.1.0.feature.md @@ -0,0 +1,15 @@ +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. diff --git a/service-versions.yaml b/service-versions.yaml index 56000df..63bc5df 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -46,8 +46,8 @@ services: - name: shower type: argocd - last-reviewed: 2026-05-10 - current-version: "1.0.2" + last-reviewed: 2026-05-11 + current-version: "1.1.0" upstream-source: https://forge.eblu.me/eblume/adelaide-baby-shower-app notes: | Django app for Adelaide / Heidi / Addie's baby shower. Wheel From dc0916a548db2017fb271cb42f3f3233b5bae279 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 11 May 2026 20:20:39 -0700 Subject: [PATCH 376/430] =?UTF-8?q?C0:=20shower=20=E2=80=94=20rebuild=20fr?= =?UTF-8?q?om=20main=20SHA=20(post-merge=20retag)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #354 was squash-merged so the branch commit 444ff91 baked into the prior image tag isn't reachable from main's history. Rebuild from main HEAD (3c7967e) and retag. Image content is byte-identical (FOD is content-addressed, inputs unchanged); only the SHA in the tag changes so future provenance tracing stays on main. Co-Authored-By: Claude Opus 4.7 (1M context) --- argocd/manifests/shower/kustomization.yaml | 2 +- docs/changelog.d/+shower-rebuild-from-main-sha.misc.md | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 docs/changelog.d/+shower-rebuild-from-main-sha.misc.md diff --git a/argocd/manifests/shower/kustomization.yaml b/argocd/manifests/shower/kustomization.yaml index 6fe641f..b6de844 100644 --- a/argocd/manifests/shower/kustomization.yaml +++ b/argocd/manifests/shower/kustomization.yaml @@ -14,4 +14,4 @@ resources: images: - name: registry.ops.eblu.me/blumeops/shower - newTag: v1.1.0-444ff91-nix + newTag: v1.1.0-3c7967e-nix diff --git a/docs/changelog.d/+shower-rebuild-from-main-sha.misc.md b/docs/changelog.d/+shower-rebuild-from-main-sha.misc.md new file mode 100644 index 0000000..a9495cd --- /dev/null +++ b/docs/changelog.d/+shower-rebuild-from-main-sha.misc.md @@ -0,0 +1,6 @@ +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. From d0b54231351d70f2bc1c87c206bab2dddc2708e0 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Tue, 12 May 2026 09:33:57 -0700 Subject: [PATCH 377/430] C1: pin ringtail wired IP to 192.168.1.21 (static) Removes DHCP lease renewal as a failure mode on ringtail after an outage on 2026-05-12 where the IP and routes silently disappeared from enp5s0 without any kernel link event. NetworkManager stays enabled for wireless fallback but no longer manages the wired interface. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/changelog.d/ringtail-static-ip.infra.md | 1 + docs/reference/infrastructure/ringtail.md | 13 +++++++++++++ nixos/ringtail/configuration.nix | 14 +++++++++++++- 3 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 docs/changelog.d/ringtail-static-ip.infra.md diff --git a/docs/changelog.d/ringtail-static-ip.infra.md b/docs/changelog.d/ringtail-static-ip.infra.md new file mode 100644 index 0000000..5137f48 --- /dev/null +++ b/docs/changelog.d/ringtail-static-ip.infra.md @@ -0,0 +1 @@ +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. diff --git a/docs/reference/infrastructure/ringtail.md b/docs/reference/infrastructure/ringtail.md index 8b93d4d..a4e6837 100644 --- a/docs/reference/infrastructure/ringtail.md +++ b/docs/reference/infrastructure/ringtail.md @@ -25,6 +25,19 @@ Service host and gaming PC. Custom-built PC running NixOS. | **OS** | NixOS 25.11 (Sway/Wayland) | | **Tailscale hostname** | `ringtail.tail8d86e.ts.net` | +## Networking + +| Property | Value | +|----------|-------| +| **Interface (wired)** | `enp5s0` | +| **IP** | `192.168.1.21/24` (static, set by NixOS scripted networking) | +| **Gateway** | `192.168.1.1` (UX7) | +| **DNS** | `192.168.1.1`, `1.1.1.1` (used as Tailscale's upstream resolvers; `/etc/resolv.conf` is owned by Tailscale's MagicDNS at `100.100.100.100`) | +| **DHCP reservation** | UniFi "Fixed IP" tied to ringtail's MAC; belt-and-suspenders so the UX7 won't lease `192.168.1.21` to anyone else even though ringtail no longer asks for it | +| **Wireless** | `wlp6s0` still managed by NetworkManager as a fallback path | + +NetworkManager is enabled but explicitly excluded from managing `enp5s0` via `networking.networkmanager.unmanaged = [ "interface-name:enp5s0" ]`. The wired address is configured by a deterministic `network-addresses-enp5s0.service` oneshot — no daemon, no lease, no renewal. + ## Software Managed declaratively via `nixos/ringtail/configuration.nix`. Home-manager handles ringtail-specific sway/waybar config; chezmoi manages cross-platform dotfiles. diff --git a/nixos/ringtail/configuration.nix b/nixos/ringtail/configuration.nix index 2cc5280..bd46222 100644 --- a/nixos/ringtail/configuration.nix +++ b/nixos/ringtail/configuration.nix @@ -16,8 +16,20 @@ in systemd.tpm2.enable = false; # Networking + # Wired interface (enp5s0) uses a static IP configured by NixOS scripted + # networking; NetworkManager is left enabled for the wireless fallback only. networking.hostName = "ringtail"; - networking.networkmanager.enable = true; + networking.networkmanager = { + enable = true; + unmanaged = [ "interface-name:enp5s0" ]; + }; + networking.useDHCP = false; + networking.interfaces.enp5s0.ipv4.addresses = [{ + address = "192.168.1.21"; + prefixLength = 24; + }]; + networking.defaultGateway = "192.168.1.1"; + networking.nameservers = [ "192.168.1.1" "1.1.1.1" ]; # Time zone time.timeZone = "America/Los_Angeles"; From a4a30aad448fb0b43f4a2e8d553015d6af379a32 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Tue, 12 May 2026 09:51:16 -0700 Subject: [PATCH 378/430] fix(ringtail): explicitly enable net.ipv4.ip_forward MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the static IP change, k3s/flannel pod networking broke because ip_forward was 0. NixOS doesn't enable IP forwarding by default — it was previously being set implicitly somewhere in the NM-managed / scripted-DHCP path. With static networking we have to set it ourselves. Verified at runtime via sysctl -w before adding here; pod outbound came back immediately and Tailscale VIP services recovered without any pod restarts. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/changelog.d/ringtail-static-ip.infra.md | 2 +- nixos/ringtail/configuration.nix | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/changelog.d/ringtail-static-ip.infra.md b/docs/changelog.d/ringtail-static-ip.infra.md index 5137f48..8474b0a 100644 --- a/docs/changelog.d/ringtail-static-ip.infra.md +++ b/docs/changelog.d/ringtail-static-ip.infra.md @@ -1 +1 @@ -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. +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. diff --git a/nixos/ringtail/configuration.nix b/nixos/ringtail/configuration.nix index bd46222..e8c634a 100644 --- a/nixos/ringtail/configuration.nix +++ b/nixos/ringtail/configuration.nix @@ -31,6 +31,12 @@ in networking.defaultGateway = "192.168.1.1"; networking.nameservers = [ "192.168.1.1" "1.1.1.1" ]; + # K3s pod networking and Tailscale tunnel routing require IP forwarding. + # NixOS leaves this off by default; previously it was being enabled + # implicitly by NM/scripted-DHCP setup, but with static networking we + # have to set it explicitly. + boot.kernel.sysctl."net.ipv4.ip_forward" = 1; + # Time zone time.timeZone = "America/Los_Angeles"; From 947e4310c306c36e1096f98f5431cf910554d823 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 13 May 2026 16:46:17 -0700 Subject: [PATCH 379/430] C2: migrate immich from minikube to ringtail (mikado chain) (#356) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary C2 Mikado chain to move the entire Immich stack (server, ML, valkey, postgres) off `minikube-indri` and onto `k3s-ringtail`. Immich is the largest single tenant on minikube (~1.5 GiB resident) and minikube is currently memory-saturated (97% RAM, swapping). This is the first concrete chain in the broader indri-k8s decommission effort. This PR contains the planning layer only — 7 cards (1 goal + 6 prerequisites). Implementation cycles follow per the Mikado Branch Invariant. ## Goal end-state - Immich `server`, `machine-learning`, `valkey` on ringtail. - ML pod uses ringtail's RTX 4080 (performance win — currently CPU-only). - CNPG `immich-pg` (PG17 + VectorChord) runs on ringtail. - Library still on sifaka NFS — ringtail mounts the same path. - `photos.ops.eblu.me` reroutes through Caddy → ringtail ingress. - Minikube `immich` and `immich-pg` are removed. ## Cards | Card | Depends on | |---|---| | `migrate-immich-to-ringtail` (goal) | all six below | | `cnpg-on-ringtail` | — | | `immich-pg-on-ringtail` | cnpg-on-ringtail | | `immich-pg-data-migration` | immich-pg-on-ringtail | | `sifaka-nfs-from-ringtail` | — | | `immich-app-on-ringtail` | immich-pg-on-ringtail, sifaka-nfs-from-ringtail | | `immich-cutover-and-decommission` | immich-pg-data-migration, immich-app-on-ringtail | ## Key constraints - **No data loss.** Downtime is acceptable; data loss is not. Two surfaces matter: postgres (ML embeddings, face data — slow to re-derive) and the library files (don't move, but NFS access from ringtail must be verified). - **Migration method:** Option A is a CNPG `externalCluster` basebackup → promote. Option B is `pg_dump`/`pg_restore` as a documented fallback. Either way, dry-run against a scratch cluster first. - **Why pg moves too** (not cross-cluster): keeping pg on minikube would block the whole decommission, and Immich is chatty with pg so tailnet round-trips would hurt. ## Test plan - [ ] Plan review — does the dependency graph make sense? - [ ] `mise run docs-mikado migrate-immich-to-ringtail` shows the chain correctly. - [ ] Per-card implementation cycles land separately (commit convention enforced by hook). Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/356 --- argocd/apps/cloudnative-pg-ringtail.yaml | 27 ++++ argocd/apps/databases-ringtail.yaml | 26 ++++ argocd/apps/immich-ringtail.yaml | 31 ++++ argocd/apps/immich.yaml | 30 ---- .../external-secret-immich-borgmatic.yaml | 15 +- .../databases-ringtail/immich-pg.yaml | 53 +++++++ .../databases-ringtail/kustomization.yaml | 9 ++ .../service-immich-pg-tailscale.yaml | 8 +- argocd/manifests/databases/immich-pg.yaml | 69 --------- argocd/manifests/databases/kustomization.yaml | 3 - .../deployment-ml.yaml | 6 + .../deployment-server.yaml | 0 .../deployment-valkey.yaml | 0 .../ingress-tailscale.yaml | 15 +- .../kustomization.yaml | 13 +- argocd/manifests/immich-ringtail/pv-nfs.yaml | 29 ++++ .../pvc-ml-cache.yaml | 0 .../{immich => immich-ringtail}/pvc.yaml | 6 +- .../service-ml.yaml | 0 .../service-valkey.yaml | 0 .../{immich => immich-ringtail}/service.yaml | 0 argocd/manifests/immich/README.md | 115 --------------- argocd/manifests/immich/pv-nfs.yaml | 22 --- .../time-slicing-config.yaml | 2 +- .../migrate-immich-to-ringtail.infra.md | 13 ++ docs/how-to/immich/cnpg-on-ringtail.md | 52 +++++++ docs/how-to/immich/immich-app-on-ringtail.md | 91 ++++++++++++ .../immich/immich-cutover-and-decommission.md | 103 ++++++++++++++ .../how-to/immich/immich-pg-data-migration.md | 79 +++++++++++ docs/how-to/immich/immich-pg-on-ringtail.md | 69 +++++++++ .../immich/migrate-immich-to-ringtail.md | 132 ++++++++++++++++++ .../how-to/immich/sifaka-nfs-from-ringtail.md | 67 +++++++++ 32 files changed, 820 insertions(+), 265 deletions(-) create mode 100644 argocd/apps/cloudnative-pg-ringtail.yaml create mode 100644 argocd/apps/databases-ringtail.yaml create mode 100644 argocd/apps/immich-ringtail.yaml delete mode 100644 argocd/apps/immich.yaml rename argocd/manifests/{databases => databases-ringtail}/external-secret-immich-borgmatic.yaml (65%) create mode 100644 argocd/manifests/databases-ringtail/immich-pg.yaml create mode 100644 argocd/manifests/databases-ringtail/kustomization.yaml rename argocd/manifests/{databases => databases-ringtail}/service-immich-pg-tailscale.yaml (57%) delete mode 100644 argocd/manifests/databases/immich-pg.yaml rename argocd/manifests/{immich => immich-ringtail}/deployment-ml.yaml (83%) rename argocd/manifests/{immich => immich-ringtail}/deployment-server.yaml (100%) rename argocd/manifests/{immich => immich-ringtail}/deployment-valkey.yaml (100%) rename argocd/manifests/{immich => immich-ringtail}/ingress-tailscale.yaml (62%) rename argocd/manifests/{immich => immich-ringtail}/kustomization.yaml (61%) create mode 100644 argocd/manifests/immich-ringtail/pv-nfs.yaml rename argocd/manifests/{immich => immich-ringtail}/pvc-ml-cache.yaml (100%) rename argocd/manifests/{immich => immich-ringtail}/pvc.yaml (54%) rename argocd/manifests/{immich => immich-ringtail}/service-ml.yaml (100%) rename argocd/manifests/{immich => immich-ringtail}/service-valkey.yaml (100%) rename argocd/manifests/{immich => immich-ringtail}/service.yaml (100%) delete mode 100644 argocd/manifests/immich/README.md delete mode 100644 argocd/manifests/immich/pv-nfs.yaml create mode 100644 docs/changelog.d/migrate-immich-to-ringtail.infra.md create mode 100644 docs/how-to/immich/cnpg-on-ringtail.md create mode 100644 docs/how-to/immich/immich-app-on-ringtail.md create mode 100644 docs/how-to/immich/immich-cutover-and-decommission.md create mode 100644 docs/how-to/immich/immich-pg-data-migration.md create mode 100644 docs/how-to/immich/immich-pg-on-ringtail.md create mode 100644 docs/how-to/immich/migrate-immich-to-ringtail.md create mode 100644 docs/how-to/immich/sifaka-nfs-from-ringtail.md diff --git a/argocd/apps/cloudnative-pg-ringtail.yaml b/argocd/apps/cloudnative-pg-ringtail.yaml new file mode 100644 index 0000000..fa7bba0 --- /dev/null +++ b/argocd/apps/cloudnative-pg-ringtail.yaml @@ -0,0 +1,27 @@ +# CloudNativePG Operator for ringtail k3s cluster +# Deploys the operator only; PostgreSQL clusters are created separately +# +# Sibling of cloudnative-pg.yaml (minikube). Same mirror, same release, +# different destination. Both apps will coexist during the immich +# migration; the minikube one is removed at the end of the broader +# indri-k8s decommission. +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: cloudnative-pg-ringtail + namespace: argocd +spec: + project: default + source: + repoURL: ssh://forgejo@forge.ops.eblu.me:2222/mirrors/cloudnative-pg.git + targetRevision: v1.27.1 + path: releases + directory: + include: 'cnpg-1.27.1.yaml' + destination: + server: https://ringtail.tail8d86e.ts.net:6443 + namespace: cnpg-system + syncPolicy: + syncOptions: + - CreateNamespace=true + - ServerSideApply=true # Required for large CRDs that exceed annotation size limit diff --git a/argocd/apps/databases-ringtail.yaml b/argocd/apps/databases-ringtail.yaml new file mode 100644 index 0000000..00de4e3 --- /dev/null +++ b/argocd/apps/databases-ringtail.yaml @@ -0,0 +1,26 @@ +# Databases on ringtail k3s. +# +# Today: only immich-pg (CNPG Cluster) + its borgmatic ExternalSecret. +# More databases may move here as the indri-k8s decommission proceeds. +# +# Prerequisites: +# - cloudnative-pg-ringtail (operator must exist before the Cluster CR) +# - external-secrets-ringtail + 1password-connect-ringtail (for the +# immich-pg-borgmatic ExternalSecret to sync) +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: databases-ringtail + namespace: argocd +spec: + project: default + source: + repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git + targetRevision: main + path: argocd/manifests/databases-ringtail + destination: + server: https://ringtail.tail8d86e.ts.net:6443 + namespace: databases + syncPolicy: + syncOptions: + - CreateNamespace=true diff --git a/argocd/apps/immich-ringtail.yaml b/argocd/apps/immich-ringtail.yaml new file mode 100644 index 0000000..c93cbee --- /dev/null +++ b/argocd/apps/immich-ringtail.yaml @@ -0,0 +1,31 @@ +# Immich on ringtail k3s. +# +# Staging deployment; the minikube `immich` app remains in parallel +# until cutover. See [[immich-cutover-and-decommission]] for the +# routing flip + minikube cleanup. +# +# Prerequisites: +# - cnpg-on-ringtail + databases-ringtail (postgres) +# - 1password-connect-ringtail + external-secrets-ringtail (not used +# by this app today — immich-db Secret is created manually, +# matching the minikube pattern) +# - The immich-db Secret in the immich namespace, holding the +# password for the `immich` postgres role (copied from the source +# immich-pg-app Secret at migration time). +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: immich-ringtail + namespace: argocd +spec: + project: default + source: + repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git + targetRevision: main + path: argocd/manifests/immich-ringtail + destination: + server: https://ringtail.tail8d86e.ts.net:6443 + namespace: immich + syncPolicy: + syncOptions: + - CreateNamespace=true diff --git a/argocd/apps/immich.yaml b/argocd/apps/immich.yaml deleted file mode 100644 index 7efd263..0000000 --- a/argocd/apps/immich.yaml +++ /dev/null @@ -1,30 +0,0 @@ -# Immich - Self-hosted photo and video management -# High-performance Google Photos/iCloud alternative with AI features -# -# Kustomize manifests in argocd/manifests/immich/ -# Components: server, machine-learning, valkey (Redis) -# -# Prerequisites: -# 1. Create immich namespace and secrets: -# kubectl create namespace immich -# kubectl --context=minikube-indri create secret generic immich-db -n immich \ -# --from-literal=password="$(kubectl --context=minikube-indri -n databases get secret immich-pg-app -o jsonpath='{.data.password}' | base64 -d)" -# 2. Create immich-pg database and user (see immich-pg app) -# 3. NFS share on sifaka at /volume1/photos with read/write for indri -apiVersion: argoproj.io/v1alpha1 -kind: Application -metadata: - name: immich - namespace: argocd -spec: - project: default - source: - repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git - targetRevision: main - path: argocd/manifests/immich - destination: - server: https://kubernetes.default.svc - namespace: immich - syncPolicy: - syncOptions: - - CreateNamespace=true diff --git a/argocd/manifests/databases/external-secret-immich-borgmatic.yaml b/argocd/manifests/databases-ringtail/external-secret-immich-borgmatic.yaml similarity index 65% rename from argocd/manifests/databases/external-secret-immich-borgmatic.yaml rename to argocd/manifests/databases-ringtail/external-secret-immich-borgmatic.yaml index 8801c1a..3d1fc14 100644 --- a/argocd/manifests/databases/external-secret-immich-borgmatic.yaml +++ b/argocd/manifests/databases-ringtail/external-secret-immich-borgmatic.yaml @@ -1,9 +1,12 @@ # ExternalSecret for borgmatic backup user password on immich-pg cluster +# (ringtail k3s). +# +# Mirror of argocd/manifests/databases/external-secret-immich-borgmatic.yaml. +# The onepassword-blumeops ClusterSecretStore exists on ringtail via the +# external-secrets-ringtail app. # -# Reuses the same 1Password item as blumeops-pg-borgmatic. # 1Password item: "borgmatic" in blumeops vault # Field: "db-password" -# apiVersion: external-secrets.io/v1 kind: ExternalSecret metadata: @@ -23,7 +26,7 @@ spec: username: borgmatic password: "{{ .password }}" data: - - secretKey: password - remoteRef: - key: borgmatic - property: db-password + - secretKey: password + remoteRef: + key: borgmatic + property: db-password diff --git a/argocd/manifests/databases-ringtail/immich-pg.yaml b/argocd/manifests/databases-ringtail/immich-pg.yaml new file mode 100644 index 0000000..982bc43 --- /dev/null +++ b/argocd/manifests/databases-ringtail/immich-pg.yaml @@ -0,0 +1,53 @@ +# PostgreSQL Cluster for Immich on ringtail k3s. +# +# Initially bootstrapped via CNPG pg_basebackup from the minikube +# immich-pg cluster on 2026-05-13, then promoted to primary. The +# externalClusters + bootstrap.pg_basebackup blocks have been pruned +# from this manifest now that the migration is complete — leaving +# them around is a footgun (re-enabling replica.enabled=true would +# try to demote this cluster against a stale source). See +# [[immich-pg-data-migration]] for the procedure used. +apiVersion: postgresql.cnpg.io/v1 +kind: Cluster +metadata: + name: immich-pg + namespace: databases +spec: + instances: 1 + imageName: ghcr.io/tensorchord/cloudnative-vectorchord:17-0.5.0 + + storage: + size: 10Gi + storageClass: local-path + + # Managed roles + managed: + roles: + - name: borgmatic + login: true + connectionLimit: -1 + ensure: present + inherit: true + inRoles: + - pg_read_all_data + passwordSecret: + name: immich-pg-borgmatic + + resources: + requests: + memory: "256Mi" + cpu: "100m" + limits: + memory: "1Gi" + cpu: "500m" + + postgresql: + shared_preload_libraries: + - "vchord.so" + parameters: + max_connections: "50" + shared_buffers: "128MB" + password_encryption: "scram-sha-256" + pg_hba: + - host all all 0.0.0.0/0 scram-sha-256 + - host all all ::/0 scram-sha-256 diff --git a/argocd/manifests/databases-ringtail/kustomization.yaml b/argocd/manifests/databases-ringtail/kustomization.yaml new file mode 100644 index 0000000..971e2d4 --- /dev/null +++ b/argocd/manifests/databases-ringtail/kustomization.yaml @@ -0,0 +1,9 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: databases + +resources: + - immich-pg.yaml + - external-secret-immich-borgmatic.yaml + - service-immich-pg-tailscale.yaml diff --git a/argocd/manifests/databases/service-immich-pg-tailscale.yaml b/argocd/manifests/databases-ringtail/service-immich-pg-tailscale.yaml similarity index 57% rename from argocd/manifests/databases/service-immich-pg-tailscale.yaml rename to argocd/manifests/databases-ringtail/service-immich-pg-tailscale.yaml index 78891dd..92deb14 100644 --- a/argocd/manifests/databases/service-immich-pg-tailscale.yaml +++ b/argocd/manifests/databases-ringtail/service-immich-pg-tailscale.yaml @@ -1,6 +1,8 @@ -# Tailscale LoadBalancer for immich-pg PostgreSQL access -# Canonical hostname: immich-pg.tail8d86e.ts.net -# Caddy L4 proxies pg.ops.eblu.me:5433 → this service for borgmatic backups +# Tailscale LoadBalancer for immich-pg PostgreSQL access on ringtail. +# Canonical hostname: immich-pg.tail8d86e.ts.net (claimed from the +# minikube side after the minikube service was removed during the +# immich-to-ringtail migration). Borgmatic on indri uses this +# hostname for nightly backups. apiVersion: v1 kind: Service metadata: diff --git a/argocd/manifests/databases/immich-pg.yaml b/argocd/manifests/databases/immich-pg.yaml deleted file mode 100644 index 74c6f4e..0000000 --- a/argocd/manifests/databases/immich-pg.yaml +++ /dev/null @@ -1,69 +0,0 @@ -# PostgreSQL Cluster for Immich -# Uses VectorChord (successor to pgvecto.rs) for AI-powered vector search -# See: https://github.com/immich-app/immich/discussions/9060 -# Managed by CloudNativePG operator -apiVersion: postgresql.cnpg.io/v1 -kind: Cluster -metadata: - name: immich-pg - namespace: databases -spec: - instances: 1 - # VectorChord image for PostgreSQL 17 with VectorChord 0.5.0 - # Immich v2.4.1 requires VectorChord >=0.3 <0.6 - # See: https://github.com/tensorchord/VectorChord - imageName: ghcr.io/tensorchord/cloudnative-vectorchord:17-0.5.0 - - storage: - size: 10Gi - storageClass: standard - - # Bootstrap creates initial database and owner - bootstrap: - initdb: - database: immich - owner: immich - postInitSQL: - # Extensions required by Immich - - CREATE EXTENSION IF NOT EXISTS vector; - - CREATE EXTENSION IF NOT EXISTS vchord CASCADE; - - CREATE EXTENSION IF NOT EXISTS cube CASCADE; - - CREATE EXTENSION IF NOT EXISTS earthdistance CASCADE; - - # Managed roles - # Note: connectionLimit, ensure, inherit are CNPG defaults added to prevent ArgoCD drift - managed: - roles: - # borgmatic read-only user for backups - - name: borgmatic - login: true - connectionLimit: -1 - ensure: present - inherit: true - inRoles: - - pg_read_all_data - passwordSecret: - name: immich-pg-borgmatic - - # Resource limits for minikube environment - resources: - requests: - memory: "256Mi" - cpu: "100m" - limits: - memory: "1Gi" - cpu: "500m" - - # PostgreSQL configuration - postgresql: - # VectorChord requires vchord.so in shared_preload_libraries - shared_preload_libraries: - - "vchord.so" - parameters: - max_connections: "50" - shared_buffers: "128MB" - password_encryption: "scram-sha-256" - pg_hba: - # Allow connections from k8s pods - - host all all 0.0.0.0/0 scram-sha-256 - - host all all ::/0 scram-sha-256 diff --git a/argocd/manifests/databases/kustomization.yaml b/argocd/manifests/databases/kustomization.yaml index b25e09e..692285a 100644 --- a/argocd/manifests/databases/kustomization.yaml +++ b/argocd/manifests/databases/kustomization.yaml @@ -5,13 +5,10 @@ namespace: databases resources: - blumeops-pg.yaml - - immich-pg.yaml - service-tailscale.yaml - - service-immich-pg-tailscale.yaml - service-metrics-tailscale.yaml - external-secret-eblume.yaml - external-secret-borgmatic.yaml - - external-secret-immich-borgmatic.yaml - external-secret-teslamate.yaml - external-secret-authentik.yaml - external-secret-paperless.yaml diff --git a/argocd/manifests/immich/deployment-ml.yaml b/argocd/manifests/immich-ringtail/deployment-ml.yaml similarity index 83% rename from argocd/manifests/immich/deployment-ml.yaml rename to argocd/manifests/immich-ringtail/deployment-ml.yaml index 57c4242..5ea8035 100644 --- a/argocd/manifests/immich/deployment-ml.yaml +++ b/argocd/manifests/immich-ringtail/deployment-ml.yaml @@ -16,11 +16,16 @@ spec: app: immich component: machine-learning spec: + runtimeClassName: nvidia securityContext: seccompProfile: type: RuntimeDefault containers: - name: machine-learning + # ringtail uses the -cuda tag (set in kustomization.yaml) + # to take advantage of the RTX 4080 via the nvidia + # device plugin. Time-slicing is configured for 4 replicas + # so frigate + ollama + this pod can share. image: ghcr.io/immich-app/immich-machine-learning:kustomized ports: - name: http @@ -57,6 +62,7 @@ spec: cpu: "100m" limits: memory: "4Gi" + nvidia.com/gpu: "1" volumes: - name: cache persistentVolumeClaim: diff --git a/argocd/manifests/immich/deployment-server.yaml b/argocd/manifests/immich-ringtail/deployment-server.yaml similarity index 100% rename from argocd/manifests/immich/deployment-server.yaml rename to argocd/manifests/immich-ringtail/deployment-server.yaml diff --git a/argocd/manifests/immich/deployment-valkey.yaml b/argocd/manifests/immich-ringtail/deployment-valkey.yaml similarity index 100% rename from argocd/manifests/immich/deployment-valkey.yaml rename to argocd/manifests/immich-ringtail/deployment-valkey.yaml diff --git a/argocd/manifests/immich/ingress-tailscale.yaml b/argocd/manifests/immich-ringtail/ingress-tailscale.yaml similarity index 62% rename from argocd/manifests/immich/ingress-tailscale.yaml rename to argocd/manifests/immich-ringtail/ingress-tailscale.yaml index 59a4c05..f0b5fe1 100644 --- a/argocd/manifests/immich/ingress-tailscale.yaml +++ b/argocd/manifests/immich-ringtail/ingress-tailscale.yaml @@ -1,6 +1,9 @@ -# Tailscale Ingress for Immich -# Exposes Immich at photos.tail8d86e.ts.net -# Caddy will proxy photos.ops.eblu.me to this endpoint +# Tailscale ProxyGroup Ingress for Immich on ringtail. +# +# Production hostname: photos.tail8d86e.ts.net +# (during the cutover window this was photos-ringtail; the minikube +# ingress was torn down before this was renamed to photos to avoid +# the Tailscale device-name collision.) apiVersion: networking.k8s.io/v1 kind: Ingress metadata: @@ -16,12 +19,6 @@ metadata: gethomepage.dev/description: "Photo management" gethomepage.dev/href: "https://photos.ops.eblu.me" gethomepage.dev/pod-selector: "app=immich,component=server" - # TODO: Add Immich widget - requires API key from Account Settings > API Keys - # See: https://gethomepage.dev/widgets/services/immich/ - # gethomepage.dev/widget.type: "immich" - # gethomepage.dev/widget.url: "https://photos.ops.eblu.me" - # gethomepage.dev/widget.key: "{{HOMEPAGE_VAR_IMMICH_API_KEY}}" - # gethomepage.dev/widget.version: "2" spec: ingressClassName: tailscale rules: diff --git a/argocd/manifests/immich/kustomization.yaml b/argocd/manifests/immich-ringtail/kustomization.yaml similarity index 61% rename from argocd/manifests/immich/kustomization.yaml rename to argocd/manifests/immich-ringtail/kustomization.yaml index 5f8d02b..c1f639e 100644 --- a/argocd/manifests/immich/kustomization.yaml +++ b/argocd/manifests/immich-ringtail/kustomization.yaml @@ -1,7 +1,8 @@ ---- apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization + namespace: immich + resources: - deployment-server.yaml - deployment-ml.yaml @@ -13,11 +14,15 @@ resources: - pv-nfs.yaml - pvc.yaml - ingress-tailscale.yaml + images: - name: ghcr.io/immich-app/immich-server newTag: v2.6.3 - name: ghcr.io/immich-app/immich-machine-learning - newTag: v2.6.3 + # CUDA variant of the same release — ringtail has an RTX 4080 + newTag: v2.6.3-cuda + # Using upstream multi-arch valkey image directly; the + # registry.ops.eblu.me/blumeops/valkey mirror is arm64-only (built + # on indri) and would crashloop on ringtail. - name: docker.io/valkey/valkey - newName: registry.ops.eblu.me/blumeops/valkey - newTag: v8.1.6-r0-fabca04 + newTag: "8.1.6" diff --git a/argocd/manifests/immich-ringtail/pv-nfs.yaml b/argocd/manifests/immich-ringtail/pv-nfs.yaml new file mode 100644 index 0000000..3d5a682 --- /dev/null +++ b/argocd/manifests/immich-ringtail/pv-nfs.yaml @@ -0,0 +1,29 @@ +# NFS PersistentVolume for Immich photo library on ringtail k3s. +# +# Mirror of argocd/manifests/immich/pv-nfs.yaml (minikube) but with +# a distinct name (minikube and ringtail are separate clusters, so PV +# names don't collide cluster-side, but using the same name in two +# manifests is confusing). +# +# The sifaka NFS export for /volume1/photos already permits +# 192.168.1.0/24 + 100.64.0.0/10. Ringtail's wired IP (192.168.1.21) +# falls in the first CIDR, so no DSM rule changes are needed. +# +# Verified 2026-05-13: ringtail pod can read existing dirs, write +# new files, and delete them. DNS resolves sifaka to 192.168.1.203 +# (LAN), so NFS traffic stays off the tailnet — avoids the known +# sifaka-tailscale-userspace bite. +apiVersion: v1 +kind: PersistentVolume +metadata: + name: immich-library-nfs-pv-ringtail +spec: + capacity: + storage: 2Ti + accessModes: + - ReadWriteMany + persistentVolumeReclaimPolicy: Retain + storageClassName: "" + nfs: + server: sifaka + path: /volume1/photos diff --git a/argocd/manifests/immich/pvc-ml-cache.yaml b/argocd/manifests/immich-ringtail/pvc-ml-cache.yaml similarity index 100% rename from argocd/manifests/immich/pvc-ml-cache.yaml rename to argocd/manifests/immich-ringtail/pvc-ml-cache.yaml diff --git a/argocd/manifests/immich/pvc.yaml b/argocd/manifests/immich-ringtail/pvc.yaml similarity index 54% rename from argocd/manifests/immich/pvc.yaml rename to argocd/manifests/immich-ringtail/pvc.yaml index c764636..5bfc052 100644 --- a/argocd/manifests/immich/pvc.yaml +++ b/argocd/manifests/immich-ringtail/pvc.yaml @@ -1,5 +1,5 @@ -# PersistentVolumeClaim for Immich photo library -# Binds to the NFS PV for sifaka:/volume1/photos +# PersistentVolumeClaim for Immich photo library on ringtail. +# Binds to immich-library-nfs-pv-ringtail (sifaka:/volume1/photos). apiVersion: v1 kind: PersistentVolumeClaim metadata: @@ -9,7 +9,7 @@ spec: accessModes: - ReadWriteMany storageClassName: "" - volumeName: immich-library-nfs-pv + volumeName: immich-library-nfs-pv-ringtail resources: requests: storage: 2Ti diff --git a/argocd/manifests/immich/service-ml.yaml b/argocd/manifests/immich-ringtail/service-ml.yaml similarity index 100% rename from argocd/manifests/immich/service-ml.yaml rename to argocd/manifests/immich-ringtail/service-ml.yaml diff --git a/argocd/manifests/immich/service-valkey.yaml b/argocd/manifests/immich-ringtail/service-valkey.yaml similarity index 100% rename from argocd/manifests/immich/service-valkey.yaml rename to argocd/manifests/immich-ringtail/service-valkey.yaml diff --git a/argocd/manifests/immich/service.yaml b/argocd/manifests/immich-ringtail/service.yaml similarity index 100% rename from argocd/manifests/immich/service.yaml rename to argocd/manifests/immich-ringtail/service.yaml diff --git a/argocd/manifests/immich/README.md b/argocd/manifests/immich/README.md deleted file mode 100644 index a82a856..0000000 --- a/argocd/manifests/immich/README.md +++ /dev/null @@ -1,115 +0,0 @@ -# Immich - -Self-hosted photo and video management solution with AI-powered search and face recognition. - -## Prerequisites - -1. **NFS Share**: Create `/volume1/photos` on sifaka with NFS permissions for indri -2. **PostgreSQL**: The `immich-pg` cluster (with pgvecto.rs) must be healthy -3. **Secrets**: Create the database password secret - -## Deployment Order - -1. Sync `blumeops-pg` (to get CloudNativePG operator if not already running) -2. Wait for `immich-pg` cluster to be healthy -3. Create secrets (see below) -4. Sync `immich` (deploys all resources: storage, services, deployments) -5. Run `mise run provision-indri -- --tags caddy` to update Caddy config - -## Components - -| Component | Deployment | Service | Port | -|-----------|------------|---------|------| -| Server (web/API) | `immich-server` | `immich-server` | 2283 | -| Machine Learning | `immich-machine-learning` | `immich-machine-learning` | 3003 | -| Valkey (Redis) | `immich-valkey` | `immich-valkey` | 6379 | - -## Secret Setup - -The `immich-db` secret contains the database password, which is auto-generated by CloudNativePG -in the `immich-pg-app` secret. To create or regenerate the secret: - -```bash -# Create namespace if needed -kubectl --context=minikube-indri create namespace immich - -# Copy password from CNPG secret to immich namespace -kubectl --context=minikube-indri create secret generic immich-db -n immich \ - --from-literal=password="$(kubectl --context=minikube-indri -n databases get secret immich-pg-app -o jsonpath='{.data.password}' | base64 -d)" -``` - -Note: This secret is not managed by ExternalSecrets since the source of truth is the CNPG-generated secret. - -## Access - -- **URL**: https://photos.ops.eblu.me (after Caddy is updated) -- **Tailscale**: https://photos.tail8d86e.ts.net (direct) - -## First-Time Setup - -1. Navigate to https://photos.ops.eblu.me -2. Create an admin account -3. Configure external library (optional - for importing existing photos) - -## External Library (iCloud Photos) - -To import existing photos from iCloud sync on indri: - -1. In Immich Admin > External Libraries, create a new library -2. Set the import path to the location where iCloud photos sync -3. Configure scan schedule or trigger manual scan - -## Architecture - -``` -┌─────────────────┐ ┌─────────────────┐ -│ immich-server │────▶│ immich-pg │ -│ (web/api) │ │ (PostgreSQL │ -└────────┬────────┘ │ + pgvecto.rs) │ - │ └─────────────────┘ - │ -┌────────▼────────┐ ┌─────────────────┐ -│ immich-ml │ │ valkey │ -│ (ML inference) │ │ (Redis cache) │ -└─────────────────┘ └─────────────────┘ - │ -┌────────▼────────┐ -│ sifaka NFS │ -│ /volume1/photos│ -└─────────────────┘ -``` - -## Version Management - -Image versions are controlled via `kustomization.yaml`: - -```yaml -images: - - name: ghcr.io/immich-app/immich-server - newTag: v2.6.3 - - name: ghcr.io/immich-app/immich-machine-learning - newTag: v2.6.3 - - name: docker.io/valkey/valkey - newTag: "8.1-alpine" -``` - -To upgrade, update `newTag` values and sync via ArgoCD. - -## Troubleshooting - -```bash -# Check pods -kubectl --context=minikube-indri -n immich get pods - -# Check immich-pg cluster -kubectl --context=minikube-indri -n databases get cluster immich-pg - -# View server logs -kubectl --context=minikube-indri -n immich logs -l app=immich,component=server - -# View ML logs -kubectl --context=minikube-indri -n immich logs -l app=immich,component=machine-learning - -# Check PVC binding -kubectl --context=minikube-indri -n immich get pvc -``` diff --git a/argocd/manifests/immich/pv-nfs.yaml b/argocd/manifests/immich/pv-nfs.yaml deleted file mode 100644 index 0bd6ee2..0000000 --- a/argocd/manifests/immich/pv-nfs.yaml +++ /dev/null @@ -1,22 +0,0 @@ -# NFS PersistentVolume for Immich photo library -# Requires: NFS share on sifaka at /volume1/photos with NFS permissions for indri -# -# To create on Synology: -# 1. Control Panel > Shared Folder > Create -# 2. Name: photos, Location: Volume 1 -# 3. Control Panel > File Services > NFS > NFS Rules -# 4. Add rule for "photos" share: Hostname=indri, Privilege=Read/Write, Squash=No mapping -apiVersion: v1 -kind: PersistentVolume -metadata: - name: immich-library-nfs-pv -spec: - capacity: - storage: 2Ti - accessModes: - - ReadWriteMany - persistentVolumeReclaimPolicy: Retain - storageClassName: "" - nfs: - server: sifaka - path: /volume1/photos diff --git a/argocd/manifests/nvidia-device-plugin/time-slicing-config.yaml b/argocd/manifests/nvidia-device-plugin/time-slicing-config.yaml index dee2fd7..100e7a9 100644 --- a/argocd/manifests/nvidia-device-plugin/time-slicing-config.yaml +++ b/argocd/manifests/nvidia-device-plugin/time-slicing-config.yaml @@ -11,4 +11,4 @@ data: timeSlicing: resources: - name: nvidia.com/gpu - replicas: 2 + replicas: 4 diff --git a/docs/changelog.d/migrate-immich-to-ringtail.infra.md b/docs/changelog.d/migrate-immich-to-ringtail.infra.md new file mode 100644 index 0000000..b47742f --- /dev/null +++ b/docs/changelog.d/migrate-immich-to-ringtail.infra.md @@ -0,0 +1,13 @@ +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. diff --git a/docs/how-to/immich/cnpg-on-ringtail.md b/docs/how-to/immich/cnpg-on-ringtail.md new file mode 100644 index 0000000..153e674 --- /dev/null +++ b/docs/how-to/immich/cnpg-on-ringtail.md @@ -0,0 +1,52 @@ +--- +title: CNPG Operator on Ringtail +modified: 2026-05-13 +last-reviewed: 2026-05-13 +tags: + - how-to + - operations + - postgres + - ringtail +--- + +# CNPG Operator on Ringtail + +Bring up the `cloudnative-pg` operator on `k3s-ringtail`. Today the +operator only exists on `minikube-indri` (see +`argocd/apps/cloudnative-pg.yaml`, destination `kubernetes.default.svc`). + +Prerequisite of [[migrate-immich-to-ringtail]]; consumed by +[[immich-pg-on-ringtail]]. + +## What to do + +- Add a sibling `argocd/apps/cloudnative-pg-ringtail.yaml` pointing + at the same mirror (`mirrors/cloudnative-pg`, tag `v1.27.1`), + destination `https://ringtail.tail8d86e.ts.net:6443`, + namespace `cnpg-system`. +- Mirror the `ServerSideApply=true` and `CreateNamespace=true` sync + options (the CRDs exceed the annotation size limit). +- Sync `apps` then `cloudnative-pg-ringtail`. Verify the operator + pod is running on ringtail. + +## Verification + +```fish +kubectl --context=k3s-ringtail -n cnpg-system get pods +kubectl --context=k3s-ringtail get crd clusters.postgresql.cnpg.io +``` + +## Why a separate app + +Each ArgoCD app targets a single cluster via `destination.server`. +We could parameterize with ApplicationSets, but blumeops' convention +is to duplicate the manifest with a `-ringtail` suffix (see +`alloy-ringtail`, `external-secrets-ringtail`, etc.). Keep the +convention. + +## Out of scope + +- Postgres clusters themselves (`immich-pg`, etc.) — those come from + [[immich-pg-on-ringtail]]. +- Removing the minikube cnpg operator. That happens at the very end + of the indri-k8s decommission, not in this chain. diff --git a/docs/how-to/immich/immich-app-on-ringtail.md b/docs/how-to/immich/immich-app-on-ringtail.md new file mode 100644 index 0000000..51b619d --- /dev/null +++ b/docs/how-to/immich/immich-app-on-ringtail.md @@ -0,0 +1,91 @@ +--- +title: Immich App on Ringtail +modified: 2026-05-13 +last-reviewed: 2026-05-13 +tags: + - how-to + - operations + - immich +--- + +# Immich App on Ringtail + +Bring up `immich-server`, `immich-machine-learning`, and +`immich-valkey` on ringtail. This card stands the stack up against +the *new* pg cluster — it does not move user traffic. Cutover lives +in [[immich-cutover-and-decommission]]. + +## What to do + +- New manifest dir `argocd/manifests/immich-ringtail/` (the suffix + matches the `-ringtail` convention used by other apps). Port from + `argocd/manifests/immich/`: + - `deployment-server.yaml` — point `DB_HOSTNAME` at the ringtail + pg service. + - `deployment-ml.yaml` — use `runtimeClassName: nvidia` + a + `resources.limits` for `nvidia.com/gpu: 1`. Use the `-cuda` tag + of the immich-ml image (set in kustomization). Ringtail is + single-node, so no node selector needed. See + `argocd/manifests/frigate/` for the existing GPU pod pattern. + + **GPU contention discovery:** ringtail's `nvidia-device-plugin` + is configured with `timeSlicing.replicas: 2`. Frigate + Ollama + already consume both virtual slices. Adding immich-ml requires + bumping the count to >= 3. Edit + `argocd/manifests/nvidia-device-plugin/configmap.yaml` (or + wherever the device-plugin config lives) and re-sync the + `nvidia-device-plugin` ArgoCD app. The plugin pod restarts and + the new advertised count appears as the node's + `nvidia.com/gpu` allocatable. + - `deployment-valkey.yaml` — straight port, BUT use the upstream + multi-arch `docker.io/valkey/valkey:` image — do NOT + use the `registry.ops.eblu.me/blumeops/valkey` rewrite in the + kustomization. That mirror was built on indri (arm64) and is + single-arch; pulling it on ringtail (amd64) gets `exec format + error` in CrashLoopBackOff. The mirror should eventually carry + a multi-arch tag, at which point the rewrite can return. + - `service*.yaml` — straight port. + - `pvc-ml-cache.yaml` — straight port (empty `local-path` PVC). + - `pv-nfs.yaml` + `pvc.yaml` — already covered by + [[sifaka-nfs-from-ringtail]] (may live in this dir or theirs). + - `ingress-tailscale.yaml` — ProxyGroup ingress, **must not** set + an explicit `host:` (or use `host: *`) per the lesson on + ProxyGroup VIP routing. + **Hostname collision warning:** the minikube ingress claims the + Tailscale device name `photos` (`tls.hosts: [photos]`). Two + devices on the tailnet cannot share that name. While the + ringtail deployment is being staged it must use a *different* + `tls.hosts` value (e.g. `photos-ringtail`) so it can coexist + with the running minikube one. The flip to `photos` happens at + cutover time, *after* the minikube ingress has been removed. + See [[immich-cutover-and-decommission#Cutover sequence]]. + - `kustomization.yaml` — same `images:` block (server, ML, valkey). +- New ArgoCD app `argocd/apps/immich-ringtail.yaml` targeting + ringtail, namespace `immich`. **Manual sync only** until the + cutover. +- Existing `argocd/apps/immich.yaml` (minikube) stays untouched + during this card — both apps exist briefly. + +## Bring it up against a copy of the DB + +Use the throwaway/test path from [[immich-pg-data-migration#Dry run +before real cutover]]: point the ringtail immich at the *test* pg +cluster first, verify the pod boots, the web UI loads (via +`kubectl port-forward`), assets list, ML embeddings query. Then +tear it down. + +## Verification + +- All three pods Ready. +- ML pod has a GPU attached: `nvidia-smi` inside the container shows + the 4080. +- `immich-server` connects to pg and valkey (no `ECONNREFUSED` in + logs). +- A `kubectl port-forward` to the server service shows the Immich + web UI. + +## Out of scope + +- Public/tailnet routing flip. Caddy still points at the minikube + Tailscale ingress until [[immich-cutover-and-decommission]]. +- Removing the minikube immich. Same. diff --git a/docs/how-to/immich/immich-cutover-and-decommission.md b/docs/how-to/immich/immich-cutover-and-decommission.md new file mode 100644 index 0000000..b44fddd --- /dev/null +++ b/docs/how-to/immich/immich-cutover-and-decommission.md @@ -0,0 +1,103 @@ +--- +title: Immich Cutover and Decommission +modified: 2026-05-13 +last-reviewed: 2026-05-13 +tags: + - how-to + - operations + - immich + - migration +--- + +# Immich Cutover and Decommission + +The user-visible flip. By the time this card opens, the ringtail +stack has been proven against a copy of the data. This card does the +real cutover. + +## Pre-cutover checklist + +- [[immich-pg-data-migration]] dry-run succeeded; method is chosen. +- Ringtail immich stack has been brought up against the test pg, + pods healthy, UI loaded ([[immich-app-on-ringtail#Verification]]). +- Borgmatic just ran successfully (a fresh nightly archive is a + belt-and-suspenders fallback, on top of the live source pg). +- User has been told to stop uploading from the iOS app for the + cutover window. + +## Cutover sequence + +1. **Quiesce source.** `kubectl --context=minikube-indri -n immich + scale deploy/immich-server --replicas=0` and same for ML. Leave + valkey + pg running. Confirm no client traffic on the source pg + via `pg_stat_activity`. +2. **Tear down the minikube Tailscale ingress.** The `photos` + Tailscale device name must be freed before ringtail's ingress can + claim it (Tailscale enforces uniqueness across the tailnet). + `kubectl --context=minikube-indri -n immich delete ingress + immich-tailscale` and wait for the corresponding `tailscale`-LB + StatefulSet pod to terminate. Verify the `photos` device is gone: + `tailscale status | grep -i photos` from any tailnet host. +3. **Final sync.** Per chosen method in + [[immich-pg-data-migration]]: + - Option A: promote the ringtail replica. + - Option B: take final `pg_dump`, restore to ringtail + `immich-pg`. +4. **Verify.** Run the row-count and schema-diff checks from + [[immich-pg-data-migration#Verification on the real run]]. +5. **Flip the ringtail ingress to `photos`.** Update + `argocd/manifests/immich-ringtail/ingress-tailscale.yaml`: + `tls.hosts: [photos]` (was `[photos-ringtail]` during staging per + [[immich-app-on-ringtail]]). Commit, `argocd app sync + immich-ringtail`. Wait for the `photos` device to register on the + tailnet again. +6. **Bring up ringtail immich** against the now-promoted pg + (`argocd app sync immich-ringtail`). Wait for Ready. +7. **Flip routing.** Update Caddy on indri + (`ansible/roles/caddy/defaults/main.yml`): `photos.ops.eblu.me` + upstream changes to the ringtail Tailscale ingress hostname + (`photos` — same MagicDNS name, now pointing to the ringtail + proxy). `mise run provision-indri -- --tags caddy`. +8. **Smoke test.** Open `photos.ops.eblu.me` in a browser. Sign in. + Scroll the timeline. Open an album. Trigger an ML search. +9. **Update borgmatic.** If the Tailscale hostname for pg changed, + update `borgmatic.cfg` on indri to point at the ringtail + `immich-pg-tailscale` service. Run a manual backup to verify. + +## After cutover + +- `argocd app set immich --revision ` is no longer relevant; + the minikube `immich` app gets deleted entirely. +- Delete `argocd/apps/immich.yaml`, `argocd/manifests/immich/`, and + the minikube `argocd/manifests/databases/immich-pg.yaml` + + `external-secret-immich-borgmatic.yaml` + + `service-immich-pg-tailscale.yaml`. +- Rename `immich-ringtail` back to `immich` (the `-ringtail` suffix + was scaffolding for the dual-cluster window; once minikube is + empty of immich, the unsuffixed name is clean). +- Confirm the minikube `immich-pg` PVC is no longer used, then + delete it (the PV with `Retain` policy will persist — clean that + up too). + +## Verification (definition of done) + +- `photos.ops.eblu.me` works for a real session, including ML search. +- Source minikube has no `immich` pods, no `immich-pg`, no PVCs. +- Memory pressure on minikube has dropped (≥1.5 GiB reclaimed). Check + `docker stats minikube` on indri. +- Nightly borgmatic run after the cutover completes successfully, + with the immich-pg archive showing the new source. + +## Rollback (within the cutover window) + +If smoke test fails: flip Caddy back, scale ringtail immich to 0, +scale source immich back up. Source pg was never destroyed. File a +plan reset on the relevant prerequisite card and try again next +session. + +## Out of scope + +- Decommissioning all of minikube. This chain just removes immich. + Other tenants migrate in their own chains as part of the broader + indri-k8s decommission. See [[migrate-immich-to-ringtail]] for + context. diff --git a/docs/how-to/immich/immich-pg-data-migration.md b/docs/how-to/immich/immich-pg-data-migration.md new file mode 100644 index 0000000..fb87783 --- /dev/null +++ b/docs/how-to/immich/immich-pg-data-migration.md @@ -0,0 +1,79 @@ +--- +title: Immich Postgres Data Migration +modified: 2026-05-13 +last-reviewed: 2026-05-13 +tags: + - how-to + - operations + - postgres + - immich + - critical +--- + +# Immich Postgres Data Migration + +**This is the data-loss surface of the migration.** Pick a method, +prove it on a throwaway copy first, then run the real cutover. + +## Decision: pick one + +### Option A — CNPG `externalCluster` bootstrap (preferred) + +Stand the ringtail cluster up as a streaming replica of the minikube +cluster via `bootstrap.pg_basebackup.source`. Replica catches up +online; when ready, promote it and point Immich at it. This is +CNPG's documented PG-to-PG migration path and gives near-zero data +loss (the WAL position at promote == the position at app stop). + +Requires: network path from ringtail to minikube's pg over the +tailnet (the existing `immich-pg-tailscale` Service works), and a +superuser secret minikube-side exposed to ringtail's basebackup. + +Pitfall to plan around: the ringtail Cluster CR will need its +`bootstrap` block rewritten *after* promotion (CNPG doesn't +gracefully drop the externalCluster reference). Account for this in +[[immich-pg-on-ringtail]] — it may force a reset of that card. + +### Option B — pg_dump / pg_restore + +Stop immich, `pg_dump -Fc` from minikube, scp to ringtail, restore. +Simpler but full downtime for the whole dump+restore window +(measure on a copy first — VectorChord indexes are slow to rebuild). +Smaller blast radius; no streaming-replication moving parts. + +Use this if Option A hits any blocker. Data loss should still be +zero if the source is stopped first. + +### Option C — leave pg on minikube + +Rejected. See goal card [[migrate-immich-to-ringtail#Why postgres on +ringtail (not cross-cluster)]]. + +## Dry run before real cutover + +Whichever option wins: + +1. Snapshot the minikube `immich-pg` PVC or take a fresh `pg_dump` + into a scratch location. +2. Restore into a *separate* ringtail CNPG cluster (different name, + e.g. `immich-pg-test`) and point a scratch immich-server pod at + it. +3. Verify: pod boots, can list assets, ML embeddings query without + error, face thumbnails render. VectorChord-backed queries should + not error. +4. Tear the scratch cluster down before doing the real one. + +## Verification on the real run + +- Row counts match for `assets`, `albums`, `users`, `face`, + `asset_face`, `smart_search` (the embedding table) — script this. +- `pg_dump --schema-only --no-owner` diff between source and dest + should be empty modulo CNPG-managed roles. +- Immich `/api/server-info/version` and `/api/server-info/statistics` + return sane numbers. + +## Rollback + +If the cutover fails verification: stop the ringtail immich, repoint +ArgoCD `immich.destination` back to minikube, re-sync. Source pg was +never deleted. Document what failed and reset the chain. diff --git a/docs/how-to/immich/immich-pg-on-ringtail.md b/docs/how-to/immich/immich-pg-on-ringtail.md new file mode 100644 index 0000000..10c7072 --- /dev/null +++ b/docs/how-to/immich/immich-pg-on-ringtail.md @@ -0,0 +1,69 @@ +--- +title: Immich Postgres Cluster on Ringtail +modified: 2026-05-13 +last-reviewed: 2026-05-13 +tags: + - how-to + - operations + - postgres + - immich +--- + +# Immich Postgres Cluster on Ringtail + +Stand up a fresh `immich-pg` CNPG Cluster on ringtail, ready to receive +data. **No data import yet** — that's [[immich-pg-data-migration]]. + +## What to do + +- Create `argocd/manifests/databases-ringtail/` (or pick another + namespace name — verify what other ringtail pg clusters will use; + if none yet, `databases` is fine). +- Port these from the minikube side: + - `immich-pg.yaml` — CNPG Cluster CR. Same image + (`ghcr.io/tensorchord/cloudnative-vectorchord:17-0.5.0`), same + extensions, same managed `borgmatic` role. Bump `storage.size` if + the minikube 10 GiB looks tight (check actual usage first). + `storageClass: local-path` on ringtail (default). + - `external-secret-immich-borgmatic.yaml` — same 1Password item, + same field, but referencing the ringtail `ClusterSecretStore` + (`onepassword-blumeops` already exists per the + `external-secrets-ringtail` app). + - Service for in-cluster access (the operator creates `immich-pg-rw` + etc. automatically; verify the app deployment uses those names). + - A Tailscale Service if we want backups to keep working via the + same hostname during the transition — see "Borgmatic" below. +- New ArgoCD app `argocd/apps/databases-ringtail.yaml` pointing at + the new path, destination ringtail. + +## Verification + +- Cluster reaches `Ready`. +- `borgmatic` role exists, `rolcanlogin=t`, and is a member of + `pg_read_all_data` (via `managed.roles[].inRoles`). +- ExternalSecret `immich-pg-borgmatic` syncs from 1Password + (`Ready: True`) and the rendered Secret has `username=borgmatic`. +- The `vchord`, `vector`, `cube`, `earthdistance` extensions show + installed in the `postgres` database (`\dx` from + `psql -U postgres`). They are NOT installed in the `immich` + database at this point — `postInitSQL` in CNPG's `initdb` block + runs against the `postgres` superuser database. The Immich app + itself creates the extensions in its own `immich` database at + startup; do not be alarmed by their absence pre-immich-deploy. + The `vchord.so` library is preloaded via + `shared_preload_libraries` regardless, so `CREATE EXTENSION` at + app startup just registers it in the right database. + +## Borgmatic implications + +`borgmatic.cfg` on indri targets `immich-pg-tailscale` over the +tailnet. During migration both clusters will exist briefly. Decide +upfront: backup the *source* pg until cutover, then flip borgmatic +to the ringtail Tailscale service. Document the flip in +[[immich-cutover-and-decommission]]. + +## Out of scope + +- Importing data. That is [[immich-pg-data-migration]], which may + drive a reset on this card if the migration approach (e.g. CNPG + `externalCluster` bootstrap) requires changes to this Cluster CR. diff --git a/docs/how-to/immich/migrate-immich-to-ringtail.md b/docs/how-to/immich/migrate-immich-to-ringtail.md new file mode 100644 index 0000000..cd23384 --- /dev/null +++ b/docs/how-to/immich/migrate-immich-to-ringtail.md @@ -0,0 +1,132 @@ +--- +title: Migrate Immich to Ringtail +modified: 2026-05-13 +last-reviewed: 2026-05-13 +tags: + - how-to + - operations + - immich + - migration +--- + +# Migrate Immich to Ringtail + +Move the entire Immich stack (server, ML, valkey, postgres) off +`minikube-indri` and onto `k3s-ringtail`. This is the first concrete +chain in the broader indri-k8s decommission: minikube is +memory-saturated (97% RAM, swapping), and Immich is the single +largest tenant (~1.5 GiB resident). + +## End state + +- Immich `server`, `machine-learning`, and `valkey` Deployments run on + ringtail k3s in the `immich` namespace. +- The `immich-machine-learning` pod uses ringtail's RTX 4080 via the + `nvidia-device-plugin` (performance win — currently CPU-only on + minikube). +- A CNPG `immich-pg` Cluster (PostgreSQL 17 + VectorChord) runs in a + `databases` namespace on ringtail, owned by the `cnpg-system` + operator on ringtail. +- The photo library still lives on [[sifaka]] at `/volume1/photos`, + mounted via NFS from ringtail pods (RWX). +- Routing: `photos.ops.eblu.me` (Caddy on indri) proxies to a + Tailscale ProxyGroup ingress on ringtail. No public surface today. +- The ArgoCD `immich` app's `destination.server` points at + `https://ringtail.tail8d86e.ts.net:6443`. The old minikube + manifests are removed. + +## Non-goals + +- Public exposure via Fly. Immich stays tailnet-only. +- Changing the immich version or runtime configuration. This is a + lift-and-shift; bumps come later. +- Backing up to a different target. [[borgmatic]] keeps running on + indri (it pulls via Tailscale and uses sifaka SMB for the library). + +## Critical constraint: no data loss + +Downtime is acceptable (Immich is a single-user system; we can take +it offline for the cutover). **Data loss is not.** Two surfaces matter: + +1. **Postgres** — face data, ML embeddings (vectors), album state, + sharing, etc. Re-derivable in theory; weeks of recompute in + practice. See [[immich-pg-data-migration]]. +2. **Library files** — `/volume1/photos`. Not moving, but the NFS + path must be verified accessible from ringtail before cutover. + See [[sifaka-nfs-from-ringtail]]. + +[[borgmatic]] backs both up to sifaka + BorgBase nightly; restore is +possible but slow. Treat it as a fallback, not a plan. + +## Why postgres on ringtail (not cross-cluster) + +`immich-pg` already has a Tailscale Service we could point ringtail +at, leaving the DB on minikube. We're not doing that because: + +- The whole goal is to retire minikube — keeping pg there blocks it. +- Immich is chatty against pg; tailnet round-trips would hurt. +- CNPG is the same operator on both sides — a Cluster CR on ringtail + is mechanically equivalent. + +## Approach + +This is a C2 Mikado chain. The prerequisite cards each represent a +distinct surface that has to work before cutover. See +[[agent-change-process#C2 — Mikado Chain]] for the discipline. + +## Workflow note: registering new ArgoCD apps during the chain + +This chain adds three new ArgoCD `Application` definitions in +`argocd/apps/`: `cloudnative-pg-ringtail`, `databases-ringtail`, +and (later) `immich-ringtail`. The usual C1/C2 pattern of +`argocd app set --revision && argocd app sync ` +does NOT work for the app-of-apps `apps` Application itself, because +`apps` self-manages: it re-reads `apps.yaml` (which declares +`targetRevision: main`) on every sync and reverts the override. As a +result, new app definitions added on a feature branch are never +visible to the cluster via `apps`. + +**Use `kubectl apply` to register each new Application directly:** + +```fish +kubectl --context=minikube-indri apply -f argocd/apps/.yaml +``` + +This creates the Application resource out-of-band, bypassing `apps`. + +For apps whose source lives in **this** repo (e.g. +`databases-ringtail`, `immich-ringtail` — manifest paths exist only +on the branch until merge), follow the apply with a branch override: + +```fish +argocd app set --revision mikado/migrate-immich-to-ringtail +argocd app sync +``` + +For apps whose source is an **external** repo at a pinned tag (e.g. +`cloudnative-pg-ringtail` → `mirrors/cloudnative-pg` `v1.27.1`), no +override is needed — the source revision is independent of this PR. + +After PR merge: + +```fish +argocd app set --revision main +argocd app sync +``` + +`apps` itself, on its next sync from `main`, will discover the new +Application definitions in `argocd/apps/` and adopt the already-running +resources without disruption — provided their in-cluster spec matches +the on-disk definitions (which it does because we applied the same +file). + +## Related + +- [[shower-on-ringtail]] — a previous migration to ringtail (simpler: + no upstream cluster, SQLite, no GPU) +- [[connect-to-postgres]] — getting a psql session against CNPG +- [[ringtail]] — the target cluster +- [[cnpg-on-ringtail]], [[immich-pg-on-ringtail]], + [[immich-pg-data-migration]], [[sifaka-nfs-from-ringtail]], + [[immich-app-on-ringtail]], [[immich-cutover-and-decommission]] — + the prerequisite cards diff --git a/docs/how-to/immich/sifaka-nfs-from-ringtail.md b/docs/how-to/immich/sifaka-nfs-from-ringtail.md new file mode 100644 index 0000000..2c490c1 --- /dev/null +++ b/docs/how-to/immich/sifaka-nfs-from-ringtail.md @@ -0,0 +1,67 @@ +--- +title: Sifaka NFS Photos from Ringtail +modified: 2026-05-13 +last-reviewed: 2026-05-13 +tags: + - how-to + - operations + - storage + - nfs + - sifaka +--- + +# Sifaka NFS Photos from Ringtail + +The Immich library lives at `sifaka:/volume1/photos` and is mounted +into the pod via an NFS PV (see `argocd/manifests/immich/pv-nfs.yaml`). +That PV is currently scoped to indri. We need ringtail to mount the +same path with the same RWX semantics, without breaking the existing +indri mount during the transition. + +## What to verify / do + +- Check `sifaka` DSM NFS rules for the `photos` share. Per + [[shower-on-ringtail#NFS + SMB share on sifaka]] convention, rules + use `192.168.1.0/24` + `100.64.0.0/10` with + `all_squash`/`Map all users to admin`. The existing rule may + already cover ringtail (it's on `192.168.1.21` per the recent + static-IP pin). If so this card is a verification card. +- If the rule is locked to indri's IP: add an entry for ringtail + (192.168.1.21) or widen to the subnet pattern above. +- Test mount from a ringtail debug pod (busybox or alpine with + nfs-utils) against the `photos` share. Read a file. Write a temp + file. Delete it. +- Watch for the known sifaka NFS-over-Tailscale gotcha: sifaka's + Tailscale must be in TUN mode (not userspace) for NFS to work + reliably over the tailnet. The NFS path here goes over the LAN + (not tailnet), so this shouldn't bite, but worth confirming the + NFS traffic is on `192.168.1.x` not `100.x`. + +## PV + PVC on ringtail + +- New `pv-nfs.yaml` mirroring the minikube one (name can be shared + if the PV is cluster-scoped — but PVs are per-cluster, so just + duplicate). Same `server: sifaka`, same path, same + `accessModes: [ReadWriteMany]`, `persistentVolumeReclaimPolicy: + Retain`. +- New `pvc.yaml` in the ringtail `immich` namespace bound to it. +- The minikube PVC stays bound and active until cutover — both + clusters can have the share NFS-mounted simultaneously (NFS RWX + permits this). Immich itself must not be running on both sides + at once. + +## Verification + +- A pod on ringtail can `ls /mnt/photos/` and see the same files + as the indri pod. +- File written from ringtail pod is visible from indri pod and + vice versa (proves there's no caching surprise). + +## Out of scope + +- Migrating photo files. Nothing moves; this is just adding a second + NFS client. +- The `pvc-ml-cache.yaml` PVC (a separate ML model cache). That's + not on NFS — it's a regular PVC. Recreated empty on ringtail in + [[immich-app-on-ringtail]]; the first ML pod boot will repopulate + it. From dc69b8c68be6d158f15178a08f9f09603de50381 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 13 May 2026 18:55:50 -0700 Subject: [PATCH 380/430] C1: fix borgmatic shower SQLite dump (ssh to ringtail) (#357) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Nightly borgmatic backups have been failing for 2 days. Root cause: the shower SQLite dump `before_backup` hook (added in PR #349) referenced `kubectl --context=k3s-ringtail`, but indri's kubeconfig deliberately doesn't carry the ringtail credentials. The hook's failure aborted the entire run, taking out *both* the local sifaka repo and the BorgBase offsite. Verified the last good archive was `indri-2026-05-11T02:00`. ## Approach ssh into ringtail and run `k3s kubectl` there — no indri-side kubeconfig needed. `/etc/rancher/k3s/k3s.yaml` is mode 644 so no sudo required, and the existing ssh access from indri to ringtail works. Inline-shell quoting got hairy fast (fish on ringtail rejected `POD=...` bash syntax; the nix shower image lacks `tar` so `kubectl cp` fails). Pulled the dump logic into `~/bin/borgmatic-k8s-sqlite-dump`, deployed by the ansible role. Each dump entry now declares a `target`: - `local:` — local kubectl with explicit context (mealie) - `ssh:` — ssh + `k3s kubectl` on the cluster host (shower) Bytes come back via `kubectl exec ... -- cat` instead of `kubectl cp` since `cp` needs `tar` in the pod (nix-built containers don't bundle it). ## Test plan - [x] `mise run provision-indri -- --tags borgmatic --check --diff` shows expected diff - [x] Apply, helper script deployed at `~/bin/borgmatic-k8s-sqlite-dump` - [x] Helper invoked directly with `ssh:eblume@ringtail` produces a valid 288 KB SQLite file - [x] Full `borgmatic create` completes without errors — both mealie.db (1.7 MB) and shower.db (288 KB) appear in `~/.local/share/borgmatic/k8s-dumps/`, archive `indri-2026-05-13T17:31:02` written to sifaka borg repo 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/357 --- ansible/roles/borgmatic/defaults/main.yml | 8 ++- ansible/roles/borgmatic/tasks/main.yml | 14 ++++ .../roles/borgmatic/templates/config.yaml.j2 | 14 +++- .../borgmatic/templates/k8s-sqlite-dump.sh.j2 | 71 +++++++++++++++++++ .../fix-borgmatic-shower-via-ssh.bugfix.md | 14 ++++ 5 files changed, 116 insertions(+), 5 deletions(-) create mode 100644 ansible/roles/borgmatic/templates/k8s-sqlite-dump.sh.j2 create mode 100644 docs/changelog.d/fix-borgmatic-shower-via-ssh.bugfix.md diff --git a/ansible/roles/borgmatic/defaults/main.yml b/ansible/roles/borgmatic/defaults/main.yml index 123cb0f..3a89a09 100644 --- a/ansible/roles/borgmatic/defaults/main.yml +++ b/ansible/roles/borgmatic/defaults/main.yml @@ -56,12 +56,16 @@ borgmatic_k8s_sqlite_dumps: namespace: mealie label_selector: app=mealie db_path: /app/data/mealie.db - context: minikube + # local kubectl, --context=minikube (indri's only configured ctx) + target: local:minikube - name: shower namespace: shower label_selector: app=shower db_path: /app/data/db.sqlite3 - context: k3s-ringtail + # ssh to ringtail and run k3s kubectl there — avoids needing a + # ringtail kubeconfig on indri. k3s.yaml on ringtail is + # world-readable (mode 644), so no sudo required. + target: ssh:eblume@ringtail # Exclude patterns borgmatic_exclude_patterns: [] diff --git a/ansible/roles/borgmatic/tasks/main.yml b/ansible/roles/borgmatic/tasks/main.yml index eacefa5..4ac242c 100644 --- a/ansible/roles/borgmatic/tasks/main.yml +++ b/ansible/roles/borgmatic/tasks/main.yml @@ -49,6 +49,20 @@ mode: '0700' when: borgmatic_k8s_sqlite_dumps | length > 0 +- name: Ensure ~/bin exists + ansible.builtin.file: + path: "{{ ansible_env.HOME }}/bin" + state: directory + mode: '0755' + when: borgmatic_k8s_sqlite_dumps | length > 0 + +- name: Deploy k8s SQLite dump helper script + ansible.builtin.template: + src: k8s-sqlite-dump.sh.j2 + dest: "{{ ansible_env.HOME }}/bin/borgmatic-k8s-sqlite-dump" + mode: '0755' + when: borgmatic_k8s_sqlite_dumps | length > 0 + - name: Deploy borgmatic configuration ansible.builtin.template: src: config.yaml.j2 diff --git a/ansible/roles/borgmatic/templates/config.yaml.j2 b/ansible/roles/borgmatic/templates/config.yaml.j2 index 85804b7..0893dbc 100644 --- a/ansible/roles/borgmatic/templates/config.yaml.j2 +++ b/ansible/roles/borgmatic/templates/config.yaml.j2 @@ -32,12 +32,20 @@ exclude_patterns: encryption_passcommand: {{ borgmatic_encryption_passcommand }} {% if borgmatic_k8s_sqlite_dumps %} -# Pre-backup: dump SQLite databases from k8s pods -# Uses sqlite3 .backup for a safe, consistent copy (no corruption from concurrent writes) +# Pre-backup: dump SQLite databases from k8s pods. +# Uses sqlite3.backup() for a safe, consistent copy. +# +# Quoting/escaping is delegated to ~/bin/borgmatic-k8s-sqlite-dump +# (deployed by the borgmatic ansible role). Each entry's `target` +# is either: +# - local: -> local kubectl with --context (mealie etc.) +# - ssh: -> ssh + k3s kubectl on the cluster host, +# used for ringtail since indri's kubeconfig +# deliberately doesn't carry that context. before_backup: - mkdir -p {{ borgmatic_k8s_dump_dir }} {% for db in borgmatic_k8s_sqlite_dumps %} - - /opt/homebrew/bin/kubectl --context={{ db.context }} exec -n {{ db.namespace }} deploy/{{ db.name }} -- python3 -c "import sqlite3; sqlite3.connect('{{ db.db_path }}').backup(sqlite3.connect('/tmp/{{ db.name }}-backup.db'))" && /opt/homebrew/bin/kubectl --context={{ db.context }} cp {{ db.namespace }}/$(/opt/homebrew/bin/kubectl --context={{ db.context }} get pod -n {{ db.namespace }} -l {{ db.label_selector }} -o jsonpath='{.items[0].metadata.name}'):/tmp/{{ db.name }}-backup.db {{ borgmatic_k8s_dump_dir }}/{{ db.name }}.db + - {{ ansible_env.HOME }}/bin/borgmatic-k8s-sqlite-dump {{ db.target }} {{ db.namespace }} {{ db.label_selector }} {{ db.db_path }} {{ db.name }} {{ borgmatic_k8s_dump_dir }}/{{ db.name }}.db {% endfor %} {% endif %} diff --git a/ansible/roles/borgmatic/templates/k8s-sqlite-dump.sh.j2 b/ansible/roles/borgmatic/templates/k8s-sqlite-dump.sh.j2 new file mode 100644 index 0000000..323e717 --- /dev/null +++ b/ansible/roles/borgmatic/templates/k8s-sqlite-dump.sh.j2 @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# {{ ansible_managed }} +# +# Helper script invoked by borgmatic's before_backup hook to capture a +# k8s pod's SQLite database. Keeps the borgmatic config readable by +# pulling all the quoting out of YAML. +# +# Usage: +# borgmatic-k8s-sqlite-dump \ +# +# +# is one of: +# local: - run local kubectl with --context= +# ssh: - ssh to host and run k3s kubectl there +# (no indri-side kubeconfig needed) +# +# - k8s namespace of the pod +# - label selector to find the pod (e.g. app=shower) +# - absolute path inside the pod to the SQLite DB +# - short name used for temp filenames +# - file on this host to receive the dump +set -euo pipefail + +target=${1:?missing target} +namespace=${2:?missing namespace} +selector=${3:?missing selector} +db_path=${4:?missing db path} +name=${5:?missing name} +dump_target=${6:?missing dump target} + +pod_tmp="/tmp/${name}-backup.db" + +python_backup='import sqlite3; sqlite3.connect("'"$db_path"'").backup(sqlite3.connect("'"$pod_tmp"'"))' + +mode=${target%%:*} +ref=${target#*:} + +case "$mode" in + local) + # Pulls dump bytes out via "kubectl exec -- cat" rather than + # "kubectl cp", which would otherwise need tar inside the pod + # (nix-built images like shower don't bundle tar). + context=$ref + kubectl="/opt/homebrew/bin/kubectl --context=$context -n $namespace" + pod=$($kubectl get pod -l "$selector" \ + -o jsonpath='{.items[0].metadata.name}') + $kubectl exec "$pod" -- python3 -c "$python_backup" + $kubectl exec "$pod" -- cat "$pod_tmp" > "$dump_target" + $kubectl exec "$pod" -- rm -f "$pod_tmp" + ;; + ssh) + host=$ref + # Force bash on the remote (user's login shell on ringtail is + # fish). Pipe the script via stdin to dodge nested quoting. + # The dump bytes come back over the ssh stdout stream — no + # intermediate scp, no tar requirement in the pod. + ssh "$host" bash < "$dump_target" +set -euo pipefail +export KUBECONFIG=/etc/rancher/k3s/k3s.yaml +pod=\$(k3s kubectl -n "$namespace" get pod -l "$selector" -o jsonpath='{.items[0].metadata.name}') +k3s kubectl -n "$namespace" exec "\$pod" -- python3 -c '$python_backup' 1>&2 +k3s kubectl -n "$namespace" exec "\$pod" -- cat "$pod_tmp" +k3s kubectl -n "$namespace" exec "\$pod" -- rm -f "$pod_tmp" 1>&2 +EOF + ;; + *) + echo "borgmatic-k8s-sqlite-dump: unknown target mode: $mode" >&2 + echo " expected local: or ssh:" >&2 + exit 1 + ;; +esac diff --git a/docs/changelog.d/fix-borgmatic-shower-via-ssh.bugfix.md b/docs/changelog.d/fix-borgmatic-shower-via-ssh.bugfix.md new file mode 100644 index 0000000..e18272c --- /dev/null +++ b/docs/changelog.d/fix-borgmatic-shower-via-ssh.bugfix.md @@ -0,0 +1,14 @@ +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:` (mealie — kubectl uses indri's +kubeconfig) or `ssh:` (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. From 6e90c4c3631ec593b0b59d97ecad9bc5b92aea15 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 13 May 2026 20:12:00 -0700 Subject: [PATCH 381/430] C0: bump shower to v1.1.1 (probe FOD hash) Co-Authored-By: Claude Opus 4.7 (1M context) --- containers/shower/default.nix | 8 ++++---- docs/changelog.d/+shower-1.1.1.infra.md | 1 + 2 files changed, 5 insertions(+), 4 deletions(-) create mode 100644 docs/changelog.d/+shower-1.1.1.infra.md diff --git a/containers/shower/default.nix b/containers/shower/default.nix index e2d369d..242d873 100644 --- a/containers/shower/default.nix +++ b/containers/shower/default.nix @@ -25,7 +25,7 @@ { pkgs ? import { } }: let - version = "1.1.0"; + version = "1.1.1"; python = pkgs.python314; @@ -43,7 +43,7 @@ let showerSdist = pkgs.fetchurl { name = "adelaide_baby_shower_app-${version}.tar.gz"; url = "https://forge.ops.eblu.me/api/packages/eblume/pypi/files/adelaide-baby-shower-app/${version}/adelaide_baby_shower_app-${version}.tar.gz"; - hash = "sha256-5dp+0u4metOIC6s6/nPlT4cdpFBCV6S3+Z/3RO0sX5U="; + hash = "sha256-muvjkcKnLrrQTb8HZ4cH9SD0pab05JSFSgwheqb0AyM="; }; # Wheel pulled from forge.ops.eblu.me (tailnet) for the same reason the @@ -53,7 +53,7 @@ let showerWheel = pkgs.fetchurl { name = "adelaide_baby_shower_app-${version}-py3-none-any.whl"; url = "https://forge.ops.eblu.me/api/packages/eblume/pypi/files/adelaide-baby-shower-app/${version}/adelaide_baby_shower_app-${version}-py3-none-any.whl"; - hash = "sha256-7orFbycON9dQxEIb6q45Xx2rFlEZ8xXSrC2tnrO5uug="; + hash = "sha256-dorrwHhZhOn9Qq6Wk3Su24HckgaWtWbkMY7RtAvomv4="; }; staticAssets = pkgs.runCommand "shower-static-assets-${version}" { } '' @@ -148,7 +148,7 @@ let outputHashAlgo = "sha256"; # Pinned dep closure — reproducible until version bumps. To recompute, # set to pkgs.lib.fakeHash and read the failure. - outputHash = "sha256-kTNOswobtkgyQmmqbQM8XO4vvaGg57nCuuZGbNXb0NM="; + outputHash = pkgs.lib.fakeHash; dontFixup = true; }; diff --git a/docs/changelog.d/+shower-1.1.1.infra.md b/docs/changelog.d/+shower-1.1.1.infra.md new file mode 100644 index 0000000..eb9476c --- /dev/null +++ b/docs/changelog.d/+shower-1.1.1.infra.md @@ -0,0 +1 @@ +Bump shower container to v1.1.1 (probe FOD hash). From 4e117dc921f4106e7c243e8eed86953bb1f025b4 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 13 May 2026 20:40:22 -0700 Subject: [PATCH 382/430] C0: pin shower v1.1.1 FOD outputHash (probed on ringtail) Co-Authored-By: Claude Opus 4.7 (1M context) --- containers/shower/default.nix | 2 +- docs/changelog.d/+shower-1.1.1-fod-pin.infra.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 docs/changelog.d/+shower-1.1.1-fod-pin.infra.md diff --git a/containers/shower/default.nix b/containers/shower/default.nix index 242d873..4f807ed 100644 --- a/containers/shower/default.nix +++ b/containers/shower/default.nix @@ -148,7 +148,7 @@ let outputHashAlgo = "sha256"; # Pinned dep closure — reproducible until version bumps. To recompute, # set to pkgs.lib.fakeHash and read the failure. - outputHash = pkgs.lib.fakeHash; + outputHash = "sha256-HTTmAldIijG03pYZNyO72LBNPCrjmyJQKgW+gU9NplI="; dontFixup = true; }; diff --git a/docs/changelog.d/+shower-1.1.1-fod-pin.infra.md b/docs/changelog.d/+shower-1.1.1-fod-pin.infra.md new file mode 100644 index 0000000..a19b578 --- /dev/null +++ b/docs/changelog.d/+shower-1.1.1-fod-pin.infra.md @@ -0,0 +1 @@ +Pin shower v1.1.1 FOD outputHash (probed locally on ringtail). From 4d2bc9975fc8c0ab18294d71cd5be790bfb8b926 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 13 May 2026 20:51:10 -0700 Subject: [PATCH 383/430] C0: deploy shower v1.1.1 (kustomize newTag bump) Co-Authored-By: Claude Opus 4.7 (1M context) --- argocd/manifests/shower/kustomization.yaml | 2 +- docs/changelog.d/+shower-1.1.1-deploy.infra.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 docs/changelog.d/+shower-1.1.1-deploy.infra.md diff --git a/argocd/manifests/shower/kustomization.yaml b/argocd/manifests/shower/kustomization.yaml index b6de844..c0cf4c8 100644 --- a/argocd/manifests/shower/kustomization.yaml +++ b/argocd/manifests/shower/kustomization.yaml @@ -14,4 +14,4 @@ resources: images: - name: registry.ops.eblu.me/blumeops/shower - newTag: v1.1.0-3c7967e-nix + newTag: v1.1.1-4e117dc-nix diff --git a/docs/changelog.d/+shower-1.1.1-deploy.infra.md b/docs/changelog.d/+shower-1.1.1-deploy.infra.md new file mode 100644 index 0000000..61244ac --- /dev/null +++ b/docs/changelog.d/+shower-1.1.1-deploy.infra.md @@ -0,0 +1 @@ +Deploy shower v1.1.1 to ringtail (kustomize newTag bump). From 12314857d8b9fdc17c5dd97b1b92a36d8463c386 Mon Sep 17 00:00:00 2001 From: Erich Blume <725328+eblume@users.noreply.github.com> Date: Fri, 15 May 2026 06:27:43 -0700 Subject: [PATCH 384/430] C0: add GE-Proton to ringtail Steam extraCompatPackages Lets Subnautica 2 (and any other game) opt into the GE-Proton build via Steam's per-game compatibility tool override, as a workaround for the Proton Experimental + DXVK D3D12 Mercuna hang. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/changelog.d/+ringtail-proton-ge.infra.md | 4 ++++ nixos/ringtail/gaming.nix | 1 + 2 files changed, 5 insertions(+) create mode 100644 docs/changelog.d/+ringtail-proton-ge.infra.md diff --git a/docs/changelog.d/+ringtail-proton-ge.infra.md b/docs/changelog.d/+ringtail-proton-ge.infra.md new file mode 100644 index 0000000..0d8bc04 --- /dev/null +++ b/docs/changelog.d/+ringtail-proton-ge.infra.md @@ -0,0 +1,4 @@ +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. diff --git a/nixos/ringtail/gaming.nix b/nixos/ringtail/gaming.nix index d84ef9b..c526857 100644 --- a/nixos/ringtail/gaming.nix +++ b/nixos/ringtail/gaming.nix @@ -5,6 +5,7 @@ programs.steam = { enable = true; dedicatedServer.openFirewall = true; + extraCompatPackages = [ pkgs.proton-ge-bin ]; }; # Proton Experimental ships an accessibility bridge (xalia) that hangs during From a33fa47b8063f7ae47ada6f10feb8030f2c69426 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Fri, 15 May 2026 06:50:46 -0700 Subject: [PATCH 385/430] C1: deploy shower v1.1.2 (#358) ## Summary Deploys `adelaide-baby-shower-app` **v1.1.2** to ringtail k3s. - Bumps `containers/shower/default.nix` `version` to 1.1.2. - Refreshes sdist + wheel `fetchurl` hashes against the forge PyPI artifacts. - Re-probed FOD `outputHash` on the nix-container-builder runner (ringtail) and pinned the new closure hash. - Bumps kustomize `newTag` to `v1.1.2-b8c7783-nix` (built from this branch's tip). - Bumps `service-versions.yaml` entry for shower to `1.1.2` / `last-reviewed: 2026-05-15`. ## Build provenance Built by Forgejo Actions run #553 on `nix-container-builder` (ringtail) at commit `b8c7783`. After merge a C0 follow-on will rebuild from main and retag so future provenance points at main history. ## Test plan - [ ] `argocd app set shower --revision shower-v1.1.2 && argocd app sync shower` deploys cleanly - [ ] Pod migrates the SQLite PV and serves at `shower.ops.eblu.me` / `shower.eblu.me` - [ ] No new errors in pod logs after `collectstatic` + gunicorn boot Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/358 --- argocd/manifests/shower/kustomization.yaml | 2 +- containers/shower/default.nix | 8 ++++---- docs/changelog.d/shower-v1.1.2.infra.md | 1 + service-versions.yaml | 4 ++-- 4 files changed, 8 insertions(+), 7 deletions(-) create mode 100644 docs/changelog.d/shower-v1.1.2.infra.md diff --git a/argocd/manifests/shower/kustomization.yaml b/argocd/manifests/shower/kustomization.yaml index c0cf4c8..2c4dadb 100644 --- a/argocd/manifests/shower/kustomization.yaml +++ b/argocd/manifests/shower/kustomization.yaml @@ -14,4 +14,4 @@ resources: images: - name: registry.ops.eblu.me/blumeops/shower - newTag: v1.1.1-4e117dc-nix + newTag: v1.1.2-b8c7783-nix diff --git a/containers/shower/default.nix b/containers/shower/default.nix index 4f807ed..f7115bc 100644 --- a/containers/shower/default.nix +++ b/containers/shower/default.nix @@ -25,7 +25,7 @@ { pkgs ? import { } }: let - version = "1.1.1"; + version = "1.1.2"; python = pkgs.python314; @@ -43,7 +43,7 @@ let showerSdist = pkgs.fetchurl { name = "adelaide_baby_shower_app-${version}.tar.gz"; url = "https://forge.ops.eblu.me/api/packages/eblume/pypi/files/adelaide-baby-shower-app/${version}/adelaide_baby_shower_app-${version}.tar.gz"; - hash = "sha256-muvjkcKnLrrQTb8HZ4cH9SD0pab05JSFSgwheqb0AyM="; + hash = "sha256-U00259dlvHSo0c9I/W0kSThyhNKUT8ukG6X+vzj0k9c="; }; # Wheel pulled from forge.ops.eblu.me (tailnet) for the same reason the @@ -53,7 +53,7 @@ let showerWheel = pkgs.fetchurl { name = "adelaide_baby_shower_app-${version}-py3-none-any.whl"; url = "https://forge.ops.eblu.me/api/packages/eblume/pypi/files/adelaide-baby-shower-app/${version}/adelaide_baby_shower_app-${version}-py3-none-any.whl"; - hash = "sha256-dorrwHhZhOn9Qq6Wk3Su24HckgaWtWbkMY7RtAvomv4="; + hash = "sha256-lF79G9SiCuxG9LcyDJkTeTeJL72qTJTDVE196At1Ods="; }; staticAssets = pkgs.runCommand "shower-static-assets-${version}" { } '' @@ -148,7 +148,7 @@ let outputHashAlgo = "sha256"; # Pinned dep closure — reproducible until version bumps. To recompute, # set to pkgs.lib.fakeHash and read the failure. - outputHash = "sha256-HTTmAldIijG03pYZNyO72LBNPCrjmyJQKgW+gU9NplI="; + outputHash = "sha256-B5INpydOP3DmlgHfgpzKf+2mv0y9Wr2YNK7/5kh0hOc="; dontFixup = true; }; diff --git a/docs/changelog.d/shower-v1.1.2.infra.md b/docs/changelog.d/shower-v1.1.2.infra.md new file mode 100644 index 0000000..aa2db0d --- /dev/null +++ b/docs/changelog.d/shower-v1.1.2.infra.md @@ -0,0 +1 @@ +Deploy shower v1.1.2 — bump container build to new app release. diff --git a/service-versions.yaml b/service-versions.yaml index 63bc5df..02f2979 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -46,8 +46,8 @@ services: - name: shower type: argocd - last-reviewed: 2026-05-11 - current-version: "1.1.0" + last-reviewed: 2026-05-15 + current-version: "1.1.2" upstream-source: https://forge.eblu.me/eblume/adelaide-baby-shower-app notes: | Django app for Adelaide / Heidi / Addie's baby shower. Wheel From 815a0cc6e6d2dc7579633853fd8d06b94afddb26 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Fri, 15 May 2026 06:57:24 -0700 Subject: [PATCH 386/430] =?UTF-8?q?C0:=20shower=20=E2=80=94=20rebuild=20fr?= =?UTF-8?q?om=20main=20SHA=20(post-merge=20retag)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #358 was squash-merged so the branch commit b8c7783 baked into the prior image tag isn't reachable from main's history. Rebuild from main HEAD (a33fa47) and retag. Image content is byte-identical (FOD is content-addressed, inputs unchanged); only the SHA in the tag changes so future provenance tracing stays on main. Co-Authored-By: Claude Opus 4.7 (1M context) --- argocd/manifests/shower/kustomization.yaml | 2 +- docs/changelog.d/+shower-v1.1.2-rebuild-from-main-sha.misc.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 docs/changelog.d/+shower-v1.1.2-rebuild-from-main-sha.misc.md diff --git a/argocd/manifests/shower/kustomization.yaml b/argocd/manifests/shower/kustomization.yaml index 2c4dadb..6d4628c 100644 --- a/argocd/manifests/shower/kustomization.yaml +++ b/argocd/manifests/shower/kustomization.yaml @@ -14,4 +14,4 @@ resources: images: - name: registry.ops.eblu.me/blumeops/shower - newTag: v1.1.2-b8c7783-nix + newTag: v1.1.2-a33fa47-nix diff --git a/docs/changelog.d/+shower-v1.1.2-rebuild-from-main-sha.misc.md b/docs/changelog.d/+shower-v1.1.2-rebuild-from-main-sha.misc.md new file mode 100644 index 0000000..9355a54 --- /dev/null +++ b/docs/changelog.d/+shower-v1.1.2-rebuild-from-main-sha.misc.md @@ -0,0 +1 @@ +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. From 96dbbb3cbe7d8a9f695c3bc0bf7006367d1181a4 Mon Sep 17 00:00:00 2001 From: Erich Blume <725328+eblume@users.noreply.github.com> Date: Fri, 15 May 2026 12:11:54 -0700 Subject: [PATCH 387/430] C0: add sn2-prelaunch wrapper to clear SN2 stale lockfiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UE5 writes Saved/running.dat as a "session in progress" marker. If the previous session exited uncleanly (SIGKILL, crash), it lingers, and SN2 pops up an invisible 0×0 Error dialog at next launch that the GameThread blocks on forever — visible only as a black screen with a spinning loader. Wrap the Steam command to clear the marker files before each launch. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../+ringtail-sn2-prelaunch.infra.md | 6 ++++++ nixos/ringtail/gaming.nix | 17 +++++++++++++++++ 2 files changed, 23 insertions(+) create mode 100644 docs/changelog.d/+ringtail-sn2-prelaunch.infra.md diff --git a/docs/changelog.d/+ringtail-sn2-prelaunch.infra.md b/docs/changelog.d/+ringtail-sn2-prelaunch.infra.md new file mode 100644 index 0000000..f9c68e2 --- /dev/null +++ b/docs/changelog.d/+ringtail-sn2-prelaunch.infra.md @@ -0,0 +1,6 @@ +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%`. diff --git a/nixos/ringtail/gaming.nix b/nixos/ringtail/gaming.nix index c526857..7c00378 100644 --- a/nixos/ringtail/gaming.nix +++ b/nixos/ringtail/gaming.nix @@ -13,6 +13,23 @@ # so disable xalia globally to avoid wedging iscriptevaluator.exe. environment.sessionVariables.PROTON_USE_XALIA = "0"; + # Subnautica 2 pre-launch wrapper. SN2 (UE5) writes Saved/running.dat as a + # "currently running" lockfile. If the prior session exited uncleanly (SIGKILL + # via Steam's Stop button, crash, etc.), the file persists and on next launch + # SN2 pops up an invisible (0x0-sized) Error dialog ("Your game might not have + # exited correctly last time...") that the GameThread blocks on forever — + # observable only as a black screen with a spinning loader. This wrapper + # removes the stale lockfiles before exec'ing the actual game command. + # Use as Steam launch option for Subnautica 2: + # sn2-prelaunch %command% + environment.systemPackages = [ + (pkgs.writeShellScriptBin "sn2-prelaunch" '' + saved="/mnt/games/SteamLibrary/steamapps/compatdata/1962700/pfx/drive_c/users/steamuser/AppData/Local/Subnautica2/Saved" + rm -f "$saved/running.dat" "$saved/beforelobby.dat" + exec "$@" + '') + ]; + # Gamescope — micro-compositor for game fullscreen/resolution management. # Use as Steam launch option: gamescope -W 2560 -H 1440 -f -- %command% programs.gamescope = { From 3645098bf1d64afb46ab562faae1a8aabeee1501 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Fri, 15 May 2026 19:56:08 -0700 Subject: [PATCH 388/430] C0: bump shower to v1.1.3 Wheel/sdist + FOD hashes probed on ringtail. Full nix-build verified end-to-end before commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- containers/shower/default.nix | 8 ++++---- docs/changelog.d/+shower-1.1.3.infra.md | 1 + 2 files changed, 5 insertions(+), 4 deletions(-) create mode 100644 docs/changelog.d/+shower-1.1.3.infra.md diff --git a/containers/shower/default.nix b/containers/shower/default.nix index f7115bc..c5bd41e 100644 --- a/containers/shower/default.nix +++ b/containers/shower/default.nix @@ -25,7 +25,7 @@ { pkgs ? import { } }: let - version = "1.1.2"; + version = "1.1.3"; python = pkgs.python314; @@ -43,7 +43,7 @@ let showerSdist = pkgs.fetchurl { name = "adelaide_baby_shower_app-${version}.tar.gz"; url = "https://forge.ops.eblu.me/api/packages/eblume/pypi/files/adelaide-baby-shower-app/${version}/adelaide_baby_shower_app-${version}.tar.gz"; - hash = "sha256-U00259dlvHSo0c9I/W0kSThyhNKUT8ukG6X+vzj0k9c="; + hash = "sha256-a3rCwEdOB+rnYXqsWDifyltpyKUgkOj0ikWB+WGQYKE="; }; # Wheel pulled from forge.ops.eblu.me (tailnet) for the same reason the @@ -53,7 +53,7 @@ let showerWheel = pkgs.fetchurl { name = "adelaide_baby_shower_app-${version}-py3-none-any.whl"; url = "https://forge.ops.eblu.me/api/packages/eblume/pypi/files/adelaide-baby-shower-app/${version}/adelaide_baby_shower_app-${version}-py3-none-any.whl"; - hash = "sha256-lF79G9SiCuxG9LcyDJkTeTeJL72qTJTDVE196At1Ods="; + hash = "sha256-a6j91gBigG4IzE2DVTBntnZ46Yrx9b5PgHn+Uro98Tk="; }; staticAssets = pkgs.runCommand "shower-static-assets-${version}" { } '' @@ -148,7 +148,7 @@ let outputHashAlgo = "sha256"; # Pinned dep closure — reproducible until version bumps. To recompute, # set to pkgs.lib.fakeHash and read the failure. - outputHash = "sha256-B5INpydOP3DmlgHfgpzKf+2mv0y9Wr2YNK7/5kh0hOc="; + outputHash = "sha256-1xx2qWAIwherklHIPXo6IOKkKHML1KUrUx6pbkMxffc="; dontFixup = true; }; diff --git a/docs/changelog.d/+shower-1.1.3.infra.md b/docs/changelog.d/+shower-1.1.3.infra.md new file mode 100644 index 0000000..33ee49d --- /dev/null +++ b/docs/changelog.d/+shower-1.1.3.infra.md @@ -0,0 +1 @@ +Bumped shower app to v1.1.3 (wheel/sdist + FOD hashes probed on ringtail). From e222d47d455d07d18d1cf66d2a8984aa85d32586 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Fri, 15 May 2026 20:09:54 -0700 Subject: [PATCH 389/430] C0: deploy shower v1.1.3 (kustomize newTag bump) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Image v1.1.3-3645098-nix was built directly on ringtail and pushed via skopeo, bypassing the Forgejo runner: indri was severely overloaded (load avg 24.92, minikube VM at 344% CPU) and the workflow-dispatch endpoint timed out. The image content is identical to what the runner would have produced — same default.nix at commit 3645098 (on main), same NIX_PATH (current nixpkgs flake), same skopeo invocation. Tag short-sha matches the commit that defines the recipe so we aren't pinning to a ghost. Co-Authored-By: Claude Opus 4.7 (1M context) --- argocd/manifests/shower/kustomization.yaml | 2 +- docs/changelog.d/+shower-1.1.3-deploy.infra.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 docs/changelog.d/+shower-1.1.3-deploy.infra.md diff --git a/argocd/manifests/shower/kustomization.yaml b/argocd/manifests/shower/kustomization.yaml index 6d4628c..1c29224 100644 --- a/argocd/manifests/shower/kustomization.yaml +++ b/argocd/manifests/shower/kustomization.yaml @@ -14,4 +14,4 @@ resources: images: - name: registry.ops.eblu.me/blumeops/shower - newTag: v1.1.2-a33fa47-nix + newTag: v1.1.3-3645098-nix diff --git a/docs/changelog.d/+shower-1.1.3-deploy.infra.md b/docs/changelog.d/+shower-1.1.3-deploy.infra.md new file mode 100644 index 0000000..833fac6 --- /dev/null +++ b/docs/changelog.d/+shower-1.1.3-deploy.infra.md @@ -0,0 +1 @@ +Deployed shower v1.1.3 to ringtail (image built and pushed from ringtail; runner bypassed due to indri overload). From 1897eb1c5bf4ef1f6d3dfe3601f875b49b8ba2a4 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sun, 17 May 2026 08:46:22 -0700 Subject: [PATCH 390/430] C0: move immich blackbox probe to ringtail alloy Immich migrated to ringtail's k3s cluster but the probe still targeted the in-cluster service DNS on indri's minikube, firing ServiceProbeFailure indefinitely. Moved the target into alloy-ringtail's config so the probe runs in the cluster where immich actually lives. Co-Authored-By: Claude Opus 4.7 (1M context) --- argocd/manifests/alloy-k8s/config.alloy | 6 ------ argocd/manifests/alloy-ringtail/config.alloy | 20 +++++++++++++++++++ .../+immich-probe-ringtail.infra.md | 1 + 3 files changed, 21 insertions(+), 6 deletions(-) create mode 100644 docs/changelog.d/+immich-probe-ringtail.infra.md diff --git a/argocd/manifests/alloy-k8s/config.alloy b/argocd/manifests/alloy-k8s/config.alloy index 56a2e13..5a0a8f9 100644 --- a/argocd/manifests/alloy-k8s/config.alloy +++ b/argocd/manifests/alloy-k8s/config.alloy @@ -196,12 +196,6 @@ prometheus.exporter.blackbox "services" { module = "http_2xx" } - target { - name = "immich" - address = "http://immich-server.immich.svc.cluster.local:2283/api/server/ping" - module = "http_2xx" - } - target { name = "navidrome" address = "http://navidrome.navidrome.svc.cluster.local:4533/" diff --git a/argocd/manifests/alloy-ringtail/config.alloy b/argocd/manifests/alloy-ringtail/config.alloy index e92ab0f..e5cc045 100644 --- a/argocd/manifests/alloy-ringtail/config.alloy +++ b/argocd/manifests/alloy-ringtail/config.alloy @@ -45,6 +45,26 @@ prometheus.scrape "kube_state_metrics" { forward_to = [prometheus.remote_write.prometheus.receiver] } +// ============== SERVICE HEALTH PROBES ============== + +// Blackbox-style HTTP probes for in-cluster services on ringtail +prometheus.exporter.blackbox "services" { + config = "{ modules: { http_2xx: { prober: http, timeout: 5s } } }" + + target { + name = "immich" + address = "http://immich-server.immich.svc.cluster.local:2283/api/server/ping" + module = "http_2xx" + } +} + +// Scrape blackbox probe results +prometheus.scrape "blackbox" { + targets = prometheus.exporter.blackbox.services.targets + scrape_interval = "30s" + forward_to = [prometheus.remote_write.prometheus.receiver] +} + // Push metrics to indri Prometheus prometheus.remote_write "prometheus" { external_labels = { cluster = "ringtail" } diff --git a/docs/changelog.d/+immich-probe-ringtail.infra.md b/docs/changelog.d/+immich-probe-ringtail.infra.md new file mode 100644 index 0000000..f2d3dee --- /dev/null +++ b/docs/changelog.d/+immich-probe-ringtail.infra.md @@ -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. From 2fae0f71618cb7ba8858714693a127555ace6543 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Tue, 19 May 2026 06:33:26 -0700 Subject: [PATCH 391/430] C0: switch grafana deployment to Recreate strategy Grafana uses an RWO PVC for SQLite + Bleve search index. RollingUpdate spawns the new pod before terminating the old one, so the new pod crashloops on the index lock until rollout timeout. Recreate terminates the old pod first, letting the new pod acquire the lock cleanly. Co-Authored-By: Claude Opus 4.7 (1M context) --- argocd/manifests/grafana/deployment.yaml | 4 +++- docs/changelog.d/+grafana-recreate-strategy.infra.md | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) create mode 100644 docs/changelog.d/+grafana-recreate-strategy.infra.md diff --git a/argocd/manifests/grafana/deployment.yaml b/argocd/manifests/grafana/deployment.yaml index 0aad9b3..cbba267 100644 --- a/argocd/manifests/grafana/deployment.yaml +++ b/argocd/manifests/grafana/deployment.yaml @@ -14,7 +14,9 @@ spec: app.kubernetes.io/name: grafana app.kubernetes.io/instance: grafana strategy: - type: RollingUpdate + # RWO PVC for SQLite + Bleve index — RollingUpdate spawns the new pod + # before the old one terminates, and it crashloops on the index lock. + type: Recreate template: metadata: labels: diff --git a/docs/changelog.d/+grafana-recreate-strategy.infra.md b/docs/changelog.d/+grafana-recreate-strategy.infra.md new file mode 100644 index 0000000..3662e10 --- /dev/null +++ b/docs/changelog.d/+grafana-recreate-strategy.infra.md @@ -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. From ee51bcafb447ff1ef6e76f67f2d0a51fdaffb1c4 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Fri, 22 May 2026 21:08:53 -0700 Subject: [PATCH 392/430] Rip out compensating-controls framework (#359) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Removes the compensating-controls (CC) framework. Prowler and Kingfisher continue to run weekly and produce reports; the Prowler mutelist YAML files stay in place but no longer carry \`CC: \` prefixes — each entry now just keeps a free-form \`Description\` of why it's muted. The CC review cadence proved to be more process overhead than this single-operator homelab needed. ## What changed **Deleted** - \`compensating-controls.yaml\` — the CC registry - \`mise-tasks/review-compensating-controls\` — the staleness-review task - \`docs/how-to/operations/review-compensating-controls.md\` - \`docs/how-to/operations/record-review-evidence.md\` (was aspirational) - \`docs/explanation/compliance-mute-categories.md\` (proposed-future CC/NA/RA work) - 5 orphan \`+review-cc-*\` / \`+compliance-mute-categories\` changelog fragments **Modified** - 6 mutelist YAML files: stripped \`CC: .\` prefix from every \`Description\` / \`statement\` field, kept the free-form text - \`mise-tasks/review-compliance-reports\`: removed CC mentions from docstrings, panel text, and the node-verification table title. Node-verification logic itself is unchanged. - \`docs/reference/operations/security.md\`: removed the "Compensating controls" section - \`docs/how-to/operations/read-compliance-reports.md\`: rewrote step 3 of "Acting on findings" to point at the mutelist YAML directly - \`docs/changelog.d/prowler-iac-mutelist.infra.md\`: rewrote to drop the "two new compensating controls" framing ## What did not change - All Prowler manifests (cronjobs, RBAC, PVs, kustomization) — scans still run on the same schedule - The Kingfisher deployment - The trivy-shim in the Prowler container — that's about Trivy ignorefile plumbing, independent of the CC concept - The mutelist entries themselves — each \`Resources\` list is unchanged; only the prose of \`Description\` was edited - \`CHANGELOG.md\` — historical releases are left as-is ## Test plan - [ ] Wait for human review before deploying — once merged, re-point ArgoCD: \`argocd app set prowler --revision main && argocd app sync prowler\` (no manifest changes besides the ConfigMap, so impact is limited to muted-finding descriptions in next week's report) - [ ] Confirm next weekly Prowler K8s CIS run (Sunday 3am) still completes and produces a report on sifaka - [ ] Confirm next weekly Prowler IaC run still honors \`trivyignore.yaml\` (the trivy shim is untouched but the ignorefile content was rewritten) - [ ] \`mise run review-compliance-reports\` — verify node-verification block still runs and prints the renamed table title Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/359 --- .../manifests/prowler/mutelist/apiserver.yaml | 24 +- .../prowler/mutelist/control-plane.yaml | 6 +- .../prowler/mutelist/core-pod-security.yaml | 33 ++- .../prowler/mutelist/manual-node-checks.yaml | 30 +-- argocd/manifests/prowler/mutelist/rbac.yaml | 15 +- .../prowler/mutelist/trivyignore.yaml | 24 +- compensating-controls.yaml | 210 ---------------- .../+compliance-mute-categories.doc.md | 1 - ...eview-cc-ephemeral-privileged-jobs.misc.md | 1 - ...review-cc-init-container-isolation.misc.md | 1 - .../+review-cc-trusted-ci-only.misc.md | 1 - .../changelog.d/prowler-iac-mutelist.infra.md | 2 +- ...ervability-stack-audit-2026-05-11.infra.md | 1 - .../rip-out-compensating-controls.infra.md | 1 + .../explanation/compliance-mute-categories.md | 99 -------- .../operations/read-compliance-reports.md | 2 +- .../operations/record-review-evidence.md | 50 ---- .../review-compensating-controls.md | 80 ------ docs/reference/operations/security.md | 8 +- mise-tasks/review-compensating-controls | 229 ------------------ mise-tasks/review-compliance-reports | 12 +- 21 files changed, 72 insertions(+), 758 deletions(-) delete mode 100644 compensating-controls.yaml delete mode 100644 docs/changelog.d/+compliance-mute-categories.doc.md delete mode 100644 docs/changelog.d/+review-cc-ephemeral-privileged-jobs.misc.md delete mode 100644 docs/changelog.d/+review-cc-init-container-isolation.misc.md delete mode 100644 docs/changelog.d/+review-cc-trusted-ci-only.misc.md delete mode 100644 docs/changelog.d/review-cc-observability-stack-audit-2026-05-11.infra.md create mode 100644 docs/changelog.d/rip-out-compensating-controls.infra.md delete mode 100644 docs/explanation/compliance-mute-categories.md delete mode 100644 docs/how-to/operations/record-review-evidence.md delete mode 100644 docs/how-to/operations/review-compensating-controls.md delete mode 100755 mise-tasks/review-compensating-controls diff --git a/argocd/manifests/prowler/mutelist/apiserver.yaml b/argocd/manifests/prowler/mutelist/apiserver.yaml index 5a25d4f..fd077e8 100644 --- a/argocd/manifests/prowler/mutelist/apiserver.yaml +++ b/argocd/manifests/prowler/mutelist/apiserver.yaml @@ -6,48 +6,48 @@ Mutelist: "apiserver_always_pull_images_plugin": Regions: ["*"] Resources: ["^kube-apiserver-minikube$"] - Description: "CC: single-user-cluster, local-registry. Only the operator has cluster access; all images pulled from private zot registry." + Description: "Only the operator has cluster access; all images pulled from private zot registry." "apiserver_audit_log_maxage_set": Regions: ["*"] Resources: ["^kube-apiserver-minikube$"] - Description: "CC: observability-stack-audit. Alloy/Loki provides pod-level audit trail." + Description: "Alloy/Loki provides pod-level audit trail." "apiserver_audit_log_maxbackup_set": Regions: ["*"] Resources: ["^kube-apiserver-minikube$"] - Description: "CC: observability-stack-audit. Alloy/Loki provides pod-level audit trail." + Description: "Alloy/Loki provides pod-level audit trail." "apiserver_audit_log_maxsize_set": Regions: ["*"] Resources: ["^kube-apiserver-minikube$"] - Description: "CC: observability-stack-audit. Alloy/Loki provides pod-level audit trail." + Description: "Alloy/Loki provides pod-level audit trail." "apiserver_audit_log_path_set": Regions: ["*"] Resources: ["^kube-apiserver-minikube$"] - Description: "CC: observability-stack-audit. Alloy/Loki provides pod-level audit trail." + Description: "Alloy/Loki provides pod-level audit trail." "apiserver_deny_service_external_ips": Regions: ["*"] Resources: ["^kube-apiserver-minikube$"] - Description: "CC: tailscale-network-isolation. No external IPs routable; cluster only reachable via tailnet." + Description: "No external IPs routable; cluster only reachable via tailnet." "apiserver_disable_profiling": Regions: ["*"] Resources: ["^kube-apiserver-minikube$"] - Description: "CC: tailscale-network-isolation. Profiling endpoint unreachable from public internet." + Description: "Profiling endpoint unreachable from public internet." "apiserver_encryption_provider_config_set": Regions: ["*"] Resources: ["^kube-apiserver-minikube$"] - Description: "CC: tailscale-network-isolation, single-user-cluster. Etcd not network-exposed; only operator has node access." + Description: "Etcd not network-exposed; only operator has node access." "apiserver_kubelet_cert_auth": Regions: ["*"] Resources: ["^kube-apiserver-minikube$"] - Description: "CC: tailscale-network-isolation. Kubelet API not exposed outside the node; minikube auto-generates certificates." + Description: "Kubelet API not exposed outside the node; minikube auto-generates certificates." "apiserver_request_timeout_set": Regions: ["*"] Resources: ["^kube-apiserver-minikube$"] - Description: "CC: tailscale-network-isolation. API server only reachable via tailnet; DoS risk limited to trusted clients." + Description: "API server only reachable via tailnet; DoS risk limited to trusted clients." "apiserver_service_account_lookup_true": Regions: ["*"] Resources: ["^kube-apiserver-minikube$"] - Description: "CC: single-user-cluster. Only operator manages service accounts; no revoked tokens in circulation." + Description: "Only operator manages service accounts; no revoked tokens in circulation." "apiserver_strong_ciphers_only": Regions: ["*"] Resources: ["^kube-apiserver-minikube$"] - Description: "CC: tailscale-network-isolation. API server traffic encrypted by WireGuard at the network layer." + Description: "API server traffic encrypted by WireGuard at the network layer." diff --git a/argocd/manifests/prowler/mutelist/control-plane.yaml b/argocd/manifests/prowler/mutelist/control-plane.yaml index 2056691..d3cc34a 100644 --- a/argocd/manifests/prowler/mutelist/control-plane.yaml +++ b/argocd/manifests/prowler/mutelist/control-plane.yaml @@ -6,12 +6,12 @@ Mutelist: "controllermanager_disable_profiling": Regions: ["*"] Resources: ["^kube-controller-manager-minikube$"] - Description: "CC: tailscale-network-isolation. Profiling endpoint unreachable from public internet." + Description: "Profiling endpoint unreachable from public internet." "scheduler_profiling": Regions: ["*"] Resources: ["^kube-scheduler-minikube$"] - Description: "CC: tailscale-network-isolation. Profiling endpoint unreachable from public internet." + Description: "Profiling endpoint unreachable from public internet." "kubelet_tls_cert_and_key": Regions: ["*"] Resources: ["^kubelet-config$"] - Description: "CC: tailscale-network-isolation, single-user-cluster. Kubelet API not exposed outside node; minikube auto-generates certificates." + Description: "Kubelet API not exposed outside node; minikube auto-generates certificates." diff --git a/argocd/manifests/prowler/mutelist/core-pod-security.yaml b/argocd/manifests/prowler/mutelist/core-pod-security.yaml index c39e0c6..b1e986e 100644 --- a/argocd/manifests/prowler/mutelist/core-pod-security.yaml +++ b/argocd/manifests/prowler/mutelist/core-pod-security.yaml @@ -17,9 +17,8 @@ Mutelist: - "^kindnet-" - "^storage-provisioner$" Description: >- - CC: tailscale-network-isolation. Control-plane and networking - pods require hostNetwork by design. Host network itself is - only reachable via tailnet. + Control-plane and networking pods require hostNetwork by design. + Host network itself is only reachable via tailnet. "core_minimize_privileged_containers": Regions: ["*"] Resources: @@ -31,7 +30,6 @@ Mutelist: # Forgejo runner - "^forgejo-runner-" Description: >- - CC: single-user-cluster, operator-managed-pods, trusted-ci-only. kube-proxy: system pod, single-user cluster. ts-*/ingress-*: Tailscale operator-managed. forgejo-runner: DinD limited to trusted private forge repos. @@ -49,25 +47,24 @@ Mutelist: - "^nameserver-" - "^ingress-" Description: >- - CC: single-user-cluster, operator-managed-pods. System pods - managed by minikube and Tailscale operator; seccomp profiles - set by upstream. Single-user cluster limits exploit surface. + System pods managed by minikube and Tailscale operator; + seccomp profiles set by upstream. Single-user cluster limits + exploit surface. "core_minimize_hostPID_containers": Regions: ["*"] Resources: - "^prowler-" Description: >- - CC: ephemeral-privileged-jobs. Prowler CIS scanner requires - hostPID for file permission checks. Runs as CronJob with - 7-day TTL, not a persistent workload. + Prowler CIS scanner requires hostPID for file permission + checks. Runs as CronJob with 7-day TTL, not a persistent + workload. "core_minimize_root_containers_admission": Regions: ["*"] Resources: - "^grafana-" Description: >- - CC: init-container-isolation. Root limited to init-chown-data - container; all runtime containers run as UID 472 with caps - dropped. + Root limited to init-chown-data container; all runtime + containers run as UID 472 with caps dropped. "core_minimize_containers_added_capabilities": Regions: ["*"] Resources: @@ -77,10 +74,9 @@ Mutelist: # Grafana init-chown-data - "^grafana-" Description: >- - CC: single-user-cluster, init-container-isolation. System - pods: capabilities required by function (minikube-managed). - Grafana: CHOWN limited to init phase; runtime containers - drop ALL. + System pods: capabilities required by function + (minikube-managed). Grafana: CHOWN limited to init phase; + runtime containers drop ALL. "core_minimize_containers_capabilities_assigned": Regions: ["*"] Resources: @@ -88,5 +84,4 @@ Mutelist: - "^kindnet-" - "^grafana-" Description: >- - CC: single-user-cluster, init-container-isolation. See - core_minimize_containers_added_capabilities. + See core_minimize_containers_added_capabilities. diff --git a/argocd/manifests/prowler/mutelist/manual-node-checks.yaml b/argocd/manifests/prowler/mutelist/manual-node-checks.yaml index 9c8354d..c91a2a6 100644 --- a/argocd/manifests/prowler/mutelist/manual-node-checks.yaml +++ b/argocd/manifests/prowler/mutelist/manual-node-checks.yaml @@ -1,7 +1,7 @@ # Node-level and RBAC checks that Prowler reports as MANUAL because it -# cannot evaluate them from inside a pod. Compensated by automated -# verification in `mise run review-compliance-reports`, which SSHes into -# the minikube node and checks each condition directly every week. +# cannot evaluate them from inside a pod. Verified out-of-band by the +# node-verification block in `mise run review-compliance-reports`, which +# SSHes into the minikube node and checks each condition directly. Mutelist: Accounts: "*": @@ -9,51 +9,51 @@ Mutelist: "etcd_unique_ca": Regions: ["*"] Resources: ["^etcd-minikube$"] - Description: "CC: node-config-automated-verification. Etcd CA fingerprint verified different from cluster CA by review-compliance-reports." + Description: "Etcd CA fingerprint verified different from cluster CA by review-compliance-reports." "kubelet_conf_file_ownership": Regions: ["*"] Resources: ["^kubelet-config$"] - Description: "CC: node-config-automated-verification. File ownership verified root:root by review-compliance-reports." + Description: "File ownership verified root:root by review-compliance-reports." "kubelet_conf_file_permissions": Regions: ["*"] Resources: ["^kubelet-config$"] - Description: "CC: node-config-automated-verification. File permissions verified 600 by review-compliance-reports." + Description: "File permissions verified 600 by review-compliance-reports." "kubelet_config_yaml_ownership": Regions: ["*"] Resources: ["^kubelet-config$"] - Description: "CC: node-config-automated-verification. File ownership verified root:root by review-compliance-reports." + Description: "File ownership verified root:root by review-compliance-reports." "kubelet_config_yaml_permissions": Regions: ["*"] Resources: ["^kubelet-config$"] - Description: "CC: node-config-automated-verification. File permissions verified 644 by review-compliance-reports." + Description: "File permissions verified 644 by review-compliance-reports." "kubelet_service_file_ownership_root": Regions: ["*"] Resources: ["^kubelet-config$"] - Description: "CC: node-config-automated-verification. File ownership verified root:root by review-compliance-reports." + Description: "File ownership verified root:root by review-compliance-reports." "kubelet_service_file_permissions": Regions: ["*"] Resources: ["^kubelet-config$"] - Description: "CC: node-config-automated-verification. File permissions verified 644 by review-compliance-reports." + Description: "File permissions verified 644 by review-compliance-reports." "kubelet_disable_read_only_port": Regions: ["*"] Resources: ["^kubelet-config$"] - Description: "CC: node-config-automated-verification. readOnlyPort absence (defaults to 0) verified by review-compliance-reports." + Description: "readOnlyPort absence (defaults to 0) verified by review-compliance-reports." "kubelet_event_record_qps": Regions: ["*"] Resources: ["^kubelet-config$"] - Description: "CC: node-config-automated-verification. eventRecordQPS absence (defaults to 5) verified by review-compliance-reports." + Description: "eventRecordQPS absence (defaults to 5) verified by review-compliance-reports." "kubelet_manage_iptables": Regions: ["*"] Resources: ["^kubelet-config$"] - Description: "CC: node-config-automated-verification. makeIPTablesUtilChains absence (defaults to true) verified by review-compliance-reports." + Description: "makeIPTablesUtilChains absence (defaults to true) verified by review-compliance-reports." "kubelet_strong_ciphers_only": Regions: ["*"] Resources: ["^kubelet-config$"] - Description: "CC: node-config-automated-verification, tailscale-network-isolation. Go default ciphers used; all traffic WireGuard-encrypted via tailnet." + Description: "Go default ciphers used; all traffic WireGuard-encrypted via tailnet." "rbac_cluster_admin_usage": Regions: ["*"] Resources: - "^cluster-admin$" - "^kubeadm:cluster-admins$" - "^minikube-rbac$" - Description: "CC: node-config-automated-verification, single-user-cluster. Only built-in/minikube cluster-admin bindings present; verified by review-compliance-reports." + Description: "Only built-in/minikube cluster-admin bindings present; verified by review-compliance-reports." diff --git a/argocd/manifests/prowler/mutelist/rbac.yaml b/argocd/manifests/prowler/mutelist/rbac.yaml index c9c52e4..324809d 100644 --- a/argocd/manifests/prowler/mutelist/rbac.yaml +++ b/argocd/manifests/prowler/mutelist/rbac.yaml @@ -13,9 +13,8 @@ Mutelist: # ArgoCD - "^argocd-" Description: >- - CC: single-user-cluster, sso-gated-admin-tools. Built-in - K8s roles: only operator can bind them. ArgoCD: requires - broad access but is SSO-gated via Authentik OIDC. + Built-in K8s roles: only operator can bind them. ArgoCD: + requires broad access but is SSO-gated via Authentik OIDC. "rbac_minimize_pod_creation_access": Regions: ["*"] Resources: @@ -26,14 +25,12 @@ Mutelist: # CloudNativePG operator - "^cnpg-manager$" Description: >- - CC: single-user-cluster. Built-in K8s roles and CNPG - operator. Only the operator can assign these roles; no - untrusted users have cluster access. + Built-in K8s roles and CNPG operator. Only the operator can + assign these roles; no untrusted users have cluster access. "rbac_minimize_service_account_token_creation": Regions: ["*"] Resources: - "^system:" Description: >- - CC: single-user-cluster. kube-controller-manager requires - token creation for SA management. Only operator manages - service accounts. + kube-controller-manager requires token creation for SA + management. Only operator manages service accounts. diff --git a/argocd/manifests/prowler/mutelist/trivyignore.yaml b/argocd/manifests/prowler/mutelist/trivyignore.yaml index 22c612a..87af966 100644 --- a/argocd/manifests/prowler/mutelist/trivyignore.yaml +++ b/argocd/manifests/prowler/mutelist/trivyignore.yaml @@ -14,26 +14,24 @@ misconfigurations: paths: - "argocd/manifests/external-secrets/rbac.yaml" statement: >- - CC: operator-purpose-bound-rbac. 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 + 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: >- - CC: kube-state-metrics-metadata-only. 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. + 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: >- - CC: operator-purpose-bound-rbac. 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. + 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. diff --git a/compensating-controls.yaml b/compensating-controls.yaml deleted file mode 100644 index 01b3cfd..0000000 --- a/compensating-controls.yaml +++ /dev/null @@ -1,210 +0,0 @@ -# Compensating Controls -# -# Documents controls that mitigate risks from suppressed or accepted security -# findings. Referenced by security tools (Prowler mutelist, Kingfisher config, -# etc.) via "CC: " in finding descriptions or suppression notes. -# -# Used by `mise run review-compensating-controls` to surface stale controls. -# -# Fields: -# id - kebab-case unique identifier, referenced from tool configs -# description - what the control actually does to mitigate risk -# created - date (YYYY-MM-DD) the control was documented -# last-reviewed - date (YYYY-MM-DD) or null -# notes - optional context - -controls: - - id: single-user-cluster - description: >- - Only the cluster operator (eblume) has kubectl access. No untrusted - users can create pods, access cached images, or bind RBAC roles. - created: 2026-03-30 - last-reviewed: 2026-04-01 - notes: >- - Verify by checking kubeconfig distribution and Tailscale ACLs. - If additional users gain cluster access, re-evaluate all findings - muted under this control. - - - id: tailscale-network-isolation - description: >- - Cluster is not internet-exposed. All access requires Tailscale - identity with ACL enforcement. Profiling endpoints, debug ports, - and control-plane APIs are unreachable from the public internet. - created: 2026-03-30 - last-reviewed: 2026-04-06 - notes: >- - Verify with 'tailscale serve status --json' on indri and review - Tailscale ACLs in pulumi/tailscale/. Only tag:flyio-target services - are publicly routable. - - - id: local-registry - description: >- - Operator-built services use a private zot registry - (registry.ops.eblu.me) for supply-chain control. Remaining - images are pulled from public registries without stored - credentials. No shared registry secrets are cached on cluster - nodes. - created: 2026-03-30 - last-reviewed: 2026-04-12 - notes: >- - Verify by checking image prefixes in kustomization.yaml files. - Known external-image categories: (1) upstream apps not yet - mirrored — immich, ollama, frigate, frigate-notify, valkey; - (2) infrastructure components — tailscale operator/proxy, - external-secrets, 1password-connect, forgejo-runner, docker - DinD, nvidia-device-plugin; (3) utility base images — busybox, - alpine (grafana init containers). Track upstream versions in - service-versions.yaml. Goal is to progressively mirror these - into zot. - - - id: sso-gated-admin-tools - description: >- - ArgoCD requires SSO authentication via Authentik OIDC. Wildcard - RBAC roles are mitigated by requiring authenticated identity - before any API access. - created: 2026-03-30 - last-reviewed: 2026-04-14 - notes: >- - Verify Authentik OIDC provider config for ArgoCD and that - anonymous access is disabled. Check ArgoCD --auth-token isn't - leaked. The workflow-bot API key account is scoped to sync/get - only. - - - id: operator-managed-pods - description: >- - Tailscale operator manages proxy pod specs (ts-*, ingress-*, - operator-*, nameserver-*). Pod security settings are set by the - operator, not user manifests. Operator is tracked in - service-versions.yaml and regularly updated. - created: 2026-03-30 - last-reviewed: 2026-04-21 - notes: >- - Verify operator version is current via 'mise run service-review'. - Check Tailscale changelog for security fixes. If operator adds - seccomp support, remove these mutes. As of 2026-04-21: still no - default seccomp on operator-generated pods (upstream issue #7359 - open). A ProxyClass + generic device plugin can downgrade proxies - from privileged to NET_ADMIN+NET_RAW and set seccompProfile — - potential future remediation to remove the seccomp mute without - waiting for upstream defaults. - - - id: ephemeral-privileged-jobs - description: >- - Prowler CIS scanner runs as a CronJob with 7-day TTL - auto-deletion, not as a persistent privileged workload. hostPID - exposure is time-bounded to scan duration (~20s). - created: 2026-03-30 - last-reviewed: 2026-04-29 - notes: >- - Verify TTL is set in cronjob.yaml. Check that no persistent - pods run with hostPID on the scanned cluster (indri). The - alloy-tracing DaemonSet on ringtail also uses hostPID but is - out of scope — Prowler only scans indri. Tracked in Todoist: - "prowler scan against ringtail" — once that lands, the - DaemonSet's hostPID+privileged posture will surface as a CIS - finding and need its own CC or remediation. - - - id: trusted-ci-only - description: >- - Forgejo runner only executes workflows from repos on the private - forge (forge.ops.eblu.me). No external or untrusted repos can - trigger privileged CI jobs. - created: 2026-03-30 - last-reviewed: 2026-05-01 - notes: >- - Verification: (1) Runner config (argocd/manifests/forgejo-runner/ - config.yaml) connects only to https://forge.ops.eblu.me/. (2) Forge - app.ini has DISABLE_REGISTRATION=true and ALLOW_ONLY_EXTERNAL_REGISTRATION - =true (ansible/roles/forgejo/defaults/main.yml) — no untrusted users - can sign up or create repos. The runner registers at instance scope - (repo_id=0/owner_id=0 in action_runner table), but the instance itself - is closed, so no per-repo allow-list is needed. Re-evaluate if the - forge ever opens to additional users or if the runner is repointed - to an external forge. - - - id: init-container-isolation - description: >- - Root privileges and added capabilities (CHOWN) are limited to - init containers that run once at pod startup. All runtime - containers run as non-root (UID 472) with all capabilities - dropped. - created: 2026-03-30 - last-reviewed: 2026-05-04 - notes: >- - Verify by inspecting grafana deployment.yaml securityContext - for both init and runtime containers. If fsGroup alone can - handle PVC ownership, remove init-chown-data and this control. - Retirement deferred until grafana lands on ringtail's k3s - (see [[indri-k8s-migration]]) — storage backend will change, - and removing init-chown-data right before that migration - trades a real safety net for marginal cleanup. Revisit - post-migration. - - - id: node-config-automated-verification - description: >- - Prowler reports certain node-level checks as MANUAL because it runs - inside a pod and cannot evaluate kubelet file permissions, kubelet - config arguments, etcd CA separation, or cluster-admin RBAC bindings. - The review-compliance-reports script SSHes into the minikube node - weekly and programmatically verifies each condition, failing loudly - if any check deviates from expected values. - created: 2026-04-14 - last-reviewed: 2026-04-14 - notes: >- - Verification runs as part of 'mise run review-compliance-reports'. - If minikube node is unreachable, all checks report as FAIL. If new - MANUAL findings appear in Prowler, add corresponding verification - logic to the script and update the mutelist. - - - id: operator-purpose-bound-rbac - description: >- - Operators whose entire function is to manage a sensitive resource - legitimately need RBAC over that resource. external-secrets-operator - manages Secret objects (its purpose) and the cert-controller mutates - its own ValidatingWebhookConfigurations to inject rotating CA bundles. - Risk is bounded by: (1) the operator code being upstream open-source - and reviewed; (2) RBAC scoped to specific named webhooks where - possible; (3) supply chain controls on the operator image (mirrored - to local registry, version tracked in service-versions.yaml). - created: 2026-04-27 - last-reviewed: 2026-04-27 - notes: >- - Verify by checking that the operators in question still match their - stated purpose (i.e. external-secrets is still the only consumer of - these ClusterRoles) and that upstream hasn't published advisories - for credential-handling bugs. Re-evaluate if a non-secrets-managing - ClusterRole appears under this control. - - - id: kube-state-metrics-metadata-only - description: >- - kube-state-metrics holds list/watch on Secrets cluster-wide but only - exposes Secret object *metadata* (name, namespace, type, creation - timestamp, labels) via the kube_secret_info / kube_secret_labels - metrics. Secret data fields are never read into KSM's exposed - metrics by upstream design. Mitigation rests on KSM's metric - schema, the version pin in service-versions.yaml, and the metrics - endpoint being reachable only on the cluster network. - created: 2026-04-27 - last-reviewed: 2026-04-27 - notes: >- - Verify by inspecting the /metrics endpoint output for any series - that include secret data (only *_info and *_labels metrics should - reference secrets, and labels should be limited to user-applied - labels — never the data:). Re-evaluate on KSM version bumps. - - - id: observability-stack-audit - description: >- - Alloy collects pod logs and ships them to Loki, providing an - audit trail for cluster activity. Compensates for missing - apiserver audit logging which neither minikube (indri) nor - k3s (ringtail) configures by default. - created: 2026-03-30 - last-reviewed: 2026-05-11 - notes: >- - Verify Alloy DaemonSet is running on each cluster (alloy-k8s on - minikube, alloy-ringtail on k3s) and Loki is receiving logs. - Note this is weaker than native apiserver audit logs — it - captures pod stdout/stderr, not API request-level auditing. - Consider enabling apiserver audit logging on k3s post-migration - (`--audit-log-path` / `--audit-policy-file`) — minikube made it - hard, k3s makes it straightforward. diff --git a/docs/changelog.d/+compliance-mute-categories.doc.md b/docs/changelog.d/+compliance-mute-categories.doc.md deleted file mode 100644 index c776e46..0000000 --- a/docs/changelog.d/+compliance-mute-categories.doc.md +++ /dev/null @@ -1 +0,0 @@ -New explanation article [[compliance-mute-categories]] documenting the gap between current `CC:`-only mute tagging and the three structurally distinct categories (compensating control, not-applicable, risk-accepted) needed for real PCI DSS / SOC2 practice. Captures the current image-scan mutelist gap (`cronjob-image-scan.yaml` doesn't pass `--mutelist-file`) and proposes an order-of-operations for wiring it up alongside the new tag conventions. Triggered by CVE-2026-31789, an OpenSSL 32-bit-only finding that surfaced the need for an NA category. diff --git a/docs/changelog.d/+review-cc-ephemeral-privileged-jobs.misc.md b/docs/changelog.d/+review-cc-ephemeral-privileged-jobs.misc.md deleted file mode 100644 index 14dcdca..0000000 --- a/docs/changelog.d/+review-cc-ephemeral-privileged-jobs.misc.md +++ /dev/null @@ -1 +0,0 @@ -Reviewed compensating control `ephemeral-privileged-jobs`: TTL and hostPID scope verified on indri. Noted that the alloy-tracing DaemonSet on ringtail is out of scope until Prowler scans ringtail (tracked in Todoist). diff --git a/docs/changelog.d/+review-cc-init-container-isolation.misc.md b/docs/changelog.d/+review-cc-init-container-isolation.misc.md deleted file mode 100644 index 295e7f8..0000000 --- a/docs/changelog.d/+review-cc-init-container-isolation.misc.md +++ /dev/null @@ -1 +0,0 @@ -Reviewed compensating control `init-container-isolation` (35 days stale). Grafana's running pod matches the manifest and the CC's claim — only `init-chown-data` runs as root with `CHOWN`; runtime containers all run as UID 472 with all caps dropped. Retirement (replacing init-chown-data with `fsGroup` alone) is plausible given the in-tree minikube-hostpath provisioner, but deferred until grafana lands on ringtail's k3s — note added to the CC. diff --git a/docs/changelog.d/+review-cc-trusted-ci-only.misc.md b/docs/changelog.d/+review-cc-trusted-ci-only.misc.md deleted file mode 100644 index 89dc653..0000000 --- a/docs/changelog.d/+review-cc-trusted-ci-only.misc.md +++ /dev/null @@ -1 +0,0 @@ -Reviewed compensating control `trusted-ci-only`: Forgejo runner is registered only to the private forge, which has registration disabled — no untrusted users can create repos or trigger privileged CI. Tightened the notes to reflect that the closed-forge property (not a per-repo allow-list) is what actually mitigates the risk. diff --git a/docs/changelog.d/prowler-iac-mutelist.infra.md b/docs/changelog.d/prowler-iac-mutelist.infra.md index 793c1ec..077cfa8 100644 --- a/docs/changelog.d/prowler-iac-mutelist.infra.md +++ b/docs/changelog.d/prowler-iac-mutelist.infra.md @@ -1 +1 @@ -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. Two new compensating controls — `operator-purpose-bound-rbac` and `kube-state-metrics-metadata-only` — justify 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`. +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`. diff --git a/docs/changelog.d/review-cc-observability-stack-audit-2026-05-11.infra.md b/docs/changelog.d/review-cc-observability-stack-audit-2026-05-11.infra.md deleted file mode 100644 index 8100c6a..0000000 --- a/docs/changelog.d/review-cc-observability-stack-audit-2026-05-11.infra.md +++ /dev/null @@ -1 +0,0 @@ -Reviewed compensating control `observability-stack-audit`. Updated description to cover ringtail's k3s as well as indri's minikube; both Alloy DaemonSets and Loki are healthy. diff --git a/docs/changelog.d/rip-out-compensating-controls.infra.md b/docs/changelog.d/rip-out-compensating-controls.infra.md new file mode 100644 index 0000000..d41fd1a --- /dev/null +++ b/docs/changelog.d/rip-out-compensating-controls.infra.md @@ -0,0 +1 @@ +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: ` 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. diff --git a/docs/explanation/compliance-mute-categories.md b/docs/explanation/compliance-mute-categories.md deleted file mode 100644 index 4c5f3a3..0000000 --- a/docs/explanation/compliance-mute-categories.md +++ /dev/null @@ -1,99 +0,0 @@ ---- -title: Compliance Mute Categories -modified: 2026-05-04 -last-reviewed: 2026-05-04 -tags: - - explanation - - security - - compliance ---- - -# Compliance Mute Categories - -> **Note:** This article was drafted by AI and reviewed by Erich. I plan to rewrite all explanatory content in my own words - these serve as placeholders to establish the documentation structure. - -How BlumeOps should categorize muted compliance findings, why a single "compensating control" tag is not enough, and what tooling work is needed to support multiple categories cleanly. - -## Why this matters - -When a compliance scanner ([[prowler]], Trivy via Prowler IaC, Kingfisher) reports a failing finding, there are three structurally different reasons we might suppress it: - -1. **Compensating control (CC)** — the requirement applies and we *do not* meet it directly, but an alternative control mitigates the same risk. -2. **Not applicable (NA)** — the requirement's preconditions cannot be satisfied in our environment, so the finding is structurally inert (e.g. a 32-bit-only CVE on 64-bit-only hosts). -3. **Risk accepted (RA)** — the requirement applies, we do not meet it, no compensating control exists, and we have explicitly chosen to accept the residual risk for a bounded period. - -Today every muted finding in BlumeOps uses the `CC: ` convention. That conflates all three categories. In a real PCI DSS or SOC2 environment, auditors treat them very differently: - -- A CC requires documentation of the constraint, the alternative measure, and recurring validation that the measure still works. -- An NA requires documentation of *why* the precondition cannot be met, with periodic verification that the environmental fact still holds. -- An RA requires an explicit decision-maker, an expiry date, and a scheduled re-decision. - -Mixing them under one tag means stale CCs hide stale RAs, and NAs that should be revisited when the environment changes get treated as permanent fixtures. - -## Trigger case: CVE-2026-31789 - -The 2026-05-03 weekly compliance review surfaced [CVE-2026-31789](https://nvd.nist.gov/vuln/detail/CVE-2026-31789), an OpenSSL heap buffer overflow during X.509 certificate processing on **32-bit systems**. Prowler's image scanner flagged 216 findings across 106 BlumeOps images carrying `libssl3` / `libcrypto3` below the fixed versions. - -The CVE is genuine, but its preconditions cannot be satisfied in our environment: indri is Apple Silicon (arm64), ringtail is x86_64, and we run no 32-bit containers. This is the canonical NA case — not a CC, because there is no "alternative measure mitigating the risk." The risk does not exist for us at all. - -A CC like `no-32bit-runtimes` would technically work, but conflates the categories: if we ever introduce a 32-bit runtime we would have to remember that this CC was load-bearing for the mute, retire or scope it down, and reopen the muted findings. An NA tag with a short justification makes the precondition explicit and self-documents the conditions under which it must be revisited. - -## Current tooling state - -Three Prowler scans run weekly. Their mute paths today: - -| Scan | Mute mechanism | File(s) | -|------|----------------|---------| -| K8s CIS (Sunday) | Prowler `--mutelist-file`, merged from ConfigMap | `argocd/manifests/prowler/mutelist/*.yaml` | -| IaC (Saturday) | Trivy `--ignorefile` shim (Prowler's `--mutelist-file` is a no-op for IaC) | `argocd/manifests/prowler/mutelist/trivyignore.yaml` | -| Container Images (Saturday) | **None — `cronjob-image-scan.yaml` does not pass `--mutelist-file`** | n/a | - -The image scan has never been wired to a mutelist. The CSV reports do contain a `MUTED` column, but it is always `False` because no mutelist is supplied. All 14k+ image findings flow through to `review-compliance-reports` unfiltered. - -The mute tag convention is consistent across the two configured scans: each entry's `Description:` (or `statement:` for trivyignore) starts with `CC: . `. `mise run review-compensating-controls` greps for those IDs to find every file that depends on each control. There is no NA tag, no RA tag, and no expiry field. - -## Proposed model - -### Tag prefixes - -Extend the description-prefix convention: - -- `CC: . ` — references an entry in `compensating-controls.yaml`. Existing convention, unchanged. -- `NA: . ` — environmental precondition fails. Reason should be specific enough that a reviewer can verify it (e.g. `NA: no 32-bit runtimes`, not `NA: doesn't apply`). -- `RA: ; expires . ` — explicit risk acceptance with a hard expiry. Past the expiry, re-review is mandatory. - -Tag choice is exclusive: a given mute is one of CC, NA, or RA. If two reasons apply, pick the strongest — CC > RA > NA. - -### Tooling changes required - -1. **Wire the image scan to a mutelist.** Add `argocd/manifests/prowler/mutelist/image-cves.yaml`, mount-and-merge it the same way `cronjob.yaml` mounts its mutelist parts, and pass `--mutelist-file` to `prowler image`. Verify experimentally that `prowler image` honors the flag — Prowler's behavior across providers is inconsistent, and the IaC provider notably does not. If `prowler image` ignores it, fall back to post-scan filtering inside `review-compliance-reports`. - -2. **Teach `review-compensating-controls` (or a sibling) to surface NA and RA entries.** CCs already get a staleness queue. NAs should appear in a separate queue keyed on the reason text — when an NA reason becomes false (e.g. we do introduce a 32-bit runtime), every NA mute citing that reason must be reopened. RAs should sort by expiry date, with anything past expiry flagged red. - -3. **Expiry parsing.** RA tags carry a hard date. The simplest path is to parse it from the description string at review time. A more durable path is to extend the mutelist YAML schema with a structured `expires:` field and a small wrapper that strips it before passing the file to Prowler. Either works; the structured field is friendlier to editors. - -### Out of scope (for now) - -- Changing the underlying Prowler mutelist YAML schema. Stay within the `Mutelist:` shape Prowler expects. -- Migrating existing `CC:` entries. The current set is genuinely CCs and should stay tagged that way. -- Building an issue-tracker integration. Todoist is the source of truth for "remember to re-review this" until that scales painfully. - -## Order of operations - -When this work is picked up, the suggested sequence is: - -1. **Scope and confirm.** Re-read this article, confirm the model still fits, adjust if not. -2. **Wire the image-scan mutelist.** Smallest atomic change; produces immediate value (the CVE-2026-31789 mute can land as the first NA entry). -3. **Add the NA convention.** Update [[read-compliance-reports]] and [[review-compensating-controls]] how-tos to describe the three tag prefixes. The convention can land before tooling supports it — review will just be manual until tooling catches up. -4. **Extend the review tools.** Add NA and RA queues to `review-compensating-controls` (or a new task). At this point, parse expiry from RA descriptions. -5. **Optionally: structured expiry.** If RA entries become common, migrate to a structured `expires:` YAML field with a wrapper that filters it out before Prowler reads the file. - -The first three steps are a coherent C1. Steps 4–5 can be split off if scope creeps. - -## Related - -- [[read-compliance-reports]] — the weekly review process this feeds into -- [[review-compensating-controls]] — current CC review tooling -- [[security-model]] — overall security posture -- [[prowler]] — scanner reference -- [[agent-change-process]] — how to scope and execute the implementation diff --git a/docs/how-to/operations/read-compliance-reports.md b/docs/how-to/operations/read-compliance-reports.md index 75fd3ab..e676ad5 100644 --- a/docs/how-to/operations/read-compliance-reports.md +++ b/docs/how-to/operations/read-compliance-reports.md @@ -80,7 +80,7 @@ Not all failures require action. Common expected failures in our minikube cluste 1. **Triage** — review new failures, distinguish real issues from expected noise 2. **Remediate** — fix what you can (pod security contexts, RBAC tightening) -3. **Mutelist** — suppress expected/accepted failures via Prowler's `--mutelist-file` to reduce noise in future scans +3. **Mutelist** — suppress expected/accepted failures by adding a Resource entry under the matching Check in `argocd/manifests/prowler/mutelist/*.yaml` with a free-form `Description` explaining why 4. **Track** — compare reports over time to spot regressions ## Related diff --git a/docs/how-to/operations/record-review-evidence.md b/docs/how-to/operations/record-review-evidence.md deleted file mode 100644 index 9de4e37..0000000 --- a/docs/how-to/operations/record-review-evidence.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -title: Record Review Evidence -modified: 2026-04-01 -last-reviewed: 2026-04-01 -tags: - - how-to - - security - - compliance ---- - -# Record Review Evidence - -How review evidence *would* be captured after a [[review-compensating-controls|compensating control review]], to make the review auditable under a compliance framework. - -blumeops does not currently collect review evidence. This card documents the target process for reference and practice. - -## Why Record Evidence? - -Reviewing a control and updating `last-reviewed` proves the review *happened* but not *what was checked*. Under frameworks like PCI DSS v4.0, a QSA needs to see dated, immutable evidence that the reviewer verified the control and that an appropriate party accepted the residual risk. Compliance platforms like Drata automate this collection, but the underlying artifacts are the same whether you use a platform or a directory of files. - -## What Evidence Would Be Captured - -For each control reviewed, artifacts should answer: - -1. **Who reviewed it** — reviewer name, date -2. **What was verified** — the specific checks performed (e.g., Tailscale ACL policy snapshot, `tailscale status` output, kubectl auth checks) -3. **What was found** — the outcome: control still in effect, circumstances changed, or control invalidated -4. **Residual risk** — what the control does *not* cover (the gap a QSA will ask about) -5. **Acceptance** — formal sign-off that the residual risk is accepted by an appropriate party (reviewer + approver, typically a manager or CTO) - -Supporting artifacts would include command output, policy snapshots, screenshots, or API responses — anything that demonstrates the verification was actually performed. - -## PCI DSS Context - -Under PCI DSS v4.0, compensating controls require a **Compensating Control Worksheet (CCW)** that maps each control to the original requirement it substitutes for. The CCW fields are: - -- **Original requirement** — the specific PCI DSS requirement not directly met -- **Constraint** — why direct compliance isn't feasible -- **Compensating control definition** — what is done instead -- **Risk addressed** — how the control mitigates the original threat -- **Residual risk** — what remains unmitigated -- **Validation procedure** — steps to verify (what `notes` captures in `compensating-controls.yaml`) - -Req 12.3.2 mandates review **at least annually** (quarterly is typical for Level 1 Service Providers). In a platform like Drata, these map to Controls with uploaded Evidence and review workflows requiring sign-off from both the reviewer and an approver. - -## Related - -- [[review-compensating-controls]] — The technical review process -- [[security]] — Security posture overview -- [[read-compliance-reports]] — Interpreting Prowler/Kingfisher reports diff --git a/docs/how-to/operations/review-compensating-controls.md b/docs/how-to/operations/review-compensating-controls.md deleted file mode 100644 index 8a32d98..0000000 --- a/docs/how-to/operations/review-compensating-controls.md +++ /dev/null @@ -1,80 +0,0 @@ ---- -title: Review Compensating Controls -modified: 2026-03-30 -last-reviewed: 2026-03-30 -tags: - - how-to - - security - - maintenance ---- - -# Review Compensating Controls - -How to periodically review compensating controls that justify suppressed security findings. - -## Review by Staleness - -Show controls sorted by when they were last reviewed (most stale first): - -```bash -mise run review-compensating-controls -``` - -This reads `compensating-controls.yaml` (repo root), sorts by `last-reviewed`, and displays the most stale control with all codebase references. It also searches for every file that references the control ID, so you can see exactly which suppressed findings depend on it. - -To show more entries: - -```bash -mise run review-compensating-controls --limit 20 -``` - -## What is a Compensating Control? - -A compensating control is a security measure that mitigates the risk a finding was designed to detect, when the finding itself cannot be directly remediated. For example: - -- **Finding:** API server does not enable AlwaysPullImages admission plugin -- **Risk:** Untrusted users could run pods using cached images they shouldn't have access to -- **Compensating control:** `single-user-cluster` — only the operator has kubectl access; no untrusted users can create pods - -Controls are documented in `compensating-controls.yaml` and referenced from security tool configurations (Prowler mutelist files, Kingfisher config, etc.) using the format `CC: `. - -A compensating control is only one of three structurally distinct ways to suppress a finding — see [[compliance-mute-categories]] for when to reach for a CC versus a not-applicable (`NA:`) or risk-accepted (`RA:`) tag instead. - -## Review Process - -For each control up for review: - -1. **Understand the risk.** Read each suppressed finding that references this control. What attack or misconfiguration does the original check guard against? - -2. **Verify the control is in effect.** Follow the verification steps in the control's `notes` field. For example, for `tailscale-network-isolation`, check that the cluster is not directly internet-exposed and Tailscale ACLs are enforced. - -3. **Assess whether the control actually mitigates the risk.** A compensating control should address the same threat the check was designed to catch, not just be a vaguely related security measure. If it doesn't hold up, either: - - Fix the underlying finding and remove the suppression - - Document a stronger or more specific compensating control - -4. **Check for changed circumstances.** Has the cluster gained new users? Has a service been exposed publicly? Has an operator added native support for the missing feature? Any of these could invalidate the control. - -5. **Update the review date.** Edit `compensating-controls.yaml` and set `last-reviewed` to today's date. Commit alongside any changes. - -## Adding a New Control - -When suppressing a new security finding, either map it to an existing control or add a new one: - -```yaml -- id: my-new-control - description: >- - What this control does and how it mitigates the specific risk. - created: 2026-03-30 - last-reviewed: 2026-03-30 - notes: >- - How to verify this control is still in effect. -``` - -Then reference it in the suppression configuration with `CC: my-new-control`. - -## Related - -- [[record-review-evidence]] — Capturing evidence artifacts for audit (aspirational) -- [[security]] — Security posture overview -- [[read-compliance-reports]] — Accessing and interpreting Prowler reports -- [[review-services]] — Periodic service version review (similar staleness pattern) diff --git a/docs/reference/operations/security.md b/docs/reference/operations/security.md index 18561a5..11c4df9 100644 --- a/docs/reference/operations/security.md +++ b/docs/reference/operations/security.md @@ -46,13 +46,7 @@ Security posture and compliance scanning for BlumeOps infrastructure. All compliance scan reports are stored on `sifaka:/volume1/reports/`. See [[read-compliance-reports]] for access and interpretation. -## Compensating controls - -Suppressed findings reference named compensating controls tracked in `compensating-controls.yaml` (repo root). Each control has a review date and verification steps. See [[review-compensating-controls]] for the review process. - -```bash -mise run review-compensating-controls -``` +Suppressed findings are kept in Prowler mutelist YAML under `argocd/manifests/prowler/mutelist/`. Each entry's `Description` field explains why the finding is muted; entries are reviewed ad-hoc rather than on a scheduled cadence. ## Known gaps diff --git a/mise-tasks/review-compensating-controls b/mise-tasks/review-compensating-controls deleted file mode 100755 index e92d302..0000000 --- a/mise-tasks/review-compensating-controls +++ /dev/null @@ -1,229 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.12" -# dependencies = ["pyyaml==6.0.3", "rich==15.0.0", "typer==0.25.0"] -# /// -#MISE description="Review the most stale compensating control" -#USAGE flag "--limit " default="10" help="Number of controls to show in the table" -"""Review compensating controls by staleness. - -Reads ``compensating-controls.yaml`` and sorts by ``last-reviewed``. -Shows a staleness table, then displays the most stale control with all -references found in the codebase. - -After reviewing, update the control entry: - - last-reviewed: YYYY-MM-DD - -Usage: mise run review-compensating-controls [--limit 10] -""" - -import subprocess -import sys -from datetime import date -from pathlib import Path -from typing import Annotated - -import typer -import yaml -from rich.console import Console -from rich.panel import Panel -from rich.table import Table - -CONTROLS_FILE = Path(__file__).parent.parent / "compensating-controls.yaml" -REPO_ROOT = Path(__file__).parent.parent - - -def load_controls(path: Path) -> list[dict]: - data = yaml.safe_load(path.read_text()) - return data.get("controls", []) - - -def parse_date(raw) -> date | None: - if raw is None: - return None - if isinstance(raw, date): - return raw - try: - return date.fromisoformat(str(raw)) - except ValueError: - return None - - -def find_references(control_id: str) -> list[str]: - """Find all files referencing a control ID using ripgrep.""" - try: - result = subprocess.run( - ["rg", "--no-heading", "-n", control_id, str(REPO_ROOT)], - capture_output=True, - text=True, - timeout=10, - ) - lines = result.stdout.strip().splitlines() - # Exclude the controls file itself and this script - return [ - ln - for ln in lines - if "compensating-controls.yaml" not in ln - and "review-compensating-controls" not in ln - ] - except (FileNotFoundError, subprocess.TimeoutExpired): - return [] - - -def main( - limit: Annotated[ - int, typer.Option(help="Number of controls to show in the table") - ] = 10, -) -> None: - console = Console() - today = date.today() - - if not CONTROLS_FILE.exists(): - console.print( - f"[bold red]Controls file not found:[/bold red] {CONTROLS_FILE}" - ) - raise typer.Exit(code=1) - - controls = load_controls(CONTROLS_FILE) - - # Parse dates and build sortable entries - entries: list[tuple[dict, date | None]] = [] - for ctrl in controls: - reviewed = parse_date(ctrl.get("last-reviewed")) - entries.append((ctrl, reviewed)) - - # Sort: never-reviewed first, then oldest - entries.sort(key=lambda e: (e[1] is not None, e[1] or date.min)) - - never_reviewed = sum(1 for _, r in entries if r is None) - - # --- Summary panel --- - console.print() - console.print( - Panel( - f"[bold]{len(entries)}[/bold] compensating controls, " - f"[bold red]{never_reviewed}[/bold red] never reviewed", - title="[bold]Compensating Control Review Queue[/bold]", - border_style="cyan", - ) - ) - console.print() - - # --- Staleness table --- - table = Table(show_header=True, header_style="bold") - table.add_column("#", justify="right") - table.add_column("Control ID") - table.add_column("Last Reviewed", justify="right") - table.add_column("Age (days)", justify="right") - table.add_column("Refs", justify="right") - - for i, (ctrl, reviewed) in enumerate(entries[:limit], 1): - control_id = ctrl["id"] - refs = len(find_references(control_id)) - - if reviewed is None: - table.add_row( - str(i), - f"[red]{control_id}[/red]", - "[red]never[/red]", - "[red]—[/red]", - str(refs), - ) - else: - age = (today - reviewed).days - style = "yellow" if age > 90 else "" - id_str = f"[{style}]{control_id}[/{style}]" if style else control_id - date_str = f"[{style}]{reviewed}[/{style}]" if style else str(reviewed) - age_str = f"[{style}]{age}[/{style}]" if style else str(age) - table.add_row(str(i), id_str, date_str, age_str, str(refs)) - - remaining = len(entries) - limit - if remaining > 0: - table.add_row("", f"[dim]… {remaining} more[/dim]", "", "", "") - - console.print(table) - console.print() - - # --- Most stale control detail --- - if not entries: - console.print("[bold red]No controls found![/bold red]") - raise typer.Exit(code=1) - - top_ctrl, top_reviewed = entries[0] - control_id = top_ctrl["id"] - refs = find_references(control_id) - - detail_lines = [ - f"[bold cyan]{control_id}[/bold cyan]", - f"[dim]Last reviewed: {top_reviewed or 'never'}[/dim]", - "", - f"[bold]Description:[/bold] {top_ctrl.get('description', '').strip()}", - ] - notes = top_ctrl.get("notes", "").strip() - if notes: - detail_lines.append(f"[bold]Notes:[/bold] {notes}") - - console.print( - Panel( - "\n".join(detail_lines), - title="[bold]Up For Review[/bold]", - border_style="green", - ) - ) - console.print() - - # --- References --- - if refs: - ref_table = Table( - show_header=True, header_style="bold", title="References in codebase" - ) - ref_table.add_column("File", style="cyan") - ref_table.add_column("Line") - - for ref in refs: - # rg output: file:line:content - parts = ref.split(":", 2) - if len(parts) >= 3: - filepath = parts[0].replace(str(REPO_ROOT) + "/", "") - line_no = parts[1] - content = parts[2].strip() - ref_table.add_row(f"{filepath}:{line_no}", content) - else: - ref_table.add_row(ref, "") - - console.print(ref_table) - else: - console.print( - f"[yellow]No references to '{control_id}' found in the codebase.[/yellow]" - ) - console.print() - - # --- Review checklist --- - checklist = [ - "[bold]Verification:[/bold]\n", - f"• {notes}\n" if notes else "", - "\n[bold]Review each reference:[/bold]\n", - "• For each muted finding referencing this control, confirm:\n", - " 1. The risk the original check guards against\n", - " 2. That this control actually mitigates that risk\n", - " 3. That the control is still in effect (not degraded or bypassed)\n", - "\n[bold]After review:[/bold]\n", - f"• Update compensating-controls.yaml: [cyan]last-reviewed: {today}[/cyan]\n", - "• If the control is no longer valid, either:\n", - " - Fix the underlying finding and remove the mute, or\n", - " - Document a new/updated compensating control\n", - "• Commit the change", - ] - - console.print( - Panel( - "".join(checklist), - title="[bold yellow]Review Guidance[/bold yellow]", - border_style="yellow", - ) - ) - - -if __name__ == "__main__": - typer.run(main) diff --git a/mise-tasks/review-compliance-reports b/mise-tasks/review-compliance-reports index bcbe090..a9146c8 100755 --- a/mise-tasks/review-compliance-reports +++ b/mise-tasks/review-compliance-reports @@ -143,7 +143,10 @@ def _kubectl(args: str, timeout: int = 15) -> subprocess.CompletedProcess: def run_node_verification(console: Console) -> None: """Verify node-level conditions that Prowler reports as MANUAL. - Compensating control: node-config-automated-verification + Prowler runs inside a pod and can't evaluate kubelet file permissions, + kubelet config arguments, etcd CA separation, or cluster-admin RBAC + bindings. We SSH into the minikube node and check each condition here, + failing loudly if any deviates from expected values. """ checks: list[tuple[str, str, bool]] = [] # (name, detail, passed) @@ -278,7 +281,7 @@ def run_node_verification(console: Console) -> None: table = Table( show_header=True, header_style="bold", - title="Node Verification (CC: node-config-automated-verification)", + title="Node Verification (out-of-band checks for MANUAL findings)", ) table.add_column("Check") table.add_column("Detail") @@ -528,8 +531,8 @@ def summarize_report( Panel( f"[bold yellow]{len(latest['unmuted'])} unmuted failure(s) " f"need triage.[/bold yellow]\n\n" - "For each: remediate or mute " - "(add to mutelist + compensating control).", + "For each: remediate, or add a Resource entry to the " + "matching check in argocd/manifests/prowler/mutelist/.", title=f"{label} Verdict", border_style="yellow", ) @@ -653,7 +656,6 @@ def main( ) # --- Node-level MANUAL check verification --- - # Compensating control: node-config-automated-verification # These checks verify conditions Prowler reports as MANUAL because it # runs inside a pod and cannot evaluate them directly. run_node_verification(console) From d02bf062af2cd3a867cd5c4da17686ae0806fa0b Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Fri, 22 May 2026 21:29:11 -0700 Subject: [PATCH 393/430] C0: review 1password reference card Added vault split (blumeops vs Personal), noted onepassword-connect runs on both indri and ringtail, and lifted op CLI guidance from agent memory into the card. Bumped last-reviewed. --- docs/changelog.d/+review-1password-doc.doc.md | 1 + docs/reference/services/1password.md | 37 ++++++++++++------- 2 files changed, 24 insertions(+), 14 deletions(-) create mode 100644 docs/changelog.d/+review-1password-doc.doc.md diff --git a/docs/changelog.d/+review-1password-doc.doc.md b/docs/changelog.d/+review-1password-doc.doc.md new file mode 100644 index 0000000..bba9591 --- /dev/null +++ b/docs/changelog.d/+review-1password-doc.doc.md @@ -0,0 +1 @@ +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. diff --git a/docs/reference/services/1password.md b/docs/reference/services/1password.md index 4489194..5ad50da 100644 --- a/docs/reference/services/1password.md +++ b/docs/reference/services/1password.md @@ -1,6 +1,7 @@ --- title: 1Password -modified: 2026-02-10 +modified: 2026-05-22 +last-reviewed: 2026-05-22 tags: - service - secrets @@ -8,15 +9,22 @@ tags: # 1Password -Root credential store for all BlumeOps secrets, synced to Kubernetes via External Secrets Operator. +Root credential store for all BlumeOps secrets. Kubernetes workloads read items via [[external-secrets|External Secrets Operator]]; humans and agents read via the `op` CLI. -## Architecture +## Vaults + +| Vault | Purpose | +|-------|---------| +| `blumeops` | Infrastructure secrets — referenced by ExternalSecret manifests and scripts. | +| `Personal` | Human login credentials keyed by URL for autofill. Not consumed by infrastructure. | + +## Kubernetes Integration ``` 1Password Cloud | v -1Password Connect (namespace: 1password) +1Password Connect (namespace: 1password, deployed on both indri and ringtail) | v External Secrets Operator (namespace: external-secrets) @@ -25,15 +33,15 @@ External Secrets Operator (namespace: external-secrets) Native Kubernetes Secrets ``` -## Vault +**ClusterSecretStore:** `onepassword-blumeops` (same name on both clusters). -The `blumeops` vault contains all infrastructure credentials. +Services reference 1Password items via `ExternalSecret` manifests. Both `minikube-indri` and `k3s-ringtail` run their own `onepassword-connect` deployment talking to the same vault. -## Kubernetes Integration +## Direct Access -**ClusterSecretStore:** `onepassword-blumeops` +Prefer `op read "op://vault/item/field"` over `op item get --fields` in scripts and IaC — `op item get --fields` wraps multi-line values in quotes, corrupting them. `op item get` without flags is fine for exploring item metadata. -Services reference 1Password items via `ExternalSecret` manifests. +If an item name contains special characters (e.g. parentheses), use the item ID instead of the name in the `op://` path. ## Disaster Recovery Backup @@ -41,8 +49,9 @@ The `mise run op-backup` task encrypts a `.1pux` vault export and transfers it t ## Related -- [[argocd]] - Uses secrets for git access -- [[postgresql]] - Database credentials -- [[run-1password-backup]] - Periodic backup procedure -- [[restore-1password-backup]] - Recovery from backup -- [[borgmatic]] - Backup system +- [[external-secrets]] — Kubernetes operator that consumes ClusterSecretStore +- [[argocd]] — Uses secrets for git access +- [[postgresql]] — Database credentials +- [[run-1password-backup]] — Periodic backup procedure +- [[restore-1password-backup]] — Recovery from backup +- [[borgmatic]] — Backup system From 08a1cb164a3f96b408979ecda560a9f7dbf768b4 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Fri, 22 May 2026 21:36:13 -0700 Subject: [PATCH 394/430] C0: fix 1password export filename in backup how-to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1Password's desktop app names exports as 1PasswordExport--.1pux automatically — you can't choose the name. Procedure now points the task at that glob. --- .../+1password-backup-doc-export-name.doc.md | 1 + docs/how-to/operations/run-1password-backup.md | 12 +++++------- 2 files changed, 6 insertions(+), 7 deletions(-) create mode 100644 docs/changelog.d/+1password-backup-doc-export-name.doc.md diff --git a/docs/changelog.d/+1password-backup-doc-export-name.doc.md b/docs/changelog.d/+1password-backup-doc-export-name.doc.md new file mode 100644 index 0000000..6c4d262 --- /dev/null +++ b/docs/changelog.d/+1password-backup-doc-export-name.doc.md @@ -0,0 +1 @@ +Fixed the export-filename step in [[run-1password-backup]]: 1Password's desktop app names the export `1PasswordExport--.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`. diff --git a/docs/how-to/operations/run-1password-backup.md b/docs/how-to/operations/run-1password-backup.md index b0807da..0dc9ec9 100644 --- a/docs/how-to/operations/run-1password-backup.md +++ b/docs/how-to/operations/run-1password-backup.md @@ -26,20 +26,18 @@ How to export and encrypt your 1Password vaults for inclusion in [[borgmatic]] b 1. Open the 1Password desktop app 2. **File > Export > All Vaults** 3. Choose **1PUX** format -4. Save to `~/Documents/1Password-export.1pux` +4. Save to `~/Documents/` — 1Password names the file `1PasswordExport--.1pux` automatically; don't bother renaming it, pass the path to the task in the next step ### 2. Run the Backup Task -```fish -mise run op-backup -``` - -Or, if you saved the export to a non-default location: +Pass the exported file's path: ```fish -mise run op-backup ~/path/to/export.1pux +mise run op-backup ~/Documents/1PasswordExport-*.1pux ``` +(If only one export exists in `~/Documents/`, the glob expands cleanly. Otherwise, paste the full path.) + The task will: 1. Prompt for the `.1pux` path if not provided From 57fd88b2698e87b5767d90c1a82151b1db87f446 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Fri, 22 May 2026 21:50:43 -0700 Subject: [PATCH 395/430] C0: fix op item edit syntax in zot key rotation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 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 uses an inline assignment via a local fish variable. --- docs/changelog.d/+zot-ci-rotation-op-syntax.doc.md | 1 + docs/reference/services/zot.md | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 docs/changelog.d/+zot-ci-rotation-op-syntax.doc.md diff --git a/docs/changelog.d/+zot-ci-rotation-op-syntax.doc.md b/docs/changelog.d/+zot-ci-rotation-op-syntax.doc.md new file mode 100644 index 0000000..ec8834f --- /dev/null +++ b/docs/changelog.d/+zot-ci-rotation-op-syntax.doc.md @@ -0,0 +1 @@ +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. diff --git a/docs/reference/services/zot.md b/docs/reference/services/zot.md index d00a200..b01a6ce 100644 --- a/docs/reference/services/zot.md +++ b/docs/reference/services/zot.md @@ -56,8 +56,9 @@ The `zot-ci` API key expires every **90 days**. To rotate: 5. Generate a new API key, copy it to clipboard 6. Update 1Password: ```fish - pbpaste | op item edit "Forgejo Secrets" --vault blumeops "zot-ci-api[password]=-" + set -l NEWKEY (pbpaste); op item edit "Forgejo Secrets" --vault blumeops "zot-ci-api[password]=$NEWKEY"; set -e NEWKEY ``` + The value is briefly visible to other `ps`-readers on this machine (single-user mac, acceptable tradeoff). The older `pbpaste | op item edit ... "field[password]=-"` stdin syntax was rejected by op 2.34 as "invalid JSON" — recent op versions treat piped input as a full JSON template. 7. Sync to Forgejo: `mise run provision-indri -- --tags forgejo_actions_secrets` ## Related From 35ae171783ca7ac54bc57fc1cc23e7a171b36782 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 27 May 2026 07:15:07 -0700 Subject: [PATCH 396/430] C0: fix sync button location in manage-forgejo-mirrors The verify step pointed to the main repo page, but the "Synchronize now" button is in the Mirror settings section of the settings page. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/changelog.d/+manage-forgejo-mirrors-sync-location.doc.md | 1 + docs/how-to/configuration/manage-forgejo-mirrors.md | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) create mode 100644 docs/changelog.d/+manage-forgejo-mirrors-sync-location.doc.md diff --git a/docs/changelog.d/+manage-forgejo-mirrors-sync-location.doc.md b/docs/changelog.d/+manage-forgejo-mirrors-sync-location.doc.md new file mode 100644 index 0000000..f71fc81 --- /dev/null +++ b/docs/changelog.d/+manage-forgejo-mirrors-sync-location.doc.md @@ -0,0 +1 @@ +Fix manage-forgejo-mirrors verify step — sync button is on the repo settings page ("Synchronize now"), not the main repo page. diff --git a/docs/how-to/configuration/manage-forgejo-mirrors.md b/docs/how-to/configuration/manage-forgejo-mirrors.md index 9c0e113..5d150dc 100644 --- a/docs/how-to/configuration/manage-forgejo-mirrors.md +++ b/docs/how-to/configuration/manage-forgejo-mirrors.md @@ -137,8 +137,8 @@ Return to [GitHub token settings](https://github.com/settings/tokens?type=beta) Trigger a manual sync on one mirror to confirm the new PAT works: -1. Go to any mirror repo on forge (e.g., `mirrors/cloudnative-pg`) -2. Click the sync button (circular arrows icon) next to the mirror status +1. Go to any mirror repo's settings page on forge (e.g., `https://forge.eblu.me/mirrors/cloudnative-pg/settings`) +2. In the "Mirror settings" section, click "Synchronize now" 3. Confirm the sync completes without errors ## Related From c09bd5b6129ce688722b305801100ae1199c9036 Mon Sep 17 00:00:00 2001 From: Erich Blume <725328+eblume@users.noreply.github.com> Date: Wed, 27 May 2026 11:54:32 -0700 Subject: [PATCH 397/430] C0: cap systemd-coredump on ringtail to stop game-crash lockups Wine/Proton game segfaults (e.g. Diablo IV) produced multi-GB cores that systemd-coredump spent minutes compressing to disk, pinning the CPU and freezing the desktop. Cap ProcessSizeMax/ExternalSizeMax at 1G (oversized cores logged but skipped) and MaxUse at 2G to bound the store. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../+ringtail-coredump-size-cap.infra.md | 1 + nixos/ringtail/configuration.nix | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) create mode 100644 docs/changelog.d/+ringtail-coredump-size-cap.infra.md diff --git a/docs/changelog.d/+ringtail-coredump-size-cap.infra.md b/docs/changelog.d/+ringtail-coredump-size-cap.infra.md new file mode 100644 index 0000000..824b2df --- /dev/null +++ b/docs/changelog.d/+ringtail-coredump-size-cap.infra.md @@ -0,0 +1 @@ +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. diff --git a/nixos/ringtail/configuration.nix b/nixos/ringtail/configuration.nix index e8c634a..f01ce9f 100644 --- a/nixos/ringtail/configuration.nix +++ b/nixos/ringtail/configuration.nix @@ -609,6 +609,22 @@ in AllowSuspendThenHibernate=no ''; + # Cap systemd-coredump. Wine/Proton games (Diablo IV, etc.) segfault + # regularly and dump multi-GB cores; with the stock (effectively unbounded) + # limits, systemd-coredump then spends minutes streaming and compressing the + # dump to disk — e.g. a single D4 crash produced a 4.6G core, read 13.7G and + # wrote 17.4G, pinning the CPU and locking up the desktop for ~3.5 minutes. + # Those cores are useless anyway: Nix .so files carry no build-id, so no + # backtrace can be generated. Capping uncompressed size at 1G makes oversized + # cores get logged-but-skipped (the kernel stops dumping once we stop reading) + # while real service cores (well under 1G) are still captured. MaxUse bounds + # the on-disk store so frequent game crashes can't accumulate (was at 8.6G). + systemd.coredump.extraConfig = '' + ProcessSizeMax=1G + ExternalSizeMax=1G + MaxUse=2G + ''; + # NixOS release system.stateVersion = "25.11"; } From 753fa9cb6317108ab8701e1f58ec1ba7c991d211 Mon Sep 17 00:00:00 2001 From: Erich Blume <725328+eblume@users.noreply.github.com> Date: Wed, 27 May 2026 12:59:29 -0700 Subject: [PATCH 398/430] C0: disable VRR on ringtail DP-1 to stop OMEN panel flicker The OMEN 27i IPS pumps brightness when its refresh swings into the low VRR range during low-framerate content (game cutscenes), producing a ~20Hz flicker that compounds over a session until a reboot. GPU health is clean (no Xid/ECC/thermal); pinning fixed 165Hz eliminates it. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/changelog.d/+ringtail-vrr-flicker.bugfix.md | 1 + nixos/ringtail/configuration.nix | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 docs/changelog.d/+ringtail-vrr-flicker.bugfix.md diff --git a/docs/changelog.d/+ringtail-vrr-flicker.bugfix.md b/docs/changelog.d/+ringtail-vrr-flicker.bugfix.md new file mode 100644 index 0000000..cb23344 --- /dev/null +++ b/docs/changelog.d/+ringtail-vrr-flicker.bugfix.md @@ -0,0 +1 @@ +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. diff --git a/nixos/ringtail/configuration.nix b/nixos/ringtail/configuration.nix index f01ce9f..bc893d5 100644 --- a/nixos/ringtail/configuration.nix +++ b/nixos/ringtail/configuration.nix @@ -337,7 +337,12 @@ in output = { "DP-1" = { mode = "2560x1440@165Hz"; - adaptive_sync = "on"; + # VRR off: the OMEN 27i IPS pumps gamma/brightness when the panel + # refresh swings into its low VRR range (e.g. low-fps game + # cutscenes), producing a ~20Hz flicker that compounds over a long + # session until a reboot. Fixed refresh at 165Hz eliminates it. + # If you want VRR back, cap in-game fps so refresh never dips low. + adaptive_sync = "off"; bg = "~/.config/sway/wallpaper.jpg fill"; }; }; From c00d7db5079e78772e5e7e3780d7594baa009bd4 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Thu, 28 May 2026 06:01:57 -0700 Subject: [PATCH 399/430] Recurring maintenance batch (2026-05-27) (#360) Bundle of recurring overdue tasks: - Ringtail flake update - Security & compliance report review - Tooling deps bump (prek, fly, mise, forgejo workflows) - Top stale doc review - Top stale service review (if trivial) Larger items (service version bumps requiring upgrades, non-local container migration) split out as separate PRs. Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/360 --- .../recurring-maintenance-2026-05-27.doc.md | 1 + .../recurring-maintenance-2026-05-27.infra.md | 4 ++++ docs/reference/infrastructure/indri.md | 9 +++++++-- fly/Dockerfile | 8 ++++---- mise-tasks/branch-cleanup | 2 +- mise-tasks/container-build-and-release | 2 +- mise-tasks/container-list | 2 +- mise-tasks/container-version-check | 2 +- mise-tasks/dns-acme-cleanup | 2 +- mise-tasks/docs-mikado | 2 +- mise-tasks/docs-preview | 2 +- mise-tasks/docs-review | 2 +- mise-tasks/docs-review-stale | 2 +- mise-tasks/mikado-branch-invariant-check | 2 +- mise-tasks/op-backup | 2 +- mise-tasks/pr-comments | 2 +- mise-tasks/prune-ringtail-generations | 2 +- mise-tasks/review-compliance-reports | 2 +- mise-tasks/runner-logs | 2 +- mise-tasks/service-review | 2 +- mise-tasks/spork-create | 2 +- nixos/ringtail/flake.lock | 18 +++++++++--------- prek.toml | 8 ++++---- 23 files changed, 46 insertions(+), 36 deletions(-) create mode 100644 docs/changelog.d/recurring-maintenance-2026-05-27.doc.md create mode 100644 docs/changelog.d/recurring-maintenance-2026-05-27.infra.md diff --git a/docs/changelog.d/recurring-maintenance-2026-05-27.doc.md b/docs/changelog.d/recurring-maintenance-2026-05-27.doc.md new file mode 100644 index 0000000..af30489 --- /dev/null +++ b/docs/changelog.d/recurring-maintenance-2026-05-27.doc.md @@ -0,0 +1 @@ +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. diff --git a/docs/changelog.d/recurring-maintenance-2026-05-27.infra.md b/docs/changelog.d/recurring-maintenance-2026-05-27.infra.md new file mode 100644 index 0000000..f2d48ad --- /dev/null +++ b/docs/changelog.d/recurring-maintenance-2026-05-27.infra.md @@ -0,0 +1,4 @@ +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. diff --git a/docs/reference/infrastructure/indri.md b/docs/reference/infrastructure/indri.md index cbb2a0f..67652ca 100644 --- a/docs/reference/infrastructure/indri.md +++ b/docs/reference/infrastructure/indri.md @@ -1,6 +1,7 @@ --- title: Indri -modified: 2026-02-19 +modified: 2026-05-27 +last-reviewed: 2026-05-27 tags: - infrastructure - host @@ -15,6 +16,7 @@ Primary BlumeOps server. Mac Mini M1 (2020). | Property | Value | |----------|-------| | **Model** | Mac mini M1, 2020 (Macmini9,1) | +| **CPU / RAM** | 8 cores / 16 GB | | **Storage** | 2TB internal SSD | | **macOS** | 15.7.3 (Sequoia) | | **Tailscale hostname** | `indri.tail8d86e.ts.net` | @@ -30,9 +32,12 @@ Primary BlumeOps server. Mac Mini M1 (2020). - [[borgmatic]] - Backup system - [[alloy|Alloy]] - Metrics/logs collector - [[caddy]] - Reverse proxy for `*.ops.eblu.me` +- [[devpi]] - PyPI mirror (LaunchAgent) +- [[cv]] - Static CV site, served by Caddy +- [[docs]] - Quartz-built docs site, served by Caddy **Kubernetes (via minikube):** -- [[apps|Most k8s applications]] (Frigate, ntfy migrated to [[ringtail]] k3s) +- [[apps|Most k8s applications]]. A growing set of apps (Authentik, Frigate, ntfy, Immich, Homepage, Shower, Kingfisher, alloy-ringtail) now run on [[ringtail]]'s k3s instead. Long-term plan is to decommission indri's minikube entirely. **GUI Applications (manual start required):** - Docker Desktop - Container runtime for minikube diff --git a/fly/Dockerfile b/fly/Dockerfile index eae8c35..d4e7a18 100644 --- a/fly/Dockerfile +++ b/fly/Dockerfile @@ -1,5 +1,5 @@ -# nginx 1.30.0-alpine -FROM nginx@sha256:0272e4604ed93c1792f03695a033a6e8546840f86e0de20a884bb17d2c924883 +# nginx 1.30.1-alpine +FROM nginx@sha256:c819f83c54b0361f5557601bf5eb4943d09360e7a7fdf426afc466570f45874d # Copy tailscale binaries from official image (v1.94.2) COPY --from=docker.io/tailscale/tailscale@sha256:95e528798bebe75f39b10e74e7051cf51188ee615934f232ba7ad06a3390ffa1 \ @@ -13,8 +13,8 @@ RUN mkdir -p /var/run/tailscale /var/lib/tailscale \ && apk add --no-cache fail2ban \ && rm -f /etc/fail2ban/jail.d/alpine-ssh.conf -# Copy Alloy binary from official image (v1.16.0, Ubuntu-based, needs libc6-compat) -COPY --from=docker.io/grafana/alloy@sha256:6e00cf7c5a692ff5f24844529416ed017d76fce922f8199004e73d5eca46b6b8 \ +# Copy Alloy binary from official image (v1.16.1, Ubuntu-based, needs libc6-compat) +COPY --from=docker.io/grafana/alloy@sha256:51aeb9d829239345070619dad3edd6873186f913c84f45b365b74574fcb38ec0 \ /bin/alloy /usr/local/bin/alloy RUN mkdir -p /var/log/nginx /etc/alloy /tmp/alloy-data diff --git a/mise-tasks/branch-cleanup b/mise-tasks/branch-cleanup index 575c9a1..a538880 100755 --- a/mise-tasks/branch-cleanup +++ b/mise-tasks/branch-cleanup @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.12" -# dependencies = ["httpx==0.28.1", "rich==15.0.0", "typer==0.25.0"] +# dependencies = ["httpx==0.28.1", "rich==15.0.0", "typer==0.26.2"] # /// #MISE description="Delete branches that have been merged into main (local and remote)" #MISE alias="bc" diff --git a/mise-tasks/container-build-and-release b/mise-tasks/container-build-and-release index ba569e7..85e6cb8 100755 --- a/mise-tasks/container-build-and-release +++ b/mise-tasks/container-build-and-release @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.12" -# dependencies = ["typer==0.25.0", "httpx==0.28.1"] +# dependencies = ["typer==0.26.2", "httpx==0.28.1"] # /// #MISE description="Trigger container build workflows via Forgejo API" #USAGE arg "" help="Container name (directory under containers/)" diff --git a/mise-tasks/container-list b/mise-tasks/container-list index 26639f2..7dad346 100755 --- a/mise-tasks/container-list +++ b/mise-tasks/container-list @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.12" -# dependencies = ["httpx==0.28.1", "rich==15.0.0", "typer==0.25.0"] +# dependencies = ["httpx==0.28.1", "rich==15.0.0", "typer==0.26.2"] # /// #MISE description="List available containers and their recent tags" #USAGE arg "[name]" help="Optional container name to filter output" diff --git a/mise-tasks/container-version-check b/mise-tasks/container-version-check index 4ebe3b6..06f96ae 100755 --- a/mise-tasks/container-version-check +++ b/mise-tasks/container-version-check @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.12" -# dependencies = ["pyyaml==6.0.3", "rich==15.0.0", "typer==0.25.0"] +# dependencies = ["pyyaml==6.0.3", "rich==15.0.0", "typer==0.26.2"] # /// #MISE description="Validate container version consistency across container.py, Dockerfiles, nix derivations, and service-versions.yaml" #USAGE flag "--all-files" help="Check all containers, not just changed ones" diff --git a/mise-tasks/dns-acme-cleanup b/mise-tasks/dns-acme-cleanup index 432a6ce..3a53b11 100755 --- a/mise-tasks/dns-acme-cleanup +++ b/mise-tasks/dns-acme-cleanup @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.12" -# dependencies = ["httpx==0.28.1", "rich==15.0.0", "typer==0.25.0"] +# dependencies = ["httpx==0.28.1", "rich==15.0.0", "typer==0.26.2"] # /// #MISE description="Delete orphaned ACME challenge TXT records in eblu.me" #USAGE flag "--dry-run" help="List orphans without deleting" diff --git a/mise-tasks/docs-mikado b/mise-tasks/docs-mikado index eea052f..c632e46 100755 --- a/mise-tasks/docs-mikado +++ b/mise-tasks/docs-mikado @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.12" -# dependencies = ["httpx==0.28.1", "pyyaml==6.0.3", "rich==15.0.0", "typer==0.25.0"] +# dependencies = ["httpx==0.28.1", "pyyaml==6.0.3", "rich==15.0.0", "typer==0.26.2"] # /// #MISE description="View active Mikado dependency chains for C2 changes" #USAGE arg "[card]" help="Card stem to show chain for" diff --git a/mise-tasks/docs-preview b/mise-tasks/docs-preview index faa79af..9e0bd16 100755 --- a/mise-tasks/docs-preview +++ b/mise-tasks/docs-preview @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.12" -# dependencies = ["pyyaml==6.0.3", "rich==15.0.0", "typer==0.25.0"] +# dependencies = ["pyyaml==6.0.3", "rich==15.0.0", "typer==0.26.2"] # /// #MISE description="Build docs with Dagger and serve locally, opening to a specific card" #USAGE arg "" help="Card path relative to docs/, e.g. how-to/knowledgebase/review-documentation" diff --git a/mise-tasks/docs-review b/mise-tasks/docs-review index d07904d..12e301f 100755 --- a/mise-tasks/docs-review +++ b/mise-tasks/docs-review @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.12" -# dependencies = ["pyyaml==6.0.3", "rich==15.0.0", "typer==0.25.0"] +# dependencies = ["pyyaml==6.0.3", "rich==15.0.0", "typer==0.26.2"] # /// #MISE description="Review the most stale documentation card by last-reviewed date" #USAGE flag "--limit " default="15" help="Number of docs to show in the table" diff --git a/mise-tasks/docs-review-stale b/mise-tasks/docs-review-stale index 4449213..0c5490e 100755 --- a/mise-tasks/docs-review-stale +++ b/mise-tasks/docs-review-stale @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.12" -# dependencies = ["rich==15.0.0", "typer==0.25.0"] +# dependencies = ["rich==15.0.0", "typer==0.26.2"] # /// #MISE description="Report docs by git-last-modified date, highlighting stale ones" #USAGE flag "--threshold " default="180" help="Days before a doc is considered stale" diff --git a/mise-tasks/mikado-branch-invariant-check b/mise-tasks/mikado-branch-invariant-check index 1f0fbcf..3135bf2 100755 --- a/mise-tasks/mikado-branch-invariant-check +++ b/mise-tasks/mikado-branch-invariant-check @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.12" -# dependencies = ["rich==15.0.0", "typer==0.25.0"] +# dependencies = ["rich==15.0.0", "typer==0.26.2"] # /// #MISE description="Validate Mikado Branch Invariant on mikado/* branches" #USAGE arg "[commit_msg_file]" help="Commit message file (passed by commit-msg hook)" diff --git a/mise-tasks/op-backup b/mise-tasks/op-backup index 37a97a6..7db033b 100755 --- a/mise-tasks/op-backup +++ b/mise-tasks/op-backup @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.12" -# dependencies = ["rich==15.0.0", "typer==0.25.0"] +# dependencies = ["rich==15.0.0", "typer==0.26.2"] # /// #MISE description="Encrypt a 1Password .1pux export and send to indri for borgmatic" #USAGE arg "[export_path]" help="Path to .1pux export file (prompted if omitted)" diff --git a/mise-tasks/pr-comments b/mise-tasks/pr-comments index 7205617..39d7c9a 100755 --- a/mise-tasks/pr-comments +++ b/mise-tasks/pr-comments @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.12" -# dependencies = ["httpx==0.28.1", "rich==15.0.0", "typer==0.25.0"] +# dependencies = ["httpx==0.28.1", "rich==15.0.0", "typer==0.26.2"] # /// #MISE description="List unresolved comments on a PR" #USAGE arg "" help="Pull request number" diff --git a/mise-tasks/prune-ringtail-generations b/mise-tasks/prune-ringtail-generations index 2b8e3f9..2ad8dc8 100755 --- a/mise-tasks/prune-ringtail-generations +++ b/mise-tasks/prune-ringtail-generations @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.12" -# dependencies = ["rich==15.0.0", "typer==0.25.0"] +# dependencies = ["rich==15.0.0", "typer==0.26.2"] # /// #MISE description="Prune old NixOS generations on ringtail, preserving rollback safety" #MISE alias="prg" diff --git a/mise-tasks/review-compliance-reports b/mise-tasks/review-compliance-reports index a9146c8..24d2afc 100755 --- a/mise-tasks/review-compliance-reports +++ b/mise-tasks/review-compliance-reports @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.12" -# dependencies = ["rich==15.0.0", "typer==0.25.0", "pyyaml==6.0.3"] +# dependencies = ["rich==15.0.0", "typer==0.26.2", "pyyaml==6.0.3"] # /// #MISE description="Summarize the latest Prowler and Kingfisher compliance reports from sifaka" #USAGE flag "--full" help="Show all unmuted failures, not just new ones" diff --git a/mise-tasks/runner-logs b/mise-tasks/runner-logs index 9c988ee..3c5e8e3 100755 --- a/mise-tasks/runner-logs +++ b/mise-tasks/runner-logs @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.12" -# dependencies = ["httpx==0.28.1", "rich==15.0.0", "typer==0.25.0"] +# dependencies = ["httpx==0.28.1", "rich==15.0.0", "typer==0.26.2"] # /// #MISE description="List recent Forgejo Actions runs or fetch logs for a specific job" #USAGE arg "[run_number]" help="Run number to show jobs for (omit to list recent runs)" diff --git a/mise-tasks/service-review b/mise-tasks/service-review index 2d50e0b..f83b104 100755 --- a/mise-tasks/service-review +++ b/mise-tasks/service-review @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.12" -# dependencies = ["pyyaml==6.0.3", "rich==15.0.0", "typer==0.25.0"] +# dependencies = ["pyyaml==6.0.3", "rich==15.0.0", "typer==0.26.2"] # /// #MISE description="Review the most stale service for version freshness" #USAGE flag "--limit " default="15" help="Number of services to show in the table" diff --git a/mise-tasks/spork-create b/mise-tasks/spork-create index 92f4e5c..3f18563 100755 --- a/mise-tasks/spork-create +++ b/mise-tasks/spork-create @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.12" -# dependencies = ["httpx==0.28.1", "rich==15.0.0", "typer==0.25.0"] +# dependencies = ["httpx==0.28.1", "rich==15.0.0", "typer==0.26.2"] # /// #MISE description="Create a spork (floating-branch soft-fork) of a mirrored upstream project" #USAGE arg "" help="Repository name in the mirrors/ org on forge (e.g. kingfisher)" diff --git a/nixos/ringtail/flake.lock b/nixos/ringtail/flake.lock index 0f53d0e..0f0da7e 100644 --- a/nixos/ringtail/flake.lock +++ b/nixos/ringtail/flake.lock @@ -7,11 +7,11 @@ ] }, "locked": { - "lastModified": 1777713215, - "narHash": "sha256-8GzXDOXckDWwST8TY5DbwYFjdvQLlP7K9CLSVx6iTTo=", + "lastModified": 1779699611, + "narHash": "sha256-EcCaSTKnmg2o4wLKaN1aqQFomwyhO7ik0bX9COdyCas=", "owner": "nix-community", "repo": "disko", - "rev": "63b4e7e6cf75307c1d26ac3762b886b5b0247267", + "rev": "5ba0c9555c28685e57fa54c7a25e42c7efdbfc8d", "type": "github" }, "original": { @@ -27,11 +27,11 @@ ] }, "locked": { - "lastModified": 1778401693, - "narHash": "sha256-OVHdCqXXUF5UdGkH+FF2ZL06OLZjj2kvP2dIUmzVWoo=", + "lastModified": 1779506708, + "narHash": "sha256-QOD/CNm196nCJRheux/URi4/HE66fthdOMqCJoPP1Y0=", "owner": "nix-community", "repo": "home-manager", - "rev": "389b83002efc26f1145e89a6a8e6edc5a6435948", + "rev": "3ee51fbdac8c8bdfe1e7e1fcaba6520a563f394f", "type": "github" }, "original": { @@ -43,11 +43,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1778430510, - "narHash": "sha256-Ti+ZBvW6yrWWAg2szExVTwCd4qOJ3KlVr1tFHfyfi8Q=", + "lastModified": 1779467186, + "narHash": "sha256-nOesoDCiXcUftqbRBMz9tt4blI5PvljMWbm3kuCA+0s=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "8fd9daa3db09ced9700431c5b7ad0e8ba199b575", + "rev": "b77b3de8775677f84492abe84635f87b0e153f0f", "type": "github" }, "original": { diff --git a/prek.toml b/prek.toml index add7799..2c66b82 100644 --- a/prek.toml +++ b/prek.toml @@ -28,7 +28,7 @@ hooks = [{ id = "check-yaml", args = ["--unsafe"] }] # Secret detection (running both tools in parallel to compare coverage) [[repos]] repo = "https://github.com/trufflesecurity/trufflehog" -rev = "17456f8c7d042d8c82c9a8ca9e937231f9f42e26" # v3.95.2 +rev = "37b77001d0174ebec2fcca2bd83ff83a6d45a3ab" # v3.95.3 hooks = [ { id = "trufflehog", entry = "trufflehog git file://. --since-commit HEAD --no-verification --fail", stages = [ "pre-commit", @@ -38,7 +38,7 @@ hooks = [ [[repos]] repo = "https://github.com/mongodb/kingfisher" -rev = "9ddec4ab8b53653d4941e6b3fd4ff602ce91d81b" # v1.97.0 +rev = "6f560103cc6ea082ef4b80a9098e3f3111afb8bc" # v1.101.0 hooks = [ { id = "kingfisher", args = [ "scan", @@ -69,12 +69,12 @@ name = "ansible-lint" entry = "env ANSIBLE_ROLES_PATH=ansible/roles ansible-lint" language = "python" files = "^ansible/" -additional_dependencies = ["ansible-lint==26.4.0", "ansible-core==2.20.5"] +additional_dependencies = ["ansible-lint==26.4.0", "ansible-core==2.21.0"] # Python - ruff for linting and formatting [[repos]] repo = "https://github.com/astral-sh/ruff-pre-commit" -rev = "6fec9b7edb08fd9989088709d864a7826dc74e80" # v0.15.12 +rev = "0c7b6c989466a93942def1f84baf36ddfcd60c83" # v0.15.14 hooks = [{ id = "ruff", args = ["--fix"] }, { id = "ruff-format" }] # Python - ty type checker From 4e25180b0ae3ff212b7fc4d57d136f215a92c310 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Thu, 28 May 2026 07:13:13 -0700 Subject: [PATCH 400/430] C0: clone blumeops via tailnet on ringtail provision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch ringtail.yml from forge.eblu.me (Fly proxy, WAN) to forge.ops.eblu.me (Caddy on indri, tailnet). Ringtail is always on the tailnet — the WAN round-trip was overhead and made provision-ringtail fail any time Fly was slow or down. --- ansible/playbooks/ringtail.yml | 2 +- docs/changelog.d/+ringtail-clone-via-tailnet.infra.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 docs/changelog.d/+ringtail-clone-via-tailnet.infra.md diff --git a/ansible/playbooks/ringtail.yml b/ansible/playbooks/ringtail.yml index ee5604b..b05d67a 100644 --- a/ansible/playbooks/ringtail.yml +++ b/ansible/playbooks/ringtail.yml @@ -57,7 +57,7 @@ tasks: - name: Ensure blumeops repo is present ansible.builtin.git: - repo: "https://forge.eblu.me/eblume/blumeops.git" + repo: "https://forge.ops.eblu.me/eblume/blumeops.git" dest: /etc/blumeops version: "{{ ringtail_commit | default('main') }}" force: true diff --git a/docs/changelog.d/+ringtail-clone-via-tailnet.infra.md b/docs/changelog.d/+ringtail-clone-via-tailnet.infra.md new file mode 100644 index 0000000..d664163 --- /dev/null +++ b/docs/changelog.d/+ringtail-clone-via-tailnet.infra.md @@ -0,0 +1 @@ +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. From f6febb1f772e858a82d69e7baade4f526e550f97 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Thu, 28 May 2026 07:59:22 -0700 Subject: [PATCH 401/430] C0: switch fly proxy deploy strategy to immediate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bluegreen kept timing out — the new green machine couldn't reach "started" within Fly's 5-minute deploy budget. The cold-start sequence (tailscaled → tailscale up → wait-for-MagicDNS → nginx startup) eats most of that, leaving no headroom for healthcheck propagation. For a single-machine proxy, bluegreen offers little benefit anyway: no warm second instance, so trading 5-10s of downtime for predictable completion is the right call. --- docs/changelog.d/+fly-deploy-immediate-strategy.infra.md | 1 + fly/fly.toml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 docs/changelog.d/+fly-deploy-immediate-strategy.infra.md diff --git a/docs/changelog.d/+fly-deploy-immediate-strategy.infra.md b/docs/changelog.d/+fly-deploy-immediate-strategy.infra.md new file mode 100644 index 0000000..205bd6a --- /dev/null +++ b/docs/changelog.d/+fly-deploy-immediate-strategy.infra.md @@ -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 (~5–10s) but actually completes. diff --git a/fly/fly.toml b/fly/fly.toml index 11aac9c..6ccf29d 100644 --- a/fly/fly.toml +++ b/fly/fly.toml @@ -7,7 +7,7 @@ primary_region = "sjc" memory = "512mb" [deploy] -strategy = "bluegreen" +strategy = "immediate" [http_service] internal_port = 8080 From 4d1f4af25b9d2a55c1b0731e3a6b83259fc33dfa Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Thu, 28 May 2026 09:59:46 -0700 Subject: [PATCH 402/430] =?UTF-8?q?Upgrade=20unpoller=20v2.34.0=20?= =?UTF-8?q?=E2=86=92=20v3.2.0,=20migrate=20to=20container.py=20(#361)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Service Review pickup: unpoller (last reviewed 73 days ago). - Upgrades unpoller from v2.34.0 to v3.2.0 (major version bump). - Migrates the container build from a Dockerfile to a native Dagger pipeline (`containers/unpoller/container.py`) following the navidrome / miniflux pattern. - Refreshes `service-versions.yaml` (last-reviewed, current-version). ## Breaking changes (upstream) - **v3.0.0** — UniFi network API shifts (later 10.x). Some metric / event / log names and labels may have changed. Worth a follow-up sweep of the unpoller Grafana dashboard for missing series. - **v3.2.0** — defaults to a 60s background poll feeding cached Prometheus scrapes (was on-demand poll per scrape). To restore previous behavior, set `interval = 0` in `up.conf`. Leaving the new default in this PR — every-15s scrapes will simply serve from cache, which is fine for our use. ## Build - Image: `registry.ops.eblu.me/blumeops/unpoller:v3.2.0-1b27242` - Built by build-container workflow run #559 from this branch. ## Test plan - [ ] `argocd app set unpoller --revision unpoller-v3 && argocd app sync unpoller` - [ ] Pod comes Ready - [ ] Verify metrics exported (`Site/Client/UAP/USG/USW` counts in logs, `unpoller_*` series in Prometheus) - [ ] Spot-check unpoller Grafana dashboard for missing series after the v3 API shift - [ ] After merge: `argocd app set unpoller --revision main && argocd app sync unpoller` 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/361 --- argocd/manifests/unpoller/kustomization.yaml | 2 +- containers/unpoller/Dockerfile | 43 ---------------- containers/unpoller/container.py | 53 ++++++++++++++++++++ docs/changelog.d/unpoller-v3.infra.md | 1 + service-versions.yaml | 4 +- 5 files changed, 57 insertions(+), 46 deletions(-) delete mode 100644 containers/unpoller/Dockerfile create mode 100644 containers/unpoller/container.py create mode 100644 docs/changelog.d/unpoller-v3.infra.md diff --git a/argocd/manifests/unpoller/kustomization.yaml b/argocd/manifests/unpoller/kustomization.yaml index 5b7a9e2..d2c4e28 100644 --- a/argocd/manifests/unpoller/kustomization.yaml +++ b/argocd/manifests/unpoller/kustomization.yaml @@ -10,7 +10,7 @@ resources: images: - name: registry.ops.eblu.me/blumeops/unpoller - newTag: v2.34.0-613f05d + newTag: v3.2.0-1b27242 configMapGenerator: - name: unpoller-config diff --git a/containers/unpoller/Dockerfile b/containers/unpoller/Dockerfile deleted file mode 100644 index 241b375..0000000 --- a/containers/unpoller/Dockerfile +++ /dev/null @@ -1,43 +0,0 @@ -# UnPoller — UniFi metrics exporter for Prometheus -# Two-stage build: Go compilation, then minimal Alpine runtime - -ARG CONTAINER_APP_VERSION=v2.34.0 - -FROM golang:alpine3.22 AS build - -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/unpoller.git /app - -WORKDIR /app - -ENV CGO_ENABLED=0 - -RUN go build -ldflags="-s -w \ - -X main.version=${CONTAINER_APP_VERSION} \ - -X main.builtBy=blumeops \ - -X golift.io/version.Version=${CONTAINER_APP_VERSION} \ - -X golift.io/version.Branch=HEAD \ - -X golift.io/version.BuildUser=blumeops \ - -X golift.io/version.Revision=blumeops-build" \ - -o /bin/unpoller . - -FROM alpine:3.22 - -ARG CONTAINER_APP_VERSION -LABEL org.opencontainers.image.title="UnPoller" -LABEL org.opencontainers.image.description="UniFi metrics exporter for Prometheus" -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" - -RUN apk add --no-cache ca-certificates tzdata - -COPY --from=build /bin/unpoller /usr/bin/unpoller - -EXPOSE 9130 -USER 65534:65534 -ENTRYPOINT ["/usr/bin/unpoller"] -CMD ["--config", "/etc/unpoller/up.conf"] diff --git a/containers/unpoller/container.py b/containers/unpoller/container.py new file mode 100644 index 0000000..bfc75ba --- /dev/null +++ b/containers/unpoller/container.py @@ -0,0 +1,53 @@ +"""UnPoller — UniFi metrics exporter for Prometheus. + +Two-stage build: Go backend, Alpine runtime. +Source cloned from forge mirror. +""" + +import dagger + +from blumeops.containers import ( + alpine_runtime, + clone_from_forge, + go_build, + oci_labels, +) + +VERSION = "v3.2.0" + + +async def build(src: dagger.Directory) -> dagger.Container: + source = clone_from_forge("unpoller", VERSION) + + backend = go_build( + source, + "/unpoller", + ldflags=( + f"-s -w " + f"-X main.version={VERSION} " + f"-X main.builtBy=blumeops " + f"-X golift.io/version.Version={VERSION} " + f"-X golift.io/version.Branch=HEAD " + f"-X golift.io/version.BuildUser=blumeops " + f"-X golift.io/version.Revision=blumeops-build" + ), + ) + + runtime = alpine_runtime( + extra_apk=["ca-certificates", "tzdata"], + create_user=False, + ) + runtime = oci_labels( + runtime, + title="UnPoller", + description="UniFi metrics exporter for Prometheus", + version=VERSION, + ) + return ( + runtime.with_file("/usr/bin/unpoller", backend.file("/unpoller")) + .with_exposed_port(9130) + .with_user("65534") + .with_default_args( + args=["/usr/bin/unpoller", "--config", "/etc/unpoller/up.conf"] + ) + ) diff --git a/docs/changelog.d/unpoller-v3.infra.md b/docs/changelog.d/unpoller-v3.infra.md new file mode 100644 index 0000000..fa6eaf9 --- /dev/null +++ b/docs/changelog.d/unpoller-v3.infra.md @@ -0,0 +1 @@ +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. diff --git a/service-versions.yaml b/service-versions.yaml index 02f2979..63b0f15 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -345,8 +345,8 @@ services: - name: unpoller type: argocd - last-reviewed: 2026-03-16 - current-version: "v2.34.0" + last-reviewed: 2026-05-28 + current-version: "v3.2.0" upstream-source: https://github.com/unpoller/unpoller/releases notes: UniFi metrics exporter for Prometheus From e703d25efe2b2da12793a6c459bce95ecdc48435 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Thu, 28 May 2026 10:10:21 -0700 Subject: [PATCH 403/430] C0: rebuild unpoller container from squashed main commit Image was previously tagged with the unpoller-v3 branch SHA (1b27242), which doesn't exist in main's history after squash-merge. Rebuilt from the squashed commit so the tag references a reachable commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- argocd/manifests/unpoller/kustomization.yaml | 2 +- docs/changelog.d/+unpoller-rebuild-on-main.infra.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 docs/changelog.d/+unpoller-rebuild-on-main.infra.md diff --git a/argocd/manifests/unpoller/kustomization.yaml b/argocd/manifests/unpoller/kustomization.yaml index d2c4e28..bf776bb 100644 --- a/argocd/manifests/unpoller/kustomization.yaml +++ b/argocd/manifests/unpoller/kustomization.yaml @@ -10,7 +10,7 @@ resources: images: - name: registry.ops.eblu.me/blumeops/unpoller - newTag: v3.2.0-1b27242 + newTag: v3.2.0-4d1f4af configMapGenerator: - name: unpoller-config diff --git a/docs/changelog.d/+unpoller-rebuild-on-main.infra.md b/docs/changelog.d/+unpoller-rebuild-on-main.infra.md new file mode 100644 index 0000000..60ae8fa --- /dev/null +++ b/docs/changelog.d/+unpoller-rebuild-on-main.infra.md @@ -0,0 +1 @@ +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). From 1ce381cb6e15ca1226feee1d6a0fa2c449f929b7 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Thu, 28 May 2026 14:36:33 -0700 Subject: [PATCH 404/430] C0: surface missing-log failures in runner-logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mise run runner-logs -j ` previously silently succeeded with no output when forgejo had no log for the task. Two layered causes: 1. zstdcat exits 0 even when the file is missing (writes "can't stat … -- ignored" to stderr). 2. ssh to indri runs fish, which silently drops the remote exit code so the subprocess returncode is always 0. Probe `test -f` over SSH and parse a stdout marker (EXISTS / MISSING) to detect the missing-log case, then report it explicitly with the indri path and a hint about action_task.log_in_storage = 0 so the operator knows where to look next. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../+runner-logs-missing-log.misc.md | 1 + mise-tasks/runner-logs | 25 ++++++++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 docs/changelog.d/+runner-logs-missing-log.misc.md diff --git a/docs/changelog.d/+runner-logs-missing-log.misc.md b/docs/changelog.d/+runner-logs-missing-log.misc.md new file mode 100644 index 0000000..c06704a --- /dev/null +++ b/docs/changelog.d/+runner-logs-missing-log.misc.md @@ -0,0 +1 @@ +`mise run runner-logs -j ` 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. diff --git a/mise-tasks/runner-logs b/mise-tasks/runner-logs index 3c5e8e3..0d3028b 100755 --- a/mise-tasks/runner-logs +++ b/mise-tasks/runner-logs @@ -229,12 +229,35 @@ def fetch_log(run_number: int, job_index: int, repo: str, token: str) -> None: hex_prefix = f"{task_id & 0xff:02x}" log_path = f"~/forgejo/data/actions_log/{repo}/{hex_prefix}/{task_id}.log.zst" + # indri's login shell (fish) silently swallows SSH exit codes, so we can't + # rely on returncode. zstdcat itself also exits 0 with a "can't stat ... + # -- ignored" stderr message when the file is missing. Detect missing logs + # by running `test -f` over SSH and parsing the marker line from stdout. + probe = subprocess.run( + ["ssh", "indri", f"test -f {log_path} && echo EXISTS || echo MISSING"], + capture_output=True, + text=True, + ) + marker = probe.stdout.strip().splitlines()[-1] if probe.stdout.strip() else "" + if marker != "EXISTS": + typer.echo( + f"Error: log not found for run #{run_number} job {job_index} (task {task_id})", + err=True, + ) + typer.echo(f"Path: indri:{log_path}", err=True) + typer.echo( + "The runner may have crashed before uploading its log buffer " + "(action_task.log_in_storage = 0).", + err=True, + ) + raise typer.Exit(1) + result = subprocess.run( ["ssh", "indri", f"zstdcat {log_path}"], capture_output=True, text=True, ) - if result.returncode != 0: + if result.returncode != 0 or not result.stdout: typer.echo( f"Error: could not read log for run #{run_number} job {job_index} (task {task_id})", err=True, From ecded3007368e094baebeed10fbf2a3fe49aed90 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Thu, 28 May 2026 14:51:09 -0700 Subject: [PATCH 405/430] Make valkey local on ringtail (nix amd64) + bump to 8.1.7 (#362) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Weekly "make one non-local container local" pickup: immich-ringtail still pulled `docker.io/valkey/valkey:8.1.6` because the existing `containers/valkey/container.py` build was arm64-only. - Adds `containers/valkey/default.nix` — nix-built amd64 valkey image, packaged by the ringtail nix-container-builder runner using `pkgs.dockerTools.buildLayeredImage`. Mirrors the existing `containers/authentik-redis/default.nix` pattern. - `containers/valkey/container.py` keeps building the Alpine arm64 image for paperless on indri. Bumped both builds to upstream valkey 8.1.7 (Alpine 3.22 now ships `8.1.7-r0`; nixpkgs has 8.1.7). - Splits `VERSION` (upstream app) from `ALPINE_PIN` (apk pin) in `container.py` so both build files can declare the same upstream version and pass `container-version-check`. - Updates `service-versions.yaml`: current-version 8.1.7, refreshed last-reviewed, upstream-source now points at the canonical valkey-io releases page. - Switches kustomizations: - `immich-ringtail/kustomization.yaml`: `docker.io/valkey/valkey:8.1.6` → `registry.ops.eblu.me/blumeops/valkey:v8.1.7-02859c5-nix`, comment updated. - `paperless/kustomization.yaml`: `v8.1.6-r0-fabca04` → `v8.1.7-02859c5`. ## Build build-container run #563 — both jobs succeeded after a transient runner crash on the first dispatch (#562 build-nix), which surfaced two separate bugs that landed in a separate C0 on main: - `runner-logs` silently returned 0 with no output when the log file didn't exist on indri - `ssh indri` swallowing remote exit codes (fish login shell), which the wrapper now works around via a stdout marker ## Test plan - [ ] `argocd app set immich-ringtail --revision valkey-nix && argocd app sync immich-ringtail` - [ ] `argocd app set paperless --revision valkey-nix && argocd app sync paperless` - [ ] Both valkey pods come Ready and start serving on :6379 - [ ] Immich app + paperless can read/write their respective cache - [ ] After merge: rebuild from squashed main commit + update kustomization tags (squash-tag follow-up) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/362 --- .../immich-ringtail/kustomization.yaml | 9 +++--- argocd/manifests/paperless/kustomization.yaml | 2 +- containers/valkey/container.py | 15 +++++----- containers/valkey/default.nix | 30 +++++++++++++++++++ docs/changelog.d/valkey-nix.infra.md | 1 + service-versions.yaml | 15 +++++----- 6 files changed, 53 insertions(+), 19 deletions(-) create mode 100644 containers/valkey/default.nix create mode 100644 docs/changelog.d/valkey-nix.infra.md diff --git a/argocd/manifests/immich-ringtail/kustomization.yaml b/argocd/manifests/immich-ringtail/kustomization.yaml index c1f639e..7a97fef 100644 --- a/argocd/manifests/immich-ringtail/kustomization.yaml +++ b/argocd/manifests/immich-ringtail/kustomization.yaml @@ -21,8 +21,9 @@ images: - name: ghcr.io/immich-app/immich-machine-learning # CUDA variant of the same release — ringtail has an RTX 4080 newTag: v2.6.3-cuda - # Using upstream multi-arch valkey image directly; the - # registry.ops.eblu.me/blumeops/valkey mirror is arm64-only (built - # on indri) and would crashloop on ringtail. + # amd64 valkey built via nix on the ringtail nix-container-builder + # (see containers/valkey/default.nix). The Alpine container.py build + # is arm64-only and serves paperless on indri. - name: docker.io/valkey/valkey - newTag: "8.1.6" + newName: registry.ops.eblu.me/blumeops/valkey + newTag: v8.1.7-02859c5-nix diff --git a/argocd/manifests/paperless/kustomization.yaml b/argocd/manifests/paperless/kustomization.yaml index 9c6a086..575dfb4 100644 --- a/argocd/manifests/paperless/kustomization.yaml +++ b/argocd/manifests/paperless/kustomization.yaml @@ -16,4 +16,4 @@ images: newTag: v2.20.13-07f52e9 - name: docker.io/library/redis newName: registry.ops.eblu.me/blumeops/valkey - newTag: v8.1.6-r0-fabca04 + newTag: v8.1.7-02859c5 diff --git a/containers/valkey/container.py b/containers/valkey/container.py index 5d150e7..34e8524 100644 --- a/containers/valkey/container.py +++ b/containers/valkey/container.py @@ -1,8 +1,8 @@ -"""Valkey — native Dagger build. +"""Valkey — native Dagger build (arm64, indri). Alpine 3.22 base with the `valkey` apk package (8.1.x — Redis-compatible). -Mirrors `docker.io/valkey/valkey:8.1-alpine`, used by paperless and immich -as a cache/queue sidecar. +Used by paperless (sidecar) on indri. immich on ringtail uses the +nix-built amd64 variant from `default.nix` in this directory. """ import dagger @@ -10,9 +10,10 @@ from dagger import dag from blumeops.containers import oci_labels -# Alpine 3.22 ships valkey 8.1.6-r0. Alpine 3.23 jumps to 9.0 — hold on 3.22 -# to keep this a 1:1 swap for the upstream `valkey:8.1-alpine` image. -VERSION = "8.1.6-r0" +# Alpine 3.22 currently ships valkey 8.1.7-r0. Alpine 3.23 jumps to 9.0 — +# hold on 3.22 to keep this aligned with the 8.1 line. +VERSION = "8.1.7" +ALPINE_PIN = "8.1.7-r0" ALPINE_BASE = "alpine:3.22" @@ -21,7 +22,7 @@ async def build(src: dagger.Directory) -> dagger.Container: ctr = ( dag.container() .from_(ALPINE_BASE) - .with_exec(["apk", "add", "--no-cache", f"valkey={VERSION}"]) + .with_exec(["apk", "add", "--no-cache", f"valkey={ALPINE_PIN}"]) .with_exec(["mkdir", "-p", "/data"]) .with_exec(["chown", "valkey:valkey", "/data"]) .with_workdir("/data") diff --git a/containers/valkey/default.nix b/containers/valkey/default.nix new file mode 100644 index 0000000..9cb1713 --- /dev/null +++ b/containers/valkey/default.nix @@ -0,0 +1,30 @@ +# 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 { } }: + +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" = { }; + }; + }; +} diff --git a/docs/changelog.d/valkey-nix.infra.md b/docs/changelog.d/valkey-nix.infra.md new file mode 100644 index 0000000..e41eb63 --- /dev/null +++ b/docs/changelog.d/valkey-nix.infra.md @@ -0,0 +1 @@ +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). diff --git a/service-versions.yaml b/service-versions.yaml index 63b0f15..5440f01 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -146,14 +146,15 @@ services: - name: valkey type: argocd - last-reviewed: 2026-05-01 - current-version: "8.1.6-r0" - upstream-source: https://pkgs.alpinelinux.org/package/v3.22/community/aarch64/valkey + last-reviewed: 2026-05-28 + current-version: "8.1.7" + upstream-source: https://github.com/valkey-io/valkey/releases notes: >- - Shared Alpine-built valkey image, used as a sidecar/cache by paperless - (sidecar) and immich (separate Deployment). Mirrors the upstream - docker.io/valkey/valkey:8.1-alpine. Pinned to Alpine 3.22 for valkey 8.1.x; - Alpine 3.23 jumps to 9.0. Distinct from authentik-redis (nix-built Redis + Dual-build valkey image: container.py builds Alpine 3.22 + apk valkey + (arm64, indri) for paperless; default.nix builds via nixpkgs (amd64, + ringtail) for immich-ringtail. Both track upstream valkey 8.1.x; Alpine + 3.22 currently ships 8.1.7-r0 and nixpkgs valkey is 8.1.7. Alpine 3.23 + jumps to 9.0. Distinct from authentik-redis (nix-built Redis 8.x) which has its own entry. - name: external-secrets From f588638331567d921e189cbff25db5425ccebaef Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Thu, 28 May 2026 14:53:21 -0700 Subject: [PATCH 406/430] C0: rebuild valkey from squashed main commit Image tags from PR #362 (v8.1.7-02859c5{,-nix}) referenced a branch SHA that no longer exists on main after squash-merge. Rebuilt both the dagger arm64 and nix amd64 variants from the squashed commit (ecded30) and updated paperless + immich-ringtail to the new tags. Co-Authored-By: Claude Opus 4.7 (1M context) --- argocd/manifests/immich-ringtail/kustomization.yaml | 2 +- argocd/manifests/paperless/kustomization.yaml | 2 +- docs/changelog.d/+valkey-rebuild-on-main.infra.md | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) create mode 100644 docs/changelog.d/+valkey-rebuild-on-main.infra.md diff --git a/argocd/manifests/immich-ringtail/kustomization.yaml b/argocd/manifests/immich-ringtail/kustomization.yaml index 7a97fef..2fa131c 100644 --- a/argocd/manifests/immich-ringtail/kustomization.yaml +++ b/argocd/manifests/immich-ringtail/kustomization.yaml @@ -26,4 +26,4 @@ images: # is arm64-only and serves paperless on indri. - name: docker.io/valkey/valkey newName: registry.ops.eblu.me/blumeops/valkey - newTag: v8.1.7-02859c5-nix + newTag: v8.1.7-ecded30-nix diff --git a/argocd/manifests/paperless/kustomization.yaml b/argocd/manifests/paperless/kustomization.yaml index 575dfb4..3cd0d74 100644 --- a/argocd/manifests/paperless/kustomization.yaml +++ b/argocd/manifests/paperless/kustomization.yaml @@ -16,4 +16,4 @@ images: newTag: v2.20.13-07f52e9 - name: docker.io/library/redis newName: registry.ops.eblu.me/blumeops/valkey - newTag: v8.1.7-02859c5 + newTag: v8.1.7-ecded30 diff --git a/docs/changelog.d/+valkey-rebuild-on-main.infra.md b/docs/changelog.d/+valkey-rebuild-on-main.infra.md new file mode 100644 index 0000000..c743e61 --- /dev/null +++ b/docs/changelog.d/+valkey-rebuild-on-main.infra.md @@ -0,0 +1 @@ +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`. From e0064de83d0d15a1f34f16146542a62817dca3ef Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 1 Jun 2026 15:52:09 -0700 Subject: [PATCH 407/430] C0: update ringtail flake inputs (nixpkgs, disko) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../+ringtail-flake-update-2026-06-01.infra.md | 4 ++++ nixos/ringtail/flake.lock | 12 ++++++------ 2 files changed, 10 insertions(+), 6 deletions(-) create mode 100644 docs/changelog.d/+ringtail-flake-update-2026-06-01.infra.md diff --git a/docs/changelog.d/+ringtail-flake-update-2026-06-01.infra.md b/docs/changelog.d/+ringtail-flake-update-2026-06-01.infra.md new file mode 100644 index 0000000..dd488b6 --- /dev/null +++ b/docs/changelog.d/+ringtail-flake-update-2026-06-01.infra.md @@ -0,0 +1,4 @@ +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]]. diff --git a/nixos/ringtail/flake.lock b/nixos/ringtail/flake.lock index 0f0da7e..bb60501 100644 --- a/nixos/ringtail/flake.lock +++ b/nixos/ringtail/flake.lock @@ -7,11 +7,11 @@ ] }, "locked": { - "lastModified": 1779699611, - "narHash": "sha256-EcCaSTKnmg2o4wLKaN1aqQFomwyhO7ik0bX9COdyCas=", + "lastModified": 1780290312, + "narHash": "sha256-eTAlX0CwgB84Ts3GaBd944A3DRXVMzgA0EqroZBISUo=", "owner": "nix-community", "repo": "disko", - "rev": "5ba0c9555c28685e57fa54c7a25e42c7efdbfc8d", + "rev": "115e5211780054d8a890b41f0b7734cafad54dfe", "type": "github" }, "original": { @@ -43,11 +43,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1779467186, - "narHash": "sha256-nOesoDCiXcUftqbRBMz9tt4blI5PvljMWbm3kuCA+0s=", + "lastModified": 1779796641, + "narHash": "sha256-ZsIrKmhp4vbBXoXXmR/tBXA/UCsAQiJL9vsgZEduhVY=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "b77b3de8775677f84492abe84635f87b0e153f0f", + "rev": "25f538306313eae3927264466c70d7001dcea1df", "type": "github" }, "original": { From a36a18aaa6714e187834edc09eb2fc565d0f5fbb Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 1 Jun 2026 20:52:20 -0700 Subject: [PATCH 408/430] C0: black-hole /mirrors/* at Fly edge + name-and-shame scrapers A $29.60 Fly bill traced to ~1.25 TB/30d egress on forge.eblu.me (99.95% of all proxy egress), ~71% of it AI scrapers (Meta meta-externalagent, OpenAI GPTBot, Amazonbot, Bytespider) crawling the public mirror repos' infinite git-history URL space and timing out Forgejo. robots.txt already disallowed /mirrors/ but those agents ignore it, so enforce at the edge: return 403 (^~ to beat the regex asset locations), served as a roll-of-dishonour page with an X-Naughty-Scrapers header. Mirrors stay reachable on the tailnet via forge.ops.eblu.me. Tier 2 (UA denylist + Anubis) and the Cloudflare rejection are documented in docs/explanation/ai-scraper-mitigation.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../+ai-scraper-mitigation-doc.doc.md | 1 + .../+forge-mirrors-blackhole.infra.md | 1 + docs/explanation/ai-scraper-mitigation.md | 201 ++++++++++++++++++ docs/tutorials/expose-service-publicly.md | 7 + fly/Dockerfile | 1 + fly/naughty.html | 64 ++++++ fly/nginx.conf | 27 +++ 7 files changed, 302 insertions(+) create mode 100644 docs/changelog.d/+ai-scraper-mitigation-doc.doc.md create mode 100644 docs/changelog.d/+forge-mirrors-blackhole.infra.md create mode 100644 docs/explanation/ai-scraper-mitigation.md create mode 100644 fly/naughty.html diff --git a/docs/changelog.d/+ai-scraper-mitigation-doc.doc.md b/docs/changelog.d/+ai-scraper-mitigation-doc.doc.md new file mode 100644 index 0000000..246fedb --- /dev/null +++ b/docs/changelog.d/+ai-scraper-mitigation-doc.doc.md @@ -0,0 +1 @@ +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. diff --git a/docs/changelog.d/+forge-mirrors-blackhole.infra.md b/docs/changelog.d/+forge-mirrors-blackhole.infra.md new file mode 100644 index 0000000..29a5e6a --- /dev/null +++ b/docs/changelog.d/+forge-mirrors-blackhole.infra.md @@ -0,0 +1 @@ +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`. diff --git a/docs/explanation/ai-scraper-mitigation.md b/docs/explanation/ai-scraper-mitigation.md new file mode 100644 index 0000000..fe4ba3d --- /dev/null +++ b/docs/explanation/ai-scraper-mitigation.md @@ -0,0 +1,201 @@ +--- +title: AI Scraper Mitigation +modified: 2026-06-01 +last-reviewed: 2026-06-01 +tags: + - explanation + - fly-io + - forgejo + - security + - networking +--- + +# AI Scraper Mitigation on the Public Proxy + +> **Note:** This article was drafted by AI and reviewed by Erich. I plan to rewrite all explanatory content in my own words — these serve as placeholders to establish the documentation structure. + +How BlumeOps keeps AI crawlers from running up the [[expose-service-publicly|Fly.io proxy]] egress bill and DoS-ing [[forgejo|Forgejo]] on [[indri]]. + +## The incident + +A $29.60 Fly.io invoice arrived, nearly all of it a single line: + +``` +Bandwidth: Egress (iad) — 958,524,714,138 bytes — $19.17 +``` + +The `iad` (Ashburn) region is a red herring: the proxy machine runs in `sjc`, +but Fly bills egress at the edge PoP nearest the *client*, so `iad` just means +"the traffic went to clients on the US East Coast." + +Tracing it through the nginx access logs (shipped to Loki via [[alloy|Alloy]]): + +| Signal | Value | +|--------|-------| +| Total proxy egress (30d) | ~1.25 TB | +| Share that was `forge.eblu.me` | **99.95%** | +| Share of forge egress that was `/mirrors/*` | **~71%** | +| Share that was declared AI bots | **~85%+** | +| Top offenders | Meta `meta-externalagent` (66% of bytes), OpenAI `GPTBot` (16%), Amazonbot, Bytespider | +| Forgejo `5xx` (upstream timeouts) | tens of thousands/day, spiking to 112k | + +The crawlers were walking [[forgejo|Forgejo]]'s git-history browse endpoints — +`src/commit/`, `commits/`, `blame/`, `raw/commit/`, plus `.patch`/`.diff` +and `?page=N` pagination. That URL space is effectively **infinite**: every +file × every commit × every page, multiplied across every mirrored repo. A +crawler that follows links never finishes, and every page is a cache `MISS` +that both tunnels to indri *and* bills as egress. + +Two distinct harms, not one: + +1. **Cost** — ~1.25 TB/mo of egress on a free-tier-ish proxy. +2. **Availability** — the crawl alone generates ~400–530k requests/day, + enough to time out Forgejo regardless of how much RAM [[indri]] has. Moving + egress elsewhere would *not* fix this; the crawl has to be throttled at the + source. + +`robots.txt` already `Disallow`s `/mirrors/`, `/user/`, and archive/download +paths — but **`meta-externalagent` and `GPTBot` ignore it.** For these agents, +`robots.txt` is a dead letter, which is why edge enforcement is required. + +## The tiered plan + +### Tier 1 — Black-hole `/mirrors/*` (shipped) + +The mirror repositories (`tailscale`, `prometheus`, `mealie`, `paperless-ngx`, +…) are mirrors of *already-public upstreams*, kept for supply-chain control +(see [[spork-strategy]] and the container/mirror story in [[why-gitops]]). They +are consumed by CI, gilbert, and other tailnet clients over +`forge.ops.eblu.me`. Their web UI on the public internet served **no +legitimate audience** — only scrapers. So the proxy now returns `403` for +anything under `/mirrors/`, pointing humans at the tailnet host: + +```nginx +location ^~ /mirrors/ { + return 403 "Mirror repositories are tailnet-only — use forge.ops.eblu.me.\n"; +} +``` + +The `^~` modifier matters: without it, the regex `location` blocks for static +assets (`*.css`, `*.js`, release downloads) would match first and leak content +under `/mirrors/`. `^~` tells nginx to stop at the prefix match and skip the +regex round. + +This is config, not bot-fighting — we simply stopped serving an infinite +tarpit to the world. It removes ~71% of forge egress and a large share of the +upstream timeouts, with zero impact on any human or tailnet consumer. It +mirrors the existing tailnet-only blocks for `/api/packages/` and `/swagger`. + +The `403` is also a small act of public shaming. Blocked requests are served a +"roll of dishonour" page (`fly/naughty.html`, status kept at `403` via +`error_page 403 /naughty.html`) that names the offending operators and their +share of the stolen bytes, and every response carries an `X-Naughty-Scrapers` +header: + +``` +X-Naughty-Scrapers: OpenAI/GPTBot, Meta/meta-externalagent, Amazonbot, ByteDance/Bytespider — robots.txt ignorers +``` + +Petty? A little. But it costs nothing, documents *why* the block exists for the +next person who hits it, and the page is a few KB versus the megabytes of git +HTML the crawlers were taking. + +**Trade-off accepted:** mirror release-artifact downloads over WAN now also +`403`. Legitimate consumers already pull these over the tailnet, and the public +exposure was the same crawl liability, so this is intentional. + +### Tier 2 — Defend the repos that *stay* public (planned) + +`/eblume/*` is intentionally public (a public profile is a feature). But the +same git-history endpoints are still a tarpit there, just lower-volume. Two +layers, in increasing order of effort and effectiveness: + +#### 2a. User-agent denylist (cheap, evadable) + +Block the declared AI crawlers at the edge regardless of path: + +```nginx +# Illustrative — not yet deployed. +map $http_user_agent $is_ai_bot { + default 0; + "~*meta-externalagent" 1; + "~*GPTBot" 1; + "~*ClaudeBot" 1; + "~*Amazonbot" 1; + "~*Bytespider" 1; + "~*SemrushBot" 1; +} +# in the forge.eblu.me server block: +if ($is_ai_bot) { return 403; } +``` + +This catches ~85% of *current* traffic for a few lines of config. It is +trivially evadable — a scraper need only spoof a browser UA — so it is a +speed-bump, not a wall. Keep `robots.txt` too: well-behaved crawlers +(Googlebot, Bingbot) do honor it, and it documents intent. + +#### 2b. Anubis proof-of-work gateway (the real wall) + +[Anubis](https://github.com/TecharoHQ/anubis) is a Go reverse proxy that +weighs each request with a browser-based proof-of-work challenge before passing +it upstream. It was written for *exactly this scenario* — its author built it +after Amazon's scraper took down their Git server — and is widely deployed in +front of Forgejo/Gitea (Codeberg, the UN, etc.). Headless scrapers that can't +run the challenge JS never reach the application; humans clear it once and +proceed. + +Why it fits BlumeOps better than the alternatives: + +- **It attacks cost *and* availability at once.** Bots receive a few-KB + challenge page instead of MB of git HTML (egress collapses) and never reach + Forgejo (timeouts collapse). No other single lever does both. +- **It stays in-house.** No third party terminates our TLS or sees our + traffic. + +Placement options: + +| Where | Pros | Cons | +|-------|------|------| +| On [[indri]], between [[caddy|Caddy]] and Forgejo | Protects every path and every entry (WAN *and* tailnet); one config | Adds a hop and a service to the indri critical path; the challenge page still tunnels back through Fly for WAN clients (small egress) | +| On the Fly proxy machine, in front of nginx | Challenge served at the edge — bots never even tunnel to indri | Fly VM is small (512 MB); another moving part in the boot sequence alongside `tailscaled`/nginx/`fail2ban`/Alloy | + +Leaning toward Caddy-side on indri for simplicity and uniform coverage, but +this is the open design question for Tier 2. Anubis is MIT-licensed and the +author has signalled a future move to an `equi-x`-based challenge, so pin a +version and track upstream. + +### Tier 3 — Move egress off Fly entirely (rejected) + +A [[#The incident|Cloudflare]] Tunnel (`cloudflared` on indri → Cloudflare +edge) would make this a non-problem on the cost axis: Cloudflare does not meter +proxied bandwidth, and it bundles free AI-bot mitigation (Bot Fight Mode, the +"block AI scrapers" toggle, Managed Challenge, AI Labyrinth). One move would +zero the egress bill and add bot defense. + +**We are not doing this, on principle.** Cloudflare is a solid platform and a +defensible engineering choice — but it already sits in front of an enormous +fraction of the modern web, and routing BlumeOps through it would add one more +site to the pile of the internet that one company can see and gate. BlumeOps +deliberately keeps its own backbone ([[expose-service-publicly|Fly + Tailscale ++ Caddy]], DNS at [[gandi|Gandi]] — see the "no Cloudflare dependency" line in +that doc). This is a values decision, not a technical one: we would rather pay +a few dollars and run our own mitigation than centralize on Cloudflare. + +It is also worth noting that **Tier 3 would not, by itself, fix the upstream +timeouts** — free egress just means we'd stop *caring* that bots crawl, while +they continued to hammer Forgejo. Crawl mitigation (Tier 1 + Tier 2) is +required regardless of where egress is billed. + +## Summary + +| Tier | Lever | Cost | Availability | Status | +|------|-------|------|--------------|--------| +| 1 | Black-hole `/mirrors/*` at edge | −~71% | big drop | **shipped** | +| 2a | UA denylist on remaining repos | −most of the rest | further drop | planned | +| 2b | Anubis PoW gateway | −near-total | near-total | planned | +| 3 | Cloudflare Tunnel | −total | needs 2b anyway | **rejected (principle)** | + +The guiding insight: the cheapest, lowest-risk mitigation is to **not serve an +infinite-URL surface that has no human audience.** Everything past Tier 1 is +about defending the surface we *do* want public, in-house, without ceding +control of our traffic to a third party. diff --git a/docs/tutorials/expose-service-publicly.md b/docs/tutorials/expose-service-publicly.md index 886cad4..65af611 100644 --- a/docs/tutorials/expose-service-publicly.md +++ b/docs/tutorials/expose-service-publicly.md @@ -376,6 +376,13 @@ Mitigations for dynamic services: - fail2ban on indri (see below) can block IPs showing abuse patterns - The break-glass shutoff remains the last resort +The most acute version of this in practice has been **AI scrapers**, which +ignore `robots.txt` and crawl dynamic services (notably [[forgejo|Forgejo]]'s +infinite git-history URL space) into both a surprise egress bill and an +effective L7 DoS. See [[ai-scraper-mitigation]] for the incident, the tiered +defense (mirror black-hole, user-agent denylist, Anubis proof-of-work), and +why a Cloudflare Tunnel is *not* the chosen answer here. + If a publicly exposed dynamic service attracts targeted attacks or the home network bandwidth is impacted, consider migrating to Cloudflare Tunnel for enterprise-grade DDoS protection (requires DNS migration; diff --git a/fly/Dockerfile b/fly/Dockerfile index d4e7a18..406c849 100644 --- a/fly/Dockerfile +++ b/fly/Dockerfile @@ -25,6 +25,7 @@ COPY fail2ban/action.d/nginx-deny.conf /etc/fail2ban/action.d/nginx-deny.conf COPY nginx.conf /etc/nginx/nginx.conf COPY error.html /usr/share/nginx/html/error.html +COPY naughty.html /usr/share/nginx/html/naughty.html COPY alloy.river /etc/alloy/config.alloy COPY start.sh /start.sh RUN chmod +x /start.sh diff --git a/fly/naughty.html b/fly/naughty.html new file mode 100644 index 0000000..d899171 --- /dev/null +++ b/fly/naughty.html @@ -0,0 +1,64 @@ + + + + + + + 403 · Roll of Dishonour + + + +
+

🪤 403 — you walked into the scraper trap

+

These are mirror repositories. They are tailnet-only.

+ +

+ This path used to serve the web UI for mirrors of public upstream + projects. It exists for supply-chain control, not for crawling. A + robots.txt politely disallowed /mirrors/. + A pack of AI scrapers ignored it, walked the infinite git-history URL + space, and ran up ~1.25 TB of egress and a real + money bill in a single month — while timing out the server for everyone + else. +

+ +

So /mirrors/ is closed at the edge now. Roll of dishonour, + by share of the bytes they stole:

+ + + + + + + + + +
OperatorUser-Agent
Metameta-externalagent
OpenAIGPTBot
AmazonAmazonbot
ByteDanceBytespider
+ +

+ If you are a human who actually wanted these mirrors, they are reachable + from the tailnet at forge.ops.eblu.me. If you are a crawler: + read the robots.txt next time. We left you a header, too. +

+ +
GNU Terry Pratchett
+
+ + diff --git a/fly/nginx.conf b/fly/nginx.conf index 570e6c9..ec35774 100644 --- a/fly/nginx.conf +++ b/fly/nginx.conf @@ -215,6 +215,33 @@ http { return 403 "API documentation is only available at forge.ops.eblu.me (tailnet).\n"; } + # Black-hole the mirror repositories on WAN. These are mirrors of + # already-public upstreams (tailscale, prometheus, mealie, …) kept + # for supply-chain control; CI, gilbert, and tailnet clients consume + # them via forge.ops.eblu.me. Their web UI served no public purpose + # but AI scrapers, which crawled the near-infinite git-history URL + # space (src/commit, commits, blame, raw) and drove ~70% of Fly + # egress (1.24 TB/30d → a surprise bill) plus enough upstream load to + # time out Forgejo. robots.txt already Disallows /mirrors/, but + # meta-externalagent and GPTBot ignore it — so enforce at the edge. + # `^~` makes this win over the regex locations below (e.g. *.css), so + # static assets under /mirrors/ can't leak through. We also name and + # shame: blocked requests get a "roll of dishonour" page (403 status + # preserved) and an X-Naughty-Scrapers header. See + # docs/explanation/ai-scraper-mitigation.md. + location ^~ /mirrors/ { + error_page 403 /naughty.html; + return 403; + } + + # Roll of dishonour — served on the /mirrors/ 403, status kept at 403. + location = /naughty.html { + internal; + root /usr/share/nginx/html; + add_header X-Naughty-Scrapers "OpenAI/GPTBot, Meta/meta-externalagent, Amazonbot, ByteDance/Bytespider — robots.txt ignorers" always; + add_header X-Clacks-Overhead "GNU Terry Pratchett" always; + } + # Redirect archive endpoints to tailnet — archive requests generate full # git bundles on demand. Unauthenticated crawlers hitting unique commit # SHAs cause unbounded CPU and disk usage (DoS vector). Legitimate users From 40bd92982015582cb7aa2680c6dc8412706498fb Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 1 Jun 2026 20:55:05 -0700 Subject: [PATCH 409/430] C0: remove visible GNU Terry Pratchett from naughty.html body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GNU lives in the overhead — the X-Clacks-Overhead header — never on the visible page. Keep the header, drop the footer. Co-Authored-By: Claude Opus 4.8 (1M context) --- fly/naughty.html | 3 --- 1 file changed, 3 deletions(-) diff --git a/fly/naughty.html b/fly/naughty.html index d899171..b6eada8 100644 --- a/fly/naughty.html +++ b/fly/naughty.html @@ -21,7 +21,6 @@ td.share { color: #f2c14e; text-align: right; font-variant-numeric: tabular-nums; } .name { color: #e8867a; } a { color: #7fb3d5; } - footer { margin-top: 2rem; color: #5c574f; font-size: .85rem; } @@ -57,8 +56,6 @@ from the tailnet at forge.ops.eblu.me. If you are a crawler: read the robots.txt next time. We left you a header, too.

- -
GNU Terry Pratchett
From fcac8e5a7290bac54b25f82895c8120ef81367ff Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 3 Jun 2026 10:34:00 -0700 Subject: [PATCH 410/430] =?UTF-8?q?Wave=201=20indri=E2=86=92ringtail=20mig?= =?UTF-8?q?ration:=20paperless,=20teslamate,=20mealie=20(#363)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate paperless, teslamate, and mealie off the OOM-saturated minikube-indri node onto ringtail k3s, shedding ~1.1 GiB of resident load. Second chain in the indri-k8s decommission after immich. **Containers ported to Nix (default.nix), build-verified on ringtail:** - paperless → wraps nixpkgs paperless-ngx 2.20.15 (pinned unstable); runs as web/worker/beat/consumer - mealie → wraps nixpkgs mealie 3.16.0 (forward 4-minor bump, breaking-change reviewed); single gunicorn, SQLite - teslamate → from-scratch beamPackages mixRelease (not in nixpkgs); erlang_27+elixir_1_18, npm assets, ex_cldr locales pre-fetched **Data:** cold downtime-tolerant cutover. paperless+teslamate postgres dump/restore from quiesced source into a new ringtail blumeops-pg CNPG cluster; mealie SQLite PVC copied. Source DBs untouched until verified (rollback = repoint). **Also:** ringtail blumeops-pg cluster + ExternalSecrets scaffold; fixes pre-existing shower version-check drift. Runbook: docs/how-to/ringtail/migrate-wave1-ringtail.md. Deploy-from-branch + cutover happens before merge; container images rebuilt from main after merge. Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/363 --- argocd/apps/mealie-ringtail.yaml | 26 +++ argocd/apps/paperless-ringtail.yaml | 28 +++ argocd/apps/teslamate-ringtail.yaml | 28 +++ .../databases-ringtail/blumeops-pg.yaml | 97 +++++++++ .../external-secret-borgmatic.yaml | 30 +++ .../external-secret-eblume.yaml | 30 +++ .../external-secret-paperless.yaml | 28 +++ .../external-secret-teslamate.yaml | 30 +++ .../databases-ringtail/kustomization.yaml | 6 + .../manifests/mealie-ringtail/deployment.yaml | 102 +++++++++ .../mealie-ringtail/external-secret.yaml | 23 ++ .../ingress-tailscale.yaml | 0 .../mealie-ringtail/kustomization.yaml | 15 ++ argocd/manifests/mealie-ringtail/pvc.yaml | 14 ++ argocd/manifests/mealie-ringtail/service.yaml | 13 ++ argocd/manifests/mealie/deployment.yaml | 4 +- argocd/manifests/mealie/kustomization.yaml | 2 +- .../paperless-ringtail/deployment.yaml | 201 ++++++++++++++++++ .../paperless-ringtail/external-secret.yaml | 31 +++ .../ingress-tailscale.yaml | 0 .../paperless-ringtail/kustomization.yaml | 21 ++ .../manifests/paperless-ringtail/pv-nfs.yaml | 22 ++ argocd/manifests/paperless-ringtail/pvc.yaml | 15 ++ .../manifests/paperless-ringtail/service.yaml | 13 ++ argocd/manifests/paperless/deployment.yaml | 5 +- argocd/manifests/paperless/kustomization.yaml | 2 +- .../teslamate-ringtail/deployment.yaml | 72 +++++++ .../external-secret-db.yaml | 25 +++ .../external-secret-encryption-key.yaml | 27 +++ .../ingress-tailscale.yaml | 0 .../teslamate-ringtail/kustomization.yaml | 15 ++ .../manifests/teslamate-ringtail/service.yaml | 12 ++ argocd/manifests/teslamate/deployment.yaml | 5 +- argocd/manifests/teslamate/kustomization.yaml | 2 +- containers/mealie/Dockerfile | 145 ------------- containers/mealie/default.nix | 65 ++++++ containers/paperless/Dockerfile | 156 -------------- containers/paperless/default.nix | 77 +++++++ containers/teslamate/container.py | 104 --------- containers/teslamate/default.nix | 122 +++++++++++ containers/teslamate/entrypoint.sh | 23 -- .../migrate-wave1-ringtail.infra.md | 13 ++ .../immich/migrate-immich-to-ringtail.md | 2 + .../how-to/ringtail/migrate-wave1-ringtail.md | 176 +++++++++++++++ service-versions.yaml | 40 +++- 45 files changed, 1422 insertions(+), 445 deletions(-) create mode 100644 argocd/apps/mealie-ringtail.yaml create mode 100644 argocd/apps/paperless-ringtail.yaml create mode 100644 argocd/apps/teslamate-ringtail.yaml create mode 100644 argocd/manifests/databases-ringtail/blumeops-pg.yaml create mode 100644 argocd/manifests/databases-ringtail/external-secret-borgmatic.yaml create mode 100644 argocd/manifests/databases-ringtail/external-secret-eblume.yaml create mode 100644 argocd/manifests/databases-ringtail/external-secret-paperless.yaml create mode 100644 argocd/manifests/databases-ringtail/external-secret-teslamate.yaml create mode 100644 argocd/manifests/mealie-ringtail/deployment.yaml create mode 100644 argocd/manifests/mealie-ringtail/external-secret.yaml rename argocd/manifests/{mealie => mealie-ringtail}/ingress-tailscale.yaml (100%) create mode 100644 argocd/manifests/mealie-ringtail/kustomization.yaml create mode 100644 argocd/manifests/mealie-ringtail/pvc.yaml create mode 100644 argocd/manifests/mealie-ringtail/service.yaml create mode 100644 argocd/manifests/paperless-ringtail/deployment.yaml create mode 100644 argocd/manifests/paperless-ringtail/external-secret.yaml rename argocd/manifests/{paperless => paperless-ringtail}/ingress-tailscale.yaml (100%) create mode 100644 argocd/manifests/paperless-ringtail/kustomization.yaml create mode 100644 argocd/manifests/paperless-ringtail/pv-nfs.yaml create mode 100644 argocd/manifests/paperless-ringtail/pvc.yaml create mode 100644 argocd/manifests/paperless-ringtail/service.yaml create mode 100644 argocd/manifests/teslamate-ringtail/deployment.yaml create mode 100644 argocd/manifests/teslamate-ringtail/external-secret-db.yaml create mode 100644 argocd/manifests/teslamate-ringtail/external-secret-encryption-key.yaml rename argocd/manifests/{teslamate => teslamate-ringtail}/ingress-tailscale.yaml (100%) create mode 100644 argocd/manifests/teslamate-ringtail/kustomization.yaml create mode 100644 argocd/manifests/teslamate-ringtail/service.yaml delete mode 100644 containers/mealie/Dockerfile create mode 100644 containers/mealie/default.nix delete mode 100644 containers/paperless/Dockerfile create mode 100644 containers/paperless/default.nix delete mode 100644 containers/teslamate/container.py create mode 100644 containers/teslamate/default.nix delete mode 100644 containers/teslamate/entrypoint.sh create mode 100644 docs/changelog.d/migrate-wave1-ringtail.infra.md create mode 100644 docs/how-to/ringtail/migrate-wave1-ringtail.md diff --git a/argocd/apps/mealie-ringtail.yaml b/argocd/apps/mealie-ringtail.yaml new file mode 100644 index 0000000..2f014a9 --- /dev/null +++ b/argocd/apps/mealie-ringtail.yaml @@ -0,0 +1,26 @@ +# 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 diff --git a/argocd/apps/paperless-ringtail.yaml b/argocd/apps/paperless-ringtail.yaml new file mode 100644 index 0000000..bec98e9 --- /dev/null +++ b/argocd/apps/paperless-ringtail.yaml @@ -0,0 +1,28 @@ +# 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 diff --git a/argocd/apps/teslamate-ringtail.yaml b/argocd/apps/teslamate-ringtail.yaml new file mode 100644 index 0000000..b7b3491 --- /dev/null +++ b/argocd/apps/teslamate-ringtail.yaml @@ -0,0 +1,28 @@ +# 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 diff --git a/argocd/manifests/databases-ringtail/blumeops-pg.yaml b/argocd/manifests/databases-ringtail/blumeops-pg.yaml new file mode 100644 index 0000000..3a37249 --- /dev/null +++ b/argocd/manifests/databases-ringtail/blumeops-pg.yaml @@ -0,0 +1,97 @@ +# 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 diff --git a/argocd/manifests/databases-ringtail/external-secret-borgmatic.yaml b/argocd/manifests/databases-ringtail/external-secret-borgmatic.yaml new file mode 100644 index 0000000..ee600e3 --- /dev/null +++ b/argocd/manifests/databases-ringtail/external-secret-borgmatic.yaml @@ -0,0 +1,30 @@ +# 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 diff --git a/argocd/manifests/databases-ringtail/external-secret-eblume.yaml b/argocd/manifests/databases-ringtail/external-secret-eblume.yaml new file mode 100644 index 0000000..a324c7d --- /dev/null +++ b/argocd/manifests/databases-ringtail/external-secret-eblume.yaml @@ -0,0 +1,30 @@ +# 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 diff --git a/argocd/manifests/databases-ringtail/external-secret-paperless.yaml b/argocd/manifests/databases-ringtail/external-secret-paperless.yaml new file mode 100644 index 0000000..e5742be --- /dev/null +++ b/argocd/manifests/databases-ringtail/external-secret-paperless.yaml @@ -0,0 +1,28 @@ +# ExternalSecret for Paperless database user password +# +# 1Password item: "Paperless (blumeops)" in blumeops vault +# Field: "postgresql-password" +# +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: blumeops-pg-paperless + namespace: databases +spec: + refreshInterval: 1h + secretStoreRef: + kind: ClusterSecretStore + name: onepassword-blumeops + target: + name: blumeops-pg-paperless + creationPolicy: Owner + template: + type: kubernetes.io/basic-auth + data: + username: paperless + password: "{{ .password }}" + data: + - secretKey: password + remoteRef: + key: Paperless (blumeops) + property: postgresql-password diff --git a/argocd/manifests/databases-ringtail/external-secret-teslamate.yaml b/argocd/manifests/databases-ringtail/external-secret-teslamate.yaml new file mode 100644 index 0000000..0c52e0b --- /dev/null +++ b/argocd/manifests/databases-ringtail/external-secret-teslamate.yaml @@ -0,0 +1,30 @@ +# ExternalSecret for TeslaMate database user password +# +# Replaces the manual op inject workflow from secret-teslamate.yaml.tpl +# +# 1Password item: "TeslaMate" in blumeops vault +# Field: "db_password" +# +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: blumeops-pg-teslamate + namespace: databases +spec: + refreshInterval: 1h + secretStoreRef: + kind: ClusterSecretStore + name: onepassword-blumeops + target: + name: blumeops-pg-teslamate + creationPolicy: Owner + template: + type: kubernetes.io/basic-auth + data: + username: teslamate + password: "{{ .password }}" + data: + - secretKey: password + remoteRef: + key: TeslaMate + property: db_password diff --git a/argocd/manifests/databases-ringtail/kustomization.yaml b/argocd/manifests/databases-ringtail/kustomization.yaml index 971e2d4..2bc2af3 100644 --- a/argocd/manifests/databases-ringtail/kustomization.yaml +++ b/argocd/manifests/databases-ringtail/kustomization.yaml @@ -7,3 +7,9 @@ resources: - immich-pg.yaml - external-secret-immich-borgmatic.yaml - service-immich-pg-tailscale.yaml + # wave-1 indri-k8s decommission: blumeops-pg (paperless + teslamate) + - blumeops-pg.yaml + - external-secret-eblume.yaml + - external-secret-borgmatic.yaml + - external-secret-paperless.yaml + - external-secret-teslamate.yaml diff --git a/argocd/manifests/mealie-ringtail/deployment.yaml b/argocd/manifests/mealie-ringtail/deployment.yaml new file mode 100644 index 0000000..10d06ab --- /dev/null +++ b/argocd/manifests/mealie-ringtail/deployment.yaml @@ -0,0 +1,102 @@ +# 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 +kind: Deployment +metadata: + name: mealie + namespace: mealie +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app: mealie + template: + metadata: + labels: + app: mealie + spec: + securityContext: + seccompProfile: + type: RuntimeDefault + containers: + - name: mealie + image: registry.ops.eblu.me/blumeops/mealie:kustomized + ports: + - containerPort: 9000 + env: + - name: BASE_URL + value: "https://meals.ops.eblu.me" + - name: ALLOW_SIGNUP + value: "false" + - name: TZ + value: "America/Los_Angeles" + - name: MAX_WORKERS + value: "1" + - name: WEB_CONCURRENCY + value: "1" + # OIDC — Authentik (public client, PKCE) + - name: OIDC_AUTH_ENABLED + value: "true" + - name: OIDC_CONFIGURATION_URL + value: "https://authentik.ops.eblu.me/application/o/mealie/.well-known/openid-configuration" + - name: OIDC_CLIENT_ID + value: "mealie" + - name: OIDC_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: mealie-secrets + key: oidc-client-secret + - name: OIDC_AUTO_REDIRECT + value: "false" + - name: OIDC_PROVIDER_NAME + value: "Authentik" + - name: OIDC_ADMIN_GROUP + value: "admins" + - name: OIDC_SIGNUP_ENABLED + value: "true" + - name: OIDC_USER_CLAIM + value: "email" + # OpenAI — recipe parsing, image OCR, ingredient extraction + - name: OPENAI_API_KEY + valueFrom: + secretKeyRef: + name: mealie-secrets + key: openai-api-key + - name: OPENAI_MODEL + value: "gpt-4o" + - name: OPENAI_REQUEST_TIMEOUT + value: "120" + - name: OPENAI_WORKERS + value: "1" + volumeMounts: + - name: data + mountPath: /app/data + resources: + requests: + memory: "128Mi" + cpu: "50m" + limits: + memory: "1000Mi" + cpu: "500m" + livenessProbe: + httpGet: + path: /api/app/about + port: 9000 + initialDelaySeconds: 30 + periodSeconds: 30 + readinessProbe: + httpGet: + path: /api/app/about + port: 9000 + initialDelaySeconds: 10 + periodSeconds: 10 + volumes: + - name: data + persistentVolumeClaim: + claimName: mealie-data diff --git a/argocd/manifests/mealie-ringtail/external-secret.yaml b/argocd/manifests/mealie-ringtail/external-secret.yaml new file mode 100644 index 0000000..99c2793 --- /dev/null +++ b/argocd/manifests/mealie-ringtail/external-secret.yaml @@ -0,0 +1,23 @@ +--- +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: mealie-secrets + namespace: mealie +spec: + refreshInterval: 1h + secretStoreRef: + kind: ClusterSecretStore + name: onepassword-blumeops + target: + name: mealie-secrets + creationPolicy: Owner + data: + - secretKey: oidc-client-secret + remoteRef: + key: "Authentik (blumeops)" + property: mealie-client-secret + - secretKey: openai-api-key + remoteRef: + key: "openai (blumeops)" + property: credential diff --git a/argocd/manifests/mealie/ingress-tailscale.yaml b/argocd/manifests/mealie-ringtail/ingress-tailscale.yaml similarity index 100% rename from argocd/manifests/mealie/ingress-tailscale.yaml rename to argocd/manifests/mealie-ringtail/ingress-tailscale.yaml diff --git a/argocd/manifests/mealie-ringtail/kustomization.yaml b/argocd/manifests/mealie-ringtail/kustomization.yaml new file mode 100644 index 0000000..8428042 --- /dev/null +++ b/argocd/manifests/mealie-ringtail/kustomization.yaml @@ -0,0 +1,15 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: mealie + +resources: + - deployment.yaml + - service.yaml + - pvc.yaml + - ingress-tailscale.yaml + - external-secret.yaml + +images: + - name: registry.ops.eblu.me/blumeops/mealie + newTag: v3.16.0-1d4cbbf-nix diff --git a/argocd/manifests/mealie-ringtail/pvc.yaml b/argocd/manifests/mealie-ringtail/pvc.yaml new file mode 100644 index 0000000..89c38ef --- /dev/null +++ b/argocd/manifests/mealie-ringtail/pvc.yaml @@ -0,0 +1,14 @@ +# SQLite data volume for Mealie on ringtail. Contents copied from the +# minikube mealie-data PVC at cutover (recipes, meal plans, uploaded media). +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: mealie-data + namespace: mealie +spec: + accessModes: + - ReadWriteOnce + storageClassName: local-path + resources: + requests: + storage: 2Gi diff --git a/argocd/manifests/mealie-ringtail/service.yaml b/argocd/manifests/mealie-ringtail/service.yaml new file mode 100644 index 0000000..4162b96 --- /dev/null +++ b/argocd/manifests/mealie-ringtail/service.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Service +metadata: + name: mealie + namespace: mealie +spec: + selector: + app: mealie + ports: + - name: http + port: 9000 + targetPort: 9000 + protocol: TCP diff --git a/argocd/manifests/mealie/deployment.yaml b/argocd/manifests/mealie/deployment.yaml index bdcf91e..7cdd275 100644 --- a/argocd/manifests/mealie/deployment.yaml +++ b/argocd/manifests/mealie/deployment.yaml @@ -4,7 +4,9 @@ metadata: name: mealie namespace: mealie spec: - replicas: 1 + # Migrated to ringtail (mealie-ringtail). Scaled to 0; SQLite PVC retained + # for rollback until the decommission PR. See [[migrate-wave1-ringtail]]. + replicas: 0 selector: matchLabels: app: mealie diff --git a/argocd/manifests/mealie/kustomization.yaml b/argocd/manifests/mealie/kustomization.yaml index fb0713b..02563f4 100644 --- a/argocd/manifests/mealie/kustomization.yaml +++ b/argocd/manifests/mealie/kustomization.yaml @@ -7,7 +7,7 @@ resources: - deployment.yaml - service.yaml - pvc.yaml - - ingress-tailscale.yaml + # ingress removed: name 'meals' handed off to mealie-ringtail at cutover - external-secret.yaml images: diff --git a/argocd/manifests/paperless-ringtail/deployment.yaml b/argocd/manifests/paperless-ringtail/deployment.yaml new file mode 100644 index 0000000..de4f456 --- /dev/null +++ b/argocd/manifests/paperless-ringtail/deployment.yaml @@ -0,0 +1,201 @@ +# 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 +kind: Deployment +metadata: + name: paperless + namespace: paperless +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app: paperless + template: + metadata: + labels: + app: paperless + spec: + securityContext: + seccompProfile: + type: RuntimeDefault + initContainers: + # redis as a native sidecar (restartPolicy: Always): starts before + # 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 + command: ["paperless-ngx", "migrate", "--no-input"] + env: &paperless-env + - name: PAPERLESS_URL + value: "https://paperless.ops.eblu.me" + - name: PAPERLESS_REDIS + value: "redis://localhost:6379" + - name: PAPERLESS_DBHOST + value: "blumeops-pg-rw.databases.svc.cluster.local" + - name: PAPERLESS_DBPORT + value: "5432" + - name: PAPERLESS_DBNAME + value: "paperless" + - name: PAPERLESS_DBUSER + value: "paperless" + - name: PAPERLESS_DBPASS + valueFrom: + secretKeyRef: + name: paperless-secrets + 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 + valueFrom: + secretKeyRef: + name: paperless-secrets + key: secret-key + - name: PAPERLESS_TIME_ZONE + value: "America/Los_Angeles" + - name: PAPERLESS_OCR_LANGUAGE + value: "eng" + - name: PAPERLESS_TASK_WORKERS + value: "1" + - name: PAPERLESS_ADMIN_USER + value: "eblume" + - name: PAPERLESS_ADMIN_PASSWORD + valueFrom: + secretKeyRef: + name: paperless-secrets + key: admin-password + - name: PAPERLESS_ADMIN_MAIL + value: "blume.erich@gmail.com" + - name: PAPERLESS_APPS + value: "allauth.socialaccount.providers.openid_connect" + - name: PAPERLESS_SOCIALACCOUNT_PROVIDERS + valueFrom: + secretKeyRef: + name: paperless-secrets + key: socialaccount-providers + - name: PAPERLESS_SOCIALACCOUNT_ALLOW_SIGNUPS + value: "true" + - name: PAPERLESS_SOCIAL_AUTO_SIGNUP + value: "true" + - name: PAPERLESS_ACCOUNT_ALLOW_SIGNUPS + value: "false" + - name: PAPERLESS_REDIRECT_LOGIN_TO_SSO + value: "false" + volumeMounts: &paperless-mounts + - name: data + mountPath: /usr/src/paperless/data + - name: media + mountPath: /usr/src/paperless/media + - name: 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: + requests: + memory: "256Mi" + cpu: "100m" + limits: + memory: "1Gi" + cpu: "1000m" + livenessProbe: + httpGet: + path: / + port: 8000 + initialDelaySeconds: 60 + periodSeconds: 30 + readinessProbe: + httpGet: + path: / + port: 8000 + initialDelaySeconds: 30 + periodSeconds: 10 + + - name: worker + image: registry.ops.eblu.me/blumeops/paperless:kustomized + command: ["celery", "--app", "paperless", "worker", "--loglevel", "INFO"] + env: *paperless-env + volumeMounts: *paperless-mounts + resources: + requests: + memory: "256Mi" + cpu: "100m" + 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" + cpu: "50m" + limits: + memory: "512Mi" + + volumes: + - name: data + emptyDir: {} + - name: media + persistentVolumeClaim: + claimName: paperless-media + - name: consume + emptyDir: {} + - name: redis-data + emptyDir: + sizeLimit: 1Gi diff --git a/argocd/manifests/paperless-ringtail/external-secret.yaml b/argocd/manifests/paperless-ringtail/external-secret.yaml new file mode 100644 index 0000000..750b7c5 --- /dev/null +++ b/argocd/manifests/paperless-ringtail/external-secret.yaml @@ -0,0 +1,31 @@ +--- +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: paperless-secrets + namespace: paperless +spec: + refreshInterval: 1h + secretStoreRef: + kind: ClusterSecretStore + name: onepassword-blumeops + target: + name: paperless-secrets + creationPolicy: Owner + data: + - secretKey: db-password + remoteRef: + key: "Paperless (blumeops)" + property: postgresql-password + - secretKey: secret-key + remoteRef: + key: "Paperless (blumeops)" + property: secret-key + - secretKey: admin-password + remoteRef: + key: "Paperless (blumeops)" + property: admin-password + - secretKey: socialaccount-providers + remoteRef: + key: "Paperless (blumeops)" + property: socialaccount-providers diff --git a/argocd/manifests/paperless/ingress-tailscale.yaml b/argocd/manifests/paperless-ringtail/ingress-tailscale.yaml similarity index 100% rename from argocd/manifests/paperless/ingress-tailscale.yaml rename to argocd/manifests/paperless-ringtail/ingress-tailscale.yaml diff --git a/argocd/manifests/paperless-ringtail/kustomization.yaml b/argocd/manifests/paperless-ringtail/kustomization.yaml new file mode 100644 index 0000000..0a691e0 --- /dev/null +++ b/argocd/manifests/paperless-ringtail/kustomization.yaml @@ -0,0 +1,21 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: paperless + +resources: + - deployment.yaml + - service.yaml + - pv-nfs.yaml + - pvc.yaml + - ingress-tailscale.yaml + - external-secret.yaml + +images: + - name: registry.ops.eblu.me/blumeops/paperless + newTag: v2.20.15-1d4cbbf-nix + # 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 + newName: registry.ops.eblu.me/blumeops/valkey + newTag: v8.1.7-ecded30-nix diff --git a/argocd/manifests/paperless-ringtail/pv-nfs.yaml b/argocd/manifests/paperless-ringtail/pv-nfs.yaml new file mode 100644 index 0000000..2990d1a --- /dev/null +++ b/argocd/manifests/paperless-ringtail/pv-nfs.yaml @@ -0,0 +1,22 @@ +# 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 diff --git a/argocd/manifests/paperless-ringtail/pvc.yaml b/argocd/manifests/paperless-ringtail/pvc.yaml new file mode 100644 index 0000000..8b44660 --- /dev/null +++ b/argocd/manifests/paperless-ringtail/pvc.yaml @@ -0,0 +1,15 @@ +# PersistentVolumeClaim for the Paperless document library on ringtail. +# Binds the NFS PV for sifaka:/volume1/paperless. +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: paperless-media + namespace: paperless +spec: + accessModes: + - ReadWriteMany + storageClassName: "" + volumeName: paperless-media-nfs-pv-ringtail + resources: + requests: + storage: 500Gi diff --git a/argocd/manifests/paperless-ringtail/service.yaml b/argocd/manifests/paperless-ringtail/service.yaml new file mode 100644 index 0000000..cff2972 --- /dev/null +++ b/argocd/manifests/paperless-ringtail/service.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Service +metadata: + name: paperless + namespace: paperless +spec: + selector: + app: paperless + ports: + - name: http + port: 8000 + targetPort: 8000 + protocol: TCP diff --git a/argocd/manifests/paperless/deployment.yaml b/argocd/manifests/paperless/deployment.yaml index cc2c013..1730486 100644 --- a/argocd/manifests/paperless/deployment.yaml +++ b/argocd/manifests/paperless/deployment.yaml @@ -4,7 +4,10 @@ metadata: name: paperless namespace: paperless spec: - replicas: 1 + # Migrated to ringtail (paperless-ringtail). Scaled to 0 to prevent + # double-writing the now-ringtail-owned database; manifest retained for + # rollback until the decommission PR. See [[migrate-wave1-ringtail]]. + replicas: 0 selector: matchLabels: app: paperless diff --git a/argocd/manifests/paperless/kustomization.yaml b/argocd/manifests/paperless/kustomization.yaml index 3cd0d74..a92a769 100644 --- a/argocd/manifests/paperless/kustomization.yaml +++ b/argocd/manifests/paperless/kustomization.yaml @@ -8,7 +8,7 @@ resources: - service.yaml - pv-nfs.yaml - pvc.yaml - - ingress-tailscale.yaml + # ingress removed: name 'paperless' handed off to paperless-ringtail at cutover - external-secret.yaml images: diff --git a/argocd/manifests/teslamate-ringtail/deployment.yaml b/argocd/manifests/teslamate-ringtail/deployment.yaml new file mode 100644 index 0000000..cf8cc73 --- /dev/null +++ b/argocd/manifests/teslamate-ringtail/deployment.yaml @@ -0,0 +1,72 @@ +# 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 +kind: Deployment +metadata: + name: teslamate + namespace: teslamate +spec: + replicas: 1 + selector: + matchLabels: + app: teslamate + template: + metadata: + labels: + app: teslamate + spec: + securityContext: + seccompProfile: + type: RuntimeDefault + containers: + - name: teslamate + image: registry.ops.eblu.me/blumeops/teslamate:kustomized + ports: + - containerPort: 4000 + env: + - name: DATABASE_USER + value: "teslamate" + - name: DATABASE_PASS + valueFrom: + secretKeyRef: + name: teslamate-db + key: password + - name: DATABASE_NAME + value: "teslamate" + - name: DATABASE_HOST + value: "blumeops-pg-rw.databases.svc.cluster.local" + - name: ENCRYPTION_KEY + valueFrom: + secretKeyRef: + name: teslamate-encryption + key: key + - name: DISABLE_MQTT + value: "true" + - name: CHECK_ORIGIN + value: "false" + - name: TZ + value: "America/Los_Angeles" + resources: + requests: + memory: "128Mi" + cpu: "100m" + limits: + memory: "512Mi" + cpu: "500m" + livenessProbe: + httpGet: + path: / + port: 4000 + initialDelaySeconds: 30 + periodSeconds: 30 + readinessProbe: + httpGet: + path: / + port: 4000 + initialDelaySeconds: 10 + periodSeconds: 10 diff --git a/argocd/manifests/teslamate-ringtail/external-secret-db.yaml b/argocd/manifests/teslamate-ringtail/external-secret-db.yaml new file mode 100644 index 0000000..11eeec6 --- /dev/null +++ b/argocd/manifests/teslamate-ringtail/external-secret-db.yaml @@ -0,0 +1,25 @@ +# ExternalSecret for TeslaMate database password +# +# Replaces the manual op inject workflow from secret-db.yaml.tpl +# +# 1Password item: "TeslaMate" in blumeops vault +# Field: "db_password" +# +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: teslamate-db + namespace: teslamate +spec: + refreshInterval: 1h + secretStoreRef: + kind: ClusterSecretStore + name: onepassword-blumeops + target: + name: teslamate-db + creationPolicy: Owner + data: + - secretKey: password + remoteRef: + key: TeslaMate + property: db_password diff --git a/argocd/manifests/teslamate-ringtail/external-secret-encryption-key.yaml b/argocd/manifests/teslamate-ringtail/external-secret-encryption-key.yaml new file mode 100644 index 0000000..96938bf --- /dev/null +++ b/argocd/manifests/teslamate-ringtail/external-secret-encryption-key.yaml @@ -0,0 +1,27 @@ +# ExternalSecret for TeslaMate encryption key +# +# Replaces the manual op inject workflow from secret-encryption-key.yaml.tpl +# +# 1Password item: "TeslaMate" in blumeops vault +# Field: "api_enc_key" +# +# This key encrypts Tesla API tokens at rest in the database. +# +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: teslamate-encryption + namespace: teslamate +spec: + refreshInterval: 1h + secretStoreRef: + kind: ClusterSecretStore + name: onepassword-blumeops + target: + name: teslamate-encryption + creationPolicy: Owner + data: + - secretKey: key + remoteRef: + key: TeslaMate + property: api_enc_key diff --git a/argocd/manifests/teslamate/ingress-tailscale.yaml b/argocd/manifests/teslamate-ringtail/ingress-tailscale.yaml similarity index 100% rename from argocd/manifests/teslamate/ingress-tailscale.yaml rename to argocd/manifests/teslamate-ringtail/ingress-tailscale.yaml diff --git a/argocd/manifests/teslamate-ringtail/kustomization.yaml b/argocd/manifests/teslamate-ringtail/kustomization.yaml new file mode 100644 index 0000000..f31fe09 --- /dev/null +++ b/argocd/manifests/teslamate-ringtail/kustomization.yaml @@ -0,0 +1,15 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: teslamate + +resources: + - deployment.yaml + - service.yaml + - ingress-tailscale.yaml + - external-secret-db.yaml + - external-secret-encryption-key.yaml + +images: + - name: registry.ops.eblu.me/blumeops/teslamate + newTag: v3.0.0-191be1b-nix diff --git a/argocd/manifests/teslamate-ringtail/service.yaml b/argocd/manifests/teslamate-ringtail/service.yaml new file mode 100644 index 0000000..b04f45e --- /dev/null +++ b/argocd/manifests/teslamate-ringtail/service.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Service +metadata: + name: teslamate + namespace: teslamate +spec: + selector: + app: teslamate + ports: + - port: 4000 + targetPort: 4000 + type: ClusterIP diff --git a/argocd/manifests/teslamate/deployment.yaml b/argocd/manifests/teslamate/deployment.yaml index 42859a7..cf7f9bb 100644 --- a/argocd/manifests/teslamate/deployment.yaml +++ b/argocd/manifests/teslamate/deployment.yaml @@ -4,7 +4,10 @@ metadata: name: teslamate namespace: teslamate spec: - replicas: 1 + # Migrated to ringtail (teslamate-ringtail). Scaled to 0 to prevent + # double-writing the now-ringtail-owned database; manifest retained for + # rollback until the decommission PR. See [[migrate-wave1-ringtail]]. + replicas: 0 selector: matchLabels: app: teslamate diff --git a/argocd/manifests/teslamate/kustomization.yaml b/argocd/manifests/teslamate/kustomization.yaml index a00586f..be9d39d 100644 --- a/argocd/manifests/teslamate/kustomization.yaml +++ b/argocd/manifests/teslamate/kustomization.yaml @@ -6,7 +6,7 @@ namespace: teslamate resources: - deployment.yaml - service.yaml - - ingress-tailscale.yaml + # ingress removed: name 'tesla' handed off to teslamate-ringtail at cutover - external-secret-db.yaml - external-secret-encryption-key.yaml diff --git a/containers/mealie/Dockerfile b/containers/mealie/Dockerfile deleted file mode 100644 index 8df38bf..0000000 --- a/containers/mealie/Dockerfile +++ /dev/null @@ -1,145 +0,0 @@ -# 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"] diff --git a/containers/mealie/default.nix b/containers/mealie/default.nix new file mode 100644 index 0000000..fdb1430 --- /dev/null +++ b/containers/mealie/default.nix @@ -0,0 +1,65 @@ +# 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 + ]; + + 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" = { }; + }; + }; +} diff --git a/containers/paperless/Dockerfile b/containers/paperless/Dockerfile deleted file mode 100644 index a7b4e65..0000000 --- a/containers/paperless/Dockerfile +++ /dev/null @@ -1,156 +0,0 @@ -# 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" diff --git a/containers/paperless/default.nix b/containers/paperless/default.nix new file mode 100644 index 0000000..734d909 --- /dev/null +++ b/containers/paperless/default.nix @@ -0,0 +1,77 @@ +# 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" = { }; + }; + }; +} diff --git a/containers/teslamate/container.py b/containers/teslamate/container.py deleted file mode 100644 index 519d77d..0000000 --- a/containers/teslamate/container.py +++ /dev/null @@ -1,104 +0,0 @@ -"""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"]) - ) diff --git a/containers/teslamate/default.nix b/containers/teslamate/default.nix new file mode 100644 index 0000000..e126561 --- /dev/null +++ b/containers/teslamate/default.nix @@ -0,0 +1,122 @@ +# 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" = { }; + }; + }; +} diff --git a/containers/teslamate/entrypoint.sh b/containers/teslamate/entrypoint.sh deleted file mode 100644 index f66117e..0000000 --- a/containers/teslamate/entrypoint.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/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 "$@" diff --git a/docs/changelog.d/migrate-wave1-ringtail.infra.md b/docs/changelog.d/migrate-wave1-ringtail.infra.md new file mode 100644 index 0000000..c44263a --- /dev/null +++ b/docs/changelog.d/migrate-wave1-ringtail.infra.md @@ -0,0 +1,13 @@ +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]]. diff --git a/docs/how-to/immich/migrate-immich-to-ringtail.md b/docs/how-to/immich/migrate-immich-to-ringtail.md index cd23384..e654b62 100644 --- a/docs/how-to/immich/migrate-immich-to-ringtail.md +++ b/docs/how-to/immich/migrate-immich-to-ringtail.md @@ -122,6 +122,8 @@ file). ## Related +- [[migrate-wave1-ringtail]] — the next chain in the indri-k8s + decommission: paperless, teslamate, and mealie - [[shower-on-ringtail]] — a previous migration to ringtail (simpler: no upstream cluster, SQLite, no GPU) - [[connect-to-postgres]] — getting a psql session against CNPG diff --git a/docs/how-to/ringtail/migrate-wave1-ringtail.md b/docs/how-to/ringtail/migrate-wave1-ringtail.md new file mode 100644 index 0000000..ffb8cdc --- /dev/null +++ b/docs/how-to/ringtail/migrate-wave1-ringtail.md @@ -0,0 +1,176 @@ +--- +title: Migrate Wave 1 (paperless, teslamate, mealie) to Ringtail +modified: 2026-06-03 +last-reviewed: 2026-06-03 +tags: + - how-to + - operations + - ringtail + - migration +--- + +# Migrate Wave 1 to Ringtail + +Move paperless, teslamate, and mealie off `minikube-indri` and onto +`k3s-ringtail`. This is the load-shedding response to minikube going +OOM: the kernel OOM killer was thrashing the 8 GiB node — killing +`kube-apiserver`, `dockerd`, and the argocd application-controller — +which made every minikube-hosted service probe-flap at once. These +three app pods are ~1.1 GiB resident combined and are the heaviest +non-observability tenants left on minikube. Following +[[migrate-immich-to-ringtail]], the first chain in the indri-k8s +decommission. + +## End state + +- `paperless`, `teslamate`, and `mealie` run on ringtail k3s in their + own namespaces, off minikube entirely. +- A CNPG `blumeops-pg` Cluster runs in a `databases` namespace on + ringtail (PostgreSQL, owned by ringtail's `cnpg-system` operator), + holding the `paperless` and `teslamate` databases. Apps reach it + in-cluster via `blumeops-pg-rw.databases.svc.cluster.local`. +- mealie keeps its SQLite database; its 2 GiB `mealie-data` PVC is + copied to a ringtail PVC. +- paperless media still lives on [[sifaka]] via NFS (RWX, 500 GiB), + mounted from ringtail pods. teslamate has no file state. +- Routing: `paperless.ops.eblu.me`, `teslamate.ops.eblu.me`, and + `mealie.ops.eblu.me` (Caddy on indri) proxy to Tailscale + ProxyGroup ingresses on ringtail. Service names are unchanged. +- The minikube manifests and the `paperless`/`teslamate`/`mealie` + databases inside indri's `blumeops-pg` are removed only after + cutover is verified. + +## Non-goals + +- Migrating the rest of `blumeops-pg` (e.g. miniflux) — that is a + later wave. This chain moves only the paperless + teslamate + databases out; the source cluster on indri stays up for the others. +- Version bumps or config changes. Lift-and-shift only. +- Public (Fly) exposure changes. These stay tailnet-only. +- The observability stack (prometheus/loki/tempo/grafana) — deferred; + it carries 50 GiB of local TSDB and is the riskiest move. + +## Critical constraint: no data loss + +**Downtime is acceptable — data loss is not.** We can take each +service fully offline for its cutover, which removes the entire +class of streaming-replication and double-writer hazards. The cold +dump is taken from a *quiesced* source, so it is internally +consistent. + +Data surfaces: + +1. **paperless postgres** — document metadata, tags, correspondents, + the search index state. The document *files* are on NFS and never + move, but losing the DB means files-without-index. This is the + surface to protect most carefully. +2. **teslamate postgres** — drive/charge history. Re-derivable only + from Tesla's API for a limited window; treat as unrecoverable. +3. **mealie SQLite** — recipes, meal plans. On the `mealie-data` PVC. + +The source databases on indri are **never dropped until the ringtail +side is verified and serving**. Rollback is "repoint and scale back +up," not "restore from backup." [[borgmatic]] remains the backstop. + +## Why a fresh CNPG cluster (not cross-cluster pg) + +indri's `blumeops-pg` is already exposed tailnet-wide at +`pg.ops.eblu.me` (Caddy L4), so we *could* leave the DBs on indri and +just move the app pods. We are not, because: + +- The goal is to retire minikube — keeping pg there blocks it and + leaves a cross-host runtime dependency (ringtail apps SPOF on + indri's pg over the tailnet). +- CNPG is the same operator on both clusters; a Cluster CR on ringtail + is mechanically equivalent to the one on minikube. +- Naming the ringtail cluster `blumeops-pg` in `databases` lets apps + use the same in-cluster DNS they would on indri. + +## Cold-cutover procedure (per service) + +Do these one service at a time. paperless first (heaviest, highest +data-sensitivity), then teslamate, then mealie. + +### 0. Prerequisites (once, before any service) + +- Confirm ringtail's `cnpg-system` operator and `databases` namespace + are healthy (immich-pg already runs there). +- Confirm ringtail pods can reach indri's `pg.ops.eblu.me:5432` (used + only to pull the dump) and the sifaka NFS export for paperless + media. See [[sifaka-nfs-from-ringtail]]. +- Define the ringtail `blumeops-pg` CNPG Cluster manifest (model on + `databases-ringtail/immich-pg.yaml`) and its ExternalSecrets for + the per-app roles. Sync it; let it come up empty and healthy. + +### 1. Quiesce the source + +```fish +kubectl --context=minikube-indri -n scale deploy/ --replicas=0 +# confirm 0 running, DB now has no writers +``` + +### 2. Dump from indri, restore to ringtail (postgres apps) + +```fish +# dump the single app DB from the quiesced source +kubectl --context=minikube-indri -n databases exec blumeops-pg-1 -- \ + pg_dump -Fc -d > /tmp/.dump + +# restore into the ringtail cluster +kubectl --context=k3s-ringtail -n databases exec -i blumeops-pg-1 -- \ + pg_restore --no-owner --role= -d < /tmp/.dump +``` + +For **mealie** (SQLite) instead: copy the `mealie-data` PVC contents +to the ringtail PVC (e.g. a one-shot rsync pod mounting both, or +`kubectl cp` via a helper pod). Verify the `.db` file size and that +mealie boots read-only against it. + +### 3. Verify the restore (before any routing flips) + +- Row counts match source for the key tables, scripted: + - paperless: `documents_document`, `documents_tag`, + `documents_correspondent`, `auth_user`. + - teslamate: `cars`, `drives`, `charging_processes`, `positions`. +- `pg_dump --schema-only --no-owner` diff between source and dest is + empty modulo CNPG-managed roles. +- Boot the app against the ringtail DB on its tailnet name *before* + Caddy is flipped, and smoke-test (paperless: documents list + + search; teslamate: dashboard loads recent drives; mealie: recipes + list). + +### 4. Release the service name + +```fish +# delete the minikube tailscale ingress so ringtail can claim the name +kubectl --context=minikube-indri -n delete ingress -tailscale +``` + +### 5. Bring up on ringtail + +- Apply the ringtail manifests (new ArgoCD app `-ringtail`, + `destination.server` = `https://ringtail.tail8d86e.ts.net:6443`). + App points at `blumeops-pg-rw.databases.svc.cluster.local`. +- Sync; wait for healthy + the ProxyGroup ingress to get its name. + +### 6. Flip routing + +- Repoint the Caddy `.ops.eblu.me` upstream at the ringtail + ProxyGroup ingress (provision-indri, caddy role). +- `mise run services-check` — confirm the service flips from FIRING + to OK and no neighbours regressed. + +### 7. Decommission the source (only after verification) + +- Remove the minikube manifests for the app. +- Drop the app DB from indri's `blumeops-pg` (paperless/teslamate) + **last**, once the ringtail side has served real traffic. + +## Rollback + +If a cutover fails verification at any step before §7: + +- Re-create the minikube tailscale ingress (if §4 ran). +- Scale the minikube app back to `1`. +- Repoint Caddy back to the minikube ingress. +- The source DB was never modified or dropped. Document the failure. diff --git a/service-versions.yaml b/service-versions.yaml index 5440f01..699f89c 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -47,7 +47,7 @@ services: - name: shower type: argocd last-reviewed: 2026-05-15 - current-version: "1.1.2" + current-version: "1.1.3" upstream-source: https://forge.eblu.me/eblume/adelaide-baby-shower-app notes: | Django app for Adelaide / Heidi / Addie's baby shower. Wheel @@ -222,9 +222,17 @@ services: - name: teslamate type: argocd - last-reviewed: 2026-04-14 + last-reviewed: "2026-06-03" current-version: "v3.0.0" upstream-source: https://github.com/teslamate-org/teslamate/releases + notes: >- + Tesla data logger. Container ported from Dagger (container.py) to Nix + (containers/teslamate/default.nix) — a from-scratch beamPackages + mixRelease (Elixir/Phoenix release with npm-built assets), since + teslamate is not in nixpkgs. Pins erlang_27 + elixir_1_18 from the + shared nixos-unstable rev; assets via in-release npm ci + esbuild; + ex_cldr locale data pre-fetched (LOCALES env) to avoid sandbox + downloads. Version unchanged (v3.0.0). Build verified on ringtail. - name: transmission type: argocd @@ -328,21 +336,31 @@ services: - name: mealie type: argocd - last-reviewed: 2026-05-11 - current-version: "v3.12.0" + last-reviewed: "2026-06-03" + current-version: "v3.16.0" upstream-source: https://github.com/mealie-recipes/mealie/releases notes: >- - Recipe manager; built from source via forge mirror. - Upstream is at v3.17.0 as of 2026-05-11 (5 minor versions ahead). - Container/manifest still pinned to v3.12.0 — upgrade deferred to a - separate task (build new image, review changelog for breaking changes). + Recipe manager. Container ported from Dockerfile to Nix + (containers/mealie/default.nix wraps nixpkgs mealie from a pinned + nixos-unstable; single gunicorn process, SQLite on the mealie-data + PVC). Bumped v3.12.0 -> v3.16.0 as part of the port (the deferred + upgrade). Breaking-change review v3.13-v3.16: no schema breaking + changes, SQLite auto-migrates forward via init_db; notable items are + minor (OIDC missing-claims log -> DEBUG, NLP parser uses user-defined + units, Nuxt 3->4 frontend, new Announcements feature, path-traversal + patches). Source PVC retained for rollback. Build verified on ringtail. - name: paperless type: argocd - last-reviewed: "2026-04-08" - current-version: "v2.20.13" + last-reviewed: "2026-06-03" + current-version: "v2.20.15" upstream-source: https://github.com/paperless-ngx/paperless-ngx/releases - notes: Document management; built from source via forge mirror + notes: >- + Document management. Container ported from Dockerfile to Nix + (containers/paperless/default.nix wraps nixpkgs paperless-ngx from a + pinned nixos-unstable). Runs as web/worker/beat/consumer containers on + ringtail (multi-process; no s6). Bumped v2.20.13 -> v2.20.15 (the + unstable package version, same-minor patch) as part of the port. - name: unpoller type: argocd From 92b54e7ba9a41b461a423cfdd5a53278a7e4ac40 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 3 Jun 2026 10:36:15 -0700 Subject: [PATCH 411/430] C0: ringtail wave-1 images rebuilt from main (fcac8e5-nix tags) Post-merge rebuild of paperless/mealie/teslamate Nix images at the main merge commit, replacing the feature-branch -nix tags. Image content is identical; only the commit-sha suffix changes. Co-Authored-By: Claude Opus 4.8 (1M context) --- argocd/manifests/mealie-ringtail/kustomization.yaml | 2 +- argocd/manifests/paperless-ringtail/kustomization.yaml | 2 +- argocd/manifests/teslamate-ringtail/kustomization.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/argocd/manifests/mealie-ringtail/kustomization.yaml b/argocd/manifests/mealie-ringtail/kustomization.yaml index 8428042..2b6a7ef 100644 --- a/argocd/manifests/mealie-ringtail/kustomization.yaml +++ b/argocd/manifests/mealie-ringtail/kustomization.yaml @@ -12,4 +12,4 @@ resources: images: - name: registry.ops.eblu.me/blumeops/mealie - newTag: v3.16.0-1d4cbbf-nix + newTag: v3.16.0-fcac8e5-nix diff --git a/argocd/manifests/paperless-ringtail/kustomization.yaml b/argocd/manifests/paperless-ringtail/kustomization.yaml index 0a691e0..41665b8 100644 --- a/argocd/manifests/paperless-ringtail/kustomization.yaml +++ b/argocd/manifests/paperless-ringtail/kustomization.yaml @@ -13,7 +13,7 @@ resources: images: - name: registry.ops.eblu.me/blumeops/paperless - newTag: v2.20.15-1d4cbbf-nix + newTag: v2.20.15-fcac8e5-nix # 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 diff --git a/argocd/manifests/teslamate-ringtail/kustomization.yaml b/argocd/manifests/teslamate-ringtail/kustomization.yaml index f31fe09..acb623e 100644 --- a/argocd/manifests/teslamate-ringtail/kustomization.yaml +++ b/argocd/manifests/teslamate-ringtail/kustomization.yaml @@ -12,4 +12,4 @@ resources: images: - name: registry.ops.eblu.me/blumeops/teslamate - newTag: v3.0.0-191be1b-nix + newTag: v3.0.0-fcac8e5-nix From e0057b46e4c7266fc4c01db7a88af69ae65ff655 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 3 Jun 2026 12:25:30 -0700 Subject: [PATCH 412/430] Wire ringtail blumeops-pg into backups + Grafana (#364) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prereq for the wave-1 decommission. The cutover moved paperless+teslamate (postgres) and mealie (SQLite) to ringtail, but borgmatic and the Grafana TeslaMate datasource still pointed at the minikube copies — the migrated live data was unbacked since cutover, and dropping the minikube DBs would break the TeslaMate dashboards. - Tailscale Service `blumeops-pg-ringtail` + Caddy L4 route `pg.ops.eblu.me:5434` - borgmatic: teslamate + paperless postgres → :5434; mealie SQLite → ssh:eblume@ringtail - Grafana TeslaMate datasource → pg.ops.eblu.me:5434 Deploy: sync databases-ringtail (tailscale svc) + grafana from branch; provision-indri --tags caddy,borgmatic; verify a backup run + dashboards. Unblocks the decommission PR. Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/364 --- ansible/roles/borgmatic/defaults/main.yml | 16 +++++++------ ansible/roles/borgmatic/tasks/main.yml | 2 ++ .../borgmatic/templates/k8s-sqlite-dump.sh.j2 | 4 +++- ansible/roles/caddy/defaults/main.yml | 2 ++ .../databases-ringtail/kustomization.yaml | 1 + .../service-blumeops-pg-tailscale.yaml | 24 +++++++++++++++++++ argocd/manifests/grafana/datasources.yaml | 4 +++- .../mealie-ringtail/kustomization.yaml | 2 +- containers/mealie/default.nix | 4 ++++ ...ckup-grafana-ringtail-blumeops-pg.infra.md | 8 +++++++ 10 files changed, 57 insertions(+), 10 deletions(-) create mode 100644 argocd/manifests/databases-ringtail/service-blumeops-pg-tailscale.yaml create mode 100644 docs/changelog.d/backup-grafana-ringtail-blumeops-pg.infra.md diff --git a/ansible/roles/borgmatic/defaults/main.yml b/ansible/roles/borgmatic/defaults/main.yml index 3a89a09..a743161 100644 --- a/ansible/roles/borgmatic/defaults/main.yml +++ b/ansible/roles/borgmatic/defaults/main.yml @@ -56,8 +56,9 @@ borgmatic_k8s_sqlite_dumps: namespace: mealie label_selector: app=mealie db_path: /app/data/mealie.db - # local kubectl, --context=minikube (indri's only configured ctx) - target: local:minikube + # migrated to ringtail (wave-1); ssh to ringtail and run k3s kubectl + # there, same as shower below. + target: ssh:eblume@ringtail - name: shower namespace: shower label_selector: app=shower @@ -102,17 +103,18 @@ borgmatic_postgresql_databases: hostname: pg.ops.eblu.me port: 5432 username: borgmatic - - name: teslamate - hostname: pg.ops.eblu.me - port: 5432 - username: borgmatic - name: authentik hostname: pg.ops.eblu.me port: 5432 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 hostname: pg.ops.eblu.me - port: 5432 + port: 5434 username: borgmatic # immich-pg cluster (VectorChord) via Caddy L4 on port 5433 - name: immich diff --git a/ansible/roles/borgmatic/tasks/main.yml b/ansible/roles/borgmatic/tasks/main.yml index 4ac242c..36d3bb6 100644 --- a/ansible/roles/borgmatic/tasks/main.yml +++ b/ansible/roles/borgmatic/tasks/main.yml @@ -19,8 +19,10 @@ ansible.builtin.copy: content: | # 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:5433:*:borgmatic:{{ borgmatic_db_password }} + pg.ops.eblu.me:5434:*:borgmatic:{{ borgmatic_db_password }} dest: ~/.pgpass mode: '0600' no_log: true diff --git a/ansible/roles/borgmatic/templates/k8s-sqlite-dump.sh.j2 b/ansible/roles/borgmatic/templates/k8s-sqlite-dump.sh.j2 index 323e717..9cc24da 100644 --- a/ansible/roles/borgmatic/templates/k8s-sqlite-dump.sh.j2 +++ b/ansible/roles/borgmatic/templates/k8s-sqlite-dump.sh.j2 @@ -28,7 +28,9 @@ db_path=${4:?missing db path} name=${5:?missing name} dump_target=${6:?missing dump target} -pod_tmp="/tmp/${name}-backup.db" +# Stage the backup next to the source DB (a guaranteed-writable volume); +# 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"'"))' diff --git a/ansible/roles/caddy/defaults/main.yml b/ansible/roles/caddy/defaults/main.yml index da6f3f9..363d09e 100644 --- a/ansible/roles/caddy/defaults/main.yml +++ b/ansible/roles/caddy/defaults/main.yml @@ -117,6 +117,8 @@ caddy_tcp_services: backend: "pg.tail8d86e.ts.net:5432" # PostgreSQL (blumeops-pg) - port: 5433 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 }}" backend: "sifaka:{{ sifaka_node_exporter_port }}" # Sifaka node_exporter - port: "{{ sifaka_smartctl_exporter_port }}" diff --git a/argocd/manifests/databases-ringtail/kustomization.yaml b/argocd/manifests/databases-ringtail/kustomization.yaml index 2bc2af3..143345c 100644 --- a/argocd/manifests/databases-ringtail/kustomization.yaml +++ b/argocd/manifests/databases-ringtail/kustomization.yaml @@ -9,6 +9,7 @@ resources: - 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 diff --git a/argocd/manifests/databases-ringtail/service-blumeops-pg-tailscale.yaml b/argocd/manifests/databases-ringtail/service-blumeops-pg-tailscale.yaml new file mode 100644 index 0000000..f7ca5ef --- /dev/null +++ b/argocd/manifests/databases-ringtail/service-blumeops-pg-tailscale.yaml @@ -0,0 +1,24 @@ +# 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 diff --git a/argocd/manifests/grafana/datasources.yaml b/argocd/manifests/grafana/datasources.yaml index 5a3d0f3..64ed2bf 100644 --- a/argocd/manifests/grafana/datasources.yaml +++ b/argocd/manifests/grafana/datasources.yaml @@ -63,5 +63,7 @@ datasources: password: $TESLAMATE_DB_PASSWORD type: postgres uid: TeslaMate - url: blumeops-pg-rw.databases.svc.cluster.local:5432 + # teslamate DB migrated to ringtail blumeops-pg (wave-1); reached via the + # Caddy L4 route on indri (pg.ops.eblu.me:5434 -> blumeops-pg-ringtail). + url: pg.ops.eblu.me:5434 user: teslamate diff --git a/argocd/manifests/mealie-ringtail/kustomization.yaml b/argocd/manifests/mealie-ringtail/kustomization.yaml index 2b6a7ef..7679032 100644 --- a/argocd/manifests/mealie-ringtail/kustomization.yaml +++ b/argocd/manifests/mealie-ringtail/kustomization.yaml @@ -12,4 +12,4 @@ resources: images: - name: registry.ops.eblu.me/blumeops/mealie - newTag: v3.16.0-fcac8e5-nix + newTag: v3.16.0-22cfd86-nix diff --git a/containers/mealie/default.nix b/containers/mealie/default.nix index fdb1430..e55efe3 100644 --- a/containers/mealie/default.nix +++ b/containers/mealie/default.nix @@ -48,6 +48,10 @@ pkgs.dockerTools.buildLayeredImage { 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 = { diff --git a/docs/changelog.d/backup-grafana-ringtail-blumeops-pg.infra.md b/docs/changelog.d/backup-grafana-ringtail-blumeops-pg.infra.md new file mode 100644 index 0000000..33b041f --- /dev/null +++ b/docs/changelog.d/backup-grafana-ringtail-blumeops-pg.infra.md @@ -0,0 +1,8 @@ +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. From 44798a6429adea3822041755af5ddd22ac149b98 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 3 Jun 2026 12:26:55 -0700 Subject: [PATCH 413/430] C0: mealie-ringtail image rebuilt from main (e0057b4-nix) Co-Authored-By: Claude Opus 4.8 (1M context) --- argocd/manifests/mealie-ringtail/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/argocd/manifests/mealie-ringtail/kustomization.yaml b/argocd/manifests/mealie-ringtail/kustomization.yaml index 7679032..ad65785 100644 --- a/argocd/manifests/mealie-ringtail/kustomization.yaml +++ b/argocd/manifests/mealie-ringtail/kustomization.yaml @@ -12,4 +12,4 @@ resources: images: - name: registry.ops.eblu.me/blumeops/mealie - newTag: v3.16.0-22cfd86-nix + newTag: v3.16.0-e0057b4-nix From 46f00021781e835fddc80de06588fb4ae87d5f5f Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 3 Jun 2026 12:36:06 -0700 Subject: [PATCH 414/430] Decommission wave-1 minikube services (paperless, teslamate, mealie) (#365) Final step of the wave-1 indri-k8s migration. paperless, teslamate, mealie run on ringtail with data migrated, verified, and backed up (local + BorgBase offsite via PR #364). - Remove minikube paperless/teslamate/mealie manifest dirs + ArgoCD app defs (prunes the parked Deployments/Services + redundant minikube mealie/paperless PVCs) - Drop paperless/teslamate roles + ExternalSecrets from the minikube blumeops-pg cluster - miniflux + authentik stay on minikube (later waves) Finalization after merge: sync apps + databases to prune, then DROP DATABASE paperless/teslamate on indri's blumeops-pg (fresh safety dump taken first). Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/365 --- argocd/apps/mealie.yaml | 17 --- argocd/apps/paperless.yaml | 17 --- argocd/apps/teslamate.yaml | 32 ----- argocd/manifests/databases/blumeops-pg.yaml | 23 +-- .../databases/external-secret-paperless.yaml | 28 ---- .../databases/external-secret-teslamate.yaml | 30 ---- argocd/manifests/databases/kustomization.yaml | 2 - argocd/manifests/mealie/deployment.yaml | 96 ------------- argocd/manifests/mealie/external-secret.yaml | 23 --- argocd/manifests/mealie/kustomization.yaml | 15 -- argocd/manifests/mealie/pvc.yaml | 13 -- argocd/manifests/mealie/service.yaml | 13 -- argocd/manifests/paperless/deployment.yaml | 133 ------------------ .../manifests/paperless/external-secret.yaml | 31 ---- argocd/manifests/paperless/kustomization.yaml | 19 --- argocd/manifests/paperless/pv-nfs.yaml | 22 --- argocd/manifests/paperless/pvc.yaml | 15 -- argocd/manifests/paperless/service.yaml | 13 -- argocd/manifests/teslamate/README.md | 69 --------- argocd/manifests/teslamate/deployment.yaml | 68 --------- .../teslamate/external-secret-db.yaml | 25 ---- .../external-secret-encryption-key.yaml | 27 ---- argocd/manifests/teslamate/kustomization.yaml | 15 -- argocd/manifests/teslamate/service.yaml | 12 -- .../decommission-wave1-minikube.infra.md | 8 ++ 25 files changed, 11 insertions(+), 755 deletions(-) delete mode 100644 argocd/apps/mealie.yaml delete mode 100644 argocd/apps/paperless.yaml delete mode 100644 argocd/apps/teslamate.yaml delete mode 100644 argocd/manifests/databases/external-secret-paperless.yaml delete mode 100644 argocd/manifests/databases/external-secret-teslamate.yaml delete mode 100644 argocd/manifests/mealie/deployment.yaml delete mode 100644 argocd/manifests/mealie/external-secret.yaml delete mode 100644 argocd/manifests/mealie/kustomization.yaml delete mode 100644 argocd/manifests/mealie/pvc.yaml delete mode 100644 argocd/manifests/mealie/service.yaml delete mode 100644 argocd/manifests/paperless/deployment.yaml delete mode 100644 argocd/manifests/paperless/external-secret.yaml delete mode 100644 argocd/manifests/paperless/kustomization.yaml delete mode 100644 argocd/manifests/paperless/pv-nfs.yaml delete mode 100644 argocd/manifests/paperless/pvc.yaml delete mode 100644 argocd/manifests/paperless/service.yaml delete mode 100644 argocd/manifests/teslamate/README.md delete mode 100644 argocd/manifests/teslamate/deployment.yaml delete mode 100644 argocd/manifests/teslamate/external-secret-db.yaml delete mode 100644 argocd/manifests/teslamate/external-secret-encryption-key.yaml delete mode 100644 argocd/manifests/teslamate/kustomization.yaml delete mode 100644 argocd/manifests/teslamate/service.yaml create mode 100644 docs/changelog.d/decommission-wave1-minikube.infra.md diff --git a/argocd/apps/mealie.yaml b/argocd/apps/mealie.yaml deleted file mode 100644 index af33469..0000000 --- a/argocd/apps/mealie.yaml +++ /dev/null @@ -1,17 +0,0 @@ -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 diff --git a/argocd/apps/paperless.yaml b/argocd/apps/paperless.yaml deleted file mode 100644 index 88437eb..0000000 --- a/argocd/apps/paperless.yaml +++ /dev/null @@ -1,17 +0,0 @@ -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 diff --git a/argocd/apps/teslamate.yaml b/argocd/apps/teslamate.yaml deleted file mode 100644 index 60247da..0000000 --- a/argocd/apps/teslamate.yaml +++ /dev/null @@ -1,32 +0,0 @@ -# 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 diff --git a/argocd/manifests/databases/blumeops-pg.yaml b/argocd/manifests/databases/blumeops-pg.yaml index 58c771a..37aef23 100644 --- a/argocd/manifests/databases/blumeops-pg.yaml +++ b/argocd/manifests/databases/blumeops-pg.yaml @@ -44,18 +44,9 @@ spec: - pg_read_all_data passwordSecret: name: blumeops-pg-borgmatic - # teslamate user for TeslaMate Tesla data logger - # Superuser removed. Extension ownership (cube, earthdistance) - # 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 + # teslamate + paperless roles removed: migrated to ringtail blumeops-pg + # (wave-1 decommission). Their databases were dropped from this cluster + # after the cutover was verified and backed up. # authentik user for Authentik identity provider (runs on ringtail) - name: authentik login: true @@ -65,14 +56,6 @@ spec: createdb: true passwordSecret: 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 resources: diff --git a/argocd/manifests/databases/external-secret-paperless.yaml b/argocd/manifests/databases/external-secret-paperless.yaml deleted file mode 100644 index e5742be..0000000 --- a/argocd/manifests/databases/external-secret-paperless.yaml +++ /dev/null @@ -1,28 +0,0 @@ -# ExternalSecret for Paperless database user password -# -# 1Password item: "Paperless (blumeops)" in blumeops vault -# Field: "postgresql-password" -# -apiVersion: external-secrets.io/v1 -kind: ExternalSecret -metadata: - name: blumeops-pg-paperless - namespace: databases -spec: - refreshInterval: 1h - secretStoreRef: - kind: ClusterSecretStore - name: onepassword-blumeops - target: - name: blumeops-pg-paperless - creationPolicy: Owner - template: - type: kubernetes.io/basic-auth - data: - username: paperless - password: "{{ .password }}" - data: - - secretKey: password - remoteRef: - key: Paperless (blumeops) - property: postgresql-password diff --git a/argocd/manifests/databases/external-secret-teslamate.yaml b/argocd/manifests/databases/external-secret-teslamate.yaml deleted file mode 100644 index 0c52e0b..0000000 --- a/argocd/manifests/databases/external-secret-teslamate.yaml +++ /dev/null @@ -1,30 +0,0 @@ -# ExternalSecret for TeslaMate database user password -# -# Replaces the manual op inject workflow from secret-teslamate.yaml.tpl -# -# 1Password item: "TeslaMate" in blumeops vault -# Field: "db_password" -# -apiVersion: external-secrets.io/v1 -kind: ExternalSecret -metadata: - name: blumeops-pg-teslamate - namespace: databases -spec: - refreshInterval: 1h - secretStoreRef: - kind: ClusterSecretStore - name: onepassword-blumeops - target: - name: blumeops-pg-teslamate - creationPolicy: Owner - template: - type: kubernetes.io/basic-auth - data: - username: teslamate - password: "{{ .password }}" - data: - - secretKey: password - remoteRef: - key: TeslaMate - property: db_password diff --git a/argocd/manifests/databases/kustomization.yaml b/argocd/manifests/databases/kustomization.yaml index 692285a..0393757 100644 --- a/argocd/manifests/databases/kustomization.yaml +++ b/argocd/manifests/databases/kustomization.yaml @@ -9,6 +9,4 @@ resources: - service-metrics-tailscale.yaml - external-secret-eblume.yaml - external-secret-borgmatic.yaml - - external-secret-teslamate.yaml - external-secret-authentik.yaml - - external-secret-paperless.yaml diff --git a/argocd/manifests/mealie/deployment.yaml b/argocd/manifests/mealie/deployment.yaml deleted file mode 100644 index 7cdd275..0000000 --- a/argocd/manifests/mealie/deployment.yaml +++ /dev/null @@ -1,96 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: mealie - namespace: mealie -spec: - # Migrated to ringtail (mealie-ringtail). Scaled to 0; SQLite PVC retained - # for rollback until the decommission PR. See [[migrate-wave1-ringtail]]. - replicas: 0 - selector: - matchLabels: - app: mealie - template: - metadata: - labels: - app: mealie - spec: - securityContext: - seccompProfile: - type: RuntimeDefault - containers: - - name: mealie - image: registry.ops.eblu.me/blumeops/mealie:kustomized - ports: - - containerPort: 9000 - env: - - name: BASE_URL - value: "https://meals.ops.eblu.me" - - name: ALLOW_SIGNUP - value: "false" - - name: TZ - value: "America/Los_Angeles" - - name: MAX_WORKERS - value: "1" - - name: WEB_CONCURRENCY - value: "1" - # OIDC — Authentik (public client, PKCE) - - name: OIDC_AUTH_ENABLED - value: "true" - - name: OIDC_CONFIGURATION_URL - value: "https://authentik.ops.eblu.me/application/o/mealie/.well-known/openid-configuration" - - name: OIDC_CLIENT_ID - value: "mealie" - - name: OIDC_CLIENT_SECRET - valueFrom: - secretKeyRef: - name: mealie-secrets - key: oidc-client-secret - - name: OIDC_AUTO_REDIRECT - value: "false" - - name: OIDC_PROVIDER_NAME - value: "Authentik" - - name: OIDC_ADMIN_GROUP - value: "admins" - - name: OIDC_SIGNUP_ENABLED - value: "true" - - name: OIDC_USER_CLAIM - value: "email" - # OpenAI — recipe parsing, image OCR, ingredient extraction - - name: OPENAI_API_KEY - valueFrom: - secretKeyRef: - name: mealie-secrets - key: openai-api-key - - name: OPENAI_MODEL - value: "gpt-4o" - - name: OPENAI_REQUEST_TIMEOUT - value: "120" - - name: OPENAI_WORKERS - value: "1" - volumeMounts: - - name: data - mountPath: /app/data - resources: - requests: - memory: "128Mi" - cpu: "50m" - limits: - memory: "1000Mi" - cpu: "500m" - livenessProbe: - httpGet: - path: /api/app/about - port: 9000 - initialDelaySeconds: 30 - periodSeconds: 30 - readinessProbe: - httpGet: - path: /api/app/about - port: 9000 - initialDelaySeconds: 10 - periodSeconds: 10 - volumes: - - name: data - persistentVolumeClaim: - claimName: mealie-data diff --git a/argocd/manifests/mealie/external-secret.yaml b/argocd/manifests/mealie/external-secret.yaml deleted file mode 100644 index 99c2793..0000000 --- a/argocd/manifests/mealie/external-secret.yaml +++ /dev/null @@ -1,23 +0,0 @@ ---- -apiVersion: external-secrets.io/v1 -kind: ExternalSecret -metadata: - name: mealie-secrets - namespace: mealie -spec: - refreshInterval: 1h - secretStoreRef: - kind: ClusterSecretStore - name: onepassword-blumeops - target: - name: mealie-secrets - creationPolicy: Owner - data: - - secretKey: oidc-client-secret - remoteRef: - key: "Authentik (blumeops)" - property: mealie-client-secret - - secretKey: openai-api-key - remoteRef: - key: "openai (blumeops)" - property: credential diff --git a/argocd/manifests/mealie/kustomization.yaml b/argocd/manifests/mealie/kustomization.yaml deleted file mode 100644 index 02563f4..0000000 --- a/argocd/manifests/mealie/kustomization.yaml +++ /dev/null @@ -1,15 +0,0 @@ -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization - -namespace: mealie - -resources: - - deployment.yaml - - service.yaml - - pvc.yaml - # ingress removed: name 'meals' handed off to mealie-ringtail at cutover - - external-secret.yaml - -images: - - name: registry.ops.eblu.me/blumeops/mealie - newTag: v3.12.0-613f05d diff --git a/argocd/manifests/mealie/pvc.yaml b/argocd/manifests/mealie/pvc.yaml deleted file mode 100644 index f473e07..0000000 --- a/argocd/manifests/mealie/pvc.yaml +++ /dev/null @@ -1,13 +0,0 @@ ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: mealie-data - namespace: mealie -spec: - accessModes: - - ReadWriteOnce - storageClassName: standard - resources: - requests: - storage: 2Gi diff --git a/argocd/manifests/mealie/service.yaml b/argocd/manifests/mealie/service.yaml deleted file mode 100644 index 4162b96..0000000 --- a/argocd/manifests/mealie/service.yaml +++ /dev/null @@ -1,13 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: mealie - namespace: mealie -spec: - selector: - app: mealie - ports: - - name: http - port: 9000 - targetPort: 9000 - protocol: TCP diff --git a/argocd/manifests/paperless/deployment.yaml b/argocd/manifests/paperless/deployment.yaml deleted file mode 100644 index 1730486..0000000 --- a/argocd/manifests/paperless/deployment.yaml +++ /dev/null @@ -1,133 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: paperless - namespace: paperless -spec: - # Migrated to ringtail (paperless-ringtail). Scaled to 0 to prevent - # double-writing the now-ringtail-owned database; manifest retained for - # rollback until the decommission PR. See [[migrate-wave1-ringtail]]. - replicas: 0 - selector: - matchLabels: - app: paperless - template: - metadata: - labels: - app: paperless - spec: - securityContext: - seccompProfile: - type: RuntimeDefault - containers: - - name: paperless - image: registry.ops.eblu.me/blumeops/paperless:kustomized - ports: - - containerPort: 8000 - name: http - env: - - name: PAPERLESS_URL - value: "https://paperless.ops.eblu.me" - - name: PAPERLESS_REDIS - value: "redis://localhost:6379" - - name: PAPERLESS_DBHOST - value: "pg.ops.eblu.me" - - name: PAPERLESS_DBPORT - value: "5432" - - name: PAPERLESS_DBNAME - 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 - value: "paperless" - - name: PAPERLESS_DBPASS - valueFrom: - secretKeyRef: - name: paperless-secrets - key: db-password - - name: PAPERLESS_SECRET_KEY - valueFrom: - secretKeyRef: - name: paperless-secrets - key: secret-key - - name: PAPERLESS_TIME_ZONE - value: "America/Los_Angeles" - - name: PAPERLESS_OCR_LANGUAGE - value: "eng" - - name: PAPERLESS_TASK_WORKERS - value: "1" - # Admin account (created on first startup) - - name: PAPERLESS_ADMIN_USER - value: "eblume" - - name: PAPERLESS_ADMIN_PASSWORD - valueFrom: - secretKeyRef: - name: paperless-secrets - key: admin-password - - name: PAPERLESS_ADMIN_MAIL - value: "blume.erich@gmail.com" - # OIDC via Authentik - # Full JSON blob pulled from 1Password (includes client secret) - - name: PAPERLESS_APPS - value: "allauth.socialaccount.providers.openid_connect" - - name: PAPERLESS_SOCIALACCOUNT_PROVIDERS - valueFrom: - secretKeyRef: - name: paperless-secrets - key: socialaccount-providers - - name: PAPERLESS_SOCIALACCOUNT_ALLOW_SIGNUPS - value: "true" - - name: PAPERLESS_SOCIAL_AUTO_SIGNUP - value: "true" - - name: PAPERLESS_ACCOUNT_ALLOW_SIGNUPS - value: "false" - - name: PAPERLESS_REDIRECT_LOGIN_TO_SSO - value: "false" - volumeMounts: - - name: data - mountPath: /usr/src/paperless/data - - name: media - mountPath: /usr/src/paperless/media - - name: consume - mountPath: /usr/src/paperless/consume - resources: - requests: - memory: "256Mi" - cpu: "100m" - limits: - memory: "2Gi" - cpu: "1000m" - livenessProbe: - httpGet: - path: / - port: 8000 - initialDelaySeconds: 60 - periodSeconds: 30 - readinessProbe: - httpGet: - path: / - port: 8000 - initialDelaySeconds: 30 - periodSeconds: 10 - - - name: redis - image: docker.io/library/redis:kustomized - ports: - - containerPort: 6379 - resources: - requests: - memory: "32Mi" - cpu: "10m" - limits: - memory: "128Mi" - - volumes: - - name: data - emptyDir: {} - - name: media - persistentVolumeClaim: - claimName: paperless-media - - name: consume - emptyDir: {} diff --git a/argocd/manifests/paperless/external-secret.yaml b/argocd/manifests/paperless/external-secret.yaml deleted file mode 100644 index 750b7c5..0000000 --- a/argocd/manifests/paperless/external-secret.yaml +++ /dev/null @@ -1,31 +0,0 @@ ---- -apiVersion: external-secrets.io/v1 -kind: ExternalSecret -metadata: - name: paperless-secrets - namespace: paperless -spec: - refreshInterval: 1h - secretStoreRef: - kind: ClusterSecretStore - name: onepassword-blumeops - target: - name: paperless-secrets - creationPolicy: Owner - data: - - secretKey: db-password - remoteRef: - key: "Paperless (blumeops)" - property: postgresql-password - - secretKey: secret-key - remoteRef: - key: "Paperless (blumeops)" - property: secret-key - - secretKey: admin-password - remoteRef: - key: "Paperless (blumeops)" - property: admin-password - - secretKey: socialaccount-providers - remoteRef: - key: "Paperless (blumeops)" - property: socialaccount-providers diff --git a/argocd/manifests/paperless/kustomization.yaml b/argocd/manifests/paperless/kustomization.yaml deleted file mode 100644 index a92a769..0000000 --- a/argocd/manifests/paperless/kustomization.yaml +++ /dev/null @@ -1,19 +0,0 @@ -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization - -namespace: paperless - -resources: - - deployment.yaml - - service.yaml - - pv-nfs.yaml - - pvc.yaml - # ingress removed: name 'paperless' handed off to paperless-ringtail at cutover - - external-secret.yaml - -images: - - name: registry.ops.eblu.me/blumeops/paperless - newTag: v2.20.13-07f52e9 - - name: docker.io/library/redis - newName: registry.ops.eblu.me/blumeops/valkey - newTag: v8.1.7-ecded30 diff --git a/argocd/manifests/paperless/pv-nfs.yaml b/argocd/manifests/paperless/pv-nfs.yaml deleted file mode 100644 index 8ee7526..0000000 --- a/argocd/manifests/paperless/pv-nfs.yaml +++ /dev/null @@ -1,22 +0,0 @@ -# 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 diff --git a/argocd/manifests/paperless/pvc.yaml b/argocd/manifests/paperless/pvc.yaml deleted file mode 100644 index 4365c9f..0000000 --- a/argocd/manifests/paperless/pvc.yaml +++ /dev/null @@ -1,15 +0,0 @@ -# PersistentVolumeClaim for Paperless document library -# Binds to the NFS PV for sifaka:/volume1/paperless -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: paperless-media - namespace: paperless -spec: - accessModes: - - ReadWriteMany - storageClassName: "" - volumeName: paperless-media-nfs-pv - resources: - requests: - storage: 500Gi diff --git a/argocd/manifests/paperless/service.yaml b/argocd/manifests/paperless/service.yaml deleted file mode 100644 index cff2972..0000000 --- a/argocd/manifests/paperless/service.yaml +++ /dev/null @@ -1,13 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: paperless - namespace: paperless -spec: - selector: - app: paperless - ports: - - name: http - port: 8000 - targetPort: 8000 - protocol: TCP diff --git a/argocd/manifests/teslamate/README.md b/argocd/manifests/teslamate/README.md deleted file mode 100644 index 7e1f9fc..0000000 --- a/argocd/manifests/teslamate/README.md +++ /dev/null @@ -1,69 +0,0 @@ -# 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 diff --git a/argocd/manifests/teslamate/deployment.yaml b/argocd/manifests/teslamate/deployment.yaml deleted file mode 100644 index cf7f9bb..0000000 --- a/argocd/manifests/teslamate/deployment.yaml +++ /dev/null @@ -1,68 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: teslamate - namespace: teslamate -spec: - # Migrated to ringtail (teslamate-ringtail). Scaled to 0 to prevent - # double-writing the now-ringtail-owned database; manifest retained for - # rollback until the decommission PR. See [[migrate-wave1-ringtail]]. - replicas: 0 - selector: - matchLabels: - app: teslamate - template: - metadata: - labels: - app: teslamate - spec: - securityContext: - seccompProfile: - type: RuntimeDefault - containers: - - name: teslamate - image: registry.ops.eblu.me/blumeops/teslamate:kustomized - ports: - - containerPort: 4000 - env: - - name: DATABASE_USER - value: "teslamate" - - name: DATABASE_PASS - valueFrom: - secretKeyRef: - name: teslamate-db - key: password - - name: DATABASE_NAME - value: "teslamate" - - name: DATABASE_HOST - value: "blumeops-pg-rw.databases.svc.cluster.local" - - name: ENCRYPTION_KEY - valueFrom: - secretKeyRef: - name: teslamate-encryption - key: key - - name: DISABLE_MQTT - value: "true" - - name: CHECK_ORIGIN - value: "false" - - name: TZ - value: "America/Los_Angeles" - resources: - requests: - memory: "128Mi" - cpu: "100m" - limits: - memory: "512Mi" - cpu: "500m" - livenessProbe: - httpGet: - path: / - port: 4000 - initialDelaySeconds: 30 - periodSeconds: 30 - readinessProbe: - httpGet: - path: / - port: 4000 - initialDelaySeconds: 10 - periodSeconds: 10 diff --git a/argocd/manifests/teslamate/external-secret-db.yaml b/argocd/manifests/teslamate/external-secret-db.yaml deleted file mode 100644 index 11eeec6..0000000 --- a/argocd/manifests/teslamate/external-secret-db.yaml +++ /dev/null @@ -1,25 +0,0 @@ -# ExternalSecret for TeslaMate database password -# -# Replaces the manual op inject workflow from secret-db.yaml.tpl -# -# 1Password item: "TeslaMate" in blumeops vault -# Field: "db_password" -# -apiVersion: external-secrets.io/v1 -kind: ExternalSecret -metadata: - name: teslamate-db - namespace: teslamate -spec: - refreshInterval: 1h - secretStoreRef: - kind: ClusterSecretStore - name: onepassword-blumeops - target: - name: teslamate-db - creationPolicy: Owner - data: - - secretKey: password - remoteRef: - key: TeslaMate - property: db_password diff --git a/argocd/manifests/teslamate/external-secret-encryption-key.yaml b/argocd/manifests/teslamate/external-secret-encryption-key.yaml deleted file mode 100644 index 96938bf..0000000 --- a/argocd/manifests/teslamate/external-secret-encryption-key.yaml +++ /dev/null @@ -1,27 +0,0 @@ -# ExternalSecret for TeslaMate encryption key -# -# Replaces the manual op inject workflow from secret-encryption-key.yaml.tpl -# -# 1Password item: "TeslaMate" in blumeops vault -# Field: "api_enc_key" -# -# This key encrypts Tesla API tokens at rest in the database. -# -apiVersion: external-secrets.io/v1 -kind: ExternalSecret -metadata: - name: teslamate-encryption - namespace: teslamate -spec: - refreshInterval: 1h - secretStoreRef: - kind: ClusterSecretStore - name: onepassword-blumeops - target: - name: teslamate-encryption - creationPolicy: Owner - data: - - secretKey: key - remoteRef: - key: TeslaMate - property: api_enc_key diff --git a/argocd/manifests/teslamate/kustomization.yaml b/argocd/manifests/teslamate/kustomization.yaml deleted file mode 100644 index be9d39d..0000000 --- a/argocd/manifests/teslamate/kustomization.yaml +++ /dev/null @@ -1,15 +0,0 @@ -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization - -namespace: teslamate - -resources: - - deployment.yaml - - service.yaml - # ingress removed: name 'tesla' handed off to teslamate-ringtail at cutover - - external-secret-db.yaml - - external-secret-encryption-key.yaml - -images: - - name: registry.ops.eblu.me/blumeops/teslamate - newTag: v3.0.0-08c698e diff --git a/argocd/manifests/teslamate/service.yaml b/argocd/manifests/teslamate/service.yaml deleted file mode 100644 index b04f45e..0000000 --- a/argocd/manifests/teslamate/service.yaml +++ /dev/null @@ -1,12 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: teslamate - namespace: teslamate -spec: - selector: - app: teslamate - ports: - - port: 4000 - targetPort: 4000 - type: ClusterIP diff --git a/docs/changelog.d/decommission-wave1-minikube.infra.md b/docs/changelog.d/decommission-wave1-minikube.infra.md new file mode 100644 index 0000000..63b3ab5 --- /dev/null +++ b/docs/changelog.d/decommission-wave1-minikube.infra.md @@ -0,0 +1,8 @@ +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). From eaa899cfc65fd5d704c88e39771bc293765b181d Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 3 Jun 2026 13:02:05 -0700 Subject: [PATCH 415/430] C0: wave-1 decommission follow-ups (argocd admin RBAC, teslamate probe) - argocd: grant local break-glass admin the admin role (g, admin, role:admin); previously only the Authentik admins group had access, locking out admin once its token expired (policy.default is unset). - alloy-k8s: repoint the teslamate blackbox probe from the deleted minikube service to https://tesla.ops.eblu.me/ (Caddy over Tailscale), like immich. Co-Authored-By: Claude Opus 4.8 (1M context) --- argocd/manifests/alloy-k8s/config.alloy | 3 ++- argocd/manifests/argocd/argocd-rbac-cm-patch.yaml | 4 ++++ docs/changelog.d/+wave1-decommission-followups.infra.md | 8 ++++++++ 3 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 docs/changelog.d/+wave1-decommission-followups.infra.md diff --git a/argocd/manifests/alloy-k8s/config.alloy b/argocd/manifests/alloy-k8s/config.alloy index 5a0a8f9..2940b0b 100644 --- a/argocd/manifests/alloy-k8s/config.alloy +++ b/argocd/manifests/alloy-k8s/config.alloy @@ -191,8 +191,9 @@ prometheus.exporter.blackbox "services" { } target { + // Migrated to ringtail (wave-1); probe through Caddy over Tailscale. name = "teslamate" - address = "http://teslamate.teslamate.svc.cluster.local:4000/" + address = "https://tesla.ops.eblu.me/" module = "http_2xx" } diff --git a/argocd/manifests/argocd/argocd-rbac-cm-patch.yaml b/argocd/manifests/argocd/argocd-rbac-cm-patch.yaml index c2ea095..4914587 100644 --- a/argocd/manifests/argocd/argocd-rbac-cm-patch.yaml +++ b/argocd/manifests/argocd/argocd-rbac-cm-patch.yaml @@ -2,6 +2,9 @@ # # - workflow-bot: minimal CI/CD permissions (sync, get) # - 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 kind: ConfigMap @@ -14,3 +17,4 @@ data: p, role:workflow-bot, applications, get, *, allow g, workflow-bot, role:workflow-bot g, admins, role:admin + g, admin, role:admin diff --git a/docs/changelog.d/+wave1-decommission-followups.infra.md b/docs/changelog.d/+wave1-decommission-followups.infra.md new file mode 100644 index 0000000..7b54d52 --- /dev/null +++ b/docs/changelog.d/+wave1-decommission-followups.infra.md @@ -0,0 +1,8 @@ +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. From 308c8e3dad287b2de98891681db4c254ef1c181a Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 3 Jun 2026 15:31:59 -0700 Subject: [PATCH 416/430] C0: drop duplicate Homepage static entries for ringtail-migrated services Mealie, Paperless, Immich, TeslaMate are now autodiscovered from their ringtail Ingress gethomepage.dev annotations; the static services.yaml entries (from when they were on minikube, which homepage-on-ringtail can't autodiscover) were duplicating them. Co-Authored-By: Claude Opus 4.8 (1M context) --- argocd/manifests/homepage/services.yaml | 16 ---------------- .../changelog.d/+homepage-dedup-migrated.misc.md | 5 +++++ 2 files changed, 5 insertions(+), 16 deletions(-) create mode 100644 docs/changelog.d/+homepage-dedup-migrated.misc.md diff --git a/argocd/manifests/homepage/services.yaml b/argocd/manifests/homepage/services.yaml index d552ff2..cc1adf4 100644 --- a/argocd/manifests/homepage/services.yaml +++ b/argocd/manifests/homepage/services.yaml @@ -71,10 +71,6 @@ enableBlocks: true enableNowPlaying: false fields: ["movies", "series", "episodes"] - - Mealie: - href: https://meals.ops.eblu.me - icon: mealie.png - description: Recipe manager - DJ: href: https://dj.ops.eblu.me icon: navidrome.png @@ -85,15 +81,7 @@ user: "{{HOMEPAGE_VAR_NAVIDROME_USER}}" token: "{{HOMEPAGE_VAR_NAVIDROME_TOKEN}}" salt: "{{HOMEPAGE_VAR_NAVIDROME_SALT}}" - - Paperless: - href: https://paperless.ops.eblu.me - icon: paperless-ngx.png - description: Document management - Content: - - Immich: - href: https://photos.ops.eblu.me - icon: immich.png - description: Photo management - Kiwix: href: https://kiwix.ops.eblu.me icon: kiwix.png @@ -138,10 +126,6 @@ href: https://docs.eblu.me icon: mdi-book-open-page-variant description: BlumeOps Documentation - - TeslaMate: - href: https://tesla.ops.eblu.me - icon: teslamate.png - description: Tesla data logger - Transmission: href: https://torrent.ops.eblu.me icon: transmission.png diff --git a/docs/changelog.d/+homepage-dedup-migrated.misc.md b/docs/changelog.d/+homepage-dedup-migrated.misc.md new file mode 100644 index 0000000..9efc5ba --- /dev/null +++ b/docs/changelog.d/+homepage-dedup-migrated.misc.md @@ -0,0 +1,5 @@ +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. From 214871458478a6b9aaa6dcc1b5aabab1336e8c7c Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 3 Jun 2026 21:32:10 -0700 Subject: [PATCH 417/430] C0: retire Todoist blumeops-tasks; point task discovery at heph Replace the Todoist-backed blumeops-tasks mise task with `heph list --project Blumeops --json` (hephaestus, now at v1 prototype on gilbert). Update task-discovery, rotation-reminder, and zk references across docs; note the zk zettelkasten is migrating into heph docs. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 12 +- .../+blumeops-tasks-due-recurrence.feature.md | 1 - .../+retire-todoist-for-heph.infra.md | 1 + .../configuration/rotate-fly-deploy-token.md | 2 +- docs/how-to/configuration/rotate-gandi-pat.md | 2 +- docs/reference/services/borgmatic.md | 2 +- docs/reference/storage/backups.md | 2 +- docs/reference/tools/mise-tasks.md | 1 - docs/tutorials/ai-assistance-guide.md | 3 +- mise-tasks/blumeops-tasks | 216 ------------------ 10 files changed, 16 insertions(+), 226 deletions(-) delete mode 100644 docs/changelog.d/+blumeops-tasks-due-recurrence.feature.md create mode 100644 docs/changelog.d/+retire-todoist-for-heph.infra.md delete mode 100755 mise-tasks/blumeops-tasks diff --git a/AGENTS.md b/AGENTS.md index 9e7350d..c64af40 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,7 +65,7 @@ See [[agent-change-process]] for the full methodology. ./pulumi/ # Pulumi IaC (tailnet ACLs, dns, cloud) ~/.config/{nvim,fish} # user's shell config, managed by chezmoi ~/code/personal/ # user's projects -~/code/personal/zk # user's Obsidian-sync managed zettelkasten. Potential source for reference data. +~/code/personal/zk # user's zettelkasten (Obsidian-sync). Reference-data source; migrating into heph docs (hephaestus). ~/code/3rd/ # mirrored external projects ~/code/work # FORBIDDEN ``` @@ -147,10 +147,16 @@ Create a new spork: `mise run spork-create ` ## Task Discovery +BlumeOps tasks live in [hephaestus](https://github.com/eblume/hephaestus) (`heph`), +the user's self-hosted context/task system. Fetch them with the CLI: + ```fish -mise run blumeops-tasks # fetch from Todoist, sorted by priority +heph list --project Blumeops --json # outstanding Blumeops tasks as JSON ``` -Most tasks are stored in `./mise-tasks/`. For scripts with any logic or + +(This replaced the retired `blumeops-tasks` mise task, which read from Todoist.) + +Most operational scripts are stored in `./mise-tasks/`. For scripts with any logic or complexity, use uv run --script 's with explicit dependencies. Complex workflows with artifacts should become dagger pipelines. Mise tasks are for development processes and operations - tools for the user or the agent. diff --git a/docs/changelog.d/+blumeops-tasks-due-recurrence.feature.md b/docs/changelog.d/+blumeops-tasks-due-recurrence.feature.md deleted file mode 100644 index 83072dd..0000000 --- a/docs/changelog.d/+blumeops-tasks-due-recurrence.feature.md +++ /dev/null @@ -1 +0,0 @@ -`blumeops-tasks` now annotates each task with a human-readable due offset (`5d overdue` / `due in 2d` / `due today`) and a `↻ ` marker for recurring tasks, and sorts by overdue-ness (most overdue first, no-due-date last) with priority as tiebreaker. diff --git a/docs/changelog.d/+retire-todoist-for-heph.infra.md b/docs/changelog.d/+retire-todoist-for-heph.infra.md new file mode 100644 index 0000000..f6284d0 --- /dev/null +++ b/docs/changelog.d/+retire-todoist-for-heph.infra.md @@ -0,0 +1 @@ +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. diff --git a/docs/how-to/configuration/rotate-fly-deploy-token.md b/docs/how-to/configuration/rotate-fly-deploy-token.md index 5863f54..9abe5f0 100644 --- a/docs/how-to/configuration/rotate-fly-deploy-token.md +++ b/docs/how-to/configuration/rotate-fly-deploy-token.md @@ -14,7 +14,7 @@ How to rotate the Fly.io API token used to deploy [[flyio-proxy]]. The token liv ## When to rotate -- Every 75 days (Todoist recurring task) +- Every 75 days (heph recurring task) - After any compromise / accidental disclosure - If `fly deploy` starts returning auth errors diff --git a/docs/how-to/configuration/rotate-gandi-pat.md b/docs/how-to/configuration/rotate-gandi-pat.md index 94a0b4e..5ce6f81 100644 --- a/docs/how-to/configuration/rotate-gandi-pat.md +++ b/docs/how-to/configuration/rotate-gandi-pat.md @@ -14,7 +14,7 @@ How to rotate the Gandi Personal Access Token. **One PAT** is shared by [[caddy] ## When to rotate -- Every 60 days (Todoist recurring task) +- Every 60 days (heph recurring task) - After any compromise / accidental disclosure - Whenever Gandi starts rejecting the PAT (see [Debugging](#debugging)) diff --git a/docs/reference/services/borgmatic.md b/docs/reference/services/borgmatic.md index fea4551..37f1a60 100644 --- a/docs/reference/services/borgmatic.md +++ b/docs/reference/services/borgmatic.md @@ -25,7 +25,7 @@ Daily backup system using Borg backup, running on indri. ## What Gets Backed Up **Directories:** -- `~/code/personal/zk` - Zettelkasten +- `~/code/personal/zk` - Zettelkasten (migrating into heph docs; see [hephaestus](https://github.com/eblume/hephaestus)) - `/opt/homebrew/var/forgejo` - Git forge data - `~/.config/borgmatic` - Borgmatic config - `~/Documents` - Personal documents diff --git a/docs/reference/storage/backups.md b/docs/reference/storage/backups.md index 14dbcea..2dfbae4 100644 --- a/docs/reference/storage/backups.md +++ b/docs/reference/storage/backups.md @@ -22,7 +22,7 @@ Daily automated backups from [[indri]] to [[sifaka|Sifaka]] NAS. | Path | Description | Priority | |------|-------------|----------| -| `~/code/personal/zk` | Zettelkasten notes | Critical | +| `~/code/personal/zk` | Zettelkasten notes (migrating into heph docs) | Critical | | `/opt/homebrew/var/forgejo` | Git repositories | Critical | | `~/.config/borgmatic` | Backup config | High | | `~/Documents` | Personal documents (includes [[1password]] encrypted export) | High | diff --git a/docs/reference/tools/mise-tasks.md b/docs/reference/tools/mise-tasks.md index 4ec3438..b614cb1 100644 --- a/docs/reference/tools/mise-tasks.md +++ b/docs/reference/tools/mise-tasks.md @@ -69,7 +69,6 @@ Run `mise tasks --sort name` for the live list with descriptions. |------|-------------| | `services-check` | Check all services are online and responding | | `service-review` | Review the most stale service for version freshness | -| `blumeops-tasks` | List tasks from Todoist sorted by priority | | `op-backup` | Encrypt 1Password export and send to indri for borgmatic | ## Infrastructure Setup diff --git a/docs/tutorials/ai-assistance-guide.md b/docs/tutorials/ai-assistance-guide.md index 3ee1ffa..4f0c595 100644 --- a/docs/tutorials/ai-assistance-guide.md +++ b/docs/tutorials/ai-assistance-guide.md @@ -98,7 +98,6 @@ BlumeOps operations are driven by mise tasks. Run `mise tasks` to list all avail | `provision-indri` | Deploy changes to [[indri]]-hosted services via Ansible | | `services-check` | After deployments - verify all services are healthy | | `pr-comments` | Check unresolved PR comments during review | -| `blumeops-tasks` | Find pending tasks from Todoist | | `container-list` | View available container images and tags | | `container-build-and-release` | Trigger container build workflows | | `dns-preview` | Preview DNS changes before applying | @@ -111,6 +110,8 @@ BlumeOps operations are driven by mise tasks. Run `mise tasks` to list all avail | `docs-review` | Review the most stale doc by last-reviewed date | | `runner-logs` | View Forgejo workflow logs (indri or ringtail runner) | +For task discovery, BlumeOps tasks live in [hephaestus](https://github.com/eblume/hephaestus) (`heph`), not Todoist. List outstanding work with `heph list --project Blumeops --json`. + For ArgoCD operations, use the `argocd` CLI directly: - `argocd app diff ` - Preview changes - `argocd app sync ` - Deploy changes diff --git a/mise-tasks/blumeops-tasks b/mise-tasks/blumeops-tasks deleted file mode 100755 index 035aa3b..0000000 --- a/mise-tasks/blumeops-tasks +++ /dev/null @@ -1,216 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.12" -# dependencies = ["httpx==0.28.1", "rich==15.0.0"] -# /// -#MISE description="List Blumeops tasks from Todoist sorted by priority" -"""Fetch and display Blumeops tasks from Todoist, sorted by priority. - -This script is specific to Erich Blume's personal development workflow and -is not intended for general use. It requires: - - - A 1Password CLI (`op`) configured with access to the author's vault - - A Todoist account with a project named "Blumeops" - -The script fetches tasks and displays them sorted by a custom priority order: -p1 (urgent), p2 (high), p4 (normal/default), p3 (backlog). The p3-last ordering -reflects a deliberate choice to treat p3 as "backlog" rather than moderate -priority. - -Usage: mise run blumeops-tasks -""" - -import subprocess -import sys -from datetime import date - -import httpx -from rich.console import Console -from rich.markup import escape -from rich.text import Text - -TODOIST_API_BASE = "https://api.todoist.com/api/v1" -PROJECT_NAME = "Blumeops" - -# Priority mapping: Todoist API uses 1=normal(p4), 2=moderate(p3), 3=high(p2), 4=urgent(p1) -# User wants order: p1, p2, p4, p3 (p3 is backlog, goes last) -PRIORITY_LABELS = {4: "p1", 3: "p2", 1: "p4", 2: "p3"} -PRIORITY_SORT_ORDER = {4: 1, 3: 2, 1: 3, 2: 4} # Lower = earlier - - -def get_todoist_token() -> str: - """Retrieve Todoist API token from 1Password.""" - result = subprocess.run( - ["op", "read", "op://vg6xf6vvfmoh5hqjjhlhbeoaie/c53h3xnmswhvexa5mntoyvhgpm/credential"], - capture_output=True, - text=True, - ) - if result.returncode != 0: - raise RuntimeError(f"Failed to get Todoist token from 1Password: {result.stderr}") - return result.stdout.strip() - - -def get_project_id(client: httpx.Client, project_name: str) -> str: - """Find project ID by name.""" - cursor = None - while True: - params = {} - if cursor: - params["cursor"] = cursor - response = client.get(f"{TODOIST_API_BASE}/projects", params=params) - response.raise_for_status() - data = response.json() - for project in data.get("results", data if isinstance(data, list) else []): - if project["name"] == project_name: - return project["id"] - cursor = data.get("next_cursor") if isinstance(data, dict) else None - if not cursor: - break - - raise RuntimeError(f"Project '{project_name}' not found in Todoist") - - -def get_tasks(client: httpx.Client, project_id: str) -> list[dict]: - """Get all tasks for a project.""" - tasks = [] - cursor = None - while True: - params = {"project_id": project_id} - if cursor: - params["cursor"] = cursor - response = client.get(f"{TODOIST_API_BASE}/tasks", params=params) - response.raise_for_status() - data = response.json() - tasks.extend(data.get("results", data if isinstance(data, list) else [])) - cursor = data.get("next_cursor") if isinstance(data, dict) else None - if not cursor: - break - return tasks - - -def is_due(task: dict) -> bool: - """Check if a task should be displayed based on its due date. - - Tasks without a due date are always shown. Tasks with a due date - are only shown when the date is today or in the past. - """ - due = task.get("due") - if due is None: - return True - due_date = date.fromisoformat(due["date"][:10]) - return due_date <= date.today() - - -def days_until_due(task: dict) -> int | None: - """Return signed days offset from today, or None if no due date. - - Negative = days remaining before due (e.g. -2 = due in 2 days). - Positive = days past due (overdue). Zero = due today. - """ - due = task.get("due") - if due is None: - return None - due_date = date.fromisoformat(due["date"][:10]) - return (date.today() - due_date).days - - -def recurrence_string(task: dict) -> str | None: - """Return the Todoist natural-language recurrence string, or None. - - Todoist's REST API doesn't expose RFC 5545 RRULE; the natural-language - `due.string` (e.g. "every monday", "every 2 weeks") is the terse form. - """ - due = task.get("due") - if due is None or not due.get("is_recurring"): - return None - return due.get("string") - - -def sort_tasks(tasks: list[dict]) -> list[dict]: - """Sort by overdue-ness, then priority. - - Most overdue first (largest +N); tasks with no due date come last. - Within a given day, tiebreaker is the custom priority order p1, p2, p4, p3. - """ - - def key(task: dict) -> tuple[int, int, int]: - days = days_until_due(task) - no_due = 1 if days is None else 0 - days_key = -(days if days is not None else 0) # descending - return (no_due, days_key, PRIORITY_SORT_ORDER.get(task["priority"], 5)) - - return sorted(tasks, key=key) - - -def main() -> int: - console = Console() - - # Get API token - try: - token = get_todoist_token() - except RuntimeError as e: - console.print(f"[red]Error:[/red] {e}") - return 1 - - # Create HTTP client with auth header - with httpx.Client(headers={"Authorization": f"Bearer {token}"}) as client: - # Find project - try: - project_id = get_project_id(client, PROJECT_NAME) - except RuntimeError as e: - console.print(f"[red]Error:[/red] {e}") - return 1 - - # Get, filter, and sort tasks - tasks = get_tasks(client, project_id) - tasks = [t for t in tasks if is_due(t)] - sorted_tasks = sort_tasks(tasks) - - if not sorted_tasks: - console.print("No tasks found in Blumeops project") - return 0 - - # Display tasks - console.print(f"[bold]Blumeops Tasks[/bold] ({len(sorted_tasks)} tasks)") - console.print("=" * 40) - console.print() - - for task in sorted_tasks: - priority = task["priority"] - label = PRIORITY_LABELS.get(priority, "p?") - content = task["content"] - description = task.get("description", "") - - # Header line with priority and content - header = Text() - header.append(f"[{label}]", style="bold") - header.append(f" {content}") - - meta = [] - days = days_until_due(task) - if days is not None: - if days == 0: - meta.append("due today") - elif days > 0: - meta.append(f"{days}d overdue") - else: - meta.append(f"due in {-days}d") - recurrence = recurrence_string(task) - if recurrence: - meta.append(f"↻ {recurrence}") - if meta: - header.append(f" ({', '.join(meta)})", style="dim") - console.print(header) - - # Description indented (escape rich markup to preserve brackets) - if description: - for line in description.split("\n"): - console.print(f" {escape(line)}", style="dim") - - console.print() - - return 0 - - -if __name__ == "__main__": - sys.exit(main()) From 29e0f012cd43d7185ed37a0a037695c6b52abc03 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 3 Jun 2026 21:39:41 -0700 Subject: [PATCH 418/430] C0: pin Quartz docs build to v4.5.2 (v5.0.0 broke build) The Dagger build_docs pipeline cloned Quartz from the default branch unpinned. Quartz v5.0.0 restructured its config layout (.quartz/plugins, ../quartz imports), breaking the docs build against our existing quartz.config.ts / quartz.layout.ts. Pin the clone to the last v4 release (v4.5.2) to restore known-good behavior. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/changelog.d/+pin-quartz-v4.bugfix.md | 1 + src/blumeops/main.py | 4 ++++ 2 files changed, 5 insertions(+) create mode 100644 docs/changelog.d/+pin-quartz-v4.bugfix.md diff --git a/docs/changelog.d/+pin-quartz-v4.bugfix.md b/docs/changelog.d/+pin-quartz-v4.bugfix.md new file mode 100644 index 0000000..e073bbb --- /dev/null +++ b/docs/changelog.d/+pin-quartz-v4.bugfix.md @@ -0,0 +1 @@ +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`. diff --git a/src/blumeops/main.py b/src/blumeops/main.py index 94b932b..9bbd12f 100644 --- a/src/blumeops/main.py +++ b/src/blumeops/main.py @@ -80,6 +80,10 @@ class Blumeops: "git", "clone", "--depth=1", + # Pin to last v4 release. v5.0.0 restructured config + # layout (.quartz/plugins, ../quartz imports) and breaks + # our quartz.config.ts/quartz.layout.ts. See changelog. + "--branch=v4.5.2", "https://github.com/jackyzha0/quartz.git", "/tmp/quartz", ] From 8f72f04d5cf5c507d0a9e8163d07d666975b53b7 Mon Sep 17 00:00:00 2001 From: Forgejo Actions Date: Wed, 3 Jun 2026 21:52:22 -0700 Subject: [PATCH 419/430] Update docs release to v1.17.0 - Built changelog from towncrier fragments [skip ci] --- CHANGELOG.md | 253 ++++++++++++++++++ ansible/roles/docs/defaults/main.yml | 3 +- .../+1password-backup-doc-export-name.doc.md | 1 - .../+agent-file-neutralization.ai.md | 1 - .../+ai-scraper-mitigation-doc.doc.md | 1 - .../+alloy-main-sha-rebuild.infra.md | 5 - .../+alloy-native-macos-v1.16.0.infra.md | 6 - .../+argocd-resource-limits.infra.md | 1 - .../+claude-md-import-agents.ai.md | 1 - ...ontainer-build-suggest-runner-logs.misc.md | 1 - .../+fix-forge-static-assets.bugfix.md | 1 - .../+fly-deploy-immediate-strategy.infra.md | 1 - .../+forge-mirrors-blackhole.infra.md | 1 - .../+frigate-notify-local.infra.md | 1 - .../+grafana-recreate-strategy.infra.md | 1 - .../+homepage-config-perms-fix.bugfix.md | 5 - .../+homepage-dedup-migrated.misc.md | 5 - .../+immich-probe-ringtail.infra.md | 1 - ...anage-forgejo-mirrors-sync-location.doc.md | 1 - docs/changelog.d/+pin-quartz-v4.bugfix.md | 1 - .../+prowler-rebuild-on-main.infra.md | 1 - .../+remove-devpi-container-build.misc.md | 1 - .../+retire-todoist-for-heph.infra.md | 1 - docs/changelog.d/+review-1password-doc.doc.md | 1 - .../+review-compliance-image-iac.feature.md | 1 - .../+review-contributing-doc.doc.md | 1 - docs/changelog.d/+review-index-doc.doc.md | 1 - docs/changelog.d/+review-navidrome-doc.doc.md | 1 - docs/changelog.d/+review-ollama-doc.doc.md | 1 - .../+ringtail-clone-via-tailnet.infra.md | 1 - .../+ringtail-coredump-size-cap.infra.md | 1 - ...+ringtail-flake-update-2026-06-01.infra.md | 4 - docs/changelog.d/+ringtail-proton-ge.infra.md | 4 - .../+ringtail-sn2-prelaunch.infra.md | 6 - .../+ringtail-sway-fuzzel.bugfix.md | 3 - .../+ringtail-vrr-flicker.bugfix.md | 1 - ...ate-fly-deploy-token-shell-examples.doc.md | 1 - docs/changelog.d/+runner-logs-auth.feature.md | 1 - .../+runner-logs-missing-log.misc.md | 1 - .../changelog.d/+shower-1.1.1-deploy.infra.md | 1 - .../+shower-1.1.1-fod-pin.infra.md | 1 - docs/changelog.d/+shower-1.1.1.infra.md | 1 - .../changelog.d/+shower-1.1.3-deploy.infra.md | 1 - docs/changelog.d/+shower-1.1.3.infra.md | 1 - .../+shower-main-sha-rebuild.infra.md | 5 - .../+shower-rebuild-from-main-sha.misc.md | 6 - ...hower-v1.1.2-rebuild-from-main-sha.misc.md | 1 - .../+tailscale-main-sha-rebuild.infra.md | 1 - .../+transmission-doc-review.doc.md | 1 - .../+unpoller-rebuild-on-main.infra.md | 1 - .../+valkey-main-tag-bump.infra.md | 1 - .../+valkey-rebuild-on-main.infra.md | 1 - .../+wave1-decommission-followups.infra.md | 8 - .../+zot-ci-rotation-op-syntax.doc.md | 1 - docs/changelog.d/+zot-v2.1.16.infra.md | 1 - docs/changelog.d/alloy-v1.16.0.infra.md | 5 - ...ckup-grafana-ringtail-blumeops-pg.infra.md | 8 - ...cleanup-cv-docs-minikube-artifacts.misc.md | 1 - ...dagger-0-20-6-runner-image-alpine.infra.md | 1 - .../decommission-wave1-minikube.infra.md | 8 - .../doc-review-replicating-blumeops.doc.md | 1 - .../fix-borgmatic-shower-via-ssh.bugfix.md | 14 - ...o-runner-v12-8-server-connections.infra.md | 1 - .../changelog.d/homepage-to-ringtail.infra.md | 8 - .../migrate-cv-docs-to-indri.infra.md | 1 - .../migrate-devpi-to-indri.infra.md | 1 - .../migrate-immich-to-ringtail.infra.md | 13 - .../migrate-wave1-ringtail.infra.md | 13 - .../mirror-tailscale-container.infra.md | 1 - .../changelog.d/prowler-iac-mutelist.infra.md | 1 - .../recurring-maintenance-2026-05-27.doc.md | 1 - .../recurring-maintenance-2026-05-27.infra.md | 4 - .../review-ringtail-flake-2026-05-11.infra.md | 1 - docs/changelog.d/ringtail-static-ip.infra.md | 1 - .../rip-out-compensating-controls.infra.md | 1 - .../service-review-mealie-2026-05-11.infra.md | 1 - docs/changelog.d/shower-app-deploy.bugfix.md | 13 - docs/changelog.d/shower-app-deploy.feature.md | 4 - docs/changelog.d/shower-app-deploy.infra.md | 9 - docs/changelog.d/shower-v1.1.0.feature.md | 15 -- docs/changelog.d/shower-v1.1.2.infra.md | 1 - docs/changelog.d/unpoller-v3.infra.md | 1 - .../update-tooling-deps-2026-04.doc.md | 1 - .../update-tooling-deps-2026-04.infra.md | 1 - docs/changelog.d/valkey-mirror.infra.md | 1 - docs/changelog.d/valkey-nix.infra.md | 1 - 86 files changed, 254 insertions(+), 234 deletions(-) delete mode 100644 docs/changelog.d/+1password-backup-doc-export-name.doc.md delete mode 100644 docs/changelog.d/+agent-file-neutralization.ai.md delete mode 100644 docs/changelog.d/+ai-scraper-mitigation-doc.doc.md delete mode 100644 docs/changelog.d/+alloy-main-sha-rebuild.infra.md delete mode 100644 docs/changelog.d/+alloy-native-macos-v1.16.0.infra.md delete mode 100644 docs/changelog.d/+argocd-resource-limits.infra.md delete mode 100644 docs/changelog.d/+claude-md-import-agents.ai.md delete mode 100644 docs/changelog.d/+container-build-suggest-runner-logs.misc.md delete mode 100644 docs/changelog.d/+fix-forge-static-assets.bugfix.md delete mode 100644 docs/changelog.d/+fly-deploy-immediate-strategy.infra.md delete mode 100644 docs/changelog.d/+forge-mirrors-blackhole.infra.md delete mode 100644 docs/changelog.d/+frigate-notify-local.infra.md delete mode 100644 docs/changelog.d/+grafana-recreate-strategy.infra.md delete mode 100644 docs/changelog.d/+homepage-config-perms-fix.bugfix.md delete mode 100644 docs/changelog.d/+homepage-dedup-migrated.misc.md delete mode 100644 docs/changelog.d/+immich-probe-ringtail.infra.md delete mode 100644 docs/changelog.d/+manage-forgejo-mirrors-sync-location.doc.md delete mode 100644 docs/changelog.d/+pin-quartz-v4.bugfix.md delete mode 100644 docs/changelog.d/+prowler-rebuild-on-main.infra.md delete mode 100644 docs/changelog.d/+remove-devpi-container-build.misc.md delete mode 100644 docs/changelog.d/+retire-todoist-for-heph.infra.md delete mode 100644 docs/changelog.d/+review-1password-doc.doc.md delete mode 100644 docs/changelog.d/+review-compliance-image-iac.feature.md delete mode 100644 docs/changelog.d/+review-contributing-doc.doc.md delete mode 100644 docs/changelog.d/+review-index-doc.doc.md delete mode 100644 docs/changelog.d/+review-navidrome-doc.doc.md delete mode 100644 docs/changelog.d/+review-ollama-doc.doc.md delete mode 100644 docs/changelog.d/+ringtail-clone-via-tailnet.infra.md delete mode 100644 docs/changelog.d/+ringtail-coredump-size-cap.infra.md delete mode 100644 docs/changelog.d/+ringtail-flake-update-2026-06-01.infra.md delete mode 100644 docs/changelog.d/+ringtail-proton-ge.infra.md delete mode 100644 docs/changelog.d/+ringtail-sn2-prelaunch.infra.md delete mode 100644 docs/changelog.d/+ringtail-sway-fuzzel.bugfix.md delete mode 100644 docs/changelog.d/+ringtail-vrr-flicker.bugfix.md delete mode 100644 docs/changelog.d/+rotate-fly-deploy-token-shell-examples.doc.md delete mode 100644 docs/changelog.d/+runner-logs-auth.feature.md delete mode 100644 docs/changelog.d/+runner-logs-missing-log.misc.md delete mode 100644 docs/changelog.d/+shower-1.1.1-deploy.infra.md delete mode 100644 docs/changelog.d/+shower-1.1.1-fod-pin.infra.md delete mode 100644 docs/changelog.d/+shower-1.1.1.infra.md delete mode 100644 docs/changelog.d/+shower-1.1.3-deploy.infra.md delete mode 100644 docs/changelog.d/+shower-1.1.3.infra.md delete mode 100644 docs/changelog.d/+shower-main-sha-rebuild.infra.md delete mode 100644 docs/changelog.d/+shower-rebuild-from-main-sha.misc.md delete mode 100644 docs/changelog.d/+shower-v1.1.2-rebuild-from-main-sha.misc.md delete mode 100644 docs/changelog.d/+tailscale-main-sha-rebuild.infra.md delete mode 100644 docs/changelog.d/+transmission-doc-review.doc.md delete mode 100644 docs/changelog.d/+unpoller-rebuild-on-main.infra.md delete mode 100644 docs/changelog.d/+valkey-main-tag-bump.infra.md delete mode 100644 docs/changelog.d/+valkey-rebuild-on-main.infra.md delete mode 100644 docs/changelog.d/+wave1-decommission-followups.infra.md delete mode 100644 docs/changelog.d/+zot-ci-rotation-op-syntax.doc.md delete mode 100644 docs/changelog.d/+zot-v2.1.16.infra.md delete mode 100644 docs/changelog.d/alloy-v1.16.0.infra.md delete mode 100644 docs/changelog.d/backup-grafana-ringtail-blumeops-pg.infra.md delete mode 100644 docs/changelog.d/cleanup-cv-docs-minikube-artifacts.misc.md delete mode 100644 docs/changelog.d/dagger-0-20-6-runner-image-alpine.infra.md delete mode 100644 docs/changelog.d/decommission-wave1-minikube.infra.md delete mode 100644 docs/changelog.d/doc-review-replicating-blumeops.doc.md delete mode 100644 docs/changelog.d/fix-borgmatic-shower-via-ssh.bugfix.md delete mode 100644 docs/changelog.d/forgejo-runner-v12-8-server-connections.infra.md delete mode 100644 docs/changelog.d/homepage-to-ringtail.infra.md delete mode 100644 docs/changelog.d/migrate-cv-docs-to-indri.infra.md delete mode 100644 docs/changelog.d/migrate-devpi-to-indri.infra.md delete mode 100644 docs/changelog.d/migrate-immich-to-ringtail.infra.md delete mode 100644 docs/changelog.d/migrate-wave1-ringtail.infra.md delete mode 100644 docs/changelog.d/mirror-tailscale-container.infra.md delete mode 100644 docs/changelog.d/prowler-iac-mutelist.infra.md delete mode 100644 docs/changelog.d/recurring-maintenance-2026-05-27.doc.md delete mode 100644 docs/changelog.d/recurring-maintenance-2026-05-27.infra.md delete mode 100644 docs/changelog.d/review-ringtail-flake-2026-05-11.infra.md delete mode 100644 docs/changelog.d/ringtail-static-ip.infra.md delete mode 100644 docs/changelog.d/rip-out-compensating-controls.infra.md delete mode 100644 docs/changelog.d/service-review-mealie-2026-05-11.infra.md delete mode 100644 docs/changelog.d/shower-app-deploy.bugfix.md delete mode 100644 docs/changelog.d/shower-app-deploy.feature.md delete mode 100644 docs/changelog.d/shower-app-deploy.infra.md delete mode 100644 docs/changelog.d/shower-v1.1.0.feature.md delete mode 100644 docs/changelog.d/shower-v1.1.2.infra.md delete mode 100644 docs/changelog.d/unpoller-v3.infra.md delete mode 100644 docs/changelog.d/update-tooling-deps-2026-04.doc.md delete mode 100644 docs/changelog.d/update-tooling-deps-2026-04.infra.md delete mode 100644 docs/changelog.d/valkey-mirror.infra.md delete mode 100644 docs/changelog.d/valkey-nix.infra.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ae5f8e..0499154 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,259 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [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:` (mealie — kubectl uses indri's + kubeconfig) or `ssh:` (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: ` 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//`, 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 (~5–10s) 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--.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 ` command after dispatching, polling the Forgejo API to resolve the run number for the commit it just triggered. +- `mise run runner-logs -j ` 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 ### Infrastructure diff --git a/ansible/roles/docs/defaults/main.yml b/ansible/roles/docs/defaults/main.yml index f09221b..a5a1a8a 100644 --- a/ansible/roles/docs/defaults/main.yml +++ b/ansible/roles/docs/defaults/main.yml @@ -3,9 +3,8 @@ # Caddy serves docs_content_dir directly via the static-kind service block, # with Quartz-style try_files (path → path/ → path.html → 404). -docs_version: "v1.16.0" +docs_version: "v1.17.0" 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_content_dir: "{{ docs_home }}/content" docs_version_sentinel: "{{ docs_home }}/.installed-version" diff --git a/docs/changelog.d/+1password-backup-doc-export-name.doc.md b/docs/changelog.d/+1password-backup-doc-export-name.doc.md deleted file mode 100644 index 6c4d262..0000000 --- a/docs/changelog.d/+1password-backup-doc-export-name.doc.md +++ /dev/null @@ -1 +0,0 @@ -Fixed the export-filename step in [[run-1password-backup]]: 1Password's desktop app names the export `1PasswordExport--.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`. diff --git a/docs/changelog.d/+agent-file-neutralization.ai.md b/docs/changelog.d/+agent-file-neutralization.ai.md deleted file mode 100644 index da16fba..0000000 --- a/docs/changelog.d/+agent-file-neutralization.ai.md +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/docs/changelog.d/+ai-scraper-mitigation-doc.doc.md b/docs/changelog.d/+ai-scraper-mitigation-doc.doc.md deleted file mode 100644 index 246fedb..0000000 --- a/docs/changelog.d/+ai-scraper-mitigation-doc.doc.md +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/docs/changelog.d/+alloy-main-sha-rebuild.infra.md b/docs/changelog.d/+alloy-main-sha-rebuild.infra.md deleted file mode 100644 index 42a7b37..0000000 --- a/docs/changelog.d/+alloy-main-sha-rebuild.infra.md +++ /dev/null @@ -1,5 +0,0 @@ -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. diff --git a/docs/changelog.d/+alloy-native-macos-v1.16.0.infra.md b/docs/changelog.d/+alloy-native-macos-v1.16.0.infra.md deleted file mode 100644 index 471990f..0000000 --- a/docs/changelog.d/+alloy-native-macos-v1.16.0.infra.md +++ /dev/null @@ -1,6 +0,0 @@ -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. diff --git a/docs/changelog.d/+argocd-resource-limits.infra.md b/docs/changelog.d/+argocd-resource-limits.infra.md deleted file mode 100644 index ba24a5a..0000000 --- a/docs/changelog.d/+argocd-resource-limits.infra.md +++ /dev/null @@ -1 +0,0 @@ -Add resource limits to all ArgoCD pods to prevent unbounded resource consumption during node-wide pressure events. diff --git a/docs/changelog.d/+claude-md-import-agents.ai.md b/docs/changelog.d/+claude-md-import-agents.ai.md deleted file mode 100644 index f63231e..0000000 --- a/docs/changelog.d/+claude-md-import-agents.ai.md +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/docs/changelog.d/+container-build-suggest-runner-logs.misc.md b/docs/changelog.d/+container-build-suggest-runner-logs.misc.md deleted file mode 100644 index d10ea51..0000000 --- a/docs/changelog.d/+container-build-suggest-runner-logs.misc.md +++ /dev/null @@ -1 +0,0 @@ -`container-build-and-release` now prints the specific `mise run runner-logs ` command after dispatching, polling the Forgejo API to resolve the run number for the commit it just triggered. diff --git a/docs/changelog.d/+fix-forge-static-assets.bugfix.md b/docs/changelog.d/+fix-forge-static-assets.bugfix.md deleted file mode 100644 index de0517e..0000000 --- a/docs/changelog.d/+fix-forge-static-assets.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/docs/changelog.d/+fly-deploy-immediate-strategy.infra.md b/docs/changelog.d/+fly-deploy-immediate-strategy.infra.md deleted file mode 100644 index 205bd6a..0000000 --- a/docs/changelog.d/+fly-deploy-immediate-strategy.infra.md +++ /dev/null @@ -1 +0,0 @@ -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 (~5–10s) but actually completes. diff --git a/docs/changelog.d/+forge-mirrors-blackhole.infra.md b/docs/changelog.d/+forge-mirrors-blackhole.infra.md deleted file mode 100644 index 29a5e6a..0000000 --- a/docs/changelog.d/+forge-mirrors-blackhole.infra.md +++ /dev/null @@ -1 +0,0 @@ -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`. diff --git a/docs/changelog.d/+frigate-notify-local.infra.md b/docs/changelog.d/+frigate-notify-local.infra.md deleted file mode 100644 index 120f915..0000000 --- a/docs/changelog.d/+frigate-notify-local.infra.md +++ /dev/null @@ -1 +0,0 @@ -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`. diff --git a/docs/changelog.d/+grafana-recreate-strategy.infra.md b/docs/changelog.d/+grafana-recreate-strategy.infra.md deleted file mode 100644 index 3662e10..0000000 --- a/docs/changelog.d/+grafana-recreate-strategy.infra.md +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/docs/changelog.d/+homepage-config-perms-fix.bugfix.md b/docs/changelog.d/+homepage-config-perms-fix.bugfix.md deleted file mode 100644 index 20e1135..0000000 --- a/docs/changelog.d/+homepage-config-perms-fix.bugfix.md +++ /dev/null @@ -1,5 +0,0 @@ -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. diff --git a/docs/changelog.d/+homepage-dedup-migrated.misc.md b/docs/changelog.d/+homepage-dedup-migrated.misc.md deleted file mode 100644 index 9efc5ba..0000000 --- a/docs/changelog.d/+homepage-dedup-migrated.misc.md +++ /dev/null @@ -1,5 +0,0 @@ -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. diff --git a/docs/changelog.d/+immich-probe-ringtail.infra.md b/docs/changelog.d/+immich-probe-ringtail.infra.md deleted file mode 100644 index f2d3dee..0000000 --- a/docs/changelog.d/+immich-probe-ringtail.infra.md +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/docs/changelog.d/+manage-forgejo-mirrors-sync-location.doc.md b/docs/changelog.d/+manage-forgejo-mirrors-sync-location.doc.md deleted file mode 100644 index f71fc81..0000000 --- a/docs/changelog.d/+manage-forgejo-mirrors-sync-location.doc.md +++ /dev/null @@ -1 +0,0 @@ -Fix manage-forgejo-mirrors verify step — sync button is on the repo settings page ("Synchronize now"), not the main repo page. diff --git a/docs/changelog.d/+pin-quartz-v4.bugfix.md b/docs/changelog.d/+pin-quartz-v4.bugfix.md deleted file mode 100644 index e073bbb..0000000 --- a/docs/changelog.d/+pin-quartz-v4.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -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`. diff --git a/docs/changelog.d/+prowler-rebuild-on-main.infra.md b/docs/changelog.d/+prowler-rebuild-on-main.infra.md deleted file mode 100644 index 107b687..0000000 --- a/docs/changelog.d/+prowler-rebuild-on-main.infra.md +++ /dev/null @@ -1 +0,0 @@ -Rebuild Prowler container against main HEAD (v5.23.0-495e45d) after merging the IaC mutelist Dockerfile changes. diff --git a/docs/changelog.d/+remove-devpi-container-build.misc.md b/docs/changelog.d/+remove-devpi-container-build.misc.md deleted file mode 100644 index 8ebec54..0000000 --- a/docs/changelog.d/+remove-devpi-container-build.misc.md +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/docs/changelog.d/+retire-todoist-for-heph.infra.md b/docs/changelog.d/+retire-todoist-for-heph.infra.md deleted file mode 100644 index f6284d0..0000000 --- a/docs/changelog.d/+retire-todoist-for-heph.infra.md +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/docs/changelog.d/+review-1password-doc.doc.md b/docs/changelog.d/+review-1password-doc.doc.md deleted file mode 100644 index bba9591..0000000 --- a/docs/changelog.d/+review-1password-doc.doc.md +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/docs/changelog.d/+review-compliance-image-iac.feature.md b/docs/changelog.d/+review-compliance-image-iac.feature.md deleted file mode 100644 index 1125359..0000000 --- a/docs/changelog.d/+review-compliance-image-iac.feature.md +++ /dev/null @@ -1 +0,0 @@ -`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. diff --git a/docs/changelog.d/+review-contributing-doc.doc.md b/docs/changelog.d/+review-contributing-doc.doc.md deleted file mode 100644 index c394a01..0000000 --- a/docs/changelog.d/+review-contributing-doc.doc.md +++ /dev/null @@ -1 +0,0 @@ -Refresh the contributing tutorial: add `last-reviewed`, include the `.ai.md` changelog fragment type, and clarify that `prek` is pinned via `mise`. diff --git a/docs/changelog.d/+review-index-doc.doc.md b/docs/changelog.d/+review-index-doc.doc.md deleted file mode 100644 index 7016a7a..0000000 --- a/docs/changelog.d/+review-index-doc.doc.md +++ /dev/null @@ -1 +0,0 @@ -Reviewed `index.md`; added ringtail to the infrastructure overview and stamped `last-reviewed`. diff --git a/docs/changelog.d/+review-navidrome-doc.doc.md b/docs/changelog.d/+review-navidrome-doc.doc.md deleted file mode 100644 index fbe5e79..0000000 --- a/docs/changelog.d/+review-navidrome-doc.doc.md +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/docs/changelog.d/+review-ollama-doc.doc.md b/docs/changelog.d/+review-ollama-doc.doc.md deleted file mode 100644 index 05ef23e..0000000 --- a/docs/changelog.d/+review-ollama-doc.doc.md +++ /dev/null @@ -1 +0,0 @@ -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`. diff --git a/docs/changelog.d/+ringtail-clone-via-tailnet.infra.md b/docs/changelog.d/+ringtail-clone-via-tailnet.infra.md deleted file mode 100644 index d664163..0000000 --- a/docs/changelog.d/+ringtail-clone-via-tailnet.infra.md +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/docs/changelog.d/+ringtail-coredump-size-cap.infra.md b/docs/changelog.d/+ringtail-coredump-size-cap.infra.md deleted file mode 100644 index 824b2df..0000000 --- a/docs/changelog.d/+ringtail-coredump-size-cap.infra.md +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/docs/changelog.d/+ringtail-flake-update-2026-06-01.infra.md b/docs/changelog.d/+ringtail-flake-update-2026-06-01.infra.md deleted file mode 100644 index dd488b6..0000000 --- a/docs/changelog.d/+ringtail-flake-update-2026-06-01.infra.md +++ /dev/null @@ -1,4 +0,0 @@ -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]]. diff --git a/docs/changelog.d/+ringtail-proton-ge.infra.md b/docs/changelog.d/+ringtail-proton-ge.infra.md deleted file mode 100644 index 0d8bc04..0000000 --- a/docs/changelog.d/+ringtail-proton-ge.infra.md +++ /dev/null @@ -1,4 +0,0 @@ -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. diff --git a/docs/changelog.d/+ringtail-sn2-prelaunch.infra.md b/docs/changelog.d/+ringtail-sn2-prelaunch.infra.md deleted file mode 100644 index f9c68e2..0000000 --- a/docs/changelog.d/+ringtail-sn2-prelaunch.infra.md +++ /dev/null @@ -1,6 +0,0 @@ -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%`. diff --git a/docs/changelog.d/+ringtail-sway-fuzzel.bugfix.md b/docs/changelog.d/+ringtail-sway-fuzzel.bugfix.md deleted file mode 100644 index 6801040..0000000 --- a/docs/changelog.d/+ringtail-sway-fuzzel.bugfix.md +++ /dev/null @@ -1,3 +0,0 @@ -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. diff --git a/docs/changelog.d/+ringtail-vrr-flicker.bugfix.md b/docs/changelog.d/+ringtail-vrr-flicker.bugfix.md deleted file mode 100644 index cb23344..0000000 --- a/docs/changelog.d/+ringtail-vrr-flicker.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/docs/changelog.d/+rotate-fly-deploy-token-shell-examples.doc.md b/docs/changelog.d/+rotate-fly-deploy-token-shell-examples.doc.md deleted file mode 100644 index 24ffcb9..0000000 --- a/docs/changelog.d/+rotate-fly-deploy-token-shell-examples.doc.md +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/docs/changelog.d/+runner-logs-auth.feature.md b/docs/changelog.d/+runner-logs-auth.feature.md deleted file mode 100644 index 9ee6fa1..0000000 --- a/docs/changelog.d/+runner-logs-auth.feature.md +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/docs/changelog.d/+runner-logs-missing-log.misc.md b/docs/changelog.d/+runner-logs-missing-log.misc.md deleted file mode 100644 index c06704a..0000000 --- a/docs/changelog.d/+runner-logs-missing-log.misc.md +++ /dev/null @@ -1 +0,0 @@ -`mise run runner-logs -j ` 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. diff --git a/docs/changelog.d/+shower-1.1.1-deploy.infra.md b/docs/changelog.d/+shower-1.1.1-deploy.infra.md deleted file mode 100644 index 61244ac..0000000 --- a/docs/changelog.d/+shower-1.1.1-deploy.infra.md +++ /dev/null @@ -1 +0,0 @@ -Deploy shower v1.1.1 to ringtail (kustomize newTag bump). diff --git a/docs/changelog.d/+shower-1.1.1-fod-pin.infra.md b/docs/changelog.d/+shower-1.1.1-fod-pin.infra.md deleted file mode 100644 index a19b578..0000000 --- a/docs/changelog.d/+shower-1.1.1-fod-pin.infra.md +++ /dev/null @@ -1 +0,0 @@ -Pin shower v1.1.1 FOD outputHash (probed locally on ringtail). diff --git a/docs/changelog.d/+shower-1.1.1.infra.md b/docs/changelog.d/+shower-1.1.1.infra.md deleted file mode 100644 index eb9476c..0000000 --- a/docs/changelog.d/+shower-1.1.1.infra.md +++ /dev/null @@ -1 +0,0 @@ -Bump shower container to v1.1.1 (probe FOD hash). diff --git a/docs/changelog.d/+shower-1.1.3-deploy.infra.md b/docs/changelog.d/+shower-1.1.3-deploy.infra.md deleted file mode 100644 index 833fac6..0000000 --- a/docs/changelog.d/+shower-1.1.3-deploy.infra.md +++ /dev/null @@ -1 +0,0 @@ -Deployed shower v1.1.3 to ringtail (image built and pushed from ringtail; runner bypassed due to indri overload). diff --git a/docs/changelog.d/+shower-1.1.3.infra.md b/docs/changelog.d/+shower-1.1.3.infra.md deleted file mode 100644 index 33ee49d..0000000 --- a/docs/changelog.d/+shower-1.1.3.infra.md +++ /dev/null @@ -1 +0,0 @@ -Bumped shower app to v1.1.3 (wheel/sdist + FOD hashes probed on ringtail). diff --git a/docs/changelog.d/+shower-main-sha-rebuild.infra.md b/docs/changelog.d/+shower-main-sha-rebuild.infra.md deleted file mode 100644 index f1751b5..0000000 --- a/docs/changelog.d/+shower-main-sha-rebuild.infra.md +++ /dev/null @@ -1,5 +0,0 @@ -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]]. diff --git a/docs/changelog.d/+shower-rebuild-from-main-sha.misc.md b/docs/changelog.d/+shower-rebuild-from-main-sha.misc.md deleted file mode 100644 index a9495cd..0000000 --- a/docs/changelog.d/+shower-rebuild-from-main-sha.misc.md +++ /dev/null @@ -1,6 +0,0 @@ -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. diff --git a/docs/changelog.d/+shower-v1.1.2-rebuild-from-main-sha.misc.md b/docs/changelog.d/+shower-v1.1.2-rebuild-from-main-sha.misc.md deleted file mode 100644 index 9355a54..0000000 --- a/docs/changelog.d/+shower-v1.1.2-rebuild-from-main-sha.misc.md +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/docs/changelog.d/+tailscale-main-sha-rebuild.infra.md b/docs/changelog.d/+tailscale-main-sha-rebuild.infra.md deleted file mode 100644 index 24bb81c..0000000 --- a/docs/changelog.d/+tailscale-main-sha-rebuild.infra.md +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/docs/changelog.d/+transmission-doc-review.doc.md b/docs/changelog.d/+transmission-doc-review.doc.md deleted file mode 100644 index 418504f..0000000 --- a/docs/changelog.d/+transmission-doc-review.doc.md +++ /dev/null @@ -1 +0,0 @@ -Reviewed transmission card: corrected storage layout (`/config/` is emptyDir, watch dir disabled) and noted the Prometheus exporter sidecar. diff --git a/docs/changelog.d/+unpoller-rebuild-on-main.infra.md b/docs/changelog.d/+unpoller-rebuild-on-main.infra.md deleted file mode 100644 index 60ae8fa..0000000 --- a/docs/changelog.d/+unpoller-rebuild-on-main.infra.md +++ /dev/null @@ -1 +0,0 @@ -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). diff --git a/docs/changelog.d/+valkey-main-tag-bump.infra.md b/docs/changelog.d/+valkey-main-tag-bump.infra.md deleted file mode 100644 index cd19f60..0000000 --- a/docs/changelog.d/+valkey-main-tag-bump.infra.md +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/docs/changelog.d/+valkey-rebuild-on-main.infra.md b/docs/changelog.d/+valkey-rebuild-on-main.infra.md deleted file mode 100644 index c743e61..0000000 --- a/docs/changelog.d/+valkey-rebuild-on-main.infra.md +++ /dev/null @@ -1 +0,0 @@ -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`. diff --git a/docs/changelog.d/+wave1-decommission-followups.infra.md b/docs/changelog.d/+wave1-decommission-followups.infra.md deleted file mode 100644 index 7b54d52..0000000 --- a/docs/changelog.d/+wave1-decommission-followups.infra.md +++ /dev/null @@ -1,8 +0,0 @@ -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. diff --git a/docs/changelog.d/+zot-ci-rotation-op-syntax.doc.md b/docs/changelog.d/+zot-ci-rotation-op-syntax.doc.md deleted file mode 100644 index ec8834f..0000000 --- a/docs/changelog.d/+zot-ci-rotation-op-syntax.doc.md +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/docs/changelog.d/+zot-v2.1.16.infra.md b/docs/changelog.d/+zot-v2.1.16.infra.md deleted file mode 100644 index f007164..0000000 --- a/docs/changelog.d/+zot-v2.1.16.infra.md +++ /dev/null @@ -1 +0,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). diff --git a/docs/changelog.d/alloy-v1.16.0.infra.md b/docs/changelog.d/alloy-v1.16.0.infra.md deleted file mode 100644 index cd9a1ef..0000000 --- a/docs/changelog.d/alloy-v1.16.0.infra.md +++ /dev/null @@ -1,5 +0,0 @@ -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. diff --git a/docs/changelog.d/backup-grafana-ringtail-blumeops-pg.infra.md b/docs/changelog.d/backup-grafana-ringtail-blumeops-pg.infra.md deleted file mode 100644 index 33b041f..0000000 --- a/docs/changelog.d/backup-grafana-ringtail-blumeops-pg.infra.md +++ /dev/null @@ -1,8 +0,0 @@ -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. diff --git a/docs/changelog.d/cleanup-cv-docs-minikube-artifacts.misc.md b/docs/changelog.d/cleanup-cv-docs-minikube-artifacts.misc.md deleted file mode 100644 index 79a81cf..0000000 --- a/docs/changelog.d/cleanup-cv-docs-minikube-artifacts.misc.md +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/docs/changelog.d/dagger-0-20-6-runner-image-alpine.infra.md b/docs/changelog.d/dagger-0-20-6-runner-image-alpine.infra.md deleted file mode 100644 index 35f77c2..0000000 --- a/docs/changelog.d/dagger-0-20-6-runner-image-alpine.infra.md +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/docs/changelog.d/decommission-wave1-minikube.infra.md b/docs/changelog.d/decommission-wave1-minikube.infra.md deleted file mode 100644 index 63b3ab5..0000000 --- a/docs/changelog.d/decommission-wave1-minikube.infra.md +++ /dev/null @@ -1,8 +0,0 @@ -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). diff --git a/docs/changelog.d/doc-review-replicating-blumeops.doc.md b/docs/changelog.d/doc-review-replicating-blumeops.doc.md deleted file mode 100644 index e9e6d0f..0000000 --- a/docs/changelog.d/doc-review-replicating-blumeops.doc.md +++ /dev/null @@ -1 +0,0 @@ -Reviewed `replicating-blumeops` tutorial: fixed "BluemeOps" typos (also in `contributing.md`) and added `last-reviewed` frontmatter. diff --git a/docs/changelog.d/fix-borgmatic-shower-via-ssh.bugfix.md b/docs/changelog.d/fix-borgmatic-shower-via-ssh.bugfix.md deleted file mode 100644 index e18272c..0000000 --- a/docs/changelog.d/fix-borgmatic-shower-via-ssh.bugfix.md +++ /dev/null @@ -1,14 +0,0 @@ -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:` (mealie — kubectl uses indri's -kubeconfig) or `ssh:` (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. diff --git a/docs/changelog.d/forgejo-runner-v12-8-server-connections.infra.md b/docs/changelog.d/forgejo-runner-v12-8-server-connections.infra.md deleted file mode 100644 index cc35684..0000000 --- a/docs/changelog.d/forgejo-runner-v12-8-server-connections.infra.md +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/docs/changelog.d/homepage-to-ringtail.infra.md b/docs/changelog.d/homepage-to-ringtail.infra.md deleted file mode 100644 index 1e3e795..0000000 --- a/docs/changelog.d/homepage-to-ringtail.infra.md +++ /dev/null @@ -1,8 +0,0 @@ -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. diff --git a/docs/changelog.d/migrate-cv-docs-to-indri.infra.md b/docs/changelog.d/migrate-cv-docs-to-indri.infra.md deleted file mode 100644 index 608a6b9..0000000 --- a/docs/changelog.d/migrate-cv-docs-to-indri.infra.md +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/docs/changelog.d/migrate-devpi-to-indri.infra.md b/docs/changelog.d/migrate-devpi-to-indri.infra.md deleted file mode 100644 index 418db70..0000000 --- a/docs/changelog.d/migrate-devpi-to-indri.infra.md +++ /dev/null @@ -1 +0,0 @@ -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]]. diff --git a/docs/changelog.d/migrate-immich-to-ringtail.infra.md b/docs/changelog.d/migrate-immich-to-ringtail.infra.md deleted file mode 100644 index b47742f..0000000 --- a/docs/changelog.d/migrate-immich-to-ringtail.infra.md +++ /dev/null @@ -1,13 +0,0 @@ -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. diff --git a/docs/changelog.d/migrate-wave1-ringtail.infra.md b/docs/changelog.d/migrate-wave1-ringtail.infra.md deleted file mode 100644 index c44263a..0000000 --- a/docs/changelog.d/migrate-wave1-ringtail.infra.md +++ /dev/null @@ -1,13 +0,0 @@ -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]]. diff --git a/docs/changelog.d/mirror-tailscale-container.infra.md b/docs/changelog.d/mirror-tailscale-container.infra.md deleted file mode 100644 index 54ca3ba..0000000 --- a/docs/changelog.d/mirror-tailscale-container.infra.md +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/docs/changelog.d/prowler-iac-mutelist.infra.md b/docs/changelog.d/prowler-iac-mutelist.infra.md deleted file mode 100644 index 077cfa8..0000000 --- a/docs/changelog.d/prowler-iac-mutelist.infra.md +++ /dev/null @@ -1 +0,0 @@ -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`. diff --git a/docs/changelog.d/recurring-maintenance-2026-05-27.doc.md b/docs/changelog.d/recurring-maintenance-2026-05-27.doc.md deleted file mode 100644 index af30489..0000000 --- a/docs/changelog.d/recurring-maintenance-2026-05-27.doc.md +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/docs/changelog.d/recurring-maintenance-2026-05-27.infra.md b/docs/changelog.d/recurring-maintenance-2026-05-27.infra.md deleted file mode 100644 index f2d48ad..0000000 --- a/docs/changelog.d/recurring-maintenance-2026-05-27.infra.md +++ /dev/null @@ -1,4 +0,0 @@ -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. diff --git a/docs/changelog.d/review-ringtail-flake-2026-05-11.infra.md b/docs/changelog.d/review-ringtail-flake-2026-05-11.infra.md deleted file mode 100644 index f39f9f4..0000000 --- a/docs/changelog.d/review-ringtail-flake-2026-05-11.infra.md +++ /dev/null @@ -1 +0,0 @@ -Updated `nixos/ringtail/flake.lock` (weekly cadence): `disko`, `home-manager`, and `nixpkgs` inputs refreshed. `nixpkgs-services` skipped per overlay convention. diff --git a/docs/changelog.d/ringtail-static-ip.infra.md b/docs/changelog.d/ringtail-static-ip.infra.md deleted file mode 100644 index 8474b0a..0000000 --- a/docs/changelog.d/ringtail-static-ip.infra.md +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/docs/changelog.d/rip-out-compensating-controls.infra.md b/docs/changelog.d/rip-out-compensating-controls.infra.md deleted file mode 100644 index d41fd1a..0000000 --- a/docs/changelog.d/rip-out-compensating-controls.infra.md +++ /dev/null @@ -1 +0,0 @@ -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: ` 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. diff --git a/docs/changelog.d/service-review-mealie-2026-05-11.infra.md b/docs/changelog.d/service-review-mealie-2026-05-11.infra.md deleted file mode 100644 index 074cd21..0000000 --- a/docs/changelog.d/service-review-mealie-2026-05-11.infra.md +++ /dev/null @@ -1 +0,0 @@ -Reviewed `mealie` service version freshness; upstream is 5 minor versions ahead (v3.17.0 vs deployed v3.12.0). Marked reviewed; upgrade deferred. diff --git a/docs/changelog.d/shower-app-deploy.bugfix.md b/docs/changelog.d/shower-app-deploy.bugfix.md deleted file mode 100644 index 91d2b3b..0000000 --- a/docs/changelog.d/shower-app-deploy.bugfix.md +++ /dev/null @@ -1,13 +0,0 @@ -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`). diff --git a/docs/changelog.d/shower-app-deploy.feature.md b/docs/changelog.d/shower-app-deploy.feature.md deleted file mode 100644 index 96218be..0000000 --- a/docs/changelog.d/shower-app-deploy.feature.md +++ /dev/null @@ -1,4 +0,0 @@ -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). diff --git a/docs/changelog.d/shower-app-deploy.infra.md b/docs/changelog.d/shower-app-deploy.infra.md deleted file mode 100644 index 157a068..0000000 --- a/docs/changelog.d/shower-app-deploy.infra.md +++ /dev/null @@ -1,9 +0,0 @@ -Wire shower app for public exposure: fly nginx `shower.eblu.me` server -block as a guest-only surface — splash page, `/prizes//`, 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. diff --git a/docs/changelog.d/shower-v1.1.0.feature.md b/docs/changelog.d/shower-v1.1.0.feature.md deleted file mode 100644 index d2c3400..0000000 --- a/docs/changelog.d/shower-v1.1.0.feature.md +++ /dev/null @@ -1,15 +0,0 @@ -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. diff --git a/docs/changelog.d/shower-v1.1.2.infra.md b/docs/changelog.d/shower-v1.1.2.infra.md deleted file mode 100644 index aa2db0d..0000000 --- a/docs/changelog.d/shower-v1.1.2.infra.md +++ /dev/null @@ -1 +0,0 @@ -Deploy shower v1.1.2 — bump container build to new app release. diff --git a/docs/changelog.d/unpoller-v3.infra.md b/docs/changelog.d/unpoller-v3.infra.md deleted file mode 100644 index fa6eaf9..0000000 --- a/docs/changelog.d/unpoller-v3.infra.md +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/docs/changelog.d/update-tooling-deps-2026-04.doc.md b/docs/changelog.d/update-tooling-deps-2026-04.doc.md deleted file mode 100644 index 141e975..0000000 --- a/docs/changelog.d/update-tooling-deps-2026-04.doc.md +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/docs/changelog.d/update-tooling-deps-2026-04.infra.md b/docs/changelog.d/update-tooling-deps-2026-04.infra.md deleted file mode 100644 index 4731eca..0000000 --- a/docs/changelog.d/update-tooling-deps-2026-04.infra.md +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/docs/changelog.d/valkey-mirror.infra.md b/docs/changelog.d/valkey-mirror.infra.md deleted file mode 100644 index 06f8d98..0000000 --- a/docs/changelog.d/valkey-mirror.infra.md +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/docs/changelog.d/valkey-nix.infra.md b/docs/changelog.d/valkey-nix.infra.md deleted file mode 100644 index e41eb63..0000000 --- a/docs/changelog.d/valkey-nix.infra.md +++ /dev/null @@ -1 +0,0 @@ -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). From 02ea1cc72af43928e9105479ded91da9b51ca18a Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Thu, 4 Jun 2026 12:39:50 -0700 Subject: [PATCH 420/430] C0: point tailscale-operator base mirror fetch at tailnet forge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The public forge.eblu.me now black-holes /mirrors/ at the Fly edge (AI-scraper mitigation), so the in-cluster ArgoCD repo-server got a 403 fetching the upstream operator manifest — leaving tailscale-operator and tailscale-operator-ringtail in Unknown sync. Use forge.ops.eblu.me. Co-Authored-By: Claude Opus 4.8 (1M context) --- argocd/manifests/tailscale-operator-base/kustomization.yaml | 5 ++++- .../+tailscale-operator-mirror-tailnet-url.bugfix.md | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 docs/changelog.d/+tailscale-operator-mirror-tailnet-url.bugfix.md diff --git a/argocd/manifests/tailscale-operator-base/kustomization.yaml b/argocd/manifests/tailscale-operator-base/kustomization.yaml index 4519af6..9d117ef 100644 --- a/argocd/manifests/tailscale-operator-base/kustomization.yaml +++ b/argocd/manifests/tailscale-operator-base/kustomization.yaml @@ -6,8 +6,11 @@ namespace: tailscale # Upstream Tailscale operator manifest from forge mirror. # 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: - - https://forge.eblu.me/mirrors/tailscale/raw/tag/v1.94.2/cmd/k8s-operator/deploy/manifests/operator.yaml + - https://forge.ops.eblu.me/mirrors/tailscale/raw/tag/v1.94.2/cmd/k8s-operator/deploy/manifests/operator.yaml - proxyclass.yaml - dnsconfig.yaml diff --git a/docs/changelog.d/+tailscale-operator-mirror-tailnet-url.bugfix.md b/docs/changelog.d/+tailscale-operator-mirror-tailnet-url.bugfix.md new file mode 100644 index 0000000..cc29cf7 --- /dev/null +++ b/docs/changelog.d/+tailscale-operator-mirror-tailnet-url.bugfix.md @@ -0,0 +1 @@ +Fixed the `tailscale-operator` and `tailscale-operator-ringtail` ArgoCD apps showing `Unknown` sync status. Their shared base kustomization fetched the upstream operator manifest from the public `forge.eblu.me/mirrors/...`, which the AI-scraper mitigation now black-holes (403). Pointed the remote resource at the tailnet host `forge.ops.eblu.me` instead, which the in-cluster repo-server can reach. From bb55fa95667903e1b38c084a46690e7da61eef0d Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Thu, 4 Jun 2026 13:37:02 -0700 Subject: [PATCH 421/430] Recurring review sweep: 4 doc cards + nvidia-device-plugin v0.19.2 (#366) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Knocks out the two daily recurring review tasks (doc review + service review) in one PR. ## Doc review (4 never-reviewed reference cards, `last-reviewed: 2026-06-04`) - **cluster.md** — Kubernetes version v1.34.0 → **v1.35.0**; refreshed the stale ringtail workload list and noted the in-progress minikube→k3s migration (points to `[[ringtail]]` as the canonical list). - **ntfy.md / tempo.md / alloy.md** — corrected image references: these are now **locally-built `registry.ops.eblu.me/blumeops/*` nix containers** (ntfy v2.19.2, tempo v2.10.3, alloy-k8s v1.16.0), not upstream Docker Hub. Fly.io alloy binary bumped to v1.16.1. ## Service review - **nvidia-device-plugin** (ringtail GPU): v0.19.0 → **v0.19.2**. Upstream patch releases — CDI/Tegra fixes + dependency bumps, no breaking changes for our manifest-based CDI + RuntimeClass setup (the service-account change in the notes is helm-only). ## Not in this PR (need container rebuilds, deferred) The other stale services are locally-built nix images, so upgrading them is a forge-runner rebuild rather than a clean tag bump — left untouched (not date-bumped, so they resurface): **prometheus** (v3.10.0→v3.12.0), **loki** (3.6.7→3.7.2), **kube-state-metrics**, **homepage**. Happy to do these as a follow-up rebuild PR. ## Deploy / verify Not yet deployed — `nvidia-device-plugin` still points at `main`. After review: ``` argocd app set nvidia-device-plugin --revision reviews-jun4 && argocd app sync nvidia-device-plugin # after merge: argocd app set nvidia-device-plugin --revision main && argocd app sync nvidia-device-plugin ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/366 --- argocd/manifests/nvidia-device-plugin/kustomization.yaml | 2 +- docs/changelog.d/reviews-jun4.doc.md | 1 + docs/changelog.d/reviews-jun4.infra.md | 1 + docs/reference/kubernetes/cluster.md | 9 ++++++--- docs/reference/services/alloy.md | 7 ++++--- docs/reference/services/ntfy.md | 5 +++-- docs/reference/services/tempo.md | 5 +++-- service-versions.yaml | 4 ++-- 8 files changed, 21 insertions(+), 13 deletions(-) create mode 100644 docs/changelog.d/reviews-jun4.doc.md create mode 100644 docs/changelog.d/reviews-jun4.infra.md diff --git a/argocd/manifests/nvidia-device-plugin/kustomization.yaml b/argocd/manifests/nvidia-device-plugin/kustomization.yaml index a46edf6..f5a33ae 100644 --- a/argocd/manifests/nvidia-device-plugin/kustomization.yaml +++ b/argocd/manifests/nvidia-device-plugin/kustomization.yaml @@ -10,4 +10,4 @@ resources: images: - name: nvcr.io/nvidia/k8s-device-plugin - newTag: v0.19.0 + newTag: v0.19.2 diff --git a/docs/changelog.d/reviews-jun4.doc.md b/docs/changelog.d/reviews-jun4.doc.md new file mode 100644 index 0000000..f1aeaa8 --- /dev/null +++ b/docs/changelog.d/reviews-jun4.doc.md @@ -0,0 +1 @@ +Reviewed four never-reviewed reference cards (`cluster`, `ntfy`, `tempo`, `alloy`) and corrected drift: minikube is now Kubernetes v1.35.0; ntfy, tempo, and alloy-k8s images are now locally-built `registry.ops.eblu.me/blumeops/*` nix containers (v2.19.2, v2.10.3, v1.16.0) rather than upstream Docker Hub; the Fly.io alloy binary is v1.16.1; and the ringtail workload list reflects the in-progress minikube→k3s migration. diff --git a/docs/changelog.d/reviews-jun4.infra.md b/docs/changelog.d/reviews-jun4.infra.md new file mode 100644 index 0000000..c128e70 --- /dev/null +++ b/docs/changelog.d/reviews-jun4.infra.md @@ -0,0 +1 @@ +Upgraded the nvidia-device-plugin on ringtail from v0.19.0 to v0.19.2 (upstream patch release: CDI/Tegra fixes and dependency bumps, no breaking changes for our manifest-based CDI + RuntimeClass setup). diff --git a/docs/reference/kubernetes/cluster.md b/docs/reference/kubernetes/cluster.md index 9b632bd..07c14af 100644 --- a/docs/reference/kubernetes/cluster.md +++ b/docs/reference/kubernetes/cluster.md @@ -1,6 +1,7 @@ --- title: Cluster -modified: 2026-02-19 +modified: 2026-06-04 +last-reviewed: 2026-06-04 tags: - kubernetes --- @@ -15,7 +16,7 @@ BlumeOps runs two Kubernetes clusters: a Minikube cluster on [[indri]] (most ser |----------|-------| | **Driver** | docker | | **Container Runtime** | docker | -| **Kubernetes Version** | v1.34.0 | +| **Kubernetes Version** | v1.35.0 | | **CPUs** | 6 | | **Memory** | 11GB | | **Disk** | 200GB | @@ -41,7 +42,9 @@ Single-node k3s cluster for workloads requiring amd64 or GPU access. See [[ringt |----------|-------| | **Context** | `k3s-ringtail` | | **API Server** | `https://ringtail.tail8d86e.ts.net:6443` | -| **Workloads** | Frigate (GPU), ntfy, frigate-notify, nvidia-device-plugin | +| **Workloads** | GPU workloads (Frigate, Ollama), notifications (ntfy, frigate-notify), [[authentik]], and services migrated off indri minikube (Immich, Mealie, Paperless, TeslaMate). See [[ringtail]] for the authoritative list. | + +Services are being progressively migrated from indri's minikube to ringtail's k3s; the split above reflects an in-progress state, not a fixed boundary. ## Related diff --git a/docs/reference/services/alloy.md b/docs/reference/services/alloy.md index d781f2f..97d1e77 100644 --- a/docs/reference/services/alloy.md +++ b/docs/reference/services/alloy.md @@ -1,6 +1,7 @@ --- title: Alloy -modified: 2026-03-13 +modified: 2026-06-04 +last-reviewed: 2026-06-04 tags: - service - observability @@ -20,10 +21,10 @@ Unified observability collector for metrics and logs with three deployments: | **Indri Binary** | `~/.local/bin/alloy` | | **Indri Config** | `~/.config/grafana-alloy/config.alloy` | | **K8s Namespace** | `alloy` | -| **K8s Image** | `grafana/alloy:v1.14.0` | +| **K8s Image** | `registry.ops.eblu.me/blumeops/alloy:v1.16.0-9564435` (locally built) | | **ArgoCD App** | `alloy-k8s` | | **Fly.io Config** | `fly/alloy.river` | -| **Fly.io Image** | `grafana/alloy:v1.5.1` (binary copied into nginx container) | +| **Fly.io Image** | `grafana/alloy:v1.16.1` (binary copied into nginx container, sha-pinned) | ## Metrics Collected diff --git a/docs/reference/services/ntfy.md b/docs/reference/services/ntfy.md index b549a6d..1bf45af 100644 --- a/docs/reference/services/ntfy.md +++ b/docs/reference/services/ntfy.md @@ -1,6 +1,7 @@ --- title: Ntfy -modified: 2026-02-17 +modified: 2026-06-04 +last-reviewed: 2026-06-04 tags: - service - notifications @@ -17,7 +18,7 @@ Self-hosted push notification service. Ntfy receives HTTP POST messages and deli | **URL** | https://ntfy.ops.eblu.me | | **Tailscale URL** | https://ntfy.tail8d86e.ts.net | | **Namespace** | `ntfy` | -| **Image** | `binwiederhier/ntfy:v2.17.0` | +| **Image** | `registry.ops.eblu.me/blumeops/ntfy:v2.19.2-fd0bebb-nix` (locally built) | | **Upstream** | https://github.com/binwiederhier/ntfy | | **Manifests** | `argocd/manifests/ntfy/` | diff --git a/docs/reference/services/tempo.md b/docs/reference/services/tempo.md index 771b97f..5eb5d87 100644 --- a/docs/reference/services/tempo.md +++ b/docs/reference/services/tempo.md @@ -1,6 +1,7 @@ --- title: Tempo -modified: 2026-03-05 +modified: 2026-06-04 +last-reviewed: 2026-06-04 tags: - service - observability @@ -18,7 +19,7 @@ Distributed tracing backend for BlumeOps infrastructure. Receives traces via OTL | **Tailscale URL** | https://tempo.tail8d86e.ts.net | | **OTLP Endpoint** | https://tempo-otlp.tail8d86e.ts.net | | **Namespace** | `monitoring` | -| **Image** | `grafana/tempo:2.10.1` | +| **Image** | `registry.ops.eblu.me/blumeops/tempo:v2.10.3-75f9ba4` (locally built) | | **Storage** | 10Gi PVC (local filesystem) | | **Retention** | 7 days | diff --git a/service-versions.yaml b/service-versions.yaml index 699f89c..11ec9f9 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -56,8 +56,8 @@ services: - name: nvidia-device-plugin type: argocd - last-reviewed: 2026-03-27 - current-version: "v0.19.0" + last-reviewed: 2026-06-04 + current-version: "v0.19.2" upstream-source: https://github.com/NVIDIA/k8s-device-plugin/releases notes: DaemonSet + RuntimeClass on ringtail for GPU workloads From 0e70a1b5242183170a5d7d8ac96ee864063f65bb Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Thu, 4 Jun 2026 14:55:55 -0700 Subject: [PATCH 422/430] Localize external-secrets container (native container.py build) (#367) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Knocks out the weekly "pick one non-local container and make it local" task by moving **external-secrets** off `ghcr.io` onto a locally-built image, under our own supply-chain control. Doubles as its overdue service review. ## What changed - **`containers/external-secrets/container.py`** (new) — native Dagger build (the Dockerfile→container.py migration pattern). Clones the forge mirror at `v2.2.0` and builds the single `all_providers` static Go binary, faithful to upstream's `make build` (CGO off, no version ldflags upstream). ENTRYPOINT is `/bin/external-secrets` so the controller/webhook/cert-controller Deployments select their role via `args:` exactly as before. - **`argocd/manifests/external-secrets/kustomization.yaml`** — image swapped to `registry.ops.eblu.me/blumeops/external-secrets:v2.2.0-2985007`. **Like-for-like (v2.2.0)**, not an upgrade. - **`service-versions.yaml`** — marked reviewed (2026-06-04), noted the local build. ## Build Built on the indri forge runner (run #579, ~4 min) → pushed to Zot. Image config verified: `Entrypoint=/bin/external-secrets`, `User=65534`, version label `v2.2.0`. ## Deployed from branch & verified - All 3 pods (controller / webhook / cert-controller) rolled to the local image, `1/1 Running` - Controller + webhook logs clean (no errors; webhook serving TLS) - **End-to-end secret fetch proven:** force-synced `monitoring/grafana-admin` → `refreshTime` advanced to now, `Ready=True` - All 10 ExternalSecrets cluster-wide remain `SecretSynced=True` — no collateral damage - App `Healthy` ## Post-merge `external-secrets` currently points at this branch (so `apps` reads OutOfSync — expected). After merge: ``` argocd app set external-secrets --revision main && argocd app sync external-secrets ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/367 --- .../external-secrets/kustomization.yaml | 3 +- containers/external-secrets/container.py | 51 +++++++++++++++++++ .../local-external-secrets.infra.md | 1 + service-versions.yaml | 7 ++- 4 files changed, 59 insertions(+), 3 deletions(-) create mode 100644 containers/external-secrets/container.py create mode 100644 docs/changelog.d/local-external-secrets.infra.md diff --git a/argocd/manifests/external-secrets/kustomization.yaml b/argocd/manifests/external-secrets/kustomization.yaml index 574aaa7..c25a7d5 100644 --- a/argocd/manifests/external-secrets/kustomization.yaml +++ b/argocd/manifests/external-secrets/kustomization.yaml @@ -12,4 +12,5 @@ resources: images: - name: ghcr.io/external-secrets/external-secrets - newTag: v2.2.0 + newName: registry.ops.eblu.me/blumeops/external-secrets + newTag: v2.2.0-2985007 diff --git a/containers/external-secrets/container.py b/containers/external-secrets/container.py new file mode 100644 index 0000000..6be5765 --- /dev/null +++ b/containers/external-secrets/container.py @@ -0,0 +1,51 @@ +"""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"]) + ) diff --git a/docs/changelog.d/local-external-secrets.infra.md b/docs/changelog.d/local-external-secrets.infra.md new file mode 100644 index 0000000..13cbb05 --- /dev/null +++ b/docs/changelog.d/local-external-secrets.infra.md @@ -0,0 +1 @@ +Localized the external-secrets controller image. It now builds from the forge mirror via a native Dagger `container.py` (single `all_providers` static Go binary, faithful to upstream's `make build`) and is served from `registry.ops.eblu.me/blumeops/external-secrets` instead of `ghcr.io`, bringing another platform component under local supply-chain control. diff --git a/service-versions.yaml b/service-versions.yaml index 11ec9f9..cc9dc9e 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -159,10 +159,13 @@ services: - name: external-secrets type: argocd - last-reviewed: 2026-03-25 + last-reviewed: 2026-06-04 current-version: "v2.2.0" upstream-source: https://github.com/external-secrets/external-secrets/releases - notes: Static kustomize manifests rendered from upstream Helm chart + notes: >- + Static kustomize manifests rendered from upstream Helm chart. Controller + image is locally built from the forge mirror via containers/external-secrets/container.py + (single all_providers static Go binary). - name: 1password-connect type: argocd From 30c82079b9dbb8e2492586d979cd4ec5b04cd08d Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Thu, 4 Jun 2026 14:59:17 -0700 Subject: [PATCH 423/430] C0: rebuild external-secrets image off main (v2.2.0-0e70a1b) Repoint to the main-branch-built image so the deployed tag traces to a main commit rather than the merged feature branch. Same v2.2.0 source, stable provenance. Co-Authored-By: Claude Opus 4.8 (1M context) --- argocd/manifests/external-secrets/kustomization.yaml | 2 +- docs/changelog.d/+external-secrets-main-sha-rebuild.infra.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 docs/changelog.d/+external-secrets-main-sha-rebuild.infra.md diff --git a/argocd/manifests/external-secrets/kustomization.yaml b/argocd/manifests/external-secrets/kustomization.yaml index c25a7d5..8b1aea5 100644 --- a/argocd/manifests/external-secrets/kustomization.yaml +++ b/argocd/manifests/external-secrets/kustomization.yaml @@ -13,4 +13,4 @@ resources: images: - name: ghcr.io/external-secrets/external-secrets newName: registry.ops.eblu.me/blumeops/external-secrets - newTag: v2.2.0-2985007 + newTag: v2.2.0-0e70a1b diff --git a/docs/changelog.d/+external-secrets-main-sha-rebuild.infra.md b/docs/changelog.d/+external-secrets-main-sha-rebuild.infra.md new file mode 100644 index 0000000..2e931d4 --- /dev/null +++ b/docs/changelog.d/+external-secrets-main-sha-rebuild.infra.md @@ -0,0 +1 @@ +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. From 13895bb04a5afcbb723d7ab3355d228431d76a5d Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Thu, 4 Jun 2026 15:37:42 -0700 Subject: [PATCH 424/430] Localize external-secrets on ringtail (amd64 nix build) (#368) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #367. That PR localized external-secrets but the Dagger build (on indri's Apple Silicon runner) only produces an **arm64** image — and external-secrets also runs on **ringtail (amd64)** via the same shared manifest. This completes the localization so both clusters run the local binary on their native arch. ## Approach (matches the kube-state-metrics dual-build pattern) - **`containers/external-secrets/default.nix`** (new) — builds the **amd64** image on ringtail's nix-container-builder. `buildGoModule` with Go 1.26 (v2.2.0 requires ≥1.26.1; nixpkgs default is 1.25.x) and `-tags all_providers`, faithful to upstream. Same v2.2.0 source from the forge mirror. - **`argocd/manifests/external-secrets-ringtail/`** (new) — thin kustomize overlay that reuses the shared indri manifest as a base and overrides **only** the image to the `-nix` (amd64) tag. No manifest duplication. - **`argocd/apps/external-secrets-ringtail.yaml`** — repointed at the new overlay. Result: indri → `v2.2.0-…` (arm64, Dagger), ringtail → `v2.2.0-…-nix` (amd64, nix). ## Build Run #581 built both arches at the branch commit. Verified the nix image is `linux/amd64`, entrypoint = the binary, user 65534. ## Deployed from branch & verified on ringtail (k3s, amd64) - All 3 pods rolled to the nix amd64 image, `1/1 Running` (no exec-format error → arch correct) - Controller logs clean - **Live secret fetch proven:** force-synced `homepage/homepage-grafana` → `refreshTime` advanced, `Ready=True` - **All 20** ringtail ExternalSecrets remain `SecretSynced=True` ## Post-merge The `external-secrets-ringtail` app is temporarily pointed at this branch + overlay path (apps app left on `main`, manual-sync, untouched). After merge: ``` argocd app sync apps # picks up the new Application path on main argocd app set external-secrets-ringtail --revision main && argocd app sync external-secrets-ringtail ``` I'll also rebuild off `main` so both clusters land on stable main-sha tags (as done for indri in #367). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/368 --- argocd/apps/external-secrets-ringtail.yaml | 2 +- .../kustomization.yaml | 16 ++++++ containers/external-secrets/default.nix | 56 +++++++++++++++++++ .../external-secrets-ringtail-nix.infra.md | 1 + 4 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 argocd/manifests/external-secrets-ringtail/kustomization.yaml create mode 100644 containers/external-secrets/default.nix create mode 100644 docs/changelog.d/external-secrets-ringtail-nix.infra.md diff --git a/argocd/apps/external-secrets-ringtail.yaml b/argocd/apps/external-secrets-ringtail.yaml index e2f5898..0bb8bd7 100644 --- a/argocd/apps/external-secrets-ringtail.yaml +++ b/argocd/apps/external-secrets-ringtail.yaml @@ -15,7 +15,7 @@ spec: source: repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git targetRevision: main - path: argocd/manifests/external-secrets + path: argocd/manifests/external-secrets-ringtail destination: server: https://ringtail.tail8d86e.ts.net:6443 namespace: external-secrets diff --git a/argocd/manifests/external-secrets-ringtail/kustomization.yaml b/argocd/manifests/external-secrets-ringtail/kustomization.yaml new file mode 100644 index 0000000..05b6b54 --- /dev/null +++ b/argocd/manifests/external-secrets-ringtail/kustomization.yaml @@ -0,0 +1,16 @@ +# 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-59dace8-nix diff --git a/containers/external-secrets/default.nix b/containers/external-secrets/default.nix new file mode 100644 index 0000000..eabe03d --- /dev/null +++ b/containers/external-secrets/default.nix @@ -0,0 +1,56 @@ +# 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 { } }: + +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"; + }; +} diff --git a/docs/changelog.d/external-secrets-ringtail-nix.infra.md b/docs/changelog.d/external-secrets-ringtail-nix.infra.md new file mode 100644 index 0000000..9ce3f85 --- /dev/null +++ b/docs/changelog.d/external-secrets-ringtail-nix.infra.md @@ -0,0 +1 @@ +Completed the external-secrets localization for the ringtail (amd64) cluster. The indri Dagger build (`container.py`) only produces an arm64 image; added `containers/external-secrets/default.nix` to build the amd64 variant on ringtail's nix-container-builder, and gave `external-secrets-ringtail` a thin kustomize overlay that reuses the shared manifest and points at the `-nix` image. Both clusters now run the locally-built external-secrets binary on their native architecture. From f6c926f1f594a0ee019bca5d31cdcc4225f6d6cf Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Thu, 4 Jun 2026 16:19:20 -0700 Subject: [PATCH 425/430] C0: rebuild external-secrets off main, repoint both clusters to stable tags indri -> v2.2.0-13895bb (arm64), ringtail -> v2.2.0-13895bb-nix (amd64). Both deployed images now trace to main commit 13895bb instead of earlier branch builds. Co-Authored-By: Claude Opus 4.8 (1M context) --- argocd/manifests/external-secrets-ringtail/kustomization.yaml | 2 +- argocd/manifests/external-secrets/kustomization.yaml | 2 +- docs/changelog.d/+external-secrets-stable-main-sha.infra.md | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) create mode 100644 docs/changelog.d/+external-secrets-stable-main-sha.infra.md diff --git a/argocd/manifests/external-secrets-ringtail/kustomization.yaml b/argocd/manifests/external-secrets-ringtail/kustomization.yaml index 05b6b54..9fd4e2f 100644 --- a/argocd/manifests/external-secrets-ringtail/kustomization.yaml +++ b/argocd/manifests/external-secrets-ringtail/kustomization.yaml @@ -13,4 +13,4 @@ resources: images: - name: registry.ops.eblu.me/blumeops/external-secrets - newTag: v2.2.0-59dace8-nix + newTag: v2.2.0-13895bb-nix diff --git a/argocd/manifests/external-secrets/kustomization.yaml b/argocd/manifests/external-secrets/kustomization.yaml index 8b1aea5..639db66 100644 --- a/argocd/manifests/external-secrets/kustomization.yaml +++ b/argocd/manifests/external-secrets/kustomization.yaml @@ -13,4 +13,4 @@ resources: images: - name: ghcr.io/external-secrets/external-secrets newName: registry.ops.eblu.me/blumeops/external-secrets - newTag: v2.2.0-0e70a1b + newTag: v2.2.0-13895bb diff --git a/docs/changelog.d/+external-secrets-stable-main-sha.infra.md b/docs/changelog.d/+external-secrets-stable-main-sha.infra.md new file mode 100644 index 0000000..fbe3c21 --- /dev/null +++ b/docs/changelog.d/+external-secrets-stable-main-sha.infra.md @@ -0,0 +1 @@ +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. From a2f1e062243a47c7c68b5a57617f14102b798503 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Fri, 5 Jun 2026 06:46:58 -0700 Subject: [PATCH 426/430] Add hephaestus sync hub to indri (launchagent, PWA, device-code OIDC) (#369) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes indri the canonical **heph** hub for the hub-and-spoke task/context system, deployed as a self-updating LaunchAgent managed by Ansible. Other devices (gilbert) attach as offline-capable spokes. ## What's here - **`ansible/roles/heph`** (tag `heph`) — bootstrap `cargo install hephd` (only if absent; `--self-update` keeps it current after), version-pinned `heph-pwa` checkout served via `--web-root`, launchagent `mcquack.eblume.heph`: ``` hephd --mode server --http-addr 0.0.0.0:8787 --db … --web-root … --oidc-issuer …/o/heph/ --oidc-audience heph --self-update --self-update-interval-secs 600 ``` `~/.cargo/bin` is on the agent `PATH` so self-update's `cargo install` works. - **Caddy** — `heph.ops.eblu.me → localhost:8787` (TLS for the PWA secure context). - **Authentik** — new `heph` **public device-code** OIDC app + `default-device-code-flow` bound to the default brand's `flow_device_code` (verified live: brand `authentik-default`, field currently unset → additive). - **Docs** — `services/hephaestus.md` (Path-A seeding runbook + spoke caveat), `indri.md`, changelog fragment. ## Three features requested - **Autoupdate** — 10-min interval (`--self-update-interval-secs 600`). - **PWA** — `--web-root` (confirmed shipped in v1.2.0). - **Spoke** — gilbert reconfig documented (post-merge step). ## Deploy plan (not done yet — awaiting review) 1. Seed from gilbert (Path A): `heph daemon stop` → copy `heph.db` → `DELETE FROM meta WHERE key='origin'`. 2. Sync Authentik `apps`/blueprint; verify blueprint status via API (not just logs). 3. `provision-indri --tags heph,caddy` from this branch. 4. Point gilbert at the hub + `heph auth login`. ## Known follow-ups (heph-side, tracked in the Hephaestus project) - `heph daemon` can't bake hub/spoke config or pass `--self-update-interval-secs` → worked around by the ansible plist. - Path-A seeding lacks a clean `hephd --owner-id`/seed command → manual `meta.origin` reset for now. - Self-update moves hephd ahead of the ansible-pinned PWA shell over time (drift; tolerated by the SW cache, revisit on next release). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/369 --- ansible/playbooks/indri.yml | 2 + ansible/roles/caddy/defaults/main.yml | 3 + ansible/roles/heph/defaults/main.yml | 49 +++++++ ansible/roles/heph/handlers/main.yml | 6 + ansible/roles/heph/tasks/main.yml | 82 +++++++++++ ansible/roles/heph/templates/heph.plist.j2 | 50 +++++++ .../authentik/configmap-blueprint.yaml | 79 +++++++++++ docs/changelog.d/heph-indri-hub.infra.md | 1 + docs/reference/infrastructure/indri.md | 1 + docs/reference/services/hephaestus.md | 130 ++++++++++++++++++ 10 files changed, 403 insertions(+) create mode 100644 ansible/roles/heph/defaults/main.yml create mode 100644 ansible/roles/heph/handlers/main.yml create mode 100644 ansible/roles/heph/tasks/main.yml create mode 100644 ansible/roles/heph/templates/heph.plist.j2 create mode 100644 docs/changelog.d/heph-indri-hub.infra.md create mode 100644 docs/reference/services/hephaestus.md diff --git a/ansible/playbooks/indri.yml b/ansible/playbooks/indri.yml index ddb57f8..1e33bb1 100644 --- a/ansible/playbooks/indri.yml +++ b/ansible/playbooks/indri.yml @@ -260,5 +260,7 @@ tags: cv - role: docs tags: docs + - role: heph + tags: heph - role: caddy tags: caddy diff --git a/ansible/roles/caddy/defaults/main.yml b/ansible/roles/caddy/defaults/main.yml index 363d09e..e6d7385 100644 --- a/ansible/roles/caddy/defaults/main.yml +++ b/ansible/roles/caddy/defaults/main.yml @@ -52,6 +52,9 @@ caddy_services: - name: devpi host: "pypi.{{ caddy_domain }}" backend: "http://localhost:3141" + - name: heph + host: "heph.{{ caddy_domain }}" + backend: "http://localhost:8787" # hephaestus hub (server mode) + PWA shell - name: kiwix host: "kiwix.{{ caddy_domain }}" backend: "https://kiwix.tail8d86e.ts.net" diff --git a/ansible/roles/heph/defaults/main.yml b/ansible/roles/heph/defaults/main.yml new file mode 100644 index 0000000..e5eea36 --- /dev/null +++ b/ansible/roles/heph/defaults/main.yml @@ -0,0 +1,49 @@ +--- +# 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.0 + +# 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 diff --git a/ansible/roles/heph/handlers/main.yml b/ansible/roles/heph/handlers/main.yml new file mode 100644 index 0000000..92fe9d7 --- /dev/null +++ b/ansible/roles/heph/handlers/main.yml @@ -0,0 +1,6 @@ +--- +- 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 diff --git a/ansible/roles/heph/tasks/main.yml b/ansible/roles/heph/tasks/main.yml new file mode 100644 index 0000000..7a45fe3 --- /dev/null +++ b/ansible/roles/heph/tasks/main.yml @@ -0,0 +1,82 @@ +--- +# 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 diff --git a/ansible/roles/heph/templates/heph.plist.j2 b/ansible/roles/heph/templates/heph.plist.j2 new file mode 100644 index 0000000..19a2367 --- /dev/null +++ b/ansible/roles/heph/templates/heph.plist.j2 @@ -0,0 +1,50 @@ + + + + + + Label + mcquack.eblume.heph + ProgramArguments + + {{ heph_binary }} + --mode + server + --http-addr + {{ heph_http_addr }} + --db + {{ heph_db }} + --socket + {{ heph_socket }} + --web-root + {{ heph_web_root }} + --oidc-issuer + {{ heph_oidc_issuer }} + --oidc-audience + {{ heph_oidc_audience }} + --self-update + --self-update-interval-secs + {{ heph_self_update_interval_secs }} + + RunAtLoad + + KeepAlive + + EnvironmentVariables + + + PATH + {{ heph_bin_dir }}:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin + HOME + /Users/erichblume + + RUSTUP_TOOLCHAIN + {{ heph_rust_toolchain }} + + StandardOutPath + {{ heph_log_dir }}/mcquack.heph.out.log + StandardErrorPath + {{ heph_log_dir }}/mcquack.heph.err.log + + diff --git a/argocd/manifests/authentik/configmap-blueprint.yaml b/argocd/manifests/authentik/configmap-blueprint.yaml index fcbb99b..56d9110 100644 --- a/argocd/manifests/authentik/configmap-blueprint.yaml +++ b/argocd/manifests/authentik/configmap-blueprint.yaml @@ -434,3 +434,82 @@ data: provider: !KeyOf mealie-provider meta_launch_url: https://meals.ops.eblu.me 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 + # Device-code (RFC 8628) + PKCE use no redirect, but the provider + # serializer requires the field — an empty list satisfies it. + redirect_uris: [] + 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]] + 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 diff --git a/docs/changelog.d/heph-indri-hub.infra.md b/docs/changelog.d/heph-indri-hub.infra.md new file mode 100644 index 0000000..6761cb7 --- /dev/null +++ b/docs/changelog.d/heph-indri-hub.infra.md @@ -0,0 +1 @@ +Added the [[hephaestus]] (`heph`) sync hub to indri as a self-updating LaunchAgent managed by Ansible (`ansible/roles/heph`, tag `heph`). The hub runs `hephd --mode server` behind `heph.ops.eblu.me` (Caddy TLS), with self-update on a 10-minute interval and the heph-pwa mobile shell served from `--web-root`. Access is gated by a new Authentik device-code (RFC 8628) OIDC application. Indri is now the canonical hub; other devices (e.g. gilbert) attach as offline-capable spokes. The hub's store was seeded from gilbert via the data-safe Path A bring-up (copy store, reset `meta.origin`). diff --git a/docs/reference/infrastructure/indri.md b/docs/reference/infrastructure/indri.md index 67652ca..8364ba0 100644 --- a/docs/reference/infrastructure/indri.md +++ b/docs/reference/infrastructure/indri.md @@ -33,6 +33,7 @@ Primary BlumeOps server. Mac Mini M1 (2020). - [[alloy|Alloy]] - Metrics/logs collector - [[caddy]] - Reverse proxy for `*.ops.eblu.me` - [[devpi]] - PyPI mirror (LaunchAgent) +- [[hephaestus]] - heph task/context sync hub (LaunchAgent, self-updating) - [[cv]] - Static CV site, served by Caddy - [[docs]] - Quartz-built docs site, served by Caddy diff --git a/docs/reference/services/hephaestus.md b/docs/reference/services/hephaestus.md new file mode 100644 index 0000000..1754ea0 --- /dev/null +++ b/docs/reference/services/hephaestus.md @@ -0,0 +1,130 @@ +--- +title: Hephaestus +modified: 2026-06-04 +last-reviewed: 2026-06-04 +tags: + - service + - hephaestus +--- + +# Hephaestus + +[hephaestus](https://github.com/eblume/hephaestus) (`heph`) is the user's +self-hosted task + context/knowledge system. It is **hub-and-spoke**: each device +runs a full local SQLite replica (`hephd --mode local`) and background-syncs +against one canonical **hub**. Indri runs that hub. + +## Quick Reference + +| Property | Value | +|----------|-------| +| **PWA URL** | https://heph.ops.eblu.me (browser PWA, Caddy TLS) | +| **Spoke sync URL** | http://indri.tail8d86e.ts.net:8787 (direct, tailnet) | +| **Local Port** | 8787 (`hephd --mode server`, bound `0.0.0.0`) | +| **Binary** | `~/.cargo/bin/hephd` (self-updating) | +| **Data** | `~/.local/share/heph/heph.db` | +| **PWA shell** | `~/.local/share/heph/web` | +| **Logs** | `~/Library/Logs/mcquack.heph.{out,err}.log` | +| **LaunchAgent** | `mcquack.eblume.heph` | +| **Ansible role** | `ansible/roles/heph` (tag `heph`) | + +## What runs on indri + +The launchagent runs the hub in server mode with three features enabled: + +``` +hephd --mode server --http-addr 0.0.0.0:8787 --db ~/.local/share/heph/heph.db + --web-root ~/.local/share/heph/web + --oidc-issuer https://authentik.ops.eblu.me/application/o/heph/ + --oidc-audience heph + --self-update --self-update-interval-secs 600 +``` + +- **Server mode** exposes the HTTP sync endpoint (`/rpc`, `/sync/*`) that spokes + reconcile their op-log against. +- **Self-update** (10-minute poll) rebuilds `hephd` from the forge when a newer + release tag appears (`cargo install --git https://forge.eblu.me/eblume/hephaestus.git`). + Indri's Rust toolchain (`~/.cargo/bin`) is on the agent's `PATH` for this, and + the plist pins `RUSTUP_TOOLCHAIN=stable` — the + launchagent runs without mise, so a bare `cargo` shim would otherwise fall back + to rustup's *default* toolchain, which can lag behind heph's `rust-version` floor + (1.89) and silently fail the build. +- **PWA** (`--web-root`) serves the [heph-pwa] mobile shell; Caddy terminates TLS + at `heph.ops.eblu.me` so the PWA runs in a secure context (service worker, + install-to-home-screen, voice capture). + +[heph-pwa]: https://github.com/eblume/hephaestus + +The hub binds `0.0.0.0` so tailnet spokes can also sync directly +(`http://indri.tail8d86e.ts.net:8787`); access is gated by Authentik OIDC either +way — tailnet reachability alone is not enough. + +## Authentication (Authentik OIDC, device-code) + +The hub verifies an OIDC bearer token on every sync. The `heph` application is a +**public** OAuth2 client using the **device-code flow** (RFC 8628), provisioned +in the [[authentik]] blueprint (`argocd/manifests/authentik/configmap-blueprint.yaml`): + +- Issuer: `https://authentik.ops.eblu.me/application/o/heph/` +- Audience / client id: `heph` +- Restricted to the `admins` group (single-owner, sensitive data). + +Because no Authentik instance ships a device-code flow by default, the blueprint +also creates `default-device-code-flow` and binds it to the default brand's +`flow_device_code`. Devices obtain a token with `heph auth login`; the PWA +currently takes a pasted token (in-app device-code login is upstream follow-up). + +## Data seeding (Path A, one-time) + +The hub was seeded from the existing `gilbert` device so no task history was +lost. heph's data-safe bring-up ("Path A") has the hub **adopt the device's +identity** rather than rewriting the device: + +1. Quiesce the seed device: `heph daemon stop` (on gilbert). +2. Copy its store to indri: `scp ~/.local/share/heph/heph.db indri:~/.local/share/heph/heph.db`. +3. 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): + ```fish + ssh indri "sqlite3 ~/.local/share/heph/heph.db \"DELETE FROM meta WHERE key='origin';\"" + ``` +4. `mise run provision-indri -- --tags heph` (installs hephd, stages the PWA, + loads the launchagent → hub starts on the seeded store). + +Only `meta.origin` changes; `owner_id`, nodes, op-log, and links are copied +untouched. A clean `hephd --owner-id` / seed command is tracked upstream as +hephaestus follow-up — until then this manual reset is the documented path. + +## Connecting a spoke (e.g. gilbert) + +A device joins by running its local daemon with the hub URL + OIDC client and +logging in once: + +```bash +hephd --mode local --hub-url http://indri.tail8d86e.ts.net:8787 \ + --oidc-issuer https://authentik.ops.eblu.me/application/o/heph/ \ + --oidc-client-id heph +heph auth login --hub-url http://indri.tail8d86e.ts.net:8787 \ + --issuer https://authentik.ops.eblu.me/application/o/heph/ --client-id heph +``` + +> **Use the direct `http://…:8787` tailnet URL for sync, not the Caddy HTTPS +> URL.** hephd's sync client is plain-HTTP-only; pointing `--hub-url` at +> `https://heph.ops.eblu.me` fails with a confusing `error sending request` +> (the HTTP connector rejects the `https` scheme before connecting). Tailscale +> encrypts the transport, and the OIDC bearer token still gates every request. +> `heph.ops.eblu.me` (Caddy TLS) exists only for the browser PWA, which needs a +> secure context. The cached token is keyed by the exact `--hub-url`, so use the +> same value for `hephd` and `heph auth login`. + +> **Caveat:** `heph daemon` cannot yet bake hub/spoke flags into the generated +> launchd plist (upstream gap). On a spoke whose plist is managed by `heph +> daemon`, the hub/OIDC flags must be hand-added — and a later `heph daemon +> start/restart` will regenerate the plist and drop them. Avoid `heph daemon` +> subcommands on a configured spoke until that gap is closed; reload via +> `launchctl` instead. + +## Related + +- [[indri]] — host +- [[authentik]] — OIDC provider +- [[caddy]] — TLS termination for `heph.ops.eblu.me` From 6576880b0e8e80cd88452add47627c3b4e6d6435 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Fri, 5 Jun 2026 07:30:31 -0700 Subject: [PATCH 427/430] heph Authentik: register heph-pwa redirect URIs (PKCE login) (#370) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the heph-pwa redirect URIs to the Authentik `heph` OAuth2 provider so the new browser **Login with Authentik** flow (Authorization Code + PKCE, hephaestus PR #9) can redirect back and exchange the code: - `https://heph.ops.eblu.me/` (the PWA origin) - `http://localhost:8787/` (local dev: `hephd --web-root`) Authentik also keys token-endpoint CORS off these origins, so they're required for the browser token exchange. Additive (the provider was `redirect_uris: []`); harmless until the PWA feature deploys. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/370 --- argocd/manifests/authentik/configmap-blueprint.yaml | 13 ++++++++++--- docs/changelog.d/heph-pwa-redirect-uris.infra.md | 1 + 2 files changed, 11 insertions(+), 3 deletions(-) create mode 100644 docs/changelog.d/heph-pwa-redirect-uris.infra.md diff --git a/argocd/manifests/authentik/configmap-blueprint.yaml b/argocd/manifests/authentik/configmap-blueprint.yaml index 56d9110..9da2f70 100644 --- a/argocd/manifests/authentik/configmap-blueprint.yaml +++ b/argocd/manifests/authentik/configmap-blueprint.yaml @@ -477,9 +477,16 @@ data: invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]] client_type: public client_id: heph - # Device-code (RFC 8628) + PKCE use no redirect, but the provider - # serializer requires the field — an empty list satisfies it. - redirect_uris: [] + # 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]] diff --git a/docs/changelog.d/heph-pwa-redirect-uris.infra.md b/docs/changelog.d/heph-pwa-redirect-uris.infra.md new file mode 100644 index 0000000..f887eed --- /dev/null +++ b/docs/changelog.d/heph-pwa-redirect-uris.infra.md @@ -0,0 +1 @@ +Registered the heph-pwa redirect URIs (`https://heph.ops.eblu.me/`, plus `http://localhost:8787/` for dev) on the Authentik `heph` OAuth2 provider, enabling the PWA's new Authorization Code + PKCE "Login with Authentik" flow (and the token-endpoint CORS it needs). Pairs with hephaestus PR #9. From 3abe80523a0b402c40a0bd3d825e5d81b87939d8 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Fri, 5 Jun 2026 07:40:51 -0700 Subject: [PATCH 428/430] C0: bump indri heph hub to v1.2.1 (PWA Authentik login + /config) Co-Authored-By: Claude Opus 4.8 (1M context) --- ansible/roles/heph/defaults/main.yml | 2 +- docs/changelog.d/+heph-hub-v1.2.1.infra.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 docs/changelog.d/+heph-hub-v1.2.1.infra.md diff --git a/ansible/roles/heph/defaults/main.yml b/ansible/roles/heph/defaults/main.yml index e5eea36..88d2240 100644 --- a/ansible/roles/heph/defaults/main.yml +++ b/ansible/roles/heph/defaults/main.yml @@ -6,7 +6,7 @@ # 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.0 +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). diff --git a/docs/changelog.d/+heph-hub-v1.2.1.infra.md b/docs/changelog.d/+heph-hub-v1.2.1.infra.md new file mode 100644 index 0000000..c203323 --- /dev/null +++ b/docs/changelog.d/+heph-hub-v1.2.1.infra.md @@ -0,0 +1 @@ +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. From cf63fcb5b5cf379700efe3ce0986b18ec4d76625 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Fri, 5 Jun 2026 08:22:46 -0700 Subject: [PATCH 429/430] C0: track heph in service-versions (self-updating; note drift task) Co-Authored-By: Claude Opus 4.8 (1M context) --- service-versions.yaml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/service-versions.yaml b/service-versions.yaml index cc9dc9e..866c687 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -414,6 +414,23 @@ services: upstream-source: https://github.com/caddyserver/caddy/releases notes: Built from source with Gandi DNS and Layer 4 plugins + - name: heph + type: ansible + last-reviewed: 2026-06-05 + current-version: "v1.2.1" + upstream-source: https://forge.eblu.me/eblume/hephaestus/releases + notes: >- + hephaestus task/context sync hub on indri (server-mode launchagent, + ansible/roles/heph; cargo-built from the forge). SELF-UPDATING: hephd + polls the forge for newer releases every 10 min and rebuilds + restarts + itself, so the running version drifts AHEAD of the ansible heph_version + pin. current-version here is the last observed/deployed tag, not a hard + pin — verify the live version via `curl https://heph.ops.eblu.me/config` + is served (hub up) and the hub log's `current=` line. Reconciling this + self-update vs IaC-pin drift is tracked in the heph "Hephaestus" project: + "Reconcile hephd self-update with ansible-pinned version (drift on indri + hub)" (node 01KTBXWT6XTHNDH92CVJY88E5K). + - name: borgmatic type: ansible last-reviewed: 2026-04-15 From 50a36ff93a9d1c697c976a1db498bc5633f2cd7c Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sat, 6 Jun 2026 18:07:13 -0700 Subject: [PATCH 430/430] heph Authentik: grant offline_access scope (fixes spoke sync refresh-token 400) The heph CLI requests scope "openid offline_access", but the Authentik heph OAuth2 provider only mapped openid/email/profile. Without the offline_access mapping the issued refresh token is bound to the login session rather than the 30-day refresh-token window; once the session lapses, hephd's refresh_token grant returns 400 Bad Request and spoke sync silently degrades (heph sync --status -> auth_failure: true). Add the built-in offline_access scope mapping to the provider's property_mappings and document the requirement in the service reference. Co-Authored-By: Claude Opus 4.8 (1M context) --- argocd/manifests/authentik/configmap-blueprint.yaml | 4 ++++ docs/changelog.d/heph-offline-access.bugfix.md | 1 + docs/reference/services/hephaestus.md | 11 +++++++++++ 3 files changed, 16 insertions(+) create mode 100644 docs/changelog.d/heph-offline-access.bugfix.md diff --git a/argocd/manifests/authentik/configmap-blueprint.yaml b/argocd/manifests/authentik/configmap-blueprint.yaml index 9da2f70..cc97dea 100644 --- a/argocd/manifests/authentik/configmap-blueprint.yaml +++ b/argocd/manifests/authentik/configmap-blueprint.yaml @@ -492,6 +492,10 @@ data: - !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 diff --git a/docs/changelog.d/heph-offline-access.bugfix.md b/docs/changelog.d/heph-offline-access.bugfix.md new file mode 100644 index 0000000..e9721bc --- /dev/null +++ b/docs/changelog.d/heph-offline-access.bugfix.md @@ -0,0 +1 @@ +Granted the `offline_access` scope on the Authentik `heph` OAuth2 provider so hephaestus spokes receive a durable 30-day refresh token. Previously the refresh token was session-bound, so spoke sync would silently fail with a `400 Bad Request` on the `refresh_token` grant once the Authentik session lapsed. diff --git a/docs/reference/services/hephaestus.md b/docs/reference/services/hephaestus.md index 1754ea0..7abc35b 100644 --- a/docs/reference/services/hephaestus.md +++ b/docs/reference/services/hephaestus.md @@ -68,6 +68,17 @@ in the [[authentik]] blueprint (`argocd/manifests/authentik/configmap-blueprint. - Issuer: `https://authentik.ops.eblu.me/application/o/heph/` - Audience / client id: `heph` - Restricted to the `admins` group (single-owner, sensitive data). +- Scope mappings: `openid`, `email`, `profile`, **`offline_access`**. + +> **`offline_access` is required for durable sync.** The `heph` CLI requests +> `scope = "openid offline_access"`, and a refresh token is only issued for the +> 30-day refresh-token window when the provider actually grants `offline_access`. +> Without that scope mapping the refresh token is bound to the login **session**; +> once the session lapses, hephd's `refresh_token` grant returns `400 Bad +> Request`, the bearer can't be refreshed, and spoke sync silently degrades +> (`heph sync --status` → `auth_failure: true`). `heph auth login` papers over it +> until the next session expiry. Keep `offline_access` in the provider's +> `property_mappings`. Because no Authentik instance ships a device-code flow by default, the blueprint also creates `default-device-code-flow` and binds it to the default brand's