diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index 4d62162..8ae5c46 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -155,8 +155,15 @@ func main() { logger.Printf("[WARN] Initial stack scan failed: %v", err) } + // Inject missing deploy fields for all deployed stacks on startup + if names := stackMgr.DeployedStackNames(); len(names) > 0 { + stackMgr.InjectMissingFields(names) + } + // --- Initialize catalog syncer --- - syncer := catalogsync.New(cfg, logger, stackMgr.ScanStacks) + syncer := catalogsync.New(cfg, logger, stackMgr.ScanStacks, func(updated []string) { + stackMgr.InjectMissingFields(updated) + }) syncer.Start() defer syncer.Stop() diff --git a/controller/internal/api/router.go b/controller/internal/api/router.go index cc603fc..f2ef844 100644 --- a/controller/internal/api/router.go +++ b/controller/internal/api/router.go @@ -111,6 +111,14 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { case hasSuffix(path, "/hdd-data") && req.Method == http.MethodGet: r.getStackHDDData(w, req, extractName(path, "/hdd-data")) + // GET /api/stacks/{name}/backup-data + case hasSuffix(path, "/backup-data") && req.Method == http.MethodGet: + r.getStackBackupData(w, req, extractName(path, "/backup-data")) + + // POST /api/stacks/{name}/remove — remove a deployed (non-orphaned) stack + case hasSuffix(path, "/remove") && req.Method == http.MethodPost: + r.removeStack(w, req, extractName(path, "/remove")) + // DELETE /api/stacks/{name} case strings.HasPrefix(path, "/stacks/") && req.Method == http.MethodDelete && !hasSubpath(path, "/stacks/"): r.deleteStack(w, req, trimSegment(path, "/stacks/")) @@ -344,6 +352,82 @@ func (r *Router) getStackHDDData(w http.ResponseWriter, _ *http.Request, name st writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: resp}) } +func (r *Router) getStackBackupData(w http.ResponseWriter, _ *http.Request, name string) { + if name == "" { + writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid stack name"}) + return + } + + // Compute the drive path for this stack (HDD or system data path) + var drivePath string + if r.crossDriveRunner != nil { + drivePath = r.crossDriveRunner.GetAppDrivePath(name) + } + + resp, err := r.stackMgr.GetStackBackupData(name, drivePath) + if err != nil { + writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: err.Error()}) + return + } + writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: resp}) +} + +func (r *Router) removeStack(w http.ResponseWriter, req *http.Request, name string) { + if name == "" { + writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid stack name"}) + return + } + limitBody(w, req) + r.logger.Printf("[API] Remove requested for stack: %s", name) + + var body struct { + RemoveHDDData bool `json:"remove_hdd_data"` + RemoveBackups bool `json:"remove_backups"` + } + if err := json.NewDecoder(req.Body).Decode(&body); err != nil { + body.RemoveHDDData = false + body.RemoveBackups = false + } + + // Compute backup paths to remove if requested + var backupPaths []string + if body.RemoveBackups && r.crossDriveRunner != nil { + drivePath := r.crossDriveRunner.GetAppDrivePath(name) + if drivePath != "" { + backupPaths = append(backupPaths, + backup.AppDBDumpPath(drivePath, name), + backup.AppSecondaryRsyncPath(drivePath, name), + ) + } + } + + resp, err := r.stackMgr.RemoveStack(name, body.RemoveHDDData, backupPaths) + if err != nil { + r.logger.Printf("[API] Remove failed for %s: %v", name, err) + status := http.StatusInternalServerError + if strings.Contains(err.Error(), "protected") { + status = http.StatusForbidden + } + if strings.Contains(err.Error(), "not found") { + status = http.StatusNotFound + } + if strings.Contains(err.Error(), "not deployed") || strings.Contains(err.Error(), "still running") { + status = http.StatusConflict + } + writeJSON(w, status, apiResponse{OK: false, Error: err.Error()}) + return + } + + // Clean up cross-drive backup config for this stack + if r.sett != nil { + if err := r.sett.SetCrossDriveConfig(name, nil); err != nil { + r.logger.Printf("[WARN] Failed to clean cross-drive config for %s: %v", name, err) + } + } + + writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: resp, Message: "Stack " + name + " removed"}) +} + func (r *Router) deleteStack(w http.ResponseWriter, req *http.Request, name string) { limitBody(w, req) r.logger.Printf("[API] Delete requested for stack: %s", name) diff --git a/controller/internal/backup/crossdrive.go b/controller/internal/backup/crossdrive.go index 6b9c619..ecf79b1 100644 --- a/controller/internal/backup/crossdrive.go +++ b/controller/internal/backup/crossdrive.go @@ -52,8 +52,8 @@ func (r *CrossDriveRunner) SetDBDumper(d DBDumper) { r.dbDumper = d } -// getAppDrivePath returns the drive path for an app. -func (r *CrossDriveRunner) getAppDrivePath(stackName string) string { +// GetAppDrivePath returns the drive path for an app (HDD path or system data path fallback). +func (r *CrossDriveRunner) GetAppDrivePath(stackName string) string { if hddPath := r.stackProvider.GetStackHDDPath(stackName); hddPath != "" { return hddPath } @@ -334,7 +334,7 @@ func (r *CrossDriveRunner) runRsyncBackup(ctx context.Context, stackName, destBa // copyStackDBDumps copies DB dump files for the given stack from its home drive. // DB dumps are at /backups/primary//db-dumps/_.sql. func (r *CrossDriveRunner) copyStackDBDumps(stackName, destDir string) error { - appDrive := r.getAppDrivePath(stackName) + appDrive := r.GetAppDrivePath(stackName) dumpDir := AppDBDumpPath(appDrive, stackName) entries, err := os.ReadDir(dumpDir) @@ -441,7 +441,7 @@ func (r *CrossDriveRunner) AutoEnableSmallApps() { } // Find destination: first active storage path that differs from the app's home drive - appDrive := r.getAppDrivePath(stack.Name) + appDrive := r.GetAppDrivePath(stack.Name) var destPath string for _, sp := range storagePaths { if sp.Path != appDrive && !sp.Disconnected && !sp.Decommissioned { diff --git a/controller/internal/stacks/delete.go b/controller/internal/stacks/delete.go index 7b85d95..f33ceaf 100644 --- a/controller/internal/stacks/delete.go +++ b/controller/internal/stacks/delete.go @@ -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: /backups/primary//db-dumps + dbDumpPath := filepath.Join(drivePath, "backups", "primary", name, "db-dumps") + resp.BackupPaths = append(resp.BackupPaths, buildPathInfo(dbDumpPath)) + + // Check cross-drive rsync directory: /backups/secondary//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 { diff --git a/controller/internal/stacks/deploy.go b/controller/internal/stacks/deploy.go index 87954ee..2c68071 100644 --- a/controller/internal/stacks/deploy.go +++ b/controller/internal/stacks/deploy.go @@ -2,6 +2,7 @@ package stacks import ( "crypto/rand" + "encoding/base64" "encoding/hex" "fmt" "math/big" @@ -424,6 +425,16 @@ func generateValue(spec string) (string, error) { return "", fmt.Errorf("reading random bytes: %w", err) } return hex.EncodeToString(b), nil + case "base64key": + byteLen := 0 + if _, err := fmt.Sscanf(parts[1], "%d", &byteLen); err != nil || byteLen <= 0 { + return "", fmt.Errorf("invalid base64key length: %q", parts[1]) + } + b := make([]byte, byteLen) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("reading random bytes: %w", err) + } + return "base64:" + base64.StdEncoding.EncodeToString(b), nil case "static": return parts[1], nil default: @@ -431,6 +442,77 @@ func generateValue(spec string) (string, error) { } } +// InjectMissingFields checks deployed stacks for new deploy_fields that are not +// yet in app.yaml and auto-generates values for secret/domain fields. +// Called after sync (for updated stacks) and on startup (for all deployed stacks). +func (m *Manager) InjectMissingFields(stackNames []string) { + for _, name := range stackNames { + stack, ok := m.GetStack(name) + if !ok { + continue + } + + stackDir := filepath.Dir(stack.ComposePath) + meta := LoadMetadata(stackDir) + appCfg := LoadAppConfig(stackDir) + if appCfg == nil || !appCfg.Deployed { + continue + } + + var injected []string + for _, field := range meta.DeployFields { + if _, exists := appCfg.Env[field.EnvVar]; exists { + continue // already present + } + + switch field.Type { + case "secret": + if field.Generate == "" { + m.logger.Printf("[WARN] Stack %s: new secret field %s has no generator — skipping", name, field.EnvVar) + continue + } + value, err := generateValue(field.Generate) + if err != nil { + m.logger.Printf("[ERROR] Stack %s: failed to generate %s: %v", name, field.EnvVar, err) + continue + } + appCfg.Env[field.EnvVar] = value + if field.LockedAfterDeploy { + appCfg.LockedFields = append(appCfg.LockedFields, field.EnvVar) + } + injected = append(injected, field.EnvVar) + + case "domain": + appCfg.Env[field.EnvVar] = m.cfg.Customer.Domain + if field.LockedAfterDeploy && !containsStr(appCfg.LockedFields, field.EnvVar) { + appCfg.LockedFields = append(appCfg.LockedFields, field.EnvVar) + } + injected = append(injected, field.EnvVar) + + default: + m.logger.Printf("[WARN] Stack %s: new field %s (type=%s) requires manual configuration", name, field.EnvVar, field.Type) + } + } + + if len(injected) > 0 { + if err := SaveAppConfig(stackDir, appCfg); err != nil { + m.logger.Printf("[ERROR] Stack %s: failed to save app.yaml after injection: %v", name, err) + continue + } + m.logger.Printf("[SYNC] Stack %s: injected missing fields: %s", name, strings.Join(injected, ", ")) + } + } +} + +func containsStr(slice []string, s string) bool { + for _, v := range slice { + if v == s { + return true + } + } + return false +} + func randomAlphanumeric(length int) (string, error) { result := make([]byte, length) for i := range result { diff --git a/controller/internal/stacks/manager.go b/controller/internal/stacks/manager.go index cd5a030..2184652 100644 --- a/controller/internal/stacks/manager.go +++ b/controller/internal/stacks/manager.go @@ -108,6 +108,19 @@ func detectComposeCommand() string { return "" } +// DeployedStackNames returns the names of all deployed stacks. +func (m *Manager) DeployedStackNames() []string { + m.mu.RLock() + defer m.mu.RUnlock() + var names []string + for name, stack := range m.stacks { + if stack.Deployed { + names = append(names, name) + } + } + return names +} + // ScanStacks discovers all compose stacks in the stacks directory. func (m *Manager) ScanStacks() error { m.mu.Lock() diff --git a/controller/internal/sync/sync.go b/controller/internal/sync/sync.go index 5842dbe..a1ba54b 100644 --- a/controller/internal/sync/sync.go +++ b/controller/internal/sync/sync.go @@ -18,15 +18,16 @@ import ( // Syncer handles periodic git sync of the app catalog to the local stacks directory. type Syncer struct { - cfg *config.Config - logger *log.Logger - cacheDir string // local git clone - rescanFn func() error - mu sync.Mutex - lastSync time.Time - lastErr error - syncing bool - stopCh chan struct{} + cfg *config.Config + logger *log.Logger + cacheDir string // local git clone + rescanFn func() error + postSyncHook func(updated []string) // called after sync with names of updated stacks + mu sync.Mutex + lastSync time.Time + lastErr error + syncing bool + stopCh chan struct{} } // SyncStatus holds information about the last sync operation. @@ -46,14 +47,16 @@ type SyncResult struct { } // New creates a new Syncer. rescanFn is called after a successful sync to trigger ScanStacks(). -func New(cfg *config.Config, logger *log.Logger, rescanFn func() error) *Syncer { +// postSyncHook is called with names of updated stacks (may be nil). +func New(cfg *config.Config, logger *log.Logger, rescanFn func() error, postSyncHook func([]string)) *Syncer { cacheDir := filepath.Join(cfg.Paths.DataDir, "catalog-cache") return &Syncer{ - cfg: cfg, - logger: logger, - cacheDir: cacheDir, - rescanFn: rescanFn, - stopCh: make(chan struct{}), + cfg: cfg, + logger: logger, + cacheDir: cacheDir, + rescanFn: rescanFn, + postSyncHook: postSyncHook, + stopCh: make(chan struct{}), } } @@ -187,6 +190,11 @@ func (s *Syncer) doSync() SyncResult { } } + // Step 4: Inject missing deploy fields for updated stacks + if len(updated) > 0 && s.postSyncHook != nil { + s.postSyncHook(updated) + } + // Build message parts := []string{} if len(newApps) > 0 { diff --git a/controller/internal/web/templates/dashboard.html b/controller/internal/web/templates/dashboard.html index acc8001..667fcb9 100644 --- a/controller/internal/web/templates/dashboard.html +++ b/controller/internal/web/templates/dashboard.html @@ -178,6 +178,7 @@ {{else}} + {{if not .Orphaned}}{{end}} {{end}} Napló {{if .Orphaned}}{{end}} diff --git a/controller/internal/web/templates/layout.html b/controller/internal/web/templates/layout.html index 06aadcc..9809b30 100644 --- a/controller/internal/web/templates/layout.html +++ b/controller/internal/web/templates/layout.html @@ -204,6 +204,121 @@ btn.textContent = 'Törlés'; } } + async function removeStack(name) { + var modal = document.createElement('div'); + modal.className = 'modal-overlay'; + modal.id = 'remove-modal'; + modal.innerHTML = ''; + modal.addEventListener('click', function(e) { if (e.target === modal) closeRemoveModal(); }); + document.body.appendChild(modal); + try { + var [hddResp, backupResp] = await Promise.all([ + fetch('/api/stacks/' + name + '/hdd-data').then(function(r) { return r.json(); }), + fetch('/api/stacks/' + name + '/backup-data').then(function(r) { return r.json(); }) + ]); + var sections = ''; + // Section 1: Always removed + sections += ''; + // Section 2: HDD data + var hddCheckbox = ''; + if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + var hddPaths = ''; + hddResp.data.hdd_paths.forEach(function(p) { + hddPaths += ''; + }); + sections += ''; + hddCheckbox = ' diff --git a/controller/internal/web/templates/stacks.html b/controller/internal/web/templates/stacks.html index adf6429..e398fe0 100644 --- a/controller/internal/web/templates/stacks.html +++ b/controller/internal/web/templates/stacks.html @@ -76,6 +76,7 @@ {{else}} + {{if not .Orphaned}}{{end}} {{end}} Naplók {{if not .Orphaned}}Részletek{{end}}