Skip to content

Commit d23d106

Browse files
committed
migrate host-agent from SQLite to PostgreSQL; fix startup race condition
- Replace SQLite with pgx driver for PostgreSQL connection - Update schema.sql with PostgreSQL syntax (SERIAL, BOOLEAN, $1 params) - Add DATABASE_URL config, derived from postgres module settings - Use sqlmock for unit tests instead of real database dependency - Add bloud-db-init user service to create host-agent database - Fix Authentik startup: depend on bloud-db-init for prestart hooks - Skip DB connection in configure.go for apps without configurators - Remove unused hosts and config tables from schema
1 parent dd926e7 commit d23d106

File tree

19 files changed

+563
-395
lines changed

19 files changed

+563
-395
lines changed

README.md

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,15 +47,27 @@ Self-hosting is overwhelming. Setting up Immich, Nextcloud, and Jellyfin takes h
4747

4848
Bloud combines declarative configuration with a dependency graph and idempotent reconciliation to eliminate manual setup.
4949

50-
**Integration Graph** - Each app declares what it needs: "I need PostgreSQL", "I support OAuth". Bloud builds a dependency graph from these declarations. Enable Miniflux, and the graph knows it needs a PostgreSQL database and Authentik OAuth. Enable any app with OAuth support, and it gets wired to Authentik automatically.
50+
### Integration Graph
5151

52-
**Idempotent Reconciliation** - Instead of fragile setup scripts that run once, Bloud uses Go configurators that reconcile desired state. They run on every startup: "Miniflux should have this OAuth provider configured. Does it? No? Add it. Yes? Move on." This means partial failures self-heal, and you can add new apps without re-running setup for existing ones.
52+
Each app declares what it needs: "I need PostgreSQL", "I support OAuth". Bloud builds a dependency graph from these declarations. Enable Miniflux, and the graph knows it needs a PostgreSQL database and Authentik OAuth. Enable any app with OAuth support, and it gets wired to Authentik automatically.
5353

54-
**Declarative Everything** - Apps are defined in `metadata.yaml` (what it needs) and `module.nix` (how to run it). Enable an app in your Nix config, rebuild, and NixOS generates the systemd units, creates the container, provisions the database, generates OAuth credentials, and starts everything in the right order.
54+
### Idempotent Reconciliation
5555

56-
**Shared Infrastructure** - Instead of each app running its own PostgreSQL, all apps share one instance. Bloud creates databases and users automatically. Same for Redis. Fewer containers, less RAM, simpler backups.
56+
Instead of fragile setup scripts that run once, Bloud uses Go configurators that reconcile desired state. They run on every startup: "Miniflux should have this OAuth provider configured. Does it? No? Add it. Yes? Move on." This means partial failures self-heal, and you can add new apps without re-running setup for existing ones.
5757

58-
**Health-Aware Startup** - The graph also determines startup order. Services declare dependencies with health checks. PostgreSQL starts first and becomes healthy. Then Authentik. Then apps that need both. Systemd handles the orchestration.
58+
### Declarative Everything
59+
60+
Apps are defined in `metadata.yaml` (what it needs) and `module.nix` (how to run it). Enable an app in your Nix config, rebuild, and NixOS generates the systemd units, creates the container, provisions the database, generates OAuth credentials, and starts everything in the right order.
61+
62+
### Shared Infrastructure
63+
64+
Instead of each app running its own PostgreSQL, all apps share one instance. Bloud creates databases and users automatically. Same for Redis. Fewer containers, less RAM, simpler backups.
65+
66+
### Health-Aware Startup
67+
68+
The graph also determines startup order. Services declare dependencies with health checks. PostgreSQL starts first and becomes healthy. Then Authentik. Then apps that need both. Systemd handles the orchestration.
69+
70+
---
5971

6072
The result: enable apps in a config file, rebuild, and the system figures out what to provision, how to connect everything, and what order to start it.
6173

apps/authentik/module.nix

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -146,8 +146,10 @@ in
146146
network = "apps-net";
147147
dependsOn = [ "apps-network" "apps-postgres" "apps-redis" ];
148148
userns = "keep-id";
149-
extraAfter = [ "authentik-db-init.service" ];
150-
extraRequires = [ "authentik-db-init.service" ];
149+
# bloud-db-init creates the host-agent database (needed by prestart hook)
150+
# authentik-db-init creates the authentik database (needed by server)
151+
extraAfter = [ "bloud-db-init.service" "authentik-db-init.service" ];
152+
extraRequires = [ "bloud-db-init.service" "authentik-db-init.service" ];
151153
waitFor = [
152154
{ container = "apps-postgres"; command = "pg_isready -U ${postgresUser}"; }
153155
{ container = "apps-redis"; command = "redis-cli ping"; }
@@ -182,8 +184,10 @@ in
182184
network = "apps-net";
183185
dependsOn = [ "apps-network" "apps-postgres" "apps-redis" ];
184186
userns = "keep-id";
185-
extraAfter = [ "authentik-db-init.service" ];
186-
extraRequires = [ "authentik-db-init.service" ];
187+
# bloud-db-init creates the host-agent database (needed by prestart hook)
188+
# authentik-db-init creates the authentik database (needed by worker)
189+
extraAfter = [ "bloud-db-init.service" "authentik-db-init.service" ];
190+
extraRequires = [ "bloud-db-init.service" "authentik-db-init.service" ];
187191
waitFor = [
188192
{ container = "apps-postgres"; command = "pg_isready -U ${postgresUser}"; }
189193
{ container = "apps-redis"; command = "redis-cli ping"; }

nixos/bloud.nix

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,47 @@ in
9898
};
9999
};
100100

101+
# Initialize the bloud database (required for app configurator hooks)
102+
# Apps with configurators should add this to their After= dependencies
103+
systemd.user.services.bloud-db-init = {
104+
description = "Initialize bloud database for host-agent";
105+
after = [ "podman-apps-postgres.service" ];
106+
requires = [ "podman-apps-postgres.service" ];
107+
wantedBy = [ "bloud-apps.target" ];
108+
before = [ "bloud-apps.target" ];
109+
serviceConfig = {
110+
Type = "oneshot";
111+
RemainAfterExit = true;
112+
ExecStart = pkgs.writeShellScript "bloud-db-init" ''
113+
set -e
114+
115+
# Wait for postgres to be ready (with timeout)
116+
echo "Waiting for PostgreSQL to be ready..."
117+
for i in $(seq 1 30); do
118+
if ${pkgs.podman}/bin/podman exec apps-postgres pg_isready -U ${config.bloud.apps.postgres.user} > /dev/null 2>&1; then
119+
echo "PostgreSQL is ready"
120+
break
121+
fi
122+
if [ $i -eq 30 ]; then
123+
echo "Timeout waiting for PostgreSQL"
124+
exit 1
125+
fi
126+
sleep 2
127+
done
128+
129+
# Create database if not exists
130+
if ! ${pkgs.podman}/bin/podman exec apps-postgres psql -U ${config.bloud.apps.postgres.user} -tc "SELECT 1 FROM pg_database WHERE datname = 'bloud'" | grep -q 1; then
131+
echo "Creating bloud database..."
132+
${pkgs.podman}/bin/podman exec apps-postgres psql -U ${config.bloud.apps.postgres.user} -c "CREATE DATABASE bloud"
133+
${pkgs.podman}/bin/podman exec apps-postgres psql -U ${config.bloud.apps.postgres.user} -c "GRANT ALL PRIVILEGES ON DATABASE bloud TO ${config.bloud.apps.postgres.user}"
134+
echo "Database created successfully"
135+
else
136+
echo "Database bloud already exists"
137+
fi
138+
'';
139+
};
140+
};
141+
101142
# Helper commands
102143
environment.systemPackages = [
103144
(pkgs.writeShellScriptBin "bloud-test" ''

nixos/modules/host-agent.nix

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,14 @@
22

33
let
44
cfg = config.bloud.host-agent;
5+
postgresCfg = config.bloud.apps.postgres;
56

67
userHome = "/home/${cfg.user}";
78
defaultDataDir = "${userHome}/.local/share/bloud";
89

10+
# Build database URL from postgres config
11+
databaseURL = "postgres://${postgresCfg.user}:${postgresCfg.password}@localhost:5432/bloud?sslmode=disable";
12+
913
# For initial development, we'll use a manually built binary
1014
# The binary should be built and placed at /tmp/host-agent
1115
# Later: Use buildGoModule for proper Nix packaging
@@ -40,28 +44,73 @@ in
4044
dataDir = lib.mkOption {
4145
type = lib.types.str;
4246
default = defaultDataDir;
43-
description = "Directory for host agent data (SQLite, configs, catalog)";
47+
description = "Directory for host agent data (configs, catalog)";
4448
};
4549
};
4650

4751
config = lib.mkIf cfg.enable {
4852
# Create data directories
4953
system.activationScripts.bloud-host-agent-dirs = lib.stringAfter [ "users" ] ''
50-
mkdir -p ${cfg.dataDir}/{state,nixos/apps,catalog}
54+
mkdir -p ${cfg.dataDir}/{nixos/apps,catalog}
5155
chown -R ${cfg.user}:users ${cfg.dataDir}
5256
'';
5357

58+
# Database initialization service
59+
systemd.services.bloud-host-agent-db-init = {
60+
description = "Initialize bloud host-agent database";
61+
after = [ "podman-apps-postgres.service" ];
62+
requires = [ "podman-apps-postgres.service" ];
63+
before = [ "bloud-host-agent.service" ];
64+
wantedBy = [ "multi-user.target" ];
65+
66+
serviceConfig = {
67+
Type = "oneshot";
68+
RemainAfterExit = true;
69+
User = cfg.user;
70+
Group = "users";
71+
ExecStart = pkgs.writeShellScript "bloud-db-init" ''
72+
set -e
73+
74+
# Wait for postgres to be ready
75+
echo "Waiting for PostgreSQL to be ready..."
76+
for i in $(seq 1 30); do
77+
if ${pkgs.podman}/bin/podman exec apps-postgres pg_isready -U ${postgresCfg.user} > /dev/null 2>&1; then
78+
echo "PostgreSQL is ready"
79+
break
80+
fi
81+
if [ $i -eq 30 ]; then
82+
echo "Timeout waiting for PostgreSQL"
83+
exit 1
84+
fi
85+
sleep 2
86+
done
87+
88+
# Create database if not exists
89+
if ! ${pkgs.podman}/bin/podman exec apps-postgres psql -U ${postgresCfg.user} -tc "SELECT 1 FROM pg_database WHERE datname = 'bloud'" | grep -q 1; then
90+
echo "Creating bloud database..."
91+
${pkgs.podman}/bin/podman exec apps-postgres psql -U ${postgresCfg.user} -c "CREATE DATABASE bloud"
92+
${pkgs.podman}/bin/podman exec apps-postgres psql -U ${postgresCfg.user} -c "GRANT ALL PRIVILEGES ON DATABASE bloud TO ${postgresCfg.user}"
93+
echo "Database created successfully"
94+
else
95+
echo "Database bloud already exists"
96+
fi
97+
'';
98+
};
99+
};
100+
54101
# systemd service (system-wide, NOT user service)
55102
# Runs as user but system-wide so it can manage system state
56103
systemd.services.bloud-host-agent = {
57104
description = "Bloud Host Agent - App Management & Web UI";
58-
after = [ "network-online.target" ];
105+
after = [ "network-online.target" "bloud-host-agent-db-init.service" ];
59106
wants = [ "network-online.target" ];
107+
requires = [ "bloud-host-agent-db-init.service" ];
60108
wantedBy = [ "multi-user.target" ];
61109

62110
environment = {
63111
BLOUD_PORT = toString cfg.port;
64112
BLOUD_DATA_DIR = cfg.dataDir;
113+
DATABASE_URL = databaseURL;
65114
};
66115

67116
serviceConfig = {

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"services/host-agent/web"
88
],
99
"scripts": {
10-
"setup": "npm install && npm run cli:build && echo '\n✓ Setup complete! Run ./bloud start to begin development.'",
10+
"setup": "npm install && npm run cli:build",
1111
"cli:build": "cd cli && go build -o ../bloud .",
1212
"dev": "turbo run dev",
1313
"build": "turbo run build",

services/host-agent/cmd/host-agent/configure.go

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,26 @@ func runConfigure(args []string) int {
3535

3636
cfg := config.Load()
3737

38-
// Initialize database (read-only for configure commands)
39-
database, err := db.InitDB(cfg.DataDir)
38+
// Create and populate registry first to check if app has a configurator
39+
registry := configurator.NewRegistry(logger)
40+
appconfig.RegisterAll(registry, cfg)
41+
42+
// For prestart/poststart, check if app has a configurator before initializing DB
43+
// This avoids chicken-and-egg problems for infrastructure apps (postgres, redis)
44+
if action == "prestart" || action == "poststart" {
45+
if len(args) < 2 {
46+
fmt.Fprintf(os.Stderr, "Usage: bloud-agent configure %s <app-name>\n", action)
47+
return 1
48+
}
49+
appName := args[1]
50+
if registry.Get(appName) == nil {
51+
logger.Debug("no configurator registered, skipping", "app", appName)
52+
return 0
53+
}
54+
}
55+
56+
// Initialize database (required for apps with configurators)
57+
database, err := db.InitDB(cfg.DatabaseURL)
4058
if err != nil {
4159
logger.Error("failed to initialize database", "error", err)
4260
return 1
@@ -46,26 +64,14 @@ func runConfigure(args []string) int {
4664
// Create app store
4765
appStore := store.NewAppStore(database)
4866

49-
// Create and populate registry
50-
registry := configurator.NewRegistry(logger)
51-
appconfig.RegisterAll(registry, cfg)
52-
5367
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
5468
defer cancel()
5569

5670
switch action {
5771
case "prestart":
58-
if len(args) < 2 {
59-
fmt.Fprintln(os.Stderr, "Usage: bloud-agent configure prestart <app-name>")
60-
return 1
61-
}
6272
return runPreStart(ctx, args[1], registry, appStore, cfg.DataDir, cfg, logger)
6373

6474
case "poststart":
65-
if len(args) < 2 {
66-
fmt.Fprintln(os.Stderr, "Usage: bloud-agent configure poststart <app-name>")
67-
return 1
68-
}
6975
return runPostStart(ctx, args[1], registry, appStore, cfg.DataDir, logger)
7076

7177
case "reconcile":

services/host-agent/cmd/host-agent/main.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ func runServer() {
5151
)
5252

5353
// Initialize database
54-
database, err := db.InitDB(cfg.DataDir)
54+
database, err := db.InitDB(cfg.DatabaseURL)
5555
if err != nil {
5656
logger.Error("failed to initialize database", "error", err)
5757
os.Exit(1)
@@ -117,3 +117,4 @@ func runServer() {
117117

118118
logger.Info("server stopped gracefully")
119119
}
120+

services/host-agent/go.mod

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,35 +4,35 @@ go 1.24.0
44

55
require (
66
codeberg.org/d-buckner/bloud-v3/apps v0.0.0
7+
github.com/DATA-DOG/go-sqlmock v1.5.2
78
github.com/go-chi/chi/v5 v5.2.3
89
github.com/go-chi/cors v1.2.2
10+
github.com/jackc/pgx/v5 v5.7.2
911
github.com/shirou/gopsutil/v3 v3.24.5
1012
github.com/stretchr/testify v1.11.1
1113
gopkg.in/yaml.v3 v3.0.1
12-
modernc.org/sqlite v1.42.2
1314
)
1415

1516
replace codeberg.org/d-buckner/bloud-v3/apps => ../../apps
1617

1718
require (
1819
github.com/davecgh/go-spew v1.1.1 // indirect
19-
github.com/dustin/go-humanize v1.0.1 // indirect
2020
github.com/go-ole/go-ole v1.2.6 // indirect
21-
github.com/google/uuid v1.6.0 // indirect
21+
github.com/jackc/pgpassfile v1.0.0 // indirect
22+
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
23+
github.com/jackc/puddle/v2 v2.2.2 // indirect
24+
github.com/kr/text v0.2.0 // indirect
2225
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
23-
github.com/mattn/go-isatty v0.0.20 // indirect
24-
github.com/ncruces/go-strftime v0.1.9 // indirect
2526
github.com/pmezard/go-difflib v1.0.0 // indirect
2627
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
27-
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
28+
github.com/rogpeppe/go-internal v1.14.1 // indirect
2829
github.com/shoenig/go-m1cpu v0.1.6 // indirect
2930
github.com/stretchr/objx v0.5.2 // indirect
3031
github.com/tklauser/go-sysconf v0.3.12 // indirect
3132
github.com/tklauser/numcpus v0.6.1 // indirect
3233
github.com/yusufpapurcu/wmi v1.2.4 // indirect
33-
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
34+
golang.org/x/crypto v0.31.0 // indirect
35+
golang.org/x/sync v0.10.0 // indirect
3436
golang.org/x/sys v0.36.0 // indirect
35-
modernc.org/libc v1.66.10 // indirect
36-
modernc.org/mathutil v1.7.1 // indirect
37-
modernc.org/memory v1.11.0 // indirect
37+
golang.org/x/text v0.21.0 // indirect
3838
)

0 commit comments

Comments
 (0)