feat: add config apply endpoint and config hash in reports
- POST /api/config/apply: accepts YAML body from Hub, validates and writes controller.yaml atomically (tmp+rename) - GET /api/config/hash: returns SHA256 hash of current config file - Report payload now includes config_hash field for Hub comparison - Config endpoints use same dual auth as self-update (session OR Bearer) - config.LoadFromBytes() for validation without file I/O - config.FileHash() helper for SHA256 computation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,8 +4,11 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -23,6 +26,7 @@ import (
|
||||
// Router handles all /api/* requests.
|
||||
type Router struct {
|
||||
cfg *config.Config
|
||||
configPath string
|
||||
sett *settings.Settings
|
||||
stackMgr *stacks.Manager
|
||||
syncer *catalogsync.Syncer
|
||||
@@ -34,8 +38,8 @@ type Router struct {
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
func NewRouter(cfg *config.Config, sett *settings.Settings, stackMgr *stacks.Manager, syncer *catalogsync.Syncer, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, crossDrive *backup.CrossDriveRunner, metricsStore *metrics.MetricsStore, updater *selfupdate.Updater, logger *log.Logger) *Router {
|
||||
return &Router{cfg: cfg, sett: sett, stackMgr: stackMgr, syncer: syncer, cpuCollector: cpuCollector, backupMgr: backupMgr, crossDriveRunner: crossDrive, metricsStore: metricsStore, updater: updater, logger: logger}
|
||||
func NewRouter(cfg *config.Config, configPath string, sett *settings.Settings, stackMgr *stacks.Manager, syncer *catalogsync.Syncer, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, crossDrive *backup.CrossDriveRunner, metricsStore *metrics.MetricsStore, updater *selfupdate.Updater, logger *log.Logger) *Router {
|
||||
return &Router{cfg: cfg, configPath: configPath, sett: sett, stackMgr: stackMgr, syncer: syncer, cpuCollector: cpuCollector, backupMgr: backupMgr, crossDriveRunner: crossDrive, metricsStore: metricsStore, updater: updater, logger: logger}
|
||||
}
|
||||
|
||||
type apiResponse struct {
|
||||
@@ -75,6 +79,14 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
case path == "/selfupdate/update" && req.Method == http.MethodPost:
|
||||
r.selfupdateTrigger(w, req)
|
||||
|
||||
// POST /api/config/apply — Hub pushes generated YAML to update controller.yaml
|
||||
case path == "/config/apply" && req.Method == http.MethodPost:
|
||||
r.configApply(w, req)
|
||||
|
||||
// GET /api/config/hash — return current config file hash
|
||||
case path == "/config/hash" && req.Method == http.MethodGet:
|
||||
r.configHash(w, req)
|
||||
|
||||
// GET /api/stacks/{name}/deploy-fields
|
||||
case hasSuffix(path, "/deploy-fields") && req.Method == http.MethodGet:
|
||||
r.getDeployFields(w, req, extractName(path, "/deploy-fields"))
|
||||
@@ -901,6 +913,55 @@ func (r *Router) selfupdateTrigger(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Frissítés elindítva"})
|
||||
}
|
||||
|
||||
// --- Config apply handler ---
|
||||
|
||||
func (r *Router) configApply(w http.ResponseWriter, req *http.Request) {
|
||||
// Read YAML body (limit to 1MB)
|
||||
body, err := io.ReadAll(io.LimitReader(req.Body, 1<<20))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "failed to read request body"})
|
||||
return
|
||||
}
|
||||
if len(body) == 0 {
|
||||
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "empty request body"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate it's parseable YAML by attempting to load it
|
||||
if _, err := config.LoadFromBytes(body); err != nil {
|
||||
r.logger.Printf("[API] Config apply rejected: invalid YAML: %v", err)
|
||||
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: fmt.Sprintf("invalid config YAML: %v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
// Atomic write: write to .tmp, then rename
|
||||
tmpPath := r.configPath + ".tmp"
|
||||
if err := os.WriteFile(tmpPath, body, 0644); err != nil {
|
||||
r.logger.Printf("[ERROR] Config apply: failed to write temp file: %v", err)
|
||||
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: "failed to write config"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.Rename(tmpPath, r.configPath); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
r.logger.Printf("[ERROR] Config apply: failed to rename temp file: %v", err)
|
||||
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: "failed to apply config"})
|
||||
return
|
||||
}
|
||||
|
||||
r.logger.Printf("[API] Config applied from Hub (%d bytes), restart needed to take effect", len(body))
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Config applied. Restart controller to apply changes."})
|
||||
}
|
||||
|
||||
func (r *Router) configHash(w http.ResponseWriter, _ *http.Request) {
|
||||
hash, err := config.FileHash(r.configPath)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: "failed to read config"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]string{"hash": hash, "path": filepath.Base(r.configPath)}})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -170,6 +172,30 @@ func Load(path string) (*Config, error) {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// LoadFromBytes parses YAML config from raw bytes (for validation without file I/O).
|
||||
func LoadFromBytes(data []byte) (*Config, error) {
|
||||
expanded := os.ExpandEnv(string(data))
|
||||
cfg := &Config{}
|
||||
if err := yaml.Unmarshal([]byte(expanded), cfg); err != nil {
|
||||
return nil, fmt.Errorf("parsing config: %w", err)
|
||||
}
|
||||
applyDefaults(cfg)
|
||||
if err := validate(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// FileHash returns the SHA256 hex digest of the config file at the given path.
|
||||
func FileHash(path string) (string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
h := sha256.Sum256(data)
|
||||
return hex.EncodeToString(h[:]), nil
|
||||
}
|
||||
|
||||
func applyDefaults(cfg *Config) {
|
||||
d := func(val *string, def string) {
|
||||
if *val == "" {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -19,6 +22,7 @@ import (
|
||||
// BuildReport collects current state from all subsystems and returns a Report.
|
||||
func BuildReport(
|
||||
cfg *config.Config,
|
||||
configPath string,
|
||||
stackMgr *stacks.Manager,
|
||||
backupMgr *backup.Manager,
|
||||
cpuCollector *system.CPUCollector,
|
||||
@@ -39,6 +43,14 @@ func BuildReport(
|
||||
r.ControllerURL = fmt.Sprintf("https://felhom.%s", cfg.Customer.Domain)
|
||||
}
|
||||
|
||||
// Config hash for Hub comparison
|
||||
if configPath != "" {
|
||||
if data, err := os.ReadFile(configPath); err == nil {
|
||||
h := sha256.Sum256(data)
|
||||
r.ConfigHash = hex.EncodeToString(h[:])
|
||||
}
|
||||
}
|
||||
|
||||
// System info
|
||||
staticInfo := metrics.GetStaticInfo()
|
||||
hddPath := cfg.Paths.HDDPath
|
||||
|
||||
@@ -9,6 +9,7 @@ type Report struct {
|
||||
CustomerName string `json:"customer_name"`
|
||||
ControllerVersion string `json:"controller_version"`
|
||||
ControllerURL string `json:"controller_url,omitempty"`
|
||||
ConfigHash string `json:"config_hash,omitempty"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
ReportingDisabled bool `json:"reporting_disabled,omitempty"`
|
||||
System SystemReport `json:"system"`
|
||||
|
||||
Reference in New Issue
Block a user