Skip to content

Commit 0be5023

Browse files
mhalsuwaidiclaude
authored andcommitted
Full DB management from admin UI: bidirectional migration, fallback, switch & restart
- db.json persists backend choice independently of the DB - OpenSmart(): try Postgres → fall back to BoltDB with warning if it fails - Reverse migration: Postgres → BoltDB (MigrateFromPostgres) - POST /api/admin/database/switch: save backend choice + auto-restart - Admin UI Database page: migrate both directions, switch backend with one click - ENV_POSTGRES_URL auto-saved to db.json for UI awareness - Default base path: /sauth Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 26aa575 commit 0be5023

8 files changed

Lines changed: 436 additions & 83 deletions

File tree

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.5.0
1+
0.5.1

internal/handler/admin_migration.go

Lines changed: 138 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"encoding/json"
55
"net/http"
66
"sync"
7+
"time"
78

89
"simpleauth/internal/store"
910
)
@@ -23,9 +24,14 @@ func (h *Handler) initMigrationState() {
2324
// handleDatabaseInfo returns info about the current database backend.
2425
// GET /api/admin/database/info
2526
func (h *Handler) handleDatabaseInfo(w http.ResponseWriter, r *http.Request) {
27+
dbCfg, _ := store.LoadDBConfig(h.cfg.DataDir)
2628
info := map[string]interface{}{
2729
"backend": h.storeBackend(),
2830
}
31+
if dbCfg != nil && dbCfg.PostgresURL != "" {
32+
// Mask password in URL for display
33+
info["postgres_configured"] = true
34+
}
2935
jsonResp(w, info, http.StatusOK)
3036
}
3137

@@ -62,21 +68,19 @@ func (h *Handler) handleMigrateTest(w http.ResponseWriter, r *http.Request) {
6268
jsonResp(w, map[string]interface{}{"ok": true}, http.StatusOK)
6369
}
6470

65-
// handleMigrateStart starts a BoltDB → Postgres migration.
71+
// handleMigrateStart starts a migration (BoltDB→Postgres or Postgres→BoltDB).
6672
// POST /api/admin/database/migrate
6773
func (h *Handler) handleMigrateStart(w http.ResponseWriter, r *http.Request) {
6874
var req struct {
69-
PostgresURL string `json:"postgres_url"`
75+
PostgresURL string `json:"postgres_url"` // required for bolt→pg
76+
Direction string `json:"direction"` // "to_postgres" (default) or "to_boltdb"
7077
}
71-
if err := readJSON(r, &req); err != nil || req.PostgresURL == "" {
72-
jsonError(w, "postgres_url is required", http.StatusBadRequest)
78+
if err := readJSON(r, &req); err != nil {
79+
jsonError(w, "invalid request body", http.StatusBadRequest)
7380
return
7481
}
75-
76-
boltStore, ok := h.store.(*store.BoltStore)
77-
if !ok {
78-
jsonError(w, "migration only supported from BoltDB backend", http.StatusBadRequest)
79-
return
82+
if req.Direction == "" {
83+
req.Direction = "to_postgres"
8084
}
8185

8286
h.migration.mu.RLock()
@@ -87,25 +91,15 @@ func (h *Handler) handleMigrateStart(w http.ResponseWriter, r *http.Request) {
8791
}
8892
h.migration.mu.RUnlock()
8993

90-
// Open target Postgres
91-
target, err := store.OpenPostgres(req.PostgresURL)
92-
if err != nil {
93-
jsonError(w, "failed to connect to postgres: "+err.Error(), http.StatusBadRequest)
94-
return
95-
}
96-
9794
// Reset status
9895
h.migration.mu.Lock()
9996
h.migration.status = store.MigrationStatus{State: "running", Progress: map[string]string{}}
10097
h.migration.mu.Unlock()
10198

10299
statusCh := make(chan store.MigrationStatus, 100)
103100

104-
// Background migration
105-
go func() {
106-
defer target.Close()
107-
108-
// Consume status updates
101+
// Consume status updates in background
102+
consumeStatus := func() chan struct{} {
109103
done := make(chan struct{})
110104
go func() {
111105
for s := range statusCh {
@@ -115,22 +109,80 @@ func (h *Handler) handleMigrateStart(w http.ResponseWriter, r *http.Request) {
115109
}
116110
close(done)
117111
}()
112+
return done
113+
}
118114

119-
err := store.MigrateToPostgres(boltStore, target, statusCh)
120-
close(statusCh)
121-
<-done
115+
switch req.Direction {
116+
case "to_postgres":
117+
if req.PostgresURL == "" {
118+
jsonError(w, "postgres_url is required", http.StatusBadRequest)
119+
return
120+
}
121+
boltStore, ok := h.store.(*store.BoltStore)
122+
if !ok {
123+
jsonError(w, "current backend is not BoltDB", http.StatusBadRequest)
124+
return
125+
}
126+
target, err := store.OpenPostgres(req.PostgresURL)
127+
if err != nil {
128+
jsonError(w, "failed to connect to postgres: "+err.Error(), http.StatusBadRequest)
129+
return
130+
}
122131

132+
go func() {
133+
done := consumeStatus()
134+
err := store.MigrateToPostgres(boltStore, target, statusCh)
135+
close(statusCh)
136+
<-done
137+
target.Close()
138+
139+
if err != nil {
140+
h.migration.mu.Lock()
141+
h.migration.status.State = "failed"
142+
h.migration.status.Error = err.Error()
143+
h.migration.mu.Unlock()
144+
}
145+
}()
146+
147+
h.audit("migration_started", "admin", getClientIP(r), map[string]interface{}{
148+
"direction": "to_postgres",
149+
})
150+
151+
case "to_boltdb":
152+
pgStore, ok := h.store.(*store.PostgresStore)
153+
if !ok {
154+
jsonError(w, "current backend is not PostgreSQL", http.StatusBadRequest)
155+
return
156+
}
157+
target, err := store.OpenBolt(h.cfg.DataDir)
123158
if err != nil {
124-
h.migration.mu.Lock()
125-
h.migration.status.State = "failed"
126-
h.migration.status.Error = err.Error()
127-
h.migration.mu.Unlock()
159+
jsonError(w, "failed to open BoltDB: "+err.Error(), http.StatusInternalServerError)
160+
return
128161
}
129-
}()
130162

131-
h.audit("migration_started", "admin", getClientIP(r), map[string]interface{}{
132-
"target": "postgres",
133-
})
163+
go func() {
164+
done := consumeStatus()
165+
err := store.MigrateFromPostgres(pgStore, target, statusCh)
166+
close(statusCh)
167+
<-done
168+
target.Close()
169+
170+
if err != nil {
171+
h.migration.mu.Lock()
172+
h.migration.status.State = "failed"
173+
h.migration.status.Error = err.Error()
174+
h.migration.mu.Unlock()
175+
}
176+
}()
177+
178+
h.audit("migration_started", "admin", getClientIP(r), map[string]interface{}{
179+
"direction": "to_boltdb",
180+
})
181+
182+
default:
183+
jsonError(w, "direction must be 'to_postgres' or 'to_boltdb'", http.StatusBadRequest)
184+
return
185+
}
134186

135187
jsonResp(w, map[string]string{"status": "started"}, http.StatusAccepted)
136188
}
@@ -146,3 +198,57 @@ func (h *Handler) handleMigrateStatus(w http.ResponseWriter, r *http.Request) {
146198
w.Header().Set("Content-Type", "application/json")
147199
w.Write(data)
148200
}
201+
202+
// handleSwitchBackend saves the backend choice to db.json and triggers restart.
203+
// POST /api/admin/database/switch
204+
func (h *Handler) handleSwitchBackend(w http.ResponseWriter, r *http.Request) {
205+
var req struct {
206+
Backend string `json:"backend"` // "boltdb" or "postgres"
207+
PostgresURL string `json:"postgres_url"` // required when backend=postgres
208+
}
209+
if err := readJSON(r, &req); err != nil {
210+
jsonError(w, "invalid request body", http.StatusBadRequest)
211+
return
212+
}
213+
214+
switch req.Backend {
215+
case "boltdb":
216+
if err := store.SaveDBConfig(h.cfg.DataDir, &store.DBConfig{Backend: "boltdb"}); err != nil {
217+
jsonError(w, "failed to save config: "+err.Error(), http.StatusInternalServerError)
218+
return
219+
}
220+
case "postgres":
221+
if req.PostgresURL == "" {
222+
jsonError(w, "postgres_url is required", http.StatusBadRequest)
223+
return
224+
}
225+
if err := store.TestPostgresConnection(req.PostgresURL); err != nil {
226+
jsonError(w, "postgres connection failed: "+err.Error(), http.StatusBadRequest)
227+
return
228+
}
229+
if err := store.SaveDBConfig(h.cfg.DataDir, &store.DBConfig{Backend: "postgres", PostgresURL: req.PostgresURL}); err != nil {
230+
jsonError(w, "failed to save config: "+err.Error(), http.StatusInternalServerError)
231+
return
232+
}
233+
default:
234+
jsonError(w, "backend must be 'boltdb' or 'postgres'", http.StatusBadRequest)
235+
return
236+
}
237+
238+
h.audit("backend_switched", "admin", getClientIP(r), map[string]interface{}{
239+
"backend": req.Backend,
240+
})
241+
242+
jsonResp(w, map[string]string{"status": "saved", "backend": req.Backend}, http.StatusOK)
243+
244+
// Trigger restart if channel available
245+
if h.restartCh != nil {
246+
go func() {
247+
time.Sleep(200 * time.Millisecond)
248+
select {
249+
case h.restartCh <- struct{}{}:
250+
default:
251+
}
252+
}()
253+
}
254+
}

internal/handler/handler.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,7 @@ func (h *Handler) registerRoutes(uiFS fs.FS) {
223223
h.mux.HandleFunc("POST /api/admin/database/test", h.requireMasterAdmin(h.handleMigrateTest))
224224
h.mux.HandleFunc("POST /api/admin/database/migrate", h.requireMasterAdmin(h.handleMigrateStart))
225225
h.mux.HandleFunc("GET /api/admin/database/migrate/status", h.requireMasterAdmin(h.handleMigrateStatus))
226+
h.mux.HandleFunc("POST /api/admin/database/switch", h.requireMasterAdmin(h.handleSwitchBackend))
226227

227228
// Admin: Backup/Restore
228229
h.mux.HandleFunc("GET /api/admin/backup", h.requireMasterAdmin(h.handleBackup))

internal/store/dbconfig.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package store
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"log"
7+
"os"
8+
"path/filepath"
9+
)
10+
11+
// DBConfig persists the active backend choice in a local JSON file.
12+
// This lives outside the DB so it can be read before opening any store.
13+
type DBConfig struct {
14+
Backend string `json:"backend"` // "boltdb" or "postgres"
15+
PostgresURL string `json:"postgres_url"` // connection string (only when backend=postgres)
16+
}
17+
18+
// DBConfigPath returns the path to db.json in the data directory.
19+
func DBConfigPath(dataDir string) string {
20+
return filepath.Join(dataDir, "db.json")
21+
}
22+
23+
// LoadDBConfig reads the backend config from db.json.
24+
// Returns nil if the file doesn't exist (first run).
25+
func LoadDBConfig(dataDir string) (*DBConfig, error) {
26+
path := DBConfigPath(dataDir)
27+
data, err := os.ReadFile(path)
28+
if os.IsNotExist(err) {
29+
return nil, nil
30+
}
31+
if err != nil {
32+
return nil, fmt.Errorf("read db config: %w", err)
33+
}
34+
var cfg DBConfig
35+
if err := json.Unmarshal(data, &cfg); err != nil {
36+
return nil, fmt.Errorf("parse db config: %w", err)
37+
}
38+
return &cfg, nil
39+
}
40+
41+
// SaveDBConfig writes the backend config to db.json.
42+
func SaveDBConfig(dataDir string, cfg *DBConfig) error {
43+
if err := os.MkdirAll(dataDir, 0700); err != nil {
44+
return err
45+
}
46+
data, err := json.MarshalIndent(cfg, "", " ")
47+
if err != nil {
48+
return err
49+
}
50+
return os.WriteFile(DBConfigPath(dataDir), data, 0600)
51+
}
52+
53+
// OpenSmart opens the appropriate store based on:
54+
// 1. db.json in dataDir (if exists — UI-managed backend choice)
55+
// 2. postgresURL from env/config (backward compat)
56+
// 3. Falls back to BoltDB
57+
//
58+
// If Postgres is configured but fails, it falls back to BoltDB with a warning.
59+
func OpenSmart(dataDir, envPostgresURL string) (Store, error) {
60+
if err := os.MkdirAll(dataDir, 0700); err != nil {
61+
return nil, fmt.Errorf("create data dir: %w", err)
62+
}
63+
64+
// Check db.json first (UI-managed)
65+
dbCfg, _ := LoadDBConfig(dataDir)
66+
if dbCfg != nil && dbCfg.Backend == "postgres" && dbCfg.PostgresURL != "" {
67+
s, err := OpenPostgres(dbCfg.PostgresURL)
68+
if err != nil {
69+
log.Printf("[store] WARNING: Postgres failed (%v) — falling back to BoltDB", err)
70+
return OpenBolt(dataDir)
71+
}
72+
log.Printf("[store] Using PostgreSQL backend")
73+
return s, nil
74+
}
75+
76+
// Check env/config postgres URL (backward compat)
77+
if envPostgresURL != "" {
78+
s, err := OpenPostgres(envPostgresURL)
79+
if err != nil {
80+
log.Printf("[store] WARNING: Postgres failed (%v) — falling back to BoltDB", err)
81+
return OpenBolt(dataDir)
82+
}
83+
// Save to db.json so UI knows the backend
84+
SaveDBConfig(dataDir, &DBConfig{Backend: "postgres", PostgresURL: envPostgresURL})
85+
log.Printf("[store] Using PostgreSQL backend (from env)")
86+
return s, nil
87+
}
88+
89+
// Default: BoltDB
90+
log.Printf("[store] Using BoltDB backend")
91+
return OpenBolt(dataDir)
92+
}

0 commit comments

Comments
 (0)