From adf6f4fbe9fc90c7bfaa0eb05a919fb18387fafc Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Fri, 16 Jan 2026 12:30:20 -0800 Subject: [PATCH] Add PostgreSQL and Miniflux services to tailnet (#16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Add PostgreSQL 18 as a new service at `pg.tail8d86e.ts.net:5432` - Add Miniflux RSS/Atom feed reader at `feed.tail8d86e.ts.net` - Both services managed via homebrew/brew services - Pulumi ACL tags added (tag:pg, tag:feed) - Alloy log collection configured for both services - Zettelkasten documentation updated ## Manual Setup Required Before running ansible, the following steps are needed on indri: ### 1. Apply Pulumi tags ```bash mise run tailnet-up ``` Then apply tags to indri in Tailscale admin console. ### 2. Create 1Password entries - miniflux PostgreSQL user password - miniflux admin password (for first run) ### 3. Set PostgreSQL user password (after ansible installs postgres) ```bash ssh indri '/opt/homebrew/opt/postgresql@18/bin/psql -c "ALTER USER miniflux PASSWORD '\''your-password'\'';"' ``` ### 4. Create password files on indri ```bash ssh indri 'echo "your-db-password" > ~/.miniflux-db-password && chmod 600 ~/.miniflux-db-password' ssh indri 'echo "your-admin-password" > ~/.miniflux-admin-password && chmod 600 ~/.miniflux-admin-password' ``` ### 5. Create ~/.pgpass for borgmatic ```bash ssh indri 'echo "localhost:5432:miniflux:miniflux:YOUR_PASSWORD" > ~/.pgpass && chmod 600 ~/.pgpass' ``` ### 6. Run ansible with first-run admin creation ```bash mise run provision-indri -- -e miniflux_create_admin=1 ``` ### 7. Update borgmatic config Add to `~/.config/borgmatic/config.yaml` on indri: ```yaml postgresql_databases: - name: miniflux hostname: localhost port: 5432 username: miniflux ``` ### 8. Cleanup after first run ```bash ssh indri 'rm ~/.miniflux-admin-password' ``` ## Test plan - [ ] Run `mise run tailnet-up` and verify Pulumi changes - [ ] Apply tags to indri in Tailscale admin - [ ] Run `mise run provision-indri -- --check --diff` for dry run - [ ] Run `mise run provision-indri -- -e miniflux_create_admin=1` - [ ] Approve services in Tailscale admin - [ ] Verify PostgreSQL: `ssh indri '/opt/homebrew/opt/postgresql@18/bin/pg_isready'` - [ ] Verify Miniflux: `curl https://feed.tail8d86e.ts.net/healthcheck` - [ ] Run `mise run indri-services-check` 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: https://forge.tail8d86e.ts.net/eblume/blumeops/pulls/16 --- CLAUDE.md | 2 + ansible/playbooks/indri.yml | 73 +++ ansible/roles/alloy/defaults/main.yml | 18 + ansible/roles/alloy/tasks/main.yml | 36 +- ansible/roles/alloy/templates/config.alloy.j2 | 19 + .../alloy/templates/postgres_queries.yaml.j2 | 20 + ansible/roles/borgmatic/defaults/main.yml | 41 ++ ansible/roles/borgmatic/tasks/main.yml | 14 +- .../roles/borgmatic/templates/config.yaml.j2 | 45 ++ .../grafana/files/dashboards/postgresql.json | 594 ++++++++++++++++++ ansible/roles/miniflux/defaults/main.yml | 36 ++ ansible/roles/miniflux/handlers/main.yml | 5 + ansible/roles/miniflux/meta/main.yml | 3 + ansible/roles/miniflux/tasks/main.yml | 64 ++ .../roles/miniflux/templates/miniflux.conf.j2 | 32 + ansible/roles/postgresql/defaults/main.yml | 41 ++ ansible/roles/postgresql/handlers/main.yml | 5 + ansible/roles/postgresql/tasks/main.yml | 184 ++++++ .../roles/postgresql/templates/pg_hba.conf.j2 | 16 + .../roles/tailscale_serve/defaults/main.yml | 10 + ansible/roles/tailscale_serve/meta/main.yml | 1 + ansible/roles/tailscale_serve/tasks/main.yml | 19 +- pulumi/__main__.py | 23 + pulumi/policy.hujson | 21 +- 24 files changed, 1309 insertions(+), 13 deletions(-) create mode 100644 ansible/roles/alloy/templates/postgres_queries.yaml.j2 create mode 100644 ansible/roles/borgmatic/templates/config.yaml.j2 create mode 100644 ansible/roles/grafana/files/dashboards/postgresql.json create mode 100644 ansible/roles/miniflux/defaults/main.yml create mode 100644 ansible/roles/miniflux/handlers/main.yml create mode 100644 ansible/roles/miniflux/meta/main.yml create mode 100644 ansible/roles/miniflux/tasks/main.yml create mode 100644 ansible/roles/miniflux/templates/miniflux.conf.j2 create mode 100644 ansible/roles/postgresql/defaults/main.yml create mode 100644 ansible/roles/postgresql/handlers/main.yml create mode 100644 ansible/roles/postgresql/tasks/main.yml create mode 100644 ansible/roles/postgresql/templates/pg_hba.conf.j2 diff --git a/CLAUDE.md b/CLAUDE.md index 44e655e..004b197 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,6 +20,8 @@ This displays all cards tagged `blumeops`, with the main project card first and You are encouraged to explore the zk, follow links, and propose updates to it as the project evolves. **Always keep the zettelkasten documentation up to date with any changes you make.** +Note: The zettelkasten is synced via Obsidian Sync, not git. You don't need to commit or push zk changes. + ## Rules for all sessions 1. Always start by reading the zk docs with the command above. diff --git a/ansible/playbooks/indri.yml b/ansible/playbooks/indri.yml index d038c89..40ca301 100644 --- a/ansible/playbooks/indri.yml +++ b/ansible/playbooks/indri.yml @@ -1,6 +1,75 @@ --- - name: Configure indri hosts: indri + + # Fetch all 1Password credentials upfront to minimize prompts + # Each role also fetches its own credentials (with 'when: is not defined') + # so they still work when running with --tags + # Tags ensure pre_tasks only run when relevant roles are included + pre_tasks: + - name: Fetch PostgreSQL superuser password + ansible.builtin.command: + cmd: op --vault vg6xf6vvfmoh5hqjjhlhbeoaie item get guxu3j7ajhjyey6xxl2ovsl2ui --fields password --reveal + delegate_to: localhost + register: _pg_superuser_pw + changed_when: false + no_log: true + tags: [postgresql] + + - name: Set PostgreSQL superuser password fact + ansible.builtin.set_fact: + pg_superuser_password: "{{ _pg_superuser_pw.stdout }}" + no_log: true + tags: [postgresql] + + - name: Fetch PostgreSQL alloy user password + ansible.builtin.command: + cmd: op --vault vg6xf6vvfmoh5hqjjhlhbeoaie item get guxu3j7ajhjyey6xxl2ovsl2ui --fields alloy-user-pw --reveal + delegate_to: localhost + register: _pg_alloy_pw + changed_when: false + no_log: true + tags: [alloy, postgresql] + + - name: Set PostgreSQL alloy password fact + ansible.builtin.set_fact: + alloy_postgres_password: "{{ _pg_alloy_pw.stdout }}" + no_log: true + tags: [alloy, postgresql] + + - name: Fetch miniflux database password + ansible.builtin.command: + cmd: op --vault vg6xf6vvfmoh5hqjjhlhbeoaie item get ns6wylqiuqgczpo7gq2akaxbti --fields password --reveal + delegate_to: localhost + register: _miniflux_db_pw + changed_when: false + no_log: true + tags: [miniflux, postgresql] + + - name: Set miniflux passwords fact + ansible.builtin.set_fact: + miniflux_db_password: "{{ _miniflux_db_pw.stdout }}" + no_log: true + tags: [miniflux, postgresql] + + - name: Fetch borgmatic database password + ansible.builtin.command: + cmd: op --vault vg6xf6vvfmoh5hqjjhlhbeoaie item get mw2bv5we7woicjza7hc6s44yvy --fields db-password --reveal + delegate_to: localhost + register: _borgmatic_db_pw + changed_when: false + no_log: true + tags: [postgresql] + + - name: Build PostgreSQL user password lookup + ansible.builtin.set_fact: + pg_user_passwords: + miniflux: "{{ _miniflux_db_pw.stdout }}" + borgmatic: "{{ _borgmatic_db_pw.stdout }}" + alloy: "{{ _pg_alloy_pw.stdout }}" + no_log: true + tags: [postgresql] + roles: - role: loki tags: loki @@ -26,5 +95,9 @@ tags: devpi_metrics - role: plex_metrics tags: plex_metrics + - role: postgresql + tags: postgresql + - role: miniflux + tags: miniflux - role: tailscale_serve tags: tailscale-serve diff --git a/ansible/roles/alloy/defaults/main.yml b/ansible/roles/alloy/defaults/main.yml index a81d0ef..45cc52b 100644 --- a/ansible/roles/alloy/defaults/main.yml +++ b/ansible/roles/alloy/defaults/main.yml @@ -40,6 +40,12 @@ alloy_brew_logs: - path: /opt/homebrew/var/transmission/transmission-daemon.log service: transmission stream: stdout + - path: /opt/homebrew/var/log/postgresql@18.log + service: postgresql + stream: stdout + - path: /opt/homebrew/var/log/miniflux.log + service: miniflux + stream: stdout alloy_mcquack_logs: - path: /Users/erichblume/Library/Logs/mcquack.devpi.out.log @@ -68,3 +74,15 @@ alloy_plex_logs: # Enable log collection (requires Loki to be running) alloy_collect_logs: true + +# PostgreSQL metrics collection +alloy_collect_postgres: true +alloy_postgres_host: localhost +alloy_postgres_port: 5432 +alloy_postgres_user: alloy +alloy_postgres_database: postgres + +# 1Password settings for PostgreSQL metrics +alloy_op_vault: vg6xf6vvfmoh5hqjjhlhbeoaie +alloy_op_postgres_item: guxu3j7ajhjyey6xxl2ovsl2ui +alloy_op_postgres_field: alloy-user-pw diff --git a/ansible/roles/alloy/tasks/main.yml b/ansible/roles/alloy/tasks/main.yml index b6f5dad..4fb6225 100644 --- a/ansible/roles/alloy/tasks/main.yml +++ b/ansible/roles/alloy/tasks/main.yml @@ -25,12 +25,46 @@ state: directory mode: '0755' +# === Fetch PostgreSQL password from 1Password === +# Skipped when running full playbook (pre_tasks sets it) +# but runs when using --tags alloy + +- name: Fetch PostgreSQL metrics password from 1Password + ansible.builtin.command: + 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 + no_log: true + when: + - alloy_collect_postgres | default(false) + - alloy_postgres_password is not defined + +- name: Set PostgreSQL password fact + ansible.builtin.set_fact: + alloy_postgres_password: "{{ alloy_postgres_password_result.stdout }}" + no_log: true + when: + - alloy_collect_postgres | default(false) + - alloy_postgres_password is not defined + +# === Deploy configuration === + +- name: Deploy PostgreSQL custom queries config + ansible.builtin.template: + src: postgres_queries.yaml.j2 + dest: "{{ alloy_config_dir }}/postgres_queries.yaml" + mode: '0600' + notify: restart alloy + when: alloy_collect_postgres | default(false) + - name: Deploy alloy configuration ansible.builtin.template: src: config.alloy.j2 dest: "{{ alloy_config_dir }}/config.alloy" - mode: '0644' + mode: '0600' notify: restart alloy + no_log: true - name: Ensure alloy service is started ansible.builtin.command: brew services start grafana-alloy diff --git a/ansible/roles/alloy/templates/config.alloy.j2 b/ansible/roles/alloy/templates/config.alloy.j2 index 069d8d4..e0c1cad 100644 --- a/ansible/roles/alloy/templates/config.alloy.j2 +++ b/ansible/roles/alloy/templates/config.alloy.j2 @@ -35,6 +35,25 @@ prometheus.remote_write "prometheus" { } } +{% if alloy_collect_postgres | default(false) %} +// ============== POSTGRESQL METRICS ============== + +// PostgreSQL exporter (read-only metrics via pg_monitor role) +prometheus.exporter.postgres "postgresql" { + data_source_names = ["postgresql://{{ alloy_postgres_user }}:{{ alloy_postgres_password | urlencode }}@{{ alloy_postgres_host }}:{{ alloy_postgres_port }}/{{ alloy_postgres_database }}?sslmode=disable"] + + // Custom queries for vacuum and XID monitoring + custom_queries_config_path = "/opt/homebrew/etc/grafana-alloy/postgres_queries.yaml" +} + +// Scrape PostgreSQL metrics +prometheus.scrape "postgresql" { + targets = prometheus.exporter.postgres.postgresql.targets + forward_to = [prometheus.relabel.instance.receiver] + scrape_interval = "{{ alloy_scrape_interval }}" +} +{% endif %} + {% if alloy_collect_logs %} // ============== LOG COLLECTION ============== diff --git a/ansible/roles/alloy/templates/postgres_queries.yaml.j2 b/ansible/roles/alloy/templates/postgres_queries.yaml.j2 new file mode 100644 index 0000000..6714995 --- /dev/null +++ b/ansible/roles/alloy/templates/postgres_queries.yaml.j2 @@ -0,0 +1,20 @@ +# {{ ansible_managed }} +# Custom PostgreSQL queries for XID exhaustion monitoring + +pg_database_xid_age: + query: | + SELECT datname, + age(datfrozenxid) as xid_age, + current_setting('autovacuum_freeze_max_age')::bigint as freeze_max_age + FROM pg_database + WHERE datallowconn + metrics: + - datname: + usage: "LABEL" + description: "Database name" + - xid_age: + usage: "GAUGE" + description: "Age of oldest unfrozen transaction ID" + - freeze_max_age: + usage: "GAUGE" + description: "autovacuum_freeze_max_age setting" diff --git a/ansible/roles/borgmatic/defaults/main.yml b/ansible/roles/borgmatic/defaults/main.yml index 1727c48..243ba4d 100644 --- a/ansible/roles/borgmatic/defaults/main.yml +++ b/ansible/roles/borgmatic/defaults/main.yml @@ -1,7 +1,48 @@ --- borgmatic_config: /Users/erichblume/.config/borgmatic/config.yaml +borgmatic_config_dir: /Users/erichblume/.config/borgmatic borgmatic_log_dir: /Users/erichblume/Library/Logs # Schedule: runs daily at 2:00 AM borgmatic_schedule_hour: 2 borgmatic_schedule_minute: 0 + +# Source directories to back up +borgmatic_source_directories: + - /Users/erichblume/code/personal/zk + - /opt/homebrew/var/forgejo + - /Users/erichblume/code/3rd/kiwix-tools + - /Users/erichblume/.config/borgmatic + - /Users/erichblume/Documents + - /Users/erichblume/Pictures + - /Users/erichblume/devpi + - /opt/homebrew/var/loki + +# Backup repository +borgmatic_repositories: + - path: /Volumes/backups/borg/ + label: sifaka-borg-backups + encryption: repokey + append_only: true + +# Exclude patterns +borgmatic_exclude_patterns: + # Exclude mirrored PyPI cache (only backup private packages) + - /Users/erichblume/devpi/+files/root/pypi + - /opt/homebrew/var/loki + +# Encryption passcommand (reads borg passphrase) +borgmatic_encryption_passcommand: cat /Users/erichblume/.borg/config.yaml + +# Retention policy +borgmatic_keep_daily: 7 +borgmatic_keep_monthly: 12 +borgmatic_keep_yearly: 1000 + +# PostgreSQL databases to backup (streamed via pg_dump) +# Password is read from ~/.pgpass (managed by postgresql role) +borgmatic_postgresql_databases: + - name: miniflux + hostname: localhost + port: 5432 + username: borgmatic diff --git a/ansible/roles/borgmatic/tasks/main.yml b/ansible/roles/borgmatic/tasks/main.yml index f0c749a..db0704d 100644 --- a/ansible/roles/borgmatic/tasks/main.yml +++ b/ansible/roles/borgmatic/tasks/main.yml @@ -1,6 +1,18 @@ --- # Note: borgmatic is installed via mise (pipx), not managed here. -# This role manages only the scheduled LaunchAgent. +# This role manages the config file and scheduled LaunchAgent. + +- name: Ensure borgmatic config directory exists + ansible.builtin.file: + path: "{{ borgmatic_config_dir }}" + state: directory + mode: '0700' + +- name: Deploy borgmatic configuration + ansible.builtin.template: + src: config.yaml.j2 + dest: "{{ borgmatic_config }}" + mode: '0600' - name: Deploy borgmatic LaunchAgent plist ansible.builtin.template: diff --git a/ansible/roles/borgmatic/templates/config.yaml.j2 b/ansible/roles/borgmatic/templates/config.yaml.j2 new file mode 100644 index 0000000..4a782bf --- /dev/null +++ b/ansible/roles/borgmatic/templates/config.yaml.j2 @@ -0,0 +1,45 @@ +# {{ ansible_managed }} + +source_directories: +{% for dir in borgmatic_source_directories %} + - {{ dir }} +{% endfor %} + +source_directories_must_exist: true + +repositories: +{% for repo in borgmatic_repositories %} + - path: {{ repo.path }} + label: {{ repo.label }} +{% if repo.encryption is defined %} + encryption: {{ repo.encryption }} +{% endif %} +{% if repo.append_only is defined %} + append_only: {{ repo.append_only | lower }} +{% endif %} +{% endfor %} + +{% if borgmatic_exclude_patterns %} +exclude_patterns: +{% for pattern in borgmatic_exclude_patterns %} + - {{ pattern }} +{% endfor %} +{% endif %} + +encryption_passcommand: {{ borgmatic_encryption_passcommand }} + +# Retention policy +keep_daily: {{ borgmatic_keep_daily }} +keep_monthly: {{ borgmatic_keep_monthly }} +keep_yearly: {{ borgmatic_keep_yearly }} + +{% if borgmatic_postgresql_databases %} +# PostgreSQL database backups (streamed via pg_dump) +postgresql_databases: +{% for db in borgmatic_postgresql_databases %} + - name: {{ db.name }} + hostname: {{ db.hostname | default('localhost') }} + port: {{ db.port | default(5432) }} + username: {{ db.username }} +{% endfor %} +{% endif %} diff --git a/ansible/roles/grafana/files/dashboards/postgresql.json b/ansible/roles/grafana/files/dashboards/postgresql.json new file mode 100644 index 0000000..8a561c7 --- /dev/null +++ b/ansible/roles/grafana/files/dashboards/postgresql.json @@ -0,0 +1,594 @@ +{ + "annotations": { + "list": [] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 80 }, + { "color": "red", "value": 100 } + ] + }, + "unit": "short" + } + }, + "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" + }, + "targets": [ + { + "expr": "pg_up", + "refId": "A" + } + ], + "title": "PostgreSQL Up", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null } + ] + }, + "unit": "short" + } + }, + "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" + }, + "targets": [ + { + "expr": "pg_stat_activity_count{state=\"active\"}", + "refId": "A" + } + ], + "title": "Active Connections", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null } + ] + }, + "unit": "short" + } + }, + "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": [ + { + "expr": "sum(pg_stat_activity_count)", + "refId": "A" + } + ], + "title": "Total Connections", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null } + ] + }, + "unit": "decbytes" + } + }, + "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": [ + { + "expr": "sum(pg_database_size_bytes)", + "refId": "A" + } + ], + "title": "Total Database Size", + "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 + }, + "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" + } + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 4 }, + "id": 5, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "expr": "pg_stat_activity_count", + "legendFormat": "{{state}}", + "refId": "A" + } + ], + "title": "Connections by State", + "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 + }, + "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": "decbytes" + } + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 4 }, + "id": 6, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "expr": "pg_database_size_bytes{datname!~\"template.*\"}", + "legendFormat": "{{datname}}", + "refId": "A" + } + ], + "title": "Database Size", + "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 + }, + "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": "ops" + } + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 12 }, + "id": 7, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "expr": "rate(pg_stat_database_tup_fetched{datname!~\"template.*\"}[5m])", + "legendFormat": "{{datname}} fetched", + "refId": "A" + }, + { + "expr": "rate(pg_stat_database_tup_inserted{datname!~\"template.*\"}[5m])", + "legendFormat": "{{datname}} inserted", + "refId": "B" + }, + { + "expr": "rate(pg_stat_database_tup_updated{datname!~\"template.*\"}[5m])", + "legendFormat": "{{datname}} updated", + "refId": "C" + }, + { + "expr": "rate(pg_stat_database_tup_deleted{datname!~\"template.*\"}[5m])", + "legendFormat": "{{datname}} deleted", + "refId": "D" + } + ], + "title": "Tuple Operations 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 + }, + "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" + } + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 12 }, + "id": 8, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "expr": "rate(pg_stat_database_xact_commit{datname!~\"template.*\"}[5m])", + "legendFormat": "{{datname}} commits", + "refId": "A" + }, + { + "expr": "rate(pg_stat_database_xact_rollback{datname!~\"template.*\"}[5m])", + "legendFormat": "{{datname}} rollbacks", + "refId": "B" + } + ], + "title": "Transactions 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 + }, + "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" + } + }, + "gridPos": { "h": 6, "w": 12, "x": 12, "y": 20 }, + "id": 10, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "expr": "pg_database_xid_age_xid_age", + "legendFormat": "{{datname}}", + "refId": "A" + } + ], + "title": "XID Age Over Time", + "type": "timeseries" + } + ], + "schemaVersion": 39, + "tags": ["postgresql", "database"], + "templating": { + "list": [] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "PostgreSQL", + "uid": "postgresql", + "version": 4 +} diff --git a/ansible/roles/miniflux/defaults/main.yml b/ansible/roles/miniflux/defaults/main.yml new file mode 100644 index 0000000..5419803 --- /dev/null +++ b/ansible/roles/miniflux/defaults/main.yml @@ -0,0 +1,36 @@ +--- +# Miniflux configuration + +# Network settings +miniflux_listen_addr: "127.0.0.1:8080" +miniflux_base_url: "https://feed.tail8d86e.ts.net/" + +# Database connection (password fetched from 1Password) +miniflux_db_host: localhost +miniflux_db_port: 5432 +miniflux_db_name: miniflux +miniflux_db_user: miniflux + +# Config paths +miniflux_config_file: /opt/homebrew/etc/miniflux.conf + +# 1Password settings for admin password +miniflux_op_vault: vg6xf6vvfmoh5hqjjhlhbeoaie +miniflux_op_item: ns6wylqiuqgczpo7gq2akaxbti + +# First run settings +# Set miniflux_create_admin to 1 for initial setup, then 0 after +miniflux_create_admin: 0 +miniflux_admin_username: admin + +# Always run migrations to keep schema updated +miniflux_run_migrations: 1 + +# Polling settings +miniflux_polling_frequency: 60 +miniflux_batch_size: 100 +miniflux_polling_scheduler: "entry_frequency" + +# Cleanup settings (archive old entries) +miniflux_cleanup_archive_unread_days: 180 +miniflux_cleanup_archive_read_days: 60 diff --git a/ansible/roles/miniflux/handlers/main.yml b/ansible/roles/miniflux/handlers/main.yml new file mode 100644 index 0000000..936d0a3 --- /dev/null +++ b/ansible/roles/miniflux/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: restart miniflux + ansible.builtin.command: brew services restart miniflux + async: 120 + poll: 0 diff --git a/ansible/roles/miniflux/meta/main.yml b/ansible/roles/miniflux/meta/main.yml new file mode 100644 index 0000000..92e0133 --- /dev/null +++ b/ansible/roles/miniflux/meta/main.yml @@ -0,0 +1,3 @@ +--- +dependencies: + - role: postgresql diff --git a/ansible/roles/miniflux/tasks/main.yml b/ansible/roles/miniflux/tasks/main.yml new file mode 100644 index 0000000..8c3f691 --- /dev/null +++ b/ansible/roles/miniflux/tasks/main.yml @@ -0,0 +1,64 @@ +--- +# Miniflux installation and configuration +# +# Prerequisites: +# - PostgreSQL role has run (creates database, user, and ~/.miniflux-db-password) +# - 1Password CLI authenticated on control machine +# +# First run: +# mise run provision-indri -- --tags miniflux -e miniflux_create_admin=1 + +- name: Install miniflux via homebrew + community.general.homebrew: + name: miniflux + state: present + +# === Fetch passwords from 1Password === +# These are skipped when running full playbook (pre_tasks sets them) +# but run when using --tags miniflux + +- name: Fetch miniflux database password from 1Password + ansible.builtin.command: + cmd: op --vault {{ miniflux_op_vault }} item get {{ miniflux_op_item }} --fields password --reveal + delegate_to: localhost + register: miniflux_db_password_result + changed_when: false + no_log: true + when: miniflux_db_password is not defined + +- name: Set database password fact + ansible.builtin.set_fact: + miniflux_db_password: "{{ miniflux_db_password_result.stdout }}" + no_log: true + when: miniflux_db_password is not defined + +- name: Fetch miniflux admin password from 1Password (for first run) + ansible.builtin.command: + cmd: op --vault {{ miniflux_op_vault }} item get {{ miniflux_op_item }} --fields admin-password --reveal + delegate_to: localhost + register: miniflux_admin_password_result + changed_when: false + no_log: true + when: miniflux_create_admin | int == 1 + +- name: Set admin password fact + ansible.builtin.set_fact: + miniflux_admin_password: "{{ miniflux_admin_password_result.stdout }}" + no_log: true + when: miniflux_create_admin | int == 1 + +# === Deploy configuration === + +- name: Deploy miniflux configuration + ansible.builtin.template: + src: miniflux.conf.j2 + dest: "{{ miniflux_config_file }}" + mode: '0600' + notify: restart miniflux + no_log: true + +- name: Ensure miniflux service is started + ansible.builtin.command: brew services start miniflux + register: brew_start + changed_when: "'Successfully started' in brew_start.stdout" + failed_when: false diff --git a/ansible/roles/miniflux/templates/miniflux.conf.j2 b/ansible/roles/miniflux/templates/miniflux.conf.j2 new file mode 100644 index 0000000..2e23e07 --- /dev/null +++ b/ansible/roles/miniflux/templates/miniflux.conf.j2 @@ -0,0 +1,32 @@ +# {{ ansible_managed }} +# Miniflux configuration - KEY=VALUE format +# Passwords fetched from 1Password at deploy time. + +# Server settings +LISTEN_ADDR={{ miniflux_listen_addr }} +BASE_URL={{ miniflux_base_url }} + +# Database connection +DATABASE_URL=postgres://{{ miniflux_db_user }}:{{ miniflux_db_password }}@{{ miniflux_db_host }}:{{ miniflux_db_port }}/{{ miniflux_db_name }}?sslmode=disable + +# Migrations (always run to keep schema updated) +RUN_MIGRATIONS={{ miniflux_run_migrations }} + +{% if miniflux_create_admin | int == 1 and miniflux_admin_password is defined %} +# First run admin creation (remove these after initial setup) +CREATE_ADMIN=1 +ADMIN_USERNAME={{ miniflux_admin_username }} +ADMIN_PASSWORD={{ miniflux_admin_password }} +{% endif %} + +# Polling settings +POLLING_FREQUENCY={{ miniflux_polling_frequency }} +BATCH_SIZE={{ miniflux_batch_size }} +POLLING_SCHEDULER={{ miniflux_polling_scheduler }} + +# Cleanup settings +CLEANUP_ARCHIVE_UNREAD_DAYS={{ miniflux_cleanup_archive_unread_days }} +CLEANUP_ARCHIVE_READ_DAYS={{ miniflux_cleanup_archive_read_days }} + +# Logging +LOG_LEVEL=info diff --git a/ansible/roles/postgresql/defaults/main.yml b/ansible/roles/postgresql/defaults/main.yml new file mode 100644 index 0000000..5e145bf --- /dev/null +++ b/ansible/roles/postgresql/defaults/main.yml @@ -0,0 +1,41 @@ +--- +# PostgreSQL configuration + +# Formula and version +postgresql_formula: postgresql@18 + +# Paths (keg-only formula on macOS) +postgresql_bin_dir: /opt/homebrew/opt/postgresql@18/bin +postgresql_data_dir: /opt/homebrew/var/postgresql@18 +postgresql_config_dir: /opt/homebrew/var/postgresql@18 + +# Network settings +postgresql_port: 5432 +postgresql_listen_addresses: "localhost" + +# 1Password vault and item IDs for credentials +postgresql_op_vault: vg6xf6vvfmoh5hqjjhlhbeoaie +postgresql_op_superuser_item: guxu3j7ajhjyey6xxl2ovsl2ui +postgresql_op_miniflux_item: ns6wylqiuqgczpo7gq2akaxbti +postgresql_op_borgmatic_item: mw2bv5we7woicjza7hc6s44yvy + +# Databases to create +postgresql_databases: + - name: miniflux + owner: miniflux + +# Users to create (passwords fetched from 1Password) +postgresql_users: + - name: miniflux + op_item: "{{ postgresql_op_miniflux_item }}" + op_field: password + - name: borgmatic + op_item: "{{ postgresql_op_borgmatic_item }}" + op_field: db-password + roles: + - pg_read_all_data + - name: alloy + op_item: "{{ postgresql_op_superuser_item }}" + op_field: alloy-user-pw + roles: + - pg_monitor diff --git a/ansible/roles/postgresql/handlers/main.yml b/ansible/roles/postgresql/handlers/main.yml new file mode 100644 index 0000000..a6ce462 --- /dev/null +++ b/ansible/roles/postgresql/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: restart postgresql + ansible.builtin.command: brew services restart {{ postgresql_formula }} + async: 120 + poll: 0 diff --git a/ansible/roles/postgresql/tasks/main.yml b/ansible/roles/postgresql/tasks/main.yml new file mode 100644 index 0000000..65a98f6 --- /dev/null +++ b/ansible/roles/postgresql/tasks/main.yml @@ -0,0 +1,184 @@ +--- +# PostgreSQL installation and configuration +# +# Passwords are fetched from 1Password at runtime using the `op` CLI. +# Requires: `op` authenticated on the control machine (run `op signin` first). + +- name: Install {{ postgresql_formula }} via homebrew + community.general.homebrew: + name: "{{ postgresql_formula }}" + state: present + +# === Fetch passwords from 1Password (on control machine) === +# These are skipped when running full playbook (pre_tasks sets them) +# but run when using --tags postgresql + +- name: Fetch superuser password from 1Password + ansible.builtin.command: + cmd: op --vault {{ postgresql_op_vault }} item get {{ postgresql_op_superuser_item }} --fields password --reveal + delegate_to: localhost + register: pg_superuser_password_result + changed_when: false + no_log: true + when: pg_superuser_password is not defined + +- name: Set superuser password fact + ansible.builtin.set_fact: + pg_superuser_password: "{{ pg_superuser_password_result.stdout }}" + no_log: true + when: pg_superuser_password is not defined + +- name: Fetch user passwords from 1Password + ansible.builtin.command: + cmd: op --vault {{ postgresql_op_vault }} item get {{ item.op_item }} --fields {{ item.op_field }} --reveal + delegate_to: localhost + loop: "{{ postgresql_users }}" + register: pg_user_passwords_result + changed_when: false + no_log: true + when: pg_user_passwords is not defined + +- name: Build user password lookup + ansible.builtin.set_fact: + pg_user_passwords: "{{ pg_user_passwords | default({}) | combine({item.item.name: item.stdout}) }}" + loop: "{{ pg_user_passwords_result.results }}" + no_log: true + when: pg_user_passwords is not defined + +# === Initialize PostgreSQL cluster === + +- name: Check if postgresql data directory is initialized + ansible.builtin.stat: + path: "{{ postgresql_data_dir }}/PG_VERSION" + register: pg_data + +- name: Create temporary password file for initdb + ansible.builtin.copy: + content: "{{ pg_superuser_password }}" + dest: /tmp/.pg_init_pwfile + mode: '0600' + when: not pg_data.stat.exists + no_log: true + +- name: Initialize postgresql database cluster with superuser password + ansible.builtin.command: > + {{ postgresql_bin_dir }}/initdb + --locale=en_US.UTF-8 -E UTF-8 + --pwfile=/tmp/.pg_init_pwfile + {{ postgresql_data_dir }} + when: not pg_data.stat.exists + +- name: Remove temporary password file + ansible.builtin.file: + path: /tmp/.pg_init_pwfile + state: absent + when: not pg_data.stat.exists + +# === Configure and start PostgreSQL === + +- name: Deploy pg_hba.conf + ansible.builtin.template: + src: pg_hba.conf.j2 + dest: "{{ postgresql_config_dir }}/pg_hba.conf" + mode: '0600' + notify: restart postgresql + +- name: Ensure postgresql service is started + ansible.builtin.command: brew services start {{ postgresql_formula }} + register: brew_start + changed_when: "'Successfully started' in brew_start.stdout" + failed_when: false + +- name: Wait for postgresql to accept connections + ansible.builtin.command: > + {{ postgresql_bin_dir }}/pg_isready -h localhost -p {{ postgresql_port }} + register: pg_ready + until: pg_ready.rc == 0 + retries: 10 + delay: 2 + changed_when: false + +# === Create users with passwords === + +- name: Check if postgresql users exist + ansible.builtin.command: > + {{ postgresql_bin_dir }}/psql -h localhost -d postgres -tAc + "SELECT 1 FROM pg_roles WHERE rolname = '{{ item.name }}';" + environment: + PGPASSWORD: "{{ pg_superuser_password }}" + loop: "{{ postgresql_users }}" + register: user_check + changed_when: false + failed_when: false + no_log: true + +- name: Create postgresql users with passwords + ansible.builtin.command: > + {{ postgresql_bin_dir }}/psql -h localhost -d postgres -c + "CREATE USER {{ item.item.name }} WITH PASSWORD '{{ pg_user_passwords[item.item.name] }}';" + environment: + PGPASSWORD: "{{ pg_superuser_password }}" + loop: "{{ user_check.results }}" + when: item.stdout != "1" + changed_when: true + no_log: true + +- name: Update postgresql user passwords (idempotent) + ansible.builtin.command: > + {{ postgresql_bin_dir }}/psql -h localhost -d postgres -c + "ALTER USER {{ item.name }} WITH PASSWORD '{{ pg_user_passwords[item.name] }}';" + environment: + PGPASSWORD: "{{ pg_superuser_password }}" + loop: "{{ postgresql_users }}" + changed_when: false + no_log: true + +# === Grant roles to users === + +- name: Grant roles to users + ansible.builtin.command: > + {{ postgresql_bin_dir }}/psql -h localhost -d postgres -c "GRANT {{ item.1 }} TO {{ item.0.name }};" + environment: + PGPASSWORD: "{{ pg_superuser_password }}" + loop: "{{ postgresql_users | subelements('roles', skip_missing=True) }}" + changed_when: false + no_log: true + +# === Create databases === + +- name: Check if postgresql databases exist + ansible.builtin.command: > + {{ postgresql_bin_dir }}/psql -h localhost -d postgres -tAc + "SELECT 1 FROM pg_database WHERE datname = '{{ item.name }}';" + environment: + PGPASSWORD: "{{ pg_superuser_password }}" + loop: "{{ postgresql_databases }}" + register: db_check + changed_when: false + failed_when: false + no_log: true + +- name: Create postgresql databases + ansible.builtin.command: > + {{ postgresql_bin_dir }}/createdb -h localhost + --owner={{ item.item.owner }} + {{ item.item.name }} + environment: + PGPASSWORD: "{{ pg_superuser_password }}" + loop: "{{ db_check.results }}" + when: item.stdout != "1" + changed_when: true + no_log: true + +# === Write credential files for local access === + +# .pgpass is used by borgmatic for pg_dump backups +# Only includes read-only roles (borgmatic has pg_read_all_data) +- name: Write .pgpass file for borgmatic backups + ansible.builtin.copy: + content: | + # Managed by ansible - only read-only roles + localhost:{{ postgresql_port }}:*:borgmatic:{{ pg_user_passwords['borgmatic'] }} + dest: ~/.pgpass + mode: '0600' + no_log: true diff --git a/ansible/roles/postgresql/templates/pg_hba.conf.j2 b/ansible/roles/postgresql/templates/pg_hba.conf.j2 new file mode 100644 index 0000000..992897a --- /dev/null +++ b/ansible/roles/postgresql/templates/pg_hba.conf.j2 @@ -0,0 +1,16 @@ +# {{ ansible_managed }} +# PostgreSQL Client Authentication Configuration File +# +# All connections require password authentication (scram-sha-256). +# Passwords are managed via 1Password and fetched by ansible at runtime. + +# TYPE DATABASE USER ADDRESS METHOD + +# Local connections (Unix socket) +local all all scram-sha-256 + +# IPv4 local connections (services connect via TCP) +host all all 127.0.0.1/32 scram-sha-256 + +# IPv6 local connections +host all all ::1/128 scram-sha-256 diff --git a/ansible/roles/tailscale_serve/defaults/main.yml b/ansible/roles/tailscale_serve/defaults/main.yml index 8b1523d..b739213 100644 --- a/ansible/roles/tailscale_serve/defaults/main.yml +++ b/ansible/roles/tailscale_serve/defaults/main.yml @@ -25,3 +25,13 @@ tailscale_services: https: port: 443 upstream: http://127.0.0.1:3141 + + - name: svc:pg + tcp: + port: 5432 + upstream: tcp://localhost:5432 + + - name: svc:feed + https: + port: 443 + upstream: http://localhost:8080 diff --git a/ansible/roles/tailscale_serve/meta/main.yml b/ansible/roles/tailscale_serve/meta/main.yml index 904ced2..bfc3021 100644 --- a/ansible/roles/tailscale_serve/meta/main.yml +++ b/ansible/roles/tailscale_serve/meta/main.yml @@ -4,3 +4,4 @@ dependencies: - role: forgejo - role: kiwix - role: devpi + - role: miniflux diff --git a/ansible/roles/tailscale_serve/tasks/main.yml b/ansible/roles/tailscale_serve/tasks/main.yml index 6ed7442..d7e9a8f 100644 --- a/ansible/roles/tailscale_serve/tasks/main.yml +++ b/ansible/roles/tailscale_serve/tasks/main.yml @@ -4,22 +4,33 @@ register: serve_status changed_when: false +- name: Parse serve status + ansible.builtin.set_fact: + serve_config: "{{ (serve_status.stdout | 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_services }}" - when: item.https is defined + when: + - item.https is defined + - serve_config[item.name] is not defined or serve_config[item.name].Web is not defined register: https_result - changed_when: "'already serving' not in https_result.stderr | default('')" 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_services }}" - when: item.tcp is defined + when: + - item.tcp is defined + - serve_config[item.name] is not defined or + serve_config[item.name].TCP is not defined or + serve_config[item.name].TCP[item.tcp.port | string] is not defined or + serve_config[item.name].TCP[item.tcp.port | string].TCPForward is not defined register: tcp_result - changed_when: "'already serving' not in tcp_result.stderr | default('')" failed_when: false diff --git a/pulumi/__main__.py b/pulumi/__main__.py index 8971cae..8c5cd39 100644 --- a/pulumi/__main__.py +++ b/pulumi/__main__.py @@ -14,5 +14,28 @@ acl = tailscale.Acl( acl=policy_content, ) +# ============== Device Tags ============== +# Manage tags for devices in the tailnet + +# indri - Mac Mini M1 running homelab services +indri = tailscale.get_device(name="indri.tail8d86e.ts.net") +indri_tags = tailscale.DeviceTags( + "indri-tags", + device_id=indri.node_id, + tags=[ + "tag:homelab", + "tag:grafana", + "tag:forge", + "tag:kiwix", + "tag:devpi", + "tag:loki", + "tag:pg", + "tag:feed", + "tag:blumeops", + ], +) + # Export useful info pulumi.export("acl_id", acl.id) +pulumi.export("indri_device_id", indri.node_id) +pulumi.export("indri_tags", indri_tags.tags) diff --git a/pulumi/policy.hujson b/pulumi/policy.hujson index 64bc11d..45ad401 100644 --- a/pulumi/policy.hujson +++ b/pulumi/policy.hujson @@ -72,25 +72,32 @@ "tagOwners": { // Grafana service host tag - "tag:grafana": ["autogroup:admin"], + "tag:grafana": ["autogroup:admin", "tag:blumeops"], // This tag applies to instances which are meant to be accessible in my homelab. These instances can be SSH'ed in to by any member of the admin autogroup. - "tag:homelab": ["autogroup:admin"], + "tag:homelab": ["autogroup:admin", "tag:blumeops"], // Kiwix, a local wiki server. I use it to create mirrors of wikipedia. - "tag:kiwix": ["autogroup:admin"], + "tag:kiwix": ["autogroup:admin", "tag:blumeops"], // Service tag for forgejo, scm host and code forge - "tag:forge": ["autogroup:admin"], + "tag:forge": ["autogroup:admin", "tag:blumeops"], // devpi pypi index - "tag:devpi": ["autogroup:admin"], + "tag:devpi": ["autogroup:admin", "tag:blumeops"], // Loki log collection - "tag:loki": ["autogroup:admin"], + "tag:loki": ["autogroup:admin", "tag:blumeops"], + + // PostgreSQL database server + "tag:pg": ["autogroup:admin", "tag:blumeops"], + + // Miniflux RSS/Atom feed reader + "tag:feed": ["autogroup:admin", "tag:blumeops"], // This tag is applied to resources modified by blumeops-pulumi IaC - "tag:blumeops": ["autogroup:admin"], + // Includes itself so the OAuth client can apply it to devices + "tag:blumeops": ["autogroup:admin", "tag:blumeops"], }, // Test access rules every time they're saved.