feat: add controller self-update mechanism (v0.16.0)
New selfupdate package: version parsing, audit state file, updater with
Gitea registry V2 check, docker pull + compose rewrite + compose up flow.
- API: /api/selfupdate/{status,check,update} with session+bearer auth
- UI: Settings "Verzió és frissítés" card with check/install buttons + JS polling
- Scheduler: periodic check (6h default) + optional daily auto-update
- Notifications: success/failure on post-update startup verification
- Alert: info banner when update available
- docker-compose.yml: add directory bind mount for compose file access
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,8 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"strings"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/api"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
@@ -22,6 +24,7 @@ import (
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/notify"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/report"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/scheduler"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/selfupdate"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
catalogsync "gitea.dooplex.hu/admin/felhom-controller/internal/sync"
|
||||
@@ -220,6 +223,26 @@ func main() {
|
||||
// --- Initialize notifier ---
|
||||
notifier := notify.New(cfg.Hub.URL, cfg.Hub.APIKey, cfg.Customer.ID, sett, logger)
|
||||
|
||||
// --- Initialize self-updater ---
|
||||
var updater *selfupdate.Updater
|
||||
if cfg.SelfUpdate.Enabled {
|
||||
composePath := filepath.Join(filepath.Dir(cfg.Paths.DataDir), "docker-compose.yml")
|
||||
updater = selfupdate.NewUpdater(&cfg.SelfUpdate, &cfg.Git, Version, cfg.Paths.DataDir, composePath, logger)
|
||||
updater.SetBackupRunningCheck(func() bool {
|
||||
return backupMgr != nil && backupMgr.IsRunning()
|
||||
})
|
||||
// Check for post-update state (did a previous update succeed or fail?)
|
||||
if state := updater.VerifyStartup(); state != nil {
|
||||
if state.Status == "success" {
|
||||
notifier.NotifyUpdateSuccess(state.PreviousVersion, state.TargetVersion)
|
||||
} else if state.Status == "failed" {
|
||||
notifier.NotifyUpdateFailed(state.TargetVersion, state.Error)
|
||||
}
|
||||
}
|
||||
logger.Printf("[INFO] Self-update enabled (check every %s, auto-update: %v, auto-update time: %s)",
|
||||
cfg.SelfUpdate.CheckInterval, cfg.SelfUpdate.AutoUpdate, cfg.SelfUpdate.AutoUpdateTime)
|
||||
}
|
||||
|
||||
// --- Initialize scheduler ---
|
||||
sched := scheduler.New(logger)
|
||||
|
||||
@@ -252,7 +275,16 @@ func main() {
|
||||
pinger.Ping(healthUUID, body)
|
||||
}
|
||||
// Refresh dashboard alerts from health report
|
||||
alertMgr.Refresh(healthReport, cfg, backupMgr)
|
||||
updateAvailable := false
|
||||
latestVersion := ""
|
||||
if updater != nil {
|
||||
status := updater.GetStatus()
|
||||
if status.LastCheck != nil {
|
||||
updateAvailable = status.LastCheck.UpdateAvailable
|
||||
latestVersion = status.LastCheck.LatestVersion
|
||||
}
|
||||
}
|
||||
alertMgr.Refresh(healthReport, cfg, backupMgr, updateAvailable, latestVersion)
|
||||
// Notify on health status changes
|
||||
notifier.NotifyHealthChange(healthReport.Status, healthReport.Issues, healthReport.Warnings)
|
||||
return nil
|
||||
@@ -347,6 +379,36 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Self-update scheduler jobs
|
||||
if cfg.SelfUpdate.Enabled && updater != nil {
|
||||
// Periodic version check (populates UI, never triggers update)
|
||||
checkInterval, ciErr := time.ParseDuration(cfg.SelfUpdate.CheckInterval)
|
||||
if ciErr != nil {
|
||||
checkInterval = 6 * time.Hour
|
||||
}
|
||||
sched.Every("selfupdate-check", checkInterval, func(ctx context.Context) error {
|
||||
result := updater.CheckForUpdate()
|
||||
if result.UpdateAvailable {
|
||||
logger.Printf("[INFO] Update available: %s -> %s", result.CurrentVersion, result.LatestVersion)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
// Auto-update (daily, fires after typical backup completion)
|
||||
if cfg.SelfUpdate.AutoUpdate {
|
||||
sched.Daily("selfupdate-auto", cfg.SelfUpdate.AutoUpdateTime, func(ctx context.Context) error {
|
||||
result := updater.CheckForUpdate()
|
||||
if !result.UpdateAvailable {
|
||||
return nil
|
||||
}
|
||||
if err := updater.TriggerUpdate("auto"); err != nil {
|
||||
logger.Printf("[WARN] Auto-update skipped: %v", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
sched.Start(ctx)
|
||||
defer sched.Stop()
|
||||
|
||||
@@ -406,6 +468,17 @@ func main() {
|
||||
hubPusher.PushOnce(r)
|
||||
}
|
||||
}
|
||||
|
||||
// Initial self-update check (so settings page shows version info quickly)
|
||||
if updater != nil {
|
||||
time.Sleep(25 * time.Second) // Additional delay after hub report
|
||||
result := updater.CheckForUpdate()
|
||||
if result.UpdateAvailable {
|
||||
logger.Printf("[INFO] Startup: update available %s -> %s", result.CurrentVersion, result.LatestVersion)
|
||||
} else if result.Error != "" {
|
||||
logger.Printf("[DEBUG] Startup version check: %s", result.Error)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Initial backup cache population (don't block startup)
|
||||
@@ -432,14 +505,14 @@ func main() {
|
||||
// Initial alert refresh (so alerts appear immediately, not after first 5min health check)
|
||||
go func() {
|
||||
report := monitor.RunHealthCheck(cfg, cpuCollector, sett.GetStoragePaths())
|
||||
alertMgr.Refresh(report, cfg, backupMgr)
|
||||
alertMgr.Refresh(report, cfg, backupMgr, false, "")
|
||||
}()
|
||||
|
||||
// --- Initialize API router ---
|
||||
apiRouter := api.NewRouter(cfg, sett, stackMgr, syncer, cpuCollector, backupMgr, crossDriveRunner, metricsStore, logger)
|
||||
apiRouter := api.NewRouter(cfg, sett, stackMgr, syncer, cpuCollector, backupMgr, crossDriveRunner, metricsStore, updater, logger)
|
||||
|
||||
// --- Initialize web server ---
|
||||
webServer := web.NewServer(cfg, stackMgr, cpuCollector, backupMgr, crossDriveRunner, sched, sett, alertMgr, notifier, logger, Version)
|
||||
webServer := web.NewServer(cfg, stackMgr, cpuCollector, backupMgr, crossDriveRunner, sched, sett, alertMgr, notifier, updater, logger, Version)
|
||||
|
||||
// Phase 3: Set DR restore mode if a restore plan was built
|
||||
if restorePlan != nil && len(restorePlan.Apps) > 0 {
|
||||
@@ -454,6 +527,8 @@ func main() {
|
||||
mux.HandleFunc("/api/health", apiRouter.HealthHandler)
|
||||
// Storage API routes handled by web server (longer prefix takes precedence over /api/)
|
||||
mux.Handle("/api/storage/", webServer.RequireAuth(http.HandlerFunc(webServer.ServeStorageAPI)))
|
||||
// Self-update API — accepts session auth OR hub API key (for external triggering)
|
||||
mux.Handle("/api/selfupdate/", selfUpdateAuthMiddleware(cfg, webServer, http.HandlerFunc(apiRouter.ServeHTTP)))
|
||||
mux.Handle("/api/", webServer.RequireAuth(http.HandlerFunc(apiRouter.ServeHTTP)))
|
||||
|
||||
// Web UI routes (auth required)
|
||||
@@ -492,6 +567,22 @@ func main() {
|
||||
logger.Println("[INFO] felhom-controller stopped")
|
||||
}
|
||||
|
||||
// selfUpdateAuthMiddleware allows access via session auth (normal UI) OR hub API key bearer token (external).
|
||||
func selfUpdateAuthMiddleware(cfg *config.Config, webServer *web.Server, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Check bearer token first (for external API calls: hub, build scripts)
|
||||
if auth := r.Header.Get("Authorization"); strings.HasPrefix(auth, "Bearer ") {
|
||||
token := strings.TrimPrefix(auth, "Bearer ")
|
||||
if token != "" && cfg.Hub.APIKey != "" && token == cfg.Hub.APIKey {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
// Fall back to session auth
|
||||
webServer.RequireAuth(next).ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func setupLogger(cfg *config.Config) *log.Logger {
|
||||
// For now, log to stdout. File logging will be added later.
|
||||
logger := log.New(os.Stdout, "", log.LstdFlags)
|
||||
|
||||
Reference in New Issue
Block a user