v0.26.0: Storage namespace felhom-data/ + test node wipe script

All felhom-managed data on external drives now lives under felhom-data/
subdirectory, cleanly separating controller data from user files.

- backup/paths.go: add FelhomDataDir constant, update 8 path helpers
- stacks/delete.go: add local felhomDataDir constant (circular import
  boundary), update ProtectedHDDPaths + GetStackBackupData
- storage/migrate_drive.go: import backup pkg, fix conflict check, verify,
  rsync excludes (felhom-data/backups/*/restic/), size estimation
- storage/migrate.go: import backup pkg, fix DB dump paths
- web/handlers.go: fix legacy 'storage' path -> backup.AppDataDir()
- storage/format_linux.go: create felhom-data/ instead of storage/
- storage/attach_linux.go: create felhom-data/ instead of storage/
- scripts/felhom-wipe.sh: new multi-level test node wipe script
  (soft/controller/full/nuclear)
- CHANGELOG.md, controller/README.md, scripts/README.md: updated docs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-22 10:10:51 +01:00
parent e238474b33
commit 7abd1c5954
12 changed files with 596 additions and 52 deletions
+45
View File
@@ -1,5 +1,50 @@
## Changelog
### v0.26.0 — Storage Namespace `felhom-data/` + Test Node Wipe Script (2026-02-22)
All felhom-managed data on external drives now lives under a `felhom-data/` subdirectory, cleanly separating controller-managed data from user files. Plus a multi-level wipe script for repeatable test node cleanup.
**Key design principle:** `HDD_PATH` env var stays as the mount point (e.g., `/mnt/hdd_1`). The `felhom-data` segment is embedded in path helpers and compose templates — not in `HDD_PATH`.
#### Changed
- **`internal/backup/paths.go`** — Added `FelhomDataDir = "felhom-data"` constant. Updated 8 path functions to insert `felhom-data` between the drive root and data subdirectory:
- `PrimaryBackupPath``<drive>/felhom-data/backups/primary`
- `PrimaryResticRepoPath``<drive>/felhom-data/backups/primary/restic`
- `AppDBDumpPath``<drive>/felhom-data/backups/primary/<stack>/db-dumps`
- `SecondaryBackupPath``<drive>/felhom-data/backups/secondary`
- `AppSecondaryRsyncPath``<drive>/felhom-data/backups/secondary/<stack>/rsync`
- `SecondaryResticRepoPath``<drive>/felhom-data/backups/secondary/restic`
- `SecondaryInfraPath``<drive>/felhom-data/backups/secondary/_infra`
- `AppDataDir``<drive>/felhom-data/appdata/<stack>`
- `InfraBackupDir` **unchanged** — stays at drive root for DR scanner
- **`internal/stacks/delete.go`** — Added local `felhomDataDir = "felhom-data"` constant (cannot import `backup` due to architectural boundary). Updated `ProtectedHDDPaths()` to protect `<drive>/felhom-data`, `<drive>/felhom-data/appdata`, `<drive>/felhom-data/backups`. Fixed hardcoded paths in `GetStackBackupData()`.
- **`internal/storage/migrate_drive.go`** — Added `backup` package import. Fixed 4 issues:
- Conflict check: uses `backup.AppDataDir()` instead of hardcoded `appdata/`
- Verify step: uses `backup.AppDataDir()` instead of hardcoded `appdata/`
- rsync excludes: updated from `backups/primary/restic/` to `felhom-data/backups/primary/restic/`
- Size estimation: now scans inside `felhom-data/` namespace, skipping restic repos correctly
- **`internal/storage/migrate.go`** — Added `backup` package import. Post-migration DB dump copy now uses `backup.AppDBDumpPath()` instead of hardcoded paths.
- **`internal/web/handlers.go`** — Fixed legacy `"storage"` path in storage app detail size calculation (was dead code — path never existed); now uses `backup.AppDataDir()`.
- **`internal/storage/format_linux.go`** — Format wizard creates `felhom-data/` subdirectory instead of legacy `storage/`.
- **`internal/storage/attach_linux.go`** — Attach wizard creates `felhom-data/` subdirectory instead of legacy `storage/`.
#### Added
- **`scripts/felhom-wipe.sh`** — Test node cleanup script with 4 wipe levels:
- `soft` — Removes controller state files (settings.json, metrics.db, session/setup/update/snapshot state)
- `controller` — Soft + removes all app containers, volumes, and stack directories (skips protected stacks by default)
- `full` — Controller + removes `felhom-data/` on all storage drives (also removes old-style `appdata/` and `backups/` for migration compatibility); restarts controller
- `nuclear` — Full + removes controller.yaml, all infrastructure containers (controller, traefik, cloudflared, portainer), DR markers, and runs `docker system prune -af --volumes`
- Auto-detects paths from `controller.yaml` and `settings.json`
- Dry-run by default; requires `--yes` to execute
- Interactive confirmation prompt with `--yes` execution
#### Notes
- **Migration**: Pre-v0.26.0 restic snapshots reference old paths (without `felhom-data/`). Existing installations need data migration before upgrading.
- **App catalog**: Compose templates need separate update: `${HDD_PATH}/appdata/``${HDD_PATH}/felhom-data/appdata/` (tracked as separate task).
- All backup, crossdrive, and restore logic automatically picks up new paths via `paths.go` helpers — no changes needed in `backup.go`, `crossdrive.go`, or `restore.go`.
---
### v0.25.0 — Debug Page: Operator Testing & Diagnostics Dashboard (2026-02-21)
**Full debug dashboard with 8 sections for testing all controller subsystems in debug mode.**
+1 -1
View File
@@ -335,7 +335,7 @@ Must compile cleanly with no errors or warnings.
## Phase 7: Wipe Script
### File: `controller/scripts/felhom-wipe.sh`
### File: `scripts/felhom-wipe.sh`
Create the following script. It must be executable (`chmod +x`).
+31 -17
View File
@@ -4,7 +4,7 @@
A single, lightweight Go container that replaces Portainer + scattered systemd scripts with a unified, Hungarian-language web dashboard for managing Docker Compose stacks, backups, storage, monitoring, and notifications on customer hardware.
**Current version: v0.25.0**
**Current version: v0.26.0**
---
@@ -227,21 +227,35 @@ self-sufficient backup** — any single tier can fully restore an app.
The nightly backup has two phases that run sequentially. All paths are **per-drive** — each physical drive gets its own restic repo and per-app DB dump directories.
**Drive layout (v0.14.1):**
**Drive layout (v0.26.0):**
```
<drive>/
├── appdata/<app>/ ← app user data
└── backups/
└── primary/
├── restic/ ← one restic repo per drive (all apps on this drive)
└── <app>/db-dumps/ ← per-app DB dump files
├── felhom-data/ ← all controller-managed data (namespace, v0.26.0+)
│ ├── appdata/<app>/ ← app user data
└── backups/
├── primary/
│ │ ├── restic/ ← one restic repo per drive (all apps on this drive)
│ │ └── <app>/db-dumps/ ← per-app DB dump files
│ └── secondary/
│ ├── restic/ ← secondary restic repo (cross-drive)
│ ├── _infra/ ← infra config mirror
│ └── <app>/rsync/ ← per-app rsync data
├── .felhom-infra-backup/ ← DR marker (stays at drive root for scanner)
├── Dokumentumok/ ← user files (not controller-managed)
└── media/ ← user files (not controller-managed)
```
Path computation is centralized in `backup/paths.go`:
- `PrimaryResticRepoPath(drivePath)``<drive>/backups/primary/restic/`
- `AppDBDumpPath(drivePath, stackName)``<drive>/backups/primary/<stack>/db-dumps/`
- `AppDataDir(drivePath, stackName)``<drive>/appdata/<stack>/`
- `SecondaryInfraPath(drivePath)``<drive>/backups/secondary/_infra/`
> **Note:** `HDD_PATH` env var in `app.yaml` is still the mount point (e.g., `/mnt/hdd_1`). The `felhom-data` segment is embedded in path helpers — not in `HDD_PATH`.
> Pre-v0.26.0 installations use `<drive>/appdata/` and `<drive>/backups/` directly (no `felhom-data/` namespace).
Path computation is centralized in `backup/paths.go` via the `FelhomDataDir = "felhom-data"` constant:
- `PrimaryResticRepoPath(drivePath)``<drive>/felhom-data/backups/primary/restic/`
- `AppDBDumpPath(drivePath, stackName)``<drive>/felhom-data/backups/primary/<stack>/db-dumps/`
- `AppDataDir(drivePath, stackName)``<drive>/felhom-data/appdata/<stack>/`
- `SecondaryResticRepoPath(drivePath)``<drive>/felhom-data/backups/secondary/restic/`
- `AppSecondaryRsyncPath(drivePath, stackName)``<drive>/felhom-data/backups/secondary/<stack>/rsync/`
- `SecondaryInfraPath(drivePath)``<drive>/felhom-data/backups/secondary/_infra/`
- `InfraBackupDir(mountPath)``<drive>/.felhom-infra-backup/` (**unchanged** — stays at drive root for DR scanner)
**Phase 1 — Database Dumps** (`internal/backup/dbdump.go`, scheduled 02:30)
@@ -405,7 +419,7 @@ A step-by-step UI at `/settings/storage/init`:
1. **Scan** — Lists available disks with model, size, partition info
2. **Select** — User picks a disk and enters a mount name (e.g., `hdd_1`)
3. **Confirm** — User types "FORMAZAS" to confirm destructive operation
4. **Format pipeline**: `wipefs``sfdisk` (GPT) → `mkfs.ext4``blkid` UUID → backup fstab → append UUID-based fstab entry → mount → `findmnt` verification → `chown 1000:1000` → create `appdata/`, `backups/`, and `Dokumentumok/` subdirectories
4. **Format pipeline**: `wipefs``sfdisk` (GPT) → `mkfs.ext4``blkid` UUID → backup fstab → append UUID-based fstab entry → mount → `findmnt` verification → `chown 1000:1000` → create `felhom-data/` and `Dokumentumok/` subdirectories
5. Auto-registers new storage path in settings.json
6. Smart partition detection: skips repartitioning for existing empty partitions
@@ -415,7 +429,7 @@ Safety guards: system disk detection, mount path conflict check, confirmation re
A step-by-step UI at `/settings/storage/attach` for drives that already have a filesystem (e.g., a previously used ext4 drive). Unlike the init wizard, this does **not** format the drive — existing data is preserved.
**Problem solved:** Mounting a whole drive at `/mnt/<name>` would mix existing user data with the controller's directory structure (`storage/`, `Dokumentumok/`, backup repos). The bind-mount approach isolates the controller's working directory from other data on the drive.
**Problem solved:** Mounting a whole drive at `/mnt/<name>` would mix existing user data with the controller's directory structure (`felhom-data/`, `Dokumentumok/`, etc.). The bind-mount approach isolates the controller's working directory from other data on the drive.
1. **Scan** — Lists available disks, filtered to partitions that have an existing filesystem (FSType != "")
2. **Mount raw** — Partition is mounted read-only at a hidden staging path (`/mnt/.felhom-raw/<label>`)
@@ -424,7 +438,7 @@ A step-by-step UI at `/settings/storage/attach` for drives that already have a f
5. **Finalize** — Bind-mounts the selected subfolder at `/mnt/<name>`. Two fstab entries are created (both with `nofail`):
- Raw mount: `UUID=<uuid> /mnt/.felhom-raw/<x> <fstype> defaults,nofail,noatime 0 2`
- Bind mount: `/mnt/.felhom-raw/<x>/<subfolder> /mnt/<name> none bind,nofail 0 0`
6. Sets permissions (`chown 1000:1000`), creates `storage/` and `Dokumentumok/` subdirectories
6. Sets permissions (`chown 1000:1000`), creates `felhom-data/` and `Dokumentumok/` subdirectories
7. Auto-registers the storage path in settings.json + syncs FileBrowser mounts
Cancel at any point cleans up the temporary raw mount. The bind mount path (`/mnt/<name>`) is a real mount point, so all existing code (disk usage, IsMountPoint checks, etc.) works unchanged.
@@ -458,7 +472,7 @@ Progress UI at `/stacks/{name}/migrate` with byte counter and percentage.
After migration, the deploy page detects leftover data on previous storage paths:
- Shows path, size, and a delete button
- Two-step confirmation required
- Protected paths (appdata, backups, media, Dokumentumok) cannot be deleted
- Protected paths (`felhom-data/`, `felhom-data/appdata/`, `felhom-data/backups/`, `media/`, `Dokumentumok/`) cannot be deleted
#### FileBrowser Mount Sync
@@ -1044,7 +1058,7 @@ controller/
│ │ └── *_other.go # Non-Linux stubs for cross-compilation
│ ├── backup/
│ │ ├── backup.go # Orchestrator (per-drive dumps + restic + cross-drive chain)
│ │ ├── paths.go # Per-drive path helpers (PrimaryResticRepoPath, InfraBackupDir, etc.)
│ │ ├── paths.go # Per-drive path helpers (FelhomDataDir constant, PrimaryResticRepoPath, AppDataDir, InfraBackupDir, etc.)
│ │ ├── local_infra.go # Local infra backup to all drives (.felhom-infra-backup/)
│ │ ├── dbdump.go # DB auto-discovery + dump (pg_dump, mariadb-dump)
│ │ ├── restic.go # Restic operations (init, snapshot, prune, check) — repoPath as param
+11 -8
View File
@@ -2,44 +2,47 @@ package backup
import "path/filepath"
// FelhomDataDir is the namespace directory on storage drives for all felhom-managed data.
const FelhomDataDir = "felhom-data"
// PrimaryBackupPath returns the root primary backup directory for a drive.
func PrimaryBackupPath(drivePath string) string {
return filepath.Join(drivePath, "backups", "primary")
return filepath.Join(drivePath, FelhomDataDir, "backups", "primary")
}
// PrimaryResticRepoPath returns the restic repo path on a drive's primary backup.
func PrimaryResticRepoPath(drivePath string) string {
return filepath.Join(drivePath, "backups", "primary", "restic")
return filepath.Join(drivePath, FelhomDataDir, "backups", "primary", "restic")
}
// AppDBDumpPath returns the DB dump directory for an app on its home drive.
func AppDBDumpPath(drivePath, stackName string) string {
return filepath.Join(drivePath, "backups", "primary", stackName, "db-dumps")
return filepath.Join(drivePath, FelhomDataDir, "backups", "primary", stackName, "db-dumps")
}
// SecondaryBackupPath returns the root secondary backup directory for a drive.
func SecondaryBackupPath(drivePath string) string {
return filepath.Join(drivePath, "backups", "secondary")
return filepath.Join(drivePath, FelhomDataDir, "backups", "secondary")
}
// AppSecondaryRsyncPath returns the rsync destination for an app's secondary backup.
func AppSecondaryRsyncPath(drivePath, stackName string) string {
return filepath.Join(drivePath, "backups", "secondary", stackName, "rsync")
return filepath.Join(drivePath, FelhomDataDir, "backups", "secondary", stackName, "rsync")
}
// SecondaryResticRepoPath returns the restic repo path on a drive's secondary backup.
func SecondaryResticRepoPath(drivePath string) string {
return filepath.Join(drivePath, "backups", "secondary", "restic")
return filepath.Join(drivePath, FelhomDataDir, "backups", "secondary", "restic")
}
// SecondaryInfraPath returns the infrastructure config mirror directory on a drive's secondary backup.
func SecondaryInfraPath(drivePath string) string {
return filepath.Join(drivePath, "backups", "secondary", "_infra")
return filepath.Join(drivePath, FelhomDataDir, "backups", "secondary", "_infra")
}
// AppDataDir returns the app data directory path on a drive.
func AppDataDir(drivePath, stackName string) string {
return filepath.Join(drivePath, "appdata", stackName)
return filepath.Join(drivePath, FelhomDataDir, "appdata", stackName)
}
// InfraBackupDir returns the hidden infra backup directory on a drive.
+13 -9
View File
@@ -11,6 +11,9 @@ import (
"time"
)
// felhomDataDir matches backup.FelhomDataDir — duplicated to avoid circular import via StackDataProvider.
const felhomDataDir = "felhom-data"
// DeleteResponse holds the result of a stack deletion (orphan delete).
type DeleteResponse struct {
Deleted string `json:"deleted"`
@@ -56,11 +59,12 @@ func ProtectedHDDPaths(hddPath string) map[string]bool {
return nil
}
return map[string]bool{
hddPath: true,
filepath.Join(hddPath, "appdata"): true,
filepath.Join(hddPath, "backups"): true,
filepath.Join(hddPath, "media"): true,
filepath.Join(hddPath, "Dokumentumok"): true,
hddPath: true,
filepath.Join(hddPath, felhomDataDir): true,
filepath.Join(hddPath, felhomDataDir, "appdata"): true,
filepath.Join(hddPath, felhomDataDir, "backups"): true,
filepath.Join(hddPath, "media"): true,
filepath.Join(hddPath, "Dokumentumok"): true,
}
}
@@ -351,12 +355,12 @@ func (m *Manager) GetStackBackupData(name string, drivePath string) (*BackupData
return resp, nil
}
// Check DB dump directory: <drive>/backups/primary/<stack>/db-dumps
dbDumpPath := filepath.Join(drivePath, "backups", "primary", name, "db-dumps")
// Check DB dump directory: <drive>/felhom-data/backups/primary/<stack>/db-dumps
dbDumpPath := filepath.Join(drivePath, felhomDataDir, "backups", "primary", name, "db-dumps")
resp.BackupPaths = append(resp.BackupPaths, buildPathInfo(dbDumpPath))
// Check cross-drive rsync directory: <drive>/backups/secondary/<stack>/rsync
rsyncPath := filepath.Join(drivePath, "backups", "secondary", name, "rsync")
// Check cross-drive rsync directory: <drive>/felhom-data/backups/secondary/<stack>/rsync
rsyncPath := filepath.Join(drivePath, felhomDataDir, "backups", "secondary", name, "rsync")
resp.BackupPaths = append(resp.BackupPaths, buildPathInfo(rsyncPath))
for _, p := range resp.BackupPaths {
+1 -1
View File
@@ -310,7 +310,7 @@ func FinalizeAttach(req AttachRequest, progress chan<- FormatProgress) (string,
_ = exec.Command("chown", "1000:1000", mountPath).Run()
for _, subdir := range []string{"storage", "Dokumentumok"} {
for _, subdir := range []string{"felhom-data", "Dokumentumok"} {
dir := filepath.Join(mountPath, subdir)
if err := os.MkdirAll(dir, 0755); err == nil {
_ = exec.Command("chown", "1000:1000", dir).Run()
+1 -1
View File
@@ -208,7 +208,7 @@ func FormatAndMount(req FormatRequest, progress chan<- FormatProgress) (string,
_ = exec.Command("chown", "1000:1000", mountPath).Run()
for _, subdir := range []string{"storage", "Dokumentumok"} {
for _, subdir := range []string{"felhom-data", "Dokumentumok"} {
dir := filepath.Join(mountPath, subdir)
if err := os.MkdirAll(dir, 0755); err == nil {
_ = exec.Command("chown", "1000:1000", dir).Run()
+3 -2
View File
@@ -13,6 +13,7 @@ import (
"sync"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
@@ -394,8 +395,8 @@ func (o *MigrateOrchestrator) RunEnhancedMigration(
// --- Post-migration steps (all non-fatal) ---
// 1. Copy DB dumps from source to destination
srcDBDumps := filepath.Join(req.CurrentHDDPath, "backups", "primary", req.StackName, "db-dumps")
dstDBDumps := filepath.Join(req.TargetPath, "backups", "primary", req.StackName, "db-dumps")
srcDBDumps := backup.AppDBDumpPath(req.CurrentHDDPath, req.StackName)
dstDBDumps := backup.AppDBDumpPath(req.TargetPath, req.StackName)
if _, err := os.Stat(srcDBDumps); err == nil {
if err := os.MkdirAll(filepath.Dir(dstDBDumps), 0755); err != nil {
o.Logger.Printf("[WARN] Migration %s: failed to create DB dump dir: %v", req.StackName, err)
+25 -12
View File
@@ -12,6 +12,7 @@ import (
"sync"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
@@ -180,7 +181,7 @@ func (dm *DriveMigrator) MigrateDrive(ctx context.Context, req DriveMigrateReque
// Check for conflicts on destination
for _, app := range appsToMigrate {
destAppData := filepath.Join(req.DestPath, "appdata", app.Name)
destAppData := backup.AppDataDir(req.DestPath, app.Name)
if info, err := os.Stat(destAppData); err == nil && info.IsDir() {
entries, _ := os.ReadDir(destAppData)
if len(entries) > 0 {
@@ -192,18 +193,30 @@ func (dm *DriveMigrator) MigrateDrive(ctx context.Context, req DriveMigrateReque
}
}
// Estimate total size (exclude restic repos)
// Estimate total size (exclude restic repos inside felhom-data/backups/)
var totalBytes int64
entries, _ := os.ReadDir(req.SourcePath)
for _, entry := range entries {
if !entry.IsDir() {
continue
}
entryPath := filepath.Join(req.SourcePath, entry.Name())
if entry.IsDir() {
// Skip restic repos in size estimate
if entry.Name() == "backups" {
totalBytes += dirSizeExcluding(entryPath, "restic")
} else {
totalBytes += dirSize(entryPath)
if entry.Name() == backup.FelhomDataDir {
// Scan inside namespace dir, excluding restic repos from estimate
subEntries, _ := os.ReadDir(entryPath)
for _, sub := range subEntries {
if !sub.IsDir() {
continue
}
subPath := filepath.Join(entryPath, sub.Name())
if sub.Name() == "backups" {
totalBytes += dirSizeExcluding(subPath, "restic")
} else {
totalBytes += dirSize(subPath)
}
}
} else {
totalBytes += dirSize(entryPath)
}
}
@@ -252,8 +265,8 @@ func (dm *DriveMigrator) MigrateDrive(ctx context.Context, req DriveMigrateReque
send("copying", "Adatok másolása...", 10)
rsyncCmd := exec.CommandContext(ctx, "rsync", "-a", "--info=progress2",
"--exclude=backups/primary/restic/",
"--exclude=backups/secondary/restic/",
"--exclude=felhom-data/backups/primary/restic/",
"--exclude=felhom-data/backups/secondary/restic/",
req.SourcePath+"/", req.DestPath+"/",
)
@@ -322,11 +335,11 @@ func (dm *DriveMigrator) MigrateDrive(ctx context.Context, req DriveMigrateReque
send("verifying", "Másolat ellenőrzése...", 62)
for _, app := range appsToMigrate {
destAppData := filepath.Join(req.DestPath, "appdata", app.Name)
destAppData := backup.AppDataDir(req.DestPath, app.Name)
if _, err := os.Stat(destAppData); os.IsNotExist(err) {
// appdata might not exist for all apps (SSD-only apps that share the drive)
// Only warn, don't fail
dm.Logger.Printf("[WARN] Drive migration: %s/appdata/%s not found on destination (may be SSD-only)", req.DestPath, app.Name)
dm.Logger.Printf("[WARN] Drive migration: %s not found on destination (may be SSD-only)", destAppData)
}
}
+1 -1
View File
@@ -1220,7 +1220,7 @@ func (s *Server) appDetailsForPath(storagePath string) []StorageAppDetail {
Stack: stack.Meta.Slug,
}
// Try to get data size from the storage subdirectory
appDataDir := filepath.Join(storagePath, "storage", stack.Name)
appDataDir := backup.AppDataDir(storagePath, stack.Name)
if fi, err := os.Stat(appDataDir); err == nil && fi.IsDir() {
detail.SizeHuman = dirSizeHuman(appDataDir)
}
+62
View File
@@ -195,3 +195,65 @@ dprune='sudo docker system prune -af'
- **ctop** — Top-like interface for container metrics
- **lazydocker** — Terminal UI for Docker management
---
## felhom-wipe.sh
**Test node cleanup script with 4 wipe levels.**
Removes felhom-managed data from a node in a controlled, repeatable way. Designed for test/demo nodes to reset state between testing cycles.
### Quick start
```bash
# Preview what will be removed (dry run — default)
sudo ./felhom-wipe.sh --level full
# Execute the wipe
sudo ./felhom-wipe.sh --level full --yes
```
### Wipe levels
| Level | What it removes |
|-------|-----------------|
| `soft` | Controller state files only: `settings.json`, `metrics.db`, `setup-state.json`, `update-state.json`, `session-data.json`, `snapshot-history.json` |
| `controller` | Soft + all non-infra Docker containers, all Docker volumes (except `portainer_data`), all stack directories (skips protected stacks by default) |
| `full` | Controller + `felhom-data/` on all storage drives (appdata, backups). Also removes old-style `appdata/` and `backups/` directories for pre-v0.26.0 compatibility. Restarts controller after cleanup. |
| `nuclear` | Full + `controller.yaml`, all infra containers (controller, traefik, cloudflared, portainer), DR markers (`.felhom-infra-backup/` on all drives), `docker system prune -af --volumes` |
### CLI options
| Option | Description |
|--------|-------------|
| `--level <level>` | Required. One of: `soft`, `controller`, `full`, `nuclear` |
| `--yes` | Execute the wipe. Default is dry-run (preview only). |
| `--include-protected` | Also remove protected stacks (controller level only). |
### Path auto-detection
- Reads `stacks_dir` and `data_dir` from `/opt/docker/felhom-controller/controller.yaml` if present
- Reads registered storage paths from `settings.json`
- Also scans `/mnt/*/` for `felhom-data/` or legacy `appdata/` directories not in the registry
### What is preserved
- OS and system files
- Infrastructure containers and config (unless `nuclear`)
- User files: `Dokumentumok/`, `media/`, other non-felhom directories on drives
- DR markers on drives (unless `nuclear`)
### Safety
- Dry-run by default — shows plan without deleting anything
- Interactive `YES` confirmation prompt required even with `--yes`
- Must run as root (`sudo`)
- Checks Docker is running before proceeding
- Protected stacks skipped by default (use `--include-protected` to override)
### Redeploy after nuclear wipe
```bash
curl -fsSL https://gitea.dooplex.hu/admin/deploy-felhom-compose/raw/branch/main/scripts/docker-setup.sh | bash
```
+402
View File
@@ -0,0 +1,402 @@
#!/usr/bin/env bash
set -euo pipefail
# ===================================================================
# felhom-wipe.sh — Clean felhom data from a test node
# Usage: ./felhom-wipe.sh --level <soft|controller|full|nuclear> [--yes]
# ===================================================================
# --- Colors ---
RED='\033[0;31m'
YELLOW='\033[1;33m'
GREEN='\033[0;32m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
# --- Defaults ---
LEVEL=""
DRY_RUN=true
INCLUDE_PROTECTED=false
# --- Configuration (auto-detected) ---
CONTROLLER_YAML="/opt/docker/felhom-controller/controller.yaml"
DATA_DIR="/opt/docker/felhom-controller/data"
COMPOSE_DIR="/opt/docker/felhom-controller"
STACKS_DIR="/opt/docker/stacks"
SETTINGS_JSON="$DATA_DIR/settings.json"
# --- Helpers ---
die() { echo -e "${RED}ERROR: $1${NC}" >&2; exit 1; }
info() { echo -e "${GREEN}$1${NC}"; }
warn() { echo -e "${YELLOW}$1${NC}"; }
bold() { echo -e "${BOLD}$1${NC}"; }
human_size() {
local path="$1"
if [ -e "$path" ]; then
du -sh "$path" 2>/dev/null | cut -f1 || echo "?"
else
echo "n/a"
fi
}
usage() {
cat <<EOF
Usage: $(basename "$0") --level <level> [--yes] [--include-protected]
Levels:
soft Controller state only (settings.json, metrics.db, session data)
controller Soft + remove all app containers, volumes, stack dirs, app.yaml files
full Controller + felhom-data/ on all drives (appdata, backups)
nuclear Full + controller.yaml, controller container, Traefik, Portainer, all Docker data
Options:
--yes Execute the wipe (default: dry run)
--include-protected Also remove protected stacks (controller level only)
EOF
exit 1
}
# --- Parse Args ---
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--level) LEVEL="$2"; shift 2 ;;
--yes) DRY_RUN=false; shift ;;
--include-protected) INCLUDE_PROTECTED=true; shift ;;
-h|--help) usage ;;
*) die "Unknown argument: $1" ;;
esac
done
[[ -z "$LEVEL" ]] && usage
case "$LEVEL" in
soft|controller|full|nuclear) ;;
*) die "Invalid level: $LEVEL (must be soft|controller|full|nuclear)" ;;
esac
}
# --- Detect Paths ---
detect_paths() {
# Auto-detect from controller.yaml if present
if [ -f "$CONTROLLER_YAML" ]; then
local sd
sd=$(grep -oP 'stacks_dir:\s*\K\S+' "$CONTROLLER_YAML" 2>/dev/null || true)
[ -n "$sd" ] && STACKS_DIR="$sd"
local dd
dd=$(grep -oP 'data_dir:\s*\K\S+' "$CONTROLLER_YAML" 2>/dev/null || true)
[ -n "$dd" ] && DATA_DIR="$dd" && SETTINGS_JSON="$dd/settings.json"
fi
}
# --- Detect Storage Paths ---
declare -a STORAGE_PATHS=()
detect_storage_paths() {
# From settings.json
if [ -f "$SETTINGS_JSON" ]; then
while IFS= read -r p; do
[ -n "$p" ] && STORAGE_PATHS+=("$p")
done < <(python3 -c "
import json, sys
try:
d = json.load(open('$SETTINGS_JSON'))
for sp in d.get('storage_paths', []):
print(sp.get('path', ''))
except: pass
" 2>/dev/null || true)
fi
# Also scan /mnt/* for felhom-data dirs not in registry
for d in /mnt/*/; do
[ -d "${d}felhom-data" ] || [ -d "${d}appdata" ] || continue
local already=false
for sp in "${STORAGE_PATHS[@]:-}"; do
[ "$sp" = "${d%/}" ] && already=true && break
done
$already || STORAGE_PATHS+=("${d%/}")
done
}
# --- List App Containers (non-infra) ---
list_app_containers() {
docker ps -a --format '{{.Names}}' 2>/dev/null | grep -v -E '^(felhom-controller|traefik|cloudflared|portainer)$' || true
}
# --- List App Volumes (non-infra) ---
list_app_volumes() {
docker volume ls -q 2>/dev/null | grep -v -E '^(portainer_data)$' || true
}
# --- Protected Stacks ---
get_protected_stacks() {
if [ -f "$CONTROLLER_YAML" ]; then
grep -A 20 'protected_stacks:' "$CONTROLLER_YAML" 2>/dev/null | grep -oP '^\s*-\s*\K\S+' || true
fi
}
# --- Print Plan ---
print_plan() {
echo ""
bold "=== Felhom Wipe — Level: $LEVEL ==="
echo ""
# State files
echo -e "${CYAN}Controller state:${NC}"
local state_files=("$DATA_DIR/settings.json" "$DATA_DIR/metrics.db" "$DATA_DIR/setup-state.json" "$DATA_DIR/update-state.json" "$DATA_DIR/session-data.json" "$DATA_DIR/snapshot-history.json")
for f in "${state_files[@]}"; do
if [ -f "$f" ]; then
echo -e " ${YELLOW}DELETE${NC} $f ($(human_size "$f"))"
fi
done
if [[ "$LEVEL" == "controller" || "$LEVEL" == "full" || "$LEVEL" == "nuclear" ]]; then
echo ""
echo -e "${CYAN}Docker containers:${NC}"
local containers
containers=$(list_app_containers)
if [ -n "$containers" ]; then
echo "$containers" | while read -r c; do
echo -e " ${YELLOW}REMOVE${NC} $c"
done
else
echo -e " ${GREEN}(none)${NC}"
fi
echo ""
echo -e "${CYAN}Docker volumes:${NC}"
local volumes
volumes=$(list_app_volumes)
if [ -n "$volumes" ]; then
echo "$volumes" | while read -r v; do
echo -e " ${YELLOW}REMOVE${NC} $v"
done
else
echo -e " ${GREEN}(none)${NC}"
fi
echo ""
echo -e "${CYAN}Stack directories:${NC}"
if [ -d "$STACKS_DIR" ]; then
for sd in "$STACKS_DIR"/*/; do
[ -d "$sd" ] || continue
local stack_name
stack_name=$(basename "$sd")
local protected_stacks
protected_stacks=$(get_protected_stacks)
if echo "$protected_stacks" | grep -qx "$stack_name" && ! $INCLUDE_PROTECTED; then
echo -e " ${GREEN}KEEP${NC} $sd (protected)"
else
echo -e " ${YELLOW}DELETE${NC} $sd"
fi
done
else
echo -e " ${GREEN}(not found)${NC}"
fi
fi
if [[ "$LEVEL" == "full" || "$LEVEL" == "nuclear" ]]; then
echo ""
echo -e "${CYAN}Storage data:${NC}"
if [ ${#STORAGE_PATHS[@]} -gt 0 ]; then
for sp in "${STORAGE_PATHS[@]}"; do
if [ -d "$sp/felhom-data" ]; then
echo -e " ${YELLOW}DELETE${NC} $sp/felhom-data/ ($(human_size "$sp/felhom-data"))"
fi
# Old-style paths
if [ -d "$sp/appdata" ]; then
echo -e " ${YELLOW}DELETE${NC} $sp/appdata/ ($(human_size "$sp/appdata")) [old-style]"
fi
if [ -d "$sp/backups" ]; then
echo -e " ${YELLOW}DELETE${NC} $sp/backups/ ($(human_size "$sp/backups")) [old-style]"
fi
done
else
echo -e " ${GREEN}(no storage paths found)${NC}"
fi
fi
if [[ "$LEVEL" == "nuclear" ]]; then
echo ""
echo -e "${RED}Nuclear:${NC}"
echo -e " ${RED}DELETE${NC} controller.yaml"
echo -e " ${RED}DELETE${NC} controller container + image"
echo -e " ${RED}DELETE${NC} Traefik container"
echo -e " ${RED}DELETE${NC} Cloudflared container"
echo -e " ${RED}DELETE${NC} Portainer container + volume"
echo -e " ${RED}DELETE${NC} .felhom-infra-backup/ (DR markers on all drives)"
echo -e " ${RED}DELETE${NC} All Docker data (docker system prune -af --volumes)"
fi
echo ""
echo -e "${CYAN}Will preserve:${NC}"
echo -e " ${GREEN}- OS and system files${NC}"
if [[ "$LEVEL" != "nuclear" ]]; then
echo -e " ${GREEN}- Controller container (felhom-controller)${NC}"
echo -e " ${GREEN}- Controller image${NC}"
echo -e " ${GREEN}- Traefik, Cloudflare Tunnel${NC}"
echo -e " ${GREEN}- controller.yaml${NC}"
echo -e " ${GREEN}- .felhom-infra-backup/ (DR markers on drives)${NC}"
fi
if [[ "$LEVEL" != "full" && "$LEVEL" != "nuclear" ]]; then
echo -e " ${GREEN}- Storage data on drives${NC}"
fi
echo -e " ${GREEN}- User files (Dokumentumok, media, etc.)${NC}"
echo ""
}
# --- Wipe Functions ---
do_soft_wipe() {
info "Soft wipe: removing controller state..."
local state_files=("$DATA_DIR/settings.json" "$DATA_DIR/metrics.db" "$DATA_DIR/setup-state.json" "$DATA_DIR/update-state.json" "$DATA_DIR/session-data.json" "$DATA_DIR/snapshot-history.json")
for f in "${state_files[@]}"; do
[ -f "$f" ] && rm -f "$f" && info " Removed: $f"
done
}
do_controller_wipe() {
do_soft_wipe
info "Controller wipe: stopping and removing app containers..."
# Stop and remove app containers
local containers
containers=$(list_app_containers)
if [ -n "$containers" ]; then
echo "$containers" | while read -r c; do
docker rm -f "$c" 2>/dev/null && info " Removed container: $c" || warn " Failed to remove: $c"
done
fi
# Remove app volumes
info "Removing app volumes..."
local volumes
volumes=$(list_app_volumes)
if [ -n "$volumes" ]; then
echo "$volumes" | while read -r v; do
docker volume rm "$v" 2>/dev/null && info " Removed volume: $v" || warn " Failed to remove: $v"
done
fi
# Remove stack directories
info "Removing stack directories..."
if [ -d "$STACKS_DIR" ]; then
local protected_stacks
protected_stacks=$(get_protected_stacks)
for sd in "$STACKS_DIR"/*/; do
[ -d "$sd" ] || continue
local stack_name
stack_name=$(basename "$sd")
if echo "$protected_stacks" | grep -qx "$stack_name" && ! $INCLUDE_PROTECTED; then
warn " Skipping protected stack: $stack_name"
continue
fi
rm -rf "$sd" && info " Removed: $sd"
done
fi
# NOTE: No restart here — callers handle restart after all cleanup is done.
}
do_full_wipe() {
do_controller_wipe
info "Full wipe: removing storage data..."
for sp in "${STORAGE_PATHS[@]}"; do
# New-style namespace
if [ -d "$sp/felhom-data" ]; then
rm -rf "$sp/felhom-data" && info " Removed: $sp/felhom-data/"
fi
# Old-style paths
if [ -d "$sp/appdata" ]; then
rm -rf "$sp/appdata" && info " Removed: $sp/appdata/ [old-style]"
fi
if [ -d "$sp/backups" ]; then
rm -rf "$sp/backups" && info " Removed: $sp/backups/ [old-style]"
fi
done
# Restart controller after all cleanup is done
info "Restarting controller..."
docker restart felhom-controller 2>/dev/null || warn "Could not restart controller"
}
do_nuclear_wipe() {
do_full_wipe
info "Nuclear wipe: removing all infrastructure..."
# Stop infrastructure containers
for c in felhom-controller traefik cloudflared portainer; do
docker rm -f "$c" 2>/dev/null && info " Removed: $c" || true
done
# Remove controller.yaml
[ -f "$CONTROLLER_YAML" ] && rm -f "$CONTROLLER_YAML" && info " Removed: controller.yaml"
# Remove DR markers (nuclear = brand-new machine simulation)
for sp in "${STORAGE_PATHS[@]}"; do
if [ -d "$sp/.felhom-infra-backup" ]; then
rm -rf "$sp/.felhom-infra-backup" && info " Removed: $sp/.felhom-infra-backup/"
fi
done
# Remove all Docker data
warn "Pruning all Docker data..."
docker system prune -af --volumes 2>/dev/null || warn "Docker prune failed"
echo ""
info "Nuclear wipe complete."
echo -e "${CYAN}To redeploy, run:${NC}"
echo " curl -fsSL https://gitea.dooplex.hu/admin/deploy-felhom-compose/raw/branch/main/scripts/docker-setup.sh | bash"
}
# --- Main ---
main() {
# Must run as root
if [ "$(id -u)" -ne 0 ]; then
die "Must run as root (use sudo)"
fi
# Check Docker
if ! docker info >/dev/null 2>&1; then
die "Docker is not running"
fi
parse_args "$@"
detect_paths
detect_storage_paths
print_plan
if $DRY_RUN; then
warn "Dry run — nothing deleted. Use --yes to execute."
exit 0
fi
# Confirmation
echo -e "${RED}${BOLD}This will permanently delete the data listed above.${NC}"
read -rp "Type YES to confirm: " confirm
if [ "$confirm" != "YES" ]; then
echo "Aborted."
exit 1
fi
echo ""
case "$LEVEL" in
soft) do_soft_wipe ;;
controller) do_controller_wipe
info "Restarting controller..."
docker restart felhom-controller 2>/dev/null || warn "Could not restart controller"
;;
full) do_full_wipe ;;
nuclear) do_nuclear_wipe ;;
esac
echo ""
info "Wipe complete (level: $LEVEL)."
}
main "$@"