Files
admin 4c8bf63ce3 feat: customer config management — CRUD, API retrieval, per-customer auth (v0.2.0)
New "Configurations" section lets operators pre-configure customer settings
in the Hub, then docker-setup.sh can download a ready-made controller.yaml
using just a customer ID and retrieval password.

- Store: customer_configs table with CRUD + per-customer API key lookup
- API: GET /api/v1/config/{id} with X-Retrieval-Password auth
- Auth: per-customer API keys alongside existing global key (backward compatible)
- Web UI: /configs list, create, edit, delete, YAML preview, copy-to-clipboard
- YAML gen: deep-merge controller.yaml.example template with customer overrides
- Template fetcher: background goroutine refreshing template from Gitea repo
- Navigation: Dashboard / Configurations tabs on all pages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 13:36:32 +01:00

114 lines
2.8 KiB
Go

package web
import (
"context"
"fmt"
"io"
"log"
"net/http"
"sync"
"time"
)
const templateRawURL = "https://gitea.dooplex.hu/admin/deploy-felhom-compose/raw/branch/main/controller/configs/controller.yaml.example"
// TemplateFetcher periodically fetches controller.yaml.example from the Gitea
// repo and caches it for config generation. Falls back to go:embed default.
type TemplateFetcher struct {
username string
token string
fetchInterval time.Duration
logger *log.Logger
mu sync.RWMutex
cachedTemplate string
lastFetch time.Time
lastError string
}
// NewTemplateFetcher creates a new TemplateFetcher.
func NewTemplateFetcher(username, token string, fetchInterval time.Duration, logger *log.Logger) *TemplateFetcher {
return &TemplateFetcher{
username: username,
token: token,
fetchInterval: fetchInterval,
logger: logger,
}
}
// Run starts the periodic fetch loop. Call in a goroutine.
// It fetches immediately on start, then every fetchInterval.
func (tf *TemplateFetcher) Run(ctx context.Context) {
tf.fetch()
ticker := time.NewTicker(tf.fetchInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
tf.fetch()
}
}
}
func (tf *TemplateFetcher) fetch() {
req, err := http.NewRequest("GET", templateRawURL, nil)
if err != nil {
tf.mu.Lock()
tf.lastError = err.Error()
tf.mu.Unlock()
tf.logger.Printf("[WARN] Template fetch: failed to create request: %v", err)
return
}
if tf.username != "" && tf.token != "" {
req.SetBasicAuth(tf.username, tf.token)
}
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
tf.mu.Lock()
tf.lastError = err.Error()
tf.mu.Unlock()
tf.logger.Printf("[WARN] Template fetch: HTTP request failed: %v", err)
return
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
tf.mu.Lock()
tf.lastError = fmt.Sprintf("HTTP %d", resp.StatusCode)
tf.mu.Unlock()
tf.logger.Printf("[WARN] Template fetch: unexpected status %d", resp.StatusCode)
return
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) // 64KB max
if err != nil {
tf.mu.Lock()
tf.lastError = err.Error()
tf.mu.Unlock()
tf.logger.Printf("[WARN] Template fetch: read error: %v", err)
return
}
tf.mu.Lock()
tf.cachedTemplate = string(body)
tf.lastFetch = time.Now()
tf.lastError = ""
tf.mu.Unlock()
tf.logger.Printf("[DEBUG] Template fetched (%d bytes)", len(body))
}
// Template returns the cached controller.yaml template.
// If the cache is empty (never fetched or all failures), returns the go:embed fallback.
func (tf *TemplateFetcher) Template() string {
tf.mu.RLock()
defer tf.mu.RUnlock()
if tf.cachedTemplate != "" {
return tf.cachedTemplate
}
return defaultControllerTemplate
}