diff --git a/TASK.md b/TASK.md index 143fb23..e69de29 100644 --- a/TASK.md +++ b/TASK.md @@ -1,820 +0,0 @@ -# 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 `` and ``: - -```html - -
-

Controller Update

-
-
- Current version - v{{.Customer.ControllerVersion}} -
- {{if .LatestVersion}} -
- Latest version - - v{{.LatestVersion}} - {{if .UpdateAvailable}} - ● update available - {{else}} - — up to date - {{end}} - -
- {{end}} - {{if .ControllerURL}} -
- Controller URL - {{.ControllerURL}} -
- {{end}} -
- {{if and .ControllerURL .UpdateAvailable}} -
- - -
- {{else if and .ControllerURL (not .LatestVersion)}} -
- - -

Registry check not configured — cannot verify if update is available

-
- {{end}} -
- - -``` - -**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 " - -# 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