diff --git a/.claude/agents/change-classifier.md b/.claude/agents/change-classifier.md deleted file mode 100644 index ab877e9..0000000 --- a/.claude/agents/change-classifier.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -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/infra-health.md b/.claude/agents/infra-health.md deleted file mode 100644 index 94bf14f..0000000 --- a/.claude/agents/infra-health.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -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 deleted file mode 100644 index 1bd0176..0000000 --- a/.claude/agents/mikado-navigator.md +++ /dev/null @@ -1,69 +0,0 @@ ---- -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/.forgejo/actions/build-push-image/action.yaml b/.forgejo/actions/build-push-image/action.yaml new file mode 100644 index 0000000..b278e02 --- /dev/null +++ b/.forgejo/actions/build-push-image/action.yaml @@ -0,0 +1,54 @@ +name: 'Build and Push Image' +description: 'Build a container image with Buildah and push to zot registry' + +# TODO: Investigate zot tag immutability to prevent overwriting released versions +# See: https://zotregistry.dev/v2.1.1/articles/immutable-tags/ + +inputs: + context: + description: 'Build context path' + required: true + dockerfile: + description: 'Dockerfile path (relative to context)' + required: false + default: 'Dockerfile' + image_name: + description: 'Image name (without registry, e.g. blumeops/devpi)' + required: true + version: + description: 'Version tag (e.g. v1.0.0)' + required: true + registry: + description: 'Registry URL' + required: false + default: 'registry.tail8d86e.ts.net' + +runs: + using: 'composite' + steps: + - name: Build image with Buildah + shell: bash + run: | + echo "Building ${{ inputs.image_name }}:${{ inputs.version }}" + buildah bud \ + --tag ${{ inputs.registry }}/${{ inputs.image_name }}:${{ inputs.version }} \ + --tag ${{ inputs.registry }}/${{ inputs.image_name }}:${{ github.sha }} \ + --file ${{ inputs.context }}/${{ inputs.dockerfile }} \ + ${{ inputs.context }} + + - name: Push to registry + shell: bash + run: | + echo "Pushing ${{ inputs.image_name }}:${{ inputs.version }}" + buildah push ${{ inputs.registry }}/${{ inputs.image_name }}:${{ inputs.version }} + buildah push ${{ inputs.registry }}/${{ inputs.image_name }}:${{ github.sha }} + + - name: Summary + shell: bash + run: | + echo "✅ Built and pushed:" + echo " ${{ inputs.registry }}/${{ inputs.image_name }}:${{ inputs.version }}" + echo " ${{ inputs.registry }}/${{ inputs.image_name }}:${{ github.sha }}" + echo "" + echo "Registry tags:" + curl -sf "https://${{ inputs.registry }}/v2/${{ inputs.image_name }}/tags/list" | jq -r '.tags[]' | sort -V | tail -10 diff --git a/.forgejo/workflows/branch-cleanup.yaml b/.forgejo/workflows/branch-cleanup.yaml deleted file mode 100644 index 29ed67c..0000000 --- a/.forgejo/workflows/branch-cleanup.yaml +++ /dev/null @@ -1,40 +0,0 @@ -# Automated Branch Cleanup -# -# Deletes remote branches that have been merged into main and are older -# than a cutoff (default 30 days). Detects both fast-forward and -# squash-merged branches via the Forgejo API. -# -# Runs on a schedule (~every 10 days) and can be triggered manually -# with a custom cutoff for testing. - -name: Branch Cleanup - -on: - schedule: - # Approximately every 10 days: 1st, 11th, 21st of each month at 06:00 UTC - - cron: '0 6 1,11,21 * *' - workflow_dispatch: - inputs: - cutoff: - description: 'Delete branches older than N days' - required: false - default: '30' - type: string - -jobs: - cleanup: - runs-on: k8s - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Run branch cleanup - env: - FORGEJO_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - CUTOFF="${{ inputs.cutoff || '30' }}" - echo "Running branch cleanup with cutoff=${CUTOFF} days..." - uv run --script mise-tasks/branch-cleanup \ - --remote-only \ - --yes \ - --cutoff "$CUTOFF" diff --git a/.forgejo/workflows/build-blumeops.yaml b/.forgejo/workflows/build-blumeops.yaml deleted file mode 100644 index c6e6c3c..0000000 --- a/.forgejo/workflows/build-blumeops.yaml +++ /dev/null @@ -1,291 +0,0 @@ -# BlumeOps Release Workflow -# -# Creates a versioned release of BlumeOps with all build artifacts. -# Currently includes: -# - Documentation site (Quartz static build) -# - Changelog (built from towncrier fragments) -# -# Usage: -# 1. Go to Actions > Build BlumeOps > Run workflow -# 2. Select version bump type (patch/minor/major) or choose specific version -# 3. The workflow creates a release with attached artifacts -# -# Documentation asset URL: -# https://forge.eblu.me/eblume/blumeops/releases/download/<tag>/docs-<version>.tar.gz - -name: Build BlumeOps - -on: - workflow_dispatch: - inputs: - version_type: - description: 'Version bump type' - required: true - default: 'BUMP_PATCH' - type: choice - options: - - BUMP_PATCH - - BUMP_MINOR - - BUMP_MAJOR - - SPECIFIC_VERSION - specific_version: - description: 'Specific version (only used when version_type is SPECIFIC_VERSION, e.g., v1.2.0)' - required: false - default: '' - type: string - -jobs: - build: - runs-on: k8s - steps: - - name: Resolve version - id: version - run: | - VERSION_TYPE="${{ inputs.version_type }}" - SPECIFIC_VERSION="${{ inputs.specific_version }}" - - # Fetch latest release - echo "Fetching latest release..." - LATEST=$(curl -s "https://forge.eblu.me/api/v1/repos/eblume/blumeops/releases/latest" | jq -r '.tag_name // empty' || true) - - if [ -z "$LATEST" ]; then - LATEST="v0.0.0" - echo "No previous releases found, using base version: $LATEST" - else - echo "Latest release: $LATEST" - fi - - # Parse current version components (strip 'v' prefix) - CURRENT="${LATEST#v}" - MAJOR=$(echo "$CURRENT" | cut -d. -f1) - MINOR=$(echo "$CURRENT" | cut -d. -f2) - PATCH=$(echo "$CURRENT" | cut -d. -f3) - - case "$VERSION_TYPE" in - BUMP_MAJOR) - VERSION="v$((MAJOR + 1)).0.0" - echo "Bumping major: $LATEST -> $VERSION" - ;; - BUMP_MINOR) - VERSION="v${MAJOR}.$((MINOR + 1)).0" - echo "Bumping minor: $LATEST -> $VERSION" - ;; - BUMP_PATCH) - VERSION="v${MAJOR}.${MINOR}.$((PATCH + 1))" - echo "Bumping patch: $LATEST -> $VERSION" - ;; - SPECIFIC_VERSION) - if [ -z "$SPECIFIC_VERSION" ]; then - echo "Error: specific_version is required when version_type is SPECIFIC_VERSION" - exit 1 - fi - # Validate format - if [[ ! "$SPECIFIC_VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "Error: Version must be in format vX.Y.Z (e.g., v1.0.0)" - exit 1 - fi - VERSION="$SPECIFIC_VERSION" - echo "Using specific version: $VERSION" - ;; - *) - echo "Error: Unknown version_type: $VERSION_TYPE" - exit 1 - ;; - esac - - # Check if this version already exists - if curl -sf "https://forge.eblu.me/api/v1/repos/eblume/blumeops/releases/tags/$VERSION" > /dev/null 2>&1; then - echo "Error: Release $VERSION already exists" - echo "See: https://forge.eblu.me/eblume/blumeops/releases/tag/$VERSION" - exit 1 - fi - - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - echo "Building BlumeOps release: $VERSION" - - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 0 - - - name: Build changelog - id: changelog - run: | - VERSION="${{ steps.version.outputs.version }}" - - # Run towncrier on the runner so that CHANGELOG.md updates and - # fragment deletions appear in the working tree for both the Quartz - # build (next step) and the git commit step. - # Check if there are any changelog fragments - FRAGMENTS=$(find docs/changelog.d -name "*.md" -not -name ".gitkeep" 2>/dev/null | wc -l) - - if [ "$FRAGMENTS" -gt 0 ]; then - echo "Found $FRAGMENTS changelog fragments, building changelog..." - uvx towncrier build --version "$VERSION" --yes - echo "changelog_updated=true" >> "$GITHUB_OUTPUT" - - # Extract the changelog section for this release to include in release body - RELEASE_NOTES=$(awk -v ver="$VERSION" ' - /^## \[/ { - if (found) exit - if (index($0, "[" ver "]")) found=1 - } - found {print} - ' CHANGELOG.md | tail -n +2) - - echo "$RELEASE_NOTES" > /tmp/release_notes.md - echo "Release notes extracted for $VERSION" - else - echo "No changelog fragments found, skipping towncrier" - echo "changelog_updated=false" >> "$GITHUB_OUTPUT" - echo "" > /tmp/release_notes.md - fi - - - name: Build docs - run: | - VERSION="${{ steps.version.outputs.version }}" - TARBALL="docs-${VERSION}.tar.gz" - echo "Building docs via Dagger..." - # Towncrier already ran on the runner above, so the working tree - # has an up-to-date CHANGELOG.md. build-docs now only runs the - # Quartz static site build (no towncrier). - dagger call build-docs --src=. --version="$VERSION" \ - export --path="./$TARBALL" - echo "Build complete!" - ls -lh "$TARBALL" - - - name: Create release - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - VERSION="${{ steps.version.outputs.version }}" - TARBALL="docs-${VERSION}.tar.gz" - CHANGELOG_UPDATED="${{ steps.changelog.outputs.changelog_updated }}" - - echo "Creating release $VERSION..." - - # Build release body with changelog if available - { - echo "BlumeOps release $VERSION" - echo "" - - if [ "$CHANGELOG_UPDATED" = "true" ] && [ -s /tmp/release_notes.md ]; then - echo "## What's Changed" - echo "" - cat /tmp/release_notes.md - echo "" - fi - - echo "## Documentation" - echo "" - echo "Download \`$TARBALL\` directly, or bump \`docs_version\`" - echo "in \`ansible/roles/docs/defaults/main.yml\` and run:" - echo "" - echo "\`\`\`" - echo "mise run provision-indri -- --tags docs" - echo "\`\`\`" - } > /tmp/release_body.txt - - # Use jq to properly escape the body for JSON - RELEASE_DATA=$(jq -n \ - --arg tag "$VERSION" \ - --arg name "BlumeOps $VERSION" \ - --rawfile body /tmp/release_body.txt \ - '{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}') - - RELEASE_RESPONSE=$(curl -s \ - -X POST \ - -H "Content-Type: application/json" \ - -H "Authorization: token $GITHUB_TOKEN" \ - -d "$RELEASE_DATA" \ - "https://forge.eblu.me/api/v1/repos/eblume/blumeops/releases") - - echo "API Response: $RELEASE_RESPONSE" - - RELEASE_ID=$(echo "$RELEASE_RESPONSE" | jq -r '.id') - - if [ -z "$RELEASE_ID" ] || [ "$RELEASE_ID" = "null" ]; then - echo "Error: Failed to create release" - exit 1 - fi - - echo "Created release ID: $RELEASE_ID" - - # Upload the asset - echo "Uploading $TARBALL..." - UPLOAD_RESPONSE=$(curl -s \ - -X POST \ - -H "Content-Type: application/gzip" \ - -H "Authorization: token $GITHUB_TOKEN" \ - --data-binary "@$TARBALL" \ - "https://forge.eblu.me/api/v1/repos/eblume/blumeops/releases/$RELEASE_ID/assets?name=$TARBALL") - - echo "Upload Response: $UPLOAD_RESPONSE" - echo "" - echo "Release created successfully!" - - - name: Bump docs_version in ansible role - run: | - VERSION="${{ steps.version.outputs.version }}" - DEFAULTS_FILE="ansible/roles/docs/defaults/main.yml" - - echo "Bumping docs_version in $DEFAULTS_FILE to ${VERSION}..." - yq -i ".docs_version = \"${VERSION}\"" "$DEFAULTS_FILE" - - echo "Updated defaults:" - grep -E "^docs_version:" "$DEFAULTS_FILE" - - - name: Commit release changes - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - VERSION="${{ steps.version.outputs.version }}" - CHANGELOG_UPDATED="${{ steps.changelog.outputs.changelog_updated }}" - - # Configure git - git config user.name "Forgejo Actions" - git config user.email "actions@forge.ops.eblu.me" - - # Stage deployment changes - git add ansible/roles/docs/defaults/main.yml - - # Stage changelog changes if updated - if [ "$CHANGELOG_UPDATED" = "true" ]; then - git add CHANGELOG.md docs/changelog.d/ - fi - - # Check if there are changes to commit - if git diff --cached --quiet; then - echo "No changes to commit" - else - git commit -m "Update docs release to $VERSION - - $([ "$CHANGELOG_UPDATED" = "true" ] && echo "- Built changelog from towncrier fragments") - - [skip ci]" - - # Push to main - git push origin HEAD:main - echo "Changes committed and pushed" - fi - - - name: Summary - run: | - VERSION="${{ steps.version.outputs.version }}" - TARBALL="docs-${VERSION}.tar.gz" - echo "================================================" - echo "BlumeOps Release: $VERSION" - echo "================================================" - echo "" - echo "Release URL:" - echo " https://forge.eblu.me/eblume/blumeops/releases/tag/$VERSION" - echo "" - 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/build-container.yaml b/.forgejo/workflows/build-container.yaml deleted file mode 100644 index 1fcfd7f..0000000 --- a/.forgejo/workflows/build-container.yaml +++ /dev/null @@ -1,202 +0,0 @@ -# Unified container build workflow -# Manual dispatch only — use `mise run container-build-and-release <name>`. -# 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: - 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: k8s - outputs: - dagger: ${{ steps.classify.outputs.dagger }} - nix: ${{ steps.classify.outputs.nix }} - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: ${{ inputs.ref || github.sha }} - fetch-depth: 2 - - - name: Classify container build type - id: classify - run: | - CHANGED='["${{ inputs.container }}"]' - echo "Building container: $CHANGED" - - # Classify each container by build type (a container can appear in both) - DAGGER='[]' - NIX='[]' - for name in $(echo "$CHANGED" | jq -r '.[]'); do - has_any=false - 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 - NIX=$(echo "$NIX" | jq -c --arg n "$name" '. + [$n]') - has_any=true - fi - if [ "$has_any" = "false" ]; then - echo "Warning: $name has neither container.py, Dockerfile, nor default.nix — skipping" - fi - done - - echo "dagger=$DAGGER" >> "$GITHUB_OUTPUT" - echo "nix=$NIX" >> "$GITHUB_OUTPUT" - echo "Dagger builds: $DAGGER" - echo "Nix builds: $NIX" - - build-dagger: - 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) }} - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: ${{ inputs.ref || github.sha }} - - - name: Extract version and SHA - id: meta - run: | - 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: Could not extract 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: Publish - env: - ZOT_CI_API_KEY: ${{ secrets.ZOT_CI_API_KEY }} - run: | - dagger call publish \ - --src=. \ - --container-name=${{ matrix.container }} \ - --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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - 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/.forgejo/workflows/build-devpi.yaml b/.forgejo/workflows/build-devpi.yaml new file mode 100644 index 0000000..89318b6 --- /dev/null +++ b/.forgejo/workflows/build-devpi.yaml @@ -0,0 +1,37 @@ +name: Build devpi + +on: + push: + tags: + - 'devpi-v*' + workflow_dispatch: + inputs: + version: + description: 'Version (e.g. v1.0.0)' + required: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Extract version from tag + id: version + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + VERSION="${{ github.event.inputs.version }}" + else + # Extract version from tag: devpi-v1.0.0 -> v1.0.0 + VERSION="${GITHUB_REF_NAME#devpi-}" + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Building version: $VERSION" + + - name: Build and push + uses: ./.forgejo/actions/build-push-image + with: + context: argocd/manifests/devpi + image_name: blumeops/devpi + version: ${{ steps.version.outputs.version }} diff --git a/.forgejo/workflows/build-runner.yaml b/.forgejo/workflows/build-runner.yaml new file mode 100644 index 0000000..54162b6 --- /dev/null +++ b/.forgejo/workflows/build-runner.yaml @@ -0,0 +1,37 @@ +name: Build forgejo-runner + +on: + push: + tags: + - 'runner-v*' + workflow_dispatch: + inputs: + version: + description: 'Version (e.g. v1.0.0)' + required: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Extract version from tag + id: version + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + VERSION="${{ github.event.inputs.version }}" + else + # Extract version from tag: runner-v1.0.0 -> v1.0.0 + VERSION="${GITHUB_REF_NAME#runner-}" + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Building version: $VERSION" + + - name: Build and push + uses: ./.forgejo/actions/build-push-image + with: + context: argocd/manifests/forgejo-runner + image_name: blumeops/forgejo-runner + version: ${{ steps.version.outputs.version }} diff --git a/.forgejo/workflows/cv-deploy.yaml b/.forgejo/workflows/cv-deploy.yaml deleted file mode 100644 index 001aa36..0000000 --- a/.forgejo/workflows/cv-deploy.yaml +++ /dev/null @@ -1,109 +0,0 @@ -# CV Deploy Workflow -# -# 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 - -on: - workflow_dispatch: - inputs: - version: - description: 'CV package version to deploy (e.g., v1.0.0, or "latest")' - required: true - default: 'latest' - type: string - -jobs: - deploy: - runs-on: k8s - steps: - - name: Resolve version - id: version - run: | - INPUT_VERSION="${{ inputs.version }}" - - if [ "$INPUT_VERSION" = "latest" ]; then - echo "Resolving latest CV package version..." - VERSION=$(curl -s "https://forge.eblu.me/api/v1/packages/eblume?type=generic&q=cv" \ - | jq -r '[.[] | select(.name == "cv")] | sort_by(.version) | last | .version // empty') - - if [ -z "$VERSION" ]; then - echo "Error: No CV packages found" - exit 1 - fi - echo "Resolved latest version: $VERSION" - else - VERSION="$INPUT_VERSION" - if [[ ! "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "Error: Version must be in format vX.Y.Z (e.g., v1.0.0)" - exit 1 - fi - fi - - # Verify the package exists - TARBALL="cv-${VERSION}.tar.gz" - PACKAGE_URL="https://forge.eblu.me/api/packages/eblume/generic/cv/${VERSION}/${TARBALL}" - if ! curl -fsSL --head "$PACKAGE_URL" > /dev/null 2>&1; then - echo "Error: Package not found at $PACKAGE_URL" - echo "Run the 'Release CV' workflow in the cv repo first." - exit 1 - fi - echo "Package verified: $PACKAGE_URL" - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Bump cv_version in ansible role - run: | - VERSION="${{ steps.version.outputs.version }}" - DEFAULTS_FILE="ansible/roles/cv/defaults/main.yml" - - echo "Bumping cv_version in $DEFAULTS_FILE to ${VERSION}..." - yq -i ".cv_version = \"${VERSION}\"" "$DEFAULTS_FILE" - - echo "Updated defaults:" - grep -E "^cv_version:" "$DEFAULTS_FILE" - - - name: Commit release changes - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - VERSION="${{ steps.version.outputs.version }}" - - git config user.name "Forgejo Actions" - git config user.email "actions@forge.ops.eblu.me" - - git add ansible/roles/cv/defaults/main.yml - - if git diff --cached --quiet; then - echo "No changes to commit (already at $VERSION)" - else - git commit -m "Update CV release to $VERSION - - [skip ci]" - git push origin HEAD:main - echo "Changes committed and pushed" - fi - - - name: Summary - run: | - VERSION="${{ steps.version.outputs.version }}" - echo "================================================" - echo "CV version bumped: $VERSION" - echo "================================================" - echo "" - 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/.forgejo/workflows/deploy-fly.yaml b/.forgejo/workflows/deploy-fly.yaml deleted file mode 100644 index a2b389b..0000000 --- a/.forgejo/workflows/deploy-fly.yaml +++ /dev/null @@ -1,37 +0,0 @@ -name: Deploy Fly.io Proxy - -on: - workflow_dispatch: - push: - branches: [main] - paths: - - 'fly/**' - -jobs: - deploy: - runs-on: k8s - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Install flyctl - run: | - curl -L https://fly.io/install.sh | sh - echo "/root/.fly/bin" >> "$GITHUB_PATH" - - - name: Deploy to Fly.io - env: - FLY_API_TOKEN: ${{ secrets.FLY_DEPLOY_TOKEN }} - run: | - cd fly - fly deploy - - - name: Verify health - env: - FLY_API_TOKEN: ${{ secrets.FLY_DEPLOY_TOKEN }} - run: | - fly status -a blumeops-proxy - echo "" - echo "Health check:" - sleep 10 - curl -sf https://blumeops-proxy.fly.dev/healthz || echo "Warning: health check failed (may need DNS propagation)" diff --git a/.forgejo/workflows/test.yaml b/.forgejo/workflows/test.yaml new file mode 100644 index 0000000..1db41ee --- /dev/null +++ b/.forgejo/workflows/test.yaml @@ -0,0 +1,45 @@ +name: Test CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Verify tools + run: | + echo "=== Node.js ===" + node --version + npm --version + echo "" + echo "=== Git ===" + git --version + echo "" + echo "=== Build tools ===" + make --version 2>&1 | head -1 || true + gcc --version 2>&1 | head -1 || true + echo "" + echo "=== Container tools (Buildah) ===" + buildah --version + podman --version + echo "" + echo "=== Other tools ===" + curl --version 2>&1 | head -1 || true + jq --version + + - name: Show repo info + run: | + echo "Repository: ${{ github.repository }}" + echo "Event: ${{ github.event_name }}" + echo "Ref: ${{ github.ref }}" + echo "Branch: ${{ github.ref_name }}" + echo "" + echo "=== Files ===" + ls -la diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index 8274184..0000000 --- a/.gitattributes +++ /dev/null @@ -1 +0,0 @@ -/sdk/** linguist-generated diff --git a/.github/USE_FORGE_WORKFLOWS.md b/.github/USE_FORGE_WORKFLOWS.md deleted file mode 100644 index 026adf6..0000000 --- a/.github/USE_FORGE_WORKFLOWS.md +++ /dev/null @@ -1,9 +0,0 @@ -# .github directory - -This directory contains configuration for GitHub-ecosystem tooling only. - -**Workflows and actions belong in `.forgejo/`** - this repository uses Forgejo Actions, not GitHub Actions. - -## Contents - -- `actionlint.yaml` - Configuration for actionlint prek hook (custom runner labels) diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml deleted file mode 100644 index ffc2bdf..0000000 --- a/.github/actionlint.yaml +++ /dev/null @@ -1,4 +0,0 @@ -self-hosted-runner: - labels: - - k8s - - nix-container-builder diff --git a/.gitignore b/.gitignore index 09e937c..f6fe9e7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,4 @@ .claude/settings.local.json -.claude/agent-memory/ -.claude/scheduled_tasks.lock # Python __pycache__/ @@ -8,10 +6,5 @@ __pycache__/ *.pyo .venv/ -# Dagger (auto-generated SDK) -/sdk/ - # OS .DS_Store -/**/__pycache__ -/.env diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..421de65 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,89 @@ +--- +# See https://pre-commit.com for more information +# Run: uvx pre-commit run --all-files +# Install: uvx pre-commit install + +repos: + # General file hygiene + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-added-large-files + args: ['--maxkb=1000'] + - id: check-merge-conflict + - id: check-json + - id: check-yaml + args: ['--unsafe'] # Allow custom tags (ansible uses them) + - id: check-toml + + # Secret detection + - repo: https://github.com/trufflesecurity/trufflehog + rev: v3.92.5 + hooks: + - id: trufflehog + entry: trufflehog git file://. --no-verification --fail + stages: [pre-commit, pre-push] + + # YAML linting + - repo: https://github.com/adrienverge/yamllint + rev: v1.38.0 + hooks: + - id: yamllint + args: ['-c', '.yamllint.yaml'] + + # Ansible linting + - repo: local + hooks: + - id: ansible-lint + 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 + + # Python - ruff for linting and formatting + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.14.13 + hooks: + - id: ruff + args: ['--fix'] + - id: ruff-format + + # Shell scripts - shellcheck and shfmt + - repo: https://github.com/shellcheck-py/shellcheck-py + rev: v0.10.0.1 + hooks: + - id: shellcheck + args: ['--severity=warning'] + + - repo: https://github.com/scop/pre-commit-shfmt + rev: v3.12.0-2 + hooks: + - id: shfmt + args: ['-i', '2', '-ci', '-bn'] # 2-space indent, case indent, binary newline + + # TOML - taplo + - repo: https://github.com/ComPWA/taplo-pre-commit + rev: v0.9.3 + hooks: + - id: taplo-format + - id: taplo-lint + + # JSON formatting (prettier for consistent style) + - repo: https://github.com/rbubley/mirrors-prettier + rev: v3.8.0 + hooks: + - id: prettier + types_or: [json] + args: ['--tab-width', '2'] + + # GitHub/Forgejo Actions workflow linting + - repo: https://github.com/rhysd/actionlint + rev: v1.7.10 + hooks: + - id: actionlint-system + files: ^\.forgejo/workflows/ diff --git a/.yamllint.yaml b/.yamllint.yaml index 1a90123..15b4de5 100644 --- a/.yamllint.yaml +++ b/.yamllint.yaml @@ -21,11 +21,11 @@ rules: # Required for ansible-lint compatibility comments-indentation: false octal-values: - forbid-implicit-octal: false + forbid-implicit-octal: true forbid-explicit-octal: true ignore: - .venv/ - pulumi/.venv/ # Third-party k8s manifest with non-standard formatting - - argocd/manifests/tailscale-operator-base/operator.yaml + - argocd/manifests/tailscale-operator/operator.yaml diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index c64af40..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,171 +0,0 @@ -# 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 <pr_number>`** before proceeding -6. **Add changelog fragments (all change levels)** - `docs/changelog.d/<name>.<type>.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: `<branch>.<type>.md` - - **C0:** Use orphan prefix: `+<descriptive-slug>.<type>.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/<chain-stem>` governed by the Mikado Branch Invariant: all card commits first, then code progress, then card closures. Commits use `C2(<chain>): 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 zettelkasten (Obsidian-sync). Reference-data source; migrating into heph docs (hephaestus). -~/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/<service>/` -2. Push. Sync 'apps' app if service definition changed (set --revision to branch). -3. Test on branch: `argocd app set <service> --revision <branch> && argocd app sync <service>` -4. After merge: `argocd app set <service> --revision main && argocd app sync <service>` - -**Commands:** `argocd app list|get|diff|sync <app>` - -**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) - -Native services: Forgejo, Zot, Caddy, Borgmatic, Alloy - -```fish -mise run provision-indri # full -mise run provision-indri -- --tags <role> # 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 <name> <version> # 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 <run#> # see jobs in the run -mise run runner-logs <run#> -j <N> # 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/<project>/`. - -### 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/<project>/` 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 <mirror-name>` - -## 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 -heph list --project Blumeops --json # outstanding Blumeops tasks as JSON -``` - -(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. - -## 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/Brewfile b/Brewfile index bf5c28c..64592c8 100644 --- a/Brewfile +++ b/Brewfile @@ -1,9 +1,6 @@ # CLI tools for blumeops management brew "actionlint" # GitHub/Forgejo Actions workflow linter -brew "age" # File encryption for 1Password backup (op-backup) brew "argocd" # ArgoCD CLI for GitOps management brew "bat" # Syntax-highlighted file concatenation -brew "mise" # Task runner and toolchain manager -brew "tea" # Gitea/Forgejo CLI for forge.ops.eblu.me -brew "flyctl" # Fly.io CLI for public proxy management +brew "tea" # Gitea/Forgejo CLI for forge.tail8d86e.ts.net brew "podman" # Container CLI (uses VM on macOS, for building/pushing images) diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 0499154..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,1394 +0,0 @@ ---- -title: changelog -tags: - - meta ---- - -# Changelog - -All notable changes to BlumeOps are documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - -<!-- towncrier release notes start --> - -## [v1.17.0] - 2026-06-03 - -### Features - -- Deploy the Adelaide / Heidi / Addie baby shower app — guest splash, raffle - picker, and prize assignment console — on ringtail k3s with `shower.eblu.me` - as the public entry and `shower.ops.eblu.me` as the tailnet admin host. App - source: [`adelaide-baby-shower-app`](https://forge.eblu.me/eblume/adelaide-baby-shower-app). -- Deploy adelaide-baby-shower-app v1.1.0 to ringtail k3s. Replaces the - boolean lock with a four-phase `ShowerState` (`pre_event` → `party` → - `prizes_locked` → `event_locked`), adds an append-only "guest memories" - panel where guests can leave photos and comments for the baby, and - polishes the admin and QR views. Three Django migrations - (`0009_shower_phase`, `0010_guest_memories`, `0011_book_description`) - run automatically in the entrypoint against the SQLite PV. No config - or env-var changes. - - Container build also gains a Forgejo-PyPI workaround: Forgejo's simple - index returns absolute file URLs hardcoded to the public ROOT_URL - (`forge.eblu.me`), which the Fly edge 403s on `/api/packages/*`. The - wheel and sdist are now both pulled via direct `fetchurl` against - `forge.ops.eblu.me` (tailnet-only) and the wheel is handed to pip as - a local path. -- `review-compliance-reports` now also fetches and summarizes the weekly Prowler container-image and IaC scans (previously only the K8s CIS in-cluster scan was processed). For each scan it shows status counts, severity breakdown, week-over-week delta, and — for the high-volume image/IaC scans — top-N tables grouped by check ID and resource instead of per-finding listings. -- runner-logs now authenticates with Forgejo API token and auto-detects the repo from git remote. Job logs are fetched via SSH to indri (reading Forgejo's on-disk zstd log files) instead of the web endpoint, which doesn't support token auth for private repos. - -### Bug Fixes - -- Fix nightly borgmatic backups failing for 2 days. The shower SQLite - dump hook referenced `kubectl --context=k3s-ringtail`, but indri's - kubeconfig deliberately doesn't carry the ringtail credentials. The - `before_backup` hook's failure aborted the entire run, taking out - *both* the local sifaka repo and the BorgBase offsite. Replaced - the inline-shell dump with a `~/bin/borgmatic-k8s-sqlite-dump` - helper deployed by the ansible role. Each dump entry now declares a - `target` of either `local:<context>` (mealie — kubectl uses indri's - kubeconfig) or `ssh:<user@host>` (shower — ssh into ringtail and - run `k3s kubectl` there, no indri-side kubeconfig needed; k3s.yaml - on ringtail is mode 644 so no sudo required). Bytes stream back via - `kubectl exec ... -- cat` rather than `kubectl cp`, since `kubectl - cp` requires `tar` inside the pod and nix-built images like shower - don't bundle it. -- Shower app container now bakes the wheel + Python deps into the image - at build time via `buildPythonPackage` instead of pip-installing on - first boot. Boots are deterministic and don't depend on forge PyPI - being reachable from the pod. The `wheelHash` in - `containers/shower/default.nix` is the sha256 sourced from the - [forge PyPI simple index](https://forge.eblu.me/api/packages/eblume/pypi/simple/adelaide-baby-shower-app/); - bumping the version means bumping that hash too. - - Borgmatic now covers the shower app: SQLite is dumped from the live - pod via `kubectl exec` (mirroring the existing mealie entry, with - `context: k3s-ringtail`), and the prize-photo media share is picked up - through `/Volumes/shower` (sifaka SMB mount on indri, same pattern as - `/Volumes/photos`). -- Disabled adaptive sync (VRR) on ringtail's DP-1 output. The OMEN 27i IPS panel pumps brightness when its refresh rate swings into the low VRR range during low-framerate content (e.g. game cutscenes), producing a flicker that worsened over a session until a reboot. Pinning the panel to a fixed 165Hz eliminates it. -- Fixed forge.eblu.me static assets (CSS, JS, images, fonts) not loading — the proxy's static asset cache block was missing the `Host` header, so Caddy couldn't route the requests. -- Fixed homepage container EACCES on cold start: the nix-built image now chowns - `/app/config` to uid 1000 at build time via `fakeRootCommands`, matching the - behavior of the old Dockerfile. Without this, homepage couldn't seed missing - skeleton configs (proxmox.yaml etc.) or create `/app/config/logs`, crashing on - its first uncached request. Caught during the ringtail cutover. -- Fixed sway keybindings on ringtail — the home-manager `keybindings` block was replacing the module's defaults entirely, leaving only explicit overrides (no workspace switching, focus, move, splits, resize mode, etc). Switched to `lib.mkOptionDefault` with `lib.mkForce` on the conflicting custom binds (`Mod+Return`, `Mod+d`, `Mod+space`, `Mod+l`) so defaults merge back in. Also added `Mod+F1` to show a filterable fuzzel list of current keybindings. - - Fixed fuzzel config errors on launch — `border-radius` and `border-width` were under `[main]`, but fuzzel expects them as `radius`/`width` under a `[border]` section. -- Pin the Quartz docs build to v4.5.2. The Dagger `build_docs` pipeline cloned Quartz from the default branch unpinned; Quartz v5.0.0 restructured its config layout (`.quartz/plugins`, `../quartz` imports) and broke the docs build against our existing `quartz.config.ts`/`quartz.layout.ts`. - -### Infrastructure - -- Wire the ringtail `blumeops-pg` cluster (which holds the wave-1-migrated - paperless + teslamate databases) into backups and Grafana. Adds a Tailscale - LoadBalancer Service (`blumeops-pg-ringtail.tail8d86e.ts.net`) and a Caddy L4 - route (`pg.ops.eblu.me:5434`), then repoints borgmatic's `teslamate` + - `paperless` postgres dumps and the `mealie` SQLite dump at ringtail, and the - Grafana TeslaMate datasource at the ringtail DB. Closes the backup gap that - opened at cutover (the migrated live data was still being backed up from the - now-frozen minikube copies) and unblocks the wave-1 decommission. -- Migrated homepage dashboard from minikube (indri/arm64) to k3s (ringtail/amd64). - The container is now built via nix (`containers/homepage/default.nix`), adapted - from nixpkgs `homepage-dashboard` with the upstream Next.js cache patches and - wrapped with `dockerTools.buildLayeredImage`. Autodiscovery shifts: services on - minikube (ArgoCD, Immich, Kiwix, Mealie, Miniflux, Grafana, Prometheus, - Navidrome, Paperless, TeslaMate, Transmission) become explicit static entries - in `services.yaml`; ringtail services (Authentik, Frigate/NVR, Ntfy, Ollama) - auto-populate via Ingress annotations. -- Migrated CV (`cv.eblu.me`) and Docs (`docs.eblu.me`) from minikube Deployments to indri-native ansible roles. Caddy now serves the extracted release tarballs directly via a new `kind: static` service-block in the Caddy template — no daemon, no container — replacing the prior nginx-in-a-pod layer. Removes a network hop on every request and shrinks minikube's footprint. See [[cv-on-indri]] and [[docs-on-indri]]. Part of the broader minikube wind-down. -- Migrated devpi (PyPI mirror at `pypi.ops.eblu.me`) from a minikube StatefulSet to a launchd-managed service on indri. devpi-server now runs in a uv-managed venv with pinned `devpi-server` and `devpi-web` versions, listens on `127.0.0.1:3141`, and is fronted by Caddy. The minikube StatefulSet was crash-looping under memory pressure (and breaking the Python toolchain everywhere); the new layout removes a layer of dependency on cluster health for critical-path tooling. See [[devpi-on-indri]]. -- Move the entire Immich stack — server, machine-learning, valkey, - and the PostgreSQL+VectorChord cluster — off `minikube-indri` and - onto `k3s-ringtail`. Postgres data migrated zero-loss via CNPG - `pg_basebackup` (replica catch-up then promote); row counts on - `asset`, `user`, `album`, `smart_search`, `activity`, `asset_face` - verified equal between source and replica before cutover. The ML - pod now uses ringtail's RTX 4080 via the nvidia-device-plugin - (time-slicing bumped 2 → 4 to share with frigate + ollama). Caddy - routing at `photos.ops.eblu.me` is unchanged (still - `photos.tail8d86e.ts.net`, the device just lives on ringtail now). - Borgmatic backups continue against the same `immich-pg` tailnet - hostname. First concrete chain in the broader indri-k8s - decommission effort. -- Add local nix container build for `tailscale` (`containers/tailscale/default.nix`) so ringtail's tailscale-operator ProxyClass proxy pods pull from the forge mirror instead of `docker.io/tailscale/tailscale`. Pinned at v1.94.2 to match `service-versions.yaml`. Indri's tailscale-operator continues to use upstream during the k8s-to-ringtail migration. -- Address the 6 critical Prowler IaC findings against `argocd/manifests/`. Prowler's IaC provider hardcodes `self._mutelist = None` and delegates filtering to Trivy, but doesn't plumb `--ignorefile` through — so the documented "use Trivy filtering" path is actually broken. Added a shim around `trivy` in the Prowler image that injects `--ignorefile $TRIVY_IGNOREFILE` for `trivy fs` invocations when the env var points at a real file. The IaC cronjob now mounts `mutelist/trivyignore.yaml` (Trivy's per-path schema) and sets the env var, muting the `external-secrets` and `kube-state-metrics` Secret-access findings (KSV-0041, KSV-0114). Separately, `grafana-clusterrole` is tightened to remove `secrets` access entirely: the dashboard sidecar already only consumes ConfigMap-labeled dashboards, so its `RESOURCE` env var is now `configmap` instead of `both`. -- Pin ringtail's wired IP to `192.168.1.21` via NixOS scripted networking; NetworkManager no longer manages `enp5s0`. Removes DHCP lease renewal as a failure mode after a silent lease teardown took ringtail offline. Also explicitly enables `net.ipv4.ip_forward` (previously set implicitly by scripted-DHCP) so k3s pod networking and Tailscale routing continue to work with static networking. -- Ripped out the compensating-controls (CC) framework: deleted `compensating-controls.yaml`, the `review-compensating-controls` mise task, and the associated how-to / explanation docs. Prowler and Kingfisher continue to run weekly and produce reports; the Prowler mutelist YAML files remain in place but no longer carry `CC: <id>` prefixes — each entry just keeps a free-form `Description` of why the finding is muted. The CC review cadence proved to be more overhead than this single-operator homelab needed. -- Wire shower app for public exposure: fly nginx `shower.eblu.me` server - block as a guest-only surface — splash page, `/prizes/<token>/`, static - assets, media. Everything authenticated (`/admin/`, `/host/`, - `/accounts/`) returns 403 with a "tailnet only" pointer. Staff hit - `shower.ops.eblu.me` for the operator console + admin; the app's - v1.0.1 `DJANGO_PUBLIC_URL_BASE` setting makes QR codes generated on - the tailnet point back at the WAN host for guests. Plus a Caddy route - on indri, Pulumi Gandi CNAME, and a Grafana APM dashboard tracking - request rate, error rate, latency, bandwidth, and access logs. -- Mirror Valkey 8.1 locally as `registry.ops.eblu.me/blumeops/valkey`. Replaces direct pulls of `docker.io/valkey/valkey:8.1-alpine` for paperless and immich sidecars. Built via native Dagger pipeline on Alpine 3.22. Stateless swap — no data migration. Authentik's nix-built Redis remains separate. -- Add nix-built amd64 valkey for ringtail (`containers/valkey/default.nix`) so immich-ringtail can stop pulling the upstream multi-arch `docker.io/valkey/valkey` image. Existing `container.py` continues to build Alpine arm64 for paperless on indri. Both bump to valkey 8.1.7 (Alpine 3.22 8.1.7-r0 / nixpkgs 8.1.7). -- Upgrade Grafana Alloy v1.14.0 → v1.16.0 across all four service deployments - (alloy-k8s, alloy-ringtail, alloy-tracing-ringtail on k8s; alloy native on - indri). Pulls in stable database observability (v1.15) and the OTel Collector - v0.147.0 bump. Container build also migrated from Dockerfile to native Dagger - `container.py` per the build-container-image migration playbook. -- Upgraded Dagger from v0.20.1 to v0.20.6 (engine, CLI pin, and SDK regen) and migrated `runner-job-image` from a Debian-based Dockerfile to a native Dagger `container.py` on Alpine 3.23, reusing the shared `alpine_runtime` helper. -- Decommission the wave-1 services on minikube-indri now that paperless, - teslamate, and mealie run on ringtail with their data backed up. Removes the - minikube `paperless`/`teslamate`/`mealie` manifest dirs + ArgoCD app - definitions (pruning the parked Deployments, Services, and the redundant - minikube mealie/paperless PVCs), and drops the `paperless`/`teslamate` roles - from the minikube `blumeops-pg` cluster. The `paperless` and `teslamate` - databases are dropped from indri's blumeops-pg as the finalization step. - miniflux + authentik remain on the minikube cluster (later waves). -- Upgraded the k8s Forgejo runner to the v12.8 line, switched it from first-boot registration to declarative `server.connections` credentials from 1Password, and consolidated the supporting runner how-to documentation. -- Move paperless, teslamate, and mealie off `minikube-indri` onto - `k3s-ringtail`, shedding ~1.1 GiB of resident load from the - OOM-thrashing 8 GiB minikube node (the kernel OOM killer had been - killing `kube-apiserver`/`dockerd`/argocd, flapping every - minikube-hosted service at once). paperless + teslamate databases - move into a fresh CNPG `blumeops-pg` cluster on ringtail via a cold - `pg_dump`/`pg_restore` from the quiesced source — row counts verified - equal before any routing flip; source DBs dropped only after the - ringtail side serves traffic. mealie's SQLite PVC is copied as-is. - paperless media stays on sifaka NFS. Downtime-tolerant cold cutover - (no streaming replication); rollback is repoint-and-scale-up with the - source untouched. Second chain in the indri-k8s decommission after - [[migrate-immich-to-ringtail]]. -- Recurring maintenance batch: - - - Ringtail flake inputs refreshed (`disko`, `home-manager`, `nixpkgs`). - - Tooling deps bumped: prek hooks (trufflehog v3.95.3, kingfisher v1.101.0, ruff v0.15.14, `ansible-core` 2.21.0); fly proxy base images (nginx 1.30.1-alpine, alloy v1.16.1); `typer==0.26.2` in mise tasks. -- Updated `nixos/ringtail/flake.lock` (weekly cadence): `disko`, `home-manager`, and `nixpkgs` inputs refreshed. `nixpkgs-services` skipped per overlay convention. -- Reviewed `mealie` service version freshness; upstream is 5 minor versions ahead (v3.17.0 vs deployed v3.12.0). Marked reviewed; upgrade deferred. -- Deploy shower v1.1.2 — bump container build to new app release. -- Upgrade unpoller v2.34.0 → v3.2.0 and migrate container build from Dockerfile to native Dagger (container.py). v3.0.0 carries breaking UniFi API changes; v3.2.0 introduces a 60s background poll (cached scrapes) by default — set `interval = 0` in `up.conf` to restore on-demand polling. -- Monthly tooling dependency refresh: prek hooks (trufflehog, kingfisher, ruff, shfmt, prettier, actionlint, ansible-lint), fly proxy base images (nginx 1.30.0, tailscale v1.94.2, alloy v1.16.0), normalize pyyaml lower bound in mise-tasks. -- Add GE-Proton (`pkgs.proton-ge-bin`) to `programs.steam.extraCompatPackages` - on ringtail. Subnautica 2 hangs at Mercuna plugin init under Proton - Experimental + DXVK D3D12; GE-Proton is available as a Steam per-game - compatibility option to work around it. -- Add `sn2-prelaunch` Steam launch wrapper on ringtail that removes - Subnautica 2's stale `Saved/running.dat` and `Saved/beforelobby.dat` - lockfiles before each launch. SN2 pops up an invisible (0×0-sized) - Error dialog when it detects an unclean exit, blocking GameThread - forever; this is observable only as a black screen with a spinning - loader. Use via Steam launch option: `sn2-prelaunch %command%`. -- Add local nix container build for `frigate-notify` (`containers/frigate-notify/default.nix`) so the Frigate→ntfy bridge is rebuilt on ringtail from the forge mirror instead of pulled from `ghcr.io/0x2142/frigate-notify`. -- Add resource limits to all ArgoCD pods to prevent unbounded resource consumption during node-wide pressure events. -- Black-hole the `/mirrors/*` repositories at the Fly proxy edge (`return 403` → `forge.ops.eblu.me`). A surprise $29.60 Fly bill traced to ~1.24 TB/30d of egress on `forge.eblu.me`, 99.95% of all proxy egress — of which ~71% was AI scrapers (Meta `meta-externalagent`, OpenAI `GPTBot`, Amazonbot) crawling the near-infinite git-history URL space of the public mirror repos and timing out Forgejo in the process. Mirrors exist for supply-chain control and are consumed over the tailnet, so their public web UI had no legitimate audience. `robots.txt` already disallowed `/mirrors/`, but the offending agents ignore it. Tier-2 mitigations (user-agent denylist, Anubis proof-of-work gateway) are documented in `docs/explanation/ai-scraper-mitigation.md`. -- Bump paperless and immich kustomizations to the main-SHA-built valkey tag (`v8.1.6-r0-fabca04`). Routine post-merge follow-up to keep production manifests pointing at images built from a commit on main. -- Bump shower container to v1.1.1 (probe FOD hash). -- Bumped shower app to v1.1.3 (wheel/sdist + FOD hashes probed on ringtail). -- Cap systemd-coredump on ringtail (ProcessSizeMax/ExternalSizeMax 1G, MaxUse 2G) so multi-GB Wine/Proton game crash dumps no longer thrash the disk and lock up the desktop. -- Deploy shower v1.1.1 to ringtail (kustomize newTag bump). -- Deployed shower v1.1.3 to ringtail (image built and pushed from ringtail; runner bypassed due to indri overload). -- Fix three follow-ups from the wave-1 decommission: grant the local - break-glass `admin` account ArgoCD admin rights (`g, admin, role:admin` — - previously only the Authentik `admins` group had access, so admin was - locked out whenever its token expired), and repoint the alloy blackbox - probe for teslamate from the deleted minikube service to - `https://tesla.ops.eblu.me/` (through Caddy over Tailscale). The orphaned - paperless/teslamate roles + ExternalSecrets left on the minikube - blumeops-pg are also cleaned up. -- Moved the Immich blackbox health probe from indri's alloy to ringtail's alloy. After the immich migration to ringtail, the probe still targeted `immich-server.immich.svc.cluster.local` on indri's cluster where the service no longer exists, causing a persistent `ServiceProbeFailure` alert. -- Pin shower v1.1.1 FOD outputHash (probed locally on ringtail). -- Rebuild Prowler container against main HEAD (v5.23.0-495e45d) after merging the IaC mutelist Dockerfile changes. -- Rebuild and retag alloy v1.16.0 container images from the main-branch SHA - following the squash-merge of #345, per the build-container-image - squash-merge convention. Both images (`registry.ops.eblu.me/blumeops/alloy`) - now reference `9564435` rather than the branch SHA `26a3ab5`, restoring - source traceability after branch cleanup. -- Rebuild shower from the post-merge commit on main so the container's - SHA tag points at a commit that will still exist after the 30-day - branch-cleanup window. Functionally identical to the branch-tag image - already deployed, just preserves source traceability per - [[build-container-image#Squash-merge and container tags]]. -- Rebuild unpoller container from squashed main commit so the image SHA tag matches a commit in main's history (was tagged with the pre-squash branch SHA). -- Rebuild valkey container from squashed main commit (both arm64 dagger and amd64 nix variants), and update paperless + immich-ringtail kustomizations to the main-SHA tags `v8.1.7-ecded30` and `v8.1.7-ecded30-nix`. -- Retired the `blumeops-tasks` mise task (Todoist API) in favor of `heph list --project Blumeops --json` from the self-hosted [hephaestus](https://github.com/eblume/hephaestus) system. Updated docs to point task discovery and rotation reminders at heph, and noted that the `~/code/personal/zk` zettelkasten is migrating into heph docs. -- Switch the Fly proxy deploy strategy from `bluegreen` to `immediate` in `fly/fly.toml`. With a single proxy machine, bluegreen offers little benefit — the green machine routinely failed to reach "started" inside Fly's default 5-minute deploy timeout (the cold-start sequence of `tailscaled` → `tailscale up` → wait-for-MagicDNS → nginx startup eats most of the budget), and the failed deploys would roll back. `immediate` replaces the machine in place with a brief downtime (~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-<account-uuid>-<timestamp>.1pux` automatically rather than letting you save to a fixed name, so the procedure now points the task at that glob instead of pretending the default name is `1Password-export.1pux`. -- Refresh the contributing tutorial: add `last-reviewed`, include the `.ai.md` changelog fragment type, and clarify that `prek` is pinned via `mise`. -- Review and refresh the Navidrome reference card: add `last-reviewed`, correct the scanner env var name, document the current image/version, and record routing and runtime details from the manifests. -- Review and refresh the Ollama reference card: add `last-reviewed`, bump the documented image tag to 0.20.4, and add the two `qwen3.5` models now declared in `models.txt`. -- Reviewed [[1password]] reference card: added the `blumeops` vs `Personal` vault split, noted that `onepassword-connect` runs on both indri and ringtail (not just one cluster), and pulled the `op read` vs `op item get --fields` guidance up from agent memory into the card. -- Reviewed `index.md`; added ringtail to the infrastructure overview and stamped `last-reviewed`. -- Reviewed transmission card: corrected storage layout (`/config/` is emptyDir, watch dir disabled) and noted the Prometheus exporter sidecar. -- rotate-fly-deploy-token: combine mint+store into one command with both fish and bash forms; document the `op item edit` "Password item requires ps value" validator gotcha and the placeholder-password workaround. - -### AI Assistance - -- Adopt `AGENTS.md` as the canonical agent instruction file, keep `CLAUDE.md` as a compatibility shim, and update docs to reference the neutral file and the correct agent-change-process path. -- CLAUDE.md now imports AGENTS.md via `@AGENTS.md` instead of telling agents to go read it. Claude Code only auto-loads CLAUDE.md, so the prose shim was easy to skip; the import inlines AGENTS.md into the session prompt unconditionally. - -### Miscellaneous - -- Removed the dead minikube manifests, container builds, and tooling shims left behind after the cv + docs migration to indri-native (#342). Deletes `argocd/{apps,manifests}/{cv,docs}/`, `containers/{cv,quartz}/`, and the `quartz`→`docs` mapping in `mise-tasks/container-version-check`. Bumps `docs.current-version` to `v1.16.0` (the blumeops release tag) now that the legacy nginx-base version pin is gone. -- Rebuild shower v1.1.0 container from main HEAD (`3c7967e`) and bump the - kustomization tag to `v1.1.0-3c7967e-nix`. The PR was squash-merged, so - the branch commit `444ff91` baked into the prior tag isn't reachable - from main's history. The new tag points at a commit that exists on - main; image content is byte-identical because the FOD output is content - addressed and the inputs didn't change. -- Rebuild shower v1.1.2 from main HEAD (a33fa47) and retag — PR #358 was squash-merged so the branch SHA baked into the prior image tag isn't reachable from main. FOD is content-addressed, so image bytes are identical; only provenance changes. -- Remove the duplicate Homepage tiles for Mealie, Paperless, Immich, and - TeslaMate. Homepage runs on ringtail and autodiscovers ringtail Ingresses via - `gethomepage.dev/*` annotations; once these services migrated to ringtail they - were discovered automatically, making their leftover static `services.yaml` - entries (needed only while they lived on minikube) redundant. -- Removed the now-unused `containers/devpi/` Dagger build artifact. Devpi runs natively on indri via uv venv; the container image is no longer referenced anywhere. Doc examples in `docs/reference/tools/dagger.md` updated to use `miniflux` as the example container name. -- `container-build-and-release` now prints the specific `mise run runner-logs <N>` command after dispatching, polling the Forgejo API to resolve the run number for the commit it just triggered. -- `mise run runner-logs <run> -j <n>` now reports a clear error when the log file doesn't exist on indri (e.g. a runner crash that left `action_task.log_in_storage = 0`). Previously it printed only the header and exited 0, because `zstdcat` exits 0 with a "can't stat … -- ignored" stderr message and ssh+fish on indri swallows the remote exit code. - - -## [v1.16.0] - 2026-04-18 - -### 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 - -- 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 - -- 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 - -- 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 - -- 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 - -- 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 - -- 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 - -- 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 - -- 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 - -- 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 - -- 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 - -- 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 - -- 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 - -- 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 - -- 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 - -- 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 - -- Add Authentik OIDC login for ArgoCD — `eblume` (admins group) gets admin access via SSO while local admin password remains as break-glass. -- Expose Forgejo publicly at forge.eblu.me via Fly.io reverse proxy with rate limiting, fail2ban, and security hardening. -- Deploy Ollama LLM server on ringtail with GPU acceleration and declarative model management -- Add distributed tracing via Grafana Tempo and Beyla eBPF auto-instrumentation. Tempo runs on minikube-indri for trace storage, while a privileged Alloy DaemonSet on ringtail uses Beyla to instrument HTTP services (Frigate, ntfy, Ollama, Immich) without code changes. Grafana gets trace-to-log and trace-to-metrics correlation. -- Add fly.io nginx proxy observability and application logs to Forgejo dashboard; rename from "Forgejo Repository Health" to "Forgejo". - -### Bug Fixes - -- Add per-torrent rate metrics using Transmission's native rate_download/rate_upload fields. Dashboard panels were querying cumulative byte gauges (torrent size) instead of actual transfer rates. -- Fix Frigate database loss on pod restart by pointing database path to persistent /db volume -- Fix runner-job-image Dagger version mismatch: bump from 0.19.11 to 0.20.0 to match upgraded Dagger module. - -### Infrastructure - -- Home-build grafana-sidecar container image, replacing upstream `quay.io/kiwigrid/k8s-sidecar` for supply chain control. -- Add HA (2 replicas + PDB) for CV and Docs services for zero-downtime deploys. -- Build Loki container image locally instead of pulling from upstream -- Replace unmaintained `metalmatze/transmission-exporter` sidecar with homegrown Python exporter using `prometheus_client` and `transmission-rpc`. Same metric names, so Grafana dashboards work unchanged. -- Upgrade Transmission from 4.0.6-r4 to 4.1.1-r1 (Alpine edge community repo) -- Bump Frigate memory limit from 2Gi to 3Gi to prevent OOMKills under steady-state ONNX + CUDA workload. -- Add Gandi bookmark to homepage dashboard -- Allow implicit octals in yamllint and use `0755` directly in k8s manifests instead of decimal or disable-line comments. -- Upgrade Dagger engine and CLI from v0.19.11 to v0.20.0 -- Upgrade TeslaMate from v2.2.0 to v3.0.0 (dark mode, BRIN index optimization, Elixir 1.19.5, trixie-slim runtime) -- Add OOMKilled Containers stat panel and Container Restarts timeseries to the Kubernetes Clusters dashboard for persistent OOMKill visibility. -- Add pre-commit hook to prevent changelog fragments from being placed in subdirectories. -- Bump kiwix-serve from 3.8.1 to 3.8.2 - -### Documentation - -- Clarify that changelog fragments apply to all change levels (C0, C1, C2), not just C2. -- Add reference card for the Ollama LLM inference service. -- Clarify that all mikado frontmatter is removed during chain finalization; clean up stale frontmatter from closed chains; fix ai-docs exit code after plans directory retirement. -- Retire docs plans directory: deleted completed/abandoned plans, converted migrate-forgejo-from-brew to a mikado chain root card, removed plans references from tutorials and how-to index. -- Review and fix upgrade-grafana doc: correct image tag reference to kustomization.yaml, add sidecar cross-reference, update stale service-versions notes. -- Use towncrier orphan fragment naming (`+slug.<type>.md`) for C0 changes to avoid `main.*` collisions. - - -## [v1.12.1] - 2026-03-02 - -### Features - -- Mikado branch invariant hook now rejects `impl` commits that modify Mikado card files (docs with `requires:`, `status:`, or `branch: mikado/` frontmatter). - -### Infrastructure - -- Switch git hooks from pre-commit to [prek](https://github.com/j178/prek), a faster Rust-native drop-in replacement. Adds built-in checks for case conflicts, private key detection, and executable shebangs. Configuration migrated from `.pre-commit-config.yaml` to `prek.toml`. - -### Documentation - -- Review build-authentik-from-source Mikado chain: fix go-server-derivation path errors, remove stale DRF fork content from mirror doc, add last-reviewed to all cards. - - -## [v1.12.0] - 2026-03-01 - -### Bug Fixes - -- Fix authentik 2026.2.0 startup crash caused by Django migration ordering bug (`FieldError: Cannot resolve keyword 'group_id'`). Patch ensures `authentik_core/0056` runs before `authentik_rbac/0010`. - -### Infrastructure - -- Upgrade authentik from 2025.10.1 to 2026.2.0, building core services from source via custom Nix derivations rather than using nixpkgs directly (nixpkgs still provides satellite dependencies like Python, Go, and system libraries). Four components (API client generation, Python backend, web UI, Go server) assembled into a single container image with full supply chain control via forge mirrors. -- Sync Frigate zone coordinates from live API to manifest (driveway_entrance, driveway) -- Pin blumeops-pg to PostgreSQL 18.3 (from floating `:18` tag at 18.1) - -### Documentation - -- Review and update authentik-api-client-generation doc: remove stale patch note, fix test-build.nix section, add last-reviewed date. -- Review all three forgejo-runner Mikado chain docs: stamp `last-reviewed`, add cross-links, fix `configmap.yaml` → `config.yaml` reference. -- Review build-grafana-container docs; fix stale grafana.md reference card (Helm → Kustomize). - - -## [v1.11.5] - 2026-02-26 - -### Features - -- Add authenticated GitHub mirror sync with PAT rotation tooling (`mirror-update-pats`, `mirror-create` auth support, how-to doc). -- Add Transmission Grafana dashboard with metrics exporter sidecar for monitoring upload/download speeds, transfer volumes, and per-torrent breakdowns. - -### Bug Fixes - -- Fix Frigate dashboard "Detection Events Rate" panel showing no data — corrected metric name to `frigate_camera_events_total` and label to `camera`. -- Filter car and bird detections from Frigate driveway zone to stop repeated alerts on parked cars at night - -### Infrastructure - -- Port CloudNative-PG operator from Helm chart to direct upstream release manifest via forge mirror. -- Add multi-cluster Kubernetes observability: deploy kube-state-metrics and Alloy on ringtail (k3s), add `cluster` label to all metrics/logs, replace single-cluster dashboards with multi-cluster Kubernetes dashboard and dedicated Ringtail dashboard with GPU monitoring. -- Add explicit ExternalSecret defaults for SSA sync parity with ArgoCD v3.3 -- Upgrade ArgoCD from v3.2.6 to v3.3.2 with Server-Side Apply enabled - -### AI Assistance - -- Bake default bat options into `ai-docs` mise task so agents no longer need verbose flags at session start. -- docs-review task now prints the file path instead of the file content, so the LLM reads it directly. - - -## [v1.11.4] - 2026-02-25 - -### Features - -- Add `mirror-create` mise task for creating upstream mirrors in the `mirrors/` Forgejo org - -### Bug Fixes - -- Fix Grafana OAuth role mapping: INI parser was stripping quotes from `role_attribute_path = 'Admin'`, causing all Authentik users to get Viewer role instead of Admin. Now uses group-based mapping from the `admins` Authentik group. -- Fix TeslaMate dashboards showing "No Data": Grafana 12.x's `grafana-postgresql-datasource` plugin requires the database name in `jsonData`, not just the top-level `database` field. - -### Infrastructure - -- Move image tags to kustomize `images:` transformer across 22 services and replace hand-written ConfigMaps with `configMapGenerator:` in 12 services, enabling content-hash-based automatic rollouts on config changes. -- Migrate upstream mirror repos from `eblume/` to `mirrors/` Forgejo organization -- Port Prometheus to local container build (3-stage: Node UI, Go binaries, Alpine runtime) for supply chain control via Zot registry. -- Fix ArgoCD app definitions and credential template to use `mirrors/` org after forge mirror migration; bump immich v2.5.2 → v2.5.6. -- Document AirPlay cross-VLAN firewall rules for Samsung Frame TV (established/related, AirPlay ports, dynamic reverse) and fix rule ordering in segment-home-network plan. -- Update image tags for all 6 mirror-migrated containers (homepage, navidrome, ntfy, miniflux, prometheus, teslamate) -- Switch prometheus, teslamate, and miniflux container builds to forge mirrors; create miniflux mirror - -### Documentation - -- Document squash-merge container tag provenance issue and post-merge workflow for updating manifests to main-SHA tags. -- Add mise-tasks reference card with categorized task inventory; include in ai-docs context -- Review 3 how-to docs: stamp provision-authentik-database and use-pypi-proxy, fix wrong policy path and misleading --yes in update-tailscale-acls - - -## [v1.11.3] - 2026-02-23 - -### Features - -- Upgrade Grafana from 11.4.0 to 12.3.3 with home-built container image and Kustomize manifests, replacing the Helm chart deployment. - -### Bug Fixes - -- Fix Dagger pipelines hanging when called from mise tasks in interactive terminals. Added `--progress=plain` to all `dagger call` invocations to prevent SIGTTOU from stopping the process when mise's child process group is not the terminal foreground group. -- Fix Grafana TeslaMate dashboards not appearing in a folder — enabled `foldersFromFilesStructure` so the sidecar's `grafana_folder` annotation is respected. -- Container build workflows now checkout the dispatch ref when building from feature branches, fixing "No Dockerfile — skipping" errors for containers not yet on main. - -### Infrastructure - -- Fix Frigate Prometheus scrape target to route via Caddy (nvr.ops.eblu.me) after migration to ringtail, and rebuild Grafana dashboard with updated Frigate 0.17 metrics (GPU usage, temperature, skipped FPS, detection events). -- Update tooling dependencies: pre-commit hooks (trufflehog, ruff, shellcheck, prettier, actionlint), Fly.io Dockerfile (pin nginx 1.28.2-alpine, alloy v1.13.1), and normalize mise task Python lower bounds. -- Rename `containers/forgejo-runner` to `containers/runner-job-image` to distinguish the CI job execution image from the Forgejo runner daemon, fixing a version-check false positive. - -### Documentation - -- Review deploy-authentik card: rewrite as reproducible process guide, remove stale version info and future work section, mark plan as completed. -- Formalize C0/C1/C2 change classification: C0 allows direct-to-main commits, C1 adds docs-first workflow with branch deployment, C2 introduces the Mikado Branch Invariant for strict commit ordering on multi-phase changes. Add C2 conventions: `C2(<chain>): plan/impl/close/finalize` commit messages, `mikado/<chain-stem>` branch naming, and `branch:` frontmatter on goal cards. New tooling: `docs-mikado --resume` for cold-start session pickup and `mikado-branch-invariant-check` pre-commit hook. -- Replace Grafana Helm upgrade plan with C2 Mikado chain for upgrading to 12.x with kustomize and home-built containers. - -### AI Assistance - -- Improved Mikado C2 process: end-of-cycle session prompts, rigorous reset discipline with documented git patterns, and `--resume` now shows PR number and stash hints. - - -## [v1.11.2] - 2026-02-22 - -### Features - -- Add `branch-cleanup` mise task and scheduled Forgejo workflow to delete merged branches locally and on the Forgejo remote. Detects squash-merged PRs via the Forgejo API. The workflow runs approximately every 10 days with a configurable age cutoff (default 30 days). -- Add Forgejo repository health metrics collector and Grafana dashboard with CI/CD, release, and language tracking across all repos. -- Switch Frigate object detection from YOLO-NAS-S (320x320) to YOLOv9-c (640x640) with CUDA Graphs support, and add `frigate-export-model` Dagger pipeline + mise task for reproducible model exports. - -### Infrastructure - -- Simplify service-versions.yaml type taxonomy to `argocd | ansible | nixos`; add nix-container-builder entry; backfill forgejo and forgejo-runner versions -- Prepare forgejo-runner v12 upgrade: review config compatibility, add workflow schema validation via Dagger, wire pre-commit hook -- Upgrade k8s forgejo-runner daemon from v6.3.1 to v12.7.0 - -### Documentation - -- Add Mikado chain for upgrading k8s forgejo-runner from v6.3.1 to v12.x - - -## [v1.11.1] - 2026-02-22 - -### Infrastructure - -- Use Zot registry logo instead of Docker logo on homepage dashboard - - -## [v1.11.0] - 2026-02-22 - -### Features - -- Add agent change process (C0/C1/C2) documentation and `docs-mikado` tool for Mikado method dependency chain resolution. Rename `zk-docs` task to `ai-docs`. -- Deploy Authentik identity provider on ringtail k3s cluster, replacing Dex as the SSO provider. Includes Nix-built container, CNPG database, Redis, and Caddy routing at `authentik.ops.eblu.me`. -- Integrate Forgejo with Authentik OIDC for single sign-on with group-based admin propagation. Enforce TOTP MFA on Authentik authentication flow. -- Add Authentik SSO to Jellyfin with admin group mapping -- Container builds now trigger automatically on merge to main (path-based) and use commit-SHA-based image tags (`vX.Y.Z-<sha>`) for full traceability. The `container-tag-and-release` task is replaced by `container-build-and-release` which dispatches workflows via the Forgejo API. Added pre-commit hook to keep container versions in sync with `service-versions.yaml`. -- Register Zot as an OIDC client in Authentik via blueprint, with artifact-workloads group, zot-ci service account, and OIDC credentials template for Ansible deployment. -- Enable OIDC + API key authentication on zot registry with three-tier access control (anonymous read, CI create, admin full). Wire both CI push paths (Dagger and Nix/skopeo) with registry credentials via Forgejo Actions secrets. Allow anonymous Prometheus metrics scraping via `accessControl.metrics.users`. - -### Bug Fixes - -- Fix frigate-notify notification pipeline: switch to webapi polling, enable dedup, drop events without snapshots, use hi-res snapshots - -### Infrastructure - -- Add Mikado prereq for commit-based container tagging scheme to harden-zot-registry chain -- Convert deploy-authentik plan to C2 Mikado chain entry point. -- Add `flake-update` Dagger pipeline for updating ringtail NixOS flake inputs. -- Upgrade frigate-notify from v0.3.5 to v0.5.4 - -### Documentation - -- Add deployment plan for Authentik identity provider to replace Dex - - -## [v1.10.0] - 2026-02-19 - -### Features - -- Deploy Dex OIDC identity provider on ringtail with Grafana as first SSO client. -- Added Nix container build for nettest, validating the full nix-container-builder pipeline on ringtail. One git tag now triggers both Dockerfile and Nix workflows — each skips if its build file is absent. Rewrote container-tag-and-release as a typer CLI with --dry-run support. Added container policy.json and registries.conf to ringtail for skopeo. -- Add NixOS configuration for ringtail (gaming/compute workstation with RTX 4080). Includes declarative disk partitioning via disko, NVIDIA drivers, sway/Wayland desktop, Steam, Tailscale, and Ansible-driven provisioning. -- Add screen lock, idle timeout, and sleep prevention to ringtail: swaylock locks after 15min, display powers off after 60min, machine never suspends. -- Systemd Forgejo Actions runner on ringtail (`nix-container-builder` label) for building containers with `nix build` and pushing via `skopeo`. K3s cluster retained for future workloads. 1Password Connect + External Secrets Operator available for k8s secret management. - -### Bug Fixes - -- Cap detect FPS to 2 and sync motion masks/zones from live config -- Fix `zk-docs` task to use new path for troubleshooting doc after how-to reorg. -- Inhibit swayidle lock screen when a fullscreen window is active on ringtail, preventing screen lock during gamepad-only gaming sessions. -- Make 1Password secret tasks in ringtail playbook idempotent by checking kubectl apply output instead of always reporting changed. - -### Infrastructure - -- Port Frigate NVR to ringtail k3s with RTX 4080 GPU acceleration (TensorRT/ONNX), replacing the ZMQ-based Apple Silicon detector on indri. -- Replace Homepage Helm chart (jameswynn/homepage v2.1.0, pinned at app v1.2.0) with plain kustomize manifests and a custom Dockerfile built from upstream v1.10.1. Gives full version control and matches the pattern used by other blumeops services. -- Port ntfy to a locally built container image from forge mirror source. -- Port Mosquitto (MQTT) and ntfy to ringtail k3s; retire Apple Silicon Detector from indri. -- Ringtail post-install: NixOS config (sway with Catppuccin Macchiato theme, fish, 1Password, Steam, LibreWolf, Bluetooth audio, chezmoi, dev tools, nix-ld), Dagger flake-lock pipeline, improved provision-ringtail workflow, services-check integration, and reference documentation. -- Add ringtail DeviceTags to Pulumi and allow homelab-to-homelab Tailscale SSH for cross-host ansible/management. -- Update Frigate zone masks from live config and expand alert notifications to cover both Driveway and Driveway_entrance zones. -- Add Apple Silicon ZMQ detector for Frigate — inference moves from in-pod ONNX CPU to CoreML on indri via ZMQ, using YOLOv9-m model -- Deploy Tailscale operator on ringtail k3s cluster -- Upgrade ntfy from v2.11.0 to v2.17.0 and add ntfy and frigate reference docs. -- Update External Secrets Operator Helm chart from 1.3.1 to 2.0.0 (operator v1.3.2) -- Upgrade Frigate NVR from 0.16.4 to 0.17.0-rc2 (prerequisite for Apple Silicon ZMQ detector) - -### Documentation - -- Add Dex OIDC documentation: reference card, federated login explanation, services-check integration, and updated plan. -- Update services-check and documentation to reflect Frigate, Mosquitto, and ntfy migration from indri minikube to ringtail k3s (PRs #216, #217). -- Review and fix update-documentation how-to: add missing cache purge step, clean up fragment types table. - - -## [v1.9.4] - 2026-02-17 - -### Documentation - -- Reorganize how-to guides into `deployment/`, `configuration/`, and `operations/` subdirectories; review and update gandi-operations doc; fix missing cv.eblu.me CNAME in gandi reference card. - - -## [v1.9.3] - 2026-02-16 - -### Features - -- Add service version review system with `mise run service-review` task, tracking file, and how-to guide. -- Add UniFi admin link to homepage dashboard bookmarks. - -### Infrastructure - -- Eliminate double towncrier run in release workflow — changelog is now built once on the runner, then the pre-processed source tree is passed to a new `build_quartz` Dagger function for the Quartz site build only. -- First service version review: pin mosquitto to 2.0.22, bump tailscale-operator to v1.94.2, record 7 reviewed services - - -## [v1.9.2] - 2026-02-16 - -### Features - -- Add how-to guide for building container images and port navidrome to a custom-built container image. - -### Bug Fixes - -- Fix Frigate repeatedly alerting on parked cars by removing per-object max_frames and setting stationary interval to 0. Make Frigate config writable so UI changes (zones, masks) persist within a pod lifecycle. -- Switch navidrome to custom container image with dedicated non-root user and fsGroup security context - -### Documentation - -- Review expose-service-publicly doc: replace stale inline code with references to actual files, add observability sidecar section, fix broken internal link, update templates to current patterns. - - -## [v1.9.1] - 2026-02-15 - -### Documentation - -- Review connect-to-postgres, create-release-artifact-workflow, and deploy-k8s-service docs. Fix stale repoURL, incorrect Caddy config keys, add Tailscale tag documentation, and migrate remaining `op item get` calls to `op read`. - - -## [v1.9.0] - 2026-02-14 - -### Features - -- Deploy cloud-free NVR stack: Frigate 0.16.4 (ARM64) with ONNX/YOLO-NAS-s detection, Mosquitto MQTT broker, Ntfy self-hosted push notifications (with iOS APNs relay), and frigate-notify for detection alerting. GableCam (ReoLink Elite Floodlight) connected via RTSP with NFS recordings on sifaka, Grafana dashboard, Prometheus scraping, Homepage integration, and Caddy reverse proxies at nvr.ops.eblu.me and ntfy.ops.eblu.me. - -### Infrastructure - -- Configure DinD sidecar to use Zot as a pull-through registry mirror for Docker Hub images, reducing bandwidth and avoiding rate limits during Dagger CI builds. -- Abandon UniFi Pulumi IaC (provider bugs caused network outage); add manual three-network segmentation plan for UX7 web UI. -- Upgrade Node.js from 20 to 22 (LTS) in Dagger docs build and forgejo-runner container -- Tier 1 version bumps: upstream images (prometheus, loki, alloy, kube-state-metrics, tailscale, navidrome), helm charts (CloudNativePG, 1Password Connect), and custom containers (miniflux, kubectl, kiwix-serve, nettest, transmission) updated to latest stable versions with Alpine 3.22 base. - -### Documentation - -- Add how-to guide for connecting to PostgreSQL as a superuser via psql. -- Review add-ansible-role doc: fix secrets to use `op read`, match tag format to playbook, fix handler pattern, add last-reviewed date. -- Review and fix why-gitops doc: correct wiki-links, fix apt->brew, broaden Pulumi scope, add last-reviewed. - - -## [v1.8.2] - 2026-02-13 - -### Features - -- Recategorize homepage groups: "Content" (Immich, Kiwix, Miniflux, DJ, Grafana) and "Misc" (CV, TeslaMate, Transmission, Docs, Prometheus, PyPI) - -### Infrastructure - -- Move non-secret forgejo-runner env vars from ExternalSecret to deployment spec so version bumps trigger automatic rollouts -- Add yq to forgejo-runner container and replace sed-based YAML editing in workflows with yq - - -## [v1.8.0] - 2026-02-12 - -### Features - -- Update CV release to v1.0.2 -- Update CV release to v1.0.3. - -### Bug Fixes - -- Fix cache hit rate panels on APM and Fly.io dashboards showing blank/red or misleading 100% for low-traffic static sites. - -### Documentation - -- Add reference/tools/ category with Dagger, ArgoCD CLI, Ansible, and Pulumi reference cards - -### Miscellaneous - -- Add X-Clacks-Overhead header to public proxy for cv and docs: GNU Terry Pratchett. - - -## [v1.7.1] - 2026-02-12 - -### Features - -- Expose CV service publicly at cv.eblu.me via Fly.io proxy. -- Update CV service to resume release v1.0.1. - -### Infrastructure - -- Add CV to services-check (tailnet and public endpoints). - -### Miscellaneous - -- Update CV homepage link to use public URL (cv.eblu.me). -- Remove `/_error` test endpoint from Fly.io nginx proxy. - - -## [v1.7.0] - 2026-02-12 - -### Features - -- Add CV/resume web app at cv.ops.eblu.me — container, k8s manifests, Caddy route, and deploy workflow. Content built from separate cv repo. - -### Infrastructure - -- Extend forgejo_actions_secrets Ansible role to support multiple repos. - -### Documentation - -- Add CV service reference card and update apps registry, Caddy docs, and services index. -- Add how-to guide for creating release artifact workflows with Forgejo packages. - - -## [v1.6.9] - 2026-02-11 - -### Bug Fixes - -- Set ``TZ=America/Los_Angeles`` in the Dagger ``build_changelog`` container so towncrier stamps the correct local date instead of UTC (which showed tomorrow's date for evening releases). - - -## [v1.6.8] - 2026-02-11 - -### Documentation - -- Update "Deploy K8s Service" how-to with current ProxyGroup ingress pattern. - - -## [v1.6.7] - 2026-02-11 - -### Documentation - -- Close Dagger CI plan (Phases 1–3 complete) and move to completed plans archive. - - -## [v1.6.6] - 2026-02-11 - -### Features - -- Simplify Forgejo runner image (Dagger Phase 3): remove Node.js, Docker CLI, buildx, skopeo, gnupg, lsb-release, and xz-utils. Add tzdata and flyctl. All build tools now live inside Dagger containers. - -### Bug Fixes - -- Restore Docker CLI to Forgejo runner image — Dagger shells out to ``docker`` to provision its BuildKit engine. -- Restore Node.js to Forgejo runner image — required by ``actions/checkout@v4`` and other JavaScript Actions that were broken by the Phase 3 simplification. - - -## [v1.6.4] - 2026-02-12 - -### Bug Fixes - -- Set Forgejo runner timezone to America/Los_Angeles. The runner previously used UTC, causing towncrier changelog entries to show tomorrow's date when releases were cut in the evening. Note: the v1.6.2 changelog entry shows 2026-02-12 due to this bug; dates may appear non-sequential as a result. - - -## [v1.6.2] - 2026-02-12 - -### Features - -- Migrate docs build pipeline to Dagger (Phase 2): `dagger call build-docs --src=. --version=dev` now runs the full Quartz build locally, identically to CI. Adds `date-modified` frontmatter to all docs and a `docs-check-frontmatter` pre-commit hook. -- Adopt Dagger as CI build engine for container images (Phase 1). Replaces the Docker buildx + skopeo composite action with a Dagger Python module. BuildKit's push is compatible with Zot, eliminating the skopeo workaround. - -### Bug Fixes - -- Fix blumeops-tasks: migrate from deprecated Todoist REST API v2 to API v1, handle cursor-based pagination, and use `op read` for 1Password credential retrieval. - - -## [v1.6.1] - 2026-02-11 - -### Bug Fixes - -- Fix Fly.io proxy cache purge command for BusyBox shell compatibility. - - -## [v1.6.0] - 2026-02-11 - -### Bug Fixes - -- Purge Fly.io proxy cache after docs deploy so new releases are served immediately. - - -## [v1.5.4] - 2026-02-11 - -### Bug Fixes - -- Bump Fly.io proxy VM memory from 256MB to 512MB to prevent Alloy OOM kills. - -### Documentation - -- Add plan documents for Dagger CI/CD adoption and upstream fork strategy. -- Add plan documents for OIDC provider adoption, zot registry hardening, and expanded network segmentation details. -- Review security-model.md: fix op CLI pattern, add Tailscale Operator section. - - -## [v1.5.3] - 2026-02-11 - -### Features - -- Add BorgBase offsite backup repository for 3-2-1 backup strategy -- Fly.io proxy serves a friendly error page when upstreams are unreachable (indri offline, Tailscale tunnel down, etc.). Test at `docs.eblu.me/_error`. -- Add `op-backup` mise task for encrypted 1Password disaster recovery backups via borgmatic -- Add SMART disk health monitoring for sifaka NAS with smartctl_exporter, Grafana dashboard, Ansible playbook, and Caddy L4 routing via ops.eblu.me. - -### Bug Fixes - -- Replace `op item get --fields` with `op read` in all mise tasks (tailnet-up, tailnet-preview, dns-up, dns-preview) to prevent multi-line secret corruption. -- Fix 502 errors during Fly.io proxy deploys by deferring health check until Tailscale is connected. -- Fix minikube ansible role not restarting cluster after power loss — status check only examined host VM state, missing stopped kubelet/apiserver. -- Log real client IPs in Fly.io proxy access logs using Fly-Client-IP header instead of showing the internal proxy address. - -### Infrastructure - -- Switch CI container builds from deprecated `docker build` to `docker buildx build` (BuildKit). -- Install `docker-buildx-plugin` in forgejo-runner image to support `docker buildx build`. -- Eliminate 502 errors during Fly.io proxy deploys by starting nginx after Tailscale, switching to bluegreen deploys, and using service-level health checks for traffic gating. - -### Documentation - -- Add troubleshooting guide for CNI conflict after unclean shutdown to restart-indri how-to. -- Add migration plan for Forgejo brew-to-source transition -- Document `op read` vs `op item get` convention for 1Password secret retrieval -- Add power infrastructure reference card documenting the battery-backed UPS chain (Anker SOLIX F2000 → CyberPower UPS → homelab). -- Add plan and reference card for UniFi Express 7 Pulumi IaC management. -- Add how-to guide for restoring 1Password backup from borgmatic, with cross-links from disaster recovery, borgmatic, 1password, and backup policy docs - - -## [v1.5.2] - 2026-02-09 - -### Features - -- Filter blumeops-tasks to only show dated/recurring tasks when due today or earlier. -- Add `docs-review` mise task that sorts docs by `last-reviewed` frontmatter date, prioritizing never-reviewed cards. Updated the review-documentation how-to to match. - -### Bug Fixes - -- Fix fly-deploy WARNING by starting nginx before Tailscale, deferring upstream DNS resolution to request time. - -### Infrastructure - -- Migrate all Ansible `op item get` calls to `op read` URI syntax for cleaner output and remove the `regex_replace` workaround on the Fly deploy token. -- Restrict fly.io proxy ACLs to dedicated `tag:flyio-target` endpoints instead of broad `tag:k8s` and `tag:homelab` grants. Migrate all Tailscale Ingresses to a shared ProxyGroup with per-Ingress tag overrides (`tag:flyio-target` on docs, loki, prometheus). Add `autoApprovers` for VIP service routes. Enable `--accept-routes` on indri for ProxyGroup VIP routing. - - -## [v1.5.1] - 2026-02-08 - -### Features - -- Add observability to Fly.io proxy: Alloy collects nginx access logs (→ Loki) and derived metrics (→ Prometheus), with Grafana dashboards for Docs APM and Fly.io proxy health. - -### Infrastructure - -- Add docs.eblu.me and Fly.io health check to services-check - - -## [v1.5.0] - 2026-02-08 - -### Features - -- Add Fly.io public reverse proxy infrastructure for exposing services to the internet (first target: docs.eblu.me) - -### Documentation - -- Add how-to guide for exposing services publicly via Fly.io reverse proxy + Tailscale tunnel. -- Update docs for public proxy: canonical URL is now docs.eblu.me, add Fly.io proxy reference card and operations how-to - - -## [v1.4.2] - 2026-02-08 - -### Documentation - -- Update all docs frontmatter titles from slug-case to human-readable and delete title-test cards. - - -## [v1.4.1] - 2026-02-08 - -### Documentation - -- Remove docs-check-titles pre-commit hook, add repo links to homepage, and test duplicate frontmatter titles. - - -## [v1.4.0] - 2026-02-08 - -### Features - -- Add documentation consistency checks: orphan detection in doc-links, new doc-index (category index coverage), doc-stale (staleness report), and doc-tags (tag inventory). - -### Bug Fixes - -- Fix broken icons for Pulumi and ArgoCD in homepage Admin bookmarks section. - -### Infrastructure - -- Add pre-commit to mise.toml project tools. - -### Documentation - -- Review exploring-the-docs tutorial: simplify wiki-links, fix broken replication/ reference, add Related section, match zk-docs flags to CLAUDE.md. Update use-pypi-proxy to document env-var-based proxy toggle. -- Add Gandi DNS reference card and operations how-to, rewrite homepage intro for wider audience. -- Add missing `ai` changelog fragment type to update-documentation guide, consolidate `cicd`→`ci-cd` and `network`→`networking` tags -- Updated restart-indri how-to to reflect actual recovery procedure after power outage. Added UPS to indri specs. -- Fixed zk-docs links after file renames due to relative path issues - -### Miscellaneous - -- Rename `doc-*` mise tasks to `docs-check-*` / `docs-review-*` for clearer naming convention. - - -## [v1.3.4] - 2026-02-05 - -### Documentation - -- Enforce unique filenames, simple wiki-links (no paths), and no spaces in wiki-link targets for obsidian.nvim compatibility - - -## [v1.3.3] - 2026-02-04 - -### Infrastructure - -- Add IaC for Forgejo Actions secrets via new `forgejo_actions_secrets` Ansible role, syncing repository secrets from 1Password to Forgejo API - -### Documentation - -- Add how-to guide for safely restarting indri, plus AutoMounter reference card. - - -## [v1.3.2] - 2026-02-04 - -### Infrastructure - -- Fix Quartz build to use -d docs flag for accurate git-based file dates - - -## [v1.3.1] - 2026-02-04 - -### Infrastructure - -- Fix Quartz build to preserve git history for accurate file dates - -### Documentation - -- Fix misc changelog fragment type to show content (was showing empty entries) - - -## [v1.3.0] - 2026-02-04 - -### Features - -- Build workflow now supports version bump selection (major/minor/patch) and includes changelog in release body -- Add 'ai' changelog fragment type for AI assistance changes - -### Bug Fixes - -- Fix Navidrome automatic library scan by correcting env var name from `ND_SCANSCHEDULE` to `ND_SCANNER_SCHEDULE` - -### Infrastructure - -- Move CHANGELOG.md to repository root (still included in docs build) -- Remove iCloud Photos from borgmatic backup (photos now managed via Immich) - -### Documentation - -- Document Forgejo Actions secrets in forgejo reference card -- Add troubleshooting how-to to zk-docs output - -### AI Assistance - -- Add wiki-link formatting convention to AI assistance guide - -### Miscellaneous - -- , - - -## [v1.2.1] - 2026-02-04 - -### Features - -- Add doc-random mise task for random documentation review - -### Documentation - -- Add Caddy reference card and fix replication tutorial sequence - - -## [v1.2.0] - 2026-02-04 - -### Documentation - -- Complete Phase 6: migrate zk content, delete legacy cards, rewrite zk-docs for AI context priming - - -## [v1.1.5] - 2026-02-04 - -### Documentation - -- Add Phase 5 explanation docs: why GitOps, architecture overview, and security model - - -## [v1.1.4] - 2026-02-04 - -### Documentation - -- Add Phase 4 how-to guides: deploy k8s services, add ansible roles, update tailscale ACLs, and troubleshooting - - -## [v1.1.3] - 2026-02-04 - -### Features - -- Build workflow now automatically deploys docs after creating a release - updates the deployment manifest with the new release URL and syncs via ArgoCD, triggering a pod rollout - -### Miscellaneous - -- Remove confirmation prompt from container-tag-and-release task for non-interactive use - - -## [v1.1.2] - 2026-02-04 - -No significant changes. - - -## [v1.1.1] - 2026-02-04 - -### Documentation - -- Add Phase 3 tutorials: "What is BlumeOps?", "Exploring the Docs", "AI Assistance Guide", "Contributing", and "Replicating BlumeOps" with sub-tutorials for Tailscale, Kubernetes, ArgoCD, and Observability. Each tutorial explicitly identifies its target audiences. - - -## [v1.1.0] - 2026-02-04 - -No significant changes. - - -## [v1.0.14] - 2026-02-04 - -No significant changes. - - -## [v1.0.13] - 2026-02-04 - -No significant changes. - - -## [v1.0.12] - 2026-02-04 - -No significant changes. - - -## [v1.0.8] - 2026-02-04 - -### Documentation - -- Convert wiki-link titles to lowercase slugs for reliable Quartz resolution - - -## [v1.0.7] - 2026-02-03 - -### Documentation - -- Switch to title-based wiki-links with validation (Quartz resolves via frontmatter title) - - -## [v1.0.6] - 2026-02-03 - -### Documentation - -- Fix wiki-links to use filename-based resolution with Quartz shortest path mode - - -## [v1.0.5] - 2026-02-03 - -### Documentation - -- Convert wiki-links to title-based format and add duplicate title detection - - -## [v1.0.2] - 2026-02-03 - -### Features - -- Add Reference section with 24 technical reference cards covering services, infrastructure, kubernetes, and storage - -### Documentation - -- Reorder documentation phases: Reference (Phase 2) now comes before Tutorials (Phase 3) so other docs can link to reference material - - -## [v1.0.1] - 2026-02-03 - -### Infrastructure - -- Add towncrier for automated changelog generation from news fragments - - -## [0.1.0] - 2026-02-03 - -This is a historical release which doesn't actually exist and which aggregates -the changelogs prior to this date. The work on this blumeops project more or -less began around Jan 16 2026. To an extent you can find corroborating details -in the git commit log, but at the beginning (during this initial phase) there -was a fairly large amount of non-source-controlled work. If a more accurate -record is needed for this work, you may find it in borgmatic zk backups from -this time period. - -### Features - -- Add Grafana Alloy for metrics remote_write to Prometheus -- Add Alloy DaemonSet for automatic pod log collection and service health probes -- Set up Borgmatic daily backups to Sifaka NAS with PostgreSQL streaming support -- Add CloudNativePG PostgreSQL metrics scraping via Tailscale service -- Add devpi PyPI caching proxy in Kubernetes with custom container image -- Add Forgejo Actions CI runner in Kubernetes with host mode execution -- Add Homepage service dashboard with automatic Kubernetes service discovery -- Add Jellyfin media server with VideoToolbox hardware transcoding on indri -- Add Kiwix offline Wikipedia server with kiwix-tools on indri -- Add kube-state-metrics for Kubernetes resource metrics (pods, deployments, etc.) -- Add Loki log aggregation with 31-day retention and Grafana integration -- Add Miniflux RSS/Atom feed reader connected to PostgreSQL -- Add Navidrome music streaming server with NFS storage from Sifaka -- Add Prometheus metrics collection on indri with Sifaka node_exporter scraping -- Add TeslaMate vehicle data logger with 18 Grafana dashboards -- Add Transmission BitTorrent daemon for ZIM archive downloads -- Add Zot OCI registry as pull-through cache for Docker Hub, GHCR, and Quay - -### Bug Fixes - -- Build Alloy with CGO for macOS native DNS resolver (fixes Tailscale MagicDNS) -- Suppress noisy "v1 Endpoints is deprecated" warning from minikube storage-provisioner - -### Infrastructure - -- Deploy ArgoCD for GitOps continuous delivery with manual sync policy for workloads -- Set up Caddy reverse proxy for *.ops.eblu.me with ACME DNS-01 TLS via Gandi -- Deploy CloudNativePG operator and blumeops-pg PostgreSQL cluster in Kubernetes -- Migrate Grafana from Homebrew to Kubernetes via Helm chart -- Migrate Kiwix to Kubernetes with torrent-sync sidecar and ZIM watcher CronJob -- Migrate Loki to Kubernetes StatefulSet with 50Gi PVC -- Migrate Miniflux from Homebrew to Kubernetes with CloudNativePG database -- Set up Minikube single-node Kubernetes cluster on indri with Tailscale API access -- Migrate minikube from podman to docker driver for better stability and NFS support -- Manage Prometheus configuration via Ansible -- Migrate Prometheus to Kubernetes StatefulSet with 50Gi PVC -- Set up Pulumi for Tailnet ACL management with OAuth authentication -- Migrate Transmission to Kubernetes with NFS storage from Sifaka -- Migrate Zot registry from Tailscale serve to Caddy reverse proxy at registry.ops.eblu.me -- Integrate Zot as minikube registry mirror for all image pulls diff --git a/CLAUDE.md b/CLAUDE.md index 43c994c..1399f9a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1,164 @@ -@AGENTS.md +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +blumeops is Erich Blume's GitOps repository for personal infrastructure management, orchestrated via tailnet `tail8d86e.ts.net`. + +**Critical: This repository is published publicly at https://github.com/eblume/blumeops, so never include any secrets!** + +## Rules + +1. At the start of every session, even if the user asked to do something else, run `mise run zk-docs -- --style=header --color=never --decorations=always` in order to review the `blumeops` documentation in the zettelkasten (zk). zk lives at `~/code/personal/zk`, and is managed via obsidian-sync (not git). + +2. When making any changes, start by making sure you're on the `main` git branch and up-to-date, and then create a feature branch. Commit often while working, and create a PR using: +```fish +tea pr create --title "Description of change" --description "$(cat <<'EOF' +## Summary +- First change +- Second change + +## Deployment and Testing +- [x] Done thing one +- [ ] Needed thing two + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` +The user will review your work as you go, and will merge the PR as the last step in the process, even after deploying. After the user reviews the PR and leaves comments, check for unresolved comments with: +```fish +mise run pr-comments <pr_number> +``` +Address each unresolved comment before proceeding. The user will resolve comments on the Forge UI as they are addressed. + +3. Always keep the zk cards up to date with any changes, and suggest new links to new cards whenever appropriate. Refer back to the zk docs often during the process of planning and making corrections to ensure accuracy, and if you make a mistake, figure out a way to guard against it using the zk. + +4. Use `Brewfile` and `mise.toml` to install tools needed on the development workstation (typically hostnamed "gilbert", username "eblume"). + +5. Services are hosted either on indri directly (via ansible) or in Kubernetes (via ArgoCD). See the "Service Deployment" section below for details. + +6. Try to always test changes before applying them. Use syntax checkers, do dry runs (`--check --diff`), run commands manually via `ssh indri 'some command'`, etc. + +7. **Wait for user review before deploying.** After creating a PR, do not run deployment commands until the user has had a chance to review the changes. The user will indicate when they're ready to deploy. + +8. After deploying changes, try to verify the result. Use `mise run indri-services-check` to do a general service health check. + +## Project Structure + +``` +./mise-tasks/ # management and utility scripts run via `mise run` +./ansible/playbooks/ # ansible playbooks (indri.yml is primary) +./ansible/roles/ # ansible roles for indri-hosted services +./argocd/apps/ # ArgoCD Application definitions (app-of-apps pattern) +./argocd/manifests/ # Kubernetes manifests for each service +./pulumi/ # Pulumi IaC for tailnet ACLs and cloud resources +./plans/ # Migration and project planning documents +~/code/personal/ # projects managed by the user +~/code/3rd/ # external projects, mirrored or downloaded +~/code/work # FORBIDDEN, never go here, avoid searching it +``` + +## Service Deployment + +### Kubernetes Services (via ArgoCD) + +Most services run on `k8s.tail8d86e.ts.net`, via minikube on indri. They are managed via ArgoCD using the app-of-apps pattern: + +- **Application definitions**: `argocd/apps/<service>.yaml` +- **Manifests**: `argocd/manifests/<service>/` +- **Sync policy**: Manual sync (no auto-sync on git push) + +**PR workflow for k8s services:** + +1. Create feature branch and add/modify manifests +2. Push branch to forge +3. Sync the `apps` application to pick up new Application definitions: + ```fish + argocd app sync apps + ``` +4. Point the service app at the feature branch for testing: + ```fish + argocd app set <service> --revision feature/branch-name + argocd app sync <service> + ``` +5. Test the deployment +6. After PR merge, reset to main and resync: + ```fish + argocd app set <service> --revision main + argocd app sync <service> + ``` + +**Useful commands:** +```fish +argocd app list # List all apps +argocd app get <app> # Get app details +argocd app diff <app> # Preview changes before sync +argocd app sync <app> # Sync an app +kubectl --context=minikube-indri get pods -n <namespace> # Check pods +kubectl --context=minikube-indri logs -n <namespace> <pod> # View logs +``` + +Note: The user has fish abbreviations `ki` for `kubectl --context=minikube-indri` and `k9i` for `k9s --context=minikube-indri`, but these only work in interactive shells. + +**ArgoCD login (when token expires):** +```fish +argocd login argocd.tail8d86e.ts.net --username admin --password "$(op --vault vg6xf6vvfmoh5hqjjhlhbeoaie item get srogeebssulhtb6tnqd7ls6qey --fields password --reveal)" +``` + +### Indri Services (via Ansible) + +Some services remain on indri outside of Kubernetes: +- **Zot Registry** - Container registry (k8s depends on it) +- **Prometheus/Loki** - Observability (must survive k8s failures) +- **Borgmatic** - Backup system +- **Grafana Alloy** - Metrics/logs collector +- **Transmission** - BitTorrent for kiwix downloads + +**Deployment:** +```fish +mise run provision-indri # Full playbook +mise run provision-indri -- --tags <role> # Specific role +mise run provision-indri -- --check --diff # Dry run +``` + +### Tailscale Service Hostnames + +When migrating a service from indri to k8s, the Tailscale hostname must be freed: + +1. Stop the service on indri +2. Clear the tailscale serve entry: `ssh indri 'tailscale serve clear svc:<name>'` +3. Delete the device from Tailscale admin console (user action required) +4. Deploy the k8s Ingress - it will claim the hostname + +Use `ssh indri 'tailscale serve status --json'` to check current serve entries (the non-JSON output may be empty even when entries exist). + +## Container Image Releases + +```fish +mise run container-list # Show containers and recent tags +mise run container-release runner v1.0.0 # Tag and trigger build workflow +``` + +## Third-Party Projects + +When a task requires cloning or using a third-party git repository (e.g., for building from source), **ask the user to mirror it on forge first**, then clone from the mirror: +- Mirror location: `https://forge.tail8d86e.ts.net/eblume/<project>.git` +- Clone to: `~/code/3rd/<project>/` + +This avoids external dependencies and ensures the project is available even if the upstream is unreachable. + +## Task Discovery + +To discover pending blumeops tasks, run: + +```fish +mise run blumeops-tasks +``` + +This fetches tasks from the "Blumeops" project in Todoist (via 1Password for API credentials) and displays them sorted by priority: p1 (urgent), p2 (high), p4 (normal/default), p3 (backlog). The typical workflow is to pick a task from this list at the start of a session, then dive in with planning. + +## Credentials + +The root store for credentials is 1password, which can be accessed via `op --vault <vaultid> item get <itemid> --field fieldname --reveal`, which will prompt the user for their assent and biometrics or password. Typically, use scripts to defer this action - try not to ever grab credentials directly. For instance, the indri.yml playbook starts with `pre_tasks` to gather the relevant secrets needed to provision its services. Some services have their credentials exported to files `chmod 0600` on indri, but they still start out in 1password. In some cases you can test services with a command that grabs the credential, but try to use environment variables or other arrangements to avoid learning the credential yourself, and warn the user first. diff --git a/LICENSE b/LICENSE deleted file mode 100644 index f288702..0000000 --- a/LICENSE +++ /dev/null @@ -1,674 +0,0 @@ - GNU GENERAL PUBLIC LICENSE - Version 3, 29 June 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/> - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU General Public License is a free, copyleft license for -software and other kinds of works. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -the GNU General Public License is intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. We, the Free Software Foundation, use the -GNU General Public License for most of our software; it applies also to -any other work released this way by its authors. You can apply it to -your programs, too. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - To protect your rights, we need to prevent others from denying you -these rights or asking you to surrender the rights. Therefore, you have -certain responsibilities if you distribute copies of the software, or if -you modify it: responsibilities to respect the freedom of others. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must pass on to the recipients the same -freedoms that you received. You must make sure that they, too, receive -or can get the source code. And you must show them these terms so they -know their rights. - - Developers that use the GNU GPL protect your rights with two steps: -(1) assert copyright on the software, and (2) offer you this License -giving you legal permission to copy, distribute and/or modify it. - - For the developers' and authors' protection, the GPL clearly explains -that there is no warranty for this free software. For both users' and -authors' sake, the GPL requires that modified versions be marked as -changed, so that their problems will not be attributed erroneously to -authors of previous versions. - - Some devices are designed to deny users access to install or run -modified versions of the software inside them, although the manufacturer -can do so. This is fundamentally incompatible with the aim of -protecting users' freedom to change the software. The systematic -pattern of such abuse occurs in the area of products for individuals to -use, which is precisely where it is most unacceptable. Therefore, we -have designed this version of the GPL to prohibit the practice for those -products. If such problems arise substantially in other domains, we -stand ready to extend this provision to those domains in future versions -of the GPL, as needed to protect the freedom of users. - - Finally, every program is threatened constantly by software patents. -States should not allow patents to restrict development and use of -software on general-purpose computers, but in those that do, we wish to -avoid the special danger that patents applied to a free program could -make it effectively proprietary. To prevent this, the GPL assures that -patents cannot be used to render the program non-free. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Use with the GNU Affero General Public License. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU Affero General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the special requirements of the GNU Affero General Public License, -section 13, concerning interaction through a network will apply to the -combination as such. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - <one line to give the program's name and a brief idea of what it does.> - Copyright (C) <year> <name of author> - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see <https://www.gnu.org/licenses/>. - -Also add information on how to contact you by electronic and paper mail. - - If the program does terminal interaction, make it output a short -notice like this when it starts in an interactive mode: - - <program> Copyright (C) <year> <name of author> - This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, your program's commands -might be different; for a GUI interface, you would use an "about box". - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU GPL, see -<https://www.gnu.org/licenses/>. - - The GNU General Public License does not permit incorporating your program -into proprietary programs. If your program is a subroutine library, you -may consider it more useful to permit linking proprietary applications with -the library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. But first, please read -<https://www.gnu.org/licenses/why-not-lgpl.html>. diff --git a/README.md b/README.md index e5945e5..61ea208 100644 --- a/README.md +++ b/README.md @@ -1,95 +1,89 @@ # blumeops -aka "Blue Mops" - -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 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 -I feel about LLMs in our current era (there are real concerns about how -training data is sourced and energy subsidy) but it felt important to learn how -to work with these tools. - -The full documentation is published at **[docs.eblu.me](https://docs.eblu.me)** -and lives in the [`docs/`](docs/) directory, structured around the -[Diataxis](https://diataxis.fr/) framework and designed to be compatible with -[Obsidian](https://obsidian,nd)/[Obsidian.nvim](https://github.com/obsidian-nvim/obsidian.nvim). - -## What runs here - -Services are a mix of Kubernetes pods (managed by ArgoCD), macOS LaunchAgent -services (managed by Ansible), and NixOS systemd services (managed by Nix -flakes), all connected via Tailscale: - -- **Indri** (Mac Mini M1) - primary server. Most services run in Minikube via - ArgoCD; Forgejo, Caddy, and others run natively as LaunchAgent services via - Ansible. -- **Ringtail** (NixOS desktop, RTX 4080) - GPU workloads (Frigate NVR, - Authentik SSO) on k3s, plus NixOS systemd services. -- **Sifaka** (Synology NAS) - backup target and bulk storage. - -Notable services include Grafana/Prometheus/Loki observability, Immich photos, -Jellyfin media, Forgejo git forge, a Zot container registry, and more. Public -access is routed through a Fly.io proxy; everything else is tailnet-only. - -## Project structure ``` -ansible/ Ansible playbooks and roles (indri, sifaka) -argocd/apps/ ArgoCD Application definitions -argocd/manifests/ Kubernetes manifests per service -containers/ Custom container builds (Dockerfile + Nix) -docs/ Diataxis documentation (published at docs.eblu.me) -fly/ Fly.io public proxy configuration -mise-tasks/ Operational scripts run via mise -nixos/ NixOS configuration for ringtail -pulumi/ Pulumi IaC (Tailscale ACLs, Gandi DNS) -.dagger/ Dagger CI pipelines -.forgejo/ Forgejo Actions CI/CD workflows + l0K k..:k. + .:...c. ;c.... + ....'o x..... + ....k x.... + ... l' 'c.... + ....,l o'.... + .....x k.... + .....d. c.... + ... l x.... + .,.d ;c.c' + 'c':; x',c. + .:,'o .x.::. + .;:.k ,:.c' + ,c.c';:. + .,.:;. + ;'.c, l + d',c..:.d. + O.:;. 'c';c + ;c.c' .:;.x + o',c. .;:.k + x.::. 'c.l. + dOKl.c, .c,'o + 0l'...... ..' .::.ocx. + 'o ............ o .... :olx; + x,ox;. ....... .k ....,dKKo;..x + 'd,OXXXXk:. ...... ; ;:dXOl;',';l;o; + x,oXXXXXXXXXkc. ... .lc,',':dKNNNx;x; + ;o;0KXXXXXXXXXXXX0l. .',ckNNNNNNNNNxco0d + l,d0oOXKOKXXXXKXXXX0. kNNNNNNNNNNNNNXxloo:: + .OXxdXKOX0kXXXX0. .KNNNNNNNNNNXONX0o. + ,OdxKldXXXXx. ,NNNNNNNNNNNKoc + :.OXXkKo .kNNNNNNNNXx. + ':0c .NdNkXkc ``` -## Getting started +*Blue Mops* — GitOps for Erich Blume's personal computing environment. -You'll need [Homebrew](https://brew.sh) and [mise](https://mise.jdx.dev): +## What is this? + +Infrastructure-as-code for my tailnet (`tail8d86e.ts.net`). This repo contains +ansible playbooks, configuration, and automation for managing my personal +infrastructure. + +This codebase was heavily co-authored by Claude Code, as an experiment in +LLM-assisted development. I want to include a personal note here that I don't +know entirely how I feel about LLMs in our current era, but it felt important +to learn. + +## Development + +### Pre-commit Hooks + +This repo uses [pre-commit](https://pre-commit.com) for code quality and consistency. Install hooks with: ```bash -brew bundle # install CLI tools (argocd, tea, flyctl, etc.) -mise install # install managed toolchains (ansible, pulumi, dagger, etc.) -prek install # set up git hooks +uvx pre-commit install ``` -Git hooks (via [prek](https://github.com/j178/prek)) enforce secret scanning -(TruffleHog), linting, formatting, and custom checks like doc link validation -and the Mikado branch invariant. They run automatically on `git commit`. - -Operational tasks are driven through mise. Run `mise tasks` to see what's -available. Key examples: +Run all hooks manually: ```bash -mise run provision-indri # deploy to indri via Ansible -mise run services-check # verify service health -mise run container-list # list tracked container images +uvx pre-commit run --all-files ``` -## AI-assisted development +Hooks include: +- **General**: trailing whitespace, end-of-file fixer, large files, merge conflicts +- **Secrets**: [TruffleHog](https://github.com/trufflesecurity/trufflehog) for secret detection +- **YAML**: yamllint, ansible-lint +- **Python**: ruff (linting + formatting) +- **Shell**: shellcheck, shfmt +- **TOML**: taplo +- **JSON**: prettier -This repo is designed to be worked on by both humans and AI agents. 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. +## CI/CD -Changes are classified before starting work: +This repo uses [Forgejo Actions](https://forgejo.org/docs/latest/user/actions/) for CI/CD. Workflows live in `.forgejo/workflows/` (not `.github/workflows/`). The runner executes jobs in host mode within the Kubernetes cluster. -- **C0** - quick fixes, committed directly to main -- **C1** - feature branch + PR, documentation written before code -- **C2** - multi-phase work using the Mikado method for dependency tracking +## Documentation -See the [agent change process](docs/explanation/agent-change-process.md) for -details. +Detailed documentation lives in my personal zettelkasten, which is not included in this repository. You can view the docs with: -## License +```bash +mise run zk-docs +``` -[GPLv3](LICENSE) +The zettelkasten is private at time of writing. If you're interested in the documentation or have questions about this project, please reach out to blume.erich@gmail.com. diff --git a/ansible/group_vars/all.yml b/ansible/group_vars/all.yml new file mode 100644 index 0000000..4559aef --- /dev/null +++ b/ansible/group_vars/all.yml @@ -0,0 +1,2 @@ +--- +ansible_managed: "Managed by ansible - do not edit. Source: ssh://forgejo@forge.tail8d86e.ts.net/eblume/blumeops.git" diff --git a/ansible/inventory/group_vars/all.yml b/ansible/inventory/group_vars/all.yml deleted file mode 100644 index 342a493..0000000 --- a/ansible/inventory/group_vars/all.yml +++ /dev/null @@ -1,6 +0,0 @@ ---- -ansible_managed: "Managed by ansible - do not edit. Source: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git" - -# Sifaka NAS exporter ports — shared by caddy (indri) and sifaka_exporters roles -sifaka_node_exporter_port: 9100 -sifaka_smartctl_exporter_port: 9633 diff --git a/ansible/inventory/host_vars/sifaka.yml b/ansible/inventory/host_vars/sifaka.yml deleted file mode 100644 index 1afd4d8..0000000 --- a/ansible/inventory/host_vars/sifaka.yml +++ /dev/null @@ -1,3 +0,0 @@ ---- -ansible_user: eblume -ansible_python_interpreter: /usr/bin/python3 diff --git a/ansible/inventory/hosts.yml b/ansible/inventory/hosts.yml index 73746bf..b69f7a0 100644 --- a/ansible/inventory/hosts.yml +++ b/ansible/inventory/hosts.yml @@ -5,9 +5,6 @@ all: hosts: indri: ansible_host: indri - ringtail: - ansible_host: ringtail - ansible_user: eblume workstations: hosts: gilbert: diff --git a/ansible/playbooks/indri.yml b/ansible/playbooks/indri.yml index 1e33bb1..6e962f1 100644 --- a/ansible/playbooks/indri.yml +++ b/ansible/playbooks/indri.yml @@ -8,7 +8,7 @@ pre_tasks: - name: Fetch borgmatic database password ansible.builtin.command: - cmd: op read "op://vg6xf6vvfmoh5hqjjhlhbeoaie/mw2bv5we7woicjza7hc6s44yvy/db-password" + cmd: op --vault vg6xf6vvfmoh5hqjjhlhbeoaie item get mw2bv5we7woicjza7hc6s44yvy --fields db-password --reveal delegate_to: localhost register: _borgmatic_db_pw changed_when: false @@ -22,26 +22,10 @@ no_log: true tags: [borgmatic] - - name: Fetch BorgBase SSH private key - ansible.builtin.command: - cmd: op read "op://vg6xf6vvfmoh5hqjjhlhbeoaie/noiobufntsxyzageu7mvlp2nbe/ssh-private-key" - delegate_to: localhost - register: _borgbase_ssh_key - changed_when: false - no_log: true - check_mode: false - tags: [borgmatic] - - - name: Set BorgBase SSH key fact - ansible.builtin.set_fact: - borgbase_ssh_private_key: "{{ _borgbase_ssh_key.stdout }}" - no_log: true - tags: [borgmatic] - # Forgejo secrets - name: Fetch forgejo LFS JWT secret ansible.builtin.command: - cmd: op read "op://vg6xf6vvfmoh5hqjjhlhbeoaie/w3663ffnvkewbftncqxtcpeavy/lfs-jwt-secret" + cmd: op --vault vg6xf6vvfmoh5hqjjhlhbeoaie item get w3663ffnvkewbftncqxtcpeavy --fields lfs-jwt-secret --reveal delegate_to: localhost register: _forgejo_lfs_jwt changed_when: false @@ -51,7 +35,7 @@ - name: Fetch forgejo internal token ansible.builtin.command: - cmd: op read "op://vg6xf6vvfmoh5hqjjhlhbeoaie/w3663ffnvkewbftncqxtcpeavy/internal-token" + cmd: op --vault vg6xf6vvfmoh5hqjjhlhbeoaie item get w3663ffnvkewbftncqxtcpeavy --fields internal-token --reveal delegate_to: localhost register: _forgejo_internal_token changed_when: false @@ -61,7 +45,7 @@ - name: Fetch forgejo OAuth2 JWT secret ansible.builtin.command: - cmd: op read "op://vg6xf6vvfmoh5hqjjhlhbeoaie/w3663ffnvkewbftncqxtcpeavy/oauth2-jwt-secret" + cmd: op --vault vg6xf6vvfmoh5hqjjhlhbeoaie item get w3663ffnvkewbftncqxtcpeavy --fields oauth2-jwt-secret --reveal delegate_to: localhost register: _forgejo_oauth2_jwt changed_when: false @@ -77,158 +61,6 @@ no_log: true tags: [forgejo] - # Forgejo Actions secrets (synced to Forgejo via API) - - name: Fetch Forgejo API token - ansible.builtin.command: - cmd: op read "op://vg6xf6vvfmoh5hqjjhlhbeoaie/w3663ffnvkewbftncqxtcpeavy/api-token" - delegate_to: localhost - register: _forgejo_api_token - changed_when: false - no_log: true - check_mode: false - tags: [forgejo_actions_secrets] - - - name: Fetch ArgoCD auth token for Forgejo Actions - ansible.builtin.command: - cmd: op read "op://vg6xf6vvfmoh5hqjjhlhbeoaie/w3663ffnvkewbftncqxtcpeavy/argocd_token" - delegate_to: localhost - register: _forgejo_argocd_token - changed_when: false - no_log: true - check_mode: false - tags: [forgejo_actions_secrets] - - - name: Fetch Fly.io deploy token for Forgejo Actions - ansible.builtin.command: - cmd: op read "op://vg6xf6vvfmoh5hqjjhlhbeoaie/on5slfaygtdjrxmdwezyhfmqsq/deploy-token" - delegate_to: localhost - register: _fly_deploy_token - changed_when: false - no_log: true - check_mode: false - tags: [forgejo_actions_secrets] - - - name: Fetch Zot CI API key for Forgejo Actions - ansible.builtin.command: - cmd: op read "op://vg6xf6vvfmoh5hqjjhlhbeoaie/w3663ffnvkewbftncqxtcpeavy/zot-ci-api" - delegate_to: localhost - register: _zot_ci_api_key - changed_when: false - no_log: true - check_mode: false - tags: [forgejo_actions_secrets] - - - name: Set Forgejo Actions secrets facts - ansible.builtin.set_fact: - forgejo_api_token: "{{ _forgejo_api_token.stdout }}" - forgejo_secret_argocd_token: "{{ _forgejo_argocd_token.stdout }}" - forgejo_secret_fly_deploy_token: "{{ _fly_deploy_token.stdout }}" - forgejo_secret_zot_ci_api_key: "{{ _zot_ci_api_key.stdout }}" - no_log: true - tags: [forgejo_actions_secrets] - - # Zot OIDC client secret - - name: Fetch zot OIDC client secret - ansible.builtin.command: - cmd: op read "op://vg6xf6vvfmoh5hqjjhlhbeoaie/oor7os5kapczgpbwv7obkca4y4/zot-client-secret" - delegate_to: localhost - register: _zot_oidc_secret - changed_when: false - no_log: true - check_mode: false - tags: [zot] - - - name: Set zot OIDC client secret fact - ansible.builtin.set_fact: - zot_oidc_client_secret: "{{ _zot_oidc_secret.stdout }}" - no_log: true - tags: [zot] - - # Caddy Gandi token for ACME DNS-01 challenges - - name: Fetch Gandi PAT for Caddy - ansible.builtin.command: - cmd: op read "op://vg6xf6vvfmoh5hqjjhlhbeoaie/mco6ka3dc3rmw7zkg2dhia5d2m/pat" - delegate_to: localhost - register: _caddy_gandi_token - changed_when: false - no_log: true - check_mode: false - tags: [caddy] - - - name: Set Caddy Gandi token fact - ansible.builtin.set_fact: - caddy_gandi_token: "{{ _caddy_gandi_token.stdout }}" - no_log: true - tags: [caddy] - - # Jellyfin SSO client secret - - name: Fetch Jellyfin OIDC client secret - ansible.builtin.command: - cmd: op read "op://vg6xf6vvfmoh5hqjjhlhbeoaie/oor7os5kapczgpbwv7obkca4y4/jellyfin-client-secret" - delegate_to: localhost - register: _jellyfin_oidc_secret - changed_when: false - no_log: true - check_mode: false - tags: [jellyfin] - - - name: Set Jellyfin OIDC client secret fact - ansible.builtin.set_fact: - jellyfin_sso_client_secret: "{{ _jellyfin_oidc_secret.stdout }}" - no_log: true - tags: [jellyfin] - - # Jellyfin API key for metrics collection - - name: Fetch Jellyfin API key - ansible.builtin.command: - cmd: op read "op://vg6xf6vvfmoh5hqjjhlhbeoaie/ceywxkcd3z7najsy2nmmbs2vke/credential" - delegate_to: localhost - register: _jellyfin_metrics_api_key - changed_when: false - no_log: true - check_mode: false - tags: [jellyfin_metrics] - - - name: Set Jellyfin API key fact - ansible.builtin.set_fact: - jellyfin_metrics_api_key: "{{ _jellyfin_metrics_api_key.stdout }}" - no_log: true - tags: [jellyfin_metrics] - - # Forgejo API token for metrics collection - - name: Fetch Forgejo API token for metrics - ansible.builtin.command: - cmd: op read "op://vg6xf6vvfmoh5hqjjhlhbeoaie/w3663ffnvkewbftncqxtcpeavy/api-token" - delegate_to: localhost - register: _forgejo_metrics_api_token - changed_when: false - no_log: true - check_mode: false - tags: [forgejo_metrics] - - - name: Set Forgejo metrics API token fact - ansible.builtin.set_fact: - forgejo_metrics_api_key: "{{ _forgejo_metrics_api_token.stdout }}" - 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 @@ -238,29 +70,15 @@ tags: borgmatic_metrics - role: forgejo tags: forgejo - - role: forgejo_actions_secrets - tags: forgejo_actions_secrets - role: zot tags: zot - role: zot_metrics tags: zot_metrics - - role: devpi - tags: devpi - role: minikube tags: minikube - role: minikube_metrics tags: minikube_metrics - - role: jellyfin - tags: jellyfin - - role: jellyfin_metrics - tags: jellyfin_metrics - - role: forgejo_metrics - tags: forgejo_metrics - - role: cv - tags: cv - - role: docs - tags: docs - - role: heph - tags: heph - - role: caddy - tags: caddy + - role: plex_metrics + tags: plex_metrics + - role: tailscale_serve + tags: tailscale-serve diff --git a/ansible/playbooks/ringtail.yml b/ansible/playbooks/ringtail.yml deleted file mode 100644 index b05d67a..0000000 --- a/ansible/playbooks/ringtail.yml +++ /dev/null @@ -1,118 +0,0 @@ ---- -- name: Configure ringtail (NixOS) - hosts: ringtail - become: true - - pre_tasks: - - name: Fetch 1Password Connect credentials from 1Password - ansible.builtin.command: - cmd: op read "op://vg6xf6vvfmoh5hqjjhlhbeoaie/1Password Connect/credentials-file" - register: _op_credentials - changed_when: false - delegate_to: localhost - become: false - - - name: Fetch 1Password Connect token from 1Password - ansible.builtin.command: - cmd: op read "op://vg6xf6vvfmoh5hqjjhlhbeoaie/1Password Connect/token" - register: _op_token - changed_when: false - delegate_to: localhost - become: false - - - name: Fetch Forgejo runner registration token from 1Password - ansible.builtin.command: - cmd: op read "op://vg6xf6vvfmoh5hqjjhlhbeoaie/Forgejo Secrets/runner_reg" - register: _runner_reg - changed_when: false - delegate_to: localhost - become: false - - - name: Ensure /etc/forgejo-runner directory exists - ansible.builtin.file: - path: /etc/forgejo-runner - state: directory - mode: "0700" - - - name: Write Forgejo runner token file - ansible.builtin.copy: - content: "TOKEN={{ _runner_reg.stdout }}" - dest: /etc/forgejo-runner/token.env - mode: "0600" - no_log: true - - - name: Ensure /etc/k3s directory exists - ansible.builtin.file: - path: /etc/k3s - state: directory - mode: "0700" - - - name: Generate k3s token if not present - ansible.builtin.copy: - content: "{{ lookup('ansible.builtin.password', '/dev/null', chars=['hexdigits'], length=32) }}" - dest: /etc/k3s/token - mode: "0600" - force: false - - tasks: - - name: Ensure blumeops repo is present - ansible.builtin.git: - repo: "https://forge.ops.eblu.me/eblume/blumeops.git" - dest: /etc/blumeops - version: "{{ ringtail_commit | default('main') }}" - force: true - register: _repo - - - name: Rebuild NixOS - ansible.builtin.command: - cmd: nixos-rebuild switch --flake /etc/blumeops/nixos/ringtail#ringtail - register: _rebuild - changed_when: "'activating the configuration' in _rebuild.stderr" - when: _repo.changed - - - name: Verify tailscale is connected - ansible.builtin.command: tailscale status --self --json - register: _ts_status - changed_when: false - failed_when: "'Running' not in _ts_status.stdout" - - post_tasks: - - name: Wait for k3s to be ready - ansible.builtin.command: k3s kubectl get nodes - register: _k3s_ready - changed_when: false - retries: 30 - delay: 5 - until: _k3s_ready.rc == 0 - - - name: Create 1password namespace - ansible.builtin.command: k3s kubectl create namespace 1password - register: _ns - changed_when: _ns.rc == 0 - failed_when: _ns.rc != 0 and 'AlreadyExists' not in _ns.stderr - - - name: Create or update op-credentials secret - ansible.builtin.shell: - cmd: | - set -o pipefail - k3s kubectl create secret generic op-credentials \ - --namespace=1password \ - --from-literal=1password-credentials.json='{{ _op_credentials.stdout }}' \ - --dry-run=client -o yaml | k3s kubectl apply -f - - executable: /run/current-system/sw/bin/bash - register: _op_credentials_apply - changed_when: "'configured' in _op_credentials_apply.stdout or 'created' in _op_credentials_apply.stdout" - no_log: true - - - name: Create or update onepassword-token secret - ansible.builtin.shell: - cmd: | - set -o pipefail - k3s kubectl create secret generic onepassword-token \ - --namespace=1password \ - --from-literal=token={{ _op_token.stdout }} \ - --dry-run=client -o yaml | k3s kubectl apply -f - - executable: /run/current-system/sw/bin/bash - register: _op_token_apply - changed_when: "'configured' in _op_token_apply.stdout or 'created' in _op_token_apply.stdout" - no_log: true diff --git a/ansible/playbooks/sifaka.yml b/ansible/playbooks/sifaka.yml deleted file mode 100644 index 511a358..0000000 --- a/ansible/playbooks/sifaka.yml +++ /dev/null @@ -1,7 +0,0 @@ ---- -- name: Configure sifaka - hosts: nas - - roles: - - role: sifaka_exporters - tags: sifaka_exporters diff --git a/ansible/roles/alloy/defaults/main.yml b/ansible/roles/alloy/defaults/main.yml index 4cf7432..afb14e7 100644 --- a/ansible/roles/alloy/defaults/main.yml +++ b/ansible/roles/alloy/defaults/main.yml @@ -10,10 +10,10 @@ # Build on dev machine (gilbert), then copy to indri: # # 1. Clone from forge mirror: -# git clone ssh://forgejo@forge.ops.eblu.me:2222/mirrors/alloy.git ~/code/3rd/alloy +# git clone ssh://forgejo@forge.tail8d86e.ts.net/eblume/alloy.git ~/code/3rd/alloy # # 2. Set up build tools via mise: -# cd ~/code/3rd/alloy && mise use go@1.25.7 node yarn +# cd ~/code/3rd/alloy && mise use go@1.25 node yarn # # 3. Build with CGO enabled (default in Makefile): # cd ~/code/3rd/alloy && mise x -- make alloy @@ -21,10 +21,7 @@ # 4. Copy binary to indri: # scp ~/code/3rd/alloy/build/alloy indri:~/.local/bin/alloy # -# 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 +# 5. Run ansible to deploy config and LaunchAgent # Binary and paths alloy_binary: /Users/erichblume/.local/bin/alloy @@ -35,11 +32,11 @@ alloy_log_dir: /Users/erichblume/Library/Logs # Textfile collector directory (same as node_exporter for compatibility) alloy_textfile_dir: /opt/homebrew/var/node_exporter/textfile -# Prometheus remote write endpoint (k8s via Caddy) -alloy_prometheus_url: "https://prometheus.ops.eblu.me/api/v1/write" +# Prometheus remote write endpoint (k8s via Tailscale) +alloy_prometheus_url: "https://prometheus.tail8d86e.ts.net/api/v1/write" -# Loki endpoint (k8s via Caddy) -alloy_loki_url: "https://loki.ops.eblu.me/loki/api/v1/push" +# Loki endpoint (k8s via Tailscale) +alloy_loki_url: "https://loki.tail8d86e.ts.net/loki/api/v1/push" # Instance label for metrics alloy_instance_label: indri @@ -75,12 +72,11 @@ alloy_mcquack_logs: - path: /Users/erichblume/Library/Logs/mcquack.zot.err.log service: zot stream: stderr - - path: /Users/erichblume/Library/Logs/mcquack.jellyfin.out.log - service: jellyfin + +alloy_plex_logs: + - path: /Users/erichblume/Library/Logs/Plex Media Server/Plex Media Server.log + service: plex stream: stdout - - path: /Users/erichblume/Library/Logs/mcquack.jellyfin.err.log - service: jellyfin - stream: stderr # Enable log collection (requires Loki to be running) alloy_collect_logs: true @@ -101,10 +97,6 @@ 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/tasks/main.yml b/ansible/roles/alloy/tasks/main.yml index 90fbf1b..1af95f5 100644 --- a/ansible/roles/alloy/tasks/main.yml +++ b/ansible/roles/alloy/tasks/main.yml @@ -38,7 +38,9 @@ - name: Fetch PostgreSQL metrics password from 1Password ansible.builtin.command: - cmd: op read "op://{{ alloy_op_vault }}/{{ alloy_op_postgres_item }}/{{ alloy_op_postgres_field }}" + cmd: >- + op --vault {{ alloy_op_vault }} item get {{ alloy_op_postgres_item }} + --fields {{ alloy_op_postgres_field }} --reveal delegate_to: localhost register: alloy_postgres_password_result changed_when: false diff --git a/ansible/roles/alloy/templates/config.alloy.j2 b/ansible/roles/alloy/templates/config.alloy.j2 index 39e4dad..b4171dd 100644 --- a/ansible/roles/alloy/templates/config.alloy.j2 +++ b/ansible/roles/alloy/templates/config.alloy.j2 @@ -29,11 +29,6 @@ prometheus.relabel "instance" { target_label = "instance" replacement = "{{ alloy_instance_label }}" } - - rule { - target_label = "cluster" - replacement = "indri" - } } // Push metrics to Prometheus via remote_write @@ -74,18 +69,6 @@ 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 ============== @@ -107,6 +90,15 @@ local.file_match "mcquack_logs" { ] } +// Discover log files - Plex Media Server +local.file_match "plex_logs" { + path_targets = [ +{% for log in alloy_plex_logs %} + {__path__ = "{{ log.path }}", service = "{{ log.service }}", stream = "{{ log.stream }}"}, +{% endfor %} + ] +} + // Read and forward brew service logs loki.source.file "brew_logs" { targets = local.file_match.brew_logs.targets @@ -119,6 +111,12 @@ loki.source.file "mcquack_logs" { forward_to = [loki.relabel.add_host.receiver] } +// Read and forward Plex logs +loki.source.file "plex_logs" { + targets = local.file_match.plex_logs.targets + forward_to = [loki.relabel.add_host.receiver] +} + // Add host label to all logs loki.relabel "add_host" { forward_to = [loki.write.loki.receiver] @@ -127,11 +125,6 @@ loki.relabel "add_host" { target_label = "host" replacement = "{{ alloy_instance_label }}" } - - rule { - target_label = "cluster" - replacement = "indri" - } } // Write logs to Loki diff --git a/ansible/roles/borgmatic/defaults/main.yml b/ansible/roles/borgmatic/defaults/main.yml index a743161..245f6ed 100644 --- a/ansible/roles/borgmatic/defaults/main.yml +++ b/ansible/roles/borgmatic/defaults/main.yml @@ -6,16 +6,6 @@ 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 @@ -26,47 +16,14 @@ borgmatic_source_directories: - /opt/homebrew/var/forgejo - /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 + - /Users/erichblume/Pictures -# Backup repositories +# Backup repository borgmatic_repositories: - path: /Volumes/backups/borg/ label: sifaka-borg-backups encryption: repokey append_only: true - - path: ssh://u3ugi1x1@u3ugi1x1.repo.borgbase.com/./repo - label: borgbase-offsite - encryption: repokey - append_only: true - -# 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 <pod-selector> -- sqlite3 <path> ".backup /tmp/backup.db" -# then copies the dump to borgmatic_k8s_dump_dir/<name>.db -borgmatic_k8s_sqlite_dumps: - - name: mealie - namespace: mealie - label_selector: app=mealie - db_path: /app/data/mealie.db - # 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 - db_path: /app/data/db.sqlite3 - # 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: [] @@ -82,42 +39,14 @@ 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_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 -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 + # k8s PostgreSQL (CloudNativePG) - name: miniflux - hostname: pg.ops.eblu.me + hostname: pg.tail8d86e.ts.net 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: 5434 - username: borgmatic - # immich-pg cluster (VectorChord) via Caddy L4 on port 5433 - - name: immich - hostname: pg.ops.eblu.me - port: 5433 + hostname: pg.tail8d86e.ts.net + port: 5432 username: borgmatic diff --git a/ansible/roles/borgmatic/handlers/main.yml b/ansible/roles/borgmatic/handlers/main.yml index 3463cce..5fd6174 100644 --- a/ansible/roles/borgmatic/handlers/main.yml +++ b/ansible/roles/borgmatic/handlers/main.yml @@ -4,9 +4,3 @@ 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 36d3bb6..e5cc1f0 100644 --- a/ansible/roles/borgmatic/tasks/main.yml +++ b/ansible/roles/borgmatic/tasks/main.yml @@ -1,11 +1,6 @@ --- -# 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" +# Note: borgmatic is installed via mise (pipx), not managed here. +# This role manages the config file and scheduled LaunchAgent. - name: Ensure borgmatic config directory exists ansible.builtin.file: @@ -19,52 +14,11 @@ 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 }} + pg.tail8d86e.ts.net:5432:*:borgmatic:{{ borgmatic_db_password }} dest: ~/.pgpass mode: '0600' no_log: true -# BorgBase offsite backup - SSH key and host verification -- name: Deploy BorgBase SSH private key - ansible.builtin.copy: - content: "{{ borgbase_ssh_private_key }}\n" - dest: "{{ borgmatic_borgbase_ssh_key_path }}" - mode: '0600' - no_log: true - -- name: Add BorgBase host keys to known_hosts - ansible.builtin.known_hosts: - 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: - path: "{{ borgmatic_k8s_dump_dir }}" - state: directory - 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 @@ -89,30 +43,3 @@ 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 deleted file mode 100644 index d5b5578..0000000 --- a/ansible/roles/borgmatic/templates/borgmatic-photos.plist.j2 +++ /dev/null @@ -1,36 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<!-- {{ ansible_managed }} --> -<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> -<plist version="1.0"> -<dict> - <key>KeepAlive</key> - <false/> - <key>Label</key> - <string>mcquack.eblume.borgmatic-photos</string> - <key>EnvironmentVariables</key> - <dict> - <key>PATH</key> - <string>/opt/homebrew/bin:/usr/bin:/bin</string> - </dict> - <key>ProgramArguments</key> - <array> - <string>{{ borgmatic_bin }}</string> - <string>--config</string> - <string>{{ borgmatic_photos_config }}</string> - <string>create</string> - </array> - <key>RunAtLoad</key> - <false/> - <key>StandardErrorPath</key> - <string>{{ borgmatic_log_dir }}/mcquack.borgmatic-photos.err.log</string> - <key>StandardOutPath</key> - <string>{{ borgmatic_log_dir }}/mcquack.borgmatic-photos.out.log</string> - <key>StartCalendarInterval</key> - <dict> - <key>Hour</key> - <integer>{{ borgmatic_photos_schedule_hour }}</integer> - <key>Minute</key> - <integer>{{ borgmatic_photos_schedule_minute }}</integer> - </dict> -</dict> -</plist> diff --git a/ansible/roles/borgmatic/templates/borgmatic.plist.j2 b/ansible/roles/borgmatic/templates/borgmatic.plist.j2 index a6422fe..75fb0c6 100644 --- a/ansible/roles/borgmatic/templates/borgmatic.plist.j2 +++ b/ansible/roles/borgmatic/templates/borgmatic.plist.j2 @@ -14,13 +14,16 @@ </dict> <key>ProgramArguments</key> <array> - <string>{{ borgmatic_bin }}</string> + <string>/opt/homebrew/opt/mise/bin/mise</string> + <string>x</string> + <string>--</string> + <string>borgmatic</string> <string>--config</string> <string>{{ borgmatic_config }}</string> <string>create</string> </array> <key>RunAtLoad</key> - <false/> + <true/> <key>StandardErrorPath</key> <string>{{ borgmatic_log_dir }}/mcquack.borgmatic.err.log</string> <key>StandardOutPath</key> diff --git a/ansible/roles/borgmatic/templates/config.yaml.j2 b/ansible/roles/borgmatic/templates/config.yaml.j2 index 0893dbc..2e2bf0f 100644 --- a/ansible/roles/borgmatic/templates/config.yaml.j2 +++ b/ansible/roles/borgmatic/templates/config.yaml.j2 @@ -31,26 +31,6 @@ 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. -# -# Quoting/escaping is delegated to ~/bin/borgmatic-k8s-sqlite-dump -# (deployed by the borgmatic ansible role). Each entry's `target` -# is either: -# - local:<context> -> local kubectl with --context (mealie etc.) -# - ssh:<user@host> -> 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 %} - - {{ 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 %} - -ssh_command: ssh -o IdentitiesOnly=yes -i {{ borgmatic_borgbase_ssh_key_path }} - # Retention policy keep_daily: {{ borgmatic_keep_daily }} keep_monthly: {{ borgmatic_keep_monthly }} diff --git a/ansible/roles/borgmatic/templates/k8s-sqlite-dump.sh.j2 b/ansible/roles/borgmatic/templates/k8s-sqlite-dump.sh.j2 deleted file mode 100644 index 9cc24da..0000000 --- a/ansible/roles/borgmatic/templates/k8s-sqlite-dump.sh.j2 +++ /dev/null @@ -1,73 +0,0 @@ -#!/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 <target> <namespace> <selector> \ -# <db_path> <name> <dump_target> -# -# <target> is one of: -# local:<context> - run local kubectl with --context=<context> -# ssh:<user@host> - ssh to host and run k3s kubectl there -# (no indri-side kubeconfig needed) -# -# <namespace> - k8s namespace of the pod -# <selector> - label selector to find the pod (e.g. app=shower) -# <db_path> - absolute path inside the pod to the SQLite DB -# <name> - short name used for temp filenames -# <dump_target> - 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} - -# 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"'"))' - -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 <<EOF > "$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:<context> or ssh:<user@host>" >&2 - exit 1 - ;; -esac diff --git a/ansible/roles/borgmatic/templates/photos.yaml.j2 b/ansible/roles/borgmatic/templates/photos.yaml.j2 deleted file mode 100644 index 2bd0a4f..0000000 --- a/ansible/roles/borgmatic/templates/photos.yaml.j2 +++ /dev/null @@ -1,37 +0,0 @@ -# {{ ansible_managed }} -# -# Borgmatic config for immich photo library backup. -# 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) - -local_path: {{ borgmatic_local_path }} - -source_directories: -{% for dir in borgmatic_photos_source_directories %} - - {{ dir }} -{% endfor %} - -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 -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 }} -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 8fcd91e..368730a 100644 --- a/ansible/roles/borgmatic_metrics/defaults/main.yml +++ b/ansible/roles/borgmatic_metrics/defaults/main.yml @@ -1,15 +1,7 @@ --- -# 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_repo: /Volumes/backups/borg/ 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_script: /Users/erichblume/bin/borgmatic-metrics borgmatic_metrics_interval: 3600 # seconds between metric collection (hourly) borgmatic_metrics_log_dir: /opt/homebrew/var/log diff --git a/ansible/roles/borgmatic_metrics/templates/borgmatic-metrics.sh.j2 b/ansible/roles/borgmatic_metrics/templates/borgmatic-metrics.sh.j2 index b3ad605..856cbe9 100644 --- a/ansible/roles/borgmatic_metrics/templates/borgmatic-metrics.sh.j2 +++ b/ansible/roles/borgmatic_metrics/templates/borgmatic-metrics.sh.j2 @@ -1,12 +1,11 @@ #!/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 }}" -export BORG_RSH="ssh -o IdentitiesOnly=yes -i {{ borgmatic_metrics_ssh_key }}" +BORG_REPO="{{ borgmatic_metrics_repo }}" OUTPUT_FILE="{{ borgmatic_metrics_dir }}/borgmatic.prom" TEMP_FILE="${OUTPUT_FILE}.tmp" @@ -14,109 +13,129 @@ TEMP_FILE="${OUTPUT_FILE}.tmp" BORG_CMD="/opt/homebrew/bin/borg" JQ_CMD="/opt/homebrew/bin/jq" -# Start fresh -cat > "$TEMP_FILE" << 'EOF' +# 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' # 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 -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" ' + # List archive contents and group by source directory + $BORG_CMD list "${BORG_REPO}::${last_archive_name}" --format "{size} {path}{NL}" 2>/dev/null | awk ' { size = $1 path = $2 @@ -126,10 +145,8 @@ 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" } @@ -138,15 +155,10 @@ EOF } END { for (src in totals) { - printf "borgmatic_source_size_bytes{repo=\"%s\",source=\"%s\"} %.0f\n", repo, src, totals[src] + printf "borgmatic_source_size_bytes{source=\"%s\"} %.0f\n", src, totals[src] } }' >> "$TEMP_FILE" -} - -# Collect metrics for each configured repository -{% for repo in borgmatic_metrics_repos %} -collect_repo_metrics "{{ repo.path }}" "{{ repo.label }}" -{% endfor %} +fi # Atomic move mv "$TEMP_FILE" "$OUTPUT_FILE" diff --git a/ansible/roles/caddy/defaults/main.yml b/ansible/roles/caddy/defaults/main.yml deleted file mode 100644 index e6d7385..0000000 --- a/ansible/roles/caddy/defaults/main.yml +++ /dev/null @@ -1,128 +0,0 @@ ---- -# Caddy reverse proxy configuration -# 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" -caddy_config_dir: /Users/erichblume/.config/caddy -caddy_data_dir: /Users/erichblume/.local/share/caddy -caddy_log_dir: /Users/erichblume/Library/Logs - -# Gandi API token file (written by ansible, chmod 0600) -# Caddy reads this file for ACME DNS-01 challenges -caddy_gandi_token_file: /Users/erichblume/.config/caddy/gandi-token - -# Domain configuration -caddy_domain: ops.eblu.me - -# HTTPS port (443 is standard) -caddy_https_port: 443 - -# Services to proxy -# Format: { name: "service", host: "hostname", backend: "url" } -caddy_services: - # Indri-local services - - name: forge - host: "forge.{{ caddy_domain }}" - backend: "http://localhost:3001" - - name: registry - host: "registry.{{ caddy_domain }}" - backend: "http://localhost:5050" - - name: jellyfin - host: "jellyfin.{{ caddy_domain }}" - backend: "http://localhost:8096" - - # K8s services (via Tailscale Ingress) - # Caddy proxies to existing Tailscale endpoints - traffic stays local - - name: grafana - host: "grafana.{{ caddy_domain }}" - backend: "https://grafana.tail8d86e.ts.net" - - name: argocd - host: "argocd.{{ caddy_domain }}" - backend: "https://argocd.tail8d86e.ts.net" - - name: prometheus - host: "prometheus.{{ caddy_domain }}" - backend: "https://prometheus.tail8d86e.ts.net" - - name: loki - host: "loki.{{ caddy_domain }}" - backend: "https://loki.tail8d86e.ts.net" - - name: miniflux - host: "feed.{{ caddy_domain }}" - backend: "https://feed.tail8d86e.ts.net" - - 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" - - name: torrent - host: "torrent.{{ caddy_domain }}" - backend: "https://torrent.tail8d86e.ts.net" - - name: teslamate - host: "tesla.{{ caddy_domain }}" - backend: "https://tesla.tail8d86e.ts.net" - - name: immich - host: "photos.{{ caddy_domain }}" - backend: "https://photos.tail8d86e.ts.net" - - name: navidrome - host: "dj.{{ caddy_domain }}" - backend: "https://dj.tail8d86e.ts.net" - - name: homepage - host: "go.{{ caddy_domain }}" - backend: "https://go.tail8d86e.ts.net" - - name: docs - host: "docs.{{ caddy_domain }}" - kind: static - root: "{{ docs_content_dir }}" - try_html: true # Quartz: path → path/ → path.html → 404.html - - name: cv - host: "cv.{{ caddy_domain }}" - 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" - - 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" - - 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: 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" - -# Layer 4 (TCP) services -# Format: { port: external_port, backend: "host:port" } -caddy_tcp_services: - - port: 2222 - backend: "localhost:2200" # Forgejo SSH - - port: 5432 - 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 }}" - backend: "sifaka:{{ sifaka_smartctl_exporter_port }}" # Sifaka smartctl_exporter diff --git a/ansible/roles/caddy/handlers/main.yml b/ansible/roles/caddy/handlers/main.yml deleted file mode 100644 index 4f2b3d7..0000000 --- a/ansible/roles/caddy/handlers/main.yml +++ /dev/null @@ -1,6 +0,0 @@ ---- -- name: Restart caddy - ansible.builtin.shell: | - launchctl unload ~/Library/LaunchAgents/mcquack.eblume.caddy.plist 2>/dev/null || true - launchctl load ~/Library/LaunchAgents/mcquack.eblume.caddy.plist - changed_when: true diff --git a/ansible/roles/caddy/tasks/main.yml b/ansible/roles/caddy/tasks/main.yml deleted file mode 100644 index ca7067d..0000000 --- a/ansible/roles/caddy/tasks/main.yml +++ /dev/null @@ -1,80 +0,0 @@ ---- -# Caddy reverse proxy deployment -# Binary is built manually - see ~/code/3rd/caddy/mise.toml - -- name: Verify caddy binary exists - ansible.builtin.stat: - path: "{{ caddy_binary }}" - register: caddy_bin - failed_when: not caddy_bin.stat.exists - changed_when: false - -- name: Create caddy config directory - ansible.builtin.file: - path: "{{ caddy_config_dir }}" - state: directory - mode: "0755" - -- name: Create caddy data directory - ansible.builtin.file: - path: "{{ caddy_data_dir }}" - state: directory - mode: "0755" - -- name: Fetch Gandi PAT (when running with --tags caddy) - ansible.builtin.command: - cmd: op read "op://vg6xf6vvfmoh5hqjjhlhbeoaie/mco6ka3dc3rmw7zkg2dhia5d2m/pat" - delegate_to: localhost - register: _caddy_gandi_token_fallback - changed_when: false - no_log: true - check_mode: false - when: caddy_gandi_token is not defined - -- name: Set Gandi token fact (fallback) - ansible.builtin.set_fact: - caddy_gandi_token: "{{ _caddy_gandi_token_fallback.stdout }}" - no_log: true - when: caddy_gandi_token is not defined - -- name: Write Gandi token file - ansible.builtin.copy: - content: "{{ caddy_gandi_token }}" - dest: "{{ caddy_gandi_token_file }}" - mode: "0600" - no_log: true - notify: Restart caddy - -- name: Deploy Caddyfile - ansible.builtin.template: - src: Caddyfile.j2 - dest: "{{ caddy_config_dir }}/Caddyfile" - mode: "0644" - notify: Restart caddy - -- name: Deploy caddy wrapper script - ansible.builtin.template: - src: caddy-wrapper.sh.j2 - dest: "{{ caddy_config_dir }}/caddy-wrapper.sh" - mode: "0755" - notify: Restart caddy - -- name: Deploy caddy LaunchAgent plist - ansible.builtin.template: - src: caddy.plist.j2 - dest: ~/Library/LaunchAgents/mcquack.eblume.caddy.plist - mode: "0644" - notify: Restart caddy - -- name: Check if caddy LaunchAgent is loaded - ansible.builtin.command: - cmd: launchctl list mcquack.eblume.caddy - register: caddy_launchctl - changed_when: false - failed_when: false - -- name: Load caddy LaunchAgent - ansible.builtin.command: - cmd: launchctl load ~/Library/LaunchAgents/mcquack.eblume.caddy.plist - when: caddy_launchctl.rc != 0 - changed_when: true diff --git a/ansible/roles/caddy/templates/Caddyfile.j2 b/ansible/roles/caddy/templates/Caddyfile.j2 deleted file mode 100644 index f6b5f64..0000000 --- a/ansible/roles/caddy/templates/Caddyfile.j2 +++ /dev/null @@ -1,87 +0,0 @@ -# Caddy reverse proxy for blumeops services -# Managed by ansible - do not edit manually -# -# All *.{{ caddy_domain }} requests are proxied to backend services. -# TLS certificates are obtained via ACME DNS-01 challenge using Gandi. - -{ - # Global options - admin off - -{% if caddy_tcp_services %} - # Layer 4 (TCP) routing - layer4 { -{% for tcp_svc in caddy_tcp_services %} - :{{ tcp_svc.port }} { - route { - proxy {{ tcp_svc.backend }} - } - } -{% endfor %} - } -{% endif %} -} - -# Wildcard certificate for all services -*.{{ caddy_domain }}:{{ caddy_https_port }} { - tls { - dns gandi {env.GANDI_BEARER_TOKEN} - } - -{% 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) %} - # 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 %} -{% 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. - # Preserve the original Host so services see *.ops.eblu.me. - header_up Host {http.request.host} - } -{% else %} - reverse_proxy {{ service.backend }} -{% endif %} -{% endif %} - } - -{% endfor %} - # Fallback for unknown hosts - handle { - respond "Unknown service" 404 - } -} - -# Base domain (ops.eblu.me) -{{ caddy_domain }}:{{ caddy_https_port }} { - tls { - dns gandi {env.GANDI_BEARER_TOKEN} - } - - respond "blumeops services - use a subdomain (e.g., forge.{{ caddy_domain }})" -} diff --git a/ansible/roles/caddy/templates/caddy-wrapper.sh.j2 b/ansible/roles/caddy/templates/caddy-wrapper.sh.j2 deleted file mode 100644 index 72308f2..0000000 --- a/ansible/roles/caddy/templates/caddy-wrapper.sh.j2 +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/bash -# Wrapper script for Caddy that loads the Gandi token from file -# Managed by ansible - do not edit manually - -export GANDI_BEARER_TOKEN=$(cat {{ caddy_gandi_token_file }}) -exec {{ caddy_binary }} run --config {{ caddy_config_dir }}/Caddyfile diff --git a/ansible/roles/caddy/templates/caddy.plist.j2 b/ansible/roles/caddy/templates/caddy.plist.j2 deleted file mode 100644 index ec36c9e..0000000 --- a/ansible/roles/caddy/templates/caddy.plist.j2 +++ /dev/null @@ -1,36 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> -<plist version="1.0"> -<dict> - <key>Label</key> - <string>mcquack.eblume.caddy</string> - - <key>ProgramArguments</key> - <array> - <string>{{ caddy_config_dir }}/caddy-wrapper.sh</string> - </array> - - <key>WorkingDirectory</key> - <string>{{ caddy_data_dir }}</string> - - <key>EnvironmentVariables</key> - <dict> - <key>XDG_DATA_HOME</key> - <string>/Users/erichblume/.local/share</string> - <key>XDG_CONFIG_HOME</key> - <string>/Users/erichblume/.config</string> - </dict> - - <key>RunAtLoad</key> - <true/> - - <key>KeepAlive</key> - <true/> - - <key>StandardOutPath</key> - <string>{{ caddy_log_dir }}/mcquack.caddy.out.log</string> - - <key>StandardErrorPath</key> - <string>{{ caddy_log_dir }}/mcquack.caddy.err.log</string> -</dict> -</plist> diff --git a/ansible/roles/cv/defaults/main.yml b/ansible/roles/cv/defaults/main.yml deleted file mode 100644 index a18cc82..0000000 --- a/ansible/roles/cv/defaults/main.yml +++ /dev/null @@ -1,10 +0,0 @@ ---- -# 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.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" -cv_version_sentinel: "{{ cv_home }}/.installed-version" diff --git a/ansible/roles/cv/tasks/main.yml b/ansible/roles/cv/tasks/main.yml deleted file mode 100644 index c254325..0000000 --- a/ansible/roles/cv/tasks/main.yml +++ /dev/null @@ -1,57 +0,0 @@ ---- -# 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/devpi/defaults/main.yml b/ansible/roles/devpi/defaults/main.yml deleted file mode 100644 index 6d52b9b..0000000 --- a/ansible/roles/devpi/defaults/main.yml +++ /dev/null @@ -1,21 +0,0 @@ ---- -# 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 deleted file mode 100644 index 2765850..0000000 --- a/ansible/roles/devpi/handlers/main.yml +++ /dev/null @@ -1,6 +0,0 @@ ---- -- 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 deleted file mode 100644 index 985ca46..0000000 --- a/ansible/roles/devpi/tasks/main.yml +++ /dev/null @@ -1,71 +0,0 @@ ---- -# 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 deleted file mode 100644 index b9485e6..0000000 --- a/ansible/roles/devpi/templates/devpi.plist.j2 +++ /dev/null @@ -1,34 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<!-- {{ ansible_managed }} --> -<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> -<plist version="1.0"> -<dict> - <key>Label</key> - <string>mcquack.eblume.devpi</string> - <key>ProgramArguments</key> - <array> - <string>{{ devpi_binary }}</string> - <string>--serverdir</string> - <string>{{ devpi_server_dir }}</string> - <string>--host</string> - <string>{{ devpi_host }}</string> - <string>--port</string> - <string>{{ devpi_port }}</string> - <string>--outside-url</string> - <string>{{ devpi_outside_url }}</string> - </array> - <key>RunAtLoad</key> - <true/> - <key>KeepAlive</key> - <true/> - <key>EnvironmentVariables</key> - <dict> - <key>PATH</key> - <string>{{ devpi_venv }}/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string> - </dict> - <key>StandardOutPath</key> - <string>{{ devpi_log_dir }}/mcquack.devpi.out.log</string> - <key>StandardErrorPath</key> - <string>{{ devpi_log_dir }}/mcquack.devpi.err.log</string> -</dict> -</plist> diff --git a/ansible/roles/docs/defaults/main.yml b/ansible/roles/docs/defaults/main.yml deleted file mode 100644 index a5a1a8a..0000000 --- a/ansible/roles/docs/defaults/main.yml +++ /dev/null @@ -1,10 +0,0 @@ ---- -# 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.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/ansible/roles/docs/tasks/main.yml b/ansible/roles/docs/tasks/main.yml deleted file mode 100644 index dec775e..0000000 --- a/ansible/roles/docs/tasks/main.yml +++ /dev/null @@ -1,57 +0,0 @@ ---- -# 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/ansible/roles/forgejo/defaults/main.yml b/ansible/roles/forgejo/defaults/main.yml index a178d99..23396e8 100644 --- a/ansible/roles/forgejo/defaults/main.yml +++ b/ansible/roles/forgejo/defaults/main.yml @@ -4,27 +4,22 @@ forgejo_app_name: Forgejo forgejo_app_slogan: "Beyond coding. We Forge." -forgejo_run_user: erichblume +forgejo_run_user: forgejo forgejo_run_mode: prod -# 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 +# Paths (brew-managed for now, will change to mcquack in Phase 3) +forgejo_work_path: /opt/homebrew/var/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 forgejo_http_port: 3001 -forgejo_domain: forge.eblu.me -forgejo_ssh_domain: forge.ops.eblu.me +forgejo_domain: forge.tail8d86e.ts.net +forgejo_ssh_domain: "{{ forgejo_domain }}" forgejo_root_url: "https://{{ forgejo_domain }}/" forgejo_offline_mode: true @@ -32,7 +27,7 @@ forgejo_offline_mode: true forgejo_disable_ssh: false forgejo_start_ssh_server: true forgejo_builtin_ssh_user: forgejo -forgejo_ssh_port: 2222 +forgejo_ssh_port: 22 forgejo_ssh_listen_port: 2200 forgejo_lfs_start_server: true diff --git a/ansible/roles/forgejo/handlers/main.yml b/ansible/roles/forgejo/handlers/main.yml index c00a3dd..dc67cbc 100644 --- a/ansible/roles/forgejo/handlers/main.yml +++ b/ansible/roles/forgejo/handlers/main.yml @@ -1,6 +1,4 @@ --- - name: 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 + ansible.builtin.command: brew services restart forgejo changed_when: true diff --git a/ansible/roles/forgejo/tasks/main.yml b/ansible/roles/forgejo/tasks/main.yml index 7cafb12..a6d27b9 100644 --- a/ansible/roles/forgejo/tasks/main.yml +++ b/ansible/roles/forgejo/tasks/main.yml @@ -1,34 +1,16 @@ --- -# Forgejo role — source-built binary with LaunchAgent +# Forgejo role # -# 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 +# Currently uses brew-managed forgejo. Phase 3 of ci-cd-bootstrap will +# transition to mcquack LaunchAgent with CI-built binary. # # Secrets (lfs_jwt_secret, internal_token, oauth2_jwt_secret) are fetched # from 1Password in the playbook pre_tasks. -- 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: Install forgejo via homebrew + community.general.homebrew: + name: forgejo + state: present - name: Ensure forgejo config directory exists ansible.builtin.file: @@ -43,21 +25,8 @@ mode: '0600' notify: Restart forgejo -- 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 +- 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" failed_when: false diff --git a/ansible/roles/forgejo/templates/app.ini.j2 b/ansible/roles/forgejo/templates/app.ini.j2 index 9c5b4d5..ec0c396 100644 --- a/ansible/roles/forgejo/templates/app.ini.j2 +++ b/ansible/roles/forgejo/templates/app.ini.j2 @@ -20,8 +20,6 @@ SSH_LISTEN_PORT = {{ forgejo_ssh_listen_port }} LFS_START_SERVER = {{ forgejo_lfs_start_server | lower }} LFS_JWT_SECRET = {{ forgejo_lfs_jwt_secret }} OFFLINE_MODE = {{ forgejo_offline_mode | lower }} -REVERSE_PROXY_LIMIT = 2 -REVERSE_PROXY_TRUSTED_PROXIES = * [database] DB_TYPE = {{ forgejo_db_type }} @@ -42,7 +40,7 @@ ENABLED = false REGISTER_EMAIL_CONFIRM = false ENABLE_NOTIFY_MAIL = false DISABLE_REGISTRATION = {{ forgejo_disable_registration | lower }} -ALLOW_ONLY_EXTERNAL_REGISTRATION = true +ALLOW_ONLY_EXTERNAL_REGISTRATION = false ENABLE_CAPTCHA = false REQUIRE_SIGNIN_VIEW = {{ forgejo_require_signin_view | lower }} DEFAULT_KEEP_EMAIL_PRIVATE = false @@ -54,19 +52,9 @@ NO_REPLY_ADDRESS = noreply.indri ENABLE_OPENID_SIGNIN = false ENABLE_OPENID_SIGNUP = false -[mirror] -DEFAULT_INTERVAL = 8h -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 }} @@ -89,17 +77,6 @@ PASSWORD_HASH_ALGO = pbkdf2_hi [oauth2] JWT_SECRET = {{ forgejo_oauth2_jwt_secret }} -[oauth2_client] -ENABLE_AUTO_REGISTRATION = true -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/ansible/roles/forgejo/templates/forgejo.plist.j2 b/ansible/roles/forgejo/templates/forgejo.plist.j2 deleted file mode 100644 index 85d63f3..0000000 --- a/ansible/roles/forgejo/templates/forgejo.plist.j2 +++ /dev/null @@ -1,26 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<!-- {{ ansible_managed }} --> -<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> -<plist version="1.0"> -<dict> - <key>Label</key> - <string>mcquack.eblume.forgejo</string> - <key>ProgramArguments</key> - <array> - <string>{{ forgejo_binary }}</string> - <string>-w</string> - <string>{{ forgejo_work_path }}</string> - <string>-c</string> - <string>{{ forgejo_config_path }}</string> - <string>web</string> - </array> - <key>RunAtLoad</key> - <true/> - <key>KeepAlive</key> - <true/> - <key>StandardOutPath</key> - <string>{{ forgejo_log_dir }}/mcquack.forgejo.out.log</string> - <key>StandardErrorPath</key> - <string>{{ forgejo_log_dir }}/mcquack.forgejo.err.log</string> -</dict> -</plist> diff --git a/ansible/roles/forgejo_actions_secrets/defaults/main.yml b/ansible/roles/forgejo_actions_secrets/defaults/main.yml deleted file mode 100644 index 7742ecf..0000000 --- a/ansible/roles/forgejo_actions_secrets/defaults/main.yml +++ /dev/null @@ -1,24 +0,0 @@ ---- -# Forgejo Actions Secrets role configuration -# -# This role syncs repository-level Actions secrets from 1Password to Forgejo -# via the Forgejo API. - -forgejo_actions_secrets_api_url: "https://forge.eblu.me/api/v1" -forgejo_actions_secrets_owner: eblume - -# Secrets to sync per repo. -# Each entry: {repo: "name", secrets: [{name: "SECRET_NAME", value_var: "ansible_fact_name"}]} -forgejo_actions_secrets_repos: - - repo: blumeops - secrets: - - name: ARGOCD_AUTH_TOKEN - value_var: forgejo_secret_argocd_token - - name: FLY_DEPLOY_TOKEN - value_var: forgejo_secret_fly_deploy_token - - name: ZOT_CI_API_KEY - value_var: forgejo_secret_zot_ci_api_key - - repo: cv - secrets: - - name: FORGE_TOKEN - value_var: forgejo_api_token diff --git a/ansible/roles/forgejo_actions_secrets/tasks/main.yml b/ansible/roles/forgejo_actions_secrets/tasks/main.yml deleted file mode 100644 index 4508dc9..0000000 --- a/ansible/roles/forgejo_actions_secrets/tasks/main.yml +++ /dev/null @@ -1,32 +0,0 @@ ---- -# Forgejo Actions Secrets role -# -# Syncs repository-level Actions secrets from 1Password to Forgejo via API. -# -# NOTE: This role runs on indri, which is also where Forgejo runs. The API -# calls go from indri back to itself (via the public URL through Caddy). -# This is intentional - it keeps the role simple and uses the same URL -# that workflows use. -# -# Secrets (forgejo_api_token, forgejo_secret_*) are fetched from 1Password -# in the playbook pre_tasks to minimize password prompts during provisioning. - -- name: Sync Actions secrets to Forgejo - ansible.builtin.uri: - url: "{{ forgejo_actions_secrets_api_url }}/repos/{{ forgejo_actions_secrets_owner }}/{{ item.0.repo }}/actions/secrets/{{ item.1.name }}" - method: PUT - headers: - Authorization: "token {{ forgejo_api_token }}" - Content-Type: "application/json" - body_format: json - body: - data: "{{ lookup('vars', item.1.value_var) }}" - status_code: [201, 204] - register: forgejo_actions_secrets_result - # API returns 201 for create, 204 for update. We can't check if value changed - # (secrets are write-only), so only report changed when creating new secrets. - changed_when: forgejo_actions_secrets_result.status == 201 - loop: "{{ forgejo_actions_secrets_repos | subelements('secrets') }}" - loop_control: - label: "{{ item.0.repo }}/{{ item.1.name }}" - no_log: true diff --git a/ansible/roles/forgejo_metrics/defaults/main.yml b/ansible/roles/forgejo_metrics/defaults/main.yml deleted file mode 100644 index de5930b..0000000 --- a/ansible/roles/forgejo_metrics/defaults/main.yml +++ /dev/null @@ -1,20 +0,0 @@ ---- -# Forgejo metrics collection configuration - -# Forgejo server URL -forgejo_metrics_url: "http://localhost:3001" - -# Path to file containing Forgejo API token (should have 600 permissions) -forgejo_metrics_api_key_file: "/Users/erichblume/.forgejo-api-key" - -# Metrics collection interval in seconds -forgejo_metrics_interval: 60 - -# Output directory for prometheus textfile collector -forgejo_metrics_dir: /opt/homebrew/var/node_exporter/textfile - -# Script installation path -forgejo_metrics_script: /Users/erichblume/.local/bin/forgejo-metrics - -# Log directory for metrics script output -forgejo_metrics_log_dir: /opt/homebrew/var/log diff --git a/ansible/roles/forgejo_metrics/handlers/main.yml b/ansible/roles/forgejo_metrics/handlers/main.yml deleted file mode 100644 index 030d428..0000000 --- a/ansible/roles/forgejo_metrics/handlers/main.yml +++ /dev/null @@ -1,6 +0,0 @@ ---- -- name: Reload forgejo-metrics - ansible.builtin.shell: | - launchctl unload ~/Library/LaunchAgents/mcquack.eblume.forgejo-metrics.plist 2>/dev/null || true - launchctl load ~/Library/LaunchAgents/mcquack.eblume.forgejo-metrics.plist - changed_when: true diff --git a/ansible/roles/forgejo_metrics/tasks/main.yml b/ansible/roles/forgejo_metrics/tasks/main.yml deleted file mode 100644 index 7acaf9b..0000000 --- a/ansible/roles/forgejo_metrics/tasks/main.yml +++ /dev/null @@ -1,55 +0,0 @@ ---- -- name: Fetch Forgejo API token (when running with --tags forgejo_metrics) - ansible.builtin.command: - cmd: op read "op://vg6xf6vvfmoh5hqjjhlhbeoaie/w3663ffnvkewbftncqxtcpeavy/api-token" - delegate_to: localhost - register: forgejo_metrics_api_key_fallback - changed_when: false - no_log: true - check_mode: false - when: forgejo_metrics_api_key is not defined - -- name: Set Forgejo API token fact (fallback) - ansible.builtin.set_fact: - forgejo_metrics_api_key: "{{ forgejo_metrics_api_key_fallback.stdout }}" - no_log: true - when: forgejo_metrics_api_key is not defined - -- name: Write Forgejo API token file - ansible.builtin.copy: - content: "{{ forgejo_metrics_api_key }}" - dest: "{{ forgejo_metrics_api_key_file }}" - mode: '0600' - no_log: true - -- name: Ensure bin directory exists - ansible.builtin.file: - path: "{{ forgejo_metrics_script | dirname }}" - state: directory - mode: '0755' - -- name: Deploy forgejo metrics collection script - ansible.builtin.template: - src: forgejo-metrics.sh.j2 - dest: "{{ forgejo_metrics_script }}" - mode: '0755' - notify: Reload forgejo-metrics - -- name: Deploy forgejo-metrics LaunchAgent plist - ansible.builtin.template: - src: forgejo-metrics.plist.j2 - dest: ~/Library/LaunchAgents/mcquack.eblume.forgejo-metrics.plist - mode: '0644' - notify: Reload forgejo-metrics - -- name: Check if forgejo-metrics LaunchAgent is loaded - ansible.builtin.command: launchctl list mcquack.eblume.forgejo-metrics - register: forgejo_metrics_launchctl_check - changed_when: false - failed_when: false - -- name: Load forgejo-metrics LaunchAgent if not loaded - ansible.builtin.command: launchctl load ~/Library/LaunchAgents/mcquack.eblume.forgejo-metrics.plist - when: forgejo_metrics_launchctl_check.rc != 0 - changed_when: true - failed_when: false diff --git a/ansible/roles/forgejo_metrics/templates/forgejo-metrics.plist.j2 b/ansible/roles/forgejo_metrics/templates/forgejo-metrics.plist.j2 deleted file mode 100644 index 4f6cf5d..0000000 --- a/ansible/roles/forgejo_metrics/templates/forgejo-metrics.plist.j2 +++ /dev/null @@ -1,26 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<!-- {{ ansible_managed }} --> -<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> -<plist version="1.0"> -<dict> - <key>Label</key> - <string>mcquack.eblume.forgejo-metrics</string> - <key>EnvironmentVariables</key> - <dict> - <key>PATH</key> - <string>/opt/homebrew/bin:/usr/bin:/bin</string> - </dict> - <key>ProgramArguments</key> - <array> - <string>{{ forgejo_metrics_script }}</string> - </array> - <key>StartInterval</key> - <integer>{{ forgejo_metrics_interval }}</integer> - <key>RunAtLoad</key> - <true/> - <key>StandardErrorPath</key> - <string>{{ forgejo_metrics_log_dir }}/mcquack.forgejo-metrics.err.log</string> - <key>StandardOutPath</key> - <string>{{ forgejo_metrics_log_dir }}/mcquack.forgejo-metrics.out.log</string> -</dict> -</plist> diff --git a/ansible/roles/forgejo_metrics/templates/forgejo-metrics.sh.j2 b/ansible/roles/forgejo_metrics/templates/forgejo-metrics.sh.j2 deleted file mode 100644 index 365b62b..0000000 --- a/ansible/roles/forgejo_metrics/templates/forgejo-metrics.sh.j2 +++ /dev/null @@ -1,162 +0,0 @@ -#!/bin/bash -# {{ ansible_managed }} -# Collects Forgejo repository health metrics for node_exporter textfile collector - -set -euo pipefail - -FORGEJO_URL="{{ forgejo_metrics_url }}" -API_KEY_FILE="{{ forgejo_metrics_api_key_file }}" -OUTPUT_FILE="{{ forgejo_metrics_dir }}/forgejo.prom" -TEMP_FILE="${OUTPUT_FILE}.tmp" - -TOKEN=$(cat "$API_KEY_FILE" 2>/dev/null | tr -d '\n' || true) - -# Authenticated API request; returns empty string on failure -api() { - curl -sf -H "Authorization: token ${TOKEN}" -H "Accept: application/json" \ - "${FORGEJO_URL}/api/v1${1}" 2>/dev/null || echo "" -} - -# jq helper: convert ISO 8601 timestamp (with any tz offset) to epoch seconds -# jq's fromdate only handles Z, so we parse the offset and apply it manually -JQ_EPOCH='def epoch: sub("[.][0-9]+"; "") | if test("[+-][0-9]{2}:[0-9]{2}$") then capture("^(?<dt>.*)(?<sign>[+-])(?<h>[0-9]{2}):(?<m>[0-9]{2})$") | (.dt + "Z" | fromdate) as $base | ((.h | tonumber) * 3600 + (.m | tonumber) * 60) as $off | if .sign == "-" then $base + $off else $base - $off end else sub("Z$"; "") + "Z" | fromdate end;' - -forgejo_up=0 -if curl -sf "${FORGEJO_URL}/api/v1/version" >/dev/null 2>&1; then - forgejo_up=1 -fi - -{ -# --- Metric type declarations --- -cat << 'HEADER' -# HELP forgejo_up Forgejo server is up and responding -# TYPE forgejo_up gauge -# HELP forgejo_repo_open_pull_requests Number of open pull requests -# TYPE forgejo_repo_open_pull_requests gauge -# HELP forgejo_repo_open_issues Number of open issues -# TYPE forgejo_repo_open_issues gauge -# HELP forgejo_repo_language_bytes Repository language size in bytes -# TYPE forgejo_repo_language_bytes gauge -# HELP forgejo_repo_releases_total Total number of releases -# TYPE forgejo_repo_releases_total gauge -# HELP forgejo_repo_latest_release_timestamp_seconds Unix timestamp of the latest release -# TYPE forgejo_repo_latest_release_timestamp_seconds gauge -# HELP forgejo_repo_latest_commit_timestamp_seconds Unix timestamp of the latest commit on default branch -# TYPE forgejo_repo_latest_commit_timestamp_seconds gauge -# HELP forgejo_actions_runs_total Action runs by status from most recent 30 -# TYPE forgejo_actions_runs_total gauge -# HELP forgejo_actions_run_duration_seconds Duration of the latest completed run per workflow in seconds -# TYPE forgejo_actions_run_duration_seconds gauge -# HELP forgejo_actions_last_success_timestamp_seconds Unix timestamp of last successful run per workflow -# TYPE forgejo_actions_last_success_timestamp_seconds gauge -# HELP forgejo_actions_jobs_waiting Number of action runs currently waiting or queued -# TYPE forgejo_actions_jobs_waiting gauge -# HELP forgejo_actions_jobs_running Number of action runs currently in progress -# TYPE forgejo_actions_jobs_running gauge -HEADER - -echo "forgejo_up ${forgejo_up}" - -if [ "$forgejo_up" -eq 1 ] && [ -n "$TOKEN" ]; then - # Discover all repos accessible to the token owner - repos_json=$(api "/repos/search?limit=50") - [ -z "$repos_json" ] && repos_json='{"data":[]}' - - repo_count=$(echo "$repos_json" | jq '.data | length' 2>/dev/null || echo "0") - - for i in $(seq 0 $((repo_count - 1))); do - repo_data=$(echo "$repos_json" | jq ".data[$i]") - full_name=$(echo "$repo_data" | jq -r '.full_name') - [ -z "$full_name" ] || [ "$full_name" = "null" ] && continue - - r="$full_name" - - # Basic repo metrics (from search results — no extra API call) - echo "forgejo_repo_open_pull_requests{repo=\"${r}\"} $(echo "$repo_data" | jq '.open_pr_counter // 0')" - echo "forgejo_repo_open_issues{repo=\"${r}\"} $(echo "$repo_data" | jq '.open_issues_count // 0')" - - default_branch=$(echo "$repo_data" | jq -r '.default_branch // "main"') - - # --- Languages --- - langs=$(api "/repos/${r}/languages") - if [ -n "$langs" ] && echo "$langs" | jq -e 'type == "object" and length > 0' >/dev/null 2>&1; then - echo "$langs" | jq -r --arg r "$r" \ - 'to_entries[] | "forgejo_repo_language_bytes{repo=\"\($r)\",language=\"\(.key)\"} \(.value)"' \ - 2>/dev/null || true - fi - - # --- Releases --- - releases=$(api "/repos/${r}/releases?limit=50") - if [ -n "$releases" ] && echo "$releases" | jq -e 'type == "array"' >/dev/null 2>&1; then - echo "forgejo_repo_releases_total{repo=\"${r}\"} $(echo "$releases" | jq 'length')" - # Latest release timestamp and version - echo "$releases" | jq -r --arg r "$r" "${JQ_EPOCH}"' - if length > 0 then - .[0] | - "forgejo_repo_latest_release_timestamp_seconds{repo=\"\($r)\",version=\"\(.tag_name)\"} \((.published_at // .created_at // .created) | epoch)" - else empty end' 2>/dev/null || true - else - echo "forgejo_repo_releases_total{repo=\"${r}\"} 0" - fi - - # --- Latest commit on default branch --- - commits=$(api "/repos/${r}/commits?limit=1&sha=${default_branch}") - if [ -n "$commits" ] && echo "$commits" | jq -e 'type == "array" and length > 0' >/dev/null 2>&1; then - echo "$commits" | jq -r --arg r "$r" "${JQ_EPOCH}"' - .[0] | - "forgejo_repo_latest_commit_timestamp_seconds{repo=\"\($r)\"} \((.created // .commit.committer.date) | epoch)"' \ - 2>/dev/null || true - fi - - # --- Action runs --- - runs_json=$(api "/repos/${r}/actions/runs?limit=30") - if [ -n "$runs_json" ] && echo "$runs_json" | jq -e '.workflow_runs | type == "array"' >/dev/null 2>&1; then - # Count by status - echo "$runs_json" | jq -r --arg r "$r" ' - .workflow_runs | group_by(.status) | .[] | - "forgejo_actions_runs_total{repo=\"\($r)\",status=\"\(.[0].status)\"} \(length)"' \ - 2>/dev/null || true - - # Jobs waiting/running - waiting=$(echo "$runs_json" | jq '[.workflow_runs[] | select(.status == "waiting" or .status == "queued")] | length' 2>/dev/null || echo "0") - running=$(echo "$runs_json" | jq '[.workflow_runs[] | select(.status == "running")] | length' 2>/dev/null || echo "0") - echo "forgejo_actions_jobs_waiting{repo=\"${r}\"} ${waiting}" - echo "forgejo_actions_jobs_running{repo=\"${r}\"} ${running}" - - # Discover current workflow files on the default branch (.forgejo/ or .github/) - current_wfs="" - for wf_dir in .forgejo/workflows .github/workflows; do - wf_list=$(api "/repos/${r}/contents/${wf_dir}?ref=${default_branch}") - if [ -n "$wf_list" ] && echo "$wf_list" | jq -e 'type == "array"' >/dev/null 2>&1; then - current_wfs=$(echo "$wf_list" | jq -r '[.[].name] | join(",")' 2>/dev/null || true) - break - fi - done - - # Per-workflow: latest completed run duration and last success timestamp - # Only include workflows that currently exist on the default branch - # Forgejo fields: workflow_id (filename), created/stopped, duration (nanoseconds) - if [ -n "$current_wfs" ]; then - echo "$runs_json" | jq -r --arg r "$r" --arg wfs "$current_wfs" "${JQ_EPOCH}"' - ($wfs | split(",")) as $current | - [.workflow_runs[] | select((.status == "success" or .status == "failure") and (.workflow_id | IN($current[])))] | - if length > 0 then - group_by(.workflow_id) | .[] | - (sort_by(.created) | reverse) as $sorted | - ($sorted[0]) as $latest | - ($latest.workflow_id | sub("[.]ya?ml$"; "")) as $wf | - "forgejo_actions_run_duration_seconds{repo=\"\($r)\",workflow=\"\($wf)\"} \(($latest.duration // 0) / 1000000000 | floor)", - ([$sorted[] | select(.status == "success")] | - if length > 0 then - .[0] as $last_ok | - "forgejo_actions_last_success_timestamp_seconds{repo=\"\($r)\",workflow=\"\($wf)\"} \($last_ok.stopped | epoch)" - else empty end) - else empty end' 2>/dev/null || true - fi - fi - done -fi -} > "$TEMP_FILE" - -# Atomic move -mv "$TEMP_FILE" "$OUTPUT_FILE" diff --git a/ansible/roles/heph/defaults/main.yml b/ansible/roles/heph/defaults/main.yml deleted file mode 100644 index 88d2240..0000000 --- a/ansible/roles/heph/defaults/main.yml +++ /dev/null @@ -1,49 +0,0 @@ ---- -# hephaestus hub — the canonical heph replica (server mode) on indri. -# Other devices (e.g. gilbert) are spokes that sync against this hub. -# See [[set-up-sync-hub]] and [[host-heph-pwa]] in the hephaestus repo. - -# Pinned release used for the initial `cargo install` and the PWA shell. -# After bootstrap, hephd's own --self-update keeps the binary current; this -# pin only governs the first install and the bundled PWA shell version. -heph_version: v1.2.1 - -# Anonymous public HTTPS clone — matches hephd's INSTALL_GIT_URL so the initial -# install and unattended self-update build from the same source (no ssh-agent). -heph_repo_url: https://forge.eblu.me/eblume/hephaestus.git - -heph_bin_dir: /Users/erichblume/.cargo/bin -heph_binary: "{{ heph_bin_dir }}/hephd" - -# rustc/cargo here are rustup shims. The bare (non-mise) environment that the -# launchagent and ansible run in falls back to rustup's *default* toolchain, -# which can lag behind heph's rust-version floor (Cargo.toml: 1.89). Pin the -# channel explicitly so both the bootstrap build and unattended self-update -# always use a current toolchain regardless of the host's rustup default. -heph_rust_toolchain: stable - -heph_data_dir: /Users/erichblume/.local/share/heph -heph_db: "{{ heph_data_dir }}/heph.db" -heph_socket: "{{ heph_data_dir }}/hephd.sock" -heph_log_dir: /Users/erichblume/Library/Logs - -# Version-pinned source checkout; the PWA static shell is served directly from -# its heph-pwa/ subdir (no copy), keeping shell and hub in lockstep at heph_version. -heph_pwa_src_dir: /Users/erichblume/.cache/heph-pwa-src -heph_web_root: "{{ heph_pwa_src_dir }}/heph-pwa" - -# Hub listens on all interfaces so tailnet spokes can reach it directly -# (http://indri.tail8d86e.ts.net:8787) and Caddy can proxy heph.ops.eblu.me. -# Access is gated by Authentik OIDC regardless — tailnet reachability is not -# enough (this is the owner's most sensitive data). -heph_http_addr: 0.0.0.0:8787 -heph_port: 8787 -heph_external_url: https://heph.ops.eblu.me - -# Authentik OIDC — issuer + audience together turn hub auth on. The audience is -# the device-code client id (see argocd/manifests/authentik heph blueprint). -heph_oidc_issuer: https://authentik.ops.eblu.me/application/o/heph/ -heph_oidc_audience: heph - -# Self-update poll interval (seconds). 10 minutes. -heph_self_update_interval_secs: 600 diff --git a/ansible/roles/heph/handlers/main.yml b/ansible/roles/heph/handlers/main.yml deleted file mode 100644 index 92fe9d7..0000000 --- a/ansible/roles/heph/handlers/main.yml +++ /dev/null @@ -1,6 +0,0 @@ ---- -- name: Restart heph - ansible.builtin.shell: | - launchctl unload ~/Library/LaunchAgents/mcquack.eblume.heph.plist 2>/dev/null || true - launchctl load ~/Library/LaunchAgents/mcquack.eblume.heph.plist - changed_when: true diff --git a/ansible/roles/heph/tasks/main.yml b/ansible/roles/heph/tasks/main.yml deleted file mode 100644 index 7a45fe3..0000000 --- a/ansible/roles/heph/tasks/main.yml +++ /dev/null @@ -1,82 +0,0 @@ ---- -# hephaestus hub (server mode) on indri. -# -# DATA SEEDING (one-time, Path A — do this BEFORE the first provision so the hub -# adopts gilbert's existing data instead of being born empty): -# -# 1. On the seed device (gilbert): heph daemon stop -# 2. Copy its store to indri: scp ~/.local/share/heph/heph.db \ -# indri:~/.local/share/heph/heph.db -# 3. On indri, give the hub its OWN device origin (keeps gilbert's owner_id + -# data; hephd regenerates a fresh origin on next start when it is missing): -# sqlite3 ~/.local/share/heph/heph.db "DELETE FROM meta WHERE key='origin';" -# 4. Run this role (installs hephd, stages the PWA, loads the launchagent). -# -# hephd auto-creates an empty store on first start if none exists, so seeding is -# optional — skip it only if you intend a fresh, empty hub. - -- name: Ensure heph data directory exists - ansible.builtin.file: - path: "{{ heph_data_dir }}" - state: directory - mode: '0700' - -- name: Check for installed hephd binary - ansible.builtin.stat: - path: "{{ heph_binary }}" - register: heph_binary_stat - -# Bootstrap install only when hephd is absent. Thereafter hephd's own -# --self-update keeps it current; ansible must not fight (or downgrade) it. -# This builds from source and can take several minutes on a cold cargo cache. -- name: Bootstrap-install heph + hephd from the forge ({{ heph_version }}) - ansible.builtin.command: - cmd: >- - {{ heph_bin_dir }}/cargo install --locked - --git {{ heph_repo_url }} - --tag {{ heph_version }} - heph hephd - environment: - PATH: "{{ heph_bin_dir }}:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" - RUSTUP_TOOLCHAIN: "{{ heph_rust_toolchain }}" - when: not heph_binary_stat.stat.exists - changed_when: true - notify: Restart heph - -# Checkout provides the PWA shell at {{ heph_web_root }} (heph-pwa/ subdir), -# served directly by hephd. Static files are read from disk per request, so a -# version bump needs no restart; the service worker (CACHE = "heph-pwa-vN") -# evicts stale assets on next load. -- name: Ensure heph cache parent directory exists - ansible.builtin.file: - path: "{{ heph_pwa_src_dir | dirname }}" - state: directory - mode: '0755' - -- name: Stage heph-pwa source at {{ heph_version }} - ansible.builtin.git: - repo: "{{ heph_repo_url }}" - dest: "{{ heph_pwa_src_dir }}" - version: "{{ heph_version }}" - depth: 1 - single_branch: true - force: true - -- name: Deploy heph LaunchAgent plist - ansible.builtin.template: - src: heph.plist.j2 - dest: ~/Library/LaunchAgents/mcquack.eblume.heph.plist - mode: '0644' - notify: Restart heph - -- name: Check if heph LaunchAgent is loaded - ansible.builtin.command: launchctl list mcquack.eblume.heph - register: heph_launchctl_check - changed_when: false - failed_when: false - -- name: Load heph LaunchAgent if not loaded - ansible.builtin.command: launchctl load ~/Library/LaunchAgents/mcquack.eblume.heph.plist - when: heph_launchctl_check.rc != 0 - changed_when: true - failed_when: false diff --git a/ansible/roles/heph/templates/heph.plist.j2 b/ansible/roles/heph/templates/heph.plist.j2 deleted file mode 100644 index 19a2367..0000000 --- a/ansible/roles/heph/templates/heph.plist.j2 +++ /dev/null @@ -1,50 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<!-- {{ ansible_managed }} --> -<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> -<plist version="1.0"> -<dict> - <key>Label</key> - <string>mcquack.eblume.heph</string> - <key>ProgramArguments</key> - <array> - <string>{{ heph_binary }}</string> - <string>--mode</string> - <string>server</string> - <string>--http-addr</string> - <string>{{ heph_http_addr }}</string> - <string>--db</string> - <string>{{ heph_db }}</string> - <string>--socket</string> - <string>{{ heph_socket }}</string> - <string>--web-root</string> - <string>{{ heph_web_root }}</string> - <string>--oidc-issuer</string> - <string>{{ heph_oidc_issuer }}</string> - <string>--oidc-audience</string> - <string>{{ heph_oidc_audience }}</string> - <string>--self-update</string> - <string>--self-update-interval-secs</string> - <string>{{ heph_self_update_interval_secs }}</string> - </array> - <key>RunAtLoad</key> - <true/> - <key>KeepAlive</key> - <true/> - <key>EnvironmentVariables</key> - <dict> - <!-- cargo + toolchain on PATH so --self-update can run `cargo install`. --> - <key>PATH</key> - <string>{{ heph_bin_dir }}:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string> - <key>HOME</key> - <string>/Users/erichblume</string> - <!-- Pin the rustup channel: the launchagent runs without mise, so a bare - cargo shim would otherwise use rustup's (stale) default toolchain. --> - <key>RUSTUP_TOOLCHAIN</key> - <string>{{ heph_rust_toolchain }}</string> - </dict> - <key>StandardOutPath</key> - <string>{{ heph_log_dir }}/mcquack.heph.out.log</string> - <key>StandardErrorPath</key> - <string>{{ heph_log_dir }}/mcquack.heph.err.log</string> -</dict> -</plist> diff --git a/ansible/roles/jellyfin/defaults/main.yml b/ansible/roles/jellyfin/defaults/main.yml deleted file mode 100644 index 380e625..0000000 --- a/ansible/roles/jellyfin/defaults/main.yml +++ /dev/null @@ -1,30 +0,0 @@ ---- -# Jellyfin media server configuration - -# Port Jellyfin listens on -jellyfin_port: 8096 - -# Data directory (standard macOS location) -jellyfin_data_dir: "{{ ansible_env.HOME }}/Library/Application Support/jellyfin" - -# Media path (NFS mount from sifaka) -jellyfin_media_path: /Volumes/allisonflix - -# Homebrew cask application path -jellyfin_cask_app_path: /Applications/Jellyfin.app - -# Binary path inside the cask app -jellyfin_binary: "{{ jellyfin_cask_app_path }}/Contents/MacOS/jellyfin" - -# Web client path (different from binary location in Homebrew cask) -jellyfin_webdir: "{{ jellyfin_cask_app_path }}/Contents/Resources/jellyfin-web" - -# Log directory -jellyfin_log_dir: "{{ ansible_env.HOME }}/Library/Logs" - -# SSO plugin configuration -jellyfin_sso_plugin_version: "4.0.0.3" -jellyfin_sso_client_id: jellyfin -jellyfin_sso_client_secret: "" -jellyfin_sso_provider_name: authentik -jellyfin_plugins_dir: "{{ jellyfin_data_dir }}/plugins" diff --git a/ansible/roles/jellyfin/handlers/main.yml b/ansible/roles/jellyfin/handlers/main.yml deleted file mode 100644 index 410ec82..0000000 --- a/ansible/roles/jellyfin/handlers/main.yml +++ /dev/null @@ -1,6 +0,0 @@ ---- -- name: Reload jellyfin - ansible.builtin.shell: | - launchctl unload ~/Library/LaunchAgents/mcquack.jellyfin.plist 2>/dev/null || true - launchctl load ~/Library/LaunchAgents/mcquack.jellyfin.plist - changed_when: true diff --git a/ansible/roles/jellyfin/tasks/main.yml b/ansible/roles/jellyfin/tasks/main.yml deleted file mode 100644 index a588a72..0000000 --- a/ansible/roles/jellyfin/tasks/main.yml +++ /dev/null @@ -1,77 +0,0 @@ ---- -- name: Install Jellyfin via Homebrew cask - community.general.homebrew_cask: - name: jellyfin - state: present - -- name: Ensure Jellyfin data directory exists - ansible.builtin.file: - path: "{{ jellyfin_data_dir }}" - state: directory - mode: '0755' - -- name: Deploy Jellyfin LaunchAgent plist - ansible.builtin.template: - src: mcquack.jellyfin.plist.j2 - dest: ~/Library/LaunchAgents/mcquack.jellyfin.plist - mode: '0644' - notify: Reload jellyfin - -- name: Check if Jellyfin LaunchAgent is loaded - ansible.builtin.command: launchctl list mcquack.jellyfin - register: jellyfin_launchctl_check - changed_when: false - failed_when: false - -- name: Load Jellyfin LaunchAgent if not loaded - ansible.builtin.command: launchctl load ~/Library/LaunchAgents/mcquack.jellyfin.plist - when: jellyfin_launchctl_check.rc != 0 - changed_when: true - failed_when: false - -# SSO plugin installation -- name: Ensure SSO-Auth plugin directory exists - ansible.builtin.file: - path: "{{ jellyfin_plugins_dir }}/SSO-Auth_{{ jellyfin_sso_plugin_version }}" - state: directory - mode: '0755' - -- name: Download SSO-Auth plugin archive - ansible.builtin.get_url: - url: "https://github.com/9p4/jellyfin-plugin-sso/releases/download/v{{ jellyfin_sso_plugin_version }}/sso-authentication_{{ jellyfin_sso_plugin_version }}.zip" - dest: "/tmp/sso-authentication_{{ jellyfin_sso_plugin_version }}.zip" - mode: '0644' - -- name: Extract SSO-Auth plugin - ansible.builtin.unarchive: - src: "/tmp/sso-authentication_{{ jellyfin_sso_plugin_version }}.zip" - dest: "{{ jellyfin_plugins_dir }}/SSO-Auth_{{ jellyfin_sso_plugin_version }}" - remote_src: true - notify: Reload jellyfin - -- name: Ensure plugin configurations directory exists - ansible.builtin.file: - path: "{{ jellyfin_plugins_dir }}/configurations" - state: directory - mode: '0755' - -- name: Deploy SSO-Auth plugin configuration - ansible.builtin.template: - src: sso-auth.xml.j2 - dest: "{{ jellyfin_plugins_dir }}/configurations/SSO-Auth.xml" - mode: '0644' - notify: Reload jellyfin - -# Branding — add SSO login button to login page -- name: Ensure Jellyfin config directory exists - ansible.builtin.file: - path: "{{ jellyfin_data_dir }}/config" - state: directory - mode: '0755' - -- name: Deploy Jellyfin branding configuration - ansible.builtin.template: - src: branding.xml.j2 - dest: "{{ jellyfin_data_dir }}/config/branding.xml" - mode: '0644' - notify: Reload jellyfin diff --git a/ansible/roles/jellyfin/templates/branding.xml.j2 b/ansible/roles/jellyfin/templates/branding.xml.j2 deleted file mode 100644 index 26aa6cb..0000000 --- a/ansible/roles/jellyfin/templates/branding.xml.j2 +++ /dev/null @@ -1,7 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- {{ ansible_managed }} --> -<BrandingOptions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema"> - <LoginDisclaimer><form action="/sso/OID/start/{{ jellyfin_sso_provider_name }}"><button class="raised block emby-button button-submit" type="submit" style="margin:2em 0">Sign in with Authentik</button></form></LoginDisclaimer> - <CustomCss /> - <SplashscreenEnabled>false</SplashscreenEnabled> -</BrandingOptions> diff --git a/ansible/roles/jellyfin/templates/mcquack.jellyfin.plist.j2 b/ansible/roles/jellyfin/templates/mcquack.jellyfin.plist.j2 deleted file mode 100644 index e39e028..0000000 --- a/ansible/roles/jellyfin/templates/mcquack.jellyfin.plist.j2 +++ /dev/null @@ -1,33 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<!-- {{ ansible_managed }} --> -<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> -<plist version="1.0"> -<dict> - <key>Label</key> - <string>mcquack.jellyfin</string> - <key>EnvironmentVariables</key> - <dict> - <key>PATH</key> - <string>/opt/homebrew/bin:/usr/bin:/bin</string> - </dict> - <key>ProgramArguments</key> - <array> - <string>{{ jellyfin_binary }}</string> - <string>--service</string> - <string>--datadir</string> - <string>{{ jellyfin_data_dir }}</string> - <string>--webdir</string> - <string>{{ jellyfin_webdir }}</string> - </array> - <key>WorkingDirectory</key> - <string>{{ jellyfin_data_dir }}</string> - <key>RunAtLoad</key> - <true/> - <key>KeepAlive</key> - <true/> - <key>StandardErrorPath</key> - <string>{{ jellyfin_log_dir }}/mcquack.jellyfin.err.log</string> - <key>StandardOutPath</key> - <string>{{ jellyfin_log_dir }}/mcquack.jellyfin.out.log</string> -</dict> -</plist> diff --git a/ansible/roles/jellyfin/templates/sso-auth.xml.j2 b/ansible/roles/jellyfin/templates/sso-auth.xml.j2 deleted file mode 100644 index 8e8bd76..0000000 --- a/ansible/roles/jellyfin/templates/sso-auth.xml.j2 +++ /dev/null @@ -1,33 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- {{ ansible_managed }} --> -<PluginConfiguration xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema"> - <SamlConfigs /> - <OidConfigs> - <item> - <key><string>{{ jellyfin_sso_provider_name }}</string></key> - <value> - <PluginConfiguration> - <OidEndpoint>https://authentik.ops.eblu.me/application/o/jellyfin</OidEndpoint> - <OidClientId>{{ jellyfin_sso_client_id }}</OidClientId> - <OidSecret>{{ jellyfin_sso_client_secret }}</OidSecret> - <Enabled>true</Enabled> - <EnableAuthorization>true</EnableAuthorization> - <EnableAllFolders>true</EnableAllFolders> - <EnabledFolders /> - <AdminRoles><string>admins</string></AdminRoles> - <Roles /> - <EnableFolderRoles>false</EnableFolderRoles> - <FolderRoleMappings /> - <RoleClaim>groups</RoleClaim> - <OidScopes> - <string>openid</string> - <string>email</string> - <string>profile</string> - </OidScopes> - <SchemeOverride>https</SchemeOverride> - <CanonicalLinks /> - </PluginConfiguration> - </value> - </item> - </OidConfigs> -</PluginConfiguration> diff --git a/ansible/roles/jellyfin_metrics/defaults/main.yml b/ansible/roles/jellyfin_metrics/defaults/main.yml deleted file mode 100644 index 8b3e8f1..0000000 --- a/ansible/roles/jellyfin_metrics/defaults/main.yml +++ /dev/null @@ -1,20 +0,0 @@ ---- -# Jellyfin metrics collection configuration - -# Jellyfin server URL -jellyfin_metrics_url: "http://localhost:8096" - -# Path to file containing Jellyfin API key (should have 600 permissions) -jellyfin_metrics_api_key_file: "/Users/erichblume/.jellyfin-api-key" - -# Metrics collection interval in seconds -jellyfin_metrics_interval: 60 - -# Output directory for prometheus textfile collector -jellyfin_metrics_dir: /opt/homebrew/var/node_exporter/textfile - -# Script installation path -jellyfin_metrics_script: /Users/erichblume/.local/bin/jellyfin-metrics - -# Log directory for metrics script output -jellyfin_metrics_log_dir: /opt/homebrew/var/log diff --git a/ansible/roles/jellyfin_metrics/handlers/main.yml b/ansible/roles/jellyfin_metrics/handlers/main.yml deleted file mode 100644 index 8921fe3..0000000 --- a/ansible/roles/jellyfin_metrics/handlers/main.yml +++ /dev/null @@ -1,6 +0,0 @@ ---- -- name: Reload jellyfin-metrics - ansible.builtin.shell: | - launchctl unload ~/Library/LaunchAgents/mcquack.eblume.jellyfin-metrics.plist 2>/dev/null || true - launchctl load ~/Library/LaunchAgents/mcquack.eblume.jellyfin-metrics.plist - changed_when: true diff --git a/ansible/roles/jellyfin_metrics/tasks/main.yml b/ansible/roles/jellyfin_metrics/tasks/main.yml deleted file mode 100644 index f7ecb31..0000000 --- a/ansible/roles/jellyfin_metrics/tasks/main.yml +++ /dev/null @@ -1,55 +0,0 @@ ---- -- name: Fetch Jellyfin API key (when running with --tags jellyfin_metrics) - ansible.builtin.command: - cmd: op read "op://vg6xf6vvfmoh5hqjjhlhbeoaie/ceywxkcd3z7najsy2nmmbs2vke/credential" - delegate_to: localhost - register: jellyfin_metrics_api_key_fallback - changed_when: false - no_log: true - check_mode: false - when: jellyfin_metrics_api_key is not defined - -- name: Set Jellyfin API key fact (fallback) - ansible.builtin.set_fact: - jellyfin_metrics_api_key: "{{ jellyfin_metrics_api_key_fallback.stdout }}" - no_log: true - when: jellyfin_metrics_api_key is not defined - -- name: Write Jellyfin API key file - ansible.builtin.copy: - content: "{{ jellyfin_metrics_api_key }}" - dest: "{{ jellyfin_metrics_api_key_file }}" - mode: '0600' - no_log: true - -- name: Ensure bin directory exists - ansible.builtin.file: - path: "{{ jellyfin_metrics_script | dirname }}" - state: directory - mode: '0755' - -- name: Deploy jellyfin metrics collection script - ansible.builtin.template: - src: jellyfin-metrics.sh.j2 - dest: "{{ jellyfin_metrics_script }}" - mode: '0755' - notify: Reload jellyfin-metrics - -- name: Deploy jellyfin-metrics LaunchAgent plist - ansible.builtin.template: - src: jellyfin-metrics.plist.j2 - dest: ~/Library/LaunchAgents/mcquack.eblume.jellyfin-metrics.plist - mode: '0644' - notify: Reload jellyfin-metrics - -- name: Check if jellyfin-metrics LaunchAgent is loaded - ansible.builtin.command: launchctl list mcquack.eblume.jellyfin-metrics - register: jellyfin_metrics_launchctl_check - changed_when: false - failed_when: false - -- name: Load jellyfin-metrics LaunchAgent if not loaded - ansible.builtin.command: launchctl load ~/Library/LaunchAgents/mcquack.eblume.jellyfin-metrics.plist - when: jellyfin_metrics_launchctl_check.rc != 0 - changed_when: true - failed_when: false diff --git a/ansible/roles/jellyfin_metrics/templates/jellyfin-metrics.sh.j2 b/ansible/roles/jellyfin_metrics/templates/jellyfin-metrics.sh.j2 deleted file mode 100644 index 0f8b0f5..0000000 --- a/ansible/roles/jellyfin_metrics/templates/jellyfin-metrics.sh.j2 +++ /dev/null @@ -1,137 +0,0 @@ -#!/bin/bash -# {{ ansible_managed }} -# Collects Jellyfin Media Server metrics for node_exporter textfile collector - -set -euo pipefail - -JELLYFIN_URL="{{ jellyfin_metrics_url }}" -API_KEY_FILE="{{ jellyfin_metrics_api_key_file }}" -OUTPUT_FILE="{{ jellyfin_metrics_dir }}/jellyfin.prom" -TEMP_FILE="${OUTPUT_FILE}.tmp" - -# Read API key from file -get_api_key() { - if [ -f "$API_KEY_FILE" ]; then - cat "$API_KEY_FILE" | tr -d '\n' - else - echo "" - fi -} - -# Make API request with optional API key -api_request() { - local endpoint="$1" - local use_auth="${2:-true}" - local api_key - local url="${JELLYFIN_URL}${endpoint}" - - if [ "$use_auth" = "true" ]; then - api_key=$(get_api_key) - if [ -n "$api_key" ]; then - curl -s -H "Accept: application/json" -H "X-Emby-Token: $api_key" "$url" 2>/dev/null - else - curl -s -H "Accept: application/json" "$url" 2>/dev/null - fi - else - curl -s -H "Accept: application/json" "$url" 2>/dev/null - fi -} - -# Initialize metrics -jellyfin_up=0 -jellyfin_version="" -jellyfin_sessions_total=0 -jellyfin_sessions_playing=0 -jellyfin_sessions_paused=0 -jellyfin_transcode_sessions_total=0 - -# Library metrics will be built dynamically -library_metrics="" - -# Check server health (no auth required) -health=$(api_request "/health" false) -if [ "$health" = "Healthy" ]; then - jellyfin_up=1 -fi - -# Get system info for version (requires auth) -if [ "$jellyfin_up" -eq 1 ] && [ -f "$API_KEY_FILE" ]; then - system_info=$(api_request "/System/Info") - if [ -n "$system_info" ]; then - jellyfin_version=$(echo "$system_info" | jq -r '.Version // ""') - fi - - # Get library counts (virtual folders) - libraries=$(api_request "/Library/VirtualFolders") - if [ -n "$libraries" ] && echo "$libraries" | jq -e '.' > /dev/null 2>&1; then - # Process each library - while IFS=$'\t' read -r lib_name lib_type lib_id; do - if [ -n "$lib_name" ] && [ -n "$lib_type" ]; then - # Get item count for this library - # Map collection type to item type for counting - case "$lib_type" in - movies) item_type="Movie" ;; - tvshows) item_type="Series" ;; - music) item_type="MusicAlbum" ;; - *) item_type="" ;; - esac - - if [ -n "$item_type" ] && [ -n "$lib_id" ]; then - items=$(api_request "/Items?parentId=${lib_id}&recursive=true&includeItemTypes=${item_type}&limit=0") - item_count=$(echo "$items" | jq -r '.TotalRecordCount // 0' 2>/dev/null || echo "0") - library_metrics="${library_metrics}jellyfin_library_items{library=\"${lib_name}\",type=\"${lib_type}\"} ${item_count} -" - fi - fi - done < <(echo "$libraries" | jq -r '.[] | [.Name, .CollectionType, .ItemId] | @tsv' 2>/dev/null || true) - fi - - # Get active sessions - sessions=$(api_request "/Sessions") - if [ -n "$sessions" ] && echo "$sessions" | jq -e '.' > /dev/null 2>&1; then - jellyfin_sessions_total=$(echo "$sessions" | jq -r 'length') - - # Count playing sessions (NowPlayingItem is present and IsPaused is false) - jellyfin_sessions_playing=$(echo "$sessions" | jq -r '[.[] | select(.NowPlayingItem != null and .PlayState.IsPaused == false)] | length') - - # Count paused sessions - jellyfin_sessions_paused=$(echo "$sessions" | jq -r '[.[] | select(.NowPlayingItem != null and .PlayState.IsPaused == true)] | length') - - # Count transcode sessions (TranscodingInfo is present) - jellyfin_transcode_sessions_total=$(echo "$sessions" | jq -r '[.[] | select(.TranscodingInfo != null)] | length') - fi -fi - -# Write metrics -cat > "$TEMP_FILE" << EOF -# HELP jellyfin_up Jellyfin Media Server is up and responding -# TYPE jellyfin_up gauge -jellyfin_up ${jellyfin_up} - -# HELP jellyfin_version_info Jellyfin Media Server version information -# TYPE jellyfin_version_info gauge -jellyfin_version_info{version="${jellyfin_version}"} 1 - -# HELP jellyfin_sessions_total Total number of active Jellyfin sessions -# TYPE jellyfin_sessions_total gauge -jellyfin_sessions_total ${jellyfin_sessions_total} - -# HELP jellyfin_sessions_playing Number of sessions currently playing -# TYPE jellyfin_sessions_playing gauge -jellyfin_sessions_playing ${jellyfin_sessions_playing} - -# HELP jellyfin_sessions_paused Number of sessions currently paused -# TYPE jellyfin_sessions_paused gauge -jellyfin_sessions_paused ${jellyfin_sessions_paused} - -# HELP jellyfin_transcode_sessions_total Number of sessions being transcoded -# TYPE jellyfin_transcode_sessions_total gauge -jellyfin_transcode_sessions_total ${jellyfin_transcode_sessions_total} - -# HELP jellyfin_library_items Number of items in each Jellyfin library -# TYPE jellyfin_library_items gauge -${library_metrics} -EOF - -# Atomic move -mv "$TEMP_FILE" "$OUTPUT_FILE" diff --git a/ansible/roles/minikube/tasks/main.yml b/ansible/roles/minikube/tasks/main.yml index e79f4de..9e9fdd3 100644 --- a/ansible/roles/minikube/tasks/main.yml +++ b/ansible/roles/minikube/tasks/main.yml @@ -37,9 +37,9 @@ msg: "WARNING: Docker does not appear to be running. Please start Docker Desktop manually." when: minikube_docker_status.rc != 0 -- name: Check minikube cluster status +- name: Check if minikube cluster exists ansible.builtin.command: - cmd: minikube status + cmd: minikube status --format={% raw %}'{{.Host}}'{% endraw %} register: minikube_status changed_when: false failed_when: false @@ -63,11 +63,11 @@ failed_when: false # Don't fail - may need manual intervention when: - minikube_docker_status.rc == 0 - - minikube_status.rc != 0 + - minikube_status.rc != 0 or 'Running' not in minikube_status.stdout - name: Check minikube status after start attempt ansible.builtin.command: - cmd: minikube status + cmd: minikube status --format={% raw %}'{{.Host}}'{% endraw %} register: minikube_final_status changed_when: false failed_when: false @@ -75,38 +75,7 @@ - name: Warn if minikube failed to start ansible.builtin.debug: 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 + when: minikube_final_status.rc != 0 or 'Running' not in minikube_final_status.stdout # Configure containerd to use zot registry as pull-through cache # With docker driver, use host.minikube.internal to reach the host @@ -116,32 +85,32 @@ ansible.builtin.command: cmd: minikube ssh --native-ssh=false "sudo mkdir -p /etc/containerd/certs.d/{{ item }}" loop: - - registry.ops.eblu.me + - registry.tail8d86e.ts.net - docker.io - ghcr.io - quay.io changed_when: false - when: minikube_final_status.rc == 0 + when: minikube_final_status.rc == 0 and 'Running' in minikube_final_status.stdout -# Private registry (registry.ops.eblu.me) - direct to zot -- name: Check registry.ops.eblu.me config +# Private registry (registry.tail8d86e.ts.net) - direct to zot +- name: Check registry.tail8d86e.ts.net config ansible.builtin.command: - cmd: minikube ssh --native-ssh=false "cat /etc/containerd/certs.d/registry.ops.eblu.me/hosts.toml 2>/dev/null || echo ''" + cmd: minikube ssh --native-ssh=false "cat /etc/containerd/certs.d/registry.tail8d86e.ts.net/hosts.toml 2>/dev/null || echo ''" register: minikube_registry_config changed_when: false - when: minikube_final_status.rc == 0 + when: minikube_final_status.rc == 0 and 'Running' in minikube_final_status.stdout -- name: Configure registry.ops.eblu.me mirror +- name: Configure registry.tail8d86e.ts.net mirror ansible.builtin.command: cmd: | minikube ssh --native-ssh=false 'echo "server = \"http://host.minikube.internal:5050\" [host.\"http://host.minikube.internal:5050\"] capabilities = [\"pull\", \"resolve\", \"push\"] - skip_verify = true" | sudo tee /etc/containerd/certs.d/registry.ops.eblu.me/hosts.toml' + skip_verify = true" | sudo tee /etc/containerd/certs.d/registry.tail8d86e.ts.net/hosts.toml' changed_when: true when: - - minikube_final_status.rc == 0 + - minikube_final_status.rc == 0 and 'Running' in minikube_final_status.stdout - "'host.minikube.internal:5050' not in minikube_registry_config.stdout" notify: Restart containerd in minikube @@ -151,7 +120,7 @@ cmd: minikube ssh --native-ssh=false "cat /etc/containerd/certs.d/docker.io/hosts.toml 2>/dev/null || echo ''" register: minikube_dockerio_config changed_when: false - when: minikube_final_status.rc == 0 + when: minikube_final_status.rc == 0 and 'Running' in minikube_final_status.stdout - name: Configure docker.io mirror through zot ansible.builtin.command: @@ -163,7 +132,7 @@ skip_verify = true" | sudo tee /etc/containerd/certs.d/docker.io/hosts.toml' changed_when: true when: - - minikube_final_status.rc == 0 + - minikube_final_status.rc == 0 and 'Running' in minikube_final_status.stdout - "'host.minikube.internal:5050' not in minikube_dockerio_config.stdout" notify: Restart containerd in minikube @@ -173,7 +142,7 @@ cmd: minikube ssh --native-ssh=false "cat /etc/containerd/certs.d/ghcr.io/hosts.toml 2>/dev/null || echo ''" register: minikube_ghcr_config changed_when: false - when: minikube_final_status.rc == 0 + when: minikube_final_status.rc == 0 and 'Running' in minikube_final_status.stdout - name: Configure ghcr.io mirror through zot ansible.builtin.command: @@ -185,7 +154,7 @@ skip_verify = true" | sudo tee /etc/containerd/certs.d/ghcr.io/hosts.toml' changed_when: true when: - - minikube_final_status.rc == 0 + - minikube_final_status.rc == 0 and 'Running' in minikube_final_status.stdout - "'host.minikube.internal:5050' not in minikube_ghcr_config.stdout" notify: Restart containerd in minikube @@ -195,7 +164,7 @@ cmd: minikube ssh --native-ssh=false "cat /etc/containerd/certs.d/quay.io/hosts.toml 2>/dev/null || echo ''" register: minikube_quay_config changed_when: false - when: minikube_final_status.rc == 0 + when: minikube_final_status.rc == 0 and 'Running' in minikube_final_status.stdout - name: Configure quay.io mirror through zot ansible.builtin.command: @@ -207,7 +176,7 @@ skip_verify = true" | sudo tee /etc/containerd/certs.d/quay.io/hosts.toml' changed_when: true when: - - minikube_final_status.rc == 0 + - minikube_final_status.rc == 0 and 'Running' in minikube_final_status.stdout - "'host.minikube.internal:5050' not in minikube_quay_config.stdout" notify: Restart containerd in minikube @@ -219,13 +188,13 @@ cmd: kubectl config view --minify -o jsonpath="{.clusters[0].cluster.server}" register: minikube_api_url changed_when: false - when: minikube_final_status.rc == 0 + when: minikube_final_status.rc == 0 and 'Running' in minikube_final_status.stdout - name: Extract API server port from URL ansible.builtin.set_fact: minikube_api_port: "{{ minikube_api_url.stdout | regex_search(':([0-9]+)$', '\\1') | first }}" when: - - minikube_final_status.rc == 0 + - minikube_final_status.rc == 0 and 'Running' in minikube_final_status.stdout - minikube_api_url.stdout is defined - name: Check current tailscale serve config for k8s diff --git a/ansible/roles/minikube_metrics/defaults/main.yml b/ansible/roles/minikube_metrics/defaults/main.yml index 56b1bc1..91ae59c 100644 --- a/ansible/roles/minikube_metrics/defaults/main.yml +++ b/ansible/roles/minikube_metrics/defaults/main.yml @@ -1,6 +1,6 @@ --- minikube_metrics_dir: /opt/homebrew/var/node_exporter/textfile -minikube_metrics_script: /Users/erichblume/.local/bin/minikube-metrics +minikube_metrics_script: /Users/erichblume/bin/minikube-metrics minikube_metrics_interval: 60 # seconds between metric collection minikube_metrics_log_dir: /opt/homebrew/var/log minikube_metrics_user_home: /Users/erichblume diff --git a/ansible/roles/plex_metrics/defaults/main.yml b/ansible/roles/plex_metrics/defaults/main.yml new file mode 100644 index 0000000..f186506 --- /dev/null +++ b/ansible/roles/plex_metrics/defaults/main.yml @@ -0,0 +1,20 @@ +--- +# Plex metrics collection configuration + +# Plex server URL +plex_metrics_url: "http://localhost:32400" + +# Path to file containing Plex token (should have 600 permissions) +plex_metrics_token_file: "/Users/erichblume/.plex-token" + +# Metrics collection interval in seconds +plex_metrics_interval: 60 + +# Output directory for prometheus textfile collector +plex_metrics_dir: /opt/homebrew/var/node_exporter/textfile + +# Script installation path +plex_metrics_script: /Users/erichblume/bin/plex-metrics + +# Log directory for metrics script output +plex_metrics_log_dir: /opt/homebrew/var/log diff --git a/ansible/roles/plex_metrics/handlers/main.yml b/ansible/roles/plex_metrics/handlers/main.yml new file mode 100644 index 0000000..ab5d382 --- /dev/null +++ b/ansible/roles/plex_metrics/handlers/main.yml @@ -0,0 +1,6 @@ +--- +- name: Reload plex-metrics + ansible.builtin.shell: | + launchctl unload ~/Library/LaunchAgents/mcquack.eblume.plex-metrics.plist 2>/dev/null || true + launchctl load ~/Library/LaunchAgents/mcquack.eblume.plex-metrics.plist + changed_when: true diff --git a/ansible/roles/plex_metrics/meta/main.yml b/ansible/roles/plex_metrics/meta/main.yml new file mode 100644 index 0000000..b05a43b --- /dev/null +++ b/ansible/roles/plex_metrics/meta/main.yml @@ -0,0 +1,4 @@ +--- +# Role ordering is controlled by indri.yml playbook - do not add dependencies here +# (Ansible's tag accumulation prevents proper deduplication when using meta dependencies) +dependencies: [] diff --git a/ansible/roles/plex_metrics/tasks/main.yml b/ansible/roles/plex_metrics/tasks/main.yml new file mode 100644 index 0000000..8225e27 --- /dev/null +++ b/ansible/roles/plex_metrics/tasks/main.yml @@ -0,0 +1,32 @@ +--- +- name: Ensure bin directory exists + ansible.builtin.file: + path: "{{ plex_metrics_script | dirname }}" + state: directory + mode: '0755' + +- name: Deploy plex metrics collection script + ansible.builtin.template: + src: plex-metrics.sh.j2 + dest: "{{ plex_metrics_script }}" + mode: '0755' + notify: Reload plex-metrics + +- name: Deploy plex-metrics LaunchAgent plist + ansible.builtin.template: + src: plex-metrics.plist.j2 + dest: ~/Library/LaunchAgents/mcquack.eblume.plex-metrics.plist + mode: '0644' + notify: Reload plex-metrics + +- name: Check if plex-metrics LaunchAgent is loaded + ansible.builtin.command: launchctl list mcquack.eblume.plex-metrics + register: plex_metrics_launchctl_check + changed_when: false + failed_when: false + +- name: Load plex-metrics LaunchAgent if not loaded + ansible.builtin.command: launchctl load ~/Library/LaunchAgents/mcquack.eblume.plex-metrics.plist + when: plex_metrics_launchctl_check.rc != 0 + changed_when: true + failed_when: false diff --git a/ansible/roles/jellyfin_metrics/templates/jellyfin-metrics.plist.j2 b/ansible/roles/plex_metrics/templates/plex-metrics.plist.j2 similarity index 63% rename from ansible/roles/jellyfin_metrics/templates/jellyfin-metrics.plist.j2 rename to ansible/roles/plex_metrics/templates/plex-metrics.plist.j2 index ead3c54..836bee1 100644 --- a/ansible/roles/jellyfin_metrics/templates/jellyfin-metrics.plist.j2 +++ b/ansible/roles/plex_metrics/templates/plex-metrics.plist.j2 @@ -4,7 +4,7 @@ <plist version="1.0"> <dict> <key>Label</key> - <string>mcquack.eblume.jellyfin-metrics</string> + <string>mcquack.eblume.plex-metrics</string> <key>EnvironmentVariables</key> <dict> <key>PATH</key> @@ -12,15 +12,15 @@ </dict> <key>ProgramArguments</key> <array> - <string>{{ jellyfin_metrics_script }}</string> + <string>{{ plex_metrics_script }}</string> </array> <key>StartInterval</key> - <integer>{{ jellyfin_metrics_interval }}</integer> + <integer>{{ plex_metrics_interval }}</integer> <key>RunAtLoad</key> <true/> <key>StandardErrorPath</key> - <string>{{ jellyfin_metrics_log_dir }}/jellyfin-metrics.err.log</string> + <string>{{ plex_metrics_log_dir }}/plex-metrics.err.log</string> <key>StandardOutPath</key> - <string>{{ jellyfin_metrics_log_dir }}/jellyfin-metrics.out.log</string> + <string>{{ plex_metrics_log_dir }}/plex-metrics.out.log</string> </dict> </plist> diff --git a/ansible/roles/plex_metrics/templates/plex-metrics.sh.j2 b/ansible/roles/plex_metrics/templates/plex-metrics.sh.j2 new file mode 100644 index 0000000..01ea2eb --- /dev/null +++ b/ansible/roles/plex_metrics/templates/plex-metrics.sh.j2 @@ -0,0 +1,133 @@ +#!/bin/bash +# {{ ansible_managed }} +# Collects Plex Media Server metrics for node_exporter textfile collector + +set -euo pipefail + +PLEX_URL="{{ plex_metrics_url }}" +TOKEN_FILE="{{ plex_metrics_token_file }}" +OUTPUT_FILE="{{ plex_metrics_dir }}/plex.prom" +TEMP_FILE="${OUTPUT_FILE}.tmp" + +# Read token from file +get_token() { + if [ -f "$TOKEN_FILE" ]; then + cat "$TOKEN_FILE" | tr -d '\n' + else + echo "" + fi +} + +# Make API request with optional token +api_request() { + local endpoint="$1" + local use_token="${2:-true}" + local token + local url="${PLEX_URL}${endpoint}" + + if [ "$use_token" = "true" ]; then + token=$(get_token) + if [ -n "$token" ]; then + curl -s -H "Accept: application/json" -H "X-Plex-Token: $token" "$url" 2>/dev/null + else + curl -s -H "Accept: application/json" "$url" 2>/dev/null + fi + else + curl -s -H "Accept: application/json" "$url" 2>/dev/null + fi +} + +# Initialize metrics +plex_up=0 +plex_version="" +plex_sessions_total=0 +plex_sessions_playing=0 +plex_sessions_paused=0 +plex_transcode_sessions_total=0 +plex_transcode_video=0 +plex_transcode_audio=0 + +# Library metrics will be built dynamically +library_metrics="" + +# Check server identity (no auth required) +identity=$(api_request "/identity" false) +if echo "$identity" | jq -e '.MediaContainer.machineIdentifier' > /dev/null 2>&1; then + plex_up=1 + plex_version=$(echo "$identity" | jq -r '.MediaContainer.version // ""') +fi + +# If server is up, get additional metrics (require auth) +if [ "$plex_up" -eq 1 ] && [ -f "$TOKEN_FILE" ]; then + # Get library sections + sections=$(api_request "/library/sections") + + # Process each library using jq + while IFS=$'\t' read -r lib_key lib_type lib_title; do + if [ -n "$lib_key" ] && [ -n "$lib_type" ]; then + # Get library details for item count + lib_detail=$(api_request "/library/sections/${lib_key}/all?X-Plex-Container-Start=0&X-Plex-Container-Size=0") + lib_size=$(echo "$lib_detail" | jq -r '.MediaContainer.totalSize // .MediaContainer.size // 0') + + library_metrics="${library_metrics}plex_library_items{library=\"${lib_title}\",type=\"${lib_type}\"} ${lib_size} +" + fi + done < <(echo "$sections" | jq -r '.MediaContainer.Directory[] | [.key, .type, .title] | @tsv' 2>/dev/null || true) + + # Get active sessions + sessions=$(api_request "/status/sessions") + if echo "$sessions" | jq -e '.MediaContainer' > /dev/null 2>&1; then + plex_sessions_total=$(echo "$sessions" | jq -r '.MediaContainer.size // 0') + + # Count playing vs paused + plex_sessions_playing=$(echo "$sessions" | jq -r '[.MediaContainer.Metadata[]? | select(.Player.state == "playing")] | length') + plex_sessions_paused=$(echo "$sessions" | jq -r '[.MediaContainer.Metadata[]? | select(.Player.state == "paused")] | length') + + # Count transcode sessions + plex_transcode_video=$(echo "$sessions" | jq -r '[.MediaContainer.Metadata[]? | select(.TranscodeSession.videoDecision == "transcode")] | length') + plex_transcode_audio=$(echo "$sessions" | jq -r '[.MediaContainer.Metadata[]? | select(.TranscodeSession.audioDecision == "transcode")] | length') + plex_transcode_sessions_total=$(echo "$sessions" | jq -r '[.MediaContainer.Metadata[]? | select(.TranscodeSession)] | length') + fi +fi + +# Write metrics +cat > "$TEMP_FILE" << EOF +# HELP plex_up Plex Media Server is up and responding +# TYPE plex_up gauge +plex_up ${plex_up} + +# HELP plex_version_info Plex Media Server version information +# TYPE plex_version_info gauge +plex_version_info{version="${plex_version}"} 1 + +# HELP plex_sessions_total Total number of active Plex sessions +# TYPE plex_sessions_total gauge +plex_sessions_total ${plex_sessions_total} + +# HELP plex_sessions_playing Number of sessions currently playing +# TYPE plex_sessions_playing gauge +plex_sessions_playing ${plex_sessions_playing} + +# HELP plex_sessions_paused Number of sessions currently paused +# TYPE plex_sessions_paused gauge +plex_sessions_paused ${plex_sessions_paused} + +# HELP plex_transcode_sessions_total Number of sessions being transcoded +# TYPE plex_transcode_sessions_total gauge +plex_transcode_sessions_total ${plex_transcode_sessions_total} + +# HELP plex_transcode_video_sessions Number of sessions transcoding video +# TYPE plex_transcode_video_sessions gauge +plex_transcode_video_sessions ${plex_transcode_video} + +# HELP plex_transcode_audio_sessions Number of sessions transcoding audio +# TYPE plex_transcode_audio_sessions gauge +plex_transcode_audio_sessions ${plex_transcode_audio} + +# HELP plex_library_items Number of items in each Plex library +# TYPE plex_library_items gauge +${library_metrics} +EOF + +# Atomic move +mv "$TEMP_FILE" "$OUTPUT_FILE" diff --git a/ansible/roles/sifaka_exporters/defaults/main.yml b/ansible/roles/sifaka_exporters/defaults/main.yml deleted file mode 100644 index a7acd4e..0000000 --- a/ansible/roles/sifaka_exporters/defaults/main.yml +++ /dev/null @@ -1,15 +0,0 @@ ---- -# Docker images for Prometheus exporters on sifaka NAS -# Ports are defined in group_vars/all.yml (shared with caddy role) -sifaka_exporters_docker: /volume1/@appstore/ContainerManager/usr/bin/docker -sifaka_exporters_node_exporter_image: "prom/node-exporter:latest" -sifaka_exporters_node_exporter_name: "prom-node-exporter-1" -sifaka_exporters_smartctl_exporter_image: "prometheuscommunity/smartctl-exporter:latest" -sifaka_exporters_smartctl_exporter_name: "smartctl-exporter" - -# Synology uses /dev/sata* instead of /dev/sd* — smartctl can't auto-detect them -sifaka_exporters_smartctl_devices: - - /dev/sata1 - - /dev/sata2 - - /dev/sata3 - - /dev/sata4 diff --git a/ansible/roles/sifaka_exporters/handlers/main.yml b/ansible/roles/sifaka_exporters/handlers/main.yml deleted file mode 100644 index f4c6355..0000000 --- a/ansible/roles/sifaka_exporters/handlers/main.yml +++ /dev/null @@ -1,12 +0,0 @@ ---- -- name: Restart node_exporter - ansible.builtin.command: "{{ sifaka_exporters_docker }} restart {{ sifaka_exporters_node_exporter_name }}" - become: true - listen: Restart node_exporter - changed_when: true - -- name: Restart smartctl_exporter - ansible.builtin.command: "{{ sifaka_exporters_docker }} restart {{ sifaka_exporters_smartctl_exporter_name }}" - become: true - listen: Restart smartctl_exporter - changed_when: true diff --git a/ansible/roles/sifaka_exporters/tasks/main.yml b/ansible/roles/sifaka_exporters/tasks/main.yml deleted file mode 100644 index 5d3a77c..0000000 --- a/ansible/roles/sifaka_exporters/tasks/main.yml +++ /dev/null @@ -1,91 +0,0 @@ ---- -# Manage Prometheus exporter containers on sifaka NAS -# Uses command module to avoid requiring docker Python SDK on Synology -# Requires passwordless sudo for docker — see docs/reference/storage/sifaka.md - -# --- node_exporter --- - -- name: Pull node_exporter image - ansible.builtin.command: "{{ sifaka_exporters_docker }} pull {{ sifaka_exporters_node_exporter_image }}" - become: true - register: sifaka_exporters_node_pull - changed_when: "'Downloaded newer image' in sifaka_exporters_node_pull.stdout" - -- name: Check if node_exporter container exists - ansible.builtin.command: "{{ sifaka_exporters_docker }} inspect {{ sifaka_exporters_node_exporter_name }} --format {% raw %}'{{.Config.Image}}'{% endraw %}" - become: true - register: sifaka_exporters_node_inspect - changed_when: false - failed_when: false - -- name: Remove node_exporter container if image changed - ansible.builtin.command: "{{ sifaka_exporters_docker }} rm -f {{ sifaka_exporters_node_exporter_name }}" - become: true - when: - - sifaka_exporters_node_inspect.rc == 0 - - sifaka_exporters_node_inspect.stdout != sifaka_exporters_node_exporter_image - changed_when: true - -- name: Start node_exporter container - ansible.builtin.command: - argv: - - "{{ sifaka_exporters_docker }}" - - run - - -d - - "--name={{ sifaka_exporters_node_exporter_name }}" - - --restart=always - - --net=host - - "{{ sifaka_exporters_node_exporter_image }}" - become: true - register: sifaka_exporters_node_start - when: > - sifaka_exporters_node_inspect.rc != 0 or - sifaka_exporters_node_inspect.stdout != sifaka_exporters_node_exporter_image - changed_when: sifaka_exporters_node_start.rc == 0 - -# --- smartctl_exporter --- - -- name: Pull smartctl_exporter image - ansible.builtin.command: "{{ sifaka_exporters_docker }} pull {{ sifaka_exporters_smartctl_exporter_image }}" - become: true - register: sifaka_exporters_smartctl_pull - changed_when: "'Downloaded newer image' in sifaka_exporters_smartctl_pull.stdout" - -- name: Check if smartctl_exporter container exists - ansible.builtin.command: "{{ sifaka_exporters_docker }} inspect {{ sifaka_exporters_smartctl_exporter_name }} --format {% raw %}'{{.Config.Image}}'{% endraw %}" - become: true - register: sifaka_exporters_smartctl_inspect - changed_when: false - failed_when: false - -- name: Remove smartctl_exporter container if image changed - ansible.builtin.command: "{{ sifaka_exporters_docker }} rm -f {{ sifaka_exporters_smartctl_exporter_name }}" - become: true - when: - - sifaka_exporters_smartctl_inspect.rc == 0 - - sifaka_exporters_smartctl_inspect.stdout != sifaka_exporters_smartctl_exporter_image - changed_when: true - -- name: Build smartctl_exporter device arguments - ansible.builtin.set_fact: - sifaka_exporters_smartctl_device_args: >- - {{ sifaka_exporters_smartctl_devices | map('regex_replace', '^(.*)$', '--smartctl.device=\1') | list }} - -- name: Start smartctl_exporter container - ansible.builtin.command: - argv: >- - {{ [ - sifaka_exporters_docker, 'run', '-d', - '--name=' + sifaka_exporters_smartctl_exporter_name, - '--restart=always', - '--privileged', - '--user=root', - '-p', sifaka_smartctl_exporter_port | string + ':' + sifaka_smartctl_exporter_port | string, - sifaka_exporters_smartctl_exporter_image - ] + sifaka_exporters_smartctl_device_args }} - become: true - register: sifaka_exporters_smartctl_start - when: > - sifaka_exporters_smartctl_inspect.rc != 0 or - sifaka_exporters_smartctl_inspect.stdout != sifaka_exporters_smartctl_exporter_image - changed_when: sifaka_exporters_smartctl_start.rc == 0 diff --git a/ansible/roles/tailscale_serve/defaults/main.yml b/ansible/roles/tailscale_serve/defaults/main.yml new file mode 100644 index 0000000..6e7ab1d --- /dev/null +++ b/ansible/roles/tailscale_serve/defaults/main.yml @@ -0,0 +1,17 @@ +--- +# Tailscale serve configuration for this host +# Each service maps a Tailscale service name to local endpoints + +tailscale_serve_services: + - name: svc:forge + https: + port: 443 + upstream: http://localhost:3001 + tcp: + port: 22 + upstream: tcp://localhost:2200 + + - name: svc:registry + https: + port: 443 + upstream: http://localhost:5050 diff --git a/ansible/roles/tailscale_serve/meta/main.yml b/ansible/roles/tailscale_serve/meta/main.yml new file mode 100644 index 0000000..b05a43b --- /dev/null +++ b/ansible/roles/tailscale_serve/meta/main.yml @@ -0,0 +1,4 @@ +--- +# Role ordering is controlled by indri.yml playbook - do not add dependencies here +# (Ansible's tag accumulation prevents proper deduplication when using meta dependencies) +dependencies: [] diff --git a/ansible/roles/tailscale_serve/tasks/main.yml b/ansible/roles/tailscale_serve/tasks/main.yml new file mode 100644 index 0000000..bf7d7be --- /dev/null +++ b/ansible/roles/tailscale_serve/tasks/main.yml @@ -0,0 +1,38 @@ +--- +- name: Get current tailscale serve status + ansible.builtin.command: tailscale serve status --json + register: tailscale_serve_status + changed_when: false + +- name: Parse serve status + ansible.builtin.set_fact: + tailscale_serve_config: "{{ ((tailscale_serve_status.stdout | default('{}', true)) | from_json).Services | default({}) }}" + +# Configure HTTPS if service doesn't have Web config yet +- name: Configure HTTPS services + ansible.builtin.command: > + tailscale serve --service="{{ item.name }}" + --https={{ item.https.port }} {{ item.https.upstream }} + loop: "{{ tailscale_serve_services }}" + when: + - item.https is defined + - tailscale_serve_config[item.name] is not defined or tailscale_serve_config[item.name].Web is not defined + register: tailscale_serve_https_result + changed_when: true + failed_when: false + +# Configure TCP if service doesn't have the specific port configured yet +- name: Configure TCP services + ansible.builtin.command: > + tailscale serve --service="{{ item.name }}" + --tcp={{ item.tcp.port }} {{ item.tcp.upstream }} + loop: "{{ tailscale_serve_services }}" + when: + - item.tcp is defined + - tailscale_serve_config[item.name] is not defined or + tailscale_serve_config[item.name].TCP is not defined or + tailscale_serve_config[item.name].TCP[item.tcp.port | string] is not defined or + tailscale_serve_config[item.name].TCP[item.tcp.port | string].TCPForward is not defined + register: tailscale_serve_tcp_result + changed_when: true + failed_when: false diff --git a/ansible/roles/zot/defaults/main.yml b/ansible/roles/zot/defaults/main.yml index a53acfa..812ac51 100644 --- a/ansible/roles/zot/defaults/main.yml +++ b/ansible/roles/zot/defaults/main.yml @@ -5,8 +5,6 @@ zot_data_dir: /Users/erichblume/zot zot_config_dir: /Users/erichblume/.config/zot zot_port: 5050 zot_log_dir: /Users/erichblume/Library/Logs -zot_external_url: https://registry.ops.eblu.me -zot_oidc_issuer: https://authentik.ops.eblu.me/application/o/zot/ # Pull-through cache registries (on-demand sync) zot_sync_registries: diff --git a/ansible/roles/zot/tasks/main.yml b/ansible/roles/zot/tasks/main.yml index 5c72577..20713b5 100644 --- a/ansible/roles/zot/tasks/main.yml +++ b/ansible/roles/zot/tasks/main.yml @@ -46,14 +46,6 @@ mode: '0644' notify: Restart zot -- name: Deploy zot OIDC credentials - ansible.builtin.template: - src: oidc-credentials.json.j2 - dest: "{{ zot_config_dir }}/oidc-credentials.json" - mode: '0600' - notify: Restart zot - when: zot_oidc_client_secret is defined - - name: Deploy zot LaunchAgent plist ansible.builtin.template: src: zot.plist.j2 diff --git a/ansible/roles/zot/templates/config.json.j2 b/ansible/roles/zot/templates/config.json.j2 index 25d1bb0..3c5c668 100644 --- a/ansible/roles/zot/templates/config.json.j2 +++ b/ansible/roles/zot/templates/config.json.j2 @@ -8,44 +8,7 @@ }, "http": { "address": "0.0.0.0", - "port": "{{ zot_port }}", - "externalUrl": "{{ zot_external_url }}", - "auth": { - "openid": { - "providers": { - "oidc": { - "credentialsFile": "{{ zot_config_dir }}/oidc-credentials.json", - "issuer": "{{ zot_oidc_issuer }}", - "scopes": ["openid", "email", "profile"], - "claimMapping": { - "username": "preferred_username" - } - } - } - }, - "apikey": true - }, - "accessControl": { - "metrics": { - "users": [""] - }, - "repositories": { - "**": { - "policies": [ - { - "groups": ["artifact-workloads"], - "actions": ["read", "create"] - }, - { - "groups": ["admins"], - "actions": ["read", "create", "update", "delete"] - } - ], - "anonymousPolicy": ["read"], - "defaultPolicy": ["read"] - } - } - } + "port": "{{ zot_port }}" }, "log": { "level": "info" diff --git a/ansible/roles/zot/templates/oidc-credentials.json.j2 b/ansible/roles/zot/templates/oidc-credentials.json.j2 deleted file mode 100644 index 30ea3cb..0000000 --- a/ansible/roles/zot/templates/oidc-credentials.json.j2 +++ /dev/null @@ -1,4 +0,0 @@ -{ - "clientid": "zot", - "clientsecret": "{{ zot_oidc_client_secret }}" -} diff --git a/ansible/roles/zot/templates/zot.plist.j2 b/ansible/roles/zot/templates/zot.plist.j2 index b777fb8..25b7da1 100644 --- a/ansible/roles/zot/templates/zot.plist.j2 +++ b/ansible/roles/zot/templates/zot.plist.j2 @@ -16,11 +16,6 @@ <true/> <key>KeepAlive</key> <true/> - <key>EnvironmentVariables</key> - <dict> - <key>PATH</key> - <string>/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string> - </dict> <key>StandardOutPath</key> <string>{{ zot_log_dir }}/mcquack.zot.out.log</string> <key>StandardErrorPath</key> diff --git a/ansible/roles/zot_metrics/defaults/main.yml b/ansible/roles/zot_metrics/defaults/main.yml index 18d340f..3280b20 100644 --- a/ansible/roles/zot_metrics/defaults/main.yml +++ b/ansible/roles/zot_metrics/defaults/main.yml @@ -1,6 +1,6 @@ --- zot_metrics_url: http://localhost:5050/v2/_catalog zot_metrics_dir: /opt/homebrew/var/node_exporter/textfile -zot_metrics_script: /Users/erichblume/.local/bin/zot-metrics +zot_metrics_script: /Users/erichblume/bin/zot-metrics zot_metrics_interval: 60 # seconds between metric collection zot_metrics_log_dir: /opt/homebrew/var/log diff --git a/argocd/apps/1password-connect-ringtail.yaml b/argocd/apps/1password-connect-ringtail.yaml deleted file mode 100644 index 60c6e43..0000000 --- a/argocd/apps/1password-connect-ringtail.yaml +++ /dev/null @@ -1,25 +0,0 @@ -# 1Password Connect for ringtail k3s cluster -# Same manifests as indri, different destination -# -# Prerequisites: -# 1. Bootstrap secrets via ansible (provision-ringtail creates 1password namespace, -# op-credentials and onepassword-token secrets) -# 2. Sync BEFORE external-secrets-ringtail -# -apiVersion: argoproj.io/v1alpha1 -kind: Application -metadata: - name: 1password-connect-ringtail - namespace: argocd -spec: - project: default - 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 - syncPolicy: - syncOptions: - - CreateNamespace=true diff --git a/argocd/apps/1password-connect.yaml b/argocd/apps/1password-connect.yaml deleted file mode 100644 index ba0a474..0000000 --- a/argocd/apps/1password-connect.yaml +++ /dev/null @@ -1,31 +0,0 @@ -# 1Password Connect - Secrets Automation Server -# Provides REST API access to 1Password vault items for External Secrets Operator -# -# 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 -# 2. Create token: op connect token create blumeops --server <server-id> --vault blumeops -# 3. Store credentials in 1Password item "1Password Connect" in blumeops vault -# 4. Bootstrap secret: -# kubectl --context=minikube-indri create namespace 1password -# op inject -i argocd/manifests/1password-connect/secret-credentials.yaml.tpl | \ -# kubectl --context=minikube-indri apply -f - -# -apiVersion: argoproj.io/v1alpha1 -kind: Application -metadata: - name: 1password-connect - namespace: argocd -spec: - project: default - 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 - syncPolicy: - syncOptions: - - CreateNamespace=true diff --git a/argocd/apps/alloy-k8s.yaml b/argocd/apps/alloy-k8s.yaml index 9b652bc..29d996c 100644 --- a/argocd/apps/alloy-k8s.yaml +++ b/argocd/apps/alloy-k8s.yaml @@ -6,7 +6,7 @@ metadata: spec: project: default source: - repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git + repoURL: ssh://forgejo@indri.tail8d86e.ts.net:2200/eblume/blumeops.git targetRevision: main path: argocd/manifests/alloy-k8s destination: diff --git a/argocd/apps/alloy-ringtail.yaml b/argocd/apps/alloy-ringtail.yaml deleted file mode 100644 index b5d7297..0000000 --- a/argocd/apps/alloy-ringtail.yaml +++ /dev/null @@ -1,17 +0,0 @@ -apiVersion: argoproj.io/v1alpha1 -kind: Application -metadata: - name: alloy-ringtail - namespace: argocd -spec: - project: default - source: - repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git - targetRevision: main - path: argocd/manifests/alloy-ringtail - destination: - server: https://ringtail.tail8d86e.ts.net:6443 - namespace: alloy - syncPolicy: - syncOptions: - - CreateNamespace=true diff --git a/argocd/apps/alloy-tracing-ringtail.yaml b/argocd/apps/alloy-tracing-ringtail.yaml deleted file mode 100644 index 78d02e3..0000000 --- a/argocd/apps/alloy-tracing-ringtail.yaml +++ /dev/null @@ -1,17 +0,0 @@ -apiVersion: argoproj.io/v1alpha1 -kind: Application -metadata: - name: alloy-tracing-ringtail - namespace: argocd -spec: - project: default - source: - repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git - targetRevision: main - path: argocd/manifests/alloy-tracing-ringtail - destination: - server: https://ringtail.tail8d86e.ts.net:6443 - namespace: alloy - syncPolicy: - syncOptions: - - CreateNamespace=true diff --git a/argocd/apps/apps.yaml b/argocd/apps/apps.yaml index 0eebe54..c028062 100644 --- a/argocd/apps/apps.yaml +++ b/argocd/apps/apps.yaml @@ -8,7 +8,7 @@ metadata: spec: project: default source: - repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git + repoURL: ssh://forgejo@indri.tail8d86e.ts.net:2200/eblume/blumeops.git targetRevision: main path: argocd/apps destination: diff --git a/argocd/apps/argocd.yaml b/argocd/apps/argocd.yaml index 63f7ff8..f056ef0 100644 --- a/argocd/apps/argocd.yaml +++ b/argocd/apps/argocd.yaml @@ -8,7 +8,7 @@ metadata: spec: project: default source: - repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git + repoURL: ssh://forgejo@indri.tail8d86e.ts.net:2200/eblume/blumeops.git targetRevision: main path: argocd/manifests/argocd destination: @@ -17,4 +17,3 @@ spec: syncPolicy: syncOptions: - CreateNamespace=true - - ServerSideApply=true diff --git a/argocd/apps/authentik.yaml b/argocd/apps/authentik.yaml deleted file mode 100644 index 38d6909..0000000 --- a/argocd/apps/authentik.yaml +++ /dev/null @@ -1,18 +0,0 @@ ---- -apiVersion: argoproj.io/v1alpha1 -kind: Application -metadata: - name: authentik - namespace: argocd -spec: - project: default - source: - repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git - targetRevision: main - path: argocd/manifests/authentik - destination: - server: https://ringtail.tail8d86e.ts.net:6443 - namespace: authentik - syncPolicy: - syncOptions: - - CreateNamespace=true diff --git a/argocd/apps/blumeops-pg.yaml b/argocd/apps/blumeops-pg.yaml index 6a9e57e..54f20c5 100644 --- a/argocd/apps/blumeops-pg.yaml +++ b/argocd/apps/blumeops-pg.yaml @@ -12,7 +12,7 @@ metadata: spec: project: default source: - repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git + repoURL: ssh://forgejo@indri.tail8d86e.ts.net:2200/eblume/blumeops.git targetRevision: main path: argocd/manifests/databases destination: diff --git a/argocd/apps/cloudnative-pg-ringtail.yaml b/argocd/apps/cloudnative-pg-ringtail.yaml deleted file mode 100644 index fa7bba0..0000000 --- a/argocd/apps/cloudnative-pg-ringtail.yaml +++ /dev/null @@ -1,27 +0,0 @@ -# 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/cloudnative-pg.yaml b/argocd/apps/cloudnative-pg.yaml index 04b1135..d2f6e81 100644 --- a/argocd/apps/cloudnative-pg.yaml +++ b/argocd/apps/cloudnative-pg.yaml @@ -1,7 +1,7 @@ # CloudNativePG Operator - PostgreSQL for Kubernetes # Deploys the operator only; PostgreSQL clusters are created separately # -# Mirror of https://github.com/cloudnative-pg/cloudnative-pg +# Chart mirrored from https://github.com/cloudnative-pg/charts to forge apiVersion: argoproj.io/v1alpha1 kind: Application metadata: @@ -9,12 +9,19 @@ metadata: 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' + sources: + # Helm chart from forge mirror (SSH via egress) + - repoURL: ssh://forgejo@indri.tail8d86e.ts.net:2200/eblume/cloudnative-pg-charts.git + targetRevision: cloudnative-pg-v0.27.0 + path: charts/cloudnative-pg + helm: + releaseName: cloudnative-pg + valueFiles: + - $values/argocd/manifests/cloudnative-pg/values.yaml + # Values from our git repo + - repoURL: ssh://forgejo@indri.tail8d86e.ts.net:2200/eblume/blumeops.git + targetRevision: main + ref: values destination: server: https://kubernetes.default.svc namespace: cnpg-system diff --git a/argocd/apps/databases-ringtail.yaml b/argocd/apps/databases-ringtail.yaml deleted file mode 100644 index 00de4e3..0000000 --- a/argocd/apps/databases-ringtail.yaml +++ /dev/null @@ -1,26 +0,0 @@ -# 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/devpi.yaml b/argocd/apps/devpi.yaml new file mode 100644 index 0000000..e294f5b --- /dev/null +++ b/argocd/apps/devpi.yaml @@ -0,0 +1,29 @@ +# 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 <password> +# 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@indri.tail8d86e.ts.net:2200/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/apps/external-secrets-crds-ringtail.yaml b/argocd/apps/external-secrets-crds-ringtail.yaml deleted file mode 100644 index 00d7fec..0000000 --- a/argocd/apps/external-secrets-crds-ringtail.yaml +++ /dev/null @@ -1,24 +0,0 @@ -# External Secrets Operator CRDs for ringtail k3s cluster -# Same CRDs source as indri, different destination -# -# Must be synced BEFORE external-secrets-ringtail operator app. -# -apiVersion: argoproj.io/v1alpha1 -kind: Application -metadata: - name: external-secrets-crds-ringtail - namespace: argocd -spec: - project: default - source: - repoURL: ssh://forgejo@forge.ops.eblu.me:2222/mirrors/external-secrets.git - targetRevision: helm-chart-2.2.0 - path: config/crds/bases - directory: - exclude: 'kustomization.yaml' - destination: - server: https://ringtail.tail8d86e.ts.net:6443 - syncPolicy: - syncOptions: - - ServerSideApply=true - - CreateNamespace=false diff --git a/argocd/apps/external-secrets-crds.yaml b/argocd/apps/external-secrets-crds.yaml deleted file mode 100644 index d822960..0000000 --- a/argocd/apps/external-secrets-crds.yaml +++ /dev/null @@ -1,28 +0,0 @@ -# External Secrets Operator CRDs -# -# CRDs are installed separately because: -# 1. They need ServerSideApply due to large annotation sizes -# 2. The Helm chart's CRDs are auto-generated during packaging (not in raw git) -# 3. CRDs should exist before the operator starts -# -# Must be synced BEFORE external-secrets operator app. -# -apiVersion: argoproj.io/v1alpha1 -kind: Application -metadata: - name: external-secrets-crds - namespace: argocd -spec: - project: default - source: - repoURL: ssh://forgejo@forge.ops.eblu.me:2222/mirrors/external-secrets.git - targetRevision: helm-chart-2.2.0 - path: config/crds/bases - directory: - exclude: 'kustomization.yaml' - destination: - server: https://kubernetes.default.svc - syncPolicy: - syncOptions: - - ServerSideApply=true - - CreateNamespace=false diff --git a/argocd/apps/external-secrets-ringtail.yaml b/argocd/apps/external-secrets-ringtail.yaml deleted file mode 100644 index 0bb8bd7..0000000 --- a/argocd/apps/external-secrets-ringtail.yaml +++ /dev/null @@ -1,25 +0,0 @@ -# External Secrets Operator for ringtail k3s cluster -# Same manifests as indri, different destination -# -# Prerequisites: -# - 1password-connect-ringtail must be deployed and healthy -# - external-secrets-crds-ringtail must be synced first -# -apiVersion: argoproj.io/v1alpha1 -kind: Application -metadata: - name: external-secrets-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-ringtail - destination: - server: https://ringtail.tail8d86e.ts.net:6443 - namespace: external-secrets - syncPolicy: - syncOptions: - - CreateNamespace=true - - ServerSideApply=true diff --git a/argocd/apps/external-secrets.yaml b/argocd/apps/external-secrets.yaml deleted file mode 100644 index 85ac21d..0000000 --- a/argocd/apps/external-secrets.yaml +++ /dev/null @@ -1,28 +0,0 @@ -# External Secrets Operator - Kubernetes secret sync from external providers -# Syncs secrets from 1Password Connect to native Kubernetes 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 -metadata: - name: external-secrets - 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 - - ServerSideApply=true diff --git a/argocd/apps/forgejo-runner.yaml b/argocd/apps/forgejo-runner.yaml index 76b4cd6..a584d33 100644 --- a/argocd/apps/forgejo-runner.yaml +++ b/argocd/apps/forgejo-runner.yaml @@ -1,3 +1,9 @@ +# Forgejo Actions Runner +# Runs in k8s, polls Forgejo for workflow jobs +# +# Before syncing, create the runner token secret: +# kubectl create namespace forgejo-runner +# op inject -i argocd/manifests/forgejo-runner/secret-token.yaml.tpl | kubectl apply -f - apiVersion: argoproj.io/v1alpha1 kind: Application metadata: @@ -6,7 +12,7 @@ metadata: spec: project: default source: - repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git + repoURL: ssh://forgejo@indri.tail8d86e.ts.net:2200/eblume/blumeops.git targetRevision: main path: argocd/manifests/forgejo-runner destination: diff --git a/argocd/apps/frigate.yaml b/argocd/apps/frigate.yaml deleted file mode 100644 index c443774..0000000 --- a/argocd/apps/frigate.yaml +++ /dev/null @@ -1,18 +0,0 @@ ---- -apiVersion: argoproj.io/v1alpha1 -kind: Application -metadata: - name: frigate - namespace: argocd -spec: - project: default - source: - repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git - targetRevision: main - path: argocd/manifests/frigate - destination: - server: https://ringtail.tail8d86e.ts.net:6443 - namespace: frigate - syncPolicy: - syncOptions: - - CreateNamespace=true diff --git a/argocd/apps/grafana-config.yaml b/argocd/apps/grafana-config.yaml index f98399c..e363933 100644 --- a/argocd/apps/grafana-config.yaml +++ b/argocd/apps/grafana-config.yaml @@ -13,7 +13,7 @@ metadata: spec: project: default source: - repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git + repoURL: ssh://forgejo@indri.tail8d86e.ts.net:2200/eblume/blumeops.git targetRevision: main path: argocd/manifests/grafana-config destination: diff --git a/argocd/apps/grafana.yaml b/argocd/apps/grafana.yaml index 3a2cdd0..1a748d8 100644 --- a/argocd/apps/grafana.yaml +++ b/argocd/apps/grafana.yaml @@ -1,5 +1,7 @@ # Grafana - Dashboards & Observability # +# Chart mirrored from https://github.com/grafana/helm-charts to forge +# # Before syncing, create the admin password secret: # kubectl create namespace monitoring # op inject -i argocd/manifests/grafana-config/secret-admin.yaml.tpl | kubectl apply -f - @@ -10,10 +12,19 @@ metadata: namespace: argocd spec: project: default - source: - repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git - targetRevision: main - path: argocd/manifests/grafana + sources: + # Helm chart from forge mirror (SSH via egress) + - repoURL: ssh://forgejo@indri.tail8d86e.ts.net:2200/eblume/grafana-helm-charts.git + targetRevision: grafana-8.8.2 + path: charts/grafana + helm: + releaseName: grafana + valueFiles: + - $values/argocd/manifests/grafana/values.yaml + # Values from our git repo + - repoURL: ssh://forgejo@indri.tail8d86e.ts.net:2200/eblume/blumeops.git + targetRevision: main + ref: values destination: server: https://kubernetes.default.svc namespace: monitoring diff --git a/argocd/apps/homepage.yaml b/argocd/apps/homepage.yaml deleted file mode 100644 index 22147f2..0000000 --- a/argocd/apps/homepage.yaml +++ /dev/null @@ -1,21 +0,0 @@ -# Homepage - Service Dashboard / Start Page -# -# Custom container built from gethomepage/homepage, kustomize manifests. -# Dashboard at go.ops.eblu.me / go.tail8d86e.ts.net -apiVersion: argoproj.io/v1alpha1 -kind: Application -metadata: - name: homepage - namespace: argocd -spec: - project: default - source: - repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git - targetRevision: main - path: argocd/manifests/homepage - destination: - server: https://ringtail.tail8d86e.ts.net:6443 - namespace: homepage - syncPolicy: - syncOptions: - - CreateNamespace=true diff --git a/argocd/apps/immich-ringtail.yaml b/argocd/apps/immich-ringtail.yaml deleted file mode 100644 index c93cbee..0000000 --- a/argocd/apps/immich-ringtail.yaml +++ /dev/null @@ -1,31 +0,0 @@ -# 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/kingfisher.yaml b/argocd/apps/kingfisher.yaml deleted file mode 100644 index ad659eb..0000000 --- a/argocd/apps/kingfisher.yaml +++ /dev/null @@ -1,17 +0,0 @@ -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://ringtail.tail8d86e.ts.net:6443 - namespace: kingfisher - syncPolicy: - syncOptions: - - CreateNamespace=true diff --git a/argocd/apps/kiwix.yaml b/argocd/apps/kiwix.yaml index 36e5b93..70be2c1 100644 --- a/argocd/apps/kiwix.yaml +++ b/argocd/apps/kiwix.yaml @@ -7,7 +7,7 @@ metadata: spec: project: default source: - repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git + repoURL: ssh://forgejo@indri.tail8d86e.ts.net:2200/eblume/blumeops.git targetRevision: main path: argocd/manifests/kiwix destination: diff --git a/argocd/apps/kube-state-metrics-ringtail.yaml b/argocd/apps/kube-state-metrics-ringtail.yaml deleted file mode 100644 index 44dd50f..0000000 --- a/argocd/apps/kube-state-metrics-ringtail.yaml +++ /dev/null @@ -1,17 +0,0 @@ -apiVersion: argoproj.io/v1alpha1 -kind: Application -metadata: - name: kube-state-metrics-ringtail - namespace: argocd -spec: - project: default - source: - repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git - targetRevision: main - path: argocd/manifests/kube-state-metrics-ringtail - destination: - server: https://ringtail.tail8d86e.ts.net:6443 - namespace: monitoring - syncPolicy: - syncOptions: - - CreateNamespace=true diff --git a/argocd/apps/kube-state-metrics.yaml b/argocd/apps/kube-state-metrics.yaml index 1644532..91df2cd 100644 --- a/argocd/apps/kube-state-metrics.yaml +++ b/argocd/apps/kube-state-metrics.yaml @@ -6,7 +6,7 @@ metadata: spec: project: default source: - repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git + repoURL: ssh://forgejo@indri.tail8d86e.ts.net:2200/eblume/blumeops.git targetRevision: main path: argocd/manifests/kube-state-metrics destination: diff --git a/argocd/apps/loki.yaml b/argocd/apps/loki.yaml index 834c86c..cb9dd41 100644 --- a/argocd/apps/loki.yaml +++ b/argocd/apps/loki.yaml @@ -6,7 +6,7 @@ metadata: spec: project: default source: - repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git + repoURL: ssh://forgejo@indri.tail8d86e.ts.net:2200/eblume/blumeops.git targetRevision: main path: argocd/manifests/loki destination: diff --git a/argocd/apps/mealie-ringtail.yaml b/argocd/apps/mealie-ringtail.yaml deleted file mode 100644 index 2f014a9..0000000 --- a/argocd/apps/mealie-ringtail.yaml +++ /dev/null @@ -1,26 +0,0 @@ -# Mealie on ringtail k3s. -# -# Wave-1 indri-k8s decommission. Staging deployment; the minikube `mealie` -# app stays in parallel until cutover (copy SQLite PVC, drop the minikube -# tailscale ingress, flip Caddy). See [[migrate-wave1-ringtail]]. -# -# Prerequisites: -# - external-secrets-ringtail (onepassword-blumeops ClusterSecretStore) -# - mealie-data PVC contents copied from minikube at cutover -apiVersion: argoproj.io/v1alpha1 -kind: Application -metadata: - name: mealie-ringtail - namespace: argocd -spec: - project: default - source: - repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git - targetRevision: main - path: argocd/manifests/mealie-ringtail - destination: - server: https://ringtail.tail8d86e.ts.net:6443 - namespace: mealie - syncPolicy: - syncOptions: - - CreateNamespace=true diff --git a/argocd/apps/miniflux.yaml b/argocd/apps/miniflux.yaml index d9165bb..36cff8d 100644 --- a/argocd/apps/miniflux.yaml +++ b/argocd/apps/miniflux.yaml @@ -16,7 +16,7 @@ metadata: spec: project: default source: - repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git + repoURL: ssh://forgejo@indri.tail8d86e.ts.net:2200/eblume/blumeops.git targetRevision: main path: argocd/manifests/miniflux destination: diff --git a/argocd/apps/navidrome.yaml b/argocd/apps/navidrome.yaml deleted file mode 100644 index 95bcf1b..0000000 --- a/argocd/apps/navidrome.yaml +++ /dev/null @@ -1,18 +0,0 @@ ---- -apiVersion: argoproj.io/v1alpha1 -kind: Application -metadata: - name: navidrome - namespace: argocd -spec: - project: default - source: - repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git - targetRevision: main - path: argocd/manifests/navidrome - destination: - server: https://kubernetes.default.svc - namespace: navidrome - syncPolicy: - syncOptions: - - CreateNamespace=true diff --git a/argocd/apps/ntfy.yaml b/argocd/apps/ntfy.yaml deleted file mode 100644 index 13927bc..0000000 --- a/argocd/apps/ntfy.yaml +++ /dev/null @@ -1,18 +0,0 @@ ---- -apiVersion: argoproj.io/v1alpha1 -kind: Application -metadata: - name: ntfy - namespace: argocd -spec: - project: default - source: - repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git - targetRevision: main - path: argocd/manifests/ntfy - destination: - server: https://ringtail.tail8d86e.ts.net:6443 - namespace: ntfy - syncPolicy: - syncOptions: - - CreateNamespace=true diff --git a/argocd/apps/nvidia-device-plugin.yaml b/argocd/apps/nvidia-device-plugin.yaml deleted file mode 100644 index af8395f..0000000 --- a/argocd/apps/nvidia-device-plugin.yaml +++ /dev/null @@ -1,18 +0,0 @@ ---- -apiVersion: argoproj.io/v1alpha1 -kind: Application -metadata: - name: nvidia-device-plugin - namespace: argocd -spec: - project: default - source: - repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git - targetRevision: main - path: argocd/manifests/nvidia-device-plugin - destination: - server: https://ringtail.tail8d86e.ts.net:6443 - namespace: nvidia-device-plugin - syncPolicy: - syncOptions: - - CreateNamespace=true diff --git a/argocd/apps/ollama.yaml b/argocd/apps/ollama.yaml deleted file mode 100644 index bb7a6a9..0000000 --- a/argocd/apps/ollama.yaml +++ /dev/null @@ -1,18 +0,0 @@ ---- -apiVersion: argoproj.io/v1alpha1 -kind: Application -metadata: - name: ollama - namespace: argocd -spec: - project: default - source: - repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git - targetRevision: main - path: argocd/manifests/ollama - destination: - server: https://ringtail.tail8d86e.ts.net:6443 - namespace: ollama - syncPolicy: - syncOptions: - - CreateNamespace=true diff --git a/argocd/apps/paperless-ringtail.yaml b/argocd/apps/paperless-ringtail.yaml deleted file mode 100644 index bec98e9..0000000 --- a/argocd/apps/paperless-ringtail.yaml +++ /dev/null @@ -1,28 +0,0 @@ -# Paperless-ngx on ringtail k3s. -# -# Wave-1 indri-k8s decommission. Staging deployment; the minikube -# `paperless` app stays in parallel until cutover (drop the minikube -# tailscale ingress to free the name, then flip Caddy). See -# [[migrate-wave1-ringtail]]. -# -# Prerequisites: -# - databases-ringtail blumeops-pg (paperless database + role) -# - external-secrets-ringtail (onepassword-blumeops ClusterSecretStore) -# - sifaka NFS rule granting ringtail access to /volume1/paperless -apiVersion: argoproj.io/v1alpha1 -kind: Application -metadata: - name: paperless-ringtail - namespace: argocd -spec: - project: default - source: - repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git - targetRevision: main - path: argocd/manifests/paperless-ringtail - destination: - server: https://ringtail.tail8d86e.ts.net:6443 - namespace: paperless - syncPolicy: - syncOptions: - - CreateNamespace=true diff --git a/argocd/apps/prometheus.yaml b/argocd/apps/prometheus.yaml index 3348736..b53a243 100644 --- a/argocd/apps/prometheus.yaml +++ b/argocd/apps/prometheus.yaml @@ -6,7 +6,7 @@ metadata: spec: project: default source: - repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git + repoURL: ssh://forgejo@indri.tail8d86e.ts.net:2200/eblume/blumeops.git targetRevision: main path: argocd/manifests/prometheus destination: diff --git a/argocd/apps/prowler.yaml b/argocd/apps/prowler.yaml deleted file mode 100644 index a98aa4f..0000000 --- a/argocd/apps/prowler.yaml +++ /dev/null @@ -1,17 +0,0 @@ -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/apps/shower.yaml b/argocd/apps/shower.yaml deleted file mode 100644 index c4a7a62..0000000 --- a/argocd/apps/shower.yaml +++ /dev/null @@ -1,20 +0,0 @@ -# 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/apps/tailscale-operator-ringtail.yaml b/argocd/apps/tailscale-operator-ringtail.yaml deleted file mode 100644 index 1e15d09..0000000 --- a/argocd/apps/tailscale-operator-ringtail.yaml +++ /dev/null @@ -1,26 +0,0 @@ ---- -# ArgoCD Application for Tailscale Kubernetes Operator on ringtail -# Shares operator.yaml, proxyclass, and dnsconfig with indri; ringtail-specific -# ProxyGroup (1 replica) and ExternalSecret live in the overlay directory. -apiVersion: argoproj.io/v1alpha1 -kind: Application -metadata: - name: tailscale-operator-ringtail - namespace: argocd -spec: - project: default - # Tailscale operator mutates externalName from "placeholder" to actual proxy service - ignoreDifferences: - - kind: Service - jsonPointers: - - /spec/externalName - source: - repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git - targetRevision: main - path: argocd/manifests/tailscale-operator-ringtail - destination: - server: https://ringtail.tail8d86e.ts.net:6443 - namespace: tailscale - syncPolicy: - syncOptions: - - CreateNamespace=true diff --git a/argocd/apps/tailscale-operator.yaml b/argocd/apps/tailscale-operator.yaml index 9e95c16..e3cc2c8 100644 --- a/argocd/apps/tailscale-operator.yaml +++ b/argocd/apps/tailscale-operator.yaml @@ -9,11 +9,12 @@ spec: project: default # Tailscale operator mutates externalName from "placeholder" to actual proxy service ignoreDifferences: - - kind: Service + - group: "" + kind: Service jsonPointers: - /spec/externalName source: - repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git + repoURL: ssh://forgejo@indri.tail8d86e.ts.net:2200/eblume/blumeops.git targetRevision: main path: argocd/manifests/tailscale-operator destination: diff --git a/argocd/apps/tempo.yaml b/argocd/apps/tempo.yaml deleted file mode 100644 index b04d297..0000000 --- a/argocd/apps/tempo.yaml +++ /dev/null @@ -1,17 +0,0 @@ -apiVersion: argoproj.io/v1alpha1 -kind: Application -metadata: - name: tempo - namespace: argocd -spec: - project: default - source: - repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git - targetRevision: main - path: argocd/manifests/tempo - destination: - server: https://kubernetes.default.svc - namespace: monitoring - syncPolicy: - syncOptions: - - CreateNamespace=true diff --git a/argocd/apps/teslamate-ringtail.yaml b/argocd/apps/teslamate-ringtail.yaml deleted file mode 100644 index b7b3491..0000000 --- a/argocd/apps/teslamate-ringtail.yaml +++ /dev/null @@ -1,28 +0,0 @@ -# TeslaMate on ringtail k3s. -# -# Wave-1 indri-k8s decommission. Staging deployment; the minikube -# `teslamate` app stays in parallel until cutover (migrate the teslamate -# database, drop the minikube tailscale ingress, flip Caddy). See -# [[migrate-wave1-ringtail]]. -# -# Prerequisites: -# - databases-ringtail blumeops-pg (teslamate database + role; cube + -# earthdistance extensions created by superuser at cutover) -# - external-secrets-ringtail (onepassword-blumeops ClusterSecretStore) -apiVersion: argoproj.io/v1alpha1 -kind: Application -metadata: - name: teslamate-ringtail - namespace: argocd -spec: - project: default - source: - repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git - targetRevision: main - path: argocd/manifests/teslamate-ringtail - destination: - server: https://ringtail.tail8d86e.ts.net:6443 - namespace: teslamate - syncPolicy: - syncOptions: - - CreateNamespace=true diff --git a/argocd/apps/teslamate.yaml b/argocd/apps/teslamate.yaml new file mode 100644 index 0000000..9c22c42 --- /dev/null +++ b/argocd/apps/teslamate.yaml @@ -0,0 +1,32 @@ +# TeslaMate Tesla Data Logger +# Requires: CloudNativePG PostgreSQL cluster and manual secret setup +# +# Before syncing, create the namespace and secrets: +# kubectl create namespace teslamate +# op inject -i argocd/manifests/databases/secret-teslamate.yaml.tpl | kubectl apply -f - +# op inject -i argocd/manifests/teslamate/secret-encryption-key.yaml.tpl | kubectl apply -f - +# op inject -i argocd/manifests/teslamate/secret-db.yaml.tpl | kubectl apply -f - +# +# Then create the database: +# PGPASSWORD=$(op --vault blumeops item get <eblume-item-id> --fields password --reveal) \ +# psql -h pg.tail8d86e.ts.net -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@indri.tail8d86e.ts.net:2200/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/apps/torrent.yaml b/argocd/apps/torrent.yaml index 7fd4135..91e5fdc 100644 --- a/argocd/apps/torrent.yaml +++ b/argocd/apps/torrent.yaml @@ -7,7 +7,7 @@ metadata: spec: project: default source: - repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git + repoURL: ssh://forgejo@indri.tail8d86e.ts.net:2200/eblume/blumeops.git targetRevision: main path: argocd/manifests/torrent destination: diff --git a/argocd/apps/unpoller.yaml b/argocd/apps/unpoller.yaml deleted file mode 100644 index 5eaadfb..0000000 --- a/argocd/apps/unpoller.yaml +++ /dev/null @@ -1,17 +0,0 @@ -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/1password-connect/README.md b/argocd/manifests/1password-connect/README.md deleted file mode 100644 index 26989f3..0000000 --- a/argocd/manifests/1password-connect/README.md +++ /dev/null @@ -1,101 +0,0 @@ -# 1Password Connect - -1Password Connect provides REST API access to 1Password vault items for External Secrets Operator. - -## Architecture - -``` -1Password Cloud - | - v -1Password Connect (this service) - | - v -External Secrets Operator - | - v -Native Kubernetes Secrets -``` - -## Prerequisites (One-Time Setup) - -Run these steps on the workstation (gilbert) before deploying: - -### 1. Create Connect Server Credentials - -```bash -# This creates the credentials file and outputs a server ID -op connect server create blumeops --vaults blumeops - -# Save the 1password-credentials.json file contents -``` - -### 2. Create Access Token - -```bash -# Replace <server-id> with the ID from step 1 -op connect token create blumeops --server <server-id> --vault blumeops - -# Save the token -``` - -### 3. Store Credentials in 1Password - -Create a new item "1Password Connect" in the blumeops vault with: -- `credentials-file` field: Paste the contents of `1password-credentials.json` (raw JSON, NOT base64 encoded) -- `token` field: Paste the access token - -> **Note:** Chart 2.3.0+ mounts credentials as a file with standard k8s base64 encoding. The old `credentials-base64` field is no longer needed. - -### 4. Create Bootstrap Secret - -```bash -kubectl --context=minikube-indri create namespace 1password -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 -argocd app sync apps -argocd app sync 1password-connect -``` - -## Verification - -```bash -# Check pods are running -kubectl --context=minikube-indri -n 1password get pods - -# Check logs -kubectl --context=minikube-indri -n 1password logs -l app=onepassword-connect - -# Test API health (port-forward first) -kubectl --context=minikube-indri -n 1password port-forward svc/onepassword-connect 8080:8080 & -curl http://localhost:8080/health -``` - -## Troubleshooting - -### Pods not starting -- Check the bootstrap secret exists: `kubectl --context=minikube-indri -n 1password get secret op-credentials` -- Verify credentials format in 1Password item - -### API returning 401 -- Check the token secret: `kubectl --context=minikube-indri -n 1password get secret onepassword-token` -- Verify the token has access to the blumeops vault - -## Related - -- [1Password Connect Documentation](https://developer.1password.com/docs/connect/) -- [External Secrets Operator](../external-secrets/README.md) diff --git a/argocd/manifests/1password-connect/deployment.yaml b/argocd/manifests/1password-connect/deployment.yaml deleted file mode 100644 index 3296e19..0000000 --- a/argocd/manifests/1password-connect/deployment.yaml +++ /dev/null @@ -1,131 +0,0 @@ -# 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 deleted file mode 100644 index d6da84d..0000000 --- a/argocd/manifests/1password-connect/kustomization.yaml +++ /dev/null @@ -1,15 +0,0 @@ ---- -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/secret-credentials.yaml.tpl b/argocd/manifests/1password-connect/secret-credentials.yaml.tpl deleted file mode 100644 index 2bc833e..0000000 --- a/argocd/manifests/1password-connect/secret-credentials.yaml.tpl +++ /dev/null @@ -1,38 +0,0 @@ -# 1Password Connect bootstrap credentials -# -# This template is processed ONCE manually to bootstrap the system. -# After External Secrets is operational, this could be converted to an -# ExternalSecret for self-management (chicken-and-egg bootstrap). -# -# Prerequisites: -# 1. Create Connect server: op connect server create blumeops --vaults blumeops -# 2. Create token: op connect token create blumeops --server <server-id> --vault blumeops -# 3. Create 1Password item "1Password Connect" in blumeops vault with: -# - credentials-file: contents of 1password-credentials.json (raw JSON) -# - token: the access token -# -# Usage: -# kubectl --context=minikube-indri create namespace 1password -# op inject -i argocd/manifests/1password-connect/secret-credentials.yaml.tpl | \ -# kubectl --context=minikube-indri apply -f - -# -# Note: chart 2.3.0+ mounts credentials as a file with standard k8s base64. -# Use raw JSON here (not pre-encoded); k8s stringData handles encoding. -# -apiVersion: v1 -kind: Secret -metadata: - name: op-credentials - namespace: 1password -type: Opaque -stringData: - 1password-credentials.json: "{{ op://blumeops/1Password Connect/credentials-file }}" ---- -apiVersion: v1 -kind: Secret -metadata: - name: onepassword-token - namespace: 1password -type: Opaque -stringData: - token: "{{ op://blumeops/1Password Connect/token }}" diff --git a/argocd/manifests/1password-connect/service.yaml b/argocd/manifests/1password-connect/service.yaml deleted file mode 100644 index 1ea8a7e..0000000 --- a/argocd/manifests/1password-connect/service.yaml +++ /dev/null @@ -1,18 +0,0 @@ -# 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/alloy-k8s/config.alloy b/argocd/manifests/alloy-k8s/config.alloy deleted file mode 100644 index 2940b0b..0000000 --- a/argocd/manifests/alloy-k8s/config.alloy +++ /dev/null @@ -1,222 +0,0 @@ -// Alloy k8s configuration - collects pod logs from all namespaces - -// ============== K8S POD LOG DISCOVERY ============== - -// Discover all pods in the cluster -discovery.kubernetes "pods" { - role = "pod" -} - -// Relabel to extract useful metadata -discovery.relabel "pods" { - targets = discovery.kubernetes.pods.targets - - // Keep only running pods - rule { - source_labels = ["__meta_kubernetes_pod_phase"] - regex = "Pending|Succeeded|Failed|Unknown" - action = "drop" - } - - // Set namespace label - rule { - source_labels = ["__meta_kubernetes_namespace"] - target_label = "namespace" - } - - // Set pod name label - rule { - source_labels = ["__meta_kubernetes_pod_name"] - target_label = "pod" - } - - // Set container name label - rule { - source_labels = ["__meta_kubernetes_pod_container_name"] - target_label = "container" - } - - // Set app label from pod labels - rule { - source_labels = ["__meta_kubernetes_pod_label_app"] - target_label = "app" - } - - // Fallback: use app.kubernetes.io/name if no app label - rule { - source_labels = ["__meta_kubernetes_pod_label_app_kubernetes_io_name"] - target_label = "app" - regex = "(.+)" - action = "replace" - } - - // Set node name - rule { - source_labels = ["__meta_kubernetes_pod_node_name"] - target_label = "node" - } - - // Build the log path for the pod container - rule { - source_labels = ["__meta_kubernetes_pod_uid", "__meta_kubernetes_pod_container_name"] - target_label = "__path__" - separator = "/" - replacement = "/var/log/pods/*$1/$2/*.log" - } -} - -// Tail pod logs -loki.source.kubernetes "pods" { - targets = discovery.relabel.pods.output - forward_to = [loki.process.pods.receiver] -} - -// Process logs - parse JSON if present, add labels -loki.process "pods" { - forward_to = [loki.write.loki.receiver] - - // Drop noisy deprecation warning from minikube storage-provisioner - // See: https://github.com/kubernetes/minikube/issues/21009 - stage.drop { - source = "" - expression = "v1 Endpoints is deprecated" - } - - // Try to parse JSON logs (e.g., structured app logs) - // Handle both "msg" (common) and "message" (zot) field names - stage.json { - expressions = { - level = "level", - msg = "msg", - message = "message", - time = "time", - caller = "caller", - repository = "repository", - } - } - - // Drop JSON parsing error labels (non-JSON logs are fine, just won't have extracted fields) - stage.label_drop { - 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 = { - level = "", - caller = "", - repository = "", - } - } - - // Add cluster label for multi-cluster identification - stage.static_labels { - values = { cluster = "indri" } - } -} - -// Write logs to Loki -loki.write "loki" { - endpoint { - url = "http://loki.monitoring.svc.cluster.local:3100/loki/api/v1/push" - } -} - -// ============== SERVICE HEALTH PROBES ============== - -// Blackbox-style HTTP probes for k8s services -prometheus.exporter.blackbox "services" { - config = "{ modules: { http_2xx: { prober: http, timeout: 5s } } }" - - target { - name = "miniflux" - address = "http://miniflux.miniflux.svc.cluster.local:8080/healthcheck" - module = "http_2xx" - } - - target { - name = "kiwix" - address = "http://kiwix.kiwix.svc.cluster.local:80/" - module = "http_2xx" - } - - target { - name = "transmission" - address = "http://transmission.torrent.svc.cluster.local:9091/transmission/web/" - module = "http_2xx" - } - - 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 = "https://pypi.ops.eblu.me/+api" - module = "http_2xx" - } - - target { - name = "argocd" - 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 { - // Migrated to ringtail (wave-1); probe through Caddy over Tailscale. - name = "teslamate" - address = "https://tesla.ops.eblu.me/" - module = "http_2xx" - } - - target { - name = "navidrome" - address = "http://navidrome.navidrome.svc.cluster.local:4533/" - 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 Prometheus -prometheus.remote_write "prometheus" { - external_labels = { cluster = "indri" } - - endpoint { - url = "http://prometheus.monitoring.svc.cluster.local:9090/api/v1/write" - } -} diff --git a/argocd/manifests/alloy-k8s/configmap.yaml b/argocd/manifests/alloy-k8s/configmap.yaml new file mode 100644 index 0000000..b0e6643 --- /dev/null +++ b/argocd/manifests/alloy-k8s/configmap.yaml @@ -0,0 +1,176 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: alloy-config + namespace: alloy +data: + config.alloy: | + // Alloy k8s configuration - collects pod logs from all namespaces + + // ============== K8S POD LOG DISCOVERY ============== + + // Discover all pods in the cluster + discovery.kubernetes "pods" { + role = "pod" + } + + // Relabel to extract useful metadata + discovery.relabel "pods" { + targets = discovery.kubernetes.pods.targets + + // Keep only running pods + rule { + source_labels = ["__meta_kubernetes_pod_phase"] + regex = "Pending|Succeeded|Failed|Unknown" + action = "drop" + } + + // Set namespace label + rule { + source_labels = ["__meta_kubernetes_namespace"] + target_label = "namespace" + } + + // Set pod name label + rule { + source_labels = ["__meta_kubernetes_pod_name"] + target_label = "pod" + } + + // Set container name label + rule { + source_labels = ["__meta_kubernetes_pod_container_name"] + target_label = "container" + } + + // Set app label from pod labels + rule { + source_labels = ["__meta_kubernetes_pod_label_app"] + target_label = "app" + } + + // Fallback: use app.kubernetes.io/name if no app label + rule { + source_labels = ["__meta_kubernetes_pod_label_app_kubernetes_io_name"] + target_label = "app" + regex = "(.+)" + action = "replace" + } + + // Set node name + rule { + source_labels = ["__meta_kubernetes_pod_node_name"] + target_label = "node" + } + + // Build the log path for the pod container + rule { + source_labels = ["__meta_kubernetes_pod_uid", "__meta_kubernetes_pod_container_name"] + target_label = "__path__" + separator = "/" + replacement = "/var/log/pods/*$1/$2/*.log" + } + } + + // Tail pod logs + loki.source.kubernetes "pods" { + targets = discovery.relabel.pods.output + forward_to = [loki.process.pods.receiver] + } + + // Process logs - parse JSON if present, add labels + loki.process "pods" { + forward_to = [loki.write.loki.receiver] + + // Drop noisy deprecation warning from minikube storage-provisioner + // See: https://github.com/kubernetes/minikube/issues/21009 + stage.drop { + source = "" + expression = "v1 Endpoints is deprecated" + } + + // Try to parse JSON logs (e.g., structured app logs) + // Handle both "msg" (common) and "message" (zot) field names + stage.json { + expressions = { + level = "level", + msg = "msg", + message = "message", + time = "time", + caller = "caller", + repository = "repository", + } + } + + // Drop JSON parsing error labels (non-JSON logs are fine, just won't have extracted fields) + stage.label_drop { + values = ["__error__", "__error_details__"] + } + + // Extract labels from parsed JSON data + stage.labels { + values = { + level = "", + caller = "", + repository = "", + } + } + } + + // Write logs to Loki + loki.write "loki" { + endpoint { + url = "http://loki.monitoring.svc.cluster.local:3100/loki/api/v1/push" + } + } + + // ============== SERVICE HEALTH PROBES ============== + + // Blackbox-style HTTP probes for k8s services + prometheus.exporter.blackbox "services" { + config = "{ modules: { http_2xx: { prober: http, timeout: 5s } } }" + + target { + name = "miniflux" + address = "http://miniflux.miniflux.svc.cluster.local:8080/healthcheck" + module = "http_2xx" + } + + target { + name = "kiwix" + address = "http://kiwix.kiwix.svc.cluster.local:80/" + module = "http_2xx" + } + + target { + name = "transmission" + address = "http://transmission.torrent.svc.cluster.local:9091/transmission/web/" + module = "http_2xx" + } + + target { + name = "devpi" + address = "http://devpi.devpi.svc.cluster.local:3141/+api" + module = "http_2xx" + } + + target { + name = "argocd" + address = "http://argocd-server.argocd.svc.cluster.local:80/healthz" + 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 Prometheus + prometheus.remote_write "prometheus" { + endpoint { + url = "http://prometheus.monitoring.svc.cluster.local:9090/api/v1/write" + } + } diff --git a/argocd/manifests/alloy-k8s/daemonset.yaml b/argocd/manifests/alloy-k8s/daemonset.yaml index f1758cd..95f780b 100644 --- a/argocd/manifests/alloy-k8s/daemonset.yaml +++ b/argocd/manifests/alloy-k8s/daemonset.yaml @@ -17,11 +17,9 @@ spec: serviceAccountName: alloy securityContext: fsGroup: 473 # alloy user group - seccompProfile: - type: RuntimeDefault containers: - name: alloy - image: registry.ops.eblu.me/blumeops/alloy:kustomized + image: grafana/alloy:v1.5.1 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 3503ead..17cd3c4 100644 --- a/argocd/manifests/alloy-k8s/kustomization.yaml +++ b/argocd/manifests/alloy-k8s/kustomization.yaml @@ -1,18 +1,7 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization - -namespace: alloy - resources: - namespace.yaml - rbac.yaml + - configmap.yaml - daemonset.yaml - -images: - - name: registry.ops.eblu.me/blumeops/alloy - newTag: v1.16.0-9564435 - -configMapGenerator: - - name: alloy-config - files: - - config.alloy diff --git a/argocd/manifests/alloy-ringtail/config.alloy b/argocd/manifests/alloy-ringtail/config.alloy deleted file mode 100644 index e5cc045..0000000 --- a/argocd/manifests/alloy-ringtail/config.alloy +++ /dev/null @@ -1,207 +0,0 @@ -// Alloy ringtail configuration - collects host metrics, pod logs, and kube-state-metrics -// Remote-writes metrics to indri Prometheus, logs to indri Loki - -// ============== HOST METRICS ============== - -// System metrics exporter (Linux host via /host/proc, /host/sys mounts) -prometheus.exporter.unix "system" { - procfs_path = "/host/proc" - sysfs_path = "/host/sys" - rootfs_path = "/host/root" -} - -// Scrape system metrics and add instance label -prometheus.scrape "system" { - targets = prometheus.exporter.unix.system.targets - forward_to = [prometheus.relabel.instance.receiver] - scrape_interval = "15s" -} - -// Add instance label -prometheus.relabel "instance" { - forward_to = [prometheus.remote_write.prometheus.receiver] - - rule { - target_label = "instance" - replacement = "ringtail" - } -} - -// ============== 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" { - targets = [{"__address__" = "kube-state-metrics.monitoring.svc.cluster.local:8080"}] - scrape_interval = "15s" - 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" } - - endpoint { - url = "https://prometheus.tail8d86e.ts.net/api/v1/write" - - tls_config { - insecure_skip_verify = true - } - } -} - -// ============== K8S POD LOG DISCOVERY ============== - -// Discover all pods in the cluster -discovery.kubernetes "pods" { - role = "pod" -} - -// Relabel to extract useful metadata -discovery.relabel "pods" { - targets = discovery.kubernetes.pods.targets - - // Keep only running pods - rule { - source_labels = ["__meta_kubernetes_pod_phase"] - regex = "Pending|Succeeded|Failed|Unknown" - action = "drop" - } - - // Set namespace label - rule { - source_labels = ["__meta_kubernetes_namespace"] - target_label = "namespace" - } - - // Set pod name label - rule { - source_labels = ["__meta_kubernetes_pod_name"] - target_label = "pod" - } - - // Set container name label - rule { - source_labels = ["__meta_kubernetes_pod_container_name"] - target_label = "container" - } - - // Set app label from pod labels - rule { - source_labels = ["__meta_kubernetes_pod_label_app"] - target_label = "app" - } - - // Fallback: use app.kubernetes.io/name if no app label - rule { - source_labels = ["__meta_kubernetes_pod_label_app_kubernetes_io_name"] - target_label = "app" - regex = "(.+)" - action = "replace" - } - - // Set node name - rule { - source_labels = ["__meta_kubernetes_pod_node_name"] - target_label = "node" - } - - // Build the log path for the pod container - rule { - source_labels = ["__meta_kubernetes_pod_uid", "__meta_kubernetes_pod_container_name"] - target_label = "__path__" - separator = "/" - replacement = "/var/log/pods/*$1/$2/*.log" - } -} - -// Tail pod logs -loki.source.kubernetes "pods" { - targets = discovery.relabel.pods.output - forward_to = [loki.process.pods.receiver] -} - -// Process logs - parse JSON if present, add labels -loki.process "pods" { - forward_to = [loki.write.loki.receiver] - - // Try to parse JSON logs - stage.json { - expressions = { - level = "level", - msg = "msg", - message = "message", - time = "time", - caller = "caller", - } - } - - // Drop JSON parsing error labels (non-JSON logs are fine) - stage.label_drop { - 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 = { - level = "", - caller = "", - } - } - - // Add cluster label for multi-cluster identification - stage.static_labels { - values = { cluster = "ringtail" } - } -} - -// Write logs to indri Loki -loki.write "loki" { - endpoint { - url = "https://loki.tail8d86e.ts.net/loki/api/v1/push" - - tls_config { - insecure_skip_verify = true - } - } -} diff --git a/argocd/manifests/alloy-ringtail/daemonset.yaml b/argocd/manifests/alloy-ringtail/daemonset.yaml deleted file mode 100644 index cdd264d..0000000 --- a/argocd/manifests/alloy-ringtail/daemonset.yaml +++ /dev/null @@ -1,90 +0,0 @@ -apiVersion: apps/v1 -kind: DaemonSet -metadata: - name: alloy - namespace: alloy - labels: - app: alloy -spec: - selector: - matchLabels: - app: alloy - template: - metadata: - labels: - app: alloy - spec: - serviceAccountName: alloy - securityContext: - fsGroup: 473 # alloy user group - containers: - - name: alloy - image: registry.ops.eblu.me/blumeops/alloy:kustomized - args: - - run - - --server.http.listen-addr=0.0.0.0:12345 - - --storage.path=/var/lib/alloy/data - - /etc/alloy/config.alloy - ports: - - containerPort: 12345 - name: http - env: - - name: HOSTNAME - valueFrom: - fieldRef: - fieldPath: spec.nodeName - - name: HOST_IP - valueFrom: - fieldRef: - fieldPath: status.hostIP - resources: - requests: - cpu: 50m - memory: 128Mi - limits: - cpu: 500m - memory: 512Mi - volumeMounts: - - name: config - mountPath: /etc/alloy - - name: varlog - mountPath: /var/log - readOnly: true - - name: data - mountPath: /var/lib/alloy/data - - name: proc - mountPath: /host/proc - readOnly: true - - name: sys - mountPath: /host/sys - readOnly: true - - name: root - mountPath: /host/root - mountPropagation: HostToContainer - readOnly: true - securityContext: - allowPrivilegeEscalation: false - readOnlyRootFilesystem: true - capabilities: - drop: - - ALL - tolerations: - - operator: Exists - volumes: - - name: config - configMap: - name: alloy-config - - name: varlog - hostPath: - path: /var/log - - name: data - emptyDir: {} - - name: proc - hostPath: - path: /proc - - name: sys - hostPath: - path: /sys - - name: root - hostPath: - path: / diff --git a/argocd/manifests/alloy-ringtail/kustomization.yaml b/argocd/manifests/alloy-ringtail/kustomization.yaml deleted file mode 100644 index 526fec5..0000000 --- a/argocd/manifests/alloy-ringtail/kustomization.yaml +++ /dev/null @@ -1,18 +0,0 @@ -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization - -namespace: alloy - -resources: - - namespace.yaml - - rbac.yaml - - daemonset.yaml - -images: - - name: registry.ops.eblu.me/blumeops/alloy - newTag: v1.16.0-9564435-nix - -configMapGenerator: - - name: alloy-config - files: - - config.alloy diff --git a/argocd/manifests/alloy-ringtail/namespace.yaml b/argocd/manifests/alloy-ringtail/namespace.yaml deleted file mode 100644 index 94f62be..0000000 --- a/argocd/manifests/alloy-ringtail/namespace.yaml +++ /dev/null @@ -1,4 +0,0 @@ -apiVersion: v1 -kind: Namespace -metadata: - name: alloy diff --git a/argocd/manifests/alloy-ringtail/rbac.yaml b/argocd/manifests/alloy-ringtail/rbac.yaml deleted file mode 100644 index 58a31df..0000000 --- a/argocd/manifests/alloy-ringtail/rbac.yaml +++ /dev/null @@ -1,35 +0,0 @@ -apiVersion: v1 -kind: ServiceAccount -metadata: - name: alloy - namespace: alloy ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - name: alloy -rules: - - apiGroups: [""] - resources: ["nodes", "nodes/proxy", "nodes/metrics", "services", "endpoints", "pods", "pods/log", "namespaces"] - verbs: ["get", "list", "watch"] - - apiGroups: [""] - resources: ["configmaps"] - verbs: ["get"] - - apiGroups: ["discovery.k8s.io"] - resources: ["endpointslices"] - verbs: ["get", "list", "watch"] - - nonResourceURLs: ["/metrics", "/metrics/cadvisor"] - verbs: ["get"] ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRoleBinding -metadata: - name: alloy -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: ClusterRole - name: alloy -subjects: - - kind: ServiceAccount - name: alloy - namespace: alloy diff --git a/argocd/manifests/alloy-tracing-ringtail/config.alloy b/argocd/manifests/alloy-tracing-ringtail/config.alloy deleted file mode 100644 index d3f0445..0000000 --- a/argocd/manifests/alloy-tracing-ringtail/config.alloy +++ /dev/null @@ -1,93 +0,0 @@ -// Alloy tracing configuration for ringtail -// Uses Beyla eBPF to auto-instrument HTTP services and export traces to Tempo on indri - -// ============== BEYLA eBPF AUTO-INSTRUMENTATION ============== - -beyla.ebpf "http_services" { - discovery { - // Instrument HTTP services on common ports - instrument { - open_ports = "80-9999" - } - - // Exclude infrastructure pods - exclude_instrument { - kubernetes { - namespace = "kube-system" - } - } - exclude_instrument { - kubernetes { - namespace = "tailscale" - } - } - exclude_instrument { - kubernetes { - pod_labels = { app = "alloy" } - } - } - exclude_instrument { - kubernetes { - pod_labels = { app = "alloy-tracing" } - } - } - exclude_instrument { - kubernetes { - pod_labels = { app = "kube-state-metrics" } - } - } - exclude_instrument { - kubernetes { - pod_labels = { "app.kubernetes.io/name" = "nvidia-device-plugin" } - } - } - } - - attributes { - kubernetes { - enable = "true" - cluster_name = "ringtail" - } - } - - traces { - instrumentations = ["http"] - } - - output { - traces = [otelcol.processor.batch.default.input] - } -} - -// ============== OTEL TRACE PIPELINE ============== - -// Batch traces before export -otelcol.processor.batch "default" { - output { - traces = [otelcol.processor.attributes.add_cluster.input] - } -} - -// Add cluster label to all spans -otelcol.processor.attributes "add_cluster" { - action { - key = "cluster" - value = "ringtail" - action = "upsert" - } - - output { - traces = [otelcol.exporter.otlphttp.tempo.input] - } -} - -// Export traces to Tempo on indri via Tailscale -otelcol.exporter.otlphttp "tempo" { - client { - endpoint = "https://tempo-otlp.tail8d86e.ts.net" - - tls { - insecure_skip_verify = true - } - } -} diff --git a/argocd/manifests/alloy-tracing-ringtail/daemonset.yaml b/argocd/manifests/alloy-tracing-ringtail/daemonset.yaml deleted file mode 100644 index b3de1de..0000000 --- a/argocd/manifests/alloy-tracing-ringtail/daemonset.yaml +++ /dev/null @@ -1,57 +0,0 @@ -apiVersion: apps/v1 -kind: DaemonSet -metadata: - name: alloy-tracing - namespace: alloy - labels: - app: alloy-tracing -spec: - selector: - matchLabels: - app: alloy-tracing - template: - metadata: - labels: - app: alloy-tracing - spec: - serviceAccountName: alloy-tracing - hostPID: true - containers: - - name: alloy - image: registry.ops.eblu.me/blumeops/alloy:kustomized - args: - - run - - --server.http.listen-addr=0.0.0.0:12346 - - --storage.path=/var/lib/alloy/data - - /etc/alloy/config.alloy - ports: - - containerPort: 12346 - name: http - env: - - name: HOSTNAME - valueFrom: - fieldRef: - fieldPath: spec.nodeName - resources: - requests: - cpu: 100m - memory: 256Mi - limits: - cpu: "1" - memory: 1Gi - volumeMounts: - - name: config - mountPath: /etc/alloy - - name: data - mountPath: /var/lib/alloy/data - securityContext: - privileged: true - runAsUser: 0 - tolerations: - - operator: Exists - volumes: - - name: config - configMap: - name: alloy-tracing-config - - name: data - emptyDir: {} diff --git a/argocd/manifests/alloy-tracing-ringtail/kustomization.yaml b/argocd/manifests/alloy-tracing-ringtail/kustomization.yaml deleted file mode 100644 index b1e6338..0000000 --- a/argocd/manifests/alloy-tracing-ringtail/kustomization.yaml +++ /dev/null @@ -1,17 +0,0 @@ -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization - -namespace: alloy - -resources: - - rbac.yaml - - daemonset.yaml - -images: - - name: registry.ops.eblu.me/blumeops/alloy - newTag: v1.16.0-9564435-nix - -configMapGenerator: - - name: alloy-tracing-config - files: - - config.alloy diff --git a/argocd/manifests/alloy-tracing-ringtail/rbac.yaml b/argocd/manifests/alloy-tracing-ringtail/rbac.yaml deleted file mode 100644 index 656c5f6..0000000 --- a/argocd/manifests/alloy-tracing-ringtail/rbac.yaml +++ /dev/null @@ -1,30 +0,0 @@ -apiVersion: v1 -kind: ServiceAccount -metadata: - name: alloy-tracing - namespace: alloy ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - name: alloy-tracing -rules: - - apiGroups: [""] - resources: ["pods", "services", "endpoints", "nodes", "namespaces"] - verbs: ["get", "list", "watch"] - - apiGroups: ["apps"] - resources: ["deployments", "replicasets", "statefulsets", "daemonsets"] - verbs: ["get", "list", "watch"] ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRoleBinding -metadata: - name: alloy-tracing -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: ClusterRole - name: alloy-tracing -subjects: - - kind: ServiceAccount - name: alloy-tracing - namespace: alloy diff --git a/argocd/manifests/argocd/README.md b/argocd/manifests/argocd/README.md index 2eaf4d4..42762df 100644 --- a/argocd/manifests/argocd/README.md +++ b/argocd/manifests/argocd/README.md @@ -25,18 +25,16 @@ 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 +argocd login argocd.tail8d86e.ts.net --username admin --grpc-web argocd account update-password # 6. Apply repo-creds-forge credential template for SSH access to all forge repos 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 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" && \ + --from-literal=url='ssh://forgejo@indri.tail8d86e.ts.net:2200/eblume/' \ + --from-literal=insecure=true \ + --from-literal=sshPrivateKey="$PRIV_KEY" && \ kubectl label secret repo-creds-forge -n argocd argocd.argoproj.io/secret-type=repo-creds # 7. Apply ArgoCD Applications (self-management + app-of-apps) @@ -84,7 +82,7 @@ metadata: spec: project: default source: - repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git + repoURL: ssh://forgejo@indri.tail8d86e.ts.net:2200/eblume/blumeops.git targetRevision: main path: argocd/manifests/my-app destination: @@ -112,6 +110,6 @@ spec: - **TODO:** Secrets (`repo-creds-forge`) are not managed by ArgoCD and must be applied manually. 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. +- The credential template (`repo-creds`) uses a URL prefix to match all repos under `eblume/`. - ArgoCD uses Tailscale Ingress with Let's Encrypt for TLS termination. -- 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. +- The `--grpc-web` flag is required for CLI access through the Tailscale ingress. diff --git a/argocd/manifests/argocd/argocd-cm-patch.yaml b/argocd/manifests/argocd/argocd-cm-patch.yaml deleted file mode 100644 index 54e4ede..0000000 --- a/argocd/manifests/argocd/argocd-cm-patch.yaml +++ /dev/null @@ -1,22 +0,0 @@ -# ArgoCD ConfigMap patch -# -# - workflow-bot: service account for CI/CD automation -# - OIDC: Authentik SSO for admin login -# -apiVersion: v1 -kind: ConfigMap -metadata: - name: argocd-cm -data: - url: https://argocd.ops.eblu.me - # workflow-bot: service account for CI/CD automation - # - apiKey: allows generating API tokens via `argocd account generate-token` - accounts.workflow-bot: apiKey - oidc.config: | - name: Authentik - issuer: https://authentik.ops.eblu.me/application/o/argocd/ - clientID: argocd - requestedScopes: - - openid - - profile - - email diff --git a/argocd/manifests/argocd/argocd-rbac-cm-patch.yaml b/argocd/manifests/argocd/argocd-rbac-cm-patch.yaml deleted file mode 100644 index 4914587..0000000 --- a/argocd/manifests/argocd/argocd-rbac-cm-patch.yaml +++ /dev/null @@ -1,20 +0,0 @@ -# ArgoCD RBAC ConfigMap patch -# -# - 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 -metadata: - name: argocd-rbac-cm -data: - scopes: '[groups]' - policy.csv: | - p, role:workflow-bot, applications, sync, *, allow - p, role:workflow-bot, applications, get, *, allow - g, workflow-bot, role:workflow-bot - g, admins, role:admin - g, admin, role:admin diff --git a/argocd/manifests/argocd/argocd-resources-patch.yaml b/argocd/manifests/argocd/argocd-resources-patch.yaml deleted file mode 100644 index 1ae0675..0000000 --- a/argocd/manifests/argocd/argocd-resources-patch.yaml +++ /dev/null @@ -1,118 +0,0 @@ -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/argocd-ssh-known-hosts-cm.yaml b/argocd/manifests/argocd/argocd-ssh-known-hosts-cm.yaml index cf3b728..61525aa 100644 --- a/argocd/manifests/argocd/argocd-ssh-known-hosts-cm.yaml +++ b/argocd/manifests/argocd/argocd-ssh-known-hosts-cm.yaml @@ -1,5 +1,5 @@ -# Patch to add forge SSH host key to ArgoCD known_hosts -# Includes upstream defaults plus forge.ops.eblu.me:2222 +# Patch to add forge (indri) SSH host key to ArgoCD known_hosts +# Includes upstream defaults plus indri.tail8d86e.ts.net:2200 apiVersion: v1 kind: ConfigMap metadata: @@ -21,5 +21,5 @@ data: gitlab.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCsj2bNKTBSpIYDEGk9KxsGh3mySTRgMtXL583qmBpzeQ+jqCMRgBqB98u3z++J1sKlXHWfM9dyhSevkMwSbhoR8XIq/U0tCNyokEi/ueaBMCvbcTHhO7FcwzY92WK4Yt0aGROY5qX2UKSeOvuP4D6TPqKF1onrSzH9bx9XUf2lEdWT/ia1NEKjunUqu1xOB/StKDHMoX4/OKyIzuS0q/T1zOATthvasJFoPrAjkohTyaDUz2LN5JoH839hViyEG82yB+MjcFV5MU3N1l1QL3cVUCh93xSaua1N85qivl+siMkPGbO5xR/En4iEY6K2XPASUEMaieWVNTRCtJ4S8H+9 ssh.dev.azure.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC7Hr1oTWqNqOlzGJOfGJ4NakVyIzf1rXYd4d7wo6jBlkLvCA4odBlL0mDUyZ0/QUfTTqeu+tm22gOsv+VrVTMk6vwRU75gY/y9ut5Mb3bR5BV58dKXyq9A9UeB5Cakehn5Zgm6x1mKoVyf+FFn26iYqXJRgzIZZcZ5V6hrE0Qg39kZm4az48o0AUbf6Sp4SLdvnuMa2sVNwHBboS7EJkm57XQPVU3/QpyNLHbWDdzwtrlS+ez30S3AdYhLKEOxAG8weOnyrtLJAUen9mTkol8oII1edf7mWWbWVf0nBmly21+nZcmCTISQBtdcyPaEno7fFQMDD26/s0lfKob4Kw8H vs-ssh.visualstudio.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC7Hr1oTWqNqOlzGJOfGJ4NakVyIzf1rXYd4d7wo6jBlkLvCA4odBlL0mDUyZ0/QUfTTqeu+tm22gOsv+VrVTMk6vwRU75gY/y9ut5Mb3bR5BV58dKXyq9A9UeB5Cakehn5Zgm6x1mKoVyf+FFn26iYqXJRgzIZZcZ5V6hrE0Qg39kZm4az48o0AUbf6Sp4SLdvnuMa2sVNwHBboS7EJkm57XQPVU3/QpyNLHbWDdzwtrlS+ez30S3AdYhLKEOxAG8weOnyrtLJAUen9mTkol8oII1edf7mWWbWVf0nBmly21+nZcmCTISQBtdcyPaEno7fFQMDD26/s0lfKob4Kw8H - # Forge - Forgejo SSH on port 2222 - [forge.ops.eblu.me]:2222 ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDlGQT5w03XxlhmEiDVtGq2SkhLIZU4vYhdMey/T2tFLp7kEiOwCWgDgbBn12VDfqXTXJreykBuREqYNSx4tL4Znwap0+HjLOjTIVri8af2ZFF6IP52pcmJEOnxm/yUZhJCosu1wOZwLOoQEPBYM6sPN4OY9PFOsrsxMO2LWPJAZujPlnsfKOTsIS5iRpiT4yU7Z+oWB21rMxjZ9sXZRn8PI2MbUIs/Yazpah2XPJm2YJ7C+kqTLmld4mXQaQtHhzvPaRNB59RS8xyinuaRs618tD3DQq3Qpt8ZZKZydLVv4CIrGvjdqavt0l+4rsNGBh8dWvDR7l2Z6wo9ggDCej957+J6tInfZ82KHSW3ONdm2mUOHObUVSte2xUPlRpnIBFt3lcCapifPULE7PuN0Xdw4r+ewr+6R65RzdptqGfKyyAYsERhbq904ryNZ9fy30vH8+j9imL5AhMkCbP8S/UW49rDIdfN6MvZlX9MoBhmbrkv+kETB7qz9zaOrocEOZOE3fzB9iZxNwlXjstUnjkqi4P1yY/SKpyLC/yDCUpxC79FbCAKIJwar3C2mZaLeBGyqL31HPKOx175VsSxIbjeJX8uNO9WhbFPlcbRETeEoq+dczeU25OESCyyelGb72tTNJYObn2R8Br9NFPiwGZJX6TLlKqaE7x3D0M64ncTJQ== + # Forge (indri) - Forgejo SSH on port 2200 + [indri.tail8d86e.ts.net]:2200 ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDlGQT5w03XxlhmEiDVtGq2SkhLIZU4vYhdMey/T2tFLp7kEiOwCWgDgbBn12VDfqXTXJreykBuREqYNSx4tL4Znwap0+HjLOjTIVri8af2ZFF6IP52pcmJEOnxm/yUZhJCosu1wOZwLOoQEPBYM6sPN4OY9PFOsrsxMO2LWPJAZujPlnsfKOTsIS5iRpiT4yU7Z+oWB21rMxjZ9sXZRn8PI2MbUIs/Yazpah2XPJm2YJ7C+kqTLmld4mXQaQtHhzvPaRNB59RS8xyinuaRs618tD3DQq3Qpt8ZZKZydLVv4CIrGvjdqavt0l+4rsNGBh8dWvDR7l2Z6wo9ggDCej957+J6tInfZ82KHSW3ONdm2mUOHObUVSte2xUPlRpnIBFt3lcCapifPULE7PuN0Xdw4r+ewr+6R65RzdptqGfKyyAYsERhbq904ryNZ9fy30vH8+j9imL5AhMkCbP8S/UW49rDIdfN6MvZlX9MoBhmbrkv+kETB7qz9zaOrocEOZOE3fzB9iZxNwlXjstUnjkqi4P1yY/SKpyLC/yDCUpxC79FbCAKIJwar3C2mZaLeBGyqL31HPKOx175VsSxIbjeJX8uNO9WhbFPlcbRETeEoq+dczeU25OESCyyelGb72tTNJYObn2R8Br9NFPiwGZJX6TLlKqaE7x3D0M64ncTJQ== diff --git a/argocd/manifests/argocd/external-secret-repo-forge.yaml b/argocd/manifests/argocd/external-secret-repo-forge.yaml deleted file mode 100644 index f7fd74e..0000000 --- a/argocd/manifests/argocd/external-secret-repo-forge.yaml +++ /dev/null @@ -1,33 +0,0 @@ -# ArgoCD repo-creds template — matches all repos on forge via SSH -# -apiVersion: external-secrets.io/v1 -kind: ExternalSecret -metadata: - name: repo-creds-forge - namespace: argocd -spec: - refreshInterval: 1h - secretStoreRef: - kind: ClusterSecretStore - name: onepassword-blumeops - target: - name: repo-creds-forge - creationPolicy: Owner - template: - metadata: - labels: - argocd.argoproj.io/secret-type: repo-creds - data: - type: git - url: "ssh://forgejo@forge.ops.eblu.me:2222/" - insecure: "false" - sshPrivateKey: "{{ .privateKey }}" - sshKnownHosts: "[forge.ops.eblu.me]:2222 ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDlGQT5w03XxlhmEiDVtGq2SkhLIZU4vYhdMey/T2tFLp7kEiOwCWgDgbBn12VDfqXTXJreykBuREqYNSx4tL4Znwap0+HjLOjTIVri8af2ZFF6IP52pcmJEOnxm/yUZhJCosu1wOZwLOoQEPBYM6sPN4OY9PFOsrsxMO2LWPJAZujPlnsfKOTsIS5iRpiT4yU7Z+oWB21rMxjZ9sXZRn8PI2MbUIs/Yazpah2XPJm2YJ7C+kqTLmld4mXQaQtHhzvPaRNB59RS8xyinuaRs618tD3DQq3Qpt8ZZKZydLVv4CIrGvjdqavt0l+4rsNGBh8dWvDR7l2Z6wo9ggDCej957+J6tInfZ82KHSW3ONdm2mUOHObUVSte2xUPlRpnIBFt3lcCapifPULE7PuN0Xdw4r+ewr+6R65RzdptqGfKyyAYsERhbq904ryNZ9fy30vH8+j9imL5AhMkCbP8S/UW49rDIdfN6MvZlX9MoBhmbrkv+kETB7qz9zaOrocEOZOE3fzB9iZxNwlXjstUnjkqi4P1yY/SKpyLC/yDCUpxC79FbCAKIJwar3C2mZaLeBGyqL31HPKOx175VsSxIbjeJX8uNO9WhbFPlcbRETeEoq+dczeU25OESCyyelGb72tTNJYObn2R8Br9NFPiwGZJX6TLlKqaE7x3D0M64ncTJQ==" - data: - - secretKey: privateKey - remoteRef: - conversionStrategy: Default - decodingStrategy: None - key: argocd-forge-ssh-key - metadataPolicy: None - property: private-key-openssh diff --git a/argocd/manifests/argocd/kustomization.yaml b/argocd/manifests/argocd/kustomization.yaml index 6deb7ec..6662c4b 100644 --- a/argocd/manifests/argocd/kustomization.yaml +++ b/argocd/manifests/argocd/kustomization.yaml @@ -5,14 +5,9 @@ namespace: argocd resources: # Pin to specific version for intentional upgrades - # ArgoCD v3.3.6 - - https://raw.githubusercontent.com/argoproj/argo-cd/998fb59dc355653c0657908a6ea2f87136e022d1/manifests/install.yaml - - ingress-tailscale.yaml - - external-secret-repo-forge.yaml + - https://raw.githubusercontent.com/argoproj/argo-cd/v3.2.6/manifests/install.yaml + - service-tailscale.yaml patches: - path: argocd-cmd-params-cm.yaml - 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/argocd/manifests/argocd/repo-forge-secret.yaml.tpl b/argocd/manifests/argocd/repo-forge-secret.yaml.tpl new file mode 100644 index 0000000..e72b037 --- /dev/null +++ b/argocd/manifests/argocd/repo-forge-secret.yaml.tpl @@ -0,0 +1,31 @@ +# ArgoCD credential template for forge SSH access +# This is a repo-creds (credential template) that matches ALL repos under eblume/ +# +# IMPORTANT: Use ?ssh-format=openssh to get OpenSSH format (required by ArgoCD) +# +# The SSH key must be added to the Forgejo user's SSH keys (not as a deploy key) +# so it has access to all repos owned by that user. +# +# Create the secret with: +# +# PRIV_KEY=$(op read "op://vg6xf6vvfmoh5hqjjhlhbeoaie/csjncynh6htjvnh2l2da65y32q/private key?ssh-format=openssh")$'\n' && \ +# kubectl create secret generic repo-creds-forge -n argocd \ +# --from-literal=type=git \ +# --from-literal=url='ssh://forgejo@indri.tail8d86e.ts.net:2200/eblume/' \ +# --from-literal=insecure=true \ +# --from-literal=sshPrivateKey="$PRIV_KEY" && \ +# kubectl label secret repo-creds-forge -n argocd argocd.argoproj.io/secret-type=repo-creds +# +apiVersion: v1 +kind: Secret +metadata: + name: repo-creds-forge + namespace: argocd + labels: + argocd.argoproj.io/secret-type: repo-creds +stringData: + type: git + url: ssh://forgejo@indri.tail8d86e.ts.net:2200/eblume/ + insecure: "true" + sshPrivateKey: | + # Key from 1Password: op://vg6xf6vvfmoh5hqjjhlhbeoaie/csjncynh6htjvnh2l2da65y32q/private key diff --git a/argocd/manifests/argocd/ingress-tailscale.yaml b/argocd/manifests/argocd/service-tailscale.yaml similarity index 61% rename from argocd/manifests/argocd/ingress-tailscale.yaml rename to argocd/manifests/argocd/service-tailscale.yaml index 85393af..2fc4ce0 100644 --- a/argocd/manifests/argocd/ingress-tailscale.yaml +++ b/argocd/manifests/argocd/service-tailscale.yaml @@ -11,14 +11,6 @@ metadata: namespace: argocd annotations: tailscale.com/proxy-class: "default" - tailscale.com/proxy-group: "ingress" - gethomepage.dev/enabled: "true" - gethomepage.dev/name: "ArgoCD" - gethomepage.dev/group: "Infrastructure" - gethomepage.dev/icon: "argo-cd.png" - gethomepage.dev/description: "GitOps CD" - gethomepage.dev/href: "https://argocd.ops.eblu.me" - gethomepage.dev/pod-selector: "app.kubernetes.io/name=argocd-server" spec: ingressClassName: tailscale defaultBackend: diff --git a/argocd/manifests/authentik/configmap-blueprint.yaml b/argocd/manifests/authentik/configmap-blueprint.yaml deleted file mode 100644 index cc97dea..0000000 --- a/argocd/manifests/authentik/configmap-blueprint.yaml +++ /dev/null @@ -1,526 +0,0 @@ ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: authentik-blueprints - namespace: authentik -data: - common.yaml: | - version: 1 - metadata: - name: BlumeOps Common Identity - labels: - blueprints.goauthentik.io/description: "Shared groups and identity resources" - entries: - # admins group — gates access to admin-only applications - - model: authentik_core.group - id: admins-group - identifiers: - name: admins - attrs: - name: admins - - mfa.yaml: | - version: 1 - metadata: - name: BlumeOps MFA Enforcement - labels: - blueprints.goauthentik.io/description: "Require MFA on default authentication flow" - entries: - # Require MFA — force_setup prompts users without MFA to enroll. - - model: authentik_stages_authenticator_validate.authenticatorvalidatestage - identifiers: - name: default-authentication-mfa-validation - attrs: - not_configured_action: configure - device_classes: - - totp - - webauthn - - static - configuration_stages: - - !Find [authentik_stages_authenticator_totp.authenticatortotpstage, [name, default-authenticator-totp-setup]] - - !Find [authentik_stages_authenticator_static.authenticatorstaticstage, [name, default-authenticator-static-setup]] - - grafana.yaml: | - version: 1 - metadata: - name: BlumeOps Grafana SSO - labels: - blueprints.goauthentik.io/description: "Grafana OIDC provider and application" - entries: - # OAuth2 provider for Grafana - - model: authentik_providers_oauth2.oauth2provider - id: grafana-provider - identifiers: - name: Grafana - attrs: - name: Grafana - 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: grafana - client_secret: !Env AUTHENTIK_GRAFANA_CLIENT_SECRET - redirect_uris: - - matching_mode: strict - url: https://grafana.ops.eblu.me/login/generic_oauth - - matching_mode: strict - url: https://grafana.tail8d86e.ts.net/login/generic_oauth - 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 - - # Grafana application — linked to the OAuth2 provider - - model: authentik_core.application - id: grafana-app - identifiers: - slug: grafana - attrs: - name: Grafana - slug: grafana - provider: !KeyOf grafana-provider - meta_launch_url: https://grafana.ops.eblu.me - policy_engine_mode: any - - # Policy binding — restrict Grafana to admins group - - model: authentik_policies.policybinding - identifiers: - order: 0 - target: !KeyOf grafana-app - group: !Find [authentik_core.group, [name, admins]] - attrs: - target: !KeyOf grafana-app - group: !Find [authentik_core.group, [name, admins]] - order: 0 - enabled: true - negate: false - timeout: 30 - - forgejo.yaml: | - version: 1 - metadata: - name: BlumeOps Forgejo SSO - labels: - blueprints.goauthentik.io/description: "Forgejo OIDC provider and application" - entries: - # OAuth2 provider for Forgejo - - model: authentik_providers_oauth2.oauth2provider - id: forgejo-provider - identifiers: - name: Forgejo - attrs: - name: Forgejo - 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: forgejo - client_secret: !Env AUTHENTIK_FORGEJO_CLIENT_SECRET - redirect_uris: - - matching_mode: strict - url: https://forge.eblu.me/user/oauth2/authentik/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 - - # Forgejo application — linked to the OAuth2 provider - - model: authentik_core.application - id: forgejo-app - identifiers: - slug: forgejo - attrs: - name: Forgejo - slug: forgejo - provider: !KeyOf forgejo-provider - meta_launch_url: https://forge.eblu.me - policy_engine_mode: any - - # Policy binding — restrict Forgejo to admins group - - model: authentik_policies.policybinding - identifiers: - order: 0 - target: !KeyOf forgejo-app - group: !Find [authentik_core.group, [name, admins]] - attrs: - target: !KeyOf forgejo-app - group: !Find [authentik_core.group, [name, admins]] - order: 0 - enabled: true - negate: false - timeout: 30 - - zot.yaml: | - version: 1 - metadata: - name: BlumeOps Zot SSO - labels: - blueprints.goauthentik.io/description: "Zot OIDC provider, application, and CI identity" - entries: - # artifact-workloads group (CI push identity) - - model: authentik_core.group - id: artifact-workloads-group - identifiers: - name: artifact-workloads - attrs: - name: artifact-workloads - - # Service account for CI push (admin sets password via UI after deploy) - - model: authentik_core.user - id: zot-ci-user - identifiers: - username: zot-ci - attrs: - username: zot-ci - name: Zot CI Service Account - type: service_account - is_active: true - groups: - - !KeyOf artifact-workloads-group - - # OAuth2 provider for Zot - - model: authentik_providers_oauth2.oauth2provider - id: zot-provider - identifiers: - name: Zot - attrs: - name: Zot - 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: zot - client_secret: !Env AUTHENTIK_ZOT_CLIENT_SECRET - redirect_uris: - - matching_mode: strict - url: https://registry.ops.eblu.me/zot/auth/callback/oidc - 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 - - # Zot application — linked to the OAuth2 provider - - model: authentik_core.application - id: zot-app - identifiers: - slug: zot - attrs: - name: Zot Registry - slug: zot - provider: !KeyOf zot-provider - meta_launch_url: https://registry.ops.eblu.me - policy_engine_mode: any - - # Policy binding — allow admins group access to Zot - - model: authentik_policies.policybinding - identifiers: - order: 0 - target: !KeyOf zot-app - group: !Find [authentik_core.group, [name, admins]] - attrs: - target: !KeyOf zot-app - group: !Find [authentik_core.group, [name, admins]] - order: 0 - enabled: true - negate: false - timeout: 30 - - # Policy binding — allow artifact-workloads group access to Zot (CI push) - - model: authentik_policies.policybinding - identifiers: - order: 1 - target: !KeyOf zot-app - group: !KeyOf artifact-workloads-group - attrs: - target: !KeyOf zot-app - group: !KeyOf artifact-workloads-group - order: 1 - enabled: true - negate: false - timeout: 30 - - argocd.yaml: | - version: 1 - metadata: - name: BlumeOps ArgoCD SSO - labels: - blueprints.goauthentik.io/description: "ArgoCD OIDC provider and application" - entries: - # OAuth2 provider for ArgoCD - - model: authentik_providers_oauth2.oauth2provider - id: argocd-provider - identifiers: - name: ArgoCD - attrs: - 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: public - client_id: argocd - redirect_uris: - - matching_mode: strict - 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]] - - !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 - - # ArgoCD application — linked to the OAuth2 provider - - model: authentik_core.application - id: argocd-app - identifiers: - slug: argocd - attrs: - name: ArgoCD - slug: argocd - provider: !KeyOf argocd-provider - meta_launch_url: https://argocd.ops.eblu.me - policy_engine_mode: any - - # Policy binding — restrict ArgoCD to admins group - - model: authentik_policies.policybinding - identifiers: - order: 0 - target: !KeyOf argocd-app - group: !Find [authentik_core.group, [name, admins]] - attrs: - target: !KeyOf argocd-app - group: !Find [authentik_core.group, [name, admins]] - order: 0 - enabled: true - negate: false - timeout: 30 - - jellyfin.yaml: | - version: 1 - metadata: - name: BlumeOps Jellyfin SSO - labels: - blueprints.goauthentik.io/description: "Jellyfin OIDC provider and application" - entries: - # OAuth2 provider for Jellyfin - - model: authentik_providers_oauth2.oauth2provider - id: jellyfin-provider - identifiers: - name: Jellyfin - attrs: - name: Jellyfin - 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: jellyfin - client_secret: !Env AUTHENTIK_JELLYFIN_CLIENT_SECRET - redirect_uris: - - matching_mode: strict - url: https://jellyfin.ops.eblu.me/sso/OID/redirect/authentik - 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 - - # Jellyfin application — all authenticated users allowed (no policy binding) - - model: authentik_core.application - id: jellyfin-app - identifiers: - slug: jellyfin - attrs: - name: Jellyfin - slug: jellyfin - provider: !KeyOf jellyfin-provider - 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: - 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 - - heph.yaml: | - version: 1 - metadata: - name: BlumeOps Heph SSO - labels: - blueprints.goauthentik.io/description: "Hephaestus hub OIDC (device-code) provider, application, and device-code flow" - entries: - # Device-code flow (RFC 8628). authentik ships no default for this, so we - # create one and bind it to the brand below. An empty stage_configuration - # flow is sufficient: the already-authenticated user just confirms the code. - - model: authentik_flows.flow - id: device-code-flow - identifiers: - slug: default-device-code-flow - attrs: - name: Device code flow - title: Device code flow - slug: default-device-code-flow - designation: stage_configuration - authentication: require_authenticated - - # Enable the device-code grant globally by binding the flow to the default - # brand (domain authentik-default). Partial update — only sets this field. - - model: authentik_brands.brand - identifiers: - domain: authentik-default - attrs: - flow_device_code: !KeyOf device-code-flow - - # OAuth2 provider for heph — PUBLIC client (device-code + PKCE, no secret). - # client_id doubles as the token audience the hub verifies (--oidc-audience heph), - # and the app slug 'heph' is the issuer path (/application/o/heph/). - - model: authentik_providers_oauth2.oauth2provider - id: heph-provider - identifiers: - name: Heph - attrs: - name: Heph - authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]] - invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]] - client_type: public - client_id: heph - # CLI/TUI use the device-code grant (no redirect). The heph-pwa browser - # login uses Authorization Code + PKCE, which DOES redirect back to the - # app's origin — register those here (Authentik also keys token-endpoint - # CORS off these origins). Trailing slash matters: the PWA's redirect_uri - # is its base dir, e.g. https://heph.ops.eblu.me/. - redirect_uris: - - matching_mode: strict - url: https://heph.ops.eblu.me/ - - matching_mode: strict - url: http://localhost:8787/ # local dev (hephd --web-root) - signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]] - property_mappings: - - !Find [authentik_providers_oauth2.scopemapping, [scope_name, openid]] - - !Find [authentik_providers_oauth2.scopemapping, [scope_name, email]] - - !Find [authentik_providers_oauth2.scopemapping, [scope_name, profile]] - # offline_access: heph CLI requests "openid offline_access"; without - # this mapping the refresh token is session-bound and hephd's - # refresh_token grant 400s once the session lapses (spoke sync dies). - - !Find [authentik_providers_oauth2.scopemapping, [scope_name, offline_access]] - sub_mode: hashed_user_id - include_claims_in_id_token: true - - # Heph application — linked to the OAuth2 provider - - model: authentik_core.application - id: heph-app - identifiers: - slug: heph - attrs: - name: Hephaestus - slug: heph - provider: !KeyOf heph-provider - meta_launch_url: https://heph.ops.eblu.me - policy_engine_mode: any - - # Policy binding — restrict heph to admins group (single-owner, sensitive data) - - model: authentik_policies.policybinding - identifiers: - order: 0 - target: !KeyOf heph-app - group: !Find [authentik_core.group, [name, admins]] - attrs: - target: !KeyOf heph-app - group: !Find [authentik_core.group, [name, admins]] - order: 0 - enabled: true - negate: false - timeout: 30 diff --git a/argocd/manifests/authentik/deployment-redis.yaml b/argocd/manifests/authentik/deployment-redis.yaml deleted file mode 100644 index 8ee822e..0000000 --- a/argocd/manifests/authentik/deployment-redis.yaml +++ /dev/null @@ -1,31 +0,0 @@ ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: authentik-redis - namespace: authentik -spec: - replicas: 1 - selector: - matchLabels: - app: authentik - component: redis - template: - metadata: - labels: - app: authentik - component: redis - spec: - containers: - - name: redis - image: docker.io/library/redis:kustomized - ports: - - name: redis - containerPort: 6379 - resources: - requests: - memory: "64Mi" - cpu: "25m" - limits: - memory: "128Mi" - cpu: "100m" diff --git a/argocd/manifests/authentik/deployment-server.yaml b/argocd/manifests/authentik/deployment-server.yaml deleted file mode 100644 index cef8ceb..0000000 --- a/argocd/manifests/authentik/deployment-server.yaml +++ /dev/null @@ -1,79 +0,0 @@ ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: authentik-server - namespace: authentik -spec: - replicas: 1 - selector: - matchLabels: - app: authentik - component: server - template: - metadata: - labels: - app: authentik - component: server - spec: - containers: - - name: server - image: registry.ops.eblu.me/blumeops/authentik:kustomized - args: ["server"] - ports: - - name: http - containerPort: 9000 - - name: https - containerPort: 9443 - env: - - name: AUTHENTIK_SECRET_KEY - valueFrom: - secretKeyRef: - name: authentik-config - key: secret-key - - name: AUTHENTIK_POSTGRESQL__HOST - valueFrom: - secretKeyRef: - name: authentik-config - key: postgresql-host - - name: AUTHENTIK_POSTGRESQL__PORT - valueFrom: - secretKeyRef: - name: authentik-config - key: postgresql-port - - name: AUTHENTIK_POSTGRESQL__NAME - valueFrom: - secretKeyRef: - name: authentik-config - key: postgresql-name - - name: AUTHENTIK_POSTGRESQL__USER - valueFrom: - secretKeyRef: - name: authentik-config - key: postgresql-user - - name: AUTHENTIK_POSTGRESQL__PASSWORD - valueFrom: - secretKeyRef: - name: authentik-config - key: postgresql-password - - name: AUTHENTIK_REDIS__HOST - value: authentik-redis - livenessProbe: - httpGet: - path: /-/health/live/ - port: 9000 - initialDelaySeconds: 30 - periodSeconds: 30 - readinessProbe: - httpGet: - path: /-/health/ready/ - port: 9000 - initialDelaySeconds: 15 - periodSeconds: 10 - resources: - requests: - memory: "256Mi" - cpu: "100m" - limits: - memory: "1Gi" - cpu: "1000m" diff --git a/argocd/manifests/authentik/deployment-worker.yaml b/argocd/manifests/authentik/deployment-worker.yaml deleted file mode 100644 index 053fa3d..0000000 --- a/argocd/manifests/authentik/deployment-worker.yaml +++ /dev/null @@ -1,102 +0,0 @@ ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: authentik-worker - namespace: authentik -spec: - replicas: 1 - selector: - matchLabels: - app: authentik - component: worker - template: - metadata: - labels: - app: authentik - component: worker - spec: - containers: - - name: worker - image: registry.ops.eblu.me/blumeops/authentik:kustomized - args: ["worker"] - env: - - name: AUTHENTIK_SECRET_KEY - valueFrom: - secretKeyRef: - name: authentik-config - key: secret-key - - name: AUTHENTIK_POSTGRESQL__HOST - valueFrom: - secretKeyRef: - name: authentik-config - key: postgresql-host - - name: AUTHENTIK_POSTGRESQL__PORT - valueFrom: - secretKeyRef: - name: authentik-config - key: postgresql-port - - name: AUTHENTIK_POSTGRESQL__NAME - valueFrom: - secretKeyRef: - name: authentik-config - key: postgresql-name - - name: AUTHENTIK_POSTGRESQL__USER - valueFrom: - secretKeyRef: - name: authentik-config - key: postgresql-user - - name: AUTHENTIK_POSTGRESQL__PASSWORD - valueFrom: - secretKeyRef: - name: authentik-config - key: postgresql-password - - name: AUTHENTIK_REDIS__HOST - value: authentik-redis - - name: AUTHENTIK_WORKER_CONCURRENCY - value: "2" - - name: AUTHENTIK_GRAFANA_CLIENT_SECRET - valueFrom: - secretKeyRef: - name: authentik-config - key: grafana-client-secret - - name: AUTHENTIK_FORGEJO_CLIENT_SECRET - valueFrom: - secretKeyRef: - name: authentik-config - key: forgejo-client-secret - - name: AUTHENTIK_ZOT_CLIENT_SECRET - valueFrom: - secretKeyRef: - name: authentik-config - key: zot-client-secret - - name: AUTHENTIK_JELLYFIN_CLIENT_SECRET - valueFrom: - secretKeyRef: - name: authentik-config - key: jellyfin-client-secret - - name: AUTHENTIK_MEALIE_CLIENT_SECRET - valueFrom: - 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 - readOnly: true - resources: - requests: - memory: "512Mi" - cpu: "100m" - limits: - memory: "2Gi" - cpu: "1000m" - volumes: - - name: blueprints - configMap: - name: authentik-blueprints diff --git a/argocd/manifests/authentik/external-secret.yaml b/argocd/manifests/authentik/external-secret.yaml deleted file mode 100644 index 93de499..0000000 --- a/argocd/manifests/authentik/external-secret.yaml +++ /dev/null @@ -1,63 +0,0 @@ ---- -apiVersion: external-secrets.io/v1 -kind: ExternalSecret -metadata: - name: authentik-config - namespace: authentik -spec: - refreshInterval: 1h - secretStoreRef: - kind: ClusterSecretStore - name: onepassword-blumeops - target: - name: authentik-config - creationPolicy: Owner - data: - - secretKey: secret-key - remoteRef: - key: "Authentik (blumeops)" - property: secret-key - - secretKey: postgresql-host - remoteRef: - key: "Authentik (blumeops)" - property: postgresql-host - - secretKey: postgresql-port - remoteRef: - key: "Authentik (blumeops)" - property: postgresql-port - - secretKey: postgresql-name - remoteRef: - key: "Authentik (blumeops)" - property: postgresql-name - - secretKey: postgresql-user - remoteRef: - key: "Authentik (blumeops)" - property: postgresql-user - - secretKey: postgresql-password - remoteRef: - key: "Authentik (blumeops)" - property: postgresql-password - - secretKey: grafana-client-secret - remoteRef: - key: "Authentik (blumeops)" - property: grafana-client-secret - - secretKey: forgejo-client-secret - remoteRef: - key: "Authentik (blumeops)" - property: forgejo-client-secret - - secretKey: zot-client-secret - remoteRef: - key: "Authentik (blumeops)" - property: zot-client-secret - - secretKey: jellyfin-client-secret - remoteRef: - key: "Authentik (blumeops)" - property: jellyfin-client-secret - - secretKey: mealie-client-secret - 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/authentik/ingress-tailscale.yaml b/argocd/manifests/authentik/ingress-tailscale.yaml deleted file mode 100644 index 6d112ba..0000000 --- a/argocd/manifests/authentik/ingress-tailscale.yaml +++ /dev/null @@ -1,26 +0,0 @@ ---- -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: authentik-tailscale - namespace: authentik - annotations: - tailscale.com/proxy-class: "default" - tailscale.com/proxy-group: "ingress" - gethomepage.dev/enabled: "true" - gethomepage.dev/name: "Authentik" - gethomepage.dev/group: "Infrastructure" - gethomepage.dev/icon: "authentik" - gethomepage.dev/description: "Identity provider (SSO)" - gethomepage.dev/href: "https://authentik.ops.eblu.me" - gethomepage.dev/pod-selector: "app=authentik" -spec: - ingressClassName: tailscale - defaultBackend: - service: - name: authentik - port: - number: 9000 - tls: - - hosts: - - authentik diff --git a/argocd/manifests/authentik/kustomization.yaml b/argocd/manifests/authentik/kustomization.yaml deleted file mode 100644 index cae2c7f..0000000 --- a/argocd/manifests/authentik/kustomization.yaml +++ /dev/null @@ -1,19 +0,0 @@ ---- -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization -namespace: authentik -resources: - - external-secret.yaml - - configmap-blueprint.yaml - - deployment-server.yaml - - deployment-worker.yaml - - deployment-redis.yaml - - service.yaml - - service-redis.yaml - - ingress-tailscale.yaml -images: - - name: registry.ops.eblu.me/blumeops/authentik - 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 diff --git a/argocd/manifests/authentik/service-redis.yaml b/argocd/manifests/authentik/service-redis.yaml deleted file mode 100644 index c278e9b..0000000 --- a/argocd/manifests/authentik/service-redis.yaml +++ /dev/null @@ -1,14 +0,0 @@ ---- -apiVersion: v1 -kind: Service -metadata: - name: authentik-redis - namespace: authentik -spec: - selector: - app: authentik - component: redis - ports: - - name: redis - port: 6379 - targetPort: 6379 diff --git a/argocd/manifests/authentik/service.yaml b/argocd/manifests/authentik/service.yaml deleted file mode 100644 index 6c15f17..0000000 --- a/argocd/manifests/authentik/service.yaml +++ /dev/null @@ -1,14 +0,0 @@ ---- -apiVersion: v1 -kind: Service -metadata: - name: authentik - namespace: authentik -spec: - selector: - app: authentik - component: server - ports: - - name: http - port: 9000 - targetPort: 9000 diff --git a/argocd/manifests/cloudnative-pg/README.md b/argocd/manifests/cloudnative-pg/README.md index 0065630..c6c1fb1 100644 --- a/argocd/manifests/cloudnative-pg/README.md +++ b/argocd/manifests/cloudnative-pg/README.md @@ -4,23 +4,13 @@ Kubernetes operator for managing PostgreSQL clusters with high availability. ## Source -- Upstream mirror: `mirrors/cloudnative-pg` on forge (from https://github.com/cloudnative-pg/cloudnative-pg) +- Helm chart: `cloudnative-pg` from https://cloudnative-pg.github.io/charts - Documentation: https://cloudnative-pg.io/documentation/ ## Deployment -Managed via ArgoCD Application pointing directly at the upstream release -manifest in the forge-mirrored repo. No Helm chart or vendored manifests — -ArgoCD applies the release YAML from the `releases/` directory using a -`directory.include` filter. - -## Upgrading - -To upgrade the operator, edit `argocd/apps/cloudnative-pg.yaml`: - -1. Update `targetRevision` to the new tag (e.g. `v1.28.0`) -2. Update `directory.include` to match (e.g. `cnpg-1.28.0.yaml`) -3. Commit and sync via ArgoCD +Managed via ArgoCD Application using Helm source (not kustomize). +The Application points directly to the upstream Helm repository. ## ArgoCD CLI Commands @@ -39,25 +29,24 @@ argocd app history cloudnative-pg ```bash # Check operator pod is running -kubectl get pods -n cnpg-system --context=minikube-indri +kubectl get pods -n cnpg-system # Check operator logs -kubectl logs -n cnpg-system -l app.kubernetes.io/name=cloudnative-pg --context=minikube-indri +kubectl logs -n cnpg-system -l app.kubernetes.io/name=cloudnative-pg # Check CRDs are installed -kubectl get crd --context=minikube-indri | grep cnpg +kubectl get crd | grep cnpg ``` ## Files | File | Description | |------|-------------| +| `values.yaml` | Helm values for customization | | `README.md` | This file | ## Notes - The operator is deployed to `cnpg-system` namespace -- PostgreSQL clusters are created separately using the `Cluster` CRD +- PostgreSQL clusters are created separately using the `Cluster` CRD (see Step 7) - No secrets required for the operator itself -- `ServerSideApply=true` is required for the large CRDs -- The `values.yaml` was removed — no Helm customization was in use diff --git a/argocd/manifests/cloudnative-pg/values.yaml b/argocd/manifests/cloudnative-pg/values.yaml new file mode 100644 index 0000000..607895e --- /dev/null +++ b/argocd/manifests/cloudnative-pg/values.yaml @@ -0,0 +1,4 @@ +# CloudNativePG Helm values +# See: https://github.com/cloudnative-pg/charts/tree/main/charts/cloudnative-pg + +# Using defaults for now - customize as needed diff --git a/argocd/manifests/databases-ringtail/blumeops-pg.yaml b/argocd/manifests/databases-ringtail/blumeops-pg.yaml deleted file mode 100644 index 3a37249..0000000 --- a/argocd/manifests/databases-ringtail/blumeops-pg.yaml +++ /dev/null @@ -1,97 +0,0 @@ -# PostgreSQL Cluster for blumeops services on ringtail k3s. -# -# Wave-1 indri-k8s decommission target (see [[migrate-wave1-ringtail]]). -# Holds the paperless and teslamate databases migrated off the minikube -# blumeops-pg via cold pg_dump/pg_restore at cutover. miniflux + authentik -# stay where they are for now (later waves), so this cluster only carries -# the wave-1 roles. -# -# Apps reach this in-cluster at blumeops-pg-rw.databases.svc.cluster.local -# — the same name they used on minikube, so teslamate's DATABASE_HOST is -# unchanged. -# -# Database creation is deferred to cutover, mirroring the minikube cluster -# (where only the bootstrap database is declared and the rest were created -# out-of-band): -# - paperless: the bootstrap database below (restored into at cutover). -# - teslamate: created at its cutover by the eblume superuser, because the -# dump's `earthdistance` extension is untrusted and CREATE EXTENSION -# needs superuser. (cube + earthdistance ownership then transferred to -# the teslamate role so it can ALTER EXTENSION UPDATE.) -apiVersion: postgresql.cnpg.io/v1 -kind: Cluster -metadata: - name: blumeops-pg - namespace: databases -spec: - instances: 1 - imageName: ghcr.io/cloudnative-pg/postgresql:18.3 - - storage: - size: 10Gi - storageClass: local-path - - bootstrap: - initdb: - database: paperless - owner: paperless - - managed: - roles: - # eblume superuser for admin + privileged restore steps (extensions) - - name: eblume - login: true - superuser: true - createdb: true - createrole: true - connectionLimit: -1 - ensure: present - inherit: true - passwordSecret: - name: blumeops-pg-eblume - # borgmatic read-only user for backups - - name: borgmatic - login: true - connectionLimit: -1 - ensure: present - inherit: true - inRoles: - - pg_read_all_data - passwordSecret: - name: blumeops-pg-borgmatic - # paperless user (also the bootstrap database owner above; the - # managed role sets its password from the 1Password-backed secret) - - name: paperless - login: true - connectionLimit: -1 - ensure: present - inherit: true - passwordSecret: - name: blumeops-pg-paperless - # teslamate user. Extension ownership (cube, earthdistance) is - # transferred to this role at cutover so it can ALTER EXTENSION UPDATE. - - name: teslamate - login: true - connectionLimit: -1 - ensure: present - inherit: true - passwordSecret: - name: blumeops-pg-teslamate - - resources: - requests: - memory: "256Mi" - cpu: "100m" - limits: - memory: "1Gi" - cpu: "500m" - - postgresql: - parameters: - max_connections: "50" - shared_buffers: "128MB" - password_encryption: "scram-sha-256" - pg_hba: - # Password auth from anywhere; network security is via Tailscale. - - host all all 0.0.0.0/0 scram-sha-256 - - host all all ::/0 scram-sha-256 diff --git a/argocd/manifests/databases-ringtail/external-secret-borgmatic.yaml b/argocd/manifests/databases-ringtail/external-secret-borgmatic.yaml deleted file mode 100644 index ee600e3..0000000 --- a/argocd/manifests/databases-ringtail/external-secret-borgmatic.yaml +++ /dev/null @@ -1,30 +0,0 @@ -# ExternalSecret for borgmatic backup user password -# -# Replaces the manual op inject workflow from secret-borgmatic.yaml.tpl -# -# 1Password item: "borgmatic" in blumeops vault -# Field: "db-password" -# -apiVersion: external-secrets.io/v1 -kind: ExternalSecret -metadata: - name: blumeops-pg-borgmatic - namespace: databases -spec: - refreshInterval: 1h - secretStoreRef: - kind: ClusterSecretStore - name: onepassword-blumeops - target: - name: blumeops-pg-borgmatic - creationPolicy: Owner - template: - type: kubernetes.io/basic-auth - data: - username: borgmatic - password: "{{ .password }}" - data: - - secretKey: password - remoteRef: - key: borgmatic - property: db-password diff --git a/argocd/manifests/databases-ringtail/external-secret-eblume.yaml b/argocd/manifests/databases-ringtail/external-secret-eblume.yaml deleted file mode 100644 index a324c7d..0000000 --- a/argocd/manifests/databases-ringtail/external-secret-eblume.yaml +++ /dev/null @@ -1,30 +0,0 @@ -# ExternalSecret for eblume superuser password -# -# Replaces the manual op inject workflow from secret-eblume.yaml.tpl -# -# 1Password item: "postgres" in blumeops vault -# Field: "password" -# -apiVersion: external-secrets.io/v1 -kind: ExternalSecret -metadata: - name: blumeops-pg-eblume - namespace: databases -spec: - refreshInterval: 1h - secretStoreRef: - kind: ClusterSecretStore - name: onepassword-blumeops - target: - name: blumeops-pg-eblume - creationPolicy: Owner - template: - type: kubernetes.io/basic-auth - data: - username: eblume - password: "{{ .password }}" - data: - - secretKey: password - remoteRef: - key: postgres - property: password diff --git a/argocd/manifests/databases-ringtail/external-secret-immich-borgmatic.yaml b/argocd/manifests/databases-ringtail/external-secret-immich-borgmatic.yaml deleted file mode 100644 index 3d1fc14..0000000 --- a/argocd/manifests/databases-ringtail/external-secret-immich-borgmatic.yaml +++ /dev/null @@ -1,32 +0,0 @@ -# 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. -# -# 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-ringtail/external-secret-paperless.yaml b/argocd/manifests/databases-ringtail/external-secret-paperless.yaml deleted file mode 100644 index e5742be..0000000 --- a/argocd/manifests/databases-ringtail/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-ringtail/external-secret-teslamate.yaml b/argocd/manifests/databases-ringtail/external-secret-teslamate.yaml deleted file mode 100644 index 0c52e0b..0000000 --- a/argocd/manifests/databases-ringtail/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-ringtail/immich-pg.yaml b/argocd/manifests/databases-ringtail/immich-pg.yaml deleted file mode 100644 index 982bc43..0000000 --- a/argocd/manifests/databases-ringtail/immich-pg.yaml +++ /dev/null @@ -1,53 +0,0 @@ -# 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 deleted file mode 100644 index 143345c..0000000 --- a/argocd/manifests/databases-ringtail/kustomization.yaml +++ /dev/null @@ -1,16 +0,0 @@ -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization - -namespace: databases - -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 - - service-blumeops-pg-tailscale.yaml - - external-secret-eblume.yaml - - external-secret-borgmatic.yaml - - external-secret-paperless.yaml - - external-secret-teslamate.yaml diff --git a/argocd/manifests/databases-ringtail/service-blumeops-pg-tailscale.yaml b/argocd/manifests/databases-ringtail/service-blumeops-pg-tailscale.yaml deleted file mode 100644 index f7ca5ef..0000000 --- a/argocd/manifests/databases-ringtail/service-blumeops-pg-tailscale.yaml +++ /dev/null @@ -1,24 +0,0 @@ -# Tailscale LoadBalancer for the ringtail blumeops-pg cluster. -# Canonical hostname: blumeops-pg-ringtail.tail8d86e.ts.net (distinct from -# the minikube blumeops-pg, which still owns pg.tail8d86e.ts.net until the -# wave-1 decommission). Borgmatic on indri and the Grafana TeslaMate -# datasource reach it via the Caddy L4 route pg.ops.eblu.me:5434. -apiVersion: v1 -kind: Service -metadata: - name: blumeops-pg-tailscale - namespace: databases - annotations: - tailscale.com/hostname: "blumeops-pg-ringtail" - tailscale.com/proxy-class: "default" -spec: - type: LoadBalancer - loadBalancerClass: tailscale - selector: - cnpg.io/cluster: blumeops-pg - role: primary - ports: - - name: postgresql - port: 5432 - targetPort: 5432 - protocol: TCP diff --git a/argocd/manifests/databases-ringtail/service-immich-pg-tailscale.yaml b/argocd/manifests/databases-ringtail/service-immich-pg-tailscale.yaml deleted file mode 100644 index 92deb14..0000000 --- a/argocd/manifests/databases-ringtail/service-immich-pg-tailscale.yaml +++ /dev/null @@ -1,24 +0,0 @@ -# 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: - 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/argocd/manifests/databases/README.md b/argocd/manifests/databases/README.md index be7fc2e..c82f4d1 100644 --- a/argocd/manifests/databases/README.md +++ b/argocd/manifests/databases/README.md @@ -2,13 +2,6 @@ PostgreSQL clusters managed by CloudNativePG operator. -## Clusters - -| Cluster | Image | Purpose | -|---------|-------|---------| -| blumeops-pg | cloudnative-pg/postgresql:18 | General services (miniflux, teslamate) | -| immich-pg | tensorchord/cloudnative-vectorchord:17 | Immich (requires pgvecto.rs extension) | - ## blumeops-pg Single-instance PostgreSQL cluster for blumeops services. @@ -54,7 +47,7 @@ After the cluster is healthy: psql -h k8s-pg.tail8d86e.ts.net -U eblume -W -d miniflux # Or with password from 1Password -PGPASSWORD=$(op read "op://blumeops/guxu3j7ajhjyey6xxl2ovsl2ui/password") \ +PGPASSWORD=$(op --vault blumeops item get guxu3j7ajhjyey6xxl2ovsl2ui --fields password --reveal) \ psql -h k8s-pg.tail8d86e.ts.net -U eblume -d miniflux # Get miniflux app credentials (for applications) @@ -73,7 +66,7 @@ Alternative if Tailscale service is unavailable: kubectl -n databases port-forward svc/blumeops-pg-rw 5432:5432 # Terminal 2: Connect as eblume -PGPASSWORD=$(op read "op://blumeops/guxu3j7ajhjyey6xxl2ovsl2ui/password") \ +PGPASSWORD=$(op --vault blumeops item get guxu3j7ajhjyey6xxl2ovsl2ui --fields password --reveal) \ psql -h localhost -U eblume -d miniflux ``` @@ -106,35 +99,3 @@ from brew PostgreSQL (indri) to this k8s cluster. At that point: 1. Delete `service-tailscale.yaml` (the `k8s-pg` service) 2. Update/create a service with `tailscale.com/hostname: "pg"` 3. Verify the orphaned `k8s-pg` device is removed from tailnet - -## immich-pg - -PostgreSQL cluster for Immich with VectorChord extension for AI-powered vector search. - -### Configuration - -- **Instances**: 1 (single-node for minikube) -- **Storage**: 10Gi on `standard` storage class -- **Image**: `ghcr.io/tensorchord/cloudnative-vectorchord:17-0.5.0` (VectorChord 0.5.0 for Immich compatibility) -- **Extensions**: `vector`, `vchord`, `cube`, `earthdistance` - -### Connection - -Immich connects via `immich-pg-rw.databases.svc.cluster.local:5432`. - -The `immich` user password is auto-generated by CloudNativePG and stored in `immich-pg-app` secret: - -```bash -# Get immich app credentials -kubectl -n databases get secret immich-pg-app -o jsonpath='{.data.password}' | base64 -d -``` - -### Status - -```bash -# Check cluster health -kubectl -n databases get cluster immich-pg - -# Check pods -kubectl -n databases get pods -l cnpg.io/cluster=immich-pg -``` diff --git a/argocd/manifests/databases/blumeops-pg.yaml b/argocd/manifests/databases/blumeops-pg.yaml index 37aef23..1c3c7de 100644 --- a/argocd/manifests/databases/blumeops-pg.yaml +++ b/argocd/manifests/databases/blumeops-pg.yaml @@ -7,7 +7,7 @@ metadata: namespace: databases spec: instances: 1 - imageName: ghcr.io/cloudnative-pg/postgresql:18.3 + imageName: ghcr.io/cloudnative-pg/postgresql:18 storage: size: 10Gi @@ -44,18 +44,17 @@ spec: - pg_read_all_data passwordSecret: name: blumeops-pg-borgmatic - # 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 + # teslamate user for TeslaMate Tesla data logger + # Note: superuser required for extension management during migrations + - name: teslamate login: true + superuser: true connectionLimit: -1 ensure: present inherit: true createdb: true passwordSecret: - name: blumeops-pg-authentik + name: blumeops-pg-teslamate # Resource limits for minikube environment resources: diff --git a/argocd/manifests/databases/external-secret-authentik.yaml b/argocd/manifests/databases/external-secret-authentik.yaml deleted file mode 100644 index 1486ed6..0000000 --- a/argocd/manifests/databases/external-secret-authentik.yaml +++ /dev/null @@ -1,28 +0,0 @@ -# ExternalSecret for Authentik database user password -# -# 1Password item: "Authentik (blumeops)" in blumeops vault -# Field: "postgresql-password" -# -apiVersion: external-secrets.io/v1 -kind: ExternalSecret -metadata: - name: blumeops-pg-authentik - namespace: databases -spec: - refreshInterval: 1h - secretStoreRef: - kind: ClusterSecretStore - name: onepassword-blumeops - target: - name: blumeops-pg-authentik - creationPolicy: Owner - template: - type: kubernetes.io/basic-auth - data: - username: authentik - password: "{{ .password }}" - data: - - secretKey: password - remoteRef: - key: Authentik (blumeops) - property: postgresql-password diff --git a/argocd/manifests/databases/external-secret-borgmatic.yaml b/argocd/manifests/databases/external-secret-borgmatic.yaml deleted file mode 100644 index ee600e3..0000000 --- a/argocd/manifests/databases/external-secret-borgmatic.yaml +++ /dev/null @@ -1,30 +0,0 @@ -# ExternalSecret for borgmatic backup user password -# -# Replaces the manual op inject workflow from secret-borgmatic.yaml.tpl -# -# 1Password item: "borgmatic" in blumeops vault -# Field: "db-password" -# -apiVersion: external-secrets.io/v1 -kind: ExternalSecret -metadata: - name: blumeops-pg-borgmatic - namespace: databases -spec: - refreshInterval: 1h - secretStoreRef: - kind: ClusterSecretStore - name: onepassword-blumeops - target: - name: blumeops-pg-borgmatic - creationPolicy: Owner - template: - type: kubernetes.io/basic-auth - data: - username: borgmatic - password: "{{ .password }}" - data: - - secretKey: password - remoteRef: - key: borgmatic - property: db-password diff --git a/argocd/manifests/databases/external-secret-eblume.yaml b/argocd/manifests/databases/external-secret-eblume.yaml deleted file mode 100644 index a324c7d..0000000 --- a/argocd/manifests/databases/external-secret-eblume.yaml +++ /dev/null @@ -1,30 +0,0 @@ -# ExternalSecret for eblume superuser password -# -# Replaces the manual op inject workflow from secret-eblume.yaml.tpl -# -# 1Password item: "postgres" in blumeops vault -# Field: "password" -# -apiVersion: external-secrets.io/v1 -kind: ExternalSecret -metadata: - name: blumeops-pg-eblume - namespace: databases -spec: - refreshInterval: 1h - secretStoreRef: - kind: ClusterSecretStore - name: onepassword-blumeops - target: - name: blumeops-pg-eblume - creationPolicy: Owner - template: - type: kubernetes.io/basic-auth - data: - username: eblume - password: "{{ .password }}" - data: - - secretKey: password - remoteRef: - key: postgres - property: password diff --git a/argocd/manifests/databases/kustomization.yaml b/argocd/manifests/databases/kustomization.yaml index 0393757..e44bdaf 100644 --- a/argocd/manifests/databases/kustomization.yaml +++ b/argocd/manifests/databases/kustomization.yaml @@ -7,6 +7,3 @@ resources: - blumeops-pg.yaml - service-tailscale.yaml - service-metrics-tailscale.yaml - - external-secret-eblume.yaml - - external-secret-borgmatic.yaml - - external-secret-authentik.yaml diff --git a/argocd/manifests/databases/secret-borgmatic.yaml.tpl b/argocd/manifests/databases/secret-borgmatic.yaml.tpl new file mode 100644 index 0000000..6a1c52a --- /dev/null +++ b/argocd/manifests/databases/secret-borgmatic.yaml.tpl @@ -0,0 +1,13 @@ +# Template for borgmatic backup user password +# Apply with: op inject -i secret-borgmatic.yaml.tpl | kubectl apply -f - +# +# Uses the same borgmatic password from 1Password as the brew PostgreSQL setup +apiVersion: v1 +kind: Secret +metadata: + name: blumeops-pg-borgmatic + namespace: databases +type: kubernetes.io/basic-auth +stringData: + username: borgmatic + password: {{ op://vg6xf6vvfmoh5hqjjhlhbeoaie/mw2bv5we7woicjza7hc6s44yvy/db-password }} diff --git a/argocd/manifests/databases/secret-eblume.yaml.tpl b/argocd/manifests/databases/secret-eblume.yaml.tpl new file mode 100644 index 0000000..481bd96 --- /dev/null +++ b/argocd/manifests/databases/secret-eblume.yaml.tpl @@ -0,0 +1,13 @@ +# Template for eblume superuser password +# Apply with: op inject -i secret-eblume.yaml.tpl | kubectl apply -f - +# +# Uses the same 1Password item as the brew PostgreSQL setup on indri +apiVersion: v1 +kind: Secret +metadata: + name: blumeops-pg-eblume + namespace: databases +type: kubernetes.io/basic-auth +stringData: + username: eblume + password: {{ op://vg6xf6vvfmoh5hqjjhlhbeoaie/guxu3j7ajhjyey6xxl2ovsl2ui/password }} diff --git a/argocd/manifests/databases/secret-teslamate.yaml.tpl b/argocd/manifests/databases/secret-teslamate.yaml.tpl new file mode 100644 index 0000000..355e2be --- /dev/null +++ b/argocd/manifests/databases/secret-teslamate.yaml.tpl @@ -0,0 +1,11 @@ +# Template for TeslaMate database user password +# Apply with: op inject -i argocd/manifests/databases/secret-teslamate.yaml.tpl | kubectl apply -f - +apiVersion: v1 +kind: Secret +metadata: + name: blumeops-pg-teslamate + namespace: databases +type: kubernetes.io/basic-auth +stringData: + username: teslamate + password: {{ op://blumeops/TeslaMate/db_password }} diff --git a/argocd/manifests/devpi/Dockerfile b/argocd/manifests/devpi/Dockerfile new file mode 100644 index 0000000..6c9cdc8 --- /dev/null +++ b/argocd/manifests/devpi/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.12-slim + +# Install devpi-server and devpi-web +RUN pip install --no-cache-dir devpi-server devpi-web + +# 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/argocd/manifests/devpi/README.md b/argocd/manifests/devpi/README.md new file mode 100644 index 0000000..11fd697 --- /dev/null +++ b/argocd/manifests/devpi/README.md @@ -0,0 +1,72 @@ +# 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/tailscale-operator/ingress-forge.yaml b/argocd/manifests/devpi/ingress-tailscale.yaml similarity index 50% rename from argocd/manifests/tailscale-operator/ingress-forge.yaml rename to argocd/manifests/devpi/ingress-tailscale.yaml index 047b59d..8f37d17 100644 --- a/argocd/manifests/tailscale-operator/ingress-forge.yaml +++ b/argocd/manifests/devpi/ingress-tailscale.yaml @@ -1,20 +1,17 @@ ---- apiVersion: networking.k8s.io/v1 kind: Ingress metadata: - name: forge-tailscale - namespace: tailscale + name: devpi-tailscale + namespace: devpi annotations: tailscale.com/proxy-class: "default" - tailscale.com/proxy-group: "ingress" - tailscale.com/tags: "tag:k8s,tag:flyio-target" spec: ingressClassName: tailscale defaultBackend: service: - name: forge-external + name: devpi port: - number: 3001 + number: 3141 tls: - hosts: - - forge + - pypi diff --git a/argocd/manifests/devpi/kustomization.yaml b/argocd/manifests/devpi/kustomization.yaml new file mode 100644 index 0000000..6bc7579 --- /dev/null +++ b/argocd/manifests/devpi/kustomization.yaml @@ -0,0 +1,9 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: devpi + +resources: + - statefulset.yaml + - service.yaml + - ingress-tailscale.yaml diff --git a/argocd/manifests/devpi/secret-root.yaml.tpl b/argocd/manifests/devpi/secret-root.yaml.tpl new file mode 100644 index 0000000..d69f9a8 --- /dev/null +++ b/argocd/manifests/devpi/secret-root.yaml.tpl @@ -0,0 +1,12 @@ +# Template for devpi root password secret +# Create the secret before deploying: +# kubectl create namespace devpi +# op inject -i argocd/manifests/devpi/secret-root.yaml.tpl | kubectl apply -f - +apiVersion: v1 +kind: Secret +metadata: + name: devpi-root + namespace: devpi +type: Opaque +stringData: + password: "{{ op://vg6xf6vvfmoh5hqjjhlhbeoaie/kyhzfifryqnuk7jeyibmmjvxxm/root password }}" diff --git a/argocd/manifests/mealie-ringtail/service.yaml b/argocd/manifests/devpi/service.yaml similarity index 53% rename from argocd/manifests/mealie-ringtail/service.yaml rename to argocd/manifests/devpi/service.yaml index 4162b96..42e1543 100644 --- a/argocd/manifests/mealie-ringtail/service.yaml +++ b/argocd/manifests/devpi/service.yaml @@ -1,13 +1,13 @@ apiVersion: v1 kind: Service metadata: - name: mealie - namespace: mealie + name: devpi + namespace: devpi spec: selector: - app: mealie + app: devpi ports: - name: http - port: 9000 - targetPort: 9000 + port: 3141 + targetPort: 3141 protocol: TCP diff --git a/argocd/manifests/devpi/start.sh b/argocd/manifests/devpi/start.sh new file mode 100644 index 0000000..e34e60c --- /dev/null +++ b/argocd/manifests/devpi/start.sh @@ -0,0 +1,31 @@ +#!/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="$CMD --outside-url $OUTSIDE_URL" +fi + +echo "Starting devpi-server..." +exec $CMD diff --git a/argocd/manifests/devpi/statefulset.yaml b/argocd/manifests/devpi/statefulset.yaml new file mode 100644 index 0000000..8afec98 --- /dev/null +++ b/argocd/manifests/devpi/statefulset.yaml @@ -0,0 +1,63 @@ +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 + containers: + - name: devpi + # TODO: Tag builds with semantic versions (e.g., v1.0.0) for reproducibility + image: registry.tail8d86e.ts.net/blumeops/devpi:latest + env: + - name: DEVPI_ROOT_PASSWORD + valueFrom: + secretKeyRef: + name: devpi-root + key: password + - name: DEVPI_OUTSIDE_URL + value: "https://pypi.tail8d86e.ts.net" + 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/argocd/manifests/external-secrets-ringtail/kustomization.yaml b/argocd/manifests/external-secrets-ringtail/kustomization.yaml deleted file mode 100644 index 9fd4e2f..0000000 --- a/argocd/manifests/external-secrets-ringtail/kustomization.yaml +++ /dev/null @@ -1,16 +0,0 @@ -# Ringtail (amd64) overlay for external-secrets. -# -# Reuses the shared indri manifest as a base and only overrides the controller -# image to the nix-built amd64 variant (`-nix` tag). The base sets the arm64 -# image (built via containers/external-secrets/container.py on indri's Dagger -# runner); ringtail's k3s is amd64 and needs the image built by -# containers/external-secrets/default.nix on the nix-container-builder. -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization - -resources: - - ../external-secrets - -images: - - name: registry.ops.eblu.me/blumeops/external-secrets - newTag: v2.2.0-13895bb-nix diff --git a/argocd/manifests/external-secrets/README.md b/argocd/manifests/external-secrets/README.md deleted file mode 100644 index abf1c14..0000000 --- a/argocd/manifests/external-secrets/README.md +++ /dev/null @@ -1,83 +0,0 @@ -# External Secrets Operator - -External Secrets Operator (ESO) syncs secrets from 1Password Connect to native Kubernetes Secrets. - -## Architecture - -- **ClusterSecretStore** (`onepassword-blumeops`): Cluster-wide access to 1Password via Connect -- **ExternalSecret** (per-namespace): Defines which secrets to sync from 1Password - -## Prerequisites - -1Password Connect must be deployed and healthy before syncing ESO. - -## Deployment - -```bash -argocd app sync external-secrets -``` - -## Verification - -```bash -# Check operator pods -kubectl --context=minikube-indri -n external-secrets get pods - -# Check ClusterSecretStore status -kubectl --context=minikube-indri get clustersecretstore onepassword-blumeops - -# Check all ExternalSecrets across namespaces -kubectl --context=minikube-indri get externalsecret -A -``` - -## Creating ExternalSecrets - -To sync a secret from 1Password, create an ExternalSecret in the target namespace: - -```yaml -apiVersion: external-secrets.io/v1 -kind: ExternalSecret -metadata: - name: my-secret - namespace: my-namespace -spec: - refreshInterval: 1h - secretStoreRef: - kind: ClusterSecretStore - name: onepassword-blumeops - target: - name: my-secret # Name of K8s Secret to create - creationPolicy: Owner # ESO owns and manages the Secret - data: - - secretKey: password # Key in the K8s Secret - remoteRef: - key: My 1Password Item # Title of item in 1Password - property: password # Field label in 1Password item -``` - -### Finding 1Password Item Details - -```bash -# List items in blumeops vault -op item list --vault blumeops - -# Get field names for an item -op item get <item-id> --vault blumeops --format json | jq -r '.fields[] | .label' -``` - -## Troubleshooting - -### ClusterSecretStore not ready -- Check 1Password Connect is running: `kubectl --context=minikube-indri -n 1password get pods` -- Verify token secret exists: `kubectl --context=minikube-indri -n 1password get secret onepassword-token` - -### ExternalSecret not syncing -- Check the ExternalSecret status: `kubectl --context=minikube-indri describe externalsecret <name> -n <namespace>` -- Verify the 1Password item title and field names match exactly -- Check ESO controller logs: `kubectl --context=minikube-indri -n external-secrets logs -l app.kubernetes.io/name=external-secrets` - -## Related - -- [External Secrets Operator Docs](https://external-secrets.io/) -- [1Password Provider](https://external-secrets.io/latest/provider/1password-automation/) -- [1Password Connect](../1password-connect/README.md) diff --git a/argocd/manifests/external-secrets/cluster-secret-store.yaml b/argocd/manifests/external-secrets/cluster-secret-store.yaml deleted file mode 100644 index f01ad75..0000000 --- a/argocd/manifests/external-secrets/cluster-secret-store.yaml +++ /dev/null @@ -1,21 +0,0 @@ -# ClusterSecretStore for 1Password Connect -# -# Provides cluster-wide access to the blumeops vault via 1Password Connect. -# ExternalSecret resources in any namespace can reference this store. -# -apiVersion: external-secrets.io/v1 -kind: ClusterSecretStore -metadata: - name: onepassword-blumeops -spec: - provider: - onepassword: - connectHost: http://onepassword-connect.1password.svc.cluster.local:8080 - vaults: - blumeops: 1 - auth: - secretRef: - connectTokenSecretRef: - name: onepassword-token - namespace: 1password - key: token diff --git a/argocd/manifests/external-secrets/deployment.yaml b/argocd/manifests/external-secrets/deployment.yaml deleted file mode 100644 index 993d8df..0000000 --- a/argocd/manifests/external-secrets/deployment.yaml +++ /dev/null @@ -1,218 +0,0 @@ ---- -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 deleted file mode 100644 index 639db66..0000000 --- a/argocd/manifests/external-secrets/kustomization.yaml +++ /dev/null @@ -1,16 +0,0 @@ ---- -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 - newName: registry.ops.eblu.me/blumeops/external-secrets - newTag: v2.2.0-13895bb diff --git a/argocd/manifests/external-secrets/rbac.yaml b/argocd/manifests/external-secrets/rbac.yaml deleted file mode 100644 index 0e68cd2..0000000 --- a/argocd/manifests/external-secrets/rbac.yaml +++ /dev/null @@ -1,445 +0,0 @@ ---- -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 deleted file mode 100644 index 3b019d7..0000000 --- a/argocd/manifests/external-secrets/service.yaml +++ /dev/null @@ -1,22 +0,0 @@ ---- -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 deleted file mode 100644 index 6bd412d..0000000 --- a/argocd/manifests/external-secrets/serviceaccount.yaml +++ /dev/null @@ -1,33 +0,0 @@ ---- -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/webhook.yaml b/argocd/manifests/external-secrets/webhook.yaml deleted file mode 100644 index d53fa60..0000000 --- a/argocd/manifests/external-secrets/webhook.yaml +++ /dev/null @@ -1,83 +0,0 @@ ---- -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/argocd/manifests/forgejo-runner/Dockerfile b/argocd/manifests/forgejo-runner/Dockerfile new file mode 100644 index 0000000..64bf571 --- /dev/null +++ b/argocd/manifests/forgejo-runner/Dockerfile @@ -0,0 +1,64 @@ +# Build forgejo-runner from source +# Source: https://forge.tail8d86e.ts.net/eblume/forgejo-runner (mirror of code.forgejo.org/forgejo/runner) + +FROM golang:1.24-alpine AS builder + +ARG FORGEJO_RUNNER_VERSION=v3.5.1 + +RUN apk add --no-cache git make build-base + +WORKDIR /src +RUN git clone --depth 1 --branch ${FORGEJO_RUNNER_VERSION} \ + https://forge.tail8d86e.ts.net/eblume/forgejo-runner.git . + +RUN make clean && make build + +# Runtime image +FROM alpine:3.21 + +# Create runner user with proper passwd entry (required by buildah) +RUN addgroup -g 1000 runner && \ + adduser -D -u 1000 -G runner -h /data runner + +# Install runtime dependencies +RUN apk add --no-cache \ + # Required for actions/checkout and other Node-based actions + nodejs \ + npm \ + # Core tools + git \ + bash \ + curl \ + wget \ + jq \ + # Build essentials + make \ + gcc \ + g++ \ + musl-dev \ + # For container builds (daemonless, no Docker socket needed) + buildah \ + podman \ + fuse-overlayfs \ + ca-certificates + +# Copy runner binary from builder +COPY --from=builder /src/forgejo-runner /bin/forgejo-runner + +# Configure buildah for rootless operation +RUN mkdir -p /etc/containers && \ + printf '[storage]\ndriver = "overlay"\nrunroot = "/tmp/containers-run"\ngraphroot = "/tmp/containers-storage"\n[storage.options.overlay]\nmount_program = "/usr/bin/fuse-overlayfs"\n' \ + > /etc/containers/storage.conf + +# Configure registries (allow insecure for local registry) +RUN printf 'unqualified-search-registries = ["docker.io"]\n[[registry]]\nlocation = "registry.tail8d86e.ts.net"\ninsecure = true\n' \ + > /etc/containers/registries.conf + +# Verify tools are available +RUN node --version && npm --version && buildah --version && /bin/forgejo-runner --version + +ENV HOME=/data +WORKDIR /data +USER runner + +CMD ["/bin/forgejo-runner"] diff --git a/argocd/manifests/forgejo-runner/config.yaml b/argocd/manifests/forgejo-runner/config.yaml deleted file mode 100644 index 01ede7c..0000000 --- a/argocd/manifests/forgejo-runner/config.yaml +++ /dev/null @@ -1,26 +0,0 @@ -# Reviewed against v12.8.2 defaults (2026-04-20) -log: - level: info - -runner: - capacity: 2 - timeout: 3h - shutdown_timeout: 3h - # Env vars injected into all job containers - envs: - DOCKER_HOST: tcp://127.0.0.1:2375 - TZ: America/Los_Angeles - -container: - 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.6-50f8c2a diff --git a/argocd/manifests/forgejo-runner/configmap.yaml b/argocd/manifests/forgejo-runner/configmap.yaml new file mode 100644 index 0000000..584efe0 --- /dev/null +++ b/argocd/manifests/forgejo-runner/configmap.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: forgejo-runner-config + namespace: forgejo-runner +data: + config.yaml: | + log: + level: info + runner: + file: /data/.runner + capacity: 1 + timeout: 3h diff --git a/argocd/manifests/forgejo-runner/daemon.json b/argocd/manifests/forgejo-runner/daemon.json deleted file mode 100644 index 637acdc..0000000 --- a/argocd/manifests/forgejo-runner/daemon.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "registry-mirrors": ["http://host.minikube.internal:5050"] -} diff --git a/argocd/manifests/forgejo-runner/deployment.yaml b/argocd/manifests/forgejo-runner/deployment.yaml index 7db7798..427d414 100644 --- a/argocd/manifests/forgejo-runner/deployment.yaml +++ b/argocd/manifests/forgejo-runner/deployment.yaml @@ -3,8 +3,6 @@ kind: Deployment metadata: name: forgejo-runner namespace: forgejo-runner - labels: - app: forgejo-runner spec: replicas: 1 selector: @@ -15,71 +13,52 @@ spec: labels: app: forgejo-runner spec: - securityContext: - seccompProfile: - type: RuntimeDefault + serviceAccountName: forgejo-runner containers: - # Forgejo runner daemon - name: runner - image: code.forgejo.org/forgejo/runner:kustomized + image: registry.tail8d86e.ts.net/blumeops/forgejo-runner:latest + imagePullPolicy: Always env: - - name: TZ - value: America/Los_Angeles + # Use internal k8s service via Tailscale operator egress + - name: FORGEJO_INSTANCE_URL + value: "http://forge.tailscale.svc.cluster.local:3001" + - name: RUNNER_NAME + value: "k8s-runner-1" + - name: RUNNER_TOKEN + valueFrom: + secretKeyRef: + name: forgejo-runner-token + key: token command: - /bin/sh - -c - | - # Wait for DinD to be ready - echo "Waiting for Docker daemon..." - while ! wget -q -O /dev/null http://localhost:2375/_ping 2>/dev/null; do - sleep 1 - done - echo "Docker daemon ready" - - # Render config with credentials from ExternalSecret. - envsubst < /config/config.yaml > /tmp/config.yaml - - # Start daemon - exec forgejo-runner daemon --config /tmp/config.yaml - envFrom: - - secretRef: - name: forgejo-runner-env + # Register runner if not already registered + if [ ! -f /data/.runner ]; then + forgejo-runner register \ + --instance "$FORGEJO_INSTANCE_URL" \ + --token "$RUNNER_TOKEN" \ + --name "$RUNNER_NAME" \ + --labels "ubuntu-latest:host,ubuntu-22.04:host" \ + --no-interactive + fi + # Start the runner daemon with config + forgejo-runner daemon --config /config/config.yaml volumeMounts: - - name: data + - name: runner-data mountPath: /data - - name: config + - name: runner-config mountPath: /config - - name: zoneinfo - mountPath: /usr/share/zoneinfo - readOnly: true - - # Docker-in-Docker sidecar - - name: dind - image: docker:kustomized - securityContext: - privileged: true - seccompProfile: - type: Unconfined - env: - - name: DOCKER_TLS_CERTDIR - value: "" - volumeMounts: - - name: dind-storage - mountPath: /var/lib/docker - - name: config - mountPath: /etc/docker/daemon.json - subPath: daemon.json - readOnly: true - + resources: + requests: + memory: "256Mi" + cpu: "100m" + limits: + memory: "1Gi" + cpu: "1000m" volumes: - - name: data + - name: runner-data emptyDir: {} - - name: dind-storage - emptyDir: {} - - name: config + - name: runner-config configMap: name: forgejo-runner-config - - name: zoneinfo - hostPath: - path: /usr/share/zoneinfo - type: Directory diff --git a/argocd/manifests/forgejo-runner/external-secret.yaml b/argocd/manifests/forgejo-runner/external-secret.yaml deleted file mode 100644 index ab7a691..0000000 --- a/argocd/manifests/forgejo-runner/external-secret.yaml +++ /dev/null @@ -1,27 +0,0 @@ -# ExternalSecret for Forgejo Runner credentials -# -# 1Password item: "Forgejo Secrets" in blumeops vault -# Fields: runner_k8s_uuid, runner_k8s_token -# -apiVersion: external-secrets.io/v1 -kind: ExternalSecret -metadata: - name: forgejo-runner-env - namespace: forgejo-runner -spec: - refreshInterval: 1h - secretStoreRef: - kind: ClusterSecretStore - name: onepassword-blumeops - target: - name: forgejo-runner-env - creationPolicy: Owner - data: - - secretKey: FORGEJO_RUNNER_UUID - remoteRef: - key: Forgejo Secrets - 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 93cd33b..332c49c 100644 --- a/argocd/manifests/forgejo-runner/kustomization.yaml +++ b/argocd/manifests/forgejo-runner/kustomization.yaml @@ -1,22 +1,8 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization - namespace: forgejo-runner - resources: - namespace.yaml - - external-secret.yaml + - serviceaccount.yaml + - configmap.yaml - deployment.yaml - -images: - - name: code.forgejo.org/forgejo/runner - newName: registry.ops.eblu.me/blumeops/forgejo-runner - newTag: v12.8.2-1425bf1 - - name: docker - newTag: 27-dind - -configMapGenerator: - - name: forgejo-runner-config - files: - - config.yaml - - daemon.json diff --git a/argocd/manifests/forgejo-runner/secret-token.yaml.tpl b/argocd/manifests/forgejo-runner/secret-token.yaml.tpl new file mode 100644 index 0000000..427d8df --- /dev/null +++ b/argocd/manifests/forgejo-runner/secret-token.yaml.tpl @@ -0,0 +1,10 @@ +# Template for op inject +# Usage: op inject -i secret-token.yaml.tpl | kubectl apply -f - +apiVersion: v1 +kind: Secret +metadata: + name: forgejo-runner-token + namespace: forgejo-runner +type: Opaque +stringData: + token: "op://blumeops/w3663ffnvkewbftncqxtcpeavy/runner_reg" diff --git a/argocd/manifests/forgejo-runner/serviceaccount.yaml b/argocd/manifests/forgejo-runner/serviceaccount.yaml new file mode 100644 index 0000000..ef8cb25 --- /dev/null +++ b/argocd/manifests/forgejo-runner/serviceaccount.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: forgejo-runner + namespace: forgejo-runner diff --git a/argocd/manifests/frigate/deployment-notify.yaml b/argocd/manifests/frigate/deployment-notify.yaml deleted file mode 100644 index 91f4237..0000000 --- a/argocd/manifests/frigate/deployment-notify.yaml +++ /dev/null @@ -1,37 +0,0 @@ ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: frigate-notify - namespace: frigate -spec: - replicas: 1 - selector: - matchLabels: - app: frigate-notify - template: - metadata: - labels: - app: frigate-notify - spec: - containers: - - name: frigate-notify - image: registry.ops.eblu.me/blumeops/frigate-notify:kustomized - env: - - name: TZ - value: America/Los_Angeles - volumeMounts: - - name: config - mountPath: /app/config.yml - subPath: config.yml - resources: - requests: - memory: "32Mi" - cpu: "50m" - limits: - memory: "128Mi" - cpu: "100m" - volumes: - - name: config - configMap: - name: frigate-notify-config diff --git a/argocd/manifests/frigate/deployment.yaml b/argocd/manifests/frigate/deployment.yaml deleted file mode 100644 index 1200e76..0000000 --- a/argocd/manifests/frigate/deployment.yaml +++ /dev/null @@ -1,97 +0,0 @@ ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: frigate - namespace: frigate -spec: - replicas: 1 - strategy: - type: Recreate - selector: - matchLabels: - app: frigate - template: - metadata: - labels: - app: frigate - spec: - runtimeClassName: nvidia - securityContext: - seccompProfile: - type: RuntimeDefault - initContainers: - - name: copy-config - image: busybox:kustomized - command: ["cp", "/config-ro/config.yml", "/config/config.yml"] - volumeMounts: - - name: config-ro - mountPath: /config-ro - - name: config - mountPath: /config - containers: - - name: frigate - image: ghcr.io/blakeblackshear/frigate:kustomized - ports: - - containerPort: 5000 - name: http - - containerPort: 8554 - name: rtsp - - containerPort: 1984 - name: go2rtc - env: - - name: FRIGATE_CAMERA_USER - valueFrom: - secretKeyRef: - name: frigate-camera - key: username - - name: FRIGATE_CAMERA_PASSWORD - valueFrom: - secretKeyRef: - name: frigate-camera - key: password - volumeMounts: - - name: config - mountPath: /config - - name: recordings - mountPath: /media/frigate - - name: database - mountPath: /db - - name: shm - mountPath: /dev/shm - resources: - requests: - memory: "256Mi" - cpu: "200m" - limits: - memory: "3Gi" - cpu: "2000m" - nvidia.com/gpu: "1" - livenessProbe: - httpGet: - path: /api/version - port: 5000 - initialDelaySeconds: 30 - periodSeconds: 30 - readinessProbe: - httpGet: - path: /api/version - port: 5000 - initialDelaySeconds: 15 - periodSeconds: 10 - volumes: - - name: config-ro - configMap: - name: frigate-config - - name: config - emptyDir: {} - - name: recordings - persistentVolumeClaim: - claimName: frigate-recordings - - name: database - persistentVolumeClaim: - claimName: frigate-database - - name: shm - emptyDir: - medium: Memory - sizeLimit: 512Mi diff --git a/argocd/manifests/frigate/external-secret.yaml b/argocd/manifests/frigate/external-secret.yaml deleted file mode 100644 index abe2c92..0000000 --- a/argocd/manifests/frigate/external-secret.yaml +++ /dev/null @@ -1,22 +0,0 @@ -apiVersion: external-secrets.io/v1 -kind: ExternalSecret -metadata: - name: frigate-camera - namespace: frigate -spec: - refreshInterval: 1h - secretStoreRef: - kind: ClusterSecretStore - name: onepassword-blumeops - target: - name: frigate-camera - creationPolicy: Owner - data: - - secretKey: username - remoteRef: - key: Reolink Floodlight Camera - property: username - - secretKey: password - remoteRef: - key: Reolink Floodlight Camera - property: password diff --git a/argocd/manifests/frigate/frigate-config.yml b/argocd/manifests/frigate/frigate-config.yml deleted file mode 100644 index 3033dd4..0000000 --- a/argocd/manifests/frigate/frigate-config.yml +++ /dev/null @@ -1,90 +0,0 @@ -database: - path: /db/frigate.db - -mqtt: - enabled: false - -go2rtc: - streams: - # GableCam IP is reserved in UX7 DHCP config - gablecam: - - "rtsp://{FRIGATE_CAMERA_USER}:{FRIGATE_CAMERA_PASSWORD}@192.168.1.159:554/h264Preview_01_main" - gablecam_sub: - - "rtsp://{FRIGATE_CAMERA_USER}:{FRIGATE_CAMERA_PASSWORD}@192.168.1.159:554/h264Preview_01_sub" - -cameras: - gablecam: - enabled: true - ffmpeg: - inputs: - - path: rtsp://127.0.0.1:8554/gablecam - input_args: preset-rtsp-restream - roles: [record] - - path: rtsp://127.0.0.1:8554/gablecam_sub - input_args: preset-rtsp-restream - roles: [detect] - detect: - enabled: true - stationary: - max_frames: - default: 1500 - motion: - mask: - - 0.401,0.026,0.4,0.078,0.587,0.072,0.585,0.02 - - 0.881,0.422,0.789,0.245,0.595,0.054,0.531,0,0.634,0,0.824,0.192,0.892,0.307 - zones: - driveway_entrance: - coordinates: 0.818,0.362,0.735,0.344,0.662,0.196,0.74,0.236 - objects: [car, dog, person] - inertia: 3 - loitering_time: 0 - driveway: - coordinates: 0.728,0.367,0.645,0.2,0.218,0.25,0.128,0.296,0.003,0.565,0.001,0.992,0.826,0.992,0.897,0.665,0.869,0.608,0.792,0.377 - objects: [person, dog, cat] - review: - alerts: - labels: [person, car] - required_zones: - - driveway_entrance - - driveway - detections: - required_zones: - - driveway - - driveway_entrance - objects: - track: [person, car, dog, cat, bird] - -detectors: - onnx: - type: onnx - -model: - model_type: yolo-generic - width: 640 - height: 640 - input_tensor: nchw - input_dtype: float - path: /media/frigate/models/yolov9-c-640.onnx - labelmap_path: /labelmap/coco-80.txt - -record: - enabled: true - preview: - quality: very_low - continuous: - days: 30 - motion: - days: 365 - alerts: - retain: - days: 730 - mode: active_objects - detections: - retain: - days: 30 - mode: motion - -snapshots: - enabled: true - retain: - default: 14 diff --git a/argocd/manifests/frigate/frigate-notify-config.yml b/argocd/manifests/frigate/frigate-notify-config.yml deleted file mode 100644 index 886e3fd..0000000 --- a/argocd/manifests/frigate/frigate-notify-config.yml +++ /dev/null @@ -1,33 +0,0 @@ -frigate: - server: http://frigate:5000 - public_url: https://nvr.ops.eblu.me - - webapi: - enabled: true - interval: 15 - - -alerts: - general: - title: "Frigate Alert" - nosnap: drop - snap_hires: true - notify_once: true - - zones: - unzoned: drop - allow: - - driveway_entrance - - driveway - - labels: - allow: - - person - - car - - ntfy: - enabled: true - server: http://ntfy.ntfy.svc.cluster.local:80 - topic: frigate-alerts - headers: - - X-Actions: "view, Open Event, {{.Extra.PublicURL}}/review?id={{.ID}}, clear=true; view, Open Camera, {{.Extra.PublicURL}}/#/cameras/{{.Camera}}" diff --git a/argocd/manifests/frigate/ingress-tailscale.yaml b/argocd/manifests/frigate/ingress-tailscale.yaml deleted file mode 100644 index d66b14f..0000000 --- a/argocd/manifests/frigate/ingress-tailscale.yaml +++ /dev/null @@ -1,26 +0,0 @@ ---- -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: frigate-tailscale - namespace: frigate - annotations: - tailscale.com/proxy-class: "default" - tailscale.com/proxy-group: "ingress" - gethomepage.dev/enabled: "true" - gethomepage.dev/name: "NVR" - gethomepage.dev/group: "Home" - gethomepage.dev/icon: "frigate.png" - gethomepage.dev/description: "Network video recorder" - gethomepage.dev/href: "https://nvr.ops.eblu.me" - gethomepage.dev/pod-selector: "app=frigate" -spec: - ingressClassName: tailscale - defaultBackend: - service: - name: frigate - port: - number: 5000 - tls: - - hosts: - - nvr diff --git a/argocd/manifests/frigate/kustomization.yaml b/argocd/manifests/frigate/kustomization.yaml deleted file mode 100644 index a61c758..0000000 --- a/argocd/manifests/frigate/kustomization.yaml +++ /dev/null @@ -1,29 +0,0 @@ ---- -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization -namespace: frigate -resources: - - external-secret.yaml - - pv-nfs.yaml - - pvc-recordings.yaml - - pvc-database.yaml - - deployment.yaml - - deployment-notify.yaml - - service.yaml - - ingress-tailscale.yaml - -images: - - name: busybox - newTag: "1.37" - - name: ghcr.io/blakeblackshear/frigate - newTag: 0.17.1-tensorrt - - name: registry.ops.eblu.me/blumeops/frigate-notify - newTag: v0.5.4-e928054-nix - -configMapGenerator: - - name: frigate-config - files: - - config.yml=frigate-config.yml - - name: frigate-notify-config - files: - - config.yml=frigate-notify-config.yml diff --git a/argocd/manifests/frigate/pv-nfs.yaml b/argocd/manifests/frigate/pv-nfs.yaml deleted file mode 100644 index 86d3956..0000000 --- a/argocd/manifests/frigate/pv-nfs.yaml +++ /dev/null @@ -1,25 +0,0 @@ -# NFS PersistentVolume for Frigate recordings -# Requires: NFS share on sifaka at /volume1/frigate with NFS permissions for ringtail -# -# To create on Synology: -# 1. Control Panel > Shared Folder > Create -# 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/<camera_name>/ — review page preview clips -apiVersion: v1 -kind: PersistentVolume -metadata: - name: frigate-recordings-nfs-pv -spec: - capacity: - storage: 2Ti - accessModes: - - ReadWriteMany - persistentVolumeReclaimPolicy: Retain - storageClassName: "" - nfs: - server: sifaka - path: /volume1/frigate diff --git a/argocd/manifests/frigate/pvc-database.yaml b/argocd/manifests/frigate/pvc-database.yaml deleted file mode 100644 index 1eacb1d..0000000 --- a/argocd/manifests/frigate/pvc-database.yaml +++ /dev/null @@ -1,13 +0,0 @@ -# PersistentVolumeClaim for Frigate SQLite database -# Uses k3s local-path storage class for local provisioning -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: frigate-database - namespace: frigate -spec: - accessModes: - - ReadWriteOnce - resources: - requests: - storage: 10Gi diff --git a/argocd/manifests/frigate/pvc-recordings.yaml b/argocd/manifests/frigate/pvc-recordings.yaml deleted file mode 100644 index 19c1be8..0000000 --- a/argocd/manifests/frigate/pvc-recordings.yaml +++ /dev/null @@ -1,15 +0,0 @@ -# PersistentVolumeClaim for Frigate recordings -# Binds to the NFS PV for sifaka:/volume1/frigate -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: frigate-recordings - namespace: frigate -spec: - accessModes: - - ReadWriteMany - storageClassName: "" - volumeName: frigate-recordings-nfs-pv - resources: - requests: - storage: 2Ti diff --git a/argocd/manifests/frigate/service.yaml b/argocd/manifests/frigate/service.yaml deleted file mode 100644 index 28034a4..0000000 --- a/argocd/manifests/frigate/service.yaml +++ /dev/null @@ -1,19 +0,0 @@ ---- -apiVersion: v1 -kind: Service -metadata: - name: frigate - namespace: frigate -spec: - selector: - app: frigate - ports: - - name: http - port: 5000 - targetPort: 5000 - - name: rtsp - port: 8554 - targetPort: 8554 - - name: go2rtc - port: 1984 - targetPort: 1984 diff --git a/argocd/manifests/grafana-config/dashboards/configmap-alerts.yaml b/argocd/manifests/grafana-config/dashboards/configmap-alerts.yaml deleted file mode 100644 index bcc6ad7..0000000 --- a/argocd/manifests/grafana-config/dashboards/configmap-alerts.yaml +++ /dev/null @@ -1,79 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: grafana-dashboard-alerts - namespace: monitoring - annotations: - grafana_folder: "Infrastructure Alerts" - 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/dashboards/configmap-borgmatic.yaml b/argocd/manifests/grafana-config/dashboards/configmap-borgmatic.yaml index c29f021..4694a5f 100644 --- a/argocd/manifests/grafana-config/dashboards/configmap-borgmatic.yaml +++ b/argocd/manifests/grafana-config/dashboards/configmap-borgmatic.yaml @@ -70,8 +70,7 @@ data: "targets": [ { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "borgmatic_up{repo=~\"$repo\"}", - "legendFormat": "{{repo}}", + "expr": "borgmatic_up", "refId": "A" } ], @@ -115,8 +114,7 @@ data: "targets": [ { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "borgmatic_repo_deduplicated_size_bytes{repo=~\"$repo\"}", - "legendFormat": "{{repo}}", + "expr": "borgmatic_repo_deduplicated_size_bytes", "refId": "A" } ], @@ -160,8 +158,7 @@ data: "targets": [ { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "borgmatic_last_archive_original_size_bytes{repo=~\"$repo\"}", - "legendFormat": "{{repo}}", + "expr": "borgmatic_last_archive_original_size_bytes", "refId": "A" } ], @@ -205,8 +202,7 @@ data: "targets": [ { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "borgmatic_archive_count{repo=~\"$repo\"}", - "legendFormat": "{{repo}}", + "expr": "borgmatic_archive_count", "refId": "A" } ], @@ -254,8 +250,7 @@ data: "targets": [ { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "time() - borgmatic_last_archive_timestamp{repo=~\"$repo\"}", - "legendFormat": "{{repo}}", + "expr": "time() - borgmatic_last_archive_timestamp", "refId": "A" } ], @@ -304,8 +299,7 @@ data: "targets": [ { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "borgmatic_repo_original_size_bytes{repo=~\"$repo\"} / borgmatic_repo_deduplicated_size_bytes{repo=~\"$repo\"}", - "legendFormat": "{{repo}}", + "expr": "borgmatic_repo_original_size_bytes / borgmatic_repo_deduplicated_size_bytes", "refId": "A" } ], @@ -349,8 +343,7 @@ data: "targets": [ { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "borgmatic_last_archive_deduplicated_size_bytes{repo=~\"$repo\"}", - "legendFormat": "{{repo}}", + "expr": "borgmatic_last_archive_deduplicated_size_bytes", "refId": "A" } ], @@ -394,8 +387,7 @@ data: "targets": [ { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "borgmatic_last_archive_files{repo=~\"$repo\"}", - "legendFormat": "{{repo}}", + "expr": "borgmatic_last_archive_files", "refId": "A" } ], @@ -443,8 +435,7 @@ data: "targets": [ { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "borgmatic_last_archive_duration_seconds{repo=~\"$repo\"}", - "legendFormat": "{{repo}}", + "expr": "borgmatic_last_archive_duration_seconds", "refId": "A" } ], @@ -488,8 +479,7 @@ data: "targets": [ { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "borgmatic_repo_unique_chunks{repo=~\"$repo\"}", - "legendFormat": "{{repo}}", + "expr": "borgmatic_repo_unique_chunks", "refId": "A" } ], @@ -534,8 +524,8 @@ data: "targets": [ { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "topk(10, borgmatic_source_size_bytes{repo=~\"$repo\"})", - "legendFormat": "{{repo}} / {{source}}", + "expr": "topk(10, borgmatic_source_size_bytes)", + "legendFormat": "{{source}}", "refId": "A" } ], @@ -551,7 +541,8 @@ data: "fieldConfig": { "defaults": { "color": { - "mode": "palette-classic" + "fixedColor": "green", + "mode": "fixed" }, "custom": { "axisBorderShow": false, @@ -612,8 +603,8 @@ data: "targets": [ { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "borgmatic_last_archive_original_size_bytes{repo=~\"$repo\"}", - "legendFormat": "{{repo}}", + "expr": "borgmatic_last_archive_original_size_bytes", + "legendFormat": "Backup Size (if extracted)", "refId": "A" } ], @@ -629,7 +620,8 @@ data: "fieldConfig": { "defaults": { "color": { - "mode": "palette-classic" + "fixedColor": "blue", + "mode": "fixed" }, "custom": { "axisBorderShow": false, @@ -690,8 +682,8 @@ data: "targets": [ { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "borgmatic_repo_deduplicated_size_bytes{repo=~\"$repo\"}", - "legendFormat": "{{repo}}", + "expr": "borgmatic_repo_deduplicated_size_bytes", + "legendFormat": "Repository Size on Disk", "refId": "A" } ], @@ -707,7 +699,8 @@ data: "fieldConfig": { "defaults": { "color": { - "mode": "palette-classic" + "fixedColor": "orange", + "mode": "fixed" }, "custom": { "axisBorderShow": false, @@ -750,7 +743,7 @@ data: }, "overrides": [] }, - "gridPos": { "h": 8, "w": 12, "x": 0, "y": 16 }, + "gridPos": { "h": 8, "w": 24, "x": 0, "y": 16 }, "id": 13, "options": { "legend": { @@ -768,132 +761,21 @@ data: "targets": [ { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "borgmatic_last_archive_deduplicated_size_bytes{repo=~\"$repo\"}", - "legendFormat": "{{repo}}", + "expr": "borgmatic_last_archive_deduplicated_size_bytes", + "legendFormat": "New Data Added", "refId": "A" } ], "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": { - "mode": "palette-classic" - }, - "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{repo=~\"$repo\"}", - "legendFormat": "{{repo}}", - "refId": "A" - } - ], - "title": "Backup Duration Over Time", - "description": "How long each backup took to complete", - "type": "timeseries" } ], "refresh": "5m", "schemaVersion": 38, - "tags": ["borg", "backup"], + "tags": ["borgmatic", "backup", "borg"], "templating": { - "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" - } - ] + "list": [] }, "time": { "from": "now-30d", @@ -901,7 +783,7 @@ data: }, "timepicker": {}, "timezone": "browser", - "title": "Borg Backups", + "title": "Borgmatic Backups", "uid": "borgmatic", "version": 1, "weekStart": "" diff --git a/argocd/manifests/grafana-config/dashboards/configmap-cv-apm.yaml b/argocd/manifests/grafana-config/dashboards/configmap-cv-apm.yaml deleted file mode 100644 index fd05c4f..0000000 --- a/argocd/manifests/grafana-config/dashboards/configmap-cv-apm.yaml +++ /dev/null @@ -1,263 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: grafana-dashboard-cv-apm - namespace: monitoring - labels: - grafana_dashboard: "1" -data: - cv-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": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "req/s", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "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": "normal" }, - "thresholdsStyle": { "mode": "off" } - }, - "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=\"cv.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=\"cv.eblu.me\",status=~\"5..\"}[5m])) / sum(rate(flyio_nginx_http_requests_total{host=\"cv.eblu.me\"}[5m]))", "refId": "A" } - ], - "title": "Error Rate (5xx)", - "type": "stat" - }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "thresholds" }, - "mappings": [{ "type": "special", "options": { "match": "null+nan", "result": { "text": "No traffic", "color": "text" } } }], - "thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "yellow", "value": 0.5 }, { "color": "green", "value": 0.8 }] }, - "unit": "percentunit" - }, - "overrides": [] - }, - "gridPos": { "h": 4, "w": 4, "x": 16, "y": 4 }, - "id": 3, - "options": { - "colorMode": "background", - "graphMode": "none", - "justifyMode": "center", - "orientation": "auto", - "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, - "textMode": "auto" - }, - "targets": [ - { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "sum(increase(flyio_nginx_cache_requests_total{host=\"cv.eblu.me\",cache_status=\"HIT\"}[$__range])) / sum(increase(flyio_nginx_cache_requests_total{host=\"cv.eblu.me\"}[$__range]))", "refId": "A" } - ], - "title": "Cache Hit Ratio", - "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=\"cv.eblu.me\"}[5m]))", "refId": "A" } - ], - "title": "Current RPS", - "type": "stat" - }, - { - "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": 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=\"cv.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=\"cv.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=\"cv.eblu.me\"}[5m])))", "legendFormat": "p99", "refId": "C" } - ], - "title": "Latency Percentiles", - "type": "timeseries" - }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "palette-classic" }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "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": "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=\"cv.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\"} |= \"cv.eblu.me\" | json | line_format \"{{.client_ip}} {{.request_method}} {{.request_uri}} {{.status}} cache={{.upstream_cache_status}} {{.request_time}}s\"", "refId": "A" } - ], - "title": "Recent Access Logs", - "type": "logs" - } - ], - "refresh": "30s", - "schemaVersion": 38, - "tags": ["cv", "flyio", "apm"], - "templating": { "list": [] }, - "time": { "from": "now-6h", "to": "now" }, - "timepicker": {}, - "timezone": "", - "title": "CV APM", - "uid": "cv-apm", - "version": 1, - "weekStart": "" - } diff --git a/argocd/manifests/grafana-config/dashboards/configmap-docs-apm.yaml b/argocd/manifests/grafana-config/dashboards/configmap-docs-apm.yaml deleted file mode 100644 index b96d1ea..0000000 --- a/argocd/manifests/grafana-config/dashboards/configmap-docs-apm.yaml +++ /dev/null @@ -1,263 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: grafana-dashboard-docs-apm - namespace: monitoring - labels: - grafana_dashboard: "1" -data: - docs-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": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "req/s", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "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": "normal" }, - "thresholdsStyle": { "mode": "off" } - }, - "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=\"docs.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=\"docs.eblu.me\",status=~\"5..\"}[5m])) / sum(rate(flyio_nginx_http_requests_total{host=\"docs.eblu.me\"}[5m]))", "refId": "A" } - ], - "title": "Error Rate (5xx)", - "type": "stat" - }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "thresholds" }, - "mappings": [{ "type": "special", "options": { "match": "null+nan", "result": { "text": "No traffic", "color": "text" } } }], - "thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "yellow", "value": 0.5 }, { "color": "green", "value": 0.8 }] }, - "unit": "percentunit" - }, - "overrides": [] - }, - "gridPos": { "h": 4, "w": 4, "x": 16, "y": 4 }, - "id": 3, - "options": { - "colorMode": "background", - "graphMode": "none", - "justifyMode": "center", - "orientation": "auto", - "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, - "textMode": "auto" - }, - "targets": [ - { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "sum(increase(flyio_nginx_cache_requests_total{host=\"docs.eblu.me\",cache_status=\"HIT\"}[$__range])) / sum(increase(flyio_nginx_cache_requests_total{host=\"docs.eblu.me\"}[$__range]))", "refId": "A" } - ], - "title": "Cache Hit Ratio", - "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=\"docs.eblu.me\"}[5m]))", "refId": "A" } - ], - "title": "Current RPS", - "type": "stat" - }, - { - "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": 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=\"docs.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=\"docs.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=\"docs.eblu.me\"}[5m])))", "legendFormat": "p99", "refId": "C" } - ], - "title": "Latency Percentiles", - "type": "timeseries" - }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "palette-classic" }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "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": "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=\"docs.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\"} |= \"docs.eblu.me\" | json | line_format \"{{.client_ip}} {{.request_method}} {{.request_uri}} {{.status}} cache={{.upstream_cache_status}} {{.request_time}}s\"", "refId": "A" } - ], - "title": "Recent Access Logs", - "type": "logs" - } - ], - "refresh": "30s", - "schemaVersion": 38, - "tags": ["docs", "flyio", "apm"], - "templating": { "list": [] }, - "time": { "from": "now-6h", "to": "now" }, - "timepicker": {}, - "timezone": "", - "title": "Docs APM", - "uid": "docs-apm", - "version": 1, - "weekStart": "" - } diff --git a/argocd/manifests/grafana-config/dashboards/configmap-flyio.yaml b/argocd/manifests/grafana-config/dashboards/configmap-flyio.yaml deleted file mode 100644 index abbf2b3..0000000 --- a/argocd/manifests/grafana-config/dashboards/configmap-flyio.yaml +++ /dev/null @@ -1,271 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: grafana-dashboard-flyio - namespace: monitoring - labels: - grafana_dashboard: "1" -data: - flyio.json: | - { - "annotations": { "list": [] }, - "editable": true, - "fiscalYearStartMonth": 0, - "graphTooltip": 1, - "id": null, - "links": [], - "panels": [ - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "thresholds" }, - "mappings": [ - { "options": { "0": { "color": "red", "text": "DOWN" }, "1": { "color": "green", "text": "UP" } }, "type": "value" } - ], - "thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "green", "value": 1 }] }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { "h": 6, "w": 6, "x": 0, "y": 0 }, - "id": 1, - "options": { - "colorMode": "background", - "graphMode": "none", - "justifyMode": "center", - "orientation": "auto", - "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, - "textMode": "value" - }, - "targets": [ - { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "up{instance=\"flyio-proxy\"}", "refId": "A" } - ], - "title": "Alloy Health", - "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": 6, "w": 6, "x": 6, "y": 0 }, - "id": 2, - "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{instance=\"flyio-proxy\"}[5m]))", "refId": "A" } - ], - "title": "Total RPS", - "type": "stat" - }, - { - "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": 6, "w": 6, "x": 12, "y": 0 }, - "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(rate(flyio_nginx_http_requests_total{instance=\"flyio-proxy\",status=~\"5..\"}[5m])) / sum(rate(flyio_nginx_http_requests_total{instance=\"flyio-proxy\"}[5m]))", "refId": "A" } - ], - "title": "Error Rate (5xx)", - "type": "stat" - }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "thresholds" }, - "mappings": [{ "type": "special", "options": { "match": "null+nan", "result": { "text": "No traffic", "color": "text" } } }], - "thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "yellow", "value": 0.5 }, { "color": "green", "value": 0.8 }] }, - "unit": "percentunit" - }, - "overrides": [] - }, - "gridPos": { "h": 6, "w": 6, "x": 18, "y": 0 }, - "id": 7, - "options": { - "colorMode": "background", - "graphMode": "none", - "justifyMode": "center", - "orientation": "auto", - "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, - "textMode": "auto" - }, - "targets": [ - { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "sum(increase(flyio_nginx_cache_requests_total{instance=\"flyio-proxy\",cache_status=\"HIT\"}[$__range])) / sum(increase(flyio_nginx_cache_requests_total{instance=\"flyio-proxy\"}[$__range]))", "refId": "A" } - ], - "title": "Cache Hit Ratio", - "type": "stat" - }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "palette-classic" }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "req/s", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "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": "normal" }, - "thresholdsStyle": { "mode": "off" } - }, - "mappings": [], - "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }, - "unit": "reqps" - }, - "overrides": [] - }, - "gridPos": { "h": 8, "w": 12, "x": 0, "y": 6 }, - "id": 4, - "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 (host) (rate(flyio_nginx_http_requests_total{instance=\"flyio-proxy\"}[5m]))", "legendFormat": "{{host}}", "refId": "A" } - ], - "title": "Total Request Rate by Host", - "type": "timeseries" - }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "palette-classic" }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "fillOpacity": 80, - "gradientMode": "none", - "hideFrom": { "legend": false, "tooltip": false, "viz": false }, - "lineWidth": 1, - "scaleDistribution": { "type": "linear" } - }, - "mappings": [], - "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { "h": 8, "w": 12, "x": 12, "y": 6 }, - "id": 5, - "options": { - "barRadius": 0.05, - "barWidth": 0.7, - "fullHighlight": false, - "groupWidth": 0.7, - "legend": { "calcs": [], "displayMode": "list", "placement": "right", "showLegend": true }, - "orientation": "horizontal", - "showValue": "always", - "stacking": "none", - "tooltip": { "mode": "single", "sort": "none" }, - "xTickLabelRotation": 0 - }, - "targets": [ - { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "sum by (cache_status) (increase(flyio_nginx_cache_requests_total{instance=\"flyio-proxy\"}[$__range]))", "legendFormat": "{{cache_status}}", "refId": "A", "instant": true } - ], - "title": "Cache Performance", - "type": "barchart" - }, - { - "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": 14 }, - "id": 6, - "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{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": "Latency Percentiles (all hosts)", - "type": "timeseries" - } - ], - "refresh": "30s", - "schemaVersion": 38, - "tags": ["flyio", "nginx", "proxy"], - "templating": { "list": [] }, - "time": { "from": "now-6h", "to": "now" }, - "timepicker": {}, - "timezone": "", - "title": "Fly.io Proxy Health", - "uid": "flyio-proxy", - "version": 1, - "weekStart": "" - } diff --git a/argocd/manifests/grafana-config/dashboards/configmap-forgejo.yaml b/argocd/manifests/grafana-config/dashboards/configmap-forgejo.yaml deleted file mode 100644 index 782135e..0000000 --- a/argocd/manifests/grafana-config/dashboards/configmap-forgejo.yaml +++ /dev/null @@ -1,985 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: grafana-dashboard-forgejo - namespace: monitoring - labels: - grafana_dashboard: "1" -data: - forgejo.json: | - { - "annotations": { - "list": [] - }, - "editable": true, - "fiscalYearStartMonth": 0, - "graphTooltip": 1, - "id": null, - "links": [], - "panels": [ - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "fieldConfig": { - "defaults": { - "color": { "mode": "thresholds" }, - "mappings": [ - { "options": { "0": { "color": "red", "index": 0, "text": "DOWN" } }, "type": "value" }, - { "options": { "1": { "color": "green", "index": 1, "text": "UP" } }, "type": "value" } - ], - "thresholds": { - "mode": "absolute", - "steps": [ - { "color": "red", "value": null }, - { "color": "green", "value": 1 } - ] - } - }, - "overrides": [] - }, - "gridPos": { "h": 4, "w": 4, "x": 0, "y": 0 }, - "id": 1, - "options": { - "colorMode": "value", - "graphMode": "none", - "justifyMode": "auto", - "orientation": "auto", - "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, - "textMode": "auto" - }, - "pluginVersion": "10.0.0", - "targets": [ - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "forgejo_up", - "refId": "A" - } - ], - "title": "Forgejo Status", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "fieldConfig": { - "defaults": { - "color": { "mode": "thresholds" }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { "color": "green", "value": null }, - { "color": "yellow", "value": 5 }, - { "color": "red", "value": 10 } - ] - } - }, - "overrides": [] - }, - "gridPos": { "h": 4, "w": 4, "x": 4, "y": 0 }, - "id": 2, - "options": { - "colorMode": "value", - "graphMode": "area", - "justifyMode": "auto", - "orientation": "auto", - "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, - "textMode": "auto" - }, - "pluginVersion": "10.0.0", - "targets": [ - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "sum(forgejo_repo_open_pull_requests{repo=~\"$repo\"})", - "refId": "A" - } - ], - "title": "Open PRs", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "fieldConfig": { - "defaults": { - "color": { "mode": "thresholds" }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { "color": "green", "value": null }, - { "color": "yellow", "value": 10 }, - { "color": "red", "value": 25 } - ] - } - }, - "overrides": [] - }, - "gridPos": { "h": 4, "w": 4, "x": 8, "y": 0 }, - "id": 3, - "options": { - "colorMode": "value", - "graphMode": "area", - "justifyMode": "auto", - "orientation": "auto", - "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, - "textMode": "auto" - }, - "pluginVersion": "10.0.0", - "targets": [ - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "sum(forgejo_repo_open_issues{repo=~\"$repo\"})", - "refId": "A" - } - ], - "title": "Open Issues", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "fieldConfig": { - "defaults": { - "color": { "mode": "thresholds" }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { "color": "green", "value": null }, - { "color": "yellow", "value": 604800 }, - { "color": "red", "value": 2592000 } - ] - }, - "unit": "dtdurations" - }, - "overrides": [] - }, - "gridPos": { "h": 4, "w": 4, "x": 12, "y": 0 }, - "id": 4, - "options": { - "colorMode": "value", - "graphMode": "none", - "justifyMode": "auto", - "orientation": "auto", - "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, - "textMode": "auto" - }, - "pluginVersion": "10.0.0", - "targets": [ - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "time() - max(forgejo_repo_latest_release_timestamp_seconds{repo=~\"$repo\"})", - "refId": "A" - } - ], - "title": "Latest Release Age", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "fieldConfig": { - "defaults": { - "color": { "mode": "thresholds" }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { "color": "green", "value": null }, - { "color": "yellow", "value": 1 }, - { "color": "red", "value": 3 } - ] - } - }, - "overrides": [] - }, - "gridPos": { "h": 4, "w": 4, "x": 16, "y": 0 }, - "id": 5, - "options": { - "colorMode": "value", - "graphMode": "area", - "justifyMode": "auto", - "orientation": "auto", - "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, - "textMode": "auto" - }, - "pluginVersion": "10.0.0", - "targets": [ - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "sum(forgejo_actions_jobs_running{repo=~\"$repo\"})", - "legendFormat": "Running", - "refId": "A" - }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "sum(forgejo_actions_jobs_waiting{repo=~\"$repo\"})", - "legendFormat": "Waiting", - "refId": "B" - } - ], - "title": "Jobs Running / Waiting", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "fieldConfig": { - "defaults": { - "color": { "mode": "thresholds" }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { "color": "green", "value": null }, - { "color": "yellow", "value": 43200 }, - { "color": "red", "value": 86400 } - ] - }, - "unit": "dtdurations" - }, - "overrides": [] - }, - "gridPos": { "h": 4, "w": 4, "x": 20, "y": 0 }, - "id": 6, - "options": { - "colorMode": "value", - "graphMode": "none", - "justifyMode": "auto", - "orientation": "auto", - "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, - "textMode": "auto" - }, - "pluginVersion": "10.0.0", - "targets": [ - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "time() - max(forgejo_actions_last_success_timestamp_seconds{repo=~\"$repo\"})", - "refId": "A" - } - ], - "title": "Last Successful Build", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "fieldConfig": { - "defaults": { - "color": { "mode": "palette-classic" }, - "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": "normal" }, - "thresholdsStyle": { "mode": "off" } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [{ "color": "green", "value": null }] - } - }, - "overrides": [ - { - "matcher": { "id": "byName", "options": "success" }, - "properties": [{ "id": "color", "value": { "fixedColor": "green", "mode": "fixed" } }] - }, - { - "matcher": { "id": "byName", "options": "failure" }, - "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] - } - ] - }, - "gridPos": { "h": 8, "w": 12, "x": 0, "y": 4 }, - "id": 7, - "options": { - "legend": { - "calcs": ["sum"], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { "mode": "multi", "sort": "desc" } - }, - "pluginVersion": "10.0.0", - "targets": [ - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "sum by (status) (forgejo_actions_runs_total{repo=~\"$repo\"})", - "legendFormat": "{{status}}", - "refId": "A" - } - ], - "title": "Actions Runs by Status", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "fieldConfig": { - "defaults": { - "color": { "mode": "palette-classic" }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "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": 12, "x": 12, "y": 4 }, - "id": 8, - "options": { - "legend": { - "calcs": ["mean", "max"], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { "mode": "multi", "sort": "desc" } - }, - "pluginVersion": "10.0.0", - "targets": [ - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "forgejo_actions_run_duration_seconds{repo=~\"$repo\"}", - "legendFormat": "{{repo}} / {{workflow}}", - "refId": "A" - } - ], - "title": "Run Duration by Workflow", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "fieldConfig": { - "defaults": { - "color": { "mode": "palette-classic" }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 30, - "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": "normal" }, - "thresholdsStyle": { "mode": "off" } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [{ "color": "green", "value": null }] - }, - "unit": "bytes" - }, - "overrides": [] - }, - "gridPos": { "h": 8, "w": 12, "x": 0, "y": 12 }, - "id": 9, - "options": { - "legend": { - "calcs": ["lastNotNull"], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { "mode": "multi", "sort": "desc" } - }, - "pluginVersion": "10.0.0", - "targets": [ - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "sum by (language) (forgejo_repo_language_bytes{repo=~\"$repo\"})", - "legendFormat": "{{language}}", - "refId": "A" - } - ], - "title": "Language Distribution", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "fieldConfig": { - "defaults": { - "color": { "mode": "palette-classic" }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 10, - "gradientMode": "none", - "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": null }] - } - }, - "overrides": [] - }, - "gridPos": { "h": 8, "w": 12, "x": 12, "y": 12 }, - "id": 10, - "options": { - "legend": { - "calcs": ["lastNotNull"], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { "mode": "multi", "sort": "desc" } - }, - "pluginVersion": "10.0.0", - "targets": [ - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "forgejo_repo_releases_total{repo=~\"$repo\"}", - "legendFormat": "{{repo}}", - "refId": "A" - } - ], - "title": "Releases Total by Repository", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "fieldConfig": { - "defaults": { - "color": { "mode": "palette-classic" }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "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 }, - { "color": "yellow", "value": 43200 }, - { "color": "red", "value": 86400 } - ] - }, - "unit": "s" - }, - "overrides": [] - }, - "gridPos": { "h": 8, "w": 12, "x": 0, "y": 20 }, - "id": 11, - "options": { - "legend": { - "calcs": ["lastNotNull"], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { "mode": "multi", "sort": "desc" } - }, - "pluginVersion": "10.0.0", - "targets": [ - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "time() - forgejo_actions_last_success_timestamp_seconds{repo=~\"$repo\"}", - "legendFormat": "{{repo}} / {{workflow}}", - "refId": "A" - } - ], - "title": "Time Since Last Success", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "fieldConfig": { - "defaults": { - "color": { "mode": "palette-classic" }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 30, - "gradientMode": "none", - "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": "normal" }, - "thresholdsStyle": { "mode": "off" } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [{ "color": "green", "value": null }] - } - }, - "overrides": [ - { - "matcher": { "id": "byName", "options": "Waiting" }, - "properties": [{ "id": "color", "value": { "fixedColor": "yellow", "mode": "fixed" } }] - }, - { - "matcher": { "id": "byName", "options": "Running" }, - "properties": [{ "id": "color", "value": { "fixedColor": "blue", "mode": "fixed" } }] - } - ] - }, - "gridPos": { "h": 8, "w": 12, "x": 12, "y": 20 }, - "id": 12, - "options": { - "legend": { - "calcs": ["mean", "max"], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { "mode": "multi", "sort": "desc" } - }, - "pluginVersion": "10.0.0", - "targets": [ - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "sum(forgejo_actions_jobs_waiting{repo=~\"$repo\"})", - "legendFormat": "Waiting", - "refId": "A" - }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "sum(forgejo_actions_jobs_running{repo=~\"$repo\"})", - "legendFormat": "Running", - "refId": "B" - } - ], - "title": "Queue Depth", - "type": "timeseries" - }, - { - "collapsed": false, - "gridPos": { "h": 1, "w": 24, "x": 0, "y": 28 }, - "id": 13, - "title": "Public Proxy (fly.io nginx)", - "type": "row" - }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "palette-classic" }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "req/s", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "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": "normal" }, - "thresholdsStyle": { "mode": "off" } - }, - "mappings": [], - "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }, - "unit": "reqps" - }, - "overrides": [] - }, - "gridPos": { "h": 8, "w": 16, "x": 0, "y": 29 }, - "id": 14, - "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=\"forge.eblu.me\"}[5m]))", "legendFormat": "{{status}}", "refId": "A" } - ], - "title": "Proxy: 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": 29 }, - "id": 15, - "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=\"forge.eblu.me\",status=~\"5..\"}[5m])) / sum(rate(flyio_nginx_http_requests_total{host=\"forge.eblu.me\"}[5m]))", "refId": "A" } - ], - "title": "Proxy: Error Rate (5xx)", - "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": 8, "x": 16, "y": 33 }, - "id": 16, - "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=\"forge.eblu.me\"}[5m]))", "refId": "A" } - ], - "title": "Proxy: Current RPS", - "type": "stat" - }, - { - "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": 12, "x": 0, "y": 37 }, - "id": 17, - "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=\"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{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{host=\"forge.eblu.me\"}[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": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "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": "Bps" - }, - "overrides": [] - }, - "gridPos": { "h": 8, "w": 12, "x": 12, "y": 37 }, - "id": 18, - "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=\"forge.eblu.me\"}[5m]))", "legendFormat": "Bandwidth", "refId": "A" } - ], - "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 }, - "id": 19, - "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\"} |= \"forge.eblu.me\" | json | line_format \"{{.client_ip}} {{.request_method}} {{.request_uri}} {{.status}} {{.request_time}}s\"", "refId": "A" } - ], - "title": "Proxy: Recent Access Logs", - "type": "logs" - }, - { - "collapsed": false, - "gridPos": { "h": 1, "w": 24, "x": 0, "y": 53 }, - "id": 20, - "title": "Application Logs", - "type": "row" - }, - { - "datasource": { "type": "loki", "uid": "loki" }, - "gridPos": { "h": 10, "w": 24, "x": 0, "y": 54 }, - "id": 21, - "options": { - "dedupStrategy": "none", - "enableLogDetails": true, - "prettifyLogMessage": false, - "showCommonLabels": false, - "showLabels": false, - "showTime": true, - "sortOrder": "Descending", - "wrapLogMessage": false - }, - "targets": [ - { "datasource": { "type": "loki", "uid": "loki" }, "expr": "{service=\"forgejo\"}", "refId": "A" } - ], - "title": "Forgejo Application Logs", - "type": "logs" - } - ], - "refresh": "30s", - "schemaVersion": 38, - "tags": ["forgejo", "ci-cd", "repository", "flyio"], - "templating": { - "list": [ - { - "current": { "selected": true, "text": "All", "value": "$__all" }, - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "definition": "label_values(forgejo_repo_open_pull_requests, repo)", - "includeAll": true, - "multi": true, - "name": "repo", - "label": "Repository", - "query": { "query": "label_values(forgejo_repo_open_pull_requests, repo)", "refId": "StandardVariableQuery" }, - "refresh": 2, - "sort": 1, - "type": "query" - } - ] - }, - "time": { - "from": "now-6h", - "to": "now" - }, - "timepicker": {}, - "timezone": "", - "title": "Forgejo", - "uid": "forgejo", - "version": 1, - "weekStart": "" - } diff --git a/argocd/manifests/grafana-config/dashboards/configmap-frigate.yaml b/argocd/manifests/grafana-config/dashboards/configmap-frigate.yaml deleted file mode 100644 index 7731c18..0000000 --- a/argocd/manifests/grafana-config/dashboards/configmap-frigate.yaml +++ /dev/null @@ -1,597 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: grafana-dashboard-frigate - namespace: monitoring - labels: - grafana_dashboard: "1" -data: - frigate.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 } - ] - }, - "unit": "s" - }, - "overrides": [] - }, - "gridPos": { "h": 4, "w": 4, "x": 0, "y": 0 }, - "id": 1, - "options": { - "colorMode": "value", - "graphMode": "none", - "justifyMode": "auto", - "orientation": "auto", - "reduceOptions": { - "calcs": ["lastNotNull"], - "fields": "", - "values": false - }, - "textMode": "auto" - }, - "targets": [ - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "frigate_service_uptime_seconds", - "refId": "A" - } - ], - "title": "Uptime", - "type": "stat" - }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "thresholds" }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { "color": "green", "value": null }, - { "color": "yellow", "value": 15 }, - { "color": "red", "value": 30 } - ] - }, - "unit": "ms" - }, - "overrides": [] - }, - "gridPos": { "h": 4, "w": 4, "x": 4, "y": 0 }, - "id": 2, - "options": { - "colorMode": "value", - "graphMode": "area", - "justifyMode": "auto", - "orientation": "auto", - "reduceOptions": { - "calcs": ["lastNotNull"], - "fields": "", - "values": false - }, - "textMode": "auto" - }, - "targets": [ - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "frigate_detector_inference_speed_seconds * 1000", - "legendFormat": "{{ name }}", - "refId": "A" - } - ], - "title": "Inference Speed", - "type": "stat" - }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "thresholds" }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { "color": "green", "value": null } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { "h": 4, "w": 4, "x": 8, "y": 0 }, - "id": 3, - "options": { - "colorMode": "value", - "graphMode": "area", - "justifyMode": "auto", - "orientation": "auto", - "reduceOptions": { - "calcs": ["lastNotNull"], - "fields": "", - "values": false - }, - "textMode": "auto" - }, - "targets": [ - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "sum(frigate_camera_fps)", - "refId": "A" - } - ], - "title": "Total Camera FPS", - "type": "stat" - }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "thresholds" }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { "color": "green", "value": null } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { "h": 4, "w": 4, "x": 12, "y": 0 }, - "id": 4, - "options": { - "colorMode": "value", - "graphMode": "area", - "justifyMode": "auto", - "orientation": "auto", - "reduceOptions": { - "calcs": ["lastNotNull"], - "fields": "", - "values": false - }, - "textMode": "auto" - }, - "targets": [ - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "frigate_detection_fps", - "refId": "A" - } - ], - "title": "Detection FPS", - "type": "stat" - }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "thresholds" }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { "color": "green", "value": null }, - { "color": "yellow", "value": 80 }, - { "color": "red", "value": 95 } - ] - }, - "unit": "percent" - }, - "overrides": [] - }, - "gridPos": { "h": 4, "w": 4, "x": 16, "y": 0 }, - "id": 5, - "options": { - "colorMode": "value", - "graphMode": "area", - "justifyMode": "auto", - "orientation": "auto", - "reduceOptions": { - "calcs": ["lastNotNull"], - "fields": "", - "values": false - }, - "textMode": "auto" - }, - "targets": [ - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "frigate_gpu_usage_percent", - "legendFormat": "{{ gpu_name }}", - "refId": "A" - } - ], - "title": "GPU Usage", - "type": "stat" - }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "palette-classic" }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "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": "percent" - }, - "overrides": [] - }, - "gridPos": { "h": 8, "w": 12, "x": 0, "y": 4 }, - "id": 7, - "options": { - "legend": { - "calcs": ["mean", "max"], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { "mode": "multi", "sort": "desc" } - }, - "targets": [ - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "frigate_cpu_usage_percent", - "legendFormat": "{{ type }} - {{ pid }}", - "refId": "A" - } - ], - "title": "CPU Usage", - "type": "timeseries" - }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "palette-classic" }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "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": "percent" - }, - "overrides": [] - }, - "gridPos": { "h": 8, "w": 12, "x": 12, "y": 4 }, - "id": 8, - "options": { - "legend": { - "calcs": ["mean", "max"], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { "mode": "multi", "sort": "desc" } - }, - "targets": [ - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "frigate_mem_usage_percent", - "legendFormat": "{{ type }} - {{ pid }}", - "refId": "A" - } - ], - "title": "Memory Usage", - "type": "timeseries" - }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "palette-classic" }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "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": "short" - }, - "overrides": [] - }, - "gridPos": { "h": 8, "w": 12, "x": 0, "y": 12 }, - "id": 9, - "options": { - "legend": { - "calcs": ["mean", "max"], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { "mode": "multi", "sort": "desc" } - }, - "targets": [ - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "frigate_camera_fps", - "legendFormat": "{{ camera_name }}", - "refId": "A" - }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "frigate_skipped_fps", - "legendFormat": "{{ camera_name }} (skipped)", - "refId": "B" - } - ], - "title": "Camera FPS", - "type": "timeseries" - }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "palette-classic" }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "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": "percent" - }, - "overrides": [] - }, - "gridPos": { "h": 8, "w": 12, "x": 12, "y": 12 }, - "id": 10, - "options": { - "legend": { - "calcs": ["mean", "max"], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { "mode": "multi", "sort": "desc" } - }, - "targets": [ - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "frigate_gpu_usage_percent", - "legendFormat": "{{ gpu_name }} usage", - "refId": "A" - }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "frigate_gpu_mem_usage_percent", - "legendFormat": "{{ gpu_name }} memory", - "refId": "B" - } - ], - "title": "GPU Over Time", - "type": "timeseries" - }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "palette-classic" }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "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": "bytes" - }, - "overrides": [] - }, - "gridPos": { "h": 8, "w": 12, "x": 0, "y": 20 }, - "id": 11, - "options": { - "legend": { - "calcs": ["lastNotNull"], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { "mode": "multi", "sort": "desc" } - }, - "targets": [ - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "frigate_storage_used_bytes", - "legendFormat": "{{ storage }}", - "refId": "A" - } - ], - "title": "Storage Usage", - "type": "timeseries" - }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "palette-classic" }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "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": "short" - }, - "overrides": [] - }, - "gridPos": { "h": 8, "w": 12, "x": 12, "y": 20 }, - "id": 12, - "options": { - "legend": { - "calcs": ["mean", "max"], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { "mode": "multi", "sort": "desc" } - }, - "targets": [ - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "rate(frigate_camera_events_total[5m])", - "legendFormat": "{{ camera }} - {{ label }}", - "refId": "A" - } - ], - "title": "Detection Events (rate)", - "type": "timeseries" - } - ], - "refresh": "30s", - "schemaVersion": 38, - "tags": ["frigate", "nvr", "camera"], - "templating": { - "list": [] - }, - "time": { - "from": "now-6h", - "to": "now" - }, - "timepicker": {}, - "timezone": "", - "title": "Frigate NVR", - "uid": "frigate", - "version": 1, - "weekStart": "" - } diff --git a/argocd/manifests/grafana-config/dashboards/configmap-kubernetes.yaml b/argocd/manifests/grafana-config/dashboards/configmap-minikube.yaml similarity index 54% rename from argocd/manifests/grafana-config/dashboards/configmap-kubernetes.yaml rename to argocd/manifests/grafana-config/dashboards/configmap-minikube.yaml index 61258de..2b1956f 100644 --- a/argocd/manifests/grafana-config/dashboards/configmap-kubernetes.yaml +++ b/argocd/manifests/grafana-config/dashboards/configmap-minikube.yaml @@ -1,12 +1,12 @@ apiVersion: v1 kind: ConfigMap metadata: - name: grafana-dashboard-kubernetes + name: grafana-dashboard-minikube namespace: monitoring labels: grafana_dashboard: "1" data: - kubernetes.json: | + minikube.json: | { "annotations": { "list": [] }, "editable": true, @@ -17,109 +17,96 @@ data: "panels": [ { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] } } }, - "gridPos": { "h": 3, "w": 4, "x": 0, "y": 0 }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [{ "options": { "0": { "color": "red", "text": "DOWN" }, "1": { "color": "green", "text": "UP" } }, "type": "value" }], + "thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "green", "value": 1 }] } + } + }, + "gridPos": { "h": 3, "w": 3, "x": 0, "y": 0 }, "id": 1, + "options": { "colorMode": "background", "graphMode": "none", "justifyMode": "center", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "value" }, + "targets": [{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "minikube_up", "refId": "A" }], + "title": "Cluster", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [{ "options": { "0": { "color": "red", "text": "DOWN" }, "1": { "color": "green", "text": "UP" } }, "type": "value" }], + "thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "green", "value": 1 }] } + } + }, + "gridPos": { "h": 3, "w": 3, "x": 3, "y": 0 }, + "id": 2, + "options": { "colorMode": "background", "graphMode": "none", "justifyMode": "center", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "value" }, + "targets": [{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "minikube_apiserver_up", "refId": "A" }], + "title": "API Server", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] } } }, + "gridPos": { "h": 3, "w": 3, "x": 6, "y": 0 }, + "id": 3, "options": { "colorMode": "value", "graphMode": "none", "justifyMode": "center", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "value" }, - "targets": [{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "count(kube_pod_info{cluster=~\"$cluster\", namespace=~\"$namespace\"})", "refId": "A" }], + "targets": [{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "count(kube_pod_info{namespace=~\"$namespace\"})", "refId": "A" }], "title": "Pods", "type": "stat" }, { "datasource": { "type": "prometheus", "uid": "prometheus" }, "fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] } } }, - "gridPos": { "h": 3, "w": 4, "x": 4, "y": 0 }, - "id": 2, + "gridPos": { "h": 3, "w": 3, "x": 9, "y": 0 }, + "id": 4, "options": { "colorMode": "value", "graphMode": "none", "justifyMode": "center", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "value" }, - "targets": [{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "count(kube_deployment_created{cluster=~\"$cluster\", namespace=~\"$namespace\"})", "refId": "A" }], + "targets": [{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "count(kube_deployment_created{namespace=~\"$namespace\"})", "refId": "A" }], "title": "Deployments", "type": "stat" }, { "datasource": { "type": "prometheus", "uid": "prometheus" }, "fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] } } }, - "gridPos": { "h": 3, "w": 4, "x": 8, "y": 0 }, - "id": 3, + "gridPos": { "h": 3, "w": 3, "x": 12, "y": 0 }, + "id": 5, "options": { "colorMode": "value", "graphMode": "none", "justifyMode": "center", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "value" }, - "targets": [{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "count(kube_statefulset_created{cluster=~\"$cluster\", namespace=~\"$namespace\"})", "refId": "A" }], + "targets": [{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "count(kube_statefulset_created{namespace=~\"$namespace\"})", "refId": "A" }], "title": "StatefulSets", "type": "stat" }, { "datasource": { "type": "prometheus", "uid": "prometheus" }, "fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] } } }, - "gridPos": { "h": 3, "w": 4, "x": 12, "y": 0 }, - "id": 4, + "gridPos": { "h": 3, "w": 3, "x": 15, "y": 0 }, + "id": 6, "options": { "colorMode": "value", "graphMode": "none", "justifyMode": "center", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "value" }, - "targets": [{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "count(kube_namespace_created{cluster=~\"$cluster\"})", "refId": "A" }], + "targets": [{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "count(kube_namespace_created)", "refId": "A" }], "title": "Namespaces", "type": "stat" }, { "datasource": { "type": "prometheus", "uid": "prometheus" }, "fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "unit": "bytes", "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] } } }, - "gridPos": { "h": 3, "w": 4, "x": 16, "y": 0 }, - "id": 5, + "gridPos": { "h": 3, "w": 3, "x": 18, "y": 0 }, + "id": 7, "options": { "colorMode": "value", "graphMode": "area", "justifyMode": "center", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "value" }, - "targets": [{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "sum(kube_pod_container_resource_requests{resource=\"memory\", cluster=~\"$cluster\", namespace=~\"$namespace\"})", "refId": "A" }], + "targets": [{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "sum(kube_pod_container_resource_requests{resource=\"memory\",namespace=~\"$namespace\"})", "refId": "A" }], "title": "Memory Requests", "type": "stat" }, { "datasource": { "type": "prometheus", "uid": "prometheus" }, "fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "unit": "short", "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] } } }, - "gridPos": { "h": 3, "w": 4, "x": 20, "y": 0 }, - "id": 6, + "gridPos": { "h": 3, "w": 3, "x": 21, "y": 0 }, + "id": 8, "options": { "colorMode": "value", "graphMode": "area", "justifyMode": "center", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "value" }, - "targets": [{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "sum(kube_pod_container_resource_requests{resource=\"cpu\", cluster=~\"$cluster\", namespace=~\"$namespace\"})", "refId": "A" }], + "targets": [{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "sum(kube_pod_container_resource_requests{resource=\"cpu\",namespace=~\"$namespace\"})", "refId": "A" }], "title": "CPU Requests (cores)", "type": "stat" }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "thresholds" }, - "mappings": [], - "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 1 }] } - } - }, - "gridPos": { "h": 4, "w": 4, "x": 0, "y": 3 }, - "id": 7, - "options": { "colorMode": "background", "graphMode": "none", "justifyMode": "center", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "value" }, - "targets": [{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "count(kube_pod_container_status_waiting_reason{cluster=~\"$cluster\", namespace=~\"$namespace\", reason=~\"ImagePullBackOff|ErrImagePull|CrashLoopBackOff|CreateContainerError|RunContainerError\"}) or vector(0)", "refId": "A" }], - "title": "Unhealthy Pods", - "type": "stat" - }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "thresholds" }, - "custom": { "align": "auto", "cellOptions": { "type": "auto" }, "inspect": false }, - "mappings": [], - "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] } - }, - "overrides": [ - { "matcher": { "id": "byName", "options": "Memory Limit" }, "properties": [{ "id": "unit", "value": "bytes" }] }, - { "matcher": { "id": "byName", "options": "Restarts" }, "properties": [{ "id": "custom.cellOptions", "value": { "type": "color-text" } }, { "id": "thresholds", "value": { "mode": "absolute", "steps": [{ "color": "yellow", "value": null }, { "color": "red", "value": 5 }] } }] } - ] - }, - "gridPos": { "h": 4, "w": 12, "x": 4, "y": 3 }, - "id": 13, - "options": { "cellHeight": "sm", "footer": { "countRows": false, "fields": "", "reducer": ["sum"], "show": false }, "showHeader": true, "sortBy": [{ "desc": true, "displayName": "Restarts" }] }, - "targets": [ - { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "kube_pod_container_status_last_terminated_reason{reason=\"OOMKilled\", cluster=~\"$cluster\", namespace=~\"$namespace\"} == 1", "format": "table", "instant": true, "refId": "oom" }, - { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "kube_pod_container_status_restarts_total{cluster=~\"$cluster\", namespace=~\"$namespace\"} * on(namespace, pod, container, cluster) kube_pod_container_status_last_terminated_reason{reason=\"OOMKilled\", cluster=~\"$cluster\", namespace=~\"$namespace\"}", "format": "table", "instant": true, "refId": "restarts" }, - { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "kube_pod_container_resource_limits{resource=\"memory\", cluster=~\"$cluster\", namespace=~\"$namespace\"} * on(namespace, pod, container, cluster) kube_pod_container_status_last_terminated_reason{reason=\"OOMKilled\", cluster=~\"$cluster\", namespace=~\"$namespace\"}", "format": "table", "instant": true, "refId": "memlim" } - ], - "title": "OOMKilled Containers", - "transformations": [ - { "id": "seriesToColumns", "options": { "byField": "pod" } }, - { "id": "organize", "options": { "excludeByName": { "Time": true, "Time 1": true, "Time 2": true, "Time 3": true, "Value #oom": true, "__name__": true, "__name__ 1": true, "__name__ 2": true, "cluster": true, "cluster 1": true, "cluster 2": true, "container 1": true, "container 2": true, "instance": true, "instance 1": true, "instance 2": true, "job": true, "job 1": true, "job 2": true, "namespace 1": true, "namespace 2": true, "reason": true, "resource": true, "uid": true, "uid 1": true, "uid 2": true, "unit": true }, "renameByName": { "namespace": "Namespace", "pod": "Pod", "container": "Container", "Value #restarts": "Restarts", "Value #memlim": "Memory Limit" } } } - ], - "type": "table" - }, { "datasource": { "type": "prometheus", "uid": "prometheus" }, "fieldConfig": { @@ -131,28 +118,10 @@ data: "unit": "short" } }, - "gridPos": { "h": 4, "w": 8, "x": 16, "y": 3 }, - "id": 8, - "options": { "legend": { "calcs": ["lastNotNull"], "displayMode": "table", "placement": "right", "showLegend": true, "sortBy": "Last *", "sortDesc": true }, "tooltip": { "mode": "multi", "sort": "desc" } }, - "targets": [{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "sum by (reason) (kube_pod_container_status_waiting_reason{cluster=~\"$cluster\", namespace=~\"$namespace\"})", "legendFormat": "{{reason}}", "refId": "A" }], - "title": "Pods by Waiting Reason", - "type": "timeseries" - }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "palette-classic" }, - "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": "normal" }, "thresholdsStyle": { "mode": "off" } }, - "mappings": [], - "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }, - "unit": "short" - } - }, - "gridPos": { "h": 8, "w": 12, "x": 0, "y": 7 }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 3 }, "id": 9, "options": { "legend": { "calcs": ["lastNotNull"], "displayMode": "table", "placement": "right", "showLegend": true, "sortBy": "Last *", "sortDesc": true }, "tooltip": { "mode": "multi", "sort": "desc" } }, - "targets": [{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "count by (namespace) (kube_pod_info{cluster=~\"$cluster\", namespace=~\"$namespace\"})", "legendFormat": "{{namespace}}", "refId": "A" }], + "targets": [{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "count by (namespace) (kube_pod_info{namespace=~\"$namespace\"})", "legendFormat": "{{namespace}}", "refId": "A" }], "title": "Pods by Namespace", "type": "timeseries" }, @@ -167,33 +136,13 @@ data: "unit": "bytes" } }, - "gridPos": { "h": 8, "w": 12, "x": 12, "y": 7 }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 3 }, "id": 10, "options": { "legend": { "calcs": ["lastNotNull"], "displayMode": "table", "placement": "right", "showLegend": true, "sortBy": "Last *", "sortDesc": true }, "tooltip": { "mode": "multi", "sort": "desc" } }, - "targets": [{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "sum by (namespace) (kube_pod_container_resource_requests{resource=\"memory\", cluster=~\"$cluster\", namespace=~\"$namespace\"})", "legendFormat": "{{namespace}}", "refId": "A" }], + "targets": [{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "sum by (namespace) (kube_pod_container_resource_requests{resource=\"memory\",namespace=~\"$namespace\"})", "legendFormat": "{{namespace}}", "refId": "A" }], "title": "Memory Requests by Namespace", "type": "timeseries" }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "palette-classic" }, - "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": "normal" }, "thresholdsStyle": { "mode": "off" } }, - "mappings": [], - "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }, - "unit": "short" - } - }, - "gridPos": { "h": 8, "w": 24, "x": 0, "y": 15 }, - "id": 14, - "options": { "legend": { "calcs": ["lastNotNull"], "displayMode": "table", "placement": "right", "showLegend": true, "sortBy": "Last *", "sortDesc": true }, "tooltip": { "mode": "multi", "sort": "desc" } }, - "targets": [ - { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "round(increase(kube_pod_container_status_restarts_total{cluster=~\"$cluster\", namespace=~\"$namespace\"}[$__rate_interval])) > 0", "legendFormat": "{{namespace}}/{{pod}}", "refId": "A" } - ], - "title": "Container Restarts", - "type": "timeseries" - }, { "datasource": { "type": "prometheus", "uid": "prometheus" }, "fieldConfig": { @@ -210,15 +159,15 @@ data: { "matcher": { "id": "byName", "options": "CPU Limits" }, "properties": [{ "id": "unit", "value": "short" }] } ] }, - "gridPos": { "h": 8, "w": 24, "x": 0, "y": 23 }, + "gridPos": { "h": 8, "w": 24, "x": 0, "y": 11 }, "id": 11, "options": { "cellHeight": "sm", "footer": { "countRows": false, "fields": "", "reducer": ["sum"], "show": false }, "showHeader": true, "sortBy": [{ "desc": true, "displayName": "Pods" }] }, "targets": [ - { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "count by (namespace) (kube_pod_info{cluster=~\"$cluster\", namespace=~\"$namespace\"})", "format": "table", "instant": true, "refId": "pods" }, - { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "sum by (namespace) (kube_pod_container_resource_requests{resource=\"memory\", cluster=~\"$cluster\", namespace=~\"$namespace\"})", "format": "table", "instant": true, "refId": "mem_req" }, - { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "sum by (namespace) (kube_pod_container_resource_limits{resource=\"memory\", cluster=~\"$cluster\", namespace=~\"$namespace\"})", "format": "table", "instant": true, "refId": "mem_lim" }, - { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "sum by (namespace) (kube_pod_container_resource_requests{resource=\"cpu\", cluster=~\"$cluster\", namespace=~\"$namespace\"})", "format": "table", "instant": true, "refId": "cpu_req" }, - { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "sum by (namespace) (kube_pod_container_resource_limits{resource=\"cpu\", cluster=~\"$cluster\", namespace=~\"$namespace\"})", "format": "table", "instant": true, "refId": "cpu_lim" } + { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "count by (namespace) (kube_pod_info{namespace=~\"$namespace\"})", "format": "table", "instant": true, "refId": "pods" }, + { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "sum by (namespace) (kube_pod_container_resource_requests{resource=\"memory\",namespace=~\"$namespace\"})", "format": "table", "instant": true, "refId": "mem_req" }, + { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "sum by (namespace) (kube_pod_container_resource_limits{resource=\"memory\",namespace=~\"$namespace\"})", "format": "table", "instant": true, "refId": "mem_lim" }, + { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "sum by (namespace) (kube_pod_container_resource_requests{resource=\"cpu\",namespace=~\"$namespace\"})", "format": "table", "instant": true, "refId": "cpu_req" }, + { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "sum by (namespace) (kube_pod_container_resource_limits{resource=\"cpu\",namespace=~\"$namespace\"})", "format": "table", "instant": true, "refId": "cpu_lim" } ], "title": "Namespace Resource Summary", "transformations": [ @@ -229,47 +178,30 @@ data: }, { "datasource": { "type": "loki", "uid": "loki" }, - "gridPos": { "h": 8, "w": 24, "x": 0, "y": 31 }, + "gridPos": { "h": 8, "w": 24, "x": 0, "y": 19 }, "id": 12, "options": { "dedupStrategy": "none", "enableLogDetails": true, "prettifyLogMessage": false, "showCommonLabels": false, "showLabels": true, "showTime": true, "sortOrder": "Descending", "wrapLogMessage": false }, - "targets": [{ "datasource": { "type": "loki", "uid": "loki" }, "expr": "{cluster=~\"$cluster\", namespace=~\"$namespace\"}", "refId": "A" }], + "targets": [{ "datasource": { "type": "loki", "uid": "loki" }, "expr": "{namespace=~\"$namespace\"}", "refId": "A" }], "title": "Pod Logs", "type": "logs" } ], "refresh": "30s", "schemaVersion": 38, - "tags": ["kubernetes", "k8s", "multi-cluster"], + "tags": ["minikube", "kubernetes", "k8s"], "templating": { "list": [ { "current": { "selected": true, "text": "All", "value": "$__all" }, "datasource": { "type": "prometheus", "uid": "prometheus" }, - "definition": "label_values(kube_namespace_created, cluster)", - "hide": 0, - "includeAll": true, - "label": "Cluster", - "multi": true, - "name": "cluster", - "options": [], - "query": { "query": "label_values(kube_namespace_created, cluster)", "refId": "StandardVariableQuery" }, - "refresh": 2, - "regex": "", - "skipUrlSync": false, - "sort": 1, - "type": "query" - }, - { - "current": { "selected": true, "text": "All", "value": "$__all" }, - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "definition": "label_values(kube_namespace_created{cluster=~\"$cluster\"}, namespace)", + "definition": "label_values(kube_namespace_created, namespace)", "hide": 0, "includeAll": true, "label": "Namespace", "multi": true, "name": "namespace", "options": [], - "query": { "query": "label_values(kube_namespace_created{cluster=~\"$cluster\"}, namespace)", "refId": "StandardVariableQuery" }, + "query": { "query": "label_values(kube_namespace_created, namespace)", "refId": "StandardVariableQuery" }, "refresh": 2, "regex": "", "skipUrlSync": false, @@ -281,8 +213,8 @@ data: "time": { "from": "now-6h", "to": "now" }, "timepicker": {}, "timezone": "browser", - "title": "Kubernetes Clusters", - "uid": "kubernetes", - "version": 1, + "title": "Minikube Kubernetes", + "uid": "minikube", + "version": 2, "weekStart": "" } diff --git a/argocd/manifests/grafana-config/dashboards/configmap-jellyfin.yaml b/argocd/manifests/grafana-config/dashboards/configmap-plex.yaml similarity index 85% rename from argocd/manifests/grafana-config/dashboards/configmap-jellyfin.yaml rename to argocd/manifests/grafana-config/dashboards/configmap-plex.yaml index 4208c8c..4fe6e96 100644 --- a/argocd/manifests/grafana-config/dashboards/configmap-jellyfin.yaml +++ b/argocd/manifests/grafana-config/dashboards/configmap-plex.yaml @@ -1,12 +1,12 @@ apiVersion: v1 kind: ConfigMap metadata: - name: grafana-dashboard-jellyfin + name: grafana-dashboard-plex namespace: monitoring labels: grafana_dashboard: "1" data: - jellyfin.json: | + plex.json: | { "annotations": { "list": [] @@ -70,11 +70,11 @@ data: "targets": [ { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "jellyfin_up", + "expr": "plex_up", "refId": "A" } ], - "title": "Jellyfin Status", + "title": "Plex Status", "type": "stat" }, { @@ -114,7 +114,7 @@ data: "targets": [ { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "jellyfin_version_info", + "expr": "plex_version_info", "format": "table", "instant": true, "refId": "A" @@ -164,7 +164,7 @@ data: "targets": [ { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "jellyfin_sessions_total", + "expr": "plex_sessions_total", "refId": "A" } ], @@ -212,7 +212,7 @@ data: "targets": [ { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "jellyfin_transcode_sessions_total", + "expr": "plex_transcode_sessions_total", "refId": "A" } ], @@ -256,7 +256,7 @@ data: "targets": [ { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "count(jellyfin_library_items)", + "expr": "count(plex_library_items)", "refId": "A" } ], @@ -283,7 +283,7 @@ data: }, "overrides": [] }, - "gridPos": { "h": 4, "w": 8, "x": 0, "y": 4 }, + "gridPos": { "h": 4, "w": 6, "x": 0, "y": 4 }, "id": 6, "options": { "colorMode": "value", @@ -301,7 +301,7 @@ data: "targets": [ { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "sum(jellyfin_library_items{type=\"movies\"})", + "expr": "sum(plex_library_items{type=\"movie\"})", "refId": "A" } ], @@ -328,7 +328,7 @@ data: }, "overrides": [] }, - "gridPos": { "h": 4, "w": 8, "x": 8, "y": 4 }, + "gridPos": { "h": 4, "w": 6, "x": 6, "y": 4 }, "id": 7, "options": { "colorMode": "value", @@ -346,7 +346,7 @@ data: "targets": [ { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "sum(jellyfin_library_items{type=\"tvshows\"})", + "expr": "sum(plex_library_items{type=\"show\"})", "refId": "A" } ], @@ -373,7 +373,7 @@ data: }, "overrides": [] }, - "gridPos": { "h": 4, "w": 8, "x": 16, "y": 4 }, + "gridPos": { "h": 4, "w": 6, "x": 12, "y": 4 }, "id": 8, "options": { "colorMode": "value", @@ -391,11 +391,56 @@ data: "targets": [ { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "sum(jellyfin_library_items{type=\"music\"})", + "expr": "sum(plex_library_items{type=\"artist\"})", "refId": "A" } ], - "title": "Music Albums", + "title": "Music Artists", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "purple", + "mode": "fixed" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [{ "color": "purple", "value": null }] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 6, "x": 18, "y": 4 }, + "id": 9, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "sum(plex_library_items{type=\"photo\"})", + "refId": "A" + } + ], + "title": "Photos", "type": "stat" }, { @@ -469,7 +514,7 @@ data: ] }, "gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 }, - "id": 9, + "id": 10, "options": { "legend": { "calcs": ["mean", "max"], @@ -486,13 +531,13 @@ data: "targets": [ { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "jellyfin_sessions_playing", + "expr": "plex_sessions_playing", "legendFormat": "Playing", "refId": "A" }, { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "jellyfin_sessions_paused", + "expr": "plex_sessions_paused", "legendFormat": "Paused", "refId": "B" } @@ -551,18 +596,27 @@ data: }, "overrides": [ { - "matcher": { "id": "byName", "options": "Transcode Sessions" }, + "matcher": { "id": "byName", "options": "Video Transcode" }, "properties": [ { "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } } ] + }, + { + "matcher": { "id": "byName", "options": "Audio Transcode" }, + "properties": [ + { + "id": "color", + "value": { "fixedColor": "orange", "mode": "fixed" } + } + ] } ] }, "gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 }, - "id": 10, + "id": 11, "options": { "legend": { "calcs": ["mean", "max"], @@ -579,9 +633,15 @@ data: "targets": [ { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "jellyfin_transcode_sessions_total", - "legendFormat": "Transcode Sessions", + "expr": "plex_transcode_video_sessions", + "legendFormat": "Video Transcode", "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "plex_transcode_audio_sessions", + "legendFormat": "Audio Transcode", + "refId": "B" } ], "title": "Transcode Sessions", @@ -593,7 +653,7 @@ data: "uid": "loki" }, "gridPos": { "h": 8, "w": 24, "x": 0, "y": 16 }, - "id": 11, + "id": 12, "options": { "dedupStrategy": "none", "enableLogDetails": true, @@ -607,17 +667,17 @@ data: "targets": [ { "datasource": { "type": "loki", "uid": "loki" }, - "expr": "{service=\"jellyfin\"}", + "expr": "{service=\"plex\"}", "refId": "A" } ], - "title": "Jellyfin Logs", + "title": "Plex Logs", "type": "logs" } ], "refresh": "30s", "schemaVersion": 38, - "tags": ["jellyfin", "media"], + "tags": ["plex", "media"], "templating": { "list": [] }, @@ -627,8 +687,8 @@ data: }, "timepicker": {}, "timezone": "browser", - "title": "Jellyfin Media Server", - "uid": "jellyfin", + "title": "Plex Media Server", + "uid": "plex", "version": 1, "weekStart": "" } diff --git a/argocd/manifests/grafana-config/dashboards/configmap-postgresql.yaml b/argocd/manifests/grafana-config/dashboards/configmap-postgresql.yaml index 5c66828..39d05f2 100644 --- a/argocd/manifests/grafana-config/dashboards/configmap-postgresql.yaml +++ b/argocd/manifests/grafana-config/dashboards/configmap-postgresql.yaml @@ -534,19 +534,13 @@ data: "mode": "none" }, "thresholdsStyle": { - "mode": "dashed" + "mode": "off" } }, "mappings": [], - "max": 220000000, - "min": 0, "thresholds": { "mode": "absolute", - "steps": [ - { "color": "green", "value": null }, - { "color": "yellow", "value": 150000000 }, - { "color": "red", "value": 200000000 } - ] + "steps": [{ "color": "green", "value": null }] }, "unit": "short" } @@ -572,7 +566,7 @@ data: "refId": "A" } ], - "title": "Tesla XID Age (autovacuum threshold: 200M)", + "title": "XID Age Over Time", "type": "timeseries" } ], diff --git a/argocd/manifests/grafana-config/dashboards/configmap-ringtail.yaml b/argocd/manifests/grafana-config/dashboards/configmap-ringtail.yaml deleted file mode 100644 index 63cd2aa..0000000 --- a/argocd/manifests/grafana-config/dashboards/configmap-ringtail.yaml +++ /dev/null @@ -1,314 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: grafana-dashboard-ringtail - namespace: monitoring - labels: - grafana_dashboard: "1" -data: - ringtail.json: | - { - "annotations": { "list": [] }, - "editable": true, - "fiscalYearStartMonth": 0, - "graphTooltip": 1, - "id": null, - "links": [], - "panels": [ - { - "collapsed": false, - "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, - "id": 100, - "panels": [], - "title": "System Overview", - "type": "row" - }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "mappings": [], "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }, "unit": "dtdurations" } }, - "gridPos": { "h": 4, "w": 4, "x": 0, "y": 1 }, - "id": 1, - "options": { "colorMode": "value", "graphMode": "none", "justifyMode": "auto", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" }, - "targets": [{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "time() - node_boot_time_seconds{instance=\"ringtail\"}", "refId": "A" }], - "title": "Uptime", - "type": "stat" - }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "mappings": [], "thresholds": { "mode": "absolute", "steps": [{ "color": "blue", "value": null }] }, "unit": "decbytes" } }, - "gridPos": { "h": 4, "w": 4, "x": 4, "y": 1 }, - "id": 2, - "options": { "colorMode": "value", "graphMode": "none", "justifyMode": "auto", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" }, - "targets": [{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "node_memory_MemTotal_bytes{instance=\"ringtail\"}", "refId": "A" }], - "title": "Total Memory", - "type": "stat" - }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "mappings": [], "thresholds": { "mode": "absolute", "steps": [{ "color": "purple", "value": null }] }, "unit": "short" } }, - "gridPos": { "h": 4, "w": 4, "x": 8, "y": 1 }, - "id": 3, - "options": { "colorMode": "value", "graphMode": "none", "justifyMode": "auto", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" }, - "targets": [{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "count(node_cpu_seconds_total{instance=\"ringtail\", mode=\"idle\"})", "refId": "A" }], - "title": "CPU Cores", - "type": "stat" - }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "mappings": [], "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 4 }, { "color": "red", "value": 8 }] }, "unit": "short", "decimals": 2 } }, - "gridPos": { "h": 4, "w": 4, "x": 12, "y": 1 }, - "id": 4, - "options": { "colorMode": "value", "graphMode": "area", "justifyMode": "auto", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" }, - "targets": [{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "node_load1{instance=\"ringtail\"}", "refId": "A" }], - "title": "Load (1m)", - "type": "stat" - }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] } } }, - "gridPos": { "h": 4, "w": 4, "x": 16, "y": 1 }, - "id": 5, - "options": { "colorMode": "value", "graphMode": "none", "justifyMode": "center", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "value" }, - "targets": [{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "count(kube_pod_info{cluster=\"ringtail\"})", "refId": "A" }], - "title": "K8s Pods", - "type": "stat" - }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "unit": "percent", "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 80 }, { "color": "red", "value": 95 }] } } }, - "gridPos": { "h": 4, "w": 4, "x": 20, "y": 1 }, - "id": 6, - "options": { "colorMode": "value", "graphMode": "area", "justifyMode": "center", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "value" }, - "targets": [{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "frigate_gpu_usage_percent", "refId": "A" }], - "title": "GPU Usage %", - "type": "stat" - }, - { - "collapsed": false, - "gridPos": { "h": 1, "w": 24, "x": 0, "y": 5 }, - "id": 101, - "panels": [], - "title": "CPU & Memory", - "type": "row" - }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "palette-classic" }, - "custom": { "axisBorderShow": false, "axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "drawStyle": "line", "fillOpacity": 30, "gradientMode": "none", "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "insertNulls": false, "lineInterpolation": "smooth", "lineWidth": 2, "pointSize": 5, "scaleDistribution": { "type": "linear" }, "showPoints": "never", "spanNulls": false, "stacking": { "group": "A", "mode": "normal" }, "thresholdsStyle": { "mode": "off" } }, - "mappings": [], - "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }, - "unit": "percentunit", - "max": 1 - } - }, - "gridPos": { "h": 8, "w": 12, "x": 0, "y": 6 }, - "id": 7, - "options": { "legend": { "calcs": ["mean", "lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true }, "tooltip": { "mode": "multi", "sort": "desc" } }, - "targets": [ - { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "sum by (mode) (rate(node_cpu_seconds_total{instance=\"ringtail\", mode!=\"idle\"}[5m])) / on() group_left count(node_cpu_seconds_total{instance=\"ringtail\", mode=\"idle\"})", "legendFormat": "{{mode}}", "refId": "A" } - ], - "title": "CPU Usage by Mode", - "type": "timeseries" - }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "palette-classic" }, - "custom": { "axisBorderShow": false, "axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "drawStyle": "line", "fillOpacity": 30, "gradientMode": "none", "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "insertNulls": false, "lineInterpolation": "smooth", "lineWidth": 2, "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": "bytes" - } - }, - "gridPos": { "h": 8, "w": 12, "x": 12, "y": 6 }, - "id": 8, - "options": { "legend": { "calcs": ["mean", "lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true }, "tooltip": { "mode": "multi", "sort": "desc" } }, - "targets": [ - { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "node_memory_MemTotal_bytes{instance=\"ringtail\"} - node_memory_MemAvailable_bytes{instance=\"ringtail\"}", "legendFormat": "Used", "refId": "A" }, - { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "node_memory_MemAvailable_bytes{instance=\"ringtail\"}", "legendFormat": "Available", "refId": "B" }, - { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "node_memory_Cached_bytes{instance=\"ringtail\"}", "legendFormat": "Cached", "refId": "C" } - ], - "title": "Memory Usage", - "type": "timeseries" - }, - { - "collapsed": false, - "gridPos": { "h": 1, "w": 24, "x": 0, "y": 14 }, - "id": 102, - "panels": [], - "title": "Storage", - "type": "row" - }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "thresholds" }, - "custom": { "align": "auto", "cellOptions": { "type": "auto" }, "inspect": false }, - "mappings": [], - "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 0.8 }, { "color": "red", "value": 0.95 }] } - }, - "overrides": [ - { "matcher": { "id": "byName", "options": "Size" }, "properties": [{ "id": "unit", "value": "bytes" }] }, - { "matcher": { "id": "byName", "options": "Available" }, "properties": [{ "id": "unit", "value": "bytes" }] }, - { "matcher": { "id": "byName", "options": "Used %" }, "properties": [{ "id": "unit", "value": "percentunit" }, { "id": "thresholds", "value": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 0.8 }, { "color": "red", "value": 0.95 }] } }, { "id": "custom.cellOptions", "value": { "mode": "gradient", "type": "gauge" } }] } - ] - }, - "gridPos": { "h": 6, "w": 24, "x": 0, "y": 15 }, - "id": 9, - "options": { "cellHeight": "sm", "footer": { "countRows": false, "fields": "", "reducer": ["sum"], "show": false }, "showHeader": true, "sortBy": [{ "desc": true, "displayName": "Size" }] }, - "targets": [ - { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "node_filesystem_size_bytes{instance=\"ringtail\", fstype!~\"tmpfs|overlay|squashfs\"}", "format": "table", "instant": true, "refId": "size" }, - { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "node_filesystem_avail_bytes{instance=\"ringtail\", fstype!~\"tmpfs|overlay|squashfs\"}", "format": "table", "instant": true, "refId": "avail" }, - { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "1 - (node_filesystem_avail_bytes{instance=\"ringtail\", fstype!~\"tmpfs|overlay|squashfs\"} / node_filesystem_size_bytes{instance=\"ringtail\", fstype!~\"tmpfs|overlay|squashfs\"})", "format": "table", "instant": true, "refId": "pct" } - ], - "title": "Filesystem Usage", - "transformations": [ - { "id": "seriesToColumns", "options": { "byField": "mountpoint" } }, - { "id": "organize", "options": { "excludeByName": { "Time": true, "Time 1": true, "Time 2": true, "Time 3": true, "__name__": true, "__name__ 1": true, "__name__ 2": true, "device": true, "device 1": true, "device 2": true, "fstype": true, "fstype 1": true, "fstype 2": true, "instance": true, "instance 1": true, "instance 2": true, "job": true, "job 1": true, "job 2": true, "cluster": true, "cluster 1": true, "cluster 2": true }, "renameByName": { "mountpoint": "Mount", "Value #size": "Size", "Value #avail": "Available", "Value #pct": "Used %" } } } - ], - "type": "table" - }, - { - "collapsed": false, - "gridPos": { "h": 1, "w": 24, "x": 0, "y": 21 }, - "id": 103, - "panels": [], - "title": "Network", - "type": "row" - }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "palette-classic" }, - "custom": { "axisBorderShow": false, "axisCenteredZero": true, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "drawStyle": "line", "fillOpacity": 20, "gradientMode": "none", "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "insertNulls": false, "lineInterpolation": "smooth", "lineWidth": 2, "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": "Bps" - } - }, - "gridPos": { "h": 8, "w": 24, "x": 0, "y": 22 }, - "id": 10, - "options": { "legend": { "calcs": ["mean", "lastNotNull"], "displayMode": "table", "placement": "right", "showLegend": true }, "tooltip": { "mode": "multi", "sort": "desc" } }, - "targets": [ - { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "rate(node_network_receive_bytes_total{instance=\"ringtail\", device!~\"lo|veth.*|cali.*|flannel.*|cni.*\"}[5m])", "legendFormat": "{{device}} rx", "refId": "A" }, - { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "-rate(node_network_transmit_bytes_total{instance=\"ringtail\", device!~\"lo|veth.*|cali.*|flannel.*|cni.*\"}[5m])", "legendFormat": "{{device}} tx", "refId": "B" } - ], - "title": "Network Traffic", - "type": "timeseries" - }, - { - "collapsed": false, - "gridPos": { "h": 1, "w": 24, "x": 0, "y": 30 }, - "id": 104, - "panels": [], - "title": "GPU", - "type": "row" - }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "palette-classic" }, - "custom": { "axisBorderShow": false, "axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "drawStyle": "line", "fillOpacity": 10, "gradientMode": "none", "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "insertNulls": false, "lineInterpolation": "linear", "lineWidth": 2, "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": "percent" - } - }, - "gridPos": { "h": 8, "w": 24, "x": 0, "y": 31 }, - "id": 11, - "options": { "legend": { "calcs": ["mean", "lastNotNull", "max"], "displayMode": "table", "placement": "right", "showLegend": true }, "tooltip": { "mode": "multi", "sort": "desc" } }, - "targets": [ - { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "frigate_gpu_usage_percent", "legendFormat": "GPU Usage", "refId": "A" }, - { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "frigate_gpu_mem_usage_percent", "legendFormat": "GPU Memory", "refId": "B" } - ], - "title": "GPU Overview", - "type": "timeseries" - }, - { - "collapsed": false, - "gridPos": { "h": 1, "w": 24, "x": 0, "y": 39 }, - "id": 105, - "panels": [], - "title": "Kubernetes", - "type": "row" - }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "palette-classic" }, - "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": "normal" }, "thresholdsStyle": { "mode": "off" } }, - "mappings": [], - "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }, - "unit": "short" - } - }, - "gridPos": { "h": 8, "w": 12, "x": 0, "y": 40 }, - "id": 12, - "options": { "legend": { "calcs": ["lastNotNull"], "displayMode": "table", "placement": "right", "showLegend": true, "sortBy": "Last *", "sortDesc": true }, "tooltip": { "mode": "multi", "sort": "desc" } }, - "targets": [{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "count by (namespace) (kube_pod_info{cluster=\"ringtail\"})", "legendFormat": "{{namespace}}", "refId": "A" }], - "title": "Pods by Namespace", - "type": "timeseries" - }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "thresholds" }, - "mappings": [], - "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 1 }] } - } - }, - "gridPos": { "h": 4, "w": 6, "x": 12, "y": 40 }, - "id": 13, - "options": { "colorMode": "background", "graphMode": "none", "justifyMode": "center", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "value" }, - "targets": [{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "count(kube_pod_container_status_waiting_reason{cluster=\"ringtail\", reason=~\"ImagePullBackOff|ErrImagePull|CrashLoopBackOff|CreateContainerError|RunContainerError\"}) or vector(0)", "refId": "A" }], - "title": "Unhealthy Pods", - "type": "stat" - }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] } } }, - "gridPos": { "h": 4, "w": 6, "x": 18, "y": 40 }, - "id": 14, - "options": { "colorMode": "value", "graphMode": "none", "justifyMode": "center", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "value" }, - "targets": [{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "count(kube_deployment_created{cluster=\"ringtail\"})", "refId": "A" }], - "title": "Deployments", - "type": "stat" - }, - { - "collapsed": false, - "gridPos": { "h": 1, "w": 24, "x": 0, "y": 48 }, - "id": 106, - "panels": [], - "title": "Logs", - "type": "row" - }, - { - "datasource": { "type": "loki", "uid": "loki" }, - "gridPos": { "h": 8, "w": 24, "x": 0, "y": 49 }, - "id": 15, - "options": { "dedupStrategy": "none", "enableLogDetails": true, "prettifyLogMessage": false, "showCommonLabels": false, "showLabels": true, "showTime": true, "sortOrder": "Descending", "wrapLogMessage": false }, - "targets": [{ "datasource": { "type": "loki", "uid": "loki" }, "expr": "{cluster=\"ringtail\"}", "refId": "A" }], - "title": "Pod Logs", - "type": "logs" - } - ], - "refresh": "30s", - "schemaVersion": 38, - "tags": ["ringtail", "k3s", "gpu", "system"], - "templating": { "list": [] }, - "time": { "from": "now-6h", "to": "now" }, - "timepicker": {}, - "timezone": "browser", - "title": "Ringtail", - "uid": "ringtail", - "version": 1, - "weekStart": "" - } diff --git a/argocd/manifests/grafana-config/dashboards/configmap-services.yaml b/argocd/manifests/grafana-config/dashboards/configmap-services.yaml new file mode 100644 index 0000000..241212a --- /dev/null +++ b/argocd/manifests/grafana-config/dashboards/configmap-services.yaml @@ -0,0 +1,145 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: grafana-dashboard-services + namespace: monitoring + labels: + grafana_dashboard: "1" +data: + services.json: | + { + "annotations": { "list": [] }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [ + { "options": { "0": { "color": "red", "text": "DOWN" }, "1": { "color": "green", "text": "UP" } }, "type": "value" } + ], + "thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "green", "value": 1 }] }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { "h": 6, "w": 24, "x": 0, "y": 0 }, + "id": 1, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "center", + "orientation": "horizontal", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "value_and_name" + }, + "pluginVersion": "11.0.0", + "targets": [ + { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "probe_success{job=\"integrations/blackbox/miniflux\"}", "legendFormat": "Miniflux", "refId": "A" }, + { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "probe_success{job=\"integrations/blackbox/kiwix\"}", "legendFormat": "Kiwix", "refId": "B" }, + { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "probe_success{job=\"integrations/blackbox/transmission\"}", "legendFormat": "Transmission", "refId": "C" }, + { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "probe_success{job=\"integrations/blackbox/devpi\"}", "legendFormat": "Devpi", "refId": "D" }, + { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "probe_success{job=\"integrations/blackbox/argocd\"}", "legendFormat": "ArgoCD", "refId": "E" } + ], + "title": "Service Status", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "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": 6 }, + "id": 2, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "right", "showLegend": true }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "pluginVersion": "11.0.0", + "targets": [ + { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "probe_duration_seconds{job=\"integrations/blackbox/miniflux\"}", "legendFormat": "Miniflux", "refId": "A" }, + { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "probe_duration_seconds{job=\"integrations/blackbox/kiwix\"}", "legendFormat": "Kiwix", "refId": "B" }, + { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "probe_duration_seconds{job=\"integrations/blackbox/transmission\"}", "legendFormat": "Transmission", "refId": "C" }, + { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "probe_duration_seconds{job=\"integrations/blackbox/devpi\"}", "legendFormat": "Devpi", "refId": "D" }, + { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "probe_duration_seconds{job=\"integrations/blackbox/argocd\"}", "legendFormat": "ArgoCD", "refId": "E" } + ], + "title": "Response Time", + "type": "timeseries" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "yellow", "value": 0.95 }, { "color": "green", "value": 0.99 }] }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { "h": 6, "w": 24, "x": 0, "y": 14 }, + "id": 3, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "center", + "orientation": "horizontal", + "reduceOptions": { "calcs": ["mean"], "fields": "", "values": false }, + "textMode": "value_and_name" + }, + "pluginVersion": "11.0.0", + "targets": [ + { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "avg_over_time(probe_success{job=\"integrations/blackbox/miniflux\"}[$__range])", "legendFormat": "Miniflux", "refId": "A" }, + { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "avg_over_time(probe_success{job=\"integrations/blackbox/kiwix\"}[$__range])", "legendFormat": "Kiwix", "refId": "B" }, + { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "avg_over_time(probe_success{job=\"integrations/blackbox/transmission\"}[$__range])", "legendFormat": "Transmission", "refId": "C" }, + { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "avg_over_time(probe_success{job=\"integrations/blackbox/devpi\"}[$__range])", "legendFormat": "Devpi", "refId": "D" }, + { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "avg_over_time(probe_success{job=\"integrations/blackbox/argocd\"}[$__range])", "legendFormat": "ArgoCD", "refId": "E" } + ], + "title": "Uptime (selected period)", + "type": "stat" + } + ], + "refresh": "30s", + "schemaVersion": 38, + "tags": ["services", "health"], + "templating": { "list": [] }, + "time": { "from": "now-24h", "to": "now" }, + "timepicker": {}, + "timezone": "browser", + "title": "K8s Services Health", + "uid": "k8s-services", + "version": 1, + "weekStart": "" + } diff --git a/argocd/manifests/grafana-config/dashboards/configmap-shower-apm.yaml b/argocd/manifests/grafana-config/dashboards/configmap-shower-apm.yaml deleted file mode 100644 index 96348e8..0000000 --- a/argocd/manifests/grafana-config/dashboards/configmap-shower-apm.yaml +++ /dev/null @@ -1,229 +0,0 @@ -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/dashboards/configmap-sifaka-disks.yaml b/argocd/manifests/grafana-config/dashboards/configmap-sifaka-disks.yaml deleted file mode 100644 index a92ec23..0000000 --- a/argocd/manifests/grafana-config/dashboards/configmap-sifaka-disks.yaml +++ /dev/null @@ -1,314 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: grafana-dashboard-sifaka-disks - namespace: monitoring - labels: - grafana_dashboard: "1" -data: - sifaka-disks.json: | - { - "annotations": { "list": [] }, - "editable": true, - "fiscalYearStartMonth": 0, - "graphTooltip": 1, - "id": null, - "links": [], - "panels": [ - { - "collapsed": false, - "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, - "id": 100, - "panels": [], - "title": "Health Overview", - "type": "row" - }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "thresholds" }, - "mappings": [ - { "options": { "0": { "color": "red", "text": "FAILING" }, "1": { "color": "green", "text": "HEALTHY" } }, "type": "value" } - ], - "thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "green", "value": 1 }] } - }, - "overrides": [] - }, - "gridPos": { "h": 6, "w": 24, "x": 0, "y": 1 }, - "id": 1, - "options": { - "colorMode": "background", - "graphMode": "none", - "justifyMode": "center", - "orientation": "auto", - "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, - "textMode": "value_and_name" - }, - "targets": [ - { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "smartctl_device_smart_status{job=\"smartctl-sifaka\"}", "legendFormat": "{{device}} ({{model_name}})", "refId": "A" } - ], - "title": "SMART Health Status", - "type": "stat" - }, - { - "collapsed": false, - "gridPos": { "h": 1, "w": 24, "x": 0, "y": 7 }, - "id": 101, - "panels": [], - "title": "Temperature", - "type": "row" - }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "thresholds" }, - "mappings": [], - "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 40 }, { "color": "red", "value": 50 }] }, - "unit": "celsius" - }, - "overrides": [] - }, - "gridPos": { "h": 4, "w": 24, "x": 0, "y": 8 }, - "id": 2, - "options": { - "colorMode": "value", - "graphMode": "none", - "justifyMode": "center", - "orientation": "auto", - "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, - "textMode": "value_and_name" - }, - "targets": [ - { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "smartctl_device_temperature{job=\"smartctl-sifaka\"}", "legendFormat": "{{device}}", "refId": "A" } - ], - "title": "Current Temperature", - "type": "stat" - }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "palette-classic" }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisLabel": "", - "axisPlacement": "auto", - "drawStyle": "line", - "fillOpacity": 10, - "gradientMode": "none", - "hideFrom": { "legend": false, "tooltip": false, "viz": false }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "showPoints": "never", - "spanNulls": false, - "stacking": { "group": "A", "mode": "none" }, - "thresholdsStyle": { "mode": "line+area" } - }, - "mappings": [], - "thresholds": { "mode": "absolute", "steps": [{ "color": "transparent", "value": null }, { "color": "red", "value": 50 }] }, - "unit": "celsius" - }, - "overrides": [] - }, - "gridPos": { "h": 8, "w": 24, "x": 0, "y": 12 }, - "id": 3, - "options": { - "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "right", "showLegend": true }, - "tooltip": { "mode": "multi", "sort": "desc" } - }, - "targets": [ - { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "smartctl_device_temperature{job=\"smartctl-sifaka\"}", "legendFormat": "{{device}}", "refId": "A" } - ], - "title": "Temperature Over Time", - "type": "timeseries" - }, - { - "collapsed": false, - "gridPos": { "h": 1, "w": 24, "x": 0, "y": 20 }, - "id": 102, - "panels": [], - "title": "Wear Indicators", - "type": "row" - }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "thresholds" }, - "mappings": [], - "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 1 }, { "color": "red", "value": 10 }] } - }, - "overrides": [] - }, - "gridPos": { "h": 4, "w": 24, "x": 0, "y": 21 }, - "id": 4, - "options": { - "colorMode": "value", - "graphMode": "none", - "justifyMode": "center", - "orientation": "auto", - "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, - "textMode": "value_and_name" - }, - "targets": [ - { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "smartctl_device_attribute{job=\"smartctl-sifaka\", attribute_name=\"Reallocated_Sector_Ct\", attribute_value_type=\"raw\"}", "legendFormat": "{{device}}", "refId": "A" } - ], - "title": "Reallocated Sectors", - "type": "stat" - }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "thresholds" }, - "mappings": [], - "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 1 }, { "color": "red", "value": 10 }] } - }, - "overrides": [] - }, - "gridPos": { "h": 4, "w": 24, "x": 0, "y": 25 }, - "id": 5, - "options": { - "colorMode": "value", - "graphMode": "none", - "justifyMode": "center", - "orientation": "auto", - "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, - "textMode": "value_and_name" - }, - "targets": [ - { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "smartctl_device_attribute{job=\"smartctl-sifaka\", attribute_name=\"Current_Pending_Sector\", attribute_value_type=\"raw\"}", "legendFormat": "{{device}}", "refId": "A" } - ], - "title": "Pending Sectors", - "type": "stat" - }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "thresholds" }, - "mappings": [], - "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 1 }, { "color": "red", "value": 100 }] } - }, - "overrides": [] - }, - "gridPos": { "h": 4, "w": 24, "x": 0, "y": 29 }, - "id": 6, - "options": { - "colorMode": "value", - "graphMode": "none", - "justifyMode": "center", - "orientation": "auto", - "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, - "textMode": "value_and_name" - }, - "targets": [ - { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "smartctl_device_attribute{job=\"smartctl-sifaka\", attribute_name=\"UDMA_CRC_Error_Count\", attribute_value_type=\"raw\"}", "legendFormat": "{{device}}", "refId": "A" } - ], - "title": "UDMA CRC Errors", - "type": "stat" - }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "thresholds" }, - "mappings": [], - "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 1 }, { "color": "red", "value": 10 }] } - }, - "overrides": [] - }, - "gridPos": { "h": 4, "w": 24, "x": 0, "y": 33 }, - "id": 7, - "options": { - "colorMode": "value", - "graphMode": "none", - "justifyMode": "center", - "orientation": "auto", - "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, - "textMode": "value_and_name" - }, - "targets": [ - { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "smartctl_device_attribute{job=\"smartctl-sifaka\", attribute_name=\"Offline_Uncorrectable\", attribute_value_type=\"raw\"}", "legendFormat": "{{device}}", "refId": "A" } - ], - "title": "Offline Uncorrectable Sectors", - "type": "stat" - }, - { - "collapsed": false, - "gridPos": { "h": 1, "w": 24, "x": 0, "y": 37 }, - "id": 103, - "panels": [], - "title": "Lifetime", - "type": "row" - }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "thresholds" }, - "mappings": [], - "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }, - "unit": "h" - }, - "overrides": [] - }, - "gridPos": { "h": 4, "w": 24, "x": 0, "y": 38 }, - "id": 8, - "options": { - "colorMode": "value", - "graphMode": "none", - "justifyMode": "center", - "orientation": "auto", - "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, - "textMode": "value_and_name" - }, - "targets": [ - { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "smartctl_device_power_on_seconds{job=\"smartctl-sifaka\"} / 3600", "legendFormat": "{{device}}", "refId": "A" } - ], - "title": "Power-On Hours", - "type": "stat" - }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "thresholds" }, - "mappings": [], - "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] } - }, - "overrides": [] - }, - "gridPos": { "h": 4, "w": 24, "x": 0, "y": 42 }, - "id": 9, - "options": { - "colorMode": "value", - "graphMode": "none", - "justifyMode": "center", - "orientation": "auto", - "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, - "textMode": "value_and_name" - }, - "targets": [ - { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "smartctl_device_power_cycle_count{job=\"smartctl-sifaka\"}", "legendFormat": "{{device}}", "refId": "A" } - ], - "title": "Power Cycle Count", - "type": "stat" - } - ], - "refresh": "1m", - "schemaVersion": 38, - "tags": ["sifaka", "storage", "smart"], - "templating": { "list": [] }, - "time": { "from": "now-24h", "to": "now" }, - "timepicker": {}, - "timezone": "browser", - "title": "Sifaka Disk Health", - "uid": "sifaka-disk-health", - "version": 1, - "weekStart": "" - } diff --git a/argocd/manifests/grafana-config/dashboards/configmap-snowflake-proxy.yaml b/argocd/manifests/grafana-config/dashboards/configmap-snowflake-proxy.yaml deleted file mode 100644 index 089cae3..0000000 --- a/argocd/manifests/grafana-config/dashboards/configmap-snowflake-proxy.yaml +++ /dev/null @@ -1,323 +0,0 @@ -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/dashboards/configmap-tempo.yaml b/argocd/manifests/grafana-config/dashboards/configmap-tempo.yaml deleted file mode 100644 index f7d5629..0000000 --- a/argocd/manifests/grafana-config/dashboards/configmap-tempo.yaml +++ /dev/null @@ -1,491 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: grafana-dashboard-tempo - namespace: monitoring - labels: - grafana_dashboard: "1" -data: - tempo.json: | - { - "annotations": { - "list": [] - }, - "editable": true, - "fiscalYearStartMonth": 0, - "graphTooltip": 0, - "id": null, - "links": [], - "panels": [ - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { "color": "green", "value": null }, - { "color": "yellow", "value": 5368709120 }, - { "color": "red", "value": 8589934592 } - ] - }, - "unit": "bytes" - }, - "overrides": [] - }, - "gridPos": { "h": 4, "w": 6, "x": 0, "y": 0 }, - "id": 1, - "options": { - "colorMode": "value", - "graphMode": "none", - "justifyMode": "auto", - "orientation": "auto", - "reduceOptions": { - "calcs": ["lastNotNull"], - "fields": "", - "values": false - }, - "textMode": "auto" - }, - "pluginVersion": "10.0.0", - "targets": [ - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "sum(tempodb_backend_bytes_total{job=\"tempo\"})", - "refId": "A" - } - ], - "title": "Storage Used", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { "color": "green", "value": null }, - { "color": "yellow", "value": 50 }, - { "color": "red", "value": 80 } - ] - }, - "unit": "percent", - "max": 100, - "min": 0 - }, - "overrides": [] - }, - "gridPos": { "h": 4, "w": 6, "x": 6, "y": 0 }, - "id": 2, - "options": { - "colorMode": "value", - "graphMode": "none", - "justifyMode": "auto", - "orientation": "auto", - "reduceOptions": { - "calcs": ["lastNotNull"], - "fields": "", - "values": false - }, - "textMode": "auto" - }, - "pluginVersion": "10.0.0", - "targets": [ - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "sum(tempodb_backend_bytes_total{job=\"tempo\"}) / 10737418240 * 100", - "refId": "A" - } - ], - "title": "PVC Utilization (of 10Gi)", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [{ "color": "green", "value": null }] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { "h": 4, "w": 6, "x": 12, "y": 0 }, - "id": 3, - "options": { - "colorMode": "value", - "graphMode": "none", - "justifyMode": "auto", - "orientation": "auto", - "reduceOptions": { - "calcs": ["lastNotNull"], - "fields": "", - "values": false - }, - "textMode": "auto" - }, - "pluginVersion": "10.0.0", - "targets": [ - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "sum(tempodb_blocklist_length{job=\"tempo\"})", - "refId": "A" - } - ], - "title": "Total Blocks", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { "color": "green", "value": null }, - { "color": "yellow", "value": 0.5 }, - { "color": "red", "value": 0.9 } - ] - }, - "unit": "percentunit" - }, - "overrides": [] - }, - "gridPos": { "h": 4, "w": 6, "x": 18, "y": 0 }, - "id": 4, - "options": { - "colorMode": "value", - "graphMode": "none", - "justifyMode": "auto", - "orientation": "auto", - "reduceOptions": { - "calcs": ["lastNotNull"], - "fields": "", - "values": false - }, - "textMode": "auto" - }, - "pluginVersion": "10.0.0", - "targets": [ - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "1 - (go_memstats_heap_idle_bytes{job=\"tempo\"} / go_memstats_heap_sys_bytes{job=\"tempo\"})", - "refId": "A" - } - ], - "title": "Heap Usage", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "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": "bytes" - }, - "overrides": [] - }, - "gridPos": { "h": 8, "w": 12, "x": 0, "y": 4 }, - "id": 5, - "options": { - "legend": { - "calcs": ["lastNotNull"], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { "mode": "multi", "sort": "desc" } - }, - "pluginVersion": "10.0.0", - "targets": [ - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "sum(tempodb_backend_bytes_total{job=\"tempo\"})", - "legendFormat": "Backend Storage", - "refId": "A" - }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "go_memstats_heap_inuse_bytes{job=\"tempo\"}", - "legendFormat": "Heap In Use", - "refId": "B" - } - ], - "title": "Storage Over Time", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "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": "short" - }, - "overrides": [] - }, - "gridPos": { "h": 8, "w": 12, "x": 12, "y": 4 }, - "id": 6, - "options": { - "legend": { - "calcs": ["mean", "max"], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { "mode": "multi", "sort": "desc" } - }, - "pluginVersion": "10.0.0", - "targets": [ - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "rate(tempo_distributor_spans_received_total{job=\"tempo\"}[5m])", - "legendFormat": "Spans/sec", - "refId": "A" - } - ], - "title": "Span Ingestion Rate", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "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": "Bps" - }, - "overrides": [] - }, - "gridPos": { "h": 8, "w": 12, "x": 0, "y": 12 }, - "id": 7, - "options": { - "legend": { - "calcs": ["mean", "max"], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { "mode": "multi", "sort": "desc" } - }, - "pluginVersion": "10.0.0", - "targets": [ - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "rate(tempo_distributor_bytes_received_total{job=\"tempo\"}[5m])", - "legendFormat": "Bytes Received", - "refId": "A" - } - ], - "title": "Ingestion Throughput", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "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": 12, "x": 12, "y": 12 }, - "id": 8, - "options": { - "legend": { - "calcs": ["mean", "max"], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { "mode": "multi", "sort": "desc" } - }, - "pluginVersion": "10.0.0", - "targets": [ - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "histogram_quantile(0.95, sum(rate(tempo_query_frontend_result_metrics_duration_seconds_bucket{job=\"tempo\"}[5m])) by (le))", - "legendFormat": "p95", - "refId": "A" - }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "histogram_quantile(0.50, sum(rate(tempo_query_frontend_result_metrics_duration_seconds_bucket{job=\"tempo\"}[5m])) by (le))", - "legendFormat": "p50", - "refId": "B" - } - ], - "title": "Query Latency", - "type": "timeseries" - } - ], - "refresh": "1m", - "schemaVersion": 38, - "tags": ["tempo", "tracing"], - "templating": { - "list": [] - }, - "time": { - "from": "now-24h", - "to": "now" - }, - "timepicker": {}, - "timezone": "", - "title": "Tempo", - "uid": "tempo-homelab", - "version": 1, - "weekStart": "" - } diff --git a/argocd/manifests/grafana-config/dashboards/configmap-teslamate-battery-health.yaml b/argocd/manifests/grafana-config/dashboards/configmap-teslamate-battery-health.yaml new file mode 100644 index 0000000..25b3cc6 --- /dev/null +++ b/argocd/manifests/grafana-config/dashboards/configmap-teslamate-battery-health.yaml @@ -0,0 +1,1761 @@ +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": "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 new file mode 100644 index 0000000..b458e61 --- /dev/null +++ b/argocd/manifests/grafana-config/dashboards/configmap-teslamate-charge-level.yaml @@ -0,0 +1,419 @@ +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": "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 new file mode 100644 index 0000000..5b3ad13 --- /dev/null +++ b/argocd/manifests/grafana-config/dashboards/configmap-teslamate-charges.yaml @@ -0,0 +1,1537 @@ +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 <a href='https://docs.teslamate.org/docs/maintenance/manually_fixing_data' target='_blank'>Manually fixing data</a>", + "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": "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 new file mode 100644 index 0000000..005665c --- /dev/null +++ b/argocd/manifests/grafana-config/dashboards/configmap-teslamate-charging-stats.yaml @@ -0,0 +1,2428 @@ +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": "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 new file mode 100644 index 0000000..d2834d9 --- /dev/null +++ b/argocd/manifests/grafana-config/dashboards/configmap-teslamate-drive-stats.yaml @@ -0,0 +1,1603 @@ +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": "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 new file mode 100644 index 0000000..c96ac02 --- /dev/null +++ b/argocd/manifests/grafana-config/dashboards/configmap-teslamate-drives.yaml @@ -0,0 +1,1636 @@ +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 <a href='https://docs.teslamate.org/docs/maintenance/manually_fixing_data' target='_blank'>Manually fixing data</a>", + "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": "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 new file mode 100644 index 0000000..5d57520 --- /dev/null +++ b/argocd/manifests/grafana-config/dashboards/configmap-teslamate-efficiency.yaml @@ -0,0 +1,1187 @@ +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": "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 new file mode 100644 index 0000000..124a084 --- /dev/null +++ b/argocd/manifests/grafana-config/dashboards/configmap-teslamate-locations.yaml @@ -0,0 +1,1200 @@ +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": "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 new file mode 100644 index 0000000..74d9d14 --- /dev/null +++ b/argocd/manifests/grafana-config/dashboards/configmap-teslamate-mileage.yaml @@ -0,0 +1,293 @@ +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": "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 new file mode 100644 index 0000000..b80d04b --- /dev/null +++ b/argocd/manifests/grafana-config/dashboards/configmap-teslamate-overview.yaml @@ -0,0 +1,1990 @@ +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": "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 new file mode 100644 index 0000000..8adf108 --- /dev/null +++ b/argocd/manifests/grafana-config/dashboards/configmap-teslamate-projected-range.yaml @@ -0,0 +1,772 @@ +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": "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 new file mode 100644 index 0000000..2238bef --- /dev/null +++ b/argocd/manifests/grafana-config/dashboards/configmap-teslamate-states.yaml @@ -0,0 +1,528 @@ +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": "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 new file mode 100644 index 0000000..b3d7de8 --- /dev/null +++ b/argocd/manifests/grafana-config/dashboards/configmap-teslamate-statistics.yaml @@ -0,0 +1,1262 @@ +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": "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 new file mode 100644 index 0000000..6a72b1a --- /dev/null +++ b/argocd/manifests/grafana-config/dashboards/configmap-teslamate-timeline.yaml @@ -0,0 +1,770 @@ +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": "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 new file mode 100644 index 0000000..7d4973a --- /dev/null +++ b/argocd/manifests/grafana-config/dashboards/configmap-teslamate-trip.yaml @@ -0,0 +1,2819 @@ +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": "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 new file mode 100644 index 0000000..1bb83fd --- /dev/null +++ b/argocd/manifests/grafana-config/dashboards/configmap-teslamate-updates.yaml @@ -0,0 +1,619 @@ +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": "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 new file mode 100644 index 0000000..7454acb --- /dev/null +++ b/argocd/manifests/grafana-config/dashboards/configmap-teslamate-vampire-drain.yaml @@ -0,0 +1,666 @@ +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": "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 new file mode 100644 index 0000000..0faef0f --- /dev/null +++ b/argocd/manifests/grafana-config/dashboards/configmap-teslamate-visited.yaml @@ -0,0 +1,571 @@ +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": "Visited", + "uid": "RG_DxSmgk", + "version": 1 + } diff --git a/argocd/manifests/grafana-config/dashboards/configmap-transmission.yaml b/argocd/manifests/grafana-config/dashboards/configmap-transmission.yaml deleted file mode 100644 index ac99ed1..0000000 --- a/argocd/manifests/grafana-config/dashboards/configmap-transmission.yaml +++ /dev/null @@ -1,591 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: grafana-dashboard-transmission - namespace: monitoring - labels: - grafana_dashboard: "1" -data: - transmission.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 }, - { "color": "yellow", "value": 1048576 }, - { "color": "red", "value": 10485760 } - ] - }, - "unit": "Bps" - }, - "overrides": [] - }, - "gridPos": { "h": 4, "w": 6, "x": 0, "y": 0 }, - "id": 1, - "options": { - "colorMode": "value", - "graphMode": "area", - "justifyMode": "auto", - "orientation": "auto", - "reduceOptions": { - "calcs": ["lastNotNull"], - "fields": "", - "values": false - }, - "textMode": "auto" - }, - "targets": [ - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "transmission_session_stats_download_speed_bytes", - "refId": "A" - } - ], - "title": "Download Speed", - "type": "stat" - }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "thresholds" }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { "color": "green", "value": null }, - { "color": "yellow", "value": 1048576 }, - { "color": "red", "value": 10485760 } - ] - }, - "unit": "Bps" - }, - "overrides": [] - }, - "gridPos": { "h": 4, "w": 6, "x": 6, "y": 0 }, - "id": 2, - "options": { - "colorMode": "value", - "graphMode": "area", - "justifyMode": "auto", - "orientation": "auto", - "reduceOptions": { - "calcs": ["lastNotNull"], - "fields": "", - "values": false - }, - "textMode": "auto" - }, - "targets": [ - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "transmission_session_stats_upload_speed_bytes", - "refId": "A" - } - ], - "title": "Upload Speed", - "type": "stat" - }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "thresholds" }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [{ "color": "green", "value": null }] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { "h": 4, "w": 6, "x": 12, "y": 0 }, - "id": 3, - "options": { - "colorMode": "value", - "graphMode": "none", - "justifyMode": "auto", - "orientation": "auto", - "reduceOptions": { - "calcs": ["lastNotNull"], - "fields": "", - "values": false - }, - "textMode": "auto" - }, - "targets": [ - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "transmission_session_stats_torrents_active", - "refId": "A" - } - ], - "title": "Active Torrents", - "type": "stat" - }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "thresholds" }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [{ "color": "blue", "value": null }] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { "h": 4, "w": 6, "x": 18, "y": 0 }, - "id": 4, - "options": { - "colorMode": "value", - "graphMode": "none", - "justifyMode": "auto", - "orientation": "auto", - "reduceOptions": { - "calcs": ["lastNotNull"], - "fields": "", - "values": false - }, - "textMode": "auto" - }, - "targets": [ - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "transmission_session_stats_torrents_total", - "refId": "A" - } - ], - "title": "Total Torrents", - "type": "stat" - }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "palette-classic" }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 10, - "gradientMode": "none", - "hideFrom": { "legend": false, "tooltip": false, "viz": false }, - "insertNulls": false, - "lineInterpolation": "smooth", - "lineWidth": 2, - "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": "Bps" - }, - "overrides": [ - { - "matcher": { "id": "byName", "options": "Download" }, - "properties": [ - { "id": "color", "value": { "fixedColor": "green", "mode": "fixed" } } - ] - }, - { - "matcher": { "id": "byName", "options": "Upload" }, - "properties": [ - { "id": "color", "value": { "fixedColor": "blue", "mode": "fixed" } } - ] - } - ] - }, - "gridPos": { "h": 8, "w": 24, "x": 0, "y": 4 }, - "id": 5, - "options": { - "legend": { - "calcs": ["mean", "max"], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { "mode": "multi", "sort": "desc" } - }, - "targets": [ - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "transmission_session_stats_download_speed_bytes", - "legendFormat": "Download", - "refId": "A" - }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "transmission_session_stats_upload_speed_bytes", - "legendFormat": "Upload", - "refId": "B" - } - ], - "title": "Transfer Speed", - "type": "timeseries" - }, - { - "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": 12, "x": 0, "y": 12 }, - "id": 6, - "options": { - "colorMode": "value", - "graphMode": "none", - "justifyMode": "auto", - "orientation": "auto", - "reduceOptions": { - "calcs": ["lastNotNull"], - "fields": "", - "values": false - }, - "textMode": "auto" - }, - "targets": [ - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "increase(transmission_session_stats_downloaded_bytes{type=\"cumulative\"}[$__range])", - "refId": "A" - } - ], - "title": "Downloaded (selected range)", - "type": "stat" - }, - { - "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": 12, "x": 12, "y": 12 }, - "id": 7, - "options": { - "colorMode": "value", - "graphMode": "none", - "justifyMode": "auto", - "orientation": "auto", - "reduceOptions": { - "calcs": ["lastNotNull"], - "fields": "", - "values": false - }, - "textMode": "auto" - }, - "targets": [ - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "increase(transmission_session_stats_uploaded_bytes{type=\"cumulative\"}[$__range])", - "refId": "A" - } - ], - "title": "Uploaded (selected range)", - "type": "stat" - }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "palette-classic" }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "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": "Bps" - }, - "overrides": [] - }, - "gridPos": { "h": 8, "w": 12, "x": 0, "y": 16 }, - "id": 8, - "options": { - "legend": { - "calcs": ["mean", "max"], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { "mode": "multi", "sort": "desc" } - }, - "targets": [ - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "transmission_torrent_download_rate_bytes > 0", - "legendFormat": "{{name}}", - "refId": "A" - } - ], - "title": "Download Rate by Torrent", - "type": "timeseries" - }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "palette-classic" }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "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": "Bps" - }, - "overrides": [] - }, - "gridPos": { "h": 8, "w": 12, "x": 12, "y": 16 }, - "id": 9, - "options": { - "legend": { - "calcs": ["mean", "max"], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { "mode": "multi", "sort": "desc" } - }, - "targets": [ - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "transmission_torrent_upload_rate_bytes > 0", - "legendFormat": "{{name}}", - "refId": "A" - } - ], - "title": "Upload Rate by Torrent", - "type": "timeseries" - }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "fieldConfig": { - "defaults": { - "color": { "mode": "thresholds" }, - "custom": { - "align": "auto", - "cellOptions": { "type": "auto" }, - "inspect": false - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [{ "color": "green", "value": null }] - } - }, - "overrides": [ - { - "matcher": { "id": "byName", "options": "name" }, - "properties": [ - { "id": "custom.width", "value": 400 } - ] - }, - { - "matcher": { "id": "byName", "options": "Value #B" }, - "properties": [ - { "id": "unit", "value": "bytes" }, - { "id": "displayName", "value": "Uploaded" } - ] - }, - { - "matcher": { "id": "byName", "options": "Value #A" }, - "properties": [ - { "id": "displayName", "value": "Ratio" } - ] - }, - { - "matcher": { "id": "byName", "options": "Value #C" }, - "properties": [ - { "id": "unit", "value": "percentunit" }, - { "id": "displayName", "value": "Done" } - ] - } - ] - }, - "gridPos": { "h": 8, "w": 24, "x": 0, "y": 24 }, - "id": 10, - "options": { - "cellHeight": "sm", - "footer": { "countRows": false, "fields": "", "reducer": ["sum"], "show": false }, - "showHeader": true, - "sortBy": [ - { "desc": true, "displayName": "Ratio" } - ] - }, - "targets": [ - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "transmission_torrent_ratio", - "format": "table", - "instant": true, - "legendFormat": "", - "refId": "A" - }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "transmission_torrent_uploaded_ever", - "format": "table", - "instant": true, - "legendFormat": "", - "refId": "B" - }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "transmission_torrent_done", - "format": "table", - "instant": true, - "legendFormat": "", - "refId": "C" - } - ], - "title": "Torrent Details", - "transformations": [ - { - "id": "seriesToColumns", - "options": { - "byField": "name" - } - }, - { - "id": "organize", - "options": { - "excludeByName": { - "Time": true, - "Time 1": true, - "Time 2": true, - "Time 3": true, - "__name__": true, - "__name__ 1": true, - "__name__ 2": true, - "__name__ 3": true, - "cluster": true, - "cluster 1": true, - "cluster 2": true, - "cluster 3": true, - "instance": true, - "instance 1": true, - "instance 2": true, - "instance 3": true, - "job": true, - "job 1": true, - "job 2": true, - "job 3": true - }, - "indexByName": { - "name": 0, - "Value #A": 1, - "Value #B": 2, - "Value #C": 3 - }, - "renameByName": { - "name": "Torrent" - } - } - } - ], - "type": "table" - } - ], - "refresh": "30s", - "schemaVersion": 38, - "tags": ["transmission", "torrent", "bittorrent"], - "templating": { - "list": [] - }, - "time": { - "from": "now-6h", - "to": "now" - }, - "timepicker": {}, - "timezone": "", - "title": "Transmission", - "uid": "transmission", - "version": 1, - "weekStart": "" - } diff --git a/argocd/manifests/grafana-config/external-secret-admin.yaml b/argocd/manifests/grafana-config/external-secret-admin.yaml deleted file mode 100644 index 6876d97..0000000 --- a/argocd/manifests/grafana-config/external-secret-admin.yaml +++ /dev/null @@ -1,29 +0,0 @@ -# ExternalSecret for Grafana admin password -# -# Replaces the manual op inject workflow from secret-admin.yaml.tpl -# -# 1Password item: "Grafana (blumeops)" in blumeops vault -# Field: "password" -# -apiVersion: external-secrets.io/v1 -kind: ExternalSecret -metadata: - name: grafana-admin - namespace: monitoring -spec: - refreshInterval: 1h - secretStoreRef: - kind: ClusterSecretStore - name: onepassword-blumeops - target: - name: grafana-admin - creationPolicy: Owner - template: - data: - admin-user: admin - admin-password: "{{ .password }}" - data: - - secretKey: password - remoteRef: - key: Grafana (blumeops) - property: password diff --git a/argocd/manifests/grafana-config/external-secret-authentik-oauth.yaml b/argocd/manifests/grafana-config/external-secret-authentik-oauth.yaml deleted file mode 100644 index a83d35e..0000000 --- a/argocd/manifests/grafana-config/external-secret-authentik-oauth.yaml +++ /dev/null @@ -1,22 +0,0 @@ ---- -apiVersion: external-secrets.io/v1 -kind: ExternalSecret -metadata: - name: grafana-authentik-oauth - namespace: monitoring -spec: - refreshInterval: 1h - secretStoreRef: - kind: ClusterSecretStore - name: onepassword-blumeops - target: - name: grafana-authentik-oauth - creationPolicy: Owner - template: - data: - GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET: "{{ .clientSecret }}" - data: - - secretKey: clientSecret - remoteRef: - key: "Authentik (blumeops)" - property: grafana-client-secret diff --git a/argocd/manifests/grafana-config/external-secret-teslamate-datasource.yaml b/argocd/manifests/grafana-config/external-secret-teslamate-datasource.yaml deleted file mode 100644 index 3f8af1a..0000000 --- a/argocd/manifests/grafana-config/external-secret-teslamate-datasource.yaml +++ /dev/null @@ -1,31 +0,0 @@ -# ExternalSecret for TeslaMate PostgreSQL datasource password -# -# Replaces the manual op inject workflow from secret-teslamate-datasource.yaml.tpl -# -# 1Password item: "TeslaMate" in blumeops vault -# Field: "db_password" -# -# This secret is mounted as environment variables in Grafana. -# The password is referenced in values.yaml datasource config as $TESLAMATE_DB_PASSWORD -# -apiVersion: external-secrets.io/v1 -kind: ExternalSecret -metadata: - name: grafana-teslamate-datasource - namespace: monitoring -spec: - refreshInterval: 1h - secretStoreRef: - kind: ClusterSecretStore - name: onepassword-blumeops - target: - name: grafana-teslamate-datasource - creationPolicy: Owner - template: - data: - TESLAMATE_DB_PASSWORD: "{{ .password }}" - data: - - secretKey: password - remoteRef: - key: TeslaMate - property: db_password diff --git a/argocd/manifests/grafana-config/ingress-tailscale.yaml b/argocd/manifests/grafana-config/ingress-tailscale.yaml index 56888ac..b72f8b9 100644 --- a/argocd/manifests/grafana-config/ingress-tailscale.yaml +++ b/argocd/manifests/grafana-config/ingress-tailscale.yaml @@ -9,19 +9,6 @@ metadata: namespace: monitoring annotations: tailscale.com/proxy-class: "default" - tailscale.com/proxy-group: "ingress" - gethomepage.dev/enabled: "true" - gethomepage.dev/name: "Grafana" - gethomepage.dev/group: "Infrastructure" - gethomepage.dev/icon: "grafana.png" - gethomepage.dev/description: "Metrics dashboards" - gethomepage.dev/href: "https://grafana.ops.eblu.me" - gethomepage.dev/pod-selector: "app.kubernetes.io/name=grafana" - gethomepage.dev/widget.type: "grafana" - 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/grafana-config/kustomization.yaml b/argocd/manifests/grafana-config/kustomization.yaml index b518043..2611b0a 100644 --- a/argocd/manifests/grafana-config/kustomization.yaml +++ b/argocd/manifests/grafana-config/kustomization.yaml @@ -5,30 +5,32 @@ namespace: monitoring resources: - ingress-tailscale.yaml - - external-secret-admin.yaml - - external-secret-teslamate-datasource.yaml - - external-secret-authentik-oauth.yaml # Dashboard ConfigMaps - discovered by Grafana sidecar via label grafana_dashboard=1 - dashboards/configmap-borgmatic.yaml - dashboards/configmap-devpi.yaml - dashboards/configmap-loki.yaml - dashboards/configmap-macos.yaml - - dashboards/configmap-kubernetes.yaml - - dashboards/configmap-jellyfin.yaml + - dashboards/configmap-minikube.yaml + - dashboards/configmap-plex.yaml - dashboards/configmap-postgresql.yaml - - dashboards/configmap-ringtail.yaml + - dashboards/configmap-services.yaml - dashboards/configmap-zot.yaml - - dashboards/configmap-frigate.yaml - - 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 - - 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. + # 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 diff --git a/argocd/manifests/grafana-config/secret-admin.yaml.tpl b/argocd/manifests/grafana-config/secret-admin.yaml.tpl new file mode 100644 index 0000000..bdd2c7b --- /dev/null +++ b/argocd/manifests/grafana-config/secret-admin.yaml.tpl @@ -0,0 +1,16 @@ +# Grafana admin password secret +# +# Apply with: op inject -i secret-admin.yaml.tpl | kubectl apply -f - +# +# 1Password item: blumeops vault (vg6xf6vvfmoh5hqjjhlhbeoaie) +# Item ID: oxkcr3xtxnewy7noep2izvyr6y +# Field: password +apiVersion: v1 +kind: Secret +metadata: + name: grafana-admin + namespace: monitoring +type: Opaque +stringData: + admin-user: admin + admin-password: {{ op://vg6xf6vvfmoh5hqjjhlhbeoaie/oxkcr3xtxnewy7noep2izvyr6y/password }} diff --git a/argocd/manifests/grafana-config/secret-teslamate-datasource.yaml.tpl b/argocd/manifests/grafana-config/secret-teslamate-datasource.yaml.tpl new file mode 100644 index 0000000..fc2ef62 --- /dev/null +++ b/argocd/manifests/grafana-config/secret-teslamate-datasource.yaml.tpl @@ -0,0 +1,13 @@ +# TeslaMate PostgreSQL datasource password for Grafana +# Apply with: op inject -i argocd/manifests/grafana-config/secret-teslamate-datasource.yaml.tpl | kubectl apply -f - +# +# This secret is mounted as environment variables in Grafana +# The password is referenced in values.yaml datasource config as $TESLAMATE_DB_PASSWORD +apiVersion: v1 +kind: Secret +metadata: + name: grafana-teslamate-datasource + namespace: monitoring +type: Opaque +stringData: + TESLAMATE_DB_PASSWORD: {{ op://blumeops/TeslaMate/db_password }} diff --git a/argocd/manifests/grafana/alerting.yaml b/argocd/manifests/grafana/alerting.yaml deleted file mode 100644 index 4ae70d3..0000000 --- a/argocd/manifests/grafana/alerting.yaml +++ /dev/null @@ -1,453 +0,0 @@ -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: 60 - 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: 5m - 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: 60 - 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 - - - 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 - 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/datasources.yaml b/argocd/manifests/grafana/datasources.yaml deleted file mode 100644 index 64ed2bf..0000000 --- a/argocd/manifests/grafana/datasources.yaml +++ /dev/null @@ -1,69 +0,0 @@ -apiVersion: 1 -datasources: -- access: proxy - editable: false - isDefault: true - name: Prometheus - orgId: 1 - type: prometheus - uid: prometheus - url: http://prometheus.monitoring.svc.cluster.local:9090 -- access: proxy - editable: false - name: Loki - orgId: 1 - type: loki - uid: loki - url: http://loki.monitoring.svc.cluster.local:3100 - jsonData: - derivedFields: - - datasourceUid: tempo - matcherRegex: '"traceID":"(\w+)"' - name: TraceID - url: "$${__value.raw}" -- access: proxy - editable: false - name: Tempo - orgId: 1 - type: tempo - uid: tempo - url: http://tempo.monitoring.svc.cluster.local:3200 - jsonData: - tracesToLogsV2: - datasourceUid: loki - filterByTraceID: true - filterBySpanID: false - tracesToMetrics: - datasourceUid: prometheus - spanStartTimeShift: "-1h" - spanEndTimeShift: "1h" - queries: - - name: Request rate - query: "sum(rate(traces_spanmetrics_calls_total{$$__tags}[5m]))" - - name: Error rate - query: "sum(rate(traces_spanmetrics_calls_total{$$__tags, status_code=\"STATUS_CODE_ERROR\"}[5m]))" - - name: Duration (p95) - query: "histogram_quantile(0.95, sum(rate(traces_spanmetrics_duration_seconds_bucket{$$__tags}[5m])) by (le))" - serviceMap: - datasourceUid: prometheus - nodeGraph: - enabled: true -- access: proxy - database: teslamate - editable: false - jsonData: - database: teslamate - connMaxLifetime: 14400 - maxIdleConns: 2 - maxOpenConns: 5 - sslmode: disable - name: TeslaMate - orgId: 1 - secureJsonData: - password: $TESLAMATE_DB_PASSWORD - type: postgres - uid: TeslaMate - # 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/grafana/deployment.yaml b/argocd/manifests/grafana/deployment.yaml deleted file mode 100644 index cbba267..0000000 --- a/argocd/manifests/grafana/deployment.yaml +++ /dev/null @@ -1,318 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: grafana - namespace: monitoring - labels: - app.kubernetes.io/name: grafana - app.kubernetes.io/instance: grafana -spec: - replicas: 1 - revisionHistoryLimit: 10 - selector: - matchLabels: - app.kubernetes.io/name: grafana - app.kubernetes.io/instance: grafana - strategy: - # 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: - app.kubernetes.io/name: grafana - app.kubernetes.io/instance: grafana - annotations: - kubectl.kubernetes.io/default-container: grafana - spec: - automountServiceAccountToken: true - serviceAccountName: grafana - securityContext: - fsGroup: 472 - runAsGroup: 472 - runAsNonRoot: true - runAsUser: 472 - initContainers: - - name: init-chown-data - image: docker.io/library/busybox:kustomized - imagePullPolicy: IfNotPresent - command: ["chown", "-R", "472:472", "/var/lib/grafana"] - securityContext: - runAsNonRoot: false - runAsUser: 0 - capabilities: - add: ["CHOWN"] - seccompProfile: - type: RuntimeDefault - 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 - # 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 "s/^ \"uid\": *\"[^\"]*\"/ \"uid\": \"${uid}\"/" "$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 - # 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" - # DPI dashboard omitted — requires DPI enabled on both UX7 and UnPoller - for f in \ - "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 - # 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/-$//' | sed 's/^unifi-poller-//') - uid="unpoller-${slug}" - sed -i "s/^ \"uid\": *\"[^\"]*\"/ \"uid\": \"${uid}\"/" "$f" - done - 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 - # 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 - # 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: - 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 - image: registry.ops.eblu.me/blumeops/grafana-sidecar:kustomized - imagePullPolicy: IfNotPresent - env: - - name: METHOD - value: WATCH - - name: LABEL - value: grafana_dashboard - - name: LABEL_VALUE - value: "1" - - name: FOLDER - value: /tmp/dashboards - - name: RESOURCE - value: configmap - - name: FOLDER_ANNOTATION - value: grafana_folder - - name: REQ_USERNAME - valueFrom: - secretKeyRef: - name: grafana-admin - key: admin-user - - name: REQ_PASSWORD - valueFrom: - secretKeyRef: - name: grafana-admin - key: admin-password - - name: REQ_URL - 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: - drop: ["ALL"] - seccompProfile: - type: RuntimeDefault - volumeMounts: - - name: sc-dashboard-volume - mountPath: /tmp/dashboards - # Grafana - - name: grafana - image: registry.ops.eblu.me/blumeops/grafana:kustomized - imagePullPolicy: IfNotPresent - env: - - name: POD_IP - valueFrom: - fieldRef: - fieldPath: status.podIP - - name: GF_SECURITY_ADMIN_USER - valueFrom: - secretKeyRef: - name: grafana-admin - key: admin-user - - name: GF_SECURITY_ADMIN_PASSWORD - valueFrom: - secretKeyRef: - name: grafana-admin - key: admin-password - - name: GF_PATHS_DATA - value: /var/lib/grafana/ - - name: GF_PATHS_LOGS - value: /var/log/grafana - - name: GF_PATHS_PLUGINS - value: /var/lib/grafana/plugins - - name: GF_PATHS_PROVISIONING - value: /etc/grafana/provisioning - envFrom: - - secretRef: - name: grafana-teslamate-datasource - optional: true - - secretRef: - name: grafana-authentik-oauth - optional: true - ports: - - name: http - containerPort: 3000 - protocol: TCP - livenessProbe: - httpGet: - path: /api/health - port: 3000 - initialDelaySeconds: 60 - timeoutSeconds: 30 - failureThreshold: 10 - readinessProbe: - httpGet: - path: /api/health - port: 3000 - resources: - requests: - cpu: 100m - memory: 128Mi - limits: - cpu: 500m - memory: 512Mi - securityContext: - allowPrivilegeEscalation: false - capabilities: - drop: ["ALL"] - seccompProfile: - type: RuntimeDefault - volumeMounts: - - name: config - mountPath: /etc/grafana/grafana.ini - subPath: grafana.ini - - 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 - mountPath: /tmp/dashboards - - name: sc-dashboard-provider - mountPath: /etc/grafana/provisioning/dashboards/sc-dashboardproviders.yaml - subPath: provider.yaml - volumes: - - name: config - configMap: - name: grafana - - name: storage - persistentVolumeClaim: - claimName: grafana - - name: sc-dashboard-volume - emptyDir: {} - - name: sc-dashboard-provider - configMap: - name: grafana-config-dashboards diff --git a/argocd/manifests/grafana/grafana.ini b/argocd/manifests/grafana/grafana.ini deleted file mode 100644 index a0a6db8..0000000 --- a/argocd/manifests/grafana/grafana.ini +++ /dev/null @@ -1,37 +0,0 @@ -[analytics] -check_for_updates = false -reporting_enabled = false - -[auth.generic_oauth] -allow_sign_up = true -api_url = https://authentik.ops.eblu.me/application/o/userinfo/ -auth_url = https://authentik.ops.eblu.me/application/o/authorize/ -auto_login = false -client_id = grafana -client_secret = $__env{GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET} -enabled = true -name = Authentik -role_attribute_path = contains(groups[*], 'admins') && 'Admin' || 'Viewer' -skip_org_role_sync = false -scopes = openid profile email -token_url = https://authentik.ops.eblu.me/application/o/token/ - -[log] -mode = console - -[paths] -data = /var/lib/grafana/ -logs = /var/log/grafana -plugins = /var/lib/grafana/plugins -provisioning = /etc/grafana/provisioning - -[security] -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 deleted file mode 100644 index a511fe1..0000000 --- a/argocd/manifests/grafana/kustomization.yaml +++ /dev/null @@ -1,39 +0,0 @@ -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization - -namespace: monitoring - -resources: - - serviceaccount.yaml - - pvc.yaml - - deployment.yaml - - service.yaml - - 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 - newTag: v2.6.0-61fcd5d - - name: registry.ops.eblu.me/blumeops/grafana - newTag: v12.4.2-4c54774 - -configMapGenerator: - - name: grafana - files: - - grafana.ini - - datasources.yaml - - alerting.yaml - options: - labels: - app.kubernetes.io/name: grafana - app.kubernetes.io/instance: grafana - - name: grafana-config-dashboards - files: - - provider.yaml - options: - labels: - app.kubernetes.io/name: grafana - app.kubernetes.io/instance: grafana diff --git a/argocd/manifests/grafana/provider.yaml b/argocd/manifests/grafana/provider.yaml deleted file mode 100644 index a35d172..0000000 --- a/argocd/manifests/grafana/provider.yaml +++ /dev/null @@ -1,11 +0,0 @@ -apiVersion: 1 -providers: - - name: 'sidecarProvider' - orgId: 1 - type: file - disableDeletion: false - allowUiUpdates: false - updateIntervalSeconds: 30 - options: - foldersFromFilesStructure: true - path: /tmp/dashboards diff --git a/argocd/manifests/grafana/pvc.yaml b/argocd/manifests/grafana/pvc.yaml deleted file mode 100644 index e119e3a..0000000 --- a/argocd/manifests/grafana/pvc.yaml +++ /dev/null @@ -1,14 +0,0 @@ -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: grafana - namespace: monitoring - labels: - app.kubernetes.io/name: grafana - app.kubernetes.io/instance: grafana -spec: - accessModes: - - ReadWriteOnce - resources: - requests: - storage: 1Gi diff --git a/argocd/manifests/grafana/rbac.yaml b/argocd/manifests/grafana/rbac.yaml deleted file mode 100644 index 1c2dee3..0000000 --- a/argocd/manifests/grafana/rbac.yaml +++ /dev/null @@ -1,54 +0,0 @@ -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - name: grafana-clusterrole - labels: - app.kubernetes.io/name: grafana - app.kubernetes.io/instance: grafana -rules: - - apiGroups: [""] - resources: ["configmaps"] - verbs: ["get", "watch", "list"] ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRoleBinding -metadata: - name: grafana-clusterrolebinding - labels: - app.kubernetes.io/name: grafana - app.kubernetes.io/instance: grafana -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: ClusterRole - name: grafana-clusterrole -subjects: - - kind: ServiceAccount - name: grafana - namespace: monitoring ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: Role -metadata: - name: grafana - namespace: monitoring - labels: - app.kubernetes.io/name: grafana - app.kubernetes.io/instance: grafana -rules: [] ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - name: grafana - namespace: monitoring - labels: - app.kubernetes.io/name: grafana - app.kubernetes.io/instance: grafana -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: Role - name: grafana -subjects: - - kind: ServiceAccount - name: grafana - namespace: monitoring diff --git a/argocd/manifests/grafana/service.yaml b/argocd/manifests/grafana/service.yaml deleted file mode 100644 index eea02f1..0000000 --- a/argocd/manifests/grafana/service.yaml +++ /dev/null @@ -1,18 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: grafana - namespace: monitoring - labels: - app.kubernetes.io/name: grafana - app.kubernetes.io/instance: grafana -spec: - type: ClusterIP - ports: - - name: http - port: 80 - targetPort: 3000 - protocol: TCP - selector: - app.kubernetes.io/name: grafana - app.kubernetes.io/instance: grafana diff --git a/argocd/manifests/grafana/serviceaccount.yaml b/argocd/manifests/grafana/serviceaccount.yaml deleted file mode 100644 index 4a1363e..0000000 --- a/argocd/manifests/grafana/serviceaccount.yaml +++ /dev/null @@ -1,9 +0,0 @@ -apiVersion: v1 -kind: ServiceAccount -metadata: - name: grafana - namespace: monitoring - labels: - app.kubernetes.io/name: grafana - app.kubernetes.io/instance: grafana -automountServiceAccountToken: false diff --git a/argocd/manifests/grafana/values.yaml b/argocd/manifests/grafana/values.yaml new file mode 100644 index 0000000..402439c --- /dev/null +++ b/argocd/manifests/grafana/values.yaml @@ -0,0 +1,91 @@ +# Grafana Helm values for blumeops +# Chart: https://github.com/grafana/helm-charts/tree/main/charts/grafana + +# Admin credentials from pre-created secret +# Secret must exist before deploying - see grafana-config/README.md +admin: + existingSecret: grafana-admin + userKey: admin-user + passwordKey: admin-password + +# Environment variables from secrets (for datasource credentials) +envFromSecrets: + - name: grafana-teslamate-datasource + optional: true + +# Persistence with PVC for SQLite database +persistence: + enabled: true + type: pvc + size: 1Gi + accessModes: + - ReadWriteOnce + +# Grafana configuration via grafana.ini +grafana.ini: + server: + root_url: https://grafana.tail8d86e.ts.net + analytics: + check_for_updates: false + reporting_enabled: false + +# Datasources - point to k8s-internal services +datasources: + datasources.yaml: + apiVersion: 1 + datasources: + - name: Prometheus + type: prometheus + access: proxy + orgId: 1 + uid: prometheus + url: http://prometheus.monitoring.svc.cluster.local:9090 + isDefault: true + editable: false + - name: Loki + type: loki + access: proxy + orgId: 1 + uid: loki + url: http://loki.monitoring.svc.cluster.local:3100 + editable: false + - name: TeslaMate + type: postgres + access: proxy + orgId: 1 + uid: TeslaMate + url: blumeops-pg-rw.databases.svc.cluster.local:5432 + database: teslamate + user: teslamate + editable: false + jsonData: + sslmode: disable + maxOpenConns: 5 + maxIdleConns: 2 + connMaxLifetime: 14400 + secureJsonData: + password: $TESLAMATE_DB_PASSWORD + +# Dashboard provisioning - sidecar watches for ConfigMaps with label +sidecar: + dashboards: + enabled: true + label: grafana_dashboard + labelValue: "1" + folderAnnotation: grafana_folder + provider: + foldersFromFilesStructure: false + +# Service configuration (Ingress will handle external access) +service: + type: ClusterIP + port: 80 + +# Resource limits for minikube +resources: + requests: + memory: "128Mi" + cpu: "100m" + limits: + memory: "512Mi" + cpu: "500m" diff --git a/argocd/manifests/homepage/bookmarks.yaml b/argocd/manifests/homepage/bookmarks.yaml deleted file mode 100644 index bc58a1e..0000000 --- a/argocd/manifests/homepage/bookmarks.yaml +++ /dev/null @@ -1,22 +0,0 @@ -- Admin: - - Tailscale Admin: - - href: https://login.tailscale.com/admin - icon: tailscale - - 1Password: - - href: https://my.1password.com - icon: 1password - - Pulumi: - - href: https://app.pulumi.com/eblume/blumeops-tailnet - icon: si-pulumi - - ArgoCD: - - href: https://argocd.ops.eblu.me - icon: argo-cd - - 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 diff --git a/argocd/manifests/homepage/clusterrole.yaml b/argocd/manifests/homepage/clusterrole.yaml deleted file mode 100644 index 2738755..0000000 --- a/argocd/manifests/homepage/clusterrole.yaml +++ /dev/null @@ -1,23 +0,0 @@ -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - name: homepage -rules: - - apiGroups: [""] - resources: ["namespaces", "pods", "nodes"] - verbs: ["get", "list"] - - apiGroups: ["extensions", "networking.k8s.io"] - resources: ["ingresses"] - verbs: ["get", "list"] - - apiGroups: ["traefik.containo.us", "traefik.io"] - resources: ["ingressroutes"] - verbs: ["get", "list"] - - apiGroups: ["gateway.networking.k8s.io"] - resources: ["gateways", "httproutes"] - verbs: ["get", "list"] - - apiGroups: ["metrics.k8s.io"] - resources: ["nodes", "pods"] - verbs: ["get", "list"] - - apiGroups: ["apiextensions.k8s.io"] - resources: ["customresourcedefinitions/status"] - verbs: ["get"] diff --git a/argocd/manifests/homepage/clusterrolebinding.yaml b/argocd/manifests/homepage/clusterrolebinding.yaml deleted file mode 100644 index 4b0613d..0000000 --- a/argocd/manifests/homepage/clusterrolebinding.yaml +++ /dev/null @@ -1,12 +0,0 @@ -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRoleBinding -metadata: - name: homepage -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: ClusterRole - name: homepage -subjects: - - kind: ServiceAccount - name: homepage - namespace: homepage diff --git a/argocd/manifests/homepage/deployment.yaml b/argocd/manifests/homepage/deployment.yaml deleted file mode 100644 index 76cbda3..0000000 --- a/argocd/manifests/homepage/deployment.yaml +++ /dev/null @@ -1,133 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: homepage - namespace: homepage -spec: - replicas: 1 - selector: - matchLabels: - app: homepage - template: - metadata: - labels: - app: homepage - spec: - serviceAccountName: homepage - securityContext: - runAsUser: 1000 - runAsGroup: 1000 - fsGroup: 1000 - seccompProfile: - type: RuntimeDefault - containers: - - name: homepage - image: registry.ops.eblu.me/blumeops/homepage:kustomized - securityContext: - runAsNonRoot: true - allowPrivilegeEscalation: false - ports: - - containerPort: 3000 - name: http - env: - - name: HOMEPAGE_ALLOWED_HOSTS - value: "go.tail8d86e.ts.net,go.ops.eblu.me" - # Weather widget - - name: HOMEPAGE_VAR_OPENWEATHERMAP_API_KEY - valueFrom: - secretKeyRef: - name: homepage-openweathermap - key: apikey - # Jellyfin widget - - name: HOMEPAGE_VAR_JELLYFIN_API_KEY - valueFrom: - secretKeyRef: - name: homepage-jellyfin - key: apikey - # Miniflux widget - - name: HOMEPAGE_VAR_MINIFLUX_API_KEY - valueFrom: - secretKeyRef: - name: homepage-miniflux - key: apikey - # Grafana widget - - name: HOMEPAGE_VAR_GRAFANA_USERNAME - valueFrom: - secretKeyRef: - name: homepage-grafana - key: username - - name: HOMEPAGE_VAR_GRAFANA_PASSWORD - valueFrom: - secretKeyRef: - name: homepage-grafana - key: password - # Forgejo widget - - name: HOMEPAGE_VAR_FORGEJO_API_KEY - valueFrom: - secretKeyRef: - name: homepage-forgejo - key: apikey - # Navidrome widget - - name: HOMEPAGE_VAR_NAVIDROME_USER - valueFrom: - secretKeyRef: - name: homepage-navidrome - key: user - - name: HOMEPAGE_VAR_NAVIDROME_SALT - valueFrom: - secretKeyRef: - name: homepage-navidrome - key: salt - - name: HOMEPAGE_VAR_NAVIDROME_TOKEN - valueFrom: - secretKeyRef: - name: homepage-navidrome - key: token - volumeMounts: - - name: config - mountPath: /app/config/bookmarks.yaml - subPath: bookmarks.yaml - - name: config - mountPath: /app/config/services.yaml - subPath: services.yaml - - name: config - mountPath: /app/config/widgets.yaml - subPath: widgets.yaml - - name: config - mountPath: /app/config/kubernetes.yaml - subPath: kubernetes.yaml - - name: config - mountPath: /app/config/docker.yaml - subPath: docker.yaml - - name: config - mountPath: /app/config/settings.yaml - subPath: settings.yaml - resources: - requests: - memory: "128Mi" - cpu: "100m" - limits: - memory: "512Mi" - cpu: "500m" - livenessProbe: - httpGet: - path: /api/healthcheck - port: 3000 - httpHeaders: - - name: Host - value: go.ops.eblu.me - initialDelaySeconds: 20 - periodSeconds: 30 - readinessProbe: - httpGet: - path: /api/healthcheck - port: 3000 - httpHeaders: - - name: Host - value: go.ops.eblu.me - initialDelaySeconds: 10 - periodSeconds: 10 - volumes: - - name: config - configMap: - name: homepage-config diff --git a/argocd/manifests/homepage/docker.yaml b/argocd/manifests/homepage/docker.yaml deleted file mode 100644 index e69de29..0000000 diff --git a/argocd/manifests/homepage/external-secret-forgejo.yaml b/argocd/manifests/homepage/external-secret-forgejo.yaml deleted file mode 100644 index 8c771ab..0000000 --- a/argocd/manifests/homepage/external-secret-forgejo.yaml +++ /dev/null @@ -1,20 +0,0 @@ -# ExternalSecret for Forgejo API key -# Used by Homepage Forgejo widget (via Gitea widget type) -apiVersion: external-secrets.io/v1 -kind: ExternalSecret -metadata: - name: homepage-forgejo - namespace: homepage -spec: - refreshInterval: 1h - secretStoreRef: - kind: ClusterSecretStore - name: onepassword-blumeops - target: - name: homepage-forgejo - creationPolicy: Owner - data: - - secretKey: apikey - remoteRef: - key: Forgejo Secrets - property: eblume-homepage-access-token diff --git a/argocd/manifests/homepage/external-secret-grafana.yaml b/argocd/manifests/homepage/external-secret-grafana.yaml deleted file mode 100644 index ed455a4..0000000 --- a/argocd/manifests/homepage/external-secret-grafana.yaml +++ /dev/null @@ -1,24 +0,0 @@ -# ExternalSecret for Grafana credentials -# Used by Homepage Grafana widget -apiVersion: external-secrets.io/v1 -kind: ExternalSecret -metadata: - name: homepage-grafana - namespace: homepage -spec: - refreshInterval: 1h - secretStoreRef: - kind: ClusterSecretStore - name: onepassword-blumeops - target: - name: homepage-grafana - creationPolicy: Owner - data: - - secretKey: username - remoteRef: - key: Grafana (blumeops) - property: username - - secretKey: password - remoteRef: - key: Grafana (blumeops) - property: password diff --git a/argocd/manifests/homepage/external-secret-jellyfin.yaml b/argocd/manifests/homepage/external-secret-jellyfin.yaml deleted file mode 100644 index 0c365c3..0000000 --- a/argocd/manifests/homepage/external-secret-jellyfin.yaml +++ /dev/null @@ -1,20 +0,0 @@ -# ExternalSecret for Jellyfin API key -# Used by Homepage Jellyfin widget -apiVersion: external-secrets.io/v1 -kind: ExternalSecret -metadata: - name: homepage-jellyfin - namespace: homepage -spec: - refreshInterval: 1h - secretStoreRef: - kind: ClusterSecretStore - name: onepassword-blumeops - target: - name: homepage-jellyfin - creationPolicy: Owner - data: - - secretKey: apikey - remoteRef: - key: jellyfin - property: credential diff --git a/argocd/manifests/homepage/external-secret-miniflux.yaml b/argocd/manifests/homepage/external-secret-miniflux.yaml deleted file mode 100644 index 7105ba9..0000000 --- a/argocd/manifests/homepage/external-secret-miniflux.yaml +++ /dev/null @@ -1,20 +0,0 @@ -# ExternalSecret for Miniflux API key -# Used by Homepage Miniflux widget -apiVersion: external-secrets.io/v1 -kind: ExternalSecret -metadata: - name: homepage-miniflux - namespace: homepage -spec: - refreshInterval: 1h - secretStoreRef: - kind: ClusterSecretStore - name: onepassword-blumeops - target: - name: homepage-miniflux - creationPolicy: Owner - data: - - secretKey: apikey - remoteRef: - key: miniflux - property: eblume-api-key diff --git a/argocd/manifests/homepage/external-secret-navidrome.yaml b/argocd/manifests/homepage/external-secret-navidrome.yaml deleted file mode 100644 index 0bcd727..0000000 --- a/argocd/manifests/homepage/external-secret-navidrome.yaml +++ /dev/null @@ -1,28 +0,0 @@ -# ExternalSecret for Navidrome Subsonic API credentials -# Used by Homepage Navidrome widget -apiVersion: external-secrets.io/v1 -kind: ExternalSecret -metadata: - name: homepage-navidrome - namespace: homepage -spec: - refreshInterval: 1h - secretStoreRef: - kind: ClusterSecretStore - name: onepassword-blumeops - target: - name: homepage-navidrome - creationPolicy: Owner - data: - - secretKey: user - remoteRef: - key: dj (navidrome) - property: username - - secretKey: salt - remoteRef: - key: dj (navidrome) - property: salt - - secretKey: token - remoteRef: - key: dj (navidrome) - property: salted_pw diff --git a/argocd/manifests/homepage/external-secret-openweathermap.yaml b/argocd/manifests/homepage/external-secret-openweathermap.yaml deleted file mode 100644 index d65c9a1..0000000 --- a/argocd/manifests/homepage/external-secret-openweathermap.yaml +++ /dev/null @@ -1,20 +0,0 @@ -# ExternalSecret for OpenWeatherMap API key -# Used by Homepage weather widget -apiVersion: external-secrets.io/v1 -kind: ExternalSecret -metadata: - name: homepage-openweathermap - namespace: homepage -spec: - refreshInterval: 1h - secretStoreRef: - kind: ClusterSecretStore - name: onepassword-blumeops - target: - name: homepage-openweathermap - creationPolicy: Owner - data: - - secretKey: apikey - remoteRef: - key: OpenWeatherMap - property: credential diff --git a/argocd/manifests/homepage/ingress-tailscale.yaml b/argocd/manifests/homepage/ingress-tailscale.yaml deleted file mode 100644 index ccc9f7e..0000000 --- a/argocd/manifests/homepage/ingress-tailscale.yaml +++ /dev/null @@ -1,27 +0,0 @@ -# Tailscale Ingress for Homepage -# Exposes at go.tail8d86e.ts.net -# Caddy proxies go.ops.eblu.me to this -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: homepage-tailscale - namespace: homepage - annotations: - tailscale.com/funnel: "false" - tailscale.com/proxy-group: "ingress" - gethomepage.dev/enabled: "false" -spec: - ingressClassName: tailscale - rules: - - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: homepage - port: - number: 3000 - tls: - - hosts: - - go diff --git a/argocd/manifests/homepage/kubernetes.yaml b/argocd/manifests/homepage/kubernetes.yaml deleted file mode 100644 index 0f259a0..0000000 --- a/argocd/manifests/homepage/kubernetes.yaml +++ /dev/null @@ -1 +0,0 @@ -mode: cluster diff --git a/argocd/manifests/homepage/kustomization.yaml b/argocd/manifests/homepage/kustomization.yaml deleted file mode 100644 index 31b6847..0000000 --- a/argocd/manifests/homepage/kustomization.yaml +++ /dev/null @@ -1,30 +0,0 @@ -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization -namespace: homepage -resources: - - serviceaccount.yaml - - clusterrole.yaml - - clusterrolebinding.yaml - - deployment.yaml - - service.yaml - - ingress-tailscale.yaml - - external-secret-openweathermap.yaml - - external-secret-jellyfin.yaml - - external-secret-forgejo.yaml - - external-secret-grafana.yaml - - external-secret-miniflux.yaml - - external-secret-navidrome.yaml - -images: - - name: registry.ops.eblu.me/blumeops/homepage - newTag: v1.11.0-678f26b-nix - -configMapGenerator: - - name: homepage-config - files: - - bookmarks.yaml - - services.yaml - - widgets.yaml - - kubernetes.yaml - - docker.yaml - - settings.yaml diff --git a/argocd/manifests/homepage/service.yaml b/argocd/manifests/homepage/service.yaml deleted file mode 100644 index 0eb1b89..0000000 --- a/argocd/manifests/homepage/service.yaml +++ /dev/null @@ -1,12 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: homepage - namespace: homepage -spec: - selector: - app: homepage - ports: - - name: http - port: 3000 - targetPort: 3000 diff --git a/argocd/manifests/homepage/serviceaccount.yaml b/argocd/manifests/homepage/serviceaccount.yaml deleted file mode 100644 index e2c39c3..0000000 --- a/argocd/manifests/homepage/serviceaccount.yaml +++ /dev/null @@ -1,5 +0,0 @@ -apiVersion: v1 -kind: ServiceAccount -metadata: - name: homepage - namespace: homepage diff --git a/argocd/manifests/homepage/services.yaml b/argocd/manifests/homepage/services.yaml deleted file mode 100644 index cc1adf4..0000000 --- a/argocd/manifests/homepage/services.yaml +++ /dev/null @@ -1,132 +0,0 @@ -# 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 - icon: forgejo - description: Git forge - widget: - 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 - 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 - description: NAS dashboard - widget: - type: prometheusmetric - url: https://prometheus.ops.eblu.me - metrics: - - label: Used - query: node_filesystem_size_bytes{mountpoint="/Volumes/backups"} - node_filesystem_avail_bytes{mountpoint="/Volumes/backups"} - format: - type: bytes - - label: Total - query: node_filesystem_size_bytes{mountpoint="/Volumes/backups"} - format: - type: bytes - - Borg Backups: - href: https://grafana.ops.eblu.me/d/borgmatic - icon: borgmatic - description: Backup system - widget: - type: prometheusmetric - url: https://prometheus.ops.eblu.me - metrics: - - label: Last backup - query: time() - borgmatic_last_archive_timestamp - format: - type: duration - - label: Archive size - 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: - - Jellyfin: - href: https://jellyfin.ops.eblu.me - icon: jellyfin - description: Media server - widget: - type: jellyfin - url: https://jellyfin.ops.eblu.me - key: "{{HOMEPAGE_VAR_JELLYFIN_API_KEY}}" - enableBlocks: true - enableNowPlaying: false - fields: ["movies", "series", "episodes"] - - 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}}" -- Content: - - 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: - - 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 - # 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 - - Transmission: - href: https://torrent.ops.eblu.me - icon: transmission.png - description: Torrent client diff --git a/argocd/manifests/homepage/settings.yaml b/argocd/manifests/homepage/settings.yaml deleted file mode 100644 index 49bbf4e..0000000 --- a/argocd/manifests/homepage/settings.yaml +++ /dev/null @@ -1,29 +0,0 @@ -title: BlumeOps -headerStyle: boxed -quicklaunch: - searchDescriptions: true - showSearchSuggestions: true - provider: custom - url: https://kagi.com/search?q= - suggestionUrl: https://kagisuggest.com/api/autosuggest?q= -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 diff --git a/argocd/manifests/homepage/widgets.yaml b/argocd/manifests/homepage/widgets.yaml deleted file mode 100644 index 097d806..0000000 --- a/argocd/manifests/homepage/widgets.yaml +++ /dev/null @@ -1,26 +0,0 @@ -- greeting: - text_size: xl - text: Welcome to Blue Mops -- datetime: - text_size: lg - format: - dateStyle: long - timeStyle: short - hour12: true -- openweathermap: - label: Camano - latitude: 48.18235 - longitude: -122.52590 - units: imperial - provider: openweathermap - apiKey: "{{HOMEPAGE_VAR_OPENWEATHERMAP_API_KEY}}" - cache: 15 -# TODO: Add UniFi widget when controller is set up -# - unifi_console: -# url: https://192.168.1.1 -# username: homepage -# password: "{{HOMEPAGE_VAR_UNIFI_PASSWORD}}" -# TODO: Add Glances widget when Glances is deployed -# - glances: -# url: http://indri.tail8d86e.ts.net:61208 -# metric: cpu diff --git a/argocd/manifests/immich-ringtail/deployment-ml.yaml b/argocd/manifests/immich-ringtail/deployment-ml.yaml deleted file mode 100644 index 5ea8035..0000000 --- a/argocd/manifests/immich-ringtail/deployment-ml.yaml +++ /dev/null @@ -1,69 +0,0 @@ ---- -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: - 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 - 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" - nvidia.com/gpu: "1" - volumes: - - name: cache - persistentVolumeClaim: - claimName: immich-ml-cache diff --git a/argocd/manifests/immich-ringtail/deployment-server.yaml b/argocd/manifests/immich-ringtail/deployment-server.yaml deleted file mode 100644 index 8ac7ab0..0000000 --- a/argocd/manifests/immich-ringtail/deployment-server.yaml +++ /dev/null @@ -1,74 +0,0 @@ ---- -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: - securityContext: - seccompProfile: - type: RuntimeDefault - 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-ringtail/deployment-valkey.yaml b/argocd/manifests/immich-ringtail/deployment-valkey.yaml deleted file mode 100644 index 1cf3346..0000000 --- a/argocd/manifests/immich-ringtail/deployment-valkey.yaml +++ /dev/null @@ -1,42 +0,0 @@ ---- -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: - securityContext: - seccompProfile: - type: RuntimeDefault - 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-ringtail/ingress-tailscale.yaml b/argocd/manifests/immich-ringtail/ingress-tailscale.yaml deleted file mode 100644 index f0b5fe1..0000000 --- a/argocd/manifests/immich-ringtail/ingress-tailscale.yaml +++ /dev/null @@ -1,36 +0,0 @@ -# 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: - name: immich-tailscale - namespace: immich - annotations: - tailscale.com/funnel: "false" - tailscale.com/proxy-group: "ingress" - gethomepage.dev/enabled: "true" - gethomepage.dev/name: "Immich" - gethomepage.dev/group: "Content" - gethomepage.dev/icon: "immich.png" - gethomepage.dev/description: "Photo management" - gethomepage.dev/href: "https://photos.ops.eblu.me" - gethomepage.dev/pod-selector: "app=immich,component=server" -spec: - ingressClassName: tailscale - rules: - - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: immich-server - port: - number: 2283 - tls: - - hosts: - - photos diff --git a/argocd/manifests/immich-ringtail/kustomization.yaml b/argocd/manifests/immich-ringtail/kustomization.yaml deleted file mode 100644 index 2fa131c..0000000 --- a/argocd/manifests/immich-ringtail/kustomization.yaml +++ /dev/null @@ -1,29 +0,0 @@ -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 - # CUDA variant of the same release — ringtail has an RTX 4080 - newTag: v2.6.3-cuda - # 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 - newName: registry.ops.eblu.me/blumeops/valkey - newTag: v8.1.7-ecded30-nix diff --git a/argocd/manifests/immich-ringtail/pv-nfs.yaml b/argocd/manifests/immich-ringtail/pv-nfs.yaml deleted file mode 100644 index 3d5a682..0000000 --- a/argocd/manifests/immich-ringtail/pv-nfs.yaml +++ /dev/null @@ -1,29 +0,0 @@ -# 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-ringtail/pvc-ml-cache.yaml b/argocd/manifests/immich-ringtail/pvc-ml-cache.yaml deleted file mode 100644 index 1e5a3d6..0000000 --- a/argocd/manifests/immich-ringtail/pvc-ml-cache.yaml +++ /dev/null @@ -1,12 +0,0 @@ ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: immich-ml-cache - namespace: immich -spec: - accessModes: - - ReadWriteOnce - resources: - requests: - storage: 10Gi diff --git a/argocd/manifests/immich-ringtail/pvc.yaml b/argocd/manifests/immich-ringtail/pvc.yaml deleted file mode 100644 index 5bfc052..0000000 --- a/argocd/manifests/immich-ringtail/pvc.yaml +++ /dev/null @@ -1,15 +0,0 @@ -# PersistentVolumeClaim for Immich photo library on ringtail. -# Binds to immich-library-nfs-pv-ringtail (sifaka:/volume1/photos). -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: immich-library - namespace: immich -spec: - accessModes: - - ReadWriteMany - storageClassName: "" - volumeName: immich-library-nfs-pv-ringtail - resources: - requests: - storage: 2Ti diff --git a/argocd/manifests/immich-ringtail/service-ml.yaml b/argocd/manifests/immich-ringtail/service-ml.yaml deleted file mode 100644 index 9bb935a..0000000 --- a/argocd/manifests/immich-ringtail/service-ml.yaml +++ /dev/null @@ -1,14 +0,0 @@ ---- -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-ringtail/service-valkey.yaml b/argocd/manifests/immich-ringtail/service-valkey.yaml deleted file mode 100644 index eb42d3b..0000000 --- a/argocd/manifests/immich-ringtail/service-valkey.yaml +++ /dev/null @@ -1,14 +0,0 @@ ---- -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-ringtail/service.yaml b/argocd/manifests/immich-ringtail/service.yaml deleted file mode 100644 index d35410f..0000000 --- a/argocd/manifests/immich-ringtail/service.yaml +++ /dev/null @@ -1,14 +0,0 @@ ---- -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/kingfisher/cronjob.yaml b/argocd/manifests/kingfisher/cronjob.yaml deleted file mode 100644 index 3c47528..0000000 --- a/argocd/manifests/kingfisher/cronjob.yaml +++ /dev/null @@ -1,67 +0,0 @@ ---- -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: registry.ops.eblu.me/blumeops/kingfisher:kustomized - command: ["/bin/bash", "-c"] - args: - - | - set -e - STAMP=$(date +%Y%m%d-%H%M%S) - OUTDIR=/reports/kingfisher/$(date +%Y-%m-%d) - 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/ \ - --user eblume \ - --repo-type all \ - --no-update-check \ - --tls-mode lax \ - --allow-internal-ips \ - --format 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: - 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 deleted file mode 100644 index 6f6a5f2..0000000 --- a/argocd/manifests/kingfisher/external-secret.yaml +++ /dev/null @@ -1,22 +0,0 @@ -# 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 deleted file mode 100644 index d501bbb..0000000 --- a/argocd/manifests/kingfisher/kustomization.yaml +++ /dev/null @@ -1,15 +0,0 @@ ---- -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization - -namespace: kingfisher - -resources: - - pv-nfs.yaml - - pvc.yaml - - external-secret.yaml - - cronjob.yaml - -images: - - name: registry.ops.eblu.me/blumeops/kingfisher - newTag: v165768b-0fe0eed-nix diff --git a/argocd/manifests/kingfisher/pv-nfs.yaml b/argocd/manifests/kingfisher/pv-nfs.yaml deleted file mode 100644 index 61ca4ce..0000000 --- a/argocd/manifests/kingfisher/pv-nfs.yaml +++ /dev/null @@ -1,16 +0,0 @@ -# NFS PersistentVolume for Kingfisher secret scan reports -# Reuses the same sifaka:/volume1/reports share as Prowler -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 deleted file mode 100644 index f48da95..0000000 --- a/argocd/manifests/kingfisher/pvc.yaml +++ /dev/null @@ -1,13 +0,0 @@ -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/argocd/manifests/kiwix/configmap-sync-script.yaml b/argocd/manifests/kiwix/configmap-sync-script.yaml new file mode 100644 index 0000000..5be8cae --- /dev/null +++ b/argocd/manifests/kiwix/configmap-sync-script.yaml @@ -0,0 +1,68 @@ +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: zim-torrent-sync-script + namespace: kiwix +data: + sync-zim-torrents.sh: | + #!/bin/bash + # Sync ZIM torrents from kiwix ConfigMap to Transmission + # Runs as a sidecar in the kiwix deployment + set -euo pipefail + + TORRENT_LIST="${TORRENT_LIST:-/config/torrents.txt}" + TRANSMISSION_HOST="${TRANSMISSION_HOST:-transmission.torrent.svc.cluster.local}" + TRANSMISSION_PORT="${TRANSMISSION_PORT:-9091}" + + echo "Syncing ZIM torrents to transmission at ${TRANSMISSION_HOST}:${TRANSMISSION_PORT}" + + # Wait for transmission to be ready + # Transmission RPC returns 409 on first request (to provide session ID), which is fine + echo "Waiting for Transmission RPC..." + max_attempts=30 + attempt=0 + until curl -s -o /dev/null -w "%{http_code}" "http://${TRANSMISSION_HOST}:${TRANSMISSION_PORT}/transmission/rpc" | grep -qE "^(200|409)$"; do + attempt=$((attempt + 1)) + if [[ $attempt -ge $max_attempts ]]; then + echo "Transmission not ready after ${max_attempts} attempts, will retry next cycle" + exit 0 # Don't fail, just skip this sync + fi + sleep 10 + done + echo "Transmission is ready" + + # Get current torrents from transmission + # transmission-remote returns header + data + footer, extract just torrent names + current=$(transmission-remote "${TRANSMISSION_HOST}:${TRANSMISSION_PORT}" -l 2>/dev/null | \ + tail -n +2 | head -n -1 | awk '{print $NF}' || true) + + added=0 + skipped=0 + + while IFS= read -r url || [[ -n "$url" ]]; do + # Skip empty lines and comments + [[ -z "$url" || "$url" =~ ^[[:space:]]*# ]] && continue + # Trim whitespace + url=$(echo "$url" | xargs) + [[ -z "$url" ]] && continue + + # Extract base name from URL (remove .torrent extension) + basename=$(basename "$url" .torrent) + # Also try without .zim in case transmission reports it differently + basename_no_zim="${basename%.zim}" + + # Check if already in transmission + if echo "$current" | grep -qF "$basename_no_zim"; then + ((skipped++)) || true + else + if transmission-remote "${TRANSMISSION_HOST}:${TRANSMISSION_PORT}" -a "$url" 2>/dev/null; then + echo "Added: $basename" + ((added++)) || true + else + echo "Warning: Failed to add $url" >&2 + fi + fi + done < "$TORRENT_LIST" + + echo "Sync complete: $added added, $skipped already present" diff --git a/argocd/manifests/kiwix/configmap-zim-torrents.yaml b/argocd/manifests/kiwix/configmap-zim-torrents.yaml new file mode 100644 index 0000000..c862f21 --- /dev/null +++ b/argocd/manifests/kiwix/configmap-zim-torrents.yaml @@ -0,0 +1,69 @@ +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: kiwix-zim-torrents + namespace: kiwix +data: + torrents.txt: | + # Declarative ZIM archive torrent URLs + # These are synced to transmission automatically by the kiwix sidecar + # Format: one URL per line, comments start with # + # + # Users can also add ZIM torrents manually via torrent.tail8d86e.ts.net + # and kiwix will pick them up automatically. + + # Wikipedia - Top 1M English articles (43G) + https://download.kiwix.org/zim/wikipedia/wikipedia_en_top1m_maxi_2025-09.zim.torrent + + # Project Gutenberg - Public domain books (72G) + https://download.kiwix.org/zim/gutenberg/gutenberg_en_all_2023-08.zim.torrent + + # iFixit - Repair guides (3.3G) + https://download.kiwix.org/zim/ifixit/ifixit_en_all_2025-12.zim.torrent + + # Stack Exchange + https://download.kiwix.org/zim/stack_exchange/superuser.com_en_all_2025-12.zim.torrent + https://download.kiwix.org/zim/stack_exchange/math.stackexchange.com_en_all_2025-12.zim.torrent + + # LibreTexts - Open educational resources + https://download.kiwix.org/zim/libretexts/libretexts.org_en_bio_2025-01.zim.torrent + https://download.kiwix.org/zim/libretexts/libretexts.org_en_chem_2025-01.zim.torrent + https://download.kiwix.org/zim/libretexts/libretexts.org_en_eng_2025-01.zim.torrent + https://download.kiwix.org/zim/libretexts/libretexts.org_en_math_2025-01.zim.torrent + https://download.kiwix.org/zim/libretexts/libretexts.org_en_phys_2025-01.zim.torrent + https://download.kiwix.org/zim/libretexts/libretexts.org_en_human_2025-01.zim.torrent + + # DevDocs - Programming documentation + https://download.kiwix.org/zim/devdocs/devdocs_en_bash_2026-01.zim.torrent + https://download.kiwix.org/zim/devdocs/devdocs_en_c_2026-01.zim.torrent + https://download.kiwix.org/zim/devdocs/devdocs_en_click_2026-01.zim.torrent + https://download.kiwix.org/zim/devdocs/devdocs_en_cmake_2026-01.zim.torrent + https://download.kiwix.org/zim/devdocs/devdocs_en_cpp_2026-01.zim.torrent + https://download.kiwix.org/zim/devdocs/devdocs_en_css_2026-01.zim.torrent + https://download.kiwix.org/zim/devdocs/devdocs_en_django-rest-framework_2026-01.zim.torrent + https://download.kiwix.org/zim/devdocs/devdocs_en_django_2026-01.zim.torrent + https://download.kiwix.org/zim/devdocs/devdocs_en_docker_2026-01.zim.torrent + https://download.kiwix.org/zim/devdocs/devdocs_en_duckdb_2026-01.zim.torrent + https://download.kiwix.org/zim/devdocs/devdocs_en_fish_2026-01.zim.torrent + https://download.kiwix.org/zim/devdocs/devdocs_en_gcc_2026-01.zim.torrent + https://download.kiwix.org/zim/devdocs/devdocs_en_git_2026-01.zim.torrent + https://download.kiwix.org/zim/devdocs/devdocs_en_go_2026-01.zim.torrent + https://download.kiwix.org/zim/devdocs/devdocs_en_godot_2026-01.zim.torrent + https://download.kiwix.org/zim/devdocs/devdocs_en_hammerspoon_2026-01.zim.torrent + https://download.kiwix.org/zim/devdocs/devdocs_en_homebrew_2026-01.zim.torrent + https://download.kiwix.org/zim/devdocs/devdocs_en_javascript_2026-01.zim.torrent + https://download.kiwix.org/zim/devdocs/devdocs_en_kubectl_2026-01.zim.torrent + https://download.kiwix.org/zim/devdocs/devdocs_en_kubernetes_2026-01.zim.torrent + https://download.kiwix.org/zim/devdocs/devdocs_en_latex_2026-01.zim.torrent + https://download.kiwix.org/zim/devdocs/devdocs_en_lua_2026-01.zim.torrent + https://download.kiwix.org/zim/devdocs/devdocs_en_markdown_2026-01.zim.torrent + https://download.kiwix.org/zim/devdocs/devdocs_en_nginx_2026-01.zim.torrent + https://download.kiwix.org/zim/devdocs/devdocs_en_nix_2026-01.zim.torrent + https://download.kiwix.org/zim/devdocs/devdocs_en_postgresql_2026-01.zim.torrent + https://download.kiwix.org/zim/devdocs/devdocs_en_python_2026-01.zim.torrent + https://download.kiwix.org/zim/devdocs/devdocs_en_redis_2026-01.zim.torrent + https://download.kiwix.org/zim/devdocs/devdocs_en_sqlite_2026-01.zim.torrent + https://download.kiwix.org/zim/devdocs/devdocs_en_typescript_2026-01.zim.torrent + https://download.kiwix.org/zim/devdocs/devdocs_en_werkzeug_2026-01.zim.torrent + https://download.kiwix.org/zim/devdocs/devdocs_en_zig_2026-01.zim.torrent diff --git a/argocd/manifests/kiwix/cronjob-zim-watcher.yaml b/argocd/manifests/kiwix/cronjob-zim-watcher.yaml index 9d5b558..491736f 100644 --- a/argocd/manifests/kiwix/cronjob-zim-watcher.yaml +++ b/argocd/manifests/kiwix/cronjob-zim-watcher.yaml @@ -9,16 +9,12 @@ spec: concurrencyPolicy: Forbid jobTemplate: spec: - ttlSecondsAfterFinished: 345600 # Auto-delete after 4 days template: spec: serviceAccountName: zim-watcher - securityContext: - seccompProfile: - type: RuntimeDefault containers: - name: watcher - image: registry.ops.eblu.me/blumeops/kubectl:kustomized + image: bitnami/kubectl:1.34.1 command: ["/bin/bash", "-c"] args: - | diff --git a/argocd/manifests/kiwix/deployment.yaml b/argocd/manifests/kiwix/deployment.yaml index a63fa49..ec141dc 100644 --- a/argocd/manifests/kiwix/deployment.yaml +++ b/argocd/manifests/kiwix/deployment.yaml @@ -17,16 +17,12 @@ spec: labels: app: kiwix spec: - securityContext: - seccompProfile: - type: RuntimeDefault containers: # Main kiwix-serve container - name: kiwix-serve - image: registry.ops.eblu.me/blumeops/kiwix-serve:kustomized + image: ghcr.io/kiwix/kiwix-serve:3.8.1 + command: ["/bin/sh", "-c"] args: - - "/bin/sh" - - "-c" - "kiwix-serve --port=80 /data/complete/*.zim" ports: - containerPort: 80 @@ -56,7 +52,7 @@ spec: # Sidecar: Syncs declarative ZIM torrents to transmission - name: torrent-sync - image: registry.ops.eblu.me/blumeops/transmission:kustomized + image: lscr.io/linuxserver/transmission:4.0.6 # Has transmission-remote CLI command: ["/bin/bash", "-c"] args: - | @@ -99,4 +95,4 @@ spec: - name: sync-script configMap: name: zim-torrent-sync-script - defaultMode: 0755 + defaultMode: 493 # 0755 in decimal diff --git a/argocd/manifests/kiwix/ingress-tailscale.yaml b/argocd/manifests/kiwix/ingress-tailscale.yaml index 4098400..67d96be 100644 --- a/argocd/manifests/kiwix/ingress-tailscale.yaml +++ b/argocd/manifests/kiwix/ingress-tailscale.yaml @@ -6,14 +6,6 @@ metadata: namespace: kiwix annotations: tailscale.com/proxy-class: "default" - tailscale.com/proxy-group: "ingress" - gethomepage.dev/enabled: "true" - gethomepage.dev/name: "Kiwix" - gethomepage.dev/group: "Content" - gethomepage.dev/icon: "kiwix.png" - gethomepage.dev/description: "Offline Wikipedia" - gethomepage.dev/href: "https://kiwix.ops.eblu.me" - gethomepage.dev/pod-selector: "app=kiwix" spec: ingressClassName: tailscale defaultBackend: diff --git a/argocd/manifests/kiwix/kustomization.yaml b/argocd/manifests/kiwix/kustomization.yaml index 1e11bdb..d5e563d 100644 --- a/argocd/manifests/kiwix/kustomization.yaml +++ b/argocd/manifests/kiwix/kustomization.yaml @@ -3,23 +3,9 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization namespace: kiwix resources: + - configmap-zim-torrents.yaml + - configmap-sync-script.yaml - deployment.yaml - service.yaml - ingress-tailscale.yaml - cronjob-zim-watcher.yaml - -images: - - name: registry.ops.eblu.me/blumeops/kiwix-serve - 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 - newTag: v1.34.4-613f05d - -configMapGenerator: - - name: kiwix-zim-torrents - files: - - torrents.txt - - name: zim-torrent-sync-script - files: - - sync-zim-torrents.sh diff --git a/argocd/manifests/kiwix/sync-zim-torrents.sh b/argocd/manifests/kiwix/sync-zim-torrents.sh deleted file mode 100644 index df3e2b1..0000000 --- a/argocd/manifests/kiwix/sync-zim-torrents.sh +++ /dev/null @@ -1,60 +0,0 @@ -#!/bin/bash -# Sync ZIM torrents from kiwix ConfigMap to Transmission -# Runs as a sidecar in the kiwix deployment -set -euo pipefail - -TORRENT_LIST="${TORRENT_LIST:-/config/torrents.txt}" -TRANSMISSION_HOST="${TRANSMISSION_HOST:-transmission.torrent.svc.cluster.local}" -TRANSMISSION_PORT="${TRANSMISSION_PORT:-9091}" - -echo "Syncing ZIM torrents to transmission at ${TRANSMISSION_HOST}:${TRANSMISSION_PORT}" - -# Wait for transmission to be ready -# Transmission RPC returns 409 on first request (to provide session ID), which is fine -echo "Waiting for Transmission RPC..." -max_attempts=30 -attempt=0 -until curl -s -o /dev/null -w "%{http_code}" "http://${TRANSMISSION_HOST}:${TRANSMISSION_PORT}/transmission/rpc" | grep -qE "^(200|409)$"; do - attempt=$((attempt + 1)) - if [[ $attempt -ge $max_attempts ]]; then - echo "Transmission not ready after ${max_attempts} attempts, will retry next cycle" - exit 0 # Don't fail, just skip this sync - fi - sleep 10 -done -echo "Transmission is ready" - -# Get current torrents from transmission -# transmission-remote returns header + data + footer, extract just torrent names -current=$(transmission-remote "${TRANSMISSION_HOST}:${TRANSMISSION_PORT}" -l 2>/dev/null | \ - tail -n +2 | head -n -1 | awk '{print $NF}' || true) - -added=0 -skipped=0 - -while IFS= read -r url || [[ -n "$url" ]]; do - # Skip empty lines and comments - [[ -z "$url" || "$url" =~ ^[[:space:]]*# ]] && continue - # Trim whitespace - url=$(echo "$url" | xargs) - [[ -z "$url" ]] && continue - - # Extract base name from URL (remove .torrent extension) - basename=$(basename "$url" .torrent) - # Also try without .zim in case transmission reports it differently - basename_no_zim="${basename%.zim}" - - # Check if already in transmission - if echo "$current" | grep -qF "$basename_no_zim"; then - ((skipped++)) || true - else - if transmission-remote "${TRANSMISSION_HOST}:${TRANSMISSION_PORT}" -a "$url" 2>/dev/null; then - echo "Added: $basename" - ((added++)) || true - else - echo "Warning: Failed to add $url" >&2 - fi - fi -done < "$TORRENT_LIST" - -echo "Sync complete: $added added, $skipped already present" diff --git a/argocd/manifests/kiwix/torrents.txt b/argocd/manifests/kiwix/torrents.txt deleted file mode 100644 index a7282a9..0000000 --- a/argocd/manifests/kiwix/torrents.txt +++ /dev/null @@ -1,61 +0,0 @@ -# Declarative ZIM archive torrent URLs -# These are synced to transmission automatically by the kiwix sidecar -# Format: one URL per line, comments start with # -# -# Users can also add ZIM torrents manually via torrent.tail8d86e.ts.net -# and kiwix will pick them up automatically. - -# Wikipedia - Top 1M English articles (43G) -https://download.kiwix.org/zim/wikipedia/wikipedia_en_top1m_maxi_2025-09.zim.torrent - -# Project Gutenberg - Public domain books (72G) -https://download.kiwix.org/zim/gutenberg/gutenberg_en_all_2023-08.zim.torrent - -# iFixit - Repair guides (3.3G) -https://download.kiwix.org/zim/ifixit/ifixit_en_all_2025-12.zim.torrent - -# Stack Exchange -https://download.kiwix.org/zim/stack_exchange/superuser.com_en_all_2025-12.zim.torrent -https://download.kiwix.org/zim/stack_exchange/math.stackexchange.com_en_all_2025-12.zim.torrent - -# LibreTexts - Open educational resources -https://download.kiwix.org/zim/libretexts/libretexts.org_en_bio_2025-01.zim.torrent -https://download.kiwix.org/zim/libretexts/libretexts.org_en_chem_2025-01.zim.torrent -https://download.kiwix.org/zim/libretexts/libretexts.org_en_eng_2025-01.zim.torrent -https://download.kiwix.org/zim/libretexts/libretexts.org_en_math_2025-01.zim.torrent -https://download.kiwix.org/zim/libretexts/libretexts.org_en_phys_2025-01.zim.torrent -https://download.kiwix.org/zim/libretexts/libretexts.org_en_human_2025-01.zim.torrent - -# DevDocs - Programming documentation -https://download.kiwix.org/zim/devdocs/devdocs_en_bash_2026-01.zim.torrent -https://download.kiwix.org/zim/devdocs/devdocs_en_c_2026-01.zim.torrent -https://download.kiwix.org/zim/devdocs/devdocs_en_click_2026-01.zim.torrent -https://download.kiwix.org/zim/devdocs/devdocs_en_cmake_2026-01.zim.torrent -https://download.kiwix.org/zim/devdocs/devdocs_en_cpp_2026-01.zim.torrent -https://download.kiwix.org/zim/devdocs/devdocs_en_css_2026-01.zim.torrent -https://download.kiwix.org/zim/devdocs/devdocs_en_django-rest-framework_2026-01.zim.torrent -https://download.kiwix.org/zim/devdocs/devdocs_en_django_2026-01.zim.torrent -https://download.kiwix.org/zim/devdocs/devdocs_en_docker_2026-01.zim.torrent -https://download.kiwix.org/zim/devdocs/devdocs_en_duckdb_2026-01.zim.torrent -https://download.kiwix.org/zim/devdocs/devdocs_en_fish_2026-01.zim.torrent -https://download.kiwix.org/zim/devdocs/devdocs_en_gcc_2026-01.zim.torrent -https://download.kiwix.org/zim/devdocs/devdocs_en_git_2026-01.zim.torrent -https://download.kiwix.org/zim/devdocs/devdocs_en_go_2026-01.zim.torrent -https://download.kiwix.org/zim/devdocs/devdocs_en_godot_2026-01.zim.torrent -https://download.kiwix.org/zim/devdocs/devdocs_en_hammerspoon_2026-01.zim.torrent -https://download.kiwix.org/zim/devdocs/devdocs_en_homebrew_2026-01.zim.torrent -https://download.kiwix.org/zim/devdocs/devdocs_en_javascript_2026-01.zim.torrent -https://download.kiwix.org/zim/devdocs/devdocs_en_kubectl_2026-01.zim.torrent -https://download.kiwix.org/zim/devdocs/devdocs_en_kubernetes_2026-01.zim.torrent -https://download.kiwix.org/zim/devdocs/devdocs_en_latex_2026-01.zim.torrent -https://download.kiwix.org/zim/devdocs/devdocs_en_lua_2026-01.zim.torrent -https://download.kiwix.org/zim/devdocs/devdocs_en_markdown_2026-01.zim.torrent -https://download.kiwix.org/zim/devdocs/devdocs_en_nginx_2026-01.zim.torrent -https://download.kiwix.org/zim/devdocs/devdocs_en_nix_2026-01.zim.torrent -https://download.kiwix.org/zim/devdocs/devdocs_en_postgresql_2026-01.zim.torrent -https://download.kiwix.org/zim/devdocs/devdocs_en_python_2026-01.zim.torrent -https://download.kiwix.org/zim/devdocs/devdocs_en_redis_2026-01.zim.torrent -https://download.kiwix.org/zim/devdocs/devdocs_en_sqlite_2026-01.zim.torrent -https://download.kiwix.org/zim/devdocs/devdocs_en_typescript_2026-01.zim.torrent -https://download.kiwix.org/zim/devdocs/devdocs_en_werkzeug_2026-01.zim.torrent -https://download.kiwix.org/zim/devdocs/devdocs_en_zig_2026-01.zim.torrent diff --git a/argocd/manifests/kube-state-metrics-ringtail/deployment.yaml b/argocd/manifests/kube-state-metrics-ringtail/deployment.yaml deleted file mode 100644 index ae34339..0000000 --- a/argocd/manifests/kube-state-metrics-ringtail/deployment.yaml +++ /dev/null @@ -1,53 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: kube-state-metrics - namespace: monitoring - labels: - app: kube-state-metrics -spec: - replicas: 1 - selector: - matchLabels: - app: kube-state-metrics - template: - metadata: - labels: - app: kube-state-metrics - spec: - serviceAccountName: kube-state-metrics - containers: - - name: kube-state-metrics - image: registry.k8s.io/kube-state-metrics/kube-state-metrics:kustomized - ports: - - containerPort: 8080 - name: http-metrics - - containerPort: 8081 - name: telemetry - livenessProbe: - httpGet: - path: /healthz - port: 8080 - initialDelaySeconds: 5 - timeoutSeconds: 5 - readinessProbe: - httpGet: - path: / - port: 8080 - initialDelaySeconds: 5 - timeoutSeconds: 5 - resources: - requests: - cpu: 10m - memory: 64Mi - limits: - cpu: 100m - memory: 256Mi - securityContext: - allowPrivilegeEscalation: false - readOnlyRootFilesystem: true - runAsNonRoot: true - runAsUser: 65534 - capabilities: - drop: - - ALL diff --git a/argocd/manifests/kube-state-metrics-ringtail/kustomization.yaml b/argocd/manifests/kube-state-metrics-ringtail/kustomization.yaml deleted file mode 100644 index 9d36e2d..0000000 --- a/argocd/manifests/kube-state-metrics-ringtail/kustomization.yaml +++ /dev/null @@ -1,10 +0,0 @@ -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization -resources: - - rbac.yaml - - deployment.yaml - - service.yaml -images: - - name: registry.k8s.io/kube-state-metrics/kube-state-metrics - newName: registry.ops.eblu.me/blumeops/kube-state-metrics - newTag: v2.18.0-f59f885-nix diff --git a/argocd/manifests/kube-state-metrics-ringtail/rbac.yaml b/argocd/manifests/kube-state-metrics-ringtail/rbac.yaml deleted file mode 100644 index 36193ac..0000000 --- a/argocd/manifests/kube-state-metrics-ringtail/rbac.yaml +++ /dev/null @@ -1,79 +0,0 @@ -apiVersion: v1 -kind: ServiceAccount -metadata: - name: kube-state-metrics - namespace: monitoring ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - name: kube-state-metrics -rules: - - apiGroups: [""] - resources: - - configmaps - - secrets - - nodes - - pods - - services - - serviceaccounts - - resourcequotas - - replicationcontrollers - - limitranges - - persistentvolumeclaims - - persistentvolumes - - namespaces - - endpoints - verbs: ["list", "watch"] - - apiGroups: ["apps"] - resources: - - statefulsets - - daemonsets - - deployments - - replicasets - verbs: ["list", "watch"] - - apiGroups: ["batch"] - resources: - - cronjobs - - jobs - verbs: ["list", "watch"] - - apiGroups: ["autoscaling"] - resources: - - horizontalpodautoscalers - verbs: ["list", "watch"] - - apiGroups: ["networking.k8s.io"] - resources: - - networkpolicies - - ingresses - verbs: ["list", "watch"] - - apiGroups: ["coordination.k8s.io"] - resources: - - leases - verbs: ["list", "watch"] - - apiGroups: ["certificates.k8s.io"] - resources: - - certificatesigningrequests - verbs: ["list", "watch"] - - apiGroups: ["storage.k8s.io"] - resources: - - storageclasses - - volumeattachments - verbs: ["list", "watch"] - - apiGroups: ["admissionregistration.k8s.io"] - resources: - - mutatingwebhookconfigurations - - validatingwebhookconfigurations - verbs: ["list", "watch"] ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRoleBinding -metadata: - name: kube-state-metrics -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: ClusterRole - name: kube-state-metrics -subjects: - - kind: ServiceAccount - name: kube-state-metrics - namespace: monitoring diff --git a/argocd/manifests/kube-state-metrics-ringtail/service.yaml b/argocd/manifests/kube-state-metrics-ringtail/service.yaml deleted file mode 100644 index 3a804df..0000000 --- a/argocd/manifests/kube-state-metrics-ringtail/service.yaml +++ /dev/null @@ -1,17 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: kube-state-metrics - namespace: monitoring - labels: - app: kube-state-metrics -spec: - selector: - app: kube-state-metrics - ports: - - name: http-metrics - port: 8080 - targetPort: http-metrics - - name: telemetry - port: 8081 - targetPort: telemetry diff --git a/argocd/manifests/kube-state-metrics/deployment.yaml b/argocd/manifests/kube-state-metrics/deployment.yaml index ddaf3e2..69d3bd2 100644 --- a/argocd/manifests/kube-state-metrics/deployment.yaml +++ b/argocd/manifests/kube-state-metrics/deployment.yaml @@ -18,7 +18,7 @@ spec: serviceAccountName: kube-state-metrics containers: - name: kube-state-metrics - image: registry.k8s.io/kube-state-metrics/kube-state-metrics:kustomized + image: registry.k8s.io/kube-state-metrics/kube-state-metrics:v2.13.0 ports: - containerPort: 8080 name: http-metrics @@ -51,5 +51,3 @@ spec: capabilities: drop: - ALL - seccompProfile: - type: RuntimeDefault diff --git a/argocd/manifests/kube-state-metrics/kustomization.yaml b/argocd/manifests/kube-state-metrics/kustomization.yaml index efac6ff..bc60c0b 100644 --- a/argocd/manifests/kube-state-metrics/kustomization.yaml +++ b/argocd/manifests/kube-state-metrics/kustomization.yaml @@ -4,7 +4,3 @@ resources: - rbac.yaml - deployment.yaml - service.yaml -images: - - name: registry.k8s.io/kube-state-metrics/kube-state-metrics - newName: registry.ops.eblu.me/blumeops/kube-state-metrics - newTag: v2.18.0-f59f885 diff --git a/argocd/manifests/loki/configmap.yaml b/argocd/manifests/loki/configmap.yaml new file mode 100644 index 0000000..19c516b --- /dev/null +++ b/argocd/manifests/loki/configmap.yaml @@ -0,0 +1,58 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: loki-config + namespace: monitoring +data: + loki-config.yaml: | + auth_enabled: false + + server: + http_listen_port: 3100 + http_listen_address: 0.0.0.0 + grpc_listen_port: 9096 + + common: + instance_addr: 127.0.0.1 + path_prefix: /loki + storage: + filesystem: + chunks_directory: /loki/chunks + rules_directory: /loki/rules + replication_factor: 1 + ring: + kvstore: + store: inmemory + + query_range: + results_cache: + cache: + embedded_cache: + enabled: true + max_size_mb: 100 + + schema_config: + configs: + - from: 2024-01-01 + store: tsdb + object_store: filesystem + schema: v13 + index: + prefix: index_ + period: 24h + + storage_config: + tsdb_shipper: + active_index_directory: /loki/tsdb-index + cache_location: /loki/tsdb-cache + + limits_config: + retention_period: 744h # 31 days + + compactor: + working_directory: /loki/compactor + compaction_interval: 10m + retention_enabled: true + retention_delete_delay: 2h + retention_delete_worker_count: 150 + delete_request_store: filesystem diff --git a/argocd/manifests/loki/ingress-tailscale.yaml b/argocd/manifests/loki/ingress-tailscale.yaml index 96622c5..bee0148 100644 --- a/argocd/manifests/loki/ingress-tailscale.yaml +++ b/argocd/manifests/loki/ingress-tailscale.yaml @@ -7,13 +7,11 @@ metadata: namespace: monitoring annotations: tailscale.com/funnel: "false" - tailscale.com/proxy-group: "ingress" - tailscale.com/tags: "tag:k8s,tag:flyio-target" - gethomepage.dev/enabled: "false" spec: ingressClassName: tailscale rules: - - http: + - host: loki + http: paths: - path: / pathType: Prefix diff --git a/argocd/manifests/loki/kustomization.yaml b/argocd/manifests/loki/kustomization.yaml index dc86060..1c65acb 100644 --- a/argocd/manifests/loki/kustomization.yaml +++ b/argocd/manifests/loki/kustomization.yaml @@ -4,16 +4,7 @@ kind: Kustomization namespace: monitoring resources: + - configmap.yaml - statefulset.yaml - service.yaml - ingress-tailscale.yaml - -images: - - name: grafana/loki - newName: registry.ops.eblu.me/blumeops/loki - newTag: v3.6.7-f9426b7 - -configMapGenerator: - - name: loki-config - files: - - loki-config.yaml diff --git a/argocd/manifests/loki/loki-config.yaml b/argocd/manifests/loki/loki-config.yaml deleted file mode 100644 index 41aac8e..0000000 --- a/argocd/manifests/loki/loki-config.yaml +++ /dev/null @@ -1,51 +0,0 @@ -auth_enabled: false - -server: - http_listen_port: 3100 - http_listen_address: 0.0.0.0 - grpc_listen_port: 9096 - -common: - instance_addr: 127.0.0.1 - path_prefix: /loki - storage: - filesystem: - chunks_directory: /loki/chunks - rules_directory: /loki/rules - replication_factor: 1 - ring: - kvstore: - store: inmemory - -query_range: - results_cache: - cache: - embedded_cache: - enabled: true - max_size_mb: 100 - -schema_config: - configs: - - from: 2024-01-01 - store: tsdb - object_store: filesystem - schema: v13 - index: - prefix: index_ - period: 24h - -storage_config: - tsdb_shipper: - active_index_directory: /loki/tsdb-index - cache_location: /loki/tsdb-cache - -limits_config: - retention_period: 8760h # 365 days - -compactor: - working_directory: /loki/compactor - compaction_interval: 10m - retention_enabled: true - retention_delete_delay: 2h - retention_delete_worker_count: 150 - delete_request_store: filesystem diff --git a/argocd/manifests/loki/statefulset.yaml b/argocd/manifests/loki/statefulset.yaml index a776d47..18067b4 100644 --- a/argocd/manifests/loki/statefulset.yaml +++ b/argocd/manifests/loki/statefulset.yaml @@ -18,11 +18,9 @@ spec: fsGroup: 10001 runAsNonRoot: true runAsUser: 10001 - seccompProfile: - type: RuntimeDefault containers: - name: loki - image: grafana/loki:kustomized + image: grafana/loki:3.3.2 args: - -config.file=/etc/loki/loki-config.yaml ports: @@ -65,4 +63,4 @@ spec: accessModes: ["ReadWriteOnce"] resources: requests: - storage: 20Gi # Not enforced by minikube hostpath; data grows freely on 1.8TB disk + storage: 20Gi diff --git a/argocd/manifests/mealie-ringtail/deployment.yaml b/argocd/manifests/mealie-ringtail/deployment.yaml deleted file mode 100644 index 10d06ab..0000000 --- a/argocd/manifests/mealie-ringtail/deployment.yaml +++ /dev/null @@ -1,102 +0,0 @@ -# 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 deleted file mode 100644 index 99c2793..0000000 --- a/argocd/manifests/mealie-ringtail/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-ringtail/ingress-tailscale.yaml b/argocd/manifests/mealie-ringtail/ingress-tailscale.yaml deleted file mode 100644 index a885e15..0000000 --- a/argocd/manifests/mealie-ringtail/ingress-tailscale.yaml +++ /dev/null @@ -1,25 +0,0 @@ -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-ringtail/kustomization.yaml b/argocd/manifests/mealie-ringtail/kustomization.yaml deleted file mode 100644 index ad65785..0000000 --- a/argocd/manifests/mealie-ringtail/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-tailscale.yaml - - external-secret.yaml - -images: - - name: registry.ops.eblu.me/blumeops/mealie - newTag: v3.16.0-e0057b4-nix diff --git a/argocd/manifests/mealie-ringtail/pvc.yaml b/argocd/manifests/mealie-ringtail/pvc.yaml deleted file mode 100644 index 89c38ef..0000000 --- a/argocd/manifests/mealie-ringtail/pvc.yaml +++ /dev/null @@ -1,14 +0,0 @@ -# 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/miniflux/README.md b/argocd/manifests/miniflux/README.md index b66d41b..4d093dd 100644 --- a/argocd/manifests/miniflux/README.md +++ b/argocd/manifests/miniflux/README.md @@ -16,11 +16,8 @@ RSS/Atom feed reader deployed via ArgoCD. kubectl create namespace miniflux # The miniflux user password is auto-generated by CNPG in blumeops-pg-app secret -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)" - -# Note: This secret is not managed by ExternalSecrets since the source of truth -# is the CNPG-generated secret. +kubectl create secret generic miniflux-db -n miniflux \ + --from-literal=url="$(kubectl -n databases get secret blumeops-pg-app -o jsonpath='{.data.uri}' | base64 -d)" ``` 2. Apply the ArgoCD application: diff --git a/argocd/manifests/miniflux/deployment.yaml b/argocd/manifests/miniflux/deployment.yaml index 94e805a..ab573c9 100644 --- a/argocd/manifests/miniflux/deployment.yaml +++ b/argocd/manifests/miniflux/deployment.yaml @@ -13,12 +13,9 @@ spec: labels: app: miniflux spec: - securityContext: - seccompProfile: - type: RuntimeDefault containers: - name: miniflux - image: registry.ops.eblu.me/blumeops/miniflux:kustomized + image: ghcr.io/miniflux/miniflux:2.2.16 ports: - containerPort: 8080 env: diff --git a/argocd/manifests/miniflux/ingress-tailscale.yaml b/argocd/manifests/miniflux/ingress-tailscale.yaml index c6807ea..8884c61 100644 --- a/argocd/manifests/miniflux/ingress-tailscale.yaml +++ b/argocd/manifests/miniflux/ingress-tailscale.yaml @@ -5,18 +5,6 @@ metadata: namespace: miniflux annotations: tailscale.com/proxy-class: "default" - tailscale.com/proxy-group: "ingress" - gethomepage.dev/enabled: "true" - gethomepage.dev/name: "Miniflux" - gethomepage.dev/group: "Content" - gethomepage.dev/icon: "miniflux.png" - gethomepage.dev/description: "RSS reader" - gethomepage.dev/href: "https://feed.ops.eblu.me" - gethomepage.dev/pod-selector: "app=miniflux" - 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: diff --git a/argocd/manifests/miniflux/kustomization.yaml b/argocd/manifests/miniflux/kustomization.yaml index 1acc708..80927eb 100644 --- a/argocd/manifests/miniflux/kustomization.yaml +++ b/argocd/manifests/miniflux/kustomization.yaml @@ -7,7 +7,3 @@ resources: - deployment.yaml - service.yaml - ingress-tailscale.yaml - -images: - - name: registry.ops.eblu.me/blumeops/miniflux - newTag: v2.2.19-352b95c diff --git a/argocd/manifests/miniflux/secret-db.yaml.tpl b/argocd/manifests/miniflux/secret-db.yaml.tpl new file mode 100644 index 0000000..462e407 --- /dev/null +++ b/argocd/manifests/miniflux/secret-db.yaml.tpl @@ -0,0 +1,13 @@ +# Miniflux database connection secret +# +# The miniflux user password is auto-generated by CloudNativePG and stored in +# blumeops-pg-app secret in the databases namespace. To create this secret: +# +# 1. Get the URI from CNPG secret: +# kubectl -n databases get secret blumeops-pg-app -o jsonpath='{.data.uri}' | base64 -d +# +# 2. Create the secret (one-liner): +# kubectl create secret generic miniflux-db -n miniflux \ +# --from-literal=url="$(kubectl -n databases get secret blumeops-pg-app -o jsonpath='{.data.uri}' | base64 -d)" +# +# Note: Uses internal k8s DNS hostname (blumeops-pg-rw.databases) not Tailscale diff --git a/argocd/manifests/navidrome/deployment.yaml b/argocd/manifests/navidrome/deployment.yaml deleted file mode 100644 index e70519c..0000000 --- a/argocd/manifests/navidrome/deployment.yaml +++ /dev/null @@ -1,72 +0,0 @@ ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: navidrome - namespace: navidrome -spec: - replicas: 1 - selector: - matchLabels: - app: navidrome - template: - metadata: - labels: - app: navidrome - spec: - securityContext: - runAsUser: 1000 - runAsGroup: 1000 - fsGroup: 1000 - seccompProfile: - type: RuntimeDefault - containers: - - name: navidrome - image: registry.ops.eblu.me/blumeops/navidrome:kustomized - securityContext: - runAsNonRoot: true - allowPrivilegeEscalation: false - ports: - - containerPort: 4533 - name: http - env: - - name: ND_SCANNER_SCHEDULE - value: "@every 1h" - - name: ND_LOGLEVEL - value: "info" - - name: ND_MUSICFOLDER - value: "/music" - - name: ND_DATAFOLDER - value: "/data" - volumeMounts: - - name: music - mountPath: /music - readOnly: true - - name: data - mountPath: /data - resources: - requests: - memory: "128Mi" - cpu: "100m" - limits: - memory: "512Mi" - cpu: "500m" - livenessProbe: - httpGet: - path: /ping - port: 4533 - initialDelaySeconds: 10 - periodSeconds: 30 - readinessProbe: - httpGet: - path: /ping - port: 4533 - initialDelaySeconds: 5 - periodSeconds: 10 - volumes: - - name: music - persistentVolumeClaim: - claimName: navidrome-music - - name: data - persistentVolumeClaim: - claimName: navidrome-data diff --git a/argocd/manifests/navidrome/ingress-tailscale.yaml b/argocd/manifests/navidrome/ingress-tailscale.yaml deleted file mode 100644 index 7264086..0000000 --- a/argocd/manifests/navidrome/ingress-tailscale.yaml +++ /dev/null @@ -1,31 +0,0 @@ ---- -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: navidrome-tailscale - namespace: navidrome - annotations: - tailscale.com/proxy-class: "default" - tailscale.com/proxy-group: "ingress" - gethomepage.dev/enabled: "true" - gethomepage.dev/name: "DJ" - gethomepage.dev/group: "Home" - gethomepage.dev/icon: "navidrome.png" - gethomepage.dev/description: "Music streaming server" - gethomepage.dev/href: "https://dj.ops.eblu.me" - gethomepage.dev/pod-selector: "app=navidrome" - gethomepage.dev/widget.type: "navidrome" - gethomepage.dev/widget.url: "https://dj.ops.eblu.me" - gethomepage.dev/widget.user: "{{HOMEPAGE_VAR_NAVIDROME_USER}}" - gethomepage.dev/widget.token: "{{HOMEPAGE_VAR_NAVIDROME_TOKEN}}" - gethomepage.dev/widget.salt: "{{HOMEPAGE_VAR_NAVIDROME_SALT}}" -spec: - ingressClassName: tailscale - defaultBackend: - service: - name: navidrome - port: - number: 4533 - tls: - - hosts: - - dj diff --git a/argocd/manifests/navidrome/kustomization.yaml b/argocd/manifests/navidrome/kustomization.yaml deleted file mode 100644 index 41689f4..0000000 --- a/argocd/manifests/navidrome/kustomization.yaml +++ /dev/null @@ -1,14 +0,0 @@ ---- -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization -namespace: navidrome -resources: - - pv-nfs.yaml - - pvc-music.yaml - - pvc-data.yaml - - deployment.yaml - - service.yaml - - ingress-tailscale.yaml -images: - - name: registry.ops.eblu.me/blumeops/navidrome - newTag: v0.61.1-3ecd888 diff --git a/argocd/manifests/navidrome/pv-nfs.yaml b/argocd/manifests/navidrome/pv-nfs.yaml deleted file mode 100644 index a6e09a4..0000000 --- a/argocd/manifests/navidrome/pv-nfs.yaml +++ /dev/null @@ -1,22 +0,0 @@ -# NFS PersistentVolume for Navidrome music library -# Requires: NFS share on sifaka at /volume1/music with NFS permissions for indri -# -# To create on Synology: -# 1. Control Panel > Shared Folder > Create -# 2. Name: music, Location: Volume 1 -# 3. Control Panel > File Services > NFS > NFS Rules -# 4. Add rule for "music" share: Hostname=indri, Privilege=Read-Only, Squash=No mapping -apiVersion: v1 -kind: PersistentVolume -metadata: - name: navidrome-music-nfs-pv -spec: - capacity: - storage: 1Ti - accessModes: - - ReadOnlyMany - persistentVolumeReclaimPolicy: Retain - storageClassName: "" - nfs: - server: sifaka - path: /volume1/music diff --git a/argocd/manifests/navidrome/pvc-data.yaml b/argocd/manifests/navidrome/pvc-data.yaml deleted file mode 100644 index 67fc8fb..0000000 --- a/argocd/manifests/navidrome/pvc-data.yaml +++ /dev/null @@ -1,13 +0,0 @@ -# PersistentVolumeClaim for Navidrome data (SQLite database, config, cache) -# Uses minikube's default storage class for local provisioning -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: navidrome-data - namespace: navidrome -spec: - accessModes: - - ReadWriteOnce - resources: - requests: - storage: 10Gi diff --git a/argocd/manifests/navidrome/pvc-music.yaml b/argocd/manifests/navidrome/pvc-music.yaml deleted file mode 100644 index 9fd047f..0000000 --- a/argocd/manifests/navidrome/pvc-music.yaml +++ /dev/null @@ -1,15 +0,0 @@ -# PersistentVolumeClaim for Navidrome music library -# Binds to the NFS PV for sifaka:/volume1/music -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: navidrome-music - namespace: navidrome -spec: - accessModes: - - ReadOnlyMany - storageClassName: "" - volumeName: navidrome-music-nfs-pv - resources: - requests: - storage: 1Ti diff --git a/argocd/manifests/navidrome/service.yaml b/argocd/manifests/navidrome/service.yaml deleted file mode 100644 index a1d25e2..0000000 --- a/argocd/manifests/navidrome/service.yaml +++ /dev/null @@ -1,13 +0,0 @@ ---- -apiVersion: v1 -kind: Service -metadata: - name: navidrome - namespace: navidrome -spec: - selector: - app: navidrome - ports: - - name: http - port: 4533 - targetPort: 4533 diff --git a/argocd/manifests/ntfy/deployment.yaml b/argocd/manifests/ntfy/deployment.yaml deleted file mode 100644 index a41387f..0000000 --- a/argocd/manifests/ntfy/deployment.yaml +++ /dev/null @@ -1,57 +0,0 @@ ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: ntfy - namespace: ntfy -spec: - replicas: 1 - selector: - matchLabels: - app: ntfy - template: - metadata: - labels: - app: ntfy - spec: - securityContext: - seccompProfile: - type: RuntimeDefault - containers: - - name: ntfy - image: registry.ops.eblu.me/blumeops/ntfy:kustomized - args: ["serve", "--config", "/etc/ntfy/server.yml"] - ports: - - containerPort: 80 - name: http - volumeMounts: - - name: config - mountPath: /etc/ntfy/server.yml - subPath: server.yml - - name: cache - mountPath: /var/cache/ntfy - resources: - requests: - memory: "32Mi" - cpu: "50m" - limits: - memory: "256Mi" - cpu: "200m" - livenessProbe: - httpGet: - path: /v1/health - port: 80 - initialDelaySeconds: 5 - periodSeconds: 30 - readinessProbe: - httpGet: - path: /v1/health - port: 80 - initialDelaySeconds: 3 - periodSeconds: 10 - volumes: - - name: config - configMap: - name: ntfy-config - - name: cache - emptyDir: {} diff --git a/argocd/manifests/ntfy/ingress-tailscale.yaml b/argocd/manifests/ntfy/ingress-tailscale.yaml deleted file mode 100644 index fff1731..0000000 --- a/argocd/manifests/ntfy/ingress-tailscale.yaml +++ /dev/null @@ -1,26 +0,0 @@ ---- -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: ntfy-tailscale - namespace: ntfy - annotations: - tailscale.com/proxy-class: "default" - tailscale.com/proxy-group: "ingress" - gethomepage.dev/enabled: "true" - gethomepage.dev/name: "Ntfy" - gethomepage.dev/group: "Infrastructure" - gethomepage.dev/icon: "ntfy.png" - gethomepage.dev/description: "Push notifications" - gethomepage.dev/href: "https://ntfy.ops.eblu.me" - gethomepage.dev/pod-selector: "app=ntfy" -spec: - ingressClassName: tailscale - defaultBackend: - service: - name: ntfy - port: - number: 80 - tls: - - hosts: - - ntfy diff --git a/argocd/manifests/ntfy/kustomization.yaml b/argocd/manifests/ntfy/kustomization.yaml deleted file mode 100644 index 7f81c6d..0000000 --- a/argocd/manifests/ntfy/kustomization.yaml +++ /dev/null @@ -1,15 +0,0 @@ ---- -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization -namespace: ntfy -resources: - - deployment.yaml - - service.yaml - - ingress-tailscale.yaml -images: - - name: registry.ops.eblu.me/blumeops/ntfy - newTag: v2.19.2-fd0bebb-nix -configMapGenerator: - - name: ntfy-config - files: - - server.yml diff --git a/argocd/manifests/ntfy/server.yml b/argocd/manifests/ntfy/server.yml deleted file mode 100644 index 0f53b0c..0000000 --- a/argocd/manifests/ntfy/server.yml +++ /dev/null @@ -1,6 +0,0 @@ -base-url: https://ntfy.ops.eblu.me -upstream-base-url: https://ntfy.sh -attachment-cache-dir: /var/cache/ntfy/attachments -attachment-total-size-limit: 1G -attachment-file-size-limit: 10M -attachment-expiry-duration: 24h diff --git a/argocd/manifests/ntfy/service.yaml b/argocd/manifests/ntfy/service.yaml deleted file mode 100644 index 7ed6ffb..0000000 --- a/argocd/manifests/ntfy/service.yaml +++ /dev/null @@ -1,13 +0,0 @@ ---- -apiVersion: v1 -kind: Service -metadata: - name: ntfy - namespace: ntfy -spec: - selector: - app: ntfy - ports: - - name: http - port: 80 - targetPort: 80 diff --git a/argocd/manifests/nvidia-device-plugin/daemonset.yaml b/argocd/manifests/nvidia-device-plugin/daemonset.yaml deleted file mode 100644 index 04431f3..0000000 --- a/argocd/manifests/nvidia-device-plugin/daemonset.yaml +++ /dev/null @@ -1,58 +0,0 @@ ---- -apiVersion: apps/v1 -kind: DaemonSet -metadata: - name: nvidia-device-plugin - namespace: nvidia-device-plugin - labels: - app: nvidia-device-plugin -spec: - selector: - matchLabels: - app: nvidia-device-plugin - template: - metadata: - labels: - app: nvidia-device-plugin - spec: - tolerations: - - key: nvidia.com/gpu - operator: Exists - effect: NoSchedule - priorityClassName: system-node-critical - containers: - - name: nvidia-device-plugin - image: nvcr.io/nvidia/k8s-device-plugin:kustomized - args: - - --device-id-strategy=index - - --config-file=/config/config.yaml - env: - - name: LD_LIBRARY_PATH - value: /run/nvidia/lib - securityContext: - privileged: true - volumeMounts: - - name: device-plugins - mountPath: /var/lib/kubelet/device-plugins - - name: cdi-specs - mountPath: /var/run/cdi - readOnly: true - - name: nvidia-libs - mountPath: /run/nvidia/lib - readOnly: true - - name: plugin-config - mountPath: /config - readOnly: true - volumes: - - name: device-plugins - hostPath: - path: /var/lib/kubelet/device-plugins - - name: cdi-specs - hostPath: - path: /var/run/cdi - - name: nvidia-libs - hostPath: - path: /etc/nvidia-driver/lib - - name: plugin-config - configMap: - name: nvidia-device-plugin-config diff --git a/argocd/manifests/nvidia-device-plugin/kustomization.yaml b/argocd/manifests/nvidia-device-plugin/kustomization.yaml deleted file mode 100644 index f5a33ae..0000000 --- a/argocd/manifests/nvidia-device-plugin/kustomization.yaml +++ /dev/null @@ -1,13 +0,0 @@ -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization - -namespace: nvidia-device-plugin - -resources: - - daemonset.yaml - - runtime-class.yaml - - time-slicing-config.yaml - -images: - - name: nvcr.io/nvidia/k8s-device-plugin - newTag: v0.19.2 diff --git a/argocd/manifests/nvidia-device-plugin/runtime-class.yaml b/argocd/manifests/nvidia-device-plugin/runtime-class.yaml deleted file mode 100644 index 7ba6add..0000000 --- a/argocd/manifests/nvidia-device-plugin/runtime-class.yaml +++ /dev/null @@ -1,6 +0,0 @@ ---- -apiVersion: node.k8s.io/v1 -kind: RuntimeClass -metadata: - name: nvidia -handler: nvidia diff --git a/argocd/manifests/nvidia-device-plugin/time-slicing-config.yaml b/argocd/manifests/nvidia-device-plugin/time-slicing-config.yaml deleted file mode 100644 index 100e7a9..0000000 --- a/argocd/manifests/nvidia-device-plugin/time-slicing-config.yaml +++ /dev/null @@ -1,14 +0,0 @@ ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: nvidia-device-plugin-config - namespace: nvidia-device-plugin -data: - config.yaml: | - version: v1 - sharing: - timeSlicing: - resources: - - name: nvidia.com/gpu - replicas: 4 diff --git a/argocd/manifests/ollama/deployment.yaml b/argocd/manifests/ollama/deployment.yaml deleted file mode 100644 index e8864c9..0000000 --- a/argocd/manifests/ollama/deployment.yaml +++ /dev/null @@ -1,93 +0,0 @@ ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: ollama - namespace: ollama -spec: - replicas: 1 - strategy: - type: Recreate - selector: - matchLabels: - app: ollama - template: - metadata: - labels: - app: ollama - spec: - runtimeClassName: nvidia - securityContext: - seccompProfile: - type: RuntimeDefault - containers: - - name: ollama - image: ollama/ollama:kustomized - ports: - - containerPort: 11434 - name: http - env: - - name: OLLAMA_MODELS - value: /models - - name: OLLAMA_HOST - value: "0.0.0.0:11434" - - name: OLLAMA_MAX_LOADED_MODELS - value: "1" - - name: OLLAMA_NUM_PARALLEL - value: "1" - - name: OLLAMA_FLASH_ATTENTION - value: "1" - volumeMounts: - - name: models - mountPath: /models - resources: - requests: - memory: "512Mi" - cpu: "500m" - limits: - memory: "24Gi" - cpu: "4000m" - nvidia.com/gpu: "1" - livenessProbe: - httpGet: - path: /api/tags - port: 11434 - initialDelaySeconds: 30 - periodSeconds: 30 - readinessProbe: - httpGet: - path: /api/tags - port: 11434 - initialDelaySeconds: 10 - periodSeconds: 10 - - name: model-sync - image: ollama/ollama:kustomized - command: ["/bin/bash", "/scripts/sync-models.sh"] - env: - - name: MODEL_LIST - value: /config/models.txt - - name: OLLAMA_HOST - value: "http://localhost:11434" - volumeMounts: - - name: models-config - mountPath: /config - - name: sync-script - mountPath: /scripts - resources: - requests: - memory: "64Mi" - cpu: "50m" - limits: - memory: "256Mi" - cpu: "200m" - volumes: - - name: models - persistentVolumeClaim: - claimName: ollama-models - - name: models-config - configMap: - name: ollama-models - - name: sync-script - configMap: - name: ollama-sync-script - defaultMode: 0755 diff --git a/argocd/manifests/ollama/ingress-tailscale.yaml b/argocd/manifests/ollama/ingress-tailscale.yaml deleted file mode 100644 index bada466..0000000 --- a/argocd/manifests/ollama/ingress-tailscale.yaml +++ /dev/null @@ -1,26 +0,0 @@ ---- -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: ollama-tailscale - namespace: ollama - annotations: - tailscale.com/proxy-class: "default" - tailscale.com/proxy-group: "ingress" - gethomepage.dev/enabled: "true" - gethomepage.dev/name: "Ollama" - gethomepage.dev/group: "AI" - gethomepage.dev/icon: "ollama.png" - gethomepage.dev/description: "LLM inference server" - gethomepage.dev/href: "https://ollama.ops.eblu.me" - gethomepage.dev/pod-selector: "app=ollama" -spec: - ingressClassName: tailscale - defaultBackend: - service: - name: ollama - port: - number: 11434 - tls: - - hosts: - - ollama diff --git a/argocd/manifests/ollama/kustomization.yaml b/argocd/manifests/ollama/kustomization.yaml deleted file mode 100644 index fd54eec..0000000 --- a/argocd/manifests/ollama/kustomization.yaml +++ /dev/null @@ -1,22 +0,0 @@ ---- -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization -namespace: ollama -resources: - - pv-hostpath.yaml - - pvc.yaml - - deployment.yaml - - service.yaml - - ingress-tailscale.yaml - -images: - - name: ollama/ollama - newTag: "0.20.4" - -configMapGenerator: - - name: ollama-models - files: - - models.txt - - name: ollama-sync-script - files: - - sync-models.sh diff --git a/argocd/manifests/ollama/models.txt b/argocd/manifests/ollama/models.txt deleted file mode 100644 index 856618d..0000000 --- a/argocd/manifests/ollama/models.txt +++ /dev/null @@ -1,8 +0,0 @@ -# Models to pull from Ollama registry -# One model per line. Comments with #. -qwen2.5:14b -deepseek-r1:14b -phi4:14b -gemma3:12b -qwen3.5:9b -qwen3.5:27b diff --git a/argocd/manifests/ollama/pv-hostpath.yaml b/argocd/manifests/ollama/pv-hostpath.yaml deleted file mode 100644 index d25dbcc..0000000 --- a/argocd/manifests/ollama/pv-hostpath.yaml +++ /dev/null @@ -1,15 +0,0 @@ ---- -apiVersion: v1 -kind: PersistentVolume -metadata: - name: ollama-models-pv -spec: - capacity: - storage: 200Gi - accessModes: - - ReadWriteOnce - persistentVolumeReclaimPolicy: Retain - storageClassName: "" - hostPath: - path: /mnt/storage1/ollama - type: DirectoryOrCreate diff --git a/argocd/manifests/ollama/pvc.yaml b/argocd/manifests/ollama/pvc.yaml deleted file mode 100644 index 76c79a8..0000000 --- a/argocd/manifests/ollama/pvc.yaml +++ /dev/null @@ -1,14 +0,0 @@ ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: ollama-models - namespace: ollama -spec: - accessModes: - - ReadWriteOnce - storageClassName: "" - volumeName: ollama-models-pv - resources: - requests: - storage: 200Gi diff --git a/argocd/manifests/ollama/service.yaml b/argocd/manifests/ollama/service.yaml deleted file mode 100644 index d9680e1..0000000 --- a/argocd/manifests/ollama/service.yaml +++ /dev/null @@ -1,13 +0,0 @@ ---- -apiVersion: v1 -kind: Service -metadata: - name: ollama - namespace: ollama -spec: - selector: - app: ollama - ports: - - name: http - port: 11434 - targetPort: 11434 diff --git a/argocd/manifests/ollama/sync-models.sh b/argocd/manifests/ollama/sync-models.sh deleted file mode 100644 index 9430704..0000000 --- a/argocd/manifests/ollama/sync-models.sh +++ /dev/null @@ -1,61 +0,0 @@ -#!/bin/bash -# Sync models from ConfigMap to Ollama server -# Runs as a sidecar in the ollama deployment, using the ollama CLI -set -euo pipefail - -MODEL_LIST="${MODEL_LIST:-/config/models.txt}" -OLLAMA_HOST="${OLLAMA_HOST:-http://localhost:11434}" -SYNC_INTERVAL="${SYNC_INTERVAL:-1800}" - -export OLLAMA_HOST - -echo "Syncing models from ${MODEL_LIST} via ollama CLI (host: ${OLLAMA_HOST})" - -while true; do - # Wait for ollama server to be ready - echo "Waiting for Ollama API..." - max_attempts=60 - attempt=0 - until ollama list > /dev/null 2>&1; do - attempt=$((attempt + 1)) - if [[ $attempt -ge $max_attempts ]]; then - echo "Ollama not ready after ${max_attempts} attempts, will retry next cycle" - sleep "$SYNC_INTERVAL" - continue 2 - fi - sleep 5 - done - echo "Ollama is ready" - - # Get list of currently pulled models - current=$(ollama list 2>/dev/null | tail -n +2 | awk '{print $1}' || true) - - pulled=0 - skipped=0 - - while IFS= read -r model || [[ -n "$model" ]]; do - # Skip empty lines and comments - [[ -z "$model" || "$model" =~ ^[[:space:]]*# ]] && continue - # Trim whitespace - model=$(echo "$model" | xargs) - [[ -z "$model" ]] && continue - - # Check if model is already pulled (ollama list shows name:tag) - if echo "$current" | grep -qF "$model"; then - echo "Already present: $model" - ((skipped++)) || true - else - echo "Pulling: $model" - if ollama pull "$model"; then - echo "Pulled: $model" - ((pulled++)) || true - else - echo "Warning: Failed to pull $model" >&2 - fi - fi - done < "$MODEL_LIST" - - echo "Sync complete: $pulled pulled, $skipped already present" - echo "Next sync in ${SYNC_INTERVAL}s" - sleep "$SYNC_INTERVAL" -done diff --git a/argocd/manifests/paperless-ringtail/deployment.yaml b/argocd/manifests/paperless-ringtail/deployment.yaml deleted file mode 100644 index de4f456..0000000 --- a/argocd/manifests/paperless-ringtail/deployment.yaml +++ /dev/null @@ -1,201 +0,0 @@ -# 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 deleted file mode 100644 index 750b7c5..0000000 --- a/argocd/manifests/paperless-ringtail/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-ringtail/ingress-tailscale.yaml b/argocd/manifests/paperless-ringtail/ingress-tailscale.yaml deleted file mode 100644 index d09ef67..0000000 --- a/argocd/manifests/paperless-ringtail/ingress-tailscale.yaml +++ /dev/null @@ -1,25 +0,0 @@ -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-ringtail/kustomization.yaml b/argocd/manifests/paperless-ringtail/kustomization.yaml deleted file mode 100644 index 41665b8..0000000 --- a/argocd/manifests/paperless-ringtail/kustomization.yaml +++ /dev/null @@ -1,21 +0,0 @@ -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-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 - 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 deleted file mode 100644 index 2990d1a..0000000 --- a/argocd/manifests/paperless-ringtail/pv-nfs.yaml +++ /dev/null @@ -1,22 +0,0 @@ -# NFS PersistentVolume for the Paperless document library, mounted from -# ringtail. Same sifaka export (/volume1/paperless) as the minikube PV, -# but a distinct PV name so both clusters can declare it during the -# parallel-run before cutover. -# -# Prerequisite: sifaka must have an NFS rule granting ringtail Read/Write -# (Squash=No mapping) on the paperless share — the same step done for -# immich. See [[sifaka-nfs-from-ringtail]]. -apiVersion: v1 -kind: PersistentVolume -metadata: - name: paperless-media-nfs-pv-ringtail -spec: - capacity: - storage: 500Gi - accessModes: - - ReadWriteMany - persistentVolumeReclaimPolicy: Retain - storageClassName: "" - nfs: - server: sifaka - path: /volume1/paperless diff --git a/argocd/manifests/paperless-ringtail/pvc.yaml b/argocd/manifests/paperless-ringtail/pvc.yaml deleted file mode 100644 index 8b44660..0000000 --- a/argocd/manifests/paperless-ringtail/pvc.yaml +++ /dev/null @@ -1,15 +0,0 @@ -# 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 deleted file mode 100644 index cff2972..0000000 --- a/argocd/manifests/paperless-ringtail/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/prometheus/configmap.yaml b/argocd/manifests/prometheus/configmap.yaml new file mode 100644 index 0000000..cc43999 --- /dev/null +++ b/argocd/manifests/prometheus/configmap.yaml @@ -0,0 +1,43 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: prometheus-config + namespace: monitoring +data: + prometheus.yml: | + global: + scrape_interval: 15s + evaluation_interval: 15s + + # Indri system metrics are pushed via Alloy remote_write + # K8s services are scraped directly + + scrape_configs: + # Sifaka NAS node-exporter (via LAN - Docker NATs through indri) + # Using LAN IP since k8s pods can reach LAN via Docker NAT (same as NFS mounts) + # If IP changes, fallback: create Tailscale egress in tailscale-operator/egress-sifaka.yaml + - job_name: "node-exporter-sifaka" + static_configs: + - targets: ["192.168.1.203:9100"] + + # CNPG PostgreSQL metrics (k8s internal) + - job_name: "cnpg-postgres" + static_configs: + - targets: ["blumeops-pg-metrics-tailscale.databases.svc.cluster.local:9187"] + labels: + instance: "blumeops-pg" + + # Prometheus self-monitoring + - job_name: "prometheus" + static_configs: + - targets: ["localhost:9090"] + + # Loki metrics + - job_name: "loki" + static_configs: + - targets: ["loki.monitoring.svc.cluster.local:3100"] + + # Kubernetes state metrics (pods, deployments, resource usage, etc.) + - job_name: "kube-state-metrics" + static_configs: + - targets: ["kube-state-metrics.monitoring.svc.cluster.local:8080"] diff --git a/argocd/manifests/prometheus/ingress-tailscale.yaml b/argocd/manifests/prometheus/ingress-tailscale.yaml index 7395e09..1aeaa34 100644 --- a/argocd/manifests/prometheus/ingress-tailscale.yaml +++ b/argocd/manifests/prometheus/ingress-tailscale.yaml @@ -7,19 +7,11 @@ metadata: namespace: monitoring annotations: tailscale.com/funnel: "false" - tailscale.com/proxy-group: "ingress" - tailscale.com/tags: "tag:k8s,tag:flyio-target" - gethomepage.dev/enabled: "true" - gethomepage.dev/name: "Prometheus" - gethomepage.dev/group: "Infrastructure" - gethomepage.dev/icon: "prometheus.png" - gethomepage.dev/description: "Metrics storage" - gethomepage.dev/href: "https://prometheus.ops.eblu.me" - gethomepage.dev/pod-selector: "app=prometheus" spec: ingressClassName: tailscale rules: - - http: + - host: prometheus + http: paths: - path: / pathType: Prefix diff --git a/argocd/manifests/prometheus/kustomization.yaml b/argocd/manifests/prometheus/kustomization.yaml index f9fc21a..1c65acb 100644 --- a/argocd/manifests/prometheus/kustomization.yaml +++ b/argocd/manifests/prometheus/kustomization.yaml @@ -4,15 +4,7 @@ kind: Kustomization namespace: monitoring resources: + - configmap.yaml - statefulset.yaml - service.yaml - ingress-tailscale.yaml - -images: - - name: registry.ops.eblu.me/blumeops/prometheus - newTag: v3.10.0-613f05d - -configMapGenerator: - - name: prometheus-config - files: - - prometheus.yml diff --git a/argocd/manifests/prometheus/prometheus.yml b/argocd/manifests/prometheus/prometheus.yml deleted file mode 100644 index f96ce12..0000000 --- a/argocd/manifests/prometheus/prometheus.yml +++ /dev/null @@ -1,99 +0,0 @@ -global: - scrape_interval: 15s - evaluation_interval: 15s - -# Indri system metrics are pushed via Alloy remote_write -# K8s services are scraped directly - -scrape_configs: - # Sifaka NAS exporters (via Caddy L4 TCP proxy on indri) - - job_name: "node-exporter-sifaka" - static_configs: - - targets: ["nas.ops.eblu.me:9100"] - metric_relabel_configs: - - target_label: cluster - replacement: indri - - - job_name: "smartctl-sifaka" - scrape_interval: 60s - static_configs: - - targets: ["nas.ops.eblu.me:9633"] - metric_relabel_configs: - - target_label: cluster - replacement: indri - - # CNPG PostgreSQL metrics (k8s internal) - - job_name: "cnpg-postgres" - static_configs: - - targets: ["blumeops-pg-metrics-tailscale.databases.svc.cluster.local:9187"] - labels: - instance: "blumeops-pg" - metric_relabel_configs: - - target_label: cluster - replacement: indri - - # Prometheus self-monitoring - - job_name: "prometheus" - static_configs: - - targets: ["localhost:9090"] - metric_relabel_configs: - - target_label: cluster - replacement: indri - - # Loki metrics - - job_name: "loki" - static_configs: - - targets: ["loki.monitoring.svc.cluster.local:3100"] - metric_relabel_configs: - - target_label: cluster - replacement: indri - - # Kubernetes state metrics (pods, deployments, resource usage, etc.) - - job_name: "kube-state-metrics" - static_configs: - - targets: ["kube-state-metrics.monitoring.svc.cluster.local:8080"] - metric_relabel_configs: - - target_label: cluster - replacement: indri - - # Transmission BitTorrent metrics (via sidecar exporter) - - job_name: "transmission" - static_configs: - - targets: ["transmission.torrent.svc.cluster.local:19091"] - metric_relabel_configs: - - target_label: cluster - replacement: indri - - # Tempo operational metrics - - job_name: "tempo" - static_configs: - - targets: ["tempo.monitoring.svc.cluster.local:3200"] - metric_relabel_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 - - # 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 - static_configs: - - targets: ["nvr.ops.eblu.me"] - metrics_path: /api/metrics - metric_relabel_configs: - - target_label: cluster - replacement: ringtail diff --git a/argocd/manifests/prometheus/statefulset.yaml b/argocd/manifests/prometheus/statefulset.yaml index 8a8e06f..651451f 100644 --- a/argocd/manifests/prometheus/statefulset.yaml +++ b/argocd/manifests/prometheus/statefulset.yaml @@ -18,15 +18,13 @@ spec: fsGroup: 65534 runAsNonRoot: true runAsUser: 65534 - seccompProfile: - type: RuntimeDefault containers: - name: prometheus - image: registry.ops.eblu.me/blumeops/prometheus:kustomized + image: prom/prometheus:v3.2.1 args: - --config.file=/etc/prometheus/prometheus.yml - --storage.tsdb.path=/prometheus - - --storage.tsdb.retention.time=3650d + - --storage.tsdb.retention.time=15d - --web.enable-remote-write-receiver - --web.enable-lifecycle ports: @@ -67,4 +65,4 @@ spec: accessModes: ["ReadWriteOnce"] resources: requests: - storage: 20Gi # Not enforced by minikube hostpath; data grows freely on 1.8TB disk + storage: 20Gi diff --git a/argocd/manifests/prowler/cronjob.yaml b/argocd/manifests/prowler/cronjob.yaml deleted file mode 100644 index 95b7dee..0000000 --- a/argocd/manifests/prowler/cronjob.yaml +++ /dev/null @@ -1,88 +0,0 @@ -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 - 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 - command: ["/bin/sh", "-c"] - args: - - | - 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 - - name: mutelist-merged - mountPath: /tmp/mutelist - readOnly: true - - 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: mutelist-parts - configMap: - name: prowler-mutelist - - name: mutelist-merged - emptyDir: {} - - 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 deleted file mode 100644 index 38295a3..0000000 --- a/argocd/manifests/prowler/kustomization.yaml +++ /dev/null @@ -1,27 +0,0 @@ ---- -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization - -namespace: prowler - -resources: - - serviceaccount.yaml - - rbac.yaml - - pv-nfs.yaml - - pvc.yaml - - cronjob.yaml - -configMapGenerator: - - name: prowler-mutelist - options: - disableNameSuffixHash: true - files: - - mutelist/apiserver.yaml - - mutelist/control-plane.yaml - - mutelist/core-pod-security.yaml - - mutelist/manual-node-checks.yaml - - mutelist/rbac.yaml - -images: - - name: registry.ops.eblu.me/blumeops/prowler - newTag: v5.23.0-495e45d diff --git a/argocd/manifests/prowler/mutelist/apiserver.yaml b/argocd/manifests/prowler/mutelist/apiserver.yaml deleted file mode 100644 index fd077e8..0000000 --- a/argocd/manifests/prowler/mutelist/apiserver.yaml +++ /dev/null @@ -1,53 +0,0 @@ -# Minikube apiserver — flags managed by static pod manifests. -Mutelist: - Accounts: - "*": - Checks: - "apiserver_always_pull_images_plugin": - Regions: ["*"] - Resources: ["^kube-apiserver-minikube$"] - 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: "Alloy/Loki provides pod-level audit trail." - "apiserver_audit_log_maxbackup_set": - Regions: ["*"] - Resources: ["^kube-apiserver-minikube$"] - Description: "Alloy/Loki provides pod-level audit trail." - "apiserver_audit_log_maxsize_set": - Regions: ["*"] - Resources: ["^kube-apiserver-minikube$"] - Description: "Alloy/Loki provides pod-level audit trail." - "apiserver_audit_log_path_set": - Regions: ["*"] - Resources: ["^kube-apiserver-minikube$"] - Description: "Alloy/Loki provides pod-level audit trail." - "apiserver_deny_service_external_ips": - Regions: ["*"] - Resources: ["^kube-apiserver-minikube$"] - Description: "No external IPs routable; cluster only reachable via tailnet." - "apiserver_disable_profiling": - Regions: ["*"] - Resources: ["^kube-apiserver-minikube$"] - Description: "Profiling endpoint unreachable from public internet." - "apiserver_encryption_provider_config_set": - Regions: ["*"] - Resources: ["^kube-apiserver-minikube$"] - Description: "Etcd not network-exposed; only operator has node access." - "apiserver_kubelet_cert_auth": - Regions: ["*"] - Resources: ["^kube-apiserver-minikube$"] - Description: "Kubelet API not exposed outside the node; minikube auto-generates certificates." - "apiserver_request_timeout_set": - Regions: ["*"] - Resources: ["^kube-apiserver-minikube$"] - Description: "API server only reachable via tailnet; DoS risk limited to trusted clients." - "apiserver_service_account_lookup_true": - Regions: ["*"] - Resources: ["^kube-apiserver-minikube$"] - Description: "Only operator manages service accounts; no revoked tokens in circulation." - "apiserver_strong_ciphers_only": - Regions: ["*"] - Resources: ["^kube-apiserver-minikube$"] - 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 deleted file mode 100644 index d3cc34a..0000000 --- a/argocd/manifests/prowler/mutelist/control-plane.yaml +++ /dev/null @@ -1,17 +0,0 @@ -# Minikube control-plane components — managed by static pod manifests. -Mutelist: - Accounts: - "*": - Checks: - "controllermanager_disable_profiling": - Regions: ["*"] - Resources: ["^kube-controller-manager-minikube$"] - Description: "Profiling endpoint unreachable from public internet." - "scheduler_profiling": - Regions: ["*"] - Resources: ["^kube-scheduler-minikube$"] - Description: "Profiling endpoint unreachable from public internet." - "kubelet_tls_cert_and_key": - Regions: ["*"] - Resources: ["^kubelet-config$"] - 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 deleted file mode 100644 index b1e986e..0000000 --- a/argocd/manifests/prowler/mutelist/core-pod-security.yaml +++ /dev/null @@ -1,87 +0,0 @@ -# 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 - - "^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 by design. - Host network itself is only reachable via tailnet. - "core_minimize_privileged_containers": - Regions: ["*"] - Resources: - # Minikube system - - "^kube-proxy-" - # Tailscale operator-managed proxies - - "^ts-" - - "^ingress-" - # Forgejo runner - - "^forgejo-runner-" - Description: >- - 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: - # Minikube system pods - - "^coredns-" - - "^kube-proxy-" - - "^kindnet-" - - "^storage-provisioner$" - # Tailscale operator-managed pods - - "^ts-" - - "^operator-" - - "^nameserver-" - - "^ingress-" - Description: >- - 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 for file permission - checks. Runs as CronJob with 7-day TTL, not a persistent - workload. - "core_minimize_root_containers_admission": - Regions: ["*"] - Resources: - - "^grafana-" - Description: >- - 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 - - "^grafana-" - Description: >- - 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: - - "^coredns-" - - "^kindnet-" - - "^grafana-" - Description: >- - 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 deleted file mode 100644 index c91a2a6..0000000 --- a/argocd/manifests/prowler/mutelist/manual-node-checks.yaml +++ /dev/null @@ -1,59 +0,0 @@ -# Node-level and RBAC checks that Prowler reports as MANUAL because it -# 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: - "*": - Checks: - "etcd_unique_ca": - Regions: ["*"] - Resources: ["^etcd-minikube$"] - Description: "Etcd CA fingerprint verified different from cluster CA by review-compliance-reports." - "kubelet_conf_file_ownership": - Regions: ["*"] - Resources: ["^kubelet-config$"] - Description: "File ownership verified root:root by review-compliance-reports." - "kubelet_conf_file_permissions": - Regions: ["*"] - Resources: ["^kubelet-config$"] - Description: "File permissions verified 600 by review-compliance-reports." - "kubelet_config_yaml_ownership": - Regions: ["*"] - Resources: ["^kubelet-config$"] - Description: "File ownership verified root:root by review-compliance-reports." - "kubelet_config_yaml_permissions": - Regions: ["*"] - Resources: ["^kubelet-config$"] - Description: "File permissions verified 644 by review-compliance-reports." - "kubelet_service_file_ownership_root": - Regions: ["*"] - Resources: ["^kubelet-config$"] - Description: "File ownership verified root:root by review-compliance-reports." - "kubelet_service_file_permissions": - Regions: ["*"] - Resources: ["^kubelet-config$"] - Description: "File permissions verified 644 by review-compliance-reports." - "kubelet_disable_read_only_port": - Regions: ["*"] - Resources: ["^kubelet-config$"] - Description: "readOnlyPort absence (defaults to 0) verified by review-compliance-reports." - "kubelet_event_record_qps": - Regions: ["*"] - Resources: ["^kubelet-config$"] - Description: "eventRecordQPS absence (defaults to 5) verified by review-compliance-reports." - "kubelet_manage_iptables": - Regions: ["*"] - Resources: ["^kubelet-config$"] - Description: "makeIPTablesUtilChains absence (defaults to true) verified by review-compliance-reports." - "kubelet_strong_ciphers_only": - Regions: ["*"] - Resources: ["^kubelet-config$"] - 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: "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 deleted file mode 100644 index 324809d..0000000 --- a/argocd/manifests/prowler/mutelist/rbac.yaml +++ /dev/null @@ -1,36 +0,0 @@ -# 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 - - "^argocd-" - Description: >- - 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: - # Built-in Kubernetes roles - - "^admin$" - - "^edit$" - - "^system:" - # CloudNativePG operator - - "^cnpg-manager$" - Description: >- - 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 SA - management. Only operator manages service accounts. diff --git a/argocd/manifests/prowler/pv-nfs.yaml b/argocd/manifests/prowler/pv-nfs.yaml deleted file mode 100644 index aa81405..0000000 --- a/argocd/manifests/prowler/pv-nfs.yaml +++ /dev/null @@ -1,22 +0,0 @@ -# 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 deleted file mode 100644 index 8d94378..0000000 --- a/argocd/manifests/prowler/pvc.yaml +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index 38fcfae..0000000 --- a/argocd/manifests/prowler/rbac.yaml +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index 26aaaa7..0000000 --- a/argocd/manifests/prowler/serviceaccount.yaml +++ /dev/null @@ -1,5 +0,0 @@ -apiVersion: v1 -kind: ServiceAccount -metadata: - name: prowler - namespace: prowler diff --git a/argocd/manifests/shower/configmap.yaml b/argocd/manifests/shower/configmap.yaml deleted file mode 100644 index 6102c1e..0000000 --- a/argocd/manifests/shower/configmap.yaml +++ /dev/null @@ -1,22 +0,0 @@ -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/<token>/`. - # /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 deleted file mode 100644 index 70547aa..0000000 --- a/argocd/manifests/shower/deployment.yaml +++ /dev/null @@ -1,81 +0,0 @@ -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 deleted file mode 100644 index 005a7e9..0000000 --- a/argocd/manifests/shower/external-secret.yaml +++ /dev/null @@ -1,19 +0,0 @@ ---- -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 deleted file mode 100644 index d09a696..0000000 --- a/argocd/manifests/shower/ingress-tailscale.yaml +++ /dev/null @@ -1,30 +0,0 @@ -# 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 deleted file mode 100644 index 1c29224..0000000 --- a/argocd/manifests/shower/kustomization.yaml +++ /dev/null @@ -1,17 +0,0 @@ -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.1.3-3645098-nix diff --git a/argocd/manifests/shower/pv-nfs.yaml b/argocd/manifests/shower/pv-nfs.yaml deleted file mode 100644 index 7354fb5..0000000 --- a/argocd/manifests/shower/pv-nfs.yaml +++ /dev/null @@ -1,24 +0,0 @@ -# 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 deleted file mode 100644 index 47fee54..0000000 --- a/argocd/manifests/shower/pvc.yaml +++ /dev/null @@ -1,30 +0,0 @@ -# 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 deleted file mode 100644 index 0a73aab..0000000 --- a/argocd/manifests/shower/service.yaml +++ /dev/null @@ -1,13 +0,0 @@ -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/argocd/manifests/tailscale-operator-base/kustomization.yaml b/argocd/manifests/tailscale-operator-base/kustomization.yaml deleted file mode 100644 index 9d117ef..0000000 --- a/argocd/manifests/tailscale-operator-base/kustomization.yaml +++ /dev/null @@ -1,33 +0,0 @@ ---- -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization - -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.ops.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.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-ringtail/external-secret.yaml b/argocd/manifests/tailscale-operator-ringtail/external-secret.yaml deleted file mode 100644 index 0776420..0000000 --- a/argocd/manifests/tailscale-operator-ringtail/external-secret.yaml +++ /dev/null @@ -1,32 +0,0 @@ ---- -# ExternalSecret for Tailscale Operator OAuth credentials -# -# Shares the same 1Password item as indri's operator (same OAuth client). -# Multiple operator instances can share one OAuth client; each registers -# as its own device. -# -# 1Password item: "Tailscale K8s Operator OAuth" in blumeops vault -# Fields: "client-id", "client-secret" -# -apiVersion: external-secrets.io/v1 -kind: ExternalSecret -metadata: - name: operator-oauth - namespace: tailscale -spec: - refreshInterval: 1h - secretStoreRef: - kind: ClusterSecretStore - name: onepassword-blumeops - target: - name: operator-oauth - creationPolicy: Owner - data: - - secretKey: client_id - remoteRef: - key: Tailscale K8s Operator OAuth - property: client-id - - secretKey: client_secret - remoteRef: - key: Tailscale K8s Operator OAuth - property: client-secret diff --git a/argocd/manifests/tailscale-operator-ringtail/kustomization.yaml b/argocd/manifests/tailscale-operator-ringtail/kustomization.yaml deleted file mode 100644 index 2d9ceb2..0000000 --- a/argocd/manifests/tailscale-operator-ringtail/kustomization.yaml +++ /dev/null @@ -1,24 +0,0 @@ ---- -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization - -namespace: tailscale - -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 deleted file mode 100644 index d1bf2a4..0000000 --- a/argocd/manifests/tailscale-operator-ringtail/proxyclass-image.yaml +++ /dev/null @@ -1,11 +0,0 @@ -apiVersion: tailscale.com/v1alpha1 -kind: ProxyClass -metadata: - name: default -spec: - statefulSet: - pod: - tailscaleContainer: - image: registry.ops.eblu.me/blumeops/tailscale:v1.94.2-0108b68-nix - tailscaleInitContainer: - image: registry.ops.eblu.me/blumeops/tailscale:v1.94.2-0108b68-nix diff --git a/argocd/manifests/tailscale-operator-ringtail/proxygroup-ingress.yaml b/argocd/manifests/tailscale-operator-ringtail/proxygroup-ingress.yaml deleted file mode 100644 index 9433da9..0000000 --- a/argocd/manifests/tailscale-operator-ringtail/proxygroup-ingress.yaml +++ /dev/null @@ -1,11 +0,0 @@ ---- -apiVersion: tailscale.com/v1alpha1 -kind: ProxyGroup -metadata: - name: ingress -spec: - type: ingress - replicas: 1 - proxyClass: default - tags: - - tag:k8s diff --git a/argocd/manifests/tailscale-operator/README.md b/argocd/manifests/tailscale-operator/README.md index dc4b009..44c5089 100644 --- a/argocd/manifests/tailscale-operator/README.md +++ b/argocd/manifests/tailscale-operator/README.md @@ -73,6 +73,7 @@ kubectl logs -n tailscale -l app.kubernetes.io/name=operator | `operator.yaml` | Operator deployment, CRDs, RBAC (secret removed) | | `proxyclass.yaml` | ProxyClass with fully-qualified images | | `dnsconfig.yaml` | DNSConfig for cluster-to-tailnet name resolution | +| `egress-forge.yaml` | Egress proxy for accessing forge on indri | | `secret.yaml.tpl` | 1Password template for OAuth credentials (manual) | | `README.md` | This file | @@ -85,3 +86,5 @@ kubectl logs -n tailscale -l app.kubernetes.io/name=operator annotations: tailscale.com/proxy-class: "default" ``` +- The egress proxy for forge targets `indri.tail8d86e.ts.net` directly (not `forge.tail8d86e.ts.net`) + because Tailscale Serve hostnames are virtual and only work via the Tailscale client. diff --git a/argocd/manifests/tailscale-operator-base/dnsconfig.yaml b/argocd/manifests/tailscale-operator/dnsconfig.yaml similarity index 100% rename from argocd/manifests/tailscale-operator-base/dnsconfig.yaml rename to argocd/manifests/tailscale-operator/dnsconfig.yaml diff --git a/argocd/manifests/tailscale-operator/egress-forge.yaml b/argocd/manifests/tailscale-operator/egress-forge.yaml new file mode 100644 index 0000000..8705eea --- /dev/null +++ b/argocd/manifests/tailscale-operator/egress-forge.yaml @@ -0,0 +1,20 @@ +# Egress proxy to expose Forgejo (forge) to the cluster +# Forge runs on indri:3001, exposed via Tailscale Serve as forge.tail8d86e.ts.net +# We target indri directly since egress can't reach Tailscale Serve hostnames +# +# See: https://tailscale.com/kb/1438/kubernetes-operator-cluster-egress +--- +apiVersion: v1 +kind: Service +metadata: + name: forge + namespace: tailscale + annotations: + tailscale.com/tailnet-fqdn: indri.tail8d86e.ts.net + tailscale.com/proxy-class: "default" +spec: + type: ExternalName + externalName: placeholder + ports: + - port: 3001 + targetPort: 3001 diff --git a/argocd/manifests/tailscale-operator/endpoints-forge.yaml b/argocd/manifests/tailscale-operator/endpoints-forge.yaml deleted file mode 100644 index e68aff9..0000000 --- a/argocd/manifests/tailscale-operator/endpoints-forge.yaml +++ /dev/null @@ -1,24 +0,0 @@ ---- -# Manual Endpoints pointing to indri's Tailscale IP for the -# forge-external Service. Must match the Service name exactly. -# -# NOTE: ArgoCD excludes all Endpoints resources (resource.exclusions in -# argocd-cm) because they are normally auto-managed by the control plane. -# This manual Endpoints is the exception — it must be applied directly -# with kubectl, not via ArgoCD. It is listed in kustomization.yaml for -# documentation purposes only; ArgoCD will silently skip it. -# -# kubectl --context=minikube-indri apply -f endpoints-forge.yaml -# -apiVersion: v1 -kind: Endpoints -metadata: - name: forge-external - namespace: tailscale -subsets: - - addresses: - - ip: 100.98.163.89 - ports: - - name: http - port: 3001 - protocol: TCP diff --git a/argocd/manifests/tailscale-operator/external-secret.yaml b/argocd/manifests/tailscale-operator/external-secret.yaml deleted file mode 100644 index 45aae71..0000000 --- a/argocd/manifests/tailscale-operator/external-secret.yaml +++ /dev/null @@ -1,29 +0,0 @@ -# ExternalSecret for Tailscale Operator OAuth credentials -# -# Replaces the manual op inject workflow from secret.yaml.tpl -# -# 1Password item: "Tailscale K8s Operator OAuth" in blumeops vault -# Fields: "client-id", "client-secret" -# -apiVersion: external-secrets.io/v1 -kind: ExternalSecret -metadata: - name: operator-oauth - namespace: tailscale -spec: - refreshInterval: 1h - secretStoreRef: - kind: ClusterSecretStore - name: onepassword-blumeops - target: - name: operator-oauth - creationPolicy: Owner - data: - - secretKey: client_id - remoteRef: - key: Tailscale K8s Operator OAuth - property: client-id - - secretKey: client_secret - remoteRef: - key: Tailscale K8s Operator OAuth - property: client-secret diff --git a/argocd/manifests/tailscale-operator/kustomization.yaml b/argocd/manifests/tailscale-operator/kustomization.yaml index f1d6f89..f0517ad 100644 --- a/argocd/manifests/tailscale-operator/kustomization.yaml +++ b/argocd/manifests/tailscale-operator/kustomization.yaml @@ -1,16 +1,13 @@ ---- apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization namespace: tailscale resources: - - ../tailscale-operator-base - - proxygroup-ingress.yaml - - external-secret.yaml - - svc-forge-external.yaml - # endpoints-forge.yaml is NOT managed by ArgoCD — Endpoints are globally - # excluded in argocd-cm resource.exclusions (too noisy for auto-managed - # Endpoints). Apply manually: - # kubectl --context=minikube-indri apply -f endpoints-forge.yaml - - ingress-forge.yaml + - operator.yaml + - proxyclass.yaml + - dnsconfig.yaml + - egress-forge.yaml + +# Note: OAuth secret (operator-oauth) is NOT included here. +# It must be manually applied before deploying - see README.md diff --git a/argocd/manifests/tailscale-operator/operator.yaml b/argocd/manifests/tailscale-operator/operator.yaml new file mode 100644 index 0000000..78a84ee --- /dev/null +++ b/argocd/manifests/tailscale-operator/operator.yaml @@ -0,0 +1,5386 @@ +# 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 + name>-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 <key,value,effect> using the matching operator <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=<proxyclass-name> 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 <pod-ip>:9002/metrics. + A metrics Service named <proxy-statefulset>-metrics will also be created in the operator's namespace and will + serve the metrics at <service-ip>: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_<proxy type>_[<parent namespace>]_<parent_name> + 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 <topologyKey> 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 <topologyKey> 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 <pod-ip>:9001/debug/pprof/ + and internal debug metrics endpoint at <pod-ip>: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 <pod-ip>:9001/debug/pprof/ + and internal debug metrics endpoint at <pod-ip>: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 <key,value,effect> using the matching operator <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 <key, value> 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 <MagicDNS name of the recorder>: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 <topologyKey> 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 <topologyKey> 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::<account-id>:role/<role-name> + 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 <key,value,effect> using the matching operator <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:v1.92.5 + 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/proxyclass.yaml similarity index 67% rename from argocd/manifests/tailscale-operator-base/proxyclass.yaml rename to argocd/manifests/tailscale-operator/proxyclass.yaml index 9fb46d6..3e4e2b4 100644 --- a/argocd/manifests/tailscale-operator-base/proxyclass.yaml +++ b/argocd/manifests/tailscale-operator/proxyclass.yaml @@ -3,8 +3,6 @@ # 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" @@ -20,10 +18,6 @@ spec: statefulSet: pod: tailscaleContainer: - image: docker.io/tailscale/tailscale:v1.94.2 - resources: - requests: - cpu: 100m - memory: 128Mi + image: docker.io/tailscale/tailscale:v1.92.5 tailscaleInitContainer: - image: docker.io/tailscale/tailscale:v1.94.2 + image: docker.io/tailscale/tailscale:v1.92.5 diff --git a/argocd/manifests/tailscale-operator/proxygroup-ingress.yaml b/argocd/manifests/tailscale-operator/proxygroup-ingress.yaml deleted file mode 100644 index 93f36b0..0000000 --- a/argocd/manifests/tailscale-operator/proxygroup-ingress.yaml +++ /dev/null @@ -1,10 +0,0 @@ -apiVersion: tailscale.com/v1alpha1 -kind: ProxyGroup -metadata: - name: ingress -spec: - type: ingress - replicas: 2 - proxyClass: default - tags: - - tag:k8s diff --git a/argocd/manifests/tailscale-operator/secret.yaml.tpl b/argocd/manifests/tailscale-operator/secret.yaml.tpl new file mode 100644 index 0000000..700bfc0 --- /dev/null +++ b/argocd/manifests/tailscale-operator/secret.yaml.tpl @@ -0,0 +1,14 @@ +# Tailscale Operator OAuth Secret +# This template is processed by `op inject` to resolve 1Password references. +# +# Usage: +# op inject -i secret.yaml.tpl | kubectl apply -f - +# +apiVersion: v1 +kind: Secret +metadata: + name: operator-oauth + namespace: tailscale +stringData: + client_id: "{{ op://vg6xf6vvfmoh5hqjjhlhbeoaie/2it22lavwgbxdskoaxanej354q/client-id }}" + client_secret: "{{ op://vg6xf6vvfmoh5hqjjhlhbeoaie/2it22lavwgbxdskoaxanej354q/client-secret }}" diff --git a/argocd/manifests/tailscale-operator/svc-forge-external.yaml b/argocd/manifests/tailscale-operator/svc-forge-external.yaml deleted file mode 100644 index 79a8645..0000000 --- a/argocd/manifests/tailscale-operator/svc-forge-external.yaml +++ /dev/null @@ -1,15 +0,0 @@ ---- -# ClusterIP service for Forgejo on indri. Paired with endpoints-forge.yaml -# which provides the actual routing to indri's Tailscale IP. -# ExternalName services don't have a ClusterIP, which the Tailscale -# ingress operator requires. -apiVersion: v1 -kind: Service -metadata: - name: forge-external - namespace: tailscale -spec: - ports: - - name: http - port: 3001 - protocol: TCP diff --git a/argocd/manifests/tempo/ingress-tailscale-otlp.yaml b/argocd/manifests/tempo/ingress-tailscale-otlp.yaml deleted file mode 100644 index ed65113..0000000 --- a/argocd/manifests/tempo/ingress-tailscale-otlp.yaml +++ /dev/null @@ -1,27 +0,0 @@ -# Tailscale Ingress for Tempo OTLP HTTP receiver -# Used by ringtail Alloy to push traces across tailnet -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: tempo-otlp-tailscale - namespace: monitoring - annotations: - tailscale.com/funnel: "false" - tailscale.com/proxy-group: "ingress" - tailscale.com/tags: "tag:k8s" - gethomepage.dev/enabled: "false" -spec: - ingressClassName: tailscale - rules: - - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: tempo - port: - number: 4318 - tls: - - hosts: - - tempo-otlp diff --git a/argocd/manifests/tempo/ingress-tailscale.yaml b/argocd/manifests/tempo/ingress-tailscale.yaml deleted file mode 100644 index 660d77a..0000000 --- a/argocd/manifests/tempo/ingress-tailscale.yaml +++ /dev/null @@ -1,26 +0,0 @@ -# Tailscale Ingress for Tempo query API -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: tempo-tailscale - namespace: monitoring - annotations: - tailscale.com/funnel: "false" - tailscale.com/proxy-group: "ingress" - tailscale.com/tags: "tag:k8s" - gethomepage.dev/enabled: "false" -spec: - ingressClassName: tailscale - rules: - - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: tempo - port: - number: 3200 - tls: - - hosts: - - tempo diff --git a/argocd/manifests/tempo/kustomization.yaml b/argocd/manifests/tempo/kustomization.yaml deleted file mode 100644 index 1ccbdc8..0000000 --- a/argocd/manifests/tempo/kustomization.yaml +++ /dev/null @@ -1,20 +0,0 @@ -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization - -namespace: monitoring - -resources: - - statefulset.yaml - - service.yaml - - ingress-tailscale.yaml - - ingress-tailscale-otlp.yaml - -images: - - name: grafana/tempo - newName: registry.ops.eblu.me/blumeops/tempo - newTag: "v2.10.3-75f9ba4" - -configMapGenerator: - - name: tempo-config - files: - - tempo.yaml diff --git a/argocd/manifests/tempo/service.yaml b/argocd/manifests/tempo/service.yaml deleted file mode 100644 index 37b25df..0000000 --- a/argocd/manifests/tempo/service.yaml +++ /dev/null @@ -1,22 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: tempo - namespace: monitoring -spec: - selector: - app: tempo - ports: - - name: http - port: 3200 - targetPort: 3200 - - name: grpc - port: 9095 - targetPort: 9095 - - name: otlp-grpc - port: 4317 - targetPort: 4317 - - name: otlp-http - port: 4318 - targetPort: 4318 - type: ClusterIP diff --git a/argocd/manifests/tempo/statefulset.yaml b/argocd/manifests/tempo/statefulset.yaml deleted file mode 100644 index 3df5c66..0000000 --- a/argocd/manifests/tempo/statefulset.yaml +++ /dev/null @@ -1,72 +0,0 @@ -apiVersion: apps/v1 -kind: StatefulSet -metadata: - name: tempo - namespace: monitoring -spec: - serviceName: tempo - replicas: 1 - selector: - matchLabels: - app: tempo - template: - metadata: - labels: - app: tempo - spec: - securityContext: - fsGroup: 10001 - runAsNonRoot: true - runAsUser: 10001 - seccompProfile: - type: RuntimeDefault - containers: - - name: tempo - image: grafana/tempo:kustomized - args: - - -config.file=/etc/tempo/tempo.yaml - ports: - - name: http - containerPort: 3200 - - name: grpc - containerPort: 9095 - - name: otlp-grpc - containerPort: 4317 - - name: otlp-http - containerPort: 4318 - volumeMounts: - - name: config - mountPath: /etc/tempo - - name: data - mountPath: /var/tempo - resources: - requests: - memory: "256Mi" - cpu: "100m" - limits: - memory: "1Gi" - cpu: "500m" - livenessProbe: - httpGet: - path: /ready - port: 3200 - initialDelaySeconds: 45 - periodSeconds: 10 - readinessProbe: - httpGet: - path: /ready - port: 3200 - initialDelaySeconds: 10 - periodSeconds: 5 - volumes: - - name: config - configMap: - name: tempo-config - volumeClaimTemplates: - - metadata: - name: data - spec: - accessModes: ["ReadWriteOnce"] - resources: - requests: - storage: 10Gi diff --git a/argocd/manifests/tempo/tempo.yaml b/argocd/manifests/tempo/tempo.yaml deleted file mode 100644 index c145d59..0000000 --- a/argocd/manifests/tempo/tempo.yaml +++ /dev/null @@ -1,58 +0,0 @@ -stream_over_http_enabled: true - -server: - http_listen_port: 3200 - grpc_listen_port: 9095 - -distributor: - receivers: - otlp: - protocols: - grpc: - endpoint: "0.0.0.0:4317" - http: - endpoint: "0.0.0.0:4318" - -storage: - trace: - backend: local - wal: - path: /var/tempo/wal - local: - path: /var/tempo/blocks - -compactor: - compaction: - block_retention: 168h # 7 days - -metrics_generator: - registry: - external_labels: - source: tempo - storage: - path: /var/tempo/generator/wal - remote_write: - - url: http://prometheus.monitoring.svc.cluster.local:9090/api/v1/write - send_exemplars: true - traces_storage: - path: /var/tempo/generator/traces - processor: - span_metrics: - dimensions: - - service.name - - http.method - - http.status_code - - http.target - service_graphs: - dimensions: - - service.name - local_blocks: - flush_to_storage: false - -overrides: - defaults: - metrics_generator: - processors: - - span-metrics - - service-graphs - - local-blocks diff --git a/argocd/manifests/teslamate-ringtail/external-secret-db.yaml b/argocd/manifests/teslamate-ringtail/external-secret-db.yaml deleted file mode 100644 index 11eeec6..0000000 --- a/argocd/manifests/teslamate-ringtail/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-ringtail/external-secret-encryption-key.yaml b/argocd/manifests/teslamate-ringtail/external-secret-encryption-key.yaml deleted file mode 100644 index 96938bf..0000000 --- a/argocd/manifests/teslamate-ringtail/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-ringtail/ingress-tailscale.yaml b/argocd/manifests/teslamate-ringtail/ingress-tailscale.yaml deleted file mode 100644 index dfafb17..0000000 --- a/argocd/manifests/teslamate-ringtail/ingress-tailscale.yaml +++ /dev/null @@ -1,25 +0,0 @@ -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: teslamate-tailscale - namespace: teslamate - annotations: - tailscale.com/proxy-class: "default" - tailscale.com/proxy-group: "ingress" - gethomepage.dev/enabled: "true" - gethomepage.dev/name: "TeslaMate" - gethomepage.dev/group: "Services" - gethomepage.dev/icon: "teslamate.png" - gethomepage.dev/description: "Tesla data logger" - gethomepage.dev/href: "https://tesla.ops.eblu.me" - gethomepage.dev/pod-selector: "app=teslamate" -spec: - ingressClassName: tailscale - defaultBackend: - service: - name: teslamate - port: - number: 4000 - tls: - - hosts: - - tesla diff --git a/argocd/manifests/teslamate/README.md b/argocd/manifests/teslamate/README.md new file mode 100644 index 0000000..65875e4 --- /dev/null +++ b/argocd/manifests/teslamate/README.md @@ -0,0 +1,69 @@ +# TeslaMate + +TeslaMate is a self-hosted Tesla data logger that collects and visualizes vehicle data. + +## Prerequisites + +### 1. Create 1Password Secrets + +Create two items in the blumeops 1Password vault: + +1. **TeslaMate DB Password** + - Generate a secure password for the teslamate PostgreSQL user + - Add a field named `password` with the generated value + +2. **TeslaMate Encryption Key** + - Generate with: `openssl rand -base64 32` + - Add a field named `key` with the generated value + - This encrypts Tesla API tokens at rest in the database + +### 2. Apply Kubernetes Secrets + +```bash +# Create namespace +kubectl create namespace teslamate + +# Apply database user secret (for CNPG) +op inject -i argocd/manifests/databases/secret-teslamate.yaml.tpl | kubectl apply -f - + +# Apply teslamate secrets +op inject -i argocd/manifests/teslamate/secret-encryption-key.yaml.tpl | kubectl apply -f - +op inject -i argocd/manifests/teslamate/secret-db.yaml.tpl | kubectl apply -f - +``` + +### 3. Create Database + +After the teslamate user exists in PostgreSQL (sync blumeops-pg first): + +```bash +PGPASSWORD=$(op --vault blumeops item get <eblume-item-id> --fields password --reveal) \ + psql -h pg.tail8d86e.ts.net -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-ringtail/deployment.yaml b/argocd/manifests/teslamate/deployment.yaml similarity index 74% rename from argocd/manifests/teslamate-ringtail/deployment.yaml rename to argocd/manifests/teslamate/deployment.yaml index cf8cc73..684b632 100644 --- a/argocd/manifests/teslamate-ringtail/deployment.yaml +++ b/argocd/manifests/teslamate/deployment.yaml @@ -1,10 +1,3 @@ -# TeslaMate on ringtail k3s — Nix image. -# -# The Nix image's Entrypoint waits for postgres, runs migrations -# (TeslaMate.Release.migrate), then starts the release — so no command -# override is needed. Stateless; all data lives in the teslamate database -# on the ringtail blumeops-pg (DATABASE_HOST already an in-cluster name, -# unchanged from minikube). See [[migrate-wave1-ringtail]]. apiVersion: apps/v1 kind: Deployment metadata: @@ -20,12 +13,9 @@ spec: labels: app: teslamate spec: - securityContext: - seccompProfile: - type: RuntimeDefault containers: - name: teslamate - image: registry.ops.eblu.me/blumeops/teslamate:kustomized + image: teslamate/teslamate:2.2.0 ports: - containerPort: 4000 env: diff --git a/argocd/manifests/teslamate/ingress-tailscale.yaml b/argocd/manifests/teslamate/ingress-tailscale.yaml new file mode 100644 index 0000000..5480ba7 --- /dev/null +++ b/argocd/manifests/teslamate/ingress-tailscale.yaml @@ -0,0 +1,17 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: teslamate-tailscale + namespace: teslamate + annotations: + tailscale.com/proxy-class: "default" +spec: + ingressClassName: tailscale + defaultBackend: + service: + name: teslamate + port: + number: 4000 + tls: + - hosts: + - tesla diff --git a/argocd/manifests/teslamate-ringtail/kustomization.yaml b/argocd/manifests/teslamate/kustomization.yaml similarity index 50% rename from argocd/manifests/teslamate-ringtail/kustomization.yaml rename to argocd/manifests/teslamate/kustomization.yaml index acb623e..5ae053f 100644 --- a/argocd/manifests/teslamate-ringtail/kustomization.yaml +++ b/argocd/manifests/teslamate/kustomization.yaml @@ -7,9 +7,3 @@ 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-fcac8e5-nix diff --git a/argocd/manifests/teslamate/secret-db.yaml.tpl b/argocd/manifests/teslamate/secret-db.yaml.tpl new file mode 100644 index 0000000..82aa731 --- /dev/null +++ b/argocd/manifests/teslamate/secret-db.yaml.tpl @@ -0,0 +1,11 @@ +# TeslaMate database password secret +# +# Apply with: op inject -i argocd/manifests/teslamate/secret-db.yaml.tpl | kubectl apply -f - +apiVersion: v1 +kind: Secret +metadata: + name: teslamate-db + namespace: teslamate +type: Opaque +stringData: + password: {{ op://blumeops/TeslaMate/db_password }} diff --git a/argocd/manifests/teslamate/secret-encryption-key.yaml.tpl b/argocd/manifests/teslamate/secret-encryption-key.yaml.tpl new file mode 100644 index 0000000..a0e57a4 --- /dev/null +++ b/argocd/manifests/teslamate/secret-encryption-key.yaml.tpl @@ -0,0 +1,12 @@ +# TeslaMate encryption key secret +# This key encrypts Tesla API tokens at rest in the database +# +# Apply with: op inject -i argocd/manifests/teslamate/secret-encryption-key.yaml.tpl | kubectl apply -f - +apiVersion: v1 +kind: Secret +metadata: + name: teslamate-encryption + namespace: teslamate +type: Opaque +stringData: + key: {{ op://blumeops/TeslaMate/api_enc_key }} diff --git a/argocd/manifests/teslamate-ringtail/service.yaml b/argocd/manifests/teslamate/service.yaml similarity index 100% rename from argocd/manifests/teslamate-ringtail/service.yaml rename to argocd/manifests/teslamate/service.yaml diff --git a/argocd/manifests/torrent/deployment.yaml b/argocd/manifests/torrent/deployment.yaml index ab42537..8f331bb 100644 --- a/argocd/manifests/torrent/deployment.yaml +++ b/argocd/manifests/torrent/deployment.yaml @@ -14,12 +14,9 @@ spec: labels: app: transmission spec: - securityContext: - seccompProfile: - type: RuntimeDefault containers: - name: transmission - image: registry.ops.eblu.me/blumeops/transmission:kustomized + image: lscr.io/linuxserver/transmission:4.0.6 env: - name: PUID value: "1000" @@ -58,20 +55,6 @@ spec: port: 9091 initialDelaySeconds: 10 periodSeconds: 10 - - name: transmission-exporter - image: registry.ops.eblu.me/blumeops/transmission-exporter:kustomized - env: - - name: TRANSMISSION_ADDR - value: "http://localhost:9091" - ports: - - containerPort: 19091 - name: metrics - resources: - requests: - memory: "32Mi" - cpu: "10m" - limits: - memory: "64Mi" volumes: - name: downloads persistentVolumeClaim: diff --git a/argocd/manifests/torrent/ingress-tailscale.yaml b/argocd/manifests/torrent/ingress-tailscale.yaml index fe15dd5..87e0916 100644 --- a/argocd/manifests/torrent/ingress-tailscale.yaml +++ b/argocd/manifests/torrent/ingress-tailscale.yaml @@ -6,20 +6,6 @@ metadata: namespace: torrent annotations: tailscale.com/proxy-class: "default" - tailscale.com/proxy-group: "ingress" - gethomepage.dev/enabled: "true" - gethomepage.dev/name: "Transmission" - gethomepage.dev/group: "Services" - gethomepage.dev/icon: "transmission.png" - gethomepage.dev/description: "Torrent client" - gethomepage.dev/href: "https://torrent.ops.eblu.me" - gethomepage.dev/pod-selector: "app=transmission" - # TODO: Add Transmission widget - requires username/password setup in Transmission - # See: https://gethomepage.dev/widgets/services/transmission/ - # gethomepage.dev/widget.type: "transmission" - # gethomepage.dev/widget.url: "https://torrent.ops.eblu.me" - # gethomepage.dev/widget.username: "{{HOMEPAGE_VAR_TRANSMISSION_USER}}" - # gethomepage.dev/widget.password: "{{HOMEPAGE_VAR_TRANSMISSION_PASSWORD}}" spec: ingressClassName: tailscale defaultBackend: diff --git a/argocd/manifests/torrent/kustomization.yaml b/argocd/manifests/torrent/kustomization.yaml index 671687c..57c6197 100644 --- a/argocd/manifests/torrent/kustomization.yaml +++ b/argocd/manifests/torrent/kustomization.yaml @@ -8,8 +8,3 @@ resources: - deployment.yaml - service.yaml - ingress-tailscale.yaml -images: - - name: registry.ops.eblu.me/blumeops/transmission - newTag: v4.1.1-r1-2c483ce - - name: registry.ops.eblu.me/blumeops/transmission-exporter - newTag: v1.0.1-2c483ce diff --git a/argocd/manifests/torrent/service.yaml b/argocd/manifests/torrent/service.yaml index a54fc93..51f7592 100644 --- a/argocd/manifests/torrent/service.yaml +++ b/argocd/manifests/torrent/service.yaml @@ -11,6 +11,3 @@ spec: - name: web port: 9091 targetPort: 9091 - - name: metrics - port: 19091 - targetPort: 19091 diff --git a/argocd/manifests/unpoller/deployment.yaml b/argocd/manifests/unpoller/deployment.yaml deleted file mode 100644 index 44c89b7..0000000 --- a/argocd/manifests/unpoller/deployment.yaml +++ /dev/null @@ -1,45 +0,0 @@ -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: - securityContext: - seccompProfile: - type: RuntimeDefault - 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 deleted file mode 100644 index c82ec0d..0000000 --- a/argocd/manifests/unpoller/external-secret.yaml +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index bf776bb..0000000 --- a/argocd/manifests/unpoller/kustomization.yaml +++ /dev/null @@ -1,18 +0,0 @@ -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: v3.2.0-4d1f4af - -configMapGenerator: - - name: unpoller-config - files: - - up.conf diff --git a/argocd/manifests/unpoller/service.yaml b/argocd/manifests/unpoller/service.yaml deleted file mode 100644 index 1ce870b..0000000 --- a/argocd/manifests/unpoller/service.yaml +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index 0430067..0000000 --- a/argocd/manifests/unpoller/up.conf +++ /dev/null @@ -1,16 +0,0 @@ -[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/alloy/container.py b/containers/alloy/container.py deleted file mode 100644 index 41d3995..0000000 --- a/containers/alloy/container.py +++ /dev/null @@ -1,95 +0,0 @@ -"""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 deleted file mode 100644 index c884704..0000000 --- a/containers/alloy/default.nix +++ /dev/null @@ -1,140 +0,0 @@ -# Nix-built Grafana Alloy telemetry collector -# 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 <nixpkgs> { } }: - -let - version = "1.16.0"; - - src = pkgs.fetchgit { - url = "https://forge.ops.eblu.me/mirrors/alloy.git"; - rev = "v${version}"; - hash = "sha256-q5R2noxBZ3OPyZqmB+bx3iJKWFxC2WIprcgh9RwjLzk="; - }; - - ui = pkgs.buildNpmPackage { - inherit version; - pname = "alloy-ui"; - src = "${src}/internal/web/ui"; - npmDepsHash = "sha256-vResNUT4auDsK9ngnJYfMUUOYr/ikPhrvakqCjGq2Q8="; - - 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_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 .. - cd collector && go mod download && cd .. - ''; - - installPhase = '' - cp -r $TMPDIR/go/pkg/mod $out - ''; - - outputHashMode = "recursive"; - outputHash = "sha256-9/v85HyDInJB+9qHauKVuDol6Yf5mkXfMWgCr7RdRTk="; - outputHashAlgo = "sha256"; - }; - - alloy = pkgs.stdenv.mkDerivation { - inherit src version; - pname = "alloy"; - - nativeBuildInputs = with pkgs; [ - go_1_26 - git - gnumake - cacert - ]; - - buildPhase = '' - runHook preBuild - - export HOME=$TMPDIR - export GOPATH=$TMPDIR/go - export GOFLAGS=-modcacherw - export GOTOOLCHAIN=local - - # 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"; - 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/containers/authentik-redis/default.nix b/containers/authentik-redis/default.nix deleted file mode 100644 index 7b66f84..0000000 --- a/containers/authentik-redis/default.nix +++ /dev/null @@ -1,29 +0,0 @@ -# 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/api-go-vendor-hook.nix b/containers/authentik/api-go-vendor-hook.nix deleted file mode 100644 index 3c7e9d6..0000000 --- a/containers/authentik/api-go-vendor-hook.nix +++ /dev/null @@ -1,28 +0,0 @@ -# Setup hook that injects generated Go API client into the vendor directory -# Replaces vendor/goauthentik.io/api/v3/ with freshly generated client-go output -# Skips during FOD (fixed-output derivation) builds to keep vendorHash stable -{ pkgs ? import <nixpkgs> { }, sources ? import ./sources.nix { inherit pkgs; } }: - -let - client-go = import ./client-go.nix { inherit pkgs sources; }; -in -pkgs.makeSetupHook - { - name = "authentik-api-go-vendor-hook"; - } - ( - pkgs.writeShellScript "authentik-api-go-vendor-hook" '' - authentikApiGoVendorHook() { - chmod -R +w vendor/goauthentik.io/api - rm -rf vendor/goauthentik.io/api/v3 - cp -r ${client-go} vendor/goauthentik.io/api/v3 - - echo "Finished authentikApiGoVendorHook" - } - - # don't run for FOD, e.g. the goModules build - if [ -z ''${outputHash-} ]; then - postConfigureHooks+=(authentikApiGoVendorHook) - fi - '' - ) diff --git a/containers/authentik/authentik-django.nix b/containers/authentik/authentik-django.nix deleted file mode 100644 index ebb7548..0000000 --- a/containers/authentik/authentik-django.nix +++ /dev/null @@ -1,162 +0,0 @@ -# Authentik Python/Django backend -# -# Assembles the final package from: -# 1. python-deps FOD (venv with stripped store references) -# 2. opencontainers git dependency (fetched via Nix) -# 3. Workspace packages (ak-guardian, django-channels-postgres, etc.) -# 4. Authentik application source -# 5. Lifecycle scripts, blueprints, manage.py -# -# autoPatchelfHook restores RPATHs that were stripped in the FOD. -# -# Optional input: webui derivation. When provided, resolves @webui@ store -# path placeholders in Python source. When null (default), leaves placeholders -# for isolated testing. -# -# Output: -# $out/bin/python3.14 venv python (symlink to nix python314) -# $out/lib/python3.14/site-packages/ all Python packages -# $out/lifecycle/ lifecycle scripts (symlink) -# $out/blueprints/ YAML blueprints -# $out/manage.py Django management script -{ pkgs ? import <nixpkgs> { } -, sources ? import ./sources.nix { inherit pkgs; } -, webui ? null -}: - -let - python-deps = import ./python-deps.nix { inherit pkgs sources; }; - - # opencontainers is a git dependency not on PyPI — fetch separately - opencontainers-src = pkgs.fetchFromGitHub { - owner = "vsoch"; - repo = "oci-python"; - rev = "ceb4fcc090851717a3069d78e85ceb1e86c2740c"; - hash = "sha256-Q6SJed0K6eIrqQ9mNAD4RGx+YCJvnI5E+0KGp5fBtTU="; - }; - - # When webui is provided, resolve paths directly; otherwise use placeholder - webuiPath = if webui != null then "${webui}" else "@webui@"; - - sp = "$out/lib/python3.14/site-packages"; -in - -pkgs.stdenv.mkDerivation { - pname = "authentik-django"; - version = sources.version; - inherit (sources) meta; - - src = sources.src; - - nativeBuildInputs = with pkgs; [ - autoPatchelfHook # restores RPATHs stripped in the FOD - ]; - - # Libraries that autoPatchelfHook resolves NEEDED entries against - buildInputs = with pkgs; [ - python314 - stdenv.cc.cc.lib # libstdc++, libgcc_s - libxml2 - libxslt - xmlsec - openssl - libpq - krb5.lib - libtool.lib - libffi - zlib - ]; - - dontBuild = true; - - installPhase = '' - runHook preInstall - - # --- Copy venv from FOD --- - cp -r ${python-deps} $out - chmod -R +w $out - - # Restore python path in pyvenv.cfg (was replaced with @python@ in FOD) - sed -i "s|@python@|${pkgs.python314}|g" $out/pyvenv.cfg - - # Recreate bin/ (was removed in FOD to strip python store refs) - mkdir -p $out/bin - ln -s ${pkgs.python314}/bin/python3.14 $out/bin/python3.14 - ln -s python3.14 $out/bin/python3 - ln -s python3.14 $out/bin/python - - # Recreate entry point scripts that were in the venv's bin/ - # (gunicorn, etc. — use python from this venv) - for ep in gunicorn uvicorn dramatiq dumb-init; do - if [ -e ${sp}/$ep ] || $out/bin/python3.14 -c "import $ep" 2>/dev/null; then - cat > $out/bin/$ep << SCRIPT - #!$out/bin/python3.14 - import sys - from importlib.metadata import entry_points - eps = entry_points(group='console_scripts', name='$ep') - if eps: - sys.exit(next(iter(eps)).load()()) - SCRIPT - chmod +x $out/bin/$ep - fi - done 2>/dev/null || true - - # --- opencontainers (git dependency, pure Python) --- - cp -r ${opencontainers-src}/opencontainers ${sp}/opencontainers - - # --- Workspace packages (pure Python — direct copy) --- - # ak-guardian: hatch config maps to "guardian" package - cp -r packages/ak-guardian/guardian ${sp}/guardian - cp -r packages/django-channels-postgres/django_channels_postgres ${sp}/ - cp -r packages/django-dramatiq-postgres/django_dramatiq_postgres ${sp}/ - cp -r packages/django-postgres-cache/django_postgres_cache ${sp}/ - - # --- Authentik application + lifecycle --- - cp -r authentik ${sp}/authentik - cp -r lifecycle ${sp}/lifecycle - chmod +x ${sp}/lifecycle/ak - - # --- Patches for Nix store paths --- - - # BASE_DIR: point to $out instead of computing from settings.py's location - substituteInPlace ${sp}/authentik/root/settings.py \ - --replace-fail \ - 'BASE_DIR = Path(__file__).absolute().parent.parent.parent' \ - "BASE_DIR = Path(\"$out\")" - - # blueprints_dir: point to $out/blueprints - substituteInPlace ${sp}/authentik/lib/default.yml \ - --replace-fail 'blueprints_dir: /blueprints' \ - "blueprints_dir: $out/blueprints" - - # Web asset paths: placeholder @webui@ for Go server card to resolve - substituteInPlace ${sp}/authentik/stages/email/utils.py \ - --replace-fail 'Path("web/icons/icon_left_brand.png")' \ - 'Path("${webuiPath}/icons/icon_left_brand.png")' \ - --replace-fail 'Path("web/dist/assets/icons/icon_left_brand.png")' \ - 'Path("${webuiPath}/dist/assets/icons/icon_left_brand.png")' - - # Migration ordering: 0010 removes Role.group_id, but 0056 needs it - # for data migration. Upstream bug in authentik 2026.2.0. - # https://github.com/goauthentik/authentik/issues/19616 - substituteInPlace ${sp}/authentik/rbac/migrations/0010_remove_role_group_alter_role_name.py \ - --replace-fail \ - '("authentik_rbac", "0009_remove_initialpermissions_mode"),' \ - '("authentik_rbac", "0009_remove_initialpermissions_mode"), ("authentik_core", "0056_user_roles"),' - - # Lifecycle bash script: use Nix store bash (no /usr/bin/env in containers) - substituteInPlace ${sp}/lifecycle/ak \ - --replace-fail '#!/usr/bin/env -S bash' '#!${pkgs.bash}/bin/bash' - - # --- Top-level structure --- - ln -s ${sp}/lifecycle $out/lifecycle - ln -s ${sp}/authentik $out/authentik - cp -r blueprints $out/blueprints - cp manage.py $out/manage.py - - runHook postInstall - ''; - - # autoPatchelfHook runs in fixupPhase — don't disable it - dontPatchShebangs = true; -} diff --git a/containers/authentik/authentik-server.nix b/containers/authentik/authentik-server.nix deleted file mode 100644 index a99d9f8..0000000 --- a/containers/authentik/authentik-server.nix +++ /dev/null @@ -1,64 +0,0 @@ -# Authentik Go HTTP server binary -# -# Builds cmd/server from the authentik source using buildGoModule. -# The compiled binary serves the web UI, REST API, spawns gunicorn -# for the Django backend, and runs the embedded reverse proxy outpost. -# -# Two runtime path dependencies are baked in at compile time: -# - authentik-django: lifecycle scripts (gunicorn launcher) -# - webui: static web assets (dist/ and authentik/ directories) -# -# The apiGoVendorHook replaces vendored goauthentik.io/api/v3 with -# freshly generated client-go output, but only during the real build -# (not the FOD module-download phase), so vendorHash stays stable. -# -# Output: $out/bin/authentik -{ pkgs ? import <nixpkgs> { } -, sources ? import ./sources.nix { inherit pkgs; } -, authentik-django ? import ./authentik-django.nix { inherit pkgs sources; } -, webui ? null -}: - -let - apiGoVendorHook = import ./api-go-vendor-hook.nix { inherit pkgs sources; }; - - # Web assets path: use real webui derivation if provided, otherwise - # a placeholder directory. The placeholder allows the binary to compile - # and pass --help verification, but web serving won't work at runtime. - webAssetsPath = - if webui != null then webui - else pkgs.runCommand "webui-placeholder" { } '' - mkdir -p $out/dist $out/authentik - ''; -in - -pkgs.buildGoModule { - pname = "authentik-server"; - inherit (sources) version src meta; - - subPackages = [ "cmd/server" ]; - - nativeBuildInputs = [ apiGoVendorHook ]; - - env.CGO_ENABLED = 0; - - postPatch = '' - substituteInPlace internal/gounicorn/gounicorn.go \ - --replace-fail './lifecycle' "${authentik-django}/lifecycle" - substituteInPlace web/static.go \ - --replace-fail './web' "${webAssetsPath}" - substituteInPlace internal/web/static.go \ - --replace-fail './web' "${webAssetsPath}" - ''; - - # Clear postPatch during the module-download FOD phase so that - # substituteInPlace (which references authentik-django and webui - # store paths) doesn't affect vendorHash computation. - overrideModAttrs.postPatch = ""; - - vendorHash = "sha256-bdILiCQgDuzp+VJDVW3z2JxTtxlHkm9tmMHiA/Sx6ts="; - - postInstall = '' - mv $out/bin/server $out/bin/authentik - ''; -} diff --git a/containers/authentik/client-go.nix b/containers/authentik/client-go.nix deleted file mode 100644 index 5b8911d..0000000 --- a/containers/authentik/client-go.nix +++ /dev/null @@ -1,47 +0,0 @@ -# Generate Go API client bindings from authentik's OpenAPI schema -# Uses openapi-generator-cli to produce Go code from schema.yml -{ pkgs ? import <nixpkgs> { }, sources ? import ./sources.nix { inherit pkgs; } }: - -pkgs.stdenvNoCC.mkDerivation { - pname = "authentik-client-go"; - version = "3.${sources.version}"; - inherit (sources) meta; - - src = sources.client-go-src; - - # Docker volume path /local → local pwd - postPatch = '' - substituteInPlace ./config.yaml \ - --replace-fail '/local' "$(pwd)" - ''; - - nativeBuildInputs = with pkgs; [ - openapi-generator-cli - go - ]; - - buildPhase = '' - runHook preBuild - - openapi-generator-cli generate \ - -i ${sources.src}/schema.yml -o $out \ - -g go \ - -c ./config.yaml - - gofmt -w $out - - runHook postBuild - ''; - - installPhase = '' - runHook preInstall - - cp go.mod go.sum $out - - cd $out - rm -rf test - rm -f .travis.yml git_push.sh - - runHook postInstall - ''; -} diff --git a/containers/authentik/client-ts.nix b/containers/authentik/client-ts.nix deleted file mode 100644 index 8ad395b..0000000 --- a/containers/authentik/client-ts.nix +++ /dev/null @@ -1,36 +0,0 @@ -# Generate TypeScript fetch client bindings from authentik's OpenAPI schema -# Uses openapi-generator-cli to produce TypeScript code, then compiles with tsc -{ pkgs ? import <nixpkgs> { }, sources ? import ./sources.nix { inherit pkgs; } }: - -pkgs.stdenvNoCC.mkDerivation { - pname = "authentik-client-ts"; - inherit (sources) version src meta; - - # Docker volume path /local → local pwd - postPatch = '' - substituteInPlace ./scripts/api/ts-config.yaml \ - --replace-fail '/local' "$(pwd)" - ''; - - nativeBuildInputs = with pkgs; [ - nodejs - openapi-generator-cli - typescript - ]; - - buildPhase = '' - runHook preBuild - - openapi-generator-cli generate \ - -i ./schema.yml -o $out \ - -g typescript-fetch \ - -c ./scripts/api/ts-config.yaml \ - --additional-properties=npmVersion=${sources.version} \ - --git-repo-id authentik --git-user-id goauthentik - - cd $out - npm run build - - runHook postBuild - ''; -} diff --git a/containers/authentik/default.nix b/containers/authentik/default.nix deleted file mode 100644 index e65467a..0000000 --- a/containers/authentik/default.nix +++ /dev/null @@ -1,76 +0,0 @@ -# Nix-built Authentik identity provider (from source) -# -# Assembles four component derivations into a container image: -# 1. webui — Lit frontend (esbuild + rollup) -# 2. authentik-django — Python backend + lifecycle scripts -# 3. authentik-server — Go HTTP server binary -# 4. ak wrapper — sets PATH/VIRTUAL_ENV, delegates to lifecycle/ak -# -# Built with dockerTools.buildLayeredImage for efficient layer caching. -{ pkgs ? import <nixpkgs> { } }: - -let - sources = import ./sources.nix { inherit pkgs; }; - # Duplicated from sources.nix so build-container-nix.yaml can grep it - 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; }; - - # Wrapper that provides bin/ak with the correct runtime environment. - # lifecycle/ak dispatches: "server" → Go binary, "worker"/"migrate"/etc → Python. - ak = pkgs.writeShellScriptBin "ak" '' - export PYTHONDONTWRITEBYTECODE=1 - export PATH="${authentik-server}/bin:${authentik-django}/bin:$PATH" - export VIRTUAL_ENV="${authentik-django}" - cd "${authentik-django}" - exec "${authentik-django}/lifecycle/ak" "$@" - ''; - - # Container entrypoint: symlink built-in blueprints then run ak. - # buildLayeredImage's extraCommands can't access store paths from contents - # (they're in separate layers), so we create the symlinks at container start. - entrypoint = pkgs.writeShellScript "authentik-entrypoint" '' - for item in ${authentik-django}/blueprints/*/; do - name=$(basename "$item") - [ ! -e "/blueprints/$name" ] && ln -s "$item" "/blueprints/$name" 2>/dev/null || true - done - exec ${ak}/bin/ak "$@" - ''; -in - -pkgs.dockerTools.buildLayeredImage { - name = "blumeops/authentik"; - contents = [ - ak - authentik-django - authentik-server - pkgs.bashInteractive - pkgs.coreutils - pkgs.cacert - pkgs.tzdata - ]; - - # Create /blueprints as world-writable so user 65534 can create symlinks at runtime. - # authentik-django hardcodes blueprints_dir to $out/blueprints; the AUTHENTIK_BLUEPRINTS_DIR - # env var overrides it to /blueprints, where custom blueprints are mounted by k8s ConfigMap. - extraCommands = '' - mkdir -p blueprints tmp - chmod 777 blueprints tmp - ''; - - config = { - Entrypoint = [ "${entrypoint}" ]; - Env = [ - "SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" - "TZDIR=${pkgs.tzdata}/share/zoneinfo" - "TMPDIR=/tmp" - "AUTHENTIK_BLUEPRINTS_DIR=/blueprints" - ]; - ExposedPorts = { - "9000/tcp" = { }; - "9443/tcp" = { }; - }; - User = "65534"; - }; -} diff --git a/containers/authentik/python-deps.nix b/containers/authentik/python-deps.nix deleted file mode 100644 index 030bac4..0000000 --- a/containers/authentik/python-deps.nix +++ /dev/null @@ -1,125 +0,0 @@ -# Fixed-output derivation (FOD): download and install all external Python -# dependencies into a venv using uv sync. -# -# FODs get network access because the output hash is declared upfront. -# However, FODs must not reference other Nix store paths in their output. -# Compiled .so files (from sdist builds) contain RPATHs to system libraries -# (libxml2, krb5, etc.) which are Nix store paths. We strip these references -# here; authentik-django.nix restores them via autoPatchelfHook. -# -# The venv's bin/ and pyvenv.cfg also reference the python store path, so we -# replace them with placeholders that the main derivation restores. -# -# When uv.lock changes, reset outputHash to pkgs.lib.fakeHash, build to -# get the correct hash from the error message, then update. -{ pkgs ? import <nixpkgs> { }, sources ? import ./sources.nix { inherit pkgs; } }: - -pkgs.stdenv.mkDerivation { - pname = "authentik-python-deps"; - version = sources.version; - - src = sources.src; - - nativeBuildInputs = with pkgs; [ - python314 - uv - git # opencontainers is a git dependency in uv.lock - cacert # HTTPS verification for PyPI + GitHub - pkg-config - removeReferencesTo - # Build tools on PATH for sdist compilation - postgresql.pg_config # pg_config for psycopg-c - krb5 # krb5-config for gssapi - ]; - - # System libraries for packages that must build from sdist: - # lxml, xmlsec — pyproject.toml [tool.uv] no-binary-package - # psycopg-c — sdist only on PyPI - # gssapi — no Linux wheels on PyPI - buildInputs = with pkgs; [ - libxml2 - libxslt - xmlsec - openssl - libpq # psycopg-c links against libpq - libtool # libltdl for xmlsec dynamic crypto backend loading - libffi - zlib - ]; - - buildPhase = '' - runHook preBuild - - export HOME=$TMPDIR - export SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt - export GIT_SSL_CAINFO=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt - export UV_PYTHON=${pkgs.python314}/bin/python3.14 - export UV_LINK_MODE=copy - - # gssapi's pre-generated C code uses S4U functions declared in gssapi_ext.h - # but doesn't include it — force-include via compiler flag - export NIX_CFLAGS_COMPILE="''${NIX_CFLAGS_COMPILE:-} -include gssapi/gssapi_ext.h" - - uv sync \ - --frozen \ - --no-install-project \ - --no-install-workspace \ - --no-dev - - runHook postBuild - ''; - - installPhase = '' - runHook preInstall - mv .venv $out - - # --- Strip Nix store references (FODs must be self-contained) --- - # autoPatchelfHook in authentik-django.nix restores correct RPATHs. - - # Replace python store path in pyvenv.cfg with placeholder - sed -i "s|${pkgs.python314}|@python@|g" $out/pyvenv.cfg - - # Remove bin/ entirely — main derivation recreates it - rm -rf $out/bin - - # Strip store refs from .pyc files (contain embedded paths) - find $out -type f -name '*.pyc' -delete - - # Dynamically discover ALL remaining Nix store paths in the output. - # This is more robust than a static list of store paths — any new - # build/runtime dependency is automatically handled. - # Note: || true needed because xargs returns 123 if grep returns 1 - # (no match) on any batch, and pipefail propagates that. - { 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" - - # Build remove-references-to args from discovered paths - refs_args="" - while IFS= read -r ref; do - refs_args="$refs_args -t $ref" - done < $TMPDIR/store-refs.txt - - # Strip all discovered references from all files - if [ -n "$refs_args" ]; then - find $out -type f -exec remove-references-to $refs_args {} + 2>/dev/null || true - fi - - # Verify — report any remaining references - 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" - if [ "$remaining" -gt 0 ]; then - echo "WARNING: Files still containing store references:" - { find $out -type f -print0 | xargs -0 grep -l '/nix/store/' 2>/dev/null || true; } - fi - - runHook postInstall - ''; - - outputHashMode = "recursive"; - outputHashAlgo = "sha256"; - outputHash = "sha256-PMNooWKoEWy/G0G3BLAWEJTqvj3FJi34ibougjwdE+c="; - - dontFixup = true; -} diff --git a/containers/authentik/sources.nix b/containers/authentik/sources.nix deleted file mode 100644 index 96aed12..0000000 --- a/containers/authentik/sources.nix +++ /dev/null @@ -1,30 +0,0 @@ -# Centralized version and source pinning for authentik 2026.2.2 -# All sources fetched from forge mirrors for supply chain control -{ pkgs ? import <nixpkgs> { } }: - -let - version = "2026.2.2"; -in -{ - inherit version; - - # Main authentik repo — provides schema.yml, Python backend, web UI, Go server - src = pkgs.fetchgit { - url = "https://forge.ops.eblu.me/mirrors/authentik.git"; - rev = "version/${version}"; - 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.2026.2.1"; - hash = "sha256-sFj+KAFHe3ajOFUtfBl9X3AVIvMCO8+Xba+/Jsy7Cgo="; - }; - - meta = with pkgs.lib; { - description = "Authentik identity provider"; - homepage = "https://goauthentik.io"; - license = licenses.mit; - }; -} diff --git a/containers/authentik/test-build.nix b/containers/authentik/test-build.nix deleted file mode 100644 index 57b7569..0000000 --- a/containers/authentik/test-build.nix +++ /dev/null @@ -1,44 +0,0 @@ -# Test harness for building authentik components on ringtail -# Uses builtins.getFlake instead of <nixpkgs> (ringtail has flakes, no NIX_PATH) -# -# Usage: -# nix-build test-build.nix -A python-deps --extra-experimental-features 'nix-command flakes' -# nix-build test-build.nix -A authentik-django --extra-experimental-features 'nix-command flakes' -# nix-build test-build.nix -A client-go --extra-experimental-features 'nix-command flakes' -# nix-build test-build.nix -A client-ts --extra-experimental-features 'nix-command flakes' -# nix-build test-build.nix -A authentik-server --extra-experimental-features 'nix-command flakes' -# nix-build test-build.nix -A webui-deps --extra-experimental-features 'nix-command flakes' -# nix-build test-build.nix -A webui --extra-experimental-features 'nix-command flakes' -# nix-build test-build.nix -A assembled --extra-experimental-features 'nix-command flakes' -let - pkgs = (builtins.getFlake "nixpkgs").legacyPackages.x86_64-linux; - sources = import ./sources.nix { inherit pkgs; }; - - # Individual components (isolated, no cross-wiring) - _webui = import ./webui.nix { inherit pkgs sources; }; - - # Fully wired assembly (webui → authentik-django → authentik-server) - _authentik-django-assembled = import ./authentik-django.nix { inherit pkgs sources; webui = _webui; }; - _authentik-server-assembled = import ./authentik-server.nix { - inherit pkgs sources; - authentik-django = _authentik-django-assembled; - webui = _webui; - }; -in -{ - # Individual component builds (for debugging in isolation) - python-deps = import ./python-deps.nix { inherit pkgs sources; }; - authentik-django = import ./authentik-django.nix { inherit pkgs sources; }; - client-go = import ./client-go.nix { inherit pkgs sources; }; - client-ts = import ./client-ts.nix { inherit pkgs sources; }; - authentik-server = import ./authentik-server.nix { inherit pkgs sources; }; - webui-deps = import ./webui-deps.nix { inherit pkgs sources; }; - webui = _webui; - - # Fully assembled stack — tests that all components wire together - assembled = pkgs.linkFarm "authentik-assembled-${sources.version}" [ - { name = "authentik-django"; path = _authentik-django-assembled; } - { name = "authentik-server"; path = _authentik-server-assembled; } - { name = "webui"; path = _webui; } - ]; -} diff --git a/containers/authentik/webui-deps.nix b/containers/authentik/webui-deps.nix deleted file mode 100644 index 5364a4b..0000000 --- a/containers/authentik/webui-deps.nix +++ /dev/null @@ -1,51 +0,0 @@ -# Fixed-output derivation for authentik web UI npm dependencies -# -# Runs `npm ci` in the web/ directory to fetch all Node.js dependencies. -# This is a FOD (fixed-output derivation) so it has network access during build -# but the output hash must match exactly. -# -# The output hash is platform-specific because npm downloads platform-specific -# native binaries for esbuild, rollup, and SWC. -# -# Workspace packages (under web/packages/*) have their own node_modules, -# so we collect all node_modules directories via find. -# -# Output: all node_modules directories from the web/ tree -{ pkgs ? import <nixpkgs> { }, sources ? import ./sources.nix { inherit pkgs; } }: - -pkgs.stdenvNoCC.mkDerivation { - pname = "authentik-webui-deps"; - inherit (sources) version src meta; - - sourceRoot = "${sources.src.name}/web"; - - outputHash = - { - "x86_64-linux" = "sha256-+4cWvFuixCcO7P+z701/0H+Ah/Z5sbLNsdx2Uowqwf4="; - } - .${pkgs.stdenvNoCC.hostPlatform.system} - or (throw "authentik-webui-deps: unsupported host platform ${pkgs.stdenvNoCC.hostPlatform.system}"); - outputHashMode = "recursive"; - - nativeBuildInputs = with pkgs; [ - nodejs_24 - cacert - ]; - - buildPhase = '' - npm ci --cache ./cache --ignore-scripts - rm -r ./cache node_modules/.package-lock.json - ''; - - # Workspace packages install dependencies into separate node_modules - # directories with symlinks between them — copy all of them - installPhase = '' - mkdir $out - find -type d -name node_modules -prune -print \ - -exec mkdir -p $out/{} \; \ - -exec cp -rT {} $out/{} \; - ''; - - dontCheckForBrokenSymlinks = true; - dontPatchShebangs = true; -} diff --git a/containers/authentik/webui.nix b/containers/authentik/webui.nix deleted file mode 100644 index 43b4177..0000000 --- a/containers/authentik/webui.nix +++ /dev/null @@ -1,80 +0,0 @@ -# Authentik web UI build -# -# Builds the Lit-based TypeScript frontend from the web/ directory. -# Uses esbuild (via wireit) for the main build and rollup for the SFE -# (Standalone Frontend Engine) sub-package. -# -# Inputs: -# - webui-deps: FOD with npm dependencies (node_modules trees) -# - client-ts: generated TypeScript API client from schema.yml -# -# Output: -# $out/dist/ esbuild bundle (admin, user, flow, rac, etc.) -# $out/authentik/ static icons for authentication sources/connectors -{ pkgs ? import <nixpkgs> { } -, sources ? import ./sources.nix { inherit pkgs; } -, webui-deps ? import ./webui-deps.nix { inherit pkgs sources; } -, client-ts ? import ./client-ts.nix { inherit pkgs sources; } -}: - -pkgs.stdenvNoCC.mkDerivation { - pname = "authentik-webui"; - inherit (sources) version src meta; - - sourceRoot = "${sources.src.name}/web"; - - nativeBuildInputs = with pkgs; [ - nodejs_24 - ]; - - # Hardcode version string instead of importing from package.json - # (the JSON import-with-assertion may not resolve in the Nix build sandbox) - postPatch = '' - substituteInPlace packages/core/version/node.js \ - --replace-fail \ - 'import PackageJSON from "../../../../package.json" with { type: "json" };' \ - "" \ - --replace-fail \ - '(PackageJSON.version);' \ - '"${sources.version}";' - ''; - - buildPhase = '' - runHook preBuild - - # Copy node_modules from the FOD into the build tree - buildRoot=$PWD - pushd ${webui-deps} - find -type d -name node_modules -prune -print \ - -exec cp -rT {} $buildRoot/{} \; - popd - - # Replace the npm-published @goauthentik/api with our generated client - chmod -R +w node_modules/@goauthentik - rm -rf node_modules/@goauthentik/api - ln -sn ${client-ts} node_modules/@goauthentik/api - - # Patch shebangs on build tool binaries so they can run in the sandbox - pushd node_modules/.bin - for tool in rollup wireit lit-localize esbuild; do - [ -L "$tool" ] && patchShebangs "$(readlink "$tool")" 2>/dev/null || true - done - popd - - npm run build - npm run build:sfe - - runHook postBuild - ''; - - installPhase = '' - runHook preInstall - mkdir $out - cp -r dist $out/dist - cp -r authentik $out/authentik - runHook postInstall - ''; - - NODE_ENV = "production"; - NODE_OPTIONS = "--openssl-legacy-provider"; -} diff --git a/containers/external-secrets/container.py b/containers/external-secrets/container.py deleted file mode 100644 index 6be5765..0000000 --- a/containers/external-secrets/container.py +++ /dev/null @@ -1,51 +0,0 @@ -"""External Secrets Operator — native Dagger build. - -Two-stage build: Go binary (all providers), Alpine runtime. -Source cloned from forge mirror. - -A single binary serves as the controller, webhook, and cert-controller; the -Deployments select the role via a subcommand passed in `args:`, so the image -ENTRYPOINT must be the binary itself (matching upstream's distroless image). -""" - -import dagger - -from blumeops.containers import ( - alpine_runtime, - clone_from_forge, - go_build, - oci_labels, -) - -VERSION = "v2.2.0" - - -async def build(src: dagger.Directory) -> dagger.Container: - source = clone_from_forge("external-secrets", VERSION) - - # Upstream `make build` compiles every secret provider into a single - # static binary (`-tags all_providers`, CGO disabled). Mirror that so the - # local image is functionally identical to ghcr.io/.../external-secrets. - backend = go_build( - source, - "/external-secrets", - tags="all_providers", - ) - - runtime = alpine_runtime( - extra_apk=["ca-certificates"], - create_user=False, - ) - runtime = oci_labels( - runtime, - title="External Secrets Operator", - description=( - "Kubernetes operator that integrates external secret management systems" - ), - version=VERSION, - ) - return ( - runtime.with_file("/bin/external-secrets", backend.file("/external-secrets")) - .with_user("65534") - .with_entrypoint(["/bin/external-secrets"]) - ) diff --git a/containers/external-secrets/default.nix b/containers/external-secrets/default.nix deleted file mode 100644 index eabe03d..0000000 --- a/containers/external-secrets/default.nix +++ /dev/null @@ -1,56 +0,0 @@ -# Nix-built External Secrets Operator (amd64, for ringtail k3s). -# Builds v2.2.0 from the forge mirror with all secret providers compiled in, -# faithful to upstream's `make build` (-tags all_providers). The container.py -# sibling builds the arm64 image for indri's minikube; this default.nix builds -# the amd64 image on ringtail's nix-container-builder. -{ pkgs ? import <nixpkgs> { } }: - -let - version = "2.2.0"; - - src = pkgs.fetchgit { - url = "https://forge.ops.eblu.me/mirrors/external-secrets.git"; - rev = "v${version}"; - hash = "sha256-eAocOAp5s4CFRrpKfQr2lf3Ji+6nQQ1A5/eTw5B7v9U="; - }; - - # external-secrets v2.2.0 requires Go >= 1.26.1; nixpkgs default go is 1.25.x. - external-secrets = (pkgs.buildGoModule.override { go = pkgs.go_1_26; }) { - inherit src version; - pname = "external-secrets"; - vendorHash = "sha256-0xuBK3fjAplPLAElHvKB6d+2lDz+De/s91fV4dPZwjE="; - - doCheck = false; - - subPackages = [ "." ]; - - tags = [ "all_providers" ]; - - ldflags = [ "-s" "-w" ]; - - meta = with pkgs.lib; { - description = "Kubernetes operator that integrates external secret management systems"; - homepage = "https://github.com/external-secrets/external-secrets"; - license = licenses.asl20; - mainProgram = "external-secrets"; - }; - }; -in - -pkgs.dockerTools.buildLayeredImage { - name = "blumeops/external-secrets"; - contents = [ - external-secrets - pkgs.cacert - pkgs.tzdata - ]; - - config = { - Entrypoint = [ "${external-secrets}/bin/external-secrets" ]; - Env = [ - "SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" - "TZDIR=${pkgs.tzdata}/share/zoneinfo" - ]; - User = "65534"; - }; -} diff --git a/containers/forgejo-runner/container.py b/containers/forgejo-runner/container.py deleted file mode 100644 index dfb2edf..0000000 --- a/containers/forgejo-runner/container.py +++ /dev/null @@ -1,54 +0,0 @@ -"""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 blumeops.containers import ( - alpine_runtime, - clone_from_forge, - go_build, - oci_labels, -) - -VERSION = "12.8.2" - - -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) - 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 - runtime = alpine_runtime( - extra_apk=["git", "bash", "ca-certificates", "gettext-envsubst"], - 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/containers/frigate-notify/default.nix b/containers/frigate-notify/default.nix deleted file mode 100644 index 701b194..0000000 --- a/containers/frigate-notify/default.nix +++ /dev/null @@ -1,66 +0,0 @@ -# Nix-built frigate-notify — polls Frigate webapi and pushes alerts to ntfy. -{ pkgs ? import <nixpkgs> { } }: - -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 - ]; - - # 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" - ]; - ExposedPorts = { - "8000/tcp" = { }; - }; - User = "65534"; - }; -} diff --git a/containers/grafana-sidecar/container.py b/containers/grafana-sidecar/container.py deleted file mode 100644 index 83950a7..0000000 --- a/containers/grafana-sidecar/container.py +++ /dev/null @@ -1,63 +0,0 @@ -"""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/containers/grafana/Dockerfile b/containers/grafana/Dockerfile deleted file mode 100644 index 3b33dd9..0000000 --- a/containers/grafana/Dockerfile +++ /dev/null @@ -1,68 +0,0 @@ -ARG CONTAINER_APP_VERSION=12.4.2 - -FROM alpine:3.22 - -ARG TARGETPLATFORM -ARG CONTAINER_APP_VERSION -ARG GRAFANA_VERSION=${CONTAINER_APP_VERSION} - -RUN set -e && \ - apk --no-cache add dumb-init curl && \ - # Detect architecture - if [ -n "$TARGETPLATFORM" ]; then \ - echo "TARGETPLATFORM: $TARGETPLATFORM"; \ - case "$TARGETPLATFORM" in \ - linux/arm64*) ARCH="arm64" ;; \ - linux/amd64*) ARCH="amd64" ;; \ - *) 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="arm64" ;; \ - x86_64) ARCH="amd64" ;; \ - *) ARCH="" ;; \ - esac; \ - fi && \ - if [ -z "$ARCH" ]; then \ - echo "ERROR: Unsupported architecture"; \ - exit 1; \ - fi && \ - url="https://dl.grafana.com/oss/release/grafana-${GRAFANA_VERSION}.linux-${ARCH}.tar.gz" && \ - echo "URL: $url" && \ - curl -fSL "$url" | tar -xz -C /tmp && \ - mv /tmp/grafana-${GRAFANA_VERSION} /usr/share/grafana && \ - apk del curl - -# Standard Grafana paths -RUN mkdir -p /etc/grafana /var/lib/grafana /var/log/grafana && \ - cp /usr/share/grafana/conf/defaults.ini /etc/grafana/grafana.ini && \ - cp /usr/share/grafana/conf/defaults.ini /etc/grafana/defaults.ini - -# UID 472 matches official Grafana image for PVC compatibility -RUN adduser -D -u 472 -h /usr/share/grafana grafana && \ - chown -R grafana:grafana /usr/share/grafana /etc/grafana /var/lib/grafana /var/log/grafana - -ENV PATH="/usr/share/grafana/bin:$PATH" - -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.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", \ - "--homepath=/usr/share/grafana", \ - "--config=/etc/grafana/grafana.ini", \ - "cfg:default.paths.data=/var/lib/grafana", \ - "cfg:default.paths.logs=/var/log/grafana", \ - "cfg:default.paths.plugins=/var/lib/grafana/plugins", \ - "cfg:default.paths.provisioning=/etc/grafana/provisioning"] diff --git a/containers/homepage/default.nix b/containers/homepage/default.nix deleted file mode 100644 index 6217847..0000000 --- a/containers/homepage/default.nix +++ /dev/null @@ -1,130 +0,0 @@ -# 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 <nixpkgs> { } }: - -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 - ''; - - # /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/<file>.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 = [ - "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"; - }; -} diff --git a/containers/kingfisher/Cargo.lock b/containers/kingfisher/Cargo.lock deleted file mode 100644 index 0612332..0000000 --- a/containers/kingfisher/Cargo.lock +++ /dev/null @@ -1,10002 +0,0 @@ -# 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 deleted file mode 100644 index 8618b88..0000000 --- a/containers/kingfisher/default.nix +++ /dev/null @@ -1,122 +0,0 @@ -# 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 <nixpkgs> { } }: - -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.bashInteractive - pkgs.coreutils - pkgs.cacert - pkgs.git - pkgs.tzdata - ]; - - extraCommands = '' - mkdir -p tmp - chmod 1777 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"; - }; -} diff --git a/containers/kiwix-serve/container.py b/containers/kiwix-serve/container.py deleted file mode 100644 index 1e6596e..0000000 --- a/containers/kiwix-serve/container.py +++ /dev/null @@ -1,53 +0,0 @@ -"""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] <zim-files>' && kiwix-serve --help", - ] - ) - ) diff --git a/containers/kube-state-metrics/Dockerfile b/containers/kube-state-metrics/Dockerfile deleted file mode 100644 index ebaf8e6..0000000 --- a/containers/kube-state-metrics/Dockerfile +++ /dev/null @@ -1,44 +0,0 @@ -# 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 deleted file mode 100644 index bd83db5..0000000 --- a/containers/kube-state-metrics/default.nix +++ /dev/null @@ -1,59 +0,0 @@ -# Nix-built kube-state-metrics -# Builds v2.18.0 from forge mirror -# Built with dockerTools.buildLayeredImage for efficient layer caching -{ pkgs ? import <nixpkgs> { } }: - -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/containers/kubectl/Dockerfile b/containers/kubectl/Dockerfile deleted file mode 100644 index bcec94a..0000000 --- a/containers/kubectl/Dockerfile +++ /dev/null @@ -1,46 +0,0 @@ -# Minimal kubectl container -# Multi-arch build: downloads correct binary for target platform - -ARG CONTAINER_APP_VERSION=v1.34.4 - -FROM alpine:3.22 AS downloader - -ARG TARGETARCH -ARG CONTAINER_APP_VERSION -ARG KUBECTL_VERSION=${CONTAINER_APP_VERSION} - -RUN apk add --no-cache curl && \ - # Detect architecture - use TARGETARCH if set, otherwise detect from uname - if [ -n "$TARGETARCH" ]; then \ - ARCH="$TARGETARCH"; \ - else \ - UNAME_ARCH=$(uname -m); \ - case "$UNAME_ARCH" in \ - aarch64|arm64) ARCH="arm64" ;; \ - x86_64) ARCH="amd64" ;; \ - *) echo "Unsupported architecture: $UNAME_ARCH"; exit 1 ;; \ - esac; \ - fi && \ - echo "Downloading kubectl for $ARCH..." && \ - curl -LO "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/${ARCH}/kubectl" && \ - chmod +x kubectl - -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 -RUN apk add --no-cache ca-certificates bash - -# Run as non-root -RUN adduser -D -u 1000 kubectl -USER kubectl - -ENTRYPOINT ["kubectl"] diff --git a/containers/loki/Dockerfile b/containers/loki/Dockerfile deleted file mode 100644 index 8ef0b2a..0000000 --- a/containers/loki/Dockerfile +++ /dev/null @@ -1,41 +0,0 @@ -# Grafana Loki log aggregation system -# Two-stage build: Go binary, Alpine runtime - -ARG CONTAINER_APP_VERSION=3.6.7 -ARG LOKI_VERSION=v${CONTAINER_APP_VERSION} - -FROM golang:alpine3.22 AS build - -ARG LOKI_VERSION -RUN apk add --no-cache build-base git - -RUN git clone --depth 1 --branch ${LOKI_VERSION} \ - https://forge.ops.eblu.me/mirrors/loki.git /go/src/app - -WORKDIR /go/src/app -ENV CGO_ENABLED=0 - -RUN go build -tags netgo \ - -ldflags="-w -s \ - -X github.com/grafana/loki/v3/pkg/util/build.Version=${LOKI_VERSION} \ - -X github.com/grafana/loki/v3/pkg/util/build.Branch=HEAD \ - -X github.com/grafana/loki/v3/pkg/util/build.BuildUser=blumeops \ - -X github.com/grafana/loki/v3/pkg/util/build.Revision=blumeops-build" \ - -o /bin/loki ./cmd/loki - -FROM alpine:3.22 - -ARG CONTAINER_APP_VERSION -LABEL org.opencontainers.image.title="Loki" -LABEL org.opencontainers.image.description="Grafana Loki log aggregation 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" - -RUN apk add --no-cache ca-certificates tzdata -RUN mkdir -p /loki && chown 10001:10001 /loki - -USER 10001 -COPY --from=build /bin/loki /usr/bin/loki -EXPOSE 3100 -ENTRYPOINT ["/usr/bin/loki"] diff --git a/containers/mealie/default.nix b/containers/mealie/default.nix deleted file mode 100644 index e55efe3..0000000 --- a/containers/mealie/default.nix +++ /dev/null @@ -1,69 +0,0 @@ -# Nix-built Mealie for ringtail (amd64). -# -# Replaces the from-source Dockerfile build (Node frontend + Python venv) -# with nixpkgs' mealie, which ships a single `mealie` gunicorn entrypoint -# serving the prebuilt frontend + backend — so this is a clean single- -# process wrap (unlike paperless, which is multi-process). -# -# Mealie stores its DB as SQLite under DATA_DIR (the mealie-data PVC at -# /app/data); there is no postgres. The run wrapper mirrors the nixpkgs -# mealie NixOS module: run `libexec/init_db` (Alembic migrations) first, -# then exec gunicorn. -# -# Self-pins nixos-unstable: stable nixpkgs lags at 3.9.2, unstable carries -# 3.16.0. This is a forward 4-minor bump from the v3.12.0 Dockerfile build -# (the deferred upgrade) — mealie auto-migrates the SQLite DB forward on -# startup via init_db; the source PVC is retained for rollback. The version -# assertion makes nix-build fail if a pin bump changes the version. -let - nixpkgs = fetchTarball { - url = "https://github.com/NixOS/nixpkgs/archive/331800de5053fcebacf6813adb5db9c9dca22a0c.tar.gz"; - sha256 = "1p54fm6dkbq62kpi55cr4wyx7b1nsajpsnjgs64cmp073fwi15f7"; - }; - pkgs = import nixpkgs { system = "x86_64-linux"; }; - - version = "3.16.0"; - - app = pkgs.mealie; - - # Mirror the NixOS module's mealie service: init_db (Alembic) then - # gunicorn bound to the app port. DATA_DIR/env come from the image + - # k8s manifest. - mealie-run = pkgs.writeShellScriptBin "mealie-run" '' - set -e - ${app}/libexec/init_db - exec ${pkgs.lib.getExe app} -b 0.0.0.0:9000 - ''; -in - -assert app.version == version; - -pkgs.dockerTools.buildLayeredImage { - name = "blumeops/mealie"; - - contents = [ - app - mealie-run - pkgs.bashInteractive - pkgs.coreutils - pkgs.cacert - pkgs.tzdata - # python3 (stdlib sqlite3) for the borgmatic k8s-sqlite-dump helper, - # which runs `python3 -c "...sqlite3...backup..."` inside the pod. - # Same nixpkgs python mealie is built against, so ~no added closure. - pkgs.python3 - ]; - - config = { - Cmd = [ "${mealie-run}/bin/mealie-run" ]; - Env = [ - "DATA_DIR=/app/data" - "SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" - "PYTHONUNBUFFERED=1" - "PRODUCTION=true" - ]; - ExposedPorts = { - "9000/tcp" = { }; - }; - }; -} diff --git a/containers/miniflux/container.py b/containers/miniflux/container.py deleted file mode 100644 index e25485c..0000000 --- a/containers/miniflux/container.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Miniflux RSS feed reader — native Dagger build. - -Two-stage build: Go (backend with PIE), Alpine (runtime). -Source cloned from forge mirror. -""" - -import dagger - -from blumeops.containers import ( - alpine_runtime, - clone_from_forge, - go_build, - 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) - 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) - 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/containers/navidrome/container.py b/containers/navidrome/container.py deleted file mode 100644 index a50a71f..0000000 --- a/containers/navidrome/container.py +++ /dev/null @@ -1,54 +0,0 @@ -"""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/containers/ntfy/Dockerfile b/containers/ntfy/Dockerfile deleted file mode 100644 index e53a39d..0000000 --- a/containers/ntfy/Dockerfile +++ /dev/null @@ -1,66 +0,0 @@ -# ntfy push notification server -# Three-stage build: Web UI (Node), server (Go+SQLite), runtime (Alpine) - -ARG CONTAINER_APP_VERSION=v2.19.2 -ARG NTFY_VERSION=${CONTAINER_APP_VERSION} -ARG NTFY_COMMIT=2ad78edca12f1a43c566ffab9e93b3ed26426a6c - -FROM node:22-alpine AS web-build - -ARG NTFY_COMMIT -RUN apk add --no-cache git - -RUN mkdir /app && cd /app \ - && git init \ - && git remote add origin https://forge.ops.eblu.me/mirrors/ntfy.git \ - && git fetch --depth 1 origin ${NTFY_COMMIT} \ - && git checkout FETCH_HEAD - -WORKDIR /app/web -RUN npm ci -RUN npm run build - -FROM golang:alpine3.22 AS build - -ARG NTFY_VERSION -ARG NTFY_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/ntfy.git \ - && git fetch --depth 1 origin ${NTFY_COMMIT} \ - && git checkout FETCH_HEAD - -WORKDIR /app - -# Copy pre-built web UI assets -COPY --from=web-build /app/web/build /app/server/site - -# Create docs placeholder with dummy file (go:embed requires non-empty dir) -RUN mkdir -p server/docs && touch server/docs/placeholder - -ENV CGO_ENABLED=1 - -RUN go build \ - -o /ntfy \ - -tags sqlite_omit_load_extension,osusergo,netgo \ - -ldflags "-linkmode=external -extldflags=-static -s -w -X main.version=${NTFY_VERSION}" - -FROM alpine:3.22 - -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.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 - -COPY --from=build /ntfy /usr/bin/ntfy - -EXPOSE 80 - -USER 65534 -ENTRYPOINT ["/usr/bin/ntfy"] diff --git a/containers/ntfy/default.nix b/containers/ntfy/default.nix deleted file mode 100644 index cc1bc2a..0000000 --- a/containers/ntfy/default.nix +++ /dev/null @@ -1,87 +0,0 @@ -# Nix-built ntfy push notification server -# Builds v2.19.2 from forge mirror -# Built with dockerTools.buildLayeredImage for efficient layer caching -{ pkgs ? import <nixpkgs> { } }: - -let - version = "2.19.2"; - - src = pkgs.fetchgit { - url = "https://forge.ops.eblu.me/mirrors/ntfy.git"; - rev = "v${version}"; - hash = "sha256-HISQnb6LkKGujZsWCzVD3dTuobhUXqrmTFuov7dU+lY="; - }; - - ui = pkgs.buildNpmPackage { - inherit src version; - pname = "ntfy-sh-ui"; - npmDepsHash = "sha256-PmhWzktybM6Cg7yYRfbxWE83C+XkmHh4garHhsydwwE="; - - prePatch = '' - cd web/ - ''; - - installPhase = '' - runHook preInstall - mv build/index.html build/app.html - rm build/config.js - mkdir -p $out - mv build/ $out/site - runHook postInstall - ''; - }; - - ntfy = pkgs.buildGoModule { - inherit src version; - pname = "ntfy-sh"; - vendorHash = "sha256-mr2PbxT5QWf4HZGgUg+oUjauqmZ6bh6N3f0ytwPDppU="; - - doCheck = false; - - subPackages = [ "." ]; - - ldflags = [ - "-s" - "-w" - "-X main.version=${version}" - ]; - - postPatch = '' - sed -i 's# /bin/echo# echo#' Makefile - ''; - - # Copy pre-built web UI; skip docs (create placeholder for go:embed) - preBuild = '' - cp -r ${ui}/site/ server/ - mkdir -p server/docs && touch server/docs/placeholder - ''; - - meta = with pkgs.lib; { - description = "Send push notifications to your phone or desktop via PUT/POST"; - homepage = "https://ntfy.sh"; - license = licenses.asl20; - mainProgram = "ntfy"; - }; - }; -in - -pkgs.dockerTools.buildLayeredImage { - name = "blumeops/ntfy"; - contents = [ - ntfy - pkgs.cacert - pkgs.tzdata - ]; - - config = { - Entrypoint = [ "${ntfy}/bin/ntfy" ]; - Env = [ - "SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" - "TZDIR=${pkgs.tzdata}/share/zoneinfo" - ]; - ExposedPorts = { - "80/tcp" = { }; - }; - User = "65534"; - }; -} diff --git a/containers/paperless/default.nix b/containers/paperless/default.nix deleted file mode 100644 index 734d909..0000000 --- a/containers/paperless/default.nix +++ /dev/null @@ -1,77 +0,0 @@ -# Nix-built Paperless-ngx for ringtail (amd64). -# -# Replaces the from-source Dockerfile build (s6-overlay) with nixpkgs' -# paperless-ngx, which already bundles the full OCR/imaging closure -# (tesseract, ghostscript, imagemagick, qpdf, poppler, jbig2enc) and the -# NLTK data via wrappers — so the image stays lean. -# -# Unlike the upstream s6 image, this image does NOT run all processes -# itself. Paperless is multi-process; on ringtail it runs as four -# containers sharing this one image, each with a different command: -# web -> paperless-web (granian, the wrapper below) -# worker -> celery --app paperless worker -# beat -> celery --app paperless beat -# consumer -> paperless-ngx document_consumer -# plus a redis/valkey sidecar. The PYTHONPATH/granian invocation mirrors -# the nixpkgs paperless NixOS module's paperless-web service exactly. -# -# Self-pins nixos-unstable: stable nixpkgs lags at 2.19.6, while unstable -# carries 2.20.15 — a same-minor forward patch bump from the previous -# Dockerfile build (v2.20.13). The version assertion makes nix-build fail -# if a pin bump changes the version, forcing an explicit acknowledgment -# here and in service-versions.yaml (enforced by container-version-check). -let - nixpkgs = fetchTarball { - url = "https://github.com/NixOS/nixpkgs/archive/331800de5053fcebacf6813adb5db9c9dca22a0c.tar.gz"; - sha256 = "1p54fm6dkbq62kpi55cr4wyx7b1nsajpsnjgs64cmp073fwi15f7"; - }; - pkgs = import nixpkgs { system = "x86_64-linux"; }; - - version = "2.20.15"; - - app = pkgs.paperless-ngx; - - # Mirror the NixOS module's paperless-web service: granian serving the - # ASGI app with the package's propagated deps + src on PYTHONPATH. - pythonPath = - "${app.python.pkgs.makePythonPath app.propagatedBuildInputs}:${app}/lib/paperless-ngx/src"; - - paperless-web = pkgs.writeShellScriptBin "paperless-web" '' - export PYTHONPATH="${pythonPath}" - export PAPERLESS_NLTK_DIR="${app.nltkDataDir}" - exec ${app.python.pkgs.granian}/bin/granian \ - --interface asginl --ws \ - --host 0.0.0.0 --port 8000 \ - "paperless.asgi:application" - ''; -in - -assert app.version == version; - -pkgs.dockerTools.buildLayeredImage { - name = "blumeops/paperless"; - - contents = [ - app - paperless-web - pkgs.bashInteractive - pkgs.coreutils - pkgs.cacert - pkgs.tzdata - ]; - - config = { - # Default command is the web server; worker/beat/consumer containers - # override `command` in their k8s manifests. - Cmd = [ "${paperless-web}/bin/paperless-web" ]; - Env = [ - "PAPERLESS_NLTK_DIR=${app.nltkDataDir}" - "SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" - "PYTHONUNBUFFERED=1" - "PNGX_CONTAINERIZED=1" - ]; - ExposedPorts = { - "8000/tcp" = { }; - }; - }; -} diff --git a/containers/prometheus/Dockerfile b/containers/prometheus/Dockerfile deleted file mode 100644 index 717293d..0000000 --- a/containers/prometheus/Dockerfile +++ /dev/null @@ -1,79 +0,0 @@ -# Prometheus monitoring system -# Three-stage build: Web UI (Node), binaries (Go), runtime (Alpine) - -ARG CONTAINER_APP_VERSION=v3.10.0 -ARG PROMETHEUS_VERSION=${CONTAINER_APP_VERSION} - -FROM node:22-alpine AS ui-build - -ARG PROMETHEUS_VERSION -RUN apk add --no-cache git bash - -RUN git clone --depth 1 --branch ${PROMETHEUS_VERSION} \ - https://forge.ops.eblu.me/mirrors/prometheus.git /app - -WORKDIR /app/web/ui - -# Install workspace dependencies (mantine-ui, modules) -RUN npm ci - -# Install legacy React app dependencies (separated from workspaces upstream) -RUN cd react-app && npm ci - -# Build all UI components: modules, react-app, mantine-ui → static/ -RUN npm run build - -FROM golang:alpine3.22 AS build - -ARG PROMETHEUS_VERSION -RUN apk add --no-cache build-base git bash - -RUN git clone --depth 1 --branch ${PROMETHEUS_VERSION} \ - https://forge.ops.eblu.me/mirrors/prometheus.git /app - -WORKDIR /app - -# Copy pre-built UI assets -COPY --from=ui-build /app/web/ui/static /app/web/ui/static - -# Generate embed.go with //go:embed directives for gzipped assets -RUN scripts/compress_assets.sh - -ENV CGO_ENABLED=0 - -RUN go build -tags netgo,builtinassets \ - -ldflags="-w -s -X github.com/prometheus/common/version.Version=${PROMETHEUS_VERSION} \ - -X github.com/prometheus/common/version.Branch=HEAD \ - -X github.com/prometheus/common/version.BuildUser=blumeops \ - -X github.com/prometheus/common/version.Revision=blumeops-build" \ - -o /bin/prometheus ./cmd/prometheus - -RUN go build -tags netgo,builtinassets \ - -ldflags="-w -s -X github.com/prometheus/common/version.Version=${PROMETHEUS_VERSION}" \ - -o /bin/promtool ./cmd/promtool - -FROM alpine:3.22 - -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.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 /prometheus /etc/prometheus \ - && chown -R 65534:65534 /prometheus /etc/prometheus - -COPY --from=build /bin/prometheus /usr/bin/prometheus -COPY --from=build /bin/promtool /usr/bin/promtool -COPY --from=build /app/documentation/examples/prometheus.yml /etc/prometheus/prometheus.yml - -EXPOSE 9090 -VOLUME ["/prometheus"] - -USER 65534 -ENTRYPOINT ["/usr/bin/prometheus"] -CMD ["--config.file=/etc/prometheus/prometheus.yml", \ - "--storage.tsdb.path=/prometheus"] diff --git a/containers/prowler/Dockerfile b/containers/prowler/Dockerfile deleted file mode 100644 index c5157cb..0000000 --- a/containers/prowler/Dockerfile +++ /dev/null @@ -1,81 +0,0 @@ -# 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.23.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 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 \ - && 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 - -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 - -ENTRYPOINT ["prowler"] diff --git a/containers/runner-job-image/container.py b/containers/runner-job-image/container.py deleted file mode 100644 index c5710ff..0000000 --- a/containers/runner-job-image/container.py +++ /dev/null @@ -1,79 +0,0 @@ -"""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/containers/shower/default.nix b/containers/shower/default.nix deleted file mode 100644 index c5bd41e..0000000 --- a/containers/shower/default.nix +++ /dev/null @@ -1,278 +0,0 @@ -# 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.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`: -# 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 <nixpkgs> { } }: - -let - version = "1.1.3"; - - 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-a3rCwEdOB+rnYXqsWDifyltpyKUgkOj0ikWB+WGQYKE="; - }; - - # 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-a6j91gBigG4IzE2DVTBntnZ46Yrx9b5PgHn+Uro98Tk="; - }; - - 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 - - # 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/ \ - "$TMPDIR/${showerWheel.name}" \ - 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-1xx2qWAIwherklHIPXo6IOKkKHML1KUrUx6pbkMxffc="; - - 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 <subcommand>` 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/containers/tailscale/default.nix b/containers/tailscale/default.nix deleted file mode 100644 index 8e87f76..0000000 --- a/containers/tailscale/default.nix +++ /dev/null @@ -1,77 +0,0 @@ -# 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 <nixpkgs> { } }: - -let - version = "1.94.2"; - - src = pkgs.fetchgit { - url = "https://forge.ops.eblu.me/mirrors/tailscale.git"; - rev = "v${version}"; - hash = "sha256-qjWVB8xWVgIVUgrf27F6hwiFIE+4ERXWeHv26ugg/x4="; - }; - - 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/containers/tempo/Dockerfile b/containers/tempo/Dockerfile deleted file mode 100644 index aeca55e..0000000 --- a/containers/tempo/Dockerfile +++ /dev/null @@ -1,40 +0,0 @@ -# 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/containers/teslamate/default.nix b/containers/teslamate/default.nix deleted file mode 100644 index e126561..0000000 --- a/containers/teslamate/default.nix +++ /dev/null @@ -1,122 +0,0 @@ -# Nix-built TeslaMate for ringtail (amd64). -# -# Replaces the Dagger container.py (Elixir+Node builder -> Debian slim). -# TeslaMate is NOT in nixpkgs, so this is a from-scratch beamPackages -# mixRelease: an Elixir/Phoenix release with npm-built assets. -# -# Pinned to the same nixos-unstable rev as paperless/mealie for a -# consistent toolchain. The BEAM combo is pinned to erlang_27 + elixir_1_18 -# (teslamate requires elixir ~> 1.17; upstream's image uses OTP 26, so we -# stay off the default OTP 28 which elixir 1.18 does not target). -# -# Source comes from the forge mirror (supply-chain control), pinned by the -# v3.0.0 tag's commit so builtins.fetchGit needs no hash. -let - nixpkgs = fetchTarball { - url = "https://github.com/NixOS/nixpkgs/archive/331800de5053fcebacf6813adb5db9c9dca22a0c.tar.gz"; - sha256 = "1p54fm6dkbq62kpi55cr4wyx7b1nsajpsnjgs64cmp073fwi15f7"; - }; - pkgs = import nixpkgs { system = "x86_64-linux"; }; - lib = pkgs.lib; - - version = "3.0.0"; - - beamPackages = pkgs.beam.packages.erlang_27; - elixir = beamPackages.elixir_1_18; - - src = builtins.fetchGit { - url = "https://forge.ops.eblu.me/mirrors/teslamate.git"; - ref = "refs/tags/v${version}"; - rev = "3281154d42330786a182c1bbe094ecda0b1c5578"; - }; - - # ex_cldr downloads locale JSON from GitHub at compile time, which the - # build sandbox blocks. teslamate's cldr.ex reads the data dir from the - # LOCALES env var; point it at the pre-fetched elixir-cldr data so no - # download is attempted (with SKIP_LOCALE_DOWNLOAD=true disabling the - # forced refresh). CLDR data version matches the compile-time errors. - cldrData = pkgs.fetchFromGitHub { - owner = "elixir-cldr"; - repo = "cldr"; - rev = "v2.46.0"; - sha256 = "1iwzk9dc754l72vpf8vsisdjncnjx26pz509552b6vnm49xbxyji"; - }; - - teslamate = beamPackages.mixRelease { - pname = "teslamate"; - inherit version src elixir; - - # Keep the build-generated Erlang cookie in the release. mixRelease - # strips it by default (expecting RELEASE_COOKIE at runtime), but the - # start script reads releases/COOKIE. teslamate is single-node (no - # distributed Erlang exposed), so a baked-in cookie is fine. - removeCookie = false; - - mixFodDeps = beamPackages.fetchMixDeps { - pname = "mix-deps-teslamate"; - inherit src version elixir; - hash = "sha256-DDrREiM1BIMgD2qFPTK8QyjOYlnfE3XlnaH/jk7G2go="; - }; - - # Frontend assets. esbuild + sass are devDeps and the esbuild platform - # binary is an optional dep, so npm ci must include both. We run npm ci - # here (not a separate derivation) because assets/package.json has - # file:../deps/phoenix references that only resolve once mixFodDeps has - # populated deps/. npmConfigHook wires up the offline cache from npmDeps; - # then `node scripts/build.js` (custom esbuild) + `mix phx.digest`. - nativeBuildInputs = [ pkgs.nodejs pkgs.npmHooks.npmConfigHook ]; - npmDeps = pkgs.fetchNpmDeps { - name = "teslamate-npm-deps"; - src = src + "/assets"; - hash = "sha256-XyiaUkT/c4rZnNxmxhVLb+vEXnc64A1hjOrnR5fhaEk="; - }; - npmRoot = "assets"; - - preBuild = '' - export SKIP_LOCALE_DOWNLOAD=true - export LOCALES=${cldrData}/priv/cldr - ( cd assets && npm ci --include=dev --include=optional && node scripts/build.js ) - mix phx.digest --no-deps-check - ''; - }; -in - -pkgs.dockerTools.buildLayeredImage { - name = "blumeops/teslamate"; - - contents = [ - teslamate - pkgs.bashInteractive - pkgs.coreutils - pkgs.dash - pkgs.netcat-openbsd - pkgs.cacert - pkgs.tzdata - ]; - - config = { - # Mirror entrypoint.sh: wait for postgres, run migrations, then start. - Entrypoint = [ - "${pkgs.dash}/bin/dash" - "-c" - '' - : "''${DATABASE_HOST:=127.0.0.1}" - : "''${DATABASE_PORT:=5432}" - while ! ${pkgs.netcat-openbsd}/bin/nc -z "$DATABASE_HOST" "$DATABASE_PORT" 2>/dev/null; do - echo "waiting for postgres at $DATABASE_HOST:$DATABASE_PORT"; sleep 1 - done - ${teslamate}/bin/teslamate eval "TeslaMate.Release.migrate" - exec ${teslamate}/bin/teslamate start - '' - ]; - Env = [ - "HOME=/opt/app" - "SRTM_CACHE=/opt/app/.srtm_cache" - "LANG=C.UTF-8" - "SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" - ]; - ExposedPorts = { - "4000/tcp" = { }; - }; - }; -} diff --git a/containers/transmission-exporter/container.py b/containers/transmission-exporter/container.py deleted file mode 100644 index e88fc70..0000000 --- a/containers/transmission-exporter/container.py +++ /dev/null @@ -1,37 +0,0 @@ -"""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-exporter/exporter.py b/containers/transmission-exporter/exporter.py deleted file mode 100644 index e7f08a8..0000000 --- a/containers/transmission-exporter/exporter.py +++ /dev/null @@ -1,164 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.13" -# dependencies = [ -# "prometheus-client", -# "transmission-rpc", -# ] -# /// -"""Minimal Prometheus exporter for Transmission, using collect-on-scrape.""" - -import os -import sys -import urllib.parse -from wsgiref.simple_server import make_server - -from prometheus_client import make_wsgi_app -from prometheus_client.core import REGISTRY, GaugeMetricFamily -from transmission_rpc import Client - - -def parse_addr(addr: str) -> dict: - """Parse TRANSMISSION_ADDR into kwargs for transmission_rpc.Client.""" - parsed = urllib.parse.urlparse(addr) - kwargs: dict = {} - if parsed.hostname: - kwargs["host"] = parsed.hostname - if parsed.port: - kwargs["port"] = parsed.port - if parsed.scheme == "https": - kwargs["protocol"] = "https" - if parsed.path and parsed.path != "/": - kwargs["path"] = parsed.path.strip("/") - if parsed.username: - kwargs["username"] = parsed.username - if parsed.password: - kwargs["password"] = parsed.password - return kwargs - - -class TransmissionCollector: - def __init__(self, client_kwargs: dict): - self._client_kwargs = client_kwargs - - def collect(self): - try: - client = Client(**self._client_kwargs) - session = client.session_stats() - torrents = client.get_torrents() - except Exception as e: - print(f"Error collecting metrics: {e}", file=sys.stderr) - return - - yield _gauge( - "transmission_session_stats_download_speed_bytes", - "Current download speed in bytes/s", - session.download_speed, - ) - yield _gauge( - "transmission_session_stats_upload_speed_bytes", - "Current upload speed in bytes/s", - session.upload_speed, - ) - yield _gauge( - "transmission_session_stats_torrents_active", - "Number of active torrents", - session.active_torrent_count, - ) - yield _gauge( - "transmission_session_stats_torrents_total", - "Total number of torrents", - session.torrent_count, - ) - - downloaded = GaugeMetricFamily( - "transmission_session_stats_downloaded_bytes", - "Total bytes downloaded", - labels=["type"], - ) - downloaded.add_metric(["cumulative"], session.cumulative_stats.downloaded_bytes) - yield downloaded - - uploaded = GaugeMetricFamily( - "transmission_session_stats_uploaded_bytes", - "Total bytes uploaded", - labels=["type"], - ) - uploaded.add_metric(["cumulative"], session.cumulative_stats.uploaded_bytes) - yield uploaded - - t_download = GaugeMetricFamily( - "transmission_torrent_download_bytes", - "Torrent total downloaded bytes", - labels=["name"], - ) - t_upload = GaugeMetricFamily( - "transmission_torrent_upload_bytes", - "Torrent total uploaded bytes", - labels=["name"], - ) - t_ratio = GaugeMetricFamily( - "transmission_torrent_ratio", - "Torrent upload ratio", - labels=["name"], - ) - t_uploaded_ever = GaugeMetricFamily( - "transmission_torrent_uploaded_ever", - "Torrent total uploaded ever in bytes", - labels=["name"], - ) - t_download_rate = GaugeMetricFamily( - "transmission_torrent_download_rate_bytes", - "Torrent current download rate in bytes/s", - labels=["name"], - ) - t_upload_rate = GaugeMetricFamily( - "transmission_torrent_upload_rate_bytes", - "Torrent current upload rate in bytes/s", - labels=["name"], - ) - t_done = GaugeMetricFamily( - "transmission_torrent_done", - "Torrent percent done (0.0-1.0)", - labels=["name"], - ) - - for t in torrents: - name = t.name or "unknown" - t_download.add_metric([name], t.total_size * t.percent_done) - t_upload.add_metric([name], t.uploaded_ever) - t_download_rate.add_metric([name], t.rate_download) - t_upload_rate.add_metric([name], t.rate_upload) - t_ratio.add_metric([name], t.ratio) - t_uploaded_ever.add_metric([name], t.uploaded_ever) - t_done.add_metric([name], t.percent_done) - - yield t_download - yield t_upload - yield t_download_rate - yield t_upload_rate - yield t_ratio - yield t_uploaded_ever - yield t_done - - -def _gauge(name: str, doc: str, value: float) -> GaugeMetricFamily: - g = GaugeMetricFamily(name, doc) - g.add_metric([], value) - return g - - -def main(): - addr = os.environ.get("TRANSMISSION_ADDR", "http://localhost:9091") - port = int(os.environ.get("EXPORTER_PORT", "19091")) - - client_kwargs = parse_addr(addr) - REGISTRY.register(TransmissionCollector(client_kwargs)) - - print(f"Listening on :{port}, scraping {addr}") - httpd = make_server("", port, make_wsgi_app()) - httpd.serve_forever() - - -if __name__ == "__main__": - main() diff --git a/containers/transmission/container.py b/containers/transmission/container.py deleted file mode 100644 index c7989aa..0000000 --- a/containers/transmission/container.py +++ /dev/null @@ -1,49 +0,0 @@ -"""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/containers/transmission/start.sh b/containers/transmission/start.sh deleted file mode 100644 index 05d0bf9..0000000 --- a/containers/transmission/start.sh +++ /dev/null @@ -1,57 +0,0 @@ -#!/bin/bash -set -e - -# Handle PUID/PGID like linuxserver images -PUID=${PUID:-1000} -PGID=${PGID:-1000} - -# Create or update transmission group/user with requested UID/GID -# The transmission package may have created a user with different IDs -echo "Setting up transmission user with UID=$PUID GID=$PGID" - -# Remove existing user/group if they exist (ignore errors) -deluser transmission 2>/dev/null || true -delgroup transmission 2>/dev/null || true - -# Create fresh user/group with requested IDs -addgroup -g "$PGID" transmission -adduser -D -u "$PUID" -G transmission transmission - -# Ensure directories exist with correct ownership -mkdir -p /config /downloads/complete /downloads/incomplete -# Only chown /config (emptyDir) - /downloads is NFS and may not allow chown -chown -R transmission:transmission /config 2>/dev/null || true -chown transmission:transmission /downloads /downloads/complete /downloads/incomplete 2>/dev/null || true - -# Create default config if it doesn't exist -CONFIG_FILE="/config/settings.json" -if [ ! -f "$CONFIG_FILE" ]; then - echo "Creating default configuration..." - cat > "$CONFIG_FILE" << 'EOF' -{ - "download-dir": "/downloads/complete", - "incomplete-dir": "/downloads/incomplete", - "incomplete-dir-enabled": true, - "rpc-enabled": true, - "rpc-bind-address": "0.0.0.0", - "rpc-port": 9091, - "rpc-whitelist-enabled": false, - "rpc-host-whitelist-enabled": false, - "peer-port": 51413, - "watch-dir-enabled": false, - "umask": 2 -} -EOF - chown transmission:transmission "$CONFIG_FILE" -fi - -# Set timezone -if [ -n "$TZ" ]; then - ln -sf "/usr/share/zoneinfo/$TZ" /etc/localtime -fi - -echo "Starting transmission-daemon..." -exec su-exec transmission transmission-daemon \ - --foreground \ - --config-dir /config \ - --log-level=info diff --git a/containers/unpoller/container.py b/containers/unpoller/container.py deleted file mode 100644 index bfc75ba..0000000 --- a/containers/unpoller/container.py +++ /dev/null @@ -1,53 +0,0 @@ -"""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/containers/valkey/container.py b/containers/valkey/container.py deleted file mode 100644 index 34e8524..0000000 --- a/containers/valkey/container.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Valkey — native Dagger build (arm64, indri). - -Alpine 3.22 base with the `valkey` apk package (8.1.x — Redis-compatible). -Used by paperless (sidecar) on indri. immich on ringtail uses the -nix-built amd64 variant from `default.nix` in this directory. -""" - -import dagger -from dagger import dag - -from blumeops.containers import oci_labels - -# 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" - - -async def build(src: dagger.Directory) -> dagger.Container: - ctr = ( - dag.container() - .from_(ALPINE_BASE) - .with_exec(["apk", "add", "--no-cache", f"valkey={ALPINE_PIN}"]) - .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/containers/valkey/default.nix b/containers/valkey/default.nix deleted file mode 100644 index 9cb1713..0000000 --- a/containers/valkey/default.nix +++ /dev/null @@ -1,30 +0,0 @@ -# Nix-built Valkey for ringtail (amd64) -# Companion to container.py (Alpine 3.22, arm64 on indri). -# Used by immich-ringtail which needs an amd64 image; paperless on indri -# continues to use the Alpine container.py build. -# -# The version assertion ensures nix-build fails if a flake.lock update -# changes the Valkey version — forcing an explicit version acknowledgment -# here and in service-versions.yaml (enforced by container-version-check). -{ pkgs ? import <nixpkgs> { } }: - -let - version = "8.1.7"; -in - -assert pkgs.valkey.version == version; - -pkgs.dockerTools.buildLayeredImage { - name = "blumeops/valkey"; - contents = [ - pkgs.valkey - ]; - - config = { - Entrypoint = [ "${pkgs.valkey}/bin/valkey-server" ]; - Cmd = [ "--bind" "0.0.0.0" "--protected-mode" "no" "--dir" "/data" ]; - ExposedPorts = { - "6379/tcp" = { }; - }; - }; -} diff --git a/dagger.json b/dagger.json deleted file mode 100644 index 3309378..0000000 --- a/dagger.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "blumeops", - "engineVersion": "v0.20.6", - "sdk": { - "source": "python" - } -} diff --git a/docs/changelog.d/+external-secrets-main-sha-rebuild.infra.md b/docs/changelog.d/+external-secrets-main-sha-rebuild.infra.md deleted file mode 100644 index 2e931d4..0000000 --- a/docs/changelog.d/+external-secrets-main-sha-rebuild.infra.md +++ /dev/null @@ -1 +0,0 @@ -Rebuilt the locally-built external-secrets image from the `main` branch so the deployed tag (`v2.2.0-0e70a1b`) traces to a `main` commit rather than the now-merged feature branch, giving a stable provenance reference. diff --git a/docs/changelog.d/+external-secrets-stable-main-sha.infra.md b/docs/changelog.d/+external-secrets-stable-main-sha.infra.md deleted file mode 100644 index fbe3c21..0000000 --- a/docs/changelog.d/+external-secrets-stable-main-sha.infra.md +++ /dev/null @@ -1 +0,0 @@ -Rebuilt the external-secrets images off `main` and repointed both clusters to the stable main-sha tags (`v2.2.0-13895bb` arm64 / `v2.2.0-13895bb-nix` amd64), so the deployed images on indri and ringtail trace to the same `main` commit rather than earlier feature-branch builds. diff --git a/docs/changelog.d/+heph-hub-v1.2.1.infra.md b/docs/changelog.d/+heph-hub-v1.2.1.infra.md deleted file mode 100644 index c203323..0000000 --- a/docs/changelog.d/+heph-hub-v1.2.1.infra.md +++ /dev/null @@ -1 +0,0 @@ -Bumped the indri heph hub to v1.2.1, which adds the hub `GET /config` endpoint and ships the heph-pwa **Login with Authentik** flow (Authorization Code + PKCE). Pairs with the Authentik `heph` provider redirect URIs registered earlier. diff --git a/docs/changelog.d/+jellyfin-10-11-11.bugfix.md b/docs/changelog.d/+jellyfin-10-11-11.bugfix.md deleted file mode 100644 index 779a042..0000000 --- a/docs/changelog.d/+jellyfin-10-11-11.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -Upgraded Jellyfin on indri from 10.11.6 to 10.11.11, picking up the security fixes in 10.11.7 (disclosed CVEs/GHSAs, flagged "upgrade immediately") and 10.11.10 (three further GHSAs). Noted the recurring gotcha in the service-versions tracking: after a `brew upgrade --cask jellyfin`, the re-quarantined `.app` makes the launchd-spawned process hang silently until the Gatekeeper first-launch dialog is approved on indri's GUI console — removing the quarantine xattr over SSH is blocked by macOS TCC. diff --git a/docs/changelog.d/+ringtail-flake-update.infra.md b/docs/changelog.d/+ringtail-flake-update.infra.md deleted file mode 100644 index 1d806df..0000000 --- a/docs/changelog.d/+ringtail-flake-update.infra.md +++ /dev/null @@ -1 +0,0 @@ -Updated ringtail NixOS flake inputs (nixpkgs `nixos-25.11`, disko) to latest via `dagger call flake-update`. diff --git a/docs/changelog.d/+tailscale-operator-doc-review.doc.md b/docs/changelog.d/+tailscale-operator-doc-review.doc.md deleted file mode 100644 index 8f7d5a3..0000000 --- a/docs/changelog.d/+tailscale-operator-doc-review.doc.md +++ /dev/null @@ -1 +0,0 @@ -Reviewed the tailscale-operator reference card: documented the dual indri/ringtail deployment, corrected the ArgoCD apps list, pinned the upstream version, and added the ProxyGroup Ingress `host:` caveat. diff --git a/docs/changelog.d/+tailscale-operator-mirror-tailnet-url.bugfix.md b/docs/changelog.d/+tailscale-operator-mirror-tailnet-url.bugfix.md deleted file mode 100644 index cc29cf7..0000000 --- a/docs/changelog.d/+tailscale-operator-mirror-tailnet-url.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/docs/changelog.d/.gitkeep b/docs/changelog.d/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/docs/changelog.d/external-secrets-ringtail-nix.infra.md b/docs/changelog.d/external-secrets-ringtail-nix.infra.md deleted file mode 100644 index 9ce3f85..0000000 --- a/docs/changelog.d/external-secrets-ringtail-nix.infra.md +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/docs/changelog.d/heph-indri-hub.infra.md b/docs/changelog.d/heph-indri-hub.infra.md deleted file mode 100644 index 6761cb7..0000000 --- a/docs/changelog.d/heph-indri-hub.infra.md +++ /dev/null @@ -1 +0,0 @@ -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/changelog.d/heph-offline-access.bugfix.md b/docs/changelog.d/heph-offline-access.bugfix.md deleted file mode 100644 index e9721bc..0000000 --- a/docs/changelog.d/heph-offline-access.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -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/changelog.d/heph-pwa-redirect-uris.infra.md b/docs/changelog.d/heph-pwa-redirect-uris.infra.md deleted file mode 100644 index f887eed..0000000 --- a/docs/changelog.d/heph-pwa-redirect-uris.infra.md +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/docs/changelog.d/local-external-secrets.infra.md b/docs/changelog.d/local-external-secrets.infra.md deleted file mode 100644 index 13cbb05..0000000 --- a/docs/changelog.d/local-external-secrets.infra.md +++ /dev/null @@ -1 +0,0 @@ -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/docs/changelog.d/retire-prowler-image-iac-scans.infra.md b/docs/changelog.d/retire-prowler-image-iac-scans.infra.md deleted file mode 100644 index 9afd261..0000000 --- a/docs/changelog.d/retire-prowler-image-iac-scans.infra.md +++ /dev/null @@ -1 +0,0 @@ -Retired the Prowler container-image CVE scan and IaC scan, keeping only the K8s CIS benchmark scan. The two retired scans generated tens of thousands of un-actioned, un-muted findings every week (~20,000 image findings and growing, mostly unpatchable upstream-image CVEs; ~650 systemic Trivy KSV pod-security warnings) — the weekly `mise run review-compliance-reports` re-surfaced them all as "action needed" though none were ever triaged. The K8s CIS scan is fully mutelisted and runs clean, so it stays. Removed the two CronJobs, the now-unused `trivyignore.yaml` mutelist, and the grouped-findings rendering in the review tool that existed solely for the high-volume scans. diff --git a/docs/changelog.d/reviews-jun4.doc.md b/docs/changelog.d/reviews-jun4.doc.md deleted file mode 100644 index f1aeaa8..0000000 --- a/docs/changelog.d/reviews-jun4.doc.md +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index c128e70..0000000 --- a/docs/changelog.d/reviews-jun4.infra.md +++ /dev/null @@ -1 +0,0 @@ -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/explanation/agent-change-process.md b/docs/explanation/agent-change-process.md deleted file mode 100644 index 5141950..0000000 --- a/docs/explanation/agent-change-process.md +++ /dev/null @@ -1,298 +0,0 @@ ---- -title: Agent Change Process -modified: 2026-03-15 -last-reviewed: 2026-02-23 -tags: - - 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 - -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 | - -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. - -1. Run `mise run ai-docs` to load context -2. Implement the change directly on main -3. Add a changelog fragment if the change is user-visible or noteworthy (`docs/changelog.d/+<descriptive-slug>.<type>.md`) -4. Commit and push - -No feature branch or PR required. If something goes wrong, fix forward with another commit. - -Examples: fix a typo, bump a version, add a simple config value, update a doc. - -## C1 — Human Review - -A change with enough complexity or risk that a human should review it, but not so much that a formal multi-phase approach is needed. - -### Process - -1. Run `mise run ai-docs` to load context -2. **Search related docs** — read existing documentation and reference cards related to the change area -3. **Create a feature branch** and open a PR early (draft is fine) -4. **Documentation first** — commit doc changes reflecting the desired end state before writing code. This helps the reviewer understand intent and catches design issues early -5. **Implement** — commit code changes, pushing as you go. The PR gets updated along the way and the user can review and comment at any point -6. **Add changelog fragment** — `docs/changelog.d/<branch>.<type>.md` for any user-visible or noteworthy changes -7. **Deploy from the branch** — do not wait for merge: - - **ArgoCD:** `argocd app set <service> --revision <branch> && argocd app sync <service>` - - **Ansible:** run playbooks directly from the branch checkout - - **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/`:** trigger a rebuild with `mise run container-build-and-release <name>`. 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 - -Upgrade to C2 if any of these happen during a C1 change: - -- You discover the change requires multiple prerequisite changes that must be sequenced -- The change is spiraling in complexity beyond a single session -- The user requests it -- During planning you realize this is a multi-phase project - -## C2 — Mikado Chain - -A complex, multi-session change managed through the [Mikado method](https://mikadomethod.info/) with a strict branch discipline called the **Mikado Branch Invariant**. - -### Planning and research - -Before writing any code, invest in understanding the problem: - -1. Run `mise run ai-docs` to load context -2. Search related docs, reference cards, and existing how-to guides for the change area -3. Think through the dependency graph — what prerequisites exist? What could go wrong? -4. Create Mikado cards for everything you can anticipate (you'll discover more later — that's the point of the method) - -This planning phase can span multiple sessions. Cards introduced during planning are merged to main and become the foundation for work cycles later. - -### The Mikado Branch Invariant - -The invariant governs how commits are ordered on a C2 feature branch. The branch must always have this structure: - -``` -main ← [plan commits] ← [impl, close] ← [impl, close] ← ... ← [finalize] - ^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - Planning layer Repeating work cycles - (cards only) (impl then close, one leaf at a time) -``` - -**Rules:** - -1. The first N commits on the branch (after diverging from main) must ALL be commits that **only introduce or modify Mikado cards** — no code changes -2. After the card layer, work proceeds in **cycles**: each cycle is one or more code commits followed by one or more commits closing leaf nodes -3. A cycle should target a single leaf node (though closing multiple in one cycle is acceptable if the code supports it) -4. Cycles repeat until the chain is complete - -**The one rule:** No Mikado card may be introduced after any code or card-closing commit. New cards require a branch reset (see below). - -**The length-zero case:** It is valid for the "planning layer" to have zero commits on the branch — this happens when all Mikado cards were introduced in earlier sessions and are already in main's history. The invariant is satisfied. - -**Exception — finalize:** The terminal commit of a completed chain rewrites Mikado cards to historical documentation. This is a card modification after code commits, and is the only permitted violation of the one rule (see "Completing a chain" below). - -### Conventions - -#### Branch naming - -C2 branches must be named `mikado/<chain-stem>`, where `<chain-stem>` is the filename stem of the goal card. Example: goal card `deploy-authentik.md` → branch `mikado/deploy-authentik`. - -#### Goal card `branch:` frontmatter - -The goal card of a C2 chain must include a `branch:` field once work begins: - -```yaml ---- -title: Deploy Authentik -status: active -branch: mikado/deploy-authentik -requires: - - configure-postgres - - setup-redis -tags: - - how-to ---- -``` - -A goal card with `status: active` but no `branch:` field indicates a chain that has been planned but not yet started — the planning-phase cards exist but no implementation branch has been created. - -#### Commit message convention - -All commits on a `mikado/*` branch must use this format: - -``` -C2(<chain-stem>): <verb> <short description> -``` - -Verbs and their meanings: - -| Verb | Phase | What it means | -|------|-------|---------------| -| `plan` | Planning layer | Introduces or modifies a Mikado card (no code changes) | -| `impl` | Work cycle | Code progress toward closing a leaf node (no card changes) | -| `close` | Work cycle | Closes a leaf node by removing `status: active` | -| `finalize` | Terminal | Rewrites cards to historical docs, adds changelog | - -Examples: -``` -C2(deploy-authentik): plan add postgres and redis prerequisite cards -C2(deploy-authentik): impl configure external-secrets for authentik -C2(deploy-authentik): close configure-postgres -C2(deploy-authentik): finalize rewrite cards as historical documentation -``` - -The `mikado-branch-invariant-check` commit-msg hook validates this convention and the invariant ordering. - -### Process - -1. **Goal card:** Create a how-to doc in `docs/how-to/` describing the desired end state - - Add `status: active` and `branch: mikado/<chain-stem>` to frontmatter - - Create prerequisite cards discovered during planning, each with `status: active` - - Commit all cards together (or in a sequence of card-only commits) using `C2(<chain>): plan ...` messages -2. **Open a PR** after the first card commits so the user can review the Mikado graph -3. **Work leaf nodes** — pick a leaf (a card with `status: active` and no unmet `requires`): - - Commit code changes (`C2(<chain>): impl ...`) that progress toward closing it - - **Verify the card's own deliverables** (deploy from branch, run tests, etc.) before closing. "Works" means the card's stated outputs are correct — not that downstream consumers have integrated them. If a downstream card later discovers the output doesn't fit, that's a new prerequisite discovery handled by the normal reset mechanism. - - Commit the card closure (`C2(<chain>): close ...`) — remove `status: active` - - Push to origin — this is the save point -4. **End the cycle** — after pushing a closed leaf node, prompt the user to review the PR and suggest ending the session. Each closed leaf is a natural stopping point; the chain is designed to be resumed later. Don't rush into the next leaf without the user's go-ahead. -5. **Repeat** until the chain is complete -6. **New agent sessions** pick up state by running `mise run docs-mikado --resume` - -### Discovering new prerequisites or errors - -When you discover a new prerequisite **or encounter an error** during code work, do not fix forward. The Mikado method's power comes from rigorous resets that keep the plan honest. You must restore the Mikado Branch Invariant: - -1. **Stash or note any in-progress work** you want to preserve -2. **Identify the reset point** — the last `plan` or `close` commit before your current `impl` commits: - ```bash - git log --oneline mikado/<chain-stem> --not main - ``` -3. **Reset the branch** to that commit: - ```bash - git reset --hard <reset-point-sha> - ``` -4. **Update the plan** — add a `plan` commit that captures what you learned: - - If you discovered a new prerequisite: add a new card and update `requires` - - If you hit an error: update the relevant card with what you learned, or introduce a new prerequisite card that addresses the root cause -5. **Replay valid work** by cherry-picking commits that still apply: - ```bash - git cherry-pick <sha1> <sha2> ... - ``` -6. **Resume the Mikado process** from the new state of the card stack - -**When to reset vs. fix forward:** If an `impl` commit introduces a bug that's a simple typo or one-liner, another `impl` commit is fine. But if the error reveals a gap in understanding, a missing prerequisite, or requires rethinking the approach — reset. The threshold is: "does this error teach us something that should be in the plan?" If yes, reset. - -**Saving work across resets:** It is acceptable to cherry-pick code commits from before the reset back onto the branch after adding the new card. Use `git stash` for uncommitted work. This is a pragmatic exception — use it only when you are confident the saved work is still valid given the new prerequisite. When in doubt, redo the work from scratch. - -### Completing a chain - -When the final leaf node is closed and no `status: active` cards remain: - -1. **Rewrite all Mikado cards** to reflect their nature as historical documentation: - - **Remove all Mikado frontmatter** from every card in the chain: `requires:`, `status:`, and `branch:`. Cards become "just documentation" — the Mikado metadata served its purpose during the chain and should not persist. - - Cards can (and should) still link to one another via wiki-links in their body text, just not via frontmatter dependencies. - - Remove transient technical details (specific version numbers, temporary workarounds) that won't matter in the future - - Frame the content as "what to do if someone wanted to repeat this process" - - Add appropriate context about what was learned -2. **Add changelog information** in `docs/changelog.d/` -3. Commit as `C2(<chain>): finalize ...` — this is the one permitted exception to the invariant's "no card changes after code" rule -4. The user reviews and merges the PR - -### Cold-start: resuming a chain in a new session - -When starting a new session to continue C2 work: - -1. Run `mise run ai-docs` to load context -2. Run `mise run docs-mikado --resume` — this will: - - Detect the current branch and match it to an active chain - - Show the chain state, ready leaf nodes, and current position in the invariant - - Show the PR number and URL if an open PR exists for the branch - - Warn about any stashed work in `git stash list` - - If on main, list active chains and suggest which to resume -3. Check PR comments with `mise run pr-comments <pr_number>` — use the PR number from the `--resume` output above -4. Pick the next ready leaf node and continue with a work cycle - -### Build artifacts - -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, trigger a rebuild with `mise run container-build-and-release <name>` and commit a C0 to update manifests to the new `[main]`-tagged image. Use `mise run container-list <name>` to find it -- **All builds are manual** — use `mise run container-build-and-release <name>` 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 - -## Card Conventions - -### Frontmatter - -```yaml ---- -title: Deploy Authentik -status: active # omit when complete -branch: mikado/deploy-authentik # goal cards only; omit when complete -requires: # explicit dependencies - - configure-postgres - - setup-redis -tags: - - how-to ---- -``` - -- `status: active` marks in-progress work; remove when done (this is the ONLY way a card is marked complete) -- `branch` is set on goal cards only, linking the card to its `mikado/<chain-stem>` branch. A goal card with `status: active` but no `branch` indicates a chain that is planned but not yet started. -- `requires` lists card stems (filenames without `.md`) that must be completed first. -- **During finalization**, remove all Mikado frontmatter (`requires`, `status`, `branch`) from every card in the chain. Use wiki-links in body text to preserve cross-references. -- `required-by` is NOT stored — it's computed by `docs-mikado` - -### Writing Cards - -- **Mikado cards are not plans.** Plans are designed upfront; Mikado cards are discovered through failed attempts. Don't put Mikado prerequisite cards in `docs/how-to/plans/`. -- Cards live in a topic subdirectory under `docs/how-to/` (e.g., `docs/how-to/authentik/` for the deploy-authentik chain). The goal card may live in `plans/` if it started as a plan. -- Keep cards brief (<30 seconds to read) -- Link to other cards rather than inlining their content -- Document what was learned from failures, not just what to do - -### Git Discipline - -- **C0:** Commit directly to main -- **C1:** Single feature branch, PR early, push often -- **C2:** Branch named `mikado/<chain-stem>`, Mikado Branch Invariant enforced, `C2()` commit convention, PR early, push after every leaf-node closure -- **Changelog fragments (all levels):** Add `docs/changelog.d/<name>.<type>.md` for any user-visible or noteworthy change, regardless of change class. C0 uses orphan fragments (`+<descriptive-slug>.<type>.md`) to avoid `main.*` collisions. C1/C2 use the branch name (`<branch>.<type>.md`). C0 includes the fragment in the same commit. C1 includes it during the branch work. C2 includes it in the `finalize` commit. -- **Deploy from branches** — C1 and C2 changes deploy from the unmerged branch (ArgoCD `--revision`, Ansible from checkout, etc.). Reset to main after merge. -- GitOps requires pushing to test — if a pushed commit breaks, revert it promptly - -## Tools - -| Command | Purpose | -|---------|---------| -| `mise run docs-mikado` | List all active Mikado chains with branch status | -| `mise run docs-mikado <card>` | Show dependency chain for a goal card | -| `mise run docs-mikado <card> --all` | Include completed cards in full | -| `mise run docs-mikado --resume` | Resume a chain: detect branch, show state and next steps | -| `mise run docs-mikado --resume <chain>` | Resume a specific chain with branch consistency check | - -The `mikado-branch-invariant-check` commit-msg hook runs automatically on `mikado/*` branches, validating commit message conventions and invariant ordering. Requires `prek install --hook-type commit-msg`. - -## Related - -- [[ai-assistance-guide]] — General AI agent conventions -- [[exploring-the-docs]] — Documentation structure overview diff --git a/docs/explanation/ai-scraper-mitigation.md b/docs/explanation/ai-scraper-mitigation.md deleted file mode 100644 index fe4ba3d..0000000 --- a/docs/explanation/ai-scraper-mitigation.md +++ /dev/null @@ -1,201 +0,0 @@ ---- -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/<sha>`, `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/explanation/architecture.md b/docs/explanation/architecture.md deleted file mode 100644 index a99956f..0000000 --- a/docs/explanation/architecture.md +++ /dev/null @@ -1,147 +0,0 @@ ---- -title: Architecture -modified: 2026-02-19 -last-reviewed: 2026-02-09 -tags: - - explanation - - architecture ---- - -# Architecture Overview - -> **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 all the BlumeOps pieces fit together. - -## Physical Layer - -Three always-on devices form the infrastructure backbone: - -``` -┌─────────────────┐ ┌─────────────────┐ -│ Indri │ │ Sifaka │ -│ Mac Mini M1 │────▶│ Synology NAS │ -│ (compute) │ │ (storage) │ -└─────────────────┘ └─────────────────┘ - │ ▲ - │ Tailscale │ NFS - │ ┌──────┴──────────┐ - │ │ Ringtail │ - │ │ NixOS PC │ - │ │ (GPU compute) │ - │ └─────────────────┘ - ▼ -┌─────────────────┐ -│ Gilbert │ -│ MacBook Air │ -│ (workstation) │ -└─────────────────┘ -``` - -- **[[indri]]** runs most services (native and containerized) -- **[[ringtail]]** runs GPU workloads (Frigate NVR) and related services (ntfy) -- **[[sifaka]]** provides bulk storage and backup targets -- **[[gilbert]]** is the development workstation - -## Network Layer - -[[tailscale]] provides the network fabric. All devices join a single tailnet (`tail8d86e.ts.net`) connected via WireGuard tunnels — no port forwarding or public IPs on homelab devices. ACLs control which devices and services can talk to each other, and MagicDNS provides `*.tail8d86e.ts.net` hostnames. - -## Routing Layer - -Three layers of reverse proxying expose services at different scopes: - -| Domain | Proxy | Reachable from | -|--------|-------|----------------| -| `*.tail8d86e.ts.net` | Tailscale MagicDNS | Tailnet clients only | -| `*.ops.eblu.me` | [[caddy]] on indri | k8s pods, containers, tailnet clients | -| `*.eblu.me` | [[flyio-proxy]] on Fly.io | Public internet | - -**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). 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 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. - -## Compute Layer - -Services run across three compute targets: - -**Native on indri (Ansible)** — services that need host-level access run directly on macOS, managed via Ansible roles in `ansible/roles/`. See [[indri]] for the full list. - -**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; ntfy supports its alerting pipeline. - -## Data Flow - -``` -┌──────────────┐ -│ Git Repo │ -│ (Forgejo) │ -└──────┬───────┘ - │ push - ▼ -┌──────────────┐ ┌──────────────┐ -│ ArgoCD │────▶│ Kubernetes │ -│ (watches) │sync │ (runs) │ -└──────────────┘ └──────────────┘ - │ - ┌────────────────────┼────────────────────┐ - ▼ ▼ ▼ -┌──────────────┐ ┌──────────────┐ ┌──────────────┐ -│ Service │ │ Service │ │ Service │ -└──────────────┘ └──────────────┘ └──────────────┘ -``` - -1. Code pushed to [[forgejo]] -2. [[argocd]] detects changes (or manual sync triggered) -3. ArgoCD applies manifests to cluster -4. Services start/update in Kubernetes - -## Observability - -``` -┌─────────────┐ ┌─────────────┐ ┌─────────────┐ -│ Alloy │────▶│ Prometheus │────▶│ Grafana │ -│ (collector) │ │ (metrics) │ │ (dashboards)│ -└─────────────┘ └─────────────┘ └─────────────┘ - │ ▲ - │ ┌─────────────┐ │ - └───────────▶│ Loki │────────────┘ - │ (logs) │ - └─────────────┘ -``` - -[[alloy]] runs in three places: -- On indri: collects host metrics and logs -- In k8s: collects pod logs and service probes -- On [[flyio-proxy]]: tails nginx access logs and derives request metrics - -See [[observability]] for details. - -## Secrets Flow - -``` -┌─────────────┐ ┌─────────────┐ ┌─────────────┐ -│ 1Password │────▶│ 1Password │────▶│ External │ -│ (vault) │ │ Connect │ │ Secrets │ -└─────────────┘ └─────────────┘ └─────────────┘ - │ - ▼ - ┌─────────────┐ - │ K8s Secret │ - └─────────────┘ -``` - -Secrets live in 1Password and flow to Kubernetes via [[external-secrets]]. - -For Ansible, secrets are fetched via `op` CLI in playbook pre_tasks. - -## Related - -- [[why-gitops]] - Philosophy behind this approach -- [[security-model]] - Access control and secrets -- [[routing]] - Service routing details diff --git a/docs/explanation/federated-login.md b/docs/explanation/federated-login.md deleted file mode 100644 index e576d9f..0000000 --- a/docs/explanation/federated-login.md +++ /dev/null @@ -1,87 +0,0 @@ ---- -title: Federated Login -modified: 2026-02-20 -last-reviewed: 2026-02-20 -tags: - - explanation - - security - - oidc ---- - -# Federated Login - -> **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 authentication works across BlumeOps services, and why it's designed this way. - -## The Problem - -Without centralized authentication, every service manages its own users independently. Grafana has an admin password, ArgoCD has a different admin password, Forgejo has local accounts, and zot has no auth at all. This creates several problems: - -- **Password sprawl** — different credentials for every service, all stored separately in 1Password -- **No onboarding path** — adding a collaborator means creating accounts in every service individually -- **No single sign-on** — logging into Grafana doesn't help you access ArgoCD -- **Inconsistent security** — some services have auth, some don't, and there's no central audit trail - -## The Solution: Authentik - -BlumeOps uses [[authentik]] as the central OIDC identity provider. Authentik is the **source of truth** for user identity — users are created and managed in Authentik, and services authenticate against it via OpenID Connect. - -This is a deliberate choice: Authentik provides a full-featured identity management UI, Blueprint-driven GitOps configuration, and support for multiple authentication protocols. Services like [[grafana]] delegate their login flow to Authentik using OIDC, and Authentik issues standardized tokens that carry user identity. - -## The Login Flow - -When a user clicks "Sign in with Authentik" on Grafana: - -``` -1. Grafana redirects browser to Authentik (authentik.ops.eblu.me/application/o/authorize/) -2. User logs in at Authentik (or is already logged in) -3. Authentik issues an OIDC token -4. Authentik redirects back to Grafana (grafana.ops.eblu.me/login/generic_oauth) -5. Grafana accepts the token, user is logged in -``` - -If the user is already logged into Authentik, the flow happens instantly — it feels like a single click. - -## Break-Glass Access - -Every service that uses Authentik SSO also keeps a local admin login. If Authentik goes down (or ringtail is offline), recovery works through: - -1. SSH to indri -2. Log into ArgoCD with local admin password (from 1Password) -3. Fix whatever is broken - -Authentik is additive — it's a convenience layer, not a hard dependency. Services never lose their local auth capability. - -## Cross-Cluster Communication - -Authentik runs on [[ringtail]]'s k3s cluster while most services run on indri's minikube. This is deliberate — the IdP is independent of the main services cluster. Communication happens via the Tailscale network: - -- Grafana (minikube) → `authentik.ops.eblu.me` → Caddy (indri) → Tailscale → Authentik (ringtail k3s) -- Browser redirects go through `authentik.ops.eblu.me`, resolved via Caddy - -No k8s-internal DNS crosses cluster boundaries. Everything uses the `*.ops.eblu.me` domain. - -## Forgejo - -[[forgejo]] authenticates against Authentik using the same OIDC flow as Grafana. The auth source is created via CLI (`forgejo admin auth add-oauth`) rather than config file — it lives in Forgejo's SQLite database. - -Account linking is configured with `ACCOUNT_LINKING = login`: when an Authentik user's email matches an existing local account, Forgejo prompts for the local password to confirm the link. This safely preserves the existing `eblume` account with all its API tokens, SSH keys, and repository ownership. - -The `admins` group in Authentik maps to Forgejo admin status, enabling centralized admin management. - -### MFA - -Authentik enforces TOTP MFA on its default authentication flow (`not_configured_action: configure`). Forgejo's auth source has `SkipLocalTwoFA: true`, so SSO logins bypass Forgejo's local 2FA — Authentik has already verified the second factor. Local password logins (break-glass) still require Forgejo's own TOTP. - -## Future Work - -- **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/explanation/no-helm-policy.md b/docs/explanation/no-helm-policy.md deleted file mode 100644 index 760c234..0000000 --- a/docs/explanation/no-helm-policy.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -title: No Helm Policy -modified: 2026-04-06 -tags: - - explanation - - kubernetes ---- - -# 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 - -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. The last Helm dependency (1Password Connect) was migrated in 2026-04. - -## 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 | 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 - -- **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/explanation/security-model.md b/docs/explanation/security-model.md deleted file mode 100644 index 37ce305..0000000 --- a/docs/explanation/security-model.md +++ /dev/null @@ -1,150 +0,0 @@ ---- -title: Security Model -modified: 2026-02-11 -last-reviewed: 2026-02-11 -tags: - - explanation - - security ---- - -# Security Model - -> **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 handles network security, secrets, and access control. - -## Network Security: Tailscale - -The foundational security decision is using [[tailscale]] as the network layer. - -### Zero Trust Networking - -BlumeOps infrastructure has no public IP addresses or port forwarding. Most services are only accessible via Tailscale: - -- **Encrypted by default** - WireGuard encryption for all traffic -- **Identity-based access** - ACLs based on user/device identity, not IP addresses -- **Minimal public surface** - only selected services are exposed via [[flyio-proxy]] - -### Public Access via Fly.io - -A small number of services are exposed to the internet through a reverse proxy on Fly.io that tunnels back to the homelab over Tailscale. The proxy uses restricted ACLs (`tag:flyio-target`) so it can only reach explicitly tagged endpoints — a compromised proxy cannot route to arbitrary services on the tailnet. See [[flyio-proxy]] for details and [[expose-service-publicly]] for the security considerations. - -### Defense in Depth - -Even within the tailnet, access is restricted: - -``` -Internet ──▶ Fly.io proxy ──▶ tag:flyio-target only (docs, observability) - -Tailnet: - Admin ────────▶ All services - Member ───────▶ User-facing services only - Homelab tag ──▶ NAS (for backups) -``` - -See [[tailscale]] for the full ACL matrix. - -### Tailscale Operator Privileges - -The [[tailscale-operator]] bridges Kubernetes and the Tailscale control plane. Its Kubernetes RBAC is namespaced to `tailscale` — it can't read secrets or create pods in other namespaces. On the Tailscale side, its OAuth client can create devices, generate auth keys, and assign `tag:k8s` or `tag:flyio-target`. In practice this means anyone who can write Ingress resources to the cluster can expose a service to the tailnet (or publicly, via `tag:flyio-target`), and Tailscale admins can reconfigure how those services are routed. Both are expected parts of normal operations — but be careful about granting write access to either Kubernetes or the Tailscale admin console, since both can change what's exposed. - -## Secrets Management - -Secrets follow a hierarchy: - -### Source of Truth: 1Password - -All secrets originate in 1Password's `blumeops` vault: -- API keys, tokens, passwords -- SSH keys and certificates -- OAuth credentials - -### Kubernetes: External Secrets Operator - -[[external-secrets]] syncs secrets from 1Password to Kubernetes: - -``` -1Password ──▶ 1Password Connect ──▶ ExternalSecret ──▶ K8s Secret -``` - -Services reference native Kubernetes Secrets; they don't know about 1Password. - -### Ansible: op CLI - -Ansible playbooks fetch secrets at runtime via `op read`: - -```yaml -- name: Fetch secret - ansible.builtin.command: - cmd: op read "op://vault/item/field" - delegate_to: localhost -``` - -Always use `op read` — never `op item get --fields`, which corrupts multi-line values by wrapping them in quotes. Secrets are held in memory as Ansible facts, never written to disk. - -### Git Repository - -The repository is public. Secrets must never be committed: -- `.gitignore` excludes sensitive patterns -- Pre-commit hooks scan for potential secrets (TruffleHog) -- All config files use references to secrets, not values - -## Access Control Philosophy - -### Principle of Least Privilege - -Services and devices get minimum necessary access: - -| Entity | Access | -|--------|--------| -| Admin users | Everything | -| Member users | User-facing services only | -| Homelab servers | Only what they need (NAS for backups) | -| K8s pods | No Tailscale access (use Caddy proxy) | - -### Tagged Devices vs User Devices - -Important Tailscale concept: -- **User devices** (like gilbert) have user identity and inherit user ACLs -- **Tagged devices** (like indri with `tag:homelab`) lose user identity - -Don't tag user devices - it breaks user-based access rules. - -## Authentication Patterns - -### Service-to-Service - -Internal services use: -- Kubernetes service discovery (no auth needed within cluster) -- Tailscale identity for cross-host communication - -### User-to-Service - -Users authenticate via: -- Service-specific credentials (stored in 1Password) -- Some services support Tailscale identity (future) - -### AI/Automation Access - -Claude Code and automation use: -- SSH keys for git operations -- ArgoCD tokens for deployments -- 1Password CLI for secret retrieval (requires user approval) - -## What's Not Protected - -Honest assessment of security boundaries: - -- **Local network attacks** - If someone is on your home WiFi, they could potentially access the NAS directly -- **Physical access** - No disk encryption on servers (trade-off for reliability) -- **Supply chain** - Container images from upstream registries -- **Operator error** - Misconfigured ACLs or leaked credentials - -The model assumes a trusted home network and focuses on protecting against internet-based attacks. - -## Related - -- [[tailscale]] - ACL configuration -- [[1password]] - Secrets management -- [[external-secrets]] - Kubernetes secrets -- [[architecture]] - Overall system design diff --git a/docs/explanation/spork-strategy.md b/docs/explanation/spork-strategy.md deleted file mode 100644 index f5ac4ea..0000000 --- a/docs/explanation/spork-strategy.md +++ /dev/null @@ -1,68 +0,0 @@ ---- -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 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: <your-runner-label>` -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 - -- [[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 - -- [[manage-forgejo-mirrors]] — how upstream mirrors work -- [[kingfisher]] — first project using the spork strategy diff --git a/docs/explanation/why-gitops.md b/docs/explanation/why-gitops.md deleted file mode 100644 index 42a0754..0000000 --- a/docs/explanation/why-gitops.md +++ /dev/null @@ -1,73 +0,0 @@ ---- -title: Why GitOps -modified: 2026-02-13 -last-reviewed: 2026-02-13 -tags: - - explanation - - philosophy ---- - -# Why GitOps? - -> **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 uses GitOps principles for managing personal infrastructure. This might seem like overkill for a homelab, but there are good reasons. - -## The Problem with Manual Infrastructure - -Traditional server management involves SSHing into machines and running commands. This works, but creates problems: - -- **Drift**: The actual state diverges from what you think it is -- **Amnesia**: You forget what you changed and why -- **Fragility**: One bad command can break things with no easy rollback -- **Bus factor**: Only you know how it works (even AI assistants struggle without context) - -## Git as the Source of Truth - -GitOps inverts the model: instead of pushing changes to servers, you commit desired state to Git, and automation pulls it into reality. - -**Benefits:** -- Every change is tracked with commit history -- Pull requests enable review before deployment -- Rollback is just `git revert` -- The repo *is* the documentation - -## Why This Matters for a Homelab - -A personal homelab isn't a production environment, but it shares the same challenges: - -1. **Memory is unreliable** - Six months from now, you won't remember why you configured Caddy that way -2. **Experimentation is constant** - You try things, break things, want to undo things -3. **AI assistance needs context** - Claude can help much more effectively when it can read your infrastructure as code - -## The BlumeOps Approach - -BlumeOps uses layered GitOps: - -| Layer | Tool | What it manages | -|-------|------|-----------------| -| **Network** | [[pulumi]] | Tailscale ACLs, tags; Gandi DNS | -| **Host config** | [[ansible]] | Services on [[indri]] | -| **Kubernetes** | [[argocd]] | Containerized workloads | - -Each layer has its own reconciliation loop: -- Pulumi applies on `mise run tailnet-up` -- Ansible applies on `mise run provision-indri` -- ArgoCD watches Git and syncs manually or automatically - -## Trade-offs - -GitOps isn't free: - -- **Learning curve** - You need to understand Ansible, ArgoCD, Pulumi -- **Indirection** - Can't just `brew install` something; need to add it to config -- **Complexity** - More moving parts than a simple server - -But for BlumeOps, the trade-off is worth it. The infrastructure is complex enough that managing it imperatively would be error-prone, and the GitOps approach enables effective AI-assisted operations. - -## Related - -- [[architecture]] - How the pieces fit together -- [[pulumi]] - Network infrastructure as code -- [[argocd]] - Kubernetes GitOps -- [[ansible]] - Host configuration diff --git a/docs/how-to/authentik/authentik-nix-build-components.md b/docs/how-to/authentik/authentik-nix-build-components.md deleted file mode 100644 index 548ec83..0000000 --- a/docs/how-to/authentik/authentik-nix-build-components.md +++ /dev/null @@ -1,136 +0,0 @@ ---- -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/build-authentik-container.md b/docs/how-to/authentik/build-authentik-container.md deleted file mode 100644 index 6b3ab57..0000000 --- a/docs/how-to/authentik/build-authentik-container.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -title: Build Authentik Container Image -modified: 2026-02-20 -last-reviewed: 2026-02-22 -tags: - - how-to - - authentik ---- - -# Build Authentik Container Image - -Build and publish a Nix-based container image for Authentik to the local registry. - -## Context - -Discovered while attempting [[deploy-authentik]]: the deployment references `registry.ops.eblu.me/blumeops/authentik:v1.0.0-nix` which doesn't exist. Authentik's nixpkgs package (`pkgs.authentik`) provides the `ak` wrapper which orchestrates a Go server binary and Python Django worker. - -## What to Do - -1. Verify `containers/authentik/default.nix` builds — locally via Dagger (`dagger call build-nix --src=. --container-name=authentik`) or on ringtail (the CI nix builder runs there) -2. The `ak` entrypoint needs bash (included via `bashInteractive`) and orchestrates both `server` and `worker` subcommands -3. Trigger build: `mise run container-build-and-release authentik` -4. Verify the `-nix` tagged image appears in the registry - -## What We Learned - -- The entrypoint is `ak` (bash wrapper), not `authentik` (Go binary) -- `ak server` runs the Go HTTP server, `ak worker` runs the Python Django worker -- `pkgs.authentik` bundles Go binary, Python environment, and static assets via `wrapProgram` -- nixpkgs has v2025.10.1, upstream latest is 2025.12.4 — acceptable for initial deployment -- Container needs `bashInteractive` since `ak` is a bash script - -## Related - -- [[deploy-authentik]] — Parent goal diff --git a/docs/how-to/authentik/build-authentik-from-source.md b/docs/how-to/authentik/build-authentik-from-source.md deleted file mode 100644 index 806b4cc..0000000 --- a/docs/how-to/authentik/build-authentik-from-source.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -title: Build Authentik from Source -modified: 2026-03-01 -last-reviewed: 2026-03-02 -tags: - - how-to - - authentik - - nix ---- - -# Build Authentik from Source - -Custom Nix derivation that builds authentik from source, replacing the `pkgs.authentik` nixpkgs dependency. This gives full version control independent of the nixpkgs release cycle. - -## Motivation - -The nix-container-builder runner on ringtail resolves `nixpkgs` via the NixOS nix registry, which pins to `nixos-25.11`. That channel lags behind upstream authentik releases. Building from source lets us target any release by updating `sources.nix`. - -## Architecture - -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-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 - -The `ak` wrapper script in `default.nix` sets PATH/VIRTUAL_ENV and delegates to `lifecycle/ak`, which dispatches `server` to the Go binary and everything else to Python/Django. - -**Python packaging strategy:** Nix provides the Python 3.14 interpreter and system libraries. Python packages are installed from PyPI using `uv`, locked by authentik's `uv.lock`. This avoids nixpkgs' Python 3.14 compatibility issues and aligns with upstream's build process. - -## Source - -All derivations fetch from forge mirrors for supply chain control: -- https://forge.eblu.me/mirrors/authentik (upstream: `goauthentik/authentik`) -- https://forge.eblu.me/mirrors/authentik-client-go (upstream: `goauthentik/client-go`) - -Version and hashes are centralized in `containers/authentik/sources.nix`. - -## Updating to a New Version - -1. Update `version` in `sources.nix` and `default.nix` -2. Update `src` and `client-go-src` hashes in `sources.nix` (use `nix-prefetch-git` on ringtail) -3. Rebuild `python-deps.nix` FOD — hash changes when `uv.lock` changes -4. Rebuild `webui-deps.nix` FOD — hash changes when `package-lock.json` or platform-specific npm binaries change -5. Recompute `vendorHash` in `authentik-server.nix` if Go dependencies changed -6. Test on ringtail: `nix-build test-build.nix -A assembled` -7. Build and push the container via CI - -## Testing - -Nix derivations target `x86_64-linux`. Test incrementally on ringtail: - -```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 assembled --extra-experimental-features 'nix-command flakes'" -ssh ringtail "rm -rf $tmpdir" -``` - -`test-build.nix` provides both individual component targets and a fully-wired `assembled` target. - -## Related - -- [[build-authentik-container]] — Container build reference -- [[deploy-authentik]] — Parent deployment goal -- [[agent-change-process]] — C2 methodology diff --git a/docs/how-to/authentik/create-authentik-secrets.md b/docs/how-to/authentik/create-authentik-secrets.md deleted file mode 100644 index 1e6e07d..0000000 --- a/docs/how-to/authentik/create-authentik-secrets.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -title: Create Authentik Secrets -modified: 2026-02-22 -last-reviewed: 2026-02-22 -tags: - - how-to - - authentik - - secrets ---- - -# Create Authentik Secrets - -Create the 1Password item that the ExternalSecret references for Authentik configuration. - -## What Was Done - -1. Created 1Password item "Authentik (blumeops)" in vault `blumeops` (category: database) with fields: - - `secret-key`: random 68-character base64 string (for `AUTHENTIK_SECRET_KEY`) - - `postgresql-host`: `pg.ops.eblu.me` - - `postgresql-port`: `5432` - - `postgresql-name`: `authentik` - - `postgresql-user`: `authentik` - - `postgresql-password`: random 44-character base64 string -2. ExternalSecret `blumeops-pg-authentik` in databases namespace resolves successfully (verified during [[provision-authentik-database]]) - -## Notes - -- The database password in this 1Password item is the same one used by the CNPG managed role via `external-secret-authentik.yaml`. Both the database ExternalSecret and the future Authentik deployment ExternalSecret reference the same 1Password item but different fields. -- The 1Password item has since grown with OIDC client secrets (`grafana-client-secret`, `forgejo-client-secret`, `zot-client-secret`, `jellyfin-client-secret`) and an `api-token` field, added during subsequent service integrations. - -## Related - -- [[deploy-authentik]] — Parent goal -- [[provision-authentik-database]] — Database provisioning (uses `postgresql-password` field) diff --git a/docs/how-to/authentik/deploy-authentik.md b/docs/how-to/authentik/deploy-authentik.md deleted file mode 100644 index bbbbfd8..0000000 --- a/docs/how-to/authentik/deploy-authentik.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -title: Deploy Authentik Identity Provider -modified: 2026-02-23 -last-reviewed: 2026-02-23 -tags: - - how-to - - authentik - - security - - oidc ---- - -# Deploy Authentik Identity Provider - -Replace Dex with [Authentik](https://goauthentik.io/) as the SSO identity provider. Authentik is the **source of truth** for user identity in BlumeOps. Users are created and managed in Authentik; services authenticate against it via OIDC. - -## Architecture Decisions - -| Decision | Choice | Rationale | -|----------|--------|-----------| -| **Identity model** | Authentik is source of truth | Central user/group management, not Forgejo-upstream like Dex | -| **Cluster** | [[ringtail]] (k3s) | IdP independent of main services cluster, same as Dex | -| **Database** | CNPG `blumeops-pg` on [[indri]] | Cross-cluster via Caddy L4 (`pg.ops.eblu.me`), no new operator needed | -| **Redis** | Co-deployed in authentik namespace | Required for caching/sessions/task queue | -| **Containers** | Nix-built (`dockerTools.buildLayeredImage`) | Supply chain control, consistent with Dex/ntfy pattern | -| **Manifests** | Kustomize (no Helm) | Consistent with all other BlumeOps services | -| **Networking** | Tailscale Ingress + Caddy reverse proxy | Same pattern as Dex | -| **IaC** | Authentik Blueprints (YAML in ConfigMap) | GitOps-native, config stored in repo | - -## Deployment Process - -1. Build a Nix container image — Authentik needs `coreutils` and `bashInteractive` alongside the main package; the entrypoint wrapper must symlink built-in blueprint directories so custom blueprints coexist with defaults -2. Create secrets in 1Password (secret key, DB credentials, OIDC client secrets) -3. Provision a dedicated database and managed role on the shared CNPG cluster -4. Deploy server, worker, and Redis as separate deployments -5. Wire ExternalSecret to pull config from 1Password -6. Add Tailscale Ingress and Caddy reverse proxy entries -7. Complete the first-run wizard manually (creates admin account) -8. Migrate OIDC clients via Blueprints, then decommission the old IdP - -## URLs - -- **Admin:** https://authentik.ops.eblu.me/if/admin/ -- **Tailscale:** https://authentik.tail8d86e.ts.net - -## Related - -- [[authentik]] — OIDC identity provider -- [[federated-login]] — How authentication works across BlumeOps -- [[ringtail]] — Target cluster -- [[agent-change-process]] — C2 methodology used for this change diff --git a/docs/how-to/authentik/migrate-grafana-to-authentik.md b/docs/how-to/authentik/migrate-grafana-to-authentik.md deleted file mode 100644 index 4f47041..0000000 --- a/docs/how-to/authentik/migrate-grafana-to-authentik.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -title: Migrate Grafana to Authentik -modified: 2026-02-24 -last-reviewed: 2026-02-24 -tags: - - how-to - - authentik - - grafana ---- - -# Migrate Grafana to Authentik - -Move Grafana's OIDC authentication from Dex to Authentik, then decommission Dex. - -## What Was Done - -### Blueprint loading fix - -The Nix-built container hardcoded `blueprints_dir` to its Nix store path, making custom blueprints invisible. Fixed by adding a wrapper entrypoint that symlinks built-in blueprint dirs from `/nix/store/*authentik-django*/blueprints/` into `/blueprints/` at container start, with `AUTHENTIK_BLUEPRINTS_DIR=/blueprints` set in the container env. The `/blueprints` dir is created world-writable by `extraCommands` so user 65534 can write symlinks. Also fixed the `!Env` tag syntax in the blueprint YAML — `!Env` takes a scalar, not a sequence (`!Env FOO` not `!Env [FOO]`). - -### Authentik configuration (via Blueprint) - -- Blueprint at `argocd/manifests/authentik/configmap-blueprint.yaml` defines: `admins` group, Grafana OAuth2 provider (client ID: `grafana`), Grafana application, and policy binding -- Blueprint mounted as ConfigMap into worker at `/blueprints/custom/` -- `grafana-client-secret` stored in 1Password "Authentik (blumeops)" -- API token stored as `api-token` in same item - -### Grafana configuration - -- `argocd/manifests/grafana/configmap.yaml` updated to point at Authentik OIDC endpoints (`authentik.ops.eblu.me`) -- `argocd/manifests/grafana-config/external-secret-authentik-oauth.yaml` pulls client secret from "Authentik (blumeops)" -- Old Dex OAuth user deleted from Grafana (different `auth_id` caused "user already exists") - -### Dex decommission - -- ArgoCD app `dex` deleted (cascade removed k8s resources from ringtail) -- Removed `argocd/manifests/dex/`, `argocd/apps/dex.yaml`, `external-secret-dex-oauth.yaml` -- Removed `dex` entry from Caddy reverse proxy config - -## Lessons Learned - -- `buildLayeredImage`'s `extraCommands` can't access Nix store paths from `contents` — they're in separate layers. Use a runtime entrypoint wrapper for symlinks instead. -- Authentik `!Env` tag takes a bare scalar (`!Env FOO`), not a YAML sequence (`!Env [FOO]`). The `!Find` tag does use sequences. -- When migrating OAuth providers, the subject ID (`auth_id`) changes. Existing Grafana users must be deleted before the new provider can recreate them. - -## Related - -- [[deploy-authentik]] — Parent goal -- [[grafana]] — Grafana reference diff --git a/docs/how-to/authentik/mirror-authentik-build-deps.md b/docs/how-to/authentik/mirror-authentik-build-deps.md deleted file mode 100644 index e5619ed..0000000 --- a/docs/how-to/authentik/mirror-authentik-build-deps.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -title: Mirror Authentik Build Dependencies -modified: 2026-03-02 -last-reviewed: 2026-03-02 -tags: - - how-to - - authentik ---- - -# Mirror Authentik Build Dependencies - -Mirror the external repositories needed to build authentik from source onto the forge, ensuring full supply chain control. - -## Context - -Building authentik from source requires fetching code from two GitHub repositories. The main `goauthentik/authentik` repo is already mirrored, but one companion repo needed mirroring: - -- **`goauthentik/client-go`** — Go API client bindings, versioned in lockstep with authentik (e.g. `v3.2026.2.0` matches `version/2026.2.0`). Used by the Go server build. - -Previously, `authentik-community/django-rest-framework` (a DRF fork) was also needed. As of authentik 2026.2.0, standard `djangorestframework` from PyPI is used instead — the fork mirror (`authentik-django-rest-framework`) can be archived. - -## What to Do - -1. Mirror `goauthentik/client-go`: - ```fish - mise run mirror-create https://github.com/goauthentik/client-go.git \ - --name authentik-client-go \ - --description "Go API client for authentik (lockstep versioned)" - ``` -2. Verify mirror syncs: check tags appear on forge - -## Related - -- [[build-authentik-from-source]] — Parent goal -- [[authentik-nix-build-components]] — Consumes client-go mirror diff --git a/docs/how-to/authentik/provision-authentik-database.md b/docs/how-to/authentik/provision-authentik-database.md deleted file mode 100644 index 9d9eb6b..0000000 --- a/docs/how-to/authentik/provision-authentik-database.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -title: Provision Authentik Database -modified: 2026-02-20 -last-reviewed: 2026-02-25 -tags: - - how-to - - authentik - - postgresql ---- - -# Provision Authentik Database - -Create a PostgreSQL database and user for Authentik on the existing CNPG cluster. - -## What Was Done - -1. Added `authentik` managed role to `blumeops-pg` CNPG cluster (`argocd/manifests/databases/blumeops-pg.yaml`) — non-superuser with `createdb` and `login` -2. Created ExternalSecret `blumeops-pg-authentik` pulling password from 1Password item "Authentik (blumeops)" field `postgresql-password` -3. Synced CNPG cluster — role reconciled with password set -4. Created `authentik` database owned by `authentik` user -5. Verified cross-cluster connectivity: ringtail pod → `pg.ops.eblu.me:5432` (Caddy L4) - -## Resolved Questions - -- **Hostname:** `pg.ops.eblu.me` via Caddy L4 plugin (not MagicDNS) -- **Permissions:** Non-superuser with `createdb` — Authentik manages its own schema via migrations - -## Related - -- [[deploy-authentik]] — Parent goal -- [[postgresql]] — CNPG cluster reference diff --git a/docs/how-to/configuration/build-spork-container.md b/docs/how-to/configuration/build-spork-container.md deleted file mode 100644 index cdb637a..0000000 --- a/docs/how-to/configuration/build-spork-container.md +++ /dev/null @@ -1,101 +0,0 @@ ---- -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/how-to/configuration/create-a-spork.md b/docs/how-to/configuration/create-a-spork.md deleted file mode 100644 index cd1a6f3..0000000 --- a/docs/how-to/configuration/create-a-spork.md +++ /dev/null @@ -1,84 +0,0 @@ ---- -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/<project>` 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/<project>` on forge | -| `mirror` | Read-only upstream mirror at `mirrors/<project>` 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-eblu-me-dns.md b/docs/how-to/configuration/manage-eblu-me-dns.md deleted file mode 100644 index 4c37d4c..0000000 --- a/docs/how-to/configuration/manage-eblu-me-dns.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -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=<indri-tailscale-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 deleted file mode 100644 index 5d150dc..0000000 --- a/docs/how-to/configuration/manage-forgejo-mirrors.md +++ /dev/null @@ -1,149 +0,0 @@ ---- -title: Manage Forgejo Mirrors -modified: 2026-02-26 -last-reviewed: 2026-02-26 -tags: - - how-to - - forgejo - - git ---- - -# Manage Forgejo Mirrors - -How Forgejo upstream mirrors work, how to create new mirrors, and how to rotate the GitHub PAT used for authenticated sync. - -## Overview - -BlumeOps mirrors upstream repositories (mostly from GitHub) into the `mirrors/` organization on forge. These are **pull mirrors** — Forgejo periodically fetches from the upstream URL and updates the local copy. ArgoCD and other consumers then read from forge instead of hitting upstream directly. - -### Why Authenticate - -GitHub rate-limits unauthenticated git fetch/clone over HTTPS. As of May 2025, these limits were tightened significantly. All mirrors should use an authenticated `clone_addr` (via a GitHub fine-grained PAT) to avoid throttling. - -The GitHub PAT is stored in 1Password: - -| Property | Value | -|----------|-------| -| **Vault** | blumeops (`vg6xf6vvfmoh5hqjjhlhbeoaie`) | -| **Item** | Forgejo Secrets (`w3663ffnvkewbftncqxtcpeavy`) | -| **Field** | `github-mirror-pat` | -| **op ref** | `op://blumeops/w3663ffnvkewbftncqxtcpeavy/github-mirror-pat` | - -### Sync Interval - -Mirror sync frequency is controlled by two settings in `app.ini`: - -| Setting | Section | Default | Purpose | -|---------|---------|---------|---------| -| `DEFAULT_INTERVAL` | `[mirror]` | `8h` | How often each mirror checks for upstream changes | -| `MIN_INTERVAL` | `[mirror]` | `10m` | Floor for per-repo interval overrides | -| `SCHEDULE` | `[cron.update_mirrors]` | `@every 10m` | How often the cron scans for due mirrors | - -With 10–30 mirrors at 8h intervals, expect ~1–4 fetches/hour — well within any rate limit when authenticated. - -The `[mirror]` settings are explicitly configured in `ansible/roles/forgejo/templates/app.ini.j2`. The `[cron.update_mirrors]` SCHEDULE is a Forgejo built-in default and is not in the template. - -## Prerequisites - -- Access to 1Password blumeops vault -- Forgejo admin account on forge.ops.eblu.me -- `op` CLI authenticated -- For new mirrors: `mise run mirror-create` - -## Create a New Mirror - -```fish -mise run mirror-create https://github.com/org/repo.git -``` - -Options: -- `--name <name>` — override the repo name on forge (default: derived from URL) -- `--description <text>` — set the repo description -- `--dry-run` — preview without creating - -For GitHub upstreams, the script automatically includes the GitHub PAT from 1Password so the mirror authenticates from the start. Non-GitHub upstreams (Codeberg, etc.) are created without upstream auth. - -## Update All Mirror PATs - -To update the GitHub PAT on all existing mirrors at once: - -```fish -mise run mirror-update-pats -``` - -This SSHs into indri and rewrites the git remote URL in each mirror's bare repository to embed `eblume:<PAT>@` in the upstream URL. It reads the PAT from 1Password and skips mirrors that already have the current PAT. - -Use `--dry-run` to preview: - -```fish -mise run mirror-update-pats --dry-run -``` - -### How It Works - -Forgejo stores mirror credentials directly in the bare repo's git config on disk (not in the database). The `remote_address` in SQLite stays as the clean URL; the actual fetch URL in `<repo>.git/config` contains the embedded credentials: - -``` -# Unauthenticated -url = https://github.com/org/repo.git - -# Authenticated -url = https://eblume:<pat>@github.com/org/repo.git -``` - -The Forgejo API has no endpoint for updating pull mirror credentials, so the script updates the git config directly via SSH. - -## Rotate the GitHub PAT - -The GitHub fine-grained PAT has a 30-day expiry. Set a recurring reminder (every 20 days) to rotate it before it expires. - -### 1. Create a New PAT on GitHub - -Go to [GitHub fine-grained token settings](https://github.com/settings/personal-access-tokens/new) and create a new token: - -- **Name:** `forgejo-mirror-sync` (or similar, include the date for tracking) -- **Expiration:** 30 days -- **Repository access:** Public repositories (read-only) -- **Permissions:** None required — fine-grained PATs automatically include read-only access to all public repos - -Copy the new PAT to your clipboard. - -### 2. Update 1Password - -With the new PAT on your clipboard: - -```fish -op item edit w3663ffnvkewbftncqxtcpeavy github-mirror-pat=(pbpaste) --vault blumeops -``` - -Verify the update: - -```fish -op read "op://blumeops/w3663ffnvkewbftncqxtcpeavy/github-mirror-pat" | head -c 12 -# Should print the first 12 chars of the new PAT (github_pat_...) -``` - -### 3. Push the PAT to All Mirrors - -```fish -mise run mirror-update-pats -``` - -### 4. Delete the Old PAT on GitHub - -Return to [GitHub token settings](https://github.com/settings/tokens?type=beta) and delete the previous token. - -### 5. Verify - -Trigger a manual sync on one mirror to confirm the new PAT works: - -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 - -- [[forgejo]] — Forgejo service reference -- [[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/manage-spork-branches.md b/docs/how-to/configuration/manage-spork-branches.md deleted file mode 100644 index 7bcf4fc..0000000 --- a/docs/how-to/configuration/manage-spork-branches.md +++ /dev/null @@ -1,116 +0,0 @@ ---- -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/<project> repo, PR targeting mirrors/<project>: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:** 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` - -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/docs/how-to/configuration/rotate-fly-deploy-token.md b/docs/how-to/configuration/rotate-fly-deploy-token.md deleted file mode 100644 index 9abe5f0..0000000 --- a/docs/how-to/configuration/rotate-fly-deploy-token.md +++ /dev/null @@ -1,122 +0,0 @@ ---- -title: Rotate the Fly.io API Token -modified: 2026-05-04 -last-reviewed: 2026-05-04 -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 (heph 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 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 -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 -``` - -`bash` / `zsh`: - -```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=<paste-new-token>' --vault vg6xf6vvfmoh5hqjjhlhbeoaie -``` - -> **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: - -```fish -mise run provision-indri -- --tags forgejo_actions_secrets -``` - -### 4. 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. - -### 5. Revoke the old token - -```fish -fly tokens list -fly tokens revoke <old-token-id> -``` - -## 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/rotate-gandi-pat.md b/docs/how-to/configuration/rotate-gandi-pat.md deleted file mode 100644 index 5ce6f81..0000000 --- a/docs/how-to/configuration/rotate-gandi-pat.md +++ /dev/null @@ -1,125 +0,0 @@ ---- -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 (heph 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/how-to/configuration/update-documentation.md b/docs/how-to/configuration/update-documentation.md deleted file mode 100644 index 7c98e24..0000000 --- a/docs/how-to/configuration/update-documentation.md +++ /dev/null @@ -1,125 +0,0 @@ ---- -title: Update Documentation -modified: 2026-02-19 -last-reviewed: 2026-02-19 -tags: - - how-to - - documentation - - ci-cd ---- - -# Update Documentation - -How to publish documentation changes to https://docs.eblu.me. - -## Quick Release - -After merging documentation changes to main: - -1. Go to **Actions** > **Build BlumeOps** > **Run workflow** -2. Select version bump type (patch/minor/major) or enter a specific version -3. The workflow builds, releases, and deploys automatically - -Direct link: https://forge.eblu.me/eblume/blumeops/actions?workflow=build-blumeops.yaml - -## What the Workflow Does - -The `build-blumeops` workflow (`.forgejo/workflows/build-blumeops.yaml`): - -1. **Resolves version** — Uses input or auto-increments from latest release -2. **Builds changelog** — Runs towncrier on the runner to update `CHANGELOG.md` -3. **Builds docs** — Calls `dagger call build-docs` (Quartz build in a container) -4. **Creates release** — Uploads `docs-<version>.tar.gz` to Forgejo releases -5. **Updates deployment** — Edits `argocd/manifests/docs/deployment.yaml` with new URL -6. **Commits changes** — Pushes changelog and deployment updates to main -7. **Deploys** — Syncs the `docs` ArgoCD app -8. **Purges cache** — Clears the nginx cache on the [[flyio-proxy]] so the new docs are served immediately - -## Changelog Fragments (Towncrier) - -When making changes, add a changelog fragment to `docs/changelog.d/`: - -```bash -# Format: <identifier>.<type>.md -# Types: feature, bugfix, infra, doc, ai, misc - -# Using branch name (preferred) -echo "Add new feature X" > docs/changelog.d/my-feature.feature.md - -# Orphan fragment (when no branch fits) -echo "Fix bug Y" > docs/changelog.d/+fix-bug.bugfix.md -``` - -Fragments are automatically collected into `CHANGELOG.md` (at repo root) during release. - -**Fragment types:** -| Type | Description | -|------|-------------| -| `feature` | New features | -| `bugfix` | Bug fixes | -| `infra` | Infrastructure changes | -| `doc` | Documentation updates | -| `ai` | AI assistance changes | -| `misc` | Other changes | - -## Runner Environment - -The workflow runs on the `k8s` label, which uses the [[forgejo]]-runner in Kubernetes: - -- **Runner deployment**: `argocd/manifests/forgejo-runner/` -- **Job image**: `registry.ops.eblu.me/blumeops/runner-job-image` (commit-SHA tagged) -- **Build engine**: [[dagger]] CLI installed at runtime; Node.js and Python run inside Dagger containers - -The job image is built from `containers/forgejo-runner/Dockerfile`. - -## Quartz Static Site Generator - -[Quartz](https://quartz.jzhao.xyz/) builds the documentation into a static site with: -- Wiki-link support (`[[page]]` syntax) -- Backlinks panel showing what references each page -- Graph view of document connections -- Full-text search - -**Configuration files** (in `docs/`): -- `quartz.config.ts` - Site metadata, plugins, theme -- `quartz.layout.ts` - Page layout components - -Quartz is cloned fresh during each build (not vendored) to use the latest version. - -## Manual Build (Local) - -To test docs locally without triggering a release: - -```bash -# Build docs tarball (identical to CI) -dagger call build-docs --src=. --version=dev export --path=./docs-dev.tar.gz - -# Inspect the output -tar tf docs-dev.tar.gz | head -20 - -# Debug a Quartz build failure interactively -dagger call --interactive build-docs --src=. --version=dev -``` - -## Troubleshooting - -**Workflow fails on "Resolve version":** -- Check if the version already exists as a release -- Ensure version format is `vX.Y.Z` - -**Docs not updating after deploy:** -- Check ArgoCD sync status: `argocd app get docs` -- Verify the pod restarted: `kubectl --context=minikube-indri -n docs get pods` -- Check pod logs for download errors - -**Towncrier not finding fragments:** -- Fragments must be in `docs/changelog.d/` -- Must have `.md` extension -- Must match pattern `<name>.<type>.md` - -## Related - -- [[docs]] - Documentation service reference -- [[dagger]] - Build engine reference -- [[forgejo]] - Git forge and CI/CD -- [[argocd]] - GitOps deployment diff --git a/docs/how-to/configuration/update-tailscale-acls.md b/docs/how-to/configuration/update-tailscale-acls.md deleted file mode 100644 index ab7f1d9..0000000 --- a/docs/how-to/configuration/update-tailscale-acls.md +++ /dev/null @@ -1,128 +0,0 @@ ---- -title: Update Tailscale ACLs -modified: 2026-02-25 -last-reviewed: 2026-02-25 -tags: - - how-to - - tailscale - - pulumi ---- - -# Update Tailscale ACLs - -How to modify Tailscale access control policies for the tailnet. - -## Prerequisites - -- Pulumi CLI installed (`brew install pulumi`) -- Access to 1Password blumeops vault (for OAuth credentials) - -## Edit the Policy - -The ACL policy lives in `pulumi/tailscale/policy.hujson` (HuJSON format with comments). - -Common changes: - -### Add a new ACL rule - -```json -{ - "acls": [ - // ... existing rules ... - { - "action": "accept", - "src": ["autogroup:admin"], - "dst": ["tag:newservice:*"] - } - ] -} -``` - -### Add a new tag - -```json -{ - "tagOwners": { - // ... existing tags ... - "tag:newservice": ["autogroup:admin"] - } -} -``` - -### Add a new group - -```json -{ - "groups": { - // ... existing groups ... - "group:newgroup": ["user1@example.com", "user2@example.com"] - } -} -``` - -## Preview and Apply - -```bash -# Preview changes (always do this first) -mise run tailnet-preview - -# Apply changes (auto-confirms via --yes) -mise run tailnet-up -``` - -## Verify - -Check the Tailscale admin console at https://login.tailscale.com/ to confirm changes. - -## Common Patterns - -### Service-specific access - -Grant access to a specific service port: - -```json -{ - "action": "accept", - "src": ["group:users"], - "dst": ["tag:homelab:8080"] -} -``` - -### SSH access - -```json -{ - "ssh": [ - { - "action": "check", - "src": ["autogroup:admin"], - "dst": ["tag:servers"], - "users": ["autogroup:nonroot"] - } - ] -} -``` - -### All ports for admins - -```json -{ - "action": "accept", - "src": ["autogroup:admin"], - "dst": ["*:*"] -} -``` - -## Troubleshooting - -**"Credential expired" error:** -Re-authenticate Pulumi with Tailscale. The OAuth token may need refreshing. - -**Changes not taking effect:** -ACL changes are applied immediately. If a device isn't following new rules, try `tailscale down && tailscale up` on that device. - -## Related - -- [[tailscale]] - ACL reference and current configuration -- [[pulumi]] - Pulumi IaC reference -- [[routing]] - Service routing diff --git a/docs/how-to/configuration/update-tooling-dependencies.md b/docs/how-to/configuration/update-tooling-dependencies.md deleted file mode 100644 index 2bfe887..0000000 --- a/docs/how-to/configuration/update-tooling-dependencies.md +++ /dev/null @@ -1,93 +0,0 @@ ---- -title: Update Tooling Dependencies -modified: 2026-02-23 -last-reviewed: 2026-02-23 -tags: - - how-to - - configuration -aliases: [] -id: update-tooling-dependencies ---- - -# Update Tooling Dependencies - -Monthly maintenance cycle for updating development tooling and CI dependencies. This is separate from [[review-services]], which tracks deployed service versions. - -## Scope - -| Category | Location | What to check | -|----------|----------|---------------| -| Prek hooks | `prek.toml` | `rev:` tags for all remote repos | -| Fly.io proxy | `fly/Dockerfile` | Pinned image tags (nginx, alloy) | -| Mise task scripts | `mise-tasks/*` | Python `# dependencies` lower bounds | -| Forgejo workflows | `.forgejo/workflows/*.yaml` | `uses:` action versions | - -Out of scope: ArgoCD-deployed service images, Ansible role versions, NixOS flake inputs. Those are covered by [[review-services]] and [[manage-lockfile]]. - -## Procedure - -### 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 **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/<owner>/<repo>.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 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** — 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/<image>:<tag> -# 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. Pin mise task dependencies - -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' -``` - -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 - -All `uses:` directives in `.forgejo/workflows/*.yaml` must reference upstream actions by **commit SHA**, not mutable tags. This prevents supply-chain attacks where a tag is moved to point at malicious code. - -Format: `uses: actions/checkout@<full-sha> # v4.3.1` - -The trailing comment documents the human-readable version. To update: - -```fish -git ls-remote --tags https://github.com/actions/checkout.git 'refs/tags/v4*' | sort -t/ -k3 -V | tail -5 -``` - -Pick the latest patch tag, note its SHA, and update all occurrences across the workflow files. - -### 5. Commit and create PR - -Create a single PR with all dependency bumps. The changelog fragment type is `infra`. - -## Notes - -- **Alloy version gaps**: Grafana Alloy releases frequently. Large version jumps (e.g., v1.5 to v1.13) are normal and generally safe — check the [changelog](https://github.com/grafana/alloy/releases) for breaking changes in the Alloy River config syntax. -- **Ruff minor bumps**: Ruff adds new lint rules in minor versions. A bump may surface new warnings. Run `prek run ruff --all-files` to check before committing. -- **shellcheck bumps**: New shellcheck versions may flag previously-ignored patterns. Review any new failures before updating. diff --git a/docs/how-to/configuration/use-pypi-proxy.md b/docs/how-to/configuration/use-pypi-proxy.md deleted file mode 100644 index 9e5187a..0000000 --- a/docs/how-to/configuration/use-pypi-proxy.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -title: Use PyPI Proxy -modified: 2026-02-07 -last-reviewed: 2026-02-25 -tags: - - how-to - - python ---- - -# Use the PyPI Proxy - -How to configure clients and publish packages to [[devpi]]. - -## Configure pip/uv - -Point pip and uv at the proxy via environment variables: - -```bash -export PIP_INDEX_URL="https://pypi.ops.eblu.me/root/pypi/+simple/" -export UV_INDEX_URL="https://pypi.ops.eblu.me/root/pypi/+simple/" -``` - -Unset both to fall back to public PyPI (e.g. when [[indri]] is offline). - -The [dotfiles repo](https://github.com/eblume/dotfiles) has shell config -that manages this toggle. - -## Upload Packages - -```bash -# Build and publish with uv -cd ~/code/personal/your-package -uv build -uv publish --publish-url https://pypi.ops.eblu.me/eblume/dev/ - -# First time: uv will prompt for credentials -``` - -## Create Users/Indices - -```bash -# Login as root -uvx devpi use https://pypi.ops.eblu.me -uvx devpi login root - -# Create user (prompts for password - store in 1Password) -uvx devpi user -c USERNAME email=EMAIL - -# Create index inheriting from PyPI mirror -uvx devpi index -c USERNAME/dev bases=root/pypi -``` - -## Verify Cache - -```bash -# Check if devpi is caching -curl -s https://pypi.ops.eblu.me/+api | jq -``` - -## Related - -- [[devpi]] - Service reference diff --git a/docs/how-to/dagger/upgrade-dagger.md b/docs/how-to/dagger/upgrade-dagger.md deleted file mode 100644 index 99058e4..0000000 --- a/docs/how-to/dagger/upgrade-dagger.md +++ /dev/null @@ -1,112 +0,0 @@ ---- -title: Upgrade Dagger -modified: 2026-04-11 -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 | -| `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=<new-version> - ``` - -2. Update `service-versions.yaml` — bump `current-version` and `last-reviewed` for `runner-job-image`. - -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<version>-<sha>`). - -### 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 = "<new-version>" - ``` - -2. Run `mise install` to get the new CLI locally. - -3. Update `dagger.json`: - ```json - "engineVersion": "v<new-version>" - ``` - -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. - -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:<tag>" - ``` - -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-<version>.<type>.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/deployment/add-ansible-role.md b/docs/how-to/deployment/add-ansible-role.md deleted file mode 100644 index 5c51a79..0000000 --- a/docs/how-to/deployment/add-ansible-role.md +++ /dev/null @@ -1,152 +0,0 @@ ---- -title: Add Ansible Role -modified: 2026-02-13 -last-reviewed: 2026-02-13 -tags: - - how-to - - ansible ---- - -# Add an Ansible Role - -Quick reference for adding a new Ansible role to provision services on [[indri]]. - -## Create Role Structure - -``` -ansible/roles/<role>/ -├── defaults/main.yml # Default variables -├── tasks/main.yml # Task definitions -├── handlers/main.yml # Handlers (restarts, etc.) -├── templates/ # Jinja2 templates -└── files/ # Static files (optional) -``` - -## Minimal Role Example - -```yaml -# ansible/roles/<role>/defaults/main.yml ---- -role_data_dir: ~/Library/Application Support/<service> -role_port: 8080 -``` - -```yaml -# ansible/roles/<role>/tasks/main.yml ---- -- name: Ensure data directory exists - ansible.builtin.file: - path: "{{ role_data_dir }}" - state: directory - mode: '0755' - -- name: Deploy configuration - ansible.builtin.template: - src: config.j2 - dest: "{{ role_data_dir }}/config" - mode: '0644' - notify: Restart <service> - -- name: Deploy LaunchAgent plist - ansible.builtin.template: - src: launchagent.plist.j2 - dest: ~/Library/LaunchAgents/mcquack.<service>.plist - mode: '0644' - notify: Restart <service> -``` - -```yaml -# ansible/roles/<role>/handlers/main.yml ---- -- name: Restart <service> - ansible.builtin.shell: | - launchctl unload ~/Library/LaunchAgents/mcquack.<service>.plist 2>/dev/null || true - launchctl load ~/Library/LaunchAgents/mcquack.<service>.plist - changed_when: true -``` - -## Add Role to Playbook - -Edit `ansible/playbooks/indri.yml`: - -```yaml - roles: - # ... existing roles ... - - role: <role> - tags: <role> -``` - -## Add Secrets (if needed) - -If the role needs secrets from 1Password, add pre_tasks: - -```yaml - pre_tasks: - # ... existing pre_tasks ... - - name: Fetch <role> secret - ansible.builtin.command: - cmd: op read "op://vg6xf6vvfmoh5hqjjhlhbeoaie/<item-id>/<field>" - delegate_to: localhost - register: _role_secret - changed_when: false - no_log: true - check_mode: false - tags: <role> - - - name: Set <role> secret fact - ansible.builtin.set_fact: - role_secret_var: "{{ _role_secret.stdout }}" - no_log: true - tags: <role> -``` - -Then use `role_secret_var` in your role with a guard: - -```yaml -# In role's tasks, fetch if not already set (allows running with --tags) -- name: Fetch secret if not set - ansible.builtin.command: - cmd: op read "op://vg6xf6vvfmoh5hqjjhlhbeoaie/<item-id>/<field>" - delegate_to: localhost - register: _role_secret - changed_when: false - no_log: true - check_mode: false - when: role_secret_var is not defined -``` - -## Test and Deploy - -```bash -# Dry run -mise run provision-indri -- --tags <role> --check --diff - -# Apply -mise run provision-indri -- --tags <role> - -# Verify -ssh indri 'launchctl list | grep <service>' -``` - -## Add Observability (optional) - -For metrics collection, create a companion `<role>_metrics` role that: -1. Writes metrics to `/opt/homebrew/var/node_exporter/textfile/` -2. Runs via a LaunchAgent (cronjob-style) - -See [[alloy]] for how metrics are collected from textfiles. - -## Checklist - -- [ ] Role created in `ansible/roles/<role>/` -- [ ] Role added to `ansible/playbooks/indri.yml` with tag -- [ ] Secrets wired via pre_tasks (if needed) -- [ ] Dry run passes: `mise run provision-indri -- --tags <role> --check --diff` -- [ ] Service added to `service-versions.yaml` for version tracking - -## Related - -- [[ansible]] - Available roles reference -- [[indri]] - Target host -- [[observability]] - Metrics collection -- [[review-services]] - Periodic service version review diff --git a/docs/how-to/deployment/build-caddy-with-plugins.md b/docs/how-to/deployment/build-caddy-with-plugins.md deleted file mode 100644 index 67e857c..0000000 --- a/docs/how-to/deployment/build-caddy-with-plugins.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -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/deployment/build-container-image.md b/docs/how-to/deployment/build-container-image.md deleted file mode 100644 index 2f0a980..0000000 --- a/docs/how-to/deployment/build-container-image.md +++ /dev/null @@ -1,164 +0,0 @@ ---- -title: Build Container Image -modified: 2026-04-11 -last-reviewed: 2026-02-15 -tags: - - how-to - - containers - - ci ---- - -# Build a Container Image - -How to create a custom container image in BlumeOps, build it locally, and release it to the [[zot]] registry via the Forgejo CI pipeline. - -## Prerequisites - -- [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 - -Add build files under `containers/<name>/`: - -``` -containers/<name>/ -├── 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 more build files. The directory name becomes the image name: `registry.ops.eblu.me/blumeops/<name>`. - -**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 - -**Any container** (native `container.py` or legacy Dockerfile) — test with Dagger: - -```bash -dagger call build --src=. --container-name=<name> -``` - -**Nix** — test with Dagger (no local nix required): - -```bash -dagger call build-nix --src=. --container-name=<name> export --path=./<name>.tar.gz -``` - -Or with nix-build directly (requires nix, e.g. on [[ringtail]]): - -```bash -nix-build containers/<name>/default.nix -o result -``` - -## 3. Release - -Container builds are triggered manually. Shared Dagger helpers (`src/blumeops/`) affect all Dagger-built containers, making path-based auto-triggers unreliable. - -To trigger a build: - -```bash -mise run container-build-and-release <name> -mise run container-build-and-release <name> --ref <commit-sha> -``` - -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 <run#> # see jobs and their status -mise run runner-logs <run#> -j <N> # fetch full logs (e.g. on failure) -``` - -| Build file | Workflow | Runner | Registry tag | -|------------|----------|--------|--------------| -| `container.py` | `build-container.yaml` | `k8s` (indri) | `:vX.Y.Z-<sha>` | -| `Dockerfile` | `build-container.yaml` | `k8s` (indri) | `:vX.Y.Z-<sha>` | -| `default.nix` | `build-container.yaml` | `nix-container-builder` ([[ringtail]]) | `:vX.Y.Z-<sha>-nix` | - -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: - -```bash -mise run container-list -``` - -## 4. Update k8s manifests - -Change the image reference in `argocd/manifests/<service>/deployment.yaml`: - -```yaml -image: registry.ops.eblu.me/blumeops/<name>:vX.Y.Z-abc1234 -``` - -Then deploy per [[deploy-k8s-service]]. - -### Squash-merge and container tags - -Container image tags include the git commit SHA they were built from (e.g. `v3.9.1-74029e1`). When a PR is squash-merged, the original branch commits are replaced by a single new commit on main — the SHA in the image tag no longer exists on main. After branch cleanup (30 days), the SHA becomes unreachable and the container loses source traceability. - -**The rule:** Production manifests must reference images built from a commit on main. After merging a PR that changed `containers/<name>/`: - -1. Trigger a rebuild: `mise run container-build-and-release <name>` -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 <name> - ``` - Tags marked `[main]` were built from a commit on main; tags marked `[branch]` are from PR branches -4. Commit a C0 follow-up updating the manifest to use the `[main]` tag: - ```yaml - image: registry.ops.eblu.me/blumeops/<name>:vX.Y.Z-<main-sha> - ``` - -This follow-up C0 is expected and routine — it's the cost of squash-merge + SHA-tagged containers. - -## Common Patterns - -Existing containers demonstrate several build approaches: - -| Pattern | Example | Notes | -|---------|---------|-------| -| 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` | -| 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) | - -### navidrome - -`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/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 - -`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. Nix containers should continue using `default.nix`. - -## Related - -- [[deploy-k8s-service]] — Deploying the service that uses the image -- [[create-release-artifact-workflow]] — Alternative: release non-container artifacts -- [[dagger]] — Dagger CI reference diff --git a/docs/how-to/deployment/create-release-artifact-workflow.md b/docs/how-to/deployment/create-release-artifact-workflow.md deleted file mode 100644 index 63c217e..0000000 --- a/docs/how-to/deployment/create-release-artifact-workflow.md +++ /dev/null @@ -1,75 +0,0 @@ ---- -title: Create Release Artifact Workflow -modified: 2026-02-15 -last-reviewed: 2026-02-15 -tags: - - how-to - - forgejo - - ci ---- - -# Create a Release Artifact Workflow - -How to set up a Forgejo Actions workflow that builds an artifact and publishes it to Forgejo generic packages. Uses the CV repo (`forge.ops.eblu.me/eblume/cv`) workflow as the reference implementation. - -## Prerequisites - -- A Forgejo repo with a build pipeline (Dagger, script, etc.) -- The `FORGE_TOKEN` secret provisioned via the `forgejo_actions_secrets` Ansible role - -## 1. Add the repo to Ansible secrets - -In `ansible/roles/forgejo_actions_secrets/defaults/main.yml`, add an entry under `forgejo_actions_secrets_repos`: - -```yaml -forgejo_actions_secrets_repos: - - repo: my-repo - secrets: - - name: FORGE_TOKEN - value_var: forgejo_api_token -``` - -Then provision: `mise run provision-indri -- --tags forgejo_actions_secrets` - -This is required because Forgejo's built-in `GITHUB_TOKEN` does not have permissions for the packages API. - -## 2. Create the workflow - -Create `.forgejo/workflows/<name>-release.yaml` with `workflow_dispatch` and a version input. Use the semver bump pattern (see `cv-release.yaml` for the full upload flow, or `build-blumeops.yaml` for the version bump logic only — it uploads to Forgejo releases, not generic packages). - -The upload step uses `FORGE_TOKEN`: - -```yaml -- name: Upload to Forgejo packages - env: - FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }} - run: | - curl -fsSL \ - -X PUT \ - -H "Authorization: token $FORGE_TOKEN" \ - --upload-file "./$TARBALL" \ - "https://forge.eblu.me/api/packages/eblume/generic/<package>/${VERSION}/${TARBALL}" -``` - -## 3. Link the package to the repo - -After the first successful upload, the package appears under your **user-level** packages at `https://forge.eblu.me/eblume/-/packages` but is not yet linked to the repo. - -To link it: - -1. Go to `https://forge.eblu.me/eblume/-/packages` -2. Click the package name -3. Click **Settings** -4. Under **Link this package to a repository**, select the repo -5. Click **Save** - -Once linked, the package shows up in the repo's **Packages** tab and the repo links back to the package. - -## 4. Create a deploy workflow (optional) - -If the artifact is consumed by a k8s deployment, create a separate deploy workflow in blumeops (see `cv-deploy.yaml`). This keeps the build/release concern in the source repo and the deploy concern in blumeops. - -## Related - -- [[deploy-k8s-service]] - Deploying the service that consumes the artifact -- [[add-ansible-role]] - Adding Ansible roles diff --git a/docs/how-to/deployment/deploy-k8s-service.md b/docs/how-to/deployment/deploy-k8s-service.md deleted file mode 100644 index 6005d6f..0000000 --- a/docs/how-to/deployment/deploy-k8s-service.md +++ /dev/null @@ -1,140 +0,0 @@ ---- -title: Deploy K8s Service -modified: 2026-02-15 -last-reviewed: 2026-02-15 -tags: - - how-to - - kubernetes - - argocd ---- - -# Deploy a Kubernetes Service - -Quick reference for deploying a new service to BlumeOps Kubernetes via ArgoCD. See [[adding-a-service|the tutorial]] for detailed explanations. - -## Create Manifests - -``` -argocd/manifests/<service>/ -├── deployment.yaml -├── service.yaml -└── ingress-tailscale.yaml -``` - -Namespace should match service name. Use `registry.ops.eblu.me` for images. - -## Create ArgoCD Application - -```yaml -# argocd/apps/<service>.yaml -apiVersion: argoproj.io/v1alpha1 -kind: Application -metadata: - name: <service> - namespace: argocd -spec: - project: default - source: - repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git - targetRevision: main - path: argocd/manifests/<service> - destination: - server: https://kubernetes.default.svc - namespace: <service> - syncPolicy: - syncOptions: - - CreateNamespace=true -``` - -## Configure Ingress - -Add a [[tailscale-operator|Tailscale Ingress]] routed through the ProxyGroup with Homepage annotations: - -```yaml -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: <service>-tailscale - namespace: <service> - annotations: - tailscale.com/proxy-class: "default" - tailscale.com/proxy-group: "ingress" - gethomepage.dev/enabled: "true" - gethomepage.dev/name: "Service Name" - gethomepage.dev/group: "Services" - gethomepage.dev/icon: "<service>.png" - gethomepage.dev/href: "https://<service>.ops.eblu.me" - gethomepage.dev/pod-selector: "app=<service>" -spec: - ingressClassName: tailscale - defaultBackend: - service: - name: <service> - port: - number: 80 - tls: - - hosts: - - <service> -``` - -Key points: -- **`proxy-group: "ingress"`** routes through the shared ProxyGroup instead of spawning a per-ingress proxy -- **Do not use `rules:` with `host:`** — the ProxyGroup proxy receives the FQDN as Host header (e.g. `<service>.tail8d86e.ts.net`), so a short `host: <service>` won't match. Use `defaultBackend` instead. -- **`tls.hosts`** sets the MagicDNS hostname (becomes `<service>.tail8d86e.ts.net`) -- **`gethomepage.dev/group`** — use one of the existing groups: "Services", "Content", or "Infrastructure" -- **`tailscale.com/tags`** is not needed in the default case — the ProxyGroup already applies `tag:k8s`. Only add this annotation when the service needs public internet access via the [[flyio-proxy]]. When you do, you must include both tags (setting tags overrides the ProxyGroup default): - ```yaml - tailscale.com/tags: "tag:k8s,tag:flyio-target" - ``` - Then add a Caddy route and Fly.io proxy config per [[expose-service-publicly]]. - -## Add Caddy Route (if needed) - -If other pods need to access the service, add to `ansible/roles/caddy/defaults/main.yml`: - -```yaml -caddy_services: - - name: <service> - host: "<service>.{{ caddy_domain }}" - backend: "https://<service>.tail8d86e.ts.net" -``` - -Then: `mise run provision-indri -- --tags caddy` - -See [[routing]] for when Caddy is needed. - -## Deploy - -```bash -# Sync apps to pick up new Application -argocd app sync apps - -# Test on feature branch first -argocd app set <service> --revision <branch> -argocd app sync <service> - -# Verify -kubectl --context=minikube-indri -n <service> get pods -kubectl --context=minikube-indri -n <service> logs -f deployment/<service> - -# After PR merge, reset to main -argocd app set <service> --revision main -argocd app sync <service> -``` - -## Checklist - -- [ ] Manifests in `argocd/manifests/<service>/` -- [ ] Application in `argocd/apps/<service>.yaml` -- [ ] Tailscale Ingress via ProxyGroup with Homepage annotations -- [ ] Caddy route (if pod-to-service access needed) -- [ ] Tested on feature branch -- [ ] PR reviewed and merged -- [ ] Reset to main branch -- [ ] Service added to `service-versions.yaml` for version tracking - -## Related - -- [[adding-a-service]] - Full tutorial with explanations -- [[apps]] - ArgoCD application registry -- [[routing]] - Service routing options diff --git a/docs/how-to/forgejo-runner/configure-k8s-runner.md b/docs/how-to/forgejo-runner/configure-k8s-runner.md deleted file mode 100644 index 3c095d0..0000000 --- a/docs/how-to/forgejo-runner/configure-k8s-runner.md +++ /dev/null @@ -1,100 +0,0 @@ ---- -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:<tag> -``` - -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/validate-forgejo-workflows.md b/docs/how-to/forgejo-runner/validate-forgejo-workflows.md deleted file mode 100644 index ed21de7..0000000 --- a/docs/how-to/forgejo-runner/validate-forgejo-workflows.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -title: Validate Forgejo Workflows -modified: 2026-04-11 -last-reviewed: 2026-04-20 -tags: - - how-to - - forgejo-runner - - ci ---- - -# Validate Forgejo Workflows - -Run `forgejo-runner validate` against all workflow files to catch schema issues before upgrading the k8s runner daemon. - -## Result - -All current workflows pass the validation step with no changes needed: - -- `branch-cleanup.yaml` — OK -- `build-blumeops.yaml` — OK -- `build-container-nix.yaml` — OK -- `build-container.yaml` — OK -- `cv-deploy.yaml` — OK -- `deploy-fly.yaml` — OK - -## Deliverables - -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 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 - -## Usage - -```fish -mise run validate-workflows -# or directly: -dagger call validate-workflows --src=. -``` - -## Related - -- [[configure-k8s-runner]] — Runner configuration and upgrade flow diff --git a/docs/how-to/grafana/build-grafana-images.md b/docs/how-to/grafana/build-grafana-images.md deleted file mode 100644 index 8a5ca3c..0000000 --- a/docs/how-to/grafana/build-grafana-images.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -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-<version>` (e.g. `grafana-12.3.3`), *not* `grafana-v<version>`. -- **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 - -**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 the Python package into a venv, and copies it into a Python Alpine runtime image. - -```fish -# Update VERSION in container.py - -mise run container-build-and-release grafana-sidecar -``` - -**Gotchas:** - -- **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 - -- [[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/kustomize-grafana-deployment.md b/docs/how-to/grafana/kustomize-grafana-deployment.md deleted file mode 100644 index d5d2773..0000000 --- a/docs/how-to/grafana/kustomize-grafana-deployment.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -title: Kustomize Grafana Deployment -modified: 2026-03-03 -last-reviewed: 2026-03-03 -tags: - - how-to - - grafana ---- - -# Kustomize Grafana Deployment - -Grafana is deployed via plain Kustomize manifests in `argocd/manifests/grafana/`, replacing the previous Helm chart. - -## Manifest Structure - -| File | Purpose | -|------|---------| -| `kustomization.yaml` | Resource list + configMapGenerator for config files | -| `deployment.yaml` | Grafana container + home-built k8s-sidecar for dashboards | -| `service.yaml` | ClusterIP on port 80 → 3000 | -| `pvc.yaml` | 1Gi SQLite storage | -| `grafana.ini` | Grafana server configuration (fed to configMapGenerator) | -| `datasources.yaml` | Datasource provisioning (fed to configMapGenerator) | -| `provider.yaml` | Dashboard provider config (fed to configMapGenerator) | -| `serviceaccount.yaml` | Service account | -| `rbac.yaml` | ClusterRole/RoleBinding for sidecar ConfigMap access | - -## Key Details - -- **PVC name must remain `grafana`** — changing it would create a new volume and lose the SQLite DB -- **Sidecar** watches ConfigMaps with label `grafana_dashboard=1` and reloads dashboards via the Grafana API -- **Secrets** come from ExternalSecrets (`grafana-admin`, `grafana-authentik-oauth`, `grafana-teslamate-datasource`) managed by the `grafana-config` ArgoCD app - -## Related - -- [[upgrade-grafana]] — Migration context -- [[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 deleted file mode 100644 index 88d41f9..0000000 --- a/docs/how-to/grafana/upgrade-grafana.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -title: Upgrade Grafana -modified: 2026-03-04 -last-reviewed: 2026-03-04 -tags: - - how-to - - grafana - - observability ---- - -# Upgrade Grafana - -Upgraded Grafana from 11.4.0 (Helm chart) to 12.3.3, converting from Helm to Kustomize with a home-built container image. - -## What Changed - -- **Image:** `docker.io/grafana/grafana:11.4.0` → `registry.ops.eblu.me/blumeops/grafana` (tagged via Kustomize `images:` overlay) -- **Deployment:** Helm multi-source (chart + values) → single Kustomize directory -- **ArgoCD app:** Simplified to one source pointing at `argocd/manifests/grafana/` - -All existing datasources ([[prometheus]], [[loki]], TeslaMate), dashboard ConfigMaps, and Authentik OIDC were preserved without changes. - -## Grafana 12 Breaking Changes - -None affected us: - -- **Angular plugin removal** — our dashboards already used React panels -- **Datasource UID format enforcement** — our UIDs were already compliant -- **Annotation table migration** — completed automatically on the small SQLite DB - -## How to Repeat - -To upgrade Grafana again in the future: - -1. Update `CONTAINER_APP_VERSION` in `containers/grafana/Dockerfile` -2. Build and push via `mise run container-build-and-release grafana` -3. Update the image tag in `argocd/manifests/grafana/kustomization.yaml` (under `images:`) -4. Update `service-versions.yaml` -5. Sync: `argocd app sync grafana` - -The SQLite PVC is disposable — dashboards come from ConfigMaps and datasources from config. - -## Related - -- [[grafana]] — Service reference card -- [[build-grafana-images]] — Building the container images -- [[kustomize-grafana-deployment]] — Kustomize manifest structure diff --git a/docs/how-to/immich/cnpg-on-ringtail.md b/docs/how-to/immich/cnpg-on-ringtail.md deleted file mode 100644 index 153e674..0000000 --- a/docs/how-to/immich/cnpg-on-ringtail.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -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 deleted file mode 100644 index 51b619d..0000000 --- a/docs/how-to/immich/immich-app-on-ringtail.md +++ /dev/null @@ -1,91 +0,0 @@ ---- -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:<version>` 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 deleted file mode 100644 index b44fddd..0000000 --- a/docs/how-to/immich/immich-cutover-and-decommission.md +++ /dev/null @@ -1,103 +0,0 @@ ---- -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 <branch>` 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 deleted file mode 100644 index fb87783..0000000 --- a/docs/how-to/immich/immich-pg-data-migration.md +++ /dev/null @@ -1,79 +0,0 @@ ---- -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 deleted file mode 100644 index 10c7072..0000000 --- a/docs/how-to/immich/immich-pg-on-ringtail.md +++ /dev/null @@ -1,69 +0,0 @@ ---- -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 deleted file mode 100644 index e654b62..0000000 --- a/docs/how-to/immich/migrate-immich-to-ringtail.md +++ /dev/null @@ -1,134 +0,0 @@ ---- -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 <app> --revision <branch> && argocd app sync <app>` -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/<new-app>.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 <new-app> --revision mikado/migrate-immich-to-ringtail -argocd app sync <new-app> -``` - -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 <new-app> --revision main -argocd app sync <new-app> -``` - -`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 - -- [[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 -- [[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 deleted file mode 100644 index 2c490c1..0000000 --- a/docs/how-to/immich/sifaka-nfs-from-ringtail.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -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. diff --git a/docs/how-to/knowledgebase/review-documentation.md b/docs/how-to/knowledgebase/review-documentation.md deleted file mode 100644 index 1dfba4e..0000000 --- a/docs/how-to/knowledgebase/review-documentation.md +++ /dev/null @@ -1,119 +0,0 @@ ---- -title: Review Documentation -modified: 2026-02-09 -last-reviewed: 2026-03-07 -tags: - - how-to - - documentation - - maintenance ---- - -# Review Documentation - -How to periodically review and maintain the BlumeOps knowledge base. - -## Review by Staleness - -Show docs sorted by when they were last reviewed (most stale first): - -```bash -mise run docs-review -``` - -This reads the `last-reviewed` frontmatter field from each card. Cards without the field are treated as never-reviewed and appear at the top. The script shows a staleness table and then displays the most stale card with a review checklist. - -To show more entries in the table: - -```bash -mise run docs-review --limit 30 -``` - -### Marking a Card as Reviewed - -After reviewing a card, add or update the `last-reviewed` field in its frontmatter: - -```yaml ---- -title: Some Card -last-reviewed: 2026-02-09 -tags: - - reference ---- -``` - -Commit this change alongside any fixes you make during the review. - -## Review Checklist - -When reviewing a documentation card, consider: - -| Check | Description | -|-------|-------------| -| **Accuracy** | Is the information current and correct? | -| **Links** | Are wiki-links working? Should more be added? | -| **Scope** | Is the card appropriately sized (not too large/small)? | -| **Category** | Is it in the right section (reference/how-to/tutorial/explanation)? | -| **Frontmatter** | Are title and tags appropriate? | -| **Related** | Should it link to related cards? | - -## Verify Deployed State - -For service reference cards, verify the documentation matches reality: - -### ArgoCD Apps (Kubernetes services) - -Check if the app is synced and healthy: - -```bash -argocd app get <app-name> -argocd app diff <app-name> # Show pending changes -``` - -If out of sync, either the docs are stale or a deployment is pending. - -### Ansible Roles (indri services) - -Check if the role applies idempotently (no changes needed): - -```bash -mise run provision-indri -- --tags <role> --check --diff -``` - -If changes would be made, either the docs are stale or the host has drifted. - -### Pulumi (Tailscale ACLs, DNS) - -Check for drift: - -```bash -mise run tailnet-preview # Tailscale ACLs -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. 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 -``` - -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]]): - -- **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. - -## Related - -- [[update-documentation]] - Publishing documentation changes -- [[exploring-the-docs]] - Navigating the documentation diff --git a/docs/how-to/knowledgebase/review-services.md b/docs/how-to/knowledgebase/review-services.md deleted file mode 100644 index 9969e4c..0000000 --- a/docs/how-to/knowledgebase/review-services.md +++ /dev/null @@ -1,143 +0,0 @@ ---- -title: Review Services -modified: 2026-04-12 -last-reviewed: 2026-04-12 -tags: - - how-to - - maintenance - - services ---- - -# Review Services - -How to periodically review BlumeOps services for version freshness and upgrade opportunities. - -## Review by Staleness - -Show services sorted by when they were last reviewed (most stale first): - -```bash -mise run service-review -``` - -This reads the tracking file at `service-versions.yaml` (repo root) and sorts by the `last-reviewed` field. Services without a review date float to the top. The script shows a staleness table and then displays the most stale service with a review checklist. - -To show more entries in the table: - -```bash -mise run service-review --limit 30 -``` - -To filter by service type: - -```bash -mise run service-review --type argocd -mise run service-review --type ansible -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 -2. Compare to the image tag in `argocd/manifests/<service>/kustomization.yaml` (`images[].newTag`) -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`) - -1. Check the upstream releases page for new versions -2. Review the role's vars/defaults for version pins in `ansible/roles/<service>/` -3. If upgrading, update the version and dry-run: `mise run provision-indri -- --tags <service> --check --diff` -4. Follow [[add-ansible-role]] patterns for role changes - -### 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. Check what version nixpkgs has: `ssh ringtail 'nix eval nixpkgs#<pkg>.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 - -### 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: - -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: - -```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. - -## Marking a Service as Reviewed - -After reviewing, edit `service-versions.yaml` (repo root) and update the service entry: - -```yaml -- name: prometheus - type: argocd - last-reviewed: 2026-02-16 - current-version: "v3.9.1" - upstream-source: https://github.com/prometheus/prometheus/releases -``` - -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 -- [[add-ansible-role]] - Add or modify Ansible roles -- [[service-versions]] - Version tracking file reference diff --git a/docs/how-to/mealie/plan-a-meal.md b/docs/how-to/mealie/plan-a-meal.md deleted file mode 100644 index 1e6eb48..0000000 --- a/docs/how-to/mealie/plan-a-meal.md +++ /dev/null @@ -1,136 +0,0 @@ ---- -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 -set SEED (date +%s) - -# Get a random protein -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&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&paginationSeed=$SEED&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/how-to/mealie/restore-from-borg.md b/docs/how-to/mealie/restore-from-borg.md deleted file mode 100644 index 7ff3625..0000000 --- a/docs/how-to/mealie/restore-from-borg.md +++ /dev/null @@ -1,157 +0,0 @@ ---- -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::<archive-name> \ - --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::<archive-name> \ - 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 <<EOF -apiVersion: v1 -kind: Pod -metadata: - name: mealie-restore - namespace: mealie -spec: - restartPolicy: Never - containers: - - name: helper - image: alpine:3.20 - command: [\"sh\", \"-c\", \"sleep 3600\"] - volumeMounts: - - name: data - mountPath: /data - volumes: - - name: data - persistentVolumeClaim: - claimName: mealie-data -EOF -kubectl --context=minikube apply -f /tmp/mealie-helper.yaml && \ - kubectl --context=minikube -n mealie wait --for=condition=Ready pod/mealie-restore --timeout=60s"' -``` - -### 7. Copy and Swap the DB - -```bash -ssh indri 'bash -c "kubectl --context=minikube cp \ - /Users/erichblume/tmp/mealie-restore/Users/erichblume/.local/share/borgmatic/k8s-dumps/mealie.db \ - mealie/mealie-restore:/data/mealie.db.restored && \ - kubectl --context=minikube -n mealie exec mealie-restore -- sh -c \ - \"mv /data/mealie.db /data/mealie.db.wiped && mv /data/mealie.db.restored /data/mealie.db && chown 911:911 /data/mealie.db\""' -``` - -Mealie's container runs as UID 911. `kubectl cp` preserves the host UID (501 on indri), so the `chown` is required — without it, mealie may fail to write to the DB. - -### 8. Tear Down the Helper and Scale Back Up - -```bash -ssh indri 'kubectl --context=minikube delete pod -n mealie mealie-restore --wait=true && \ - kubectl --context=minikube -n mealie scale deploy/mealie --replicas=1 && \ - kubectl --context=minikube -n mealie wait --for=condition=Available deploy/mealie --timeout=120s' -``` - -### 9. Verify the Restore is Live - -```bash -ssh indri 'bash -c "kubectl --context=minikube -n mealie exec deploy/mealie -- \ - python3 -c \"import sqlite3; c=sqlite3.connect(\\\"/app/data/mealie.db\\\"); \ - print(\\\"recipes:\\\", c.execute(\\\"select count(*) from recipes\\\").fetchone()[0]); \ - print(\\\"users:\\\", c.execute(\\\"select count(*) from users\\\").fetchone()[0])\""' -``` - -The counts should match what you saw in step 3. - -### 10. Clean Up - -Once mealie is working and you've confirmed the data, remove the in-PVC safety copy: - -```bash -ssh indri 'kubectl --context=minikube -n mealie exec deploy/mealie -- rm -f /app/data/mealie.db.wiped' -``` - -Leave the host-side copy at `~/tmp/mealie-restore/wiped-mealie.db` until the next borgmatic run captures the restored state, in case you need to roll back. - -## Notes - -- The active kubectl context for mealie is `minikube` on [[indri]] until the [[ringtail]] migration completes. Update the `--context` flag if mealie has moved. -- OIDC sign-ins after the wipe may have created new user rows; the restored DB will replace them. Affected users will sign in fresh and Authentik will re-link them on next login. - -## Related - -- [[mealie]] — Service reference -- [[borgmatic]] — Backup tooling -- [[restore-1password-backup]] — Similar restore pattern for 1Password diff --git a/docs/how-to/operations/connect-to-postgres.md b/docs/how-to/operations/connect-to-postgres.md deleted file mode 100644 index d6b01f7..0000000 --- a/docs/how-to/operations/connect-to-postgres.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -title: Connect to Postgres -modified: 2026-02-15 -last-reviewed: 2026-02-15 -tags: - - how-to - - database ---- - -# Connect to Postgres - -How to connect to the [[postgresql]] cluster as a superuser using `psql`. - -## Prerequisites - -- `psql` installed (`brew install libpq` on macOS) -- [1Password CLI](https://developer.1password.com/docs/cli/) (`op`) installed and signed in -- Machine on the tailnet (e.g. [[gilbert]]) - -## Connect - -```bash -PGPASSWORD=$(op read "op://blumeops/postgres/password") psql -h pg.ops.eblu.me -U eblume -d postgres -``` - -This connects as the `eblume` superuser. To connect to a specific database, replace `postgres` with the database name (e.g. `miniflux`, `teslamate`). - -## Useful Queries - -```sql --- List databases -\l - --- List roles -\du - --- Check cluster status (CNPG) -SELECT pg_is_in_recovery(); - --- Show active connections -SELECT datname, usename, client_addr, state -FROM pg_stat_activity -WHERE state IS NOT NULL; -``` - -## Related - -- [[postgresql]] - Service reference -- [[borgmatic]] - Database backup -- [[troubleshooting]] - Cluster health checks diff --git a/docs/how-to/operations/cv-on-indri.md b/docs/how-to/operations/cv-on-indri.md deleted file mode 100644 index 432acab..0000000 --- a/docs/how-to/operations/cv-on-indri.md +++ /dev/null @@ -1,72 +0,0 @@ ---- -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/deploy-prowler.md b/docs/how-to/operations/deploy-prowler.md deleted file mode 100644 index 1475680..0000000 --- a/docs/how-to/operations/deploy-prowler.md +++ /dev/null @@ -1,76 +0,0 @@ ---- -title: Deploy Prowler CIS Scanner -modified: 2026-06-08 -last-reviewed: 2026-03-24 -tags: - - how-to - - kubernetes - - security - - compliance ---- - -# Deploy Prowler CIS Scanner - -Prowler runs a weekly CIS Kubernetes Benchmark scan against minikube-indri and writes HTML/CSV/JSON reports to the NFS share on sifaka. - -## Why only the K8s CIS scan - -Prowler originally ran three CronJobs: K8s CIS, container-image CVE scanning, and IaC scanning. The image and IaC scans were **retired in 2026-06**. - -Both were pure toil with no realized value: - -- **Image scan** produced ~20,000 unmuted findings per run and growing, none ever triaged or muted. They were overwhelmingly CVEs in *upstream* base images we don't control and can't patch, and the job re-scanned every historical tag still in the registry, multiplying the count. -- **IaC scan** produced ~650 Trivy KSV findings (`runAsNonRoot`, `readOnlyRootFilesystem`, drop-capabilities, …) against our own manifests — real but systemic, homelab-acceptable, and likewise never muted, so the weekly review re-surfaced all of them indefinitely. - -The K8s CIS scan, by contrast, is fully mutelisted and runs clean (0 unmuted findings week over week), so it stays. The guiding principle matches [[ai-scraper-mitigation]]: don't keep generating a firehose of output that has no audience. If image-CVE signal is wanted later, the right shape is critical-severity-only, currently-deployed-tags-only, alert-on-new — a rebuild, not a revival (tracked as the "Trivy for image/IaC scanning" task). - -Note that the K8s CIS scan itself is tied to minikube-indri, which is slated for retirement; on k3s only ~22 of 70 checks produce results (no static pods). Re-pointing a lean posture check at ringtail is tracked separately ("prowler scan against ringtail"). - -## 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 | -|----------|--------|-------------| -| **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/devpi-on-indri.md b/docs/how-to/operations/devpi-on-indri.md deleted file mode 100644 index 0334d37..0000000 --- a/docs/how-to/operations/devpi-on-indri.md +++ /dev/null @@ -1,74 +0,0 @@ ---- -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/docs-on-indri.md b/docs/how-to/operations/docs-on-indri.md deleted file mode 100644 index e683db5..0000000 --- a/docs/how-to/operations/docs-on-indri.md +++ /dev/null @@ -1,66 +0,0 @@ ---- -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-<version>.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/how-to/operations/manage-flyio-proxy.md b/docs/how-to/operations/manage-flyio-proxy.md deleted file mode 100644 index d1a243d..0000000 --- a/docs/how-to/operations/manage-flyio-proxy.md +++ /dev/null @@ -1,96 +0,0 @@ ---- -title: Manage Fly.io Proxy -modified: 2026-04-18 -last-reviewed: 2026-04-18 -tags: - - how-to - - fly-io - - networking - - operations ---- - -# Manage Fly.io Proxy - -Operational tasks for the [[flyio-proxy]] public reverse proxy. - -## Deploy Changes - -After modifying files in `fly/`: - -```bash -mise run fly-deploy -``` - -Pushes to `fly/` on main also trigger automatic deployment via the Forgejo CI workflow. - -## Add a New Public Service - -See [[expose-service-publicly#Per-service setup]] for the full walkthrough. In short: - -1. Add a `server` block to `fly/nginx.conf` -2. Add a Fly.io certificate: `fly certs add <domain> -a blumeops-proxy` -3. Deploy: `mise run fly-deploy` -4. Verify against `blumeops-proxy.fly.dev` with a `Host` header -5. Add DNS CNAME via Pulumi: `mise run dns-preview` then `mise run dns-up` - -## Emergency Shutoff - -If the proxy is causing issues (DDoS, unexpected traffic, bandwidth consumption on the home network): - -**Level 1 — Stop the container (seconds, reversible):** -```bash -mise run fly-shutoff -# or: fly scale count 0 -a blumeops-proxy --yes -``` -All public services go offline immediately. Tailscale tunnel drops. Zero traffic reaches indri. Restore with `fly scale count 1 -a blumeops-proxy`. - -**Level 2 — Revoke Tailscale access (seconds):** -Remove the `flyio-proxy` node in the Tailscale admin console. Even if the container is running, it cannot reach the tailnet. Use this if the container itself may be compromised. - -**Level 3 — Remove DNS (minutes to hours):** -Delete the CNAME records at Gandi. Takes time for DNS propagation but is the permanent shutoff. - -**Level 1 is the primary response.** It is a single command, takes effect in seconds, and is trivially reversible. Keep `mise run fly-shutoff` somewhere easily accessible (e.g., pinned in a notes app) so it can be run quickly under stress. - -## Check Status - -```bash -# App and machine status -fly status -a blumeops-proxy - -# Live logs -fly logs -a blumeops-proxy - -# Health check -curl -sf https://blumeops-proxy.fly.dev/healthz - -# Certificate status -fly certs list -a blumeops-proxy -``` - -## Rotate Tailscale Auth Key - -The auth key expires every 90 days. To rotate: - -1. Re-apply Pulumi to generate a new key: `mise run tailnet-up` -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. - -**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 <domain> -a blumeops-proxy`. Certs auto-provision via Let's Encrypt and may take a few minutes. - -**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 - -- [[flyio-proxy]] - Service reference card -- [[expose-service-publicly]] - Full setup guide and architecture diff --git a/docs/how-to/operations/read-compliance-reports.md b/docs/how-to/operations/read-compliance-reports.md deleted file mode 100644 index 2990026..0000000 --- a/docs/how-to/operations/read-compliance-reports.md +++ /dev/null @@ -1,95 +0,0 @@ ---- -title: Read Compliance Reports -modified: 2026-06-08 -last-reviewed: 2026-04-06 -tags: - - how-to - - security - - compliance ---- - -# Read Compliance Reports - -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: - -| Scanner | Path | Schedule | -|---------|------|----------| -| [[prowler]] K8s CIS | `sifaka:/volume1/reports/prowler/` | Weekly (Sunday 3am) | - -> **Retired (2026-06):** the Prowler **image** (`prowler-images/`) and **IaC** -> (`prowler-iac/`) scans were retired. They produced tens of thousands of -> un-actioned, un-muted findings every week — mostly unpatchable upstream-image -> CVEs and systemic pod-security KSV warnings — and nobody triaged them. See -> [[deploy-prowler#Why only the K8s CIS scan]] for the rationale. Their stale -> report directories may linger on sifaka until manually removed. - -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 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 - -- [[security]] — security & compliance posture overview -- [[deploy-prowler]] — Prowler deployment and ad-hoc scans -- [[kingfisher]] — secret detection scanner diff --git a/docs/how-to/operations/rebuild-minikube-cluster.md b/docs/how-to/operations/rebuild-minikube-cluster.md deleted file mode 100644 index 0d924e9..0000000 --- a/docs/how-to/operations/rebuild-minikube-cluster.md +++ /dev/null @@ -1,245 +0,0 @@ ---- -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 <name>`), 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)" \ - argocd app sync apps``` - -## Phase 4: Bootstrap 1Password Connect + External Secrets - -```bash -# 1. Sync foundation -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 \ - --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 -# Databases -argocd app sync blumeops-pg -# Observability -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 -# Everything else -argocd app sync tailscale-operator alloy-k8s# ... 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 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\";" - -# 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 <machine-id> --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 | - -## Post-Rebuild: Cold Cache Failures - -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 - -- [[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 deleted file mode 100644 index e92581e..0000000 --- a/docs/how-to/operations/restart-indri.md +++ /dev/null @@ -1,201 +0,0 @@ ---- -title: Restart Indri -modified: 2026-03-14 -last-reviewed: 2026-03-14 -tags: - - how-to - - operations ---- - -# Restart Indri - -How to safely shut down and restart [[indri]], the primary BlumeOps server. - -## Prerequisites - -- SSH access to indri -- Tailscale connected - -## Shutdown Procedure - -### 1. Stop Kubernetes Gracefully - -Minikube runs on the Docker driver, so stopping it cleanly ensures pods terminate gracefully and persistent volumes are properly unmounted. - -```bash -ssh indri 'minikube stop' -``` - -This may take a minute as pods receive termination signals. You can verify it stopped: - -```bash -ssh indri 'minikube status' -``` - -### 2. Stop Native Services (Optional) - -Native services managed by launchd will stop automatically during macOS shutdown. However, if you want to stop them explicitly first: - -```bash -# 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.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' -``` - -### 3. Quit GUI Applications - -These apps don't autostart and should be quit cleanly before reboot: - -- **Docker Desktop** - Quit from menubar or: `ssh indri 'osascript -e "quit app \"Docker\""'` -- **Amphetamine** - Quit from menubar (prevents sleep; will need restart) -- **AutoMounter** - Quit from menubar (mounts sifaka SMB shares) - -### 4. Reboot - -```bash -ssh indri 'sudo shutdown -r now' -``` - -Or if you're at the console, use the Apple menu. - -## Startup Procedure - -After indri boots, most services recover automatically. Only a few things need manual attention. - -**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). - -> **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: - -| App | Purpose | Launch Method | -|-----|---------|---------------| -| **Amphetamine** | Prevents sleep | Spotlight or App Store apps | -| **AutoMounter** | Mounts sifaka SMB shares to `/Volumes/` | Spotlight or App Store apps | - -Docker Desktop autostarts on login. Wait for it to finish starting (whale icon in menubar stops animating) before proceeding. - -### 2. Verify Sifaka Mounts - -AutoMounter should automatically mount the sifaka shares. Verify: - -```bash -ssh indri 'ls /Volumes/' -``` - -You should see: `allisonflix`, `backups`, `music`, `photos`, `torrents` (or similar). - -If mounts are missing, open AutoMounter and trigger a reconnect. - -### 3. Fix Minikube Remote Access - -Minikube uses the Docker driver, which assigns a **random API server port** on each start. After a reboot, the Tailscale serve proxy (`k8s.tail8d86e.ts.net`) will still point to the old port, breaking remote `kubectl` access. - -Run the minikube ansible role to detect the new port and update Tailscale serve: - -```bash -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 -- Update `tailscale serve` to forward to the correct port - -You can verify remote access works: - -```bash -kubectl --context=minikube-indri get nodes -``` - -### 4. Run Health Check - -Once everything is up, verify all services: - -```bash -mise run services-check -``` - -All checks should pass. If any fail, see [[troubleshooting]]. - -## Troubleshooting: CNI Conflict After Unclean Shutdown - -After a power loss or unclean reboot, minikube may come up with broken pod networking. The symptom is that **new pods cannot reach CoreDNS** — services crash-loop with DNS errors (`EAI_AGAIN`, `connection timed out; no servers could be reached`) or fail liveness probes because their event loops hang on blocked network calls. - -Existing pods that were restarted (not recreated) may appear healthy because the kubelet reuses their cached network namespaces. - -### Cause - -During minikube recovery from a bad state, the CRI-O / Docker networking bootstrap can regenerate a default CNI config file (`1-k8s.conflist`) that conflicts with kindnet's config (`10-kindnet.conflist`). Since `1-` sorts before `10-`, the stale bridge+firewall config takes precedence, and new pods get attached to a different network topology than existing pods. - -### Diagnosis - -**1. Check if new pods can resolve DNS:** - -```bash -kubectl --context=minikube-indri run dns-test --image=alpine:3.21 --restart=Never \ - --command -- sh -c 'nslookup kubernetes.default.svc.cluster.local' -sleep 10 -kubectl --context=minikube-indri logs dns-test -kubectl --context=minikube-indri delete pod dns-test -``` - -If this shows `connection timed out; no servers could be reached`, pod networking is broken. - -**2. Check for conflicting CNI configs:** - -```bash -ssh indri 'minikube ssh "ls -la /etc/cni/net.d/"' -``` - -You should see **only** `10-kindnet.conflist` (plus `200-loopback.conf` and disabled `.mk_disabled` files). If `1-k8s.conflist` or any other active config exists alongside `10-kindnet.conflist`, that's the conflict. - -**3. Confirm the conflict by inspecting the stale config:** - -```bash -ssh indri 'minikube ssh "cat /etc/cni/net.d/1-k8s.conflist"' -``` - -If it uses a `bridge` plugin with a `firewall` plugin (instead of kindnet's `ptp` plugin), it's the culprit. - -### Fix - -**1. Remove the stale CNI config:** - -```bash -ssh indri 'minikube ssh "sudo rm /etc/cni/net.d/1-k8s.conflist"' -``` - -**2. Delete all pods that were created while the bad config was active.** The simplest approach is to restart all deployments: - -```bash -kubectl --context=minikube-indri get deployments -A --no-headers | \ - awk '{print "-n " $1 " " $2}' | \ - xargs -L1 kubectl --context=minikube-indri rollout restart deployment -``` - -StatefulSets managed by operators (CNPG, Tailscale) generally survive because the kubelet restarts their containers in-place rather than creating new pods. - -**3. Verify with the DNS test above**, then run `mise run services-check`. - -## Related - -- [[indri]] - Server specifications -- [[troubleshooting]] - Diagnose issues -- [[cluster]] - Kubernetes details -- [[sifaka]] - NAS storage diff --git a/docs/how-to/operations/restore-1password-backup.md b/docs/how-to/operations/restore-1password-backup.md deleted file mode 100644 index 7b89004..0000000 --- a/docs/how-to/operations/restore-1password-backup.md +++ /dev/null @@ -1,107 +0,0 @@ ---- -title: Restore 1Password Backup -modified: 2026-03-15 -last-reviewed: 2026-03-15 -tags: - - how-to - - operations - - backup ---- - -# Restore 1Password Backup - -How to recover a 1Password `.1pux` export from a [[borgmatic]] backup. This procedure assumes the worst case — [[indri]] and [[sifaka]] may both be gone. All you need is a copy of the borg repository and your Emergency Kit. - -## Prerequisites - -- 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) - -## When to Use This - -Use this procedure when you've lost access to 1Password and need to recover credentials from the encrypted backup created by `mise run op-backup`. - -## Procedure - -### 1. Extract From Borg Repository - -If you have direct access to the borg repository (e.g. mounted from [[sifaka]] or restored from off-site), extract directly: - -```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="<your-borg-passphrase>" borg list /path/to/borg/repo --last 5 - -# Extract using the archive name from the list above -BORG_PASSPHRASE="<your-borg-passphrase>" borg extract \ - "/path/to/borg/repo::<archive-name>" \ - Users/erichblume/Documents/1password-backup/ -``` - -If [[indri]] is available, you can use borgmatic instead: - -```bash -ssh indri 'cd /tmp && mkdir -p op-restore && cd op-restore && \ - BORG_PASSCOMMAND="cat /Users/erichblume/.borg/config.yaml" \ - /opt/homebrew/bin/borg extract \ - "/Volumes/backups/borg/::<archive-name>" \ - Users/erichblume/Documents/1password-backup/' -``` - -Verify you have a `.age` file (~30-45 MB) and a `.key.enc` file (~200 bytes). - -### 2. Decrypt the Age Private Key - -The private key is encrypted with `openssl aes-256-cbc`. The passphrase is `{master_password}:{secret_key}` from your Emergency Kit. - -```bash -cd /tmp/op-restore/Users/erichblume/Documents/1password-backup -openssl enc -d -aes-256-cbc -pbkdf2 \ - -in 1password-export-*.key.enc \ - -out key.txt -``` - -Enter the passphrase when prompted: `{master_password}:{secret_key}` (colon-separated, no spaces around the colon). - -### 3. Decrypt the Export - -```bash -age -d -i key.txt < 1password-export-*.age > export.1pux -``` - -### 4. Verify - -The `.1pux` file is a zip archive. Verify it looks correct: - -```bash -file export.1pux # Should say "Zip archive data" -ls -lh export.1pux # Should be ~30-45 MB -unzip -l export.1pux | head -20 # Should list files/ entries -``` - -### 5. Import Into 1Password - -Open 1Password and use **File > Import** to restore from the `.1pux` file. - -### 6. Clean Up - -Remove all temporary files — the decrypted export and key contain secrets: - -```bash -rm -rf /tmp/op-restore -``` - -## Notes on the Borg Passphrase - -The borg repo uses `repokey` encryption — the key is stored in the repo itself, so you only need the passphrase (not a separate keyfile). The passphrase is recorded on your Emergency Kit alongside the 1Password credentials. - -## Related - -- [[run-1password-backup]] - How to create the backup (export + encrypt + transfer) -- [[borgmatic]] - Backup system -- [[1password]] - Credential management -- [[backups]] - Backup policy and schedule -- [[disaster-recovery]] - Overall disaster recovery diff --git a/docs/how-to/operations/run-1password-backup.md b/docs/how-to/operations/run-1password-backup.md deleted file mode 100644 index 0dc9ec9..0000000 --- a/docs/how-to/operations/run-1password-backup.md +++ /dev/null @@ -1,73 +0,0 @@ ---- -title: Run 1Password Backup -modified: 2026-03-11 -last-reviewed: 2026-03-16 -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 names the file `1PasswordExport-<account-uuid>-<timestamp>.1pux` automatically; don't bother renaming it, pass the path to the task in the next step - -### 2. Run the Backup Task - -Pass the exported file's path: - -```fish -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 -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/how-to/operations/shower-on-ringtail.md b/docs/how-to/operations/shower-on-ringtail.md deleted file mode 100644 index daf1046..0000000 --- a/docs/how-to/operations/shower-on-ringtail.md +++ /dev/null @@ -1,245 +0,0 @@ ---- -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/<token>/`, `/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-<sha>-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/how-to/operations/troubleshoot-sifaka-nfs.md b/docs/how-to/operations/troubleshoot-sifaka-nfs.md deleted file mode 100644 index 85514d4..0000000 --- a/docs/how-to/operations/troubleshoot-sifaka-nfs.md +++ /dev/null @@ -1,73 +0,0 @@ ---- -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/how-to/operations/troubleshooting.md b/docs/how-to/operations/troubleshooting.md deleted file mode 100644 index 84301c3..0000000 --- a/docs/how-to/operations/troubleshooting.md +++ /dev/null @@ -1,265 +0,0 @@ ---- -title: Troubleshooting -modified: 2026-03-16 -last-reviewed: 2026-03-16 -tags: - - how-to - - operations ---- - -# Troubleshooting Common Issues - -Quick reference for diagnosing and fixing common BlumeOps issues. - -## General Health Check - -Run the comprehensive service health check: - -```bash -mise run services-check -``` - -This checks all services on indri and in Kubernetes. - -## Kubernetes Issues (Indri / Minikube) - -Most services run on [[indri]]'s minikube. For [[ringtail]] (k3s) services, see the ringtail section below. - -### Pod not starting - -```bash -# Check pod status -kubectl --context=minikube-indri -n <namespace> get pods - -# Describe pod for events -kubectl --context=minikube-indri -n <namespace> describe pod <pod> - -# Check logs -kubectl --context=minikube-indri -n <namespace> logs <pod> - -# Previous container logs (if restarting) -kubectl --context=minikube-indri -n <namespace> logs <pod> --previous -``` - -Common causes: -- **ImagePullBackOff** - Image doesn't exist or registry unreachable -- **CrashLoopBackOff** - Application crashing; check logs -- **Pending** - Insufficient resources or node issues -- **ContainerCreating** - Waiting for volumes or secrets - -### [[argocd|ArgoCD]] sync issues - -```bash -# Check app status -argocd app get <app> - -# See what will change -argocd app diff <app> - -# Force sync -argocd app sync <app> --force - -# Sync with prune (removes deleted resources) -argocd app sync <app> --prune -``` - -**App stuck in "Syncing":** -Check if there are failed hooks or jobs: -```bash -kubectl --context=minikube-indri -n <namespace> get jobs -kubectl --context=minikube-indri -n <namespace> get pods --field-selector=status.phase=Failed -``` - -**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')" -``` - -### kubectl connection refused - -```bash -# Check if minikube is running (on indri) -ssh indri 'minikube status' - -# Restart if needed -ssh indri 'minikube start' - -# Verify tailscale is serving the API -ssh indri 'tailscale serve status --json' -``` - -## Indri Service Issues - -### Service not responding - -```bash -# Check LaunchAgent status -ssh indri 'launchctl list | grep mcquack' - -# Restart a LaunchAgent -ssh indri 'launchctl unload ~/Library/LaunchAgents/mcquack.<service>.plist' -ssh indri 'launchctl load ~/Library/LaunchAgents/mcquack.<service>.plist' - -# Check service logs -ssh indri 'tail -50 ~/Library/Logs/mcquack.<service>.err.log' -ssh indri 'tail -50 ~/Library/Logs/mcquack.<service>.out.log' -``` - -### [[forgejo|Forgejo]] not accessible - -```bash -# Check if forgejo is running -ssh indri 'lsof -nP -iTCP:3001 -sTCP:LISTEN' - -# Check logs -ssh indri 'tail -50 ~/Library/Logs/mcquack.forgejo.err.log' - -# Restart forgejo -ssh indri 'launchctl kickstart -k gui/$(id -u)/mcquack.forgejo' -``` - -### Registry ([[zot|Zot]]) issues - -```bash -# Test registry API -ssh indri 'curl -s http://localhost:5050/v2/_catalog | jq' - -# Check if zot is running -ssh indri 'lsof -nP -iTCP:5050 -sTCP:LISTEN' - -# Restart zot -ssh indri 'launchctl kickstart -k gui/$(id -u)/mcquack.zot' -``` - -## Network Issues - -### Service unreachable via *.ops.eblu.me - -[[caddy|Caddy]] handles routing for `*.ops.eblu.me`: - -```bash -# Check if Caddy is running -ssh indri 'launchctl list | grep caddy' - -# View Caddy logs -ssh indri 'tail -50 ~/Library/Logs/caddy/access.log' -ssh indri 'tail -50 ~/Library/Logs/caddy/error.log' - -# Restart Caddy -ssh indri 'launchctl kickstart -k gui/$(id -u)/homebrew.mxcl.caddy' -``` - -### Tailscale MagicDNS not resolving - -```bash -# Check tailscale serve status -ssh indri 'tailscale serve status --json' - -# Restart tailscale if needed -ssh indri 'tailscale down && tailscale up' -``` - -## Observability - -### Check metrics - -```bash -# Open [[grafana|Grafana]] -open https://grafana.ops.eblu.me - -# Check [[prometheus|Prometheus]] directly -open https://prometheus.ops.eblu.me -``` - -### Check logs - -```bash -# Open Grafana Explore -open https://grafana.ops.eblu.me/explore - -# Query [[loki|Loki]] directly -curl -G 'https://loki.ops.eblu.me/loki/api/v1/query_range' \ - --data-urlencode 'query={service="<service>"}' \ - --data-urlencode 'limit=100' -``` - -### [[alloy|Alloy]] (metrics/logs collector) issues - -```bash -# Indri alloy (host metrics) -ssh indri 'launchctl list | grep alloy' -ssh indri 'tail -50 ~/Library/Logs/alloy/alloy.log' - -# K8s alloy (pod logs) -kubectl --context=minikube-indri -n monitoring logs -l app=alloy -``` - -## Database Issues - -### [[postgresql|PostgreSQL]] connection failed - -```bash -# Check CNPG cluster status -kubectl --context=minikube-indri -n databases get cluster - -# Check PostgreSQL pods -kubectl --context=minikube-indri -n databases get pods -l cnpg.io/cluster=blumeops-pg - -# Connect to database -kubectl --context=minikube-indri -n databases exec -it blumeops-pg-1 -- psql -U postgres -``` - -## Backup Issues - -### Check [[borgmatic|backup]] status - -```bash -# View latest backup info -ssh indri 'cat /opt/homebrew/var/node_exporter/textfile/borgmatic.prom' - -# Run backup manually -ssh indri 'borgmatic --verbosity 1' - -# Check backup logs -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 <namespace> get pods - -# Describe pod for events -kubectl --context=k3s-ringtail -n <namespace> describe pod <pod> - -# Check logs -kubectl --context=k3s-ringtail -n <namespace> logs <pod> -``` - -### 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 diff --git a/docs/how-to/ringtail/manage-lockfile.md b/docs/how-to/ringtail/manage-lockfile.md deleted file mode 100644 index aae5344..0000000 --- a/docs/how-to/ringtail/manage-lockfile.md +++ /dev/null @@ -1,73 +0,0 @@ ---- -title: Manage Ringtail Lockfile -modified: 2026-03-27 -tags: - - how-to - - ringtail - - nix ---- - -# Manage Ringtail Lockfile - -Two [[dagger]] pipelines manage the ringtail NixOS flake lockfile (`nixos/ringtail/flake.lock`) for different purposes. - -## Update All Inputs - -To pull the latest versions of all flake inputs (equivalent to `nix flake update`): - -```fish -# 1. Update flake.lock -dagger call flake-update --src=. --flake-path=nixos/ringtail \ - export --path=nixos/ringtail/flake.lock - -# 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 -- [[dagger]] — Build engine (provides both pipelines) diff --git a/docs/how-to/ringtail/migrate-wave1-ringtail.md b/docs/how-to/ringtail/migrate-wave1-ringtail.md deleted file mode 100644 index ffb8cdc..0000000 --- a/docs/how-to/ringtail/migrate-wave1-ringtail.md +++ /dev/null @@ -1,176 +0,0 @@ ---- -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 <ns> scale deploy/<app> --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 <appdb> > /tmp/<appdb>.dump - -# restore into the ringtail cluster -kubectl --context=k3s-ringtail -n databases exec -i blumeops-pg-1 -- \ - pg_restore --no-owner --role=<approle> -d <appdb> < /tmp/<appdb>.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 <ns> delete ingress <app>-tailscale -``` - -### 5. Bring up on ringtail - -- Apply the ringtail manifests (new ArgoCD app `<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 `<app>.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/docs/how-to/runbooks/configure-grafana-alerting-pipeline.md b/docs/how-to/runbooks/configure-grafana-alerting-pipeline.md deleted file mode 100644 index eb90128..0000000 --- a/docs/how-to/runbooks/configure-grafana-alerting-pipeline.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -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 deleted file mode 100644 index e02523d..0000000 --- a/docs/how-to/runbooks/deploy-infra-alerting.md +++ /dev/null @@ -1,77 +0,0 @@ ---- -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 deleted file mode 100644 index 6ce13bf..0000000 --- a/docs/how-to/runbooks/first-alert-and-runbook.md +++ /dev/null @@ -1,68 +0,0 @@ ---- -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 deleted file mode 100644 index 4420f58..0000000 --- a/docs/how-to/runbooks/port-services-check-alerts.md +++ /dev/null @@ -1,74 +0,0 @@ ---- -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 deleted file mode 100644 index 244be1f..0000000 --- a/docs/how-to/runbooks/refactor-services-check-to-query-alerts.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -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 deleted file mode 100644 index 753b336..0000000 --- a/docs/how-to/runbooks/runbook-argocd-out-of-sync.md +++ /dev/null @@ -1,65 +0,0 @@ ---- -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 deleted file mode 100644 index ea04e79..0000000 --- a/docs/how-to/runbooks/runbook-frigate-camera-down.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -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 deleted file mode 100644 index 49dd35e..0000000 --- a/docs/how-to/runbooks/runbook-pod-not-ready.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -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 deleted file mode 100644 index 2910851..0000000 --- a/docs/how-to/runbooks/runbook-postgres-unhealthy.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -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 deleted file mode 100644 index 575606e..0000000 --- a/docs/how-to/runbooks/runbook-service-probe-failure.md +++ /dev/null @@ -1,75 +0,0 @@ ---- -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 deleted file mode 100644 index 2a70adf..0000000 --- a/docs/how-to/runbooks/runbook-textfile-stale.md +++ /dev/null @@ -1,58 +0,0 @@ ---- -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/how-to/zot/add-container-version-sync-check.md b/docs/how-to/zot/add-container-version-sync-check.md deleted file mode 100644 index 12d758b..0000000 --- a/docs/how-to/zot/add-container-version-sync-check.md +++ /dev/null @@ -1,78 +0,0 @@ ---- -title: Add Container Version Sync Check -modified: 2026-04-11 -tags: - - how-to - - containers - - ci - - zot ---- - -# Add Container Version Sync Check - -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 - -Discovered during analysis of [[adopt-commit-based-container-tags]]: the new commit-SHA-based image tags need a reliable version source (`vX.Y.Z-<sha>`). Versions are currently scattered across Dockerfile ARGs (varying naming conventions), `service-versions.yaml` entries (many still `null`), and nix derivations (implicit from nixpkgs). A sync check ensures these stay consistent without adding a redundant fourth source. - -## What Was Done - -### 1. Created `mise run container-version-check` task - -A typer-based uv-script that iterates over `containers/*/` and validates six rules per container: - -1. Any `container.py` must declare `VERSION = "<value>"` -2. Any Dockerfile must declare `ARG CONTAINER_APP_VERSION=<value>` -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. - -Blacklisted containers (utility images, not tracked services): `kubectl`. - -Container-to-service name mapping: `quartz` → `docs`, `kiwix-serve` → `kiwix`. - -### 2. Added prek hook - -```yaml -- id: container-version-check - name: container-version-check - entry: mise run container-version-check - language: system - files: ^(containers/|service-versions\.yaml) - pass_filenames: false -``` - -### 3. Populated `service-versions.yaml` - -Filled in `current-version` for all hybrid services: navidrome (v0.60.3), miniflux (2.2.17), teslamate (v2.2.0), transmission (4.0.6-r4), kiwix (3.8.1), forgejo-runner (0.19.11). Added authentik (2025.10.1) as a new hybrid entry. - -### ntfy nix version skew (resolved) - -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 - -| File | Change | -|------|--------| -| `mise-tasks/container-version-check` | New: typer CLI sync validation script | -| `prek.toml` | Add `container-version-check` hook | -| `service-versions.yaml` | Populate `current-version` for all hybrid services + authentik | - -## Verification - -- [x] `mise run container-version-check --all-files` passes with no errors -- [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 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 -- [[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/add-dagger-nix-build.md b/docs/how-to/zot/add-dagger-nix-build.md deleted file mode 100644 index c84661a..0000000 --- a/docs/how-to/zot/add-dagger-nix-build.md +++ /dev/null @@ -1,96 +0,0 @@ ---- -title: Add Dagger Nix Build Function -modified: 2026-04-11 -tags: - - how-to - - containers - - ci - - dagger - - zot ---- - -# Add Dagger Nix Build Function - -Add Dagger functions for building nix container images and extracting version info from nix derivations. This enables local nix container evaluation and provides the version extraction mechanism needed by [[add-container-version-sync-check]]. - -## Context - -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. - -## What to Do - -### 1. Add `build_nix` Dagger function - -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 -async def build_nix( - self, src: dagger.Directory, container_name: str -) -> dagger.File: - """Build a nix container from containers/<name>/default.nix. Returns the image tarball.""" - # Uses NIX_IMAGE (nixos/nix:2.33.3) — already defined in the module - # Runs nix-build inside the container - # Returns the docker-archive tarball -``` - -This mirrors the existing `build` function (Dockerfile) but for nix. The result is a docker-archive tarball that can be loaded with `docker load` or pushed with `skopeo`. - -### 2. Add `nix_version` Dagger function - -A function that extracts the version of a specific nix package from the nixpkgs pin: - -```python -@function -async def nix_version( - self, src: dagger.Directory, package: str -) -> str: - """Extract the version of a nixpkgs package. Returns version string.""" - # nix eval --raw nixpkgs#<package>.version -``` - -This lets the version sync check run `dagger call nix-version --src=. --package=authentik` to get the actual version that would be built. - -### 3. Add `publish_nix` Dagger function (optional) - -If useful, a combined build-and-push that mirrors `publish` but for nix images: - -```python -@function -async def publish_nix( - self, src: dagger.Directory, container_name: str, version: str, - registry: str = "registry.ops.eblu.me", -) -> str: - """Build nix container and push to registry via skopeo.""" -``` - -This would give a `dagger call publish-nix` path parallel to the existing `dagger call publish`. - -## Nix in Dagger - -The `flake_lock` function already demonstrates running nix inside Dagger using `nixos/nix:2.33.3`. The nix build function follows the same pattern but needs: - -- `NIX_PATH` set to resolved nixpkgs (same as the CI workflow does) -- `--extra-experimental-features "nix-command flakes"` for `nix eval` -- The full repo source mounted (nix files may reference other files like `test-connectivity.sh`) - -## Key Files - -| File | Change | -|------|--------| -| `src/blumeops/main.py` | Add `build_nix`, `nix_version`, optionally `publish_nix` | - -## Verification - -- [ ] `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 - -## Related - -- [[add-container-version-sync-check]] — Parent: needs nix version extraction for sync check -- [[adopt-commit-based-container-tags]] — Grandparent goal -- [[dagger]] — Dagger reference diff --git a/docs/how-to/zot/adopt-commit-based-container-tags.md b/docs/how-to/zot/adopt-commit-based-container-tags.md deleted file mode 100644 index 8344ce6..0000000 --- a/docs/how-to/zot/adopt-commit-based-container-tags.md +++ /dev/null @@ -1,80 +0,0 @@ ---- -title: Adopt Commit-Based Container Tags -modified: 2026-04-11 -tags: - - how-to - - containers - - ci - - zot ---- - -# Adopt Commit-Based Container Tags - -Replace the current git-tag-triggered container build system with path-based triggers and commit-SHA-based image tags, so that container versions reflect the actual bundled app version and are traceable to exact source commits. - -## Context - -Currently, container builds trigger on git tags matching `<container>-vX.Y.Z`. The version is chosen arbitrarily at release time and is not connected to the upstream app version bundled in the image. This creates several problems: - -- **Version opacity** — `v1.0.0` of a container tells you nothing about which upstream version it bundles -- **Manual release step** — `mise run container-tag-and-release` must be run by hand for every build -- **No automatic rebuilds** — changes to a container's build files don't trigger builds unless someone creates a tag -- **Mutable risk** — version tags can be re-pushed (addressed separately by [[enforce-tag-immutability]], but commit SHAs are inherently unique) - -## New Scheme - -### Triggers - -All container builds are triggered manually via `mise run container-build-and-release <name>` (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` - -The workflow classifies the container by build type and routes to the correct runner. - -### Version Source - -Each container's version is extracted at build time from existing declarations — no separate VERSION file: - -- **Dockerfile builds**: parsed from `ARG CONTAINER_APP_VERSION=<value>` in the Dockerfile -- **Nix builds**: extracted from `version = "..."` in `default.nix`, or `CONTAINER_APP_VERSION` from the Dockerfile, or `dagger call nix-version` for nixpkgs packages - -The [[add-container-version-sync-check]] prek check ensures these declarations stay in sync with `service-versions.yaml`. See [[pin-container-versions]] for the work to ensure every container has a parseable version. - -### Image Tag Format - -| Build type | Tag format | Example | -|------------|-----------|---------| -| Dockerfile | `vX.Y.Z-<sha>` | `v2.2.17-abc1234` | -| Nix | `vX.Y.Z-<sha>-nix` | `v2.17.0-abc1234-nix` | - -Where: -- `X.Y.Z` is the version of the most relevant bundled app (e.g., miniflux `2.2.17`, navidrome `0.60.3`) -- `<sha>` is the 7-char short commit SHA of the source tree used for the build - -### What This Replaces - -- The `container-tag-and-release` mise task is **replaced** by `container-build-and-release` — it triggers a manual workflow dispatch instead of creating git tags -- Git tags of the form `<container>-vX.Y.Z` are no longer used to trigger builds -- The `container-list` mise task displays the new tag format - -## Key Files - -| File | Change | -|------|--------| -| `.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 | -| `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 | - -## Interaction With Other Prereqs - -- **[[enforce-tag-immutability]]** — Commit SHA tags are inherently unique, reducing the scope of immutability enforcement -- **[[wire-ci-registry-auth]]** — Auth changes apply regardless of tagging scheme; no conflict - -## Related - -- [[harden-zot-registry]] — Parent goal -- [[enforce-tag-immutability]] — Complementary prereq (scope may narrow) -- [[build-container-image]] — How-to doc to update diff --git a/docs/how-to/zot/enforce-tag-immutability.md b/docs/how-to/zot/enforce-tag-immutability.md deleted file mode 100644 index c6f27d2..0000000 --- a/docs/how-to/zot/enforce-tag-immutability.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -title: Enforce Tag Immutability -modified: 2026-02-21 -last-reviewed: 2026-04-14 -tags: - - how-to - - zot - - ci ---- - -# Enforce Tag Immutability - -Prevent accidental overwrite of version tags during CI push. - -## Resolution - -Tag immutability is enforced server-side via `accessControl` policies in [[harden-zot-registry]], not by client-side push checks. The three-tier access model makes push-side enforcement unnecessary: - -- **Anonymous:** `["read"]` — pull only, no push at all -- **`artifact-workloads` group (CI):** `["read", "create"]` — can push new tags but cannot overwrite or delete existing ones -- **Admins:** `["read", "create", "update", "delete"]` — break-glass for removing bad images - -Since CI only has `create` (not `update`), pushing an existing version tag is rejected by zot itself. Commit SHA tags are inherently unique and never collide. - -This approach requires authentication to be meaningful — without auth, everyone is anonymous. The requirements are therefore part of the root [[harden-zot-registry]] goal's `accessControl` configuration. - -## Related - -- [[harden-zot-registry]] — Parent goal (includes this requirement) -- [[zot]] — Zot registry service reference diff --git a/docs/how-to/zot/harden-zot-registry.md b/docs/how-to/zot/harden-zot-registry.md deleted file mode 100644 index d74a5d0..0000000 --- a/docs/how-to/zot/harden-zot-registry.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -title: Harden Zot Registry -modified: 2026-04-11 -tags: - - how-to - - zot - - registry - - security ---- - -# Harden Zot Registry - -OIDC + API key authentication on zot with anonymous pull preserved, and tag immutability enforced server-side via accessControl. Completed as a C2 Mikado goal across PRs #236 and #237. - -## What Was Done - -Updated `ansible/roles/zot/templates/config.json.j2` with: - -1. **`http.auth.openid`** — OIDC provider pointing to Authentik (`authentik.ops.eblu.me`) -2. **`http.auth.apikey: true`** — API key generation for CI service accounts -3. **`http.accessControl`** — three-tier policy: - - `anonymousPolicy: ["read"]` — anyone can pull - - `artifact-workloads` group: `["read", "create"]` — CI can push new tags but cannot overwrite or delete (immutable tags) - - `admins` group: `["read", "create", "update", "delete"]` — break-glass -4. **`http.externalUrl`** — `https://registry.ops.eblu.me` for OIDC callback redirects -5. **`accessControl.metrics.users: [""]`** — allows anonymous Prometheus/Alloy scraping - -## Key Files - -| File | Purpose | -|------|---------| -| `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 | -| `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 | - -## Verified - -- [x] Anonymous pull works (pull-through cache on gilbert) -- [x] Unauthenticated push fails (401) -- [x] OIDC browser login works (redirect to Authentik and back) -- [x] API key push works (zot-ci API key) -- [x] CI push succeeds (Dagger and Nix/skopeo paths) -- [x] Pull-through caching still works -- [x] Metrics endpoint accessible without auth -- [x] `mise run services-check` passes - -## Related - -- [[register-zot-oidc-client]] — OIDC client registration in Authentik -- [[wire-ci-registry-auth]] — CI push path wiring -- [[enforce-tag-immutability]] — Server-side via accessControl -- [[adopt-commit-based-container-tags]] — Commit-SHA-based image tags diff --git a/docs/how-to/zot/pin-container-versions.md b/docs/how-to/zot/pin-container-versions.md deleted file mode 100644 index b592728..0000000 --- a/docs/how-to/zot/pin-container-versions.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -title: Pin Container Versions -modified: 2026-04-11 -tags: - - how-to - - containers - - ci - - zot ---- - -# Pin Container Versions - -Ensure every container has an explicit, parseable version declaration so that [[add-container-version-sync-check]] has something to validate against. - -## Context - -Discovered during analysis of [[adopt-commit-based-container-tags]]: containers needed a uniform, parseable version declaration for the sync check. Most containers already had version ARGs (miniflux, navidrome, ntfy, etc.), but with inconsistent naming (`NAVIDROME_VERSION`, `MINIFLUX_VERSION`, etc.), and several containers (devpi, cv, quartz, nettest) had none. - -## What Was Done - -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) -- **quartz**: `CONTAINER_APP_VERSION=1.28.2` (pinned nginx:1.28.2-alpine base) -- **All others**: Existing versions carried forward with new uniform ARG pattern - -## Key Files - -| File | Change | -|------|--------| -| `containers/*/Dockerfile` | Add `ARG CONTAINER_APP_VERSION` to all 13 containers | -| `service-versions.yaml` | Populate `current-version` for devpi, cv, docs | - -## Verification - -- [x] Every container Dockerfile has `ARG CONTAINER_APP_VERSION=X.Y.Z` -- [x] ARG chaining tested with Docker build (nginx:1.28.2-alpine) -- [x] devpi container pins pip package versions -- [x] cv version matches Forgejo package release (1.0.3) -- [x] quartz pins nginx base image to stable (1.28.2) - -## Related - -- [[add-container-version-sync-check]] — Parent: needs parseable versions for sync check -- [[adopt-commit-based-container-tags]] — Grandparent goal diff --git a/docs/how-to/zot/register-zot-oidc-client.md b/docs/how-to/zot/register-zot-oidc-client.md deleted file mode 100644 index 744b81e..0000000 --- a/docs/how-to/zot/register-zot-oidc-client.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -title: Register Zot OIDC Client -modified: 2026-02-21 -last-reviewed: 2026-04-20 -tags: - - how-to - - zot - - authentik - - oidc ---- - -# Register Zot OIDC Client - -Register a zot OAuth2 provider and application in Authentik via blueprint, following the same pattern as Grafana and Forgejo. - -Completed in PR [#236](https://forge.eblu.me/eblume/blumeops/pulls/236). - -## What Was Done - -1. **Added `zot.yaml` blueprint section** to `argocd/manifests/authentik/configmap-blueprint.yaml`: - - OAuth2Provider (`client_id: zot`), Application, PolicyBinding (admins group), `artifact-workloads` group, and `zot-ci` service account -2. **Client secret** stored in 1Password as field `zot-client-secret` on the "Authentik (blumeops)" item (referenced by item ID `oor7os5kapczgpbwv7obkca4y4` to avoid parentheses in `op read`) -3. **ExternalSecret** wired `zot-client-secret` → worker Deployment env var `AUTHENTIK_ZOT_CLIENT_SECRET` → blueprint `!Env` -4. **OIDC credentials template** (`ansible/roles/zot/templates/oidc-credentials.json.j2`) deployed by zot role with a `when` guard; pre_task in `ansible/playbooks/indri.yml` fetches the secret from 1Password - -### Deviations from Original Plan - -- Worker Deployment env var injection was an additional wiring step not originally listed -- Service account password and API keys are manual post-deploy steps (not automated in the blueprint) - -## Key Files - -| File | Purpose | -|------|---------| -| `argocd/manifests/authentik/configmap-blueprint.yaml` | Zot blueprint (provider + app + policy + group + service account) | -| `argocd/manifests/authentik/external-secret.yaml` | `AUTHENTIK_ZOT_CLIENT_SECRET` env var | -| `argocd/manifests/authentik/deployment-worker.yaml` | Env var injection for blueprint `!Env` | -| `ansible/roles/zot/templates/oidc-credentials.json.j2` | OIDC credentials for zot | -| `ansible/playbooks/indri.yml` | Pre_task for zot OIDC client secret | - -## Related - -- [[harden-zot-registry]] — Parent goal -- [[deploy-authentik]] — Authentik deployment (completed) diff --git a/docs/how-to/zot/wire-ci-registry-auth.md b/docs/how-to/zot/wire-ci-registry-auth.md deleted file mode 100644 index e2507c9..0000000 --- a/docs/how-to/zot/wire-ci-registry-auth.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -title: Wire CI Registry Auth -modified: 2026-04-11 -tags: - - how-to - - zot - - ci - - forgejo ---- - -# Wire CI Registry Auth - -How CI pipelines authenticate to the zot registry after OIDC + apikey auth is enabled. - -## Overview - -The `zot-ci` service account (created in [[register-zot-oidc-client]]) belongs to the `artifact-workloads` group, granting `["read", "create"]` permissions — CI can push new tags but cannot overwrite or delete existing ones. - -Authentication uses a zot API key generated after the service account's first OIDC login. The key is stored in 1Password (`Forgejo Secrets` item, field `zot-ci-api`, in blumeops vault) and synced to Forgejo Actions secrets via the `forgejo_actions_secrets` ansible role. The key expires every 90 days — see [[zot#API Key Rotation]] for the rotation procedure. - -## Push Paths - -### Dagger path (Dockerfile containers) - -`.forgejo/workflows/build-container.yaml` passes `--registry-password=env:ZOT_CI_API_KEY` to the Dagger `publish()` function, which calls `with_registry_auth()` before pushing. - -### Nix/skopeo path (Nix containers) - -`.forgejo/workflows/build-container-nix.yaml` passes `--dest-creds=zot-ci:$ZOT_CI_API_KEY` to `skopeo copy`. - -## Secret Flow - -1Password `Forgejo Secrets` item (field `zot-ci-api`) → ansible pre_task fetches it → `forgejo_actions_secrets` role syncs to Forgejo API → both runners (k8s on indri, host on ringtail) access it as `${{ secrets.ZOT_CI_API_KEY }}`. - -## Key Files - -| File | Purpose | -|------|---------| -| `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 | -| `ansible/roles/forgejo_actions_secrets/defaults/main.yml` | Secret entry for `ZOT_CI_API_KEY` | - -## Related - -- [[harden-zot-registry]] — Parent goal -- [[register-zot-oidc-client]] — OIDC client registration diff --git a/docs/index.md b/docs/index.md deleted file mode 100644 index fb04c47..0000000 --- a/docs/index.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -title: BlumeOps -modified: 2026-05-06 -last-reviewed: 2026-05-06 -aliases: [] -id: index -tags: [] ---- - -Welcome to the BlumeOps (aka "Blue Mops") documentation. Here you will find -hopefully everything you'll need to understand and operate my personal digital -infrastructure. - -**New here?** Start with [[exploring-the-docs]] to find your way around. - -## What is BlumeOps? - -BlumeOps is my personal homelab infrastructure managed entirely through code. -Everything lives in a [single git repository](https://github.com/eblume/blumeops), from service configs to -deployment automation. Even the [[forgejo]] instance that [hosts this repo](https://forge.eblu.me/eblume/blumeops) -is defined within it, making BlumeOps fully self-hosting. It's a digital life -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]], 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). - -The goal of BlumeOps is threefold: - -1. To provide a rich array of useful personal services in order to manage my - own digital life. -2. To exercise my skills as a software engineer specializing in - Platforms/DevOps/SRE. -3. To act as a portfolio piece for talking about building hosted software - platforms. - -## 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 -- [[CHANGELOG]] - Release history and changes diff --git a/docs/quartz.config.ts b/docs/quartz.config.ts deleted file mode 100644 index 51743a5..0000000 --- a/docs/quartz.config.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { QuartzConfig } from "./quartz/cfg" -import * as Plugin from "./quartz/plugins" - -/** - * Quartz configuration for BlumeOps documentation - * See https://quartz.jzhao.xyz/configuration - */ -const config: QuartzConfig = { - configuration: { - pageTitle: "BlumeOps Docs", - pageTitleSuffix: "", - enableSPA: false, - enablePopovers: true, - analytics: null, - locale: "en-US", - baseUrl: "docs.ops.eblu.me", - ignorePatterns: ["private", "templates", ".obsidian"], - defaultDateType: "modified", - theme: { - fontOrigin: "googleFonts", - cdnCaching: true, - typography: { - header: "Schibsted Grotesk", - body: "Source Sans Pro", - code: "IBM Plex Mono", - }, - colors: { - lightMode: { - light: "#faf8f8", - lightgray: "#e5e5e5", - gray: "#b8b8b8", - darkgray: "#4e4e4e", - dark: "#2b2b2b", - secondary: "#284b63", - tertiary: "#84a59d", - highlight: "rgba(143, 159, 169, 0.15)", - textHighlight: "#fff23688", - }, - darkMode: { - light: "#161618", - lightgray: "#393639", - gray: "#646464", - darkgray: "#d4d4d4", - dark: "#ebebec", - secondary: "#7b97aa", - tertiary: "#84a59d", - highlight: "rgba(143, 159, 169, 0.15)", - textHighlight: "#fff23688", - }, - }, - }, - }, - plugins: { - transformers: [ - Plugin.FrontMatter(), - Plugin.CreatedModifiedDate({ - priority: ["frontmatter", "git", "filesystem"], - }), - Plugin.SyntaxHighlighting({ - theme: { - light: "github-light", - dark: "github-dark", - }, - keepBackground: false, - }), - Plugin.ObsidianFlavoredMarkdown({ enableInHtmlEmbed: false }), - Plugin.GitHubFlavoredMarkdown(), - Plugin.TableOfContents(), - Plugin.CrawlLinks({ markdownLinkResolution: "shortest" }), - Plugin.Description(), - Plugin.Latex({ renderEngine: "katex" }), - ], - filters: [Plugin.RemoveDrafts()], - emitters: [ - Plugin.AliasRedirects(), - Plugin.ComponentResources(), - Plugin.ContentPage(), - Plugin.FolderPage(), - Plugin.TagPage(), - Plugin.ContentIndex({ - enableSiteMap: true, - enableRSS: true, - }), - Plugin.Assets(), - Plugin.Static(), - Plugin.NotFoundPage(), - ], - }, -} - -export default config diff --git a/docs/quartz.layout.ts b/docs/quartz.layout.ts deleted file mode 100644 index cba29ec..0000000 --- a/docs/quartz.layout.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { PageLayout, SharedLayout } from "./quartz/cfg" -import * as Component from "./quartz/components" - -/** - * Quartz layout configuration for BlumeOps documentation - * See https://quartz.jzhao.xyz/layout - */ - -// Components shared across all pages -export const sharedPageComponents: SharedLayout = { - head: Component.Head(), - header: [], - afterBody: [], - footer: Component.Footer({ - links: { - "GitHub": "https://github.com/eblume/blumeops", - "Forge": "https://forge.eblu.me/eblume/blumeops", - }, - }), -} - -// Components for pages that list posts (folder pages, tag pages) -export const defaultContentPageLayout: PageLayout = { - beforeBody: [ - Component.Breadcrumbs(), - Component.ArticleTitle(), - Component.ContentMeta(), - Component.TagList(), - ], - left: [ - Component.PageTitle(), - Component.MobileOnly(Component.Spacer()), - Component.Search(), - Component.Darkmode(), - Component.DesktopOnly(Component.Explorer()), - ], - right: [ - Component.Graph(), - Component.DesktopOnly(Component.TableOfContents()), - Component.Backlinks(), - ], -} - -export const defaultListPageLayout: PageLayout = { - beforeBody: [Component.Breadcrumbs(), Component.ArticleTitle(), Component.ContentMeta()], - left: [ - Component.PageTitle(), - Component.MobileOnly(Component.Spacer()), - Component.Search(), - Component.Darkmode(), - Component.DesktopOnly(Component.Explorer()), - ], - right: [], -} diff --git a/docs/reference/infrastructure/gandi.md b/docs/reference/infrastructure/gandi.md deleted file mode 100644 index 763bae3..0000000 --- a/docs/reference/infrastructure/gandi.md +++ /dev/null @@ -1,69 +0,0 @@ ---- -title: Gandi -modified: 2026-04-27 -last-reviewed: 2026-04-27 -tags: - - infrastructure - - networking - - dns ---- - -# Gandi - -DNS hosting provider for the `eblu.me` domain, managed via Pulumi IaC. - -## Quick Reference - -| Property | Value | -|----------|-------| -| **Domain** | `eblu.me` | -| **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. 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 - -### Private services (Caddy on indri) - -| Record | Type | Value | TTL | -|--------|------|-------|-----| -| `*.ops.eblu.me` | A | indri's Tailscale IP | 300s | -| `ops.eblu.me` | A | indri's Tailscale IP | 300s | - -Both records point to [[indri]], which runs [[caddy]] as the reverse proxy for all private services. - -### Public services (Fly.io proxy) - -| Record | Type | Value | TTL | -|--------|------|-------|-----| -| `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. See [[routing]] for the full service URL map. - -## TLS Integration - -[[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 - -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 - -- [[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/infrastructure/gilbert.md b/docs/reference/infrastructure/gilbert.md deleted file mode 100644 index e4ef584..0000000 --- a/docs/reference/infrastructure/gilbert.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -title: Gilbert -modified: 2026-02-07 -last-reviewed: 2026-03-17 -tags: - - infrastructure - - host ---- - -# Gilbert - -Primary development workstation. - -## Specifications - -| Property | Value | -|----------|-------| -| **Model** | 13" MacBook Air M4, 2025 | -| **User** | eblume | -| **Role** | Development workstation | - -## Development Tools - -Managed via `Brewfile` and `mise.toml` in the blumeops repo. - -## Related - -- [[indri]] - Server accessed from gilbert -- [[cluster|Cluster]] - Remote k8s access diff --git a/docs/reference/infrastructure/hosts.md b/docs/reference/infrastructure/hosts.md deleted file mode 100644 index 439edf7..0000000 --- a/docs/reference/infrastructure/hosts.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -title: Hosts -modified: 2026-04-11 -last-reviewed: 2026-04-11 -tags: - - reference - - infrastructure ---- - -# Host Inventory - -All devices connected via [Tailscale](https://login.tailscale.com/) tailnet `tail8d86e.ts.net`. - -## Devices - -| Host | Description | Card | -|------|-------------|------| -| **[[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]]** | UniFi Express 7 - Home WiFi | [[unifi|Details]] | -| **Dwarf** | iPad Air - Employer-provided, off tailnet | - | - -## Related - -- [[tailscale]] - Network configuration -- [[routing|Routing]] - Service URLs diff --git a/docs/reference/infrastructure/indri.md b/docs/reference/infrastructure/indri.md deleted file mode 100644 index 8364ba0..0000000 --- a/docs/reference/infrastructure/indri.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -title: Indri -modified: 2026-05-27 -last-reviewed: 2026-05-27 -tags: - - infrastructure - - host ---- - -# Indri - -Primary BlumeOps server. Mac Mini M1 (2020). - -## Specifications - -| 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` | -| **Tailscale Tag** | `tag:homelab` | -| **Power** | [[power|Battery-backed UPS]] | - -## Services Hosted - -**Native (via Ansible):** -- [[forgejo]] - Git forge -- [[zot]] - Container registry -- [[jellyfin]] - Media server -- [[borgmatic]] - Backup system -- [[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 - -**Kubernetes (via minikube):** -- [[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 -- Amphetamine - Prevents sleep -- [[automounter]] - Mounts [[sifaka]] SMB shares - -## Maintenance Notes - -**Sleep prevention:** Uses Amphetamine (App Store) to prevent sleep. If Amphetamine crashes after extended uptime, consider switching to `pmset` or `caffeinate` via ansible. - -**Passwordless sudo:** Configured for `erichblume` user (`/etc/sudoers.d/erichblume`) to allow ansible `become: true` without prompts. Acceptable given Tailscale is the trust boundary. - -## Related - -- [[routing]] - Port mappings -- [[cluster]] - Minikube details -- [[automounter]] - SMB share mounting -- [[restart-indri]] - Shutdown and startup procedure diff --git a/docs/reference/infrastructure/power.md b/docs/reference/infrastructure/power.md deleted file mode 100644 index 33b000b..0000000 --- a/docs/reference/infrastructure/power.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -title: Power -modified: 2026-02-09 -last-reviewed: 2026-03-18 -tags: - - infrastructure - - reference ---- - -# Power Infrastructure - -The homelab runs on battery-backed power to survive grid outages. - -## Power Chain - -``` -AC Grid (120V) → Anker SOLIX F2000 → CyberPower CP1000PFCLCD → Homelab -``` - -| Stage | Device | Notes | -|-------|--------|-------| -| **Grid** | 120V AC mains | Charges the battery station | -| **Battery** | Anker SOLIX F2000 GaNPrime | 2048Wh portable power station | -| **UPS** | CyberPower CP1000PFCLCD | 1000VA / 600W, pure sine wave output | - -## Devices on UPS - -| Device | Role | -|--------|------| -| [[indri]] | Primary server | -| [[ringtail]] | GPU compute / gaming PC | -| [[sifaka]] | NAS | -| UniFi Express 7 | WiFi router | -| Starlink | Satellite internet uplink | - -## Related - -- [[hosts]] - Device inventory -- [[restart-indri]] - Shutdown and startup procedure diff --git a/docs/reference/infrastructure/ringtail.md b/docs/reference/infrastructure/ringtail.md deleted file mode 100644 index a4e6837..0000000 --- a/docs/reference/infrastructure/ringtail.md +++ /dev/null @@ -1,141 +0,0 @@ ---- -title: Ringtail -modified: 2026-02-22 -tags: - - infrastructure - - host ---- - -# Ringtail - -Service host and gaming PC. Custom-built PC running NixOS. - -## Specifications - -| Property | Value | -|----------|-------| -| **Motherboard** | ASUS ROG Crosshair VI Hero (Wi-Fi AC) | -| **CPU** | AMD Ryzen 7 1700X (8-core/16-thread, 3.4 GHz) | -| **RAM** | 32 GB DDR4 (4x8 GB Corsair Vengeance CMK16GX4M2B3200C16, 3200 MT/s DOCP) | -| **GPU** | NVIDIA GeForce RTX 4080 (AD103, 16 GB VRAM) | -| **Monitor** | HP OMEN 27i IPS (2560x1440, 165 Hz, DisplayPort) | -| **Storage (boot)** | Samsung 970 PRO 1TB NVMe | -| **Storage (SATA)** | Samsung 850 EVO 1TB (`/mnt/games`), 850 EVO 500GB (`/mnt/storage1`), 840 PRO 120GB (`/mnt/storage2`) | -| **Peripherals** | Das Keyboard 4, Logitech MX Master 3, 8BitDo Ultimate 2 controller | -| **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. - -- **Desktop:** Sway (Wayland, Catppuccin Macchiato theme) with waybar and wezterm -- **Browser:** LibreWolf -- **Gaming:** Steam (library on `/mnt/games`), 8BitDo controller via Steam Input -- **Audio:** Edifier R1280DBs (Bluetooth), PipeWire -- **Secrets:** 1Password CLI + GUI (NixOS modules for polkit/setgid integration) -- **Runtimes:** mise manages Node, Python, Rust, .NET; nix-ld enables dynamically linked binaries -- **Dotfiles:** `chezmoi init eblume && chezmoi apply` - -## Deployment - -```fish -mise run provision-ringtail -``` - -This locks new flake inputs via Dagger, verifies the current commit is pushed to forge, then deploys the exact commit via ansible. If the lockfile changed, it stages the file and exits so you can commit and re-run. To update all inputs to latest versions, see [[manage-lockfile]]. - -## K3s Cluster - -Ringtail runs a single-node k3s cluster for native amd64 workloads, registered in [[argocd|ArgoCD]] on indri as `k3s-ringtail`. - -- **Disabled components:** Traefik, ServiceLB, metrics-server (minimal footprint) -- **TLS SAN:** `ringtail.tail8d86e.ts.net` (ArgoCD connects via Tailscale) -- **Registry mirrors:** Containerd pulls through Zot on indri (`registry.ops.eblu.me`) -- **Token:** `/etc/k3s/token` (generated on first provision) -- **Kubeconfig:** `/etc/rancher/k3s/k3s.yaml` (world-readable via `--write-kubeconfig-mode=644`) - -### Secrets Management - -1Password Connect + External Secrets Operator syncs secrets from 1Password to k8s, matching the [[1password|indri pattern]]. Bootstrap credentials (`op-credentials`, `onepassword-token`) are provisioned by Ansible; ArgoCD manages the operator stack. - -Sync order: `1password-connect-ringtail` -> `external-secrets-crds-ringtail` -> `external-secrets-ringtail` -> `external-secrets-config-ringtail` - -### Workloads - -| Workload | Namespace | Notes | -|----------|-----------|-------| -| [[frigate]] | `frigate` | NVR with GPU-accelerated detection (RTX 4080) | -| [[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) | -| nvidia-device-plugin | `nvidia-device-plugin` | Exposes GPU to pods via CDI + nvidia RuntimeClass | - -### Manual Cluster Registration - -After first provision, register the cluster in ArgoCD: - -```fish -ssh ringtail 'sudo cat /etc/rancher/k3s/k3s.yaml' | \ - sed 's|127.0.0.1|ringtail.tail8d86e.ts.net|' > /tmp/k3s-ringtail.yaml -set -x KUBECONFIG /tmp/k3s-ringtail.yaml -kubectl get nodes # verify access -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`. - -| Property | Value | -|----------|-------| -| **Label** | `nix-container-builder` | -| **Execution** | Host (no containers) | -| **Token** | `/etc/forgejo-runner/token.env` (provisioned by Ansible) | -| **Service unit** | `gitea-runner-nix_container_builder.service` | - -The runner resolves `<nixpkgs>` 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. - -**NVIDIA:** Proprietary drivers. Sway launched with `--unsupported-gpu` via greetd. - -**No TPM:** `systemd.tpm2.enable = false` prevents 90s boot delay. - -**RAM speed:** Running at 3200 MT/s via DOCP 1 (BIOS 8902+). - -## Related - -- [[hosts]] - Device inventory -- [[tailscale]] - Network configuration diff --git a/docs/reference/infrastructure/routing.md b/docs/reference/infrastructure/routing.md deleted file mode 100644 index 6708a92..0000000 --- a/docs/reference/infrastructure/routing.md +++ /dev/null @@ -1,84 +0,0 @@ ---- -title: Routing -modified: 2026-04-18 -tags: - - infrastructure - - networking ---- - -# Service Routing - -Services are accessible via three DNS domains with different reachability. - -## DNS Domains - -| Domain | Proxy | Reachable From | -|--------|-------|----------------| -| `*.eblu.me` | [[flyio-proxy]] (Fly.io → Tailscale tunnel) | Public internet | -| `*.ops.eblu.me` | Caddy on indri | k8s pods, docker containers, tailnet clients | -| `*.tail8d86e.ts.net` | Tailscale MagicDNS | Tailnet clients only | - -**Use `*.ops.eblu.me`** for services that need pod-to-service communication. Use `*.eblu.me` for services exposed publicly via Fly.io. - -## Caddy Services (`*.ops.eblu.me`) - -DNS points to [[indri]]'s Tailscale IP. TLS via Let's Encrypt (ACME DNS-01 with Gandi). - -| Service | URL | Description | -|---------|-----|-------------| -| Homepage | https://go.ops.eblu.me | Service dashboard | -| [[forgejo]] | https://forge.ops.eblu.me | Git hosting (SSH: 2222) | -| [[zot]] | https://registry.ops.eblu.me | Container registry | -| [[grafana]] | https://grafana.ops.eblu.me | Dashboards | -| [[argocd]] | https://argocd.ops.eblu.me | GitOps CD | -| [[prometheus]] | https://prometheus.ops.eblu.me | Metrics | -| [[loki]] | https://loki.ops.eblu.me | Logs | -| [[miniflux]] | https://feed.ops.eblu.me | RSS reader | -| [[kiwix]] | https://kiwix.ops.eblu.me | Offline Wikipedia | -| [[transmission]] | https://torrent.ops.eblu.me | BitTorrent | -| [[teslamate]] | https://tesla.ops.eblu.me | Tesla logger | -| [[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 | -| [[paperless]] | https://paperless.ops.eblu.me | Document management | -| [[sifaka|Sifaka]] | https://nas.ops.eblu.me | NAS dashboard | - -## Public Services (`*.eblu.me`) - -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 | -|---------|-----|-------------| -| [[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 - -| Service | URL | Description | -|---------|-----|-------------| -| Kubernetes | https://k8s.tail8d86e.ts.net | Minikube API | - -## Port Map (Indri) - -| Port | Service | Protocol | Binding | Notes | -|------|---------|----------|---------|-------| -| 443 | Caddy | HTTPS | 0.0.0.0 | Reverse proxy | -| 2222 | Caddy L4 | TCP | 0.0.0.0 | SSH proxy to Forgejo | -| 5432 | Caddy L4 | TCP | 0.0.0.0 | PostgreSQL proxy | -| 9100 | Caddy L4 | TCP | 0.0.0.0 | Sifaka node_exporter proxy | -| 9633 | Caddy L4 | TCP | 0.0.0.0 | Sifaka smartctl_exporter proxy | -| 2200 | Forgejo SSH | TCP | localhost | Built-in SSH server | -| 3001 | Forgejo | HTTP | localhost | Web UI | -| 5050 | Zot | HTTP | localhost | Registry API | -| 8096 | Jellyfin | HTTP | localhost | Media server | -| 44491 | K8s API | HTTPS | 0.0.0.0 | Minikube API server | - -## Related - -- [[gandi]] - DNS hosting for `eblu.me` -- [[tailscale]] - ACL configuration -- [[indri]] - Where services run -- [[flyio-proxy]] - Public reverse proxy for `*.eblu.me` -- [[expose-service-publicly]] - How to add a new public service diff --git a/docs/reference/infrastructure/tailscale.md b/docs/reference/infrastructure/tailscale.md deleted file mode 100644 index 9c15d83..0000000 --- a/docs/reference/infrastructure/tailscale.md +++ /dev/null @@ -1,100 +0,0 @@ ---- -title: Tailscale -modified: 2026-04-18 -last-reviewed: 2026-04-18 -tags: - - infrastructure - - networking ---- - -# Tailscale - -Tailnet `tail8d86e.ts.net` provides secure networking for all BlumeOps infrastructure. - -## ACL Management - -ACLs managed via Pulumi in `pulumi/tailscale/policy.hujson`. - -## Groups - -| Group | Members | Purpose | -|-------|---------|---------| -| `group:allisonflix` | admin, member | [[jellyfin]] media access | - -## Device Tags - -| Tag | Devices | Purpose | -|-----|---------|---------| -| `tag:homelab` | indri, ringtail | Server infrastructure | -| `tag:nas` | sifaka | Network-attached storage | -| `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: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) | - -**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 | DevPI | Miniflux | PostgreSQL | NAS | Grafana | Loki | -|--------|-------|-------|-------|----------|------------|-----|---------|------| -| `autogroup:admin` | Y | Y | Y | Y | Y | Y | Y | 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** — 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 - -| Source | Destinations | Auth | -|--------|--------------|------| -| `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 - -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 <host>` — it should say `via <ip>:<port>`, not `via DERP(<region>)`. - -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 -- [[hosts|Hosts]] - Device inventory diff --git a/docs/reference/infrastructure/unifi.md b/docs/reference/infrastructure/unifi.md deleted file mode 100644 index 6182880..0000000 --- a/docs/reference/infrastructure/unifi.md +++ /dev/null @@ -1,85 +0,0 @@ ---- -title: UniFi -modified: 2026-03-16 -tags: - - infrastructure - - networking ---- - -# UniFi - -Home WiFi router and network controller, managed via the UX7 web UI. - -## Quick Reference - -| Property | Value | -|----------|-------| -| **Model** | UniFi Express 7 (UX7) | -| **LAN IP** | `192.168.1.1` | -| **Management URL** | `https://192.168.1.1` | -| **Management** | Web UI only (no IaC) | -| **Power** | Battery-backed via UPS (see [[power]]) | - -## What It Does - -The UX7 is the home WiFi access point and network gateway. It provides: - -- WiFi (main, guest, IoT networks) -- DHCP for all network subnets -- Built-in UniFi controller for managing adopted devices (switches) -- Zone-based firewall and traffic management - -## Networks - -| Network | VLAN | Subnet | Purpose | -|---------|------|--------|---------| -| Main | 1 (default) | 192.168.1.0/24 | Trusted devices (indri, sifaka, gilbert, mouse) | -| Guest | 2 | 192.168.2.0/24 | Visitors, internet-only | -| IoT | 3 | 192.168.3.0/24 | Smart devices (Frame TV, appliances) | - -Three-network segmentation configured manually via UX7 web UI (Feb 2026). - -## Network Topology - -``` -ISP Modem - └── UniFi Express 7 [WAN] - └── [LAN port] ──→ Switch A (by router/sifaka) - ├── sifaka (Synology NAS) - └── ~12ft Cat6 ──→ Switch B (on desk) - ├── indri (Mac Mini, primary server) - └── gilbert (USB-C adapter) -``` - -All wired devices share the default VLAN (192.168.1.0/24). The two daisy-chained UniFi Switch Flex Minis provide enough ports for all devices while using the UX7's single LAN port. - -## Operations - -| Task | Method | -|------|--------| -| Manage networks/WiFi/firewall | `https://192.168.1.1` web UI | -| Backup configuration | Settings → System → Backup | -| Restore from backup | Settings → System → Backup → Restore | - -## Authentication - -Local admin account on the UX7. Credentials stored in 1Password (vault `blumeops`). WiFi passphrase stored in 1Password item "Radio New Vegas" (Wireless Router type) in vault `blumeops`. - -## Why Not IaC? - -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 -- [[power]] — UPS power chain -- [[indri]] — Primary server (wired connection) -- [[tailscale]] — Tailnet networking diff --git a/docs/reference/kubernetes/apps.md b/docs/reference/kubernetes/apps.md deleted file mode 100644 index fd5c06f..0000000 --- a/docs/reference/kubernetes/apps.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -title: Apps -modified: 2026-03-04 -tags: - - kubernetes - - argocd ---- - -# ArgoCD Applications - -Registry of all applications deployed via [[argocd]]. - -## Application Registry - -| App | Namespace | Path/Source | Service | -|-----|-----------|-------------|---------| -| `apps` | argocd | `argocd/apps/` | App-of-apps root | -| `argocd` | argocd | `argocd/manifests/argocd/` | [[argocd]] | -| `tailscale-operator` | tailscale | `argocd/manifests/tailscale-operator/` | [[tailscale-operator]] | -| `1password-connect` | 1password | `argocd/manifests/1password-connect/` | [[1password]] | -| `external-secrets` | external-secrets | Helm chart | [[1password]] | -| `external-secrets-config` | external-secrets | `argocd/manifests/external-secrets-config/` | [[1password]] | -| `cloudnative-pg` | cnpg-system | `mirrors/cloudnative-pg` release manifest | PostgreSQL operator | -| `blumeops-pg` | databases | `argocd/manifests/databases/` | [[postgresql]] | -| `prometheus` | monitoring | `argocd/manifests/prometheus/` | [[prometheus]] | -| `loki` | monitoring | `argocd/manifests/loki/` | [[loki]] | -| `grafana` | monitoring | `argocd/manifests/grafana/` | [[grafana]] | -| `grafana-config` | monitoring | `argocd/manifests/grafana-config/` | [[grafana]] | -| `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) | -| `kube-state-metrics` | monitoring | `argocd/manifests/kube-state-metrics/` | K8s metrics | -| `miniflux` | miniflux | `argocd/manifests/miniflux/` | [[miniflux]] | -| `kiwix` | kiwix | `argocd/manifests/kiwix/` | [[kiwix]] | -| `torrent` | torrent | `argocd/manifests/torrent/` | [[transmission]] | -| `navidrome` | navidrome | `argocd/manifests/navidrome/` | [[navidrome]] | -| `teslamate` | teslamate | `argocd/manifests/teslamate/` | [[teslamate]] | -| `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]] | -| `paperless` | paperless | `argocd/manifests/paperless/` | [[paperless]] | -| `shower` | shower | `argocd/manifests/shower/` | [[shower-app]] | -| `prowler` | prowler | `argocd/manifests/prowler/` | [[prowler]] | - -## Sync Policies - -| Application | Policy | Rationale | -|-------------|--------|-----------| -| `apps` | Automated | Picks up new Application manifests | -| All others | Manual | Explicit control over deployments | - -## Related - -- [[argocd]] - GitOps platform details -- [[cluster|Cluster]] - Kubernetes infrastructure diff --git a/docs/reference/kubernetes/cluster.md b/docs/reference/kubernetes/cluster.md deleted file mode 100644 index 07c14af..0000000 --- a/docs/reference/kubernetes/cluster.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -title: Cluster -modified: 2026-06-04 -last-reviewed: 2026-06-04 -tags: - - kubernetes ---- - -# Kubernetes Cluster - -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 - -| Property | Value | -|----------|-------| -| **Driver** | docker | -| **Container Runtime** | docker | -| **Kubernetes Version** | v1.35.0 | -| **CPUs** | 6 | -| **Memory** | 11GB | -| **Disk** | 200GB | -| **API Server** | https://k8s.tail8d86e.ts.net | - -**Prerequisites:** Docker Desktop with at least 12GB memory allocated. - -## Volume Mounting - -Pods mount NFS directly from [[sifaka|Sifaka]]. Docker NATs outbound traffic through indri's LAN IP (192.168.1.50), allowing access to Sifaka's NFS exports. - -## Registry Mirror - -Containerd uses [[zot]] as a pull-through cache at `host.minikube.internal:5050`. - -Mirrors configured: `registry.ops.eblu.me`, `docker.io`, `ghcr.io`, `quay.io` - -## K3s on Ringtail - -Single-node k3s cluster for workloads requiring amd64 or GPU access. See [[ringtail]] for cluster specs, workload list, and secrets management. - -| Property | Value | -|----------|-------| -| **Context** | `k3s-ringtail` | -| **API Server** | `https://ringtail.tail8d86e.ts.net:6443` | -| **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 - -- [[apps|Apps]] - ArgoCD applications -- [[argocd]] - GitOps deployment -- [[zot]] - Registry mirror diff --git a/docs/reference/kubernetes/external-secrets.md b/docs/reference/kubernetes/external-secrets.md deleted file mode 100644 index 3a1e08e..0000000 --- a/docs/reference/kubernetes/external-secrets.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -title: External Secrets -modified: 2026-03-23 -last-reviewed: 2026-03-23 -tags: - - kubernetes - - secrets ---- - -# External Secrets - -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/kubernetes/tailscale-operator.md b/docs/reference/kubernetes/tailscale-operator.md deleted file mode 100644 index 174b347..0000000 --- a/docs/reference/kubernetes/tailscale-operator.md +++ /dev/null @@ -1,61 +0,0 @@ ---- -title: Tailscale Operator -modified: 2026-06-08 -last-reviewed: 2026-06-08 -tags: - - kubernetes - - tailscale ---- - -# Tailscale Kubernetes Operator - -The Tailscale operator enables Kubernetes services to be exposed directly on the Tailscale network via Ingress resources. - -## Quick Reference - -| Property | Value | -|----------|-------| -| **Namespace** | `tailscale` | -| **Upstream** | `mirrors/tailscale` on forge (static manifest, pinned `v1.94.2`) | -| **ArgoCD Apps** | `tailscale-operator` (indri/minikube), `tailscale-operator-ringtail` (ringtail/k3s) | - -The operator runs on **both** clusters — indri's minikube and ringtail's k3s. -Both apps layer on the shared `tailscale-operator-base` kustomize directory -(operator manifest, `ProxyClass`, `dnsconfig`); each cluster supplies its own -`ProxyGroup` (indri: 2 replicas, ringtail: 1) and OAuth `ExternalSecret`. The -ringtail overlay additionally rewrites the proxy image to a locally nix-built -mirror. See [[ringtail]] and [[migrate-wave1-ringtail]] for the ongoing -migration of k8s workloads onto ringtail. - -## How It Works - -Ingresses use a shared ProxyGroup (`ingress`) rather than per-service Tailscale nodes. When you create an Ingress with `ingressClassName: tailscale`: - -1. Operator configures the shared ProxyGroup pods to serve the new Ingress -2. Service gets a VIP (Virtual IP) address on the tailnet -3. Service becomes accessible at `<hostname>.tail8d86e.ts.net` -4. TLS is handled automatically via Tailscale - -Two requirements for VIP routing to work: - -1. Tailnet clients must have `--accept-routes` enabled to route to VIP addresses. -2. Ingress rules must **not** set an explicit `host:` field. The ProxyGroup - proxy receives the FQDN as the `Host` header (e.g. - `prometheus.tail8d86e.ts.net`), which won't match a short name. Use - `host: "*"` or omit `host:` entirely. - -Services can be individually tagged (e.g., `tag:flyio-target`) via Ingress annotations to control which ACL grants apply. See [[expose-service-publicly]] for the tagging workflow. - -## Limitations - -Services exposed via Tailscale Ingress are **not accessible** from: -- Other Kubernetes pods (they're not Tailscale clients) -- Docker containers on indri - -For pod-to-service communication, use [[routing|Caddy]] (`*.ops.eblu.me`) instead. - -## Related - -- [[tailscale]] - Network configuration -- [[routing]] - Service routing options -- [[apps]] - Application registry diff --git a/docs/reference/operations/backup.md b/docs/reference/operations/backup.md deleted file mode 100644 index 50d8daa..0000000 --- a/docs/reference/operations/backup.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -title: Backup -modified: 2026-03-23 -last-reviewed: 2026-03-23 -tags: - - operations ---- - -# Backup - -Daily automated backups of BlumeOps data. - -## Components - -- [[borgmatic]] - Backup orchestration -- [[sifaka|Sifaka]] - Backup target (NAS) -- [[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 deleted file mode 100644 index 1a498ba..0000000 --- a/docs/reference/operations/disaster-recovery.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -title: Disaster Recovery -modified: 2026-03-23 -last-reviewed: 2026-03-23 -tags: - - operations ---- - -# Disaster Recovery - -Recovery procedures for BlumeOps infrastructure. - -## Procedures - -| Scenario | Guide | -|----------|-------| -| Indri reboot/power loss | [[restart-indri]] | -| Full minikube cluster rebuild | [[rebuild-minikube-cluster]] | -| Lost 1Password access | [[restore-1password-backup]] | - -## 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/operations/observability.md b/docs/reference/operations/observability.md deleted file mode 100644 index 622779e..0000000 --- a/docs/reference/operations/observability.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -title: Observability -modified: 2026-03-26 -tags: - - operations ---- - -# Observability - -Metrics, logs, traces, and dashboards for BlumeOps infrastructure. - -## Components - -- [[prometheus]] - Metrics storage and querying -- [[loki]] - Log aggregation -- [[tempo]] - Distributed tracing -- [[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) -- [[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/docs/reference/operations/security.md b/docs/reference/operations/security.md deleted file mode 100644 index 86b3d3b..0000000 --- a/docs/reference/operations/security.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -title: Security & Compliance -modified: 2026-06-08 -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). The container-image CVE scan and IaC scan were retired in 2026-06 (un-actioned noise — see [[deploy-prowler#Why only the K8s CIS scan]]); only the K8s CIS scan remains. - - [[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 - -- [[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. - -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 - -- 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 CVE scanning (the Prowler image scan was retired 2026-06 as un-actioned noise). If reintroduced, scope it to critical-severity, currently-deployed tags, alert-on-new -- No automated IaC misconfiguration scanning (the Prowler IaC scan was retired 2026-06). Manifest pod-security hardening is now an accept-and-document decision rather than a weekly report diff --git a/docs/reference/operations/service-versions.md b/docs/reference/operations/service-versions.md deleted file mode 100644 index 23d23e1..0000000 --- a/docs/reference/operations/service-versions.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -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/1password.md b/docs/reference/services/1password.md deleted file mode 100644 index 5ad50da..0000000 --- a/docs/reference/services/1password.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -title: 1Password -modified: 2026-05-22 -last-reviewed: 2026-05-22 -tags: - - service - - secrets ---- - -# 1Password - -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. - -## 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, deployed on both indri and ringtail) - | - v -External Secrets Operator (namespace: external-secrets) - | - v -Native Kubernetes Secrets -``` - -**ClusterSecretStore:** `onepassword-blumeops` (same name on both clusters). - -Services reference 1Password items via `ExternalSecret` manifests. Both `minikube-indri` and `k3s-ringtail` run their own `onepassword-connect` deployment talking to the same vault. - -## Direct Access - -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. - -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 - -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 - -- [[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 diff --git a/docs/reference/services/alloy.md b/docs/reference/services/alloy.md deleted file mode 100644 index 97d1e77..0000000 --- a/docs/reference/services/alloy.md +++ /dev/null @@ -1,68 +0,0 @@ ---- -title: Alloy -modified: 2026-06-04 -last-reviewed: 2026-06-04 -tags: - - service - - observability ---- - -# Grafana Alloy - -Unified observability collector for metrics and logs with three deployments: -1. **Indri (host)** - System metrics and service logs from macOS host -2. **Kubernetes (DaemonSet)** - Automatic pod log collection and service health probes -3. **Fly.io proxy (embedded)** - nginx access log metrics and log forwarding from [[flyio-proxy]] - -## Quick Reference - -| Property | Value | -|----------|-------| -| **Indri Binary** | `~/.local/bin/alloy` | -| **Indri Config** | `~/.config/grafana-alloy/config.alloy` | -| **K8s Namespace** | `alloy` | -| **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.16.1` (binary copied into nginx container, sha-pinned) | - -## Metrics Collected - -### From Indri -- System metrics via `prometheus.exporter.unix` -- Textfile collector: `minikube.prom`, `borgmatic.prom`, `zot.prom`, `jellyfin.prom` -- Zot registry metrics from `http://localhost:5050/metrics` -- Pushed to [[prometheus]] via remote_write - -### From Kubernetes -- All pod logs via `loki.source.kubernetes` -- Service health probes: miniflux, kiwix, transmission, devpi, argocd - -### From Fly.io Proxy -- `flyio_nginx_http_requests_total` — request rate by status/method/host -- `flyio_nginx_http_request_duration_seconds` — latency histogram -- `flyio_nginx_http_response_bytes_total` — response bandwidth -- `flyio_nginx_cache_requests_total` — cache HIT/MISS/EXPIRED counts -- Pushed to [[prometheus]] via remote_write through [[caddy]] - -## Logs Collected - -**Brew services:** forgejo, tailscale - -**mcquack LaunchAgents:** alloy, borgmatic, zot, jellyfin - -Logs pushed to [[loki]] at `https://loki.tail8d86e.ts.net/loki/api/v1/push`. - -**Fly.io proxy:** nginx JSON access logs pushed to [[loki]] at `https://loki.ops.eblu.me/loki/api/v1/push` (via [[caddy]]). - -## Why Built from Source - -The Homebrew bottle uses `CGO_ENABLED=0`, which breaks Tailscale MagicDNS. Building with `CGO_ENABLED=1` uses the macOS native resolver. - -**Note:** This may no longer be needed now that services use `*.ops.eblu.me` URLs (routed via Caddy) instead of `*.tail8d86e.ts.net`. Should be tested in the future. - -## Related - -- [[prometheus]] - Metrics storage -- [[loki]] - Log storage -- [[grafana]] - Visualization diff --git a/docs/reference/services/argocd.md b/docs/reference/services/argocd.md deleted file mode 100644 index e890cc5..0000000 --- a/docs/reference/services/argocd.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -title: ArgoCD -modified: 2026-02-07 -tags: - - service - - gitops ---- - -# ArgoCD - -GitOps continuous delivery platform for the [[cluster|Kubernetes cluster]]. - -## Quick Reference - -| Property | Value | -|----------|-------| -| **URL** | https://argocd.ops.eblu.me | -| **Tailscale URL** | https://argocd.tail8d86e.ts.net | -| **Namespace** | `argocd` | -| **Git Source** | `ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git` | -| **Manifests Path** | `argocd/` | - -## Sync Policy - -| Application | Sync Policy | Rationale | -|-------------|-------------|-----------| -| `apps` | Automated | Picks up new Application manifests | -| All workloads | Manual | Explicit control over deployments | - -## Credentials - -- Admin password: 1Password (blumeops vault) -- Git deploy key (SSH): 1Password - -## Related - -- [[argocd-cli]] - CLI usage and deployment workflows -- [[apps|Apps]] - Full application registry -- [[forgejo]] - Git source diff --git a/docs/reference/services/authentik.md b/docs/reference/services/authentik.md deleted file mode 100644 index 89a17cc..0000000 --- a/docs/reference/services/authentik.md +++ /dev/null @@ -1,88 +0,0 @@ ---- -title: Authentik -modified: 2026-02-20 -tags: - - service - - security - - oidc ---- - -# Authentik - -OIDC identity provider for BlumeOps. Authentik is the **source of truth** for user identity — users are created and managed in Authentik, and services authenticate against it via OIDC. - -## Quick Reference - -| Property | Value | -|----------|-------| -| **URL** | https://authentik.ops.eblu.me | -| **Admin UI** | https://authentik.ops.eblu.me/if/admin/ | -| **Tailscale URL** | https://authentik.tail8d86e.ts.net | -| **Namespace** | `authentik` | -| **Cluster** | k3s (ringtail) | -| **Manifests** | `argocd/manifests/authentik/` | -| **Container build** | `containers/authentik/default.nix` | - -## Architecture - -Authentik runs on [[ringtail]]'s k3s cluster, isolated from the main services on indri's minikube. This means the IdP is independent of the minikube cluster lifecycle. - -Three deployments: -- **server** — HTTP/HTTPS interface, handles OIDC flows -- **worker** — Background tasks, blueprint application -- **redis** — Caching, sessions, task queue - -## Database - -Uses the shared CNPG `blumeops-pg` cluster on [[indri]], accessed cross-cluster via `pg.ops.eblu.me:5432`. Database `authentik` with managed role. - -## Blueprints - -Authentik configuration is managed via Blueprints (YAML) stored as a ConfigMap mounted into the worker at `/blueprints/custom/`. Current blueprints: - -- **`common.yaml`** — shared identity resources (`admins` group) -- **`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. - -Blueprint file: `argocd/manifests/authentik/configmap-blueprint.yaml` - -## OIDC Clients - -| Client | Status | -|--------|--------| -| [[grafana]] | Active | -| [[forgejo]] | Active | -| [[zot]] | Active | - -Future clients: [[argocd]], [[miniflux]] - -## Secrets - -Injected via [[external-secrets]] from the "Authentik (blumeops)" 1Password item (see [[create-authentik-secrets]] for setup). - -| 1Password Field | Purpose | -|-----------------|---------| -| `secret-key` | Authentik secret key | -| `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 - -Nix-built via `dockerTools.buildLayeredImage`. The entrypoint wrapper symlinks built-in blueprint directories from the Nix store into `/blueprints/` at runtime, allowing custom blueprints to coexist with defaults. `AUTHENTIK_BLUEPRINTS_DIR=/blueprints` overrides the hardcoded Nix store path. - -## Related - -- [[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/automounter.md b/docs/reference/services/automounter.md deleted file mode 100644 index c8205ea..0000000 --- a/docs/reference/services/automounter.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -title: Automounter -modified: 2026-02-07 -tags: - - services - - macos ---- - -# AutoMounter - -macOS app that automatically mounts [[sifaka]] SMB shares on [[indri]]. - -## Quick Reference - -| Property | Value | -|----------|-------| -| **App** | [AutoMounter](https://www.pixeleyes.co.nz/automounter/) | -| **Source** | Mac App Store (paid) | -| **Autostart** | No (must launch manually after reboot) | -| **Purpose** | Mount sifaka SMB shares to `/Volumes/` | - -## Mounted Shares - -| Share | Mount Point | Consumers | -|-------|-------------|-----------| -| backups | `/Volumes/backups` | [[borgmatic]] | -| torrents | `/Volumes/torrents` | [[kiwix]], [[transmission]] | -| music | `/Volumes/music` | [[navidrome]] | -| allisonflix | `/Volumes/allisonflix` | [[jellyfin]] | -| photos | `/Volumes/photos` | [[immich]] | -| frigate | `/Volumes/frigate` | [[frigate]] | - -## Why AutoMounter? - -There are free alternatives for mounting network shares on macOS (autofs, automountd, login scripts). AutoMounter was chosen for convenience and has proven reliable. If it becomes problematic, the alternative would be configuring autofs via Ansible. - -## Related - -- [[indri]] - Host machine -- [[sifaka]] - NAS providing the shares -- [[restart-indri]] - Startup procedure diff --git a/docs/reference/services/borgmatic.md b/docs/reference/services/borgmatic.md deleted file mode 100644 index 37f1a60..0000000 --- a/docs/reference/services/borgmatic.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -title: Borgmatic -modified: 2026-03-16 -tags: - - service - - backup ---- - -# Borgmatic - -Daily backup system using Borg backup, running on indri. - -## Quick Reference - -| Property | Value | -|----------|-------| -| **Install** | mise (pipx) | -| **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 - -**Directories:** -- `~/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 -- `~/.local/share/borgmatic/k8s-dumps/` - SQLite dumps from k8s pods - -**PostgreSQL databases:** -- `miniflux` on [[postgresql]] -- `teslamate` on [[postgresql]] - -**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) -- Loki logs (ephemeral) - -## Retention Policy - -| Period | Count | -|--------|-------| -| Daily | 7 | -| Monthly | 12 | -| Yearly | 1000 | - -## Monitoring - -Metrics exposed via textfile collector to [[prometheus]]: -- `borgmatic_up` - Repository accessibility -- `borgmatic_last_archive_timestamp` - Last backup time -- `borgmatic_repo_deduplicated_size_bytes` - Disk usage - -Dashboard: "Borgmatic Backups" in [[grafana]] - -## Related - -- [[backups|Backups]] - Full backup policy -- [[sifaka|Sifaka]] - Backup target -- [[postgresql]] - Database backups -- [[restore-1password-backup]] - Recover 1Password from backup diff --git a/docs/reference/services/caddy.md b/docs/reference/services/caddy.md deleted file mode 100644 index 04861ec..0000000 --- a/docs/reference/services/caddy.md +++ /dev/null @@ -1,100 +0,0 @@ ---- -title: Caddy -modified: 2026-04-18 -tags: - - service - - networking - - tls ---- - -# Caddy - -Reverse proxy for `*.ops.eblu.me` services with automatic TLS via ACME DNS-01. - -## Quick Reference - -| Property | Value | -|----------|-------| -| **Domain** | `*.ops.eblu.me` | -| **HTTPS Port** | 443 | -| **Config** | `ansible/roles/caddy/templates/Caddyfile.j2` | -| **Binary** | Custom build with Gandi DNS plugin | - -## Why Caddy? - -Caddy provides a single TLS termination point for all BlumeOps services: - -- **Wildcard certificate** for `*.ops.eblu.me` via Let's Encrypt -- **DNS-01 challenge** using Gandi API (no port 80 needed) -- **Unified access** from k8s pods, containers, and tailnet clients - -See [[routing]] for when to use `*.ops.eblu.me` vs `*.tail8d86e.ts.net`. - -## Proxied Services - -### Indri-Local Services - -| Subdomain | Backend | Service | -|-----------|---------|---------| -| `forge.ops.eblu.me` | `localhost:3001` | [[forgejo]] | -| `registry.ops.eblu.me` | `localhost:5050` | [[zot]] | -| `jellyfin.ops.eblu.me` | `localhost:8096` | [[jellyfin]] | - -### Kubernetes Services - -K8s services are proxied via their Tailscale Ingress endpoints: - -| Subdomain | Backend | Service | -|-----------|---------|---------| -| `grafana.ops.eblu.me` | `grafana.tail8d86e.ts.net` | [[grafana]] | -| `argocd.ops.eblu.me` | `argocd.tail8d86e.ts.net` | [[argocd]] | -| `cv.ops.eblu.me` | `cv.tail8d86e.ts.net` | [[cv]] | -| `docs.ops.eblu.me` | `docs.tail8d86e.ts.net` | [[docs]] (now publicly available at `docs.eblu.me` via [[flyio-proxy]]) | -| `feed.ops.eblu.me` | `feed.tail8d86e.ts.net` | [[miniflux]] | -| ... | ... | (see defaults/main.yml for full list) | - -### TCP Services (Layer 4) - -| Port | Backend | Service | -|------|---------|---------| -| 2222 | `localhost:2200` | Forgejo SSH | -| 5432 | `pg.tail8d86e.ts.net:5432` | [[postgresql]] | - -## Configuration - -Caddy is managed via the `caddy` Ansible role: - -```bash -# Deploy caddy changes -mise run provision-indri -- --tags caddy -``` - -**Key files:** -- `ansible/roles/caddy/defaults/main.yml` - Service definitions -- `ansible/roles/caddy/templates/Caddyfile.j2` - Caddy config template - -## Secrets - -| Secret | Source | Description | -|--------|--------|-------------| -| `GANDI_BEARER_TOKEN` | 1Password | API token for DNS-01 challenges | - -The token is written to `~/.config/caddy/gandi-token` (chmod 0600) and sourced by the Caddy wrapper script. - -## 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`, `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 - -Custom `xcaddy` build with Gandi DNS and L4 plugins. See [[build-caddy-with-plugins]] for build instructions and forge mirror details. - -## Related - -- [[gandi]] - DNS hosting and ACME DNS-01 provider -- [[routing]] - Service routing architecture -- [[forgejo]] - Git forge (proxied by Caddy) -- [[zot]] - Container registry (proxied by Caddy) -- [[tailscale-operator]] - K8s services use Tailscale Ingress, then Caddy diff --git a/docs/reference/services/cv.md b/docs/reference/services/cv.md deleted file mode 100644 index 1bc5f15..0000000 --- a/docs/reference/services/cv.md +++ /dev/null @@ -1,69 +0,0 @@ ---- -title: CV -modified: 2026-04-29 -last-reviewed: 2026-04-29 -tags: - - service - - resume ---- - -# CV (Resume) - -Personal resume/CV served as a static HTML page with PDF download, built from YAML source via Jinja2 and WeasyPrint. - -## Quick Reference - -| Property | Value | -|----------|-------| -| **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`) | - -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**: 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 (Caddy adds `Content-Disposition: attachment`) | - -## Configuration - -**Key files (blumeops):** - -- `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):** - -- `resume.yaml` — Resume content (YAML) -- `template.html` — Jinja2 HTML template -- `style.css` — CSS with screen/print media queries -- `render.py` — uv script runner (PEP 723) that renders YAML → HTML -- `src/cv_ci/main.py` — Dagger pipeline (alpine + uv + WeasyPrint) -- `.forgejo/workflows/cv-release.yaml` — Release workflow - -## Release flow - -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 - -- [[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 diff --git a/docs/reference/services/devpi.md b/docs/reference/services/devpi.md deleted file mode 100644 index 589a802..0000000 --- a/docs/reference/services/devpi.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -title: Devpi -modified: 2026-04-29 -last-reviewed: 2026-04-29 -tags: - - service - - python ---- - -# devpi (PyPI Proxy) - -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` | -| **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 by `devpi-init`) | -| `eblume/dev` | Private packages (inherits from `root/pypi`) | - -## Credentials - -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 - -- [[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/services/docs.md b/docs/reference/services/docs.md deleted file mode 100644 index 8ca8310..0000000 --- a/docs/reference/services/docs.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -title: Docs -modified: 2026-04-29 -last-reviewed: 2026-04-29 -tags: - - service - - documentation ---- - -# Docs (Quartz) - -Documentation site built with [Quartz](https://quartz.jzhao.xyz/). - -## Quick Reference - -| Property | Value | -|----------|-------| -| **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` | - -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**: `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` -- **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 - -- [[docs-on-indri]] — Operations how-to -- [[cv]] — Similar architecture -- [[forgejo]] — Build workflows diff --git a/docs/reference/services/flyio-proxy.md b/docs/reference/services/flyio-proxy.md deleted file mode 100644 index 182e80c..0000000 --- a/docs/reference/services/flyio-proxy.md +++ /dev/null @@ -1,134 +0,0 @@ ---- -title: Fly.io Proxy -modified: 2026-04-18 -tags: - - service - - networking - - fly-io ---- - -# Fly.io Proxy - -Public reverse proxy on [Fly.io](https://fly.io) that exposes selected BlumeOps services to the internet via a Tailscale tunnel back to the homelab. - -## Quick Reference - -| Property | Value | -|----------|-------| -| **App** | `blumeops-proxy` | -| **Region** | `sjc` (San Jose) | -| **Fly.io URL** | `blumeops-proxy.fly.dev` | -| **Config** | `fly/` directory in repo | -| **IaC** | `fly/fly.toml` (app), Pulumi (DNS + auth key) | - -## Exposed Services - -| 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 [[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. - -### Why Caddy, not per-service Tailscale Ingress? - -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. - -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. - -### 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 - -| File | Purpose | -|------|---------| -| `fly/fly.toml` | App configuration | -| `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, 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 | - -## Networking - -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 - -[[alloy|Alloy]] runs inside the container alongside nginx and Tailscale, providing: - -- **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` — 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 - -### Dashboards - -| Dashboard | Purpose | -|-----------|---------| -| **Docs APM** | Per-service view for `docs.eblu.me`: request rate, latency percentiles, cache hit ratio, error rate, bandwidth, access logs | -| **Fly.io Proxy Health** | Aggregate proxy health: connections, total request rate by host, cache performance, upstream latency, Alloy health | - -Alloy listens on `127.0.0.1:12345` for self-scraping its `/metrics` endpoint. All metrics carry `instance="flyio-proxy"`. - -## Security Considerations - -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 - -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 (`/<owner>/<repo>/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 a Caddy route for it and an nginx `server` block. See [[expose-service-publicly]] for the full workflow. - -## Spider Trap Mitigation - -The SPA fallback (`try_files ... /index.html`) serves `index.html` with a 200 for *any* URI, including non-existent paths. Quartz's relative links (`../path`) compound when resolved from phantom URLs, creating an infinite tree of unique URIs that crawlers follow indefinitely. In March 2026, Meta's crawler (`meta-externalagent/1.1`) hit ~49,000 unique URIs over 7 hours this way. - -Two nginx `location` guards in `containers/quartz/default.conf` mitigate the trap: - -1. **`/tags/` depth limit** — `/tags/<name>` is always flat; anything deeper returns 404. -2. **Global depth-5 cutoff** — real content never exceeds depth 4; paths with 5+ segments return 404. - -These are applied in the Quartz container's nginx config, not the Fly.io proxy. The proper fix is switching Quartz to root-absolute links (planned for the fork). - -## Secrets - -| Secret | Source | Description | -|--------|--------|-------------| -| `TS_AUTHKEY` | Pulumi state → `fly secrets` | Tailscale auth key for joining tailnet | -| `FLY_DEPLOY_TOKEN` | Fly.io → 1Password | Deploy token for CI | - -## Related - -- [[expose-service-publicly]] - Setup guide for adding new public services -- [[manage-flyio-proxy]] - Operational tasks (deploy, shutoff, troubleshoot) -- [[caddy]] - Private reverse proxy for `*.ops.eblu.me` (separate system) -- [[tailscale]] - WireGuard mesh network -- [[gandi]] - DNS hosting diff --git a/docs/reference/services/forgejo-runner.md b/docs/reference/services/forgejo-runner.md deleted file mode 100644 index 612f20f..0000000 --- a/docs/reference/services/forgejo-runner.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -title: Forgejo Runner -modified: 2026-04-20 -last-reviewed: 2026-04-20 -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** | `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. 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]]). - -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 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 - -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 | -|--------|--------|---------| -| `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 - -- [[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 deleted file mode 100644 index 5b16b0e..0000000 --- a/docs/reference/services/forgejo.md +++ /dev/null @@ -1,179 +0,0 @@ ---- -title: Forgejo -modified: 2026-04-17 -tags: - - service - - git - - ci-cd ---- - -# Forgejo - -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 | -|----------|-------| -| **URL (public)** | https://forge.eblu.me | -| **URL (internal)** | https://forge.ops.eblu.me | -| **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 - -| Repo | Description | -|------|-------------| -| `eblume/blumeops` | Infrastructure as code (primary) | -| `eblume/alloy` | Grafana Alloy fork (CGO build) | -| `eblume/tesla_auth` | Tesla OAuth helper | - -## CI/CD (Forgejo Actions) - -**Runners:** - -| Runner | Host | Labels | Purpose | -|--------|------|--------|---------| -| k8s DinD pod | [[indri]] (minikube) | `k8s` | Dockerfile builds via Dagger | -| ringtail-nix-builder | [[ringtail]] (native) | `nix-container-builder` | Nix builds via `nix-build` + `skopeo` | - -**Workflows:** `.forgejo/workflows/` -- `build-container.yaml` - Dockerfile builds on tag (runs on `k8s`) -- `build-container-nix.yaml` - Nix builds on tag (runs on `nix-container-builder`) -- `build-blumeops.yaml` - Documentation builds and releases - -Both container workflows trigger on the same tag pattern (`*-v[0-9]*`). Each checks for its build file (`Dockerfile` or `default.nix`) and skips if not present. See [[build-container-image]]. - -## Secrets (Forgejo Config) - -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 - -Repository-level secrets for CI/CD workflows, synced from 1Password via Ansible. - -| Secret | 1Password Field | Used By | Purpose | -|--------|-----------------|---------|---------| -| `ARGOCD_AUTH_TOKEN` | `argocd_token` | `build-blumeops.yaml` | Sync docs app after release | - -These secrets are injected as `${{ secrets.SECRET_NAME }}` in workflow files. - -**IaC:** The `forgejo_actions_secrets` Ansible role syncs these secrets from 1Password to Forgejo via the Forgejo API. Run with: - -```bash -mise run provision-indri -- --tags forgejo_actions_secrets -``` - -### API Token Setup (Manual, One-Time) - -The Ansible role authenticates to the Forgejo API using a Personal Access Token (PAT). This PAT must be created manually: - -1. Go to https://forge.eblu.me/user/settings/applications -2. Create a new token with `write:repository` scope -3. Store it in 1Password → "Forgejo Secrets" item → `api-token` field - -This is a bootstrapping requirement - the PAT enables IaC for all other secrets. - -## Identity Provider - -[[authentik]] is the BlumeOps OIDC identity provider and source of truth for user identity. Forgejo authenticates against Authentik as an OIDC client. - -**Configuration:** -- OAuth2 provider and application defined in Authentik blueprints (`argocd/manifests/authentik/configmap-blueprint.yaml`) -- Auth source created via `forgejo admin auth add-oauth` with `--skip-local-2fa` (lives in Forgejo's SQLite database, not app.ini) -- `[oauth2_client]` section in `app.ini.j2` controls auto-registration and account linking behavior - -**MFA:** SSO logins skip Forgejo's local 2FA (`--skip-local-2fa` on the auth source) — Authentik enforces MFA instead. Local password logins still require Forgejo's own TOTP. Note: the `--skip-local-2fa` CLI flag has a [known bug](https://codeberg.org/forgejo/forgejo/issues/5366) where it doesn't persist via `update-oauth`; it was set directly in the `login_source.cfg` JSON (`SkipLocalTwoFA: true`). - -**Account linking:** `ACCOUNT_LINKING = login` — when an Authentik user's email matches an existing local account, Forgejo prompts for the local password (and local MFA) to confirm the link. This is a one-time operation that preserves existing accounts, API tokens, SSH keys, and repository ownership. - -**Group-based admin:** The `admins` group in Authentik maps to Forgejo admin status via `--admin-group admins` on the auth source. Manage admin access in Authentik, not Forgejo. - -**Break-glass:** Local password login always works (with local MFA). Authentik SSO is additive — if Authentik is down, log in with local credentials. - -## Public Access - -Forgejo is publicly accessible at `https://forge.eblu.me` via [[flyio-proxy]]. This is the first dynamic, authenticated service exposed publicly. - -| Access Method | URL | Reachable From | -|---------------|-----|----------------| -| **HTTPS (public)** | https://forge.eblu.me | Public internet | -| **HTTPS (internal)** | https://forge.ops.eblu.me | Tailnet only | -| **SSH** | `ssh://forgejo@forge.ops.eblu.me:2222` | Tailnet only | - -The UI shows `forge.eblu.me` for HTTPS clone URLs and `forge.ops.eblu.me` for SSH clone URLs. - -### Security Controls - -- **Registration:** Local registration disabled; only [[authentik]] SSO login allowed (`ALLOW_ONLY_EXTERNAL_REGISTRATION = true`) -- **Reverse proxy trust:** `REVERSE_PROXY_LIMIT = 2`, `REVERSE_PROXY_TRUSTED_PROXIES = *` — Forgejo logs the real client IP from `X-Real-IP` header, not the proxy's Tailscale IP -- **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. - -## 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/docs/reference/services/frigate.md b/docs/reference/services/frigate.md deleted file mode 100644 index 000d4ee..0000000 --- a/docs/reference/services/frigate.md +++ /dev/null @@ -1,80 +0,0 @@ ---- -title: Frigate -modified: 2026-02-22 -tags: - - service - - surveillance ---- - -# Frigate - -Open-source network video recorder (NVR) with object detection. Runs cloud-free with all video stored locally on [[sifaka]]. - -## Quick Reference - -| Property | Value | -|----------|-------| -| **URL** | https://nvr.ops.eblu.me | -| **Tailscale URL** | https://nvr.tail8d86e.ts.net | -| **Namespace** | `frigate` | -| **Image** | `ghcr.io/blakeblackshear/frigate:0.17.0-rc2-tensorrt` | -| **Upstream** | https://github.com/blakeblackshear/frigate | -| **Manifests** | `argocd/manifests/frigate/` | - -## Architecture - -``` -ReoLink Camera (GableCam) - │ RTSP - ▼ -Frigate pod (ringtail k3s) - ├── go2rtc — RTSP restream proxy - ├── FFmpeg — stream decoding - ├── detector — ONNX with CUDA (RTX 4080) - ├── /media/frigate — NFS recordings (sifaka) - └── /db — SQLite (local PVC) - │ - └──→ frigate-notify (webapi poll) → ntfy → mobile -``` - -## Cameras - -| Camera | IP | Location | Objects Tracked | -|--------|----|----------|-----------------| -| GableCam | `192.168.1.159` | Front gable | person, car, dog, cat, bird | - -Camera credentials are stored in 1Password and synced via [[external-secrets]] to the `frigate-camera` Secret. - -## Detection - -Object detection runs on [[ringtail]]'s RTX 4080 via the ONNX detector with CUDA execution provider (TensorRT). The model is YOLOv9-c at 640x640 (`yolov9-c-640.onnx`, `model_type: yolo-generic`), which benefits from CUDA Graphs in Frigate 0.17. To re-export or change model size, use `mise run frigate-export-model`. - -Two zones are configured: `driveway_entrance` (triggers review alerts for person/car) and `driveway` (triggers review detections). - -## Retention - -| Type | Duration | Mode | -|------|----------|------| -| Continuous recording | 3 days | all | -| Alert clips | 30 days | active objects | -| Detection clips | 14 days | motion | -| Snapshots | 14 days | — | - -## Storage - -| Mount | Backend | Size | -|-------|---------|------| -| `/media/frigate` | NFS PV on [[sifaka]] (`/volume1/frigate`) | 2 Ti | -| `/db` | Local PVC (`frigate-database`) | SQLite | -| `/dev/shm` | Memory-backed `emptyDir` | 512 Mi | - -## Alerting (frigate-notify) - -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 - -- [[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/grafana.md b/docs/reference/services/grafana.md deleted file mode 100644 index 3a9ae01..0000000 --- a/docs/reference/services/grafana.md +++ /dev/null @@ -1,69 +0,0 @@ ---- -title: Grafana -modified: 2026-02-28 -tags: - - service - - observability ---- - -# Grafana - -Dashboards and visualization for BlumeOps observability. - -## Quick Reference - -| Property | Value | -|----------|-------| -| **URL** | https://grafana.ops.eblu.me | -| **Tailscale URL** | https://grafana.tail8d86e.ts.net | -| **Namespace** | `monitoring` | -| **Deployment** | Kustomize (`argocd/manifests/grafana/`) | -| **Image** | `registry.ops.eblu.me/blumeops/grafana` | -| **Sidecar Image** | `registry.ops.eblu.me/blumeops/grafana-sidecar` | - -## Authentication - -Grafana supports two login methods: - -- **SSO via [[authentik]]** — OIDC login through Authentik (`auth.generic_oauth`). Users click "Sign in with Authentik", authenticate at Authentik, and are redirected back as Admin. -- **Local admin** — break-glass login using the password from 1Password ("Grafana (blumeops)"). Always available if Authentik is down. - -The OIDC client secret is injected via [[external-secrets]] (`grafana-authentik-oauth` secret in monitoring namespace). - -## Datasources - -| Name | Type | Target | -|------|------|--------| -| Prometheus | prometheus | `prometheus.monitoring.svc.cluster.local:9090` | -| Loki | loki | `loki.monitoring.svc.cluster.local:3100` | -| Tempo | tempo | `tempo.monitoring.svc.cluster.local:3200` | -| TeslaMate | postgres | `blumeops-pg-rw.databases.svc.cluster.local:5432` | - -## Dashboard Provisioning - -Dashboards are ConfigMaps with label `grafana_dashboard: "1"`. - -Location: `argocd/manifests/grafana-config/dashboards/` - -Optional annotation: `grafana_folder: "FolderName"` - -## Key Dashboards - -- macOS System - Host metrics for indri -- Minikube - Kubernetes cluster overview -- Borgmatic Backups - Backup status and trends -- Services Health - HTTP probe results -- Docs APM - Request rate, latency, cache for docs.eblu.me -- Fly.io Proxy Health - Aggregate proxy health across all upstream services -- TeslaMate (18 dashboards) - Vehicle data - -## Related - -- [[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 -- [[prometheus]] - Metrics datasource -- [[loki]] - Logs datasource -- [[tempo]] - Traces datasource -- [[alloy|Alloy]] - Data collector diff --git a/docs/reference/services/hephaestus.md b/docs/reference/services/hephaestus.md deleted file mode 100644 index 7abc35b..0000000 --- a/docs/reference/services/hephaestus.md +++ /dev/null @@ -1,141 +0,0 @@ ---- -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). -- 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 -`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` diff --git a/docs/reference/services/immich.md b/docs/reference/services/immich.md deleted file mode 100644 index 063deac..0000000 --- a/docs/reference/services/immich.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -title: Immich -modified: 2026-04-04 -last-reviewed: 2026-03-23 -tags: - - service - - media ---- - -# Immich - -Self-hosted photo and video management. - -## Quick Reference - -| Property | Value | -|----------|-------| -| **URL** | https://photos.ops.eblu.me | -| **Namespace** | `immich` | -| **Deployment** | Kustomize (k8s) | -| **Database** | [[postgresql]] (CNPG) | -| **Storage** | [[sifaka|Sifaka]] photos volume | - -## Related - -- [[postgresql]] - Database backend -- [[sifaka|Sifaka]] - Photo storage -- [[jellyfin]] - Video streaming (separate service) diff --git a/docs/reference/services/jellyfin.md b/docs/reference/services/jellyfin.md deleted file mode 100644 index c7b3074..0000000 --- a/docs/reference/services/jellyfin.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -title: Jellyfin -modified: 2026-06-08 -last-reviewed: 2026-06-08 -tags: - - service - - media ---- - -# Jellyfin - -Open-source media server running natively on indri for VideoToolbox hardware transcoding. - -## Quick Reference - -| Property | Value | -|----------|-------| -| **URL** | https://jellyfin.ops.eblu.me | -| **Local Port** | 8096 | -| **Data** | `~/Library/Application Support/jellyfin` | -| **Media** | `/Volumes/allisonflix` (NFS from sifaka) | -| **LaunchAgent** | `mcquack.jellyfin` | - -## Hardware Transcoding - -Apple VideoToolbox on M1 Mac Mini. - -| Codec | Support | -|-------|---------| -| H.264 encode/decode | Hardware | -| HEVC (H.265) encode/decode | Hardware | -| AV1 decode | Software (requires M3+) | -| HDR to SDR tone mapping | VPP (hardware) | - -Concurrent 4K streams with HDR tonemapping: ~3 - -## Configuration - -Dashboard > Playback: -1. Hardware Acceleration: Apple VideoToolbox -2. Allow hardware encoding: Enabled -3. VPP Tone mapping: Enabled - -## Upgrades - -Installed via Homebrew cask (`state: present`, unpinned), so the Ansible role -won't bump an already-installed cask. To upgrade, run on indri: - -```bash -brew upgrade --cask jellyfin -``` - -**Gatekeeper gotcha:** a cask upgrade replaces `/Applications/Jellyfin.app` and -re-applies the `com.apple.quarantine` xattr. When launchd respawns the service, -the new binary hangs silently — process alive but ~0 CPU, no logs, no listening -socket — because Gatekeeper is holding the first launch pending approval. -Removing the xattr over SSH fails (`xattr -dr com.apple.quarantine ...` → -"Operation not permitted", blocked by macOS TCC). Approve the first-launch -dialog on indri's GUI console (or run the `xattr` removal from a local Terminal -with Full Disk Access), then reload the LaunchAgent. - -## Observability - -- Metrics: `jellyfin_metrics` ansible role -- Logs: Forwarded via [[alloy|Alloy]] -- Dashboard: "Jellyfin Media Server" in [[grafana]] - -## Related - -- [[navidrome]] - Music streaming -- [[sifaka|Sifaka]] - Media storage diff --git a/docs/reference/services/kingfisher.md b/docs/reference/services/kingfisher.md deleted file mode 100644 index 7512d6b..0000000 --- a/docs/reference/services/kingfisher.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -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** | `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/` | -| **Upstream** | `forge.eblu.me/mirrors/kingfisher` (GitHub mirror) | - -## What it does - -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. - -## 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 - -- 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 - -- [[prowler]] — CIS Kubernetes, image, and IaC compliance scanning -- [[read-compliance-reports]] — how to access and interpret reports diff --git a/docs/reference/services/kiwix.md b/docs/reference/services/kiwix.md deleted file mode 100644 index 04fe0f6..0000000 --- a/docs/reference/services/kiwix.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -title: Kiwix -modified: 2026-05-04 -last-reviewed: 2026-05-04 -tags: - - service - - knowledge ---- - -# Kiwix - -Offline Wikipedia and ZIM archive server. - -## Quick Reference - -| Property | Value | -|----------|-------| -| **URL** | https://kiwix.ops.eblu.me | -| **Tailscale URL** | https://kiwix.tail8d86e.ts.net | -| **Namespace** | `kiwix` | -| **Image** | `registry.ops.eblu.me/blumeops/kiwix-serve:v3.8.2` | -| **Storage** | NFS from [[sifaka|Sifaka]] (`/volume1/torrents`) | - -## Architecture - -| Component | Purpose | -|-----------|---------| -| kiwix-serve | Serves ZIM files on port 80 | -| torrent-sync | Sidecar syncing ZIM torrents to [[transmission]] | -| zim-watcher | CronJob (hourly) to restart on new ZIMs | - -## Configured Archives - -- Wikipedia top 1M English articles with images -- Project Gutenberg (60,000+ books) -- iFixit repair guides -- Stack Exchange (SuperUser, Math, etc.) -- LibreTexts textbooks -- DevDocs developer documentation - -Full list: `argocd/manifests/kiwix/torrents.txt` - -## Adding Archives - -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]] -5. zim-watcher restarts kiwix when download completes - -## Related - -- [[transmission]] - Downloads ZIM files -- [[sifaka|Sifaka]] - ZIM storage diff --git a/docs/reference/services/loki.md b/docs/reference/services/loki.md deleted file mode 100644 index 2b3b44e..0000000 --- a/docs/reference/services/loki.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -title: Loki -modified: 2026-03-23 -last-reviewed: 2026-03-23 -tags: - - service - - observability ---- - -# Loki - -Log aggregation system for BlumeOps infrastructure. - -## Quick Reference - -| Property | Value | -|----------|-------| -| **URL** | https://loki.ops.eblu.me | -| **Namespace** | `monitoring` | -| **Image** | `registry.ops.eblu.me/blumeops/loki` (see `argocd/manifests/loki/kustomization.yaml` for current tag) | -| **Storage** | 50Gi PVC | -| **Retention** | 31 days | - -## Architecture - -- Single-node deployment with filesystem storage -- TSDB index with 24h period -- Logs collected by [[alloy|Alloy]] and pushed via Loki API -- Queried via [[grafana]] - -## Log Sources - -**From Indri (via Alloy):** -- forgejo, tailscale (brew services) -- alloy, borgmatic, zot, jellyfin (LaunchAgents) - -**From Kubernetes (via Alloy DaemonSet):** -- All pods in all namespaces - -**From Fly.io proxy (via embedded Alloy):** -- nginx JSON access logs (`{instance="flyio-proxy", job="flyio-nginx"}`) - -## Query Examples (LogQL) - -```logql -{service="forgejo"} # All forgejo logs -{service="borgmatic", stream="stderr"} # Borgmatic errors -{host="indri"} |= "error" # All logs containing "error" -{instance="flyio-proxy"} |= "docs.eblu.me" # Fly.io proxy access logs for docs -``` - -## Related - -- [[alloy|Alloy]] - Log collector -- [[grafana]] - Log visualization -- [[prometheus]] - Metrics counterpart diff --git a/docs/reference/services/mealie.md b/docs/reference/services/mealie.md deleted file mode 100644 index fdd0260..0000000 --- a/docs/reference/services/mealie.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -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. - -To restore from a borg archive, see [[restore-from-borg]]. - -## 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 - -- [[plan-a-meal]] — Generate unified cooking timelines from meal plans -- [[authentik]] — OIDC identity provider -- [[ollama]] — LLM backend for meal timeline generation -- [[borgmatic]] — Data backup diff --git a/docs/reference/services/miniflux.md b/docs/reference/services/miniflux.md deleted file mode 100644 index c34e5f7..0000000 --- a/docs/reference/services/miniflux.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -title: Miniflux -modified: 2026-03-23 -last-reviewed: 2026-03-23 -tags: - - service - - rss ---- - -# Miniflux - -Minimalist RSS/Atom feed reader. - -## Quick Reference - -| Property | Value | -|----------|-------| -| **URL** | https://feed.ops.eblu.me | -| **Namespace** | `miniflux` | -| **Image** | `registry.ops.eblu.me/blumeops/miniflux` (see `argocd/manifests/miniflux/kustomization.yaml` for current tag) | -| **Database** | [[postgresql]] | - -## Features - -- Keyboard shortcuts for efficient reading -- Fever and Google Reader API compatible -- Mobile-friendly web interface -- OPML import/export -- Content scraping for full articles - -## Database - -Uses CloudNativePG cluster at `pg.ops.eblu.me`. Database user password stored in `blumeops-pg-app` secret (auto-generated by CNPG). - -## Backup - -Feed subscriptions and read state backed up via [[borgmatic]] PostgreSQL hook. - -## Related - -- [[postgresql]] - Database backend -- [[borgmatic]] - Data backup diff --git a/docs/reference/services/navidrome.md b/docs/reference/services/navidrome.md deleted file mode 100644 index 68a2a21..0000000 --- a/docs/reference/services/navidrome.md +++ /dev/null @@ -1,69 +0,0 @@ ---- -title: Navidrome -modified: 2026-04-18 -last-reviewed: 2026-04-18 -tags: - - service - - media ---- - -# Navidrome - -Self-hosted music streaming server. - -## Quick Reference - -| Property | Value | -|----------|-------| -| **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 - -| Mount | Type | Source | Access | -|-------|------|--------|--------| -| /music | NFS PV | sifaka:/volume1/music | Read-only | -| /data | Local PVC (10Gi) | minikube storage | Read-write | - -The `/data` directory contains SQLite database, configuration, and cache. - -## Configuration - -| Variable | Value | -|----------|-------| -| `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 - -- [[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 diff --git a/docs/reference/services/ntfy.md b/docs/reference/services/ntfy.md deleted file mode 100644 index 1bf45af..0000000 --- a/docs/reference/services/ntfy.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -title: Ntfy -modified: 2026-06-04 -last-reviewed: 2026-06-04 -tags: - - service - - notifications ---- - -# Ntfy - -Self-hosted push notification service. Ntfy receives HTTP POST messages and delivers them to subscribed clients (mobile apps, web UI, CLI). - -## Quick Reference - -| Property | Value | -|----------|-------| -| **URL** | https://ntfy.ops.eblu.me | -| **Tailscale URL** | https://ntfy.tail8d86e.ts.net | -| **Namespace** | `ntfy` | -| **Image** | `registry.ops.eblu.me/blumeops/ntfy:v2.19.2-fd0bebb-nix` (locally built) | -| **Upstream** | https://github.com/binwiederhier/ntfy | -| **Manifests** | `argocd/manifests/ntfy/` | - -## Architecture - -Ntfy runs as a single pod with no persistent storage — message cache and attachments use an `emptyDir` volume. This is intentional: ntfy is treated as an ephemeral delivery channel, not a message store. Messages lost on pod restart are acceptable. - -The upstream relay (`ntfy.sh`) is configured so mobile app clients can receive push notifications via Google FCM / Apple APNs without self-hosting those integrations. - -## Producers - -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 → frigate-notify (webapi polling) → ntfy → mobile clients -``` - -The frigate-notify config points to ntfy's cluster-internal address: - -``` -http://ntfy.ntfy.svc.cluster.local:80 -``` - -Other services could publish to ntfy in the future — any HTTP client can POST to a topic. - -## Configuration - -Server config is in a ConfigMap (`ntfy-config`): - -| Setting | Value | -|---------|-------| -| `base-url` | `https://ntfy.ops.eblu.me` | -| `upstream-base-url` | `https://ntfy.sh` | -| `attachment-total-size-limit` | 1 GB | -| `attachment-file-size-limit` | 10 MB | -| `attachment-expiry-duration` | 24h | - -No authentication is configured — access is restricted by Tailscale ACLs (only tailnet clients can reach the service). - -## Related - -- [[routing]] - How ntfy is exposed via Caddy -- [[observability]] - Monitoring and alerting infrastructure diff --git a/docs/reference/services/nvidia-device-plugin.md b/docs/reference/services/nvidia-device-plugin.md deleted file mode 100644 index 7eb28d9..0000000 --- a/docs/reference/services/nvidia-device-plugin.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -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/docs/reference/services/ollama.md b/docs/reference/services/ollama.md deleted file mode 100644 index b749cf2..0000000 --- a/docs/reference/services/ollama.md +++ /dev/null @@ -1,92 +0,0 @@ ---- -title: Ollama -modified: 2026-05-01 -last-reviewed: 2026-05-01 -tags: - - service - - ai ---- - -# Ollama - -LLM inference server with GPU acceleration. Runs on [[ringtail]] with declarative model management via a sidecar. - -## Quick Reference - -| Property | Value | -|----------|-------| -| **URL** | https://ollama.ops.eblu.me | -| **Tailscale URL** | https://ollama.tail8d86e.ts.net | -| **Namespace** | `ollama` | -| **Cluster** | ringtail k3s | -| **Image** | `ollama/ollama:0.20.4` | -| **Upstream** | https://github.com/ollama/ollama | -| **Manifests** | `argocd/manifests/ollama/` | -| **API Port** | 11434 | - -## Architecture - -``` -models.txt (ConfigMap, declarative) - │ - ▼ -model-sync sidecar ──ollama pull──► Ollama server (GPU) - │ │ - │ reads /config/models.txt │ serves /api/* - │ polls every 30 min │ NVIDIA runtime (RTX 4080, time-sliced) - │ │ - └────────────────────────────────────┘ - │ - /models (200 Gi hostPath PV) - /mnt/storage1/ollama on ringtail -``` - -## Models - -Declared in `argocd/manifests/ollama/models.txt`. The model-sync sidecar pulls missing models on startup and every 30 minutes. - -| Model | Parameters | -|-------|------------| -| `qwen2.5:14b` | 14B | -| `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. - -## GPU - -Shares [[ringtail]]'s RTX 4080 with [[frigate]] via NVIDIA device plugin time-slicing (2 virtual slots). Constrained to one loaded model and one parallel request to avoid VRAM contention. - -| Setting | Value | -|---------|-------| -| `OLLAMA_MAX_LOADED_MODELS` | 1 | -| `OLLAMA_NUM_PARALLEL` | 1 | -| GPU limit | `nvidia.com/gpu: "1"` (time-sliced) | - -## Storage - -| Mount | Backend | Size | -|-------|---------|------| -| `/models` | hostPath PV (`/mnt/storage1/ollama`) | 200 Gi | - -PV reclaim policy is `Retain` — models survive PV deletion. - -## Networking - -| Endpoint | Reachable from | -|----------|----------------| -| `https://ollama.ops.eblu.me` | Public internet (Fly.io → Caddy) | -| `https://ollama.tail8d86e.ts.net` | Tailnet clients | -| `http://ollama.ollama.svc.cluster.local:11434` | In-cluster (ringtail) | - -Tailscale ingress uses ProxyGroup `ingress` — no explicit `host:` field (see [[tailscale-operator]]). - -## Related - -- [[frigate]] — Shares GPU via time-slicing -- [[ringtail]] — Host node -- [[apps]] — ArgoCD application registry -- [[tailscale-operator]] — Tailscale ingress diff --git a/docs/reference/services/paperless.md b/docs/reference/services/paperless.md deleted file mode 100644 index c74543e..0000000 --- a/docs/reference/services/paperless.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -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/docs/reference/services/postgresql.md b/docs/reference/services/postgresql.md deleted file mode 100644 index ef86418..0000000 --- a/docs/reference/services/postgresql.md +++ /dev/null @@ -1,74 +0,0 @@ ---- -title: PostgreSQL -modified: 2026-04-07 -last-reviewed: 2026-04-07 -tags: - - service - - database ---- - -# PostgreSQL - -Database clusters via CloudNativePG operator. - -## Quick Reference - -| Property | Value | -|----------|-------| -| **URL** | `tcp://pg.ops.eblu.me:5432` | -| **Metrics** | `http://cnpg-metrics.tail8d86e.ts.net:9187/metrics` | -| **Namespace** | `databases` | -| **Clusters** | `blumeops-pg`, `immich-pg` | -| **Operator** | CloudNativePG | - -## Databases - -| Database | Cluster | Owner | Purpose | -|----------|---------|-------|---------| -| 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 | Cluster | Role | Purpose | -|------|---------|------|---------| -| postgres | both | superuser | CNPG internal | -| miniflux | blumeops-pg | app owner | Owns miniflux database | -| 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 | - -## Backup - -Backed up via [[borgmatic]] `postgresql_databases` hook. Streams `pg_dump` directly to Borg (no intermediate files, no downtime). See [[backup]] for overall backup policy. - -## Credentials - -**1Password items:** -- `guxu3j7ajhjyey6xxl2ovsl2ui` - eblume password -- `mw2bv5we7woicjza7hc6s44yvy` - borgmatic password - -**CNPG-managed secrets (blumeops-pg):** -- `blumeops-pg-app` - miniflux user -- `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 - -- [[connect-to-postgres]] - How to connect via psql -- [[miniflux]] - Feed reader database -- [[teslamate]] - Vehicle data database -- [[immich]] - Photo management database -- [[authentik]] - Identity provider database -- [[borgmatic]] - Database backup diff --git a/docs/reference/services/prometheus.md b/docs/reference/services/prometheus.md deleted file mode 100644 index 4d23588..0000000 --- a/docs/reference/services/prometheus.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -title: Prometheus -modified: 2026-03-23 -last-reviewed: 2026-03-23 -tags: - - service - - observability ---- - -# Prometheus - -Metrics storage and querying for BlumeOps infrastructure. - -## Quick Reference - -| Property | Value | -|----------|-------| -| **URL** | https://prometheus.ops.eblu.me | -| **Namespace** | `monitoring` | -| **Image** | `registry.ops.eblu.me/blumeops/prometheus` (see `argocd/manifests/prometheus/kustomization.yaml` for current tag) | -| **Storage** | 50Gi PVC | -| **Manifests** | `argocd/manifests/prometheus/` | - -## Data Sources - -### Remote Write (from Alloy) -- Indri system metrics via [[alloy|Alloy]] remote_write -- Textfile metrics: minikube, borgmatic, zot, jellyfin -- [[flyio-proxy]] nginx metrics (`flyio_nginx_*`) via Alloy embedded in Fly.io container - -### Scrape Targets - -| Target | Metrics | -|--------|---------| -| `sifaka:9100` | [[sifaka|Sifaka]] NAS (node_exporter) | -| `blumeops-pg-metrics-tailscale.databases.svc.cluster.local:9187` | [[postgresql|CloudNativePG]] metrics | -| `kube-state-metrics.monitoring.svc:8080` | Kubernetes resource metrics | - -## Related - -- [[alloy|Alloy]] - Metrics collector -- [[grafana]] - Visualization -- [[loki]] - Logs counterpart diff --git a/docs/reference/services/prowler.md b/docs/reference/services/prowler.md deleted file mode 100644 index 9f7e4b3..0000000 --- a/docs/reference/services/prowler.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -title: Prowler -modified: 2026-06-08 -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** | K8s CIS: Sunday 3am | -| **Reports** | `sifaka:/volume1/reports/prowler/` (NFS) | -| **Manifests** | `argocd/manifests/prowler/` | - -## What it does - -Runs Prowler 5 as a single CronJob: - -- **K8s CIS scan** (Sunday) — 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. - -The **image** and **IaC** scans (formerly Saturday CronJobs) were retired in 2026-06 — they generated tens of thousands of un-actioned findings weekly. See [[deploy-prowler#Why only the K8s CIS scan]]. - -## 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/docs/reference/services/shower-app.md b/docs/reference/services/shower-app.md deleted file mode 100644 index 26d1764..0000000 --- a/docs/reference/services/shower-app.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -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/<token>/` 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/reference/services/snowflake-proxy.md b/docs/reference/services/snowflake-proxy.md deleted file mode 100644 index 2322c5f..0000000 --- a/docs/reference/services/snowflake-proxy.md +++ /dev/null @@ -1,74 +0,0 @@ ---- -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/docs/reference/services/tempo.md b/docs/reference/services/tempo.md deleted file mode 100644 index 5eb5d87..0000000 --- a/docs/reference/services/tempo.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -title: Tempo -modified: 2026-06-04 -last-reviewed: 2026-06-04 -tags: - - service - - observability ---- - -# Grafana Tempo - -Distributed tracing backend for BlumeOps infrastructure. Receives traces via OTLP, stores them locally, and generates RED metrics (rate, error, duration) for [[prometheus]]. - -## Quick Reference - -| Property | Value | -|----------|-------| -| **URL** | https://tempo.ops.eblu.me (when Caddy route added) | -| **Tailscale URL** | https://tempo.tail8d86e.ts.net | -| **OTLP Endpoint** | https://tempo-otlp.tail8d86e.ts.net | -| **Namespace** | `monitoring` | -| **Image** | `registry.ops.eblu.me/blumeops/tempo:v2.10.3-75f9ba4` (locally built) | -| **Storage** | 10Gi PVC (local filesystem) | -| **Retention** | 7 days | - -## Architecture - -- Single-node deployment with local filesystem storage -- OTLP receivers: gRPC (4317) and HTTP (4318) -- `metrics_generator` produces span-metrics and service-graphs, remote-written to [[prometheus]] -- Queried via [[grafana]] Tempo datasource -- Two Tailscale Ingresses: one for query API (3200), one for OTLP HTTP receiver (4318) - -## Trace Sources - -**From ringtail (via Beyla eBPF in Alloy):** - -| Service | Protocol | Coverage | -|---------|----------|----------| -| [[frigate]] | HTTP REST | Request rate, error rate, latency, trace spans | -| [[ntfy]] | HTTP | Same | -| [[ollama]] | HTTP REST | Same (model inference latency) | -| [[immich]] | HTTP REST | Same | - -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. - -## Storage Monitoring - -Tempo exposes `tempodb_backend_bytes_total` via its `/metrics` endpoint (scraped by [[prometheus]]). To check storage utilization against the 10Gi PVC: - -```promql -tempodb_backend_bytes_total / 10737418240 * 100 -``` - -Full PVC-level monitoring (via kubelet volume stats) is not yet available — see backlog. - -## Grafana Integration - -- **Tempo datasource** with trace-to-log and trace-to-metrics correlation -- **Service map** and **node graph** visualization -- **Loki derived fields** link trace IDs in logs back to Tempo - -## Related - -- [[alloy|Alloy]] - Trace collector (Beyla eBPF on ringtail) -- [[prometheus]] - Receives span-metrics from Tempo -- [[loki]] - Log correlation via trace IDs -- [[grafana]] - Trace visualization diff --git a/docs/reference/services/teslamate.md b/docs/reference/services/teslamate.md deleted file mode 100644 index a11ed0e..0000000 --- a/docs/reference/services/teslamate.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -title: TeslaMate -modified: 2026-04-07 -last-reviewed: 2026-03-23 -tags: - - service - - vehicle ---- - -# TeslaMate - -Self-hosted Tesla data logger collecting vehicle telemetry from the Tesla API. - -## Quick Reference - -| Property | Value | -|----------|-------| -| **URL** | https://tesla.ops.eblu.me | -| **Namespace** | `teslamate` | -| **Image** | `registry.ops.eblu.me/blumeops/teslamate` (see `argocd/manifests/teslamate/kustomization.yaml` for current tag) | -| **Database** | [[postgresql]] | - -## Data Collected - -- Battery level, state of charge, range estimates -- Charging sessions (location, energy, cost, duration) -- Drives (distance, efficiency, routes) -- Climate/HVAC usage -- Software update history -- Vampire drain analysis -- Vehicle states (asleep, driving, charging, online) - -## Grafana Dashboards - -18 dashboards in the "TeslaMate" folder: -- Overview, Charges, Drives, Efficiency, States -- Battery Health, Vampire Drain, Statistics -- Charge Level, Locations, Trip, Mileage -- Drive Stats, Charging Stats, Projected Range -- Timeline, Updates, Visited - -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 - -Uses Tesla Owner API via OAuth: -1. Access https://tesla.ops.eblu.me -2. Click "Sign in with Tesla" -3. Tokens encrypted with ENCRYPTION_KEY - -## Credentials - -**1Password:** `TeslaMate` item with `db_password` and `api_enc_key` - -## Related - -- [[postgresql]] - Data storage -- [[grafana]] - Dashboards -- [[borgmatic]] - Database backup diff --git a/docs/reference/services/transmission.md b/docs/reference/services/transmission.md deleted file mode 100644 index 89904ce..0000000 --- a/docs/reference/services/transmission.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -title: Transmission -modified: 2026-04-29 -last-reviewed: 2026-04-29 -tags: - - service - - torrent ---- - -# Transmission - -BitTorrent daemon, primarily for downloading ZIM archives for [[kiwix]]. - -## Quick Reference - -| Property | Value | -|----------|-------| -| **URL** | https://torrent.ops.eblu.me | -| **Tailscale URL** | https://torrent.tail8d86e.ts.net | -| **Namespace** | `torrent` | -| **Image** | `registry.ops.eblu.me/blumeops/transmission` | -| **Storage** | NFS PVC from [[sifaka|Sifaka]] | - -## Storage Layout - -| 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 | - -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. - -## Integration with Kiwix - -The Kiwix deployment includes a torrent-sync sidecar that: -1. Reads ZIM torrent list from ConfigMap -2. Adds missing torrents via RPC -3. Runs on startup and every 30 minutes - -When downloads complete, the zim-watcher CronJob detects new ZIMs and restarts Kiwix. - -## Monitoring - -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. - -## Related - -- [[kiwix]] - ZIM archive consumer -- [[sifaka|Sifaka]] - Download storage diff --git a/docs/reference/services/zot.md b/docs/reference/services/zot.md deleted file mode 100644 index b01a6ce..0000000 --- a/docs/reference/services/zot.md +++ /dev/null @@ -1,70 +0,0 @@ ---- -title: Zot -modified: 2026-03-14 -tags: - - service - - registry ---- - -# Zot - -OCI-native container registry providing pull-through cache and private image storage. - -## Quick Reference - -| Property | Value | -|----------|-------| -| **URL** | https://registry.ops.eblu.me | -| **Local Port** | 5050 | -| **Data** | `~/zot` | -| **Config** | `~/.config/zot/config.json` | -| **LaunchAgent** | mcquack | - -## Namespace Convention - -| Path | Source | -|------|--------| -| `registry.ops.eblu.me/docker.io/*` | Cached from Docker Hub | -| `registry.ops.eblu.me/ghcr.io/*` | Cached from GHCR | -| `registry.ops.eblu.me/quay.io/*` | Cached from Quay | -| `registry.ops.eblu.me/blumeops/*` | Private images | - -## Pull-Through Cache - -When [[cluster|minikube]] pulls an image, containerd checks zot first. If cached, returns immediately. If not, zot fetches from upstream, caches it, then returns. - -## Security Model - -OIDC authentication via [[authentik]], with API key support for CI. Three-tier access control: - -| Role | Permissions | Use case | -|------|------------|----------| -| Anonymous | read | Pull images without auth | -| `artifact-workloads` group | read, create | CI push (new tags only, no overwrite/delete) | -| `admins` group | read, create, update, delete | Break-glass admin access | - -CI authenticates with a zot API key generated from the `zot-ci` service account's OIDC session. The key is stored in the `Forgejo Secrets` 1Password item (field `zot-ci-api`) and synced to Forgejo Actions secrets via ansible. - -## API Key Rotation - -The `zot-ci` API key expires every **90 days**. To rotate: - -1. In Authentik admin UI, impersonate the `zot-ci` user -2. Visit `https://registry.ops.eblu.me` — you'll land on the login page -3. Click "SIGN IN WITH OIDC" to authenticate as zot-ci -4. Navigate to `https://registry.ops.eblu.me/user/apikey` -5. Generate a new API key, copy it to clipboard -6. Update 1Password: - ```fish - 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 - -- [[forgejo]] - Container build CI -- [[cluster|Cluster]] - Registry consumer -- [[authentik]] - OIDC identity provider -- [[harden-zot-registry]] - Security hardening guide -- [[service-versions]] - Version tracking for deployed services diff --git a/docs/reference/storage/backups.md b/docs/reference/storage/backups.md deleted file mode 100644 index 2dfbae4..0000000 --- a/docs/reference/storage/backups.md +++ /dev/null @@ -1,97 +0,0 @@ ---- -title: Backups -modified: 2026-03-27 -tags: - - storage - - backup ---- - -# Backup Policy - -Daily automated backups from [[indri]] to [[sifaka|Sifaka]] NAS. - -## Schedule - -| Time | Frequency | System | -|------|-----------|--------| -| 2:00 AM | Daily | [[borgmatic]] | - -## What Gets Backed Up - -### Directories - -| Path | Description | Priority | -|------|-------------|----------| -| `~/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 | - -### Databases - -| 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 | - -## 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 - -Other data lives directly on [[sifaka]] (music via [[navidrome]], video via [[jellyfin]]). See [[sifaka]] for data protection details. - -## What Is NOT Backed Up - -| Data | Reason | -|------|--------| -| ZIM archives (`~/transmission/`) | Re-downloadable via torrent | -| Prometheus metrics | Ephemeral, in k8s PVC | -| Loki logs | Ephemeral, in k8s PVC | -| devpi cache (`~/devpi/server-dir/` on indri) | Re-fetchable from PyPI on first request | - -## Retention Policy - -| Period | Retention | -|--------|-----------| -| Daily | 7 backups | -| Monthly | 12 backups | -| Yearly | 1000 backups | - -## Backup Targets - -| 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 - -Metrics exposed to [[prometheus]]: -- `borgmatic_up` - Repository accessible -- `borgmatic_last_archive_timestamp` - Last backup time -- `borgmatic_repo_deduplicated_size_bytes` - Disk usage - -Dashboard: "Borgmatic Backups" in [[grafana]] - -## Related - -- [[borgmatic]] - Backup system details -- [[sifaka|Sifaka]] - Backup storage -- [[postgresql]] - Database backups -- [[restore-1password-backup]] - Recover 1Password from backup diff --git a/docs/reference/storage/sifaka.md b/docs/reference/storage/sifaka.md deleted file mode 100644 index b3387c1..0000000 --- a/docs/reference/storage/sifaka.md +++ /dev/null @@ -1,132 +0,0 @@ ---- -title: Sifaka -modified: 2026-03-28 -last-reviewed: 2026-03-28 -tags: - - storage ---- - -# Sifaka NAS - -Synology NAS providing network storage and backup target. - -## Quick Reference - -| Property | Value | -|----------|-------| -| **Dashboard** | https://nas.ops.eblu.me | -| **Model** | Synology DS423+ (DSM 7) | -| **Storage** | 10.9TB RAID 5 (4x Seagate IronWolf 4TB, ST4000VN006) | -| **Role** | Backup target, media storage | - -## Network Shares - -| Share | Path | Purpose | Consumers | -|-------|------|---------|-----------| -| backups | `/volume1/backups` | Borg backup repository | [[borgmatic]] | -| torrents | `/volume1/torrents` | ZIM downloads | [[kiwix]], [[transmission]] | -| 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 | 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 - -Prometheus exporters run as Docker containers, managed by Ansible (`mise run provision-sifaka`). - -| Exporter | Port | Purpose | -|----------|------|---------| -| node_exporter | 9100 | System metrics (CPU, memory, disk I/O) | -| smartctl_exporter | 9633 | SMART disk health data | - -Scraped by [[prometheus]] via Caddy L4 TCP proxy at `nas.ops.eblu.me:9100` and `nas.ops.eblu.me:9633`. Dashboard: [[grafana]] > Sifaka Disk Health. - -## First-Time Setup - -These steps were performed once to enable Ansible provisioning. They are documented here for reference if sifaka is ever replaced or reset. - -### 1. Enable SSH - -DSM Control Panel > Terminal & SNMP > Enable SSH service (port 22). - -### 2. SSH Key Authentication - -From a tailnet client with an existing SSH key: - -```bash -ssh-copy-id eblume@sifaka # uses password auth initially -``` - -Synology requires strict permissions on the home directory. On sifaka: - -```bash -chmod 755 ~ # DSM defaults to 777; SSH refuses keys otherwise -chmod 700 ~/.ssh -chmod 600 ~/.ssh/authorized_keys -``` - -Home directory path: `/var/services/homes/eblume`. - -### 3. Passwordless Sudo for Docker - -Ansible needs `become: true` for Docker commands. Create a sudoers drop-in: - -```bash -sudo vi /etc/sudoers.d/docker-ansible -``` - -Contents: - -``` -eblume ALL=(ALL) NOPASSWD: /volume1/@appstore/ContainerManager/usr/bin/docker -``` - -This grants passwordless sudo only for the Docker binary — no broader root access. - -### 4. Docker Path - -Synology installs Docker via Container Manager at a non-standard path: - -``` -/volume1/@appstore/ContainerManager/usr/bin/docker -``` - -This is configured in the `sifaka_exporters` role defaults. - -### 5. Synology Device Naming - -Synology uses `/dev/sata*` (e.g., `/dev/sata1` through `/dev/sata4`) instead of the standard `/dev/sd*` naming. The `smartctl_exporter` cannot auto-detect these devices, so they are passed explicitly via `--smartctl.device=` flags in the Ansible role. - -## Tailscale - -- Tag: `tag:nas` -- ACL: `tag:homelab` can access for backups -- **Must run in TUN mode** for NFS — see [[troubleshoot-sifaka-nfs]] - -## Backup - -Sifaka is the **target** for [[backup|backups]], not a backup source. [[borgmatic]] sends backups TO sifaka, not OF sifaka. - -Data protection for sifaka itself currently relies on the Synology RAID 5 configuration, which provides single-disk fault tolerance. Future plans include offsite duplication for additional resiliency. - -## 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 diff --git a/docs/reference/tools/ansible.md b/docs/reference/tools/ansible.md deleted file mode 100644 index 7c0ebc9..0000000 --- a/docs/reference/tools/ansible.md +++ /dev/null @@ -1,76 +0,0 @@ ---- -title: Ansible -modified: 2026-03-30 -last-reviewed: 2026-03-30 -tags: - - ansible - - reference ---- - -# Ansible - -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 - -```bash -# Full provisioning -mise run provision-indri - -# Specific role only -mise run provision-indri -- --tags caddy - -# Dry run (preview changes) -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 | -|------|---------|---------| -| **alloy** | Observability collector | [[alloy]] | -| **borgmatic** | Backup automation | [[borgmatic]] | -| **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]] | -| **minikube_metrics** | Cluster metrics | [[cluster]] | -| **zot** | Container registry | [[zot]] | -| **zot_metrics** | Registry metrics | [[zot]] | - -## Role Structure - -Each role follows Ansible conventions: -``` -ansible/roles/<role>/ -├── defaults/main.yml # Default variables -├── tasks/main.yml # Task definitions -├── handlers/main.yml # Handlers (restarts, etc.) -├── templates/ # Jinja2 templates -└── files/ # Static files -``` - -## Secrets - -Roles that need secrets use 1Password via the playbook's `pre_tasks`. Secrets are gathered at playbook start and passed to roles as variables. - -## Related - -- [[indri]] — Primary managed host -- [[ringtail]] — NixOS host managed by its own playbook -- [[sifaka]] — Synology NAS managed by its own playbook -- [[observability]] — Metrics collection diff --git a/docs/reference/tools/argocd-cli.md b/docs/reference/tools/argocd-cli.md deleted file mode 100644 index a2aa223..0000000 --- a/docs/reference/tools/argocd-cli.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -title: ArgoCD CLI -modified: 2026-02-12 -last-reviewed: 2026-04-01 -tags: - - reference - - gitops - - argocd ---- - -# ArgoCD CLI - -Command-line workflows for deploying and managing applications via [[argocd]]. - -## CLI Commands - -```bash -argocd app list # List all applications and sync status -argocd app get <app> # Show app details, health, and resources -argocd app diff <app> # Preview what would change on sync -argocd app sync <app> # Apply pending changes -argocd app sync apps # Sync the app-of-apps (picks up new Application manifests) -``` - -## 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 \ - --password "$(op read 'op://vg6xf6vvfmoh5hqjjhlhbeoaie/srogeebssulhtb6tnqd7ls6qey/password')" -``` - -## Branch-Testing Workflow - -Test changes from a feature branch before merging: - -```bash -# 1. Point the app at your branch -argocd app set <service> --revision <branch> - -# 2. Sync to deploy the branch version -argocd app sync <service> - -# 3. Test the changes... - -# 4. After merge, reset to main and sync -argocd app set <service> --revision main -argocd app sync <service> -``` - -## Related - -- [[argocd]] — Service reference (URLs, credentials, sync policy) -- [[apps]] — Full application registry -- [[forgejo]] — Git source diff --git a/docs/reference/tools/dagger.md b/docs/reference/tools/dagger.md deleted file mode 100644 index 81c5caf..0000000 --- a/docs/reference/tools/dagger.md +++ /dev/null @@ -1,104 +0,0 @@ ---- -title: Dagger -modified: 2026-04-11 -tags: - - reference - - ci-cd - - dagger ---- - -# Dagger - -Build engine for BlumeOps CI/CD pipelines. Replaces shell-based build scripts with Python functions that run identically locally and in CI. - -## Quick Reference - -| Property | Value | -|----------|-------| -| **Module** | `blumeops` | -| **Engine Version** | v0.20.6 | -| **SDK** | Python | -| **Source** | `src/blumeops/main.py` | -| **Config** | `dagger.json` (source: `.`) | - -## Functions - -| Function | Signature | Description | -|----------|-----------|-------------| -| `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/<name>/default.nix`, return docker-archive tarball | -| `nix_version` | `(package) → str` | Extract the version of a nixpkgs package | -| `build_docs` | `(src, version) → File` | Build Quartz docs site, return docs tarball | -| `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 -# Build a container -dagger call build --src=. --container-name=miniflux - -# Drop into container shell for inspection -dagger call build --src=. --container-name=miniflux terminal - -# Debug a failure interactively -dagger call --interactive build --src=. --container-name=miniflux - -# Publish a container to zot -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 - -# Check a nixpkgs package version -dagger call nix-version --package=authentik - -# Build docs tarball locally -dagger call build-docs --src=. --version=dev export --path=./docs-dev.tar.gz - -# Debug a docs build failure -dagger call --interactive build-docs --src=. --version=dev - -# Update all ringtail flake inputs -dagger call flake-update --src=. --flake-path=nixos/ringtail \ - export --path=nixos/ringtail/flake.lock -``` - -## Secrets - -Dagger has a first-class `Secret` type — values are never logged or cached. Pass secrets from environment variables using the `env:VAR` syntax: - -```bash -dagger call release-docs \ - --src=. --version=v1.6.0 \ - --forgejo-token=env:FORGEJO_TOKEN \ - --argocd-token=env:ARGOCD_TOKEN -``` - -In [[forgejo]] Actions, secrets are injected as env vars. Locally, mise tasks call `op read` to populate them. - -## Caveats - -- **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 - -- [[forgejo]] — CI/CD trigger layer -- [[zot]] — Container registry (publish target) -- [[docs]] — Documentation site (build target) -- [[manage-lockfile]] — Ringtail flake lockfile management diff --git a/docs/reference/tools/mise-tasks.md b/docs/reference/tools/mise-tasks.md deleted file mode 100644 index b614cb1..0000000 --- a/docs/reference/tools/mise-tasks.md +++ /dev/null @@ -1,93 +0,0 @@ ---- -title: Mise Tasks -modified: 2026-04-11 -tags: - - reference - - tools - - mise ---- - -# Mise Tasks - -Operational tasks for BlumeOps, run via `mise run <task>`. Tasks live in `mise-tasks/` and use `#USAGE` directives for argument parsing. - -Run `mise tasks --sort name` for the live list with descriptions. - -## AI & Documentation - -| Task | Description | -|------|-------------| -| `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) | -| `docs-review` | Review the most stale doc by `last-reviewed` date | -| `docs-review-stale` | Report docs by last-modified date | -| `docs-review-tags` | Print frontmatter tag inventory | - -## Deployment & Provisioning - -| Task | Description | -|------|-------------| -| `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 (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]] | -| `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]] | - -## Containers & Registry - -| Task | Description | -|------|-------------| -| `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 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 | - -## Git & Forge - -| Task | Description | -|------|-------------| -| `branch-cleanup` | Delete merged branches (local and remote) | -| `pr-comments` | List unresolved PR comments | -| `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 | - -## Operations & Monitoring - -| Task | Description | -|------|-------------| -| `services-check` | Check all services are online and responding | -| `service-review` | Review the most stale service for version freshness | -| `op-backup` | Encrypt 1Password export and send to indri for borgmatic | - -## Infrastructure Setup - -| Task | Description | -|------|-------------| -| `ensure-minikube-indri-kubectl-config` | Set up kubectl config for minikube-indri | -| `ensure-k3s-ringtail-kubectl-config` | Set up kubectl config for k3s-ringtail | - -## ML & Hardware - -| Task | Description | -|------|-------------| -| `frigate-export-model` | Export YOLOv9 model weights to ONNX via [[dagger]] | - -## Related - -- [[dagger]] — CI/CD build engine (containers, docs) -- [[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/pulumi.md b/docs/reference/tools/pulumi.md deleted file mode 100644 index a716bb9..0000000 --- a/docs/reference/tools/pulumi.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -title: Pulumi -modified: 2026-04-02 -last-reviewed: 2026-04-02 -tags: - - reference - - iac - - pulumi ---- - -# Pulumi - -Infrastructure-as-Code for DNS and Tailscale ACL management. Two independent projects, both using the Python SDK with uv toolchain. - -## Projects - -| Project | Stack | Source | Manages | -|---------|-------|--------|---------| -| `blumeops-dns` | `eblu-me` | `pulumi/gandi/` | DNS records for `eblu.me` via Gandi LiveDNS | -| `blumeops-tailnet` | `tail8d86e` | `pulumi/tailscale/` | ACL policy, device tags, auth keys | - -### DNS (`blumeops-dns`) - -Manages `*.ops.eblu.me` wildcard and base records pointing to [[indri]]'s Tailscale IP, plus public CNAME records for services routed via [[flyio-proxy]]. - -### Tailnet (`blumeops-tailnet`) - -Manages the ACL policy (`policy.hujson`), device tags for [[indri]] and [[sifaka]], and auth keys for the Fly.io proxy. - -## CLI Patterns - -All operations use mise tasks that wrap `pulumi` with the correct stack and working directory: - -```bash -# DNS -mise run dns-preview # Preview DNS changes -mise run dns-up # Apply DNS changes - -# Tailscale -mise run tailnet-preview # Preview ACL/tag changes -mise run tailnet-up # Apply ACL/tag changes -``` - -## Authentication - -- **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 - -- [[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 -- [[routing]] — How DNS records map to services diff --git a/docs/reference/tools/qart-tuner.md b/docs/reference/tools/qart-tuner.md deleted file mode 100644 index a05c302..0000000 --- a/docs/reference/tools/qart-tuner.md +++ /dev/null @@ -1,61 +0,0 @@ ---- -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/docs/tutorials/adding-a-service.md b/docs/tutorials/adding-a-service.md deleted file mode 100644 index 52d4965..0000000 --- a/docs/tutorials/adding-a-service.md +++ /dev/null @@ -1,319 +0,0 @@ ---- -title: Adding a Service -modified: 2026-04-08 -last-reviewed: 2026-04-08 -tags: - - tutorials - - argocd - - kubernetes ---- - -# Adding an ArgoCD-Managed Service - -> **Audiences:** Contributor, Replicator - -This tutorial walks through deploying a new service to BlumeOps via ArgoCD, including ingress configuration, homepage integration, and observability setup. - -## Prerequisites - -- Access to the [[tailscale|Tailscale]] network -- `kubectl` configured with `minikube-indri` context -- `argocd` CLI installed (via Brewfile: `brew bundle`) - -## Overview - -Adding a service involves: -1. Creating Kubernetes manifests -2. Creating an ArgoCD Application -3. Configuring Tailscale ingress -4. Adding Homepage dashboard entry -5. Creating a reference card -6. Setting up Grafana dashboards (optional) - -## Step 1: Create Manifests Directory - -Create a directory for your service's Kubernetes manifests: - -``` -argocd/manifests/<service-name>/ -├── 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 -# argocd/manifests/myservice/deployment.yaml -apiVersion: apps/v1 -kind: Deployment -metadata: - name: myservice - namespace: myservice -spec: - replicas: 1 - selector: - matchLabels: - app: myservice - template: - metadata: - labels: - app: myservice - spec: - containers: - - name: myservice - image: registry.ops.eblu.me/myservice:kustomized - ports: - - containerPort: 8080 -``` - -### Example Service - -```yaml -# argocd/manifests/myservice/service.yaml -apiVersion: v1 -kind: Service -metadata: - name: myservice - namespace: myservice -spec: - selector: - app: myservice - ports: - - port: 80 - targetPort: 8080 -``` - -## Step 2: Configure Tailscale Ingress - -Create an Ingress to expose the service via Tailscale. See [[tailscale-operator]] for details. - -```yaml -# argocd/manifests/myservice/ingress-tailscale.yaml -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: myservice - namespace: myservice -spec: - ingressClassName: tailscale - tls: - - hosts: - - myservice - rules: - - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: myservice - port: - number: 80 -``` - -This exposes the service at `https://myservice.tail8d86e.ts.net`. - -## Step 3: Add Homepage Annotations - -Add annotations to the Ingress for automatic Homepage dashboard discovery: - -```yaml -metadata: - annotations: - gethomepage.dev/enabled: "true" - gethomepage.dev/name: "My Service" - gethomepage.dev/group: "Apps" - gethomepage.dev/icon: "myservice.png" - gethomepage.dev/description: "Short description" - gethomepage.dev/href: "https://myservice.ops.eblu.me" - gethomepage.dev/pod-selector: "app=myservice" -``` - -Icons use [Dashboard Icons](https://github.com/walkxcode/dashboard-icons) format. - -## Step 4: Create ArgoCD Application - -Create an Application manifest to tell ArgoCD about your service: - -```yaml -# argocd/apps/myservice.yaml -apiVersion: argoproj.io/v1alpha1 -kind: Application -metadata: - name: myservice - namespace: argocd -spec: - project: default - source: - repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git - targetRevision: main - path: argocd/manifests/myservice - destination: - server: https://kubernetes.default.svc - namespace: myservice - syncPolicy: - syncOptions: - - CreateNamespace=true -``` - -## Step 5: Create a Reference Card - -Add a reference card at `docs/reference/services/<service-name>.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`: - -```yaml -caddy_services: - # ... existing services ... - - name: myservice - upstream: "https://myservice.tail8d86e.ts.net" -``` - -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 7: Deploy - -### Testing on a Feature Branch - -For new services, point ArgoCD at your feature branch first: - -```bash -# Sync the apps application to pick up your new Application -argocd app sync apps - -# Point your app at the feature branch -argocd app set myservice --revision feature/your-branch -argocd app sync myservice -``` - -### Verify Deployment - -```bash -kubectl --context=minikube-indri -n myservice get pods -kubectl --context=minikube-indri -n myservice logs -f deployment/myservice -``` - -### After PR Merge - -Reset to main branch: -```bash -argocd app set myservice --revision main -argocd app sync myservice -``` - -## Step 8: Add Observability (Optional) - -### Prometheus Metrics - -If your service exposes Prometheus metrics, add scrape annotations: - -```yaml -# In deployment.yaml pod template -metadata: - annotations: - prometheus.io/scrape: "true" - prometheus.io/port: "8080" - prometheus.io/path: "/metrics" -``` - -### Grafana Dashboard - -Create a ConfigMap in `argocd/manifests/grafana-config/dashboards/`: - -```yaml -apiVersion: v1 -kind: ConfigMap -metadata: - name: myservice-dashboard - namespace: monitoring - labels: - grafana_dashboard: "1" - annotations: - grafana_folder: "Services" -data: - myservice.json: | - { ... dashboard JSON ... } -``` - -See [[grafana]] for dashboard provisioning details. - -## Checklist - -- [ ] Manifests created in `argocd/manifests/<service>/` -- [ ] 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) -- [ ] PR created and reviewed -- [ ] Reset to main after merge -- [ ] Service added to `service-versions.yaml` for version tracking - -## Related - -- [[argocd]] - GitOps platform -- [[tailscale-operator]] - Kubernetes ingress -- [[routing]] - Service routing options -- [[grafana]] - Dashboard configuration -- [[apps]] - Application registry diff --git a/docs/tutorials/ai-assistance-guide.md b/docs/tutorials/ai-assistance-guide.md deleted file mode 100644 index 4f0c595..0000000 --- a/docs/tutorials/ai-assistance-guide.md +++ /dev/null @@ -1,143 +0,0 @@ ---- -title: AI Assistance Guide -modified: 2026-02-23 -tags: - - tutorials - - ai ---- - -# AI Assistance Guide - -> **Audiences:** AI, Owner - -This guide provides context for AI agents assisting with BlumeOps operations, and helps Erich understand how to work effectively with AI assistance. - -## Critical Rules - -These are non-negotiable for AI agents working in this repo: - -1. **Always use `--context=minikube-indri` with kubectl** - Work contexts exist that must never be touched -2. **Run `mise run ai-docs` at session start** - Review current infrastructure state -3. **Never commit secrets** - The repo is public at github.com/eblume/blumeops -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 `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 - -### Branching - -Branching depends on change classification (see [[agent-change-process]]): - -- **C0 (Quick Fix):** Commit directly to main — no branch or PR needed -- **C1/C2:** Feature branch required: -```bash -git checkout main && git pull -git checkout -b feature/descriptive-name -# ... make changes ... -git commit -m "Description" -``` - -### Pull Requests - -Use the forge's `tea` CLI: -```bash -tea pr create --title "Title" --description "$(cat <<'EOF' -## Summary -- Change 1 -- Change 2 - -## Deployment and Testing -- [ ] Test step -EOF -)" -``` - -### Changelog Fragments - -Add a fragment for user-visible changes: -```bash -# C1/C2: use branch name -echo "Description" > docs/changelog.d/branch-name.feature.md - -# C0: use orphan prefix (no branch to name after) -echo "Description" > docs/changelog.d/+descriptive-slug.feature.md -``` - -Types (file suffix): `.feature`, `.bugfix`, `.infra`, `.doc`, `.ai`, `.misc` - -### Wiki-Link Formatting - -Use simple wiki-links without alternate text or extra spaces: -- Prefer `[[borgmatic]]` over `[[borgmatic|Borgmatic]]` -- Only use alternate text when grammatically warranted (e.g., `[[cluster|Kubernetes]]` reads better than `[[cluster]]`) -- No spaces around the pipe: `[[path|Text]]` not `[[ path|Text ]]` - -When editing documentation, rewrite links to follow this convention as you encounter them. - -## Service Locations - -Understanding where services run helps target changes correctly: - -| Location | Services | Management | -|----------|----------|------------| -| [[indri]] (native) | Forgejo, Zot, Jellyfin, Caddy | Ansible | -| [[cluster|Kubernetes]] | Everything else | ArgoCD | - -## Mise Tasks - -BlumeOps operations are driven by mise tasks. Run `mise tasks` to list all available tasks. - -| Task | When to Use | -|------|-------------| -| `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 | -| `services-check` | After deployments - verify all services are healthy | -| `pr-comments` | Check unresolved PR comments during review | -| `container-list` | View available container images and tags | -| `container-build-and-release` | Trigger container build workflows | -| `dns-preview` | Preview DNS changes before applying | -| `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 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 | -| `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 <service>` - Preview changes -- `argocd app sync <service>` - Deploy changes - -## Reference Navigation - -For AI agents building context: - -- [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 - -## Credential Access - -Credentials live in 1Password. Never retrieve them directly - use existing patterns: -- Ansible `pre_tasks` gather secrets at playbook start -- [[external-secrets]] syncs to Kubernetes -- Scripts use `op` CLI with user biometric prompts - -## Common Pitfalls - -| Pitfall | Correct Approach | -|---------|------------------| -| Missing kubectl context | Always add `--context=minikube-indri` | -| Deploying without review | Create PR first, wait for user approval | -| Re-explaining reference material | Link to reference cards instead | -| Committing to main without classifying | Classify as C0/C1/C2 first — only C0 goes to main | -| Guessing at credentials | Ask user or check 1Password patterns | diff --git a/docs/tutorials/contributing.md b/docs/tutorials/contributing.md deleted file mode 100644 index 0d48e8f..0000000 --- a/docs/tutorials/contributing.md +++ /dev/null @@ -1,180 +0,0 @@ ---- -title: Contributing -modified: 2026-04-21 -last-reviewed: 2026-04-21 -tags: - - tutorials - - contributing ---- - -# Your First Contribution - -> **Audiences:** Contributor - -This tutorial walks through making your first contribution to BlumeOps - from understanding the codebase to submitting a pull request. - -## Prerequisites - -Before contributing, you'll need: -- Access to the [[tailscale|Tailscale]] network (request from Erich) -- SSH key added to [[forgejo|Forgejo]] (https://forge.eblu.me) -- Development tools installed (see below) - -## Tooling Setup - -The repo includes a `Brewfile` and `mise.toml` for easy setup, but these are optional - install the tools however you prefer. - -### Required Tools - -- `tea` - Gitea/Forgejo CLI for creating PRs -- `argocd` - ArgoCD CLI for deployments -- `prek` - Git hooks for validation - -### Using Brewfile (Optional) - -```bash -brew bundle # installs tea, argocd, mise, etc. -``` - -### Using Mise (Optional) - -Mise manages language toolchains, runs tasks, and pins tools like `prek`: -```bash -mise install # installs Python, Node.js, prek, etc. from mise.toml -``` - -### Git Hooks (prek) - -Git hooks validate changes on `git commit` (prek is pinned in `mise.toml`): -```bash -prek install -prek run --all-files # verify setup -``` - -All hooks should pass on a fresh clone. - -## Understanding the Codebase - -BlumeOps manages infrastructure through three main systems: - -| System | Directory | What It Manages | -|--------|-----------|-----------------| -| **Ansible** | `ansible/` | Services running directly on [[indri]] | -| **ArgoCD** | `argocd/` | Kubernetes services in the [[cluster]] | -| **Pulumi** | `pulumi/` | [[tailscale|Tailscale]] ACLs and DNS | - -Most contributions involve either Ansible roles or ArgoCD manifests. - -## The Contribution Workflow - -### 1. Clone and Branch - -```bash -git clone ssh://git@forge.ops.eblu.me:2222/eblume/blumeops.git -cd blumeops -git checkout -b feature/your-change-name -``` - -### 2. Make Your Changes - -Depending on what you're changing: - -**For Kubernetes services:** -- Edit manifests in `argocd/manifests/<service>/` -- Or create new Application in `argocd/apps/` -- For new apps, set `targetRevision` to your feature branch for testing -- For existing apps, you'll need to temporarily change the revision via `argocd app set` - -**For Indri services:** -- Edit or create roles in `ansible/roles/` -- Update `ansible/playbooks/indri.yml` if adding a role - -**For documentation:** -- Edit files in `docs/` -- Add changelog fragment (see below) - -### 3. Add a Changelog Fragment - -For user-visible changes: -```bash -echo "Description of your change" > docs/changelog.d/your-branch.feature.md -``` - -Fragment types (file suffix): -- `.feature.md` - New functionality -- `.bugfix.md` - Bug fixes -- `.infra.md` - Infrastructure changes -- `.doc.md` - Documentation -- `.ai.md` - AI-assisted changes -- `.misc.md` - Other - -### 4. Test Your Changes - -**Before pushing, always test:** - -For Kubernetes changes: -```bash -# Preview what will change -argocd app diff <service> -``` - -For DNS changes: -```bash -mise run dns-preview -``` - -### 5. Commit and Push - -```bash -git add <files> -git commit -m "Brief description of change" -git push -u origin feature/your-change-name -``` - -### 6. Create a Pull Request - -```bash -tea pr create --title "Your PR Title" --description "$(cat <<'EOF' -## Summary -- What you changed -- Why you changed it - -## Deployment and Testing -- [ ] Tested locally / dry run -- [ ] Ready for ArgoCD sync / Ansible apply - -EOF -)" -``` - -### 7. Wait for Review - -Erich will review your PR and may leave comments. Check for feedback: -```bash -mise run pr-comments <pr_number> -``` - -Address each comment, then Erich will: -1. Approve the changes -2. Deploy them (you don't need to do this) -3. Merge the PR - -## Example: Adding a Homepage Link - -A simple first contribution - adding a service to the Homepage dashboard (go.ops.eblu.me): - -1. Find the service's Ingress in `argocd/manifests/<service>/` -2. Add homepage annotations: -```yaml -annotations: - gethomepage.dev/enabled: "true" - gethomepage.dev/name: "Service Name" - gethomepage.dev/group: "Apps" - gethomepage.dev/icon: "service.png" -``` -3. Create PR and wait for sync - -## Related - -- [[adding-a-service]] - Full tutorial on deploying a new service -- [[replicating-blumeops]] - If you want to build your own instead diff --git a/docs/tutorials/exploring-the-docs.md b/docs/tutorials/exploring-the-docs.md deleted file mode 100644 index 2fd5f66..0000000 --- a/docs/tutorials/exploring-the-docs.md +++ /dev/null @@ -1,89 +0,0 @@ ---- -title: Exploring the Docs -modified: 2026-02-10 -tags: - - tutorials - - getting-started ---- - -# Exploring the Documentation - -> **Audiences:** All (Owner, AI, Reader, Contributor, Replicator) - -This guide explains how the BlumeOps documentation is organized and how to find what you need. - -## Documentation Structure - -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" | - -## Quick Paths by Audience - -### For Erich (Owner) - -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 AI agents -- Run `mise run ai-docs` to prime AI context with key documentation - -### 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 `AGENTS.md` has critical rules (especially the kubectl context requirement) - -### For External Readers - -Understanding what this is: -- [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](/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](/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 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 validate that all wiki-links resolve to existing files and flag ambiguous bare-name links. - -## AI Context Priming - -The `ai-docs` mise task concatenates key documentation files for AI context: - -```bash -mise run ai-docs -``` - -This outputs key documentation files and a full tree listing of all docs, providing an agent with essential context for BlumeOps operations. - -## Related - -- [[update-documentation]] - How to publish doc changes -- [[review-documentation]] - Periodic doc review process diff --git a/docs/tutorials/expose-service-publicly.md b/docs/tutorials/expose-service-publicly.md deleted file mode 100644 index 65af611..0000000 --- a/docs/tutorials/expose-service-publicly.md +++ /dev/null @@ -1,501 +0,0 @@ ---- -title: Expose a Service Publicly -modified: 2026-04-18 -last-reviewed: 2026-04-18 -tags: - - tutorials - - fly-io - - tailscale - - networking -aliases: [] -id: expose-service-publicly ---- - -# Expose a Service Publicly via Fly.io + Tailscale - -This guide describes how to expose a BlumeOps service to the public internet -using a reverse proxy container on [Fly.io](https://fly.io) that tunnels back -to [[indri]] over [[tailscale]]. The approach keeps the home IP hidden, -requires no changes to existing infrastructure (`*.ops.eblu.me`, [[caddy]], -DNS), and is reusable for multiple services. - -## Architecture - -``` -Internet → <service>.eblu.me - │ - Fly.io edge (Anycast, TLS via Let's Encrypt) - │ - Fly.io VM (nginx reverse proxy + Tailscale) - │ (direct WireGuard tunnel to indri) - Caddy on indri (*.ops.eblu.me routing) - │ - backend service (k8s, native, or remote) -``` - -A single Fly.io container serves as the public-facing proxy for all exposed -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. - -The `*.ops.eblu.me` routes continue to work in parallel for private tailnet -access — the Fly proxy sends `Host: <service>.ops.eblu.me` headers that -match the same Caddy routes. - -## Key decisions - -| Decision | Choice | Rationale | -|----------|--------|-----------| -| Proxy host | Fly.io (free tier) | Managed container, no server to maintain via Ansible. Shared IPv4 + IPv6 are free for HTTP/HTTPS; dedicated IPv4 is $2/mo if a service needs non-HTTP(S) protocols | -| Tunnel | Tailscale (existing) | Already in use, WireGuard encryption, ACL control | -| DNS | CNAME at [[gandi]] | No DNS migration needed, no Cloudflare dependency | -| TLS (public) | Fly.io auto-provisions Let's Encrypt | No cert management, `$0.10/mo` per hostname | -| TLS (origin) | Tailscale handles encryption | WireGuard tunnel encrypts all traffic | -| CDN/cache | nginx `proxy_cache` in container | Per-service: aggressive for static sites, selective or disabled for dynamic services | -| DDoS | Fly.io Anycast + nginx rate limiting | Not enterprise-grade; see [[#Break-glass shutoff]] | -| IaC | `fly/` directory in repo, Pulumi for DNS + TS key | No well-maintained Fly.io Pulumi provider; `fly.toml` is the app's IaC | - -## TLS in this architecture - -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 → Caddy on indri**: nginx proxies to `https://indri.tail8d86e.ts.net` - with `Host: <service>.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. - -## External references - -- [Tailscale on Fly.io](https://tailscale.com/kb/1132/flydotio) — official guide for running Tailscale in a Fly.io container -- [Fly.io Custom Domains](https://fly.io/docs/networking/custom-domain/) — how Fly handles TLS for custom domains -- [Home Assistant + Fly.io + Tailscale](https://community.home-assistant.io/t/expose-ha-to-the-internet-via-a-cloud-reverse-proxy-fly-io-and-a-vpn-tailscale-for-free-for-now-without-opening-ports/352118) — community guide describing this exact pattern - ---- - -## One-time setup (first service) - -These steps establish the Fly.io proxy infrastructure. They only need to be done once. - -### Step 1: Fly.io account and app - -1. Create or recover a Fly.io account at https://fly.io (requires credit card for free tier) -2. Install `flyctl`: `brew install flyctl` -3. Authenticate: `fly auth login` -4. Create the app: `fly apps create blumeops-proxy` -5. Store the Fly.io deploy token in 1Password (blumeops vault): - - Generate: `fly tokens create deploy -a blumeops-proxy` - - Store as `fly-deploy-token` field - -### Step 2: Repository structure - -Create the `fly/` directory at the repository root. This is separate from `containers/` because the image is built and deployed directly to Fly.io by `fly deploy` — it never goes through `registry.ops.eblu.me`. - -``` -fly/ -├── fly.toml # Fly.io app configuration -├── Dockerfile # nginx + tailscale + alloy -├── nginx.conf # Reverse proxy + cache config -├── start.sh # Entrypoint: start tailscale, nginx, alloy -├── alloy.river # Observability: logs → Loki, metrics → Prometheus -└── error.html # Friendly 503 page for upstream failures -``` - -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 --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: <service>.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 - -The Fly.io container includes [[alloy]] baked in (`fly/alloy.river`). Alloy tails the nginx JSON access log and: - -- Forwards log lines to [[loki]] via the Tailscale Ingress endpoint -- Derives Prometheus metrics (`flyio_nginx_http_requests_total`, `flyio_nginx_http_request_duration_seconds`, `flyio_nginx_cache_requests_total`, etc.) and remote-writes them to [[prometheus]] - -Both Loki and Prometheus are reached directly via their `*.tail8d86e.ts.net` Tailscale Ingress endpoints (not via [[caddy]]), since the proxy's ACLs only allow `tag:flyio-target`. - -### Step 3: Tailscale auth key and ACLs (Pulumi) - -Extend the existing `pulumi/tailscale/` project. - -**Add to `pulumi/tailscale/__main__.py`:** - -```python -# Auth key for Fly.io proxy container -flyio_key = tailscale.TailnetKey( - "flyio-proxy-key", - reusable=True, - ephemeral=True, - preauthorized=True, # Skip device approval on the tailnet - tags=["tag:flyio-proxy"], - expiry=7776000, # 90 days -) -pulumi.export("flyio_authkey", flyio_key.key) -``` - -> **Note:** `preauthorized=True` is required if your tailnet has device -> approval enabled. Without it, each new container start (including -> health-check restarts) creates a node that needs manual approval, -> causing the container to hang before nginx starts. - -**Add to `pulumi/tailscale/policy.hujson`:** - -Tag owner (allows the k8s operator to assign this tag to Ingress proxy nodes): -``` -"tag:flyio-target": ["autogroup:admin", "tag:blumeops", "tag:k8s-operator"], -``` - -Access grant (Fly.io proxy → explicitly tagged endpoints on HTTPS only): -``` -{ - "src": ["tag:flyio-proxy"], - "dst": ["tag:flyio-target"], - "ip": ["tcp:443"], -}, -``` - -ACL test: -``` -{ - "src": "tag:flyio-proxy", - "accept": ["tag:flyio-target:443"], - "deny": ["tag:k8s:443", "tag:homelab:22", "tag:nas:445", "tag:registry:443"], -}, -``` - -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`. - -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 -mise run fly-setup -``` - -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 - -Three mise tasks manage the proxy lifecycle. See the actual scripts in `mise-tasks/` for current implementation: - -- **`mise run fly-deploy`** — runs `fly deploy` from the `fly/` directory -- **`mise run fly-setup`** — one-time, idempotent setup: fetches the Tailscale auth key from Pulumi state, stages it as a Fly.io secret, allocates IPs, and adds TLS certs for all public domains (currently `docs.eblu.me` and `cv.eblu.me`) -- **`mise run fly-shutoff`** — emergency shutoff: scales machines to zero, immediately stopping all public traffic - -### Step 5: Forgejo CI workflow - -A Forgejo Actions workflow (`.forgejo/workflows/deploy-fly.yaml`) auto-deploys on pushes to `main` that touch `fly/**`. It installs `flyctl`, runs `fly deploy`, and verifies health. It can also be triggered manually via `workflow_dispatch`. - -The `FLY_DEPLOY_TOKEN` Forgejo Actions secret must be set via the [[forgejo]] API or UI, following the pattern in the `forgejo_actions_secrets` Ansible role. - ---- - -## Per-service setup - -To expose an additional service (example: `wiki.eblu.me`): - -### 1. Ensure the service has a Caddy route - -The service must be accessible via `<service>.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`. - -### 2. Add nginx server block - -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): - -```nginx -# --- wiki.eblu.me (static) --- -server { - listen 8080; - server_name wiki.eblu.me; - - limit_req zone=general burst=20 nodelay; - - error_page 502 503 504 /error.html; - location = /error.html { - root /usr/share/nginx/html; - internal; - } - - location / { - proxy_pass https://indri_backend$request_uri; - proxy_ssl_verify off; - proxy_ssl_server_name on; - proxy_ssl_name wiki.ops.eblu.me; - proxy_set_header Host wiki.ops.eblu.me; - 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; - 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; - 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. - -### 2. Add Fly.io certificate - -```bash -fly certs add wiki.eblu.me -a blumeops-proxy -``` - -Or add it to `mise-tasks/fly-setup` so it's captured for future runs. - -### 3. Deploy - -```bash -mise run fly-deploy -``` - -Or push the `fly/nginx.conf` change to main — the Forgejo workflow deploys automatically. - -### 4. Verify against fly.dev - -Test the proxy before touching DNS. Use the `Host` header to simulate -the real domain: - -```bash -# Health check -curl -sf https://blumeops-proxy.fly.dev/healthz - -# Simulate real domain request -curl -I -H "Host: wiki.eblu.me" https://blumeops-proxy.fly.dev/ -# Should return 200 with X-Cache-Status header -``` - -If this fails, debug without any public DNS impact. - -### 5. Add DNS CNAME (Pulumi) - -Only after verifying the proxy works. Add to `pulumi/gandi/__main__.py`: - -```python -wiki_public = gandi.livedns.Record( - "wiki-public", - zone=domain, - name="wiki", - type="CNAME", - ttl=300, - values=["blumeops-proxy.fly.dev."], -) -``` - -Deploy: `mise run dns-preview` then `mise run dns-up`. - -### 6. Verify with real domain - -```bash -curl -I https://wiki.eblu.me -# Should return 200 with X-Cache-Status header -``` - -### 7. Verify routing - -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. - ---- - -## Security - -### DDoS and rate limiting - -This approach provides basic protection, not enterprise-grade: - -- **Fly.io Anycast** absorbs volumetric L3/L4 attacks -- **nginx `limit_req`** caps per-IP request rates at the container level -- **nginx `proxy_cache`** serves most requests from cache — only cache - misses traverse the Tailscale tunnel to indri - -For **static sites**, the cache is the primary defense. Most requests -never reach the origin. Cache-busting is mitigated by ignoring query -strings (`proxy_cache_key $host$uri`) and client cache-control headers. - -For **dynamic services**, the cache covers only static assets. Most -requests flow through the Tailscale tunnel to indri on every hit. This -makes dynamic services significantly more vulnerable to L7 DDoS — an -attacker sending high volumes of legitimate-looking requests (login -pages, API endpoints, search queries) bypasses the cache entirely. -Mitigations for dynamic services: - -- nginx `limit_req` is the primary defense at the proxy layer — tune - the rate and burst per service -- The backend service's own rate limiting (e.g., Forgejo's built-in - rate limiter) provides a second layer -- 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; -see plan history in git). - -### fail2ban - -fail2ban monitors log files for repeated failed authentication attempts -and bans offending IPs. - -**Static sites**: fail2ban does not apply. There is no login surface, -no sessions, no credentials to brute force. - -**Dynamic services with authentication** (e.g., Forgejo): fail2ban -runs in the **Fly.io container**, not on indri. Standard iptables -banning won't work in Fly.io because `$remote_addr` is Fly's internal -proxy IP, not the client. Instead, fail2ban uses a custom nginx-based -ban action: - -1. fail2ban watches the nginx JSON access log for repeated 401/403 - responses to login endpoints, keyed on the `client_ip` field - (populated from the `Fly-Client-IP` header) -2. On ban, it appends the IP to `/etc/nginx/forge-deny.conf` and - reloads nginx -3. nginx uses a `geo` directive keyed on `$http_fly_client_ip` to - check the deny list and return 403 for banned IPs - -Ban lists are **ephemeral across deploys** — nginx rate limiting -provides the persistent baseline; fail2ban adds escalating bans for -active attacks. - -See `fly/fail2ban/` for the filter, jail, and action configuration. - -### Break-glass shutoff - -If the proxy is causing issues, stop it immediately: - -```bash -mise run fly-shutoff -``` - -This stops all machines in seconds — zero traffic reaches indri. See [[manage-flyio-proxy#Emergency Shutoff]] for the full escalation ladder (container stop → Tailscale revoke → DNS removal). - ---- - -## Considerations for dynamic services - -The architecture described in this guide works for both static and dynamic -services, but the nginx configuration and security posture differ -significantly. This section summarizes what changes when exposing a -dynamic, authenticated service like [[forgejo]]. - -| Concern | Static site | Dynamic service | -|---------|-------------|-----------------| -| Caching | Aggressive (cache everything, 1d TTL) | Static assets only, or disabled | -| Session cookies | Ignored (`proxy_ignore_headers Set-Cookie`) | Must be passed through | -| Query strings | Ignored in cache key | Included (default behavior) | -| Rate limiting | 10r/s is plenty | Higher burst needed; coordinate with backend rate limiter | -| Request body size | Default 1MB is fine | Increase for uploads (`client_max_body_size`) | -| WebSocket | Not needed | Often needed (`proxy_http_version 1.1`, `Upgrade` headers) | -| Proxy headers | Optional | Required (`X-Real-IP`, `X-Forwarded-For`, `X-Forwarded-Proto`) | -| fail2ban | Not applicable | Configure on indri, watching service logs | -| DDoS exposure | Low — cache absorbs most traffic | Higher — most requests hit origin | -| Pre-exposure checklist | Deploy and go | Disable open registration, audit access controls, configure fail2ban | - -### Checklist before exposing a dynamic service - -- [ ] Disable open user registration (require invites or admin approval) -- [ ] Audit access controls and permissions -- [ ] Configure the service to log the forwarded client IP (not the proxy IP) -- [ ] Set up fail2ban in the Fly.io container with a filter for the service's login endpoints -- [ ] Tag the service's Tailscale Ingress with `tag:flyio-target` -- [ ] Test the nginx config locally or in staging before deploying -- [ ] Rehearse the break-glass shutoff (`mise run fly-shutoff`) - ---- - -## IaC summary - -| Component | Managed by | Declarative? | -|-----------|------------|:---:| -| Tailscale auth key | Pulumi (`pulumi/tailscale/`) | yes | -| Tailscale ACLs | Pulumi (`pulumi/tailscale/policy.hujson`) | yes | -| DNS CNAMEs | Pulumi (`pulumi/gandi/`) | yes | -| Container + app config | `fly/Dockerfile` + `fly/fly.toml` in repo | yes | -| Observability | `fly/alloy.river` in repo | yes | -| Deployment | Forgejo CI on push to `fly/`, or `mise run fly-deploy` | yes | -| Fly.io secrets + certs | `mise run fly-setup` (one-time, idempotent) | semi | - -The "semi" for Fly.io secrets is a one-time operation backed by a repeatable mise task. Fly.io does not have a mature Pulumi or Terraform provider, so `fly.toml` + `flyctl` is the standard IaC model for Fly.io apps. - ---- - -## Verification - -### Pre-DNS (verify against fly.dev) - -Test the proxy works before creating any public DNS records: - -1. `curl -sf https://blumeops-proxy.fly.dev/healthz` — returns `ok` -2. `curl -I -H "Host: docs.eblu.me" https://blumeops-proxy.fly.dev/` — returns 200 with `X-Cache-Status` header -3. `fly status -a blumeops-proxy` — shows healthy machine -4. All `*.ops.eblu.me` services still work from tailnet (unchanged) -5. `mise run services-check` passes - -If anything fails here, debug without public DNS impact. - -### Post-DNS (after CNAME is live) - -After deploying DNS (`mise run dns-up`): - -1. `curl -I https://docs.eblu.me` — returns 200 with `X-Cache-Status` header -2. `curl -I https://cv.eblu.me` — same for each public service -3. `dig docs.eblu.me` — resolves to Fly.io IPs (not Tailscale IP) -4. `dig forge.ops.eblu.me` — still resolves to indri's Tailscale IP (unchanged) -5. Second request to same URL shows `X-Cache-Status: HIT` diff --git a/docs/tutorials/replicating-blumeops.md b/docs/tutorials/replicating-blumeops.md deleted file mode 100644 index e54ecb2..0000000 --- a/docs/tutorials/replicating-blumeops.md +++ /dev/null @@ -1,141 +0,0 @@ ---- -title: Replicating BlumeOps -modified: 2026-05-11 -last-reviewed: 2026-05-11 -tags: - - tutorials - - replication ---- - -# Replicating BlumeOps - -> **Audiences:** Replicator - -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 - -By following this guide, you'll have: -- A secure mesh network connecting your devices -- A Kubernetes cluster for running containerized services -- GitOps-driven deployments via ArgoCD -- Observability with metrics, logs, and dashboards -- Backup and disaster recovery capabilities - -## Hardware Requirements - -BlumeOps runs on modest hardware. At minimum: - -| Component | BlumeOps Uses | Minimum Alternative | -|-----------|---------------|---------------------| -| **Server** | Mac Mini M1 | Any machine with sufficient RAM (16GB recommended) | -| **NAS** | Synology DS920+ | USB drive or second machine | -| **Workstation** | MacBook Air M4 | Whatever you use daily | - -You can start with a single machine and add storage later. - -## The Journey - -### Phase 1: Networking Foundation - -Before deploying services, establish secure connectivity. - -**[[tailscale-setup|Setting Up Tailscale]]** -- Create a tailnet and connect your devices -- Configure ACLs for service access -- Set up MagicDNS for convenient naming - -This replaces: traditional VPNs, port forwarding, dynamic DNS - -### Phase 2: Core Services - -Bootstrap the essential services that everything else depends on. - -**[[core-services|Core Services Setup]]** -- Set up [[forgejo]] for git hosting and CI/CD -- Optionally set up [[zot]] container registry -- Configure SSH access and deploy keys - -Forgejo is central to GitOps - it's where your infrastructure definitions live and where CI/CD workflows run. - -### Phase 3: Kubernetes Cluster - -A cluster for running containerized workloads. - -**[[kubernetes-bootstrap|Bootstrapping Kubernetes]]** -- Install minikube (or k3s, kind, etc.) -- Configure persistent storage -- Expose the API securely via Tailscale - -BlumeOps uses minikube for simplicity, but the patterns apply to any distribution. - -### Phase 4: GitOps with ArgoCD - -Declarative, git-driven deployments. - -**[[argocd-config|Configuring ArgoCD]]** -- Install ArgoCD in your cluster -- Connect to your git repository -- Deploy your first application -- Set up the app-of-apps pattern - -This is the heart of GitOps - changes in git automatically sync to your cluster. - -### Phase 5: Observability Stack - -Know what's happening in your infrastructure. - -**[[observability-stack|Building the Observability Stack]]** -- Deploy Prometheus for metrics -- Deploy Loki for logs -- Deploy Grafana for dashboards -- Configure Alloy for collection - -Without observability, you're flying blind. - -### Phase 6: Your First Services - -With the foundation in place, deploy actual workloads. BlumeOps runs: -- [[miniflux]] - RSS reader -- [[jellyfin]] - Media server -- [[immich]] - Photo management -- [[navidrome]] - Music streaming -- [[docs]] - Documentation site (Quartz) - -Pick what matters to you. Each service follows similar patterns: -1. Create Kubernetes manifests -2. Create ArgoCD Application -3. Configure ingress routing -4. Sync and verify - -### Phase 7: Backups and Resilience - -Protect your data. - -- Set up [[borgmatic]] for backup automation -- Configure NAS as backup target -- Test restore procedures -- Document disaster recovery - -## Alternative Approaches - -BlumeOps makes specific choices that may not suit everyone: - -| BlumeOps Choice | Alternative | -|-----------------|-------------| -| macOS server | Linux server (more common) | -| Minikube | k3s, kind, or managed K8s | -| Tailscale | WireGuard, Nebula | -| ArgoCD | Flux, manual kubectl | -| Ansible | NixOS, Docker Compose | - -The principles (GitOps, IaC, observability) matter more than specific tools. - -## Getting Started - -Begin with [[tailscale-setup]] - networking is the foundation everything else builds on. - -## Related - -- [Reference](/reference/) - See BlumeOps' specific configurations -- [[contributing]] - Help improve BlumeOps instead diff --git a/docs/tutorials/replication/argocd-config.md b/docs/tutorials/replication/argocd-config.md deleted file mode 100644 index 4a0c0f6..0000000 --- a/docs/tutorials/replication/argocd-config.md +++ /dev/null @@ -1,225 +0,0 @@ ---- -title: ArgoCD Config -modified: 2026-03-24 -last-reviewed: 2026-03-24 -tags: - - tutorials - - replication - - argocd ---- - -# Configuring ArgoCD - -> **Audiences:** Replicator - -This tutorial walks through installing ArgoCD and establishing GitOps-driven deployments for your homelab. - -## What is GitOps? - -GitOps means your git repository is the source of truth for infrastructure: -- Infrastructure state is defined in git -- Changes happen through commits and pull requests -- A controller (ArgoCD) syncs git state to the cluster -- Drift is detected and can be corrected automatically - -For BlumeOps specifics, see [[argocd|ArgoCD Reference]]. - -## Step 1: Install ArgoCD - -```bash -kubectl create namespace argocd -kubectl apply -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml -``` - -Wait for pods to be ready: -```bash -kubectl -n argocd get pods -w -``` - -## Step 2: Access the UI - -### Get the Initial Password - -```bash -kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath="{.data.password}" | base64 -d -``` - -### Expose the Service - -For Tailscale access: -```bash -tailscale serve --bg --https 8443 https+insecure://localhost:$(kubectl -n argocd get svc argocd-server -o jsonpath='{.spec.ports[?(@.name=="https")].port}') -``` - -Or create a Tailscale Ingress in Kubernetes (see [[tailscale-operator]]). - -Access at `https://your-server.tailnet.ts.net:8443` (replace `tailnet` with your tailnet name, found in the Tailscale admin console) - -### Install the CLI - -BlumeOps includes `argocd` in its Brewfile (`brew bundle`), or install it however you prefer. - -Login: -```bash -argocd login your-server.tailnet.ts.net:8443 -``` - -## Step 3: Connect Your Git Repository - -Create a repository credential: - -```bash -# For SSH -argocd repo add git@github.com:you/your-repo.git \ - --ssh-private-key-path ~/.ssh/id_ed25519 - -# For HTTPS -argocd repo add https://github.com/you/your-repo.git \ - --username you \ - --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: -``` -your-repo/ -└── apps/ - └── hello-world/ - ├── deployment.yaml - └── service.yaml -``` - -With a simple deployment: -```yaml -# deployment.yaml -apiVersion: apps/v1 -kind: Deployment -metadata: - name: hello-world -spec: - replicas: 1 - selector: - matchLabels: - app: hello-world - template: - metadata: - labels: - app: hello-world - spec: - containers: - - name: hello - image: nginx:alpine - ports: - - containerPort: 80 -``` - -Create the ArgoCD Application: -```bash -argocd app create hello-world \ - --repo git@github.com:you/your-repo.git \ - --path apps/hello-world \ - --dest-server https://kubernetes.default.svc \ - --dest-namespace default -``` - -## Step 5: Sync and Verify - -```bash -# See what will be deployed -argocd app diff hello-world - -# Deploy it -argocd app sync hello-world - -# Check status -argocd app get hello-world -``` - -The pods should now be running: -```bash -kubectl get pods -l app=hello-world -``` - -## Step 6: App of Apps Pattern - -For managing multiple applications, use the "app of apps" pattern: - -``` -your-repo/ -├── argocd/ -│ ├── apps/ # Application definitions -│ │ ├── hello-world.yaml -│ │ └── another-app.yaml -│ └── manifests/ # Actual Kubernetes manifests -│ ├── hello-world/ -│ └── another-app/ -``` - -Create a root Application that manages other Applications: -```yaml -# argocd/apps/apps.yaml -apiVersion: argoproj.io/v1alpha1 -kind: Application -metadata: - name: apps - namespace: argocd -spec: - project: default - source: - repoURL: git@github.com:you/your-repo.git - targetRevision: main - path: argocd/apps - destination: - server: https://kubernetes.default.svc - namespace: argocd - syncPolicy: - syncOptions: - - CreateNamespace=true -``` - -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 - -| Policy | When to Use | -|--------|-------------| -| Manual sync | Production, explicit control | -| Auto sync | Development, or trusted workloads | -| Auto prune | Remove resources deleted from git | -| Self heal | Revert manual kubectl changes | - -BlumeOps uses manual sync for all applications, including the root `apps` Application. - -## What You Now Have - -- GitOps workflow for deployments -- UI for visualizing application state -- Automatic drift detection -- Declarative application management - -## Next Steps - -- [[observability-stack|Build observability]] - Monitor your deployments -- Add more applications to your repo -- Set up notifications for sync failures - -## BlumeOps Specifics - -BlumeOps' ArgoCD configuration includes: -- SSH connection to [[forgejo]] git server -- Manual sync policy for all workloads -- Separate manifests and apps directories - -See [[argocd|ArgoCD Reference]] and [[apps|Apps Reference]] for full details. - -## Troubleshooting - -| Problem | Solution | -|---------|----------| -| Sync failed | Check `argocd app get <app>` for error details | -| Can't connect to repo | Verify credentials, check SSH key permissions | -| Resources not appearing | Ensure path in Application matches repo structure | -| Out of sync but no diff | Check for ignored differences in app config | diff --git a/docs/tutorials/replication/core-services.md b/docs/tutorials/replication/core-services.md deleted file mode 100644 index 12c79e9..0000000 --- a/docs/tutorials/replication/core-services.md +++ /dev/null @@ -1,154 +0,0 @@ ---- -title: Core Services -modified: 2026-02-07 -last-reviewed: 2026-04-05 -tags: - - tutorials - - replication - - forgejo ---- - -# 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]] - -This tutorial walks through setting up the foundational services that your GitOps infrastructure depends on: a git forge and optionally a container registry. - -## Why Core Services First? - -Before Kubernetes and ArgoCD, you need somewhere to store your infrastructure definitions. [[forgejo]] provides: -- Git hosting for your GitOps repository -- CI/CD workflows for building and deploying -- A web interface for code review and PRs - -The [[zot]] container registry is optional but useful for hosting your own container images. - -## Step 1: Install Forgejo - -Forgejo runs directly on your server (not in Kubernetes) because Kubernetes depends on it. - -### Using Ansible (BlumeOps Approach) - -BlumeOps manages Forgejo via an Ansible role. See [[ansible]]. - -### Manual Installation - -1. Download Forgejo from [forgejo.org](https://forgejo.org/download/) -2. Create a service user and directories -3. Configure with `app.ini` -4. Set up as a system service - -Key configuration points: -- SSH on a non-standard port (e.g., 2222) to avoid conflicts -- Database (SQLite works fine for personal use) -- Domain and URL settings for your Tailscale hostname - -## 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 (using the non-standard port directly): -ssh -T git@your-server.tailnet.ts.net -p 2222 -``` - -## Step 3: Create Your GitOps Repository - -1. Create a new repository in Forgejo (e.g., `infrastructure` or `homelab`) -2. Initialize the standard directory structure: - -``` -your-repo/ -├── ansible/ # Host configuration -│ ├── playbooks/ -│ └── roles/ -├── argocd/ # Kubernetes GitOps -│ ├── apps/ # ArgoCD Applications -│ └── manifests/ # K8s manifests per service -├── pulumi/ # IaC for Tailscale, DNS -└── docs/ # Documentation -``` - -3. Push your initial commit - -## Step 4: Set Up CI/CD Runner (Optional) - -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. Download the `forgejo-runner` binary from [code.forgejo.org](https://code.forgejo.org/forgejo/runner/releases): - -```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 <registration-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) - -If you'll build custom container images, set up [[zot]]: - -1. Install Zot on your server -2. Configure authentication -3. Set up TLS (via Caddy or similar) - -For getting started, you can skip this and use public registries. - -## What You Now Have - -- Git hosting for infrastructure code -- SSH access for git operations -- Foundation for CI/CD workflows -- Optionally, a private container registry - -## Next Steps - -- [[kubernetes-bootstrap|Bootstrap Kubernetes]] - Now that you have a git repo, set up your cluster -- Configure Forgejo webhooks for ArgoCD (after ArgoCD is running) - -## BlumeOps Specifics - -BlumeOps' Forgejo setup includes: -- Ansible role for installation and updates -- SSH on port 2222, proxied via Caddy -- Integration with ArgoCD via deploy keys -- Forgejo runner in Kubernetes for CI/CD - -See [[forgejo]] and [[zot]] for full details. diff --git a/docs/tutorials/replication/kubernetes-bootstrap.md b/docs/tutorials/replication/kubernetes-bootstrap.md deleted file mode 100644 index 92d6aec..0000000 --- a/docs/tutorials/replication/kubernetes-bootstrap.md +++ /dev/null @@ -1,194 +0,0 @@ ---- -title: Kubernetes Bootstrap -modified: 2026-03-25 -last-reviewed: 2026-03-25 -tags: - - tutorials - - replication - - kubernetes ---- - -# Bootstrapping Kubernetes - -> **Audiences:** Replicator - -This tutorial walks through setting up a Kubernetes cluster for your homelab, making it accessible via Tailscale. - -## Choosing a Distribution - -For homelab use, lightweight distributions work well: - -| Distribution | Best For | BlumeOps Uses | -|--------------|----------|---------------| -| **Minikube** | Single-node, macOS | Yes | -| **k3s** | Single-node, Linux | Yes (ringtail) | -| **kind** | Local development | - | -| **kubeadm** | Multi-node clusters | - | - -This tutorial uses minikube, but principles apply broadly. - -For BlumeOps specifics, see [[cluster|Cluster Reference]]. - -## Step 1: Install Minikube - -### macOS - -```bash -brew install minikube -``` - -### Linux - -```bash -curl -LO https://storage.googleapis.com/minikube/releases/latest/minikube-linux-amd64 -sudo install minikube-linux-amd64 /usr/local/bin/minikube -``` - -## Step 2: Create the Cluster - -```bash -minikube start \ - --driver=docker \ - --cpus=4 \ - --memory=8g \ - --disk-size=100g \ - --apiserver-names=k8s.your-tailnet.ts.net,$(hostname) \ - --listen-address=0.0.0.0 -``` - -Key flags: -- `--apiserver-names` - Include your Tailscale hostname for remote access -- `--listen-address=0.0.0.0` - Allow connections from other machines - -## Step 3: Verify the Cluster - -```bash -kubectl get nodes -# Should show your node as Ready - -kubectl get pods -A -# Should show system pods running -``` - -## Step 4: Expose via Tailscale - -To access the cluster from other Tailscale devices, expose the API server: - -### Option A: Tailscale Serve (Simple) - -```bash -tailscale serve --bg --tcp 6443 tcp://$(minikube ip):8443 -``` - -### Option B: Tailscale Kubernetes Operator (Advanced) - -For production-like setup, install the Tailscale operator which manages ingress automatically. - -BlumeOps uses TCP passthrough via Caddy - see [[routing|Routing Reference]]. - -## Step 5: Configure Remote Access - -On your workstation, add a context for the remote cluster: - -```bash -# Copy the CA cert from the server -scp server:~/.minikube/ca.crt ~/.kube/minikube-ca.crt - -# Add the cluster -kubectl config set-cluster minikube-remote \ - --server=https://k8s.your-tailnet.ts.net:6443 \ - --certificate-authority=$HOME/.kube/minikube-ca.crt - -# Add credentials (copy from server's ~/.kube/config) -kubectl config set-credentials minikube-remote \ - --client-certificate=... \ - --client-key=... - -# Add context -kubectl config set-context minikube-remote \ - --cluster=minikube-remote \ - --user=minikube-remote - -# Test -kubectl --context=minikube-remote get nodes -``` - -## Step 6: Storage Configuration - -For persistent workloads, configure storage: - -### Local Path Provisioner (Simple) - -```bash -kubectl apply -f https://raw.githubusercontent.com/rancher/local-path-provisioner/master/deploy/local-path-storage.yaml -kubectl patch storageclass local-path -p '{"metadata": {"annotations":{"storageclass.kubernetes.io/is-default-class":"true"}}}' -``` - -### NFS for Shared Storage - -If you have a NAS on your tailnet, create a static PersistentVolume and PersistentVolumeClaim pair: - -```yaml -apiVersion: v1 -kind: PersistentVolume -metadata: - name: media-nfs-pv -spec: - capacity: - storage: 1Ti - accessModes: - - ReadWriteMany - persistentVolumeReclaimPolicy: Retain - storageClassName: "" - nfs: - 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 -- Remote access via Tailscale -- Storage for persistent workloads - -## Next Steps - -- [[argocd-config|Configure ArgoCD]] - GitOps deployments -- Install essential addons (ingress controller, cert-manager) - -## BlumeOps Specifics - -BlumeOps' cluster configuration includes: -- Tailscale operator for automatic ingress -- NFS mounts from [[sifaka]] for media storage -- CloudNativePG for PostgreSQL databases - -See [[cluster|Cluster Reference]] and [[apps|Apps Reference]] for full details. - -## Troubleshooting - -| Problem | Solution | -|---------|----------| -| Can't connect remotely | Check `--apiserver-names` includes Tailscale hostname | -| Pods stuck pending | Check storage class is available | -| Connection refused | Verify `--listen-address=0.0.0.0` was set | -| Certificate errors | Ensure CA cert matches server's | diff --git a/docs/tutorials/replication/observability-stack.md b/docs/tutorials/replication/observability-stack.md deleted file mode 100644 index d62731e..0000000 --- a/docs/tutorials/replication/observability-stack.md +++ /dev/null @@ -1,239 +0,0 @@ ---- -title: Observability Stack -modified: 2026-04-06 -last-reviewed: 2026-04-06 -tags: - - tutorials - - replication - - observability ---- - -# 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. - -## The Stack - -A complete observability solution has three pillars plus a collection layer: - -| Component | Purpose | BlumeOps Uses | -|-----------|---------|---------------| -| **Metrics** | Numeric measurements over time | [[prometheus]] | -| **Logs** | Text output from applications | [[loki]] | -| **Dashboards** | Visualization and alerting | [[grafana]] | -| **Collection** | Gathering and forwarding data | [[alloy]] | - -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 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 -``` - -## Step 2: Deploy Prometheus - -Prometheus collects and stores metrics. BlumeOps runs it as a StatefulSet with local persistent storage. - -### Write the Manifests - -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" -``` - -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`: - -```yaml -apiVersion: argoproj.io/v1alpha1 -kind: Application -metadata: - name: prometheus - namespace: argocd -spec: - project: default - source: - 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 - -```bash -kubectl -n monitoring get pods -l app.kubernetes.io/name=prometheus -``` - -## Step 3: Deploy Loki - -Loki aggregates logs — think Prometheus, but for log lines instead of metrics. - -### Write the Manifests - -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, visualization, and alerting. - -### 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 -``` - -### Secrets - -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-dashboard-my-service - labels: - grafana_dashboard: "1" -data: - my-service.json: | - { ... dashboard JSON ... } -``` - -## Next Steps - -- Set up [[authentik]] SSO for Grafana login (see [[federated-login]]) -- Create custom dashboards for your services -- Configure alerting rules and notification channels -- Add service-specific metrics exporters - -## Related - -- [[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 diff --git a/docs/tutorials/replication/tailscale-setup.md b/docs/tutorials/replication/tailscale-setup.md deleted file mode 100644 index 463de42..0000000 --- a/docs/tutorials/replication/tailscale-setup.md +++ /dev/null @@ -1,146 +0,0 @@ ---- -title: Tailscale Setup -modified: 2026-03-26 -last-reviewed: 2026-03-26 -tags: - - tutorials - - replication - - tailscale ---- - -# Setting Up Tailscale - -> **Audiences:** Replicator - -This tutorial walks through establishing a Tailscale mesh network as the foundation for your homelab infrastructure. - -## Why Tailscale? - -Tailscale solves several problems at once: -- **Secure connectivity** - WireGuard-encrypted traffic between all devices -- **No port forwarding** - Devices connect directly through NATs and firewalls -- **MagicDNS** - Human-readable names like `server.tailnet.ts.net` -- **ACLs** - Fine-grained access control between devices - -For BlumeOps context, see [[tailscale|Tailscale Reference]]. - -## Step 1: Create Your Tailnet - -1. Sign up at [tailscale.com](https://tailscale.com) -2. Choose your identity provider (Google, Microsoft, GitHub, etc.) -3. Note your tailnet name (e.g., `yourname.ts.net`) - -## Step 2: Install on Your Devices - -### 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 -brew services start tailscale -tailscale up -``` - -### Linux - -```bash -curl -fsSL https://tailscale.com/install.sh | sh -sudo tailscale up -``` - -### Other Platforms - -See [Tailscale Downloads](https://tailscale.com/download) for iOS, Android, Windows, etc. - -## Step 3: Verify Connectivity - -After installing on two devices: -```bash -tailscale status -# Shows all connected devices - -ping <other-device>.yourname.ts.net -# Should work immediately -``` - -## Step 4: Configure ACLs - -Default Tailscale allows all-to-all connectivity. For a homelab, you'll want restrictions. - -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": { - "group:admin": ["your-email@example.com"] - }, - "tagOwners": { - "tag:homelab": ["group:admin"] - }, - "acls": [ - // Admins can access everything - {"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}, - // Homelab servers can reach NAS - {"action": "accept", "src": ["tag:homelab"], "dst": ["tag:nas:*"]} - ] -} -``` - -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 - -In the Tailscale admin console: -1. Go to DNS settings -2. Enable MagicDNS -3. Optionally add a search domain - -Now `ssh server` works instead of `ssh 100.x.y.z`. - -## Step 6: Tag Your Devices - -Tags enable role-based access control: -```bash -# On your server -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 -- DNS names for each device -- Foundation for exposing services securely - -## Next Steps - -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 via the [[tailscale-operator|Tailscale Operator]] - -## BlumeOps Specifics - -BlumeOps' Tailscale configuration includes: -- Multiple device tags (`homelab`, `nas`, `registry`, `k8s-operator`) -- Group-based access for family members -- SSH access rules with authentication requirements - -See [[tailscale|Tailscale Reference]] for full details. - -## Troubleshooting - -| Problem | Solution | -|---------|----------| -| Device won't connect | Check firewall allows UDP 41641 | -| Can't reach other devices | Verify ACLs don't block traffic | -| DNS not resolving | Enable MagicDNS in admin console | -| Tags not applying | Ensure tags defined in ACL policy | diff --git a/fly/Dockerfile b/fly/Dockerfile deleted file mode 100644 index 406c849..0000000 --- a/fly/Dockerfile +++ /dev/null @@ -1,35 +0,0 @@ -# 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 \ - /usr/local/bin/tailscaled /usr/local/bin/tailscaled -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 \ - && apk add --no-cache iptables ip6tables \ - && apk add --no-cache libc6-compat \ - && apk add --no-cache fail2ban \ - && rm -f /etc/fail2ban/jail.d/alpine-ssh.conf - -# 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 - -COPY fail2ban/filter.d/forge-login.conf /etc/fail2ban/filter.d/forge-login.conf -COPY fail2ban/jail.d/forge.conf /etc/fail2ban/jail.d/forge.conf -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 - -EXPOSE 8080 - -CMD ["/start.sh"] diff --git a/fly/alloy.river b/fly/alloy.river deleted file mode 100644 index 015583c..0000000 --- a/fly/alloy.river +++ /dev/null @@ -1,155 +0,0 @@ -// Grafana Alloy configuration for flyio-proxy -// Collects nginx access logs → Loki, extracts metrics → Prometheus. -// Note: stub_status connection metrics are not collected — Alloy has no -// built-in nginx exporter. The log-derived metrics cover the key signals. - -// ============== LOG COLLECTION ============== - -// Tail the JSON access log written by nginx -local.file_match "nginx_access" { - path_targets = [ - {__path__ = "/var/log/nginx/access.json.log", job = "flyio-nginx"}, - ] -} - -loki.source.file "nginx_access" { - targets = local.file_match.nginx_access.targets - forward_to = [loki.process.nginx.receiver] -} - -// Parse JSON fields, extract labels, derive metrics -loki.process "nginx" { - forward_to = [loki.relabel.instance.receiver] - - // Parse the JSON log line - stage.json { - expressions = { - client_ip = "client_ip", - status = "status", - method = "request_method", - host = "http_host", - cache_status = "upstream_cache_status", - request_time = "request_time", - body_bytes_sent = "body_bytes_sent", - upstream_response_time = "upstream_response_time", - } - } - - // Promote to labels for filtering in Loki - stage.labels { - values = { - status = "", - method = "", - host = "", - cache_status = "", - } - } - - // --- Derived metrics (exposed on Alloy's /metrics endpoint) --- - - stage.metrics { - metric.counter { - name = "flyio_nginx_http_requests_total" - description = "Total HTTP requests by status, method, and host." - match_all = true - action = "inc" - } - } - - stage.metrics { - metric.histogram { - 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, 15, 20, 30, 45, 60] - } - } - - 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" - description = "Total bytes sent in HTTP responses." - source = "body_bytes_sent" - action = "add" - } - } - - stage.metrics { - metric.counter { - name = "flyio_nginx_cache_requests_total" - description = "Total cache lookups by cache status." - source = "cache_status" - action = "inc" - } - } -} - -// Add instance label to logs -loki.relabel "instance" { - forward_to = [loki.write.loki.receiver] - - rule { - target_label = "instance" - replacement = "flyio-proxy" - } -} - -// Write logs to Loki via Tailscale Ingress (direct, bypasses Caddy) -// Uses direct Tailscale endpoint because flyio-proxy ACLs only allow -// tag:flyio-target — Caddy on indri (tag:homelab) is not reachable. -loki.write "loki" { - endpoint { - url = "https://loki.tail8d86e.ts.net/loki/api/v1/push" - } -} - -// ============== METRICS PIPELINE ============== - -// Self-scrape to collect the log-derived metrics from /metrics -prometheus.scrape "self" { - targets = [{"__address__" = "127.0.0.1:12345"}] - forward_to = [prometheus.relabel.instance.receiver] - scrape_interval = "15s" -} - -// Strip the "loki_process_custom_" prefix that Alloy adds to stage.metrics, -// then add instance label. This keeps dashboard queries clean. -prometheus.relabel "instance" { - forward_to = [prometheus.remote_write.prometheus.receiver] - - rule { - source_labels = ["__name__"] - regex = "loki_process_custom_(.*)" - target_label = "__name__" - replacement = "$1" - } - - // Drop internal labels added by the loki pipeline - rule { - regex = "component_id|component_path|filename" - action = "labeldrop" - } - - rule { - target_label = "instance" - replacement = "flyio-proxy" - } -} - -// Push metrics to Prometheus via Tailscale Ingress (direct, bypasses Caddy) -// Uses direct Tailscale endpoint because flyio-proxy ACLs only allow -// tag:flyio-target — Caddy on indri (tag:homelab) is not reachable. -prometheus.remote_write "prometheus" { - endpoint { - url = "https://prometheus.tail8d86e.ts.net/api/v1/write" - } -} diff --git a/fly/error.html b/fly/error.html deleted file mode 100644 index ef31eae..0000000 --- a/fly/error.html +++ /dev/null @@ -1,68 +0,0 @@ -<!DOCTYPE html> -<html lang="en"> -<head> -<meta charset="utf-8"> -<meta name="viewport" content="width=device-width, initial-scale=1"> -<title>Service Unavailable - - - -
-
503
-

Service Unavailable

-

The upstream server is not reachable right now. This usually means - the backend is offline for maintenance or the network tunnel is down.

-

Services should recover automatically. Please try again shortly.

-
BlumeOps — eblu.me
-
- - diff --git a/fly/fail2ban/action.d/nginx-deny.conf b/fly/fail2ban/action.d/nginx-deny.conf deleted file mode 100644 index bab8abb..0000000 --- a/fly/fail2ban/action.d/nginx-deny.conf +++ /dev/null @@ -1,23 +0,0 @@ -# Custom fail2ban action that bans IPs via an nginx deny list. -# 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;" >> && 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/fail2ban/filter.d/forge-login.conf b/fly/fail2ban/filter.d/forge-login.conf deleted file mode 100644 index 6961b9a..0000000 --- a/fly/fail2ban/filter.d/forge-login.conf +++ /dev/null @@ -1,10 +0,0 @@ -# Filter for Forgejo login failures via nginx JSON access log. -# Matches 401/403 responses to authentication endpoints, keyed on -# the client_ip field (populated from Fly-Client-IP header). - -[Definition] - -# Match JSON log lines with 401 or 403 status on login-related paths -failregex = "client_ip":"".*"request_uri":"\/user\/(login|sign_up|forgot_password)[^"]*".*"status":(401|403) - -ignoreregex = diff --git a/fly/fail2ban/jail.d/forge.conf b/fly/fail2ban/jail.d/forge.conf deleted file mode 100644 index 7b0843f..0000000 --- a/fly/fail2ban/jail.d/forge.conf +++ /dev/null @@ -1,8 +0,0 @@ -[forge-login] -enabled = true -filter = forge-login -logpath = /var/log/nginx/access.json.log -maxretry = 5 -findtime = 600 -bantime = 3600 -banaction = nginx-deny diff --git a/fly/fly.toml b/fly/fly.toml deleted file mode 100644 index 6ccf29d..0000000 --- a/fly/fly.toml +++ /dev/null @@ -1,33 +0,0 @@ -app = "blumeops-proxy" -primary_region = "sjc" - -[build] - -[[vm]] -memory = "512mb" - -[deploy] -strategy = "immediate" - -[http_service] -internal_port = 8080 -force_https = true -auto_stop_machines = "off" -auto_start_machines = true -min_machines_running = 1 - -[[http_service.checks]] -grace_period = "15s" -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/naughty.html b/fly/naughty.html deleted file mode 100644 index b6eada8..0000000 --- a/fly/naughty.html +++ /dev/null @@ -1,61 +0,0 @@ - - - - - - - 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. -

-
- - diff --git a/fly/nginx.conf b/fly/nginx.conf deleted file mode 100644 index ec35774..0000000 --- a/fly/nginx.conf +++ /dev/null @@ -1,496 +0,0 @@ -worker_processes auto; - -events { - worker_connections 1024; -} - -http { - include /etc/nginx/mime.types; - default_type application/octet-stream; - - # JSON access log for Alloy to tail → Loki + metric extraction - log_format json_log escape=json - '{' - '"time":"$time_iso8601",' - '"remote_addr":"$remote_addr",' - '"client_ip":"$http_fly_client_ip",' - '"request_method":"$request_method",' - '"request_uri":"$request_uri",' - '"status":$status,' - '"body_bytes_sent":$body_bytes_sent,' - '"request_time":$request_time,' - '"upstream_response_time":"$upstream_response_time",' - '"upstream_cache_status":"$upstream_cache_status",' - '"http_host":"$http_host",' - '"http_user_agent":"$http_user_agent"' - '}'; - access_log /var/log/nginx/access.json.log json_log; - - # Rate limiting zones — define per-service zones as needed - 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 - # 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. - geo $http_fly_client_ip $forge_banned { - default 0; - include /etc/nginx/forge-deny.conf; - } - - # Proxy cache: 200MB, evict after 24h of no access - proxy_cache_path /tmp/cache levels=1:2 keys_zone=services:10m - max_size=200m inactive=24h; - - # 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 --- - # DNS resolved via Tailscale MagicDNS at config load. - resolver 100.100.100.100 valid=30s; - resolver_timeout 5s; - - # 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) --- - server { - listen 8080; - server_name docs.eblu.me; - - limit_req zone=general burst=20 nodelay; - - # Serve a friendly error page when upstreams are unreachable - # (indri offline, Tailscale tunnel down, emergency shutoff, etc.) - # proxy_cache_use_stale still takes priority when cached content exists. - error_page 502 503 504 /error.html; - location = /error.html { - root /usr/share/nginx/html; - internal; - } - location / { - proxy_pass https://indri_backend$request_uri; - proxy_ssl_verify off; - proxy_ssl_server_name on; - proxy_ssl_name docs.ops.eblu.me; - proxy_set_header Host docs.ops.eblu.me; - 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; - proxy_cache_valid 200 1d; - proxy_cache_valid 404 1m; - proxy_cache_use_stale error timeout updating; - proxy_cache_lock on; - - # Prevent cache-busting: ignore query strings and - # client cache-control headers. - # Safe for static sites; breaks dynamic services. - proxy_cache_key $host$uri; - proxy_ignore_headers Cache-Control Set-Cookie; - - add_header X-Cache-Status $upstream_cache_status; - add_header X-Clacks-Overhead "GNU Terry Pratchett" always; - } - - } - - # --- cv.eblu.me (static site) --- - server { - listen 8080; - server_name cv.eblu.me; - - limit_req zone=general burst=20 nodelay; - - error_page 502 503 504 /error.html; - location = /error.html { - root /usr/share/nginx/html; - internal; - } - - location / { - proxy_pass https://indri_backend$request_uri; - proxy_ssl_verify off; - proxy_ssl_server_name on; - proxy_ssl_name cv.ops.eblu.me; - proxy_set_header Host cv.ops.eblu.me; - 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; - 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; - add_header X-Clacks-Overhead "GNU Terry Pratchett" always; - } - } - - # --- forge.eblu.me (dynamic, authenticated) --- - server { - listen 8080; - server_name forge.eblu.me; - - # Block fail2ban-banned IPs - if ($forge_banned) { - return 403 "Temporarily blocked. Try again later.\n"; - } - - # General rate limit — higher burst for git operations and CI webhooks - limit_req zone=general burst=50 nodelay; - - # Git LFS and repo uploads can be large - client_max_body_size 512m; - - # Security headers - 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 "strict-origin-when-cross-origin" always; - - error_page 502 503 504 /error.html; - location = /error.html { - root /usr/share/nginx/html; - internal; - } - - # Serve robots.txt directly — block crawlers from expensive endpoints - location = /robots.txt { - default_type text/plain; - 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"; - } - - # 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 - # 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; - - proxy_pass https://indri_backend$request_uri; - proxy_ssl_verify off; - proxy_ssl_server_name on; - proxy_ssl_name forge.ops.eblu.me; - proxy_intercept_errors on; - - 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_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection $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/ { - proxy_pass https://indri_backend$request_uri; - proxy_ssl_verify off; - proxy_ssl_server_name on; - proxy_ssl_name forge.ops.eblu.me; - - proxy_http_version 1.1; - proxy_set_header Connection $connection_upgrade; - - proxy_cache services; - proxy_cache_valid 200 7d; - proxy_cache_key $host$uri; - - 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; - - 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?)$ { - proxy_pass https://indri_backend$request_uri; - proxy_ssl_verify off; - proxy_ssl_server_name on; - proxy_ssl_name forge.ops.eblu.me; - - 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; - - add_header X-Cache-Status $upstream_cache_status; - add_header X-Clacks-Overhead "GNU Terry Pratchett" always; - } - - location / { - proxy_pass https://indri_backend$request_uri; - proxy_ssl_verify off; - proxy_ssl_server_name on; - proxy_ssl_name forge.ops.eblu.me; - proxy_intercept_errors on; - - # NO proxy_cache — dynamic content with sessions - - 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; - - # WebSocket support (Forgejo uses it for live updates) - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection $connection_upgrade; - - add_header X-Clacks-Overhead "GNU Terry Pratchett" always; - } - } - - # --- 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; - - location /healthz { - return 200 "ok\n"; - } - - location /stub_status { - stub_status; - allow 127.0.0.1; - deny all; - } - - location / { - return 444; - } - } -} diff --git a/fly/start.sh b/fly/start.sh deleted file mode 100644 index a924849..0000000 --- a/fly/start.sh +++ /dev/null @@ -1,46 +0,0 @@ -#!/bin/sh -set -e - -# Connect to tailnet first — nginx needs MagicDNS for upstream resolution. -# 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 --port=41641 & -sleep 2 -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 -# (the geo directive's `include` fails if the file is missing). -touch /etc/nginx/forge-deny.conf - -# Start nginx — MagicDNS is available, upstreams resolved. -nginx -g "daemon off;" & -NGINX_PID=$! -echo "Nginx started" - -# Start fail2ban for login brute-force protection. -# Non-fatal — nginx rate limiting is the primary defense; fail2ban is additive. -if fail2ban-server -b; then - echo "fail2ban started" -else - echo "WARNING: fail2ban failed to start (nginx rate limiting still active)" -fi - -# Start Alloy for observability (logs → Loki, metrics → Prometheus) -alloy run /etc/alloy/config.alloy \ - --server.http.listen-addr=127.0.0.1:12345 \ - --storage.path=/tmp/alloy-data & -echo "Alloy started" - -# Block on nginx — container exits if nginx stops -wait $NGINX_PID diff --git a/mise-tasks/ai-docs b/mise-tasks/ai-docs deleted file mode 100755 index 66e11d7..0000000 --- a/mise-tasks/ai-docs +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/env bash -#MISE description="Prime AI context with all BlumeOps documentation" - -set -euo pipefail - -DOCS_DIR="$(cd "$(dirname "$0")/.." && pwd)/docs" - -# 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 deleted file mode 100755 index 325b6e5..0000000 --- a/mise-tasks/ai-sources +++ /dev/null @@ -1,20 +0,0 @@ -#!/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$' \ - | grep -v '^docs/' \ - | while read -r f; do - printf '=== %s ===\n' "$f" - cat "$ROOT/$f" - printf '\n' - done diff --git a/mise-tasks/blumeops-tasks b/mise-tasks/blumeops-tasks new file mode 100755 index 0000000..603bbe7 --- /dev/null +++ b/mise-tasks/blumeops-tasks @@ -0,0 +1,141 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.12" +# dependencies = ["httpx>=0.27.0", "rich>=13.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 + +import httpx +from rich.console import Console +from rich.text import Text + +TODOIST_API_BASE = "https://api.todoist.com/rest/v2" +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", + "--vault", + "vg6xf6vvfmoh5hqjjhlhbeoaie", + "item", + "get", + "c53h3xnmswhvexa5mntoyvhgpm", + "--fields", + "credential", + "--reveal", + ], + 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.""" + response = client.get(f"{TODOIST_API_BASE}/projects") + response.raise_for_status() + projects = response.json() + + for project in projects: + if project["name"] == project_name: + return project["id"] + + 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.""" + response = client.get(f"{TODOIST_API_BASE}/tasks", params={"project_id": project_id}) + response.raise_for_status() + return response.json() + + +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)) + + +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 and sort tasks + tasks = get_tasks(client, project_id) + 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}") + console.print(header) + + # Description indented + if description: + for line in description.split("\n"): + console.print(f" {line}", style="dim") + + console.print() + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/mise-tasks/branch-cleanup b/mise-tasks/branch-cleanup deleted file mode 100755 index a538880..0000000 --- a/mise-tasks/branch-cleanup +++ /dev/null @@ -1,440 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.12" -# 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" -#USAGE flag "--dry-run" help="Show what would be deleted without deleting" -#USAGE flag "--yes" help="Skip confirmation prompt" -#USAGE flag "--local-only" help="Only clean up local branches" -#USAGE flag "--remote-only" help="Only clean up remote branches" -#USAGE flag "--token " help="Forgejo API token (default: read from 1Password)" -#USAGE flag "--cutoff " default="30" help="Only delete branches whose HEAD commit is older than N days (default 30)" -"""Clean up merged branches locally and on the Forgejo remote. - -Detects merged branches via two methods: - 1. git branch --merged (catches fast-forward merges) - 2. Forgejo API (catches squash-merged PRs) - -Remote branches are deleted via the Forgejo API. The token is resolved: - 1. --token flag (explicit) - 2. FORGEJO_TOKEN environment variable (for CI) - 3. 1Password: op read (for local use, prompts biometric) - -Local branches are deleted via git branch -D. - -Warns about stale local branches that couldn't be confirmed as merged. - -Usage: - mise run branch-cleanup # interactive cleanup (30-day cutoff) - mise run branch-cleanup --cutoff 7 # only branches older than 7 days - mise run branch-cleanup --cutoff 0 # all merged branches regardless of age - mise run branch-cleanup --dry-run # preview only -""" - -import os -import subprocess -from datetime import datetime, timezone -from typing import Annotated - -import httpx -import typer -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( - ["git", *args], - capture_output=True, - text=True, - check=True, - ) - return result.stdout.strip() - - -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]") - try: - result = subprocess.run( - ["op", "read", OP_TOKEN_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 token from 1Password:[/red] {e}") - console.print("[dim]Pass --token explicitly or ensure op CLI is available[/dim]") - raise typer.Exit(1) - - -def branch_head_age_days(ref: str) -> int | None: - """Get the age in days of the HEAD commit on a branch ref.""" - try: - date_str = run_git("log", "-1", "--format=%aI", ref) - if not date_str: - return None - commit_date = datetime.fromisoformat(date_str) - return (datetime.now(timezone.utc) - commit_date).days - except subprocess.CalledProcessError: - return None - - -def api_branch_age_days(commit_date_str: str) -> int | None: - """Compute age in days from an ISO date string.""" - try: - commit_date = datetime.fromisoformat(commit_date_str) - return (datetime.now(timezone.utc) - commit_date).days - except (ValueError, TypeError): - return None - - -def get_git_merged_local_branches() -> set[str]: - """Get local branches that are fully merged into main (fast-forward).""" - try: - output = run_git("branch", "--merged", "main") - except subprocess.CalledProcessError: - return set() - branches = set() - for line in output.splitlines(): - name = line.strip().lstrip("* ") - if name and not is_protected(name): - branches.add(name) - return branches - - -def get_git_merged_remote_branches() -> set[str]: - """Get remote branches that are fully merged into origin/main (fast-forward).""" - try: - output = run_git("branch", "-r", "--merged", "origin/main") - except subprocess.CalledProcessError: - return set() - branches = set() - for line in output.splitlines(): - name = line.strip() - if " -> " in name: - continue - if not name.startswith("origin/"): - continue - short = name.removeprefix("origin/") - if short not in PROTECTED_BRANCHES: - branches.add(short) - return branches - - -def get_all_local_branches() -> set[str]: - """Get all local branch names.""" - output = run_git("branch") - branches = set() - for line in output.splitlines(): - name = line.strip().lstrip("* ") - if name and not is_protected(name): - branches.add(name) - return branches - - -def get_api_branches(client: httpx.Client) -> dict[str, str]: - """Get all remote branches via API. Returns {name: commit_date_iso}.""" - branches: dict[str, str] = {} - page = 1 - limit = 50 - while True: - resp = client.get( - f"{FORGE_API}/repos/{REPO_OWNER}/{REPO_NAME}/branches", - params={"limit": limit, "page": page}, - ) - resp.raise_for_status() - data = resp.json() - if not data: - break - for branch in data: - name = branch["name"] - if not is_protected(name): - date = branch.get("commit", {}).get("timestamp", "") - branches[name] = date - page += 1 - return branches - - -def get_merged_pr_branches(client: httpx.Client, console: Console) -> set[str]: - """Query Forgejo API for branch names from merged PRs.""" - merged_branches: set[str] = set() - page = 1 - limit = 50 - try: - while True: - resp = client.get( - f"{FORGE_API}/repos/{REPO_OWNER}/{REPO_NAME}/pulls", - params={"state": "closed", "limit": limit, "page": page}, - ) - resp.raise_for_status() - prs = resp.json() - if not prs: - break - for pr in prs: - if pr.get("merged"): - head = pr.get("head", {}) - ref = head.get("ref", "") - # Forgejo rewrites ref to refs/pull/N/head once the - # source branch is deleted; the original name is in label - if ref.startswith("refs/pull/"): - ref = head.get("label", "") - if ref and ref not in PROTECTED_BRANCHES: - merged_branches.add(ref) - page += 1 - except httpx.HTTPError as e: - console.print(f"[yellow]Warning:[/yellow] Failed to query merged PRs: {e}") - return merged_branches - - -def delete_local_branch(branch: str) -> tuple[bool, str]: - """Delete a local branch. Returns (success, message).""" - try: - # Use -D since squash-merged branches aren't git-ancestors of main - run_git("branch", "-D", branch) - return True, "deleted" - except subprocess.CalledProcessError as e: - return False, e.stderr.strip() - - -def delete_remote_branch_api( - client: httpx.Client, branch: str, -) -> tuple[bool, str]: - """Delete a remote branch via Forgejo API. Returns (success, message).""" - resp = client.delete( - f"{FORGE_API}/repos/{REPO_OWNER}/{REPO_NAME}/branches/{branch}", - ) - if resp.status_code == 204: - return True, "deleted" - return False, f"HTTP {resp.status_code}: {resp.text[:120]}" - - -app = typer.Typer(add_completion=False) - - -@app.command() -def main( - cutoff: Annotated[ - int, - typer.Option(help="Only delete branches whose HEAD commit is older than N days"), - ] = 30, - dry_run: Annotated[ - bool, - typer.Option("--dry-run", help="Show what would be deleted without deleting"), - ] = False, - yes: Annotated[ - bool, - typer.Option("--yes", "-y", help="Skip confirmation prompt"), - ] = False, - local_only: Annotated[ - bool, - typer.Option("--local-only", help="Only clean up local branches"), - ] = False, - remote_only: Annotated[ - bool, - typer.Option("--remote-only", help="Only clean up remote branches"), - ] = False, - token: Annotated[ - str | None, - typer.Option("--token", help="Forgejo API token (default: read from 1Password)"), - ] = None, -) -> None: - """Delete branches that have been merged into main.""" - console = Console() - - do_local = not remote_only - do_remote = not local_only - - # Resolve token (needed for API branch listing and deletion) - api_token = resolve_token(token, console) if do_remote else None - - # Fetch latest remote state for local branch operations - if do_local: - console.print("[dim]Fetching remote branches...[/dim]") - try: - run_git("fetch", "--prune", "origin") - except subprocess.CalledProcessError as e: - console.print(f"[yellow]Warning:[/yellow] git fetch failed: {e.stderr}") - - # Gather merge info - console.print("[dim]Checking Forgejo for squash-merged PRs...[/dim]") - with httpx.Client( - timeout=15, - headers={"Authorization": f"token {api_token}"} if api_token else {}, - ) as client: - api_merged = get_merged_pr_branches(client, console) - git_merged_local = get_git_merged_local_branches() if do_local else set() - git_merged_remote = get_git_merged_remote_branches() if do_local else set() - - # Union of all confirmed-merged branch names - all_confirmed_merged = api_merged | git_merged_local | git_merged_remote - - # Remote branches and ages via API - remote_branch_ages: dict[str, int | None] = {} - if do_remote: - console.print("[dim]Listing remote branches via API...[/dim]") - api_branches = get_api_branches(client) - for name, date_str in api_branches.items(): - remote_branch_ages[name] = api_branch_age_days(date_str) - all_remote = set(api_branches.keys()) - else: - all_remote = set() - - remote_to_delete = sorted(all_remote & all_confirmed_merged) - - # Local branches - all_local = get_all_local_branches() if do_local else set() - local_to_delete = sorted(all_local & all_confirmed_merged) - - # Compute ages for all candidates - all_candidates = sorted(set(local_to_delete) | set(remote_to_delete)) - branch_ages: dict[str, int | None] = {} - for name in all_candidates: - age = remote_branch_ages.get(name) - if age is None and name in all_local: - age = branch_head_age_days(name) - branch_ages[name] = age - - # Apply cutoff filter - local_to_delete = [ - b for b in local_to_delete - if branch_ages.get(b) is not None and branch_ages[b] >= cutoff # type: ignore[operator] - ] - remote_to_delete = [ - b for b in remote_to_delete - if branch_ages.get(b) is not None and branch_ages[b] >= cutoff # type: ignore[operator] - ] - - filtered_candidates = sorted(set(local_to_delete) | set(remote_to_delete)) - skipped_count = len(all_candidates) - len(filtered_candidates) - - if filtered_candidates: - table = Table(title=f"Merged branches to delete (older than {cutoff} days)") - table.add_column("Branch") - table.add_column("Age (days)", justify="right") - table.add_column("Method") - table.add_column("Local") - table.add_column("Remote") - - for branch in filtered_candidates: - has_local = branch in local_to_delete - has_remote = branch in remote_to_delete - age = branch_ages.get(branch) - age_str = str(age) if age is not None else "?" - - methods = [] - if branch in git_merged_local or branch in git_merged_remote: - methods.append("git") - if branch in api_merged: - methods.append("api") - method_str = "+".join(methods) - - table.add_row( - branch, - age_str, - method_str, - "[yellow]delete[/yellow]" if has_local else "[dim]-[/dim]", - "[yellow]delete[/yellow]" if has_remote else "[dim]-[/dim]", - ) - - console.print(table) - console.print( - f"\n[bold]{len(local_to_delete)}[/bold] local, " - f"[bold]{len(remote_to_delete)}[/bold] remote branches to delete" - ) - if skipped_count: - console.print(f"[dim]({skipped_count} merged branches skipped — " - f"newer than {cutoff} days)[/dim]") - else: - console.print(f"[green]No merged branches older than " - f"{cutoff} days to clean up.[/green]") - if skipped_count: - console.print(f"[dim]({skipped_count} merged branches skipped — " - f"newer than {cutoff} days)[/dim]") - - # Warn about stale unmerged local branches - if do_local: - unmerged_local = all_local - all_confirmed_merged - stale_unmerged = [] - for name in sorted(unmerged_local): - age = branch_head_age_days(name) - if age is not None and age >= cutoff: - stale_unmerged.append((name, age)) - - if stale_unmerged: - console.print() - warn_table = Table( - title=f"[yellow]Warning:[/yellow] Stale unmerged local branches " - f"(older than {cutoff} days)", - title_style="", - ) - warn_table.add_column("Branch") - warn_table.add_column("Age (days)", justify="right") - for name, age in stale_unmerged: - warn_table.add_row(f"[yellow]{name}[/yellow]", str(age)) - console.print(warn_table) - console.print( - f"[dim]These {len(stale_unmerged)} branches have no merged PR on " - f"Forgejo and are not git-ancestors of main.\n" - f"They may contain work-in-progress — inspect manually " - f"before deleting.[/dim]" - ) - - if not filtered_candidates: - raise typer.Exit(0) - - if dry_run: - console.print("\n[dim]Dry run — no branches were deleted.[/dim]") - raise typer.Exit(0) - - # Confirm - if not yes and not typer.confirm("\nProceed with deletion?"): - console.print("[dim]Aborted.[/dim]") - raise typer.Exit(0) - - # Delete remote branches via API - if remote_to_delete: - console.print("\n[bold]Deleting remote branches...[/bold]") - for branch in remote_to_delete: - ok, msg = delete_remote_branch_api(client, branch) - if ok: - console.print(f" [green]✓[/green] origin/{branch}") - else: - console.print(f" [red]✗[/red] origin/{branch}: {msg}") - - # Delete local branches (outside httpx client context) - if local_to_delete: - console.print("\n[bold]Deleting local branches...[/bold]") - for branch in local_to_delete: - ok, msg = delete_local_branch(branch) - if ok: - console.print(f" [green]✓[/green] {branch}") - else: - console.print(f" [red]✗[/red] {branch}: {msg}") - - console.print("\n[green]Done.[/green]") - - -if __name__ == "__main__": - app() diff --git a/mise-tasks/changelog-check b/mise-tasks/changelog-check deleted file mode 100755 index 2bc76ea..0000000 --- a/mise-tasks/changelog-check +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env bash -#MISE description="Validate changelog fragments are flat files in docs/changelog.d/" -# Ensures no fragments end up in subdirectories (e.g. from branch names like feature/foo). -# Towncrier expects flat ..md files, not nested paths. - -set -euo pipefail - -CHANGELOG_DIR="$(git rev-parse --show-toplevel)/docs/changelog.d" - -errors=0 -while IFS= read -r -d '' entry; do - rel="${entry#"$CHANGELOG_DIR/"}" - if [[ "$rel" == */* ]]; then - echo "ERROR: changelog fragment in subdirectory: docs/changelog.d/$rel" - echo " Move to: docs/changelog.d/$(echo "$rel" | tr '/' '-')" - errors=$((errors + 1)) - fi -done < <(find "$CHANGELOG_DIR" -name '*.md' -print0) - -if [ "$errors" -gt 0 ]; then - echo "" - echo "$errors fragment(s) in subdirectories. Towncrier requires flat files in docs/changelog.d/." - exit 1 -fi - -echo "All changelog fragments are correctly placed." diff --git a/mise-tasks/container-build-and-release b/mise-tasks/container-build-and-release deleted file mode 100755 index 85e6cb8..0000000 --- a/mise-tasks/container-build-and-release +++ /dev/null @@ -1,212 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.12" -# 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/)" -#USAGE flag "--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 workflow via Forgejo API dispatch. - -Dispatches the unified build-container workflow, which handles both -Dockerfile and Nix builds in a single workflow. -""" - -import subprocess -import sys -import time -from pathlib import Path - -import httpx -import typer - -REGISTRY = "registry.ops.eblu.me" -FORGE_URL = "https://forge.eblu.me" -FORGE_API = f"{FORGE_URL}/api/v1" -REPO = "eblume/blumeops" -FORGE_ACTIONS = f"{FORGE_URL}/{REPO}/actions" - -WORKFLOW = "build-container.yaml" - -app = typer.Typer(add_completion=False) - - -def git(*args: str) -> str: - result = subprocess.run( - ["git", *args], capture_output=True, text=True, check=True - ) - return result.stdout.strip() - - -def get_forge_token() -> str: - result = subprocess.run( - ["op", "read", "op://blumeops/w3663ffnvkewbftncqxtcpeavy/api-token"], - capture_output=True, - text=True, - check=True, - ) - 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()): - 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(): - types.append("nix") - if types: - typer.echo(f" - {d.name} ({', '.join(types)})") - - -@app.command() -def main( - container: str = typer.Argument(help="Container name (directory under containers/)"), - ref: str = typer.Option("", "--ref", help="Commit SHA to build (defaults to current HEAD)"), - dry_run: bool = typer.Option(False, "--dry-run", help="Show what would be done without triggering"), -) -> 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_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) - - if not ref: - ref = git("rev-parse", "HEAD") - else: - # Resolve short SHAs or branch names to full SHA - ref = git("rev-parse", ref) - - short_sha = ref[:7] - - image = f"blumeops/{container}" - - # Show expected builds - builds = [] - 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':12s} -> {REGISTRY}/{image}:v-{short_sha}-nix") - - if dry_run: - typer.echo("[dry-run mode]") - typer.echo(f"Container: {container}") - typer.echo(f"Commit: {ref} ({short_sha})") - typer.echo(f"Expected builds:") - for b in builds: - typer.echo(b) - typer.echo() - - if dry_run: - typer.echo(f"[dry-run] Would dispatch {WORKFLOW}") - typer.echo() - typer.echo("Monitor builds with: mise run runner-logs") - typer.echo(f" or visit: {FORGE_ACTIONS}") - return - - token = get_forge_token() - headers = { - "Authorization": f"token {token}", - "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) - - # 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", - "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() - 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__": - app() diff --git a/mise-tasks/container-list b/mise-tasks/container-list index 7dad346..21a2ad9 100755 --- a/mise-tasks/container-list +++ b/mise-tasks/container-list @@ -1,150 +1,53 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.12" -# dependencies = ["httpx==0.28.1", "rich==15.0.0", "typer==0.26.2"] -# /// +#!/usr/bin/env bash #MISE description="List available containers and their recent tags" -#USAGE arg "[name]" help="Optional container name to filter output" -"""List container images and their recent registry tags. -Shows build type (dockerfile/nix), registry path, and recent tags from the -zot registry. Tags are annotated with [main] or [branch] to indicate whether -the build commit is an ancestor of origin/main. +set -euo pipefail -Usage: - mise run container-list # all containers - mise run container-list prometheus # single container (more tags shown) -""" +REGISTRY="registry.tail8d86e.ts.net" +WORKFLOW_DIR=".forgejo/workflows" -import re -import subprocess -from pathlib import Path +echo "Container Images" +echo "================" +echo "" -import httpx -import typer -from rich.console import Console -from rich.table import Table +# Find all build-*.yaml workflows +for workflow in "$WORKFLOW_DIR"/build-*.yaml; do + [[ -f "$workflow" ]] || continue -REGISTRY = "registry.ops.eblu.me" -CONTAINER_DIR = Path("containers") + # Extract container name from filename: build-runner.yaml -> runner + filename=$(basename "$workflow") + container="${filename#build-}" + container="${container%.yaml}" -console = Console() -app = typer.Typer(add_completion=False) + # Skip if not a container build workflow (check for image_name) + if ! grep -q "image_name:" "$workflow" 2>/dev/null; then + continue + fi + # Extract image name from workflow + image=$(grep -E "^\s+image_name:" "$workflow" | head -1 | awk '{print $2}') -def git(*args: str) -> str: - result = subprocess.run( - ["git", *args], capture_output=True, text=True, check=True - ) - return result.stdout.strip() + echo "📦 $container" + echo " Image: $REGISTRY/$image" + echo " Workflow: $workflow" + # Query zot for recent tags + tags=$(curl -sf "https://$REGISTRY/v2/$image/tags/list" 2>/dev/null | jq -r '.tags // [] | .[]' | grep -E '^v[0-9]' | sort -V | tail -4 || true) -def sha_hint(tag: str) -> str: - """Check if the 7-char hex SHA in a tag is on origin/main.""" - match = re.search(r"[0-9a-f]{7}", tag) - if not match: - return "" - sha = match.group() - try: - full_sha = git("rev-parse", "--verify", sha) - except subprocess.CalledProcessError: - return "[dim]\\[unknown][/dim]" - try: - git("merge-base", "--is-ancestor", full_sha, "origin/main") - return "[green]\\[main][/green]" - except subprocess.CalledProcessError: - return "[yellow]\\[branch][/yellow]" + if [[ -n "$tags" ]]; then + echo " Recent tags:" + echo "$tags" | while read -r tag; do + echo " - $tag" + done + else + echo " Recent tags: (none)" + fi + echo "" +done - -def get_tags(image: str) -> list[str]: - """Query zot registry for version tags.""" - try: - resp = httpx.get( - f"https://{REGISTRY}/v2/{image}/tags/list", timeout=10 - ) - resp.raise_for_status() - tags = resp.json().get("tags", []) - return sorted( - [t for t in tags if re.match(r"^v[0-9]", t)], - key=lambda t: t, - ) - except (httpx.HTTPError, ValueError): - return [] - - -def discover_containers() -> list[dict]: - """Find container directories with build files.""" - containers = [] - 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_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: - types.append("nix") - containers.append({ - "name": d.name, - "types": types, - "path": str(d), - }) - return containers - - -@app.command() -def main( - name: str = typer.Argument("", help="Container name to filter (optional)"), -) -> None: - """List available containers and their recent tags.""" - containers = discover_containers() - - if name: - containers = [c for c in containers if c["name"] == name] - if not containers: - console.print(f"[red]No container found matching '{name}'.[/red]") - console.print("Run without arguments to see all containers.") - raise typer.Exit(1) - - tag_count = 10 if name else 4 - - for c in containers: - image = f"blumeops/{c['name']}" - label = "+".join(c["types"]) - tags = get_tags(image) - recent = tags[-tag_count:] if tags else [] - - console.print(f"[bold]\\[{label}] {c['name']}[/bold]") - console.print(f" Image: {REGISTRY}/{image}") - console.print(f" Path: {c['path']}") - - if recent: - console.print(" Recent tags:") - for tag in recent: - hint = sha_hint(tag) - console.print(f" - {tag} {hint}") - else: - console.print(" Recent tags: [dim](none)[/dim]") - console.print() - - console.print("[dim]---[/dim]") - console.print( - "Tags marked [green]\\[main][/green] were built from a commit on main." - ) - console.print( - "Tags marked [yellow]\\[branch][/yellow] were built from a PR branch — " - "use \\[main] tags in production manifests." - ) - console.print() - console.print("To trigger a build:") - console.print(" mise run container-build-and-release ") - - -if __name__ == "__main__": - app() +echo "---" +echo "To release a new version:" +echo " mise run container-release " +echo "" +echo "Example:" +echo " mise run container-release runner v1.0.0" diff --git a/mise-tasks/container-release b/mise-tasks/container-release new file mode 100755 index 0000000..9e8802b --- /dev/null +++ b/mise-tasks/container-release @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +#MISE description="Release a container image by creating a git tag" + +set -euo pipefail + +CONTAINER="${1:-}" +VERSION="${2:-}" + +if [[ -z "$CONTAINER" || -z "$VERSION" ]]; then + echo "Usage: mise run container-release " + echo "" + echo "Run 'mise run container-list' to see available containers and recent tags." + exit 1 +fi + +# Validate version format +if [[ ! "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Error: Version must be in format vX.Y.Z (e.g. v1.0.0)" + exit 1 +fi + +TAG="${CONTAINER}-${VERSION}" + +echo "Creating release tag: $TAG" +echo "" + +# Check if tag already exists +if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "Error: Tag '$TAG' already exists" + echo "Existing tags for $CONTAINER:" + git tag -l "${CONTAINER}-v*" | sort -V | tail -5 + exit 1 +fi + +# Find the workflow file to determine image name +WORKFLOW_FILE=".forgejo/workflows/build-${CONTAINER}.yaml" +if [[ ! -f "$WORKFLOW_FILE" ]]; then + echo "Error: No workflow found for container '$CONTAINER'" + echo "" + echo "Run 'mise run container-list' to see available containers." + exit 1 +fi + +# Extract image name from workflow +IMAGE=$(grep -E "^\s+image_name:" "$WORKFLOW_FILE" | head -1 | awk '{print $2}') +if [[ -z "$IMAGE" ]]; then + echo "Error: Could not determine image name from $WORKFLOW_FILE" + exit 1 +fi + +echo "Container: $CONTAINER" +echo "Workflow: $WORKFLOW_FILE" +echo "Image: registry.tail8d86e.ts.net/$IMAGE:$VERSION" +echo "" + +# Confirm +read -p "Create tag and push? [y/N] " -n 1 -r +echo +if [[ ! $REPLY =~ ^[Yy]$ ]]; then + echo "Aborted." + exit 0 +fi + +# Create and push tag +git tag "$TAG" +git push origin "$TAG" + +echo "" +echo "✅ Tag '$TAG' created and pushed" +echo "" +echo "The workflow will now build and push:" +echo " registry.tail8d86e.ts.net/$IMAGE:$VERSION" +echo "" +echo "Monitor the build at:" +echo " https://forge.tail8d86e.ts.net/eblume/blumeops/actions" diff --git a/mise-tasks/container-version-check b/mise-tasks/container-version-check deleted file mode 100755 index 06f96ae..0000000 --- a/mise-tasks/container-version-check +++ /dev/null @@ -1,269 +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.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" -"""Validate that container versions are consistent across all declaration sites. - -For each container directory under containers/, checks: -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. - -Usage: - mise run container-version-check # changed containers only - mise run container-version-check --all-files # all containers -""" - -import re -import shutil -import subprocess -from pathlib import Path - -import typer -import yaml -from rich.console import Console -from rich.table import Table - -REPO_ROOT = Path(__file__).parent.parent -CONTAINERS_DIR = REPO_ROOT / "containers" -SERVICE_VERSIONS_FILE = REPO_ROOT / "service-versions.yaml" - -# Containers that are utility/test images, not tracked services -BLACKLIST = {"kubectl"} - -# Container dir name → service-versions.yaml name (when they differ) -CONTAINER_TO_SERVICE = { - "kiwix-serve": "kiwix", -} - -# Container dir name → nixpkgs package name for dagger nix-version. -# Used for containers that use an unmodified nixpkgs package (version matches upstream). -# Containers with local overrides (e.g. ntfy) declare version in default.nix -# and are detected automatically via NIX_VERSION_PATTERN. -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) - -app = typer.Typer() -console = Console() - - -def strip_v(version: str) -> str: - """Strip leading 'v' prefix for comparison.""" - return version.lstrip("v") - - -def changed_containers() -> set[str] | None: - """Return container names with changes vs main, or None on git failure.""" - result = subprocess.run( - ["git", "diff", "--name-only", "main...HEAD"], - capture_output=True, - text=True, - cwd=REPO_ROOT, - ) - if result.returncode != 0: - return None - - names: set[str] = set() - sv_changed = False - for line in result.stdout.splitlines(): - if line.startswith("containers/"): - parts = line.split("/") - if len(parts) >= 2: - names.add(parts[1]) - if line == "service-versions.yaml": - sv_changed = True - - # If service-versions.yaml changed, check all containers - if sv_changed: - return None - - return names - - -def get_nix_version(container_name: str, nix_file: Path) -> str | None: - """Extract nix package version. Tries local nix file first, then dagger.""" - # Try extracting version declared directly in the nix file (local overrides) - match = NIX_VERSION_PATTERN.search(nix_file.read_text()) - if match: - return match.group(1) - - # Fall back to dagger for unmodified nixpkgs packages - pkg = NIX_PACKAGE_MAP.get(container_name) - if pkg is None: - return None - - if not shutil.which("dagger"): - return None - - result = subprocess.run( - ["dagger", "call", "nix-version", f"--package={pkg}"], - capture_output=True, - text=True, - cwd=REPO_ROOT, - ) - if result.returncode != 0: - return None - - return result.stdout.strip().splitlines()[-1].strip() - - -@app.command() -def main( - all_files: bool = typer.Option(False, "--all-files", help="Check all containers, not just changed ones"), -) -> None: - """Validate container version consistency.""" - # Determine which containers to check - if all_files: - scope = None # check all - else: - scope = changed_containers() # None means check all (fallback) - - # Load service versions - data = yaml.safe_load(SERVICE_VERSIONS_FILE.read_text()) - services = {svc["name"]: svc for svc in data.get("services", [])} - - errors: list[tuple[str, str]] = [] - results: list[dict] = [] - - for container_dir in sorted(CONTAINERS_DIR.iterdir()): - if not container_dir.is_dir(): - continue - - name = container_dir.name - if name in BLACKLIST: - continue - 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 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: 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: - versions["dockerfile"] = match.group(1) - else: - errors.append((name, "Dockerfile missing ARG CONTAINER_APP_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: - versions["nix"] = nix_ver - elif name in NIX_PACKAGE_MAP: - errors.append((name, "Failed to extract nix version via dagger")) - - # Rule 4: service-versions.yaml entry with non-null version - svc_name = CONTAINER_TO_SERVICE.get(name, name) - svc = services.get(svc_name) - if svc is None: - errors.append((name, f"No entry '{svc_name}' in service-versions.yaml")) - elif svc.get("current-version") is None: - errors.append((name, f"Null current-version for '{svc_name}' in service-versions.yaml")) - else: - versions["service-versions"] = str(svc["current-version"]) - - # Rule 5: all resolved versions must match - if len(versions) >= 2: - normalized = {src: strip_v(v) for src, v in versions.items()} - unique = set(normalized.values()) - if len(unique) > 1: - detail = ", ".join(f"{src}={v}" for src, v in sorted(versions.items())) - errors.append((name, f"Version mismatch: {detail}")) - - # Output - console.print("[bold]Container Version Sync Check[/bold]") - if scope is not None: - console.print(f"Scope: {len(scope)} container(s) changed vs main") - else: - console.print("Scope: all containers") - console.print() - - if results: - table = Table(show_header=True, header_style="bold") - table.add_column("Container") - table.add_column("Build") - table.add_column("Versions") - table.add_column("Status") - - 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"]: - build_parts.append("nix") - - ver_parts = [f"{src}={v}" for src, v in sorted(entry["versions"].items())] - has_error = any(e[0] == name for e in errors) - status = "[red]FAIL[/red]" if has_error else "[green]OK[/green]" - - table.add_row( - name, - "+".join(build_parts), - ", ".join(ver_parts) or "—", - status, - ) - - console.print(table) - console.print() - - if errors: - console.print(f"[bold red]{len(errors)} error(s):[/bold red]") - for name, msg in errors: - console.print(f" {name}: {msg}") - console.print() - raise typer.Exit(code=1) - - if not results: - console.print("[dim]No containers to check.[/dim]") - else: - console.print("[bold green]All container versions are consistent![/bold green]") - - -if __name__ == "__main__": - app() diff --git a/mise-tasks/dns-acme-cleanup b/mise-tasks/dns-acme-cleanup deleted file mode 100755 index 3a53b11..0000000 --- a/mise-tasks/dns-acme-cleanup +++ /dev/null @@ -1,112 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.12" -# 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" -"""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/mise-tasks/dns-preview b/mise-tasks/dns-preview deleted file mode 100755 index 2591640..0000000 --- a/mise-tasks/dns-preview +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env bash -#MISE description="Preview DNS changes to eblu.me with Pulumi" - -set -euo pipefail - -GANDI_PERSONAL_ACCESS_TOKEN=$(op read "op://blumeops/gandi - blumeops/pat") -export GANDI_PERSONAL_ACCESS_TOKEN - -cd "$(dirname "$0")/../pulumi/gandi" -uv sync --quiet || { echo "uv sync failed — if devpi is down, run 'devpi off' and retry"; exit 1; } -pulumi stack select eblu-me -pulumi preview "$@" diff --git a/mise-tasks/dns-up b/mise-tasks/dns-up deleted file mode 100755 index 55f786a..0000000 --- a/mise-tasks/dns-up +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env bash -#MISE description="Apply DNS changes to eblu.me with Pulumi" - -set -euo pipefail - -GANDI_PERSONAL_ACCESS_TOKEN=$(op read "op://blumeops/gandi - blumeops/pat") -export GANDI_PERSONAL_ACCESS_TOKEN - -cd "$(dirname "$0")/../pulumi/gandi" -uv sync --quiet || { echo "uv sync failed — if devpi is down, run 'devpi off' and retry"; exit 1; } -pulumi stack select eblu-me -pulumi up --yes "$@" diff --git a/mise-tasks/docs-check-frontmatter b/mise-tasks/docs-check-frontmatter deleted file mode 100755 index 35e1879..0000000 --- a/mise-tasks/docs-check-frontmatter +++ /dev/null @@ -1,115 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.12" -# dependencies = ["rich==15.0.0"] -# /// -#MISE description="Check that all docs have required frontmatter fields" -"""Validate that all documentation files have required YAML frontmatter. - -Required fields: title, tags, modified - -Scans all markdown files in docs/ (excluding changelog.d/) and checks -that each file has YAML frontmatter containing the required fields. - -Usage: mise run docs-check-frontmatter -""" - -import re -import sys -from pathlib import Path - -from rich.console import Console -from rich.table import Table - -DOCS_DIR = Path(__file__).parent.parent / "docs" -HOWTO_DIR = DOCS_DIR / "how-to" -REQUIRED_FIELDS = {"title", "tags", "modified"} -# These fields are only permitted in docs/how-to/ -HOWTO_ONLY_FIELDS = {"status", "requires"} - -# Match YAML frontmatter block -FRONTMATTER_PATTERN = re.compile(r"^---\n(.*?)\n---\n", re.DOTALL) - -# Match top-level YAML keys (not indented) -KEY_PATTERN = re.compile(r"^([a-zA-Z][a-zA-Z0-9_-]*):", re.MULTILINE) - - -def extract_frontmatter_keys(file_path: Path) -> set[str] | None: - """Extract top-level keys from YAML frontmatter. Returns None if no frontmatter.""" - content = file_path.read_text() - match = FRONTMATTER_PATTERN.match(content) - if not match: - return None - frontmatter = match.group(1) - return set(KEY_PATTERN.findall(frontmatter)) - - -def main() -> int: - console = Console() - console.print("[bold]Frontmatter Validation[/bold]") - console.print(f"Required fields: {', '.join(sorted(REQUIRED_FIELDS))}") - console.print() - - missing_issues: list[tuple[str, set[str]]] = [] - misplaced_issues: list[tuple[str, set[str]]] = [] - - for md_file in sorted(DOCS_DIR.rglob("*.md")): - if "changelog.d" in md_file.parts: - continue - - rel_path = str(md_file.relative_to(DOCS_DIR)) - keys = extract_frontmatter_keys(md_file) - - if keys is None: - missing_issues.append((rel_path, REQUIRED_FIELDS)) - continue - - missing = REQUIRED_FIELDS - keys - if missing: - missing_issues.append((rel_path, missing)) - - # Check that status/requires only appear in how-to docs - is_howto = HOWTO_DIR in md_file.parents or md_file.parent == HOWTO_DIR - if not is_howto: - misplaced = keys & HOWTO_ONLY_FIELDS - if misplaced: - misplaced_issues.append((rel_path, misplaced)) - - has_issues = bool(missing_issues or misplaced_issues) - - if missing_issues: - console.print("[bold red]Missing Required Frontmatter[/bold red]") - console.print() - table = Table(show_header=True, header_style="bold") - table.add_column("File") - table.add_column("Missing Fields") - - for rel_path, missing in missing_issues: - table.add_row(rel_path, ", ".join(sorted(missing))) - - console.print(table) - console.print() - - if misplaced_issues: - console.print("[bold red]Misplaced Frontmatter Fields[/bold red]") - console.print(f"[dim]These fields are only allowed in {HOWTO_DIR.relative_to(DOCS_DIR)}/[/dim]") - console.print() - table = Table(show_header=True, header_style="bold") - table.add_column("File") - table.add_column("Disallowed Fields") - - for rel_path, misplaced in misplaced_issues: - table.add_row(rel_path, ", ".join(sorted(misplaced))) - - console.print(table) - console.print() - - if has_issues: - return 1 - - console.print("[bold green]All docs have required frontmatter![/bold green]") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/mise-tasks/docs-check-links b/mise-tasks/docs-check-links deleted file mode 100755 index 9974fc7..0000000 --- a/mise-tasks/docs-check-links +++ /dev/null @@ -1,236 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.12" -# 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. - -This script scans all markdown files in the docs/ directory (excluding -changelog.d/), extracts wiki-links, and verifies each link target resolves -to an existing file. - -Wiki-link formats supported: -- [[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) - -Resolution mirrors Quartz's "shortest" markdownLinkResolution: -bare names resolve when unique; use paths to disambiguate duplicates. - -Usage: mise run docs-check-links -""" - -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" - -# 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_wikilinks(file_path: Path) -> list[tuple[str, int, bool]]: - """Extract all wiki-link targets from a markdown file with line numbers. - - Returns list of (target, line_num, has_spaces) tuples. - has_spaces is True if the target or pipe separator had surrounding spaces. - - Ignores wiki-links inside inline code (backticks) as these are examples. - """ - content = file_path.read_text() - links = [] - - for line_num, line in enumerate(content.splitlines(), start=1): - # Remove inline code before searching for wiki-links - line_without_code = INLINE_CODE_PATTERN.sub("", line) - for match in WIKILINK_PATTERN.finditer(line_without_code): - raw_target = match.group(1) - target = raw_target.strip() - pipe_part = match.group(2) # "|Display" or None - # Check for spaces: in target, or around the pipe - has_spaces = raw_target != target - if pipe_part and (raw_target.endswith(" ") or pipe_part.startswith("| ")): - has_spaces = True - links.append((target, line_num, has_spaces)) - - return links - - -def main() -> int: - console = Console() - - # 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]] = {} - - for md_file in DOCS_DIR.rglob("*.md"): - if "changelog.d" in md_file.parts: - continue - stem = md_file.stem - rel_path_str = str(md_file.relative_to(DOCS_DIR).with_suffix("")) - 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) - - # 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(): - 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 errors - broken_links: list[tuple[str, int, str]] = [] - ambiguous_links: list[tuple[str, int, str, list[str]]] = [] - spaced_links: list[tuple[str, int, str]] = [] - - # Track linked stems for orphan detection - all_doc_stems: set[str] = set(stem_to_paths.keys()) - linked_stems: set[str] = set() - - for md_file in sorted(DOCS_DIR.rglob("*.md")): - if "changelog.d" in md_file.parts: - continue - - rel_path = str(md_file.relative_to(DOCS_DIR)) - source_stem = md_file.stem - links = extract_wikilinks(md_file) - - for target, line_num, has_spaces in links: - if has_spaces: - spaced_links.append((rel_path, line_num, target)) - continue - - # 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 [[#Heading]] — always valid - continue - - if "/" in 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(path_targets)} valid link targets in documentation.") - console.print() - - has_errors = False - - if spaced_links: - has_errors = True - console.print("[bold red]Wiki-Links With Spaces Found[/bold red]") - console.print("Wiki-links must not have spaces in the target or around the pipe.") - console.print("Use [[target|Display Text]] not [[target | Display Text]].") - 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 spaced_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 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") - table.add_column("Line", justify="right") - table.add_column("Target") - table.add_column("Possible Paths") - - for file_path, line_num, target, paths in ambiguous_links: - table.add_row(file_path, str(line_num), escape(f"[[{target}]]"), "\n".join(paths)) - - console.print(table) - console.print() - - if broken_links: - has_errors = True - console.print("[bold red]Broken Wiki-Links Found[/bold red]") - 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 broken_links: - table.add_row(file_path, str(line_num), escape(f"[[{target}]]")) - - console.print(table) - console.print() - 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 - ORPHAN_EXCEPTIONS = {"index"} - orphan_stems = sorted(all_doc_stems - linked_stems - ORPHAN_EXCEPTIONS) - if orphan_stems: - has_errors = True - console.print("[bold red]Orphan Documents Found[/bold red]") - console.print("These docs are not linked from any other document.") - console.print() - table = Table(show_header=True, header_style="bold") - table.add_column("File") - table.add_column("Stem") - - for stem in orphan_stems: - paths = stem_to_paths[stem] - for path in paths: - table.add_row(f"{path}.md", stem) - - console.print(table) - console.print() - - if has_errors: - return 1 - - console.print("[bold green]All wiki-links are valid![/bold green]") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/mise-tasks/docs-mikado b/mise-tasks/docs-mikado deleted file mode 100755 index c632e46..0000000 --- a/mise-tasks/docs-mikado +++ /dev/null @@ -1,656 +0,0 @@ -#!/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.26.2"] -# /// -#MISE description="View active Mikado dependency chains for C2 changes" -#USAGE arg "[card]" help="Card stem to show chain for" -#USAGE flag "--all" help="Show all cards in full, including complete ones" -#USAGE flag "--resume" help="Resume a chain: detect branch, show state and next steps" -"""View active Mikado dependency chains for C2 changes. - -Scans all markdown files in docs/ for YAML frontmatter with ``status: active`` -and ``requires`` fields, then builds and displays the Mikado dependency graph. - -Usage: - mise run docs-mikado # list all active chains - mise run docs-mikado deploy-authentik # show chain for a card - mise run docs-mikado deploy-authentik --all # include complete cards in full - mise run docs-mikado --resume # resume: detect branch, show state - mise run docs-mikado --resume deploy-authentik # resume specific chain -""" - -import re -import subprocess -import sys -from pathlib import Path -from typing import Annotated - -import typer -import yaml -from rich.console import Console -from rich.markdown import Markdown -from rich.panel import Panel -from rich.table import Table - -DOCS_DIR = Path(__file__).parent.parent / "docs" -REPO_DIR = Path(__file__).parent.parent - -C2_COMMIT_RE = re.compile(r"^C2\(([^)]+)\):\s+(plan|impl|close|finalize)\s+(.+)$") - - -def extract_frontmatter(file_path: Path) -> dict | None: - """Extract YAML frontmatter from a markdown file.""" - content = file_path.read_text() - if not content.startswith("---"): - return None - - end_idx = content.find("---", 3) - if end_idx == -1: - return None - - frontmatter_text = content[3:end_idx].strip() - try: - return yaml.safe_load(frontmatter_text) or {} - except yaml.YAMLError: - return None - - -def build_graph() -> dict[str, dict]: - """Build the dependency graph from all docs.""" - cards: dict[str, dict] = {} - - for md_file in sorted(DOCS_DIR.rglob("*.md")): - if "changelog.d" in md_file.parts: - continue - - frontmatter = extract_frontmatter(md_file) - if frontmatter is None: - continue - - stem = md_file.stem - cards[stem] = { - "path": md_file, - "title": frontmatter.get("title", stem), - "status": frontmatter.get("status"), - "branch": frontmatter.get("branch"), - "requires": frontmatter.get("requires", []) or [], - "required_by": [], - } - - # Compute inverse relationships - for stem, card in cards.items(): - for req in card["requires"]: - if req in cards: - cards[req]["required_by"].append(stem) - - return cards - - -def detect_cycles(cards: dict[str, dict]) -> list[list[str]]: - """Detect circular dependencies in the graph. Returns list of cycles.""" - cycles: list[list[str]] = [] - visited: set[str] = set() - on_stack: set[str] = set() - - def dfs(stem: str, path: list[str]) -> None: - if stem in on_stack: - cycle_start = path.index(stem) - cycles.append(path[cycle_start:] + [stem]) - return - if stem in visited or stem not in cards: - return - visited.add(stem) - on_stack.add(stem) - path.append(stem) - for req in cards[stem]["requires"]: - dfs(req, path) - path.pop() - on_stack.discard(stem) - - for stem in cards: - dfs(stem, []) - - return cycles - - -def is_active(card: dict) -> bool: - """Check if a card has status: active.""" - return card.get("status") == "active" - - -def find_root_goals(cards: dict[str, dict]) -> list[str]: - """Find active cards that aren't required by another active card.""" - roots = [] - for stem, card in cards.items(): - if not is_active(card): - continue - # A root goal is not required by any other active card - has_active_parent = any( - is_active(cards[rb]) for rb in card["required_by"] if rb in cards - ) - if not has_active_parent: - roots.append(stem) - return sorted(roots) - - -def find_ready_leaves(cards: dict[str, dict], root: str) -> list[str]: - """Find active leaf nodes ready for work (no unmet active requires).""" - leaves = [] - visited: set[str] = set() - - def walk(stem: str) -> None: - if stem in visited or stem not in cards: - return - visited.add(stem) - card = cards[stem] - if not is_active(card): - return - # Check if all requires are met (not active) - unmet = [ - r for r in card["requires"] if r in cards and is_active(cards[r]) - ] - if not unmet: - leaves.append(stem) - for req in card["requires"]: - walk(req) - - walk(root) - return sorted(leaves) - - -def get_current_branch() -> str | None: - """Get the current git branch name.""" - try: - result = subprocess.run( - ["git", "rev-parse", "--abbrev-ref", "HEAD"], - capture_output=True, - text=True, - cwd=REPO_DIR, - ) - if result.returncode == 0: - branch = result.stdout.strip() - return branch if branch != "HEAD" else None - return None - except FileNotFoundError: - return None - - -def get_branch_commits(branch: str) -> list[dict]: - """Get commits on a branch since it diverged from main.""" - try: - result = subprocess.run( - ["git", "log", "--oneline", "--format=%H %s", f"main..{branch}"], - capture_output=True, - text=True, - cwd=REPO_DIR, - ) - if result.returncode != 0: - return [] - commits = [] - for line in result.stdout.strip().split("\n"): - if not line: - continue - sha, _, subject = line.partition(" ") - match = C2_COMMIT_RE.match(subject) - commits.append( - { - "sha": sha, - "subject": subject, - "chain": match.group(1) if match else None, - "verb": match.group(2) if match else None, - "description": match.group(3) if match else None, - "conventional": match is not None, - } - ) - # git log returns newest first; reverse for chronological - commits.reverse() - return commits - except FileNotFoundError: - return [] - - -def classify_branch_position(commits: list[dict]) -> str: - """Determine where in the invariant the branch currently is.""" - if not commits: - return "empty" - - verbs = [c["verb"] for c in commits if c["conventional"]] - if not verbs: - return "unconventional" - - last_verb = verbs[-1] - has_plan = "plan" in verbs - has_impl = "impl" in verbs - has_close = "close" in verbs - - if last_verb == "finalize": - return "finalized" - if not has_impl and not has_close: - return "planning" - if last_verb == "close": - return "between-cycles" - if last_verb == "impl": - return "mid-cycle" - if last_verb == "plan" and has_impl: - # plan after impl — invariant violation - return "invariant-violation" - return "unknown" - - -FORGE_API = "https://forge.eblu.me/api/v1" - - -def find_pr_for_branch(branch: str) -> dict | None: - """Find an open PR for the given branch via the Forgejo API.""" - import httpx - - try: - resp = httpx.get( - f"{FORGE_API}/repos/eblume/blumeops/pulls", - params={"state": "open", "limit": 50}, - timeout=10, - ) - if resp.status_code != 200: - return None - for pr in resp.json(): - if pr.get("head", {}).get("ref") == branch: - return { - "number": pr["number"], - "title": pr["title"], - "url": pr.get("html_url", ""), - } - except (httpx.HTTPError, KeyError): - pass - return None - - -def get_stash_list() -> list[str]: - """Get the list of git stash entries.""" - try: - result = subprocess.run( - ["git", "stash", "list"], - capture_output=True, - text=True, - cwd=REPO_DIR, - ) - if result.returncode == 0 and result.stdout.strip(): - return result.stdout.strip().split("\n") - except FileNotFoundError: - pass - return [] - - -def show_resume( - cards: dict[str, dict], - console: Console, - chain_name: str | None, -) -> None: - """Show resume information for continuing C2 work.""" - current_branch = get_current_branch() - roots = find_root_goals(cards) - - # Find chain-to-branch mapping from goal cards - chain_branches: dict[str, str | None] = {} - for stem in roots: - card = cards[stem] - chain_branches[stem] = card.get("branch") - - if chain_name: - # Explicit chain requested — validate - if chain_name not in cards: - console.print(f"[red]Chain not found: {chain_name}[/red]") - raise typer.Exit(code=1) - if not is_active(cards[chain_name]): - console.print( - f"[yellow]{chain_name} is not active (no status: active)[/yellow]" - ) - raise typer.Exit(code=1) - - expected_branch = cards[chain_name].get("branch") - if expected_branch and current_branch != expected_branch: - console.print( - f"[red]Branch mismatch:[/red] chain {chain_name} expects " - f"branch [bold]{expected_branch}[/bold] but you are on " - f"[bold]{current_branch}[/bold]" - ) - console.print( - f"\n[dim]Run: git checkout {expected_branch}[/dim]" - ) - raise typer.Exit(code=1) - - _show_chain_resume(cards, console, chain_name, current_branch) - return - - # No explicit chain — try to detect from branch - if current_branch and current_branch.startswith("mikado/"): - chain_stem = current_branch.removeprefix("mikado/") - # Try to match to a goal card - matched = None - for stem in roots: - if stem == chain_stem: - matched = stem - break - if cards[stem].get("branch") == current_branch: - matched = stem - break - - if matched: - _show_chain_resume(cards, console, matched, current_branch) - return - else: - console.print( - f"[yellow]On branch {current_branch} but no matching " - f"active chain found for stem '{chain_stem}'[/yellow]" - ) - - # On main or unrecognized branch — list options - console.print() - if not roots: - console.print("[dim]No active Mikado chains to resume.[/dim]") - raise typer.Exit() - - console.print("[bold]Active Mikado chains:[/bold]") - console.print() - - table = Table(show_header=True, header_style="bold") - table.add_column("Chain") - table.add_column("Title") - table.add_column("Branch") - table.add_column("Status") - table.add_column("Ready Leaves") - - for stem in roots: - card = cards[stem] - branch = card.get("branch") - if branch: - branch_display = f"[green]{branch}[/green]" - status = "in progress" - else: - branch_display = "[dim]not started[/dim]" - status = "planned" - - leaves = find_ready_leaves(cards, stem) - leaves_display = ", ".join(leaves) if leaves else "[dim]none[/dim]" - - table.add_row(stem, card["title"], branch_display, status, leaves_display) - - console.print(table) - console.print() - console.print( - "[dim]Run: mise run docs-mikado --resume to resume a specific chain[/dim]" - ) - - -def _show_chain_resume( - cards: dict[str, dict], - console: Console, - chain: str, - current_branch: str | None, -) -> None: - """Show detailed resume info for a specific chain.""" - card = cards[chain] - branch = card.get("branch") or current_branch - - console.print() - - # Look up PR for this branch - pr_info = find_pr_for_branch(branch) if branch else None - panel_lines = [ - f"[bold]{chain}[/bold] — {card['title']}", - f"Branch: [green]{branch or 'not set'}[/green]", - ] - if pr_info: - panel_lines.append( - f"PR: [cyan]#{pr_info['number']}[/cyan] — {pr_info['title']}" - ) - if pr_info["url"]: - panel_lines.append(f" {pr_info['url']}") - - console.print(Panel("\n".join(panel_lines), title="Resuming Mikado Chain")) - - if pr_info: - console.print( - f"[dim]Check PR comments: mise run pr-comments {pr_info['number']}[/dim]" - ) - - # Show branch position if we can read commits - if branch and current_branch == branch: - commits = get_branch_commits(branch) - position = classify_branch_position(commits) - - position_labels = { - "empty": "[dim]No commits on branch yet[/dim]", - "planning": "[cyan]Planning phase[/cyan] — still adding cards", - "mid-cycle": "[yellow]Mid-cycle[/yellow] — impl in progress, leaf not yet closed", - "between-cycles": "[green]Between cycles[/green] — ready for next leaf", - "finalized": "[green]Finalized[/green] — chain complete, awaiting merge", - "unconventional": "[yellow]Unconventional commits[/yellow] — commits don't follow C2() convention", - "invariant-violation": "[red]Invariant violation[/red] — plan commit found after impl", - "unknown": "[dim]Unknown position[/dim]", - } - console.print(f"\nBranch position: {position_labels.get(position, position)}") - - if commits: - console.print("\n[bold]Recent commits:[/bold]") - # Show last 10 commits - for c in commits[-10:]: - if c["conventional"]: - verb_colors = { - "plan": "cyan", - "impl": "yellow", - "close": "green", - "finalize": "magenta", - } - color = verb_colors.get(c["verb"], "white") - console.print( - f" {c['sha'][:8]} [{color}]{c['verb']}[/{color}] {c['description']}" - ) - else: - console.print( - f" {c['sha'][:8]} [dim]{c['subject']}[/dim]" - ) - - # Show ready leaves - leaves = find_ready_leaves(cards, chain) - if leaves: - console.print("\n[bold]Ready leaf nodes (no unmet dependencies):[/bold]") - for leaf in leaves: - leaf_card = cards[leaf] - console.print(f" [green]→[/green] {leaf} — {leaf_card['title']}") - else: - console.print("\n[dim]No ready leaf nodes — all leaves have unmet dependencies[/dim]") - - # Check for stashed work - stashes = get_stash_list() - if stashes: - console.print( - f"\n[yellow]Note: {len(stashes)} stash entr{'y' if len(stashes) == 1 else 'ies'} found:[/yellow]" - ) - for entry in stashes[:5]: - console.print(f" [dim]{entry}[/dim]") - if len(stashes) > 5: - console.print(f" [dim]... and {len(stashes) - 5} more[/dim]") - console.print( - "[dim]Review with: git stash list / git stash show[/dim]" - ) - - console.print() - - -def walk_chain( - cards: dict[str, dict], - stem: str, - console: Console, - show_all: bool, - visited: set[str] | None = None, - depth: int = 0, -) -> None: - """Walk the dependency tree depth-first from a card.""" - if visited is None: - visited = set() - - if stem in visited: - card = cards.get(stem) - if card: - status = "[active]" if is_active(card) else "[complete]" - console.print( - f"{' ' * depth}[dim]↑ {stem} — {card['title']} {status}" - f" (shared dep, shown above)[/dim]" - ) - else: - console.print(f"{' ' * depth}[red](missing: {stem})[/red]") - return - visited.add(stem) - - card = cards.get(stem) - if card is None: - console.print(f"{' ' * depth}[red](missing: {stem})[/red]") - return - - active = is_active(card) - - if active or show_all: - # Print full card content - console.print() - separator = "=" * 72 - console.print(f"[bold cyan]{separator}[/bold cyan]") - status_tag = "[active]" if active else "[complete]" - console.print( - f"[bold]{status_tag} {stem}[/bold] — {card['title']}" - ) - console.print(f"[dim]{card['path'].relative_to(DOCS_DIR)}[/dim]") - if card.get("branch"): - console.print(f"[dim]branch: {card['branch']}[/dim]") - if card["requires"]: - console.print(f"[dim]requires: {', '.join(card['requires'])}[/dim]") - if card["required_by"]: - console.print( - f"[dim]required-by: {', '.join(card['required_by'])}[/dim]" - ) - console.print(f"[bold cyan]{separator}[/bold cyan]") - console.print() - content = card["path"].read_text() - console.print(content) - else: - # One-line summary for complete cards - console.print( - f"{' ' * depth}[green][complete][/green] {stem} — {card['title']}" - ) - - # Recurse into dependencies - for req in card["requires"]: - walk_chain(cards, req, console, show_all, visited, depth + 1) - - -def main( - card: Annotated[ - str | None, typer.Argument(help="Card stem to show chain for") - ] = None, - all: Annotated[ - bool, - typer.Option("--all", help="Show all cards in full, including complete ones"), - ] = False, - resume: Annotated[ - bool, - typer.Option("--resume", help="Resume a chain: detect branch, show state"), - ] = False, -) -> None: - console = Console() - cards = build_graph() - - # Check for circular dependencies - cycles = detect_cycles(cards) - if cycles: - console.print("[bold red]Circular dependencies detected![/bold red]") - for cycle in cycles: - console.print(f" [red]{' → '.join(cycle)}[/red]") - console.print() - - if resume: - show_resume(cards, console, chain_name=card) - return - - if card is None: - # List all active Mikado chains - roots = find_root_goals(cards) - - if not roots: - console.print("[dim]No active Mikado chains found.[/dim]") - console.print( - "[dim]Cards need status: active in frontmatter to appear here.[/dim]" - ) - raise typer.Exit() - - table = Table( - title="Active Mikado Chains", show_header=True, header_style="bold" - ) - table.add_column("Goal Card") - table.add_column("Title") - table.add_column("Branch") - table.add_column("Active Deps", justify="right") - table.add_column("Total Deps", justify="right") - - for stem in roots: - card_data = cards[stem] - - # Count dependencies - def count_deps(s: str, seen: set[str] | None = None) -> tuple[int, int]: - if seen is None: - seen = set() - if s in seen: - return 0, 0 - seen.add(s) - active_count = 0 - total_count = 0 - c = cards.get(s) - if c is None: - return 0, 0 - for req in c["requires"]: - total_count += 1 - req_card = cards.get(req) - if req_card and is_active(req_card): - active_count += 1 - sub_active, sub_total = count_deps(req, seen) - active_count += sub_active - total_count += sub_total - return active_count, total_count - - active_deps, total_deps = count_deps(stem) - branch = card_data.get("branch") - if branch: - branch_display = branch - else: - branch_display = "[dim]not started[/dim]" - - table.add_row( - f"[bold]{stem}[/bold]", - card_data["title"], - branch_display, - str(active_deps), - str(total_deps), - ) - - console.print() - console.print(table) - console.print() - console.print( - "[dim]Run: mise run docs-mikado to see full chain[/dim]" - ) - console.print( - "[dim]Run: mise run docs-mikado --resume to resume work on a chain[/dim]" - ) - else: - if card not in cards: - console.print(f"[red]Card not found: {card}[/red]") - console.print("[dim]Available cards with status: active:[/dim]") - for stem, data in sorted(cards.items()): - if is_active(data): - console.print(f" {stem} — {data['title']}") - raise typer.Exit(code=1) - - walk_chain(cards, card, console, show_all=all) - - -if __name__ == "__main__": - typer.run(main) diff --git a/mise-tasks/docs-preview b/mise-tasks/docs-preview deleted file mode 100755 index 9e0bd16..0000000 --- a/mise-tasks/docs-preview +++ /dev/null @@ -1,140 +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.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" -#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") - # 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 - 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, filter="data") - - 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 deleted file mode 100755 index 12e301f..0000000 --- a/mise-tasks/docs-review +++ /dev/null @@ -1,199 +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.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" -"""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, 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. - -After reviewing, update the card's frontmatter: - - last-reviewed: YYYY-MM-DD - -Usage: mise run docs-review [-- --limit 10] -""" - -import subprocess -import sys -from datetime import date, datetime, timezone -from pathlib import Path -from typing import Annotated - -import typer -import yaml -from rich.console import Console -from rich.markdown import Markdown -from rich.panel import Panel -from rich.table import Table - -DOCS_DIR = Path(__file__).parent.parent / "docs" - - -def extract_frontmatter(file_path: Path) -> dict | None: - """Extract YAML frontmatter from a markdown file.""" - content = file_path.read_text() - if not content.startswith("---"): - return None - - end_idx = content.find("---", 3) - if end_idx == -1: - return None - - frontmatter_text = content[3:end_idx].strip() - try: - return yaml.safe_load(frontmatter_text) or {} - except yaml.YAMLError: - return None - - -def get_last_reviewed(frontmatter: dict) -> date | None: - """Extract last-reviewed date from frontmatter.""" - raw = frontmatter.get("last-reviewed") - if raw is None: - return None - if isinstance(raw, date): - return raw - try: - return date.fromisoformat(str(raw)) - except ValueError: - 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, datetime | None, Path]] = [] - - for md_file in sorted(DOCS_DIR.rglob("*.md")): - if "changelog.d" in md_file.parts: - continue - - 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, last_updated, md_file)) - - # 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) - - # --- Staleness table --- - console.print() - console.print(Panel( - f"[bold]{len(entries)}[/bold] total docs, " - f"[bold red]{never_reviewed}[/bold red] never reviewed", - title="[bold]Documentation Review Queue[/bold]", - border_style="cyan", - )) - console.print() - - table = Table(show_header=True, header_style="bold") - table.add_column("#", justify="right") - 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, 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 - style = "yellow" if age > 90 else "" - 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, updated_str) - - remaining = len(entries) - limit - if remaining > 0: - table.add_row("", f"[dim]… {remaining} more[/dim]", "", "", "") - - console.print(table) - console.print() - - # --- Show the most stale doc --- - if not entries: - console.print("[bold red]No documentation files found![/bold red]") - raise typer.Exit(code=1) - - 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]"), - title="[bold]Up For Review[/bold]", - border_style="green", - )) - console.print() - - console.print(f"[bold]Read the file:[/bold] {top_file.resolve()}") - console.print() - - console.print() - console.print(Panel( - "[bold]Review Checklist:[/bold]\n\n" - "• Is the information accurate and up-to-date?\n" - "• Are there broken or missing wiki-links?\n" - "• Should this card link to other related cards?\n" - "• Is the card too large and should be split?\n" - "• Is the card too small and should be merged?\n" - "• Does the frontmatter (tags, title) make sense?\n" - "• Is the card in the correct category (reference/how-to/etc)?\n\n" - "[bold]Verify Deployed State:[/bold]\n\n" - "• 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]After Review:[/bold]\n\n" - "• Update the card's frontmatter: [cyan]last-reviewed: " - + str(today) - + "[/cyan]\n" - "• 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", - )) - - -if __name__ == "__main__": - typer.run(main) diff --git a/mise-tasks/docs-review-stale b/mise-tasks/docs-review-stale deleted file mode 100755 index 0c5490e..0000000 --- a/mise-tasks/docs-review-stale +++ /dev/null @@ -1,102 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.12" -# 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" -"""Report documentation files sorted by git-last-modified date. - -Scans all markdown files in docs/ (excluding changelog.d/) and shows -their last modification date according to git. Docs older than the -threshold (default 180 days) are highlighted as stale. - -This is informational only — it always exits 0. - -Usage: mise run docs-review-stale [-- --threshold 90] -""" - -import subprocess -import sys -from datetime import datetime, timezone -from pathlib import Path -from typing import Annotated - -import typer -from rich.console import Console -from rich.table import Table - -DOCS_DIR = Path(__file__).parent.parent / "docs" - - -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( - threshold: Annotated[int, typer.Option(help="Days before a doc is considered stale")] = 180, -) -> None: - console = Console() - threshold_days = threshold - console.print("[bold]Documentation Staleness Report[/bold]") - console.print(f"Threshold: {threshold_days} days") - console.print() - - now = datetime.now(timezone.utc) - entries: list[tuple[str, datetime, int, bool]] = [] - - for md_file in sorted(DOCS_DIR.rglob("*.md")): - if "changelog.d" in md_file.parts: - continue - - last_modified = git_last_modified(md_file) - if last_modified is None: - continue - - rel_path = str(md_file.relative_to(DOCS_DIR)) - age_days = (now - last_modified).days - is_stale = age_days > threshold_days - entries.append((rel_path, last_modified, age_days, is_stale)) - - # Sort oldest-first - entries.sort(key=lambda e: e[1]) - - stale_count = sum(1 for e in entries if e[3]) - - table = Table(show_header=True, header_style="bold") - table.add_column("File") - table.add_column("Last Modified", justify="right") - table.add_column("Age (days)", justify="right") - table.add_column("Status") - - for rel_path, last_modified, age_days, is_stale in entries: - date_str = last_modified.strftime("%Y-%m-%d") - if is_stale: - table.add_row( - f"[red]{rel_path}[/red]", - f"[red]{date_str}[/red]", - f"[red]{age_days}[/red]", - "[red]STALE[/red]", - ) - else: - table.add_row(rel_path, date_str, str(age_days), "[green]OK[/green]") - - console.print(table) - console.print() - console.print(f"Total: {len(entries)} docs, {stale_count} stale") - -if __name__ == "__main__": - typer.run(main) diff --git a/mise-tasks/docs-review-tags b/mise-tasks/docs-review-tags deleted file mode 100755 index 869e2f2..0000000 --- a/mise-tasks/docs-review-tags +++ /dev/null @@ -1,89 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.12" -# 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. - -Scans all markdown files in docs/ (excluding changelog.d/) for YAML -frontmatter tags, then displays a table sorted by count showing which -docs use each tag. - -This is informational only — it always exits 0. - -Usage: mise run docs-review-tags -""" - -import sys -from collections import defaultdict -from pathlib import Path - -import yaml -from rich.console import Console -from rich.table import Table - -DOCS_DIR = Path(__file__).parent.parent / "docs" - - -def extract_frontmatter(file_path: Path) -> dict | None: - """Extract YAML frontmatter from a markdown file.""" - content = file_path.read_text() - if not content.startswith("---"): - return None - - end_idx = content.find("---", 3) - if end_idx == -1: - return None - - frontmatter_text = content[3:end_idx].strip() - try: - return yaml.safe_load(frontmatter_text) or {} - except yaml.YAMLError: - return None - - -def main() -> int: - console = Console() - console.print("[bold]Documentation Tag Inventory[/bold]") - console.print() - - # tag -> list of file paths - tag_files: dict[str, list[str]] = defaultdict(list) - - for md_file in sorted(DOCS_DIR.rglob("*.md")): - if "changelog.d" in md_file.parts: - continue - - frontmatter = extract_frontmatter(md_file) - if not frontmatter: - continue - - tags = frontmatter.get("tags", []) - if not isinstance(tags, list): - continue - - rel_path = str(md_file.relative_to(DOCS_DIR)) - for tag in tags: - tag_files[str(tag)].append(rel_path) - - # Sort by count descending, then alphabetically - sorted_tags = sorted(tag_files.items(), key=lambda t: (-len(t[1]), t[0])) - - table = Table(show_header=True, header_style="bold") - table.add_column("Tag") - table.add_column("Count", justify="right") - table.add_column("Files") - - for tag, files in sorted_tags: - table.add_row(tag, str(len(files)), "\n".join(files)) - - console.print(table) - console.print() - console.print(f"Total: {len(sorted_tags)} unique tags across {sum(len(f) for f in tag_files.values())} usages") - - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/mise-tasks/ensure-k3s-ringtail-kubectl-config b/mise-tasks/ensure-k3s-ringtail-kubectl-config deleted file mode 100755 index d8a1b80..0000000 --- a/mise-tasks/ensure-k3s-ringtail-kubectl-config +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env bash -#MISE description="Ensure kubectl config for k3s-ringtail is set up on this workstation" - -set -euo pipefail - -CONFIG_DIR="$HOME/.kube/k3s-ringtail" -CONFIG_FILE="$CONFIG_DIR/config.yml" - -echo "Ensuring k3s-ringtail kubectl config..." - -# Create directory if needed -mkdir -p "$CONFIG_DIR" - -# Fetch kubeconfig from ringtail and extract the CA cert -echo "Fetching kubeconfig from ringtail..." -RAW_CONFIG=$(ssh ringtail 'sudo cat /etc/rancher/k3s/k3s.yaml') - -# Extract and decode the CA certificate -echo "$RAW_CONFIG" | grep certificate-authority-data | awk '{print $2}' | base64 -d > "$CONFIG_DIR/ca.crt" - -# Extract and decode the client certificate -echo "$RAW_CONFIG" | grep client-certificate-data | awk '{print $2}' | base64 -d > "$CONFIG_DIR/client.crt" - -# Extract and decode the client key -echo "$RAW_CONFIG" | grep client-key-data | awk '{print $2}' | base64 -d > "$CONFIG_DIR/client.key" -chmod 600 "$CONFIG_DIR/client.key" - -# Write kubeconfig with file-based certs and tailscale hostname -cat > "$CONFIG_FILE" << EOF -apiVersion: v1 -kind: Config -clusters: -- cluster: - certificate-authority: $CONFIG_DIR/ca.crt - server: https://ringtail.tail8d86e.ts.net:6443 - name: k3s-ringtail -contexts: -- context: - cluster: k3s-ringtail - user: k3s-ringtail - name: k3s-ringtail -current-context: k3s-ringtail -users: -- name: k3s-ringtail - user: - client-certificate: $CONFIG_DIR/client.crt - client-key: $CONFIG_DIR/client.key -EOF - -echo "Config written to $CONFIG_FILE" - -# Warn if KUBECONFIG doesn't include this file -if [[ -z "${KUBECONFIG:-}" ]] || [[ ":$KUBECONFIG:" != *":$CONFIG_FILE:"* ]]; then - echo "" - echo "WARNING: KUBECONFIG does not include $CONFIG_FILE" - echo "Add this to your shell config:" - echo " export KUBECONFIG=\"\$KUBECONFIG:$CONFIG_FILE\"" -fi - -echo "" -echo "Test with: kubectl --context=k3s-ringtail get nodes" diff --git a/mise-tasks/fly-deploy b/mise-tasks/fly-deploy deleted file mode 100755 index 8d693fd..0000000 --- a/mise-tasks/fly-deploy +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env bash -#MISE description="Deploy the Fly.io public proxy" - -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 "$@" diff --git a/mise-tasks/fly-reload b/mise-tasks/fly-reload deleted file mode 100755 index 34806c5..0000000 --- a/mise-tasks/fly-reload +++ /dev/null @@ -1,16 +0,0 @@ -#!/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." diff --git a/mise-tasks/fly-setup b/mise-tasks/fly-setup deleted file mode 100755 index be797e5..0000000 --- a/mise-tasks/fly-setup +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env bash -#MISE description="One-time setup: configure Fly.io secrets and certs (idempotent)" - -set -euo pipefail - -APP="blumeops-proxy" - -# Fetch Tailscale auth key from Pulumi state -echo "Fetching Tailscale auth key from Pulumi..." -TS_AUTHKEY=$(cd "$(dirname "$0")/../pulumi/tailscale" && pulumi stack select tail8d86e && pulumi stack output flyio_authkey --show-secrets) -fly secrets set TS_AUTHKEY="$TS_AUTHKEY" --stage -a "$APP" -echo "Tailscale auth key staged (will take effect on next deploy)" - -# Allocate IPs (idempotent — fly errors if already allocated) -# Shared IPv4 is free and sufficient for HTTP/HTTPS services. -# Use 'fly ips allocate-v4' (no --shared) for dedicated IPv4 ($2/mo) -# if the service needs non-HTTP protocols. -fly ips allocate-v4 --shared -a "$APP" 2>/dev/null || true -fly ips allocate-v6 -a "$APP" 2>/dev/null || true -echo "IPs allocated" - -# Add certs for all public domains (idempotent — fly ignores duplicates) -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/mise-tasks/fly-shutoff b/mise-tasks/fly-shutoff deleted file mode 100755 index f9e4f90..0000000 --- a/mise-tasks/fly-shutoff +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env bash -#MISE description="Emergency shutoff: stop all Fly.io proxy machines" - -set -euo pipefail - -APP="blumeops-proxy" - -echo "EMERGENCY SHUTOFF: Stopping all machines for $APP" -fly scale count 0 -a "$APP" --yes -echo "All machines stopped. Public services are offline." -echo "To restore: fly scale count 1 -a $APP" diff --git a/mise-tasks/frigate-export-model b/mise-tasks/frigate-export-model deleted file mode 100755 index 09ad131..0000000 --- a/mise-tasks/frigate-export-model +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env bash -#MISE description="Export YOLOv9 model weights to ONNX for Frigate NVR via Dagger" -#USAGE flag "--model-size " default="c" help="Model variant: s (small), c (compact), e (extra-large)" -#USAGE flag "--input-size " default="640" help="Input resolution (width=height)" -#USAGE flag "--deploy" help="Copy exported model to sifaka NAS frigate share" - -set -euo pipefail - -MODEL_SIZE="${usage_model_size:-c}" -INPUT_SIZE="${usage_input_size:-640}" -DEPLOY="${usage_deploy:-false}" -OUTPUT_FILE="yolov9-${MODEL_SIZE}-${INPUT_SIZE}.onnx" - -echo "Exporting YOLOv9-${MODEL_SIZE} (${INPUT_SIZE}x${INPUT_SIZE}) via Dagger..." -echo "" - -dagger call --progress=plain export-yolov-9 \ - --model-size="$MODEL_SIZE" \ - --input-size="$INPUT_SIZE" \ - export --path="$OUTPUT_FILE" - -SIZE=$(du -h "$OUTPUT_FILE" | cut -f1) -echo "" -echo "Exported: ${OUTPUT_FILE} (${SIZE})" - -if [[ "$DEPLOY" == "true" ]]; then - DEST="sifaka:/volume1/frigate/models/${OUTPUT_FILE}" - echo "Copying to ${DEST}..." - scp -O "$OUTPUT_FILE" "$DEST" - echo "Deployed." - echo "" - echo "Update argocd/manifests/frigate/configmap.yaml:" - echo " model:" - echo " model_type: yolo-generic" - echo " width: ${INPUT_SIZE}" - echo " height: ${INPUT_SIZE}" - echo " input_tensor: nchw" - echo " input_dtype: float" - echo " path: /media/frigate/models/${OUTPUT_FILE}" - echo " labelmap_path: /labelmap/coco-80.txt" -else - echo "" - echo "To deploy to Frigate NAS:" - echo " scp ${OUTPUT_FILE} sifaka:/volume1/frigate/models/" - echo "" - echo "Then update argocd/manifests/frigate/configmap.yaml:" - echo " model:" - echo " model_type: yolo-generic" - echo " width: ${INPUT_SIZE}" - echo " height: ${INPUT_SIZE}" - echo " input_tensor: nchw" - echo " input_dtype: float" - echo " path: /media/frigate/models/${OUTPUT_FILE}" - echo " labelmap_path: /labelmap/coco-80.txt" -fi diff --git a/mise-tasks/indri-services-check b/mise-tasks/indri-services-check new file mode 100755 index 0000000..a6b6944 --- /dev/null +++ b/mise-tasks/indri-services-check @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +#MISE description="Check that all indri services are online and responding" + +set -euo pipefail + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +NC='\033[0m' # No Color + +FAILED=0 + +check_service() { + local name="$1" + local check_cmd="$2" + + printf "%-24s " "$name..." + if eval "$check_cmd" > /dev/null 2>&1; then + echo -e "${GREEN}OK${NC}" + else + echo -e "${RED}FAILED${NC}" + FAILED=1 + fi +} + +check_http() { + local name="$1" + local url="$2" + + printf "%-24s " "$name..." + if curl -sf --max-time 5 "$url" > /dev/null 2>&1; then + echo -e "${GREEN}OK${NC}" + else + echo -e "${RED}FAILED${NC}" + FAILED=1 + fi +} + +echo "Checking indri services..." +echo "==========================" +echo "" + +# Local services on indri +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 \"^-\"'" +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 \"^-\"'" +check_service "zot" "ssh indri 'launchctl list mcquack.eblume.zot | grep -v \"^-\"'" +check_service "zot-metrics" "ssh indri 'launchctl list mcquack.zot-metrics | grep -v \"^-\"'" +check_service "minikube-metrics" "ssh indri 'launchctl list mcquack.minikube-metrics | grep -v \"^-\"'" +check_service "plex-metrics" "ssh indri 'launchctl list mcquack.plex-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 "plex.prom" "ssh indri 'test -f /opt/homebrew/var/node_exporter/textfile/plex.prom'" + +echo "" +echo "Kubernetes cluster:" +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 Tailscale):" +check_http "Prometheus" "https://prometheus.tail8d86e.ts.net/-/healthy" +check_http "Loki" "https://loki.tail8d86e.ts.net/ready" +check_http "Grafana" "https://grafana.tail8d86e.ts.net/api/health" +check_http "ArgoCD" "https://argocd.tail8d86e.ts.net/healthz" +check_http "Forgejo" "https://forge.tail8d86e.ts.net/" +check_http "Zot Registry" "https://registry.tail8d86e.ts.net/v2/_catalog" +check_http "Kiwix" "https://kiwix.tail8d86e.ts.net/" +check_http "Miniflux" "https://feed.tail8d86e.ts.net/healthcheck" +check_http "TeslaMate" "https://tesla.tail8d86e.ts.net/" +check_http "Devpi" "https://pypi.tail8d86e.ts.net/+api" +check_http "Transmission" "https://torrent.tail8d86e.ts.net/" + +echo "" +echo "Database:" +check_service "PostgreSQL (k8s)" "pg_isready -h pg.tail8d86e.ts.net -p 5432" + +echo "" +echo "Kubernetes 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:" +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 +done < <(kubectl --context=minikube-indri get applications -n argocd --no-headers -o custom-columns='NAME:.metadata.name,SYNC:.status.sync.status,HEALTH:.status.health.status,TARGET:.spec.source.targetRevision' 2>/dev/null) + +echo "" +if [ $FAILED -eq 0 ]; then + echo -e "${GREEN}All services healthy!${NC}" + exit 0 +else + echo -e "${RED}Some services failed health check${NC}" + exit 1 +fi diff --git a/mise-tasks/mikado-branch-invariant-check b/mise-tasks/mikado-branch-invariant-check deleted file mode 100755 index 3135bf2..0000000 --- a/mise-tasks/mikado-branch-invariant-check +++ /dev/null @@ -1,306 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.12" -# 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)" -"""Validate the Mikado Branch Invariant for C2 change branches. - -Runs as a commit-msg hook on mikado/* branches. Receives the commit message -file as its first argument, classifies the incoming commit, appends it to the -existing branch history, and validates the full sequence. - -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. 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. - -Exit code 0 if valid (or not on a mikado/* branch), 1 if violations found. -""" - -import re -import subprocess -from pathlib import Path -from typing import Annotated - -import typer -from rich.console import Console - -REPO_DIR = Path(__file__).parent.parent -C2_COMMIT_RE = re.compile(r"^C2\(([^)]+)\):\s+(plan|impl|close|finalize)\s+(.+)$") -FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---", re.DOTALL) - - -def get_current_branch() -> str | None: - """Get the current git branch name.""" - result = subprocess.run( - ["git", "rev-parse", "--abbrev-ref", "HEAD"], - capture_output=True, - text=True, - cwd=REPO_DIR, - ) - if result.returncode == 0: - branch = result.stdout.strip() - return branch if branch != "HEAD" else None - return None - - -def get_branch_commits(branch: str) -> list[dict]: - """Get commits on a branch since it diverged from main.""" - result = subprocess.run( - ["git", "log", "--oneline", "--format=%H %s", f"main..{branch}"], - capture_output=True, - text=True, - cwd=REPO_DIR, - ) - if result.returncode != 0: - return [] - commits = [] - for line in result.stdout.strip().split("\n"): - if not line: - continue - sha, _, subject = line.partition(" ") - match = C2_COMMIT_RE.match(subject) - commits.append( - { - "sha": sha, - "subject": subject, - "chain": match.group(1) if match else None, - "verb": match.group(2) if match else None, - "description": match.group(3) if match else None, - "conventional": match is not None, - } - ) - # git log returns newest first; reverse for chronological - commits.reverse() - return commits - - -def parse_commit_message(msg_path: str) -> str: - """Read and return the first line of a commit message file.""" - with open(msg_path) as f: - for line in f: - line = line.strip() - if line and not line.startswith("#"): - return line - return "" - - -def make_pending_commit(subject: str) -> dict: - """Create a pseudo-commit dict for the incoming (not yet created) commit.""" - match = C2_COMMIT_RE.match(subject) - return { - "sha": "(pending)", - "subject": subject, - "chain": match.group(1) if match else None, - "verb": match.group(2) if match else None, - "description": match.group(3) if match else None, - "conventional": match is not None, - } - - -def is_mikado_card(content: str) -> bool: - """Check if file content has Mikado card frontmatter. - - A file is a Mikado card if its YAML frontmatter contains any of: - - requires: (dependency list for active chain cards) - - status: (e.g. 'active' for in-progress cards) - - branch: mikado/... (goal cards linking to their branch) - """ - match = FRONTMATTER_RE.match(content) - if not match: - return False - for line in match.group(1).split("\n"): - stripped = line.strip() - if stripped.startswith("requires:"): - return True - if stripped.startswith("status:"): - return True - if stripped.startswith("branch:") and "mikado/" in stripped: - return True - return False - - -def get_file_at_ref(ref: str, path: str) -> str | None: - """Get file content at a git ref. Use ':path' for staged, 'sha:path' for commits.""" - result = subprocess.run( - ["git", "show", f"{ref}:{path}"], - capture_output=True, - text=True, - cwd=REPO_DIR, - ) - return result.stdout if result.returncode == 0 else None - - -def get_staged_md_files() -> list[str]: - """Get markdown files staged for commit (added, copied, modified, renamed).""" - result = subprocess.run( - ["git", "diff", "--cached", "--name-only", "--diff-filter=ACMR"], - capture_output=True, - text=True, - cwd=REPO_DIR, - ) - if result.returncode != 0: - return [] - return [f for f in result.stdout.strip().split("\n") if f.endswith(".md")] - - -def get_commit_md_files(sha: str) -> list[str]: - """Get markdown files changed in a specific commit.""" - result = subprocess.run( - ["git", "diff-tree", "--no-commit-id", "--name-only", "-r", sha], - capture_output=True, - text=True, - cwd=REPO_DIR, - ) - if result.returncode != 0: - return [] - return [f for f in result.stdout.strip().split("\n") if f.endswith(".md")] - - -def check_impl_files_staged() -> list[str]: - """Check that staged files for a pending impl commit don't include Mikado cards.""" - errors = [] - for path in get_staged_md_files(): - # Check staged version; fall back to HEAD for deletions - content = get_file_at_ref("", path) - if content is None: - content = get_file_at_ref("HEAD", path) - if content and is_mikado_card(content): - errors.append( - f"(pending): impl commit modifies Mikado card: {path} — " - f"use 'plan' for card changes or 'close' to close leaf nodes" - ) - return errors - - -def check_impl_files_historical(sha: str) -> list[str]: - """Check that a historical impl commit didn't modify Mikado cards.""" - errors = [] - for path in get_commit_md_files(sha): - # Try file at this commit; fall back to parent for deletions - content = get_file_at_ref(sha, path) - if content is None: - content = get_file_at_ref(f"{sha}~1", path) - if content and is_mikado_card(content): - errors.append( - f"{sha[:8]}: impl commit modifies Mikado card: {path} — " - f"use 'plan' for card changes or 'close' to close leaf nodes" - ) - return errors - - -def check_invariant(commits: list[dict], chain_stem: str) -> list[str]: - """Check the Mikado Branch Invariant. Returns list of violation messages.""" - errors: list[str] = [] - - if not commits: - return errors - - seen_impl = False - seen_close = False - - for i, commit in enumerate(commits): - sha_short = commit["sha"][:8] - - if not commit["conventional"]: - errors.append( - f"{sha_short}: commit doesn't follow C2() convention: " - f"{commit['subject']}" - ) - continue - - if commit["chain"] != chain_stem: - errors.append( - f"{sha_short}: chain mismatch — expected C2({chain_stem}) " - f"but found C2({commit['chain']})" - ) - - verb = commit["verb"] - - if verb == "plan": - if seen_impl or seen_close: - errors.append( - f"{sha_short}: plan commit after impl/close — " - f"violates the Mikado Branch Invariant. " - f"New prerequisites require a branch reset." - ) - elif verb == "impl": - seen_impl = True - elif verb == "close": - seen_close = True - elif verb == "finalize": - # finalize is the permitted exception — must be last - if i != len(commits) - 1: - errors.append( - f"{sha_short}: finalize must be the last commit on the branch" - ) - - return errors - - -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 - 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 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) - raise SystemExit(0) - - errors = check_invariant(commits, chain_stem) - - # Check file discipline: impl commits must not touch Mikado cards - for commit in commits: - if commit["verb"] != "impl": - continue - if commit["sha"] == "(pending)": - # Pending commit — check staged files - errors.extend(check_impl_files_staged()) - else: - # Historical commit — check files in that commit - errors.extend(check_impl_files_historical(commit["sha"])) - - if errors: - console.print("[bold red]Mikado Branch Invariant violations:[/bold red]") - for error in errors: - console.print(f" [red]✗[/red] {error}") - console.print() - console.print( - "[dim]See: docs/explanation/agent-change-process.md " - "§ The Mikado Branch Invariant[/dim]" - ) - raise SystemExit(1) - - raise SystemExit(0) - - -if __name__ == "__main__": - app() diff --git a/mise-tasks/mirror-create b/mise-tasks/mirror-create deleted file mode 100755 index b0e82a0..0000000 --- a/mise-tasks/mirror-create +++ /dev/null @@ -1,80 +0,0 @@ -#!/usr/bin/env bash -#MISE description="Create a new upstream mirror in the mirrors/ Forgejo org" -#USAGE arg "" help="Upstream git URL to mirror (e.g. https://github.com/org/repo.git)" -#USAGE flag "--name " help="Repository name on forge (default: derived from URL)" -#USAGE flag "--description " help="Repository description" -#USAGE flag "--dry-run" help="Show what would be done without creating" -set -euo pipefail - -FORGE_API="https://forge.eblu.me/api/v1" -ORG="mirrors" -OP_TOKEN_REF="op://blumeops/w3663ffnvkewbftncqxtcpeavy/api-token" -OP_GITHUB_PAT_REF="op://blumeops/w3663ffnvkewbftncqxtcpeavy/github-mirror-pat" - -url="${usage_url:?}" - -# Derive repo name from URL if not provided -if [[ -n "${usage_name:-}" ]]; then - repo_name="${usage_name}" -else - # Strip trailing .git and extract last path component - repo_name="$(basename "$url" .git)" -fi - -description="${usage_description:-}" - -# Detect service type from URL -service="git" -case "$url" in - *github.com*) service="github" ;; - *codeberg.org*) service="gitea" ;; - *forgejo.org*) service="gitea" ;; -esac - -echo "Mirror: $url" -echo "Forge repo: $ORG/$repo_name" -echo "Service: $service" -[[ -n "$description" ]] && echo "Description: $description" -echo - -if [[ "${usage_dry_run:-}" == "true" ]]; then - echo "[dry-run] Would create mirror at ${FORGE_API}/repos/migrate" - exit 0 -fi - -echo "Reading secrets from 1Password..." -token="$(op read "$OP_TOKEN_REF")" - -# For GitHub upstreams, include the PAT for authenticated sync -auth_token="" -if [[ "$service" == "github" ]]; then - auth_token="$(op read "$OP_GITHUB_PAT_REF")" - echo "Using GitHub PAT for authenticated mirror sync" -fi - -payload=$(cat </dev/null) - -if [[ -z "$mirrors" ]]; then - echo "No GitHub mirrors found." - exit 0 -fi - -updated=0 -skipped=0 - -while IFS='|' read -r org repo upstream_url; do - bare_repo="${REPO_BASE}/${org}/${repo}.git" - - # Build authenticated URL: https://eblume:@github.com/... - # Note: \/ in the replacement part of ${var/pat/rep} is literal backslash-slash, - # not an escaped slash. Use prefix stripping instead. - auth_url="https://eblume:${pat}@github.com${upstream_url#https://github.com}" - - if [[ "$DRY_RUN" == "true" ]]; then - echo "[dry-run] ${org}/${repo}: would set origin to authenticated URL" - ((updated++)) - continue - fi - - # Check current remote URL (< /dev/null prevents ssh from consuming loop stdin) - current_url=$(ssh indri "git -C '${bare_repo}' config remote.origin.url" < /dev/null 2>/dev/null || echo "") - - if [[ "$current_url" == "$auth_url" ]]; then - echo " ${org}/${repo}: already up to date" - ((skipped++)) - continue - fi - - ssh indri "git -C '${bare_repo}' remote set-url origin '${auth_url}'" < /dev/null 2>/dev/null - echo " ${org}/${repo}: updated" - ((updated++)) -done <<< "$mirrors" - -echo -echo "Done. Updated: ${updated}, Skipped: ${skipped}" - -if [[ "$DRY_RUN" == "true" ]]; then - echo "(dry-run mode — no changes were made)" -fi diff --git a/mise-tasks/op-backup b/mise-tasks/op-backup deleted file mode 100755 index 7db033b..0000000 --- a/mise-tasks/op-backup +++ /dev/null @@ -1,336 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.12" -# 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)" -"""Encrypt a 1Password export and transfer to indri for borgmatic backup. - -Generates a temporary age key pair, encrypts the .1pux with the public key, -then encrypts the private key with openssl using your 1Password master password -and secret key as the passphrase (via fd, never exposed in env or ps). Both -files are SCPed to indri for borgmatic pickup. - -Usage: - mise run op-backup [path/to/export.1pux] - -If no path is given, prompts you to export from the 1Password desktop app first. - -DISASTER RECOVERY: - 1. Restore borgmatic archive to get the .age and .key.enc files - 2. Retrieve Emergency Kit from fire safety box - 3. openssl enc -d -aes-256-cbc -pbkdf2 < backup.key.enc > key.txt - (passphrase: {master_password}:{secret_key}) - 4. age -d -i key.txt < backup.age > export.1pux - 5. Open export.1pux with 1Password or unzip to inspect -""" - -import os -import shutil -import subprocess -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" -EXPORT_DIR = Path.home() / "Documents" -KEEP_RECENT = 3 - -# 1Password vault/item references for the encryption credentials -OP_VAULT = "wpwhqn557rkb4ybpyvdxi5wsmu" -OP_ITEM = "nev7fcgapzcjtdlxxlhg5kz7ca" - -console = Console() - - -def check_dependencies() -> bool: - """Verify required CLI tools are installed.""" - ok = True - for cmd in ("op", "age", "openssl", "ssh", "scp"): - if not shutil.which(cmd): - console.print(f"[red]ERROR:[/red] {cmd} is required but not installed") - if cmd == "age": - console.print(" Install with: brew install age") - ok = False - 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, auto-detection, or interactive prompt.""" - if argv_path: - path = Path(argv_path).expanduser() - else: - console.print("[bold]=== 1Password Disaster Recovery Backup ===[/bold]") - console.print() - - 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}") - return None - - if path.suffix != ".1pux": - console.print(f"[yellow]WARNING:[/yellow] File does not have .1pux extension: {path}") - confirm = console.input("Continue anyway? [y/N]: ").strip().lower() - if confirm != "y": - return None - - return path - - -def fetch_credentials() -> str | None: - """Fetch master password and secret key from 1Password (triggers biometric).""" - console.print("Fetching encryption credentials from 1Password...") - - password = _op_field("password") - if not password: - console.print("[red]ERROR:[/red] Failed to fetch password from 1Password") - return None - - secret_key = _op_field("Secret Key") - if not secret_key: - console.print("[red]ERROR:[/red] Failed to fetch secret key from 1Password") - return None - - return f"{password}:{secret_key}" - - -def _op_field(field: str) -> str | None: - """Retrieve a single field from the 1Password credentials item.""" - result = subprocess.run( - [ - "op", "--vault", OP_VAULT, - "item", "get", OP_ITEM, - "--fields", field, "--reveal", - ], - capture_output=True, - text=True, - ) - if result.returncode != 0: - return None - return result.stdout.strip() or None - - -def encrypt(export_path: Path, passphrase: str, tmpdir: Path) -> tuple[Path, Path] | None: - """Encrypt the .1pux with a temporary age key pair. - - Returns (encrypted_export, encrypted_key) paths on success, None on failure. - - 1. age-keygen generates a fresh key pair - 2. age encrypts the .1pux with the public key (non-interactive) - 3. openssl encrypts the private key with the passphrase via fd (no env/ps leak) - """ - console.print("Generating age key pair...") - key_path = tmpdir / "key.txt" - result = subprocess.run( - ["age-keygen", "-o", str(key_path)], - capture_output=True, - text=True, - ) - if result.returncode != 0: - console.print("[red]ERROR:[/red] age-keygen failed") - return None - - # Extract public key from stderr (age-keygen prints "Public key: age1...") - pubkey = None - for line in result.stderr.splitlines(): - if line.startswith("Public key:"): - pubkey = line.split(": ", 1)[1].strip() - break - if not pubkey: - console.print("[red]ERROR:[/red] Could not parse public key from age-keygen") - return None - - # Encrypt the .1pux with the public key (non-interactive) - console.print("Encrypting export...") - encrypted_export = tmpdir / "export.age" - with open(export_path, "rb") as src, open(encrypted_export, "wb") as dst: - result = subprocess.run( - ["age", "-e", "-r", pubkey, "--armor"], - stdin=src, - stdout=dst, - ) - if result.returncode != 0 or encrypted_export.stat().st_size == 0: - console.print("[red]ERROR:[/red] age encryption failed") - return None - - # Encrypt the private key with openssl, passphrase via fd (not env or cli arg) - console.print("Encrypting age key with passphrase...") - encrypted_key = tmpdir / "key.enc" - pass_read_fd, pass_write_fd = os.pipe() - os.write(pass_write_fd, passphrase.encode()) - os.close(pass_write_fd) - - with open(key_path, "rb") as src, open(encrypted_key, "wb") as dst: - result = subprocess.run( - [ - "openssl", "enc", "-aes-256-cbc", "-pbkdf2", - "-pass", f"fd:{pass_read_fd}", - ], - stdin=src, - stdout=dst, - pass_fds=(pass_read_fd,), - ) - os.close(pass_read_fd) - - if result.returncode != 0 or encrypted_key.stat().st_size == 0: - console.print("[red]ERROR:[/red] openssl encryption of key failed") - return None - - # Shred the plaintext key immediately - key_path.unlink() - - return encrypted_export, encrypted_key - - -def transfer_to_indri(encrypted_export: Path, encrypted_key: Path, timestamp: str) -> bool: - """SCP both encrypted files to indri and clean up old exports.""" - console.print("Transferring to indri...") - - remote_export = f"{REMOTE_DIR}/1password-export-{timestamp}.age" - remote_key = f"{REMOTE_DIR}/1password-export-{timestamp}.key.enc" - - # Ensure remote directory exists - subprocess.run( - ["ssh", "indri", f"mkdir -p {REMOTE_DIR}"], - capture_output=True, - ) - - # Transfer both files - result = subprocess.run( - [ - "scp", "-q", - str(encrypted_export), f"indri:{remote_export}", - ], - capture_output=True, - ) - if result.returncode != 0: - console.print("[red]ERROR:[/red] SCP of encrypted export failed") - console.print(f"Encrypted files preserved in: {encrypted_export.parent}") - return False - - result = subprocess.run( - [ - "scp", "-q", - str(encrypted_key), f"indri:{remote_key}", - ], - capture_output=True, - ) - if result.returncode != 0: - console.print("[red]ERROR:[/red] SCP of encrypted key failed") - console.print(f"Encrypted files preserved in: {encrypted_export.parent}") - return False - - # Clean up old exports (keep last N sets — each set is a .age + .key.enc) - subprocess.run( - [ - "ssh", "indri", - f"ls -t {REMOTE_DIR}/1password-export-*.age 2>/dev/null" - f" | tail -n +{KEEP_RECENT + 1} | xargs rm -f 2>/dev/null", - ], - capture_output=True, - ) - subprocess.run( - [ - "ssh", "indri", - f"ls -t {REMOTE_DIR}/1password-export-*.key.enc 2>/dev/null" - f" | tail -n +{KEEP_RECENT + 1} | xargs rm -f 2>/dev/null", - ], - capture_output=True, - ) - - # Report size - size_result = subprocess.run( - ["ssh", "indri", f"du -h '{remote_export}'"], - capture_output=True, - text=True, - ) - size = size_result.stdout.split()[0] if size_result.returncode == 0 else "?" - - console.print() - console.print(f"[green]Backup complete:[/green] indri:{remote_export} ({size})") - return True - - -app = typer.Typer() - - -@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: - 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: - raise SystemExit(1) - - with tempfile.TemporaryDirectory() as tmpdir: - result = encrypt(export_path, passphrase, Path(tmpdir)) - del passphrase - if not result: - raise SystemExit(1) - - encrypted_export, encrypted_key = result - - # Delete plaintext .1pux - export_path.unlink() - console.print(f"Deleted plaintext export: {export_path}") - - timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") - if not transfer_to_indri(encrypted_export, encrypted_key, timestamp): - raise SystemExit(1) - - 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 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") - console.print(" 5. Open export.1pux with 1Password or unzip to inspect") - - -if __name__ == "__main__": - app() diff --git a/mise-tasks/pr-comments b/mise-tasks/pr-comments index 39d7c9a..be8d25b 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.26.2"] +# dependencies = ["httpx>=0.27.0", "rich>=13.0.0"] # /// #MISE description="List unresolved comments on a PR" #USAGE arg "" help="Pull request number" @@ -14,14 +14,13 @@ if its 'resolver' field is null. Usage: mise run pr-comments """ -from typing import Annotated +import sys import httpx -import typer from rich.console import Console from rich.text import Text -FORGE_API_BASE = "https://forge.eblu.me/api/v1" +FORGE_API_BASE = "https://forge.tail8d86e.ts.net/api/v1" REPO_OWNER = "eblume" REPO_NAME = "blumeops" @@ -44,15 +43,20 @@ def get_review_comments(client: httpx.Client, pr_number: int, review_id: int) -> return response.json() -app = typer.Typer() - - -@app.command() -def main( - pr_number: Annotated[int, typer.Argument(help="Pull request number")], -) -> None: +def main() -> int: 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: @@ -64,7 +68,7 @@ def main( console.print(f"[red]Error:[/red] PR #{pr_number} not found") else: console.print(f"[red]Error:[/red] API request failed: {e}") - raise SystemExit(1) + return 1 # For each review, get comments and filter to unresolved for review in reviews: @@ -79,7 +83,7 @@ def main( if not unresolved_comments: console.print(f"[green]No unresolved comments on PR #{pr_number}[/green]") - raise SystemExit(0) + return 0 # Display unresolved comments console.print(f"[bold]Unresolved Comments on PR #{pr_number}[/bold] ({len(unresolved_comments)} comments)") @@ -107,6 +111,8 @@ def main( console.print() + return 0 + if __name__ == "__main__": - app() + sys.exit(main()) diff --git a/mise-tasks/provision-ringtail b/mise-tasks/provision-ringtail deleted file mode 100755 index 7f35229..0000000 --- a/mise-tasks/provision-ringtail +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env bash -#MISE description="Run ansible playbook to provision ringtail (NixOS)" - -set -euo pipefail - -export MISE_TASK_OUTPUT=interleave - -# Update flake.lock via Dagger before deploying -echo "Updating nixos/ringtail/flake.lock..." -dagger call --progress=plain flake-lock --src=. --flake-path=nixos/ringtail \ - export --path=nixos/ringtail/flake.lock - -if ! git diff --quiet nixos/ringtail/flake.lock; then - git add nixos/ringtail/flake.lock - echo "flake.lock changed and staged. Commit, push, and re-run." - exit 1 -fi - -COMMIT=$(git rev-parse HEAD) -REMOTE_REF=$(git ls-remote origin "$(git rev-parse --abbrev-ref HEAD)" 2>/dev/null | awk 'NR==1{print $1}') - -if [[ "$REMOTE_REF" != "$COMMIT" ]]; then - echo "ERROR: Current commit $COMMIT is not pushed to forge." - echo "Push your changes first: git push" - exit 1 -fi - -echo "Deploying commit $COMMIT to ringtail..." - -cd ansible -ansible-playbook playbooks/ringtail.yml -e "ringtail_commit=$COMMIT" "$@" diff --git a/mise-tasks/provision-sifaka b/mise-tasks/provision-sifaka deleted file mode 100755 index 8ef0631..0000000 --- a/mise-tasks/provision-sifaka +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env bash -#MISE description="Run ansible playbook to provision sifaka" - -set -euo pipefail - -export MISE_TASK_OUTPUT=interleave - -cd ansible -ansible-playbook playbooks/sifaka.yml "$@" diff --git a/mise-tasks/prune-ringtail-generations b/mise-tasks/prune-ringtail-generations deleted file mode 100755 index 2ad8dc8..0000000 --- a/mise-tasks/prune-ringtail-generations +++ /dev/null @@ -1,166 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.12" -# dependencies = ["rich==15.0.0", "typer==0.26.2"] -# /// -#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 " 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_numberprofile_pathkernel_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() diff --git a/mise-tasks/review-compliance-reports b/mise-tasks/review-compliance-reports deleted file mode 100755 index f2a0a54..0000000 --- a/mise-tasks/review-compliance-reports +++ /dev/null @@ -1,610 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.12" -# 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" -#USAGE flag "--show-muted" help="Also show muted failures" -"""Fetch and summarize compliance reports from sifaka. - -Covers: - - Prowler K8s CIS (in-cluster): per-finding detail - - Kingfisher secret scanning: TODO — pending upstream JSON/CSV output - support (currently HTML-only; contribute from spork) - -The Prowler container-image CVE scan and IaC scan were retired in 2026-06 -(see docs/how-to/operations/deploy-prowler.md) — they produced tens of -thousands of un-actioned findings weekly. Only the K8s CIS scan remains. - -For the 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 (per-finding detail) - -This is the primary tool for the weekly compliance report review. -""" - -import csv -import subprocess -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 - -PROWLER_SCANS: list[tuple[str, str]] = [ - # (label, sifaka base path) - ("K8s CIS (In-Cluster)", "/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(base: str) -> list[str]: - """List Prowler CSV reports under `base` on sifaka, sorted by timestamp.""" - result = subprocess.run( - ["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(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) - 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 _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. - - 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) - - # --- 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 (out-of-band checks for MANUAL findings)", - ) - 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() - - -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, -) -> None: - """Fetch and summarize the latest Prowler report under `base`.""" - console.rule(f"[bold]{label}[/bold]") - csvs = list_reports(base) - if not csvs: - console.print( - f"[bold yellow]{label}: no Prowler CSV reports found " - f"under {base}[/bold yellow]" - ) - console.print() - return - - 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), - ) - - 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 "[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 --- - if latest["unmuted"]: - _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 add a Resource entry to the " - "matching check in argocd/manifests/prowler/mutelist/.", - 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 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 in PROWLER_SCANS: - summarize_report( - label, - base, - tmpdir, - show_muted=show_muted, - ) - - # --- Node-level MANUAL check 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: - # - # 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) diff --git a/mise-tasks/runner-logs b/mise-tasks/runner-logs deleted file mode 100755 index 0d3028b..0000000 --- a/mise-tasks/runner-logs +++ /dev/null @@ -1,330 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.12" -# 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)" -#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: 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: - 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 os -import re -import subprocess -import sys -from typing import Annotated - -import httpx -import typer -from rich.console import Console -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. -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 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 - while True: - 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() - batch = resp.json().get("workflow_runs", []) - if not batch: - break - tasks.extend(batch) - page += 1 - return tasks - - -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, token) - - # 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("Jobs") - table.add_column("Title") - table.add_column("Event") - - 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 - - # 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(rn), - f"[{style}]{status}[/{style}]", - host, - 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, token: str, console: Console) -> None: - """Show the jobs within a specific run.""" - 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) - - 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_number: int, job_index: int, repo: str, token: str) -> None: - """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: job index {job_index} out of range (run #{run_number} has {len(jobs)} jobs)", - err=True, - ) - raise typer.Exit(1) - - 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" - - # 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 or not result.stdout: - 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() -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.Option("--runner", "-r", help="Filter listing by runner: indri, ringtail, or all"), - ] = "all", - repo: Annotated[ - 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"): - typer.echo(f"Error: runner must be 'indri', 'ringtail', or 'all', got '{runner}'") - raise typer.Exit(1) - - 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, resolved_token, console) - elif job is None: - show_jobs(run_number, repo, resolved_token, console) - else: - fetch_log(run_number, job, repo, resolved_token) - - -if __name__ == "__main__": - app() diff --git a/mise-tasks/service-review b/mise-tasks/service-review deleted file mode 100755 index f83b104..0000000 --- a/mise-tasks/service-review +++ /dev/null @@ -1,226 +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.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" -#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 -by the ``last-reviewed`` field. Services without the field (or null) are -treated as never-reviewed and float to the top. Displays a staleness table -and then shows the most stale service with a review checklist. - -After reviewing, update the service entry in the YAML file: - - last-reviewed: YYYY-MM-DD - current-version: "x.y.z" - -Usage: mise run service-review [-- --limit 15] [-- --type argocd] -""" - -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 - -VERSIONS_FILE = Path(__file__).parent.parent / "service-versions.yaml" - - -def load_services(path: Path) -> list[dict]: - """Load services from the YAML tracking file.""" - data = yaml.safe_load(path.read_text()) - return data.get("services", []) - - -def parse_date(raw) -> date | None: - """Parse a date value from YAML.""" - if raw is None: - return None - if isinstance(raw, date): - return raw - try: - return date.fromisoformat(str(raw)) - except ValueError: - return None - - -def main( - limit: Annotated[int, typer.Option(help="Number of services to show in the table")] = 15, - type: Annotated[str | None, typer.Option(help="Filter by service type (argocd, ansible, nixos)")] = None, -) -> None: - console = Console() - today = date.today() - - if not VERSIONS_FILE.exists(): - console.print(f"[bold red]Tracking file not found:[/bold red] {VERSIONS_FILE}") - raise typer.Exit(code=1) - - services = load_services(VERSIONS_FILE) - - if type: - services = [s for s in services if s.get("type") == type] - if not services: - console.print(f"[bold red]No services found with type '{type}'[/bold red]") - raise typer.Exit(code=1) - - # Parse dates and build sortable entries - entries: list[tuple[dict, date | None]] = [] - for svc in services: - reviewed = parse_date(svc.get("last-reviewed")) - entries.append((svc, reviewed)) - - # Sort: never-reviewed first (None), then oldest reviewed - 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) - type_label = f" ({type})" if type else "" - - # --- Summary panel --- - console.print() - console.print(Panel( - f"[bold]{len(entries)}[/bold] total services{type_label}, " - f"[bold red]{never_reviewed}[/bold red] never reviewed", - title="[bold]Service 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("Service") - table.add_column("Type", justify="center") - table.add_column("Version") - table.add_column("Last Reviewed", justify="right") - table.add_column("Age (days)", justify="right") - - for i, (svc, reviewed) in enumerate(entries[:limit], 1): - name = svc["name"] - svc_type = svc.get("type", "?") - version = svc.get("current-version") or "—" - - if reviewed is None: - table.add_row( - str(i), - f"[red]{name}[/red]", - svc_type, - f"[dim]{version}[/dim]", - "[red]never[/red]", - "[red]—[/red]", - ) - else: - age = (today - reviewed).days - style = "yellow" if age > 90 else "" - name_str = f"[{style}]{name}[/{style}]" if style else name - 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), name_str, svc_type, version, date_str, age_str) - - remaining = len(entries) - limit - if remaining > 0: - table.add_row("", f"[dim]… {remaining} more[/dim]", "", "", "", "") - - console.print(table) - console.print() - - # --- Show the most stale service --- - if not entries: - console.print("[bold red]No services found![/bold red]") - raise typer.Exit(code=1) - - top_svc, top_reviewed = entries[0] - upstream = top_svc.get("upstream-source") or "N/A" - notes = top_svc.get("notes") or "" - - detail_lines = [ - f"[bold cyan]{top_svc['name']}[/bold cyan] [dim]({top_svc.get('type', '?')})[/dim]", - f"[dim]Last reviewed: {top_reviewed or 'never'}[/dim]", - f"[dim]Current version: {top_svc.get('current-version') or 'unknown'}[/dim]", - f"[dim]Upstream: {upstream}[/dim]", - ] - if notes: - detail_lines.append(f"[dim]Notes: {notes}[/dim]") - - console.print(Panel( - "\n".join(detail_lines), - title="[bold]Up For Review[/bold]", - border_style="green", - )) - console.print() - - # --- Review checklist --- - checklist_parts = [ - "[bold]Version Check:[/bold]\n", - f"• Check upstream releases: {upstream}\n", - "• Compare upstream latest to current deployed version\n", - "• Review changelog for breaking changes or security fixes\n", - ] - - 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", - f"• Check role vars for version pins: ansible/roles/{top_svc['name']}/\n", - f"• Dry run: mise run provision-indri -- --tags {top_svc['name']} --check --diff\n", - ] - elif svc_type == "nixos": - checklist_parts += [ - "\n[bold]NixOS Deployment:[/bold]\n", - "• Version tracks nixpkgs via flake.lock\n", - "• 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", - "• Verify the service is running and healthy\n", - "• Check logs for errors or warnings\n", - "\n[bold]After Review:[/bold]\n", - "• Update the tracking file: [cyan]docs/reference/services/service-versions.yaml[/cyan]\n", - f"• Set [cyan]last-reviewed: {today}[/cyan] and [cyan]current-version[/cyan]\n", - "• Commit the change (along with any upgrades)", - ] - - console.print(Panel( - "".join(checklist_parts), - title="[bold yellow]Review Guidance[/bold yellow]", - border_style="yellow", - )) - - -if __name__ == "__main__": - typer.run(main) diff --git a/mise-tasks/services-check b/mise-tasks/services-check deleted file mode 100755 index 1e90f93..0000000 --- a/mise-tasks/services-check +++ /dev/null @@ -1,217 +0,0 @@ -#!/usr/bin/env bash -#MISE description="Check that all services are online and responding" - -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 - -check_service() { - local name="$1" - local check_cmd="$2" - - printf "%-24s " "$name..." - if eval "$check_cmd" > /dev/null 2>&1; then - echo -e "${GREEN}OK${NC}" - else - echo -e "${RED}FAILED${NC}" - FAILED=1 - fi -} - -check_http() { - local name="$1" - local url="$2" - - printf "%-24s " "$name..." - if curl -sf --max-time 5 "$url" > /dev/null 2>&1; then - echo -e "${GREEN}OK${NC}" - else - echo -e "${RED}FAILED${NC}" - FAILED=1 - 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') or a['state'].startswith('Alerting'): - 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 - echo -e "${RED}FIRING${NC}" - 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 -} - -echo "Checking services..." -echo "====================" -echo "" - -# Local services on indri (not yet covered by alerting) -echo "Local services on indri:" -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 \"^-\"'" -check_service "zot" "ssh indri 'launchctl list mcquack.eblume.zot | grep -v \"^-\"'" -check_service "zot-metrics" "ssh indri 'launchctl list mcquack.zot-metrics | grep -v \"^-\"'" -check_service "minikube-metrics" "ssh indri 'launchctl list mcquack.minikube-metrics | grep -v \"^-\"'" -check_service "jellyfin-metrics" "ssh indri 'launchctl list mcquack.eblume.jellyfin-metrics | grep -v \"^-\"'" - -echo "" -echo "Metrics textfiles (via alerting):" -check_alert "textfile-freshness" "TextfileStale" - -echo "" -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 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 "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" - -echo "" -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'" -check_service "k3s-apiserver (remote)" "kubectl --context=k3s-ringtail get --raw /healthz" -check_service "forgejo-runner" "ssh ringtail 'systemctl is-active gitea-runner-nix_container_builder.service'" - -echo "" -echo "Pod health (via alerting):" -check_alert "pod-readiness" "PodNotReady" - -echo "" -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 "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" - else - printf "%-20s %-12s %-12s %s\n" "$name" "$sync" "$health" "$target" - fi -done < <(kubectl --context=minikube-indri get applications -n argocd --no-headers -o custom-columns='NAME:.metadata.name,SYNC:.status.sync.status,HEALTH:.status.health.status,TARGET:.spec.source.targetRevision' 2>/dev/null) - -echo "" -if [ $FAILED -eq 0 ]; then - echo -e "${GREEN}All services healthy!${NC}" - exit 0 -else - echo -e "${RED}Some services failed health check${NC}" - exit 1 -fi diff --git a/mise-tasks/spork-create b/mise-tasks/spork-create deleted file mode 100755 index 3f18563..0000000 --- a/mise-tasks/spork-create +++ /dev/null @@ -1,453 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.12" -# 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)" -#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 -B {main_branch} origin/{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() - - # --- 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: - # 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) - - # 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}") - console.print(f" Description: {desc}") - console.print(f" Fork target: {OWNER}/{repo_name}") - if not no_clone: - console.print(f" Local clone: {local_path}") - 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_path} with 3 remotes") - return - - # --- 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}") - - # 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 - - 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: - 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() diff --git a/mise-tasks/tailnet-preview b/mise-tasks/tailnet-preview index 8a39842..dd9e308 100755 --- a/mise-tasks/tailnet-preview +++ b/mise-tasks/tailnet-preview @@ -3,13 +3,11 @@ set -euo pipefail -TAILSCALE_OAUTH_CLIENT_ID=$(op read "op://blumeops/tailscale - blumeops/client_id") +TAILSCALE_OAUTH_CLIENT_ID=$(op --vault vg6xf6vvfmoh5hqjjhlhbeoaie item get wi6bkf7bcccwfy4eu776ab4p4u --fields client_id) export TAILSCALE_OAUTH_CLIENT_ID -TAILSCALE_OAUTH_CLIENT_SECRET=$(op read "op://blumeops/tailscale - blumeops/client_secret") +TAILSCALE_OAUTH_CLIENT_SECRET=$(op --vault vg6xf6vvfmoh5hqjjhlhbeoaie item get wi6bkf7bcccwfy4eu776ab4p4u --fields client_secret --reveal) export TAILSCALE_OAUTH_CLIENT_SECRET export TAILSCALE_TAILNET="tail8d86e.ts.net" -cd "$(dirname "$0")/../pulumi/tailscale" -uv sync --quiet || { echo "uv sync failed — if devpi is down, run 'devpi off' and retry"; exit 1; } -pulumi stack select tail8d86e +cd "$(dirname "$0")/../pulumi" pulumi preview "$@" diff --git a/mise-tasks/tailnet-up b/mise-tasks/tailnet-up index 7f36d93..4b097b6 100755 --- a/mise-tasks/tailnet-up +++ b/mise-tasks/tailnet-up @@ -3,13 +3,11 @@ set -euo pipefail -TAILSCALE_OAUTH_CLIENT_ID=$(op read "op://blumeops/tailscale - blumeops/client_id") +TAILSCALE_OAUTH_CLIENT_ID=$(op --vault vg6xf6vvfmoh5hqjjhlhbeoaie item get wi6bkf7bcccwfy4eu776ab4p4u --fields client_id) export TAILSCALE_OAUTH_CLIENT_ID -TAILSCALE_OAUTH_CLIENT_SECRET=$(op read "op://blumeops/tailscale - blumeops/client_secret") +TAILSCALE_OAUTH_CLIENT_SECRET=$(op --vault vg6xf6vvfmoh5hqjjhlhbeoaie item get wi6bkf7bcccwfy4eu776ab4p4u --fields client_secret --reveal) export TAILSCALE_OAUTH_CLIENT_SECRET export TAILSCALE_TAILNET="tail8d86e.ts.net" -cd "$(dirname "$0")/../pulumi/tailscale" -uv sync --quiet || { echo "uv sync failed — if devpi is down, run 'devpi off' and retry"; exit 1; } -pulumi stack select tail8d86e -pulumi up --yes "$@" +cd "$(dirname "$0")/../pulumi" +pulumi up "$@" diff --git a/mise-tasks/validate-workflows b/mise-tasks/validate-workflows deleted file mode 100755 index 0ab2c5d..0000000 --- a/mise-tasks/validate-workflows +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env bash -#MISE description="Validate Forgejo workflow files against runner schema" - -set -euo pipefail - -dagger call --progress=plain validate-workflows --src=. diff --git a/mise-tasks/zk-docs b/mise-tasks/zk-docs new file mode 100755 index 0000000..dbec30a --- /dev/null +++ b/mise-tasks/zk-docs @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +#MISE description="Concatenate all blumeops zettelkasten cards" + +set -euo pipefail + +ZK_DIR="$HOME/code/personal/zk" +MAIN_CARD="$ZK_DIR/1767747119-YCPO.md" + +# Find all files tagged with blumeops (excluding main card) +other_cards=$(grep -l '^ - blumeops$' "$ZK_DIR"/*.md 2>/dev/null | grep -v "$(basename "$MAIN_CARD")" | sort) + +# Concatenate: main card first, then others +# Pass through any args to bat (e.g., --style=header --color=never --decorations=always) +bat "$@" "$MAIN_CARD" $other_cards diff --git a/mise.toml b/mise.toml index 286c4e0..2861f91 100644 --- a/mise.toml +++ b/mise.toml @@ -1,12 +1,3 @@ [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" } -"pipx:borgmatic" = "2.1.4" -prek = "0.3.4" -pulumi = "3.215.0" -dagger = "0.20.6" -"pipx:ty" = "0.0.29" +"pipx:ansible-core" = { version = "latest", uvx = "true", uvx_args = "--with botocore --with boto3" } +pulumi = "latest" diff --git a/nixos/ringtail/configuration.nix b/nixos/ringtail/configuration.nix deleted file mode 100644 index bc893d5..0000000 --- a/nixos/ringtail/configuration.nix +++ /dev/null @@ -1,635 +0,0 @@ -{ config, pkgs, lib, ... }: - -let - # Libraries needed by mise-compiled runtimes (python-build, etc.) - buildDeps = with pkgs; [ zlib readline bzip2 xz libffi ncurses sqlite openssl ]; -in -{ - # Allow unfree packages (NVIDIA drivers, Steam) - nixpkgs.config.allowUnfree = true; - - # Bootloader - boot.loader.systemd-boot.enable = true; - boot.loader.efi.canTouchEfiVariables = true; - - # No TPM module on this board - 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; - 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" ]; - - # 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"; - - # Locale - i18n.defaultLocale = "en_US.UTF-8"; - - # NVIDIA proprietary drivers - hardware.graphics.enable = true; - services.xserver.videoDrivers = [ "nvidia" ]; - hardware.nvidia = { - modesetting.enable = true; - open = false; # Use proprietary driver for RTX 4080 - nvidiaSettings = true; - package = config.boot.kernelPackages.nvidiaPackages.stable; - }; - - # NVIDIA container toolkit (CDI specs + runtime for containerd/k3s GPU pods) - hardware.nvidia-container-toolkit.enable = true; - - # Stable path to NVIDIA driver libraries for k3s device plugin pod mounts. - # Avoids mounting all of /nix/store — only the driver derivation is needed. - environment.etc."nvidia-driver/lib".source = "${config.hardware.nvidia.package}/lib"; - - # Stable-path wrapper for nvidia-container-runtime.cdi (the CDI-based OCI - # runtime that injects GPU devices/libs from NixOS-generated CDI specs). - # The wrapper adds runc to PATH since k3s doesn't ship a standalone runc binary. - environment.etc."nvidia-container-runtime/nvidia-runtime-cdi-wrapper" = { - mode = "0755"; - text = '' - #!/bin/sh - export PATH="${pkgs.runc}/bin:$PATH" - exec ${pkgs.nvidia-container-toolkit.tools}/bin/nvidia-container-runtime.cdi "$@" - ''; - }; - - # NFS client support (required for k3s to mount NFS PersistentVolumes) - boot.supportedFilesystems = [ "nfs" ]; - - # Wayland / Sway - programs.sway = { - enable = true; - wrapperFeatures.gtk = true; - extraSessionCommands = '' - export WLR_NO_HARDWARE_CURSORS=1 - ''; - extraPackages = with pkgs; [ - swaylock - swayidle - wezterm # terminal - wmenu # app launcher - mako # notifications - grim # screenshots - slurp # region selection - ]; - }; - security.polkit.enable = true; - security.pam.services.swaylock = {}; # Allow swaylock to authenticate - security.sudo.wheelNeedsPassword = false; - - # Enable greetd as display manager for sway - services.greetd = { - enable = true; - settings = { - default_session = { - command = "${pkgs.tuigreet}/bin/tuigreet --time --cmd 'sway --unsupported-gpu'"; - user = "greeter"; - }; - }; - }; - - # PipeWire for audio - services.pipewire = { - enable = true; - alsa.enable = true; - pulse.enable = true; - }; - - # Bluetooth - hardware.bluetooth = { - enable = true; - powerOnBoot = true; - }; - services.blueman.enable = true; - - # Fish shell - programs.fish.enable = true; - - # Firefox with 1Password extension - programs.firefox = { - enable = true; - 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 = { - enable = true; - polkitPolicyOwners = [ "eblume" ]; - }; - - # K3s single-node cluster - services.k3s = { - enable = true; - role = "server"; - tokenFile = "/etc/k3s/token"; - extraFlags = toString [ - "--disable=traefik" - "--disable=servicelb" - "--disable=metrics-server" - "--write-kubeconfig-mode=644" - "--tls-san=ringtail.tail8d86e.ts.net" - ]; - containerdConfigTemplate = '' - {{ template "base" . }} - - [plugins.'io.containerd.cri.v1.runtime'] - enable_cdi = true - cdi_spec_dirs = ["/var/run/cdi", "/etc/cdi"] - - [plugins.'io.containerd.cri.v1.runtime'.containerd.runtimes.nvidia] - privileged_without_host_devices = false - runtime_type = "io.containerd.runc.v2" - [plugins.'io.containerd.cri.v1.runtime'.containerd.runtimes.nvidia.options] - BinaryName = "/etc/nvidia-container-runtime/nvidia-runtime-cdi-wrapper" - ''; - }; - - # 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"; - - # 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; - - # Tailscale - services.tailscale = { - enable = true; - extraUpFlags = [ "--accept-routes" "--ssh" ]; - }; - - # Trust Tailscale and k3s CNI interfaces - # - tailscale0: ArgoCD on indri connects via tailnet - # - cni0/flannel.1: k3s pod overlay network (pods must reach host API server) - networking.firewall.trustedInterfaces = [ "tailscale0" "cni0" "flannel.1" ]; - - # SSH - services.openssh = { - enable = true; - settings = { - PasswordAuthentication = false; - PermitRootLogin = "no"; - }; - }; - - # User account - users.users.eblume = { - isNormalUser = true; - shell = pkgs.fish; - extraGroups = [ "wheel" "networkmanager" "video" ]; - openssh.authorizedKeys.keys = [ - "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILmh1SSCdDAyu3vkSQH7kAXEPDi8APyjo9JXDTjtha2j" - ]; - }; - - # System packages - environment.systemPackages = with pkgs; [ - git - kubectl - python3 # required for Ansible - vim - htop - curl - wget - chezmoi - neovim - eza - fd - fzf - zoxide - starship - atuin - bat - ripgrep - mise - gcc - gnumake - pkg-config - openssl - gnupg - unzip - fuzzel - pulseaudio - librewolf - ]; - - # Allow running dynamically linked binaries (mise-installed runtimes, etc.) - programs.nix-ld.enable = true; - programs.nix-ld.libraries = buildDeps ++ [ pkgs.icu ]; - - # Compile-time flags for mise python-build and similar source builds - environment.sessionVariables = { - PKG_CONFIG_PATH = lib.makeSearchPath "lib/pkgconfig" (map lib.getDev buildDeps); - CFLAGS = lib.concatMapStringsSep " " (p: "-I${lib.getDev p}/include") buildDeps; - LDFLAGS = lib.concatMapStringsSep " " (p: "-L${lib.getLib p}/lib") buildDeps; - }; - - # Fonts - fonts.packages = with pkgs; [ - nerd-fonts.victor-mono - ]; - - # Home Manager (minimal — chezmoi owns dotfiles, this is ringtail-specific) - home-manager.useGlobalPkgs = true; - home-manager.useUserPackages = true; - 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; - config = { - terminal = "wezterm"; - modifier = "Mod4"; - fonts = { - names = [ "VictorMono Nerd Font" ]; - size = 10.0; - }; - bars = [{ command = "waybar"; }]; - gaps = { - inner = 8; - outer = 4; - }; - window = { - border = 2; - titlebar = false; - commands = [ - { command = "inhibit_idle fullscreen"; criteria = { class = ".*"; }; } - { command = "inhibit_idle fullscreen"; criteria = { app_id = ".*"; }; } - { command = "fullscreen enable"; criteria = { class = "steam_app_1174180"; }; } - ]; - }; - colors = { - focused = { - border = "#8aadf4"; - background = "#24273a"; - text = "#cad3f5"; - indicator = "#c6a0f6"; - childBorder = "#8aadf4"; - }; - focusedInactive = { - border = "#494d64"; - background = "#1e2030"; - text = "#a5adcb"; - indicator = "#494d64"; - childBorder = "#494d64"; - }; - unfocused = { - border = "#363a4f"; - background = "#1e2030"; - text = "#6e738d"; - indicator = "#363a4f"; - childBorder = "#363a4f"; - }; - urgent = { - border = "#ed8796"; - background = "#24273a"; - text = "#cad3f5"; - indicator = "#ed8796"; - childBorder = "#ed8796"; - }; - }; - input = { - "*" = { - xkb_options = "ctrl:nocaps"; - }; - }; - output = { - "DP-1" = { - mode = "2560x1440@165Hz"; - # 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"; - }; - }; - # 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%"; - "--locked XF86AudioMicMute" = "exec pactl set-source-mute @DEFAULT_SOURCE@ toggle"; - }; - startup = [ - { command = "1password"; } - { command = "steam"; } - ]; - }; - }; - - programs.swaylock = { - enable = true; - settings = { - color = "24273a"; - font = "VictorMono Nerd Font"; - font-size = 24; - indicator-radius = 100; - indicator-thickness = 7; - inside-color = "24273a"; - inside-clear-color = "24273a"; - inside-ver-color = "24273a"; - inside-wrong-color = "24273a"; - key-hl-color = "8aadf4"; - bs-hl-color = "ed8796"; - ring-color = "363a4f"; - ring-clear-color = "f5a97f"; - ring-ver-color = "8aadf4"; - ring-wrong-color = "ed8796"; - line-color = "00000000"; - line-clear-color = "00000000"; - line-ver-color = "00000000"; - line-wrong-color = "00000000"; - separator-color = "00000000"; - text-color = "cad3f5"; - text-clear-color = "cad3f5"; - text-ver-color = "cad3f5"; - text-wrong-color = "ed8796"; - show-failed-attempts = true; - }; - }; - - services.swayidle = { - enable = true; - events = [ - { event = "before-sleep"; command = "${pkgs.swaylock}/bin/swaylock -f"; } - { event = "lock"; command = "${pkgs.swaylock}/bin/swaylock -f"; } - ]; - timeouts = [ - { - timeout = 900; # 15 minutes — lock screen - command = "${pkgs.swaylock}/bin/swaylock -f"; - } - { - timeout = 3600; # 60 minutes — turn off display - command = "${pkgs.sway}/bin/swaymsg 'output * dpms off'"; - resumeCommand = "${pkgs.sway}/bin/swaymsg 'output * dpms on'"; - } - ]; - }; - - programs.fuzzel = { - enable = true; - settings = { - main = { - font = "VictorMono Nerd Font:size=14"; - terminal = "wezterm"; - width = 40; - horizontal-pad = 16; - vertical-pad = 8; - }; - border = { - radius = 8; - width = 2; - }; - colors = { - background = "24273add"; - text = "cad3f5ff"; - match = "8aadf4ff"; - selection = "363a4fff"; - selection-text = "cad3f5ff"; - selection-match = "8aadf4ff"; - border = "8aadf4ff"; - }; - }; - }; - - programs.waybar = { - enable = true; - settings = [{ - layer = "top"; - position = "top"; - height = 30; - modules-left = [ "sway/workspaces" "sway/mode" ]; - modules-center = [ "sway/window" ]; - modules-right = [ "pulseaudio" "bluetooth" "network" "clock" "tray" ]; - tray = { spacing = 8; }; - clock = { format = "{:%a %b %d %H:%M}"; }; - network = { - interval = 2; - format-ethernet = "{bandwidthDownBits} down {bandwidthUpBits} up"; - format-wifi = "{essid} {bandwidthDownBits} down {bandwidthUpBits} up"; - format-disconnected = "disconnected"; - }; - pulseaudio = { - format = "{icon} {volume}%"; - format-muted = " muted"; - format-icons = { - headphone = ""; - default = [ "" "" "" ]; - }; - }; - }]; - style = '' - * { - font-family: "VictorMono Nerd Font"; - font-size: 13px; - border: none; - border-radius: 0; - min-height: 0; - } - window#waybar { - background-color: rgba(30, 32, 48, 0.9); - color: #cad3f5; - margin: 4px 4px 0 4px; - } - #workspaces button { - padding: 0 8px; - margin: 0 2px; - color: #6e738d; - background: transparent; - border-radius: 4px; - } - #workspaces button.focused { - color: #8aadf4; - background: #363a4f; - border-bottom: 2px solid #8aadf4; - } - #workspaces button.urgent { - color: #ed8796; - } - #window { - color: #a5adcb; - } - #bluetooth { - color: #8aadf4; - } - #bluetooth.off, #bluetooth.disabled { - color: #6e738d; - } - #clock, #network, #pulseaudio, #bluetooth, #tray { - padding: 0 12px; - margin: 4px 2px; - color: #cad3f5; - background: #363a4f; - border-radius: 4px; - } - #clock { - color: #8aadf4; - } - #pulseaudio { - color: #f5a97f; - } - #network { - color: #a6da95; - } - #network.disconnected { - color: #ed8796; - } - ''; - }; - }; - - # Ensure mounted drives are owned by eblume - systemd.tmpfiles.rules = [ - "d /mnt/games 0755 eblume users -" - "d /mnt/storage1 0755 eblume users -" - "d /mnt/storage2 0755 eblume users -" - ]; - - # Container config for skopeo (used by the forgejo runner to push images) - # and for unqualified image pulls via Zot pull-through cache - environment.etc."containers/policy.json".text = builtins.toJSON { - default = [{ type = "insecureAcceptAnything"; }]; - }; - environment.etc."containers/registries.conf".text = '' - 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; - instances.nix_container_builder = { - enable = true; - name = "ringtail-nix-builder"; - url = "https://forge.ops.eblu.me"; - tokenFile = "/etc/forgejo-runner/token.env"; - labels = [ "nix-container-builder:host" ]; - hostPackages = with pkgs; [ - bash coreutils curl gawk gitMinimal gnused jq nodejs wget - nix skopeo - ]; - settings = { - log.level = "info"; - runner = { - capacity = 1; - timeout = "3h"; - }; - }; - }; - }; - - # Enable nix flakes - nix.settings.experimental-features = [ "nix-command" "flakes" ]; - - # Allow the runner's dynamic user to access the nix daemon - nix.settings.trusted-users = [ "gitea-runner" ]; - - # Prevent machine from sleeping (workstation should stay on) - systemd.sleep.extraConfig = '' - AllowSuspend=no - AllowHibernation=no - AllowHybridSleep=no - 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"; -} diff --git a/nixos/ringtail/disk-config.nix b/nixos/ringtail/disk-config.nix deleted file mode 100644 index f383212..0000000 --- a/nixos/ringtail/disk-config.nix +++ /dev/null @@ -1,84 +0,0 @@ -{ - disko.devices = { - disk = { - nvme = { - type = "disk"; - device = "/dev/nvme0n1"; - content = { - type = "gpt"; - partitions = { - ESP = { - size = "512M"; - type = "EF00"; - content = { - type = "filesystem"; - format = "vfat"; - mountpoint = "/boot"; - mountOptions = [ "umask=0077" ]; - }; - }; - root = { - size = "100%"; - content = { - type = "filesystem"; - format = "ext4"; - mountpoint = "/"; - }; - }; - }; - }; - }; - games = { - type = "disk"; - device = "/dev/sda"; - content = { - type = "gpt"; - partitions = { - games = { - size = "100%"; - content = { - type = "filesystem"; - format = "ext4"; - mountpoint = "/mnt/games"; - }; - }; - }; - }; - }; - storage1 = { - type = "disk"; - device = "/dev/sdb"; - content = { - type = "gpt"; - partitions = { - storage1 = { - size = "100%"; - content = { - type = "filesystem"; - format = "ext4"; - mountpoint = "/mnt/storage1"; - }; - }; - }; - }; - }; - storage2 = { - type = "disk"; - device = "/dev/sdc"; - content = { - type = "gpt"; - partitions = { - storage2 = { - size = "100%"; - content = { - type = "filesystem"; - format = "ext4"; - mountpoint = "/mnt/storage2"; - }; - }; - }; - }; - }; - }; - }; -} diff --git a/nixos/ringtail/flake.lock b/nixos/ringtail/flake.lock deleted file mode 100644 index 340bd9d..0000000 --- a/nixos/ringtail/flake.lock +++ /dev/null @@ -1,87 +0,0 @@ -{ - "nodes": { - "disko": { - "inputs": { - "nixpkgs": [ - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1780894562, - "narHash": "sha256-c3430xwxwhHipl3jigUGMMBfpaMylDqytW/kdmB3ZGs=", - "owner": "nix-community", - "repo": "disko", - "rev": "24fed06cac83bcc44ac8efbb57cab1a82fa0bedc", - "type": "github" - }, - "original": { - "owner": "nix-community", - "repo": "disko", - "type": "github" - } - }, - "home-manager": { - "inputs": { - "nixpkgs": [ - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1779506708, - "narHash": "sha256-QOD/CNm196nCJRheux/URi4/HE66fthdOMqCJoPP1Y0=", - "owner": "nix-community", - "repo": "home-manager", - "rev": "3ee51fbdac8c8bdfe1e7e1fcaba6520a563f394f", - "type": "github" - }, - "original": { - "owner": "nix-community", - "ref": "release-25.11", - "repo": "home-manager", - "type": "github" - } - }, - "nixpkgs": { - "locked": { - "lastModified": 1780511130, - "narHash": "sha256-2v9lT4ya59Lh1FqPeLnz1MoX9y/wz2huqfe9RtQZITk=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "535f3e6942cb1cead3929c604320d3db54b542b9", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixos-25.11", - "repo": "nixpkgs", - "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-services": "nixpkgs-services" - } - } - }, - "root": "root", - "version": 7 -} diff --git a/nixos/ringtail/flake.nix b/nixos/ringtail/flake.nix deleted file mode 100644 index 541bafa..0000000 --- a/nixos/ringtail/flake.nix +++ /dev/null @@ -1,47 +0,0 @@ -{ - description = "NixOS configuration for ringtail (service host & gaming PC)"; - - 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"; - }; - home-manager = { - url = "github:nix-community/home-manager/release-25.11"; - inputs.nixpkgs.follows = "nixpkgs"; - }; - }; - - outputs = { nixpkgs, nixpkgs-services, disko, home-manager, ... }: { - nixosConfigurations.ringtail = nixpkgs.lib.nixosSystem { - system = "x86_64-linux"; - modules = [ - disko.nixosModules.disko - home-manager.nixosModules.home-manager - ./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. - ({ ... }: { - 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/nixos/ringtail/gaming.nix b/nixos/ringtail/gaming.nix deleted file mode 100644 index 7c00378..0000000 --- a/nixos/ringtail/gaming.nix +++ /dev/null @@ -1,39 +0,0 @@ -{ pkgs, ... }: - -{ - # Steam - programs.steam = { - enable = true; - dedicatedServer.openFirewall = true; - extraCompatPackages = [ pkgs.proton-ge-bin ]; - }; - - # 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"; - - # 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 = { - enable = true; - capSysNice = true; # Allow gamescope to set realtime scheduling - }; -} diff --git a/nixos/ringtail/hardware-configuration.nix b/nixos/ringtail/hardware-configuration.nix deleted file mode 100644 index 7a1481e..0000000 --- a/nixos/ringtail/hardware-configuration.nix +++ /dev/null @@ -1,18 +0,0 @@ -# Do not modify this file! It was generated by 'nixos-generate-config' -# and may be overwritten by future invocations. Please make changes -# to configuration.nix instead. -{ config, lib, pkgs, modulesPath, ... }: - -{ - imports = - [ (modulesPath + "/installer/scan/not-detected.nix") - ]; - - boot.initrd.availableKernelModules = [ "nvme" "xhci_pci" "ahci" "usb_storage" "usbhid" "sd_mod" ]; - boot.initrd.kernelModules = [ ]; - boot.kernelModules = [ "kvm-amd" ]; - boot.extraModulePackages = [ ]; - - nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux"; - hardware.cpu.amd.updateMicrocode = lib.mkDefault config.hardware.enableRedistributableFirmware; -} diff --git a/nixos/ringtail/k3s-registries.yaml b/nixos/ringtail/k3s-registries.yaml deleted file mode 100644 index 312362c..0000000 --- a/nixos/ringtail/k3s-registries.yaml +++ /dev/null @@ -1,13 +0,0 @@ -mirrors: - docker.io: - endpoint: - - "https://registry.ops.eblu.me" - ghcr.io: - endpoint: - - "https://registry.ops.eblu.me" - quay.io: - endpoint: - - "https://registry.ops.eblu.me" - registry.ops.eblu.me: - endpoint: - - "https://registry.ops.eblu.me" diff --git a/plans/ci-cd-bootstrap/00_overview.md b/plans/ci-cd-bootstrap/00_overview.md new file mode 100644 index 0000000..84199d6 --- /dev/null +++ b/plans/ci-cd-bootstrap/00_overview.md @@ -0,0 +1,179 @@ +# Forgejo Actions CI/CD Bootstrap Plan + +This plan details the setup of Forgejo Actions as the CI/CD system for blumeops, starting with the bootstrapping problem: using Forgejo to build and deploy Forgejo itself. + +## Goals + +1. **Forgejo Actions** as the primary CI system (replaces Woodpecker from original plan) +2. **Self-hosted Forgejo** built from source, deployed as mcquack LaunchAgent on indri +3. **Container builds** for ArgoCD manifests (devpi, etc.) +4. **Cron-scheduled tasks** via k8s CronJobs (not Actions) +5. **Local development** parity using `act` for workflow testing + +## Why Forgejo Actions over Woodpecker? + +- Native integration with Forgejo (no OAuth setup, automatic repo detection) +- GitHub Actions compatible syntax (huge ecosystem of reusable actions) +- `act` tool for local testing on gilbert +- Single system to maintain instead of two + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ INDRI │ +│ ┌─────────────────────┐ │ +│ │ Forgejo │ ← Built from source │ +│ │ (mcquack agent) │ ← Deploys itself via CI │ +│ │ │ │ +│ │ - Web UI (3001) │ │ +│ │ - SSH (2200) │ │ +│ │ - Actions enabled │ │ +│ └─────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ + │ + │ SSH deploy + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ KUBERNETES (minikube) │ +│ ┌─────────────────────┐ ┌─────────────────────┐ │ +│ │ Forgejo Runner │ │ Other Services │ │ +│ │ (host mode) │ │ (via ArgoCD) │ │ +│ │ │ │ │ │ +│ │ - Custom image │ │ │ │ +│ │ - Node.js + tools │ │ │ │ +│ │ - Docker builds │ │ │ │ +│ └─────────────────────┘ └─────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## Phases + +| Phase | Name | Description | Status | +|-------|------|-------------|--------| +| 1 | [Enable Actions](P1_enable_actions.md) | Configure Forgejo for Actions, deploy runner in host mode | ✅ Complete | +| 2 | [Custom Runner Image](P2_mirror_and_build.md) | Build custom runner with Node.js/tools, enable standard Actions | ✅ Complete | +| 3 | [Mirror Forgejo & Build](P3_mirror_forgejo.md) | Mirror upstream Forgejo, create build workflow | Planning | +| 4 | [Self-Deploy](P4_self_deploy.md) | Forgejo deploys itself, transition to mcquack | Planning | +| 5 | [Container Builds](P5_container_builds.md) | Build custom container images (devpi, etc.) | Planning | + +## The Bootstrap Problem + +**Chicken-and-egg**: We need Forgejo Actions to build Forgejo, but Forgejo must be running first. + +**Additional complication**: The stock runner image lacks Node.js, so standard GitHub Actions don't work. + +**Solution**: +1. Keep current brew-based Forgejo running during setup ✅ +2. Enable Actions, deploy runner in host mode ✅ +3. **Build custom runner image** with Node.js and tools (bootstrap manually, then automate) +4. Mirror upstream Forgejo, create build workflow +5. Address cross-compilation challenge (Linux runner → macOS target) +6. First CI build creates the binary +7. CI deploys binary to indri as mcquack service +8. `brew services stop forgejo` and uninstall +9. Future builds: Forgejo builds and deploys itself + +**Cross-compilation challenge**: +The runner runs in Linux containers (k8s), but Forgejo needs to run on indri (macOS ARM64). Options: +- Cross-compile with CGO_ENABLED=1 (complex, needs OSX toolchain) +- Cross-compile with CGO_ENABLED=0 (breaks Tailscale DNS resolution) +- Build on gilbert manually, use CI only for deploy +- Run a native macOS runner on indri (outside k8s) + +This will be addressed in Phase 3. + +**Risk mitigation**: If self-deployment breaks Forgejo: +- blumeops is mirrored to GitHub +- Manual recovery: build on gilbert, scp to indri, restart service +- See Disaster Recovery section in P4 + +## Host Mode Runner + +The runner uses **host mode** (`ubuntu-latest:host`), meaning: +- Jobs run directly in the runner container (no Docker/k8s pods spawned) +- Tools must be pre-installed in the runner image +- Stock image lacks Node.js, so `actions/checkout@v4` doesn't work +- Solution: Build custom runner image with necessary tools (Phase 2) + +## Ansible Role Strategy + +The forgejo ansible role will follow the zot/alloy pattern: + +1. **Check binary exists** at expected path +2. **If missing**: Fail with message pointing to CI trigger instructions +3. **If present**: Deploy config, ensure LaunchAgent loaded + +Ansible does NOT: +- Build the binary (that's CI's job) +- Deploy new versions (that's CI's job) + +Ansible DOES: +- Manage app.ini configuration (via template with secrets from 1Password) +- Manage mcquack LaunchAgent plist +- Ensure service is running +- Collect logs via Alloy + +## Files Summary + +### New Files + +| Path | Purpose | +|------|---------| +| `argocd/apps/forgejo-runner.yaml` | ArgoCD Application for runner ✅ | +| `argocd/manifests/forgejo-runner/` | Runner k8s manifests ✅ | +| `argocd/manifests/forgejo-runner/Dockerfile` | Custom runner image (P2) | +| `.forgejo/workflows/build-runner.yml` | Auto-rebuild runner image (P2) | +| `.forgejo/workflows/test.yml` | Test workflow ✅ | +| (on forge) `eblume/forgejo/.forgejo/workflows/` | Build workflow in forgejo mirror (P3) | + +### Modified Files + +| Path | Change | +|------|--------| +| `ansible/roles/forgejo/` | Complete rewrite for mcquack pattern (P4) | +| `ansible/roles/alloy/defaults/main.yml` | Update forgejo log paths (P4) | +| zk cards | Update forgejo, argocd, blumeops cards | + +### Credentials Needed + +| Item | Purpose | Storage | +|------|---------|---------| +| Runner registration token | Runner auth to Forgejo | 1Password ✅ | +| SSH deploy key | Runner SSH to indri (for Forgejo deploy) | 1Password + k8s secret (P3) | + +## Related Plans + +- [P7_forgejo.md](../k8s-migration/P7_forgejo.md) - Original k8s migration plan (superseded for Forgejo itself, but SSH hostname split info still relevant) +- [P8_woodpecker.md](../k8s-migration/P8_woodpecker.md) - Original Woodpecker plan (superseded by Forgejo Actions) + +## Decision Log + +### 2026-01-23: Custom runner image as Phase 2 + +**Decision**: Move custom runner image work from P4 to P2 + +**Rationale**: +- Stock runner lacks Node.js, can't run `actions/checkout@v4` +- Need working GitHub Actions before building Forgejo +- Bootstrap manually (podman build on gilbert), then automate + +### 2026-01-23: Forgejo Actions over Woodpecker + +**Decision**: Use Forgejo Actions instead of Woodpecker CI + +**Rationale**: +- Native Forgejo integration (Actions is built-in) +- GitHub Actions compatible (reuse existing actions) +- `act` for local testing +- One less system to deploy and maintain + +### 2026-01-23: Keep Forgejo on indri (not k8s) + +**Decision**: Forgejo stays on indri as mcquack service, not migrated to k8s + +**Rationale**: +- Avoid circular dependency (ArgoCD needs Forgejo to deploy Forgejo) +- Simpler SSH handling (direct port, no k8s networking complexity) +- Forgejo is critical infrastructure, benefits from isolation +- Can still use Tailscale serve for external access diff --git a/plans/ci-cd-bootstrap/P1_enable_actions.md b/plans/ci-cd-bootstrap/P1_enable_actions.md new file mode 100644 index 0000000..a988348 --- /dev/null +++ b/plans/ci-cd-bootstrap/P1_enable_actions.md @@ -0,0 +1,322 @@ +# Phase 1: Enable Forgejo Actions + +**Goal**: Configure Forgejo to support Actions workflows and deploy a runner in k8s + +**Status**: Completed (2026-01-23) + +**Prerequisites**: None (uses existing brew-based Forgejo) + +--- + +## Current State + +- Forgejo runs via `brew services` on indri +- Config at `/opt/homebrew/var/forgejo/custom/conf/app.ini` +- Actions not enabled +- No runners deployed + +--- + +## Step 1: Enable Actions in Forgejo + +### 1.1 Update app.ini + +SSH to indri and edit the Forgejo config: + +```bash +ssh indri 'vim /opt/homebrew/var/forgejo/custom/conf/app.ini' +``` + +Add the following sections: + +```ini +[actions] +ENABLED = true +DEFAULT_ACTIONS_URL = https://code.forgejo.org + +[repository] +; Allow workflows to be stored in .forgejo/workflows +DEFAULT_REPO_UNITS = repo.code,repo.issues,repo.pulls,repo.releases,repo.wiki,repo.projects,repo.packages,repo.actions +``` + +### 1.2 Restart Forgejo + +```bash +ssh indri 'brew services restart forgejo' +``` + +### 1.3 Verify Actions Enabled + +1. Go to https://forge.tail8d86e.ts.net +2. Navigate to any repo → Settings → Actions +3. Should see "Enable Repository Actions" option + +--- + +## Step 2: Create Runner Registration Token + +### 2.1 Generate Token in Forgejo UI + +1. Go to https://forge.tail8d86e.ts.net/admin/actions/runners +2. Click "Create new Runner" +3. Copy the registration token +4. Store in 1Password (blumeops vault) as "Forgejo Runner Token" + +### 2.2 Create k8s Secret Template + +Create `argocd/manifests/forgejo-runner/secret-token.yaml.tpl`: + +```yaml +# Template for op inject +apiVersion: v1 +kind: Secret +metadata: + name: forgejo-runner-token + namespace: forgejo-runner +type: Opaque +stringData: + token: "op://blumeops//token" +``` + +--- + +## Step 3: Deploy Runner to Kubernetes + +### 3.1 Create ArgoCD Application + +Create `argocd/apps/forgejo-runner.yaml`: + +```yaml +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: forgejo-runner + namespace: argocd +spec: + project: default + source: + repoURL: ssh://forgejo@indri.tail8d86e.ts.net:2200/eblume/blumeops.git + targetRevision: main + path: argocd/manifests/forgejo-runner + destination: + server: https://kubernetes.default.svc + namespace: forgejo-runner + syncPolicy: + syncOptions: + - CreateNamespace=true +``` + +### 3.2 Create Runner Manifests + +Create directory `argocd/manifests/forgejo-runner/` with: + +**kustomization.yaml**: +```yaml +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: forgejo-runner +resources: + - namespace.yaml + - deployment.yaml + - serviceaccount.yaml + - secret-token.yaml +``` + +**namespace.yaml**: +```yaml +apiVersion: v1 +kind: Namespace +metadata: + name: forgejo-runner +``` + +**serviceaccount.yaml**: +```yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: forgejo-runner + namespace: forgejo-runner +``` + +**deployment.yaml**: +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: forgejo-runner + namespace: forgejo-runner +spec: + replicas: 1 + selector: + matchLabels: + app: forgejo-runner + template: + metadata: + labels: + app: forgejo-runner + spec: + serviceAccountName: forgejo-runner + containers: + - name: runner + image: code.forgejo.org/forgejo/runner:3.5.1 + env: + - name: FORGEJO_INSTANCE_URL + value: "https://forge.tail8d86e.ts.net" + - name: RUNNER_NAME + value: "k8s-runner-1" + - name: RUNNER_TOKEN + valueFrom: + secretKeyRef: + name: forgejo-runner-token + key: token + command: + - /bin/sh + - -c + - | + # Register runner if not already registered + if [ ! -f /data/.runner ]; then + forgejo-runner register \ + --instance "$FORGEJO_INSTANCE_URL" \ + --token "$RUNNER_TOKEN" \ + --name "$RUNNER_NAME" \ + --labels "ubuntu-latest:docker://node:20-bookworm,ubuntu-22.04:docker://ubuntu:22.04" \ + --no-interactive + fi + # Start the runner daemon + forgejo-runner daemon + volumeMounts: + - name: runner-data + mountPath: /data + - name: docker-sock + mountPath: /var/run/docker.sock + resources: + requests: + memory: "256Mi" + cpu: "100m" + limits: + memory: "1Gi" + cpu: "1000m" + volumes: + - name: runner-data + emptyDir: {} + - name: docker-sock + hostPath: + path: /var/run/docker.sock + type: Socket +``` + +**Note**: The runner needs access to Docker to run workflow jobs in containers. In minikube with docker driver, `/var/run/docker.sock` is available. + +--- + +## Step 4: Deploy and Verify + +### 4.1 Inject Secrets and Deploy + +```bash +# Inject secrets +op inject -i argocd/manifests/forgejo-runner/secret-token.yaml.tpl \ + -o argocd/manifests/forgejo-runner/secret-token.yaml + +# Sync apps +argocd app sync apps +argocd app sync forgejo-runner +``` + +### 4.2 Verify Runner Registration + +```bash +# Check runner pod +kubectl --context=minikube-indri -n forgejo-runner get pods + +# Check runner logs +kubectl --context=minikube-indri -n forgejo-runner logs -f deployment/forgejo-runner + +# Verify in Forgejo UI +# Go to https://forge.tail8d86e.ts.net/admin/actions/runners +# Should see "k8s-runner-1" as online +``` + +--- + +## Step 5: Test with Simple Workflow + +### 5.1 Create Test Workflow + +In the blumeops repo, create `.forgejo/workflows/test.yml`: + +```yaml +name: Test CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Hello World + run: | + echo "Hello from Forgejo Actions!" + echo "Runner: ${{ runner.name }}" + echo "Repo: ${{ github.repository }}" +``` + +### 5.2 Push and Verify + +```bash +git add .forgejo/ +git commit -m "Add test workflow for Forgejo Actions" +git push +``` + +Check https://forge.tail8d86e.ts.net/eblume/blumeops/actions for the workflow run. + +--- + +## Verification Checklist + +- [x] Actions enabled in app.ini +- [x] Forgejo restarted successfully +- [x] Runner token stored in 1Password +- [x] Runner deployment created in ArgoCD +- [x] Runner pod running in k8s +- [x] Runner shows as online in Forgejo admin +- [x] Test workflow runs successfully + +--- + +## Troubleshooting + +### Runner Can't Connect to Forgejo + +The runner needs to reach `forge.tail8d86e.ts.net` from inside k8s. This should work via Tailscale operator egress (already configured for ArgoCD). + +If not working: +```bash +# Test from inside k8s +kubectl --context=minikube-indri run -it --rm curl --image=curlimages/curl -- \ + curl -v https://forge.tail8d86e.ts.net/api/v1/version +``` + +### Docker Socket Permission Denied + +The runner container needs to access the Docker socket. In minikube with docker driver, this should work. If permission denied: + +```bash +# Check socket permissions +kubectl --context=minikube-indri -n forgejo-runner exec deployment/forgejo-runner -- ls -la /var/run/docker.sock +``` + +May need to run runner as root or adjust security context. + +--- + +## Next Phase + +Once runner is working, proceed to [Phase 2: Mirror & Build](P2_mirror_and_build.md). diff --git a/plans/ci-cd-bootstrap/P2_mirror_and_build.md b/plans/ci-cd-bootstrap/P2_mirror_and_build.md new file mode 100644 index 0000000..5981066 --- /dev/null +++ b/plans/ci-cd-bootstrap/P2_mirror_and_build.md @@ -0,0 +1,347 @@ +# Phase 2: Custom Runner Image + +**Goal**: Build a custom forgejo-runner image with necessary tools, enabling standard GitHub Actions + +**Status**: Complete (2026-01-23) + +**Prerequisites**: [Phase 1](P1_enable_actions.md) complete (Actions enabled, runner deployed in host mode) + +--- + +## Problem Statement + +The stock `code.forgejo.org/forgejo/runner:3.5.1` image lacks tools needed for standard GitHub Actions: +- **Node.js** - Required by most actions (checkout, setup-*, etc.) +- **Git** - For repository operations (present but minimal) +- **Common build tools** - make, gcc, curl, jq, etc. + +In host mode, jobs run directly in the runner container, so these tools must be pre-installed. + +### Chicken-and-Egg Problem + +We can't use `actions/checkout@v4` to build the custom runner because that action requires Node.js, which we don't have yet. Solution: Bootstrap manually, then automate. + +--- + +## Step 1: Create Dockerfile for Custom Runner + +Create `argocd/manifests/forgejo-runner/Dockerfile`: + +```dockerfile +FROM code.forgejo.org/forgejo/runner:3.5.1 + +# The base image is Debian-based +# Install tools needed for GitHub Actions and builds +RUN apt-get update && apt-get install -y --no-install-recommends \ + # Required for actions/checkout and other Node-based actions + nodejs \ + npm \ + # Build essentials + git \ + curl \ + wget \ + jq \ + make \ + gcc \ + g++ \ + # For container builds (if we add Docker-in-Docker later) + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Verify Node.js is available +RUN node --version && npm --version +``` + +--- + +## Step 2: Bootstrap - Build Image Manually + +Since we can't use CI yet, build the image manually on gilbert and push to zot. + +### 2.1 Build with Podman + +```bash +cd ~/code/personal/blumeops/argocd/manifests/forgejo-runner + +# Build for linux/arm64 (minikube on M1 Mac) +podman build --platform linux/arm64 -t registry.tail8d86e.ts.net/blumeops/forgejo-runner:latest . + +# Push to zot (no auth required) +podman push registry.tail8d86e.ts.net/blumeops/forgejo-runner:latest +``` + +### 2.2 Verify Image in Registry + +```bash +curl -s https://registry.tail8d86e.ts.net/v2/blumeops/forgejo-runner/tags/list | jq . +``` + +--- + +## Step 3: Update Runner Deployment + +### 3.1 Update deployment.yaml + +Change the image from stock to custom: + +```yaml +# Before +image: code.forgejo.org/forgejo/runner:3.5.1 + +# After +image: registry.tail8d86e.ts.net/blumeops/forgejo-runner:latest +``` + +### 3.2 Update kustomization.yaml + +Add Dockerfile to resources (for reference, not deployed): + +```yaml +# Note: Dockerfile is for building, not k8s deployment +# It lives here for co-location with the runner manifests +``` + +### 3.3 Sync Deployment + +```bash +argocd app sync forgejo-runner + +# Verify new image is running +kubectl --context=minikube-indri -n forgejo-runner get pods -o jsonpath='{.items[*].spec.containers[*].image}' +``` + +--- + +## Step 4: Test with Real GitHub Action + +Now that we have Node.js, test with `actions/checkout@v4`. + +### 4.1 Update Test Workflow + +Update `.forgejo/workflows/test.yml`: + +```yaml +name: Test CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Verify tools + run: | + echo "Node.js: $(node --version)" + echo "npm: $(npm --version)" + echo "Git: $(git --version)" + echo "Make: $(make --version | head -1)" + + - name: Show repo info + run: | + echo "Repository: ${{ github.repository }}" + echo "Branch: ${{ github.ref_name }}" + ls -la +``` + +### 4.2 Push and Verify + +```bash +git add .forgejo/workflows/test.yml +git commit -m "Test checkout action with custom runner" +git push +``` + +Check https://forge.tail8d86e.ts.net/eblume/blumeops/actions - should see successful run with `actions/checkout@v4`. + +--- + +## Step 5: Create Auto-Build Workflow for Runner + +Now that Actions work properly, create a workflow to rebuild the runner image automatically. + +### 5.1 Create Build Workflow + +Create `.forgejo/workflows/build-runner.yml`: + +```yaml +name: Build Runner Image + +on: + push: + paths: + - 'argocd/manifests/forgejo-runner/Dockerfile' + - '.forgejo/workflows/build-runner.yml' + workflow_dispatch: + +env: + REGISTRY: registry.tail8d86e.ts.net + IMAGE_NAME: blumeops/forgejo-runner + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build image + run: | + cd argocd/manifests/forgejo-runner + # Use docker build (available in runner container) + # Note: This builds for the runner's native arch + docker build -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} . + docker tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} \ + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest + + - name: Push to registry + run: | + # Zot has no auth, just push + docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} + docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest + + - name: Verify push + run: | + curl -sf "https://${{ env.REGISTRY }}/v2/${{ env.IMAGE_NAME }}/tags/list" | jq . + echo "Image pushed: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}" +``` + +### 5.2 Note on Docker-in-Docker + +The runner runs in host mode, so we need Docker CLI available. Options: + +1. **Add Docker CLI to the custom image** (see Dockerfile update below) +2. **Mount Docker socket from minikube** (requires deployment change) +3. **Use Podman instead** (rootless, no socket needed) + +For now, we'll add Docker CLI to the image and mount the socket. + +### 5.3 Update Dockerfile for Docker Builds + +```dockerfile +FROM code.forgejo.org/forgejo/runner:3.5.1 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + nodejs \ + npm \ + git \ + curl \ + wget \ + jq \ + make \ + gcc \ + g++ \ + ca-certificates \ + # Docker CLI for building container images + docker.io \ + && rm -rf /var/lib/apt/lists/* + +RUN node --version && npm --version && docker --version +``` + +### 5.4 Update Deployment for Docker Socket + +Add Docker socket mount to `deployment.yaml`: + +```yaml +volumeMounts: + - name: runner-data + mountPath: /data + - name: runner-config + mountPath: /config + - name: docker-sock + mountPath: /var/run/docker.sock +volumes: + - name: runner-data + emptyDir: {} + - name: runner-config + configMap: + name: forgejo-runner-config + - name: docker-sock + hostPath: + path: /var/run/docker.sock + type: Socket +``` + +--- + +## Step 6: Verification + +### 6.1 Manual Image Build Works + +```bash +# On gilbert +podman build --platform linux/arm64 -t registry.tail8d86e.ts.net/blumeops/forgejo-runner:test . +podman push registry.tail8d86e.ts.net/blumeops/forgejo-runner:test +``` + +### 6.2 Runner Uses Custom Image + +```bash +kubectl --context=minikube-indri -n forgejo-runner get pods -o jsonpath='{.items[*].spec.containers[*].image}' +# Should show: registry.tail8d86e.ts.net/blumeops/forgejo-runner:latest +``` + +### 6.3 GitHub Actions Work + +- `actions/checkout@v4` succeeds +- Test workflow shows Node.js, npm, git versions + +### 6.4 Auto-Build Workflow Works + +Push a change to the Dockerfile and verify: +1. Workflow triggers +2. Image builds successfully +3. Image pushed to zot + +--- + +## Verification Checklist + +- [x] Dockerfile created for custom runner (Alpine-based with apk) +- [x] Image built manually on gilbert (podman build) +- [x] Image pushed to zot registry +- [x] Runner deployment updated to use custom image +- [x] Runner pod running with new image +- [x] `actions/checkout@v4` works in test workflow +- [ ] Auto-build workflow created (deferred - needs Docker socket) +- [ ] Docker socket mounted (for container builds) +- [ ] Auto-build workflow successfully rebuilds runner + +--- + +## Troubleshooting + +### Image Pull Fails in Minikube + +Minikube needs to be able to pull from zot. Check registry mirror config: +```bash +ssh indri 'minikube ssh -- cat /etc/containerd/certs.d/registry.tail8d86e.ts.net/hosts.toml' +``` + +### Docker Build Fails in Workflow + +If Docker socket mount doesn't work: +1. Check socket exists in minikube: `minikube ssh -- ls -la /var/run/docker.sock` +2. Check permissions: runner may need to be in docker group +3. Alternative: Use `podman` (rootless) instead of Docker + +### Node.js Actions Still Fail + +Ensure the runner pod restarted after image update: +```bash +kubectl --context=minikube-indri -n forgejo-runner rollout restart deployment/forgejo-runner +kubectl --context=minikube-indri -n forgejo-runner logs -f deployment/forgejo-runner +``` + +--- + +## Next Phase + +Once the custom runner is working with auto-build, proceed to [Phase 3: Mirror Forgejo & Build](P3_mirror_and_build.md) to set up Forgejo source builds. diff --git a/plans/ci-cd-bootstrap/P3_mirror_forgejo.md b/plans/ci-cd-bootstrap/P3_mirror_forgejo.md new file mode 100644 index 0000000..9e1e142 --- /dev/null +++ b/plans/ci-cd-bootstrap/P3_mirror_forgejo.md @@ -0,0 +1,349 @@ +# Phase 3: Mirror Forgejo & Build from Source + +**Goal**: Mirror upstream Forgejo to forge and create a workflow that builds it for macOS ARM64 + +**Status**: Planning + +**Prerequisites**: [Phase 2](P2_mirror_and_build.md) complete (custom runner image with Node.js/tools) + +--- + +## Problem Statement + +We want to build Forgejo from source to: +1. Have full control over the binary running on indri +2. Enable self-deployment via CI +3. Ensure proper macOS DNS resolution (requires CGO_ENABLED=1) + +### The Cross-Compilation Challenge + +The runner runs in a Linux container (k8s on indri), but the target is macOS ARM64 (indri itself). + +**Options**: + +| Option | Pros | Cons | +|--------|------|------| +| A. Cross-compile CGO_ENABLED=0 | Simple, no special toolchain | Breaks Tailscale MagicDNS resolution | +| B. Cross-compile CGO_ENABLED=1 | Proper DNS | Needs OSX cross-compiler (osxcross), complex | +| C. Build on gilbert manually | Works now, simple | Not automated, manual step | +| D. Native macOS runner on indri | Full native build | Runner outside k8s, different architecture | +| E. Hybrid: build on gilbert, deploy via CI | Uses existing tools | Partial automation | + +**Recommendation**: Start with Option C/E (manual build on gilbert, CI just deploys), then consider Option D if we want full automation. + +--- + +## Step 1: Mirror Upstream Forgejo + +### 1.1 User Action: Create Mirror on Forge + +**Manual step** (hairpinning doesn't work from indri): + +1. Go to https://forge.tail8d86e.ts.net +2. Click "+" → "New Migration" +3. Select "Gitea" as clone source +4. URL: `https://codeberg.org/forgejo/forgejo.git` +5. Repository name: `forgejo` +6. Check "This repository will be a mirror" +7. Click "Migrate Repository" + +### 1.2 Clone Mirror Locally + +```bash +git clone ssh://forgejo@forge.tail8d86e.ts.net/eblume/forgejo.git ~/code/3rd/forgejo +cd ~/code/3rd/forgejo +``` + +--- + +## Step 2: Understand Forgejo Build Process + +### 2.1 Build Requirements + +From Forgejo's `Makefile` and docs: + +- **Go**: 1.23+ (check `go.mod` for exact version) +- **Node.js**: 20+ (for frontend) +- **Make**: GNU Make +- **Git**: For version embedding + +### 2.2 Build Commands + +```bash +# Install frontend dependencies and build +make deps-frontend +make frontend + +# Build backend (with CGO for proper DNS on macOS) +CGO_ENABLED=1 TAGS="bindata sqlite sqlite_unlock_notify" make backend + +# Or all-in-one +CGO_ENABLED=1 TAGS="bindata sqlite sqlite_unlock_notify" make build +``` + +### 2.3 Output + +Binary at `gitea` (yes, the binary is still named `gitea` for compatibility). + +--- + +## Step 3: Build on Gilbert (Manual Bootstrap) + +For the initial bootstrap, build on gilbert (macOS ARM64 native). + +### 3.1 Setup Build Environment + +```bash +cd ~/code/3rd/forgejo +mise use go@1.23 node@20 + +# Verify tools +go version +node --version +make --version +``` + +### 3.2 Build + +```bash +# Clean build +make clean + +# Build frontend +make deps-frontend +make frontend + +# Build backend with CGO (important for macOS DNS!) +CGO_ENABLED=1 TAGS="bindata sqlite sqlite_unlock_notify" make backend + +# Verify binary +./gitea --version +file gitea # Should show: Mach-O 64-bit executable arm64 +``` + +### 3.3 Deploy to Indri + +```bash +# Copy binary +scp gitea indri:~/.local/bin/forgejo-new + +# Verify on indri +ssh indri '~/.local/bin/forgejo-new --version' +``` + +--- + +## Step 4: Create Deploy Workflow (Option E) + +Since cross-compilation is complex, use a hybrid approach: +1. Build on gilbert (manual trigger or pre-built) +2. CI workflow fetches and deploys + +### 4.1 SSH Deploy Key for Runner + +The runner needs SSH access to indri to deploy the binary. + +**Generate key on gilbert**: +```bash +ssh-keygen -t ed25519 -C "forgejo-runner-deploy" -f ~/.ssh/forgejo-runner-deploy -N "" +``` + +**Add public key to indri's authorized_keys**: +```bash +cat ~/.ssh/forgejo-runner-deploy.pub | ssh indri 'cat >> ~/.ssh/authorized_keys' +``` + +**Store private key in 1Password** (blumeops vault) as "Forgejo Runner Deploy Key" + +### 4.2 Create k8s Secret + +Create `argocd/manifests/forgejo-runner/secret-ssh.yaml.tpl`: + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: forgejo-runner-ssh + namespace: forgejo-runner +type: Opaque +stringData: + id_ed25519: | + op://blumeops//private-key + known_hosts: | + # Get with: ssh-keyscan indri.tail8d86e.ts.net 2>/dev/null | grep ed25519 + indri.tail8d86e.ts.net ssh-ed25519 AAAAC3... +``` + +### 4.3 Update Deployment for SSH + +Add SSH secret mount to `deployment.yaml`: + +```yaml +volumeMounts: + - name: ssh-key + mountPath: /root/.ssh + readOnly: true +volumes: + - name: ssh-key + secret: + secretName: forgejo-runner-ssh + defaultMode: 0600 +``` + +### 4.4 Create Deploy-Only Workflow + +Create `.forgejo/workflows/deploy-forgejo.yml` in blumeops: + +```yaml +name: Deploy Forgejo + +on: + workflow_dispatch: + inputs: + version: + description: 'Version to deploy (tag or commit)' + required: true + default: 'v10.0.0' + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Deploy to indri + env: + VERSION: ${{ github.event.inputs.version }} + run: | + # SSH config + mkdir -p ~/.ssh + cp /root/.ssh/id_ed25519 ~/.ssh/ + cp /root/.ssh/known_hosts ~/.ssh/ + chmod 600 ~/.ssh/id_ed25519 + + # Deploy script + ssh erichblume@indri.tail8d86e.ts.net << 'EOF' + set -e + cd ~/.local/bin + + # Verify the new binary exists and runs + if [ ! -f forgejo-new ]; then + echo "ERROR: forgejo-new not found. Build on gilbert first:" + echo " cd ~/code/3rd/forgejo && git checkout $VERSION" + echo " CGO_ENABLED=1 TAGS='bindata sqlite sqlite_unlock_notify' make build" + echo " scp gitea indri:~/.local/bin/forgejo-new" + exit 1 + fi + + ./forgejo-new --version + + # Stop current service + launchctl unload ~/Library/LaunchAgents/mcquack.eblume.forgejo.plist 2>/dev/null || true + + # Atomic swap + mv forgejo forgejo-old 2>/dev/null || true + mv forgejo-new forgejo + + # Start new service + launchctl load ~/Library/LaunchAgents/mcquack.eblume.forgejo.plist + + # Verify it's running + sleep 5 + curl -sf http://localhost:3001/api/v1/version || exit 1 + + echo "Deploy successful!" + ./forgejo --version + EOF +``` + +--- + +## Future: Full CI Build (Option D) + +If we want full automation, consider running a native macOS runner on indri: + +### Native Runner on Indri + +```bash +# Install forgejo-runner on indri via mise +ssh indri 'mise use forgejo-runner' + +# Register as a macOS runner +ssh indri 'forgejo-runner register \ + --instance https://forge.tail8d86e.ts.net \ + --token "$TOKEN" \ + --name "indri-native" \ + --labels "macos-arm64:host" \ + --no-interactive' + +# Create LaunchAgent for runner +# (similar to other mcquack services) +``` + +Then workflow uses: +```yaml +runs-on: macos-arm64 +``` + +This enables full native builds in CI. Document in a future phase if needed. + +--- + +## Verification Checklist + +- [ ] Forgejo mirrored to forge +- [ ] Mirror cloned to ~/code/3rd/forgejo +- [ ] Build succeeds on gilbert +- [ ] Binary is valid macOS ARM64 executable +- [ ] Binary deployed to indri ~/.local/bin/ +- [ ] SSH deploy key created and stored in 1Password +- [ ] Deploy key added to indri authorized_keys +- [ ] (Optional) k8s SSH secret created +- [ ] (Optional) Deploy workflow created + +--- + +## Troubleshooting + +### Build Fails: Node.js Version + +``` +error: engine "node" is incompatible +``` + +Update Node.js: `mise use node@20` + +### Build Fails: Go Version + +``` +go: go.mod requires go >= 1.23 +``` + +Update Go: `mise use go@1.23` + +### Binary Crashes on indri + +Check if CGO was enabled: +```bash +# If built without CGO, DNS resolution may fail +./forgejo --version # Should work +./forgejo web # May fail to resolve Tailscale hostnames +``` + +Rebuild with `CGO_ENABLED=1`. + +### SSH Deploy Fails + +Check runner has SSH access: +```bash +# Test from inside runner pod +kubectl --context=minikube-indri -n forgejo-runner exec deployment/forgejo-runner -- \ + ssh -i /root/.ssh/id_ed25519 erichblume@indri.tail8d86e.ts.net 'echo ok' +``` + +--- + +## Next Phase + +Once Forgejo is building and deploying successfully, proceed to [Phase 4: Self-Deploy](P4_self_deploy.md) for the full mcquack transition. diff --git a/plans/ci-cd-bootstrap/P4_self_deploy.md b/plans/ci-cd-bootstrap/P4_self_deploy.md new file mode 100644 index 0000000..8a73843 --- /dev/null +++ b/plans/ci-cd-bootstrap/P4_self_deploy.md @@ -0,0 +1,409 @@ +# Phase 4: Self-Deploy & Transition to mcquack + +**Goal**: Complete the bootstrap - Forgejo deploys itself, transition from brew to mcquack LaunchAgent + +**Status**: Planning + +**Prerequisites**: [Phase 3](P3_mirror_forgejo.md) complete (Forgejo builds and deploys to indri) + +--- + +## Overview + +This phase completes the bootstrap: +1. First successful CI deploy creates the binary +2. Transition from brew service to mcquack LaunchAgent +3. Update ansible role to mcquack pattern +4. Remove brew forgejo + +After this phase, Forgejo builds and deploys itself on every tagged release. + +--- + +## Step 1: Prepare indri for mcquack + +### 1.1 Create Directory Structure + +```bash +ssh indri << 'EOF' + mkdir -p ~/.local/bin + mkdir -p ~/.config/forgejo + mkdir -p ~/Library/Logs +EOF +``` + +### 1.2 Prepare Data Directory + +The existing data is at `/opt/homebrew/var/forgejo`. We'll keep it there for now (simpler), or optionally migrate to `~/forgejo`. + +**Option A: Keep existing path** (recommended for simplicity) +- Data stays at `/opt/homebrew/var/forgejo` +- Binary moves to `~/.local/bin/forgejo` + +**Option B: Full migration** +- Move data to `~/forgejo` +- Requires updating app.ini paths + +For this plan, we'll use Option A. + +--- + +## Step 2: First CI Deploy + +### 2.1 Trigger Build with Deploy + +1. Go to https://forge.tail8d86e.ts.net/eblume/forgejo/actions +2. Select "Build Forgejo" workflow +3. Click "Run workflow" +4. Set deploy=true +5. Monitor the run + +### 2.2 Verify Binary Deployed + +```bash +ssh indri 'ls -la ~/.local/bin/forgejo && ~/.local/bin/forgejo --version' +``` + +At this point: +- New binary is at `~/.local/bin/forgejo` +- Brew forgejo is still running +- LaunchAgent doesn't exist yet + +--- + +## Step 3: Create mcquack LaunchAgent + +### 3.1 Create Plist Manually (One-Time Bootstrap) + +```bash +ssh indri << 'EOF' +cat > ~/Library/LaunchAgents/mcquack.eblume.forgejo.plist << 'PLIST' + + + + + Label + mcquack.eblume.forgejo + ProgramArguments + + /Users/erichblume/.local/bin/forgejo + web + --config + /opt/homebrew/var/forgejo/custom/conf/app.ini + --work-path + /opt/homebrew/var/forgejo + + RunAtLoad + + KeepAlive + + StandardOutPath + /Users/erichblume/Library/Logs/mcquack.forgejo.out.log + StandardErrorPath + /Users/erichblume/Library/Logs/mcquack.forgejo.err.log + EnvironmentVariables + + HOME + /Users/erichblume + USER + erichblume + + + +PLIST +EOF +``` + +--- + +## Step 4: Cutover from Brew to mcquack + +### 4.1 Stop Brew Service + +```bash +ssh indri 'brew services stop forgejo' +``` + +### 4.2 Start mcquack Service + +```bash +ssh indri 'launchctl load ~/Library/LaunchAgents/mcquack.eblume.forgejo.plist' +``` + +### 4.3 Verify Service Running + +```bash +# Check process +ssh indri 'launchctl list | grep forgejo' + +# Check logs +ssh indri 'tail -20 ~/Library/Logs/mcquack.forgejo.err.log' + +# Check HTTP +curl -s https://forge.tail8d86e.ts.net/api/v1/version +``` + +### 4.4 Verify Git Operations + +```bash +# SSH test +ssh -T forgejo@forge.tail8d86e.ts.net + +# Clone test +git clone ssh://forgejo@forge.tail8d86e.ts.net/eblume/blumeops.git /tmp/test-clone +rm -rf /tmp/test-clone +``` + +--- + +## Step 5: Update Ansible Role + +### 5.1 Rewrite forgejo Role + +Replace `ansible/roles/forgejo/tasks/main.yml`: + +```yaml +--- +# Forgejo is built from source via CI and deployed automatically. +# This role manages the configuration and LaunchAgent only. +# +# BINARY DEPLOYMENT: +# The binary at ~/.local/bin/forgejo is deployed by Forgejo Actions CI. +# If missing, trigger a build at: +# https://forge.tail8d86e.ts.net/eblume/forgejo/actions +# +# CONFIGURATION: +# app.ini at /opt/homebrew/var/forgejo/custom/conf/app.ini contains secrets +# and is NOT managed by ansible. It is backed up by borgmatic. + +- 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 }}. + + The binary is deployed by Forgejo Actions CI. To build and deploy: + 1. Go to https://forge.tail8d86e.ts.net/eblume/forgejo/actions + 2. Select "Build Forgejo" workflow + 3. Click "Run workflow" with deploy=true + + Alternatively, build manually on gilbert and scp to indri. + when: not forgejo_binary_stat.stat.exists + +- name: Check forgejo config exists + ansible.builtin.stat: + path: "{{ forgejo_config }}" + register: forgejo_config_stat + +- name: Fail if forgejo config is missing + ansible.builtin.fail: + msg: | + Forgejo config not found at {{ forgejo_config }} + This file contains secrets and is not managed by ansible. + To restore from backup, run: + borgmatic --config ~/.config/borgmatic/config.yaml extract --archive latest \ + --path {{ forgejo_config }} + when: not forgejo_config_stat.stat.exists + +- 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 +``` + +### 5.2 Create defaults/main.yml + +```yaml +--- +# Forgejo binary and paths +forgejo_binary: /Users/erichblume/.local/bin/forgejo +forgejo_work_path: /opt/homebrew/var/forgejo +forgejo_config: "{{ forgejo_work_path }}/custom/conf/app.ini" +forgejo_log_dir: /Users/erichblume/Library/Logs + +# HTTP and SSH ports (must match app.ini) +forgejo_http_port: 3001 +forgejo_ssh_port: 2200 +``` + +### 5.3 Create templates/forgejo.plist.j2 + +```xml + + + + + + Label + mcquack.eblume.forgejo + ProgramArguments + + {{ forgejo_binary }} + web + --config + {{ forgejo_config }} + --work-path + {{ forgejo_work_path }} + + RunAtLoad + + KeepAlive + + StandardOutPath + {{ forgejo_log_dir }}/mcquack.forgejo.out.log + StandardErrorPath + {{ forgejo_log_dir }}/mcquack.forgejo.err.log + EnvironmentVariables + + HOME + /Users/erichblume + USER + erichblume + + + +``` + +### 5.4 Update handlers/main.yml + +```yaml +--- +- name: 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 +``` + +--- + +## Step 6: Update Alloy Log Collection + +Update `ansible/roles/alloy/defaults/main.yml`: + +Change forgejo log paths from brew to mcquack: +```yaml +alloy_brew_logs: + # Remove forgejo from here + - path: /opt/homebrew/var/log/tailscaled.log + service: tailscale + stream: stdout + +alloy_mcquack_logs: + # ... existing entries ... + - path: /Users/erichblume/Library/Logs/mcquack.forgejo.out.log + service: forgejo + stream: stdout + - path: /Users/erichblume/Library/Logs/mcquack.forgejo.err.log + service: forgejo + stream: stderr +``` + +--- + +## Step 7: Remove Brew Forgejo + +### 7.1 Uninstall Brew Package + +```bash +ssh indri 'brew uninstall forgejo' +``` + +### 7.2 Remove Old Logs + +```bash +ssh indri 'rm -f /opt/homebrew/var/log/forgejo.log' +``` + +--- + +## Step 8: Run Ansible + +```bash +mise run provision-indri -- --tags forgejo,alloy +``` + +--- + +## Disaster Recovery + +### If CI Deploy Breaks Forgejo + +1. **Build manually on gilbert**: + ```bash + cd ~/code/3rd/forgejo + git pull + mise use go node + TAGS="bindata sqlite sqlite_unlock_notify" make build + scp gitea indri:~/.local/bin/forgejo + ``` + +2. **Restart service**: + ```bash + ssh indri 'launchctl unload ~/Library/LaunchAgents/mcquack.eblume.forgejo.plist; launchctl load ~/Library/LaunchAgents/mcquack.eblume.forgejo.plist' + ``` + +3. **Verify**: + ```bash + curl https://forge.tail8d86e.ts.net/api/v1/version + ``` + +### If Forgejo Won't Start + +1. Check logs: `ssh indri 'tail -100 ~/Library/Logs/mcquack.forgejo.err.log'` +2. Check binary: `ssh indri '~/.local/bin/forgejo --version'` +3. Check config: `ssh indri 'cat /opt/homebrew/var/forgejo/custom/conf/app.ini | head -50'` +4. Try running manually: `ssh indri '~/.local/bin/forgejo web --config /opt/homebrew/var/forgejo/custom/conf/app.ini --work-path /opt/homebrew/var/forgejo'` + +### Switch ArgoCD to GitHub (Nuclear Option) + +If Forgejo is down and you need to deploy fixes: + +```bash +argocd repo add https://github.com/eblume/blumeops.git --username eblume --password $GITHUB_PAT +argocd app set apps --repo https://github.com/eblume/blumeops.git +argocd app sync apps +``` + +After recovery, switch back to Forgejo. + +--- + +## Verification Checklist + +- [ ] CI deploy completed successfully +- [ ] Binary at `~/.local/bin/forgejo` +- [ ] mcquack LaunchAgent created +- [ ] Brew service stopped +- [ ] mcquack service started +- [ ] HTTP works (`curl https://forge.tail8d86e.ts.net/api/v1/version`) +- [ ] SSH works (`ssh -T forgejo@forge.tail8d86e.ts.net`) +- [ ] Git clone/push works +- [ ] Ansible role updated +- [ ] Alloy logs updated +- [ ] Brew package uninstalled +- [ ] `mise run provision-indri` succeeds + +--- + +## Next Phase + +After bootstrap is complete, proceed to [Phase 5: Container Builds](P5_container_builds.md) to set up container image building for ArgoCD. diff --git a/plans/ci-cd-bootstrap/P5_container_builds.md b/plans/ci-cd-bootstrap/P5_container_builds.md new file mode 100644 index 0000000..fcae2b2 --- /dev/null +++ b/plans/ci-cd-bootstrap/P5_container_builds.md @@ -0,0 +1,505 @@ +# Phase 5: Container Image Builds + +**Goal**: Set up CI workflows to build custom container images and push to zot registry + +**Status**: Planning + +**Prerequisites**: [Phase 4](P4_self_deploy.md) complete (Forgejo self-deploying, Actions working) + +--- + +## Overview + +With Forgejo Actions operational (including custom runner from P2), we can now build container images for: +- Custom devpi with pre-installed plugins +- Any other custom images needed for k8s services +- Release artifacts for Python packages + +**Note**: The custom runner image build is covered in [Phase 2](P2_mirror_and_build.md). This phase focuses on application container builds. + +--- + +## Use Case 1: devpi Custom Image + +### Current State + +devpi runs from `registry.tail8d86e.ts.net/blumeops/devpi:latest`, built manually: +- Base image: python +- Adds: devpi-server, devpi-web +- Startup script for auto-initialization + +### Goal + +Automate builds triggered by: +- Push to devpi repo on forge +- Manual workflow dispatch +- Optionally: upstream devpi release (via schedule check) + +--- + +## Step 1: Create Workflow for devpi + +### 1.1 Ensure devpi Repo Has Dockerfile + +The Dockerfile already exists at `argocd/manifests/devpi/Dockerfile`. We'll create a workflow in the blumeops repo that builds it. + +### 1.2 Create Build Workflow + +Create `.forgejo/workflows/build-devpi.yml` in blumeops repo: + +```yaml +name: Build devpi Image + +on: + push: + paths: + - 'argocd/manifests/devpi/Dockerfile' + - 'argocd/manifests/devpi/start.sh' + - '.forgejo/workflows/build-devpi.yml' + workflow_dispatch: + inputs: + tag: + description: 'Image tag (default: latest)' + required: false + default: 'latest' + +env: + REGISTRY: registry.tail8d86e.ts.net + IMAGE_NAME: blumeops/devpi + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Determine tag + id: tag + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + TAG="${{ github.event.inputs.tag }}" + else + TAG="latest" + fi + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + + - name: Build image + uses: docker/build-push-action@v5 + with: + context: argocd/manifests/devpi + file: argocd/manifests/devpi/Dockerfile + platforms: linux/arm64 + load: true + tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.tag }} + + - name: Push to registry + run: | + # Zot has no auth, just push + docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.tag }} + + - name: Verify push + run: | + # Check image exists in registry + curl -sf "https://${{ env.REGISTRY }}/v2/${{ env.IMAGE_NAME }}/tags/list" | jq . +``` + +### 1.3 Runner Needs Registry Access + +The runner needs to reach `registry.tail8d86e.ts.net`. This should work via Tailscale egress (same as Forgejo access). + +If not, add egress for registry in `argocd/manifests/tailscale-operator/`: +```yaml +apiVersion: tailscale.com/v1alpha1 +kind: Connector +metadata: + name: egress-registry + namespace: tailscale-operator +spec: + hostname: egress-registry + subnetRouter: + advertiseRoutes: + - registry.tail8d86e.ts.net/32 +``` + +--- + +## Step 2: Test Build Workflow + +### 2.1 Push and Trigger + +```bash +# Make a small change to trigger +echo "# Build $(date)" >> argocd/manifests/devpi/Dockerfile +git add argocd/manifests/devpi/Dockerfile +git commit -m "Trigger devpi image rebuild" +git push +``` + +### 2.2 Monitor Build + +1. Go to https://forge.tail8d86e.ts.net/eblume/blumeops/actions +2. Watch "Build devpi Image" workflow +3. Verify success + +### 2.3 Verify Image in Registry + +```bash +curl -s https://registry.tail8d86e.ts.net/v2/blumeops/devpi/tags/list | jq . +``` + +### 2.4 Restart devpi to Use New Image + +```bash +kubectl --context=minikube-indri -n devpi rollout restart statefulset/devpi +``` + +--- + +## Step 3: Reusable Container Build Workflow + +### 3.1 Create Reusable Workflow + +Create `.forgejo/workflows/build-container.yml`: + +```yaml +name: Build Container Image + +on: + workflow_call: + inputs: + context: + description: 'Build context path' + required: true + type: string + dockerfile: + description: 'Dockerfile path (relative to context)' + required: false + type: string + default: 'Dockerfile' + image_name: + description: 'Image name (without registry)' + required: true + type: string + tag: + description: 'Image tag' + required: false + type: string + default: 'latest' + platforms: + description: 'Target platforms' + required: false + type: string + default: 'linux/arm64' + +env: + REGISTRY: registry.tail8d86e.ts.net + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: ${{ inputs.context }} + file: ${{ inputs.context }}/${{ inputs.dockerfile }} + platforms: ${{ inputs.platforms }} + push: true + tags: ${{ env.REGISTRY }}/${{ inputs.image_name }}:${{ inputs.tag }} + + - name: Verify push + run: | + curl -sf "https://${{ env.REGISTRY }}/v2/${{ inputs.image_name }}/tags/list" | jq . +``` + +### 3.2 Use in devpi Workflow + +Simplify `.forgejo/workflows/build-devpi.yml`: + +```yaml +name: Build devpi Image + +on: + push: + paths: + - 'argocd/manifests/devpi/**' + workflow_dispatch: + +jobs: + build: + uses: ./.forgejo/workflows/build-container.yml + with: + context: argocd/manifests/devpi + image_name: blumeops/devpi +``` + +--- + +## Step 4: Python Package Builds (Optional) + +### 4.1 Use Case + +Build Python packages from forge repos and publish to devpi. + +Example: `mcquack` package (LaunchAgent management library) + +### 4.2 Create Python Build Workflow + +Create `.forgejo/workflows/build-python.yml`: + +```yaml +name: Build Python Package + +on: + workflow_call: + inputs: + package_path: + description: 'Path to package (contains pyproject.toml)' + required: false + type: string + default: '.' + python_version: + description: 'Python version' + required: false + type: string + default: '3.12' + publish: + description: 'Publish to devpi' + required: false + type: boolean + default: false + secrets: + DEVPI_PASSWORD: + required: false + +env: + DEVPI_URL: https://pypi.tail8d86e.ts.net + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: ${{ inputs.python_version }} + + - name: Install uv + run: pip install uv + + - name: Build package + run: | + cd ${{ inputs.package_path }} + uv build + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: dist + path: ${{ inputs.package_path }}/dist/ + + - name: Publish to devpi + if: inputs.publish + run: | + cd ${{ inputs.package_path }} + uv publish \ + --publish-url ${{ env.DEVPI_URL }}/eblume/dev/ \ + --username eblume \ + --password "${{ secrets.DEVPI_PASSWORD }}" +``` + +--- + +## Step 5: Scheduled Builds (Cron) + +### 5.1 Weekly Rebuild + +Keep images fresh with weekly rebuilds: + +```yaml +name: Weekly Image Rebuilds + +on: + schedule: + # Every Sunday at 3 AM UTC + - cron: '0 3 * * 0' + workflow_dispatch: + +jobs: + devpi: + uses: ./.forgejo/workflows/build-container.yml + with: + context: argocd/manifests/devpi + image_name: blumeops/devpi +``` + +--- + +## Future Improvements + +### Multi-Arch Builds + +For images that need both ARM64 and AMD64: + +```yaml +platforms: linux/arm64,linux/amd64 +``` + +Requires QEMU emulation setup in runner (already supported by buildx). + +### Build Caching + +Use GitHub/Forgejo cache actions: + +```yaml +- name: Cache Docker layers + uses: actions/cache@v4 + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-buildx-${{ hashFiles('**/Dockerfile') }} +``` + +### Security Scanning + +Add Trivy or similar: + +```yaml +- name: Run Trivy vulnerability scanner + uses: aquasecurity/trivy-action@master + with: + image-ref: '${{ env.REGISTRY }}/${{ inputs.image_name }}:${{ inputs.tag }}' +``` + +--- + +## Step 6: Runner Observability (Logging & Metrics) + +### 6.1 Problem + +The forgejo-runner pod generates logs and metrics that should be collected for: +- Debugging failed workflow runs +- Monitoring runner health and capacity +- Alerting on runner failures + +### 6.2 Log Collection via Alloy + +The forgejo-runner namespace needs to be included in Alloy's k8s log collection. Alloy is already configured to scrape logs from k8s pods - verify the runner namespace is included. + +Check current Alloy config: +```bash +ssh indri 'cat ~/.config/alloy/config.alloy | grep -A20 discovery.kubernetes' +``` + +If using namespace filtering, ensure `forgejo-runner` is included. + +### 6.3 Metrics Collection + +The forgejo-runner exposes Prometheus metrics. Add a ServiceMonitor or configure Alloy to scrape: + +**Option A: ServiceMonitor (if using Prometheus Operator)** + +Create `argocd/manifests/forgejo-runner/servicemonitor.yaml`: +```yaml +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: forgejo-runner + namespace: forgejo-runner +spec: + selector: + matchLabels: + app: forgejo-runner + endpoints: + - port: metrics + interval: 30s +``` + +**Option B: Alloy scrape config** + +Add to Alloy's k8s scrape config to discover the runner pod's metrics endpoint. + +### 6.4 Create Runner Service for Metrics + +Add `argocd/manifests/forgejo-runner/service.yaml`: +```yaml +apiVersion: v1 +kind: Service +metadata: + name: forgejo-runner-metrics + namespace: forgejo-runner + labels: + app: forgejo-runner +spec: + selector: + app: forgejo-runner + ports: + - name: metrics + port: 8080 + targetPort: 8080 +``` + +Update kustomization.yaml to include the service. + +### 6.5 Grafana Dashboard + +Consider creating a dashboard for: +- Runner status (online/offline) +- Job queue depth +- Job execution time +- Success/failure rates + +### 6.6 Verification + +```bash +# Check runner logs are appearing in Loki +# Go to Grafana → Explore → Loki +# Query: {namespace="forgejo-runner"} + +# Check metrics are being scraped +# Go to Grafana → Explore → Prometheus +# Query: forgejo_runner_* +``` + +--- + +## Verification Checklist + +- [ ] devpi build workflow created +- [ ] devpi image builds successfully +- [ ] Image pushed to zot registry +- [ ] devpi pod uses new image +- [ ] Reusable container workflow created +- [ ] (Optional) Python build workflow created +- [ ] (Optional) Scheduled builds configured +- [ ] Runner logs visible in Loki +- [ ] Runner metrics scraped by Prometheus/Alloy + +--- + +## Summary + +With this phase complete, we have: +1. **Forgejo Actions** running with k8s runner +2. **Forgejo self-deploys** from CI on tagged releases +3. **Container images** built automatically on push +4. Infrastructure for Python package builds +5. **Runner observability** with logs in Loki and metrics in Prometheus + +The CI/CD bootstrap is complete. Future work: +- Add more container builds as needed +- Add Python package publishing for internal tools +- Consider adding a macOS runner on indri for native builds +- Create Grafana dashboards for CI/CD monitoring diff --git a/plans/completed/k8s-migration/00_overview.md b/plans/completed/k8s-migration/00_overview.md new file mode 100644 index 0000000..5e336c0 --- /dev/null +++ b/plans/completed/k8s-migration/00_overview.md @@ -0,0 +1,79 @@ +# Blumeops Minikube Migration Plan + +**Status**: Completed (2026-01-23) + +This plan detailed the phased migration of blumeops services from direct hosting on indri (Mac Mini M1) to a minikube cluster. The migration is now complete for all services that will be migrated. + +## Final Status + +| Phase | Name | Status | Notes | +|-------|------|--------|-------| +| 0 | [Foundation](P0_foundation.complete.md) | ✅ Complete | Container registry (zot) + minikube cluster | +| 1 | [K8s Infrastructure](P1_k8s_infrastructure.complete.md) | ✅ Complete | Tailscale operator, ArgoCD, CloudNativePG, PostgreSQL cluster | +| 2 | [Grafana](P2_grafana.complete.md) | ✅ Complete | Migrated Grafana via ArgoCD | +| 3 | [PostgreSQL](P3_postgresql.complete.md) | ✅ Complete | Data migration to k8s PostgreSQL | +| 4 | [Miniflux](P4_miniflux.complete.md) | ✅ Complete | Migrated Miniflux via ArgoCD | +| 5 | [devpi](P5_devpi.complete.md) | ✅ Complete | Migrated devpi via ArgoCD | +| 5.1 | [Docker Migration](P5.1_docker_migration.complete.md) | ✅ Complete | Switched minikube to docker driver (not QEMU2) | +| 6 | [Kiwix](P6_kiwix.complete.md) | ✅ Complete | Migrated Kiwix + Transmission via ArgoCD | +| 7 | [Forgejo](P7_forgejo.md) | ⏭️ Won't Do | Forgejo stays on indri - see [CI/CD Bootstrap](../../ci-cd-bootstrap/) | +| 8 | [Woodpecker](P8_woodpecker.md) | ⏭️ Won't Do | Replaced by Forgejo Actions - see [CI/CD Bootstrap](../../ci-cd-bootstrap/) | +| 9 | [Cleanup](P9_cleanup.md) | ⏭️ Won't Do | Observability cleanup done separately (2026-01-22) | + +## What Was Migrated to K8s + +| Service | Status | Notes | +|---------|--------|-------| +| Grafana | ✅ In k8s | Helm chart via ArgoCD | +| PostgreSQL | ✅ In k8s | CloudNativePG operator | +| Miniflux | ✅ In k8s | Using k8s PostgreSQL | +| devpi | ✅ In k8s | Custom container image | +| Kiwix | ✅ In k8s | NFS mount from sifaka | +| Transmission | ✅ In k8s | NFS mount from sifaka | +| Prometheus | ✅ In k8s | Migrated 2026-01-22 | +| Loki | ✅ In k8s | Migrated 2026-01-22 | +| Alloy (k8s) | ✅ In k8s | DaemonSet for pod logs | +| TeslaMate | ✅ In k8s | Added 2026-01-23 | + +## What Stays on Indri + +| Service | Reason | +|---------|--------| +| **Forgejo** | Critical infrastructure, avoids circular dependency with ArgoCD | +| **Zot Registry** | K8s needs images to start - must be outside k8s | +| **Alloy (host)** | Collects host-level metrics and logs | +| **Borgmatic** | Backup system must survive k8s failures | +| **Plex** | Uses own NAT traversal, not Tailscale | + +## Architecture Decisions Made + +### Minikube Driver: Docker (not QEMU2/Podman) +- Original plan called for QEMU2, but docker driver proved simpler +- NFS mounts work via Docker NAT through indri's LAN IP +- API server accessible via Tailscale TCP passthrough + +### Forgejo: Stays on Indri +- Original P7 planned k8s migration +- Decision changed: Forgejo is critical infrastructure +- Will be built from source via Forgejo Actions CI +- See [CI/CD Bootstrap Plan](../../ci-cd-bootstrap/) for details + +### CI/CD: Forgejo Actions (not Woodpecker) +- Original P8 planned Woodpecker deployment +- Decision changed: Use Forgejo's native Actions instead +- Simpler (one less system), GitHub Actions compatible +- See [CI/CD Bootstrap Plan](../../ci-cd-bootstrap/) for details + +### Observability: Migrated to K8s +- Original plan kept Prometheus/Loki on indri +- Changed: Migrated both to k8s (2026-01-22) +- Alloy on indri pushes to k8s endpoints +- Alloy DaemonSet in k8s collects pod logs + +## Lessons Learned + +1. **Docker driver is simpler than QEMU2** - Direct NFS mounts work, no VM complexity +2. **Tailscale operator works well** - Easy service exposure with automatic TLS +3. **CloudNativePG is production-ready** - Good operator, easy backups +4. **Keep critical infra outside k8s** - Forgejo and zot must survive k8s failures +5. **CGO matters on macOS** - Alloy needed CGO=1 for Tailscale DNS resolution diff --git a/plans/completed/k8s-migration/P0_foundation.complete.md b/plans/completed/k8s-migration/P0_foundation.complete.md new file mode 100644 index 0000000..934e83a --- /dev/null +++ b/plans/completed/k8s-migration/P0_foundation.complete.md @@ -0,0 +1,1225 @@ +# Phase 0: Foundation (Complete) + +**Goal**: Container registry + minikube cluster without disrupting existing services + +**Status**: Complete + +--- + +## Important: Tailscale Service Creation Order + +> **WARNING**: You MUST create services in the Tailscale admin console BEFORE running `tailscale serve` commands via ansible. If you run `tailscale serve --service svc:foo` before the service exists in the admin console, the local config will be in a bad state. +> +> To fix a misconfigured service: +> ```bash +> tailscale serve --service svc:foo reset +> ``` +> Then create the service in admin console and try again. + +--- + +## Step 0.1: Update Pulumi ACLs (BEFORE Tailscale serve) + +**Files to modify:** +- `pulumi/policy.hujson` + +**Changes:** + +1. Add new tag to `tagOwners` section (around line 104, after `"tag:feed"`): +```hujson +"tag:registry": ["autogroup:admin", "tag:blumeops"], +``` + +2. Add test cases to `tests` section: + - Update Erich's accept list (around line 111) to include registry: + ```hujson + "accept": ["tag:grafana:443", "tag:kiwix:443", "tag:feed:443", "tag:loki:3100", "tag:pg:5432", "tag:homelab:22", "tag:registry:443"], + ``` + - Update Allison's deny list (around line 117) to deny registry: + ```hujson + "deny": ["tag:grafana:443", "tag:loki:3100", "tag:nas:445", "tag:registry:443"], + ``` + +**Note:** +- No member grant needed - admins have full access via wildcard, members don't need registry +- `tag:k8s` is added later in Phase 1 when the Tailscale Kubernetes Operator is deployed +- Zot supports htpasswd auth if we later need finer-grained control + +**Testing:** +```bash +mise run tailnet-preview # Review changes - should show new tag +mise run tailnet-up # Apply changes +``` + +**Implementation Details:** +- Also need to add `"tag:registry"` to indri's tags in `pulumi/__main__.py` (the `DeviceTags` resource), not just define it in `policy.hujson`. The policy file defines the tag ownership rules, but the device tags are managed separately in the Python code. + +--- + +## Step 0.2: Create Tailscale Services in Admin Console (MANUAL) + +> **CRITICAL**: Do this BEFORE running any ansible that calls `tailscale serve` + +1. Go to https://login.tailscale.com/admin/services +2. Create service `registry` with: + - Port: 443 (HTTPS) + - Host: indri + +**Implementation Details:** +- Tag is applied to indri via Pulumi in Step 0.1, not manually in admin console. + +**Verification:** +```bash +# Service should appear (even if not yet serving) +tailscale status | grep registry +``` + +--- + +## Step 0.3: Create Zot Registry Ansible Role + +**Note:** Zot is NOT in homebrew (no formula or tap). Clone to `~/code/3rd/` on indri and build from source (requires Go). + +**Prerequisites on indri (ALREADY COMPLETED):** +```bash +# Clone zot from forge mirror (use localhost:3001 - hairpinning doesn't work on indri) +ssh indri 'git clone http://localhost:3001/eblume/zot.git ~/code/3rd/zot' + +# Set up Go via mise (creates mise.toml in repo directory) +ssh indri 'cd ~/code/3rd/zot && mise use go@1.25' + +# Build (creates bin/zot-darwin-arm64, ~183MB) +ssh indri 'cd ~/code/3rd/zot && mise x -- make binary' + +# Verify binary exists +ssh indri 'ls -la ~/code/3rd/zot/bin/zot-darwin-arm64' +``` + +**Build verified:** Binary at `~/code/3rd/zot/bin/zot-darwin-arm64` (183MB, ARM64 native). + +**New files:** +``` +ansible/roles/zot/ +├── defaults/main.yml +├── tasks/main.yml +├── templates/ +│ ├── config.json.j2 +│ └── zot.plist.j2 +└── handlers/main.yml +``` + +**Key configuration (defaults/main.yml):** +```yaml +zot_repo_dir: "/Users/erichblume/code/3rd/zot" +zot_binary: "{{ zot_repo_dir }}/bin/zot-darwin-arm64" +zot_data_dir: "/Users/erichblume/zot" +zot_config_dir: "/Users/erichblume/.config/zot" +zot_port: 5000 +zot_log_dir: "/Users/erichblume/Library/Logs" + +# Pull-through cache registries (on-demand sync) +zot_sync_registries: + - name: docker.io + url: https://registry-1.docker.io + - name: ghcr.io + url: https://ghcr.io + - name: quay.io + url: https://quay.io +``` + +**Zot config.json template** (key sections): +```json +{ + "storage": { + "rootDirectory": "/Users/erichblume/zot" + }, + "http": { + "address": "0.0.0.0", + "port": "5000" + }, + "extensions": { + "sync": { + "enable": true, + "registries": [ + { + "urls": ["https://registry-1.docker.io"], + "content": [{"prefix": "**"}], + "onDemand": true, + "tlsVerify": true + }, + { + "urls": ["https://ghcr.io"], + "content": [{"prefix": "**"}], + "onDemand": true, + "tlsVerify": true + }, + { + "urls": ["https://quay.io"], + "content": [{"prefix": "**"}], + "onDemand": true, + "tlsVerify": true + } + ] + } + } +} +``` + +**Two modes of operation:** + +1. **Pull-through cache** (automatic): When you pull `registry.tail8d86e.ts.net/docker.io/library/nginx:latest`, Zot fetches from Docker Hub and caches locally. Subsequent pulls are local. + +2. **Private images** (manual push): Push your own images to any path NOT matching a sync prefix: + ```bash + # From gilbert (after building) + podman push myapp:v1 registry.tail8d86e.ts.net/blumeops/myapp:v1 + ``` + +**Namespace convention:** +- `registry.tail8d86e.ts.net/docker.io/*` → cached from Docker Hub +- `registry.tail8d86e.ts.net/ghcr.io/*` → cached from GHCR +- `registry.tail8d86e.ts.net/quay.io/*` → cached from Quay +- `registry.tail8d86e.ts.net/blumeops/*` → private images (built by you/Woodpecker) + +**LaunchAgent template (zot.plist.j2):** +```xml + + + + + Label + mcquack.eblume.zot + ProgramArguments + + + {{ zot_binary }} + serve + {{ zot_config_dir }}/config.json + + RunAtLoad + + KeepAlive + + StandardOutPath + {{ zot_log_dir }}/mcquack.zot.out.log + StandardErrorPath + {{ zot_log_dir }}/mcquack.zot.err.log + + +``` + +**Handlers (handlers/main.yml):** +```yaml +- name: Restart zot + ansible.builtin.shell: | + launchctl unload ~/Library/LaunchAgents/mcquack.eblume.zot.plist 2>/dev/null || true + launchctl load ~/Library/LaunchAgents/mcquack.eblume.zot.plist + changed_when: true +``` + +**Tasks should notify handler on config change:** +```yaml +- name: Deploy zot config + ansible.builtin.template: + src: config.json.j2 + dest: "{{ zot_config_dir }}/config.json" + notify: Restart zot +``` + +**Testing (after deploying role):** +```bash +# Check LaunchAgent is running +ssh indri 'launchctl list | grep zot' + +# Check zot is responding +ssh indri 'curl -s http://localhost:5000/v2/_catalog' +# Expected: {"repositories":[]} + +# Check logs for errors +ssh indri 'tail -20 ~/Library/Logs/mcquack.zot.err.log' + +# Test pull-through cache via curl (podman not installed until Step 0.8) +ssh indri 'curl -s http://localhost:5000/v2/docker.io/library/alpine/manifests/latest -H "Accept: application/vnd.oci.image.manifest.v1+json"' +# Should return manifest JSON (triggers cache fetch from Docker Hub) +ssh indri 'curl -s http://localhost:5000/v2/_catalog' +# Expected: {"repositories":["docker.io/library/alpine"]} +``` + +**Implementation Details:** +- Changed port from 5000 to 5050 because macOS ControlCenter (AirPlay Receiver) uses port 5000 by default. +- Fixed sync config: use `"content": [{"prefix": "**", "destination": "/{{ registry.name }}"}]` instead of `"prefix": "{{ registry.name }}/**"`. The destination rewrites the local path, while prefix `**` matches all upstream repos. + +--- + +## Step 0.4: Add Zot to Tailscale Serve + +**Files to modify:** +- `ansible/roles/tailscale_serve/defaults/main.yml` + +**Changes:** +```yaml +# Add to tailscale_serve_services list +- name: svc:registry + https: + port: 443 + upstream: http://localhost:5000 +``` + +**Testing:** +```bash +# Deploy tailscale serve config +mise run provision-indri -- --tags tailscale-serve + +# Verify from gilbert (not indri - hairpinning doesn't work) +curl -s https://registry.tail8d86e.ts.net/v2/_catalog +# Expected: {"repositories":["docker.io/library/alpine"]} (from Step 0.3 test) + +# Test private image push from gilbert +podman pull alpine:latest +podman tag alpine:latest registry.tail8d86e.ts.net/blumeops/test:v1 +podman push registry.tail8d86e.ts.net/blumeops/test:v1 +curl -s https://registry.tail8d86e.ts.net/v2/_catalog +# Expected: {"repositories":["blumeops/test","docker.io/library/alpine"]} +``` + +**Implementation Details:** +- Changed upstream port from 5000 to 5050 (see Step 0.3 implementation details). +- After running `tailscale serve`, the service must be approved in Tailscale admin console at https://login.tailscale.com/admin/services before it becomes accessible. +- Podman needed on gilbert for testing - added to Brewfile. Requires `podman machine init && podman machine start` after install. + +--- + +## Step 0.5: Create Zot Metrics Role + +**New files:** +``` +ansible/roles/zot_metrics/ +├── defaults/main.yml +├── tasks/main.yml +├── templates/ +│ ├── zot-metrics.sh.j2 +│ └── zot-metrics.plist.j2 +└── handlers/main.yml +``` + +**Metrics script pattern (zot-metrics.sh.j2):** +```bash +#!/bin/bash +# Collect Zot registry metrics for Prometheus textfile collector +set -euo pipefail + +METRICS_FILE="/opt/homebrew/var/node_exporter/textfile/zot.prom" +TEMP_FILE="${METRICS_FILE}.tmp" + +# Check if zot is up +if curl -sf http://localhost:5000/v2/_catalog > /dev/null 2>&1; then + echo "zot_up 1" > "$TEMP_FILE" +else + echo "zot_up 0" > "$TEMP_FILE" +fi + +mv "$TEMP_FILE" "$METRICS_FILE" +``` + +**Note:** Start with just `zot_up` for now. Additional metrics (storage usage, cache stats) can be added later after reviewing zot's metrics endpoint. + +**Testing:** +```bash +# Deploy metrics role +mise run provision-indri -- --tags zot_metrics + +# Check metrics file exists and is updated +ssh indri 'cat /opt/homebrew/var/node_exporter/textfile/zot.prom' +# Expected: zot_up 1 + +# Verify metrics appear in Prometheus (after a scrape cycle) +curl -s "http://indri:9090/api/v1/query?query=zot_up" | jq '.data.result[0].value[1]' +# Expected: "1" +``` + +--- + +## Step 0.6: Add Zot Log Collection to Alloy + +**Files to modify:** +- `ansible/roles/alloy/defaults/main.yml` + +**Changes:** +Add to the `alloy_mcquack_logs` list: +```yaml + - path: /Users/erichblume/Library/Logs/mcquack.zot.out.log + service: zot + stream: stdout + - path: /Users/erichblume/Library/Logs/mcquack.zot.err.log + service: zot + stream: stderr +``` + +**Testing:** +```bash +# Deploy alloy config (handler restarts alloy automatically if config changed) +mise run provision-indri -- --tags alloy + +# Wait a minute, then check Loki for zot logs +# In Grafana Explore, query: {service="zot"} +``` + +--- + +## Step 0.7: Update indri-services-check Script + +**Files to modify:** +- `mise-tasks/indri-services-check` + +**Changes to add:** +```bash +# Add after existing service checks (around line 55) +check_service "zot" "ssh indri 'launchctl list | grep zot | grep -v \"^-\"'" +check_service "zot-metrics" "ssh indri 'launchctl list | grep zot-metrics | grep -v \"^-\"'" + +# Add to HTTP endpoints section (around line 65) +check_http "Zot Registry" "http://indri:5000/v2/_catalog" + +# Add metrics file check +check_service "Zot metrics" "ssh indri 'test -f /opt/homebrew/var/node_exporter/textfile/zot.prom'" +``` + +**Testing:** +```bash +# Run the health check +mise run indri-services-check + +# Expected output includes: +# zot... OK +# zot-metrics... OK +# Zot Registry... OK +# Zot metrics... OK +``` + +**Implementation Details:** +- Used Tailscale service URL (`https://registry.tail8d86e.ts.net/v2/_catalog`) instead of internal endpoint to verify full path works. + +--- + +## Step 0.8: Install and Configure Podman on Indri + +**New files:** +``` +ansible/roles/podman/ +├── tasks/main.yml +└── handlers/main.yml +``` + +**Tasks (tasks/main.yml):** +```yaml +- name: Install podman via homebrew + community.general.homebrew: + name: podman + state: present + +- name: Initialize podman machine (if not exists) + ansible.builtin.command: + cmd: podman machine init --cpus 4 --memory 8192 --disk-size 220 + register: podman_init + changed_when: podman_init.rc == 0 + failed_when: podman_init.rc not in [0, 125] # 125 = already exists + +- name: Start podman machine + ansible.builtin.command: + cmd: podman machine start + register: podman_start + changed_when: "'started successfully' in podman_start.stdout" + failed_when: podman_start.rc not in [0, 125] # 125 = already running +``` + +**Testing:** +```bash +# Deploy podman role +mise run provision-indri -- --tags podman + +# Verify podman is working +ssh indri 'podman info' +ssh indri 'podman run --rm hello-world' +``` + +**Implementation Details:** +- **KNOWN ISSUE**: `podman machine init` and `podman machine start` have reliability issues when run via Ansible/SSH. The machine sometimes gets stuck in "Starting" state due to a race condition (see https://github.com/containers/podman/issues/16945). Apple Hypervisor may also require GUI session context. +- **WORKAROUND**: If the machine fails to start via Ansible, manually run on indri: + ```bash + podman machine rm -f podman-machine-default + podman machine init --cpus 4 --memory 8192 --disk-size 220 + podman machine start + ``` +- LaunchAgent approach was attempted but didn't resolve the issue reliably. +- TODO: Investigate proper automation solution for reliable podman machine management. + +--- + +## Step 0.9: Install and Configure Minikube + +**New files:** +``` +ansible/roles/minikube/ +├── defaults/main.yml +├── tasks/main.yml +└── handlers/main.yml +``` + +**Defaults:** +```yaml +minikube_cpus: 4 +minikube_memory: 8192 +minikube_disk_size: "200g" +minikube_driver: podman +minikube_container_runtime: cri-o +``` + +**Note on storage:** The disk-size is for node-local storage only (container images, emptyDir, local PVs). Pods can also mount external storage: +- **hostPath** - indri filesystem (e.g., `~/transmission/` for kiwix ZIM files) +- **NFS** - sifaka volumes (Synology supports NFS natively, easiest for k8s) +- **SMB/CIFS** - requires csi-driver-smb; sifaka currently uses SMB for desktop mounts + +**Tasks:** +```yaml +- name: Install minikube via homebrew + community.general.homebrew: + name: minikube + state: present + +- name: Check if minikube cluster exists + ansible.builtin.command: + cmd: minikube status --format='{{.Host}}' + register: minikube_status + changed_when: false + failed_when: false + +- name: Start minikube cluster + 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 }} + when: minikube_status.rc != 0 or 'Running' not in minikube_status.stdout +``` + +**Testing:** +```bash +# Deploy minikube role +mise run provision-indri -- --tags minikube + +# Verify cluster is running +ssh indri 'minikube status' +# Expected: host: Running, kubelet: Running, apiserver: Running + +# Test kubectl access from indri +ssh indri 'kubectl get nodes' +# Expected: minikube Ready control-plane ... +``` + +**Implementation Details:** +- Changed `minikube_memory` from 8192 to 7800 because podman machine reports slightly less available memory (7908MB) due to VM overhead. Minikube rejects memory requests exceeding what podman reports. +- Deployed with Kubernetes v1.34.0 and CRI-O 1.24.6. + +--- + +## Step 0.10: Configure Kubeconfig on Gilbert + +**Goal**: Enable `kubectl` and `k9s` on gilbert to connect to the minikube cluster running on indri. + +**Considerations:** +- Minikube runs inside a podman VM on indri, so the API server isn't directly exposed on indri's network interface +- Admin users have full Tailscale access to indri via `autogroup:admin → * → *` +- Be careful not to overwrite existing work kubeconfigs + +**Possible approaches:** +1. SSH tunneling to forward the API server port +2. `minikube tunnel` running on indri (exposes LoadBalancer services) +3. Configure minikube with `--apiserver-names=indri` at cluster creation time +4. Use `kubectl` via SSH wrapper: `ssh indri kubectl ...` + +**Verification:** +```bash +# From gilbert, these should work: +kubectl get nodes +kubectl get namespaces +k9s # Should show the minikube cluster +``` + +The exact approach will be determined during implementation based on what works best with the podman driver. + +**Implementation Details:** + +Chose **Option 3: Recreate cluster with `--apiserver-names`** after researching alternatives: + +1. **SSH tunneling** - Requires keeping a tunnel running or complex on-demand setup +2. **SOCKS5 proxy with kubeconfig `proxy-url`** - Kubeconfig supports `proxy-url: socks5://localhost:1080` per-context, but still requires managing the proxy +3. **`--apiserver-names` + `--listen-address`** - Native minikube support, cleanest solution + +**Cluster Setup:** Recreated the minikube cluster with additional flags: +```bash +minikube delete +minikube start \ + --driver=podman \ + --container-runtime=cri-o \ + --cpus=4 --memory=7800 --disk-size=200g \ + --apiserver-names=indri \ + --listen-address=0.0.0.0 +``` + +- `--apiserver-names=indri` adds "indri" to the API server certificate SAN +- `--listen-address=0.0.0.0` tells podman to expose the API port on all interfaces +- API server port is dynamic (check with `kubectl config view --minify -o jsonpath="{.clusters[0].cluster.server}"` on indri) + +**Credential Management with 1Password:** + +Rather than copying private keys between machines, credentials are stored in 1Password and fetched on-demand using kubectl's exec credential plugin. This mirrors the 1Password SSH agent pattern for biometric-protected key access. + +1. **Store credentials in 1Password** (vault: `vg6xf6vvfmoh5hqjjhlhbeoaie`, item: `3jo4f2hnzvwfmamudfsbbbec7e`): + - `client-cert` - Contents of `~/.minikube/profiles/minikube/client.crt` (text field) + - `client-key` - Contents of `~/.minikube/profiles/minikube/client.key` (text field) + - `ca-cert` - Contents of `~/.minikube/ca.crt` (text field, not secret but stored for convenience) + +2. **Created credential helper script** at `bin/kubectl-credential-1password`: + ```bash + #!/bin/bash + # Fetches client cert/key from 1Password, outputs ExecCredential JSON + # Usage: kubectl-credential-1password + ``` + Symlinked to `~/.local/bin/kubectl-credential-1password` + +3. **Kubeconfig setup on gilbert:** + ```bash + # Store CA cert locally (not secret - public key for server verification) + mkdir -p ~/.kube/minikube-indri + op --vault item get --fields ca-cert | sed 's/^"//; s/"$//' > ~/.kube/minikube-indri/ca.crt + + # Configure cluster + kubectl config set-cluster minikube-indri \ + --server=https://indri: \ + --certificate-authority=/Users/eblume/.kube/minikube-indri/ca.crt + + # Configure credentials with exec plugin + kubectl config set-credentials minikube-indri \ + --exec-api-version=client.authentication.k8s.io/v1beta1 \ + --exec-command=kubectl-credential-1password \ + --exec-arg= \ + --exec-arg= \ + --exec-arg=client-cert \ + --exec-arg=client-key + + # Create context + kubectl config set-context minikube-indri \ + --cluster=minikube-indri \ + --user=minikube-indri + ``` + +4. **Usage:** + ```bash + kubectl --context=minikube-indri get nodes + # or + kubectl config use-context minikube-indri + kubectl get nodes + ``` + +**Security Notes:** +- Client private key never stored on disk - fetched from 1Password on each kubectl command +- CA cert stored on disk (not secret - it's a public key for server verification) +- 1Password biometric/password prompt required for credential access +- `op` command strips quotes from text fields with `sed 's/^"//; s/"$//'` + +**References:** +- [minikube start options](https://minikube.sigs.k8s.io/docs/commands/start/) +- [Using kubectl via SSH Tunnel](https://blog.scottlowe.org/2020/06/16/using-kubectl-via-an-ssh-tunnel/) +- [SOCKS5 Proxy Access to K8s API](https://kubernetes.ltd/docs/tasks/extend-kubernetes/socks5-proxy-access-api/) +- [kubectl-tokensshtunnel](https://github.com/jordiprats/kubectl-tokensshtunnel) +- [Securing kubectl config with 1Password](https://blog.mikael.green/post/1password-kubeconfig/) + +--- + +## Step 0.11: Add Minikube to indri-services-check + +**Files to modify:** +- `mise-tasks/indri-services-check` + +**Changes:** +```bash +# Add new section for Kubernetes +echo "" +echo "Kubernetes cluster:" +check_service "minikube" "ssh indri 'minikube status --format={{.Host}} | grep -q Running'" +check_service "k8s-apiserver" "ssh indri 'kubectl get --raw /healthz'" +``` + +**Testing:** +```bash +mise run indri-services-check + +# Expected output includes: +# Kubernetes cluster: +# minikube... OK +# k8s-apiserver... OK +``` + +**Implementation Notes:** +- Added a third check `k8s-apiserver (remote)` that verifies kubectl access from gilbert, not just via SSH to indri. This ensures the 1Password credential flow and remote API server access are working. +- The remote check uses both `--kubeconfig` and `--context` flags explicitly since the script runs in bash (not fish) and doesn't inherit the KUBECONFIG environment variable from fish config. + +--- + +## Step 0.12: Create Zettelkasten Documentation + +**New files:** +- `~/code/personal/zk/zot.md` +- `~/code/personal/zk/minikube.md` + +**Files to update:** +- `~/code/personal/zk/1767747119-YCPO.md` (main blumeops card) + +**Updates to main blumeops card:** + +1. Add to **Device Tags** table: + | `tag:registry` | indri | Container registry access | + +2. Add to **Services** table: + | **Registry** | https://registry.tail8d86e.ts.net | OCI container registry (Zot) | [[zot]] | + | **Kubernetes** | https://indri: | Minikube cluster | [[minikube]] | + +3. Add to **Port Map (Indri)** table: + | 5050 | Zot | HTTP | localhost | Container registry | + | | K8s API | HTTPS | 0.0.0.0 | Minikube API server | + +4. Add new section **Remote Kubernetes Access**: + ```markdown + ## Remote Kubernetes Access (from Gilbert) + + The minikube cluster on indri is accessible from gilbert via direct connection. + Cluster was created with `--apiserver-names=indri --listen-address=0.0.0.0`. + + **Fish abbreviations** (in `~/.config/fish/config.fish`): + - `ki` → `kubectl --context=minikube-indri` + - `k9i` → `k9s --context=minikube-indri` + - `k9` → `k9s` + + ```bash + # Quick access via abbreviations + ki get nodes + k9i + + # Or explicitly set context + kubectl config use-context minikube-indri + kubectl get nodes + ``` + ``` + +**Template for zot.md:** +```markdown +--- +id: zot +aliases: + - zot + - container-registry +tags: + - blumeops +--- + +# Zot Registry Management Log + +Zot is an OCI-native container registry running on Indri, providing: +1. Pull-through cache for Docker Hub, GHCR, Quay (avoids rate limits) +2. Private image storage for custom-built containers + +## Service Details + +- URL: https://registry.tail8d86e.ts.net +- Local port: 5050 +- Data directory: ~/zot +- Config: ~/.config/zot/config.json +- Managed via: mcquack LaunchAgent + +## Namespace Convention + +| Path | Source | +|------|--------| +| `registry.../docker.io/*` | Cached from Docker Hub | +| `registry.../ghcr.io/*` | Cached from GHCR | +| `registry.../quay.io/*` | Cached from Quay | +| `registry.../blumeops/*` | Private images (yours) | + +## Useful Commands + +\`\`\`bash +# List all images +curl -s http://localhost:5050/v2/_catalog | jq + +# Pull via cache (from indri or k8s) +podman pull localhost:5050/docker.io/library/nginx:latest + +# Build and push private image (from gilbert) +podman build -t registry.tail8d86e.ts.net/blumeops/myapp:v1 . +podman push registry.tail8d86e.ts.net/blumeops/myapp:v1 + +# Check service status +launchctl list | grep zot + +# View logs +tail -f ~/Library/Logs/mcquack.zot.err.log +\`\`\` + +## Log + +### [DATE] +- Initial setup for k8s migration Phase 0 +``` + +**Template for minikube.md:** +```markdown +--- +id: minikube +aliases: + - minikube + - kubernetes + - k8s +tags: + - blumeops +--- + +# Minikube Management Log + +Minikube provides a single-node Kubernetes cluster on Indri for running containerized services. + +## Cluster Details + +- Driver: podman (rootless) +- Container runtime: CRI-O +- Kubernetes version: v1.34.0 +- Resources: 4 CPUs, 7800MB RAM, 200GB disk +- API server: https://indri: (accessible from gilbert via Tailscale) + +## Remote Access from Gilbert + +Cluster was created with `--apiserver-names=indri --listen-address=0.0.0.0` to allow remote kubectl access. + +\`\`\`bash +# Switch context +kubectl config use-context minikube-indri + +# Verify +kubectl get nodes +kubectl get namespaces + +# Use k9s +k9s --context minikube-indri +\`\`\` + +## Useful Commands (on indri) + +\`\`\`bash +# Cluster status +minikube status + +# Start/stop cluster +minikube start +minikube stop + +# Access dashboard +minikube dashboard + +# SSH into node +minikube ssh + +# View logs +minikube logs +\`\`\` + +## Podman Machine (prerequisite) + +Minikube uses podman as the container runtime. The podman machine must be running: + +\`\`\`bash +# Check podman machine +podman machine list + +# Start if needed +podman machine start +\`\`\` + +## Log + +### [DATE] +- Initial cluster setup for k8s migration Phase 0 +- Configured for remote access with --apiserver-names=indri +``` + +**Implementation Notes:** +- Created zot.md and minikube.md in ~/code/personal/zk/ +- Updated 1767747119-YCPO.md (main blumeops card) with all specified changes +- Added 1Password credential plugin reference to minikube docs +- K8s API port is 39535 (dynamically assigned by minikube, may change on cluster recreation) + +--- + +## Step 0.13: Update Main Playbook + +**Files to modify:** +- `ansible/playbooks/indri.yml` + +**Changes:** +```yaml +# Add new roles to the roles list +- role: podman + tags: podman +- role: zot + tags: zot +- role: zot_metrics + tags: zot_metrics +- role: minikube + tags: minikube +``` + +**Implementation Notes:** +- Roles were added incrementally during Steps 0.3, 0.5, 0.8, and 0.9 +- All four roles (zot, zot_metrics, podman, minikube) confirmed present in indri.yml + +--- + +## Step 0.14: Expose K8s API as Tailscale Service (Added Post-Completion) + +> **Note**: This step was added after Phase 0 was otherwise complete, to provide a stable, named endpoint for the Kubernetes API server. + +**Goal**: Expose the minikube API server as `k8s.tail8d86e.ts.net` instead of using `indri:`. + +**Current state:** +- Minikube API server on port 39535 (dynamic, could change on cluster recreation) +- Accessed via `https://indri:39535` +- Certificate SANs include "indri" + +**Target state:** +- Stable Tailscale service at `k8s.tail8d86e.ts.net:443` +- Fixed API server port (6443, the k8s standard) +- Certificate SANs include both hostnames for compatibility + +--- + +### Step 0.14.1: Update Pulumi ACLs + +**Files to modify:** +- `pulumi/policy.hujson` +- `pulumi/__main__.py` + +**Changes to policy.hujson:** + +1. Add tag to `tagOwners`: +```hujson +"tag:k8s-api": ["autogroup:admin", "tag:blumeops"], +``` + +2. Update Erich's test case accept list to include k8s-api: +```hujson +"accept": ["tag:grafana:443", "tag:kiwix:443", "tag:feed:443", "tag:loki:3100", "tag:pg:5432", "tag:homelab:22", "tag:registry:443", "tag:k8s-api:443"], +``` + +3. Update Allison's deny list: +```hujson +"deny": ["tag:grafana:443", "tag:loki:3100", "tag:nas:445", "tag:registry:443", "tag:k8s-api:443"], +``` + +**Changes to __main__.py:** +- Add `"tag:k8s-api"` to indri's DeviceTags + +**Testing:** +```bash +mise run tailnet-preview # Review changes +mise run tailnet-up # Apply changes +``` + +--- + +### Step 0.14.2: Create Tailscale Service in Admin Console (MANUAL) + +> **CRITICAL**: Do this BEFORE running ansible that calls `tailscale serve` + +1. Go to https://login.tailscale.com/admin/services +2. Create service `k8s` with: + - Port: 443 (TCP) + - Host: indri + +--- + +### Step 0.14.3: Recreate Minikube Cluster + +The cluster needs to be recreated to: +1. Add `k8s.tail8d86e.ts.net` to the API server certificate SANs +2. Fix the API server port to 6443 (standard k8s port) + +**On indri:** +```bash +# Stop and delete existing cluster +minikube stop +minikube delete + +# Recreate with new settings +minikube start \ + --driver=podman \ + --container-runtime=cri-o \ + --cpus=4 --memory=7800 --disk-size=200g \ + --apiserver-names=k8s.tail8d86e.ts.net,indri \ + --apiserver-port=6443 \ + --listen-address=0.0.0.0 + +# Verify certificate SANs include both names +kubectl config view --minify -o jsonpath="{.clusters[0].cluster.server}" +# Expected: https://127.0.0.1:6443 or similar + +# Verify cluster is running +minikube status +kubectl get nodes +``` + +**Update ansible role defaults** (`ansible/roles/minikube/defaults/main.yml`): +```yaml +minikube_apiserver_names: + - k8s.tail8d86e.ts.net + - indri +minikube_apiserver_port: 6443 +``` + +--- + +### Step 0.14.4: Add K8s Service to Tailscale Serve + +**Files to modify:** +- `ansible/roles/tailscale_serve/defaults/main.yml` + +**Add to services list:** +```yaml +- name: svc:k8s + tcp: + port: 443 + upstream: tcp://localhost:6443 +``` + +**Note:** Using TCP passthrough (not HTTPS termination) because k8s uses mTLS authentication. + +**Deploy:** +```bash +mise run provision-indri -- --tags tailscale-serve +``` + +--- + +### Step 0.14.5: Update 1Password Credentials + +After cluster recreation, the client certificates have changed. + +**On indri, get the new credentials:** +```bash +# Display new certificates (copy to 1Password) +cat ~/.minikube/profiles/minikube/client.crt +cat ~/.minikube/profiles/minikube/client.key +cat ~/.minikube/ca.crt +``` + +**In 1Password** (vault: `vg6xf6vvfmoh5hqjjhlhbeoaie`, item: `3jo4f2hnzvwfmamudfsbbbec7e`): +- Update `client-cert` field with new certificate +- Update `client-key` field with new key +- Update `ca-cert` field with new CA certificate + +--- + +### Step 0.14.6: Update Kubeconfig on Gilbert + +**Update CA certificate:** +```bash +# Fetch new CA cert from 1Password +op --vault vg6xf6vvfmoh5hqjjhlhbeoaie item get 3jo4f2hnzvwfmamudfsbbbec7e --fields ca-cert | sed 's/^"//; s/"$//' > ~/.kube/minikube-indri/ca.crt +``` + +**Update kubeconfig** (`~/.kube/minikube-indri/config.yml`): +```yaml +clusters: +- cluster: + certificate-authority: /Users/eblume/.kube/minikube-indri/ca.crt + server: https://k8s.tail8d86e.ts.net # Changed from https://indri:39535 + name: minikube-indri +``` + +**Verification:** +```bash +# Test connection via new hostname +kubectl --context=minikube-indri get nodes + +# Test via abbreviation +ki get nodes +``` + +--- + +### Step 0.14.7: Update Documentation + +**Files to update:** +- `~/code/personal/zk/minikube.md` - Update API server URL and port info +- `~/code/personal/zk/1767747119-YCPO.md` - Update Services table and Port Map + +**Changes to blumeops card:** + +1. Update Services table: + | **Kubernetes** | https://k8s.tail8d86e.ts.net | Minikube cluster | [[minikube]] | + +2. Update Port Map: + | 6443 | K8s API | HTTPS/TCP | 0.0.0.0 | Minikube API server (via Tailscale) | + +3. Add `tag:k8s-api` to Device Tags table + +--- + +### Step 0.14.8: Update indri-services-check + +**Files to modify:** +- `mise-tasks/indri-services-check` + +**Changes:** +```bash +# Update remote k8s check to use new URL +check_service "k8s-apiserver (remote)" "kubectl --kubeconfig=$HOME/.kube/minikube-indri/config.yml --context=minikube-indri get --raw /healthz" +# (No change needed - uses kubeconfig which now points to k8s.tail8d86e.ts.net) +``` + +--- + +### Step 0.14 Verification + +```bash +# 1. Service health check +mise run indri-services-check +# All services should be OK + +# 2. Test k8s access via Tailscale hostname +curl -k https://k8s.tail8d86e.ts.net/healthz +# Expected: ok (or certificate error if mTLS required - that's fine) + +# 3. kubectl via Tailscale +ki get nodes +ki get namespaces + +# 4. k9s via Tailscale +k9i +``` + +--- + +## Phase 0 Verification Checklist + +Run after completing all steps: + +```bash +# 1. Full service health check +mise run indri-services-check +# All services should show OK, including new ones + +# 2. Registry functionality - pull-through cache +ssh indri 'podman pull localhost:5000/docker.io/library/alpine:latest' +curl -s https://registry.tail8d86e.ts.net/v2/_catalog +# Expected: {"repositories":["docker.io/library/alpine"]} + +# 3. Registry functionality - private image push (from gilbert) +podman pull alpine:latest +podman tag alpine:latest registry.tail8d86e.ts.net/blumeops/test:v1 +podman push registry.tail8d86e.ts.net/blumeops/test:v1 +curl -s https://registry.tail8d86e.ts.net/v2/_catalog +# Expected: {"repositories":["blumeops/test","docker.io/library/alpine"]} + +# 4. Kubernetes cluster +ssh indri 'minikube status' +ssh indri 'kubectl get nodes' +kubectl get nodes # from gilbert + +# 5. Metrics in Prometheus +curl -s "http://indri:9090/api/v1/query?query=zot_up" +# Expected: value = 1 + +# 6. Logs in Loki +# In Grafana Explore: {service="zot"} +# Should see zot log entries + +# 7. k9s from gilbert +k9s +# Should connect and show minikube cluster +``` + +--- + +## Phase 0 Rollback + +If something goes wrong: + +```bash +# Stop and remove minikube +ssh indri 'minikube stop && minikube delete' + +# Stop and remove zot +ssh indri 'launchctl unload ~/Library/LaunchAgents/mcquack.eblume.zot.plist' +ssh indri 'rm ~/Library/LaunchAgents/mcquack.eblume.zot.plist' + +# Remove podman machine +ssh indri 'podman machine stop && podman machine rm' + +# Remove from tailscale serve +ssh indri 'tailscale serve --service svc:registry reset' + +# Remove tags from Pulumi (revert policy.hujson changes) +mise run tailnet-up + +# Revert ansible playbook changes +git checkout ansible/playbooks/indri.yml +git checkout ansible/roles/tailscale_serve/defaults/main.yml +git checkout ansible/roles/alloy/templates/config.alloy.j2 + +# Remove new roles +rm -rf ansible/roles/{zot,zot_metrics,podman,minikube} + +# Remove zk cards +rm ~/code/personal/zk/{zot,minikube}.md +``` + +--- + +## Phase 0 Follow-up: Grafana Dashboards + +After Phase 0 is running and stable, create monitoring dashboards: + +**Zot Dashboard** (`ansible/roles/grafana/files/dashboards/zot.json`): +1. Check what metrics zot exposes: `ssh indri 'curl -s http://localhost:5000/metrics'` +2. Review community dashboards for inspiration (copy permitted if license allows) +3. Create dashboard with available metrics (at minimum: `zot_up`) + +**Minikube Dashboard** (`ansible/roles/grafana/files/dashboards/minikube.json`): +1. Deploy kube-state-metrics if needed for additional cluster metrics +2. Review what Prometheus can scrape from the cluster +3. Review community dashboards for inspiration (copy permitted if license allows) +4. Create dashboard with relevant panels (node usage, pod counts, etc.) + +--- + +## New Files Summary + +| File | Purpose | +|------|---------| +| `ansible/roles/zot/` | Zot registry deployment | +| `ansible/roles/zot_metrics/` | Metrics collection for Zot | +| `ansible/roles/podman/` | Podman installation and setup | +| `ansible/roles/minikube/` | Minikube cluster setup | +| `~/code/personal/zk/zot.md` | Zot management documentation | +| `~/code/personal/zk/minikube.md` | Minikube management documentation | + +## Modified Files Summary + +| File | Changes | +|------|---------| +| `pulumi/policy.hujson` | Add tag:registry | +| `ansible/playbooks/indri.yml` | Add new roles | +| `ansible/roles/tailscale_serve/defaults/main.yml` | Add svc:registry | +| `ansible/roles/alloy/templates/config.alloy.j2` | Add zot log collection | +| `mise-tasks/indri-services-check` | Add zot and k8s checks | diff --git a/plans/completed/k8s-migration/P1_k8s_infrastructure.complete.md b/plans/completed/k8s-migration/P1_k8s_infrastructure.complete.md new file mode 100644 index 0000000..9e02286 --- /dev/null +++ b/plans/completed/k8s-migration/P1_k8s_infrastructure.complete.md @@ -0,0 +1,657 @@ +# Phase 1: Kubernetes Infrastructure + +**Goal**: Tailscale operator, ArgoCD, CloudNativePG operator, PostgreSQL cluster + +**Status**: In Progress + +**Prerequisites**: [Phase 0](P0_foundation.complete.md) complete + +--- + +## Overview + +Phase 1 establishes the k8s control plane infrastructure: +1. **Tailscale operator** - Exposes services on the tailnet +2. **ArgoCD** - GitOps continuous delivery +3. **CloudNativePG** - PostgreSQL operator +4. **PostgreSQL cluster** - Database for future app migrations + +The deployment follows a bootstrap pattern: +- First two components deployed via `kubectl apply -k` (no GitOps yet) +- ArgoCD then takes over management of all components including itself +- All subsequent deployments use ArgoCD + +--- + +## Kubernetes Tags Overview + +| Tag | Purpose | Applied To | +|-----|---------|------------| +| `tag:k8s-api` | Controls access to the K8s API server | indri (Phase 0.14) | +| `tag:k8s-operator` | Identifies the Tailscale K8s Operator | OAuth client for operator | +| `tag:k8s` | Default tag for operator-managed resources | Proxies, services, ingresses created by operator | + +**Ownership chain**: `tag:k8s-operator` must own `tag:k8s` so the operator can assign that tag to devices it creates. + +--- + +## PostgreSQL Migration Strategy + +The k8s PostgreSQL cluster will eventually replace the brew PostgreSQL on indri. + +| Phase | `pg.tail8d86e.ts.net` points to | Miniflux connects to | +|-------|--------------------------------|---------------------| +| Current | brew PostgreSQL (indri) | `pg.tail8d86e.ts.net` | +| Phase 1 | brew PostgreSQL (indri) | `pg.tail8d86e.ts.net` (no change) | +| Phase 4 | brew PostgreSQL (indri) | k8s PG (internal, after miniflux migrates to k8s) | +| Post-Phase 4 | k8s PostgreSQL | k8s PG (internal) | +| Cleanup | k8s PostgreSQL | k8s PG (internal) | + +This allows zero-downtime migration - the Tailscale service switches after apps are migrated. + +--- + +## Steps + +### 1. Update Pulumi ACLs for k8s workloads ✓ + +**Status**: Complete + +Added to `pulumi/policy.hujson`: +- `tag:k8s-operator` - for the operator OAuth client +- `tag:k8s` - for operator-managed resources (owned by `tag:k8s-operator`) +- Grant for `tag:k8s` → `tag:registry` access + +--- + +### 2. Create Tailscale OAuth client ✓ + +**Status**: Complete + +OAuth client stored in 1Password (vault: `vg6xf6vvfmoh5hqjjhlhbeoaie`, item: `2it22lavwgbxdskoaxanej354q`) + +**Configuration used:** +- Tags: `tag:k8s-operator` +- Devices write scope tag: `tag:k8s` +- Scopes: Devices Core (R/W), Auth Keys (R/W), Services (Write) + +--- + +### 3. Deploy Tailscale Kubernetes Operator (Bootstrap) + +Deploy via `kubectl apply -k` - will be migrated to ArgoCD management in Step 5. + +**Setup manifests directory:** +```bash +mkdir -p argocd/manifests/tailscale-operator +cd argocd/manifests/tailscale-operator + +# Download static manifest from Tailscale repo +curl -sL https://raw.githubusercontent.com/tailscale/tailscale/main/cmd/k8s-operator/deploy/manifests/operator.yaml -o operator.yaml + +# Download CRDs +curl -sL https://raw.githubusercontent.com/tailscale/tailscale/main/cmd/k8s-operator/deploy/crds/tailscale.com_connectors.yaml -o crds/connectors.yaml +curl -sL https://raw.githubusercontent.com/tailscale/tailscale/main/cmd/k8s-operator/deploy/crds/tailscale.com_proxyclasses.yaml -o crds/proxyclasses.yaml +# ... (other CRDs as needed) +``` + +**Create kustomization.yaml:** +```yaml +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: tailscale-system +resources: + - operator.yaml +secretGenerator: + - name: operator-oauth + namespace: tailscale-system + literals: + - client_id=PLACEHOLDER + - client_secret=PLACEHOLDER +generatorOptions: + disableNameSuffixHash: true +``` + +**Deploy:** +```bash +# Get credentials from 1Password and create secret manually (kustomize secretGenerator is for reference) +CLIENT_ID=$(op --vault vg6xf6vvfmoh5hqjjhlhbeoaie item get 2it22lavwgbxdskoaxanej354q --fields client-id --reveal) +CLIENT_SECRET=$(op --vault vg6xf6vvfmoh5hqjjhlhbeoaie item get 2it22lavwgbxdskoaxanej354q --fields client-secret --reveal) + +kubectl create namespace tailscale-system +kubectl create secret generic operator-oauth \ + --namespace tailscale-system \ + --from-literal=client_id=$CLIENT_ID \ + --from-literal=client_secret=$CLIENT_SECRET + +# Apply operator manifests +kubectl apply -k argocd/manifests/tailscale-operator/ +``` + +**Verification:** +```bash +kubectl get pods -n tailscale-system +# Expected: operator pod Running + +kubectl logs -n tailscale-system -l app.kubernetes.io/name=tailscale-operator +``` + +--- + +### 4. Deploy ArgoCD + +Deploy ArgoCD and expose via Tailscale as `argocd.tail8d86e.ts.net`. + +**Prerequisites:** +- Add `tag:argocd` to Pulumi ACLs +- Create Tailscale service `argocd` in admin console + +**Setup manifests:** +```bash +mkdir -p argocd/manifests/argocd + +# Download ArgoCD install manifest +curl -sL https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml -o argocd/manifests/argocd/install.yaml +``` + +**Create kustomization.yaml:** +```yaml +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: argocd +resources: + - install.yaml + - service-tailscale.yaml # LoadBalancer for Tailscale exposure +``` + +**Create service-tailscale.yaml:** +```yaml +apiVersion: v1 +kind: Service +metadata: + name: argocd-server-tailscale + namespace: argocd + annotations: + tailscale.com/hostname: "argocd" +spec: + type: LoadBalancer + loadBalancerClass: tailscale + selector: + app.kubernetes.io/name: argocd-server + ports: + - name: https + port: 443 + targetPort: 8080 +``` + +**Deploy:** +```bash +kubectl create namespace argocd +kubectl apply -k argocd/manifests/argocd/ +``` + +**Get initial admin password:** +```bash +kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath="{.data.password}" | base64 -d +``` + +**Verification:** +- https://argocd.tail8d86e.ts.net loads +- Can login with admin / + +**Post-setup:** +1. Change admin password, store in 1Password +2. Configure git repo connection to `github.com/eblume/blumeops` (public, no auth needed) + - Note: Using GitHub mirror since ArgoCD can't easily reach forge without additional networking + +--- + +### 5. Migrate Tailscale Operator to ArgoCD + +Create ArgoCD Application to manage the Tailscale operator. + +**Create argocd/apps/tailscale-operator.yaml:** +```yaml +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: tailscale-operator + namespace: argocd +spec: + project: default + source: + repoURL: https://github.com/eblume/blumeops.git + targetRevision: main + path: argocd/manifests/tailscale-operator + destination: + server: https://kubernetes.default.svc + namespace: tailscale-system + syncPolicy: + automated: + prune: true + selfHeal: true +``` + +**Apply:** +```bash +kubectl apply -f argocd/apps/tailscale-operator.yaml +``` + +**Note on secrets:** The OAuth secret was created manually in Step 3. For GitOps, consider: +- Sealed Secrets +- External Secrets Operator +- SOPS + +For now, the secret remains manually managed outside of ArgoCD. + +--- + +### 6. Deploy CloudNativePG via ArgoCD + +**Setup manifests:** +```bash +mkdir -p argocd/manifests/cloudnative-pg + +# Download CNPG operator manifest +curl -sL https://raw.githubusercontent.com/cloudnative-pg/cloudnative-pg/release-1.24/releases/cnpg-1.24.0.yaml -o argocd/manifests/cloudnative-pg/operator.yaml +``` + +**Create kustomization.yaml:** +```yaml +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - operator.yaml +``` + +**Create ArgoCD Application (argocd/apps/cloudnative-pg.yaml):** +```yaml +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: cloudnative-pg + namespace: argocd +spec: + project: default + source: + repoURL: https://github.com/eblume/blumeops.git + targetRevision: main + path: argocd/manifests/cloudnative-pg + destination: + server: https://kubernetes.default.svc + namespace: cnpg-system + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true +``` + +**Apply:** +```bash +kubectl apply -f argocd/apps/cloudnative-pg.yaml +``` + +**Verification:** +```bash +kubectl get pods -n cnpg-system +# Expected: cnpg-controller-manager Running +``` + +--- + +### 7. Create PostgreSQL Cluster via ArgoCD + +Create the database cluster. **Not exposed via Tailscale yet** - internal only until apps migrate. + +**Create argocd/manifests/databases/blumeops-pg.yaml:** +```yaml +apiVersion: postgresql.cnpg.io/v1 +kind: Cluster +metadata: + name: blumeops-pg + namespace: databases +spec: + instances: 1 + storage: + size: 10Gi + storageClass: standard + monitoring: + enablePodMonitor: true + bootstrap: + initdb: + database: miniflux + owner: miniflux +``` + +**Create kustomization.yaml:** +```yaml +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: databases +resources: + - blumeops-pg.yaml +``` + +**Create ArgoCD Application (argocd/apps/blumeops-pg.yaml):** +```yaml +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: blumeops-pg + namespace: argocd +spec: + project: default + source: + repoURL: https://github.com/eblume/blumeops.git + targetRevision: main + path: argocd/manifests/databases + destination: + server: https://kubernetes.default.svc + namespace: databases + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true +``` + +**Apply:** +```bash +kubectl apply -f argocd/apps/blumeops-pg.yaml +``` + +**Verification:** +```bash +kubectl get cluster -n databases +# Expected: blumeops-pg with STATUS "Cluster in healthy state" + +kubectl get pods -n databases +# Expected: blumeops-pg-1 Running + +# Get connection secret +kubectl -n databases get secret blumeops-pg-app -o jsonpath='{.data.uri}' | base64 -d +``` + +--- + +### 8. Create App-of-Apps Root Application + +Once all components are deployed, create a root application to manage all apps. + +**Create argocd/apps/root.yaml:** +```yaml +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: root + namespace: argocd +spec: + project: default + source: + repoURL: https://github.com/eblume/blumeops.git + targetRevision: main + path: argocd/apps + destination: + server: https://kubernetes.default.svc + namespace: argocd + syncPolicy: + automated: + prune: true + selfHeal: true +``` + +**Apply:** +```bash +kubectl apply -f argocd/apps/root.yaml +``` + +Now ArgoCD manages itself and all other applications via the app-of-apps pattern. + +--- + +## New Files Summary + +``` +argocd/ + apps/ + root.yaml # App-of-apps root + tailscale-operator.yaml # Tailscale operator app + cloudnative-pg.yaml # CNPG operator app + blumeops-pg.yaml # PostgreSQL cluster app + manifests/ + tailscale-operator/ + kustomization.yaml + operator.yaml + argocd/ + kustomization.yaml + install.yaml + service-tailscale.yaml + cloudnative-pg/ + kustomization.yaml + operator.yaml + databases/ + kustomization.yaml + blumeops-pg.yaml +``` + +--- + +## Pulumi ACL Updates Required + +Add to `pulumi/policy.hujson`: +```hujson +"tag:argocd": ["autogroup:admin", "tag:blumeops"], +``` + +Add to Erich's test accept list: +```hujson +"accept": [..., "tag:argocd:443"], +``` + +Add to Allison's deny list: +```hujson +"deny": [..., "tag:argocd:443"], +``` + +--- + +## Verification Checklist + +```bash +# 1. Tailscale operator running +kubectl get pods -n tailscale-system + +# 2. ArgoCD accessible +curl -k https://argocd.tail8d86e.ts.net/healthz + +# 3. CloudNativePG operator running +kubectl get pods -n cnpg-system + +# 4. PostgreSQL cluster healthy +kubectl get cluster -n databases + +# 5. All ArgoCD apps synced +kubectl get applications -n argocd +# All should show STATUS: Synced, HEALTH: Healthy +``` + +--- + +## Rollback + +```bash +# Remove ArgoCD apps (will cascade delete managed resources) +kubectl delete application -n argocd root +kubectl delete application -n argocd blumeops-pg +kubectl delete application -n argocd cloudnative-pg +kubectl delete application -n argocd tailscale-operator + +# Remove ArgoCD +kubectl delete -k argocd/manifests/argocd/ +kubectl delete namespace argocd + +# Remove namespaces +kubectl delete namespace databases +kubectl delete namespace cnpg-system +kubectl delete namespace tailscale-system + +# Revert ACL changes +git checkout pulumi/policy.hujson +mise run tailnet-up +``` + +--- + +## Implementation Notes (Deviations from Plan) + +*Added during implementation for retrospective review* + +### Git Source: Forge Instead of GitHub + +**Plan**: Use GitHub mirror (`github.com/eblume/blumeops`) +**Actual**: Use internal Forgejo (`ssh://forgejo@indri.tail8d86e.ts.net:2200/eblume/blumeops.git`) + +**Why**: User preference to use internal infrastructure, accepting circular dependency for later. + +**Required changes**: +- Deploy key added to forge for ArgoCD SSH access +- Repository secret `repo-forge` with SSH private key from 1Password +- Discovered: `op read` requires `?ssh-format=openssh` query parameter for ArgoCD-compatible key format +- Egress proxy service to reach forge from cluster (targets `indri.tail8d86e.ts.net` not `forge.tail8d86e.ts.net` due to Tailscale Serve limitation) +- DNSConfig CRD for cluster-to-tailnet MagicDNS resolution +- ACL grant: `tag:k8s` → `tag:homelab` on ports 3001 (HTTP) and 2200 (SSH) + +### ArgoCD Exposure: Ingress Instead of LoadBalancer + +**Plan**: LoadBalancer service with `tailscale.com/hostname` annotation +**Actual**: Tailscale Ingress with Let's Encrypt TLS termination + +**Why**: Ingress provides automatic TLS certificates and is the recommended approach. + +**File**: `argocd/manifests/argocd/service-tailscale.yaml` uses `kind: Ingress` with `ingressClassName: tailscale` + +### Namespace: `tailscale` Instead of `tailscale-system` + +**Plan**: `tailscale-system` namespace +**Actual**: `tailscale` namespace + +**Why**: Matches upstream Tailscale operator defaults. + +### Sync Policy: Manual Instead of Automated + +**Plan**: `syncPolicy.automated` with prune and selfHeal +**Actual**: Manual sync policy for workload apps; auto-sync only for app-of-apps + +**Why**: User preference for explicit control over deployments during initial migration phase. + +**Pattern**: +- `apps.yaml` (app-of-apps): auto-sync to pick up new Application manifests +- All workload apps: manual sync requires `argocd app sync ` + +### CloudNativePG: Helm Chart Instead of Raw Manifest + +**Plan**: Download raw CNPG manifest +**Actual**: Multi-source Application using official Helm chart from `https://cloudnative-pg.github.io/charts` + +**Why**: Helm chart is the officially supported distribution method. + +**Additional fix**: Required `ServerSideApply=true` sync option due to large CRD exceeding annotation size limit. + +### App-of-Apps: Named `apps` Instead of `root` + +**Plan**: `argocd/apps/root.yaml` +**Actual**: `argocd/apps/apps.yaml` with Application named `apps` + +**Why**: Clearer naming; `apps` manages apps, `argocd` manages itself. + +### ArgoCD Self-Management Added + +**Plan**: Not explicitly planned +**Actual**: `argocd/apps/argocd.yaml` Application for ArgoCD self-management + +**Why**: Standard GitOps pattern - ArgoCD manages its own deployment after bootstrap. + +### CRI-O Registry Mirror for Zot + +**Plan**: Not in original plan +**Actual**: Configured CRI-O to use zot as pull-through cache for docker.io, ghcr.io, quay.io + +**Why**: Reduces external bandwidth, speeds up pulls, avoids rate limits. + +**Implementation**: Ansible `minikube` role applies `/etc/containers/registries.conf.d/zot-mirror.conf` inside minikube VM using stable hostname `host.containers.internal:5050`. + +### ProxyClass for CRI-O Image Compatibility + +**Plan**: Not mentioned +**Actual**: Required `ProxyClass` with fully-qualified image paths (`docker.io/tailscale/...`) + +**Why**: CRI-O requires fully-qualified image references; default Tailscale operator uses short names. + +### Actual File Structure + +``` +argocd/ + apps/ + apps.yaml # App-of-apps (auto-sync) + argocd.yaml # ArgoCD self-management (manual sync) + tailscale-operator.yaml # Tailscale operator (manual sync) + cloudnative-pg.yaml # CNPG operator via Helm (manual sync) + manifests/ + tailscale-operator/ + kustomization.yaml + operator.yaml + proxyclass.yaml # CRI-O compatibility + dnsconfig.yaml # Cluster-to-tailnet DNS + egress-forge.yaml # Egress proxy for forge + secret.yaml.tpl # OAuth secret template (manual) + README.md + argocd/ + kustomization.yaml # Uses remote base from upstream + service-tailscale.yaml # Ingress (not LoadBalancer) + argocd-cmd-params-cm.yaml # Disable HTTPS redirect + repo-forge-secret.yaml.tpl # SSH key template (manual) + README.md + cloudnative-pg/ + values.yaml # Helm values (currently minimal) + README.md +``` + +### Bootstrap Commands (Actual) + +```bash +# 1. Create namespaces +kubectl create namespace tailscale +kubectl create namespace argocd + +# 2. Apply secrets (manual, uses 1Password) +op inject -i argocd/manifests/tailscale-operator/secret.yaml.tpl | kubectl apply -f - + +PRIV_KEY=$(op read "op://vg6xf6vvfmoh5hqjjhlhbeoaie/csjncynh6htjvnh2l2da65y32q/private key?ssh-format=openssh")$'\n' && \ +kubectl create secret generic repo-forge -n argocd \ + --from-literal=type=git \ + --from-literal=url='ssh://forgejo@indri.tail8d86e.ts.net:2200/eblume/blumeops.git' \ + --from-literal=insecure=true \ + --from-literal=sshPrivateKey="$PRIV_KEY" && \ +kubectl label secret repo-forge -n argocd argocd.argoproj.io/secret-type=repository + +# 3. Bootstrap tailscale-operator +kubectl apply -k argocd/manifests/tailscale-operator/ + +# 4. Bootstrap ArgoCD +kubectl apply -k argocd/manifests/argocd/ + +# 5. Login and change password +argocd login argocd.tail8d86e.ts.net --username admin --grpc-web +argocd account update-password + +# 6. Apply ArgoCD Applications +kubectl apply -f argocd/apps/argocd.yaml +kubectl apply -f argocd/apps/apps.yaml + +# 7. Sync workloads +argocd app sync tailscale-operator +argocd app sync cloudnative-pg +``` diff --git a/plans/completed/k8s-migration/P2_grafana.complete.md b/plans/completed/k8s-migration/P2_grafana.complete.md new file mode 100644 index 0000000..7bb37f3 --- /dev/null +++ b/plans/completed/k8s-migration/P2_grafana.complete.md @@ -0,0 +1,396 @@ +# Phase 2: Grafana Migration (Pilot) + +**Goal**: Migrate Grafana as lowest-risk pilot service + +**Status**: Complete (2026-01-19) + +**Prerequisites**: [Phase 1](P1_k8s_infrastructure.complete.md) complete + +--- + +## Overview + +This phase migrates Grafana from Homebrew/Ansible on indri to Kubernetes, establishing the pattern for future service migrations. Additionally, we establish the pattern of mirroring Helm chart repositories to forge for resilience and GitOps consistency. + +--- + +## Key Decisions + +### Helm Chart Mirroring + +**Problem**: P1 uses external Helm repos which creates external dependencies. + +**Solution**: Mirror Helm chart Git repositories to forge, reference charts from git path. + +ArgoCD auto-detects Helm charts when a directory contains `Chart.yaml`. No build step needed. + +| Chart | Upstream Git Repo | Forge Mirror | Chart Path | +|-------|-------------------|--------------|------------| +| cloudnative-pg | `github.com/cloudnative-pg/charts` | `forge/eblume/cloudnative-pg-charts` | `charts/cloudnative-pg/` | +| grafana | `github.com/grafana/helm-charts` | `forge/eblume/grafana-helm-charts` | `charts/grafana/` | + +### Database Storage + +Use SQLite with 1Gi PVC (not k8s PostgreSQL). Grafana stores minimal persistent data and dashboards are git-provisioned. + +### Datasource URLs + +From k8s pods, use `host.containers.internal` to reach indri services: +- Prometheus: `http://host.containers.internal:9090` +- Loki: `http://host.containers.internal:3100` (requires ansible change to bind 0.0.0.0) + +### Ingress + +Tailscale Ingress with Let's Encrypt TLS (following ArgoCD pattern), with `crio-compat` proxy class. + +### Secrets Management + +Admin password stored in 1Password, injected manually via `op inject`. Future: migrate to External Secrets Operator or similar. + +--- + +## Prerequisites + +### 0.1 Mirror Helm Chart Repos to Forge + +**User action**: Create mirrors in forge: + +1. **CloudNativePG charts** (fix existing P1 app): + - Mirror: `https://github.com/cloudnative-pg/charts` + - To: `forge.tail8d86e.ts.net/eblume/cloudnative-pg-charts` + +2. **Grafana helm-charts** (new): + - Mirror: `https://github.com/grafana/helm-charts` + - To: `forge.tail8d86e.ts.net/eblume/grafana-helm-charts` + +### 0.2 Update Loki to Bind 0.0.0.0 + +**File**: `ansible/roles/loki/templates/loki-config.yaml.j2` + +Add under `server:`: +```yaml +http_listen_address: 0.0.0.0 +``` + +Deploy: `mise run provision-indri -- --tags loki` + +--- + +## Steps + +### 1. Fix CloudNativePG to Use Forge Mirror + +Update `argocd/apps/cloudnative-pg.yaml` to use forge-mirrored chart: + +```yaml +sources: + - repoURL: ssh://forgejo@indri.tail8d86e.ts.net:2200/eblume/cloudnative-pg-charts.git + targetRevision: cloudnative-pg-0.23.0 # git tag + path: charts/cloudnative-pg + helm: + releaseName: cloudnative-pg + valueFiles: + - $values/argocd/manifests/cloudnative-pg/values.yaml + - repoURL: ssh://forgejo@indri.tail8d86e.ts.net:2200/eblume/blumeops.git + targetRevision: main + ref: values +``` + +--- + +### 2. Create Grafana Helm Values + +**File**: `argocd/manifests/grafana/values.yaml` + +```yaml +admin: + existingSecret: grafana-admin + userKey: admin-user + passwordKey: admin-password + +persistence: + enabled: true + type: pvc + size: 1Gi + +grafana.ini: + server: + root_url: https://grafana.tail8d86e.ts.net + analytics: + check_for_updates: false + reporting_enabled: false + +datasources: + datasources.yaml: + apiVersion: 1 + datasources: + - name: Prometheus + type: prometheus + access: proxy + uid: prometheus + url: http://host.containers.internal:9090 + isDefault: true + editable: false + - name: Loki + type: loki + access: proxy + uid: loki + url: http://host.containers.internal:3100 + editable: false + +sidecar: + dashboards: + enabled: true + label: grafana_dashboard + labelValue: "1" + +service: + type: ClusterIP + port: 80 + +resources: + requests: + memory: "128Mi" + cpu: "100m" + limits: + memory: "512Mi" + cpu: "500m" +``` + +--- + +### 3. Create Grafana ArgoCD Application + +**File**: `argocd/apps/grafana.yaml` + +```yaml +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: grafana + namespace: argocd +spec: + project: default + sources: + - repoURL: ssh://forgejo@indri.tail8d86e.ts.net:2200/eblume/grafana-helm-charts.git + targetRevision: grafana-8.8.2 + path: charts/grafana + helm: + releaseName: grafana + valueFiles: + - $values/argocd/manifests/grafana/values.yaml + - repoURL: ssh://forgejo@indri.tail8d86e.ts.net:2200/eblume/blumeops.git + targetRevision: main + ref: values + destination: + server: https://kubernetes.default.svc + namespace: monitoring + syncPolicy: + syncOptions: + - CreateNamespace=true +``` + +--- + +### 4. Create Grafana Config Application + +**File**: `argocd/apps/grafana-config.yaml` + +Deploys Tailscale Ingress and Dashboard ConfigMaps from `argocd/manifests/grafana-config/`. + +--- + +### 5. Create Grafana Config Manifests + +**Directory**: `argocd/manifests/grafana-config/` + +Contents: +- `kustomization.yaml` +- `ingress-tailscale.yaml` - Tailscale Ingress for `grafana.tail8d86e.ts.net` +- `secret-admin.yaml.tpl` - Admin password template (1Password-backed) +- `README.md` - Notes on secrets management +- `dashboards/configmap-*.yaml` - 9 dashboard ConfigMaps + +**Ingress**: +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: grafana-tailscale + namespace: monitoring + annotations: + tailscale.com/proxy-class: "crio-compat" +spec: + ingressClassName: tailscale + defaultBackend: + service: + name: grafana + port: + number: 80 + tls: + - hosts: + - grafana +``` + +**Secret template** (`secret-admin.yaml.tpl`): +```yaml +# Apply: op inject -i secret-admin.yaml.tpl | kubectl apply -f - +apiVersion: v1 +kind: Secret +metadata: + name: grafana-admin + namespace: monitoring +type: Opaque +stringData: + admin-user: admin + admin-password: {{ op://vg6xf6vvfmoh5hqjjhlhbeoaie/oxkcr3xtxnewy7noep2izvyr6y/password }} +``` + +**Dashboard ConfigMaps**: Convert each JSON from `ansible/roles/grafana/files/dashboards/` to ConfigMap with label `grafana_dashboard: "1"`. + +--- + +### 6. Deploy to Kubernetes + +```bash +# Create namespace and secret +ki create namespace monitoring +op inject -i argocd/manifests/grafana-config/secret-admin.yaml.tpl | ki apply -f - + +# Push changes and sync +argocd app sync grafana +argocd app sync grafana-config +``` + +--- + +### 7. Tailscale Service Cutover + +Remove `svc:grafana` from `ansible/roles/tailscale_serve/defaults/main.yml`, then: + +```bash +mise run provision-indri -- --tags tailscale-serve +``` + +--- + +### 8. Stop Brew Grafana + +```bash +ssh indri 'brew services stop grafana' +``` + +--- + +### 9. Retire Ansible Grafana Role + +Once k8s Grafana is verified working: + +1. **Remove role from playbook** - Delete grafana role entry from `ansible/playbooks/indri.yml` + +2. **Delete the role directory** - `rm -rf ansible/roles/grafana/` + +3. **Update zk documentation** - Note in `~/code/personal/zk/1767747119-YCPO.md` that Grafana is now k8s-hosted + +--- + +## New Files + +| Path | Purpose | +|------|---------| +| `argocd/apps/grafana.yaml` | Grafana Helm chart Application | +| `argocd/apps/grafana-config.yaml` | Grafana config Application | +| `argocd/manifests/grafana/values.yaml` | Helm values | +| `argocd/manifests/grafana-config/kustomization.yaml` | Kustomize config | +| `argocd/manifests/grafana-config/ingress-tailscale.yaml` | Tailscale Ingress | +| `argocd/manifests/grafana-config/secret-admin.yaml.tpl` | Admin password template | +| `argocd/manifests/grafana-config/README.md` | Secrets management notes | +| `argocd/manifests/grafana-config/dashboards/configmap-*.yaml` | 9 dashboard ConfigMaps | + +## Modified Files + +| Path | Change | +|------|--------| +| `argocd/apps/cloudnative-pg.yaml` | Switch to forge-mirrored chart | +| `ansible/roles/loki/templates/loki-config.yaml.j2` | Add `http_listen_address: 0.0.0.0` | +| `ansible/roles/tailscale_serve/defaults/main.yml` | Remove `svc:grafana` | +| `ansible/playbooks/indri.yml` | Remove grafana role | + +## Deleted Files + +| Path | Reason | +|------|--------| +| `ansible/roles/grafana/` | Replaced by k8s deployment | + +--- + +## Verification + +- [x] Loki accessible from k8s pods +- [x] Prometheus accessible from k8s pods +- [x] Grafana pod running in `monitoring` namespace +- [x] Grafana Ingress active +- [x] https://grafana.tail8d86e.ts.net loads +- [x] All 9 dashboards visible +- [x] Prometheus datasource queries work +- [x] Loki datasource queries work + +--- + +## Rollback + +1. Re-add `svc:grafana` to ansible tailscale_serve +2. `mise run provision-indri -- --tags tailscale-serve,grafana` +3. `argocd app delete grafana grafana-config --cascade` + +--- + +## Implementation Notes + +*Added during implementation for retrospective review* + +### SSH Credential Management + +**Issue**: Initial plan used HTTPS URLs for forge-mirrored Helm chart repos, but ArgoCD in cluster couldn't resolve `forge.tail8d86e.ts.net` (MagicDNS not available inside cluster). + +**Solution**: Use SSH URLs for all forge repos. Created a **credential template** (`repo-creds-forge`) that matches all repos under `ssh://forgejo@indri.tail8d86e.ts.net:2200/eblume/` using URL prefix matching. This allows a single SSH key (added to Forgejo user, not as deploy key) to work for all repos. + +### SSH Host Key for ArgoCD + +**Issue**: ArgoCD's known_hosts didn't include indri's SSH host key, causing `knownhosts: key is unknown` errors. + +**Solution**: Added `argocd-ssh-known-hosts-cm.yaml` as a kustomize patch to include indri's host key alongside the upstream defaults. + +**Gotcha**: Kustomize patches must **not specify namespace** - the namespace transformation happens *after* patch matching. Our patch had `namespace: argocd` which caused "no matches for Id" errors until removed. + +### Tailscale Hostname Cutover + +**Issue**: After removing `svc:grafana` from ansible's tailscale_serve config, the k8s Ingress still got a numbered hostname (`grafana-1.tail8d86e.ts.net`). + +**Solution**: The old `svc:grafana` service remained registered in Tailscale admin console even after clearing its serve config. **Manual deletion in Tailscale admin console** was required to free the `grafana` hostname for the k8s Ingress to claim. After deletion, recreating the Ingress picked up the correct hostname. + +### ArgoCD Workflow Decision + +During implementation, we established the pattern for GitOps workflow: + +- **All apps target `main` branch** (not feature branches) +- Manual sync policy on workload apps = merge doesn't auto-deploy +- Workflow: feature branch → PR → merge to main → `argocd app sync ` +- For testing: temporarily set one app to feature branch via `argocd app set --revision` + +This avoids the friction of switching `targetRevision` in manifests during development. + +### Bootstrap Dependencies + +Some resources must be applied manually before ArgoCD can manage itself: + +1. **SSH known_hosts** - chicken-and-egg: ArgoCD can't sync the config that adds the host key +2. **Credential secrets** - `repo-creds-forge` must exist before ArgoCD can pull from forge + +These are documented in `argocd/manifests/argocd/README.md` as bootstrap steps. + +### Actual Versions Used + +- Grafana Helm chart: `grafana-8.8.2` (tag in grafana-helm-charts repo) +- CloudNativePG Helm chart: `cloudnative-pg-v0.23.0` (tag in cloudnative-pg-charts repo) +- Grafana version: 11.4.0 diff --git a/plans/completed/k8s-migration/P3_postgresql.complete.md b/plans/completed/k8s-migration/P3_postgresql.complete.md new file mode 100644 index 0000000..e74f09d --- /dev/null +++ b/plans/completed/k8s-migration/P3_postgresql.complete.md @@ -0,0 +1,359 @@ +# Phase 3: PostgreSQL Disaster Recovery & Backup + +**Goal**: Test disaster recovery and configure borgmatic backups for k8s-pg + +**Status**: Complete (2026-01-19) + +**Prerequisites**: [Phase 2](P2_grafana.complete.md) complete + +--- + +## Overview + +Phase 3 establishes disaster recovery capabilities for the k8s PostgreSQL cluster: +1. **Fix borgmatic backup issues** - Resolve `borg: command not found` error +2. **Test disaster recovery** - Restore miniflux data from borgmatic backup to k8s-pg +3. **Create borgmatic user** - Read-only backup user in k8s-pg via CloudNativePG +4. **Configure dual database backup** - Backup both brew PostgreSQL and k8s-pg during migration + +This phase prepares for Phase 4 (miniflux migration) by verifying we can restore data to k8s-pg. + +--- + +## Key Decisions + +### Backup Both Databases During Transition + +**Decision**: Configure borgmatic to backup both `localhost:5432/miniflux` (brew) and `k8s-pg.tail8d86e.ts.net:5432/miniflux` (k8s) until migration complete. + +**Why**: Provides redundancy during migration. After Phase 4, remove localhost entry. + +### Reuse Existing borgmatic Password + +**Decision**: Use same borgmatic password from 1Password for k8s-pg user. + +**Why**: Simpler credential management, password already proven secure. + +### CloudNativePG Managed Roles + +**Decision**: Declare borgmatic user via CloudNativePG `managed.roles` instead of SQL commands. + +**Why**: Declarative, version-controlled, matches eblume user pattern. + +### Disable selfHeal on apps App + +**Decision**: Remove `selfHeal: true` from `argocd/apps/apps.yaml`. + +**Why**: Allows temporarily pointing child apps to feature branches during development without ArgoCD reverting the change. + +--- + +## Steps + +### 1. Fix borgmatic borg path issue + +**Problem**: borgmatic failing with `borg: command not found` + +**Cause**: LaunchAgent doesn't have homebrew in PATH, so `borg` binary not found. + +**Solution**: Add `local_path` to borgmatic config template. + +**File**: `ansible/roles/borgmatic/templates/config.yaml.j2` +```yaml +# Path to borg binary (LaunchAgent doesn't have homebrew in PATH) +local_path: {{ borgmatic_local_path }} +``` + +**File**: `ansible/roles/borgmatic/defaults/main.yml` +```yaml +borgmatic_local_path: /opt/homebrew/bin/borg +``` + +--- + +### 2. Run manual backup to verify fix + +```bash +mise run provision-indri -- --tags borgmatic +ssh indri '/opt/homebrew/bin/borgmatic --verbosity 1' +``` + +--- + +### 3. Extract miniflux dump from borgmatic + +```bash +ssh indri 'borgmatic list --archive latest' +ssh indri 'borgmatic restore --archive latest --destination /tmp/restore' +``` + +--- + +### 4. Add ACL grant for homelab → k8s + +**Problem**: Connection from indri to k8s-pg blocked - Tailscale proxy logs showed "no rules matched" + +**Solution**: Add ACL grant in Pulumi. + +**File**: `pulumi/policy.hujson` +```hujson +// Homelab can reach k8s PostgreSQL for borgmatic backups +{ + "src": ["tag:homelab"], + "dst": ["tag:k8s"], + "ip": ["tcp:5432"], +}, +``` + +Deploy: `mise run tailnet-up` + +--- + +### 5. Restore data to k8s-pg + +```bash +# Using eblume superuser credentials from 1Password +ssh indri "psql 'postgres://eblume@k8s-pg.tail8d86e.ts.net:5432/miniflux' -f /tmp/restore/localhost/miniflux/miniflux" +``` + +**Verification**: +```bash +psql 'postgres://eblume@k8s-pg.tail8d86e.ts.net:5432/miniflux' -c 'SELECT COUNT(*) FROM users; SELECT COUNT(*) FROM feeds; SELECT COUNT(*) FROM entries;' +# Result: 2 users, 2 feeds, 44 entries +``` + +--- + +### 6. Create borgmatic user in k8s-pg via CloudNativePG + +**File**: `argocd/manifests/databases/secret-borgmatic.yaml.tpl` +```yaml +# Template for borgmatic backup user password +# Apply with: op inject -i secret-borgmatic.yaml.tpl | kubectl apply -f - +apiVersion: v1 +kind: Secret +metadata: + name: blumeops-pg-borgmatic + namespace: databases +type: kubernetes.io/basic-auth +stringData: + username: borgmatic + password: {{ op://vg6xf6vvfmoh5hqjjhlhbeoaie/mw2bv5we7woicjza7hc6s44yvy/db-password }} +``` + +**File**: `argocd/manifests/databases/blumeops-pg.yaml` (add to managed roles) +```yaml +managed: + roles: + # ... existing eblume role ... + # 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 +``` + +**Deploy**: +```bash +op inject -i argocd/manifests/databases/secret-borgmatic.yaml.tpl | kubectl apply -f - +argocd app set blumeops-pg --revision feature/p3-postgresql-borgmatic +argocd app sync blumeops-pg +``` + +--- + +### 7. Configure borgmatic for dual database backup + +**File**: `ansible/roles/borgmatic/defaults/main.yml` +```yaml +borgmatic_postgresql_databases: + # Brew PostgreSQL on indri (current production) + - name: miniflux + hostname: localhost + port: 5432 + username: borgmatic + # k8s PostgreSQL (CloudNativePG) - backup both during migration + - name: miniflux + hostname: k8s-pg.tail8d86e.ts.net + port: 5432 + username: borgmatic +``` + +**File**: `ansible/roles/postgresql/tasks/main.yml` (update .pgpass) +```yaml +- name: Write .pgpass file for borgmatic backups + ansible.builtin.copy: + content: | + # Managed by ansible - only read-only roles + localhost:{{ postgresql_port }}:*:borgmatic:{{ postgresql_user_passwords['borgmatic'] }} + k8s-pg.tail8d86e.ts.net:5432:*:borgmatic:{{ postgresql_user_passwords['borgmatic'] }} + dest: ~/.pgpass + mode: '0600' + no_log: true +``` + +--- + +### 8. Verify complete backup pipeline + +```bash +mise run provision-indri -- --tags borgmatic,postgresql +ssh indri '/opt/homebrew/bin/borgmatic --verbosity 1' +ssh indri 'borgmatic list --archive latest' +``` + +**Expected output**: Archive contains both dumps: +- `localhost/miniflux/miniflux` +- `k8s-pg.tail8d86e.ts.net/miniflux/miniflux` + +--- + +### 9. Fix ArgoCD drift from CNPG defaults + +**Problem**: ArgoCD showed blumeops-pg as OutOfSync due to CNPG operator adding default values. + +**Solution**: Add CNPG defaults explicitly to managed roles. + +**File**: `argocd/manifests/databases/blumeops-pg.yaml` +```yaml +managed: + roles: + - name: eblume + # ... existing fields ... + connectionLimit: -1 + ensure: present + inherit: true + - name: borgmatic + # ... existing fields ... + connectionLimit: -1 + ensure: present + inherit: true +``` + +--- + +### 10. Update zk documentation + +Updated: +- `~/code/personal/zk/borgmatic.md` - k8s-pg backup documentation and log entry +- `~/code/personal/zk/postgresql.md` - k8s PostgreSQL section and log entry + +--- + +## New Files + +| Path | Purpose | +|------|---------| +| `argocd/manifests/databases/secret-borgmatic.yaml.tpl` | borgmatic user password template | + +## Modified Files + +| Path | Change | +|------|--------| +| `ansible/roles/borgmatic/defaults/main.yml` | Added `borgmatic_local_path`, k8s-pg database entry | +| `ansible/roles/borgmatic/templates/config.yaml.j2` | Added `local_path` option | +| `ansible/roles/postgresql/tasks/main.yml` | Added k8s-pg to .pgpass | +| `argocd/apps/apps.yaml` | Disabled selfHeal | +| `argocd/manifests/databases/blumeops-pg.yaml` | Added borgmatic managed role, CNPG defaults | +| `pulumi/policy.hujson` | Added ACL grant homelab → k8s on tcp:5432 | + +--- + +## Verification + +- [x] borgmatic backup runs successfully +- [x] Miniflux data restored to k8s-pg (2 users, 2 feeds, 44 entries) +- [x] borgmatic user created in k8s-pg with pg_read_all_data role +- [x] Both localhost and k8s-pg databases in backup archive +- [x] ArgoCD shows blumeops-pg as Synced +- [x] zk documentation updated + +--- + +## Rollback + +Keep brew PostgreSQL running until Phase 4 verified. To revert: + +1. Remove k8s-pg entry from borgmatic databases +2. Remove k8s-pg from .pgpass +3. `mise run provision-indri -- --tags borgmatic,postgresql` + +--- + +## Implementation Notes + +*Added during implementation for retrospective review* + +### borgmatic LaunchAgent PATH Issue + +**Problem**: borgmatic LaunchAgent failed with `borg: command not found` + +**Root cause**: LaunchAgents run with minimal PATH that doesn't include `/opt/homebrew/bin` + +**Solution**: Added `local_path: /opt/homebrew/bin/borg` to borgmatic config. This was already done for `pg_dump_command` but not for borg itself. + +**Lesson**: Any tool invoked by borgmatic needs absolute path when running from LaunchAgent. + +### 1Password Field Name Mismatch + +**Issue**: Initial secret template used `password` field but 1Password item had `db-password`. + +**Discovery**: Error message from `op inject` indicated field not found. + +**Fix**: Updated template to use correct field name `db-password`. + +### ACL Grant Discovery + +**Problem**: Connection from indri (tag:homelab) to k8s-pg (tag:k8s) failed. + +**Diagnosis**: Checked Tailscale operator proxy logs which showed "no rules matched" - clear indication of missing ACL. + +**Solution**: Added explicit grant in `pulumi/policy.hujson` for `tag:homelab` → `tag:k8s` on `tcp:5432`. + +### ArgoCD selfHeal and Feature Branch Development + +**Problem**: When testing changes, temporarily pointed blumeops-pg app to feature branch via `argocd app set --revision`. ArgoCD's selfHeal kept reverting it back to main. + +**Discussion**: Two options considered: +- Option A: Disable selfHeal on apps app (manual sync required for new apps) +- Option B: Keep selfHeal, use different workflow + +**Decision**: Option A chosen. The apps app now only has `prune: true`, not selfHeal. This allows: +1. Temporarily testing feature branches +2. Manual control over when app manifest changes are applied + +**Trade-off**: Must manually sync apps app when adding/removing Application manifests. + +### CloudNativePG Managed Role Reconciliation + +**Issue**: After creating borgmatic secret with correct password, CNPG didn't immediately update the user. + +**Solution**: Annotated the Cluster to trigger reconciliation: +```bash +kubectl annotate cluster blumeops-pg -n databases cnpg.io/reconcile=$(date +%s) --overwrite +``` + +### ArgoCD Drift from CNPG Defaults + +**Problem**: blumeops-pg showed OutOfSync despite successful syncs. + +**Cause**: CNPG operator adds default values (`connectionLimit: -1`, `ensure: present`, `inherit: true`) to managed roles that weren't in our spec. + +**Solution**: Added these defaults explicitly to our spec to match what CNPG generates. + +**Comment added**: Documented in blumeops-pg.yaml that these are "CNPG defaults added to prevent ArgoCD drift". + +### Git Workflow for Phase 3 + +1. Created feature branch: `feature/p3-postgresql-borgmatic` +2. Made commits throughout implementation +3. Pointed blumeops-pg app to feature branch for testing +4. Created PR #32 for review +5. After merge, reset app to main: `argocd app set blumeops-pg --revision main` + +This workflow was enabled by disabling selfHeal (see above). diff --git a/plans/completed/k8s-migration/P4_miniflux.complete.md b/plans/completed/k8s-migration/P4_miniflux.complete.md new file mode 100644 index 0000000..1fc73cf --- /dev/null +++ b/plans/completed/k8s-migration/P4_miniflux.complete.md @@ -0,0 +1,162 @@ +# Phase 4: Miniflux Migration to Kubernetes + +**Goal**: Migrate Miniflux entirely off indri and onto k8s, retire brew PostgreSQL, rename k8s-pg to pg + +**Status**: Complete (2026-01-20) + +**Prerequisites**: [Phase 3](P3_postgresql.complete.md) complete + +--- + +## Overview + +This phase completed the miniflux migration and retired brew PostgreSQL: +1. Deployed miniflux container in k8s via ArgoCD +2. Exposed via Tailscale Ingress at `feed.tail8d86e.ts.net` +3. Removed all miniflux infrastructure from indri (ansible role, brew service, Tailscale serve) +4. Retired brew PostgreSQL (no longer needed) +5. Renamed k8s-pg to pg (canonical Tailscale hostname) +6. Updated borgmatic to backup only `pg.tail8d86e.ts.net` +7. Updated all zk documentation + +--- + +## New Files + +| Path | Purpose | +|------|---------| +| `argocd/apps/miniflux.yaml` | ArgoCD Application definition | +| `argocd/manifests/miniflux/deployment.yaml` | Miniflux Deployment | +| `argocd/manifests/miniflux/service.yaml` | ClusterIP Service | +| `argocd/manifests/miniflux/ingress-tailscale.yaml` | Tailscale Ingress for `feed.tail8d86e.ts.net` | +| `argocd/manifests/miniflux/secret-db.yaml.tpl` | Database URL secret documentation | +| `argocd/manifests/miniflux/kustomization.yaml` | Kustomize configuration | +| `argocd/manifests/miniflux/README.md` | Setup instructions | + +## Modified Files + +| Path | Change | +|------|--------| +| `ansible/playbooks/indri.yml` | Removed miniflux and postgresql roles, simplified pre_tasks | +| `ansible/roles/tailscale_serve/defaults/main.yml` | Removed `svc:feed` and `svc:pg` entries | +| `ansible/roles/alloy/defaults/main.yml` | Removed miniflux and postgresql logs, disabled postgres metrics | +| `ansible/roles/borgmatic/defaults/main.yml` | Updated to backup only `pg.tail8d86e.ts.net` | +| `ansible/roles/borgmatic/tasks/main.yml` | Added .pgpass file management | +| `argocd/manifests/databases/service-tailscale.yaml` | Renamed hostname from k8s-pg to pg | + +## Deleted Files + +| Path | Reason | +|------|--------| +| `ansible/roles/miniflux/` | Entire role no longer needed | +| `ansible/roles/postgresql/` | Brew PostgreSQL no longer needed | + +--- + +## Verification + +- [x] Miniflux pod healthy in k8s +- [x] https://feed.tail8d86e.ts.net accessible +- [x] User `eblume` can log in +- [x] Feeds visible and entries readable +- [x] `pg.tail8d86e.ts.net` resolves to k8s PostgreSQL +- [x] Old `k8s-pg` and `feed` devices removed from Tailscale +- [x] brew miniflux and postgresql services stopped +- [x] Tailscale serve entries cleared from indri +- [x] zk documentation updated + +--- + +## Implementation Notes + +*Lessons learned and issues encountered* + +### CNPG-Generated Password vs 1Password + +**Problem**: Initial secret template used 1Password for miniflux database password, but CNPG auto-generates the bootstrap owner password. + +**Solution**: Reference the CNPG-generated password from `blumeops-pg-app` secret: +```bash +kubectl create secret generic miniflux-db -n miniflux \ + --from-literal=url="$(kubectl -n databases get secret blumeops-pg-app -o jsonpath='{.data.uri}' | base64 -d)" +``` + +### Table Ownership Issue After P3 Restore + +**Problem**: Miniflux pod crashed with "permission denied for table schema_version". + +**Root cause**: P3 restore was run as the `eblume` superuser, so all tables were created owned by `eblume`, not `miniflux`. + +**Solution**: Transfer ownership of all tables to miniflux: +```sql +DO $$ +DECLARE r RECORD; +BEGIN + FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = 'public') LOOP + EXECUTE 'ALTER TABLE public.' || quote_ident(r.tablename) || ' OWNER TO miniflux'; + END LOOP; +END$$; +``` + +### Tailscale Ingress Hostname Suffix + +**Behavior**: When requesting a Tailscale hostname that's already taken, the operator adds a suffix (e.g., `feed-1`). + +**Workflow**: +1. Deploy initially - gets `feed-1.tail8d86e.ts.net` +2. Clear old `svc:feed` from indri +3. Delete old `feed` device from Tailscale admin +4. Delete and recreate the Ingress - now claims `feed` + +### Renaming Tailscale Service Hostname + +**Problem**: Changing the `tailscale.com/hostname` annotation doesn't automatically update the Tailscale device. + +**Solution**: Delete the service and let ArgoCD recreate it: +```bash +kubectl -n databases delete service blumeops-pg-tailscale +argocd app sync blumeops-pg +``` + +### .pgpass Management Migration + +**Issue**: The postgresql role managed `~/.pgpass` for borgmatic. With postgresql role deleted, borgmatic couldn't authenticate. + +**Solution**: Moved .pgpass management to the borgmatic role. Password is still fetched in playbook pre_tasks as `borgmatic_db_password`. + +### Ansible Check Mode and Registered Variables + +**Problem**: Running `provision-indri --check --diff` failed in the podman role with "Conditional result (True) was derived from value of type 'str'" errors. + +**Root cause**: Command tasks are skipped in check mode, leaving registered variables undefined or with unexpected types when used in conditionals. + +**Solution**: Added `check_mode: false` to read-only command tasks that gather information: +```yaml +- name: Check if podman machine exists + ansible.builtin.command: + cmd: podman machine list --format json + register: podman_machine_list + changed_when: false + check_mode: false # Safe to run in check mode - read-only +``` + +**Lesson**: Any task that registers a variable used in conditionals should have `check_mode: false` if the command is read-only/safe. + +### 1Password CLI on Headless Hosts + +**Issue**: Attempted to run `op` commands on indri, but 1Password CLI requires interactive authentication (biometrics/password). + +**Solution**: All `op` commands must be in `pre_tasks` of the playbook with `delegate_to: localhost` so they run on gilbert (the workstation with GUI auth). + +### Git Workflow for Phase 4 + +1. Created feature branch: `feature/p4-miniflux` +2. Made incremental commits throughout implementation +3. Pointed `miniflux` and `blumeops-pg` apps to feature branch for testing +4. Created PR #33 for review +5. After merge, reset apps to main: + ```bash + argocd app set miniflux --revision main + argocd app set blumeops-pg --revision main + argocd app sync apps + ``` diff --git a/plans/completed/k8s-migration/P5.1_docker_migration.complete.md b/plans/completed/k8s-migration/P5.1_docker_migration.complete.md new file mode 100644 index 0000000..d91d6de --- /dev/null +++ b/plans/completed/k8s-migration/P5.1_docker_migration.complete.md @@ -0,0 +1,208 @@ +# Phase 5.1: Migrate Minikube from QEMU2 to Docker Driver + +**Goal**: Replace the qemu2 driver with docker to fix remote API access and simplify volume mounts + +**Status**: Complete (2026-01-21) - Cluster running, ArgoCD deployed, apps synced + +**Prerequisites**: [Phase 5](P5_devpi.complete.md) complete + +--- + +## Background + +### Original Problem (Podman → QEMU2) + +During Phase 6 (Kiwix/Transmission migration), we discovered that the **podman driver has fundamental limitations** that prevent mounting external volumes: + +1. **SMB CSI driver fails** with "Operation not permitted" - the rootless container lacks kernel-level mount capabilities +2. **`minikube mount` fails** - 9p mount gets "permission denied" inside the podman VM +3. **hostPath volumes** only work for paths inside the minikube container, not the macOS host + +We migrated to QEMU2 to get a full VM with kernel capabilities. + +### New Problem (QEMU2 → Docker) + +The QEMU2 driver introduced a **new problem**: the Kubernetes API server is inside the VM at `192.168.105.2:6443`, and Tailscale's TCP proxy cannot forward to it properly: + +- TCP connections succeed (nc -zv works) +- TLS handshake times out +- Root cause unknown, but likely related to Tailscale serve's handling of non-localhost upstreams + +Additionally, the volume mount solution with QEMU2 was complex: +- Required NFS mount from sifaka → indri +- Then `minikube mount` to pass through to VM +- Two LaunchAgents/LaunchDaemons for persistence +- macOS GUI approval required for network access + +### Why Docker? + +The **docker driver** solves both problems: + +1. **API Server on localhost**: Docker Desktop handles port forwarding from container to localhost automatically, so `tailscale serve --tcp=443 tcp://localhost:PORT` works + +2. **Simpler volume mounts**: Docker Desktop has built-in macOS file sharing. Paths shared with Docker are accessible inside containers. + +3. **Official Tailscale recommendation**: Tailscale's own [Kubernetes guide](https://tailscale.com/learn/managing-access-to-kubernetes-with-tailscale) uses minikube with the docker driver. + +--- + +## Implementation Summary + +### Infrastructure Changes + +1. **Docker Desktop installed** (manual via `brew install --cask docker`) + - Configured with 12GB memory in Docker Desktop settings + - Kubernetes option disabled (using minikube instead) + +2. **Docker minikube cluster created**: + ```bash + minikube start \ + --driver=docker \ + --container-runtime=docker \ + --cpus=6 \ + --memory=11264 \ + --disk-size=200g \ + --apiserver-names=k8s.tail8d86e.ts.net,indri \ + --apiserver-port=6443 \ + --listen-address=0.0.0.0 + ``` + +3. **Tailscale serve configured** for k8s API: + - API server on localhost (port is dynamic with docker driver) + - `tailscale serve --service=svc:k8s --tcp=443 tcp://localhost:` + +4. **Remote kubectl access working** from gilbert: + - Created `mise-tasks/ensure-minikube-indri-kubectl-config` script + - Fetches certs from indri and sets up `~/.kube/minikube-indri/config.yml` + +### Ansible Roles Updated + +- `ansible/roles/minikube/` - docker driver, removed qemu2/NFS/socket_vmnet +- `ansible/roles/tailscale_serve/` - removed svc:k8s (minikube role handles dynamic port) +- Containerd registry mirrors configured for zot pull-through cache + +### ArgoCD Bootstrap + +All apps deployed and synced from `feature/p5.1-qemu2-migration` branch: + +| App | Status | Notes | +|-----|--------|-------| +| tailscale-operator | Healthy | Manages Tailscale ingresses | +| argocd | Healthy | Self-managed | +| cloudnative-pg | Healthy | PostgreSQL operator | +| blumeops-pg | Progressing | PostgreSQL cluster starting | +| grafana | Progressing | Needs grafana-admin secret | +| grafana-config | Healthy | Dashboards and ingress | +| miniflux | Progressing | Needs miniflux-config secret | +| devpi | Progressing | Starting up | + +### Secrets Still Needed + +After PR merge, apply these secrets manually: + +```bash +# Grafana admin password +op inject -i argocd/manifests/grafana-config/secret-admin.yaml.tpl | kubectl --context=minikube-indri apply -f - + +# Miniflux config +op inject -i argocd/manifests/miniflux/secret.yaml.tpl | kubectl --context=minikube-indri apply -f - +``` + +--- + +## Technical Notes + +### API Server Port + +With docker driver, the API server port is **dynamic** - Docker maps a random host port to 6443 inside the container. + +The minikube ansible role queries the port after cluster start and configures tailscale serve accordingly. + +### Registry Mirror Configuration + +Containerd uses `/etc/containerd/certs.d//hosts.toml` files. The ansible role configures mirrors for: +- `registry.tail8d86e.ts.net` (private images) +- `docker.io` +- `ghcr.io` +- `quay.io` + +### ProxyClass Renamed + +Changed from `crio-compat` to `default` - the old name was misleading since we're no longer using CRI-O. + +### Volume Mounts for P6 (Kiwix/Transmission) + +**Solution: Direct NFS from pods to sifaka** ✅ TESTED AND WORKING + +Docker NATs outbound traffic through indri's LAN IP (192.168.1.50), so sifaka's NFS exports need to allow `192.168.1.0/24`. + +Sifaka NFS exports configured: +- `192.168.1.0/24` - Docker containers via indri NAT +- `100.64.0.0/10` - Tailscale clients + +Pods can mount NFS directly: +```yaml +volumes: + - name: torrents + nfs: + server: sifaka + path: /volume1/torrents +``` + +No LaunchAgents, no `minikube mount`, no SMB CSI driver needed. + +--- + +## Verification Checklist + +- [x] Docker Desktop installed and running on indri +- [x] QEMU2 minikube deleted +- [x] Docker minikube running (6 CPUs, 11GB RAM) +- [x] API server accessible on localhost +- [x] Tailscale serve configured for svc:k8s +- [x] Remote kubectl access working from gilbert +- [x] Ansible roles updated for docker driver +- [x] socket_vmnet stopped +- [x] ArgoCD deployed and synced +- [x] All apps synced to feature branch +- [x] Apply app secrets (grafana-admin, miniflux-db, devpi-root, eblume, borgmatic) +- [x] Verify all apps healthy after secrets applied +- [x] Miniflux database restored from borgmatic backup +- [ ] Merge PR and reset apps to main branch +- [ ] `mise run indri-services-check` passes + +--- + +## Post-Merge Steps + +After PR is merged: + +```bash +# Reset all blumeops apps to main branch +argocd app set apps --revision main +argocd app set argocd --revision main +argocd app set blumeops-pg --revision main +argocd app set devpi --revision main +argocd app set grafana-config --revision main +argocd app set miniflux --revision main +argocd app set tailscale-operator --revision main + +# Sync all apps +argocd app sync apps +argocd app sync argocd +argocd app sync tailscale-operator +argocd app sync blumeops-pg +argocd app sync grafana-config +argocd app sync miniflux +argocd app sync devpi +``` + +--- + +## Rollback Plan + +If Docker driver doesn't work: + +1. Delete Docker minikube: `minikube delete` +2. Recreate QEMU2 cluster (restore old ansible config from git) +3. Accept the Tailscale TCP forwarding limitation and use SSH tunnel for remote kubectl diff --git a/plans/completed/k8s-migration/P5_devpi.complete.md b/plans/completed/k8s-migration/P5_devpi.complete.md new file mode 100644 index 0000000..78669ca --- /dev/null +++ b/plans/completed/k8s-migration/P5_devpi.complete.md @@ -0,0 +1,102 @@ +# Phase 5: devpi Migration to Kubernetes + +**Goal**: Migrate devpi PyPI caching proxy from indri to k8s + +**Status**: Complete (2026-01-20) + +**Prerequisites**: [Phase 4](P4_miniflux.complete.md) complete + +--- + +## Summary + +Successfully migrated devpi from mcquack LaunchAgent on indri to Kubernetes: +- Custom container image with devpi-server + devpi-web + auto-init startup script +- StatefulSet with 50Gi PVC for data persistence +- Tailscale Ingress at `pypi.tail8d86e.ts.net` +- Root password from 1Password secret, auto-initialized on first run +- Verified pip caching proxy and mcquack package upload + +--- + +## Key Learnings + +### Registry Mirror Configuration +- Minikube's CRI-O can't resolve Tailscale hostnames directly +- Added registry mirror config to redirect `registry.tail8d86e.ts.net` → `host.containers.internal:5050` +- Also added direct insecure registry entry for `host.containers.internal:5050` +- Config in `ansible/roles/minikube/files/zot-mirror.conf` + +### Memory Requirements +- devpi-web's Whoosh search indexer needs significant memory during PyPI index build +- Initial 512Mi limit caused OOMKills +- Solution: High limit (2Gi) with low request (256Mi) - memory reclaimed after indexing + +### Environment Variable Conflicts +- Kubernetes auto-sets `DEVPI_PORT` for service discovery +- Conflicted with our port config - renamed to `DEVPI_LISTEN_PORT` + +### Tailscale Serve Cleanup +- Use `tailscale serve status --json` to see entries (non-JSON output can be empty) +- Use `tailscale serve clear svc:` to remove entries + +### ArgoCD Workflow +- Changed `apps` to manual sync (was auto-sync with prune) +- Workflow: sync apps → set revision to feature branch → sync service → test → reset to main after merge + +--- + +## Verification Checklist + +- [x] devpi pod healthy in k8s +- [x] https://pypi.tail8d86e.ts.net accessible +- [x] Web interface shows root/pypi index +- [x] `pip install ` works through proxy +- [x] mcquack v1.0.0 uploaded to eblume/dev +- [x] `pip install --index-url https://pypi.tail8d86e.ts.net/eblume/dev/+simple/ mcquack` works +- [x] Old devpi service removed from indri +- [x] zk documentation updated + +--- + +## Files Changed + +### New Files +| Path | Purpose | +|------|---------| +| `argocd/apps/devpi.yaml` | ArgoCD Application definition | +| `argocd/manifests/devpi/Dockerfile` | Container image with startup script | +| `argocd/manifests/devpi/start.sh` | Auto-init startup script | +| `argocd/manifests/devpi/statefulset.yaml` | StatefulSet with PVC | +| `argocd/manifests/devpi/service.yaml` | ClusterIP Service | +| `argocd/manifests/devpi/ingress-tailscale.yaml` | Tailscale Ingress | +| `argocd/manifests/devpi/kustomization.yaml` | Kustomize configuration | +| `argocd/manifests/devpi/secret-root.yaml.tpl` | 1Password secret template | +| `argocd/manifests/devpi/README.md` | Setup documentation | + +### Modified Files +| Path | Change | +|------|--------| +| `CLAUDE.md` | Added k8s/ArgoCD workflow documentation | +| `ansible/playbooks/indri.yml` | Removed devpi and devpi_metrics roles | +| `ansible/roles/tailscale_serve/defaults/main.yml` | Removed svc:pypi | +| `ansible/roles/alloy/defaults/main.yml` | Removed devpi log collection | +| `ansible/roles/borgmatic/defaults/main.yml` | Removed devpi backup paths | +| `ansible/roles/minikube/files/zot-mirror.conf` | Added registry mirror for Tailscale hostname | +| `argocd/apps/apps.yaml` | Changed to manual sync policy | + +### Roles Kept (not deleted) +- `ansible/roles/devpi/` - Kept for reference +- `ansible/roles/devpi_metrics/` - Kept for reference + +--- + +## Post-Merge Cleanup + +After PR merge, reset ArgoCD apps to main: +```fish +argocd app set apps --revision main +argocd app sync apps +argocd app set devpi --revision main +argocd app sync devpi +``` diff --git a/plans/completed/k8s-migration/P6_kiwix.complete.md b/plans/completed/k8s-migration/P6_kiwix.complete.md new file mode 100644 index 0000000..6e4ebea --- /dev/null +++ b/plans/completed/k8s-migration/P6_kiwix.complete.md @@ -0,0 +1,1039 @@ +# Phase 6: Kiwix and Transmission Migration + +**Goal**: Migrate kiwix-serve and transmission torrent daemon to k8s with shared storage + +**Status**: Ready to implement + +**Prerequisites**: [Phase 5.1](P5.1_docker_migration.md) complete (minikube on docker driver) + +--- + +## Blocker: Podman Driver Volume Mount Limitations + +**First attempt branch:** `feature/p6-kiwix-transmission` + +The initial implementation was completed and tested, but **all volume mount approaches failed** due to the podman driver's rootless container limitations: + +| Approach | Result | +|----------|--------| +| NFS volume | Failed - CAP_SYS_ADMIN required for NFS mounts | +| SMB CSI driver | Failed - `mount.cifs` returns EPERM inside rootless container | +| `minikube mount` (9p) | Failed - permission denied mounting into podman VM | +| hostPath | Failed - path doesn't exist inside minikube container | + +**Root cause:** The podman driver runs minikube in a rootless container that lacks kernel capabilities for filesystem mounts. This is a [documented limitation](https://minikube.sigs.k8s.io/docs/drivers/podman/) of the experimental podman driver. + +**Solution:** Phase 5.1 migrates minikube from podman to QEMU2 driver, which creates an actual VM with full kernel capabilities. + +**What's preserved:** +- All k8s manifests in `feature/p6-kiwix-transmission` are complete and tested +- Prerequisites (SMB share, k8s-smb user, data rsync) are done +- Can retry P6 immediately after P5.1 completes + +--- + +## Overview + +This phase migrates two services that share storage but operate independently: +1. **Transmission** - General-purpose BitTorrent daemon (standalone service) +2. **Kiwix** - Serves ZIM archives via HTTP + +The current architecture on indri: +- Transmission downloads torrents to `~/transmission/` +- Ansible syncs a declarative torrent list to transmission +- Completed ZIMs are symlinked to kiwix's serving directory +- kiwix-serve runs as a LaunchAgent with explicit file arguments + +New architecture in k8s: +- **SMB volume** on sifaka (`/volume1/torrents`) for all torrent downloads +- **SMB CSI driver** for mounting the Synology share in k8s +- **Transmission** as a standalone service with Tailscale ingress (`torrent.tail8d86e.ts.net`) +- **Kiwix** deployment that watches for `.zim` files among all downloads +- **Declarative ZIM list** in kiwix manifest, synced to transmission automatically +- **CronJob** to detect new ZIMs and restart kiwix + +**Key design principles:** +- Transmission is a general-purpose torrent daemon, not just for kiwix +- Users can add arbitrary torrents via transmission web UI/RPC +- Kiwix declares which ZIM torrents it wants and handles syncing them to transmission +- Kiwix watches the shared download directory for any `.zim` files (regardless of how they were added) + +--- + +## Architecture Decisions + +### Storage: Direct NFS to Sifaka ✅ TESTED + +**Solution:** Direct NFS volume mounts from pods to sifaka. No SMB CSI driver or `minikube mount` needed. + +With the docker driver, minikube containers NAT outbound traffic through indri's LAN IP (192.168.1.50). Sifaka's NFS exports are configured to allow: +- `192.168.1.0/24` - Docker containers via indri NAT +- `100.64.0.0/10` - Tailscale clients + +**Storage path:** `/volume1/torrents/` on sifaka (NFS export) +- General-purpose torrent download directory +- Contains ZIM files, Linux ISOs, and whatever else users download +- Accessed via native k8s NFS volume (no credentials needed - IP-based access) + +**No backup needed:** +- Sifaka is RAID 5/6, already the backup target +- ZIM files are re-downloadable via torrent +- Other torrents are typically re-downloadable too +- Future offsite backups will cover all shares + +### Torrent Daemon: Transmission (Standalone Service) + +**Why stick with Transmission:** +- Proven reliability on indri +- Well-maintained container images (`linuxserver/transmission`) +- RPC API for automation +- DHT/PEX for good peer discovery +- Web UI for interactive management + +**Container image:** `lscr.io/linuxserver/transmission:latest` +- Includes web UI for monitoring and adding torrents +- Supports environment variable configuration +- Uses `/downloads` for completed files + +**Standalone service:** +- Own namespace: `torrent` +- Own Tailscale ingress: `torrent.tail8d86e.ts.net` +- Can be used for any torrents, not just ZIM archives +- Users interact with it directly via web UI + +### Declarative ZIM Torrent Management + +**Pattern:** Kiwix ConfigMap → Kiwix Sidecar → Transmission RPC + +1. **ConfigMap** (`kiwix-zim-torrents`) in kiwix namespace lists desired ZIM torrent URLs +2. **Kiwix sidecar** syncs ConfigMap to transmission (adds missing torrents) +3. Transmission downloads to shared SMB volume +4. Kiwix watches SMB volume for `.zim` files + +This allows adding new ZIM archives by: +1. Adding torrent URL to ConfigMap in kiwix's ArgoCD manifest +2. Syncing the kiwix ArgoCD app +3. Kiwix sidecar adds torrent to transmission +4. Waiting for download to complete +5. Kiwix restarts automatically when ZIM watcher detects the new file + +**Non-declarative torrents:** +- Users can add any torrent via `torrent.tail8d86e.ts.net` web UI +- If someone adds a ZIM torrent manually, kiwix will still pick it up +- Non-ZIM downloads coexist in the same directory + +### Kiwix Restart Orchestration + +**Challenge:** kiwix-serve doesn't hot-reload new ZIM files; requires restart. + +**Solution:** CronJob watcher +- Runs hourly (configurable) +- Lists completed `.zim` files in SMB volume (among all downloads) +- Compares with hash of last-seen list +- If changed, triggers `kubectl rollout restart deployment/kiwix` + +**Graceful handling of incomplete downloads:** +- Transmission stores incomplete files with `.part` extension +- Kiwix glob pattern `*.zim` only matches completed files +- Kiwix can start immediately with whatever ZIMs exist + +--- + +## Prerequisites (Manual Steps) + +### 1. Configure NFS Export on Sifaka + +**Status: DONE** - The `torrents` shared folder exists at `/volume1/torrents` with NFS exports allowing: +- `192.168.1.0/24` - Docker containers via indri NAT +- `100.64.0.0/10` - Tailscale clients + +### 2. Copy Existing Downloads to Sifaka + +Before migration, copy existing downloads to avoid re-downloading ~138GB: + +```bash +# From indri - mount the NFS share +sudo mount -t nfs sifaka:/volume1/torrents /Volumes/torrents + +# Then rsync (adjust mount path as needed) +rsync -avP ~/transmission/ /Volumes/torrents/ + +# Verify ZIM files +ls -la /Volumes/torrents/*.zim +``` + +--- + +## Steps + +### 1. Create Shared NFS PersistentVolume + +This PV is shared between transmission and kiwix namespaces. Uses direct NFS - no CSI driver needed. + +**File:** `argocd/manifests/torrent/pv-nfs.yaml` + +```yaml +apiVersion: v1 +kind: PersistentVolume +metadata: + name: torrents-nfs-pv +spec: + capacity: + storage: 1Ti + accessModes: + - ReadWriteMany + persistentVolumeReclaimPolicy: Retain + storageClassName: "" + nfs: + server: sifaka + path: /volume1/torrents +``` + +No secrets needed - NFS uses IP-based access control configured on sifaka. + +--- + +## Transmission Service (Standalone) + +### 3. Create Transmission Namespace Resources + +**File:** `argocd/manifests/torrent/pvc.yaml` + +```yaml +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: torrents-storage + namespace: torrent +spec: + accessModes: + - ReadWriteMany + storageClassName: "" + volumeName: torrents-nfs-pv + resources: + requests: + storage: 1Ti +``` + +**File:** `argocd/manifests/torrent/deployment.yaml` + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: transmission + namespace: torrent +spec: + replicas: 1 + selector: + matchLabels: + app: transmission + template: + metadata: + labels: + app: transmission + spec: + containers: + - name: transmission + image: lscr.io/linuxserver/transmission:latest + env: + - name: PUID + value: "1000" + - name: PGID + value: "1000" + - name: TZ + value: "America/Los_Angeles" + ports: + - containerPort: 9091 + name: web + - containerPort: 51413 + name: peer-tcp + - containerPort: 51413 + protocol: UDP + name: peer-udp + volumeMounts: + - name: downloads + mountPath: /downloads + - name: config + mountPath: /config + resources: + requests: + memory: "256Mi" + cpu: "100m" + limits: + memory: "512Mi" + livenessProbe: + httpGet: + path: /transmission/web/ + port: 9091 + initialDelaySeconds: 30 + periodSeconds: 30 + readinessProbe: + httpGet: + path: /transmission/web/ + port: 9091 + initialDelaySeconds: 10 + periodSeconds: 10 + volumes: + - name: downloads + persistentVolumeClaim: + claimName: torrents-storage + - name: config + emptyDir: {} # Config is ephemeral; torrents persist in SMB +``` + +**File:** `argocd/manifests/torrent/service.yaml` + +```yaml +apiVersion: v1 +kind: Service +metadata: + name: transmission + namespace: torrent +spec: + selector: + app: transmission + ports: + - name: web + port: 9091 + targetPort: 9091 +``` + +**File:** `argocd/manifests/torrent/ingress-tailscale.yaml` + +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: transmission + namespace: torrent +spec: + ingressClassName: tailscale + rules: + - host: torrent + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: transmission + port: + number: 9091 +``` + +**File:** `argocd/manifests/torrent/kustomization.yaml` + +```yaml +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: torrent +resources: + - pv-nfs.yaml + - pvc.yaml + - deployment.yaml + - service.yaml + - ingress-tailscale.yaml +``` + +**File:** `argocd/apps/torrent.yaml` + +```yaml +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: torrent + namespace: argocd +spec: + project: default + source: + repoURL: ssh://forgejo@indri.tail8d86e.ts.net:2200/eblume/blumeops.git + targetRevision: main + path: argocd/manifests/torrent + destination: + server: https://kubernetes.default.svc + namespace: torrent + syncPolicy: + syncOptions: + - CreateNamespace=true +``` + +--- + +## Kiwix Service + +### 2. Create Kiwix PVC (References Same PV) + +**File:** `argocd/manifests/kiwix/pvc.yaml` + +```yaml +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: torrents-storage + namespace: kiwix +spec: + accessModes: + - ReadWriteMany # Need write for the sync sidecar to work + storageClassName: "" + volumeName: torrents-nfs-pv + resources: + requests: + storage: 1Ti +``` + +### 4. Create Declarative ZIM Torrent List ConfigMap + +This ConfigMap lists the ZIM archives that kiwix wants. The kiwix sidecar syncs these to transmission. + +**File:** `argocd/manifests/kiwix/configmap-zim-torrents.yaml` + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: kiwix-zim-torrents + namespace: kiwix +data: + torrents.txt: | + # Declarative ZIM archive torrent URLs + # These are synced to transmission automatically by the kiwix sidecar + # Format: one URL per line, comments start with # + # + # Users can also add ZIM torrents manually via torrent.tail8d86e.ts.net + # and kiwix will pick them up automatically. + + # Wikipedia - Top 1M English articles (43G) + https://download.kiwix.org/zim/wikipedia/wikipedia_en_top1m_maxi_2025-09.zim.torrent + + # Project Gutenberg - Public domain books (72G) + https://download.kiwix.org/zim/gutenberg/gutenberg_en_all_2023-08.zim.torrent + + # iFixit - Repair guides (3.3G) + https://download.kiwix.org/zim/ifixit/ifixit_en_all_2025-12.zim.torrent + + # Stack Exchange + https://download.kiwix.org/zim/stack_exchange/superuser.com_en_all_2025-12.zim.torrent + https://download.kiwix.org/zim/stack_exchange/math.stackexchange.com_en_all_2025-12.zim.torrent + + # LibreTexts - Open educational resources + https://download.kiwix.org/zim/libretexts/libretexts.org_en_bio_2025-01.zim.torrent + https://download.kiwix.org/zim/libretexts/libretexts.org_en_chem_2025-01.zim.torrent + https://download.kiwix.org/zim/libretexts/libretexts.org_en_eng_2025-01.zim.torrent + https://download.kiwix.org/zim/libretexts/libretexts.org_en_math_2025-01.zim.torrent + https://download.kiwix.org/zim/libretexts/libretexts.org_en_phys_2025-01.zim.torrent + https://download.kiwix.org/zim/libretexts/libretexts.org_en_human_2025-01.zim.torrent + + # DevDocs - Programming documentation + https://download.kiwix.org/zim/devdocs/devdocs_en_bash_2026-01.zim.torrent + https://download.kiwix.org/zim/devdocs/devdocs_en_python_2026-01.zim.torrent + https://download.kiwix.org/zim/devdocs/devdocs_en_go_2026-01.zim.torrent + https://download.kiwix.org/zim/devdocs/devdocs_en_kubernetes_2026-01.zim.torrent + https://download.kiwix.org/zim/devdocs/devdocs_en_docker_2026-01.zim.torrent + https://download.kiwix.org/zim/devdocs/devdocs_en_git_2026-01.zim.torrent + https://download.kiwix.org/zim/devdocs/devdocs_en_postgresql_2026-01.zim.torrent + # Add more from ansible/roles/kiwix/defaults/main.yml as needed +``` + +### 5. Create Torrent Sync Script ConfigMap + +This script syncs the declarative ZIM list to transmission. + +**File:** `argocd/manifests/kiwix/configmap-sync-script.yaml` + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: zim-torrent-sync-script + namespace: kiwix +data: + sync-zim-torrents.sh: | + #!/bin/bash + # Sync ZIM torrents from kiwix ConfigMap to Transmission + # Runs as a sidecar in the kiwix deployment + set -euo pipefail + + TORRENT_LIST="${TORRENT_LIST:-/config/torrents.txt}" + TRANSMISSION_HOST="${TRANSMISSION_HOST:-transmission.torrent.svc.cluster.local}" + TRANSMISSION_PORT="${TRANSMISSION_PORT:-9091}" + + echo "Syncing ZIM torrents to transmission at ${TRANSMISSION_HOST}:${TRANSMISSION_PORT}" + + # Wait for transmission to be ready + echo "Waiting for Transmission RPC..." + max_attempts=30 + attempt=0 + until curl -sf "http://${TRANSMISSION_HOST}:${TRANSMISSION_PORT}/transmission/rpc" >/dev/null 2>&1; do + attempt=$((attempt + 1)) + if [[ $attempt -ge $max_attempts ]]; then + echo "Transmission not ready after ${max_attempts} attempts, will retry next cycle" + exit 0 # Don't fail, just skip this sync + fi + sleep 10 + done + echo "Transmission is ready" + + # Get current torrents from transmission + # transmission-remote returns header + data + footer, extract just torrent names + current=$(transmission-remote "${TRANSMISSION_HOST}:${TRANSMISSION_PORT}" -l 2>/dev/null | \ + tail -n +2 | head -n -1 | awk '{print $NF}' || true) + + added=0 + skipped=0 + + while IFS= read -r url || [[ -n "$url" ]]; do + # Skip empty lines and comments + [[ -z "$url" || "$url" =~ ^[[:space:]]*# ]] && continue + # Trim whitespace + url=$(echo "$url" | xargs) + [[ -z "$url" ]] && continue + + # Extract base name from URL (remove .torrent extension) + basename=$(basename "$url" .torrent) + # Also try without .zim in case transmission reports it differently + basename_no_zim="${basename%.zim}" + + # Check if already in transmission + if echo "$current" | grep -qF "$basename_no_zim"; then + ((skipped++)) || true + else + if transmission-remote "${TRANSMISSION_HOST}:${TRANSMISSION_PORT}" -a "$url" 2>/dev/null; then + echo "Added: $basename" + ((added++)) || true + else + echo "Warning: Failed to add $url" >&2 + fi + fi + done < "$TORRENT_LIST" + + echo "Sync complete: $added added, $skipped already present" +``` + +### 6. Deploy Kiwix with Torrent Sync Sidecar + +**File:** `argocd/manifests/kiwix/deployment.yaml` + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: kiwix + namespace: kiwix + annotations: + # Track ZIM file changes for restart detection + kiwix.blumeops/zim-hash: "" +spec: + replicas: 1 + selector: + matchLabels: + app: kiwix + template: + metadata: + labels: + app: kiwix + spec: + containers: + # Main kiwix-serve container + - name: kiwix-serve + image: ghcr.io/kiwix/kiwix-serve:3.8.1 + args: + - --port=80 + - /data/*.zim # Serves ALL .zim files, regardless of how they were added + ports: + - containerPort: 80 + name: http + volumeMounts: + - name: torrents + mountPath: /data + readOnly: true + resources: + requests: + memory: "256Mi" + cpu: "100m" + limits: + memory: "1Gi" + livenessProbe: + httpGet: + path: / + port: 80 + initialDelaySeconds: 10 + periodSeconds: 30 + readinessProbe: + httpGet: + path: / + port: 80 + initialDelaySeconds: 5 + periodSeconds: 10 + + # Sidecar: Syncs declarative ZIM torrents to transmission + - name: torrent-sync + image: lscr.io/linuxserver/transmission:latest # Has transmission-remote CLI + command: ["/bin/bash", "-c"] + args: + - | + echo "Starting ZIM torrent sync sidecar" + # Initial sync + /scripts/sync-zim-torrents.sh || echo "Initial sync failed, will retry" + # Periodic sync every 30 minutes + while true; do + sleep 1800 + /scripts/sync-zim-torrents.sh || echo "Sync failed, will retry" + done + env: + - name: TRANSMISSION_HOST + value: "transmission.torrent.svc.cluster.local" + - name: TRANSMISSION_PORT + value: "9091" + - name: TORRENT_LIST + value: "/config/torrents.txt" + volumeMounts: + - name: zim-torrents-config + mountPath: /config/torrents.txt + subPath: torrents.txt + - name: sync-script + mountPath: /scripts + resources: + requests: + memory: "32Mi" + cpu: "10m" + limits: + memory: "64Mi" + + volumes: + - name: torrents + persistentVolumeClaim: + claimName: torrents-storage + - name: zim-torrents-config + configMap: + name: kiwix-zim-torrents + - name: sync-script + configMap: + name: zim-torrent-sync-script + defaultMode: 0755 +``` + +**File:** `argocd/manifests/kiwix/service.yaml` + +```yaml +apiVersion: v1 +kind: Service +metadata: + name: kiwix + namespace: kiwix +spec: + selector: + app: kiwix + ports: + - name: http + port: 80 + targetPort: 80 +``` + +### 7. Create Tailscale Ingress for Kiwix + +**File:** `argocd/manifests/kiwix/ingress-tailscale.yaml` + +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: kiwix + namespace: kiwix +spec: + ingressClassName: tailscale + rules: + - host: kiwix + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: kiwix + port: + number: 80 +``` + +### 8. Create ZIM Watcher CronJob + +This CronJob runs hourly to detect new completed ZIMs (from any source) and triggers a kiwix restart. + +**File:** `argocd/manifests/kiwix/cronjob-zim-watcher.yaml` + +```yaml +apiVersion: batch/v1 +kind: CronJob +metadata: + name: zim-watcher + namespace: kiwix +spec: + schedule: "0 * * * *" # Every hour + concurrencyPolicy: Forbid + jobTemplate: + spec: + template: + spec: + serviceAccountName: zim-watcher + containers: + - name: watcher + image: bitnami/kubectl:latest + command: ["/bin/bash", "-c"] + args: + - | + set -euo pipefail + + # Get current ZIM files (among all downloads) + # This picks up ZIMs from both declarative list AND manually added torrents + current_zims=$(ls -1 /data/*.zim 2>/dev/null | sort | md5sum | cut -d' ' -f1 || echo "empty") + + # Get stored hash from deployment annotation + stored_hash=$(kubectl get deployment kiwix -n kiwix -o jsonpath='{.metadata.annotations.kiwix\.blumeops/zim-hash}' 2>/dev/null || echo "") + + echo "Current ZIMs hash: $current_zims" + echo "Stored hash: $stored_hash" + + # Also list what ZIMs we found + echo "ZIM files found:" + ls -la /data/*.zim 2>/dev/null || echo " (none)" + + if [[ "$current_zims" != "$stored_hash" && "$current_zims" != "empty" ]]; then + echo "ZIM files changed, restarting kiwix deployment..." + kubectl annotate deployment kiwix -n kiwix "kiwix.blumeops/zim-hash=$current_zims" --overwrite + kubectl rollout restart deployment/kiwix -n kiwix + echo "Restart triggered" + else + echo "No changes detected" + fi + volumeMounts: + - name: torrents + mountPath: /data + readOnly: true + restartPolicy: OnFailure + volumes: + - name: torrents + persistentVolumeClaim: + claimName: torrents-storage +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: zim-watcher + namespace: kiwix +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: zim-watcher + namespace: kiwix +rules: + - apiGroups: ["apps"] + resources: ["deployments"] + verbs: ["get", "patch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: zim-watcher + namespace: kiwix +subjects: + - kind: ServiceAccount + name: zim-watcher + namespace: kiwix +roleRef: + kind: Role + name: zim-watcher + apiGroup: rbac.authorization.k8s.io +``` + +### 9. Create Kiwix Kustomization + +**File:** `argocd/manifests/kiwix/kustomization.yaml` + +```yaml +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: kiwix +resources: + - pvc.yaml + - configmap-zim-torrents.yaml + - configmap-sync-script.yaml + - deployment.yaml + - service.yaml + - ingress-tailscale.yaml + - cronjob-zim-watcher.yaml +``` + +### 10. Create Kiwix ArgoCD Application + +**File:** `argocd/apps/kiwix.yaml` + +```yaml +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: kiwix + namespace: argocd +spec: + project: default + source: + repoURL: ssh://forgejo@indri.tail8d86e.ts.net:2200/eblume/blumeops.git + targetRevision: main + path: argocd/manifests/kiwix + destination: + server: https://kubernetes.default.svc + namespace: kiwix + syncPolicy: + syncOptions: + - CreateNamespace=true +``` + +--- + +## Deployment Sequence + +### Phase A: Storage Setup (Manual) + +1. **Configure SMB share on sifaka** (see Prerequisites section) +2. **Copy existing downloads:** + ```bash + ssh indri 'rsync -avP ~/transmission/ sifaka:/volume1/torrents/' + ``` +3. **Verify SMB access from indri:** + ```bash + # Test SMB mount via Finder or smbclient + smbclient -L //sifaka -U eblume + ``` + +### Phase B: Deploy Transmission to Kubernetes + +Deploy transmission first since kiwix depends on it. + +1. **Create feature branch** (if not already done) +2. **Add torrent manifests** to `argocd/manifests/torrent/` +3. **Add ArgoCD Application** to `argocd/apps/torrent.yaml` +4. **Push branch to forge** +5. **Sync ArgoCD apps:** + ```bash + argocd app sync apps + argocd app set torrent --revision feature/p6-kiwix + argocd app sync torrent + ``` +6. **Verify transmission deployment:** + ```bash + kubectl --context=minikube-indri -n torrent get pods + kubectl --context=minikube-indri -n torrent logs deployment/transmission + ``` +7. **Test transmission web UI:** + - Open https://torrent.tail8d86e.ts.net in browser + - Should see transmission web interface + +### Phase C: Deploy Kiwix to Kubernetes + +1. **Add kiwix manifests** to `argocd/manifests/kiwix/` +2. **Add ArgoCD Application** to `argocd/apps/kiwix.yaml` +3. **Push to forge** +4. **Sync ArgoCD:** + ```bash + argocd app set kiwix --revision feature/p6-kiwix + argocd app sync kiwix + ``` +5. **Verify kiwix deployment:** + ```bash + kubectl --context=minikube-indri -n kiwix get pods + kubectl --context=minikube-indri -n kiwix logs deployment/kiwix -c kiwix-serve + kubectl --context=minikube-indri -n kiwix logs deployment/kiwix -c torrent-sync + ``` + +### Phase D: Verification + +1. **Test kiwix access:** + ```bash + curl -s https://kiwix.tail8d86e.ts.net/ | head -20 + ``` +2. **Verify ZIM files are served:** + - Open https://kiwix.tail8d86e.ts.net in browser + - Should see library with existing ZIM archives +3. **Check transmission status via k8s:** + ```bash + kubectl --context=minikube-indri -n torrent exec deployment/transmission -- transmission-remote -l + ``` +4. **Verify torrent sync is working:** + ```bash + kubectl --context=minikube-indri -n kiwix logs deployment/kiwix -c torrent-sync + ``` +5. **Add a test torrent manually** via https://torrent.tail8d86e.ts.net to verify interactive use + +### Phase E: Cutover + +1. **Verify all services working correctly** +2. **Stop transmission on indri:** + ```bash + ssh indri 'brew services stop transmission-cli' + ``` +3. **Stop kiwix on indri:** + ```bash + ssh indri 'launchctl unload ~/Library/LaunchAgents/mcquack.eblume.kiwix-serve.plist' + ``` +4. **Clear kiwix Tailscale serve entry:** + ```bash + ssh indri 'tailscale serve status --json' + ssh indri 'tailscale serve clear svc:kiwix' + ``` +5. **Delete svc:kiwix device from Tailscale admin** (if needed to free hostname) +6. **Verify k8s services claim the hostnames:** + ```bash + curl -s https://kiwix.tail8d86e.ts.net/ + curl -s https://torrent.tail8d86e.ts.net/transmission/web/ + ``` + +### Phase F: Cleanup + +1. **Remove indri transmission/kiwix from ansible:** + - Remove `transmission` and `transmission_metrics` roles from `indri.yml` + - Remove `kiwix` role from `indri.yml` + - Remove `svc:kiwix` from `tailscale_serve` + - Remove transmission/kiwix log collection from `alloy` +2. **Run ansible to clean up:** + ```bash + mise run provision-indri -- --tags tailscale-serve,alloy + ``` +3. **Merge PR** after all verification +4. **Reset ArgoCD to main:** + ```bash + argocd app set torrent --revision main + argocd app sync torrent + argocd app set kiwix --revision main + argocd app sync kiwix + ``` + +--- + +## Adding New ZIM Archives (Declarative) + +To add a new ZIM archive via GitOps: + +1. **Find torrent URL** on https://download.kiwix.org/zim/ +2. **Add URL to ConfigMap** in `argocd/manifests/kiwix/configmap-zim-torrents.yaml` +3. **Commit and push** to feature branch +4. **Sync ArgoCD:** + ```bash + argocd app sync kiwix + ``` +5. **Wait for download** (check transmission at https://torrent.tail8d86e.ts.net) +6. **Kiwix restarts automatically** when ZIM watcher detects the new file (hourly) + - Or manually: `kubectl rollout restart deployment/kiwix -n kiwix` + +## Adding ZIM Archives (Manual/Interactive) + +Alternatively, add a ZIM torrent manually: + +1. **Open transmission web UI** at https://torrent.tail8d86e.ts.net +2. **Add torrent** via URL or file upload +3. **Wait for download** to complete +4. **Kiwix restarts automatically** when ZIM watcher detects the new file (hourly) + - Or manually: `kubectl rollout restart deployment/kiwix -n kiwix` + +Note: Manually added ZIM torrents are NOT tracked in git. If you want them to persist across cluster rebuilds, add them to the ConfigMap. + +## Adding Non-ZIM Torrents + +The transmission service is general-purpose: + +1. **Open transmission web UI** at https://torrent.tail8d86e.ts.net +2. **Add any torrent** (Linux ISOs, etc.) +3. **Downloads go to** `/volume1/torrents/` on sifaka SMB share +4. **Access downloads** via SMB mount or sifaka's file browser + +Non-ZIM downloads don't affect kiwix - it only serves `.zim` files. + +--- + +## Rollback Plan + +If migration fails: + +1. **Stop k8s services:** + ```bash + argocd app delete kiwix --cascade + argocd app delete torrent --cascade + kubectl delete namespace kiwix + kubectl delete namespace torrent + kubectl delete pv torrents-smb-pv + ``` +2. **Restart indri services:** + ```bash + ssh indri 'brew services start transmission-cli' + ssh indri 'launchctl load ~/Library/LaunchAgents/mcquack.eblume.kiwix-serve.plist' + ``` +3. **Re-enable Tailscale serve:** + ```bash + mise run provision-indri -- --tags tailscale-serve + ``` +4. **Verify access:** + ```bash + curl https://kiwix.tail8d86e.ts.net/ + ``` + +--- + +## Files Summary + +### New Files + +| Path | Purpose | +|------|---------| +| **Transmission (torrent namespace)** | | +| `argocd/apps/torrent.yaml` | ArgoCD Application for transmission | +| `argocd/manifests/torrent/pv-nfs.yaml` | Shared NFS PersistentVolume | +| `argocd/manifests/torrent/pvc.yaml` | Transmission PVC | +| `argocd/manifests/torrent/deployment.yaml` | Transmission deployment | +| `argocd/manifests/torrent/service.yaml` | Transmission service | +| `argocd/manifests/torrent/ingress-tailscale.yaml` | Tailscale Ingress for torrent.tail8d86e.ts.net | +| `argocd/manifests/torrent/kustomization.yaml` | Kustomize configuration | +| **Kiwix (kiwix namespace)** | | +| `argocd/apps/kiwix.yaml` | ArgoCD Application for kiwix | +| `argocd/manifests/kiwix/pvc.yaml` | Kiwix PVC (references shared PV) | +| `argocd/manifests/kiwix/configmap-zim-torrents.yaml` | Declarative ZIM torrent URL list | +| `argocd/manifests/kiwix/configmap-sync-script.yaml` | ZIM torrent sync script | +| `argocd/manifests/kiwix/deployment.yaml` | Kiwix deployment with sync sidecar | +| `argocd/manifests/kiwix/service.yaml` | Kiwix service | +| `argocd/manifests/kiwix/ingress-tailscale.yaml` | Tailscale Ingress for kiwix.tail8d86e.ts.net | +| `argocd/manifests/kiwix/cronjob-zim-watcher.yaml` | ZIM watcher CronJob + RBAC | +| `argocd/manifests/kiwix/kustomization.yaml` | Kustomize configuration | + +### Modified Files + +| Path | Change | +|------|--------| +| `ansible/playbooks/indri.yml` | Remove transmission, transmission_metrics, kiwix roles | +| `ansible/roles/tailscale_serve/defaults/main.yml` | Remove svc:kiwix | +| `ansible/roles/alloy/defaults/main.yml` | Remove transmission/kiwix log collection | + +### Roles Kept (not deleted) + +- `ansible/roles/transmission/` - Kept for reference +- `ansible/roles/transmission_metrics/` - Kept for reference +- `ansible/roles/kiwix/` - Kept for reference + +--- + +## Verification Checklist + +- [x] NFS export configured on sifaka (`/volume1/torrents`) +- [x] NFS exports allow 192.168.1.0/24 and 100.64.0.0/10 +- [x] Direct NFS mount from pod tested and working +- [ ] Existing downloads copied to sifaka +- [ ] Transmission pod running in k8s (`torrent` namespace) +- [ ] https://torrent.tail8d86e.ts.net accessible (web UI) +- [ ] Can add torrents manually via web UI +- [ ] Kiwix pod running in k8s (`kiwix` namespace) +- [ ] https://kiwix.tail8d86e.ts.net accessible +- [ ] All existing ZIM archives visible in kiwix +- [ ] Kiwix torrent-sync sidecar synced ZIMs to transmission +- [ ] ZIM watcher CronJob ran successfully +- [ ] Indri transmission stopped +- [ ] Indri kiwix stopped +- [ ] Tailscale hostname cutover complete (both services) +- [ ] Ansible playbook updated +- [ ] zk documentation updated diff --git a/plans/completed/k8s-migration/P7_forgejo.md b/plans/completed/k8s-migration/P7_forgejo.md new file mode 100644 index 0000000..f994d12 --- /dev/null +++ b/plans/completed/k8s-migration/P7_forgejo.md @@ -0,0 +1,394 @@ +# Phase 7: Forgejo Migration to Kubernetes + +**Goal**: Migrate Forgejo from indri (macOS Homebrew) to Kubernetes via ArgoCD + +**Status**: Planning (2026-01-21) + +**Prerequisites**: [Phase 6](P6_kiwix.complete.md) complete + +--- + +## Critical Risks & Mitigations + +### 1. Circular Dependency (Highest Risk) + +ArgoCD pulls manifests from Forgejo. If k8s Forgejo fails, we cannot redeploy it. + +**Mitigation**: blumeops is mirrored to `github.com/eblume/blumeops`. DR procedure documented to switch ArgoCD to GitHub temporarily (see Disaster Recovery section). + +### 2. Split Hostnames Required + +The Tailscale k8s operator [cannot expose both HTTPS and TCP/SSH on the same hostname](https://github.com/tailscale/tailscale/issues/15539). See also [user comment](https://github.com/tailscale/tailscale/issues/15539#issuecomment-3782368432). + +**Solution**: +- **HTTPS (web UI)**: `forge.tail8d86e.ts.net` via Tailscale Ingress +- **SSH (git operations)**: `git.tail8d86e.ts.net` via Tailscale LoadBalancer + +--- + +## Current State + +### Forgejo on indri + +| Component | Location/Details | +|-----------|------------------| +| Data directory | `/opt/homebrew/var/forgejo/` (~426MB) | +| SQLite database | `/opt/homebrew/var/forgejo/data/forgejo.db` (4.1MB) | +| Git repositories | `/opt/homebrew/var/forgejo/data/forgejo-repositories/` (~418MB) | +| Configuration | `/opt/homebrew/var/forgejo/custom/conf/app.ini` (contains secrets) | +| HTTP port | 3001 (localhost) | +| SSH port | 2200 (localhost) | +| Tailscale | `svc:forge` with tcp:22→2200 and https:443→3001 | +| Backup | borgmatic backs up to sifaka | + +### Hosted Repositories (8 total) + +- blumeops (mirrored to GitHub) +- cloudnative-pg-charts +- csi-driver-smb +- devpi +- dotfiles +- grafana-helm-charts +- mcquack +- zot + +--- + +## Architecture Decision: Helm Chart via ArgoCD + +Following established pattern from cloudnative-pg and grafana: +1. Mirror `https://code.forgejo.org/forgejo-helm/forgejo-helm` to forge +2. ArgoCD Application with multi-source (chart + values) +3. Values file in `argocd/manifests/forgejo/values.yaml` + +--- + +## All `forge` References Requiring Update + +### SSH URLs (change to `git.tail8d86e.ts.net:22`) + +| File | Current | After | +|------|---------|-------| +| `argocd/apps/apps.yaml` | `ssh://forgejo@indri.tail8d86e.ts.net:2200/...` | `ssh://forgejo@git.tail8d86e.ts.net/...` | +| `argocd/apps/argocd.yaml` | same | same | +| `argocd/apps/blumeops-pg.yaml` | same | same | +| `argocd/apps/cloudnative-pg.yaml` | same | same | +| `argocd/apps/devpi.yaml` | same | same | +| `argocd/apps/grafana.yaml` | same | same | +| `argocd/apps/grafana-config.yaml` | same | same | +| `argocd/apps/kiwix.yaml` | same | same | +| `argocd/apps/miniflux.yaml` | same | same | +| `argocd/apps/tailscale-operator.yaml` | same | same | +| `argocd/apps/torrent.yaml` | same | same | +| `argocd/manifests/argocd/repo-forge-secret.yaml.tpl` | `ssh://forgejo@indri.tail8d86e.ts.net:2200/eblume/` | `ssh://forgejo@git.tail8d86e.ts.net/eblume/` | +| `ansible/group_vars/all.yml` | `ssh://forgejo@forge.tail8d86e.ts.net/...` | `ssh://forgejo@git.tail8d86e.ts.net/...` | + +### SSH Known Hosts (add `git.tail8d86e.ts.net`) + +| File | Change | +|------|--------| +| `argocd/manifests/argocd/argocd-ssh-known-hosts-cm.yaml` | Add `git.tail8d86e.ts.net ssh-ed25519 AAAA...` | + +### HTTPS URLs (stay as `forge.tail8d86e.ts.net`) + +These remain unchanged: +- `CLAUDE.md:135` - Mirror location +- `mise-tasks/pr-comments:23` - Forge API base +- `mise-tasks/indri-services-check:65` - HTTP health check (update to check k8s) + +### Ansible/Indri Cleanup (remove after migration) + +| File | Action | +|------|--------| +| `ansible/playbooks/indri.yml:36-37` | Remove forgejo role | +| `ansible/roles/tailscale_serve/defaults/main.yml:6` | Remove `svc:forge` entry | +| `ansible/roles/alloy/defaults/main.yml:31-32` | Remove forgejo log collection | +| `ansible/roles/borgmatic/defaults/main.yml:17` | Update backup path | + +### Tailscale/Pulumi (update after hostname cutover) + +| File | Change | +|------|--------| +| `argocd/manifests/tailscale-operator/egress-forge.yaml` | Delete (no longer needed) | +| `pulumi/policy.hujson` | Update `tag:forge` ACLs for k8s source | + +--- + +## Pre-Migration Checklist + +- [ ] GitHub mirror verified current +- [ ] Full borgmatic backup completed and verified +- [ ] Manual backup of `/opt/homebrew/var/forgejo` on indri +- [ ] Document all SSH deploy keys and webhooks +- [ ] **User action**: Mirror forgejo-helm chart to forge +- [ ] Extract secrets from app.ini to 1Password: + - `INTERNAL_TOKEN` + - `SECRET_KEY` + - `JWT_SECRET` + - Any OAuth/webhook secrets + +--- + +## Steps + +### Phase A: Create k8s Manifests + +**New Files:** +``` +argocd/apps/forgejo.yaml # ArgoCD Application (multi-source Helm) +argocd/manifests/forgejo/values.yaml # Helm chart values +argocd/manifests/forgejo/kustomization.yaml # Kustomize config +argocd/manifests/forgejo/pvc.yaml # 10Gi PersistentVolumeClaim +argocd/manifests/forgejo/secret-app.yaml.tpl # Secrets from 1Password +``` + +**Key values.yaml settings:** +```yaml +service: + ssh: + type: LoadBalancer + loadBalancerClass: tailscale + port: 22 + annotations: + tailscale.com/hostname: "git-1" # Test hostname first + +ingress: + enabled: true + className: tailscale + hosts: + - host: forge-1 # Test hostname first + +gitea: + config: + server: + DOMAIN: forge-1.tail8d86e.ts.net + ROOT_URL: https://forge-1.tail8d86e.ts.net/ + SSH_DOMAIN: git-1.tail8d86e.ts.net + SSH_PORT: 22 + database: + DB_TYPE: sqlite3 + PATH: /data/forgejo.db +``` + +--- + +### Phase B: Deploy to Test Hostnames + +1. Create feature branch, push to forge +2. Sync ArgoCD apps: `argocd app sync apps` +3. Point forgejo app to feature branch: `argocd app set forgejo --revision feature/p7-forgejo` +4. Sync forgejo app: `argocd app sync forgejo` +5. Verify pods running (empty data initially) + +--- + +### Phase C: Data Migration (~10 min downtime) + +1. **Stop indri Forgejo** + ```bash + ssh indri 'brew services stop forgejo' + ``` + +2. **Copy data** (option A: rsync via NFS staging) + ```bash + ssh indri 'rsync -avP /opt/homebrew/var/forgejo/ sifaka:/volume1/forgejo-migration/' + ``` + +3. **Copy to PVC and fix permissions** + ```bash + kubectl exec -n forgejo deployment/forgejo -- rsync -avP /staging/ /data/ + kubectl exec -n forgejo deployment/forgejo -- chown -R 1000:1000 /data + ``` + +4. **Restart Forgejo** + ```bash + kubectl rollout restart deployment/forgejo -n forgejo + ``` + +--- + +### Phase D: Validation (Critical) + +- [ ] Web UI accessible at `forge-1.tail8d86e.ts.net` +- [ ] SSH works: `ssh -T forgejo@git-1.tail8d86e.ts.net` +- [ ] All 8 repos visible and accessible +- [ ] Git clone works +- [ ] Git push works (test on non-critical repo) +- [ ] eblume user preserved with correct permissions +- [ ] PR history intact +- [ ] Webhooks functioning +- [ ] GitHub mirror push still works + +--- + +### Phase E: Hostname Cutover + +1. **Clear indri Tailscale serve** + ```bash + ssh indri 'tailscale serve clear svc:forge' + ``` + +2. **User action**: Delete `svc:forge` and `forge-1` devices from Tailscale admin + +3. **Update manifests**: Change `forge-1` → `forge`, `git-1` → `git` + +4. **Sync ArgoCD** + +5. **Verify hostnames claimed** + ```bash + curl https://forge.tail8d86e.ts.net/api/v1/version + ssh -T forgejo@git.tail8d86e.ts.net + ``` + +--- + +### Phase F: Update ArgoCD to Use New Forgejo + +1. **Get SSH host key from k8s Forgejo** + ```bash + kubectl exec -n forgejo deployment/forgejo -- cat /data/ssh/ssh_host_ed25519_key.pub + ``` + +2. **Update known_hosts ConfigMap** with `git.tail8d86e.ts.net` key + +3. **Update repo-creds-forge secret** (manual kubectl commands) + +4. **Update all ArgoCD Application manifests** with new repoURL + +5. **Delete egress-forge.yaml** (no longer needed) + +6. **Sync ArgoCD** and verify all apps sync successfully + +--- + +### Phase G: Update Local Git Remotes + +```bash +cd ~/code/personal/blumeops +git remote set-url origin ssh://forgejo@git.tail8d86e.ts.net/eblume/blumeops.git +# Repeat for all 8 repos +``` + +--- + +### Phase H: Cleanup + +1. Remove forgejo role from `ansible/playbooks/indri.yml` +2. Remove `svc:forge` from `ansible/roles/tailscale_serve/defaults/main.yml` +3. Remove forgejo log collection from `ansible/roles/alloy/defaults/main.yml` +4. Delete `argocd/manifests/tailscale-operator/egress-forge.yaml` +5. Update `mise-tasks/indri-services-check` +6. Run ansible to clean up indri: `mise run provision-indri -- --tags tailscale-serve,alloy` +7. Update zk documentation (forgejo, argocd, blumeops cards) +8. Merge PR +9. Reset ArgoCD to main + +--- + +## Disaster Recovery Procedure + +**Add to [[forgejo]] zk card:** + +### When Forgejo is Unavailable + +1. **Add GitHub repository to ArgoCD** + ```bash + argocd repo add https://github.com/eblume/blumeops.git \ + --username eblume \ + --password $(op read "op:////github-pat") + ``` + +2. **Point critical apps to GitHub** + ```bash + argocd app set apps --repo https://github.com/eblume/blumeops.git + argocd app set forgejo --repo https://github.com/eblume/blumeops.git + argocd app sync forgejo + ``` + +3. **Fix Forgejo** (restore from backup, fix config, etc.) + +4. **Verify Forgejo is healthy** + ```bash + curl https://forge.tail8d86e.ts.net/api/v1/version + ssh -T forgejo@git.tail8d86e.ts.net + ``` + +5. **Switch back to Forgejo** + ```bash + argocd app set apps --repo ssh://forgejo@git.tail8d86e.ts.net/eblume/blumeops.git + argocd app set forgejo --repo ssh://forgejo@git.tail8d86e.ts.net/eblume/blumeops.git + argocd app sync apps + argocd repo rm https://github.com/eblume/blumeops.git + ``` + +--- + +## Files Summary + +### New Files + +| Path | Purpose | +|------|---------| +| `argocd/apps/forgejo.yaml` | ArgoCD Application (multi-source Helm) | +| `argocd/manifests/forgejo/values.yaml` | Helm chart values | +| `argocd/manifests/forgejo/kustomization.yaml` | Kustomize config | +| `argocd/manifests/forgejo/pvc.yaml` | 10Gi PersistentVolumeClaim | +| `argocd/manifests/forgejo/secret-app.yaml.tpl` | Secrets template | + +### Modified Files + +| Path | Change | +|------|--------| +| All `argocd/apps/*.yaml` | Update repoURL to `git.tail8d86e.ts.net` | +| `argocd/manifests/argocd/argocd-ssh-known-hosts-cm.yaml` | Add `git.tail8d86e.ts.net` | +| `argocd/manifests/argocd/repo-forge-secret.yaml.tpl` | Update URL | +| `ansible/playbooks/indri.yml` | Remove forgejo role | +| `ansible/roles/tailscale_serve/defaults/main.yml` | Remove `svc:forge` | +| `ansible/roles/alloy/defaults/main.yml` | Remove forgejo logs | + +### Files to Delete + +| Path | Reason | +|------|--------| +| `argocd/manifests/tailscale-operator/egress-forge.yaml` | No longer needed | + +--- + +## Rollback + +If migration fails at any point: + +1. **Delete k8s resources** + ```bash + argocd app delete forgejo --cascade + kubectl delete namespace forgejo + ``` + +2. **Restart indri Forgejo** + ```bash + ssh indri 'brew services start forgejo' + ``` + +3. **Re-enable Tailscale serve** + ```bash + mise run provision-indri -- --tags tailscale-serve + ``` + +4. **Revert ArgoCD apps to indri URLs** (if changed) + +--- + +## Verification Checklist + +- [ ] GitHub mirror verified current +- [ ] Helm chart mirrored to forge +- [ ] Secrets extracted to 1Password +- [ ] k8s Forgejo pod running +- [ ] All 8 repos accessible +- [ ] SSH clone/push works via `git.tail8d86e.ts.net` +- [ ] HTTPS works via `forge.tail8d86e.ts.net` +- [ ] ArgoCD syncs from new URL +- [ ] All local remotes updated +- [ ] Indri cleanup complete +- [ ] zk docs updated +- [ ] DR procedure documented in [[forgejo]] card diff --git a/plans/completed/k8s-migration/P8_woodpecker.md b/plans/completed/k8s-migration/P8_woodpecker.md new file mode 100644 index 0000000..904398e --- /dev/null +++ b/plans/completed/k8s-migration/P8_woodpecker.md @@ -0,0 +1,32 @@ +# Phase 8: CI/CD (Woodpecker) + +**Goal**: Deploy Woodpecker CI integrated with Forgejo + +**Status**: Pending + +**Prerequisites**: [Phase 7](P7_forgejo.md) complete + +--- + +## Steps + +### 1. Create Forgejo OAuth application + +- Callback: https://ci.tail8d86e.ts.net/authorize +- Store in 1Password + +--- + +### 2. Deploy Woodpecker Server + Agent + +--- + +### 3. Configure Tailscale LoadBalancer + +Tag: `svc:ci` + +--- + +### 4. Test pipeline + +Create `.woodpecker.yaml` in test repo diff --git a/plans/completed/k8s-migration/P9_cleanup.md b/plans/completed/k8s-migration/P9_cleanup.md new file mode 100644 index 0000000..9178b01 --- /dev/null +++ b/plans/completed/k8s-migration/P9_cleanup.md @@ -0,0 +1,52 @@ +# Phase 9: Cleanup + +**Goal**: Remove deprecated services, harden system + +**Status**: Pending + +**Prerequisites**: [Phase 8](P8_woodpecker.md) complete + +--- + +## Steps + +### 1. Stop/remove unused brew services + +- postgresql@18 +- grafana +- miniflux +- forgejo + +--- + +### 2. Update ansible playbook + +- Remove migrated service roles +- Add k8s deployment references + +--- + +### 3. Configure Velero backups (optional) + +- Install with MinIO on sifaka +- Schedule daily cluster backups + +--- + +### 4. Update zk documentation + +- New architecture +- Runbooks +- DR procedures + +--- + +## Plan Completion + +When all phases are complete and verified: + +```bash +# Rename this folder to indicate completion +git mv plans/k8s-migration plans/k8s-migration.complete +git commit -m "Complete k8s migration plan" +``` diff --git a/prek.toml b/prek.toml deleted file mode 100644 index 2c66b82..0000000 --- a/prek.toml +++ /dev/null @@ -1,194 +0,0 @@ -# prek.toml - Git hooks configuration -# Run: prek run --all-files -# Install: prek install && prek install --hook-type commit-msg - -# Built-in hooks (fast, Rust-native — no external dependencies) -[[repos]] -repo = "builtin" -hooks = [ - { id = "trailing-whitespace" }, - { id = "end-of-file-fixer" }, - { id = "check-added-large-files", args = [ - "--maxkb=1000", - ] }, - { id = "check-merge-conflict" }, - { id = "check-json" }, - { id = "check-toml" }, - { id = "check-case-conflict" }, - { id = "detect-private-key" }, - { id = "check-executables-have-shebangs" }, -] - -# check-yaml with --unsafe (builtin fast path doesn't support --unsafe yet) -[[repos]] -repo = "https://github.com/pre-commit/pre-commit-hooks" -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 = "37b77001d0174ebec2fcca2bd83ff83a6d45a3ab" # v3.95.3 -hooks = [ - { id = "trufflehog", entry = "trufflehog git file://. --since-commit HEAD --no-verification --fail", stages = [ - "pre-commit", - "pre-push", - ] }, -] - -[[repos]] -repo = "https://github.com/mongodb/kingfisher" -rev = "6f560103cc6ea082ef4b80a9098e3f3111afb8bc" # v1.101.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" -rev = "cba56bcde1fdd01c1deb3f945e69764c291a6530" # v1.38.0 -hooks = [{ id = "yamllint", args = ["-c", ".yamllint.yaml"] }] - -# Ansible linting -[[repos]] -repo = "local" - -[[repos.hooks]] -id = "ansible-lint" -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.21.0"] - -# Python - ruff for linting and formatting -[[repos]] -repo = "https://github.com/astral-sh/ruff-pre-commit" -rev = "0c7b6c989466a93942def1f84baf36ddfcd60c83" # v0.15.14 -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" -rev = "745eface02aef23e168a8afb6b5737818efbea95" # v0.11.0.1 -hooks = [{ id = "shellcheck", args = ["--severity=warning"] }] - -[[repos]] -repo = "https://github.com/scop/pre-commit-shfmt" -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 = "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 = "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 = "914e7df21a07ef503a81201c76d2b11c789d3fca" # v1.7.12 -hooks = [ - { id = "actionlint-system", args = [ - "-config-file", - ".github/actionlint.yaml", - ], files = '\.forgejo/workflows/' }, -] - -# Custom local hooks - -# Forgejo workflow schema validation (via Dagger + forgejo-runner validate) -[[repos]] -repo = "local" - -[[repos.hooks]] -id = "validate-workflows" -name = "validate-workflows" -entry = "mise run validate-workflows" -language = "system" -files = '\.forgejo/workflows/' -pass_filenames = false - -# Container version consistency -[[repos]] -repo = "local" - -[[repos.hooks]] -id = "container-version-check" -name = "container-version-check" -entry = "mise run container-version-check" -language = "system" -files = "^(containers/|service-versions\\.yaml)" -pass_filenames = false - -# Changelog fragment validation (no subdirectories) -[[repos]] -repo = "local" - -[[repos.hooks]] -id = "changelog-check" -name = "changelog-check" -entry = "mise run changelog-check" -language = "system" -files = '^docs/changelog\.d/' -pass_filenames = false - -# Mikado Branch Invariant (C2 changes) -[[repos]] -repo = "local" - -[[repos.hooks]] -id = "mikado-branch-invariant-check" -name = "mikado-branch-invariant-check" -entry = "mise run mikado-branch-invariant-check" -language = "system" -always_run = true -stages = ["commit-msg"] - -# Documentation validation -[[repos]] -repo = "local" - -[[repos.hooks]] -id = "docs-check-links" -name = "docs-check-links" -entry = "mise run docs-check-links" -language = "system" -files = '^docs/.*\.md$' -pass_filenames = false - -[[repos.hooks]] -id = "docs-check-frontmatter" -name = "docs-check-frontmatter" -entry = "mise run docs-check-frontmatter" -language = "system" -files = '^docs/.*\.md$' -pass_filenames = false diff --git a/pulumi/tailscale/.gitignore b/pulumi/.gitignore similarity index 79% rename from pulumi/tailscale/.gitignore rename to pulumi/.gitignore index 36cb85b..01a30d0 100644 --- a/pulumi/tailscale/.gitignore +++ b/pulumi/.gitignore @@ -3,5 +3,8 @@ __pycache__/ *.py[cod] +# uv +uv.lock + # Pulumi *.pyc diff --git a/pulumi/tailscale/Pulumi.tail8d86e.yaml b/pulumi/Pulumi.tail8d86e.yaml similarity index 100% rename from pulumi/tailscale/Pulumi.tail8d86e.yaml rename to pulumi/Pulumi.tail8d86e.yaml diff --git a/pulumi/tailscale/Pulumi.yaml b/pulumi/Pulumi.yaml similarity index 100% rename from pulumi/tailscale/Pulumi.yaml rename to pulumi/Pulumi.yaml diff --git a/pulumi/tailscale/__main__.py b/pulumi/__main__.py similarity index 71% rename from pulumi/tailscale/__main__.py rename to pulumi/__main__.py index 3acbb62..7c76c26 100644 --- a/pulumi/tailscale/__main__.py +++ b/pulumi/__main__.py @@ -5,7 +5,7 @@ This program manages: - Device tags for infrastructure classification Devices are tagged based on their role: -- tag:homelab - Server infrastructure (indri, ringtail) +- tag:homelab - Server infrastructure (indri) - tag:workstation - Development machines that can manage homelab (gilbert) - tag:nas - Network-attached storage (sifaka) - tag:blumeops - Resources managed by this IaC @@ -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, etc.) run in k8s with their own Tailscale devices. +# Other services (grafana, kiwix, devpi, etc.) run in k8s with their own Tailscale devices. indri = tailscale.get_device(name="indri.tail8d86e.ts.net") indri_tags = tailscale.DeviceTags( "indri-tags", @@ -50,7 +50,6 @@ 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 ], ) @@ -71,40 +70,12 @@ sifaka_tags = tailscale.DeviceTags( ], ) -# ringtail - NixOS gaming/compute workstation -# Managed by this IaC after initial bootstrap via auth key. -ringtail = tailscale.get_device(name="ringtail.tail8d86e.ts.net") -ringtail_tags = tailscale.DeviceTags( - "ringtail-tags", - device_id=ringtail.node_id, - tags=[ - "tag:homelab", # Server role - allows SSH from workstations and homelab peers - "tag:blumeops", # Managed by this IaC - ], -) - -# ============== Auth Keys ============== - -# Auth key for Fly.io proxy container (public reverse proxy) -flyio_key = tailscale.TailnetKey( - "flyio-proxy-key", - reusable=True, - ephemeral=True, - preauthorized=True, - tags=["tag:flyio-proxy"], - expiry=7776000, # 90 days -) - # ============== Exports ============== pulumi.export("acl_id", acl.id) pulumi.export("policy_hash", policy_hash) -pulumi.export("flyio_authkey", flyio_key.key) pulumi.export("indri_device_id", indri.node_id) pulumi.export("indri_tags", indri_tags.tags) pulumi.export("sifaka_device_id", sifaka.node_id) pulumi.export("sifaka_tags", sifaka_tags.tags) - -pulumi.export("ringtail_device_id", ringtail.node_id) -pulumi.export("ringtail_tags", ringtail_tags.tags) diff --git a/pulumi/gandi/.gitignore b/pulumi/gandi/.gitignore deleted file mode 100644 index 21d0b89..0000000 --- a/pulumi/gandi/.gitignore +++ /dev/null @@ -1 +0,0 @@ -.venv/ diff --git a/pulumi/gandi/Pulumi.eblu-me.yaml b/pulumi/gandi/Pulumi.eblu-me.yaml deleted file mode 100644 index 7c62cd9..0000000 --- a/pulumi/gandi/Pulumi.eblu-me.yaml +++ /dev/null @@ -1,6 +0,0 @@ ---- -config: - blumeops-dns:domain: eblu.me - blumeops-dns:subdomain: ops - # The target IP is resolved dynamically from indri.tail8d86e.ts.net - # indri hosts Caddy, which reverse-proxies all blumeops services diff --git a/pulumi/gandi/Pulumi.yaml b/pulumi/gandi/Pulumi.yaml deleted file mode 100644 index 81e7215..0000000 --- a/pulumi/gandi/Pulumi.yaml +++ /dev/null @@ -1,7 +0,0 @@ ---- -name: blumeops-dns -runtime: - name: python - options: - toolchain: uv -description: DNS configuration for eblu.me via Gandi LiveDNS diff --git a/pulumi/gandi/README.md b/pulumi/gandi/README.md deleted file mode 100644 index 70d2821..0000000 --- a/pulumi/gandi/README.md +++ /dev/null @@ -1,67 +0,0 @@ -# Gandi DNS Management - -This Pulumi project manages DNS records for `eblu.me` via Gandi LiveDNS. - -## What It Does - -Creates DNS records that point `*.ops.eblu.me` to indri's Tailscale IP. - -**Why indri?** indri hosts Caddy, the reverse proxy for all blumeops services. -All `*.ops.eblu.me` requests route through Caddy, which proxies to the appropriate -backend service (either on indri itself or in the k8s cluster). - -Since Tailscale IPs (100.x.x.x) are not routable on the public internet, these -DNS records effectively make services accessible only from within the tailnet, -while still using real, resolvable DNS names. - -The target IP is resolved dynamically from `indri.tail8d86e.ts.net` at deploy time, -so if indri's Tailscale IP changes, just re-run the deployment. - -## Setup - -```bash -cd pulumi/gandi -uv sync -pulumi stack select eblu-me # or: pulumi stack init eblu-me -``` - -## Authentication - -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 mise tasks handle fetching the PAT from 1Password: - -```bash -mise run dns-preview # Preview only -mise run dns-up # Preview and apply -``` - -Or manually: - -```bash -export GANDI_PERSONAL_ACCESS_TOKEN=$(op read "op://blumeops/gandi - blumeops/pat") -pulumi up -``` - -## DNS Records Created - -| Record | Type | Value | Purpose | -|--------|------|-------|---------| -| `*.ops.eblu.me` | A | (indri's Tailscale IP) | Wildcard for all services | -| `ops.eblu.me` | A | (indri's Tailscale IP) | Base subdomain | - -## Service Hostnames - -Once Caddy is configured on indri, services will be accessible at: - -- `forge.ops.eblu.me` - Forgejo git server -- `registry.ops.eblu.me` - Zot container registry -- `grafana.ops.eblu.me` - Grafana dashboards -- `argocd.ops.eblu.me` - ArgoCD -- `feed.ops.eblu.me` - Miniflux RSS reader -- `pypi.ops.eblu.me` - DevPI Python index -- `kiwix.ops.eblu.me` - Kiwix offline content -- `tesla.ops.eblu.me` - TeslaMate -- `torrent.ops.eblu.me` - Transmission -- `prometheus.ops.eblu.me` - Prometheus metrics -- `loki.ops.eblu.me` - Loki logs diff --git a/pulumi/gandi/__main__.py b/pulumi/gandi/__main__.py deleted file mode 100644 index 25fd0f7..0000000 --- a/pulumi/gandi/__main__.py +++ /dev/null @@ -1,105 +0,0 @@ -"""Pulumi program to manage eblu.me DNS via Gandi LiveDNS. - -This program manages DNS records for blumeops infrastructure: -- Wildcard record for *.ops.eblu.me pointing to indri's Tailscale IP -- indri hosts Caddy as the reverse proxy for all services -- This allows services to be accessed via real DNS names while remaining - tailnet-only (Tailscale IPs are not publicly routable) - -Authentication: - Set GANDI_PERSONAL_ACCESS_TOKEN environment variable. - See docs/how-to/configuration/rotate-gandi-pat.md for PAT management. -""" - -import os -import socket - -import pulumi -import pulumiverse_gandi as gandi - -# Get configuration -config = pulumi.Config() -domain = config.require("domain") # eblu.me -subdomain = config.require("subdomain") # ops - -# Resolve indri's Tailscale IP dynamically via MagicDNS -# This script runs on the tailnet, so we can resolve the hostname directly. -# indri hosts Caddy, which reverse-proxies all services. -# Break-glass: set BLUMEOPS_REVERSE_PROXY_IP env var to override DNS resolution -REVERSE_PROXY_HOST = "indri.tail8d86e.ts.net" -tailscale_ip = os.environ.get("BLUMEOPS_REVERSE_PROXY_IP") or socket.gethostbyname( - REVERSE_PROXY_HOST -) - -# Wildcard A record for *.ops.eblu.me -# Points to indri's Tailscale IP, which is only routable within the tailnet. -# This allows containers and other systems to resolve real DNS names -# while keeping services private to the tailnet. -wildcard_record = gandi.livedns.Record( - "ops-wildcard", - zone=domain, - name=f"*.{subdomain}", - type="A", - ttl=300, - values=[tailscale_ip], -) - -# Base subdomain record (ops.eblu.me) - same IP -base_record = gandi.livedns.Record( - "ops-base", - zone=domain, - name=subdomain, - type="A", - ttl=300, - values=[tailscale_ip], -) - -# ============== Public Services (Fly.io proxy) ============== -# CNAME records pointing public subdomains to Fly.io for reverse proxying -# back to the tailnet. See docs/how-to/expose-service-publicly.md - -docs_public = gandi.livedns.Record( - "docs-public", - zone=domain, - name="docs", - type="CNAME", - ttl=300, - values=["blumeops-proxy.fly.dev."], -) - -cv_public = gandi.livedns.Record( - "cv-public", - zone=domain, - name="cv", - type="CNAME", - ttl=300, - values=["blumeops-proxy.fly.dev."], -) - -forge_public = gandi.livedns.Record( - "forge-public", - zone=domain, - name="forge", - type="CNAME", - ttl=300, - 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}") -pulumi.export("base_fqdn", f"{subdomain}.{domain}") -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/pulumi/gandi/pyproject.toml b/pulumi/gandi/pyproject.toml deleted file mode 100644 index 472c93a..0000000 --- a/pulumi/gandi/pyproject.toml +++ /dev/null @@ -1,5 +0,0 @@ -[project] -name = "blumeops-dns" -version = "0.1.0" -requires-python = ">=3.11" -dependencies = ["pulumi>=3.0.0", "pulumiverse-gandi>=2.3.0"] diff --git a/pulumi/gandi/uv.lock b/pulumi/gandi/uv.lock deleted file mode 100644 index dad6981..0000000 --- a/pulumi/gandi/uv.lock +++ /dev/null @@ -1,265 +0,0 @@ -version = 1 -revision = 3 -requires-python = ">=3.11" -resolution-markers = [ - "python_full_version >= '3.14'", - "python_full_version < '3.14'", -] - -[[package]] -name = "arpeggio" -version = "2.0.3" -source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } -sdist = { url = "https://pypi.ops.eblu.me/root/pypi/+f/9e8/5ad35cfc6c938/Arpeggio-2.0.3.tar.gz", hash = "sha256:9e85ad35cfc6c938676817c7ae9a1000a7c72a34c71db0c687136c460d12b85e" } -wheels = [ - { url = "https://pypi.ops.eblu.me/root/pypi/+f/937/4d9c531b62018/Arpeggio-2.0.3-py2.py3-none-any.whl", hash = "sha256:9374d9c531b62018b787635f37fd81c9a6ee69ef2d28c5db3cd18791b1f7db2f" }, -] - -[[package]] -name = "attrs" -version = "25.4.0" -source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } -sdist = { url = "https://pypi.ops.eblu.me/root/pypi/+f/16d/5969b87f0859e/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11" } -wheels = [ - { url = "https://pypi.ops.eblu.me/root/pypi/+f/adc/f7e2a1fb3b36a/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373" }, -] - -[[package]] -name = "blumeops-dns" -version = "0.1.0" -source = { virtual = "." } -dependencies = [ - { name = "pulumi" }, - { name = "pulumiverse-gandi" }, -] - -[package.metadata] -requires-dist = [ - { name = "pulumi", specifier = ">=3.0.0" }, - { name = "pulumiverse-gandi", specifier = ">=2.3.0" }, -] - -[[package]] -name = "debugpy" -version = "1.8.19" -source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } -sdist = { url = "https://pypi.ops.eblu.me/root/pypi/+f/eea/7e5987445ab0b/debugpy-1.8.19.tar.gz", hash = "sha256:eea7e5987445ab0b5ed258093722d5ecb8bb72217c5c9b1e21f64efe23ddebdb" } -wheels = [ - { url = "https://pypi.ops.eblu.me/root/pypi/+f/c5d/cfa21de1f735a/debugpy-1.8.19-cp311-cp311-macosx_15_0_universal2.whl", hash = "sha256:c5dcfa21de1f735a4f7ced4556339a109aa0f618d366ede9da0a3600f2516d8b" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/806/d680024624400/debugpy-1.8.19-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:806d6800246244004625d5222d7765874ab2d22f3ba5f615416cf1342d61c488" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/783/a519e6dfb1f3c/debugpy-1.8.19-cp311-cp311-win32.whl", hash = "sha256:783a519e6dfb1f3cd773a9bda592f4887a65040cb0c7bd38dde410f4e53c40d4" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/140/35cbdbb1fe4b6/debugpy-1.8.19-cp311-cp311-win_amd64.whl", hash = "sha256:14035cbdbb1fe4b642babcdcb5935c2da3b1067ac211c5c5a8fdc0bb31adbcaa" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/bcc/b1540a49cde77/debugpy-1.8.19-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:bccb1540a49cde77edc7ce7d9d075c1dbeb2414751bc0048c7a11e1b597a4c2e" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/e9c/68d9a382ec754/debugpy-1.8.19-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:e9c68d9a382ec754dc05ed1d1b4ed5bd824b9f7c1a8cd1083adb84b3c93501de" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/659/9cab8a783d149/debugpy-1.8.19-cp312-cp312-win32.whl", hash = "sha256:6599cab8a783d1496ae9984c52cb13b7c4a3bd06a8e6c33446832a5d97ce0bee" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/66e/3d2fd8f2035a8/debugpy-1.8.19-cp312-cp312-win_amd64.whl", hash = "sha256:66e3d2fd8f2035a8f111eb127fa508469dfa40928a89b460b41fd988684dc83d" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/91e/35db2672a0aba/debugpy-1.8.19-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:91e35db2672a0abaf325f4868fcac9c1674a0d9ad9bb8a8c849c03a5ebba3e6d" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/850/16a73ab84dea1/debugpy-1.8.19-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:85016a73ab84dea1c1f1dcd88ec692993bcbe4532d1b49ecb5f3c688ae50c606" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/b60/5f17e89ba0ece/debugpy-1.8.19-cp313-cp313-win32.whl", hash = "sha256:b605f17e89ba0ecee994391194285fada89cee111cfcd29d6f2ee11cbdc40976" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/c30/639998a9f9cd9/debugpy-1.8.19-cp313-cp313-win_amd64.whl", hash = "sha256:c30639998a9f9cd9699b4b621942c0179a6527f083c72351f95c6ab1728d5b73" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/1e8/c4d1bd230067b/debugpy-1.8.19-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:1e8c4d1bd230067bf1bbcdbd6032e5a57068638eb28b9153d008ecde288152af" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/d40/c016c1f538dbf/debugpy-1.8.19-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:d40c016c1f538dbf1762936e3aeb43a89b965069d9f60f9e39d35d9d25e6b809" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/060/1708223fe1cd0/debugpy-1.8.19-cp314-cp314-win32.whl", hash = "sha256:0601708223fe1cd0e27c6cce67a899d92c7d68e73690211e6788a4b0e1903f5b" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/8e1/9a725f5d486f2/debugpy-1.8.19-cp314-cp314-win_amd64.whl", hash = "sha256:8e19a725f5d486f20e53a1dde2ab8bb2c9607c40c00a42ab646def962b41125f" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/360/ffd231a780abb/debugpy-1.8.19-py2.py3-none-any.whl", hash = "sha256:360ffd231a780abbc414ba0f005dad409e71c78637efe8f2bd75837132a41d38" }, -] - -[[package]] -name = "dill" -version = "0.4.1" -source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } -sdist = { url = "https://pypi.ops.eblu.me/root/pypi/+f/423/092df4182177d/dill-0.4.1.tar.gz", hash = "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa" } -wheels = [ - { url = "https://pypi.ops.eblu.me/root/pypi/+f/1e1/ce33e978ae97f/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d" }, -] - -[[package]] -name = "grpcio" -version = "1.76.0" -source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://pypi.ops.eblu.me/root/pypi/+f/7be/78388d6da1a25/grpcio-1.76.0.tar.gz", hash = "sha256:7be78388d6da1a25c0d5ec506523db58b18be22d9c37d8d3a32c08be4987bd73" } -wheels = [ - { url = "https://pypi.ops.eblu.me/root/pypi/+f/2e1/743fbd7f5fa71/grpcio-1.76.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:2e1743fbd7f5fa713a1b0a8ac8ebabf0ec980b5d8809ec358d488e273b9cf02a" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/a8c/2cf1209497cf6/grpcio-1.76.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:a8c2cf1209497cf659a667d7dea88985e834c24b7c3b605e6254cbb5076d985c" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/08c/aea849a9d3c71/grpcio-1.76.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:08caea849a9d3c71a542827d6df9d5a69067b0a1efbea8a855633ff5d9571465" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/f0e/34c2079d47ae9/grpcio-1.76.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f0e34c2079d47ae9f6188211db9e777c619a21d4faba6977774e8fa43b085e48" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/884/3114c0cfce61b/grpcio-1.76.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8843114c0cfce61b40ad48df65abcfc00d4dba82eae8718fab5352390848c5da" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/8ed/dfb4d203a237d/grpcio-1.76.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8eddfb4d203a237da6f3cc8a540dad0517d274b5a1e9e636fd8d2c79b5c1d397" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/324/83fe2aab2c379/grpcio-1.76.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:32483fe2aab2c3794101c2a159070584e5db11d0aa091b2c0ea9c4fc43d0d749" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/dcf/e41187da8992c/grpcio-1.76.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dcfe41187da8992c5f40aa8c5ec086fa3672834d2be57a32384c08d5a05b4c00" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/210/7b0c024d1b35f/grpcio-1.76.0-cp311-cp311-win32.whl", hash = "sha256:2107b0c024d1b35f4083f11245c0e23846ae64d02f40b2b226684840260ed054" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/522/175aba7af9113/grpcio-1.76.0-cp311-cp311-win_amd64.whl", hash = "sha256:522175aba7af9113c48ec10cc471b9b9bd4f6ceb36aeb4544a8e2c80ed9d252d" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/81f/d9652b37b36f1/grpcio-1.76.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:81fd9652b37b36f16138611c7e884eb82e0cec137c40d3ef7c3f9b3ed00f6ed8" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/04b/be1bfe3a68bbf/grpcio-1.76.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:04bbe1bfe3a68bbfd4e52402ab7d4eb59d72d02647ae2042204326cf4bbad280" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/d38/8087771c837cd/grpcio-1.76.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d388087771c837cdb6515539f43b9d4bf0b0f23593a24054ac16f7a960be16f4" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/9f8/f757bebaaea11/grpcio-1.76.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:9f8f757bebaaea112c00dba718fc0d3260052ce714e25804a03f93f5d1c6cc11" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/980/a846182ce88c4/grpcio-1.76.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:980a846182ce88c4f2f7e2c22c56aefd515daeb36149d1c897f83cf57999e0b6" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/f92/f88e6c033db65/grpcio-1.76.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f92f88e6c033db65a5ae3d97905c8fea9c725b63e28d5a75cb73b49bda5024d8" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/4ba/f3cbe2f0be328/grpcio-1.76.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4baf3cbe2f0be3289eb68ac8ae771156971848bb8aaff60bad42005539431980" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/615/ba64c208aaceb/grpcio-1.76.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:615ba64c208aaceb5ec83bfdce7728b80bfeb8be97562944836a7a0a9647d882" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/45d/59a649a82df57/grpcio-1.76.0-cp312-cp312-win32.whl", hash = "sha256:45d59a649a82df5718fd9527ce775fd66d1af35e6d31abdcdc906a49c6822958" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/c08/8e7a90b601730/grpcio-1.76.0-cp312-cp312-win_amd64.whl", hash = "sha256:c088e7a90b6017307f423efbb9d1ba97a22aa2170876223f9709e9d1de0b5347" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/26e/f06c73eb53267/grpcio-1.76.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:26ef06c73eb53267c2b319f43e6634c7556ea37672029241a056629af27c10e2" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/45e/0111e73f43f73/grpcio-1.76.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:45e0111e73f43f735d70786557dc38141185072d7ff8dc1829d6a77ac1471468" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/83d/57312a58dcfe2/grpcio-1.76.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:83d57312a58dcfe2a3a0f9d1389b299438909a02db60e2f2ea2ae2d8034909d3" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/3e2/a27c89eb9ac3d/grpcio-1.76.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3e2a27c89eb9ac3d81ec8835e12414d73536c6e620355d65102503064a4ed6eb" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/61f/69297cba3950a/grpcio-1.76.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61f69297cba3950a524f61c7c8ee12e55c486cb5f7db47ff9dcee33da6f0d3ae" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/6a1/5c17af8839b68/grpcio-1.76.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a15c17af8839b6801d554263c546c69c4d7718ad4321e3166175b37eaacca77" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/25a/18e9810fbc7e7/grpcio-1.76.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:25a18e9810fbc7e7f03ec2516addc116a957f8cbb8cbc95ccc80faa072743d03" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/931/091142fd8cc14/grpcio-1.76.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:931091142fd8cc14edccc0845a79248bc155425eee9a98b2db2ea4f00a235a42" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/5e8/571632780e085/grpcio-1.76.0-cp313-cp313-win32.whl", hash = "sha256:5e8571632780e08526f118f74170ad8d50fb0a48c23a746bef2a6ebade3abd6f" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/f9f/7bd5faab55f47/grpcio-1.76.0-cp313-cp313-win_amd64.whl", hash = "sha256:f9f7bd5faab55f47231ad8dba7787866b69f5e93bc306e3915606779bbfb4ba8" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/ff8/a59ea85a1f219/grpcio-1.76.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:ff8a59ea85a1f2191a0ffcc61298c571bc566332f82e5f5be1b83c9d8e668a62" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/06c/3d6b076e7b593/grpcio-1.76.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:06c3d6b076e7b593905d04fdba6a0525711b3466f43b3400266f04ff735de0cd" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/fd5/ef5932f6475c4/grpcio-1.76.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd5ef5932f6475c436c4a55e4336ebbe47bd3272be04964a03d316bbf4afbcbc" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/b33/1680e46239e09/grpcio-1.76.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b331680e46239e090f5b3cead313cc772f6caa7d0fc8de349337563125361a4a" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/222/9ae655ec4e899/grpcio-1.76.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2229ae655ec4e8999599469559e97630185fdd53ae1e8997d147b7c9b2b72cba" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/490/fa6d203992c47/grpcio-1.76.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:490fa6d203992c47c7b9e4a9d39003a0c2bcc1c9aa3c058730884bbbb0ee9f09" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/479/496325ce55479/grpcio-1.76.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:479496325ce554792dba6548fae3df31a72cef7bad71ca2e12b0e58f9b336bfc" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/1c9/b93f79f48b03a/grpcio-1.76.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c9b93f79f48b03ada57ea24725d83a30284a012ec27eab2cf7e50a550cbbbcc" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/747/fa73efa9b8b14/grpcio-1.76.0-cp314-cp314-win32.whl", hash = "sha256:747fa73efa9b8b1488a95d0ba1039c8e2dca0f741612d80415b1e1c560febf4e" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/922/fa70ba549fce3/grpcio-1.76.0-cp314-cp314-win_amd64.whl", hash = "sha256:922fa70ba549fce362d2e2871ab542082d66e2aaf0c19480ea453905b01f384e" }, -] - -[[package]] -name = "parver" -version = "0.5" -source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } -dependencies = [ - { name = "arpeggio" }, - { name = "attrs" }, -] -sdist = { url = "https://pypi.ops.eblu.me/root/pypi/+f/b9f/de1e6bb9ce9f0/parver-0.5.tar.gz", hash = "sha256:b9fde1e6bb9ce9f07e08e9c4bea8d8825c5e78e18a0052d02e02bf9517eb4777" } -wheels = [ - { url = "https://pypi.ops.eblu.me/root/pypi/+f/228/1b187276c8e8e/parver-0.5-py3-none-any.whl", hash = "sha256:2281b187276c8e8e3c15634f62287b2fb6fe0efe3010f739a6bd1e45fa2bf2b2" }, -] - -[[package]] -name = "pip" -version = "25.3" -source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } -sdist = { url = "https://pypi.ops.eblu.me/root/pypi/+f/8d0/538dbbd7babbd/pip-25.3.tar.gz", hash = "sha256:8d0538dbbd7babbd207f261ed969c65de439f6bc9e5dbd3b3b9a77f25d95f343" } -wheels = [ - { url = "https://pypi.ops.eblu.me/root/pypi/+f/965/5943313a94722/pip-25.3-py3-none-any.whl", hash = "sha256:9655943313a94722b7774661c21049070f6bbb0a1516bf02f7c8d5d9201514cd" }, -] - -[[package]] -name = "protobuf" -version = "5.29.5" -source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } -sdist = { url = "https://pypi.ops.eblu.me/root/pypi/+f/bc1/463bafd4b0929/protobuf-5.29.5.tar.gz", hash = "sha256:bc1463bafd4b0929216c35f437a8e28731a2b7fe3d98bb77a600efced5a15c84" } -wheels = [ - { url = "https://pypi.ops.eblu.me/root/pypi/+f/3f1/c6468a2cfd102/protobuf-5.29.5-cp310-abi3-win32.whl", hash = "sha256:3f1c6468a2cfd102ff4703976138844f78ebd1fb45f49011afc5139e9e283079" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/3f7/6e3a3675b4a4d/protobuf-5.29.5-cp310-abi3-win_amd64.whl", hash = "sha256:3f76e3a3675b4a4d867b52e4a5f5b78a2ef9565549d4037e06cf7b0942b1d3fc" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/e38/c5add5a311f2a/protobuf-5.29.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:e38c5add5a311f2a6eb0340716ef9b039c1dfa428b28f25a7838ac329204a671" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/fa1/8533a299d7ab6/protobuf-5.29.5-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:fa18533a299d7ab6c55a238bf8629311439995f2e7eca5caaff08663606e9015" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/638/48923da3325e1/protobuf-5.29.5-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:63848923da3325e1bf7e9003d680ce6e14b07e55d0473253a690c3a8b8fd6e61" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/6cf/42630262c59b2/protobuf-5.29.5-py3-none-any.whl", hash = "sha256:6cf42630262c59b2d8de33954443d94b746c952b01434fc58a417fdbd2e84bd5" }, -] - -[[package]] -name = "pulumi" -version = "3.217.0" -source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } -dependencies = [ - { name = "debugpy" }, - { name = "dill" }, - { name = "grpcio" }, - { name = "pip" }, - { name = "protobuf" }, - { name = "pyyaml" }, - { name = "semver" }, -] -wheels = [ - { url = "https://pypi.ops.eblu.me/root/pypi/+f/b5e/fb2e9fb34c2d2/pulumi-3.217.0-py3-none-any.whl", hash = "sha256:b5efb2e9fb34c2d2902d3ec39af0150775c27b80e38c5e421a77454d69dbae25" }, -] - -[[package]] -name = "pulumiverse-gandi" -version = "2.3.2" -source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } -dependencies = [ - { name = "parver" }, - { name = "pulumi" }, - { name = "semver" }, -] -sdist = { url = "https://pypi.ops.eblu.me/root/pypi/+f/59a/621d46fc35be4/pulumiverse_gandi-2.3.2.tar.gz", hash = "sha256:59a621d46fc35be46196d6484a026cae8d6ab973a7241cbc44e0849c931d5ac6" } -wheels = [ - { url = "https://pypi.ops.eblu.me/root/pypi/+f/13d/be5b0bb9c080d/pulumiverse_gandi-2.3.2-py3-none-any.whl", hash = "sha256:13dbe5b0bb9c080d6c389b1d0fcda907adf8904fa363c053b36bbbf60918220d" }, -] - -[[package]] -name = "pyyaml" -version = "6.0.3" -source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } -sdist = { url = "https://pypi.ops.eblu.me/root/pypi/+f/d76/623373421df22/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f" } -wheels = [ - { url = "https://pypi.ops.eblu.me/root/pypi/+f/44e/dc64787392855/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/652/cb6edd41e7185/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/108/92704fc220243/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/850/774a7879607d3/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/b8b/b0864c5a28024/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/1d3/7d57ad971609c/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/375/03bfbfc9d2c40/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/809/8f252adfa6c80/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/9f3/bfb4965eb8744/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/7f0/47e29dcae4460/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/fc0/9d0aa354569bc/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/914/9cad251584d5f/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/5fd/ec68f91a0c673/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/ba1/cc08a7ccde2d2/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/8dc/52c23056b9ddd/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/417/15c910c881bc0/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/96b/533f0e99f6579/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/5fc/d34e47f6e0b79/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/643/86e5e707d03a7/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/8da/9669d359f02c0/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/228/3a07e2c21a2aa/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/ee2/922902c45ae8c/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/a33/284e20b78bd4a/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/0f2/9edc409a63924/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/f70/57c9a337546ed/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/eda/16858a3cab07b/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/d0e/ae10f8159e8fd/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/790/05a0d97d5ddab/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/549/8cd1645aa724a/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/8d1/fab6bb153a416/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/34d/5fcd24b8445fa/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/501/a031947e3a902/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/b3b/c83488de33889/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/c45/8b6d084f9b935/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/7c6/610def4f16354/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/519/0d403f121660c/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/4a2/e8cebe2ff6ab7/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/93d/da82c9c22deb0/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/028/93d100e99e03e/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/c1f/f362665ae5072/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/6ad/c77889b628398/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/a80/cb027f6b34984/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/00c/4bdeba853cc34/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/66e/1674c3ef6f541/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/162/49ee61e95f858/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/4ad/1906908f2f5ae/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/ebc/55a14a21cb140/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b" }, -] - -[[package]] -name = "semver" -version = "3.0.4" -source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } -sdist = { url = "https://pypi.ops.eblu.me/root/pypi/+f/afc/7d8c584a5ed0a/semver-3.0.4.tar.gz", hash = "sha256:afc7d8c584a5ed0a11033af086e8af226a9c0b206f313e0301f8dd7b6b589602" } -wheels = [ - { url = "https://pypi.ops.eblu.me/root/pypi/+f/9c8/24d87ba7f7ab4/semver-3.0.4-py3-none-any.whl", hash = "sha256:9c824d87ba7f7ab4a1890799cec8596f15c1241cb473404ea1cb0c55e4b04746" }, -] - -[[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" }, -] diff --git a/pulumi/tailscale/policy.hujson b/pulumi/policy.hujson similarity index 70% rename from pulumi/tailscale/policy.hujson rename to pulumi/policy.hujson index 88408ef..7f18820 100644 --- a/pulumi/tailscale/policy.hujson +++ b/pulumi/policy.hujson @@ -20,8 +20,7 @@ }, // --- Members: user-facing services only --- - // Kiwix, Forge, Miniflux, PostgreSQL - // (devpi moved off-cluster to indri; reachable via Caddy on tag:flyio-target) + // Kiwix, Forge, devpi, Miniflux, PostgreSQL { "src": ["autogroup:member"], "dst": ["tag:kiwix"], @@ -32,6 +31,11 @@ "dst": ["tag:forge"], "ip": ["tcp:443", "tcp:22"], }, + { + "src": ["autogroup:member"], + "dst": ["tag:devpi"], + "ip": ["tcp:443"], + }, { "src": ["autogroup:member"], "dst": ["tag:feed"], @@ -56,22 +60,6 @@ "ip": ["*"], }, - // --- Fly.io proxy --- - // Public reverse proxy can only reach explicitly tagged endpoints - { - "src": ["tag:flyio-proxy"], - "dst": ["tag:flyio-target"], - "ip": ["tcp:443"], - }, - - // --- CI Gateway --- - // Ephemeral CI containers can push images to registry - { - "src": ["tag:ci-gateway"], - "dst": ["tag:registry"], - "ip": ["tcp:443"], - }, - // --- Kubernetes workloads --- // k8s workloads (e.g., Woodpecker CI) can push/pull from registry { @@ -86,7 +74,6 @@ "dst": ["tag:homelab"], "ip": ["tcp:3001", "tcp:2200"], }, - // Homelab can reach k8s services: PostgreSQL, CNPG metrics, Prometheus/Loki { "src": ["tag:homelab"], @@ -120,25 +107,8 @@ "users": ["autogroup:nonroot"], "checkPeriod": "12h0m0s", }, - // Homelab can SSH to homelab (for ansible, cross-host management) - // Tagged devices can't do interactive "check" auth, so use "accept". - { - "action": "accept", - "src": ["tag:homelab"], - "dst": ["tag:homelab"], - "users": ["autogroup:nonroot"], - }, ], - // ============== Auto Approvers ============== - // Allow ProxyGroup pods (tag:k8s) to auto-approve VIP Services - // Required for multi-cluster Ingress per Tailscale docs - "autoApprovers": { - "services": { - "tag:k8s": ["tag:k8s"], - }, - }, - // ============== Tag Owners ============== "tagOwners": { "tag:blumeops": ["autogroup:admin", "tag:blumeops"], @@ -148,16 +118,14 @@ "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"], "tag:registry": ["autogroup:admin", "tag:blumeops"], "tag:k8s-api": ["autogroup:admin", "tag:blumeops"], - "tag:k8s-operator": ["autogroup:admin", "tag:blumeops", "tag:k8s-operator"], + "tag:k8s-operator": ["autogroup:admin", "tag:blumeops"], "tag:k8s": ["autogroup:admin", "tag:blumeops", "tag:k8s-operator"], - "tag:ci-gateway": ["autogroup:admin", "tag:blumeops"], - "tag:flyio-proxy": ["autogroup:admin", "tag:blumeops"], - "tag:flyio-target": ["autogroup:admin", "tag:blumeops", "tag:k8s-operator"], }, // ============== ACL Tests ============== @@ -183,18 +151,5 @@ "src": "tag:k8s", "accept": ["tag:registry:443", "tag:homelab:3001", "tag:homelab:2200"], }, - // CI gateway can push to registry - { - "src": "tag:ci-gateway", - "accept": ["tag:registry:443"], - }, - // 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:22", "tag:nas:445", "tag:registry:443"], - }, ], } diff --git a/pulumi/tailscale/pyproject.toml b/pulumi/pyproject.toml similarity index 100% rename from pulumi/tailscale/pyproject.toml rename to pulumi/pyproject.toml diff --git a/pulumi/tailscale/uv.lock b/pulumi/tailscale/uv.lock deleted file mode 100644 index 9462590..0000000 --- a/pulumi/tailscale/uv.lock +++ /dev/null @@ -1,265 +0,0 @@ -version = 1 -revision = 3 -requires-python = ">=3.11" -resolution-markers = [ - "python_full_version >= '3.14'", - "python_full_version < '3.14'", -] - -[[package]] -name = "arpeggio" -version = "2.0.3" -source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } -sdist = { url = "https://pypi.ops.eblu.me/root/pypi/+f/9e8/5ad35cfc6c938/Arpeggio-2.0.3.tar.gz", hash = "sha256:9e85ad35cfc6c938676817c7ae9a1000a7c72a34c71db0c687136c460d12b85e" } -wheels = [ - { url = "https://pypi.ops.eblu.me/root/pypi/+f/937/4d9c531b62018/Arpeggio-2.0.3-py2.py3-none-any.whl", hash = "sha256:9374d9c531b62018b787635f37fd81c9a6ee69ef2d28c5db3cd18791b1f7db2f" }, -] - -[[package]] -name = "attrs" -version = "25.4.0" -source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } -sdist = { url = "https://pypi.ops.eblu.me/root/pypi/+f/16d/5969b87f0859e/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11" } -wheels = [ - { url = "https://pypi.ops.eblu.me/root/pypi/+f/adc/f7e2a1fb3b36a/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373" }, -] - -[[package]] -name = "blumeops-tailnet" -version = "0.1.0" -source = { virtual = "." } -dependencies = [ - { name = "pulumi" }, - { name = "pulumi-tailscale" }, -] - -[package.metadata] -requires-dist = [ - { name = "pulumi", specifier = ">=3.0.0" }, - { name = "pulumi-tailscale", specifier = ">=0.24.0" }, -] - -[[package]] -name = "debugpy" -version = "1.8.19" -source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } -sdist = { url = "https://pypi.ops.eblu.me/root/pypi/+f/eea/7e5987445ab0b/debugpy-1.8.19.tar.gz", hash = "sha256:eea7e5987445ab0b5ed258093722d5ecb8bb72217c5c9b1e21f64efe23ddebdb" } -wheels = [ - { url = "https://pypi.ops.eblu.me/root/pypi/+f/c5d/cfa21de1f735a/debugpy-1.8.19-cp311-cp311-macosx_15_0_universal2.whl", hash = "sha256:c5dcfa21de1f735a4f7ced4556339a109aa0f618d366ede9da0a3600f2516d8b" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/806/d680024624400/debugpy-1.8.19-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:806d6800246244004625d5222d7765874ab2d22f3ba5f615416cf1342d61c488" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/783/a519e6dfb1f3c/debugpy-1.8.19-cp311-cp311-win32.whl", hash = "sha256:783a519e6dfb1f3cd773a9bda592f4887a65040cb0c7bd38dde410f4e53c40d4" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/140/35cbdbb1fe4b6/debugpy-1.8.19-cp311-cp311-win_amd64.whl", hash = "sha256:14035cbdbb1fe4b642babcdcb5935c2da3b1067ac211c5c5a8fdc0bb31adbcaa" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/bcc/b1540a49cde77/debugpy-1.8.19-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:bccb1540a49cde77edc7ce7d9d075c1dbeb2414751bc0048c7a11e1b597a4c2e" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/e9c/68d9a382ec754/debugpy-1.8.19-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:e9c68d9a382ec754dc05ed1d1b4ed5bd824b9f7c1a8cd1083adb84b3c93501de" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/659/9cab8a783d149/debugpy-1.8.19-cp312-cp312-win32.whl", hash = "sha256:6599cab8a783d1496ae9984c52cb13b7c4a3bd06a8e6c33446832a5d97ce0bee" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/66e/3d2fd8f2035a8/debugpy-1.8.19-cp312-cp312-win_amd64.whl", hash = "sha256:66e3d2fd8f2035a8f111eb127fa508469dfa40928a89b460b41fd988684dc83d" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/91e/35db2672a0aba/debugpy-1.8.19-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:91e35db2672a0abaf325f4868fcac9c1674a0d9ad9bb8a8c849c03a5ebba3e6d" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/850/16a73ab84dea1/debugpy-1.8.19-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:85016a73ab84dea1c1f1dcd88ec692993bcbe4532d1b49ecb5f3c688ae50c606" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/b60/5f17e89ba0ece/debugpy-1.8.19-cp313-cp313-win32.whl", hash = "sha256:b605f17e89ba0ecee994391194285fada89cee111cfcd29d6f2ee11cbdc40976" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/c30/639998a9f9cd9/debugpy-1.8.19-cp313-cp313-win_amd64.whl", hash = "sha256:c30639998a9f9cd9699b4b621942c0179a6527f083c72351f95c6ab1728d5b73" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/1e8/c4d1bd230067b/debugpy-1.8.19-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:1e8c4d1bd230067bf1bbcdbd6032e5a57068638eb28b9153d008ecde288152af" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/d40/c016c1f538dbf/debugpy-1.8.19-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:d40c016c1f538dbf1762936e3aeb43a89b965069d9f60f9e39d35d9d25e6b809" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/060/1708223fe1cd0/debugpy-1.8.19-cp314-cp314-win32.whl", hash = "sha256:0601708223fe1cd0e27c6cce67a899d92c7d68e73690211e6788a4b0e1903f5b" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/8e1/9a725f5d486f2/debugpy-1.8.19-cp314-cp314-win_amd64.whl", hash = "sha256:8e19a725f5d486f20e53a1dde2ab8bb2c9607c40c00a42ab646def962b41125f" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/360/ffd231a780abb/debugpy-1.8.19-py2.py3-none-any.whl", hash = "sha256:360ffd231a780abbc414ba0f005dad409e71c78637efe8f2bd75837132a41d38" }, -] - -[[package]] -name = "dill" -version = "0.4.0" -source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } -sdist = { url = "https://pypi.ops.eblu.me/root/pypi/+f/063/3f1d2df477324/dill-0.4.0.tar.gz", hash = "sha256:0633f1d2df477324f53a895b02c901fb961bdbf65a17122586ea7019292cbcf0" } -wheels = [ - { url = "https://pypi.ops.eblu.me/root/pypi/+f/44f/54bf6412c2c84/dill-0.4.0-py3-none-any.whl", hash = "sha256:44f54bf6412c2c8464c14e8243eb163690a9800dbe2c367330883b19c7561049" }, -] - -[[package]] -name = "grpcio" -version = "1.76.0" -source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://pypi.ops.eblu.me/root/pypi/+f/7be/78388d6da1a25/grpcio-1.76.0.tar.gz", hash = "sha256:7be78388d6da1a25c0d5ec506523db58b18be22d9c37d8d3a32c08be4987bd73" } -wheels = [ - { url = "https://pypi.ops.eblu.me/root/pypi/+f/2e1/743fbd7f5fa71/grpcio-1.76.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:2e1743fbd7f5fa713a1b0a8ac8ebabf0ec980b5d8809ec358d488e273b9cf02a" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/a8c/2cf1209497cf6/grpcio-1.76.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:a8c2cf1209497cf659a667d7dea88985e834c24b7c3b605e6254cbb5076d985c" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/08c/aea849a9d3c71/grpcio-1.76.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:08caea849a9d3c71a542827d6df9d5a69067b0a1efbea8a855633ff5d9571465" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/f0e/34c2079d47ae9/grpcio-1.76.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f0e34c2079d47ae9f6188211db9e777c619a21d4faba6977774e8fa43b085e48" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/884/3114c0cfce61b/grpcio-1.76.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8843114c0cfce61b40ad48df65abcfc00d4dba82eae8718fab5352390848c5da" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/8ed/dfb4d203a237d/grpcio-1.76.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8eddfb4d203a237da6f3cc8a540dad0517d274b5a1e9e636fd8d2c79b5c1d397" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/324/83fe2aab2c379/grpcio-1.76.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:32483fe2aab2c3794101c2a159070584e5db11d0aa091b2c0ea9c4fc43d0d749" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/dcf/e41187da8992c/grpcio-1.76.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dcfe41187da8992c5f40aa8c5ec086fa3672834d2be57a32384c08d5a05b4c00" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/210/7b0c024d1b35f/grpcio-1.76.0-cp311-cp311-win32.whl", hash = "sha256:2107b0c024d1b35f4083f11245c0e23846ae64d02f40b2b226684840260ed054" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/522/175aba7af9113/grpcio-1.76.0-cp311-cp311-win_amd64.whl", hash = "sha256:522175aba7af9113c48ec10cc471b9b9bd4f6ceb36aeb4544a8e2c80ed9d252d" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/81f/d9652b37b36f1/grpcio-1.76.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:81fd9652b37b36f16138611c7e884eb82e0cec137c40d3ef7c3f9b3ed00f6ed8" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/04b/be1bfe3a68bbf/grpcio-1.76.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:04bbe1bfe3a68bbfd4e52402ab7d4eb59d72d02647ae2042204326cf4bbad280" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/d38/8087771c837cd/grpcio-1.76.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d388087771c837cdb6515539f43b9d4bf0b0f23593a24054ac16f7a960be16f4" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/9f8/f757bebaaea11/grpcio-1.76.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:9f8f757bebaaea112c00dba718fc0d3260052ce714e25804a03f93f5d1c6cc11" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/980/a846182ce88c4/grpcio-1.76.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:980a846182ce88c4f2f7e2c22c56aefd515daeb36149d1c897f83cf57999e0b6" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/f92/f88e6c033db65/grpcio-1.76.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f92f88e6c033db65a5ae3d97905c8fea9c725b63e28d5a75cb73b49bda5024d8" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/4ba/f3cbe2f0be328/grpcio-1.76.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4baf3cbe2f0be3289eb68ac8ae771156971848bb8aaff60bad42005539431980" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/615/ba64c208aaceb/grpcio-1.76.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:615ba64c208aaceb5ec83bfdce7728b80bfeb8be97562944836a7a0a9647d882" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/45d/59a649a82df57/grpcio-1.76.0-cp312-cp312-win32.whl", hash = "sha256:45d59a649a82df5718fd9527ce775fd66d1af35e6d31abdcdc906a49c6822958" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/c08/8e7a90b601730/grpcio-1.76.0-cp312-cp312-win_amd64.whl", hash = "sha256:c088e7a90b6017307f423efbb9d1ba97a22aa2170876223f9709e9d1de0b5347" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/26e/f06c73eb53267/grpcio-1.76.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:26ef06c73eb53267c2b319f43e6634c7556ea37672029241a056629af27c10e2" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/45e/0111e73f43f73/grpcio-1.76.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:45e0111e73f43f735d70786557dc38141185072d7ff8dc1829d6a77ac1471468" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/83d/57312a58dcfe2/grpcio-1.76.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:83d57312a58dcfe2a3a0f9d1389b299438909a02db60e2f2ea2ae2d8034909d3" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/3e2/a27c89eb9ac3d/grpcio-1.76.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3e2a27c89eb9ac3d81ec8835e12414d73536c6e620355d65102503064a4ed6eb" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/61f/69297cba3950a/grpcio-1.76.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61f69297cba3950a524f61c7c8ee12e55c486cb5f7db47ff9dcee33da6f0d3ae" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/6a1/5c17af8839b68/grpcio-1.76.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a15c17af8839b6801d554263c546c69c4d7718ad4321e3166175b37eaacca77" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/25a/18e9810fbc7e7/grpcio-1.76.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:25a18e9810fbc7e7f03ec2516addc116a957f8cbb8cbc95ccc80faa072743d03" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/931/091142fd8cc14/grpcio-1.76.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:931091142fd8cc14edccc0845a79248bc155425eee9a98b2db2ea4f00a235a42" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/5e8/571632780e085/grpcio-1.76.0-cp313-cp313-win32.whl", hash = "sha256:5e8571632780e08526f118f74170ad8d50fb0a48c23a746bef2a6ebade3abd6f" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/f9f/7bd5faab55f47/grpcio-1.76.0-cp313-cp313-win_amd64.whl", hash = "sha256:f9f7bd5faab55f47231ad8dba7787866b69f5e93bc306e3915606779bbfb4ba8" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/ff8/a59ea85a1f219/grpcio-1.76.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:ff8a59ea85a1f2191a0ffcc61298c571bc566332f82e5f5be1b83c9d8e668a62" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/06c/3d6b076e7b593/grpcio-1.76.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:06c3d6b076e7b593905d04fdba6a0525711b3466f43b3400266f04ff735de0cd" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/fd5/ef5932f6475c4/grpcio-1.76.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd5ef5932f6475c436c4a55e4336ebbe47bd3272be04964a03d316bbf4afbcbc" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/b33/1680e46239e09/grpcio-1.76.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b331680e46239e090f5b3cead313cc772f6caa7d0fc8de349337563125361a4a" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/222/9ae655ec4e899/grpcio-1.76.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2229ae655ec4e8999599469559e97630185fdd53ae1e8997d147b7c9b2b72cba" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/490/fa6d203992c47/grpcio-1.76.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:490fa6d203992c47c7b9e4a9d39003a0c2bcc1c9aa3c058730884bbbb0ee9f09" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/479/496325ce55479/grpcio-1.76.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:479496325ce554792dba6548fae3df31a72cef7bad71ca2e12b0e58f9b336bfc" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/1c9/b93f79f48b03a/grpcio-1.76.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c9b93f79f48b03ada57ea24725d83a30284a012ec27eab2cf7e50a550cbbbcc" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/747/fa73efa9b8b14/grpcio-1.76.0-cp314-cp314-win32.whl", hash = "sha256:747fa73efa9b8b1488a95d0ba1039c8e2dca0f741612d80415b1e1c560febf4e" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/922/fa70ba549fce3/grpcio-1.76.0-cp314-cp314-win_amd64.whl", hash = "sha256:922fa70ba549fce362d2e2871ab542082d66e2aaf0c19480ea453905b01f384e" }, -] - -[[package]] -name = "parver" -version = "0.5" -source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } -dependencies = [ - { name = "arpeggio" }, - { name = "attrs" }, -] -sdist = { url = "https://pypi.ops.eblu.me/root/pypi/+f/b9f/de1e6bb9ce9f0/parver-0.5.tar.gz", hash = "sha256:b9fde1e6bb9ce9f07e08e9c4bea8d8825c5e78e18a0052d02e02bf9517eb4777" } -wheels = [ - { url = "https://pypi.ops.eblu.me/root/pypi/+f/228/1b187276c8e8e/parver-0.5-py3-none-any.whl", hash = "sha256:2281b187276c8e8e3c15634f62287b2fb6fe0efe3010f739a6bd1e45fa2bf2b2" }, -] - -[[package]] -name = "pip" -version = "25.3" -source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } -sdist = { url = "https://pypi.ops.eblu.me/root/pypi/+f/8d0/538dbbd7babbd/pip-25.3.tar.gz", hash = "sha256:8d0538dbbd7babbd207f261ed969c65de439f6bc9e5dbd3b3b9a77f25d95f343" } -wheels = [ - { url = "https://pypi.ops.eblu.me/root/pypi/+f/965/5943313a94722/pip-25.3-py3-none-any.whl", hash = "sha256:9655943313a94722b7774661c21049070f6bbb0a1516bf02f7c8d5d9201514cd" }, -] - -[[package]] -name = "protobuf" -version = "5.29.5" -source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } -sdist = { url = "https://pypi.ops.eblu.me/root/pypi/+f/bc1/463bafd4b0929/protobuf-5.29.5.tar.gz", hash = "sha256:bc1463bafd4b0929216c35f437a8e28731a2b7fe3d98bb77a600efced5a15c84" } -wheels = [ - { url = "https://pypi.ops.eblu.me/root/pypi/+f/3f1/c6468a2cfd102/protobuf-5.29.5-cp310-abi3-win32.whl", hash = "sha256:3f1c6468a2cfd102ff4703976138844f78ebd1fb45f49011afc5139e9e283079" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/3f7/6e3a3675b4a4d/protobuf-5.29.5-cp310-abi3-win_amd64.whl", hash = "sha256:3f76e3a3675b4a4d867b52e4a5f5b78a2ef9565549d4037e06cf7b0942b1d3fc" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/e38/c5add5a311f2a/protobuf-5.29.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:e38c5add5a311f2a6eb0340716ef9b039c1dfa428b28f25a7838ac329204a671" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/fa1/8533a299d7ab6/protobuf-5.29.5-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:fa18533a299d7ab6c55a238bf8629311439995f2e7eca5caaff08663606e9015" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/638/48923da3325e1/protobuf-5.29.5-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:63848923da3325e1bf7e9003d680ce6e14b07e55d0473253a690c3a8b8fd6e61" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/6cf/42630262c59b2/protobuf-5.29.5-py3-none-any.whl", hash = "sha256:6cf42630262c59b2d8de33954443d94b746c952b01434fc58a417fdbd2e84bd5" }, -] - -[[package]] -name = "pulumi" -version = "3.215.0" -source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } -dependencies = [ - { name = "debugpy" }, - { name = "dill" }, - { name = "grpcio" }, - { name = "pip" }, - { name = "protobuf" }, - { name = "pyyaml" }, - { name = "semver" }, -] -wheels = [ - { url = "https://pypi.ops.eblu.me/root/pypi/+f/97a/380c26414f9ea/pulumi-3.215.0-py3-none-any.whl", hash = "sha256:97a380c26414f9ea14d7265513f7f667b139cfc44576e8d492117d304a70283c" }, -] - -[[package]] -name = "pulumi-tailscale" -version = "0.24.0" -source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } -dependencies = [ - { name = "parver" }, - { name = "pulumi" }, - { name = "semver" }, -] -sdist = { url = "https://pypi.ops.eblu.me/root/pypi/+f/4bd/da5ca09b07efd/pulumi_tailscale-0.24.0.tar.gz", hash = "sha256:4bdda5ca09b07efd89886cc3d20c4e45cde5430b7a8559a25cc26643ad86a46a" } -wheels = [ - { url = "https://pypi.ops.eblu.me/root/pypi/+f/6da/c592c7812c821/pulumi_tailscale-0.24.0-py3-none-any.whl", hash = "sha256:6dac592c7812c82164e06228f833fa93a8ccb73afbb599c36ce417f773729ba9" }, -] - -[[package]] -name = "pyyaml" -version = "6.0.3" -source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } -sdist = { url = "https://pypi.ops.eblu.me/root/pypi/+f/d76/623373421df22/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f" } -wheels = [ - { url = "https://pypi.ops.eblu.me/root/pypi/+f/44e/dc64787392855/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/652/cb6edd41e7185/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/108/92704fc220243/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/850/774a7879607d3/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/b8b/b0864c5a28024/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/1d3/7d57ad971609c/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/375/03bfbfc9d2c40/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/809/8f252adfa6c80/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/9f3/bfb4965eb8744/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/7f0/47e29dcae4460/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/fc0/9d0aa354569bc/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/914/9cad251584d5f/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/5fd/ec68f91a0c673/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/ba1/cc08a7ccde2d2/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/8dc/52c23056b9ddd/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/417/15c910c881bc0/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/96b/533f0e99f6579/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/5fc/d34e47f6e0b79/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/643/86e5e707d03a7/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/8da/9669d359f02c0/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/228/3a07e2c21a2aa/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/ee2/922902c45ae8c/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/a33/284e20b78bd4a/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/0f2/9edc409a63924/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/f70/57c9a337546ed/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/eda/16858a3cab07b/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/d0e/ae10f8159e8fd/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/790/05a0d97d5ddab/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/549/8cd1645aa724a/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/8d1/fab6bb153a416/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/34d/5fcd24b8445fa/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/501/a031947e3a902/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/b3b/c83488de33889/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/c45/8b6d084f9b935/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/7c6/610def4f16354/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/519/0d403f121660c/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/4a2/e8cebe2ff6ab7/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/93d/da82c9c22deb0/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/028/93d100e99e03e/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/c1f/f362665ae5072/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/6ad/c77889b628398/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/a80/cb027f6b34984/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/00c/4bdeba853cc34/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/66e/1674c3ef6f541/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/162/49ee61e95f858/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/4ad/1906908f2f5ae/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9" }, - { url = "https://pypi.ops.eblu.me/root/pypi/+f/ebc/55a14a21cb140/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b" }, -] - -[[package]] -name = "semver" -version = "3.0.4" -source = { registry = "https://pypi.ops.eblu.me/root/pypi/+simple/" } -sdist = { url = "https://pypi.ops.eblu.me/root/pypi/+f/afc/7d8c584a5ed0a/semver-3.0.4.tar.gz", hash = "sha256:afc7d8c584a5ed0a11033af086e8af226a9c0b206f313e0301f8dd7b6b589602" } -wheels = [ - { url = "https://pypi.ops.eblu.me/root/pypi/+f/9c8/24d87ba7f7ab4/semver-3.0.4-py3-none-any.whl", hash = "sha256:9c824d87ba7f7ab4a1890799cec8596f15c1241cb473404ea1cb0c55e4b04746" }, -] - -[[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" }, -] diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index f612759..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,19 +0,0 @@ -[project] -name = "blumeops" -version = "0.1.0" -requires-python = ">=3.13" -dependencies = ["dagger-io"] - -[build-system] -requires = ["uv_build>=0.8.4,<0.9.0"] -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 deleted file mode 100644 index 419d129..0000000 --- a/service-versions.yaml +++ /dev/null @@ -1,517 +0,0 @@ -# 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. -# -# Fields: -# name - kebab-case service identifier -# 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 -# notes - optional context - -services: - - name: prometheus - type: argocd - last-reviewed: 2026-03-18 - current-version: "v3.10.0" - upstream-source: https://github.com/prometheus/prometheus/releases - - - name: loki - type: argocd - last-reviewed: 2026-03-20 - current-version: "3.6.7" - upstream-source: https://github.com/grafana/loki/releases - - - name: kube-state-metrics - type: argocd - last-reviewed: 2026-03-22 - current-version: "v2.18.0" - upstream-source: https://github.com/kubernetes/kube-state-metrics/releases - - - - name: ntfy - type: argocd - last-reviewed: 2026-03-23 - current-version: "v2.19.2" - upstream-source: https://github.com/binwiederhier/ntfy/releases - - - name: homepage - type: argocd - last-reviewed: 2026-03-26 - current-version: "v1.11.0" - upstream-source: https://github.com/gethomepage/homepage/releases - notes: Custom container, kustomize manifests - - - name: shower - type: argocd - last-reviewed: 2026-05-15 - 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 - 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-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 - - - name: frigate - type: argocd - last-reviewed: 2026-03-24 - current-version: "0.17.1" - upstream-source: https://github.com/blakeblackshear/frigate/releases - - - name: frigate-notify - type: argocd - last-reviewed: 2026-03-28 - current-version: "v0.5.4" - upstream-source: https://github.com/0x2142/frigate-notify/releases - - - name: tempo - type: argocd - 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 - 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-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-04-30 - current-version: "v1.16.0" - upstream-source: https://github.com/grafana/alloy/releases - - - name: tailscale-operator - type: argocd - last-reviewed: 2026-03-22 - 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 - current-version: "12.4.2" - upstream-source: https://github.com/grafana/grafana/releases - notes: Home-built container from Alpine; upgraded from Helm to Kustomize - - - name: grafana-sidecar - type: argocd - parent: grafana - 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 - - - name: cloudnative-pg - type: argocd - 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) - - - name: immich - type: argocd - last-reviewed: 2026-04-04 - current-version: "v2.6.3" - upstream-source: https://github.com/immich-app/immich/releases - notes: Kustomize manifests with upstream images - - - name: valkey - type: argocd - last-reviewed: 2026-05-28 - current-version: "8.1.7" - upstream-source: https://github.com/valkey-io/valkey/releases - notes: >- - 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 - type: argocd - 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. 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 - last-reviewed: 2026-04-06 - current-version: "1.8.2" - upstream-source: https://hub.docker.com/r/1password/connect-api/tags - notes: Kustomize manifests rendered from connect-helm-charts v2.4.1 - - - name: argocd - type: argocd - 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 - - - name: blumeops-pg - type: argocd - 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 - - - name: authentik - type: argocd - last-reviewed: "2026-04-08" - current-version: "2026.2.2" - 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-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 - - - name: navidrome - type: argocd - last-reviewed: 2026-04-11 - current-version: "v0.61.1" - upstream-source: https://github.com/navidrome/navidrome/releases - - - name: miniflux - type: argocd - last-reviewed: 2026-04-12 - current-version: "2.2.19" - upstream-source: https://github.com/miniflux/v2/releases - - - name: teslamate - type: argocd - 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 - 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-04-15 - current-version: "1.0.1" - upstream-source: null - notes: Homegrown Python exporter, no upstream - - - name: kiwix - type: argocd - last-reviewed: 2026-04-17 - current-version: "3.8.2" - upstream-source: https://github.com/kiwix/kiwix-tools/releases - - - name: devpi - 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: ansible - last-reviewed: 2026-04-29 - current-version: "1.0.3" - upstream-source: https://forge.eblu.me/eblume/cv - 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: ansible - last-reviewed: 2026-04-29 - 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). current-version - tracks the blumeops docs release tag. - - - name: forgejo-runner - type: argocd - last-reviewed: 2026-04-20 - 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 - image is tracked separately as runner-job-image. - - - name: runner-job-image - type: argocd - 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 - Dagger CLI version, the primary build tool in the image. - - - name: nix-container-builder - type: nixos - last-reviewed: 2026-04-01 - current-version: "12.7.2" - upstream-source: https://code.forgejo.org/forgejo/runner/releases - 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-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; 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 - last-reviewed: "2026-06-03" - current-version: "v3.16.0" - upstream-source: https://github.com/mealie-recipes/mealie/releases - notes: >- - 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-06-03" - current-version: "v2.20.15" - upstream-source: https://github.com/paperless-ngx/paperless-ngx/releases - 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 - last-reviewed: 2026-05-28 - current-version: "v3.2.0" - upstream-source: https://github.com/unpoller/unpoller/releases - notes: UniFi metrics exporter for Prometheus - - - name: prowler - type: argocd - 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 - - - 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 - current-version: "14.0.3" - upstream-source: https://codeberg.org/forgejo/forgejo/releases - notes: Built from source on indri (~/code/3rd/forgejo) - - - name: alloy - type: ansible - last-reviewed: 2026-04-30 - current-version: "v1.16.0" - upstream-source: https://github.com/grafana/alloy/releases - notes: Built from source on indri - - - name: zot - type: ansible - 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 - - - name: caddy - type: ansible - 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 - - - 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 - current-version: "2.1.4" - upstream-source: https://github.com/borgmatic-collective/borgmatic/releases - notes: Installed via mise (pipx); version pinned in ansible/roles/borgmatic/defaults/main.yml and mise.toml - - - name: jellyfin - type: ansible - last-reviewed: 2026-06-08 - current-version: "10.11.11" - upstream-source: https://github.com/jellyfin/jellyfin/releases - notes: >- - Homebrew cask (state: present, unpinned). Upgrade with - `brew upgrade --cask jellyfin` on indri. After upgrade the .app is - re-quarantined; launchd-spawned launch hangs silently until the - Gatekeeper first-launch dialog is approved on indri's GUI console - (xattr removal over SSH is blocked by TCC). - - - name: automounter - type: ansible - 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. - - - 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 - - - name: dagger - type: mise - 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 - - - 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/__init__.py b/src/blumeops/__init__.py deleted file mode 100644 index 1d1b128..0000000 --- a/src/blumeops/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""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 deleted file mode 100644 index d63c127..0000000 --- a/src/blumeops/containers.py +++ /dev/null @@ -1,171 +0,0 @@ -"""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", - 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.23. - - Returns a container with the built binary at `output`. - """ - apk_packages = ["build-base", "git"] + (extra_apk or []) - ctr = ( - dag.container() - .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") - ) - 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( - 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", - create_user: bool = True, -) -> dagger.Container: - """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. - Alpine's built-in nobody:65534). - """ - packages = extra_apk or [] - setup_cmds = [] - if packages: - setup_cmds.append(f"apk add --no-cache {' '.join(packages)}") - if create_user: - 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.23") - if setup_cmds: - ctr = ctr.with_exec(["sh", "-c", " && ".join(setup_cmds)]) - return ctr - - -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/src/blumeops/main.py b/src/blumeops/main.py deleted file mode 100644 index 9bbd12f..0000000 --- a/src/blumeops/main.py +++ /dev/null @@ -1,343 +0,0 @@ -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 Blumeops: - @function - 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, - src: dagger.Directory, - container_name: str, - version: str, - commit_sha: str, - registry: str = "registry.ops.eblu.me", - registry_username: str = "zot-ci", - registry_password: dagger.Secret | None = None, - ) -> str: - """Build and push to registry. Returns the image ref. - - Tag format: {version}-{commit_sha} (e.g. v1.0.0-abc1234) - """ - 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}" - return await ctr.publish(ref) - - @function - async def build_docs(self, src: dagger.Directory, version: str) -> dagger.File: - """Build Quartz docs site. Returns docs tarball.""" - return await ( - dag.container() - .from_("node:22-slim") - .with_exec(["apt-get", "update", "-qq"]) - .with_exec(["apt-get", "install", "-y", "-qq", "git"]) - .with_directory("/workspace", src) - .with_workdir("/workspace") - .with_exec( - [ - "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", - ] - ) - .with_exec( - [ - "sh", - "-c", - "cp -r /tmp/quartz/quartz /tmp/quartz/package*.json " - "/tmp/quartz/tsconfig.json .", - ] - ) - .with_exec(["npm", "ci"]) - .with_exec(["cp", "docs/quartz.config.ts", "."]) - .with_exec(["cp", "docs/quartz.layout.ts", "."]) - .with_exec(["cp", "CHANGELOG.md", "docs/"]) - .with_exec(["npx", "quartz", "build", "-d", "docs"]) - .with_exec( - [ - "tar", - "-czf", - f"/docs-{version}.tar.gz", - "-C", - "public", - ".", - ] - ) - .file(f"/docs-{version}.tar.gz") - ) - - @function - async def build_nix( - self, src: dagger.Directory, container_name: str - ) -> dagger.File: - """Build a nix container from containers//default.nix. - - Returns the docker-archive tarball that can be loaded with - `docker load` or pushed with `skopeo copy`. - """ - nix_file = f"containers/{container_name}/default.nix" - # Resolve nixpkgs store path from flake registry, then build. - # Uses nix-instantiate to parse JSON (avoids needing jq). - resolve_and_build = ( - "set -e; " - "nix --extra-experimental-features 'nix-command flakes' " - "flake metadata nixpkgs --json > /tmp/nixpkgs.json; " - "NIXPKGS_PATH=$(nix-instantiate --eval -E " - '"(builtins.fromJSON (builtins.readFile /tmp/nixpkgs.json)).path" ' - "| tr -d '\"'); " - 'export NIX_PATH="nixpkgs=$NIXPKGS_PATH"; ' - 'echo "NIX_PATH=$NIX_PATH"; ' - 'nix-build "$1" -o /result' - ) - return await ( - dag.container() - .from_(NIX_IMAGE) - .with_directory("/workspace", src) - .with_workdir("/workspace") - .with_exec(["sh", "-c", resolve_and_build, "_", nix_file]) - .file("/result") - ) - - @function - async def nix_version(self, package: str) -> str: - """Extract the version of a nixpkgs package. Returns version string.""" - return await ( - dag.container() - .from_(NIX_IMAGE) - .with_exec( - [ - "nix", - "--extra-experimental-features", - "nix-command flakes", - "eval", - "--raw", - f"nixpkgs#{package}.version", - ] - ) - .stdout() - ) - - @function - async def flake_lock( - self, src: dagger.Directory, flake_path: str = "nixos/ringtail" - ) -> dagger.File: - """Resolve flake inputs and return updated flake.lock.""" - 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", - "lock", - "--accept-flake-config", - ] - ) - .file(f"/workspace/{flake_path}/flake.lock") - ) - - @function - async def export_yolov9( - self, - model_size: str = "c", - input_size: int = 640, - ) -> dagger.File: - """Export YOLOv9 pretrained weights to ONNX for Frigate NVR. - - Downloads pretrained weights from the WongKinYiu/yolov9 repo and - exports to ONNX with onnx-simplifier. Use with Frigate's - `model_type: yolo-generic`. - - Args: - model_size: Model variant: s (small), c (compact), e (extra-large). - input_size: Input resolution (width and height). 640 recommended. - """ - output_file = f"yolov9-{model_size}-{input_size}.onnx" - weights_url = ( - "https://github.com/WongKinYiu/yolov9/releases/download/v0.1/" - f"yolov9-{model_size}-converted.pt" - ) - # Patch torch.load to allow weights_only=False (required for - # YOLOv9 checkpoints that contain non-tensor objects). - patch_and_export = ( - "set -e; " - "cd /yolov9 && " - "sed -i " - '"s/ckpt = torch.load(attempt_download(w),' - " map_location='cpu')/ckpt = torch.load(attempt_download(w)," - " map_location='cpu', weights_only=False)/g\"" - " models/experimental.py && " - f"python3 export.py --weights ./weights.pt" - f" --imgsz {input_size} --simplify --include onnx && " - f"mv ./weights.onnx /output/{output_file}" - ) - return await ( - dag.container(platform=dagger.Platform("linux/amd64")) - .from_("python:3.11-slim") - .with_exec(["apt-get", "update", "-qq"]) - .with_exec( - [ - "apt-get", - "install", - "-y", - "-qq", - "git", - "libgl1", - "libglib2.0-0", - "cmake", - "build-essential", - ] - ) - .with_exec( - [ - "git", - "clone", - "--depth=1", - "https://github.com/WongKinYiu/yolov9.git", - "/yolov9", - ] - ) - .with_exec( - [ - "pip", - "install", - "--quiet", - "-r", - "/yolov9/requirements.txt", - "numpy<2", - "onnx>=1.18.0", - "onnxruntime", - "onnx-simplifier>=0.4.1", - "onnxscript", - ] - ) - .with_exec(["mkdir", "-p", "/output"]) - .with_file("/yolov9/weights.pt", dag.http(weights_url)) - .with_exec(["sh", "-c", patch_and_export]) - .file(f"/output/{output_file}") - ) - - @function - async def validate_workflows( - self, - src: dagger.Directory, - runner_version: str = "12.7.0", - ) -> str: - """Validate Forgejo Actions workflow files against runner schema. - - Runs forgejo-runner validate (available v9.0+) against all workflow - files in .forgejo/workflows/. Returns validation output. Fails if - any workflow has schema errors. - """ - return await ( - dag.container() - .from_(f"code.forgejo.org/forgejo/runner:{runner_version}") - .with_directory("/workspace", src) - .with_workdir("/workspace") - .with_exec(["forgejo-runner", "validate", "--directory", "."]) - .stdout() - ) - - @function - async def flake_update( - 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. - - 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_env_variable("SKIP_INPUTS", skip_inputs) - .with_exec(["sh", "-c", update_script]) - .file(f"/workspace/{flake_path}/flake.lock") - ) diff --git a/towncrier.toml b/towncrier.toml deleted file mode 100644 index 387040f..0000000 --- a/towncrier.toml +++ /dev/null @@ -1,40 +0,0 @@ -# Towncrier configuration for BlumeOps changelog -# https://towncrier.readthedocs.io/ - -[tool.towncrier] -directory = "docs/changelog.d" -filename = "CHANGELOG.md" -package = "" -title_format = "## [{version}] - {project_date}" -issue_format = "" -underlines = ["", "", ""] - -[[tool.towncrier.type]] -directory = "feature" -name = "Features" -showcontent = true - -[[tool.towncrier.type]] -directory = "bugfix" -name = "Bug Fixes" -showcontent = true - -[[tool.towncrier.type]] -directory = "infra" -name = "Infrastructure" -showcontent = true - -[[tool.towncrier.type]] -directory = "doc" -name = "Documentation" -showcontent = true - -[[tool.towncrier.type]] -directory = "ai" -name = "AI Assistance" -showcontent = true - -[[tool.towncrier.type]] -directory = "misc" -name = "Miscellaneous" -showcontent = true diff --git a/utils/qart/.gitignore b/utils/qart/.gitignore deleted file mode 100644 index 7b4f4ae..0000000 --- a/utils/qart/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -qart -qart-gen -*.png diff --git a/utils/qart/README.md b/utils/qart/README.md deleted file mode 100644 index 459f65e..0000000 --- a/utils/qart/README.md +++ /dev/null @@ -1,83 +0,0 @@ -# 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 deleted file mode 100644 index 51c6481..0000000 --- a/utils/qart/go.mod +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index 19a61d9..0000000 --- a/utils/qart/go.sum +++ /dev/null @@ -1,2 +0,0 @@ -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 deleted file mode 100644 index 103fe72..0000000 --- a/utils/qart/main.go +++ /dev/null @@ -1,753 +0,0 @@ -// 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 = ` - - - -QArt Tuner - - - -
-

QArt Tuner

-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
- - -
-
-
-
- QArt code -
- - -` diff --git a/utils/qart/mise.toml b/utils/qart/mise.toml deleted file mode 100644 index c173ea5..0000000 --- a/utils/qart/mise.toml +++ /dev/null @@ -1,11 +0,0 @@ -[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}" -""" diff --git a/uv.lock b/uv.lock deleted file mode 100644 index b86a906..0000000 --- a/uv.lock +++ /dev/null @@ -1,806 +0,0 @@ -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" }, -]