diff --git a/CHANGELOG.md b/CHANGELOG.md index 1665d96..6f2d26b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ ## Changelog +### What was just completed (2026-02-19 session 58) +- **v0.16.0 — Controller Self-Update:** + + Watchtower-style self-update mechanism. New package `internal/selfupdate/` with 3 files: `version.go` (semver parsing/comparison), `state.go` (audit log state file I/O), `updater.go` (registry check via Gitea V2 API, update trigger, startup verification). + + **Flow:** Gitea registry tag list → `docker pull` → atomic compose file rewrite → `docker compose up -d` → process replaced. State file (`update-state.json`) persists across restart as audit log; verified on next startup to detect success/failure. + + **Config:** `SelfUpdateConfig` extended with `AutoUpdateTime` field + defaults for `Image` and `AutoUpdateTime`. Scheduler jobs: periodic check every `check_interval` (default 6h); optional daily auto-update at `auto_update_time` (default 04:30). + + **API:** 3 new endpoints under `/api/selfupdate/` (`status`, `check`, `update`). Auth via session cookie OR `Authorization: Bearer ` header (for external triggering from build scripts). + + **UI:** Settings page "Verzió és frissítés" card shows current/latest version, check time, auto-update status, last update result. "Frissítés keresése" button queries registry; "Frissítés telepítése" button appears when update is available. `pollUntilBack()` JS polls `/api/health` after triggering update and reloads when container is back up. + + **Notifications:** `NotifyUpdateSuccess()` and `NotifyUpdateFailed()` added to notifier for post-update startup verification results. + + **Alert:** Dashboard shows "Új controller verzió elérhető" info alert when update is available. + + **docker-compose.yml:** Added `/opt/docker/felhom-controller:/opt/docker/felhom-controller` directory bind mount (required for compose file access during self-update); named volume and read-only config override on top. + + **Files modified/created (12):** `internal/selfupdate/version.go` (NEW), `internal/selfupdate/state.go` (NEW), `internal/selfupdate/updater.go` (NEW), `internal/config/config.go`, `internal/notify/notifier.go`, `internal/api/router.go`, `internal/web/server.go`, `internal/web/handlers.go`, `internal/web/alerts.go`, `internal/web/templates/settings.html`, `cmd/controller/main.go`, `docker-compose.yml` + ### What was just completed (2026-02-19 session 57) - **v0.15.7 — Fix backup page storage display & rename system drive label:** diff --git a/controller/README.md b/controller/README.md index 62f557e..95a0ef0 100644 --- a/controller/README.md +++ b/controller/README.md @@ -4,7 +4,7 @@ A single, lightweight Go container that replaces Portainer + scattered systemd scripts with a unified, Hungarian-language web dashboard for managing Docker Compose stacks, backups, storage, monitoring, and notifications on customer hardware. -**Current version: v0.15.5** +**Current version: v0.16.0** --- @@ -88,6 +88,7 @@ A single, lightweight Go container that replaces Portainer + scattered systemd s | **Monitor** | `internal/monitor/` | Healthchecks.io pinger, system health checks | | **Metrics** | `internal/metrics/` | SQLite time-series store, system + container metric collection | | **Scheduler** | `internal/scheduler/` | Central job scheduler (periodic + daily, skip-if-running, panic recovery) | +| **SelfUpdate** | `internal/selfupdate/` | Version checking (registry), update trigger, state persistence, startup verification | | **Notify** | `internal/notify/` | Email notifications via hub relay, preference sync, per-event cooldowns | | **Report** | `internal/report/` | Hub report builder + HTTP pusher (system, stacks, backup, health) | | **API** | `internal/api/` | REST JSON endpoints | @@ -542,6 +543,134 @@ Notification preferences (email, enabled events, cooldown) are: | `UPDATE_REQUIRED=true` | Mandatory — auto-applied during next update window | | `UPDATE_SECURITY=true` | Critical — applied immediately | +#### Controller Self-Update (`internal/selfupdate/`) + +The controller can update itself — a Watchtower-style pull-and-restart mechanism for a single container. Replaces manual SSH-based `docker pull + sed + docker compose up -d` with a one-click Settings page button or scheduled auto-update. + +##### How It Works + +``` +1. Check Gitea Docker Registry V2 API for new image tags +2. Compare highest semver tag with current Version (set at build time via ldflags) +3. If newer version exists → pull image → update compose file → docker compose up -d +4. Current container is replaced by Docker → new container starts with new version +5. On startup, new container reads update-state.json → marks update success/failure +``` + +##### Design Philosophy + +- **No automatic rollback** — follows the Watchtower pattern (24k+ GitHub stars, no rollback). Docker's `restart: unless-stopped` policy is the crash safety net. Healthchecks.io detects when the controller goes down. +- **Audit state file** — `update-state.json` in the data volume records every update attempt (previous version, target version, initiator, result). Operators can SSH in and revert using `PreviousImage` from this file. +- **Backup-aware** — refuses to start an update while a backup is in progress (`backupRunning()` guard). + +##### Package Structure + +| File | Purpose | +|------|---------| +| `version.go` | `ParseVersion("X.Y.Z")` → `Version{Major,Minor,Patch}`, `Compare()` returns -1/0/1. Hand-rolled, no external deps. Rejects "dev" and "latest". | +| `state.go` | `UpdateState` struct persisted as JSON. `LoadState()`, `SaveState()` (atomic: `.tmp` + rename), `ClearState()`. Status values: `"pending"`, `"success"`, `"failed"`. | +| `updater.go` | Core `Updater` struct. Registry check via HTTP GET to `gitea.dooplex.hu/v2/admin/felhom-controller/tags/list` with Basic Auth (git username/token). Update trigger: `docker pull` → compose file regex replace → `docker compose up -d`. Thread-safe with `sync.Mutex`. | + +##### Update Trigger Flow + +1. **Guard checks:** concurrent update lock, dev version check, backup running check, compose file accessible +2. Write `update-state.json` with status `"pending"` (audit trail) +3. `docker pull :` +4. Read compose file → replace image tag via regexp → atomic write (`.tmp` + rename) +5. `docker compose -f /opt/docker/felhom-controller/docker-compose.yml -p felhom-controller up -d` +6. Docker kills the current container, starts the new one + +##### Startup Verification + +Called once from `main.go` before the scheduler starts: +1. Load `update-state.json` — if missing or status != `"pending"`, nothing to do +2. Compare running `Version` with `state.TargetVersion` +3. **Match** → mark `"success"`, notify via hub +4. **Mismatch** → mark `"failed"`, notify via hub +5. No rollback attempt — operator reverts manually if needed + +##### Auto-Update Scheduling + +Two separate scheduler jobs prevent interference with backups: + +| Job | Type | Default | Purpose | +|-----|------|---------|---------| +| `selfupdate-check` | `sched.Every` | 6h | Check registry, cache result (for UI). Never triggers update. | +| `selfupdate-auto` | `sched.Daily` | 04:30 | If auto-update enabled + update available + backup not running → trigger. | + +The auto-update time (`config.SelfUpdate.AutoUpdateTime`, default `"04:30"`) is deliberately separate from the backup window (02:30-~04:00) to avoid collisions. The `backupRunning()` guard is the hard safety check — if backups run long past 04:30, the update is skipped and retried the next day. + +An initial version check fires 30s after startup so the Settings page shows version info quickly. + +##### Compose File Access + +The controller needs write access to its own `docker-compose.yml`. This is achieved via Docker volume mount ordering: + +```yaml +volumes: + # 1. Directory mount — gives access to compose file + .env + - /opt/docker/felhom-controller:/opt/docker/felhom-controller + # 2. Read-only override — prevents accidental config writes + - /opt/docker/felhom-controller/controller.yaml:/opt/docker/felhom-controller/controller.yaml:ro + # 3. Named volume override — persistent data in Docker-managed volume + - controller-data:/opt/docker/felhom-controller/data +``` + +##### API Endpoints + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/api/selfupdate/status` | Session or API key | Current status (cached, no network call) | +| POST | `/api/selfupdate/check` | Session or API key | Force registry check, return result | +| POST | `/api/selfupdate/update` | Session or API key | Trigger update (async, returns immediately) | + +Self-update endpoints accept either session auth (for UI) or hub API key as bearer token (for external triggering from build scripts or hub). This enables the post-v0.16.0 deploy workflow: + +```bash +# After building + pushing new image: +curl -s -X POST https://felhom.demo-felhom.eu/api/selfupdate/update \ + -H "Authorization: Bearer " +``` + +##### Settings Page UI + +The "Verzió és frissítés" card on the Settings page (`/settings`) shows: +- Current version and latest available version +- "Frissítés elérhető" (update available) badge +- Last check time and any errors +- Auto-update status with configured time +- Last update result (success/failed/pending) +- **Buttons:** "Frissítés keresése" (check) + "Frissítés telepítése" (apply) + +After triggering an update, the page polls `/api/health` every 3s and reloads when the new container responds. + +A global info-level alert ("Új controller verzió elérhető") appears on all pages when an update is available, linking to the Settings page. + +##### Configuration + +```yaml +self_update: + enabled: true + check_interval: "6h" # How often to check registry + image: "gitea.dooplex.hu/admin/felhom-controller" # Default + auto_update: false # Set true for unattended updates + auto_update_time: "04:30" # When to auto-apply (after backups) + health_timeout_seconds: 60 # Reserved for future use +``` + +##### Edge Cases + +| Scenario | Behavior | +|----------|----------| +| `Version == "dev"` | `ParseVersion` returns error → no updates reported, trigger refused | +| Registry unreachable | Log warning, return error in check result. No crash. | +| No registry credentials | Return error "Registry hitelesítő adatok hiányoznak" | +| Compose file not writable | Refuse update before doing anything | +| Backup running | Refuse with "Mentés fut, próbálja később" | +| Concurrent update | Mutex prevents duplicates: "Frissítés már folyamatban" | +| Bad update (crash loop) | Docker restarts container. State file stays "pending". Operator SSH-reverts using `PreviousImage`. | +| Corrupt state file | Treated as "no pending update", logged, deleted | + --- ### 7. Authentication & Settings @@ -572,11 +701,12 @@ All public methods use `sync.RWMutex`. File writes are atomic (`.tmp` + rename). #### Settings Page (`/settings`) -Three sections: +Five sections: 1. **System config** — read-only display of `controller.yaml` values -2. **Password change** — current + new + confirm, min 8 chars +2. **Version & update** — current/latest version, check/update buttons, auto-update status, last update result 3. **Storage paths** — add/remove, edit labels, set default, toggle schedulable, per-path app list with sizes -4. **Notifications** — email, event checkboxes, cooldown hours, test email button +4. **Password change** — current + new + confirm, min 8 chars +5. **Notifications** — email, event checkboxes, cooldown hours, test email button --- @@ -683,6 +813,10 @@ controller/ │ │ ├── store.go # SQLite time-series (WAL mode, downsampled queries) │ │ ├── collector.go # Background collector (60s, system + docker stats) │ │ └── sysinfo.go # Static system info (/proc, /etc) +│ ├── selfupdate/ +│ │ ├── version.go # Semver parsing + comparison (hand-rolled) +│ │ ├── state.go # Update audit state (JSON, atomic writes) +│ │ └── updater.go # Registry check, update trigger, startup verify │ ├── notify/notifier.go # Email relay to hub, preference sync, cooldowns │ ├── report/ │ │ ├── builder.go # Hub report builder (all subsystems → JSON) @@ -784,6 +918,8 @@ Auto-generated during deployment. Contains env vars, locked fields list, deploy | backup | daily | 03:00 | Restic backup → cross-drive chain | | backup-integrity | daily | Sun 04:00 | Restic check | | metrics-prune | daily | 04:00 | Delete metrics older than 30 days | +| selfupdate-check | periodic | 6h | Check registry for new version (cache for UI) | +| selfupdate-auto | daily | 04:30 | Auto-update if enabled + backup not running | All daily jobs use Europe/Budapest timezone. Skip-if-running prevents concurrent execution. Panic recovery in all jobs. @@ -838,6 +974,16 @@ All daily jobs use Europe/Budapest timezone. Skip-if-running prevents concurrent | POST | `/api/storage/migrate` | Start app data migration | | GET | `/api/storage/migrate/status` | Migration progress | +### Self-Update + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/selfupdate/status` | Update status (cached check result + last state) | +| POST | `/api/selfupdate/check` | Force registry check | +| POST | `/api/selfupdate/update` | Trigger self-update (async) | + +Self-update endpoints accept session auth OR `Authorization: Bearer ` for external triggering. + ### Metrics | Method | Endpoint | Description | @@ -864,11 +1010,24 @@ git -C ~/git/deploy-felhom-compose pull ### Deploy on customer node +**Option A: Self-Update API (v0.16.0+)** + +After building and pushing the new image, trigger the controller's self-update endpoint: + +```bash +curl -s -X POST https://felhom.demo-felhom.eu/api/selfupdate/update \ + -H "Authorization: Bearer " +``` + +The controller pulls the new image, updates its own compose file, and runs `docker compose up -d` to replace itself. The Settings page also has a "Frissítés telepítése" button for manual triggering. + +**Option B: Manual SSH (pre-v0.16.0 or fallback)** + ```bash # On customer node (e.g., 192.168.0.162) cd /opt/docker/felhom-controller -sudo docker pull gitea.dooplex.hu/admin/felhom-controller:v0.14.1 -sudo sed -i 's|image: gitea.dooplex.hu/admin/felhom-controller:.*|image: gitea.dooplex.hu/admin/felhom-controller:v0.14.1|' docker-compose.yml +sudo docker pull gitea.dooplex.hu/admin/felhom-controller: +sudo sed -i 's|image: gitea.dooplex.hu/admin/felhom-controller:.*|image: gitea.dooplex.hu/admin/felhom-controller:|' docker-compose.yml sudo docker compose up -d ``` @@ -910,11 +1069,11 @@ See `docker-compose.yml` for the full volume configuration. - [x] Auto Tier 2 for small apps (v0.14.1) — auto-enable daily rsync for non-HDD apps when ≥2 drives - [x] Infrastructure config in cross-drive backup (v0.14.1) — stacks dir + controller.yaml in `_infra/` + restic - [x] Disaster recovery (v0.15.5) — Hub-based infra backup, auto-mount by UUID, restore UI with full-page takeover +- [x] Controller self-update (v0.16.0) — Watchtower-style pull + restart, Settings page UI, API key auth, auto-update scheduling ### In Progress / Planned - [ ] Update classification and auto-apply (optional/required/security markers) -- [ ] Self-update mechanism with health-based rollback - [ ] Docker volume backup (`/var/lib/docker/volumes:ro`) - [ ] Raspberry Pi testing (pi-customer-1) - [ ] CSRF protection on POST endpoints @@ -926,7 +1085,7 @@ See `docker-compose.yml` for the full volume configuration. | Node | Hardware | Domain | Status | |------|----------|--------|--------| -| demo-felhom | Acemagic GK3PLUS N100, 16G RAM, 512G SSD + 1TB HDD | demo-felhom.eu | Controller v0.15.5 | +| demo-felhom | Acemagic GK3PLUS N100, 16G RAM, 512G SSD + 1TB HDD | demo-felhom.eu | Controller v0.16.0 | | pi-customer-1 | Raspberry Pi 3B+, 1G RAM, 32G SD | pi-customer-1.local | Not yet tested | ## Related Repositories diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index a3d0ab2..55a0143 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -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) diff --git a/controller/docker-compose.yml b/controller/docker-compose.yml index c6579a8..3d4a756 100644 --- a/controller/docker-compose.yml +++ b/controller/docker-compose.yml @@ -14,9 +14,11 @@ services: volumes: # Docker socket — required for compose operations + DB dumps (docker exec) - /var/run/docker.sock:/var/run/docker.sock - # Controller config + # Controller directory (compose file access for self-update) + - /opt/docker/felhom-controller:/opt/docker/felhom-controller + # Controller config (read-only override on top of directory mount) - /opt/docker/felhom-controller/controller.yaml:/opt/docker/felhom-controller/controller.yaml:ro - # Controller persistent data (sessions, restic cache, restic password) + # Controller persistent data (named volume override on top of directory mount) - controller-data:/opt/docker/felhom-controller/data # Stack compose files (read + write for git sync) - /opt/docker/stacks:/opt/docker/stacks diff --git a/controller/internal/api/router.go b/controller/internal/api/router.go index 6c7d88d..3b8c263 100644 --- a/controller/internal/api/router.go +++ b/controller/internal/api/router.go @@ -13,6 +13,7 @@ import ( "gitea.dooplex.hu/admin/felhom-controller/internal/backup" "gitea.dooplex.hu/admin/felhom-controller/internal/config" "gitea.dooplex.hu/admin/felhom-controller/internal/metrics" + "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" @@ -29,11 +30,12 @@ type Router struct { backupMgr *backup.Manager crossDriveRunner *backup.CrossDriveRunner metricsStore *metrics.MetricsStore + updater *selfupdate.Updater logger *log.Logger } -func NewRouter(cfg *config.Config, sett *settings.Settings, stackMgr *stacks.Manager, syncer *catalogsync.Syncer, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, crossDrive *backup.CrossDriveRunner, metricsStore *metrics.MetricsStore, logger *log.Logger) *Router { - return &Router{cfg: cfg, sett: sett, stackMgr: stackMgr, syncer: syncer, cpuCollector: cpuCollector, backupMgr: backupMgr, crossDriveRunner: crossDrive, metricsStore: metricsStore, logger: logger} +func NewRouter(cfg *config.Config, sett *settings.Settings, stackMgr *stacks.Manager, syncer *catalogsync.Syncer, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, crossDrive *backup.CrossDriveRunner, metricsStore *metrics.MetricsStore, updater *selfupdate.Updater, logger *log.Logger) *Router { + return &Router{cfg: cfg, sett: sett, stackMgr: stackMgr, syncer: syncer, cpuCollector: cpuCollector, backupMgr: backupMgr, crossDriveRunner: crossDrive, metricsStore: metricsStore, updater: updater, logger: logger} } type apiResponse struct { @@ -154,6 +156,18 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { case path == "/metrics/sysinfo" && req.Method == http.MethodGet: r.metricsSysInfo(w, req) + // GET /api/selfupdate/status + case path == "/selfupdate/status" && req.Method == http.MethodGet: + r.selfupdateStatus(w, req) + + // POST /api/selfupdate/check + case path == "/selfupdate/check" && req.Method == http.MethodPost: + r.selfupdateCheck(w, req) + + // POST /api/selfupdate/update + case path == "/selfupdate/update" && req.Method == http.MethodPost: + r.selfupdateTrigger(w, req) + default: writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: "endpoint not found"}) } @@ -779,6 +793,36 @@ func extractName(path, suffix string) string { return name } +func (r *Router) selfupdateStatus(w http.ResponseWriter, _ *http.Request) { + if r.updater == nil { + writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{"enabled": false}}) + return + } + writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: r.updater.GetStatus()}) +} + +func (r *Router) selfupdateCheck(w http.ResponseWriter, _ *http.Request) { + if r.updater == nil { + writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "Self-update not configured"}) + return + } + result := r.updater.CheckForUpdate() + writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: result}) +} + +func (r *Router) selfupdateTrigger(w http.ResponseWriter, _ *http.Request) { + if r.updater == nil { + writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "Self-update not configured"}) + return + } + if err := r.updater.TriggerUpdate("manual"); err != nil { + writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: err.Error()}) + return + } + r.logger.Println("[API] Manual self-update triggered") + writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Frissítés elindítva"}) +} + func writeJSON(w http.ResponseWriter, status int, v interface{}) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) diff --git a/controller/internal/config/config.go b/controller/internal/config/config.go index 8b2b0bf..2b714d3 100644 --- a/controller/internal/config/config.go +++ b/controller/internal/config/config.go @@ -118,6 +118,7 @@ type SelfUpdateConfig struct { CheckInterval string `yaml:"check_interval"` Image string `yaml:"image"` AutoUpdate bool `yaml:"auto_update"` + AutoUpdateTime string `yaml:"auto_update_time"` HealthTimeoutSeconds int `yaml:"health_timeout_seconds"` } @@ -206,6 +207,8 @@ func applyDefaults(cfg *Config) { di(&cfg.Monitoring.Thresholds.TemperatureWarnCelsius, 75) d(&cfg.Hub.PushInterval, "15m") d(&cfg.SelfUpdate.CheckInterval, "6h") + d(&cfg.SelfUpdate.Image, "gitea.dooplex.hu/admin/felhom-controller") + d(&cfg.SelfUpdate.AutoUpdateTime, "04:30") di(&cfg.SelfUpdate.HealthTimeoutSeconds, 60) d(&cfg.Logging.Level, "info") di(&cfg.Logging.MaxSizeMB, 10) diff --git a/controller/internal/notify/notifier.go b/controller/internal/notify/notifier.go index caaef8a..689e9b7 100644 --- a/controller/internal/notify/notifier.go +++ b/controller/internal/notify/notifier.go @@ -242,6 +242,18 @@ func (n *Notifier) NotifyIntegrityFailed(message, details string) { n.Notify("integrity_failed", "warning", message, details) } +// NotifyUpdateSuccess sends a notification about a successful controller update. +func (n *Notifier) NotifyUpdateSuccess(fromVer, toVer string) { + n.Notify("update_success", "info", + fmt.Sprintf("Controller frissítve: %s → %s", fromVer, toVer), "") +} + +// NotifyUpdateFailed sends a notification about a failed controller update. +func (n *Notifier) NotifyUpdateFailed(targetVer, errMsg string) { + n.Notify("update_failed", "warning", + fmt.Sprintf("Controller frissítés sikertelen: %s — %s", targetVer, errMsg), "") +} + // SendTest sends a test notification for verifying the notification flow. func (n *Notifier) SendTest() error { if !n.enabled { diff --git a/controller/internal/selfupdate/state.go b/controller/internal/selfupdate/state.go new file mode 100644 index 0000000..a2efbf1 --- /dev/null +++ b/controller/internal/selfupdate/state.go @@ -0,0 +1,71 @@ +package selfupdate + +import ( + "encoding/json" + "fmt" + "log" + "os" + "path/filepath" +) + +const stateFileName = "update-state.json" + +// UpdateState tracks the last update attempt. Persisted to disk as audit log. +type UpdateState struct { + Status string `json:"status"` // "pending", "success", "failed" + PreviousVersion string `json:"previous_version"` + PreviousImage string `json:"previous_image"` + TargetVersion string `json:"target_version"` + TargetImage string `json:"target_image"` + InitiatedAt string `json:"initiated_at"` // RFC3339 + InitiatedBy string `json:"initiated_by"` // "manual" or "auto" + CompletedAt string `json:"completed_at,omitempty"` + Error string `json:"error,omitempty"` +} + +// LoadState reads the update state file. Returns nil, nil if file doesn't exist. +func LoadState(dataDir string) (*UpdateState, error) { + path := filepath.Join(dataDir, stateFileName) + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("reading state file: %w", err) + } + + var state UpdateState + if err := json.Unmarshal(data, &state); err != nil { + return nil, fmt.Errorf("parsing state file: %w", err) + } + return &state, nil +} + +// SaveState writes the state file atomically (write to .tmp, then rename). +func SaveState(dataDir string, state *UpdateState) error { + path := filepath.Join(dataDir, stateFileName) + tmpPath := path + ".tmp" + + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return fmt.Errorf("marshaling state: %w", err) + } + + if err := os.WriteFile(tmpPath, data, 0644); err != nil { + return fmt.Errorf("writing temp state file: %w", err) + } + + if err := os.Rename(tmpPath, path); err != nil { + return fmt.Errorf("renaming state file: %w", err) + } + + return nil +} + +// ClearState removes the state file. Used for cleanup. +func ClearState(dataDir string, logger *log.Logger) { + path := filepath.Join(dataDir, stateFileName) + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + logger.Printf("[WARN] Failed to clear update state file: %v", err) + } +} diff --git a/controller/internal/selfupdate/updater.go b/controller/internal/selfupdate/updater.go new file mode 100644 index 0000000..2948480 --- /dev/null +++ b/controller/internal/selfupdate/updater.go @@ -0,0 +1,417 @@ +package selfupdate + +import ( + "bytes" + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "os/exec" + "regexp" + "strings" + "sync" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/config" +) + +// CheckResult holds the result of a version check. +type CheckResult struct { + CurrentVersion string `json:"current_version"` + LatestVersion string `json:"latest_version"` + UpdateAvailable bool `json:"update_available"` + Error string `json:"error,omitempty"` + CheckedAt string `json:"checked_at"` +} + +// UpdateStatus is the complete status returned by the API. +type UpdateStatus struct { + Running bool `json:"running"` + LastCheck *CheckResult `json:"last_check,omitempty"` + LastState *UpdateState `json:"last_state,omitempty"` +} + +// Updater manages controller self-updates. +type Updater struct { + cfg *config.SelfUpdateConfig + gitCfg *config.GitConfig + currentVer string + dataDir string + composePath string // e.g., "/opt/docker/felhom-controller/docker-compose.yml" + logger *log.Logger + + mu sync.Mutex + latestVersion string + lastCheck *CheckResult + updateRunning bool + backupRunning func() bool +} + +// NewUpdater creates a new Updater instance. +func NewUpdater(cfg *config.SelfUpdateConfig, gitCfg *config.GitConfig, currentVersion, dataDir, composePath string, logger *log.Logger) *Updater { + return &Updater{ + cfg: cfg, + gitCfg: gitCfg, + currentVer: currentVersion, + dataDir: dataDir, + composePath: composePath, + logger: logger, + } +} + +// SetBackupRunningCheck sets the callback to check if a backup is in progress. +func (u *Updater) SetBackupRunningCheck(fn func() bool) { + u.backupRunning = fn +} + +// IsUpdateRunning returns true if an update is currently in progress. +func (u *Updater) IsUpdateRunning() bool { + u.mu.Lock() + defer u.mu.Unlock() + return u.updateRunning +} + +// GetStatus returns the current update status for API/UI. +func (u *Updater) GetStatus() UpdateStatus { + u.mu.Lock() + lastCheck := u.lastCheck + running := u.updateRunning + u.mu.Unlock() + + state, err := LoadState(u.dataDir) + if err != nil { + u.logger.Printf("[WARN] Failed to load update state: %v", err) + } + + return UpdateStatus{ + Running: running, + LastCheck: lastCheck, + LastState: state, + } +} + +// CheckForUpdate queries the Gitea registry for the latest version tag. +// Caches the result. Thread-safe. +func (u *Updater) CheckForUpdate() CheckResult { + result := CheckResult{ + CurrentVersion: u.currentVer, + CheckedAt: time.Now().UTC().Format(time.RFC3339), + } + + // Dev version can't check for updates + currentVer, err := ParseVersion(u.currentVer) + if err != nil { + result.Error = "Dev verzió nem ellenőrizhető" + u.mu.Lock() + u.lastCheck = &result + u.mu.Unlock() + return result + } + + // Query registry + latestStr, err := u.queryRegistry() + if err != nil { + result.Error = fmt.Sprintf("Registry lekérdezés sikertelen: %v", err) + u.logger.Printf("[WARN] Registry check failed: %v", err) + u.mu.Lock() + u.lastCheck = &result + u.mu.Unlock() + return result + } + + result.LatestVersion = latestStr + + latestVer, err := ParseVersion(latestStr) + if err != nil { + result.Error = fmt.Sprintf("Érvénytelen verzió a registry-ben: %s", latestStr) + u.mu.Lock() + u.lastCheck = &result + u.mu.Unlock() + return result + } + + if latestVer.Compare(currentVer) > 0 { + result.UpdateAvailable = true + } + + u.mu.Lock() + u.latestVersion = latestStr + u.lastCheck = &result + u.mu.Unlock() + + return result +} + +// queryRegistry queries the Gitea Docker Registry V2 API for available tags. +// Returns the highest valid semver tag found. +func (u *Updater) queryRegistry() (string, error) { + if u.gitCfg.Username == "" || u.gitCfg.Token == "" { + return "", fmt.Errorf("registry hitelesítő adatok hiányoznak") + } + + // Gitea registry V2: GET /v2///tags/list + url := fmt.Sprintf("https://gitea.dooplex.hu/v2/%s/tags/list", registryImagePath(u.cfg.Image)) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return "", fmt.Errorf("creating request: %w", err) + } + req.SetBasicAuth(u.gitCfg.Username, u.gitCfg.Token) + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == 401 { + return "", fmt.Errorf("authentication failed (401)") + } + if resp.StatusCode != 200 { + return "", fmt.Errorf("unexpected status: %d", resp.StatusCode) + } + + var tagsResp struct { + Name string `json:"name"` + Tags []string `json:"tags"` + } + if err := json.NewDecoder(resp.Body).Decode(&tagsResp); err != nil { + return "", fmt.Errorf("decoding response: %w", err) + } + + // Find highest semver tag + var highest *Version + for _, tag := range tagsResp.Tags { + v, err := ParseVersion(tag) + if err != nil { + continue // skip non-semver tags ("latest", "dev", etc.) + } + if highest == nil || v.Compare(*highest) > 0 { + highest = &v + } + } + + if highest == nil { + return "", fmt.Errorf("no valid semver tags found") + } + + return highest.String(), nil +} + +// registryImagePath extracts the "owner/repo" from a full image reference. +// e.g., "gitea.dooplex.hu/admin/felhom-controller" → "admin/felhom-controller" +func registryImagePath(image string) string { + // Remove registry host + parts := strings.SplitN(image, "/", 2) + if len(parts) == 2 { + return parts[1] + } + return image +} + +// imageName extracts the repo name from a full image reference. +// e.g., "gitea.dooplex.hu/admin/felhom-controller" → "felhom-controller" +func imageName(image string) string { + parts := strings.Split(image, "/") + return parts[len(parts)-1] +} + +// TriggerUpdate starts the self-update process. Returns error immediately if +// preconditions fail. The actual update runs in a goroutine. +func (u *Updater) TriggerUpdate(initiatedBy string) error { + u.mu.Lock() + if u.updateRunning { + u.mu.Unlock() + return fmt.Errorf("Frissítés már folyamatban") + } + + // Dev version check + if _, err := ParseVersion(u.currentVer); err != nil { + u.mu.Unlock() + return fmt.Errorf("Dev verzió nem frissíthető") + } + + // Backup running check + if u.backupRunning != nil && u.backupRunning() { + u.mu.Unlock() + return fmt.Errorf("Mentés fut, próbálja később") + } + + // Compose file accessible check + if _, err := os.Stat(u.composePath); err != nil { + u.mu.Unlock() + return fmt.Errorf("docker-compose.yml nem elérhető: %w", err) + } + + u.updateRunning = true + u.mu.Unlock() + + // Check for update (or use cached) + result := u.CheckForUpdate() + if !result.UpdateAvailable { + u.mu.Lock() + u.updateRunning = false + u.mu.Unlock() + return fmt.Errorf("Nincs elérhető frissítés") + } + + targetVersion := result.LatestVersion + targetImage := fmt.Sprintf("%s:%s", u.cfg.Image, targetVersion) + previousImage := fmt.Sprintf("%s:%s", u.cfg.Image, u.currentVer) + + u.logger.Printf("[INFO] Starting self-update: %s → %s (initiated by: %s)", u.currentVer, targetVersion, initiatedBy) + + go u.performUpdate(targetVersion, targetImage, previousImage, initiatedBy) + + return nil +} + +// performUpdate runs the actual update steps in a goroutine. +func (u *Updater) performUpdate(targetVersion, targetImage, previousImage, initiatedBy string) { + defer func() { + u.mu.Lock() + u.updateRunning = false + u.mu.Unlock() + }() + + // 1. Write pending state + state := &UpdateState{ + Status: "pending", + PreviousVersion: u.currentVer, + PreviousImage: previousImage, + TargetVersion: targetVersion, + TargetImage: targetImage, + InitiatedAt: time.Now().UTC().Format(time.RFC3339), + InitiatedBy: initiatedBy, + } + if err := SaveState(u.dataDir, state); err != nil { + u.logger.Printf("[ERROR] Failed to save update state: %v", err) + return + } + + // 2. Docker pull + u.logger.Printf("[INFO] Pulling image: %s", targetImage) + pullOut, pullErr := runCommand("docker", "pull", targetImage) + if pullErr != nil { + state.Status = "failed" + state.Error = fmt.Sprintf("docker pull failed: %v — %s", pullErr, pullOut) + state.CompletedAt = time.Now().UTC().Format(time.RFC3339) + SaveState(u.dataDir, state) + u.logger.Printf("[ERROR] Docker pull failed: %v — %s", pullErr, pullOut) + return + } + u.logger.Printf("[INFO] Image pulled successfully: %s", targetImage) + + // 3. Update compose file (replace image tag) + if err := u.updateComposeFile(targetImage); err != nil { + state.Status = "failed" + state.Error = fmt.Sprintf("compose update failed: %v", err) + state.CompletedAt = time.Now().UTC().Format(time.RFC3339) + SaveState(u.dataDir, state) + u.logger.Printf("[ERROR] Compose file update failed: %v", err) + return + } + u.logger.Printf("[INFO] Compose file updated with new image: %s", targetImage) + + // 4. Docker compose up -d (this kills the current container) + u.logger.Printf("[INFO] Running docker compose up -d — container will restart") + composeDir := strings.TrimSuffix(u.composePath, "/docker-compose.yml") + upOut, upErr := runCommand("docker", "compose", "-f", u.composePath, "-p", "felhom-controller", "up", "-d") + if upErr != nil { + // If we get here, compose up failed but we already changed the image tag. + // Log the error — the state file remains "pending" for manual investigation. + u.logger.Printf("[ERROR] docker compose up -d failed: %v — %s (dir: %s)", upErr, upOut, composeDir) + return + } + + // If we're still alive after compose up -d, log it. + // Normally this process should be killed when Docker replaces the container. + u.logger.Printf("[WARN] Still running after docker compose up -d — expected to be replaced") + time.Sleep(30 * time.Second) + u.logger.Printf("[WARN] Still alive 30s after docker compose up -d") +} + +// updateComposeFile reads the compose file, replaces the image tag, and writes it back atomically. +func (u *Updater) updateComposeFile(newImage string) error { + data, err := os.ReadFile(u.composePath) + if err != nil { + return fmt.Errorf("reading compose file: %w", err) + } + + // Replace image line: "image: gitea.dooplex.hu/admin/felhom-controller:..." → new image + re := regexp.MustCompile(`(image:\s*)gitea\.dooplex\.hu/admin/felhom-controller:\S+`) + newData := re.ReplaceAll(data, []byte("${1}"+newImage)) + + if bytes.Equal(data, newData) { + return fmt.Errorf("no image line found to replace in compose file") + } + + // Atomic write: write to .tmp, then rename + tmpPath := u.composePath + ".tmp" + if err := os.WriteFile(tmpPath, newData, 0644); err != nil { + return fmt.Errorf("writing temp compose file: %w", err) + } + if err := os.Rename(tmpPath, u.composePath); err != nil { + return fmt.Errorf("renaming compose file: %w", err) + } + + return nil +} + +// VerifyStartup checks the update state file on startup. +// Called once from main.go before the scheduler starts. +// Returns the state if a pending update was detected, nil otherwise. +func (u *Updater) VerifyStartup() *UpdateState { + state, err := LoadState(u.dataDir) + if err != nil { + u.logger.Printf("[WARN] Failed to load update state on startup: %v — clearing", err) + ClearState(u.dataDir, u.logger) + return nil + } + if state == nil || state.Status != "pending" { + return nil + } + + // Compare current version with target + currentVer, curErr := ParseVersion(u.currentVer) + targetVer, tgtErr := ParseVersion(state.TargetVersion) + + if curErr != nil || tgtErr != nil { + state.Status = "failed" + state.Error = "Version parse error on startup verification" + state.CompletedAt = time.Now().UTC().Format(time.RFC3339) + SaveState(u.dataDir, state) + u.logger.Printf("[WARN] Post-update startup: version parse error (current=%s, target=%s)", u.currentVer, state.TargetVersion) + return state + } + + if currentVer.Compare(targetVer) == 0 { + // Success — we're running the target version + state.Status = "success" + state.CompletedAt = time.Now().UTC().Format(time.RFC3339) + SaveState(u.dataDir, state) + u.logger.Printf("[INFO] Post-update startup: update successful (%s → %s)", state.PreviousVersion, state.TargetVersion) + } else { + // Version mismatch — update may have failed + state.Status = "failed" + state.Error = fmt.Sprintf("Version mismatch: expected %s, running %s", state.TargetVersion, u.currentVer) + state.CompletedAt = time.Now().UTC().Format(time.RFC3339) + SaveState(u.dataDir, state) + u.logger.Printf("[WARN] Post-update startup: version mismatch (expected %s, running %s)", state.TargetVersion, u.currentVer) + } + + return state +} + +// runCommand executes a command and returns combined stdout+stderr and error. +func runCommand(name string, args ...string) (string, error) { + cmd := exec.Command(name, args...) + var out bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &out + err := cmd.Run() + return out.String(), err +} + diff --git a/controller/internal/selfupdate/version.go b/controller/internal/selfupdate/version.go new file mode 100644 index 0000000..b61fb0b --- /dev/null +++ b/controller/internal/selfupdate/version.go @@ -0,0 +1,68 @@ +package selfupdate + +import ( + "fmt" + "strconv" + "strings" +) + +// Version represents a semantic version (Major.Minor.Patch). +type Version struct { + Major int + Minor int + Patch int + Raw string +} + +// ParseVersion parses "X.Y.Z" or "vX.Y.Z". Returns error for "dev", "latest", or invalid formats. +func ParseVersion(s string) (Version, error) { + s = strings.TrimPrefix(s, "v") + if s == "dev" || s == "latest" || s == "" { + return Version{}, fmt.Errorf("invalid version: %q", s) + } + parts := strings.SplitN(s, ".", 3) + if len(parts) != 3 { + return Version{}, fmt.Errorf("invalid version format: %q (expected X.Y.Z)", s) + } + major, err := strconv.Atoi(parts[0]) + if err != nil { + return Version{}, fmt.Errorf("invalid major version: %w", err) + } + minor, err := strconv.Atoi(parts[1]) + if err != nil { + return Version{}, fmt.Errorf("invalid minor version: %w", err) + } + patch, err := strconv.Atoi(parts[2]) + if err != nil { + return Version{}, fmt.Errorf("invalid patch version: %w", err) + } + return Version{Major: major, Minor: minor, Patch: patch, Raw: s}, nil +} + +// Compare returns -1 if a < b, 0 if a == b, 1 if a > b. +func (a Version) Compare(b Version) int { + if a.Major != b.Major { + if a.Major < b.Major { + return -1 + } + return 1 + } + if a.Minor != b.Minor { + if a.Minor < b.Minor { + return -1 + } + return 1 + } + if a.Patch != b.Patch { + if a.Patch < b.Patch { + return -1 + } + return 1 + } + return 0 +} + +// String returns the version as "X.Y.Z". +func (v Version) String() string { + return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch) +} diff --git a/controller/internal/web/alerts.go b/controller/internal/web/alerts.go index 48b632e..44ffe3d 100644 --- a/controller/internal/web/alerts.go +++ b/controller/internal/web/alerts.go @@ -40,7 +40,7 @@ func NewAlertManager(logger *log.Logger) *AlertManager { // Refresh regenerates alerts from the latest health check report and config state. // Called after each health check cycle (every 5 minutes). -func (am *AlertManager) Refresh(report *monitor.HealthReport, cfg *config.Config, backupMgr *backup.Manager) { +func (am *AlertManager) Refresh(report *monitor.HealthReport, cfg *config.Config, backupMgr *backup.Manager, updateAvailable bool, latestVersion string) { var alerts []Alert // From health check issues (critical) @@ -97,6 +97,17 @@ func (am *AlertManager) Refresh(report *monitor.HealthReport, cfg *config.Config }) } + // Update available + if updateAvailable && latestVersion != "" { + alerts = append(alerts, Alert{ + ID: "update-available", + Level: "info", + Message: fmt.Sprintf("Új controller verzió elérhető: %s", latestVersion), + Link: "/settings", + LinkText: "Frissítés", + }) + } + // Sort: errors first, then warnings, then info sortAlerts(alerts) diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index e68371d..5aaa2cb 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -906,6 +906,25 @@ func (s *Server) settingsData() map[string]interface{} { data["MonitoringEnabled"] = s.cfg.Monitoring.Enabled data["HealthchecksBase"] = s.cfg.Monitoring.HealthchecksBase data["HubEnabled"] = s.cfg.Hub.Enabled + + // Self-update status + data["SelfUpdateEnabled"] = s.cfg.SelfUpdate.Enabled + if s.updater != nil { + status := s.updater.GetStatus() + data["UpdateRunning"] = status.Running + if status.LastCheck != nil { + data["UpdateAvailable"] = status.LastCheck.UpdateAvailable + data["LatestVersion"] = status.LastCheck.LatestVersion + data["LastCheckTime"] = status.LastCheck.CheckedAt + data["LastCheckError"] = status.LastCheck.Error + } + if status.LastState != nil { + data["LastUpdateState"] = status.LastState + } + data["AutoUpdateEnabled"] = s.cfg.SelfUpdate.AutoUpdate + data["AutoUpdateTime"] = s.cfg.SelfUpdate.AutoUpdateTime + } + data["NotificationPrefs"] = s.settings.GetNotificationPrefs() // Storage paths with display data diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go index 512db45..94bd2a7 100644 --- a/controller/internal/web/server.go +++ b/controller/internal/web/server.go @@ -14,6 +14,7 @@ import ( "gitea.dooplex.hu/admin/felhom-controller/internal/config" "gitea.dooplex.hu/admin/felhom-controller/internal/notify" "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" "gitea.dooplex.hu/admin/felhom-controller/internal/system" @@ -29,6 +30,7 @@ type Server struct { settings *settings.Settings alertManager *AlertManager notifier *notify.Notifier + updater *selfupdate.Updater logger *log.Logger version string tmpl *template.Template @@ -49,7 +51,7 @@ type Server struct { restorePlan *backup.RestorePlan } -func NewServer(cfg *config.Config, stackMgr *stacks.Manager, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, crossDrive *backup.CrossDriveRunner, sched *scheduler.Scheduler, sett *settings.Settings, alertMgr *AlertManager, notif *notify.Notifier, logger *log.Logger, version string) *Server { +func NewServer(cfg *config.Config, stackMgr *stacks.Manager, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, crossDrive *backup.CrossDriveRunner, sched *scheduler.Scheduler, sett *settings.Settings, alertMgr *AlertManager, notif *notify.Notifier, updater *selfupdate.Updater, logger *log.Logger, version string) *Server { s := &Server{ cfg: cfg, stackMgr: stackMgr, @@ -60,6 +62,7 @@ func NewServer(cfg *config.Config, stackMgr *stacks.Manager, cpuCollector *syste settings: sett, alertManager: alertMgr, notifier: notif, + updater: updater, logger: logger, version: version, sessions: make(map[string]*session), diff --git a/controller/internal/web/templates/settings.html b/controller/internal/web/templates/settings.html index b89cb9d..a8ed77d 100644 --- a/controller/internal/web/templates/settings.html +++ b/controller/internal/web/templates/settings.html @@ -56,13 +56,144 @@ Hub jelentés {{if .HubEnabled}}✅ Aktív{{else}}–{{end}} -
- Controller verzió - {{.Version}} -
+ +
+

Verzió és frissítés

+
+
+ Jelenlegi verzió + {{.Version}} +
+ {{if .SelfUpdateEnabled}} + {{if .LatestVersion}} +
+ Legújabb verzió + + {{.LatestVersion}} + {{if .UpdateAvailable}} + ● Frissítés elérhető + {{else}} + — naprakész + {{end}} + +
+ {{end}} + {{if .LastCheckTime}} +
+ Utolsó ellenőrzés + {{.LastCheckTime}} +
+ {{end}} + {{if .LastCheckError}} +
+ Hiba + {{.LastCheckError}} +
+ {{end}} +
+ Automatikus frissítés + + {{if .AutoUpdateEnabled}}✅ Aktív ({{.AutoUpdateTime}}){{else}}–{{end}} + +
+ {{with .LastUpdateState}} +
+ Utolsó frissítés + + {{if eq .Status "success"}}✅ Sikeres ({{.PreviousVersion}} → {{.TargetVersion}}) + {{else if eq .Status "failed"}}❌ Sikertelen — {{.Error}} + {{else if eq .Status "pending"}}⏳ Folyamatban + {{end}} + +
+ {{end}} +
+ + + + {{if .UpdateAvailable}} + + {{end}} + + +
+ {{end}} +
+
+ + +

Adattárolók