hub v0.3.1: Config diff display + pull config

Replace broken SHA256 hash comparison with value-based YAML comparison.
Add "Show Diff" button showing per-key differences in a color-coded table.
Add "Pull Config" to import controller's current config into the Hub.
New endpoints: GET /customers/{id}/config-diff, POST /customers/{id}/pull-config.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-20 19:26:53 +01:00
parent 3217cb4751
commit 11428659d1
6 changed files with 501 additions and 34 deletions
+383 -31
View File
@@ -1,8 +1,7 @@
package web
import (
"crypto/sha256"
"encoding/hex"
"encoding/base64"
"encoding/json"
"fmt"
"io"
@@ -14,6 +13,7 @@ import (
"gitea.dooplex.hu/admin/felhom-hub/internal/configgen"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
"gopkg.in/yaml.v3"
)
var validCustomerID = regexp.MustCompile(`^[a-zA-Z0-9.\-]+$`)
@@ -182,31 +182,29 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
}
}
// Config hash comparison
var controllerConfigHash string
var hubConfigHash string
// Config value comparison (parse both YAMLs, compare actual values)
var configSyncStatus string // "in_sync", "mismatch", "unknown"
if customer != nil && cfg != nil {
var rptHash struct {
ConfigHash string `json:"config_hash"`
}
json.Unmarshal([]byte(customer.ReportJSON), &rptHash)
controllerConfigHash = rptHash.ConfigHash
if controllerConfigHash != "" {
// Generate Hub-side YAML and compute its hash
templateYAML := defaultControllerTemplate
if s.templateFetcher != nil {
templateYAML = s.templateFetcher.Template()
}
if yamlOutput, err := configgen.Generate(templateYAML, cfg); err == nil {
h := sha256.Sum256([]byte(yamlOutput))
hubConfigHash = hex.EncodeToString(h[:])
if hubConfigHash == controllerConfigHash {
configSyncStatus = "in_sync"
} else {
configSyncStatus = "mismatch"
var configDiffCount int
if cfg != nil {
infraData, _ := s.store.GetInfraBackup(customerID)
if infraData != nil {
controllerYAML := extractControllerYAML(infraData)
if controllerYAML != "" {
templateYAML := defaultControllerTemplate
if s.templateFetcher != nil {
templateYAML = s.templateFetcher.Template()
}
if hubYAML, err := configgen.Generate(templateYAML, cfg); err == nil {
diffs := compareYAMLValues(hubYAML, controllerYAML)
configDiffCount = len(diffs)
if configDiffCount == 0 {
configSyncStatus = "in_sync"
} else {
configSyncStatus = "mismatch"
}
}
} else {
configSyncStatus = "unknown"
}
} else {
configSyncStatus = "unknown"
@@ -264,9 +262,8 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
UpdateAvailable bool
ControllerURL string
ConfigSyncStatus string // "in_sync", "mismatch", "unknown"
ControllerConfigHash string
HubConfigHash string
ConfigSyncStatus string // "in_sync", "mismatch", "unknown"
ConfigDiffCount int
InfraBackup *store.InfraBackupMeta
InfraBackupAge string
@@ -301,9 +298,8 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
UpdateAvailable: updateAvailable,
ControllerURL: controllerURL,
ConfigSyncStatus: configSyncStatus,
ControllerConfigHash: controllerConfigHash,
HubConfigHash: hubConfigHash,
ConfigSyncStatus: configSyncStatus,
ConfigDiffCount: configDiffCount,
InfraBackup: infraMeta,
InfraBackupAge: infraBackupAge,
@@ -724,3 +720,359 @@ func buildConfigJSON(r *http.Request) string {
data, _ := json.Marshal(overrides)
return string(data)
}
// --- Config comparison helpers ---
// extractControllerYAML decodes the controller.yaml from an infra backup JSON payload.
func extractControllerYAML(infraData []byte) string {
var parsed struct {
ControllerConfigB64 string `json:"controller_config_b64"`
}
if err := json.Unmarshal(infraData, &parsed); err != nil || parsed.ControllerConfigB64 == "" {
return ""
}
data, err := base64.StdEncoding.DecodeString(parsed.ControllerConfigB64)
if err != nil {
return ""
}
return string(data)
}
// volatileKeys are YAML keys ignored during config comparison (always differ).
var volatileKeys = map[string]bool{
"web.session_secret": true,
}
// sensitiveKeyParts are substrings that indicate a value should be masked in diff output.
var sensitiveKeyParts = []string{"token", "password", "secret", "api_key"}
// flattenYAML recursively flattens a nested map into dot-separated key-value pairs.
func flattenYAML(m map[string]interface{}, prefix string) map[string]string {
result := make(map[string]string)
for k, v := range m {
key := k
if prefix != "" {
key = prefix + "." + k
}
switch val := v.(type) {
case map[string]interface{}:
for fk, fv := range flattenYAML(val, key) {
result[fk] = fv
}
case []interface{}:
for i, item := range val {
itemKey := fmt.Sprintf("%s.%d", key, i)
if sub, ok := item.(map[string]interface{}); ok {
for fk, fv := range flattenYAML(sub, itemKey) {
result[fk] = fv
}
} else {
result[itemKey] = fmt.Sprintf("%v", item)
}
}
default:
result[key] = fmt.Sprintf("%v", v)
}
}
return result
}
// configDiff represents a single key-value difference between two configs.
type configDiff struct {
Key string `json:"key"`
HubValue string `json:"hub"`
CtrlValue string `json:"controller"`
Status string `json:"status"` // "changed", "hub_only", "controller_only"
}
// compareYAMLValues parses two YAML strings and returns their value differences.
// Volatile keys (e.g., web.session_secret) are excluded.
func compareYAMLValues(hubYAML, controllerYAML string) []configDiff {
var hubMap, ctrlMap map[string]interface{}
yaml.Unmarshal([]byte(hubYAML), &hubMap)
yaml.Unmarshal([]byte(controllerYAML), &ctrlMap)
hubFlat := flattenYAML(hubMap, "")
ctrlFlat := flattenYAML(ctrlMap, "")
var diffs []configDiff
// Keys in hub but different/missing in controller
for k, hv := range hubFlat {
if volatileKeys[k] {
continue
}
cv, exists := ctrlFlat[k]
if !exists {
if hv != "" && hv != "<nil>" {
diffs = append(diffs, configDiff{Key: k, HubValue: hv, CtrlValue: "(not set)", Status: "hub_only"})
}
} else if hv != cv {
diffs = append(diffs, configDiff{Key: k, HubValue: hv, CtrlValue: cv, Status: "changed"})
}
}
// Keys in controller but missing in hub
for k, cv := range ctrlFlat {
if volatileKeys[k] {
continue
}
if _, exists := hubFlat[k]; !exists {
if cv != "" && cv != "<nil>" {
diffs = append(diffs, configDiff{Key: k, HubValue: "(not set)", CtrlValue: cv, Status: "controller_only"})
}
}
}
sort.Slice(diffs, func(i, j int) bool { return diffs[i].Key < diffs[j].Key })
return diffs
}
// maskSensitive masks a value if the key contains sensitive substrings.
func maskSensitive(key, value string) string {
if value == "" || value == "(not set)" {
return value
}
keyLower := strings.ToLower(key)
for _, part := range sensitiveKeyParts {
if strings.Contains(keyLower, part) {
if len(value) > 8 {
return "***" + value[len(value)-4:]
}
return "***"
}
}
return value
}
// handleConfigDiff returns a JSON diff between Hub-generated and controller's live config.
func (s *Server) handleConfigDiff(w http.ResponseWriter, r *http.Request, customerID string) {
cfg, err := s.store.GetCustomerConfig(customerID)
if err != nil || cfg == nil {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": "No config found"})
return
}
// Get controller URL
customer, _ := s.store.GetCustomer(customerID)
controllerURL := ""
if customer != nil {
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")
json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": "Controller URL not available"})
return
}
// Fetch live config from controller
fetchURL := controllerURL + "/api/config"
req, err := http.NewRequest("GET", fetchURL, nil)
if err != nil {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": "Failed to create request"})
return
}
req.Header.Set("Authorization", "Bearer "+s.apiKey)
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": fmt.Sprintf("Controller unreachable: %v", err)})
return
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": fmt.Sprintf("Controller returned HTTP %d", resp.StatusCode)})
return
}
controllerYAML, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": "Failed to read controller response"})
return
}
// Generate Hub YAML
templateYAML := defaultControllerTemplate
if s.templateFetcher != nil {
templateYAML = s.templateFetcher.Template()
}
hubYAML, err := configgen.Generate(templateYAML, cfg)
if err != nil {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": "Failed to generate Hub config"})
return
}
// Compare
diffs := compareYAMLValues(hubYAML, string(controllerYAML))
// Mask sensitive values
for i := range diffs {
diffs[i].HubValue = maskSensitive(diffs[i].Key, diffs[i].HubValue)
diffs[i].CtrlValue = maskSensitive(diffs[i].Key, diffs[i].CtrlValue)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"ok": true,
"in_sync": len(diffs) == 0,
"diff_count": len(diffs),
"diffs": diffs,
})
}
// handlePullConfig fetches the controller's live config and imports it into the Hub.
func (s *Server) handlePullConfig(w http.ResponseWriter, r *http.Request, customerID string) {
cfg, err := s.store.GetCustomerConfig(customerID)
if err != nil || cfg == nil {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": "No config found"})
return
}
// Get controller URL
customer, _ := s.store.GetCustomer(customerID)
controllerURL := ""
if customer != nil {
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")
json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": "Controller URL not available"})
return
}
// Fetch live config from controller
fetchURL := controllerURL + "/api/config"
req, err := http.NewRequest("GET", fetchURL, nil)
if err != nil {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": "Failed to create request"})
return
}
req.Header.Set("Authorization", "Bearer "+s.apiKey)
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": fmt.Sprintf("Controller unreachable: %v", err)})
return
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": fmt.Sprintf("Controller returned HTTP %d", resp.StatusCode)})
return
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": "Failed to read controller response"})
return
}
// Parse controller's YAML
var parsed map[string]interface{}
if err := yaml.Unmarshal(body, &parsed); err != nil {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": "Failed to parse controller YAML"})
return
}
// Extract identity fields
if customer, ok := parsed["customer"].(map[string]interface{}); ok {
if v, ok := customer["name"].(string); ok && v != "" {
cfg.CustomerName = v
}
if v, ok := customer["domain"].(string); ok && v != "" {
cfg.Domain = v
}
if v, ok := customer["email"].(string); ok && v != "" {
cfg.Email = v
}
}
// Build config_json from override fields
overrides := make(map[string]interface{})
// Infrastructure tokens
if infra, ok := parsed["infrastructure"].(map[string]interface{}); ok {
infraOverrides := make(map[string]interface{})
if v, ok := infra["cf_tunnel_token"].(string); ok && v != "" {
infraOverrides["cf_tunnel_token"] = v
}
if v, ok := infra["cf_api_token"].(string); ok && v != "" {
infraOverrides["cf_api_token"] = v
}
if len(infraOverrides) > 0 {
overrides["infrastructure"] = infraOverrides
}
}
// Git credentials
if git, ok := parsed["git"].(map[string]interface{}); ok {
gitOverrides := make(map[string]interface{})
if v, ok := git["username"].(string); ok && v != "" {
gitOverrides["username"] = v
}
if v, ok := git["token"].(string); ok && v != "" {
gitOverrides["token"] = v
}
if len(gitOverrides) > 0 {
overrides["git"] = gitOverrides
}
}
// Monitoring UUIDs (legacy but still imported)
if monitoring, ok := parsed["monitoring"].(map[string]interface{}); ok {
if uuids, ok := monitoring["ping_uuids"].(map[string]interface{}); ok {
uuidOverrides := make(map[string]interface{})
for _, key := range []string{"heartbeat", "system_health", "db_dump", "backup", "backup_integrity"} {
if v, ok := uuids[key].(string); ok && v != "" {
uuidOverrides[key] = v
}
}
if len(uuidOverrides) > 0 {
overrides["monitoring"] = map[string]interface{}{"ping_uuids": uuidOverrides}
}
}
}
configJSON, _ := json.Marshal(overrides)
cfg.ConfigJSON = string(configJSON)
if err := s.store.SaveCustomerConfig(cfg); err != nil {
s.logger.Printf("[ERROR] Pull config: failed to update config for %s: %v", customerID, err)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": "Failed to save config"})
return
}
s.logger.Printf("[INFO] Config pulled from controller for %s", customerID)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Config imported from controller"})
}