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:
@@ -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 <hub_api_key>` 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:**
|
||||
|
||||
|
||||
+167
-8
@@ -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 <image>:<targetVersion>`
|
||||
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 <HUB_API_KEY>"
|
||||
```
|
||||
|
||||
##### 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 <hub_api_key>` 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 <HUB_API_KEY>"
|
||||
```
|
||||
|
||||
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:<VERSION>
|
||||
sudo sed -i 's|image: gitea.dooplex.hu/admin/felhom-controller:.*|image: gitea.dooplex.hu/admin/felhom-controller:<VERSION>|' 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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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/<owner>/<repo>/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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -56,13 +56,144 @@
|
||||
<span class="settings-label">Hub jelentés</span>
|
||||
<span class="settings-value">{{if .HubEnabled}}<span class="state-text-green">✅ Aktív</span>{{else}}–{{end}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section: Version & Update -->
|
||||
<div class="settings-card">
|
||||
<h3>Verzió és frissítés</h3>
|
||||
<div class="settings-grid">
|
||||
<div class="settings-row">
|
||||
<span class="settings-label">Controller verzió</span>
|
||||
<span class="settings-label">Jelenlegi verzió</span>
|
||||
<span class="settings-value mono">{{.Version}}</span>
|
||||
</div>
|
||||
{{if .SelfUpdateEnabled}}
|
||||
{{if .LatestVersion}}
|
||||
<div class="settings-row">
|
||||
<span class="settings-label">Legújabb verzió</span>
|
||||
<span class="settings-value mono">
|
||||
{{.LatestVersion}}
|
||||
{{if .UpdateAvailable}}
|
||||
<span class="state-text-green" style="margin-left:0.5em;">● Frissítés elérhető</span>
|
||||
{{else}}
|
||||
<span style="margin-left:0.5em; color:#888;">— naprakész</span>
|
||||
{{end}}
|
||||
</span>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .LastCheckTime}}
|
||||
<div class="settings-row">
|
||||
<span class="settings-label">Utolsó ellenőrzés</span>
|
||||
<span class="settings-value mono">{{.LastCheckTime}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .LastCheckError}}
|
||||
<div class="settings-row">
|
||||
<span class="settings-label">Hiba</span>
|
||||
<span class="settings-value state-text-red">{{.LastCheckError}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="settings-row">
|
||||
<span class="settings-label">Automatikus frissítés</span>
|
||||
<span class="settings-value">
|
||||
{{if .AutoUpdateEnabled}}<span class="state-text-green">✅ Aktív</span> <span class="mono">({{.AutoUpdateTime}})</span>{{else}}–{{end}}
|
||||
</span>
|
||||
</div>
|
||||
{{with .LastUpdateState}}
|
||||
<div class="settings-row">
|
||||
<span class="settings-label">Utolsó frissítés</span>
|
||||
<span class="settings-value">
|
||||
{{if eq .Status "success"}}<span class="state-text-green">✅ Sikeres</span> ({{.PreviousVersion}} → {{.TargetVersion}})
|
||||
{{else if eq .Status "failed"}}<span class="state-text-red">❌ Sikertelen</span> — {{.Error}}
|
||||
{{else if eq .Status "pending"}}<span class="state-text-yellow">⏳ Folyamatban</span>
|
||||
{{end}}
|
||||
</span>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="settings-row" style="padding-top: 0.5em;">
|
||||
<span class="settings-label"></span>
|
||||
<span class="settings-value">
|
||||
<button class="btn btn-secondary btn-sm" id="btn-check-update" onclick="checkUpdate()">Frissítés keresése</button>
|
||||
{{if .UpdateAvailable}}
|
||||
<button class="btn btn-primary btn-sm" id="btn-trigger-update" onclick="triggerUpdate()" style="margin-left:0.5em;">Frissítés telepítése</button>
|
||||
{{end}}
|
||||
<span id="update-status-msg" style="margin-left:0.5em; display:none;"></span>
|
||||
</span>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function checkUpdate() {
|
||||
var btn = document.getElementById('btn-check-update');
|
||||
var msg = document.getElementById('update-status-msg');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Ellenőrzés...';
|
||||
msg.style.display = 'none';
|
||||
fetch('/api/selfupdate/check', {method:'POST'})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.ok) {
|
||||
location.reload();
|
||||
} else {
|
||||
msg.textContent = data.error || 'Hiba történt';
|
||||
msg.style.display = 'inline';
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Frissítés keresése';
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
msg.textContent = 'Kapcsolódási hiba';
|
||||
msg.style.display = 'inline';
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Frissítés keresése';
|
||||
});
|
||||
}
|
||||
|
||||
function triggerUpdate() {
|
||||
if (!confirm('Biztosan frissíti a controllert?\n\nA folyamat alatt a vezérlőpult rövid időre elérhetetlenné válik.')) return;
|
||||
var btn = document.getElementById('btn-trigger-update');
|
||||
var checkBtn = document.getElementById('btn-check-update');
|
||||
var msg = document.getElementById('update-status-msg');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Frissítés...';
|
||||
if (checkBtn) checkBtn.disabled = true;
|
||||
msg.textContent = 'Frissítés folyamatban...';
|
||||
msg.style.display = 'inline';
|
||||
fetch('/api/selfupdate/update', {method:'POST'})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.ok) {
|
||||
msg.textContent = 'Újraindulás...';
|
||||
pollUntilBack();
|
||||
} else {
|
||||
msg.textContent = data.error || 'Hiba történt';
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Frissítés telepítése';
|
||||
if (checkBtn) checkBtn.disabled = false;
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
msg.textContent = 'Kapcsolódási hiba';
|
||||
pollUntilBack();
|
||||
});
|
||||
}
|
||||
|
||||
function pollUntilBack() {
|
||||
var iv = setInterval(function() {
|
||||
fetch('/api/health')
|
||||
.then(function(r) {
|
||||
if (r.ok) {
|
||||
clearInterval(iv);
|
||||
location.reload();
|
||||
}
|
||||
})
|
||||
.catch(function() {});
|
||||
}, 3000);
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Section: Storage Paths -->
|
||||
<div class="settings-card">
|
||||
<h3>Adattárolók</h3>
|
||||
|
||||
Reference in New Issue
Block a user