Files
deploy-felhom-compose/TASK.md
T
2026-02-19 18:07:20 +01:00

27 KiB

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:

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:

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:

	// 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:

var Version = "0.16.0"

To:

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):

	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):

	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:

	// 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:

	webServer := web.New(dataStore, cfg.Auth.PasswordHash, staleThreshold, logger)

To:

	webServer := web.New(dataStore, cfg.Auth.PasswordHash, cfg.API.ReportAPIKey, staleThreshold, logger)

After creating the version checker, set it on the web server:

	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):

	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:

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:

		ControllerURL     string `json:"controller_url"`

Update the INSERT query and args:

	_, 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:

		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:

		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:

		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:

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:

	"io"
	"sync"

(fmt, encoding/json, net/http, time should already be present.)

C) Update New() constructor to accept apiKey:

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:

// 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):

// 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:

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:

	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:

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:

	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:

	// 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:

		ControllerURL:       controllerURL,
		LatestVersion:       latestVersion,
		UpdateAvailable:     updateAvailable,

J) Add compareVersions helper function:

// 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 -->:

        <!-- 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:

	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:

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:

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)

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)

# 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