infra/stacks/wealthfolio/main.tf

779 lines
25 KiB
Terraform
Raw Normal View History

variable "tls_secret_name" {
type = string
sensitive = true
}
2026-02-23 22:05:28 +00:00
variable "nfs_server" { type = string }
variable "postgresql_host" { type = string }
resource "kubernetes_namespace" "wealthfolio" {
metadata {
name = "wealthfolio"
labels = {
"istio-injection" : "disabled"
tier = local.tiers.aux
}
}
[infra] Suppress Goldilocks vpa-update-mode label drift on all namespaces [ci skip] ## Context Wave 3B-continued: the Goldilocks VPA dashboard (stacks/vpa) runs a Kyverno ClusterPolicy `goldilocks-vpa-auto-mode` that mutates every namespace with `metadata.labels["goldilocks.fairwinds.com/vpa-update-mode"] = "off"`. This is intentional — Terraform owns container resource limits, and Goldilocks should only provide recommendations, never auto-update. The label is how Goldilocks decides per-namespace whether to run its VPA in `off` mode. Effect on Terraform: every `kubernetes_namespace` resource shows the label as pending-removal (`-> null`) on every `scripts/tg plan`. Dawarich survey 2026-04-18 confirmed the drift. Cluster-side count: 88 namespaces carry the label (`kubectl get ns -o json | jq ... | wc -l`). Every TF-managed namespace is affected. This commit brings the intentional admission drift under the same `# KYVERNO_LIFECYCLE_V1` discoverability marker introduced in c9d221d5 for the ndots dns_config pattern. The marker now stands generically for any Kyverno admission-webhook drift suppression; the inline comment records which specific policy stamps which specific field so future grep audits show why each suppression exists. ## This change 107 `.tf` files touched — every stack's `resource "kubernetes_namespace"` resource gets: ```hcl lifecycle { # KYVERNO_LIFECYCLE_V1: goldilocks-vpa-auto-mode ClusterPolicy stamps this label on every namespace ignore_changes = [metadata[0].labels["goldilocks.fairwinds.com/vpa-update-mode"]] } ``` Injection was done with a brace-depth-tracking Python pass (`/tmp/add_goldilocks_ignore.py`): match `^resource "kubernetes_namespace" ` → track `{` / `}` until the outermost closing brace → insert the lifecycle block before the closing brace. The script is idempotent (skips any file that already mentions `goldilocks.fairwinds.com/vpa-update-mode`) so re-running is safe. Vault stack picked up 2 namespaces in the same file (k8s-users produces one, plus a second explicit ns) — confirmed via file diff (+8 lines). ## What is NOT in this change - `stacks/trading-bot/main.tf` — entire file is `/* … */` commented out (paused 2026-04-06 per user decision). Reverted after the script ran. - `stacks/_template/main.tf.example` — per-stack skeleton, intentionally minimal. User keeps it that way. Not touched by the script (file has no real `resource "kubernetes_namespace"` — only a placeholder comment). - `.terraform/` copies (e.g. `stacks/metallb/.terraform/modules/...`) — gitignored, won't commit; the live path was edited. - `terraform fmt` cleanup of adjacent pre-existing alignment issues in authentik, freedify, hermes-agent, nvidia, vault, meshcentral. Reverted to keep the commit scoped to the Goldilocks sweep. Those files will need a separate fmt-only commit or will be cleaned up on next real apply to that stack. ## Verification Dawarich (one of the hundred-plus touched stacks) showed the pattern before and after: ``` $ cd stacks/dawarich && ../../scripts/tg plan Before: Plan: 0 to add, 2 to change, 0 to destroy. # kubernetes_namespace.dawarich will be updated in-place (goldilocks.fairwinds.com/vpa-update-mode -> null) # module.tls_secret.kubernetes_secret.tls_secret will be updated in-place (Kyverno generate.* labels — fixed in 8d94688d) After: No changes. Your infrastructure matches the configuration. ``` Injection count check: ``` $ rg -c 'KYVERNO_LIFECYCLE_V1: goldilocks-vpa-auto-mode' stacks/ | awk -F: '{s+=$2} END {print s}' 108 ``` ## Reproduce locally 1. `git pull` 2. Pick any stack: `cd stacks/<name> && ../../scripts/tg plan` 3. Expect: no drift on the namespace's goldilocks.fairwinds.com/vpa-update-mode label. Closes: code-dwx Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 21:15:27 +00:00
lifecycle {
# KYVERNO_LIFECYCLE_V1: goldilocks-vpa-auto-mode ClusterPolicy stamps this label on every namespace
ignore_changes = [metadata[0].labels["goldilocks.fairwinds.com/vpa-update-mode"]]
}
}
resource "kubernetes_manifest" "external_secret" {
manifest = {
apiVersion = "external-secrets.io/v1beta1"
kind = "ExternalSecret"
metadata = {
name = "wealthfolio-secrets"
namespace = "wealthfolio"
}
spec = {
refreshInterval = "15m"
secretStoreRef = {
name = "vault-kv"
kind = "ClusterSecretStore"
}
target = {
name = "wealthfolio-secrets"
}
dataFrom = [{
extract = {
key = "wealthfolio"
}
}]
}
}
depends_on = [kubernetes_namespace.wealthfolio]
}
# DB credentials for the SQLite→PG ETL sidecar. Vault DB engine static role
# `pg-wealthfolio-sync` rotates this every 7 days; ExternalSecret refreshes
# the K8s Secret every 15m so the sidecar always has a valid password.
resource "kubernetes_manifest" "wealthfolio_sync_db_external_secret" {
manifest = {
apiVersion = "external-secrets.io/v1beta1"
kind = "ExternalSecret"
metadata = {
name = "wealthfolio-sync-db-creds"
namespace = "wealthfolio"
}
spec = {
refreshInterval = "15m"
secretStoreRef = {
name = "vault-database"
kind = "ClusterSecretStore"
}
target = {
name = "wealthfolio-sync-db-creds"
template = {
metadata = {
annotations = {
"reloader.stakater.com/match" = "true"
}
}
data = {
PGHOST = var.postgresql_host
PGPORT = "5432"
PGDATABASE = "wealthfolio_sync"
PGUSER = "wealthfolio_sync"
PGPASSWORD = "{{ .password }}"
}
}
}
data = [{
secretKey = "password"
remoteRef = {
key = "static-creds/pg-wealthfolio-sync"
property = "password"
}
}]
}
}
depends_on = [kubernetes_namespace.wealthfolio]
}
module "tls_secret" {
source = "../../modules/kubernetes/setup_tls_secret"
namespace = kubernetes_namespace.wealthfolio.metadata[0].name
tls_secret_name = var.tls_secret_name
}
resource "random_string" "random" {
length = 32
lower = true
}
resource "kubernetes_deployment" "wealthfolio" {
lifecycle {
[infra] Establish KYVERNO_LIFECYCLE_V1 drift-suppression convention [ci skip] ## Context Phase 1 of the state-drift consolidation audit (plan Wave 3) identified that the entire repo leans on a repeated `lifecycle { ignore_changes = [...dns_config] }` snippet to suppress Kyverno's admission-webhook dns_config mutation (the ndots=2 override that prevents NxDomain search-domain flooding). 27 occurrences across 19 stacks. Without this suppression, every pod-owning resource shows perpetual TF plan drift. The original plan proposed a shared `modules/kubernetes/kyverno_lifecycle/` module emitting the ignore-paths list as an output that stacks would consume in their `ignore_changes` blocks. That approach is architecturally impossible: Terraform's `ignore_changes` meta-argument accepts only static attribute paths — it rejects module outputs, locals, variables, and any expression (the HCL spec evaluates `lifecycle` before the regular expression graph). So a DRY module cannot exist. The canonical pattern IS the repeated snippet. What the snippet was missing was a *discoverability tag* so that (a) new resources can be validated for compliance, (b) the existing 27 sites can be grep'd in a single command, and (c) future maintainers understand the convention rather than each reinventing it. ## This change - Introduces `# KYVERNO_LIFECYCLE_V1` as the canonical marker comment. Attached inline on every `spec[0].template[0].spec[0].dns_config` line (or `spec[0].job_template[0].spec[0]...` for CronJobs) across all 27 existing suppression sites. - Documents the convention with rationale and copy-paste snippets in `AGENTS.md` → new "Kyverno Drift Suppression" section. - Expands the existing `.claude/CLAUDE.md` Kyverno ndots note to reference the marker and explain why the module approach is blocked. - Updates `_template/main.tf.example` so every new stack starts compliant. ## What is NOT in this change - The `kubernetes_manifest` Kyverno annotation drift (beads `code-seq`) — that is Phase B with a sibling `# KYVERNO_MANIFEST_V1` marker. - Behavioral changes — every `ignore_changes` list is byte-identical save for the inline comment. - The fallback module the original plan anticipated — skipped because Terraform rejects expressions in `ignore_changes`. - `terraform fmt` cleanup on adjacent unrelated blocks in three files (claude-agent-service, freedify/factory, hermes-agent). Reverted to keep this commit scoped to the convention rollout. ## Before / after Before (cannot distinguish accidental-forgotten from intentional-convention): ```hcl lifecycle { ignore_changes = [spec[0].template[0].spec[0].dns_config] } ``` After (greppable, self-documenting, discoverable by tooling): ```hcl lifecycle { ignore_changes = [spec[0].template[0].spec[0].dns_config] # KYVERNO_LIFECYCLE_V1 } ``` ## Test Plan ### Automated ``` $ rg -c 'KYVERNO_LIFECYCLE_V1' stacks/ --include='*.tf' --include='*.tf.example' \ | awk -F: '{s+=$2} END {print s}' 27 $ git diff --stat | grep -E '\.(tf|tf\.example|md)$' | wc -l 21 # All code-file diffs are 1 insertion + 1 deletion per marker site, # except beads-server (3), ebooks (4), immich (3), uptime-kuma (2). $ git diff --stat stacks/ | tail -1 20 files changed, 45 insertions(+), 28 deletions(-) ``` ### Manual Verification No apply required — HCL comments only. Zero effect on any stack's plan output. Future audits: `rg 'KYVERNO_LIFECYCLE_V1' stacks/ | wc -l` must grow as new pod-owning resources are added. ## Reproduce locally 1. `cd infra && git pull` 2. `rg 'KYVERNO_LIFECYCLE_V1' stacks/` → expect 27 hits in 19 files 3. Grep any new `kubernetes_deployment` for the marker; absence = missing suppression. Closes: code-28m Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 14:15:51 +00:00
ignore_changes = [spec[0].template[0].spec[0].dns_config] # KYVERNO_LIFECYCLE_V1
}
metadata {
name = "wealthfolio"
namespace = kubernetes_namespace.wealthfolio.metadata[0].name
labels = {
app = "wealthfolio"
tier = local.tiers.aux
}
annotations = {
"reloader.stakater.com/auto" = "true"
}
}
spec {
replicas = 1
strategy {
type = "Recreate"
}
selector {
match_labels = {
app = "wealthfolio"
}
}
template {
metadata {
labels = {
app = "wealthfolio"
}
annotations = {
"diun.enable" = "true"
"diun.include_tags" = "^v?\\d+\\.\\d+\\.\\d+$"
}
}
spec {
container {
image = "afadil/wealthfolio:3.2"
name = "wealthfolio"
port {
container_port = 8080
}
env {
name = "WF_LISTEN_ADDR"
value = "0.0.0.0:8080"
}
env {
name = "WF_AUTH_PASSWORD_HASH"
value_from {
secret_key_ref {
name = "wealthfolio-secrets"
key = "password_hash"
}
}
}
env {
name = "WF_DB_PATH"
value = "/data/wealthfolio.db"
}
env {
name = "WF_CORS_ALLOW_ORIGINS"
value = "https://authentik.viktorbarzin.me"
}
env {
name = "WF_AUTH_TOKEN_TTL_MINUTES"
value = "10080"
}
env {
name = "WF_SECRET_KEY"
value = random_string.random.result
}
volume_mount {
name = "data"
mount_path = "/data"
}
# 2026-04-18 OOM after broker-sync Phase 3 landed (~700 activities
# across 6 accounts including Fidelity + matched cash flows). The
# /api/v1/net-worth + /valuations/history endpoints materialise the
# full history in memory for the chart; 64Mi was a Phase-0 guess
# that fit a 10-activity demo DB and nothing bigger.
2026-03-01 19:18:50 +00:00
resources {
requests = {
cpu = "10m"
memory = "256Mi"
2026-03-01 19:18:50 +00:00
}
limits = {
memory = "1Gi"
2026-03-01 19:18:50 +00:00
}
}
}
wealthfolio: add nightly backup sidecar — SQLite → NFS ## Context Upstream Wealthfolio uses SQLite exclusively (Diesel ORM, no PG/MySQL support — confirmed 2026-04-18 via repo inspection). The DB lives on an RWO PVC (proxmox-lvm-encrypted) held 24/7 by the main pod. First attempt at a standalone backup CronJob failed with Multi-Attach error: RWO volume is already attached to the running WF pod, so no separate pod can mount it. Switched to a backup sidecar in the same pod — shares the PVC mount naturally. ## This change - `container "backup"` added to the WF Deployment: - alpine:3.20 + sqlite + busybox-suid (for crond). - Mounts /data read-only (shared with WF container) + /backup (new NFS volume at 192.168.1.127:/srv/nfs/wealthfolio-backup). - Writes /etc/crontabs/root with a `30 4 * * *` line + /scripts/backup.sh which runs `sqlite3 .backup` (WAL-safe online snapshot, zero downtime), copies secrets.json, and prunes anything older than 30d. - 16Mi request / 64Mi limit — sleeps most of the time. - NFS volume declared in pod spec — server from the existing `var.nfs_server` variable; path `/srv/nfs/wealthfolio-backup` created on the PVE host in the same session. Removed the standalone backup CronJob that couldn't work. ## Verification ### Automated `scripts/tg apply stacks/wealthfolio` → Apply complete! Resources: 0 added, 1 changed, 1 destroyed (the transient CronJob). ### Manual (2026-04-18) $ kubectl -n wealthfolio get pods -l app=wealthfolio wealthfolio-95d8bd498-cj8kw 2/2 Running $ kubectl -n wealthfolio logs <pod> -c backup wealthfolio-backup sidecar ready; next 04:30 UTC $ kubectl -n wealthfolio exec <pod> -c backup -- /scripts/backup.sh wealthfolio-backup: /backup/2026-04-18T22-24-55 (34.2M) $ ls /srv/nfs/wealthfolio-backup/ 2026-04-18T22-24-55/ ← first sidecar-produced backup ## Reproduce locally 1. kubectl -n wealthfolio exec $(kubectl -n wealthfolio get pods -l app=wealthfolio -o jsonpath='{.items[0].metadata.name}') -c backup -- /scripts/backup.sh 2. ssh root@192.168.1.127 ls /srv/nfs/wealthfolio-backup/ 3. Expected: new dated folder appears with wealthfolio.db + secrets.json. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 22:25:19 +00:00
# Backup sidecar — see the big comment further down. Shares the WF
# data PVC (read-only) + the NFS backup target. busybox crond fires
# a nightly sqlite3 .backup so we have an off-cluster copy.
container {
name = "backup"
image = "alpine:3.20"
command = ["/bin/sh", "-c", <<-EOT
set -eu
apk add --no-cache --quiet sqlite busybox-suid
mkdir -p /etc/crontabs
cat >/etc/crontabs/root <<'CRON'
30 4 * * * /scripts/backup.sh >>/proc/1/fd/1 2>&1
CRON
mkdir -p /scripts
cat >/scripts/backup.sh <<'SCRIPT'
#!/bin/sh
set -eu
TS=$(date +%Y-%m-%dT%H-%M-%S)
DIR=/backup/$TS
mkdir -p "$DIR"
sqlite3 /data/wealthfolio.db ".backup $DIR/wealthfolio.db"
cp /data/secrets.json "$DIR/" 2>/dev/null || true
# Retention — keep 30 days.
find /backup -mindepth 1 -maxdepth 1 -type d -mtime +30 -exec rm -rf {} +
echo "wealthfolio-backup: $DIR ($(du -sh $DIR | cut -f1))"
SCRIPT
chmod +x /scripts/backup.sh
echo "wealthfolio-backup sidecar ready; next 04:30 UTC"
exec crond -f -l 8
EOT
]
volume_mount {
name = "data"
mount_path = "/data"
read_only = true
}
volume_mount {
name = "backup"
mount_path = "/backup"
}
resources {
requests = { cpu = "5m", memory = "16Mi" }
limits = { memory = "64Mi" }
}
}
# pg-sync sidecar — mirrors a small subset of SQLite into PG every hour
# so Grafana can chart net worth / contributions / growth via the
# `wealthfolio_sync` database. Mounts /data RO; writes to a tmp dir
# for the sqlite3 .backup snapshot to avoid blocking writers. Bootstrap
# DDL runs each iteration (CREATE TABLE IF NOT EXISTS — idempotent).
# Truncate-and-reload pattern: tables are small (~10k DAV rows, ~500
# activities, 6 accounts), so a full reload each hour is simpler than
# incremental upserts and gives clean cold-start behaviour.
container {
name = "pg-sync"
image = "alpine:3.20"
env {
name = "PGHOST"
value_from {
secret_key_ref {
name = "wealthfolio-sync-db-creds"
key = "PGHOST"
}
}
}
env {
name = "PGPORT"
value_from {
secret_key_ref {
name = "wealthfolio-sync-db-creds"
key = "PGPORT"
}
}
}
env {
name = "PGDATABASE"
value_from {
secret_key_ref {
name = "wealthfolio-sync-db-creds"
key = "PGDATABASE"
}
}
}
env {
name = "PGUSER"
value_from {
secret_key_ref {
name = "wealthfolio-sync-db-creds"
key = "PGUSER"
}
}
}
env {
name = "PGPASSWORD"
value_from {
secret_key_ref {
name = "wealthfolio-sync-db-creds"
key = "PGPASSWORD"
}
}
}
command = ["/bin/sh", "-c", <<-EOT
set -eu
apk add --no-cache --quiet sqlite postgresql-client busybox-suid
mkdir -p /etc/crontabs /scripts /tmp/wf-sync
cat >/etc/crontabs/root <<'CRON'
# Hourly: snapshot SQLite, reload PG mirror.
7 * * * * /scripts/sync.sh >>/proc/1/fd/1 2>&1
CRON
cat >/scripts/sync.sh <<'SCRIPT'
#!/bin/sh
set -eu
TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
echo "[$TS] wealthfolio-pg-sync: starting"
# Bootstrap schema (idempotent).
psql -v ON_ERROR_STOP=1 <<'SQL'
CREATE TABLE IF NOT EXISTS accounts (
id TEXT PRIMARY KEY,
name TEXT,
account_type TEXT,
currency TEXT,
is_active BOOLEAN
);
CREATE TABLE IF NOT EXISTS daily_account_valuation (
id TEXT PRIMARY KEY,
account_id TEXT NOT NULL,
valuation_date DATE NOT NULL,
account_currency TEXT,
base_currency TEXT,
fx_rate_to_base NUMERIC,
cash_balance NUMERIC,
investment_market_value NUMERIC,
total_value NUMERIC,
cost_basis NUMERIC,
net_contribution NUMERIC
);
CREATE INDEX IF NOT EXISTS idx_dav_acct_date ON daily_account_valuation(account_id, valuation_date);
CREATE INDEX IF NOT EXISTS idx_dav_date ON daily_account_valuation(valuation_date);
CREATE TABLE IF NOT EXISTS activities (
id TEXT PRIMARY KEY,
account_id TEXT,
asset_id TEXT,
activity_type TEXT,
activity_date TIMESTAMPTZ,
quantity NUMERIC,
unit_price NUMERIC,
amount NUMERIC,
fee NUMERIC,
currency TEXT,
fx_rate NUMERIC,
notes TEXT
);
CREATE INDEX IF NOT EXISTS idx_act_date ON activities(activity_date);
SQL
# Snapshot SQLite (online backup — non-blocking).
rm -f /tmp/wf-sync/snapshot.db
sqlite3 /data/wealthfolio.db ".backup /tmp/wf-sync/snapshot.db"
# Dump source rows to TSV.
sqlite3 -separator $'\t' /tmp/wf-sync/snapshot.db \
"SELECT id, name, account_type, currency, is_active FROM accounts;" \
> /tmp/wf-sync/accounts.tsv
sqlite3 -separator $'\t' /tmp/wf-sync/snapshot.db <<'SQ' > /tmp/wf-sync/dav.tsv
SELECT id, account_id, valuation_date, account_currency, base_currency,
CAST(fx_rate_to_base AS REAL),
CAST(cash_balance AS REAL),
CAST(investment_market_value AS REAL),
CAST(total_value AS REAL),
CAST(cost_basis AS REAL),
CAST(net_contribution AS REAL)
FROM daily_account_valuation
WHERE account_id != 'TOTAL'; -- synthetic pre-aggregated row; would double-count when summed
SQ
sqlite3 -separator $'\t' /tmp/wf-sync/snapshot.db <<'SQ' > /tmp/wf-sync/activities.tsv
SELECT id, account_id, asset_id, activity_type, activity_date,
CAST(quantity AS REAL),
CAST(unit_price AS REAL),
CAST(amount AS REAL),
CAST(fee AS REAL),
currency,
CAST(fx_rate AS REAL),
notes
FROM activities WHERE status='POSTED';
SQ
# Truncate-and-reload (small tables; simpler than upserts).
psql -v ON_ERROR_STOP=1 <<SQL
BEGIN;
TRUNCATE accounts, daily_account_valuation, activities;
\copy accounts FROM '/tmp/wf-sync/accounts.tsv' WITH (FORMAT csv, DELIMITER E'\t', NULL '');
\copy daily_account_valuation FROM '/tmp/wf-sync/dav.tsv' WITH (FORMAT csv, DELIMITER E'\t', NULL '');
\copy activities FROM '/tmp/wf-sync/activities.tsv' WITH (FORMAT csv, DELIMITER E'\t', NULL '');
COMMIT;
SQL
ROWS=$(psql -tAc "SELECT COUNT(*) FROM daily_account_valuation;")
echo "[$TS] wealthfolio-pg-sync: ok (daily_account_valuation rows=$ROWS)"
rm -f /tmp/wf-sync/*.tsv /tmp/wf-sync/snapshot.db
SCRIPT
chmod +x /scripts/sync.sh
echo "wealthfolio-pg-sync sidecar ready; running initial sync, then hourly at :07"
/scripts/sync.sh || echo "initial sync failed (will retry on next cron tick)"
exec crond -f -l 8
EOT
]
volume_mount {
name = "data"
mount_path = "/data"
read_only = true
}
resources {
requests = { cpu = "10m", memory = "32Mi" }
limits = { memory = "128Mi" }
}
}
volume {
name = "data"
persistent_volume_claim {
claim_name = "wealthfolio-data-encrypted"
}
}
wealthfolio: add nightly backup sidecar — SQLite → NFS ## Context Upstream Wealthfolio uses SQLite exclusively (Diesel ORM, no PG/MySQL support — confirmed 2026-04-18 via repo inspection). The DB lives on an RWO PVC (proxmox-lvm-encrypted) held 24/7 by the main pod. First attempt at a standalone backup CronJob failed with Multi-Attach error: RWO volume is already attached to the running WF pod, so no separate pod can mount it. Switched to a backup sidecar in the same pod — shares the PVC mount naturally. ## This change - `container "backup"` added to the WF Deployment: - alpine:3.20 + sqlite + busybox-suid (for crond). - Mounts /data read-only (shared with WF container) + /backup (new NFS volume at 192.168.1.127:/srv/nfs/wealthfolio-backup). - Writes /etc/crontabs/root with a `30 4 * * *` line + /scripts/backup.sh which runs `sqlite3 .backup` (WAL-safe online snapshot, zero downtime), copies secrets.json, and prunes anything older than 30d. - 16Mi request / 64Mi limit — sleeps most of the time. - NFS volume declared in pod spec — server from the existing `var.nfs_server` variable; path `/srv/nfs/wealthfolio-backup` created on the PVE host in the same session. Removed the standalone backup CronJob that couldn't work. ## Verification ### Automated `scripts/tg apply stacks/wealthfolio` → Apply complete! Resources: 0 added, 1 changed, 1 destroyed (the transient CronJob). ### Manual (2026-04-18) $ kubectl -n wealthfolio get pods -l app=wealthfolio wealthfolio-95d8bd498-cj8kw 2/2 Running $ kubectl -n wealthfolio logs <pod> -c backup wealthfolio-backup sidecar ready; next 04:30 UTC $ kubectl -n wealthfolio exec <pod> -c backup -- /scripts/backup.sh wealthfolio-backup: /backup/2026-04-18T22-24-55 (34.2M) $ ls /srv/nfs/wealthfolio-backup/ 2026-04-18T22-24-55/ ← first sidecar-produced backup ## Reproduce locally 1. kubectl -n wealthfolio exec $(kubectl -n wealthfolio get pods -l app=wealthfolio -o jsonpath='{.items[0].metadata.name}') -c backup -- /scripts/backup.sh 2. ssh root@192.168.1.127 ls /srv/nfs/wealthfolio-backup/ 3. Expected: new dated folder appears with wealthfolio.db + secrets.json. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 22:25:19 +00:00
volume {
name = "backup"
nfs {
server = var.nfs_server
path = "/srv/nfs/wealthfolio-backup"
}
}
}
}
}
}
resource "kubernetes_service" "wealthfolio" {
metadata {
name = "wealthfolio"
namespace = kubernetes_namespace.wealthfolio.metadata[0].name
labels = {
"app" = "wealthfolio"
}
}
spec {
selector = {
app = "wealthfolio"
}
port {
name = "http"
port = 80
target_port = 8080
}
}
}
module "ingress" {
source = "../../modules/kubernetes/ingress_factory"
[infra] Auto-create Cloudflare DNS records from ingress_factory ## Context Deploying new services required manually adding hostnames to cloudflare_proxied_names/cloudflare_non_proxied_names in config.tfvars — a separate file from the service stack. This was frequently forgotten, leaving services unreachable externally. ## This change: - Add `dns_type` parameter to `ingress_factory` and `reverse_proxy/factory` modules. Setting `dns_type = "proxied"` or `"non-proxied"` auto-creates the Cloudflare DNS record (CNAME to tunnel or A/AAAA to public IP). - Simplify cloudflared tunnel from 100 per-hostname rules to wildcard `*.viktorbarzin.me → Traefik`. Traefik still handles host-based routing. - Add global Cloudflare provider via terragrunt.hcl (separate cloudflare_provider.tf with Vault-sourced API key). - Migrate 118 hostnames from centralized config.tfvars to per-service dns_type. 17 hostnames remain centrally managed (Helm ingresses, special cases). - Update docs, AGENTS.md, CLAUDE.md, dns.md runbook. ``` BEFORE AFTER config.tfvars (manual list) stacks/<svc>/main.tf | module "ingress" { v dns_type = "proxied" stacks/cloudflared/ } for_each = list | cloudflare_record auto-creates tunnel per-hostname cloudflare_record + annotation ``` ## What is NOT in this change: - Uptime Kuma monitor migration (still reads from config.tfvars) - 17 remaining centrally-managed hostnames (Helm, special cases) - Removal of allow_overwrite (keep until migration confirmed stable) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 13:45:04 +00:00
dns_type = "proxied"
namespace = kubernetes_namespace.wealthfolio.metadata[0].name
name = "wealthfolio"
tls_secret_name = var.tls_secret_name
ingress_factory: replace `protected` bool with `auth` enum + audit pass across 100 stacks Phase 3+4 of default-deny ingress plan. Replaces the `protected = bool` (default false → unprotected) variable in `modules/kubernetes/ingress_factory` with `auth = string` enum (default "required" → fail-closed). Touches every ingress_factory caller so the audit decision is recorded explicitly in code. ingress_factory (Phase 3): - `auth = "required"`: standard Authentik forward-auth (the legacy `protected = true` semantic). - `auth = "public"`: forward-auth via the new `authentik-forward-auth-public` middleware → dedicated public outpost → guest auto-bind. Logged-in users keep their real identity. - `auth = "none"`: no Authentik middleware. For Anubis-fronted content, native client APIs (Git, /v2/, WebDAV), webhook receivers, the Authentik outpost itself. - `effective_anti_ai` default flips ON only when `auth = "none"` (auth-gated ingresses don't need anti-AI noise; the auth flow already discourages bots). Audit pass (Phase 4) across 96 ingress_factory call sites: - 49 explicit `protected = true` → `auth = "required"` - 8 explicit `protected = false` → `auth = "none"` (5) or `auth = "public"` (3) - 64 previously-default (no protected line) → `auth = "required"` ADDED, then reviewed individually: * 9 Anubis-fronted (blog, www, kms, travel, f1, cyberchef, jsoncrack, homepage, wrongmove UI, privatebin) → `auth = "none"` * 22 native-client / programmatic surfaces (Forgejo Git+/v2/, webhook handler, claude-memory MCP, Nextcloud WebDAV, Matrix, Vault CLI/OIDC, xray VPN, ntfy, woodpecker webhooks, n8n triggers, ntfy push, dawarich location ingestion, immich frame kiosk, headscale CP, send anonymous drops, rybbit beacon, vaultwarden API, Authentik UI itself + outposts) → `auth = "none"` * Remaining ~33 → `auth = "required"` confirmed (admin tools, internal UIs, services without app-level auth) - Smoke-test promotions to `auth = "public"`: fire-planner public UI, k8s-portal API, insta2spotify callback. Three call sites in wrapper modules (`stacks/freedify/factory/`, `stacks/reverse-proxy/modules/reverse_proxy/`) keep their internal `protected` bool — they translate to `auth` internally, out of scope for this rename. Behavior change: previously-default ingresses now fail closed (require Authentik login) unless explicitly flipped to `auth = "none"` or `auth = "public"`. This is the audit goal — no more accidentally-unprotected surfaces. Sites that were intentionally public (Anubis content, native APIs, webhooks) are now explicitly recorded as `auth = "none"`. Drive-by: `modules/create-vm/main.tf` picked up cosmetic alignment via `terraform fmt -recursive` during the audit. Behavior-neutral. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 18:53:49 +00:00
auth = "required"
extra_annotations = {
"gethomepage.dev/enabled" = "true"
"gethomepage.dev/name" = "Wealthfolio"
"gethomepage.dev/description" = "Investment portfolio tracker"
"gethomepage.dev/icon" = "mdi-finance"
"gethomepage.dev/group" = "Finance & Personal"
"gethomepage.dev/pod-selector" = ""
}
}
resource "kubernetes_cron_job_v1" "wealthfolio_sync" {
metadata {
name = "wealthfolio-sync"
namespace = kubernetes_namespace.wealthfolio.metadata[0].name
}
spec {
schedule = "0 8 1 * *"
concurrency_policy = "Forbid"
successful_jobs_history_limit = 3
failed_jobs_history_limit = 3
job_template {
metadata {}
spec {
backoff_limit = 2
template {
metadata {}
spec {
restart_policy = "OnFailure"
image_pull_secrets {
name = "registry-credentials"
}
container {
ingress_factory: replace `protected` bool with `auth` enum + audit pass across 100 stacks Phase 3+4 of default-deny ingress plan. Replaces the `protected = bool` (default false → unprotected) variable in `modules/kubernetes/ingress_factory` with `auth = string` enum (default "required" → fail-closed). Touches every ingress_factory caller so the audit decision is recorded explicitly in code. ingress_factory (Phase 3): - `auth = "required"`: standard Authentik forward-auth (the legacy `protected = true` semantic). - `auth = "public"`: forward-auth via the new `authentik-forward-auth-public` middleware → dedicated public outpost → guest auto-bind. Logged-in users keep their real identity. - `auth = "none"`: no Authentik middleware. For Anubis-fronted content, native client APIs (Git, /v2/, WebDAV), webhook receivers, the Authentik outpost itself. - `effective_anti_ai` default flips ON only when `auth = "none"` (auth-gated ingresses don't need anti-AI noise; the auth flow already discourages bots). Audit pass (Phase 4) across 96 ingress_factory call sites: - 49 explicit `protected = true` → `auth = "required"` - 8 explicit `protected = false` → `auth = "none"` (5) or `auth = "public"` (3) - 64 previously-default (no protected line) → `auth = "required"` ADDED, then reviewed individually: * 9 Anubis-fronted (blog, www, kms, travel, f1, cyberchef, jsoncrack, homepage, wrongmove UI, privatebin) → `auth = "none"` * 22 native-client / programmatic surfaces (Forgejo Git+/v2/, webhook handler, claude-memory MCP, Nextcloud WebDAV, Matrix, Vault CLI/OIDC, xray VPN, ntfy, woodpecker webhooks, n8n triggers, ntfy push, dawarich location ingestion, immich frame kiosk, headscale CP, send anonymous drops, rybbit beacon, vaultwarden API, Authentik UI itself + outposts) → `auth = "none"` * Remaining ~33 → `auth = "required"` confirmed (admin tools, internal UIs, services without app-level auth) - Smoke-test promotions to `auth = "public"`: fire-planner public UI, k8s-portal API, insta2spotify callback. Three call sites in wrapper modules (`stacks/freedify/factory/`, `stacks/reverse-proxy/modules/reverse_proxy/`) keep their internal `protected` bool — they translate to `auth` internally, out of scope for this rename. Behavior change: previously-default ingresses now fail closed (require Authentik login) unless explicitly flipped to `auth = "none"` or `auth = "public"`. This is the audit goal — no more accidentally-unprotected surfaces. Sites that were intentionally public (Anubis content, native APIs, webhooks) are now explicitly recorded as `auth = "none"`. Drive-by: `modules/create-vm/main.tf` picked up cosmetic alignment via `terraform fmt -recursive` during the audit. Behavior-neutral. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 18:53:49 +00:00
name = "sync"
# Phase 4 of forgejo-registry-consolidation 2026-05-07 +
# post-cutover wealthfolio-sync rebuild: image is now
# produced by /home/wizard/code/broker-sync (Forgejo
# viktor/broker-sync, DockerHub viktorbarzin/broker-sync,
# Forgejo viktor/wealthfolio-sync as the cluster pull path).
image = "forgejo.viktorbarzin.me/viktor/wealthfolio-sync:latest"
env {
name = "IMAP_HOST"
value_from {
secret_key_ref {
name = "wealthfolio-secrets"
key = "imap_host"
}
}
}
env {
name = "IMAP_USER"
value_from {
secret_key_ref {
name = "wealthfolio-secrets"
key = "imap_user"
}
}
}
env {
name = "IMAP_PASSWORD"
value_from {
secret_key_ref {
name = "wealthfolio-secrets"
key = "imap_password"
}
}
}
env {
name = "IMAP_DIRECTORY"
value_from {
secret_key_ref {
name = "wealthfolio-secrets"
key = "imap_directory"
}
}
}
env {
name = "TRADING212_API_KEYS"
value_from {
secret_key_ref {
name = "wealthfolio-secrets"
key = "trading212_api_keys"
}
}
}
env {
name = "DB_PATH"
value = "/data/wealthfolio.db"
}
volume_mount {
name = "data"
mount_path = "/data"
}
resources {
requests = {
cpu = "10m"
memory = "32Mi"
}
limits = {
memory = "128Mi"
}
}
}
volume {
name = "data"
persistent_volume_claim {
claim_name = "wealthfolio-data-encrypted"
}
}
}
}
}
}
}
[infra] Sweep dns_config ignore_changes across all pod-owning resources [ci skip] ## Context Wave 3A (commit c9d221d5) added the `# KYVERNO_LIFECYCLE_V1` marker to the 27 pre-existing `ignore_changes = [...dns_config]` sites so they could be grepped and audited. It did NOT address pod-owning resources that were simply missing the suppression entirely. Post-Wave-3A sampling (2026-04-18) found that navidrome, f1-stream, frigate, servarr, monitoring, crowdsec, and many other stacks showed perpetual `dns_config` drift every plan because their `kubernetes_deployment` / `kubernetes_stateful_set` / `kubernetes_cron_job_v1` resources had no `lifecycle {}` block at all. Root cause (same as Wave 3A): Kyverno's admission webhook stamps `dns_config { option { name = "ndots"; value = "2" } }` on every pod's `spec.template.spec.dns_config` to prevent NxDomain search-domain flooding (see `k8s-ndots-search-domain-nxdomain-flood` skill). Without `ignore_changes` on every Terraform-managed pod-owner, Terraform repeatedly tries to strip the injected field. ## This change Extends the Wave 3A convention by sweeping EVERY `kubernetes_deployment`, `kubernetes_stateful_set`, `kubernetes_daemon_set`, `kubernetes_cron_job_v1`, `kubernetes_job_v1` (+ their `_v1` variants) in the repo and ensuring each carries the right `ignore_changes` path: - **kubernetes_deployment / stateful_set / daemon_set / job_v1**: `spec[0].template[0].spec[0].dns_config` - **kubernetes_cron_job_v1**: `spec[0].job_template[0].spec[0].template[0].spec[0].dns_config` (extra `job_template[0]` nesting — the CronJob's PodTemplateSpec is one level deeper) Each injection / extension is tagged `# KYVERNO_LIFECYCLE_V1: Kyverno admission webhook mutates dns_config with ndots=2` inline so the suppression is discoverable via `rg 'KYVERNO_LIFECYCLE_V1' stacks/`. Two insertion paths are handled by a Python pass (`/tmp/add_dns_config_ignore.py`): 1. **No existing `lifecycle {}`**: inject a brand-new block just before the resource's closing `}`. 108 new blocks on 93 files. 2. **Existing `lifecycle {}` (usually for `DRIFT_WORKAROUND: CI owns image tag` from Wave 4, commit a62b43d1)**: extend its `ignore_changes` list with the dns_config path. Handles both inline (`= [x]`) and multiline (`= [\n x,\n]`) forms; ensures the last pre-existing list item carries a trailing comma so the extended list is valid HCL. 34 extensions. The script skips anything already mentioning `dns_config` inside an `ignore_changes`, so re-running is a no-op. ## Scale - 142 total lifecycle injections/extensions - 93 `.tf` files touched - 108 brand-new `lifecycle {}` blocks + 34 extensions of existing ones - Every Tier 0 and Tier 1 stack with a pod-owning resource is covered - Together with Wave 3A's 27 pre-existing markers → **169 greppable `KYVERNO_LIFECYCLE_V1` dns_config sites across the repo** ## What is NOT in this change - `stacks/trading-bot/main.tf` — entirely commented-out block (`/* … */`). Python script touched the file, reverted manually. - `_template/main.tf.example` skeleton — kept minimal on purpose; any future stack created from it should either inherit the Wave 3A one-line form or add its own on first `kubernetes_deployment`. - `terraform fmt` fixes to pre-existing alignment issues in meshcentral, nvidia/modules/nvidia, vault — unrelated to this commit. Left for a separate fmt-only pass. - Non-pod resources (`kubernetes_service`, `kubernetes_secret`, `kubernetes_manifest`, etc.) — they don't own pods so they don't get Kyverno dns_config mutation. ## Verification Random sample post-commit: ``` $ cd stacks/navidrome && ../../scripts/tg plan → No changes. $ cd stacks/f1-stream && ../../scripts/tg plan → No changes. $ cd stacks/frigate && ../../scripts/tg plan → No changes. $ rg -c 'KYVERNO_LIFECYCLE_V1' stacks/ --include='*.tf' --include='*.tf.example' \ | awk -F: '{s+=$2} END {print s}' 169 ``` ## Reproduce locally 1. `git pull` 2. `rg 'KYVERNO_LIFECYCLE_V1' stacks/ | wc -l` → 169+ 3. `cd stacks/navidrome && ../../scripts/tg plan` → expect 0 drift on the deployment's dns_config field. Refs: code-seq (Wave 3B dns_config class closed; kubernetes_manifest annotation class handled separately in 8d94688d for tls_secret) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 21:19:48 +00:00
lifecycle {
# KYVERNO_LIFECYCLE_V1: Kyverno admission webhook mutates dns_config with ndots=2
ignore_changes = [spec[0].job_template[0].spec[0].template[0].spec[0].dns_config]
}
}
wealthfolio: add nightly backup sidecar — SQLite → NFS ## Context Upstream Wealthfolio uses SQLite exclusively (Diesel ORM, no PG/MySQL support — confirmed 2026-04-18 via repo inspection). The DB lives on an RWO PVC (proxmox-lvm-encrypted) held 24/7 by the main pod. First attempt at a standalone backup CronJob failed with Multi-Attach error: RWO volume is already attached to the running WF pod, so no separate pod can mount it. Switched to a backup sidecar in the same pod — shares the PVC mount naturally. ## This change - `container "backup"` added to the WF Deployment: - alpine:3.20 + sqlite + busybox-suid (for crond). - Mounts /data read-only (shared with WF container) + /backup (new NFS volume at 192.168.1.127:/srv/nfs/wealthfolio-backup). - Writes /etc/crontabs/root with a `30 4 * * *` line + /scripts/backup.sh which runs `sqlite3 .backup` (WAL-safe online snapshot, zero downtime), copies secrets.json, and prunes anything older than 30d. - 16Mi request / 64Mi limit — sleeps most of the time. - NFS volume declared in pod spec — server from the existing `var.nfs_server` variable; path `/srv/nfs/wealthfolio-backup` created on the PVE host in the same session. Removed the standalone backup CronJob that couldn't work. ## Verification ### Automated `scripts/tg apply stacks/wealthfolio` → Apply complete! Resources: 0 added, 1 changed, 1 destroyed (the transient CronJob). ### Manual (2026-04-18) $ kubectl -n wealthfolio get pods -l app=wealthfolio wealthfolio-95d8bd498-cj8kw 2/2 Running $ kubectl -n wealthfolio logs <pod> -c backup wealthfolio-backup sidecar ready; next 04:30 UTC $ kubectl -n wealthfolio exec <pod> -c backup -- /scripts/backup.sh wealthfolio-backup: /backup/2026-04-18T22-24-55 (34.2M) $ ls /srv/nfs/wealthfolio-backup/ 2026-04-18T22-24-55/ ← first sidecar-produced backup ## Reproduce locally 1. kubectl -n wealthfolio exec $(kubectl -n wealthfolio get pods -l app=wealthfolio -o jsonpath='{.items[0].metadata.name}') -c backup -- /scripts/backup.sh 2. ssh root@192.168.1.127 ls /srv/nfs/wealthfolio-backup/ 3. Expected: new dated folder appears with wealthfolio.db + secrets.json. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 22:25:19 +00:00
# ExternalSecret in the monitoring namespace mirroring the rotating
# wealthfolio_sync DB password. Grafana mounts this via envFromSecrets
# in monitoring/grafana_chart_values.yaml; the datasource ConfigMap
# below references it as $__env{WEALTH_PG_PASSWORD}. Reloader restarts
# Grafana whenever ESO updates this secret (every 7d on rotation).
resource "kubernetes_manifest" "grafana_wealth_db_external_secret" {
manifest = {
apiVersion = "external-secrets.io/v1beta1"
kind = "ExternalSecret"
metadata = {
name = "grafana-wealth-pg-creds"
namespace = "monitoring"
}
spec = {
refreshInterval = "15m"
secretStoreRef = {
name = "vault-database"
kind = "ClusterSecretStore"
}
target = {
name = "grafana-wealth-pg-creds"
template = {
metadata = {
annotations = {
"reloader.stakater.com/match" = "true"
}
}
data = {
WEALTH_PG_PASSWORD = "{{ .password }}"
}
}
}
data = [{
secretKey = "password"
remoteRef = {
key = "static-creds/pg-wealthfolio-sync"
property = "password"
}
}]
}
}
}
# Grafana datasource for wealthfolio_sync PostgreSQL DB.
# Lives in the monitoring namespace so the Grafana sidecar (grafana_datasource=1) picks it up.
# Password is injected via $__env{...} from grafana-wealth-pg-creds (above).
resource "kubernetes_config_map" "grafana_wealth_datasource" {
metadata {
name = "grafana-wealth-datasource"
namespace = "monitoring"
labels = {
grafana_datasource = "1"
}
}
data = {
"wealth-datasource.yaml" = yamlencode({
apiVersion = 1
datasources = [{
name = "Wealth"
type = "postgres"
access = "proxy"
url = "${var.postgresql_host}:5432"
user = "wealthfolio_sync"
uid = "wealth-pg"
# Grafana 11.2+ Postgres plugin reads DB name from jsonData.database
# (top-level `database` is silently ignored).
jsonData = {
database = "wealthfolio_sync"
sslmode = "disable"
postgresVersion = 1600
timescaledb = false
}
secureJsonData = {
password = "$__env{WEALTH_PG_PASSWORD}"
}
editable = true
}]
})
}
depends_on = [kubernetes_manifest.grafana_wealth_db_external_secret]
}
wealthfolio: add nightly backup sidecar — SQLite → NFS ## Context Upstream Wealthfolio uses SQLite exclusively (Diesel ORM, no PG/MySQL support — confirmed 2026-04-18 via repo inspection). The DB lives on an RWO PVC (proxmox-lvm-encrypted) held 24/7 by the main pod. First attempt at a standalone backup CronJob failed with Multi-Attach error: RWO volume is already attached to the running WF pod, so no separate pod can mount it. Switched to a backup sidecar in the same pod — shares the PVC mount naturally. ## This change - `container "backup"` added to the WF Deployment: - alpine:3.20 + sqlite + busybox-suid (for crond). - Mounts /data read-only (shared with WF container) + /backup (new NFS volume at 192.168.1.127:/srv/nfs/wealthfolio-backup). - Writes /etc/crontabs/root with a `30 4 * * *` line + /scripts/backup.sh which runs `sqlite3 .backup` (WAL-safe online snapshot, zero downtime), copies secrets.json, and prunes anything older than 30d. - 16Mi request / 64Mi limit — sleeps most of the time. - NFS volume declared in pod spec — server from the existing `var.nfs_server` variable; path `/srv/nfs/wealthfolio-backup` created on the PVE host in the same session. Removed the standalone backup CronJob that couldn't work. ## Verification ### Automated `scripts/tg apply stacks/wealthfolio` → Apply complete! Resources: 0 added, 1 changed, 1 destroyed (the transient CronJob). ### Manual (2026-04-18) $ kubectl -n wealthfolio get pods -l app=wealthfolio wealthfolio-95d8bd498-cj8kw 2/2 Running $ kubectl -n wealthfolio logs <pod> -c backup wealthfolio-backup sidecar ready; next 04:30 UTC $ kubectl -n wealthfolio exec <pod> -c backup -- /scripts/backup.sh wealthfolio-backup: /backup/2026-04-18T22-24-55 (34.2M) $ ls /srv/nfs/wealthfolio-backup/ 2026-04-18T22-24-55/ ← first sidecar-produced backup ## Reproduce locally 1. kubectl -n wealthfolio exec $(kubectl -n wealthfolio get pods -l app=wealthfolio -o jsonpath='{.items[0].metadata.name}') -c backup -- /scripts/backup.sh 2. ssh root@192.168.1.127 ls /srv/nfs/wealthfolio-backup/ 3. Expected: new dated folder appears with wealthfolio.db + secrets.json. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 22:25:19 +00:00
############################################################################
# Backup — sidecar approach
#
# Wealthfolio has no PG/MySQL support (Diesel ORM hard-wired to SQLite per
# upstream README). The data lives on an RWO PVC that's held 24/7 by the
# main WF pod, so a separate backup CronJob would hit a Multi-Attach error
# (confirmed 2026-04-18 test).
#
# Instead, the WF Deployment gets a backup sidecar:
# - Shares the data PVC read-only + the NFS backup target.
# - Runs busybox `crond` with a 04:30-daily entry.
# - Uses `sqlite3 .backup` (WAL-safe, no downtime) to snapshot into an
# NFS dated folder + retains 30 days.
#
# See `resource "kubernetes_deployment" "wealthfolio"` above — the sidecar
# is wired in via the deployment's container/volume blocks.
############################################################################
############################################################################
# Daily portfolio-recalc CronJob — keeps the Grafana wealth dashboard fresh.
#
# Wealthfolio writes new `daily_account_valuation` rows ONLY when a
# PortfolioJob fires with ValuationRecalcMode != None. None of its built-in
# schedulers do that for our deployment:
# * Internal 6h quote scheduler — refreshes the `quotes` table only.
# * Internal 4h broker scheduler — short-circuits if `sync_refresh_token`
# is unset (it is — we route broker imports through the external
# wealthfolio-sync CronJob).
# Result: valuations only update when the Tauri/web UI hits
# /api/v1/market-data/sync — i.e. when someone opens the dashboard.
#
# This CronJob mimics that: login → POST /api/v1/market-data/sync. The
# server runs the portfolio job (Incremental quote sync + IncrementalFromLast
# valuation recalc), backfilling missing daily_account_valuation rows up to
# today. The pg-sync sidecar's :07 hourly tick mirrors them to PG, and
# Grafana auto-refreshes within 5 min.
#
# Schedule 16:00 UTC (= 17:00 BST in summer):
# - After UK market close (15:30 UTC BST), so EOD UK prices are settled
# - US market open ~2.5h (good intra-day US quotes)
# - pg-sync next tick at 16:07 → Grafana fresh by ~16:12 UTC ≈ 17:12 BST,
# well before the 18:00 BST "fresh data by 6pm" target.
#
# Plaintext password lives at Vault `secret/wealthfolio.web_password`,
# pulled into the existing `wealthfolio-secrets` K8s Secret by the
# `dataFrom.extract` ExternalSecret above (no extra ESO wiring needed —
# the new key flows through automatically).
############################################################################
resource "kubernetes_cron_job_v1" "wealthfolio_daily_sync" {
metadata {
name = "wealthfolio-daily-sync"
namespace = kubernetes_namespace.wealthfolio.metadata[0].name
}
spec {
schedule = "0 16 * * *"
successful_jobs_history_limit = 1
failed_jobs_history_limit = 3
concurrency_policy = "Forbid"
job_template {
metadata {}
spec {
active_deadline_seconds = 180
backoff_limit = 1
template {
metadata {}
spec {
restart_policy = "Never"
container {
name = "curl"
image = "curlimages/curl:8.11.1"
env {
name = "WF_PASSWORD"
value_from {
secret_key_ref {
name = "wealthfolio-secrets"
key = "web_password"
}
}
}
command = ["/bin/sh", "-c"]
args = [
<<-EOT
set -eu
BASE=http://wealthfolio.wealthfolio.svc.cluster.local
JAR=$(mktemp)
trap 'rm -f "$JAR"' EXIT
echo "[$(date -u +%FT%TZ)] login"
curl -sS --max-time 15 --fail -X POST "$BASE/api/v1/auth/login" \
-H "Content-Type: application/json" \
-d "{\"password\":\"$WF_PASSWORD\"}" \
-c "$JAR" -o /dev/null
echo "[$(date -u +%FT%TZ)] POST /api/v1/market-data/sync"
curl -sS --max-time 60 --fail -X POST "$BASE/api/v1/market-data/sync" \
-H "Content-Type: application/json" \
-b "$JAR" \
-d '{"refetchAll":false}' -o /dev/null
echo "[$(date -u +%FT%TZ)] sync queued (204) — portfolio job runs async"
EOT
]
}
}
}
}
}
}
lifecycle {
# KYVERNO_LIFECYCLE_V1: Kyverno admission webhook mutates dns_config with ndots=2
ignore_changes = [spec[0].job_template[0].spec[0].template[0].spec[0].dns_config]
}
}