Files
felhom.eu/hub/cmd/hub/main.go
T
admin 1e354cbd41 feat(hub): Configuration page, asset seedOrUpdate, English UI
- Add Configuration page with "Refresh Assets" button
- Replace seedIfEmpty with seedOrUpdate (SHA-256 compare on startup)
- Translate all Hungarian text on Apps pages to English
- Add Configuration tab to all template navigation
- Expand isAssetFile to match favicon patterns
- Add felhom-logo.svg to website assets for the pipeline

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 09:34:43 +01:00

361 lines
11 KiB
Go

package main
import (
"context"
"flag"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/api"
"gitea.dooplex.hu/admin/felhom-hub/internal/assets"
"gitea.dooplex.hu/admin/felhom-hub/internal/monitor"
"gitea.dooplex.hu/admin/felhom-hub/internal/notify"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
"gitea.dooplex.hu/admin/felhom-hub/internal/web"
"gopkg.in/yaml.v3"
)
var (
Version = "dev"
BuildTime = "unknown"
)
// Config is the hub configuration loaded from hub.yaml.
type Config struct {
Auth struct {
PasswordHash string `yaml:"password_hash"`
} `yaml:"auth"`
API struct {
ReportAPIKey string `yaml:"report_api_key"`
} `yaml:"api"`
Notifications struct {
ResendAPIKey string `yaml:"resend_api_key"`
FromEmail string `yaml:"from_email"`
OperatorEmail string `yaml:"operator_email"`
OperatorEnabled bool `yaml:"operator_enabled"`
} `yaml:"notifications"`
Retention struct {
MaxDays int `yaml:"max_days"`
PruneSchedule string `yaml:"prune_schedule"`
} `yaml:"retention"`
Alerting struct {
StaleThreshold string `yaml:"stale_threshold"`
} `yaml:"alerting"`
Registry struct {
Image string `yaml:"image"`
Username string `yaml:"username"`
Token string `yaml:"token"`
CheckInterval string `yaml:"check_interval"`
TemplateInterval string `yaml:"template_interval"`
} `yaml:"registry"`
Server struct {
Listen string `yaml:"listen"`
DataDir string `yaml:"data_dir"`
} `yaml:"server"`
}
func main() {
configPath := flag.String("config", "/etc/felhom-hub/hub.yaml", "Path to configuration file")
showVersion := flag.Bool("version", false, "Show version and exit")
flag.Parse()
if *showVersion {
fmt.Printf("felhom-hub %s (built %s)\n", Version, BuildTime)
os.Exit(0)
}
logger := log.New(os.Stdout, "", log.LstdFlags)
logger.Printf("[INFO] felhom-hub %s starting", Version)
// Load config
cfg := loadConfig(*configPath, logger)
// Environment variable overrides (for k8s Secrets)
if v := os.Getenv("REGISTRY_USERNAME"); v != "" {
cfg.Registry.Username = v
}
if v := os.Getenv("REGISTRY_TOKEN"); v != "" {
cfg.Registry.Token = v
}
// Ensure data dir exists
os.MkdirAll(cfg.Server.DataDir, 0755)
// Initialize store
dbPath := filepath.Join(cfg.Server.DataDir, "hub.db")
dataStore, err := store.New(dbPath, logger)
if err != nil {
logger.Fatalf("[FATAL] Failed to initialize store: %v", err)
}
defer dataStore.Close()
logger.Printf("[INFO] Database opened at %s", dbPath)
// Parse stale threshold
staleThreshold, err := time.ParseDuration(cfg.Alerting.StaleThreshold)
if err != nil {
staleThreshold = 30 * time.Minute
}
// Background context for all goroutines
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Initialize template fetcher for customer config generation
var templateFetcher *web.TemplateFetcher
if cfg.Registry.Username != "" && cfg.Registry.Token != "" {
templateInterval, err := time.ParseDuration(cfg.Registry.TemplateInterval)
if err != nil {
templateInterval = 1 * time.Hour
}
templateFetcher = web.NewTemplateFetcher(cfg.Registry.Username, cfg.Registry.Token, templateInterval, logger)
go templateFetcher.Run(ctx)
logger.Printf("[INFO] Template fetcher started (every %s)", cfg.Registry.TemplateInterval)
}
// Initialize asset manager (PVC storage with image seed)
assetsDir := filepath.Join(cfg.Server.DataDir, "assets")
assetsMgr := assets.New(assetsDir, "/usr/share/felhom/assets-seed", logger)
// Initialize handlers — pass templateFetcher as interface (nil-safe)
var templateProvider api.ConfigTemplateProvider
if templateFetcher != nil {
templateProvider = templateFetcher
}
apiHandler := api.New(dataStore, cfg.API.ReportAPIKey, cfg.Notifications.ResendAPIKey, cfg.Notifications.FromEmail, templateProvider, logger)
apiHandler.SetAssetManager(assetsMgr)
// Initialize notification dispatcher
dispatcher := notify.NewDispatcher(
dataStore,
cfg.Notifications.ResendAPIKey,
cfg.Notifications.FromEmail,
cfg.Notifications.OperatorEmail,
cfg.Notifications.OperatorEnabled,
logger,
)
apiHandler.SetDispatcher(dispatcher)
webServer := web.New(dataStore, cfg.Auth.PasswordHash, cfg.API.ReportAPIKey, Version, staleThreshold, logger)
webServer.SetTemplateFetcher(templateFetcher)
webServer.SetAssetManager(assetsMgr)
// Build HTTP mux
mux := http.NewServeMux()
// Health check endpoint — bypasses auth (for k8s probes)
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
if err := dataStore.Ping(); err != nil {
w.WriteHeader(http.StatusServiceUnavailable)
w.Write([]byte("db unhealthy"))
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
// API routes (no dashboard auth for report ingest)
mux.Handle("/api/v1/", apiHandler)
// Web routes (auth required)
if cfg.Auth.PasswordHash != "" {
mux.Handle("/", webServer.RequireAuth(http.HandlerFunc(webServer.ServeHTTP)))
} else {
mux.Handle("/", http.HandlerFunc(webServer.ServeHTTP))
}
// Start HTTP server
server := &http.Server{
Addr: cfg.Server.Listen,
Handler: mux,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
// 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)")
}
webServer.SetVersionChecker(versionChecker)
// Session cleanup — removes expired sessions every hour
go webServer.CleanupSessions(ctx)
// Prune on startup, then daily at configured time (default 04:30)
if cfg.Retention.MaxDays > 0 {
pruneAll(dataStore, cfg.Retention.MaxDays, logger)
go scheduleDaily(ctx, "prune", cfg.Retention.PruneSchedule, func() {
pruneAll(dataStore, cfg.Retention.MaxDays, logger)
}, logger)
}
// Staleness checker — runs every 60s
stalenessChecker := monitor.NewStalenessChecker(dataStore, staleThreshold, dispatcher.ProcessEvent, logger)
go func() {
ticker := time.NewTicker(60 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
stalenessChecker.Check()
}
}
}()
// Backup deadline checker — runs daily at 05:00 Budapest
go scheduleDaily(ctx, "deadline-check", "05:00", func() {
monitor.CheckBackupDeadlines(dataStore, stalenessChecker, dispatcher.ProcessEvent, logger)
}, logger)
// Signal handling
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
sig := <-sigCh
logger.Printf("[INFO] Received signal %v, shutting down...", sig)
cancel()
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second)
defer shutdownCancel()
if err := server.Shutdown(shutdownCtx); err != nil {
logger.Printf("[ERROR] HTTP server shutdown error: %v", err)
}
}()
logger.Printf("[INFO] Listening on %s", cfg.Server.Listen)
if err := server.ListenAndServe(); err != http.ErrServerClosed {
logger.Fatalf("[FATAL] HTTP server error: %v", err)
}
logger.Println("[INFO] felhom-hub stopped")
}
func loadConfig(path string, logger *log.Logger) *Config {
cfg := &Config{}
// Defaults
cfg.Server.Listen = ":8080"
cfg.Server.DataDir = "/data"
cfg.Retention.MaxDays = 90
cfg.Retention.PruneSchedule = "04:30"
cfg.Alerting.StaleThreshold = "30m"
data, err := os.ReadFile(path)
if err != nil {
logger.Printf("[WARN] Config file not found at %s, using defaults", path)
return cfg
}
if err := yaml.Unmarshal(data, cfg); err != nil {
logger.Printf("[WARN] Failed to parse config: %v, using defaults", err)
return cfg
}
// Apply defaults for zero values
if cfg.Server.Listen == "" {
cfg.Server.Listen = ":8080"
}
if cfg.Server.DataDir == "" {
cfg.Server.DataDir = "/data"
}
if cfg.Retention.MaxDays == 0 {
cfg.Retention.MaxDays = 90
}
if cfg.Alerting.StaleThreshold == "" {
cfg.Alerting.StaleThreshold = "30m"
}
if cfg.Notifications.FromEmail == "" {
cfg.Notifications.FromEmail = "monitoring@felhom.eu"
}
if cfg.Registry.Image == "" {
cfg.Registry.Image = "gitea.dooplex.hu/admin/felhom-controller"
}
if cfg.Registry.CheckInterval == "" {
cfg.Registry.CheckInterval = "6h"
}
if cfg.Registry.TemplateInterval == "" {
cfg.Registry.TemplateInterval = "1h"
}
return cfg
}
// scheduleDaily runs fn once daily at the given "HH:MM" time in Europe/Budapest.
// It blocks until ctx is cancelled.
func scheduleDaily(ctx context.Context, name, timeStr string, fn func(), logger *log.Logger) {
budapest, err := time.LoadLocation("Europe/Budapest")
if err != nil {
budapest = time.FixedZone("CET", 3600)
}
hour, min := parseHM(timeStr)
for {
now := time.Now().In(budapest)
next := time.Date(now.Year(), now.Month(), now.Day(), hour, min, 0, 0, budapest)
if !next.After(now) {
next = next.Add(24 * time.Hour)
}
delay := time.Until(next)
logger.Printf("[INFO] %s: next run at %s (in %s)", name, next.Format("2006-01-02 15:04 MST"), delay.Round(time.Second))
select {
case <-ctx.Done():
return
case <-time.After(delay):
fn()
}
}
}
// parseHM parses "HH:MM" into hour and minute. Returns 0, 0 on invalid input.
func parseHM(s string) (int, int) {
var h, m int
if _, err := fmt.Sscanf(s, "%d:%d", &h, &m); err != nil {
return 0, 0
}
return h, m
}
func pruneAll(s *store.Store, maxDays int, logger *log.Logger) {
if deleted, err := s.Prune(maxDays); err != nil {
logger.Printf("[WARN] Prune reports failed: %v", err)
} else if deleted > 0 {
logger.Printf("[INFO] Pruned %d old report rows", deleted)
}
if deleted, err := s.PruneEvents(maxDays); err != nil {
logger.Printf("[WARN] Prune events failed: %v", err)
} else if deleted > 0 {
logger.Printf("[INFO] Pruned %d old event rows", deleted)
}
if n, err := s.PruneAppTelemetry(time.Now().Add(-90 * 24 * time.Hour)); err != nil {
logger.Printf("[ERROR] Prune app telemetry: %v", err)
} else if n > 0 {
logger.Printf("[INFO] Pruned %d old app telemetry rows", n)
}
if n, err := s.PruneStaleIssues(time.Now().Add(-30 * 24 * time.Hour)); err != nil {
logger.Printf("[ERROR] Prune stale issues: %v", err)
} else if n > 0 {
logger.Printf("[INFO] Pruned %d stale app issues", n)
}
}