feat: deployed app removal + missing field injection (v0.19.0)
Add "Eltávolítás" to remove deployed (non-orphaned) stacks — reverts them to "Nincs telepítve" while preserving templates for redeploy. Modal offers HDD data and backup data cleanup choices. Auto-inject missing deploy fields (secrets, domains) into existing app.yaml when templates are updated via sync or on controller startup. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// DeleteResponse holds the result of a stack deletion.
|
||||
// DeleteResponse holds the result of a stack deletion (orphan delete).
|
||||
type DeleteResponse struct {
|
||||
Deleted string `json:"deleted"`
|
||||
VolumesRemoved []string `json:"volumes_removed"`
|
||||
@@ -19,6 +19,22 @@ type DeleteResponse struct {
|
||||
HDDPathsPreserved []string `json:"hdd_paths_preserved"`
|
||||
}
|
||||
|
||||
// RemoveResponse holds the result of removing a deployed (non-orphaned) stack.
|
||||
type RemoveResponse struct {
|
||||
Removed string `json:"removed"`
|
||||
VolumesRemoved []string `json:"volumes_removed"`
|
||||
HDDPathsRemoved []string `json:"hdd_paths_removed"`
|
||||
HDDPathsPreserved []string `json:"hdd_paths_preserved"`
|
||||
BackupPathsRemoved []string `json:"backup_paths_removed,omitempty"`
|
||||
}
|
||||
|
||||
// BackupDataResponse holds information about backup data associated with a stack.
|
||||
type BackupDataResponse struct {
|
||||
Stack string `json:"stack"`
|
||||
BackupPaths []HDDPath `json:"backup_paths"` // reuses HDDPath (path, size, exists)
|
||||
HasBackups bool `json:"has_backups"`
|
||||
}
|
||||
|
||||
// HDDDataResponse holds information about HDD data associated with a stack.
|
||||
type HDDDataResponse struct {
|
||||
Stack string `json:"stack"`
|
||||
@@ -199,6 +215,176 @@ func (m *Manager) GetStackHDDData(name string) (*HDDDataResponse, error) {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// RemoveStack removes a deployed (non-orphaned) stack: stops containers, removes
|
||||
// volumes, optionally removes HDD data and backup data, then removes app.yaml
|
||||
// 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) {
|
||||
// Safety: never remove protected stacks
|
||||
if m.cfg.IsProtectedStack(name) {
|
||||
return nil, fmt.Errorf("stack %q is protected and cannot be removed", name)
|
||||
}
|
||||
|
||||
stack, ok := m.GetStack(name)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("stack %q not found", name)
|
||||
}
|
||||
|
||||
// Must be deployed
|
||||
if !stack.Deployed {
|
||||
return nil, fmt.Errorf("stack %q is not deployed", name)
|
||||
}
|
||||
|
||||
// Must be stopped (not running)
|
||||
if stack.State == StateRunning || stack.State == StateStarting || stack.State == StateRestarting {
|
||||
return nil, fmt.Errorf("stack %q is still running — stop it first before removing", name)
|
||||
}
|
||||
|
||||
stackDir := filepath.Dir(stack.ComposePath)
|
||||
hddPath := m.cfg.Paths.HDDPath
|
||||
|
||||
m.logger.Printf("[INFO] Removing deployed stack: %s (removeHDDData=%v, backupPaths=%d)", name, removeHDDData, len(backupPathsToRemove))
|
||||
start := time.Now()
|
||||
|
||||
resp := &RemoveResponse{
|
||||
Removed: name,
|
||||
}
|
||||
|
||||
// Step 1: Parse compose file for HDD bind mounts
|
||||
hddMounts := ParseComposeHDDMounts(stack.ComposePath, hddPath)
|
||||
|
||||
// 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 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)
|
||||
}
|
||||
|
||||
// Step 3: Identify removed volumes from compose output
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.Contains(line, "Removing volume") || strings.Contains(line, "Volume") {
|
||||
resp.VolumesRemoved = append(resp.VolumesRemoved, line)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Handle HDD data
|
||||
protected := ProtectedHDDPaths(hddPath)
|
||||
for _, mount := range hddMounts {
|
||||
cleanPath := filepath.Clean(mount)
|
||||
if protected != nil && protected[cleanPath] {
|
||||
m.logger.Printf("[WARN] Refusing to delete protected HDD path: %s", cleanPath)
|
||||
continue
|
||||
}
|
||||
|
||||
if _, err := os.Stat(cleanPath); os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
|
||||
if removeHDDData {
|
||||
sizeHuman := getDirSizeHuman(cleanPath)
|
||||
if err := os.RemoveAll(cleanPath); err != nil {
|
||||
m.logger.Printf("[ERROR] Failed to remove HDD data %s: %v", cleanPath, err)
|
||||
} else {
|
||||
m.logger.Printf("[INFO] Removed HDD data: %s (%s)", cleanPath, sizeHuman)
|
||||
resp.HDDPathsRemoved = append(resp.HDDPathsRemoved, fmt.Sprintf("%s (%s)", cleanPath, sizeHuman))
|
||||
}
|
||||
} else {
|
||||
sizeHuman := getDirSizeHuman(cleanPath)
|
||||
resp.HDDPathsPreserved = append(resp.HDDPathsPreserved, fmt.Sprintf("%s (%s)", cleanPath, sizeHuman))
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: Handle backup data cleanup
|
||||
for _, bkPath := range backupPathsToRemove {
|
||||
cleanPath := filepath.Clean(bkPath)
|
||||
if _, err := os.Stat(cleanPath); os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
sizeHuman := getDirSizeHuman(cleanPath)
|
||||
if err := os.RemoveAll(cleanPath); err != nil {
|
||||
m.logger.Printf("[ERROR] Failed to remove backup data %s: %v", cleanPath, err)
|
||||
} else {
|
||||
m.logger.Printf("[INFO] Removed backup data: %s (%s)", cleanPath, sizeHuman)
|
||||
resp.BackupPathsRemoved = append(resp.BackupPathsRemoved, fmt.Sprintf("%s (%s)", cleanPath, sizeHuman))
|
||||
}
|
||||
}
|
||||
|
||||
// Step 6: Remove app.yaml only (keep template files for redeploy)
|
||||
appYAMLPath := filepath.Join(stackDir, "app.yaml")
|
||||
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)
|
||||
}
|
||||
|
||||
m.logger.Printf("[INFO] Stack %s removed successfully (took %.1fs)", name, time.Since(start).Seconds())
|
||||
|
||||
// Step 7: Update in-memory state and rescan
|
||||
m.mu.Lock()
|
||||
if s, ok := m.stacks[name]; ok {
|
||||
s.Deployed = false
|
||||
s.AppConfig = nil
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
if err := m.ScanStacks(); err != nil {
|
||||
m.logger.Printf("[WARN] Rescan after remove failed: %v", err)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// GetStackBackupData returns information about backup data for a stack.
|
||||
// drivePath is the app's home drive (HDD or system data path).
|
||||
func (m *Manager) GetStackBackupData(name string, drivePath string) (*BackupDataResponse, error) {
|
||||
_, ok := m.GetStack(name)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("stack %q not found", name)
|
||||
}
|
||||
|
||||
resp := &BackupDataResponse{
|
||||
Stack: name,
|
||||
}
|
||||
|
||||
if drivePath == "" {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// Check DB dump directory: <drive>/backups/primary/<stack>/db-dumps
|
||||
dbDumpPath := filepath.Join(drivePath, "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")
|
||||
resp.BackupPaths = append(resp.BackupPaths, buildPathInfo(rsyncPath))
|
||||
|
||||
for _, p := range resp.BackupPaths {
|
||||
if p.Exists {
|
||||
resp.HasBackups = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// buildPathInfo creates an HDDPath with size info for a given path.
|
||||
func buildPathInfo(path string) HDDPath {
|
||||
item := HDDPath{Path: path}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
item.Exists = false
|
||||
return item
|
||||
}
|
||||
item.Exists = true
|
||||
if info.IsDir() {
|
||||
item.SizeBytes = getDirSizeBytes(path)
|
||||
item.SizeHuman = getDirSizeHuman(path)
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
// ParseComposeHDDMounts reads a docker-compose.yml and extracts host paths
|
||||
// that reference the HDD path from volume bind mounts.
|
||||
func ParseComposeHDDMounts(composePath, hddPath string) []string {
|
||||
|
||||
Reference in New Issue
Block a user