821 lines
27 KiB
Markdown
821 lines
27 KiB
Markdown
# TASK: Hub Update Trigger + Controller URL Reporting
|
|
|
|
**Controller:** v0.16.0 → v0.16.1
|
|
**Hub:** v0.1.7 → v0.1.8
|
|
|
|
## Overview
|
|
|
|
Add the ability to trigger a controller self-update from the hub's customer detail page. This requires:
|
|
|
|
1. **Controller** sends its URL in periodic reports (`controller_url` field)
|
|
2. **Hub** stores the controller URL, checks the Gitea registry for the latest controller image version, and shows a "Trigger Update" button when an update is available
|
|
3. **Hub** proxies the update trigger request to the controller's existing `/api/selfupdate/update` endpoint using the shared API key
|
|
|
|
**Two repositories involved:**
|
|
- `deploy-felhom-compose/controller/` — add `controller_url` to reports (Part 1)
|
|
- `felhom.eu/hub/` — version checker, trigger button, URL tracking (Part 2)
|
|
|
|
---
|
|
|
|
## Part 1: Controller Changes (v0.16.1)
|
|
|
|
Minimal changes — just add `controller_url` to the report payload so the hub knows where to reach the controller.
|
|
|
|
### 1.1 `controller/internal/report/types.go`
|
|
|
|
**Add `ControllerURL` field to the Report struct (after `ControllerVersion`, line 10):**
|
|
|
|
Change:
|
|
```go
|
|
type Report struct {
|
|
Version int `json:"version"`
|
|
CustomerID string `json:"customer_id"`
|
|
CustomerName string `json:"customer_name"`
|
|
ControllerVersion string `json:"controller_version"`
|
|
Timestamp time.Time `json:"timestamp"`
|
|
```
|
|
|
|
To:
|
|
```go
|
|
type Report struct {
|
|
Version int `json:"version"`
|
|
CustomerID string `json:"customer_id"`
|
|
CustomerName string `json:"customer_name"`
|
|
ControllerVersion string `json:"controller_version"`
|
|
ControllerURL string `json:"controller_url,omitempty"`
|
|
Timestamp time.Time `json:"timestamp"`
|
|
```
|
|
|
|
### 1.2 `controller/internal/report/builder.go`
|
|
|
|
**Add `"fmt"` to imports and set `ControllerURL` in `BuildReport()`.**
|
|
|
|
After line 33 (`ControllerVersion: version,`), add:
|
|
```go
|
|
// Controller URL for hub callbacks (self-update trigger, etc.)
|
|
if cfg.Customer.Domain != "" {
|
|
r.ControllerURL = fmt.Sprintf("https://felhom.%s", cfg.Customer.Domain)
|
|
}
|
|
```
|
|
|
|
Add `"fmt"` to the import block.
|
|
|
|
### 1.3 `controller/cmd/controller/main.go`
|
|
|
|
**Bump version constant:**
|
|
|
|
Change:
|
|
```go
|
|
var Version = "0.16.0"
|
|
```
|
|
To:
|
|
```go
|
|
var Version = "0.16.1"
|
|
```
|
|
|
|
(Or wherever the `Version` variable is defined — check the exact location.)
|
|
|
|
---
|
|
|
|
## Part 2: Hub Changes (v0.1.8)
|
|
|
|
### 2.1 `hub/cmd/hub/main.go` — Config + wiring
|
|
|
|
**A) Add `Registry` section to Config struct (after the `Alerting` section, line 44):**
|
|
|
|
```go
|
|
Registry struct {
|
|
Image string `yaml:"image"`
|
|
Username string `yaml:"username"`
|
|
Token string `yaml:"token"`
|
|
CheckInterval string `yaml:"check_interval"`
|
|
} `yaml:"registry"`
|
|
```
|
|
|
|
**B) Add defaults in `loadConfig()` (at the end of the defaults section):**
|
|
|
|
```go
|
|
if cfg.Registry.Image == "" {
|
|
cfg.Registry.Image = "gitea.dooplex.hu/admin/felhom-controller"
|
|
}
|
|
if cfg.Registry.CheckInterval == "" {
|
|
cfg.Registry.CheckInterval = "6h"
|
|
}
|
|
```
|
|
|
|
**C) Create version checker and pass API key to web server.**
|
|
|
|
After `webServer := web.New(...)`, add version checker setup:
|
|
|
|
```go
|
|
// Initialize version checker for controller image registry
|
|
var versionChecker *web.VersionChecker
|
|
if cfg.Registry.Username != "" && cfg.Registry.Token != "" {
|
|
checkInterval, err := time.ParseDuration(cfg.Registry.CheckInterval)
|
|
if err != nil {
|
|
checkInterval = 6 * time.Hour
|
|
}
|
|
versionChecker = web.NewVersionChecker(cfg.Registry.Image, cfg.Registry.Username, cfg.Registry.Token, checkInterval, logger)
|
|
go versionChecker.Run(ctx)
|
|
logger.Printf("[INFO] Registry version checker started (every %s)", cfg.Registry.CheckInterval)
|
|
} else {
|
|
logger.Printf("[INFO] Registry version checker disabled (no credentials configured)")
|
|
}
|
|
```
|
|
|
|
**D) Update `web.New()` call to include API key and version checker:**
|
|
|
|
Change from:
|
|
```go
|
|
webServer := web.New(dataStore, cfg.Auth.PasswordHash, staleThreshold, logger)
|
|
```
|
|
|
|
To:
|
|
```go
|
|
webServer := web.New(dataStore, cfg.Auth.PasswordHash, cfg.API.ReportAPIKey, staleThreshold, logger)
|
|
```
|
|
|
|
After creating the version checker, set it on the web server:
|
|
```go
|
|
webServer.SetVersionChecker(versionChecker)
|
|
```
|
|
|
|
### 2.2 `hub/internal/store/store.go` — Add controller_url tracking
|
|
|
|
**A) Add `ControllerURL` field to `CustomerSummary` struct (after `ReportJSON`, line 31):**
|
|
|
|
```go
|
|
ControllerURL string
|
|
```
|
|
|
|
**B) Add migration for `controller_url` column at the end of the `migrate()` function:**
|
|
|
|
After the existing `_, err := s.db.Exec(...)` block and the `return err` line, change the function to:
|
|
|
|
```go
|
|
func (s *Store) migrate() error {
|
|
_, err := s.db.Exec(`
|
|
// ... existing SQL unchanged ...
|
|
`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// v0.1.8: add controller_url column (idempotent — ignore error if already exists)
|
|
s.db.Exec("ALTER TABLE reports ADD COLUMN controller_url TEXT")
|
|
|
|
return nil
|
|
}
|
|
```
|
|
|
|
(Move `return err` to be right after the first Exec block, then add the ALTER TABLE, then `return nil`.)
|
|
|
|
**C) Update `SaveReport()` to extract and store `controller_url`:**
|
|
|
|
In the `parsed` struct (around line 202), add the field:
|
|
```go
|
|
ControllerURL string `json:"controller_url"`
|
|
```
|
|
|
|
Update the INSERT query and args:
|
|
|
|
```go
|
|
_, err := s.db.Exec(`
|
|
INSERT INTO reports (customer_id, report_json, health_status, cpu_percent,
|
|
memory_percent, container_total, container_running,
|
|
backup_last_snapshot, controller_version, controller_url)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
customerID, string(reportJSON),
|
|
parsed.Health.Status, parsed.System.CPUPercent,
|
|
parsed.System.MemoryPercent, parsed.Containers.Total,
|
|
parsed.Containers.Running, backupSnapshot,
|
|
parsed.ControllerVersion, parsed.ControllerURL,
|
|
)
|
|
```
|
|
|
|
**D) Update `GetCustomers()` to scan `controller_url`:**
|
|
|
|
Add `r.controller_url` to the SELECT query:
|
|
```sql
|
|
SELECT r.customer_id, r.received_at, r.report_json,
|
|
r.health_status, r.cpu_percent, r.memory_percent,
|
|
r.container_total, r.container_running,
|
|
r.backup_last_snapshot, r.controller_version, r.controller_url
|
|
FROM reports r
|
|
```
|
|
|
|
Add a `controllerURL sql.NullString` variable and update the Scan:
|
|
```go
|
|
var controllerURL sql.NullString
|
|
if err := rows.Scan(&c.CustomerID, &receivedAt, &c.ReportJSON,
|
|
&c.HealthStatus, &c.CPUPercent, &c.MemoryPercent,
|
|
&c.ContainerTotal, &c.ContainerRunning,
|
|
&backupSnapshot, &c.ControllerVersion, &controllerURL); err != nil {
|
|
return nil, err
|
|
}
|
|
```
|
|
|
|
After scanning, assign:
|
|
```go
|
|
if controllerURL.Valid {
|
|
c.ControllerURL = controllerURL.String
|
|
}
|
|
```
|
|
|
|
**E) Update `GetCustomer()` identically** — add `r.controller_url` to SELECT, `controllerURL sql.NullString`, update Scan, assign.
|
|
|
|
**F) Update `GetCustomerHistory()` identically** — add `r.controller_url` to SELECT, `controllerURL sql.NullString`, update Scan, assign.
|
|
|
|
### 2.3 `hub/internal/web/server.go` — Version checker, trigger handler, template data
|
|
|
|
**A) Add fields to Server struct:**
|
|
|
|
```go
|
|
type Server struct {
|
|
store *store.Store
|
|
passwordHash string
|
|
apiKey string // report API key — used for controller callbacks
|
|
logger *log.Logger
|
|
templates *template.Template
|
|
staleThreshold time.Duration
|
|
versionChecker *VersionChecker
|
|
}
|
|
```
|
|
|
|
**B) Add imports:**
|
|
|
|
```go
|
|
"io"
|
|
"sync"
|
|
```
|
|
|
|
(`fmt`, `encoding/json`, `net/http`, `time` should already be present.)
|
|
|
|
**C) Update `New()` constructor to accept `apiKey`:**
|
|
|
|
```go
|
|
func New(store *store.Store, passwordHash, apiKey string, staleThreshold time.Duration, logger *log.Logger) *Server {
|
|
```
|
|
|
|
Add to struct init: `apiKey: apiKey,`
|
|
|
|
**D) Add `SetVersionChecker` method:**
|
|
|
|
```go
|
|
// SetVersionChecker sets the version checker (optional, may be nil if no registry credentials).
|
|
func (s *Server) SetVersionChecker(vc *VersionChecker) {
|
|
s.versionChecker = vc
|
|
}
|
|
```
|
|
|
|
**E) Add the `VersionChecker` type (at the bottom of `server.go` or in a new file `hub/internal/web/version.go` — implementer's choice):**
|
|
|
|
```go
|
|
// VersionChecker periodically queries the Gitea Docker Registry V2 API
|
|
// for the latest controller image version tag.
|
|
type VersionChecker struct {
|
|
image string // e.g., "gitea.dooplex.hu/admin/felhom-controller"
|
|
username string
|
|
token string
|
|
checkInterval time.Duration
|
|
logger *log.Logger
|
|
|
|
mu sync.RWMutex
|
|
latestVersion string
|
|
lastCheck time.Time
|
|
lastError string
|
|
}
|
|
|
|
// NewVersionChecker creates a new VersionChecker.
|
|
func NewVersionChecker(image, username, token string, checkInterval time.Duration, logger *log.Logger) *VersionChecker {
|
|
return &VersionChecker{
|
|
image: image,
|
|
username: username,
|
|
token: token,
|
|
checkInterval: checkInterval,
|
|
logger: logger,
|
|
}
|
|
}
|
|
|
|
// Run starts the periodic version check loop. Call in a goroutine.
|
|
// It checks immediately on start, then every checkInterval.
|
|
func (vc *VersionChecker) Run(ctx context.Context) {
|
|
vc.check()
|
|
ticker := time.NewTicker(vc.checkInterval)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
vc.check()
|
|
}
|
|
}
|
|
}
|
|
|
|
func (vc *VersionChecker) check() {
|
|
latest, err := vc.queryRegistry()
|
|
vc.mu.Lock()
|
|
defer vc.mu.Unlock()
|
|
vc.lastCheck = time.Now()
|
|
if err != nil {
|
|
vc.lastError = err.Error()
|
|
vc.logger.Printf("[WARN] Registry version check failed: %v", err)
|
|
return
|
|
}
|
|
vc.lastError = ""
|
|
vc.latestVersion = latest
|
|
vc.logger.Printf("[DEBUG] Registry version check: latest = %s", latest)
|
|
}
|
|
|
|
// LatestVersion returns the cached latest version string (e.g., "0.16.1"), or "" if unknown.
|
|
func (vc *VersionChecker) LatestVersion() string {
|
|
vc.mu.RLock()
|
|
defer vc.mu.RUnlock()
|
|
return vc.latestVersion
|
|
}
|
|
|
|
// queryRegistry queries the Gitea Docker Registry V2 API for available tags
|
|
// and returns the highest valid semver tag.
|
|
func (vc *VersionChecker) queryRegistry() (string, error) {
|
|
// Extract "owner/repo" from image reference
|
|
// e.g., "gitea.dooplex.hu/admin/felhom-controller" → "admin/felhom-controller"
|
|
imagePath := vc.image
|
|
if parts := strings.SplitN(vc.image, "/", 2); len(parts) == 2 {
|
|
imagePath = parts[1]
|
|
}
|
|
|
|
url := fmt.Sprintf("https://gitea.dooplex.hu/v2/%s/tags/list", imagePath)
|
|
|
|
req, err := http.NewRequest("GET", url, nil)
|
|
if err != nil {
|
|
return "", fmt.Errorf("creating request: %w", err)
|
|
}
|
|
req.SetBasicAuth(vc.username, vc.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 {
|
|
Tags []string `json:"tags"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&tagsResp); err != nil {
|
|
return "", fmt.Errorf("decoding response: %w", err)
|
|
}
|
|
|
|
// Find the highest valid semver tag
|
|
var bestMajor, bestMinor, bestPatch int
|
|
found := false
|
|
for _, tag := range tagsResp.Tags {
|
|
tag = strings.TrimPrefix(tag, "v")
|
|
parts := strings.SplitN(tag, ".", 3)
|
|
if len(parts) != 3 {
|
|
continue
|
|
}
|
|
major, e1 := strconv.Atoi(parts[0])
|
|
minor, e2 := strconv.Atoi(parts[1])
|
|
patch, e3 := strconv.Atoi(parts[2])
|
|
if e1 != nil || e2 != nil || e3 != nil {
|
|
continue
|
|
}
|
|
if !found || major > bestMajor ||
|
|
(major == bestMajor && minor > bestMinor) ||
|
|
(major == bestMajor && minor == bestMinor && patch > bestPatch) {
|
|
bestMajor, bestMinor, bestPatch = major, minor, patch
|
|
found = true
|
|
}
|
|
}
|
|
|
|
if !found {
|
|
return "", fmt.Errorf("no valid semver tags found")
|
|
}
|
|
|
|
return fmt.Sprintf("%d.%d.%d", bestMajor, bestMinor, bestPatch), nil
|
|
}
|
|
```
|
|
|
|
**F) Add imports needed by VersionChecker:**
|
|
|
|
If placing in `server.go`, add `"strconv"` and `"context"` to imports (some may already be present).
|
|
|
|
If placing in a new `version.go` file, include all needed imports:
|
|
```go
|
|
package web
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
```
|
|
|
|
**G) Add route for trigger-update in `ServeHTTP`.**
|
|
|
|
This case MUST come BEFORE the general `/customers/` case. Reorder the switch:
|
|
|
|
```go
|
|
case strings.HasPrefix(path, "/customers/") && strings.HasSuffix(path, "/trigger-update"):
|
|
customerID := strings.TrimPrefix(path, "/customers/")
|
|
customerID = strings.TrimSuffix(customerID, "/trigger-update")
|
|
if r.Method == http.MethodPost {
|
|
s.handleTriggerUpdate(w, r, customerID)
|
|
} else {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
case strings.HasPrefix(path, "/customers/"):
|
|
customerID := strings.TrimPrefix(path, "/customers/")
|
|
s.handleCustomerDetail(w, r, customerID)
|
|
```
|
|
|
|
**H) Add `handleTriggerUpdate` handler:**
|
|
|
|
```go
|
|
func (s *Server) handleTriggerUpdate(w http.ResponseWriter, r *http.Request, customerID string) {
|
|
customer, err := s.store.GetCustomer(customerID)
|
|
if err != nil {
|
|
s.logger.Printf("[ERROR] Trigger update — get customer %s: %v", customerID, err)
|
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if customer == nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
// Get controller URL — from denormalized field or report JSON fallback
|
|
controllerURL := customer.ControllerURL
|
|
if controllerURL == "" {
|
|
var rpt struct {
|
|
ControllerURL string `json:"controller_url"`
|
|
}
|
|
json.Unmarshal([]byte(customer.ReportJSON), &rpt)
|
|
controllerURL = rpt.ControllerURL
|
|
}
|
|
if controllerURL == "" {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
w.Write([]byte(`{"ok":false,"error":"Controller URL not available — waiting for next report"}`))
|
|
return
|
|
}
|
|
|
|
if s.apiKey == "" {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
w.Write([]byte(`{"ok":false,"error":"API key not configured"}`))
|
|
return
|
|
}
|
|
|
|
// POST to controller's self-update endpoint
|
|
updateURL := controllerURL + "/api/selfupdate/update"
|
|
req, err := http.NewRequest("POST", updateURL, nil)
|
|
if err != nil {
|
|
s.logger.Printf("[ERROR] Trigger update — create request for %s: %v", updateURL, err)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
w.Write([]byte(`{"ok":false,"error":"Failed to create request"}`))
|
|
return
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+s.apiKey)
|
|
|
|
client := &http.Client{Timeout: 30 * time.Second}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
s.logger.Printf("[ERROR] Trigger update — request to %s failed: %v", updateURL, err)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusBadGateway)
|
|
json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": fmt.Sprintf("Controller unreachable: %v", err)})
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// Forward the controller's response
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
|
|
s.logger.Printf("[INFO] Trigger update for %s — controller responded %d: %s", customerID, resp.StatusCode, string(body))
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(resp.StatusCode)
|
|
w.Write(body)
|
|
}
|
|
```
|
|
|
|
**I) Update `handleCustomerDetail` to include version + URL data for the template:**
|
|
|
|
Add fields to `detailData` struct:
|
|
```go
|
|
type detailData struct {
|
|
Customer *store.CustomerSummary
|
|
Report map[string]interface{}
|
|
History []store.CustomerSummary
|
|
OverallStatus string
|
|
NotifPrefs *store.NotificationPrefs
|
|
RecentNotifications []store.NotificationLogEntry
|
|
InfraBackup *store.InfraBackupMeta
|
|
InfraBackupAge string
|
|
ControllerURL string // controller's external URL
|
|
LatestVersion string // latest controller image version from registry
|
|
UpdateAvailable bool // true if latest > current
|
|
}
|
|
```
|
|
|
|
Before the `data := detailData{...}` assignment, add:
|
|
|
|
```go
|
|
// Get controller URL (from denormalized field or report JSON fallback)
|
|
controllerURL := customer.ControllerURL
|
|
if controllerURL == "" {
|
|
var rpt struct {
|
|
ControllerURL string `json:"controller_url"`
|
|
}
|
|
json.Unmarshal([]byte(customer.ReportJSON), &rpt)
|
|
controllerURL = rpt.ControllerURL
|
|
}
|
|
|
|
// Check if update is available
|
|
var latestVersion string
|
|
var updateAvailable bool
|
|
if s.versionChecker != nil {
|
|
latestVersion = s.versionChecker.LatestVersion()
|
|
if latestVersion != "" && customer.ControllerVersion != "" {
|
|
updateAvailable = latestVersion != customer.ControllerVersion && compareVersions(latestVersion, customer.ControllerVersion) > 0
|
|
}
|
|
}
|
|
```
|
|
|
|
In the `data := detailData{...}` assignment, add:
|
|
```go
|
|
ControllerURL: controllerURL,
|
|
LatestVersion: latestVersion,
|
|
UpdateAvailable: updateAvailable,
|
|
```
|
|
|
|
**J) Add `compareVersions` helper function:**
|
|
|
|
```go
|
|
// compareVersions returns >0 if a > b, 0 if equal, <0 if a < b.
|
|
// Accepts "X.Y.Z" format. Returns 0 on parse error.
|
|
func compareVersions(a, b string) int {
|
|
a = strings.TrimPrefix(a, "v")
|
|
b = strings.TrimPrefix(b, "v")
|
|
aParts := strings.SplitN(a, ".", 3)
|
|
bParts := strings.SplitN(b, ".", 3)
|
|
if len(aParts) != 3 || len(bParts) != 3 {
|
|
return 0
|
|
}
|
|
for i := 0; i < 3; i++ {
|
|
ai, e1 := strconv.Atoi(aParts[i])
|
|
bi, e2 := strconv.Atoi(bParts[i])
|
|
if e1 != nil || e2 != nil {
|
|
return 0
|
|
}
|
|
if ai != bi {
|
|
return ai - bi
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
```
|
|
|
|
### 2.4 `hub/internal/web/templates/customer.html` — Update trigger section
|
|
|
|
**Add "Controller Update" section after the "Health" section (after line 184, before the "Notifications" section).**
|
|
|
|
Insert between the Health `</section>` and `<!-- Notifications -->`:
|
|
|
|
```html
|
|
<!-- Controller Update -->
|
|
<section class="card">
|
|
<h2>Controller Update</h2>
|
|
<div class="info-grid">
|
|
<div class="info-item">
|
|
<span class="label">Current version</span>
|
|
<span class="value">v{{.Customer.ControllerVersion}}</span>
|
|
</div>
|
|
{{if .LatestVersion}}
|
|
<div class="info-item">
|
|
<span class="label">Latest version</span>
|
|
<span class="value">
|
|
v{{.LatestVersion}}
|
|
{{if .UpdateAvailable}}
|
|
<span style="color: #4ade80; margin-left: 0.3em;">● update available</span>
|
|
{{else}}
|
|
<span style="color: #94a3b8; margin-left: 0.3em;">— up to date</span>
|
|
{{end}}
|
|
</span>
|
|
</div>
|
|
{{end}}
|
|
{{if .ControllerURL}}
|
|
<div class="info-item">
|
|
<span class="label">Controller URL</span>
|
|
<span class="value"><a href="{{.ControllerURL}}" target="_blank" style="color: #60a5fa;">{{.ControllerURL}}</a></span>
|
|
</div>
|
|
{{end}}
|
|
</div>
|
|
{{if and .ControllerURL .UpdateAvailable}}
|
|
<div style="margin-top: 0.75em;">
|
|
<button class="btn" id="btn-trigger-update" onclick="triggerControllerUpdate('{{.Customer.CustomerID}}')">
|
|
Trigger Update
|
|
</button>
|
|
<span id="update-msg" style="margin-left: 0.5em; display: none;"></span>
|
|
</div>
|
|
{{else if and .ControllerURL (not .LatestVersion)}}
|
|
<div style="margin-top: 0.75em;">
|
|
<button class="btn" id="btn-trigger-update" onclick="triggerControllerUpdate('{{.Customer.CustomerID}}')">
|
|
Trigger Update
|
|
</button>
|
|
<span id="update-msg" style="margin-left: 0.5em; display: none;"></span>
|
|
<p style="color: #94a3b8; font-size: 0.85em; margin-top: 0.3em;">Registry check not configured — cannot verify if update is available</p>
|
|
</div>
|
|
{{end}}
|
|
</section>
|
|
|
|
<script>
|
|
function triggerControllerUpdate(customerID) {
|
|
if (!confirm('Trigger self-update on this controller?\n\nThe controller will be briefly unavailable during restart.')) return;
|
|
var btn = document.getElementById('btn-trigger-update');
|
|
var msg = document.getElementById('update-msg');
|
|
btn.disabled = true;
|
|
btn.textContent = 'Triggering...';
|
|
msg.style.display = 'none';
|
|
fetch('/customers/' + customerID + '/trigger-update', {method: 'POST'})
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
if (data.ok) {
|
|
msg.textContent = 'Update triggered — controller restarting';
|
|
msg.style.display = 'inline';
|
|
msg.style.color = '#4ade80';
|
|
} else {
|
|
msg.textContent = data.error || 'Failed';
|
|
msg.style.display = 'inline';
|
|
msg.style.color = '#f87171';
|
|
btn.disabled = false;
|
|
btn.textContent = 'Trigger Update';
|
|
}
|
|
})
|
|
.catch(function() {
|
|
msg.textContent = 'Connection error';
|
|
msg.style.display = 'inline';
|
|
msg.style.color = '#f87171';
|
|
btn.disabled = false;
|
|
btn.textContent = 'Trigger Update';
|
|
});
|
|
}
|
|
</script>
|
|
```
|
|
|
|
**Button visibility logic:**
|
|
- `ControllerURL` set AND `UpdateAvailable` = true → show "Trigger Update" button (green dot, update available)
|
|
- `ControllerURL` set AND `LatestVersion` empty (no registry config) → show button with a note that version can't be verified
|
|
- `ControllerURL` set AND version is up to date → show version info only, no button
|
|
- `ControllerURL` not set → whole "Controller Update" card still shows version info, but no button
|
|
|
|
### 2.5 `hub/internal/api/handler.go` — Include `controller_url` in API response
|
|
|
|
**In `handleCustomers()`, add `ControllerURL` to the `customerJSON` struct:**
|
|
|
|
```go
|
|
type customerJSON struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
ControllerVersion string `json:"controller_version"`
|
|
ControllerURL string `json:"controller_url,omitempty"`
|
|
HealthStatus string `json:"health_status"`
|
|
LastSeen time.Time `json:"last_seen"`
|
|
CPUPercent float64 `json:"cpu_percent"`
|
|
MemoryPercent float64 `json:"memory_percent"`
|
|
ContainerTotal int `json:"container_total"`
|
|
ContainerRunning int `json:"container_running"`
|
|
BackupLastSnapshot *time.Time `json:"backup_last_snapshot"`
|
|
}
|
|
```
|
|
|
|
In the loop, add `ControllerURL: c.ControllerURL,` to the struct literal.
|
|
|
|
### 2.6 `hub/cmd/hub/main.go` — Bump version
|
|
|
|
Change:
|
|
```go
|
|
var (
|
|
Version = "dev"
|
|
```
|
|
|
|
**Note:** The version is injected at build time via `-ldflags`, so no code change needed — just use `0.1.8` as the build script argument.
|
|
|
|
---
|
|
|
|
## Summary of All File Changes
|
|
|
|
### Controller (`deploy-felhom-compose/controller/`) — v0.16.1
|
|
|
|
| # | File | Change |
|
|
|---|------|--------|
|
|
| 1 | `internal/report/types.go` | Add `ControllerURL string` field to Report struct |
|
|
| 2 | `internal/report/builder.go` | Set `ControllerURL` from `cfg.Customer.Domain`, add `"fmt"` import |
|
|
| 3 | `cmd/controller/main.go` | Bump Version to `"0.16.1"` |
|
|
|
|
### Hub (`felhom.eu/hub/`) — v0.1.8
|
|
|
|
| # | File | Change |
|
|
|---|------|--------|
|
|
| 1 | `cmd/hub/main.go` | Add `Registry` config section + defaults, create VersionChecker, pass API key to web.New() |
|
|
| 2 | `internal/store/store.go` | Add `controller_url` column migration + `ControllerURL` to CustomerSummary + update SaveReport/GetCustomers/GetCustomer/GetCustomerHistory |
|
|
| 3 | `internal/web/server.go` (or new `version.go`) | Add `apiKey` + `versionChecker` fields, `VersionChecker` struct + `Run()` + `queryRegistry()`, `handleTriggerUpdate` handler, route, `ControllerURL`/`LatestVersion`/`UpdateAvailable` in detailData, `compareVersions` helper |
|
|
| 4 | `internal/web/templates/customer.html` | Add "Controller Update" card with version comparison + conditional trigger button + JS |
|
|
| 5 | `internal/api/handler.go` | Add `controller_url` to customer API JSON response |
|
|
|
|
---
|
|
|
|
## Hub Config Addition
|
|
|
|
Add to `hub.yaml` on deploy:
|
|
|
|
```yaml
|
|
registry:
|
|
image: "gitea.dooplex.hu/admin/felhom-controller"
|
|
username: "admin" # Gitea username with read access to container registry
|
|
token: "..." # Gitea access token
|
|
check_interval: "6h" # How often to check for new versions
|
|
```
|
|
|
|
If no registry credentials are configured, the version checker is disabled. The "Trigger Update" button still appears but with a note that version cannot be verified.
|
|
|
|
---
|
|
|
|
## Build & Deploy
|
|
|
|
### Controller (v0.16.1)
|
|
|
|
```bash
|
|
SSH=/c/Windows/System32/OpenSSH/ssh.exe
|
|
|
|
# 1. Commit and push
|
|
cd /e/git/deploy-felhom-compose
|
|
git add -A && git commit -m "feat: add controller_url to hub reports (v0.16.1)" && git push
|
|
|
|
# 2. Build
|
|
$SSH kisfenyo@192.168.0.180 "cd ~/build/felhom-controller && git -C ~/git/deploy-felhom-compose pull && ./build.sh 0.16.1 --push"
|
|
|
|
# 3. Trigger self-update via API (v0.16.0 already has self-update)
|
|
curl -s -X POST https://felhom.demo-felhom.eu/api/selfupdate/update \
|
|
-H "Authorization: Bearer <HUB_API_KEY>"
|
|
|
|
# 4. Wait + verify
|
|
sleep 15
|
|
$SSH kisfenyo@192.168.0.162 "docker ps --filter name=felhom-controller --format '{{.Image}} {{.Status}}'"
|
|
```
|
|
|
|
### Hub (v0.1.8)
|
|
|
|
```bash
|
|
# 1. Commit and push
|
|
cd /e/git/felhom.eu
|
|
git add -A && git commit -m "feat: add controller update trigger + version checker (v0.1.8)" && git push
|
|
|
|
# 2. Build
|
|
$SSH kisfenyo@192.168.0.180 "cd ~/build/felhom-hub && ./build.sh 0.1.8 --push"
|
|
|
|
# 3. Deploy to k3s
|
|
$SSH kisfenyo@192.168.0.180 "sudo kubectl set image -n felhom-system deploy/hub hub=gitea.dooplex.hu/admin/felhom-hub:0.1.8"
|
|
|
|
# 4. Verify
|
|
$SSH kisfenyo@192.168.0.180 "sudo kubectl get pods -n felhom-system -l app=hub && sudo kubectl logs -n felhom-system -l app=hub --tail 10"
|
|
|
|
# 5. Add registry config to hub.yaml (one-time)
|
|
# SSH to build server and edit the hub config to add the registry section
|
|
```
|
|
|
|
**Deploy order:** Controller first (v0.16.1), then Hub (v0.1.8). The hub needs the controller to already send `controller_url` in its reports.
|
|
|
|
---
|
|
|
|
## Verification
|
|
|
|
### Controller
|
|
1. After deploy, next hub report contains `"controller_url": "https://felhom.demo-felhom.eu"` field
|
|
2. Verify: `$SSH kisfenyo@192.168.0.162 "docker logs felhom-controller --tail 5"` — check report push log
|
|
|
|
### Hub
|
|
1. Customer detail page shows "Controller Update" card with current version
|
|
2. If registry configured: shows latest version + green/gray indicator
|
|
3. If update available: "Trigger Update" button appears
|
|
4. Click button → triggers self-update on controller → shows success/error
|
|
5. If registry NOT configured: button shown with caveat note
|
|
6. Logs show `[INFO] Registry version checker started (every 6h)` on startup
|
|
7. API at `GET /api/v1/customers` includes `controller_url` field
|