package backup import ( "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "log" "os" "path/filepath" ) // MaxSchemaVersion is the highest infra backup schema version this controller can read. const MaxSchemaVersion = 1 // InfraMetadata is the lightweight metadata file written alongside backup.json. type InfraMetadata struct { SchemaVersion int `json:"schema_version"` Timestamp string `json:"timestamp"` CustomerID string `json:"customer_id"` ControllerVersion string `json:"controller_version"` Checksum string `json:"checksum"` // SHA256 hex of backup.json } // WriteLocalInfraBackup writes the infra backup to .felhom-infra-backup/ on each drive. // Individual drive failures are logged but not returned — the function is best-effort. func WriteLocalInfraBackup(backupJSON []byte, customerID, controllerVersion, timestamp string, drives []string, logger *log.Logger) { if len(drives) == 0 { logger.Printf("[DEBUG] No drives configured for local infra backup") return } // Compute checksum of backup data hash := sha256.Sum256(backupJSON) checksum := hex.EncodeToString(hash[:]) meta := InfraMetadata{ SchemaVersion: 1, Timestamp: timestamp, CustomerID: customerID, ControllerVersion: controllerVersion, Checksum: checksum, } metaJSON, err := json.MarshalIndent(meta, "", " ") if err != nil { logger.Printf("[ERROR] Local infra backup: failed to marshal metadata: %v", err) return } written := 0 for _, drive := range drives { dir := InfraBackupDir(drive) if err := writeInfraToDir(dir, backupJSON, metaJSON); err != nil { logger.Printf("[WARN] Local infra backup: failed to write to %s: %v", drive, err) continue } written++ } logger.Printf("[INFO] Local infra backup written to %d/%d drive(s)", written, len(drives)) } // writeInfraToDir writes backup.json and metadata.json atomically to the given directory. func writeInfraToDir(dir string, backupData, metaData []byte) error { if err := os.MkdirAll(dir, 0700); err != nil { return fmt.Errorf("creating dir: %w", err) } // Write backup.json atomically backupPath := filepath.Join(dir, "backup.json") if err := atomicWrite(backupPath, backupData, 0600); err != nil { return fmt.Errorf("writing backup.json: %w", err) } // Write metadata.json atomically metaPath := filepath.Join(dir, "metadata.json") if err := atomicWrite(metaPath, metaData, 0600); err != nil { return fmt.Errorf("writing metadata.json: %w", err) } return nil } // atomicWrite writes data to a .tmp file then renames to the target path. func atomicWrite(path string, data []byte, perm os.FileMode) error { tmp := path + ".tmp" if err := os.WriteFile(tmp, data, perm); err != nil { os.Remove(tmp) return err } if err := os.Rename(tmp, path); err != nil { os.Remove(tmp) return err } return nil } // ReadLocalInfraBackup reads and validates an infra backup from a mount point. // Returns the raw backup JSON, metadata, and any error. func ReadLocalInfraBackup(mountPath string) ([]byte, *InfraMetadata, error) { dir := InfraBackupDir(mountPath) // Read metadata metaPath := filepath.Join(dir, "metadata.json") metaData, err := os.ReadFile(metaPath) if err != nil { return nil, nil, fmt.Errorf("reading metadata.json: %w", err) } var meta InfraMetadata if err := json.Unmarshal(metaData, &meta); err != nil { return nil, nil, fmt.Errorf("parsing metadata.json: %w", err) } // Check schema version if meta.SchemaVersion > MaxSchemaVersion { return nil, &meta, fmt.Errorf("backup schema version %d is newer than supported version %d — upgrade the controller", meta.SchemaVersion, MaxSchemaVersion) } // Read backup data backupPath := filepath.Join(dir, "backup.json") backupData, err := os.ReadFile(backupPath) if err != nil { return nil, &meta, fmt.Errorf("reading backup.json: %w", err) } // Verify checksum hash := sha256.Sum256(backupData) actual := hex.EncodeToString(hash[:]) if actual != meta.Checksum { return nil, &meta, fmt.Errorf("checksum mismatch: expected %s, got %s", meta.Checksum, actual) } return backupData, &meta, nil }