finance: add actual budget and firefly

This commit is contained in:
Brad Stein 2026-01-16 23:52:56 -03:00
parent 354a803ff4
commit 3e3061fe5b
24 changed files with 1003 additions and 1 deletions

View File

@ -0,0 +1,30 @@
# clusters/atlas/flux-system/applications/finance/kustomization.yaml
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: finance
namespace: flux-system
spec:
interval: 10m
path: ./services/finance
prune: true
sourceRef:
kind: GitRepository
name: flux-system
targetNamespace: finance
dependsOn:
- name: keycloak
- name: postgres
- name: traefik
- name: vault
- name: mailu
healthChecks:
- apiVersion: apps/v1
kind: Deployment
name: actual-budget
namespace: finance
- apiVersion: apps/v1
kind: Deployment
name: firefly
namespace: finance
wait: false

View File

@ -28,4 +28,5 @@ resources:
- nextcloud-mail-sync/kustomization.yaml
- outline/kustomization.yaml
- planka/kustomization.yaml
- finance/kustomization.yaml
- health/kustomization.yaml

View File

@ -102,6 +102,12 @@ spec:
value: wger-user-sync
- name: WGER_USER_SYNC_WAIT_TIMEOUT_SEC
value: "90"
- name: FIREFLY_NAMESPACE
value: finance
- name: FIREFLY_USER_SYNC_CRONJOB
value: firefly-user-sync
- name: FIREFLY_USER_SYNC_WAIT_TIMEOUT_SEC
value: "90"
ports:
- name: http
containerPort: 8080

View File

@ -0,0 +1,12 @@
# services/finance/actual-budget-data-pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: actual-budget-data
namespace: finance
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: asteria
resources:
requests:
storage: 10Gi

View File

@ -0,0 +1,156 @@
# services/finance/actual-budget-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: actual-budget
namespace: finance
labels:
app: actual-budget
spec:
replicas: 1
selector:
matchLabels:
app: actual-budget
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 0
maxUnavailable: 1
template:
metadata:
labels:
app: actual-budget
annotations:
vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/role: "finance"
vault.hashicorp.com/agent-inject-secret-actual-env.sh: "kv/data/atlas/finance/actual-oidc"
vault.hashicorp.com/agent-inject-template-actual-env.sh: |
{{ with secret "kv/data/atlas/finance/actual-oidc" }}
export ACTUAL_OPENID_CLIENT_ID="{{ .Data.data.ACTUAL_OPENID_CLIENT_ID }}"
export ACTUAL_OPENID_CLIENT_SECRET="{{ .Data.data.ACTUAL_OPENID_CLIENT_SECRET }}"
{{ end }}
spec:
serviceAccountName: finance-vault
nodeSelector:
kubernetes.io/arch: arm64
node-role.kubernetes.io/worker: "true"
affinity:
nodeAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
preference:
matchExpressions:
- key: hardware
operator: In
values: ["rpi5"]
- weight: 70
preference:
matchExpressions:
- key: hardware
operator: In
values: ["rpi4"]
securityContext:
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
initContainers:
- name: init-data-permissions
image: docker.io/alpine:3.20
command: ["/bin/sh", "-c"]
args:
- |
set -e
mkdir -p /data
chown -R 1000:1000 /data
securityContext:
runAsUser: 0
runAsGroup: 0
volumeMounts:
- name: actual-data
mountPath: /data
- name: init-openid
image: actualbudget/actual-server:sha-b6452f9-alpine
command: ["/bin/sh", "-c"]
args:
- |
set -eu
. /vault/secrets/actual-env.sh
node /scripts/actual_openid_bootstrap.mjs
env:
- name: ACTUAL_DATA_DIR
value: /data
- name: ACTUAL_LOGIN_METHOD
value: openid
- name: ACTUAL_ALLOWED_LOGIN_METHODS
value: openid
- name: ACTUAL_MULTIUSER
value: "true"
- name: ACTUAL_OPENID_DISCOVERY_URL
value: https://sso.bstein.dev/realms/atlas
- name: ACTUAL_OPENID_SERVER_HOSTNAME
value: https://budget.bstein.dev
volumeMounts:
- name: actual-data
mountPath: /data
- name: actual-openid-bootstrap-script
mountPath: /scripts
readOnly: true
containers:
- name: actual-budget
image: actualbudget/actual-server:sha-b6452f9-alpine
command: ["/bin/sh", "-c"]
args:
- |
. /vault/secrets/actual-env.sh
exec node app
ports:
- name: http
containerPort: 5006
env:
- name: ACTUAL_DATA_DIR
value: /data
- name: ACTUAL_LOGIN_METHOD
value: openid
- name: ACTUAL_ALLOWED_LOGIN_METHODS
value: openid
- name: ACTUAL_MULTIUSER
value: "true"
- name: ACTUAL_OPENID_DISCOVERY_URL
value: https://sso.bstein.dev/realms/atlas
- name: ACTUAL_OPENID_SERVER_HOSTNAME
value: https://budget.bstein.dev
volumeMounts:
- name: actual-data
mountPath: /data
readinessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 6
livenessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 30
periodSeconds: 20
timeoutSeconds: 3
failureThreshold: 6
resources:
requests:
cpu: 200m
memory: 512Mi
limits:
cpu: "1"
memory: 1Gi
volumes:
- name: actual-data
persistentVolumeClaim:
claimName: actual-budget-data
- name: actual-openid-bootstrap-script
configMap:
name: actual-openid-bootstrap-script
defaultMode: 0555

View File

@ -0,0 +1,26 @@
# services/finance/actual-budget-ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: actual-budget
namespace: finance
annotations:
kubernetes.io/ingress.class: traefik
traefik.ingress.kubernetes.io/router.entrypoints: websecure
traefik.ingress.kubernetes.io/router.tls: "true"
cert-manager.io/cluster-issuer: letsencrypt
spec:
tls:
- hosts: ["budget.bstein.dev"]
secretName: actual-budget-tls
rules:
- host: budget.bstein.dev
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: actual-budget
port:
number: 80

View File

@ -0,0 +1,15 @@
# services/finance/actual-budget-service.yaml
apiVersion: v1
kind: Service
metadata:
name: actual-budget
namespace: finance
labels:
app: actual-budget
spec:
selector:
app: actual-budget
ports:
- name: http
port: 80
targetPort: 5006

View File

@ -0,0 +1,55 @@
# services/finance/firefly-cronjob.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
name: firefly-cron
namespace: finance
spec:
schedule: "0 3 * * *"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 1
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 1
template:
metadata:
annotations:
vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/agent-pre-populate-only: "true"
vault.hashicorp.com/role: "finance"
vault.hashicorp.com/agent-inject-secret-firefly-cron-token: "kv/data/atlas/finance/firefly-secrets"
vault.hashicorp.com/agent-inject-template-firefly-cron-token: |
{{- with secret "kv/data/atlas/finance/firefly-secrets" -}}
{{ .Data.data.STATIC_CRON_TOKEN }}
{{- end -}}
spec:
serviceAccountName: finance-vault
restartPolicy: Never
affinity:
nodeAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
preference:
matchExpressions:
- key: hardware
operator: In
values: ["rpi5"]
- weight: 70
preference:
matchExpressions:
- key: hardware
operator: In
values: ["rpi4"]
nodeSelector:
kubernetes.io/arch: arm64
node-role.kubernetes.io/worker: "true"
containers:
- name: cron
image: curlimages/curl:8.5.0
command: ["/bin/sh", "-c"]
args:
- |
set -eu
token="$(cat /vault/secrets/firefly-cron-token)"
curl -fsS "http://firefly.finance.svc.cluster.local/api/v1/cron/${token}"

View File

@ -0,0 +1,164 @@
# services/finance/firefly-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: firefly
namespace: finance
labels:
app: firefly
spec:
replicas: 1
selector:
matchLabels:
app: firefly
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 0
maxUnavailable: 1
template:
metadata:
labels:
app: firefly
annotations:
vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/role: "finance"
vault.hashicorp.com/agent-inject-secret-firefly-env.sh: "kv/data/atlas/finance/firefly-db"
vault.hashicorp.com/agent-inject-template-firefly-env.sh: |
{{ with secret "kv/data/atlas/finance/firefly-db" }}
export DB_CONNECTION="pgsql"
export DB_HOST="{{ .Data.data.DB_HOST }}"
export DB_PORT="{{ .Data.data.DB_PORT }}"
export DB_DATABASE="{{ .Data.data.DB_DATABASE }}"
export DB_USERNAME="{{ .Data.data.DB_USERNAME }}"
export DB_PASSWORD="$(cat /vault/secrets/firefly-db-password)"
{{ end }}
{{ with secret "kv/data/atlas/finance/firefly-secrets" }}
export APP_KEY="$(cat /vault/secrets/firefly-app-key)"
export STATIC_CRON_TOKEN="$(cat /vault/secrets/firefly-cron-token)"
{{ end }}
{{ with secret "kv/data/atlas/shared/postmark-relay" }}
export MAIL_USERNAME="{{ index .Data.data "relay-username" }}"
export MAIL_PASSWORD="{{ index .Data.data "relay-password" }}"
{{ end }}
vault.hashicorp.com/agent-inject-secret-firefly-db-password: "kv/data/atlas/finance/firefly-db"
vault.hashicorp.com/agent-inject-template-firefly-db-password: |
{{- with secret "kv/data/atlas/finance/firefly-db" -}}
{{ .Data.data.DB_PASSWORD }}
{{- end -}}
vault.hashicorp.com/agent-inject-secret-firefly-app-key: "kv/data/atlas/finance/firefly-secrets"
vault.hashicorp.com/agent-inject-template-firefly-app-key: |
{{- with secret "kv/data/atlas/finance/firefly-secrets" -}}
{{ .Data.data.APP_KEY }}
{{- end -}}
vault.hashicorp.com/agent-inject-secret-firefly-cron-token: "kv/data/atlas/finance/firefly-secrets"
vault.hashicorp.com/agent-inject-template-firefly-cron-token: |
{{- with secret "kv/data/atlas/finance/firefly-secrets" -}}
{{ .Data.data.STATIC_CRON_TOKEN }}
{{- end -}}
spec:
serviceAccountName: finance-vault
nodeSelector:
kubernetes.io/arch: arm64
node-role.kubernetes.io/worker: "true"
affinity:
nodeAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
preference:
matchExpressions:
- key: hardware
operator: In
values: ["rpi5"]
- weight: 70
preference:
matchExpressions:
- key: hardware
operator: In
values: ["rpi4"]
securityContext:
runAsUser: 33
runAsGroup: 33
fsGroup: 33
fsGroupChangePolicy: OnRootMismatch
initContainers:
- name: init-storage-permissions
image: docker.io/alpine:3.20
command: ["/bin/sh", "-c"]
args:
- |
set -e
mkdir -p /var/www/html/storage
chown -R 33:33 /var/www/html/storage
securityContext:
runAsUser: 0
runAsGroup: 0
volumeMounts:
- name: firefly-storage
mountPath: /var/www/html/storage
containers:
- name: firefly
image: fireflyiii/core:version-6.4.15
args: ["/bin/sh", "-c", ". /vault/secrets/firefly-env.sh && exec /init"]
env:
- name: APP_ENV
value: production
- name: APP_DEBUG
value: "false"
- name: APP_URL
value: https://money.bstein.dev
- name: SITE_OWNER
value: brad@bstein.dev
- name: TZ
value: Etc/UTC
- name: TRUSTED_PROXIES
value: "**"
- name: AUTHENTICATION_GUARD
value: web
- name: MAIL_MAILER
value: smtp
- name: MAIL_HOST
value: mail.bstein.dev
- name: MAIL_PORT
value: "587"
- name: MAIL_ENCRYPTION
value: tls
- name: MAIL_FROM
value: no-reply-firefly@bstein.dev
- name: CACHE_DRIVER
value: file
- name: SESSION_DRIVER
value: file
ports:
- name: http
containerPort: 8080
volumeMounts:
- name: firefly-storage
mountPath: /var/www/html/storage
readinessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 20
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 6
livenessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 30
periodSeconds: 20
timeoutSeconds: 5
failureThreshold: 6
resources:
requests:
cpu: 200m
memory: 512Mi
limits:
cpu: "1"
memory: 1Gi
volumes:
- name: firefly-storage
persistentVolumeClaim:
claimName: firefly-storage

View File

@ -0,0 +1,26 @@
# services/finance/firefly-ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: firefly
namespace: finance
annotations:
kubernetes.io/ingress.class: traefik
traefik.ingress.kubernetes.io/router.entrypoints: websecure
traefik.ingress.kubernetes.io/router.tls: "true"
cert-manager.io/cluster-issuer: letsencrypt
spec:
tls:
- hosts: ["money.bstein.dev"]
secretName: firefly-tls
rules:
- host: money.bstein.dev
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: firefly
port:
number: 80

View File

@ -0,0 +1,15 @@
# services/finance/firefly-service.yaml
apiVersion: v1
kind: Service
metadata:
name: firefly
namespace: finance
labels:
app: firefly
spec:
selector:
app: firefly
ports:
- name: http
port: 80
targetPort: 8080

View File

@ -0,0 +1,12 @@
# services/finance/firefly-storage-pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: firefly-storage
namespace: finance
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: asteria
resources:
requests:
storage: 10Gi

View File

@ -0,0 +1,90 @@
# services/finance/firefly-user-sync-cronjob.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
name: firefly-user-sync
namespace: finance
spec:
schedule: "0 6 * * *"
suspend: true
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 1
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 0
template:
metadata:
annotations:
vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/agent-pre-populate-only: "true"
vault.hashicorp.com/role: "finance"
vault.hashicorp.com/agent-inject-secret-firefly-env.sh: "kv/data/atlas/finance/firefly-db"
vault.hashicorp.com/agent-inject-template-firefly-env.sh: |
{{ with secret "kv/data/atlas/finance/firefly-db" }}
export DB_CONNECTION="pgsql"
export DB_HOST="{{ .Data.data.DB_HOST }}"
export DB_PORT="{{ .Data.data.DB_PORT }}"
export DB_DATABASE="{{ .Data.data.DB_DATABASE }}"
export DB_USERNAME="{{ .Data.data.DB_USERNAME }}"
export DB_PASSWORD="$(cat /vault/secrets/firefly-db-password)"
{{ end }}
{{ with secret "kv/data/atlas/finance/firefly-secrets" }}
export APP_KEY="$(cat /vault/secrets/firefly-app-key)"
{{ end }}
vault.hashicorp.com/agent-inject-secret-firefly-db-password: "kv/data/atlas/finance/firefly-db"
vault.hashicorp.com/agent-inject-template-firefly-db-password: |
{{- with secret "kv/data/atlas/finance/firefly-db" -}}
{{ .Data.data.DB_PASSWORD }}
{{- end -}}
vault.hashicorp.com/agent-inject-secret-firefly-app-key: "kv/data/atlas/finance/firefly-secrets"
vault.hashicorp.com/agent-inject-template-firefly-app-key: |
{{- with secret "kv/data/atlas/finance/firefly-secrets" -}}
{{ .Data.data.APP_KEY }}
{{- end -}}
spec:
serviceAccountName: finance-vault
restartPolicy: Never
affinity:
nodeAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
preference:
matchExpressions:
- key: hardware
operator: In
values: ["rpi5"]
- weight: 70
preference:
matchExpressions:
- key: hardware
operator: In
values: ["rpi4"]
nodeSelector:
kubernetes.io/arch: arm64
node-role.kubernetes.io/worker: "true"
containers:
- name: sync
image: fireflyiii/core:version-6.4.15
command: ["/bin/sh", "-c"]
args:
- |
set -eu
. /vault/secrets/firefly-env.sh
exec php /scripts/firefly_user_sync.php
env:
- name: APP_ENV
value: production
- name: APP_DEBUG
value: "false"
- name: TZ
value: Etc/UTC
volumeMounts:
- name: firefly-user-sync-script
mountPath: /scripts
readOnly: true
volumes:
- name: firefly-user-sync-script
configMap:
name: firefly-user-sync-script
defaultMode: 0555

View File

@ -0,0 +1,27 @@
# services/finance/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: finance
resources:
- namespace.yaml
- serviceaccount.yaml
- portal-rbac.yaml
- actual-budget-data-pvc.yaml
- firefly-storage-pvc.yaml
- actual-budget-deployment.yaml
- firefly-deployment.yaml
- firefly-user-sync-cronjob.yaml
- firefly-cronjob.yaml
- actual-budget-service.yaml
- firefly-service.yaml
- actual-budget-ingress.yaml
- firefly-ingress.yaml
generatorOptions:
disableNameSuffixHash: true
configMapGenerator:
- name: actual-openid-bootstrap-script
files:
- actual_openid_bootstrap.mjs=scripts/actual_openid_bootstrap.mjs
- name: firefly-user-sync-script
files:
- firefly_user_sync.php=scripts/firefly_user_sync.php

View File

@ -0,0 +1,5 @@
# services/finance/namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
name: finance

View File

@ -0,0 +1,31 @@
# services/finance/portal-rbac.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: bstein-dev-home-firefly-user-sync
namespace: finance
rules:
- apiGroups: ["batch"]
resources: ["cronjobs"]
verbs: ["get"]
resourceNames: ["firefly-user-sync"]
- apiGroups: ["batch"]
resources: ["jobs"]
verbs: ["create", "get", "list", "watch"]
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: bstein-dev-home-firefly-user-sync
namespace: finance
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: bstein-dev-home-firefly-user-sync
subjects:
- kind: ServiceAccount
name: bstein-dev-home
namespace: bstein-dev-home

View File

@ -0,0 +1,70 @@
import fs from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
function findRoot() {
const candidates = [];
if (process.env.ACTUAL_SERVER_ROOT) {
candidates.push(process.env.ACTUAL_SERVER_ROOT);
}
candidates.push('/app');
candidates.push('/usr/src/app');
candidates.push('/srv/app');
candidates.push('/opt/actual-server');
for (const base of candidates) {
if (!base) {
continue;
}
const accountDb = path.join(base, 'src', 'account-db.js');
if (fs.existsSync(accountDb)) {
return base;
}
}
return '';
}
const root = findRoot();
if (!root) {
console.error('actual server root not found');
process.exit(1);
}
const accountDbUrl = pathToFileURL(path.join(root, 'src', 'account-db.js')).href;
const loadConfigUrl = pathToFileURL(path.join(root, 'src', 'load-config.js')).href;
const accountDb = await import(accountDbUrl);
const { default: finalConfig } = await import(loadConfigUrl);
const openId = finalConfig?.openId;
if (!openId) {
console.error('missing openid configuration');
process.exit(1);
}
const active = accountDb.getActiveLoginMethod();
if (active === 'openid') {
console.log('openid already enabled');
process.exit(0);
}
try {
if (accountDb.needsBootstrap()) {
const result = await accountDb.bootstrap({ openId });
if (result?.error && result.error !== 'already-bootstrapped') {
console.error(`bootstrap failed: ${result.error}`);
process.exit(1);
}
} else {
const result = await accountDb.enableOpenID({ openId });
if (result?.error) {
console.error(`enable openid failed: ${result.error}`);
process.exit(1);
}
}
console.log('openid bootstrap complete');
} catch (err) {
console.error('openid bootstrap error:', err);
process.exit(1);
}

View File

@ -0,0 +1,107 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
use FireflyIII\Console\Commands\Correction\CreatesGroupMemberships;
use FireflyIII\Models\Role;
use FireflyIII\Repositories\User\UserRepositoryInterface;
use FireflyIII\User;
use Illuminate\Contracts\Console\Kernel as ConsoleKernel;
function log_line(string $message): void
{
fwrite(STDOUT, $message . PHP_EOL);
}
function error_line(string $message): void
{
fwrite(STDERR, $message . PHP_EOL);
}
function find_app_root(): string
{
$candidates = [];
$env_root = getenv('FIREFLY_APP_DIR') ?: '';
if ($env_root !== '') {
$candidates[] = $env_root;
}
$candidates[] = '/var/www/html';
$candidates[] = '/var/www/firefly-iii';
$candidates[] = '/app';
foreach ($candidates as $candidate) {
if (!is_dir($candidate)) {
continue;
}
if (file_exists($candidate . '/vendor/autoload.php')) {
return $candidate;
}
}
return '';
}
$email = trim((string) getenv('FIREFLY_USER_EMAIL'));
$password = (string) getenv('FIREFLY_USER_PASSWORD');
if ($email === '' || $password === '') {
error_line('missing FIREFLY_USER_EMAIL or FIREFLY_USER_PASSWORD');
exit(1);
}
$root = find_app_root();
if ($root === '') {
error_line('firefly app root not found');
exit(1);
}
$autoload = $root . '/vendor/autoload.php';
$app_bootstrap = $root . '/bootstrap/app.php';
if (!file_exists($autoload) || !file_exists($app_bootstrap)) {
error_line('firefly bootstrap files missing');
exit(1);
}
require $autoload;
$app = require $app_bootstrap;
$kernel = $app->make(ConsoleKernel::class);
$kernel->bootstrap();
$repository = $app->make(UserRepositoryInterface::class);
$existing_user = User::where('email', $email)->first();
$first_user = User::count() == 0;
if (!$existing_user) {
$existing_user = User::create(
[
'email' => $email,
'password' => bcrypt($password),
'blocked' => false,
'blocked_code' => null,
]
);
if ($first_user) {
$role = Role::where('name', 'owner')->first();
if ($role) {
$existing_user->roles()->attach($role);
}
}
log_line(sprintf('created firefly user %s', $email));
} else {
log_line(sprintf('updating firefly user %s', $email));
}
$existing_user->blocked = false;
$existing_user->blocked_code = null;
$existing_user->save();
$repository->changePassword($existing_user, $password);
CreatesGroupMemberships::createGroupMembership($existing_user);
log_line('firefly user sync complete');

View File

@ -0,0 +1,6 @@
# services/finance/serviceaccount.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: finance-vault
namespace: finance

View File

@ -0,0 +1,48 @@
# services/keycloak/actual-oidc-secret-ensure-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
name: actual-oidc-secret-ensure-1
namespace: sso
spec:
backoffLimit: 0
ttlSecondsAfterFinished: 3600
template:
metadata:
annotations:
vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/agent-pre-populate-only: "true"
vault.hashicorp.com/role: "sso-secrets"
vault.hashicorp.com/agent-inject-secret-keycloak-admin-env.sh: "kv/data/atlas/shared/keycloak-admin"
vault.hashicorp.com/agent-inject-template-keycloak-admin-env.sh: |
{{ with secret "kv/data/atlas/shared/keycloak-admin" }}
export KEYCLOAK_ADMIN="{{ .Data.data.username }}"
export KEYCLOAK_ADMIN_USER="{{ .Data.data.username }}"
export KEYCLOAK_ADMIN_PASSWORD="{{ .Data.data.password }}"
{{ end }}
spec:
serviceAccountName: mas-secrets-ensure
restartPolicy: Never
volumes:
- name: actual-oidc-secret-ensure-script
configMap:
name: actual-oidc-secret-ensure-script
defaultMode: 0555
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/arch
operator: In
values: ["arm64"]
- key: node-role.kubernetes.io/worker
operator: Exists
containers:
- name: apply
image: alpine:3.20
command: ["/scripts/actual_oidc_secret_ensure.sh"]
volumeMounts:
- name: actual-oidc-secret-ensure-script
mountPath: /scripts
readOnly: true

View File

@ -24,6 +24,7 @@ resources:
- logs-oidc-secret-ensure-job.yaml
- harbor-oidc-secret-ensure-job.yaml
- vault-oidc-secret-ensure-job.yaml
- actual-oidc-secret-ensure-job.yaml
- service.yaml
- ingress.yaml
generatorOptions:
@ -39,3 +40,6 @@ configMapGenerator:
- name: vault-oidc-secret-ensure-script
files:
- vault_oidc_secret_ensure.sh=scripts/vault_oidc_secret_ensure.sh
- name: actual-oidc-secret-ensure-script
files:
- actual_oidc_secret_ensure.sh=scripts/actual_oidc_secret_ensure.sh

View File

@ -250,6 +250,22 @@ spec:
"permissions": {"view": ["admin"], "edit": ["admin"]},
"validations": {"length": {"max": 64}},
},
{
"name": "firefly_password",
"displayName": "Firefly Password",
"multivalued": False,
"annotations": {"group": "user-metadata"},
"permissions": {"view": ["admin"], "edit": ["admin"]},
"validations": {"length": {"max": 255}},
},
{
"name": "firefly_password_updated_at",
"displayName": "Firefly Password Updated At",
"multivalued": False,
"annotations": {"group": "user-metadata"},
"permissions": {"view": ["admin"], "edit": ["admin"]},
"validations": {"length": {"max": 64}},
},
]
def has_attr(name: str) -> bool:

View File

@ -0,0 +1,78 @@
#!/usr/bin/env sh
set -euo pipefail
apk add --no-cache curl jq >/dev/null
. /vault/secrets/keycloak-admin-env.sh
KC_URL="http://keycloak.sso.svc.cluster.local"
ACCESS_TOKEN=""
for attempt in 1 2 3 4 5; do
TOKEN_JSON="$(curl -sS -X POST "$KC_URL/realms/master/protocol/openid-connect/token" \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d "grant_type=password" \
-d "client_id=admin-cli" \
-d "username=${KEYCLOAK_ADMIN}" \
-d "password=${KEYCLOAK_ADMIN_PASSWORD}" || true)"
ACCESS_TOKEN="$(echo "$TOKEN_JSON" | jq -r '.access_token' 2>/dev/null || true)"
if [ -n "$ACCESS_TOKEN" ] && [ "$ACCESS_TOKEN" != "null" ]; then
break
fi
echo "Keycloak token request failed (attempt ${attempt})" >&2
sleep $((attempt * 2))
done
if [ -z "$ACCESS_TOKEN" ] || [ "$ACCESS_TOKEN" = "null" ]; then
echo "Failed to fetch Keycloak admin token" >&2
exit 1
fi
CLIENT_QUERY="$(curl -sS -H "Authorization: Bearer ${ACCESS_TOKEN}" \
"$KC_URL/admin/realms/atlas/clients?clientId=actual-budget" || true)"
CLIENT_ID="$(echo "$CLIENT_QUERY" | jq -r '.[0].id' 2>/dev/null || true)"
if [ -z "$CLIENT_ID" ] || [ "$CLIENT_ID" = "null" ]; then
create_payload='{"clientId":"actual-budget","enabled":true,"protocol":"openid-connect","publicClient":false,"standardFlowEnabled":true,"implicitFlowEnabled":false,"directAccessGrantsEnabled":false,"serviceAccountsEnabled":false,"redirectUris":["https://budget.bstein.dev/openid/callback"],"webOrigins":["https://budget.bstein.dev"],"rootUrl":"https://budget.bstein.dev","baseUrl":"/"}'
status="$(curl -sS -o /dev/null -w "%{http_code}" -X POST \
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
-H 'Content-Type: application/json' \
-d "${create_payload}" \
"$KC_URL/admin/realms/atlas/clients")"
if [ "$status" != "201" ] && [ "$status" != "204" ]; then
echo "Keycloak client create failed (status ${status})" >&2
exit 1
fi
CLIENT_QUERY="$(curl -sS -H "Authorization: Bearer ${ACCESS_TOKEN}" \
"$KC_URL/admin/realms/atlas/clients?clientId=actual-budget" || true)"
CLIENT_ID="$(echo "$CLIENT_QUERY" | jq -r '.[0].id' 2>/dev/null || true)"
fi
if [ -z "$CLIENT_ID" ] || [ "$CLIENT_ID" = "null" ]; then
echo "Keycloak client actual-budget not found" >&2
exit 1
fi
CLIENT_SECRET="$(curl -sS -H "Authorization: Bearer ${ACCESS_TOKEN}" \
"$KC_URL/admin/realms/atlas/clients/${CLIENT_ID}/client-secret" | jq -r '.value' 2>/dev/null || true)"
if [ -z "$CLIENT_SECRET" ] || [ "$CLIENT_SECRET" = "null" ]; then
echo "Keycloak client secret not found" >&2
exit 1
fi
vault_addr="${VAULT_ADDR:-http://vault.vault.svc.cluster.local:8200}"
vault_role="${VAULT_ROLE:-sso-secrets}"
jwt="$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)"
login_payload="$(jq -nc --arg jwt "${jwt}" --arg role "${vault_role}" '{jwt:$jwt, role:$role}')"
vault_token="$(curl -sS --request POST --data "${login_payload}" \
"${vault_addr}/v1/auth/kubernetes/login" | jq -r '.auth.client_token')"
if [ -z "${vault_token}" ] || [ "${vault_token}" = "null" ]; then
echo "vault login failed" >&2
exit 1
fi
payload="$(jq -nc \
--arg client_id "actual-budget" \
--arg client_secret "${CLIENT_SECRET}" \
'{data:{ACTUAL_OPENID_CLIENT_ID:$client_id, ACTUAL_OPENID_CLIENT_SECRET:$client_secret}}')"
curl -sS -X POST -H "X-Vault-Token: ${vault_token}" \
-d "${payload}" "${vault_addr}/v1/kv/data/atlas/finance/actual-oidc" >/dev/null

View File

@ -214,6 +214,8 @@ write_policy_and_role "crypto" "crypto" "crypto-vault-sync" \
"crypto/* harbor-pull/crypto" ""
write_policy_and_role "health" "health" "health-vault-sync" \
"health/*" ""
write_policy_and_role "finance" "finance" "finance-vault" \
"finance/* shared/postmark-relay" ""
write_policy_and_role "longhorn" "longhorn-system" "longhorn-vault,longhorn-vault-sync" \
"longhorn/* harbor-pull/longhorn" ""
write_policy_and_role "postgres" "postgres" "postgres-vault" \
@ -223,7 +225,7 @@ write_policy_and_role "vault" "vault" "vault" \
write_policy_and_role "sso-secrets" "sso" "mas-secrets-ensure" \
"shared/keycloak-admin" \
"harbor/harbor-oidc vault/vault-oidc-config comms/synapse-oidc logging/oauth2-proxy-logs-oidc"
"harbor/harbor-oidc vault/vault-oidc-config comms/synapse-oidc logging/oauth2-proxy-logs-oidc finance/actual-oidc"
write_policy_and_role "crypto-secrets" "crypto" "crypto-secrets-ensure" \
"" \
"crypto/wallet-monero-temp-rpc-auth"