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:
@@ -155,8 +155,15 @@ func main() {
|
|||||||
logger.Printf("[WARN] Initial stack scan failed: %v", err)
|
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 ---
|
// --- 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()
|
syncer.Start()
|
||||||
defer syncer.Stop()
|
defer syncer.Stop()
|
||||||
|
|
||||||
|
|||||||
@@ -111,6 +111,14 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
|||||||
case hasSuffix(path, "/hdd-data") && req.Method == http.MethodGet:
|
case hasSuffix(path, "/hdd-data") && req.Method == http.MethodGet:
|
||||||
r.getStackHDDData(w, req, extractName(path, "/hdd-data"))
|
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}
|
// DELETE /api/stacks/{name}
|
||||||
case strings.HasPrefix(path, "/stacks/") && req.Method == http.MethodDelete && !hasSubpath(path, "/stacks/"):
|
case strings.HasPrefix(path, "/stacks/") && req.Method == http.MethodDelete && !hasSubpath(path, "/stacks/"):
|
||||||
r.deleteStack(w, req, trimSegment(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})
|
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) {
|
func (r *Router) deleteStack(w http.ResponseWriter, req *http.Request, name string) {
|
||||||
limitBody(w, req)
|
limitBody(w, req)
|
||||||
r.logger.Printf("[API] Delete requested for stack: %s", name)
|
r.logger.Printf("[API] Delete requested for stack: %s", name)
|
||||||
|
|||||||
@@ -52,8 +52,8 @@ func (r *CrossDriveRunner) SetDBDumper(d DBDumper) {
|
|||||||
r.dbDumper = d
|
r.dbDumper = d
|
||||||
}
|
}
|
||||||
|
|
||||||
// getAppDrivePath returns the drive path for an app.
|
// GetAppDrivePath returns the drive path for an app (HDD path or system data path fallback).
|
||||||
func (r *CrossDriveRunner) getAppDrivePath(stackName string) string {
|
func (r *CrossDriveRunner) GetAppDrivePath(stackName string) string {
|
||||||
if hddPath := r.stackProvider.GetStackHDDPath(stackName); hddPath != "" {
|
if hddPath := r.stackProvider.GetStackHDDPath(stackName); hddPath != "" {
|
||||||
return 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.
|
// copyStackDBDumps copies DB dump files for the given stack from its home drive.
|
||||||
// DB dumps are at <drive>/backups/primary/<stack>/db-dumps/<stack>_<dbtype>.sql.
|
// DB dumps are at <drive>/backups/primary/<stack>/db-dumps/<stack>_<dbtype>.sql.
|
||||||
func (r *CrossDriveRunner) copyStackDBDumps(stackName, destDir string) error {
|
func (r *CrossDriveRunner) copyStackDBDumps(stackName, destDir string) error {
|
||||||
appDrive := r.getAppDrivePath(stackName)
|
appDrive := r.GetAppDrivePath(stackName)
|
||||||
dumpDir := AppDBDumpPath(appDrive, stackName)
|
dumpDir := AppDBDumpPath(appDrive, stackName)
|
||||||
|
|
||||||
entries, err := os.ReadDir(dumpDir)
|
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
|
// 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
|
var destPath string
|
||||||
for _, sp := range storagePaths {
|
for _, sp := range storagePaths {
|
||||||
if sp.Path != appDrive && !sp.Disconnected && !sp.Decommissioned {
|
if sp.Path != appDrive && !sp.Disconnected && !sp.Decommissioned {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DeleteResponse holds the result of a stack deletion.
|
// DeleteResponse holds the result of a stack deletion (orphan delete).
|
||||||
type DeleteResponse struct {
|
type DeleteResponse struct {
|
||||||
Deleted string `json:"deleted"`
|
Deleted string `json:"deleted"`
|
||||||
VolumesRemoved []string `json:"volumes_removed"`
|
VolumesRemoved []string `json:"volumes_removed"`
|
||||||
@@ -19,6 +19,22 @@ type DeleteResponse struct {
|
|||||||
HDDPathsPreserved []string `json:"hdd_paths_preserved"`
|
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.
|
// HDDDataResponse holds information about HDD data associated with a stack.
|
||||||
type HDDDataResponse struct {
|
type HDDDataResponse struct {
|
||||||
Stack string `json:"stack"`
|
Stack string `json:"stack"`
|
||||||
@@ -199,6 +215,176 @@ func (m *Manager) GetStackHDDData(name string) (*HDDDataResponse, error) {
|
|||||||
return resp, nil
|
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
|
// ParseComposeHDDMounts reads a docker-compose.yml and extracts host paths
|
||||||
// that reference the HDD path from volume bind mounts.
|
// that reference the HDD path from volume bind mounts.
|
||||||
func ParseComposeHDDMounts(composePath, hddPath string) []string {
|
func ParseComposeHDDMounts(composePath, hddPath string) []string {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package stacks
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/big"
|
"math/big"
|
||||||
@@ -424,6 +425,16 @@ func generateValue(spec string) (string, error) {
|
|||||||
return "", fmt.Errorf("reading random bytes: %w", err)
|
return "", fmt.Errorf("reading random bytes: %w", err)
|
||||||
}
|
}
|
||||||
return hex.EncodeToString(b), nil
|
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":
|
case "static":
|
||||||
return parts[1], nil
|
return parts[1], nil
|
||||||
default:
|
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) {
|
func randomAlphanumeric(length int) (string, error) {
|
||||||
result := make([]byte, length)
|
result := make([]byte, length)
|
||||||
for i := range result {
|
for i := range result {
|
||||||
|
|||||||
@@ -108,6 +108,19 @@ func detectComposeCommand() string {
|
|||||||
return ""
|
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.
|
// ScanStacks discovers all compose stacks in the stacks directory.
|
||||||
func (m *Manager) ScanStacks() error {
|
func (m *Manager) ScanStacks() error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ type Syncer struct {
|
|||||||
logger *log.Logger
|
logger *log.Logger
|
||||||
cacheDir string // local git clone
|
cacheDir string // local git clone
|
||||||
rescanFn func() error
|
rescanFn func() error
|
||||||
|
postSyncHook func(updated []string) // called after sync with names of updated stacks
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
lastSync time.Time
|
lastSync time.Time
|
||||||
lastErr error
|
lastErr error
|
||||||
@@ -46,13 +47,15 @@ type SyncResult struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Syncer. rescanFn is called after a successful sync to trigger ScanStacks().
|
// 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")
|
cacheDir := filepath.Join(cfg.Paths.DataDir, "catalog-cache")
|
||||||
return &Syncer{
|
return &Syncer{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
cacheDir: cacheDir,
|
cacheDir: cacheDir,
|
||||||
rescanFn: rescanFn,
|
rescanFn: rescanFn,
|
||||||
|
postSyncHook: postSyncHook,
|
||||||
stopCh: make(chan struct{}),
|
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
|
// Build message
|
||||||
parts := []string{}
|
parts := []string{}
|
||||||
if len(newApps) > 0 {
|
if len(newApps) > 0 {
|
||||||
|
|||||||
@@ -178,6 +178,7 @@
|
|||||||
<button class="btn btn-sm btn-danger" onclick="stackAction(event, '{{.Name}}', 'stop')">■</button>
|
<button class="btn btn-sm btn-danger" onclick="stackAction(event, '{{.Name}}', 'stop')">■</button>
|
||||||
{{else}}
|
{{else}}
|
||||||
<button class="btn btn-sm btn-success" onclick="stackAction(event, '{{.Name}}', 'start')">▶</button>
|
<button class="btn btn-sm btn-success" onclick="stackAction(event, '{{.Name}}', 'start')">▶</button>
|
||||||
|
{{if not .Orphaned}}<button class="btn btn-sm btn-danger" onclick="removeStack('{{.Name}}')">Eltávolítás</button>{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
<a href="/stacks/{{.Name}}/logs" class="btn btn-sm btn-outline">Napló</a>
|
<a href="/stacks/{{.Name}}/logs" class="btn btn-sm btn-outline">Napló</a>
|
||||||
{{if .Orphaned}}<button class="btn btn-sm btn-danger" onclick="deleteOrphanStack('{{.Name}}')">Törlés</button>{{end}}
|
{{if .Orphaned}}<button class="btn btn-sm btn-danger" onclick="deleteOrphanStack('{{.Name}}')">Törlés</button>{{end}}
|
||||||
|
|||||||
@@ -204,6 +204,121 @@
|
|||||||
btn.textContent = 'Törlés';
|
btn.textContent = 'Törlés';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
async function removeStack(name) {
|
||||||
|
var modal = document.createElement('div');
|
||||||
|
modal.className = 'modal-overlay';
|
||||||
|
modal.id = 'remove-modal';
|
||||||
|
modal.innerHTML = '<div class="modal-card"><h3>Betöltés...</h3></div>';
|
||||||
|
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 += '<div class="modal-section">' +
|
||||||
|
'<strong>Mindig törlődik:</strong>' +
|
||||||
|
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-secondary);font-size:.85rem">' +
|
||||||
|
'<li>Docker kötetek (adatbázis, alkalmazás konfiguráció)</li>' +
|
||||||
|
'<li>Telepítési konfiguráció (app.yaml)</li>' +
|
||||||
|
'<li>Másodlagos mentés ütemezése</li>' +
|
||||||
|
'</ul></div>';
|
||||||
|
// 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 += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||||
|
});
|
||||||
|
sections += '<div class="modal-section">' +
|
||||||
|
'<strong>Felhasználói adatok a merevlemezen:</strong>' + hddPaths +
|
||||||
|
'<label class="modal-checkbox"><input type="checkbox" id="remove-hdd-check"> Felhasználói adatok törlése</label>' +
|
||||||
|
'<div class="alert alert-info" style="margin-top:.5rem;font-size:.8rem" id="remove-hdd-keep-warning">' +
|
||||||
|
'Ha újratelepíti az alkalmazást, az adatokat újra importálnia kell, mivel az adatbázis törlődik. A megtartott adatok a továbbiakban NEM lesznek automatikusan mentve.' +
|
||||||
|
'</div></div>';
|
||||||
|
hddCheckbox = '<script>document.getElementById("remove-hdd-check").addEventListener("change",function(){document.getElementById("remove-hdd-keep-warning").style.display=this.checked?"none":"";});<\/script>';
|
||||||
|
}
|
||||||
|
// Section 3: Backup data
|
||||||
|
var backupCheckbox = '';
|
||||||
|
if (backupResp.ok && backupResp.data && backupResp.data.has_backups) {
|
||||||
|
var bkPaths = '';
|
||||||
|
backupResp.data.backup_paths.forEach(function(p) {
|
||||||
|
if (p.exists) bkPaths += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||||
|
});
|
||||||
|
if (bkPaths) {
|
||||||
|
sections += '<div class="modal-section">' +
|
||||||
|
'<strong>Mentési adatok:</strong>' + bkPaths +
|
||||||
|
'<label class="modal-checkbox"><input type="checkbox" id="remove-backup-check"> Mentési adatok törlése</label>' +
|
||||||
|
'<div class="alert alert-info" style="margin-top:.5rem;font-size:.8rem">' +
|
||||||
|
'Az éjszakai restic pillanatképek nem törölhetők egyenként — a megőrzési szabályok szerint automatikusan elavulnak.' +
|
||||||
|
'</div></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
modal.querySelector('.modal-card').innerHTML =
|
||||||
|
'<h3>Alkalmazás eltávolítása: ' + name + '</h3>' +
|
||||||
|
'<p style="color:var(--text-secondary);font-size:.9rem;margin-bottom:.75rem">Az alkalmazás visszaáll "Nincs telepítve" állapotba. A sablon megmarad, újratelepíthető.</p>' +
|
||||||
|
'<div class="alert alert-warning" style="margin-bottom:.75rem">Ez a művelet nem visszavonható!</div>' +
|
||||||
|
sections +
|
||||||
|
'<div class="modal-actions">' +
|
||||||
|
'<button class="btn btn-outline" onclick="closeRemoveModal()">Mégsem</button>' +
|
||||||
|
'<button class="btn btn-danger" id="confirm-remove-btn" onclick="confirmRemoveStack(\'' + name + '\')">Eltávolítás</button>' +
|
||||||
|
'</div>' + hddCheckbox;
|
||||||
|
} catch (err) {
|
||||||
|
modal.querySelector('.modal-card').innerHTML =
|
||||||
|
'<h3>Hiba</h3><p style="color:var(--text-secondary)">Nem sikerült lekérni az adatokat: ' + err.message + '</p>' +
|
||||||
|
'<div class="modal-actions"><button class="btn btn-outline" onclick="closeRemoveModal()">Bezárás</button></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function closeRemoveModal() {
|
||||||
|
var modal = document.getElementById('remove-modal');
|
||||||
|
if (modal) modal.remove();
|
||||||
|
}
|
||||||
|
async function confirmRemoveStack(name) {
|
||||||
|
var btn = document.getElementById('confirm-remove-btn');
|
||||||
|
var hddCheck = document.getElementById('remove-hdd-check');
|
||||||
|
var backupCheck = document.getElementById('remove-backup-check');
|
||||||
|
var removeHDD = hddCheck ? hddCheck.checked : false;
|
||||||
|
var removeBackups = backupCheck ? backupCheck.checked : false;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Eltávolítás folyamatban...';
|
||||||
|
try {
|
||||||
|
var resp = await fetch('/api/stacks/' + name + '/remove', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({remove_hdd_data: removeHDD, remove_backups: removeBackups})
|
||||||
|
});
|
||||||
|
var data = await resp.json();
|
||||||
|
if (data.ok) {
|
||||||
|
var modal = document.getElementById('remove-modal');
|
||||||
|
var removedInfo = '';
|
||||||
|
if (data.data && data.data.hdd_paths_removed && data.data.hdd_paths_removed.length > 0) {
|
||||||
|
removedInfo += '<p style="color:var(--text-secondary);font-size:.85rem;margin-top:.5rem">Törölt adatok: ' + data.data.hdd_paths_removed.join(', ') + '</p>';
|
||||||
|
}
|
||||||
|
if (data.data && data.data.backup_paths_removed && data.data.backup_paths_removed.length > 0) {
|
||||||
|
removedInfo += '<p style="color:var(--text-secondary);font-size:.85rem;margin-top:.5rem">Törölt mentések: ' + data.data.backup_paths_removed.join(', ') + '</p>';
|
||||||
|
}
|
||||||
|
var preservedInfo = '';
|
||||||
|
if (data.data && data.data.hdd_paths_preserved && data.data.hdd_paths_preserved.length > 0) {
|
||||||
|
preservedInfo = '<p style="color:var(--text-secondary);font-size:.85rem;margin-top:.5rem">Megőrzött adatok: ' + data.data.hdd_paths_preserved.join(', ') + '</p>';
|
||||||
|
}
|
||||||
|
modal.querySelector('.modal-card').innerHTML =
|
||||||
|
'<h3>Sikeresen eltávolítva!</h3>' +
|
||||||
|
'<p style="color:var(--text-secondary)">Az alkalmazás (' + name + ') eltávolítva. Újratelepíthető a Telepítés gombbal.</p>' +
|
||||||
|
removedInfo + preservedInfo +
|
||||||
|
'<div class="modal-actions"><button class="btn btn-primary" onclick="window.location.href=\'/stacks\'">Bezárás</button></div>';
|
||||||
|
} else {
|
||||||
|
alert('Hiba: ' + (data.error || 'Ismeretlen hiba'));
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Eltávolítás';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert('Hálózati hiba: ' + err.message);
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Eltávolítás';
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -76,6 +76,7 @@
|
|||||||
<button class="btn btn-danger" onclick="stackAction(event, '{{.Name}}', 'stop')">Leállítás</button>
|
<button class="btn btn-danger" onclick="stackAction(event, '{{.Name}}', 'stop')">Leállítás</button>
|
||||||
{{else}}
|
{{else}}
|
||||||
<button class="btn btn-success" onclick="stackAction(event, '{{.Name}}', 'start')">Indítás</button>
|
<button class="btn btn-success" onclick="stackAction(event, '{{.Name}}', 'start')">Indítás</button>
|
||||||
|
{{if not .Orphaned}}<button class="btn btn-danger" onclick="removeStack('{{.Name}}')">Eltávolítás</button>{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
<a href="/stacks/{{.Name}}/logs" class="btn btn-outline">Naplók</a>
|
<a href="/stacks/{{.Name}}/logs" class="btn btn-outline">Naplók</a>
|
||||||
{{if not .Orphaned}}<a href="{{appPageURL .Meta.Slug}}" class="btn btn-outline">Részletek</a>{{end}}
|
{{if not .Orphaned}}<a href="{{appPageURL .Meta.Slug}}" class="btn btn-outline">Részletek</a>{{end}}
|
||||||
|
|||||||
Reference in New Issue
Block a user