# TASK: Phase C — Storage Initialization, Data Migration & Startup Fixes **Version target:** controller 0.11.0 **Repo:** `deploy-felhom-compose` (controller) ## Overview Three features in this phase: 1. **Startup ping + hub report** — Controller should announce itself immediately on start, not wait 5–15 minutes for the first scheduler tick 2. **Storage initialization** — Detect unformatted/unmounted disks, format (ext4), mount, and register as storage path — all from the web UI 3. **Data migration** — Per-app "Mozgatás" button to move app data between storage paths with rsync + progress --- ## 0. Startup Ping & Hub Report (Quick Fix) ### 0.1 Problem After controller starts (e.g., after `docker restart felhom-controller` or system reboot), the first heartbeat fires after 5 minutes, first system_health after 5 minutes, and first hub report after 15 minutes. During this gap, Healthchecks shows stale "Last Ping: X minutes ago" and hub has no fresh data. ### 0.2 Fix: Fire initial pings + report immediately after scheduler starts Add to `main.go` after `sched.Start(ctx)`, inside a goroutine (non-blocking): ```go // Fire startup pings + hub report immediately (don't wait for first scheduler tick) go func() { time.Sleep(5 * time.Second) // Let all subsystems fully initialize // Heartbeat ping pinger.Ping(cfg.Monitoring.PingUUIDs.Heartbeat, "startup") logger.Println("[INFO] Startup heartbeat ping sent") // System health ping healthReport := monitor.RunHealthCheck(cfg, cpuCollector, sett.GetStoragePaths()) body := healthReport.FormatMessage() healthUUID := cfg.Monitoring.PingUUIDs.SystemHealth if healthReport.Status == "fail" { pinger.Fail(healthUUID, body) } else { pinger.Ping(healthUUID, body) } logger.Printf("[INFO] Startup health ping sent (status: %s)", healthReport.Status) // Hub report if cfg.Hub.Enabled && cfg.Hub.URL != "" { pusher := report.NewPusher(&cfg.Hub, logger) r := report.BuildReport(cfg, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths()) if err := pusher.Push(r); err != nil { logger.Printf("[WARN] Startup hub report failed: %v", err) } else { logger.Println("[INFO] Startup hub report sent") } } }() ``` **Note:** The existing `go func()` that runs `alertMgr.Refresh()` at startup already does a health check but doesn't ping Healthchecks or hub. Merge this logic or add alongside it. The 5-second delay gives Docker, metrics, and backup subsystems time to initialize. ### 0.3 Reuse existing pusher instance The hub-report scheduler task already creates a `pusher` — consider creating it once at init and reusing: ```go var pusher *report.Pusher if cfg.Hub.Enabled && cfg.Hub.URL != "" { pusher = report.NewPusher(&cfg.Hub, logger) sched.Every("hub-report", pushInterval, func(ctx context.Context) error { r := report.BuildReport(...) return pusher.Push(r) }) } // ... then in startup goroutine: if pusher != nil { // use same pusher } ``` --- ## 1. Storage Initialization Feature ### 1.1 Concept The controller UI provides a guided wizard to: 1. **Scan** for block devices that are not mounted (like `sdb` in the demo system) 2. **Show** disk info: size, model, existing partitions/filesystems 3. **Format** with ext4 after explicit confirmation with safety warnings 4. **Mount** at a user-specified path under `/mnt/` 5. **Register** automatically as a storage path in settings.json This replaces the need to SSH in and run `hdd-setup.sh` manually. The existing `scripts/hdd-setup.sh` in the repo provides the proven logic — the controller wraps this as a web-based flow. ### 1.2 Architecture: Go package, not bash wrapper Implement in Go directly (not shelling out to `hdd-setup.sh`) for: - Structured error handling and progress feedback - Testability - No dependency on bash script being present in the container Use `exec.Command` for: `lsblk`, `blkid`, `mkfs.ext4`, `mount`, and fstab editing. ### 1.3 New package: `internal/storage/` ```go package storage // BlockDevice represents a detected physical disk. type BlockDevice struct { Name string // "sdb" Path string // "/dev/sdb" Size string // "931.5G" SizeBytes int64 Model string // "WD Elements 25A2" Serial string // "WX..." (if available) Type string // "disk" Removable bool // true for USB Partitions []Partition Mounted bool // any partition mounted } // Partition represents a partition on a block device. type Partition struct { Name string // "sdb1" Path string // "/dev/sdb1" Size string // "931.5G" FSType string // "ext4", "" (no filesystem) Label string // filesystem label UUID string MountPoint string // "" if not mounted } // ScanResult from disk detection. type ScanResult struct { AvailableDisks []BlockDevice // Unmounted disks/partitions SystemDisks []BlockDevice // Mounted/system disks (for display only, not selectable) } // FormatRequest parameters for formatting a disk. type FormatRequest struct { DevicePath string // "/dev/sdb" (whole disk) or "/dev/sdb1" (partition) MountName string // "hdd_1" → will mount at /mnt/hdd_1 Label string // Display label for storage path registry CreatePartition bool // If true, wipe and create single partition first } // FormatProgress tracks the formatting/mounting progress. type FormatProgress struct { Step string // "partitioning", "formatting", "mounting", "fstab", "registering", "done", "error" Message string // Human-readable status Error string // Error message if Step == "error" Percent int // 0-100 } ``` ### 1.4 Core functions ```go // ScanDisks detects all block devices and classifies them. func ScanDisks() (*ScanResult, error) { // Run: lsblk -J -o NAME,SIZE,TYPE,FSTYPE,MOUNTPOINT,MODEL,SERIAL,RM,PKNAME -b // Parse JSON output // Classify: system disk (has / or /boot mount) vs available // A disk is "available" if NO partition is mounted AND it's not the system disk } // FormatAndMount formats a partition and mounts it. // This is a long-running operation — use a channel for progress updates. func FormatAndMount(req FormatRequest, progress chan<- FormatProgress) error { // Step 1: Validate // - Device exists // - Not already mounted // - Not system disk (compare with / device) // - Mount name valid (alphanumeric + underscore, no spaces) // - Mount path doesn't already exist OR is empty dir // Step 2: Partition (if CreatePartition) // - sfdisk: create single Linux partition filling whole disk // - Wait for kernel to re-read partition table (partprobe) // - Update device path to new partition (e.g., /dev/sdb1) // Step 3: Format // - mkfs.ext4 -L