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"})
}
+16
View File
@@ -114,6 +114,22 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
case strings.HasPrefix(path, "/customers/") && strings.HasSuffix(path, "/pull-config"):
customerID := strings.TrimPrefix(path, "/customers/")
customerID = strings.TrimSuffix(customerID, "/pull-config")
if r.Method == http.MethodPost {
s.handlePullConfig(w, r, customerID)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
case strings.HasPrefix(path, "/customers/") && strings.HasSuffix(path, "/config-diff"):
customerID := strings.TrimPrefix(path, "/customers/")
customerID = strings.TrimSuffix(customerID, "/config-diff")
if r.Method == http.MethodGet {
s.handleConfigDiff(w, r, customerID)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
case strings.HasPrefix(path, "/customers/") && strings.HasSuffix(path, "/create-config"):
customerID := strings.TrimPrefix(path, "/customers/")
customerID = strings.TrimSuffix(customerID, "/create-config")
@@ -375,11 +375,13 @@
<span class="label">Config Sync</span>
<span class="value">
{{if eq .ConfigSyncStatus "in_sync"}}<span style="color: #22c55e;">&#x2713; In sync</span>
{{else if eq .ConfigSyncStatus "mismatch"}}<span style="color: #f59e0b;">&#x26A0; Config mismatch — Hub config differs from controller</span>
{{else}}<span style="color: #94a3b8;">Unknown — controller not reporting config hash (update controller)</span>
{{else if eq .ConfigSyncStatus "mismatch"}}<span style="color: #f59e0b;">&#x26A0; Config mismatch — {{.ConfigDiffCount}} difference{{if gt .ConfigDiffCount 1}}s{{end}}</span>
<button class="btn btn-outline btn-sm" style="margin-left: 0.5em; font-size: 0.8em;" onclick="showConfigDiff('{{.CustomerID}}')">Show Diff</button>
{{else}}<span style="color: #94a3b8;">Unknown — no infra backup available yet</span>
{{end}}
</span>
</div>
<div id="config-diff-container" style="display: none; margin-top: 0.5rem;"></div>
{{end}}
<div style="margin-top: 0.75em; display: flex; gap: 0.5rem; flex-wrap: wrap;">
{{if and .ControllerURL .UpdateAvailable}}
@@ -395,6 +397,9 @@
<button class="btn btn-outline btn-sm" id="btn-push-config" onclick="pushConfig('{{.CustomerID}}')">
Push Config
</button>
<button class="btn btn-outline btn-sm" id="btn-pull-config" onclick="pullConfig('{{.CustomerID}}')">
Pull Config
</button>
{{end}}
<span id="action-msg" style="margin-left: 0.5em; display: none;"></span>
</div>
@@ -610,6 +615,83 @@
});
}
function pullConfig(customerID) {
if (!confirm('Import the controller\'s current config into the Hub?\n\nThis updates the Hub\'s stored configuration to match the controller.')) return;
var btn = document.getElementById('btn-pull-config');
var msg = document.getElementById('action-msg');
btn.disabled = true;
btn.textContent = 'Pulling...';
msg.style.display = 'none';
fetch('/customers/' + customerID + '/pull-config', {method: 'POST'})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.ok) {
msg.textContent = 'Config imported successfully';
msg.style.display = 'inline';
msg.style.color = '#4ade80';
setTimeout(function() { location.reload(); }, 1500);
} else {
msg.textContent = data.error || 'Failed';
msg.style.display = 'inline';
msg.style.color = '#f87171';
}
btn.disabled = false;
btn.textContent = 'Pull Config';
})
.catch(function() {
msg.textContent = 'Connection error';
msg.style.display = 'inline';
msg.style.color = '#f87171';
btn.disabled = false;
btn.textContent = 'Pull Config';
});
}
function showConfigDiff(customerID) {
var container = document.getElementById('config-diff-container');
if (container.style.display !== 'none') {
container.style.display = 'none';
return;
}
container.innerHTML = '<p class="text-muted">Loading diff...</p>';
container.style.display = 'block';
fetch('/customers/' + customerID + '/config-diff')
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.ok) {
container.innerHTML = '<p style="color: #f87171;">' + (data.error || 'Failed to load diff') + '</p>';
return;
}
if (data.in_sync) {
container.innerHTML = '<p style="color: #22c55e;">Configs are in sync (no differences found).</p>';
return;
}
var html = '<table class="data-table" style="font-size: 0.85em;">';
html += '<thead><tr><th>Key</th><th>Hub Value</th><th>Controller Value</th><th>Status</th></tr></thead><tbody>';
data.diffs.forEach(function(d) {
var cls = 'diff-' + d.status;
var statusLabel = d.status === 'changed' ? 'Changed' : d.status === 'hub_only' ? 'Hub only' : 'Controller only';
html += '<tr class="' + cls + '">';
html += '<td style="font-family: monospace; white-space: nowrap;">' + escHtml(d.key) + '</td>';
html += '<td style="word-break: break-all;">' + escHtml(d.hub) + '</td>';
html += '<td style="word-break: break-all;">' + escHtml(d.controller) + '</td>';
html += '<td>' + statusLabel + '</td>';
html += '</tr>';
});
html += '</tbody></table>';
container.innerHTML = html;
})
.catch(function() {
container.innerHTML = '<p style="color: #f87171;">Failed to fetch diff from controller.</p>';
});
}
function escHtml(s) {
var div = document.createElement('div');
div.appendChild(document.createTextNode(s));
return div.innerHTML;
}
{{if .HasConfig}}
// Load YAML preview
fetch('/configs/{{.CustomerID}}/preview')
+5
View File
@@ -613,6 +613,11 @@ code {
color: #3b82f6;
}
/* Config diff table */
.diff-changed td { color: #f59e0b; }
.diff-hub_only td { color: #3b82f6; }
.diff-controller_only td { color: #fb923c; }
/* Responsive */
@media (max-width: 768px) {
.container { padding: 1rem; }