feat: comprehensive debug logging across all controller modules

Add detailed [DEBUG] logging to every controller module when
logging.level is set to "debug". Each module with stateful debug
uses SetDebug(bool) wired from main.go. Covers stacks, backup,
cloudflare, integrations, system, monitor, settings, scheduler,
web handlers, storage, metrics, API, selfupdate, and assets.

Also includes the app export/import (.fab bundles) feature from
v0.32.0 and its debug page integration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 18:14:43 +01:00
parent f6caea8067
commit 95c821deb2
54 changed files with 5015 additions and 82 deletions
+91
View File
@@ -71,6 +71,10 @@ func ProtectedHDDPaths(hddPath string) map[string]bool {
// DeleteStack removes an orphaned stack: stops containers, removes volumes,
// optionally removes HDD data, and deletes the stack directory.
func (m *Manager) DeleteStack(name string, removeHDDData bool) (*DeleteResponse, error) {
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] DeleteStack called: name=%q, removeHDDData=%v", name, removeHDDData)
}
// Safety: never delete protected stacks
if m.cfg.IsProtectedStack(name) {
return nil, fmt.Errorf("stack %q is protected and cannot be deleted", name)
@@ -81,6 +85,11 @@ func (m *Manager) DeleteStack(name string, removeHDDData bool) (*DeleteResponse,
return nil, fmt.Errorf("stack %q not found", name)
}
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] DeleteStack %s: state=%s, deployed=%v, orphaned=%v, deploying=%v",
name, stack.State, stack.Deployed, stack.Orphaned, stack.Deploying)
}
// Must be orphaned
if !stack.Orphaned {
return nil, fmt.Errorf("stack %q is not orphaned — only orphaned stacks can be deleted", name)
@@ -108,11 +117,20 @@ func (m *Manager) DeleteStack(name string, removeHDDData bool) (*DeleteResponse,
// Step 1: Parse compose file for HDD bind mounts
hddMounts := ParseComposeHDDMounts(stack.ComposePath, hddPath)
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] DeleteStack %s: found %d HDD mounts from compose file", name, len(hddMounts))
for i, mount := range hddMounts {
m.logger.Printf("[DEBUG] [stacks] DeleteStack %s: HDD mount[%d]=%s", name, i, mount)
}
}
// Step 2: Run docker compose down --rmi local --volumes
// H14: Return error if docker compose down fails — continuing would leave orphaned containers.
env := m.stackEnv(stackDir)
output, err := m.composeExecCustomEnv(stackDir, env, "down", "--rmi", "local", "--volumes")
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] DeleteStack %s: compose down output: %s", name, truncateStr(output, 500))
}
if err != nil {
m.logger.Printf("[ERROR] docker compose down for %s failed: %v (output: %s)", name, err, truncateStr(output, 200))
return resp, fmt.Errorf("docker compose down failed for %s: %w", name, err)
@@ -137,12 +155,18 @@ func (m *Manager) DeleteStack(name string, removeHDDData bool) (*DeleteResponse,
}
if _, err := os.Stat(cleanPath); os.IsNotExist(err) {
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] DeleteStack %s: HDD path does not exist, skipping: %s", name, cleanPath)
}
continue // path doesn't exist, nothing to do
}
if removeHDDData {
// Get size before removal
sizeHuman := getDirSizeHuman(cleanPath)
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] DeleteStack %s: removing HDD path %s (%s)", name, cleanPath, sizeHuman)
}
if err := os.RemoveAll(cleanPath); err != nil {
m.logger.Printf("[ERROR] Failed to remove HDD data %s: %v", cleanPath, err)
} else {
@@ -151,11 +175,17 @@ func (m *Manager) DeleteStack(name string, removeHDDData bool) (*DeleteResponse,
}
} else {
sizeHuman := getDirSizeHuman(cleanPath)
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] DeleteStack %s: preserving HDD path %s (%s)", name, cleanPath, sizeHuman)
}
resp.HDDPathsPreserved = append(resp.HDDPathsPreserved, fmt.Sprintf("%s (%s)", cleanPath, sizeHuman))
}
}
// Step 5: Remove stack directory
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] DeleteStack %s: removing stack directory %s", name, stackDir)
}
if err := os.RemoveAll(stackDir); err != nil {
m.logger.Printf("[ERROR] Failed to remove stack directory %s: %v", stackDir, err)
return resp, fmt.Errorf("failed to remove stack directory: %w", err)
@@ -188,12 +218,19 @@ func (m *Manager) GetStackHDDData(name string) (*HDDDataResponse, error) {
}
if hddPath == "" {
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] GetStackHDDData %s: no HDD path configured, returning empty", name)
}
return resp, nil
}
mounts := ParseComposeHDDMounts(stack.ComposePath, hddPath)
protected := ProtectedHDDPaths(hddPath)
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] GetStackHDDData %s: found %d raw HDD mounts from compose", name, len(mounts))
}
for _, mount := range mounts {
cleanPath := filepath.Clean(mount)
@@ -221,6 +258,14 @@ func (m *Manager) GetStackHDDData(name string) (*HDDDataResponse, error) {
}
resp.HasHDDData = len(resp.HDDPaths) > 0
if m.isDebug() {
for _, p := range resp.HDDPaths {
m.logger.Printf("[DEBUG] [stacks] GetStackHDDData %s: path=%s exists=%v size=%s", name, p.Path, p.Exists, p.SizeHuman)
}
m.logger.Printf("[DEBUG] [stacks] GetStackHDDData %s: hasHDDData=%v, %d paths returned", name, resp.HasHDDData, len(resp.HDDPaths))
}
return resp, nil
}
@@ -229,6 +274,10 @@ func (m *Manager) GetStackHDDData(name string) (*HDDDataResponse, error) {
// so the stack reverts to "not deployed" state. The template files (docker-compose.yml,
// .felhom.yml) are preserved so the user can redeploy.
func (m *Manager) RemoveStack(name string, removeHDDData bool, backupPathsToRemove []string) (*RemoveResponse, error) {
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] RemoveStack called: name=%q, removeHDDData=%v, backupPathsToRemove=%d", name, removeHDDData, len(backupPathsToRemove))
}
// Safety: never remove protected stacks
if m.cfg.IsProtectedStack(name) {
return nil, fmt.Errorf("stack %q is protected and cannot be removed", name)
@@ -239,6 +288,11 @@ func (m *Manager) RemoveStack(name string, removeHDDData bool, backupPathsToRemo
return nil, fmt.Errorf("stack %q not found", name)
}
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] RemoveStack %s: state=%s, deployed=%v, orphaned=%v, deploying=%v",
name, stack.State, stack.Deployed, stack.Orphaned, stack.Deploying)
}
// Must be deployed
if !stack.Deployed {
return nil, fmt.Errorf("stack %q is not deployed", name)
@@ -266,10 +320,19 @@ func (m *Manager) RemoveStack(name string, removeHDDData bool, backupPathsToRemo
// Step 1: Parse compose file for HDD bind mounts
hddMounts := ParseComposeHDDMounts(stack.ComposePath, hddPath)
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] RemoveStack %s: found %d HDD mounts from compose file", name, len(hddMounts))
for i, mount := range hddMounts {
m.logger.Printf("[DEBUG] [stacks] RemoveStack %s: HDD mount[%d]=%s", name, i, mount)
}
}
// Step 2: Run docker compose down --volumes (keep images for potential redeploy)
env := m.stackEnv(stackDir)
output, err := m.composeExecCustomEnv(stackDir, env, "down", "--volumes")
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] RemoveStack %s: compose down output: %s", name, truncateStr(output, 500))
}
if err != nil {
m.logger.Printf("[ERROR] docker compose down for %s failed: %v (output: %s)", name, err, truncateStr(output, 200))
return resp, fmt.Errorf("docker compose down failed for %s: %w", name, err)
@@ -293,11 +356,17 @@ func (m *Manager) RemoveStack(name string, removeHDDData bool, backupPathsToRemo
}
if _, err := os.Stat(cleanPath); os.IsNotExist(err) {
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] RemoveStack %s: HDD path does not exist, skipping: %s", name, cleanPath)
}
continue
}
if removeHDDData {
sizeHuman := getDirSizeHuman(cleanPath)
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] RemoveStack %s: removing HDD path %s (%s)", name, cleanPath, sizeHuman)
}
if err := os.RemoveAll(cleanPath); err != nil {
m.logger.Printf("[ERROR] Failed to remove HDD data %s: %v", cleanPath, err)
} else {
@@ -306,12 +375,18 @@ func (m *Manager) RemoveStack(name string, removeHDDData bool, backupPathsToRemo
}
} else {
sizeHuman := getDirSizeHuman(cleanPath)
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] RemoveStack %s: preserving HDD path %s (%s)", name, cleanPath, sizeHuman)
}
resp.HDDPathsPreserved = append(resp.HDDPathsPreserved, fmt.Sprintf("%s (%s)", cleanPath, sizeHuman))
}
}
// Step 5: Handle backup data cleanup
backupsBase := filepath.Join(hddPath, felhomDataDir, "backups")
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] RemoveStack %s: processing %d backup paths for removal (base=%s)", name, len(backupPathsToRemove), backupsBase)
}
for _, bkPath := range backupPathsToRemove {
cleanPath := filepath.Clean(bkPath)
// Validate path is under the expected backups directory
@@ -333,6 +408,9 @@ func (m *Manager) RemoveStack(name string, removeHDDData bool, backupPathsToRemo
// Step 6: Remove app.yaml only (keep template files for redeploy)
appYAMLPath := filepath.Join(stackDir, "app.yaml")
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] RemoveStack %s: removing app.yaml at %s", name, appYAMLPath)
}
if err := os.Remove(appYAMLPath); err != nil && !os.IsNotExist(err) {
m.logger.Printf("[ERROR] Failed to remove %s: %v", appYAMLPath, err)
return resp, fmt.Errorf("failed to remove app.yaml: %w", err)
@@ -368,6 +446,9 @@ func (m *Manager) GetStackBackupData(name string, drivePath string) (*BackupData
}
if drivePath == "" {
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] GetStackBackupData %s: no drive path provided, returning empty", name)
}
return resp, nil
}
@@ -379,6 +460,12 @@ func (m *Manager) GetStackBackupData(name string, drivePath string) (*BackupData
rsyncPath := filepath.Join(drivePath, felhomDataDir, "backups", "secondary", name, "rsync")
resp.BackupPaths = append(resp.BackupPaths, buildPathInfo(rsyncPath))
if m.isDebug() {
for _, p := range resp.BackupPaths {
m.logger.Printf("[DEBUG] [stacks] GetStackBackupData %s: checked path=%s exists=%v size=%s", name, p.Path, p.Exists, p.SizeHuman)
}
}
for _, p := range resp.BackupPaths {
if p.Exists {
resp.HasBackups = true
@@ -386,6 +473,10 @@ func (m *Manager) GetStackBackupData(name string, drivePath string) (*BackupData
}
}
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] GetStackBackupData %s: hasBackups=%v", name, resp.HasBackups)
}
return resp, nil
}