slice 8C Phase B.2 + C.1/C.2: retire disk subsystem + rewire disk mgmt to agent
Retired (~12.3k LOC): internal/storage/* (scan/format/attach/migrate/safety), backup restic/crossdrive/restore_drives/disk_layout/local_infra/restore_scan/ paths + restore_app, report/infra_backup*/infra_pull, setup/scanner, monitor/watchdog+pinger, web/storage_handlers+handler_restore. Surgically split backup.Manager to app-data only (DB dumps + volume tars + app restore; dropped restic + cross-drive + snapshot history). Fixed router/main/web wiring. Added agent-backed disk API (web/agent_disk_handlers.go): /api/disks list/ assign/eject/format proxying agentapi; data-bearing format refusal -> HTTP 409 'operator authorization required'. report/config_pull.go keeps the setup fresh-install config download. go build + go test green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -6,8 +6,6 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||
@@ -241,32 +239,16 @@ func buildBackupReport(cfg *config.Config, backupMgr *backup.Manager) BackupRepo
|
||||
return br
|
||||
}
|
||||
|
||||
// Disk-tier backup (restic snapshots, integrity check, repo stats) has moved to
|
||||
// the host agent (slice 8C). The controller report now covers only app-data backup
|
||||
// (database dumps); restic/snapshot/integrity fields are left zero.
|
||||
nextDBDump := scheduler.NextDailyRun(cfg.Backup.DBDumpSchedule)
|
||||
nextBackup := scheduler.NextDailyRun(cfg.Backup.ResticSchedule)
|
||||
status := backupMgr.GetFullStatus(nextDBDump, nextBackup)
|
||||
status := backupMgr.GetFullStatus(nextDBDump)
|
||||
|
||||
if status.LastDBDump != nil {
|
||||
t := status.LastDBDump.LastRun
|
||||
br.LastDBDump = &t
|
||||
}
|
||||
if status.LastBackup != nil {
|
||||
t := status.LastBackup.LastRun
|
||||
br.LastSnapshot = &t
|
||||
}
|
||||
if status.RepoStats != nil {
|
||||
br.SnapshotCount = status.RepoStats.SnapshotCount
|
||||
br.RepoSizeMB = parseSizeToMB(status.RepoStats.TotalSize)
|
||||
}
|
||||
if !status.LastCheckTime.IsZero() {
|
||||
t := status.LastCheckTime
|
||||
br.LastIntegrityCheck = &t
|
||||
}
|
||||
br.IntegrityOK = status.LastCheckOK
|
||||
|
||||
// Include restic password for hub-side disaster recovery
|
||||
if pw, err := backupMgr.GetResticPassword(); err == nil {
|
||||
br.ResticPassword = pw
|
||||
}
|
||||
|
||||
return br
|
||||
}
|
||||
@@ -296,31 +278,3 @@ func buildStacksReport(stackMgr *stacks.Manager) StacksReport {
|
||||
return sr
|
||||
}
|
||||
|
||||
// parseSizeToMB parses a formatted size string like "1.5 GB", "512.0 MB" into MB.
|
||||
func parseSizeToMB(s string) int64 {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
parts := strings.Fields(s)
|
||||
if len(parts) != 2 {
|
||||
return 0
|
||||
}
|
||||
|
||||
val, err := strconv.ParseFloat(parts[0], 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
switch strings.ToUpper(parts[1]) {
|
||||
case "GB":
|
||||
return int64(val * 1024)
|
||||
case "MB":
|
||||
return int64(val)
|
||||
case "KB":
|
||||
return int64(val / 1024)
|
||||
default:
|
||||
return int64(val)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config-pull error types for setup-wizard UI display.
|
||||
//
|
||||
// These (and PullConfig) were previously part of infra_pull.go alongside the
|
||||
// disk-tier DR-recovery (PullRecovery / infra-backup) client. Disk recovery moved
|
||||
// to the host agent in slice 8C; only the fresh-install config download survives
|
||||
// here, so it lives in this slimmed file.
|
||||
var (
|
||||
ErrHubUnreachable = errors.New("hub unreachable")
|
||||
ErrAuthFailed = errors.New("authentication failed")
|
||||
ErrNotFound = errors.New("customer not found")
|
||||
ErrHubError = errors.New("hub error")
|
||||
)
|
||||
|
||||
// PullConfig fetches a generated controller.yaml from the Hub config endpoint.
|
||||
// Auth: X-Retrieval-Password header. Used by the setup wizard's fresh-install flow.
|
||||
func PullConfig(hubURL, customerID, retrievalPassword string) (string, error) {
|
||||
url := strings.TrimRight(hubURL, "/") + "/api/v1/config/" + customerID
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: %v", ErrHubError, err)
|
||||
}
|
||||
req.Header.Set("X-Retrieval-Password", retrievalPassword)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: %v", ErrHubUnreachable, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
switch resp.StatusCode {
|
||||
case http.StatusOK:
|
||||
// success
|
||||
case http.StatusUnauthorized:
|
||||
return "", ErrAuthFailed
|
||||
case http.StatusNotFound:
|
||||
return "", ErrNotFound
|
||||
default:
|
||||
return "", fmt.Errorf("%w: HTTP %d", ErrHubError, resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 1MB limit
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: reading response: %v", ErrHubError, err)
|
||||
}
|
||||
|
||||
return string(body), nil
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// InfraBackup is the payload pushed to the Hub for disaster recovery.
|
||||
type InfraBackup struct {
|
||||
CustomerID string `json:"customer_id"`
|
||||
Domain string `json:"domain"`
|
||||
ControllerVersion string `json:"controller_version"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
|
||||
ControllerConfigB64 string `json:"controller_config_b64"`
|
||||
SettingsJSONB64 string `json:"settings_json_b64,omitempty"`
|
||||
|
||||
DiskLayout backup.DiskLayout `json:"disk_layout"`
|
||||
DeployedStacks []InfraStack `json:"deployed_stacks"`
|
||||
|
||||
ResticPassword string `json:"restic_password,omitempty"`
|
||||
CrossDrivePassword string `json:"cross_drive_password,omitempty"`
|
||||
EncryptionKeyB64 string `json:"encryption_key_b64,omitempty"`
|
||||
}
|
||||
|
||||
// InfraStack identifies a deployed app for disaster recovery.
|
||||
// Note: AppYamlB64 contains encrypted secrets (ENC:... values).
|
||||
// The encryption key is also in this backup (EncryptionKeyB64).
|
||||
// This is intentional — the infra backup must be self-contained for DR.
|
||||
// Physical security of the backup media protects both.
|
||||
type InfraStack struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
HDDPath string `json:"hdd_path,omitempty"`
|
||||
NeedsHDD bool `json:"needs_hdd"`
|
||||
DockerComposeB64 string `json:"docker_compose_b64,omitempty"`
|
||||
AppYamlB64 string `json:"app_yaml_b64,omitempty"`
|
||||
FelhomYamlB64 string `json:"felhom_yaml_b64,omitempty"`
|
||||
}
|
||||
|
||||
// BuildInfraBackup collects all infrastructure state for Hub backup.
|
||||
func BuildInfraBackup(
|
||||
customerID, domain, version string,
|
||||
controllerYAMLPath string,
|
||||
settingsPath string,
|
||||
resticPasswordFile string,
|
||||
encryptionKeyFile string,
|
||||
systemDataPath string,
|
||||
sett *settings.Settings,
|
||||
stackProvider backup.StackDataProvider,
|
||||
logger *log.Logger,
|
||||
) (*InfraBackup, error) {
|
||||
ib := &InfraBackup{
|
||||
CustomerID: customerID,
|
||||
Domain: domain,
|
||||
ControllerVersion: version,
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
// Read and encode controller.yaml (critical — fail if unreadable)
|
||||
data, err := os.ReadFile(controllerYAMLPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading controller config %s: %w", controllerYAMLPath, err)
|
||||
}
|
||||
ib.ControllerConfigB64 = base64.StdEncoding.EncodeToString(data)
|
||||
|
||||
// Read and encode settings.json (important but non-fatal)
|
||||
if data, err := os.ReadFile(settingsPath); err == nil {
|
||||
ib.SettingsJSONB64 = base64.StdEncoding.EncodeToString(data)
|
||||
} else if !os.IsNotExist(err) {
|
||||
logger.Printf("[WARN] [report] Infra backup: could not read settings.json: %v", err)
|
||||
}
|
||||
|
||||
// Read primary restic password (important but non-fatal)
|
||||
if data, err := os.ReadFile(resticPasswordFile); err == nil {
|
||||
ib.ResticPassword = base64.StdEncoding.EncodeToString(data)
|
||||
} else if !os.IsNotExist(err) {
|
||||
logger.Printf("[WARN] [report] Infra backup: could not read restic password file: %v", err)
|
||||
}
|
||||
|
||||
// Read encryption key for app.yaml secrets (important but non-fatal)
|
||||
if encryptionKeyFile != "" {
|
||||
if data, err := os.ReadFile(encryptionKeyFile); err == nil {
|
||||
ib.EncryptionKeyB64 = base64.StdEncoding.EncodeToString(data)
|
||||
} else if !os.IsNotExist(err) {
|
||||
logger.Printf("[WARN] [report] Infra backup: could not read encryption key file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Collect disk layout from fstab + blkid
|
||||
ib.DiskLayout = collectDiskLayout(systemDataPath)
|
||||
|
||||
// Collect deployed stacks (including actual config files for DR)
|
||||
deployed := stackProvider.ListDeployedStacks()
|
||||
for _, s := range deployed {
|
||||
is := InfraStack{
|
||||
Name: s.Name,
|
||||
DisplayName: s.DisplayName,
|
||||
HDDPath: stackProvider.GetStackHDDPath(s.Name),
|
||||
NeedsHDD: s.NeedsHDD,
|
||||
}
|
||||
if composePath, ok := stackProvider.GetStackComposePath(s.Name); ok {
|
||||
stackDir := filepath.Dir(composePath)
|
||||
if data, err := os.ReadFile(filepath.Join(stackDir, "docker-compose.yml")); err == nil {
|
||||
is.DockerComposeB64 = base64.StdEncoding.EncodeToString(data)
|
||||
}
|
||||
if data, err := os.ReadFile(filepath.Join(stackDir, "app.yaml")); err == nil {
|
||||
is.AppYamlB64 = base64.StdEncoding.EncodeToString(data)
|
||||
}
|
||||
if data, err := os.ReadFile(filepath.Join(stackDir, ".felhom.yml")); err == nil {
|
||||
is.FelhomYamlB64 = base64.StdEncoding.EncodeToString(data)
|
||||
}
|
||||
}
|
||||
ib.DeployedStacks = append(ib.DeployedStacks, is)
|
||||
}
|
||||
if ib.DeployedStacks == nil {
|
||||
ib.DeployedStacks = []InfraStack{}
|
||||
}
|
||||
|
||||
logger.Printf("[INFO] [report] InfraBackup built successfully (stacks=%d)", len(ib.DeployedStacks))
|
||||
return ib, nil
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
//go:build linux
|
||||
|
||||
package report
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||
)
|
||||
|
||||
// collectDiskLayout reads /host-fstab and correlates with blkid/lsblk to build
|
||||
// the disk mount topology. Only includes data partitions (not root, boot, or swap).
|
||||
func collectDiskLayout(systemDataPath string) backup.DiskLayout {
|
||||
layout := backup.DiskLayout{}
|
||||
|
||||
fstabPath := "/host-fstab"
|
||||
if _, err := os.Stat(fstabPath); err != nil {
|
||||
fstabPath = "/etc/fstab"
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(fstabPath)
|
||||
if err != nil {
|
||||
return layout
|
||||
}
|
||||
|
||||
// Parse fstab into UUID-based entries and bind mount entries
|
||||
type fstabEntry struct {
|
||||
source string
|
||||
mountPoint string
|
||||
fsType string
|
||||
options string
|
||||
}
|
||||
|
||||
var uuidEntries []fstabEntry
|
||||
var bindEntries []fstabEntry
|
||||
|
||||
systemMounts := map[string]bool{"/": true, "/boot": true, "/boot/efi": true}
|
||||
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 4 {
|
||||
continue
|
||||
}
|
||||
source := fields[0]
|
||||
mountPoint := fields[1]
|
||||
fsType := fields[2]
|
||||
options := fields[3]
|
||||
|
||||
// Skip system mounts and swap
|
||||
if systemMounts[mountPoint] || fsType == "swap" {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(source, "UUID=") {
|
||||
uuidEntries = append(uuidEntries, fstabEntry{
|
||||
source: strings.TrimPrefix(source, "UUID="),
|
||||
mountPoint: mountPoint,
|
||||
fsType: fsType,
|
||||
options: options,
|
||||
})
|
||||
} else if fsType == "none" && strings.Contains(options, "bind") {
|
||||
bindEntries = append(bindEntries, fstabEntry{
|
||||
source: source,
|
||||
mountPoint: mountPoint,
|
||||
options: options,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Process UUID-based entries
|
||||
for _, e := range uuidEntries {
|
||||
dm := backup.DiskMount{
|
||||
UUID: e.source,
|
||||
MountPoint: e.mountPoint,
|
||||
FSType: e.fsType,
|
||||
FstabOptions: e.options,
|
||||
}
|
||||
|
||||
// Get label via blkid
|
||||
if out, err := exec.Command("blkid", "-o", "value", "-s", "LABEL", "-U", e.source).Output(); err == nil {
|
||||
dm.Label = strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
// Get size via lsblk (resolve UUID to device first)
|
||||
if devPath, err := exec.Command("blkid", "-U", e.source).Output(); err == nil {
|
||||
dev := strings.TrimSpace(string(devPath))
|
||||
if dev != "" {
|
||||
if out, err := exec.Command("lsblk", "-b", "-n", "-o", "SIZE", dev).Output(); err == nil {
|
||||
if sz, err := strconv.ParseInt(strings.TrimSpace(string(out)), 10, 64); err == nil {
|
||||
dm.SizeBytes = sz
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine role
|
||||
if e.mountPoint == systemDataPath {
|
||||
dm.Role = "system_data"
|
||||
} else {
|
||||
dm.Role = "hdd_storage"
|
||||
}
|
||||
|
||||
// Check for a corresponding bind mount
|
||||
for _, bind := range bindEntries {
|
||||
if strings.HasPrefix(bind.source, e.mountPoint+"/") {
|
||||
subdir := strings.TrimPrefix(bind.source, e.mountPoint+"/")
|
||||
dm.BindSubdir = subdir
|
||||
dm.RawMount = e.mountPoint
|
||||
dm.MountPoint = bind.mountPoint // the final user-facing mount point
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Get label from mount point basename as fallback
|
||||
if dm.Label == "" {
|
||||
if dm.RawMount != "" {
|
||||
dm.Label = filepath.Base(dm.RawMount)
|
||||
} else {
|
||||
dm.Label = filepath.Base(dm.MountPoint)
|
||||
}
|
||||
}
|
||||
|
||||
layout.Mounts = append(layout.Mounts, dm)
|
||||
}
|
||||
|
||||
return layout
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
//go:build !linux
|
||||
|
||||
package report
|
||||
|
||||
import "gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||
|
||||
// collectDiskLayout is a no-op on non-Linux platforms.
|
||||
// The controller only runs on Linux; this stub allows cross-compilation.
|
||||
func collectDiskLayout(systemDataPath string) backup.DiskLayout {
|
||||
return backup.DiskLayout{}
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Recovery pull error types for UI display.
|
||||
var (
|
||||
ErrHubUnreachable = errors.New("hub unreachable")
|
||||
ErrAuthFailed = errors.New("authentication failed")
|
||||
ErrNotFound = errors.New("customer not found")
|
||||
ErrHubError = errors.New("hub error")
|
||||
)
|
||||
|
||||
// BackupVersionSummary holds metadata about one backup version (from Hub).
|
||||
type BackupVersionSummary struct {
|
||||
ID int64 `json:"id"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
StackCount int `json:"stack_count"`
|
||||
DiskCount int `json:"disk_count"`
|
||||
StackNames []string `json:"stack_names,omitempty"`
|
||||
}
|
||||
|
||||
// RecoveryResponse is the combined config + infra backup from the Hub recovery endpoint.
|
||||
type RecoveryResponse struct {
|
||||
CustomerID string `json:"customer_id"`
|
||||
ConfigYAML string `json:"config_yaml"`
|
||||
InfraBackup *InfraBackup `json:"infra_backup"`
|
||||
HasInfraBackup bool `json:"has_infra_backup"`
|
||||
BackupVersions []BackupVersionSummary `json:"backup_versions,omitempty"`
|
||||
}
|
||||
|
||||
// PullRecovery fetches combined recovery data from the Hub (config + infra backup).
|
||||
// Auth: X-Retrieval-Password header.
|
||||
func PullRecovery(hubURL, customerID, retrievalPassword string) (*RecoveryResponse, error) {
|
||||
url := strings.TrimRight(hubURL, "/") + "/api/v1/recovery/" + customerID
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrHubError, err)
|
||||
}
|
||||
req.Header.Set("X-Retrieval-Password", retrievalPassword)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrHubUnreachable, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
switch resp.StatusCode {
|
||||
case http.StatusOK:
|
||||
// success, continue below
|
||||
case http.StatusUnauthorized:
|
||||
return nil, ErrAuthFailed
|
||||
case http.StatusNotFound:
|
||||
return nil, ErrNotFound
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: HTTP %d", ErrHubError, resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20)) // 10MB limit
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: reading response: %v", ErrHubError, err)
|
||||
}
|
||||
|
||||
var rr RecoveryResponse
|
||||
if err := json.Unmarshal(body, &rr); err != nil {
|
||||
return nil, fmt.Errorf("%w: parsing response: %v", ErrHubError, err)
|
||||
}
|
||||
|
||||
return &rr, nil
|
||||
}
|
||||
|
||||
// PullRecoveryVersion fetches recovery data for a specific backup version ID.
|
||||
func PullRecoveryVersion(hubURL, customerID, retrievalPassword string, versionID int64) (*RecoveryResponse, error) {
|
||||
url := strings.TrimRight(hubURL, "/") + "/api/v1/recovery/" + customerID + fmt.Sprintf("?version=%d", versionID)
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrHubError, err)
|
||||
}
|
||||
req.Header.Set("X-Retrieval-Password", retrievalPassword)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrHubUnreachable, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
switch resp.StatusCode {
|
||||
case http.StatusOK:
|
||||
// success
|
||||
case http.StatusUnauthorized:
|
||||
return nil, ErrAuthFailed
|
||||
case http.StatusNotFound:
|
||||
return nil, ErrNotFound
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: HTTP %d", ErrHubError, resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: reading response: %v", ErrHubError, err)
|
||||
}
|
||||
|
||||
var rr RecoveryResponse
|
||||
if err := json.Unmarshal(body, &rr); err != nil {
|
||||
return nil, fmt.Errorf("%w: parsing response: %v", ErrHubError, err)
|
||||
}
|
||||
|
||||
return &rr, nil
|
||||
}
|
||||
|
||||
// PullConfig fetches a generated controller.yaml from the Hub config endpoint.
|
||||
// Auth: X-Retrieval-Password header.
|
||||
func PullConfig(hubURL, customerID, retrievalPassword string) (string, error) {
|
||||
url := strings.TrimRight(hubURL, "/") + "/api/v1/config/" + customerID
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: %v", ErrHubError, err)
|
||||
}
|
||||
req.Header.Set("X-Retrieval-Password", retrievalPassword)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: %v", ErrHubUnreachable, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
switch resp.StatusCode {
|
||||
case http.StatusOK:
|
||||
// success
|
||||
case http.StatusUnauthorized:
|
||||
return "", ErrAuthFailed
|
||||
case http.StatusNotFound:
|
||||
return "", ErrNotFound
|
||||
default:
|
||||
return "", fmt.Errorf("%w: HTTP %d", ErrHubError, resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 1MB limit
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: reading response: %v", ErrHubError, err)
|
||||
}
|
||||
|
||||
return string(body), nil
|
||||
}
|
||||
|
||||
// PullInfraBackup fetches the infrastructure backup from the Hub.
|
||||
// Returns nil, nil if no backup exists for this customer.
|
||||
func PullInfraBackup(hubURL, apiKey, customerID string) (*InfraBackup, error) {
|
||||
url := strings.TrimRight(hubURL, "/") + "/api/v1/infra-backup/" + customerID
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if apiKey != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hub request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, nil // no backup for this customer
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("hub returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 5<<20)) // 5MB limit
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading response: %w", err)
|
||||
}
|
||||
|
||||
var ib InfraBackup
|
||||
if err := json.Unmarshal(body, &ib); err != nil {
|
||||
return nil, fmt.Errorf("parsing infra backup: %w", err)
|
||||
}
|
||||
|
||||
return &ib, nil
|
||||
}
|
||||
Reference in New Issue
Block a user