Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions cmd/simpledeploy/configboot.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package main

import (
"context"
"fmt"
"log"
"os"
"path/filepath"

"github.com/vazra/simpledeploy/internal/config"
"github.com/vazra/simpledeploy/internal/configsync"
"github.com/vazra/simpledeploy/internal/store"
)

// initConfigSync runs the boot-time sidecar steps that come before git sync
// and the reconciler: disaster-recovery import of config.yml into an empty
// DB, orphan sidecar pruning, the mutation hook and the one-time backfill.
// App sidecars are imported later, by the reconciler, once it re-creates
// each app row from its folder.
func initConfigSync(db *store.Store, syncer *configsync.Syncer, dataDir string) {
// Rehydrate global config if DB is empty (first boot after DR).
if imported, err := syncer.ImportGlobalIfEmpty(); err != nil {
log.Printf("[configsync] global import failed: %v", err)
} else if imported {
log.Printf("[configsync] imported global sidecar into empty DB")
}

// Prune sidecar files left in folders that are no longer apps (e.g. from
// prior delete cycles before this feature existed). App folders with a
// compose file keep theirs even without a DB row.
if pruned, err := syncer.PruneOrphanSidecars(); err != nil {
log.Printf("[configsync] prune orphan sidecars: %v", err)
} else if len(pruned) > 0 {
log.Printf("[configsync] pruned orphan sidecars for: %v", pruned)
}

// Install mutation hook so future mutations update sidecars.
// FS-authoritative state writes are eventually consistent: this hook fires
// after a successful DB commit and schedules a debounced FS write (500ms).
// If the FS write fails, the reconciler watcher (internal/reconciler) re-
// applies on the next file edit. Contract: DB success => FS reflects soon.
// See docs/operations/state-on-disk.md.
db.SetMutationHook(func(scope store.MutationScope, slug string) {
switch scope {
case store.ScopeApp:
if slug != "" {
syncer.ScheduleAppWrite(slug)
}
case store.ScopeGlobal:
syncer.ScheduleGlobalWrite()
}
})

// First-boot sidecar backfill: write sidecars for all existing DB state
// so that upgrades from pre-configsync installs get sidecars on first boot.
backfillMarker := filepath.Join(dataDir, ".configsync_backfill_v1")
if _, err := os.Stat(backfillMarker); os.IsNotExist(err) {
allApps, listErr := db.ListApps()
if listErr != nil {
log.Printf("[configsync] backfill: list apps failed: %v (skipping)", listErr)
} else {
allOK := true
if backfillErr := syncer.WriteGlobal(); backfillErr != nil {
log.Printf("[configsync] backfill: write global failed: %v", backfillErr)
allOK = false
}
for _, a := range allApps {
if werr := syncer.WriteAppSidecar(a.Slug); werr != nil {
log.Printf("[configsync] backfill: write sidecar for %s: %v", a.Slug, werr)
allOK = false
}
}
if allOK {
log.Printf("[configsync] first-boot sidecar backfill: wrote %d apps + global", len(allApps))
if werr := os.WriteFile(backfillMarker, nil, 0600); werr != nil {
log.Printf("[configsync] backfill: write marker failed: %v", werr)
}
}
}
}
}

// reloadConfigFromFS runs the one-time FS-authoritative seed, then applies
// the sidecars on disk to the DB. Runs after git sync is wired (it decides
// whether access lists are applied) and before the reconciler starts.
func reloadConfigFromFS(ctx context.Context, db *store.Store, syncer *configsync.Syncer, cfg *config.Config) error {
// FS-authoritative seed (only on first boot post-upgrade).
if err := configsync.RunFirstBootSeedIfNeeded(ctx, db, syncer, cfg); err != nil {
return fmt.Errorf("fs-auth seed: %w", err)
}
// Reconcile DB cache from FS files (idempotent on every boot).
if err := syncer.ReconcileDBFromFS(ctx); err != nil {
return fmt.Errorf("fs-auth reload: %w", err)
}
return nil
}
158 changes: 158 additions & 0 deletions cmd/simpledeploy/configboot_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package main

import (
"context"
"os"
"path/filepath"
"testing"

"github.com/vazra/simpledeploy/internal/compose"
"github.com/vazra/simpledeploy/internal/config"
"github.com/vazra/simpledeploy/internal/configsync"
"github.com/vazra/simpledeploy/internal/deployer"
"github.com/vazra/simpledeploy/internal/reconciler"
"github.com/vazra/simpledeploy/internal/store"
)

// stubDeployer reports success for every action without running docker.
type stubDeployer struct{}

func (stubDeployer) Deploy(context.Context, *compose.AppConfig, ...deployer.RegistryAuth) deployer.DeployResult {
return deployer.DeployResult{}
}
func (stubDeployer) RollbackDeploy(context.Context, *compose.AppConfig, int, *int64, ...deployer.RegistryAuth) deployer.DeployResult {
return deployer.DeployResult{}
}
func (stubDeployer) Teardown(context.Context, string) error { return nil }
func (stubDeployer) Restart(context.Context, *compose.AppConfig) deployer.DeployResult {
return deployer.DeployResult{}
}
func (stubDeployer) Stop(context.Context, string) error { return nil }
func (stubDeployer) Start(context.Context, string) error { return nil }
func (stubDeployer) Pull(context.Context, *compose.AppConfig, []deployer.RegistryAuth) deployer.DeployResult {
return deployer.DeployResult{}
}
func (stubDeployer) Scale(context.Context, *compose.AppConfig, map[string]int) error { return nil }
func (stubDeployer) Status(context.Context, string) ([]deployer.ServiceStatus, error) {
return nil, nil
}
func (stubDeployer) Cancel(context.Context, *compose.AppConfig) error { return nil }

// bootServe runs the config steps of `serve` in boot order (no git sync)
// up to and including the reconciler's initial pass. The returned func is
// the graceful-shutdown flush.
func bootServe(t *testing.T, cfg *config.Config) (*store.Store, func()) {
t.Helper()
ctx := context.Background()
db, err := store.Open(filepath.Join(cfg.DataDir, "simpledeploy.db"))
if err != nil {
t.Fatal(err)
}
syncer := configsync.New(db, cfg.AppsDir, cfg.DataDir)
initConfigSync(db, syncer, cfg.DataDir)
if err := reloadConfigFromFS(ctx, db, syncer, cfg); err != nil {
t.Fatalf("reloadConfigFromFS: %v", err)
}
if err := reconciler.New(db, stubDeployer{}, nil, cfg.AppsDir, cfg, syncer).Reconcile(ctx); err != nil {
t.Fatalf("Reconcile: %v", err)
}
return db, func() {
if err := syncer.Close(); err != nil {
t.Errorf("syncer close: %v", err)
}
db.Close()
}
}

// TestServeBootRecoversConfigAfterDBWipe mirrors the disaster-recovery E2E:
// configure an app, stop, delete the DB, start again. Global config comes
// back from config.yml and the app's alert rules, backup configs and access
// grants from its simpledeploy.yml, which must not be pruned.
func TestServeBootRecoversConfigAfterDBWipe(t *testing.T) {
root := t.TempDir()
cfg := &config.Config{
DataDir: filepath.Join(root, "data"),
AppsDir: filepath.Join(root, "apps"),
MasterSecret: "test-secret-0123456789abcdef0123456789",
}
appDir := filepath.Join(cfg.AppsDir, "dr-nginx")
for _, d := range []string{cfg.DataDir, appDir} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(appDir, "docker-compose.yml"), []byte("services:\n web:\n image: nginx:alpine\n"), 0o644); err != nil {
t.Fatal(err)
}

// First run: the reconciler adopts the app, then it is configured.
db, shutdown := bootServe(t, cfg)
app, err := db.GetAppBySlug("dr-nginx")
if err != nil {
t.Fatalf("app not adopted: %v", err)
}
wh := &store.Webhook{Name: "dr-hook", Type: "slack", URL: "http://127.0.0.1:9/dr-hook"}
if err := db.CreateWebhook(wh); err != nil {
t.Fatal(err)
}
if _, err := db.CreateUser("dr-admin", "$2a$10$admin", "super_admin", "", ""); err != nil {
t.Fatal(err)
}
editor, err := db.CreateUser("dr-editor", "$2a$10$editor", "manage", "DR Editor", "editor@dr.local")
if err != nil {
t.Fatal(err)
}
if err := db.GrantAppAccess(editor.ID, app.ID); err != nil {
t.Fatal(err)
}
if err := db.CreateAlertRule(&store.AlertRule{AppID: &app.ID, Metric: "cpu_pct", Operator: ">", Threshold: 90, DurationSec: 300, WebhookID: wh.ID, Enabled: true}); err != nil {
t.Fatal(err)
}
if err := db.CreateBackupConfig(&store.BackupConfig{AppID: app.ID, Strategy: "volume", Target: "local", ScheduleCron: "0 3 * * *", TargetConfigJSON: "enc:target", RetentionMode: "count", RetentionCount: 7}); err != nil {
t.Fatal(err)
}
shutdown()

sidecar := filepath.Join(appDir, "simpledeploy.yml")
if _, err := os.Stat(sidecar); err != nil {
t.Fatalf("app sidecar not written on shutdown: %v", err)
}
for _, f := range []string{"simpledeploy.db", "simpledeploy.db-wal", "simpledeploy.db-shm"} {
if err := os.Remove(filepath.Join(cfg.DataDir, f)); err != nil && !os.IsNotExist(err) {
t.Fatal(err)
}
}

// Restart on the empty DB.
db, shutdown = bootServe(t, cfg)
defer shutdown()

if _, err := os.Stat(sidecar); err != nil {
t.Fatalf("app sidecar deleted on restart: %v", err)
}
u, err := db.GetUserByUsername("dr-editor")
if err != nil || u.Role != "manage" || u.DisplayName != "DR Editor" {
t.Fatalf("editor not restored: %+v, %v", u, err)
}
webhooks, _ := db.ListWebhooks()
if len(webhooks) != 1 || webhooks[0].Name != "dr-hook" || webhooks[0].URL != "http://127.0.0.1:9/dr-hook" {
t.Fatalf("webhook not restored: %+v", webhooks)
}
hook := webhooks[0]
app, err = db.GetAppBySlug("dr-nginx")
if err != nil {
t.Fatalf("app not re-adopted: %v", err)
}
rules, _ := db.ListAlertRules(&app.ID)
if len(rules) != 1 || rules[0].Metric != "cpu_pct" || rules[0].Operator != ">" || rules[0].Threshold != 90 ||
rules[0].DurationSec != 300 || rules[0].WebhookID != hook.ID || !rules[0].Enabled {
t.Errorf("alert rule not restored: %+v", rules)
}
backups, _ := db.ListBackupConfigs(&app.ID)
if len(backups) != 1 || backups[0].ScheduleCron != "0 3 * * *" || backups[0].TargetConfigJSON != "enc:target" {
t.Errorf("backup config not restored: %+v", backups)
}
if access, _ := db.ListAccessForApp(app.ID); len(access) != 1 || access[0] != "dr-editor" {
t.Errorf("access grant not restored: %v", access)
}
}
69 changes: 3 additions & 66 deletions cmd/simpledeploy/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -476,65 +476,7 @@ func runServe(cmd *cobra.Command, args []string) error {
syncer := configsync.New(db, cfg.AppsDir, cfg.DataDir)
defer syncer.Close()

// Rehydrate global config if DB is empty (first boot after DR).
if imported, err := syncer.ImportGlobalIfEmpty(); err != nil {
log.Printf("[configsync] global import failed: %v", err)
} else if imported {
log.Printf("[configsync] imported global sidecar into empty DB")
}

// Prune sidecar files for apps that no longer exist in the DB (e.g. from
// prior delete cycles before this feature existed).
if pruned, err := syncer.PruneOrphanSidecars(); err != nil {
log.Printf("[configsync] prune orphan sidecars: %v", err)
} else if len(pruned) > 0 {
log.Printf("[configsync] pruned orphan sidecars for: %v", pruned)
}

// Install mutation hook so future mutations update sidecars.
// FS-authoritative state writes are eventually consistent: this hook fires
// after a successful DB commit and schedules a debounced FS write (500ms).
// If the FS write fails, the reconciler watcher (internal/reconciler) re-
// applies on the next file edit. Contract: DB success => FS reflects soon.
// See docs/operations/state-on-disk.md.
db.SetMutationHook(func(scope store.MutationScope, slug string) {
switch scope {
case store.ScopeApp:
if slug != "" {
syncer.ScheduleAppWrite(slug)
}
case store.ScopeGlobal:
syncer.ScheduleGlobalWrite()
}
})

// First-boot sidecar backfill: write sidecars for all existing DB state
// so that upgrades from pre-configsync installs get sidecars on first boot.
backfillMarker := filepath.Join(cfg.DataDir, ".configsync_backfill_v1")
if _, err := os.Stat(backfillMarker); os.IsNotExist(err) {
allApps, listErr := db.ListApps()
if listErr != nil {
log.Printf("[configsync] backfill: list apps failed: %v (skipping)", listErr)
} else {
allOK := true
if backfillErr := syncer.WriteGlobal(); backfillErr != nil {
log.Printf("[configsync] backfill: write global failed: %v", backfillErr)
allOK = false
}
for _, a := range allApps {
if werr := syncer.WriteAppSidecar(a.Slug); werr != nil {
log.Printf("[configsync] backfill: write sidecar for %s: %v", a.Slug, werr)
allOK = false
}
}
if allOK {
log.Printf("[configsync] first-boot sidecar backfill: wrote %d apps + global", len(allApps))
if werr := os.WriteFile(backfillMarker, nil, 0600); werr != nil {
log.Printf("[configsync] backfill: write marker failed: %v", werr)
}
}
}
}
initConfigSync(db, syncer, cfg.DataDir)

// GitSync wiring (optional; non-fatal on error).
// Build syncer using DB-wins resolver. Start deferred to after ctx creation.
Expand Down Expand Up @@ -615,13 +557,8 @@ func runServe(cmd *cobra.Command, args []string) error {
ctx, cancel := context.WithCancel(cmd.Context())
defer cancel()

// FS-authoritative seed (only on first boot post-upgrade).
if err := configsync.RunFirstBootSeedIfNeeded(ctx, db, syncer, cfg); err != nil {
return fmt.Errorf("fs-auth seed: %w", err)
}
// Reconcile DB cache from FS files (idempotent on every boot).
if err := syncer.ReconcileDBFromFS(ctx); err != nil {
return fmt.Errorf("fs-auth reload: %w", err)
if err := reloadConfigFromFS(ctx, db, syncer, cfg); err != nil {
return err
}

// Start gitsync now that ctx exists.
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/reconciler.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ There is no internal periodic tick. If you want a heartbeat sweep, restart the p

1. `scanAppsDir()` walks `apps_dir`, parses and validates every `docker-compose.yml`, and returns a `scanResult`: `desired` (apps that pass) and `refused` (apps on disk that fail compose security rules, have a symlinked compose file, or reference files outside the app folder). Hidden directories (leading `.`) and directories without a compose file are skipped silently.
2. `store.ListApps()` returns currently-known apps from SQLite.
3. For each desired app: if not in the store, deploy. If in the store but the file's SHA-256 differs from `apps.compose_hash`, redeploy.
3. For each desired app: if not in the store, deploy, then import its `simpledeploy.yml` when the app has no alert rules, backup configs or access grants yet (`ImportAppSidecarIfMissing`; this is how per-app config comes back after a DB loss). If in the store but the file's SHA-256 differs from `apps.compose_hash`, redeploy.
4. For each store app neither desired nor refused, whose compose file is gone: archive it (teardown, tombstone, row marked archived; see [Archived apps](/operations/archived-apps/)).
5. Recompute the route table from desired apps plus route-only refused apps (below), tag every route with its app's store ID (`Route.AppID`, `math.MaxInt64` for apps not in the store yet), and call `proxy.SetRoutes()`. The proxy uses `AppID` to give a domain claimed by several apps to the one created first (see [Proxy](/architecture/proxy/)).

Expand Down
Loading
Loading