2026-03-07 14:30:36 +00:00
|
|
|
variable "tls_secret_name" {
|
2026-03-14 08:51:45 +00:00
|
|
|
type = string
|
2026-03-07 14:30:36 +00:00
|
|
|
sensitive = true
|
|
|
|
|
}
|
[ci skip] Infrastructure hardening: security, monitoring, reliability, maintainability
Phase 1 - Critical Security:
- Netbox: move hardcoded DB/superuser passwords to variables
- MeshCentral: disable public registration, add Authentik auth
- Traefik: disable insecure API dashboard (api.insecure=false)
- Traefik: configure forwarded headers with Cloudflare trusted IPs
Phase 2 - Security Hardening:
- Add security headers middleware (HSTS, X-Frame-Options, nosniff, etc.)
- Add Kyverno pod security policies in audit mode (privileged, host
namespaces, SYS_ADMIN, trusted registries)
- Tighten rate limiting (avg=10, burst=50)
- Add Authentik protection to grampsweb
Phase 3 - Monitoring & Alerting:
- Add critical service alerts (PostgreSQL, MySQL, Redis, Headscale,
Authentik, Loki)
- Increase Loki retention from 7 to 30 days (720h)
- Add predictive PV filling alert (predict_linear)
- Re-enable Hackmd and Privatebin down alerts
Phase 4 - Reliability:
- Add resource requests/limits to Redis, DBaaS, Technitium, Headscale,
Vaultwarden, Uptime Kuma
- Increase Alloy DaemonSet memory to 512Mi/1Gi
Phase 6 - Maintainability:
- Extract duplicated tiers locals to terragrunt.hcl generate block
(removed from 67 stacks)
- Replace hardcoded NFS IP 10.0.10.15 with var.nfs_server (114
instances across 63 files)
- Replace hardcoded Redis/PostgreSQL/MySQL/Ollama/mail host references
with variables across ~35 stacks
- Migrate xray raw ingress resources to ingress_factory modules
2026-02-23 22:05:28 +00:00
|
|
|
variable "nfs_server" { type = string }
|
wealth: SQLite→PG ETL sidecar + new Grafana dashboard
Mirrors Wealthfolio's daily_account_valuation / accounts / activities
from SQLite into a new PG database (wealthfolio_sync) every hour, so
Grafana can chart net worth, contributions, and growth over time.
Components:
- dbaas: null_resource creates wealthfolio_sync DB + role on the CNPG
cluster (dynamic primary lookup so it survives failover).
- vault: pg-wealthfolio-sync static role rotates the password every 7d.
- wealthfolio: ExternalSecret pulls the rotated password into the WF
namespace; new pg-sync sidecar (alpine + sqlite + postgresql-client +
busybox crond) does sqlite3 .backup → TSV dump → truncate-and-reload
psql, hourly at :07. Plus a grafana-wealth-datasource ConfigMap in
the monitoring namespace (uid: wealth-pg).
- monitoring: new Wealth dashboard (wealth.json, 10 panels) — current
net worth / contribution / growth / ROI% stats, then time-series
for net worth, contribution-vs-market, growth area, per-account
stacked area, cash-vs-invested, and a 100-row activity log.
Initial sync: 6 accounts, 10,798 daily valuations, 518 activities.
Verified PG totals match SQLite latest snapshot exactly.
2026-04-25 17:07:33 +00:00
|
|
|
variable "postgresql_host" { type = string }
|
2026-02-22 13:56:34 +00:00
|
|
|
|
2026-02-22 15:13:55 +00:00
|
|
|
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"]]
|
|
|
|
|
}
|
2026-02-22 15:13:55 +00:00
|
|
|
}
|
|
|
|
|
|
migrate consuming stacks to ESO + remove k8s-dashboard static token
Phase 9: ExternalSecret migration across 26 stacks:
Fully migrated (vault data source removed, ESO delivers secrets):
- speedtest, shadowsocks, wealthfolio, plotting-book, f1-stream, tandoor
- n8n, dawarich, diun, netbox, onlyoffice, tuya-bridge
- hackmd (ESO template for DB URL), health (ESO template for DB URL)
- trading-bot (ESO template for DATABASE_URL + 7 secret env vars)
- forgejo (removed unused vault data source)
Partially migrated (vault kept for plan-time, ESO added for runtime):
- immich, linkwarden, nextcloud, paperless-ngx (jsondecode for homepage)
- claude-memory, rybbit, url, webhook_handler (plan-time in locals/jobs)
- woodpecker, openclaw, resume (plan-time in helm values/jobs/modules)
17 stacks unchanged (all plan-time: homepage annotations, configmaps,
module inputs) — vault data source works with OIDC auth.
Phase 17a: Remove k8s-dashboard static admin token secret.
Users now get tokens via: vault write kubernetes/creds/dashboard-admin
2026-03-15 19:05:04 +00:00
|
|
|
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]
|
|
|
|
|
}
|
|
|
|
|
|
wealth: SQLite→PG ETL sidecar + new Grafana dashboard
Mirrors Wealthfolio's daily_account_valuation / accounts / activities
from SQLite into a new PG database (wealthfolio_sync) every hour, so
Grafana can chart net worth, contributions, and growth over time.
Components:
- dbaas: null_resource creates wealthfolio_sync DB + role on the CNPG
cluster (dynamic primary lookup so it survives failover).
- vault: pg-wealthfolio-sync static role rotates the password every 7d.
- wealthfolio: ExternalSecret pulls the rotated password into the WF
namespace; new pg-sync sidecar (alpine + sqlite + postgresql-client +
busybox crond) does sqlite3 .backup → TSV dump → truncate-and-reload
psql, hourly at :07. Plus a grafana-wealth-datasource ConfigMap in
the monitoring namespace (uid: wealth-pg).
- monitoring: new Wealth dashboard (wealth.json, 10 panels) — current
net worth / contribution / growth / ROI% stats, then time-series
for net worth, contribution-vs-market, growth area, per-account
stacked area, cash-vs-invested, and a 100-row activity log.
Initial sync: 6 accounts, 10,798 daily valuations, 518 activities.
Verified PG totals match SQLite latest snapshot exactly.
2026-04-25 17:07:33 +00:00
|
|
|
# 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]
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 15:13:55 +00:00
|
|
|
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" {
|
2026-03-24 02:07:36 +02:00
|
|
|
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
|
2026-03-24 02:07:36 +02:00
|
|
|
}
|
2026-02-22 15:13:55 +00:00
|
|
|
metadata {
|
|
|
|
|
name = "wealthfolio"
|
|
|
|
|
namespace = kubernetes_namespace.wealthfolio.metadata[0].name
|
|
|
|
|
labels = {
|
|
|
|
|
app = "wealthfolio"
|
|
|
|
|
tier = local.tiers.aux
|
|
|
|
|
}
|
migrate consuming stacks to ESO + remove k8s-dashboard static token
Phase 9: ExternalSecret migration across 26 stacks:
Fully migrated (vault data source removed, ESO delivers secrets):
- speedtest, shadowsocks, wealthfolio, plotting-book, f1-stream, tandoor
- n8n, dawarich, diun, netbox, onlyoffice, tuya-bridge
- hackmd (ESO template for DB URL), health (ESO template for DB URL)
- trading-bot (ESO template for DATABASE_URL + 7 secret env vars)
- forgejo (removed unused vault data source)
Partially migrated (vault kept for plan-time, ESO added for runtime):
- immich, linkwarden, nextcloud, paperless-ngx (jsondecode for homepage)
- claude-memory, rybbit, url, webhook_handler (plan-time in locals/jobs)
- woodpecker, openclaw, resume (plan-time in helm values/jobs/modules)
17 stacks unchanged (all plan-time: homepage annotations, configmaps,
module inputs) — vault data source works with OIDC auth.
Phase 17a: Remove k8s-dashboard static admin token secret.
Users now get tokens via: vault write kubernetes/creds/dashboard-admin
2026-03-15 19:05:04 +00:00
|
|
|
annotations = {
|
|
|
|
|
"reloader.stakater.com/auto" = "true"
|
|
|
|
|
}
|
2026-02-22 15:13:55 +00:00
|
|
|
}
|
|
|
|
|
spec {
|
|
|
|
|
replicas = 1
|
feat(storage): migrate 12 SQLite NFS PVCs to proxmox-lvm (Wave 1)
Add proxmox-lvm PVCs with pvc-autoresizer annotations for all
SQLite-backed services. Deployments updated to use new block storage
PVCs. Old NFS modules retained for 1-week rollback.
Services: ntfy, freshrss, insta2spotify, actualbudget (x3),
wealthfolio, navidrome (DB only), audiobookshelf config,
headscale, forgejo, uptime-kuma.
Also: set Recreate strategy on ntfy, forgejo, insta2spotify,
wealthfolio (required for RWO volumes).
2026-04-04 16:26:59 +03:00
|
|
|
strategy {
|
|
|
|
|
type = "Recreate"
|
|
|
|
|
}
|
2026-02-22 15:13:55 +00:00
|
|
|
selector {
|
|
|
|
|
match_labels = {
|
|
|
|
|
app = "wealthfolio"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
template {
|
|
|
|
|
metadata {
|
|
|
|
|
labels = {
|
|
|
|
|
app = "wealthfolio"
|
|
|
|
|
}
|
feat: pin ~28 images to specific versions, enable DIUN monitoring, add app-stacks pipeline
Pin third-party images from :latest to current stable versions:
- Platform: cloudflared, technitium, snmp-exporter, pve-exporter,
headscale, shadowsocks, xray
- Apps: paperless-ngx, linkwarden, wealthfolio, speedtest, synapse,
n8n, prowlarr, qbittorrent, lidarr, rybbit, ollama, immichframe,
cyberchef, networking-toolbox, echo, coturn, shlink, affine
Enable DIUN annotations on all pinned deployments with per-image
tag patterns. Add Woodpecker app-stacks pipeline for selective
terragrunt apply on changed app stacks.
2026-04-06 14:27:13 +03:00
|
|
|
annotations = {
|
|
|
|
|
"diun.enable" = "true"
|
|
|
|
|
"diun.include_tags" = "^v?\\d+\\.\\d+\\.\\d+$"
|
|
|
|
|
}
|
2026-02-22 15:13:55 +00:00
|
|
|
}
|
|
|
|
|
spec {
|
|
|
|
|
container {
|
2026-04-17 05:51:52 +00:00
|
|
|
image = "afadil/wealthfolio:3.2"
|
2026-02-22 15:13:55 +00:00
|
|
|
name = "wealthfolio"
|
|
|
|
|
port {
|
|
|
|
|
container_port = 8080
|
|
|
|
|
}
|
|
|
|
|
env {
|
|
|
|
|
name = "WF_LISTEN_ADDR"
|
|
|
|
|
value = "0.0.0.0:8080"
|
|
|
|
|
}
|
|
|
|
|
env {
|
migrate consuming stacks to ESO + remove k8s-dashboard static token
Phase 9: ExternalSecret migration across 26 stacks:
Fully migrated (vault data source removed, ESO delivers secrets):
- speedtest, shadowsocks, wealthfolio, plotting-book, f1-stream, tandoor
- n8n, dawarich, diun, netbox, onlyoffice, tuya-bridge
- hackmd (ESO template for DB URL), health (ESO template for DB URL)
- trading-bot (ESO template for DATABASE_URL + 7 secret env vars)
- forgejo (removed unused vault data source)
Partially migrated (vault kept for plan-time, ESO added for runtime):
- immich, linkwarden, nextcloud, paperless-ngx (jsondecode for homepage)
- claude-memory, rybbit, url, webhook_handler (plan-time in locals/jobs)
- woodpecker, openclaw, resume (plan-time in helm values/jobs/modules)
17 stacks unchanged (all plan-time: homepage annotations, configmaps,
module inputs) — vault data source works with OIDC auth.
Phase 17a: Remove k8s-dashboard static admin token secret.
Users now get tokens via: vault write kubernetes/creds/dashboard-admin
2026-03-15 19:05:04 +00:00
|
|
|
name = "WF_AUTH_PASSWORD_HASH"
|
|
|
|
|
value_from {
|
|
|
|
|
secret_key_ref {
|
|
|
|
|
name = "wealthfolio-secrets"
|
|
|
|
|
key = "password_hash"
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-22 15:13:55 +00:00
|
|
|
}
|
|
|
|
|
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 19:13:05 +00:00
|
|
|
# 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.
|
[ci skip] right-size all pod resources based on VPA + live metrics audit
Full cluster resource audit: cross-referenced Goldilocks VPA recommendations,
live kubectl top metrics, and Terraform definitions for 100+ containers.
Critical fixes:
- dashy: CPU throttled at 98% (490m/500m) → 2 CPU limit
- stirling-pdf: CPU throttled at 99.7% (299m/300m) → 2 CPU limit
- traefik auth-proxy/bot-block-proxy: mem limit 32Mi → 128Mi
Added explicit resources to ~40 containers that had none:
- audiobookshelf, changedetection, cyberchef, dawarich, diun, echo,
excalidraw, freshrss, hackmd, isponsorblocktv, linkwarden, n8n,
navidrome, ntfy, owntracks, privatebin, send, shadowsocks, tandoor,
tor-proxy, wealthfolio, networking-toolbox, rybbit, mailserver,
cloudflared, pgadmin, phpmyadmin, crowdsec-web, xray, wireguard,
k8s-portal, tuya-bridge, ollama-ui, whisper, piper, immich-server,
immich-postgresql, osrm-foot
GPU containers: added CPU/mem alongside GPU limits:
- ollama: removed CPU/mem limits (models vary in size), keep GPU only
- frigate: req 500m/2Gi, lim 4/8Gi + GPU
- immich-ml: req 100m/1Gi, lim 2/4Gi + GPU
Right-sized ~25 over-provisioned containers:
- kms-web-page: 500m/512Mi → 50m/64Mi (was using 0m/10Mi)
- onlyoffice: CPU 8 → 2 (VPA upper 45m)
- realestate-crawler-api: CPU 2000m → 250m
- blog/travel-blog/webhook-handler: 500m → 100m
- coturn/health/plotting-book: reduced to match actual usage
Conservative methodology: limits = max(VPA upper * 2, live usage * 2)
2026-03-01 19:18:50 +00:00
|
|
|
resources {
|
|
|
|
|
requests = {
|
|
|
|
|
cpu = "10m"
|
2026-04-18 19:13:05 +00:00
|
|
|
memory = "256Mi"
|
[ci skip] right-size all pod resources based on VPA + live metrics audit
Full cluster resource audit: cross-referenced Goldilocks VPA recommendations,
live kubectl top metrics, and Terraform definitions for 100+ containers.
Critical fixes:
- dashy: CPU throttled at 98% (490m/500m) → 2 CPU limit
- stirling-pdf: CPU throttled at 99.7% (299m/300m) → 2 CPU limit
- traefik auth-proxy/bot-block-proxy: mem limit 32Mi → 128Mi
Added explicit resources to ~40 containers that had none:
- audiobookshelf, changedetection, cyberchef, dawarich, diun, echo,
excalidraw, freshrss, hackmd, isponsorblocktv, linkwarden, n8n,
navidrome, ntfy, owntracks, privatebin, send, shadowsocks, tandoor,
tor-proxy, wealthfolio, networking-toolbox, rybbit, mailserver,
cloudflared, pgadmin, phpmyadmin, crowdsec-web, xray, wireguard,
k8s-portal, tuya-bridge, ollama-ui, whisper, piper, immich-server,
immich-postgresql, osrm-foot
GPU containers: added CPU/mem alongside GPU limits:
- ollama: removed CPU/mem limits (models vary in size), keep GPU only
- frigate: req 500m/2Gi, lim 4/8Gi + GPU
- immich-ml: req 100m/1Gi, lim 2/4Gi + GPU
Right-sized ~25 over-provisioned containers:
- kms-web-page: 500m/512Mi → 50m/64Mi (was using 0m/10Mi)
- onlyoffice: CPU 8 → 2 (VPA upper 45m)
- realestate-crawler-api: CPU 2000m → 250m
- blog/travel-blog/webhook-handler: 500m → 100m
- coturn/health/plotting-book: reduced to match actual usage
Conservative methodology: limits = max(VPA upper * 2, live usage * 2)
2026-03-01 19:18:50 +00:00
|
|
|
}
|
|
|
|
|
limits = {
|
2026-04-18 19:13:05 +00:00
|
|
|
memory = "1Gi"
|
[ci skip] right-size all pod resources based on VPA + live metrics audit
Full cluster resource audit: cross-referenced Goldilocks VPA recommendations,
live kubectl top metrics, and Terraform definitions for 100+ containers.
Critical fixes:
- dashy: CPU throttled at 98% (490m/500m) → 2 CPU limit
- stirling-pdf: CPU throttled at 99.7% (299m/300m) → 2 CPU limit
- traefik auth-proxy/bot-block-proxy: mem limit 32Mi → 128Mi
Added explicit resources to ~40 containers that had none:
- audiobookshelf, changedetection, cyberchef, dawarich, diun, echo,
excalidraw, freshrss, hackmd, isponsorblocktv, linkwarden, n8n,
navidrome, ntfy, owntracks, privatebin, send, shadowsocks, tandoor,
tor-proxy, wealthfolio, networking-toolbox, rybbit, mailserver,
cloudflared, pgadmin, phpmyadmin, crowdsec-web, xray, wireguard,
k8s-portal, tuya-bridge, ollama-ui, whisper, piper, immich-server,
immich-postgresql, osrm-foot
GPU containers: added CPU/mem alongside GPU limits:
- ollama: removed CPU/mem limits (models vary in size), keep GPU only
- frigate: req 500m/2Gi, lim 4/8Gi + GPU
- immich-ml: req 100m/1Gi, lim 2/4Gi + GPU
Right-sized ~25 over-provisioned containers:
- kms-web-page: 500m/512Mi → 50m/64Mi (was using 0m/10Mi)
- onlyoffice: CPU 8 → 2 (VPA upper 45m)
- realestate-crawler-api: CPU 2000m → 250m
- blog/travel-blog/webhook-handler: 500m → 100m
- coturn/health/plotting-book: reduced to match actual usage
Conservative methodology: limits = max(VPA upper * 2, live usage * 2)
2026-03-01 19:18:50 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-02-22 15:13:55 +00:00
|
|
|
}
|
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" }
|
|
|
|
|
}
|
|
|
|
|
}
|
wealth: SQLite→PG ETL sidecar + new Grafana dashboard
Mirrors Wealthfolio's daily_account_valuation / accounts / activities
from SQLite into a new PG database (wealthfolio_sync) every hour, so
Grafana can chart net worth, contributions, and growth over time.
Components:
- dbaas: null_resource creates wealthfolio_sync DB + role on the CNPG
cluster (dynamic primary lookup so it survives failover).
- vault: pg-wealthfolio-sync static role rotates the password every 7d.
- wealthfolio: ExternalSecret pulls the rotated password into the WF
namespace; new pg-sync sidecar (alpine + sqlite + postgresql-client +
busybox crond) does sqlite3 .backup → TSV dump → truncate-and-reload
psql, hourly at :07. Plus a grafana-wealth-datasource ConfigMap in
the monitoring namespace (uid: wealth-pg).
- monitoring: new Wealth dashboard (wealth.json, 10 panels) — current
net worth / contribution / growth / ROI% stats, then time-series
for net worth, contribution-vs-market, growth area, per-account
stacked area, cash-vs-invested, and a 100-row activity log.
Initial sync: 6 accounts, 10,798 daily valuations, 518 activities.
Verified PG totals match SQLite latest snapshot exactly.
2026-04-25 17:07:33 +00:00
|
|
|
|
|
|
|
|
# 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)
|
2026-04-25 22:59:24 +00:00
|
|
|
FROM daily_account_valuation
|
|
|
|
|
WHERE account_id != 'TOTAL'; -- synthetic pre-aggregated row; would double-count when summed
|
wealth: SQLite→PG ETL sidecar + new Grafana dashboard
Mirrors Wealthfolio's daily_account_valuation / accounts / activities
from SQLite into a new PG database (wealthfolio_sync) every hour, so
Grafana can chart net worth, contributions, and growth over time.
Components:
- dbaas: null_resource creates wealthfolio_sync DB + role on the CNPG
cluster (dynamic primary lookup so it survives failover).
- vault: pg-wealthfolio-sync static role rotates the password every 7d.
- wealthfolio: ExternalSecret pulls the rotated password into the WF
namespace; new pg-sync sidecar (alpine + sqlite + postgresql-client +
busybox crond) does sqlite3 .backup → TSV dump → truncate-and-reload
psql, hourly at :07. Plus a grafana-wealth-datasource ConfigMap in
the monitoring namespace (uid: wealth-pg).
- monitoring: new Wealth dashboard (wealth.json, 10 panels) — current
net worth / contribution / growth / ROI% stats, then time-series
for net worth, contribution-vs-market, growth area, per-account
stacked area, cash-vs-invested, and a 100-row activity log.
Initial sync: 6 accounts, 10,798 daily valuations, 518 activities.
Verified PG totals match SQLite latest snapshot exactly.
2026-04-25 17:07:33 +00:00
|
|
|
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" }
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-22 15:13:55 +00:00
|
|
|
volume {
|
|
|
|
|
name = "data"
|
[ci skip] migrate 29 services from inline NFS to CSI-backed PV/PVC
Batch migration of all single-volume and simple multi-volume stacks.
All services verified healthy after migration. Uses nfs-truenas
StorageClass with soft,timeo=30,retrans=3 mount options to eliminate
stale NFS mount hangs.
Services: atuin, audiobookshelf, calibre, changedetection, diun,
excalidraw, forgejo, freshrss, grampsweb, hackmd, health,
isponsorblocktv, matrix, meshcentral, n8n, navidrome, ntfy, ollama,
onlyoffice, owntracks, paperless-ngx, poison-fountain, send,
stirling-pdf, tandoor, wealthfolio, whisper, woodpecker, ytdlp
2026-03-02 00:15:39 +00:00
|
|
|
persistent_volume_claim {
|
2026-04-17 20:29:57 +00:00
|
|
|
claim_name = "wealthfolio-data-encrypted"
|
2026-02-22 15:13:55 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-04-18 22:25:19 +00:00
|
|
|
volume {
|
|
|
|
|
name = "backup"
|
|
|
|
|
nfs {
|
|
|
|
|
server = var.nfs_server
|
|
|
|
|
path = "/srv/nfs/wealthfolio-backup"
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-22 15:13:55 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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"
|
2026-04-16 13:45:04 +00:00
|
|
|
dns_type = "proxied"
|
2026-02-22 15:13:55 +00:00
|
|
|
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"
|
2026-03-07 16:41:36 +00:00
|
|
|
extra_annotations = {
|
|
|
|
|
"gethomepage.dev/enabled" = "true"
|
|
|
|
|
"gethomepage.dev/name" = "Wealthfolio"
|
|
|
|
|
"gethomepage.dev/description" = "Investment portfolio tracker"
|
2026-03-07 21:32:58 +00:00
|
|
|
"gethomepage.dev/icon" = "mdi-finance"
|
2026-03-07 16:41:36 +00:00
|
|
|
"gethomepage.dev/group" = "Finance & Personal"
|
|
|
|
|
"gethomepage.dev/pod-selector" = ""
|
|
|
|
|
}
|
2026-02-22 13:56:34 +00:00
|
|
|
}
|
2026-03-24 02:07:36 +02:00
|
|
|
|
|
|
|
|
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"
|
2026-05-07 22:38:14 +00:00
|
|
|
# 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"
|
2026-03-24 02:07:36 +02:00
|
|
|
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 {
|
2026-04-17 20:29:57 +00:00
|
|
|
claim_name = "wealthfolio-data-encrypted"
|
2026-03-24 02:07:36 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
[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]
|
|
|
|
|
}
|
2026-03-24 02:07:36 +02:00
|
|
|
}
|
2026-04-18 22:25:19 +00:00
|
|
|
|
2026-05-09 17:38:38 +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"
|
|
|
|
|
}
|
|
|
|
|
}]
|
|
|
|
|
}
|
wealth: SQLite→PG ETL sidecar + new Grafana dashboard
Mirrors Wealthfolio's daily_account_valuation / accounts / activities
from SQLite into a new PG database (wealthfolio_sync) every hour, so
Grafana can chart net worth, contributions, and growth over time.
Components:
- dbaas: null_resource creates wealthfolio_sync DB + role on the CNPG
cluster (dynamic primary lookup so it survives failover).
- vault: pg-wealthfolio-sync static role rotates the password every 7d.
- wealthfolio: ExternalSecret pulls the rotated password into the WF
namespace; new pg-sync sidecar (alpine + sqlite + postgresql-client +
busybox crond) does sqlite3 .backup → TSV dump → truncate-and-reload
psql, hourly at :07. Plus a grafana-wealth-datasource ConfigMap in
the monitoring namespace (uid: wealth-pg).
- monitoring: new Wealth dashboard (wealth.json, 10 panels) — current
net worth / contribution / growth / ROI% stats, then time-series
for net worth, contribution-vs-market, growth area, per-account
stacked area, cash-vs-invested, and a 100-row activity log.
Initial sync: 6 accounts, 10,798 daily valuations, 518 activities.
Verified PG totals match SQLite latest snapshot exactly.
2026-04-25 17:07:33 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Grafana datasource for wealthfolio_sync PostgreSQL DB.
|
|
|
|
|
# Lives in the monitoring namespace so the Grafana sidecar (grafana_datasource=1) picks it up.
|
2026-05-09 17:38:38 +00:00
|
|
|
# Password is injected via $__env{...} from grafana-wealth-pg-creds (above).
|
wealth: SQLite→PG ETL sidecar + new Grafana dashboard
Mirrors Wealthfolio's daily_account_valuation / accounts / activities
from SQLite into a new PG database (wealthfolio_sync) every hour, so
Grafana can chart net worth, contributions, and growth over time.
Components:
- dbaas: null_resource creates wealthfolio_sync DB + role on the CNPG
cluster (dynamic primary lookup so it survives failover).
- vault: pg-wealthfolio-sync static role rotates the password every 7d.
- wealthfolio: ExternalSecret pulls the rotated password into the WF
namespace; new pg-sync sidecar (alpine + sqlite + postgresql-client +
busybox crond) does sqlite3 .backup → TSV dump → truncate-and-reload
psql, hourly at :07. Plus a grafana-wealth-datasource ConfigMap in
the monitoring namespace (uid: wealth-pg).
- monitoring: new Wealth dashboard (wealth.json, 10 panels) — current
net worth / contribution / growth / ROI% stats, then time-series
for net worth, contribution-vs-market, growth area, per-account
stacked area, cash-vs-invested, and a 100-row activity log.
Initial sync: 6 accounts, 10,798 daily valuations, 518 activities.
Verified PG totals match SQLite latest snapshot exactly.
2026-04-25 17:07:33 +00:00
|
|
|
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 = {
|
2026-05-09 17:38:38 +00:00
|
|
|
password = "$__env{WEALTH_PG_PASSWORD}"
|
wealth: SQLite→PG ETL sidecar + new Grafana dashboard
Mirrors Wealthfolio's daily_account_valuation / accounts / activities
from SQLite into a new PG database (wealthfolio_sync) every hour, so
Grafana can chart net worth, contributions, and growth over time.
Components:
- dbaas: null_resource creates wealthfolio_sync DB + role on the CNPG
cluster (dynamic primary lookup so it survives failover).
- vault: pg-wealthfolio-sync static role rotates the password every 7d.
- wealthfolio: ExternalSecret pulls the rotated password into the WF
namespace; new pg-sync sidecar (alpine + sqlite + postgresql-client +
busybox crond) does sqlite3 .backup → TSV dump → truncate-and-reload
psql, hourly at :07. Plus a grafana-wealth-datasource ConfigMap in
the monitoring namespace (uid: wealth-pg).
- monitoring: new Wealth dashboard (wealth.json, 10 panels) — current
net worth / contribution / growth / ROI% stats, then time-series
for net worth, contribution-vs-market, growth area, per-account
stacked area, cash-vs-invested, and a 100-row activity log.
Initial sync: 6 accounts, 10,798 daily valuations, 518 activities.
Verified PG totals match SQLite latest snapshot exactly.
2026-04-25 17:07:33 +00:00
|
|
|
}
|
|
|
|
|
editable = true
|
|
|
|
|
}]
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-05-09 17:38:38 +00:00
|
|
|
depends_on = [kubernetes_manifest.grafana_wealth_db_external_secret]
|
wealth: SQLite→PG ETL sidecar + new Grafana dashboard
Mirrors Wealthfolio's daily_account_valuation / accounts / activities
from SQLite into a new PG database (wealthfolio_sync) every hour, so
Grafana can chart net worth, contributions, and growth over time.
Components:
- dbaas: null_resource creates wealthfolio_sync DB + role on the CNPG
cluster (dynamic primary lookup so it survives failover).
- vault: pg-wealthfolio-sync static role rotates the password every 7d.
- wealthfolio: ExternalSecret pulls the rotated password into the WF
namespace; new pg-sync sidecar (alpine + sqlite + postgresql-client +
busybox crond) does sqlite3 .backup → TSV dump → truncate-and-reload
psql, hourly at :07. Plus a grafana-wealth-datasource ConfigMap in
the monitoring namespace (uid: wealth-pg).
- monitoring: new Wealth dashboard (wealth.json, 10 panels) — current
net worth / contribution / growth / ROI% stats, then time-series
for net worth, contribution-vs-market, growth area, per-account
stacked area, cash-vs-invested, and a 100-row activity log.
Initial sync: 6 accounts, 10,798 daily valuations, 518 activities.
Verified PG totals match SQLite latest snapshot exactly.
2026-04-25 17:07:33 +00:00
|
|
|
}
|
|
|
|
|
|
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.
|
|
|
|
|
############################################################################
|
2026-04-29 21:21:24 +00:00
|
|
|
|
|
|
|
|
############################################################################
|
|
|
|
|
# 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]
|
|
|
|
|
}
|
|
|
|
|
}
|