2026-06-09 08:45:33 +00:00
variable " tls_secret_name " {
type = string
sensitive = true
}
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
" keel.sh/enrolled " = " true "
}
}
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 " {
2026-06-25 21:28:11 +00:00
field_manager {
force_conflicts = true
}
2026-06-09 08:45:33 +00:00
manifest = {
2026-06-22 19:13:04 +00:00
apiVersion = " external-secrets.io/v1 "
2026-06-09 08:45:33 +00:00
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 " {
2026-06-25 21:28:11 +00:00
field_manager {
force_conflicts = true
}
2026-06-09 08:45:33 +00:00
manifest = {
2026-06-22 19:13:04 +00:00
apiVersion = " external-secrets.io/v1 "
2026-06-09 08:45:33 +00:00
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 {
ignore_changes = [
spec [ 0 ] . template [ 0 ] . spec [ 0 ] . dns_config , # KYVERNO_LIFECYCLE_V1
metadata [ 0 ] . annotations [ " keel.sh/policy " ] ,
metadata [ 0 ] . annotations [ " keel.sh/trigger " ] ,
metadata [ 0 ] . annotations [ " keel.sh/pollSchedule " ] , # KYVERNO_LIFECYCLE_V2
metadata [ 0 ] . annotations [ " keel.sh/match-tag " ] ,
spec [ 0 ] . template [ 0 ] . spec [ 0 ] . container [ 0 ] . image , # KEEL_IGNORE_IMAGE — Keel manages tag updates
spec [ 0 ] . template [ 0 ] . spec [ 0 ] . container [ 1 ] . image ,
spec [ 0 ] . template [ 0 ] . spec [ 0 ] . container [ 2 ] . image ,
metadata [ 0 ] . annotations [ " kubernetes.io/change-cause " ] ,
metadata [ 0 ] . annotations [ " deployment.kubernetes.io/revision " ] ,
spec [ 0 ] . template [ 0 ] . metadata [ 0 ] . annotations [ " keel.sh/update-time " ] , # KEEL_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 {
# Pinned 2026-05-26: prior live was :3.2.1, Keel rolled it to :2.0
# on 2026-05-26 03:13, then truncated to :3.2 at 06:46 (Keel string
# match dropped the patch suffix). Restore the patch version.
image = " afadil/wealthfolio:3.2.1 "
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.
resources {
requests = {
cpu = " 10m "
2026-06-29 15:27:17 +00:00
memory = " 128Mi "
2026-06-09 08:45:33 +00:00
}
limits = {
2026-06-29 15:27:17 +00:00
memory = " 512Mi "
2026-06-09 08:45:33 +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 ) ;
CREATE TABLE IF NOT EXISTS assets (
id TEXT PRIMARY KEY ,
symbol TEXT ,
name TEXT ,
currency TEXT ,
kind TEXT ,
exchange TEXT ,
is_active BOOLEAN
) ;
CREATE TABLE IF NOT EXISTS quote_latest (
asset_id TEXT PRIMARY KEY ,
day DATE NOT NULL ,
close NUMERIC NOT NULL ,
currency TEXT
) ;
CREATE TABLE IF NOT EXISTS positions_latest (
asset_id TEXT PRIMARY KEY ,
snapshot_date DATE NOT NULL ,
quantity NUMERIC NOT NULL ,
average_cost NUMERIC NOT NULL ,
total_cost_basis NUMERIC NOT NULL ,
currency TEXT
) ;
- - Drop - in replacement for daily_account_valuation . Net contribution
- - is corrected for two classes of " synthetic " flows that broker - sync
- - emits to make Wealthfolio ' s bookkeeping balance , but which do NOT
- - represent real user contributions / withdrawals :
- -
- - 1 . Fidelity pension ` unrealised - gains - offset ` DEPOSITs — emitted
- - to reconcile WF totals with PlanViewer . Otherwise WF treats
- - the gain as a contribution , growth shows £ 0 .
- -
- - 2 . Schwab RSU ` cash - flow - match ` DEPOSITs and WITHDRAWALs —
- - emitted to pair each vest BUY with a cash DEPOSIT and each
- - sell - to - cover SELL with a cash WITHDRAWAL . The user never
- - transfers cash to Schwab ( RSUs are compensation ) and the
- - sell proceeds leave the account to bank ( count ed elsewhere
- - when redeposited to IE / T212 ) . Without correction , Schwab
- - shows huge negative net_contribution because sell proceeds
- - exceed vest cost basis cumulatively .
- -
- - Scope : the cash - flow - match filter targets ONLY the Schwab account
- - ( account_id below ) . For InvestEngine / Trading212 the same note
- - pattern marks REAL user deposits , so they must be preserved .
CREATE OR REPLACE VIEW dav_corrected AS
WITH synthetic_flows AS (
- - Fidelity pension unrealised - gains - offsets ( always DEPOSIT ) .
SELECT account_id ,
activity_date : : date AS effective_date ,
COALESCE ( amount , 0 ) AS synthetic_net
FROM activities
WHERE notes LIKE ' fidelity - planviewer : unrealised - gains - offset % '
UNION ALL
- - Schwab RSU cash - flow - match ( DEPOSIT positive , WITHDRAWAL negative ) .
SELECT account_id ,
activity_date : : date AS effective_date ,
CASE
WHEN activity_type =' DEPOSIT ' THEN COALESCE ( amount , 0 )
WHEN activity_type =' WITHDRAWAL ' THEN - COALESCE ( amount , 0 )
ELSE 0
END AS synthetic_net
FROM activities
WHERE notes LIKE ' cash - flow - match : % '
AND account_id = ' 72 d34e09 - c1a6 -41 aa -99 ea - abe3305ecc4a ' - - Schwab
) ,
base AS (
SELECT
d . id , d . account_id , d . valuation_date , d . account_currency ,
d . base_currency , d . fx_rate_to_base , d . cash_balance ,
d . investment_market_value , d . total_value , d . cost_basis ,
d . net_contribution AS nc_raw ,
COALESCE ( SUM ( s . synthetic_net ) , 0 ) AS synthetic_adjustment
FROM daily_account_valuation d
LEFT JOIN synthetic_flows s
ON s . account_id = d . account_id
AND s . effective_date < = d . valuation_date
GROUP BY d . id , d . account_id , d . valuation_date , d . account_currency ,
d . base_currency , d . fx_rate_to_base , d . cash_balance ,
d . investment_market_value , d . total_value , d . cost_basis ,
d . net_contribution
) ,
- - LOCF gap - fill : a Fidelity pension valuation of 0 is always a
- - PlanViewer scrape gap ( the pot can ' t really be £ 0 ) , never a real
- - balance . Without this a missed scrape craters net worth to £ 0 for
- - the gap and the " Monthly contributions " panel shows a phantom
- - withdrawal then rebound ( witnessed Feb 2026 : - £ 97 k / + £ 100 k ) . Carry
- - the last non - zero day forward across the gap . Scoped to Fidelity
- - ( account_id below ) ; brokerage 0 s are left untouched .
filled AS (
SELECT * ,
SUM ( CASE WHEN total_value > 0 THEN 1 ELSE 0 END )
OVER ( PARTITION BY account_id ORDER BY valuation_date ) AS tv_grp
FROM base
)
SELECT
id , account_id , valuation_date , account_currency , base_currency ,
fx_rate_to_base ,
CASE WHEN account_id = ' a7d6208d -2 bd6 -4 f85 - bf54 - b77984c78234 ' AND total_value = 0
THEN MAX ( cash_balance ) OVER w ELSE cash_balance END AS cash_balance ,
CASE WHEN account_id = ' a7d6208d -2 bd6 -4 f85 - bf54 - b77984c78234 ' AND total_value = 0
THEN MAX ( investment_market_value ) OVER w ELSE investment_market_value END AS investment_market_value ,
CASE WHEN account_id = ' a7d6208d -2 bd6 -4 f85 - bf54 - b77984c78234 ' AND total_value = 0
THEN MAX ( total_value ) OVER w ELSE total_value END AS total_value ,
CASE WHEN account_id = ' a7d6208d -2 bd6 -4 f85 - bf54 - b77984c78234 ' AND total_value = 0
THEN MAX ( cost_basis ) OVER w ELSE cost_basis END AS cost_basis ,
nc_raw AS net_contribution_raw ,
( CASE WHEN account_id = ' a7d6208d -2 bd6 -4 f85 - bf54 - b77984c78234 ' AND total_value = 0
THEN MAX ( nc_raw ) OVER w ELSE nc_raw END ) - synthetic_adjustment AS net_contribution ,
synthetic_adjustment
FROM filled
WINDOW w AS ( PARTITION BY account_id , tv_grp ) ;
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
sqlite3 - separator $ ' \ t ' / tmp / wf - sync / snapshot . db < < ' SQ ' > / tmp / wf - sync / assets . tsv
SELECT id ,
COALESCE ( display_code , instrument_symbol ) AS symbol ,
name ,
quote_ccy AS currency ,
kind ,
COALESCE ( instrument_exchange_mic , ' ' ) AS exchange ,
is_active
FROM assets ;
SQ
# Latest quote per asset, preferring YAHOO over MANUAL when both exist on the same day.
sqlite3 - separator $ ' \ t ' / tmp / wf - sync / snapshot . db < < ' SQ ' > / tmp / wf - sync / quote_latest . tsv
SELECT asset_id , day , CAST ( close AS REAL ) AS close , currency
FROM (
SELECT asset_id , day , close , currency ,
ROW_NUMBER ( ) OVER (
PARTITION BY asset_id
ORDER BY day DESC , CASE source WHEN ' YAHOO ' THEN 1 ELSE 2 END
) AS rn
FROM quotes
)
WHERE rn = 1 ;
SQ
# Currently-held positions only, from the TOTAL aggregate snapshot (sums lots across accounts).
sqlite3 - separator $ ' \ t ' / tmp / wf - sync / snapshot . db < < ' SQ ' > / tmp / wf - sync / positions_latest . tsv
SELECT je . key AS asset_id ,
snapshot_date ,
CAST ( json_extract ( je . value , ' $ . quantity ' ) AS REAL ) AS quantity ,
CAST ( json_extract ( je . value , ' $ . averageCost ' ) AS REAL ) AS average_cost ,
CAST ( json_extract ( je . value , ' $ . totalCostBasis ' ) AS REAL ) AS total_cost_basis ,
json_extract ( je . value , ' $ . currency ' ) AS currency
FROM holdings_snapshots , json_each ( holdings_snapshots . positions ) AS je
WHERE account_id = ' TOTAL '
AND snapshot_date = ( SELECT MAX ( snapshot_date ) FROM holdings_snapshots WHERE account_id = ' TOTAL ' )
AND CAST ( json_extract ( je . value , ' $ . quantity ' ) AS REAL ) > 0 . 0001 ;
SQ
# Truncate-and-reload (small tables; simpler than upserts).
psql - v ON_ERROR_STOP =1 < < SQL
BEGIN ;
TRUNCATE accounts , daily_account_valuation , activities , assets , quote_latest , positions_latest ;
\ 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 ' ' ) ;
\ copy assets FROM ' / tmp / wf - sync / assets . tsv ' WITH ( FORMAT csv , DELIMITER E ' \ t ' , NULL ' ' ) ;
\ copy quote_latest FROM ' / tmp / wf - sync / quote_latest . tsv ' WITH ( FORMAT csv , DELIMITER E ' \ t ' , NULL ' ' ) ;
\ copy positions_latest FROM ' / tmp / wf - sync / positions_latest . 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 "
}
}
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 "
dns_type = " proxied "
namespace = kubernetes_namespace . wealthfolio . metadata [ 0 ] . name
name = " wealthfolio "
tls_secret_name = var . tls_secret_name
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 "
# Co-locate with the main wealthfolio app pod: both mount the same
# RWO `wealthfolio-data-encrypted` volume (the shared wealthfolio.db
# SQLite). An RWO volume attaches to only ONE node, so without this
# the monthly sync pod can land on a different node than the app and
# hang forever with a Multi-Attach error (observed 2026-06-04: a
# 2026-06-01 sync sat ContainerCreating for 3 days on node4 while the
# app held the volume on node3).
affinity {
pod_affinity {
required_during_scheduling_ignored_during_execution {
label_selector {
match_labels = {
app = " wealthfolio "
}
}
topology_key = " kubernetes.io/hostname "
}
}
}
image_pull_secrets {
name = " registry-credentials "
}
2026-06-13 01:39:35 +00:00
# Private ghcr image (ADR-0002) — cloned by sync-ghcr-credentials.
image_pull_secrets {
name = " ghcr-credentials "
}
2026-06-09 08:45:33 +00:00
container {
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).
2026-06-13 01:39:35 +00:00
image = " ghcr.io/viktorbarzin/wealthfolio-sync:latest "
2026-06-09 08:45:33 +00: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 {
claim_name = " wealthfolio-data-encrypted "
}
}
}
}
}
}
}
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 ]
}
}
# 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 " {
2026-06-25 21:28:11 +00:00
field_manager {
force_conflicts = true
}
2026-06-09 08:45:33 +00:00
manifest = {
2026-06-22 19:13:04 +00:00
apiVersion = " external-secrets.io/v1 "
2026-06-09 08:45:33 +00:00
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 ]
}
############################################################################
# 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 ]
}
}
2026-06-29 15:59:41 +00:00
# rightsizing reconcile 2026-06-29: re-trigger CI apply (memory limit committed in batch 2/3 but #427 was killed mid-apply; local apply blocked on stale backend-init).